added finance code

This commit is contained in:
Vaibhav Surve 2025-11-08 15:46:28 +05:30
parent eb46194679
commit 1070f04d1a
9 changed files with 1462 additions and 7 deletions

View File

@ -0,0 +1,144 @@
import 'dart:async';
import 'package:get/get.dart';
import 'package:marco/model/finance/advance_payment_model.dart';
import 'package:marco/model/finance/get_employee_model.dart';
import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/api_service.dart';
class AdvancePaymentController extends GetxController {
/// Advance payments list
var payments = <AdvancePayment>[].obs;
var isLoading = false.obs;
/// Employees for dropdown search
var employees = <Employee>[].obs;
var allEmployees = <Employee>[]; // cache of last API response
var employeesLoading = false.obs;
var searchQuery = ''.obs;
var selectedEmployee = Rxn<Employee>();
/// Prevents unwanted API calls while programmatically updating search
var _suppressSearch = false.obs;
Timer? _debounceTimer;
@override
void onInit() {
super.onInit();
ever<String>(searchQuery, (q) {
if (_suppressSearch.value) return; // Skip while selecting employee
// 🔹 When user types new text, clear previous employee + payments instantly
if (selectedEmployee.value != null) {
selectedEmployee.value = null;
payments.clear();
}
// 🔹 Show fresh dropdown results for new query
_debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 400), () {
if (q.isNotEmpty) {
fetchEmployees(q); // repopulate dropdown
} else {
employees.clear(); // hide dropdown when search cleared
}
});
});
}
@override
void onClose() {
_debounceTimer?.cancel();
super.onClose();
}
/// Fetch employees by query
Future<void> fetchEmployees(String q) async {
if (q.isEmpty) {
employees.clear();
return;
}
if (employeesLoading.value) return;
try {
employeesLoading.value = true;
final list = await ApiService.getEmployees(query: q);
final parsed = Employee.listFromJson(list);
logSafe("✅ Employees fetched from API: ${parsed.map((e) => e.name).toList()}");
// Save full result and filter locally
allEmployees = parsed;
_filterEmployees(q);
} catch (e, s) {
logSafe("❌ fetchEmployees error: $e\n$s", level: LogLevel.error);
employees.clear();
} finally {
employeesLoading.value = false;
}
}
/// Local filter to update list based on search text
void _filterEmployees(String query) {
final q = query.toLowerCase();
employees
..clear()
..addAll(allEmployees.where((e) {
return e.name.toLowerCase().contains(q) ||
e.email.toLowerCase().contains(q);
}));
}
/// When user selects employee
void selectEmployee(Employee emp) {
_suppressSearch.value = true;
selectedEmployee.value = emp;
employees.clear(); // hide dropdown
searchQuery.value = emp.name;
fetchAdvancePayments(emp.id);
// Re-enable search after a short delay
Future.delayed(const Duration(milliseconds: 300), () {
_suppressSearch.value = false;
});
}
/// Fetch advance payments for the selected employee
Future<void> fetchAdvancePayments(String employeeId) async {
if (employeeId.isEmpty) {
payments.clear();
return;
}
try {
isLoading.value = true;
final list = await ApiService.getAdvancePayments(employeeId);
payments.assignAll(list);
} catch (e, s) {
logSafe("❌ fetchAdvancePayments error: $e\n$s", level: LogLevel.error);
payments.clear();
} finally {
isLoading.value = false;
}
}
/// Clear employee selection
void clearSelection() {
selectedEmployee.value = null;
payments.clear();
employees.clear();
searchQuery.value = '';
}
void resetSelectionOnNewSearch() {
if (selectedEmployee.value != null) {
selectedEmployee.value = null;
payments.clear();
}
}
}

View File

