405 lines
14 KiB
Dart
405 lines
14 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:get/get.dart';
|
|
import 'package:marco/controller/project_controller.dart';
|
|
import 'package:marco/controller/finance/payment_request_controller.dart';
|
|
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
|
|
import 'package:marco/helpers/widgets/my_spacing.dart';
|
|
import 'package:marco/helpers/widgets/my_text.dart';
|
|
import 'package:marco/model/finance/payment_request_filter_bottom_sheet.dart';
|
|
import 'package:marco/model/finance/add_payment_request_bottom_sheet.dart';
|
|
import 'package:marco/helpers/utils/date_time_utils.dart';
|
|
import 'package:marco/view/finance/payment_request_detail_screen.dart';
|
|
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
|
|
import 'package:marco/controller/permission_controller.dart';
|
|
import 'package:marco/helpers/utils/permission_constants.dart';
|
|
|
|
class PaymentRequestMainScreen extends StatefulWidget {
|
|
const PaymentRequestMainScreen({super.key});
|
|
|
|
@override
|
|
State<PaymentRequestMainScreen> createState() =>
|
|
_PaymentRequestMainScreenState();
|
|
}
|
|
|
|
class _PaymentRequestMainScreenState extends State<PaymentRequestMainScreen>
|
|
with SingleTickerProviderStateMixin, UIMixin {
|
|
late TabController _tabController;
|
|
final searchController = TextEditingController();
|
|
final paymentController = Get.put(PaymentRequestController());
|
|
final projectController = Get.find<ProjectController>();
|
|
final permissionController = Get.put(PermissionController());
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_tabController = TabController(length: 2, vsync: this);
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
paymentController.fetchPaymentRequests();
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_tabController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _refreshPaymentRequests() async {
|
|
await paymentController.fetchPaymentRequests();
|
|
}
|
|
|
|
void _openFilterBottomSheet() {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
|
),
|
|
builder: (context) => PaymentRequestFilterBottomSheet(
|
|
controller: paymentController,
|
|
scrollController: ScrollController(),
|
|
),
|
|
);
|
|
}
|
|
|
|
List filteredList({required bool isHistory}) {
|
|
final query = searchController.text.trim().toLowerCase();
|
|
final now = DateTime.now();
|
|
|
|
final filtered = paymentController.paymentRequests.where((e) {
|
|
return query.isEmpty ||
|
|
e.title.toLowerCase().contains(query) ||
|
|
e.payee.toLowerCase().contains(query);
|
|
}).toList()
|
|
..sort((a, b) => b.dueDate.compareTo(a.dueDate));
|
|
|
|
return isHistory
|
|
? filtered
|
|
.where((e) => e.dueDate.isBefore(DateTime(now.year, now.month, 1)))
|
|
.toList()
|
|
: filtered
|
|
.where((e) => e.dueDate.isAfter(DateTime(now.year, now.month, 0)))
|
|
.toList();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
backgroundColor: Colors.white,
|
|
appBar: _buildAppBar(),
|
|
body: Column(
|
|
children: [
|
|
Container(
|
|
color: Colors.white,
|
|
child: TabBar(
|
|
controller: _tabController,
|
|
labelColor: Colors.black,
|
|
unselectedLabelColor: Colors.grey,
|
|
indicatorColor: Colors.red,
|
|
tabs: const [
|
|
Tab(text: "Current Month"),
|
|
Tab(text: "History"),
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Container(
|
|
color: Colors.grey[100],
|
|
child: Column(
|
|
children: [
|
|
_buildSearchBar(),
|
|
Expanded(
|
|
child: TabBarView(
|
|
controller: _tabController,
|
|
children: [
|
|
_buildPaymentRequestList(isHistory: false),
|
|
_buildPaymentRequestList(isHistory: true),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
floatingActionButton: Obx(() {
|
|
if (permissionController.permissions.isEmpty) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
final canCreate =
|
|
permissionController.hasPermission(Permissions.expenseUpload);
|
|
|
|
return canCreate
|
|
? FloatingActionButton.extended(
|
|
backgroundColor: contentTheme.primary,
|
|
onPressed: showPaymentRequestBottomSheet,
|
|
icon: const Icon(Icons.add, color: Colors.white),
|
|
label: const Text(
|
|
"Create Payment Request",
|
|
style: TextStyle(color: Colors.white, fontSize: 16),
|
|
),
|
|
)
|
|
: const SizedBox.shrink();
|
|
}),
|
|
);
|
|
}
|
|
|
|
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(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
IconButton(
|
|
icon: const Icon(Icons.arrow_back_ios_new,
|
|
color: Colors.black, size: 20),
|
|
onPressed: () => Get.offNamed('/dashboard/finance'),
|
|
),
|
|
MySpacing.width(8),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
MyText.titleLarge(
|
|
'Payment Requests',
|
|
fontWeight: 700,
|
|
color: Colors.black,
|
|
),
|
|
MySpacing.height(2),
|
|
GetBuilder<ProjectController>(
|
|
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,
|
|
fontWeight: 600,
|
|
overflow: TextOverflow.ellipsis,
|
|
color: Colors.grey[700],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSearchBar() {
|
|
return Padding(
|
|
padding: MySpacing.fromLTRB(12, 10, 12, 0),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: SizedBox(
|
|
height: 35,
|
|
child: TextField(
|
|
controller: searchController,
|
|
onChanged: (_) => setState(() {}),
|
|
decoration: InputDecoration(
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
|
prefixIcon:
|
|
const Icon(Icons.search, size: 20, color: Colors.grey),
|
|
hintText: 'Search payment requests...',
|
|
filled: true,
|
|
fillColor: Colors.white,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(5),
|
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(5),
|
|
borderSide:
|
|
BorderSide(color: contentTheme.primary, width: 1.5),
|
|
),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(5),
|
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
MySpacing.width(4),
|
|
Obx(() {
|
|
return IconButton(
|
|
icon: Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
const Icon(Icons.tune, color: Colors.black),
|
|
if (paymentController.isFilterApplied.value)
|
|
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: _openFilterBottomSheet,
|
|
);
|
|
}),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPaymentRequestList({required bool isHistory}) {
|
|
return Obx(() {
|
|
if (paymentController.isLoading.value &&
|
|
paymentController.paymentRequests.isEmpty) {
|
|
return SkeletonLoaders.paymentRequestListSkeletonLoader();
|
|
}
|
|
|
|
final list = filteredList(isHistory: isHistory);
|
|
|
|
// Single ScrollController for this list
|
|
final scrollController = ScrollController();
|
|
|
|
// Load more when reaching near bottom
|
|
scrollController.addListener(() {
|
|
if (scrollController.position.pixels >=
|
|
scrollController.position.maxScrollExtent - 100 &&
|
|
!paymentController.isLoading.value) {
|
|
paymentController.loadMorePaymentRequests();
|
|
}
|
|
});
|
|
|
|
return RefreshIndicator(
|
|
onRefresh: _refreshPaymentRequests,
|
|
child: list.isEmpty
|
|
? ListView(
|
|
physics: const AlwaysScrollableScrollPhysics(),
|
|
children: [
|
|
SizedBox(
|
|
height: MediaQuery.of(context).size.height * 0.5,
|
|
child: Center(
|
|
child: Text(
|
|
paymentController.errorMessage.isNotEmpty
|
|
? paymentController.errorMessage.value
|
|
: "No payment requests found",
|
|
style: const TextStyle(color: Colors.grey),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
)
|
|
: ListView.separated(
|
|
controller: scrollController, // attach controller
|
|
padding: const EdgeInsets.fromLTRB(12, 12, 12, 80),
|
|
itemCount: list.length + 1, // extra item for loading
|
|
separatorBuilder: (_, __) =>
|
|
Divider(color: Colors.grey.shade300, height: 20),
|
|
itemBuilder: (context, index) {
|
|
if (index == list.length) {
|
|
// Show loading indicator at bottom
|
|
return Obx(() => paymentController.isLoading.value
|
|
? const Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 16),
|
|
child: Center(child: CircularProgressIndicator()),
|
|
)
|
|
: const SizedBox.shrink());
|
|
}
|
|
|
|
final item = list[index];
|
|
return _buildPaymentRequestTile(item);
|
|
},
|
|
),
|
|
);
|
|
});
|
|
}
|
|
|
|
Widget _buildPaymentRequestTile(dynamic item) {
|
|
final dueDate =
|
|
DateTimeUtils.formatDate(item.dueDate, DateTimeUtils.defaultFormat);
|
|
|
|
return Material(
|
|
color: Colors.transparent,
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(8),
|
|
onTap: () {
|
|
// Navigate to detail screen, passing the payment request ID
|
|
Get.to(() => PaymentRequestDetailScreen(paymentRequestId: item.id));
|
|
},
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
MyText.bodyMedium(item.expenseCategory.name, fontWeight: 600),
|
|
],
|
|
),
|
|
const SizedBox(height: 6),
|
|
Row(
|
|
children: [
|
|
MyText.bodySmall("Payee: ", color: Colors.grey[600]),
|
|
MyText.bodySmall(item.payee, fontWeight: 600),
|
|
],
|
|
),
|
|
const SizedBox(height: 6),
|
|
Row(
|
|
children: [
|
|
Row(
|
|
children: [
|
|
MyText.bodySmall("Due Date: ", color: Colors.grey[600]),
|
|
MyText.bodySmall(dueDate, fontWeight: 600),
|
|
],
|
|
),
|
|
const Spacer(),
|
|
Container(
|
|
padding:
|
|
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: Color(int.parse(
|
|
'0xff${item.expenseStatus.color.substring(1)}'))
|
|
.withOpacity(0.5),
|
|
borderRadius: BorderRadius.circular(5),
|
|
),
|
|
child: MyText.bodySmall(
|
|
item.expenseStatus.name,
|
|
color: Colors.white,
|
|
fontWeight: 500,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|