From 8c5035d67968b5fa080bf30d7ded79f28a978902 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Fri, 18 Jul 2025 17:51:59 +0530 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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, ), ], ),