@ -15,6 +15,9 @@ class ApiEndpoints {
static const String getExpenseTypeReport = "/Dashboard/expense/type"; static const String getExpenseTypeReport = "/Dashboard/expense/type";
static const String getPendingExpenses = "/Dashboard/expense/pendings"; static const String getPendingExpenses = "/Dashboard/expense/pendings";
///// Projects Module API Endpoints
static const String createProject = "/project";
// Attendance Module API Endpoints // Attendance Module API Endpoints
static const String getProjects = "/project/list"; static const String getProjects = "/project/list";
static const String getGlobalProjects = "/project/list/basic"; static const String getGlobalProjects = "/project/list/basic";
@ -104,4 +107,5 @@ class ApiEndpoints {
static const getAllOrganizations = "/organization/list"; static const getAllOrganizations = "/organization/list";
static const String getAssignedServices = "/Project/get/assigned/services"; static const String getAssignedServices = "/Project/get/assigned/services";
static const String getAdvancePayments = '/Expense/get/transactions';
} }

View File

@ -26,13 +26,14 @@ import 'package:marco/model/all_organization_model.dart';
import 'package:marco/model/dashboard/pending_expenses_model.dart'; import 'package:marco/model/dashboard/pending_expenses_model.dart';
import 'package:marco/model/dashboard/expense_type_report_model.dart'; import 'package:marco/model/dashboard/expense_type_report_model.dart';
import 'package:marco/model/dashboard/monthly_expence_model.dart'; import 'package:marco/model/dashboard/monthly_expence_model.dart';
import 'package:marco/model/finance/advance_payment_model.dart';
class ApiService { class ApiService {
static const bool enableLogs = true; static const bool enableLogs = true;
static const Duration extendedTimeout = Duration(seconds: 60); static const Duration extendedTimeout = Duration(seconds: 60);
static Future<String?> _getToken() async { static Future<String?> _getToken() async {
final token = await LocalStorage.getJwtToken(); final token = LocalStorage.getJwtToken();
if (token == null) { if (token == null) {
logSafe("No JWT token found. Logging out..."); logSafe("No JWT token found. Logging out...");
@ -45,7 +46,7 @@ class ApiService {
logSafe("Access token is expired. Attempting refresh..."); logSafe("Access token is expired. Attempting refresh...");
final refreshed = await AuthService.refreshToken(); final refreshed = await AuthService.refreshToken();
if (refreshed) { if (refreshed) {
return await LocalStorage.getJwtToken(); return LocalStorage.getJwtToken();
} else { } else {
logSafe("Token refresh failed. Logging out immediately..."); logSafe("Token refresh failed. Logging out immediately...");
await LocalStorage.logout(); await LocalStorage.logout();
@ -62,7 +63,7 @@ class ApiService {
"Access token is about to expire in ${difference.inSeconds}s. Refreshing..."); "Access token is about to expire in ${difference.inSeconds}s. Refreshing...");
final refreshed = await AuthService.refreshToken(); final refreshed = await AuthService.refreshToken();
if (refreshed) { if (refreshed) {
return await LocalStorage.getJwtToken(); return LocalStorage.getJwtToken();
} else { } else {
logSafe("Token refresh failed (near expiry). Logging out..."); logSafe("Token refresh failed (near expiry). Logging out...");
await LocalStorage.logout(); await LocalStorage.logout();
@ -1314,6 +1315,73 @@ class ApiService {
} }
} }
static Future<List<AdvancePayment>> getAdvancePayments(
String employeeId) async {
try {
final endpoint = "${ApiEndpoints.getAdvancePayments}/$employeeId";
final response = await _getRequest(endpoint);
if (response == null) {
logSafe("❌ getAdvancePayments: Null response");
return [];
}
if (response.statusCode == 200) {
// 🟢 Added log to inspect raw JSON
logSafe("🔍 AdvancePayment raw response: ${response.body}");
final Map<String, dynamic> body = jsonDecode(response.body);
final data = body['data'] ?? body;
return AdvancePayment.listFromJson(data);
} else {
logSafe("⚠ getAdvancePayments failed → ${response.statusCode}");
return [];
}
} catch (e, s) {
logSafe("❌ ApiService.getAdvancePayments error: $e\n$s",
level: LogLevel.error);
return [];
}
}
/// Fetch employees with optional query. Returns raw list (List<dynamic>)
static Future<List<dynamic>> getEmployees({String query = ''}) async {
try {
// endpoint relative to ApiEndpoints.baseUrl; _getRequest builds full url
var endpoint = ApiEndpoints.getEmployeesWithoutPermission;
Map<String, String>? queryParams;
if (query.isNotEmpty) {
// server may expect a query param name other than 'q'. Adjust if needed.
queryParams = {'q': query};
}
final resp = await _getRequest(endpoint, queryParams: queryParams);
if (resp == null) return [];
// parse response
try {
final body = jsonDecode(resp.body);
if (body is Map && body.containsKey('data')) {
final data = body['data'];
if (data is List) return data;
return [];
} else if (body is List) {
return body;
} else {
return [];
}
} catch (e, s) {
logSafe("❌ ApiService.getEmployees: parse error $e\n$s",
level: LogLevel.error);
return [];
}
} catch (e, s) {
logSafe("❌ ApiService.getEmployees error: $e\n$s", level: LogLevel.error);
return [];
}
}
/// Fetch Master Payment Modes /// Fetch Master Payment Modes
static Future<List<dynamic>?> getMasterPaymentModes() async { static Future<List<dynamic>?> getMasterPaymentModes() async {
const endpoint = ApiEndpoints.getMasterPaymentModes; const endpoint = ApiEndpoints.getMasterPaymentModes;

View File

@ -0,0 +1,63 @@
class AdvancePayment {
final String id;
final String title;
final String name;
final double amount;
final double balance;
final String date;
const AdvancePayment({
required this.id,
required this.title,
required this.name,
required this.amount,
required this.balance,
required this.date,
});
factory AdvancePayment.fromJson(Map<String, dynamic> json) {
double parseDouble(dynamic value) {
if (value == null) return 0.0;
if (value is num) return value.toDouble();
if (value is String) return double.tryParse(value) ?? 0.0;
return 0.0;
}
String extractProjectName(dynamic project) {
if (project is Map && project.containsKey('name')) {
return project['name']?.toString() ?? '';
} else if (project is String) {
return project;
}
return '';
}
return AdvancePayment(
id: json['id']?.toString() ?? '',
// 👇 Fallback for APIs using "description" instead of "title"
title: json['title']?.toString() ?? json['description']?.toString() ?? '',
name: extractProjectName(json['project']),
amount: parseDouble(json['amount']),
balance: parseDouble(json['currentBalance']),
date: json['paidAt']?.toString() ?? json['createdAt']?.toString() ?? '',
);
}
static List<AdvancePayment> listFromJson(dynamic data) {
if (data is List) {
return data
.map((e) => e is Map<String, dynamic>
? AdvancePayment.fromJson(Map<String, dynamic>.from(e))
: const AdvancePayment(
id: '',
title: '',
name: '',
amount: 0,
balance: 0,
date: '',
))
.toList();
}
return [];
}
}

View File

@ -0,0 +1,102 @@
class Employee {
final String id;
final String name;
final String email;
Employee({
required this.id,
required this.name,
required this.email,
});
/// Computed getters for first and last names
String get firstName {
final parts = name.trim().split(RegExp(r'\s+'));
return parts.isNotEmpty ? parts.first : '';
}
String get lastName {
final parts = name.trim().split(RegExp(r'\s+'));
if (parts.length > 1) {
return parts.sublist(1).join(' ');
}
return '';
}
factory Employee.fromJson(Map<String, dynamic> json) {
// Try many possible id fields
final idVal = (json['id'] ??
json['Id'] ??
json['employeeId'] ??
json['empId'] ??
json['employee_id'])
?.toString();
// Try many possible first/last name fields
final first = (json['firstName'] ??
json['first_name'] ??
json['firstname'] ??
json['fname'] ??
'')
.toString()
.trim();
final last = (json['lastName'] ??
json['last_name'] ??
json['lastname'] ??
json['lname'] ??
'')
.toString()
.trim();
// Name may come as a single field in multiple variants
String nameVal = (json['name'] ??
json['Name'] ??
json['fullName'] ??
json['full_name'] ??
json['employeeName'] ??
json['employee_name'] ??
json['empName'] ??
json['employee'] ??
'')
.toString()
.trim();
// If separate first/last found and name empty, combine them
if (nameVal.isEmpty && (first.isNotEmpty || last.isNotEmpty)) {
nameVal = ('$first ${last}').trim();
}
// If name still empty, fallback to email or id to avoid blank name
if (nameVal.isEmpty && (json['email'] != null)) {
nameVal = json['email'].toString().split('@').first;
}
if (nameVal.isEmpty) {
nameVal = idVal ?? '';
}
final emailVal = (json['email'] ??
json['emailAddress'] ??
json['email_address'] ??
json['employeeEmail'] ??
json['employee_email'] ??
'')
.toString();
return Employee(
id: idVal ?? '',
name: nameVal,
email: emailVal,
);
}
static List<Employee> listFromJson(dynamic data) {
if (data is List) {
return data.map((e) {
if (e is Map<String, dynamic>) return Employee.fromJson(e);
return Employee.fromJson(Map<String, dynamic>.from(e));
}).toList();
}
// In case API returns { data: [...] }, that should already be unwrapped by ApiService
return [];
}
}

View File

@ -21,7 +21,8 @@ import 'package:marco/view/directory/directory_main_screen.dart';
import 'package:marco/view/expense/expense_screen.dart'; import 'package:marco/view/expense/expense_screen.dart';
import 'package:marco/view/document/user_document_screen.dart'; import 'package:marco/view/document/user_document_screen.dart';
import 'package:marco/view/tenant/tenant_selection_screen.dart'; import 'package:marco/view/tenant/tenant_selection_screen.dart';
import 'package:marco/view/finance/finance_screen.dart';
import 'package:marco/view/finance/advance_payment_screen.dart';
class AuthMiddleware extends GetMiddleware { class AuthMiddleware extends GetMiddleware {
@override @override
RouteSettings? redirect(String? route) { RouteSettings? redirect(String? route) {
@ -114,6 +115,19 @@ getPageRoute() {
name: '/error/404', name: '/error/404',
page: () => Error404Screen(), page: () => Error404Screen(),
middlewares: [AuthMiddleware()]), middlewares: [AuthMiddleware()]),
// Finance
GetPage(
name: '/dashboard/finance',
page: () => FinanceScreen(),
middlewares: [AuthMiddleware()],
),
// Advance Payment
GetPage(
name: '/dashboard/finance/advance-payment',
page: () => AdvancePaymentScreen(),
middlewares: [AuthMiddleware()],
),
]; ];
return routes return routes
.map((e) => GetPage( .map((e) => GetPage(

View File

@ -30,7 +30,7 @@ class DashboardScreen extends StatefulWidget {
static const String dailyTasksProgressRoute = static const String dailyTasksProgressRoute =
"/dashboard/daily-task-progress"; "/dashboard/daily-task-progress";
static const String directoryMainPageRoute = "/dashboard/directory-main-page"; static const String directoryMainPageRoute = "/dashboard/directory-main-page";
static const String expenseMainPageRoute = "/dashboard/expense-main-page"; static const String financeMainPageRoute = "/dashboard/finance";
static const String documentMainPageRoute = "/dashboard/document-main-page"; static const String documentMainPageRoute = "/dashboard/document-main-page";
@override @override
@ -249,8 +249,8 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
contentTheme.info, DashboardScreen.dailyTasksProgressRoute), contentTheme.info, DashboardScreen.dailyTasksProgressRoute),
_StatItem(LucideIcons.folder, "Directory", contentTheme.info, _StatItem(LucideIcons.folder, "Directory", contentTheme.info,
DashboardScreen.directoryMainPageRoute), DashboardScreen.directoryMainPageRoute),
_StatItem(LucideIcons.badge_dollar_sign, "Expense", contentTheme.info, _StatItem(LucideIcons.wallet, "Finance", contentTheme.info,
DashboardScreen.expenseMainPageRoute), DashboardScreen.financeMainPageRoute),
_StatItem(LucideIcons.file_text, "Documents", contentTheme.info, _StatItem(LucideIcons.file_text, "Documents", contentTheme.info,
DashboardScreen.documentMainPageRoute), DashboardScreen.documentMainPageRoute),
]; ];

View File

@ -0,0 +1,471 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/controller/finance/advance_payment_controller.dart';
import 'package:marco/controller/project_controller.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
class AdvancePaymentScreen extends StatefulWidget {
const AdvancePaymentScreen({super.key});
@override
State<AdvancePaymentScreen> createState() => _AdvancePaymentScreenState();
}
class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
with UIMixin {
late final AdvancePaymentController controller;
late final TextEditingController _searchCtrl;
final FocusNode _searchFocus = FocusNode();
@override
void initState() {
super.initState();
controller = Get.put(AdvancePaymentController());
_searchCtrl = TextEditingController();
WidgetsBinding.instance.addPostFrameCallback((_) {
final employeeId = Get.arguments?['employeeId'] ?? '';
if (employeeId.isNotEmpty) {
controller.fetchAdvancePayments(employeeId);
}
});
controller.searchQuery.listen((q) {
if (_searchCtrl.text != q) _searchCtrl.text = q;
});
}
@override
void dispose() {
_searchCtrl.dispose();
_searchFocus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final projectController = Get.find<ProjectController>();
return Scaffold(
backgroundColor: const Color(
0xFFF5F5F5), // light grey background (Expense screen style)
appBar: _buildAppBar(projectController),
body: GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: RefreshIndicator(
onRefresh: () async {
final emp = controller.selectedEmployee.value;
if (emp != null) {
await controller.fetchAdvancePayments(emp.id.toString());
}
},
color: Colors.white, // spinner color
backgroundColor: Colors.blue, // circle background color
strokeWidth: 2.5,
displacement: 60,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: Container(
color:
const Color(0xFFF5F5F5), // match background inside scroll
child: Column(
children: [
_buildSearchBar(),
_buildEmployeeDropdown(context),
_buildTopBalance(),
_buildPaymentList(),
],
),
),
),
),
),
);
}
// ---------------- AppBar ----------------
PreferredSizeWidget _buildAppBar(ProjectController projectController) {
return AppBar(
backgroundColor: Colors.grey[100],
elevation: 0.5,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () => Get.offNamed('/dashboard/finance'),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleLarge("Advance Payment",
fontWeight: 700, color: Colors.black),
MySpacing.height(2),
GetBuilder<ProjectController>(
builder: (_) {
final projectName =
projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
projectName,
overflow: TextOverflow.ellipsis,
fontWeight: 600,
color: Colors.grey[700],
),
),
],
);
},
),
],
),
),
],
),
),
);
}
// ---------------- Search ----------------
Widget _buildSearchBar() {
return Container(
color: Colors.grey[100],
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
Expanded(
child: SizedBox(
height: 38,
child: TextField(
controller: _searchCtrl,
focusNode: _searchFocus,
onChanged: (v) => controller.searchQuery.value = v.trim(),
decoration: InputDecoration(
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 0),
prefixIcon:
const Icon(Icons.search, size: 20, color: Colors.grey),
hintText: 'Search Employee...',
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide:
BorderSide(color: Colors.grey.shade300, width: 1),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide:
BorderSide(color: Colors.grey.shade300, width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide:
BorderSide(color: contentTheme.primary, width: 1.5),
),
),
),
),
),
const SizedBox(width: 4),
IconButton(
icon: const Icon(Icons.tune, color: Colors.black),
onPressed: () {},
),
],
),
);
}
// ---------------- Employee Dropdown ----------------
Widget _buildEmployeeDropdown(BuildContext context) {
return Obx(() {
if (controller.employees.isEmpty ||
controller.selectedEmployee.value != null) {
return const SizedBox.shrink();
}
return Container(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 6,
offset: const Offset(0, 3))
],
),
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.4,
),
child: ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 6),
shrinkWrap: true,
physics: const BouncingScrollPhysics(),
itemCount: controller.employees.length,
separatorBuilder: (_, __) =>
Divider(height: 1, color: Colors.grey.shade200),
itemBuilder: (_, i) => _buildEmployeeItem(controller.employees[i]),
),
);
});
}
Widget _buildEmployeeItem(dynamic e) {
return InkWell(
onTap: () {
controller.selectEmployee(e);
_searchCtrl.text = e.name;
controller.searchQuery.value = e.name;
FocusScope.of(context).unfocus();
SystemChannels.textInput.invokeMethod('TextInput.hide');
controller.employees.clear();
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
CircleAvatar(
radius: 18,
backgroundColor: _avatarColorFor(e.name),
child: Text(
_initials(e.firstName, e.lastName),
style: const TextStyle(color: Colors.white),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(e.name,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Colors.black87)),
if (e.email.isNotEmpty)
Text(e.email,
style: TextStyle(
fontSize: 13, color: Colors.grey.shade600)),
],
),
),
],
),
),
);
}
// ---------------- Current Balance ----------------
Widget _buildTopBalance() {
return Obx(() {
if (controller.payments.isEmpty) return const SizedBox.shrink();
final bal = controller.payments.first.balance.truncate();
return Container(
width: double.infinity,
color: Colors.grey[100],
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
mainAxisAlignment:
MainAxisAlignment.end,
children: [
const Text(
"Current Balance : ",
style: TextStyle(
fontWeight: FontWeight.w600,
color: Colors.green,
fontSize: 22,
),
),
Text(
"${_formatAmount(bal)}",
style: const TextStyle(
fontWeight: FontWeight.bold,
color: Colors.green,
fontSize: 22,
),
),
],
),
);
});
}
// ---------------- Payments List ----------------
Widget _buildPaymentList() {
return Obx(() {
if (controller.isLoading.value) {
return const Padding(
padding: EdgeInsets.only(top: 100),
child: Center(child: CircularProgressIndicator(color: Colors.blue)),
);
}
// No employee selected yet
if (controller.selectedEmployee.value == null) {
return const Padding(
padding: EdgeInsets.only(top: 100),
child: Center(child: Text("Please select an Employee")),
);
}
// Employee selected but no payments found
if (controller.payments.isEmpty) {
return const Padding(
padding: EdgeInsets.only(top: 100),
child: Center(
child: Text("No advance payment transactions found."),
),
);
}
// Payments available
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 6),
itemCount: controller.payments.length,
itemBuilder: (context, index) =>
_buildPaymentItem(controller.payments[index]),
);
});
}
// ---------------- Payment Item ----------------
Widget _buildPaymentItem(dynamic item) {
final dateStr = (item.date ?? '').toString();
DateTime? parsedDate;
try {
parsedDate = DateTime.parse(dateStr);
} catch (_) {}
final formattedDate = parsedDate != null
? DateFormat('dd MMM yyyy').format(parsedDate)
: (dateStr.isNotEmpty ? dateStr : '');
final formattedTime =
parsedDate != null ? DateFormat('hh:mm a').format(parsedDate) : '';
final project = item.name ?? '';
final desc = item.title ?? '';
final amount = (item.amount ?? 0).toDouble();
final isCredit = amount >= 0;
final accentColor = isCredit ? Colors.green.shade700 : Colors.red.shade700;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: Colors.grey[100],
border: Border(
bottom: BorderSide(color: Color(0xFFE0E0E0), width: 0.9),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
formattedDate,
style:
TextStyle(fontSize: 12, color: Colors.grey.shade600),
),
if (formattedTime.isNotEmpty) ...[
const SizedBox(width: 6),
Text(
formattedTime,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade500,
fontStyle: FontStyle.italic),
),
]
],
),
const SizedBox(height: 4),
Text(
project.isNotEmpty ? project : 'No Project',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Colors.black,
),
),
const SizedBox(height: 4),
Text(
desc.isNotEmpty ? desc : 'No Details',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade700,
),
),
],
),
),
const SizedBox(width: 8),
Text(
"${isCredit ? '+' : '-'}${_formatAmount(amount)}",
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: accentColor,
),
),
],
),
);
}
// ---------------- Utilities ----------------
String _initials(String? firstName, [String? lastName]) {
if ((firstName?.isEmpty ?? true) && (lastName?.isEmpty ?? true)) return '?';
return ((firstName?.isNotEmpty == true ? firstName![0] : '') +
(lastName?.isNotEmpty == true ? lastName![0] : ''))
.toUpperCase();
}
String _formatAmount(num amount) {
final format = NumberFormat('#,##,###.##', 'en_IN');
return format.format(amount);
}
static Color _avatarColorFor(String name) {
final colors = [
Colors.green,
Colors.indigo,
Colors.orange,
Colors.blueGrey,
Colors.deepPurple,
Colors.teal,
Colors.amber,
];
final hash = name.codeUnits.fold(0, (p, e) => p + e);
return colors[hash % colors.length];
}
}

