diff --git a/lib/helpers/widgets/expense_main_components.dart b/lib/helpers/widgets/expense_main_components.dart new file mode 100644 index 0000000..d502825 --- /dev/null +++ b/lib/helpers/widgets/expense_main_components.dart @@ -0,0 +1,437 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import 'package:marco/controller/project_controller.dart'; +import 'package:marco/controller/expense/expense_screen_controller.dart'; +import 'package:marco/helpers/utils/date_time_utils.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:marco/model/expense/expense_list_model.dart'; +import 'package:marco/view/expense/expense_detail_screen.dart'; + +class ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget { + final ProjectController projectController; + + const ExpenseAppBar({required this.projectController, super.key}); + + @override + Size get preferredSize => const Size.fromHeight(72); + + @override + Widget build(BuildContext context) { + return AppBar( + backgroundColor: const Color(0xFFF5F5F5), + elevation: 0.5, + automaticallyImplyLeading: false, + titleSpacing: 0, + title: Padding( + padding: MySpacing.xy(16, 0), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back_ios_new, + color: Colors.black, size: 20), + onPressed: () => Get.offNamed('/dashboard'), + ), + MySpacing.width(8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.titleLarge('Expenses', fontWeight: 700), + MySpacing.height(2), + GetBuilder( + builder: (_) { + final name = projectController.selectedProject?.name ?? + 'Select Project'; + return Row( + children: [ + const Icon(Icons.work_outline, + size: 14, color: Colors.grey), + MySpacing.width(4), + Expanded( + child: MyText.bodySmall( + name, + overflow: TextOverflow.ellipsis, + color: Colors.grey[700], + fontWeight: 600, + ), + ), + ], + ); + }, + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class SearchAndFilter extends StatelessWidget { + final TextEditingController controller; + final ValueChanged onChanged; + final VoidCallback onFilterTap; + final VoidCallback onRefreshTap; + final ExpenseController expenseController; + + const SearchAndFilter({ + required this.controller, + required this.onChanged, + required this.onFilterTap, + required this.onRefreshTap, + required this.expenseController, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: MySpacing.fromLTRB(12, 10, 12, 0), + child: Row( + children: [ + Expanded( + child: SizedBox( + height: 35, + child: TextField( + controller: controller, + onChanged: onChanged, + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric(horizontal: 12), + prefixIcon: + const Icon(Icons.search, size: 20, color: Colors.grey), + hintText: 'Search expenses...', + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + ), + ), + ), + ), + MySpacing.width(8), + Tooltip( + message: 'Refresh Data', + child: IconButton( + icon: const Icon(Icons.refresh, color: Colors.green, size: 24), + onPressed: onRefreshTap, + ), + ), + MySpacing.width(4), + Obx(() { + return IconButton( + icon: Stack( + clipBehavior: Clip.none, + children: [ + const Icon(Icons.tune, color: Colors.black), + if (expenseController.isFilterApplied) + Positioned( + top: -1, + right: -1, + child: Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 1.5), + ), + ), + ), + ], + ), + onPressed: onFilterTap, + ); + }), + ], + ), + ); + } +} + +class ToggleButtonsRow extends StatelessWidget { + final bool isHistoryView; + final ValueChanged onToggle; + + const ToggleButtonsRow({ + required this.isHistoryView, + required this.onToggle, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: MySpacing.fromLTRB(8, 12, 8, 5), + child: Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: const Color(0xFFF0F0F0), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + _ToggleButton( + label: 'Expenses', + icon: Icons.receipt_long, + selected: !isHistoryView, + onTap: () => onToggle(false), + ), + _ToggleButton( + label: 'History', + icon: Icons.history, + selected: isHistoryView, + onTap: () => onToggle(true), + ), + ], + ), + ), + ); + } +} + +class _ToggleButton extends StatelessWidget { + final String label; + final IconData icon; + final bool selected; + final VoidCallback onTap; + + const _ToggleButton({ + required this.label, + required this.icon, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Expanded( + child: GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 10), + decoration: BoxDecoration( + color: selected ? Colors.red : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, + size: 16, color: selected ? Colors.white : Colors.grey), + const SizedBox(width: 6), + MyText.bodyMedium(label, + color: selected ? Colors.white : Colors.grey, + fontWeight: 600), + ], + ), + ), + ), + ); + } +} + +class ExpenseList extends StatelessWidget { + final List expenseList; + final Future Function()? onViewDetail; + + const ExpenseList({ + required this.expenseList, + this.onViewDetail, + super.key, + }); + + void _showDeleteConfirmation(BuildContext context, ExpenseModel expense) { + final ExpenseController controller = Get.find(); + final RxBool isDeleting = false.obs; + + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: Obx(() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: isDeleting.value + ? const SizedBox( + height: 100, + child: Center(child: CircularProgressIndicator()), + ) + : Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.delete, + size: 48, color: Colors.redAccent), + const SizedBox(height: 16), + MyText.titleLarge("Delete Expense", + fontWeight: 600, + color: + Theme.of(context).colorScheme.onBackground), + const SizedBox(height: 12), + MyText.bodySmall( + "Are you sure you want to delete this draft expense?", + textAlign: TextAlign.center, + color: Theme.of(context) + .colorScheme + .onSurface + .withOpacity(0.7), + ), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: () => Navigator.pop(context), + icon: + const Icon(Icons.close, color: Colors.white), + label: MyText.bodyMedium( + "Cancel", + color: Colors.white, + fontWeight: 600, + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.grey, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: + const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + onPressed: () async { + isDeleting.value = true; + await controller.deleteExpense(expense.id); + isDeleting.value = false; + Navigator.pop(context); + showAppSnackbar( + title: 'Deleted', + message: 'Expense has been deleted.', + type: SnackbarType.success, + ); + }, + icon: const Icon(Icons.delete_forever, + color: Colors.white), + label: MyText.bodyMedium( + "Delete", + color: Colors.white, + fontWeight: 600, + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.redAccent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: + const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + ], + ), + ], + ), + ); + }), + ), + ); + } + + @override + Widget build(BuildContext context) { + if (expenseList.isEmpty && !Get.find().isLoading.value) { + return Center(child: MyText.bodyMedium('No expenses found.')); + } + + return ListView.separated( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 80), + itemCount: expenseList.length, + separatorBuilder: (_, __) => + Divider(color: Colors.grey.shade300, height: 20), + itemBuilder: (context, index) { + final expense = expenseList[index]; + final formattedDate = DateTimeUtils.convertUtcToLocal( + expense.transactionDate.toIso8601String(), + format: 'dd MMM yyyy, hh:mm a', + ); + + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () async { + final result = await Get.to( + () => ExpenseDetailScreen(expenseId: expense.id), + arguments: {'expense': expense}, + ); + if (result == true && onViewDetail != null) { + await onViewDetail!(); + } + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + MyText.bodyMedium(expense.expensesType.name, + fontWeight: 600), + Row( + children: [ + MyText.bodyMedium( + '₹ ${expense.amount.toStringAsFixed(2)}', + fontWeight: 600), + if (expense.status.name.toLowerCase() == 'draft') ...[ + const SizedBox(width: 8), + GestureDetector( + onTap: () => + _showDeleteConfirmation(context, expense), + child: const Icon(Icons.delete, + color: Colors.red, size: 20), + ), + ], + ], + ), + ], + ), + const SizedBox(height: 6), + Row( + children: [ + MyText.bodySmall(formattedDate, fontWeight: 500), + const Spacer(), + MyText.bodySmall(expense.status.name, fontWeight: 500), + ], + ), + ], + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/view/expense/expense_detail_screen.dart b/lib/view/expense/expense_detail_screen.dart index 2cf1276..37e377f 100644 --- a/lib/view/expense/expense_detail_screen.dart +++ b/lib/view/expense/expense_detail_screen.dart @@ -268,40 +268,79 @@ class _ExpenseDetailScreenState extends State { shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16))), builder: (context) => ReimbursementBottomSheet( - expenseId: expense.id, - statusId: next.id, - onClose: () {}, - onSubmit: ({ - required String comment, - required String reimburseTransactionId, - required String reimburseDate, - required String reimburseById, - required String statusId, - }) async { - final success = - await controller.updateExpenseStatusWithReimbursement( - comment: comment, - reimburseTransactionId: reimburseTransactionId, - reimburseDate: reimburseDate, - reimburseById: reimburseById, - statusId: statusId, - ); - if (success) { - showAppSnackbar( + expenseId: expense.id, + statusId: next.id, + onClose: () {}, + onSubmit: ({ + required String comment, + required String reimburseTransactionId, + required String reimburseDate, + required String reimburseById, + required String statusId, + }) async { + final transactionDate = DateTime.tryParse( + controller.expense.value?.transactionDate ?? ''); + final selectedReimburseDate = + DateTime.tryParse(reimburseDate); + final today = DateTime.now(); + + if (transactionDate == null || + selectedReimburseDate == null) { + showAppSnackbar( + title: 'Invalid date', + message: + 'Could not parse transaction or reimbursement date.', + type: SnackbarType.error, + ); + return false; + } + + if (selectedReimburseDate.isBefore(transactionDate)) { + showAppSnackbar( + title: 'Invalid Date', + message: + 'Reimbursement date cannot be before the transaction date.', + type: SnackbarType.error, + ); + return false; + } + + if (selectedReimburseDate.isAfter(today)) { + showAppSnackbar( + title: 'Invalid Date', + message: 'Reimbursement date cannot be in the future.', + type: SnackbarType.error, + ); + return false; + } + + final success = + await controller.updateExpenseStatusWithReimbursement( + comment: comment, + reimburseTransactionId: reimburseTransactionId, + reimburseDate: reimburseDate, + reimburseById: reimburseById, + statusId: statusId, + ); + + if (success) { + Navigator.of(context).pop(); + showAppSnackbar( title: 'Success', message: 'Expense reimbursed successfully.', - type: SnackbarType.success); - await controller.fetchExpenseDetails(); - return true; - } else { - showAppSnackbar( + type: SnackbarType.success, + ); + await controller.fetchExpenseDetails(); + return true; + } else { + showAppSnackbar( title: 'Error', message: 'Failed to reimburse expense.', - type: SnackbarType.error); - return false; - } - }, - ), + type: SnackbarType.error, + ); + return false; + } + }), ); } else { final comment = await showCommentBottomSheet(context, next.name); diff --git a/lib/view/expense/expense_screen.dart b/lib/view/expense/expense_screen.dart index d20547e..d9bfd53 100644 --- a/lib/view/expense/expense_screen.dart +++ b/lib/view/expense/expense_screen.dart @@ -1,17 +1,15 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; - import 'package:marco/controller/project_controller.dart'; import 'package:marco/controller/expense/expense_screen_controller.dart'; -import 'package:marco/helpers/utils/date_time_utils.dart'; -import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/controller/permission_controller.dart'; import 'package:marco/helpers/widgets/my_text.dart'; -import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; import 'package:marco/model/expense/expense_list_model.dart'; -import 'package:marco/view/expense/expense_detail_screen.dart'; import 'package:marco/model/expense/add_expense_bottom_sheet.dart'; import 'package:marco/view/expense/expense_filter_bottom_sheet.dart'; +import 'package:marco/helpers/widgets/expense_main_components.dart'; +import 'package:marco/helpers/utils/permission_constants.dart'; class ExpenseMainScreen extends StatefulWidget { const ExpenseMainScreen({super.key}); @@ -25,6 +23,7 @@ class _ExpenseMainScreenState extends State { final searchController = TextEditingController(); final expenseController = Get.put(ExpenseController()); final projectController = Get.find(); + final permissionController = Get.find(); @override void initState() { @@ -74,18 +73,18 @@ class _ExpenseMainScreenState extends State { Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.white, - appBar: _ExpenseAppBar(projectController: projectController), + appBar: ExpenseAppBar(projectController: projectController), body: SafeArea( child: Column( children: [ - _SearchAndFilter( + SearchAndFilter( controller: searchController, onChanged: (_) => setState(() {}), onFilterTap: _openFilterBottomSheet, onRefreshTap: _refreshExpenses, expenseController: expenseController, ), - _ToggleButtons( + ToggleButtonsRow( isHistoryView: isHistoryView, onToggle: (v) => setState(() => isHistoryView = v), ), @@ -105,7 +104,7 @@ class _ExpenseMainScreenState extends State { } final filteredList = _getFilteredExpenses(); - return _ExpenseList( + return ExpenseList( expenseList: filteredList, onViewDetail: () => expenseController.fetchExpenses(), ); @@ -114,435 +113,16 @@ class _ExpenseMainScreenState extends State { ], ), ), - floatingActionButton: FloatingActionButton( - backgroundColor: Colors.red, - onPressed: showAddExpenseBottomSheet, - child: const Icon(Icons.add, color: Colors.white), - ), - ); - } -} - -class _ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget { - final ProjectController projectController; - - const _ExpenseAppBar({required this.projectController}); - - @override - Size get preferredSize => const Size.fromHeight(72); - - @override - Widget build(BuildContext context) { - return AppBar( - backgroundColor: const Color(0xFFF5F5F5), - elevation: 0.5, - automaticallyImplyLeading: false, - titleSpacing: 0, - title: Padding( - padding: MySpacing.xy(16, 0), - child: Row( - children: [ - IconButton( - icon: const Icon(Icons.arrow_back_ios_new, - color: Colors.black, size: 20), - onPressed: () => Get.offNamed('/dashboard'), - ), - MySpacing.width(8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.titleLarge('Expenses', fontWeight: 700), - MySpacing.height(2), - GetBuilder( - builder: (_) { - final name = projectController.selectedProject?.name ?? - 'Select Project'; - return Row( - children: [ - const Icon(Icons.work_outline, - size: 14, color: Colors.grey), - MySpacing.width(4), - Expanded( - child: MyText.bodySmall( - name, - overflow: TextOverflow.ellipsis, - color: Colors.grey[700], - fontWeight: 600, - ), - ), - ], - ); - }, - ), - ], - ), - ), - ], - ), - ), - ); - } -} - -class _SearchAndFilter extends StatelessWidget { - final TextEditingController controller; - final ValueChanged onChanged; - final VoidCallback onFilterTap; - final VoidCallback onRefreshTap; - final ExpenseController expenseController; - - const _SearchAndFilter({ - required this.controller, - required this.onChanged, - required this.onFilterTap, - required this.onRefreshTap, - required this.expenseController, - }); - - @override - Widget build(BuildContext context) { - return Padding( - padding: MySpacing.fromLTRB(12, 10, 12, 0), - child: Row( - children: [ - Expanded( - child: SizedBox( - height: 35, - child: TextField( - controller: controller, - onChanged: onChanged, - decoration: InputDecoration( - contentPadding: const EdgeInsets.symmetric(horizontal: 12), - prefixIcon: - const Icon(Icons.search, size: 20, color: Colors.grey), - hintText: 'Search expenses...', - filled: true, - fillColor: Colors.white, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: BorderSide(color: Colors.grey.shade300), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: BorderSide(color: Colors.grey.shade300), - ), - ), - ), - ), - ), - MySpacing.width(8), - Tooltip( - message: 'Refresh Data', - child: IconButton( - icon: const Icon(Icons.refresh, color: Colors.green, size: 24), - onPressed: onRefreshTap, - ), - ), - MySpacing.width(4), - Obx(() { - return IconButton( - icon: Stack( - clipBehavior: Clip.none, - children: [ - const Icon(Icons.tune, color: Colors.black), - if (expenseController.isFilterApplied) - Positioned( - top: -1, - right: -1, - child: Container( - width: 10, - height: 10, - decoration: BoxDecoration( - color: Colors.red, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 1.5), - ), - ), - ), - ], - ), - onPressed: onFilterTap, - ); - }), - ], - ), - ); - } -} - -class _ToggleButtons extends StatelessWidget { - final bool isHistoryView; - final ValueChanged onToggle; - - const _ToggleButtons({ - required this.isHistoryView, - required this.onToggle, - }); - - @override - Widget build(BuildContext context) { - return Padding( - padding: MySpacing.fromLTRB(8, 12, 8, 5), - child: Container( - padding: const EdgeInsets.all(2), - decoration: BoxDecoration( - color: const Color(0xFFF0F0F0), - borderRadius: BorderRadius.circular(10), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: Row( - children: [ - _ToggleButton( - label: 'Expenses', - icon: Icons.receipt_long, - selected: !isHistoryView, - onTap: () => onToggle(false), - ), - _ToggleButton( - label: 'History', - icon: Icons.history, - selected: isHistoryView, - onTap: () => onToggle(true), - ), - ], - ), - ), - ); - } -} - -class _ToggleButton extends StatelessWidget { - final String label; - final IconData icon; - final bool selected; - final VoidCallback onTap; - - const _ToggleButton({ - required this.label, - required this.icon, - required this.selected, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return Expanded( - child: GestureDetector( - onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 10), - decoration: BoxDecoration( - color: selected ? Colors.red : Colors.transparent, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(icon, - size: 16, color: selected ? Colors.white : Colors.grey), - const SizedBox(width: 6), - MyText.bodyMedium(label, - color: selected ? Colors.white : Colors.grey, - fontWeight: 600), - ], - ), - ), - ), - ); - } -} - -class _ExpenseList extends StatelessWidget { - final List expenseList; - final Future Function()? onViewDetail; - - const _ExpenseList({required this.expenseList, this.onViewDetail}); - - void _showDeleteConfirmation(BuildContext context, ExpenseModel expense) { - final ExpenseController controller = Get.find(); - final RxBool isDeleting = false.obs; - - showDialog( - context: context, - barrierDismissible: false, - builder: (_) => Dialog( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - child: Obx(() { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), - child: isDeleting.value - ? const SizedBox( - height: 100, - child: Center(child: CircularProgressIndicator()), - ) - : Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.delete, - size: 48, color: Colors.redAccent), - const SizedBox(height: 16), - MyText.titleLarge( - "Delete Expense", - fontWeight: 600, - color: Theme.of(context).colorScheme.onBackground, - ), - const SizedBox(height: 12), - MyText.bodySmall( - "Are you sure you want to delete this draft expense?", - textAlign: TextAlign.center, - color: Theme.of(context) - .colorScheme - .onSurface - .withOpacity(0.7), - ), - const SizedBox(height: 24), - - // Updated Button UI - Row( - children: [ - Expanded( - child: ElevatedButton.icon( - onPressed: () => Navigator.pop(context), - icon: - const Icon(Icons.close, color: Colors.white), - label: MyText.bodyMedium( - "Cancel", - color: Colors.white, - fontWeight: 600, - ), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.grey, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: - const EdgeInsets.symmetric(vertical: 12), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: ElevatedButton.icon( - onPressed: () async { - isDeleting.value = true; - await controller.deleteExpense(expense.id); - isDeleting.value = false; - Navigator.pop(context); - - showAppSnackbar( - title: 'Deleted', - message: 'Expense has been deleted.', - type: SnackbarType.success, - ); - }, - icon: const Icon(Icons.delete_forever, - color: Colors.white), - label: MyText.bodyMedium( - "Delete", - color: Colors.white, - fontWeight: 600, - ), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.redAccent, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: - const EdgeInsets.symmetric(vertical: 12), - ), - ), - ), - ], - ), - ], - ), - ); - }), - ), - ); - } - - @override - Widget build(BuildContext context) { - if (expenseList.isEmpty && !Get.find().isLoading.value) { - return Center(child: MyText.bodyMedium('No expenses found.')); - } - - return ListView.separated( - padding: const EdgeInsets.fromLTRB(12, 12, 12, 80), - itemCount: expenseList.length, - separatorBuilder: (_, __) => - Divider(color: Colors.grey.shade300, height: 20), - itemBuilder: (context, index) { - final expense = expenseList[index]; - final formattedDate = DateTimeUtils.convertUtcToLocal( - expense.transactionDate.toIso8601String(), - format: 'dd MMM yyyy, hh:mm a', - ); - - return Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(8), - onTap: () async { - final result = await Get.to( - () => ExpenseDetailScreen(expenseId: expense.id), - arguments: {'expense': expense}, - ); - if (result == true && onViewDetail != null) { - await onViewDetail!(); - } - }, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - MyText.bodyMedium(expense.expensesType.name, - fontWeight: 600), - Row( - children: [ - MyText.bodyMedium( - '₹ ${expense.amount.toStringAsFixed(2)}', - fontWeight: 600), - if (expense.status.name.toLowerCase() == 'draft') ...[ - const SizedBox(width: 8), - GestureDetector( - onTap: () => - _showDeleteConfirmation(context, expense), - child: const Icon(Icons.delete, - color: Colors.red, size: 20), - ), - ], - ], - ), - ], - ), - const SizedBox(height: 6), - Row( - children: [ - MyText.bodySmall(formattedDate, fontWeight: 500), - const Spacer(), - MyText.bodySmall(expense.status.name, fontWeight: 500), - ], - ), - ], - ), - ), - ), - ); - }, + + // ✅ FAB only if user has expenseUpload permission + floatingActionButton: permissionController + .hasPermission(Permissions.expenseUpload) + ? FloatingActionButton( + backgroundColor: Colors.red, + onPressed: showAddExpenseBottomSheet, + child: const Icon(Icons.add, color: Colors.white), + ) + : null, ); } }