edit payment request

This commit is contained in:
Manish 2025-11-10 12:10:04 +05:30
parent 44674da8ac
commit fd5ea9a1b3
5 changed files with 419 additions and 258 deletions

View File

@ -1,4 +1,3 @@
// payment_request_bottom_sheet.dart
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/controller/finance/add_payment_request_controller.dart'; import 'package:marco/controller/finance/add_payment_request_controller.dart';
@ -10,16 +9,31 @@ import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/helpers/widgets/expense/expense_form_widgets.dart'; import 'package:marco/helpers/widgets/expense/expense_form_widgets.dart';
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart'; import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
Future<T?> showPaymentRequestBottomSheet<T>({bool isEdit = false}) { Future<T?> showPaymentRequestBottomSheet<T>({
bool isEdit = false,
Map<String, dynamic>? existingData,
VoidCallback? onUpdated,
}) {
return Get.bottomSheet<T>( return Get.bottomSheet<T>(
_PaymentRequestBottomSheet(isEdit: isEdit), _PaymentRequestBottomSheet(
isEdit: isEdit,
existingData: existingData,
onUpdated: onUpdated,
),
isScrollControlled: true, isScrollControlled: true,
); );
} }
class _PaymentRequestBottomSheet extends StatefulWidget { class _PaymentRequestBottomSheet extends StatefulWidget {
final bool isEdit; final bool isEdit;
const _PaymentRequestBottomSheet({this.isEdit = false}); final Map<String, dynamic>? existingData;
final VoidCallback? onUpdated;
const _PaymentRequestBottomSheet({
this.isEdit = false,
this.existingData,
this.onUpdated,
});
@override @override
State<_PaymentRequestBottomSheet> createState() => State<_PaymentRequestBottomSheet> createState() =>
@ -35,6 +49,64 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
final _categoryDropdownKey = GlobalKey(); final _categoryDropdownKey = GlobalKey();
final _currencyDropdownKey = GlobalKey(); final _currencyDropdownKey = GlobalKey();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (widget.isEdit && widget.existingData != null) {
final data = widget.existingData!;
// 🧩 Prefill basic text fields
controller.titleController.text = data["title"] ?? "";
controller.amountController.text = data["amount"]?.toString() ?? "";
controller.descriptionController.text = data["description"] ?? "";
controller.dueDateController.text =
data["dueDate"]?.toString().split(" ")[0] ?? "";
// 🧩 Prefill dropdowns & toggles
controller.selectedProject.value = {
'id': data["projectId"],
'name': data["projectName"],
};
controller.selectedPayee.value = data["payee"] ?? "";
controller.isAdvancePayment.value = data["isAdvancePayment"] ?? false;
// 🕒 Wait until categories & currencies are loaded before setting them
everAll([
controller.categories,
controller.currencies,
], (_) {
controller.selectedCategory.value = controller.categories
.firstWhereOrNull((c) => c.id == data["expenseCategoryId"]);
controller.selectedCurrency.value = controller.currencies
.firstWhereOrNull((c) => c.id == data["currencyId"]);
});
// 🖇 Attachments - Safe parsing (avoids null or wrong type)
final attachmentsData = data["attachments"];
if (attachmentsData != null &&
attachmentsData is List &&
attachmentsData.isNotEmpty) {
final attachments = attachmentsData
.whereType<Map<String, dynamic>>()
.map((a) => {
"id": a["id"],
"fileName": a["fileName"],
"url": a["url"],
"thumbUrl": a["thumbUrl"],
"fileSize": a["fileSize"] ?? 0,
"contentType": a["contentType"] ?? "",
})
.toList();
controller.existingAttachments.assignAll(attachments);
} else {
controller.existingAttachments.clear();
}
}
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Obx(() => Form( return Obx(() => Form(
@ -49,12 +121,14 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
if (_formKey.currentState!.validate() && _validateSelections()) { if (_formKey.currentState!.validate() && _validateSelections()) {
final success = await controller.submitPaymentRequest(); final success = await controller.submitPaymentRequest();
if (success) { if (success) {
// First close the BottomSheet
Get.back(); Get.back();
// Then show Snackbar if (widget.onUpdated != null) widget.onUpdated!();
showAppSnackbar( showAppSnackbar(
title: "Success", title: "Success",
message: "Payment request created successfully!", message: widget.isEdit
? "Payment request updated successfully!"
: "Payment request created successfully!",
type: SnackbarType.success, type: SnackbarType.success,
); );
} }
@ -129,6 +203,8 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
)); ));
} }
// ---------------- Helper Widgets ----------------
Widget _buildDropdown<T>(String title, IconData icon, String value, Widget _buildDropdown<T>(String title, IconData icon, String value,
List<T> options, String Function(T) getLabel, ValueChanged<T> onSelected, List<T> options, String Function(T) getLabel, ValueChanged<T> onSelected,
{required GlobalKey key}) { {required GlobalKey key}) {
@ -258,7 +334,6 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
displayStringForOption: (option) => option, displayStringForOption: (option) => option,
fieldViewBuilder: fieldViewBuilder:
(context, fieldController, focusNode, onFieldSubmitted) { (context, fieldController, focusNode, onFieldSubmitted) {
// Avoid updating during build
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (fieldController.text != controller.selectedPayee.value) { if (fieldController.text != controller.selectedPayee.value) {
fieldController.text = controller.selectedPayee.value; fieldController.text = controller.selectedPayee.value;
@ -425,12 +500,15 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
controller.selectedProject.value!['id'].toString().isEmpty) { controller.selectedProject.value!['id'].toString().isEmpty) {
return _showError("Please select a project"); return _showError("Please select a project");
} }
if (controller.selectedCategory.value == null) if (controller.selectedCategory.value == null) {
return _showError("Please select a category"); return _showError("Please select a category");
if (controller.selectedPayee.value.isEmpty) }
if (controller.selectedPayee.value.isEmpty) {
return _showError("Please select a payee"); return _showError("Please select a payee");
if (controller.selectedCurrency.value == null) }
if (controller.selectedCurrency.value == null) {
return _showError("Please select currency"); return _showError("Please select currency");
}
return true; return true;
} }

View File

@ -118,8 +118,7 @@ class PaymentRequestData {
expenseStatus: ExpenseStatus.fromJson(json['expenseStatus']), expenseStatus: ExpenseStatus.fromJson(json['expenseStatus']),
paidTransactionId: json['paidTransactionId'], paidTransactionId: json['paidTransactionId'],
paidAt: json['paidAt'] != null ? DateTime.parse(json['paidAt']) : null, paidAt: json['paidAt'] != null ? DateTime.parse(json['paidAt']) : null,
paidBy: paidBy: json['paidBy'] != null ? User.fromJson(json['paidBy']) : null,
json['paidBy'] != null ? User.fromJson(json['paidBy']) : null,
isAdvancePayment: json['isAdvancePayment'], isAdvancePayment: json['isAdvancePayment'],
createdAt: DateTime.parse(json['createdAt']), createdAt: DateTime.parse(json['createdAt']),
createdBy: User.fromJson(json['createdBy']), createdBy: User.fromJson(json['createdBy']),
@ -373,7 +372,7 @@ class NextStatus {
class UpdateLog { class UpdateLog {
String id; String id;
ExpenseStatus status; ExpenseStatus? status;
ExpenseStatus nextStatus; ExpenseStatus nextStatus;
String comment; String comment;
DateTime updatedAt; DateTime updatedAt;
@ -381,7 +380,7 @@ class UpdateLog {
UpdateLog({ UpdateLog({
required this.id, required this.id,
required this.status, this.status,
required this.nextStatus, required this.nextStatus,
required this.comment, required this.comment,
required this.updatedAt, required this.updatedAt,
@ -390,7 +389,9 @@ class UpdateLog {
factory UpdateLog.fromJson(Map<String, dynamic> json) => UpdateLog( factory UpdateLog.fromJson(Map<String, dynamic> json) => UpdateLog(
id: json['id'], id: json['id'],
status: ExpenseStatus.fromJson(json['status']), status: json['status'] != null
? ExpenseStatus.fromJson(json['status'])
: null,
nextStatus: ExpenseStatus.fromJson(json['nextStatus']), nextStatus: ExpenseStatus.fromJson(json['nextStatus']),
comment: json['comment'], comment: json['comment'],
updatedAt: DateTime.parse(json['updatedAt']), updatedAt: DateTime.parse(json['updatedAt']),
@ -399,7 +400,7 @@ class UpdateLog {
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'id': id, 'id': id,
'status': status.toJson(), 'status': status?.toJson(),
'nextStatus': nextStatus.toJson(), 'nextStatus': nextStatus.toJson(),
'comment': comment, 'comment': comment,
'updatedAt': updatedAt.toIso8601String(), 'updatedAt': updatedAt.toIso8601String(),

View File

@ -115,7 +115,7 @@ class _FAQScreenState extends State<FAQScreen> with UIMixin {
color: contentTheme.primary.withOpacity(0.1), color: contentTheme.primary.withOpacity(0.1),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: Icon(LucideIcons.badge_help, child: Icon(LucideIcons.badge_alert,
color: contentTheme.primary, size: 24), color: contentTheme.primary, size: 24),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),

View File

@ -20,6 +20,7 @@ import 'package:marco/model/employees/employee_info.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/model/finance/payment_request_rembursement_bottom_sheet.dart'; import 'package:marco/model/finance/payment_request_rembursement_bottom_sheet.dart';
import 'package:marco/model/finance/make_expense_bottom_sheet.dart'; import 'package:marco/model/finance/make_expense_bottom_sheet.dart';
import 'package:marco/model/finance/add_payment_request_bottom_sheet.dart';
class PaymentRequestDetailScreen extends StatefulWidget { class PaymentRequestDetailScreen extends StatefulWidget {
final String paymentRequestId; final String paymentRequestId;
@ -49,21 +50,10 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
void _checkPermissionToSubmit(PaymentRequestData request) { void _checkPermissionToSubmit(PaymentRequestData request) {
const draftStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7'; const draftStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7';
final isCreatedByCurrentUser = employeeInfo?.id == request.createdBy.id; final isCreatedByCurrentUser = employeeInfo?.id == request.createdBy.id;
final hasDraftNextStatus = final hasDraftNextStatus =
request.nextStatus.any((s) => s.id == draftStatusId); request.nextStatus.any((s) => s.id == draftStatusId);
canSubmit.value = isCreatedByCurrentUser && hasDraftNextStatus;
final result = isCreatedByCurrentUser && hasDraftNextStatus;
// Debug log
print('🔐 Submit Permission Check:\n'
'Logged-in employee: ${employeeInfo?.id}\n'
'Created by: ${request.createdBy.id}\n'
'Has Draft Next Status: $hasDraftNextStatus\n'
'Can Submit: $result');
canSubmit.value = result;
} }
Future<void> _loadEmployeeInfo() async { Future<void> _loadEmployeeInfo() async {
@ -77,6 +67,38 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
return Color(int.parse(hex, radix: 16)); return Color(int.parse(hex, radix: 16));
} }
void _openEditPaymentRequestBottomSheet(request) {
showPaymentRequestBottomSheet(
isEdit: true,
existingData: {
"paymentRequestId": request.paymentRequestUID,
"title": request.title,
"projectId": request.project.id,
"projectName": request.project.name,
"expenseCategoryId": request.expenseCategory.id,
"expenseCategoryName": request.expenseCategory.name,
"amount": request.amount.toString(),
"currencyId": request.currency.id,
"currencySymbol": request.currency.symbol,
"payee": request.payee,
"description": request.description,
"isAdvancePayment": request.isAdvancePayment,
"dueDate": request.dueDate,
"attachments": request.attachments
.map((a) => {
"url": a.url,
"fileName": a.fileName,
"documentId": a.id,
"contentType": a.contentType,
})
.toList(),
},
onUpdated: () async {
await controller.fetchPaymentRequestDetail();
},
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@ -87,6 +109,7 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
if (controller.isLoading.value) { if (controller.isLoading.value) {
return SkeletonLoaders.paymentRequestDetailSkeletonLoader(); return SkeletonLoaders.paymentRequestDetailSkeletonLoader();
} }
final request = controller.paymentRequest.value; final request = controller.paymentRequest.value;
if (controller.errorMessage.isNotEmpty || request == null) { if (controller.errorMessage.isNotEmpty || request == null) {
return Center(child: MyText.bodyMedium("No data to display.")); return Center(child: MyText.bodyMedium("No data to display."));
@ -125,6 +148,7 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
_DetailsTable(request: request), _DetailsTable(request: request),
const Divider(height: 30, thickness: 1.2), const Divider(height: 30, thickness: 1.2),
_Documents(documents: request.attachments), _Documents(documents: request.attachments),
MySpacing.height(24),
], ],
), ),
), ),
@ -135,15 +159,54 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
); );
}), }),
), ),
bottomNavigationBar: Obx(() { bottomNavigationBar: _buildBottomActionBar(),
// Added Floating Action Button for Edit
floatingActionButton: Obx(() {
if (controller.isLoading.value) return const SizedBox.shrink();
final request = controller.paymentRequest.value; final request = controller.paymentRequest.value;
if (request == null || if (controller.errorMessage.isNotEmpty || request == null) {
controller.isLoading.value || return const SizedBox.shrink();
employeeInfo == null) { }
if (!_checkedPermission) {
_checkedPermission = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkPermissionToSubmit(request);
});
}
final canEdit = PaymentRequestPermissionHelper.canEditPaymentRequest(
employeeInfo,
request,
);
if (!canEdit) return const SizedBox.shrink();
return FloatingActionButton.extended(
onPressed: () => _openEditPaymentRequestBottomSheet(request),
backgroundColor: contentTheme.primary,
icon: const Icon(Icons.edit),
label: MyText.bodyMedium(
"Edit Payment Request",
fontWeight: 600,
color: Colors.white,
),
);
}),
);
}
Widget _buildBottomActionBar() {
return Obx(() {
final request = controller.paymentRequest.value;
if (request == null ||
controller.isLoading.value ||
employeeInfo == null) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
// Check permissions once
if (!_checkedPermission) { if (!_checkedPermission) {
_checkedPermission = true; _checkedPermission = true;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@ -151,7 +214,6 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
}); });
} }
// Filter statuses
const reimbursementStatusId = '61578360-3a49-4c34-8604-7b35a3787b95'; const reimbursementStatusId = '61578360-3a49-4c34-8604-7b35a3787b95';
const draftStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7'; const draftStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7';
@ -163,7 +225,6 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
.hasAnyPermission(status.permissionIds ?? []); .hasAnyPermission(status.permissionIds ?? []);
}).toList(); }).toList();
// If there are no next statuses, show "Create Expense" button
if (availableStatuses.isEmpty) { if (availableStatuses.isEmpty) {
return SafeArea( return SafeArea(
child: Container( child: Container(
@ -180,20 +241,17 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
), ),
onPressed: () { onPressed: () => showCreateExpenseBottomSheet(),
showCreateExpenseBottomSheet();
},
child: const Text( child: const Text(
"Create Expense", "Create Expense",
style: TextStyle( style:
color: Colors.white, fontWeight: FontWeight.bold), TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
), ),
), ),
), ),
); );
} }
// Normal status buttons
return SafeArea( return SafeArea(
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
@ -210,8 +268,8 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
return ElevatedButton( return ElevatedButton(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric( padding:
horizontal: 16, vertical: 10), const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
backgroundColor: color, backgroundColor: color,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
@ -237,8 +295,7 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
context, status.displayName); context, status.displayName);
if (comment == null || comment.trim().isEmpty) return; if (comment == null || comment.trim().isEmpty) return;
final success = final success = await controller.updatePaymentRequestStatus(
await controller.updatePaymentRequestStatus(
statusId: status.id, statusId: status.id,
comment: comment.trim(), comment: comment.trim(),
); );
@ -248,8 +305,7 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
message: success message: success
? 'Status updated successfully' ? 'Status updated successfully'
: 'Failed to update status', : 'Failed to update status',
type: type: success ? SnackbarType.success : SnackbarType.error,
success ? SnackbarType.success : SnackbarType.error,
); );
if (success) await controller.fetchPaymentRequestDetail(); if (success) await controller.fetchPaymentRequestDetail();
@ -262,8 +318,7 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
), ),
), ),
); );
}), });
);
} }
PreferredSizeWidget _buildAppBar() { PreferredSizeWidget _buildAppBar() {
@ -326,6 +381,29 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
} }
} }
class PaymentRequestPermissionHelper {
static bool canEditPaymentRequest(
EmployeeInfo? employee, PaymentRequestData request) {
return employee?.id == request.createdBy.id &&
_isInAllowedEditStatus(request.expenseStatus.id);
}
static bool canSubmitPaymentRequest(
EmployeeInfo? employee, PaymentRequestData request) {
return employee?.id == request.createdBy.id &&
request.nextStatus.isNotEmpty;
}
static bool _isInAllowedEditStatus(String statusId) {
const editableStatusIds = [
"d1ee5eec-24b6-4364-8673-a8f859c60729",
"965eda62-7907-4963-b4a1-657fb0b2724b",
"297e0d8f-f668-41b5-bfea-e03b354251c8",
];
return editableStatusIds.contains(statusId);
}
}
class _Header extends StatelessWidget { class _Header extends StatelessWidget {
final PaymentRequestData request; final PaymentRequestData request;
final Color Function(String) colorParser; final Color Function(String) colorParser;
@ -383,124 +461,128 @@ class _Header extends StatelessWidget {
} }
class _Logs extends StatelessWidget { class _Logs extends StatelessWidget {
final List<UpdateLog> logs; final List<UpdateLog> logs;
final Color Function(String) colorParser; final Color Function(String) colorParser;
const _Logs({required this.logs, required this.colorParser}); const _Logs({required this.logs, required this.colorParser});
DateTime _parseTimestamp(DateTime ts) => ts; DateTime _parseTimestamp(DateTime ts) => ts;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (logs.isEmpty) { if (logs.isEmpty) {
return MyText.bodyMedium('No Timeline', color: Colors.grey); return MyText.bodyMedium('No Timeline', color: Colors.grey);
}
final reversedLogs = logs.reversed.toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodySmall("Timeline:", fontWeight: 600),
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: reversedLogs.length,
itemBuilder: (_, index) {
final log = reversedLogs[index];
final status = log.status.name;
final description = log.status.description;
final comment = log.comment;
final nextStatusName = log.nextStatus.name;
final updatedBy = log.updatedBy;
final initials =
'${updatedBy.firstName.isNotEmpty == true ? updatedBy.firstName[0] : ''}'
'${updatedBy.lastName.isNotEmpty == true ? updatedBy.lastName[0] : ''}';
final name = '${updatedBy.firstName} ${updatedBy.lastName}';
final timestamp = _parseTimestamp(log.updatedAt);
final timeAgo = timeago.format(timestamp);
final statusColor = colorParser(log.status.color);
final nextStatusColor = colorParser(log.nextStatus.color);
return TimelineTile(
alignment: TimelineAlign.start,
isFirst: index == 0,
isLast: index == reversedLogs.length - 1,
indicatorStyle: IndicatorStyle(
width: 16,
height: 16,
indicator: Container(
decoration:
BoxDecoration(shape: BoxShape.circle, color: statusColor),
),
),
beforeLineStyle:
LineStyle(color: Colors.grey.shade300, thickness: 2),
endChild: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
MyText.bodyMedium(status,
fontWeight: 600, color: statusColor),
MyText.bodySmall(timeAgo,
color: Colors.grey[600],
textAlign: TextAlign.right),
],
),
if (description.isNotEmpty) ...[
const SizedBox(height: 4),
MyText.bodySmall(description, color: Colors.grey[800]),
],
if (comment.isNotEmpty) ...[
const SizedBox(height: 8),
MyText.bodyMedium(comment, fontWeight: 500),
],
const SizedBox(height: 8),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(4),
),
child: MyText.bodySmall(initials, fontWeight: 600),
),
const SizedBox(width: 6),
Expanded(
child: MyText.bodySmall(name,
overflow: TextOverflow.ellipsis)),
if (nextStatusName.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: nextStatusColor.withOpacity(0.15),
borderRadius: BorderRadius.circular(4),
),
child: MyText.bodySmall(nextStatusName,
fontWeight: 600, color: nextStatusColor),
),
],
),
],
),
),
);
},
)
],
);
}
} }
final reversedLogs = logs.reversed.toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodySmall("Timeline:", fontWeight: 600),
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: reversedLogs.length,
itemBuilder: (_, index) {
final log = reversedLogs[index];
final status = log.status?.name ?? 'Unknown';
final description = log.status?.description ?? '';
final statusColor = log.status != null
? colorParser(log.status!.color)
: Colors.grey;
final comment = log.comment;
final nextStatusName = log.nextStatus.name;
final updatedBy = log.updatedBy;
final initials =
'${updatedBy.firstName.isNotEmpty == true ? updatedBy.firstName[0] : ''}'
'${updatedBy.lastName.isNotEmpty == true ? updatedBy.lastName[0] : ''}';
final name = '${updatedBy.firstName} ${updatedBy.lastName}';
final timestamp = _parseTimestamp(log.updatedAt);
final timeAgo = timeago.format(timestamp);
final nextStatusColor = colorParser(log.nextStatus.color);
return TimelineTile(
alignment: TimelineAlign.start,
isFirst: index == 0,
isLast: index == reversedLogs.length - 1,
indicatorStyle: IndicatorStyle(
width: 16,
height: 16,
indicator: Container(
decoration:
BoxDecoration(shape: BoxShape.circle, color: statusColor),
),
),
beforeLineStyle:
LineStyle(color: Colors.grey.shade300, thickness: 2),
endChild: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
MyText.bodyMedium(status,
fontWeight: 600, color: statusColor),
MyText.bodySmall(timeAgo,
color: Colors.grey[600],
textAlign: TextAlign.right),
],
),
if (description.isNotEmpty) ...[
const SizedBox(height: 4),
MyText.bodySmall(description, color: Colors.grey[800]),
],
if (comment.isNotEmpty) ...[
const SizedBox(height: 8),
MyText.bodyMedium(comment, fontWeight: 500),
],
const SizedBox(height: 8),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(4),
),
child: MyText.bodySmall(initials, fontWeight: 600),
),
const SizedBox(width: 6),
Expanded(
child: MyText.bodySmall(name,
overflow: TextOverflow.ellipsis)),
if (nextStatusName.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: nextStatusColor.withOpacity(0.15),
borderRadius: BorderRadius.circular(4),
),
child: MyText.bodySmall(nextStatusName,
fontWeight: 600, color: nextStatusColor),
),
],
),
],
),
),
);
},
)
],
);
}
}
class _Parties extends StatelessWidget { class _Parties extends StatelessWidget {
final PaymentRequestData request; final PaymentRequestData request;
const _Parties({required this.request}); const _Parties({required this.request});

View File

@ -215,7 +215,7 @@ class _UserProfileBarState extends State<UserProfileBar>
), ),
SizedBox(height: spacingHeight), SizedBox(height: spacingHeight),
_menuItemRow( _menuItemRow(
icon: LucideIcons.badge_help, icon: LucideIcons.badge_alert,
label: 'FAQ', label: 'FAQ',
onTap: () { onTap: () {
Get.to(() => FAQScreen()); Get.to(() => FAQScreen());