View File

@ -0,0 +1,589 @@
import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:get/get.dart';
import 'package:marco/controller/project_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';
class FinanceScreen extends StatefulWidget {
const FinanceScreen({super.key});
@override
State<FinanceScreen> createState() => _FinanceScreenState();
}
class _FinanceScreenState extends State<FinanceScreen>
with UIMixin, TickerProviderStateMixin {
final projectController = Get.find<ProjectController>();
late AnimationController _animationController;
late Animation<double> _fadeAnimation;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: const Duration(milliseconds: 800),
vsync: this,
);
_fadeAnimation = CurvedAnimation(
parent: _animationController,
curve: Curves.easeInOut,
);
_animationController.forward();
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF8F9FA),
appBar: PreferredSize(
preferredSize: const Size.fromHeight(72),
child: AppBar(
backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.5,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: MySpacing.xy(16, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () => Get.offNamed('/dashboard'),
),
MySpacing.width(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
MyText.titleLarge(
'Finance',
fontWeight: 700,
color: Colors.black,
),
MySpacing.height(2),
GetBuilder<ProjectController>(
builder: (projectController) {
final projectName =
projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
projectName,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
},
),
],
),
),
],
),
),
),
),
body: FadeTransition(
opacity: _fadeAnimation,
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildWelcomeSection(),
MySpacing.height(24),
_buildFinanceModules(),
MySpacing.height(24),
_buildQuickStatsSection(),
],
),
),
),
);
}
Widget _buildWelcomeSection() {
final projectSelected = projectController.selectedProject != null;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
contentTheme.primary.withValues(alpha: 0.1),
contentTheme.info.withValues(alpha: 0.05),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: contentTheme.primary.withValues(alpha: 0.2),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: contentTheme.primary.withValues(alpha: 0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Icon(
LucideIcons.landmark,
color: contentTheme.primary,
size: 24,
),
),
MySpacing.width(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleMedium(
'Financial Management',
fontWeight: 700,
color: Colors.black87,
),
MySpacing.height(2),
MyText.bodySmall(
projectSelected
? 'Manage your project finances'
: 'Select a project to get started',
color: Colors.grey[600],
),
],
),
),
],
),
if (!projectSelected) ...[
MySpacing.height(12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.orange.withValues(alpha: 0.3)),
),
child: Row(
children: [
Icon(
LucideIcons.badge_alert,
size: 16,
color: Colors.orange[700],
),
MySpacing.width(8),
Expanded(
child: MyText.bodySmall(
'Please select a project to access finance modules',
color: Colors.orange[700],
fontWeight: 500,
),
),
],
),
),
],
],
),
);
}
Widget _buildFinanceModules() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleMedium(
'Finance Modules',
fontWeight: 700,
color: Colors.black87,
),
MySpacing.height(4),
MyText.bodySmall(
'Select a module to manage',
color: Colors.grey[600],
),
MySpacing.height(16),
_buildModuleGrid(),
],
);
}
Widget _buildModuleGrid() {
final stats = [
_FinanceStatItem(
LucideIcons.badge_dollar_sign,
"Expense",
"Track and manage expenses",
contentTheme.info,
"/dashboard/expense-main-page",
),
_FinanceStatItem(
LucideIcons.receipt_text,
"Payment Request",
"Submit payment requests",
contentTheme.primary,
"/dashboard/payment-request",
),
_FinanceStatItem(
LucideIcons.wallet,
"Advance Payment",
"Manage advance payments",
contentTheme.warning,
"/dashboard/finance/advance-payment",
),
];
final projectSelected = projectController.selectedProject != null;
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: 1.1,
),
itemCount: stats.length,
itemBuilder: (context, index) {
return _buildModernFinanceCard(
stats[index],
projectSelected,
index,
);
},
);
}
Widget _buildModernFinanceCard(
_FinanceStatItem statItem,
bool isProjectSelected,
int index,
) {
final bool isEnabled = isProjectSelected;
return TweenAnimationBuilder<double>(
duration: Duration(milliseconds: 400 + (index * 100)),
tween: Tween(begin: 0.0, end: 1.0),
curve: Curves.easeOutCubic,
builder: (context, value, child) {
return Transform.scale(
scale: value,
child: Opacity(
opacity: isEnabled ? 1.0 : 0.5,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () => _onCardTap(statItem, isEnabled),
borderRadius: BorderRadius.circular(16),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isEnabled
? statItem.color.withValues(alpha: 0.2)
: Colors.grey.withValues(alpha: 0.2),
width: 1.5,
),
),
child: Stack(
children: [
// Content
Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(12),
child: Icon(
statItem.icon,
size: 28,
color: statItem.color,
),
),
MySpacing.height(12),
MyText.titleSmall(
statItem.title,
fontWeight: 700,
color: Colors.black87,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
MySpacing.height(4),
if (isEnabled)
Row(
children: [
MyText.bodySmall(
'View Details',
color: statItem.color,
fontWeight: 600,
fontSize: 11,
),
MySpacing.width(4),
Icon(
LucideIcons.arrow_right,
size: 14,
color: statItem.color,
),
],
),
],
),
),
// Lock icon for disabled state
if (!isEnabled)
Positioned(
top: 12,
right: 12,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(6),
),
child: Icon(
LucideIcons.lock,
size: 14,
color: Colors.grey[600],
),
),
),
],
),
),
),
),
),
);
},
);
}
Widget _buildQuickStatsSection() {
final projectSelected = projectController.selectedProject != null;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleMedium(
'Quick Stats',
fontWeight: 700,
color: Colors.black87,
),
MySpacing.height(4),
MyText.bodySmall(
'Overview of your finances',
color: Colors.grey[600],
),
MySpacing.height(16),
_buildStatsRow(projectSelected),
],
);
}
Widget _buildStatsRow(bool projectSelected) {
final stats = [
_QuickStat(
icon: LucideIcons.trending_up,
label: 'Total Expenses',
value: projectSelected ? '₹0' : '--',
color: contentTheme.danger,
),
_QuickStat(
icon: LucideIcons.clock,
label: 'Pending',
value: projectSelected ? '0' : '--',
color: contentTheme.warning,
),
];
return Row(
children: stats
.map((stat) => Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 8),
child: _buildStatCard(stat, projectSelected),
),
))
.toList(),
);
}
Widget _buildStatCard(_QuickStat stat, bool isEnabled) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: stat.color.withValues(alpha: 0.2),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: stat.color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
stat.icon,
size: 20,
color: stat.color,
),
),
MySpacing.height(12),
MyText.bodySmall(
stat.label,
color: Colors.grey[600],
fontSize: 11,
),
MySpacing.height(4),
MyText.titleLarge(
stat.value,
fontWeight: 700,
color: isEnabled ? Colors.black87 : Colors.grey[400],
),
],
),
);
}
void _onCardTap(_FinanceStatItem statItem, bool isEnabled) {
if (!isEnabled) {
Get.dialog(
Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(
LucideIcons.badge_alert,
color: Colors.orange[700],
size: 32,
),
),
MySpacing.height(16),
MyText.titleMedium(
"No Project Selected",
fontWeight: 700,
color: Colors.black87,
textAlign: TextAlign.center,
),
MySpacing.height(8),
MyText.bodyMedium(
"Please select a project before accessing this section.",
color: Colors.grey[600],
textAlign: TextAlign.center,
),
MySpacing.height(24),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => Get.back(),
style: ElevatedButton.styleFrom(
backgroundColor: contentTheme.primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
elevation: 0,
),
child: MyText.bodyMedium(
"OK",
color: Colors.white,
fontWeight: 600,
),
),
),
],
),
),
),
);
return;
}
Get.toNamed(statItem.route);
}
}
class _FinanceStatItem {
final IconData icon;
final String title;
final String subtitle;
final Color color;
final String route;
_FinanceStatItem(
this.icon,
this.title,
this.subtitle,
this.color,
this.route,
);
}
class _QuickStat {
final IconData icon;
final String label;
final String value;
final Color color;
_QuickStat({
required this.icon,
required this.label,
required this.value,
required this.color,
});
}