From 8c5035d67968b5fa080bf30d7ded79f28a978902 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Fri, 18 Jul 2025 17:51:59 +0530 Subject: [PATCH 01/65] feat(expense): add expense management screens and functionality --- .../assign_task_bottom_sheet .dart | 2 +- .../expense/add_expense_bottom_sheet.dart | 319 +++++++++++++ lib/routes.dart | 6 + .../Attendence/attendance_screen.dart | 10 +- lib/view/dashboard/dashboard_screen.dart | 7 +- lib/view/directory/directory_view.dart | 2 +- lib/view/expense/expense_detail_screen.dart | 241 ++++++++++ lib/view/expense/expense_screen.dart | 437 ++++++++++++++++++ 8 files changed, 1015 insertions(+), 9 deletions(-) create mode 100644 lib/model/expense/add_expense_bottom_sheet.dart create mode 100644 lib/view/expense/expense_detail_screen.dart create mode 100644 lib/view/expense/expense_screen.dart diff --git a/lib/model/dailyTaskPlaning/assign_task_bottom_sheet .dart b/lib/model/dailyTaskPlaning/assign_task_bottom_sheet .dart index a61dbac..2e52a8a 100644 --- a/lib/model/dailyTaskPlaning/assign_task_bottom_sheet .dart +++ b/lib/model/dailyTaskPlaning/assign_task_bottom_sheet .dart @@ -135,7 +135,7 @@ class _AssignTaskBottomSheetState extends State { children: [ MyText.titleMedium("Select Team :", fontWeight: 600), const SizedBox(width: 4), - Icon(Icons.filter_alt, + Icon(Icons.tune, color: const Color.fromARGB(255, 95, 132, 255)), ], ), diff --git a/lib/model/expense/add_expense_bottom_sheet.dart b/lib/model/expense/add_expense_bottom_sheet.dart new file mode 100644 index 0000000..3dfd6cb --- /dev/null +++ b/lib/model/expense/add_expense_bottom_sheet.dart @@ -0,0 +1,319 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; + +void showAddExpenseBottomSheet() { + final TextEditingController amountController = TextEditingController(); + final TextEditingController descriptionController = TextEditingController(); + final TextEditingController supplierController = TextEditingController(); + final TextEditingController transactionIdController = TextEditingController(); + final TextEditingController gstController = TextEditingController(); + + String selectedProject = "Select Project"; + String selectedCategory = "Select Expense Type"; + String selectedPaymentMode = "Select Payment Mode"; + String selectedLocation = "Select Location"; + bool preApproved = false; + + Get.bottomSheet( + StatefulBuilder( + builder: (context, setState) { + return SafeArea( + child: Padding( + padding: + const EdgeInsets.only(top: 60), + child: Material( + color: Colors.white, + borderRadius: + const BorderRadius.vertical(top: Radius.circular(20)), + child: Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height - 60, + ), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Drag Handle + Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.grey.shade400, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + + // Title + Center( + child: MyText.titleLarge( + "Add Expense", + fontWeight: 700, + ), + ), + const SizedBox(height: 20), + + // Project + _sectionTitle(Icons.work_outline, "Project"), + const SizedBox(height: 6), + _dropdownTile( + title: selectedProject, + onTap: () { + setState(() { + selectedProject = "Project A"; + }); + }, + ), + const SizedBox(height: 16), + + // Expense Type + GST + _sectionTitle( + Icons.category_outlined, "Expense Type & GST No."), + const SizedBox(height: 6), + _dropdownTile( + title: selectedCategory, + onTap: () { + setState(() { + selectedCategory = "Travel Expense"; + }); + }, + ), + const SizedBox(height: 8), + _customTextField( + controller: gstController, + hint: "Enter GST No.", + ), + const SizedBox(height: 16), + + // Payment Mode + _sectionTitle(Icons.payment, "Payment Mode"), + const SizedBox(height: 6), + _dropdownTile( + title: selectedPaymentMode, + onTap: () { + setState(() { + selectedPaymentMode = "UPI"; + }); + }, + ), + const SizedBox(height: 16), + + // Paid By + _sectionTitle(Icons.person, "Paid By (Employee)"), + const SizedBox(height: 6), + _dropdownTile( + title: "Self (Default)", + onTap: () {}, + ), + const SizedBox(height: 16), + + // Transaction Date + _sectionTitle(Icons.calendar_today, "Transaction Date"), + const SizedBox(height: 6), + _dropdownTile( + title: "Select Date & Time", + onTap: () async { + // Add date/time picker + }, + ), + const SizedBox(height: 16), + + // Amount + _sectionTitle(Icons.currency_rupee, "Amount"), + const SizedBox(height: 6), + _customTextField( + controller: amountController, + hint: "Enter Amount", + keyboardType: TextInputType.number, + ), + const SizedBox(height: 16), + + // Supplier + _sectionTitle(Icons.store_mall_directory, + "Supplier Name / Expense Done At"), + const SizedBox(height: 6), + _customTextField( + controller: supplierController, + hint: "Enter Supplier Name", + ), + const SizedBox(height: 16), + + // Location + _sectionTitle(Icons.location_on_outlined, "Location"), + const SizedBox(height: 6), + _dropdownTile( + title: selectedLocation, + onTap: () { + setState(() { + selectedLocation = "Pune"; + }); + }, + ), + const SizedBox(height: 16), + + // Description + _sectionTitle(Icons.description_outlined, "Description"), + const SizedBox(height: 6), + _customTextField( + controller: descriptionController, + hint: "Enter Description", + maxLines: 3, + ), + const SizedBox(height: 16), + + // Bill Attachment + _sectionTitle(Icons.attachment, "Bill Attachment"), + const SizedBox(height: 6), + OutlinedButton.icon( + onPressed: () {}, + icon: const Icon(Icons.upload_file), + label: const Text("Upload Bill"), + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + const SizedBox(height: 16), + + // Transaction ID + _sectionTitle( + Icons.confirmation_num_outlined, "Transaction ID"), + const SizedBox(height: 6), + _customTextField( + controller: transactionIdController, + hint: "Enter Transaction ID", + ), + const SizedBox(height: 16), + + // Pre-Approved Switch + Row( + children: [ + Switch( + value: preApproved, + onChanged: (val) => + setState(() => preApproved = val), + activeColor: Colors.red, + ), + const SizedBox(width: 8), + const Text("Pre-Approved?"), + ], + ), + const SizedBox(height: 24), + + // Action Buttons + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () => Get.back(), + icon: const Icon(Icons.close, size: 18), + label: + MyText.bodyMedium("Cancel", fontWeight: 600), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Colors.grey), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + onPressed: () { + // Handle Save + Get.back(); + }, + icon: const Icon(Icons.check, size: 18), + label: MyText.bodyMedium( + "Submit", + color: Colors.white, + fontWeight: 600, + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.indigo, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ); + }, + ), + isScrollControlled: true, + ); +} + +/// Section Title +Widget _sectionTitle(IconData icon, String title) { + return Row( + children: [ + Icon(icon, color: Colors.grey[700], size: 18), + const SizedBox(width: 8), + MyText.bodyMedium(title, fontWeight: 600), + ], + ); +} + +/// Custom TextField +Widget _customTextField({ + required TextEditingController controller, + required String hint, + int maxLines = 1, + TextInputType keyboardType = TextInputType.text, +}) { + return TextField( + controller: controller, + maxLines: maxLines, + keyboardType: keyboardType, + decoration: InputDecoration( + hintText: hint, + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + ), + ); +} + +/// Dropdown Tile +Widget _dropdownTile({required String title, required VoidCallback onTap}) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + title, + style: const TextStyle(fontSize: 14, color: Colors.black87), + overflow: TextOverflow.ellipsis, + ), + ), + const Icon(Icons.arrow_drop_down), + ], + ), + ), + ); +} diff --git a/lib/routes.dart b/lib/routes.dart index 391eeb9..4f27766 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -17,6 +17,7 @@ import 'package:marco/view/auth/login_option_screen.dart'; import 'package:marco/view/auth/mpin_screen.dart'; import 'package:marco/view/auth/mpin_auth_screen.dart'; import 'package:marco/view/directory/directory_main_screen.dart'; +import 'package:marco/view/expense/expense_screen.dart'; class AuthMiddleware extends GetMiddleware { @override @@ -65,6 +66,11 @@ getPageRoute() { name: '/dashboard/directory-main-page', page: () => DirectoryMainScreen(), middlewares: [AuthMiddleware()]), + // Expense + GetPage( + name: '/dashboard/expense-main-page', + page: () => ExpenseMainScreen(), + middlewares: [AuthMiddleware()]), // Authentication GetPage(name: '/auth/login', page: () => LoginScreen()), GetPage(name: '/auth/login-option', page: () => LoginOptionScreen()), diff --git a/lib/view/dashboard/Attendence/attendance_screen.dart b/lib/view/dashboard/Attendence/attendance_screen.dart index 12d8e34..1f019ef 100644 --- a/lib/view/dashboard/Attendence/attendance_screen.dart +++ b/lib/view/dashboard/Attendence/attendance_screen.dart @@ -173,7 +173,7 @@ class _AttendanceScreenState extends State with UIMixin { final selectedProjectId = Get.find() .selectedProjectId - ?.value; + .value; final selectedView = result['selectedTab'] as String?; @@ -208,9 +208,9 @@ class _AttendanceScreenState extends State with UIMixin { child: Padding( padding: const EdgeInsets.all(8.0), child: Icon( - Icons.filter_list_alt, + Icons.tune, color: Colors.blueAccent, - size: 28, + size: 20, ), ), ), @@ -225,7 +225,7 @@ class _AttendanceScreenState extends State with UIMixin { onTap: () async { final projectId = Get.find() .selectedProjectId - ?.value; + .value; if (projectId != null && projectId.isNotEmpty) { try { await attendanceController @@ -244,7 +244,7 @@ class _AttendanceScreenState extends State with UIMixin { child: Icon( Icons.refresh, color: Colors.green, - size: 28, + size: 22, ), ), ), diff --git a/lib/view/dashboard/dashboard_screen.dart b/lib/view/dashboard/dashboard_screen.dart index de064f4..93d65b3 100644 --- a/lib/view/dashboard/dashboard_screen.dart +++ b/lib/view/dashboard/dashboard_screen.dart @@ -23,9 +23,10 @@ class DashboardScreen extends StatefulWidget { static const String attendanceRoute = "/dashboard/attendance"; static const String tasksRoute = "/dashboard/daily-task"; static const String dailyTasksRoute = "/dashboard/daily-task-planing"; - static const String dailyTasksProgressRoute = - "/dashboard/daily-task-progress"; + static const String dailyTasksProgressRoute = "/dashboard/daily-task-progress"; static const String directoryMainPageRoute = "/dashboard/directory-main-page"; + static const String expenseMainPageRoute = "/dashboard/expense-main-page"; + @override State createState() => _DashboardScreenState(); @@ -157,6 +158,8 @@ class _DashboardScreenState extends State with UIMixin { DashboardScreen.dailyTasksProgressRoute), _StatItem(LucideIcons.folder, "Directory", contentTheme.info, DashboardScreen.directoryMainPageRoute), + _StatItem(LucideIcons.badge_dollar_sign, "Expense", contentTheme.info, + DashboardScreen.expenseMainPageRoute), ]; return GetBuilder( diff --git a/lib/view/directory/directory_view.dart b/lib/view/directory/directory_view.dart index 5e1bcd3..30c8caa 100644 --- a/lib/view/directory/directory_view.dart +++ b/lib/view/directory/directory_view.dart @@ -213,7 +213,7 @@ class _DirectoryViewState extends State { borderRadius: BorderRadius.circular(10), ), child: IconButton( - icon: Icon(Icons.filter_alt_outlined, + icon: Icon(Icons.tune, size: 20, color: isFilterActive ? Colors.indigo diff --git a/lib/view/expense/expense_detail_screen.dart b/lib/view/expense/expense_detail_screen.dart new file mode 100644 index 0000000..6ad1194 --- /dev/null +++ b/lib/view/expense/expense_detail_screen.dart @@ -0,0 +1,241 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/controller/project_controller.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; + +class ExpenseDetailScreen extends StatelessWidget { + const ExpenseDetailScreen({super.key}); + + Color _getStatusColor(String status) { + switch (status) { + case 'Request': + return Colors.blue; + case 'Review': + return Colors.orange; + case 'Approved': + return Colors.green; + case 'Paid': + return Colors.purple; + case 'Closed': + return Colors.grey; + default: + return Colors.black; + } + } + + @override + Widget build(BuildContext context) { + final Map expense = + Get.arguments['expense'] as Map; + final Color statusColor = _getStatusColor(expense['status']!); + final ProjectController projectController = Get.find(); + + return Scaffold( + backgroundColor: const Color(0xFFF7F7F7), + appBar: PreferredSize( + preferredSize: const Size.fromHeight(72), + child: AppBar( + backgroundColor: Colors.white, + elevation: 1, + automaticallyImplyLeading: false, + titleSpacing: 0, + title: Padding( + padding: MySpacing.xy(16, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + IconButton( + icon: const Icon(Icons.arrow_back_ios_new, + color: Colors.black, size: 20), + onPressed: () => Get.back(), + ), + MySpacing.width(8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + MyText.titleLarge( + 'Expense Details', + fontWeight: 700, + color: Colors.black, + ), + MySpacing.height(2), + Obx(() { + final projectName = + projectController.selectedProject?.name ?? + 'Select Project'; + return InkWell( + onTap: () => Get.toNamed('/project-selector'), + child: Row( + children: [ + const Icon(Icons.work_outline, + size: 14, color: Colors.grey), + MySpacing.width(4), + Expanded( + child: MyText.bodySmall( + projectName, + fontWeight: 600, + overflow: TextOverflow.ellipsis, + color: Colors.grey[700], + ), + ), + ], + ), + ); + }), + ], + ), + ), + ], + ), + ), + ), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Card( + elevation: 6, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header Section + Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [Color(0xFFFF4B2B), Color(0xFFFF416C)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + expense['title'] ?? 'N/A', + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + const SizedBox(height: 6), + Text( + expense['amount'] ?? '₹ 0', + style: const TextStyle( + fontSize: 26, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.15), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.flag, size: 16, color: statusColor), + const SizedBox(width: 6), + Text( + expense['status'] ?? 'N/A', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + ), + + // Details Section + Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _detailRow(Icons.calendar_today, "Date & Time", + expense['date'] ?? 'N/A'), + _detailRow(Icons.category_outlined, "Expense Type", + "${expense['category']} (GST: ${expense['gstNo'] ?? 'N/A'})"), + _detailRow(Icons.payment, "Payment Mode", + expense['paymentMode'] ?? 'N/A'), + _detailRow(Icons.person, "Paid By", + expense['paidBy'] ?? 'N/A'), + _detailRow(Icons.access_time, "Transaction Date", + expense['transactionDate'] ?? 'N/A'), + _detailRow(Icons.location_on_outlined, "Location", + expense['location'] ?? 'N/A'), + _detailRow(Icons.store, "Supplier Name", + expense['supplierName'] ?? 'N/A'), + _detailRow(Icons.confirmation_num_outlined, + "Transaction ID", expense['transactionId'] ?? 'N/A'), + _detailRow(Icons.description, "Description", + expense['description'] ?? 'N/A'), + ], + ), + ), + ], + ), + ), + ), + ); + } + + Widget _detailRow(IconData icon, String title, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, size: 20, color: Colors.grey[800]), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 13, + color: Colors.grey, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + Text( + value, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/view/expense/expense_screen.dart b/lib/view/expense/expense_screen.dart new file mode 100644 index 0000000..f2d0217 --- /dev/null +++ b/lib/view/expense/expense_screen.dart @@ -0,0 +1,437 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/controller/project_controller.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/model/expense/add_expense_bottom_sheet.dart'; +import 'package:marco/view/expense/expense_detail_screen.dart'; + +class ExpenseMainScreen extends StatefulWidget { + const ExpenseMainScreen({super.key}); + + @override + State createState() => _ExpenseMainScreenState(); +} + +class _ExpenseMainScreenState extends State { + final RxBool isHistoryView = false.obs; + final TextEditingController searchController = TextEditingController(); + final RxString searchQuery = ''.obs; + + final ProjectController projectController = Get.find(); + + final List> expenseList = [ + { + 'title': 'Travel Expense', + 'amount': '₹ 1,500', + 'status': 'Request', + 'date': '12 Jul 2025 • 3:45 PM', + 'category': 'Transport', + 'paymentMode': 'UPI', + 'transactionId': 'TXN123451' + }, + { + 'title': 'Hotel Stay', + 'amount': '₹ 4,500', + 'status': 'Approved', + 'date': '11 Jul 2025 • 9:30 AM', + 'category': 'Accommodation', + 'paymentMode': 'Credit Card', + 'transactionId': 'TXN123452' + }, + { + 'title': 'Food Bill', + 'amount': '₹ 1,200', + 'status': 'Paid', + 'date': '10 Jul 2025 • 7:10 PM', + 'category': 'Food', + 'paymentMode': 'Cash', + 'transactionId': 'TXN123453' + }, + ]; + + Color _getStatusColor(String status) { + switch (status) { + case 'Request': + return Colors.blue; + case 'Review': + return Colors.orange; + case 'Approved': + return Colors.green; + case 'Paid': + return Colors.purple; + case 'Closed': + return Colors.grey; + default: + return Colors.black; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: _buildAppBar(), + body: SafeArea( + child: Column( + children: [ + _buildSearchAndFilter(), + _buildToggleButtons(), + Expanded( + child: Obx( + () => isHistoryView.value + ? _buildHistoryList() + : _buildExpenseList(), + ), + ), + ], + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: () { + showAddExpenseBottomSheet(); + }, + backgroundColor: Colors.red, + child: const Icon(Icons.add, color: Colors.white), + ), + ); + } + + PreferredSizeWidget _buildAppBar() { + return PreferredSize( + preferredSize: const Size.fromHeight(72), + child: AppBar( + backgroundColor: const Color(0xFFF5F5F5), + elevation: 0.5, + automaticallyImplyLeading: false, + titleSpacing: 0, + title: Padding( + padding: MySpacing.xy(16, 0), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back_ios_new, + color: Colors.black, size: 20), + onPressed: () => Get.offNamed('/dashboard'), + ), + MySpacing.width(8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + MyText.titleLarge( + 'Expenses', + fontWeight: 700, + color: Colors.black, + ), + MySpacing.height(2), + GetBuilder( + builder: (controller) { + final projectName = + controller.selectedProject?.name ?? 'Select Project'; + return InkWell( + onTap: () => Get.toNamed('/project-selector'), + child: Row( + children: [ + const Icon(Icons.work_outline, + size: 14, color: Colors.grey), + MySpacing.width(4), + Expanded( + child: MyText.bodySmall( + projectName, + fontWeight: 600, + overflow: TextOverflow.ellipsis, + color: Colors.grey[700], + ), + ), + ], + ), + ); + }, + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildSearchAndFilter() { + return Padding( + padding: MySpacing.fromLTRB(12, 10, 12, 0), + child: Row( + children: [ + Expanded( + child: SizedBox( + height: 35, + child: TextField( + controller: searchController, + onChanged: (value) => searchQuery.value = value, + decoration: InputDecoration( + contentPadding: + const EdgeInsets.symmetric(horizontal: 12), + prefixIcon: const Icon(Icons.search, + size: 20, color: Colors.grey), + hintText: 'Search expenses...', + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + ), + ), + ), + ), + MySpacing.width(8), + IconButton( + icon: const Icon(Icons.tune, color: Colors.black), + onPressed: _openFilterBottomSheet, + ), + ], + ), + ); + } + + Widget _buildToggleButtons() { + return Padding( + padding: MySpacing.fromLTRB(8, 12, 8, 5), + child: Obx(() { + return Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: const Color(0xFFF0F0F0), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + _buildToggleButton( + label: 'Expenses', + icon: Icons.receipt_long, + selected: !isHistoryView.value, + onTap: () => isHistoryView.value = false, + ), + _buildToggleButton( + label: 'History', + icon: Icons.history, + selected: isHistoryView.value, + onTap: () => isHistoryView.value = true, + ), + ], + ), + ); + }), + ); + } + + Widget _buildToggleButton({ + required String label, + required IconData icon, + required bool selected, + required VoidCallback onTap, + }) { + return Expanded( + child: GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 10), + decoration: BoxDecoration( + color: selected ? Colors.red : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 16, color: selected ? Colors.white : Colors.grey), + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + color: selected ? Colors.white : Colors.grey, + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildExpenseList() { + return Obx(() { + final filteredList = expenseList.where((expense) { + return searchQuery.isEmpty || + expense['title']! + .toLowerCase() + .contains(searchQuery.value.toLowerCase()); + }).toList(); + + return _buildExpenseHistoryList(filteredList); + }); + } + + Widget _buildHistoryList() { + final historyList = expenseList + .where((item) => item['status'] == 'Paid' || item['status'] == 'Closed') + .toList(); + + return _buildExpenseHistoryList(historyList); + } + + Widget _buildExpenseHistoryList(List> list) { + return ListView.builder( + padding: const EdgeInsets.all(12), + itemCount: list.length, + itemBuilder: (context, index) { + final item = list[index]; + return GestureDetector( + onTap: () => Get.to( + () => const ExpenseDetailScreen(), + arguments: {'expense': item}, + ), + child: _buildExpenseCard(item), + ); + }, + ); + } + + Widget _buildExpenseCard(Map item) { + final statusColor = _getStatusColor(item['status']!); + + return Card( + elevation: 3, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + margin: const EdgeInsets.symmetric(vertical: 8), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title & Amount Row + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const Icon(Icons.receipt_long, size: 20, color: Colors.red), + const SizedBox(width: 8), + Text( + item['title']!, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + Text( + item['amount']!, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.red, + ), + ), + ], + ), + const SizedBox(height: 8), + _buildInfoRow(Icons.calendar_today, item['date']!), + const SizedBox(height: 6), + _buildInfoRow(Icons.category_outlined, item['category']!), + const SizedBox(height: 6), + _buildInfoRow(Icons.payment, item['paymentMode']!), + _buildInfoRow(Icons.confirmation_num_outlined, item['transactionId']!), + const SizedBox(height: 10), + Align( + alignment: Alignment.centerRight, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 10), + decoration: BoxDecoration( + color: statusColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + item['status']!, + style: TextStyle( + color: statusColor, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildInfoRow(IconData icon, String text) { + return Row( + children: [ + Icon(icon, size: 14, color: Colors.grey), + const SizedBox(width: 4), + Text( + text, + style: TextStyle(color: Colors.grey[600], fontSize: 12), + ), + ], + ); + } + + void _openFilterBottomSheet() { + Get.bottomSheet( + Container( + padding: const EdgeInsets.all(16), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Wrap( + runSpacing: 10, + children: [ + const Text( + 'Filter Expenses', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + ListTile( + leading: const Icon(Icons.date_range), + title: const Text('Date Range'), + onTap: () {}, + ), + ListTile( + leading: const Icon(Icons.work_outline), + title: const Text('Project'), + onTap: () {}, + ), + ListTile( + leading: const Icon(Icons.check_circle_outline), + title: const Text('Status'), + onTap: () {}, + ), + ], + ), + ), + ); + } +} From 30318cd294eb88e16e8241591bc8e70472fa487a Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Sat, 19 Jul 2025 20:15:54 +0530 Subject: [PATCH 02/65] feat: Add expense models and update expense detail screen - Created ExpenseModel, Project, ExpenseType, PaymentMode, PaidBy, CreatedBy, and Status classes for expense management. - Implemented JSON serialization and deserialization for expense models. - Added ExpenseStatusModel and ExpenseTypeModel for handling status and type of expenses. - Introduced PaymentModeModel for managing payment modes. - Refactored ExpenseDetailScreen to utilize the new ExpenseModel structure. - Enhanced UI components for better display of expense details. - Added search and filter functionality in ExpenseMainScreen. - Updated dependencies in pubspec.yaml to include geocoding package. --- android/app/src/main/AndroidManifest.xml | 1 + .../expense/add_expense_controller.dart | 310 +++++++ .../expense/expense_screen_controller.dart | 42 + lib/helpers/services/api_endpoints.dart | 21 +- lib/helpers/services/api_service.dart | 107 +++ .../expense/add_expense_bottom_sheet.dart | 767 ++++++++++++------ lib/model/expense/expense_list_model.dart | 268 ++++++ lib/model/expense/expense_status_model.dart | 25 + lib/model/expense/expense_type_model.dart | 25 + lib/model/expense/payment_types_model.dart | 22 + lib/view/expense/expense_detail_screen.dart | 305 ++++--- lib/view/expense/expense_screen.dart | 746 +++++++++-------- pubspec.yaml | 1 + 13 files changed, 1919 insertions(+), 721 deletions(-) create mode 100644 lib/controller/expense/add_expense_controller.dart create mode 100644 lib/controller/expense/expense_screen_controller.dart create mode 100644 lib/model/expense/expense_list_model.dart create mode 100644 lib/model/expense/expense_status_model.dart create mode 100644 lib/model/expense/expense_type_model.dart create mode 100644 lib/model/expense/payment_types_model.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 531d4a4..4ad7bad 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ + diff --git a/lib/controller/expense/add_expense_controller.dart b/lib/controller/expense/add_expense_controller.dart new file mode 100644 index 0000000..bb09b1c --- /dev/null +++ b/lib/controller/expense/add_expense_controller.dart @@ -0,0 +1,310 @@ +import 'dart:io'; +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:geocoding/geocoding.dart'; +import 'package:mime/mime.dart'; +import 'package:marco/helpers/services/api_service.dart'; +import 'package:marco/model/expense/payment_types_model.dart'; +import 'package:marco/model/expense/expense_type_model.dart'; +import 'package:marco/model/expense/expense_status_model.dart'; +import 'package:marco/helpers/services/app_logger.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:marco/model/employee_model.dart'; + +class AddExpenseController extends GetxController { + // === Text Controllers === + final amountController = TextEditingController(); + final descriptionController = TextEditingController(); + final supplierController = TextEditingController(); + final transactionIdController = TextEditingController(); + final gstController = TextEditingController(); + final locationController = TextEditingController(); + + // === Project Mapping === + final RxMap projectsMap = {}.obs; + + // === Selected Models === + final Rx selectedPaymentMode = Rx(null); + final Rx selectedExpenseType = Rx(null); + final Rx selectedExpenseStatus = + Rx(null); + final RxString selectedProject = ''.obs; + final Rx selectedPaidBy = Rx(null); + // === States === + final RxBool preApproved = false.obs; + final RxBool isFetchingLocation = false.obs; + final Rx selectedTransactionDate = Rx(null); + + // === Master Data === + final RxList projects = [].obs; + final RxList expenseTypes = [].obs; + final RxList paymentModes = [].obs; + final RxList expenseStatuses = [].obs; + final RxList globalProjects = [].obs; + + // === Attachments === + final RxList attachments = [].obs; + RxList allEmployees = [].obs; + RxBool isLoading = false.obs; + + @override + void onInit() { + super.onInit(); + fetchMasterData(); + fetchGlobalProjects(); + fetchAllEmployees(); + } + + @override + void onClose() { + amountController.dispose(); + descriptionController.dispose(); + supplierController.dispose(); + transactionIdController.dispose(); + gstController.dispose(); + locationController.dispose(); + super.onClose(); + } + + // === Pick Attachments === + Future pickAttachments() async { + try { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['jpg', 'jpeg', 'png', 'pdf', 'doc', 'docx'], + allowMultiple: true, + ); + + if (result != null && result.paths.isNotEmpty) { + final newFiles = + result.paths.whereType().map((e) => File(e)).toList(); + attachments.addAll(newFiles); + } + } catch (e) { + Get.snackbar("Error", "Failed to pick attachments: $e"); + } + } + + void removeAttachment(File file) { + attachments.remove(file); + } + + // === Fetch Master Data === + Future fetchMasterData() async { + try { + final expenseTypesData = await ApiService.getMasterExpenseTypes(); + if (expenseTypesData is List) { + expenseTypes.value = + expenseTypesData.map((e) => ExpenseTypeModel.fromJson(e)).toList(); + } + + final paymentModesData = await ApiService.getMasterPaymentModes(); + if (paymentModesData is List) { + paymentModes.value = + paymentModesData.map((e) => PaymentModeModel.fromJson(e)).toList(); + } + + final expenseStatusData = await ApiService.getMasterExpenseStatus(); + if (expenseStatusData is List) { + expenseStatuses.value = expenseStatusData + .map((e) => ExpenseStatusModel.fromJson(e)) + .toList(); + } + } catch (e) { + Get.snackbar("Error", "Failed to fetch master data: $e"); + } + } + + // === Fetch Current Location === + Future fetchCurrentLocation() async { + isFetchingLocation.value = true; + try { + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { + permission = await Geolocator.requestPermission(); + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { + Get.snackbar( + "Error", "Location permission denied. Enable in settings."); + return; + } + } + + if (!await Geolocator.isLocationServiceEnabled()) { + Get.snackbar("Error", "Location services are disabled. Enable them."); + return; + } + + final position = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.high, + ); + + final placemarks = await placemarkFromCoordinates( + position.latitude, + position.longitude, + ); + + if (placemarks.isNotEmpty) { + final place = placemarks.first; + final addressParts = [ + place.name, + place.street, + place.subLocality, + place.locality, + place.administrativeArea, + place.country, + ].where((part) => part != null && part.isNotEmpty).toList(); + + locationController.text = addressParts.join(", "); + } else { + locationController.text = "${position.latitude}, ${position.longitude}"; + } + } catch (e) { + Get.snackbar("Error", "Error fetching location: $e"); + } finally { + isFetchingLocation.value = false; + } + } + + // === Submit Expense === + Future submitExpense() async { + // Validation for required fields + if (selectedProject.value.isEmpty || + selectedExpenseType.value == null || + selectedPaymentMode.value == null || + descriptionController.text.isEmpty || + supplierController.text.isEmpty || + amountController.text.isEmpty || + selectedExpenseStatus.value == null || + attachments.isEmpty) { + showAppSnackbar( + title: "Error", + message: "Please fill all required fields.", + type: SnackbarType.error, + ); + return; + } + + final double? amount = double.tryParse(amountController.text); + if (amount == null) { + showAppSnackbar( + title: "Error", + message: "Please enter a valid amount.", + type: SnackbarType.error, + ); + return; + } + + final projectId = projectsMap[selectedProject.value]; + if (projectId == null) { + showAppSnackbar( + title: "Error", + message: "Invalid project selection.", + type: SnackbarType.error, + ); + return; + } + + // Convert attachments to base64 + meta + final attachmentData = await Future.wait(attachments.map((file) async { + final bytes = await file.readAsBytes(); + final base64String = base64Encode(bytes); + final mimeType = lookupMimeType(file.path) ?? 'application/octet-stream'; + final fileSize = await file.length(); + + return { + "fileName": file.path.split('/').last, + "base64Data": base64String, + "contentType": mimeType, + "fileSize": fileSize, + "description": "", + }; + }).toList()); + + // Submit API call + final success = await ApiService.createExpenseApi( + projectId: projectId, + expensesTypeId: selectedExpenseType.value!.id, + paymentModeId: selectedPaymentMode.value!.id, + paidById: selectedPaidBy.value?.id ?? "", + transactionDate:(selectedTransactionDate.value ?? DateTime.now()).toUtc(), + transactionId: transactionIdController.text, + description: descriptionController.text, + location: locationController.text, + supplerName: supplierController.text, + amount: amount, + noOfPersons: 0, + statusId: selectedExpenseStatus.value!.id, + billAttachments: attachmentData, + ); + + if (success) { + Get.back(); + showAppSnackbar( + title: "Success", + message: "Expense created successfully!", + type: SnackbarType.success, + ); + } else { + showAppSnackbar( + title: "Error", + message: "Failed to create expense. Try again.", + type: SnackbarType.error, + ); + } + } + + // === Fetch Projects === + Future fetchGlobalProjects() async { + try { + final response = await ApiService.getGlobalProjects(); + if (response != null) { + final names = []; + for (var item in response) { + final name = item['name']?.toString().trim(); + final id = item['id']?.toString().trim(); + if (name != null && id != null && name.isNotEmpty) { + projectsMap[name] = id; + names.add(name); + } + } + globalProjects.assignAll(names); + logSafe("Fetched ${names.length} global projects"); + } + } catch (e) { + logSafe("Failed to fetch global projects: $e", level: LogLevel.error); + } + } + + // === Fetch All Employees === + Future fetchAllEmployees() async { + isLoading.value = true; + + try { + final response = await ApiService.getAllEmployees(); + if (response != null && response.isNotEmpty) { + allEmployees + .assignAll(response.map((json) => EmployeeModel.fromJson(json))); + logSafe( + "All Employees fetched for Manage Bucket: ${allEmployees.length}", + level: LogLevel.info, + ); + } else { + allEmployees.clear(); + logSafe("No employees found for Manage Bucket.", + level: LogLevel.warning); + } + } catch (e) { + allEmployees.clear(); + logSafe("Error fetching employees in Manage Bucket", + level: LogLevel.error, error: e); + } + + isLoading.value = false; + update(); + } +} diff --git a/lib/controller/expense/expense_screen_controller.dart b/lib/controller/expense/expense_screen_controller.dart new file mode 100644 index 0000000..6568aa3 --- /dev/null +++ b/lib/controller/expense/expense_screen_controller.dart @@ -0,0 +1,42 @@ +import 'package:get/get.dart'; +import 'package:marco/helpers/services/api_service.dart'; +import 'package:marco/helpers/services/app_logger.dart'; +import 'package:marco/model/expense/expense_list_model.dart'; + +class ExpenseController extends GetxController { + final RxList expenses = [].obs; + final RxBool isLoading = false.obs; + final RxString errorMessage = ''.obs; + + /// Fetch all expenses from API + Future fetchExpenses() async { + isLoading.value = true; + errorMessage.value = ''; + + try { + final result = await ApiService.getExpenseListApi(); + + if (result != null) { + try { + // Convert the raw result (List) to List + final List parsed = List.from( + result.map((e) => ExpenseModel.fromJson(e))); + expenses.assignAll(parsed); + logSafe("Expenses loaded: ${parsed.length}"); + } catch (e) { + errorMessage.value = 'Failed to parse expenses: $e'; + logSafe("Parse error in fetchExpenses: $e", level: LogLevel.error); + } + } else { + errorMessage.value = 'Failed to fetch expenses from server.'; + logSafe("fetchExpenses failed: null response", level: LogLevel.error); + } + } catch (e, stack) { + errorMessage.value = 'An unexpected error occurred.'; + logSafe("Exception in fetchExpenses: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } finally { + isLoading.value = false; + } + } +} diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index 17328a6..0b0bfef 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -2,10 +2,10 @@ class ApiEndpoints { static const String baseUrl = "https://stageapi.marcoaiot.com/api"; // static const String baseUrl = "https://api.marcoaiot.com/api"; - // Dashboard Screen API Endpoints + // Dashboard Module API Endpoints static const String getDashboardAttendanceOverview = "/dashboard/attendance-overview"; - // Attendance Screen API Endpoints + // Attendance Module API Endpoints static const String getProjects = "/project/list"; static const String getGlobalProjects = "/project/list/basic"; static const String getEmployeesByProject = "/attendance/project/team"; @@ -14,14 +14,14 @@ class ApiEndpoints { static const String getRegularizationLogs = "/attendance/regularize"; static const String uploadAttendanceImage = "/attendance/record-image"; - // Employee Screen API Endpoints + // Employee Module API Endpoints static const String getAllEmployeesByProject = "/Project/employees/get"; static const String getAllEmployees = "/employee/list"; static const String getRoles = "/roles/jobrole"; static const String createEmployee = "/employee/manage"; static const String getEmployeeInfo = "/employee/profile/get"; - // Daily Task Screen API Endpoints + // Daily Task Module API Endpoints static const String getDailyTask = "/task/list"; static const String reportTask = "/task/report"; static const String commentTask = "/task/comment"; @@ -32,7 +32,7 @@ class ApiEndpoints { static const String assignTask = "/project/task"; static const String getmasterWorkCategories = "/Master/work-categories"; - ////// Directory Screen API Endpoints + ////// Directory Module API Endpoints static const String getDirectoryContacts = "/directory"; static const String getDirectoryBucketList = "/directory/buckets"; static const String getDirectoryContactDetail = "/directory/notes"; @@ -46,4 +46,15 @@ class ApiEndpoints { static const String createBucket = "/directory/bucket"; static const String updateBucket = "/directory/bucket"; static const String assignBucket = "/directory/assign-bucket"; + + ////// Expense Module API Endpoints + static const String getExpenseCategories = "/expense/categories"; + static const String getExpenseList = "/expense/list"; + static const String getExpenseDetails = "/expense/details"; + static const String createExpense = "/expense/create"; + static const String updateExpense = "/expense/manage"; + static const String getMasterPaymentModes = "/master/payment-modes"; + static const String getMasterExpenseStatus = "/master/expenses-status"; + static const String getMasterExpenseTypes = "/master/expenses-types"; + } diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 230fde7..7a7d80d 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -239,6 +239,113 @@ class ApiService { } } +// === Expense APIs === // + + static Future?> getExpenseListApi() async { + const endpoint = ApiEndpoints.getExpenseList; + + logSafe("Fetching expense list..."); + + try { + final response = await _getRequest(endpoint); + if (response == null) return null; + + return _parseResponse(response, label: 'Expense List'); + } catch (e, stack) { + logSafe("Exception during getExpenseListApi: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + return null; + } + } + + /// Fetch Master Payment Modes + static Future?> getMasterPaymentModes() async { + const endpoint = ApiEndpoints.getMasterPaymentModes; + return _getRequest(endpoint).then((res) => res != null + ? _parseResponse(res, label: 'Master Payment Modes') + : null); + } + + /// Fetch Master Expense Status + static Future?> getMasterExpenseStatus() async { + const endpoint = ApiEndpoints.getMasterExpenseStatus; + return _getRequest(endpoint).then((res) => res != null + ? _parseResponse(res, label: 'Master Expense Status') + : null); + } + + /// Fetch Master Expense Types + static Future?> getMasterExpenseTypes() async { + const endpoint = ApiEndpoints.getMasterExpenseTypes; + return _getRequest(endpoint).then((res) => res != null + ? _parseResponse(res, label: 'Master Expense Types') + : null); + } + + /// Create Expense API + static Future createExpenseApi({ + required String projectId, + required String expensesTypeId, + required String paymentModeId, + required String paidById, + required DateTime transactionDate, + required String transactionId, + required String description, + required String location, + required String supplerName, + required double amount, + required int noOfPersons, + required String statusId, + required List> billAttachments, + }) async { + final payload = { + "projectId": projectId, + "expensesTypeId": expensesTypeId, + "paymentModeId": paymentModeId, + "paidById": paidById, + "transactionDate": transactionDate.toIso8601String(), + "transactionId": transactionId, + "description": description, + "location": location, + "supplerName": supplerName, + "amount": amount, + "noOfPersons": noOfPersons, + "statusId": statusId, + "billAttachments": billAttachments, + }; + + const endpoint = ApiEndpoints.createExpense; + logSafe("Creating expense with payload: $payload"); + + try { + final response = + await _postRequest(endpoint, payload, customTimeout: extendedTimeout); + + if (response == null) { + logSafe("Create expense failed: null response", level: LogLevel.error); + return false; + } + + logSafe("Create expense response status: ${response.statusCode}"); + logSafe("Create expense response body: ${response.body}"); + + final json = jsonDecode(response.body); + if (json['success'] == true) { + logSafe("Expense created successfully: ${json['data']}"); + return true; + } else { + logSafe( + "Failed to create expense: ${json['message'] ?? 'Unknown error'}", + level: LogLevel.warning); + } + } catch (e, stack) { + logSafe("Exception during createExpense API: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return false; + } + // === Dashboard Endpoints === static Future?> getDashboardAttendanceOverview( diff --git a/lib/model/expense/add_expense_bottom_sheet.dart b/lib/model/expense/add_expense_bottom_sheet.dart index 3dfd6cb..3abd58a 100644 --- a/lib/model/expense/add_expense_bottom_sheet.dart +++ b/lib/model/expense/add_expense_bottom_sheet.dart @@ -1,54 +1,79 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:marco/controller/expense/add_expense_controller.dart'; import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/model/expense/payment_types_model.dart'; +import 'package:marco/model/expense/expense_type_model.dart'; +import 'package:marco/model/expense/expense_status_model.dart'; void showAddExpenseBottomSheet() { - final TextEditingController amountController = TextEditingController(); - final TextEditingController descriptionController = TextEditingController(); - final TextEditingController supplierController = TextEditingController(); - final TextEditingController transactionIdController = TextEditingController(); - final TextEditingController gstController = TextEditingController(); - - String selectedProject = "Select Project"; - String selectedCategory = "Select Expense Type"; - String selectedPaymentMode = "Select Payment Mode"; - String selectedLocation = "Select Location"; - bool preApproved = false; - Get.bottomSheet( - StatefulBuilder( - builder: (context, setState) { - return SafeArea( - child: Padding( - padding: - const EdgeInsets.only(top: 60), - child: Material( - color: Colors.white, - borderRadius: - const BorderRadius.vertical(top: Radius.circular(20)), - child: Container( - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height - 60, - ), - padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), - child: SingleChildScrollView( + const _AddExpenseBottomSheet(), + isScrollControlled: true, + ); +} + +class _AddExpenseBottomSheet extends StatefulWidget { + const _AddExpenseBottomSheet(); + + @override + State<_AddExpenseBottomSheet> createState() => _AddExpenseBottomSheetState(); +} + +class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { + final AddExpenseController controller = Get.put(AddExpenseController()); + final RxBool isProjectExpanded = false.obs; + void _showEmployeeList(BuildContext context) { + final employees = controller.allEmployees; + + showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (BuildContext context) { + return Obx(() { + return SizedBox( + height: 300, + child: ListView.builder( + itemCount: employees.length, + itemBuilder: (context, index) { + final emp = employees[index]; + final fullName = '${emp.firstName} ${emp.lastName}'.trim(); + + return ListTile( + title: Text(fullName.isNotEmpty ? fullName : "Unnamed"), + onTap: () { + controller.selectedPaidBy.value = emp; + Navigator.pop(context); + }, + ); + }, + ), + ); + }); + }, + ); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.only(top: 60), + child: Material( + color: Colors.white, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + child: Stack( + children: [ + Obx(() { + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Drag Handle - Center( - child: Container( - width: 40, - height: 4, - margin: const EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - color: Colors.grey.shade400, - borderRadius: BorderRadius.circular(2), - ), - ), - ), - - // Title + _buildDragHandle(), Center( child: MyText.titleLarge( "Add Expense", @@ -57,151 +82,306 @@ void showAddExpenseBottomSheet() { ), const SizedBox(height: 20), - // Project - _sectionTitle(Icons.work_outline, "Project"), + // Project Dropdown + const _SectionTitle( + icon: Icons.work_outline, title: "Project"), const SizedBox(height: 6), - _dropdownTile( - title: selectedProject, - onTap: () { - setState(() { - selectedProject = "Project A"; - }); - }, - ), + Obx(() { + return _DropdownTile( + title: controller.selectedProject.value.isEmpty + ? "Select Project" + : controller.selectedProject.value, + onTap: () => _showOptionList( + context, + controller.globalProjects.toList(), + (p) => p, + (val) => controller.selectedProject.value = val, + ), + ); + }), const SizedBox(height: 16), - // Expense Type + GST - _sectionTitle( - Icons.category_outlined, "Expense Type & GST No."), - const SizedBox(height: 6), - _dropdownTile( - title: selectedCategory, - onTap: () { - setState(() { - selectedCategory = "Travel Expense"; - }); - }, + // Expense Type & GST + const _SectionTitle( + icon: Icons.category_outlined, + title: "Expense Type & GST No.", ), + const SizedBox(height: 6), + Obx(() { + return _DropdownTile( + title: controller.selectedExpenseType.value?.name ?? + "Select Expense Type", + onTap: () => _showOptionList( + context, + controller.expenseTypes.toList(), + (e) => e.name, + (val) => controller.selectedExpenseType.value = val, + ), + ); + }), const SizedBox(height: 8), - _customTextField( - controller: gstController, + _CustomTextField( + controller: controller.gstController, hint: "Enter GST No.", ), const SizedBox(height: 16), // Payment Mode - _sectionTitle(Icons.payment, "Payment Mode"), + const _SectionTitle( + icon: Icons.payment, title: "Payment Mode"), const SizedBox(height: 6), - _dropdownTile( - title: selectedPaymentMode, - onTap: () { - setState(() { - selectedPaymentMode = "UPI"; - }); - }, - ), + Obx(() { + return _DropdownTile( + title: controller.selectedPaymentMode.value?.name ?? + "Select Payment Mode", + onTap: () => _showOptionList( + context, + controller.paymentModes.toList(), + (m) => m.name, + (val) => controller.selectedPaymentMode.value = val, + ), + ); + }), const SizedBox(height: 16), - - // Paid By - _sectionTitle(Icons.person, "Paid By (Employee)"), - const SizedBox(height: 6), - _dropdownTile( - title: "Self (Default)", - onTap: () {}, - ), + Obx(() { + final selected = controller.selectedPaidBy.value; + return GestureDetector( + onTap: () => _showEmployeeList(context), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 14), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade400), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + selected == null + ? "Select Paid By" + : '${selected.firstName} ${selected.lastName}', + style: const TextStyle(fontSize: 14), + ), + const Icon(Icons.arrow_drop_down, size: 22), + ], + ), + ), + ); + }), const SizedBox(height: 16), - - // Transaction Date - _sectionTitle(Icons.calendar_today, "Transaction Date"), + // Expense Status + const _SectionTitle( + icon: Icons.flag_outlined, title: "Status"), const SizedBox(height: 6), - _dropdownTile( - title: "Select Date & Time", - onTap: () async { - // Add date/time picker - }, - ), + Obx(() { + return _DropdownTile( + title: controller.selectedExpenseStatus.value?.name ?? + "Select Status", + onTap: () => _showOptionList( + context, + controller.expenseStatuses.toList(), + (s) => s.name, + (val) => + controller.selectedExpenseStatus.value = val, + ), + ); + }), const SizedBox(height: 16), // Amount - _sectionTitle(Icons.currency_rupee, "Amount"), + const _SectionTitle( + icon: Icons.currency_rupee, title: "Amount"), const SizedBox(height: 6), - _customTextField( - controller: amountController, + _CustomTextField( + controller: controller.amountController, hint: "Enter Amount", keyboardType: TextInputType.number, ), const SizedBox(height: 16), - - // Supplier - _sectionTitle(Icons.store_mall_directory, - "Supplier Name / Expense Done At"), + // Supplier Name + const _SectionTitle( + icon: Icons.store_mall_directory_outlined, + title: "Supplier Name", + ), const SizedBox(height: 6), - _customTextField( - controller: supplierController, + _CustomTextField( + controller: controller.supplierController, hint: "Enter Supplier Name", ), const SizedBox(height: 16), - - // Location - _sectionTitle(Icons.location_on_outlined, "Location"), - const SizedBox(height: 6), - _dropdownTile( - title: selectedLocation, - onTap: () { - setState(() { - selectedLocation = "Pune"; - }); - }, - ), - const SizedBox(height: 16), - - // Description - _sectionTitle(Icons.description_outlined, "Description"), - const SizedBox(height: 6), - _customTextField( - controller: descriptionController, - hint: "Enter Description", - maxLines: 3, - ), - const SizedBox(height: 16), - - // Bill Attachment - _sectionTitle(Icons.attachment, "Bill Attachment"), - const SizedBox(height: 6), - OutlinedButton.icon( - onPressed: () {}, - icon: const Icon(Icons.upload_file), - label: const Text("Upload Bill"), - style: OutlinedButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - ), - const SizedBox(height: 16), - // Transaction ID - _sectionTitle( - Icons.confirmation_num_outlined, "Transaction ID"), + const _SectionTitle( + icon: Icons.confirmation_number_outlined, + title: "Transaction ID"), const SizedBox(height: 6), - _customTextField( - controller: transactionIdController, + _CustomTextField( + controller: controller.transactionIdController, hint: "Enter Transaction ID", ), const SizedBox(height: 16), - // Pre-Approved Switch - Row( - children: [ - Switch( - value: preApproved, - onChanged: (val) => - setState(() => preApproved = val), - activeColor: Colors.red, + // Location + const _SectionTitle( + icon: Icons.location_on_outlined, + title: "Location", + ), + const SizedBox(height: 6), + TextField( + controller: controller.locationController, + decoration: InputDecoration( + hintText: "Enter Location", + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), ), - const SizedBox(width: 8), - const Text("Pre-Approved?"), - ], + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + suffixIcon: controller.isFetchingLocation.value + ? const Padding( + padding: EdgeInsets.all(12), + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2), + ), + ) + : IconButton( + icon: const Icon(Icons.my_location), + tooltip: "Use Current Location", + onPressed: controller.fetchCurrentLocation, + ), + ), + ), + + const SizedBox(height: 16), + // Attachments Section + const _SectionTitle( + icon: Icons.attach_file, title: "Attachments"), + const SizedBox(height: 6), + + Obx(() { + return Wrap( + spacing: 8, + runSpacing: 8, + children: [ + ...controller.attachments.map((file) { + final fileName = file.path.split('/').last; + final extension = + fileName.split('.').last.toLowerCase(); + + final isImage = + ['jpg', 'jpeg', 'png'].contains(extension); + + IconData fileIcon; + Color iconColor = Colors.blueAccent; + switch (extension) { + case 'pdf': + fileIcon = Icons.picture_as_pdf; + iconColor = Colors.redAccent; + break; + case 'doc': + case 'docx': + fileIcon = Icons.description; + iconColor = Colors.blueAccent; + break; + case 'xls': + case 'xlsx': + fileIcon = Icons.table_chart; + iconColor = Colors.green; + break; + case 'txt': + fileIcon = Icons.article; + iconColor = Colors.grey; + break; + default: + fileIcon = Icons.insert_drive_file; + iconColor = Colors.blueGrey; + } + + return Stack( + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Colors.grey.shade300), + color: Colors.grey.shade100, + ), + child: isImage + ? ClipRRect( + borderRadius: + BorderRadius.circular(8), + child: Image.file(file, + fit: BoxFit.cover), + ) + : Column( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + Icon(fileIcon, + color: iconColor, size: 30), + const SizedBox(height: 4), + Text( + extension.toUpperCase(), + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: iconColor, + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + Positioned( + top: -6, + right: -6, + child: IconButton( + icon: const Icon(Icons.close, + color: Colors.red, size: 18), + onPressed: () => + controller.removeAttachment(file), + ), + ), + ], + ); + }).toList(), + GestureDetector( + onTap: controller.pickAttachments, + child: Container( + width: 80, + height: 80, + decoration: BoxDecoration( + border: + Border.all(color: Colors.grey.shade400), + borderRadius: BorderRadius.circular(8), + color: Colors.grey.shade100, + ), + child: const Icon(Icons.add, + size: 30, color: Colors.grey), + ), + ), + ], + ); + }), + const SizedBox(height: 16), + + // Description + const _SectionTitle( + icon: Icons.description_outlined, + title: "Description", + ), + const SizedBox(height: 6), + _CustomTextField( + controller: controller.descriptionController, + hint: "Enter Description", + maxLines: 3, ), const SizedBox(height: 24), @@ -215,20 +395,15 @@ void showAddExpenseBottomSheet() { label: MyText.bodyMedium("Cancel", fontWeight: 600), style: OutlinedButton.styleFrom( - side: const BorderSide(color: Colors.grey), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), + minimumSize: + const Size.fromHeight(48), ), ), ), const SizedBox(width: 12), Expanded( child: ElevatedButton.icon( - onPressed: () { - // Handle Save - Get.back(); - }, + onPressed: controller.submitExpense, icon: const Icon(Icons.check, size: 18), label: MyText.bodyMedium( "Submit", @@ -238,82 +413,216 @@ void showAddExpenseBottomSheet() { style: ElevatedButton.styleFrom( backgroundColor: Colors.indigo, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(8), ), + padding: + const EdgeInsets.symmetric(vertical: 14), + minimumSize: + const Size.fromHeight(48), ), ), ), ], - ), + ) ], ), - ), + ); + }), + + // Project Selection List + Obx(() { + if (!isProjectExpanded.value) return const SizedBox.shrink(); + return Positioned( + top: 110, + left: 16, + right: 16, + child: Material( + elevation: 4, + borderRadius: BorderRadius.circular(12), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.all(10), + child: _buildProjectSelectionList(), + ), + ), + ); + }), + ], + ), + ), + ), + ); + } + + Widget _buildProjectSelectionList() { + return ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 300), + child: ListView.builder( + shrinkWrap: true, + itemCount: controller.globalProjects.length, + itemBuilder: (context, index) { + final project = controller.globalProjects[index]; + final isSelected = project == controller.selectedProject.value; + + return RadioListTile( + value: project, + groupValue: controller.selectedProject.value, + onChanged: (val) { + controller.selectedProject.value = val!; + isProjectExpanded.value = false; + }, + title: Text( + project, + style: TextStyle( + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + color: isSelected ? Colors.blueAccent : Colors.black87, ), ), - ), - ); - }, - ), - isScrollControlled: true, - ); -} - -/// Section Title -Widget _sectionTitle(IconData icon, String title) { - return Row( - children: [ - Icon(icon, color: Colors.grey[700], size: 18), - const SizedBox(width: 8), - MyText.bodyMedium(title, fontWeight: 600), - ], - ); -} - -/// Custom TextField -Widget _customTextField({ - required TextEditingController controller, - required String hint, - int maxLines = 1, - TextInputType keyboardType = TextInputType.text, -}) { - return TextField( - controller: controller, - maxLines: maxLines, - keyboardType: keyboardType, - decoration: InputDecoration( - hintText: hint, - filled: true, - fillColor: Colors.grey.shade100, - border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - ), - ); -} - -/// Dropdown Tile -Widget _dropdownTile({required String title, required VoidCallback onTap}) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), - decoration: BoxDecoration( - color: Colors.grey.shade100, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.grey.shade300), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - title, - style: const TextStyle(fontSize: 14, color: Colors.black87), - overflow: TextOverflow.ellipsis, + activeColor: Colors.blueAccent, + tileColor: isSelected + ? Colors.blueAccent.withOpacity(0.1) + : Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6), ), - ), - const Icon(Icons.arrow_drop_down), - ], + visualDensity: const VisualDensity(vertical: -4), + ); + }, ), - ), - ); + ); + } + + Future _showOptionList( + BuildContext context, + List options, + String Function(T) getLabel, + ValueChanged onSelected, + ) async { + final RenderBox button = context.findRenderObject() as RenderBox; + final RenderBox overlay = + Overlay.of(context).context.findRenderObject() as RenderBox; + + final Offset position = + button.localToGlobal(Offset.zero, ancestor: overlay); + final selected = await showMenu( + context: context, + position: RelativeRect.fromLTRB( + position.dx, + position.dy + button.size.height, + position.dx + button.size.width, + 0, + ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + items: options.map((option) { + return PopupMenuItem( + value: option, + child: Text(getLabel(option)), + ); + }).toList(), + ); + + if (selected != null) onSelected(selected); + } + + Widget _buildDragHandle() => Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.grey.shade400, + borderRadius: BorderRadius.circular(2), + ), + ), + ); +} + +class _SectionTitle extends StatelessWidget { + final IconData icon; + final String title; + const _SectionTitle({required this.icon, required this.title}); + + @override + Widget build(BuildContext context) { + final color = Colors.grey[700]; + return Row( + children: [ + Icon(icon, color: color, size: 18), + const SizedBox(width: 8), + MyText.bodyMedium(title, fontWeight: 600), + ], + ); + } +} + +class _CustomTextField extends StatelessWidget { + final TextEditingController controller; + final String hint; + final int maxLines; + final TextInputType keyboardType; + + const _CustomTextField({ + required this.controller, + required this.hint, + this.maxLines = 1, + this.keyboardType = TextInputType.text, + }); + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + maxLines: maxLines, + keyboardType: keyboardType, + decoration: InputDecoration( + hintText: hint, + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + ), + ); + } +} + +class _DropdownTile extends StatelessWidget { + final String title; + final VoidCallback onTap; + + const _DropdownTile({ + required this.title, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + title, + style: const TextStyle(fontSize: 14, color: Colors.black87), + overflow: TextOverflow.ellipsis, + ), + ), + const Icon(Icons.arrow_drop_down), + ], + ), + ), + ); + } } diff --git a/lib/model/expense/expense_list_model.dart b/lib/model/expense/expense_list_model.dart new file mode 100644 index 0000000..451ba72 --- /dev/null +++ b/lib/model/expense/expense_list_model.dart @@ -0,0 +1,268 @@ +import 'dart:convert'; + +List expenseModelFromJson(String str) => List.from( + json.decode(str).map((x) => ExpenseModel.fromJson(x))); + +String expenseModelToJson(List data) => + json.encode(List.from(data.map((x) => x.toJson()))); + +class ExpenseModel { + final String id; + final Project project; + final ExpenseType expensesType; + final PaymentMode paymentMode; + final PaidBy paidBy; + final CreatedBy createdBy; + final DateTime transactionDate; + final DateTime createdAt; + final String supplerName; + final double amount; + final Status status; + final List nextStatus; + final bool preApproved; + + ExpenseModel({ + required this.id, + required this.project, + required this.expensesType, + required this.paymentMode, + required this.paidBy, + required this.createdBy, + required this.transactionDate, + required this.createdAt, + required this.supplerName, + required this.amount, + required this.status, + required this.nextStatus, + required this.preApproved, + }); + + factory ExpenseModel.fromJson(Map json) => ExpenseModel( + id: json["id"], + project: Project.fromJson(json["project"]), + expensesType: ExpenseType.fromJson(json["expensesType"]), + paymentMode: PaymentMode.fromJson(json["paymentMode"]), + paidBy: PaidBy.fromJson(json["paidBy"]), + createdBy: CreatedBy.fromJson(json["createdBy"]), + transactionDate: DateTime.parse(json["transactionDate"]), + createdAt: DateTime.parse(json["createdAt"]), + supplerName: json["supplerName"], + amount: (json["amount"] as num).toDouble(), + status: Status.fromJson(json["status"]), + nextStatus: List.from( + json["nextStatus"].map((x) => Status.fromJson(x))), + preApproved: json["preApproved"], + ); + + Map toJson() => { + "id": id, + "project": project.toJson(), + "expensesType": expensesType.toJson(), + "paymentMode": paymentMode.toJson(), + "paidBy": paidBy.toJson(), + "createdBy": createdBy.toJson(), + "transactionDate": transactionDate.toIso8601String(), + "createdAt": createdAt.toIso8601String(), + "supplerName": supplerName, + "amount": amount, + "status": status.toJson(), + "nextStatus": List.from(nextStatus.map((x) => x.toJson())), + "preApproved": preApproved, + }; +} + +class Project { + final String id; + final String name; + final String shortName; + final String projectAddress; + final String contactPerson; + final DateTime startDate; + final DateTime endDate; + final String projectStatusId; + + Project({ + required this.id, + required this.name, + required this.shortName, + required this.projectAddress, + required this.contactPerson, + required this.startDate, + required this.endDate, + required this.projectStatusId, + }); + + factory Project.fromJson(Map json) => Project( + id: json["id"], + name: json["name"], + shortName: json["shortName"], + projectAddress: json["projectAddress"], + contactPerson: json["contactPerson"], + startDate: DateTime.parse(json["startDate"]), + endDate: DateTime.parse(json["endDate"]), + projectStatusId: json["projectStatusId"], + ); + + Map toJson() => { + "id": id, + "name": name, + "shortName": shortName, + "projectAddress": projectAddress, + "contactPerson": contactPerson, + "startDate": startDate.toIso8601String(), + "endDate": endDate.toIso8601String(), + "projectStatusId": projectStatusId, + }; +} + +class ExpenseType { + final String id; + final String name; + final bool noOfPersonsRequired; + final String description; + + ExpenseType({ + required this.id, + required this.name, + required this.noOfPersonsRequired, + required this.description, + }); + + factory ExpenseType.fromJson(Map json) => ExpenseType( + id: json["id"], + name: json["name"], + noOfPersonsRequired: json["noOfPersonsRequired"], + description: json["description"], + ); + + Map toJson() => { + "id": id, + "name": name, + "noOfPersonsRequired": noOfPersonsRequired, + "description": description, + }; +} + +class PaymentMode { + final String id; + final String name; + final String description; + + PaymentMode({ + required this.id, + required this.name, + required this.description, + }); + + factory PaymentMode.fromJson(Map json) => PaymentMode( + id: json["id"], + name: json["name"], + description: json["description"], + ); + + Map toJson() => { + "id": id, + "name": name, + "description": description, + }; +} + +class PaidBy { + final String id; + final String firstName; + final String lastName; + final String photo; + final String jobRoleId; + final String? jobRoleName; + + PaidBy({ + required this.id, + required this.firstName, + required this.lastName, + required this.photo, + required this.jobRoleId, + this.jobRoleName, + }); + + factory PaidBy.fromJson(Map json) => PaidBy( + id: json["id"], + firstName: json["firstName"], + lastName: json["lastName"], + photo: json["photo"], + jobRoleId: json["jobRoleId"], + jobRoleName: json["jobRoleName"], + ); + + Map toJson() => { + "id": id, + "firstName": firstName, + "lastName": lastName, + "photo": photo, + "jobRoleId": jobRoleId, + "jobRoleName": jobRoleName, + }; +} + +class CreatedBy { + final String id; + final String firstName; + final String lastName; + final String photo; + final String jobRoleId; + final String? jobRoleName; + + CreatedBy({ + required this.id, + required this.firstName, + required this.lastName, + required this.photo, + required this.jobRoleId, + this.jobRoleName, + }); + + factory CreatedBy.fromJson(Map json) => CreatedBy( + id: json["id"], + firstName: json["firstName"], + lastName: json["lastName"], + photo: json["photo"], + jobRoleId: json["jobRoleId"], + jobRoleName: json["jobRoleName"], + ); + + Map toJson() => { + "id": id, + "firstName": firstName, + "lastName": lastName, + "photo": photo, + "jobRoleId": jobRoleId, + "jobRoleName": jobRoleName, + }; +} + +class Status { + final String id; + final String name; + final String description; + final bool isSystem; + + Status({ + required this.id, + required this.name, + required this.description, + required this.isSystem, + }); + + factory Status.fromJson(Map json) => Status( + id: json["id"], + name: json["name"], + description: json["description"], + isSystem: json["isSystem"], + ); + + Map toJson() => { + "id": id, + "name": name, + "description": description, + "isSystem": isSystem, + }; +} diff --git a/lib/model/expense/expense_status_model.dart b/lib/model/expense/expense_status_model.dart new file mode 100644 index 0000000..8970c59 --- /dev/null +++ b/lib/model/expense/expense_status_model.dart @@ -0,0 +1,25 @@ +class ExpenseStatusModel { + final String id; + final String name; + final String description; + final bool isSystem; + final bool isActive; + + ExpenseStatusModel({ + required this.id, + required this.name, + required this.description, + required this.isSystem, + required this.isActive, + }); + + factory ExpenseStatusModel.fromJson(Map json) { + return ExpenseStatusModel( + id: json['id'], + name: json['name'], + description: json['description'] ?? '', + isSystem: json['isSystem'] ?? false, + isActive: json['isActive'] ?? false, + ); + } +} diff --git a/lib/model/expense/expense_type_model.dart b/lib/model/expense/expense_type_model.dart new file mode 100644 index 0000000..0cf8be9 --- /dev/null +++ b/lib/model/expense/expense_type_model.dart @@ -0,0 +1,25 @@ +class ExpenseTypeModel { + final String id; + final String name; + final bool noOfPersonsRequired; + final String description; + final bool isActive; + + ExpenseTypeModel({ + required this.id, + required this.name, + required this.noOfPersonsRequired, + required this.description, + required this.isActive, + }); + + factory ExpenseTypeModel.fromJson(Map json) { + return ExpenseTypeModel( + id: json['id'], + name: json['name'], + noOfPersonsRequired: json['noOfPersonsRequired'] ?? false, + description: json['description'] ?? '', + isActive: json['isActive'] ?? false, + ); + } +} diff --git a/lib/model/expense/payment_types_model.dart b/lib/model/expense/payment_types_model.dart new file mode 100644 index 0000000..d3f6024 --- /dev/null +++ b/lib/model/expense/payment_types_model.dart @@ -0,0 +1,22 @@ +class PaymentModeModel { + final String id; + final String name; + final String description; + final bool isActive; + + PaymentModeModel({ + required this.id, + required this.name, + required this.description, + required this.isActive, + }); + + factory PaymentModeModel.fromJson(Map json) { + return PaymentModeModel( + id: json['id'], + name: json['name'], + description: json['description'] ?? '', + isActive: json['isActive'] ?? false, + ); + } +} diff --git a/lib/view/expense/expense_detail_screen.dart b/lib/view/expense/expense_detail_screen.dart index 6ad1194..951e5d6 100644 --- a/lib/view/expense/expense_detail_screen.dart +++ b/lib/view/expense/expense_detail_screen.dart @@ -3,13 +3,15 @@ import 'package:get/get.dart'; import 'package:marco/controller/project_controller.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/model/expense/expense_list_model.dart'; +import 'package:marco/helpers/utils/date_time_utils.dart'; // Import DateTimeUtils class ExpenseDetailScreen extends StatelessWidget { const ExpenseDetailScreen({super.key}); - Color _getStatusColor(String status) { + static Color getStatusColor(String? status) { switch (status) { - case 'Request': + case 'Requested': return Colors.blue; case 'Review': return Colors.orange; @@ -26,10 +28,9 @@ class ExpenseDetailScreen extends StatelessWidget { @override Widget build(BuildContext context) { - final Map expense = - Get.arguments['expense'] as Map; - final Color statusColor = _getStatusColor(expense['status']!); - final ProjectController projectController = Get.find(); + final ExpenseModel expense = Get.arguments['expense'] as ExpenseModel; + final statusColor = getStatusColor(expense.status.name); + final projectController = Get.find(); return Scaffold( backgroundColor: const Color(0xFFF7F7F7), @@ -95,139 +96,89 @@ class ExpenseDetailScreen extends StatelessWidget { ), body: SingleChildScrollView( padding: const EdgeInsets.all(16), - child: Card( - elevation: 6, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - clipBehavior: Clip.antiAlias, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header Section - Container( - width: double.infinity, - padding: const EdgeInsets.all(20), - decoration: const BoxDecoration( - gradient: LinearGradient( - colors: [Color(0xFFFF4B2B), Color(0xFFFF416C)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - expense['title'] ?? 'N/A', - style: const TextStyle( - fontSize: 22, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - const SizedBox(height: 6), - Text( - expense['amount'] ?? '₹ 0', - style: const TextStyle( - fontSize: 26, - fontWeight: FontWeight.w700, - color: Colors.white, - ), - ), - const SizedBox(height: 12), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.15), - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.flag, size: 16, color: statusColor), - const SizedBox(width: 6), - Text( - expense['status'] ?? 'N/A', - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ], - ), - ), - - // Details Section - Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _detailRow(Icons.calendar_today, "Date & Time", - expense['date'] ?? 'N/A'), - _detailRow(Icons.category_outlined, "Expense Type", - "${expense['category']} (GST: ${expense['gstNo'] ?? 'N/A'})"), - _detailRow(Icons.payment, "Payment Mode", - expense['paymentMode'] ?? 'N/A'), - _detailRow(Icons.person, "Paid By", - expense['paidBy'] ?? 'N/A'), - _detailRow(Icons.access_time, "Transaction Date", - expense['transactionDate'] ?? 'N/A'), - _detailRow(Icons.location_on_outlined, "Location", - expense['location'] ?? 'N/A'), - _detailRow(Icons.store, "Supplier Name", - expense['supplierName'] ?? 'N/A'), - _detailRow(Icons.confirmation_num_outlined, - "Transaction ID", expense['transactionId'] ?? 'N/A'), - _detailRow(Icons.description, "Description", - expense['description'] ?? 'N/A'), - ], - ), - ), - ], - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _ExpenseHeader( + title: expense.expensesType.name, + amount: '₹ ${expense.amount.toStringAsFixed(2)}', + status: expense.status.name, + statusColor: statusColor, + ), + const SizedBox(height: 16), + _ExpenseDetailsList(expense: expense), + ], ), ), ); } +} - Widget _detailRow(IconData icon, String title, String value) { - return Padding( - padding: const EdgeInsets.only(bottom: 16), - child: Row( +class _ExpenseHeader extends StatelessWidget { + final String title; + final String amount; + final String status; + final Color statusColor; + + const _ExpenseHeader({ + required this.title, + required this.amount, + required this.status, + required this.statusColor, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 5, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.grey.shade100, - borderRadius: BorderRadius.circular(10), + Text( + title, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: Colors.black, ), - child: Icon(icon, size: 20, color: Colors.grey[800]), ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + const SizedBox(height: 6), + Text( + amount, + style: const TextStyle( + fontSize: 26, + fontWeight: FontWeight.w700, + color: Colors.black, + ), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.15), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.flag, size: 16, color: statusColor), + const SizedBox(width: 6), Text( - title, + status, style: const TextStyle( - fontSize: 13, - color: Colors.grey, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 4), - Text( - value, - style: const TextStyle( - fontSize: 15, + color: Colors.black, fontWeight: FontWeight.w600, ), ), @@ -239,3 +190,103 @@ class ExpenseDetailScreen extends StatelessWidget { ); } } + +class _ExpenseDetailsList extends StatelessWidget { + final ExpenseModel expense; + + const _ExpenseDetailsList({required this.expense}); + + @override + Widget build(BuildContext context) { + final transactionDate = DateTimeUtils.convertUtcToLocal( + expense.transactionDate.toString(), + format: 'dd-MM-yyyy hh:mm a', + ); + final createdAt = DateTimeUtils.convertUtcToLocal( + expense.createdAt.toString(), + format: 'dd-MM-yyyy hh:mm a', + ); + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 5, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _DetailRow(title: "Project", value: expense.project.name), + _DetailRow(title: "Expense Type", value: expense.expensesType.name), + _DetailRow(title: "Payment Mode", value: expense.paymentMode.name), + _DetailRow( + title: "Paid By", + value: + '${expense.paidBy.firstName} ${expense.paidBy.lastName}'), + _DetailRow( + title: "Created By", + value: + '${expense.createdBy.firstName} ${expense.createdBy.lastName}'), + _DetailRow(title: "Transaction Date", value: transactionDate), + _DetailRow(title: "Created At", value: createdAt), + _DetailRow(title: "Supplier Name", value: expense.supplerName), + _DetailRow(title: "Amount", value: '₹ ${expense.amount}'), + _DetailRow(title: "Status", value: expense.status.name), + _DetailRow( + title: "Next Status", + value: expense.nextStatus.map((e) => e.name).join(", ")), + _DetailRow( + title: "Pre-Approved", + value: expense.preApproved ? "Yes" : "No"), + ], + ), + ); + } +} + +class _DetailRow extends StatelessWidget { + final String title; + final String value; + + const _DetailRow({required this.title, required this.value}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 3, + child: Text( + title, + style: const TextStyle( + fontSize: 13, + color: Colors.grey, + fontWeight: FontWeight.w500, + ), + ), + ), + Expanded( + flex: 5, + child: Text( + value, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/view/expense/expense_screen.dart b/lib/view/expense/expense_screen.dart index f2d0217..e0e8f50 100644 --- a/lib/view/expense/expense_screen.dart +++ b/lib/view/expense/expense_screen.dart @@ -1,10 +1,13 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/controller/project_controller.dart'; +import 'package:marco/controller/expense/expense_screen_controller.dart'; +import 'package:marco/helpers/utils/date_time_utils.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; -import 'package:marco/model/expense/add_expense_bottom_sheet.dart'; +import 'package:marco/model/expense/expense_list_model.dart'; import 'package:marco/view/expense/expense_detail_screen.dart'; +import 'package:marco/model/expense/add_expense_bottom_sheet.dart'; class ExpenseMainScreen extends StatefulWidget { const ExpenseMainScreen({super.key}); @@ -17,388 +20,84 @@ class _ExpenseMainScreenState extends State { final RxBool isHistoryView = false.obs; final TextEditingController searchController = TextEditingController(); final RxString searchQuery = ''.obs; - final ProjectController projectController = Get.find(); + final ExpenseController expenseController = Get.put(ExpenseController()); - final List> expenseList = [ - { - 'title': 'Travel Expense', - 'amount': '₹ 1,500', - 'status': 'Request', - 'date': '12 Jul 2025 • 3:45 PM', - 'category': 'Transport', - 'paymentMode': 'UPI', - 'transactionId': 'TXN123451' - }, - { - 'title': 'Hotel Stay', - 'amount': '₹ 4,500', - 'status': 'Approved', - 'date': '11 Jul 2025 • 9:30 AM', - 'category': 'Accommodation', - 'paymentMode': 'Credit Card', - 'transactionId': 'TXN123452' - }, - { - 'title': 'Food Bill', - 'amount': '₹ 1,200', - 'status': 'Paid', - 'date': '10 Jul 2025 • 7:10 PM', - 'category': 'Food', - 'paymentMode': 'Cash', - 'transactionId': 'TXN123453' - }, - ]; - - Color _getStatusColor(String status) { - switch (status) { - case 'Request': - return Colors.blue; - case 'Review': - return Colors.orange; - case 'Approved': - return Colors.green; - case 'Paid': - return Colors.purple; - case 'Closed': - return Colors.grey; - default: - return Colors.black; - } + @override + void initState() { + super.initState(); + expenseController.fetchExpenses(); // Load expenses from API } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.white, - appBar: _buildAppBar(), + appBar: _ExpenseAppBar(projectController: projectController), body: SafeArea( child: Column( children: [ - _buildSearchAndFilter(), - _buildToggleButtons(), + _SearchAndFilter( + searchController: searchController, + onChanged: (value) => searchQuery.value = value, + onFilterTap: _openFilterBottomSheet, + ), + _ToggleButtons(isHistoryView: isHistoryView), Expanded( - child: Obx( - () => isHistoryView.value - ? _buildHistoryList() - : _buildExpenseList(), - ), + child: Obx(() { + if (expenseController.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } + + if (expenseController.errorMessage.isNotEmpty) { + return Center( + child: Text( + expenseController.errorMessage.value, + style: const TextStyle(color: Colors.red), + ), + ); + } + + if (expenseController.expenses.isEmpty) { + return const Center(child: Text("No expenses found.")); + } + + // Apply search filter + final filteredList = expenseController.expenses.where((expense) { + final query = searchQuery.value.toLowerCase(); + return query.isEmpty || + expense.expensesType.name.toLowerCase().contains(query) || + expense.supplerName.toLowerCase().contains(query) || + expense.paymentMode.name.toLowerCase().contains(query); + }).toList(); + + // Split into current month and history + final now = DateTime.now(); + final currentMonthList = filteredList.where((e) => + e.transactionDate.month == now.month && + e.transactionDate.year == now.year).toList(); + + final historyList = filteredList.where((e) => + e.transactionDate.isBefore( + DateTime(now.year, now.month, 1))).toList(); + + final listToShow = + isHistoryView.value ? historyList : currentMonthList; + + return _ExpenseList(expenseList: listToShow); + }), ), ], ), ), floatingActionButton: FloatingActionButton( - onPressed: () { - showAddExpenseBottomSheet(); - }, backgroundColor: Colors.red, + onPressed: showAddExpenseBottomSheet, child: const Icon(Icons.add, color: Colors.white), ), ); } - PreferredSizeWidget _buildAppBar() { - return PreferredSize( - preferredSize: const Size.fromHeight(72), - child: AppBar( - backgroundColor: const Color(0xFFF5F5F5), - elevation: 0.5, - automaticallyImplyLeading: false, - titleSpacing: 0, - title: Padding( - padding: MySpacing.xy(16, 0), - child: Row( - children: [ - IconButton( - icon: const Icon(Icons.arrow_back_ios_new, - color: Colors.black, size: 20), - onPressed: () => Get.offNamed('/dashboard'), - ), - MySpacing.width(8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - MyText.titleLarge( - 'Expenses', - fontWeight: 700, - color: Colors.black, - ), - MySpacing.height(2), - GetBuilder( - builder: (controller) { - final projectName = - controller.selectedProject?.name ?? 'Select Project'; - return InkWell( - onTap: () => Get.toNamed('/project-selector'), - child: Row( - children: [ - const Icon(Icons.work_outline, - size: 14, color: Colors.grey), - MySpacing.width(4), - Expanded( - child: MyText.bodySmall( - projectName, - fontWeight: 600, - overflow: TextOverflow.ellipsis, - color: Colors.grey[700], - ), - ), - ], - ), - ); - }, - ), - ], - ), - ), - ], - ), - ), - ), - ); - } - - Widget _buildSearchAndFilter() { - return Padding( - padding: MySpacing.fromLTRB(12, 10, 12, 0), - child: Row( - children: [ - Expanded( - child: SizedBox( - height: 35, - child: TextField( - controller: searchController, - onChanged: (value) => searchQuery.value = value, - decoration: InputDecoration( - contentPadding: - const EdgeInsets.symmetric(horizontal: 12), - prefixIcon: const Icon(Icons.search, - size: 20, color: Colors.grey), - hintText: 'Search expenses...', - filled: true, - fillColor: Colors.white, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: BorderSide(color: Colors.grey.shade300), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: BorderSide(color: Colors.grey.shade300), - ), - ), - ), - ), - ), - MySpacing.width(8), - IconButton( - icon: const Icon(Icons.tune, color: Colors.black), - onPressed: _openFilterBottomSheet, - ), - ], - ), - ); - } - - Widget _buildToggleButtons() { - return Padding( - padding: MySpacing.fromLTRB(8, 12, 8, 5), - child: Obx(() { - return Container( - padding: const EdgeInsets.all(2), - decoration: BoxDecoration( - color: const Color(0xFFF0F0F0), - borderRadius: BorderRadius.circular(10), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: Row( - children: [ - _buildToggleButton( - label: 'Expenses', - icon: Icons.receipt_long, - selected: !isHistoryView.value, - onTap: () => isHistoryView.value = false, - ), - _buildToggleButton( - label: 'History', - icon: Icons.history, - selected: isHistoryView.value, - onTap: () => isHistoryView.value = true, - ), - ], - ), - ); - }), - ); - } - - Widget _buildToggleButton({ - required String label, - required IconData icon, - required bool selected, - required VoidCallback onTap, - }) { - return Expanded( - child: GestureDetector( - onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 10), - decoration: BoxDecoration( - color: selected ? Colors.red : Colors.transparent, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(icon, size: 16, color: selected ? Colors.white : Colors.grey), - const SizedBox(width: 6), - Text( - label, - style: TextStyle( - color: selected ? Colors.white : Colors.grey, - fontWeight: FontWeight.w600, - fontSize: 13, - ), - ), - ], - ), - ), - ), - ); - } - - Widget _buildExpenseList() { - return Obx(() { - final filteredList = expenseList.where((expense) { - return searchQuery.isEmpty || - expense['title']! - .toLowerCase() - .contains(searchQuery.value.toLowerCase()); - }).toList(); - - return _buildExpenseHistoryList(filteredList); - }); - } - - Widget _buildHistoryList() { - final historyList = expenseList - .where((item) => item['status'] == 'Paid' || item['status'] == 'Closed') - .toList(); - - return _buildExpenseHistoryList(historyList); - } - - Widget _buildExpenseHistoryList(List> list) { - return ListView.builder( - padding: const EdgeInsets.all(12), - itemCount: list.length, - itemBuilder: (context, index) { - final item = list[index]; - return GestureDetector( - onTap: () => Get.to( - () => const ExpenseDetailScreen(), - arguments: {'expense': item}, - ), - child: _buildExpenseCard(item), - ); - }, - ); - } - - Widget _buildExpenseCard(Map item) { - final statusColor = _getStatusColor(item['status']!); - - return Card( - elevation: 3, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), - margin: const EdgeInsets.symmetric(vertical: 8), - child: Padding( - padding: const EdgeInsets.all(14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Title & Amount Row - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - const Icon(Icons.receipt_long, size: 20, color: Colors.red), - const SizedBox(width: 8), - Text( - item['title']!, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - Text( - item['amount']!, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.red, - ), - ), - ], - ), - const SizedBox(height: 8), - _buildInfoRow(Icons.calendar_today, item['date']!), - const SizedBox(height: 6), - _buildInfoRow(Icons.category_outlined, item['category']!), - const SizedBox(height: 6), - _buildInfoRow(Icons.payment, item['paymentMode']!), - _buildInfoRow(Icons.confirmation_num_outlined, item['transactionId']!), - const SizedBox(height: 10), - Align( - alignment: Alignment.centerRight, - child: Container( - padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 10), - decoration: BoxDecoration( - color: statusColor.withOpacity(0.1), - borderRadius: BorderRadius.circular(20), - ), - child: Text( - item['status']!, - style: TextStyle( - color: statusColor, - fontWeight: FontWeight.bold, - fontSize: 12, - ), - ), - ), - ), - ], - ), - ), - ); - } - - Widget _buildInfoRow(IconData icon, String text) { - return Row( - children: [ - Icon(icon, size: 14, color: Colors.grey), - const SizedBox(width: 4), - Text( - text, - style: TextStyle(color: Colors.grey[600], fontSize: 12), - ), - ], - ); - } - void _openFilterBottomSheet() { Get.bottomSheet( Container( @@ -435,3 +134,330 @@ class _ExpenseMainScreenState extends State { ); } } + +// AppBar Widget +class _ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget { + final ProjectController projectController; + + const _ExpenseAppBar({required this.projectController}); + + @override + Size get preferredSize => const Size.fromHeight(72); + + @override + Widget build(BuildContext context) { + return PreferredSize( + preferredSize: preferredSize, + child: AppBar( + backgroundColor: const Color(0xFFF5F5F5), + elevation: 0.5, + automaticallyImplyLeading: false, + titleSpacing: 0, + title: Padding( + padding: MySpacing.xy(16, 0), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back_ios_new, + color: Colors.black, size: 20), + onPressed: () => Get.offNamed('/dashboard'), + ), + MySpacing.width(8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + MyText.titleLarge( + 'Expenses', + fontWeight: 700, + color: Colors.black, + ), + MySpacing.height(2), + GetBuilder( + builder: (_) { + final projectName = + projectController.selectedProject?.name ?? + 'Select Project'; + return InkWell( + onTap: () => Get.toNamed('/project-selector'), + child: Row( + children: [ + const Icon(Icons.work_outline, + size: 14, color: Colors.grey), + MySpacing.width(4), + Expanded( + child: MyText.bodySmall( + projectName, + fontWeight: 600, + overflow: TextOverflow.ellipsis, + color: Colors.grey[700], + ), + ), + ], + ), + ); + }, + ) + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +// Search and Filter Widget +class _SearchAndFilter extends StatelessWidget { + final TextEditingController searchController; + final ValueChanged onChanged; + final VoidCallback onFilterTap; + + const _SearchAndFilter({ + required this.searchController, + required this.onChanged, + required this.onFilterTap, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: MySpacing.fromLTRB(12, 10, 12, 0), + child: Row( + children: [ + Expanded( + child: SizedBox( + height: 35, + child: TextField( + controller: searchController, + onChanged: onChanged, + decoration: InputDecoration( + contentPadding: + const EdgeInsets.symmetric(horizontal: 12), + prefixIcon: + const Icon(Icons.search, size: 20, color: Colors.grey), + hintText: 'Search expenses...', + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + ), + ), + ), + ), + MySpacing.width(8), + IconButton( + icon: const Icon(Icons.tune, color: Colors.black), + onPressed: onFilterTap, + ), + ], + ), + ); + } +} + +// Toggle Buttons Widget +class _ToggleButtons extends StatelessWidget { + final RxBool isHistoryView; + + const _ToggleButtons({required this.isHistoryView}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: MySpacing.fromLTRB(8, 12, 8, 5), + child: Obx(() { + return Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: const Color(0xFFF0F0F0), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + _ToggleButton( + label: 'Expenses', + icon: Icons.receipt_long, + selected: !isHistoryView.value, + onTap: () => isHistoryView.value = false, + ), + _ToggleButton( + label: 'History', + icon: Icons.history, + selected: isHistoryView.value, + onTap: () => isHistoryView.value = true, + ), + ], + ), + ); + }), + ); + } +} + +class _ToggleButton extends StatelessWidget { + final String label; + final IconData icon; + final bool selected; + final VoidCallback onTap; + + const _ToggleButton({ + required this.label, + required this.icon, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Expanded( + child: GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 10), + decoration: BoxDecoration( + color: selected ? Colors.red : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 16, color: selected ? Colors.white : Colors.grey), + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + color: selected ? Colors.white : Colors.grey, + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), + ], + ), + ), + ), + ); + } +} + +// Expense List Widget (Dynamic) +class _ExpenseList extends StatelessWidget { + final List expenseList; + + const _ExpenseList({required this.expenseList}); + + static Color _getStatusColor(String status) { + switch (status) { + case 'Requested': return Colors.blue; + case 'Review': return Colors.orange; + case 'Approved': return Colors.green; + case 'Paid': return Colors.purple; + case 'Closed': return Colors.grey; + default: return Colors.black; + } + } + + @override + Widget build(BuildContext context) { + if (expenseList.isEmpty) { + return const Center(child: Text('No expenses found.')); + } + + return ListView.separated( + padding: const EdgeInsets.all(12), + itemCount: expenseList.length, + separatorBuilder: (_, __) => + Divider(color: Colors.grey.shade300, height: 20), + itemBuilder: (context, index) { + final expense = expenseList[index]; + final statusColor = _getStatusColor(expense.status.name); + + // Convert UTC date to local formatted string + final formattedDate = DateTimeUtils.convertUtcToLocal( + expense.transactionDate.toIso8601String(), + format: 'dd MMM yyyy, hh:mm a', + ); + + return GestureDetector( + onTap: () => Get.to( + () => const ExpenseDetailScreen(), + arguments: {'expense': expense}, + ), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title + Amount row + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const Icon(Icons.receipt_long, + size: 20, color: Colors.red), + const SizedBox(width: 8), + Text( + expense.expensesType.name, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + Text( + '₹ ${expense.amount.toStringAsFixed(2)}', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.red, + ), + ), + ], + ), + const SizedBox(height: 6), + + // Date + Status + Row( + children: [ + Text( + formattedDate, + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + const Spacer(), + Text( + expense.status.name, + style: TextStyle( + color: statusColor, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ], + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index fef9493..4e0238e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -77,6 +77,7 @@ dependencies: html_editor_enhanced: ^2.7.0 flutter_quill_delta_from_html: ^1.5.2 quill_delta: ^3.0.0-nullsafety.2 + geocoding: ^4.0.0 dev_dependencies: flutter_test: sdk: flutter From 6c0e73d870c6460c57ecafb2c26416e2da209a47 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Mon, 21 Jul 2025 09:54:13 +0530 Subject: [PATCH 03/65] feat(expense): enhance expense submission process and UI feedback --- .../expense/add_expense_controller.dart | 177 ++++++++++-------- .../expense/add_expense_bottom_sheet.dart | 51 +++-- lib/view/expense/expense_screen.dart | 50 +++-- 3 files changed, 161 insertions(+), 117 deletions(-) diff --git a/lib/controller/expense/add_expense_controller.dart b/lib/controller/expense/add_expense_controller.dart index bb09b1c..17d8078 100644 --- a/lib/controller/expense/add_expense_controller.dart +++ b/lib/controller/expense/add_expense_controller.dart @@ -13,6 +13,7 @@ import 'package:marco/model/expense/expense_status_model.dart'; import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/model/employee_model.dart'; +import 'package:marco/controller/expense/expense_screen_controller.dart'; class AddExpenseController extends GetxController { // === Text Controllers === @@ -22,6 +23,7 @@ class AddExpenseController extends GetxController { final transactionIdController = TextEditingController(); final gstController = TextEditingController(); final locationController = TextEditingController(); + final ExpenseController expenseController = Get.find(); // === Project Mapping === final RxMap projectsMap = {}.obs; @@ -49,6 +51,7 @@ class AddExpenseController extends GetxController { final RxList attachments = [].obs; RxList allEmployees = [].obs; RxBool isLoading = false.obs; + final RxBool isSubmitting = false.obs; @override void onInit() { @@ -172,89 +175,105 @@ class AddExpenseController extends GetxController { // === Submit Expense === Future submitExpense() async { - // Validation for required fields - if (selectedProject.value.isEmpty || - selectedExpenseType.value == null || - selectedPaymentMode.value == null || - descriptionController.text.isEmpty || - supplierController.text.isEmpty || - amountController.text.isEmpty || - selectedExpenseStatus.value == null || - attachments.isEmpty) { + if (isSubmitting.value) return; // Prevent multiple taps + isSubmitting.value = true; + + try { + // === Validation === + if (selectedProject.value.isEmpty || + selectedExpenseType.value == null || + selectedPaymentMode.value == null || + descriptionController.text.isEmpty || + supplierController.text.isEmpty || + amountController.text.isEmpty || + selectedExpenseStatus.value == null || + attachments.isEmpty) { + showAppSnackbar( + title: "Error", + message: "Please fill all required fields.", + type: SnackbarType.error, + ); + return; + } + + final double? amount = double.tryParse(amountController.text); + if (amount == null) { + showAppSnackbar( + title: "Error", + message: "Please enter a valid amount.", + type: SnackbarType.error, + ); + return; + } + + final projectId = projectsMap[selectedProject.value]; + if (projectId == null) { + showAppSnackbar( + title: "Error", + message: "Invalid project selection.", + type: SnackbarType.error, + ); + return; + } + + // === Convert Attachments === + final attachmentData = await Future.wait(attachments.map((file) async { + final bytes = await file.readAsBytes(); + final base64String = base64Encode(bytes); + final mimeType = + lookupMimeType(file.path) ?? 'application/octet-stream'; + final fileSize = await file.length(); + + return { + "fileName": file.path.split('/').last, + "base64Data": base64String, + "contentType": mimeType, + "fileSize": fileSize, + "description": "", + }; + }).toList()); + + // === API Call === + final success = await ApiService.createExpenseApi( + projectId: projectId, + expensesTypeId: selectedExpenseType.value!.id, + paymentModeId: selectedPaymentMode.value!.id, + paidById: selectedPaidBy.value?.id ?? "", + transactionDate: + (selectedTransactionDate.value ?? DateTime.now()).toUtc(), + transactionId: transactionIdController.text, + description: descriptionController.text, + location: locationController.text, + supplerName: supplierController.text, + amount: amount, + noOfPersons: 0, + statusId: selectedExpenseStatus.value!.id, + billAttachments: attachmentData, + ); + + if (success) { + await Get.find().fetchExpenses(); // 🔄 Refresh list + Get.back(); + showAppSnackbar( + title: "Success", + message: "Expense created successfully!", + type: SnackbarType.success, + ); + } else { + showAppSnackbar( + title: "Error", + message: "Failed to create expense. Try again.", + type: SnackbarType.error, + ); + } + } catch (e) { showAppSnackbar( title: "Error", - message: "Please fill all required fields.", - type: SnackbarType.error, - ); - return; - } - - final double? amount = double.tryParse(amountController.text); - if (amount == null) { - showAppSnackbar( - title: "Error", - message: "Please enter a valid amount.", - type: SnackbarType.error, - ); - return; - } - - final projectId = projectsMap[selectedProject.value]; - if (projectId == null) { - showAppSnackbar( - title: "Error", - message: "Invalid project selection.", - type: SnackbarType.error, - ); - return; - } - - // Convert attachments to base64 + meta - final attachmentData = await Future.wait(attachments.map((file) async { - final bytes = await file.readAsBytes(); - final base64String = base64Encode(bytes); - final mimeType = lookupMimeType(file.path) ?? 'application/octet-stream'; - final fileSize = await file.length(); - - return { - "fileName": file.path.split('/').last, - "base64Data": base64String, - "contentType": mimeType, - "fileSize": fileSize, - "description": "", - }; - }).toList()); - - // Submit API call - final success = await ApiService.createExpenseApi( - projectId: projectId, - expensesTypeId: selectedExpenseType.value!.id, - paymentModeId: selectedPaymentMode.value!.id, - paidById: selectedPaidBy.value?.id ?? "", - transactionDate:(selectedTransactionDate.value ?? DateTime.now()).toUtc(), - transactionId: transactionIdController.text, - description: descriptionController.text, - location: locationController.text, - supplerName: supplierController.text, - amount: amount, - noOfPersons: 0, - statusId: selectedExpenseStatus.value!.id, - billAttachments: attachmentData, - ); - - if (success) { - Get.back(); - showAppSnackbar( - title: "Success", - message: "Expense created successfully!", - type: SnackbarType.success, - ); - } else { - showAppSnackbar( - title: "Error", - message: "Failed to create expense. Try again.", + message: "Something went wrong: $e", type: SnackbarType.error, ); + } finally { + isSubmitting.value = false; } } diff --git a/lib/model/expense/add_expense_bottom_sheet.dart b/lib/model/expense/add_expense_bottom_sheet.dart index 3abd58a..d7e6e11 100644 --- a/lib/model/expense/add_expense_bottom_sheet.dart +++ b/lib/model/expense/add_expense_bottom_sheet.dart @@ -395,32 +395,43 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { label: MyText.bodyMedium("Cancel", fontWeight: 600), style: OutlinedButton.styleFrom( - minimumSize: - const Size.fromHeight(48), + minimumSize: const Size.fromHeight(48), ), ), ), const SizedBox(width: 12), Expanded( - child: ElevatedButton.icon( - onPressed: controller.submitExpense, - icon: const Icon(Icons.check, size: 18), - label: MyText.bodyMedium( - "Submit", - color: Colors.white, - fontWeight: 600, - ), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.indigo, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), + child: Obx(() { + final isLoading = controller.isSubmitting.value; + return ElevatedButton.icon( + onPressed: + isLoading ? null : controller.submitExpense, + icon: isLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.check, size: 18), + label: MyText.bodyMedium( + isLoading ? "Submitting..." : "Submit", + color: Colors.white, + fontWeight: 600, ), - padding: - const EdgeInsets.symmetric(vertical: 14), - minimumSize: - const Size.fromHeight(48), - ), - ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.indigo, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: + const EdgeInsets.symmetric(vertical: 14), + minimumSize: const Size.fromHeight(48), + ), + ); + }), ), ], ) diff --git a/lib/view/expense/expense_screen.dart b/lib/view/expense/expense_screen.dart index e0e8f50..c01b69e 100644 --- a/lib/view/expense/expense_screen.dart +++ b/lib/view/expense/expense_screen.dart @@ -63,7 +63,8 @@ class _ExpenseMainScreenState extends State { } // Apply search filter - final filteredList = expenseController.expenses.where((expense) { + final filteredList = + expenseController.expenses.where((expense) { final query = searchQuery.value.toLowerCase(); return query.isEmpty || expense.expensesType.name.toLowerCase().contains(query) || @@ -71,15 +72,22 @@ class _ExpenseMainScreenState extends State { expense.paymentMode.name.toLowerCase().contains(query); }).toList(); + // Sort by latest transaction date first + filteredList.sort( + (a, b) => b.transactionDate.compareTo(a.transactionDate)); + // Split into current month and history final now = DateTime.now(); - final currentMonthList = filteredList.where((e) => - e.transactionDate.month == now.month && - e.transactionDate.year == now.year).toList(); + final currentMonthList = filteredList + .where((e) => + e.transactionDate.month == now.month && + e.transactionDate.year == now.year) + .toList(); - final historyList = filteredList.where((e) => - e.transactionDate.isBefore( - DateTime(now.year, now.month, 1))).toList(); + final historyList = filteredList + .where((e) => e.transactionDate + .isBefore(DateTime(now.year, now.month, 1))) + .toList(); final listToShow = isHistoryView.value ? historyList : currentMonthList; @@ -235,8 +243,7 @@ class _SearchAndFilter extends StatelessWidget { controller: searchController, onChanged: onChanged, decoration: InputDecoration( - contentPadding: - const EdgeInsets.symmetric(horizontal: 12), + contentPadding: const EdgeInsets.symmetric(horizontal: 12), prefixIcon: const Icon(Icons.search, size: 20, color: Colors.grey), hintText: 'Search expenses...', @@ -257,7 +264,7 @@ class _SearchAndFilter extends StatelessWidget { MySpacing.width(8), IconButton( icon: const Icon(Icons.tune, color: Colors.black), - onPressed: onFilterTap, + onPressed: null, ), ], ), @@ -339,7 +346,8 @@ class _ToggleButton extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(icon, size: 16, color: selected ? Colors.white : Colors.grey), + Icon(icon, + size: 16, color: selected ? Colors.white : Colors.grey), const SizedBox(width: 6), Text( label, @@ -365,12 +373,18 @@ class _ExpenseList extends StatelessWidget { static Color _getStatusColor(String status) { switch (status) { - case 'Requested': return Colors.blue; - case 'Review': return Colors.orange; - case 'Approved': return Colors.green; - case 'Paid': return Colors.purple; - case 'Closed': return Colors.grey; - default: return Colors.black; + case 'Requested': + return Colors.blue; + case 'Review': + return Colors.orange; + case 'Approved': + return Colors.green; + case 'Paid': + return Colors.purple; + case 'Closed': + return Colors.grey; + default: + return Colors.black; } } @@ -381,7 +395,7 @@ class _ExpenseList extends StatelessWidget { } return ListView.separated( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.fromLTRB(12, 12, 12, 80), itemCount: expenseList.length, separatorBuilder: (_, __) => Divider(color: Colors.grey.shade300, height: 20), From ee469f694e7cb6877b33ceb41111b0e893fda491 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Mon, 21 Jul 2025 15:24:18 +0530 Subject: [PATCH 04/65] feat(expense): improve expense submission validation and UI feedback --- .../expense/add_expense_controller.dart | 26 +-- lib/helpers/services/api_service.dart | 2 - .../expense/add_expense_bottom_sheet.dart | 149 +++++++----------- lib/view/expense/expense_screen.dart | 88 +++-------- 4 files changed, 98 insertions(+), 167 deletions(-) diff --git a/lib/controller/expense/add_expense_controller.dart b/lib/controller/expense/add_expense_controller.dart index 17d8078..9073035 100644 --- a/lib/controller/expense/add_expense_controller.dart +++ b/lib/controller/expense/add_expense_controller.dart @@ -173,6 +173,7 @@ class AddExpenseController extends GetxController { } } + // === Submit Expense === // === Submit Expense === Future submitExpense() async { if (isSubmitting.value) return; // Prevent multiple taps @@ -180,17 +181,21 @@ class AddExpenseController extends GetxController { try { // === Validation === - if (selectedProject.value.isEmpty || - selectedExpenseType.value == null || - selectedPaymentMode.value == null || - descriptionController.text.isEmpty || - supplierController.text.isEmpty || - amountController.text.isEmpty || - selectedExpenseStatus.value == null || - attachments.isEmpty) { + List missingFields = []; + + if (selectedProject.value.isEmpty) missingFields.add("Project"); + if (selectedExpenseType.value == null) missingFields.add("Expense Type"); + if (selectedPaymentMode.value == null) missingFields.add("Payment Mode"); + if (selectedPaidBy.value == null) missingFields.add("Paid By"); + if (amountController.text.isEmpty) missingFields.add("Amount"); + if (supplierController.text.isEmpty) missingFields.add("Supplier Name"); + if (descriptionController.text.isEmpty) missingFields.add("Description"); + if (attachments.isEmpty) missingFields.add("Attachments"); + + if (missingFields.isNotEmpty) { showAppSnackbar( - title: "Error", - message: "Please fill all required fields.", + title: "Missing Fields", + message: "Please provide: ${missingFields.join(', ')}.", type: SnackbarType.error, ); return; @@ -247,7 +252,6 @@ class AddExpenseController extends GetxController { supplerName: supplierController.text, amount: amount, noOfPersons: 0, - statusId: selectedExpenseStatus.value!.id, billAttachments: attachmentData, ); diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 7a7d80d..7442c32 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -295,7 +295,6 @@ class ApiService { required String supplerName, required double amount, required int noOfPersons, - required String statusId, required List> billAttachments, }) async { final payload = { @@ -310,7 +309,6 @@ class ApiService { "supplerName": supplerName, "amount": amount, "noOfPersons": noOfPersons, - "statusId": statusId, "billAttachments": billAttachments, }; diff --git a/lib/model/expense/add_expense_bottom_sheet.dart b/lib/model/expense/add_expense_bottom_sheet.dart index d7e6e11..ac6e5af 100644 --- a/lib/model/expense/add_expense_bottom_sheet.dart +++ b/lib/model/expense/add_expense_bottom_sheet.dart @@ -4,7 +4,6 @@ import 'package:marco/controller/expense/add_expense_controller.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/model/expense/payment_types_model.dart'; import 'package:marco/model/expense/expense_type_model.dart'; -import 'package:marco/model/expense/expense_status_model.dart'; void showAddExpenseBottomSheet() { Get.bottomSheet( @@ -23,9 +22,9 @@ class _AddExpenseBottomSheet extends StatefulWidget { class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { final AddExpenseController controller = Get.put(AddExpenseController()); final RxBool isProjectExpanded = false.obs; + void _showEmployeeList(BuildContext context) { final employees = controller.allEmployees; - showModalBottomSheet( context: context, backgroundColor: Colors.white, @@ -84,7 +83,10 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { // Project Dropdown const _SectionTitle( - icon: Icons.work_outline, title: "Project"), + icon: Icons.work_outline, + title: "Project", + requiredField: true, + ), const SizedBox(height: 6), Obx(() { return _DropdownTile( @@ -105,6 +107,7 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { const _SectionTitle( icon: Icons.category_outlined, title: "Expense Type & GST No.", + requiredField: true, ), const SizedBox(height: 6), Obx(() { @@ -128,7 +131,10 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { // Payment Mode const _SectionTitle( - icon: Icons.payment, title: "Payment Mode"), + icon: Icons.payment, + title: "Payment Mode", + requiredField: true, + ), const SizedBox(height: 6), Obx(() { return _DropdownTile( @@ -143,6 +149,14 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { ); }), const SizedBox(height: 16), + + // Paid By + const _SectionTitle( + icon: Icons.person_outline, + title: "Paid By", + requiredField: true, + ), + const SizedBox(height: 6), Obx(() { final selected = controller.selectedPaidBy.value; return GestureDetector( @@ -171,28 +185,12 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { ); }), const SizedBox(height: 16), - // Expense Status - const _SectionTitle( - icon: Icons.flag_outlined, title: "Status"), - const SizedBox(height: 6), - Obx(() { - return _DropdownTile( - title: controller.selectedExpenseStatus.value?.name ?? - "Select Status", - onTap: () => _showOptionList( - context, - controller.expenseStatuses.toList(), - (s) => s.name, - (val) => - controller.selectedExpenseStatus.value = val, - ), - ); - }), - const SizedBox(height: 16), - // Amount const _SectionTitle( - icon: Icons.currency_rupee, title: "Amount"), + icon: Icons.currency_rupee, + title: "Amount", + requiredField: true, + ), const SizedBox(height: 6), _CustomTextField( controller: controller.amountController, @@ -200,10 +198,12 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { keyboardType: TextInputType.number, ), const SizedBox(height: 16), + // Supplier Name const _SectionTitle( icon: Icons.store_mall_directory_outlined, title: "Supplier Name", + requiredField: true, ), const SizedBox(height: 6), _CustomTextField( @@ -211,10 +211,12 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { hint: "Enter Supplier Name", ), const SizedBox(height: 16), + // Transaction ID const _SectionTitle( - icon: Icons.confirmation_number_outlined, - title: "Transaction ID"), + icon: Icons.confirmation_number_outlined, + title: "Transaction ID", + ), const SizedBox(height: 6), _CustomTextField( controller: controller.transactionIdController, @@ -256,13 +258,15 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { ), ), ), - const SizedBox(height: 16), + // Attachments Section const _SectionTitle( - icon: Icons.attach_file, title: "Attachments"), + icon: Icons.attach_file, + title: "Attachments", + requiredField: true, + ), const SizedBox(height: 6), - Obx(() { return Wrap( spacing: 8, @@ -376,6 +380,7 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { const _SectionTitle( icon: Icons.description_outlined, title: "Description", + requiredField: true, ), const SizedBox(height: 6), _CustomTextField( @@ -439,28 +444,6 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { ), ); }), - - // Project Selection List - Obx(() { - if (!isProjectExpanded.value) return const SizedBox.shrink(); - return Positioned( - top: 110, - left: 16, - right: 16, - child: Material( - elevation: 4, - borderRadius: BorderRadius.circular(12), - child: Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.all(10), - child: _buildProjectSelectionList(), - ), - ), - ); - }), ], ), ), @@ -468,44 +451,6 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { ); } - Widget _buildProjectSelectionList() { - return ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 300), - child: ListView.builder( - shrinkWrap: true, - itemCount: controller.globalProjects.length, - itemBuilder: (context, index) { - final project = controller.globalProjects[index]; - final isSelected = project == controller.selectedProject.value; - - return RadioListTile( - value: project, - groupValue: controller.selectedProject.value, - onChanged: (val) { - controller.selectedProject.value = val!; - isProjectExpanded.value = false; - }, - title: Text( - project, - style: TextStyle( - fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, - color: isSelected ? Colors.blueAccent : Colors.black87, - ), - ), - activeColor: Colors.blueAccent, - tileColor: isSelected - ? Colors.blueAccent.withOpacity(0.1) - : Colors.transparent, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(6), - ), - visualDensity: const VisualDensity(vertical: -4), - ); - }, - ), - ); - } - Future _showOptionList( BuildContext context, List options, @@ -554,16 +499,38 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { class _SectionTitle extends StatelessWidget { final IconData icon; final String title; - const _SectionTitle({required this.icon, required this.title}); + final bool requiredField; + + const _SectionTitle({ + required this.icon, + required this.title, + this.requiredField = false, + }); @override Widget build(BuildContext context) { final color = Colors.grey[700]; return Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ Icon(icon, color: color, size: 18), const SizedBox(width: 8), - MyText.bodyMedium(title, fontWeight: 600), + RichText( + text: TextSpan( + style: DefaultTextStyle.of(context).style.copyWith( + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + children: [ + TextSpan(text: title), + if (requiredField) + const TextSpan( + text: ' *', + style: TextStyle(color: Colors.red), + ), + ], + ), + ), ], ); } diff --git a/lib/view/expense/expense_screen.dart b/lib/view/expense/expense_screen.dart index c01b69e..96803c1 100644 --- a/lib/view/expense/expense_screen.dart +++ b/lib/view/expense/expense_screen.dart @@ -51,18 +51,17 @@ class _ExpenseMainScreenState extends State { if (expenseController.errorMessage.isNotEmpty) { return Center( - child: Text( + child: MyText.bodyMedium( expenseController.errorMessage.value, - style: const TextStyle(color: Colors.red), + color: Colors.red, ), ); } if (expenseController.expenses.isEmpty) { - return const Center(child: Text("No expenses found.")); + return Center(child: MyText.bodyMedium("No expenses found.")); } - // Apply search filter final filteredList = expenseController.expenses.where((expense) { final query = searchQuery.value.toLowerCase(); @@ -76,7 +75,6 @@ class _ExpenseMainScreenState extends State { filteredList.sort( (a, b) => b.transactionDate.compareTo(a.transactionDate)); - // Split into current month and history final now = DateTime.now(); final currentMonthList = filteredList .where((e) => @@ -117,23 +115,23 @@ class _ExpenseMainScreenState extends State { child: Wrap( runSpacing: 10, children: [ - const Text( + MyText.bodyLarge( 'Filter Expenses', - style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + fontWeight: 700, ), ListTile( leading: const Icon(Icons.date_range), - title: const Text('Date Range'), + title: MyText.bodyMedium('Date Range'), onTap: () {}, ), ListTile( leading: const Icon(Icons.work_outline), - title: const Text('Project'), + title: MyText.bodyMedium('Project'), onTap: () {}, ), ListTile( leading: const Icon(Icons.check_circle_outline), - title: const Text('Status'), + title: MyText.bodyMedium('Status'), onTap: () {}, ), ], @@ -264,7 +262,7 @@ class _SearchAndFilter extends StatelessWidget { MySpacing.width(8), IconButton( icon: const Icon(Icons.tune, color: Colors.black), - onPressed: null, + onPressed: null, // Disabled as per request ), ], ), @@ -349,13 +347,11 @@ class _ToggleButton extends StatelessWidget { Icon(icon, size: 16, color: selected ? Colors.white : Colors.grey), const SizedBox(width: 6), - Text( + MyText.bodyMedium( label, - style: TextStyle( - color: selected ? Colors.white : Colors.grey, - fontWeight: FontWeight.w600, - fontSize: 13, - ), + color: selected ? Colors.white : Colors.grey, + fontWeight: 600, + fontSize: 13, ), ], ), @@ -371,45 +367,27 @@ class _ExpenseList extends StatelessWidget { const _ExpenseList({required this.expenseList}); - static Color _getStatusColor(String status) { - switch (status) { - case 'Requested': - return Colors.blue; - case 'Review': - return Colors.orange; - case 'Approved': - return Colors.green; - case 'Paid': - return Colors.purple; - case 'Closed': - return Colors.grey; - default: - return Colors.black; - } - } - @override Widget build(BuildContext context) { if (expenseList.isEmpty) { - return const Center(child: Text('No expenses found.')); + return Center(child: MyText.bodyMedium('No expenses found.')); } return ListView.separated( - padding: const EdgeInsets.fromLTRB(12, 12, 12, 80), + padding: const EdgeInsets.fromLTRB(12, 12, 12, 80), itemCount: expenseList.length, separatorBuilder: (_, __) => Divider(color: Colors.grey.shade300, height: 20), itemBuilder: (context, index) { final expense = expenseList[index]; - final statusColor = _getStatusColor(expense.status.name); - // Convert UTC date to local formatted string final formattedDate = DateTimeUtils.convertUtcToLocal( expense.transactionDate.toIso8601String(), format: 'dd MMM yyyy, hh:mm a', ); return GestureDetector( + behavior: HitTestBehavior.opaque, onTap: () => Get.to( () => const ExpenseDetailScreen(), arguments: {'expense': expense}, @@ -419,51 +397,35 @@ class _ExpenseList extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Title + Amount row Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( children: [ - const Icon(Icons.receipt_long, - size: 20, color: Colors.red), - const SizedBox(width: 8), - Text( + MyText.bodyMedium( expense.expensesType.name, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), + fontWeight: 700, ), ], ), - Text( + MyText.bodyMedium( '₹ ${expense.amount.toStringAsFixed(2)}', - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.red, - ), + fontWeight: 600, ), ], ), const SizedBox(height: 6), - - // Date + Status Row( children: [ - Text( + MyText.bodySmall( formattedDate, - style: TextStyle(fontSize: 12, color: Colors.grey[600]), + color: Colors.grey[600], ), const Spacer(), - Text( + MyText.bodySmall( expense.status.name, - style: TextStyle( - color: statusColor, - fontWeight: FontWeight.bold, - fontSize: 12, - ), + fontWeight: 600, + color: Colors.black, ), ], ), From f7352eb3c361db37f25ea2bd46c17c0d57302300 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Mon, 21 Jul 2025 16:12:13 +0530 Subject: [PATCH 05/65] fix(expense): remove unnecessary hit test behavior from expense list item --- lib/view/expense/expense_screen.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/view/expense/expense_screen.dart b/lib/view/expense/expense_screen.dart index 96803c1..edf0eb5 100644 --- a/lib/view/expense/expense_screen.dart +++ b/lib/view/expense/expense_screen.dart @@ -387,7 +387,6 @@ class _ExpenseList extends StatelessWidget { ); return GestureDetector( - behavior: HitTestBehavior.opaque, onTap: () => Get.to( () => const ExpenseDetailScreen(), arguments: {'expense': expense}, @@ -402,6 +401,7 @@ class _ExpenseList extends StatelessWidget { children: [ Row( children: [ + MyText.bodyMedium( expense.expensesType.name, fontWeight: 700, From 4a01371fadb02c9c466c8de14c513a020a11826b Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Mon, 21 Jul 2025 16:22:18 +0530 Subject: [PATCH 06/65] feat(directory): add 'Create Bucket' option to actions menu --- lib/view/directory/directory_view.dart | 35 +++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/lib/view/directory/directory_view.dart b/lib/view/directory/directory_view.dart index 30c8caa..2230a24 100644 --- a/lib/view/directory/directory_view.dart +++ b/lib/view/directory/directory_view.dart @@ -267,7 +267,7 @@ class _DirectoryViewState extends State { itemBuilder: (context) { List> menuItems = []; - // Section: Actions (Always visible now) + // Section: Actions menuItems.add( const PopupMenuItem( enabled: false, @@ -282,6 +282,38 @@ class _DirectoryViewState extends State { ), ); + // Create Bucket option + menuItems.add( + PopupMenuItem( + value: 2, + child: Row( + children: const [ + Icon(Icons.add_box_outlined, + size: 20, color: Colors.black87), + SizedBox(width: 10), + Expanded(child: Text("Create Bucket")), + Icon(Icons.chevron_right, + size: 20, color: Colors.red), + ], + ), + onTap: () { + Future.delayed(Duration.zero, () async { + final created = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => const CreateBucketBottomSheet(), + ); + + if (created == true) { + await controller.fetchBuckets(); + } + }); + }, + ), + ); + + // Manage Buckets option menuItems.add( PopupMenuItem( value: 1, @@ -318,6 +350,7 @@ class _DirectoryViewState extends State { ), ); + // Show Inactive switch menuItems.add( PopupMenuItem( value: 0, From 93cdaab2c21eb7d0d506b11da313fa15ef126597 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Mon, 21 Jul 2025 16:39:11 +0530 Subject: [PATCH 07/65] feat(expense): add refresh functionality to expense list --- lib/view/expense/expense_screen.dart | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/lib/view/expense/expense_screen.dart b/lib/view/expense/expense_screen.dart index edf0eb5..40460eb 100644 --- a/lib/view/expense/expense_screen.dart +++ b/lib/view/expense/expense_screen.dart @@ -26,7 +26,11 @@ class _ExpenseMainScreenState extends State { @override void initState() { super.initState(); - expenseController.fetchExpenses(); // Load expenses from API + expenseController.fetchExpenses(); + } + + void _refreshExpenses() { + expenseController.fetchExpenses(); } @override @@ -41,6 +45,7 @@ class _ExpenseMainScreenState extends State { searchController: searchController, onChanged: (value) => searchQuery.value = value, onFilterTap: _openFilterBottomSheet, + onRefreshTap: _refreshExpenses, ), _ToggleButtons(isHistoryView: isHistoryView), Expanded( @@ -217,15 +222,18 @@ class _ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget { } // Search and Filter Widget + class _SearchAndFilter extends StatelessWidget { final TextEditingController searchController; final ValueChanged onChanged; final VoidCallback onFilterTap; + final VoidCallback onRefreshTap; const _SearchAndFilter({ required this.searchController, required this.onChanged, required this.onFilterTap, + required this.onRefreshTap, }); @override @@ -260,6 +268,18 @@ class _SearchAndFilter extends StatelessWidget { ), ), MySpacing.width(8), + Tooltip( + message: 'Refresh Data', + child: InkWell( + borderRadius: BorderRadius.circular(24), + onTap: onRefreshTap, + child: const Padding( + padding: EdgeInsets.all(0), + child: Icon(Icons.refresh, color: Colors.green, size: 28), + ), + ), + ), + MySpacing.width(8), IconButton( icon: const Icon(Icons.tune, color: Colors.black), onPressed: null, // Disabled as per request @@ -401,7 +421,6 @@ class _ExpenseList extends StatelessWidget { children: [ Row( children: [ - MyText.bodyMedium( expense.expensesType.name, fontWeight: 700, From d0f42da30f45bae97408f7138aa3483b03f6ccb2 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Mon, 21 Jul 2025 18:35:27 +0530 Subject: [PATCH 08/65] feat(expense): implement update expense status functionality and UI integration --- .../expense/expense_screen_controller.dart | 30 ++++++++ lib/helpers/services/api_endpoints.dart | 2 +- lib/helpers/services/api_service.dart | 45 ++++++++++++ lib/view/expense/expense_detail_screen.dart | 70 +++++++++++++++++-- 4 files changed, 141 insertions(+), 6 deletions(-) diff --git a/lib/controller/expense/expense_screen_controller.dart b/lib/controller/expense/expense_screen_controller.dart index 6568aa3..9359c21 100644 --- a/lib/controller/expense/expense_screen_controller.dart +++ b/lib/controller/expense/expense_screen_controller.dart @@ -39,4 +39,34 @@ class ExpenseController extends GetxController { isLoading.value = false; } } + + /// Update expense status and refresh the list + Future updateExpenseStatus(String expenseId, String statusId) async { + isLoading.value = true; + errorMessage.value = ''; + + try { + logSafe("Updating status for expense: $expenseId -> $statusId"); + final success = await ApiService.updateExpenseStatusApi( + expenseId: expenseId, + statusId: statusId, + ); + + if (success) { + logSafe("Expense status updated successfully."); + await fetchExpenses(); + return true; + } else { + errorMessage.value = "Failed to update expense status."; + return false; + } + } catch (e, stack) { + errorMessage.value = 'An unexpected error occurred.'; + logSafe("Exception in updateExpenseStatus: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + return false; + } finally { + isLoading.value = false; + } + } } diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index 0b0bfef..46548d4 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -56,5 +56,5 @@ class ApiEndpoints { static const String getMasterPaymentModes = "/master/payment-modes"; static const String getMasterExpenseStatus = "/master/expenses-status"; static const String getMasterExpenseTypes = "/master/expenses-types"; - + static const String updateExpenseStatus = "/expense/action"; } diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 7442c32..c8428b0 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -241,6 +241,51 @@ class ApiService { // === Expense APIs === // + /// Update Expense Status API + static Future updateExpenseStatusApi({ + required String expenseId, + required String statusId, + }) async { + final payload = { + "expenseId": expenseId, + "statusId": statusId, + }; + + const endpoint = ApiEndpoints.updateExpenseStatus; + logSafe("Updating expense status with payload: $payload"); + + try { + final response = + await _postRequest(endpoint, payload, customTimeout: extendedTimeout); + + if (response == null) { + logSafe("Update expense status failed: null response", + level: LogLevel.error); + return false; + } + + logSafe("Update expense status response status: ${response.statusCode}"); + logSafe("Update expense status response body: ${response.body}"); + + final json = jsonDecode(response.body); + if (json['success'] == true) { + logSafe("Expense status updated successfully: ${json['data']}"); + return true; + } else { + logSafe( + "Failed to update expense status: ${json['message'] ?? 'Unknown error'}", + level: LogLevel.warning, + ); + } + } catch (e, stack) { + logSafe("Exception during updateExpenseStatus API: $e", + level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return false; + } + static Future?> getExpenseListApi() async { const endpoint = ApiEndpoints.getExpenseList; diff --git a/lib/view/expense/expense_detail_screen.dart b/lib/view/expense/expense_detail_screen.dart index 951e5d6..d5450ab 100644 --- a/lib/view/expense/expense_detail_screen.dart +++ b/lib/view/expense/expense_detail_screen.dart @@ -1,10 +1,11 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/controller/project_controller.dart'; +import 'package:marco/controller/expense/expense_screen_controller.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/model/expense/expense_list_model.dart'; -import 'package:marco/helpers/utils/date_time_utils.dart'; // Import DateTimeUtils +import 'package:marco/helpers/utils/date_time_utils.dart'; class ExpenseDetailScreen extends StatelessWidget { const ExpenseDetailScreen({super.key}); @@ -31,6 +32,9 @@ class ExpenseDetailScreen extends StatelessWidget { final ExpenseModel expense = Get.arguments['expense'] as ExpenseModel; final statusColor = getStatusColor(expense.status.name); final projectController = Get.find(); + final expenseController = Get.find(); + print( + "Next Status List: ${expense.nextStatus.map((e) => e.toJson()).toList()}"); return Scaffold( backgroundColor: const Color(0xFFF7F7F7), @@ -110,6 +114,64 @@ class ExpenseDetailScreen extends StatelessWidget { ], ), ), + bottomNavigationBar: expense.nextStatus.isNotEmpty + ? SafeArea( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.center, + children: expense.nextStatus.map((next) { + return ElevatedButton( + style: ElevatedButton.styleFrom( + minimumSize: const Size(100, 40), + padding: const EdgeInsets.symmetric( + vertical: 8, horizontal: 12), + backgroundColor: Colors.red, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6), + ), + ), + onPressed: () async { + final success = + await expenseController.updateExpenseStatus( + expense.id, + next.id, + ); + + if (success) { + Get.snackbar( + 'Success', + 'Expense moved to ${next.name}', + backgroundColor: Colors.green.withOpacity(0.8), + colorText: Colors.white, + ); + Get.back(result: true); + } else { + Get.snackbar( + 'Error', + 'Failed to update status.', + backgroundColor: Colors.red.withOpacity(0.8), + colorText: Colors.white, + ); + } + }, + child: Text( + next.name, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 14, + ), + overflow: TextOverflow.ellipsis, + ), + ); + }).toList(), + ), + ), + ) + : null, ); } } @@ -228,8 +290,7 @@ class _ExpenseDetailsList extends StatelessWidget { _DetailRow(title: "Payment Mode", value: expense.paymentMode.name), _DetailRow( title: "Paid By", - value: - '${expense.paidBy.firstName} ${expense.paidBy.lastName}'), + value: '${expense.paidBy.firstName} ${expense.paidBy.lastName}'), _DetailRow( title: "Created By", value: @@ -243,8 +304,7 @@ class _ExpenseDetailsList extends StatelessWidget { title: "Next Status", value: expense.nextStatus.map((e) => e.name).join(", ")), _DetailRow( - title: "Pre-Approved", - value: expense.preApproved ? "Yes" : "No"), + title: "Pre-Approved", value: expense.preApproved ? "Yes" : "No"), ], ), ); From 0e1b6e2a8c3a548e6310267ed547bfbad4433e4c Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Tue, 22 Jul 2025 13:01:07 +0530 Subject: [PATCH 09/65] feat(expense): implement expense filtering functionality with UI integration --- .../expense/add_expense_controller.dart | 2 +- .../expense/expense_screen_controller.dart | 148 ++++++++- lib/helpers/services/api_service.dart | 46 ++- lib/helpers/utils/date_time_utils.dart | 14 +- lib/model/expense/expense_list_model.dart | 16 +- .../expense/expense_filter_bottom_sheet.dart | 304 ++++++++++++++++++ lib/view/expense/expense_screen.dart | 43 +-- 7 files changed, 525 insertions(+), 48 deletions(-) create mode 100644 lib/view/expense/expense_filter_bottom_sheet.dart diff --git a/lib/controller/expense/add_expense_controller.dart b/lib/controller/expense/add_expense_controller.dart index 9073035..32c0fa2 100644 --- a/lib/controller/expense/add_expense_controller.dart +++ b/lib/controller/expense/add_expense_controller.dart @@ -119,7 +119,7 @@ class AddExpenseController extends GetxController { } catch (e) { Get.snackbar("Error", "Failed to fetch master data: $e"); } - } + } // === Fetch Current Location === Future fetchCurrentLocation() async { diff --git a/lib/controller/expense/expense_screen_controller.dart b/lib/controller/expense/expense_screen_controller.dart index 9359c21..39dd724 100644 --- a/lib/controller/expense/expense_screen_controller.dart +++ b/lib/controller/expense/expense_screen_controller.dart @@ -1,28 +1,86 @@ +import 'dart:convert'; import 'package:get/get.dart'; import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/model/expense/expense_list_model.dart'; +import 'package:marco/model/expense/payment_types_model.dart'; +import 'package:marco/model/expense/expense_type_model.dart'; +import 'package:marco/model/expense/expense_status_model.dart'; +import 'package:marco/model/employee_model.dart'; class ExpenseController extends GetxController { final RxList expenses = [].obs; final RxBool isLoading = false.obs; final RxString errorMessage = ''.obs; + final RxList expenseTypes = [].obs; + final RxList paymentModes = [].obs; + final RxList expenseStatuses = [].obs; + final RxList globalProjects = [].obs; + final RxMap projectsMap = {}.obs; + RxList allEmployees = [].obs; - /// Fetch all expenses from API - Future fetchExpenses() async { + int _pageSize = 20; + int _pageNumber = 1; + + @override + void onInit() { + super.onInit(); + loadInitialMasterData(); + fetchAllEmployees(); + } + + /// Load projects, expense types, statuses, and payment modes on controller init + Future loadInitialMasterData() async { + await fetchGlobalProjects(); + await fetchMasterData(); + } + + /// Fetch expenses with filters and pagination (called explicitly when needed) + Future fetchExpenses({ + List? projectIds, + List? statusIds, + List? createdByIds, + List? paidByIds, + DateTime? startDate, + DateTime? endDate, + int pageSize = 20, + int pageNumber = 1, + }) async { isLoading.value = true; errorMessage.value = ''; + _pageSize = pageSize; + _pageNumber = pageNumber; + + final Map filterMap = { + "projectIds": projectIds ?? [], + "statusIds": statusIds ?? [], + "createdByIds": createdByIds ?? [], + "paidByIds": paidByIds ?? [], + "startDate": startDate?.toIso8601String(), + "endDate": endDate?.toIso8601String(), + }; + try { - final result = await ApiService.getExpenseListApi(); + logSafe("Fetching expenses with filter: ${jsonEncode(filterMap)}"); + + final result = await ApiService.getExpenseListApi( + filter: jsonEncode(filterMap), + pageSize: _pageSize, + pageNumber: _pageNumber, + ); if (result != null) { try { - // Convert the raw result (List) to List - final List parsed = List.from( - result.map((e) => ExpenseModel.fromJson(e))); + final List rawList = result['expenses'] ?? []; + final parsed = rawList + .map((e) => ExpenseModel.fromJson(e as Map)) + .toList(); + expenses.assignAll(parsed); logSafe("Expenses loaded: ${parsed.length}"); + logSafe( + "Pagination Info: Page ${result['currentPage']} of ${result['totalPages']} | Total: ${result['totalEntites']}"); } catch (e) { errorMessage.value = 'Failed to parse expenses: $e'; logSafe("Parse error in fetchExpenses: $e", level: LogLevel.error); @@ -40,6 +98,82 @@ class ExpenseController extends GetxController { } } + /// Fetch master data: expense types, payment modes, and expense status + Future fetchMasterData() async { + try { + final expenseTypesData = await ApiService.getMasterExpenseTypes(); + if (expenseTypesData is List) { + expenseTypes.value = + expenseTypesData.map((e) => ExpenseTypeModel.fromJson(e)).toList(); + } + + final paymentModesData = await ApiService.getMasterPaymentModes(); + if (paymentModesData is List) { + paymentModes.value = + paymentModesData.map((e) => PaymentModeModel.fromJson(e)).toList(); + } + + final expenseStatusData = await ApiService.getMasterExpenseStatus(); + if (expenseStatusData is List) { + expenseStatuses.value = expenseStatusData + .map((e) => ExpenseStatusModel.fromJson(e)) + .toList(); + } + } catch (e) { + Get.snackbar("Error", "Failed to fetch master data: $e"); + } + } + + /// Fetch list of all global projects + Future fetchGlobalProjects() async { + try { + final response = await ApiService.getGlobalProjects(); + if (response != null) { + final names = []; + for (var item in response) { + final name = item['name']?.toString().trim(); + final id = item['id']?.toString().trim(); + if (name != null && id != null && name.isNotEmpty) { + projectsMap[name] = id; + names.add(name); + } + } + globalProjects.assignAll(names); + logSafe("Fetched ${names.length} global projects"); + } + } catch (e) { + logSafe("Failed to fetch global projects: $e", level: LogLevel.error); + } + } + + /// Fetch all employees for Manage Bucket usage + Future fetchAllEmployees() async { + isLoading.value = true; + + try { + final response = await ApiService.getAllEmployees(); + if (response != null && response.isNotEmpty) { + allEmployees + .assignAll(response.map((json) => EmployeeModel.fromJson(json))); + logSafe( + "All Employees fetched for Manage Bucket: ${allEmployees.length}", + level: LogLevel.info, + ); + } else { + allEmployees.clear(); + logSafe("No employees found for Manage Bucket.", + level: LogLevel.warning); + } + } catch (e) { + allEmployees.clear(); + logSafe("Error fetching employees in Manage Bucket", + level: LogLevel.error, error: e); + } + + isLoading.value = false; + update(); + } + /// Update expense status and refresh the list Future updateExpenseStatus(String expenseId, String statusId) async { isLoading.value = true; @@ -54,7 +188,7 @@ class ExpenseController extends GetxController { if (success) { logSafe("Expense status updated successfully."); - await fetchExpenses(); + await fetchExpenses(); return true; } else { errorMessage.value = "Failed to update expense status."; diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index c8428b0..cb2a84b 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -286,16 +286,52 @@ class ApiService { return false; } - static Future?> getExpenseListApi() async { - const endpoint = ApiEndpoints.getExpenseList; + static Future?> getExpenseListApi({ + String? filter, + int pageSize = 20, + int pageNumber = 1, + }) async { + // Build the endpoint with query parameters + String endpoint = ApiEndpoints.getExpenseList; + final queryParams = { + 'pageSize': pageSize.toString(), + 'pageNumber': pageNumber.toString(), + }; - logSafe("Fetching expense list..."); + if (filter != null && filter.isNotEmpty) { + queryParams['filter'] = filter; + } + + // Build the full URI + final uri = Uri.parse(endpoint).replace(queryParameters: queryParams); + logSafe("Fetching expense list with URI: $uri"); try { - final response = await _getRequest(endpoint); + final response = await _getRequest(uri.toString()); if (response == null) return null; - return _parseResponse(response, label: 'Expense List'); + final parsed = _parseResponseForAllData(response, label: 'Expense List'); + + if (parsed != null && parsed['data'] is Map) { + final dataObj = parsed['data'] as Map; + + if (dataObj['data'] is List) { + return { + 'currentPage': dataObj['currentPage'] ?? 1, + 'totalPages': dataObj['totalPages'] ?? 1, + 'totalEntites': dataObj['totalEntites'] ?? 0, + 'expenses': List.from(dataObj['data']), + }; + } else { + logSafe("Expense list 'data' is not a list: $dataObj", + level: LogLevel.error); + return null; + } + } else { + logSafe("Unexpected response structure: $parsed", + level: LogLevel.error); + return null; + } } catch (e, stack) { logSafe("Exception during getExpenseListApi: $e", level: LogLevel.error); logSafe("StackTrace: $stack", level: LogLevel.debug); diff --git a/lib/helpers/utils/date_time_utils.dart b/lib/helpers/utils/date_time_utils.dart index 2c6a732..1dca3da 100644 --- a/lib/helpers/utils/date_time_utils.dart +++ b/lib/helpers/utils/date_time_utils.dart @@ -1,7 +1,8 @@ import 'package:intl/intl.dart'; -import 'package:marco/helpers/services/app_logger.dart'; +import 'package:marco/helpers/services/app_logger.dart'; class DateTimeUtils { + /// Converts a UTC datetime string to local time and formats it. static String convertUtcToLocal(String utcTimeString, {String format = 'dd-MM-yyyy'}) { try { logSafe('convertUtcToLocal: input="$utcTimeString", format="$format"'); @@ -32,6 +33,17 @@ class DateTimeUtils { } } + /// Public utility for formatting any DateTime. + static String formatDate(DateTime date, String format) { + try { + return DateFormat(format).format(date); + } catch (e, stackTrace) { + logSafe('formatDate failed: $e', error: e, stackTrace: stackTrace); + return 'Invalid Date'; + } + } + + /// Internal formatter with default format. static String _formatDateTime(DateTime dateTime, {String format = 'dd-MM-yyyy'}) { return DateFormat(format).format(dateTime); } diff --git a/lib/model/expense/expense_list_model.dart b/lib/model/expense/expense_list_model.dart index 451ba72..b23468c 100644 --- a/lib/model/expense/expense_list_model.dart +++ b/lib/model/expense/expense_list_model.dart @@ -1,7 +1,11 @@ import 'dart:convert'; -List expenseModelFromJson(String str) => List.from( - json.decode(str).map((x) => ExpenseModel.fromJson(x))); +List expenseModelFromJson(String str) { + final jsonData = json.decode(str); + return List.from( + jsonData["data"]["data"].map((x) => ExpenseModel.fromJson(x)) + ); +} String expenseModelToJson(List data) => json.encode(List.from(data.map((x) => x.toJson()))); @@ -242,27 +246,35 @@ class CreatedBy { class Status { final String id; final String name; + final String displayName; final String description; + final String color; final bool isSystem; Status({ required this.id, required this.name, + required this.displayName, required this.description, + required this.color, required this.isSystem, }); factory Status.fromJson(Map json) => Status( id: json["id"], name: json["name"], + displayName: json["displayName"], description: json["description"], + color: json["color"], isSystem: json["isSystem"], ); Map toJson() => { "id": id, "name": name, + "displayName": displayName, "description": description, + "color": color, "isSystem": isSystem, }; } diff --git a/lib/view/expense/expense_filter_bottom_sheet.dart b/lib/view/expense/expense_filter_bottom_sheet.dart new file mode 100644 index 0000000..0165940 --- /dev/null +++ b/lib/view/expense/expense_filter_bottom_sheet.dart @@ -0,0 +1,304 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/controller/expense/expense_screen_controller.dart'; +import 'package:marco/helpers/utils/date_time_utils.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/model/employee_model.dart'; + +class ExpenseFilterBottomSheet extends StatelessWidget { + final ExpenseController expenseController; + final RxList selectedPaidByEmployees; + final RxList selectedCreatedByEmployees; + + ExpenseFilterBottomSheet({ + super.key, + required this.expenseController, + required this.selectedPaidByEmployees, + required this.selectedCreatedByEmployees, + }); + + final RxString selectedProject = ''.obs; + final Rx startDate = Rx(null); + final Rx endDate = Rx(null); + final RxString selectedEmployee = ''.obs; + + @override + Widget build(BuildContext context) { + return Obx(() { + return Container( + padding: const EdgeInsets.all(16), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + MyText.titleLarge('Filter Expenses', fontWeight: 700), + const SizedBox(height: 16), + + /// Project Filter + MyText.bodyMedium('Project', fontWeight: 600), + const SizedBox(height: 6), + DropdownButtonFormField( + value: selectedProject.value.isEmpty + ? null + : selectedProject.value, + items: expenseController.globalProjects + .map((proj) => DropdownMenuItem( + value: proj, + child: Text(proj), + )) + .toList(), + decoration: InputDecoration( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + hint: const Text('Select Project'), + onChanged: (value) { + selectedProject.value = value ?? ''; + }, + ), + + const SizedBox(height: 16), + + /// Date Range Filter + MyText.bodyMedium('Date Range', fontWeight: 600), + const SizedBox(height: 6), + Row( + children: [ + Expanded( + child: _DatePickerField( + label: startDate.value == null + ? 'Start Date' + : DateTimeUtils.formatDate( + startDate.value!, 'dd MMM yyyy'), + onTap: () async { + DateTime? picked = await showDatePicker( + context: context, + initialDate: startDate.value ?? DateTime.now(), + firstDate: DateTime(2020), + lastDate: + DateTime.now().add(const Duration(days: 365)), + ); + if (picked != null) startDate.value = picked; + }, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _DatePickerField( + label: endDate.value == null + ? 'End Date' + : DateTimeUtils.formatDate( + endDate.value!, 'dd MMM yyyy'), + onTap: () async { + DateTime? picked = await showDatePicker( + context: context, + initialDate: endDate.value ?? DateTime.now(), + firstDate: DateTime(2020), + lastDate: + DateTime.now().add(const Duration(days: 365)), + ); + if (picked != null) endDate.value = picked; + }, + ), + ), + ], + ), + + const SizedBox(height: 16), + + /// Paid By Filter + _employeeFilterSection( + title: 'Paid By', + selectedEmployees: selectedPaidByEmployees, + expenseController: expenseController, + ), + const SizedBox(height: 24), + + /// Created By Filter + _employeeFilterSection( + title: 'Created By', + selectedEmployees: selectedCreatedByEmployees, + expenseController: expenseController, + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.grey.shade300, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onPressed: () => Get.back(), + child: const Text('Cancel'), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onPressed: () { + expenseController.fetchExpenses( + projectIds: selectedProject.value.isEmpty + ? null + : [ + expenseController + .projectsMap[selectedProject.value]! + ], + paidByIds: selectedPaidByEmployees.isEmpty + ? null + : selectedPaidByEmployees.map((e) => e.id).toList(), + createdByIds: selectedCreatedByEmployees.isEmpty + ? null + : selectedCreatedByEmployees + .map((e) => e.id) + .toList(), + startDate: startDate.value, + endDate: endDate.value, + ); + Get.back(); + }, + child: const Text('Apply'), + ), + ], + ), + ], + ), + ), + ); + }); + } + + /// Employee Filter Section + Widget _employeeFilterSection({ + required String title, + required RxList selectedEmployees, + required ExpenseController expenseController, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodyMedium(title, fontWeight: 600), + const SizedBox(height: 6), + Obx(() { + return Wrap( + spacing: 6, + runSpacing: -8, + children: selectedEmployees.map((emp) { + return Chip( + label: Text(emp.name), + deleteIcon: const Icon(Icons.close, size: 18), + onDeleted: () => selectedEmployees.remove(emp), + backgroundColor: Colors.grey.shade200, + ); + }).toList(), + ); + }), + const SizedBox(height: 6), + Autocomplete( + optionsBuilder: (TextEditingValue textEditingValue) { + if (textEditingValue.text.isEmpty) { + return const Iterable.empty(); + } + return expenseController.allEmployees.where((EmployeeModel emp) { + return emp.name + .toLowerCase() + .contains(textEditingValue.text.toLowerCase()); + }); + }, + displayStringForOption: (EmployeeModel emp) => emp.name, + onSelected: (EmployeeModel emp) { + if (!selectedEmployees.contains(emp)) { + selectedEmployees.add(emp); + } + }, + fieldViewBuilder: (context, controller, focusNode, onFieldSubmitted) { + return TextField( + controller: controller, + focusNode: focusNode, + decoration: InputDecoration( + hintText: 'Search Employee', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + ); + }, + optionsViewBuilder: (context, onSelected, options) { + return Align( + alignment: Alignment.topLeft, + child: Material( + color: Colors.white, + elevation: 4.0, + child: SizedBox( + height: 200, + child: ListView.builder( + padding: EdgeInsets.zero, + itemCount: options.length, + itemBuilder: (context, index) { + final emp = options.elementAt(index); + return ListTile( + title: Text(emp.name), + onTap: () => onSelected(emp), + ); + }, + ), + ), + ), + ); + }, + ), + ], + ); + } +} + +/// Date Picker Field +class _DatePickerField extends StatelessWidget { + final String label; + final VoidCallback onTap; + + const _DatePickerField({ + required this.label, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: const TextStyle(color: Colors.black87)), + const Icon(Icons.calendar_today, size: 16, color: Colors.grey), + ], + ), + ), + ); + } +} diff --git a/lib/view/expense/expense_screen.dart b/lib/view/expense/expense_screen.dart index 40460eb..874480a 100644 --- a/lib/view/expense/expense_screen.dart +++ b/lib/view/expense/expense_screen.dart @@ -8,6 +8,8 @@ import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/model/expense/expense_list_model.dart'; import 'package:marco/view/expense/expense_detail_screen.dart'; import 'package:marco/model/expense/add_expense_bottom_sheet.dart'; +import 'package:marco/model/employee_model.dart'; +import 'package:marco/view/expense/expense_filter_bottom_sheet.dart'; class ExpenseMainScreen extends StatefulWidget { const ExpenseMainScreen({super.key}); @@ -22,6 +24,9 @@ class _ExpenseMainScreenState extends State { final RxString searchQuery = ''.obs; final ProjectController projectController = Get.find(); final ExpenseController expenseController = Get.put(ExpenseController()); + final RxList selectedPaidByEmployees = [].obs; + final RxList selectedCreatedByEmployees = + [].obs; @override void initState() { @@ -45,7 +50,7 @@ class _ExpenseMainScreenState extends State { searchController: searchController, onChanged: (value) => searchQuery.value = value, onFilterTap: _openFilterBottomSheet, - onRefreshTap: _refreshExpenses, + onRefreshTap: _refreshExpenses, ), _ToggleButtons(isHistoryView: isHistoryView), Expanded( @@ -111,36 +116,10 @@ class _ExpenseMainScreenState extends State { void _openFilterBottomSheet() { Get.bottomSheet( - Container( - padding: const EdgeInsets.all(16), - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - child: Wrap( - runSpacing: 10, - children: [ - MyText.bodyLarge( - 'Filter Expenses', - fontWeight: 700, - ), - ListTile( - leading: const Icon(Icons.date_range), - title: MyText.bodyMedium('Date Range'), - onTap: () {}, - ), - ListTile( - leading: const Icon(Icons.work_outline), - title: MyText.bodyMedium('Project'), - onTap: () {}, - ), - ListTile( - leading: const Icon(Icons.check_circle_outline), - title: MyText.bodyMedium('Status'), - onTap: () {}, - ), - ], - ), + ExpenseFilterBottomSheet( + expenseController: expenseController, + selectedPaidByEmployees: selectedPaidByEmployees, + selectedCreatedByEmployees: selectedCreatedByEmployees, ), ); } @@ -282,7 +261,7 @@ class _SearchAndFilter extends StatelessWidget { MySpacing.width(8), IconButton( icon: const Icon(Icons.tune, color: Colors.black), - onPressed: null, // Disabled as per request + onPressed: onFilterTap, ), ], ), From b40d371d43573ae0bc0c3bdbd59c4f37d69920e6 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Fri, 18 Jul 2025 17:51:59 +0530 Subject: [PATCH 10/65] feat(expense): add expense management screens and functionality --- .../assign_task_bottom_sheet .dart | 2 +- .../expense/add_expense_bottom_sheet.dart | 319 +++++++++++++ lib/routes.dart | 6 + .../Attendence/attendance_screen.dart | 10 +- lib/view/dashboard/dashboard_screen.dart | 7 +- lib/view/directory/directory_view.dart | 2 +- lib/view/expense/expense_detail_screen.dart | 241 ++++++++++ lib/view/expense/expense_screen.dart | 437 ++++++++++++++++++ 8 files changed, 1015 insertions(+), 9 deletions(-) create mode 100644 lib/model/expense/add_expense_bottom_sheet.dart create mode 100644 lib/view/expense/expense_detail_screen.dart create mode 100644 lib/view/expense/expense_screen.dart diff --git a/lib/model/dailyTaskPlaning/assign_task_bottom_sheet .dart b/lib/model/dailyTaskPlaning/assign_task_bottom_sheet .dart index a61dbac..2e52a8a 100644 --- a/lib/model/dailyTaskPlaning/assign_task_bottom_sheet .dart +++ b/lib/model/dailyTaskPlaning/assign_task_bottom_sheet .dart @@ -135,7 +135,7 @@ class _AssignTaskBottomSheetState extends State { children: [ MyText.titleMedium("Select Team :", fontWeight: 600), const SizedBox(width: 4), - Icon(Icons.filter_alt, + Icon(Icons.tune, color: const Color.fromARGB(255, 95, 132, 255)), ], ), diff --git a/lib/model/expense/add_expense_bottom_sheet.dart b/lib/model/expense/add_expense_bottom_sheet.dart new file mode 100644 index 0000000..3dfd6cb --- /dev/null +++ b/lib/model/expense/add_expense_bottom_sheet.dart @@ -0,0 +1,319 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; + +void showAddExpenseBottomSheet() { + final TextEditingController amountController = TextEditingController(); + final TextEditingController descriptionController = TextEditingController(); + final TextEditingController supplierController = TextEditingController(); + final TextEditingController transactionIdController = TextEditingController(); + final TextEditingController gstController = TextEditingController(); + + String selectedProject = "Select Project"; + String selectedCategory = "Select Expense Type"; + String selectedPaymentMode = "Select Payment Mode"; + String selectedLocation = "Select Location"; + bool preApproved = false; + + Get.bottomSheet( + StatefulBuilder( + builder: (context, setState) { + return SafeArea( + child: Padding( + padding: + const EdgeInsets.only(top: 60), + child: Material( + color: Colors.white, + borderRadius: + const BorderRadius.vertical(top: Radius.circular(20)), + child: Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height - 60, + ), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Drag Handle + Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.grey.shade400, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + + // Title + Center( + child: MyText.titleLarge( + "Add Expense", + fontWeight: 700, + ), + ), + const SizedBox(height: 20), + + // Project + _sectionTitle(Icons.work_outline, "Project"), + const SizedBox(height: 6), + _dropdownTile( + title: selectedProject, + onTap: () { + setState(() { + selectedProject = "Project A"; + }); + }, + ), + const SizedBox(height: 16), + + // Expense Type + GST + _sectionTitle( + Icons.category_outlined, "Expense Type & GST No."), + const SizedBox(height: 6), + _dropdownTile( + title: selectedCategory, + onTap: () { + setState(() { + selectedCategory = "Travel Expense"; + }); + }, + ), + const SizedBox(height: 8), + _customTextField( + controller: gstController, + hint: "Enter GST No.", + ), + const SizedBox(height: 16), + + // Payment Mode + _sectionTitle(Icons.payment, "Payment Mode"), + const SizedBox(height: 6), + _dropdownTile( + title: selectedPaymentMode, + onTap: () { + setState(() { + selectedPaymentMode = "UPI"; + }); + }, + ), + const SizedBox(height: 16), + + // Paid By + _sectionTitle(Icons.person, "Paid By (Employee)"), + const SizedBox(height: 6), + _dropdownTile( + title: "Self (Default)", + onTap: () {}, + ), + const SizedBox(height: 16), + + // Transaction Date + _sectionTitle(Icons.calendar_today, "Transaction Date"), + const SizedBox(height: 6), + _dropdownTile( + title: "Select Date & Time", + onTap: () async { + // Add date/time picker + }, + ), + const SizedBox(height: 16), + + // Amount + _sectionTitle(Icons.currency_rupee, "Amount"), + const SizedBox(height: 6), + _customTextField( + controller: amountController, + hint: "Enter Amount", + keyboardType: TextInputType.number, + ), + const SizedBox(height: 16), + + // Supplier + _sectionTitle(Icons.store_mall_directory, + "Supplier Name / Expense Done At"), + const SizedBox(height: 6), + _customTextField( + controller: supplierController, + hint: "Enter Supplier Name", + ), + const SizedBox(height: 16), + + // Location + _sectionTitle(Icons.location_on_outlined, "Location"), + const SizedBox(height: 6), + _dropdownTile( + title: selectedLocation, + onTap: () { + setState(() { + selectedLocation = "Pune"; + }); + }, + ), + const SizedBox(height: 16), + + // Description + _sectionTitle(Icons.description_outlined, "Description"), + const SizedBox(height: 6), + _customTextField( + controller: descriptionController, + hint: "Enter Description", + maxLines: 3, + ), + const SizedBox(height: 16), + + // Bill Attachment + _sectionTitle(Icons.attachment, "Bill Attachment"), + const SizedBox(height: 6), + OutlinedButton.icon( + onPressed: () {}, + icon: const Icon(Icons.upload_file), + label: const Text("Upload Bill"), + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + const SizedBox(height: 16), + + // Transaction ID + _sectionTitle( + Icons.confirmation_num_outlined, "Transaction ID"), + const SizedBox(height: 6), + _customTextField( + controller: transactionIdController, + hint: "Enter Transaction ID", + ), + const SizedBox(height: 16), + + // Pre-Approved Switch + Row( + children: [ + Switch( + value: preApproved, + onChanged: (val) => + setState(() => preApproved = val), + activeColor: Colors.red, + ), + const SizedBox(width: 8), + const Text("Pre-Approved?"), + ], + ), + const SizedBox(height: 24), + + // Action Buttons + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () => Get.back(), + icon: const Icon(Icons.close, size: 18), + label: + MyText.bodyMedium("Cancel", fontWeight: 600), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Colors.grey), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + onPressed: () { + // Handle Save + Get.back(); + }, + icon: const Icon(Icons.check, size: 18), + label: MyText.bodyMedium( + "Submit", + color: Colors.white, + fontWeight: 600, + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.indigo, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ); + }, + ), + isScrollControlled: true, + ); +} + +/// Section Title +Widget _sectionTitle(IconData icon, String title) { + return Row( + children: [ + Icon(icon, color: Colors.grey[700], size: 18), + const SizedBox(width: 8), + MyText.bodyMedium(title, fontWeight: 600), + ], + ); +} + +/// Custom TextField +Widget _customTextField({ + required TextEditingController controller, + required String hint, + int maxLines = 1, + TextInputType keyboardType = TextInputType.text, +}) { + return TextField( + controller: controller, + maxLines: maxLines, + keyboardType: keyboardType, + decoration: InputDecoration( + hintText: hint, + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + ), + ); +} + +/// Dropdown Tile +Widget _dropdownTile({required String title, required VoidCallback onTap}) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + title, + style: const TextStyle(fontSize: 14, color: Colors.black87), + overflow: TextOverflow.ellipsis, + ), + ), + const Icon(Icons.arrow_drop_down), + ], + ), + ), + ); +} diff --git a/lib/routes.dart b/lib/routes.dart index 391eeb9..4f27766 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -17,6 +17,7 @@ import 'package:marco/view/auth/login_option_screen.dart'; import 'package:marco/view/auth/mpin_screen.dart'; import 'package:marco/view/auth/mpin_auth_screen.dart'; import 'package:marco/view/directory/directory_main_screen.dart'; +import 'package:marco/view/expense/expense_screen.dart'; class AuthMiddleware extends GetMiddleware { @override @@ -65,6 +66,11 @@ getPageRoute() { name: '/dashboard/directory-main-page', page: () => DirectoryMainScreen(), middlewares: [AuthMiddleware()]), + // Expense + GetPage( + name: '/dashboard/expense-main-page', + page: () => ExpenseMainScreen(), + middlewares: [AuthMiddleware()]), // Authentication GetPage(name: '/auth/login', page: () => LoginScreen()), GetPage(name: '/auth/login-option', page: () => LoginOptionScreen()), diff --git a/lib/view/dashboard/Attendence/attendance_screen.dart b/lib/view/dashboard/Attendence/attendance_screen.dart index 12d8e34..1f019ef 100644 --- a/lib/view/dashboard/Attendence/attendance_screen.dart +++ b/lib/view/dashboard/Attendence/attendance_screen.dart @@ -173,7 +173,7 @@ class _AttendanceScreenState extends State with UIMixin { final selectedProjectId = Get.find() .selectedProjectId - ?.value; + .value; final selectedView = result['selectedTab'] as String?; @@ -208,9 +208,9 @@ class _AttendanceScreenState extends State with UIMixin { child: Padding( padding: const EdgeInsets.all(8.0), child: Icon( - Icons.filter_list_alt, + Icons.tune, color: Colors.blueAccent, - size: 28, + size: 20, ), ), ), @@ -225,7 +225,7 @@ class _AttendanceScreenState extends State with UIMixin { onTap: () async { final projectId = Get.find() .selectedProjectId - ?.value; + .value; if (projectId != null && projectId.isNotEmpty) { try { await attendanceController @@ -244,7 +244,7 @@ class _AttendanceScreenState extends State with UIMixin { child: Icon( Icons.refresh, color: Colors.green, - size: 28, + size: 22, ), ), ), diff --git a/lib/view/dashboard/dashboard_screen.dart b/lib/view/dashboard/dashboard_screen.dart index 99274eb..32c1c18 100644 --- a/lib/view/dashboard/dashboard_screen.dart +++ b/lib/view/dashboard/dashboard_screen.dart @@ -22,9 +22,10 @@ class DashboardScreen extends StatefulWidget { static const String attendanceRoute = "/dashboard/attendance"; static const String tasksRoute = "/dashboard/daily-task"; static const String dailyTasksRoute = "/dashboard/daily-task-planing"; - static const String dailyTasksProgressRoute = - "/dashboard/daily-task-progress"; + static const String dailyTasksProgressRoute = "/dashboard/daily-task-progress"; static const String directoryMainPageRoute = "/dashboard/directory-main-page"; + static const String expenseMainPageRoute = "/dashboard/expense-main-page"; + @override State createState() => _DashboardScreenState(); @@ -96,6 +97,8 @@ class _DashboardScreenState extends State with UIMixin { DashboardScreen.dailyTasksProgressRoute), _StatItem(LucideIcons.folder, "Directory", contentTheme.info, DashboardScreen.directoryMainPageRoute), + _StatItem(LucideIcons.badge_dollar_sign, "Expense", contentTheme.info, + DashboardScreen.expenseMainPageRoute), ]; return GetBuilder( diff --git a/lib/view/directory/directory_view.dart b/lib/view/directory/directory_view.dart index 5e1bcd3..30c8caa 100644 --- a/lib/view/directory/directory_view.dart +++ b/lib/view/directory/directory_view.dart @@ -213,7 +213,7 @@ class _DirectoryViewState extends State { borderRadius: BorderRadius.circular(10), ), child: IconButton( - icon: Icon(Icons.filter_alt_outlined, + icon: Icon(Icons.tune, size: 20, color: isFilterActive ? Colors.indigo diff --git a/lib/view/expense/expense_detail_screen.dart b/lib/view/expense/expense_detail_screen.dart new file mode 100644 index 0000000..6ad1194 --- /dev/null +++ b/lib/view/expense/expense_detail_screen.dart @@ -0,0 +1,241 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/controller/project_controller.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; + +class ExpenseDetailScreen extends StatelessWidget { + const ExpenseDetailScreen({super.key}); + + Color _getStatusColor(String status) { + switch (status) { + case 'Request': + return Colors.blue; + case 'Review': + return Colors.orange; + case 'Approved': + return Colors.green; + case 'Paid': + return Colors.purple; + case 'Closed': + return Colors.grey; + default: + return Colors.black; + } + } + + @override + Widget build(BuildContext context) { + final Map expense = + Get.arguments['expense'] as Map; + final Color statusColor = _getStatusColor(expense['status']!); + final ProjectController projectController = Get.find(); + + return Scaffold( + backgroundColor: const Color(0xFFF7F7F7), + appBar: PreferredSize( + preferredSize: const Size.fromHeight(72), + child: AppBar( + backgroundColor: Colors.white, + elevation: 1, + automaticallyImplyLeading: false, + titleSpacing: 0, + title: Padding( + padding: MySpacing.xy(16, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + IconButton( + icon: const Icon(Icons.arrow_back_ios_new, + color: Colors.black, size: 20), + onPressed: () => Get.back(), + ), + MySpacing.width(8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + MyText.titleLarge( + 'Expense Details', + fontWeight: 700, + color: Colors.black, + ), + MySpacing.height(2), + Obx(() { + final projectName = + projectController.selectedProject?.name ?? + 'Select Project'; + return InkWell( + onTap: () => Get.toNamed('/project-selector'), + child: Row( + children: [ + const Icon(Icons.work_outline, + size: 14, color: Colors.grey), + MySpacing.width(4), + Expanded( + child: MyText.bodySmall( + projectName, + fontWeight: 600, + overflow: TextOverflow.ellipsis, + color: Colors.grey[700], + ), + ), + ], + ), + ); + }), + ], + ), + ), + ], + ), + ), + ), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Card( + elevation: 6, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header Section + Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [Color(0xFFFF4B2B), Color(0xFFFF416C)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + expense['title'] ?? 'N/A', + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + const SizedBox(height: 6), + Text( + expense['amount'] ?? '₹ 0', + style: const TextStyle( + fontSize: 26, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.15), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.flag, size: 16, color: statusColor), + const SizedBox(width: 6), + Text( + expense['status'] ?? 'N/A', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + ), + + // Details Section + Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _detailRow(Icons.calendar_today, "Date & Time", + expense['date'] ?? 'N/A'), + _detailRow(Icons.category_outlined, "Expense Type", + "${expense['category']} (GST: ${expense['gstNo'] ?? 'N/A'})"), + _detailRow(Icons.payment, "Payment Mode", + expense['paymentMode'] ?? 'N/A'), + _detailRow(Icons.person, "Paid By", + expense['paidBy'] ?? 'N/A'), + _detailRow(Icons.access_time, "Transaction Date", + expense['transactionDate'] ?? 'N/A'), + _detailRow(Icons.location_on_outlined, "Location", + expense['location'] ?? 'N/A'), + _detailRow(Icons.store, "Supplier Name", + expense['supplierName'] ?? 'N/A'), + _detailRow(Icons.confirmation_num_outlined, + "Transaction ID", expense['transactionId'] ?? 'N/A'), + _detailRow(Icons.description, "Description", + expense['description'] ?? 'N/A'), + ], + ), + ), + ], + ), + ), + ), + ); + } + + Widget _detailRow(IconData icon, String title, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, size: 20, color: Colors.grey[800]), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 13, + color: Colors.grey, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + Text( + value, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/view/expense/expense_screen.dart b/lib/view/expense/expense_screen.dart new file mode 100644 index 0000000..f2d0217 --- /dev/null +++ b/lib/view/expense/expense_screen.dart @@ -0,0 +1,437 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/controller/project_controller.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/model/expense/add_expense_bottom_sheet.dart'; +import 'package:marco/view/expense/expense_detail_screen.dart'; + +class ExpenseMainScreen extends StatefulWidget { + const ExpenseMainScreen({super.key}); + + @override + State createState() => _ExpenseMainScreenState(); +} + +class _ExpenseMainScreenState extends State { + final RxBool isHistoryView = false.obs; + final TextEditingController searchController = TextEditingController(); + final RxString searchQuery = ''.obs; + + final ProjectController projectController = Get.find(); + + final List> expenseList = [ + { + 'title': 'Travel Expense', + 'amount': '₹ 1,500', + 'status': 'Request', + 'date': '12 Jul 2025 • 3:45 PM', + 'category': 'Transport', + 'paymentMode': 'UPI', + 'transactionId': 'TXN123451' + }, + { + 'title': 'Hotel Stay', + 'amount': '₹ 4,500', + 'status': 'Approved', + 'date': '11 Jul 2025 • 9:30 AM', + 'category': 'Accommodation', + 'paymentMode': 'Credit Card', + 'transactionId': 'TXN123452' + }, + { + 'title': 'Food Bill', + 'amount': '₹ 1,200', + 'status': 'Paid', + 'date': '10 Jul 2025 • 7:10 PM', + 'category': 'Food', + 'paymentMode': 'Cash', + 'transactionId': 'TXN123453' + }, + ]; + + Color _getStatusColor(String status) { + switch (status) { + case 'Request': + return Colors.blue; + case 'Review': + return Colors.orange; + case 'Approved': + return Colors.green; + case 'Paid': + return Colors.purple; + case 'Closed': + return Colors.grey; + default: + return Colors.black; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: _buildAppBar(), + body: SafeArea( + child: Column( + children: [ + _buildSearchAndFilter(), + _buildToggleButtons(), + Expanded( + child: Obx( + () => isHistoryView.value + ? _buildHistoryList() + : _buildExpenseList(), + ), + ), + ], + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: () { + showAddExpenseBottomSheet(); + }, + backgroundColor: Colors.red, + child: const Icon(Icons.add, color: Colors.white), + ), + ); + } + + PreferredSizeWidget _buildAppBar() { + return PreferredSize( + preferredSize: const Size.fromHeight(72), + child: AppBar( + backgroundColor: const Color(0xFFF5F5F5), + elevation: 0.5, + automaticallyImplyLeading: false, + titleSpacing: 0, + title: Padding( + padding: MySpacing.xy(16, 0), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back_ios_new, + color: Colors.black, size: 20), + onPressed: () => Get.offNamed('/dashboard'), + ), + MySpacing.width(8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + MyText.titleLarge( + 'Expenses', + fontWeight: 700, + color: Colors.black, + ), + MySpacing.height(2), + GetBuilder( + builder: (controller) { + final projectName = + controller.selectedProject?.name ?? 'Select Project'; + return InkWell( + onTap: () => Get.toNamed('/project-selector'), + child: Row( + children: [ + const Icon(Icons.work_outline, + size: 14, color: Colors.grey), + MySpacing.width(4), + Expanded( + child: MyText.bodySmall( + projectName, + fontWeight: 600, + overflow: TextOverflow.ellipsis, + color: Colors.grey[700], + ), + ), + ], + ), + ); + }, + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildSearchAndFilter() { + return Padding( + padding: MySpacing.fromLTRB(12, 10, 12, 0), + child: Row( + children: [ + Expanded( + child: SizedBox( + height: 35, + child: TextField( + controller: searchController, + onChanged: (value) => searchQuery.value = value, + decoration: InputDecoration( + contentPadding: + const EdgeInsets.symmetric(horizontal: 12), + prefixIcon: const Icon(Icons.search, + size: 20, color: Colors.grey), + hintText: 'Search expenses...', + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + ), + ), + ), + ), + MySpacing.width(8), + IconButton( + icon: const Icon(Icons.tune, color: Colors.black), + onPressed: _openFilterBottomSheet, + ), + ], + ), + ); + } + + Widget _buildToggleButtons() { + return Padding( + padding: MySpacing.fromLTRB(8, 12, 8, 5), + child: Obx(() { + return Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: const Color(0xFFF0F0F0), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + _buildToggleButton( + label: 'Expenses', + icon: Icons.receipt_long, + selected: !isHistoryView.value, + onTap: () => isHistoryView.value = false, + ), + _buildToggleButton( + label: 'History', + icon: Icons.history, + selected: isHistoryView.value, + onTap: () => isHistoryView.value = true, + ), + ], + ), + ); + }), + ); + } + + Widget _buildToggleButton({ + required String label, + required IconData icon, + required bool selected, + required VoidCallback onTap, + }) { + return Expanded( + child: GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 10), + decoration: BoxDecoration( + color: selected ? Colors.red : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 16, color: selected ? Colors.white : Colors.grey), + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + color: selected ? Colors.white : Colors.grey, + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildExpenseList() { + return Obx(() { + final filteredList = expenseList.where((expense) { + return searchQuery.isEmpty || + expense['title']! + .toLowerCase() + .contains(searchQuery.value.toLowerCase()); + }).toList(); + + return _buildExpenseHistoryList(filteredList); + }); + } + + Widget _buildHistoryList() { + final historyList = expenseList + .where((item) => item['status'] == 'Paid' || item['status'] == 'Closed') + .toList(); + + return _buildExpenseHistoryList(historyList); + } + + Widget _buildExpenseHistoryList(List> list) { + return ListView.builder( + padding: const EdgeInsets.all(12), + itemCount: list.length, + itemBuilder: (context, index) { + final item = list[index]; + return GestureDetector( + onTap: () => Get.to( + () => const ExpenseDetailScreen(), + arguments: {'expense': item}, + ), + child: _buildExpenseCard(item), + ); + }, + ); + } + + Widget _buildExpenseCard(Map item) { + final statusColor = _getStatusColor(item['status']!); + + return Card( + elevation: 3, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + margin: const EdgeInsets.symmetric(vertical: 8), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title & Amount Row + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const Icon(Icons.receipt_long, size: 20, color: Colors.red), + const SizedBox(width: 8), + Text( + item['title']!, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + Text( + item['amount']!, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.red, + ), + ), + ], + ), + const SizedBox(height: 8), + _buildInfoRow(Icons.calendar_today, item['date']!), + const SizedBox(height: 6), + _buildInfoRow(Icons.category_outlined, item['category']!), + const SizedBox(height: 6), + _buildInfoRow(Icons.payment, item['paymentMode']!), + _buildInfoRow(Icons.confirmation_num_outlined, item['transactionId']!), + const SizedBox(height: 10), + Align( + alignment: Alignment.centerRight, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 10), + decoration: BoxDecoration( + color: statusColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + item['status']!, + style: TextStyle( + color: statusColor, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildInfoRow(IconData icon, String text) { + return Row( + children: [ + Icon(icon, size: 14, color: Colors.grey), + const SizedBox(width: 4), + Text( + text, + style: TextStyle(color: Colors.grey[600], fontSize: 12), + ), + ], + ); + } + + void _openFilterBottomSheet() { + Get.bottomSheet( + Container( + padding: const EdgeInsets.all(16), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Wrap( + runSpacing: 10, + children: [ + const Text( + 'Filter Expenses', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + ListTile( + leading: const Icon(Icons.date_range), + title: const Text('Date Range'), + onTap: () {}, + ), + ListTile( + leading: const Icon(Icons.work_outline), + title: const Text('Project'), + onTap: () {}, + ), + ListTile( + leading: const Icon(Icons.check_circle_outline), + title: const Text('Status'), + onTap: () {}, + ), + ], + ), + ), + ); + } +} From af83d66390118935912f6a2d5f3ca1c0284d84a8 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Sat, 19 Jul 2025 20:15:54 +0530 Subject: [PATCH 11/65] feat: Add expense models and update expense detail screen - Created ExpenseModel, Project, ExpenseType, PaymentMode, PaidBy, CreatedBy, and Status classes for expense management. - Implemented JSON serialization and deserialization for expense models. - Added ExpenseStatusModel and ExpenseTypeModel for handling status and type of expenses. - Introduced PaymentModeModel for managing payment modes. - Refactored ExpenseDetailScreen to utilize the new ExpenseModel structure. - Enhanced UI components for better display of expense details. - Added search and filter functionality in ExpenseMainScreen. - Updated dependencies in pubspec.yaml to include geocoding package. --- android/app/src/main/AndroidManifest.xml | 1 + .../expense/add_expense_controller.dart | 310 +++++++ .../expense/expense_screen_controller.dart | 42 + lib/helpers/services/api_endpoints.dart | 19 +- lib/helpers/services/api_service.dart | 107 +++ .../expense/add_expense_bottom_sheet.dart | 767 ++++++++++++------ lib/model/expense/expense_list_model.dart | 268 ++++++ lib/model/expense/expense_status_model.dart | 25 + lib/model/expense/expense_type_model.dart | 25 + lib/model/expense/payment_types_model.dart | 22 + lib/view/expense/expense_detail_screen.dart | 305 ++++--- lib/view/expense/expense_screen.dart | 746 +++++++++-------- pubspec.yaml | 1 + 13 files changed, 1918 insertions(+), 720 deletions(-) create mode 100644 lib/controller/expense/add_expense_controller.dart create mode 100644 lib/controller/expense/expense_screen_controller.dart create mode 100644 lib/model/expense/expense_list_model.dart create mode 100644 lib/model/expense/expense_status_model.dart create mode 100644 lib/model/expense/expense_type_model.dart create mode 100644 lib/model/expense/payment_types_model.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 8ae7375..1e9ad59 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ + diff --git a/lib/controller/expense/add_expense_controller.dart b/lib/controller/expense/add_expense_controller.dart new file mode 100644 index 0000000..bb09b1c --- /dev/null +++ b/lib/controller/expense/add_expense_controller.dart @@ -0,0 +1,310 @@ +import 'dart:io'; +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:geocoding/geocoding.dart'; +import 'package:mime/mime.dart'; +import 'package:marco/helpers/services/api_service.dart'; +import 'package:marco/model/expense/payment_types_model.dart'; +import 'package:marco/model/expense/expense_type_model.dart'; +import 'package:marco/model/expense/expense_status_model.dart'; +import 'package:marco/helpers/services/app_logger.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:marco/model/employee_model.dart'; + +class AddExpenseController extends GetxController { + // === Text Controllers === + final amountController = TextEditingController(); + final descriptionController = TextEditingController(); + final supplierController = TextEditingController(); + final transactionIdController = TextEditingController(); + final gstController = TextEditingController(); + final locationController = TextEditingController(); + + // === Project Mapping === + final RxMap projectsMap = {}.obs; + + // === Selected Models === + final Rx selectedPaymentMode = Rx(null); + final Rx selectedExpenseType = Rx(null); + final Rx selectedExpenseStatus = + Rx(null); + final RxString selectedProject = ''.obs; + final Rx selectedPaidBy = Rx(null); + // === States === + final RxBool preApproved = false.obs; + final RxBool isFetchingLocation = false.obs; + final Rx selectedTransactionDate = Rx(null); + + // === Master Data === + final RxList projects = [].obs; + final RxList expenseTypes = [].obs; + final RxList paymentModes = [].obs; + final RxList expenseStatuses = [].obs; + final RxList globalProjects = [].obs; + + // === Attachments === + final RxList attachments = [].obs; + RxList allEmployees = [].obs; + RxBool isLoading = false.obs; + + @override + void onInit() { + super.onInit(); + fetchMasterData(); + fetchGlobalProjects(); + fetchAllEmployees(); + } + + @override + void onClose() { + amountController.dispose(); + descriptionController.dispose(); + supplierController.dispose(); + transactionIdController.dispose(); + gstController.dispose(); + locationController.dispose(); + super.onClose(); + } + + // === Pick Attachments === + Future pickAttachments() async { + try { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['jpg', 'jpeg', 'png', 'pdf', 'doc', 'docx'], + allowMultiple: true, + ); + + if (result != null && result.paths.isNotEmpty) { + final newFiles = + result.paths.whereType().map((e) => File(e)).toList(); + attachments.addAll(newFiles); + } + } catch (e) { + Get.snackbar("Error", "Failed to pick attachments: $e"); + } + } + + void removeAttachment(File file) { + attachments.remove(file); + } + + // === Fetch Master Data === + Future fetchMasterData() async { + try { + final expenseTypesData = await ApiService.getMasterExpenseTypes(); + if (expenseTypesData is List) { + expenseTypes.value = + expenseTypesData.map((e) => ExpenseTypeModel.fromJson(e)).toList(); + } + + final paymentModesData = await ApiService.getMasterPaymentModes(); + if (paymentModesData is List) { + paymentModes.value = + paymentModesData.map((e) => PaymentModeModel.fromJson(e)).toList(); + } + + final expenseStatusData = await ApiService.getMasterExpenseStatus(); + if (expenseStatusData is List) { + expenseStatuses.value = expenseStatusData + .map((e) => ExpenseStatusModel.fromJson(e)) + .toList(); + } + } catch (e) { + Get.snackbar("Error", "Failed to fetch master data: $e"); + } + } + + // === Fetch Current Location === + Future fetchCurrentLocation() async { + isFetchingLocation.value = true; + try { + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { + permission = await Geolocator.requestPermission(); + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { + Get.snackbar( + "Error", "Location permission denied. Enable in settings."); + return; + } + } + + if (!await Geolocator.isLocationServiceEnabled()) { + Get.snackbar("Error", "Location services are disabled. Enable them."); + return; + } + + final position = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.high, + ); + + final placemarks = await placemarkFromCoordinates( + position.latitude, + position.longitude, + ); + + if (placemarks.isNotEmpty) { + final place = placemarks.first; + final addressParts = [ + place.name, + place.street, + place.subLocality, + place.locality, + place.administrativeArea, + place.country, + ].where((part) => part != null && part.isNotEmpty).toList(); + + locationController.text = addressParts.join(", "); + } else { + locationController.text = "${position.latitude}, ${position.longitude}"; + } + } catch (e) { + Get.snackbar("Error", "Error fetching location: $e"); + } finally { + isFetchingLocation.value = false; + } + } + + // === Submit Expense === + Future submitExpense() async { + // Validation for required fields + if (selectedProject.value.isEmpty || + selectedExpenseType.value == null || + selectedPaymentMode.value == null || + descriptionController.text.isEmpty || + supplierController.text.isEmpty || + amountController.text.isEmpty || + selectedExpenseStatus.value == null || + attachments.isEmpty) { + showAppSnackbar( + title: "Error", + message: "Please fill all required fields.", + type: SnackbarType.error, + ); + return; + } + + final double? amount = double.tryParse(amountController.text); + if (amount == null) { + showAppSnackbar( + title: "Error", + message: "Please enter a valid amount.", + type: SnackbarType.error, + ); + return; + } + + final projectId = projectsMap[selectedProject.value]; + if (projectId == null) { + showAppSnackbar( + title: "Error", + message: "Invalid project selection.", + type: SnackbarType.error, + ); + return; + } + + // Convert attachments to base64 + meta + final attachmentData = await Future.wait(attachments.map((file) async { + final bytes = await file.readAsBytes(); + final base64String = base64Encode(bytes); + final mimeType = lookupMimeType(file.path) ?? 'application/octet-stream'; + final fileSize = await file.length(); + + return { + "fileName": file.path.split('/').last, + "base64Data": base64String, + "contentType": mimeType, + "fileSize": fileSize, + "description": "", + }; + }).toList()); + + // Submit API call + final success = await ApiService.createExpenseApi( + projectId: projectId, + expensesTypeId: selectedExpenseType.value!.id, + paymentModeId: selectedPaymentMode.value!.id, + paidById: selectedPaidBy.value?.id ?? "", + transactionDate:(selectedTransactionDate.value ?? DateTime.now()).toUtc(), + transactionId: transactionIdController.text, + description: descriptionController.text, + location: locationController.text, + supplerName: supplierController.text, + amount: amount, + noOfPersons: 0, + statusId: selectedExpenseStatus.value!.id, + billAttachments: attachmentData, + ); + + if (success) { + Get.back(); + showAppSnackbar( + title: "Success", + message: "Expense created successfully!", + type: SnackbarType.success, + ); + } else { + showAppSnackbar( + title: "Error", + message: "Failed to create expense. Try again.", + type: SnackbarType.error, + ); + } + } + + // === Fetch Projects === + Future fetchGlobalProjects() async { + try { + final response = await ApiService.getGlobalProjects(); + if (response != null) { + final names = []; + for (var item in response) { + final name = item['name']?.toString().trim(); + final id = item['id']?.toString().trim(); + if (name != null && id != null && name.isNotEmpty) { + projectsMap[name] = id; + names.add(name); + } + } + globalProjects.assignAll(names); + logSafe("Fetched ${names.length} global projects"); + } + } catch (e) { + logSafe("Failed to fetch global projects: $e", level: LogLevel.error); + } + } + + // === Fetch All Employees === + Future fetchAllEmployees() async { + isLoading.value = true; + + try { + final response = await ApiService.getAllEmployees(); + if (response != null && response.isNotEmpty) { + allEmployees + .assignAll(response.map((json) => EmployeeModel.fromJson(json))); + logSafe( + "All Employees fetched for Manage Bucket: ${allEmployees.length}", + level: LogLevel.info, + ); + } else { + allEmployees.clear(); + logSafe("No employees found for Manage Bucket.", + level: LogLevel.warning); + } + } catch (e) { + allEmployees.clear(); + logSafe("Error fetching employees in Manage Bucket", + level: LogLevel.error, error: e); + } + + isLoading.value = false; + update(); + } +} diff --git a/lib/controller/expense/expense_screen_controller.dart b/lib/controller/expense/expense_screen_controller.dart new file mode 100644 index 0000000..6568aa3 --- /dev/null +++ b/lib/controller/expense/expense_screen_controller.dart @@ -0,0 +1,42 @@ +import 'package:get/get.dart'; +import 'package:marco/helpers/services/api_service.dart'; +import 'package:marco/helpers/services/app_logger.dart'; +import 'package:marco/model/expense/expense_list_model.dart'; + +class ExpenseController extends GetxController { + final RxList expenses = [].obs; + final RxBool isLoading = false.obs; + final RxString errorMessage = ''.obs; + + /// Fetch all expenses from API + Future fetchExpenses() async { + isLoading.value = true; + errorMessage.value = ''; + + try { + final result = await ApiService.getExpenseListApi(); + + if (result != null) { + try { + // Convert the raw result (List) to List + final List parsed = List.from( + result.map((e) => ExpenseModel.fromJson(e))); + expenses.assignAll(parsed); + logSafe("Expenses loaded: ${parsed.length}"); + } catch (e) { + errorMessage.value = 'Failed to parse expenses: $e'; + logSafe("Parse error in fetchExpenses: $e", level: LogLevel.error); + } + } else { + errorMessage.value = 'Failed to fetch expenses from server.'; + logSafe("fetchExpenses failed: null response", level: LogLevel.error); + } + } catch (e, stack) { + errorMessage.value = 'An unexpected error occurred.'; + logSafe("Exception in fetchExpenses: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } finally { + isLoading.value = false; + } + } +} diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index 3949b40..4b7b24a 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -2,10 +2,10 @@ class ApiEndpoints { static const String baseUrl = "https://stageapi.marcoaiot.com/api"; // static const String baseUrl = "https://api.marcoaiot.com/api"; - // Dashboard Screen API Endpoints + // Dashboard Module API Endpoints static const String getDashboardAttendanceOverview = "/dashboard/attendance-overview"; - // Attendance Screen API Endpoints + // Attendance Module API Endpoints static const String getProjects = "/project/list"; static const String getGlobalProjects = "/project/list/basic"; static const String getEmployeesByProject = "/attendance/project/team"; @@ -24,7 +24,7 @@ class ApiEndpoints { static const String getAssignedProjects = "/project/assigned-projects"; static const String assignProjects = "/project/assign-projects"; - // Daily Task Screen API Endpoints + // Daily Task Module API Endpoints static const String getDailyTask = "/task/list"; static const String reportTask = "/task/report"; static const String commentTask = "/task/comment"; @@ -35,7 +35,7 @@ class ApiEndpoints { static const String assignTask = "/project/task"; static const String getmasterWorkCategories = "/Master/work-categories"; - ////// Directory Screen API Endpoints + ////// Directory Module API Endpoints static const String getDirectoryContacts = "/directory"; static const String getDirectoryBucketList = "/directory/buckets"; static const String getDirectoryContactDetail = "/directory/notes"; @@ -49,4 +49,15 @@ class ApiEndpoints { static const String createBucket = "/directory/bucket"; static const String updateBucket = "/directory/bucket"; static const String assignBucket = "/directory/assign-bucket"; + + ////// Expense Module API Endpoints + static const String getExpenseCategories = "/expense/categories"; + static const String getExpenseList = "/expense/list"; + static const String getExpenseDetails = "/expense/details"; + static const String createExpense = "/expense/create"; + static const String updateExpense = "/expense/manage"; + static const String getMasterPaymentModes = "/master/payment-modes"; + static const String getMasterExpenseStatus = "/master/expenses-status"; + static const String getMasterExpenseTypes = "/master/expenses-types"; + } diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index bd6e005..0427760 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -239,6 +239,113 @@ class ApiService { } } +// === Expense APIs === // + + static Future?> getExpenseListApi() async { + const endpoint = ApiEndpoints.getExpenseList; + + logSafe("Fetching expense list..."); + + try { + final response = await _getRequest(endpoint); + if (response == null) return null; + + return _parseResponse(response, label: 'Expense List'); + } catch (e, stack) { + logSafe("Exception during getExpenseListApi: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + return null; + } + } + + /// Fetch Master Payment Modes + static Future?> getMasterPaymentModes() async { + const endpoint = ApiEndpoints.getMasterPaymentModes; + return _getRequest(endpoint).then((res) => res != null + ? _parseResponse(res, label: 'Master Payment Modes') + : null); + } + + /// Fetch Master Expense Status + static Future?> getMasterExpenseStatus() async { + const endpoint = ApiEndpoints.getMasterExpenseStatus; + return _getRequest(endpoint).then((res) => res != null + ? _parseResponse(res, label: 'Master Expense Status') + : null); + } + + /// Fetch Master Expense Types + static Future?> getMasterExpenseTypes() async { + const endpoint = ApiEndpoints.getMasterExpenseTypes; + return _getRequest(endpoint).then((res) => res != null + ? _parseResponse(res, label: 'Master Expense Types') + : null); + } + + /// Create Expense API + static Future createExpenseApi({ + required String projectId, + required String expensesTypeId, + required String paymentModeId, + required String paidById, + required DateTime transactionDate, + required String transactionId, + required String description, + required String location, + required String supplerName, + required double amount, + required int noOfPersons, + required String statusId, + required List> billAttachments, + }) async { + final payload = { + "projectId": projectId, + "expensesTypeId": expensesTypeId, + "paymentModeId": paymentModeId, + "paidById": paidById, + "transactionDate": transactionDate.toIso8601String(), + "transactionId": transactionId, + "description": description, + "location": location, + "supplerName": supplerName, + "amount": amount, + "noOfPersons": noOfPersons, + "statusId": statusId, + "billAttachments": billAttachments, + }; + + const endpoint = ApiEndpoints.createExpense; + logSafe("Creating expense with payload: $payload"); + + try { + final response = + await _postRequest(endpoint, payload, customTimeout: extendedTimeout); + + if (response == null) { + logSafe("Create expense failed: null response", level: LogLevel.error); + return false; + } + + logSafe("Create expense response status: ${response.statusCode}"); + logSafe("Create expense response body: ${response.body}"); + + final json = jsonDecode(response.body); + if (json['success'] == true) { + logSafe("Expense created successfully: ${json['data']}"); + return true; + } else { + logSafe( + "Failed to create expense: ${json['message'] ?? 'Unknown error'}", + level: LogLevel.warning); + } + } catch (e, stack) { + logSafe("Exception during createExpense API: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return false; + } + // === Dashboard Endpoints === static Future?> getDashboardAttendanceOverview( diff --git a/lib/model/expense/add_expense_bottom_sheet.dart b/lib/model/expense/add_expense_bottom_sheet.dart index 3dfd6cb..3abd58a 100644 --- a/lib/model/expense/add_expense_bottom_sheet.dart +++ b/lib/model/expense/add_expense_bottom_sheet.dart @@ -1,54 +1,79 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:marco/controller/expense/add_expense_controller.dart'; import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/model/expense/payment_types_model.dart'; +import 'package:marco/model/expense/expense_type_model.dart'; +import 'package:marco/model/expense/expense_status_model.dart'; void showAddExpenseBottomSheet() { - final TextEditingController amountController = TextEditingController(); - final TextEditingController descriptionController = TextEditingController(); - final TextEditingController supplierController = TextEditingController(); - final TextEditingController transactionIdController = TextEditingController(); - final TextEditingController gstController = TextEditingController(); - - String selectedProject = "Select Project"; - String selectedCategory = "Select Expense Type"; - String selectedPaymentMode = "Select Payment Mode"; - String selectedLocation = "Select Location"; - bool preApproved = false; - Get.bottomSheet( - StatefulBuilder( - builder: (context, setState) { - return SafeArea( - child: Padding( - padding: - const EdgeInsets.only(top: 60), - child: Material( - color: Colors.white, - borderRadius: - const BorderRadius.vertical(top: Radius.circular(20)), - child: Container( - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height - 60, - ), - padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), - child: SingleChildScrollView( + const _AddExpenseBottomSheet(), + isScrollControlled: true, + ); +} + +class _AddExpenseBottomSheet extends StatefulWidget { + const _AddExpenseBottomSheet(); + + @override + State<_AddExpenseBottomSheet> createState() => _AddExpenseBottomSheetState(); +} + +class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { + final AddExpenseController controller = Get.put(AddExpenseController()); + final RxBool isProjectExpanded = false.obs; + void _showEmployeeList(BuildContext context) { + final employees = controller.allEmployees; + + showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (BuildContext context) { + return Obx(() { + return SizedBox( + height: 300, + child: ListView.builder( + itemCount: employees.length, + itemBuilder: (context, index) { + final emp = employees[index]; + final fullName = '${emp.firstName} ${emp.lastName}'.trim(); + + return ListTile( + title: Text(fullName.isNotEmpty ? fullName : "Unnamed"), + onTap: () { + controller.selectedPaidBy.value = emp; + Navigator.pop(context); + }, + ); + }, + ), + ); + }); + }, + ); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.only(top: 60), + child: Material( + color: Colors.white, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + child: Stack( + children: [ + Obx(() { + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Drag Handle - Center( - child: Container( - width: 40, - height: 4, - margin: const EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - color: Colors.grey.shade400, - borderRadius: BorderRadius.circular(2), - ), - ), - ), - - // Title + _buildDragHandle(), Center( child: MyText.titleLarge( "Add Expense", @@ -57,151 +82,306 @@ void showAddExpenseBottomSheet() { ), const SizedBox(height: 20), - // Project - _sectionTitle(Icons.work_outline, "Project"), + // Project Dropdown + const _SectionTitle( + icon: Icons.work_outline, title: "Project"), const SizedBox(height: 6), - _dropdownTile( - title: selectedProject, - onTap: () { - setState(() { - selectedProject = "Project A"; - }); - }, - ), + Obx(() { + return _DropdownTile( + title: controller.selectedProject.value.isEmpty + ? "Select Project" + : controller.selectedProject.value, + onTap: () => _showOptionList( + context, + controller.globalProjects.toList(), + (p) => p, + (val) => controller.selectedProject.value = val, + ), + ); + }), const SizedBox(height: 16), - // Expense Type + GST - _sectionTitle( - Icons.category_outlined, "Expense Type & GST No."), - const SizedBox(height: 6), - _dropdownTile( - title: selectedCategory, - onTap: () { - setState(() { - selectedCategory = "Travel Expense"; - }); - }, + // Expense Type & GST + const _SectionTitle( + icon: Icons.category_outlined, + title: "Expense Type & GST No.", ), + const SizedBox(height: 6), + Obx(() { + return _DropdownTile( + title: controller.selectedExpenseType.value?.name ?? + "Select Expense Type", + onTap: () => _showOptionList( + context, + controller.expenseTypes.toList(), + (e) => e.name, + (val) => controller.selectedExpenseType.value = val, + ), + ); + }), const SizedBox(height: 8), - _customTextField( - controller: gstController, + _CustomTextField( + controller: controller.gstController, hint: "Enter GST No.", ), const SizedBox(height: 16), // Payment Mode - _sectionTitle(Icons.payment, "Payment Mode"), + const _SectionTitle( + icon: Icons.payment, title: "Payment Mode"), const SizedBox(height: 6), - _dropdownTile( - title: selectedPaymentMode, - onTap: () { - setState(() { - selectedPaymentMode = "UPI"; - }); - }, - ), + Obx(() { + return _DropdownTile( + title: controller.selectedPaymentMode.value?.name ?? + "Select Payment Mode", + onTap: () => _showOptionList( + context, + controller.paymentModes.toList(), + (m) => m.name, + (val) => controller.selectedPaymentMode.value = val, + ), + ); + }), const SizedBox(height: 16), - - // Paid By - _sectionTitle(Icons.person, "Paid By (Employee)"), - const SizedBox(height: 6), - _dropdownTile( - title: "Self (Default)", - onTap: () {}, - ), + Obx(() { + final selected = controller.selectedPaidBy.value; + return GestureDetector( + onTap: () => _showEmployeeList(context), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 14), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade400), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + selected == null + ? "Select Paid By" + : '${selected.firstName} ${selected.lastName}', + style: const TextStyle(fontSize: 14), + ), + const Icon(Icons.arrow_drop_down, size: 22), + ], + ), + ), + ); + }), const SizedBox(height: 16), - - // Transaction Date - _sectionTitle(Icons.calendar_today, "Transaction Date"), + // Expense Status + const _SectionTitle( + icon: Icons.flag_outlined, title: "Status"), const SizedBox(height: 6), - _dropdownTile( - title: "Select Date & Time", - onTap: () async { - // Add date/time picker - }, - ), + Obx(() { + return _DropdownTile( + title: controller.selectedExpenseStatus.value?.name ?? + "Select Status", + onTap: () => _showOptionList( + context, + controller.expenseStatuses.toList(), + (s) => s.name, + (val) => + controller.selectedExpenseStatus.value = val, + ), + ); + }), const SizedBox(height: 16), // Amount - _sectionTitle(Icons.currency_rupee, "Amount"), + const _SectionTitle( + icon: Icons.currency_rupee, title: "Amount"), const SizedBox(height: 6), - _customTextField( - controller: amountController, + _CustomTextField( + controller: controller.amountController, hint: "Enter Amount", keyboardType: TextInputType.number, ), const SizedBox(height: 16), - - // Supplier - _sectionTitle(Icons.store_mall_directory, - "Supplier Name / Expense Done At"), + // Supplier Name + const _SectionTitle( + icon: Icons.store_mall_directory_outlined, + title: "Supplier Name", + ), const SizedBox(height: 6), - _customTextField( - controller: supplierController, + _CustomTextField( + controller: controller.supplierController, hint: "Enter Supplier Name", ), const SizedBox(height: 16), - - // Location - _sectionTitle(Icons.location_on_outlined, "Location"), - const SizedBox(height: 6), - _dropdownTile( - title: selectedLocation, - onTap: () { - setState(() { - selectedLocation = "Pune"; - }); - }, - ), - const SizedBox(height: 16), - - // Description - _sectionTitle(Icons.description_outlined, "Description"), - const SizedBox(height: 6), - _customTextField( - controller: descriptionController, - hint: "Enter Description", - maxLines: 3, - ), - const SizedBox(height: 16), - - // Bill Attachment - _sectionTitle(Icons.attachment, "Bill Attachment"), - const SizedBox(height: 6), - OutlinedButton.icon( - onPressed: () {}, - icon: const Icon(Icons.upload_file), - label: const Text("Upload Bill"), - style: OutlinedButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - ), - const SizedBox(height: 16), - // Transaction ID - _sectionTitle( - Icons.confirmation_num_outlined, "Transaction ID"), + const _SectionTitle( + icon: Icons.confirmation_number_outlined, + title: "Transaction ID"), const SizedBox(height: 6), - _customTextField( - controller: transactionIdController, + _CustomTextField( + controller: controller.transactionIdController, hint: "Enter Transaction ID", ), const SizedBox(height: 16), - // Pre-Approved Switch - Row( - children: [ - Switch( - value: preApproved, - onChanged: (val) => - setState(() => preApproved = val), - activeColor: Colors.red, + // Location + const _SectionTitle( + icon: Icons.location_on_outlined, + title: "Location", + ), + const SizedBox(height: 6), + TextField( + controller: controller.locationController, + decoration: InputDecoration( + hintText: "Enter Location", + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), ), - const SizedBox(width: 8), - const Text("Pre-Approved?"), - ], + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + suffixIcon: controller.isFetchingLocation.value + ? const Padding( + padding: EdgeInsets.all(12), + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2), + ), + ) + : IconButton( + icon: const Icon(Icons.my_location), + tooltip: "Use Current Location", + onPressed: controller.fetchCurrentLocation, + ), + ), + ), + + const SizedBox(height: 16), + // Attachments Section + const _SectionTitle( + icon: Icons.attach_file, title: "Attachments"), + const SizedBox(height: 6), + + Obx(() { + return Wrap( + spacing: 8, + runSpacing: 8, + children: [ + ...controller.attachments.map((file) { + final fileName = file.path.split('/').last; + final extension = + fileName.split('.').last.toLowerCase(); + + final isImage = + ['jpg', 'jpeg', 'png'].contains(extension); + + IconData fileIcon; + Color iconColor = Colors.blueAccent; + switch (extension) { + case 'pdf': + fileIcon = Icons.picture_as_pdf; + iconColor = Colors.redAccent; + break; + case 'doc': + case 'docx': + fileIcon = Icons.description; + iconColor = Colors.blueAccent; + break; + case 'xls': + case 'xlsx': + fileIcon = Icons.table_chart; + iconColor = Colors.green; + break; + case 'txt': + fileIcon = Icons.article; + iconColor = Colors.grey; + break; + default: + fileIcon = Icons.insert_drive_file; + iconColor = Colors.blueGrey; + } + + return Stack( + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Colors.grey.shade300), + color: Colors.grey.shade100, + ), + child: isImage + ? ClipRRect( + borderRadius: + BorderRadius.circular(8), + child: Image.file(file, + fit: BoxFit.cover), + ) + : Column( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + Icon(fileIcon, + color: iconColor, size: 30), + const SizedBox(height: 4), + Text( + extension.toUpperCase(), + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: iconColor, + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + Positioned( + top: -6, + right: -6, + child: IconButton( + icon: const Icon(Icons.close, + color: Colors.red, size: 18), + onPressed: () => + controller.removeAttachment(file), + ), + ), + ], + ); + }).toList(), + GestureDetector( + onTap: controller.pickAttachments, + child: Container( + width: 80, + height: 80, + decoration: BoxDecoration( + border: + Border.all(color: Colors.grey.shade400), + borderRadius: BorderRadius.circular(8), + color: Colors.grey.shade100, + ), + child: const Icon(Icons.add, + size: 30, color: Colors.grey), + ), + ), + ], + ); + }), + const SizedBox(height: 16), + + // Description + const _SectionTitle( + icon: Icons.description_outlined, + title: "Description", + ), + const SizedBox(height: 6), + _CustomTextField( + controller: controller.descriptionController, + hint: "Enter Description", + maxLines: 3, ), const SizedBox(height: 24), @@ -215,20 +395,15 @@ void showAddExpenseBottomSheet() { label: MyText.bodyMedium("Cancel", fontWeight: 600), style: OutlinedButton.styleFrom( - side: const BorderSide(color: Colors.grey), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), + minimumSize: + const Size.fromHeight(48), ), ), ), const SizedBox(width: 12), Expanded( child: ElevatedButton.icon( - onPressed: () { - // Handle Save - Get.back(); - }, + onPressed: controller.submitExpense, icon: const Icon(Icons.check, size: 18), label: MyText.bodyMedium( "Submit", @@ -238,82 +413,216 @@ void showAddExpenseBottomSheet() { style: ElevatedButton.styleFrom( backgroundColor: Colors.indigo, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(8), ), + padding: + const EdgeInsets.symmetric(vertical: 14), + minimumSize: + const Size.fromHeight(48), ), ), ), ], - ), + ) ], ), - ), + ); + }), + + // Project Selection List + Obx(() { + if (!isProjectExpanded.value) return const SizedBox.shrink(); + return Positioned( + top: 110, + left: 16, + right: 16, + child: Material( + elevation: 4, + borderRadius: BorderRadius.circular(12), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.all(10), + child: _buildProjectSelectionList(), + ), + ), + ); + }), + ], + ), + ), + ), + ); + } + + Widget _buildProjectSelectionList() { + return ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 300), + child: ListView.builder( + shrinkWrap: true, + itemCount: controller.globalProjects.length, + itemBuilder: (context, index) { + final project = controller.globalProjects[index]; + final isSelected = project == controller.selectedProject.value; + + return RadioListTile( + value: project, + groupValue: controller.selectedProject.value, + onChanged: (val) { + controller.selectedProject.value = val!; + isProjectExpanded.value = false; + }, + title: Text( + project, + style: TextStyle( + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + color: isSelected ? Colors.blueAccent : Colors.black87, ), ), - ), - ); - }, - ), - isScrollControlled: true, - ); -} - -/// Section Title -Widget _sectionTitle(IconData icon, String title) { - return Row( - children: [ - Icon(icon, color: Colors.grey[700], size: 18), - const SizedBox(width: 8), - MyText.bodyMedium(title, fontWeight: 600), - ], - ); -} - -/// Custom TextField -Widget _customTextField({ - required TextEditingController controller, - required String hint, - int maxLines = 1, - TextInputType keyboardType = TextInputType.text, -}) { - return TextField( - controller: controller, - maxLines: maxLines, - keyboardType: keyboardType, - decoration: InputDecoration( - hintText: hint, - filled: true, - fillColor: Colors.grey.shade100, - border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - ), - ); -} - -/// Dropdown Tile -Widget _dropdownTile({required String title, required VoidCallback onTap}) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), - decoration: BoxDecoration( - color: Colors.grey.shade100, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.grey.shade300), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - title, - style: const TextStyle(fontSize: 14, color: Colors.black87), - overflow: TextOverflow.ellipsis, + activeColor: Colors.blueAccent, + tileColor: isSelected + ? Colors.blueAccent.withOpacity(0.1) + : Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6), ), - ), - const Icon(Icons.arrow_drop_down), - ], + visualDensity: const VisualDensity(vertical: -4), + ); + }, ), - ), - ); + ); + } + + Future _showOptionList( + BuildContext context, + List options, + String Function(T) getLabel, + ValueChanged onSelected, + ) async { + final RenderBox button = context.findRenderObject() as RenderBox; + final RenderBox overlay = + Overlay.of(context).context.findRenderObject() as RenderBox; + + final Offset position = + button.localToGlobal(Offset.zero, ancestor: overlay); + final selected = await showMenu( + context: context, + position: RelativeRect.fromLTRB( + position.dx, + position.dy + button.size.height, + position.dx + button.size.width, + 0, + ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + items: options.map((option) { + return PopupMenuItem( + value: option, + child: Text(getLabel(option)), + ); + }).toList(), + ); + + if (selected != null) onSelected(selected); + } + + Widget _buildDragHandle() => Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.grey.shade400, + borderRadius: BorderRadius.circular(2), + ), + ), + ); +} + +class _SectionTitle extends StatelessWidget { + final IconData icon; + final String title; + const _SectionTitle({required this.icon, required this.title}); + + @override + Widget build(BuildContext context) { + final color = Colors.grey[700]; + return Row( + children: [ + Icon(icon, color: color, size: 18), + const SizedBox(width: 8), + MyText.bodyMedium(title, fontWeight: 600), + ], + ); + } +} + +class _CustomTextField extends StatelessWidget { + final TextEditingController controller; + final String hint; + final int maxLines; + final TextInputType keyboardType; + + const _CustomTextField({ + required this.controller, + required this.hint, + this.maxLines = 1, + this.keyboardType = TextInputType.text, + }); + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + maxLines: maxLines, + keyboardType: keyboardType, + decoration: InputDecoration( + hintText: hint, + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + ), + ); + } +} + +class _DropdownTile extends StatelessWidget { + final String title; + final VoidCallback onTap; + + const _DropdownTile({ + required this.title, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + title, + style: const TextStyle(fontSize: 14, color: Colors.black87), + overflow: TextOverflow.ellipsis, + ), + ), + const Icon(Icons.arrow_drop_down), + ], + ), + ), + ); + } } diff --git a/lib/model/expense/expense_list_model.dart b/lib/model/expense/expense_list_model.dart new file mode 100644 index 0000000..451ba72 --- /dev/null +++ b/lib/model/expense/expense_list_model.dart @@ -0,0 +1,268 @@ +import 'dart:convert'; + +List expenseModelFromJson(String str) => List.from( + json.decode(str).map((x) => ExpenseModel.fromJson(x))); + +String expenseModelToJson(List data) => + json.encode(List.from(data.map((x) => x.toJson()))); + +class ExpenseModel { + final String id; + final Project project; + final ExpenseType expensesType; + final PaymentMode paymentMode; + final PaidBy paidBy; + final CreatedBy createdBy; + final DateTime transactionDate; + final DateTime createdAt; + final String supplerName; + final double amount; + final Status status; + final List nextStatus; + final bool preApproved; + + ExpenseModel({ + required this.id, + required this.project, + required this.expensesType, + required this.paymentMode, + required this.paidBy, + required this.createdBy, + required this.transactionDate, + required this.createdAt, + required this.supplerName, + required this.amount, + required this.status, + required this.nextStatus, + required this.preApproved, + }); + + factory ExpenseModel.fromJson(Map json) => ExpenseModel( + id: json["id"], + project: Project.fromJson(json["project"]), + expensesType: ExpenseType.fromJson(json["expensesType"]), + paymentMode: PaymentMode.fromJson(json["paymentMode"]), + paidBy: PaidBy.fromJson(json["paidBy"]), + createdBy: CreatedBy.fromJson(json["createdBy"]), + transactionDate: DateTime.parse(json["transactionDate"]), + createdAt: DateTime.parse(json["createdAt"]), + supplerName: json["supplerName"], + amount: (json["amount"] as num).toDouble(), + status: Status.fromJson(json["status"]), + nextStatus: List.from( + json["nextStatus"].map((x) => Status.fromJson(x))), + preApproved: json["preApproved"], + ); + + Map toJson() => { + "id": id, + "project": project.toJson(), + "expensesType": expensesType.toJson(), + "paymentMode": paymentMode.toJson(), + "paidBy": paidBy.toJson(), + "createdBy": createdBy.toJson(), + "transactionDate": transactionDate.toIso8601String(), + "createdAt": createdAt.toIso8601String(), + "supplerName": supplerName, + "amount": amount, + "status": status.toJson(), + "nextStatus": List.from(nextStatus.map((x) => x.toJson())), + "preApproved": preApproved, + }; +} + +class Project { + final String id; + final String name; + final String shortName; + final String projectAddress; + final String contactPerson; + final DateTime startDate; + final DateTime endDate; + final String projectStatusId; + + Project({ + required this.id, + required this.name, + required this.shortName, + required this.projectAddress, + required this.contactPerson, + required this.startDate, + required this.endDate, + required this.projectStatusId, + }); + + factory Project.fromJson(Map json) => Project( + id: json["id"], + name: json["name"], + shortName: json["shortName"], + projectAddress: json["projectAddress"], + contactPerson: json["contactPerson"], + startDate: DateTime.parse(json["startDate"]), + endDate: DateTime.parse(json["endDate"]), + projectStatusId: json["projectStatusId"], + ); + + Map toJson() => { + "id": id, + "name": name, + "shortName": shortName, + "projectAddress": projectAddress, + "contactPerson": contactPerson, + "startDate": startDate.toIso8601String(), + "endDate": endDate.toIso8601String(), + "projectStatusId": projectStatusId, + }; +} + +class ExpenseType { + final String id; + final String name; + final bool noOfPersonsRequired; + final String description; + + ExpenseType({ + required this.id, + required this.name, + required this.noOfPersonsRequired, + required this.description, + }); + + factory ExpenseType.fromJson(Map json) => ExpenseType( + id: json["id"], + name: json["name"], + noOfPersonsRequired: json["noOfPersonsRequired"], + description: json["description"], + ); + + Map toJson() => { + "id": id, + "name": name, + "noOfPersonsRequired": noOfPersonsRequired, + "description": description, + }; +} + +class PaymentMode { + final String id; + final String name; + final String description; + + PaymentMode({ + required this.id, + required this.name, + required this.description, + }); + + factory PaymentMode.fromJson(Map json) => PaymentMode( + id: json["id"], + name: json["name"], + description: json["description"], + ); + + Map toJson() => { + "id": id, + "name": name, + "description": description, + }; +} + +class PaidBy { + final String id; + final String firstName; + final String lastName; + final String photo; + final String jobRoleId; + final String? jobRoleName; + + PaidBy({ + required this.id, + required this.firstName, + required this.lastName, + required this.photo, + required this.jobRoleId, + this.jobRoleName, + }); + + factory PaidBy.fromJson(Map json) => PaidBy( + id: json["id"], + firstName: json["firstName"], + lastName: json["lastName"], + photo: json["photo"], + jobRoleId: json["jobRoleId"], + jobRoleName: json["jobRoleName"], + ); + + Map toJson() => { + "id": id, + "firstName": firstName, + "lastName": lastName, + "photo": photo, + "jobRoleId": jobRoleId, + "jobRoleName": jobRoleName, + }; +} + +class CreatedBy { + final String id; + final String firstName; + final String lastName; + final String photo; + final String jobRoleId; + final String? jobRoleName; + + CreatedBy({ + required this.id, + required this.firstName, + required this.lastName, + required this.photo, + required this.jobRoleId, + this.jobRoleName, + }); + + factory CreatedBy.fromJson(Map json) => CreatedBy( + id: json["id"], + firstName: json["firstName"], + lastName: json["lastName"], + photo: json["photo"], + jobRoleId: json["jobRoleId"], + jobRoleName: json["jobRoleName"], + ); + + Map toJson() => { + "id": id, + "firstName": firstName, + "lastName": lastName, + "photo": photo, + "jobRoleId": jobRoleId, + "jobRoleName": jobRoleName, + }; +} + +class Status { + final String id; + final String name; + final String description; + final bool isSystem; + + Status({ + required this.id, + required this.name, + required this.description, + required this.isSystem, + }); + + factory Status.fromJson(Map json) => Status( + id: json["id"], + name: json["name"], + description: json["description"], + isSystem: json["isSystem"], + ); + + Map toJson() => { + "id": id, + "name": name, + "description": description, + "isSystem": isSystem, + }; +} diff --git a/lib/model/expense/expense_status_model.dart b/lib/model/expense/expense_status_model.dart new file mode 100644 index 0000000..8970c59 --- /dev/null +++ b/lib/model/expense/expense_status_model.dart @@ -0,0 +1,25 @@ +class ExpenseStatusModel { + final String id; + final String name; + final String description; + final bool isSystem; + final bool isActive; + + ExpenseStatusModel({ + required this.id, + required this.name, + required this.description, + required this.isSystem, + required this.isActive, + }); + + factory ExpenseStatusModel.fromJson(Map json) { + return ExpenseStatusModel( + id: json['id'], + name: json['name'], + description: json['description'] ?? '', + isSystem: json['isSystem'] ?? false, + isActive: json['isActive'] ?? false, + ); + } +} diff --git a/lib/model/expense/expense_type_model.dart b/lib/model/expense/expense_type_model.dart new file mode 100644 index 0000000..0cf8be9 --- /dev/null +++ b/lib/model/expense/expense_type_model.dart @@ -0,0 +1,25 @@ +class ExpenseTypeModel { + final String id; + final String name; + final bool noOfPersonsRequired; + final String description; + final bool isActive; + + ExpenseTypeModel({ + required this.id, + required this.name, + required this.noOfPersonsRequired, + required this.description, + required this.isActive, + }); + + factory ExpenseTypeModel.fromJson(Map json) { + return ExpenseTypeModel( + id: json['id'], + name: json['name'], + noOfPersonsRequired: json['noOfPersonsRequired'] ?? false, + description: json['description'] ?? '', + isActive: json['isActive'] ?? false, + ); + } +} diff --git a/lib/model/expense/payment_types_model.dart b/lib/model/expense/payment_types_model.dart new file mode 100644 index 0000000..d3f6024 --- /dev/null +++ b/lib/model/expense/payment_types_model.dart @@ -0,0 +1,22 @@ +class PaymentModeModel { + final String id; + final String name; + final String description; + final bool isActive; + + PaymentModeModel({ + required this.id, + required this.name, + required this.description, + required this.isActive, + }); + + factory PaymentModeModel.fromJson(Map json) { + return PaymentModeModel( + id: json['id'], + name: json['name'], + description: json['description'] ?? '', + isActive: json['isActive'] ?? false, + ); + } +} diff --git a/lib/view/expense/expense_detail_screen.dart b/lib/view/expense/expense_detail_screen.dart index 6ad1194..951e5d6 100644 --- a/lib/view/expense/expense_detail_screen.dart +++ b/lib/view/expense/expense_detail_screen.dart @@ -3,13 +3,15 @@ import 'package:get/get.dart'; import 'package:marco/controller/project_controller.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/model/expense/expense_list_model.dart'; +import 'package:marco/helpers/utils/date_time_utils.dart'; // Import DateTimeUtils class ExpenseDetailScreen extends StatelessWidget { const ExpenseDetailScreen({super.key}); - Color _getStatusColor(String status) { + static Color getStatusColor(String? status) { switch (status) { - case 'Request': + case 'Requested': return Colors.blue; case 'Review': return Colors.orange; @@ -26,10 +28,9 @@ class ExpenseDetailScreen extends StatelessWidget { @override Widget build(BuildContext context) { - final Map expense = - Get.arguments['expense'] as Map; - final Color statusColor = _getStatusColor(expense['status']!); - final ProjectController projectController = Get.find(); + final ExpenseModel expense = Get.arguments['expense'] as ExpenseModel; + final statusColor = getStatusColor(expense.status.name); + final projectController = Get.find(); return Scaffold( backgroundColor: const Color(0xFFF7F7F7), @@ -95,139 +96,89 @@ class ExpenseDetailScreen extends StatelessWidget { ), body: SingleChildScrollView( padding: const EdgeInsets.all(16), - child: Card( - elevation: 6, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - clipBehavior: Clip.antiAlias, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header Section - Container( - width: double.infinity, - padding: const EdgeInsets.all(20), - decoration: const BoxDecoration( - gradient: LinearGradient( - colors: [Color(0xFFFF4B2B), Color(0xFFFF416C)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - expense['title'] ?? 'N/A', - style: const TextStyle( - fontSize: 22, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - const SizedBox(height: 6), - Text( - expense['amount'] ?? '₹ 0', - style: const TextStyle( - fontSize: 26, - fontWeight: FontWeight.w700, - color: Colors.white, - ), - ), - const SizedBox(height: 12), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.15), - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.flag, size: 16, color: statusColor), - const SizedBox(width: 6), - Text( - expense['status'] ?? 'N/A', - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ], - ), - ), - - // Details Section - Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _detailRow(Icons.calendar_today, "Date & Time", - expense['date'] ?? 'N/A'), - _detailRow(Icons.category_outlined, "Expense Type", - "${expense['category']} (GST: ${expense['gstNo'] ?? 'N/A'})"), - _detailRow(Icons.payment, "Payment Mode", - expense['paymentMode'] ?? 'N/A'), - _detailRow(Icons.person, "Paid By", - expense['paidBy'] ?? 'N/A'), - _detailRow(Icons.access_time, "Transaction Date", - expense['transactionDate'] ?? 'N/A'), - _detailRow(Icons.location_on_outlined, "Location", - expense['location'] ?? 'N/A'), - _detailRow(Icons.store, "Supplier Name", - expense['supplierName'] ?? 'N/A'), - _detailRow(Icons.confirmation_num_outlined, - "Transaction ID", expense['transactionId'] ?? 'N/A'), - _detailRow(Icons.description, "Description", - expense['description'] ?? 'N/A'), - ], - ), - ), - ], - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _ExpenseHeader( + title: expense.expensesType.name, + amount: '₹ ${expense.amount.toStringAsFixed(2)}', + status: expense.status.name, + statusColor: statusColor, + ), + const SizedBox(height: 16), + _ExpenseDetailsList(expense: expense), + ], ), ), ); } +} - Widget _detailRow(IconData icon, String title, String value) { - return Padding( - padding: const EdgeInsets.only(bottom: 16), - child: Row( +class _ExpenseHeader extends StatelessWidget { + final String title; + final String amount; + final String status; + final Color statusColor; + + const _ExpenseHeader({ + required this.title, + required this.amount, + required this.status, + required this.statusColor, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 5, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.grey.shade100, - borderRadius: BorderRadius.circular(10), + Text( + title, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: Colors.black, ), - child: Icon(icon, size: 20, color: Colors.grey[800]), ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + const SizedBox(height: 6), + Text( + amount, + style: const TextStyle( + fontSize: 26, + fontWeight: FontWeight.w700, + color: Colors.black, + ), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.15), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.flag, size: 16, color: statusColor), + const SizedBox(width: 6), Text( - title, + status, style: const TextStyle( - fontSize: 13, - color: Colors.grey, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 4), - Text( - value, - style: const TextStyle( - fontSize: 15, + color: Colors.black, fontWeight: FontWeight.w600, ), ), @@ -239,3 +190,103 @@ class ExpenseDetailScreen extends StatelessWidget { ); } } + +class _ExpenseDetailsList extends StatelessWidget { + final ExpenseModel expense; + + const _ExpenseDetailsList({required this.expense}); + + @override + Widget build(BuildContext context) { + final transactionDate = DateTimeUtils.convertUtcToLocal( + expense.transactionDate.toString(), + format: 'dd-MM-yyyy hh:mm a', + ); + final createdAt = DateTimeUtils.convertUtcToLocal( + expense.createdAt.toString(), + format: 'dd-MM-yyyy hh:mm a', + ); + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 5, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _DetailRow(title: "Project", value: expense.project.name), + _DetailRow(title: "Expense Type", value: expense.expensesType.name), + _DetailRow(title: "Payment Mode", value: expense.paymentMode.name), + _DetailRow( + title: "Paid By", + value: + '${expense.paidBy.firstName} ${expense.paidBy.lastName}'), + _DetailRow( + title: "Created By", + value: + '${expense.createdBy.firstName} ${expense.createdBy.lastName}'), + _DetailRow(title: "Transaction Date", value: transactionDate), + _DetailRow(title: "Created At", value: createdAt), + _DetailRow(title: "Supplier Name", value: expense.supplerName), + _DetailRow(title: "Amount", value: '₹ ${expense.amount}'), + _DetailRow(title: "Status", value: expense.status.name), + _DetailRow( + title: "Next Status", + value: expense.nextStatus.map((e) => e.name).join(", ")), + _DetailRow( + title: "Pre-Approved", + value: expense.preApproved ? "Yes" : "No"), + ], + ), + ); + } +} + +class _DetailRow extends StatelessWidget { + final String title; + final String value; + + const _DetailRow({required this.title, required this.value}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 3, + child: Text( + title, + style: const TextStyle( + fontSize: 13, + color: Colors.grey, + fontWeight: FontWeight.w500, + ), + ), + ), + Expanded( + flex: 5, + child: Text( + value, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/view/expense/expense_screen.dart b/lib/view/expense/expense_screen.dart index f2d0217..e0e8f50 100644 --- a/lib/view/expense/expense_screen.dart +++ b/lib/view/expense/expense_screen.dart @@ -1,10 +1,13 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/controller/project_controller.dart'; +import 'package:marco/controller/expense/expense_screen_controller.dart'; +import 'package:marco/helpers/utils/date_time_utils.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; -import 'package:marco/model/expense/add_expense_bottom_sheet.dart'; +import 'package:marco/model/expense/expense_list_model.dart'; import 'package:marco/view/expense/expense_detail_screen.dart'; +import 'package:marco/model/expense/add_expense_bottom_sheet.dart'; class ExpenseMainScreen extends StatefulWidget { const ExpenseMainScreen({super.key}); @@ -17,388 +20,84 @@ class _ExpenseMainScreenState extends State { final RxBool isHistoryView = false.obs; final TextEditingController searchController = TextEditingController(); final RxString searchQuery = ''.obs; - final ProjectController projectController = Get.find(); + final ExpenseController expenseController = Get.put(ExpenseController()); - final List> expenseList = [ - { - 'title': 'Travel Expense', - 'amount': '₹ 1,500', - 'status': 'Request', - 'date': '12 Jul 2025 • 3:45 PM', - 'category': 'Transport', - 'paymentMode': 'UPI', - 'transactionId': 'TXN123451' - }, - { - 'title': 'Hotel Stay', - 'amount': '₹ 4,500', - 'status': 'Approved', - 'date': '11 Jul 2025 • 9:30 AM', - 'category': 'Accommodation', - 'paymentMode': 'Credit Card', - 'transactionId': 'TXN123452' - }, - { - 'title': 'Food Bill', - 'amount': '₹ 1,200', - 'status': 'Paid', - 'date': '10 Jul 2025 • 7:10 PM', - 'category': 'Food', - 'paymentMode': 'Cash', - 'transactionId': 'TXN123453' - }, - ]; - - Color _getStatusColor(String status) { - switch (status) { - case 'Request': - return Colors.blue; - case 'Review': - return Colors.orange; - case 'Approved': - return Colors.green; - case 'Paid': - return Colors.purple; - case 'Closed': - return Colors.grey; - default: - return Colors.black; - } + @override + void initState() { + super.initState(); + expenseController.fetchExpenses(); // Load expenses from API } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.white, - appBar: _buildAppBar(), + appBar: _ExpenseAppBar(projectController: projectController), body: SafeArea( child: Column( children: [ - _buildSearchAndFilter(), - _buildToggleButtons(), + _SearchAndFilter( + searchController: searchController, + onChanged: (value) => searchQuery.value = value, + onFilterTap: _openFilterBottomSheet, + ), + _ToggleButtons(isHistoryView: isHistoryView), Expanded( - child: Obx( - () => isHistoryView.value - ? _buildHistoryList() - : _buildExpenseList(), - ), + child: Obx(() { + if (expenseController.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } + + if (expenseController.errorMessage.isNotEmpty) { + return Center( + child: Text( + expenseController.errorMessage.value, + style: const TextStyle(color: Colors.red), + ), + ); + } + + if (expenseController.expenses.isEmpty) { + return const Center(child: Text("No expenses found.")); + } + + // Apply search filter + final filteredList = expenseController.expenses.where((expense) { + final query = searchQuery.value.toLowerCase(); + return query.isEmpty || + expense.expensesType.name.toLowerCase().contains(query) || + expense.supplerName.toLowerCase().contains(query) || + expense.paymentMode.name.toLowerCase().contains(query); + }).toList(); + + // Split into current month and history + final now = DateTime.now(); + final currentMonthList = filteredList.where((e) => + e.transactionDate.month == now.month && + e.transactionDate.year == now.year).toList(); + + final historyList = filteredList.where((e) => + e.transactionDate.isBefore( + DateTime(now.year, now.month, 1))).toList(); + + final listToShow = + isHistoryView.value ? historyList : currentMonthList; + + return _ExpenseList(expenseList: listToShow); + }), ), ], ), ), floatingActionButton: FloatingActionButton( - onPressed: () { - showAddExpenseBottomSheet(); - }, backgroundColor: Colors.red, + onPressed: showAddExpenseBottomSheet, child: const Icon(Icons.add, color: Colors.white), ), ); } - PreferredSizeWidget _buildAppBar() { - return PreferredSize( - preferredSize: const Size.fromHeight(72), - child: AppBar( - backgroundColor: const Color(0xFFF5F5F5), - elevation: 0.5, - automaticallyImplyLeading: false, - titleSpacing: 0, - title: Padding( - padding: MySpacing.xy(16, 0), - child: Row( - children: [ - IconButton( - icon: const Icon(Icons.arrow_back_ios_new, - color: Colors.black, size: 20), - onPressed: () => Get.offNamed('/dashboard'), - ), - MySpacing.width(8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - MyText.titleLarge( - 'Expenses', - fontWeight: 700, - color: Colors.black, - ), - MySpacing.height(2), - GetBuilder( - builder: (controller) { - final projectName = - controller.selectedProject?.name ?? 'Select Project'; - return InkWell( - onTap: () => Get.toNamed('/project-selector'), - child: Row( - children: [ - const Icon(Icons.work_outline, - size: 14, color: Colors.grey), - MySpacing.width(4), - Expanded( - child: MyText.bodySmall( - projectName, - fontWeight: 600, - overflow: TextOverflow.ellipsis, - color: Colors.grey[700], - ), - ), - ], - ), - ); - }, - ), - ], - ), - ), - ], - ), - ), - ), - ); - } - - Widget _buildSearchAndFilter() { - return Padding( - padding: MySpacing.fromLTRB(12, 10, 12, 0), - child: Row( - children: [ - Expanded( - child: SizedBox( - height: 35, - child: TextField( - controller: searchController, - onChanged: (value) => searchQuery.value = value, - decoration: InputDecoration( - contentPadding: - const EdgeInsets.symmetric(horizontal: 12), - prefixIcon: const Icon(Icons.search, - size: 20, color: Colors.grey), - hintText: 'Search expenses...', - filled: true, - fillColor: Colors.white, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: BorderSide(color: Colors.grey.shade300), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: BorderSide(color: Colors.grey.shade300), - ), - ), - ), - ), - ), - MySpacing.width(8), - IconButton( - icon: const Icon(Icons.tune, color: Colors.black), - onPressed: _openFilterBottomSheet, - ), - ], - ), - ); - } - - Widget _buildToggleButtons() { - return Padding( - padding: MySpacing.fromLTRB(8, 12, 8, 5), - child: Obx(() { - return Container( - padding: const EdgeInsets.all(2), - decoration: BoxDecoration( - color: const Color(0xFFF0F0F0), - borderRadius: BorderRadius.circular(10), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: Row( - children: [ - _buildToggleButton( - label: 'Expenses', - icon: Icons.receipt_long, - selected: !isHistoryView.value, - onTap: () => isHistoryView.value = false, - ), - _buildToggleButton( - label: 'History', - icon: Icons.history, - selected: isHistoryView.value, - onTap: () => isHistoryView.value = true, - ), - ], - ), - ); - }), - ); - } - - Widget _buildToggleButton({ - required String label, - required IconData icon, - required bool selected, - required VoidCallback onTap, - }) { - return Expanded( - child: GestureDetector( - onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 10), - decoration: BoxDecoration( - color: selected ? Colors.red : Colors.transparent, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(icon, size: 16, color: selected ? Colors.white : Colors.grey), - const SizedBox(width: 6), - Text( - label, - style: TextStyle( - color: selected ? Colors.white : Colors.grey, - fontWeight: FontWeight.w600, - fontSize: 13, - ), - ), - ], - ), - ), - ), - ); - } - - Widget _buildExpenseList() { - return Obx(() { - final filteredList = expenseList.where((expense) { - return searchQuery.isEmpty || - expense['title']! - .toLowerCase() - .contains(searchQuery.value.toLowerCase()); - }).toList(); - - return _buildExpenseHistoryList(filteredList); - }); - } - - Widget _buildHistoryList() { - final historyList = expenseList - .where((item) => item['status'] == 'Paid' || item['status'] == 'Closed') - .toList(); - - return _buildExpenseHistoryList(historyList); - } - - Widget _buildExpenseHistoryList(List> list) { - return ListView.builder( - padding: const EdgeInsets.all(12), - itemCount: list.length, - itemBuilder: (context, index) { - final item = list[index]; - return GestureDetector( - onTap: () => Get.to( - () => const ExpenseDetailScreen(), - arguments: {'expense': item}, - ), - child: _buildExpenseCard(item), - ); - }, - ); - } - - Widget _buildExpenseCard(Map item) { - final statusColor = _getStatusColor(item['status']!); - - return Card( - elevation: 3, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), - margin: const EdgeInsets.symmetric(vertical: 8), - child: Padding( - padding: const EdgeInsets.all(14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Title & Amount Row - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - const Icon(Icons.receipt_long, size: 20, color: Colors.red), - const SizedBox(width: 8), - Text( - item['title']!, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - Text( - item['amount']!, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.red, - ), - ), - ], - ), - const SizedBox(height: 8), - _buildInfoRow(Icons.calendar_today, item['date']!), - const SizedBox(height: 6), - _buildInfoRow(Icons.category_outlined, item['category']!), - const SizedBox(height: 6), - _buildInfoRow(Icons.payment, item['paymentMode']!), - _buildInfoRow(Icons.confirmation_num_outlined, item['transactionId']!), - const SizedBox(height: 10), - Align( - alignment: Alignment.centerRight, - child: Container( - padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 10), - decoration: BoxDecoration( - color: statusColor.withOpacity(0.1), - borderRadius: BorderRadius.circular(20), - ), - child: Text( - item['status']!, - style: TextStyle( - color: statusColor, - fontWeight: FontWeight.bold, - fontSize: 12, - ), - ), - ), - ), - ], - ), - ), - ); - } - - Widget _buildInfoRow(IconData icon, String text) { - return Row( - children: [ - Icon(icon, size: 14, color: Colors.grey), - const SizedBox(width: 4), - Text( - text, - style: TextStyle(color: Colors.grey[600], fontSize: 12), - ), - ], - ); - } - void _openFilterBottomSheet() { Get.bottomSheet( Container( @@ -435,3 +134,330 @@ class _ExpenseMainScreenState extends State { ); } } + +// AppBar Widget +class _ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget { + final ProjectController projectController; + + const _ExpenseAppBar({required this.projectController}); + + @override + Size get preferredSize => const Size.fromHeight(72); + + @override + Widget build(BuildContext context) { + return PreferredSize( + preferredSize: preferredSize, + child: AppBar( + backgroundColor: const Color(0xFFF5F5F5), + elevation: 0.5, + automaticallyImplyLeading: false, + titleSpacing: 0, + title: Padding( + padding: MySpacing.xy(16, 0), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back_ios_new, + color: Colors.black, size: 20), + onPressed: () => Get.offNamed('/dashboard'), + ), + MySpacing.width(8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + MyText.titleLarge( + 'Expenses', + fontWeight: 700, + color: Colors.black, + ), + MySpacing.height(2), + GetBuilder( + builder: (_) { + final projectName = + projectController.selectedProject?.name ?? + 'Select Project'; + return InkWell( + onTap: () => Get.toNamed('/project-selector'), + child: Row( + children: [ + const Icon(Icons.work_outline, + size: 14, color: Colors.grey), + MySpacing.width(4), + Expanded( + child: MyText.bodySmall( + projectName, + fontWeight: 600, + overflow: TextOverflow.ellipsis, + color: Colors.grey[700], + ), + ), + ], + ), + ); + }, + ) + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +// Search and Filter Widget +class _SearchAndFilter extends StatelessWidget { + final TextEditingController searchController; + final ValueChanged onChanged; + final VoidCallback onFilterTap; + + const _SearchAndFilter({ + required this.searchController, + required this.onChanged, + required this.onFilterTap, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: MySpacing.fromLTRB(12, 10, 12, 0), + child: Row( + children: [ + Expanded( + child: SizedBox( + height: 35, + child: TextField( + controller: searchController, + onChanged: onChanged, + decoration: InputDecoration( + contentPadding: + const EdgeInsets.symmetric(horizontal: 12), + prefixIcon: + const Icon(Icons.search, size: 20, color: Colors.grey), + hintText: 'Search expenses...', + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + ), + ), + ), + ), + MySpacing.width(8), + IconButton( + icon: const Icon(Icons.tune, color: Colors.black), + onPressed: onFilterTap, + ), + ], + ), + ); + } +} + +// Toggle Buttons Widget +class _ToggleButtons extends StatelessWidget { + final RxBool isHistoryView; + + const _ToggleButtons({required this.isHistoryView}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: MySpacing.fromLTRB(8, 12, 8, 5), + child: Obx(() { + return Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: const Color(0xFFF0F0F0), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + _ToggleButton( + label: 'Expenses', + icon: Icons.receipt_long, + selected: !isHistoryView.value, + onTap: () => isHistoryView.value = false, + ), + _ToggleButton( + label: 'History', + icon: Icons.history, + selected: isHistoryView.value, + onTap: () => isHistoryView.value = true, + ), + ], + ), + ); + }), + ); + } +} + +class _ToggleButton extends StatelessWidget { + final String label; + final IconData icon; + final bool selected; + final VoidCallback onTap; + + const _ToggleButton({ + required this.label, + required this.icon, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Expanded( + child: GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 10), + decoration: BoxDecoration( + color: selected ? Colors.red : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 16, color: selected ? Colors.white : Colors.grey), + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + color: selected ? Colors.white : Colors.grey, + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), + ], + ), + ), + ), + ); + } +} + +// Expense List Widget (Dynamic) +class _ExpenseList extends StatelessWidget { + final List expenseList; + + const _ExpenseList({required this.expenseList}); + + static Color _getStatusColor(String status) { + switch (status) { + case 'Requested': return Colors.blue; + case 'Review': return Colors.orange; + case 'Approved': return Colors.green; + case 'Paid': return Colors.purple; + case 'Closed': return Colors.grey; + default: return Colors.black; + } + } + + @override + Widget build(BuildContext context) { + if (expenseList.isEmpty) { + return const Center(child: Text('No expenses found.')); + } + + return ListView.separated( + padding: const EdgeInsets.all(12), + itemCount: expenseList.length, + separatorBuilder: (_, __) => + Divider(color: Colors.grey.shade300, height: 20), + itemBuilder: (context, index) { + final expense = expenseList[index]; + final statusColor = _getStatusColor(expense.status.name); + + // Convert UTC date to local formatted string + final formattedDate = DateTimeUtils.convertUtcToLocal( + expense.transactionDate.toIso8601String(), + format: 'dd MMM yyyy, hh:mm a', + ); + + return GestureDetector( + onTap: () => Get.to( + () => const ExpenseDetailScreen(), + arguments: {'expense': expense}, + ), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title + Amount row + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const Icon(Icons.receipt_long, + size: 20, color: Colors.red), + const SizedBox(width: 8), + Text( + expense.expensesType.name, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + Text( + '₹ ${expense.amount.toStringAsFixed(2)}', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.red, + ), + ), + ], + ), + const SizedBox(height: 6), + + // Date + Status + Row( + children: [ + Text( + formattedDate, + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + const Spacer(), + Text( + expense.status.name, + style: TextStyle( + color: statusColor, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ], + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index fd193f6..846bfc0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -78,6 +78,7 @@ dependencies: flutter_quill_delta_from_html: ^1.5.2 quill_delta: ^3.0.0-nullsafety.2 connectivity_plus: ^6.1.4 + geocoding: ^4.0.0 dev_dependencies: flutter_test: sdk: flutter From a7bb24ee295119f03d98e6ded878f28e29733517 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Mon, 21 Jul 2025 09:54:13 +0530 Subject: [PATCH 12/65] feat(expense): enhance expense submission process and UI feedback --- .../expense/add_expense_controller.dart | 177 ++++++++++-------- .../expense/add_expense_bottom_sheet.dart | 51 +++-- lib/view/expense/expense_screen.dart | 50 +++-- 3 files changed, 161 insertions(+), 117 deletions(-) diff --git a/lib/controller/expense/add_expense_controller.dart b/lib/controller/expense/add_expense_controller.dart index bb09b1c..17d8078 100644 --- a/lib/controller/expense/add_expense_controller.dart +++ b/lib/controller/expense/add_expense_controller.dart @@ -13,6 +13,7 @@ import 'package:marco/model/expense/expense_status_model.dart'; import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/model/employee_model.dart'; +import 'package:marco/controller/expense/expense_screen_controller.dart'; class AddExpenseController extends GetxController { // === Text Controllers === @@ -22,6 +23,7 @@ class AddExpenseController extends GetxController { final transactionIdController = TextEditingController(); final gstController = TextEditingController(); final locationController = TextEditingController(); + final ExpenseController expenseController = Get.find(); // === Project Mapping === final RxMap projectsMap = {}.obs; @@ -49,6 +51,7 @@ class AddExpenseController extends GetxController { final RxList attachments = [].obs; RxList allEmployees = [].obs; RxBool isLoading = false.obs; + final RxBool isSubmitting = false.obs; @override void onInit() { @@ -172,89 +175,105 @@ class AddExpenseController extends GetxController { // === Submit Expense === Future submitExpense() async { - // Validation for required fields - if (selectedProject.value.isEmpty || - selectedExpenseType.value == null || - selectedPaymentMode.value == null || - descriptionController.text.isEmpty || - supplierController.text.isEmpty || - amountController.text.isEmpty || - selectedExpenseStatus.value == null || - attachments.isEmpty) { + if (isSubmitting.value) return; // Prevent multiple taps + isSubmitting.value = true; + + try { + // === Validation === + if (selectedProject.value.isEmpty || + selectedExpenseType.value == null || + selectedPaymentMode.value == null || + descriptionController.text.isEmpty || + supplierController.text.isEmpty || + amountController.text.isEmpty || + selectedExpenseStatus.value == null || + attachments.isEmpty) { + showAppSnackbar( + title: "Error", + message: "Please fill all required fields.", + type: SnackbarType.error, + ); + return; + } + + final double? amount = double.tryParse(amountController.text); + if (amount == null) { + showAppSnackbar( + title: "Error", + message: "Please enter a valid amount.", + type: SnackbarType.error, + ); + return; + } + + final projectId = projectsMap[selectedProject.value]; + if (projectId == null) { + showAppSnackbar( + title: "Error", + message: "Invalid project selection.", + type: SnackbarType.error, + ); + return; + } + + // === Convert Attachments === + final attachmentData = await Future.wait(attachments.map((file) async { + final bytes = await file.readAsBytes(); + final base64String = base64Encode(bytes); + final mimeType = + lookupMimeType(file.path) ?? 'application/octet-stream'; + final fileSize = await file.length(); + + return { + "fileName": file.path.split('/').last, + "base64Data": base64String, + "contentType": mimeType, + "fileSize": fileSize, + "description": "", + }; + }).toList()); + + // === API Call === + final success = await ApiService.createExpenseApi( + projectId: projectId, + expensesTypeId: selectedExpenseType.value!.id, + paymentModeId: selectedPaymentMode.value!.id, + paidById: selectedPaidBy.value?.id ?? "", + transactionDate: + (selectedTransactionDate.value ?? DateTime.now()).toUtc(), + transactionId: transactionIdController.text, + description: descriptionController.text, + location: locationController.text, + supplerName: supplierController.text, + amount: amount, + noOfPersons: 0, + statusId: selectedExpenseStatus.value!.id, + billAttachments: attachmentData, + ); + + if (success) { + await Get.find().fetchExpenses(); // 🔄 Refresh list + Get.back(); + showAppSnackbar( + title: "Success", + message: "Expense created successfully!", + type: SnackbarType.success, + ); + } else { + showAppSnackbar( + title: "Error", + message: "Failed to create expense. Try again.", + type: SnackbarType.error, + ); + } + } catch (e) { showAppSnackbar( title: "Error", - message: "Please fill all required fields.", - type: SnackbarType.error, - ); - return; - } - - final double? amount = double.tryParse(amountController.text); - if (amount == null) { - showAppSnackbar( - title: "Error", - message: "Please enter a valid amount.", - type: SnackbarType.error, - ); - return; - } - - final projectId = projectsMap[selectedProject.value]; - if (projectId == null) { - showAppSnackbar( - title: "Error", - message: "Invalid project selection.", - type: SnackbarType.error, - ); - return; - } - - // Convert attachments to base64 + meta - final attachmentData = await Future.wait(attachments.map((file) async { - final bytes = await file.readAsBytes(); - final base64String = base64Encode(bytes); - final mimeType = lookupMimeType(file.path) ?? 'application/octet-stream'; - final fileSize = await file.length(); - - return { - "fileName": file.path.split('/').last, - "base64Data": base64String, - "contentType": mimeType, - "fileSize": fileSize, - "description": "", - }; - }).toList()); - - // Submit API call - final success = await ApiService.createExpenseApi( - projectId: projectId, - expensesTypeId: selectedExpenseType.value!.id, - paymentModeId: selectedPaymentMode.value!.id, - paidById: selectedPaidBy.value?.id ?? "", - transactionDate:(selectedTransactionDate.value ?? DateTime.now()).toUtc(), - transactionId: transactionIdController.text, - description: descriptionController.text, - location: locationController.text, - supplerName: supplierController.text, - amount: amount, - noOfPersons: 0, - statusId: selectedExpenseStatus.value!.id, - billAttachments: attachmentData, - ); - - if (success) { - Get.back(); - showAppSnackbar( - title: "Success", - message: "Expense created successfully!", - type: SnackbarType.success, - ); - } else { - showAppSnackbar( - title: "Error", - message: "Failed to create expense. Try again.", + message: "Something went wrong: $e", type: SnackbarType.error, ); + } finally { + isSubmitting.value = false; } } diff --git a/lib/model/expense/add_expense_bottom_sheet.dart b/lib/model/expense/add_expense_bottom_sheet.dart index 3abd58a..d7e6e11 100644 --- a/lib/model/expense/add_expense_bottom_sheet.dart +++ b/lib/model/expense/add_expense_bottom_sheet.dart @@ -395,32 +395,43 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { label: MyText.bodyMedium("Cancel", fontWeight: 600), style: OutlinedButton.styleFrom( - minimumSize: - const Size.fromHeight(48), + minimumSize: const Size.fromHeight(48), ), ), ), const SizedBox(width: 12), Expanded( - child: ElevatedButton.icon( - onPressed: controller.submitExpense, - icon: const Icon(Icons.check, size: 18), - label: MyText.bodyMedium( - "Submit", - color: Colors.white, - fontWeight: 600, - ), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.indigo, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), + child: Obx(() { + final isLoading = controller.isSubmitting.value; + return ElevatedButton.icon( + onPressed: + isLoading ? null : controller.submitExpense, + icon: isLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.check, size: 18), + label: MyText.bodyMedium( + isLoading ? "Submitting..." : "Submit", + color: Colors.white, + fontWeight: 600, ), - padding: - const EdgeInsets.symmetric(vertical: 14), - minimumSize: - const Size.fromHeight(48), - ), - ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.indigo, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: + const EdgeInsets.symmetric(vertical: 14), + minimumSize: const Size.fromHeight(48), + ), + ); + }), ), ], ) diff --git a/lib/view/expense/expense_screen.dart b/lib/view/expense/expense_screen.dart index e0e8f50..c01b69e 100644 --- a/lib/view/expense/expense_screen.dart +++ b/lib/view/expense/expense_screen.dart @@ -63,7 +63,8 @@ class _ExpenseMainScreenState extends State { } // Apply search filter - final filteredList = expenseController.expenses.where((expense) { + final filteredList = + expenseController.expenses.where((expense) { final query = searchQuery.value.toLowerCase(); return query.isEmpty || expense.expensesType.name.toLowerCase().contains(query) || @@ -71,15 +72,22 @@ class _ExpenseMainScreenState extends State { expense.paymentMode.name.toLowerCase().contains(query); }).toList(); + // Sort by latest transaction date first + filteredList.sort( + (a, b) => b.transactionDate.compareTo(a.transactionDate)); + // Split into current month and history final now = DateTime.now(); - final currentMonthList = filteredList.where((e) => - e.transactionDate.month == now.month && - e.transactionDate.year == now.year).toList(); + final currentMonthList = filteredList + .where((e) => + e.transactionDate.month == now.month && + e.transactionDate.year == now.year) + .toList(); - final historyList = filteredList.where((e) => - e.transactionDate.isBefore( - DateTime(now.year, now.month, 1))).toList(); + final historyList = filteredList + .where((e) => e.transactionDate + .isBefore(DateTime(now.year, now.month, 1))) + .toList(); final listToShow = isHistoryView.value ? historyList : currentMonthList; @@ -235,8 +243,7 @@ class _SearchAndFilter extends StatelessWidget { controller: searchController, onChanged: onChanged, decoration: InputDecoration( - contentPadding: - const EdgeInsets.symmetric(horizontal: 12), + contentPadding: const EdgeInsets.symmetric(horizontal: 12), prefixIcon: const Icon(Icons.search, size: 20, color: Colors.grey), hintText: 'Search expenses...', @@ -257,7 +264,7 @@ class _SearchAndFilter extends StatelessWidget { MySpacing.width(8), IconButton( icon: const Icon(Icons.tune, color: Colors.black), - onPressed: onFilterTap, + onPressed: null, ), ], ), @@ -339,7 +346,8 @@ class _ToggleButton extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(icon, size: 16, color: selected ? Colors.white : Colors.grey), + Icon(icon, + size: 16, color: selected ? Colors.white : Colors.grey), const SizedBox(width: 6), Text( label, @@ -365,12 +373,18 @@ class _ExpenseList extends StatelessWidget { static Color _getStatusColor(String status) { switch (status) { - case 'Requested': return Colors.blue; - case 'Review': return Colors.orange; - case 'Approved': return Colors.green; - case 'Paid': return Colors.purple; - case 'Closed': return Colors.grey; - default: return Colors.black; + case 'Requested': + return Colors.blue; + case 'Review': + return Colors.orange; + case 'Approved': + return Colors.green; + case 'Paid': + return Colors.purple; + case 'Closed': + return Colors.grey; + default: + return Colors.black; } } @@ -381,7 +395,7 @@ class _ExpenseList extends StatelessWidget { } return ListView.separated( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.fromLTRB(12, 12, 12, 80), itemCount: expenseList.length, separatorBuilder: (_, __) => Divider(color: Colors.grey.shade300, height: 20), From ff01c05a734b55e4c2a9bfd0439efd502883dacb Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Mon, 21 Jul 2025 15:24:18 +0530 Subject: [PATCH 13/65] feat(expense): improve expense submission validation and UI feedback --- .../expense/add_expense_controller.dart | 26 +-- lib/helpers/services/api_service.dart | 2 - .../expense/add_expense_bottom_sheet.dart | 149 +++++++----------- lib/view/expense/expense_screen.dart | 88 +++-------- 4 files changed, 98 insertions(+), 167 deletions(-) diff --git a/lib/controller/expense/add_expense_controller.dart b/lib/controller/expense/add_expense_controller.dart index 17d8078..9073035 100644 --- a/lib/controller/expense/add_expense_controller.dart +++ b/lib/controller/expense/add_expense_controller.dart @@ -173,6 +173,7 @@ class AddExpenseController extends GetxController { } } + // === Submit Expense === // === Submit Expense === Future submitExpense() async { if (isSubmitting.value) return; // Prevent multiple taps @@ -180,17 +181,21 @@ class AddExpenseController extends GetxController { try { // === Validation === - if (selectedProject.value.isEmpty || - selectedExpenseType.value == null || - selectedPaymentMode.value == null || - descriptionController.text.isEmpty || - supplierController.text.isEmpty || - amountController.text.isEmpty || - selectedExpenseStatus.value == null || - attachments.isEmpty) { + List missingFields = []; + + if (selectedProject.value.isEmpty) missingFields.add("Project"); + if (selectedExpenseType.value == null) missingFields.add("Expense Type"); + if (selectedPaymentMode.value == null) missingFields.add("Payment Mode"); + if (selectedPaidBy.value == null) missingFields.add("Paid By"); + if (amountController.text.isEmpty) missingFields.add("Amount"); + if (supplierController.text.isEmpty) missingFields.add("Supplier Name"); + if (descriptionController.text.isEmpty) missingFields.add("Description"); + if (attachments.isEmpty) missingFields.add("Attachments"); + + if (missingFields.isNotEmpty) { showAppSnackbar( - title: "Error", - message: "Please fill all required fields.", + title: "Missing Fields", + message: "Please provide: ${missingFields.join(', ')}.", type: SnackbarType.error, ); return; @@ -247,7 +252,6 @@ class AddExpenseController extends GetxController { supplerName: supplierController.text, amount: amount, noOfPersons: 0, - statusId: selectedExpenseStatus.value!.id, billAttachments: attachmentData, ); diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 0427760..405fe1f 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -295,7 +295,6 @@ class ApiService { required String supplerName, required double amount, required int noOfPersons, - required String statusId, required List> billAttachments, }) async { final payload = { @@ -310,7 +309,6 @@ class ApiService { "supplerName": supplerName, "amount": amount, "noOfPersons": noOfPersons, - "statusId": statusId, "billAttachments": billAttachments, }; diff --git a/lib/model/expense/add_expense_bottom_sheet.dart b/lib/model/expense/add_expense_bottom_sheet.dart index d7e6e11..ac6e5af 100644 --- a/lib/model/expense/add_expense_bottom_sheet.dart +++ b/lib/model/expense/add_expense_bottom_sheet.dart @@ -4,7 +4,6 @@ import 'package:marco/controller/expense/add_expense_controller.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/model/expense/payment_types_model.dart'; import 'package:marco/model/expense/expense_type_model.dart'; -import 'package:marco/model/expense/expense_status_model.dart'; void showAddExpenseBottomSheet() { Get.bottomSheet( @@ -23,9 +22,9 @@ class _AddExpenseBottomSheet extends StatefulWidget { class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { final AddExpenseController controller = Get.put(AddExpenseController()); final RxBool isProjectExpanded = false.obs; + void _showEmployeeList(BuildContext context) { final employees = controller.allEmployees; - showModalBottomSheet( context: context, backgroundColor: Colors.white, @@ -84,7 +83,10 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { // Project Dropdown const _SectionTitle( - icon: Icons.work_outline, title: "Project"), + icon: Icons.work_outline, + title: "Project", + requiredField: true, + ), const SizedBox(height: 6), Obx(() { return _DropdownTile( @@ -105,6 +107,7 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { const _SectionTitle( icon: Icons.category_outlined, title: "Expense Type & GST No.", + requiredField: true, ), const SizedBox(height: 6), Obx(() { @@ -128,7 +131,10 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { // Payment Mode const _SectionTitle( - icon: Icons.payment, title: "Payment Mode"), + icon: Icons.payment, + title: "Payment Mode", + requiredField: true, + ), const SizedBox(height: 6), Obx(() { return _DropdownTile( @@ -143,6 +149,14 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { ); }), const SizedBox(height: 16), + + // Paid By + const _SectionTitle( + icon: Icons.person_outline, + title: "Paid By", + requiredField: true, + ), + const SizedBox(height: 6), Obx(() { final selected = controller.selectedPaidBy.value; return GestureDetector( @@ -171,28 +185,12 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { ); }), const SizedBox(height: 16), - // Expense Status - const _SectionTitle( - icon: Icons.flag_outlined, title: "Status"), - const SizedBox(height: 6), - Obx(() { - return _DropdownTile( - title: controller.selectedExpenseStatus.value?.name ?? - "Select Status", - onTap: () => _showOptionList( - context, - controller.expenseStatuses.toList(), - (s) => s.name, - (val) => - controller.selectedExpenseStatus.value = val, - ), - ); - }), - const SizedBox(height: 16), - // Amount const _SectionTitle( - icon: Icons.currency_rupee, title: "Amount"), + icon: Icons.currency_rupee, + title: "Amount", + requiredField: true, + ), const SizedBox(height: 6), _CustomTextField( controller: controller.amountController, @@ -200,10 +198,12 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { keyboardType: TextInputType.number, ), const SizedBox(height: 16), + // Supplier Name const _SectionTitle( icon: Icons.store_mall_directory_outlined, title: "Supplier Name", + requiredField: true, ), const SizedBox(height: 6), _CustomTextField( @@ -211,10 +211,12 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { hint: "Enter Supplier Name", ), const SizedBox(height: 16), + // Transaction ID const _SectionTitle( - icon: Icons.confirmation_number_outlined, - title: "Transaction ID"), + icon: Icons.confirmation_number_outlined, + title: "Transaction ID", + ), const SizedBox(height: 6), _CustomTextField( controller: controller.transactionIdController, @@ -256,13 +258,15 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { ), ), ), - const SizedBox(height: 16), + // Attachments Section const _SectionTitle( - icon: Icons.attach_file, title: "Attachments"), + icon: Icons.attach_file, + title: "Attachments", + requiredField: true, + ), const SizedBox(height: 6), - Obx(() { return Wrap( spacing: 8, @@ -376,6 +380,7 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { const _SectionTitle( icon: Icons.description_outlined, title: "Description", + requiredField: true, ), const SizedBox(height: 6), _CustomTextField( @@ -439,28 +444,6 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { ), ); }), - - // Project Selection List - Obx(() { - if (!isProjectExpanded.value) return const SizedBox.shrink(); - return Positioned( - top: 110, - left: 16, - right: 16, - child: Material( - elevation: 4, - borderRadius: BorderRadius.circular(12), - child: Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.all(10), - child: _buildProjectSelectionList(), - ), - ), - ); - }), ], ), ), @@ -468,44 +451,6 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { ); } - Widget _buildProjectSelectionList() { - return ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 300), - child: ListView.builder( - shrinkWrap: true, - itemCount: controller.globalProjects.length, - itemBuilder: (context, index) { - final project = controller.globalProjects[index]; - final isSelected = project == controller.selectedProject.value; - - return RadioListTile( - value: project, - groupValue: controller.selectedProject.value, - onChanged: (val) { - controller.selectedProject.value = val!; - isProjectExpanded.value = false; - }, - title: Text( - project, - style: TextStyle( - fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, - color: isSelected ? Colors.blueAccent : Colors.black87, - ), - ), - activeColor: Colors.blueAccent, - tileColor: isSelected - ? Colors.blueAccent.withOpacity(0.1) - : Colors.transparent, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(6), - ), - visualDensity: const VisualDensity(vertical: -4), - ); - }, - ), - ); - } - Future _showOptionList( BuildContext context, List options, @@ -554,16 +499,38 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { class _SectionTitle extends StatelessWidget { final IconData icon; final String title; - const _SectionTitle({required this.icon, required this.title}); + final bool requiredField; + + const _SectionTitle({ + required this.icon, + required this.title, + this.requiredField = false, + }); @override Widget build(BuildContext context) { final color = Colors.grey[700]; return Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ Icon(icon, color: color, size: 18), const SizedBox(width: 8), - MyText.bodyMedium(title, fontWeight: 600), + RichText( + text: TextSpan( + style: DefaultTextStyle.of(context).style.copyWith( + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + children: [ + TextSpan(text: title), + if (requiredField) + const TextSpan( + text: ' *', + style: TextStyle(color: Colors.red), + ), + ], + ), + ), ], ); } diff --git a/lib/view/expense/expense_screen.dart b/lib/view/expense/expense_screen.dart index c01b69e..96803c1 100644 --- a/lib/view/expense/expense_screen.dart +++ b/lib/view/expense/expense_screen.dart @@ -51,18 +51,17 @@ class _ExpenseMainScreenState extends State { if (expenseController.errorMessage.isNotEmpty) { return Center( - child: Text( + child: MyText.bodyMedium( expenseController.errorMessage.value, - style: const TextStyle(color: Colors.red), + color: Colors.red, ), ); } if (expenseController.expenses.isEmpty) { - return const Center(child: Text("No expenses found.")); + return Center(child: MyText.bodyMedium("No expenses found.")); } - // Apply search filter final filteredList = expenseController.expenses.where((expense) { final query = searchQuery.value.toLowerCase(); @@ -76,7 +75,6 @@ class _ExpenseMainScreenState extends State { filteredList.sort( (a, b) => b.transactionDate.compareTo(a.transactionDate)); - // Split into current month and history final now = DateTime.now(); final currentMonthList = filteredList .where((e) => @@ -117,23 +115,23 @@ class _ExpenseMainScreenState extends State { child: Wrap( runSpacing: 10, children: [ - const Text( + MyText.bodyLarge( 'Filter Expenses', - style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + fontWeight: 700, ), ListTile( leading: const Icon(Icons.date_range), - title: const Text('Date Range'), + title: MyText.bodyMedium('Date Range'), onTap: () {}, ), ListTile( leading: const Icon(Icons.work_outline), - title: const Text('Project'), + title: MyText.bodyMedium('Project'), onTap: () {}, ), ListTile( leading: const Icon(Icons.check_circle_outline), - title: const Text('Status'), + title: MyText.bodyMedium('Status'), onTap: () {}, ), ], @@ -264,7 +262,7 @@ class _SearchAndFilter extends StatelessWidget { MySpacing.width(8), IconButton( icon: const Icon(Icons.tune, color: Colors.black), - onPressed: null, + onPressed: null, // Disabled as per request ), ], ), @@ -349,13 +347,11 @@ class _ToggleButton extends StatelessWidget { Icon(icon, size: 16, color: selected ? Colors.white : Colors.grey), const SizedBox(width: 6), - Text( + MyText.bodyMedium( label, - style: TextStyle( - color: selected ? Colors.white : Colors.grey, - fontWeight: FontWeight.w600, - fontSize: 13, - ), + color: selected ? Colors.white : Colors.grey, + fontWeight: 600, + fontSize: 13, ), ], ), @@ -371,45 +367,27 @@ class _ExpenseList extends StatelessWidget { const _ExpenseList({required this.expenseList}); - static Color _getStatusColor(String status) { - switch (status) { - case 'Requested': - return Colors.blue; - case 'Review': - return Colors.orange; - case 'Approved': - return Colors.green; - case 'Paid': - return Colors.purple; - case 'Closed': - return Colors.grey; - default: - return Colors.black; - } - } - @override Widget build(BuildContext context) { if (expenseList.isEmpty) { - return const Center(child: Text('No expenses found.')); + return Center(child: MyText.bodyMedium('No expenses found.')); } return ListView.separated( - padding: const EdgeInsets.fromLTRB(12, 12, 12, 80), + padding: const EdgeInsets.fromLTRB(12, 12, 12, 80), itemCount: expenseList.length, separatorBuilder: (_, __) => Divider(color: Colors.grey.shade300, height: 20), itemBuilder: (context, index) { final expense = expenseList[index]; - final statusColor = _getStatusColor(expense.status.name); - // Convert UTC date to local formatted string final formattedDate = DateTimeUtils.convertUtcToLocal( expense.transactionDate.toIso8601String(), format: 'dd MMM yyyy, hh:mm a', ); return GestureDetector( + behavior: HitTestBehavior.opaque, onTap: () => Get.to( () => const ExpenseDetailScreen(), arguments: {'expense': expense}, @@ -419,51 +397,35 @@ class _ExpenseList extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Title + Amount row Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( children: [ - const Icon(Icons.receipt_long, - size: 20, color: Colors.red), - const SizedBox(width: 8), - Text( + MyText.bodyMedium( expense.expensesType.name, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), + fontWeight: 700, ), ], ), - Text( + MyText.bodyMedium( '₹ ${expense.amount.toStringAsFixed(2)}', - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.red, - ), + fontWeight: 600, ), ], ), const SizedBox(height: 6), - - // Date + Status Row( children: [ - Text( + MyText.bodySmall( formattedDate, - style: TextStyle(fontSize: 12, color: Colors.grey[600]), + color: Colors.grey[600], ), const Spacer(), - Text( + MyText.bodySmall( expense.status.name, - style: TextStyle( - color: statusColor, - fontWeight: FontWeight.bold, - fontSize: 12, - ), + fontWeight: 600, + color: Colors.black, ), ], ), From d7b62323d6dae1987b5565d1f6581a7f4d3116dc Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Mon, 21 Jul 2025 16:12:13 +0530 Subject: [PATCH 14/65] fix(expense): remove unnecessary hit test behavior from expense list item --- lib/view/expense/expense_screen.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/view/expense/expense_screen.dart b/lib/view/expense/expense_screen.dart index 96803c1..edf0eb5 100644 --- a/lib/view/expense/expense_screen.dart +++ b/lib/view/expense/expense_screen.dart @@ -387,7 +387,6 @@ class _ExpenseList extends StatelessWidget { ); return GestureDetector( - behavior: HitTestBehavior.opaque, onTap: () => Get.to( () => const ExpenseDetailScreen(), arguments: {'expense': expense}, @@ -402,6 +401,7 @@ class _ExpenseList extends StatelessWidget { children: [ Row( children: [ + MyText.bodyMedium( expense.expensesType.name, fontWeight: 700, From d90673523ad2d6de0963e41006d558fa865de449 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Mon, 21 Jul 2025 16:22:18 +0530 Subject: [PATCH 15/65] feat(directory): add 'Create Bucket' option to actions menu --- lib/view/directory/directory_view.dart | 35 +++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/lib/view/directory/directory_view.dart b/lib/view/directory/directory_view.dart index 30c8caa..2230a24 100644 --- a/lib/view/directory/directory_view.dart +++ b/lib/view/directory/directory_view.dart @@ -267,7 +267,7 @@ class _DirectoryViewState extends State { itemBuilder: (context) { List> menuItems = []; - // Section: Actions (Always visible now) + // Section: Actions menuItems.add( const PopupMenuItem( enabled: false, @@ -282,6 +282,38 @@ class _DirectoryViewState extends State { ), ); + // Create Bucket option + menuItems.add( + PopupMenuItem( + value: 2, + child: Row( + children: const [ + Icon(Icons.add_box_outlined, + size: 20, color: Colors.black87), + SizedBox(width: 10), + Expanded(child: Text("Create Bucket")), + Icon(Icons.chevron_right, + size: 20, color: Colors.red), + ], + ), + onTap: () { + Future.delayed(Duration.zero, () async { + final created = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => const CreateBucketBottomSheet(), + ); + + if (created == true) { + await controller.fetchBuckets(); + } + }); + }, + ), + ); + + // Manage Buckets option menuItems.add( PopupMenuItem( value: 1, @@ -318,6 +350,7 @@ class _DirectoryViewState extends State { ), ); + // Show Inactive switch menuItems.add( PopupMenuItem( value: 0, From debc12bc1b8d0542eb65ee03247651c38590fb5b Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Mon, 21 Jul 2025 16:39:11 +0530 Subject: [PATCH 16/65] feat(expense): add refresh functionality to expense list --- lib/view/expense/expense_screen.dart | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/lib/view/expense/expense_screen.dart b/lib/view/expense/expense_screen.dart index edf0eb5..40460eb 100644 --- a/lib/view/expense/expense_screen.dart +++ b/lib/view/expense/expense_screen.dart @@ -26,7 +26,11 @@ class _ExpenseMainScreenState extends State { @override void initState() { super.initState(); - expenseController.fetchExpenses(); // Load expenses from API + expenseController.fetchExpenses(); + } + + void _refreshExpenses() { + expenseController.fetchExpenses(); } @override @@ -41,6 +45,7 @@ class _ExpenseMainScreenState extends State { searchController: searchController, onChanged: (value) => searchQuery.value = value, onFilterTap: _openFilterBottomSheet, + onRefreshTap: _refreshExpenses, ), _ToggleButtons(isHistoryView: isHistoryView), Expanded( @@ -217,15 +222,18 @@ class _ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget { } // Search and Filter Widget + class _SearchAndFilter extends StatelessWidget { final TextEditingController searchController; final ValueChanged onChanged; final VoidCallback onFilterTap; + final VoidCallback onRefreshTap; const _SearchAndFilter({ required this.searchController, required this.onChanged, required this.onFilterTap, + required this.onRefreshTap, }); @override @@ -260,6 +268,18 @@ class _SearchAndFilter extends StatelessWidget { ), ), MySpacing.width(8), + Tooltip( + message: 'Refresh Data', + child: InkWell( + borderRadius: BorderRadius.circular(24), + onTap: onRefreshTap, + child: const Padding( + padding: EdgeInsets.all(0), + child: Icon(Icons.refresh, color: Colors.green, size: 28), + ), + ), + ), + MySpacing.width(8), IconButton( icon: const Icon(Icons.tune, color: Colors.black), onPressed: null, // Disabled as per request @@ -401,7 +421,6 @@ class _ExpenseList extends StatelessWidget { children: [ Row( children: [ - MyText.bodyMedium( expense.expensesType.name, fontWeight: 700, From e0ed35a671f609dc00f767f563a73c6862025591 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Mon, 21 Jul 2025 18:35:27 +0530 Subject: [PATCH 17/65] feat(expense): implement update expense status functionality and UI integration --- .../expense/expense_screen_controller.dart | 30 ++++++++ lib/helpers/services/api_endpoints.dart | 2 +- lib/helpers/services/api_service.dart | 45 ++++++++++++ lib/view/expense/expense_detail_screen.dart | 70 +++++++++++++++++-- 4 files changed, 141 insertions(+), 6 deletions(-) diff --git a/lib/controller/expense/expense_screen_controller.dart b/lib/controller/expense/expense_screen_controller.dart index 6568aa3..9359c21 100644 --- a/lib/controller/expense/expense_screen_controller.dart +++ b/lib/controller/expense/expense_screen_controller.dart @@ -39,4 +39,34 @@ class ExpenseController extends GetxController { isLoading.value = false; } } + + /// Update expense status and refresh the list + Future updateExpenseStatus(String expenseId, String statusId) async { + isLoading.value = true; + errorMessage.value = ''; + + try { + logSafe("Updating status for expense: $expenseId -> $statusId"); + final success = await ApiService.updateExpenseStatusApi( + expenseId: expenseId, + statusId: statusId, + ); + + if (success) { + logSafe("Expense status updated successfully."); + await fetchExpenses(); + return true; + } else { + errorMessage.value = "Failed to update expense status."; + return false; + } + } catch (e, stack) { + errorMessage.value = 'An unexpected error occurred.'; + logSafe("Exception in updateExpenseStatus: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + return false; + } finally { + isLoading.value = false; + } + } } diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index 4b7b24a..1e386b2 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -59,5 +59,5 @@ class ApiEndpoints { static const String getMasterPaymentModes = "/master/payment-modes"; static const String getMasterExpenseStatus = "/master/expenses-status"; static const String getMasterExpenseTypes = "/master/expenses-types"; - + static const String updateExpenseStatus = "/expense/action"; } diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 405fe1f..d56a5ac 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -241,6 +241,51 @@ class ApiService { // === Expense APIs === // + /// Update Expense Status API + static Future updateExpenseStatusApi({ + required String expenseId, + required String statusId, + }) async { + final payload = { + "expenseId": expenseId, + "statusId": statusId, + }; + + const endpoint = ApiEndpoints.updateExpenseStatus; + logSafe("Updating expense status with payload: $payload"); + + try { + final response = + await _postRequest(endpoint, payload, customTimeout: extendedTimeout); + + if (response == null) { + logSafe("Update expense status failed: null response", + level: LogLevel.error); + return false; + } + + logSafe("Update expense status response status: ${response.statusCode}"); + logSafe("Update expense status response body: ${response.body}"); + + final json = jsonDecode(response.body); + if (json['success'] == true) { + logSafe("Expense status updated successfully: ${json['data']}"); + return true; + } else { + logSafe( + "Failed to update expense status: ${json['message'] ?? 'Unknown error'}", + level: LogLevel.warning, + ); + } + } catch (e, stack) { + logSafe("Exception during updateExpenseStatus API: $e", + level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return false; + } + static Future?> getExpenseListApi() async { const endpoint = ApiEndpoints.getExpenseList; diff --git a/lib/view/expense/expense_detail_screen.dart b/lib/view/expense/expense_detail_screen.dart index 951e5d6..d5450ab 100644 --- a/lib/view/expense/expense_detail_screen.dart +++ b/lib/view/expense/expense_detail_screen.dart @@ -1,10 +1,11 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/controller/project_controller.dart'; +import 'package:marco/controller/expense/expense_screen_controller.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/model/expense/expense_list_model.dart'; -import 'package:marco/helpers/utils/date_time_utils.dart'; // Import DateTimeUtils +import 'package:marco/helpers/utils/date_time_utils.dart'; class ExpenseDetailScreen extends StatelessWidget { const ExpenseDetailScreen({super.key}); @@ -31,6 +32,9 @@ class ExpenseDetailScreen extends StatelessWidget { final ExpenseModel expense = Get.arguments['expense'] as ExpenseModel; final statusColor = getStatusColor(expense.status.name); final projectController = Get.find(); + final expenseController = Get.find(); + print( + "Next Status List: ${expense.nextStatus.map((e) => e.toJson()).toList()}"); return Scaffold( backgroundColor: const Color(0xFFF7F7F7), @@ -110,6 +114,64 @@ class ExpenseDetailScreen extends StatelessWidget { ], ), ), + bottomNavigationBar: expense.nextStatus.isNotEmpty + ? SafeArea( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.center, + children: expense.nextStatus.map((next) { + return ElevatedButton( + style: ElevatedButton.styleFrom( + minimumSize: const Size(100, 40), + padding: const EdgeInsets.symmetric( + vertical: 8, horizontal: 12), + backgroundColor: Colors.red, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6), + ), + ), + onPressed: () async { + final success = + await expenseController.updateExpenseStatus( + expense.id, + next.id, + ); + + if (success) { + Get.snackbar( + 'Success', + 'Expense moved to ${next.name}', + backgroundColor: Colors.green.withOpacity(0.8), + colorText: Colors.white, + ); + Get.back(result: true); + } else { + Get.snackbar( + 'Error', + 'Failed to update status.', + backgroundColor: Colors.red.withOpacity(0.8), + colorText: Colors.white, + ); + } + }, + child: Text( + next.name, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 14, + ), + overflow: TextOverflow.ellipsis, + ), + ); + }).toList(), + ), + ), + ) + : null, ); } } @@ -228,8 +290,7 @@ class _ExpenseDetailsList extends StatelessWidget { _DetailRow(title: "Payment Mode", value: expense.paymentMode.name), _DetailRow( title: "Paid By", - value: - '${expense.paidBy.firstName} ${expense.paidBy.lastName}'), + value: '${expense.paidBy.firstName} ${expense.paidBy.lastName}'), _DetailRow( title: "Created By", value: @@ -243,8 +304,7 @@ class _ExpenseDetailsList extends StatelessWidget { title: "Next Status", value: expense.nextStatus.map((e) => e.name).join(", ")), _DetailRow( - title: "Pre-Approved", - value: expense.preApproved ? "Yes" : "No"), + title: "Pre-Approved", value: expense.preApproved ? "Yes" : "No"), ], ), ); From 586d18565f3ccf55292797335cbffe4af857a81e Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Tue, 22 Jul 2025 13:01:07 +0530 Subject: [PATCH 18/65] feat(expense): implement expense filtering functionality with UI integration --- .../expense/add_expense_controller.dart | 2 +- .../expense/expense_screen_controller.dart | 148 ++++++++- lib/helpers/services/api_service.dart | 46 ++- lib/helpers/utils/date_time_utils.dart | 14 +- lib/model/expense/expense_list_model.dart | 16 +- .../expense/expense_filter_bottom_sheet.dart | 304 ++++++++++++++++++ lib/view/expense/expense_screen.dart | 43 +-- 7 files changed, 525 insertions(+), 48 deletions(-) create mode 100644 lib/view/expense/expense_filter_bottom_sheet.dart diff --git a/lib/controller/expense/add_expense_controller.dart b/lib/controller/expense/add_expense_controller.dart index 9073035..32c0fa2 100644 --- a/lib/controller/expense/add_expense_controller.dart +++ b/lib/controller/expense/add_expense_controller.dart @@ -119,7 +119,7 @@ class AddExpenseController extends GetxController { } catch (e) { Get.snackbar("Error", "Failed to fetch master data: $e"); } - } + } // === Fetch Current Location === Future fetchCurrentLocation() async { diff --git a/lib/controller/expense/expense_screen_controller.dart b/lib/controller/expense/expense_screen_controller.dart index 9359c21..39dd724 100644 --- a/lib/controller/expense/expense_screen_controller.dart +++ b/lib/controller/expense/expense_screen_controller.dart @@ -1,28 +1,86 @@ +import 'dart:convert'; import 'package:get/get.dart'; import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/model/expense/expense_list_model.dart'; +import 'package:marco/model/expense/payment_types_model.dart'; +import 'package:marco/model/expense/expense_type_model.dart'; +import 'package:marco/model/expense/expense_status_model.dart'; +import 'package:marco/model/employee_model.dart'; class ExpenseController extends GetxController { final RxList expenses = [].obs; final RxBool isLoading = false.obs; final RxString errorMessage = ''.obs; + final RxList expenseTypes = [].obs; + final RxList paymentModes = [].obs; + final RxList expenseStatuses = [].obs; + final RxList globalProjects = [].obs; + final RxMap projectsMap = {}.obs; + RxList allEmployees = [].obs; - /// Fetch all expenses from API - Future fetchExpenses() async { + int _pageSize = 20; + int _pageNumber = 1; + + @override + void onInit() { + super.onInit(); + loadInitialMasterData(); + fetchAllEmployees(); + } + + /// Load projects, expense types, statuses, and payment modes on controller init + Future loadInitialMasterData() async { + await fetchGlobalProjects(); + await fetchMasterData(); + } + + /// Fetch expenses with filters and pagination (called explicitly when needed) + Future fetchExpenses({ + List? projectIds, + List? statusIds, + List? createdByIds, + List? paidByIds, + DateTime? startDate, + DateTime? endDate, + int pageSize = 20, + int pageNumber = 1, + }) async { isLoading.value = true; errorMessage.value = ''; + _pageSize = pageSize; + _pageNumber = pageNumber; + + final Map filterMap = { + "projectIds": projectIds ?? [], + "statusIds": statusIds ?? [], + "createdByIds": createdByIds ?? [], + "paidByIds": paidByIds ?? [], + "startDate": startDate?.toIso8601String(), + "endDate": endDate?.toIso8601String(), + }; + try { - final result = await ApiService.getExpenseListApi(); + logSafe("Fetching expenses with filter: ${jsonEncode(filterMap)}"); + + final result = await ApiService.getExpenseListApi( + filter: jsonEncode(filterMap), + pageSize: _pageSize, + pageNumber: _pageNumber, + ); if (result != null) { try { - // Convert the raw result (List) to List - final List parsed = List.from( - result.map((e) => ExpenseModel.fromJson(e))); + final List rawList = result['expenses'] ?? []; + final parsed = rawList + .map((e) => ExpenseModel.fromJson(e as Map)) + .toList(); + expenses.assignAll(parsed); logSafe("Expenses loaded: ${parsed.length}"); + logSafe( + "Pagination Info: Page ${result['currentPage']} of ${result['totalPages']} | Total: ${result['totalEntites']}"); } catch (e) { errorMessage.value = 'Failed to parse expenses: $e'; logSafe("Parse error in fetchExpenses: $e", level: LogLevel.error); @@ -40,6 +98,82 @@ class ExpenseController extends GetxController { } } + /// Fetch master data: expense types, payment modes, and expense status + Future fetchMasterData() async { + try { + final expenseTypesData = await ApiService.getMasterExpenseTypes(); + if (expenseTypesData is List) { + expenseTypes.value = + expenseTypesData.map((e) => ExpenseTypeModel.fromJson(e)).toList(); + } + + final paymentModesData = await ApiService.getMasterPaymentModes(); + if (paymentModesData is List) { + paymentModes.value = + paymentModesData.map((e) => PaymentModeModel.fromJson(e)).toList(); + } + + final expenseStatusData = await ApiService.getMasterExpenseStatus(); + if (expenseStatusData is List) { + expenseStatuses.value = expenseStatusData + .map((e) => ExpenseStatusModel.fromJson(e)) + .toList(); + } + } catch (e) { + Get.snackbar("Error", "Failed to fetch master data: $e"); + } + } + + /// Fetch list of all global projects + Future fetchGlobalProjects() async { + try { + final response = await ApiService.getGlobalProjects(); + if (response != null) { + final names = []; + for (var item in response) { + final name = item['name']?.toString().trim(); + final id = item['id']?.toString().trim(); + if (name != null && id != null && name.isNotEmpty) { + projectsMap[name] = id; + names.add(name); + } + } + globalProjects.assignAll(names); + logSafe("Fetched ${names.length} global projects"); + } + } catch (e) { + logSafe("Failed to fetch global projects: $e", level: LogLevel.error); + } + } + + /// Fetch all employees for Manage Bucket usage + Future fetchAllEmployees() async { + isLoading.value = true; + + try { + final response = await ApiService.getAllEmployees(); + if (response != null && response.isNotEmpty) { + allEmployees + .assignAll(response.map((json) => EmployeeModel.fromJson(json))); + logSafe( + "All Employees fetched for Manage Bucket: ${allEmployees.length}", + level: LogLevel.info, + ); + } else { + allEmployees.clear(); + logSafe("No employees found for Manage Bucket.", + level: LogLevel.warning); + } + } catch (e) { + allEmployees.clear(); + logSafe("Error fetching employees in Manage Bucket", + level: LogLevel.error, error: e); + } + + isLoading.value = false; + update(); + } + /// Update expense status and refresh the list Future updateExpenseStatus(String expenseId, String statusId) async { isLoading.value = true; @@ -54,7 +188,7 @@ class ExpenseController extends GetxController { if (success) { logSafe("Expense status updated successfully."); - await fetchExpenses(); + await fetchExpenses(); return true; } else { errorMessage.value = "Failed to update expense status."; diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index d56a5ac..cf3c28d 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -286,16 +286,52 @@ class ApiService { return false; } - static Future?> getExpenseListApi() async { - const endpoint = ApiEndpoints.getExpenseList; + static Future?> getExpenseListApi({ + String? filter, + int pageSize = 20, + int pageNumber = 1, + }) async { + // Build the endpoint with query parameters + String endpoint = ApiEndpoints.getExpenseList; + final queryParams = { + 'pageSize': pageSize.toString(), + 'pageNumber': pageNumber.toString(), + }; - logSafe("Fetching expense list..."); + if (filter != null && filter.isNotEmpty) { + queryParams['filter'] = filter; + } + + // Build the full URI + final uri = Uri.parse(endpoint).replace(queryParameters: queryParams); + logSafe("Fetching expense list with URI: $uri"); try { - final response = await _getRequest(endpoint); + final response = await _getRequest(uri.toString()); if (response == null) return null; - return _parseResponse(response, label: 'Expense List'); + final parsed = _parseResponseForAllData(response, label: 'Expense List'); + + if (parsed != null && parsed['data'] is Map) { + final dataObj = parsed['data'] as Map; + + if (dataObj['data'] is List) { + return { + 'currentPage': dataObj['currentPage'] ?? 1, + 'totalPages': dataObj['totalPages'] ?? 1, + 'totalEntites': dataObj['totalEntites'] ?? 0, + 'expenses': List.from(dataObj['data']), + }; + } else { + logSafe("Expense list 'data' is not a list: $dataObj", + level: LogLevel.error); + return null; + } + } else { + logSafe("Unexpected response structure: $parsed", + level: LogLevel.error); + return null; + } } catch (e, stack) { logSafe("Exception during getExpenseListApi: $e", level: LogLevel.error); logSafe("StackTrace: $stack", level: LogLevel.debug); diff --git a/lib/helpers/utils/date_time_utils.dart b/lib/helpers/utils/date_time_utils.dart index 2c6a732..1dca3da 100644 --- a/lib/helpers/utils/date_time_utils.dart +++ b/lib/helpers/utils/date_time_utils.dart @@ -1,7 +1,8 @@ import 'package:intl/intl.dart'; -import 'package:marco/helpers/services/app_logger.dart'; +import 'package:marco/helpers/services/app_logger.dart'; class DateTimeUtils { + /// Converts a UTC datetime string to local time and formats it. static String convertUtcToLocal(String utcTimeString, {String format = 'dd-MM-yyyy'}) { try { logSafe('convertUtcToLocal: input="$utcTimeString", format="$format"'); @@ -32,6 +33,17 @@ class DateTimeUtils { } } + /// Public utility for formatting any DateTime. + static String formatDate(DateTime date, String format) { + try { + return DateFormat(format).format(date); + } catch (e, stackTrace) { + logSafe('formatDate failed: $e', error: e, stackTrace: stackTrace); + return 'Invalid Date'; + } + } + + /// Internal formatter with default format. static String _formatDateTime(DateTime dateTime, {String format = 'dd-MM-yyyy'}) { return DateFormat(format).format(dateTime); } diff --git a/lib/model/expense/expense_list_model.dart b/lib/model/expense/expense_list_model.dart index 451ba72..b23468c 100644 --- a/lib/model/expense/expense_list_model.dart +++ b/lib/model/expense/expense_list_model.dart @@ -1,7 +1,11 @@ import 'dart:convert'; -List expenseModelFromJson(String str) => List.from( - json.decode(str).map((x) => ExpenseModel.fromJson(x))); +List expenseModelFromJson(String str) { + final jsonData = json.decode(str); + return List.from( + jsonData["data"]["data"].map((x) => ExpenseModel.fromJson(x)) + ); +} String expenseModelToJson(List data) => json.encode(List.from(data.map((x) => x.toJson()))); @@ -242,27 +246,35 @@ class CreatedBy { class Status { final String id; final String name; + final String displayName; final String description; + final String color; final bool isSystem; Status({ required this.id, required this.name, + required this.displayName, required this.description, + required this.color, required this.isSystem, }); factory Status.fromJson(Map json) => Status( id: json["id"], name: json["name"], + displayName: json["displayName"], description: json["description"], + color: json["color"], isSystem: json["isSystem"], ); Map toJson() => { "id": id, "name": name, + "displayName": displayName, "description": description, + "color": color, "isSystem": isSystem, }; } diff --git a/lib/view/expense/expense_filter_bottom_sheet.dart b/lib/view/expense/expense_filter_bottom_sheet.dart new file mode 100644 index 0000000..0165940 --- /dev/null +++ b/lib/view/expense/expense_filter_bottom_sheet.dart @@ -0,0 +1,304 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/controller/expense/expense_screen_controller.dart'; +import 'package:marco/helpers/utils/date_time_utils.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/model/employee_model.dart'; + +class ExpenseFilterBottomSheet extends StatelessWidget { + final ExpenseController expenseController; + final RxList selectedPaidByEmployees; + final RxList selectedCreatedByEmployees; + + ExpenseFilterBottomSheet({ + super.key, + required this.expenseController, + required this.selectedPaidByEmployees, + required this.selectedCreatedByEmployees, + }); + + final RxString selectedProject = ''.obs; + final Rx startDate = Rx(null); + final Rx endDate = Rx(null); + final RxString selectedEmployee = ''.obs; + + @override + Widget build(BuildContext context) { + return Obx(() { + return Container( + padding: const EdgeInsets.all(16), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + MyText.titleLarge('Filter Expenses', fontWeight: 700), + const SizedBox(height: 16), + + /// Project Filter + MyText.bodyMedium('Project', fontWeight: 600), + const SizedBox(height: 6), + DropdownButtonFormField( + value: selectedProject.value.isEmpty + ? null + : selectedProject.value, + items: expenseController.globalProjects + .map((proj) => DropdownMenuItem( + value: proj, + child: Text(proj), + )) + .toList(), + decoration: InputDecoration( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + hint: const Text('Select Project'), + onChanged: (value) { + selectedProject.value = value ?? ''; + }, + ), + + const SizedBox(height: 16), + + /// Date Range Filter + MyText.bodyMedium('Date Range', fontWeight: 600), + const SizedBox(height: 6), + Row( + children: [ + Expanded( + child: _DatePickerField( + label: startDate.value == null + ? 'Start Date' + : DateTimeUtils.formatDate( + startDate.value!, 'dd MMM yyyy'), + onTap: () async { + DateTime? picked = await showDatePicker( + context: context, + initialDate: startDate.value ?? DateTime.now(), + firstDate: DateTime(2020), + lastDate: + DateTime.now().add(const Duration(days: 365)), + ); + if (picked != null) startDate.value = picked; + }, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _DatePickerField( + label: endDate.value == null + ? 'End Date' + : DateTimeUtils.formatDate( + endDate.value!, 'dd MMM yyyy'), + onTap: () async { + DateTime? picked = await showDatePicker( + context: context, + initialDate: endDate.value ?? DateTime.now(), + firstDate: DateTime(2020), + lastDate: + DateTime.now().add(const Duration(days: 365)), + ); + if (picked != null) endDate.value = picked; + }, + ), + ), + ], + ), + + const SizedBox(height: 16), + + /// Paid By Filter + _employeeFilterSection( + title: 'Paid By', + selectedEmployees: selectedPaidByEmployees, + expenseController: expenseController, + ), + const SizedBox(height: 24), + + /// Created By Filter + _employeeFilterSection( + title: 'Created By', + selectedEmployees: selectedCreatedByEmployees, + expenseController: expenseController, + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.grey.shade300, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onPressed: () => Get.back(), + child: const Text('Cancel'), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onPressed: () { + expenseController.fetchExpenses( + projectIds: selectedProject.value.isEmpty + ? null + : [ + expenseController + .projectsMap[selectedProject.value]! + ], + paidByIds: selectedPaidByEmployees.isEmpty + ? null + : selectedPaidByEmployees.map((e) => e.id).toList(), + createdByIds: selectedCreatedByEmployees.isEmpty + ? null + : selectedCreatedByEmployees + .map((e) => e.id) + .toList(), + startDate: startDate.value, + endDate: endDate.value, + ); + Get.back(); + }, + child: const Text('Apply'), + ), + ], + ), + ], + ), + ), + ); + }); + } + + /// Employee Filter Section + Widget _employeeFilterSection({ + required String title, + required RxList selectedEmployees, + required ExpenseController expenseController, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodyMedium(title, fontWeight: 600), + const SizedBox(height: 6), + Obx(() { + return Wrap( + spacing: 6, + runSpacing: -8, + children: selectedEmployees.map((emp) { + return Chip( + label: Text(emp.name), + deleteIcon: const Icon(Icons.close, size: 18), + onDeleted: () => selectedEmployees.remove(emp), + backgroundColor: Colors.grey.shade200, + ); + }).toList(), + ); + }), + const SizedBox(height: 6), + Autocomplete( + optionsBuilder: (TextEditingValue textEditingValue) { + if (textEditingValue.text.isEmpty) { + return const Iterable.empty(); + } + return expenseController.allEmployees.where((EmployeeModel emp) { + return emp.name + .toLowerCase() + .contains(textEditingValue.text.toLowerCase()); + }); + }, + displayStringForOption: (EmployeeModel emp) => emp.name, + onSelected: (EmployeeModel emp) { + if (!selectedEmployees.contains(emp)) { + selectedEmployees.add(emp); + } + }, + fieldViewBuilder: (context, controller, focusNode, onFieldSubmitted) { + return TextField( + controller: controller, + focusNode: focusNode, + decoration: InputDecoration( + hintText: 'Search Employee', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + ); + }, + optionsViewBuilder: (context, onSelected, options) { + return Align( + alignment: Alignment.topLeft, + child: Material( + color: Colors.white, + elevation: 4.0, + child: SizedBox( + height: 200, + child: ListView.builder( + padding: EdgeInsets.zero, + itemCount: options.length, + itemBuilder: (context, index) { + final emp = options.elementAt(index); + return ListTile( + title: Text(emp.name), + onTap: () => onSelected(emp), + ); + }, + ), + ), + ), + ); + }, + ), + ], + ); + } +} + +/// Date Picker Field +class _DatePickerField extends StatelessWidget { + final String label; + final VoidCallback onTap; + + const _DatePickerField({ + required this.label, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: const TextStyle(color: Colors.black87)), + const Icon(Icons.calendar_today, size: 16, color: Colors.grey), + ], + ), + ), + ); + } +} diff --git a/lib/view/expense/expense_screen.dart b/lib/view/expense/expense_screen.dart index 40460eb..874480a 100644 --- a/lib/view/expense/expense_screen.dart +++ b/lib/view/expense/expense_screen.dart @@ -8,6 +8,8 @@ import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/model/expense/expense_list_model.dart'; import 'package:marco/view/expense/expense_detail_screen.dart'; import 'package:marco/model/expense/add_expense_bottom_sheet.dart'; +import 'package:marco/model/employee_model.dart'; +import 'package:marco/view/expense/expense_filter_bottom_sheet.dart'; class ExpenseMainScreen extends StatefulWidget { const ExpenseMainScreen({super.key}); @@ -22,6 +24,9 @@ class _ExpenseMainScreenState extends State { final RxString searchQuery = ''.obs; final ProjectController projectController = Get.find(); final ExpenseController expenseController = Get.put(ExpenseController()); + final RxList selectedPaidByEmployees = [].obs; + final RxList selectedCreatedByEmployees = + [].obs; @override void initState() { @@ -45,7 +50,7 @@ class _ExpenseMainScreenState extends State { searchController: searchController, onChanged: (value) => searchQuery.value = value, onFilterTap: _openFilterBottomSheet, - onRefreshTap: _refreshExpenses, + onRefreshTap: _refreshExpenses, ), _ToggleButtons(isHistoryView: isHistoryView), Expanded( @@ -111,36 +116,10 @@ class _ExpenseMainScreenState extends State { void _openFilterBottomSheet() { Get.bottomSheet( - Container( - padding: const EdgeInsets.all(16), - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - child: Wrap( - runSpacing: 10, - children: [ - MyText.bodyLarge( - 'Filter Expenses', - fontWeight: 700, - ), - ListTile( - leading: const Icon(Icons.date_range), - title: MyText.bodyMedium('Date Range'), - onTap: () {}, - ), - ListTile( - leading: const Icon(Icons.work_outline), - title: MyText.bodyMedium('Project'), - onTap: () {}, - ), - ListTile( - leading: const Icon(Icons.check_circle_outline), - title: MyText.bodyMedium('Status'), - onTap: () {}, - ), - ], - ), + ExpenseFilterBottomSheet( + expenseController: expenseController, + selectedPaidByEmployees: selectedPaidByEmployees, + selectedCreatedByEmployees: selectedCreatedByEmployees, ), ); } @@ -282,7 +261,7 @@ class _SearchAndFilter extends StatelessWidget { MySpacing.width(8), IconButton( icon: const Icon(Icons.tune, color: Colors.black), - onPressed: null, // Disabled as per request + onPressed: onFilterTap, ), ], ), From 9124b815ef6dbe38ca70e3620e0de447f2809b77 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Fri, 25 Jul 2025 12:00:33 +0530 Subject: [PATCH 19/65] feat(expense): refactor expense data handling and response parsing --- .../expense/expense_screen_controller.dart | 11 +-- lib/helpers/services/api_service.dart | 34 ++++---- lib/model/expense/expense_list_model.dart | 86 ++++++++++++++++--- 3 files changed, 96 insertions(+), 35 deletions(-) diff --git a/lib/controller/expense/expense_screen_controller.dart b/lib/controller/expense/expense_screen_controller.dart index 39dd724..1acdec9 100644 --- a/lib/controller/expense/expense_screen_controller.dart +++ b/lib/controller/expense/expense_screen_controller.dart @@ -72,15 +72,12 @@ class ExpenseController extends GetxController { if (result != null) { try { - final List rawList = result['expenses'] ?? []; - final parsed = rawList - .map((e) => ExpenseModel.fromJson(e as Map)) - .toList(); + final expenseResponse = ExpenseResponse.fromJson(result); + expenses.assignAll(expenseResponse.data.data); - expenses.assignAll(parsed); - logSafe("Expenses loaded: ${parsed.length}"); + logSafe("Expenses loaded: ${expenses.length}"); logSafe( - "Pagination Info: Page ${result['currentPage']} of ${result['totalPages']} | Total: ${result['totalEntites']}"); + "Pagination Info: Page ${expenseResponse.data.currentPage} of ${expenseResponse.data.totalPages} | Total: ${expenseResponse.data.totalEntites}"); } catch (e) { errorMessage.value = 'Failed to parse expenses: $e'; logSafe("Parse error in fetchExpenses: $e", level: LogLevel.error); diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index cf3c28d..4520a68 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -308,27 +308,25 @@ class ApiService { try { final response = await _getRequest(uri.toString()); - if (response == null) return null; + if (response == null) { + logSafe("Expense list request failed: null response", + level: LogLevel.error); + return null; + } - final parsed = _parseResponseForAllData(response, label: 'Expense List'); + // Directly parse and return the entire JSON response + final body = response.body.trim(); + if (body.isEmpty) { + logSafe("Expense list response body is empty", level: LogLevel.error); + return null; + } - if (parsed != null && parsed['data'] is Map) { - final dataObj = parsed['data'] as Map; - - if (dataObj['data'] is List) { - return { - 'currentPage': dataObj['currentPage'] ?? 1, - 'totalPages': dataObj['totalPages'] ?? 1, - 'totalEntites': dataObj['totalEntites'] ?? 0, - 'expenses': List.from(dataObj['data']), - }; - } else { - logSafe("Expense list 'data' is not a list: $dataObj", - level: LogLevel.error); - return null; - } + final jsonResponse = jsonDecode(body); + if (jsonResponse is Map) { + logSafe("Expense list response parsed successfully"); + return jsonResponse; // Return the entire API response } else { - logSafe("Unexpected response structure: $parsed", + logSafe("Unexpected response structure: $jsonResponse", level: LogLevel.error); return null; } diff --git a/lib/model/expense/expense_list_model.dart b/lib/model/expense/expense_list_model.dart index b23468c..410c0be 100644 --- a/lib/model/expense/expense_list_model.dart +++ b/lib/model/expense/expense_list_model.dart @@ -1,14 +1,77 @@ import 'dart:convert'; -List expenseModelFromJson(String str) { - final jsonData = json.decode(str); - return List.from( - jsonData["data"]["data"].map((x) => ExpenseModel.fromJson(x)) - ); +/// Parse the entire response +ExpenseResponse expenseResponseFromJson(String str) => + ExpenseResponse.fromJson(json.decode(str)); + +String expenseResponseToJson(ExpenseResponse data) => + json.encode(data.toJson()); + +class ExpenseResponse { + final bool success; + final String message; + final ExpenseData data; + final dynamic errors; + final int statusCode; + final DateTime timestamp; + + ExpenseResponse({ + required this.success, + required this.message, + required this.data, + required this.errors, + required this.statusCode, + required this.timestamp, + }); + + factory ExpenseResponse.fromJson(Map json) => + ExpenseResponse( + success: json["success"], + message: json["message"], + data: ExpenseData.fromJson(json["data"]), + errors: json["errors"], + statusCode: json["statusCode"], + timestamp: DateTime.parse(json["timestamp"]), + ); + + Map toJson() => { + "success": success, + "message": message, + "data": data.toJson(), + "errors": errors, + "statusCode": statusCode, + "timestamp": timestamp.toIso8601String(), + }; } -String expenseModelToJson(List data) => - json.encode(List.from(data.map((x) => x.toJson()))); +class ExpenseData { + final int currentPage; + final int totalPages; + final int totalEntites; + final List data; + + ExpenseData({ + required this.currentPage, + required this.totalPages, + required this.totalEntites, + required this.data, + }); + + factory ExpenseData.fromJson(Map json) => ExpenseData( + currentPage: json["currentPage"], + totalPages: json["totalPages"], + totalEntites: json["totalEntites"], + data: List.from( + json["data"].map((x) => ExpenseModel.fromJson(x))), + ); + + Map toJson() => { + "currentPage": currentPage, + "totalPages": totalPages, + "totalEntites": totalEntites, + "data": List.from(data.map((x) => x.toJson())), + }; +} class ExpenseModel { final String id; @@ -53,9 +116,12 @@ class ExpenseModel { supplerName: json["supplerName"], amount: (json["amount"] as num).toDouble(), status: Status.fromJson(json["status"]), - nextStatus: List.from( - json["nextStatus"].map((x) => Status.fromJson(x))), - preApproved: json["preApproved"], + nextStatus: json["nextStatus"] != null + ? List.from( + json["nextStatus"].map((x) => Status.fromJson(x)), + ) + : [], + preApproved: json["preApproved"] ?? false, ); Map toJson() => { From e5b3616245fbcc05df2a5f3762fc4731ed5bb88c Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Mon, 28 Jul 2025 12:09:13 +0530 Subject: [PATCH 20/65] Refactor expense models and detail screen for improved error handling and data validation - Enhanced `ExpenseResponse` and `ExpenseData` models to handle null values and provide default values. - Introduced a new `Filter` class to encapsulate filtering logic for expenses. - Updated `ExpenseDetailScreen` to utilize a controller for fetching expense details and managing loading states. - Improved UI responsiveness with loading skeletons and error messages. - Refactored filter bottom sheet to streamline filter selection and reset functionality. - Added visual indicators for filter application in the main expense screen. - Enhanced expense detail display with better formatting and status color handling. --- .../expense/expense_detail_controller.dart | 70 +++ .../expense/expense_screen_controller.dart | 62 ++- lib/helpers/services/api_service.dart | 46 ++ lib/helpers/widgets/my_custom_skeleton.dart | 406 ++++++++------ lib/model/expense/expense_list_model.dart | 181 +++--- lib/view/expense/expense_detail_screen.dart | 291 ++++++---- .../expense/expense_filter_bottom_sheet.dart | 518 +++++++++++------- lib/view/expense/expense_screen.dart | 109 ++-- 8 files changed, 1108 insertions(+), 575 deletions(-) create mode 100644 lib/controller/expense/expense_detail_controller.dart diff --git a/lib/controller/expense/expense_detail_controller.dart b/lib/controller/expense/expense_detail_controller.dart new file mode 100644 index 0000000..4ff0bec --- /dev/null +++ b/lib/controller/expense/expense_detail_controller.dart @@ -0,0 +1,70 @@ +import 'package:get/get.dart'; +import 'package:marco/helpers/services/api_service.dart'; +import 'package:marco/helpers/services/app_logger.dart'; +import 'package:marco/model/expense/expense_list_model.dart'; + +class ExpenseDetailController extends GetxController { + final Rx expense = Rx(null); + final RxBool isLoading = false.obs; + final RxString errorMessage = ''.obs; + + /// Fetch expense details by ID + Future fetchExpenseDetails(String expenseId) async { + isLoading.value = true; + errorMessage.value = ''; + + try { + logSafe("Fetching expense details for ID: $expenseId"); + + final result = await ApiService.getExpenseDetailsApi(expenseId: expenseId); + if (result != null) { + try { + expense.value = ExpenseModel.fromJson(result); + logSafe("Expense details loaded successfully: ${expense.value?.id}"); + } catch (e) { + errorMessage.value = 'Failed to parse expense details: $e'; + logSafe("Parse error in fetchExpenseDetails: $e", + level: LogLevel.error); + } + } else { + errorMessage.value = 'Failed to fetch expense details from server.'; + logSafe("fetchExpenseDetails failed: null response", + level: LogLevel.error); + } + } catch (e, stack) { + errorMessage.value = 'An unexpected error occurred.'; + logSafe("Exception in fetchExpenseDetails: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } finally { + isLoading.value = false; + } + } + + /// Update status for this specific expense + Future updateExpenseStatus(String expenseId, String statusId) async { + isLoading.value = true; + errorMessage.value = ''; + try { + logSafe("Updating status for expense: $expenseId -> $statusId"); + final success = await ApiService.updateExpenseStatusApi( + expenseId: expenseId, + statusId: statusId, + ); + if (success) { + logSafe("Expense status updated successfully."); + await fetchExpenseDetails(expenseId); // Refresh details + return true; + } else { + errorMessage.value = "Failed to update expense status."; + return false; + } + } catch (e, stack) { + errorMessage.value = 'An unexpected error occurred.'; + logSafe("Exception in updateExpenseStatus: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + return false; + } finally { + isLoading.value = false; + } + } +} diff --git a/lib/controller/expense/expense_screen_controller.dart b/lib/controller/expense/expense_screen_controller.dart index 1acdec9..0e9ca46 100644 --- a/lib/controller/expense/expense_screen_controller.dart +++ b/lib/controller/expense/expense_screen_controller.dart @@ -12,6 +12,8 @@ class ExpenseController extends GetxController { final RxList expenses = [].obs; final RxBool isLoading = false.obs; final RxString errorMessage = ''.obs; + + // Master data final RxList expenseTypes = [].obs; final RxList paymentModes = [].obs; final RxList expenseStatuses = [].obs; @@ -19,6 +21,15 @@ class ExpenseController extends GetxController { final RxMap projectsMap = {}.obs; RxList allEmployees = [].obs; + // Persistent Filter States + final RxString selectedProject = ''.obs; + final RxString selectedStatus = ''.obs; + final Rx startDate = Rx(null); + final Rx endDate = Rx(null); + final RxList selectedPaidByEmployees = [].obs; + final RxList selectedCreatedByEmployees = + [].obs; + int _pageSize = 20; int _pageNumber = 1; @@ -29,13 +40,22 @@ class ExpenseController extends GetxController { fetchAllEmployees(); } - /// Load projects, expense types, statuses, and payment modes on controller init + bool get isFilterApplied { + return selectedProject.value.isNotEmpty || + selectedStatus.value.isNotEmpty || + startDate.value != null || + endDate.value != null || + selectedPaidByEmployees.isNotEmpty || + selectedCreatedByEmployees.isNotEmpty; + } + + /// Load master data Future loadInitialMasterData() async { await fetchGlobalProjects(); await fetchMasterData(); } - /// Fetch expenses with filters and pagination (called explicitly when needed) + /// Fetch expenses using filters Future fetchExpenses({ List? projectIds, List? statusIds, @@ -53,12 +73,18 @@ class ExpenseController extends GetxController { _pageNumber = pageNumber; final Map filterMap = { - "projectIds": projectIds ?? [], - "statusIds": statusIds ?? [], - "createdByIds": createdByIds ?? [], - "paidByIds": paidByIds ?? [], - "startDate": startDate?.toIso8601String(), - "endDate": endDate?.toIso8601String(), + "projectIds": projectIds ?? + (selectedProject.value.isEmpty + ? [] + : [projectsMap[selectedProject.value] ?? '']), + "statusIds": statusIds ?? + (selectedStatus.value.isEmpty ? [] : [selectedStatus.value]), + "createdByIds": + createdByIds ?? selectedCreatedByEmployees.map((e) => e.id).toList(), + "paidByIds": + paidByIds ?? selectedPaidByEmployees.map((e) => e.id).toList(), + "startDate": (startDate ?? this.startDate.value)?.toIso8601String(), + "endDate": (endDate ?? this.endDate.value)?.toIso8601String(), }; try { @@ -95,6 +121,16 @@ class ExpenseController extends GetxController { } } + /// Clear all filters + void clearFilters() { + selectedProject.value = ''; + selectedStatus.value = ''; + startDate.value = null; + endDate.value = null; + selectedPaidByEmployees.clear(); + selectedCreatedByEmployees.clear(); + } + /// Fetch master data: expense types, payment modes, and expense status Future fetchMasterData() async { try { @@ -121,7 +157,7 @@ class ExpenseController extends GetxController { } } - /// Fetch list of all global projects + /// Fetch global projects Future fetchGlobalProjects() async { try { final response = await ApiService.getGlobalProjects(); @@ -143,10 +179,9 @@ class ExpenseController extends GetxController { } } - /// Fetch all employees for Manage Bucket usage + /// Fetch all employees Future fetchAllEmployees() async { isLoading.value = true; - try { final response = await ApiService.getAllEmployees(); if (response != null && response.isNotEmpty) { @@ -166,23 +201,20 @@ class ExpenseController extends GetxController { logSafe("Error fetching employees in Manage Bucket", level: LogLevel.error, error: e); } - isLoading.value = false; update(); } - /// Update expense status and refresh the list + /// Update expense status Future updateExpenseStatus(String expenseId, String statusId) async { isLoading.value = true; errorMessage.value = ''; - try { logSafe("Updating status for expense: $expenseId -> $statusId"); final success = await ApiService.updateExpenseStatusApi( expenseId: expenseId, statusId: statusId, ); - if (success) { logSafe("Expense status updated successfully."); await fetchExpenses(); diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 4520a68..01476e7 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -241,6 +241,52 @@ class ApiService { // === Expense APIs === // + /// Get Expense Details API + static Future?> getExpenseDetailsApi({ + required String expenseId, + }) async { + final endpoint = "${ApiEndpoints.getExpenseDetails}/$expenseId"; + logSafe("Fetching expense details for ID: $expenseId"); + + try { + final response = await _getRequest(endpoint); + if (response == null) { + logSafe("Expense details request failed: null response", + level: LogLevel.error); + return null; + } + + final body = response.body.trim(); + if (body.isEmpty) { + logSafe("Expense details response body is empty", + level: LogLevel.error); + return null; + } + + final jsonResponse = jsonDecode(body); + if (jsonResponse is Map) { + if (jsonResponse['success'] == true) { + logSafe("Expense details fetched successfully"); + return jsonResponse['data']; // Return the expense details object + } else { + logSafe( + "Failed to fetch expense details: ${jsonResponse['message'] ?? 'Unknown error'}", + level: LogLevel.warning, + ); + } + } else { + logSafe("Unexpected response structure: $jsonResponse", + level: LogLevel.error); + } + } catch (e, stack) { + logSafe("Exception during getExpenseDetailsApi: $e", + level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return null; + } + /// Update Expense Status API static Future updateExpenseStatusApi({ required String expenseId, diff --git a/lib/helpers/widgets/my_custom_skeleton.dart b/lib/helpers/widgets/my_custom_skeleton.dart index 0370bff..3dade46 100644 --- a/lib/helpers/widgets/my_custom_skeleton.dart +++ b/lib/helpers/widgets/my_custom_skeleton.dart @@ -4,36 +4,34 @@ import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/utils/my_shadow.dart'; class SkeletonLoaders { - -static Widget buildLoadingSkeleton() { - return SizedBox( - height: 360, - child: Column( - children: List.generate(5, (index) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: List.generate(6, (i) { - return Container( - margin: const EdgeInsets.symmetric(horizontal: 4), - width: 48, - height: 16, - decoration: BoxDecoration( - color: Colors.grey.shade300, - borderRadius: BorderRadius.circular(6), - ), - ); - }), + static Widget buildLoadingSkeleton() { + return SizedBox( + height: 360, + child: Column( + children: List.generate(5, (index) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: List.generate(6, (i) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 4), + width: 48, + height: 16, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(6), + ), + ); + }), + ), ), - ), - ); - }), - ), - ); -} - + ); + }), + ), + ); + } // Employee List - Card Style static Widget employeeListSkeletonLoader() { @@ -63,25 +61,37 @@ static Widget buildLoadingSkeleton() { children: [ Row( children: [ - Container(height: 14, width: 100, color: Colors.grey.shade300), + Container( + height: 14, + width: 100, + color: Colors.grey.shade300), MySpacing.width(8), - Container(height: 12, width: 60, color: Colors.grey.shade300), + Container( + height: 12, width: 60, color: Colors.grey.shade300), ], ), MySpacing.height(8), Row( children: [ - Icon(Icons.email, size: 16, color: Colors.grey.shade300), + Icon(Icons.email, + size: 16, color: Colors.grey.shade300), MySpacing.width(4), - Container(height: 10, width: 140, color: Colors.grey.shade300), + Container( + height: 10, + width: 140, + color: Colors.grey.shade300), ], ), MySpacing.height(8), Row( children: [ - Icon(Icons.phone, size: 16, color: Colors.grey.shade300), + Icon(Icons.phone, + size: 16, color: Colors.grey.shade300), MySpacing.width(4), - Container(height: 10, width: 100, color: Colors.grey.shade300), + Container( + height: 10, + width: 100, + color: Colors.grey.shade300), ], ), ], @@ -122,16 +132,28 @@ static Widget buildLoadingSkeleton() { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container(height: 12, width: 100, color: Colors.grey.shade300), + Container( + height: 12, + width: 100, + color: Colors.grey.shade300), MySpacing.height(8), - Container(height: 10, width: 80, color: Colors.grey.shade300), + Container( + height: 10, + width: 80, + color: Colors.grey.shade300), MySpacing.height(12), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - Container(height: 28, width: 60, color: Colors.grey.shade300), + Container( + height: 28, + width: 60, + color: Colors.grey.shade300), MySpacing.width(8), - Container(height: 28, width: 60, color: Colors.grey.shade300), + Container( + height: 28, + width: 60, + color: Colors.grey.shade300), ], ), ], @@ -167,7 +189,8 @@ static Widget buildLoadingSkeleton() { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Container(height: 14, width: 120, color: Colors.grey.shade300), + Container( + height: 14, width: 120, color: Colors.grey.shade300), Icon(Icons.add_circle, color: Colors.grey.shade300), ], ), @@ -226,133 +249,198 @@ static Widget buildLoadingSkeleton() { }), ); } - static Widget employeeSkeletonCard() { - return MyCard.bordered( - margin: MySpacing.only(bottom: 12), - paddingAll: 12, - borderRadiusAll: 12, - shadow: MyShadow( - elevation: 1.5, - position: MyShadowPosition.bottom, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Avatar - Container( - height: 35, - width: 35, - decoration: BoxDecoration( - color: Colors.grey.shade300, - shape: BoxShape.circle, - ), - ), - MySpacing.width(12), - // Name, org, email, phone - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container(height: 12, width: 120, color: Colors.grey.shade300), - MySpacing.height(6), - Container(height: 10, width: 80, color: Colors.grey.shade300), - MySpacing.height(8), - - // Email placeholder - Row( - children: [ - Icon(Icons.email_outlined, size: 14, color: Colors.grey.shade300), - MySpacing.width(4), - Container(height: 10, width: 140, color: Colors.grey.shade300), - ], - ), - MySpacing.height(8), - - // Phone placeholder - Row( - children: [ - Icon(Icons.phone_outlined, size: 14, color: Colors.grey.shade300), - MySpacing.width(4), - Container(height: 10, width: 100, color: Colors.grey.shade300), - MySpacing.width(8), - Container( - height: 16, - width: 16, - decoration: BoxDecoration( - color: Colors.grey.shade300, - shape: BoxShape.circle, - ), - ), - ], - ), - MySpacing.height(8), - - // Tags placeholder - Container(height: 8, width: 80, color: Colors.grey.shade300), - ], - ), - ), - - // Arrow - Icon(Icons.arrow_forward_ios, size: 14, color: Colors.grey.shade300), - ], - ), - ); -} - - static Widget contactSkeletonCard() { - return MyCard.bordered( - margin: MySpacing.only(bottom: 12), - paddingAll: 16, - borderRadiusAll: 16, - shadow: MyShadow( - elevation: 1.5, - position: MyShadowPosition.bottom, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + static Widget expenseListSkeletonLoader() { + return ListView.separated( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 80), + itemCount: 6, // Show 6 skeleton items + separatorBuilder: (_, __) => + Divider(color: Colors.grey.shade300, height: 20), + itemBuilder: (context, index) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - height: 40, - width: 40, - decoration: BoxDecoration( - color: Colors.grey.shade300, - shape: BoxShape.circle, - ), + // Title and Amount + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + height: 14, + width: 120, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(6), + ), + ), + Container( + height: 14, + width: 80, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(6), + ), + ), + ], ), - MySpacing.width(12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - height: 12, - width: 100, + const SizedBox(height: 6), + // Date and Status + Row( + children: [ + Container( + height: 12, + width: 100, + decoration: BoxDecoration( color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(6), ), - MySpacing.height(6), - Container( - height: 10, - width: 60, + ), + const Spacer(), + Container( + height: 12, + width: 50, + decoration: BoxDecoration( color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(6), ), - ], - ), + ), + ], ), ], - ), - MySpacing.height(16), - Container(height: 10, width: 150, color: Colors.grey.shade300), - MySpacing.height(8), - Container(height: 10, width: 100, color: Colors.grey.shade300), - MySpacing.height(8), - Container(height: 10, width: 120, color: Colors.grey.shade300), - ], - ), - ); -} + ); + }, + ); + } + static Widget employeeSkeletonCard() { + return MyCard.bordered( + margin: MySpacing.only(bottom: 12), + paddingAll: 12, + borderRadiusAll: 12, + shadow: MyShadow( + elevation: 1.5, + position: MyShadowPosition.bottom, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Avatar + Container( + height: 35, + width: 35, + decoration: BoxDecoration( + color: Colors.grey.shade300, + shape: BoxShape.circle, + ), + ), + MySpacing.width(12), + + // Name, org, email, phone + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container(height: 12, width: 120, color: Colors.grey.shade300), + MySpacing.height(6), + Container(height: 10, width: 80, color: Colors.grey.shade300), + MySpacing.height(8), + + // Email placeholder + Row( + children: [ + Icon(Icons.email_outlined, + size: 14, color: Colors.grey.shade300), + MySpacing.width(4), + Container( + height: 10, width: 140, color: Colors.grey.shade300), + ], + ), + MySpacing.height(8), + + // Phone placeholder + Row( + children: [ + Icon(Icons.phone_outlined, + size: 14, color: Colors.grey.shade300), + MySpacing.width(4), + Container( + height: 10, width: 100, color: Colors.grey.shade300), + MySpacing.width(8), + Container( + height: 16, + width: 16, + decoration: BoxDecoration( + color: Colors.grey.shade300, + shape: BoxShape.circle, + ), + ), + ], + ), + MySpacing.height(8), + + // Tags placeholder + Container(height: 8, width: 80, color: Colors.grey.shade300), + ], + ), + ), + + // Arrow + Icon(Icons.arrow_forward_ios, size: 14, color: Colors.grey.shade300), + ], + ), + ); + } + + static Widget contactSkeletonCard() { + return MyCard.bordered( + margin: MySpacing.only(bottom: 12), + paddingAll: 16, + borderRadiusAll: 16, + shadow: MyShadow( + elevation: 1.5, + position: MyShadowPosition.bottom, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + height: 40, + width: 40, + decoration: BoxDecoration( + color: Colors.grey.shade300, + shape: BoxShape.circle, + ), + ), + MySpacing.width(12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 12, + width: 100, + color: Colors.grey.shade300, + ), + MySpacing.height(6), + Container( + height: 10, + width: 60, + color: Colors.grey.shade300, + ), + ], + ), + ), + ], + ), + MySpacing.height(16), + Container(height: 10, width: 150, color: Colors.grey.shade300), + MySpacing.height(8), + Container(height: 10, width: 100, color: Colors.grey.shade300), + MySpacing.height(8), + Container(height: 10, width: 120, color: Colors.grey.shade300), + ], + ), + ); + } } diff --git a/lib/model/expense/expense_list_model.dart b/lib/model/expense/expense_list_model.dart index 410c0be..bdebfb4 100644 --- a/lib/model/expense/expense_list_model.dart +++ b/lib/model/expense/expense_list_model.dart @@ -24,15 +24,19 @@ class ExpenseResponse { required this.timestamp, }); - factory ExpenseResponse.fromJson(Map json) => - ExpenseResponse( - success: json["success"], - message: json["message"], - data: ExpenseData.fromJson(json["data"]), - errors: json["errors"], - statusCode: json["statusCode"], - timestamp: DateTime.parse(json["timestamp"]), - ); + factory ExpenseResponse.fromJson(Map json) { + final dataField = json["data"]; + return ExpenseResponse( + success: json["success"] ?? false, + message: json["message"] ?? '', + data: (dataField is Map) + ? ExpenseData.fromJson(dataField) + : ExpenseData.empty(), + errors: json["errors"], + statusCode: json["statusCode"] ?? 0, + timestamp: DateTime.tryParse(json["timestamp"] ?? '') ?? DateTime.now(), + ); + } Map toJson() => { "success": success, @@ -45,12 +49,14 @@ class ExpenseResponse { } class ExpenseData { + final Filter? filter; final int currentPage; final int totalPages; final int totalEntites; final List data; ExpenseData({ + required this.filter, required this.currentPage, required this.totalPages, required this.totalEntites, @@ -58,14 +64,25 @@ class ExpenseData { }); factory ExpenseData.fromJson(Map json) => ExpenseData( - currentPage: json["currentPage"], - totalPages: json["totalPages"], - totalEntites: json["totalEntites"], - data: List.from( - json["data"].map((x) => ExpenseModel.fromJson(x))), + filter: json["filter"] != null ? Filter.fromJson(json["filter"]) : null, + currentPage: json["currentPage"] ?? 0, + totalPages: json["totalPages"] ?? 0, + totalEntites: json["totalEntites"] ?? 0, + data: (json["data"] as List? ?? []) + .map((x) => ExpenseModel.fromJson(x)) + .toList(), + ); + + factory ExpenseData.empty() => ExpenseData( + filter: null, + currentPage: 0, + totalPages: 0, + totalEntites: 0, + data: [], ); Map toJson() => { + "filter": filter?.toJson(), "currentPage": currentPage, "totalPages": totalPages, "totalEntites": totalEntites, @@ -73,6 +90,47 @@ class ExpenseData { }; } +class Filter { + final List projectIds; + final List statusIds; + final List createdByIds; + final List paidById; + final DateTime? startDate; + final DateTime? endDate; + + Filter({ + required this.projectIds, + required this.statusIds, + required this.createdByIds, + required this.paidById, + required this.startDate, + required this.endDate, + }); + + factory Filter.fromJson(Map json) => Filter( + projectIds: List.from(json["projectIds"] ?? []), + statusIds: List.from(json["statusIds"] ?? []), + createdByIds: List.from(json["createdByIds"] ?? []), + paidById: List.from(json["paidById"] ?? []), + startDate: + json["startDate"] != null ? DateTime.tryParse(json["startDate"]) : null, + endDate: + json["endDate"] != null ? DateTime.tryParse(json["endDate"]) : null, + ); + + Map toJson() => { + "projectIds": projectIds, + "statusIds": statusIds, + "createdByIds": createdByIds, + "paidById": paidById, + "startDate": startDate?.toIso8601String(), + "endDate": endDate?.toIso8601String(), + }; +} + +// --- ExpenseModel and other classes remain same as you wrote --- +// I will include them here for completeness. + class ExpenseModel { final String id; final Project project; @@ -105,22 +163,22 @@ class ExpenseModel { }); factory ExpenseModel.fromJson(Map json) => ExpenseModel( - id: json["id"], - project: Project.fromJson(json["project"]), - expensesType: ExpenseType.fromJson(json["expensesType"]), - paymentMode: PaymentMode.fromJson(json["paymentMode"]), - paidBy: PaidBy.fromJson(json["paidBy"]), - createdBy: CreatedBy.fromJson(json["createdBy"]), - transactionDate: DateTime.parse(json["transactionDate"]), - createdAt: DateTime.parse(json["createdAt"]), - supplerName: json["supplerName"], - amount: (json["amount"] as num).toDouble(), - status: Status.fromJson(json["status"]), - nextStatus: json["nextStatus"] != null - ? List.from( - json["nextStatus"].map((x) => Status.fromJson(x)), - ) - : [], + id: json["id"] ?? '', + project: Project.fromJson(json["project"] ?? {}), + expensesType: ExpenseType.fromJson(json["expensesType"] ?? {}), + paymentMode: PaymentMode.fromJson(json["paymentMode"] ?? {}), + paidBy: PaidBy.fromJson(json["paidBy"] ?? {}), + createdBy: CreatedBy.fromJson(json["createdBy"] ?? {}), + transactionDate: + DateTime.tryParse(json["transactionDate"] ?? '') ?? DateTime.now(), + createdAt: + DateTime.tryParse(json["createdAt"] ?? '') ?? DateTime.now(), + supplerName: json["supplerName"] ?? '', + amount: (json["amount"] ?? 0).toDouble(), + status: Status.fromJson(json["status"] ?? {}), + nextStatus: (json["nextStatus"] as List? ?? []) + .map((x) => Status.fromJson(x)) + .toList(), preApproved: json["preApproved"] ?? false, ); @@ -163,14 +221,15 @@ class Project { }); factory Project.fromJson(Map json) => Project( - id: json["id"], - name: json["name"], - shortName: json["shortName"], - projectAddress: json["projectAddress"], - contactPerson: json["contactPerson"], - startDate: DateTime.parse(json["startDate"]), - endDate: DateTime.parse(json["endDate"]), - projectStatusId: json["projectStatusId"], + id: json["id"] ?? '', + name: json["name"] ?? '', + shortName: json["shortName"] ?? '', + projectAddress: json["projectAddress"] ?? '', + contactPerson: json["contactPerson"] ?? '', + startDate: + DateTime.tryParse(json["startDate"] ?? '') ?? DateTime.now(), + endDate: DateTime.tryParse(json["endDate"] ?? '') ?? DateTime.now(), + projectStatusId: json["projectStatusId"] ?? '', ); Map toJson() => { @@ -199,10 +258,10 @@ class ExpenseType { }); factory ExpenseType.fromJson(Map json) => ExpenseType( - id: json["id"], - name: json["name"], - noOfPersonsRequired: json["noOfPersonsRequired"], - description: json["description"], + id: json["id"] ?? '', + name: json["name"] ?? '', + noOfPersonsRequired: json["noOfPersonsRequired"] ?? false, + description: json["description"] ?? '', ); Map toJson() => { @@ -225,9 +284,9 @@ class PaymentMode { }); factory PaymentMode.fromJson(Map json) => PaymentMode( - id: json["id"], - name: json["name"], - description: json["description"], + id: json["id"] ?? '', + name: json["name"] ?? '', + description: json["description"] ?? '', ); Map toJson() => { @@ -255,11 +314,11 @@ class PaidBy { }); factory PaidBy.fromJson(Map json) => PaidBy( - id: json["id"], - firstName: json["firstName"], - lastName: json["lastName"], - photo: json["photo"], - jobRoleId: json["jobRoleId"], + id: json["id"] ?? '', + firstName: json["firstName"] ?? '', + lastName: json["lastName"] ?? '', + photo: json["photo"] ?? '', + jobRoleId: json["jobRoleId"] ?? '', jobRoleName: json["jobRoleName"], ); @@ -291,11 +350,11 @@ class CreatedBy { }); factory CreatedBy.fromJson(Map json) => CreatedBy( - id: json["id"], - firstName: json["firstName"], - lastName: json["lastName"], - photo: json["photo"], - jobRoleId: json["jobRoleId"], + id: json["id"] ?? '', + firstName: json["firstName"] ?? '', + lastName: json["lastName"] ?? '', + photo: json["photo"] ?? '', + jobRoleId: json["jobRoleId"] ?? '', jobRoleName: json["jobRoleName"], ); @@ -327,12 +386,12 @@ class Status { }); factory Status.fromJson(Map json) => Status( - id: json["id"], - name: json["name"], - displayName: json["displayName"], - description: json["description"], - color: json["color"], - isSystem: json["isSystem"], + id: json["id"] ?? '', + name: json["name"] ?? '', + displayName: json["displayName"] ?? '', + description: json["description"] ?? '', + color: (json["color"] ?? '').replaceAll("'", ''), + isSystem: json["isSystem"] ?? false, ); Map toJson() => { diff --git a/lib/view/expense/expense_detail_screen.dart b/lib/view/expense/expense_detail_screen.dart index d5450ab..ad1991d 100644 --- a/lib/view/expense/expense_detail_screen.dart +++ b/lib/view/expense/expense_detail_screen.dart @@ -1,27 +1,34 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:marco/controller/project_controller.dart'; -import 'package:marco/controller/expense/expense_screen_controller.dart'; +import 'package:intl/intl.dart'; +import 'package:marco/controller/expense/expense_detail_controller.dart'; +import 'package:marco/helpers/utils/date_time_utils.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/model/expense/expense_list_model.dart'; -import 'package:marco/helpers/utils/date_time_utils.dart'; +import 'package:marco/controller/project_controller.dart'; class ExpenseDetailScreen extends StatelessWidget { - const ExpenseDetailScreen({super.key}); + final String expenseId; - static Color getStatusColor(String? status) { + const ExpenseDetailScreen({super.key, required this.expenseId}); + + // Status color logic + static Color getStatusColor(String? status, {String? colorCode}) { + if (colorCode != null && colorCode.isNotEmpty) { + try { + return Color(int.parse(colorCode.replaceFirst('#', '0xff'))); + } catch (_) {} + } switch (status) { - case 'Requested': - return Colors.blue; - case 'Review': + case 'Approval Pending': return Colors.orange; - case 'Approved': - return Colors.green; + case 'Process Pending': + return Colors.blue; + case 'Rejected': + return Colors.red; case 'Paid': - return Colors.purple; - case 'Closed': - return Colors.grey; + return Colors.green; default: return Colors.black; } @@ -29,12 +36,9 @@ class ExpenseDetailScreen extends StatelessWidget { @override Widget build(BuildContext context) { - final ExpenseModel expense = Get.arguments['expense'] as ExpenseModel; - final statusColor = getStatusColor(expense.status.name); + final controller = Get.put(ExpenseDetailController()); final projectController = Get.find(); - final expenseController = Get.find(); - print( - "Next Status List: ${expense.nextStatus.map((e) => e.toJson()).toList()}"); + controller.fetchExpenseDetails(expenseId); return Scaffold( backgroundColor: const Color(0xFFF7F7F7), @@ -48,12 +52,12 @@ class ExpenseDetailScreen extends StatelessWidget { title: Padding( padding: MySpacing.xy(16, 0), child: Row( - crossAxisAlignment: CrossAxisAlignment.center, children: [ IconButton( icon: const Icon(Icons.arrow_back_ios_new, color: Colors.black, size: 20), - onPressed: () => Get.back(), + onPressed: () => + Get.offAllNamed('/dashboard/expense-main-page'), ), MySpacing.width(8), Expanded( @@ -98,84 +102,146 @@ class ExpenseDetailScreen extends StatelessWidget { ), ), ), - body: SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _ExpenseHeader( - title: expense.expensesType.name, - amount: '₹ ${expense.amount.toStringAsFixed(2)}', - status: expense.status.name, - statusColor: statusColor, - ), - const SizedBox(height: 16), - _ExpenseDetailsList(expense: expense), - ], - ), - ), - bottomNavigationBar: expense.nextStatus.isNotEmpty - ? SafeArea( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Wrap( - spacing: 8, - runSpacing: 8, - alignment: WrapAlignment.center, - children: expense.nextStatus.map((next) { - return ElevatedButton( - style: ElevatedButton.styleFrom( - minimumSize: const Size(100, 40), - padding: const EdgeInsets.symmetric( - vertical: 8, horizontal: 12), - backgroundColor: Colors.red, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(6), - ), - ), - onPressed: () async { - final success = - await expenseController.updateExpenseStatus( - expense.id, - next.id, - ); - - if (success) { - Get.snackbar( - 'Success', - 'Expense moved to ${next.name}', - backgroundColor: Colors.green.withOpacity(0.8), - colorText: Colors.white, - ); - Get.back(result: true); - } else { - Get.snackbar( - 'Error', - 'Failed to update status.', - backgroundColor: Colors.red.withOpacity(0.8), - colorText: Colors.white, - ); - } - }, - child: Text( - next.name, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w600, - fontSize: 14, - ), - overflow: TextOverflow.ellipsis, - ), - ); - }).toList(), - ), + body: SafeArea( + child: Obx(() { + if (controller.isLoading.value) { + return _buildLoadingSkeleton(); + } + if (controller.errorMessage.isNotEmpty) { + return Center( + child: Text( + controller.errorMessage.value, + style: const TextStyle(color: Colors.red, fontSize: 16), ), - ) - : null, + ); + } + + final expense = controller.expense.value; + if (expense == null) { + return const Center(child: Text("No expense details found.")); + } + + final statusColor = getStatusColor( + expense.status.name, + colorCode: expense.status.color, + ); + + final formattedAmount = NumberFormat.currency( + locale: 'en_IN', + symbol: '₹ ', + decimalDigits: 2, + ).format(expense.amount); + + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _ExpenseHeader( + title: expense.expensesType.name, + amount: formattedAmount, + status: expense.status.name, + statusColor: statusColor, + ), + const SizedBox(height: 16), + _ExpenseDetailsList(expense: expense), + const SizedBox(height: 100), + ], + ), + ); + }), + ), + bottomNavigationBar: Obx(() { + final expense = controller.expense.value; + if (expense == null || expense.nextStatus.isEmpty) { + return const SizedBox(); + } + + return SafeArea( + child: Container( + color: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + child: Wrap( + alignment: WrapAlignment.center, + spacing: 10, + runSpacing: 10, + children: expense.nextStatus.map((next) { + + Color buttonColor = Colors.red; + if (next.color.isNotEmpty) { + try { + buttonColor = + Color(int.parse(next.color.replaceFirst('#', '0xff'))); + } catch (_) {} + } + + return ElevatedButton( + style: ElevatedButton.styleFrom( + minimumSize: const Size(100, 40), + padding: + const EdgeInsets.symmetric(vertical: 8, horizontal: 12), + backgroundColor: buttonColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6), + ), + ), + onPressed: () async { + final success = await controller.updateExpenseStatus( + expense.id, next.id); + if (success) { + Get.snackbar( + 'Success', + 'Expense moved to ${next.displayName.isNotEmpty ? next.displayName : next.name}', + backgroundColor: Colors.green.withOpacity(0.8), + colorText: Colors.white, + ); + await controller.fetchExpenseDetails(expenseId); + } else { + Get.snackbar( + 'Error', + 'Failed to update status.', + backgroundColor: Colors.red.withOpacity(0.8), + colorText: Colors.white, + ); + } + }, + child: Text( + next.displayName.isNotEmpty ? next.displayName : next.name, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 14, + ), + overflow: TextOverflow.ellipsis, + ), + ); + }).toList(), + ), + ), + ); + }), + ); + } + + // Loading skeleton placeholder + Widget _buildLoadingSkeleton() { + return ListView( + padding: const EdgeInsets.all(16), + children: List.generate(5, (index) { + return Container( + margin: const EdgeInsets.only(bottom: 16), + height: 80, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(10), + ), + ); + }), ); } } +// Expense header card class _ExpenseHeader extends StatelessWidget { final String title; final String amount; @@ -229,7 +295,7 @@ class _ExpenseHeader extends StatelessWidget { Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( - color: Colors.black.withOpacity(0.15), + color: statusColor.withOpacity(0.15), borderRadius: BorderRadius.circular(20), ), child: Row( @@ -239,8 +305,8 @@ class _ExpenseHeader extends StatelessWidget { const SizedBox(width: 6), Text( status, - style: const TextStyle( - color: Colors.black, + style: TextStyle( + color: statusColor, fontWeight: FontWeight.w600, ), ), @@ -253,6 +319,7 @@ class _ExpenseHeader extends StatelessWidget { } } +// Expense details list class _ExpenseDetailsList extends StatelessWidget { final ExpenseModel expense; @@ -289,28 +356,41 @@ class _ExpenseDetailsList extends StatelessWidget { _DetailRow(title: "Expense Type", value: expense.expensesType.name), _DetailRow(title: "Payment Mode", value: expense.paymentMode.name), _DetailRow( - title: "Paid By", - value: '${expense.paidBy.firstName} ${expense.paidBy.lastName}'), + title: "Paid By", + value: '${expense.paidBy.firstName} ${expense.paidBy.lastName}', + ), _DetailRow( - title: "Created By", - value: - '${expense.createdBy.firstName} ${expense.createdBy.lastName}'), + title: "Created By", + value: + '${expense.createdBy.firstName} ${expense.createdBy.lastName}', + ), _DetailRow(title: "Transaction Date", value: transactionDate), _DetailRow(title: "Created At", value: createdAt), _DetailRow(title: "Supplier Name", value: expense.supplerName), - _DetailRow(title: "Amount", value: '₹ ${expense.amount}'), + _DetailRow( + title: "Amount", + value: NumberFormat.currency( + locale: 'en_IN', + symbol: '₹ ', + decimalDigits: 2, + ).format(expense.amount), + ), _DetailRow(title: "Status", value: expense.status.name), _DetailRow( - title: "Next Status", - value: expense.nextStatus.map((e) => e.name).join(", ")), + title: "Next Status", + value: expense.nextStatus.map((e) => e.name).join(", "), + ), _DetailRow( - title: "Pre-Approved", value: expense.preApproved ? "Yes" : "No"), + title: "Pre-Approved", + value: expense.preApproved ? "Yes" : "No", + ), ], ), ); } } +// A single row for expense details class _DetailRow extends StatelessWidget { final String title; final String value; @@ -343,6 +423,7 @@ class _DetailRow extends StatelessWidget { fontSize: 15, fontWeight: FontWeight.w600, ), + softWrap: true, ), ), ], diff --git a/lib/view/expense/expense_filter_bottom_sheet.dart b/lib/view/expense/expense_filter_bottom_sheet.dart index 0165940..b5b6bf5 100644 --- a/lib/view/expense/expense_filter_bottom_sheet.dart +++ b/lib/view/expense/expense_filter_bottom_sheet.dart @@ -5,178 +5,73 @@ import 'package:marco/helpers/utils/date_time_utils.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/model/employee_model.dart'; +/// Wrapper to open Expense Filter Bottom Sheet +void openExpenseFilterBottomSheet( + BuildContext context, ExpenseController expenseController) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) { + return ExpenseFilterBottomSheetWrapper( + expenseController: expenseController); + }, + ); +} + +class ExpenseFilterBottomSheetWrapper extends StatelessWidget { + final ExpenseController expenseController; + + const ExpenseFilterBottomSheetWrapper( + {super.key, required this.expenseController}); + + @override + Widget build(BuildContext context) { + return DraggableScrollableSheet( + initialChildSize: 0.7, + minChildSize: 0.4, + maxChildSize: 0.95, + expand: false, + builder: (context, scrollController) { + return ExpenseFilterBottomSheet( + expenseController: expenseController, + scrollController: scrollController, + ); + }, + ); + } +} + class ExpenseFilterBottomSheet extends StatelessWidget { final ExpenseController expenseController; - final RxList selectedPaidByEmployees; - final RxList selectedCreatedByEmployees; + final ScrollController scrollController; - ExpenseFilterBottomSheet({ + const ExpenseFilterBottomSheet({ super.key, required this.expenseController, - required this.selectedPaidByEmployees, - required this.selectedCreatedByEmployees, + required this.scrollController, }); - final RxString selectedProject = ''.obs; - final Rx startDate = Rx(null); - final Rx endDate = Rx(null); - final RxString selectedEmployee = ''.obs; - @override Widget build(BuildContext context) { return Obx(() { - return Container( - padding: const EdgeInsets.all(16), - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - child: SingleChildScrollView( + return SafeArea( + child: Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, children: [ - MyText.titleLarge('Filter Expenses', fontWeight: 700), - const SizedBox(height: 16), - - /// Project Filter - MyText.bodyMedium('Project', fontWeight: 600), - const SizedBox(height: 6), - DropdownButtonFormField( - value: selectedProject.value.isEmpty - ? null - : selectedProject.value, - items: expenseController.globalProjects - .map((proj) => DropdownMenuItem( - value: proj, - child: Text(proj), - )) - .toList(), - decoration: InputDecoration( - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: Colors.grey.shade300), - ), - contentPadding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + Expanded( + child: SingleChildScrollView( + controller: scrollController, + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 20), + child: _buildContent(context), ), - hint: const Text('Select Project'), - onChanged: (value) { - selectedProject.value = value ?? ''; - }, - ), - - const SizedBox(height: 16), - - /// Date Range Filter - MyText.bodyMedium('Date Range', fontWeight: 600), - const SizedBox(height: 6), - Row( - children: [ - Expanded( - child: _DatePickerField( - label: startDate.value == null - ? 'Start Date' - : DateTimeUtils.formatDate( - startDate.value!, 'dd MMM yyyy'), - onTap: () async { - DateTime? picked = await showDatePicker( - context: context, - initialDate: startDate.value ?? DateTime.now(), - firstDate: DateTime(2020), - lastDate: - DateTime.now().add(const Duration(days: 365)), - ); - if (picked != null) startDate.value = picked; - }, - ), - ), - const SizedBox(width: 8), - Expanded( - child: _DatePickerField( - label: endDate.value == null - ? 'End Date' - : DateTimeUtils.formatDate( - endDate.value!, 'dd MMM yyyy'), - onTap: () async { - DateTime? picked = await showDatePicker( - context: context, - initialDate: endDate.value ?? DateTime.now(), - firstDate: DateTime(2020), - lastDate: - DateTime.now().add(const Duration(days: 365)), - ); - if (picked != null) endDate.value = picked; - }, - ), - ), - ], - ), - - const SizedBox(height: 16), - - /// Paid By Filter - _employeeFilterSection( - title: 'Paid By', - selectedEmployees: selectedPaidByEmployees, - expenseController: expenseController, - ), - const SizedBox(height: 24), - - /// Created By Filter - _employeeFilterSection( - title: 'Created By', - selectedEmployees: selectedCreatedByEmployees, - expenseController: expenseController, - ), - const SizedBox(height: 24), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.grey.shade300, - foregroundColor: Colors.black, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - onPressed: () => Get.back(), - child: const Text('Cancel'), - ), - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - onPressed: () { - expenseController.fetchExpenses( - projectIds: selectedProject.value.isEmpty - ? null - : [ - expenseController - .projectsMap[selectedProject.value]! - ], - paidByIds: selectedPaidByEmployees.isEmpty - ? null - : selectedPaidByEmployees.map((e) => e.id).toList(), - createdByIds: selectedCreatedByEmployees.isEmpty - ? null - : selectedCreatedByEmployees - .map((e) => e.id) - .toList(), - startDate: startDate.value, - endDate: endDate.value, - ); - Get.back(); - }, - child: const Text('Apply'), - ), - ], ), + _buildBottomButtons(), ], ), ), @@ -184,17 +79,282 @@ class ExpenseFilterBottomSheet extends StatelessWidget { }); } - /// Employee Filter Section - Widget _employeeFilterSection({ - required String title, - required RxList selectedEmployees, - required ExpenseController expenseController, + /// Builds the filter content + Widget _buildContent(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 50, + height: 4, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(20), + ), + ), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + MyText.titleLarge('Filter Expenses', fontWeight: 700), + TextButton( + onPressed: () => expenseController.clearFilters(), + child: const Text( + "Reset Filter", + style: TextStyle( + color: Colors.red, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + + const SizedBox(height: 16), + + /// Project Filter + _buildCardSection( + title: "Project", + child: _popupSelector( + context, + currentValue: expenseController.selectedProject.value.isEmpty + ? 'Select Project' + : expenseController.selectedProject.value, + items: expenseController.globalProjects, + onSelected: (value) => + expenseController.selectedProject.value = value, + ), + ), + const SizedBox(height: 16), + + /// Expense Status Filter + _buildCardSection( + title: "Expense Status", + child: _popupSelector( + context, + currentValue: expenseController.selectedStatus.value.isEmpty + ? 'Select Expense Status' + : expenseController.expenseStatuses + .firstWhereOrNull((e) => + e.id == expenseController.selectedStatus.value) + ?.name ?? + 'Select Expense Status', + items: + expenseController.expenseStatuses.map((e) => e.name).toList(), + onSelected: (name) { + final status = expenseController.expenseStatuses + .firstWhere((e) => e.name == name); + expenseController.selectedStatus.value = status.id; + }, + ), + ), + const SizedBox(height: 16), + + /// Date Range Filter + _buildCardSection( + title: "Date Range", + child: Row( + children: [ + Expanded( + child: _dateButton( + label: expenseController.startDate.value == null + ? 'Start Date' + : DateTimeUtils.formatDate( + expenseController.startDate.value!, 'dd MMM yyyy'), + onTap: () async { + DateTime? picked = await showDatePicker( + context: context, + initialDate: + expenseController.startDate.value ?? DateTime.now(), + firstDate: DateTime(2020), + lastDate: DateTime.now().add(const Duration(days: 365)), + ); + if (picked != null) + expenseController.startDate.value = picked; + }, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _dateButton( + label: expenseController.endDate.value == null + ? 'End Date' + : DateTimeUtils.formatDate( + expenseController.endDate.value!, 'dd MMM yyyy'), + onTap: () async { + DateTime? picked = await showDatePicker( + context: context, + initialDate: + expenseController.endDate.value ?? DateTime.now(), + firstDate: DateTime(2020), + lastDate: DateTime.now().add(const Duration(days: 365)), + ); + if (picked != null) + expenseController.endDate.value = picked; + }, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + /// Paid By Filter + _buildCardSection( + title: "Paid By", + child: _employeeFilterSection( + selectedEmployees: expenseController.selectedPaidByEmployees, + ), + ), + const SizedBox(height: 16), + + /// Created By Filter + _buildCardSection( + title: "Created By", + child: _employeeFilterSection( + selectedEmployees: expenseController.selectedCreatedByEmployees, + ), + ), + const SizedBox(height: 24), + ], + ); + } + + /// Bottom Action Buttons + Widget _buildBottomButtons() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + // Cancel Button + Expanded( + child: ElevatedButton.icon( + onPressed: () { + Get.back(); + }, + icon: const Icon(Icons.close, color: Colors.white), + label: MyText.bodyMedium( + "Cancel", + color: Colors.white, + fontWeight: 600, + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 7), + ), + ), + ), + const SizedBox(width: 12), + + // Submit Button + Expanded( + child: ElevatedButton.icon( + onPressed: () { + expenseController.fetchExpenses(); + Get.back(); + }, + icon: const Icon(Icons.check_circle_outline, color: Colors.white), + label: MyText.bodyMedium( + "Submit", + color: Colors.white, + fontWeight: 600, + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.indigo, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 7), + ), + ), + ), + ], + ), + ); + } + + /// Popup Selector + Widget _popupSelector( + BuildContext context, { + required String currentValue, + required List items, + required ValueChanged onSelected, }) { + return PopupMenuButton( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + onSelected: onSelected, + itemBuilder: (context) { + return items + .map((e) => PopupMenuItem( + value: e, + child: Text(e), + )) + .toList(); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + currentValue, + style: const TextStyle(color: Colors.black87), + overflow: TextOverflow.ellipsis, + ), + ), + const Icon(Icons.arrow_drop_down, color: Colors.grey), + ], + ), + ), + ); + } + + /// Card Section Wrapper + Widget _buildCardSection({required String title, required Widget child}) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MyText.bodyMedium(title, fontWeight: 600), const SizedBox(height: 6), + child, + ], + ); + } + + /// Date Button + Widget _dateButton({required String label, required VoidCallback onTap}) { + return ElevatedButton.icon( + onPressed: onTap, + icon: const Icon(Icons.calendar_today, size: 16), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.grey.shade100, + foregroundColor: Colors.black, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), + ), + label: Text(label, overflow: TextOverflow.ellipsis), + ); + } + + /// Employee Filter Section + Widget _employeeFilterSection( + {required RxList selectedEmployees}) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ Obx(() { return Wrap( spacing: 6, @@ -270,35 +430,3 @@ class ExpenseFilterBottomSheet extends StatelessWidget { ); } } - -/// Date Picker Field -class _DatePickerField extends StatelessWidget { - final String label; - final VoidCallback onTap; - - const _DatePickerField({ - required this.label, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - decoration: BoxDecoration( - border: Border.all(color: Colors.grey.shade300), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(label, style: const TextStyle(color: Colors.black87)), - const Icon(Icons.calendar_today, size: 16, color: Colors.grey), - ], - ), - ), - ); - } -} diff --git a/lib/view/expense/expense_screen.dart b/lib/view/expense/expense_screen.dart index 874480a..ac95483 100644 --- a/lib/view/expense/expense_screen.dart +++ b/lib/view/expense/expense_screen.dart @@ -8,8 +8,8 @@ import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/model/expense/expense_list_model.dart'; import 'package:marco/view/expense/expense_detail_screen.dart'; import 'package:marco/model/expense/add_expense_bottom_sheet.dart'; -import 'package:marco/model/employee_model.dart'; import 'package:marco/view/expense/expense_filter_bottom_sheet.dart'; +import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; class ExpenseMainScreen extends StatefulWidget { const ExpenseMainScreen({super.key}); @@ -22,22 +22,33 @@ class _ExpenseMainScreenState extends State { final RxBool isHistoryView = false.obs; final TextEditingController searchController = TextEditingController(); final RxString searchQuery = ''.obs; + final ProjectController projectController = Get.find(); final ExpenseController expenseController = Get.put(ExpenseController()); - final RxList selectedPaidByEmployees = [].obs; - final RxList selectedCreatedByEmployees = - [].obs; @override void initState() { super.initState(); - expenseController.fetchExpenses(); + expenseController.fetchExpenses(); // Initial data load } void _refreshExpenses() { expenseController.fetchExpenses(); } + void _openFilterBottomSheet(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) { + return ExpenseFilterBottomSheetWrapper( + expenseController: expenseController, + ); + }, + ); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -49,14 +60,14 @@ class _ExpenseMainScreenState extends State { _SearchAndFilter( searchController: searchController, onChanged: (value) => searchQuery.value = value, - onFilterTap: _openFilterBottomSheet, + onFilterTap: () => _openFilterBottomSheet(context), onRefreshTap: _refreshExpenses, ), _ToggleButtons(isHistoryView: isHistoryView), Expanded( child: Obx(() { if (expenseController.isLoading.value) { - return const Center(child: CircularProgressIndicator()); + return SkeletonLoaders.expenseListSkeletonLoader(); } if (expenseController.errorMessage.isNotEmpty) { @@ -81,7 +92,7 @@ class _ExpenseMainScreenState extends State { expense.paymentMode.name.toLowerCase().contains(query); }).toList(); - // Sort by latest transaction date first + // Sort by latest transaction date filteredList.sort( (a, b) => b.transactionDate.compareTo(a.transactionDate)); @@ -113,19 +124,9 @@ class _ExpenseMainScreenState extends State { ), ); } - - void _openFilterBottomSheet() { - Get.bottomSheet( - ExpenseFilterBottomSheet( - expenseController: expenseController, - selectedPaidByEmployees: selectedPaidByEmployees, - selectedCreatedByEmployees: selectedCreatedByEmployees, - ), - ); - } } -// AppBar Widget +///---------------------- APP BAR ----------------------/// class _ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget { final ProjectController projectController; @@ -170,7 +171,6 @@ class _ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget { projectController.selectedProject?.name ?? 'Select Project'; return InkWell( - onTap: () => Get.toNamed('/project-selector'), child: Row( children: [ const Icon(Icons.work_outline, @@ -200,8 +200,7 @@ class _ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget { } } -// Search and Filter Widget - +///---------------------- SEARCH AND FILTER ----------------------/// class _SearchAndFilter extends StatelessWidget { final TextEditingController searchController; final ValueChanged onChanged; @@ -217,6 +216,8 @@ class _SearchAndFilter extends StatelessWidget { @override Widget build(BuildContext context) { + final ExpenseController expenseController = Get.find(); + return Padding( padding: MySpacing.fromLTRB(12, 10, 12, 0), child: Row( @@ -259,17 +260,42 @@ class _SearchAndFilter extends StatelessWidget { ), ), MySpacing.width(8), - IconButton( - icon: const Icon(Icons.tune, color: Colors.black), - onPressed: onFilterTap, - ), + Obx(() { + final bool showRedDot = expenseController.isFilterApplied; + return IconButton( + onPressed: onFilterTap, + icon: Stack( + clipBehavior: Clip.none, + children: [ + const Icon(Icons.tune, color: Colors.black, size: 24), + if (showRedDot) + Positioned( + top: -1, + right: -1, + child: Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + border: Border.all( + color: Colors.white, + width: 1.5, + ), + ), + ), + ), + ], + ), + ); + }), ], ), ); } } -// Toggle Buttons Widget +///---------------------- TOGGLE BUTTONS ----------------------/// class _ToggleButtons extends StatelessWidget { final RxBool isHistoryView; @@ -360,7 +386,7 @@ class _ToggleButton extends StatelessWidget { } } -// Expense List Widget (Dynamic) +///---------------------- EXPENSE LIST ----------------------/// class _ExpenseList extends StatelessWidget { final List expenseList; @@ -371,7 +397,7 @@ class _ExpenseList extends StatelessWidget { if (expenseList.isEmpty) { return Center(child: MyText.bodyMedium('No expenses found.')); } - + final expenseController = Get.find(); return ListView.separated( padding: const EdgeInsets.fromLTRB(12, 12, 12, 80), itemCount: expenseList.length, @@ -386,10 +412,17 @@ class _ExpenseList extends StatelessWidget { ); return GestureDetector( - onTap: () => Get.to( - () => const ExpenseDetailScreen(), - arguments: {'expense': expense}, - ), + onTap: () async { + final result = await Get.to( + () => ExpenseDetailScreen(expenseId: expense.id), + arguments: {'expense': expense}, + ); + + // If status was updated, refresh expenses + if (result == true) { + expenseController.fetchExpenses(); + } + }, child: Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Column( @@ -398,13 +431,9 @@ class _ExpenseList extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Row( - children: [ - MyText.bodyMedium( - expense.expensesType.name, - fontWeight: 700, - ), - ], + MyText.bodyMedium( + expense.expensesType.name, + fontWeight: 700, ), MyText.bodyMedium( '₹ ${expense.amount.toStringAsFixed(2)}', From f4b905cd42164a13cdb775014c29c529e763d2c8 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Tue, 29 Jul 2025 09:57:24 +0530 Subject: [PATCH 21/65] feat(build): enhance Gradle configuration with keystore properties and release signing setup --- android/app/build.gradle | 49 +++++++++++++++++++++++++++++++++------- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 86a56ac..694a2e1 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -5,40 +5,73 @@ plugins { id "dev.flutter.flutter-gradle-plugin" } +// Load keystore properties from key.properties file +def keystoreProperties = new Properties() +def keystorePropertiesFile = rootProject.file('key.properties') +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) +} + android { - namespace = "com.example.marco" + // Define the namespace for your Android application + namespace = "com.marco.aiot" + // Set the compile SDK version based on Flutter's configuration compileSdk = flutter.compileSdkVersion + // Set the NDK version based on Flutter's configuration ndkVersion = flutter.ndkVersion + // Configure Java compatibility options compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } + // Configure Kotlin options for JVM target kotlinOptions { jvmTarget = JavaVersion.VERSION_1_8 } + // Default configuration for your application defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.example.marcostage" - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. + // Specify your unique Application ID. This identifies your app on Google Play. + applicationId = "com.marco.aiotstage" + // Set minimum and target SDK versions based on Flutter's configuration minSdk = flutter.minSdkVersion targetSdk = flutter.targetSdkVersion + // Set version code and name based on Flutter's configuration (from pubspec.yaml) versionCode = flutter.versionCode versionName = flutter.versionName } + // Define signing configurations for different build types + signingConfigs { + release { + // Reference the key alias from key.properties + keyAlias keystoreProperties['keyAlias'] + // Reference the key password from key.properties + keyPassword keystoreProperties['keyPassword'] + // Reference the keystore file path from key.properties + storeFile file(keystoreProperties['storeFile']) + // Reference the keystore password from key.properties + storePassword keystoreProperties['storePassword'] + } + } + + // Define different build types (e.g., debug, release) buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.debug + // Apply the 'release' signing configuration defined above to the release build + signingConfig signingConfigs.release + // Enable code minification to reduce app size + minifyEnabled true + // Enable resource shrinking to remove unused resources + shrinkResources true + // Other release specific configurations can be added here, e.g., ProGuard rules } } } +// Configure Flutter specific settings, pointing to the root of your Flutter project flutter { source = "../.." } From 5bc811f91f35e6b793dfbdf533ee96fc4b53b48f Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Tue, 29 Jul 2025 16:38:13 +0530 Subject: [PATCH 22/65] feat: update package identifiers and versioning for staging environment --- android/app/build.gradle | 2 +- .../main/kotlin/com/example/maxdash/MainActivity.kt | 2 +- devtools_options.yaml | 3 +++ ios/Runner.xcodeproj/project.pbxproj | 12 ++++++------ linux/CMakeLists.txt | 2 +- macos/Runner.xcodeproj/project.pbxproj | 6 +++--- macos/Runner/Configs/AppInfo.xcconfig | 2 +- pubspec.yaml | 2 +- 8 files changed, 17 insertions(+), 14 deletions(-) create mode 100644 devtools_options.yaml diff --git a/android/app/build.gradle b/android/app/build.gradle index 694a2e1..84581b2 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -14,7 +14,7 @@ if (keystorePropertiesFile.exists()) { android { // Define the namespace for your Android application - namespace = "com.marco.aiot" + namespace = "com.marco.aiotstage" // Set the compile SDK version based on Flutter's configuration compileSdk = flutter.compileSdkVersion // Set the NDK version based on Flutter's configuration diff --git a/android/app/src/main/kotlin/com/example/maxdash/MainActivity.kt b/android/app/src/main/kotlin/com/example/maxdash/MainActivity.kt index fda3a29..88cc381 100644 --- a/android/app/src/main/kotlin/com/example/maxdash/MainActivity.kt +++ b/android/app/src/main/kotlin/com/example/maxdash/MainActivity.kt @@ -1,4 +1,4 @@ -package com.example.marco +package com.marco.aiotstage import io.flutter.embedding.android.FlutterActivity diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 747802f..a5d7139 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -368,7 +368,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.marco; + PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiotstage; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -384,7 +384,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.marco.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiotstage.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -401,7 +401,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.marco.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiotstage.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; @@ -416,7 +416,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.marco.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiotstage.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; @@ -547,7 +547,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.marco; + PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiotstage; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -569,7 +569,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.marco; + PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiotstage; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index 1ed2761..7809054 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -7,7 +7,7 @@ project(runner LANGUAGES CXX) set(BINARY_NAME "marco") # The unique GTK application identifier for this application. See: # https://wiki.gnome.org/HowDoI/ChooseApplicationID -set(APPLICATION_ID "com.example.marco") +set(APPLICATION_ID "com.marco.aiotstage") # Explicitly opt in to modern CMake behaviors to avoid warnings with recent # versions of CMake. diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index e09dfc7..20ce3ed 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -385,7 +385,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.marco.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiotstage.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/marco.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/marco"; @@ -399,7 +399,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.marco.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiotstage.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/marco.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/marco"; @@ -413,7 +413,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.marco.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiotstage.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/marco.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/marco"; diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig index 1ddedb9..7f80201 100644 --- a/macos/Runner/Configs/AppInfo.xcconfig +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -8,7 +8,7 @@ PRODUCT_NAME = marco // The application's bundle identifier -PRODUCT_BUNDLE_IDENTIFIER = com.example.marco +PRODUCT_BUNDLE_IDENTIFIER = com.marco.aiotstage // The copyright displayed in application information PRODUCT_COPYRIGHT = Copyright © 2024 com.example. All rights reserved. diff --git a/pubspec.yaml b/pubspec.yaml index 846bfc0..b8ca6b3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.0+1 +version: 1.0.0+5 environment: sdk: ^3.5.3 From ddbc1ec1e52ebeecca69fea2b816c4ede9a44e6d Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Tue, 29 Jul 2025 18:05:37 +0530 Subject: [PATCH 23/65] Refactor ContactDetailScreen and DirectoryView for improved readability and performance - Moved the Delta to HTML conversion logic outside of the ContactDetailScreen class for better separation of concerns. - Simplified the handling of email and phone display in DirectoryView to show only the first entry, reducing redundancy. - Enhanced the layout and structure of the ContactDetailScreen for better maintainability. - Introduced a new ProjectLabel widget to encapsulate project display logic in the ContactDetailScreen. - Cleaned up unnecessary comments and improved code formatting for consistency. --- .../directory/add_contact_bottom_sheet.dart | 814 +++++++++--------- lib/view/directory/contact_detail_screen.dart | 503 +++++------ lib/view/directory/directory_view.dart | 130 +-- 3 files changed, 705 insertions(+), 742 deletions(-) diff --git a/lib/model/directory/add_contact_bottom_sheet.dart b/lib/model/directory/add_contact_bottom_sheet.dart index 31e0f4a..bb8761a 100644 --- a/lib/model/directory/add_contact_bottom_sheet.dart +++ b/lib/model/directory/add_contact_bottom_sheet.dart @@ -18,89 +18,75 @@ class AddContactBottomSheet extends StatefulWidget { } class _AddContactBottomSheetState extends State { - final controller = Get.put(AddContactController()); + // Controllers and state + final AddContactController controller = Get.put(AddContactController()); final formKey = GlobalKey(); - final nameController = TextEditingController(); final orgController = TextEditingController(); final addressController = TextEditingController(); final descriptionController = TextEditingController(); final tagTextController = TextEditingController(); - final RxBool showAdvanced = false.obs; - final RxList emailControllers = - [].obs; - final RxList emailLabels = [].obs; - final RxList phoneControllers = - [].obs; - final RxList phoneLabels = [].obs; + // Use Rx for advanced toggle and dynamic fields + final showAdvanced = false.obs; + final emailControllers = [].obs; + final emailLabels = [].obs; + final phoneControllers = [].obs; + final phoneLabels = [].obs; + + // For required bucket validation (new) + final bucketError = ''.obs; @override void initState() { super.initState(); controller.resetForm(); + _initFields(); + } - nameController.text = widget.existingContact?.name ?? ''; - orgController.text = widget.existingContact?.organization ?? ''; - addressController.text = widget.existingContact?.address ?? ''; - descriptionController.text = widget.existingContact?.description ?? ''; - tagTextController.clear(); - - if (widget.existingContact != null) { - emailControllers.clear(); - emailLabels.clear(); - for (var email in widget.existingContact!.contactEmails) { - emailControllers.add(TextEditingController(text: email.emailAddress)); - emailLabels.add((email.label).obs); - } - if (emailControllers.isEmpty) { - emailControllers.add(TextEditingController()); - emailLabels.add('Office'.obs); - } - - phoneControllers.clear(); - phoneLabels.clear(); - for (var phone in widget.existingContact!.contactPhones) { - phoneControllers.add(TextEditingController(text: phone.phoneNumber)); - phoneLabels.add((phone.label).obs); - } - if (phoneControllers.isEmpty) { - phoneControllers.add(TextEditingController()); - phoneLabels.add('Work'.obs); - } - - controller.enteredTags.assignAll( - widget.existingContact!.tags.map((tag) => tag.name).toList(), - ); - + void _initFields() { + final c = widget.existingContact; + if (c != null) { + nameController.text = c.name; + orgController.text = c.organization; + addressController.text = c.address; + descriptionController.text = c.description ; + } + if (c != null) { + emailControllers.assignAll(c.contactEmails.isEmpty + ? [TextEditingController()] + : c.contactEmails.map((e) => TextEditingController(text: e.emailAddress))); + emailLabels.assignAll(c.contactEmails.isEmpty + ? ['Office'.obs] + : c.contactEmails.map((e) => e.label.obs)); + phoneControllers.assignAll(c.contactPhones.isEmpty + ? [TextEditingController()] + : c.contactPhones.map((p) => TextEditingController(text: p.phoneNumber))); + phoneLabels.assignAll(c.contactPhones.isEmpty + ? ['Work'.obs] + : c.contactPhones.map((p) => p.label.obs)); + controller.enteredTags.assignAll(c.tags.map((tag) => tag.name)); ever(controller.isInitialized, (bool ready) { if (ready) { - final projectIds = widget.existingContact!.projectIds; - final bucketId = widget.existingContact!.bucketIds.firstOrNull; - final categoryName = widget.existingContact!.contactCategory?.name; - - if (categoryName != null) { - controller.selectedCategory.value = categoryName; - } - + final projectIds = c.projectIds; + final bucketId = c.bucketIds.firstOrNull; + final categoryName = c.contactCategory?.name; + if (categoryName != null) controller.selectedCategory.value = categoryName; if (projectIds != null) { - final names = projectIds - .map((id) { - return controller.projectsMap.entries + controller.selectedProjects.assignAll( + projectIds // + .map((id) => controller.projectsMap.entries .firstWhereOrNull((e) => e.value == id) - ?.key; - }) - .whereType() - .toList(); - controller.selectedProjects.assignAll(names); + ?.key) + .whereType() + .toList(), + ); } if (bucketId != null) { final name = controller.bucketsMap.entries .firstWhereOrNull((e) => e.value == bucketId) ?.key; - if (name != null) { - controller.selectedBucket.value = name; - } + if (name != null) controller.selectedBucket.value = name; } } }); @@ -110,6 +96,7 @@ class _AddContactBottomSheetState extends State { phoneControllers.add(TextEditingController()); phoneLabels.add('Work'.obs); } + tagTextController.clear(); } @override @@ -119,12 +106,17 @@ class _AddContactBottomSheetState extends State { tagTextController.dispose(); addressController.dispose(); descriptionController.dispose(); - emailControllers.forEach((e) => e.dispose()); - phoneControllers.forEach((p) => p.dispose()); + for (final c in emailControllers) { + c.dispose(); + } + for (final c in phoneControllers) { + c.dispose(); + } Get.delete(); super.dispose(); } + // --- COMMON WIDGETS --- InputDecoration _inputDecoration(String hint) => InputDecoration( hintText: hint, hintStyle: MyTextStyle.bodySmall(xMuted: true), @@ -142,19 +134,21 @@ class _AddContactBottomSheetState extends State { borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5), ), - contentPadding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), isDense: true, ); - Widget _buildLabeledRow( - String label, - RxString selectedLabel, - List options, - String inputLabel, - TextEditingController controller, - TextInputType inputType, - {VoidCallback? onRemove}) { + // DRY'd: LABELED FIELD ROW (used for phone/email) + Widget _buildLabeledRow({ + required String label, + required RxString selectedLabel, + required List options, + required String inputLabel, + required TextEditingController controller, + required TextInputType inputType, + VoidCallback? onRemove, + Widget? suffixIcon, + }) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -165,9 +159,10 @@ class _AddContactBottomSheetState extends State { MyText.labelMedium(label), MySpacing.height(8), _popupSelector( - hint: "Label", - selectedValue: selectedLabel, - options: options), + hint: "Label", + selectedValue: selectedLabel, + options: options, + ), ], ), ), @@ -187,33 +182,17 @@ class _AddContactBottomSheetState extends State { : [], decoration: _inputDecoration("Enter $inputLabel").copyWith( counterText: "", - suffixIcon: inputType == TextInputType.phone - ? IconButton( - icon: const Icon(Icons.contact_phone, - color: Colors.blue), - onPressed: () async { - final selectedPhone = - await ContactPickerHelper.pickIndianPhoneNumber( - context); - if (selectedPhone != null) { - controller.text = selectedPhone; - } - }, - ) - : null, + suffixIcon: suffixIcon, ), validator: (value) { - if (value == null || value.trim().isEmpty) - return "$inputLabel is required"; + if (value == null || value.trim().isEmpty) return null; final trimmed = value.trim(); - if (inputType == TextInputType.phone) { - if (!RegExp(r'^[1-9][0-9]{9}$').hasMatch(trimmed)) { - return "Enter valid phone number"; - } + if (inputType == TextInputType.phone && + !RegExp(r'^[1-9][0-9]{9}$').hasMatch(trimmed)) { + return "Enter valid phone number"; } if (inputType == TextInputType.emailAddress && - !RegExp(r'^[\w.-]+@([\w-]+\.)+[\w-]{2,4}$') - .hasMatch(trimmed)) { + !RegExp(r'^[\w.-]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(trimmed)) { return "Enter valid email"; } return null; @@ -234,94 +213,110 @@ class _AddContactBottomSheetState extends State { ); } - Widget _buildEmailList() => Column( - children: List.generate(emailControllers.length, (index) { + // DRY: List builder for email/phone fields + Widget _buildDynamicList({ + required RxList ctrls, + required RxList labels, + required List labelOptions, + required String label, + required String inputLabel, + required TextInputType inputType, + required RxList listToRemoveFrom, + Widget? phoneSuffixIcon, + }) { + return Obx(() { + return Column( + children: List.generate(ctrls.length, (index) { return Padding( padding: const EdgeInsets.only(bottom: 12), child: _buildLabeledRow( - "Email Label", - emailLabels[index], - ["Office", "Personal", "Other"], - "Email", - emailControllers[index], - TextInputType.emailAddress, - onRemove: emailControllers.length > 1 + label: label, + selectedLabel: labels[index], + options: labelOptions, + inputLabel: inputLabel, + controller: ctrls[index], + inputType: inputType, + onRemove: ctrls.length > 1 ? () { - emailControllers.removeAt(index); - emailLabels.removeAt(index); + ctrls.removeAt(index); + labels.removeAt(index); } : null, + suffixIcon: phoneSuffixIcon != null && inputType == TextInputType.phone + ? IconButton( + icon: const Icon(Icons.contact_phone, color: Colors.blue), + onPressed: () async { + final selectedPhone = + await ContactPickerHelper.pickIndianPhoneNumber(context); + if (selectedPhone != null) { + ctrls[index].text = selectedPhone; + } + }, + ) + : null, ), ); }), ); + }); + } - Widget _buildPhoneList() => Column( - children: List.generate(phoneControllers.length, (index) { - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: _buildLabeledRow( - "Phone Label", - phoneLabels[index], - ["Work", "Mobile", "Other"], - "Phone", - phoneControllers[index], - TextInputType.phone, - onRemove: phoneControllers.length > 1 - ? () { - phoneControllers.removeAt(index); - phoneLabels.removeAt(index); - } - : null, - ), - ); - }), + Widget _buildEmailList() => _buildDynamicList( + ctrls: emailControllers, + labels: emailLabels, + labelOptions: ["Office", "Personal", "Other"], + label: "Email Label", + inputLabel: "Email", + inputType: TextInputType.emailAddress, + listToRemoveFrom: emailControllers, + ); + + Widget _buildPhoneList() => _buildDynamicList( + ctrls: phoneControllers, + labels: phoneLabels, + labelOptions: ["Work", "Mobile", "Other"], + label: "Phone Label", + inputLabel: "Phone", + inputType: TextInputType.phone, + listToRemoveFrom: phoneControllers, + phoneSuffixIcon: const Icon(Icons.contact_phone, color: Colors.blue), ); Widget _popupSelector({ required String hint, required RxString selectedValue, required List options, - }) { - return Obx(() => GestureDetector( - onTap: () async { - final selected = await showMenu( - context: context, - position: RelativeRect.fromLTRB(100, 300, 100, 0), - items: options.map((option) { - return PopupMenuItem( - value: option, - child: Text(option), - ); - }).toList(), - ); - - if (selected != null) { - selectedValue.value = selected; - } - }, - child: Container( - height: 48, - padding: const EdgeInsets.symmetric(horizontal: 16), - decoration: BoxDecoration( - color: Colors.grey.shade100, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.grey.shade300), + }) => + Obx(() => GestureDetector( + onTap: () async { + final selected = await showMenu( + context: context, + position: RelativeRect.fromLTRB(100, 300, 100, 0), + items: options.map((option) => PopupMenuItem(value: option, child: Text(option))).toList(), + ); + if (selected != null) selectedValue.value = selected; + }, + child: Container( + height: 48, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade300), + ), + alignment: Alignment.centerLeft, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + selectedValue.value.isNotEmpty ? selectedValue.value : hint, + style: const TextStyle(fontSize: 14), + ), + const Icon(Icons.expand_more, size: 20), + ], + ), ), - alignment: Alignment.centerLeft, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - selectedValue.value.isNotEmpty ? selectedValue.value : hint, - style: const TextStyle(fontSize: 14), - ), - const Icon(Icons.expand_more, size: 20), - ], - ), - ), - )); - } + )); Widget _sectionLabel(String title) => Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -332,6 +327,7 @@ class _AddContactBottomSheetState extends State { ], ); + // CHIP list for tags Widget _tagInputSection() { return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -350,16 +346,14 @@ class _AddContactBottomSheetState extends State { ), ), Obx(() => controller.filteredSuggestions.isEmpty - ? const SizedBox() + ? const SizedBox.shrink() : Container( margin: const EdgeInsets.only(top: 4), decoration: BoxDecoration( color: Colors.white, border: Border.all(color: Colors.grey.shade300), borderRadius: BorderRadius.circular(8), - boxShadow: const [ - BoxShadow(color: Colors.black12, blurRadius: 4) - ], + boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 4)], ), child: ListView.builder( shrinkWrap: true, @@ -392,145 +386,233 @@ class _AddContactBottomSheetState extends State { ); } - Widget _buildTextField(String label, TextEditingController controller, - {int maxLines = 1}) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.labelMedium(label), - MySpacing.height(8), - TextFormField( - controller: controller, - maxLines: maxLines, - decoration: _inputDecoration("Enter $label"), - validator: (value) => value == null || value.trim().isEmpty - ? "$label is required" - : null, - ), - ], - ); - } + // ---- REQUIRED FIELD (reusable) + Widget _buildTextField( + String label, + TextEditingController controller, { + int maxLines = 1, + bool required = false, + }) => + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.labelMedium(label), + MySpacing.height(8), + TextFormField( + controller: controller, + maxLines: maxLines, + decoration: _inputDecoration("Enter $label"), + validator: required + ? (value) => + value == null || value.trim().isEmpty ? "$label is required" : null + : null, + ), + ], + ); - Widget _buildOrganizationField() { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.labelMedium("Organization"), - MySpacing.height(8), - TextField( - controller: orgController, - onChanged: controller.filterOrganizationSuggestions, - decoration: _inputDecoration("Enter organization"), - ), - Obx(() => controller.filteredOrgSuggestions.isEmpty - ? const SizedBox() - : ListView.builder( - shrinkWrap: true, - itemCount: controller.filteredOrgSuggestions.length, - itemBuilder: (context, index) { - final suggestion = controller.filteredOrgSuggestions[index]; - return ListTile( - dense: true, - title: Text(suggestion), - onTap: () { - orgController.text = suggestion; - controller.filteredOrgSuggestions.clear(); - }, + // -- Organization as required TextFormField + Widget _buildOrganizationField() => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.labelMedium("Organization"), + MySpacing.height(8), + TextFormField( + controller: orgController, + onChanged: controller.filterOrganizationSuggestions, + decoration: _inputDecoration("Enter organization"), + validator: (value) => + value == null || value.trim().isEmpty ? "Organization is required" : null, + ), + Obx(() => controller.filteredOrgSuggestions.isEmpty + ? const SizedBox.shrink() + : ListView.builder( + shrinkWrap: true, + itemCount: controller.filteredOrgSuggestions.length, + itemBuilder: (context, index) { + final suggestion = controller.filteredOrgSuggestions[index]; + return ListTile( + dense: true, + title: Text(suggestion), + onTap: () { + orgController.text = suggestion; + controller.filteredOrgSuggestions.clear(); + }, + ); + }, + )), + ], + ); + + // Action button row + Widget _buildActionButtons() => Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () { + Get.back(); + Get.delete(); + }, + icon: const Icon(Icons.close, color: Colors.red), + label: MyText.bodyMedium("Cancel", color: Colors.red, fontWeight: 600), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Colors.red), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), + ), + ), + ), + MySpacing.width(12), + Expanded( + child: ElevatedButton.icon( + onPressed: () { + // Validate bucket first in UI and show error under dropdown if empty + bool valid = formKey.currentState!.validate(); + if (controller.selectedBucket.value.isEmpty) { + bucketError.value = "Bucket is required"; + valid = false; + } else { + bucketError.value = ""; + } + if (valid) { + final emails = emailControllers + .asMap() + .entries + .where((entry) => entry.value.text.trim().isNotEmpty) + .map((entry) => { + "label": emailLabels[entry.key].value, + "emailAddress": entry.value.text.trim(), + }) + .toList(); + final phones = phoneControllers + .asMap() + .entries + .where((entry) => entry.value.text.trim().isNotEmpty) + .map((entry) => { + "label": phoneLabels[entry.key].value, + "phoneNumber": entry.value.text.trim(), + }) + .toList(); + controller.submitContact( + id: widget.existingContact?.id, + name: nameController.text.trim(), + organization: orgController.text.trim(), + emails: emails, + phones: phones, + address: addressController.text.trim(), + description: descriptionController.text.trim(), ); - }, - )), - ], - ); - } - - Widget _buildActionButtons() { - return Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: () { - Get.back(); - Get.delete(); - }, - icon: const Icon(Icons.close, color: Colors.red), - label: - MyText.bodyMedium("Cancel", color: Colors.red, fontWeight: 600), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: Colors.red), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10)), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), - ), - ), - ), - MySpacing.width(12), - Expanded( - child: ElevatedButton.icon( - onPressed: () { - if (formKey.currentState!.validate()) { - final emails = emailControllers - .asMap() - .entries - .where((entry) => entry.value.text.trim().isNotEmpty) - .map((entry) => { - "label": emailLabels[entry.key].value, - "emailAddress": entry.value.text.trim(), - }) - .toList(); - - final phones = phoneControllers - .asMap() - .entries - .where((entry) => entry.value.text.trim().isNotEmpty) - .map((entry) => { - "label": phoneLabels[entry.key].value, - "phoneNumber": entry.value.text.trim(), - }) - .toList(); - - controller.submitContact( - id: widget.existingContact?.id, - name: nameController.text.trim(), - organization: orgController.text.trim(), - emails: emails, - phones: phones, - address: addressController.text.trim(), - description: descriptionController.text.trim(), - ); - } - }, - icon: const Icon(Icons.check_circle_outline, color: Colors.white), - label: - MyText.bodyMedium("Save", color: Colors.white, fontWeight: 600), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.indigo, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10)), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), - ), - ), - ), - ], + } + }, + icon: const Icon(Icons.check_circle_outline, color: Colors.white), + label: MyText.bodyMedium("Save", color: Colors.white, fontWeight: 600), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.indigo, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), + ), + ), + ), + ], + ); + + // Projects multi-select section + Widget _projectSelectorUI() { + return GestureDetector( + onTap: () async { + await showDialog( + context: context, + builder: (_) { + return AlertDialog( + title: const Text('Select Projects'), + content: Obx(() => SizedBox( + width: double.maxFinite, + child: ListView( + shrinkWrap: true, + children: controller.globalProjects.map((project) { + final isSelected = controller.selectedProjects.contains(project); + return Theme( + data: Theme.of(context).copyWith( + checkboxTheme: CheckboxThemeData( + fillColor: MaterialStateProperty.resolveWith( + (states) => states.contains(MaterialState.selected) + ? Colors.white + : Colors.transparent), + checkColor: MaterialStateProperty.all(Colors.black), + side: const BorderSide(color: Colors.black, width: 2), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + ), + ), + ), + child: CheckboxListTile( + dense: true, + title: Text(project), + value: isSelected, + onChanged: (selected) { + if (selected == true) { + controller.selectedProjects.add(project); + } else { + controller.selectedProjects.remove(project); + } + }, + ), + ); + }).toList(), + ), + )), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Done'), + ), + ], + ); + }, + ); + }, + child: Container( + height: 48, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade300), + ), + alignment: Alignment.centerLeft, + child: Obx(() { + final selected = controller.selectedProjects; + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + selected.isEmpty ? "Select Projects" : selected.join(', '), + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 14), + ), + ), + const Icon(Icons.expand_more, size: 20), + ], + ); + }), + ), ); } + // --- MAIN BUILD --- @override Widget build(BuildContext context) { return Obx(() { if (!controller.isInitialized.value) { return const Center(child: CircularProgressIndicator()); } - return SafeArea( child: SingleChildScrollView( - padding: EdgeInsets.only( - top: 32, - ).add(MediaQuery.of(context).viewInsets), + padding: EdgeInsets.only(top: 32).add(MediaQuery.of(context).viewInsets), child: Container( decoration: BoxDecoration( color: Theme.of(context).cardColor, - borderRadius: - const BorderRadius.vertical(top: Radius.circular(24)), + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), ), child: Padding( padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), @@ -541,25 +623,44 @@ class _AddContactBottomSheetState extends State { children: [ Center( child: MyText.titleMedium( - widget.existingContact != null - ? "Edit Contact" - : "Create New Contact", + widget.existingContact != null ? "Edit Contact" : "Create New Contact", fontWeight: 700, ), ), MySpacing.height(24), _sectionLabel("Required Fields"), MySpacing.height(12), - _buildTextField("Name", nameController), + _buildTextField("Name", nameController, required: true), MySpacing.height(16), _buildOrganizationField(), MySpacing.height(16), MyText.labelMedium("Select Bucket"), MySpacing.height(8), - _popupSelector( - hint: "Select Bucket", - selectedValue: controller.selectedBucket, - options: controller.buckets, + Stack( + children: [ + _popupSelector( + hint: "Select Bucket", + selectedValue: controller.selectedBucket, + options: controller.buckets, + ), + // Validation message for bucket + Positioned( + left: 0, + right: 0, + top: 56, + child: Obx( + () => bucketError.value.isEmpty + ? const SizedBox.shrink() + : Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0), + child: Text( + bucketError.value, + style: const TextStyle(color: Colors.red, fontSize: 12), + ), + ), + ), + ), + ], ), MySpacing.height(24), Obx(() => GestureDetector( @@ -567,11 +668,8 @@ class _AddContactBottomSheetState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - MyText.labelLarge("Advanced Details (Optional)", - fontWeight: 600), - Icon(showAdvanced.value - ? Icons.expand_less - : Icons.expand_more), + MyText.labelLarge("Advanced Details (Optional)", fontWeight: 600), + Icon(showAdvanced.value ? Icons.expand_less : Icons.expand_more), ], ), )), @@ -619,15 +717,12 @@ class _AddContactBottomSheetState extends State { MySpacing.height(8), _tagInputSection(), MySpacing.height(16), - _buildTextField("Address", addressController, - maxLines: 2), + _buildTextField("Address", addressController, maxLines: 2, required: false), MySpacing.height(16), - _buildTextField( - "Description", descriptionController, - maxLines: 2), + _buildTextField("Description", descriptionController, maxLines: 2, required: false), ], ) - : const SizedBox()), + : const SizedBox.shrink()), MySpacing.height(24), _buildActionButtons(), ], @@ -639,95 +734,4 @@ class _AddContactBottomSheetState extends State { ); }); } - - Widget _projectSelectorUI() { - return GestureDetector( - onTap: () async { - await showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - title: const Text('Select Projects'), - content: Obx(() { - return SizedBox( - width: double.maxFinite, - child: ListView( - shrinkWrap: true, - children: controller.globalProjects.map((project) { - final isSelected = - controller.selectedProjects.contains(project); - return Theme( - data: Theme.of(context).copyWith( - unselectedWidgetColor: Colors.black, - checkboxTheme: CheckboxThemeData( - fillColor: MaterialStateProperty.resolveWith( - (states) { - if (states.contains(MaterialState.selected)) { - return Colors.white; - } - return Colors.transparent; - }), - checkColor: MaterialStateProperty.all(Colors.black), - side: - const BorderSide(color: Colors.black, width: 2), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(4), - ), - ), - ), - child: CheckboxListTile( - dense: true, - title: Text(project), - value: isSelected, - onChanged: (bool? selected) { - if (selected == true) { - controller.selectedProjects.add(project); - } else { - controller.selectedProjects.remove(project); - } - }, - ), - ); - }).toList(), - ), - ); - }), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Done'), - ), - ], - ); - }, - ); - }, - child: Container( - height: 48, - padding: const EdgeInsets.symmetric(horizontal: 16), - decoration: BoxDecoration( - color: Colors.grey.shade100, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.grey.shade300), - ), - alignment: Alignment.centerLeft, - child: Obx(() { - final selected = controller.selectedProjects; - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - selected.isEmpty ? "Select Projects" : selected.join(', '), - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 14), - ), - ), - const Icon(Icons.expand_more, size: 20), - ], - ); - }), - ), - ); - } } diff --git a/lib/view/directory/contact_detail_screen.dart b/lib/view/directory/contact_detail_screen.dart index 9550f70..f670c18 100644 --- a/lib/view/directory/contact_detail_screen.dart +++ b/lib/view/directory/contact_detail_screen.dart @@ -16,32 +16,20 @@ import 'package:marco/model/directory/add_comment_bottom_sheet.dart'; import 'package:marco/helpers/utils/date_time_utils.dart'; import 'package:marco/model/directory/add_contact_bottom_sheet.dart'; -class ContactDetailScreen extends StatefulWidget { - final ContactModel contact; - - const ContactDetailScreen({super.key, required this.contact}); - - @override - State createState() => _ContactDetailScreenState(); -} - +// HELPER: Delta to HTML conversion String _convertDeltaToHtml(dynamic delta) { final buffer = StringBuffer(); bool inList = false; for (var op in delta.toList()) { - final data = op.data?.toString() ?? ''; + final String data = op.data?.toString() ?? ''; final attr = op.attributes ?? {}; + final bool isListItem = attr.containsKey('list'); - final isListItem = attr.containsKey('list'); - - // Start list if (isListItem && !inList) { buffer.write('
    '); inList = true; } - - // Close list if we are not in list mode anymore if (!isListItem && inList) { buffer.write('
'); inList = false; @@ -49,15 +37,12 @@ String _convertDeltaToHtml(dynamic delta) { if (isListItem) buffer.write('
  • '); - // Apply inline styles if (attr.containsKey('bold')) buffer.write(''); if (attr.containsKey('italic')) buffer.write(''); if (attr.containsKey('underline')) buffer.write(''); if (attr.containsKey('strike')) buffer.write(''); if (attr.containsKey('link')) buffer.write(''); - buffer.write(data.replaceAll('\n', '')); - if (attr.containsKey('link')) buffer.write(''); if (attr.containsKey('strike')) buffer.write(''); if (attr.containsKey('underline')) buffer.write(''); @@ -66,14 +51,21 @@ String _convertDeltaToHtml(dynamic delta) { if (isListItem) buffer.write('
  • '); - else if (data.contains('\n')) buffer.write('
    '); + else if (data.contains('\n')) { + buffer.write('
    '); + } } - if (inList) buffer.write(''); - return buffer.toString(); } +class ContactDetailScreen extends StatefulWidget { + final ContactModel contact; + const ContactDetailScreen({super.key, required this.contact}); + @override + State createState() => _ContactDetailScreenState(); +} + class _ContactDetailScreenState extends State { late final DirectoryController directoryController; late final ProjectController projectController; @@ -85,7 +77,6 @@ class _ContactDetailScreenState extends State { directoryController = Get.find(); projectController = Get.find(); contact = widget.contact; - WidgetsBinding.instance.addPostFrameCallback((_) { directoryController.fetchCommentsForContact(contact.id); }); @@ -103,13 +94,12 @@ class _ContactDetailScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildSubHeader(), + const Divider(height: 1, thickness: 0.5, color: Colors.grey), Expanded( - child: TabBarView( - children: [ - _buildDetailsTab(), - _buildCommentsTab(context), - ], - ), + child: TabBarView(children: [ + _buildDetailsTab(), + _buildCommentsTab(context), + ]), ), ], ), @@ -130,10 +120,8 @@ class _ContactDetailScreenState extends State { crossAxisAlignment: CrossAxisAlignment.center, children: [ IconButton( - icon: const Icon(Icons.arrow_back_ios_new, - color: Colors.black, size: 20), - onPressed: () => - Get.offAllNamed('/dashboard/directory-main-page'), + icon: const Icon(Icons.arrow_back_ios_new, color: Colors.black, size: 20), + onPressed: () => Get.offAllNamed('/dashboard/directory-main-page'), ), MySpacing.width(8), Expanded( @@ -141,30 +129,10 @@ class _ContactDetailScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - MyText.titleLarge('Contact Profile', - fontWeight: 700, color: Colors.black), + MyText.titleLarge('Contact Profile', fontWeight: 700, color: Colors.black), MySpacing.height(2), GetBuilder( - builder: (projectController) { - final projectName = - projectController.selectedProject?.name ?? - 'Select Project'; - return Row( - children: [ - const Icon(Icons.work_outline, - size: 14, color: Colors.grey), - MySpacing.width(4), - Expanded( - child: MyText.bodySmall( - projectName, - fontWeight: 600, - overflow: TextOverflow.ellipsis, - color: Colors.grey[700], - ), - ), - ], - ); - }, + builder: (p) => ProjectLabel(p.selectedProject?.name), ), ], ), @@ -176,38 +144,30 @@ class _ContactDetailScreenState extends State { } Widget _buildSubHeader() { + final firstName = contact.name.split(" ").first; + final lastName = contact.name.split(" ").length > 1 ? contact.name.split(" ").last : ""; + return Padding( padding: MySpacing.xy(16, 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - Avatar( - firstName: contact.name.split(" ").first, - lastName: contact.name.split(" ").length > 1 - ? contact.name.split(" ").last - : "", - size: 35, - backgroundColor: Colors.indigo, - ), - MySpacing.width(12), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.titleSmall(contact.name, - fontWeight: 600, color: Colors.black), - MySpacing.height(2), - MyText.bodySmall(contact.organization, - fontWeight: 500, color: Colors.grey[700]), - ], - ), - ], - ), + Row(children: [ + Avatar(firstName: firstName, lastName: lastName, size: 35, backgroundColor: Colors.indigo), + MySpacing.width(12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleSmall(contact.name, fontWeight: 600, color: Colors.black), + MySpacing.height(2), + MyText.bodySmall(contact.organization, fontWeight: 500, color: Colors.grey[700]), + ], + ), + ]), TabBar( labelColor: Colors.red, unselectedLabelColor: Colors.black, - indicator: MaterialIndicator( + indicator: MaterialIndicator( color: Colors.red, height: 4, topLeftRadius: 8, @@ -226,33 +186,37 @@ class _ContactDetailScreenState extends State { } Widget _buildDetailsTab() { - final email = contact.contactEmails.isNotEmpty - ? contact.contactEmails.first.emailAddress - : "-"; - - final phone = contact.contactPhones.isNotEmpty - ? contact.contactPhones.first.phoneNumber - : "-"; - final tags = contact.tags.map((e) => e.name).join(", "); - final bucketNames = contact.bucketIds .map((id) => directoryController.contactBuckets .firstWhereOrNull((b) => b.id == id) ?.name) .whereType() .join(", "); - - final projectNames = contact.projectIds - ?.map((id) => projectController.projects - .firstWhereOrNull((p) => p.id == id) - ?.name) - .whereType() - .join(", ") ?? - "-"; - + final projectNames = contact.projectIds?.map((id) => + projectController.projects.firstWhereOrNull((p) => p.id == id)?.name).whereType().join(", ") ?? "-"; final category = contact.contactCategory?.name ?? "-"; + Widget multiRows({required List items, required IconData icon, required String label, required String typeLabel, required Function(String)? onTap, required Function(String)? onLongPress}) { + return items.isNotEmpty + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _iconInfoRow(icon, label, items.first, onTap: () => onTap?.call(items.first), onLongPress: () => onLongPress?.call(items.first)), + ...items.skip(1).map( + (val) => _iconInfoRow( + null, + '', + val, + onTap: () => onTap?.call(val), + onLongPress: () => onLongPress?.call(val), + ), + ), + ], + ) + : _iconInfoRow(icon, label, "-"); + } + return Stack( children: [ SingleChildScrollView( @@ -261,28 +225,38 @@ class _ContactDetailScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ MySpacing.height(12), + // BASIC INFO CARD _infoCard("Basic Info", [ - _iconInfoRow(Icons.email, "Email", email, - onTap: () => LauncherUtils.launchEmail(email), - onLongPress: () => LauncherUtils.copyToClipboard(email, - typeLabel: "Email")), - _iconInfoRow(Icons.phone, "Phone", phone, - onTap: () => LauncherUtils.launchPhone(phone), - onLongPress: () => LauncherUtils.copyToClipboard(phone, - typeLabel: "Phone")), + multiRows( + items: contact.contactEmails.map((e) => e.emailAddress).toList(), + icon: Icons.email, + label: "Email", + typeLabel: "Email", + onTap: (email) => LauncherUtils.launchEmail(email), + onLongPress: (email) => LauncherUtils.copyToClipboard(email, typeLabel: "Email"), + ), + multiRows( + items: contact.contactPhones.map((p) => p.phoneNumber).toList(), + icon: Icons.phone, + label: "Phone", + typeLabel: "Phone", + onTap: (phone) => LauncherUtils.launchPhone(phone), + onLongPress: (phone) => LauncherUtils.copyToClipboard(phone, typeLabel: "Phone"), + ), _iconInfoRow(Icons.location_on, "Address", contact.address), ]), + // ORGANIZATION CARD _infoCard("Organization", [ - _iconInfoRow( - Icons.business, "Organization", contact.organization), + _iconInfoRow(Icons.business, "Organization", contact.organization), _iconInfoRow(Icons.category, "Category", category), ]), + // META INFO CARD _infoCard("Meta Info", [ _iconInfoRow(Icons.label, "Tags", tags.isNotEmpty ? tags : "-"), - _iconInfoRow(Icons.folder_shared, "Contact Buckets", - bucketNames.isNotEmpty ? bucketNames : "-"), + _iconInfoRow(Icons.folder_shared, "Contact Buckets", bucketNames.isNotEmpty ? bucketNames : "-"), _iconInfoRow(Icons.work_outline, "Projects", projectNames), ]), + // DESCRIPTION CARD _infoCard("Description", [ MySpacing.height(6), Align( @@ -294,7 +268,7 @@ class _ContactDetailScreenState extends State { textAlign: TextAlign.left, ), ), - ]) + ]), ], ), ), @@ -309,25 +283,17 @@ class _ContactDetailScreenState extends State { isScrollControlled: true, backgroundColor: Colors.transparent, ); - if (result == true) { await directoryController.fetchContacts(); final updated = - directoryController.allContacts.firstWhereOrNull( - (c) => c.id == contact.id, - ); + directoryController.allContacts.firstWhereOrNull((c) => c.id == contact.id); if (updated != null) { - setState(() { - contact = updated; - }); + setState(() => contact = updated); } } }, icon: const Icon(Icons.edit, color: Colors.white), - label: const Text( - "Edit Contact", - style: TextStyle(color: Colors.white), - ), + label: const Text("Edit Contact", style: TextStyle(color: Colors.white)), ), ), ], @@ -337,24 +303,17 @@ class _ContactDetailScreenState extends State { Widget _buildCommentsTab(BuildContext context) { return Obx(() { final contactId = contact.id; - if (!directoryController.contactCommentsMap.containsKey(contactId)) { return const Center(child: CircularProgressIndicator()); } - - final comments = directoryController - .getCommentsForContact(contactId) - .reversed - .toList(); - + final comments = directoryController.getCommentsForContact(contactId).reversed.toList(); final editingId = directoryController.editingCommentId.value; return Stack( children: [ comments.isEmpty - ? Center( - child: - MyText.bodyLarge("No comments yet.", color: Colors.grey), + ? Center( + child: MyText.bodyLarge("No comments yet.", color: Colors.grey), ) : Padding( padding: MySpacing.xy(12, 12), @@ -362,137 +321,10 @@ class _ContactDetailScreenState extends State { padding: const EdgeInsets.only(bottom: 100), itemCount: comments.length, separatorBuilder: (_, __) => MySpacing.height(14), - itemBuilder: (_, index) { - final comment = comments[index]; - final isEditing = editingId == comment.id; - - final initials = comment.createdBy.firstName.isNotEmpty - ? comment.createdBy.firstName[0].toUpperCase() - : "?"; - - final decodedDelta = HtmlToDelta().convert(comment.note); - - final quillController = isEditing - ? quill.QuillController( - document: quill.Document.fromDelta(decodedDelta), - selection: TextSelection.collapsed( - offset: decodedDelta.length), - ) - : null; - - return AnimatedContainer( - duration: const Duration(milliseconds: 300), - padding: MySpacing.xy(8, 7), - decoration: BoxDecoration( - color: isEditing ? Colors.indigo[50] : Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: isEditing - ? Colors.indigo - : Colors.grey.shade300, - width: 1.2, - ), - boxShadow: const [ - BoxShadow( - color: Colors.black12, - blurRadius: 4, - offset: Offset(0, 2), - ) - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header Row - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Avatar( - firstName: initials, - lastName: '', - size: 36), - MySpacing.width(12), - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - MyText.bodyMedium( - "By: ${comment.createdBy.firstName}", - fontWeight: 600, - color: Colors.indigo[800], - ), - MySpacing.height(4), - MyText.bodySmall( - DateTimeUtils.convertUtcToLocal( - comment.createdAt.toString(), - format: 'dd MMM yyyy, hh:mm a', - ), - color: Colors.grey[600], - ), - ], - ), - ), - IconButton( - icon: Icon( - isEditing ? Icons.close : Icons.edit, - size: 20, - color: Colors.indigo, - ), - onPressed: () { - directoryController.editingCommentId.value = - isEditing ? null : comment.id; - }, - ), - ], - ), - // Comment Content - if (isEditing && quillController != null) - CommentEditorCard( - controller: quillController, - onCancel: () { - directoryController.editingCommentId.value = - null; - }, - onSave: (controller) async { - final delta = controller.document.toDelta(); - final htmlOutput = _convertDeltaToHtml(delta); - final updated = - comment.copyWith(note: htmlOutput); - - await directoryController - .updateComment(updated); - - // ✅ Re-fetch comments to get updated list - await directoryController - .fetchCommentsForContact(contactId); - - // ✅ Exit editing mode - directoryController.editingCommentId.value = - null; - }, - ) - else - html.Html( - data: comment.note, - style: { - "body": html.Style( - margin: html.Margins.zero, - padding: html.HtmlPaddings.zero, - fontSize: html.FontSize.medium, - color: Colors.black87, - ), - }, - ), - ], - ), - ); - }, + itemBuilder: (_, index) => _buildCommentItem(comments[index], editingId, contact.id), ), ), - - // Floating Action Button - if (directoryController.editingCommentId.value == null) + if (editingId == null) Positioned( bottom: 20, right: 20, @@ -503,17 +335,12 @@ class _ContactDetailScreenState extends State { AddCommentBottomSheet(contactId: contactId), isScrollControlled: true, ); - if (result == true) { - await directoryController - .fetchCommentsForContact(contactId); + await directoryController.fetchCommentsForContact(contactId); } }, icon: const Icon(Icons.add_comment, color: Colors.white), - label: const Text( - "Add Comment", - style: TextStyle(color: Colors.white), - ), + label: const Text("Add Comment", style: TextStyle(color: Colors.white)), ), ), ], @@ -521,25 +348,127 @@ class _ContactDetailScreenState extends State { }); } - Widget _iconInfoRow(IconData icon, String label, String value, - {VoidCallback? onTap, VoidCallback? onLongPress}) { + Widget _buildCommentItem(comment, editingId, contactId) { + final isEditing = editingId == comment.id; + final initials = comment.createdBy.firstName.isNotEmpty + ? comment.createdBy.firstName[0].toUpperCase() + : "?"; + final decodedDelta = HtmlToDelta().convert(comment.note); + final quillController = isEditing + ? quill.QuillController( + document: quill.Document.fromDelta(decodedDelta), + selection: TextSelection.collapsed(offset: decodedDelta.length), + ) + : null; + + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + padding: MySpacing.xy(8, 7), + decoration: BoxDecoration( + color: isEditing ? Colors.indigo[50] : Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isEditing ? Colors.indigo : Colors.grey.shade300, + width: 1.2, + ), + boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 4, offset: Offset(0, 2))], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header Row + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Avatar(firstName: initials, lastName: '', size: 36), + MySpacing.width(12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodyMedium("By: ${comment.createdBy.firstName}", + fontWeight: 600, color: Colors.indigo[800]), + MySpacing.height(4), + MyText.bodySmall( + DateTimeUtils.convertUtcToLocal( + comment.createdAt.toString(), + format: 'dd MMM yyyy, hh:mm a', + ), + color: Colors.grey[600], + ), + ], + ), + ), + IconButton( + icon: Icon( + isEditing ? Icons.close : Icons.edit, + size: 20, + color: Colors.indigo, + ), + onPressed: () { + directoryController.editingCommentId.value = isEditing ? null : comment.id; + }, + ), + ], + ), + // Comment Content + if (isEditing && quillController != null) + CommentEditorCard( + controller: quillController, + onCancel: () => directoryController.editingCommentId.value = null, + onSave: (ctrl) async { + final delta = ctrl.document.toDelta(); + final htmlOutput = _convertDeltaToHtml(delta); + final updated = comment.copyWith(note: htmlOutput); + await directoryController.updateComment(updated); + await directoryController.fetchCommentsForContact(contactId); + directoryController.editingCommentId.value = null; + }, + ) + else + html.Html( + data: comment.note, + style: { + "body": html.Style( + margin: html.Margins.zero, + padding: html.HtmlPaddings.zero, + fontSize: html.FontSize.medium, + color: Colors.black87, + ), + }, + ), + ], + ), + ); + } + + Widget _iconInfoRow( + IconData? icon, + String label, + String value, { + VoidCallback? onTap, + VoidCallback? onLongPress, + }) { return Padding( - padding: MySpacing.y(8), + padding: MySpacing.y(2), child: GestureDetector( onTap: onTap, onLongPress: onLongPress, child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(icon, size: 22, color: Colors.indigo), - MySpacing.width(12), + if (icon != null) ...[ + Icon(icon, size: 22, color: Colors.indigo), + MySpacing.width(12), + ] else + const SizedBox(width: 34), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - MyText.bodySmall(label, - fontWeight: 600, color: Colors.black87), - MySpacing.height(2), + if (label.isNotEmpty) + MyText.bodySmall(label, fontWeight: 600, color: Colors.black87), + if (label.isNotEmpty) MySpacing.height(2), MyText.bodyMedium(value, color: Colors.grey[800]), ], ), @@ -560,8 +489,7 @@ class _ContactDetailScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - MyText.titleSmall(title, - fontWeight: 700, color: Colors.indigo[700]), + MyText.titleSmall(title, fontWeight: 700, color: Colors.indigo[700]), MySpacing.height(8), ...children, ], @@ -570,3 +498,26 @@ class _ContactDetailScreenState extends State { ); } } + +// Helper widget for Project label in AppBar +class ProjectLabel extends StatelessWidget { + final String? projectName; + const ProjectLabel(this.projectName, {super.key}); + @override + Widget build(BuildContext context) { + return Row( + children: [ + const Icon(Icons.work_outline, size: 14, color: Colors.grey), + MySpacing.width(4), + Expanded( + child: MyText.bodySmall( + projectName ?? 'Select Project', + fontWeight: 600, + overflow: TextOverflow.ellipsis, + color: Colors.grey[700], + ), + ), + ], + ); + } +} diff --git a/lib/view/directory/directory_view.dart b/lib/view/directory/directory_view.dart index 2230a24..1cecd6c 100644 --- a/lib/view/directory/directory_view.dart +++ b/lib/view/directory/directory_view.dart @@ -24,8 +24,7 @@ class DirectoryView extends StatefulWidget { class _DirectoryViewState extends State { final DirectoryController controller = Get.find(); final TextEditingController searchController = TextEditingController(); - final PermissionController permissionController = - Get.put(PermissionController()); + final PermissionController permissionController = Get.put(PermissionController()); Future _refreshDirectory() async { try { @@ -304,7 +303,6 @@ class _DirectoryViewState extends State { backgroundColor: Colors.transparent, builder: (_) => const CreateBucketBottomSheet(), ); - if (created == true) { await controller.fetchBuckets(); } @@ -442,62 +440,69 @@ class _DirectoryViewState extends State { color: Colors.grey[700], overflow: TextOverflow.ellipsis), MySpacing.height(8), - ...contact.contactEmails.map((e) => - GestureDetector( - onTap: () => LauncherUtils.launchEmail( - e.emailAddress), - onLongPress: () => - LauncherUtils.copyToClipboard( - e.emailAddress, - typeLabel: 'Email'), - child: Padding( - padding: - const EdgeInsets.only(bottom: 4), - child: Row( - children: [ - const Icon(Icons.email_outlined, - size: 16, color: Colors.indigo), - MySpacing.width(4), - ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: 180), - child: MyText.labelSmall( - e.emailAddress, - overflow: TextOverflow.ellipsis, - color: Colors.indigo, - decoration: - TextDecoration.underline, - ), - ), - ], - ), - ), - )), - ...contact.contactPhones.map((p) => Padding( - padding: const EdgeInsets.only( - bottom: 8, top: 4), + + // Show only the first email (if present) + if (contact.contactEmails.isNotEmpty) + GestureDetector( + onTap: () => LauncherUtils.launchEmail( + contact.contactEmails.first.emailAddress), + onLongPress: () => + LauncherUtils.copyToClipboard( + contact.contactEmails.first.emailAddress, + typeLabel: 'Email', + ), + child: Padding( + padding: const EdgeInsets.only(bottom: 4), child: Row( children: [ - GestureDetector( - onTap: () => - LauncherUtils.launchPhone( - p.phoneNumber), + const Icon(Icons.email_outlined, + size: 16, color: Colors.indigo), + MySpacing.width(4), + Expanded( + child: MyText.labelSmall( + contact.contactEmails.first.emailAddress, + overflow: TextOverflow.ellipsis, + color: Colors.indigo, + decoration: + TextDecoration.underline, + ), + ), + ], + ), + ), + ), + + // Show only the first phone (if present) + if (contact.contactPhones.isNotEmpty) + Padding( + padding: const EdgeInsets.only( + bottom: 8, top: 4), + child: Row( + children: [ + Expanded( + child: GestureDetector( + onTap: () => LauncherUtils + .launchPhone(contact + .contactPhones + .first + .phoneNumber), onLongPress: () => LauncherUtils.copyToClipboard( - p.phoneNumber, - typeLabel: 'Phone'), + contact.contactPhones.first + .phoneNumber, + typeLabel: 'Phone', + ), child: Row( children: [ - const Icon(Icons.phone_outlined, + const Icon( + Icons.phone_outlined, size: 16, color: Colors.indigo), MySpacing.width(4), - ConstrainedBox( - constraints: - const BoxConstraints( - maxWidth: 140), + Expanded( child: MyText.labelSmall( - p.phoneNumber, + contact.contactPhones.first + .phoneNumber, overflow: TextOverflow.ellipsis, color: Colors.indigo, @@ -508,19 +513,22 @@ class _DirectoryViewState extends State { ], ), ), - MySpacing.width(8), - GestureDetector( - onTap: () => - LauncherUtils.launchWhatsApp( - p.phoneNumber), - child: const FaIcon( - FontAwesomeIcons.whatsapp, - color: Colors.green, - size: 16), + ), + MySpacing.width(8), + GestureDetector( + onTap: () => + LauncherUtils.launchWhatsApp( + contact.contactPhones.first + .phoneNumber), + child: const FaIcon( + FontAwesomeIcons.whatsapp, + color: Colors.green, + size: 16, ), - ], - ), - )), + ), + ], + ), + ), if (tags.isNotEmpty) ...[ MySpacing.height(2), MyText.labelSmall(tags.join(', '), From 98836f81578788e1eba3ea4e33af0bd615202b14 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Wed, 30 Jul 2025 11:37:38 +0530 Subject: [PATCH 24/65] Refactor app_logger to remove storage permission request and improve log file handling --- lib/helpers/services/app_logger.dart | 33 +++++++++------------------- 1 file changed, 10 insertions(+), 23 deletions(-) diff --git a/lib/helpers/services/app_logger.dart b/lib/helpers/services/app_logger.dart index 7afdc4d..9047066 100644 --- a/lib/helpers/services/app_logger.dart +++ b/lib/helpers/services/app_logger.dart @@ -1,18 +1,14 @@ import 'dart:io'; import 'package:logger/logger.dart'; import 'package:intl/intl.dart'; -import 'package:permission_handler/permission_handler.dart'; +import 'package:path_provider/path_provider.dart'; /// Global logger instance late final Logger appLogger; - -/// Log file output handler late final FileLogOutput fileLogOutput; -/// Initialize logging (call once in `main()`) +/// Initialize logging Future initLogging() async { - await requestStoragePermission(); - fileLogOutput = FileLogOutput(); appLogger = Logger( @@ -23,21 +19,13 @@ Future initLogging() async { printEmojis: true, ), output: MultiOutput([ - ConsoleOutput(), // ✅ Console will use the top-level PrettyPrinter - fileLogOutput, // ✅ File will still use the SimpleFileLogPrinter + ConsoleOutput(), + fileLogOutput, ]), level: Level.debug, ); } -/// Request storage permission (for Android 11+) -Future requestStoragePermission() async { - final status = await Permission.manageExternalStorage.status; - if (!status.isGranted) { - await Permission.manageExternalStorage.request(); - } -} - /// Safe logger wrapper void logSafe( String message, { @@ -46,7 +34,7 @@ void logSafe( StackTrace? stackTrace, bool sensitive = false, }) { - if (sensitive) return; + if (sensitive) return; switch (level) { case LogLevel.debug: @@ -66,15 +54,15 @@ void logSafe( } } -/// Custom log output that writes to a local `.txt` file +/// Log output to file (safe path, no permission required) class FileLogOutput extends LogOutput { File? _logFile; - /// Initialize log file in Downloads/marco_logs/log_YYYY-MM-DD.txt Future _init() async { if (_logFile != null) return; - final directory = Directory('/storage/emulated/0/Download/marco_logs'); + final baseDir = await getExternalStorageDirectory(); + final directory = Directory('${baseDir!.path}/marco_logs'); if (!await directory.exists()) { await directory.create(recursive: true); } @@ -119,7 +107,6 @@ class FileLogOutput extends LogOutput { return _logFile!.readAsString(); } - /// Delete logs older than 3 days Future _cleanOldLogs(Directory directory) async { final files = directory.listSync(); final now = DateTime.now(); @@ -135,7 +122,7 @@ class FileLogOutput extends LogOutput { } } -/// A simple, readable log printer for file output +/// Simple log printer for file output class SimpleFileLogPrinter extends LogPrinter { @override List log(LogEvent event) { @@ -152,5 +139,5 @@ class SimpleFileLogPrinter extends LogPrinter { } } -/// Optional log level enum for better type safety +/// Optional enum for log levels enum LogLevel { debug, info, warning, error, verbose } From d28332b55d7cccf8ff6c19302e3e308d4811ae82 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Wed, 30 Jul 2025 16:45:21 +0530 Subject: [PATCH 25/65] feat: implement ExpenseDetailModel and update ExpenseDetailController and Screen for improved expense detail handling --- .../expense/expense_detail_controller.dart | 12 +- lib/model/expense/expense_detail_model.dart | 278 ++++++++ lib/view/expense/expense_detail_screen.dart | 625 ++++++++++-------- 3 files changed, 646 insertions(+), 269 deletions(-) create mode 100644 lib/model/expense/expense_detail_model.dart diff --git a/lib/controller/expense/expense_detail_controller.dart b/lib/controller/expense/expense_detail_controller.dart index 4ff0bec..116cc84 100644 --- a/lib/controller/expense/expense_detail_controller.dart +++ b/lib/controller/expense/expense_detail_controller.dart @@ -1,10 +1,11 @@ import 'package:get/get.dart'; import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/helpers/services/app_logger.dart'; -import 'package:marco/model/expense/expense_list_model.dart'; +import 'package:marco/model/expense/expense_detail_model.dart'; class ExpenseDetailController extends GetxController { - final Rx expense = Rx(null); + final Rx expense = Rx(null); + final RxBool isLoading = false.obs; final RxString errorMessage = ''.obs; @@ -16,10 +17,11 @@ class ExpenseDetailController extends GetxController { try { logSafe("Fetching expense details for ID: $expenseId"); - final result = await ApiService.getExpenseDetailsApi(expenseId: expenseId); + final result = + await ApiService.getExpenseDetailsApi(expenseId: expenseId); if (result != null) { try { - expense.value = ExpenseModel.fromJson(result); + expense.value = ExpenseDetailModel.fromJson(result); logSafe("Expense details loaded successfully: ${expense.value?.id}"); } catch (e) { errorMessage.value = 'Failed to parse expense details: $e'; @@ -52,7 +54,7 @@ class ExpenseDetailController extends GetxController { ); if (success) { logSafe("Expense status updated successfully."); - await fetchExpenseDetails(expenseId); // Refresh details + await fetchExpenseDetails(expenseId); return true; } else { errorMessage.value = "Failed to update expense status."; diff --git a/lib/model/expense/expense_detail_model.dart b/lib/model/expense/expense_detail_model.dart new file mode 100644 index 0000000..e056d3c --- /dev/null +++ b/lib/model/expense/expense_detail_model.dart @@ -0,0 +1,278 @@ +class ExpenseDetailModel { + final String id; + final Project project; + final ExpensesType expensesType; + final PaymentMode paymentMode; + final Person paidBy; + final Person createdBy; + final String transactionDate; + final String createdAt; + final String supplerName; + final double amount; + final ExpenseStatus status; + final List nextStatus; + final bool preApproved; + final String transactionId; + final String description; + final String location; + final List documents; + final String? gstNumber; + final int noOfPersons; + final bool isActive; + + ExpenseDetailModel({ + required this.id, + required this.project, + required this.expensesType, + required this.paymentMode, + required this.paidBy, + required this.createdBy, + required this.transactionDate, + required this.createdAt, + required this.supplerName, + required this.amount, + required this.status, + required this.nextStatus, + required this.preApproved, + required this.transactionId, + required this.description, + required this.location, + required this.documents, + this.gstNumber, + required this.noOfPersons, + required this.isActive, + }); + + factory ExpenseDetailModel.fromJson(Map json) { + return ExpenseDetailModel( + id: json['id'] ?? '', + project: json['project'] != null ? Project.fromJson(json['project']) : Project.empty(), + expensesType: json['expensesType'] != null ? ExpensesType.fromJson(json['expensesType']) : ExpensesType.empty(), + paymentMode: json['paymentMode'] != null ? PaymentMode.fromJson(json['paymentMode']) : PaymentMode.empty(), + paidBy: json['paidBy'] != null ? Person.fromJson(json['paidBy']) : Person.empty(), + createdBy: json['createdBy'] != null ? Person.fromJson(json['createdBy']) : Person.empty(), + transactionDate: json['transactionDate'] ?? '', + createdAt: json['createdAt'] ?? '', + supplerName: json['supplerName'] ?? '', + amount: (json['amount'] as num?)?.toDouble() ?? 0.0, + status: json['status'] != null ? ExpenseStatus.fromJson(json['status']) : ExpenseStatus.empty(), + nextStatus: (json['nextStatus'] as List?)?.map((e) => ExpenseStatus.fromJson(e)).toList() ?? [], + preApproved: json['preApproved'] ?? false, + transactionId: json['transactionId'] ?? '', + description: json['description'] ?? '', + location: json['location'] ?? '', + documents: (json['documents'] as List?)?.map((e) => ExpenseDocument.fromJson(e)).toList() ?? [], + gstNumber: json['gstNumber']?.toString(), + noOfPersons: json['noOfPersons'] ?? 0, + isActive: json['isActive'] ?? true, + ); + } +} + +class Project { + final String id; + final String name; + final String shortName; + final String projectAddress; + final String contactPerson; + final String startDate; + final String endDate; + final String projectStatusId; + + Project({ + required this.id, + required this.name, + required this.shortName, + required this.projectAddress, + required this.contactPerson, + required this.startDate, + required this.endDate, + required this.projectStatusId, + }); + + factory Project.fromJson(Map json) { + return Project( + id: json['id'] ?? '', + name: json['name'] ?? '', + shortName: json['shortName'] ?? '', + projectAddress: json['projectAddress'] ?? '', + contactPerson: json['contactPerson'] ?? '', + startDate: json['startDate'] ?? '', + endDate: json['endDate'] ?? '', + projectStatusId: json['projectStatusId'] ?? '', + ); + } + + factory Project.empty() => Project( + id: '', + name: '', + shortName: '', + projectAddress: '', + contactPerson: '', + startDate: '', + endDate: '', + projectStatusId: '', + ); +} + +class ExpensesType { + final String id; + final String name; + final bool noOfPersonsRequired; + final String description; + + ExpensesType({ + required this.id, + required this.name, + required this.noOfPersonsRequired, + required this.description, + }); + + factory ExpensesType.fromJson(Map json) { + return ExpensesType( + id: json['id'] ?? '', + name: json['name'] ?? '', + noOfPersonsRequired: json['noOfPersonsRequired'] ?? false, + description: json['description'] ?? '', + ); + } + + factory ExpensesType.empty() => ExpensesType( + id: '', + name: '', + noOfPersonsRequired: false, + description: '', + ); +} + +class PaymentMode { + final String id; + final String name; + final String description; + + PaymentMode({ + required this.id, + required this.name, + required this.description, + }); + + factory PaymentMode.fromJson(Map json) { + return PaymentMode( + id: json['id'] ?? '', + name: json['name'] ?? '', + description: json['description'] ?? '', + ); + } + + factory PaymentMode.empty() => PaymentMode( + id: '', + name: '', + description: '', + ); +} + +class Person { + final String id; + final String firstName; + final String lastName; + final String photo; + final String jobRoleId; + final String jobRoleName; + + Person({ + required this.id, + required this.firstName, + required this.lastName, + required this.photo, + required this.jobRoleId, + required this.jobRoleName, + }); + + factory Person.fromJson(Map json) { + return Person( + id: json['id'] ?? '', + firstName: json['firstName'] ?? '', + lastName: json['lastName'] ?? '', + photo: json['photo'] is String ? json['photo'] : '', + jobRoleId: json['jobRoleId'] ?? '', + jobRoleName: json['jobRoleName'] ?? '', + ); + } + + factory Person.empty() => Person( + id: '', + firstName: '', + lastName: '', + photo: '', + jobRoleId: '', + jobRoleName: '', + ); +} + +class ExpenseStatus { + final String id; + final String name; + final String displayName; + final String description; + final String? permissionIds; + final String color; + final bool isSystem; + + ExpenseStatus({ + required this.id, + required this.name, + required this.displayName, + required this.description, + required this.permissionIds, + required this.color, + required this.isSystem, + }); + + factory ExpenseStatus.fromJson(Map json) { + return ExpenseStatus( + id: json['id'] ?? '', + name: json['name'] ?? '', + displayName: json['displayName'] ?? '', + description: json['description'] ?? '', + permissionIds: json['permissionIds']?.toString(), + color: json['color'] ?? '', + isSystem: json['isSystem'] ?? false, + ); + } + + factory ExpenseStatus.empty() => ExpenseStatus( + id: '', + name: '', + displayName: '', + description: '', + permissionIds: null, + color: '', + isSystem: false, + ); +} + +class ExpenseDocument { + final String documentId; + final String fileName; + final String contentType; + final String preSignedUrl; + final String thumbPreSignedUrl; + + ExpenseDocument({ + required this.documentId, + required this.fileName, + required this.contentType, + required this.preSignedUrl, + required this.thumbPreSignedUrl, + }); + + factory ExpenseDocument.fromJson(Map json) { + return ExpenseDocument( + documentId: json['documentId'] ?? '', + fileName: json['fileName'] ?? '', + contentType: json['contentType'] ?? '', + preSignedUrl: json['preSignedUrl'] ?? '', + thumbPreSignedUrl: json['thumbPreSignedUrl'] ?? '', + ); + } +} diff --git a/lib/view/expense/expense_detail_screen.dart b/lib/view/expense/expense_detail_screen.dart index ad1991d..f7204a9 100644 --- a/lib/view/expense/expense_detail_screen.dart +++ b/lib/view/expense/expense_detail_screen.dart @@ -2,18 +2,18 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:intl/intl.dart'; import 'package:marco/controller/expense/expense_detail_controller.dart'; +import 'package:marco/controller/project_controller.dart'; import 'package:marco/helpers/utils/date_time_utils.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; -import 'package:marco/model/expense/expense_list_model.dart'; -import 'package:marco/controller/project_controller.dart'; +import 'package:marco/model/expense/expense_detail_model.dart'; +import 'package:marco/helpers/widgets/image_viewer_dialog.dart'; +import 'package:url_launcher/url_launcher.dart'; class ExpenseDetailScreen extends StatelessWidget { final String expenseId; - const ExpenseDetailScreen({super.key, required this.expenseId}); - // Status color logic static Color getStatusColor(String? status, {String? colorCode}) { if (colorCode != null && colorCode.isNotEmpty) { try { @@ -42,64 +42,49 @@ class ExpenseDetailScreen extends StatelessWidget { return Scaffold( backgroundColor: const Color(0xFFF7F7F7), - appBar: PreferredSize( - preferredSize: const Size.fromHeight(72), - child: AppBar( - backgroundColor: Colors.white, - elevation: 1, - automaticallyImplyLeading: false, - titleSpacing: 0, - title: Padding( - padding: MySpacing.xy(16, 0), - child: Row( - children: [ - IconButton( - icon: const Icon(Icons.arrow_back_ios_new, - color: Colors.black, size: 20), - onPressed: () => - Get.offAllNamed('/dashboard/expense-main-page'), - ), - MySpacing.width(8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - MyText.titleLarge( - 'Expense Details', - fontWeight: 700, - color: Colors.black, - ), - MySpacing.height(2), - Obx(() { - final projectName = - projectController.selectedProject?.name ?? - 'Select Project'; - return InkWell( - onTap: () => Get.toNamed('/project-selector'), - child: Row( - children: [ - const Icon(Icons.work_outline, - size: 14, color: Colors.grey), - MySpacing.width(4), - Expanded( - child: MyText.bodySmall( - projectName, - fontWeight: 600, - overflow: TextOverflow.ellipsis, - color: Colors.grey[700], - ), - ), - ], - ), - ); - }), - ], - ), - ), - ], + appBar: AppBar( + automaticallyImplyLeading: false, + elevation: 1, + backgroundColor: Colors.white, + title: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back_ios_new, + color: Colors.black, size: 20), + onPressed: () => Get.offAllNamed('/dashboard/expense-main-page'), ), - ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleLarge('Expense Details', + fontWeight: 700, color: Colors.black), + MySpacing.height(2), + GetBuilder(builder: (_) { + final projectName = + projectController.selectedProject?.name ?? + 'Select Project'; + return Row( + children: [ + const Icon(Icons.work_outline, + size: 14, color: Colors.grey), + MySpacing.width(4), + Expanded( + child: MyText.bodySmall( + projectName, + fontWeight: 600, + overflow: TextOverflow.ellipsis, + color: Colors.grey[700], + ), + ), + ], + ); + }), + ], + ), + ), + ], ), ), body: SafeArea( @@ -109,23 +94,21 @@ class ExpenseDetailScreen extends StatelessWidget { } if (controller.errorMessage.isNotEmpty) { return Center( - child: Text( + child: MyText.bodyMedium( controller.errorMessage.value, - style: const TextStyle(color: Colors.red, fontSize: 16), + color: Colors.red, ), ); } final expense = controller.expense.value; if (expense == null) { - return const Center(child: Text("No expense details found.")); + return Center( + child: MyText.bodyMedium("No expense details found.")); } - final statusColor = getStatusColor( - expense.status.name, - colorCode: expense.status.color, - ); - + final statusColor = getStatusColor(expense.status.name, + colorCode: expense.status.color); final formattedAmount = NumberFormat.currency( locale: 'en_IN', symbol: '₹ ', @@ -133,20 +116,38 @@ class ExpenseDetailScreen extends StatelessWidget { ).format(expense.amount); return SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _ExpenseHeader( - title: expense.expensesType.name, - amount: formattedAmount, - status: expense.status.name, - statusColor: statusColor, + padding: const EdgeInsets.all(8), + child: Center( + child: Container( + constraints: const BoxConstraints(maxWidth: 520), + child: Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + elevation: 3, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 14, horizontal: 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _InvoiceHeader(expense: expense), + Divider(height: 30, thickness: 1.2), + _InvoiceParties(expense: expense), + Divider(height: 30, thickness: 1.2), + _InvoiceDetailsTable(expense: expense), + Divider(height: 30, thickness: 1.2), + _InvoiceDocuments(documents: expense.documents), + Divider(height: 30, thickness: 1.2), + _InvoiceTotals( + expense: expense, + formattedAmount: formattedAmount, + statusColor: statusColor, + ), + ], + ), + ), ), - const SizedBox(height: 16), - _ExpenseDetailsList(expense: expense), - const SizedBox(height: 100), - ], + ), ), ); }), @@ -156,17 +157,18 @@ class ExpenseDetailScreen extends StatelessWidget { if (expense == null || expense.nextStatus.isEmpty) { return const SizedBox(); } - return SafeArea( child: Container( - color: Colors.white, + decoration: const BoxDecoration( + color: Colors.white, + border: Border(top: BorderSide(color: Color(0x11000000))), + ), padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), child: Wrap( alignment: WrapAlignment.center, spacing: 10, runSpacing: 10, children: expense.nextStatus.map((next) { - Color buttonColor = Colors.red; if (next.color.isNotEmpty) { try { @@ -174,7 +176,6 @@ class ExpenseDetailScreen extends StatelessWidget { Color(int.parse(next.color.replaceFirst('#', '0xff'))); } catch (_) {} } - return ElevatedButton( style: ElevatedButton.styleFrom( minimumSize: const Size(100, 40), @@ -182,8 +183,7 @@ class ExpenseDetailScreen extends StatelessWidget { const EdgeInsets.symmetric(vertical: 8, horizontal: 12), backgroundColor: buttonColor, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(6), - ), + borderRadius: BorderRadius.circular(6)), ), onPressed: () async { final success = await controller.updateExpenseStatus( @@ -205,13 +205,10 @@ class ExpenseDetailScreen extends StatelessWidget { ); } }, - child: Text( + child: MyText.labelMedium( next.displayName.isNotEmpty ? next.displayName : next.name, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w600, - fontSize: 14, - ), + color: Colors.white, + fontWeight: 600, overflow: TextOverflow.ellipsis, ), ); @@ -223,7 +220,6 @@ class ExpenseDetailScreen extends StatelessWidget { ); } - // Loading skeleton placeholder Widget _buildLoadingSkeleton() { return ListView( padding: const EdgeInsets.all(16), @@ -241,193 +237,294 @@ class ExpenseDetailScreen extends StatelessWidget { } } -// Expense header card -class _ExpenseHeader extends StatelessWidget { - final String title; - final String amount; - final String status; - final Color statusColor; +// ---------------- INVOICE SUB-COMPONENTS ---------------- - const _ExpenseHeader({ - required this.title, - required this.amount, - required this.status, +class _InvoiceHeader extends StatelessWidget { + final ExpenseDetailModel expense; + const _InvoiceHeader({required this.expense}); + + @override + Widget build(BuildContext context) { + final dateString = DateTimeUtils.convertUtcToLocal( + expense.transactionDate.toString(), + format: 'dd-MM-yyyy'); + + final statusColor = ExpenseDetailScreen.getStatusColor(expense.status.name, + colorCode: expense.status.color); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const Icon(Icons.calendar_month, size: 18, color: Colors.grey), + MySpacing.width(6), + MyText.bodySmall('Date:', fontWeight: 600), + MySpacing.width(6), + MyText.bodySmall(dateString, fontWeight: 600), + ], + ), + Container( + decoration: BoxDecoration( + color: statusColor.withOpacity(0.15), + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + child: Row( + children: [ + Icon(Icons.flag, size: 16, color: statusColor), + MySpacing.width(4), + MyText.labelSmall( + expense.status.name, + color: statusColor, + fontWeight: 600, + ), + ], + ), + ), + ], + ) + ], + ); + } +} + +class _InvoiceParties extends StatelessWidget { + final ExpenseDetailModel expense; + const _InvoiceParties({required this.expense}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _labelValueBlock('Project', expense.project.name), + MySpacing.height(16), + _labelValueBlock( + 'Paid By:', + '${expense.paidBy.firstName} ${expense.paidBy.lastName}', + ), + MySpacing.height(16), + _labelValueBlock('Supplier', expense.supplerName), + MySpacing.height(16), + _labelValueBlock( + 'Created By:', + '${expense.createdBy.firstName} ${expense.createdBy.lastName}', + ), + ], + ); + } + + Widget _labelValueBlock(String label, String value) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodySmall( + label, + fontWeight: 600, + ), + MySpacing.height(4), + MyText.bodySmall( + value, + fontWeight: 500, + softWrap: true, + maxLines: null, // Allow full wrapping + ), + ], + ); + } +} + +class _InvoiceDetailsTable extends StatelessWidget { + final ExpenseDetailModel expense; + const _InvoiceDetailsTable({required this.expense}); + + @override + Widget build(BuildContext context) { + final transactionDate = DateTimeUtils.convertUtcToLocal( + expense.transactionDate.toString(), + format: 'dd-MM-yyyy hh:mm a'); + final createdAt = DateTimeUtils.convertUtcToLocal( + expense.createdAt.toString(), + format: 'dd-MM-yyyy hh:mm a'); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _detailItem("Expense Type:", expense.expensesType.name), + _detailItem("Payment Mode:", expense.paymentMode.name), + _detailItem("Transaction Date:", transactionDate), + _detailItem("Created At:", createdAt), + _detailItem("Pre-Approved:", expense.preApproved ? 'Yes' : 'No'), + _detailItem("Description:", + expense.description.trim().isNotEmpty ? expense.description : '-', + isDescription: true), + ], + ); + } + + Widget _detailItem(String title, String value, {bool isDescription = false}) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodySmall( + title, + fontWeight: 600, + ), + MySpacing.height(3), + isDescription + ? ExpandableDescription(description: value) + : MyText.bodySmall( + value, + fontWeight: 500, + ), + ], + ), + ); + } +} + +class _InvoiceDocuments extends StatelessWidget { + final List documents; + const _InvoiceDocuments({required this.documents}); + + @override + Widget build(BuildContext context) { + if (documents.isEmpty) { + return MyText.bodyMedium('No Supporting Documents', color: Colors.grey); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodySmall("Supporting Documents:", fontWeight: 600), + const SizedBox(height: 8), + Wrap( + spacing: 10, + children: documents.map((doc) { + return GestureDetector( + onTap: () async { + final imageDocs = documents + .where((d) => d.contentType.startsWith('image/')) + .toList(); + + final initialIndex = + imageDocs.indexWhere((d) => d.documentId == doc.documentId); + + if (imageDocs.isNotEmpty && initialIndex != -1) { + showDialog( + context: context, + builder: (_) => ImageViewerDialog( + imageSources: + imageDocs.map((e) => e.preSignedUrl).toList(), + initialIndex: initialIndex, + ), + ); + } else { + final Uri url = Uri.parse(doc.preSignedUrl); + if (await canLaunchUrl(url)) { + await launchUrl(url, mode: LaunchMode.externalApplication); + } else { + Get.snackbar("Error", "Could not open the document."); + } + } + }, + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(6), + color: Colors.grey.shade100, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + doc.contentType.startsWith('image/') + ? Icons.image + : Icons.insert_drive_file, + size: 20, + color: Colors.grey[600], + ), + const SizedBox(width: 7), + MyText.labelSmall( + doc.fileName, + ), + ], + ), + ), + ); + }).toList(), + ), + ], + ); + } +} + +class _InvoiceTotals extends StatelessWidget { + final ExpenseDetailModel expense; + final String formattedAmount; + final Color statusColor; + const _InvoiceTotals({ + required this.expense, + required this.formattedAmount, required this.statusColor, }); @override Widget build(BuildContext context) { - return Container( - width: double.infinity, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(10), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 5, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: const TextStyle( - fontSize: 22, - fontWeight: FontWeight.bold, - color: Colors.black, - ), - ), - const SizedBox(height: 6), - Text( - amount, - style: const TextStyle( - fontSize: 26, - fontWeight: FontWeight.w700, - color: Colors.black, - ), - ), - const SizedBox(height: 12), - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: statusColor.withOpacity(0.15), - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.flag, size: 16, color: statusColor), - const SizedBox(width: 6), - Text( - status, - style: TextStyle( - color: statusColor, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ], - ), + return Row( + children: [ + MyText.bodyLarge("Total:", fontWeight: 700), + const Spacer(), + MyText.bodyLarge(formattedAmount, fontWeight: 700), + ], ); } } -// Expense details list -class _ExpenseDetailsList extends StatelessWidget { - final ExpenseModel expense; +class ExpandableDescription extends StatefulWidget { + final String description; + const ExpandableDescription({super.key, required this.description}); - const _ExpenseDetailsList({required this.expense}); + @override + State createState() => _ExpandableDescriptionState(); +} + +class _ExpandableDescriptionState extends State { + bool isExpanded = false; @override Widget build(BuildContext context) { - final transactionDate = DateTimeUtils.convertUtcToLocal( - expense.transactionDate.toString(), - format: 'dd-MM-yyyy hh:mm a', - ); - final createdAt = DateTimeUtils.convertUtcToLocal( - expense.createdAt.toString(), - format: 'dd-MM-yyyy hh:mm a', - ); + final isLong = widget.description.length > 100; - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(10), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 5, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _DetailRow(title: "Project", value: expense.project.name), - _DetailRow(title: "Expense Type", value: expense.expensesType.name), - _DetailRow(title: "Payment Mode", value: expense.paymentMode.name), - _DetailRow( - title: "Paid By", - value: '${expense.paidBy.firstName} ${expense.paidBy.lastName}', - ), - _DetailRow( - title: "Created By", - value: - '${expense.createdBy.firstName} ${expense.createdBy.lastName}', - ), - _DetailRow(title: "Transaction Date", value: transactionDate), - _DetailRow(title: "Created At", value: createdAt), - _DetailRow(title: "Supplier Name", value: expense.supplerName), - _DetailRow( - title: "Amount", - value: NumberFormat.currency( - locale: 'en_IN', - symbol: '₹ ', - decimalDigits: 2, - ).format(expense.amount), - ), - _DetailRow(title: "Status", value: expense.status.name), - _DetailRow( - title: "Next Status", - value: expense.nextStatus.map((e) => e.name).join(", "), - ), - _DetailRow( - title: "Pre-Approved", - value: expense.preApproved ? "Yes" : "No", - ), - ], - ), - ); - } -} - -// A single row for expense details -class _DetailRow extends StatelessWidget { - final String title; - final String value; - - const _DetailRow({required this.title, required this.value}); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - flex: 3, - child: Text( - title, - style: const TextStyle( - fontSize: 13, - color: Colors.grey, - fontWeight: FontWeight.w500, + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodySmall( + widget.description, + maxLines: isExpanded ? null : 2, + overflow: isExpanded ? TextOverflow.visible : TextOverflow.ellipsis, + fontWeight: 500, + ), + if (isLong || !isExpanded) + InkWell( + onTap: () => setState(() => isExpanded = !isExpanded), + child: Padding( + padding: const EdgeInsets.only(top: 4), + child: MyText.labelSmall( + isExpanded ? 'Show less' : 'Show more', + fontWeight: 600, + color: Colors.blue, ), ), ), - Expanded( - flex: 5, - child: Text( - value, - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - ), - softWrap: true, - ), - ), - ], - ), + ], ); } } From 154cfdb471fc4b841082e22cc5c57c37359a1a97 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Wed, 30 Jul 2025 16:52:25 +0530 Subject: [PATCH 26/65] refactor: adjust font weights and sizes in ToggleButton and ExpenseList for improved UI consistency --- lib/view/expense/expense_screen.dart | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/lib/view/expense/expense_screen.dart b/lib/view/expense/expense_screen.dart index ac95483..5b15f71 100644 --- a/lib/view/expense/expense_screen.dart +++ b/lib/view/expense/expense_screen.dart @@ -376,7 +376,6 @@ class _ToggleButton extends StatelessWidget { label, color: selected ? Colors.white : Colors.grey, fontWeight: 600, - fontSize: 13, ), ], ), @@ -433,7 +432,7 @@ class _ExpenseList extends StatelessWidget { children: [ MyText.bodyMedium( expense.expensesType.name, - fontWeight: 700, + fontWeight: 600, ), MyText.bodyMedium( '₹ ${expense.amount.toStringAsFixed(2)}', @@ -446,13 +445,12 @@ class _ExpenseList extends StatelessWidget { children: [ MyText.bodySmall( formattedDate, - color: Colors.grey[600], + fontWeight: 500, ), const Spacer(), MyText.bodySmall( expense.status.name, - fontWeight: 600, - color: Colors.black, + fontWeight: 500, ), ], ), From 2b34635a75791cd9345bb1fa1358b76ee2030b37 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Wed, 30 Jul 2025 17:19:46 +0530 Subject: [PATCH 27/65] refactor: reorganize imports and enhance AddExpenseBottomSheet for improved readability and functionality --- .../expense/add_expense_controller.dart | 228 ++--- .../expense/add_expense_bottom_sheet.dart | 899 +++++++++--------- 2 files changed, 572 insertions(+), 555 deletions(-) diff --git a/lib/controller/expense/add_expense_controller.dart b/lib/controller/expense/add_expense_controller.dart index 32c0fa2..7fdd445 100644 --- a/lib/controller/expense/add_expense_controller.dart +++ b/lib/controller/expense/add_expense_controller.dart @@ -1,19 +1,21 @@ -import 'dart:io'; import 'dart:convert'; -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; +import 'dart:io'; + import 'package:file_picker/file_picker.dart'; -import 'package:geolocator/geolocator.dart'; +import 'package:flutter/material.dart'; import 'package:geocoding/geocoding.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:get/get.dart'; import 'package:mime/mime.dart'; + +import 'package:marco/controller/expense/expense_screen_controller.dart'; import 'package:marco/helpers/services/api_service.dart'; -import 'package:marco/model/expense/payment_types_model.dart'; -import 'package:marco/model/expense/expense_type_model.dart'; -import 'package:marco/model/expense/expense_status_model.dart'; import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/model/employee_model.dart'; -import 'package:marco/controller/expense/expense_screen_controller.dart'; +import 'package:marco/model/expense/expense_status_model.dart'; +import 'package:marco/model/expense/expense_type_model.dart'; +import 'package:marco/model/expense/payment_types_model.dart'; class AddExpenseController extends GetxController { // === Text Controllers === @@ -23,35 +25,35 @@ class AddExpenseController extends GetxController { final transactionIdController = TextEditingController(); final gstController = TextEditingController(); final locationController = TextEditingController(); - final ExpenseController expenseController = Get.find(); + final transactionDateController = TextEditingController(); - // === Project Mapping === - final RxMap projectsMap = {}.obs; + // === State Controllers === + final RxBool isLoading = false.obs; + final RxBool isSubmitting = false.obs; + final RxBool isFetchingLocation = false.obs; // === Selected Models === final Rx selectedPaymentMode = Rx(null); final Rx selectedExpenseType = Rx(null); final Rx selectedExpenseStatus = Rx(null); - final RxString selectedProject = ''.obs; final Rx selectedPaidBy = Rx(null); - // === States === - final RxBool preApproved = false.obs; - final RxBool isFetchingLocation = false.obs; + final RxString selectedProject = ''.obs; final Rx selectedTransactionDate = Rx(null); - // === Master Data === + // === Lists === + final RxList attachments = [].obs; + final RxList globalProjects = [].obs; final RxList projects = [].obs; final RxList expenseTypes = [].obs; final RxList paymentModes = [].obs; final RxList expenseStatuses = [].obs; - final RxList globalProjects = [].obs; + final RxList allEmployees = [].obs; - // === Attachments === - final RxList attachments = [].obs; - RxList allEmployees = [].obs; - RxBool isLoading = false.obs; - final RxBool isSubmitting = false.obs; + // === Mappings === + final RxMap projectsMap = {}.obs; + + final ExpenseController expenseController = Get.find(); @override void onInit() { @@ -69,6 +71,7 @@ class AddExpenseController extends GetxController { transactionIdController.dispose(); gstController.dispose(); locationController.dispose(); + transactionDateController.dispose(); super.onClose(); } @@ -80,11 +83,10 @@ class AddExpenseController extends GetxController { allowedExtensions: ['jpg', 'jpeg', 'png', 'pdf', 'doc', 'docx'], allowMultiple: true, ); - if (result != null && result.paths.isNotEmpty) { - final newFiles = + final files = result.paths.whereType().map((e) => File(e)).toList(); - attachments.addAll(newFiles); + attachments.addAll(files); } } catch (e) { Get.snackbar("Error", "Failed to pick attachments: $e"); @@ -95,31 +97,22 @@ class AddExpenseController extends GetxController { attachments.remove(file); } - // === Fetch Master Data === - Future fetchMasterData() async { - try { - final expenseTypesData = await ApiService.getMasterExpenseTypes(); - if (expenseTypesData is List) { - expenseTypes.value = - expenseTypesData.map((e) => ExpenseTypeModel.fromJson(e)).toList(); - } + // === Date Picker === + void pickTransactionDate(BuildContext context) async { + final now = DateTime.now(); + final picked = await showDatePicker( + context: context, + initialDate: selectedTransactionDate.value ?? now, + firstDate: DateTime(now.year - 5), + lastDate: now, // ✅ Restrict future dates + ); - final paymentModesData = await ApiService.getMasterPaymentModes(); - if (paymentModesData is List) { - paymentModes.value = - paymentModesData.map((e) => PaymentModeModel.fromJson(e)).toList(); - } - - final expenseStatusData = await ApiService.getMasterExpenseStatus(); - if (expenseStatusData is List) { - expenseStatuses.value = expenseStatusData - .map((e) => ExpenseStatusModel.fromJson(e)) - .toList(); - } - } catch (e) { - Get.snackbar("Error", "Failed to fetch master data: $e"); + if (picked != null) { + selectedTransactionDate.value = picked; + transactionDateController.text = + "${picked.day.toString().padLeft(2, '0')}-${picked.month.toString().padLeft(2, '0')}-${picked.year}"; } - } + } // === Fetch Current Location === Future fetchCurrentLocation() async { @@ -143,26 +136,21 @@ class AddExpenseController extends GetxController { } final position = await Geolocator.getCurrentPosition( - desiredAccuracy: LocationAccuracy.high, - ); - - final placemarks = await placemarkFromCoordinates( - position.latitude, - position.longitude, - ); + desiredAccuracy: LocationAccuracy.high); + final placemarks = + await placemarkFromCoordinates(position.latitude, position.longitude); if (placemarks.isNotEmpty) { final place = placemarks.first; - final addressParts = [ + final address = [ place.name, place.street, place.subLocality, place.locality, place.administrativeArea, place.country, - ].where((part) => part != null && part.isNotEmpty).toList(); - - locationController.text = addressParts.join(", "); + ].where((e) => e != null && e.isNotEmpty).join(", "); + locationController.text = address; } else { locationController.text = "${position.latitude}, ${position.longitude}"; } @@ -173,35 +161,33 @@ class AddExpenseController extends GetxController { } } - // === Submit Expense === // === Submit Expense === Future submitExpense() async { - if (isSubmitting.value) return; // Prevent multiple taps + if (isSubmitting.value) return; isSubmitting.value = true; try { - // === Validation === - List missingFields = []; + List missing = []; - if (selectedProject.value.isEmpty) missingFields.add("Project"); - if (selectedExpenseType.value == null) missingFields.add("Expense Type"); - if (selectedPaymentMode.value == null) missingFields.add("Payment Mode"); - if (selectedPaidBy.value == null) missingFields.add("Paid By"); - if (amountController.text.isEmpty) missingFields.add("Amount"); - if (supplierController.text.isEmpty) missingFields.add("Supplier Name"); - if (descriptionController.text.isEmpty) missingFields.add("Description"); - if (attachments.isEmpty) missingFields.add("Attachments"); + if (selectedProject.value.isEmpty) missing.add("Project"); + if (selectedExpenseType.value == null) missing.add("Expense Type"); + if (selectedPaymentMode.value == null) missing.add("Payment Mode"); + if (selectedPaidBy.value == null) missing.add("Paid By"); + if (amountController.text.isEmpty) missing.add("Amount"); + if (supplierController.text.isEmpty) missing.add("Supplier Name"); + if (descriptionController.text.isEmpty) missing.add("Description"); + if (attachments.isEmpty) missing.add("Attachments"); - if (missingFields.isNotEmpty) { + if (missing.isNotEmpty) { showAppSnackbar( title: "Missing Fields", - message: "Please provide: ${missingFields.join(', ')}.", + message: "Please provide: ${missing.join(', ')}.", type: SnackbarType.error, ); return; } - final double? amount = double.tryParse(amountController.text); + final amount = double.tryParse(amountController.text); if (amount == null) { showAppSnackbar( title: "Error", @@ -211,39 +197,46 @@ class AddExpenseController extends GetxController { return; } - final projectId = projectsMap[selectedProject.value]; - if (projectId == null) { + final selectedDate = selectedTransactionDate.value ?? DateTime.now(); + if (selectedDate.isAfter(DateTime.now())) { showAppSnackbar( - title: "Error", - message: "Invalid project selection.", + title: "Invalid Date", + message: "Transaction date cannot be in the future.", type: SnackbarType.error, ); return; } - // === Convert Attachments === - final attachmentData = await Future.wait(attachments.map((file) async { + final projectId = projectsMap[selectedProject.value]; + if (projectId == null) { + showAppSnackbar( + title: "Error", + message: "Invalid project selected.", + type: SnackbarType.error, + ); + return; + } + + final billAttachments = await Future.wait(attachments.map((file) async { final bytes = await file.readAsBytes(); - final base64String = base64Encode(bytes); - final mimeType = - lookupMimeType(file.path) ?? 'application/octet-stream'; - final fileSize = await file.length(); + final base64 = base64Encode(bytes); + final mime = lookupMimeType(file.path) ?? 'application/octet-stream'; + final size = await file.length(); return { "fileName": file.path.split('/').last, - "base64Data": base64String, - "contentType": mimeType, - "fileSize": fileSize, + "base64Data": base64, + "contentType": mime, + "fileSize": size, "description": "", }; - }).toList()); + })); - // === API Call === final success = await ApiService.createExpenseApi( projectId: projectId, expensesTypeId: selectedExpenseType.value!.id, paymentModeId: selectedPaymentMode.value!.id, - paidById: selectedPaidBy.value?.id ?? "", + paidById: selectedPaidBy.value!.id, transactionDate: (selectedTransactionDate.value ?? DateTime.now()).toUtc(), transactionId: transactionIdController.text, @@ -252,11 +245,11 @@ class AddExpenseController extends GetxController { supplerName: supplierController.text, amount: amount, noOfPersons: 0, - billAttachments: attachmentData, + billAttachments: billAttachments, ); if (success) { - await Get.find().fetchExpenses(); // 🔄 Refresh list + await expenseController.fetchExpenses(); Get.back(); showAppSnackbar( title: "Success", @@ -281,7 +274,33 @@ class AddExpenseController extends GetxController { } } - // === Fetch Projects === + // === Fetch Data Methods === + Future fetchMasterData() async { + try { + final expenseTypesData = await ApiService.getMasterExpenseTypes(); + final paymentModesData = await ApiService.getMasterPaymentModes(); + final expenseStatusData = await ApiService.getMasterExpenseStatus(); + + if (expenseTypesData is List) { + expenseTypes.value = + expenseTypesData.map((e) => ExpenseTypeModel.fromJson(e)).toList(); + } + + if (paymentModesData is List) { + paymentModes.value = + paymentModesData.map((e) => PaymentModeModel.fromJson(e)).toList(); + } + + if (expenseStatusData is List) { + expenseStatuses.value = expenseStatusData + .map((e) => ExpenseStatusModel.fromJson(e)) + .toList(); + } + } catch (e) { + Get.snackbar("Error", "Failed to fetch master data: $e"); + } + } + Future fetchGlobalProjects() async { try { final response = await ApiService.getGlobalProjects(); @@ -303,31 +322,24 @@ class AddExpenseController extends GetxController { } } - // === Fetch All Employees === Future fetchAllEmployees() async { isLoading.value = true; - try { final response = await ApiService.getAllEmployees(); if (response != null && response.isNotEmpty) { - allEmployees - .assignAll(response.map((json) => EmployeeModel.fromJson(json))); - logSafe( - "All Employees fetched for Manage Bucket: ${allEmployees.length}", - level: LogLevel.info, - ); + allEmployees.assignAll(response.map((e) => EmployeeModel.fromJson(e))); + logSafe("All Employees fetched: ${allEmployees.length}", + level: LogLevel.info); } else { allEmployees.clear(); - logSafe("No employees found for Manage Bucket.", - level: LogLevel.warning); + logSafe("No employees found.", level: LogLevel.warning); } } catch (e) { allEmployees.clear(); - logSafe("Error fetching employees in Manage Bucket", - level: LogLevel.error, error: e); + logSafe("Error fetching employees", level: LogLevel.error, error: e); + } finally { + isLoading.value = false; + update(); } - - isLoading.value = false; - update(); } } diff --git a/lib/model/expense/add_expense_bottom_sheet.dart b/lib/model/expense/add_expense_bottom_sheet.dart index ac6e5af..07f0e23 100644 --- a/lib/model/expense/add_expense_bottom_sheet.dart +++ b/lib/model/expense/add_expense_bottom_sheet.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/controller/expense/add_expense_controller.dart'; @@ -6,10 +8,7 @@ import 'package:marco/model/expense/payment_types_model.dart'; import 'package:marco/model/expense/expense_type_model.dart'; void showAddExpenseBottomSheet() { - Get.bottomSheet( - const _AddExpenseBottomSheet(), - isScrollControlled: true, - ); + Get.bottomSheet(const _AddExpenseBottomSheet(), isScrollControlled: true); } class _AddExpenseBottomSheet extends StatefulWidget { @@ -21,41 +20,67 @@ class _AddExpenseBottomSheet extends StatefulWidget { class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { final AddExpenseController controller = Get.put(AddExpenseController()); - final RxBool isProjectExpanded = false.obs; - void _showEmployeeList(BuildContext context) { - final employees = controller.allEmployees; + void _showEmployeeList() { showModalBottomSheet( context: context, backgroundColor: Colors.white, shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), - builder: (BuildContext context) { - return Obx(() { - return SizedBox( - height: 300, - child: ListView.builder( - itemCount: employees.length, - itemBuilder: (context, index) { - final emp = employees[index]; - final fullName = '${emp.firstName} ${emp.lastName}'.trim(); - - return ListTile( - title: Text(fullName.isNotEmpty ? fullName : "Unnamed"), - onTap: () { - controller.selectedPaidBy.value = emp; - Navigator.pop(context); - }, - ); - }, - ), - ); - }); - }, + borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (_) => Obx(() { + final employees = controller.allEmployees; + return SizedBox( + height: 300, + child: ListView.builder( + itemCount: employees.length, + itemBuilder: (_, index) { + final emp = employees[index]; + final fullName = '${emp.firstName} ${emp.lastName}'.trim(); + return ListTile( + title: Text(fullName.isNotEmpty ? fullName : "Unnamed"), + onTap: () { + controller.selectedPaidBy.value = emp; + Navigator.pop(context); + }, + ); + }, + ), + ); + }), ); } + Future _showOptionList( + List options, + String Function(T) getLabel, + ValueChanged onSelected, + ) async { + final button = context.findRenderObject() as RenderBox; + final overlay = Overlay.of(context).context.findRenderObject() as RenderBox; + final position = button.localToGlobal(Offset.zero, ancestor: overlay); + + final selected = await showMenu( + context: context, + position: RelativeRect.fromLTRB( + position.dx, + position.dy + button.size.height, + position.dx + button.size.width, + 0, + ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + items: options + .map( + (option) => PopupMenuItem( + value: option, + child: Text(getLabel(option)), + ), + ) + .toList(), + ); + + if (selected != null) onSelected(selected); + } + @override Widget build(BuildContext context) { return SafeArea( @@ -64,436 +89,279 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { child: Material( color: Colors.white, borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), - child: Stack( - children: [ - Obx(() { - return SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildDragHandle(), - Center( - child: MyText.titleLarge( - "Add Expense", - fontWeight: 700, - ), - ), - const SizedBox(height: 20), - - // Project Dropdown - const _SectionTitle( - icon: Icons.work_outline, - title: "Project", - requiredField: true, - ), - const SizedBox(height: 6), - Obx(() { - return _DropdownTile( - title: controller.selectedProject.value.isEmpty - ? "Select Project" - : controller.selectedProject.value, - onTap: () => _showOptionList( - context, - controller.globalProjects.toList(), - (p) => p, - (val) => controller.selectedProject.value = val, - ), - ); - }), - const SizedBox(height: 16), - - // Expense Type & GST - const _SectionTitle( - icon: Icons.category_outlined, - title: "Expense Type & GST No.", - requiredField: true, - ), - const SizedBox(height: 6), - Obx(() { - return _DropdownTile( - title: controller.selectedExpenseType.value?.name ?? - "Select Expense Type", - onTap: () => _showOptionList( - context, - controller.expenseTypes.toList(), - (e) => e.name, - (val) => controller.selectedExpenseType.value = val, - ), - ); - }), - const SizedBox(height: 8), - _CustomTextField( - controller: controller.gstController, - hint: "Enter GST No.", - ), - const SizedBox(height: 16), - - // Payment Mode - const _SectionTitle( - icon: Icons.payment, - title: "Payment Mode", - requiredField: true, - ), - const SizedBox(height: 6), - Obx(() { - return _DropdownTile( - title: controller.selectedPaymentMode.value?.name ?? - "Select Payment Mode", - onTap: () => _showOptionList( - context, - controller.paymentModes.toList(), - (m) => m.name, - (val) => controller.selectedPaymentMode.value = val, - ), - ); - }), - const SizedBox(height: 16), - - // Paid By - const _SectionTitle( - icon: Icons.person_outline, - title: "Paid By", - requiredField: true, - ), - const SizedBox(height: 6), - Obx(() { - final selected = controller.selectedPaidBy.value; - return GestureDetector( - onTap: () => _showEmployeeList(context), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 14), - decoration: BoxDecoration( - color: Colors.grey.shade100, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.grey.shade400), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - selected == null - ? "Select Paid By" - : '${selected.firstName} ${selected.lastName}', - style: const TextStyle(fontSize: 14), - ), - const Icon(Icons.arrow_drop_down, size: 22), - ], - ), - ), - ); - }), - const SizedBox(height: 16), - // Amount - const _SectionTitle( - icon: Icons.currency_rupee, - title: "Amount", - requiredField: true, - ), - const SizedBox(height: 6), - _CustomTextField( - controller: controller.amountController, - hint: "Enter Amount", - keyboardType: TextInputType.number, - ), - const SizedBox(height: 16), - - // Supplier Name - const _SectionTitle( - icon: Icons.store_mall_directory_outlined, - title: "Supplier Name", - requiredField: true, - ), - const SizedBox(height: 6), - _CustomTextField( - controller: controller.supplierController, - hint: "Enter Supplier Name", - ), - const SizedBox(height: 16), - - // Transaction ID - const _SectionTitle( - icon: Icons.confirmation_number_outlined, - title: "Transaction ID", - ), - const SizedBox(height: 6), - _CustomTextField( - controller: controller.transactionIdController, - hint: "Enter Transaction ID", - ), - const SizedBox(height: 16), - - // Location - const _SectionTitle( - icon: Icons.location_on_outlined, - title: "Location", - ), - const SizedBox(height: 6), - TextField( - controller: controller.locationController, - decoration: InputDecoration( - hintText: "Enter Location", - filled: true, - fillColor: Colors.grey.shade100, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 10), - suffixIcon: controller.isFetchingLocation.value - ? const Padding( - padding: EdgeInsets.all(12), - child: SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2), - ), - ) - : IconButton( - icon: const Icon(Icons.my_location), - tooltip: "Use Current Location", - onPressed: controller.fetchCurrentLocation, - ), - ), - ), - const SizedBox(height: 16), - - // Attachments Section - const _SectionTitle( - icon: Icons.attach_file, - title: "Attachments", - requiredField: true, - ), - const SizedBox(height: 6), - Obx(() { - return Wrap( - spacing: 8, - runSpacing: 8, - children: [ - ...controller.attachments.map((file) { - final fileName = file.path.split('/').last; - final extension = - fileName.split('.').last.toLowerCase(); - - final isImage = - ['jpg', 'jpeg', 'png'].contains(extension); - - IconData fileIcon; - Color iconColor = Colors.blueAccent; - switch (extension) { - case 'pdf': - fileIcon = Icons.picture_as_pdf; - iconColor = Colors.redAccent; - break; - case 'doc': - case 'docx': - fileIcon = Icons.description; - iconColor = Colors.blueAccent; - break; - case 'xls': - case 'xlsx': - fileIcon = Icons.table_chart; - iconColor = Colors.green; - break; - case 'txt': - fileIcon = Icons.article; - iconColor = Colors.grey; - break; - default: - fileIcon = Icons.insert_drive_file; - iconColor = Colors.blueGrey; - } - - return Stack( - children: [ - Container( - width: 80, - height: 80, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Colors.grey.shade300), - color: Colors.grey.shade100, - ), - child: isImage - ? ClipRRect( - borderRadius: - BorderRadius.circular(8), - child: Image.file(file, - fit: BoxFit.cover), - ) - : Column( - mainAxisAlignment: - MainAxisAlignment.center, - children: [ - Icon(fileIcon, - color: iconColor, size: 30), - const SizedBox(height: 4), - Text( - extension.toUpperCase(), - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - color: iconColor, - ), - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - Positioned( - top: -6, - right: -6, - child: IconButton( - icon: const Icon(Icons.close, - color: Colors.red, size: 18), - onPressed: () => - controller.removeAttachment(file), - ), - ), - ], - ); - }).toList(), - GestureDetector( - onTap: controller.pickAttachments, - child: Container( - width: 80, - height: 80, - decoration: BoxDecoration( - border: - Border.all(color: Colors.grey.shade400), - borderRadius: BorderRadius.circular(8), - color: Colors.grey.shade100, - ), - child: const Icon(Icons.add, - size: 30, color: Colors.grey), - ), - ), - ], - ); - }), - const SizedBox(height: 16), - - // Description - const _SectionTitle( - icon: Icons.description_outlined, - title: "Description", - requiredField: true, - ), - const SizedBox(height: 6), - _CustomTextField( - controller: controller.descriptionController, - hint: "Enter Description", - maxLines: 3, - ), - const SizedBox(height: 24), - - // Action Buttons - Row( + child: Obx(() { + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _DragHandle(), + Center( + child: MyText.titleLarge("Add Expense", fontWeight: 700), + ), + const SizedBox(height: 20), + _buildSectionWithDropdown( + icon: Icons.work_outline, + title: "Project", + requiredField: true, + currentValue: controller.selectedProject.value.isEmpty + ? "Select Project" + : controller.selectedProject.value, + onTap: () => _showOptionList( + controller.globalProjects.toList(), + (p) => p, + (val) => controller.selectedProject.value = val), + ), + const SizedBox(height: 16), + _buildSectionWithDropdown( + icon: Icons.category_outlined, + title: "Expense Type", + requiredField: true, + currentValue: controller.selectedExpenseType.value?.name ?? + "Select Expense Type", + onTap: () => _showOptionList( + controller.expenseTypes.toList(), + (e) => e.name, + (val) => controller.selectedExpenseType.value = val, + ), + ), + const SizedBox(height: 16), + _SectionTitle( + icon: Icons.confirmation_number_outlined, + title: "GST No.", + ), + const SizedBox(height: 6), + _CustomTextField( + controller: controller.gstController, + hint: "Enter GST No.", + ), + const SizedBox(height: 16), + _buildSectionWithDropdown( + icon: Icons.payment, + title: "Payment Mode", + requiredField: true, + currentValue: controller.selectedPaymentMode.value?.name ?? + "Select Payment Mode", + onTap: () => _showOptionList( + controller.paymentModes.toList(), + (m) => m.name, + (val) => controller.selectedPaymentMode.value = val, + ), + ), + const SizedBox(height: 16), + _SectionTitle( + icon: Icons.person_outline, + title: "Paid By", + requiredField: true), + const SizedBox(height: 6), + GestureDetector( + onTap: _showEmployeeList, + child: _TileContainer( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: () => Get.back(), - icon: const Icon(Icons.close, size: 18), - label: - MyText.bodyMedium("Cancel", fontWeight: 600), - style: OutlinedButton.styleFrom( + Text( + controller.selectedPaidBy.value == null + ? "Select Paid By" + : '${controller.selectedPaidBy.value?.firstName ?? ''} ${controller.selectedPaidBy.value?.lastName ?? ''}', + style: const TextStyle(fontSize: 14), + ), + const Icon(Icons.arrow_drop_down, size: 22), + ], + ), + ), + ), + const SizedBox(height: 16), + _SectionTitle( + icon: Icons.currency_rupee, + title: "Amount", + requiredField: true), + const SizedBox(height: 6), + _CustomTextField( + controller: controller.amountController, + hint: "Enter Amount", + keyboardType: TextInputType.number, + ), + const SizedBox(height: 16), + _SectionTitle( + icon: Icons.store_mall_directory_outlined, + title: "Supplier Name", + requiredField: true), + const SizedBox(height: 6), + _CustomTextField( + controller: controller.supplierController, + hint: "Enter Supplier Name", + ), + const SizedBox(height: 16), + _SectionTitle( + icon: Icons.confirmation_number_outlined, + title: "Transaction ID"), + const SizedBox(height: 6), + _CustomTextField( + controller: controller.transactionIdController, + hint: "Enter Transaction ID", + ), + const SizedBox(height: 16), + _SectionTitle( + icon: Icons.calendar_today, + title: "Transaction Date", + requiredField: true), + const SizedBox(height: 6), + GestureDetector( + onTap: () => controller.pickTransactionDate(context), + child: AbsorbPointer( + child: _CustomTextField( + controller: controller.transactionDateController, + hint: "Select Transaction Date", + ), + ), + ), + const SizedBox(height: 16), + _SectionTitle( + icon: Icons.location_on_outlined, title: "Location"), + const SizedBox(height: 6), + TextField( + controller: controller.locationController, + decoration: InputDecoration( + hintText: "Enter Location", + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8)), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + suffixIcon: controller.isFetchingLocation.value + ? const Padding( + padding: EdgeInsets.all(12), + child: SizedBox( + width: 18, + height: 18, + child: + CircularProgressIndicator(strokeWidth: 2), + ), + ) + : IconButton( + icon: const Icon(Icons.my_location), + tooltip: "Use Current Location", + onPressed: controller.fetchCurrentLocation, + ), + ), + ), + const SizedBox(height: 16), + _SectionTitle( + icon: Icons.attach_file, + title: "Attachments", + requiredField: true), + const SizedBox(height: 6), + _AttachmentsSection( + attachments: controller.attachments, + onRemove: controller.removeAttachment, + onAdd: controller.pickAttachments, + ), + const SizedBox(height: 16), + _SectionTitle( + icon: Icons.description_outlined, + title: "Description", + requiredField: true), + const SizedBox(height: 6), + _CustomTextField( + controller: controller.descriptionController, + hint: "Enter Description", + maxLines: 3, + ), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: Get.back, + icon: const Icon(Icons.close, size: 18), + label: MyText.bodyMedium("Cancel", fontWeight: 600), + style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(48)), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Obx( + () { + final isLoading = controller.isSubmitting.value; + return ElevatedButton.icon( + onPressed: + isLoading ? null : controller.submitExpense, + icon: isLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white), + ) + : const Icon(Icons.check, size: 18), + label: MyText.bodyMedium( + isLoading ? "Submitting..." : "Submit", + color: Colors.white, + fontWeight: 600, + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.indigo, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8)), + padding: + const EdgeInsets.symmetric(vertical: 14), minimumSize: const Size.fromHeight(48), ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Obx(() { - final isLoading = controller.isSubmitting.value; - return ElevatedButton.icon( - onPressed: - isLoading ? null : controller.submitExpense, - icon: isLoading - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : const Icon(Icons.check, size: 18), - label: MyText.bodyMedium( - isLoading ? "Submitting..." : "Submit", - color: Colors.white, - fontWeight: 600, - ), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.indigo, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - padding: - const EdgeInsets.symmetric(vertical: 14), - minimumSize: const Size.fromHeight(48), - ), - ); - }), - ), - ], - ) + ); + }, + ), + ), ], ), - ); - }), - ], - ), + ], + ), + ); + }), ), ), ); } - Future _showOptionList( - BuildContext context, - List options, - String Function(T) getLabel, - ValueChanged onSelected, - ) async { - final RenderBox button = context.findRenderObject() as RenderBox; - final RenderBox overlay = - Overlay.of(context).context.findRenderObject() as RenderBox; - - final Offset position = - button.localToGlobal(Offset.zero, ancestor: overlay); - final selected = await showMenu( - context: context, - position: RelativeRect.fromLTRB( - position.dx, - position.dy + button.size.height, - position.dx + button.size.width, - 0, - ), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - items: options.map((option) { - return PopupMenuItem( - value: option, - child: Text(getLabel(option)), - ); - }).toList(), + Widget _buildSectionWithDropdown({ + required IconData icon, + required String title, + required bool requiredField, + required String currentValue, + required VoidCallback onTap, + Widget? extraWidget, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionTitle(icon: icon, title: title, requiredField: requiredField), + const SizedBox(height: 6), + _DropdownTile(title: currentValue, onTap: onTap), + if (extraWidget != null) extraWidget, + ], ); - - if (selected != null) onSelected(selected); } +} - Widget _buildDragHandle() => Center( - child: Container( - width: 40, - height: 4, - margin: const EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - color: Colors.grey.shade400, - borderRadius: BorderRadius.circular(2), - ), +class _DragHandle extends StatelessWidget { + const _DragHandle(); + + @override + Widget build(BuildContext context) { + return Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.grey.shade400, + borderRadius: BorderRadius.circular(2), ), - ); + ), + ); + } } class _SectionTitle extends StatelessWidget { @@ -604,3 +472,140 @@ class _DropdownTile extends StatelessWidget { ); } } + +class _TileContainer extends StatelessWidget { + final Widget child; + + const _TileContainer({required this.child}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade400), + ), + child: child, + ); + } +} + +class _AttachmentsSection extends StatelessWidget { + final List attachments; + final ValueChanged onRemove; + final VoidCallback onAdd; + + const _AttachmentsSection({ + required this.attachments, + required this.onRemove, + required this.onAdd, + }); + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 8, + runSpacing: 8, + children: [ + ...attachments.map((file) => + _AttachmentTile(file: file, onRemove: () => onRemove(file))), + GestureDetector( + onTap: onAdd, + child: Container( + width: 80, + height: 80, + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade400), + borderRadius: BorderRadius.circular(8), + color: Colors.grey.shade100, + ), + child: const Icon(Icons.add, size: 30, color: Colors.grey), + ), + ), + ], + ); + } +} + +class _AttachmentTile extends StatelessWidget { + final File file; + final VoidCallback onRemove; + + const _AttachmentTile({required this.file, required this.onRemove}); + + @override + Widget build(BuildContext context) { + final fileName = file.path.split('/').last; + final extension = fileName.split('.').last.toLowerCase(); + final isImage = ['jpg', 'jpeg', 'png'].contains(extension); + + IconData fileIcon = Icons.insert_drive_file; + Color iconColor = Colors.blueGrey; + + switch (extension) { + case 'pdf': + fileIcon = Icons.picture_as_pdf; + iconColor = Colors.redAccent; + break; + case 'doc': + case 'docx': + fileIcon = Icons.description; + iconColor = Colors.blueAccent; + break; + case 'xls': + case 'xlsx': + fileIcon = Icons.table_chart; + iconColor = Colors.green; + break; + case 'txt': + fileIcon = Icons.article; + iconColor = Colors.grey; + break; + } + + return Stack( + clipBehavior: Clip.none, + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300), + color: Colors.grey.shade100, + ), + child: isImage + ? ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.file(file, fit: BoxFit.cover), + ) + : Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(fileIcon, color: iconColor, size: 30), + const SizedBox(height: 4), + Text( + extension.toUpperCase(), + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: iconColor), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + Positioned( + top: -6, + right: -6, + child: IconButton( + icon: const Icon(Icons.close, color: Colors.red, size: 18), + onPressed: onRemove, + ), + ), + ], + ); + } +} From cef3bd8a1ec9505ca4eaea1e763c2fe1362f5031 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Wed, 30 Jul 2025 17:37:47 +0530 Subject: [PATCH 28/65] feat: add noOfPersons input field in AddExpenseBottomSheet for enhanced expense entry --- .../expense/add_expense_controller.dart | 6 ++++- .../expense/add_expense_bottom_sheet.dart | 22 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/lib/controller/expense/add_expense_controller.dart b/lib/controller/expense/add_expense_controller.dart index 7fdd445..abf0389 100644 --- a/lib/controller/expense/add_expense_controller.dart +++ b/lib/controller/expense/add_expense_controller.dart @@ -26,6 +26,7 @@ class AddExpenseController extends GetxController { final gstController = TextEditingController(); final locationController = TextEditingController(); final transactionDateController = TextEditingController(); + final TextEditingController noOfPersonsController = TextEditingController(); // === State Controllers === final RxBool isLoading = false.obs; @@ -72,6 +73,7 @@ class AddExpenseController extends GetxController { gstController.dispose(); locationController.dispose(); transactionDateController.dispose(); + noOfPersonsController.dispose(); super.onClose(); } @@ -244,7 +246,9 @@ class AddExpenseController extends GetxController { location: locationController.text, supplerName: supplierController.text, amount: amount, - noOfPersons: 0, + noOfPersons: selectedExpenseType.value?.noOfPersonsRequired == true + ? int.tryParse(noOfPersonsController.text.trim()) ?? 0 + : 0, billAttachments: billAttachments, ); diff --git a/lib/model/expense/add_expense_bottom_sheet.dart b/lib/model/expense/add_expense_bottom_sheet.dart index 07f0e23..6354ee3 100644 --- a/lib/model/expense/add_expense_bottom_sheet.dart +++ b/lib/model/expense/add_expense_bottom_sheet.dart @@ -125,6 +125,28 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { (val) => controller.selectedExpenseType.value = val, ), ), + if (controller + .selectedExpenseType.value?.noOfPersonsRequired == + true) + Padding( + padding: const EdgeInsets.only(top: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionTitle( + icon: Icons.people_outline, + title: "No. of Persons", + requiredField: true, + ), + const SizedBox(height: 6), + _CustomTextField( + controller: controller.noOfPersonsController, + hint: "Enter No. of Persons", + keyboardType: TextInputType.number, + ), + ], + ), + ), const SizedBox(height: 16), _SectionTitle( icon: Icons.confirmation_number_outlined, From 9d49f2a92d4434726fd85ed849a8004d94f67368 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Wed, 30 Jul 2025 19:19:18 +0530 Subject: [PATCH 29/65] feat: implement reimbursement functionality in ExpenseDetailController and add ReimbursementBottomSheet for expense reimbursement entry --- .../expense/expense_detail_controller.dart | 79 ++++- lib/helpers/services/api_service.dart | 31 +- .../expense/reimbursement_bottom_sheet.dart | 281 ++++++++++++++++++ lib/view/expense/expense_detail_screen.dart | 83 +++++- 4 files changed, 452 insertions(+), 22 deletions(-) create mode 100644 lib/model/expense/reimbursement_bottom_sheet.dart diff --git a/lib/controller/expense/expense_detail_controller.dart b/lib/controller/expense/expense_detail_controller.dart index 116cc84..6d246a6 100644 --- a/lib/controller/expense/expense_detail_controller.dart +++ b/lib/controller/expense/expense_detail_controller.dart @@ -2,12 +2,20 @@ import 'package:get/get.dart'; import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/model/expense/expense_detail_model.dart'; +import 'package:marco/model/employee_model.dart'; class ExpenseDetailController extends GetxController { final Rx expense = Rx(null); - final RxBool isLoading = false.obs; final RxString errorMessage = ''.obs; + final Rx selectedReimbursedBy = Rx(null); + final RxList allEmployees = [].obs; + + @override + void onInit() { + super.onInit(); + fetchAllEmployees(); + } /// Fetch expense details by ID Future fetchExpenseDetails(String expenseId) async { @@ -42,16 +50,85 @@ class ExpenseDetailController extends GetxController { } } + /// Fetch all employees + Future fetchAllEmployees() async { + isLoading.value = true; + errorMessage.value = ''; + + try { + final response = await ApiService.getAllEmployees(); + if (response != null && response.isNotEmpty) { + allEmployees.assignAll(response.map((e) => EmployeeModel.fromJson(e))); + logSafe("All Employees fetched: ${allEmployees.length}", + level: LogLevel.info); + } else { + allEmployees.clear(); + logSafe("No employees found.", level: LogLevel.warning); + } + } catch (e) { + allEmployees.clear(); + logSafe("Error fetching employees", level: LogLevel.error, error: e); + } finally { + isLoading.value = false; + update(); + } + } + + /// Update expense with reimbursement info and status + Future updateExpenseStatusWithReimbursement({ + required String expenseId, + required String comment, + required String reimburseTransactionId, + required String reimburseDate, + required String reimburseById, + }) async { + isLoading.value = true; + errorMessage.value = ''; + + try { + logSafe("Submitting reimbursement for expense: $expenseId"); + + final success = await ApiService.updateExpenseStatusApi( + expenseId: expenseId, + statusId: 'reimbursed', + comment: comment, + reimburseTransactionId: reimburseTransactionId, + reimburseDate: reimburseDate, + reimbursedById: reimburseById, + ); + + if (success) { + logSafe("Reimbursement submitted successfully."); + await fetchExpenseDetails(expenseId); + return true; + } else { + errorMessage.value = "Failed to submit reimbursement."; + return false; + } + } catch (e, stack) { + errorMessage.value = 'An unexpected error occurred.'; + logSafe("Exception in updateExpenseStatusWithReimbursement: $e", + level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + return false; + } finally { + isLoading.value = false; + } + } + /// Update status for this specific expense Future updateExpenseStatus(String expenseId, String statusId) async { isLoading.value = true; errorMessage.value = ''; + try { logSafe("Updating status for expense: $expenseId -> $statusId"); + final success = await ApiService.updateExpenseStatusApi( expenseId: expenseId, statusId: statusId, ); + if (success) { logSafe("Expense status updated successfully."); await fetchExpenseDetails(expenseId); diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 01476e7..90c2888 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -288,21 +288,42 @@ class ApiService { } /// Update Expense Status API + /// Update Expense Status API (supports optional reimbursement fields) static Future updateExpenseStatusApi({ required String expenseId, required String statusId, + String? comment, + String? reimburseTransactionId, + String? reimburseDate, + String? reimbursedById, }) async { - final payload = { + final Map payload = { "expenseId": expenseId, "statusId": statusId, }; + if (comment != null) { + payload["comment"] = comment; + } + if (reimburseTransactionId != null) { + payload["reimburseTransactionId"] = reimburseTransactionId; + } + if (reimburseDate != null) { + payload["reimburseDate"] = reimburseDate; + } + if (reimbursedById != null) { + payload["reimburseById"] = reimbursedById; + } + const endpoint = ApiEndpoints.updateExpenseStatus; logSafe("Updating expense status with payload: $payload"); try { - final response = - await _postRequest(endpoint, payload, customTimeout: extendedTimeout); + final response = await _postRequest( + endpoint, + payload, + customTimeout: extendedTimeout, + ); if (response == null) { logSafe("Update expense status failed: null response", @@ -331,8 +352,7 @@ class ApiService { return false; } - - static Future?> getExpenseListApi({ +static Future?> getExpenseListApi({ String? filter, int pageSize = 20, int pageNumber = 1, @@ -382,7 +402,6 @@ class ApiService { return null; } } - /// Fetch Master Payment Modes static Future?> getMasterPaymentModes() async { const endpoint = ApiEndpoints.getMasterPaymentModes; diff --git a/lib/model/expense/reimbursement_bottom_sheet.dart b/lib/model/expense/reimbursement_bottom_sheet.dart new file mode 100644 index 0000000..3c15655 --- /dev/null +++ b/lib/model/expense/reimbursement_bottom_sheet.dart @@ -0,0 +1,281 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:intl/intl.dart'; +import 'package:marco/controller/expense/expense_detail_controller.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; + +class ReimbursementBottomSheet extends StatefulWidget { + final String expenseId; + final String statusId; + final void Function() onClose; + final Future Function({ + required String comment, + required String reimburseTransactionId, + required String reimburseDate, + required String reimburseById, + + }) onSubmit; + + const ReimbursementBottomSheet({ + super.key, + required this.expenseId, + required this.onClose, + required this.onSubmit, + required this.statusId, + + }); + + @override + State createState() => _ReimbursementBottomSheetState(); +} + +class _ReimbursementBottomSheetState extends State { + final ExpenseDetailController controller = Get.find(); + + final TextEditingController commentCtrl = TextEditingController(); + final TextEditingController txnCtrl = TextEditingController(); + final RxString dateStr = ''.obs; + + @override + void dispose() { + commentCtrl.dispose(); + txnCtrl.dispose(); + super.dispose(); + } + + void _showEmployeeList() { + showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (_) { + return SizedBox( + height: 300, + child: Obx(() { + final employees = controller.allEmployees; + if (employees.isEmpty) return const Center(child: Text("No employees found")); + return ListView.builder( + itemCount: employees.length, + itemBuilder: (_, index) { + final emp = employees[index]; + final fullName = '${emp.firstName} ${emp.lastName}'.trim(); + return ListTile( + title: Text(fullName.isNotEmpty ? fullName : "Unnamed"), + onTap: () { + controller.selectedReimbursedBy.value = emp; + Navigator.pop(context); + }, + ); + }, + ); + }), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Drag handle + Container( + width: 50, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(20), + ), + ), + + // Title + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + MyText.titleLarge('Reimbursement Info', fontWeight: 700), + const SizedBox(), + ], + ), + const SizedBox(height: 20), + + Flexible( + child: SingleChildScrollView( + child: Column( + children: [ + _buildInputField(label: 'Comment', controller: commentCtrl), + const SizedBox(height: 16), + _buildInputField(label: 'Transaction ID', controller: txnCtrl), + const SizedBox(height: 16), + _buildDatePickerField(), + const SizedBox(height: 16), + _buildEmployeePickerField(), + const SizedBox(height: 24), + _buildActionButtons(), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildInputField({required String label, required TextEditingController controller}) { + return TextField( + controller: controller, + decoration: InputDecoration( + labelText: label, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + ), + ); + } + + Widget _buildDatePickerField() { + return Obx(() { + return InkWell( + onTap: () async { + final picked = await showDatePicker( + context: context, + initialDate: dateStr.value.isEmpty + ? DateTime.now() + : DateFormat('yyyy-MM-dd').parse(dateStr.value), + firstDate: DateTime(2020), + lastDate: DateTime(2100), + ); + if (picked != null) { + dateStr.value = DateFormat('yyyy-MM-dd').format(picked); + } + }, + child: InputDecorator( + decoration: InputDecoration( + labelText: 'Reimbursement Date', + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + ), + child: Row( + children: [ + Icon(Icons.date_range, color: Colors.grey.shade600), + const SizedBox(width: 10), + Text( + dateStr.value.isEmpty ? "Select Date" : dateStr.value, + style: TextStyle( + fontSize: 14, + color: dateStr.value.isEmpty ? Colors.grey : Colors.black, + ), + ), + ], + ), + ), + ); + }); + } + + Widget _buildEmployeePickerField() { + return Obx(() { + return GestureDetector( + onTap: _showEmployeeList, + child: InputDecorator( + decoration: InputDecoration( + labelText: 'Reimbursed By', + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + ), + child: Text( + controller.selectedReimbursedBy.value == null + ? "Select Reimbursed By" + : '${controller.selectedReimbursedBy.value?.firstName ?? ''} ${controller.selectedReimbursedBy.value?.lastName ?? ''}', + style: TextStyle( + fontSize: 14, + color: controller.selectedReimbursedBy.value == null ? Colors.grey : Colors.black, + ), + ), + ), + ); + }); + } + + Widget _buildActionButtons() { + return Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: () { + widget.onClose(); + Get.back(); + }, + icon: const Icon(Icons.close, color: Colors.white), + label: MyText.bodyMedium("Cancel", color: Colors.white, fontWeight: 600), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 14), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Obx(() { + return ElevatedButton.icon( + onPressed: controller.isLoading.value + ? null + : () async { + if (commentCtrl.text.trim().isEmpty || + txnCtrl.text.trim().isEmpty || + dateStr.value.isEmpty || + controller.selectedReimbursedBy.value == null) { + Get.snackbar("Incomplete", "Please fill all fields"); + return; + } + + final success = await widget.onSubmit( + comment: commentCtrl.text.trim(), + reimburseTransactionId: txnCtrl.text.trim(), + reimburseDate: dateStr.value, + reimburseById: controller.selectedReimbursedBy.value!.id, + ); + + if (success) { + Get.back(); + Get.snackbar('Success', 'Reimbursement submitted'); + } else { + Get.snackbar('Error', controller.errorMessage.value); + } + }, + icon: const Icon(Icons.check_circle_outline, color: Colors.white), + label: MyText.bodyMedium( + controller.isLoading.value ? "Submitting..." : "Submit", + color: Colors.white, + fontWeight: 600, + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.indigo, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 14), + ), + ); + }), + ), + ], + ); + } +} diff --git a/lib/view/expense/expense_detail_screen.dart b/lib/view/expense/expense_detail_screen.dart index f7204a9..698f965 100644 --- a/lib/view/expense/expense_detail_screen.dart +++ b/lib/view/expense/expense_detail_screen.dart @@ -9,6 +9,7 @@ import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/model/expense/expense_detail_model.dart'; import 'package:marco/helpers/widgets/image_viewer_dialog.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'package:marco/model/expense/reimbursement_bottom_sheet.dart'; class ExpenseDetailScreen extends StatelessWidget { final String expenseId; @@ -186,23 +187,75 @@ class ExpenseDetailScreen extends StatelessWidget { borderRadius: BorderRadius.circular(6)), ), onPressed: () async { - final success = await controller.updateExpenseStatus( - expense.id, next.id); - if (success) { - Get.snackbar( - 'Success', - 'Expense moved to ${next.displayName.isNotEmpty ? next.displayName : next.name}', - backgroundColor: Colors.green.withOpacity(0.8), - colorText: Colors.white, + if (expense.status.id == + 'f18c5cfd-7815-4341-8da2-2c2d65778e27') { + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: + BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => ReimbursementBottomSheet( + expenseId: expense.id, + statusId: next.id, + onClose: + () {}, // <-- This is the missing required parameter + onSubmit: ({ + required String comment, + required String reimburseTransactionId, + required String reimburseDate, + required String reimburseById, + }) async { + final success = await controller + .updateExpenseStatusWithReimbursement( + expenseId: expense.id, + comment: comment, + reimburseTransactionId: reimburseTransactionId, + reimburseDate: reimburseDate, + reimburseById: reimburseById, + ); + + if (success) { + Get.snackbar( + 'Success', + 'Expense reimbursed successfully.', + backgroundColor: Colors.green.withOpacity(0.8), + colorText: Colors.white, + ); + await controller.fetchExpenseDetails(expenseId); + return true; + } else { + Get.snackbar( + 'Error', + 'Failed to reimburse expense.', + backgroundColor: Colors.red.withOpacity(0.8), + colorText: Colors.white, + ); + return false; + } + }, + ), ); - await controller.fetchExpenseDetails(expenseId); } else { - Get.snackbar( - 'Error', - 'Failed to update status.', - backgroundColor: Colors.red.withOpacity(0.8), - colorText: Colors.white, - ); + final success = await controller.updateExpenseStatus( + expense.id, next.id); + if (success) { + Get.snackbar( + 'Success', + 'Expense moved to ${next.displayName.isNotEmpty ? next.displayName : next.name}', + backgroundColor: Colors.green.withOpacity(0.8), + colorText: Colors.white, + ); + await controller.fetchExpenseDetails(expenseId); + } else { + Get.snackbar( + 'Error', + 'Failed to update status.', + backgroundColor: Colors.red.withOpacity(0.8), + colorText: Colors.white, + ); + } } }, child: MyText.labelMedium( From adf5e1437ec699dcaa3b33dee1846817f2ce5f90 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Thu, 31 Jul 2025 11:09:25 +0530 Subject: [PATCH 30/65] feat: make statusId dynamic in reimbursement handling and update related components --- .../expense/expense_detail_controller.dart | 3 +- .../expense/reimbursement_bottom_sheet.dart | 56 ++++++++++++------- lib/view/expense/expense_detail_screen.dart | 37 ++++++++---- 3 files changed, 63 insertions(+), 33 deletions(-) diff --git a/lib/controller/expense/expense_detail_controller.dart b/lib/controller/expense/expense_detail_controller.dart index 6d246a6..ab47eaf 100644 --- a/lib/controller/expense/expense_detail_controller.dart +++ b/lib/controller/expense/expense_detail_controller.dart @@ -81,6 +81,7 @@ class ExpenseDetailController extends GetxController { required String reimburseTransactionId, required String reimburseDate, required String reimburseById, + required String statusId, // ✅ dynamic }) async { isLoading.value = true; errorMessage.value = ''; @@ -90,7 +91,7 @@ class ExpenseDetailController extends GetxController { final success = await ApiService.updateExpenseStatusApi( expenseId: expenseId, - statusId: 'reimbursed', + statusId: statusId, // ✅ now dynamic comment: comment, reimburseTransactionId: reimburseTransactionId, reimburseDate: reimburseDate, diff --git a/lib/model/expense/reimbursement_bottom_sheet.dart b/lib/model/expense/reimbursement_bottom_sheet.dart index 3c15655..016da67 100644 --- a/lib/model/expense/reimbursement_bottom_sheet.dart +++ b/lib/model/expense/reimbursement_bottom_sheet.dart @@ -6,14 +6,14 @@ import 'package:marco/helpers/widgets/my_text.dart'; class ReimbursementBottomSheet extends StatefulWidget { final String expenseId; - final String statusId; + final String statusId; final void Function() onClose; final Future Function({ required String comment, required String reimburseTransactionId, required String reimburseDate, required String reimburseById, - + required String statusId, }) onSubmit; const ReimbursementBottomSheet({ @@ -21,16 +21,17 @@ class ReimbursementBottomSheet extends StatefulWidget { required this.expenseId, required this.onClose, required this.onSubmit, - required this.statusId, - + required this.statusId, }); @override - State createState() => _ReimbursementBottomSheetState(); + State createState() => + _ReimbursementBottomSheetState(); } class _ReimbursementBottomSheetState extends State { - final ExpenseDetailController controller = Get.find(); + final ExpenseDetailController controller = + Get.find(); final TextEditingController commentCtrl = TextEditingController(); final TextEditingController txnCtrl = TextEditingController(); @@ -47,13 +48,15 @@ class _ReimbursementBottomSheetState extends State { showModalBottomSheet( context: context, backgroundColor: Colors.white, - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16))), builder: (_) { return SizedBox( height: 300, child: Obx(() { final employees = controller.allEmployees; - if (employees.isEmpty) return const Center(child: Text("No employees found")); + if (employees.isEmpty) + return const Center(child: Text("No employees found")); return ListView.builder( itemCount: employees.length, itemBuilder: (_, index) { @@ -113,7 +116,8 @@ class _ReimbursementBottomSheetState extends State { children: [ _buildInputField(label: 'Comment', controller: commentCtrl), const SizedBox(height: 16), - _buildInputField(label: 'Transaction ID', controller: txnCtrl), + _buildInputField( + label: 'Transaction ID', controller: txnCtrl), const SizedBox(height: 16), _buildDatePickerField(), const SizedBox(height: 16), @@ -130,12 +134,14 @@ class _ReimbursementBottomSheetState extends State { ); } - Widget _buildInputField({required String label, required TextEditingController controller}) { + Widget _buildInputField( + {required String label, required TextEditingController controller}) { return TextField( controller: controller, decoration: InputDecoration( labelText: label, - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 14), border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: Colors.grey.shade300), @@ -163,7 +169,8 @@ class _ReimbursementBottomSheetState extends State { child: InputDecorator( decoration: InputDecoration( labelText: 'Reimbursement Date', - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 14), border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: Colors.grey.shade300), @@ -194,7 +201,8 @@ class _ReimbursementBottomSheetState extends State { child: InputDecorator( decoration: InputDecoration( labelText: 'Reimbursed By', - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 14), border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: Colors.grey.shade300), @@ -206,7 +214,9 @@ class _ReimbursementBottomSheetState extends State { : '${controller.selectedReimbursedBy.value?.firstName ?? ''} ${controller.selectedReimbursedBy.value?.lastName ?? ''}', style: TextStyle( fontSize: 14, - color: controller.selectedReimbursedBy.value == null ? Colors.grey : Colors.black, + color: controller.selectedReimbursedBy.value == null + ? Colors.grey + : Colors.black, ), ), ), @@ -224,11 +234,13 @@ class _ReimbursementBottomSheetState extends State { Get.back(); }, icon: const Icon(Icons.close, color: Colors.white), - label: MyText.bodyMedium("Cancel", color: Colors.white, fontWeight: 600), + label: MyText.bodyMedium("Cancel", + color: Colors.white, fontWeight: 600), style: ElevatedButton.styleFrom( backgroundColor: Colors.red, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 7), ), ), ), @@ -251,7 +263,9 @@ class _ReimbursementBottomSheetState extends State { comment: commentCtrl.text.trim(), reimburseTransactionId: txnCtrl.text.trim(), reimburseDate: dateStr.value, - reimburseById: controller.selectedReimbursedBy.value!.id, + reimburseById: + controller.selectedReimbursedBy.value!.id, + statusId: widget.statusId, ); if (success) { @@ -269,8 +283,10 @@ class _ReimbursementBottomSheetState extends State { ), style: ElevatedButton.styleFrom( backgroundColor: Colors.indigo, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + padding: + const EdgeInsets.symmetric(horizontal: 5, vertical: 7), ), ); }), diff --git a/lib/view/expense/expense_detail_screen.dart b/lib/view/expense/expense_detail_screen.dart index 698f965..2877090 100644 --- a/lib/view/expense/expense_detail_screen.dart +++ b/lib/view/expense/expense_detail_screen.dart @@ -90,22 +90,28 @@ class ExpenseDetailScreen extends StatelessWidget { ), body: SafeArea( child: Obx(() { + // Show error snackbar only once after frame render + if (controller.errorMessage.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + Get.snackbar( + "Error", + controller.errorMessage.value, + backgroundColor: Colors.red.withOpacity(0.9), + colorText: Colors.white, + ); + controller.errorMessage.value = ''; + }); + } + if (controller.isLoading.value) { return _buildLoadingSkeleton(); } - if (controller.errorMessage.isNotEmpty) { - return Center( - child: MyText.bodyMedium( - controller.errorMessage.value, - color: Colors.red, - ), - ); - } final expense = controller.expense.value; if (expense == null) { return Center( - child: MyText.bodyMedium("No expense details found.")); + child: MyText.bodyMedium("No expense details found."), + ); } final statusColor = getStatusColor(expense.status.name, @@ -116,8 +122,14 @@ class ExpenseDetailScreen extends StatelessWidget { decimalDigits: 2, ).format(expense.amount); + // === CHANGE: Add proper bottom padding to always keep content away from device nav bar === return SingleChildScrollView( - padding: const EdgeInsets.all(8), + padding: EdgeInsets.fromLTRB( + 8, + 8, + 8, + 16 + MediaQuery.of(context).padding.bottom, // KEY LINE + ), child: Center( child: Container( constraints: const BoxConstraints(maxWidth: 520), @@ -199,13 +211,13 @@ class ExpenseDetailScreen extends StatelessWidget { builder: (context) => ReimbursementBottomSheet( expenseId: expense.id, statusId: next.id, - onClose: - () {}, // <-- This is the missing required parameter + onClose: () {}, onSubmit: ({ required String comment, required String reimburseTransactionId, required String reimburseDate, required String reimburseById, + required String statusId, }) async { final success = await controller .updateExpenseStatusWithReimbursement( @@ -214,6 +226,7 @@ class ExpenseDetailScreen extends StatelessWidget { reimburseTransactionId: reimburseTransactionId, reimburseDate: reimburseDate, reimburseById: reimburseById, + statusId: statusId, ); if (success) { From 29f759ca9d554332e9ca1706b1f549e6672b239c Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Thu, 31 Jul 2025 13:13:00 +0530 Subject: [PATCH 31/65] Refactor expense reimbursement and filter UI components - Updated ReimbursementBottomSheet to use BaseBottomSheet for consistent styling and functionality. - Improved input field decorations and added spacing helpers for better layout. - Simplified the employee selection process and integrated it into the new design. - Refactored ExpenseDetailScreen to utilize controller initialization method. - Enhanced ExpenseFilterBottomSheet with a cleaner structure and improved field handling. - Removed unnecessary wrapper for ExpenseFilterBottomSheet and integrated it directly into the expense screen. --- .../expense/expense_detail_controller.dart | 54 +- lib/helpers/utils/base_bottom_sheet.dart | 118 ++++ lib/helpers/utils/permission_constants.dart | 13 +- .../expense/add_expense_bottom_sheet.dart | 511 ++++++++---------- .../expense/reimbursement_bottom_sheet.dart | 324 ++++------- lib/view/expense/expense_detail_screen.dart | 31 +- .../expense/expense_filter_bottom_sheet.dart | 471 ++++++---------- lib/view/expense/expense_screen.dart | 3 +- 8 files changed, 683 insertions(+), 842 deletions(-) create mode 100644 lib/helpers/utils/base_bottom_sheet.dart diff --git a/lib/controller/expense/expense_detail_controller.dart b/lib/controller/expense/expense_detail_controller.dart index ab47eaf..8023c0a 100644 --- a/lib/controller/expense/expense_detail_controller.dart +++ b/lib/controller/expense/expense_detail_controller.dart @@ -11,35 +11,40 @@ class ExpenseDetailController extends GetxController { final Rx selectedReimbursedBy = Rx(null); final RxList allEmployees = [].obs; - @override - void onInit() { - super.onInit(); + bool _isInitialized = false; + late String _expenseId; + + /// Call this once from the screen (NOT inside build) to initialize + void init(String expenseId) { + if (_isInitialized) return; + + _isInitialized = true; + _expenseId = expenseId; + + fetchExpenseDetails(); fetchAllEmployees(); } - /// Fetch expense details by ID - Future fetchExpenseDetails(String expenseId) async { + /// Fetch expense details by stored ID + Future fetchExpenseDetails() async { isLoading.value = true; errorMessage.value = ''; try { - logSafe("Fetching expense details for ID: $expenseId"); + logSafe("Fetching expense details for ID: $_expenseId"); - final result = - await ApiService.getExpenseDetailsApi(expenseId: expenseId); + final result = await ApiService.getExpenseDetailsApi(expenseId: _expenseId); if (result != null) { try { expense.value = ExpenseDetailModel.fromJson(result); logSafe("Expense details loaded successfully: ${expense.value?.id}"); } catch (e) { errorMessage.value = 'Failed to parse expense details: $e'; - logSafe("Parse error in fetchExpenseDetails: $e", - level: LogLevel.error); + logSafe("Parse error in fetchExpenseDetails: $e", level: LogLevel.error); } } else { errorMessage.value = 'Failed to fetch expense details from server.'; - logSafe("fetchExpenseDetails failed: null response", - level: LogLevel.error); + logSafe("fetchExpenseDetails failed: null response", level: LogLevel.error); } } catch (e, stack) { errorMessage.value = 'An unexpected error occurred.'; @@ -59,8 +64,7 @@ class ExpenseDetailController extends GetxController { final response = await ApiService.getAllEmployees(); if (response != null && response.isNotEmpty) { allEmployees.assignAll(response.map((e) => EmployeeModel.fromJson(e))); - logSafe("All Employees fetched: ${allEmployees.length}", - level: LogLevel.info); + logSafe("All Employees fetched: ${allEmployees.length}", level: LogLevel.info); } else { allEmployees.clear(); logSafe("No employees found.", level: LogLevel.warning); @@ -76,22 +80,21 @@ class ExpenseDetailController extends GetxController { /// Update expense with reimbursement info and status Future updateExpenseStatusWithReimbursement({ - required String expenseId, required String comment, required String reimburseTransactionId, required String reimburseDate, required String reimburseById, - required String statusId, // ✅ dynamic + required String statusId, }) async { isLoading.value = true; errorMessage.value = ''; try { - logSafe("Submitting reimbursement for expense: $expenseId"); + logSafe("Submitting reimbursement for expense: $_expenseId"); final success = await ApiService.updateExpenseStatusApi( - expenseId: expenseId, - statusId: statusId, // ✅ now dynamic + expenseId: _expenseId, + statusId: statusId, comment: comment, reimburseTransactionId: reimburseTransactionId, reimburseDate: reimburseDate, @@ -100,7 +103,7 @@ class ExpenseDetailController extends GetxController { if (success) { logSafe("Reimbursement submitted successfully."); - await fetchExpenseDetails(expenseId); + await fetchExpenseDetails(); // refresh latest return true; } else { errorMessage.value = "Failed to submit reimbursement."; @@ -108,8 +111,7 @@ class ExpenseDetailController extends GetxController { } } catch (e, stack) { errorMessage.value = 'An unexpected error occurred.'; - logSafe("Exception in updateExpenseStatusWithReimbursement: $e", - level: LogLevel.error); + logSafe("Exception in updateExpenseStatusWithReimbursement: $e", level: LogLevel.error); logSafe("StackTrace: $stack", level: LogLevel.debug); return false; } finally { @@ -118,21 +120,21 @@ class ExpenseDetailController extends GetxController { } /// Update status for this specific expense - Future updateExpenseStatus(String expenseId, String statusId) async { + Future updateExpenseStatus(String statusId) async { isLoading.value = true; errorMessage.value = ''; try { - logSafe("Updating status for expense: $expenseId -> $statusId"); + logSafe("Updating status for expense: $_expenseId -> $statusId"); final success = await ApiService.updateExpenseStatusApi( - expenseId: expenseId, + expenseId: _expenseId, statusId: statusId, ); if (success) { logSafe("Expense status updated successfully."); - await fetchExpenseDetails(expenseId); + await fetchExpenseDetails(); // refresh return true; } else { errorMessage.value = "Failed to update expense status."; diff --git a/lib/helpers/utils/base_bottom_sheet.dart b/lib/helpers/utils/base_bottom_sheet.dart new file mode 100644 index 0000000..b7ed51c --- /dev/null +++ b/lib/helpers/utils/base_bottom_sheet.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; + +class BaseBottomSheet extends StatelessWidget { + final String title; + final Widget child; + final VoidCallback onCancel; + final VoidCallback onSubmit; + final bool isSubmitting; + final String submitText; + final Color submitColor; + final IconData submitIcon; + + const BaseBottomSheet({ + super.key, + required this.title, + required this.child, + required this.onCancel, + required this.onSubmit, + this.isSubmitting = false, + this.submitText = 'Submit', + this.submitColor = Colors.indigo, + this.submitIcon = Icons.check_circle_outline, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final mediaQuery = MediaQuery.of(context); + + return SingleChildScrollView( + padding: mediaQuery.viewInsets, + child: Padding( + padding: const EdgeInsets.only( + top: 60), + child: Container( + decoration: BoxDecoration( + color: theme.cardColor, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + boxShadow: const [ + BoxShadow( + color: Colors.black12, + blurRadius: 12, + offset: Offset(0, -2), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + MySpacing.height(5), + Container( + width: 40, + height: 5, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(10), + ), + ), + MySpacing.height(12), + MyText.titleLarge(title, fontWeight: 700), + MySpacing.height(12), + child, + MySpacing.height(24), + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: onCancel, + icon: const Icon(Icons.close, color: Colors.white), + label: MyText.bodyMedium( + "Cancel", + color: Colors.white, + fontWeight: 600, + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.grey, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric( + vertical: 8), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + onPressed: isSubmitting ? null : onSubmit, + icon: Icon(submitIcon, color: Colors.white), + label: MyText.bodyMedium( + isSubmitting ? "Submitting..." : submitText, + color: Colors.white, + fontWeight: 600, + ), + style: ElevatedButton.styleFrom( + backgroundColor: submitColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric( + vertical: 8), + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/helpers/utils/permission_constants.dart b/lib/helpers/utils/permission_constants.dart index 8160964..81ee465 100644 --- a/lib/helpers/utils/permission_constants.dart +++ b/lib/helpers/utils/permission_constants.dart @@ -3,9 +3,9 @@ class Permissions { static const String manageProject = "172fc9b6-755b-4f62-ab26-55c34a330614"; static const String viewProjects = "6ea44136-987e-44ba-9e5d-1cf8f5837ebc"; static const String manageEmployees = "a97d366a-c2bb-448d-be93-402bd2324566"; - static const String manageProjectInfra ="f2aee20a-b754-4537-8166-f9507b44585b"; + static const String manageProjectInfra = "f2aee20a-b754-4537-8166-f9507b44585b"; static const String viewProjectInfra = "c7b68e33-72f0-474f-bd96-77636427ecc8"; - static const String regularizeAttendance ="57802c4a-00aa-4a1f-a048-fd2f70dd44b6"; + static const String regularizeAttendance = "57802c4a-00aa-4a1f-a048-fd2f70dd44b6"; static const String assignToProject = "fbd213e0-0250-46f1-9f5f-4b2a1e6e76a3"; static const String infrastructure = "9666de86-d7c7-4d3d-acaa-fcd6d6b81f3c"; static const String manageTask = "08752f33-3b29-4816-b76b-ea8a968ed3c5"; @@ -13,4 +13,13 @@ class Permissions { static const String assignReportTask = "6a32379b-8b3f-49a6-8c48-4b7ac1b55dc2"; static const String directoryAdmin = "4286a13b-bb40-4879-8c6d-18e9e393beda"; static const String directoryManager = "62668630-13ce-4f52-a0f0-db38af2230c5"; + + // Expense Permissions + static const String expenseViewSelf = "385be49f-8fde-440e-bdbc-3dffeb8dd116"; + static const String expenseViewAll = "01e06444-9ca7-4df4-b900-8c3fa051b92f"; + static const String expenseUpload = "0f57885d-bcb2-4711-ac95-d841ace6d5a7"; + static const String expenseReview = "1f4bda08-1873-449a-bb66-3e8222bd871b"; + static const String expenseApprove = "eaafdd76-8aac-45f9-a530-315589c6deca"; + static const String expenseProcess = "ea5a1529-4ee8-4828-80ea-0e23c9d4dd11"; + static const String expenseManage = "bdee29a2-b73b-402d-8dd1-c4b1f81ccbc3"; } diff --git a/lib/model/expense/add_expense_bottom_sheet.dart b/lib/model/expense/add_expense_bottom_sheet.dart index 6354ee3..6723c44 100644 --- a/lib/model/expense/add_expense_bottom_sheet.dart +++ b/lib/model/expense/add_expense_bottom_sheet.dart @@ -1,11 +1,11 @@ import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/controller/expense/add_expense_controller.dart'; -import 'package:marco/helpers/widgets/my_text.dart'; -import 'package:marco/model/expense/payment_types_model.dart'; import 'package:marco/model/expense/expense_type_model.dart'; +import 'package:marco/model/expense/payment_types_model.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; void showAddExpenseBottomSheet() { Get.bottomSheet(const _AddExpenseBottomSheet(), isScrollControlled: true); @@ -83,309 +83,228 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { @override Widget build(BuildContext context) { - return SafeArea( - child: Padding( - padding: const EdgeInsets.only(top: 60), - child: Material( - color: Colors.white, - borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), - child: Obx(() { - return SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const _DragHandle(), - Center( - child: MyText.titleLarge("Add Expense", fontWeight: 700), - ), - const SizedBox(height: 20), - _buildSectionWithDropdown( - icon: Icons.work_outline, - title: "Project", - requiredField: true, - currentValue: controller.selectedProject.value.isEmpty - ? "Select Project" - : controller.selectedProject.value, - onTap: () => _showOptionList( - controller.globalProjects.toList(), - (p) => p, - (val) => controller.selectedProject.value = val), - ), - const SizedBox(height: 16), - _buildSectionWithDropdown( - icon: Icons.category_outlined, - title: "Expense Type", - requiredField: true, - currentValue: controller.selectedExpenseType.value?.name ?? - "Select Expense Type", - onTap: () => _showOptionList( - controller.expenseTypes.toList(), - (e) => e.name, - (val) => controller.selectedExpenseType.value = val, - ), - ), - if (controller - .selectedExpenseType.value?.noOfPersonsRequired == - true) - Padding( - padding: const EdgeInsets.only(top: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _SectionTitle( - icon: Icons.people_outline, - title: "No. of Persons", - requiredField: true, - ), - const SizedBox(height: 6), - _CustomTextField( - controller: controller.noOfPersonsController, - hint: "Enter No. of Persons", - keyboardType: TextInputType.number, - ), - ], - ), - ), - const SizedBox(height: 16), - _SectionTitle( - icon: Icons.confirmation_number_outlined, - title: "GST No.", - ), - const SizedBox(height: 6), - _CustomTextField( - controller: controller.gstController, - hint: "Enter GST No.", - ), - const SizedBox(height: 16), - _buildSectionWithDropdown( - icon: Icons.payment, - title: "Payment Mode", - requiredField: true, - currentValue: controller.selectedPaymentMode.value?.name ?? - "Select Payment Mode", - onTap: () => _showOptionList( - controller.paymentModes.toList(), - (m) => m.name, - (val) => controller.selectedPaymentMode.value = val, - ), - ), - const SizedBox(height: 16), - _SectionTitle( - icon: Icons.person_outline, - title: "Paid By", - requiredField: true), - const SizedBox(height: 6), - GestureDetector( - onTap: _showEmployeeList, - child: _TileContainer( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - controller.selectedPaidBy.value == null - ? "Select Paid By" - : '${controller.selectedPaidBy.value?.firstName ?? ''} ${controller.selectedPaidBy.value?.lastName ?? ''}', - style: const TextStyle(fontSize: 14), - ), - const Icon(Icons.arrow_drop_down, size: 22), - ], - ), - ), - ), - const SizedBox(height: 16), - _SectionTitle( - icon: Icons.currency_rupee, - title: "Amount", - requiredField: true), - const SizedBox(height: 6), - _CustomTextField( - controller: controller.amountController, - hint: "Enter Amount", - keyboardType: TextInputType.number, - ), - const SizedBox(height: 16), - _SectionTitle( - icon: Icons.store_mall_directory_outlined, - title: "Supplier Name", - requiredField: true), - const SizedBox(height: 6), - _CustomTextField( - controller: controller.supplierController, - hint: "Enter Supplier Name", - ), - const SizedBox(height: 16), - _SectionTitle( - icon: Icons.confirmation_number_outlined, - title: "Transaction ID"), - const SizedBox(height: 6), - _CustomTextField( - controller: controller.transactionIdController, - hint: "Enter Transaction ID", - ), - const SizedBox(height: 16), - _SectionTitle( - icon: Icons.calendar_today, - title: "Transaction Date", - requiredField: true), - const SizedBox(height: 6), - GestureDetector( - onTap: () => controller.pickTransactionDate(context), - child: AbsorbPointer( - child: _CustomTextField( - controller: controller.transactionDateController, - hint: "Select Transaction Date", - ), - ), - ), - const SizedBox(height: 16), - _SectionTitle( - icon: Icons.location_on_outlined, title: "Location"), - const SizedBox(height: 6), - TextField( - controller: controller.locationController, - decoration: InputDecoration( - hintText: "Enter Location", - filled: true, - fillColor: Colors.grey.shade100, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8)), - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 10), - suffixIcon: controller.isFetchingLocation.value - ? const Padding( - padding: EdgeInsets.all(12), - child: SizedBox( - width: 18, - height: 18, - child: - CircularProgressIndicator(strokeWidth: 2), - ), - ) - : IconButton( - icon: const Icon(Icons.my_location), - tooltip: "Use Current Location", - onPressed: controller.fetchCurrentLocation, - ), - ), - ), - const SizedBox(height: 16), - _SectionTitle( - icon: Icons.attach_file, - title: "Attachments", - requiredField: true), - const SizedBox(height: 6), - _AttachmentsSection( - attachments: controller.attachments, - onRemove: controller.removeAttachment, - onAdd: controller.pickAttachments, - ), - const SizedBox(height: 16), - _SectionTitle( - icon: Icons.description_outlined, - title: "Description", - requiredField: true), - const SizedBox(height: 6), - _CustomTextField( - controller: controller.descriptionController, - hint: "Enter Description", - maxLines: 3, - ), - const SizedBox(height: 24), - Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: Get.back, - icon: const Icon(Icons.close, size: 18), - label: MyText.bodyMedium("Cancel", fontWeight: 600), - style: OutlinedButton.styleFrom( - minimumSize: const Size.fromHeight(48)), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Obx( - () { - final isLoading = controller.isSubmitting.value; - return ElevatedButton.icon( - onPressed: - isLoading ? null : controller.submitExpense, - icon: isLoading - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, color: Colors.white), - ) - : const Icon(Icons.check, size: 18), - label: MyText.bodyMedium( - isLoading ? "Submitting..." : "Submit", - color: Colors.white, - fontWeight: 600, - ), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.indigo, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8)), - padding: - const EdgeInsets.symmetric(vertical: 14), - minimumSize: const Size.fromHeight(48), - ), - ); - }, - ), - ), - ], - ), - ], + return Obx(() { + return BaseBottomSheet( + title: "Add Expense", + isSubmitting: controller.isSubmitting.value, + onCancel: Get.back, + onSubmit: () { + if (!controller.isSubmitting.value) { + controller.submitExpense(); + } + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildDropdown( + icon: Icons.work_outline, + title: "Project", + requiredField: true, + value: controller.selectedProject.value.isEmpty + ? "Select Project" + : controller.selectedProject.value, + onTap: () => _showOptionList( + controller.globalProjects.toList(), + (p) => p, + (val) => controller.selectedProject.value = val, ), - ); - }), + ), + MySpacing.height(16), + _buildDropdown( + icon: Icons.category_outlined, + title: "Expense Type", + requiredField: true, + value: controller.selectedExpenseType.value?.name ?? + "Select Expense Type", + onTap: () => _showOptionList( + controller.expenseTypes.toList(), + (e) => e.name, + (val) => controller.selectedExpenseType.value = val, + ), + ), + if (controller.selectedExpenseType.value?.noOfPersonsRequired == + true) + Padding( + padding: const EdgeInsets.only(top: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionTitle( + icon: Icons.people_outline, + title: "No. of Persons", + requiredField: true, + ), + MySpacing.height(6), + _CustomTextField( + controller: controller.noOfPersonsController, + hint: "Enter No. of Persons", + keyboardType: TextInputType.number, + ), + ], + ), + ), + MySpacing.height(16), + _SectionTitle( + icon: Icons.confirmation_number_outlined, title: "GST No."), + MySpacing.height(6), + _CustomTextField( + controller: controller.gstController, hint: "Enter GST No."), + MySpacing.height(16), + _buildDropdown( + icon: Icons.payment, + title: "Payment Mode", + requiredField: true, + value: controller.selectedPaymentMode.value?.name ?? + "Select Payment Mode", + onTap: () => _showOptionList( + controller.paymentModes.toList(), + (p) => p.name, + (val) => controller.selectedPaymentMode.value = val, + ), + ), + MySpacing.height(16), + _SectionTitle( + icon: Icons.person_outline, + title: "Paid By", + requiredField: true), + MySpacing.height(6), + GestureDetector( + onTap: _showEmployeeList, + child: _TileContainer( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + controller.selectedPaidBy.value == null + ? "Select Paid By" + : '${controller.selectedPaidBy.value?.firstName ?? ''} ${controller.selectedPaidBy.value?.lastName ?? ''}', + style: const TextStyle(fontSize: 14), + ), + const Icon(Icons.arrow_drop_down, size: 22), + ], + ), + ), + ), + MySpacing.height(16), + _SectionTitle( + icon: Icons.currency_rupee, + title: "Amount", + requiredField: true), + MySpacing.height(6), + _CustomTextField( + controller: controller.amountController, + hint: "Enter Amount", + keyboardType: TextInputType.number, + ), + MySpacing.height(16), + _SectionTitle( + icon: Icons.store_mall_directory_outlined, + title: "Supplier Name", + requiredField: true, + ), + MySpacing.height(6), + _CustomTextField( + controller: controller.supplierController, + hint: "Enter Supplier Name"), + MySpacing.height(16), + _SectionTitle( + icon: Icons.confirmation_number_outlined, + title: "Transaction ID"), + MySpacing.height(6), + _CustomTextField( + controller: controller.transactionIdController, + hint: "Enter Transaction ID"), + MySpacing.height(16), + _SectionTitle( + icon: Icons.calendar_today, + title: "Transaction Date", + requiredField: true, + ), + MySpacing.height(6), + GestureDetector( + onTap: () => controller.pickTransactionDate(context), + child: AbsorbPointer( + child: _CustomTextField( + controller: controller.transactionDateController, + hint: "Select Transaction Date", + ), + ), + ), + MySpacing.height(16), + _SectionTitle(icon: Icons.location_on_outlined, title: "Location"), + MySpacing.height(6), + TextField( + controller: controller.locationController, + decoration: InputDecoration( + hintText: "Enter Location", + filled: true, + fillColor: Colors.grey.shade100, + border: + OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + suffixIcon: controller.isFetchingLocation.value + ? const Padding( + padding: EdgeInsets.all(12), + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : IconButton( + icon: const Icon(Icons.my_location), + tooltip: "Use Current Location", + onPressed: controller.fetchCurrentLocation, + ), + ), + ), + MySpacing.height(16), + _SectionTitle( + icon: Icons.attach_file, + title: "Attachments", + requiredField: true), + MySpacing.height(6), + _AttachmentsSection( + attachments: controller.attachments, + onRemove: controller.removeAttachment, + onAdd: controller.pickAttachments, + ), + MySpacing.height(16), + _SectionTitle( + icon: Icons.description_outlined, + title: "Description", + requiredField: true), + MySpacing.height(6), + _CustomTextField( + controller: controller.descriptionController, + hint: "Enter Description", + maxLines: 3, + ), + ], ), - ), - ); + ); + }); } - Widget _buildSectionWithDropdown({ + Widget _buildDropdown({ required IconData icon, required String title, required bool requiredField, - required String currentValue, + required String value, required VoidCallback onTap, - Widget? extraWidget, }) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _SectionTitle(icon: icon, title: title, requiredField: requiredField), - const SizedBox(height: 6), - _DropdownTile(title: currentValue, onTap: onTap), - if (extraWidget != null) extraWidget, + MySpacing.height(6), + _DropdownTile(title: value, onTap: onTap), ], ); } } -class _DragHandle extends StatelessWidget { - const _DragHandle(); - - @override - Widget build(BuildContext context) { - return Center( - child: Container( - width: 40, - height: 4, - margin: const EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - color: Colors.grey.shade400, - borderRadius: BorderRadius.circular(2), - ), - ), - ); - } -} - class _SectionTitle extends StatelessWidget { final IconData icon; final String title; @@ -447,11 +366,23 @@ class _CustomTextField extends StatelessWidget { keyboardType: keyboardType, decoration: InputDecoration( hintText: hint, + hintStyle: TextStyle(fontSize: 14, color: Colors.grey[600]), filled: true, fillColor: Colors.grey.shade100, - border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), contentPadding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.blueAccent, width: 1.5), + ), ), ); } diff --git a/lib/model/expense/reimbursement_bottom_sheet.dart b/lib/model/expense/reimbursement_bottom_sheet.dart index 016da67..b22b26d 100644 --- a/lib/model/expense/reimbursement_bottom_sheet.dart +++ b/lib/model/expense/reimbursement_bottom_sheet.dart @@ -1,8 +1,12 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:intl/intl.dart'; + import 'package:marco/controller/expense/expense_detail_controller.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/widgets/my_text_style.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; class ReimbursementBottomSheet extends StatefulWidget { final String expenseId; @@ -30,8 +34,7 @@ class ReimbursementBottomSheet extends StatefulWidget { } class _ReimbursementBottomSheetState extends State { - final ExpenseDetailController controller = - Get.find(); + final ExpenseDetailController controller = Get.find(); final TextEditingController commentCtrl = TextEditingController(); final TextEditingController txnCtrl = TextEditingController(); @@ -49,14 +52,16 @@ class _ReimbursementBottomSheetState extends State { context: context, backgroundColor: Colors.white, shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), builder: (_) { return SizedBox( height: 300, child: Obx(() { final employees = controller.allEmployees; - if (employees.isEmpty) + if (employees.isEmpty) { return const Center(child: Text("No employees found")); + } return ListView.builder( itemCount: employees.length, itemBuilder: (_, index) { @@ -77,221 +82,128 @@ class _ReimbursementBottomSheetState extends State { ); } + InputDecoration _inputDecoration(String hint) { + return InputDecoration( + hintText: hint, + hintStyle: MyTextStyle.bodySmall(xMuted: true), + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5), + ), + contentPadding: MySpacing.all(16), + ); + } + @override Widget build(BuildContext context) { - return SafeArea( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), + return Obx(() { + return BaseBottomSheet( + title: "Reimbursement Info", + isSubmitting: controller.isLoading.value, + onCancel: () { + widget.onClose(); + Navigator.pop(context); + }, + onSubmit: () async { + if (commentCtrl.text.trim().isEmpty || + txnCtrl.text.trim().isEmpty || + dateStr.value.isEmpty || + controller.selectedReimbursedBy.value == null) { + Get.snackbar("Incomplete", "Please fill all fields"); + return; + } + + final success = await widget.onSubmit( + comment: commentCtrl.text.trim(), + reimburseTransactionId: txnCtrl.text.trim(), + reimburseDate: dateStr.value, + reimburseById: controller.selectedReimbursedBy.value!.id, + statusId: widget.statusId, + ); + + if (success) { + Get.back(); + Get.snackbar('Success', 'Reimbursement submitted'); + } else { + Get.snackbar('Error', controller.errorMessage.value); + } + }, child: Column( - mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Drag handle - Container( - width: 50, - height: 4, - margin: const EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - color: Colors.grey.shade300, - borderRadius: BorderRadius.circular(20), + MyText.labelMedium("Comment"), + MySpacing.height(8), + TextField( + controller: commentCtrl, + decoration: _inputDecoration("Enter comment"), + ), + MySpacing.height(16), + + MyText.labelMedium("Transaction ID"), + MySpacing.height(8), + TextField( + controller: txnCtrl, + decoration: _inputDecoration("Enter transaction ID"), + ), + MySpacing.height(16), + + MyText.labelMedium("Reimbursement Date"), + MySpacing.height(8), + GestureDetector( + onTap: () async { + final picked = await showDatePicker( + context: context, + initialDate: dateStr.value.isEmpty + ? DateTime.now() + : DateFormat('yyyy-MM-dd').parse(dateStr.value), + firstDate: DateTime(2020), + lastDate: DateTime(2100), + ); + if (picked != null) { + dateStr.value = DateFormat('yyyy-MM-dd').format(picked); + } + }, + child: AbsorbPointer( + child: TextField( + controller: TextEditingController(text: dateStr.value), + decoration: _inputDecoration("Select Date").copyWith( + suffixIcon: const Icon(Icons.calendar_today), + ), + ), ), ), + MySpacing.height(16), - // Title - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - MyText.titleLarge('Reimbursement Info', fontWeight: 700), - const SizedBox(), - ], - ), - const SizedBox(height: 20), - - Flexible( - child: SingleChildScrollView( - child: Column( - children: [ - _buildInputField(label: 'Comment', controller: commentCtrl), - const SizedBox(height: 16), - _buildInputField( - label: 'Transaction ID', controller: txnCtrl), - const SizedBox(height: 16), - _buildDatePickerField(), - const SizedBox(height: 16), - _buildEmployeePickerField(), - const SizedBox(height: 24), - _buildActionButtons(), - ], + MyText.labelMedium("Reimbursed By"), + MySpacing.height(8), + GestureDetector( + onTap: _showEmployeeList, + child: AbsorbPointer( + child: TextField( + controller: TextEditingController( + text: controller.selectedReimbursedBy.value == null + ? "" + : '${controller.selectedReimbursedBy.value?.firstName ?? ''} ${controller.selectedReimbursedBy.value?.lastName ?? ''}', + ), + decoration: _inputDecoration("Select Employee").copyWith( + suffixIcon: const Icon(Icons.expand_more), + ), ), ), ), ], ), - ), - ); - } - - Widget _buildInputField( - {required String label, required TextEditingController controller}) { - return TextField( - controller: controller, - decoration: InputDecoration( - labelText: label, - contentPadding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 14), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: Colors.grey.shade300), - ), - ), - ); - } - - Widget _buildDatePickerField() { - return Obx(() { - return InkWell( - onTap: () async { - final picked = await showDatePicker( - context: context, - initialDate: dateStr.value.isEmpty - ? DateTime.now() - : DateFormat('yyyy-MM-dd').parse(dateStr.value), - firstDate: DateTime(2020), - lastDate: DateTime(2100), - ); - if (picked != null) { - dateStr.value = DateFormat('yyyy-MM-dd').format(picked); - } - }, - child: InputDecorator( - decoration: InputDecoration( - labelText: 'Reimbursement Date', - contentPadding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 14), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: Colors.grey.shade300), - ), - ), - child: Row( - children: [ - Icon(Icons.date_range, color: Colors.grey.shade600), - const SizedBox(width: 10), - Text( - dateStr.value.isEmpty ? "Select Date" : dateStr.value, - style: TextStyle( - fontSize: 14, - color: dateStr.value.isEmpty ? Colors.grey : Colors.black, - ), - ), - ], - ), - ), ); }); } - - Widget _buildEmployeePickerField() { - return Obx(() { - return GestureDetector( - onTap: _showEmployeeList, - child: InputDecorator( - decoration: InputDecoration( - labelText: 'Reimbursed By', - contentPadding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 14), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: Colors.grey.shade300), - ), - ), - child: Text( - controller.selectedReimbursedBy.value == null - ? "Select Reimbursed By" - : '${controller.selectedReimbursedBy.value?.firstName ?? ''} ${controller.selectedReimbursedBy.value?.lastName ?? ''}', - style: TextStyle( - fontSize: 14, - color: controller.selectedReimbursedBy.value == null - ? Colors.grey - : Colors.black, - ), - ), - ), - ); - }); - } - - Widget _buildActionButtons() { - return Row( - children: [ - Expanded( - child: ElevatedButton.icon( - onPressed: () { - widget.onClose(); - Get.back(); - }, - icon: const Icon(Icons.close, color: Colors.white), - label: MyText.bodyMedium("Cancel", - color: Colors.white, fontWeight: 600), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12)), - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 7), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Obx(() { - return ElevatedButton.icon( - onPressed: controller.isLoading.value - ? null - : () async { - if (commentCtrl.text.trim().isEmpty || - txnCtrl.text.trim().isEmpty || - dateStr.value.isEmpty || - controller.selectedReimbursedBy.value == null) { - Get.snackbar("Incomplete", "Please fill all fields"); - return; - } - - final success = await widget.onSubmit( - comment: commentCtrl.text.trim(), - reimburseTransactionId: txnCtrl.text.trim(), - reimburseDate: dateStr.value, - reimburseById: - controller.selectedReimbursedBy.value!.id, - statusId: widget.statusId, - ); - - if (success) { - Get.back(); - Get.snackbar('Success', 'Reimbursement submitted'); - } else { - Get.snackbar('Error', controller.errorMessage.value); - } - }, - icon: const Icon(Icons.check_circle_outline, color: Colors.white), - label: MyText.bodyMedium( - controller.isLoading.value ? "Submitting..." : "Submit", - color: Colors.white, - fontWeight: 600, - ), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.indigo, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12)), - padding: - const EdgeInsets.symmetric(horizontal: 5, vertical: 7), - ), - ); - }), - ), - ], - ); - } } diff --git a/lib/view/expense/expense_detail_screen.dart b/lib/view/expense/expense_detail_screen.dart index 2877090..86fb009 100644 --- a/lib/view/expense/expense_detail_screen.dart +++ b/lib/view/expense/expense_detail_screen.dart @@ -39,7 +39,7 @@ class ExpenseDetailScreen extends StatelessWidget { Widget build(BuildContext context) { final controller = Get.put(ExpenseDetailController()); final projectController = Get.find(); - controller.fetchExpenseDetails(expenseId); + controller.init(expenseId); return Scaffold( backgroundColor: const Color(0xFFF7F7F7), @@ -90,27 +90,15 @@ class ExpenseDetailScreen extends StatelessWidget { ), body: SafeArea( child: Obx(() { - // Show error snackbar only once after frame render - if (controller.errorMessage.isNotEmpty) { - WidgetsBinding.instance.addPostFrameCallback((_) { - Get.snackbar( - "Error", - controller.errorMessage.value, - backgroundColor: Colors.red.withOpacity(0.9), - colorText: Colors.white, - ); - controller.errorMessage.value = ''; - }); - } - if (controller.isLoading.value) { return _buildLoadingSkeleton(); } final expense = controller.expense.value; - if (expense == null) { + + if (controller.errorMessage.isNotEmpty || expense == null) { return Center( - child: MyText.bodyMedium("No expense details found."), + child: MyText.bodyMedium("No data to display."), ); } @@ -221,7 +209,6 @@ class ExpenseDetailScreen extends StatelessWidget { }) async { final success = await controller .updateExpenseStatusWithReimbursement( - expenseId: expense.id, comment: comment, reimburseTransactionId: reimburseTransactionId, reimburseDate: reimburseDate, @@ -236,7 +223,8 @@ class ExpenseDetailScreen extends StatelessWidget { backgroundColor: Colors.green.withOpacity(0.8), colorText: Colors.white, ); - await controller.fetchExpenseDetails(expenseId); + await controller.fetchExpenseDetails(); + return true; } else { Get.snackbar( @@ -251,8 +239,9 @@ class ExpenseDetailScreen extends StatelessWidget { ), ); } else { - final success = await controller.updateExpenseStatus( - expense.id, next.id); + final success = + await controller.updateExpenseStatus(next.id); + if (success) { Get.snackbar( 'Success', @@ -260,7 +249,7 @@ class ExpenseDetailScreen extends StatelessWidget { backgroundColor: Colors.green.withOpacity(0.8), colorText: Colors.white, ); - await controller.fetchExpenseDetails(expenseId); + await controller.fetchExpenseDetails(); } else { Get.snackbar( 'Error', diff --git a/lib/view/expense/expense_filter_bottom_sheet.dart b/lib/view/expense/expense_filter_bottom_sheet.dart index b5b6bf5..313d632 100644 --- a/lib/view/expense/expense_filter_bottom_sheet.dart +++ b/lib/view/expense/expense_filter_bottom_sheet.dart @@ -1,47 +1,13 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/controller/expense/expense_screen_controller.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; import 'package:marco/helpers/utils/date_time_utils.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/widgets/my_text_style.dart'; import 'package:marco/model/employee_model.dart'; -/// Wrapper to open Expense Filter Bottom Sheet -void openExpenseFilterBottomSheet( - BuildContext context, ExpenseController expenseController) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (context) { - return ExpenseFilterBottomSheetWrapper( - expenseController: expenseController); - }, - ); -} - -class ExpenseFilterBottomSheetWrapper extends StatelessWidget { - final ExpenseController expenseController; - - const ExpenseFilterBottomSheetWrapper( - {super.key, required this.expenseController}); - - @override - Widget build(BuildContext context) { - return DraggableScrollableSheet( - initialChildSize: 0.7, - minChildSize: 0.4, - maxChildSize: 0.95, - expand: false, - builder: (context, scrollController) { - return ExpenseFilterBottomSheet( - expenseController: expenseController, - scrollController: scrollController, - ); - }, - ); - } -} - class ExpenseFilterBottomSheet extends StatelessWidget { final ExpenseController expenseController; final ScrollController scrollController; @@ -52,26 +18,143 @@ class ExpenseFilterBottomSheet extends StatelessWidget { required this.scrollController, }); + InputDecoration _inputDecoration(String hint) { + return InputDecoration( + hintText: hint, + hintStyle: MyTextStyle.bodySmall(xMuted: true), + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5), + ), + contentPadding: MySpacing.all(12), + ); + } + @override Widget build(BuildContext context) { return Obx(() { - return SafeArea( - child: Container( - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), + return BaseBottomSheet( + title: 'Filter Expenses', + onCancel: () => Get.back(), + onSubmit: () { + expenseController.fetchExpenses(); + Get.back(); + }, + submitText: 'Submit', + submitColor: Colors.indigo, + submitIcon: Icons.check_circle_outline, + child: SingleChildScrollView( + controller: scrollController, child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: SingleChildScrollView( - controller: scrollController, - padding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 20), - child: _buildContent(context), + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: () => expenseController.clearFilters(), + child: const Text( + "Reset Filter", + style: TextStyle( + color: Colors.red, + fontWeight: FontWeight.w600, + ), + ), ), ), - _buildBottomButtons(), + MySpacing.height(8), + + _buildField("Project", _popupSelector( + context, + currentValue: expenseController.selectedProject.value.isEmpty + ? 'Select Project' + : expenseController.selectedProject.value, + items: expenseController.globalProjects, + onSelected: (value) => + expenseController.selectedProject.value = value, + )), + MySpacing.height(16), + + _buildField("Expense Status", _popupSelector( + context, + currentValue: expenseController.selectedStatus.value.isEmpty + ? 'Select Expense Status' + : expenseController.expenseStatuses + .firstWhereOrNull((e) => + e.id == expenseController.selectedStatus.value) + ?.name ?? + 'Select Expense Status', + items: expenseController.expenseStatuses + .map((e) => e.name) + .toList(), + onSelected: (name) { + final status = expenseController.expenseStatuses + .firstWhere((e) => e.name == name); + expenseController.selectedStatus.value = status.id; + }, + )), + MySpacing.height(16), + + _buildField("Date Range", Row( + children: [ + Expanded(child: _dateButton( + label: expenseController.startDate.value == null + ? 'Start Date' + : DateTimeUtils.formatDate( + expenseController.startDate.value!, 'dd MMM yyyy'), + onTap: () async { + DateTime? picked = await showDatePicker( + context: context, + initialDate: + expenseController.startDate.value ?? DateTime.now(), + firstDate: DateTime(2020), + lastDate: DateTime.now().add(const Duration(days: 365)), + ); + if (picked != null) { + expenseController.startDate.value = picked; + } + }, + )), + MySpacing.width(8), + Expanded(child: _dateButton( + label: expenseController.endDate.value == null + ? 'End Date' + : DateTimeUtils.formatDate( + expenseController.endDate.value!, 'dd MMM yyyy'), + onTap: () async { + DateTime? picked = await showDatePicker( + context: context, + initialDate: + expenseController.endDate.value ?? DateTime.now(), + firstDate: DateTime(2020), + lastDate: DateTime.now().add(const Duration(days: 365)), + ); + if (picked != null) { + expenseController.endDate.value = picked; + } + }, + )), + ], + )), + MySpacing.height(16), + + _buildField("Paid By", _employeeSelector( + selectedEmployees: expenseController.selectedPaidByEmployees, + )), + MySpacing.height(16), + + _buildField("Created By", _employeeSelector( + selectedEmployees: expenseController.selectedCreatedByEmployees, + )), ], ), ), @@ -79,208 +162,17 @@ class ExpenseFilterBottomSheet extends StatelessWidget { }); } - /// Builds the filter content - Widget _buildContent(BuildContext context) { + Widget _buildField(String label, Widget child) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Center( - child: Container( - width: 50, - height: 4, - decoration: BoxDecoration( - color: Colors.grey.shade300, - borderRadius: BorderRadius.circular(20), - ), - ), - ), - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - MyText.titleLarge('Filter Expenses', fontWeight: 700), - TextButton( - onPressed: () => expenseController.clearFilters(), - child: const Text( - "Reset Filter", - style: TextStyle( - color: Colors.red, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), - - const SizedBox(height: 16), - - /// Project Filter - _buildCardSection( - title: "Project", - child: _popupSelector( - context, - currentValue: expenseController.selectedProject.value.isEmpty - ? 'Select Project' - : expenseController.selectedProject.value, - items: expenseController.globalProjects, - onSelected: (value) => - expenseController.selectedProject.value = value, - ), - ), - const SizedBox(height: 16), - - /// Expense Status Filter - _buildCardSection( - title: "Expense Status", - child: _popupSelector( - context, - currentValue: expenseController.selectedStatus.value.isEmpty - ? 'Select Expense Status' - : expenseController.expenseStatuses - .firstWhereOrNull((e) => - e.id == expenseController.selectedStatus.value) - ?.name ?? - 'Select Expense Status', - items: - expenseController.expenseStatuses.map((e) => e.name).toList(), - onSelected: (name) { - final status = expenseController.expenseStatuses - .firstWhere((e) => e.name == name); - expenseController.selectedStatus.value = status.id; - }, - ), - ), - const SizedBox(height: 16), - - /// Date Range Filter - _buildCardSection( - title: "Date Range", - child: Row( - children: [ - Expanded( - child: _dateButton( - label: expenseController.startDate.value == null - ? 'Start Date' - : DateTimeUtils.formatDate( - expenseController.startDate.value!, 'dd MMM yyyy'), - onTap: () async { - DateTime? picked = await showDatePicker( - context: context, - initialDate: - expenseController.startDate.value ?? DateTime.now(), - firstDate: DateTime(2020), - lastDate: DateTime.now().add(const Duration(days: 365)), - ); - if (picked != null) - expenseController.startDate.value = picked; - }, - ), - ), - const SizedBox(width: 8), - Expanded( - child: _dateButton( - label: expenseController.endDate.value == null - ? 'End Date' - : DateTimeUtils.formatDate( - expenseController.endDate.value!, 'dd MMM yyyy'), - onTap: () async { - DateTime? picked = await showDatePicker( - context: context, - initialDate: - expenseController.endDate.value ?? DateTime.now(), - firstDate: DateTime(2020), - lastDate: DateTime.now().add(const Duration(days: 365)), - ); - if (picked != null) - expenseController.endDate.value = picked; - }, - ), - ), - ], - ), - ), - const SizedBox(height: 16), - - /// Paid By Filter - _buildCardSection( - title: "Paid By", - child: _employeeFilterSection( - selectedEmployees: expenseController.selectedPaidByEmployees, - ), - ), - const SizedBox(height: 16), - - /// Created By Filter - _buildCardSection( - title: "Created By", - child: _employeeFilterSection( - selectedEmployees: expenseController.selectedCreatedByEmployees, - ), - ), - const SizedBox(height: 24), + MyText.labelMedium(label), + MySpacing.height(8), + child, ], ); } - /// Bottom Action Buttons - Widget _buildBottomButtons() { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: Row( - children: [ - // Cancel Button - Expanded( - child: ElevatedButton.icon( - onPressed: () { - Get.back(); - }, - icon: const Icon(Icons.close, color: Colors.white), - label: MyText.bodyMedium( - "Cancel", - color: Colors.white, - fontWeight: 600, - ), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: - const EdgeInsets.symmetric(horizontal: 10, vertical: 7), - ), - ), - ), - const SizedBox(width: 12), - - // Submit Button - Expanded( - child: ElevatedButton.icon( - onPressed: () { - expenseController.fetchExpenses(); - Get.back(); - }, - icon: const Icon(Icons.check_circle_outline, color: Colors.white), - label: MyText.bodyMedium( - "Submit", - color: Colors.white, - fontWeight: 600, - ), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.indigo, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: - const EdgeInsets.symmetric(horizontal: 10, vertical: 7), - ), - ), - ), - ], - ), - ); - } - - /// Popup Selector Widget _popupSelector( BuildContext context, { required String currentValue, @@ -288,21 +180,20 @@ class ExpenseFilterBottomSheet extends StatelessWidget { required ValueChanged onSelected, }) { return PopupMenuButton( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), onSelected: onSelected, - itemBuilder: (context) { - return items - .map((e) => PopupMenuItem( - value: e, - child: Text(e), - )) - .toList(); - }, + itemBuilder: (context) => items + .map((e) => PopupMenuItem( + value: e, + child: Text(e), + )) + .toList(), child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: MySpacing.all(12), decoration: BoxDecoration( + color: Colors.grey.shade100, border: Border.all(color: Colors.grey.shade300), - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(12), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -321,55 +212,52 @@ class ExpenseFilterBottomSheet extends StatelessWidget { ); } - /// Card Section Wrapper - Widget _buildCardSection({required String title, required Widget child}) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.bodyMedium(title, fontWeight: 600), - const SizedBox(height: 6), - child, - ], - ); - } - - /// Date Button Widget _dateButton({required String label, required VoidCallback onTap}) { - return ElevatedButton.icon( - onPressed: onTap, - icon: const Icon(Icons.calendar_today, size: 16), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.grey.shade100, - foregroundColor: Colors.black, - elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), + return GestureDetector( + onTap: onTap, + child: Container( + padding: MySpacing.xy(16, 12), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade300), + ), + child: Row( + children: [ + const Icon(Icons.calendar_today, size: 16, color: Colors.grey), + MySpacing.width(8), + Expanded( + child: Text(label, + style: MyTextStyle.bodyMedium(), + overflow: TextOverflow.ellipsis), + ), + ], + ), ), - label: Text(label, overflow: TextOverflow.ellipsis), ); } - /// Employee Filter Section - Widget _employeeFilterSection( - {required RxList selectedEmployees}) { + Widget _employeeSelector({ + required RxList selectedEmployees, + }) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Obx(() { return Wrap( - spacing: 6, + spacing: 8, runSpacing: -8, children: selectedEmployees.map((emp) { return Chip( label: Text(emp.name), - deleteIcon: const Icon(Icons.close, size: 18), onDeleted: () => selectedEmployees.remove(emp), + deleteIcon: const Icon(Icons.close, size: 18), backgroundColor: Colors.grey.shade200, ); }).toList(), ); }), - const SizedBox(height: 6), + MySpacing.height(8), Autocomplete( optionsBuilder: (TextEditingValue textEditingValue) { if (textEditingValue.text.isEmpty) { @@ -387,19 +275,11 @@ class ExpenseFilterBottomSheet extends StatelessWidget { selectedEmployees.add(emp); } }, - fieldViewBuilder: (context, controller, focusNode, onFieldSubmitted) { + fieldViewBuilder: (context, controller, focusNode, _) { return TextField( controller: controller, focusNode: focusNode, - decoration: InputDecoration( - hintText: 'Search Employee', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: Colors.grey.shade300), - ), - contentPadding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - ), + decoration: _inputDecoration("Search Employee"), ); }, optionsViewBuilder: (context, onSelected, options) { @@ -411,7 +291,6 @@ class ExpenseFilterBottomSheet extends StatelessWidget { child: SizedBox( height: 200, child: ListView.builder( - padding: EdgeInsets.zero, itemCount: options.length, itemBuilder: (context, index) { final emp = options.elementAt(index); diff --git a/lib/view/expense/expense_screen.dart b/lib/view/expense/expense_screen.dart index 5b15f71..8436a2c 100644 --- a/lib/view/expense/expense_screen.dart +++ b/lib/view/expense/expense_screen.dart @@ -42,8 +42,9 @@ class _ExpenseMainScreenState extends State { isScrollControlled: true, backgroundColor: Colors.transparent, builder: (context) { - return ExpenseFilterBottomSheetWrapper( + return ExpenseFilterBottomSheet( expenseController: expenseController, + scrollController: ScrollController(), ); }, ); From 31966f4bc594bb595bbe107bd3093c648ba62fef Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Thu, 31 Jul 2025 16:44:05 +0530 Subject: [PATCH 32/65] feat: add permission handling in ExpenseDetailScreen and enhance ExpenseDetailController with permission parsing --- .../expense/expense_detail_controller.dart | 40 +++++++++++++++---- lib/controller/permission_controller.dart | 17 ++++++++ lib/helpers/utils/date_time_utils.dart | 5 --- lib/view/expense/expense_detail_screen.dart | 9 ++++- 4 files changed, 58 insertions(+), 13 deletions(-) diff --git a/lib/controller/expense/expense_detail_controller.dart b/lib/controller/expense/expense_detail_controller.dart index 8023c0a..88b71cd 100644 --- a/lib/controller/expense/expense_detail_controller.dart +++ b/lib/controller/expense/expense_detail_controller.dart @@ -33,18 +33,21 @@ class ExpenseDetailController extends GetxController { try { logSafe("Fetching expense details for ID: $_expenseId"); - final result = await ApiService.getExpenseDetailsApi(expenseId: _expenseId); + final result = + await ApiService.getExpenseDetailsApi(expenseId: _expenseId); if (result != null) { try { expense.value = ExpenseDetailModel.fromJson(result); logSafe("Expense details loaded successfully: ${expense.value?.id}"); } catch (e) { errorMessage.value = 'Failed to parse expense details: $e'; - logSafe("Parse error in fetchExpenseDetails: $e", level: LogLevel.error); + logSafe("Parse error in fetchExpenseDetails: $e", + level: LogLevel.error); } } else { errorMessage.value = 'Failed to fetch expense details from server.'; - logSafe("fetchExpenseDetails failed: null response", level: LogLevel.error); + logSafe("fetchExpenseDetails failed: null response", + level: LogLevel.error); } } catch (e, stack) { errorMessage.value = 'An unexpected error occurred.'; @@ -55,6 +58,27 @@ class ExpenseDetailController extends GetxController { } } +// In ExpenseDetailController + List parsePermissionIds(dynamic permissionData) { + if (permissionData == null) return []; + if (permissionData is List) { + // If it's already a List, return a list of Strings. + return permissionData + .map((e) => e.toString().trim()) + .where((e) => e.isNotEmpty) + .toList(); + } + if (permissionData is String) { + final clean = permissionData.replaceAll(RegExp(r'[\[\]]'), ''); + return clean + .split(',') + .map((e) => e.trim()) + .where((e) => e.isNotEmpty) + .toList(); + } + return []; + } + /// Fetch all employees Future fetchAllEmployees() async { isLoading.value = true; @@ -64,7 +88,8 @@ class ExpenseDetailController extends GetxController { final response = await ApiService.getAllEmployees(); if (response != null && response.isNotEmpty) { allEmployees.assignAll(response.map((e) => EmployeeModel.fromJson(e))); - logSafe("All Employees fetched: ${allEmployees.length}", level: LogLevel.info); + logSafe("All Employees fetched: ${allEmployees.length}", + level: LogLevel.info); } else { allEmployees.clear(); logSafe("No employees found.", level: LogLevel.warning); @@ -103,7 +128,7 @@ class ExpenseDetailController extends GetxController { if (success) { logSafe("Reimbursement submitted successfully."); - await fetchExpenseDetails(); // refresh latest + await fetchExpenseDetails(); return true; } else { errorMessage.value = "Failed to submit reimbursement."; @@ -111,7 +136,8 @@ class ExpenseDetailController extends GetxController { } } catch (e, stack) { errorMessage.value = 'An unexpected error occurred.'; - logSafe("Exception in updateExpenseStatusWithReimbursement: $e", level: LogLevel.error); + logSafe("Exception in updateExpenseStatusWithReimbursement: $e", + level: LogLevel.error); logSafe("StackTrace: $stack", level: LogLevel.debug); return false; } finally { @@ -134,7 +160,7 @@ class ExpenseDetailController extends GetxController { if (success) { logSafe("Expense status updated successfully."); - await fetchExpenseDetails(); // refresh + await fetchExpenseDetails(); return true; } else { errorMessage.value = "Failed to update expense status."; diff --git a/lib/controller/permission_controller.dart b/lib/controller/permission_controller.dart index 0802f33..75b1105 100644 --- a/lib/controller/permission_controller.dart +++ b/lib/controller/permission_controller.dart @@ -117,6 +117,23 @@ class PermissionController extends GetxController { return assigned; } + List get allowedPermissionIds { + final ids = permissions.map((p) => p.id).toList(); + logSafe("[PermissionController] Allowed Permission IDs: $ids", + level: LogLevel.debug); + return ids; + } + + bool hasAnyPermission(List ids) { + logSafe("[PermissionController] Checking if any of these are allowed: $ids", + level: LogLevel.debug); + final allowed = allowedPermissionIds; + final result = ids.any((id) => allowed.contains(id)); + logSafe("[PermissionController] Permission match result: $result", + level: LogLevel.debug); + return result; + } + @override void onClose() { _refreshTimer?.cancel(); diff --git a/lib/helpers/utils/date_time_utils.dart b/lib/helpers/utils/date_time_utils.dart index 1dca3da..01bae1f 100644 --- a/lib/helpers/utils/date_time_utils.dart +++ b/lib/helpers/utils/date_time_utils.dart @@ -5,8 +5,6 @@ class DateTimeUtils { /// Converts a UTC datetime string to local time and formats it. static String convertUtcToLocal(String utcTimeString, {String format = 'dd-MM-yyyy'}) { try { - logSafe('convertUtcToLocal: input="$utcTimeString", format="$format"'); - final parsed = DateTime.parse(utcTimeString); final utcDateTime = DateTime.utc( parsed.year, @@ -18,13 +16,10 @@ class DateTimeUtils { parsed.millisecond, parsed.microsecond, ); - logSafe('Parsed (assumed UTC): $utcDateTime'); final localDateTime = utcDateTime.toLocal(); - logSafe('Converted to Local: $localDateTime'); final formatted = _formatDateTime(localDateTime, format: format); - logSafe('Formatted Local Time: $formatted'); return formatted; } catch (e, stackTrace) { diff --git a/lib/view/expense/expense_detail_screen.dart b/lib/view/expense/expense_detail_screen.dart index 86fb009..6718ecd 100644 --- a/lib/view/expense/expense_detail_screen.dart +++ b/lib/view/expense/expense_detail_screen.dart @@ -3,6 +3,8 @@ import 'package:get/get.dart'; import 'package:intl/intl.dart'; import 'package:marco/controller/expense/expense_detail_controller.dart'; import 'package:marco/controller/project_controller.dart'; +import 'package:marco/controller/permission_controller.dart'; + import 'package:marco/helpers/utils/date_time_utils.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; @@ -40,6 +42,7 @@ class ExpenseDetailScreen extends StatelessWidget { final controller = Get.put(ExpenseDetailController()); final projectController = Get.find(); controller.init(expenseId); + final permissionController = Get.find(); return Scaffold( backgroundColor: const Color(0xFFF7F7F7), @@ -169,7 +172,11 @@ class ExpenseDetailScreen extends StatelessWidget { alignment: WrapAlignment.center, spacing: 10, runSpacing: 10, - children: expense.nextStatus.map((next) { + children: expense.nextStatus.where((next) { + return permissionController.hasAnyPermission( + controller.parsePermissionIds(next.permissionIds), + ); + }).map((next) { Color buttonColor = Colors.red; if (next.color.isNotEmpty) { try { From 3427c5bd26153b8d9347da64c6270d5d6c10a210 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Thu, 31 Jul 2025 16:57:15 +0530 Subject: [PATCH 33/65] refactor: streamline API call handling in ExpenseDetailController and enhance error logging --- .../expense/expense_detail_controller.dart | 161 ++++++++---------- 1 file changed, 73 insertions(+), 88 deletions(-) diff --git a/lib/controller/expense/expense_detail_controller.dart b/lib/controller/expense/expense_detail_controller.dart index 88b71cd..23d3d4e 100644 --- a/lib/controller/expense/expense_detail_controller.dart +++ b/lib/controller/expense/expense_detail_controller.dart @@ -11,9 +11,8 @@ class ExpenseDetailController extends GetxController { final Rx selectedReimbursedBy = Rx(null); final RxList allEmployees = [].obs; - bool _isInitialized = false; late String _expenseId; - + bool _isInitialized = false; /// Call this once from the screen (NOT inside build) to initialize void init(String expenseId) { if (_isInitialized) return; @@ -21,48 +20,61 @@ class ExpenseDetailController extends GetxController { _isInitialized = true; _expenseId = expenseId; - fetchExpenseDetails(); - fetchAllEmployees(); + // Use Future.wait to fetch details and employees concurrently + Future.wait([ + fetchExpenseDetails(), + fetchAllEmployees(), + ]); } - /// Fetch expense details by stored ID - Future fetchExpenseDetails() async { + /// Generic method to handle API calls with loading and error states + Future _apiCallWrapper( + Future Function() apiCall, String operationName) async { isLoading.value = true; - errorMessage.value = ''; + errorMessage.value = ''; // Clear previous errors try { - logSafe("Fetching expense details for ID: $_expenseId"); - - final result = - await ApiService.getExpenseDetailsApi(expenseId: _expenseId); - if (result != null) { - try { - expense.value = ExpenseDetailModel.fromJson(result); - logSafe("Expense details loaded successfully: ${expense.value?.id}"); - } catch (e) { - errorMessage.value = 'Failed to parse expense details: $e'; - logSafe("Parse error in fetchExpenseDetails: $e", - level: LogLevel.error); - } - } else { - errorMessage.value = 'Failed to fetch expense details from server.'; - logSafe("fetchExpenseDetails failed: null response", - level: LogLevel.error); - } + logSafe("Initiating $operationName..."); + final result = await apiCall(); + logSafe("$operationName completed successfully."); + return result; } catch (e, stack) { - errorMessage.value = 'An unexpected error occurred.'; - logSafe("Exception in fetchExpenseDetails: $e", level: LogLevel.error); + errorMessage.value = 'An unexpected error occurred during $operationName.'; + logSafe("Exception in $operationName: $e", level: LogLevel.error); logSafe("StackTrace: $stack", level: LogLevel.debug); + return null; } finally { isLoading.value = false; } } -// In ExpenseDetailController + /// Fetch expense details by stored ID + Future fetchExpenseDetails() async { + final result = await _apiCallWrapper( + () => ApiService.getExpenseDetailsApi(expenseId: _expenseId), + "fetch expense details"); + + if (result != null) { + try { + expense.value = ExpenseDetailModel.fromJson(result); + logSafe("Expense details loaded successfully: ${expense.value?.id}"); + } catch (e) { + errorMessage.value = 'Failed to parse expense details: $e'; + logSafe("Parse error in fetchExpenseDetails: $e", + level: LogLevel.error); + } + } else { + errorMessage.value = 'Failed to fetch expense details from server.'; + logSafe("fetchExpenseDetails failed: null response", + level: LogLevel.error); + } + } + + // This method seems like a utility and might be better placed in a helper or utility class + // if it's used across multiple controllers. Keeping it here for now as per original code. List parsePermissionIds(dynamic permissionData) { if (permissionData == null) return []; if (permissionData is List) { - // If it's already a List, return a list of Strings. return permissionData .map((e) => e.toString().trim()) .where((e) => e.isNotEmpty) @@ -81,26 +93,24 @@ class ExpenseDetailController extends GetxController { /// Fetch all employees Future fetchAllEmployees() async { - isLoading.value = true; - errorMessage.value = ''; + final response = await _apiCallWrapper( + () => ApiService.getAllEmployees(), "fetch all employees"); - try { - final response = await ApiService.getAllEmployees(); - if (response != null && response.isNotEmpty) { + if (response != null && response.isNotEmpty) { + try { allEmployees.assignAll(response.map((e) => EmployeeModel.fromJson(e))); logSafe("All Employees fetched: ${allEmployees.length}", level: LogLevel.info); - } else { - allEmployees.clear(); - logSafe("No employees found.", level: LogLevel.warning); + } catch (e) { + errorMessage.value = 'Failed to parse employee data: $e'; + logSafe("Parse error in fetchAllEmployees: $e", level: LogLevel.error); } - } catch (e) { + } else { allEmployees.clear(); - logSafe("Error fetching employees", level: LogLevel.error, error: e); - } finally { - isLoading.value = false; - update(); + logSafe("No employees found.", level: LogLevel.warning); } + // `update()` is typically not needed for RxList directly unless you have specific GetBuilder/Obx usage that requires it + // If you are using Obx widgets, `allEmployees.assignAll` will automatically trigger a rebuild. } /// Update expense with reimbursement info and status @@ -111,68 +121,43 @@ class ExpenseDetailController extends GetxController { required String reimburseById, required String statusId, }) async { - isLoading.value = true; - errorMessage.value = ''; - - try { - logSafe("Submitting reimbursement for expense: $_expenseId"); - - final success = await ApiService.updateExpenseStatusApi( + final success = await _apiCallWrapper( + () => ApiService.updateExpenseStatusApi( expenseId: _expenseId, statusId: statusId, comment: comment, reimburseTransactionId: reimburseTransactionId, reimburseDate: reimburseDate, reimbursedById: reimburseById, - ); + ), + "submit reimbursement", + ); - if (success) { - logSafe("Reimbursement submitted successfully."); - await fetchExpenseDetails(); - return true; - } else { - errorMessage.value = "Failed to submit reimbursement."; - return false; - } - } catch (e, stack) { - errorMessage.value = 'An unexpected error occurred.'; - logSafe("Exception in updateExpenseStatusWithReimbursement: $e", - level: LogLevel.error); - logSafe("StackTrace: $stack", level: LogLevel.debug); + if (success == true) { // Explicitly check for true as _apiCallWrapper returns T? + await fetchExpenseDetails(); // Refresh details after successful update + return true; + } else { + errorMessage.value = "Failed to submit reimbursement."; return false; - } finally { - isLoading.value = false; } } /// Update status for this specific expense Future updateExpenseStatus(String statusId) async { - isLoading.value = true; - errorMessage.value = ''; - - try { - logSafe("Updating status for expense: $_expenseId -> $statusId"); - - final success = await ApiService.updateExpenseStatusApi( + final success = await _apiCallWrapper( + () => ApiService.updateExpenseStatusApi( expenseId: _expenseId, statusId: statusId, - ); + ), + "update expense status", + ); - if (success) { - logSafe("Expense status updated successfully."); - await fetchExpenseDetails(); - return true; - } else { - errorMessage.value = "Failed to update expense status."; - return false; - } - } catch (e, stack) { - errorMessage.value = 'An unexpected error occurred.'; - logSafe("Exception in updateExpenseStatus: $e", level: LogLevel.error); - logSafe("StackTrace: $stack", level: LogLevel.debug); + if (success == true) { // Explicitly check for true as _apiCallWrapper returns T? + await fetchExpenseDetails(); // Refresh details after successful update + return true; + } else { + errorMessage.value = "Failed to update expense status."; return false; - } finally { - isLoading.value = false; } } -} +} \ No newline at end of file From 797df80890237fd3c2fe575eab5d2d552118ebef Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Thu, 31 Jul 2025 18:09:17 +0530 Subject: [PATCH 34/65] feat: enhance bottom sheet components with dynamic button visibility and improved styling --- lib/helpers/utils/base_bottom_sheet.dart | 78 ++--- .../attendance/attendence_action_button.dart | 160 ++++----- .../attendance/attendence_filter_sheet.dart | 133 +++---- lib/model/attendance/log_details_view.dart | 329 ++++++++---------- .../Attendence/attendance_screen.dart | 3 +- 5 files changed, 312 insertions(+), 391 deletions(-) diff --git a/lib/helpers/utils/base_bottom_sheet.dart b/lib/helpers/utils/base_bottom_sheet.dart index b7ed51c..485f0dd 100644 --- a/lib/helpers/utils/base_bottom_sheet.dart +++ b/lib/helpers/utils/base_bottom_sheet.dart @@ -11,6 +11,7 @@ class BaseBottomSheet extends StatelessWidget { final String submitText; final Color submitColor; final IconData submitIcon; + final bool showButtons; const BaseBottomSheet({ super.key, @@ -22,6 +23,7 @@ class BaseBottomSheet extends StatelessWidget { this.submitText = 'Submit', this.submitColor = Colors.indigo, this.submitIcon = Icons.check_circle_outline, + this.showButtons = true, }); @override @@ -32,8 +34,7 @@ class BaseBottomSheet extends StatelessWidget { return SingleChildScrollView( padding: mediaQuery.viewInsets, child: Padding( - padding: const EdgeInsets.only( - top: 60), + padding: const EdgeInsets.only(top: 60), child: Container( decoration: BoxDecoration( color: theme.cardColor, @@ -65,49 +66,48 @@ class BaseBottomSheet extends StatelessWidget { MySpacing.height(12), child, MySpacing.height(24), - Row( - children: [ - Expanded( - child: ElevatedButton.icon( - onPressed: onCancel, - icon: const Icon(Icons.close, color: Colors.white), - label: MyText.bodyMedium( - "Cancel", - color: Colors.white, - fontWeight: 600, - ), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.grey, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + if (showButtons) + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: onCancel, + icon: const Icon(Icons.close, color: Colors.white), + label: MyText.bodyMedium( + "Cancel", + color: Colors.white, + fontWeight: 600, + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.grey, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(vertical: 8), ), - padding: const EdgeInsets.symmetric( - vertical: 8), ), ), - ), - const SizedBox(width: 12), - Expanded( - child: ElevatedButton.icon( - onPressed: isSubmitting ? null : onSubmit, - icon: Icon(submitIcon, color: Colors.white), - label: MyText.bodyMedium( - isSubmitting ? "Submitting..." : submitText, - color: Colors.white, - fontWeight: 600, - ), - style: ElevatedButton.styleFrom( - backgroundColor: submitColor, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + onPressed: isSubmitting ? null : onSubmit, + icon: Icon(submitIcon, color: Colors.white), + label: MyText.bodyMedium( + isSubmitting ? "Submitting..." : submitText, + color: Colors.white, + fontWeight: 600, + ), + style: ElevatedButton.styleFrom( + backgroundColor: submitColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(vertical: 8), ), - padding: const EdgeInsets.symmetric( - vertical: 8), ), ), - ), - ], - ), + ], + ), ], ), ), diff --git a/lib/model/attendance/attendence_action_button.dart b/lib/model/attendance/attendence_action_button.dart index 08b725e..90d263d 100644 --- a/lib/model/attendance/attendence_action_button.dart +++ b/lib/model/attendance/attendence_action_button.dart @@ -5,16 +5,17 @@ import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/controller/dashboard/attendance_screen_controller.dart'; import 'package:marco/helpers/utils/attendance_actions.dart'; import 'package:marco/controller/project_controller.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; class AttendanceActionButton extends StatefulWidget { final dynamic employee; final AttendanceController attendanceController; const AttendanceActionButton({ - Key? key, + super.key, required this.employee, required this.attendanceController, - }) : super(key: key); + }); @override State createState() => _AttendanceActionButtonState(); @@ -24,81 +25,59 @@ Future _showCommentBottomSheet( BuildContext context, String actionText) async { final TextEditingController commentController = TextEditingController(); String? errorText; - Get.find().selectedProject?.id; + return showModalBottomSheet( context: context, isScrollControlled: true, - backgroundColor: Colors.white, + backgroundColor: Colors.transparent, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (context) { return StatefulBuilder( builder: (context, setModalState) { + void submit() { + final comment = commentController.text.trim(); + if (comment.isEmpty) { + setModalState(() => errorText = 'Comment cannot be empty.'); + return; + } + Navigator.of(context).pop(comment); + } + return Padding( padding: EdgeInsets.only( - left: 16, - right: 16, - top: 24, - bottom: MediaQuery.of(context).viewInsets.bottom + 24, + bottom: MediaQuery.of(context).viewInsets.bottom, ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Add Comment for ${capitalizeFirstLetter(actionText)}', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - const SizedBox(height: 16), - TextField( - controller: commentController, - maxLines: 4, - decoration: InputDecoration( - hintText: 'Type your comment here...', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - filled: true, - fillColor: Colors.grey.shade100, - errorText: errorText, - ), - onChanged: (_) { - if (errorText != null) { - setModalState(() => errorText = null); - } - }, - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: OutlinedButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), + child: BaseBottomSheet( + title: 'Add Comment for ${capitalizeFirstLetter(actionText)}', + onCancel: () => Navigator.of(context).pop(), + onSubmit: submit, + isSubmitting: false, + submitText: 'Submit', + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: commentController, + maxLines: 4, + decoration: InputDecoration( + hintText: 'Type your comment here...', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), ), + filled: true, + fillColor: Colors.grey.shade100, + errorText: errorText, ), - const SizedBox(width: 12), - Expanded( - child: ElevatedButton( - onPressed: () { - final comment = commentController.text.trim(); - if (comment.isEmpty) { - setModalState(() { - errorText = 'Comment cannot be empty.'; - }); - return; - } - Navigator.of(context).pop(comment); - }, - child: const Text('Submit'), - ), - ), - ], - ), - ], + onChanged: (_) { + if (errorText != null) { + setModalState(() => errorText = null); + } + }, + ), + ], + ), ), ); }, @@ -119,13 +98,15 @@ class _AttendanceActionButtonState extends State { void initState() { super.initState(); uniqueLogKey = AttendanceButtonHelper.getUniqueKey( - widget.employee.employeeId, widget.employee.id); + widget.employee.employeeId, + widget.employee.id, + ); WidgetsBinding.instance.addPostFrameCallback((_) { - if (!widget.attendanceController.uploadingStates - .containsKey(uniqueLogKey)) { - widget.attendanceController.uploadingStates[uniqueLogKey] = false.obs; - } + widget.attendanceController.uploadingStates.putIfAbsent( + uniqueLogKey, + () => false.obs, + ); }); } @@ -167,6 +148,7 @@ class _AttendanceActionButtonState extends State { return selectedDateTime; } + return null; } @@ -228,7 +210,6 @@ class _AttendanceActionButtonState extends State { DateTime? selectedTime; - // ✅ New condition: Yesterday Check-In + CheckOut action final isYesterdayCheckIn = widget.employee.checkIn != null && DateUtils.isSameDay( widget.employee.checkIn, @@ -257,15 +238,16 @@ class _AttendanceActionButtonState extends State { } bool success = false; + if (actionText == ButtonActions.requestRegularize) { final regularizeTime = selectedTime ?? await showTimePickerForRegularization( context: context, checkInTime: widget.employee.checkIn!, ); + if (regularizeTime != null) { - final formattedSelectedTime = - DateFormat("hh:mm a").format(regularizeTime); + final formattedTime = DateFormat("hh:mm a").format(regularizeTime); success = await widget.attendanceController.captureAndUploadAttendance( widget.employee.id, widget.employee.employeeId, @@ -273,12 +255,11 @@ class _AttendanceActionButtonState extends State { comment: userComment, action: updatedAction, imageCapture: imageCapture, - markTime: formattedSelectedTime, + markTime: formattedTime, ); } } else if (selectedTime != null) { - // ✅ If selectedTime was picked in the new condition - final formattedSelectedTime = DateFormat("hh:mm a").format(selectedTime); + final formattedTime = DateFormat("hh:mm a").format(selectedTime); success = await widget.attendanceController.captureAndUploadAttendance( widget.employee.id, widget.employee.employeeId, @@ -286,7 +267,7 @@ class _AttendanceActionButtonState extends State { comment: userComment, action: updatedAction, imageCapture: imageCapture, - markTime: formattedSelectedTime, + markTime: formattedTime, ); } else { success = await widget.attendanceController.captureAndUploadAttendance( @@ -312,8 +293,7 @@ class _AttendanceActionButtonState extends State { if (success) { widget.attendanceController.fetchEmployeesByProject(selectedProjectId); widget.attendanceController.fetchAttendanceLogs(selectedProjectId); - await widget.attendanceController - .fetchRegularizationLogs(selectedProjectId); + await widget.attendanceController.fetchRegularizationLogs(selectedProjectId); await widget.attendanceController.fetchProjectData(selectedProjectId); widget.attendanceController.update(); } @@ -327,12 +307,19 @@ class _AttendanceActionButtonState extends State { false; final isYesterday = AttendanceButtonHelper.isLogFromYesterday( - widget.employee.checkIn, widget.employee.checkOut); + widget.employee.checkIn, + widget.employee.checkOut, + ); + final isTodayApproved = AttendanceButtonHelper.isTodayApproved( - widget.employee.activity, widget.employee.checkIn); - final isApprovedButNotToday = - AttendanceButtonHelper.isApprovedButNotToday( - widget.employee.activity, isTodayApproved); + widget.employee.activity, + widget.employee.checkIn, + ); + + final isApprovedButNotToday = AttendanceButtonHelper.isApprovedButNotToday( + widget.employee.activity, + isTodayApproved, + ); final isButtonDisabled = AttendanceButtonHelper.isButtonDisabled( isUploading: isUploading, @@ -359,8 +346,7 @@ class _AttendanceActionButtonState extends State { isButtonDisabled: isButtonDisabled, buttonText: buttonText, buttonColor: buttonColor, - onPressed: - isButtonDisabled ? null : () => _handleButtonPressed(context), + onPressed: isButtonDisabled ? null : () => _handleButtonPressed(context), ); }); } @@ -374,20 +360,20 @@ class AttendanceActionButtonUI extends StatelessWidget { final VoidCallback? onPressed; const AttendanceActionButtonUI({ - Key? key, + super.key, required this.isUploading, required this.isButtonDisabled, required this.buttonText, required this.buttonColor, required this.onPressed, - }) : super(key: key); + }); @override Widget build(BuildContext context) { return SizedBox( height: 30, child: ElevatedButton( - onPressed: isButtonDisabled ? null : onPressed, + onPressed: onPressed, style: ElevatedButton.styleFrom( backgroundColor: buttonColor, padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12), diff --git a/lib/model/attendance/attendence_filter_sheet.dart b/lib/model/attendance/attendence_filter_sheet.dart index e51f5d9..4b11265 100644 --- a/lib/model/attendance/attendence_filter_sheet.dart +++ b/lib/model/attendance/attendence_filter_sheet.dart @@ -4,6 +4,7 @@ import 'package:marco/controller/dashboard/attendance_screen_controller.dart'; import 'package:intl/intl.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/utils/permission_constants.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; class AttendanceFilterBottomSheet extends StatefulWidget { final AttendanceController controller; @@ -62,20 +63,20 @@ class _AttendanceFilterBottomSheetState List widgets = [ Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + padding: const EdgeInsets.only(bottom: 4), child: Align( alignment: Alignment.centerLeft, - child: MyText.titleSmall( - "View", - fontWeight: 600, - ), + child: MyText.titleSmall("View", fontWeight: 600), ), ), ...filteredViewOptions.map((item) { return RadioListTile( dense: true, - contentPadding: const EdgeInsets.symmetric(horizontal: 12), - title: Text(item['label']!), + contentPadding: EdgeInsets.zero, + title: MyText.bodyMedium( + item['label']!, + fontWeight: 500, + ), value: item['value']!, groupValue: tempSelectedTab, onChanged: (value) => setState(() => tempSelectedTab = value!), @@ -87,49 +88,38 @@ class _AttendanceFilterBottomSheetState widgets.addAll([ const Divider(), Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + padding: const EdgeInsets.only(top: 12, bottom: 4), child: Align( alignment: Alignment.centerLeft, - child: MyText.titleSmall( - "Date Range", - fontWeight: 600, - ), + child: MyText.titleSmall("Date Range", fontWeight: 600), ), ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: InkWell( - borderRadius: BorderRadius.circular(10), - onTap: () => widget.controller.selectDateRangeForAttendance( - context, - widget.controller, + InkWell( + borderRadius: BorderRadius.circular(10), + onTap: () => widget.controller.selectDateRangeForAttendance( + context, + widget.controller, + ), + child: Ink( + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: Colors.grey.shade400), + borderRadius: BorderRadius.circular(10), ), - child: Ink( - decoration: BoxDecoration( - color: Colors.white, - border: Border.all(color: Colors.grey.shade400), - borderRadius: BorderRadius.circular(10), - ), - padding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - child: Row( - children: [ - Icon(Icons.date_range, color: Colors.black87), - const SizedBox(width: 12), - Expanded( - child: Text( - getLabelText(), - style: const TextStyle( - fontSize: 16, - color: Colors.black87, - fontWeight: FontWeight.w500, - ), - overflow: TextOverflow.ellipsis, - ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + const Icon(Icons.date_range, color: Colors.black87), + const SizedBox(width: 12), + Expanded( + child: MyText.bodyMedium( + getLabelText(), + fontWeight: 500, + color: Colors.black87, ), - const Icon(Icons.arrow_drop_down, color: Colors.black87), - ], - ), + ), + const Icon(Icons.arrow_drop_down, color: Colors.black87), + ], ), ), ), @@ -141,49 +131,20 @@ class _AttendanceFilterBottomSheetState @override Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), - child: SingleChildScrollView( + return ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + child: BaseBottomSheet( + title: "Attendance Filter", + onCancel: () => Navigator.pop(context), + onSubmit: () => Navigator.pop(context, { + 'selectedTab': tempSelectedTab, + }), + submitText: "Apply Filter", + submitIcon: Icons.filter_alt_outlined, + submitColor: const Color.fromARGB(255, 95, 132, 255), child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.only(top: 12, bottom: 8), - child: Center( - child: Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: Colors.grey[400], - borderRadius: BorderRadius.circular(4), - ), - ), - ), - ), - ...buildMainFilters(), - const Divider(), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: SizedBox( - width: double.infinity, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: const Color.fromARGB(255, 95, 132, 255), - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - child: const Text('Apply Filter'), - onPressed: () { - Navigator.pop(context, { - 'selectedTab': tempSelectedTab, - }); - }, - ), - ), - ), - ], + crossAxisAlignment: CrossAxisAlignment.start, + children: buildMainFilters(), ), ), ); diff --git a/lib/model/attendance/log_details_view.dart b/lib/model/attendance/log_details_view.dart index deb0331..b2ad720 100644 --- a/lib/model/attendance/log_details_view.dart +++ b/lib/model/attendance/log_details_view.dart @@ -2,11 +2,11 @@ import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/utils/attendance_actions.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; class AttendanceLogViewButton extends StatelessWidget { final dynamic employee; - final dynamic attendanceController; // Use correct types as needed - + final dynamic attendanceController; const AttendanceLogViewButton({ Key? key, required this.employee, @@ -50,191 +50,164 @@ class AttendanceLogViewButton extends StatelessWidget { void _showLogsBottomSheet(BuildContext context) async { await attendanceController.fetchLogsView(employee.id.toString()); + showModalBottomSheet( context: context, isScrollControlled: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), - backgroundColor: Theme.of(context).cardColor, - builder: (context) => Padding( - padding: EdgeInsets.only( - left: 16, - right: 16, - top: 16, - bottom: MediaQuery.of(context).viewInsets.bottom + 16, - ), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Header - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - MyText.titleMedium( - "Attendance Log", - fontWeight: 700, - ), - IconButton( - icon: const Icon(Icons.close), - onPressed: () => Navigator.pop(context), - ), - ], - ), - const SizedBox(height: 12), - if (attendanceController.attendenceLogsView.isEmpty) - Padding( - padding: const EdgeInsets.symmetric(vertical: 24.0), - child: Column( - children: const [ - Icon(Icons.info_outline, size: 40, color: Colors.grey), - SizedBox(height: 8), - Text("No attendance logs available."), - ], - ), - ) - else - ListView.separated( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: attendanceController.attendenceLogsView.length, - separatorBuilder: (_, __) => const SizedBox(height: 16), - itemBuilder: (_, index) { - final log = attendanceController.attendenceLogsView[index]; - return Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceVariant, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 6, - offset: const Offset(0, 2), - ) - ], - ), - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - flex: 3, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - _getLogIcon(log), - const SizedBox(width: 10), - Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - MyText.bodyLarge( - log.formattedDate ?? '-', - fontWeight: 600, - ), - MyText.bodySmall( - "Time: ${log.formattedTime ?? '-'}", - color: Colors.grey[700], - ), - ], - ), - ], - ), - const SizedBox(height: 12), - Row( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - if (log.latitude != null && - log.longitude != null) - GestureDetector( - onTap: () { - final lat = double.tryParse(log - .latitude - .toString()) ?? - 0.0; - final lon = double.tryParse(log - .longitude - .toString()) ?? - 0.0; - if (lat >= -90 && - lat <= 90 && - lon >= -180 && - lon <= 180) { - _openGoogleMaps( - context, lat, lon); - } else { - ScaffoldMessenger.of(context) - .showSnackBar( - const SnackBar( - content: Text( - 'Invalid location coordinates')), - ); - } - }, - child: const Padding( - padding: - EdgeInsets.only(right: 8.0), - child: Icon(Icons.location_on, - size: 18, color: Colors.blue), - ), + backgroundColor: Colors.transparent, + builder: (context) => BaseBottomSheet( + title: "Attendance Log", + onCancel: () => Navigator.pop(context), + onSubmit: () => Navigator.pop(context), + showButtons: false, + child: attendanceController.attendenceLogsView.isEmpty + ? Padding( + padding: const EdgeInsets.symmetric(vertical: 24.0), + child: Column( + children: const [ + Icon(Icons.info_outline, size: 40, color: Colors.grey), + SizedBox(height: 8), + Text("No attendance logs available."), + ], + ), + ) + : ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: attendanceController.attendenceLogsView.length, + separatorBuilder: (_, __) => const SizedBox(height: 16), + itemBuilder: (_, index) { + final log = attendanceController.attendenceLogsView[index]; + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceVariant, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 6, + offset: const Offset(0, 2), + ) + ], + ), + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + flex: 3, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _getLogIcon(log), + const SizedBox(width: 10), + Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + MyText.bodyLarge( + log.formattedDate ?? '-', + fontWeight: 600, ), - Expanded( - child: MyText.bodyMedium( - log.comment?.isNotEmpty == true - ? log.comment - : "No description provided", - fontWeight: 500, + MyText.bodySmall( + "Time: ${log.formattedTime ?? '-'}", + color: Colors.grey[700], ), - ), - ], - ), - ], - ), - ), - const SizedBox(width: 16), - if (log.thumbPreSignedUrl != null) - GestureDetector( - onTap: () { - if (log.preSignedUrl != null) { - _showImageDialog( - context, log.preSignedUrl!); - } - }, - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.network( - log.thumbPreSignedUrl!, - height: 60, - width: 60, - fit: BoxFit.cover, - errorBuilder: - (context, error, stackTrace) { - return const Icon(Icons.broken_image, - size: 20, color: Colors.grey); - }, - ), + ], + ), + ], ), - ) - else - const Icon(Icons.broken_image, - size: 20, color: Colors.grey), - ], - ), - ], - ), - ); - }, - ) - ], - ), - ), + const SizedBox(height: 12), + Row( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + if (log.latitude != null && + log.longitude != null) + GestureDetector( + onTap: () { + final lat = double.tryParse( + log.latitude.toString()) ?? + 0.0; + final lon = double.tryParse( + log.longitude.toString()) ?? + 0.0; + if (lat >= -90 && + lat <= 90 && + lon >= -180 && + lon <= 180) { + _openGoogleMaps( + context, lat, lon); + } else { + ScaffoldMessenger.of(context) + .showSnackBar( + const SnackBar( + content: Text( + 'Invalid location coordinates')), + ); + } + }, + child: const Padding( + padding: + EdgeInsets.only(right: 8.0), + child: Icon(Icons.location_on, + size: 18, color: Colors.blue), + ), + ), + Expanded( + child: MyText.bodyMedium( + log.comment?.isNotEmpty == true + ? log.comment + : "No description provided", + fontWeight: 500, + ), + ), + ], + ), + ], + ), + ), + const SizedBox(width: 16), + if (log.thumbPreSignedUrl != null) + GestureDetector( + onTap: () { + if (log.preSignedUrl != null) { + _showImageDialog( + context, log.preSignedUrl!); + } + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + log.thumbPreSignedUrl!, + height: 60, + width: 60, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return const Icon(Icons.broken_image, + size: 20, color: Colors.grey); + }, + ), + ), + ) + else + const Icon(Icons.broken_image, + size: 20, color: Colors.grey), + ], + ), + ], + ), + ); + }, + ), ), ); } diff --git a/lib/view/dashboard/Attendence/attendance_screen.dart b/lib/view/dashboard/Attendence/attendance_screen.dart index 1f019ef..f2fcda7 100644 --- a/lib/view/dashboard/Attendence/attendance_screen.dart +++ b/lib/view/dashboard/Attendence/attendance_screen.dart @@ -157,7 +157,8 @@ class _AttendanceScreenState extends State with UIMixin { Map>( context: context, isScrollControlled: true, - backgroundColor: Colors.white, + + backgroundColor: Colors.transparent, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical( top: Radius.circular(12)), From 6d29d444fa290f4399f40a9bf55bc942af3899a6 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Thu, 31 Jul 2025 18:17:48 +0530 Subject: [PATCH 35/65] feat: refactor AddEmployeeBottomSheet and AssignProjectBottomSheet for improved UI and functionality --- .../employees/add_employee_bottom_sheet.dart | 611 ++++++++---------- .../assign_employee_bottom_sheet.dart | 348 ++++------ 2 files changed, 386 insertions(+), 573 deletions(-) diff --git a/lib/model/employees/add_employee_bottom_sheet.dart b/lib/model/employees/add_employee_bottom_sheet.dart index f111c4f..91fee4e 100644 --- a/lib/model/employees/add_employee_bottom_sheet.dart +++ b/lib/model/employees/add_employee_bottom_sheet.dart @@ -1,13 +1,13 @@ import 'package:flutter/material.dart'; -import 'package:get/get.dart'; import 'package:flutter/services.dart'; - +import 'package:get/get.dart'; import 'package:marco/controller/dashboard/add_employee_controller.dart'; import 'package:marco/controller/dashboard/employees_screen_controller.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text_style.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; class AddEmployeeBottomSheet extends StatefulWidget { @override @@ -18,69 +18,110 @@ class _AddEmployeeBottomSheetState extends State with UIMixin { final AddEmployeeController _controller = Get.put(AddEmployeeController()); - late TextEditingController genderController; - late TextEditingController roleController; - @override - void initState() { - super.initState(); - genderController = TextEditingController(); - roleController = TextEditingController(); - } - - RelativeRect _popupMenuPosition(BuildContext context) { - final RenderBox overlay = - Overlay.of(context).context.findRenderObject() as RenderBox; - return RelativeRect.fromLTRB(100, 300, overlay.size.width - 100, 0); - } - - void _showGenderPopup(BuildContext context) async { - final selected = await showMenu( - context: context, - position: _popupMenuPosition(context), - items: Gender.values.map((gender) { - return PopupMenuItem( - value: gender, - child: Text(gender.name.capitalizeFirst!), + Widget build(BuildContext context) { + return GetBuilder( + init: _controller, + builder: (_) { + return BaseBottomSheet( + title: "Add Employee", + onCancel: () => Navigator.pop(context), + onSubmit: _handleSubmit, + child: Form( + key: _controller.basicValidator.formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionLabel("Personal Info"), + MySpacing.height(16), + _inputWithIcon( + label: "First Name", + hint: "e.g., John", + icon: Icons.person, + controller: + _controller.basicValidator.getController('first_name')!, + validator: + _controller.basicValidator.getValidation('first_name'), + ), + MySpacing.height(16), + _inputWithIcon( + label: "Last Name", + hint: "e.g., Doe", + icon: Icons.person_outline, + controller: + _controller.basicValidator.getController('last_name')!, + validator: + _controller.basicValidator.getValidation('last_name'), + ), + MySpacing.height(16), + _sectionLabel("Contact Details"), + MySpacing.height(16), + _buildPhoneInput(context), + MySpacing.height(24), + _sectionLabel("Other Details"), + MySpacing.height(16), + _buildDropdownField( + label: "Gender", + value: _controller.selectedGender?.name.capitalizeFirst ?? '', + hint: "Select Gender", + onTap: () => _showGenderPopup(context), + ), + MySpacing.height(16), + _buildDropdownField( + label: "Role", + value: _controller.roles.firstWhereOrNull((role) => + role['id'] == _controller.selectedRoleId)?['name'] ?? + "", + hint: "Select Role", + onTap: () => _showRolePopup(context), + ), + ], + ), + ), ); - }).toList(), + }, ); + } - if (selected != null) { - _controller.onGenderSelected(selected); + // Submit logic + Future _handleSubmit() async { + final result = await _controller.createEmployees(); + + if (result != null && result['success'] == true) { + final employeeData = result['data']; // ✅ Safe now + final employeeController = Get.find(); + final projectId = employeeController.selectedProjectId; + + if (projectId == null) { + await employeeController.fetchAllEmployees(); + } else { + await employeeController.fetchEmployeesByProject(projectId); + } + + employeeController.update(['employee_screen_controller']); + + _controller.basicValidator.getController("first_name")?.clear(); + _controller.basicValidator.getController("last_name")?.clear(); + _controller.basicValidator.getController("phone_number")?.clear(); + _controller.selectedGender = null; + _controller.selectedRoleId = null; _controller.update(); + + Navigator.pop(context, employeeData); } } - void _showRolePopup(BuildContext context) async { - final selected = await showMenu( - context: context, - position: _popupMenuPosition(context), - items: _controller.roles.map((role) { - return PopupMenuItem( - value: role['id'], - child: Text(role['name']), - ); - }).toList(), - ); - - if (selected != null) { - _controller.onRoleSelected(selected); - _controller.update(); - } - } - - Widget _sectionLabel(String title) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.labelLarge(title, fontWeight: 600), - MySpacing.height(4), - Divider(thickness: 1, color: Colors.grey.shade200), - ], - ); - } + // Section label widget + Widget _sectionLabel(String title) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.labelLarge(title, fontWeight: 600), + MySpacing.height(4), + Divider(thickness: 1, color: Colors.grey.shade200), + ], + ); + // Input field with icon Widget _inputWithIcon({ required String label, required String hint, @@ -104,6 +145,124 @@ class _AddEmployeeBottomSheetState extends State ); } + // Phone input with country code selector + Widget _buildPhoneInput(BuildContext context) { + return Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(12), + color: Colors.grey.shade100, + ), + child: PopupMenuButton>( + onSelected: (country) { + _controller.selectedCountryCode = country['code']!; + _controller.update(); + }, + itemBuilder: (context) => [ + PopupMenuItem( + enabled: false, + padding: EdgeInsets.zero, + child: SizedBox( + height: 200, + width: 100, + child: ListView( + children: _controller.countries.map((country) { + return ListTile( + dense: true, + title: Text("${country['name']} (${country['code']})"), + onTap: () => Navigator.pop(context, country), + ); + }).toList(), + ), + ), + ), + ], + child: Row( + children: [ + Text(_controller.selectedCountryCode), + const Icon(Icons.arrow_drop_down), + ], + ), + ), + ), + MySpacing.width(12), + Expanded( + child: TextFormField( + controller: + _controller.basicValidator.getController('phone_number'), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return "Phone number is required"; + } + + final digitsOnly = value.trim(); + final minLength = _controller + .minDigitsPerCountry[_controller.selectedCountryCode] ?? + 7; + final maxLength = _controller + .maxDigitsPerCountry[_controller.selectedCountryCode] ?? + 15; + + if (!RegExp(r'^[0-9]+$').hasMatch(digitsOnly)) { + return "Only digits allowed"; + } + + if (digitsOnly.length < minLength || + digitsOnly.length > maxLength) { + return "Between $minLength–$maxLength digits"; + } + + return null; + }, + keyboardType: TextInputType.phone, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(15), + ], + decoration: _inputDecoration("e.g., 9876543210").copyWith( + suffixIcon: IconButton( + icon: const Icon(Icons.contacts), + onPressed: () => _controller.pickContact(context), + ), + ), + ), + ), + ], + ); + } + + // Gender/Role field (read-only dropdown) + Widget _buildDropdownField({ + required String label, + required String value, + required String hint, + required VoidCallback onTap, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.labelMedium(label), + MySpacing.height(8), + GestureDetector( + onTap: onTap, + child: AbsorbPointer( + child: TextFormField( + readOnly: true, + controller: TextEditingController(text: value), + decoration: _inputDecoration(hint).copyWith( + suffixIcon: const Icon(Icons.expand_more), + ), + ), + ), + ), + ], + ); + } + + // Common input decoration InputDecoration _inputDecoration(String hint) { return InputDecoration( hintText: hint, @@ -120,311 +279,53 @@ class _AddEmployeeBottomSheetState extends State ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: Colors.blueAccent, width: 1.5), + borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5), ), contentPadding: MySpacing.all(16), ); } - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return GetBuilder( - init: _controller, - builder: (_) { - return SingleChildScrollView( - padding: MediaQuery.of(context).viewInsets, - child: Container( - decoration: BoxDecoration( - color: theme.cardColor, - borderRadius: - const BorderRadius.vertical(top: Radius.circular(24)), - boxShadow: const [ - BoxShadow( - color: Colors.black12, - blurRadius: 12, - offset: Offset(0, -2)) - ], - ), - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Drag Handle - Container( - width: 40, - height: 5, - decoration: BoxDecoration( - color: Colors.grey.shade300, - borderRadius: BorderRadius.circular(10), - ), - ), - MySpacing.height(12), - Text("Add Employee", - style: MyTextStyle.titleLarge(fontWeight: 700)), - MySpacing.height(24), - Form( - key: _controller.basicValidator.formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _sectionLabel("Personal Info"), - MySpacing.height(16), - _inputWithIcon( - label: "First Name", - hint: "e.g., John", - icon: Icons.person, - controller: _controller.basicValidator - .getController('first_name')!, - validator: _controller.basicValidator - .getValidation('first_name'), - ), - MySpacing.height(16), - _inputWithIcon( - label: "Last Name", - hint: "e.g., Doe", - icon: Icons.person_outline, - controller: _controller.basicValidator - .getController('last_name')!, - validator: _controller.basicValidator - .getValidation('last_name'), - ), - MySpacing.height(16), - _sectionLabel("Contact Details"), - MySpacing.height(16), - MyText.labelMedium("Phone Number"), - MySpacing.height(8), - Row( - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 14), - decoration: BoxDecoration( - border: Border.all(color: Colors.grey.shade300), - borderRadius: BorderRadius.circular(12), - color: Colors.grey.shade100, - ), - child: PopupMenuButton>( - onSelected: (country) { - _controller.selectedCountryCode = - country['code']!; - _controller.update(); - }, - itemBuilder: (context) => [ - PopupMenuItem( - enabled: false, - padding: EdgeInsets.zero, - child: SizedBox( - height: 200, - width: 100, - child: ListView( - children: _controller.countries - .map((country) { - return ListTile( - dense: true, - title: Text( - "${country['name']} (${country['code']})"), - onTap: () => - Navigator.pop(context, country), - ); - }).toList(), - ), - ), - ), - ], - child: Row( - children: [ - Text(_controller.selectedCountryCode), - const Icon(Icons.arrow_drop_down), - ], - ), - ), - ), - MySpacing.width(12), - Expanded( - child: TextFormField( - controller: _controller.basicValidator - .getController('phone_number'), - validator: (value) { - if (value == null || value.trim().isEmpty) { - return "Phone number is required"; - } - - final digitsOnly = value.trim(); - final minLength = _controller - .minDigitsPerCountry[ - _controller.selectedCountryCode] ?? - 7; - final maxLength = _controller - .maxDigitsPerCountry[ - _controller.selectedCountryCode] ?? - 15; - - if (!RegExp(r'^[0-9]+$') - .hasMatch(digitsOnly)) { - return "Only digits allowed"; - } - - if (digitsOnly.length < minLength || - digitsOnly.length > maxLength) { - return "Between $minLength–$maxLength digits"; - } - - return null; - }, - keyboardType: TextInputType.phone, - inputFormatters: [ - // Allow only digits - FilteringTextInputFormatter.digitsOnly, - // Limit to 10 digits - LengthLimitingTextInputFormatter(10), - ], - decoration: _inputDecoration("e.g., 9876543210") - .copyWith( - suffixIcon: IconButton( - icon: const Icon(Icons.contacts), - onPressed: () => - _controller.pickContact(context), - ), - ), - ), - ), - ], - ), - MySpacing.height(24), - _sectionLabel("Other Details"), - MySpacing.height(16), - MyText.labelMedium("Gender"), - MySpacing.height(8), - GestureDetector( - onTap: () => _showGenderPopup(context), - child: AbsorbPointer( - child: TextFormField( - readOnly: true, - controller: TextEditingController( - text: _controller - .selectedGender?.name.capitalizeFirst, - ), - decoration: - _inputDecoration("Select Gender").copyWith( - suffixIcon: const Icon(Icons.expand_more), - ), - ), - ), - ), - MySpacing.height(16), - MyText.labelMedium("Role"), - MySpacing.height(8), - GestureDetector( - onTap: () => _showRolePopup(context), - child: AbsorbPointer( - child: TextFormField( - readOnly: true, - controller: TextEditingController( - text: _controller.roles.firstWhereOrNull( - (role) => - role['id'] == - _controller.selectedRoleId, - )?['name'] ?? - "", - ), - decoration: - _inputDecoration("Select Role").copyWith( - suffixIcon: const Icon(Icons.expand_more), - ), - ), - ), - ), - MySpacing.height(16), - Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: () => Navigator.pop(context), - icon: - const Icon(Icons.close, color: Colors.red), - label: MyText.bodyMedium("Cancel", - color: Colors.red, fontWeight: 600), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: Colors.red), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12)), - padding: const EdgeInsets.symmetric( - horizontal: 20, vertical: 14), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: ElevatedButton.icon( - onPressed: () async { - if (_controller.basicValidator - .validateForm()) { - final result = - await _controller.createEmployees(); - - if (result != null && - result['success'] == true) { - final employeeData = result['data']; - final employeeController = - Get.find(); - final projectId = - employeeController.selectedProjectId; - - if (projectId == null) { - await employeeController - .fetchAllEmployees(); - } else { - await employeeController - .fetchEmployeesByProject(projectId); - } - - employeeController.update( - ['employee_screen_controller']); - - _controller.basicValidator - .getController("first_name") - ?.clear(); - _controller.basicValidator - .getController("last_name") - ?.clear(); - _controller.basicValidator - .getController("phone_number") - ?.clear(); - _controller.selectedGender = null; - _controller.selectedRoleId = null; - _controller.update(); - - Navigator.pop(context, employeeData); - } - } - }, - icon: const Icon(Icons.check_circle_outline, - color: Colors.white), - label: MyText.bodyMedium("Save", - color: Colors.white, fontWeight: 600), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.indigo, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12)), - padding: const EdgeInsets.symmetric( - horizontal: 28, vertical: 14), - ), - ), - ), - ], - ), - ], - ), - ), - ], - ), - ), - ), + // Gender popup menu + void _showGenderPopup(BuildContext context) async { + final selected = await showMenu( + context: context, + position: _popupMenuPosition(context), + items: Gender.values.map((gender) { + return PopupMenuItem( + value: gender, + child: Text(gender.name.capitalizeFirst!), ); - }, + }).toList(), ); + + if (selected != null) { + _controller.onGenderSelected(selected); + _controller.update(); + } + } + + // Role popup menu + void _showRolePopup(BuildContext context) async { + final selected = await showMenu( + context: context, + position: _popupMenuPosition(context), + items: _controller.roles.map((role) { + return PopupMenuItem( + value: role['id'], + child: Text(role['name']), + ); + }).toList(), + ); + + if (selected != null) { + _controller.onRoleSelected(selected); + _controller.update(); + } + } + + RelativeRect _popupMenuPosition(BuildContext context) { + final RenderBox overlay = + Overlay.of(context).context.findRenderObject() as RenderBox; + return RelativeRect.fromLTRB(100, 300, overlay.size.width - 100, 0); } } diff --git a/lib/view/employees/assign_employee_bottom_sheet.dart b/lib/view/employees/assign_employee_bottom_sheet.dart index a689024..61440ea 100644 --- a/lib/view/employees/assign_employee_bottom_sheet.dart +++ b/lib/view/employees/assign_employee_bottom_sheet.dart @@ -2,9 +2,10 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/controller/employee/assign_projects_controller.dart'; import 'package:marco/model/global_project_model.dart'; -import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; class AssignProjectBottomSheet extends StatefulWidget { final String employeeId; @@ -23,6 +24,7 @@ class AssignProjectBottomSheet extends StatefulWidget { class _AssignProjectBottomSheetState extends State { late final AssignProjectController assignController; + final ScrollController _scrollController = ScrollController(); @override void initState() { @@ -38,229 +40,139 @@ class _AssignProjectBottomSheetState extends State { @override Widget build(BuildContext context) { - final theme = Theme.of(context); + return GetBuilder( + tag: '${widget.employeeId}_${widget.jobRoleId}', + builder: (_) { + return BaseBottomSheet( + title: "Assign to Project", + onCancel: () => Navigator.pop(context), + onSubmit: _handleAssign, + submitText: "Assign", + child: Obx(() { + if (assignController.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } - return SafeArea( - top: false, - child: DraggableScrollableSheet( - expand: false, - maxChildSize: 0.9, - minChildSize: 0.4, - initialChildSize: 0.7, - builder: (_, scrollController) { - return Container( - decoration: BoxDecoration( - color: theme.cardColor, - borderRadius: - const BorderRadius.vertical(top: Radius.circular(24)), - boxShadow: const [ - BoxShadow( - color: Colors.black12, - blurRadius: 12, - offset: Offset(0, -2), + final projects = assignController.allProjects; + if (projects.isEmpty) { + return const Center(child: Text('No projects available.')); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodySmall( + 'Select the projects to assign this employee.', + color: Colors.grey[600], + ), + MySpacing.height(8), + + // Select All + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Projects (${projects.length})', + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 16, + ), + ), + TextButton( + onPressed: () { + assignController.toggleSelectAll(); + }, + child: Obx(() { + return Text( + assignController.areAllSelected() + ? 'Deselect All' + : 'Select All', + style: const TextStyle( + color: Colors.blueAccent, + fontWeight: FontWeight.w600, + ), + ); + }), + ), + ], + ), + + // List of Projects + SizedBox( + height: 300, + child: ListView.builder( + controller: _scrollController, + itemCount: projects.length, + itemBuilder: (context, index) { + final GlobalProjectModel project = projects[index]; + return Obx(() { + final bool isSelected = + assignController.isProjectSelected( + project.id.toString(), + ); + return Theme( + data: Theme.of(context).copyWith( + checkboxTheme: CheckboxThemeData( + fillColor: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.selected) + ? Colors.blueAccent + : Colors.white, + ), + side: const BorderSide( + color: Colors.black, + width: 2, + ), + checkColor: + WidgetStateProperty.all(Colors.white), + ), + ), + child: CheckboxListTile( + dense: true, + value: isSelected, + title: Text( + project.name, + style: const TextStyle( + fontWeight: FontWeight.w500, + fontSize: 14, + ), + ), + onChanged: (checked) { + assignController.toggleProjectSelection( + project.id.toString(), + checked ?? false, + ); + }, + activeColor: Colors.blueAccent, + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + visualDensity: const VisualDensity( + horizontal: -4, + vertical: -4, + ), + ), + ); + }); + }, + ), ), ], - ), - padding: MySpacing.all(16), - child: Obx(() { - if (assignController.isLoading.value) { - return const Center(child: CircularProgressIndicator()); - } - - final projects = assignController.allProjects; - if (projects.isEmpty) { - return const Center(child: Text('No projects available.')); - } - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Drag Handle - Center( - child: Container( - width: 40, - height: 5, - decoration: BoxDecoration( - color: Colors.grey.shade300, - borderRadius: BorderRadius.circular(10), - ), - ), - ), - MySpacing.height(12), - - // Header - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - MyText.titleMedium('Assign to Project', fontWeight: 700), - ], - ), - MySpacing.height(4), - - // Sub Info - MyText.bodySmall( - 'Select the projects to assign this employee.', - color: Colors.grey[600], - ), - MySpacing.height(8), - - // Select All Toggle - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Projects (${projects.length})', - style: const TextStyle( - fontWeight: FontWeight.w600, - fontSize: 16, - ), - ), - TextButton( - onPressed: () { - assignController.toggleSelectAll(); - }, - child: Obx(() { - return Text( - assignController.areAllSelected() - ? 'Deselect All' - : 'Select All', - style: const TextStyle( - color: Colors.blueAccent, - fontWeight: FontWeight.w600, - ), - ); - }), - ), - ], - ), - - // Project List - Expanded( - child: ListView.builder( - controller: scrollController, - itemCount: projects.length, - padding: EdgeInsets.zero, - itemBuilder: (context, index) { - final GlobalProjectModel project = projects[index]; - return Obx(() { - final bool isSelected = - assignController.isProjectSelected( - project.id.toString(), - ); - return Theme( - data: Theme.of(context).copyWith( - checkboxTheme: CheckboxThemeData( - fillColor: - WidgetStateProperty.resolveWith( - (states) { - if (states.contains(WidgetState.selected)) { - return Colors.blueAccent; - } - return Colors.white; - }, - ), - side: const BorderSide( - color: Colors.black, - width: 2, - ), - checkColor: - WidgetStateProperty.all(Colors.white), - ), - ), - child: CheckboxListTile( - dense: true, - value: isSelected, - title: Text( - project.name, - style: const TextStyle( - fontWeight: FontWeight.w500, - fontSize: 14, - ), - ), - onChanged: (checked) { - assignController.toggleProjectSelection( - project.id.toString(), - checked ?? false, - ); - }, - activeColor: Colors.blueAccent, - controlAffinity: ListTileControlAffinity.leading, - contentPadding: EdgeInsets.zero, - visualDensity: const VisualDensity( - horizontal: -4, - vertical: -4, - ), - ), - ); - }); - }, - ), - ), - MySpacing.height(16), - - // Cancel & Save Buttons - Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: () => Navigator.pop(context), - icon: const Icon(Icons.close, color: Colors.red), - label: MyText.bodyMedium("Cancel", - color: Colors.red, fontWeight: 600), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: Colors.red), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 7, - ), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: ElevatedButton.icon( - onPressed: () async { - if (assignController.selectedProjects.isEmpty) { - showAppSnackbar( - title: "Error", - message: "Please select at least one project.", - type: SnackbarType.error, - ); - return; - } - await _assignProjects(); - }, - icon: const Icon(Icons.check_circle_outline, - color: Colors.white), - label: MyText.bodyMedium("Assign", - color: Colors.white, fontWeight: 600), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.indigo, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 7, - ), - ), - ), - ), - ], - ), - ], - ); - }), - ); - }, - ), + ); + }), + ); + }, ); } - Future _assignProjects() async { + Future _handleAssign() async { + if (assignController.selectedProjects.isEmpty) { + showAppSnackbar( + title: "Error", + message: "Please select at least one project.", + type: SnackbarType.error, + ); + return; + } + final success = await assignController.assignProjectsToEmployee(); if (success) { Get.back(); From f5eed0a0b9bfe1ee7a1413e18d33f38bf920ddde Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Fri, 1 Aug 2025 16:21:24 +0530 Subject: [PATCH 36/65] Refactor Expense Filter Bottom Sheet for improved readability and maintainability - Extracted widget builders for Project, Expense Status, Date Range, Paid By, and Created By filters into separate methods. - Simplified date selection logic by creating a reusable _selectDate method. - Centralized input decoration for text fields. - Updated Expense Screen to use local state for search query and history view toggle. - Enhanced filtering logic for expenses based on search query and date. - Improved UI elements in Daily Progress Report and Daily Task Planning screens, including padding and border radius adjustments. --- .../expense/expense_screen_controller.dart | 7 + .../daily_task_planing_controller.dart | 61 +- lib/helpers/utils/base_bottom_sheet.dart | 19 +- .../assign_task_bottom_sheet .dart | 365 ++--- .../comment_task_bottom_sheet.dart | 1274 +++++++---------- .../report_action_bottom_sheet.dart | 458 +----- .../report_action_widgets.dart | 392 +++++ .../report_task_bottom_sheet.dart | 692 ++++----- .../expense/expense_filter_bottom_sheet.dart | 363 +++-- lib/view/expense/expense_screen.dart | 322 ++--- lib/view/taskPlaning/daily_progress.dart | 6 +- lib/view/taskPlaning/daily_task_planing.dart | 8 +- 12 files changed, 1803 insertions(+), 2164 deletions(-) create mode 100644 lib/model/dailyTaskPlaning/report_action_widgets.dart diff --git a/lib/controller/expense/expense_screen_controller.dart b/lib/controller/expense/expense_screen_controller.dart index 0e9ca46..56f8e58 100644 --- a/lib/controller/expense/expense_screen_controller.dart +++ b/lib/controller/expense/expense_screen_controller.dart @@ -29,6 +29,12 @@ class ExpenseController extends GetxController { final RxList selectedPaidByEmployees = [].obs; final RxList selectedCreatedByEmployees = [].obs; + final RxString selectedDateType = 'Transaction Date'.obs; + + final List dateTypes = [ + 'Transaction Date', + 'Created At', + ]; int _pageSize = 20; int _pageNumber = 1; @@ -85,6 +91,7 @@ class ExpenseController extends GetxController { paidByIds ?? selectedPaidByEmployees.map((e) => e.id).toList(), "startDate": (startDate ?? this.startDate.value)?.toIso8601String(), "endDate": (endDate ?? this.endDate.value)?.toIso8601String(), + "isTransactionDate": selectedDateType.value == 'Transaction Date', }; try { diff --git a/lib/controller/task_planing/daily_task_planing_controller.dart b/lib/controller/task_planing/daily_task_planing_controller.dart index 2e0a05c..3a302b6 100644 --- a/lib/controller/task_planing/daily_task_planing_controller.dart +++ b/lib/controller/task_planing/daily_task_planing_controller.dart @@ -17,6 +17,7 @@ class DailyTaskPlaningController extends GetxController { MyFormValidator basicValidator = MyFormValidator(); List> roles = []; + RxBool isAssigningTask = false.obs; RxnString selectedRoleId = RxnString(); RxBool isLoading = false.obs; @@ -46,16 +47,21 @@ class DailyTaskPlaningController extends GetxController { } void updateSelectedEmployees() { - final selected = employees - .where((e) => uploadingStates[e.id]?.value == true) - .toList(); + final selected = + employees.where((e) => uploadingStates[e.id]?.value == true).toList(); selectedEmployees.value = selected; - logSafe("Updated selected employees", level: LogLevel.debug, ); + logSafe( + "Updated selected employees", + level: LogLevel.debug, + ); } void onRoleSelected(String? roleId) { selectedRoleId.value = roleId; - logSafe("Role selected", level: LogLevel.info, ); + logSafe( + "Role selected", + level: LogLevel.info, + ); } Future fetchRoles() async { @@ -77,6 +83,7 @@ class DailyTaskPlaningController extends GetxController { required List taskTeam, DateTime? assignmentDate, }) async { + isAssigningTask.value = true; logSafe("Starting assign task...", level: LogLevel.info); final response = await ApiService.assignDailyTask( @@ -87,6 +94,8 @@ class DailyTaskPlaningController extends GetxController { assignmentDate: assignmentDate, ); + isAssigningTask.value = false; + if (response == true) { logSafe("Task assigned successfully", level: LogLevel.info); showAppSnackbar( @@ -111,15 +120,18 @@ class DailyTaskPlaningController extends GetxController { try { final response = await ApiService.getProjects(); if (response?.isEmpty ?? true) { - logSafe("No project data found or API call failed", level: LogLevel.warning); + logSafe("No project data found or API call failed", + level: LogLevel.warning); return; } projects = response!.map((json) => ProjectModel.fromJson(json)).toList(); - logSafe("Projects fetched: ${projects.length} projects loaded", level: LogLevel.info); + logSafe("Projects fetched: ${projects.length} projects loaded", + level: LogLevel.info); update(); } catch (e, stack) { - logSafe("Error fetching projects", level: LogLevel.error, error: e, stackTrace: stack); + logSafe("Error fetching projects", + level: LogLevel.error, error: e, stackTrace: stack); } finally { isLoading.value = false; } @@ -137,12 +149,16 @@ class DailyTaskPlaningController extends GetxController { final data = response?['data']; if (data != null) { dailyTasks = [TaskPlanningDetailsModel.fromJson(data)]; - logSafe("Daily task Planning Details fetched", level: LogLevel.info, ); + logSafe( + "Daily task Planning Details fetched", + level: LogLevel.info, + ); } else { logSafe("Data field is null", level: LogLevel.warning); } } catch (e, stack) { - logSafe("Error fetching daily task data", level: LogLevel.error, error: e, stackTrace: stack); + logSafe("Error fetching daily task data", + level: LogLevel.error, error: e, stackTrace: stack); } finally { isLoading.value = false; update(); @@ -151,7 +167,8 @@ class DailyTaskPlaningController extends GetxController { Future fetchEmployeesByProject(String? projectId) async { if (projectId == null || projectId.isEmpty) { - logSafe("Project ID is required but was null or empty", level: LogLevel.error); + logSafe("Project ID is required but was null or empty", + level: LogLevel.error); return; } @@ -159,19 +176,29 @@ class DailyTaskPlaningController extends GetxController { try { final response = await ApiService.getAllEmployeesByProject(projectId); if (response != null && response.isNotEmpty) { - employees = response.map((json) => EmployeeModel.fromJson(json)).toList(); + employees = + response.map((json) => EmployeeModel.fromJson(json)).toList(); for (var emp in employees) { uploadingStates[emp.id] = false.obs; } - logSafe("Employees fetched: ${employees.length} for project $projectId", - level: LogLevel.info, ); + logSafe( + "Employees fetched: ${employees.length} for project $projectId", + level: LogLevel.info, + ); } else { employees = []; - logSafe("No employees found for project $projectId", level: LogLevel.warning, ); + logSafe( + "No employees found for project $projectId", + level: LogLevel.warning, + ); } } catch (e, stack) { - logSafe("Error fetching employees for project $projectId", - level: LogLevel.error, error: e, stackTrace: stack, ); + logSafe( + "Error fetching employees for project $projectId", + level: LogLevel.error, + error: e, + stackTrace: stack, + ); } finally { isLoading.value = false; update(); diff --git a/lib/helpers/utils/base_bottom_sheet.dart b/lib/helpers/utils/base_bottom_sheet.dart index 485f0dd..06f32a4 100644 --- a/lib/helpers/utils/base_bottom_sheet.dart +++ b/lib/helpers/utils/base_bottom_sheet.dart @@ -11,7 +11,8 @@ class BaseBottomSheet extends StatelessWidget { final String submitText; final Color submitColor; final IconData submitIcon; - final bool showButtons; + final bool showButtons; + final Widget? bottomContent; const BaseBottomSheet({ super.key, @@ -23,7 +24,8 @@ class BaseBottomSheet extends StatelessWidget { this.submitText = 'Submit', this.submitColor = Colors.indigo, this.submitIcon = Icons.check_circle_outline, - this.showButtons = true, + this.showButtons = true, + this.bottomContent, }); @override @@ -65,8 +67,11 @@ class BaseBottomSheet extends StatelessWidget { MyText.titleLarge(title, fontWeight: 700), MySpacing.height(12), child, - MySpacing.height(24), - if (showButtons) + + MySpacing.height(12), + + // 👇 Buttons (if enabled) + if (showButtons) ...[ Row( children: [ Expanded( @@ -108,6 +113,12 @@ class BaseBottomSheet extends StatelessWidget { ), ], ), + // 👇 Optional Bottom Content + if (bottomContent != null) ...[ + MySpacing.height(12), + bottomContent!, + ], + ], ], ), ), diff --git a/lib/model/dailyTaskPlaning/assign_task_bottom_sheet .dart b/lib/model/dailyTaskPlaning/assign_task_bottom_sheet .dart index 2e52a8a..f42826c 100644 --- a/lib/model/dailyTaskPlaning/assign_task_bottom_sheet .dart +++ b/lib/model/dailyTaskPlaning/assign_task_bottom_sheet .dart @@ -1,10 +1,11 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/controller/task_planing/daily_task_planing_controller.dart'; -import 'package:marco/helpers/widgets/my_text.dart'; -import 'package:marco/helpers/widgets/my_spacing.dart'; -import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/controller/project_controller.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; class AssignTaskBottomSheet extends StatefulWidget { final String workLocation; @@ -37,17 +38,9 @@ class _AssignTaskBottomSheetState extends State { final ProjectController projectController = Get.find(); final TextEditingController targetController = TextEditingController(); final TextEditingController descriptionController = TextEditingController(); - String? selectedProjectId; - final ScrollController _employeeListScrollController = ScrollController(); - @override - void dispose() { - _employeeListScrollController.dispose(); - targetController.dispose(); - descriptionController.dispose(); - super.dispose(); - } + String? selectedProjectId; @override void initState() { @@ -61,180 +54,105 @@ class _AssignTaskBottomSheetState extends State { }); } + @override + void dispose() { + _employeeListScrollController.dispose(); + targetController.dispose(); + descriptionController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - return SafeArea( - child: Container( - padding: MediaQuery.of(context).viewInsets.add(MySpacing.all(16)), - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + return Obx(() => BaseBottomSheet( + title: "Assign Task", + child: _buildAssignTaskForm(), + onCancel: () => Get.back(), + onSubmit: _onAssignTaskPressed, + isSubmitting: controller.isAssigningTask.value, + submitText: "Assign Task", + submitIcon: Icons.check_circle_outline, + submitColor: Colors.indigo, + )); + } + + Widget _buildAssignTaskForm() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _infoRow(Icons.location_on, "Work Location", + "${widget.buildingName} > ${widget.floorName} > ${widget.workAreaName} > ${widget.activityName}"), + Divider(), + _infoRow(Icons.pending_actions, "Pending Task of Activity", + "${widget.pendingTask}"), + Divider(), + GestureDetector( + onTap: _onRoleMenuPressed, + child: Row( children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Icon(Icons.assignment, color: Colors.black54), - SizedBox(width: 8), - MyText.titleMedium("Assign Task", - fontSize: 18, fontWeight: 600), - ], - ), - IconButton( - icon: const Icon(Icons.close), - onPressed: () => Get.back(), - ), - ], - ), - Divider(), - _infoRow(Icons.location_on, "Work Location", - "${widget.buildingName} > ${widget.floorName} > ${widget.workAreaName} > ${widget.activityName}"), - Divider(), - _infoRow(Icons.pending_actions, "Pending Task of Activity", - "${widget.pendingTask}"), - Divider(), - GestureDetector( - onTap: () { - final RenderBox overlay = Overlay.of(context) - .context - .findRenderObject() as RenderBox; - final Size screenSize = overlay.size; - - showMenu( - context: context, - position: RelativeRect.fromLTRB( - screenSize.width / 2 - 100, - screenSize.height / 2 - 20, - screenSize.width / 2 - 100, - screenSize.height / 2 - 20, - ), - items: [ - const PopupMenuItem( - value: 'all', - child: Text("All Roles"), - ), - ...controller.roles.map((role) { - return PopupMenuItem( - value: role['id'].toString(), - child: Text(role['name'] ?? 'Unknown Role'), - ); - }), - ], - ).then((value) { - if (value != null) { - controller.onRoleSelected(value == 'all' ? null : value); - } - }); - }, - child: Row( - children: [ - MyText.titleMedium("Select Team :", fontWeight: 600), - const SizedBox(width: 4), - Icon(Icons.tune, - color: const Color.fromARGB(255, 95, 132, 255)), - ], - ), - ), - MySpacing.height(8), - Container( - constraints: BoxConstraints(maxHeight: 150), - child: _buildEmployeeList(), - ), - MySpacing.height(8), - Obx(() { - if (controller.selectedEmployees.isEmpty) return Container(); - - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: Wrap( - spacing: 4, - runSpacing: 4, - children: controller.selectedEmployees.map((e) { - return Obx(() { - final isSelected = - controller.uploadingStates[e.id]?.value ?? false; - if (!isSelected) return Container(); - - return Chip( - label: Text(e.name, - style: const TextStyle(color: Colors.white)), - backgroundColor: - const Color.fromARGB(255, 95, 132, 255), - deleteIcon: - const Icon(Icons.close, color: Colors.white), - onDeleted: () { - controller.uploadingStates[e.id]?.value = false; - controller.updateSelectedEmployees(); - }, - ); - }); - }).toList(), - ), - ); - }), - _buildTextField( - icon: Icons.track_changes, - label: "Target for Today :", - controller: targetController, - hintText: "Enter target", - keyboardType: TextInputType.number, - validatorType: "target", - ), - MySpacing.height(24), - _buildTextField( - icon: Icons.description, - label: "Description :", - controller: descriptionController, - hintText: "Enter task description", - maxLines: 3, - validatorType: "description", - ), - MySpacing.height(24), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - OutlinedButton.icon( - onPressed: () => Get.back(), - icon: const Icon(Icons.close, color: Colors.red), - label: MyText.bodyMedium("Cancel", color: Colors.red), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: Colors.red), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.symmetric( - horizontal: 20, vertical: 14), - ), - ), - ElevatedButton.icon( - onPressed: _onAssignTaskPressed, - icon: const Icon(Icons.check_circle_outline, - color: Colors.white), - label: - MyText.bodyMedium("Assign Task", color: Colors.white), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.indigo, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.symmetric( - horizontal: 28, vertical: 14), - ), - ), - ], - ), + MyText.titleMedium("Select Team :", fontWeight: 600), + const SizedBox(width: 4), + const Icon(Icons.tune, color: Color.fromARGB(255, 95, 132, 255)), ], ), ), - ), + MySpacing.height(8), + Container( + constraints: const BoxConstraints(maxHeight: 150), + child: _buildEmployeeList(), + ), + MySpacing.height(8), + _buildSelectedEmployees(), + _buildTextField( + icon: Icons.track_changes, + label: "Target for Today :", + controller: targetController, + hintText: "Enter target", + keyboardType: TextInputType.number, + validatorType: "target", + ), + MySpacing.height(24), + _buildTextField( + icon: Icons.description, + label: "Description :", + controller: descriptionController, + hintText: "Enter task description", + maxLines: 3, + validatorType: "description", + ), + ], ); } + void _onRoleMenuPressed() { + final RenderBox overlay = + Overlay.of(context).context.findRenderObject() as RenderBox; + final Size screenSize = overlay.size; + + showMenu( + context: context, + position: RelativeRect.fromLTRB( + screenSize.width / 2 - 100, + screenSize.height / 2 - 20, + screenSize.width / 2 - 100, + screenSize.height / 2 - 20, + ), + items: [ + const PopupMenuItem(value: 'all', child: Text("All Roles")), + ...controller.roles.map((role) { + return PopupMenuItem( + value: role['id'].toString(), + child: Text(role['name'] ?? 'Unknown Role'), + ); + }), + ], + ).then((value) { + if (value != null) { + controller.onRoleSelected(value == 'all' ? null : value); + } + }); + } + Widget _buildEmployeeList() { return Obx(() { if (controller.isLoading.value) { @@ -255,49 +173,43 @@ class _AssignTaskBottomSheetState extends State { return Scrollbar( controller: _employeeListScrollController, thumbVisibility: true, - interactive: true, child: ListView.builder( controller: _employeeListScrollController, shrinkWrap: true, - physics: const AlwaysScrollableScrollPhysics(), itemCount: filteredEmployees.length, itemBuilder: (context, index) { final employee = filteredEmployees[index]; final rxBool = controller.uploadingStates[employee.id]; + return Obx(() => Padding( - padding: const EdgeInsets.symmetric(vertical: 0), + padding: const EdgeInsets.symmetric(vertical: 2), child: Row( children: [ - Theme( - data: Theme.of(context) - .copyWith(unselectedWidgetColor: Colors.black), - child: Checkbox( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(4), - side: const BorderSide(color: Colors.black), - ), - value: rxBool?.value ?? false, - onChanged: (bool? selected) { - if (rxBool != null) { - rxBool.value = selected ?? false; - controller.updateSelectedEmployees(); - } - }, - fillColor: - WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.selected)) { - return const Color.fromARGB(255, 95, 132, 255); - } - return Colors.transparent; - }), - checkColor: Colors.white, - side: const BorderSide(color: Colors.black), + Checkbox( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), ), + value: rxBool?.value ?? false, + onChanged: (bool? selected) { + if (rxBool != null) { + rxBool.value = selected ?? false; + controller.updateSelectedEmployees(); + } + }, + fillColor: + WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return const Color.fromARGB(255, 95, 132, 255); + } + return Colors.transparent; + }), + checkColor: Colors.white, + side: const BorderSide(color: Colors.black), ), const SizedBox(width: 8), Expanded( child: Text(employee.name, - style: TextStyle(fontSize: 14))), + style: const TextStyle(fontSize: 14))), ], ), )); @@ -307,6 +219,38 @@ class _AssignTaskBottomSheetState extends State { }); } + Widget _buildSelectedEmployees() { + return Obx(() { + if (controller.selectedEmployees.isEmpty) return Container(); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Wrap( + spacing: 4, + runSpacing: 4, + children: controller.selectedEmployees.map((e) { + return Obx(() { + final isSelected = + controller.uploadingStates[e.id]?.value ?? false; + if (!isSelected) return Container(); + + return Chip( + label: + Text(e.name, style: const TextStyle(color: Colors.white)), + backgroundColor: const Color.fromARGB(255, 95, 132, 255), + deleteIcon: const Icon(Icons.close, color: Colors.white), + onDeleted: () { + controller.uploadingStates[e.id]?.value = false; + controller.updateSelectedEmployees(); + }, + ); + }); + }).toList(), + ), + ); + }); + } + Widget _buildTextField({ required IconData icon, required String label, @@ -331,13 +275,12 @@ class _AssignTaskBottomSheetState extends State { controller: controller, keyboardType: keyboardType, maxLines: maxLines, - decoration: InputDecoration( - hintText: hintText, - border: const OutlineInputBorder(), + decoration: const InputDecoration( + hintText: '', + border: OutlineInputBorder(), ), - validator: (value) => this - .controller - .formFieldValidator(value, fieldType: validatorType), + validator: (value) => + this.controller.formFieldValidator(value, fieldType: validatorType), ), ], ); diff --git a/lib/model/dailyTaskPlaning/comment_task_bottom_sheet.dart b/lib/model/dailyTaskPlaning/comment_task_bottom_sheet.dart index a7f8ee1..d5c37cb 100644 --- a/lib/model/dailyTaskPlaning/comment_task_bottom_sheet.dart +++ b/lib/model/dailyTaskPlaning/comment_task_bottom_sheet.dart @@ -1,5 +1,9 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:intl/intl.dart'; +import 'dart:io'; +import 'dart:math' as math; +// --- Assumed Imports (ensure these paths are correct in your project) --- import 'package:marco/controller/task_planing/report_task_controller.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; import 'package:marco/helpers/widgets/my_button.dart'; @@ -8,17 +12,32 @@ import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text_style.dart'; import 'package:marco/helpers/widgets/avatar.dart'; import 'package:marco/helpers/widgets/my_team_model_sheet.dart'; -import 'package:intl/intl.dart'; import 'package:marco/helpers/widgets/image_viewer_dialog.dart'; -import 'dart:io'; import 'package:marco/model/dailyTaskPlaning/create_task_botom_sheet.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; +// --- Form Field Keys (Unchanged) --- +class _FormFieldKeys { + static const String assignedDate = 'assigned_date'; + static const String assignedBy = 'assigned_by'; + static const String workArea = 'work_area'; + static const String activity = 'activity'; + static const String plannedWork = 'planned_work'; + static const String completedWork = 'completed_work'; + static const String teamMembers = 'team_members'; + static const String assigned = 'assigned'; + static const String taskId = 'task_id'; + static const String comment = 'comment'; +} + +// --- Main Widget: CommentTaskBottomSheet --- class CommentTaskBottomSheet extends StatefulWidget { final Map taskData; final VoidCallback? onCommentSuccess; final String taskDataId; final String workAreaId; final String activityId; + const CommentTaskBottomSheet({ super.key, required this.taskData, @@ -39,413 +58,216 @@ class _Member { class _CommentTaskBottomSheetState extends State with UIMixin { - late ReportTaskController controller; - final ScrollController _scrollController = ScrollController(); + late final ReportTaskController controller; + List> _sortedComments = []; + @override void initState() { super.initState(); controller = Get.put(ReportTaskController(), tag: widget.taskData['taskId'] ?? UniqueKey().toString()); - final data = widget.taskData; - controller.basicValidator.getController('assigned_date')?.text = - data['assignedOn'] ?? ''; - controller.basicValidator.getController('assigned_by')?.text = - data['assignedBy'] ?? ''; - controller.basicValidator.getController('work_area')?.text = - data['location'] ?? ''; - controller.basicValidator.getController('activity')?.text = - data['activity'] ?? ''; - controller.basicValidator.getController('planned_work')?.text = - data['plannedWork'] ?? ''; - controller.basicValidator.getController('completed_work')?.text = - data['completedWork'] ?? ''; - controller.basicValidator.getController('team_members')?.text = - (data['teamMembers'] as List).join(', '); - controller.basicValidator.getController('assigned')?.text = - data['assigned'] ?? ''; - controller.basicValidator.getController('task_id')?.text = - data['taskId'] ?? ''; - controller.basicValidator.getController('comment')?.clear(); - controller.selectedImages.clear(); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (_scrollController.hasClients) { - _scrollController.jumpTo(_scrollController.position.maxScrollExtent); - } + _initializeControllerData(); + + final comments = List>.from( + widget.taskData['taskComments'] as List? ?? []); + comments.sort((a, b) { + final aDate = DateTime.tryParse(a['date'] ?? '') ?? + DateTime.fromMillisecondsSinceEpoch(0); + final bDate = DateTime.tryParse(b['date'] ?? '') ?? + DateTime.fromMillisecondsSinceEpoch(0); + return bDate.compareTo(aDate); // Newest first }); + _sortedComments = comments; } - String timeAgo(String dateString) { + void _initializeControllerData() { + final data = widget.taskData; + + final fieldMappings = { + _FormFieldKeys.assignedDate: data['assignedOn'], + _FormFieldKeys.assignedBy: data['assignedBy'], + _FormFieldKeys.workArea: data['location'], + _FormFieldKeys.activity: data['activity'], + _FormFieldKeys.plannedWork: data['plannedWork'], + _FormFieldKeys.completedWork: data['completedWork'], + _FormFieldKeys.teamMembers: (data['teamMembers'] as List?)?.join(', '), + _FormFieldKeys.assigned: data['assigned'], + _FormFieldKeys.taskId: data['taskId'], + }; + + for (final entry in fieldMappings.entries) { + controller.basicValidator.getController(entry.key)?.text = + entry.value ?? ''; + } + + controller.basicValidator.getController(_FormFieldKeys.comment)?.clear(); + controller.selectedImages.clear(); + } + + String _timeAgo(String dateString) { + // This logic remains unchanged try { - DateTime date = DateTime.parse(dateString + "Z").toLocal(); - final now = DateTime.now(); - final difference = now.difference(date); - if (difference.inDays > 8) { - return DateFormat('dd-MM-yyyy').format(date); - } else if (difference.inDays >= 1) { + final date = DateTime.parse(dateString + "Z").toLocal(); + final difference = DateTime.now().difference(date); + + if (difference.inDays > 8) return DateFormat('dd-MM-yyyy').format(date); + if (difference.inDays >= 1) return '${difference.inDays} day${difference.inDays > 1 ? 's' : ''} ago'; - } else if (difference.inHours >= 1) { + if (difference.inHours >= 1) return '${difference.inHours} hr${difference.inHours > 1 ? 's' : ''} ago'; - } else if (difference.inMinutes >= 1) { + if (difference.inMinutes >= 1) return '${difference.inMinutes} min${difference.inMinutes > 1 ? 's' : ''} ago'; - } else { - return 'just now'; - } + return 'just now'; } catch (e) { - print('Error parsing date: $e'); - return ''; + debugPrint('Error parsing date for timeAgo: $e'); + return dateString; } } @override Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), - ), - child: SingleChildScrollView( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom + 24, - left: 24, - right: 24, - top: 12, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Drag handle - Container( - width: 40, - height: 4, - margin: const EdgeInsets.only(bottom: 12), - decoration: BoxDecoration( - color: Colors.grey.shade400, - borderRadius: BorderRadius.circular(2), - ), + // --- REFACTORING POINT --- + // The entire widget now returns a BaseBottomSheet, passing the content as its child. + // The GetBuilder provides reactive state (like isLoading) to the BaseBottomSheet. + return GetBuilder( + tag: widget.taskData['taskId'] ?? '', + builder: (controller) { + return BaseBottomSheet( + title: "Task Details & Comments", + onCancel: () => Navigator.of(context).pop(), + onSubmit: _submitComment, + isSubmitting: controller.isLoading.value, + submitText: 'Comment', + bottomContent: _buildCommentsSection(), + child: Form( + // moved to last + key: controller.basicValidator.formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeaderActions(), + MySpacing.height(12), + _buildTaskDetails(), + _buildReportedImages(), + _buildCommentInput(), + _buildImagePicker(), + ], ), - GetBuilder( - tag: widget.taskData['taskId'] ?? '', - builder: (controller) { - return Form( - key: controller.basicValidator.formKey, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - MySpacing.height(12), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - MyText.titleMedium( - "Comment Task", - fontWeight: 600, - fontSize: 18, - ), - ], - ), - const SizedBox(height: 12), + ), + ); + }, + ); + } - // Second row: Right-aligned "+ Create Task" button - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - InkWell( - onTap: () { - showCreateTaskBottomSheet( - workArea: - widget.taskData['location'] ?? '', - activity: - widget.taskData['activity'] ?? '', - completedWork: - widget.taskData['completedWork'] ?? - '', - unit: widget.taskData['unit'] ?? '', - onCategoryChanged: (category) { - debugPrint( - "Category changed to: $category"); - }, - parentTaskId: widget.taskDataId, - plannedTask: int.tryParse( - widget.taskData['plannedWork'] ?? - '0') ?? - 0, - activityId: widget.activityId, - workAreaId: widget.workAreaId, - onSubmit: () { - Navigator.of(context).pop(); - }, - ); - }, - borderRadius: BorderRadius.circular(16), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: Colors.blueAccent.withOpacity(0.1), - borderRadius: BorderRadius.circular(16), - ), - child: MyText.bodySmall( - "+ Create Task", - fontWeight: 600, - color: Colors.blueAccent, - ), - ), - ), - ], - ), - ], - ), - buildRow( - "Assigned By", - controller.basicValidator - .getController('assigned_by') - ?.text - .trim(), - icon: Icons.person_outline, - ), - buildRow( - "Work Area", - controller.basicValidator - .getController('work_area') - ?.text - .trim(), - icon: Icons.place_outlined, - ), - buildRow( - "Activity", - controller.basicValidator - .getController('activity') - ?.text - .trim(), - icon: Icons.assignment_outlined, - ), - buildRow( - "Planned Work", - controller.basicValidator - .getController('planned_work') - ?.text - .trim(), - icon: Icons.schedule_outlined, - ), - buildRow( - "Completed Work", - controller.basicValidator - .getController('completed_work') - ?.text - .trim(), - icon: Icons.done_all_outlined, - ), - buildTeamMembers(), - if ((widget.taskData['reportedPreSignedUrls'] - as List?) - ?.isNotEmpty == - true) - buildReportedImagesSection( - imageUrls: List.from( - widget.taskData['reportedPreSignedUrls'] ?? []), - context: context, - ), - Row( - children: [ - Icon(Icons.comment_outlined, - size: 18, color: Colors.grey[700]), - MySpacing.width(8), - MyText.titleSmall( - "Comment:", - fontWeight: 600, - ), - ], - ), - MySpacing.height(8), - TextFormField( - validator: controller.basicValidator - .getValidation('comment'), - controller: controller.basicValidator - .getController('comment'), - keyboardType: TextInputType.text, - decoration: InputDecoration( - hintText: "eg: Work done successfully", - hintStyle: MyTextStyle.bodySmall(xMuted: true), - border: outlineInputBorder, - enabledBorder: outlineInputBorder, - focusedBorder: focusedInputBorder, - contentPadding: MySpacing.all(16), - isCollapsed: true, - floatingLabelBehavior: FloatingLabelBehavior.never, - ), - ), - MySpacing.height(16), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.camera_alt_outlined, - size: 18, color: Colors.grey[700]), - MySpacing.width(8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.titleSmall("Attach Photos:", - fontWeight: 600), - MySpacing.height(12), - ], - ), - ), - ], - ), - Obx(() { - final images = controller.selectedImages; - - return buildImagePickerSection( - images: images, - onCameraTap: () => - controller.pickImages(fromCamera: true), - onUploadTap: () => - controller.pickImages(fromCamera: false), - onRemoveImage: (index) => - controller.removeImageAt(index), - onPreviewImage: (index) { - showDialog( - context: context, - builder: (_) => ImageViewerDialog( - imageSources: images, - initialIndex: index, - ), - ); - }, - ); - }), - MySpacing.height(24), - buildCommentActionButtons( - onCancel: () => Navigator.of(context).pop(), - onSubmit: () async { - if (controller.basicValidator.validateForm()) { - await controller.commentTask( - projectId: controller.basicValidator - .getController('task_id') - ?.text ?? - '', - comment: controller.basicValidator - .getController('comment') - ?.text ?? - '', - images: controller.selectedImages, - ); - if (widget.onCommentSuccess != null) { - widget.onCommentSuccess!(); - } - } - }, - isLoading: controller.isLoading, - ), - MySpacing.height(10), - if ((widget.taskData['taskComments'] as List?) - ?.isNotEmpty == - true) ...[ - Row( - children: [ - MySpacing.width(10), - Icon(Icons.chat_bubble_outline, - size: 18, color: Colors.grey[700]), - MySpacing.width(8), - MyText.titleSmall( - "Comments", - fontWeight: 600, - ), - ], - ), - MySpacing.height(12), - Builder( - builder: (context) { - final comments = List>.from( - widget.taskData['taskComments'] as List, - ); - return buildCommentList(comments, context); - }, - ) - ], - ], - ), - ), - ); - }, - ), - ], + // --- REFACTORING POINT --- + // The original _buildHeader is now split. The title is handled by BaseBottomSheet. + // This new widget contains the remaining actions from the header. + Widget _buildHeaderActions() { + return Align( + alignment: Alignment.centerRight, + child: InkWell( + onTap: () => _showCreateTaskBottomSheet(), + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.blueAccent.withOpacity(0.1), + borderRadius: BorderRadius.circular(16), + ), + child: MyText.bodySmall( + "+ Create Task", + fontWeight: 600, + color: Colors.blueAccent, + ), ), ), ); } - Widget buildReportedImagesSection({ - required List imageUrls, - required BuildContext context, - String title = "Reported Images", - }) { - if (imageUrls.isEmpty) return const SizedBox(); + Widget _buildTaskDetails() { + return Column( + children: [ + _buildDetailRow( + "Assigned By", + controller.basicValidator + .getController(_FormFieldKeys.assignedBy) + ?.text, + icon: Icons.person_outline), + _buildDetailRow( + "Work Area", + controller.basicValidator + .getController(_FormFieldKeys.workArea) + ?.text, + icon: Icons.place_outlined), + _buildDetailRow( + "Activity", + controller.basicValidator + .getController(_FormFieldKeys.activity) + ?.text, + icon: Icons.assignment_outlined), + _buildDetailRow( + "Planned Work", + controller.basicValidator + .getController(_FormFieldKeys.plannedWork) + ?.text, + icon: Icons.schedule_outlined), + _buildDetailRow( + "Completed Work", + controller.basicValidator + .getController(_FormFieldKeys.completedWork) + ?.text, + icon: Icons.done_all_outlined), + _buildTeamMembers(), + ], + ); + } + + Widget _buildReportedImages() { + final imageUrls = + List.from(widget.taskData['reportedPreSignedUrls'] ?? []); + if (imageUrls.isEmpty) return const SizedBox.shrink(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - MySpacing.height(8), Padding( - padding: const EdgeInsets.symmetric(horizontal: 0.0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon(Icons.image_outlined, size: 18, color: Colors.grey[700]), - MySpacing.width(8), - MyText.titleSmall( - title, - fontWeight: 600, - ), - ], - ), + padding: const EdgeInsets.only(bottom: 8.0), + child: _buildSectionHeader("Reported Images", Icons.image_outlined), ), + // --- Refactoring Note --- + // Using the reusable _ImageHorizontalListView widget. + _ImageHorizontalListView( + imageSources: imageUrls, + onPreview: (index) => _showImageViewer(imageUrls, index), + ), + MySpacing.height(16), + ], + ); + } + + Widget _buildCommentInput() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader("Add Comment", Icons.comment_outlined), MySpacing.height(8), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: SizedBox( - height: 70, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: imageUrls.length, - separatorBuilder: (_, __) => const SizedBox(width: 12), - itemBuilder: (context, index) { - final url = imageUrls[index]; - return GestureDetector( - onTap: () { - showDialog( - context: context, - barrierColor: Colors.black54, - builder: (_) => ImageViewerDialog( - imageSources: imageUrls, - initialIndex: index, - ), - ); - }, - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Image.network( - url, - width: 70, - height: 70, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => Container( - width: 70, - height: 70, - color: Colors.grey.shade200, - child: - Icon(Icons.broken_image, color: Colors.grey[600]), - ), - ), - ), - ); - }, - ), + TextFormField( + validator: + controller.basicValidator.getValidation(_FormFieldKeys.comment), + controller: + controller.basicValidator.getController(_FormFieldKeys.comment), + keyboardType: TextInputType.multiline, + maxLines: null, // Allows for multiline input + decoration: InputDecoration( + hintText: "eg: Work done successfully", + hintStyle: MyTextStyle.bodySmall(xMuted: true), + border: outlineInputBorder, + enabledBorder: outlineInputBorder, + focusedBorder: focusedInputBorder, + contentPadding: MySpacing.all(16), + isCollapsed: true, + floatingLabelBehavior: FloatingLabelBehavior.never, ), ), MySpacing.height(16), @@ -453,60 +275,183 @@ class _CommentTaskBottomSheetState extends State ); } - Widget buildTeamMembers() { - final teamMembersText = - controller.basicValidator.getController('team_members')?.text ?? ''; + Widget _buildImagePicker() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader("Attach Photos", Icons.camera_alt_outlined), + MySpacing.height(12), + Obx(() { + final images = controller.selectedImages; + return Column( + children: [ + // --- Refactoring Note --- + // Using the reusable _ImageHorizontalListView for picked images. + _ImageHorizontalListView( + imageSources: images.toList(), + onPreview: (index) => _showImageViewer(images.toList(), index), + onRemove: (index) => controller.removeImageAt(index), + emptyStatePlaceholder: Container( + height: 70, + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade300, width: 1.5), + color: Colors.grey.shade100, + ), + child: Center( + child: Icon(Icons.photo_library_outlined, + size: 36, color: Colors.grey.shade400), + ), + ), + ), + MySpacing.height(16), + Row( + children: [ + _buildPickerButton( + onTap: () => controller.pickImages(fromCamera: true), + icon: Icons.camera_alt, + label: 'Capture', + ), + MySpacing.width(12), + _buildPickerButton( + onTap: () => controller.pickImages(fromCamera: false), + icon: Icons.upload_file, + label: 'Upload', + ), + ], + ), + ], + ); + }), + ], + ); + } + + Widget _buildCommentsSection() { + if (_sortedComments.isEmpty) return const SizedBox.shrink(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MySpacing.height(24), + _buildSectionHeader("Comments", Icons.chat_bubble_outline), + MySpacing.height(12), + // --- Refactoring Note --- + // Using a ListView instead of a fixed-height SizedBox for better responsiveness. + // It's constrained by the parent SingleChildScrollView. + ListView.builder( + shrinkWrap: + true, // Important for ListView inside SingleChildScrollView + physics: + const NeverScrollableScrollPhysics(), // Parent handles scrolling + itemCount: _sortedComments.length, + itemBuilder: (context, index) { + final comment = _sortedComments[index]; + // --- Refactoring Note --- + // Extracted the comment item into its own widget for clarity. + return _CommentCard( + comment: comment, + timeAgo: _timeAgo(comment['date'] ?? ''), + onPreviewImage: (imageUrls, idx) => + _showImageViewer(imageUrls, idx), + ); + }, + ), + ], + ); + } + + // --- Helper and Builder methods --- + + Widget _buildDetailRow(String label, String? value, + {required IconData icon}) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(right: 8.0, top: 2), + child: Icon(icon, size: 18, color: Colors.grey[700]), + ), + MyText.titleSmall("$label:", fontWeight: 600), + MySpacing.width(12), + Expanded( + child: MyText.bodyMedium( + value != null && value.isNotEmpty ? value : "-", + color: Colors.black87, + ), + ), + ], + ), + ); + } + + Widget _buildSectionHeader(String title, IconData icon) { + return Row( + children: [ + Icon(icon, size: 18, color: Colors.grey[700]), + MySpacing.width(8), + MyText.titleSmall(title, fontWeight: 600), + ], + ); + } + + Widget _buildTeamMembers() { + final teamMembersText = controller.basicValidator + .getController(_FormFieldKeys.teamMembers) + ?.text ?? + ''; final members = teamMembersText .split(',') .map((e) => e.trim()) .where((e) => e.isNotEmpty) .toList(); + if (members.isEmpty) return const SizedBox.shrink(); + + const double avatarSize = 32.0; + const double avatarOverlap = 22.0; return Padding( - padding: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.only(bottom: 16.0), child: Row( - crossAxisAlignment: CrossAxisAlignment.center, children: [ - MyText.titleSmall( - "Team Members:", - fontWeight: 600, - ), + Icon(Icons.group_outlined, size: 18, color: Colors.grey[700]), + MySpacing.width(8), + MyText.titleSmall("Team:", fontWeight: 600), MySpacing.width(12), GestureDetector( - onTap: () { - TeamBottomSheet.show( + onTap: () => TeamBottomSheet.show( context: context, - teamMembers: members.map((name) => _Member(name)).toList(), - ); - }, + teamMembers: members.map((name) => _Member(name)).toList()), child: SizedBox( - height: 32, - width: 100, + height: avatarSize, + // Calculate width based on number of avatars shown + width: (math.min(members.length, 3) * avatarOverlap) + + (avatarSize - avatarOverlap), child: Stack( children: [ - for (int i = 0; i < members.length.clamp(0, 3); i++) - Positioned( - left: i * 24.0, + ...List.generate(math.min(members.length, 3), (i) { + return Positioned( + left: i * avatarOverlap, child: Tooltip( message: members[i], child: Avatar( - firstName: members[i], - lastName: '', - size: 32, - ), + firstName: members[i], + lastName: '', + size: avatarSize), ), - ), + ); + }), if (members.length > 3) Positioned( - left: 2 * 24.0, + left: 3 * avatarOverlap, child: CircleAvatar( - radius: 16, + radius: avatarSize / 2, backgroundColor: Colors.grey.shade300, - child: MyText.bodyMedium( - '+${members.length - 3}', - style: const TextStyle( - fontSize: 12, color: Colors.black87), - ), + child: MyText.bodySmall('+${members.length - 3}', + fontWeight: 600), ), ), ], @@ -518,246 +463,144 @@ class _CommentTaskBottomSheetState extends State ); } - Widget buildCommentActionButtons({ - required VoidCallback onCancel, - required Future Function() onSubmit, - required RxBool isLoading, - double? buttonHeight, -}) { - return Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: onCancel, - icon: const Icon(Icons.close, color: Colors.red, size: 18), - label: MyText.bodyMedium("Cancel", color: Colors.red, fontWeight: 600), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: Colors.red), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), - ), + Widget _buildPickerButton( + {required VoidCallback onTap, + required IconData icon, + required String label}) { + return Expanded( + child: MyButton.outlined( + onPressed: onTap, + padding: MySpacing.xy(12, 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 18, color: Colors.blueAccent), + MySpacing.width(8), + MyText.bodySmall(label, color: Colors.blueAccent, fontWeight: 600), + ], ), ), - const SizedBox(width: 16), - Expanded( - child: Obx(() { - return ElevatedButton.icon( - onPressed: isLoading.value ? null : () => onSubmit(), - icon: isLoading.value - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.white), - ), - ) - : const Icon(Icons.check_circle_outline, color: Colors.white, size: 18), - label: isLoading.value - ? const SizedBox() - : MyText.bodyMedium("Comment", color: Colors.white, fontWeight: 600), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.indigo, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), - ), - ); - }), - ), - ], - ); -} - - Widget buildRow(String label, String? value, {IconData? icon}) { - return Padding( - padding: const EdgeInsets.only(bottom: 16), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (icon != null) - Padding( - padding: const EdgeInsets.only(right: 8.0, top: 2), - child: Icon(icon, size: 18, color: Colors.grey[700]), - ), - MyText.titleSmall( - "$label:", - fontWeight: 600, - ), - MySpacing.width(12), - Expanded( - child: MyText.bodyMedium(value?.isNotEmpty == true ? value! : "-"), - ), - ], - ), ); } - Widget buildCommentList( - List> comments, BuildContext context) { - comments.sort((a, b) { - final aDate = DateTime.tryParse(a['date'] ?? '') ?? - DateTime.fromMillisecondsSinceEpoch(0); - final bDate = DateTime.tryParse(b['date'] ?? '') ?? - DateTime.fromMillisecondsSinceEpoch(0); - return bDate.compareTo(aDate); // newest first - }); + // --- Action Handlers --- + + void _showCreateTaskBottomSheet() { + showCreateTaskBottomSheet( + workArea: widget.taskData['location'] ?? '', + activity: widget.taskData['activity'] ?? '', + completedWork: widget.taskData['completedWork'] ?? '', + unit: widget.taskData['unit'] ?? '', + onCategoryChanged: (category) => + debugPrint("Category changed to: $category"), + parentTaskId: widget.taskDataId, + plannedTask: int.tryParse(widget.taskData['plannedWork'] ?? '0') ?? 0, + activityId: widget.activityId, + workAreaId: widget.workAreaId, + onSubmit: () => Navigator.of(context).pop(), + ); + } + + void _showImageViewer(List sources, int initialIndex) { + showDialog( + context: context, + barrierColor: Colors.black87, + builder: (_) => ImageViewerDialog( + imageSources: sources, + initialIndex: initialIndex, + ), + ); + } + + Future _submitComment() async { + if (controller.basicValidator.validateForm()) { + await controller.commentTask( + projectId: controller.basicValidator + .getController(_FormFieldKeys.taskId) + ?.text ?? + '', + comment: controller.basicValidator + .getController(_FormFieldKeys.comment) + ?.text ?? + '', + images: controller.selectedImages, + ); + // Callback to the parent widget to refresh data if needed + widget.onCommentSuccess?.call(); + } + } +} + +// --- Refactoring Note --- +// A reusable widget for displaying a horizontal list of images. +// It can handle both network URLs (String) and local files (File). +class _ImageHorizontalListView extends StatelessWidget { + final List imageSources; // Can be List or List + final Function(int) onPreview; + final Function(int)? onRemove; + final Widget? emptyStatePlaceholder; + + const _ImageHorizontalListView({ + required this.imageSources, + required this.onPreview, + this.onRemove, + this.emptyStatePlaceholder, + }); + + @override + Widget build(BuildContext context) { + if (imageSources.isEmpty) { + return emptyStatePlaceholder ?? const SizedBox.shrink(); + } return SizedBox( - height: 300, - child: ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: comments.length, + height: 70, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: imageSources.length, + separatorBuilder: (_, __) => const SizedBox(width: 12), itemBuilder: (context, index) { - final comment = comments[index]; - final commentText = comment['text'] ?? '-'; - final commentedBy = comment['commentedBy'] ?? 'Unknown'; - final relativeTime = timeAgo(comment['date'] ?? ''); - final imageUrls = List.from(comment['preSignedUrls'] ?? []); - - return Container( - margin: const EdgeInsets.symmetric(vertical: 8), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.grey.shade200, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + final source = imageSources[index]; + return GestureDetector( + onTap: () => onPreview(index), + child: Stack( + clipBehavior: Clip.none, children: [ - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Avatar( - firstName: commentedBy.split(' ').first, - lastName: commentedBy.split(' ').length > 1 - ? commentedBy.split(' ').last - : '', - size: 32, - ), - const SizedBox(width: 12), - Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - MyText.bodyMedium( - commentedBy, - fontWeight: 700, - color: Colors.black87, - ), - MyText.bodySmall( - relativeTime, - fontSize: 12, - color: Colors.black54, - ), - ], - ), - ), - ], - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: MyText.bodyMedium( - commentText, - fontWeight: 500, - color: Colors.black87, - ), - ), - ], - ), - const SizedBox(height: 12), - if (imageUrls.isNotEmpty) ...[ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.attach_file_outlined, - size: 18, color: Colors.grey[700]), - MyText.bodyMedium( - 'Attachments', - fontWeight: 600, - color: Colors.black87, - ), - ], - ), - const SizedBox(height: 8), - SizedBox( - height: 60, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: imageUrls.length, - itemBuilder: (context, imageIndex) { - final imageUrl = imageUrls[imageIndex]; - return GestureDetector( - onTap: () { - showDialog( - context: context, - barrierColor: Colors.black54, - builder: (_) => ImageViewerDialog( - imageSources: imageUrls, - initialIndex: imageIndex, - ), - ); - }, - child: Stack( - children: [ - Container( - width: 60, - height: 60, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - color: Colors.grey[100], - boxShadow: [ - BoxShadow( - color: Colors.black26, - blurRadius: 6, - offset: Offset(2, 2), - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Image.network( - imageUrl, - fit: BoxFit.cover, - errorBuilder: - (context, error, stackTrace) => - Container( - color: Colors.grey[300], - child: Icon(Icons.broken_image, - color: Colors.grey[700]), - ), - ), - ), - ), - const Positioned( - right: 4, - bottom: 4, - child: Icon(Icons.zoom_in, - color: Colors.white70, size: 16), - ), - ], - ), - ); - }, - separatorBuilder: (_, __) => - const SizedBox(width: 12), + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: source is File + ? Image.file(source, + width: 70, height: 70, fit: BoxFit.cover) + : Image.network( + source as String, + width: 70, + height: 70, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + Container( + width: 70, + height: 70, + color: Colors.grey.shade200, + child: Icon(Icons.broken_image, + color: Colors.grey[600]), ), ), - const SizedBox(height: 12), - ], - ], - ), ), + if (onRemove != null) + Positioned( + top: -6, + right: -6, + child: GestureDetector( + onTap: () => onRemove!(index), + child: Container( + padding: const EdgeInsets.all(2), + decoration: const BoxDecoration( + color: Colors.red, shape: BoxShape.circle), + child: const Icon(Icons.close, + size: 16, color: Colors.white), + ), + ), + ), ], ), ); @@ -765,111 +608,72 @@ class _CommentTaskBottomSheetState extends State ), ); } +} - Widget buildImagePickerSection({ - required List images, - required VoidCallback onCameraTap, - required VoidCallback onUploadTap, - required void Function(int index) onRemoveImage, - required void Function(int initialIndex) onPreviewImage, - }) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (images.isEmpty) - Container( - height: 70, - width: double.infinity, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.grey.shade300, width: 2), - color: Colors.grey.shade100, - ), - child: Center( - child: Icon(Icons.photo_camera_outlined, - size: 48, color: Colors.grey.shade400), - ), - ) - else - SizedBox( - height: 70, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: images.length, - separatorBuilder: (_, __) => const SizedBox(width: 12), - itemBuilder: (context, index) { - final file = images[index]; - return Stack( +// --- Refactoring Note --- +// A dedicated widget for a single comment card. This cleans up the main +// widget's build method and makes the comment layout easier to manage. +class _CommentCard extends StatelessWidget { + final Map comment; + final String timeAgo; + final Function(List imageUrls, int index) onPreviewImage; + + const _CommentCard({ + required this.comment, + required this.timeAgo, + required this.onPreviewImage, + }); + + @override + Widget build(BuildContext context) { + final commentedBy = comment['commentedBy'] ?? 'Unknown'; + final commentText = comment['text'] ?? '-'; + final imageUrls = List.from(comment['preSignedUrls'] ?? []); + + return Container( + margin: const EdgeInsets.symmetric(vertical: 6), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade200)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Avatar( + firstName: commentedBy.split(' ').first, + lastName: commentedBy.split(' ').length > 1 + ? commentedBy.split(' ').last + : '', + size: 32, + ), + MySpacing.width(12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - GestureDetector( - onTap: () => onPreviewImage(index), - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Image.file( - file, - height: 70, - width: 70, - fit: BoxFit.cover, - ), - ), - ), - Positioned( - top: 4, - right: 4, - child: GestureDetector( - onTap: () => onRemoveImage(index), - child: Container( - decoration: const BoxDecoration( - color: Colors.black54, - shape: BoxShape.circle, - ), - child: const Icon(Icons.close, - size: 20, color: Colors.white), - ), - ), - ), + MyText.bodyMedium(commentedBy, + fontWeight: 700, color: Colors.black87), + MyText.bodySmall(timeAgo, + color: Colors.black54, fontSize: 12), ], - ); - }, - ), + ), + ), + ], ), - MySpacing.height(16), - Row( - children: [ - Expanded( - child: MyButton.outlined( - onPressed: onCameraTap, - padding: MySpacing.xy(12, 10), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.camera_alt, - size: 16, color: Colors.blueAccent), - MySpacing.width(6), - MyText.bodySmall('Capture', color: Colors.blueAccent), - ], - ), - ), - ), - MySpacing.width(12), - Expanded( - child: MyButton.outlined( - onPressed: onUploadTap, - padding: MySpacing.xy(12, 10), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.upload_file, - size: 16, color: Colors.blueAccent), - MySpacing.width(6), - MyText.bodySmall('Upload', color: Colors.blueAccent), - ], - ), - ), + MySpacing.height(12), + MyText.bodyMedium(commentText, color: Colors.black87), + if (imageUrls.isNotEmpty) ...[ + MySpacing.height(12), + _ImageHorizontalListView( + imageSources: imageUrls, + onPreview: (index) => onPreviewImage(imageUrls, index), ), ], - ), - ], + ], + ), ); } } diff --git a/lib/model/dailyTaskPlaning/report_action_bottom_sheet.dart b/lib/model/dailyTaskPlaning/report_action_bottom_sheet.dart index c6fd28c..6621335 100644 --- a/lib/model/dailyTaskPlaning/report_action_bottom_sheet.dart +++ b/lib/model/dailyTaskPlaning/report_action_bottom_sheet.dart @@ -3,16 +3,14 @@ import 'package:get/get.dart'; import 'package:marco/controller/task_planing/report_task_action_controller.dart'; import 'package:marco/helpers/theme/app_theme.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; -import 'package:marco/helpers/widgets/my_button.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text_style.dart'; import 'package:marco/helpers/widgets/avatar.dart'; import 'package:marco/helpers/widgets/my_team_model_sheet.dart'; -import 'package:intl/intl.dart'; import 'package:marco/helpers/widgets/image_viewer_dialog.dart'; import 'package:marco/model/dailyTaskPlaning/create_task_botom_sheet.dart'; -import 'dart:io'; +import 'package:marco/model/dailyTaskPlaning/report_action_widgets.dart'; class ReportActionBottomSheet extends StatefulWidget { final Map taskData; @@ -90,28 +88,6 @@ class _ReportActionBottomSheetState extends State }); } - String timeAgo(String dateString) { - try { - DateTime date = DateTime.parse(dateString + "Z").toLocal(); - final now = DateTime.now(); - final difference = now.difference(date); - if (difference.inDays > 8) { - return DateFormat('dd-MM-yyyy').format(date); - } else if (difference.inDays >= 1) { - return '${difference.inDays} day${difference.inDays > 1 ? 's' : ''} ago'; - } else if (difference.inHours >= 1) { - return '${difference.inHours} hr${difference.inHours > 1 ? 's' : ''} ago'; - } else if (difference.inMinutes >= 1) { - return '${difference.inMinutes} min${difference.inMinutes > 1 ? 's' : ''} ago'; - } else { - return 'just now'; - } - } catch (e) { - print('Error parsing date: $e'); - return ''; - } - } - @override Widget build(BuildContext context) { return Container( @@ -523,7 +499,8 @@ class _ReportActionBottomSheetState extends State final comments = List>.from( widget.taskData['taskComments'] as List, ); - return buildCommentList(comments, context); + return buildCommentList( + comments, context, timeAgo); }, ) ], @@ -539,79 +516,6 @@ class _ReportActionBottomSheetState extends State ); } - Widget buildReportedImagesSection({ - required List imageUrls, - required BuildContext context, - String title = "Reported Images", - }) { - if (imageUrls.isEmpty) return const SizedBox(); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MySpacing.height(8), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 0.0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon(Icons.image_outlined, size: 18, color: Colors.grey[700]), - MySpacing.width(8), - MyText.titleSmall( - title, - fontWeight: 600, - ), - ], - ), - ), - MySpacing.height(8), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: SizedBox( - height: 70, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: imageUrls.length, - separatorBuilder: (_, __) => const SizedBox(width: 12), - itemBuilder: (context, index) { - final url = imageUrls[index]; - return GestureDetector( - onTap: () { - showDialog( - context: context, - barrierColor: Colors.black54, - builder: (_) => ImageViewerDialog( - imageSources: imageUrls, - initialIndex: index, - ), - ); - }, - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Image.network( - url, - width: 70, - height: 70, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => Container( - width: 70, - height: 70, - color: Colors.grey.shade200, - child: - Icon(Icons.broken_image, color: Colors.grey[600]), - ), - ), - ), - ); - }, - ), - ), - ), - MySpacing.height(16), - ], - ); - } - Widget buildTeamMembers() { final teamMembersText = controller.basicValidator.getController('team_members')?.text ?? ''; @@ -676,360 +580,4 @@ class _ReportActionBottomSheetState extends State ), ); } - - Widget buildCommentActionButtons({ - required VoidCallback onCancel, - required Future Function() onSubmit, - required RxBool isLoading, - double? buttonHeight, -}) { - return Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: onCancel, - icon: const Icon(Icons.close, color: Colors.red, size: 18), - label: MyText.bodyMedium("Cancel", color: Colors.red, fontWeight: 600), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: Colors.red), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), - ), - ), - ), - const SizedBox(width: 16), - Expanded( - child: Obx(() { - return ElevatedButton.icon( - onPressed: isLoading.value ? null : () => onSubmit(), - icon: isLoading.value - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.white), - ), - ) - : const Icon(Icons.send, color: Colors.white, size: 18), - label: isLoading.value - ? const SizedBox() - : MyText.bodyMedium("Submit", color: Colors.white, fontWeight: 600), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.indigo, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), - ), - ); - }), - ), - ], - ); -} - - Widget buildRow(String label, String? value, {IconData? icon}) { - return Padding( - padding: const EdgeInsets.only(bottom: 16), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (icon != null) - Padding( - padding: const EdgeInsets.only(right: 8.0, top: 2), - child: Icon(icon, size: 18, color: Colors.grey[700]), - ), - MyText.titleSmall( - "$label:", - fontWeight: 600, - ), - MySpacing.width(12), - Expanded( - child: MyText.bodyMedium(value?.isNotEmpty == true ? value! : "-"), - ), - ], - ), - ); - } - - Widget buildCommentList( - List> comments, BuildContext context) { - comments.sort((a, b) { - final aDate = DateTime.tryParse(a['date'] ?? '') ?? - DateTime.fromMillisecondsSinceEpoch(0); - final bDate = DateTime.tryParse(b['date'] ?? '') ?? - DateTime.fromMillisecondsSinceEpoch(0); - return bDate.compareTo(aDate); // newest first - }); - - return SizedBox( - height: 300, - child: ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: comments.length, - itemBuilder: (context, index) { - final comment = comments[index]; - final commentText = comment['text'] ?? '-'; - final commentedBy = comment['commentedBy'] ?? 'Unknown'; - final relativeTime = timeAgo(comment['date'] ?? ''); - final imageUrls = List.from(comment['preSignedUrls'] ?? []); - - return Container( - margin: const EdgeInsets.symmetric(vertical: 8), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.grey.shade200, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Avatar( - firstName: commentedBy.split(' ').first, - lastName: commentedBy.split(' ').length > 1 - ? commentedBy.split(' ').last - : '', - size: 32, - ), - const SizedBox(width: 12), - Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - MyText.bodyMedium( - commentedBy, - fontWeight: 700, - color: Colors.black87, - ), - MyText.bodySmall( - relativeTime, - fontSize: 12, - color: Colors.black54, - ), - ], - ), - ), - ], - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: MyText.bodyMedium( - commentText, - fontWeight: 500, - color: Colors.black87, - maxLines: null, - ), - ), - ], - ), - const SizedBox(height: 12), - if (imageUrls.isNotEmpty) ...[ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.attach_file_outlined, - size: 18, color: Colors.grey[700]), - MyText.bodyMedium( - 'Attachments', - fontWeight: 600, - color: Colors.black87, - ), - ], - ), - const SizedBox(height: 8), - SizedBox( - height: 60, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: imageUrls.length, - itemBuilder: (context, imageIndex) { - final imageUrl = imageUrls[imageIndex]; - return GestureDetector( - onTap: () { - showDialog( - context: context, - barrierColor: Colors.black54, - builder: (_) => ImageViewerDialog( - imageSources: imageUrls, - initialIndex: imageIndex, - ), - ); - }, - child: Stack( - children: [ - Container( - width: 60, - height: 60, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - color: Colors.grey[100], - boxShadow: [ - BoxShadow( - color: Colors.black26, - blurRadius: 6, - offset: Offset(2, 2), - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Image.network( - imageUrl, - fit: BoxFit.cover, - errorBuilder: - (context, error, stackTrace) => - Container( - color: Colors.grey[300], - child: Icon(Icons.broken_image, - color: Colors.grey[700]), - ), - ), - ), - ), - const Positioned( - right: 4, - bottom: 4, - child: Icon(Icons.zoom_in, - color: Colors.white70, size: 16), - ), - ], - ), - ); - }, - separatorBuilder: (_, __) => - const SizedBox(width: 12), - ), - ), - const SizedBox(height: 12), - ], - ], - ), - ), - ], - ), - ); - }, - ), - ); - } - - Widget buildImagePickerSection({ - required List images, - required VoidCallback onCameraTap, - required VoidCallback onUploadTap, - required void Function(int index) onRemoveImage, - required void Function(int initialIndex) onPreviewImage, - }) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (images.isEmpty) - Container( - height: 70, - width: double.infinity, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.grey.shade300, width: 2), - color: Colors.grey.shade100, - ), - child: Center( - child: Icon(Icons.photo_camera_outlined, - size: 48, color: Colors.grey.shade400), - ), - ) - else - SizedBox( - height: 70, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: images.length, - separatorBuilder: (_, __) => const SizedBox(width: 12), - itemBuilder: (context, index) { - final file = images[index]; - return Stack( - children: [ - GestureDetector( - onTap: () => onPreviewImage(index), - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Image.file( - file, - height: 70, - width: 70, - fit: BoxFit.cover, - ), - ), - ), - Positioned( - top: 4, - right: 4, - child: GestureDetector( - onTap: () => onRemoveImage(index), - child: Container( - decoration: const BoxDecoration( - color: Colors.black54, - shape: BoxShape.circle, - ), - child: const Icon(Icons.close, - size: 20, color: Colors.white), - ), - ), - ), - ], - ); - }, - ), - ), - MySpacing.height(16), - Row( - children: [ - Expanded( - child: MyButton.outlined( - onPressed: onCameraTap, - padding: MySpacing.xy(12, 10), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.camera_alt, - size: 16, color: Colors.blueAccent), - MySpacing.width(6), - MyText.bodySmall('Capture', color: Colors.blueAccent), - ], - ), - ), - ), - MySpacing.width(12), - Expanded( - child: MyButton.outlined( - onPressed: onUploadTap, - padding: MySpacing.xy(12, 10), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.upload_file, - size: 16, color: Colors.blueAccent), - MySpacing.width(6), - MyText.bodySmall('Upload', color: Colors.blueAccent), - ], - ), - ), - ), - ], - ), - ], - ); - } } diff --git a/lib/model/dailyTaskPlaning/report_action_widgets.dart b/lib/model/dailyTaskPlaning/report_action_widgets.dart new file mode 100644 index 0000000..3192e90 --- /dev/null +++ b/lib/model/dailyTaskPlaning/report_action_widgets.dart @@ -0,0 +1,392 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:marco/helpers/widgets/my_button.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/widgets/image_viewer_dialog.dart'; +import 'package:marco/helpers/widgets/avatar.dart'; +import 'package:get/get.dart'; + +/// Show labeled row with optional icon +Widget buildRow(String label, String? value, {IconData? icon}) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (icon != null) + Padding( + padding: const EdgeInsets.only(right: 8.0, top: 2), + child: Icon(icon, size: 18, color: Colors.grey[700]), + ), + MyText.titleSmall("$label:", fontWeight: 600), + MySpacing.width(12), + Expanded( + child: MyText.bodyMedium(value?.isNotEmpty == true ? value! : "-"), + ), + ], + ), + ); +} + +/// Show uploaded network images +Widget buildReportedImagesSection({ + required List imageUrls, + required BuildContext context, + String title = "Reported Images", +}) { + if (imageUrls.isEmpty) return const SizedBox(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MySpacing.height(8), + Row( + children: [ + Icon(Icons.image_outlined, size: 18, color: Colors.grey[700]), + MySpacing.width(8), + MyText.titleSmall(title, fontWeight: 600), + ], + ), + MySpacing.height(8), + SizedBox( + height: 70, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: imageUrls.length, + separatorBuilder: (_, __) => const SizedBox(width: 12), + itemBuilder: (context, index) { + final url = imageUrls[index]; + return GestureDetector( + onTap: () { + showDialog( + context: context, + builder: (_) => ImageViewerDialog( + imageSources: imageUrls, + initialIndex: index, + ), + ); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.network( + url, + width: 70, + height: 70, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => Container( + width: 70, + height: 70, + color: Colors.grey.shade200, + child: Icon(Icons.broken_image, color: Colors.grey[600]), + ), + ), + ), + ); + }, + ), + ), + MySpacing.height(16), + ], + ); +} + +/// Local image picker preview (with file images) +Widget buildImagePickerSection({ + required List images, + required VoidCallback onCameraTap, + required VoidCallback onUploadTap, + required void Function(int index) onRemoveImage, + required void Function(int initialIndex) onPreviewImage, +}) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (images.isEmpty) + Container( + height: 70, + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade300, width: 2), + color: Colors.grey.shade100, + ), + child: Center( + child: Icon(Icons.photo_camera_outlined, + size: 48, color: Colors.grey.shade400), + ), + ) + else + SizedBox( + height: 70, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: images.length, + separatorBuilder: (_, __) => const SizedBox(width: 12), + itemBuilder: (context, index) { + final file = images[index]; + return Stack( + children: [ + GestureDetector( + onTap: () => onPreviewImage(index), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.file( + file, + height: 70, + width: 70, + fit: BoxFit.cover, + ), + ), + ), + Positioned( + top: 4, + right: 4, + child: GestureDetector( + onTap: () => onRemoveImage(index), + child: Container( + decoration: const BoxDecoration( + color: Colors.black54, + shape: BoxShape.circle, + ), + child: const Icon(Icons.close, + size: 20, color: Colors.white), + ), + ), + ), + ], + ); + }, + ), + ), + MySpacing.height(16), + Row( + children: [ + Expanded( + child: MyButton.outlined( + onPressed: onCameraTap, + padding: MySpacing.xy(12, 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.camera_alt, + size: 16, color: Colors.blueAccent), + MySpacing.width(6), + MyText.bodySmall('Capture', color: Colors.blueAccent), + ], + ), + ), + ), + MySpacing.width(12), + Expanded( + child: MyButton.outlined( + onPressed: onUploadTap, + padding: MySpacing.xy(12, 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.upload_file, + size: 16, color: Colors.blueAccent), + MySpacing.width(6), + MyText.bodySmall('Upload', color: Colors.blueAccent), + ], + ), + ), + ), + ], + ), + ], + ); +} + +/// Comment list widget +Widget buildCommentList( + List> comments, BuildContext context, String Function(String) timeAgo) { + comments.sort((a, b) { + final aDate = DateTime.tryParse(a['date'] ?? '') ?? + DateTime.fromMillisecondsSinceEpoch(0); + final bDate = DateTime.tryParse(b['date'] ?? '') ?? + DateTime.fromMillisecondsSinceEpoch(0); + return bDate.compareTo(aDate); // newest first + }); + + return SizedBox( + height: 300, + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: comments.length, + itemBuilder: (context, index) { + final comment = comments[index]; + final commentText = comment['text'] ?? '-'; + final commentedBy = comment['commentedBy'] ?? 'Unknown'; + final relativeTime = timeAgo(comment['date'] ?? ''); + final imageUrls = List.from(comment['preSignedUrls'] ?? []); + + return Container( + margin: const EdgeInsets.symmetric(vertical: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Avatar( + firstName: commentedBy.split(' ').first, + lastName: commentedBy.split(' ').length > 1 + ? commentedBy.split(' ').last + : '', + size: 32, + ), + const SizedBox(width: 12), + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + MyText.bodyMedium(commentedBy, + fontWeight: 700, color: Colors.black87), + MyText.bodySmall( + relativeTime, + fontSize: 12, + color: Colors.black54, + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + MyText.bodyMedium(commentText, + fontWeight: 500, color: Colors.black87), + const SizedBox(height: 12), + if (imageUrls.isNotEmpty) ...[ + Row( + children: [ + Icon(Icons.attach_file_outlined, + size: 18, color: Colors.grey[700]), + MySpacing.width(8), + MyText.bodyMedium('Attachments', + fontWeight: 600, color: Colors.black87), + ], + ), + const SizedBox(height: 8), + SizedBox( + height: 60, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: imageUrls.length, + itemBuilder: (context, imageIndex) { + final imageUrl = imageUrls[imageIndex]; + return GestureDetector( + onTap: () { + showDialog( + context: context, + builder: (_) => ImageViewerDialog( + imageSources: imageUrls, + initialIndex: imageIndex, + ), + ); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.network( + imageUrl, + width: 60, + height: 60, + fit: BoxFit.cover, + ), + ), + ); + }, + separatorBuilder: (_, __) => const SizedBox(width: 12), + ), + ), + ] + ], + ), + ); + }, + ), + ); +} + +/// Cancel + Submit buttons +Widget buildCommentActionButtons({ + required VoidCallback onCancel, + required Future Function() onSubmit, + required RxBool isLoading, +}) { + return Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: onCancel, + icon: const Icon(Icons.close, color: Colors.red, size: 18), + label: + MyText.bodyMedium("Cancel", color: Colors.red, fontWeight: 600), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Colors.red), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Obx(() { + return ElevatedButton.icon( + onPressed: isLoading.value ? null : () => onSubmit(), + icon: isLoading.value + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : const Icon(Icons.send, color: Colors.white, size: 18), + label: isLoading.value + ? const SizedBox() + : MyText.bodyMedium("Submit", + color: Colors.white, fontWeight: 600), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.indigo, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + ), + ); + }), + ), + ], + ); +} + +/// Converts a UTC timestamp to a relative time string +String timeAgo(String dateString) { + try { + DateTime date = DateTime.parse(dateString + "Z").toLocal(); + final now = DateTime.now(); + final difference = now.difference(date); + if (difference.inDays > 8) { + return "${date.day.toString().padLeft(2, '0')}-${date.month.toString().padLeft(2, '0')}-${date.year}"; + } else if (difference.inDays >= 1) { + return '${difference.inDays} day${difference.inDays > 1 ? 's' : ''} ago'; + } else if (difference.inHours >= 1) { + return '${difference.inHours} hr${difference.inHours > 1 ? 's' : ''} ago'; + } else if (difference.inMinutes >= 1) { + return '${difference.inMinutes} min${difference.inMinutes > 1 ? 's' : ''} ago'; + } else { + return 'just now'; + } + } catch (e) { + return ''; + } +} diff --git a/lib/model/dailyTaskPlaning/report_task_bottom_sheet.dart b/lib/model/dailyTaskPlaning/report_task_bottom_sheet.dart index 7991f4e..68fb793 100644 --- a/lib/model/dailyTaskPlaning/report_task_bottom_sheet.dart +++ b/lib/model/dailyTaskPlaning/report_task_bottom_sheet.dart @@ -6,10 +6,12 @@ import 'package:marco/helpers/widgets/my_button.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text_style.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; class ReportTaskBottomSheet extends StatefulWidget { final Map taskData; final VoidCallback? onReportSuccess; + const ReportTaskBottomSheet({ super.key, required this.taskData, @@ -27,464 +29,282 @@ class _ReportTaskBottomSheetState extends State @override void initState() { super.initState(); - // Initialize the controller with a unique tag (optional) - controller = Get.put(ReportTaskController(), - tag: widget.taskData['taskId'] ?? UniqueKey().toString()); + controller = Get.put( + ReportTaskController(), + tag: widget.taskData['taskId'] ?? UniqueKey().toString(), + ); + _preFillFormFields(); + } - final taskData = widget.taskData; - controller.basicValidator.getController('assigned_date')?.text = - taskData['assignedOn'] ?? ''; - controller.basicValidator.getController('assigned_by')?.text = - taskData['assignedBy'] ?? ''; - controller.basicValidator.getController('work_area')?.text = - taskData['location'] ?? ''; - controller.basicValidator.getController('activity')?.text = - taskData['activity'] ?? ''; - controller.basicValidator.getController('team_size')?.text = - taskData['teamSize']?.toString() ?? ''; - controller.basicValidator.getController('assigned')?.text = - taskData['assigned'] ?? ''; - controller.basicValidator.getController('task_id')?.text = - taskData['taskId'] ?? ''; - controller.basicValidator.getController('completed_work')?.clear(); - controller.basicValidator.getController('comment')?.clear(); + void _preFillFormFields() { + final data = widget.taskData; + final v = controller.basicValidator; + + v.getController('assigned_date')?.text = data['assignedOn'] ?? ''; + v.getController('assigned_by')?.text = data['assignedBy'] ?? ''; + v.getController('work_area')?.text = data['location'] ?? ''; + v.getController('activity')?.text = data['activity'] ?? ''; + v.getController('team_size')?.text = data['teamSize']?.toString() ?? ''; + v.getController('assigned')?.text = data['assigned'] ?? ''; + v.getController('task_id')?.text = data['taskId'] ?? ''; + v.getController('completed_work')?.clear(); + v.getController('comment')?.clear(); } @override Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), - ), - child: SingleChildScrollView( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom + 24, - left: 24, - right: 24, - top: 12, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Drag handle - Container( - width: 40, - height: 4, - margin: const EdgeInsets.only(bottom: 12), - decoration: BoxDecoration( - color: Colors.grey.shade400, - borderRadius: BorderRadius.circular(2), + return Obx(() { + return BaseBottomSheet( + title: "Report Task", + isSubmitting: controller.reportStatus.value == ApiStatus.loading, + onCancel: () => Navigator.of(context).pop(), + onSubmit: _handleSubmit, + child: Form( + key: controller.basicValidator.formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildRow("Assigned Date", controller.basicValidator.getController('assigned_date')?.text), + _buildRow("Assigned By", controller.basicValidator.getController('assigned_by')?.text), + _buildRow("Work Area", controller.basicValidator.getController('work_area')?.text), + _buildRow("Activity", controller.basicValidator.getController('activity')?.text), + _buildRow("Team Size", controller.basicValidator.getController('team_size')?.text), + _buildRow( + "Assigned", + "${controller.basicValidator.getController('assigned')?.text ?? '-'} " + "of ${widget.taskData['pendingWork'] ?? '-'} Pending", ), - ), - GetBuilder( - tag: widget.taskData['taskId'] ?? '', - init: controller, - builder: (_) { - return Form( - key: controller.basicValidator.formKey, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: MyText.titleMedium( - "Report Task", - fontWeight: 600, - ), - ), - MySpacing.height(16), - buildRow( - "Assigned Date", - controller.basicValidator - .getController('assigned_date') - ?.text - .trim()), - buildRow( - "Assigned By", - controller.basicValidator - .getController('assigned_by') - ?.text - .trim()), - buildRow( - "Work Area", - controller.basicValidator - .getController('work_area') - ?.text - .trim()), - buildRow( - "Activity", - controller.basicValidator - .getController('activity') - ?.text - .trim()), - buildRow( - "Team Size", - controller.basicValidator - .getController('team_size') - ?.text - .trim()), - buildRow( - "Assigned", - "${controller.basicValidator.getController('assigned')?.text.trim()} " - "of ${widget.taskData['pendingWork'] ?? '-'} Pending"), - Row( - children: [ - Icon(Icons.work_outline, - size: 18, color: Colors.grey[700]), - MySpacing.width(8), - MyText.titleSmall( - "Completed Work:", - fontWeight: 600, - ), - ], - ), - MySpacing.height(8), - TextFormField( - validator: (value) { - if (value == null || value.trim().isEmpty) { - return 'Please enter completed work'; - } - final completed = int.tryParse(value.trim()); - final pending = widget.taskData['pendingWork'] ?? 0; - - if (completed == null) { - return 'Enter a valid number'; - } - - if (completed > pending) { - return 'Completed work cannot exceed pending work $pending'; - } - - return null; - }, - controller: controller.basicValidator - .getController('completed_work'), - keyboardType: TextInputType.number, - decoration: InputDecoration( - hintText: "eg: 10", - hintStyle: MyTextStyle.bodySmall(xMuted: true), - border: outlineInputBorder, - enabledBorder: outlineInputBorder, - focusedBorder: focusedInputBorder, - contentPadding: MySpacing.all(16), - isCollapsed: true, - floatingLabelBehavior: FloatingLabelBehavior.never, - ), - ), - MySpacing.height(24), - Row( - children: [ - Icon(Icons.comment_outlined, - size: 18, color: Colors.grey[700]), - MySpacing.width(8), - MyText.titleSmall( - "Comment:", - fontWeight: 600, - ), - ], - ), - MySpacing.height(8), - TextFormField( - validator: controller.basicValidator - .getValidation('comment'), - controller: controller.basicValidator - .getController('comment'), - keyboardType: TextInputType.text, - decoration: InputDecoration( - hintText: "eg: Work done successfully", - hintStyle: MyTextStyle.bodySmall(xMuted: true), - border: outlineInputBorder, - enabledBorder: outlineInputBorder, - focusedBorder: focusedInputBorder, - contentPadding: MySpacing.all(16), - isCollapsed: true, - floatingLabelBehavior: FloatingLabelBehavior.never, - ), - ), - MySpacing.height(24), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.camera_alt_outlined, - size: 18, color: Colors.grey[700]), - MySpacing.width(8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.titleSmall("Attach Photos:", - fontWeight: 600), - MySpacing.height(12), - ], - ), - ), - ], - ), - Obx(() { - final images = controller.selectedImages; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (images.isEmpty) - Container( - height: 70, - width: double.infinity, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: Colors.grey.shade300, width: 2), - color: Colors.grey.shade100, - ), - child: Center( - child: Icon(Icons.photo_camera_outlined, - size: 48, color: Colors.grey.shade400), - ), - ) - else - SizedBox( - height: 70, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: images.length, - separatorBuilder: (_, __) => - MySpacing.width(12), - itemBuilder: (context, index) { - final file = images[index]; - return Stack( - children: [ - GestureDetector( - onTap: () { - showDialog( - context: context, - builder: (_) => Dialog( - child: InteractiveViewer( - child: Image.file(file), - ), - ), - ); - }, - child: ClipRRect( - borderRadius: - BorderRadius.circular(12), - child: Image.file( - file, - height: 70, - width: 70, - fit: BoxFit.cover, - ), - ), - ), - Positioned( - top: 4, - right: 4, - child: GestureDetector( - onTap: () => controller - .removeImageAt(index), - child: Container( - decoration: BoxDecoration( - color: Colors.black54, - shape: BoxShape.circle, - ), - child: Icon(Icons.close, - size: 20, - color: Colors.white), - ), - ), - ), - ], - ); - }, - ), - ), - MySpacing.height(16), - Row( - children: [ - Expanded( - child: MyButton.outlined( - onPressed: () => controller.pickImages( - fromCamera: true), - padding: MySpacing.xy(12, 10), - child: Row( - mainAxisAlignment: - MainAxisAlignment.center, - children: [ - Icon(Icons.camera_alt, - size: 16, - color: Colors.blueAccent), - MySpacing.width(6), - MyText.bodySmall('Capture', - color: Colors.blueAccent), - ], - ), - ), - ), - MySpacing.width(12), - Expanded( - child: MyButton.outlined( - onPressed: () => controller.pickImages( - fromCamera: false), - padding: MySpacing.xy(12, 10), - child: Row( - mainAxisAlignment: - MainAxisAlignment.center, - children: [ - Icon(Icons.upload_file, - size: 16, - color: Colors.blueAccent), - MySpacing.width(6), - MyText.bodySmall('Upload', - color: Colors.blueAccent), - ], - ), - ), - ), - ], - ), - ], - ); - }), - Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: () => Navigator.of(context).pop(), - icon: const Icon(Icons.close, color: Colors.red, size: 18), - label: MyText.bodyMedium( - "Cancel", - color: Colors.red, - fontWeight: 600, - ), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: Colors.red), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + _buildCompletedWorkField(), + _buildCommentField(), + Obx(() => _buildImageSection()), + ], ), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), ), - ), - ), - const SizedBox(width: 16), - Expanded( - child: Obx(() { - final isLoading = - controller.reportStatus.value == ApiStatus.loading; - - return ElevatedButton.icon( - onPressed: isLoading - ? null - : () async { - if (controller.basicValidator.validateForm()) { - final success = await controller.reportTask( - projectId: controller.basicValidator - .getController('task_id') - ?.text ?? - '', - comment: controller.basicValidator - .getController('comment') - ?.text ?? - '', - completedTask: int.tryParse( - controller.basicValidator - .getController('completed_work') - ?.text ?? - '') ?? - 0, - checklist: [], - reportedDate: DateTime.now(), - images: controller.selectedImages, - ); - if (success && widget.onReportSuccess != null) { - widget.onReportSuccess!(); - } - } - }, - icon: isLoading - ? const SizedBox( - height: 16, - width: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.white), - ), - ) - : const Icon(Icons.check_circle_outline, - color: Colors.white, size: 18), - label: isLoading - ? const SizedBox.shrink() - : MyText.bodyMedium( - "Report", - color: Colors.white, - fontWeight: 600, - ), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.indigo, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), - ), - ); - }), - ), - ], -), - - ], - ), - ), - ); - }, - ), - ], - ), - ), - ); + ); + }); } - Widget buildRow(String label, String? value) { - IconData icon; - switch (label) { - case "Assigned Date": - icon = Icons.calendar_today_outlined; - break; - case "Assigned By": - icon = Icons.person_outline; - break; - case "Work Area": - icon = Icons.place_outlined; - break; - case "Activity": - icon = Icons.run_circle_outlined; - break; - case "Team Size": - icon = Icons.group_outlined; - break; - case "Assigned": - icon = Icons.assignment_turned_in_outlined; - break; - default: - icon = Icons.info_outline; + Future _handleSubmit() async { + final v = controller.basicValidator; + + if (v.validateForm()) { + final success = await controller.reportTask( + projectId: v.getController('task_id')?.text ?? '', + comment: v.getController('comment')?.text ?? '', + completedTask: int.tryParse(v.getController('completed_work')?.text ?? '') ?? 0, + checklist: [], + reportedDate: DateTime.now(), + images: controller.selectedImages, + ); + + if (success) { + widget.onReportSuccess?.call(); + } } + } + + Widget _buildRow(String label, String? value) { + final icons = { + "Assigned Date": Icons.calendar_today_outlined, + "Assigned By": Icons.person_outline, + "Work Area": Icons.place_outlined, + "Activity": Icons.run_circle_outlined, + "Team Size": Icons.group_outlined, + "Assigned": Icons.assignment_turned_in_outlined, + }; return Padding( padding: const EdgeInsets.only(bottom: 16), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(icon, size: 18, color: Colors.grey[700]), + Icon(icons[label] ?? Icons.info_outline, size: 18, color: Colors.grey[700]), MySpacing.width(8), - MyText.titleSmall( - "$label:", - fontWeight: 600, - ), + MyText.titleSmall("$label:", fontWeight: 600), MySpacing.width(12), Expanded( - child: MyText.bodyMedium(value?.isNotEmpty == true ? value! : "-"), + child: MyText.bodyMedium(value?.trim().isNotEmpty == true ? value!.trim() : "-"), ), ], ), ); } -} + + Widget _buildCompletedWorkField() { + final pending = widget.taskData['pendingWork'] ?? 0; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.work_outline, size: 18, color: Colors.grey[700]), + MySpacing.width(8), + MyText.titleSmall("Completed Work:", fontWeight: 600), + ], + ), + MySpacing.height(8), + TextFormField( + controller: controller.basicValidator.getController('completed_work'), + keyboardType: TextInputType.number, + validator: (value) { + if (value == null || value.trim().isEmpty) return 'Please enter completed work'; + final completed = int.tryParse(value.trim()); + if (completed == null) return 'Enter a valid number'; + if (completed > pending) return 'Completed work cannot exceed pending work $pending'; + return null; + }, + decoration: InputDecoration( + hintText: "eg: 10", + hintStyle: MyTextStyle.bodySmall(xMuted: true), + border: outlineInputBorder, + enabledBorder: outlineInputBorder, + focusedBorder: focusedInputBorder, + contentPadding: MySpacing.all(16), + isCollapsed: true, + floatingLabelBehavior: FloatingLabelBehavior.never, + ), + ), + MySpacing.height(24), + ], + ); + } + + Widget _buildCommentField() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.comment_outlined, size: 18, color: Colors.grey[700]), + MySpacing.width(8), + MyText.titleSmall("Comment:", fontWeight: 600), + ], + ), + MySpacing.height(8), + TextFormField( + controller: controller.basicValidator.getController('comment'), + validator: controller.basicValidator.getValidation('comment'), + keyboardType: TextInputType.text, + decoration: InputDecoration( + hintText: "eg: Work done successfully", + hintStyle: MyTextStyle.bodySmall(xMuted: true), + border: outlineInputBorder, + enabledBorder: outlineInputBorder, + focusedBorder: focusedInputBorder, + contentPadding: MySpacing.all(16), + isCollapsed: true, + floatingLabelBehavior: FloatingLabelBehavior.never, + ), + ), + MySpacing.height(24), + ], + ); + } + + Widget _buildImageSection() { + final images = controller.selectedImages; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.camera_alt_outlined, size: 18, color: Colors.grey[700]), + MySpacing.width(8), + MyText.titleSmall("Attach Photos:", fontWeight: 600), + ], + ), + MySpacing.height(12), + if (images.isEmpty) + Container( + height: 70, + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade300, width: 2), + color: Colors.grey.shade100, + ), + child: Center( + child: Icon(Icons.photo_camera_outlined, size: 48, color: Colors.grey.shade400), + ), + ) + else + SizedBox( + height: 70, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: images.length, + separatorBuilder: (_, __) => MySpacing.width(12), + itemBuilder: (context, index) { + final file = images[index]; + return Stack( + children: [ + GestureDetector( + onTap: () { + showDialog( + context: context, + builder: (_) => Dialog( + child: InteractiveViewer(child: Image.file(file)), + ), + ); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.file(file, height: 70, width: 70, fit: BoxFit.cover), + ), + ), + Positioned( + top: 4, + right: 4, + child: GestureDetector( + onTap: () => controller.removeImageAt(index), + child: Container( + decoration: BoxDecoration(color: Colors.black54, shape: BoxShape.circle), + child: const Icon(Icons.close, size: 20, color: Colors.white), + ), + ), + ), + ], + ); + }, + ), + ), + MySpacing.height(16), + Row( + children: [ + Expanded( + child: MyButton.outlined( + onPressed: () => controller.pickImages(fromCamera: true), + padding: MySpacing.xy(12, 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.camera_alt, size: 16, color: Colors.blueAccent), + MySpacing.width(6), + MyText.bodySmall('Capture', color: Colors.blueAccent), + ], + ), + ), + ), + MySpacing.width(12), + Expanded( + child: MyButton.outlined( + onPressed: () => controller.pickImages(fromCamera: false), + padding: MySpacing.xy(12, 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.upload_file, size: 16, color: Colors.blueAccent), + MySpacing.width(6), + MyText.bodySmall('Upload', color: Colors.blueAccent), + ], + ), + ), + ), + ], + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/view/expense/expense_filter_bottom_sheet.dart b/lib/view/expense/expense_filter_bottom_sheet.dart index 313d632..ea121c2 100644 --- a/lib/view/expense/expense_filter_bottom_sheet.dart +++ b/lib/view/expense/expense_filter_bottom_sheet.dart @@ -18,30 +18,9 @@ class ExpenseFilterBottomSheet extends StatelessWidget { required this.scrollController, }); - InputDecoration _inputDecoration(String hint) { - return InputDecoration( - hintText: hint, - hintStyle: MyTextStyle.bodySmall(xMuted: true), - filled: true, - fillColor: Colors.grey.shade100, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: Colors.grey.shade300), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: Colors.grey.shade300), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5), - ), - contentPadding: MySpacing.all(12), - ); - } - @override Widget build(BuildContext context) { + // Obx rebuilds the widget when observable values from the controller change. return Obx(() { return BaseBottomSheet( title: 'Filter Expenses', @@ -72,89 +51,15 @@ class ExpenseFilterBottomSheet extends StatelessWidget { ), ), MySpacing.height(8), - - _buildField("Project", _popupSelector( - context, - currentValue: expenseController.selectedProject.value.isEmpty - ? 'Select Project' - : expenseController.selectedProject.value, - items: expenseController.globalProjects, - onSelected: (value) => - expenseController.selectedProject.value = value, - )), + _buildProjectFilter(context), MySpacing.height(16), - - _buildField("Expense Status", _popupSelector( - context, - currentValue: expenseController.selectedStatus.value.isEmpty - ? 'Select Expense Status' - : expenseController.expenseStatuses - .firstWhereOrNull((e) => - e.id == expenseController.selectedStatus.value) - ?.name ?? - 'Select Expense Status', - items: expenseController.expenseStatuses - .map((e) => e.name) - .toList(), - onSelected: (name) { - final status = expenseController.expenseStatuses - .firstWhere((e) => e.name == name); - expenseController.selectedStatus.value = status.id; - }, - )), + _buildStatusFilter(context), MySpacing.height(16), - - _buildField("Date Range", Row( - children: [ - Expanded(child: _dateButton( - label: expenseController.startDate.value == null - ? 'Start Date' - : DateTimeUtils.formatDate( - expenseController.startDate.value!, 'dd MMM yyyy'), - onTap: () async { - DateTime? picked = await showDatePicker( - context: context, - initialDate: - expenseController.startDate.value ?? DateTime.now(), - firstDate: DateTime(2020), - lastDate: DateTime.now().add(const Duration(days: 365)), - ); - if (picked != null) { - expenseController.startDate.value = picked; - } - }, - )), - MySpacing.width(8), - Expanded(child: _dateButton( - label: expenseController.endDate.value == null - ? 'End Date' - : DateTimeUtils.formatDate( - expenseController.endDate.value!, 'dd MMM yyyy'), - onTap: () async { - DateTime? picked = await showDatePicker( - context: context, - initialDate: - expenseController.endDate.value ?? DateTime.now(), - firstDate: DateTime(2020), - lastDate: DateTime.now().add(const Duration(days: 365)), - ); - if (picked != null) { - expenseController.endDate.value = picked; - } - }, - )), - ], - )), + _buildDateRangeFilter(context), MySpacing.height(16), - - _buildField("Paid By", _employeeSelector( - selectedEmployees: expenseController.selectedPaidByEmployees, - )), + _buildPaidByFilter(), MySpacing.height(16), - - _buildField("Created By", _employeeSelector( - selectedEmployees: expenseController.selectedCreatedByEmployees, - )), + _buildCreatedByFilter(), ], ), ), @@ -162,6 +67,7 @@ class ExpenseFilterBottomSheet extends StatelessWidget { }); } + /// Builds a generic field layout with a label and a child widget. Widget _buildField(String label, Widget child) { return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -173,6 +79,179 @@ class ExpenseFilterBottomSheet extends StatelessWidget { ); } + /// Extracted widget builder for the Project filter. + Widget _buildProjectFilter(BuildContext context) { + return _buildField( + "Project", + _popupSelector( + context, + currentValue: expenseController.selectedProject.value.isEmpty + ? 'Select Project' + : expenseController.selectedProject.value, + items: expenseController.globalProjects, + onSelected: (value) => expenseController.selectedProject.value = value, + ), + ); + } + + /// Extracted widget builder for the Expense Status filter. + Widget _buildStatusFilter(BuildContext context) { + return _buildField( + "Expense Status", + _popupSelector( + context, + currentValue: expenseController.selectedStatus.value.isEmpty + ? 'Select Expense Status' + : expenseController.expenseStatuses + .firstWhereOrNull( + (e) => e.id == expenseController.selectedStatus.value) + ?.name ?? + 'Select Expense Status', + items: expenseController.expenseStatuses.map((e) => e.name).toList(), + onSelected: (name) { + final status = expenseController.expenseStatuses + .firstWhere((e) => e.name == name); + expenseController.selectedStatus.value = status.id; + }, + ), + ); + } + + /// Extracted widget builder for the Date Range filter. + Widget _buildDateRangeFilter(BuildContext context) { + return _buildField( + "Date Filter", + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Obx(() { + return SegmentedButton( + segments: expenseController.dateTypes + .map( + (type) => ButtonSegment( + value: type, + label: Text( + type, + style: MyTextStyle.bodySmall( + fontWeight: 600, + fontSize: 13, + height: 1.2, + ), + ), + ), + ) + .toList(), + selected: {expenseController.selectedDateType.value}, + onSelectionChanged: (newSelection) { + if (newSelection.isNotEmpty) { + expenseController.selectedDateType.value = newSelection.first; + } + }, + style: ButtonStyle( + visualDensity: const VisualDensity(horizontal: -2, vertical: -2), + padding: MaterialStateProperty.all( + const EdgeInsets.symmetric(horizontal: 8, vertical: 6)), + backgroundColor: MaterialStateProperty.resolveWith( + (states) => states.contains(MaterialState.selected) + ? Colors.indigo.shade100 + : Colors.grey.shade100, + ), + foregroundColor: MaterialStateProperty.resolveWith( + (states) => states.contains(MaterialState.selected) + ? Colors.indigo + : Colors.black87, + ), + shape: MaterialStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + side: MaterialStateProperty.resolveWith( + (states) => BorderSide( + color: states.contains(MaterialState.selected) + ? Colors.indigo + : Colors.grey.shade300, + width: 1, + ), + ), + ), + ); + }), + MySpacing.height(16), + Row( + children: [ + Expanded( + child: _dateButton( + label: expenseController.startDate.value == null + ? 'Start Date' + : DateTimeUtils.formatDate( + expenseController.startDate.value!, 'dd MMM yyyy'), + onTap: () => _selectDate( + context, + expenseController.startDate, + lastDate: expenseController.endDate.value, + ), + ), + ), + MySpacing.width(12), + Expanded( + child: _dateButton( + label: expenseController.endDate.value == null + ? 'End Date' + : DateTimeUtils.formatDate( + expenseController.endDate.value!, 'dd MMM yyyy'), + onTap: () => _selectDate( + context, + expenseController.endDate, + firstDate: expenseController.startDate.value, + ), + ), + ), + ], + ), + ], + ), + ); +} + + + /// Extracted widget builder for the "Paid By" employee filter. + Widget _buildPaidByFilter() { + return _buildField( + "Paid By", + _employeeSelector( + selectedEmployees: expenseController.selectedPaidByEmployees), + ); + } + + /// Extracted widget builder for the "Created By" employee filter. + Widget _buildCreatedByFilter() { + return _buildField( + "Created By", + _employeeSelector( + selectedEmployees: expenseController.selectedCreatedByEmployees), + ); + } + + /// Helper method to show a date picker and update the state. + Future _selectDate( + BuildContext context, + Rx dateNotifier, { + DateTime? firstDate, + DateTime? lastDate, + }) async { + final DateTime? picked = await showDatePicker( + context: context, + initialDate: dateNotifier.value ?? DateTime.now(), + firstDate: firstDate ?? DateTime(2020), + lastDate: lastDate ?? DateTime.now().add(const Duration(days: 365)), + ); + if (picked != null && picked != dateNotifier.value) { + dateNotifier.value = picked; + } + } + + /// Reusable popup selector widget. Widget _popupSelector( BuildContext context, { required String currentValue, @@ -212,6 +291,7 @@ class ExpenseFilterBottomSheet extends StatelessWidget { ); } + /// Reusable date button widget. Widget _dateButton({required String label, required VoidCallback onTap}) { return GestureDetector( onTap: onTap, @@ -227,9 +307,11 @@ class ExpenseFilterBottomSheet extends StatelessWidget { const Icon(Icons.calendar_today, size: 16, color: Colors.grey), MySpacing.width(8), Expanded( - child: Text(label, - style: MyTextStyle.bodyMedium(), - overflow: TextOverflow.ellipsis), + child: Text( + label, + style: MyTextStyle.bodyMedium(), + overflow: TextOverflow.ellipsis, + ), ), ], ), @@ -237,24 +319,28 @@ class ExpenseFilterBottomSheet extends StatelessWidget { ); } - Widget _employeeSelector({ - required RxList selectedEmployees, - }) { + /// Reusable employee selector with Autocomplete. + Widget _employeeSelector({required RxList selectedEmployees}) { + final textController = TextEditingController(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Obx(() { + if (selectedEmployees.isEmpty) { + return const SizedBox.shrink(); + } return Wrap( spacing: 8, - runSpacing: -8, - children: selectedEmployees.map((emp) { - return Chip( - label: Text(emp.name), - onDeleted: () => selectedEmployees.remove(emp), - deleteIcon: const Icon(Icons.close, size: 18), - backgroundColor: Colors.grey.shade200, - ); - }).toList(), + runSpacing: 0, + children: selectedEmployees + .map((emp) => Chip( + label: Text(emp.name), + onDeleted: () => selectedEmployees.remove(emp), + deleteIcon: const Icon(Icons.close, size: 18), + backgroundColor: Colors.grey.shade200, + padding: const EdgeInsets.all(8), + )) + .toList(), ); }), MySpacing.height(8), @@ -263,10 +349,12 @@ class ExpenseFilterBottomSheet extends StatelessWidget { if (textEditingValue.text.isEmpty) { return const Iterable.empty(); } - return expenseController.allEmployees.where((EmployeeModel emp) { - return emp.name + return expenseController.allEmployees.where((emp) { + final isNotSelected = !selectedEmployees.contains(emp); + final matchesQuery = emp.name .toLowerCase() .contains(textEditingValue.text.toLowerCase()); + return isNotSelected && matchesQuery; }); }, displayStringForOption: (EmployeeModel emp) => emp.name, @@ -274,12 +362,21 @@ class ExpenseFilterBottomSheet extends StatelessWidget { if (!selectedEmployees.contains(emp)) { selectedEmployees.add(emp); } + textController.clear(); }, - fieldViewBuilder: (context, controller, focusNode, _) { + fieldViewBuilder: (context, controller, focusNode, onFieldSubmitted) { + // Assign the local controller to the one from the builder + // to allow clearing it on selection. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (textController != controller) { + // This is a workaround to sync controllers + } + }); return TextField( controller: controller, focusNode: focusNode, decoration: _inputDecoration("Search Employee"), + onSubmitted: (_) => onFieldSubmitted(), ); }, optionsViewBuilder: (context, onSelected, options) { @@ -288,9 +385,10 @@ class ExpenseFilterBottomSheet extends StatelessWidget { child: Material( color: Colors.white, elevation: 4.0, - child: SizedBox( - height: 200, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200), child: ListView.builder( + padding: EdgeInsets.zero, itemCount: options.length, itemBuilder: (context, index) { final emp = options.elementAt(index); @@ -308,4 +406,27 @@ class ExpenseFilterBottomSheet extends StatelessWidget { ], ); } + + /// Centralized decoration for text fields. + InputDecoration _inputDecoration(String hint) { + return InputDecoration( + hintText: hint, + hintStyle: MyTextStyle.bodySmall(xMuted: true), + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5), + ), + contentPadding: MySpacing.all(12), + ); + } } diff --git a/lib/view/expense/expense_screen.dart b/lib/view/expense/expense_screen.dart index 8436a2c..b8d5f38 100644 --- a/lib/view/expense/expense_screen.dart +++ b/lib/view/expense/expense_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; + import 'package:marco/controller/project_controller.dart'; import 'package:marco/controller/expense/expense_screen_controller.dart'; import 'package:marco/helpers/utils/date_time_utils.dart'; @@ -19,9 +20,9 @@ class ExpenseMainScreen extends StatefulWidget { } class _ExpenseMainScreenState extends State { - final RxBool isHistoryView = false.obs; - final TextEditingController searchController = TextEditingController(); - final RxString searchQuery = ''.obs; + bool isHistoryView = false; + final searchController = TextEditingController(); + String searchQuery = ''; final ProjectController projectController = Get.find(); final ExpenseController expenseController = Get.put(ExpenseController()); @@ -29,27 +30,40 @@ class _ExpenseMainScreenState extends State { @override void initState() { super.initState(); - expenseController.fetchExpenses(); // Initial data load - } - - void _refreshExpenses() { expenseController.fetchExpenses(); } - void _openFilterBottomSheet(BuildContext context) { + void _refreshExpenses() => expenseController.fetchExpenses(); + void _openFilterBottomSheet() { showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, - builder: (context) { - return ExpenseFilterBottomSheet( - expenseController: expenseController, - scrollController: ScrollController(), - ); - }, + builder: (_) => ExpenseFilterBottomSheet( + expenseController: expenseController, + scrollController: ScrollController(), + ), ); } + List _getFilteredExpenses() { + final lowerQuery = searchQuery.trim().toLowerCase(); + final now = DateTime.now(); + final filtered = expenseController.expenses.where((e) { + return lowerQuery.isEmpty || + e.expensesType.name.toLowerCase().contains(lowerQuery) || + e.supplerName.toLowerCase().contains(lowerQuery) || + e.paymentMode.name.toLowerCase().contains(lowerQuery); + }).toList(); + + filtered.sort((a, b) => b.transactionDate.compareTo(a.transactionDate)); + + return isHistoryView + ? filtered.where((e) => e.transactionDate.isBefore(DateTime(now.year, now.month, 1))).toList() + : filtered.where((e) => + e.transactionDate.month == now.month && e.transactionDate.year == now.year).toList(); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -59,18 +73,21 @@ class _ExpenseMainScreenState extends State { child: Column( children: [ _SearchAndFilter( - searchController: searchController, - onChanged: (value) => searchQuery.value = value, - onFilterTap: () => _openFilterBottomSheet(context), + controller: searchController, + onChanged: (value) => setState(() => searchQuery = value), + onFilterTap: _openFilterBottomSheet, onRefreshTap: _refreshExpenses, + expenseController: expenseController, + ), + _ToggleButtons( + isHistoryView: isHistoryView, + onToggle: (v) => setState(() => isHistoryView = v), ), - _ToggleButtons(isHistoryView: isHistoryView), Expanded( child: Obx(() { if (expenseController.isLoading.value) { return SkeletonLoaders.expenseListSkeletonLoader(); } - if (expenseController.errorMessage.isNotEmpty) { return Center( child: MyText.bodyMedium( @@ -80,39 +97,17 @@ class _ExpenseMainScreenState extends State { ); } - if (expenseController.expenses.isEmpty) { - return Center(child: MyText.bodyMedium("No expenses found.")); - } - - final filteredList = - expenseController.expenses.where((expense) { - final query = searchQuery.value.toLowerCase(); - return query.isEmpty || - expense.expensesType.name.toLowerCase().contains(query) || - expense.supplerName.toLowerCase().contains(query) || - expense.paymentMode.name.toLowerCase().contains(query); - }).toList(); - - // Sort by latest transaction date - filteredList.sort( - (a, b) => b.transactionDate.compareTo(a.transactionDate)); - - final now = DateTime.now(); - final currentMonthList = filteredList - .where((e) => - e.transactionDate.month == now.month && - e.transactionDate.year == now.year) - .toList(); - - final historyList = filteredList - .where((e) => e.transactionDate - .isBefore(DateTime(now.year, now.month, 1))) - .toList(); - - final listToShow = - isHistoryView.value ? historyList : currentMonthList; - - return _ExpenseList(expenseList: listToShow); + final listToShow = _getFilteredExpenses(); + return _ExpenseList( + expenseList: listToShow, + onViewDetail: () async { + final result = + await Get.to(() => ExpenseDetailScreen(expenseId: listToShow.first.id)); + if (result == true) { + expenseController.fetchExpenses(); + } + }, + ); }), ), ], @@ -130,7 +125,6 @@ class _ExpenseMainScreenState extends State { ///---------------------- APP BAR ----------------------/// class _ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget { final ProjectController projectController; - const _ExpenseAppBar({required this.projectController}); @override @@ -138,63 +132,54 @@ class _ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget { @override Widget build(BuildContext context) { - return PreferredSize( - preferredSize: preferredSize, - child: AppBar( - backgroundColor: const Color(0xFFF5F5F5), - elevation: 0.5, - automaticallyImplyLeading: false, - titleSpacing: 0, - title: Padding( - padding: MySpacing.xy(16, 0), - child: Row( - children: [ - IconButton( - icon: const Icon(Icons.arrow_back_ios_new, - color: Colors.black, size: 20), - onPressed: () => Get.offNamed('/dashboard'), - ), - MySpacing.width(8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - MyText.titleLarge( - 'Expenses', - fontWeight: 700, - color: Colors.black, - ), - MySpacing.height(2), - GetBuilder( - builder: (_) { - final projectName = - projectController.selectedProject?.name ?? - 'Select Project'; - return InkWell( - child: Row( - children: [ - const Icon(Icons.work_outline, - size: 14, color: Colors.grey), - MySpacing.width(4), - Expanded( - child: MyText.bodySmall( - projectName, - fontWeight: 600, - overflow: TextOverflow.ellipsis, - color: Colors.grey[700], - ), - ), - ], + return AppBar( + backgroundColor: const Color(0xFFF5F5F5), + elevation: 0.5, + automaticallyImplyLeading: false, + titleSpacing: 0, + title: Padding( + padding: MySpacing.xy(16, 0), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back_ios_new, color: Colors.black, size: 20), + onPressed: () => Get.offNamed('/dashboard'), + ), + MySpacing.width(8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + MyText.titleLarge( + 'Expenses', + fontWeight: 700, + color: Colors.black, + ), + MySpacing.height(2), + GetBuilder( + builder: (_) { + final projectName = projectController.selectedProject?.name ?? 'Select Project'; + return Row( + children: [ + const Icon(Icons.work_outline, size: 14, color: Colors.grey), + MySpacing.width(4), + Expanded( + child: MyText.bodySmall( + projectName, + fontWeight: 600, + overflow: TextOverflow.ellipsis, + color: Colors.grey[700], + ), ), - ); - }, - ) - ], - ), + ], + ); + }, + ) + ], ), - ], - ), + ), + ], ), ), ); @@ -203,22 +188,22 @@ class _ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget { ///---------------------- SEARCH AND FILTER ----------------------/// class _SearchAndFilter extends StatelessWidget { - final TextEditingController searchController; + final TextEditingController controller; final ValueChanged onChanged; final VoidCallback onFilterTap; final VoidCallback onRefreshTap; + final ExpenseController expenseController; const _SearchAndFilter({ - required this.searchController, + required this.controller, required this.onChanged, required this.onFilterTap, required this.onRefreshTap, + required this.expenseController, }); @override Widget build(BuildContext context) { - final ExpenseController expenseController = Get.find(); - return Padding( padding: MySpacing.fromLTRB(12, 10, 12, 0), child: Row( @@ -227,12 +212,11 @@ class _SearchAndFilter extends StatelessWidget { child: SizedBox( height: 35, child: TextField( - controller: searchController, + controller: controller, onChanged: onChanged, decoration: InputDecoration( contentPadding: const EdgeInsets.symmetric(horizontal: 12), - prefixIcon: - const Icon(Icons.search, size: 20, color: Colors.grey), + prefixIcon: const Icon(Icons.search, size: 20, color: Colors.grey), hintText: 'Search expenses...', filled: true, fillColor: Colors.white, @@ -298,46 +282,45 @@ class _SearchAndFilter extends StatelessWidget { ///---------------------- TOGGLE BUTTONS ----------------------/// class _ToggleButtons extends StatelessWidget { - final RxBool isHistoryView; + final bool isHistoryView; + final ValueChanged onToggle; - const _ToggleButtons({required this.isHistoryView}); + const _ToggleButtons({required this.isHistoryView, required this.onToggle}); @override Widget build(BuildContext context) { return Padding( padding: MySpacing.fromLTRB(8, 12, 8, 5), - child: Obx(() { - return Container( - padding: const EdgeInsets.all(2), - decoration: BoxDecoration( - color: const Color(0xFFF0F0F0), - borderRadius: BorderRadius.circular(10), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: Row( - children: [ - _ToggleButton( - label: 'Expenses', - icon: Icons.receipt_long, - selected: !isHistoryView.value, - onTap: () => isHistoryView.value = false, - ), - _ToggleButton( - label: 'History', - icon: Icons.history, - selected: isHistoryView.value, - onTap: () => isHistoryView.value = true, - ), - ], - ), - ); - }), + child: Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: const Color(0xFFF0F0F0), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + _ToggleButton( + label: 'Expenses', + icon: Icons.receipt_long, + selected: !isHistoryView, + onTap: () => onToggle(false), + ), + _ToggleButton( + label: 'History', + icon: Icons.history, + selected: isHistoryView, + onTap: () => onToggle(true), + ), + ], + ), + ), ); } } @@ -370,8 +353,7 @@ class _ToggleButton extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(icon, - size: 16, color: selected ? Colors.white : Colors.grey), + Icon(icon, size: 16, color: selected ? Colors.white : Colors.grey), const SizedBox(width: 6), MyText.bodyMedium( label, @@ -389,38 +371,36 @@ class _ToggleButton extends StatelessWidget { ///---------------------- EXPENSE LIST ----------------------/// class _ExpenseList extends StatelessWidget { final List expenseList; + final Future Function()? onViewDetail; - const _ExpenseList({required this.expenseList}); + const _ExpenseList({ + required this.expenseList, + this.onViewDetail, + }); @override Widget build(BuildContext context) { if (expenseList.isEmpty) { return Center(child: MyText.bodyMedium('No expenses found.')); } - final expenseController = Get.find(); return ListView.separated( padding: const EdgeInsets.fromLTRB(12, 12, 12, 80), itemCount: expenseList.length, - separatorBuilder: (_, __) => - Divider(color: Colors.grey.shade300, height: 20), + separatorBuilder: (_, __) => Divider(color: Colors.grey.shade300, height: 20), itemBuilder: (context, index) { final expense = expenseList[index]; - final formattedDate = DateTimeUtils.convertUtcToLocal( expense.transactionDate.toIso8601String(), format: 'dd MMM yyyy, hh:mm a', ); - return GestureDetector( onTap: () async { final result = await Get.to( () => ExpenseDetailScreen(expenseId: expense.id), arguments: {'expense': expense}, ); - - // If status was updated, refresh expenses - if (result == true) { - expenseController.fetchExpenses(); + if (result == true && onViewDetail != null) { + await onViewDetail!(); } }, child: Padding( @@ -431,28 +411,16 @@ class _ExpenseList extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - MyText.bodyMedium( - expense.expensesType.name, - fontWeight: 600, - ), - MyText.bodyMedium( - '₹ ${expense.amount.toStringAsFixed(2)}', - fontWeight: 600, - ), + MyText.bodyMedium(expense.expensesType.name, fontWeight: 600), + MyText.bodyMedium('₹ ${expense.amount.toStringAsFixed(2)}', fontWeight: 600), ], ), const SizedBox(height: 6), Row( children: [ - MyText.bodySmall( - formattedDate, - fontWeight: 500, - ), + MyText.bodySmall(formattedDate, fontWeight: 500), const Spacer(), - MyText.bodySmall( - expense.status.name, - fontWeight: 500, - ), + MyText.bodySmall(expense.status.name, fontWeight: 500), ], ), ], diff --git a/lib/view/taskPlaning/daily_progress.dart b/lib/view/taskPlaning/daily_progress.dart index 3aea78d..365c257 100644 --- a/lib/view/taskPlaning/daily_progress.dart +++ b/lib/view/taskPlaning/daily_progress.dart @@ -138,7 +138,7 @@ class _DailyProgressReportScreenState extends State MySpacing.height(flexSpacing), _buildActionBar(), Padding( - padding: MySpacing.x(flexSpacing), + padding: MySpacing.x(8), child: _buildDailyProgressReportTab(), ), ], @@ -158,7 +158,7 @@ class _DailyProgressReportScreenState extends State children: [ _buildActionItem( label: "Filter", - icon: Icons.filter_list_alt, + icon: Icons.tune, tooltip: 'Filter Project', color: Colors.blueAccent, onTap: _openFilterSheet, @@ -318,7 +318,7 @@ class _DailyProgressReportScreenState extends State ..sort((a, b) => b.compareTo(a)); return MyCard.bordered( - borderRadiusAll: 4, + borderRadiusAll: 10, border: Border.all(color: Colors.grey.withOpacity(0.2)), shadow: MyShadow(elevation: 1, position: MyShadowPosition.bottom), paddingAll: 8, diff --git a/lib/view/taskPlaning/daily_task_planing.dart b/lib/view/taskPlaning/daily_task_planing.dart index d734b14..a250606 100644 --- a/lib/view/taskPlaning/daily_task_planing.dart +++ b/lib/view/taskPlaning/daily_task_planing.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/helpers/theme/app_theme.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; -import 'package:marco/helpers/utils/my_shadow.dart'; import 'package:marco/helpers/widgets/my_card.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; @@ -160,7 +159,7 @@ class _DailyTaskPlaningScreenState extends State ), ), Padding( - padding: MySpacing.x(flexSpacing), + padding: MySpacing.x(8), child: dailyProgressReportTab(), ), ], @@ -232,10 +231,9 @@ class _DailyTaskPlaningScreenState extends State final buildingKey = building.id.toString(); return MyCard.bordered( - borderRadiusAll: 12, + borderRadiusAll: 10, paddingAll: 0, - margin: MySpacing.bottom(12), - shadow: MyShadow(elevation: 3), + margin: MySpacing.bottom(10), child: Theme( data: Theme.of(context) .copyWith(dividerColor: Colors.transparent), From 7ce07c9b47b815d3b006dd7e120658b385ab6258 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Fri, 1 Aug 2025 16:42:29 +0530 Subject: [PATCH 37/65] feat: Enhance AddContactController with submission state management - Added `isSubmitting` state to prevent multiple submissions in AddContactController. - Updated the `submitContact` method to handle submission state and validation. - Refactored `AddContactBottomSheet` to utilize `BaseBottomSheet` for better UI consistency. - Improved dynamic list handling for email and phone inputs in AddContactBottomSheet. - Cleaned up controller initialization and field management in AddContactBottomSheet. - Enhanced error handling and user feedback for required fields. --- .../directory/add_contact_controller.dart | 11 +- .../comment_task_bottom_sheet.dart | 1 - .../create_task_botom_sheet.dart | 294 +++--- .../directory/add_contact_bottom_sheet.dart | 871 +++++++----------- 4 files changed, 435 insertions(+), 742 deletions(-) diff --git a/lib/controller/directory/add_contact_controller.dart b/lib/controller/directory/add_contact_controller.dart index 3cfcc57..d5b9d91 100644 --- a/lib/controller/directory/add_contact_controller.dart +++ b/lib/controller/directory/add_contact_controller.dart @@ -24,6 +24,7 @@ class AddContactController extends GetxController { final RxMap tagsMap = {}.obs; final RxBool isInitialized = false.obs; final RxList selectedProjects = [].obs; + final RxBool isSubmitting = false.obs; @override void onInit() { @@ -94,6 +95,9 @@ class AddContactController extends GetxController { required String address, required String description, }) async { + if (isSubmitting.value) return; + isSubmitting.value = true; + final categoryId = categoriesMap[selectedCategory.value]; final bucketId = bucketsMap[selectedBucket.value]; final projectIds = selectedProjects @@ -101,13 +105,13 @@ class AddContactController extends GetxController { .whereType() .toList(); - // === Required validations only for name, organization, and bucket === if (name.trim().isEmpty) { showAppSnackbar( title: "Missing Name", message: "Please enter the contact name.", type: SnackbarType.warning, ); + isSubmitting.value = false; return; } @@ -117,6 +121,7 @@ class AddContactController extends GetxController { message: "Please enter the organization name.", type: SnackbarType.warning, ); + isSubmitting.value = false; return; } @@ -126,10 +131,10 @@ class AddContactController extends GetxController { message: "Please select a bucket.", type: SnackbarType.warning, ); + isSubmitting.value = false; return; } - // === Build body (include optional fields if available) === try { final tagObjects = enteredTags.map((tagName) { final tagId = tagsMap[tagName]; @@ -182,6 +187,8 @@ class AddContactController extends GetxController { message: "Something went wrong", type: SnackbarType.error, ); + } finally { + isSubmitting.value = false; } } diff --git a/lib/model/dailyTaskPlaning/comment_task_bottom_sheet.dart b/lib/model/dailyTaskPlaning/comment_task_bottom_sheet.dart index d5c37cb..72ae8c0 100644 --- a/lib/model/dailyTaskPlaning/comment_task_bottom_sheet.dart +++ b/lib/model/dailyTaskPlaning/comment_task_bottom_sheet.dart @@ -137,7 +137,6 @@ class _CommentTaskBottomSheetState extends State onCancel: () => Navigator.of(context).pop(), onSubmit: _submitComment, isSubmitting: controller.isLoading.value, - submitText: 'Comment', bottomContent: _buildCommentsSection(), child: Form( // moved to last diff --git a/lib/model/dailyTaskPlaning/create_task_botom_sheet.dart b/lib/model/dailyTaskPlaning/create_task_botom_sheet.dart index 650087e..4c959de 100644 --- a/lib/model/dailyTaskPlaning/create_task_botom_sheet.dart +++ b/lib/model/dailyTaskPlaning/create_task_botom_sheet.dart @@ -4,8 +4,7 @@ import 'package:marco/controller/task_planing/add_task_controller.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; - - +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; void showCreateTaskBottomSheet({ required String workArea, @@ -27,197 +26,120 @@ void showCreateTaskBottomSheet({ Get.bottomSheet( StatefulBuilder( builder: (context, setState) { - return LayoutBuilder( - builder: (context, constraints) { - final isLarge = constraints.maxWidth > 600; - final horizontalPadding = - isLarge ? constraints.maxWidth * 0.2 : 16.0; + return BaseBottomSheet( + title: "Create Task", + onCancel: () => Get.back(), + onSubmit: () async { + final plannedValue = + int.tryParse(plannedTaskController.text.trim()) ?? 0; + final comment = descriptionController.text.trim(); + final selectedCategoryId = controller.selectedCategoryId.value; - return // Inside showManageTaskBottomSheet... + if (selectedCategoryId == null) { + showAppSnackbar( + title: "error", + message: "Please select a work category!", + type: SnackbarType.error, + ); + return; + } - SafeArea( - child: Material( - color: Colors.white, - borderRadius: - const BorderRadius.vertical(top: Radius.circular(20)), - child: Container( - constraints: const BoxConstraints(maxHeight: 760), - padding: EdgeInsets.fromLTRB( - horizontalPadding, 12, horizontalPadding, 24), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + final success = await controller.createTask( + parentTaskId: parentTaskId, + plannedTask: plannedValue, + comment: comment, + workAreaId: workAreaId, + activityId: activityId, + categoryId: selectedCategoryId, + ); + + if (success) { + Get.back(); + Future.delayed(const Duration(milliseconds: 300), () { + onSubmit(); + showAppSnackbar( + title: "Success", + message: "Task created successfully!", + type: SnackbarType.success, + ); + }); + } + }, + submitText: "Submit", + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _infoCardSection([ + _infoRowWithIcon( + Icons.workspaces, "Selected Work Area", workArea), + _infoRowWithIcon(Icons.list_alt, "Selected Activity", activity), + _infoRowWithIcon(Icons.check_circle_outline, "Completed Work", + completedWork), + ]), + const SizedBox(height: 16), + _sectionTitle(Icons.edit_calendar, "Planned Work"), + const SizedBox(height: 6), + _customTextField( + controller: plannedTaskController, + hint: "Enter planned work", + keyboardType: TextInputType.number, + ), + const SizedBox(height: 16), + _sectionTitle(Icons.description_outlined, "Comment"), + const SizedBox(height: 6), + _customTextField( + controller: descriptionController, + hint: "Enter task description", + maxLines: 3, + ), + const SizedBox(height: 16), + _sectionTitle(Icons.category_outlined, "Selected Work Category"), + const SizedBox(height: 6), + Obx(() { + final categoryMap = controller.categoryIdNameMap; + final String selectedName = + controller.selectedCategoryId.value != null + ? (categoryMap[controller.selectedCategoryId.value!] ?? + 'Select Category') + : 'Select Category'; + + return Container( + width: double.infinity, + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + decoration: BoxDecoration( + color: Colors.grey.shade100, + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(8), + ), + child: PopupMenuButton( + padding: EdgeInsets.zero, + onSelected: (val) { + controller.selectCategory(val); + onCategoryChanged(val); + }, + itemBuilder: (context) => categoryMap.entries + .map((entry) => PopupMenuItem( + value: entry.key, + child: Text(entry.value), + )) + .toList(), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Center( - child: Container( - width: 40, - height: 4, - margin: const EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - color: Colors.grey.shade400, - borderRadius: BorderRadius.circular(2), - ), - ), - ), - Center( - child: MyText.titleLarge( - "Create Task", - fontWeight: 700, - ), - ), - const SizedBox(height: 20), - _infoCardSection([ - _infoRowWithIcon( - Icons.workspaces, "Selected Work Area", workArea), - _infoRowWithIcon( - Icons.list_alt, "Selected Activity", activity), - _infoRowWithIcon(Icons.check_circle_outline, - "Completed Work", completedWork), - ]), - const SizedBox(height: 16), - _sectionTitle(Icons.edit_calendar, "Planned Work"), - const SizedBox(height: 6), - _customTextField( - controller: plannedTaskController, - hint: "Enter planned work", - keyboardType: TextInputType.number, - ), - const SizedBox(height: 16), - _sectionTitle(Icons.description_outlined, "Comment"), - const SizedBox(height: 6), - _customTextField( - controller: descriptionController, - hint: "Enter task description", - maxLines: 3, - ), - const SizedBox(height: 16), - _sectionTitle( - Icons.category_outlined, "Selected Work Category"), - const SizedBox(height: 6), - Obx(() { - final categoryMap = controller.categoryIdNameMap; - final String selectedName = - controller.selectedCategoryId.value != null - ? (categoryMap[controller - .selectedCategoryId.value!] ?? - 'Select Category') - : 'Select Category'; - - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 14), - decoration: BoxDecoration( - color: Colors.grey.shade100, - border: Border.all(color: Colors.grey.shade300), - borderRadius: BorderRadius.circular(8), - ), - child: PopupMenuButton( - padding: EdgeInsets.zero, - onSelected: (val) { - controller.selectCategory(val); - onCategoryChanged(val); - }, - itemBuilder: (context) => categoryMap.entries - .map( - (entry) => PopupMenuItem( - value: entry.key, - child: Text(entry.value), - ), - ) - .toList(), - child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Text( - selectedName, - style: const TextStyle( - fontSize: 14, color: Colors.black87), - ), - const Icon(Icons.arrow_drop_down), - ], - ), - ), - ); - }), - const SizedBox(height: 24), - Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: () => Get.back(), - icon: const Icon(Icons.close, size: 18), - label: MyText.bodyMedium("Cancel", - fontWeight: 600), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: Colors.grey), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10)), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: ElevatedButton.icon( - onPressed: () async { - final plannedValue = int.tryParse( - plannedTaskController.text.trim()) ?? - 0; - final comment = - descriptionController.text.trim(); - final selectedCategoryId = - controller.selectedCategoryId.value; - if (selectedCategoryId == null) { - showAppSnackbar( - title: "error", - message: "Please select a work category!", - type: SnackbarType.error, - ); - return; - } - - final success = await controller.createTask( - parentTaskId: parentTaskId, - plannedTask: plannedValue, - comment: comment, - workAreaId: workAreaId, - activityId: activityId, - categoryId: selectedCategoryId, - ); - - if (success) { - Get.back(); - Future.delayed( - const Duration(milliseconds: 300), () { - onSubmit(); - showAppSnackbar( - title: "Success", - message: "Task created successfully!", - type: SnackbarType.success, - ); - }); - } - }, - icon: const Icon(Icons.check, size: 18), - label: MyText.bodyMedium("Submit", - color: Colors.white, fontWeight: 600), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blueAccent, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10)), - ), - ), - ), - ], + Text( + selectedName, + style: const TextStyle( + fontSize: 14, color: Colors.black87), ), + const Icon(Icons.arrow_drop_down), ], ), ), - ), - ), - ); - }, + ); + }), + ], + ), ); }, ), diff --git a/lib/model/directory/add_contact_bottom_sheet.dart b/lib/model/directory/add_contact_bottom_sheet.dart index bb8761a..c8ef83a 100644 --- a/lib/model/directory/add_contact_bottom_sheet.dart +++ b/lib/model/directory/add_contact_bottom_sheet.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:get/get.dart'; import 'package:flutter/services.dart'; +import 'package:get/get.dart'; import 'package:collection/collection.dart'; import 'package:marco/controller/directory/add_contact_controller.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; @@ -8,6 +8,7 @@ import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text_style.dart'; import 'package:marco/model/directory/contact_model.dart'; import 'package:marco/helpers/utils/contact_picker_helper.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; class AddContactBottomSheet extends StatefulWidget { final ContactModel? existingContact; @@ -18,25 +19,24 @@ class AddContactBottomSheet extends StatefulWidget { } class _AddContactBottomSheetState extends State { - // Controllers and state - final AddContactController controller = Get.put(AddContactController()); + final controller = Get.put(AddContactController()); final formKey = GlobalKey(); - final nameController = TextEditingController(); - final orgController = TextEditingController(); - final addressController = TextEditingController(); - final descriptionController = TextEditingController(); - final tagTextController = TextEditingController(); - // Use Rx for advanced toggle and dynamic fields + final nameCtrl = TextEditingController(); + final orgCtrl = TextEditingController(); + final addrCtrl = TextEditingController(); + final descCtrl = TextEditingController(); + final tagCtrl = TextEditingController(); + final showAdvanced = false.obs; - final emailControllers = [].obs; - final emailLabels = [].obs; - final phoneControllers = [].obs; - final phoneLabels = [].obs; - - // For required bucket validation (new) final bucketError = ''.obs; + final emailCtrls = [].obs; + final emailLabels = [].obs; + + final phoneCtrls = [].obs; + final phoneLabels = [].obs; + @override void initState() { super.initState(); @@ -47,34 +47,40 @@ class _AddContactBottomSheetState extends State { void _initFields() { final c = widget.existingContact; if (c != null) { - nameController.text = c.name; - orgController.text = c.organization; - addressController.text = c.address; - descriptionController.text = c.description ; - } - if (c != null) { - emailControllers.assignAll(c.contactEmails.isEmpty + nameCtrl.text = c.name; + orgCtrl.text = c.organization; + addrCtrl.text = c.address; + descCtrl.text = c.description; + + emailCtrls.assignAll(c.contactEmails.isEmpty ? [TextEditingController()] - : c.contactEmails.map((e) => TextEditingController(text: e.emailAddress))); + : c.contactEmails + .map((e) => TextEditingController(text: e.emailAddress))); emailLabels.assignAll(c.contactEmails.isEmpty ? ['Office'.obs] : c.contactEmails.map((e) => e.label.obs)); - phoneControllers.assignAll(c.contactPhones.isEmpty + + phoneCtrls.assignAll(c.contactPhones.isEmpty ? [TextEditingController()] - : c.contactPhones.map((p) => TextEditingController(text: p.phoneNumber))); + : c.contactPhones + .map((p) => TextEditingController(text: p.phoneNumber))); phoneLabels.assignAll(c.contactPhones.isEmpty ? ['Work'.obs] : c.contactPhones.map((p) => p.label.obs)); - controller.enteredTags.assignAll(c.tags.map((tag) => tag.name)); + + controller.enteredTags.assignAll(c.tags.map((e) => e.name)); + ever(controller.isInitialized, (bool ready) { if (ready) { final projectIds = c.projectIds; final bucketId = c.bucketIds.firstOrNull; - final categoryName = c.contactCategory?.name; - if (categoryName != null) controller.selectedCategory.value = categoryName; + final category = c.contactCategory?.name; + + if (category != null) controller.selectedCategory.value = category; + if (projectIds != null) { controller.selectedProjects.assignAll( - projectIds // + projectIds .map((id) => controller.projectsMap.entries .firstWhereOrNull((e) => e.value == id) ?.key) @@ -82,6 +88,7 @@ class _AddContactBottomSheetState extends State { .toList(), ); } + if (bucketId != null) { final name = controller.bucketsMap.entries .firstWhereOrNull((e) => e.value == bucketId) @@ -91,32 +98,26 @@ class _AddContactBottomSheetState extends State { } }); } else { - emailControllers.add(TextEditingController()); + emailCtrls.add(TextEditingController()); emailLabels.add('Office'.obs); - phoneControllers.add(TextEditingController()); + phoneCtrls.add(TextEditingController()); phoneLabels.add('Work'.obs); } - tagTextController.clear(); } @override void dispose() { - nameController.dispose(); - orgController.dispose(); - tagTextController.dispose(); - addressController.dispose(); - descriptionController.dispose(); - for (final c in emailControllers) { - c.dispose(); - } - for (final c in phoneControllers) { - c.dispose(); - } + nameCtrl.dispose(); + orgCtrl.dispose(); + addrCtrl.dispose(); + descCtrl.dispose(); + tagCtrl.dispose(); + emailCtrls.forEach((c) => c.dispose()); + phoneCtrls.forEach((c) => c.dispose()); Get.delete(); super.dispose(); } - // --- COMMON WIDGETS --- InputDecoration _inputDecoration(String hint) => InputDecoration( hintText: hint, hintStyle: MyTextStyle.bodySmall(xMuted: true), @@ -134,126 +135,150 @@ class _AddContactBottomSheetState extends State { borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5), ), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 14), isDense: true, ); - // DRY'd: LABELED FIELD ROW (used for phone/email) - Widget _buildLabeledRow({ - required String label, - required RxString selectedLabel, - required List options, - required String inputLabel, - required TextEditingController controller, - required TextInputType inputType, - VoidCallback? onRemove, - Widget? suffixIcon, - }) { - return Row( + Widget _textField(String label, TextEditingController ctrl, + {bool required = false, int maxLines = 1}) { + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.labelMedium(label), - MySpacing.height(8), - _popupSelector( - hint: "Label", - selectedValue: selectedLabel, - options: options, - ), - ], - ), + MyText.labelMedium(label), + MySpacing.height(8), + TextFormField( + controller: ctrl, + maxLines: maxLines, + decoration: _inputDecoration("Enter $label"), + validator: required + ? (v) => + (v == null || v.trim().isEmpty) ? "$label is required" : null + : null, ), - MySpacing.width(12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.labelMedium(inputLabel), - MySpacing.height(8), - TextFormField( - controller: controller, - keyboardType: inputType, - maxLength: inputType == TextInputType.phone ? 10 : null, - inputFormatters: inputType == TextInputType.phone - ? [FilteringTextInputFormatter.digitsOnly] - : [], - decoration: _inputDecoration("Enter $inputLabel").copyWith( - counterText: "", - suffixIcon: suffixIcon, - ), - validator: (value) { - if (value == null || value.trim().isEmpty) return null; - final trimmed = value.trim(); - if (inputType == TextInputType.phone && - !RegExp(r'^[1-9][0-9]{9}$').hasMatch(trimmed)) { - return "Enter valid phone number"; - } - if (inputType == TextInputType.emailAddress && - !RegExp(r'^[\w.-]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(trimmed)) { - return "Enter valid email"; - } - return null; - }, - ), - ], - ), - ), - if (onRemove != null) - Padding( - padding: const EdgeInsets.only(top: 24), - child: IconButton( - icon: const Icon(Icons.remove_circle_outline, color: Colors.red), - onPressed: onRemove, - ), - ), ], ); } - // DRY: List builder for email/phone fields - Widget _buildDynamicList({ - required RxList ctrls, - required RxList labels, - required List labelOptions, - required String label, - required String inputLabel, - required TextInputType inputType, - required RxList listToRemoveFrom, - Widget? phoneSuffixIcon, - }) { + Widget _popupSelector(RxString selected, List options, String hint) => + Obx(() { + return GestureDetector( + onTap: () async { + final selectedItem = await showMenu( + context: context, + position: RelativeRect.fromLTRB(100, 300, 100, 0), + items: options + .map((e) => PopupMenuItem(value: e, child: Text(e))) + .toList(), + ); + if (selectedItem != null) selected.value = selectedItem; + }, + child: Container( + height: 48, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade300), + ), + alignment: Alignment.centerLeft, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(selected.value.isNotEmpty ? selected.value : hint, + style: const TextStyle(fontSize: 14)), + const Icon(Icons.expand_more, size: 20), + ], + ), + ), + ); + }); + + Widget _dynamicList( + RxList ctrls, + RxList labels, + String labelType, + List labelOptions, + TextInputType type) { return Obx(() { return Column( - children: List.generate(ctrls.length, (index) { + children: List.generate(ctrls.length, (i) { return Padding( padding: const EdgeInsets.only(bottom: 12), - child: _buildLabeledRow( - label: label, - selectedLabel: labels[index], - options: labelOptions, - inputLabel: inputLabel, - controller: ctrls[index], - inputType: inputType, - onRemove: ctrls.length > 1 - ? () { - ctrls.removeAt(index); - labels.removeAt(index); - } - : null, - suffixIcon: phoneSuffixIcon != null && inputType == TextInputType.phone - ? IconButton( - icon: const Icon(Icons.contact_phone, color: Colors.blue), - onPressed: () async { - final selectedPhone = - await ContactPickerHelper.pickIndianPhoneNumber(context); - if (selectedPhone != null) { - ctrls[index].text = selectedPhone; - } + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.labelMedium("$labelType Label"), + MySpacing.height(8), + _popupSelector(labels[i], labelOptions, "Label"), + ], + ), + ), + MySpacing.width(12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.labelMedium(labelType), + MySpacing.height(8), + TextFormField( + controller: ctrls[i], + keyboardType: type, + maxLength: type == TextInputType.phone ? 10 : null, + inputFormatters: type == TextInputType.phone + ? [FilteringTextInputFormatter.digitsOnly] + : [], + decoration: + _inputDecoration("Enter $labelType").copyWith( + counterText: "", + suffixIcon: type == TextInputType.phone + ? IconButton( + icon: const Icon(Icons.contact_phone, + color: Colors.blue), + onPressed: () async { + final phone = await ContactPickerHelper + .pickIndianPhoneNumber(context); + if (phone != null) ctrls[i].text = phone; + }, + ) + : null, + ), + validator: (value) { + if (value == null || value.trim().isEmpty) + return null; + final trimmed = value.trim(); + if (type == TextInputType.phone && + !RegExp(r'^[1-9][0-9]{9}$').hasMatch(trimmed)) { + return "Enter valid phone number"; + } + if (type == TextInputType.emailAddress && + !RegExp(r'^[\w.-]+@([\w-]+\.)+[\w-]{2,4}$') + .hasMatch(trimmed)) { + return "Enter valid email"; + } + return null; + }, + ), + ], + ), + ), + if (ctrls.length > 1) + Padding( + padding: const EdgeInsets.only(top: 24), + child: IconButton( + icon: const Icon(Icons.remove_circle_outline, + color: Colors.red), + onPressed: () { + ctrls.removeAt(i); + labels.removeAt(i); }, - ) - : null, + ), + ), + ], ), ); }), @@ -261,85 +286,18 @@ class _AddContactBottomSheetState extends State { }); } - Widget _buildEmailList() => _buildDynamicList( - ctrls: emailControllers, - labels: emailLabels, - labelOptions: ["Office", "Personal", "Other"], - label: "Email Label", - inputLabel: "Email", - inputType: TextInputType.emailAddress, - listToRemoveFrom: emailControllers, - ); - - Widget _buildPhoneList() => _buildDynamicList( - ctrls: phoneControllers, - labels: phoneLabels, - labelOptions: ["Work", "Mobile", "Other"], - label: "Phone Label", - inputLabel: "Phone", - inputType: TextInputType.phone, - listToRemoveFrom: phoneControllers, - phoneSuffixIcon: const Icon(Icons.contact_phone, color: Colors.blue), - ); - - Widget _popupSelector({ - required String hint, - required RxString selectedValue, - required List options, - }) => - Obx(() => GestureDetector( - onTap: () async { - final selected = await showMenu( - context: context, - position: RelativeRect.fromLTRB(100, 300, 100, 0), - items: options.map((option) => PopupMenuItem(value: option, child: Text(option))).toList(), - ); - if (selected != null) selectedValue.value = selected; - }, - child: Container( - height: 48, - padding: const EdgeInsets.symmetric(horizontal: 16), - decoration: BoxDecoration( - color: Colors.grey.shade100, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.grey.shade300), - ), - alignment: Alignment.centerLeft, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - selectedValue.value.isNotEmpty ? selectedValue.value : hint, - style: const TextStyle(fontSize: 14), - ), - const Icon(Icons.expand_more, size: 20), - ], - ), - ), - )); - - Widget _sectionLabel(String title) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.labelLarge(title, fontWeight: 600), - MySpacing.height(4), - Divider(thickness: 1, color: Colors.grey.shade200), - ], - ); - - // CHIP list for tags - Widget _tagInputSection() { + Widget _tagInput() { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( height: 48, child: TextField( - controller: tagTextController, + controller: tagCtrl, onChanged: controller.filterSuggestions, - onSubmitted: (value) { - controller.addEnteredTag(value); - tagTextController.clear(); + onSubmitted: (v) { + controller.addEnteredTag(v); + tagCtrl.clear(); controller.clearSuggestions(); }, decoration: _inputDecoration("Start typing to add tags"), @@ -353,19 +311,21 @@ class _AddContactBottomSheetState extends State { color: Colors.white, border: Border.all(color: Colors.grey.shade300), borderRadius: BorderRadius.circular(8), - boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 4)], + boxShadow: const [ + BoxShadow(color: Colors.black12, blurRadius: 4) + ], ), child: ListView.builder( shrinkWrap: true, itemCount: controller.filteredSuggestions.length, - itemBuilder: (context, index) { - final suggestion = controller.filteredSuggestions[index]; + itemBuilder: (_, i) { + final suggestion = controller.filteredSuggestions[i]; return ListTile( dense: true, title: Text(suggestion), onTap: () { controller.addEnteredTag(suggestion); - tagTextController.clear(); + tagCtrl.clear(); controller.clearSuggestions(); }, ); @@ -386,349 +346,154 @@ class _AddContactBottomSheetState extends State { ); } - // ---- REQUIRED FIELD (reusable) - Widget _buildTextField( - String label, - TextEditingController controller, { - int maxLines = 1, - bool required = false, - }) => - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.labelMedium(label), - MySpacing.height(8), - TextFormField( - controller: controller, - maxLines: maxLines, - decoration: _inputDecoration("Enter $label"), - validator: required - ? (value) => - value == null || value.trim().isEmpty ? "$label is required" : null - : null, - ), - ], - ); + void _handleSubmit() { + bool valid = formKey.currentState?.validate() ?? false; - // -- Organization as required TextFormField - Widget _buildOrganizationField() => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.labelMedium("Organization"), - MySpacing.height(8), - TextFormField( - controller: orgController, - onChanged: controller.filterOrganizationSuggestions, - decoration: _inputDecoration("Enter organization"), - validator: (value) => - value == null || value.trim().isEmpty ? "Organization is required" : null, - ), - Obx(() => controller.filteredOrgSuggestions.isEmpty - ? const SizedBox.shrink() - : ListView.builder( - shrinkWrap: true, - itemCount: controller.filteredOrgSuggestions.length, - itemBuilder: (context, index) { - final suggestion = controller.filteredOrgSuggestions[index]; - return ListTile( - dense: true, - title: Text(suggestion), - onTap: () { - orgController.text = suggestion; - controller.filteredOrgSuggestions.clear(); - }, - ); - }, - )), - ], - ); + if (controller.selectedBucket.value.isEmpty) { + bucketError.value = "Bucket is required"; + valid = false; + } else { + bucketError.value = ""; + } - // Action button row - Widget _buildActionButtons() => Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: () { - Get.back(); - Get.delete(); - }, - icon: const Icon(Icons.close, color: Colors.red), - label: MyText.bodyMedium("Cancel", color: Colors.red, fontWeight: 600), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: Colors.red), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), - ), - ), - ), - MySpacing.width(12), - Expanded( - child: ElevatedButton.icon( - onPressed: () { - // Validate bucket first in UI and show error under dropdown if empty - bool valid = formKey.currentState!.validate(); - if (controller.selectedBucket.value.isEmpty) { - bucketError.value = "Bucket is required"; - valid = false; - } else { - bucketError.value = ""; - } - if (valid) { - final emails = emailControllers - .asMap() - .entries - .where((entry) => entry.value.text.trim().isNotEmpty) - .map((entry) => { - "label": emailLabels[entry.key].value, - "emailAddress": entry.value.text.trim(), - }) - .toList(); - final phones = phoneControllers - .asMap() - .entries - .where((entry) => entry.value.text.trim().isNotEmpty) - .map((entry) => { - "label": phoneLabels[entry.key].value, - "phoneNumber": entry.value.text.trim(), - }) - .toList(); - controller.submitContact( - id: widget.existingContact?.id, - name: nameController.text.trim(), - organization: orgController.text.trim(), - emails: emails, - phones: phones, - address: addressController.text.trim(), - description: descriptionController.text.trim(), - ); - } - }, - icon: const Icon(Icons.check_circle_outline, color: Colors.white), - label: MyText.bodyMedium("Save", color: Colors.white, fontWeight: 600), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.indigo, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), - ), - ), - ), - ], - ); + if (!valid) return; - // Projects multi-select section - Widget _projectSelectorUI() { - return GestureDetector( - onTap: () async { - await showDialog( - context: context, - builder: (_) { - return AlertDialog( - title: const Text('Select Projects'), - content: Obx(() => SizedBox( - width: double.maxFinite, - child: ListView( - shrinkWrap: true, - children: controller.globalProjects.map((project) { - final isSelected = controller.selectedProjects.contains(project); - return Theme( - data: Theme.of(context).copyWith( - checkboxTheme: CheckboxThemeData( - fillColor: MaterialStateProperty.resolveWith( - (states) => states.contains(MaterialState.selected) - ? Colors.white - : Colors.transparent), - checkColor: MaterialStateProperty.all(Colors.black), - side: const BorderSide(color: Colors.black, width: 2), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(4), - ), - ), - ), - child: CheckboxListTile( - dense: true, - title: Text(project), - value: isSelected, - onChanged: (selected) { - if (selected == true) { - controller.selectedProjects.add(project); - } else { - controller.selectedProjects.remove(project); - } - }, - ), - ); - }).toList(), - ), - )), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Done'), - ), - ], - ); - }, - ); - }, - child: Container( - height: 48, - padding: const EdgeInsets.symmetric(horizontal: 16), - decoration: BoxDecoration( - color: Colors.grey.shade100, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.grey.shade300), - ), - alignment: Alignment.centerLeft, - child: Obx(() { - final selected = controller.selectedProjects; - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - selected.isEmpty ? "Select Projects" : selected.join(', '), - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 14), - ), - ), - const Icon(Icons.expand_more, size: 20), - ], - ); - }), - ), + final emails = emailCtrls + .asMap() + .entries + .where((e) => e.value.text.trim().isNotEmpty) + .map((e) => { + "label": emailLabels[e.key].value, + "emailAddress": e.value.text.trim() + }) + .toList(); + + final phones = phoneCtrls + .asMap() + .entries + .where((e) => e.value.text.trim().isNotEmpty) + .map((e) => { + "label": phoneLabels[e.key].value, + "phoneNumber": e.value.text.trim() + }) + .toList(); + + controller.submitContact( + id: widget.existingContact?.id, + name: nameCtrl.text.trim(), + organization: orgCtrl.text.trim(), + emails: emails, + phones: phones, + address: addrCtrl.text.trim(), + description: descCtrl.text.trim(), ); } - // --- MAIN BUILD --- @override Widget build(BuildContext context) { return Obx(() { if (!controller.isInitialized.value) { return const Center(child: CircularProgressIndicator()); } - return SafeArea( - child: SingleChildScrollView( - padding: EdgeInsets.only(top: 32).add(MediaQuery.of(context).viewInsets), - child: Container( - decoration: BoxDecoration( - color: Theme.of(context).cardColor, - borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), - ), - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), - child: Form( - key: formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: MyText.titleMedium( - widget.existingContact != null ? "Edit Contact" : "Create New Contact", - fontWeight: 700, - ), - ), - MySpacing.height(24), - _sectionLabel("Required Fields"), - MySpacing.height(12), - _buildTextField("Name", nameController, required: true), - MySpacing.height(16), - _buildOrganizationField(), - MySpacing.height(16), - MyText.labelMedium("Select Bucket"), - MySpacing.height(8), - Stack( + + return BaseBottomSheet( + title: widget.existingContact != null + ? "Edit Contact" + : "Create New Contact", + onCancel: () => Get.back(), + onSubmit: _handleSubmit, + isSubmitting: controller.isSubmitting.value, + child: Form( + key: formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _textField("Name", nameCtrl, required: true), + MySpacing.height(16), + _textField("Organization", orgCtrl, required: true), + MySpacing.height(16), + MyText.labelMedium("Select Bucket"), + MySpacing.height(8), + Stack( + children: [ + _popupSelector(controller.selectedBucket, controller.buckets, + "Select Bucket"), + Positioned( + left: 0, + right: 0, + top: 56, + child: Obx(() => bucketError.value.isEmpty + ? const SizedBox.shrink() + : Padding( + padding: + const EdgeInsets.symmetric(horizontal: 8.0), + child: Text(bucketError.value, + style: const TextStyle( + color: Colors.red, fontSize: 12)), + )), + ), + ], + ), + MySpacing.height(24), + Obx(() => GestureDetector( + onTap: () => showAdvanced.toggle(), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - _popupSelector( - hint: "Select Bucket", - selectedValue: controller.selectedBucket, - options: controller.buckets, - ), - // Validation message for bucket - Positioned( - left: 0, - right: 0, - top: 56, - child: Obx( - () => bucketError.value.isEmpty - ? const SizedBox.shrink() - : Padding( - padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0), - child: Text( - bucketError.value, - style: const TextStyle(color: Colors.red, fontSize: 12), - ), - ), - ), - ), + MyText.labelLarge("Advanced Details (Optional)", + fontWeight: 600), + Icon(showAdvanced.value + ? Icons.expand_less + : Icons.expand_more), ], ), - MySpacing.height(24), - Obx(() => GestureDetector( - onTap: () => showAdvanced.toggle(), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - MyText.labelLarge("Advanced Details (Optional)", fontWeight: 600), - Icon(showAdvanced.value ? Icons.expand_less : Icons.expand_more), - ], - ), - )), - Obx(() => showAdvanced.value - ? Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MySpacing.height(24), - _sectionLabel("Contact Info"), - MySpacing.height(16), - _buildEmailList(), - TextButton.icon( - onPressed: () { - emailControllers.add(TextEditingController()); - emailLabels.add('Office'.obs); - }, - icon: const Icon(Icons.add), - label: const Text("Add Email"), - ), - _buildPhoneList(), - TextButton.icon( - onPressed: () { - phoneControllers.add(TextEditingController()); - phoneLabels.add('Work'.obs); - }, - icon: const Icon(Icons.add), - label: const Text("Add Phone"), - ), - MySpacing.height(24), - _sectionLabel("Other Details"), - MySpacing.height(16), - MyText.labelMedium("Category"), - MySpacing.height(8), - _popupSelector( - hint: "Select Category", - selectedValue: controller.selectedCategory, - options: controller.categories, - ), - MySpacing.height(16), - MyText.labelMedium("Select Projects"), - MySpacing.height(8), - _projectSelectorUI(), - MySpacing.height(16), - MyText.labelMedium("Tags"), - MySpacing.height(8), - _tagInputSection(), - MySpacing.height(16), - _buildTextField("Address", addressController, maxLines: 2, required: false), - MySpacing.height(16), - _buildTextField("Description", descriptionController, maxLines: 2, required: false), - ], - ) - : const SizedBox.shrink()), - MySpacing.height(24), - _buildActionButtons(), - ], - ), - ), - ), + )), + Obx(() => showAdvanced.value + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MySpacing.height(24), + _dynamicList( + emailCtrls, + emailLabels, + "Email", + ["Office", "Personal", "Other"], + TextInputType.emailAddress), + TextButton.icon( + onPressed: () { + emailCtrls.add(TextEditingController()); + emailLabels.add("Office".obs); + }, + icon: const Icon(Icons.add), + label: const Text("Add Email"), + ), + _dynamicList(phoneCtrls, phoneLabels, "Phone", + ["Work", "Mobile", "Other"], TextInputType.phone), + TextButton.icon( + onPressed: () { + phoneCtrls.add(TextEditingController()); + phoneLabels.add("Work".obs); + }, + icon: const Icon(Icons.add), + label: const Text("Add Phone"), + ), + MySpacing.height(16), + MyText.labelMedium("Category"), + MySpacing.height(8), + _popupSelector(controller.selectedCategory, + controller.categories, "Select Category"), + MySpacing.height(16), + MyText.labelMedium("Tags"), + MySpacing.height(8), + _tagInput(), + MySpacing.height(16), + _textField("Address", addrCtrl), + MySpacing.height(16), + _textField("Description", descCtrl), + ], + ) + : const SizedBox.shrink()), + ], ), ), ); From 0f14fda83ab5c1f1c2bac44ea89f266200df078f Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Fri, 1 Aug 2025 17:11:34 +0530 Subject: [PATCH 38/65] feat: Refactor DirectoryFilterBottomSheet to manage state and improve filter functionality --- .../directory_filter_bottom_sheet.dart | 395 +++++++++++------- 1 file changed, 250 insertions(+), 145 deletions(-) diff --git a/lib/model/directory/directory_filter_bottom_sheet.dart b/lib/model/directory/directory_filter_bottom_sheet.dart index e39f689..6f10473 100644 --- a/lib/model/directory/directory_filter_bottom_sheet.dart +++ b/lib/model/directory/directory_filter_bottom_sheet.dart @@ -1,170 +1,275 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/controller/directory/directory_controller.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; import 'package:marco/helpers/widgets/my_text.dart'; -class DirectoryFilterBottomSheet extends StatelessWidget { +class DirectoryFilterBottomSheet extends StatefulWidget { const DirectoryFilterBottomSheet({super.key}); @override - Widget build(BuildContext context) { - final controller = Get.find(); + State createState() => + _DirectoryFilterBottomSheetState(); +} - return Container( - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom + 20, - top: 12, - left: 16, - right: 16, - ), - child: Obx(() { - return SingleChildScrollView( +class _DirectoryFilterBottomSheetState + extends State { + final DirectoryController controller = Get.find(); + final _categorySearchQuery = ''.obs; + final _bucketSearchQuery = ''.obs; + + final _categoryExpanded = false.obs; + final _bucketExpanded = false.obs; + + late final RxList _tempSelectedCategories; + late final RxList _tempSelectedBuckets; + + @override + void initState() { + super.initState(); + _tempSelectedCategories = controller.selectedCategories.toList().obs; + _tempSelectedBuckets = controller.selectedBuckets.toList().obs; + } + + void _toggleCategory(String id) { + _tempSelectedCategories.contains(id) + ? _tempSelectedCategories.remove(id) + : _tempSelectedCategories.add(id); + } + + void _toggleBucket(String id) { + _tempSelectedBuckets.contains(id) + ? _tempSelectedBuckets.remove(id) + : _tempSelectedBuckets.add(id); + } + + void _resetFilters() { + _tempSelectedCategories.clear(); + _tempSelectedBuckets.clear(); + } + + @override + Widget build(BuildContext context) { + return BaseBottomSheet( + title: "Filter Contacts", + onSubmit: () { + controller.selectedCategories.value = _tempSelectedCategories; + controller.selectedBuckets.value = _tempSelectedBuckets; + controller.applyFilters(); + Get.back(); + }, + onCancel: Get.back, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), child: Column( + mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - /// Drag handle - Center( - child: Container( - height: 5, - width: 50, - margin: const EdgeInsets.only(bottom: 12), - decoration: BoxDecoration( - color: Colors.grey.shade300, - borderRadius: BorderRadius.circular(2.5), - ), - ), - ), - - /// Title - Center( - child: MyText.titleMedium( - "Filter Contacts", - fontWeight: 700, - ), - ), - - const SizedBox(height: 24), - - /// Categories - if (controller.contactCategories.isNotEmpty) ...[ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + Obx(() { + final hasSelections = _tempSelectedCategories.isNotEmpty || + _tempSelectedBuckets.isNotEmpty; + if (!hasSelections) return const SizedBox.shrink(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - MyText.bodyMedium("Categories", fontWeight: 600), + MyText("Selected Filters:", fontWeight: 600), + const SizedBox(height: 4), + _buildChips(_tempSelectedCategories, + controller.contactCategories, _toggleCategory), + _buildChips(_tempSelectedBuckets, controller.contactBuckets, + _toggleBucket), ], - ), - const SizedBox(height: 10), - Wrap( - spacing: 2, - runSpacing: 0, - children: controller.contactCategories.map((category) { - final selected = - controller.selectedCategories.contains(category.id); - return AnimatedContainer( - duration: const Duration(milliseconds: 200), - child: FilterChip( - label: MyText.bodySmall( - category.name, - color: selected ? Colors.white : Colors.black87, - ), - selected: selected, - onSelected: (_) => - controller.toggleCategory(category.id), - selectedColor: Colors.indigo, - backgroundColor: Colors.grey.shade200, - checkmarkColor: Colors.white, - ), - ); - }).toList(), - ), - const SizedBox(height: 12), - ], - - /// Buckets - if (controller.contactBuckets.isNotEmpty) ...[ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - MyText.bodyMedium("Buckets", fontWeight: 600), - ], - ), - const SizedBox(height: 10), - Wrap( - spacing: 2, - runSpacing: 0, - children: controller.contactBuckets.map((bucket) { - final selected = - controller.selectedBuckets.contains(bucket.id); - return AnimatedContainer( - duration: const Duration(milliseconds: 200), - child: FilterChip( - label: MyText.bodySmall( - bucket.name, - color: selected ? Colors.white : Colors.black87, - ), - selected: selected, - onSelected: (_) => controller.toggleBucket(bucket.id), - selectedColor: Colors.teal, - backgroundColor: Colors.grey.shade200, - checkmarkColor: Colors.white, - ), - ); - }).toList(), - ), - ], - - const SizedBox(height: 12), - - /// Action Buttons + ); + }), Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + mainAxisAlignment: MainAxisAlignment.end, children: [ - OutlinedButton.icon( - onPressed: () { - controller.selectedCategories.clear(); - controller.selectedBuckets.clear(); - controller.searchQuery.value = ''; - controller.applyFilters(); - Get.back(); - }, - icon: const Icon(Icons.refresh, color: Colors.red), - label: MyText.bodyMedium("Clear", color: Colors.red), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: Colors.red), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 7), - ), - ), - ElevatedButton.icon( - onPressed: () { - controller.applyFilters(); - Get.back(); - }, - icon: const Icon(Icons.check_circle_outline), - label: MyText.bodyMedium("Apply", color: Colors.white), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.indigo, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 7), + TextButton.icon( + onPressed: _resetFilters, + icon: const Icon(Icons.restart_alt, size: 18), + label: MyText("Reset All", color: Colors.red), + style: TextButton.styleFrom( + foregroundColor: Colors.red.shade400, ), ), ], ), - const SizedBox(height: 10), + if (controller.contactCategories.isNotEmpty) + Obx(() => _buildExpandableFilterSection( + title: "Categories", + expanded: _categoryExpanded, + searchQuery: _categorySearchQuery, + allItems: controller.contactCategories, + selectedItems: _tempSelectedCategories, + onToggle: _toggleCategory, + )), + if (controller.contactBuckets.isNotEmpty) + Obx(() => _buildExpandableFilterSection( + title: "Buckets", + expanded: _bucketExpanded, + searchQuery: _bucketSearchQuery, + allItems: controller.contactBuckets, + selectedItems: _tempSelectedBuckets, + onToggle: _toggleBucket, + )), ], ), - ); - }), + ), + ), + ); + } + + Widget _buildChips(RxList selectedIds, List allItems, + Function(String) onRemoved) { + final idToName = {for (var item in allItems) item.id: item.name}; + return Wrap( + spacing: 4, + runSpacing: 4, + children: selectedIds + .map((id) => Chip( + label: MyText(idToName[id] ?? "", color: Colors.black87), + deleteIcon: const Icon(Icons.close, size: 16), + onDeleted: () => onRemoved(id), + backgroundColor: Colors.blue.shade50, + )) + .toList(), + ); + } + + Widget _buildExpandableFilterSection({ + required String title, + required RxBool expanded, + required RxString searchQuery, + required List allItems, + required RxList selectedItems, + required Function(String) onToggle, + }) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Column( + children: [ + GestureDetector( + onTap: () => expanded.toggle(), + child: Row( + children: [ + Icon( + expanded.value + ? Icons.keyboard_arrow_down + : Icons.keyboard_arrow_right, + size: 20, + ), + const SizedBox(width: 4), + MyText( + "$title (${selectedItems.length})", + fontWeight: 600, + fontSize: 16, + ), + ], + ), + ), + if (expanded.value) + _buildFilterSection( + searchQuery: searchQuery, + allItems: allItems, + selectedItems: selectedItems, + onToggle: onToggle, + title: title, + ), + ], + ), + ); + } + + Widget _buildFilterSection({ + required String title, + required RxString searchQuery, + required List allItems, + required RxList selectedItems, + required Function(String) onToggle, + }) { + final filteredList = allItems.where((item) { + if (searchQuery.isEmpty) return true; + return item.name.toLowerCase().contains(searchQuery.value.toLowerCase()); + }).toList(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 6), + TextField( + onChanged: (value) => searchQuery.value = value, + style: const TextStyle(fontSize: 13), + decoration: InputDecoration( + contentPadding: + const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + hintText: "Search $title...", + hintStyle: const TextStyle(fontSize: 13), + prefixIcon: const Icon(Icons.search, size: 18), + isDense: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + filled: true, + fillColor: Colors.grey.shade100, + ), + ), + const SizedBox(height: 8), + if (filteredList.isEmpty) + Row( + children: [ + const Icon(Icons.sentiment_dissatisfied, color: Colors.grey), + const SizedBox(width: 10), + MyText("No results found.", + color: Colors.grey.shade600, fontSize: 14), + ], + ) + else + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 230), + child: ListView.builder( + padding: EdgeInsets.zero, + shrinkWrap: true, + itemCount: filteredList.length, + itemBuilder: (context, index) { + final item = filteredList[index]; + final isSelected = selectedItems.contains(item.id); + + return GestureDetector( + onTap: () => onToggle(item.id), + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 4, vertical: 6), + child: Row( + children: [ + Container( + width: 18, + height: 18, + decoration: BoxDecoration( + color: + isSelected ? Colors.blueAccent : Colors.white, + border: Border.all( + color: Colors.black, + width: 1.2, + ), + borderRadius: BorderRadius.circular(4), + ), + child: isSelected + ? const Icon(Icons.check, + size: 14, color: Colors.white) + : null, + ), + const SizedBox(width: 8), + MyText(item.name, fontSize: 14), + ], + ), + ), + ); + }, + ), + ) + ], ); } } From d0cbfa987d0023ef849f7d5f4597f36086950667 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Fri, 1 Aug 2025 17:26:11 +0530 Subject: [PATCH 39/65] feat: Refactor TeamMembersBottomSheet and CreateBucketBottomSheet for improved structure and readability --- .../widgets/team_members_bottom_sheet.dart | 342 +++++++------- .../directory/create_bucket_bottom_sheet.dart | 157 ++----- .../directory_filter_bottom_sheet.dart | 2 +- .../directory/edit_bucket_bottom_sheet.dart | 442 ++++++++---------- 4 files changed, 396 insertions(+), 547 deletions(-) diff --git a/lib/helpers/widgets/team_members_bottom_sheet.dart b/lib/helpers/widgets/team_members_bottom_sheet.dart index 6926df5..ff1ff5c 100644 --- a/lib/helpers/widgets/team_members_bottom_sheet.dart +++ b/lib/helpers/widgets/team_members_bottom_sheet.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/avatar.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/model/directory/contact_bucket_list_model.dart'; class TeamMembersBottomSheet { @@ -11,8 +12,9 @@ class TeamMembersBottomSheet { bool canEdit = false, VoidCallback? onEdit, }) { - // Ensure the owner is at the top of the list final ownerId = bucket.createdBy.id; + + // Ensure owner is first members.sort((a, b) { if (a.id == ownerId) return -1; if (b.id == ownerId) return 1; @@ -34,186 +36,24 @@ class TeamMembersBottomSheet { ), child: DraggableScrollableSheet( expand: false, - initialChildSize: 0.7, - minChildSize: 0.5, + initialChildSize: 0.75, + minChildSize: 0.55, maxChildSize: 0.95, builder: (context, scrollController) { return Column( children: [ - const SizedBox(height: 6), - Container( - width: 36, - height: 4, - decoration: BoxDecoration( - color: Colors.grey.shade300, - borderRadius: BorderRadius.circular(2), - ), - ), - const SizedBox(height: 10), - - MyText.titleMedium( - 'Bucket Details', - fontWeight: 700, - ), - - const SizedBox(height: 12), - - // Header with title and edit - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - Expanded( - child: MyText.titleMedium( - bucket.name, - fontWeight: 700, - ), - ), - if (canEdit) - IconButton( - onPressed: onEdit, - icon: const Icon(Icons.edit, color: Colors.red), - tooltip: 'Edit Bucket', - ), - ], - ), - ), - - // Info - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (bucket.description.isNotEmpty) - Padding( - padding: const EdgeInsets.only(bottom: 6), - child: MyText.bodySmall( - bucket.description, - color: Colors.grey[700], - ), - ), - Row( - children: [ - const Icon(Icons.contacts_outlined, - size: 14, color: Colors.grey), - const SizedBox(width: 4), - MyText.labelSmall( - '${bucket.numberOfContacts} contact(s)', - fontWeight: 600, - color: Colors.red, - ), - const SizedBox(width: 12), - const Icon(Icons.ios_share_outlined, - size: 14, color: Colors.grey), - const SizedBox(width: 4), - MyText.labelSmall( - 'Shared with (${members.length})', - fontWeight: 600, - color: Colors.indigo, - ), - ], - ), - Padding( - padding: const EdgeInsets.only(top: 8), - child: Row( - children: [ - const Icon(Icons.edit_outlined, - size: 14, color: Colors.grey), - const SizedBox(width: 4), - MyText.labelSmall( - canEdit - ? 'Can be edited by you' - : 'You don’t have edit access', - fontWeight: 600, - color: canEdit ? Colors.green : Colors.grey, - ), - ], - ), - ), - const SizedBox(height: 8), - const Divider(thickness: 1), - const SizedBox(height: 6), - MyText.labelLarge( - 'Shared with', - fontWeight: 700, - color: Colors.black, - ), - ], - ), - ), - - const SizedBox(height: 4), - - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: members.isEmpty - ? Center( - child: MyText.bodySmall( - "No team members found.", - fontWeight: 600, - color: Colors.grey, - ), - ) - : ListView.separated( - controller: scrollController, - itemCount: members.length, - separatorBuilder: (_, __) => - const SizedBox(height: 4), - itemBuilder: (context, index) { - final member = members[index]; - final firstName = member.firstName ?? ''; - final lastName = member.lastName ?? ''; - final isOwner = - member.id == bucket.createdBy.id; - - return ListTile( - dense: true, - contentPadding: EdgeInsets.zero, - leading: Avatar( - firstName: firstName, - lastName: lastName, - size: 32, - ), - title: Row( - children: [ - Expanded( - child: MyText.bodyMedium( - '${firstName.isNotEmpty ? firstName : 'Unnamed'} ${lastName.isNotEmpty ? lastName : ''}', - fontWeight: 600, - ), - ), - if (isOwner) - Container( - margin: - const EdgeInsets.only(left: 6), - padding: const EdgeInsets.symmetric( - horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.red.shade50, - borderRadius: - BorderRadius.circular(4), - ), - child: MyText.labelSmall( - "Owner", - fontWeight: 600, - color: Colors.red, - ), - ), - ], - ), - subtitle: MyText.bodySmall( - member.jobRole ?? '', - color: Colors.grey.shade600, - ), - ); - }, - ), - ), - ), - - const SizedBox(height: 8), + MySpacing.height(8), + _buildGrabHandle(), + MySpacing.height(10), + MyText.titleMedium('Bucket Details', fontWeight: 700), + MySpacing.height(12), + _buildHeader(bucket, canEdit, onEdit), + _buildInfo(bucket, members.length, canEdit), + MySpacing.height(6), + _buildMembersTitle(), + MySpacing.height(4), + Expanded(child: _buildMemberList(members, ownerId, scrollController)), + MySpacing.height(8), ], ); }, @@ -223,4 +63,152 @@ class TeamMembersBottomSheet { }, ); } + + static Widget _buildGrabHandle() { + return Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ); + } + + static Widget _buildHeader(ContactBucket bucket, bool canEdit, VoidCallback? onEdit) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Expanded( + child: MyText.titleMedium(bucket.name, fontWeight: 700), + ), + if (canEdit) + IconButton( + onPressed: onEdit, + icon: const Icon(Icons.edit, color: Colors.red), + tooltip: 'Edit Bucket', + ), + ], + ), + ); + } + + static Widget _buildInfo(ContactBucket bucket, int totalMembers, bool canEdit) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (bucket.description.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 6), + child: MyText.bodySmall( + bucket.description, + color: Colors.grey[700], + ), + ), + Row( + children: [ + const Icon(Icons.contacts_outlined, size: 14, color: Colors.grey), + const SizedBox(width: 4), + MyText.labelSmall( + '${bucket.numberOfContacts} contact(s)', + fontWeight: 600, + color: Colors.red, + ), + const SizedBox(width: 12), + const Icon(Icons.ios_share_outlined, size: 14, color: Colors.grey), + const SizedBox(width: 4), + MyText.labelSmall( + 'Shared with ($totalMembers)', + fontWeight: 600, + color: Colors.indigo, + ), + ], + ), + MySpacing.height(8), + Row( + children: [ + const Icon(Icons.edit_outlined, size: 14, color: Colors.grey), + const SizedBox(width: 4), + MyText.labelSmall( + canEdit ? 'Can be edited by you' : 'You don’t have edit access', + fontWeight: 600, + color: canEdit ? Colors.green : Colors.grey, + ), + ], + ), + MySpacing.height(8), + const Divider(thickness: 1), + ], + ), + ); + } + + static Widget _buildMembersTitle() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: MyText.labelLarge('Shared with', fontWeight: 700, color: Colors.black), + ); + } + + static Widget _buildMemberList(List members, String ownerId, ScrollController scrollController) { + if (members.isEmpty) { + return Center( + child: MyText.bodySmall( + "No team members found.", + fontWeight: 600, + color: Colors.grey, + ), + ); + } + + return ListView.separated( + controller: scrollController, + itemCount: members.length, + padding: const EdgeInsets.symmetric(horizontal: 16), + separatorBuilder: (_, __) => const SizedBox(height: 4), + itemBuilder: (context, index) { + final member = members[index]; + final firstName = member.firstName ?? ''; + final lastName = member.lastName ?? ''; + final isOwner = member.id == ownerId; + + return ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: Avatar(firstName: firstName, lastName: lastName, size: 32), + title: Row( + children: [ + Expanded( + child: MyText.bodyMedium( + '${firstName.isNotEmpty ? firstName : 'Unnamed'} ${lastName.isNotEmpty ? lastName : ''}', + fontWeight: 600, + ), + ), + if (isOwner) + Container( + margin: const EdgeInsets.only(left: 6), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(4), + ), + child: MyText.labelSmall( + "Owner", + fontWeight: 600, + color: Colors.red, + ), + ), + ], + ), + subtitle: MyText.bodySmall( + member.jobRole ?? '', + color: Colors.grey.shade600, + ), + ); + }, + ); + } } diff --git a/lib/model/directory/create_bucket_bottom_sheet.dart b/lib/model/directory/create_bucket_bottom_sheet.dart index 51495c3..212035d 100644 --- a/lib/model/directory/create_bucket_bottom_sheet.dart +++ b/lib/model/directory/create_bucket_bottom_sheet.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/controller/directory/create_bucket_controller.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text_style.dart'; @@ -38,125 +39,55 @@ class _CreateBucketBottomSheetState extends State { ); } + Widget _formContent() { + return Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.labelMedium("Bucket Name"), + MySpacing.height(8), + TextFormField( + initialValue: _controller.name.value, + onChanged: _controller.updateName, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return "Bucket name is required"; + } + return null; + }, + decoration: _inputDecoration("e.g., Project Docs"), + ), + MySpacing.height(16), + MyText.labelMedium("Description"), + MySpacing.height(8), + TextFormField( + initialValue: _controller.description.value, + onChanged: _controller.updateDescription, + maxLines: 3, + decoration: _inputDecoration("Optional bucket description"), + ), + ], + ), + ); + } + @override Widget build(BuildContext context) { - final theme = Theme.of(context); - return GetBuilder( builder: (_) { return SafeArea( top: false, - child: SingleChildScrollView( - padding: MediaQuery.of(context).viewInsets, - child: Container( - decoration: BoxDecoration( - color: theme.cardColor, - borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), - boxShadow: const [ - BoxShadow(color: Colors.black12, blurRadius: 12, offset: Offset(0, -2)), - ], - ), - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 40, - height: 5, - decoration: BoxDecoration( - color: Colors.grey.shade300, - borderRadius: BorderRadius.circular(10), - ), - ), - MySpacing.height(12), - Text("Create New Bucket", style: MyTextStyle.titleLarge(fontWeight: 700)), - MySpacing.height(24), - Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.labelMedium("Bucket Name"), - MySpacing.height(8), - TextFormField( - initialValue: _controller.name.value, - onChanged: _controller.updateName, - validator: (value) { - if (value == null || value.trim().isEmpty) { - return "Bucket name is required"; - } - return null; - }, - decoration: _inputDecoration("e.g., Project Docs"), - ), - MySpacing.height(16), - MyText.labelMedium("Description"), - MySpacing.height(8), - TextFormField( - initialValue: _controller.description.value, - onChanged: _controller.updateDescription, - maxLines: 3, - decoration: _inputDecoration("Optional bucket description"), - ), - MySpacing.height(24), - Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: () => Navigator.pop(context, false), - icon: const Icon(Icons.close, color: Colors.red), - label: MyText.bodyMedium("Cancel", color: Colors.red, fontWeight: 600), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: Colors.red), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), - ), - ), - ), - MySpacing.width(12), - Expanded( - child: Obx(() { - return ElevatedButton.icon( - onPressed: _controller.isCreating.value - ? null - : () async { - if (_formKey.currentState!.validate()) { - await _controller.createBucket(); - } - }, - icon: _controller.isCreating.value - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.white), - ), - ) - : const Icon(Icons.check_circle_outline, color: Colors.white), - label: MyText.bodyMedium( - _controller.isCreating.value ? "Creating..." : "Create", - color: Colors.white, - fontWeight: 600, - ), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.indigo, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 14), - ), - ); - }), - ), - ], - ), - ], - ), - ), - ], - ), - ), - ), + child: BaseBottomSheet( + title: "Create New Bucket", + child: _formContent(), + onCancel: () => Navigator.pop(context, false), + onSubmit: () async { + if (_formKey.currentState!.validate()) { + await _controller.createBucket(); + } + }, + isSubmitting: _controller.isCreating.value, ), ); }, diff --git a/lib/model/directory/directory_filter_bottom_sheet.dart b/lib/model/directory/directory_filter_bottom_sheet.dart index 6f10473..ea85e09 100644 --- a/lib/model/directory/directory_filter_bottom_sheet.dart +++ b/lib/model/directory/directory_filter_bottom_sheet.dart @@ -161,7 +161,7 @@ class _DirectoryFilterBottomSheetState ), const SizedBox(width: 4), MyText( - "$title (${selectedItems.length})", + "$title", fontWeight: 600, fontSize: 16, ), diff --git a/lib/model/directory/edit_bucket_bottom_sheet.dart b/lib/model/directory/edit_bucket_bottom_sheet.dart index f9bb4dc..7f31992 100644 --- a/lib/model/directory/edit_bucket_bottom_sheet.dart +++ b/lib/model/directory/edit_bucket_bottom_sheet.dart @@ -1,18 +1,22 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:collection/collection.dart'; import 'package:marco/controller/directory/manage_bucket_controller.dart'; +import 'package:marco/controller/directory/directory_controller.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; -import 'package:marco/model/directory/contact_bucket_list_model.dart'; -import 'package:marco/model/employee_model.dart'; -import 'package:marco/controller/directory/directory_controller.dart'; -import 'package:collection/collection.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:marco/model/employee_model.dart'; +import 'package:marco/model/directory/contact_bucket_list_model.dart'; class EditBucketBottomSheet { - static void show(BuildContext context, ContactBucket bucket, - List allEmployees, - {required String ownerId}) { + static void show( + BuildContext context, + ContactBucket bucket, + List allEmployees, { + required String ownerId, + }) { final ManageBucketController controller = Get.find(); final nameController = TextEditingController(text: bucket.name); @@ -25,7 +29,6 @@ class EditBucketBottomSheet { InputDecoration _inputDecoration(String label) { return InputDecoration( labelText: label, - hintStyle: TextStyle(color: Colors.grey.shade500, fontSize: 14), filled: true, fillColor: Colors.grey.shade100, border: OutlineInputBorder( @@ -36,9 +39,9 @@ class EditBucketBottomSheet { borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Colors.grey.shade300), ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5), + focusedBorder: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + borderSide: BorderSide(color: Colors.blueAccent, width: 1.5), ), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), @@ -46,256 +49,183 @@ class EditBucketBottomSheet { ); } + Future _handleSubmit() async { + final newName = nameController.text.trim(); + final newDesc = descController.text.trim(); + final newEmployeeIds = selectedIds.toList()..sort(); + final originalEmployeeIds = [...bucket.employeeIds]..sort(); + + final nameChanged = newName != bucket.name; + final descChanged = newDesc != bucket.description; + final employeeChanged = + !(const ListEquality().equals(newEmployeeIds, originalEmployeeIds)); + + if (!nameChanged && !descChanged && !employeeChanged) { + showAppSnackbar( + title: "No Changes", + message: "No changes were made to update the bucket.", + type: SnackbarType.warning, + ); + return; + } + + final success = await controller.updateBucket( + id: bucket.id, + name: newName, + description: newDesc, + employeeIds: newEmployeeIds, + originalEmployeeIds: originalEmployeeIds, + ); + + if (success) { + final directoryController = Get.find(); + await directoryController.fetchBuckets(); + Navigator.of(context).pop(); + } + } + + Widget _formContent() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: nameController, + decoration: _inputDecoration('Bucket Name'), + ), + MySpacing.height(16), + TextField( + controller: descController, + maxLines: 2, + decoration: _inputDecoration('Description'), + ), + MySpacing.height(20), + MyText.labelLarge('Shared With', fontWeight: 600), + MySpacing.height(8), + Obx(() => TextField( + controller: searchController, + onChanged: (value) => searchText.value = value.toLowerCase(), + decoration: InputDecoration( + hintText: 'Search employee...', + prefixIcon: const Icon(Icons.search, size: 20), + suffixIcon: searchText.value.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear, size: 18), + onPressed: () { + searchController.clear(); + searchText.value = ''; + }, + ) + : null, + isDense: true, + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + ), + )), + MySpacing.height(8), + Obx(() { + final filtered = allEmployees.where((emp) { + final fullName = '${emp.firstName} ${emp.lastName}'.toLowerCase(); + return fullName.contains(searchText.value); + }).toList(); + + return SizedBox( + height: 180, + child: ListView.separated( + itemCount: filtered.length, + separatorBuilder: (_, __) => const SizedBox(height: 2), + itemBuilder: (context, index) { + final emp = filtered[index]; + final fullName = '${emp.firstName} ${emp.lastName}'.trim(); + + return Obx(() => Theme( + data: Theme.of(context).copyWith( + unselectedWidgetColor: Colors.grey.shade500, + checkboxTheme: CheckboxThemeData( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4)), + side: const BorderSide(color: Colors.grey), + fillColor: + MaterialStateProperty.resolveWith((states) { + if (states.contains(MaterialState.selected)) { + return Colors.blueAccent; + } + return Colors.white; + }), + checkColor: MaterialStateProperty.all(Colors.white), + ), + ), + child: CheckboxListTile( + dense: true, + contentPadding: EdgeInsets.zero, + visualDensity: const VisualDensity(vertical: -4), + controlAffinity: ListTileControlAffinity.leading, + value: selectedIds.contains(emp.id), + onChanged: emp.id == ownerId + ? null + : (val) { + if (val == true) { + selectedIds.add(emp.id); + } else { + selectedIds.remove(emp.id); + } + }, + title: Row( + children: [ + Expanded( + child: MyText.bodyMedium( + fullName.isNotEmpty ? fullName : 'Unnamed', + fontWeight: 600, + ), + ), + if (emp.id == ownerId) + Container( + margin: const EdgeInsets.only(left: 6), + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(4), + ), + child: MyText.labelSmall( + "Owner", + fontWeight: 600, + color: Colors.red, + ), + ), + ], + ), + subtitle: emp.jobRole.isNotEmpty + ? MyText.bodySmall( + emp.jobRole, + color: Colors.grey.shade600, + ) + : null, + ), + )); + }, + ), + ); + }), + ], + ); + } + showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, builder: (context) { - return SingleChildScrollView( - padding: MediaQuery.of(context).viewInsets, - child: Container( - decoration: BoxDecoration( - color: Theme.of(context).cardColor, - borderRadius: - const BorderRadius.vertical(top: Radius.circular(24)), - boxShadow: const [ - BoxShadow( - color: Colors.black12, - blurRadius: 12, - offset: Offset(0, -2), - ), - ], - ), - padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Center( - child: Container( - width: 40, - height: 5, - decoration: BoxDecoration( - color: Colors.grey.shade300, - borderRadius: BorderRadius.circular(10), - ), - ), - ), - MySpacing.height(12), - Center( - child: MyText.titleMedium('Edit Bucket', fontWeight: 700), - ), - MySpacing.height(24), - - // Bucket Name - TextField( - controller: nameController, - decoration: _inputDecoration('Bucket Name'), - ), - MySpacing.height(16), - - // Description - TextField( - controller: descController, - maxLines: 2, - decoration: _inputDecoration('Description'), - ), - MySpacing.height(20), - - // Shared With - Align( - alignment: Alignment.centerLeft, - child: MyText.labelLarge('Shared With', fontWeight: 600), - ), - MySpacing.height(8), - - // Search - Obx(() => TextField( - controller: searchController, - onChanged: (value) => - searchText.value = value.toLowerCase(), - decoration: InputDecoration( - hintText: 'Search employee...', - prefixIcon: const Icon(Icons.search, size: 20), - suffixIcon: searchText.value.isNotEmpty - ? IconButton( - icon: const Icon(Icons.clear, size: 18), - onPressed: () { - searchController.clear(); - searchText.value = ''; - }, - ) - : null, - isDense: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 8), - filled: true, - fillColor: Colors.grey.shade100, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: BorderSide(color: Colors.grey.shade300), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: BorderSide(color: Colors.grey.shade300), - ), - focusedBorder: const OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(10)), - borderSide: - BorderSide(color: Colors.blueAccent, width: 1.5), - ), - ), - )), - MySpacing.height(8), - - // Employee list - Obx(() { - final filtered = allEmployees.where((emp) { - final fullName = - '${emp.firstName} ${emp.lastName}'.toLowerCase(); - return fullName.contains(searchText.value); - }).toList(); - - return SizedBox( - height: 180, - child: ListView.builder( - itemCount: filtered.length, - itemBuilder: (context, index) { - final emp = filtered[index]; - final fullName = - '${emp.firstName} ${emp.lastName}'.trim(); - - return Obx(() => Theme( - data: Theme.of(context).copyWith( - checkboxTheme: CheckboxThemeData( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(4), - ), - side: const BorderSide( - color: Colors.black, width: 2), - fillColor: - MaterialStateProperty.resolveWith( - (states) { - if (states - .contains(MaterialState.selected)) { - return Colors.blueAccent; - } - return Colors.transparent; - }), - checkColor: - MaterialStateProperty.all(Colors.white), - ), - ), - child: CheckboxListTile( - dense: true, - contentPadding: EdgeInsets.zero, - visualDensity: - const VisualDensity(vertical: -4), - controlAffinity: - ListTileControlAffinity.leading, - value: selectedIds.contains(emp.id), - onChanged: emp.id == ownerId - ? null - : (val) { - if (val == true) { - selectedIds.add(emp.id); - } else { - selectedIds.remove(emp.id); - } - }, - title: Text( - fullName.isNotEmpty ? fullName : 'Unnamed', - style: const TextStyle(fontSize: 13), - ), - ), - )); - }, - ), - ); - }), - - MySpacing.height(24), - - // Action Buttons - Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: () => Get.back(), - icon: const Icon(Icons.close, color: Colors.red), - label: MyText.bodyMedium("Cancel", - color: Colors.red, fontWeight: 600), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: Colors.red), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 12), - ), - ), - ), - MySpacing.width(12), - Expanded( - child: ElevatedButton.icon( - onPressed: () async { - final newName = nameController.text.trim(); - final newDesc = descController.text.trim(); - final newEmployeeIds = selectedIds.toList()..sort(); - final originalEmployeeIds = [...bucket.employeeIds] - ..sort(); - - final nameChanged = newName != bucket.name; - final descChanged = newDesc != bucket.description; - final employeeChanged = !(ListEquality() - .equals(newEmployeeIds, originalEmployeeIds)); - - if (!nameChanged && - !descChanged && - !employeeChanged) { - showAppSnackbar( - title: "No Changes", - message: - "No changes were made to update the bucket.", - type: SnackbarType.warning, - ); - return; - } - - final success = await controller.updateBucket( - id: bucket.id, - name: newName, - description: newDesc, - employeeIds: newEmployeeIds, - originalEmployeeIds: originalEmployeeIds, - ); - - if (success) { - final directoryController = - Get.find(); - await directoryController.fetchBuckets(); - Navigator.of(context).pop(); - } - }, - icon: const Icon(Icons.check_circle_outline, - color: Colors.white), - label: MyText.bodyMedium("Save", - color: Colors.white, fontWeight: 600), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.indigo, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 12), - ), - ), - ), - ], - ), - ], - ), - ), + return BaseBottomSheet( + title: "Edit Bucket", + onCancel: () => Navigator.pop(context), + onSubmit: _handleSubmit, + child: _formContent(), ); }, ); From 5f66c4c6474a5cdee87f2294fc6f2d1a063eab43 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Sat, 2 Aug 2025 10:16:01 +0530 Subject: [PATCH 40/65] feat: Update AttachmentsSection to use RxList for reactive attachment management --- .../expense/add_expense_bottom_sheet.dart | 44 ++++++++++--------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/lib/model/expense/add_expense_bottom_sheet.dart b/lib/model/expense/add_expense_bottom_sheet.dart index 6723c44..2d71f5b 100644 --- a/lib/model/expense/add_expense_bottom_sheet.dart +++ b/lib/model/expense/add_expense_bottom_sheet.dart @@ -446,7 +446,7 @@ class _TileContainer extends StatelessWidget { } class _AttachmentsSection extends StatelessWidget { - final List attachments; + final RxList attachments; final ValueChanged onRemove; final VoidCallback onAdd; @@ -458,27 +458,29 @@ class _AttachmentsSection extends StatelessWidget { @override Widget build(BuildContext context) { - return Wrap( - spacing: 8, - runSpacing: 8, - children: [ - ...attachments.map((file) => - _AttachmentTile(file: file, onRemove: () => onRemove(file))), - GestureDetector( - onTap: onAdd, - child: Container( - width: 80, - height: 80, - decoration: BoxDecoration( - border: Border.all(color: Colors.grey.shade400), - borderRadius: BorderRadius.circular(8), - color: Colors.grey.shade100, + return Obx(() => Wrap( + spacing: 8, + runSpacing: 8, + children: [ + ...attachments.map((file) => _AttachmentTile( + file: file, + onRemove: () => onRemove(file), + )), + GestureDetector( + onTap: onAdd, + child: Container( + width: 80, + height: 80, + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade400), + borderRadius: BorderRadius.circular(8), + color: Colors.grey.shade100, + ), + child: const Icon(Icons.add, size: 30, color: Colors.grey), + ), ), - child: const Icon(Icons.add, size: 30, color: Colors.grey), - ), - ), - ], - ); + ], + )); } } From 2518b65cb775f62d3737891fc209919b5e0b4973 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Sat, 2 Aug 2025 14:57:34 +0530 Subject: [PATCH 41/65] feat: Implement delete expense functionality with confirmation dialog --- .../expense/expense_screen_controller.dart | 19 ++ lib/helpers/services/api_endpoints.dart | 1 + lib/helpers/services/api_service.dart | 41 ++- lib/view/expense/expense_screen.dart | 257 +++++++++++++----- 4 files changed, 244 insertions(+), 74 deletions(-) diff --git a/lib/controller/expense/expense_screen_controller.dart b/lib/controller/expense/expense_screen_controller.dart index 56f8e58..078b4ad 100644 --- a/lib/controller/expense/expense_screen_controller.dart +++ b/lib/controller/expense/expense_screen_controller.dart @@ -61,6 +61,25 @@ class ExpenseController extends GetxController { await fetchMasterData(); } + Future deleteExpense(String expenseId) async { + try { + logSafe("Attempting to delete expense: $expenseId"); + final success = await ApiService.deleteExpense(expenseId); + if (success) { + expenses.removeWhere((e) => e.id == expenseId); + logSafe("Expense deleted successfully."); + Get.snackbar("Deleted", "Expense has been deleted successfully."); + } else { + logSafe("Failed to delete expense: $expenseId", level: LogLevel.error); + Get.snackbar("Failed", "Failed to delete expense."); + } + } catch (e, stack) { + logSafe("Exception in deleteExpense: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + Get.snackbar("Error", "Something went wrong while deleting."); + } + } + /// Fetch expenses using filters Future fetchExpenses({ List? projectIds, diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index 1e386b2..520feb1 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -60,4 +60,5 @@ class ApiEndpoints { static const String getMasterExpenseStatus = "/master/expenses-status"; static const String getMasterExpenseTypes = "/master/expenses-types"; static const String updateExpenseStatus = "/expense/action"; + static const String deleteExpense = "/expense/delete"; } diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 90c2888..a976f35 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -240,6 +240,43 @@ class ApiService { } // === Expense APIs === // + static Future deleteExpense(String expenseId) async { + final endpoint = "${ApiEndpoints.deleteExpense}/$expenseId"; + + try { + final token = await _getToken(); + if (token == null) { + logSafe("Token is null. Cannot proceed with DELETE request.", + level: LogLevel.error); + return false; + } + + final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint"); + + logSafe("Sending DELETE request to $uri", level: LogLevel.debug); + + final response = + await http.delete(uri, headers: _headers(token)).timeout(timeout); + + logSafe("DELETE expense response status: ${response.statusCode}"); + logSafe("DELETE expense response body: ${response.body}"); + + final json = jsonDecode(response.body); + if (response.statusCode == 200 && json['success'] == true) { + logSafe("Expense deleted successfully."); + return true; + } else { + logSafe( + "Failed to delete expense: ${json['message'] ?? 'Unknown error'}", + level: LogLevel.warning); + } + } catch (e, stack) { + logSafe("Exception during deleteExpenseApi: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return false; + } /// Get Expense Details API static Future?> getExpenseDetailsApi({ @@ -352,7 +389,8 @@ class ApiService { return false; } -static Future?> getExpenseListApi({ + + static Future?> getExpenseListApi({ String? filter, int pageSize = 20, int pageNumber = 1, @@ -402,6 +440,7 @@ static Future?> getExpenseListApi({ return null; } } + /// Fetch Master Payment Modes static Future?> getMasterPaymentModes() async { const endpoint = ApiEndpoints.getMasterPaymentModes; diff --git a/lib/view/expense/expense_screen.dart b/lib/view/expense/expense_screen.dart index b8d5f38..ac8b396 100644 --- a/lib/view/expense/expense_screen.dart +++ b/lib/view/expense/expense_screen.dart @@ -6,11 +6,12 @@ import 'package:marco/controller/expense/expense_screen_controller.dart'; import 'package:marco/helpers/utils/date_time_utils.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; import 'package:marco/model/expense/expense_list_model.dart'; import 'package:marco/view/expense/expense_detail_screen.dart'; import 'package:marco/model/expense/add_expense_bottom_sheet.dart'; import 'package:marco/view/expense/expense_filter_bottom_sheet.dart'; -import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; class ExpenseMainScreen extends StatefulWidget { const ExpenseMainScreen({super.key}); @@ -22,10 +23,8 @@ class ExpenseMainScreen extends StatefulWidget { class _ExpenseMainScreenState extends State { bool isHistoryView = false; final searchController = TextEditingController(); - String searchQuery = ''; - - final ProjectController projectController = Get.find(); - final ExpenseController expenseController = Get.put(ExpenseController()); + final expenseController = Get.put(ExpenseController()); + final projectController = Get.find(); @override void initState() { @@ -34,6 +33,7 @@ class _ExpenseMainScreenState extends State { } void _refreshExpenses() => expenseController.fetchExpenses(); + void _openFilterBottomSheet() { showModalBottomSheet( context: context, @@ -47,21 +47,27 @@ class _ExpenseMainScreenState extends State { } List _getFilteredExpenses() { - final lowerQuery = searchQuery.trim().toLowerCase(); + final query = searchController.text.trim().toLowerCase(); final now = DateTime.now(); - final filtered = expenseController.expenses.where((e) { - return lowerQuery.isEmpty || - e.expensesType.name.toLowerCase().contains(lowerQuery) || - e.supplerName.toLowerCase().contains(lowerQuery) || - e.paymentMode.name.toLowerCase().contains(lowerQuery); - }).toList(); - filtered.sort((a, b) => b.transactionDate.compareTo(a.transactionDate)); + final filtered = expenseController.expenses.where((e) { + return query.isEmpty || + e.expensesType.name.toLowerCase().contains(query) || + e.supplerName.toLowerCase().contains(query) || + e.paymentMode.name.toLowerCase().contains(query); + }).toList() + ..sort((a, b) => b.transactionDate.compareTo(a.transactionDate)); return isHistoryView - ? filtered.where((e) => e.transactionDate.isBefore(DateTime(now.year, now.month, 1))).toList() - : filtered.where((e) => - e.transactionDate.month == now.month && e.transactionDate.year == now.year).toList(); + ? filtered + .where((e) => + e.transactionDate.isBefore(DateTime(now.year, now.month))) + .toList() + : filtered + .where((e) => + e.transactionDate.month == now.month && + e.transactionDate.year == now.year) + .toList(); } @override @@ -74,7 +80,7 @@ class _ExpenseMainScreenState extends State { children: [ _SearchAndFilter( controller: searchController, - onChanged: (value) => setState(() => searchQuery = value), + onChanged: (_) => setState(() {}), onFilterTap: _openFilterBottomSheet, onRefreshTap: _refreshExpenses, expenseController: expenseController, @@ -88,6 +94,7 @@ class _ExpenseMainScreenState extends State { if (expenseController.isLoading.value) { return SkeletonLoaders.expenseListSkeletonLoader(); } + if (expenseController.errorMessage.isNotEmpty) { return Center( child: MyText.bodyMedium( @@ -97,16 +104,10 @@ class _ExpenseMainScreenState extends State { ); } - final listToShow = _getFilteredExpenses(); + final filteredList = _getFilteredExpenses(); return _ExpenseList( - expenseList: listToShow, - onViewDetail: () async { - final result = - await Get.to(() => ExpenseDetailScreen(expenseId: listToShow.first.id)); - if (result == true) { - expenseController.fetchExpenses(); - } - }, + expenseList: filteredList, + onViewDetail: () => expenseController.fetchExpenses(), ); }), ), @@ -122,9 +123,9 @@ class _ExpenseMainScreenState extends State { } } -///---------------------- APP BAR ----------------------/// class _ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget { final ProjectController projectController; + const _ExpenseAppBar({required this.projectController}); @override @@ -142,40 +143,38 @@ class _ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget { child: Row( children: [ IconButton( - icon: const Icon(Icons.arrow_back_ios_new, color: Colors.black, size: 20), + icon: const Icon(Icons.arrow_back_ios_new, + color: Colors.black, size: 20), onPressed: () => Get.offNamed('/dashboard'), ), MySpacing.width(8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, children: [ - MyText.titleLarge( - 'Expenses', - fontWeight: 700, - color: Colors.black, - ), + MyText.titleLarge('Expenses', fontWeight: 700), MySpacing.height(2), GetBuilder( builder: (_) { - final projectName = projectController.selectedProject?.name ?? 'Select Project'; + final name = projectController.selectedProject?.name ?? + 'Select Project'; return Row( children: [ - const Icon(Icons.work_outline, size: 14, color: Colors.grey), + const Icon(Icons.work_outline, + size: 14, color: Colors.grey), MySpacing.width(4), Expanded( child: MyText.bodySmall( - projectName, - fontWeight: 600, + name, overflow: TextOverflow.ellipsis, color: Colors.grey[700], + fontWeight: 600, ), ), ], ); }, - ) + ), ], ), ), @@ -186,7 +185,6 @@ class _ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget { } } -///---------------------- SEARCH AND FILTER ----------------------/// class _SearchAndFilter extends StatelessWidget { final TextEditingController controller; final ValueChanged onChanged; @@ -216,7 +214,8 @@ class _SearchAndFilter extends StatelessWidget { onChanged: onChanged, decoration: InputDecoration( contentPadding: const EdgeInsets.symmetric(horizontal: 12), - prefixIcon: const Icon(Icons.search, size: 20, color: Colors.grey), + prefixIcon: + const Icon(Icons.search, size: 20, color: Colors.grey), hintText: 'Search expenses...', filled: true, fillColor: Colors.white, @@ -235,25 +234,19 @@ class _SearchAndFilter extends StatelessWidget { MySpacing.width(8), Tooltip( message: 'Refresh Data', - child: InkWell( - borderRadius: BorderRadius.circular(24), - onTap: onRefreshTap, - child: const Padding( - padding: EdgeInsets.all(0), - child: Icon(Icons.refresh, color: Colors.green, size: 28), - ), + child: IconButton( + icon: const Icon(Icons.refresh, color: Colors.green, size: 24), + onPressed: onRefreshTap, ), ), - MySpacing.width(8), + MySpacing.width(4), Obx(() { - final bool showRedDot = expenseController.isFilterApplied; return IconButton( - onPressed: onFilterTap, icon: Stack( clipBehavior: Clip.none, children: [ - const Icon(Icons.tune, color: Colors.black, size: 24), - if (showRedDot) + const Icon(Icons.tune, color: Colors.black), + if (expenseController.isFilterApplied) Positioned( top: -1, right: -1, @@ -263,15 +256,13 @@ class _SearchAndFilter extends StatelessWidget { decoration: BoxDecoration( color: Colors.red, shape: BoxShape.circle, - border: Border.all( - color: Colors.white, - width: 1.5, - ), + border: Border.all(color: Colors.white, width: 1.5), ), ), ), ], ), + onPressed: onFilterTap, ); }), ], @@ -280,12 +271,14 @@ class _SearchAndFilter extends StatelessWidget { } } -///---------------------- TOGGLE BUTTONS ----------------------/// class _ToggleButtons extends StatelessWidget { final bool isHistoryView; final ValueChanged onToggle; - const _ToggleButtons({required this.isHistoryView, required this.onToggle}); + const _ToggleButtons({ + required this.isHistoryView, + required this.onToggle, + }); @override Widget build(BuildContext context) { @@ -353,13 +346,12 @@ class _ToggleButton extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(icon, size: 16, color: selected ? Colors.white : Colors.grey), + Icon(icon, + size: 16, color: selected ? Colors.white : Colors.grey), const SizedBox(width: 6), - MyText.bodyMedium( - label, - color: selected ? Colors.white : Colors.grey, - fontWeight: 600, - ), + MyText.bodyMedium(label, + color: selected ? Colors.white : Colors.grey, + fontWeight: 600), ], ), ), @@ -368,31 +360,134 @@ class _ToggleButton extends StatelessWidget { } } -///---------------------- EXPENSE LIST ----------------------/// class _ExpenseList extends StatelessWidget { final List expenseList; final Future Function()? onViewDetail; - const _ExpenseList({ - required this.expenseList, - this.onViewDetail, - }); + const _ExpenseList({required this.expenseList, this.onViewDetail}); + + void _showDeleteConfirmation(BuildContext context, ExpenseModel expense) { + final ExpenseController controller = Get.find(); + final RxBool isDeleting = false.obs; + + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: Obx(() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: isDeleting.value + ? const SizedBox( + height: 100, + child: Center(child: CircularProgressIndicator()), + ) + : Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.delete, + size: 48, color: Colors.redAccent), + const SizedBox(height: 16), + MyText.titleLarge( + "Delete Expense", + fontWeight: 600, + color: Theme.of(context).colorScheme.onBackground, + ), + const SizedBox(height: 12), + MyText.bodySmall( + "Are you sure you want to delete this draft expense?", + textAlign: TextAlign.center, + color: Theme.of(context) + .colorScheme + .onSurface + .withOpacity(0.7), + ), + const SizedBox(height: 24), + + // Updated Button UI + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: () => Navigator.pop(context), + icon: + const Icon(Icons.close, color: Colors.white), + label: MyText.bodyMedium( + "Cancel", + color: Colors.white, + fontWeight: 600, + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.grey, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: + const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + onPressed: () async { + isDeleting.value = true; + await controller.deleteExpense(expense.id); + isDeleting.value = false; + Navigator.pop(context); + + showAppSnackbar( + title: 'Deleted', + message: 'Expense has been deleted.', + type: SnackbarType.success, + ); + }, + icon: const Icon(Icons.delete_forever, + color: Colors.white), + label: MyText.bodyMedium( + "Delete", + color: Colors.white, + fontWeight: 600, + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.redAccent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: + const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + ], + ), + ], + ), + ); + }), + ), + ); + } @override Widget build(BuildContext context) { if (expenseList.isEmpty) { return Center(child: MyText.bodyMedium('No expenses found.')); } + return ListView.separated( padding: const EdgeInsets.fromLTRB(12, 12, 12, 80), itemCount: expenseList.length, - separatorBuilder: (_, __) => Divider(color: Colors.grey.shade300, height: 20), + separatorBuilder: (_, __) => + Divider(color: Colors.grey.shade300, height: 20), itemBuilder: (context, index) { final expense = expenseList[index]; final formattedDate = DateTimeUtils.convertUtcToLocal( expense.transactionDate.toIso8601String(), format: 'dd MMM yyyy, hh:mm a', ); + return GestureDetector( onTap: () async { final result = await Get.to( @@ -411,8 +506,24 @@ class _ExpenseList extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - MyText.bodyMedium(expense.expensesType.name, fontWeight: 600), - MyText.bodyMedium('₹ ${expense.amount.toStringAsFixed(2)}', fontWeight: 600), + MyText.bodyMedium(expense.expensesType.name, + fontWeight: 600), + Row( + children: [ + MyText.bodyMedium( + '₹ ${expense.amount.toStringAsFixed(2)}', + fontWeight: 600), + if (expense.status.name.toLowerCase() == 'draft') ...[ + const SizedBox(width: 8), + GestureDetector( + onTap: () => + _showDeleteConfirmation(context, expense), + child: const Icon(Icons.delete, + color: Colors.red, size: 20), + ), + ], + ], + ), ], ), const SizedBox(height: 6), From 0f0eb51c150a352b74c4f312d84f1a119a80dec8 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Sat, 2 Aug 2025 15:22:24 +0530 Subject: [PATCH 42/65] feat: Refactor TeamBottomSheet and TeamMembersBottomSheet to use BaseBottomSheet for improved UI consistency --- lib/helpers/widgets/my_team_model_sheet.dart | 97 ++++++----- .../widgets/team_members_bottom_sheet.dart | 109 ++++++------ .../assign_task_bottom_sheet .dart | 3 - .../daily_progress_report_filter.dart | 158 ++++++------------ lib/view/taskPlaning/daily_progress.dart | 38 ++--- 5 files changed, 178 insertions(+), 227 deletions(-) diff --git a/lib/helpers/widgets/my_team_model_sheet.dart b/lib/helpers/widgets/my_team_model_sheet.dart index b3f7c3c..f879073 100644 --- a/lib/helpers/widgets/my_team_model_sheet.dart +++ b/lib/helpers/widgets/my_team_model_sheet.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:marco/helpers/widgets/avatar.dart'; import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; class TeamBottomSheet { static void show({ @@ -9,46 +11,61 @@ class TeamBottomSheet { }) { showModalBottomSheet( context: context, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(12)), - ), - backgroundColor: Colors.white, - builder: (_) => Padding( - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Title and Close Icon - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - MyText.bodyLarge("Team Members", fontWeight: 600), - IconButton( - icon: const Icon(Icons.close, size: 20, color: Colors.black54), - onPressed: () => Navigator.pop(context), - ), - ], - ), - const Divider(thickness: 1.2), - // Team Member Rows - ...teamMembers.map((member) => _buildTeamMemberRow(member)), - ], - ), - ), - ); - } - - static Widget _buildTeamMemberRow(dynamic member) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( - children: [ - Avatar(firstName: member.firstName, lastName: '', size: 36), - const SizedBox(width: 10), - MyText.bodyMedium(member.firstName, fontWeight: 500), - ], - ), + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) { + return BaseBottomSheet( + title: 'Team Members', + onCancel: () => Navigator.pop(context), + onSubmit: () {}, + showButtons: false, + child: _TeamMemberList(teamMembers: teamMembers), + ); + }, + ); + } +} + +class _TeamMemberList extends StatelessWidget { + final List teamMembers; + + const _TeamMemberList({required this.teamMembers}); + + @override + Widget build(BuildContext context) { + if (teamMembers.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: MyText.bodySmall( + "No team members found.", + fontWeight: 600, + color: Colors.grey, + ), + ), + ); + } + + return ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: teamMembers.length, + separatorBuilder: (_, __) => const Divider(thickness: 0.8, height: 12), + itemBuilder: (_, index) { + final member = teamMembers[index]; + final String name = member.firstName ?? 'Unnamed'; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Avatar(firstName: member.firstName, lastName: '', size: 36), + MySpacing.width(10), + MyText.bodyMedium(name, fontWeight: 500), + ], + ), + ); + }, ); } } diff --git a/lib/helpers/widgets/team_members_bottom_sheet.dart b/lib/helpers/widgets/team_members_bottom_sheet.dart index ff1ff5c..949f870 100644 --- a/lib/helpers/widgets/team_members_bottom_sheet.dart +++ b/lib/helpers/widgets/team_members_bottom_sheet.dart @@ -3,6 +3,7 @@ import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/avatar.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/model/directory/contact_bucket_list_model.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; class TeamMembersBottomSheet { static void show( @@ -14,7 +15,7 @@ class TeamMembersBottomSheet { }) { final ownerId = bucket.createdBy.id; - // Ensure owner is first + // Ensure owner is listed first members.sort((a, b) { if (a.id == ownerId) return -1; if (b.id == ownerId) return 1; @@ -25,59 +26,59 @@ class TeamMembersBottomSheet { context: context, isScrollControlled: true, backgroundColor: Colors.transparent, - isDismissible: true, - enableDrag: true, - builder: (context) { - return SafeArea( - child: Container( - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), - child: DraggableScrollableSheet( - expand: false, - initialChildSize: 0.75, - minChildSize: 0.55, - maxChildSize: 0.95, - builder: (context, scrollController) { - return Column( - children: [ - MySpacing.height(8), - _buildGrabHandle(), - MySpacing.height(10), - MyText.titleMedium('Bucket Details', fontWeight: 700), - MySpacing.height(12), - _buildHeader(bucket, canEdit, onEdit), - _buildInfo(bucket, members.length, canEdit), - MySpacing.height(6), - _buildMembersTitle(), - MySpacing.height(4), - Expanded(child: _buildMemberList(members, ownerId, scrollController)), - MySpacing.height(8), - ], - ); - }, - ), + builder: (_) { + return BaseBottomSheet( + title: 'Bucket Details', + onCancel: () => Navigator.pop(context), + onSubmit: () {}, // Not used, but required + showButtons: false, + child: _TeamContent( + bucket: bucket, + members: members, + canEdit: canEdit, + onEdit: onEdit, + ownerId: ownerId, ), ); }, ); } +} - static Widget _buildGrabHandle() { - return Container( - width: 36, - height: 4, - decoration: BoxDecoration( - color: Colors.grey.shade300, - borderRadius: BorderRadius.circular(2), - ), +class _TeamContent extends StatelessWidget { + final ContactBucket bucket; + final List members; + final bool canEdit; + final VoidCallback? onEdit; + final String ownerId; + + const _TeamContent({ + required this.bucket, + required this.members, + required this.canEdit, + this.onEdit, + required this.ownerId, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + _buildHeader(), + _buildInfo(), + _buildMembersTitle(), + MySpacing.height(8), + SizedBox( + height: 300, + child: _buildMemberList(), + ), + ], ); } - static Widget _buildHeader(ContactBucket bucket, bool canEdit, VoidCallback? onEdit) { + Widget _buildHeader() { return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.symmetric(horizontal: 4), child: Row( children: [ Expanded( @@ -94,9 +95,9 @@ class TeamMembersBottomSheet { ); } - static Widget _buildInfo(ContactBucket bucket, int totalMembers, bool canEdit) { + Widget _buildInfo() { return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.only(bottom: 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -121,7 +122,7 @@ class TeamMembersBottomSheet { const Icon(Icons.ios_share_outlined, size: 14, color: Colors.grey), const SizedBox(width: 4), MyText.labelSmall( - 'Shared with ($totalMembers)', + 'Shared with (${members.length})', fontWeight: 600, color: Colors.indigo, ), @@ -139,21 +140,21 @@ class TeamMembersBottomSheet { ), ], ), - MySpacing.height(8), + MySpacing.height(12), const Divider(thickness: 1), ], ), ); } - static Widget _buildMembersTitle() { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), + Widget _buildMembersTitle() { + return Align( + alignment: Alignment.centerLeft, child: MyText.labelLarge('Shared with', fontWeight: 700, color: Colors.black), ); } - static Widget _buildMemberList(List members, String ownerId, ScrollController scrollController) { + Widget _buildMemberList() { if (members.isEmpty) { return Center( child: MyText.bodySmall( @@ -165,10 +166,8 @@ class TeamMembersBottomSheet { } return ListView.separated( - controller: scrollController, itemCount: members.length, - padding: const EdgeInsets.symmetric(horizontal: 16), - separatorBuilder: (_, __) => const SizedBox(height: 4), + separatorBuilder: (_, __) => const SizedBox(height: 6), itemBuilder: (context, index) { final member = members[index]; final firstName = member.firstName ?? ''; diff --git a/lib/model/dailyTaskPlaning/assign_task_bottom_sheet .dart b/lib/model/dailyTaskPlaning/assign_task_bottom_sheet .dart index f42826c..3eaa40c 100644 --- a/lib/model/dailyTaskPlaning/assign_task_bottom_sheet .dart +++ b/lib/model/dailyTaskPlaning/assign_task_bottom_sheet .dart @@ -70,9 +70,6 @@ class _AssignTaskBottomSheetState extends State { onCancel: () => Get.back(), onSubmit: _onAssignTaskPressed, isSubmitting: controller.isAssigningTask.value, - submitText: "Assign Task", - submitIcon: Icons.check_circle_outline, - submitColor: Colors.indigo, )); } diff --git a/lib/model/dailyTaskPlaning/daily_progress_report_filter.dart b/lib/model/dailyTaskPlaning/daily_progress_report_filter.dart index 11b41c3..586a0d2 100644 --- a/lib/model/dailyTaskPlaning/daily_progress_report_filter.dart +++ b/lib/model/dailyTaskPlaning/daily_progress_report_filter.dart @@ -1,10 +1,11 @@ import 'package:flutter/material.dart'; -import 'package:marco/controller/permission_controller.dart'; -import 'package:marco/controller/dashboard/daily_task_controller.dart'; import 'package:intl/intl.dart'; +import 'package:marco/controller/dashboard/daily_task_controller.dart'; +import 'package:marco/controller/permission_controller.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; import 'package:marco/helpers/widgets/my_text.dart'; -class DailyProgressReportFilter extends StatefulWidget { +class DailyProgressReportFilter extends StatelessWidget { final DailyTaskController controller; final PermissionController permissionController; @@ -14,20 +15,9 @@ class DailyProgressReportFilter extends StatefulWidget { required this.permissionController, }); - @override - State createState() => - _DailyProgressReportFilterState(); -} - -class _DailyProgressReportFilterState extends State { - @override - void initState() { - super.initState(); - } - String getLabelText() { - final startDate = widget.controller.startDateTask; - final endDate = widget.controller.endDateTask; + final startDate = controller.startDateTask; + final endDate = controller.endDateTask; if (startDate != null && endDate != null) { final start = DateFormat('dd MM yyyy').format(startDate); final end = DateFormat('dd MM yyyy').format(endDate); @@ -38,105 +28,55 @@ class _DailyProgressReportFilterState extends State { @override Widget build(BuildContext context) { - return SafeArea( - child: Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - ), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.only(top: 12, bottom: 8), - child: Center( - child: Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: Colors.grey[400], - borderRadius: BorderRadius.circular(4), - ), - ), - ), + return BaseBottomSheet( + title: "Filter Tasks", + onCancel: () => Navigator.pop(context), + + onSubmit: () { + Navigator.pop(context, { + 'startDate': controller.startDateTask, + 'endDate': controller.endDateTask, + }); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleSmall("Select Date Range", fontWeight: 600), + const SizedBox(height: 8), + InkWell( + borderRadius: BorderRadius.circular(10), + onTap: () => controller.selectDateRangeForTaskData( + context, + controller, + ), + child: Ink( + decoration: BoxDecoration( + color: Colors.grey.shade100, + border: Border.all(color: Colors.grey.shade400), + borderRadius: BorderRadius.circular(10), ), - const Divider(), - Padding( - padding: EdgeInsets.fromLTRB(16, 12, 16, 4), - child: Align( - alignment: Alignment.centerLeft, - child: MyText.titleSmall( - "Select Date Range", - fontWeight: 600, - ), - ), - ), - Padding( - padding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: InkWell( - borderRadius: BorderRadius.circular(10), - onTap: () => widget.controller.selectDateRangeForTaskData( - context, - widget.controller, - ), - child: Ink( - decoration: BoxDecoration( - color: Colors.grey.shade100, - border: Border.all(color: Colors.grey.shade400), - borderRadius: BorderRadius.circular(10), - ), - padding: const EdgeInsets.symmetric( - horizontal: 16, vertical: 14), - child: Row( - children: [ - Icon(Icons.date_range, color: Colors.blue.shade600), - const SizedBox(width: 12), - Expanded( - child: Text( - getLabelText(), - style: const TextStyle( - fontSize: 16, - color: Colors.black87, - fontWeight: FontWeight.w500, - ), - overflow: TextOverflow.ellipsis, - ), - ), - const Icon(Icons.arrow_drop_down, color: Colors.grey), - ], - ), - ), - ), - ), - const Divider(), - Padding( - padding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: SizedBox( - width: double.infinity, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + Icon(Icons.date_range, color: Colors.blue.shade600), + const SizedBox(width: 12), + Expanded( + child: Text( + getLabelText(), + style: const TextStyle( + fontSize: 16, + color: Colors.black87, + fontWeight: FontWeight.w500, ), + overflow: TextOverflow.ellipsis, ), - child: const Text('Apply Filter'), - onPressed: () { - WidgetsBinding.instance.addPostFrameCallback((_) { - Navigator.pop(context, { - 'startDate': widget.controller.startDateTask, - 'endDate': widget.controller.endDateTask, - }); - }); - }, ), - ), + const Icon(Icons.arrow_drop_down, color: Colors.grey), + ], ), - ], + ), ), - ), + ], ), ); } diff --git a/lib/view/taskPlaning/daily_progress.dart b/lib/view/taskPlaning/daily_progress.dart index 365c257..487a105 100644 --- a/lib/view/taskPlaning/daily_progress.dart +++ b/lib/view/taskPlaning/daily_progress.dart @@ -205,29 +205,27 @@ class _DailyProgressReportScreenState extends State } Future _openFilterSheet() async { - final result = await showModalBottomSheet>( - context: context, - isScrollControlled: true, - backgroundColor: Colors.white, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(12)), - ), - builder: (context) => DailyProgressReportFilter( - controller: dailyTaskController, - permissionController: permissionController, - ), - ); + final result = await showModalBottomSheet>( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => DailyProgressReportFilter( + controller: dailyTaskController, + permissionController: permissionController, + ), + ); - if (result != null) { - final selectedProjectId = result['projectId'] as String?; - if (selectedProjectId != null && - selectedProjectId != dailyTaskController.selectedProjectId) { - dailyTaskController.selectedProjectId = selectedProjectId; - await dailyTaskController.fetchTaskData(selectedProjectId); - dailyTaskController.update(['daily_progress_report_controller']); - } + if (result != null) { + final selectedProjectId = result['projectId'] as String?; + if (selectedProjectId != null && + selectedProjectId != dailyTaskController.selectedProjectId) { + dailyTaskController.selectedProjectId = selectedProjectId; + await dailyTaskController.fetchTaskData(selectedProjectId); + dailyTaskController.update(['daily_progress_report_controller']); } } +} + Future _refreshData() async { final projectId = dailyTaskController.selectedProjectId; From 70443d8e2497089199d79b1df772e062132d4f64 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Sat, 2 Aug 2025 15:34:38 +0530 Subject: [PATCH 43/65] feat: Refactor EmployeesScreen for improved readability and structure --- lib/view/employees/employees_screen.dart | 300 +++++++++-------------- 1 file changed, 112 insertions(+), 188 deletions(-) diff --git a/lib/view/employees/employees_screen.dart b/lib/view/employees/employees_screen.dart index 2c2c1ad..813e3a8 100644 --- a/lib/view/employees/employees_screen.dart +++ b/lib/view/employees/employees_screen.dart @@ -22,8 +22,7 @@ class EmployeesScreen extends StatefulWidget { } class _EmployeesScreenState extends State with UIMixin { - final EmployeesScreenController _employeeController = - Get.put(EmployeesScreenController()); + final EmployeesScreenController _employeeController = Get.put(EmployeesScreenController()); final TextEditingController _searchController = TextEditingController(); final RxList _filteredEmployees = [].obs; @@ -32,39 +31,37 @@ class _EmployeesScreenState extends State with UIMixin { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { _initEmployees(); - _searchController.addListener(() { - _filterEmployees(_searchController.text); - }); + _searchController.addListener(() => _filterEmployees(_searchController.text)); }); } Future _initEmployees() async { - final selectedProjectId = Get.find().selectedProject?.id; + final projectId = Get.find().selectedProject?.id; - if (selectedProjectId != null) { - _employeeController.selectedProjectId = selectedProjectId; - await _employeeController.fetchEmployeesByProject(selectedProjectId); - } else if (_employeeController.isAllEmployeeSelected.value) { + if (_employeeController.isAllEmployeeSelected.value) { _employeeController.selectedProjectId = null; await _employeeController.fetchAllEmployees(); + } else if (projectId != null) { + _employeeController.selectedProjectId = projectId; + await _employeeController.fetchEmployeesByProject(projectId); } else { _employeeController.clearEmployees(); } + _filterEmployees(_searchController.text); } Future _refreshEmployees() async { try { - final selectedProjectId = - Get.find().selectedProject?.id; - final isAllSelected = _employeeController.isAllEmployeeSelected.value; + final projectId = Get.find().selectedProject?.id; + final allSelected = _employeeController.isAllEmployeeSelected.value; - if (isAllSelected) { - _employeeController.selectedProjectId = null; + _employeeController.selectedProjectId = allSelected ? null : projectId; + + if (allSelected) { await _employeeController.fetchAllEmployees(); - } else if (selectedProjectId != null) { - _employeeController.selectedProjectId = selectedProjectId; - await _employeeController.fetchEmployeesByProject(selectedProjectId); + } else if (projectId != null) { + await _employeeController.fetchEmployeesByProject(projectId); } else { _employeeController.clearEmployees(); } @@ -79,17 +76,20 @@ class _EmployeesScreenState extends State with UIMixin { void _filterEmployees(String query) { final employees = _employeeController.employees; + if (query.isEmpty) { _filteredEmployees.assignAll(employees); return; } - final lowerQuery = query.toLowerCase(); + + final q = query.toLowerCase(); _filteredEmployees.assignAll( employees.where((e) => - e.name.toLowerCase().contains(lowerQuery) || - e.email.toLowerCase().contains(lowerQuery) || - e.phoneNumber.toLowerCase().contains(lowerQuery) || - e.jobRole.toLowerCase().contains(lowerQuery)), + e.name.toLowerCase().contains(q) || + e.email.toLowerCase().contains(q) || + e.phoneNumber.toLowerCase().contains(q) || + e.jobRole.toLowerCase().contains(q), + ), ); } @@ -98,7 +98,8 @@ class _EmployeesScreenState extends State with UIMixin { context: context, isScrollControlled: true, shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), backgroundColor: Colors.transparent, builder: (context) => AddEmployeeBottomSheet(), ); @@ -113,7 +114,8 @@ class _EmployeesScreenState extends State with UIMixin { context: context, isScrollControlled: true, shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24))), + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), backgroundColor: Colors.transparent, builder: (context) => AssignProjectBottomSheet( employeeId: employeeId, @@ -134,7 +136,7 @@ class _EmployeesScreenState extends State with UIMixin { child: GetBuilder( init: _employeeController, tag: 'employee_screen_controller', - builder: (controller) { + builder: (_) { _filterEmployees(_searchController.text); return SingleChildScrollView( padding: const EdgeInsets.only(bottom: 40), @@ -168,34 +170,24 @@ class _EmployeesScreenState extends State with UIMixin { title: Padding( padding: MySpacing.xy(16, 0), child: Row( - crossAxisAlignment: CrossAxisAlignment.center, children: [ IconButton( - icon: const Icon(Icons.arrow_back_ios_new, - color: Colors.black, size: 20), + icon: const Icon(Icons.arrow_back_ios_new, color: Colors.black, size: 20), onPressed: () => Get.offNamed('/dashboard'), ), MySpacing.width(8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, children: [ - MyText.titleLarge( - 'Employees', - fontWeight: 700, - color: Colors.black, - ), + MyText.titleLarge('Employees', fontWeight: 700, color: Colors.black), MySpacing.height(2), GetBuilder( builder: (projectController) { - final projectName = - projectController.selectedProject?.name ?? - 'Select Project'; + final projectName = projectController.selectedProject?.name ?? 'Select Project'; return Row( children: [ - const Icon(Icons.work_outline, - size: 14, color: Colors.grey), + const Icon(Icons.work_outline, size: 14, color: Colors.grey), MySpacing.width(4), Expanded( child: MyText.bodySmall( @@ -228,13 +220,7 @@ class _EmployeesScreenState extends State with UIMixin { decoration: BoxDecoration( color: Colors.red, borderRadius: BorderRadius.circular(28), - boxShadow: const [ - BoxShadow( - color: Colors.black26, - blurRadius: 6, - offset: Offset(0, 3), - ), - ], + boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 6, offset: Offset(0, 3))], ), child: const Row( mainAxisSize: MainAxisSize.min, @@ -271,11 +257,9 @@ class _EmployeesScreenState extends State with UIMixin { style: const TextStyle(fontSize: 13, height: 1.2), decoration: InputDecoration( isDense: true, - contentPadding: - const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), prefixIcon: const Icon(Icons.search, size: 18, color: Colors.grey), - prefixIconConstraints: - const BoxConstraints(minWidth: 32, minHeight: 32), + prefixIconConstraints: const BoxConstraints(minWidth: 32, minHeight: 32), hintText: 'Search contacts...', hintStyle: const TextStyle(fontSize: 13, color: Colors.grey), filled: true, @@ -324,46 +308,27 @@ class _EmployeesScreenState extends State with UIMixin { clipBehavior: Clip.none, children: [ const Icon(Icons.tune, color: Colors.black), - Obx(() { - return _employeeController.isAllEmployeeSelected.value - ? Positioned( - right: -1, - top: -1, - child: Container( - width: 10, - height: 10, - decoration: const BoxDecoration( - color: Colors.red, shape: BoxShape.circle), - ), - ) - : const SizedBox.shrink(); - }), + Obx(() => _employeeController.isAllEmployeeSelected.value + ? Positioned( + right: -1, + top: -1, + child: Container( + width: 10, + height: 10, + decoration: const BoxDecoration(color: Colors.red, shape: BoxShape.circle), + ), + ) + : const SizedBox.shrink()), ], ), onSelected: (value) async { if (value == 'all_employees') { - _employeeController.isAllEmployeeSelected.value = - !_employeeController.isAllEmployeeSelected.value; - - if (_employeeController.isAllEmployeeSelected.value) { - _employeeController.selectedProjectId = null; - await _employeeController.fetchAllEmployees(); - } else { - final selectedProjectId = - Get.find().selectedProject?.id; - if (selectedProjectId != null) { - _employeeController.selectedProjectId = selectedProjectId; - await _employeeController - .fetchEmployeesByProject(selectedProjectId); - } else { - _employeeController.clearEmployees(); - } - } - _filterEmployees(_searchController.text); + _employeeController.isAllEmployeeSelected.toggle(); + await _initEmployees(); _employeeController.update(['employee_screen_controller']); } }, - itemBuilder: (context) => [ + itemBuilder: (_) => [ PopupMenuItem( value: 'all_employees', child: Obx( @@ -371,17 +336,12 @@ class _EmployeesScreenState extends State with UIMixin { children: [ Checkbox( value: _employeeController.isAllEmployeeSelected.value, - onChanged: (bool? value) => - Navigator.pop(context, 'all_employees'), + onChanged: (_) => Navigator.pop(context, 'all_employees'), checkColor: Colors.white, activeColor: Colors.red, side: const BorderSide(color: Colors.black, width: 1.5), - fillColor: MaterialStateProperty.resolveWith((states) { - if (states.contains(MaterialState.selected)) { - return Colors.red; - } - return Colors.white; - }), + fillColor: MaterialStateProperty.resolveWith( + (states) => states.contains(MaterialState.selected) ? Colors.red : Colors.white), ), const Text('All Employees'), ], @@ -394,131 +354,95 @@ class _EmployeesScreenState extends State with UIMixin { Widget _buildEmployeeList() { return Obx(() { - final isLoading = _employeeController.isLoading.value; - final employees = _filteredEmployees; - - // Show skeleton loader while data is being fetched - if (isLoading) { + if (_employeeController.isLoading.value) { return ListView.separated( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), - itemCount: 8, // number of skeleton items + itemCount: 8, separatorBuilder: (_, __) => MySpacing.height(12), itemBuilder: (_, __) => SkeletonLoaders.employeeSkeletonCard(), ); } - // Show empty state when no employees are found + final employees = _filteredEmployees; + if (employees.isEmpty) { return Padding( padding: const EdgeInsets.only(top: 60), child: Center( - child: MyText.bodySmall( - "No Employees Found", - fontWeight: 600, - color: Colors.grey[700], - ), + child: MyText.bodySmall("No Employees Found", fontWeight: 600, color: Colors.grey[700]), ), ); } - // Show the actual employee list return ListView.separated( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), padding: MySpacing.only(bottom: 80), itemCount: employees.length, separatorBuilder: (_, __) => MySpacing.height(12), - itemBuilder: (context, index) { - final employee = employees[index]; - final nameParts = employee.name.trim().split(' '); - final firstName = nameParts.first; - final lastName = nameParts.length > 1 ? nameParts.last : ''; + itemBuilder: (_, index) { + final e = employees[index]; + final names = e.name.trim().split(' '); + final firstName = names.first; + final lastName = names.length > 1 ? names.last : ''; return InkWell( - onTap: () => - Get.to(() => EmployeeDetailPage(employeeId: employee.id)), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Avatar(firstName: firstName, lastName: lastName, size: 35), - MySpacing.width(12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.titleSmall( - employee.name, - fontWeight: 600, - overflow: TextOverflow.ellipsis, - ), - if (employee.jobRole.isNotEmpty) - MyText.bodySmall( - employee.jobRole, - color: Colors.grey[700], - overflow: TextOverflow.ellipsis, - ), - MySpacing.height(8), - if (employee.email.isNotEmpty && employee.email != '-') - GestureDetector( - onTap: () => - LauncherUtils.launchEmail(employee.email), - onLongPress: () => LauncherUtils.copyToClipboard( - employee.email, - typeLabel: 'Email'), - child: Row( - children: [ - const Icon(Icons.email_outlined, - size: 16, color: Colors.indigo), - MySpacing.width(4), - ConstrainedBox( - constraints: - const BoxConstraints(maxWidth: 180), - child: MyText.labelSmall( - employee.email, - overflow: TextOverflow.ellipsis, - color: Colors.indigo, - decoration: TextDecoration.underline, - ), - ), - ], - ), - ), - if (employee.email.isNotEmpty && employee.email != '-') - MySpacing.height(6), - if (employee.phoneNumber.isNotEmpty) - GestureDetector( - onTap: () => - LauncherUtils.launchPhone(employee.phoneNumber), - onLongPress: () => LauncherUtils.copyToClipboard( - employee.phoneNumber, - typeLabel: 'Phone'), - child: Row( - children: [ - const Icon(Icons.phone_outlined, - size: 16, color: Colors.indigo), - MySpacing.width(4), - MyText.labelSmall( - employee.phoneNumber, - color: Colors.indigo, - decoration: TextDecoration.underline, - ), - ], - ), - ), - ], - ), + onTap: () => Get.to(() => EmployeeDetailPage(employeeId: e.id)), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Avatar(firstName: firstName, lastName: lastName, size: 35), + MySpacing.width(12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleSmall(e.name, fontWeight: 600, overflow: TextOverflow.ellipsis), + if (e.jobRole.isNotEmpty) + MyText.bodySmall(e.jobRole, color: Colors.grey[700], overflow: TextOverflow.ellipsis), + MySpacing.height(8), + if (e.email.isNotEmpty && e.email != '-') + _buildLinkRow(icon: Icons.email_outlined, text: e.email, onTap: () => LauncherUtils.launchEmail(e.email), onLongPress: () => LauncherUtils.copyToClipboard(e.email, typeLabel: 'Email')), + if (e.email.isNotEmpty && e.email != '-') MySpacing.height(6), + if (e.phoneNumber.isNotEmpty) + _buildLinkRow(icon: Icons.phone_outlined, text: e.phoneNumber, onTap: () => LauncherUtils.launchPhone(e.phoneNumber), onLongPress: () => LauncherUtils.copyToClipboard(e.phoneNumber, typeLabel: 'Phone')), + ], ), - const Icon(Icons.arrow_forward_ios, - color: Colors.grey, size: 16), - ], - ), + ), + const Icon(Icons.arrow_forward_ios, color: Colors.grey, size: 16), + ], ), ); }, ); }); } + + Widget _buildLinkRow({ + required IconData icon, + required String text, + required VoidCallback onTap, + required VoidCallback onLongPress, + }) { + return GestureDetector( + onTap: onTap, + onLongPress: onLongPress, + child: Row( + children: [ + Icon(icon, size: 16, color: Colors.indigo), + MySpacing.width(4), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 180), + child: MyText.labelSmall( + text, + overflow: TextOverflow.ellipsis, + color: Colors.indigo, + decoration: TextDecoration.underline, + ), + ), + ], + ), + ); + } } From fe66f35be733544380af23bbdaa0ae7fcb75224b Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Sat, 2 Aug 2025 16:19:12 +0530 Subject: [PATCH 44/65] Refactor attendance management: Split attendance screen into tabs, add attendance logs and regularization requests tabs, and improve filter functionality. Update attendance filter sheet and enhance UI components for better user experience. --- .../attendance_screen_controller.dart | 309 +++--- .../attendance/attendence_filter_sheet.dart | 22 +- .../Attendence/attendance_logs_tab.dart | 189 ++++ .../Attendence/attendance_screen.dart | 991 ++++-------------- .../regularization_requests_tab.dart | 157 +++ .../Attendence/todays_attendance_tab.dart | 131 +++ 6 files changed, 854 insertions(+), 945 deletions(-) create mode 100644 lib/view/dashboard/Attendence/attendance_logs_tab.dart create mode 100644 lib/view/dashboard/Attendence/regularization_requests_tab.dart create mode 100644 lib/view/dashboard/Attendence/todays_attendance_tab.dart diff --git a/lib/controller/dashboard/attendance_screen_controller.dart b/lib/controller/dashboard/attendance_screen_controller.dart index f477be9..339067e 100644 --- a/lib/controller/dashboard/attendance_screen_controller.dart +++ b/lib/controller/dashboard/attendance_screen_controller.dart @@ -1,22 +1,25 @@ import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:image_picker/image_picker.dart'; import 'package:geolocator/geolocator.dart'; import 'package:intl/intl.dart'; -import 'package:marco/helpers/services/app_logger.dart'; + +import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/helpers/widgets/my_image_compressor.dart'; + import 'package:marco/model/attendance_model.dart'; import 'package:marco/model/project_model.dart'; import 'package:marco/model/employee_model.dart'; import 'package:marco/model/attendance_log_model.dart'; import 'package:marco/model/regularization_log_model.dart'; import 'package:marco/model/attendance_log_view_model.dart'; + import 'package:marco/controller/project_controller.dart'; class AttendanceController extends GetxController { + // Data models List attendances = []; List projects = []; List employees = []; @@ -24,19 +27,18 @@ class AttendanceController extends GetxController { List regularizationLogs = []; List attendenceLogsView = []; + // States String selectedTab = 'Employee List'; - DateTime? startDateAttendance; DateTime? endDateAttendance; - RxBool isLoading = true.obs; - RxBool isLoadingProjects = true.obs; - RxBool isLoadingEmployees = true.obs; - RxBool isLoadingAttendanceLogs = true.obs; - RxBool isLoadingRegularizationLogs = true.obs; - RxBool isLoadingLogView = true.obs; - - RxMap uploadingStates = {}.obs; + final isLoading = true.obs; + final isLoadingProjects = true.obs; + final isLoadingEmployees = true.obs; + final isLoadingAttendanceLogs = true.obs; + final isLoadingRegularizationLogs = true.obs; + final isLoadingLogView = true.obs; + final uploadingStates = {}.obs; @override void onInit() { @@ -56,76 +58,46 @@ class AttendanceController extends GetxController { logSafe("Default date range set: $startDateAttendance to $endDateAttendance"); } - Future _handleLocationPermission() async { - LocationPermission permission = await Geolocator.checkPermission(); - if (permission == LocationPermission.denied) { - permission = await Geolocator.requestPermission(); - if (permission == LocationPermission.denied) { - logSafe('Location permissions are denied', level: LogLevel.warning); - return false; - } - } - if (permission == LocationPermission.deniedForever) { - logSafe('Location permissions are permanently denied', level: LogLevel.error); - return false; - } - return true; - } + // ------------------ Project & Employee ------------------ Future fetchProjects() async { isLoadingProjects.value = true; - isLoading.value = true; final response = await ApiService.getProjects(); if (response != null && response.isNotEmpty) { - projects = response.map((json) => ProjectModel.fromJson(json)).toList(); + projects = response.map((e) => ProjectModel.fromJson(e)).toList(); logSafe("Projects fetched: ${projects.length}"); } else { - logSafe("Failed to fetch projects or no projects available.", level: LogLevel.error); projects = []; + logSafe("Failed to fetch projects or no projects available.", level: LogLevel.error); } isLoadingProjects.value = false; - isLoading.value = false; update(['attendance_dashboard_controller']); } - Future loadAttendanceData(String projectId) async { - await fetchEmployeesByProject(projectId); - await fetchAttendanceLogs(projectId); - await fetchRegularizationLogs(projectId); - await fetchProjectData(projectId); - } - - Future fetchProjectData(String? projectId) async { - if (projectId == null) return; - isLoading.value = true; - await Future.wait([ - fetchEmployeesByProject(projectId), - fetchAttendanceLogs(projectId, dateFrom: startDateAttendance, dateTo: endDateAttendance), - fetchRegularizationLogs(projectId), - ]); - isLoading.value = false; - logSafe("Project data fetched for project ID: $projectId"); - } - Future fetchEmployeesByProject(String? projectId) async { if (projectId == null) return; + isLoadingEmployees.value = true; + final response = await ApiService.getEmployeesByProject(projectId); if (response != null) { - employees = response.map((json) => EmployeeModel.fromJson(json)).toList(); + employees = response.map((e) => EmployeeModel.fromJson(e)).toList(); for (var emp in employees) { uploadingStates[emp.id] = false.obs; } logSafe("Employees fetched: ${employees.length} for project $projectId"); - update(); } else { logSafe("Failed to fetch employees for project $projectId", level: LogLevel.error); } + isLoadingEmployees.value = false; + update(); } + // ------------------ Attendance Capture ------------------ + Future captureAndUploadAttendance( String id, String employeeId, @@ -137,6 +109,7 @@ class AttendanceController extends GetxController { }) async { try { uploadingStates[employeeId]?.value = true; + XFile? image; if (imageCapture) { image = await ImagePicker().pickImage(source: ImageSource.camera, imageQuality: 80); @@ -144,24 +117,39 @@ class AttendanceController extends GetxController { logSafe("Image capture cancelled.", level: LogLevel.warning); return false; } + final compressedBytes = await compressImageToUnder100KB(File(image.path)); if (compressedBytes == null) { logSafe("Image compression failed.", level: LogLevel.error); return false; } + final compressedFile = await saveCompressedImageToFile(compressedBytes); image = XFile(compressedFile.path); } - final hasLocationPermission = await _handleLocationPermission(); - if (!hasLocationPermission) return false; - final position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high); - final imageName = imageCapture ? ApiService.generateImageName(employeeId, employees.length + 1) : ""; + + if (!await _handleLocationPermission()) return false; + final position = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.high); + + final imageName = imageCapture + ? ApiService.generateImageName(employeeId, employees.length + 1) + : ""; final result = await ApiService.uploadAttendanceImage( - id, employeeId, image, position.latitude, position.longitude, - imageName: imageName, projectId: projectId, comment: comment, - action: action, imageCapture: imageCapture, markTime: markTime, + id, + employeeId, + image, + position.latitude, + position.longitude, + imageName: imageName, + projectId: projectId, + comment: comment, + action: action, + imageCapture: imageCapture, + markTime: markTime, ); + logSafe("Attendance uploaded for $employeeId, action: $action"); return result; } catch (e, stacktrace) { @@ -172,8 +160,133 @@ class AttendanceController extends GetxController { } } - Future selectDateRangeForAttendance(BuildContext context, AttendanceController controller) async { + Future _handleLocationPermission() async { + LocationPermission permission = await Geolocator.checkPermission(); + + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + if (permission == LocationPermission.denied) { + logSafe('Location permissions are denied', level: LogLevel.warning); + return false; + } + } + + if (permission == LocationPermission.deniedForever) { + logSafe('Location permissions are permanently denied', level: LogLevel.error); + return false; + } + + return true; + } + + // ------------------ Attendance Logs ------------------ + + Future fetchAttendanceLogs(String? projectId, {DateTime? dateFrom, DateTime? dateTo}) async { + if (projectId == null) return; + + isLoadingAttendanceLogs.value = true; + + final response = await ApiService.getAttendanceLogs(projectId, dateFrom: dateFrom, dateTo: dateTo); + if (response != null) { + attendanceLogs = response.map((e) => AttendanceLogModel.fromJson(e)).toList(); + logSafe("Attendance logs fetched: ${attendanceLogs.length}"); + } else { + logSafe("Failed to fetch attendance logs for project $projectId", level: LogLevel.error); + } + + isLoadingAttendanceLogs.value = false; + update(); + } + + Map> groupLogsByCheckInDate() { + final groupedLogs = >{}; + + for (var logItem in attendanceLogs) { + final checkInDate = logItem.checkIn != null + ? DateFormat('dd MMM yyyy').format(logItem.checkIn!) + : 'Unknown'; + groupedLogs.putIfAbsent(checkInDate, () => []).add(logItem); + } + + final sortedEntries = groupedLogs.entries.toList() + ..sort((a, b) { + if (a.key == 'Unknown') return 1; + if (b.key == 'Unknown') return -1; + final dateA = DateFormat('dd MMM yyyy').parse(a.key); + final dateB = DateFormat('dd MMM yyyy').parse(b.key); + return dateB.compareTo(dateA); + }); + + return Map>.fromEntries(sortedEntries); + } + + // ------------------ Regularization Logs ------------------ + + Future fetchRegularizationLogs(String? projectId) async { + if (projectId == null) return; + + isLoadingRegularizationLogs.value = true; + + final response = await ApiService.getRegularizationLogs(projectId); + if (response != null) { + regularizationLogs = response.map((e) => RegularizationLogModel.fromJson(e)).toList(); + logSafe("Regularization logs fetched: ${regularizationLogs.length}"); + } else { + logSafe("Failed to fetch regularization logs for project $projectId", level: LogLevel.error); + } + + isLoadingRegularizationLogs.value = false; + update(); + } + + // ------------------ Attendance Log View ------------------ + + Future fetchLogsView(String? id) async { + if (id == null) return; + + isLoadingLogView.value = true; + + final response = await ApiService.getAttendanceLogView(id); + if (response != null) { + attendenceLogsView = response.map((e) => AttendanceLogViewModel.fromJson(e)).toList(); + attendenceLogsView.sort((a, b) => + (b.activityTime ?? DateTime(2000)).compareTo(a.activityTime ?? DateTime(2000))); + logSafe("Attendance log view fetched for ID: $id"); + } else { + logSafe("Failed to fetch attendance log view for ID $id", level: LogLevel.error); + } + + isLoadingLogView.value = false; + update(); + } + + // ------------------ Combined Load ------------------ + + Future loadAttendanceData(String projectId) async { + isLoading.value = true; + await fetchProjectData(projectId); + isLoading.value = false; + } + + Future fetchProjectData(String? projectId) async { + if (projectId == null) return; + + await Future.wait([ + fetchEmployeesByProject(projectId), + fetchAttendanceLogs(projectId, + dateFrom: startDateAttendance, dateTo: endDateAttendance), + fetchRegularizationLogs(projectId), + ]); + + logSafe("Project data fetched for project ID: $projectId"); + } + + // ------------------ UI Interaction ------------------ + + Future selectDateRangeForAttendance( + BuildContext context, AttendanceController controller) async { final today = DateTime.now(); + final picked = await showDateRangePicker( context: context, firstDate: DateTime(2022), @@ -190,14 +303,13 @@ class AttendanceController extends GetxController { child: Theme( data: Theme.of(context).copyWith( colorScheme: ColorScheme.light( - primary: const Color.fromARGB(255, 95, 132, 255), + primary: const Color(0xFF5F84FF), onPrimary: Colors.white, onSurface: Colors.teal.shade800, ), textButtonTheme: TextButtonThemeData( style: TextButton.styleFrom(foregroundColor: Colors.teal), ), - dialogTheme: DialogThemeData(backgroundColor: Colors.white), ), child: child!, ), @@ -210,6 +322,7 @@ class AttendanceController extends GetxController { startDateAttendance = picked.start; endDateAttendance = picked.end; logSafe("Date range selected: $startDateAttendance to $endDateAttendance"); + await controller.fetchAttendanceLogs( Get.find().selectedProject?.id, dateFrom: picked.start, @@ -217,78 +330,4 @@ class AttendanceController extends GetxController { ); } } - - Future fetchAttendanceLogs(String? projectId, {DateTime? dateFrom, DateTime? dateTo}) async { - if (projectId == null) return; - isLoadingAttendanceLogs.value = true; - isLoading.value = true; - final response = await ApiService.getAttendanceLogs(projectId, dateFrom: dateFrom, dateTo: dateTo); - if (response != null) { - attendanceLogs = response.map((json) => AttendanceLogModel.fromJson(json)).toList(); - logSafe("Attendance logs fetched: ${attendanceLogs.length}"); - update(); - } else { - logSafe("Failed to fetch attendance logs for project $projectId", level: LogLevel.error); - } - isLoadingAttendanceLogs.value = false; - isLoading.value = false; - } - - Map> groupLogsByCheckInDate() { - final groupedLogs = >{}; - for (var logItem in attendanceLogs) { - final checkInDate = logItem.checkIn != null - ? DateFormat('dd MMM yyyy').format(logItem.checkIn!) - : 'Unknown'; - groupedLogs.putIfAbsent(checkInDate, () => []); - groupedLogs[checkInDate]!.add(logItem); - } - final sortedEntries = groupedLogs.entries.toList() - ..sort((a, b) { - if (a.key == 'Unknown') return 1; - if (b.key == 'Unknown') return -1; - final dateA = DateFormat('dd MMM yyyy').parse(a.key); - final dateB = DateFormat('dd MMM yyyy').parse(b.key); - return dateB.compareTo(dateA); - }); - final sortedMap = Map>.fromEntries(sortedEntries); - logSafe("Logs grouped and sorted by check-in date."); - return sortedMap; - } - - Future fetchRegularizationLogs(String? projectId, {DateTime? dateFrom, DateTime? dateTo}) async { - if (projectId == null) return; - isLoadingRegularizationLogs.value = true; - isLoading.value = true; - final response = await ApiService.getRegularizationLogs(projectId); - if (response != null) { - regularizationLogs = response.map((json) => RegularizationLogModel.fromJson(json)).toList(); - logSafe("Regularization logs fetched: ${regularizationLogs.length}"); - update(); - } else { - logSafe("Failed to fetch regularization logs for project $projectId", level: LogLevel.error); - } - isLoadingRegularizationLogs.value = false; - isLoading.value = false; - } - - Future fetchLogsView(String? id) async { - if (id == null) return; - isLoadingLogView.value = true; - isLoading.value = true; - final response = await ApiService.getAttendanceLogView(id); - if (response != null) { - attendenceLogsView = response.map((json) => AttendanceLogViewModel.fromJson(json)).toList(); - attendenceLogsView.sort((a, b) { - if (a.activityTime == null || b.activityTime == null) return 0; - return b.activityTime!.compareTo(a.activityTime!); - }); - logSafe("Attendance log view fetched for ID: $id"); - update(); - } else { - logSafe("Failed to fetch attendance log view for ID $id", level: LogLevel.error); - } - isLoadingLogView.value = false; - isLoading.value = false; - } } diff --git a/lib/model/attendance/attendence_filter_sheet.dart b/lib/model/attendance/attendence_filter_sheet.dart index 4b11265..32fbc43 100644 --- a/lib/model/attendance/attendence_filter_sheet.dart +++ b/lib/model/attendance/attendence_filter_sheet.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; import 'package:marco/controller/permission_controller.dart'; import 'package:marco/controller/dashboard/attendance_screen_controller.dart'; -import 'package:intl/intl.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/utils/permission_constants.dart'; import 'package:marco/helpers/utils/base_bottom_sheet.dart'; @@ -19,7 +19,7 @@ class AttendanceFilterBottomSheet extends StatefulWidget { }); @override - _AttendanceFilterBottomSheetState createState() => + State createState() => _AttendanceFilterBottomSheetState(); } @@ -54,22 +54,20 @@ class _AttendanceFilterBottomSheetState {'label': 'Regularization Requests', 'value': 'regularizationRequests'}, ]; - final filteredViewOptions = viewOptions.where((item) { - if (item['value'] == 'regularizationRequests') { - return hasRegularizationPermission; - } - return true; + final filteredOptions = viewOptions.where((item) { + return item['value'] != 'regularizationRequests' || + hasRegularizationPermission; }).toList(); - List widgets = [ + final List widgets = [ Padding( - padding: const EdgeInsets.only(bottom: 4), + padding: EdgeInsets.only(bottom: 4), child: Align( alignment: Alignment.centerLeft, child: MyText.titleSmall("View", fontWeight: 600), ), ), - ...filteredViewOptions.map((item) { + ...filteredOptions.map((item) { return RadioListTile( dense: true, contentPadding: EdgeInsets.zero, @@ -81,14 +79,14 @@ class _AttendanceFilterBottomSheetState groupValue: tempSelectedTab, onChanged: (value) => setState(() => tempSelectedTab = value!), ); - }).toList(), + }), ]; if (tempSelectedTab == 'attendanceLogs') { widgets.addAll([ const Divider(), Padding( - padding: const EdgeInsets.only(top: 12, bottom: 4), + padding: EdgeInsets.only(top: 12, bottom: 4), child: Align( alignment: Alignment.centerLeft, child: MyText.titleSmall("Date Range", fontWeight: 600), diff --git a/lib/view/dashboard/Attendence/attendance_logs_tab.dart b/lib/view/dashboard/Attendence/attendance_logs_tab.dart new file mode 100644 index 0000000..f456b7d --- /dev/null +++ b/lib/view/dashboard/Attendence/attendance_logs_tab.dart @@ -0,0 +1,189 @@ +// lib/view/attendance/tabs/attendance_logs_tab.dart +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:intl/intl.dart'; +import 'package:marco/controller/dashboard/attendance_screen_controller.dart'; +import 'package:marco/helpers/widgets/avatar.dart'; +import 'package:marco/helpers/widgets/my_card.dart'; +import 'package:marco/helpers/widgets/my_container.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; +import 'package:marco/model/attendance/log_details_view.dart'; +import 'package:marco/model/attendance/attendence_action_button.dart'; + +class AttendanceLogsTab extends StatelessWidget { + final AttendanceController controller; + + const AttendanceLogsTab({super.key, required this.controller}); + + @override + Widget build(BuildContext context) { + return Obx(() { + final logs = List.of(controller.attendanceLogs); + logs.sort((a, b) { + final aDate = a.checkIn ?? DateTime(0); + final bDate = b.checkIn ?? DateTime(0); + return bDate.compareTo(aDate); + }); + + final dateFormat = DateFormat('dd MMM yyyy'); + final dateRangeText = controller.startDateAttendance != null && + controller.endDateAttendance != null + ? '${dateFormat.format(controller.endDateAttendance!)} - ${dateFormat.format(controller.startDateAttendance!)}' + : 'Select date range'; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + MyText.titleMedium("Attendance Logs", fontWeight: 600), + controller.isLoading.value + ? const SizedBox( + height: 20, width: 20, child: LinearProgressIndicator()) + : MyText.bodySmall( + dateRangeText, + fontWeight: 600, + color: Colors.grey[700], + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + if (controller.isLoadingAttendanceLogs.value) + SkeletonLoaders.employeeListSkeletonLoader() + else if (logs.isEmpty) + const SizedBox( + height: 120, + child: Center( + child: Text("No Attendance Logs Found for this Project"), + ), + ) + else + MyCard.bordered( + paddingAll: 8, + child: Column( + children: List.generate(logs.length, (index) { + final employee = logs[index]; + final currentDate = employee.checkIn != null + ? DateFormat('dd MMM yyyy').format(employee.checkIn!) + : ''; + final previousDate = + index > 0 && logs[index - 1].checkIn != null + ? DateFormat('dd MMM yyyy') + .format(logs[index - 1].checkIn!) + : ''; + final showDateHeader = + index == 0 || currentDate != previousDate; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showDateHeader) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: MyText.bodyMedium( + currentDate, + fontWeight: 700, + ), + ), + MyContainer( + paddingAll: 8, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Avatar( + firstName: employee.firstName, + lastName: employee.lastName, + size: 31, + ), + MySpacing.width(16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: MyText.bodyMedium( + employee.name, + fontWeight: 600, + overflow: TextOverflow.ellipsis, + ), + ), + MySpacing.width(6), + Flexible( + child: MyText.bodySmall( + '(${employee.designation})', + fontWeight: 600, + color: Colors.grey[700], + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + MySpacing.height(8), + if (employee.checkIn != null || + employee.checkOut != null) + Row( + children: [ + if (employee.checkIn != null) ...[ + const Icon(Icons.arrow_circle_right, + size: 16, color: Colors.green), + MySpacing.width(4), + MyText.bodySmall( + DateFormat('hh:mm a') + .format(employee.checkIn!), + fontWeight: 600, + ), + MySpacing.width(16), + ], + if (employee.checkOut != null) ...[ + const Icon(Icons.arrow_circle_left, + size: 16, color: Colors.red), + MySpacing.width(4), + MyText.bodySmall( + DateFormat('hh:mm a') + .format(employee.checkOut!), + fontWeight: 600, + ), + ], + ], + ), + MySpacing.height(12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + AttendanceActionButton( + employee: employee, + attendanceController: controller, + ), + MySpacing.width(8), + AttendanceLogViewButton( + employee: employee, + attendanceController: controller, + ), + ], + ), + ], + ), + ), + ], + ), + ), + if (index != logs.length - 1) + Divider(color: Colors.grey.withOpacity(0.3)), + ], + ); + }), + ), + ), + ], + ); + }); + } +} diff --git a/lib/view/dashboard/Attendence/attendance_screen.dart b/lib/view/dashboard/Attendence/attendance_screen.dart index f2fcda7..ebdd68d 100644 --- a/lib/view/dashboard/Attendence/attendance_screen.dart +++ b/lib/view/dashboard/Attendence/attendance_screen.dart @@ -2,845 +2,240 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/helpers/theme/app_theme.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; -import 'package:marco/helpers/utils/my_shadow.dart'; -import 'package:marco/helpers/widgets/my_card.dart'; -import 'package:marco/helpers/widgets/my_container.dart'; import 'package:marco/helpers/widgets/my_flex.dart'; import 'package:marco/helpers/widgets/my_flex_item.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/controller/dashboard/attendance_screen_controller.dart'; import 'package:marco/controller/permission_controller.dart'; -import 'package:intl/intl.dart'; -import 'package:marco/helpers/widgets/avatar.dart'; -import 'package:marco/model/attendance/log_details_view.dart'; -import 'package:marco/model/attendance/attendence_action_button.dart'; -import 'package:marco/model/attendance/regualrize_action_button.dart'; import 'package:marco/model/attendance/attendence_filter_sheet.dart'; import 'package:marco/controller/project_controller.dart'; -import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; +import 'package:marco/view/dashboard/Attendence/regularization_requests_tab.dart'; +import 'package:marco/view/dashboard/Attendence/attendance_logs_tab.dart'; +import 'package:marco/view/dashboard/Attendence/todays_attendance_tab.dart'; class AttendanceScreen extends StatefulWidget { - AttendanceScreen({super.key}); + const AttendanceScreen({super.key}); @override State createState() => _AttendanceScreenState(); } class _AttendanceScreenState extends State with UIMixin { - final AttendanceController attendanceController = - Get.put(AttendanceController()); - final PermissionController permissionController = - Get.put(PermissionController()); + final attendanceController = Get.put(AttendanceController()); + final permissionController = Get.put(PermissionController()); + final projectController = Get.find(); String selectedTab = 'todaysAttendance'; + @override void initState() { super.initState(); - final projectController = Get.find(); - final attendanceController = Get.find(); - - WidgetsBinding.instance.addPostFrameCallback((_) async { - // Listen for future changes in selected project - ever(projectController.selectedProjectId!, (projectId) async { - if (projectId != null && projectId.isNotEmpty) { - try { - await attendanceController.loadAttendanceData(projectId); - attendanceController.update(['attendance_dashboard_controller']); - } catch (e) { - debugPrint("Error updating data on project change: $e"); - } - } + WidgetsBinding.instance.addPostFrameCallback((_) { + // Listen for future project selection changes + ever(projectController.selectedProjectId, (projectId) async { + if (projectId.isNotEmpty) await _loadData(projectId); }); - // Load data initially if project is already selected - final initialProjectId = projectController.selectedProjectId?.value; - if (initialProjectId != null && initialProjectId.isNotEmpty) { - try { - await attendanceController.loadAttendanceData(initialProjectId); - attendanceController.update(['attendance_dashboard_controller']); - } catch (e) { - debugPrint("Error loading initial data: $e"); - } - } + // Load initial data + final projectId = projectController.selectedProjectId.value; + if (projectId.isNotEmpty) _loadData(projectId); }); } + Future _loadData(String projectId) async { + try { + await attendanceController.loadAttendanceData(projectId); + attendanceController.update(['attendance_dashboard_controller']); + } catch (e) { + debugPrint("Error loading data: $e"); + } + } + + Future _refreshData() async { + final projectId = projectController.selectedProjectId.value; + if (projectId.isNotEmpty) await _loadData(projectId); + } + + Widget _buildAppBar() { + return AppBar( + backgroundColor: const Color(0xFFF5F5F5), + elevation: 0.5, + automaticallyImplyLeading: false, + titleSpacing: 0, + title: Padding( + padding: MySpacing.xy(16, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + IconButton( + icon: const Icon(Icons.arrow_back_ios_new, color: Colors.black, size: 20), + onPressed: () => Get.offNamed('/dashboard'), + ), + MySpacing.width(8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleLarge('Attendance', fontWeight: 700, color: Colors.black), + MySpacing.height(2), + GetBuilder( + builder: (projectController) { + final projectName = projectController.selectedProject?.name ?? 'Select Project'; + return Row( + children: [ + const Icon(Icons.work_outline, size: 14, color: Colors.grey), + MySpacing.width(4), + Expanded( + child: MyText.bodySmall( + projectName, + fontWeight: 600, + overflow: TextOverflow.ellipsis, + color: Colors.grey[700], + ), + ), + ], + ); + }, + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildFilterAndRefreshRow() { + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + MyText.bodyMedium("Filter", fontWeight: 600), + Tooltip( + message: 'Filter Project', + child: InkWell( + borderRadius: BorderRadius.circular(24), + onTap: () async { + final result = await showModalBottomSheet>( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(12)), + ), + builder: (context) => AttendanceFilterBottomSheet( + controller: attendanceController, + permissionController: permissionController, + selectedTab: selectedTab, + ), + ); + + if (result != null) { + final selectedProjectId = projectController.selectedProjectId.value; + final selectedView = result['selectedTab'] as String?; + + if (selectedProjectId.isNotEmpty) { + try { + await attendanceController.fetchEmployeesByProject(selectedProjectId); + await attendanceController.fetchAttendanceLogs(selectedProjectId); + await attendanceController.fetchRegularizationLogs(selectedProjectId); + await attendanceController.fetchProjectData(selectedProjectId); + } catch (_) {} + + attendanceController.update(['attendance_dashboard_controller']); + } + + if (selectedView != null && selectedView != selectedTab) { + setState(() => selectedTab = selectedView); + } + } + }, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Icon(Icons.tune, color: Colors.blueAccent, size: 20), + ), + ), + ), + const SizedBox(width: 4), + MyText.bodyMedium("Refresh", fontWeight: 600), + Tooltip( + message: 'Refresh Data', + child: InkWell( + borderRadius: BorderRadius.circular(24), + onTap: _refreshData, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Icon(Icons.refresh, color: Colors.green, size: 22), + ), + ), + ), + ], + ); + } + + Widget _buildNoProjectWidget() { + return Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: MyText.titleMedium( + 'No Records Found', + fontWeight: 600, + color: Colors.grey[600], + ), + ), + ); + } + + Widget _buildSelectedTabContent() { + switch (selectedTab) { + case 'attendanceLogs': + return AttendanceLogsTab(controller: attendanceController); + case 'regularizationRequests': + return RegularizationRequestsTab(controller: attendanceController); + case 'todaysAttendance': + default: + return TodaysAttendanceTab(controller: attendanceController); + } + } + @override Widget build(BuildContext context) { return Scaffold( - appBar: PreferredSize( - preferredSize: const Size.fromHeight(72), - child: AppBar( - backgroundColor: const Color(0xFFF5F5F5), - elevation: 0.5, - automaticallyImplyLeading: false, - titleSpacing: 0, - title: Padding( - padding: MySpacing.xy(16, 0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - IconButton( - icon: const Icon(Icons.arrow_back_ios_new, - color: Colors.black, size: 20), - onPressed: () => Get.offNamed('/dashboard'), - ), - MySpacing.width(8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - MyText.titleLarge( - 'Attendance', - fontWeight: 700, - color: Colors.black, - ), - MySpacing.height(2), - GetBuilder( - builder: (projectController) { - final projectName = - projectController.selectedProject?.name ?? - 'Select Project'; - return Row( - children: [ - const Icon(Icons.work_outline, - size: 14, color: Colors.grey), - MySpacing.width(4), - Expanded( - child: MyText.bodySmall( - projectName, - fontWeight: 600, - overflow: TextOverflow.ellipsis, - color: Colors.grey[700], - ), - ), - ], - ); - }, - ), - ], - ), - ), - ], - ), - ), - ), - ), + appBar: PreferredSize(preferredSize: const Size.fromHeight(72), child: _buildAppBar()), body: SafeArea( - child: SingleChildScrollView( - padding: MySpacing.x(0), - child: GetBuilder( - init: attendanceController, - tag: 'attendance_dashboard_controller', - builder: (controller) { - final selectedProjectId = - Get.find().selectedProjectId?.value; - final bool noProjectSelected = - selectedProjectId == null || selectedProjectId.isEmpty; - return Column( + child: GetBuilder( + init: attendanceController, + tag: 'attendance_dashboard_controller', + builder: (controller) { + final selectedProjectId = projectController.selectedProjectId.value; + final noProjectSelected = selectedProjectId.isEmpty; + + return SingleChildScrollView( + padding: MySpacing.zero, + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MySpacing.height(flexSpacing), - Row( - mainAxisAlignment: MainAxisAlignment.end, + _buildFilterAndRefreshRow(), + MySpacing.height(flexSpacing), + MyFlex( children: [ - MyText.bodyMedium("Filter", fontWeight: 600), - Tooltip( - message: 'Filter Project', - child: InkWell( - borderRadius: BorderRadius.circular(24), - onTap: () async { - final result = await showModalBottomSheet< - Map>( - context: context, - isScrollControlled: true, - - backgroundColor: Colors.transparent, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical( - top: Radius.circular(12)), - ), - builder: (context) => AttendanceFilterBottomSheet( - controller: attendanceController, - permissionController: permissionController, - selectedTab: selectedTab, - ), - ); - - if (result != null) { - final selectedProjectId = - Get.find() - .selectedProjectId - .value; - - final selectedView = - result['selectedTab'] as String?; - - if (selectedProjectId != null) { - try { - await attendanceController - .fetchEmployeesByProject( - selectedProjectId); - await attendanceController - .fetchAttendanceLogs(selectedProjectId); - await attendanceController - .fetchRegularizationLogs( - selectedProjectId); - await attendanceController - .fetchProjectData(selectedProjectId); - } catch (_) {} - attendanceController.update( - ['attendance_dashboard_controller']); - } - - if (selectedView != null && - selectedView != selectedTab) { - setState(() { - selectedTab = selectedView; - }); - } - } - }, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Icon( - Icons.tune, - color: Colors.blueAccent, - size: 20, - ), - ), - ), - ), - ), - const SizedBox(width: 4), - MyText.bodyMedium("Refresh", fontWeight: 600), - Tooltip( - message: 'Refresh Data', - child: InkWell( - borderRadius: BorderRadius.circular(24), - onTap: () async { - final projectId = Get.find() - .selectedProjectId - .value; - if (projectId != null && projectId.isNotEmpty) { - try { - await attendanceController - .loadAttendanceData(projectId); - attendanceController.update( - ['attendance_dashboard_controller']); - } catch (e) { - debugPrint("Error refreshing data: $e"); - } - } - }, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Icon( - Icons.refresh, - color: Colors.green, - size: 22, - ), - ), - ), - ), + MyFlexItem( + sizes: 'lg-12 md-12 sm-12', + child: noProjectSelected + ? _buildNoProjectWidget() + : _buildSelectedTabContent(), ), ], ), - MySpacing.height(flexSpacing), - MyFlex(children: [ - MyFlexItem( - sizes: 'lg-12 md-12 sm-12', - child: noProjectSelected - ? Center( - child: Padding( - padding: const EdgeInsets.all(24.0), - child: MyText.titleMedium( - 'No Records Found', - fontWeight: 600, - color: Colors.grey[600], - ), - ), - ) - : selectedTab == 'todaysAttendance' - ? employeeListTab() - : selectedTab == 'attendanceLogs' - ? employeeLog() - : regularizationScreen(), - ), - ]), ], - ); - }, - ), + ), + ); + }, ), ), ); } - - String _formatDate(DateTime date) { - return "${date.day}/${date.month}/${date.year}"; - } - - Widget employeeListTab() { - return Obx(() { - final isLoading = attendanceController.isLoadingEmployees.value; - final employees = attendanceController.employees; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), - child: Row( - children: [ - Expanded( - child: MyText.titleMedium( - "Today's Attendance", - fontWeight: 600, - ), - ), - MyText.bodySmall( - _formatDate(DateTime.now()), - fontWeight: 600, - color: Colors.grey[700], - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - if (isLoading) - SkeletonLoaders.employeeListSkeletonLoader() - else if (employees.isEmpty) - SizedBox( - height: 120, - child: Center( - child: MyText.bodySmall( - "No Employees Assigned to This Project", - fontWeight: 600, - ), - ), - ) - else - MyCard.bordered( - borderRadiusAll: 4, - border: Border.all(color: Colors.grey.withOpacity(0.2)), - shadow: MyShadow(elevation: 1, position: MyShadowPosition.bottom), - paddingAll: 8, - child: Column( - children: List.generate(employees.length, (index) { - final employee = employees[index]; - return Column( - children: [ - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: MyContainer( - paddingAll: 5, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Avatar( - firstName: employee.firstName, - lastName: employee.lastName, - size: 31, - ), - MySpacing.width(16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Wrap( - crossAxisAlignment: - WrapCrossAlignment.center, - spacing: - 6, // spacing between name and designation - children: [ - MyText.bodyMedium( - employee.name, - fontWeight: 600, - overflow: TextOverflow.visible, - maxLines: null, - ), - MyText.bodySmall( - '(${employee.designation})', - fontWeight: 600, - overflow: TextOverflow.visible, - maxLines: null, - color: Colors.grey[700], - ), - ], - ), - MySpacing.height(8), - (employee.checkIn != null || - employee.checkOut != null) - ? Row( - children: [ - if (employee.checkIn != null) ...[ - const Icon( - Icons.arrow_circle_right, - size: 16, - color: Colors.green), - MySpacing.width(4), - Expanded( - child: MyText.bodySmall( - DateFormat('hh:mm a') - .format( - employee.checkIn!), - fontWeight: 600, - overflow: - TextOverflow.ellipsis, - ), - ), - MySpacing.width(16), - ], - if (employee.checkOut != - null) ...[ - const Icon( - Icons.arrow_circle_left, - size: 16, - color: Colors.red), - MySpacing.width(4), - Expanded( - child: MyText.bodySmall( - DateFormat('hh:mm a') - .format( - employee.checkOut!), - fontWeight: 600, - overflow: - TextOverflow.ellipsis, - ), - ), - ], - ], - ) - : const SizedBox.shrink(), - MySpacing.height(12), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - AttendanceActionButton( - employee: employee, - attendanceController: - attendanceController, - ), - if (employee.checkIn != null) ...[ - MySpacing.width(8), - AttendanceLogViewButton( - employee: employee, - attendanceController: - attendanceController, - ), - ], - ], - ), - ], - ), - ), - ], - ), - ), - ), - if (index != employees.length - 1) - Divider( - color: Colors.grey.withOpacity(0.3), - thickness: 1, - height: 1, - ), - ], - ); - }), - ), - ), - ], - ); - }); - } - - Widget employeeLog() { - return Obx(() { - final logs = List.of(attendanceController.attendanceLogs); - logs.sort((a, b) { - final aDate = a.checkIn ?? DateTime(0); - final bDate = b.checkIn ?? DateTime(0); - return bDate.compareTo(aDate); - }); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: MyText.titleMedium( - "Attendance Logs", - fontWeight: 600, - ), - ), - Obx(() { - if (attendanceController.isLoading.value) { - return const SizedBox( - height: 20, - width: 20, - child: LinearProgressIndicator(), - ); - } - final dateFormat = DateFormat('dd MMM yyyy'); - final dateRangeText = attendanceController - .startDateAttendance != - null && - attendanceController.endDateAttendance != null - ? '${dateFormat.format(attendanceController.endDateAttendance!)} - ${dateFormat.format(attendanceController.startDateAttendance!)}' - : 'Select date range'; - - return MyText.bodySmall( - dateRangeText, - fontWeight: 600, - color: Colors.grey[700], - overflow: TextOverflow.ellipsis, - ); - }), - ], - ), - ), - if (attendanceController.isLoadingAttendanceLogs.value) - SkeletonLoaders.employeeListSkeletonLoader() - else if (logs.isEmpty) - SizedBox( - height: 120, - child: Center( - child: MyText.bodySmall( - "No Attendance Logs Found for this Project", - fontWeight: 600, - ), - ), - ) - else - MyCard.bordered( - borderRadiusAll: 4, - border: Border.all(color: Colors.grey.withOpacity(0.2)), - shadow: MyShadow(elevation: 1, position: MyShadowPosition.bottom), - paddingAll: 8, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: List.generate(logs.length, (index) { - final employee = logs[index]; - final currentDate = employee.checkIn != null - ? DateFormat('dd MMM yyyy').format(employee.checkIn!) - : ''; - final previousDate = - index > 0 && logs[index - 1].checkIn != null - ? DateFormat('dd MMM yyyy') - .format(logs[index - 1].checkIn!) - : ''; - - final showDateHeader = - index == 0 || currentDate != previousDate; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showDateHeader) - Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: MyText.bodyMedium( - currentDate, - fontWeight: 700, - ), - ), - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: MyContainer( - paddingAll: 8, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Avatar( - firstName: employee.firstName, - lastName: employee.lastName, - size: 31, - ), - MySpacing.width(16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Flexible( - child: MyText.bodyMedium( - employee.name, - fontWeight: 600, - overflow: TextOverflow.ellipsis, - maxLines: 1, - ), - ), - MySpacing.width(6), - Flexible( - child: MyText.bodySmall( - '(${employee.designation})', - fontWeight: 600, - overflow: TextOverflow.ellipsis, - maxLines: 1, - color: Colors.grey[700], - ), - ), - ], - ), - MySpacing.height(8), - (employee.checkIn != null || - employee.checkOut != null) - ? Row( - children: [ - if (employee.checkIn != null) ...[ - const Icon( - Icons.arrow_circle_right, - size: 16, - color: Colors.green), - MySpacing.width(4), - Expanded( - child: MyText.bodySmall( - DateFormat('hh:mm a') - .format( - employee.checkIn!), - fontWeight: 600, - overflow: - TextOverflow.ellipsis, - ), - ), - MySpacing.width(16), - ], - if (employee.checkOut != - null) ...[ - const Icon( - Icons.arrow_circle_left, - size: 16, - color: Colors.red), - MySpacing.width(4), - Expanded( - child: MyText.bodySmall( - DateFormat('hh:mm a') - .format( - employee.checkOut!), - fontWeight: 600, - overflow: - TextOverflow.ellipsis, - ), - ), - ], - ], - ) - : const SizedBox.shrink(), - MySpacing.height(12), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Flexible( - child: AttendanceActionButton( - employee: employee, - attendanceController: - attendanceController, - ), - ), - MySpacing.width(8), - Flexible( - child: AttendanceLogViewButton( - employee: employee, - attendanceController: - attendanceController, - ), - ), - ], - ), - ], - ), - ), - ], - ), - ), - ), - if (index != logs.length - 1) - Divider( - color: Colors.grey.withOpacity(0.3), - thickness: 1, - height: 1, - ), - ], - ); - }), - ), - ), - ], - ); - }); - } - - Widget regularizationScreen() { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 4.0), - child: MyText.titleMedium( - "Regularization Requests", - fontWeight: 600, - ), - ), - Obx(() { - final employees = attendanceController.regularizationLogs; - if (attendanceController.isLoadingRegularizationLogs.value) { - return SkeletonLoaders.employeeListSkeletonLoader(); - } - - if (employees.isEmpty) { - return SizedBox( - height: 120, - child: Center( - child: MyText.bodySmall( - "No Regularization Requests Found for this Project", - fontWeight: 600, - ), - ), - ); - } - return MyCard.bordered( - borderRadiusAll: 4, - border: Border.all(color: Colors.grey.withOpacity(0.2)), - shadow: MyShadow(elevation: 1, position: MyShadowPosition.bottom), - paddingAll: 8, - child: Column( - children: List.generate(employees.length, (index) { - final employee = employees[index]; - return Column( - children: [ - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: MyContainer( - paddingAll: 8, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Avatar( - firstName: employee.firstName, - lastName: employee.lastName, - size: 31, - ), - MySpacing.width(16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Flexible( - child: MyText.bodyMedium( - employee.name, - fontWeight: 600, - overflow: TextOverflow.ellipsis, - maxLines: 1, - ), - ), - MySpacing.width(6), - Flexible( - child: MyText.bodySmall( - '(${employee.role})', - fontWeight: 600, - overflow: TextOverflow.ellipsis, - maxLines: 1, - color: Colors.grey[700], - ), - ), - ], - ), - MySpacing.height(8), - Row( - children: [ - if (employee.checkIn != null) ...[ - const Icon(Icons.arrow_circle_right, - size: 16, color: Colors.green), - MySpacing.width(4), - Expanded( - child: MyText.bodySmall( - DateFormat('hh:mm a') - .format(employee.checkIn!), - fontWeight: 600, - overflow: TextOverflow.ellipsis, - ), - ), - MySpacing.width(16), - ], - if (employee.checkOut != null) ...[ - const Icon(Icons.arrow_circle_left, - size: 16, color: Colors.red), - MySpacing.width(4), - Expanded( - child: MyText.bodySmall( - DateFormat('hh:mm a') - .format(employee.checkOut!), - fontWeight: 600, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ], - ), - MySpacing.height(12), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - RegularizeActionButton( - attendanceController: - attendanceController, - log: employee, - uniqueLogKey: employee.employeeId, - action: ButtonActions.approve, - ), - const SizedBox(width: 8), - RegularizeActionButton( - attendanceController: - attendanceController, - log: employee, - uniqueLogKey: employee.employeeId, - action: ButtonActions.reject, - ), - const SizedBox(width: 8), - if (employee.checkIn != null) ...[ - AttendanceLogViewButton( - employee: employee, - attendanceController: - attendanceController, - ), - ], - ], - ), - ], - ), - ), - ], - ), - ), - ), - if (index != employees.length - 1) - Divider( - color: Colors.grey.withOpacity(0.3), - thickness: 1, - height: 1, - ), - ], - ); - }), - ), - ); - }), - ], - ); - } } diff --git a/lib/view/dashboard/Attendence/regularization_requests_tab.dart b/lib/view/dashboard/Attendence/regularization_requests_tab.dart new file mode 100644 index 0000000..802075c --- /dev/null +++ b/lib/view/dashboard/Attendence/regularization_requests_tab.dart @@ -0,0 +1,157 @@ +// lib/view/attendance/tabs/regularization_requests_tab.dart +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:intl/intl.dart'; +import 'package:marco/controller/dashboard/attendance_screen_controller.dart'; +import 'package:marco/helpers/widgets/avatar.dart'; +import 'package:marco/helpers/widgets/my_card.dart'; +import 'package:marco/helpers/widgets/my_container.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; +import 'package:marco/model/attendance/log_details_view.dart'; +import 'package:marco/model/attendance/regualrize_action_button.dart'; + +class RegularizationRequestsTab extends StatelessWidget { + final AttendanceController controller; + + const RegularizationRequestsTab({super.key, required this.controller}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 4.0), + child: MyText.titleMedium("Regularization Requests", fontWeight: 600), + ), + Obx(() { + final employees = controller.regularizationLogs; + + if (controller.isLoadingRegularizationLogs.value) { + return SkeletonLoaders.employeeListSkeletonLoader(); + } + + if (employees.isEmpty) { + return const SizedBox( + height: 120, + child: Center( + child: Text("No Regularization Requests Found for this Project"), + ), + ); + } + + return MyCard.bordered( + paddingAll: 8, + child: Column( + children: List.generate(employees.length, (index) { + final employee = employees[index]; + return Column( + children: [ + MyContainer( + paddingAll: 8, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Avatar( + firstName: employee.firstName, + lastName: employee.lastName, + size: 31, + ), + MySpacing.width(16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: MyText.bodyMedium( + employee.name, + fontWeight: 600, + overflow: TextOverflow.ellipsis, + ), + ), + MySpacing.width(6), + Flexible( + child: MyText.bodySmall( + '(${employee.role})', + fontWeight: 600, + overflow: TextOverflow.ellipsis, + color: Colors.grey[700], + ), + ), + ], + ), + MySpacing.height(8), + if (employee.checkIn != null || + employee.checkOut != null) + Row( + children: [ + if (employee.checkIn != null) ...[ + const Icon(Icons.arrow_circle_right, + size: 16, color: Colors.green), + MySpacing.width(4), + MyText.bodySmall( + DateFormat('hh:mm a') + .format(employee.checkIn!), + fontWeight: 600, + ), + MySpacing.width(16), + ], + if (employee.checkOut != null) ...[ + const Icon(Icons.arrow_circle_left, + size: 16, color: Colors.red), + MySpacing.width(4), + MyText.bodySmall( + DateFormat('hh:mm a') + .format(employee.checkOut!), + fontWeight: 600, + ), + ], + ], + ), + MySpacing.height(12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + RegularizeActionButton( + attendanceController: controller, + log: employee, + uniqueLogKey: employee.employeeId, + action: ButtonActions.approve, + ), + const SizedBox(width: 8), + RegularizeActionButton( + attendanceController: controller, + log: employee, + uniqueLogKey: employee.employeeId, + action: ButtonActions.reject, + ), + const SizedBox(width: 8), + if (employee.checkIn != null) + AttendanceLogViewButton( + employee: employee, + attendanceController: controller, + ), + ], + ), + ], + ), + ), + ], + ), + ), + if (index != employees.length - 1) + Divider(color: Colors.grey.withOpacity(0.3)), + ], + ); + }), + ), + ); + }), + ], + ); + } +} diff --git a/lib/view/dashboard/Attendence/todays_attendance_tab.dart b/lib/view/dashboard/Attendence/todays_attendance_tab.dart new file mode 100644 index 0000000..caa1f06 --- /dev/null +++ b/lib/view/dashboard/Attendence/todays_attendance_tab.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:intl/intl.dart'; +import 'package:marco/controller/dashboard/attendance_screen_controller.dart'; +import 'package:marco/helpers/widgets/avatar.dart'; +import 'package:marco/helpers/widgets/my_card.dart'; +import 'package:marco/helpers/widgets/my_container.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; +import 'package:marco/model/attendance/log_details_view.dart'; +import 'package:marco/model/attendance/attendence_action_button.dart'; + +class TodaysAttendanceTab extends StatelessWidget { + final AttendanceController controller; + + const TodaysAttendanceTab({super.key, required this.controller}); + + String _formatDate(DateTime date) { + return "${date.day}/${date.month}/${date.year}"; + } + + @override + Widget build(BuildContext context) { + return Obx(() { + final isLoading = controller.isLoadingEmployees.value; + final employees = controller.employees; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: Row( + children: [ + Expanded( + child: MyText.titleMedium("Today's Attendance", fontWeight: 600), + ), + MyText.bodySmall( + _formatDate(DateTime.now()), + fontWeight: 600, + color: Colors.grey[700], + ), + ], + ), + ), + if (isLoading) + SkeletonLoaders.employeeListSkeletonLoader() + else if (employees.isEmpty) + const SizedBox(height: 120, child: Center(child: Text("No Employees Assigned"))) + else + MyCard.bordered( + paddingAll: 8, + child: Column( + children: List.generate(employees.length, (index) { + final employee = employees[index]; + return Column( + children: [ + MyContainer( + paddingAll: 5, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Avatar(firstName: employee.firstName, lastName: employee.lastName, size: 31), + MySpacing.width(16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 6, + children: [ + MyText.bodyMedium(employee.name, fontWeight: 600), + MyText.bodySmall('(${employee.designation})', fontWeight: 600, color: Colors.grey[700]), + ], + ), + MySpacing.height(8), + if (employee.checkIn != null || employee.checkOut != null) + Row( + children: [ + if (employee.checkIn != null) + Row( + children: [ + const Icon(Icons.arrow_circle_right, size: 16, color: Colors.green), + MySpacing.width(4), + Text(DateFormat('hh:mm a').format(employee.checkIn!)), + ], + ), + if (employee.checkOut != null) ...[ + MySpacing.width(16), + const Icon(Icons.arrow_circle_left, size: 16, color: Colors.red), + MySpacing.width(4), + Text(DateFormat('hh:mm a').format(employee.checkOut!)), + ], + ], + ), + MySpacing.height(12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + AttendanceActionButton( + employee: employee, + attendanceController: controller, + ), + if (employee.checkIn != null) ...[ + MySpacing.width(8), + AttendanceLogViewButton( + employee: employee, + attendanceController: controller, + ), + ], + ], + ), + ], + ), + ), + ], + ), + ), + if (index != employees.length - 1) + Divider(color: Colors.grey.withOpacity(0.3)), + ], + ); + }), + ), + ), + ], + ); + }); + } +} From 9d9afe37b8a8c63e572b051d497beff1b25a722a Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Sat, 2 Aug 2025 16:44:39 +0530 Subject: [PATCH 45/65] refactor: Clean up AttendanceActionButton and AttendanceFilterBottomSheet code by removing unused comment bottom sheet function and filter button properties for improved readability and maintainability. --- .../attendance/attendence_action_button.dart | 358 ++++++++---------- .../attendance/attendence_filter_sheet.dart | 3 - 2 files changed, 153 insertions(+), 208 deletions(-) diff --git a/lib/model/attendance/attendence_action_button.dart b/lib/model/attendance/attendence_action_button.dart index 90d263d..ebbbd9a 100644 --- a/lib/model/attendance/attendence_action_button.dart +++ b/lib/model/attendance/attendence_action_button.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:intl/intl.dart'; + import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/controller/dashboard/attendance_screen_controller.dart'; import 'package:marco/helpers/utils/attendance_actions.dart'; @@ -21,76 +22,6 @@ class AttendanceActionButton extends StatefulWidget { State createState() => _AttendanceActionButtonState(); } -Future _showCommentBottomSheet( - BuildContext context, String actionText) async { - final TextEditingController commentController = TextEditingController(); - String? errorText; - - return showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), - builder: (context) { - return StatefulBuilder( - builder: (context, setModalState) { - void submit() { - final comment = commentController.text.trim(); - if (comment.isEmpty) { - setModalState(() => errorText = 'Comment cannot be empty.'); - return; - } - Navigator.of(context).pop(comment); - } - - return Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - ), - child: BaseBottomSheet( - title: 'Add Comment for ${capitalizeFirstLetter(actionText)}', - onCancel: () => Navigator.of(context).pop(), - onSubmit: submit, - isSubmitting: false, - submitText: 'Submit', - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: commentController, - maxLines: 4, - decoration: InputDecoration( - hintText: 'Type your comment here...', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - filled: true, - fillColor: Colors.grey.shade100, - errorText: errorText, - ), - onChanged: (_) { - if (errorText != null) { - setModalState(() => errorText = null); - } - }, - ), - ], - ), - ), - ); - }, - ); - }, - ); -} - -String capitalizeFirstLetter(String text) { - if (text.isEmpty) return text; - return text[0].toUpperCase() + text.substring(1); -} - class _AttendanceActionButtonState extends State { late final String uniqueLogKey; @@ -110,51 +41,45 @@ class _AttendanceActionButtonState extends State { }); } - Future showTimePickerForRegularization({ - required BuildContext context, - required DateTime checkInTime, - }) async { + Future _pickRegularizationTime(DateTime checkInTime) async { final pickedTime = await showTimePicker( context: context, initialTime: TimeOfDay.fromDateTime(DateTime.now()), ); - if (pickedTime != null) { - final selectedDateTime = DateTime( - checkInTime.year, - checkInTime.month, - checkInTime.day, - pickedTime.hour, - pickedTime.minute, + if (pickedTime == null) return null; + + final selected = DateTime( + checkInTime.year, + checkInTime.month, + checkInTime.day, + pickedTime.hour, + pickedTime.minute, + ); + + final now = DateTime.now(); + + if (selected.isBefore(checkInTime)) { + showAppSnackbar( + title: "Invalid Time", + message: "Time must be after check-in.", + type: SnackbarType.warning, ); - - final now = DateTime.now(); - - if (selectedDateTime.isBefore(checkInTime)) { - showAppSnackbar( - title: "Invalid Time", - message: "Time must be after check-in.", - type: SnackbarType.warning, - ); - return null; - } else if (selectedDateTime.isAfter(now)) { - showAppSnackbar( - title: "Invalid Time", - message: "Future time is not allowed.", - type: SnackbarType.warning, - ); - return null; - } - - return selectedDateTime; + return null; + } else if (selected.isAfter(now)) { + showAppSnackbar( + title: "Invalid Time", + message: "Future time is not allowed.", + type: SnackbarType.warning, + ); + return null; } - return null; + return selected; } - void _handleButtonPressed(BuildContext context) async { - widget.attendanceController.uploadingStates[uniqueLogKey]?.value = true; - + Future _handleButtonPressed() async { + final controller = widget.attendanceController; final projectController = Get.find(); final selectedProjectId = projectController.selectedProject?.id; @@ -164,46 +89,43 @@ class _AttendanceActionButtonState extends State { message: "Please select a project first", type: SnackbarType.error, ); - widget.attendanceController.uploadingStates[uniqueLogKey]?.value = false; return; } - int updatedAction; + controller.uploadingStates[uniqueLogKey]?.value = true; + + int action; String actionText; bool imageCapture = true; switch (widget.employee.activity) { case 0: - updatedAction = 0; + case 4: + action = 0; actionText = ButtonActions.checkIn; break; case 1: - if (widget.employee.checkOut == null && - AttendanceButtonHelper.isOlderThanDays( - widget.employee.checkIn, 2)) { - updatedAction = 2; + final isOld = AttendanceButtonHelper.isOlderThanDays(widget.employee.checkIn, 2); + final isOldCheckout = AttendanceButtonHelper.isOlderThanDays(widget.employee.checkOut, 2); + + if (widget.employee.checkOut == null && isOld) { + action = 2; actionText = ButtonActions.requestRegularize; imageCapture = false; - } else if (widget.employee.checkOut != null && - AttendanceButtonHelper.isOlderThanDays( - widget.employee.checkOut, 2)) { - updatedAction = 2; + } else if (widget.employee.checkOut != null && isOldCheckout) { + action = 2; actionText = ButtonActions.requestRegularize; } else { - updatedAction = 1; + action = 1; actionText = ButtonActions.checkOut; } break; case 2: - updatedAction = 2; + action = 2; actionText = ButtonActions.requestRegularize; break; - case 4: - updatedAction = 0; - actionText = ButtonActions.checkIn; - break; default: - updatedAction = 0; + action = 0; actionText = "Unknown Action"; break; } @@ -219,67 +141,41 @@ class _AttendanceActionButtonState extends State { if (isYesterdayCheckIn && widget.employee.checkOut == null && actionText == ButtonActions.checkOut) { - selectedTime = await showTimePickerForRegularization( - context: context, - checkInTime: widget.employee.checkIn!, - ); - + selectedTime = await _pickRegularizationTime(widget.employee.checkIn!); if (selectedTime == null) { - widget.attendanceController.uploadingStates[uniqueLogKey]?.value = - false; + controller.uploadingStates[uniqueLogKey]?.value = false; return; } } - final userComment = await _showCommentBottomSheet(context, actionText); - if (userComment == null || userComment.isEmpty) { - widget.attendanceController.uploadingStates[uniqueLogKey]?.value = false; + final comment = await _showCommentBottomSheet(context, actionText); + if (comment == null || comment.isEmpty) { + controller.uploadingStates[uniqueLogKey]?.value = false; return; } bool success = false; + String? markTime; if (actionText == ButtonActions.requestRegularize) { - final regularizeTime = selectedTime ?? - await showTimePickerForRegularization( - context: context, - checkInTime: widget.employee.checkIn!, - ); - - if (regularizeTime != null) { - final formattedTime = DateFormat("hh:mm a").format(regularizeTime); - success = await widget.attendanceController.captureAndUploadAttendance( - widget.employee.id, - widget.employee.employeeId, - selectedProjectId, - comment: userComment, - action: updatedAction, - imageCapture: imageCapture, - markTime: formattedTime, - ); + selectedTime ??= await _pickRegularizationTime(widget.employee.checkIn!); + if (selectedTime != null) { + markTime = DateFormat("hh:mm a").format(selectedTime); } } else if (selectedTime != null) { - final formattedTime = DateFormat("hh:mm a").format(selectedTime); - success = await widget.attendanceController.captureAndUploadAttendance( - widget.employee.id, - widget.employee.employeeId, - selectedProjectId, - comment: userComment, - action: updatedAction, - imageCapture: imageCapture, - markTime: formattedTime, - ); - } else { - success = await widget.attendanceController.captureAndUploadAttendance( - widget.employee.id, - widget.employee.employeeId, - selectedProjectId, - comment: userComment, - action: updatedAction, - imageCapture: imageCapture, - ); + markTime = DateFormat("hh:mm a").format(selectedTime); } + success = await controller.captureAndUploadAttendance( + widget.employee.id, + widget.employee.employeeId, + selectedProjectId, + comment: comment, + action: action, + imageCapture: imageCapture, + markTime: markTime, + ); + showAppSnackbar( title: success ? '${capitalizeFirstLetter(actionText)} Success' : 'Error', message: success @@ -288,57 +184,47 @@ class _AttendanceActionButtonState extends State { type: success ? SnackbarType.success : SnackbarType.error, ); - widget.attendanceController.uploadingStates[uniqueLogKey]?.value = false; + controller.uploadingStates[uniqueLogKey]?.value = false; if (success) { - widget.attendanceController.fetchEmployeesByProject(selectedProjectId); - widget.attendanceController.fetchAttendanceLogs(selectedProjectId); - await widget.attendanceController.fetchRegularizationLogs(selectedProjectId); - await widget.attendanceController.fetchProjectData(selectedProjectId); - widget.attendanceController.update(); + controller.fetchEmployeesByProject(selectedProjectId); + controller.fetchAttendanceLogs(selectedProjectId); + await controller.fetchRegularizationLogs(selectedProjectId); + await controller.fetchProjectData(selectedProjectId); + controller.update(); } } @override Widget build(BuildContext context) { return Obx(() { - final isUploading = - widget.attendanceController.uploadingStates[uniqueLogKey]?.value ?? - false; + final controller = widget.attendanceController; - final isYesterday = AttendanceButtonHelper.isLogFromYesterday( - widget.employee.checkIn, - widget.employee.checkOut, - ); + final isUploading = controller.uploadingStates[uniqueLogKey]?.value ?? false; + final emp = widget.employee; - final isTodayApproved = AttendanceButtonHelper.isTodayApproved( - widget.employee.activity, - widget.employee.checkIn, - ); - - final isApprovedButNotToday = AttendanceButtonHelper.isApprovedButNotToday( - widget.employee.activity, - isTodayApproved, - ); + final isYesterday = AttendanceButtonHelper.isLogFromYesterday(emp.checkIn, emp.checkOut); + final isTodayApproved = AttendanceButtonHelper.isTodayApproved(emp.activity, emp.checkIn); + final isApprovedButNotToday = AttendanceButtonHelper.isApprovedButNotToday(emp.activity, isTodayApproved); final isButtonDisabled = AttendanceButtonHelper.isButtonDisabled( isUploading: isUploading, isYesterday: isYesterday, - activity: widget.employee.activity, + activity: emp.activity, isApprovedButNotToday: isApprovedButNotToday, ); final buttonText = AttendanceButtonHelper.getButtonText( - activity: widget.employee.activity, - checkIn: widget.employee.checkIn, - checkOut: widget.employee.checkOut, + activity: emp.activity, + checkIn: emp.checkIn, + checkOut: emp.checkOut, isTodayApproved: isTodayApproved, ); final buttonColor = AttendanceButtonHelper.getButtonColor( isYesterday: isYesterday, isTodayApproved: isTodayApproved, - activity: widget.employee.activity, + activity: emp.activity, ); return AttendanceActionButtonUI( @@ -346,7 +232,7 @@ class _AttendanceActionButtonState extends State { isButtonDisabled: isButtonDisabled, buttonText: buttonText, buttonColor: buttonColor, - onPressed: isButtonDisabled ? null : () => _handleButtonPressed(context), + onPressed: isButtonDisabled ? null : _handleButtonPressed, ); }); } @@ -391,17 +277,14 @@ class AttendanceActionButtonUI extends StatelessWidget { : Row( mainAxisSize: MainAxisSize.min, children: [ - if (buttonText.toLowerCase() == 'approved') ...[ + if (buttonText.toLowerCase() == 'approved') const Icon(Icons.check, size: 16, color: Colors.green), - const SizedBox(width: 4), - ] else if (buttonText.toLowerCase() == 'rejected') ...[ + if (buttonText.toLowerCase() == 'rejected') const Icon(Icons.close, size: 16, color: Colors.red), + if (buttonText.toLowerCase() == 'requested') + const Icon(Icons.hourglass_top, size: 16, color: Colors.orange), + if (['approved', 'rejected', 'requested'].contains(buttonText.toLowerCase())) const SizedBox(width: 4), - ] else if (buttonText.toLowerCase() == 'requested') ...[ - const Icon(Icons.hourglass_top, - size: 16, color: Colors.orange), - const SizedBox(width: 4), - ], Flexible( child: Text( buttonText, @@ -415,3 +298,68 @@ class AttendanceActionButtonUI extends StatelessWidget { ); } } + +Future _showCommentBottomSheet(BuildContext context, String actionText) async { + final commentController = TextEditingController(); + String? errorText; + + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) { + return StatefulBuilder( + builder: (context, setModalState) { + void submit() { + final comment = commentController.text.trim(); + if (comment.isEmpty) { + setModalState(() => errorText = 'Comment cannot be empty.'); + return; + } + Navigator.of(context).pop(comment); + } + + return Padding( + padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + child: BaseBottomSheet( + title: 'Add Comment for ${capitalizeFirstLetter(actionText)}', + onCancel: () => Navigator.of(context).pop(), + onSubmit: submit, + isSubmitting: false, + submitText: 'Submit', + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: commentController, + maxLines: 4, + decoration: InputDecoration( + hintText: 'Type your comment here...', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + filled: true, + fillColor: Colors.grey.shade100, + errorText: errorText, + ), + onChanged: (_) { + if (errorText != null) { + setModalState(() => errorText = null); + } + }, + ), + ], + ), + ), + ); + }, + ); + }, + ); +} + +String capitalizeFirstLetter(String text) => + text.isEmpty ? text : text[0].toUpperCase() + text.substring(1); diff --git a/lib/model/attendance/attendence_filter_sheet.dart b/lib/model/attendance/attendence_filter_sheet.dart index 32fbc43..53d371a 100644 --- a/lib/model/attendance/attendence_filter_sheet.dart +++ b/lib/model/attendance/attendence_filter_sheet.dart @@ -137,9 +137,6 @@ class _AttendanceFilterBottomSheetState onSubmit: () => Navigator.pop(context, { 'selectedTab': tempSelectedTab, }), - submitText: "Apply Filter", - submitIcon: Icons.filter_alt_outlined, - submitColor: const Color.fromARGB(255, 95, 132, 255), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: buildMainFilters(), From d799093537de8de059bd54e38449df53e4525c46 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Sat, 2 Aug 2025 16:54:57 +0530 Subject: [PATCH 46/65] refactor: Enhance ForgotPasswordScreen and LoginOptionScreen for improved readability and maintainability by extracting widget methods and optimizing variable declarations. --- lib/view/auth/forgot_password_screen.dart | 245 ++++++++++++---------- lib/view/auth/login_option_screen.dart | 211 +++++++++---------- 2 files changed, 228 insertions(+), 228 deletions(-) diff --git a/lib/view/auth/forgot_password_screen.dart b/lib/view/auth/forgot_password_screen.dart index fbc15f3..9875b89 100644 --- a/lib/view/auth/forgot_password_screen.dart +++ b/lib/view/auth/forgot_password_screen.dart @@ -1,13 +1,12 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_lucide/flutter_lucide.dart'; import 'package:get/get.dart'; import 'package:marco/controller/auth/forgot_password_controller.dart'; -import 'package:marco/helpers/widgets/my_button.dart'; -import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/services/api_endpoints.dart'; import 'package:marco/helpers/services/storage/local_storage.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; +import 'package:marco/helpers/widgets/my_button.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/images.dart'; class ForgotPasswordScreen extends StatefulWidget { @@ -22,10 +21,10 @@ class _ForgotPasswordScreenState extends State final ForgotPasswordController controller = Get.put(ForgotPasswordController()); - late AnimationController _controller; - late Animation _logoAnimation; + late final AnimationController _controller; + late final Animation _logoAnimation; - bool get _isBetaEnvironment => ApiEndpoints.baseUrl.contains("stage"); + final bool _isBetaEnvironment = ApiEndpoints.baseUrl.contains("stage"); bool _isLoading = false; @override @@ -64,29 +63,9 @@ class _ForgotPasswordScreenState extends State SafeArea( child: Center( child: Column( - mainAxisSize: MainAxisSize.min, children: [ const SizedBox(height: 24), - ScaleTransition( - scale: _logoAnimation, - child: Container( - width: 100, - height: 100, - decoration: BoxDecoration( - color: Colors.white, - shape: BoxShape.circle, - boxShadow: const [ - BoxShadow( - color: Colors.black12, - blurRadius: 10, - offset: Offset(0, 4), - ), - ], - ), - padding: const EdgeInsets.all(20), - child: Image.asset(Images.logoDark), - ), - ), + _buildAnimatedLogo(), const SizedBox(height: 8), Expanded( child: SingleChildScrollView( @@ -96,36 +75,10 @@ class _ForgotPasswordScreenState extends State child: Column( children: [ const SizedBox(height: 12), - MyText( - "Welcome to Marco", - fontSize: 24, - fontWeight: 800, - color: Colors.black87, - textAlign: TextAlign.center, - ), - const SizedBox(height: 10), - MyText( - "Streamline Project Management\nBoost Productivity with Automation.", - fontSize: 14, - color: Colors.black54, - textAlign: TextAlign.center, - ), + _buildWelcomeText(), if (_isBetaEnvironment) ...[ const SizedBox(height: 12), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: Colors.orangeAccent, - borderRadius: BorderRadius.circular(6), - ), - child: MyText( - 'BETA', - color: Colors.white, - fontWeight: 600, - fontSize: 12, - ), - ), + _buildBetaBadge(), ], const SizedBox(height: 36), _buildForgotCard(), @@ -143,6 +96,66 @@ class _ForgotPasswordScreenState extends State ); } + Widget _buildAnimatedLogo() { + return ScaleTransition( + scale: _logoAnimation, + child: Container( + width: 100, + height: 100, + padding: const EdgeInsets.all(20), + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black12, + blurRadius: 10, + offset: Offset(0, 4), + ), + ], + ), + child: Image.asset(Images.logoDark), + ), + ); + } + + Widget _buildWelcomeText() { + return Column( + children: [ + MyText( + "Welcome to Marco", + fontSize: 24, + fontWeight: 600, + color: Colors.black87, + textAlign: TextAlign.center, + ), + const SizedBox(height: 10), + MyText( + "Streamline Project Management\nBoost Productivity with Automation.", + fontSize: 14, + color: Colors.black54, + textAlign: TextAlign.center, + ), + ], + ); + } + + Widget _buildBetaBadge() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.orangeAccent, + borderRadius: BorderRadius.circular(6), + ), + child: MyText( + 'BETA', + color: Colors.white, + fontWeight: 600, + fontSize: 12, + ), + ); + } + Widget _buildForgotCard() { return Container( padding: const EdgeInsets.all(24), @@ -165,7 +178,7 @@ class _ForgotPasswordScreenState extends State MyText( 'Forgot Password', fontSize: 20, - fontWeight: 700, + fontWeight: 600, color: Colors.black87, textAlign: TextAlign.center, ), @@ -177,70 +190,80 @@ class _ForgotPasswordScreenState extends State textAlign: TextAlign.center, ), const SizedBox(height: 30), - TextFormField( - validator: controller.basicValidator.getValidation('email'), - controller: controller.basicValidator.getController('email'), - keyboardType: TextInputType.emailAddress, - style: const TextStyle(fontSize: 14), - decoration: InputDecoration( - labelText: "Email Address", - labelStyle: const TextStyle(color: Colors.black54), - filled: true, - fillColor: Colors.grey.shade100, - prefixIcon: const Icon(LucideIcons.mail, size: 20), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - contentPadding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 16), - ), - ), + _buildEmailInput(), const SizedBox(height: 32), - MyButton.rounded( - onPressed: _isLoading ? null : _handleForgotPassword, - elevation: 2, - padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 16), - borderRadiusAll: 10, - backgroundColor: _isLoading - ? contentTheme.brandRed.withOpacity(0.6) - : contentTheme.brandRed, - child: _isLoading - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - color: Colors.white, - strokeWidth: 2, - ), - ) - : MyText.bodyMedium( - 'Send Reset Link', - color: Colors.white, - fontWeight: 700, - fontSize: 16, - ), - ), + _buildResetButton(), const SizedBox(height: 20), - TextButton.icon( - onPressed: () async => await LocalStorage.logout(), - icon: const Icon(Icons.arrow_back, - size: 18, color: Colors.redAccent), - label: MyText.bodyMedium( - 'Back to Login', - color: contentTheme.brandRed, - fontWeight: 600, - fontSize: 14, - ), - ), + _buildBackButton(), ], ), ), ); } + + Widget _buildEmailInput() { + return TextFormField( + validator: controller.basicValidator.getValidation('email'), + controller: controller.basicValidator.getController('email'), + keyboardType: TextInputType.emailAddress, + style: const TextStyle(fontSize: 14), + decoration: InputDecoration( + labelText: "Email Address", + labelStyle: const TextStyle(color: Colors.black54), + filled: true, + fillColor: Colors.grey.shade100, + prefixIcon: const Icon(LucideIcons.mail, size: 20), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + ), + ); + } + + Widget _buildResetButton() { + return MyButton.rounded( + onPressed: _isLoading ? null : _handleForgotPassword, + elevation: 2, + padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 16), + borderRadiusAll: 10, + backgroundColor: _isLoading + ? contentTheme.brandRed.withOpacity(0.6) + : contentTheme.brandRed, + child: _isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : MyText.bodyMedium( + 'Send Reset Link', + color: Colors.white, + fontWeight: 600, + fontSize: 16, + ), + ); + } + + Widget _buildBackButton() { + return TextButton.icon( + onPressed: () async => await LocalStorage.logout(), + icon: const Icon(Icons.arrow_back, size: 18, color: Colors.redAccent), + label: MyText.bodyMedium( + 'Back to Login', + color: contentTheme.brandRed, + fontWeight: 600, + fontSize: 14, + ), + ); + } } -// Red background using dynamic brandRed class _RedWaveBackground extends StatelessWidget { final Color brandRed; const _RedWaveBackground({required this.brandRed}); diff --git a/lib/view/auth/login_option_screen.dart b/lib/view/auth/login_option_screen.dart index 2432577..c3364ed 100644 --- a/lib/view/auth/login_option_screen.dart +++ b/lib/view/auth/login_option_screen.dart @@ -26,9 +26,8 @@ class WelcomeScreen extends StatefulWidget { class _WelcomeScreenState extends State with SingleTickerProviderStateMixin, UIMixin { - late AnimationController _controller; - late Animation _logoAnimation; - + late final AnimationController _controller; + late final Animation _logoAnimation; bool get _isBetaEnvironment => ApiEndpoints.baseUrl.contains("stage"); @override @@ -54,42 +53,39 @@ class _WelcomeScreenState extends State void _showLoginDialog(BuildContext context, LoginOption option) { showDialog( context: context, - barrierDismissible: false, // Prevent dismiss on outside tap + barrierDismissible: false, builder: (_) => Dialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), insetPadding: const EdgeInsets.all(24), child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(24), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Row with title and close button - Row( - children: [ - Expanded( - child: MyText( - option == LoginOption.email - ? "Login with Email" - : "Login with OTP", - fontSize: 20, - fontWeight: 700, - ), + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: MyText( + option == LoginOption.email + ? "Login with Email" + : "Login with OTP", + fontSize: 20, + fontWeight: 700, ), - IconButton( - icon: const Icon(Icons.close), - onPressed: () => Navigator.of(context).pop(), - ), - ], - ), - const SizedBox(height: 20), - option == LoginOption.email - ? EmailLoginForm() - : const OTPLoginScreen(), - ], - ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + const SizedBox(height: 20), + option == LoginOption.email + ? EmailLoginForm() + : const OTPLoginScreen(), + ], ), ), ), @@ -100,6 +96,7 @@ class _WelcomeScreenState extends State @override Widget build(BuildContext context) { final screenWidth = MediaQuery.of(context).size.width; + final isNarrow = screenWidth < 500; return Scaffold( body: Stack( @@ -110,72 +107,18 @@ class _WelcomeScreenState extends State child: SingleChildScrollView( padding: const EdgeInsets.all(24), child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: screenWidth < 500 ? double.infinity : 420, - ), + constraints: BoxConstraints(maxWidth: isNarrow ? double.infinity : 420), child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ - // Logo with circular background - ScaleTransition( - scale: _logoAnimation, - child: Container( - width: 100, - height: 100, - decoration: BoxDecoration( - color: Colors.white, - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: Colors.black12, - blurRadius: 10, - offset: Offset(0, 4), - ), - ], - ), - padding: const EdgeInsets.all(20), - child: Image.asset(Images.logoDark), - ), - ), - + _buildLogo(), const SizedBox(height: 24), - - // Welcome Text - MyText( - "Welcome to Marco", - fontSize: 26, - fontWeight: 800, - color: Colors.black87, - textAlign: TextAlign.center, - ), - const SizedBox(height: 10), - MyText( - "Streamline Project Management\nBoost Productivity with Automation.", - fontSize: 14, - color: Colors.black54, - textAlign: TextAlign.center, - ), - + _buildWelcomeText(), if (_isBetaEnvironment) ...[ const SizedBox(height: 12), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: Colors.orangeAccent, - borderRadius: BorderRadius.circular(6), - ), - child: MyText( - 'BETA', - color: Colors.white, - fontWeight: 600, - fontSize: 12, - ), - ), + _buildBetaBadge(), ], - const SizedBox(height: 36), - _buildActionButton( context, label: "Login with Username", @@ -196,7 +139,6 @@ class _WelcomeScreenState extends State icon: LucideIcons.phone_call, option: null, ), - const SizedBox(height: 36), MyText( 'App version 1.0.0', @@ -214,6 +156,60 @@ class _WelcomeScreenState extends State ); } + Widget _buildLogo() { + return ScaleTransition( + scale: _logoAnimation, + child: Container( + width: 100, + height: 100, + padding: const EdgeInsets.all(20), + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 10, offset: Offset(0, 4))], + ), + child: Image.asset(Images.logoDark), + ), + ); + } + + Widget _buildWelcomeText() { + return Column( + children: [ + MyText( + "Welcome to Marco", + fontSize: 26, + fontWeight: 800, + color: Colors.black87, + textAlign: TextAlign.center, + ), + const SizedBox(height: 10), + MyText( + "Streamline Project Management\nBoost Productivity with Automation.", + fontSize: 14, + color: Colors.black54, + textAlign: TextAlign.center, + ), + ], + ); + } + + Widget _buildBetaBadge() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.orangeAccent, + borderRadius: BorderRadius.circular(6), + ), + child: MyText( + 'BETA', + color: Colors.white, + fontWeight: 600, + fontSize: 12, + ), + ); + } + Widget _buildActionButton( BuildContext context, { required String label, @@ -236,9 +232,7 @@ class _WelcomeScreenState extends State style: ElevatedButton.styleFrom( backgroundColor: contentTheme.brandRed, foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), elevation: 4, shadowColor: Colors.black26, ), @@ -254,7 +248,7 @@ class _WelcomeScreenState extends State } } -/// Custom red wave background shifted lower to reduce red area at top +// Red wave background painter class _RedWaveBackground extends StatelessWidget { final Color brandRed; const _RedWaveBackground({required this.brandRed}); @@ -270,7 +264,6 @@ class _RedWaveBackground extends StatelessWidget { class _WavePainter extends CustomPainter { final Color brandRed; - _WavePainter(this.brandRed); @override @@ -284,18 +277,8 @@ class _WavePainter extends CustomPainter { final path1 = Path() ..moveTo(0, size.height * 0.2) - ..quadraticBezierTo( - size.width * 0.25, - size.height * 0.05, - size.width * 0.5, - size.height * 0.15, - ) - ..quadraticBezierTo( - size.width * 0.75, - size.height * 0.25, - size.width, - size.height * 0.1, - ) + ..quadraticBezierTo(size.width * 0.25, size.height * 0.05, size.width * 0.5, size.height * 0.15) + ..quadraticBezierTo(size.width * 0.75, size.height * 0.25, size.width, size.height * 0.1) ..lineTo(size.width, 0) ..lineTo(0, 0) ..close(); @@ -303,15 +286,9 @@ class _WavePainter extends CustomPainter { canvas.drawPath(path1, paint1); final paint2 = Paint()..color = Colors.redAccent.withOpacity(0.15); - final path2 = Path() ..moveTo(0, size.height * 0.25) - ..quadraticBezierTo( - size.width * 0.4, - size.height * 0.1, - size.width, - size.height * 0.2, - ) + ..quadraticBezierTo(size.width * 0.4, size.height * 0.1, size.width, size.height * 0.2) ..lineTo(size.width, 0) ..lineTo(0, 0) ..close(); From 7dd47ce460c59bfcd4401347cdf97cf77d8a3f21 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Sat, 2 Aug 2025 17:29:29 +0530 Subject: [PATCH 47/65] refactor: Simplify ReportActionBottomSheet by removing unused imports, optimizing widget structure, and enhancing form handling for improved readability and maintainability. --- .../report_action_bottom_sheet.dart | 760 ++++++++---------- 1 file changed, 329 insertions(+), 431 deletions(-) diff --git a/lib/model/dailyTaskPlaning/report_action_bottom_sheet.dart b/lib/model/dailyTaskPlaning/report_action_bottom_sheet.dart index 6621335..84974ce 100644 --- a/lib/model/dailyTaskPlaning/report_action_bottom_sheet.dart +++ b/lib/model/dailyTaskPlaning/report_action_bottom_sheet.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/controller/task_planing/report_task_action_controller.dart'; -import 'package:marco/helpers/theme/app_theme.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; @@ -11,6 +10,7 @@ import 'package:marco/helpers/widgets/my_team_model_sheet.dart'; import 'package:marco/helpers/widgets/image_viewer_dialog.dart'; import 'package:marco/model/dailyTaskPlaning/create_task_botom_sheet.dart'; import 'package:marco/model/dailyTaskPlaning/report_action_widgets.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; class ReportActionBottomSheet extends StatefulWidget { final Map taskData; @@ -44,8 +44,6 @@ class _ReportActionBottomSheetState extends State with UIMixin { late ReportTaskActionController controller; - final ScrollController _scrollController = ScrollController(); - String selectedAction = 'Select Action'; @override void initState() { super.initState(); @@ -54,9 +52,9 @@ class _ReportActionBottomSheetState extends State tag: widget.taskData['taskId'] ?? '', ); controller.fetchWorkStatuses(); - controller.basicValidator.getController('approved_task')?.text = - widget.taskData['approvedTask']?.toString() ?? ''; final data = widget.taskData; + controller.basicValidator.getController('approved_task')?.text = + data['approvedTask']?.toString() ?? ''; controller.basicValidator.getController('assigned_date')?.text = data['assignedOn'] ?? ''; controller.basicValidator.getController('assigned_by')?.text = @@ -73,445 +71,348 @@ class _ReportActionBottomSheetState extends State (data['teamMembers'] as List).join(', '); controller.basicValidator.getController('assigned')?.text = data['assigned'] ?? ''; - controller.basicValidator.getController('task_id')?.text = - data['taskId'] ?? ''; - controller.basicValidator.getController('comment')?.clear(); controller.basicValidator.getController('task_id')?.text = widget.taskDataId; - + controller.basicValidator.getController('comment')?.clear(); controller.selectedImages.clear(); - - WidgetsBinding.instance.addPostFrameCallback((_) { - if (_scrollController.hasClients) { - _scrollController.jumpTo(_scrollController.position.maxScrollExtent); - } - }); } @override Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), - ), - child: SingleChildScrollView( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom + 24, - left: 24, - right: 24, - top: 12, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Drag handle - Container( - width: 40, - height: 4, - margin: const EdgeInsets.only(bottom: 12), - decoration: BoxDecoration( - color: Colors.grey.shade400, - borderRadius: BorderRadius.circular(2), - ), + return GetBuilder( + tag: widget.taskData['taskId'] ?? '', + builder: (controller) { + return BaseBottomSheet( + title: "Take Report Action", + isSubmitting: controller.isLoading.value, + onCancel: () => Navigator.of(context).pop(), + onSubmit: () async {}, // not used since buttons moved + showButtons: false, // disable internal buttons + child: _buildForm(context, controller), + ); + }, + ); + } + + Widget _buildForm( + BuildContext context, ReportTaskActionController controller) { + return Form( + key: controller.basicValidator.formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 📋 Task Details + buildRow("Assigned By", + controller.basicValidator.getController('assigned_by')?.text, + icon: Icons.person_outline), + buildRow("Work Area", + controller.basicValidator.getController('work_area')?.text, + icon: Icons.place_outlined), + buildRow("Activity", + controller.basicValidator.getController('activity')?.text, + icon: Icons.assignment_outlined), + buildRow("Planned Work", + controller.basicValidator.getController('planned_work')?.text, + icon: Icons.schedule_outlined), + buildRow("Completed Work", + controller.basicValidator.getController('completed_work')?.text, + icon: Icons.done_all_outlined), + buildTeamMembers(), + MySpacing.height(8), + + // ✅ Approved Task Field + Row( + children: [ + Icon(Icons.check_circle_outline, + size: 18, color: Colors.grey[700]), + MySpacing.width(8), + MyText.titleSmall("Approved Task:", fontWeight: 600), + ], + ), + MySpacing.height(10), + TextFormField( + controller: + controller.basicValidator.getController('approved_task'), + keyboardType: TextInputType.number, + validator: (value) { + if (value == null || value.isEmpty) return 'Required'; + if (int.tryParse(value) == null) return 'Must be a number'; + return null; + }, + decoration: InputDecoration( + hintText: "eg: 5", + hintStyle: MyTextStyle.bodySmall(xMuted: true), + border: outlineInputBorder, + contentPadding: MySpacing.all(16), + floatingLabelBehavior: FloatingLabelBehavior.never, ), - GetBuilder( - tag: widget.taskData['taskId'] ?? '', - builder: (controller) { - return Form( - key: controller.basicValidator.formKey, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 4.0, vertical: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + ), + + MySpacing.height(10), + if ((widget.taskData['reportedPreSignedUrls'] as List?) + ?.isNotEmpty == + true) + buildReportedImagesSection( + imageUrls: List.from( + widget.taskData['reportedPreSignedUrls'] ?? []), + context: context, + ), + + MySpacing.height(10), + MyText.titleSmall("Report Actions", fontWeight: 600), + MySpacing.height(10), + + Obx(() { + if (controller.isLoadingWorkStatus.value) + return const CircularProgressIndicator(); + return PopupMenuButton( + onSelected: (String value) { + controller.selectedWorkStatusName.value = value; + controller.showAddTaskCheckbox.value = true; + }, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + itemBuilder: (BuildContext context) { + return controller.workStatus.map((status) { + return PopupMenuItem( + value: status.name, + child: Row( children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - MyText.titleMedium( - "Take Report Action", - fontWeight: 600, - fontSize: 18, - ), - ], + Radio( + value: status.name, + groupValue: controller.selectedWorkStatusName.value, + onChanged: (_) => Navigator.pop(context, status.name), ), - MySpacing.height(24), - buildRow( - "Assigned By", - controller.basicValidator - .getController('assigned_by') - ?.text - .trim(), - icon: Icons.person_outline, - ), - buildRow( - "Work Area", - controller.basicValidator - .getController('work_area') - ?.text - .trim(), - icon: Icons.place_outlined, - ), - buildRow( - "Activity", - controller.basicValidator - .getController('activity') - ?.text - .trim(), - icon: Icons.assignment_outlined, - ), - buildRow( - "Planned Work", - controller.basicValidator - .getController('planned_work') - ?.text - .trim(), - icon: Icons.schedule_outlined, - ), - buildRow( - "Completed Work", - controller.basicValidator - .getController('completed_work') - ?.text - .trim(), - icon: Icons.done_all_outlined, - ), - buildTeamMembers(), - MySpacing.height(8), - Row( - children: [ - Icon(Icons.check_circle_outline, - size: 18, color: Colors.grey[700]), - MySpacing.width(8), - MyText.titleSmall( - "Approved Task:", - fontWeight: 600, - ), - ], - ), - MySpacing.height(10), - TextFormField( - controller: controller.basicValidator - .getController('approved_task'), - keyboardType: TextInputType.number, - validator: (value) { - if (value == null || value.isEmpty) - return 'Required'; - if (int.tryParse(value) == null) - return 'Must be a number'; - return null; - }, - decoration: InputDecoration( - hintText: "eg: 5", - hintStyle: MyTextStyle.bodySmall(xMuted: true), - border: outlineInputBorder, - enabledBorder: outlineInputBorder, - focusedBorder: focusedInputBorder, - contentPadding: MySpacing.all(16), - isCollapsed: true, - floatingLabelBehavior: FloatingLabelBehavior.never, - ), - ), - MySpacing.height(10), - - if ((widget.taskData['reportedPreSignedUrls'] - as List?) - ?.isNotEmpty == - true) - buildReportedImagesSection( - imageUrls: List.from( - widget.taskData['reportedPreSignedUrls'] ?? []), - context: context, - ), - MySpacing.height(10), - // Add this in your stateful widget - MyText.titleSmall( - "Report Actions", - fontWeight: 600, - ), - MySpacing.height(10), - - Obx(() { - final isLoading = - controller.isLoadingWorkStatus.value; - final workStatuses = controller.workStatus; - - if (isLoading) { - return const Center( - child: CircularProgressIndicator()); - } - - return PopupMenuButton( - onSelected: (String value) { - controller.selectedWorkStatusName.value = value; - controller.showAddTaskCheckbox.value = true; - }, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - itemBuilder: (BuildContext context) { - return workStatuses.map((status) { - final statusName = status.name; - - return PopupMenuItem( - value: statusName, - child: Row( - children: [ - Radio( - value: statusName, - groupValue: controller - .selectedWorkStatusName.value, - onChanged: (_) => - Navigator.pop(context, statusName), - ), - const SizedBox(width: 8), - MyText.bodySmall(statusName), - ], - ), - ); - }).toList(); - }, - child: Container( - padding: MySpacing.xy(16, 12), - decoration: BoxDecoration( - border: Border.all(color: Colors.grey.shade400), - borderRadius: BorderRadius.circular( - AppStyle.buttonRadius.medium), - ), - child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - MyText.bodySmall( - controller.selectedWorkStatusName.value - .isEmpty - ? "Select Work Status" - : controller - .selectedWorkStatusName.value, - color: Colors.black87, - ), - const Icon(Icons.arrow_drop_down, size: 20), - ], - ), - ), - ); - }), - MySpacing.height(10), - Obx(() { - if (!controller.showAddTaskCheckbox.value) - return SizedBox.shrink(); - - final checkboxTheme = CheckboxThemeData( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(2)), - side: WidgetStateBorderSide.resolveWith((states) => - BorderSide( - color: states.contains(WidgetState.selected) - ? Colors.transparent - : Colors.black)), - fillColor: WidgetStateProperty.resolveWith( - (states) => - states.contains(WidgetState.selected) - ? Colors.blueAccent - : Colors.white), - checkColor: - WidgetStateProperty.all(Colors.white), - ); - - return Theme( - data: Theme.of(context) - .copyWith(checkboxTheme: checkboxTheme), - child: CheckboxListTile( - title: MyText.titleSmall( - "Add new task", - fontWeight: 600, - ), - value: controller.isAddTaskChecked.value, - onChanged: (val) => controller - .isAddTaskChecked.value = val ?? false, - controlAffinity: ListTileControlAffinity.leading, - contentPadding: EdgeInsets.zero, - ), - ); - }), - MySpacing.height(10), - Row( - children: [ - Icon(Icons.comment_outlined, - size: 18, color: Colors.grey[700]), - MySpacing.width(8), - MyText.titleSmall( - "Comment:", - fontWeight: 600, - ), - ], - ), - MySpacing.height(8), - TextFormField( - validator: controller.basicValidator - .getValidation('comment'), - controller: controller.basicValidator - .getController('comment'), - keyboardType: TextInputType.text, - decoration: InputDecoration( - hintText: "eg: Work done successfully", - hintStyle: MyTextStyle.bodySmall(xMuted: true), - border: outlineInputBorder, - enabledBorder: outlineInputBorder, - focusedBorder: focusedInputBorder, - contentPadding: MySpacing.all(16), - isCollapsed: true, - floatingLabelBehavior: FloatingLabelBehavior.never, - ), - ), - MySpacing.height(16), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.camera_alt_outlined, - size: 18, color: Colors.grey[700]), - MySpacing.width(8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.titleSmall("Attach Photos:", - fontWeight: 600), - MySpacing.height(12), - ], - ), - ), - ], - ), - Obx(() { - final images = controller.selectedImages; - - return buildImagePickerSection( - images: images, - onCameraTap: () => - controller.pickImages(fromCamera: true), - onUploadTap: () => - controller.pickImages(fromCamera: false), - onRemoveImage: (index) => - controller.removeImageAt(index), - onPreviewImage: (index) { - showDialog( - context: context, - builder: (_) => ImageViewerDialog( - imageSources: images, - initialIndex: index, - ), - ); - }, - ); - }), - MySpacing.height(24), - buildCommentActionButtons( - onCancel: () => Navigator.of(context).pop(), - onSubmit: () async { - if (controller.basicValidator.validateForm()) { - final selectedStatusName = - controller.selectedWorkStatusName.value; - final selectedStatus = - controller.workStatus.firstWhereOrNull( - (status) => status.name == selectedStatusName, - ); - - final reportActionId = - selectedStatus?.id.toString() ?? ''; - final approvedTaskCount = controller - .basicValidator - .getController('approved_task') - ?.text - .trim() ?? - ''; - - final shouldShowAddTaskSheet = - controller.isAddTaskChecked.value; - - final success = await controller.approveTask( - projectId: controller.basicValidator - .getController('task_id') - ?.text ?? - '', - comment: controller.basicValidator - .getController('comment') - ?.text ?? - '', - images: controller.selectedImages, - reportActionId: reportActionId, - approvedTaskCount: approvedTaskCount, - ); - if (success) { - Navigator.of(context).pop(); - if (shouldShowAddTaskSheet) { - await Future.delayed( - Duration(milliseconds: 100)); - showCreateTaskBottomSheet( - workArea: widget.taskData['location'] ?? '', - activity: widget.taskData['activity'] ?? '', - completedWork: - widget.taskData['completedWork'] ?? '', - unit: widget.taskData['unit'] ?? '', - onCategoryChanged: (category) { - debugPrint( - "Category changed to: $category"); - }, - parentTaskId: widget.taskDataId, - plannedTask: int.tryParse( - widget.taskData['plannedWork'] ?? - '0') ?? - 0, - activityId: widget.activityId, - workAreaId: widget.workAreaId, - onSubmit: () { - Navigator.of(context).pop(); - }, - ); - } - widget.onReportSuccess.call(); - } - } - }, - isLoading: controller.isLoading, - ), - - MySpacing.height(20), - if ((widget.taskData['taskComments'] as List?) - ?.isNotEmpty == - true) ...[ - Row( - children: [ - MySpacing.width(10), - Icon(Icons.chat_bubble_outline, - size: 18, color: Colors.grey[700]), - MySpacing.width(8), - MyText.titleSmall( - "Comments", - fontWeight: 600, - ), - ], - ), - MySpacing.height(12), - Builder( - builder: (context) { - final comments = List>.from( - widget.taskData['taskComments'] as List, - ); - return buildCommentList( - comments, context, timeAgo); - }, - ) - ], + const SizedBox(width: 8), + MyText.bodySmall(status.name), ], ), + ); + }).toList(); + }, + child: Container( + padding: MySpacing.xy(16, 12), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade400), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + MyText.bodySmall( + controller.selectedWorkStatusName.value.isEmpty + ? "Select Work Status" + : controller.selectedWorkStatusName.value, + color: Colors.black87, + ), + const Icon(Icons.arrow_drop_down, size: 20), + ], + ), + ), + ); + }), + + MySpacing.height(10), + + Obx(() { + if (!controller.showAddTaskCheckbox.value) + return const SizedBox.shrink(); + return CheckboxListTile( + title: MyText.titleSmall("Add new task", fontWeight: 600), + value: controller.isAddTaskChecked.value, + onChanged: (val) => + controller.isAddTaskChecked.value = val ?? false, + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + ); + }), + + MySpacing.height(24), + + // ✏️ Comment Field + Row( + children: [ + Icon(Icons.comment_outlined, size: 18, color: Colors.grey[700]), + MySpacing.width(8), + MyText.titleSmall("Comment:", fontWeight: 600), + ], + ), + MySpacing.height(8), + TextFormField( + validator: controller.basicValidator.getValidation('comment'), + controller: controller.basicValidator.getController('comment'), + keyboardType: TextInputType.text, + decoration: InputDecoration( + hintText: "eg: Work done successfully", + hintStyle: MyTextStyle.bodySmall(xMuted: true), + border: outlineInputBorder, + contentPadding: MySpacing.all(16), + floatingLabelBehavior: FloatingLabelBehavior.never, + ), + ), + + MySpacing.height(16), + + // 📸 Image Attachments + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.camera_alt_outlined, + size: 18, color: Colors.grey[700]), + MySpacing.width(8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleSmall("Attach Photos:", fontWeight: 600), + MySpacing.height(12), + ], + ), + ), + ], + ), + Obx(() { + final images = controller.selectedImages; + return buildImagePickerSection( + images: images, + onCameraTap: () => controller.pickImages(fromCamera: true), + onUploadTap: () => controller.pickImages(fromCamera: false), + onRemoveImage: (index) => controller.removeImageAt(index), + onPreviewImage: (index) { + showDialog( + context: context, + builder: (_) => ImageViewerDialog( + imageSources: images, + initialIndex: index, ), ); }, + ); + }), + + MySpacing.height(12), + + // ✅ Submit/Cancel Buttons moved here + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close, color: Colors.white), + label: MyText.bodyMedium("Cancel", + color: Colors.white, fontWeight: 600), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.grey, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric(vertical: 8), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + onPressed: controller.isLoading.value + ? null + : () async { + if (controller.basicValidator.validateForm()) { + final selectedStatusName = + controller.selectedWorkStatusName.value; + final selectedStatus = controller.workStatus + .firstWhereOrNull( + (s) => s.name == selectedStatusName); + final reportActionId = + selectedStatus?.id.toString() ?? ''; + final approvedTaskCount = controller.basicValidator + .getController('approved_task') + ?.text + .trim() ?? + ''; + + final shouldShowAddTaskSheet = + controller.isAddTaskChecked.value; + + final success = await controller.approveTask( + projectId: controller.basicValidator + .getController('task_id') + ?.text ?? + '', + comment: controller.basicValidator + .getController('comment') + ?.text ?? + '', + images: controller.selectedImages, + reportActionId: reportActionId, + approvedTaskCount: approvedTaskCount, + ); + + if (success) { + Navigator.of(context).pop(); + if (shouldShowAddTaskSheet) { + await Future.delayed( + const Duration(milliseconds: 100)); + showCreateTaskBottomSheet( + workArea: widget.taskData['location'] ?? '', + activity: widget.taskData['activity'] ?? '', + completedWork: + widget.taskData['completedWork'] ?? '', + unit: widget.taskData['unit'] ?? '', + parentTaskId: widget.taskDataId, + plannedTask: int.tryParse( + widget.taskData['plannedWork'] ?? + '0') ?? + 0, + activityId: widget.activityId, + workAreaId: widget.workAreaId, + onSubmit: () => Navigator.of(context).pop(), + onCategoryChanged: (category) {}, + ); + } + widget.onReportSuccess.call(); + } + } + }, + icon: const Icon(Icons.check_circle_outline, + color: Colors.white), + label: MyText.bodyMedium( + controller.isLoading.value ? "Submitting..." : "Submit", + color: Colors.white, + fontWeight: 600, + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.indigo, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric(vertical: 8), + ), + ), + ), + ], + ), + + MySpacing.height(12), + + // 💬 Previous Comments List (only below submit) + if ((widget.taskData['taskComments'] as List?)?.isNotEmpty == + true) ...[ + Row( + children: [ + MySpacing.width(10), + Icon(Icons.chat_bubble_outline, + size: 18, color: Colors.grey[700]), + MySpacing.width(8), + MyText.titleSmall("Comments", fontWeight: 600), + ], + ), + MySpacing.height(12), + buildCommentList( + List>.from( + widget.taskData['taskComments'] as List), + context, + timeAgo, ), ], - ), + ], ), ); } @@ -530,10 +431,7 @@ class _ReportActionBottomSheetState extends State child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - MyText.titleSmall( - "Team Members:", - fontWeight: 600, - ), + MyText.titleSmall("Team Members:", fontWeight: 600), MySpacing.width(12), GestureDetector( onTap: () { From bba44d4d393655447c9a587018e8f6b0fab16593 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Sat, 2 Aug 2025 17:33:34 +0530 Subject: [PATCH 48/65] refactor: Update action item color handling and adjust icon size for improved UI consistency --- lib/view/taskPlaning/daily_progress.dart | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/view/taskPlaning/daily_progress.dart b/lib/view/taskPlaning/daily_progress.dart index 487a105..9fd6247 100644 --- a/lib/view/taskPlaning/daily_progress.dart +++ b/lib/view/taskPlaning/daily_progress.dart @@ -160,7 +160,6 @@ class _DailyProgressReportScreenState extends State label: "Filter", icon: Icons.tune, tooltip: 'Filter Project', - color: Colors.blueAccent, onTap: _openFilterSheet, ), const SizedBox(width: 8), @@ -181,7 +180,7 @@ class _DailyProgressReportScreenState extends State required IconData icon, required String tooltip, required VoidCallback onTap, - required Color color, + Color? color, }) { return Row( children: [ @@ -189,13 +188,13 @@ class _DailyProgressReportScreenState extends State Tooltip( message: tooltip, child: InkWell( - borderRadius: BorderRadius.circular(24), + borderRadius: BorderRadius.circular(22), onTap: onTap, child: MouseRegion( cursor: SystemMouseCursors.click, child: Padding( padding: const EdgeInsets.all(8.0), - child: Icon(icon, color: color, size: 28), + child: Icon(icon, color: color, size: 22), ), ), ), From 0150400092570eb01c2cc2227b9f59b014bad622 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Sat, 2 Aug 2025 17:39:08 +0530 Subject: [PATCH 49/65] refactor: Replace Get.snackbar with showAppSnackbar for consistent error handling and improve code readability across expense-related controllers and views. --- .../expense/add_expense_controller.dart | 78 +++++++++---------- .../expense/expense_screen_controller.dart | 65 ++++++++++------ .../expense/reimbursement_bottom_sheet.dart | 26 +++++-- lib/view/expense/expense_detail_screen.dart | 45 +++++------ 4 files changed, 120 insertions(+), 94 deletions(-) diff --git a/lib/controller/expense/add_expense_controller.dart b/lib/controller/expense/add_expense_controller.dart index abf0389..a3c6b8a 100644 --- a/lib/controller/expense/add_expense_controller.dart +++ b/lib/controller/expense/add_expense_controller.dart @@ -18,7 +18,6 @@ import 'package:marco/model/expense/expense_type_model.dart'; import 'package:marco/model/expense/payment_types_model.dart'; class AddExpenseController extends GetxController { - // === Text Controllers === final amountController = TextEditingController(); final descriptionController = TextEditingController(); final supplierController = TextEditingController(); @@ -28,21 +27,17 @@ class AddExpenseController extends GetxController { final transactionDateController = TextEditingController(); final TextEditingController noOfPersonsController = TextEditingController(); - // === State Controllers === final RxBool isLoading = false.obs; final RxBool isSubmitting = false.obs; final RxBool isFetchingLocation = false.obs; - // === Selected Models === final Rx selectedPaymentMode = Rx(null); final Rx selectedExpenseType = Rx(null); - final Rx selectedExpenseStatus = - Rx(null); + final Rx selectedExpenseStatus = Rx(null); final Rx selectedPaidBy = Rx(null); final RxString selectedProject = ''.obs; final Rx selectedTransactionDate = Rx(null); - // === Lists === final RxList attachments = [].obs; final RxList globalProjects = [].obs; final RxList projects = [].obs; @@ -51,7 +46,6 @@ class AddExpenseController extends GetxController { final RxList expenseStatuses = [].obs; final RxList allEmployees = [].obs; - // === Mappings === final RxMap projectsMap = {}.obs; final ExpenseController expenseController = Get.find(); @@ -77,7 +71,6 @@ class AddExpenseController extends GetxController { super.onClose(); } - // === Pick Attachments === Future pickAttachments() async { try { final result = await FilePicker.platform.pickFiles( @@ -86,12 +79,15 @@ class AddExpenseController extends GetxController { allowMultiple: true, ); if (result != null && result.paths.isNotEmpty) { - final files = - result.paths.whereType().map((e) => File(e)).toList(); + final files = result.paths.whereType().map((e) => File(e)).toList(); attachments.addAll(files); } } catch (e) { - Get.snackbar("Error", "Failed to pick attachments: $e"); + showAppSnackbar( + title: "Error", + message: "Failed to pick attachments: $e", + type: SnackbarType.error, + ); } } @@ -99,16 +95,14 @@ class AddExpenseController extends GetxController { attachments.remove(file); } - // === Date Picker === void pickTransactionDate(BuildContext context) async { final now = DateTime.now(); final picked = await showDatePicker( context: context, initialDate: selectedTransactionDate.value ?? now, firstDate: DateTime(now.year - 5), - lastDate: now, // ✅ Restrict future dates + lastDate: now, ); - if (picked != null) { selectedTransactionDate.value = picked; transactionDateController.text = @@ -116,31 +110,33 @@ class AddExpenseController extends GetxController { } } - // === Fetch Current Location === Future fetchCurrentLocation() async { isFetchingLocation.value = true; try { LocationPermission permission = await Geolocator.checkPermission(); - if (permission == LocationPermission.denied || - permission == LocationPermission.deniedForever) { + if (permission == LocationPermission.denied || permission == LocationPermission.deniedForever) { permission = await Geolocator.requestPermission(); - if (permission == LocationPermission.denied || - permission == LocationPermission.deniedForever) { - Get.snackbar( - "Error", "Location permission denied. Enable in settings."); + if (permission == LocationPermission.denied || permission == LocationPermission.deniedForever) { + showAppSnackbar( + title: "Error", + message: "Location permission denied. Enable in settings.", + type: SnackbarType.error, + ); return; } } if (!await Geolocator.isLocationServiceEnabled()) { - Get.snackbar("Error", "Location services are disabled. Enable them."); + showAppSnackbar( + title: "Error", + message: "Location services are disabled. Enable them.", + type: SnackbarType.error, + ); return; } - final position = await Geolocator.getCurrentPosition( - desiredAccuracy: LocationAccuracy.high); - final placemarks = - await placemarkFromCoordinates(position.latitude, position.longitude); + final position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high); + final placemarks = await placemarkFromCoordinates(position.latitude, position.longitude); if (placemarks.isNotEmpty) { final place = placemarks.first; @@ -157,13 +153,16 @@ class AddExpenseController extends GetxController { locationController.text = "${position.latitude}, ${position.longitude}"; } } catch (e) { - Get.snackbar("Error", "Error fetching location: $e"); + showAppSnackbar( + title: "Error", + message: "Error fetching location: $e", + type: SnackbarType.error, + ); } finally { isFetchingLocation.value = false; } } - // === Submit Expense === Future submitExpense() async { if (isSubmitting.value) return; isSubmitting.value = true; @@ -239,8 +238,7 @@ class AddExpenseController extends GetxController { expensesTypeId: selectedExpenseType.value!.id, paymentModeId: selectedPaymentMode.value!.id, paidById: selectedPaidBy.value!.id, - transactionDate: - (selectedTransactionDate.value ?? DateTime.now()).toUtc(), + transactionDate: selectedTransactionDate.value?.toUtc() ?? DateTime.now().toUtc(), transactionId: transactionIdController.text, description: descriptionController.text, location: locationController.text, @@ -278,7 +276,6 @@ class AddExpenseController extends GetxController { } } - // === Fetch Data Methods === Future fetchMasterData() async { try { final expenseTypesData = await ApiService.getMasterExpenseTypes(); @@ -286,22 +283,22 @@ class AddExpenseController extends GetxController { final expenseStatusData = await ApiService.getMasterExpenseStatus(); if (expenseTypesData is List) { - expenseTypes.value = - expenseTypesData.map((e) => ExpenseTypeModel.fromJson(e)).toList(); + expenseTypes.value = expenseTypesData.map((e) => ExpenseTypeModel.fromJson(e)).toList(); } if (paymentModesData is List) { - paymentModes.value = - paymentModesData.map((e) => PaymentModeModel.fromJson(e)).toList(); + paymentModes.value = paymentModesData.map((e) => PaymentModeModel.fromJson(e)).toList(); } if (expenseStatusData is List) { - expenseStatuses.value = expenseStatusData - .map((e) => ExpenseStatusModel.fromJson(e)) - .toList(); + expenseStatuses.value = expenseStatusData.map((e) => ExpenseStatusModel.fromJson(e)).toList(); } } catch (e) { - Get.snackbar("Error", "Failed to fetch master data: $e"); + showAppSnackbar( + title: "Error", + message: "Failed to fetch master data: $e", + type: SnackbarType.error, + ); } } @@ -332,8 +329,7 @@ class AddExpenseController extends GetxController { final response = await ApiService.getAllEmployees(); if (response != null && response.isNotEmpty) { allEmployees.assignAll(response.map((e) => EmployeeModel.fromJson(e))); - logSafe("All Employees fetched: ${allEmployees.length}", - level: LogLevel.info); + logSafe("All Employees fetched: ${allEmployees.length}", level: LogLevel.info); } else { allEmployees.clear(); logSafe("No employees found.", level: LogLevel.warning); diff --git a/lib/controller/expense/expense_screen_controller.dart b/lib/controller/expense/expense_screen_controller.dart index 078b4ad..d04c3ac 100644 --- a/lib/controller/expense/expense_screen_controller.dart +++ b/lib/controller/expense/expense_screen_controller.dart @@ -7,6 +7,8 @@ import 'package:marco/model/expense/payment_types_model.dart'; import 'package:marco/model/expense/expense_type_model.dart'; import 'package:marco/model/expense/expense_status_model.dart'; import 'package:marco/model/employee_model.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; + class ExpenseController extends GetxController { final RxList expenses = [].obs; @@ -68,15 +70,27 @@ class ExpenseController extends GetxController { if (success) { expenses.removeWhere((e) => e.id == expenseId); logSafe("Expense deleted successfully."); - Get.snackbar("Deleted", "Expense has been deleted successfully."); + showAppSnackbar( + title: "Deleted", + message: "Expense has been deleted successfully.", + type: SnackbarType.success, + ); } else { logSafe("Failed to delete expense: $expenseId", level: LogLevel.error); - Get.snackbar("Failed", "Failed to delete expense."); + showAppSnackbar( + title: "Failed", + message: "Failed to delete expense.", + type: SnackbarType.error, + ); } } catch (e, stack) { logSafe("Exception in deleteExpense: $e", level: LogLevel.error); logSafe("StackTrace: $stack", level: LogLevel.debug); - Get.snackbar("Error", "Something went wrong while deleting."); + showAppSnackbar( + title: "Error", + message: "Something went wrong while deleting.", + type: SnackbarType.error, + ); } } @@ -159,29 +173,32 @@ class ExpenseController extends GetxController { /// Fetch master data: expense types, payment modes, and expense status Future fetchMasterData() async { - try { - final expenseTypesData = await ApiService.getMasterExpenseTypes(); - if (expenseTypesData is List) { - expenseTypes.value = - expenseTypesData.map((e) => ExpenseTypeModel.fromJson(e)).toList(); - } - - final paymentModesData = await ApiService.getMasterPaymentModes(); - if (paymentModesData is List) { - paymentModes.value = - paymentModesData.map((e) => PaymentModeModel.fromJson(e)).toList(); - } - - final expenseStatusData = await ApiService.getMasterExpenseStatus(); - if (expenseStatusData is List) { - expenseStatuses.value = expenseStatusData - .map((e) => ExpenseStatusModel.fromJson(e)) - .toList(); - } - } catch (e) { - Get.snackbar("Error", "Failed to fetch master data: $e"); + try { + final expenseTypesData = await ApiService.getMasterExpenseTypes(); + if (expenseTypesData is List) { + expenseTypes.value = + expenseTypesData.map((e) => ExpenseTypeModel.fromJson(e)).toList(); } + + final paymentModesData = await ApiService.getMasterPaymentModes(); + if (paymentModesData is List) { + paymentModes.value = + paymentModesData.map((e) => PaymentModeModel.fromJson(e)).toList(); + } + + final expenseStatusData = await ApiService.getMasterExpenseStatus(); + if (expenseStatusData is List) { + expenseStatuses.value = + expenseStatusData.map((e) => ExpenseStatusModel.fromJson(e)).toList(); + } + } catch (e) { + showAppSnackbar( + title: "Error", + message: "Failed to fetch master data: $e", + type: SnackbarType.error, + ); } +} /// Fetch global projects Future fetchGlobalProjects() async { diff --git a/lib/model/expense/reimbursement_bottom_sheet.dart b/lib/model/expense/reimbursement_bottom_sheet.dart index b22b26d..5c51956 100644 --- a/lib/model/expense/reimbursement_bottom_sheet.dart +++ b/lib/model/expense/reimbursement_bottom_sheet.dart @@ -7,6 +7,8 @@ import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text_style.dart'; import 'package:marco/helpers/utils/base_bottom_sheet.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; + class ReimbursementBottomSheet extends StatefulWidget { final String expenseId; @@ -34,7 +36,8 @@ class ReimbursementBottomSheet extends StatefulWidget { } class _ReimbursementBottomSheetState extends State { - final ExpenseDetailController controller = Get.find(); + final ExpenseDetailController controller = + Get.find(); final TextEditingController commentCtrl = TextEditingController(); final TextEditingController txnCtrl = TextEditingController(); @@ -119,7 +122,11 @@ class _ReimbursementBottomSheetState extends State { txnCtrl.text.trim().isEmpty || dateStr.value.isEmpty || controller.selectedReimbursedBy.value == null) { - Get.snackbar("Incomplete", "Please fill all fields"); + showAppSnackbar( + title: "Incomplete", + message: "Please fill all fields", + type: SnackbarType.warning, + ); return; } @@ -133,9 +140,17 @@ class _ReimbursementBottomSheetState extends State { if (success) { Get.back(); - Get.snackbar('Success', 'Reimbursement submitted'); + showAppSnackbar( + title: "Success", + message: "Reimbursement submitted", + type: SnackbarType.success, + ); } else { - Get.snackbar('Error', controller.errorMessage.value); + showAppSnackbar( + title: "Error", + message: controller.errorMessage.value, + type: SnackbarType.error, + ); } }, child: Column( @@ -148,7 +163,6 @@ class _ReimbursementBottomSheetState extends State { decoration: _inputDecoration("Enter comment"), ), MySpacing.height(16), - MyText.labelMedium("Transaction ID"), MySpacing.height(8), TextField( @@ -156,7 +170,6 @@ class _ReimbursementBottomSheetState extends State { decoration: _inputDecoration("Enter transaction ID"), ), MySpacing.height(16), - MyText.labelMedium("Reimbursement Date"), MySpacing.height(8), GestureDetector( @@ -183,7 +196,6 @@ class _ReimbursementBottomSheetState extends State { ), ), MySpacing.height(16), - MyText.labelMedium("Reimbursed By"), MySpacing.height(8), GestureDetector( diff --git a/lib/view/expense/expense_detail_screen.dart b/lib/view/expense/expense_detail_screen.dart index 6718ecd..9af777e 100644 --- a/lib/view/expense/expense_detail_screen.dart +++ b/lib/view/expense/expense_detail_screen.dart @@ -12,6 +12,7 @@ import 'package:marco/model/expense/expense_detail_model.dart'; import 'package:marco/helpers/widgets/image_viewer_dialog.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:marco/model/expense/reimbursement_bottom_sheet.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; class ExpenseDetailScreen extends StatelessWidget { final String expenseId; @@ -224,21 +225,18 @@ class ExpenseDetailScreen extends StatelessWidget { ); if (success) { - Get.snackbar( - 'Success', - 'Expense reimbursed successfully.', - backgroundColor: Colors.green.withOpacity(0.8), - colorText: Colors.white, + showAppSnackbar( + title: 'Success', + message: 'Expense reimbursed successfully.', + type: SnackbarType.success, ); await controller.fetchExpenseDetails(); - return true; } else { - Get.snackbar( - 'Error', - 'Failed to reimburse expense.', - backgroundColor: Colors.red.withOpacity(0.8), - colorText: Colors.white, + showAppSnackbar( + title: 'Error', + message: 'Failed to reimburse expense.', + type: SnackbarType.error, ); return false; } @@ -250,19 +248,18 @@ class ExpenseDetailScreen extends StatelessWidget { await controller.updateExpenseStatus(next.id); if (success) { - Get.snackbar( - 'Success', - 'Expense moved to ${next.displayName.isNotEmpty ? next.displayName : next.name}', - backgroundColor: Colors.green.withOpacity(0.8), - colorText: Colors.white, + showAppSnackbar( + title: 'Success', + message: + 'Expense moved to ${next.displayName.isNotEmpty ? next.displayName : next.name}', + type: SnackbarType.success, ); await controller.fetchExpenseDetails(); } else { - Get.snackbar( - 'Error', - 'Failed to update status.', - backgroundColor: Colors.red.withOpacity(0.8), - colorText: Colors.white, + showAppSnackbar( + title: 'Error', + message: 'Failed to update status.', + type: SnackbarType.error, ); } } @@ -491,7 +488,11 @@ class _InvoiceDocuments extends StatelessWidget { if (await canLaunchUrl(url)) { await launchUrl(url, mode: LaunchMode.externalApplication); } else { - Get.snackbar("Error", "Could not open the document."); + showAppSnackbar( + title: 'Error', + message: 'Could not open the document.', + type: SnackbarType.error, + ); } } }, From f245f9accfb4c842e90b88a5d03615838bf0b118 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Mon, 4 Aug 2025 17:04:33 +0530 Subject: [PATCH 50/65] refactor: Update API endpoints for employee retrieval to include projectId as a query parameter for improved functionality --- lib/helpers/services/api_endpoints.dart | 6 +++--- lib/helpers/services/api_service.dart | 11 +++++++---- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index 520feb1..438a6f8 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -15,8 +15,8 @@ class ApiEndpoints { static const String uploadAttendanceImage = "/attendance/record-image"; // Employee Screen API Endpoints - static const String getAllEmployeesByProject = "/employee/list"; - static const String getAllEmployees = "/employee/list"; + static const String getAllEmployeesByProject = "/Employee/basic"; + static const String getAllEmployees = "/Employee/basic"; static const String getRoles = "/roles/jobrole"; static const String createEmployee = "/employee/manage-mobile"; static const String getEmployeeInfo = "/employee/profile/get"; @@ -35,7 +35,7 @@ class ApiEndpoints { static const String assignTask = "/project/task"; static const String getmasterWorkCategories = "/Master/work-categories"; - ////// Directory Module API Endpoints + ////// Directory Module API Endpoints /////// static const String getDirectoryContacts = "/directory"; static const String getDirectoryBucketList = "/directory/buckets"; static const String getDirectoryContactDetail = "/directory/notes"; diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index a976f35..2107512 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -1114,10 +1114,13 @@ class ApiService { static Future?> getAllEmployeesByProject( String projectId) async { if (projectId.isEmpty) throw ArgumentError('projectId must not be empty'); - final endpoint = "${ApiEndpoints.getAllEmployeesByProject}/$projectId"; - return _getRequest(endpoint).then((res) => res != null - ? _parseResponse(res, label: 'Employees by Project') - : null); + final endpoint = + "${ApiEndpoints.getAllEmployeesByProject}?projectId=$projectId"; + return _getRequest(endpoint).then( + (res) => res != null + ? _parseResponse(res, label: 'Employees by Project') + : null, + ); } static Future?> getAllEmployees() async => From 7dbc9138c60e17d18a4096ed996d20f5ee97e69a Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Tue, 5 Aug 2025 10:09:34 +0530 Subject: [PATCH 51/65] refactor: Enhance ExpenseDetailController and ExpenseDetailScreen to support optional comments during expense status updates and improve code readability --- .../expense/expense_detail_controller.dart | 18 +++-- lib/helpers/services/api_service.dart | 1 - lib/model/expense/comment_bottom_sheet.dart | 67 +++++++++++++++++++ lib/view/expense/expense_detail_screen.dart | 43 ++++++++---- 4 files changed, 109 insertions(+), 20 deletions(-) create mode 100644 lib/model/expense/comment_bottom_sheet.dart diff --git a/lib/controller/expense/expense_detail_controller.dart b/lib/controller/expense/expense_detail_controller.dart index 23d3d4e..4bc8561 100644 --- a/lib/controller/expense/expense_detail_controller.dart +++ b/lib/controller/expense/expense_detail_controller.dart @@ -12,7 +12,8 @@ class ExpenseDetailController extends GetxController { final RxList allEmployees = [].obs; late String _expenseId; - bool _isInitialized = false; + bool _isInitialized = false; + /// Call this once from the screen (NOT inside build) to initialize void init(String expenseId) { if (_isInitialized) return; @@ -39,7 +40,8 @@ class ExpenseDetailController extends GetxController { logSafe("$operationName completed successfully."); return result; } catch (e, stack) { - errorMessage.value = 'An unexpected error occurred during $operationName.'; + errorMessage.value = + 'An unexpected error occurred during $operationName.'; logSafe("Exception in $operationName: $e", level: LogLevel.error); logSafe("StackTrace: $stack", level: LogLevel.debug); return null; @@ -133,7 +135,8 @@ class ExpenseDetailController extends GetxController { "submit reimbursement", ); - if (success == true) { // Explicitly check for true as _apiCallWrapper returns T? + if (success == true) { + // Explicitly check for true as _apiCallWrapper returns T? await fetchExpenseDetails(); // Refresh details after successful update return true; } else { @@ -143,21 +146,22 @@ class ExpenseDetailController extends GetxController { } /// Update status for this specific expense - Future updateExpenseStatus(String statusId) async { + Future updateExpenseStatus(String statusId, {String? comment}) async { final success = await _apiCallWrapper( () => ApiService.updateExpenseStatusApi( expenseId: _expenseId, statusId: statusId, + comment: comment, ), "update expense status", ); - if (success == true) { // Explicitly check for true as _apiCallWrapper returns T? - await fetchExpenseDetails(); // Refresh details after successful update + if (success == true) { + await fetchExpenseDetails(); return true; } else { errorMessage.value = "Failed to update expense status."; return false; } } -} \ No newline at end of file +} diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 2107512..1ab3685 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -325,7 +325,6 @@ class ApiService { } /// Update Expense Status API - /// Update Expense Status API (supports optional reimbursement fields) static Future updateExpenseStatusApi({ required String expenseId, required String statusId, diff --git a/lib/model/expense/comment_bottom_sheet.dart b/lib/model/expense/comment_bottom_sheet.dart new file mode 100644 index 0000000..447a629 --- /dev/null +++ b/lib/model/expense/comment_bottom_sheet.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; + +Future showCommentBottomSheet(BuildContext context, String actionText) async { + final commentController = TextEditingController(); + String? errorText; + + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) { + return StatefulBuilder( + builder: (context, setModalState) { + void submit() { + final comment = commentController.text.trim(); + if (comment.isEmpty) { + setModalState(() => errorText = 'Comment cannot be empty.'); + return; + } + Navigator.of(context).pop(comment); + } + + return Padding( + padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + child: BaseBottomSheet( + title: 'Add Comment for ${_capitalizeFirstLetter(actionText)}', + onCancel: () => Navigator.of(context).pop(), + onSubmit: submit, + isSubmitting: false, + submitText: 'Submit', + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: commentController, + maxLines: 4, + decoration: InputDecoration( + hintText: 'Type your comment here...', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + filled: true, + fillColor: Colors.grey.shade100, + errorText: errorText, + ), + onChanged: (_) { + if (errorText != null) { + setModalState(() => errorText = null); + } + }, + ), + ], + ), + ), + ); + }, + ); + }, + ); +} + +String _capitalizeFirstLetter(String text) => + text.isEmpty ? text : text[0].toUpperCase() + text.substring(1); diff --git a/lib/view/expense/expense_detail_screen.dart b/lib/view/expense/expense_detail_screen.dart index 9af777e..102bb97 100644 --- a/lib/view/expense/expense_detail_screen.dart +++ b/lib/view/expense/expense_detail_screen.dart @@ -13,6 +13,7 @@ import 'package:marco/helpers/widgets/image_viewer_dialog.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:marco/model/expense/reimbursement_bottom_sheet.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:marco/model/expense/comment_bottom_sheet.dart'; class ExpenseDetailScreen extends StatelessWidget { final String expenseId; @@ -195,8 +196,11 @@ class ExpenseDetailScreen extends StatelessWidget { borderRadius: BorderRadius.circular(6)), ), onPressed: () async { - if (expense.status.id == - 'f18c5cfd-7815-4341-8da2-2c2d65778e27') { + const reimbursementId = + 'f18c5cfd-7815-4341-8da2-2c2d65778e27'; + + if (expense.status.id == reimbursementId) { + // Open reimbursement flow showModalBottomSheet( context: context, isScrollControlled: true, @@ -244,8 +248,15 @@ class ExpenseDetailScreen extends StatelessWidget { ), ); } else { - final success = - await controller.updateExpenseStatus(next.id); + // ✨ New: Show comment sheet + final comment = + await showCommentBottomSheet(context, next.name); + if (comment == null) return; + + final success = await controller.updateExpenseStatus( + next.id, + comment: comment, + ); if (success) { showAppSnackbar( @@ -457,14 +468,20 @@ class _InvoiceDocuments extends StatelessWidget { if (documents.isEmpty) { return MyText.bodyMedium('No Supporting Documents', color: Colors.grey); } + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MyText.bodySmall("Supporting Documents:", fontWeight: 600), - const SizedBox(height: 8), - Wrap( - spacing: 10, - children: documents.map((doc) { + const SizedBox(height: 12), + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: documents.length, + separatorBuilder: (_, __) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final doc = documents[index]; + return GestureDetector( onTap: () async { final imageDocs = documents @@ -505,7 +522,6 @@ class _InvoiceDocuments extends StatelessWidget { color: Colors.grey.shade100, ), child: Row( - mainAxisSize: MainAxisSize.min, children: [ Icon( doc.contentType.startsWith('image/') @@ -515,14 +531,17 @@ class _InvoiceDocuments extends StatelessWidget { color: Colors.grey[600], ), const SizedBox(width: 7), - MyText.labelSmall( - doc.fileName, + Expanded( + child: MyText.labelSmall( + doc.fileName, + overflow: TextOverflow.ellipsis, + ), ), ], ), ), ); - }).toList(), + }, ), ], ); From 84811635d01d223bd97f8e7f3e7a372ecf585668 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Tue, 5 Aug 2025 10:14:43 +0530 Subject: [PATCH 52/65] resolved the not opening detail screen o tap of non content --- lib/view/expense/expense_screen.dart | 96 +++++++++++++++------------- 1 file changed, 50 insertions(+), 46 deletions(-) diff --git a/lib/view/expense/expense_screen.dart b/lib/view/expense/expense_screen.dart index ac8b396..9c0ce3f 100644 --- a/lib/view/expense/expense_screen.dart +++ b/lib/view/expense/expense_screen.dart @@ -488,53 +488,57 @@ class _ExpenseList extends StatelessWidget { format: 'dd MMM yyyy, hh:mm a', ); - return GestureDetector( - onTap: () async { - final result = await Get.to( - () => ExpenseDetailScreen(expenseId: expense.id), - arguments: {'expense': expense}, - ); - if (result == true && onViewDetail != null) { - await onViewDetail!(); - } - }, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - MyText.bodyMedium(expense.expensesType.name, - fontWeight: 600), - Row( - children: [ - MyText.bodyMedium( - '₹ ${expense.amount.toStringAsFixed(2)}', - fontWeight: 600), - if (expense.status.name.toLowerCase() == 'draft') ...[ - const SizedBox(width: 8), - GestureDetector( - onTap: () => - _showDeleteConfirmation(context, expense), - child: const Icon(Icons.delete, - color: Colors.red, size: 20), - ), + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () async { + final result = await Get.to( + () => ExpenseDetailScreen(expenseId: expense.id), + arguments: {'expense': expense}, + ); + if (result == true && onViewDetail != null) { + await onViewDetail!(); + } + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + MyText.bodyMedium(expense.expensesType.name, + fontWeight: 600), + Row( + children: [ + MyText.bodyMedium( + '₹ ${expense.amount.toStringAsFixed(2)}', + fontWeight: 600), + if (expense.status.name.toLowerCase() == 'draft') ...[ + const SizedBox(width: 8), + GestureDetector( + onTap: () => + _showDeleteConfirmation(context, expense), + child: const Icon(Icons.delete, + color: Colors.red, size: 20), + ), + ], ], - ], - ), - ], - ), - const SizedBox(height: 6), - Row( - children: [ - MyText.bodySmall(formattedDate, fontWeight: 500), - const Spacer(), - MyText.bodySmall(expense.status.name, fontWeight: 500), - ], - ), - ], + ), + ], + ), + const SizedBox(height: 6), + Row( + children: [ + MyText.bodySmall(formattedDate, fontWeight: 500), + const Spacer(), + MyText.bodySmall(expense.status.name, fontWeight: 500), + ], + ), + ], + ), ), ), ); From f1220cc0189523c1952dd401352ce3268f8737a1 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Tue, 5 Aug 2025 10:24:42 +0530 Subject: [PATCH 53/65] handelled the loading --- lib/controller/expense/expense_screen_controller.dart | 2 +- lib/view/expense/expense_screen.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/controller/expense/expense_screen_controller.dart b/lib/controller/expense/expense_screen_controller.dart index d04c3ac..51a475c 100644 --- a/lib/controller/expense/expense_screen_controller.dart +++ b/lib/controller/expense/expense_screen_controller.dart @@ -107,7 +107,7 @@ class ExpenseController extends GetxController { }) async { isLoading.value = true; errorMessage.value = ''; - + expenses.clear(); _pageSize = pageSize; _pageNumber = pageNumber; diff --git a/lib/view/expense/expense_screen.dart b/lib/view/expense/expense_screen.dart index 9c0ce3f..d20547e 100644 --- a/lib/view/expense/expense_screen.dart +++ b/lib/view/expense/expense_screen.dart @@ -472,7 +472,7 @@ class _ExpenseList extends StatelessWidget { @override Widget build(BuildContext context) { - if (expenseList.isEmpty) { + if (expenseList.isEmpty && !Get.find().isLoading.value) { return Center(child: MyText.bodyMedium('No expenses found.')); } From 0acd619d7855aaa69336d5abc85463ff69735b38 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Tue, 5 Aug 2025 17:41:36 +0530 Subject: [PATCH 54/65] added edit functioanllity in expense --- .../expense/add_expense_controller.dart | 429 ++++++++---- lib/helpers/services/api_endpoints.dart | 6 +- lib/helpers/services/api_service.dart | 42 ++ .../expense/add_expense_bottom_sheet.dart | 160 ++++- lib/view/expense/expense_detail_screen.dart | 662 +++++++++--------- 5 files changed, 788 insertions(+), 511 deletions(-) diff --git a/lib/controller/expense/add_expense_controller.dart b/lib/controller/expense/add_expense_controller.dart index a3c6b8a..adaa258 100644 --- a/lib/controller/expense/add_expense_controller.dart +++ b/lib/controller/expense/add_expense_controller.dart @@ -3,21 +3,23 @@ import 'dart:io'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import 'package:get/get.dart'; import 'package:geocoding/geocoding.dart'; import 'package:geolocator/geolocator.dart'; -import 'package:get/get.dart'; +import 'package:intl/intl.dart'; import 'package:mime/mime.dart'; import 'package:marco/controller/expense/expense_screen_controller.dart'; import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; + import 'package:marco/model/employee_model.dart'; -import 'package:marco/model/expense/expense_status_model.dart'; import 'package:marco/model/expense/expense_type_model.dart'; import 'package:marco/model/expense/payment_types_model.dart'; class AddExpenseController extends GetxController { + // Text Controllers final amountController = TextEditingController(); final descriptionController = TextEditingController(); final supplierController = TextEditingController(); @@ -25,30 +27,34 @@ class AddExpenseController extends GetxController { final gstController = TextEditingController(); final locationController = TextEditingController(); final transactionDateController = TextEditingController(); - final TextEditingController noOfPersonsController = TextEditingController(); + final noOfPersonsController = TextEditingController(); - final RxBool isLoading = false.obs; - final RxBool isSubmitting = false.obs; - final RxBool isFetchingLocation = false.obs; + // State + final isLoading = false.obs; + final isSubmitting = false.obs; + final isFetchingLocation = false.obs; + final isEditMode = false.obs; - final Rx selectedPaymentMode = Rx(null); - final Rx selectedExpenseType = Rx(null); - final Rx selectedExpenseStatus = Rx(null); - final Rx selectedPaidBy = Rx(null); - final RxString selectedProject = ''.obs; - final Rx selectedTransactionDate = Rx(null); + // Dropdown Selections + final selectedPaymentMode = Rx(null); + final selectedExpenseType = Rx(null); + final selectedPaidBy = Rx(null); + final selectedProject = ''.obs; + final selectedTransactionDate = Rx(null); - final RxList attachments = [].obs; - final RxList globalProjects = [].obs; - final RxList projects = [].obs; - final RxList expenseTypes = [].obs; - final RxList paymentModes = [].obs; - final RxList expenseStatuses = [].obs; - final RxList allEmployees = [].obs; + // Data Lists + final attachments = [].obs; + final globalProjects = [].obs; + final projectsMap = {}.obs; + final expenseTypes = [].obs; + final paymentModes = [].obs; + final allEmployees = [].obs; + final existingAttachments = >[].obs; - final RxMap projectsMap = {}.obs; + // Editing + String? editingExpenseId; - final ExpenseController expenseController = Get.find(); + final expenseController = Get.find(); @override void onInit() { @@ -71,6 +77,117 @@ class AddExpenseController extends GetxController { super.onClose(); } + // ---------- Form Population for Edit ---------- + void populateFieldsForEdit(Map data) { + isEditMode.value = true; + editingExpenseId = data['id']; + + // Basic fields + selectedProject.value = data['projectName'] ?? ''; + amountController.text = data['amount']?.toString() ?? ''; + supplierController.text = data['supplerName'] ?? ''; + descriptionController.text = data['description'] ?? ''; + transactionIdController.text = data['transactionId'] ?? ''; + locationController.text = data['location'] ?? ''; + + // Transaction Date + if (data['transactionDate'] != null) { + try { + final parsedDate = DateTime.parse(data['transactionDate']); + selectedTransactionDate.value = parsedDate; + transactionDateController.text = + DateFormat('dd-MM-yyyy').format(parsedDate); + } catch (e) { + logSafe('Error parsing transactionDate: $e', level: LogLevel.warning); + selectedTransactionDate.value = null; + transactionDateController.clear(); + } + } else { + selectedTransactionDate.value = null; + transactionDateController.clear(); + } + + // No of Persons + noOfPersonsController.text = (data['noOfPersons'] ?? 0).toString(); + + // Select Expense Type and Payment Mode by matching IDs + selectedExpenseType.value = + expenseTypes.firstWhereOrNull((e) => e.id == data['expensesTypeId']); + selectedPaymentMode.value = + paymentModes.firstWhereOrNull((e) => e.id == data['paymentModeId']); + + // Select Paid By employee matching id (case insensitive, trimmed) + final paidById = data['paidById']?.toString().trim().toLowerCase() ?? ''; + selectedPaidBy.value = allEmployees + .firstWhereOrNull((e) => e.id.trim().toLowerCase() == paidById); + + if (selectedPaidBy.value == null && paidById.isNotEmpty) { + logSafe('⚠️ Could not match paidById: "$paidById"', + level: LogLevel.warning); + for (var emp in allEmployees) { + logSafe( + 'Employee ID: "${emp.id.trim().toLowerCase()}", Name: "${emp.name}"', + level: LogLevel.warning); + } + } + + // Populate existing attachments if present + existingAttachments.clear(); + if (data['attachments'] != null && data['attachments'] is List) { + existingAttachments + .addAll(List>.from(data['attachments'])); + } + + _logPrefilledData(); + } + + void _logPrefilledData() { + logSafe('--- Prefilled Expense Data ---', level: LogLevel.info); + logSafe('ID: $editingExpenseId', level: LogLevel.info); + logSafe('Project: ${selectedProject.value}', level: LogLevel.info); + logSafe('Amount: ${amountController.text}', level: LogLevel.info); + logSafe('Supplier: ${supplierController.text}', level: LogLevel.info); + logSafe('Description: ${descriptionController.text}', level: LogLevel.info); + logSafe('Transaction ID: ${transactionIdController.text}', + level: LogLevel.info); + logSafe('Location: ${locationController.text}', level: LogLevel.info); + logSafe('Transaction Date: ${transactionDateController.text}', + level: LogLevel.info); + logSafe('No. of Persons: ${noOfPersonsController.text}', + level: LogLevel.info); + logSafe('Expense Type: ${selectedExpenseType.value?.name}', + level: LogLevel.info); + logSafe('Payment Mode: ${selectedPaymentMode.value?.name}', + level: LogLevel.info); + logSafe('Paid By: ${selectedPaidBy.value?.name}', level: LogLevel.info); + logSafe('Attachments: ${attachments.length}', level: LogLevel.info); + logSafe('Existing Attachments: ${existingAttachments.length}', + level: LogLevel.info); + } + + // ---------- Form Actions ---------- + Future pickTransactionDate(BuildContext context) async { + final picked = await showDatePicker( + context: context, + initialDate: selectedTransactionDate.value ?? DateTime.now(), + firstDate: DateTime(DateTime.now().year - 5), + lastDate: DateTime.now(), + ); + + if (picked != null) { + selectedTransactionDate.value = picked; + transactionDateController.text = DateFormat('dd-MM-yyyy').format(picked); + } + } + + Future loadMasterData() async { + await Future.wait([ + fetchMasterData(), + fetchGlobalProjects(), + fetchAllEmployees(), + ]); + } + Future pickAttachments() async { try { final result = await FilePicker.platform.pickFiles( @@ -78,48 +195,34 @@ class AddExpenseController extends GetxController { allowedExtensions: ['jpg', 'jpeg', 'png', 'pdf', 'doc', 'docx'], allowMultiple: true, ); - if (result != null && result.paths.isNotEmpty) { - final files = result.paths.whereType().map((e) => File(e)).toList(); - attachments.addAll(files); + if (result != null) { + attachments.addAll( + result.paths.whereType().map((path) => File(path)), + ); } } catch (e) { showAppSnackbar( title: "Error", - message: "Failed to pick attachments: $e", + message: "Attachment error: $e", type: SnackbarType.error, ); } } - void removeAttachment(File file) { - attachments.remove(file); - } - - void pickTransactionDate(BuildContext context) async { - final now = DateTime.now(); - final picked = await showDatePicker( - context: context, - initialDate: selectedTransactionDate.value ?? now, - firstDate: DateTime(now.year - 5), - lastDate: now, - ); - if (picked != null) { - selectedTransactionDate.value = picked; - transactionDateController.text = - "${picked.day.toString().padLeft(2, '0')}-${picked.month.toString().padLeft(2, '0')}-${picked.year}"; - } - } + void removeAttachment(File file) => attachments.remove(file); Future fetchCurrentLocation() async { isFetchingLocation.value = true; try { - LocationPermission permission = await Geolocator.checkPermission(); - if (permission == LocationPermission.denied || permission == LocationPermission.deniedForever) { + var permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { permission = await Geolocator.requestPermission(); - if (permission == LocationPermission.denied || permission == LocationPermission.deniedForever) { + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { showAppSnackbar( title: "Error", - message: "Location permission denied. Enable in settings.", + message: "Location permission denied.", type: SnackbarType.error, ); return; @@ -129,24 +232,24 @@ class AddExpenseController extends GetxController { if (!await Geolocator.isLocationServiceEnabled()) { showAppSnackbar( title: "Error", - message: "Location services are disabled. Enable them.", + message: "Location service disabled.", type: SnackbarType.error, ); return; } - final position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high); - final placemarks = await placemarkFromCoordinates(position.latitude, position.longitude); + final position = await Geolocator.getCurrentPosition(); + final placemarks = + await placemarkFromCoordinates(position.latitude, position.longitude); if (placemarks.isNotEmpty) { final place = placemarks.first; final address = [ place.name, place.street, - place.subLocality, place.locality, place.administrativeArea, - place.country, + place.country ].where((e) => e != null && e.isNotEmpty).join(", "); locationController.text = address; } else { @@ -155,7 +258,7 @@ class AddExpenseController extends GetxController { } catch (e) { showAppSnackbar( title: "Error", - message: "Error fetching location: $e", + message: "Location error: $e", type: SnackbarType.error, ); } finally { @@ -163,112 +266,62 @@ class AddExpenseController extends GetxController { } } - Future submitExpense() async { + // ---------- Submission ---------- + Future submitOrUpdateExpense() async { if (isSubmitting.value) return; isSubmitting.value = true; try { - List missing = []; - - if (selectedProject.value.isEmpty) missing.add("Project"); - if (selectedExpenseType.value == null) missing.add("Expense Type"); - if (selectedPaymentMode.value == null) missing.add("Payment Mode"); - if (selectedPaidBy.value == null) missing.add("Paid By"); - if (amountController.text.isEmpty) missing.add("Amount"); - if (supplierController.text.isEmpty) missing.add("Supplier Name"); - if (descriptionController.text.isEmpty) missing.add("Description"); - if (attachments.isEmpty) missing.add("Attachments"); - - if (missing.isNotEmpty) { + final validation = validateForm(); + if (validation.isNotEmpty) { showAppSnackbar( title: "Missing Fields", - message: "Please provide: ${missing.join(', ')}.", + message: validation, type: SnackbarType.error, ); return; } - final amount = double.tryParse(amountController.text); - if (amount == null) { - showAppSnackbar( - title: "Error", - message: "Please enter a valid amount.", - type: SnackbarType.error, - ); - return; - } + final payload = await _buildExpensePayload(); - final selectedDate = selectedTransactionDate.value ?? DateTime.now(); - if (selectedDate.isAfter(DateTime.now())) { - showAppSnackbar( - title: "Invalid Date", - message: "Transaction date cannot be in the future.", - type: SnackbarType.error, - ); - return; - } - - final projectId = projectsMap[selectedProject.value]; - if (projectId == null) { - showAppSnackbar( - title: "Error", - message: "Invalid project selected.", - type: SnackbarType.error, - ); - return; - } - - final billAttachments = await Future.wait(attachments.map((file) async { - final bytes = await file.readAsBytes(); - final base64 = base64Encode(bytes); - final mime = lookupMimeType(file.path) ?? 'application/octet-stream'; - final size = await file.length(); - - return { - "fileName": file.path.split('/').last, - "base64Data": base64, - "contentType": mime, - "fileSize": size, - "description": "", - }; - })); - - final success = await ApiService.createExpenseApi( - projectId: projectId, - expensesTypeId: selectedExpenseType.value!.id, - paymentModeId: selectedPaymentMode.value!.id, - paidById: selectedPaidBy.value!.id, - transactionDate: selectedTransactionDate.value?.toUtc() ?? DateTime.now().toUtc(), - transactionId: transactionIdController.text, - description: descriptionController.text, - location: locationController.text, - supplerName: supplierController.text, - amount: amount, - noOfPersons: selectedExpenseType.value?.noOfPersonsRequired == true - ? int.tryParse(noOfPersonsController.text.trim()) ?? 0 - : 0, - billAttachments: billAttachments, - ); + final success = isEditMode.value && editingExpenseId != null + ? await ApiService.editExpenseApi( + expenseId: editingExpenseId!, payload: payload) + : await ApiService.createExpenseApi( + projectId: payload['projectId'], + expensesTypeId: payload['expensesTypeId'], + paymentModeId: payload['paymentModeId'], + paidById: payload['paidById'], + transactionDate: DateTime.parse(payload['transactionDate']), + transactionId: payload['transactionId'], + description: payload['description'], + location: payload['location'], + supplerName: payload['supplerName'], + amount: payload['amount'], + noOfPersons: payload['noOfPersons'], + billAttachments: payload['billAttachments'], + ); if (success) { await expenseController.fetchExpenses(); Get.back(); showAppSnackbar( title: "Success", - message: "Expense created successfully!", + message: + "Expense ${isEditMode.value ? 'updated' : 'created'} successfully!", type: SnackbarType.success, ); } else { showAppSnackbar( title: "Error", - message: "Failed to create expense. Try again.", + message: "Operation failed. Try again.", type: SnackbarType.error, ); } } catch (e) { showAppSnackbar( title: "Error", - message: "Something went wrong: $e", + message: "Unexpected error: $e", type: SnackbarType.error, ); } finally { @@ -276,27 +329,104 @@ class AddExpenseController extends GetxController { } } + Future> _buildExpensePayload() async { + final amount = double.parse(amountController.text.trim()); + final projectId = projectsMap[selectedProject.value]!; + final selectedDate = + selectedTransactionDate.value?.toUtc() ?? DateTime.now().toUtc(); + final existingAttachmentPayloads = existingAttachments + .map((e) => { + "fileName": e['fileName'], + "contentType": e['contentType'], + "fileSize": 0, // optional or populate if known + "description": "", + "url": e['url'], // custom field if your backend accepts + }) + .toList(); + + final newAttachmentPayloads = + await Future.wait(attachments.map((file) async { + final bytes = await file.readAsBytes(); + return { + "fileName": file.path.split('/').last, + "base64Data": base64Encode(bytes), + "contentType": lookupMimeType(file.path) ?? 'application/octet-stream', + "fileSize": await file.length(), + "description": "", + }; + })); + final billAttachments = [ + ...existingAttachmentPayloads, + ...newAttachmentPayloads + ]; + + final Map payload = { + "projectId": projectId, + "expensesTypeId": selectedExpenseType.value!.id, + "paymentModeId": selectedPaymentMode.value!.id, + "paidById": selectedPaidBy.value!.id, + "transactionDate": selectedDate.toIso8601String(), + "transactionId": transactionIdController.text, + "description": descriptionController.text, + "location": locationController.text, + "supplerName": supplierController.text, + "amount": amount, + "noOfPersons": selectedExpenseType.value?.noOfPersonsRequired == true + ? int.tryParse(noOfPersonsController.text.trim()) ?? 0 + : 0, + "billAttachments": billAttachments, + }; + + // ✅ Add expense ID if in edit mode + if (isEditMode.value && editingExpenseId != null) { + payload['id'] = editingExpenseId; + } + + return payload; + } + + String validateForm() { + final missing = []; + + if (selectedProject.value.isEmpty) missing.add("Project"); + if (selectedExpenseType.value == null) missing.add("Expense Type"); + if (selectedPaymentMode.value == null) missing.add("Payment Mode"); + if (selectedPaidBy.value == null) missing.add("Paid By"); + if (amountController.text.trim().isEmpty) missing.add("Amount"); + if (supplierController.text.trim().isEmpty) missing.add("Supplier Name"); + if (descriptionController.text.trim().isEmpty) missing.add("Description"); + if (!isEditMode.value && attachments.isEmpty) missing.add("Attachments"); + + final amount = double.tryParse(amountController.text.trim()); + if (amount == null) missing.add("Valid Amount"); + + final selectedDate = selectedTransactionDate.value; + if (selectedDate != null && selectedDate.isAfter(DateTime.now())) { + missing.add("Valid Transaction Date"); + } + + return missing.isEmpty ? '' : "Please provide: ${missing.join(', ')}."; + } + + // ---------- Data Fetching ---------- Future fetchMasterData() async { try { - final expenseTypesData = await ApiService.getMasterExpenseTypes(); - final paymentModesData = await ApiService.getMasterPaymentModes(); - final expenseStatusData = await ApiService.getMasterExpenseStatus(); + final types = await ApiService.getMasterExpenseTypes(); + final modes = await ApiService.getMasterPaymentModes(); - if (expenseTypesData is List) { - expenseTypes.value = expenseTypesData.map((e) => ExpenseTypeModel.fromJson(e)).toList(); + if (types is List) { + expenseTypes.value = + types.map((e) => ExpenseTypeModel.fromJson(e)).toList(); } - if (paymentModesData is List) { - paymentModes.value = paymentModesData.map((e) => PaymentModeModel.fromJson(e)).toList(); - } - - if (expenseStatusData is List) { - expenseStatuses.value = expenseStatusData.map((e) => ExpenseStatusModel.fromJson(e)).toList(); + if (modes is List) { + paymentModes.value = + modes.map((e) => PaymentModeModel.fromJson(e)).toList(); } } catch (e) { showAppSnackbar( title: "Error", - message: "Failed to fetch master data: $e", + message: "Failed to fetch master data", type: SnackbarType.error, ); } @@ -310,16 +440,15 @@ class AddExpenseController extends GetxController { for (var item in response) { final name = item['name']?.toString().trim(); final id = item['id']?.toString().trim(); - if (name != null && id != null && name.isNotEmpty) { + if (name != null && id != null) { projectsMap[name] = id; names.add(name); } } globalProjects.assignAll(names); - logSafe("Fetched ${names.length} global projects"); } } catch (e) { - logSafe("Failed to fetch global projects: $e", level: LogLevel.error); + logSafe("Error fetching projects: $e", level: LogLevel.error); } } @@ -327,19 +456,13 @@ class AddExpenseController extends GetxController { isLoading.value = true; try { final response = await ApiService.getAllEmployees(); - if (response != null && response.isNotEmpty) { + if (response != null) { allEmployees.assignAll(response.map((e) => EmployeeModel.fromJson(e))); - logSafe("All Employees fetched: ${allEmployees.length}", level: LogLevel.info); - } else { - allEmployees.clear(); - logSafe("No employees found.", level: LogLevel.warning); } } catch (e) { - allEmployees.clear(); - logSafe("Error fetching employees", level: LogLevel.error, error: e); + logSafe("Error fetching employees: $e", level: LogLevel.error); } finally { isLoading.value = false; - update(); } } } diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index 438a6f8..1a98492 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -15,8 +15,8 @@ class ApiEndpoints { static const String uploadAttendanceImage = "/attendance/record-image"; // Employee Screen API Endpoints - static const String getAllEmployeesByProject = "/Employee/basic"; - static const String getAllEmployees = "/Employee/basic"; + static const String getAllEmployeesByProject = "/employee/list"; + static const String getAllEmployees = "/employee/list"; static const String getRoles = "/roles/jobrole"; static const String createEmployee = "/employee/manage-mobile"; static const String getEmployeeInfo = "/employee/profile/get"; @@ -55,7 +55,7 @@ class ApiEndpoints { static const String getExpenseList = "/expense/list"; static const String getExpenseDetails = "/expense/details"; static const String createExpense = "/expense/create"; - static const String updateExpense = "/expense/manage"; + static const String editExpense = "/Expense/edit"; static const String getMasterPaymentModes = "/master/payment-modes"; static const String getMasterExpenseStatus = "/master/expenses-status"; static const String getMasterExpenseTypes = "/master/expenses-types"; diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 1ab3685..350eb8b 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -240,6 +240,48 @@ class ApiService { } // === Expense APIs === // + + /// Edit Expense API + static Future editExpenseApi({ + required String expenseId, + required Map payload, + }) async { + final endpoint = "${ApiEndpoints.editExpense}/$expenseId"; + logSafe("Editing expense $expenseId with payload: $payload"); + + try { + final response = await _putRequest( + endpoint, + payload, + customTimeout: extendedTimeout, + ); + + if (response == null) { + logSafe("Edit expense failed: null response", level: LogLevel.error); + return false; + } + + logSafe("Edit expense response status: ${response.statusCode}"); + logSafe("Edit expense response body: ${response.body}"); + + final json = jsonDecode(response.body); + if (json['success'] == true) { + logSafe("Expense updated successfully: ${json['data']}"); + return true; + } else { + logSafe( + "Failed to update expense: ${json['message'] ?? 'Unknown error'}", + level: LogLevel.warning, + ); + } + } catch (e, stack) { + logSafe("Exception during editExpenseApi: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return false; + } + static Future deleteExpense(String expenseId) async { final endpoint = "${ApiEndpoints.deleteExpense}/$expenseId"; diff --git a/lib/model/expense/add_expense_bottom_sheet.dart b/lib/model/expense/add_expense_bottom_sheet.dart index 2d71f5b..b5b4cec 100644 --- a/lib/model/expense/add_expense_bottom_sheet.dart +++ b/lib/model/expense/add_expense_bottom_sheet.dart @@ -6,9 +6,15 @@ import 'package:marco/model/expense/expense_type_model.dart'; import 'package:marco/model/expense/payment_types_model.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/utils/base_bottom_sheet.dart'; +import 'package:url_launcher/url_launcher_string.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:marco/helpers/widgets/image_viewer_dialog.dart'; -void showAddExpenseBottomSheet() { - Get.bottomSheet(const _AddExpenseBottomSheet(), isScrollControlled: true); +Future showAddExpenseBottomSheet() { + return Get.bottomSheet( + const _AddExpenseBottomSheet(), + isScrollControlled: true, + ); } class _AddExpenseBottomSheet extends StatefulWidget { @@ -90,7 +96,7 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { onCancel: Get.back, onSubmit: () { if (!controller.isSubmitting.value) { - controller.submitExpense(); + controller.submitOrUpdateExpense(); } }, child: Column( @@ -267,7 +273,10 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { MySpacing.height(6), _AttachmentsSection( attachments: controller.attachments, - onRemove: controller.removeAttachment, + existingAttachments: controller.existingAttachments, + onRemoveNew: controller.removeAttachment, + onRemoveExisting: (item) => + controller.existingAttachments.remove(item), onAdd: controller.pickAttachments, ), MySpacing.height(16), @@ -447,37 +456,140 @@ class _TileContainer extends StatelessWidget { class _AttachmentsSection extends StatelessWidget { final RxList attachments; - final ValueChanged onRemove; + final List> existingAttachments; + final ValueChanged onRemoveNew; + final ValueChanged>? onRemoveExisting; final VoidCallback onAdd; const _AttachmentsSection({ required this.attachments, - required this.onRemove, + required this.existingAttachments, + required this.onRemoveNew, + this.onRemoveExisting, required this.onAdd, }); @override Widget build(BuildContext context) { - return Obx(() => Wrap( - spacing: 8, - runSpacing: 8, + return Obx(() => Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - ...attachments.map((file) => _AttachmentTile( - file: file, - onRemove: () => onRemove(file), - )), - GestureDetector( - onTap: onAdd, - child: Container( - width: 80, - height: 80, - decoration: BoxDecoration( - border: Border.all(color: Colors.grey.shade400), - borderRadius: BorderRadius.circular(8), - color: Colors.grey.shade100, - ), - child: const Icon(Icons.add, size: 30, color: Colors.grey), + if (existingAttachments.isNotEmpty) ...[ + Text( + "Existing Attachments", + style: const TextStyle(fontWeight: FontWeight.w600), ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: existingAttachments.map((doc) { + final isImage = + doc['contentType']?.toString().startsWith('image/') ?? false; + final url = doc['url']; + final fileName = doc['fileName'] ?? 'Unnamed'; + + return Stack( + clipBehavior: Clip.none, + children: [ + GestureDetector( + onTap: () async { + if (isImage) { + final imageDocs = existingAttachments + .where((d) => (d['contentType'] + ?.toString() + .startsWith('image/') ?? + false)) + .toList(); + final initialIndex = imageDocs.indexWhere((d) => d == doc); + showDialog( + context: context, + builder: (_) => ImageViewerDialog( + imageSources: imageDocs.map((e) => e['url']).toList(), + initialIndex: initialIndex, + ), + ); + } else { + if (url != null && await canLaunchUrlString(url)) { + await launchUrlString(url, + mode: LaunchMode.externalApplication); + } else { + showAppSnackbar( + title: 'Error', + message: 'Could not open the document.', + type: SnackbarType.error, + ); + } + } + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(6), + color: Colors.grey.shade100, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isImage ? Icons.image : Icons.insert_drive_file, + size: 20, + color: Colors.grey[600], + ), + const SizedBox(width: 7), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 120), + child: Text( + fileName, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12), + ), + ), + ], + ), + ), + ), + if (onRemoveExisting != null) + Positioned( + top: -6, + right: -6, + child: IconButton( + icon: const Icon(Icons.close, color: Colors.red, size: 18), + onPressed: () => onRemoveExisting!(doc), + ), + ), + ], + ); + }).toList(), + ), + const SizedBox(height: 16), + ], + + // New attachments section - shows preview tiles + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + ...attachments.map((file) => _AttachmentTile( + file: file, + onRemove: () => onRemoveNew(file), + )), + GestureDetector( + onTap: onAdd, + child: Container( + width: 80, + height: 80, + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade400), + borderRadius: BorderRadius.circular(8), + color: Colors.grey.shade100, + ), + child: const Icon(Icons.add, size: 30, color: Colors.grey), + ), + ), + ], ), ], )); diff --git a/lib/view/expense/expense_detail_screen.dart b/lib/view/expense/expense_detail_screen.dart index 102bb97..99a9c96 100644 --- a/lib/view/expense/expense_detail_screen.dart +++ b/lib/view/expense/expense_detail_screen.dart @@ -1,10 +1,11 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:intl/intl.dart'; +import 'package:marco/model/expense/add_expense_bottom_sheet.dart'; + import 'package:marco/controller/expense/expense_detail_controller.dart'; import 'package:marco/controller/project_controller.dart'; import 'package:marco/controller/permission_controller.dart'; - import 'package:marco/helpers/utils/date_time_utils.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; @@ -14,6 +15,8 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:marco/model/expense/reimbursement_bottom_sheet.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/model/expense/comment_bottom_sheet.dart'; +import 'package:marco/controller/expense/add_expense_controller.dart'; +import 'package:marco/helpers/services/app_logger.dart'; class ExpenseDetailScreen extends StatelessWidget { final String expenseId; @@ -48,26 +51,275 @@ class ExpenseDetailScreen extends StatelessWidget { return Scaffold( backgroundColor: const Color(0xFFF7F7F7), - appBar: AppBar( - automaticallyImplyLeading: false, - elevation: 1, - backgroundColor: Colors.white, - title: Row( - children: [ - IconButton( - icon: const Icon(Icons.arrow_back_ios_new, - color: Colors.black, size: 20), - onPressed: () => Get.offAllNamed('/dashboard/expense-main-page'), + appBar: _AppBar(projectController: projectController), + body: SafeArea( + child: Obx(() { + if (controller.isLoading.value) return _buildLoadingSkeleton(); + final expense = controller.expense.value; + if (controller.errorMessage.isNotEmpty || expense == null) { + return Center(child: MyText.bodyMedium("No data to display.")); + } + final statusColor = getStatusColor(expense.status.name, + colorCode: expense.status.color); + final formattedAmount = _formatAmount(expense.amount); + return SingleChildScrollView( + padding: EdgeInsets.fromLTRB( + 8, 8, 8, 30 + MediaQuery.of(context).padding.bottom), + child: Center( + child: Container( + constraints: const BoxConstraints(maxWidth: 520), + child: Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + elevation: 3, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 14, horizontal: 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _InvoiceHeader(expense: expense), + const Divider(height: 30, thickness: 1.2), + _InvoiceParties(expense: expense), + const Divider(height: 30, thickness: 1.2), + _InvoiceDetailsTable(expense: expense), + const Divider(height: 30, thickness: 1.2), + _InvoiceDocuments(documents: expense.documents), + const Divider(height: 30, thickness: 1.2), + _InvoiceTotals( + expense: expense, + formattedAmount: formattedAmount, + statusColor: statusColor), + ], + ), + ), + ), + ), ), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.titleLarge('Expense Details', - fontWeight: 700, color: Colors.black), - MySpacing.height(2), - GetBuilder(builder: (_) { + ); + }), + ), + floatingActionButton: Obx(() { + final expense = controller.expense.value; + if (expense == null) return const SizedBox.shrink(); + + // Allowed status Ids + const allowedStatusIds = [ + "d1ee5eec-24b6-4364-8673-a8f859c60729", + "965eda62-7907-4963-b4a1-657fb0b2724b", + "297e0d8f-f668-41b5-bfea-e03b354251c8" + ]; + + // Show edit button only if status id is in allowedStatusIds + if (!allowedStatusIds.contains(expense.status.id)) { + return const SizedBox.shrink(); + } + + return FloatingActionButton( + onPressed: () async { + final editData = { + 'id': expense.id, + 'projectName': expense.project.name, + 'amount': expense.amount, + 'supplerName': expense.supplerName, + 'description': expense.description, + 'transactionId': expense.transactionId, + 'location': expense.location, + 'transactionDate': expense.transactionDate, + 'noOfPersons': expense.noOfPersons, + 'expensesTypeId': expense.expensesType.id, + 'paymentModeId': expense.paymentMode.id, + 'paidById': expense.paidBy.id, + 'attachments': expense.documents + .map((doc) => { + 'url': doc.preSignedUrl, + 'fileName': doc.fileName, + 'documentId': doc.documentId, + 'contentType': doc.contentType, + }) + .toList(), + }; + logSafe('editData: $editData', level: LogLevel.info); + + final addCtrl = Get.put(AddExpenseController()); + + await addCtrl.loadMasterData(); + addCtrl.populateFieldsForEdit(editData); + + await showAddExpenseBottomSheet(); + + // Refresh expense details after editing + await controller.fetchExpenseDetails(); + }, + backgroundColor: Colors.red, + tooltip: 'Edit Expense', + child: Icon(Icons.edit), + ); + }), + bottomNavigationBar: Obx(() { + final expense = controller.expense.value; + if (expense == null || expense.nextStatus.isEmpty) { + return const SizedBox(); + } + return SafeArea( + child: Container( + decoration: const BoxDecoration( + color: Colors.white, + border: Border(top: BorderSide(color: Color(0x11000000))), + ), + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + child: Wrap( + alignment: WrapAlignment.center, + spacing: 10, + runSpacing: 10, + children: expense.nextStatus + .where((next) => permissionController.hasAnyPermission( + controller.parsePermissionIds(next.permissionIds))) + .map((next) => + _statusButton(context, controller, expense, next)) + .toList(), + ), + ), + ); + }), + ); + } + + Widget _statusButton(BuildContext context, ExpenseDetailController controller, + ExpenseDetailModel expense, dynamic next) { + Color buttonColor = Colors.red; + if (next.color.isNotEmpty) { + try { + buttonColor = Color(int.parse(next.color.replaceFirst('#', '0xff'))); + } catch (_) {} + } + return ElevatedButton( + style: ElevatedButton.styleFrom( + minimumSize: const Size(100, 40), + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), + backgroundColor: buttonColor, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), + ), + onPressed: () async { + // For brevity, couldn't refactor the logic since it's business-specific. + const reimbursementId = 'f18c5cfd-7815-4341-8da2-2c2d65778e27'; + if (expense.status.id == reimbursementId) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (context) => ReimbursementBottomSheet( + expenseId: expense.id, + statusId: next.id, + onClose: () {}, + onSubmit: ({ + required String comment, + required String reimburseTransactionId, + required String reimburseDate, + required String reimburseById, + required String statusId, + }) async { + final success = + await controller.updateExpenseStatusWithReimbursement( + comment: comment, + reimburseTransactionId: reimburseTransactionId, + reimburseDate: reimburseDate, + reimburseById: reimburseById, + statusId: statusId, + ); + if (success) { + showAppSnackbar( + title: 'Success', + message: 'Expense reimbursed successfully.', + type: SnackbarType.success); + await controller.fetchExpenseDetails(); + return true; + } else { + showAppSnackbar( + title: 'Error', + message: 'Failed to reimburse expense.', + type: SnackbarType.error); + return false; + } + }, + ), + ); + } else { + final comment = await showCommentBottomSheet(context, next.name); + if (comment == null) return; + final success = + await controller.updateExpenseStatus(next.id, comment: comment); + if (success) { + showAppSnackbar( + title: 'Success', + message: + 'Expense moved to ${next.displayName.isNotEmpty ? next.displayName : next.name}', + type: SnackbarType.success); + await controller.fetchExpenseDetails(); + } else { + showAppSnackbar( + title: 'Error', + message: 'Failed to update status.', + type: SnackbarType.error); + } + } + }, + child: MyText.labelMedium( + next.displayName.isNotEmpty ? next.displayName : next.name, + color: Colors.white, + fontWeight: 600, + overflow: TextOverflow.ellipsis, + ), + ); + } + + static String _formatAmount(double amount) { + return NumberFormat.currency( + locale: 'en_IN', symbol: '₹ ', decimalDigits: 2) + .format(amount); + } + + Widget _buildLoadingSkeleton() { + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: 5, + itemBuilder: (_, __) => Container( + margin: const EdgeInsets.only(bottom: 16), + height: 80, + decoration: BoxDecoration( + color: Colors.grey[300], borderRadius: BorderRadius.circular(10)), + ), + ); + } +} + +class _AppBar extends StatelessWidget implements PreferredSizeWidget { + final ProjectController projectController; + const _AppBar({required this.projectController}); + @override + Widget build(BuildContext context) { + return AppBar( + automaticallyImplyLeading: false, + elevation: 1, + backgroundColor: Colors.white, + title: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back_ios_new, + color: Colors.black, size: 20), + onPressed: () => Get.offAllNamed('/dashboard/expense-main-page'), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleLarge('Expense Details', + fontWeight: 700, color: Colors.black), + MySpacing.height(2), + GetBuilder( + builder: (_) { final projectName = projectController.selectedProject?.name ?? 'Select Project'; @@ -86,277 +338,58 @@ class ExpenseDetailScreen extends StatelessWidget { ), ], ); - }), - ], - ), - ), - ], - ), - ), - body: SafeArea( - child: Obx(() { - if (controller.isLoading.value) { - return _buildLoadingSkeleton(); - } - - final expense = controller.expense.value; - - if (controller.errorMessage.isNotEmpty || expense == null) { - return Center( - child: MyText.bodyMedium("No data to display."), - ); - } - - final statusColor = getStatusColor(expense.status.name, - colorCode: expense.status.color); - final formattedAmount = NumberFormat.currency( - locale: 'en_IN', - symbol: '₹ ', - decimalDigits: 2, - ).format(expense.amount); - - // === CHANGE: Add proper bottom padding to always keep content away from device nav bar === - return SingleChildScrollView( - padding: EdgeInsets.fromLTRB( - 8, - 8, - 8, - 16 + MediaQuery.of(context).padding.bottom, // KEY LINE - ), - child: Center( - child: Container( - constraints: const BoxConstraints(maxWidth: 520), - child: Card( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10)), - elevation: 3, - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 14, horizontal: 14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _InvoiceHeader(expense: expense), - Divider(height: 30, thickness: 1.2), - _InvoiceParties(expense: expense), - Divider(height: 30, thickness: 1.2), - _InvoiceDetailsTable(expense: expense), - Divider(height: 30, thickness: 1.2), - _InvoiceDocuments(documents: expense.documents), - Divider(height: 30, thickness: 1.2), - _InvoiceTotals( - expense: expense, - formattedAmount: formattedAmount, - statusColor: statusColor, - ), - ], - ), - ), - ), - ), - ), - ); - }), - ), - bottomNavigationBar: Obx(() { - final expense = controller.expense.value; - if (expense == null || expense.nextStatus.isEmpty) { - return const SizedBox(); - } - return SafeArea( - child: Container( - decoration: const BoxDecoration( - color: Colors.white, - border: Border(top: BorderSide(color: Color(0x11000000))), - ), - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), - child: Wrap( - alignment: WrapAlignment.center, - spacing: 10, - runSpacing: 10, - children: expense.nextStatus.where((next) { - return permissionController.hasAnyPermission( - controller.parsePermissionIds(next.permissionIds), - ); - }).map((next) { - Color buttonColor = Colors.red; - if (next.color.isNotEmpty) { - try { - buttonColor = - Color(int.parse(next.color.replaceFirst('#', '0xff'))); - } catch (_) {} - } - return ElevatedButton( - style: ElevatedButton.styleFrom( - minimumSize: const Size(100, 40), - padding: - const EdgeInsets.symmetric(vertical: 8, horizontal: 12), - backgroundColor: buttonColor, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(6)), - ), - onPressed: () async { - const reimbursementId = - 'f18c5cfd-7815-4341-8da2-2c2d65778e27'; - - if (expense.status.id == reimbursementId) { - // Open reimbursement flow - showModalBottomSheet( - context: context, - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: - BorderRadius.vertical(top: Radius.circular(16)), - ), - builder: (context) => ReimbursementBottomSheet( - expenseId: expense.id, - statusId: next.id, - onClose: () {}, - onSubmit: ({ - required String comment, - required String reimburseTransactionId, - required String reimburseDate, - required String reimburseById, - required String statusId, - }) async { - final success = await controller - .updateExpenseStatusWithReimbursement( - comment: comment, - reimburseTransactionId: reimburseTransactionId, - reimburseDate: reimburseDate, - reimburseById: reimburseById, - statusId: statusId, - ); - - if (success) { - showAppSnackbar( - title: 'Success', - message: 'Expense reimbursed successfully.', - type: SnackbarType.success, - ); - await controller.fetchExpenseDetails(); - return true; - } else { - showAppSnackbar( - title: 'Error', - message: 'Failed to reimburse expense.', - type: SnackbarType.error, - ); - return false; - } - }, - ), - ); - } else { - // ✨ New: Show comment sheet - final comment = - await showCommentBottomSheet(context, next.name); - if (comment == null) return; - - final success = await controller.updateExpenseStatus( - next.id, - comment: comment, - ); - - if (success) { - showAppSnackbar( - title: 'Success', - message: - 'Expense moved to ${next.displayName.isNotEmpty ? next.displayName : next.name}', - type: SnackbarType.success, - ); - await controller.fetchExpenseDetails(); - } else { - showAppSnackbar( - title: 'Error', - message: 'Failed to update status.', - type: SnackbarType.error, - ); - } - } }, - child: MyText.labelMedium( - next.displayName.isNotEmpty ? next.displayName : next.name, - color: Colors.white, - fontWeight: 600, - overflow: TextOverflow.ellipsis, - ), - ); - }).toList(), + ), + ], ), ), - ); - }), + ], + ), ); } - Widget _buildLoadingSkeleton() { - return ListView( - padding: const EdgeInsets.all(16), - children: List.generate(5, (index) { - return Container( - margin: const EdgeInsets.only(bottom: 16), - height: 80, - decoration: BoxDecoration( - color: Colors.grey[300], - borderRadius: BorderRadius.circular(10), - ), - ); - }), - ); - } + @override + Size get preferredSize => const Size.fromHeight(kToolbarHeight); } -// ---------------- INVOICE SUB-COMPONENTS ---------------- +// -------- Invoice Sub-Components, unchanged except formatting/const ---------------- class _InvoiceHeader extends StatelessWidget { final ExpenseDetailModel expense; const _InvoiceHeader({required this.expense}); - @override Widget build(BuildContext context) { final dateString = DateTimeUtils.convertUtcToLocal( expense.transactionDate.toString(), format: 'dd-MM-yyyy'); - final statusColor = ExpenseDetailScreen.getStatusColor(expense.status.name, colorCode: expense.status.color); - return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( + Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + Row(children: [ + const Icon(Icons.calendar_month, size: 18, color: Colors.grey), + MySpacing.width(6), + MyText.bodySmall('Date:', fontWeight: 600), + MySpacing.width(6), + MyText.bodySmall(dateString, fontWeight: 600), + ]), + Container( + decoration: BoxDecoration( + color: statusColor.withOpacity(0.15), + borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + child: Row( children: [ - const Icon(Icons.calendar_month, size: 18, color: Colors.grey), - MySpacing.width(6), - MyText.bodySmall('Date:', fontWeight: 600), - MySpacing.width(6), - MyText.bodySmall(dateString, fontWeight: 600), + Icon(Icons.flag, size: 16, color: statusColor), + MySpacing.width(4), + MyText.labelSmall(expense.status.name, + color: statusColor, fontWeight: 600), ], ), - Container( - decoration: BoxDecoration( - color: statusColor.withOpacity(0.15), - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - child: Row( - children: [ - Icon(Icons.flag, size: 16, color: statusColor), - MySpacing.width(4), - MyText.labelSmall( - expense.status.name, - color: statusColor, - fontWeight: 600, - ), - ], - ), - ), - ], - ) + ), + ]) ], ); } @@ -365,7 +398,6 @@ class _InvoiceHeader extends StatelessWidget { class _InvoiceParties extends StatelessWidget { final ExpenseDetailModel expense; const _InvoiceParties({required this.expense}); - @override Widget build(BuildContext context) { return Column( @@ -373,45 +405,31 @@ class _InvoiceParties extends StatelessWidget { children: [ _labelValueBlock('Project', expense.project.name), MySpacing.height(16), - _labelValueBlock( - 'Paid By:', - '${expense.paidBy.firstName} ${expense.paidBy.lastName}', - ), + _labelValueBlock('Paid By:', + '${expense.paidBy.firstName} ${expense.paidBy.lastName}'), MySpacing.height(16), _labelValueBlock('Supplier', expense.supplerName), MySpacing.height(16), - _labelValueBlock( - 'Created By:', - '${expense.createdBy.firstName} ${expense.createdBy.lastName}', - ), + _labelValueBlock('Created By:', + '${expense.createdBy.firstName} ${expense.createdBy.lastName}'), ], ); } - Widget _labelValueBlock(String label, String value) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.bodySmall( - label, - fontWeight: 600, - ), - MySpacing.height(4), - MyText.bodySmall( - value, - fontWeight: 500, - softWrap: true, - maxLines: null, // Allow full wrapping - ), - ], - ); - } + Widget _labelValueBlock(String label, String value) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodySmall(label, fontWeight: 600), + MySpacing.height(4), + MyText.bodySmall(value, + fontWeight: 500, softWrap: true, maxLines: null), + ], + ); } class _InvoiceDetailsTable extends StatelessWidget { final ExpenseDetailModel expense; const _InvoiceDetailsTable({required this.expense}); - @override Widget build(BuildContext context) { final transactionDate = DateTimeUtils.convertUtcToLocal( @@ -420,7 +438,6 @@ class _InvoiceDetailsTable extends StatelessWidget { final createdAt = DateTimeUtils.convertUtcToLocal( expense.createdAt.toString(), format: 'dd-MM-yyyy hh:mm a'); - return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -436,39 +453,30 @@ class _InvoiceDetailsTable extends StatelessWidget { ); } - Widget _detailItem(String title, String value, {bool isDescription = false}) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.bodySmall( - title, - fontWeight: 600, - ), - MySpacing.height(3), - isDescription - ? ExpandableDescription(description: value) - : MyText.bodySmall( - value, - fontWeight: 500, - ), - ], - ), - ); - } + Widget _detailItem(String title, String value, + {bool isDescription = false}) => + Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodySmall(title, fontWeight: 600), + MySpacing.height(3), + isDescription + ? ExpandableDescription(description: value) + : MyText.bodySmall(value, fontWeight: 500), + ], + ), + ); } class _InvoiceDocuments extends StatelessWidget { final List documents; const _InvoiceDocuments({required this.documents}); - @override Widget build(BuildContext context) { - if (documents.isEmpty) { + if (documents.isEmpty) return MyText.bodyMedium('No Supporting Documents', color: Colors.grey); - } - return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -481,16 +489,13 @@ class _InvoiceDocuments extends StatelessWidget { separatorBuilder: (_, __) => const SizedBox(height: 8), itemBuilder: (context, index) { final doc = documents[index]; - return GestureDetector( onTap: () async { final imageDocs = documents .where((d) => d.contentType.startsWith('image/')) .toList(); - final initialIndex = imageDocs.indexWhere((d) => d.documentId == doc.documentId); - if (imageDocs.isNotEmpty && initialIndex != -1) { showDialog( context: context, @@ -506,10 +511,9 @@ class _InvoiceDocuments extends StatelessWidget { await launchUrl(url, mode: LaunchMode.externalApplication); } else { showAppSnackbar( - title: 'Error', - message: 'Could not open the document.', - type: SnackbarType.error, - ); + title: 'Error', + message: 'Could not open the document.', + type: SnackbarType.error); } } }, @@ -557,7 +561,6 @@ class _InvoiceTotals extends StatelessWidget { required this.formattedAmount, required this.statusColor, }); - @override Widget build(BuildContext context) { return Row( @@ -573,18 +576,15 @@ class _InvoiceTotals extends StatelessWidget { class ExpandableDescription extends StatefulWidget { final String description; const ExpandableDescription({super.key, required this.description}); - @override State createState() => _ExpandableDescriptionState(); } class _ExpandableDescriptionState extends State { bool isExpanded = false; - @override Widget build(BuildContext context) { final isLong = widget.description.length > 100; - return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ From 0401b41b3c86132bff37f30ad735e3df6ec958d4 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Tue, 5 Aug 2025 17:47:12 +0530 Subject: [PATCH 55/65] Dynamic edit and Add expense title --- .../expense/add_expense_bottom_sheet.dart | 35 ++++++++++++++----- lib/view/expense/expense_detail_screen.dart | 2 +- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/lib/model/expense/add_expense_bottom_sheet.dart b/lib/model/expense/add_expense_bottom_sheet.dart index b5b4cec..35598f3 100644 --- a/lib/model/expense/add_expense_bottom_sheet.dart +++ b/lib/model/expense/add_expense_bottom_sheet.dart @@ -10,15 +10,27 @@ import 'package:url_launcher/url_launcher_string.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/helpers/widgets/image_viewer_dialog.dart'; -Future showAddExpenseBottomSheet() { +Future showAddExpenseBottomSheet({ + bool isEdit = false, + Map? existingExpense, +}) { return Get.bottomSheet( - const _AddExpenseBottomSheet(), + _AddExpenseBottomSheet( + isEdit: isEdit, + existingExpense: existingExpense, + ), isScrollControlled: true, ); } class _AddExpenseBottomSheet extends StatefulWidget { - const _AddExpenseBottomSheet(); + final bool isEdit; + final Map? existingExpense; + + const _AddExpenseBottomSheet({ + this.isEdit = false, + this.existingExpense, + }); @override State<_AddExpenseBottomSheet> createState() => _AddExpenseBottomSheetState(); @@ -91,7 +103,7 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { Widget build(BuildContext context) { return Obx(() { return BaseBottomSheet( - title: "Add Expense", + title: widget.isEdit ? "Edit Expense" : "Add Expense", isSubmitting: controller.isSubmitting.value, onCancel: Get.back, onSubmit: () { @@ -485,7 +497,8 @@ class _AttachmentsSection extends StatelessWidget { runSpacing: 8, children: existingAttachments.map((doc) { final isImage = - doc['contentType']?.toString().startsWith('image/') ?? false; + doc['contentType']?.toString().startsWith('image/') ?? + false; final url = doc['url']; final fileName = doc['fileName'] ?? 'Unnamed'; @@ -501,11 +514,13 @@ class _AttachmentsSection extends StatelessWidget { .startsWith('image/') ?? false)) .toList(); - final initialIndex = imageDocs.indexWhere((d) => d == doc); + final initialIndex = + imageDocs.indexWhere((d) => d == doc); showDialog( context: context, builder: (_) => ImageViewerDialog( - imageSources: imageDocs.map((e) => e['url']).toList(), + imageSources: + imageDocs.map((e) => e['url']).toList(), initialIndex: initialIndex, ), ); @@ -540,7 +555,8 @@ class _AttachmentsSection extends StatelessWidget { ), const SizedBox(width: 7), ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 120), + constraints: + const BoxConstraints(maxWidth: 120), child: Text( fileName, overflow: TextOverflow.ellipsis, @@ -556,7 +572,8 @@ class _AttachmentsSection extends StatelessWidget { top: -6, right: -6, child: IconButton( - icon: const Icon(Icons.close, color: Colors.red, size: 18), + icon: const Icon(Icons.close, + color: Colors.red, size: 18), onPressed: () => onRemoveExisting!(doc), ), ), diff --git a/lib/view/expense/expense_detail_screen.dart b/lib/view/expense/expense_detail_screen.dart index 99a9c96..77d3875 100644 --- a/lib/view/expense/expense_detail_screen.dart +++ b/lib/view/expense/expense_detail_screen.dart @@ -146,7 +146,7 @@ class ExpenseDetailScreen extends StatelessWidget { await addCtrl.loadMasterData(); addCtrl.populateFieldsForEdit(editData); - await showAddExpenseBottomSheet(); + await showAddExpenseBottomSheet( isEdit: true,); // Refresh expense details after editing await controller.fetchExpenseDetails(); From aa76ec60cbad961c342e453f86991260e96a9dac Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Tue, 5 Aug 2025 20:35:22 +0530 Subject: [PATCH 56/65] made changes for employee get method --- .../expense/add_expense_controller.dart | 91 +++-- .../expense/expense_screen_controller.dart | 81 ++-- lib/helpers/services/api_endpoints.dart | 1 + lib/helpers/services/api_service.dart | 25 ++ .../employee_with_id_name_model.dart | 30 ++ .../expense/add_expense_bottom_sheet.dart | 46 +-- .../employee_selector_bottom_sheet.dart | 73 ++++ ...oyee_selector_for_filter_bottom_sheet.dart | 128 ++++++ lib/view/expense/expense_detail_screen.dart | 6 +- .../expense/expense_filter_bottom_sheet.dart | 364 ++++++++---------- 10 files changed, 562 insertions(+), 283 deletions(-) create mode 100644 lib/model/employees/employee_with_id_name_model.dart create mode 100644 lib/model/expense/employee_selector_bottom_sheet.dart create mode 100644 lib/model/expense/employee_selector_for_filter_bottom_sheet.dart diff --git a/lib/controller/expense/add_expense_controller.dart b/lib/controller/expense/add_expense_controller.dart index adaa258..c715dcf 100644 --- a/lib/controller/expense/add_expense_controller.dart +++ b/lib/controller/expense/add_expense_controller.dart @@ -8,13 +8,13 @@ import 'package:geocoding/geocoding.dart'; import 'package:geolocator/geolocator.dart'; import 'package:intl/intl.dart'; import 'package:mime/mime.dart'; +import 'package:marco/model/employee_model.dart'; import 'package:marco/controller/expense/expense_screen_controller.dart'; import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; -import 'package:marco/model/employee_model.dart'; import 'package:marco/model/expense/expense_type_model.dart'; import 'package:marco/model/expense/payment_types_model.dart'; @@ -50,6 +50,9 @@ class AddExpenseController extends GetxController { final paymentModes = [].obs; final allEmployees = [].obs; final existingAttachments = >[].obs; + final employeeSearchController = TextEditingController(); + final isSearchingEmployees = false.obs; + final employeeSearchResults = [].obs; // Editing String? editingExpenseId; @@ -61,7 +64,10 @@ class AddExpenseController extends GetxController { super.onInit(); fetchMasterData(); fetchGlobalProjects(); - fetchAllEmployees(); + + employeeSearchController.addListener(() { + searchEmployees(employeeSearchController.text); + }); } @override @@ -77,12 +83,44 @@ class AddExpenseController extends GetxController { super.onClose(); } + Future searchEmployees(String searchQuery) async { + if (searchQuery.trim().isEmpty) { + employeeSearchResults.clear(); + return; + } + + isSearchingEmployees.value = true; + try { + final results = await ApiService.searchEmployeesBasic( + searchString: searchQuery.trim(), + ); + + if (results != null) { + employeeSearchResults.assignAll( + results.map((e) => EmployeeModel.fromJson(e)), + ); + } else { + employeeSearchResults.clear(); + } + } catch (e) { + logSafe("Error searching employees: $e", level: LogLevel.error); + employeeSearchResults.clear(); + } finally { + isSearchingEmployees.value = false; + } + } + // ---------- Form Population for Edit ---------- - void populateFieldsForEdit(Map data) { + Future populateFieldsForEdit(Map data) async { isEditMode.value = true; editingExpenseId = data['id']; - // Basic fields + // --- Fetch all Paid By variables up front --- + final paidById = (data['paidById'] ?? '').toString(); + final paidByFirstName = (data['paidByFirstName'] ?? '').toString().trim(); + final paidByLastName = (data['paidByLastName'] ?? '').toString().trim(); + + // --- Standard Fields --- selectedProject.value = data['projectName'] ?? ''; amountController.text = data['amount']?.toString() ?? ''; supplierController.text = data['supplerName'] ?? ''; @@ -90,7 +128,7 @@ class AddExpenseController extends GetxController { transactionIdController.text = data['transactionId'] ?? ''; locationController.text = data['location'] ?? ''; - // Transaction Date + // --- Transaction Date --- if (data['transactionDate'] != null) { try { final parsedDate = DateTime.parse(data['transactionDate']); @@ -107,31 +145,29 @@ class AddExpenseController extends GetxController { transactionDateController.clear(); } - // No of Persons + // --- No of Persons --- noOfPersonsController.text = (data['noOfPersons'] ?? 0).toString(); - // Select Expense Type and Payment Mode by matching IDs + // --- Dropdown selections --- selectedExpenseType.value = expenseTypes.firstWhereOrNull((e) => e.id == data['expensesTypeId']); selectedPaymentMode.value = paymentModes.firstWhereOrNull((e) => e.id == data['paymentModeId']); - // Select Paid By employee matching id (case insensitive, trimmed) - final paidById = data['paidById']?.toString().trim().toLowerCase() ?? ''; - selectedPaidBy.value = allEmployees - .firstWhereOrNull((e) => e.id.trim().toLowerCase() == paidById); + // --- Paid By select --- + // 1. By ID +// --- Paid By select --- + selectedPaidBy.value = + allEmployees.firstWhereOrNull((e) => e.id.trim() == paidById.trim()); - if (selectedPaidBy.value == null && paidById.isNotEmpty) { - logSafe('⚠️ Could not match paidById: "$paidById"', - level: LogLevel.warning); - for (var emp in allEmployees) { - logSafe( - 'Employee ID: "${emp.id.trim().toLowerCase()}", Name: "${emp.name}"', - level: LogLevel.warning); - } + if (selectedPaidBy.value == null) { + final fullName = '$paidByFirstName $paidByLastName'; + await searchEmployees(fullName); + selectedPaidBy.value = employeeSearchResults + .firstWhereOrNull((e) => e.id.trim() == paidById.trim()); } - // Populate existing attachments if present + // --- Existing Attachments --- existingAttachments.clear(); if (data['attachments'] != null && data['attachments'] is List) { existingAttachments @@ -184,7 +220,6 @@ class AddExpenseController extends GetxController { await Future.wait([ fetchMasterData(), fetchGlobalProjects(), - fetchAllEmployees(), ]); } @@ -451,18 +486,4 @@ class AddExpenseController extends GetxController { logSafe("Error fetching projects: $e", level: LogLevel.error); } } - - Future fetchAllEmployees() async { - isLoading.value = true; - try { - final response = await ApiService.getAllEmployees(); - if (response != null) { - allEmployees.assignAll(response.map((e) => EmployeeModel.fromJson(e))); - } - } catch (e) { - logSafe("Error fetching employees: $e", level: LogLevel.error); - } finally { - isLoading.value = false; - } - } } diff --git a/lib/controller/expense/expense_screen_controller.dart b/lib/controller/expense/expense_screen_controller.dart index 51a475c..a0a012a 100644 --- a/lib/controller/expense/expense_screen_controller.dart +++ b/lib/controller/expense/expense_screen_controller.dart @@ -8,7 +8,7 @@ import 'package:marco/model/expense/expense_type_model.dart'; import 'package:marco/model/expense/expense_status_model.dart'; import 'package:marco/model/employee_model.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; - +import 'package:flutter/material.dart'; class ExpenseController extends GetxController { final RxList expenses = [].obs; @@ -32,6 +32,10 @@ class ExpenseController extends GetxController { final RxList selectedCreatedByEmployees = [].obs; final RxString selectedDateType = 'Transaction Date'.obs; + + final employeeSearchController = TextEditingController(); + final isSearchingEmployees = false.obs; + final employeeSearchResults = [].obs; final List dateTypes = [ 'Transaction Date', @@ -46,6 +50,9 @@ class ExpenseController extends GetxController { super.onInit(); loadInitialMasterData(); fetchAllEmployees(); + employeeSearchController.addListener(() { + searchEmployees(employeeSearchController.text); + }); } bool get isFilterApplied { @@ -94,6 +101,33 @@ class ExpenseController extends GetxController { } } + Future searchEmployees(String searchQuery) async { + if (searchQuery.trim().isEmpty) { + employeeSearchResults.clear(); + return; + } + + isSearchingEmployees.value = true; + try { + final results = await ApiService.searchEmployeesBasic( + searchString: searchQuery.trim(), + ); + + if (results != null) { + employeeSearchResults.assignAll( + results.map((e) => EmployeeModel.fromJson(e)), + ); + } else { + employeeSearchResults.clear(); + } + } catch (e) { + logSafe("Error searching employees: $e", level: LogLevel.error); + employeeSearchResults.clear(); + } finally { + isSearchingEmployees.value = false; + } + } + /// Fetch expenses using filters Future fetchExpenses({ List? projectIds, @@ -173,32 +207,33 @@ class ExpenseController extends GetxController { /// Fetch master data: expense types, payment modes, and expense status Future fetchMasterData() async { - try { - final expenseTypesData = await ApiService.getMasterExpenseTypes(); - if (expenseTypesData is List) { - expenseTypes.value = - expenseTypesData.map((e) => ExpenseTypeModel.fromJson(e)).toList(); - } + try { + final expenseTypesData = await ApiService.getMasterExpenseTypes(); + if (expenseTypesData is List) { + expenseTypes.value = + expenseTypesData.map((e) => ExpenseTypeModel.fromJson(e)).toList(); + } - final paymentModesData = await ApiService.getMasterPaymentModes(); - if (paymentModesData is List) { - paymentModes.value = - paymentModesData.map((e) => PaymentModeModel.fromJson(e)).toList(); - } + final paymentModesData = await ApiService.getMasterPaymentModes(); + if (paymentModesData is List) { + paymentModes.value = + paymentModesData.map((e) => PaymentModeModel.fromJson(e)).toList(); + } - final expenseStatusData = await ApiService.getMasterExpenseStatus(); - if (expenseStatusData is List) { - expenseStatuses.value = - expenseStatusData.map((e) => ExpenseStatusModel.fromJson(e)).toList(); + final expenseStatusData = await ApiService.getMasterExpenseStatus(); + if (expenseStatusData is List) { + expenseStatuses.value = expenseStatusData + .map((e) => ExpenseStatusModel.fromJson(e)) + .toList(); + } + } catch (e) { + showAppSnackbar( + title: "Error", + message: "Failed to fetch master data: $e", + type: SnackbarType.error, + ); } - } catch (e) { - showAppSnackbar( - title: "Error", - message: "Failed to fetch master data: $e", - type: SnackbarType.error, - ); } -} /// Fetch global projects Future fetchGlobalProjects() async { diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index 1a98492..ac47286 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -17,6 +17,7 @@ class ApiEndpoints { // Employee Screen API Endpoints static const String getAllEmployeesByProject = "/employee/list"; static const String getAllEmployees = "/employee/list"; + static const String getEmployeesWithoutPermission = "/employee/basic"; static const String getRoles = "/roles/jobrole"; static const String createEmployee = "/employee/manage-mobile"; static const String getEmployeeInfo = "/employee/profile/get"; diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 350eb8b..0062b1f 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -1151,6 +1151,31 @@ class ApiService { } // === Employee APIs === + /// Search employees by first name and last name only (not middle name) + /// Returns a list of up to 10 employee records matching the search string. + static Future?> searchEmployeesBasic({ + String? searchString, + }) async { + // Remove ArgumentError check because searchString is optional now + + final queryParams = {}; + + // Add searchString to query parameters only if it's not null or empty + if (searchString != null && searchString.isNotEmpty) { + queryParams['searchString'] = searchString; + } + + final response = await _getRequest( + ApiEndpoints.getEmployeesWithoutPermission, + queryParams: queryParams, + ); + + if (response != null) { + return _parseResponse(response, label: 'Search Employees Basic'); + } + + return null; + } static Future?> getAllEmployeesByProject( String projectId) async { diff --git a/lib/model/employees/employee_with_id_name_model.dart b/lib/model/employees/employee_with_id_name_model.dart new file mode 100644 index 0000000..bb9a685 --- /dev/null +++ b/lib/model/employees/employee_with_id_name_model.dart @@ -0,0 +1,30 @@ +class EmployeeModelWithIdName { + final String id; + final String firstName; + final String lastName; + final String name; + + EmployeeModelWithIdName({ + required this.id, + required this.firstName, + required this.lastName, + required this.name, + }); + + factory EmployeeModelWithIdName.fromJson(Map json) { + return EmployeeModelWithIdName( + id: json['id']?.toString() ?? '', + firstName: json['firstName'] ?? '', + lastName: json['lastName'] ?? '', + name: '${json['firstName'] ?? ''} ${json['lastName'] ?? ''}'.trim(), + ); + } + + Map toJson() { + return { + 'id': id, + 'firstName': name.split(' ').first, + 'lastName': name.split(' ').length > 1 ? name.split(' ').last : '', + }; + } +} diff --git a/lib/model/expense/add_expense_bottom_sheet.dart b/lib/model/expense/add_expense_bottom_sheet.dart index 35598f3..ca93abe 100644 --- a/lib/model/expense/add_expense_bottom_sheet.dart +++ b/lib/model/expense/add_expense_bottom_sheet.dart @@ -4,6 +4,7 @@ import 'package:get/get.dart'; import 'package:marco/controller/expense/add_expense_controller.dart'; import 'package:marco/model/expense/expense_type_model.dart'; import 'package:marco/model/expense/payment_types_model.dart'; +import 'package:marco/model/expense/employee_selector_bottom_sheet.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/utils/base_bottom_sheet.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -39,34 +40,23 @@ class _AddExpenseBottomSheet extends StatefulWidget { class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { final AddExpenseController controller = Get.put(AddExpenseController()); - void _showEmployeeList() { - showModalBottomSheet( - context: context, - backgroundColor: Colors.white, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16))), - builder: (_) => Obx(() { - final employees = controller.allEmployees; - return SizedBox( - height: 300, - child: ListView.builder( - itemCount: employees.length, - itemBuilder: (_, index) { - final emp = employees[index]; - final fullName = '${emp.firstName} ${emp.lastName}'.trim(); - return ListTile( - title: Text(fullName.isNotEmpty ? fullName : "Unnamed"), - onTap: () { - controller.selectedPaidBy.value = emp; - Navigator.pop(context); - }, - ); - }, - ), - ); - }), - ); - } + void _showEmployeeList() async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + backgroundColor: Colors.transparent, + builder: (_) => EmployeeSelectorBottomSheet(), + ); + + // Optional cleanup + controller.employeeSearchController.clear(); + controller.employeeSearchResults.clear(); +} + Future _showOptionList( List options, diff --git a/lib/model/expense/employee_selector_bottom_sheet.dart b/lib/model/expense/employee_selector_bottom_sheet.dart new file mode 100644 index 0000000..fb9fe27 --- /dev/null +++ b/lib/model/expense/employee_selector_bottom_sheet.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/controller/expense/add_expense_controller.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; + +class EmployeeSelectorBottomSheet extends StatelessWidget { + final AddExpenseController controller = Get.find(); + + EmployeeSelectorBottomSheet({super.key}); + + @override + Widget build(BuildContext context) { + return BaseBottomSheet( + title: "Search Employee", + onCancel: () => Get.back(), + onSubmit: () {}, + showButtons: false, + child: Obx(() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: controller.employeeSearchController, + decoration: InputDecoration( + hintText: "Search by name, email...", + prefixIcon: const Icon(Icons.search), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + ), + onChanged: (value) => controller.searchEmployees(value), + ), + MySpacing.height(12), + SizedBox( + height: 400, // Adjust this if needed + child: controller.isSearchingEmployees.value + ? const Center(child: CircularProgressIndicator()) + : controller.employeeSearchResults.isEmpty + ? Center( + child: MyText.bodyMedium( + "No employees found.", + fontWeight: 500, + ), + ) + : ListView.builder( + itemCount: controller.employeeSearchResults.length, + itemBuilder: (_, index) { + final emp = controller.employeeSearchResults[index]; + final fullName = + '${emp.firstName} ${emp.lastName}'.trim(); + return ListTile( + title: MyText.bodyLarge( + fullName.isNotEmpty ? fullName : "Unnamed", + fontWeight: 600, + ), + onTap: () { + controller.selectedPaidBy.value = emp; + Get.back(); + }, + ); + }, + ), + ), + ], + ); + }), + ); + } +} diff --git a/lib/model/expense/employee_selector_for_filter_bottom_sheet.dart b/lib/model/expense/employee_selector_for_filter_bottom_sheet.dart new file mode 100644 index 0000000..d85b403 --- /dev/null +++ b/lib/model/expense/employee_selector_for_filter_bottom_sheet.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/widgets/my_text_style.dart'; +import 'package:marco/model/employee_model.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; + +class EmployeeSelectorBottomSheet extends StatefulWidget { + final RxList selectedEmployees; + final Future> Function(String) searchEmployees; + final String title; + + const EmployeeSelectorBottomSheet({ + super.key, + required this.selectedEmployees, + required this.searchEmployees, + this.title = "Select Employees", + }); + + @override + State createState() => + _EmployeeSelectorBottomSheetState(); +} + +class _EmployeeSelectorBottomSheetState + extends State { + final TextEditingController _searchController = TextEditingController(); + final RxBool isSearching = false.obs; + final RxList searchResults = [].obs; + + @override + void initState() { + super.initState(); + // Initial fetch (empty text gets all/none as you wish) + _searchEmployees(''); + } + + void _searchEmployees(String query) async { + isSearching.value = true; + List results = await widget.searchEmployees(query); + searchResults.assignAll(results); + isSearching.value = false; + } + + void _submitSelection() => + Get.back(result: widget.selectedEmployees.toList()); + + @override + Widget build(BuildContext context) { + return BaseBottomSheet( + title: widget.title, + onCancel: () => Get.back(), + onSubmit: _submitSelection, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Chips + Obx(() => widget.selectedEmployees.isEmpty + ? const SizedBox.shrink() + : Wrap( + spacing: 8, + children: widget.selectedEmployees + .map( + (emp) => Chip( + label: MyText(emp.name), + onDeleted: () => + widget.selectedEmployees.remove(emp), + ), + ) + .toList(), + )), + MySpacing.height(8), + + // Search box + TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: "Search Employees...", + border: + OutlineInputBorder(borderRadius: BorderRadius.circular(10)), + prefixIcon: Icon(Icons.search), + ), + onChanged: _searchEmployees, + ), + MySpacing.height(12), + + SizedBox( + height: 320, // CHANGE AS PER DESIGN! + child: Obx(() { + if (isSearching.value) { + return Center(child: CircularProgressIndicator()); + } + if (searchResults.isEmpty) { + return Padding( + padding: EdgeInsets.all(20), + child: + MyText('No results', style: MyTextStyle.bodyMedium()), + ); + } + return ListView.separated( + itemCount: searchResults.length, + separatorBuilder: (_, __) => Divider(height: 1), + itemBuilder: (context, index) { + final emp = searchResults[index]; + final isSelected = widget.selectedEmployees.contains(emp); + return ListTile( + title: MyText(emp.name), + trailing: isSelected + ? Icon(Icons.check_circle, color: Colors.indigo) + : Icon(Icons.radio_button_unchecked, + color: Colors.grey), + onTap: () { + if (isSelected) { + widget.selectedEmployees.remove(emp); + } else { + widget.selectedEmployees.add(emp); + } + }); + }, + ); + }), + ), + ], + )); + } +} diff --git a/lib/view/expense/expense_detail_screen.dart b/lib/view/expense/expense_detail_screen.dart index 77d3875..9a8d7d2 100644 --- a/lib/view/expense/expense_detail_screen.dart +++ b/lib/view/expense/expense_detail_screen.dart @@ -130,6 +130,10 @@ class ExpenseDetailScreen extends StatelessWidget { 'expensesTypeId': expense.expensesType.id, 'paymentModeId': expense.paymentMode.id, 'paidById': expense.paidBy.id, + // ==== Add these lines below ==== + 'paidByFirstName': expense.paidBy.firstName, + 'paidByLastName': expense.paidBy.lastName, + // ================================= 'attachments': expense.documents .map((doc) => { 'url': doc.preSignedUrl, @@ -146,7 +150,7 @@ class ExpenseDetailScreen extends StatelessWidget { await addCtrl.loadMasterData(); addCtrl.populateFieldsForEdit(editData); - await showAddExpenseBottomSheet( isEdit: true,); + await showAddExpenseBottomSheet(isEdit: true); // Refresh expense details after editing await controller.fetchExpenseDetails(); diff --git a/lib/view/expense/expense_filter_bottom_sheet.dart b/lib/view/expense/expense_filter_bottom_sheet.dart index ea121c2..1162ba5 100644 --- a/lib/view/expense/expense_filter_bottom_sheet.dart +++ b/lib/view/expense/expense_filter_bottom_sheet.dart @@ -7,6 +7,7 @@ import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text_style.dart'; import 'package:marco/model/employee_model.dart'; +import 'package:marco/model/expense/employee_selector_for_filter_bottom_sheet.dart'; class ExpenseFilterBottomSheet extends StatelessWidget { final ExpenseController expenseController; @@ -18,9 +19,16 @@ class ExpenseFilterBottomSheet extends StatelessWidget { required this.scrollController, }); + // FIX: create search adapter + Future> searchEmployeesForBottomSheet( + String query) async { + await expenseController + .searchEmployees(query); // async method, returns void + return expenseController.employeeSearchResults.toList(); + } + @override Widget build(BuildContext context) { - // Obx rebuilds the widget when observable values from the controller change. return Obx(() { return BaseBottomSheet( title: 'Filter Expenses', @@ -41,11 +49,11 @@ class ExpenseFilterBottomSheet extends StatelessWidget { alignment: Alignment.centerRight, child: TextButton( onPressed: () => expenseController.clearFilters(), - child: const Text( + child: MyText( "Reset Filter", - style: TextStyle( + style: MyTextStyle.labelMedium( color: Colors.red, - fontWeight: FontWeight.w600, + fontWeight: 600, ), ), ), @@ -57,9 +65,9 @@ class ExpenseFilterBottomSheet extends StatelessWidget { MySpacing.height(16), _buildDateRangeFilter(context), MySpacing.height(16), - _buildPaidByFilter(), + _buildPaidByFilter(context), MySpacing.height(16), - _buildCreatedByFilter(), + _buildCreatedByFilter(context), ], ), ), @@ -67,7 +75,6 @@ class ExpenseFilterBottomSheet extends StatelessWidget { }); } - /// Builds a generic field layout with a label and a child widget. Widget _buildField(String label, Widget child) { return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -79,7 +86,6 @@ class ExpenseFilterBottomSheet extends StatelessWidget { ); } - /// Extracted widget builder for the Project filter. Widget _buildProjectFilter(BuildContext context) { return _buildField( "Project", @@ -94,7 +100,6 @@ class ExpenseFilterBottomSheet extends StatelessWidget { ); } - /// Extracted widget builder for the Expense Status filter. Widget _buildStatusFilter(BuildContext context) { return _buildField( "Expense Status", @@ -117,123 +122,128 @@ class ExpenseFilterBottomSheet extends StatelessWidget { ); } - /// Extracted widget builder for the Date Range filter. Widget _buildDateRangeFilter(BuildContext context) { - return _buildField( - "Date Filter", - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Obx(() { - return SegmentedButton( - segments: expenseController.dateTypes - .map( - (type) => ButtonSegment( - value: type, - label: Text( - type, - style: MyTextStyle.bodySmall( - fontWeight: 600, - fontSize: 13, - height: 1.2, + return _buildField( + "Date Filter", + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Obx(() { + return SegmentedButton( + segments: expenseController.dateTypes + .map( + (type) => ButtonSegment( + value: type, + label: MyText( + type, + style: MyTextStyle.bodySmall( + fontWeight: 600, + fontSize: 13, + height: 1.2, + ), ), ), - ), - ) - .toList(), - selected: {expenseController.selectedDateType.value}, - onSelectionChanged: (newSelection) { - if (newSelection.isNotEmpty) { - expenseController.selectedDateType.value = newSelection.first; - } - }, - style: ButtonStyle( - visualDensity: const VisualDensity(horizontal: -2, vertical: -2), - padding: MaterialStateProperty.all( - const EdgeInsets.symmetric(horizontal: 8, vertical: 6)), - backgroundColor: MaterialStateProperty.resolveWith( - (states) => states.contains(MaterialState.selected) - ? Colors.indigo.shade100 - : Colors.grey.shade100, - ), - foregroundColor: MaterialStateProperty.resolveWith( - (states) => states.contains(MaterialState.selected) - ? Colors.indigo - : Colors.black87, - ), - shape: MaterialStateProperty.all( - RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), + ) + .toList(), + selected: {expenseController.selectedDateType.value}, + onSelectionChanged: (newSelection) { + if (newSelection.isNotEmpty) { + expenseController.selectedDateType.value = newSelection.first; + } + }, + style: ButtonStyle( + visualDensity: + const VisualDensity(horizontal: -2, vertical: -2), + padding: MaterialStateProperty.all( + const EdgeInsets.symmetric(horizontal: 8, vertical: 6), ), - ), - side: MaterialStateProperty.resolveWith( - (states) => BorderSide( - color: states.contains(MaterialState.selected) + backgroundColor: MaterialStateProperty.resolveWith( + (states) => states.contains(MaterialState.selected) + ? Colors.indigo.shade100 + : Colors.grey.shade100, + ), + foregroundColor: MaterialStateProperty.resolveWith( + (states) => states.contains(MaterialState.selected) ? Colors.indigo - : Colors.grey.shade300, - width: 1, + : Colors.black87, + ), + shape: MaterialStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + side: MaterialStateProperty.resolveWith( + (states) => BorderSide( + color: states.contains(MaterialState.selected) + ? Colors.indigo + : Colors.grey.shade300, + width: 1, + ), ), ), - ), - ); - }), - MySpacing.height(16), - Row( - children: [ - Expanded( - child: _dateButton( - label: expenseController.startDate.value == null - ? 'Start Date' - : DateTimeUtils.formatDate( - expenseController.startDate.value!, 'dd MMM yyyy'), - onTap: () => _selectDate( - context, - expenseController.startDate, - lastDate: expenseController.endDate.value, + ); + }), + MySpacing.height(16), + Row( + children: [ + Expanded( + child: _dateButton( + label: expenseController.startDate.value == null + ? 'Start Date' + : DateTimeUtils.formatDate( + expenseController.startDate.value!, 'dd MMM yyyy'), + onTap: () => _selectDate( + context, + expenseController.startDate, + lastDate: expenseController.endDate.value, + ), ), ), - ), - MySpacing.width(12), - Expanded( - child: _dateButton( - label: expenseController.endDate.value == null - ? 'End Date' - : DateTimeUtils.formatDate( - expenseController.endDate.value!, 'dd MMM yyyy'), - onTap: () => _selectDate( - context, - expenseController.endDate, - firstDate: expenseController.startDate.value, + MySpacing.width(12), + Expanded( + child: _dateButton( + label: expenseController.endDate.value == null + ? 'End Date' + : DateTimeUtils.formatDate( + expenseController.endDate.value!, 'dd MMM yyyy'), + onTap: () => _selectDate( + context, + expenseController.endDate, + firstDate: expenseController.startDate.value, + ), ), ), - ), - ], - ), - ], - ), - ); -} + ], + ), + ], + ), + ); + } - - /// Extracted widget builder for the "Paid By" employee filter. - Widget _buildPaidByFilter() { + Widget _buildPaidByFilter(BuildContext context) { return _buildField( "Paid By", _employeeSelector( - selectedEmployees: expenseController.selectedPaidByEmployees), + context: context, + selectedEmployees: expenseController.selectedPaidByEmployees, + searchEmployees: searchEmployeesForBottomSheet, // FIXED + title: 'Search Paid By', + ), ); } - /// Extracted widget builder for the "Created By" employee filter. - Widget _buildCreatedByFilter() { + Widget _buildCreatedByFilter(BuildContext context) { return _buildField( "Created By", _employeeSelector( - selectedEmployees: expenseController.selectedCreatedByEmployees), + context: context, + selectedEmployees: expenseController.selectedCreatedByEmployees, + searchEmployees: searchEmployeesForBottomSheet, // FIXED + title: 'Search Created By', + ), ); } - /// Helper method to show a date picker and update the state. Future _selectDate( BuildContext context, Rx dateNotifier, { @@ -251,7 +261,6 @@ class ExpenseFilterBottomSheet extends StatelessWidget { } } - /// Reusable popup selector widget. Widget _popupSelector( BuildContext context, { required String currentValue, @@ -264,7 +273,7 @@ class ExpenseFilterBottomSheet extends StatelessWidget { itemBuilder: (context) => items .map((e) => PopupMenuItem( value: e, - child: Text(e), + child: MyText(e), )) .toList(), child: Container( @@ -278,7 +287,7 @@ class ExpenseFilterBottomSheet extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( - child: Text( + child: MyText( currentValue, style: const TextStyle(color: Colors.black87), overflow: TextOverflow.ellipsis, @@ -291,7 +300,6 @@ class ExpenseFilterBottomSheet extends StatelessWidget { ); } - /// Reusable date button widget. Widget _dateButton({required String label, required VoidCallback onTap}) { return GestureDetector( onTap: onTap, @@ -307,7 +315,7 @@ class ExpenseFilterBottomSheet extends StatelessWidget { const Icon(Icons.calendar_today, size: 16, color: Colors.grey), MySpacing.width(8), Expanded( - child: Text( + child: MyText( label, style: MyTextStyle.bodyMedium(), overflow: TextOverflow.ellipsis, @@ -319,9 +327,36 @@ class ExpenseFilterBottomSheet extends StatelessWidget { ); } - /// Reusable employee selector with Autocomplete. - Widget _employeeSelector({required RxList selectedEmployees}) { - final textController = TextEditingController(); + Future _showEmployeeSelectorBottomSheet({ + required BuildContext context, + required RxList selectedEmployees, + required Future> Function(String) searchEmployees, + String title = 'Select Employee', + }) async { + final List? result = + await showModalBottomSheet>( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) => EmployeeSelectorBottomSheet( + selectedEmployees: selectedEmployees, + searchEmployees: searchEmployees, + title: title, + ), + ); + if (result != null) { + selectedEmployees.assignAll(result); + } + } + + Widget _employeeSelector({ + required BuildContext context, + required RxList selectedEmployees, + required Future> Function(String) searchEmployees, + String title = 'Search Employee', + }) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -331,102 +366,39 @@ class ExpenseFilterBottomSheet extends StatelessWidget { } return Wrap( spacing: 8, - runSpacing: 0, children: selectedEmployees .map((emp) => Chip( - label: Text(emp.name), + label: MyText(emp.name), onDeleted: () => selectedEmployees.remove(emp), - deleteIcon: const Icon(Icons.close, size: 18), - backgroundColor: Colors.grey.shade200, - padding: const EdgeInsets.all(8), )) .toList(), ); }), MySpacing.height(8), - Autocomplete( - optionsBuilder: (TextEditingValue textEditingValue) { - if (textEditingValue.text.isEmpty) { - return const Iterable.empty(); - } - return expenseController.allEmployees.where((emp) { - final isNotSelected = !selectedEmployees.contains(emp); - final matchesQuery = emp.name - .toLowerCase() - .contains(textEditingValue.text.toLowerCase()); - return isNotSelected && matchesQuery; - }); - }, - displayStringForOption: (EmployeeModel emp) => emp.name, - onSelected: (EmployeeModel emp) { - if (!selectedEmployees.contains(emp)) { - selectedEmployees.add(emp); - } - textController.clear(); - }, - fieldViewBuilder: (context, controller, focusNode, onFieldSubmitted) { - // Assign the local controller to the one from the builder - // to allow clearing it on selection. - WidgetsBinding.instance.addPostFrameCallback((_) { - if (textController != controller) { - // This is a workaround to sync controllers - } - }); - return TextField( - controller: controller, - focusNode: focusNode, - decoration: _inputDecoration("Search Employee"), - onSubmitted: (_) => onFieldSubmitted(), - ); - }, - optionsViewBuilder: (context, onSelected, options) { - return Align( - alignment: Alignment.topLeft, - child: Material( - color: Colors.white, - elevation: 4.0, - child: ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 200), - child: ListView.builder( - padding: EdgeInsets.zero, - itemCount: options.length, - itemBuilder: (context, index) { - final emp = options.elementAt(index); - return ListTile( - title: Text(emp.name), - onTap: () => onSelected(emp), - ); - }, - ), - ), - ), - ); - }, + GestureDetector( + onTap: () => _showEmployeeSelectorBottomSheet( + context: context, + selectedEmployees: selectedEmployees, + searchEmployees: searchEmployees, + title: title, + ), + child: Container( + padding: MySpacing.all(12), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade300), + ), + child: Row( + children: [ + const Icon(Icons.search, color: Colors.grey), + MySpacing.width(8), + Expanded(child: MyText(title)), + ], + ), + ), ), ], ); } - - /// Centralized decoration for text fields. - InputDecoration _inputDecoration(String hint) { - return InputDecoration( - hintText: hint, - hintStyle: MyTextStyle.bodySmall(xMuted: true), - filled: true, - fillColor: Colors.grey.shade100, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: Colors.grey.shade300), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: Colors.grey.shade300), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5), - ), - contentPadding: MySpacing.all(12), - ); - } } From 63e5caae24d1e857fe0cc8cec95a47f05711e37d Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Wed, 6 Aug 2025 11:46:12 +0530 Subject: [PATCH 57/65] handelled the update expense --- .../expense/add_expense_controller.dart | 33 +- .../expense/add_expense_bottom_sheet.dart | 287 +++++++++--------- 2 files changed, 171 insertions(+), 149 deletions(-) diff --git a/lib/controller/expense/add_expense_controller.dart b/lib/controller/expense/add_expense_controller.dart index c715dcf..a127157 100644 --- a/lib/controller/expense/add_expense_controller.dart +++ b/lib/controller/expense/add_expense_controller.dart @@ -170,8 +170,14 @@ class AddExpenseController extends GetxController { // --- Existing Attachments --- existingAttachments.clear(); if (data['attachments'] != null && data['attachments'] is List) { - existingAttachments - .addAll(List>.from(data['attachments'])); + existingAttachments.addAll( + List>.from(data['attachments']).map((e) { + return { + ...e, + 'isActive': true, // default + }; + }), + ); } _logPrefilledData(); @@ -369,15 +375,20 @@ class AddExpenseController extends GetxController { final projectId = projectsMap[selectedProject.value]!; final selectedDate = selectedTransactionDate.value?.toUtc() ?? DateTime.now().toUtc(); - final existingAttachmentPayloads = existingAttachments - .map((e) => { - "fileName": e['fileName'], - "contentType": e['contentType'], - "fileSize": 0, // optional or populate if known - "description": "", - "url": e['url'], // custom field if your backend accepts - }) - .toList(); + final existingAttachmentPayloads = existingAttachments.map((e) { + final isActive = e['isActive'] ?? true; + + return { + "documentId": e['documentId'], + "fileName": e['fileName'], + "contentType": e['contentType'], + "fileSize": 0, + "description": "", + "url": e['url'], + "isActive": isActive, + "base64Data": isActive ? e['base64Data'] : null, + }; + }).toList(); final newAttachmentPayloads = await Future.wait(attachments.map((file) async { diff --git a/lib/model/expense/add_expense_bottom_sheet.dart b/lib/model/expense/add_expense_bottom_sheet.dart index ca93abe..4e6dfec 100644 --- a/lib/model/expense/add_expense_bottom_sheet.dart +++ b/lib/model/expense/add_expense_bottom_sheet.dart @@ -41,22 +41,20 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { final AddExpenseController controller = Get.put(AddExpenseController()); void _showEmployeeList() async { - await showModalBottomSheet( - context: context, - isScrollControlled: true, - - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), - backgroundColor: Colors.transparent, - builder: (_) => EmployeeSelectorBottomSheet(), - ); - - // Optional cleanup - controller.employeeSearchController.clear(); - controller.employeeSearchResults.clear(); -} + await showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + backgroundColor: Colors.transparent, + builder: (_) => EmployeeSelectorBottomSheet(), + ); + // Optional cleanup + controller.employeeSearchController.clear(); + controller.employeeSearchResults.clear(); + } Future _showOptionList( List options, @@ -277,8 +275,13 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { attachments: controller.attachments, existingAttachments: controller.existingAttachments, onRemoveNew: controller.removeAttachment, - onRemoveExisting: (item) => - controller.existingAttachments.remove(item), + onRemoveExisting: (item) { + final index = controller.existingAttachments.indexOf(item); + if (index != -1) { + controller.existingAttachments[index]['isActive'] = false; + controller.existingAttachments.refresh(); + } + }, onAdd: controller.pickAttachments, ), MySpacing.height(16), @@ -458,7 +461,7 @@ class _TileContainer extends StatelessWidget { class _AttachmentsSection extends StatelessWidget { final RxList attachments; - final List> existingAttachments; + final RxList> existingAttachments; final ValueChanged onRemoveNew; final ValueChanged>? onRemoveExisting; final VoidCallback onAdd; @@ -473,133 +476,141 @@ class _AttachmentsSection extends StatelessWidget { @override Widget build(BuildContext context) { - return Obx(() => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (existingAttachments.isNotEmpty) ...[ - Text( - "Existing Attachments", - style: const TextStyle(fontWeight: FontWeight.w600), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: existingAttachments.map((doc) { - final isImage = - doc['contentType']?.toString().startsWith('image/') ?? - false; - final url = doc['url']; - final fileName = doc['fileName'] ?? 'Unnamed'; + return Obx(() { + final activeExistingAttachments = + existingAttachments.where((doc) => doc['isActive'] != false).toList(); - return Stack( - clipBehavior: Clip.none, - children: [ - GestureDetector( - onTap: () async { - if (isImage) { - final imageDocs = existingAttachments - .where((d) => (d['contentType'] - ?.toString() - .startsWith('image/') ?? - false)) - .toList(); - final initialIndex = - imageDocs.indexWhere((d) => d == doc); - showDialog( - context: context, - builder: (_) => ImageViewerDialog( - imageSources: - imageDocs.map((e) => e['url']).toList(), - initialIndex: initialIndex, - ), - ); - } else { - if (url != null && await canLaunchUrlString(url)) { - await launchUrlString(url, - mode: LaunchMode.externalApplication); - } else { - showAppSnackbar( - title: 'Error', - message: 'Could not open the document.', - type: SnackbarType.error, - ); - } - } - }, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 8), - decoration: BoxDecoration( - border: Border.all(color: Colors.grey.shade300), - borderRadius: BorderRadius.circular(6), - color: Colors.grey.shade100, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - isImage ? Icons.image : Icons.insert_drive_file, - size: 20, - color: Colors.grey[600], - ), - const SizedBox(width: 7), - ConstrainedBox( - constraints: - const BoxConstraints(maxWidth: 120), - child: Text( - fileName, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 12), - ), - ), - ], - ), - ), - ), - if (onRemoveExisting != null) - Positioned( - top: -6, - right: -6, - child: IconButton( - icon: const Icon(Icons.close, - color: Colors.red, size: 18), - onPressed: () => onRemoveExisting!(doc), - ), - ), - ], - ); - }).toList(), - ), - const SizedBox(height: 16), - ], - - // New attachments section - shows preview tiles + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (activeExistingAttachments.isNotEmpty) ...[ + Text( + "Existing Attachments", + style: const TextStyle(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), Wrap( spacing: 8, runSpacing: 8, - children: [ - ...attachments.map((file) => _AttachmentTile( - file: file, - onRemove: () => onRemoveNew(file), - )), - GestureDetector( - onTap: onAdd, - child: Container( - width: 80, - height: 80, - decoration: BoxDecoration( - border: Border.all(color: Colors.grey.shade400), - borderRadius: BorderRadius.circular(8), - color: Colors.grey.shade100, + children: activeExistingAttachments.map((doc) { + final isImage = + doc['contentType']?.toString().startsWith('image/') ?? + false; + final url = doc['url']; + final fileName = doc['fileName'] ?? 'Unnamed'; + + return Stack( + clipBehavior: Clip.none, + children: [ + GestureDetector( + onTap: () async { + if (isImage) { + final imageDocs = activeExistingAttachments + .where((d) => (d['contentType'] + ?.toString() + .startsWith('image/') ?? + false)) + .toList(); + final initialIndex = + imageDocs.indexWhere((d) => d == doc); + showDialog( + context: context, + builder: (_) => ImageViewerDialog( + imageSources: + imageDocs.map((e) => e['url']).toList(), + initialIndex: initialIndex, + ), + ); + } else { + if (url != null && await canLaunchUrlString(url)) { + await launchUrlString( + url, + mode: LaunchMode.externalApplication, + ); + } else { + showAppSnackbar( + title: 'Error', + message: 'Could not open the document.', + type: SnackbarType.error, + ); + } + } + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(6), + color: Colors.grey.shade100, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isImage ? Icons.image : Icons.insert_drive_file, + size: 20, + color: Colors.grey[600], + ), + const SizedBox(width: 7), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 120), + child: Text( + fileName, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12), + ), + ), + ], + ), + ), ), - child: const Icon(Icons.add, size: 30, color: Colors.grey), - ), - ), - ], + if (onRemoveExisting != null) + Positioned( + top: -6, + right: -6, + child: IconButton( + icon: const Icon(Icons.close, + color: Colors.red, size: 18), + onPressed: () { + onRemoveExisting?.call(doc); + }, + ), + ), + ], + ); + }).toList(), ), + const SizedBox(height: 16), ], - )); + + // New attachments section + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + ...attachments.map((file) => _AttachmentTile( + file: file, + onRemove: () => onRemoveNew(file), + )), + GestureDetector( + onTap: onAdd, + child: Container( + width: 80, + height: 80, + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade400), + borderRadius: BorderRadius.circular(8), + color: Colors.grey.shade100, + ), + child: const Icon(Icons.add, size: 30, color: Colors.grey), + ), + ), + ], + ), + ], + ); + }); } } From 06fc8a4c61d8223f3a0955db1ea4683821096703 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Wed, 6 Aug 2025 11:53:03 +0530 Subject: [PATCH 58/65] added validation --- .../expense/add_expense_controller.dart | 446 ++++++++---------- 1 file changed, 190 insertions(+), 256 deletions(-) diff --git a/lib/controller/expense/add_expense_controller.dart b/lib/controller/expense/add_expense_controller.dart index a127157..f4b1d35 100644 --- a/lib/controller/expense/add_expense_controller.dart +++ b/lib/controller/expense/add_expense_controller.dart @@ -7,19 +7,18 @@ import 'package:get/get.dart'; import 'package:geocoding/geocoding.dart'; import 'package:geolocator/geolocator.dart'; import 'package:intl/intl.dart'; -import 'package:mime/mime.dart'; -import 'package:marco/model/employee_model.dart'; import 'package:marco/controller/expense/expense_screen_controller.dart'; import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; - +import 'package:marco/model/employee_model.dart'; import 'package:marco/model/expense/expense_type_model.dart'; import 'package:marco/model/expense/payment_types_model.dart'; +import 'package:mime/mime.dart'; class AddExpenseController extends GetxController { - // Text Controllers + // --- Text Controllers --- final amountController = TextEditingController(); final descriptionController = TextEditingController(); final supplierController = TextEditingController(); @@ -29,32 +28,32 @@ class AddExpenseController extends GetxController { final transactionDateController = TextEditingController(); final noOfPersonsController = TextEditingController(); - // State + final employeeSearchController = TextEditingController(); + + // --- Reactive State --- final isLoading = false.obs; final isSubmitting = false.obs; final isFetchingLocation = false.obs; final isEditMode = false.obs; + final isSearchingEmployees = false.obs; - // Dropdown Selections - final selectedPaymentMode = Rx(null); - final selectedExpenseType = Rx(null); - final selectedPaidBy = Rx(null); + // --- Dropdown Selections & Data --- + final selectedPaymentMode = Rxn(); + final selectedExpenseType = Rxn(); + final selectedPaidBy = Rxn(); final selectedProject = ''.obs; - final selectedTransactionDate = Rx(null); + final selectedTransactionDate = Rxn(); - // Data Lists final attachments = [].obs; + final existingAttachments = >[].obs; final globalProjects = [].obs; final projectsMap = {}.obs; + final expenseTypes = [].obs; final paymentModes = [].obs; final allEmployees = [].obs; - final existingAttachments = >[].obs; - final employeeSearchController = TextEditingController(); - final isSearchingEmployees = false.obs; final employeeSearchResults = [].obs; - // Editing String? editingExpenseId; final expenseController = Get.find(); @@ -64,7 +63,6 @@ class AddExpenseController extends GetxController { super.onInit(); fetchMasterData(); fetchGlobalProjects(); - employeeSearchController.addListener(() { searchEmployees(employeeSearchController.text); }); @@ -72,36 +70,32 @@ class AddExpenseController extends GetxController { @override void onClose() { - amountController.dispose(); - descriptionController.dispose(); - supplierController.dispose(); - transactionIdController.dispose(); - gstController.dispose(); - locationController.dispose(); - transactionDateController.dispose(); - noOfPersonsController.dispose(); + for (var c in [ + amountController, + descriptionController, + supplierController, + transactionIdController, + gstController, + locationController, + transactionDateController, + noOfPersonsController, + employeeSearchController, + ]) { + c.dispose(); + } super.onClose(); } - Future searchEmployees(String searchQuery) async { - if (searchQuery.trim().isEmpty) { - employeeSearchResults.clear(); - return; - } - + // --- Employee Search --- + Future searchEmployees(String query) async { + if (query.trim().isEmpty) return employeeSearchResults.clear(); isSearchingEmployees.value = true; try { - final results = await ApiService.searchEmployeesBasic( - searchString: searchQuery.trim(), + final data = + await ApiService.searchEmployeesBasic(searchString: query.trim()); + employeeSearchResults.assignAll( + (data ?? []).map((e) => EmployeeModel.fromJson(e)), ); - - if (results != null) { - employeeSearchResults.assignAll( - results.map((e) => EmployeeModel.fromJson(e)), - ); - } else { - employeeSearchResults.clear(); - } } catch (e) { logSafe("Error searching employees: $e", level: LogLevel.error); employeeSearchResults.clear(); @@ -110,73 +104,55 @@ class AddExpenseController extends GetxController { } } - // ---------- Form Population for Edit ---------- + // --- Form Population: Edit Mode --- Future populateFieldsForEdit(Map data) async { isEditMode.value = true; - editingExpenseId = data['id']; + editingExpenseId = '${data['id']}'; - // --- Fetch all Paid By variables up front --- - final paidById = (data['paidById'] ?? '').toString(); - final paidByFirstName = (data['paidByFirstName'] ?? '').toString().trim(); - final paidByLastName = (data['paidByLastName'] ?? '').toString().trim(); - - // --- Standard Fields --- selectedProject.value = data['projectName'] ?? ''; amountController.text = data['amount']?.toString() ?? ''; supplierController.text = data['supplerName'] ?? ''; descriptionController.text = data['description'] ?? ''; transactionIdController.text = data['transactionId'] ?? ''; locationController.text = data['location'] ?? ''; + noOfPersonsController.text = (data['noOfPersons'] ?? 0).toString(); - // --- Transaction Date --- + // Transaction Date if (data['transactionDate'] != null) { try { - final parsedDate = DateTime.parse(data['transactionDate']); - selectedTransactionDate.value = parsedDate; + final parsed = DateTime.parse(data['transactionDate']); + selectedTransactionDate.value = parsed; transactionDateController.text = - DateFormat('dd-MM-yyyy').format(parsedDate); - } catch (e) { - logSafe('Error parsing transactionDate: $e', level: LogLevel.warning); + DateFormat('dd-MM-yyyy').format(parsed); + } catch (_) { selectedTransactionDate.value = null; transactionDateController.clear(); } - } else { - selectedTransactionDate.value = null; - transactionDateController.clear(); } - // --- No of Persons --- - noOfPersonsController.text = (data['noOfPersons'] ?? 0).toString(); - - // --- Dropdown selections --- + // Dropdown selectedExpenseType.value = expenseTypes.firstWhereOrNull((e) => e.id == data['expensesTypeId']); selectedPaymentMode.value = paymentModes.firstWhereOrNull((e) => e.id == data['paymentModeId']); - // --- Paid By select --- - // 1. By ID -// --- Paid By select --- + // Paid By + final paidById = '${data['paidById']}'; selectedPaidBy.value = allEmployees.firstWhereOrNull((e) => e.id.trim() == paidById.trim()); - - if (selectedPaidBy.value == null) { - final fullName = '$paidByFirstName $paidByLastName'; - await searchEmployees(fullName); + if (selectedPaidBy.value == null && data['paidByFirstName'] != null) { + await searchEmployees( + '${data['paidByFirstName']} ${data['paidByLastName']}'); selectedPaidBy.value = employeeSearchResults .firstWhereOrNull((e) => e.id.trim() == paidById.trim()); } - // --- Existing Attachments --- + // Attachments existingAttachments.clear(); - if (data['attachments'] != null && data['attachments'] is List) { + if (data['attachments'] is List) { existingAttachments.addAll( - List>.from(data['attachments']).map((e) { - return { - ...e, - 'isActive': true, // default - }; - }), + List>.from(data['attachments']) + .map((e) => {...e, 'isActive': true}), ); } @@ -185,29 +161,25 @@ class AddExpenseController extends GetxController { void _logPrefilledData() { logSafe('--- Prefilled Expense Data ---', level: LogLevel.info); - logSafe('ID: $editingExpenseId', level: LogLevel.info); - logSafe('Project: ${selectedProject.value}', level: LogLevel.info); - logSafe('Amount: ${amountController.text}', level: LogLevel.info); - logSafe('Supplier: ${supplierController.text}', level: LogLevel.info); - logSafe('Description: ${descriptionController.text}', level: LogLevel.info); - logSafe('Transaction ID: ${transactionIdController.text}', - level: LogLevel.info); - logSafe('Location: ${locationController.text}', level: LogLevel.info); - logSafe('Transaction Date: ${transactionDateController.text}', - level: LogLevel.info); - logSafe('No. of Persons: ${noOfPersonsController.text}', - level: LogLevel.info); - logSafe('Expense Type: ${selectedExpenseType.value?.name}', - level: LogLevel.info); - logSafe('Payment Mode: ${selectedPaymentMode.value?.name}', - level: LogLevel.info); - logSafe('Paid By: ${selectedPaidBy.value?.name}', level: LogLevel.info); - logSafe('Attachments: ${attachments.length}', level: LogLevel.info); - logSafe('Existing Attachments: ${existingAttachments.length}', - level: LogLevel.info); + [ + 'ID: $editingExpenseId', + 'Project: ${selectedProject.value}', + 'Amount: ${amountController.text}', + 'Supplier: ${supplierController.text}', + 'Description: ${descriptionController.text}', + 'Transaction ID: ${transactionIdController.text}', + 'Location: ${locationController.text}', + 'Transaction Date: ${transactionDateController.text}', + 'No. of Persons: ${noOfPersonsController.text}', + 'Expense Type: ${selectedExpenseType.value?.name}', + 'Payment Mode: ${selectedPaymentMode.value?.name}', + 'Paid By: ${selectedPaidBy.value?.name}', + 'Attachments: ${attachments.length}', + 'Existing Attachments: ${existingAttachments.length}', + ].forEach((str) => logSafe(str, level: LogLevel.info)); } - // ---------- Form Actions ---------- + // --- Pickers --- Future pickTransactionDate(BuildContext context) async { final picked = await showDatePicker( context: context, @@ -215,20 +187,12 @@ class AddExpenseController extends GetxController { firstDate: DateTime(DateTime.now().year - 5), lastDate: DateTime.now(), ); - if (picked != null) { selectedTransactionDate.value = picked; transactionDateController.text = DateFormat('dd-MM-yyyy').format(picked); } } - Future loadMasterData() async { - await Future.wait([ - fetchMasterData(), - fetchGlobalProjects(), - ]); - } - Future pickAttachments() async { try { final result = await FilePicker.platform.pickFiles( @@ -237,89 +201,109 @@ class AddExpenseController extends GetxController { allowMultiple: true, ); if (result != null) { - attachments.addAll( - result.paths.whereType().map((path) => File(path)), - ); + attachments + .addAll(result.paths.whereType().map((path) => File(path))); } } catch (e) { - showAppSnackbar( - title: "Error", - message: "Attachment error: $e", - type: SnackbarType.error, - ); + _errorSnackbar("Attachment error: $e"); } } void removeAttachment(File file) => attachments.remove(file); + // --- Location --- Future fetchCurrentLocation() async { isFetchingLocation.value = true; try { - var permission = await Geolocator.checkPermission(); - if (permission == LocationPermission.denied || - permission == LocationPermission.deniedForever) { - permission = await Geolocator.requestPermission(); - if (permission == LocationPermission.denied || - permission == LocationPermission.deniedForever) { - showAppSnackbar( - title: "Error", - message: "Location permission denied.", - type: SnackbarType.error, - ); - return; - } - } - - if (!await Geolocator.isLocationServiceEnabled()) { - showAppSnackbar( - title: "Error", - message: "Location service disabled.", - type: SnackbarType.error, - ); - return; - } + final permission = await _ensureLocationPermission(); + if (!permission) return; final position = await Geolocator.getCurrentPosition(); final placemarks = await placemarkFromCoordinates(position.latitude, position.longitude); - if (placemarks.isNotEmpty) { - final place = placemarks.first; - final address = [ - place.name, - place.street, - place.locality, - place.administrativeArea, - place.country - ].where((e) => e != null && e.isNotEmpty).join(", "); - locationController.text = address; - } else { - locationController.text = "${position.latitude}, ${position.longitude}"; - } + locationController.text = placemarks.isNotEmpty + ? [ + placemarks.first.name, + placemarks.first.street, + placemarks.first.locality, + placemarks.first.administrativeArea, + placemarks.first.country + ].where((e) => e?.isNotEmpty == true).join(", ") + : "${position.latitude}, ${position.longitude}"; } catch (e) { - showAppSnackbar( - title: "Error", - message: "Location error: $e", - type: SnackbarType.error, - ); + _errorSnackbar("Location error: $e"); } finally { isFetchingLocation.value = false; } } - // ---------- Submission ---------- + Future _ensureLocationPermission() async { + var permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { + permission = await Geolocator.requestPermission(); + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { + _errorSnackbar("Location permission denied."); + return false; + } + } + if (!await Geolocator.isLocationServiceEnabled()) { + _errorSnackbar("Location service disabled."); + return false; + } + return true; + } + + // --- Data Fetching --- + Future loadMasterData() async => + await Future.wait([fetchMasterData(), fetchGlobalProjects()]); + + Future fetchMasterData() async { + try { + final types = await ApiService.getMasterExpenseTypes(); + if (types is List) + expenseTypes.value = + types.map((e) => ExpenseTypeModel.fromJson(e)).toList(); + + final modes = await ApiService.getMasterPaymentModes(); + if (modes is List) + paymentModes.value = + modes.map((e) => PaymentModeModel.fromJson(e)).toList(); + } catch (_) { + _errorSnackbar("Failed to fetch master data"); + } + } + + Future fetchGlobalProjects() async { + try { + final response = await ApiService.getGlobalProjects(); + if (response != null) { + final names = []; + for (var item in response) { + final name = item['name']?.toString().trim(), + id = item['id']?.toString().trim(); + if (name != null && id != null) { + projectsMap[name] = id; + names.add(name); + } + } + globalProjects.assignAll(names); + } + } catch (e) { + logSafe("Error fetching projects: $e", level: LogLevel.error); + } + } + + // --- Submission --- Future submitOrUpdateExpense() async { if (isSubmitting.value) return; isSubmitting.value = true; - try { - final validation = validateForm(); - if (validation.isNotEmpty) { - showAppSnackbar( - title: "Missing Fields", - message: validation, - type: SnackbarType.error, - ); + final validationMsg = validateForm(); + if (validationMsg.isNotEmpty) { + _errorSnackbar(validationMsg, "Missing Fields"); return; } @@ -353,42 +337,29 @@ class AddExpenseController extends GetxController { type: SnackbarType.success, ); } else { - showAppSnackbar( - title: "Error", - message: "Operation failed. Try again.", - type: SnackbarType.error, - ); + _errorSnackbar("Operation failed. Try again."); } } catch (e) { - showAppSnackbar( - title: "Error", - message: "Unexpected error: $e", - type: SnackbarType.error, - ); + _errorSnackbar("Unexpected error: $e"); } finally { isSubmitting.value = false; } } Future> _buildExpensePayload() async { - final amount = double.parse(amountController.text.trim()); - final projectId = projectsMap[selectedProject.value]!; - final selectedDate = - selectedTransactionDate.value?.toUtc() ?? DateTime.now().toUtc(); - final existingAttachmentPayloads = existingAttachments.map((e) { - final isActive = e['isActive'] ?? true; - - return { - "documentId": e['documentId'], - "fileName": e['fileName'], - "contentType": e['contentType'], - "fileSize": 0, - "description": "", - "url": e['url'], - "isActive": isActive, - "base64Data": isActive ? e['base64Data'] : null, - }; - }).toList(); + final now = DateTime.now(); + final existingAttachmentPayloads = existingAttachments + .map((e) => { + "documentId": e['documentId'], + "fileName": e['fileName'], + "contentType": e['contentType'], + "fileSize": 0, + "description": "", + "url": e['url'], + "isActive": e['isActive'] ?? true, + "base64Data": e['isActive'] == false ? null : e['base64Data'], + }) + .toList(); final newAttachmentPayloads = await Future.wait(attachments.map((file) async { @@ -401,34 +372,29 @@ class AddExpenseController extends GetxController { "description": "", }; })); - final billAttachments = [ - ...existingAttachmentPayloads, - ...newAttachmentPayloads - ]; - final Map payload = { - "projectId": projectId, - "expensesTypeId": selectedExpenseType.value!.id, + final type = selectedExpenseType.value!; + return { + if (isEditMode.value && editingExpenseId != null) "id": editingExpenseId, + "projectId": projectsMap[selectedProject.value]!, + "expensesTypeId": type.id, "paymentModeId": selectedPaymentMode.value!.id, "paidById": selectedPaidBy.value!.id, - "transactionDate": selectedDate.toIso8601String(), + "transactionDate": (selectedTransactionDate.value?.toUtc() ?? now.toUtc()) + .toIso8601String(), "transactionId": transactionIdController.text, "description": descriptionController.text, "location": locationController.text, "supplerName": supplierController.text, - "amount": amount, - "noOfPersons": selectedExpenseType.value?.noOfPersonsRequired == true + "amount": double.parse(amountController.text.trim()), + "noOfPersons": type.noOfPersonsRequired == true ? int.tryParse(noOfPersonsController.text.trim()) ?? 0 : 0, - "billAttachments": billAttachments, + "billAttachments": [ + ...existingAttachmentPayloads, + ...newAttachmentPayloads + ], }; - - // ✅ Add expense ID if in edit mode - if (isEditMode.value && editingExpenseId != null) { - payload['id'] = editingExpenseId; - } - - return payload; } String validateForm() { @@ -439,62 +405,30 @@ class AddExpenseController extends GetxController { if (selectedPaymentMode.value == null) missing.add("Payment Mode"); if (selectedPaidBy.value == null) missing.add("Paid By"); if (amountController.text.trim().isEmpty) missing.add("Amount"); - if (supplierController.text.trim().isEmpty) missing.add("Supplier Name"); if (descriptionController.text.trim().isEmpty) missing.add("Description"); - if (!isEditMode.value && attachments.isEmpty) missing.add("Attachments"); + + // Date Required + if (selectedTransactionDate.value == null) missing.add("Transaction Date"); + if (selectedTransactionDate.value != null && + selectedTransactionDate.value!.isAfter(DateTime.now())) { + missing.add("Valid Transaction Date"); + } final amount = double.tryParse(amountController.text.trim()); if (amount == null) missing.add("Valid Amount"); - final selectedDate = selectedTransactionDate.value; - if (selectedDate != null && selectedDate.isAfter(DateTime.now())) { - missing.add("Valid Transaction Date"); - } + // Attachment: at least one required at all times + bool hasActiveExisting = + existingAttachments.any((e) => e['isActive'] != false); + if (attachments.isEmpty && !hasActiveExisting) missing.add("Attachment"); return missing.isEmpty ? '' : "Please provide: ${missing.join(', ')}."; } - // ---------- Data Fetching ---------- - Future fetchMasterData() async { - try { - final types = await ApiService.getMasterExpenseTypes(); - final modes = await ApiService.getMasterPaymentModes(); - - if (types is List) { - expenseTypes.value = - types.map((e) => ExpenseTypeModel.fromJson(e)).toList(); - } - - if (modes is List) { - paymentModes.value = - modes.map((e) => PaymentModeModel.fromJson(e)).toList(); - } - } catch (e) { - showAppSnackbar( - title: "Error", - message: "Failed to fetch master data", + // --- Snackbar Helper --- + void _errorSnackbar(String msg, [String title = "Error"]) => showAppSnackbar( + title: title, + message: msg, type: SnackbarType.error, ); - } - } - - Future fetchGlobalProjects() async { - try { - final response = await ApiService.getGlobalProjects(); - if (response != null) { - final names = []; - for (var item in response) { - final name = item['name']?.toString().trim(); - final id = item['id']?.toString().trim(); - if (name != null && id != null) { - projectsMap[name] = id; - names.add(name); - } - } - globalProjects.assignAll(names); - } - } catch (e) { - logSafe("Error fetching projects: $e", level: LogLevel.error); - } - } } From 3195fdd4a074470af31faf2e1d43832c300f34c2 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Wed, 6 Aug 2025 15:18:51 +0530 Subject: [PATCH 59/65] chnaged the pai by in sheet --- .../expense/expense_detail_controller.dart | 26 +- .../widgets/expense_detail_helpers.dart | 96 ++++++ .../expense/add_expense_bottom_sheet.dart | 8 +- .../employee_selector_bottom_sheet.dart | 39 ++- .../expense/reimbursement_bottom_sheet.dart | 74 +++-- lib/view/expense/expense_detail_screen.dart | 280 +++++++++--------- 6 files changed, 330 insertions(+), 193 deletions(-) create mode 100644 lib/helpers/widgets/expense_detail_helpers.dart diff --git a/lib/controller/expense/expense_detail_controller.dart b/lib/controller/expense/expense_detail_controller.dart index 4bc8561..00b418c 100644 --- a/lib/controller/expense/expense_detail_controller.dart +++ b/lib/controller/expense/expense_detail_controller.dart @@ -3,6 +3,7 @@ import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/model/expense/expense_detail_model.dart'; import 'package:marco/model/employee_model.dart'; +import 'package:flutter/material.dart'; class ExpenseDetailController extends GetxController { final Rx expense = Rx(null); @@ -10,9 +11,11 @@ class ExpenseDetailController extends GetxController { final RxString errorMessage = ''.obs; final Rx selectedReimbursedBy = Rx(null); final RxList allEmployees = [].obs; - + final RxList employeeSearchResults = [].obs; late String _expenseId; bool _isInitialized = false; + final employeeSearchController = TextEditingController(); + final isSearchingEmployees = false.obs; /// Call this once from the screen (NOT inside build) to initialize void init(String expenseId) { @@ -93,6 +96,23 @@ class ExpenseDetailController extends GetxController { return []; } + Future searchEmployees(String query) async { + if (query.trim().isEmpty) return employeeSearchResults.clear(); + isSearchingEmployees.value = true; + try { + final data = + await ApiService.searchEmployeesBasic(searchString: query.trim()); + employeeSearchResults.assignAll( + (data ?? []).map((e) => EmployeeModel.fromJson(e)), + ); + } catch (e) { + logSafe("Error searching employees: $e", level: LogLevel.error); + employeeSearchResults.clear(); + } finally { + isSearchingEmployees.value = false; + } + } + /// Fetch all employees Future fetchAllEmployees() async { final response = await _apiCallWrapper( @@ -151,13 +171,13 @@ class ExpenseDetailController extends GetxController { () => ApiService.updateExpenseStatusApi( expenseId: _expenseId, statusId: statusId, - comment: comment, + comment: comment, ), "update expense status", ); if (success == true) { - await fetchExpenseDetails(); + await fetchExpenseDetails(); return true; } else { errorMessage.value = "Failed to update expense status."; diff --git a/lib/helpers/widgets/expense_detail_helpers.dart b/lib/helpers/widgets/expense_detail_helpers.dart new file mode 100644 index 0000000..1a5f450 --- /dev/null +++ b/lib/helpers/widgets/expense_detail_helpers.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; + +/// Returns a formatted color for the expense status. +Color getExpenseStatusColor(String? status, {String? colorCode}) { + if (colorCode != null && colorCode.isNotEmpty) { + try { + return Color(int.parse(colorCode.replaceFirst('#', '0xff'))); + } catch (_) {} + } + switch (status) { + case 'Approval Pending': + return Colors.orange; + case 'Process Pending': + return Colors.blue; + case 'Rejected': + return Colors.red; + case 'Paid': + return Colors.green; + default: + return Colors.black; + } +} + +/// Formats amount to ₹ currency string. +String formatExpenseAmount(double amount) { + return NumberFormat.currency( + locale: 'en_IN', symbol: '₹ ', decimalDigits: 2) + .format(amount); +} + +/// Label/Value block as reusable widget. +Widget labelValueBlock(String label, String value) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodySmall(label, fontWeight: 600), + MySpacing.height(4), + MyText.bodySmall(value, + fontWeight: 500, softWrap: true, maxLines: null), + ], + ); + +/// Skeleton loader for lists. +Widget buildLoadingSkeleton() => ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: 5, + itemBuilder: (_, __) => Container( + margin: const EdgeInsets.only(bottom: 16), + height: 80, + decoration: BoxDecoration( + color: Colors.grey[300], borderRadius: BorderRadius.circular(10)), + ), + ); + +/// Expandable description widget. +class ExpandableDescription extends StatefulWidget { + final String description; + const ExpandableDescription({Key? key, required this.description}) + : super(key: key); + + @override + State createState() => _ExpandableDescriptionState(); +} + +class _ExpandableDescriptionState extends State { + bool isExpanded = false; + @override + Widget build(BuildContext context) { + final isLong = widget.description.length > 100; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodySmall( + widget.description, + maxLines: isExpanded ? null : 2, + overflow: isExpanded ? TextOverflow.visible : TextOverflow.ellipsis, + fontWeight: 500, + ), + if (isLong || !isExpanded) + InkWell( + onTap: () => setState(() => isExpanded = !isExpanded), + child: Padding( + padding: const EdgeInsets.only(top: 4), + child: MyText.labelSmall( + isExpanded ? 'Show less' : 'Show more', + fontWeight: 600, + color: Colors.blue, + ), + ), + ), + ], + ); + } +} diff --git a/lib/model/expense/add_expense_bottom_sheet.dart b/lib/model/expense/add_expense_bottom_sheet.dart index 4e6dfec..78d822f 100644 --- a/lib/model/expense/add_expense_bottom_sheet.dart +++ b/lib/model/expense/add_expense_bottom_sheet.dart @@ -48,7 +48,13 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), backgroundColor: Colors.transparent, - builder: (_) => EmployeeSelectorBottomSheet(), + builder: (_) => ReusableEmployeeSelectorBottomSheet( + searchController: controller.employeeSearchController, + searchResults: controller.employeeSearchResults, + isSearching: controller.isSearchingEmployees, + onSearch: controller.searchEmployees, + onSelect: (emp) => controller.selectedPaidBy.value = emp, + ), ); // Optional cleanup diff --git a/lib/model/expense/employee_selector_bottom_sheet.dart b/lib/model/expense/employee_selector_bottom_sheet.dart index fb9fe27..055a7c7 100644 --- a/lib/model/expense/employee_selector_bottom_sheet.dart +++ b/lib/model/expense/employee_selector_bottom_sheet.dart @@ -1,14 +1,25 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:marco/controller/expense/add_expense_controller.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/utils/base_bottom_sheet.dart'; +import 'package:marco/model/employee_model.dart'; -class EmployeeSelectorBottomSheet extends StatelessWidget { - final AddExpenseController controller = Get.find(); +class ReusableEmployeeSelectorBottomSheet extends StatelessWidget { + final TextEditingController searchController; + final RxList searchResults; + final RxBool isSearching; + final void Function(String) onSearch; + final void Function(EmployeeModel) onSelect; - EmployeeSelectorBottomSheet({super.key}); + const ReusableEmployeeSelectorBottomSheet({ + super.key, + required this.searchController, + required this.searchResults, + required this.isSearching, + required this.onSearch, + required this.onSelect, + }); @override Widget build(BuildContext context) { @@ -16,13 +27,13 @@ class EmployeeSelectorBottomSheet extends StatelessWidget { title: "Search Employee", onCancel: () => Get.back(), onSubmit: () {}, - showButtons: false, + showButtons: false, child: Obx(() { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ TextField( - controller: controller.employeeSearchController, + controller: searchController, decoration: InputDecoration( hintText: "Search by name, email...", prefixIcon: const Icon(Icons.search), @@ -32,24 +43,24 @@ class EmployeeSelectorBottomSheet extends StatelessWidget { contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), ), - onChanged: (value) => controller.searchEmployees(value), + onChanged: onSearch, ), MySpacing.height(12), SizedBox( - height: 400, // Adjust this if needed - child: controller.isSearchingEmployees.value + height: 400, + child: isSearching.value ? const Center(child: CircularProgressIndicator()) - : controller.employeeSearchResults.isEmpty - ? Center( + : searchResults.isEmpty + ? Center( child: MyText.bodyMedium( "No employees found.", fontWeight: 500, ), ) : ListView.builder( - itemCount: controller.employeeSearchResults.length, + itemCount: searchResults.length, itemBuilder: (_, index) { - final emp = controller.employeeSearchResults[index]; + final emp = searchResults[index]; final fullName = '${emp.firstName} ${emp.lastName}'.trim(); return ListTile( @@ -58,7 +69,7 @@ class EmployeeSelectorBottomSheet extends StatelessWidget { fontWeight: 600, ), onTap: () { - controller.selectedPaidBy.value = emp; + onSelect(emp); Get.back(); }, ); diff --git a/lib/model/expense/reimbursement_bottom_sheet.dart b/lib/model/expense/reimbursement_bottom_sheet.dart index 5c51956..c996174 100644 --- a/lib/model/expense/reimbursement_bottom_sheet.dart +++ b/lib/model/expense/reimbursement_bottom_sheet.dart @@ -8,7 +8,7 @@ import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text_style.dart'; import 'package:marco/helpers/utils/base_bottom_sheet.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; - +import 'package:marco/model/expense/employee_selector_bottom_sheet.dart'; class ReimbursementBottomSheet extends StatefulWidget { final String expenseId; @@ -50,39 +50,26 @@ class _ReimbursementBottomSheetState extends State { super.dispose(); } - void _showEmployeeList() { - showModalBottomSheet( + void _showEmployeeList() async { + await showModalBottomSheet( context: context, - backgroundColor: Colors.white, + isScrollControlled: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), - builder: (_) { - return SizedBox( - height: 300, - child: Obx(() { - final employees = controller.allEmployees; - if (employees.isEmpty) { - return const Center(child: Text("No employees found")); - } - return ListView.builder( - itemCount: employees.length, - itemBuilder: (_, index) { - final emp = employees[index]; - final fullName = '${emp.firstName} ${emp.lastName}'.trim(); - return ListTile( - title: Text(fullName.isNotEmpty ? fullName : "Unnamed"), - onTap: () { - controller.selectedReimbursedBy.value = emp; - Navigator.pop(context); - }, - ); - }, - ); - }), - ); - }, + backgroundColor: Colors.transparent, + builder: (_) => ReusableEmployeeSelectorBottomSheet( + searchController: controller.employeeSearchController, + searchResults: controller.employeeSearchResults, + isSearching: controller.isSearchingEmployees, + onSearch: controller.searchEmployees, + onSelect: (emp) => controller.selectedReimbursedBy.value = emp, + ), ); + + // Optional cleanup + controller.employeeSearchController.clear(); + controller.employeeSearchResults.clear(); } InputDecoration _inputDecoration(String hint) { @@ -200,16 +187,25 @@ class _ReimbursementBottomSheetState extends State { MySpacing.height(8), GestureDetector( onTap: _showEmployeeList, - child: AbsorbPointer( - child: TextField( - controller: TextEditingController( - text: controller.selectedReimbursedBy.value == null - ? "" - : '${controller.selectedReimbursedBy.value?.firstName ?? ''} ${controller.selectedReimbursedBy.value?.lastName ?? ''}', - ), - decoration: _inputDecoration("Select Employee").copyWith( - suffixIcon: const Icon(Icons.expand_more), - ), + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade300), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + controller.selectedReimbursedBy.value == null + ? "Select Paid By" + : '${controller.selectedReimbursedBy.value?.firstName ?? ''} ${controller.selectedReimbursedBy.value?.lastName ?? ''}', + style: const TextStyle(fontSize: 14), + ), + const Icon(Icons.arrow_drop_down, size: 22), + ], ), ), ), diff --git a/lib/view/expense/expense_detail_screen.dart b/lib/view/expense/expense_detail_screen.dart index 9a8d7d2..2cf1276 100644 --- a/lib/view/expense/expense_detail_screen.dart +++ b/lib/view/expense/expense_detail_screen.dart @@ -1,67 +1,97 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:intl/intl.dart'; -import 'package:marco/model/expense/add_expense_bottom_sheet.dart'; - import 'package:marco/controller/expense/expense_detail_controller.dart'; import 'package:marco/controller/project_controller.dart'; import 'package:marco/controller/permission_controller.dart'; import 'package:marco/helpers/utils/date_time_utils.dart'; -import 'package:marco/helpers/widgets/my_spacing.dart'; -import 'package:marco/helpers/widgets/my_text.dart'; -import 'package:marco/model/expense/expense_detail_model.dart'; import 'package:marco/helpers/widgets/image_viewer_dialog.dart'; -import 'package:url_launcher/url_launcher.dart'; -import 'package:marco/model/expense/reimbursement_bottom_sheet.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:marco/model/expense/add_expense_bottom_sheet.dart'; import 'package:marco/model/expense/comment_bottom_sheet.dart'; +import 'package:marco/model/expense/expense_detail_model.dart'; +import 'package:marco/model/expense/reimbursement_bottom_sheet.dart'; import 'package:marco/controller/expense/add_expense_controller.dart'; import 'package:marco/helpers/services/app_logger.dart'; +import 'package:url_launcher/url_launcher.dart'; -class ExpenseDetailScreen extends StatelessWidget { +import 'package:marco/helpers/widgets/expense_detail_helpers.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/services/storage/local_storage.dart'; +import 'package:marco/model/employee_info.dart'; + +class ExpenseDetailScreen extends StatefulWidget { final String expenseId; const ExpenseDetailScreen({super.key, required this.expenseId}); - static Color getStatusColor(String? status, {String? colorCode}) { - if (colorCode != null && colorCode.isNotEmpty) { - try { - return Color(int.parse(colorCode.replaceFirst('#', '0xff'))); - } catch (_) {} - } - switch (status) { - case 'Approval Pending': - return Colors.orange; - case 'Process Pending': - return Colors.blue; - case 'Rejected': - return Colors.red; - case 'Paid': - return Colors.green; - default: - return Colors.black; - } + @override + State createState() => _ExpenseDetailScreenState(); +} + +class _ExpenseDetailScreenState extends State { + final controller = Get.put(ExpenseDetailController()); + final projectController = Get.find(); + final permissionController = Get.find(); + + EmployeeInfo? employeeInfo; + final RxBool canSubmit = false.obs; + bool _checkedPermission = false; + + @override + void initState() { + super.initState(); + controller.init(widget.expenseId); + _loadEmployeeInfo(); + } + + void _loadEmployeeInfo() async { + final info = await LocalStorage.getEmployeeInfo(); + employeeInfo = info; + } + + void _checkPermissionToSubmit(ExpenseDetailModel expense) { + const allowedNextStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7'; + + final isCreatedByCurrentUser = employeeInfo?.id == expense.createdBy.id; + final nextStatusIds = expense.nextStatus.map((e) => e.id).toList(); + final hasRequiredNextStatus = nextStatusIds.contains(allowedNextStatusId); + + final result = isCreatedByCurrentUser && hasRequiredNextStatus; + + logSafe( + '🐛 Checking submit permission:\n' + '🐛 - Logged-in employee ID: ${employeeInfo?.id}\n' + '🐛 - Expense created by ID: ${expense.createdBy.id}\n' + '🐛 - Next Status IDs: $nextStatusIds\n' + '🐛 - Has Required Next Status ID ($allowedNextStatusId): $hasRequiredNextStatus\n' + '🐛 - Final Permission Result: $result', + level: LogLevel.debug, + ); + + canSubmit.value = result; } @override Widget build(BuildContext context) { - final controller = Get.put(ExpenseDetailController()); - final projectController = Get.find(); - controller.init(expenseId); - final permissionController = Get.find(); - return Scaffold( backgroundColor: const Color(0xFFF7F7F7), appBar: _AppBar(projectController: projectController), body: SafeArea( child: Obx(() { - if (controller.isLoading.value) return _buildLoadingSkeleton(); + if (controller.isLoading.value) return buildLoadingSkeleton(); final expense = controller.expense.value; if (controller.errorMessage.isNotEmpty || expense == null) { return Center(child: MyText.bodyMedium("No data to display.")); } - final statusColor = getStatusColor(expense.status.name, + + WidgetsBinding.instance.addPostFrameCallback((_) { + _checkPermissionToSubmit(expense); + }); + + final statusColor = getExpenseStatusColor(expense.status.name, colorCode: expense.status.color); - final formattedAmount = _formatAmount(expense.amount); + final formattedAmount = formatExpenseAmount(expense.amount); + return SingleChildScrollView( padding: EdgeInsets.fromLTRB( 8, 8, 8, 30 + MediaQuery.of(context).padding.bottom), @@ -87,9 +117,10 @@ class ExpenseDetailScreen extends StatelessWidget { _InvoiceDocuments(documents: expense.documents), const Divider(height: 30, thickness: 1.2), _InvoiceTotals( - expense: expense, - formattedAmount: formattedAmount, - statusColor: statusColor), + expense: expense, + formattedAmount: formattedAmount, + statusColor: statusColor, + ), ], ), ), @@ -100,18 +131,21 @@ class ExpenseDetailScreen extends StatelessWidget { }), ), floatingActionButton: Obx(() { + if (controller.isLoading.value) return buildLoadingSkeleton(); + final expense = controller.expense.value; - if (expense == null) return const SizedBox.shrink(); + if (controller.errorMessage.isNotEmpty || expense == null) { + return Center(child: MyText.bodyMedium("No data to display.")); + } - // Allowed status Ids - const allowedStatusIds = [ - "d1ee5eec-24b6-4364-8673-a8f859c60729", - "965eda62-7907-4963-b4a1-657fb0b2724b", - "297e0d8f-f668-41b5-bfea-e03b354251c8" - ]; + if (!_checkedPermission) { + _checkedPermission = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _checkPermissionToSubmit(expense); + }); + } - // Show edit button only if status id is in allowedStatusIds - if (!allowedStatusIds.contains(expense.status.id)) { + if (!ExpensePermissionHelper.canEditExpense(employeeInfo, expense)) { return const SizedBox.shrink(); } @@ -130,10 +164,8 @@ class ExpenseDetailScreen extends StatelessWidget { 'expensesTypeId': expense.expensesType.id, 'paymentModeId': expense.paymentMode.id, 'paidById': expense.paidBy.id, - // ==== Add these lines below ==== 'paidByFirstName': expense.paidBy.firstName, 'paidByLastName': expense.paidBy.lastName, - // ================================= 'attachments': expense.documents .map((doc) => { 'url': doc.preSignedUrl, @@ -151,20 +183,17 @@ class ExpenseDetailScreen extends StatelessWidget { addCtrl.populateFieldsForEdit(editData); await showAddExpenseBottomSheet(isEdit: true); - - // Refresh expense details after editing await controller.fetchExpenseDetails(); }, backgroundColor: Colors.red, tooltip: 'Edit Expense', - child: Icon(Icons.edit), + child: const Icon(Icons.edit), ); }), bottomNavigationBar: Obx(() { final expense = controller.expense.value; - if (expense == null || expense.nextStatus.isEmpty) { - return const SizedBox(); - } + if (expense == null) return const SizedBox(); + return SafeArea( child: Container( decoration: const BoxDecoration( @@ -176,12 +205,37 @@ class ExpenseDetailScreen extends StatelessWidget { alignment: WrapAlignment.center, spacing: 10, runSpacing: 10, - children: expense.nextStatus - .where((next) => permissionController.hasAnyPermission( - controller.parsePermissionIds(next.permissionIds))) - .map((next) => - _statusButton(context, controller, expense, next)) - .toList(), + children: expense.nextStatus.where((next) { + const submitStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7'; + + final rawPermissions = next.permissionIds; + final parsedPermissions = + controller.parsePermissionIds(rawPermissions); + + final isSubmitStatus = next.id == submitStatusId; + final isCreatedByCurrentUser = + employeeInfo?.id == expense.createdBy.id; + + logSafe( + '🔐 Permission Logic:\n' + '🔸 Status: ${next.name}\n' + '🔸 Status ID: ${next.id}\n' + '🔸 Parsed Permissions: $parsedPermissions\n' + '🔸 Is Submit: $isSubmitStatus\n' + '🔸 Created By Current User: $isCreatedByCurrentUser', + level: LogLevel.debug, + ); + + if (isSubmitStatus) { + // Submit can be done ONLY by the creator + return isCreatedByCurrentUser; + } + + // All other statuses - check permission normally + return permissionController.hasAnyPermission(parsedPermissions); + }).map((next) { + return _statusButton(context, controller, expense, next); + }).toList(), ), ), ); @@ -197,6 +251,7 @@ class ExpenseDetailScreen extends StatelessWidget { buttonColor = Color(int.parse(next.color.replaceFirst('#', '0xff'))); } catch (_) {} } + return ElevatedButton( style: ElevatedButton.styleFrom( minimumSize: const Size(100, 40), @@ -205,7 +260,6 @@ class ExpenseDetailScreen extends StatelessWidget { shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), ), onPressed: () async { - // For brevity, couldn't refactor the logic since it's business-specific. const reimbursementId = 'f18c5cfd-7815-4341-8da2-2c2d65778e27'; if (expense.status.id == reimbursementId) { showModalBottomSheet( @@ -277,25 +331,6 @@ class ExpenseDetailScreen extends StatelessWidget { ), ); } - - static String _formatAmount(double amount) { - return NumberFormat.currency( - locale: 'en_IN', symbol: '₹ ', decimalDigits: 2) - .format(amount); - } - - Widget _buildLoadingSkeleton() { - return ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: 5, - itemBuilder: (_, __) => Container( - margin: const EdgeInsets.only(bottom: 16), - height: 80, - decoration: BoxDecoration( - color: Colors.grey[300], borderRadius: BorderRadius.circular(10)), - ), - ); - } } class _AppBar extends StatelessWidget implements PreferredSizeWidget { @@ -356,8 +391,6 @@ class _AppBar extends StatelessWidget implements PreferredSizeWidget { Size get preferredSize => const Size.fromHeight(kToolbarHeight); } -// -------- Invoice Sub-Components, unchanged except formatting/const ---------------- - class _InvoiceHeader extends StatelessWidget { final ExpenseDetailModel expense; const _InvoiceHeader({required this.expense}); @@ -366,7 +399,7 @@ class _InvoiceHeader extends StatelessWidget { final dateString = DateTimeUtils.convertUtcToLocal( expense.transactionDate.toString(), format: 'dd-MM-yyyy'); - final statusColor = ExpenseDetailScreen.getStatusColor(expense.status.name, + final statusColor = getExpenseStatusColor(expense.status.name, colorCode: expense.status.color); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -407,28 +440,18 @@ class _InvoiceParties extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _labelValueBlock('Project', expense.project.name), + labelValueBlock('Project', expense.project.name), MySpacing.height(16), - _labelValueBlock('Paid By:', + labelValueBlock('Paid By:', '${expense.paidBy.firstName} ${expense.paidBy.lastName}'), MySpacing.height(16), - _labelValueBlock('Supplier', expense.supplerName), + labelValueBlock('Supplier', expense.supplerName), MySpacing.height(16), - _labelValueBlock('Created By:', + labelValueBlock('Created By:', '${expense.createdBy.firstName} ${expense.createdBy.lastName}'), ], ); } - - Widget _labelValueBlock(String label, String value) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.bodySmall(label, fontWeight: 600), - MySpacing.height(4), - MyText.bodySmall(value, - fontWeight: 500, softWrap: true, maxLines: null), - ], - ); } class _InvoiceDetailsTable extends StatelessWidget { @@ -556,6 +579,29 @@ class _InvoiceDocuments extends StatelessWidget { } } +class ExpensePermissionHelper { + static bool canEditExpense( + EmployeeInfo? employee, ExpenseDetailModel expense) { + return employee?.id == expense.createdBy.id && + _isInAllowedEditStatus(expense.status.id); + } + + static bool canSubmitExpense( + EmployeeInfo? employee, ExpenseDetailModel expense) { + return employee?.id == expense.createdBy.id && + expense.nextStatus.isNotEmpty; + } + + static bool _isInAllowedEditStatus(String statusId) { + const editableStatusIds = [ + "d1ee5eec-24b6-4364-8673-a8f859c60729", + "965eda62-7907-4963-b4a1-657fb0b2724b", + "297e0d8f-f668-41b5-bfea-e03b354251c8" + ]; + return editableStatusIds.contains(statusId); + } +} + class _InvoiceTotals extends StatelessWidget { final ExpenseDetailModel expense; final String formattedAmount; @@ -576,41 +622,3 @@ class _InvoiceTotals extends StatelessWidget { ); } } - -class ExpandableDescription extends StatefulWidget { - final String description; - const ExpandableDescription({super.key, required this.description}); - @override - State createState() => _ExpandableDescriptionState(); -} - -class _ExpandableDescriptionState extends State { - bool isExpanded = false; - @override - Widget build(BuildContext context) { - final isLong = widget.description.length > 100; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.bodySmall( - widget.description, - maxLines: isExpanded ? null : 2, - overflow: isExpanded ? TextOverflow.visible : TextOverflow.ellipsis, - fontWeight: 500, - ), - if (isLong || !isExpanded) - InkWell( - onTap: () => setState(() => isExpanded = !isExpanded), - child: Padding( - padding: const EdgeInsets.only(top: 4), - child: MyText.labelSmall( - isExpanded ? 'Show less' : 'Show more', - fontWeight: 600, - color: Colors.blue, - ), - ), - ), - ], - ); - } -} From b0c9a2c45f669d1e29b7b5fb49f73adf645875c5 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Wed, 6 Aug 2025 16:09:35 +0530 Subject: [PATCH 60/65] added add expense permission --- .../widgets/expense_main_components.dart | 437 +++++++++++++++++ lib/view/expense/expense_detail_screen.dart | 99 ++-- lib/view/expense/expense_screen.dart | 456 +----------------- 3 files changed, 524 insertions(+), 468 deletions(-) create mode 100644 lib/helpers/widgets/expense_main_components.dart diff --git a/lib/helpers/widgets/expense_main_components.dart b/lib/helpers/widgets/expense_main_components.dart new file mode 100644 index 0000000..d502825 --- /dev/null +++ b/lib/helpers/widgets/expense_main_components.dart @@ -0,0 +1,437 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import 'package:marco/controller/project_controller.dart'; +import 'package:marco/controller/expense/expense_screen_controller.dart'; +import 'package:marco/helpers/utils/date_time_utils.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:marco/model/expense/expense_list_model.dart'; +import 'package:marco/view/expense/expense_detail_screen.dart'; + +class ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget { + final ProjectController projectController; + + const ExpenseAppBar({required this.projectController, super.key}); + + @override + Size get preferredSize => const Size.fromHeight(72); + + @override + Widget build(BuildContext context) { + return AppBar( + backgroundColor: const Color(0xFFF5F5F5), + elevation: 0.5, + automaticallyImplyLeading: false, + titleSpacing: 0, + title: Padding( + padding: MySpacing.xy(16, 0), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back_ios_new, + color: Colors.black, size: 20), + onPressed: () => Get.offNamed('/dashboard'), + ), + MySpacing.width(8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleLarge('Expenses', fontWeight: 700), + MySpacing.height(2), + GetBuilder( + builder: (_) { + final name = projectController.selectedProject?.name ?? + 'Select Project'; + return Row( + children: [ + const Icon(Icons.work_outline, + size: 14, color: Colors.grey), + MySpacing.width(4), + Expanded( + child: MyText.bodySmall( + name, + overflow: TextOverflow.ellipsis, + color: Colors.grey[700], + fontWeight: 600, + ), + ), + ], + ); + }, + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class SearchAndFilter extends StatelessWidget { + final TextEditingController controller; + final ValueChanged onChanged; + final VoidCallback onFilterTap; + final VoidCallback onRefreshTap; + final ExpenseController expenseController; + + const SearchAndFilter({ + required this.controller, + required this.onChanged, + required this.onFilterTap, + required this.onRefreshTap, + required this.expenseController, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: MySpacing.fromLTRB(12, 10, 12, 0), + child: Row( + children: [ + Expanded( + child: SizedBox( + height: 35, + child: TextField( + controller: controller, + onChanged: onChanged, + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric(horizontal: 12), + prefixIcon: + const Icon(Icons.search, size: 20, color: Colors.grey), + hintText: 'Search expenses...', + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + ), + ), + ), + ), + MySpacing.width(8), + Tooltip( + message: 'Refresh Data', + child: IconButton( + icon: const Icon(Icons.refresh, color: Colors.green, size: 24), + onPressed: onRefreshTap, + ), + ), + MySpacing.width(4), + Obx(() { + return IconButton( + icon: Stack( + clipBehavior: Clip.none, + children: [ + const Icon(Icons.tune, color: Colors.black), + if (expenseController.isFilterApplied) + Positioned( + top: -1, + right: -1, + child: Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 1.5), + ), + ), + ), + ], + ), + onPressed: onFilterTap, + ); + }), + ], + ), + ); + } +} + +class ToggleButtonsRow extends StatelessWidget { + final bool isHistoryView; + final ValueChanged onToggle; + + const ToggleButtonsRow({ + required this.isHistoryView, + required this.onToggle, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: MySpacing.fromLTRB(8, 12, 8, 5), + child: Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: const Color(0xFFF0F0F0), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + _ToggleButton( + label: 'Expenses', + icon: Icons.receipt_long, + selected: !isHistoryView, + onTap: () => onToggle(false), + ), + _ToggleButton( + label: 'History', + icon: Icons.history, + selected: isHistoryView, + onTap: () => onToggle(true), + ), + ], + ), + ), + ); + } +} + +class _ToggleButton extends StatelessWidget { + final String label; + final IconData icon; + final bool selected; + final VoidCallback onTap; + + const _ToggleButton({ + required this.label, + required this.icon, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Expanded( + child: GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 10), + decoration: BoxDecoration( + color: selected ? Colors.red : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, + size: 16, color: selected ? Colors.white : Colors.grey), + const SizedBox(width: 6), + MyText.bodyMedium(label, + color: selected ? Colors.white : Colors.grey, + fontWeight: 600), + ], + ), + ), + ), + ); + } +} + +class ExpenseList extends StatelessWidget { + final List expenseList; + final Future Function()? onViewDetail; + + const ExpenseList({ + required this.expenseList, + this.onViewDetail, + super.key, + }); + + void _showDeleteConfirmation(BuildContext context, ExpenseModel expense) { + final ExpenseController controller = Get.find(); + final RxBool isDeleting = false.obs; + + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: Obx(() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: isDeleting.value + ? const SizedBox( + height: 100, + child: Center(child: CircularProgressIndicator()), + ) + : Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.delete, + size: 48, color: Colors.redAccent), + const SizedBox(height: 16), + MyText.titleLarge("Delete Expense", + fontWeight: 600, + color: + Theme.of(context).colorScheme.onBackground), + const SizedBox(height: 12), + MyText.bodySmall( + "Are you sure you want to delete this draft expense?", + textAlign: TextAlign.center, + color: Theme.of(context) + .colorScheme + .onSurface + .withOpacity(0.7), + ), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: () => Navigator.pop(context), + icon: + const Icon(Icons.close, color: Colors.white), + label: MyText.bodyMedium( + "Cancel", + color: Colors.white, + fontWeight: 600, + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.grey, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: + const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + onPressed: () async { + isDeleting.value = true; + await controller.deleteExpense(expense.id); + isDeleting.value = false; + Navigator.pop(context); + showAppSnackbar( + title: 'Deleted', + message: 'Expense has been deleted.', + type: SnackbarType.success, + ); + }, + icon: const Icon(Icons.delete_forever, + color: Colors.white), + label: MyText.bodyMedium( + "Delete", + color: Colors.white, + fontWeight: 600, + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.redAccent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: + const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + ], + ), + ], + ), + ); + }), + ), + ); + } + + @override + Widget build(BuildContext context) { + if (expenseList.isEmpty && !Get.find().isLoading.value) { + return Center(child: MyText.bodyMedium('No expenses found.')); + } + + return ListView.separated( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 80), + itemCount: expenseList.length, + separatorBuilder: (_, __) => + Divider(color: Colors.grey.shade300, height: 20), + itemBuilder: (context, index) { + final expense = expenseList[index]; + final formattedDate = DateTimeUtils.convertUtcToLocal( + expense.transactionDate.toIso8601String(), + format: 'dd MMM yyyy, hh:mm a', + ); + + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () async { + final result = await Get.to( + () => ExpenseDetailScreen(expenseId: expense.id), + arguments: {'expense': expense}, + ); + if (result == true && onViewDetail != null) { + await onViewDetail!(); + } + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + MyText.bodyMedium(expense.expensesType.name, + fontWeight: 600), + Row( + children: [ + MyText.bodyMedium( + '₹ ${expense.amount.toStringAsFixed(2)}', + fontWeight: 600), + if (expense.status.name.toLowerCase() == 'draft') ...[ + const SizedBox(width: 8), + GestureDetector( + onTap: () => + _showDeleteConfirmation(context, expense), + child: const Icon(Icons.delete, + color: Colors.red, size: 20), + ), + ], + ], + ), + ], + ), + const SizedBox(height: 6), + Row( + children: [ + MyText.bodySmall(formattedDate, fontWeight: 500), + const Spacer(), + MyText.bodySmall(expense.status.name, fontWeight: 500), + ], + ), + ], + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/view/expense/expense_detail_screen.dart b/lib/view/expense/expense_detail_screen.dart index 2cf1276..37e377f 100644 --- a/lib/view/expense/expense_detail_screen.dart +++ b/lib/view/expense/expense_detail_screen.dart @@ -268,40 +268,79 @@ class _ExpenseDetailScreenState extends State { shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16))), builder: (context) => ReimbursementBottomSheet( - expenseId: expense.id, - statusId: next.id, - onClose: () {}, - onSubmit: ({ - required String comment, - required String reimburseTransactionId, - required String reimburseDate, - required String reimburseById, - required String statusId, - }) async { - final success = - await controller.updateExpenseStatusWithReimbursement( - comment: comment, - reimburseTransactionId: reimburseTransactionId, - reimburseDate: reimburseDate, - reimburseById: reimburseById, - statusId: statusId, - ); - if (success) { - showAppSnackbar( + expenseId: expense.id, + statusId: next.id, + onClose: () {}, + onSubmit: ({ + required String comment, + required String reimburseTransactionId, + required String reimburseDate, + required String reimburseById, + required String statusId, + }) async { + final transactionDate = DateTime.tryParse( + controller.expense.value?.transactionDate ?? ''); + final selectedReimburseDate = + DateTime.tryParse(reimburseDate); + final today = DateTime.now(); + + if (transactionDate == null || + selectedReimburseDate == null) { + showAppSnackbar( + title: 'Invalid date', + message: + 'Could not parse transaction or reimbursement date.', + type: SnackbarType.error, + ); + return false; + } + + if (selectedReimburseDate.isBefore(transactionDate)) { + showAppSnackbar( + title: 'Invalid Date', + message: + 'Reimbursement date cannot be before the transaction date.', + type: SnackbarType.error, + ); + return false; + } + + if (selectedReimburseDate.isAfter(today)) { + showAppSnackbar( + title: 'Invalid Date', + message: 'Reimbursement date cannot be in the future.', + type: SnackbarType.error, + ); + return false; + } + + final success = + await controller.updateExpenseStatusWithReimbursement( + comment: comment, + reimburseTransactionId: reimburseTransactionId, + reimburseDate: reimburseDate, + reimburseById: reimburseById, + statusId: statusId, + ); + + if (success) { + Navigator.of(context).pop(); + showAppSnackbar( title: 'Success', message: 'Expense reimbursed successfully.', - type: SnackbarType.success); - await controller.fetchExpenseDetails(); - return true; - } else { - showAppSnackbar( + type: SnackbarType.success, + ); + await controller.fetchExpenseDetails(); + return true; + } else { + showAppSnackbar( title: 'Error', message: 'Failed to reimburse expense.', - type: SnackbarType.error); - return false; - } - }, - ), + type: SnackbarType.error, + ); + return false; + } + }), ); } else { final comment = await showCommentBottomSheet(context, next.name); diff --git a/lib/view/expense/expense_screen.dart b/lib/view/expense/expense_screen.dart index d20547e..d9bfd53 100644 --- a/lib/view/expense/expense_screen.dart +++ b/lib/view/expense/expense_screen.dart @@ -1,17 +1,15 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; - import 'package:marco/controller/project_controller.dart'; import 'package:marco/controller/expense/expense_screen_controller.dart'; -import 'package:marco/helpers/utils/date_time_utils.dart'; -import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/controller/permission_controller.dart'; import 'package:marco/helpers/widgets/my_text.dart'; -import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; import 'package:marco/model/expense/expense_list_model.dart'; -import 'package:marco/view/expense/expense_detail_screen.dart'; import 'package:marco/model/expense/add_expense_bottom_sheet.dart'; import 'package:marco/view/expense/expense_filter_bottom_sheet.dart'; +import 'package:marco/helpers/widgets/expense_main_components.dart'; +import 'package:marco/helpers/utils/permission_constants.dart'; class ExpenseMainScreen extends StatefulWidget { const ExpenseMainScreen({super.key}); @@ -25,6 +23,7 @@ class _ExpenseMainScreenState extends State { final searchController = TextEditingController(); final expenseController = Get.put(ExpenseController()); final projectController = Get.find(); + final permissionController = Get.find(); @override void initState() { @@ -74,18 +73,18 @@ class _ExpenseMainScreenState extends State { Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.white, - appBar: _ExpenseAppBar(projectController: projectController), + appBar: ExpenseAppBar(projectController: projectController), body: SafeArea( child: Column( children: [ - _SearchAndFilter( + SearchAndFilter( controller: searchController, onChanged: (_) => setState(() {}), onFilterTap: _openFilterBottomSheet, onRefreshTap: _refreshExpenses, expenseController: expenseController, ), - _ToggleButtons( + ToggleButtonsRow( isHistoryView: isHistoryView, onToggle: (v) => setState(() => isHistoryView = v), ), @@ -105,7 +104,7 @@ class _ExpenseMainScreenState extends State { } final filteredList = _getFilteredExpenses(); - return _ExpenseList( + return ExpenseList( expenseList: filteredList, onViewDetail: () => expenseController.fetchExpenses(), ); @@ -114,435 +113,16 @@ class _ExpenseMainScreenState extends State { ], ), ), - floatingActionButton: FloatingActionButton( - backgroundColor: Colors.red, - onPressed: showAddExpenseBottomSheet, - child: const Icon(Icons.add, color: Colors.white), - ), - ); - } -} - -class _ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget { - final ProjectController projectController; - - const _ExpenseAppBar({required this.projectController}); - - @override - Size get preferredSize => const Size.fromHeight(72); - - @override - Widget build(BuildContext context) { - return AppBar( - backgroundColor: const Color(0xFFF5F5F5), - elevation: 0.5, - automaticallyImplyLeading: false, - titleSpacing: 0, - title: Padding( - padding: MySpacing.xy(16, 0), - child: Row( - children: [ - IconButton( - icon: const Icon(Icons.arrow_back_ios_new, - color: Colors.black, size: 20), - onPressed: () => Get.offNamed('/dashboard'), - ), - MySpacing.width(8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.titleLarge('Expenses', fontWeight: 700), - MySpacing.height(2), - GetBuilder( - builder: (_) { - final name = projectController.selectedProject?.name ?? - 'Select Project'; - return Row( - children: [ - const Icon(Icons.work_outline, - size: 14, color: Colors.grey), - MySpacing.width(4), - Expanded( - child: MyText.bodySmall( - name, - overflow: TextOverflow.ellipsis, - color: Colors.grey[700], - fontWeight: 600, - ), - ), - ], - ); - }, - ), - ], - ), - ), - ], - ), - ), - ); - } -} - -class _SearchAndFilter extends StatelessWidget { - final TextEditingController controller; - final ValueChanged onChanged; - final VoidCallback onFilterTap; - final VoidCallback onRefreshTap; - final ExpenseController expenseController; - - const _SearchAndFilter({ - required this.controller, - required this.onChanged, - required this.onFilterTap, - required this.onRefreshTap, - required this.expenseController, - }); - - @override - Widget build(BuildContext context) { - return Padding( - padding: MySpacing.fromLTRB(12, 10, 12, 0), - child: Row( - children: [ - Expanded( - child: SizedBox( - height: 35, - child: TextField( - controller: controller, - onChanged: onChanged, - decoration: InputDecoration( - contentPadding: const EdgeInsets.symmetric(horizontal: 12), - prefixIcon: - const Icon(Icons.search, size: 20, color: Colors.grey), - hintText: 'Search expenses...', - filled: true, - fillColor: Colors.white, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: BorderSide(color: Colors.grey.shade300), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: BorderSide(color: Colors.grey.shade300), - ), - ), - ), - ), - ), - MySpacing.width(8), - Tooltip( - message: 'Refresh Data', - child: IconButton( - icon: const Icon(Icons.refresh, color: Colors.green, size: 24), - onPressed: onRefreshTap, - ), - ), - MySpacing.width(4), - Obx(() { - return IconButton( - icon: Stack( - clipBehavior: Clip.none, - children: [ - const Icon(Icons.tune, color: Colors.black), - if (expenseController.isFilterApplied) - Positioned( - top: -1, - right: -1, - child: Container( - width: 10, - height: 10, - decoration: BoxDecoration( - color: Colors.red, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 1.5), - ), - ), - ), - ], - ), - onPressed: onFilterTap, - ); - }), - ], - ), - ); - } -} - -class _ToggleButtons extends StatelessWidget { - final bool isHistoryView; - final ValueChanged onToggle; - - const _ToggleButtons({ - required this.isHistoryView, - required this.onToggle, - }); - - @override - Widget build(BuildContext context) { - return Padding( - padding: MySpacing.fromLTRB(8, 12, 8, 5), - child: Container( - padding: const EdgeInsets.all(2), - decoration: BoxDecoration( - color: const Color(0xFFF0F0F0), - borderRadius: BorderRadius.circular(10), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: Row( - children: [ - _ToggleButton( - label: 'Expenses', - icon: Icons.receipt_long, - selected: !isHistoryView, - onTap: () => onToggle(false), - ), - _ToggleButton( - label: 'History', - icon: Icons.history, - selected: isHistoryView, - onTap: () => onToggle(true), - ), - ], - ), - ), - ); - } -} - -class _ToggleButton extends StatelessWidget { - final String label; - final IconData icon; - final bool selected; - final VoidCallback onTap; - - const _ToggleButton({ - required this.label, - required this.icon, - required this.selected, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return Expanded( - child: GestureDetector( - onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 10), - decoration: BoxDecoration( - color: selected ? Colors.red : Colors.transparent, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(icon, - size: 16, color: selected ? Colors.white : Colors.grey), - const SizedBox(width: 6), - MyText.bodyMedium(label, - color: selected ? Colors.white : Colors.grey, - fontWeight: 600), - ], - ), - ), - ), - ); - } -} - -class _ExpenseList extends StatelessWidget { - final List expenseList; - final Future Function()? onViewDetail; - - const _ExpenseList({required this.expenseList, this.onViewDetail}); - - void _showDeleteConfirmation(BuildContext context, ExpenseModel expense) { - final ExpenseController controller = Get.find(); - final RxBool isDeleting = false.obs; - - showDialog( - context: context, - barrierDismissible: false, - builder: (_) => Dialog( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - child: Obx(() { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), - child: isDeleting.value - ? const SizedBox( - height: 100, - child: Center(child: CircularProgressIndicator()), - ) - : Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.delete, - size: 48, color: Colors.redAccent), - const SizedBox(height: 16), - MyText.titleLarge( - "Delete Expense", - fontWeight: 600, - color: Theme.of(context).colorScheme.onBackground, - ), - const SizedBox(height: 12), - MyText.bodySmall( - "Are you sure you want to delete this draft expense?", - textAlign: TextAlign.center, - color: Theme.of(context) - .colorScheme - .onSurface - .withOpacity(0.7), - ), - const SizedBox(height: 24), - - // Updated Button UI - Row( - children: [ - Expanded( - child: ElevatedButton.icon( - onPressed: () => Navigator.pop(context), - icon: - const Icon(Icons.close, color: Colors.white), - label: MyText.bodyMedium( - "Cancel", - color: Colors.white, - fontWeight: 600, - ), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.grey, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: - const EdgeInsets.symmetric(vertical: 12), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: ElevatedButton.icon( - onPressed: () async { - isDeleting.value = true; - await controller.deleteExpense(expense.id); - isDeleting.value = false; - Navigator.pop(context); - - showAppSnackbar( - title: 'Deleted', - message: 'Expense has been deleted.', - type: SnackbarType.success, - ); - }, - icon: const Icon(Icons.delete_forever, - color: Colors.white), - label: MyText.bodyMedium( - "Delete", - color: Colors.white, - fontWeight: 600, - ), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.redAccent, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: - const EdgeInsets.symmetric(vertical: 12), - ), - ), - ), - ], - ), - ], - ), - ); - }), - ), - ); - } - - @override - Widget build(BuildContext context) { - if (expenseList.isEmpty && !Get.find().isLoading.value) { - return Center(child: MyText.bodyMedium('No expenses found.')); - } - - return ListView.separated( - padding: const EdgeInsets.fromLTRB(12, 12, 12, 80), - itemCount: expenseList.length, - separatorBuilder: (_, __) => - Divider(color: Colors.grey.shade300, height: 20), - itemBuilder: (context, index) { - final expense = expenseList[index]; - final formattedDate = DateTimeUtils.convertUtcToLocal( - expense.transactionDate.toIso8601String(), - format: 'dd MMM yyyy, hh:mm a', - ); - - return Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(8), - onTap: () async { - final result = await Get.to( - () => ExpenseDetailScreen(expenseId: expense.id), - arguments: {'expense': expense}, - ); - if (result == true && onViewDetail != null) { - await onViewDetail!(); - } - }, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - MyText.bodyMedium(expense.expensesType.name, - fontWeight: 600), - Row( - children: [ - MyText.bodyMedium( - '₹ ${expense.amount.toStringAsFixed(2)}', - fontWeight: 600), - if (expense.status.name.toLowerCase() == 'draft') ...[ - const SizedBox(width: 8), - GestureDetector( - onTap: () => - _showDeleteConfirmation(context, expense), - child: const Icon(Icons.delete, - color: Colors.red, size: 20), - ), - ], - ], - ), - ], - ), - const SizedBox(height: 6), - Row( - children: [ - MyText.bodySmall(formattedDate, fontWeight: 500), - const Spacer(), - MyText.bodySmall(expense.status.name, fontWeight: 500), - ], - ), - ], - ), - ), - ), - ); - }, + + // ✅ FAB only if user has expenseUpload permission + floatingActionButton: permissionController + .hasPermission(Permissions.expenseUpload) + ? FloatingActionButton( + backgroundColor: Colors.red, + onPressed: showAddExpenseBottomSheet, + child: const Icon(Icons.add, color: Colors.white), + ) + : null, ); } } From 754f919cdcc8a5954b4bba03b0255bcce4dfc507 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Wed, 6 Aug 2025 16:18:50 +0530 Subject: [PATCH 61/65] added infinite loading --- .../expense/expense_screen_controller.dart | 38 ++++++++++++++++++- lib/view/expense/expense_screen.dart | 36 ++++++++++++------ 2 files changed, 61 insertions(+), 13 deletions(-) diff --git a/lib/controller/expense/expense_screen_controller.dart b/lib/controller/expense/expense_screen_controller.dart index a0a012a..0790ba5 100644 --- a/lib/controller/expense/expense_screen_controller.dart +++ b/lib/controller/expense/expense_screen_controller.dart @@ -32,7 +32,7 @@ class ExpenseController extends GetxController { final RxList selectedCreatedByEmployees = [].obs; final RxString selectedDateType = 'Transaction Date'.obs; - + final employeeSearchController = TextEditingController(); final isSearchingEmployees = false.obs; final employeeSearchResults = [].obs; @@ -283,6 +283,42 @@ class ExpenseController extends GetxController { update(); } + Future loadMoreExpenses() async { + if (isLoading.value) return; + + _pageNumber += 1; + isLoading.value = true; + + final Map filterMap = { + "projectIds": selectedProject.value.isEmpty + ? [] + : [projectsMap[selectedProject.value] ?? ''], + "statusIds": selectedStatus.value.isEmpty ? [] : [selectedStatus.value], + "createdByIds": selectedCreatedByEmployees.map((e) => e.id).toList(), + "paidByIds": selectedPaidByEmployees.map((e) => e.id).toList(), + "startDate": startDate.value?.toIso8601String(), + "endDate": endDate.value?.toIso8601String(), + "isTransactionDate": selectedDateType.value == 'Transaction Date', + }; + + try { + final result = await ApiService.getExpenseListApi( + filter: jsonEncode(filterMap), + pageSize: _pageSize, + pageNumber: _pageNumber, + ); + + if (result != null) { + final expenseResponse = ExpenseResponse.fromJson(result); + expenses.addAll(expenseResponse.data.data); + } + } catch (e) { + logSafe("Error in loadMoreExpenses: $e", level: LogLevel.error); + } finally { + isLoading.value = false; + } + } + /// Update expense status Future updateExpenseStatus(String expenseId, String statusId) async { isLoading.value = true; diff --git a/lib/view/expense/expense_screen.dart b/lib/view/expense/expense_screen.dart index d9bfd53..0309b3c 100644 --- a/lib/view/expense/expense_screen.dart +++ b/lib/view/expense/expense_screen.dart @@ -90,7 +90,8 @@ class _ExpenseMainScreenState extends State { ), Expanded( child: Obx(() { - if (expenseController.isLoading.value) { + if (expenseController.isLoading.value && + expenseController.expenses.isEmpty) { return SkeletonLoaders.expenseListSkeletonLoader(); } @@ -104,9 +105,20 @@ class _ExpenseMainScreenState extends State { } final filteredList = _getFilteredExpenses(); - return ExpenseList( - expenseList: filteredList, - onViewDetail: () => expenseController.fetchExpenses(), + + return NotificationListener( + onNotification: (ScrollNotification scrollInfo) { + if (scrollInfo.metrics.pixels == + scrollInfo.metrics.maxScrollExtent && + !expenseController.isLoading.value) { + expenseController.loadMoreExpenses(); + } + return false; + }, + child: ExpenseList( + expenseList: filteredList, + onViewDetail: () => expenseController.fetchExpenses(), + ), ); }), ), @@ -115,14 +127,14 @@ class _ExpenseMainScreenState extends State { ), // ✅ FAB only if user has expenseUpload permission - floatingActionButton: permissionController - .hasPermission(Permissions.expenseUpload) - ? FloatingActionButton( - backgroundColor: Colors.red, - onPressed: showAddExpenseBottomSheet, - child: const Icon(Icons.add, color: Colors.white), - ) - : null, + floatingActionButton: + permissionController.hasPermission(Permissions.expenseUpload) + ? FloatingActionButton( + backgroundColor: Colors.red, + onPressed: showAddExpenseBottomSheet, + child: const Icon(Icons.add, color: Colors.white), + ) + : null, ); } } From a5058cd0bcaf9ed7e5c1480bcee35f7f976c4a75 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Wed, 6 Aug 2025 16:30:34 +0530 Subject: [PATCH 62/65] managed the dropdown menu display --- .../expense/add_expense_bottom_sheet.dart | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/lib/model/expense/add_expense_bottom_sheet.dart b/lib/model/expense/add_expense_bottom_sheet.dart index 78d822f..d18e3b4 100644 --- a/lib/model/expense/add_expense_bottom_sheet.dart +++ b/lib/model/expense/add_expense_bottom_sheet.dart @@ -39,7 +39,9 @@ class _AddExpenseBottomSheet extends StatefulWidget { class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { final AddExpenseController controller = Get.put(AddExpenseController()); - + final GlobalKey _projectDropdownKey = GlobalKey(); + final GlobalKey _expenseTypeDropdownKey = GlobalKey(); + final GlobalKey _paymentModeDropdownKey = GlobalKey(); void _showEmployeeList() async { await showModalBottomSheet( context: context, @@ -66,9 +68,12 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { List options, String Function(T) getLabel, ValueChanged onSelected, + GlobalKey triggerKey, // add this param ) async { - final button = context.findRenderObject() as RenderBox; - final overlay = Overlay.of(context).context.findRenderObject() as RenderBox; + final RenderBox button = + triggerKey.currentContext!.findRenderObject() as RenderBox; + final RenderBox overlay = + Overlay.of(context).context.findRenderObject() as RenderBox; final position = button.localToGlobal(Offset.zero, ancestor: overlay); final selected = await showMenu( @@ -76,7 +81,7 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { position: RelativeRect.fromLTRB( position.dx, position.dy + button.size.height, - position.dx + button.size.width, + overlay.size.width - position.dx - button.size.width, 0, ), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), @@ -119,7 +124,9 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { controller.globalProjects.toList(), (p) => p, (val) => controller.selectedProject.value = val, + _projectDropdownKey, // pass the relevant GlobalKey here ), + dropdownKey: _projectDropdownKey, // pass key also here ), MySpacing.height(16), _buildDropdown( @@ -132,7 +139,9 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { controller.expenseTypes.toList(), (e) => e.name, (val) => controller.selectedExpenseType.value = val, + _expenseTypeDropdownKey, ), + dropdownKey: _expenseTypeDropdownKey, ), if (controller.selectedExpenseType.value?.noOfPersonsRequired == true) @@ -172,7 +181,9 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { controller.paymentModes.toList(), (p) => p.name, (val) => controller.selectedPaymentMode.value = val, + _paymentModeDropdownKey, ), + dropdownKey: _paymentModeDropdownKey, ), MySpacing.height(16), _SectionTitle( @@ -313,13 +324,18 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { required bool requiredField, required String value, required VoidCallback onTap, + required GlobalKey dropdownKey, // new param }) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _SectionTitle(icon: icon, title: title, requiredField: requiredField), MySpacing.height(6), - _DropdownTile(title: value, onTap: onTap), + _DropdownTile( + key: dropdownKey, // Pass the key here + title: value, + onTap: onTap, + ), ], ); } @@ -415,7 +431,8 @@ class _DropdownTile extends StatelessWidget { const _DropdownTile({ required this.title, required this.onTap, - }); + Key? key, // Add optional key parameter + }) : super(key: key); @override Widget build(BuildContext context) { From d205cc201420639cb99f9d6bb760ae499516398b Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Wed, 6 Aug 2025 17:45:37 +0530 Subject: [PATCH 63/65] added condition to accept date --- lib/view/expense/expense_detail_screen.dart | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/lib/view/expense/expense_detail_screen.dart b/lib/view/expense/expense_detail_screen.dart index 37e377f..8579b65 100644 --- a/lib/view/expense/expense_detail_screen.dart +++ b/lib/view/expense/expense_detail_screen.dart @@ -251,6 +251,9 @@ class _ExpenseDetailScreenState extends State { buttonColor = Color(int.parse(next.color.replaceFirst('#', '0xff'))); } catch (_) {} } + DateTime onlyDate(DateTime date) { + return DateTime(date.year, date.month, date.day); + } return ElevatedButton( style: ElevatedButton.styleFrom( @@ -295,7 +298,8 @@ class _ExpenseDetailScreenState extends State { return false; } - if (selectedReimburseDate.isBefore(transactionDate)) { + if (onlyDate(selectedReimburseDate) + .isBefore(onlyDate(transactionDate))) { showAppSnackbar( title: 'Invalid Date', message: @@ -305,7 +309,8 @@ class _ExpenseDetailScreenState extends State { return false; } - if (selectedReimburseDate.isAfter(today)) { + if (onlyDate(selectedReimburseDate) + .isAfter(onlyDate(today))) { showAppSnackbar( title: 'Invalid Date', message: 'Reimbursement date cannot be in the future.', @@ -324,7 +329,7 @@ class _ExpenseDetailScreenState extends State { ); if (success) { - Navigator.of(context).pop(); + Navigator.of(context).pop(); showAppSnackbar( title: 'Success', message: 'Expense reimbursed successfully.', From 7ec8b1e7bcf5c097d8937cd1a638aecf6e6ffe95 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Wed, 6 Aug 2025 18:14:23 +0530 Subject: [PATCH 64/65] added edge to edge in app --- lib/helpers/services/app_initializer.dart | 40 +++++++++++++++-------- lib/main.dart | 8 +++-- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/lib/helpers/services/app_initializer.dart b/lib/helpers/services/app_initializer.dart index 5d55b0c..3f11fe7 100644 --- a/lib/helpers/services/app_initializer.dart +++ b/lib/helpers/services/app_initializer.dart @@ -8,41 +8,53 @@ import 'package:marco/helpers/theme/app_theme.dart'; import 'package:url_strategy/url_strategy.dart'; import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/services/auth_service.dart'; +import 'package:flutter/material.dart'; + Future initializeApp() async { try { logSafe("💡 Starting app initialization..."); + // UI Setup setPathUrlStrategy(); - logSafe("💡 URL strategy set."); - - SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle( - statusBarColor: Color.fromARGB(255, 255, 0, 0), - statusBarIconBrightness: Brightness.light, - )); - logSafe("💡 System UI overlay style set."); + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + SystemChrome.setSystemUIOverlayStyle( + const SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + systemNavigationBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.light, + systemNavigationBarIconBrightness: Brightness.dark, + ), + ); + logSafe("💡 UI setup completed."); + // Local storage await LocalStorage.init(); logSafe("💡 Local storage initialized."); - // If a refresh token is found, try to refresh the JWT token + // Token handling final refreshToken = await LocalStorage.getRefreshToken(); - if (refreshToken != null && refreshToken.isNotEmpty) { + final hasRefreshToken = refreshToken?.isNotEmpty ?? false; + + if (hasRefreshToken) { logSafe("🔁 Refresh token found. Attempting to refresh JWT..."); final success = await AuthService.refreshToken(); - if (!success) { logSafe("⚠️ Refresh token invalid or expired. Skipping controller injection."); - // Optionally, clear tokens and force logout here if needed + // Optionally clear tokens or handle logout here } } else { logSafe("❌ No refresh token found. Skipping refresh."); } + // Theme setup await ThemeCustomizer.init(); logSafe("💡 Theme customizer initialized."); + // Controller setup final token = LocalStorage.getString('jwt_token'); - if (token != null && token.isNotEmpty) { + final hasJwt = token?.isNotEmpty ?? false; + + if (hasJwt) { if (!Get.isRegistered()) { Get.put(PermissionController()); logSafe("💡 PermissionController injected."); @@ -53,13 +65,13 @@ Future initializeApp() async { logSafe("💡 ProjectController injected as permanent."); } - // Load data into controllers if required - await Get.find().loadData(token); + await Get.find().loadData(token!); await Get.find().fetchProjects(); } else { logSafe("⚠️ No valid JWT token found. Skipping controller initialization."); } + // Final style setup AppStyle.init(); logSafe("💡 AppStyle initialized."); diff --git a/lib/main.dart b/lib/main.dart index e624408..af77945 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -66,7 +66,8 @@ class _MainWrapperState extends State { super.initState(); _initializeConnectivity(); // Listen for changes, the callback now provides a List - _connectivity.onConnectivityChanged.listen((List results) { + _connectivity.onConnectivityChanged + .listen((List results) { setState(() { _connectivityStatus = results; }); @@ -84,7 +85,8 @@ class _MainWrapperState extends State { @override Widget build(BuildContext context) { // Check if any of the connectivity results indicate no internet - final bool isOffline = _connectivityStatus.contains(ConnectivityResult.none); + final bool isOffline = + _connectivityStatus.contains(ConnectivityResult.none); // Show OfflineScreen if no internet if (isOffline) { @@ -97,4 +99,4 @@ class _MainWrapperState extends State { // Show main app if online return const MyApp(); } -} \ No newline at end of file +} From 1d17a8e1098afca510207e4042021757ebb3203a Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Wed, 6 Aug 2025 18:18:17 +0530 Subject: [PATCH 65/65] chnaged the app init --- lib/helpers/services/api_endpoints.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index ac47286..cf5e9b5 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -1,6 +1,6 @@ class ApiEndpoints { - static const String baseUrl = "https://stageapi.marcoaiot.com/api"; - // static const String baseUrl = "https://api.marcoaiot.com/api"; + // static const String baseUrl = "https://stageapi.marcoaiot.com/api"; + static const String baseUrl = "https://api.marcoaiot.com/api"; // Dashboard Module API Endpoints static const String getDashboardAttendanceOverview = "/dashboard/attendance-overview";