resoled payment request model crash issue

This commit is contained in:
Vaibhav Surve 2025-11-18 10:57:46 +05:30
parent 618ac6f27a
commit dc4ea7979c
2 changed files with 473 additions and 416 deletions

View File

@ -1,30 +1,32 @@
class PaymentRequestDetail {
bool success;
String message;
PaymentRequestData? data;
dynamic errors;
int statusCode;
DateTime timestamp;
final bool? success;
final String? message;
final PaymentRequestData? data;
final dynamic errors;
final int? statusCode;
final DateTime? timestamp;
PaymentRequestDetail({
required this.success,
required this.message,
this.success,
this.message,
this.data,
this.errors,
required this.statusCode,
required this.timestamp,
this.statusCode,
this.timestamp,
});
factory PaymentRequestDetail.fromJson(Map<String, dynamic> json) =>
PaymentRequestDetail(
success: json['success'],
message: json['message'],
success: json['success'] as bool?,
message: json['message'] as String?,
data: json['data'] != null
? PaymentRequestData.fromJson(json['data'])
: null,
errors: json['errors'],
statusCode: json['statusCode'],
timestamp: DateTime.parse(json['timestamp']),
statusCode: json['statusCode'] as int?,
timestamp: json['timestamp'] != null
? DateTime.tryParse(json['timestamp'])
: null,
);
Map<String, dynamic> toJson() => {
@ -33,117 +35,129 @@ class PaymentRequestDetail {
'data': data?.toJson(),
'errors': errors,
'statusCode': statusCode,
'timestamp': timestamp.toIso8601String(),
'timestamp': timestamp?.toIso8601String(),
};
}
class PaymentRequestData {
String id;
String title;
String description;
String paymentRequestUID;
String payee;
Currency currency;
double amount;
double? baseAmount;
double? taxAmount;
DateTime dueDate;
Project project;
dynamic recurringPayment;
ExpenseCategory expenseCategory;
ExpenseStatus expenseStatus;
String? paidTransactionId;
DateTime? paidAt;
User? paidBy;
bool isAdvancePayment;
DateTime createdAt;
User createdBy;
DateTime updatedAt;
User? updatedBy;
List<NextStatus> nextStatus;
List<UpdateLog> updateLogs;
List<Attachment> attachments;
bool isActive;
bool isExpenseCreated;
final String? id;
final String? title;
final String? description;
final String? paymentRequestUID;
final String? payee;
final Currency? currency;
final double? amount;
final double? baseAmount;
final double? taxAmount;
final DateTime? dueDate;
final Project? project;
final RecurringPayment? recurringPayment;
final ExpenseCategory? expenseCategory;
final ExpenseStatus? expenseStatus;
final String? paidTransactionId;
final DateTime? paidAt;
final User? paidBy;
final bool? isAdvancePayment;
final DateTime? createdAt;
final User? createdBy;
final DateTime? updatedAt;
final User? updatedBy;
final List<NextStatus>? nextStatus;
final List<UpdateLog>? updateLogs;
final List<Attachment>? attachments;
final bool? isActive;
final bool? isExpenseCreated;
PaymentRequestData({
required this.id,
required this.title,
required this.description,
required this.paymentRequestUID,
required this.payee,
required this.currency,
required this.amount,
this.id,
this.title,
this.description,
this.paymentRequestUID,
this.payee,
this.currency,
this.amount,
this.baseAmount,
this.taxAmount,
required this.dueDate,
required this.project,
this.dueDate,
this.project,
this.recurringPayment,
required this.expenseCategory,
required this.expenseStatus,
this.expenseCategory,
this.expenseStatus,
this.paidTransactionId,
this.paidAt,
this.paidBy,
required this.isAdvancePayment,
required this.createdAt,
required this.createdBy,
required this.updatedAt,
this.isAdvancePayment,
this.createdAt,
this.createdBy,
this.updatedAt,
this.updatedBy,
required this.nextStatus,
required this.updateLogs,
required this.attachments,
required this.isActive,
required this.isExpenseCreated,
this.nextStatus,
this.updateLogs,
this.attachments,
this.isActive,
this.isExpenseCreated,
});
factory PaymentRequestData.fromJson(Map<String, dynamic> json) =>
PaymentRequestData(
id: json['id'],
title: json['title'],
description: json['description'],
paymentRequestUID: json['paymentRequestUID'],
payee: json['payee'],
currency: Currency.fromJson(json['currency']),
amount: (json['amount'] as num).toDouble(),
baseAmount: json['baseAmount'] != null
? (json['baseAmount'] as num).toDouble()
id: json['id'] as String?,
title: json['title'] as String?,
description: json['description'] as String?,
paymentRequestUID: json['paymentRequestUID'] as String?,
payee: json['payee'] as String?,
currency: json['currency'] != null
? Currency.fromJson(json['currency'])
: null,
taxAmount: json['taxAmount'] != null
? (json['taxAmount'] as num).toDouble()
amount: (json['amount'] as num?)?.toDouble(),
baseAmount: (json['baseAmount'] as num?)?.toDouble(),
taxAmount: (json['taxAmount'] as num?)?.toDouble(),
dueDate: json['dueDate'] != null
? DateTime.tryParse(json['dueDate'])
: null,
project: json['project'] != null
? Project.fromJson(json['project'])
: null,
dueDate: DateTime.parse(json['dueDate']),
project: Project.fromJson(json['project']),
recurringPayment: json['recurringPayment'] != null
? RecurringPayment.fromJson(json['recurringPayment'])
: null,
expenseCategory: ExpenseCategory.fromJson(json['expenseCategory']),
expenseStatus: ExpenseStatus.fromJson(json['expenseStatus']),
paidTransactionId: json['paidTransactionId'],
paidAt: json['paidAt'] != null ? DateTime.parse(json['paidAt']) : null,
paidBy: json['paidBy'] != null ? User.fromJson(json['paidBy']) : null,
isAdvancePayment: json['isAdvancePayment'] ?? false,
createdAt: DateTime.parse(json['createdAt']),
createdBy: User.fromJson(json['createdBy']),
updatedAt: DateTime.parse(json['updatedAt']),
expenseCategory: json['expenseCategory'] != null
? ExpenseCategory.fromJson(json['expenseCategory'])
: null,
expenseStatus: json['expenseStatus'] != null
? ExpenseStatus.fromJson(json['expenseStatus'])
: null,
paidTransactionId: json['paidTransactionId'] as String?,
paidAt: json['paidAt'] != null
? DateTime.tryParse(json['paidAt'])
: null,
paidBy:
json['paidBy'] != null ? User.fromJson(json['paidBy']) : null,
isAdvancePayment: json['isAdvancePayment'] as bool?,
createdAt: json['createdAt'] != null
? DateTime.tryParse(json['createdAt'])
: null,
createdBy: json['createdBy'] != null
? User.fromJson(json['createdBy'])
: null,
updatedAt: json['updatedAt'] != null
? DateTime.tryParse(json['updatedAt'])
: null,
updatedBy:
json['updatedBy'] != null ? User.fromJson(json['updatedBy']) : null,
nextStatus: (json['nextStatus'] != null
? (json['nextStatus'] as List<dynamic>)
.map((e) => NextStatus.fromJson(e))
.toList()
: <NextStatus>[]),
updateLogs: (json['updateLogs'] != null
? (json['updateLogs'] as List<dynamic>)
.map((e) => UpdateLog.fromJson(e))
.toList()
: <UpdateLog>[]),
attachments: (json['attachments'] != null
? (json['attachments'] as List<dynamic>)
.map((e) => Attachment.fromJson(e))
.toList()
: <Attachment>[]),
isActive: json['isActive'] ?? true,
isExpenseCreated: json['isExpenseCreated'] ?? false,
nextStatus: (json['nextStatus'] as List?)
?.map((e) => NextStatus.fromJson(e))
.toList() ??
[],
updateLogs: (json['updateLogs'] as List?)
?.map((e) => UpdateLog.fromJson(e))
.toList() ??
[],
attachments: (json['attachments'] as List?)
?.map((e) => Attachment.fromJson(e))
.toList() ??
[],
isActive: json['isActive'] as bool?,
isExpenseCreated: json['isExpenseCreated'] as bool?,
);
Map<String, dynamic> toJson() => {
@ -152,50 +166,50 @@ class PaymentRequestData {
'description': description,
'paymentRequestUID': paymentRequestUID,
'payee': payee,
'currency': currency.toJson(),
'currency': currency?.toJson(),
'amount': amount,
'baseAmount': baseAmount,
'taxAmount': taxAmount,
'dueDate': dueDate.toIso8601String(),
'project': project.toJson(),
'recurringPayment': recurringPayment,
'expenseCategory': expenseCategory.toJson(),
'expenseStatus': expenseStatus.toJson(),
'dueDate': dueDate?.toIso8601String(),
'project': project?.toJson(),
'recurringPayment': recurringPayment?.toJson(),
'expenseCategory': expenseCategory?.toJson(),
'expenseStatus': expenseStatus?.toJson(),
'paidTransactionId': paidTransactionId,
'paidAt': paidAt?.toIso8601String(),
'paidBy': paidBy?.toJson(),
'isAdvancePayment': isAdvancePayment,
'createdAt': createdAt.toIso8601String(),
'createdBy': createdBy.toJson(),
'updatedAt': updatedAt.toIso8601String(),
'createdAt': createdAt?.toIso8601String(),
'createdBy': createdBy?.toJson(),
'updatedAt': updatedAt?.toIso8601String(),
'updatedBy': updatedBy?.toJson(),
'nextStatus': nextStatus.map((e) => e.toJson()).toList(),
'updateLogs': updateLogs.map((e) => e.toJson()).toList(),
'attachments': attachments.map((e) => e.toJson()).toList(),
'nextStatus': nextStatus?.map((e) => e.toJson()).toList(),
'updateLogs': updateLogs?.map((e) => e.toJson()).toList(),
'attachments': attachments?.map((e) => e.toJson()).toList(),
'isActive': isActive,
'isExpenseCreated': isExpenseCreated,
};
}
class RecurringPayment {
String id;
String recurringPaymentUID;
double amount;
bool isVariable;
final String? id;
final String? recurringPaymentUID;
final double? amount;
final bool? isVariable;
RecurringPayment({
required this.id,
required this.recurringPaymentUID,
required this.amount,
required this.isVariable,
this.id,
this.recurringPaymentUID,
this.amount,
this.isVariable,
});
factory RecurringPayment.fromJson(Map<String, dynamic> json) =>
RecurringPayment(
id: json['id'],
recurringPaymentUID: json['recurringPaymentUID'],
amount: (json['amount'] as num).toDouble(),
isVariable: json['isVariable'],
id: json['id'] as String?,
recurringPaymentUID: json['recurringPaymentUID'] as String?,
amount: (json['amount'] as num?)?.toDouble(),
isVariable: json['isVariable'] as bool?,
);
Map<String, dynamic> toJson() => {
@ -207,26 +221,26 @@ class RecurringPayment {
}
class Currency {
String id;
String currencyCode;
String currencyName;
String symbol;
bool isActive;
final String? id;
final String? currencyCode;
final String? currencyName;
final String? symbol;
final bool? isActive;
Currency({
required this.id,
required this.currencyCode,
required this.currencyName,
required this.symbol,
required this.isActive,
this.id,
this.currencyCode,
this.currencyName,
this.symbol,
this.isActive,
});
factory Currency.fromJson(Map<String, dynamic> json) => Currency(
id: json['id'],
currencyCode: json['currencyCode'],
currencyName: json['currencyName'],
symbol: json['symbol'],
isActive: json['isActive'],
id: json['id'] as String?,
currencyCode: json['currencyCode'] as String?,
currencyName: json['currencyName'] as String?,
symbol: json['symbol'] as String?,
isActive: json['isActive'] as bool?,
);
Map<String, dynamic> toJson() => {
@ -239,39 +253,39 @@ class Currency {
}
class Project {
String id;
String name;
final String? id;
final String? name;
Project({required this.id, required this.name});
Project({this.id, this.name});
factory Project.fromJson(Map<String, dynamic> json) =>
Project(id: json['id'], name: json['name']);
Project(id: json['id'] as String?, name: json['name'] as String?);
Map<String, dynamic> toJson() => {'id': id, 'name': name};
}
class ExpenseCategory {
String id;
String name;
bool noOfPersonsRequired;
bool isAttachmentRequried;
String description;
final String? id;
final String? name;
final bool? noOfPersonsRequired;
final bool? isAttachmentRequried;
final String? description;
ExpenseCategory({
required this.id,
required this.name,
required this.noOfPersonsRequired,
required this.isAttachmentRequried,
required this.description,
this.id,
this.name,
this.noOfPersonsRequired,
this.isAttachmentRequried,
this.description,
});
factory ExpenseCategory.fromJson(Map<String, dynamic> json) =>
ExpenseCategory(
id: json['id'],
name: json['name'],
noOfPersonsRequired: json['noOfPersonsRequired'],
isAttachmentRequried: json['isAttachmentRequried'],
description: json['description'],
id: json['id'] as String?,
name: json['name'] as String?,
noOfPersonsRequired: json['noOfPersonsRequired'] as bool?,
isAttachmentRequried: json['isAttachmentRequried'] as bool?,
description: json['description'] as String?,
);
Map<String, dynamic> toJson() => {
@ -284,34 +298,34 @@ class ExpenseCategory {
}
class ExpenseStatus {
String id;
String name;
String displayName;
String description;
List<String>? permissionIds;
String color;
bool isSystem;
final String? id;
final String? name;
final String? displayName;
final String? description;
final List<String>? permissionIds;
final String? color;
final bool? isSystem;
ExpenseStatus({
required this.id,
required this.name,
required this.displayName,
required this.description,
this.id,
this.name,
this.displayName,
this.description,
this.permissionIds,
required this.color,
required this.isSystem,
this.color,
this.isSystem,
});
factory ExpenseStatus.fromJson(Map<String, dynamic> json) => ExpenseStatus(
id: json['id'],
name: json['name'],
displayName: json['displayName'],
description: json['description'],
id: json['id'] as String?,
name: json['name'] as String?,
displayName: json['displayName'] as String?,
description: json['description'] as String?,
permissionIds: json['permissionIds'] != null
? List<String>.from(json['permissionIds'])
: null,
color: json['color'],
isSystem: json['isSystem'],
color: json['color'] as String?,
isSystem: json['isSystem'] as bool?,
);
Map<String, dynamic> toJson() => {
@ -326,32 +340,32 @@ class ExpenseStatus {
}
class User {
String id;
String firstName;
String lastName;
String email;
String photo;
String jobRoleId;
String jobRoleName;
final String? id;
final String? firstName;
final String? lastName;
final String? email;
final String? photo;
final String? jobRoleId;
final String? jobRoleName;
User({
required this.id,
required this.firstName,
required this.lastName,
required this.email,
required this.photo,
required this.jobRoleId,
required this.jobRoleName,
this.id,
this.firstName,
this.lastName,
this.email,
this.photo,
this.jobRoleId,
this.jobRoleName,
});
factory User.fromJson(Map<String, dynamic> json) => User(
id: json['id'],
firstName: json['firstName'],
lastName: json['lastName'],
email: json['email'],
photo: json['photo'],
jobRoleId: json['jobRoleId'],
jobRoleName: json['jobRoleName'],
id: json['id'] as String?,
firstName: json['firstName'] as String?,
lastName: json['lastName'] as String?,
email: json['email'] as String?,
photo: json['photo'] as String?,
jobRoleId: json['jobRoleId'] as String?,
jobRoleName: json['jobRoleName'] as String?,
);
Map<String, dynamic> toJson() => {
@ -366,34 +380,34 @@ class User {
}
class NextStatus {
String id;
String name;
String displayName;
String description;
List<String>? permissionIds;
String color;
bool isSystem;
final String? id;
final String? name;
final String? displayName;
final String? description;
final List<String>? permissionIds;
final String? color;
final bool? isSystem;
NextStatus({
required this.id,
required this.name,
required this.displayName,
required this.description,
this.id,
this.name,
this.displayName,
this.description,
this.permissionIds,
required this.color,
required this.isSystem,
this.color,
this.isSystem,
});
factory NextStatus.fromJson(Map<String, dynamic> json) => NextStatus(
id: json['id'],
name: json['name'],
displayName: json['displayName'],
description: json['description'],
id: json['id'] as String?,
name: json['name'] as String?,
displayName: json['displayName'] as String?,
description: json['description'] as String?,
permissionIds: json['permissionIds'] != null
? List<String>.from(json['permissionIds'])
: null,
color: json['color'],
isSystem: json['isSystem'],
color: json['color'] as String?,
isSystem: json['isSystem'] as bool?,
);
Map<String, dynamic> toJson() => {
@ -408,67 +422,73 @@ class NextStatus {
}
class UpdateLog {
String id;
ExpenseStatus? status;
ExpenseStatus nextStatus;
String comment;
DateTime updatedAt;
User updatedBy;
final String? id;
final ExpenseStatus? status;
final ExpenseStatus? nextStatus;
final String? comment;
final DateTime? updatedAt;
final User? updatedBy;
UpdateLog({
required this.id,
this.id,
this.status,
required this.nextStatus,
required this.comment,
required this.updatedAt,
required this.updatedBy,
this.nextStatus,
this.comment,
this.updatedAt,
this.updatedBy,
});
factory UpdateLog.fromJson(Map<String, dynamic> json) => UpdateLog(
id: json['id'],
id: json['id'] as String?,
status: json['status'] != null
? ExpenseStatus.fromJson(json['status'])
: null,
nextStatus: ExpenseStatus.fromJson(json['nextStatus']),
comment: json['comment'],
updatedAt: DateTime.parse(json['updatedAt']),
updatedBy: User.fromJson(json['updatedBy']),
nextStatus: json['nextStatus'] != null
? ExpenseStatus.fromJson(json['nextStatus'])
: null,
comment: json['comment'] as String?,
updatedAt: json['updatedAt'] != null
? DateTime.tryParse(json['updatedAt'])
: null,
updatedBy: json['updatedBy'] != null
? User.fromJson(json['updatedBy'])
: null,
);
Map<String, dynamic> toJson() => {
'id': id,
'status': status?.toJson(),
'nextStatus': nextStatus.toJson(),
'nextStatus': nextStatus?.toJson(),
'comment': comment,
'updatedAt': updatedAt.toIso8601String(),
'updatedBy': updatedBy.toJson(),
'updatedAt': updatedAt?.toIso8601String(),
'updatedBy': updatedBy?.toJson(),
};
}
class Attachment {
String id;
String fileName;
String url;
String? thumbUrl;
int fileSize;
String contentType;
final String? id;
final String? fileName;
final String? url;
final String? thumbUrl;
final int? fileSize;
final String? contentType;
Attachment({
required this.id,
required this.fileName,
required this.url,
this.id,
this.fileName,
this.url,
this.thumbUrl,
required this.fileSize,
required this.contentType,
this.fileSize,
this.contentType,
});
factory Attachment.fromJson(Map<String, dynamic> json) => Attachment(
id: json['id'],
fileName: json['fileName'],
url: json['url'],
thumbUrl: json['thumbUrl'],
fileSize: json['fileSize'],
contentType: json['contentType'],
id: json['id'] as String?,
fileName: json['fileName'] as String?,
url: json['url'] as String?,
thumbUrl: json['thumbUrl'] as String?,
fileSize: json['fileSize'] as int?,
contentType: json['contentType'] as String?,
);
Map<String, dynamic> toJson() => {

View File

@ -50,9 +50,9 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
void _checkPermissionToSubmit(PaymentRequestData request) {
const draftStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7';
final isCreatedByCurrentUser = employeeInfo?.id == request.createdBy.id;
final isCreatedByCurrentUser = employeeInfo?.id == request.createdBy?.id;
final hasDraftNextStatus =
request.nextStatus.any((s) => s.id == draftStatusId);
(request.nextStatus ?? []).any((s) => (s.id ?? '') == draftStatusId);
canSubmit.value = isCreatedByCurrentUser && hasDraftNextStatus;
}
@ -61,36 +61,41 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
setState(() {});
}
Color _parseColor(String hexColor) {
String hex = hexColor.toUpperCase().replaceAll('#', '');
if (hex.length == 6) hex = 'FF$hex';
return Color(int.parse(hex, radix: 16));
Color _parseColor(String? hexColor) {
try {
if (hexColor == null || hexColor.trim().isEmpty) return Colors.grey;
String hex = hexColor.toUpperCase().replaceAll('#', '');
if (hex.length == 6) hex = 'FF$hex';
return Color(int.parse(hex, radix: 16));
} catch (_) {
return Colors.grey;
}
}
void _openEditPaymentRequestBottomSheet(request) {
void _openEditPaymentRequestBottomSheet(PaymentRequestData request) {
showPaymentRequestBottomSheet(
isEdit: true,
existingData: {
"id": request.id,
"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
"id": request.id ?? '',
"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 ?? 0).toString(),
"currencyId": request.currency?.id ?? '',
"currencySymbol": request.currency?.symbol ?? '',
"payee": request.payee ?? '',
"description": request.description ?? '',
"isAdvancePayment": request.isAdvancePayment ?? false,
"dueDate": request.dueDate?.toIso8601String() ?? '',
"attachments": (request.attachments ?? [])
.map((a) => {
"url": a.url,
"fileName": a.fileName,
"documentId": a.id,
"contentType": a.contentType,
"url": a.url ?? '',
"fileName": a.fileName ?? '',
"documentId": a.id ?? '',
"contentType": a.contentType ?? '',
})
.toList(),
},
@ -105,70 +110,72 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
return Scaffold(
backgroundColor: Colors.white,
appBar: _buildAppBar(),
body: SafeArea(child: Obx(() {
if (controller.isLoading.value &&
controller.paymentRequest.value == null) {
return SkeletonLoaders.paymentRequestDetailSkeletonLoader();
}
body: SafeArea(
child: Obx(() {
if (controller.isLoading.value &&
controller.paymentRequest.value == null) {
return SkeletonLoaders.paymentRequestDetailSkeletonLoader();
}
final request = controller.paymentRequest.value;
final request = controller.paymentRequest.value;
if (controller.errorMessage.isNotEmpty) {
return Center(
child: MyText.bodyMedium(controller.errorMessage.value));
}
if ((controller.errorMessage.value ).isNotEmpty) {
return Center(child: MyText.bodyMedium(controller.errorMessage.value));
}
if (request == null) {
return Center(child: MyText.bodyMedium("No data to display."));
}
if (request == null) {
return Center(child: MyText.bodyMedium("No data to display."));
}
return MyRefreshIndicator(
onRefresh: controller.fetchPaymentRequestDetail,
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
12,
12,
12,
60 + MediaQuery.of(context).padding.bottom,
),
child: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 520),
child: Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5)),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 12, horizontal: 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Header(
request: request,
colorParser: _parseColor,
employeeInfo: employeeInfo,
onEdit: () =>
_openEditPaymentRequestBottomSheet(request),
),
const Divider(height: 30, thickness: 1.2),
_Logs(
logs: request.updateLogs, colorParser: _parseColor),
const Divider(height: 30, thickness: 1.2),
_Parties(request: request),
const Divider(height: 30, thickness: 1.2),
_DetailsTable(request: request),
const Divider(height: 30, thickness: 1.2),
_Documents(documents: request.attachments),
MySpacing.height(24),
],
return MyRefreshIndicator(
onRefresh: controller.fetchPaymentRequestDetail,
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
12,
12,
12,
60 + MediaQuery.of(context).padding.bottom,
),
child: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 520),
child: Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5)),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 12, horizontal: 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Header(
request: request,
colorParser: _parseColor,
employeeInfo: employeeInfo,
onEdit: () => _openEditPaymentRequestBottomSheet(request),
),
const Divider(height: 30, thickness: 1.2),
_Logs(
logs: request.updateLogs ?? [],
colorParser: _parseColor,
),
const Divider(height: 30, thickness: 1.2),
_Parties(request: request),
const Divider(height: 30, thickness: 1.2),
_DetailsTable(request: request),
const Divider(height: 30, thickness: 1.2),
_Documents(documents: request.attachments ?? []),
MySpacing.height(24),
],
),
),
),
),
),
),
),
);
})),
);
}),
),
bottomNavigationBar: _buildBottomActionBar(),
);
}
@ -192,14 +199,18 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
const reimbursementStatusId = '61578360-3a49-4c34-8604-7b35a3787b95';
const draftStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7';
final availableStatuses = request.nextStatus.where((status) {
if (status.id == draftStatusId) {
return employeeInfo?.id == request.createdBy.id;
final availableStatuses = (request.nextStatus ?? []).where((status) {
if ((status.id ?? '') == draftStatusId) {
return employeeInfo?.id == request.createdBy?.id;
}
return permissionController
.hasAnyPermission(status.permissionIds ?? []);
}).toList();
if (availableStatuses.isEmpty) {
return const SizedBox.shrink();
}
return SafeArea(
child: Container(
decoration: BoxDecoration(
@ -224,7 +235,10 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
),
),
onPressed: () async {
if (status.id == reimbursementStatusId) {
final statusId = status.id ?? '';
final dispName = status.displayName ?? '';
if (statusId == reimbursementStatusId) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
@ -233,23 +247,23 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
BorderRadius.vertical(top: Radius.circular(5)),
),
builder: (ctx) => UpdatePaymentRequestWithReimbursement(
expenseId: request.paymentRequestUID,
statusId: status.id,
expenseId: request.paymentRequestUID ?? '',
statusId: statusId,
onClose: () {},
),
);
} else if (status.id ==
} else if (statusId ==
'b8586f67-dc19-49c3-b4af-224149efe1d3') {
showCreateExpenseBottomSheet(
statusId: status.id,
statusId: statusId,
);
} else {
final comment = await showCommentBottomSheet(
context, status.displayName);
final comment =
await showCommentBottomSheet(context, dispName);
if (comment == null || comment.trim().isEmpty) return;
final success = await controller.updatePaymentRequestStatus(
statusId: status.id,
statusId: statusId,
comment: comment.trim(),
);
@ -258,14 +272,15 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
message: success
? 'Status updated successfully'
: 'Failed to update status',
type: success ? SnackbarType.success : SnackbarType.error,
type:
success ? SnackbarType.success : SnackbarType.error,
);
if (success) await controller.fetchPaymentRequestDetail();
}
},
child: MyText.bodySmall(
status.displayName,
status.displayName ?? '',
color: Colors.white,
fontWeight: 600,
),
@ -308,8 +323,8 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
),
MySpacing.height(2),
GetBuilder<ProjectController>(builder: (_) {
final name = projectController.selectedProject?.name ??
'Select Project';
final name =
projectController.selectedProject?.name ?? 'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
@ -340,14 +355,14 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
class PaymentRequestPermissionHelper {
static bool canEditPaymentRequest(
EmployeeInfo? employee, PaymentRequestData request) {
return employee?.id == request.createdBy.id &&
_isInAllowedEditStatus(request.expenseStatus.id);
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;
return employee?.id == request.createdBy?.id &&
(request.nextStatus ?? []).isNotEmpty;
}
static bool _isInAllowedEditStatus(String statusId) {
@ -362,9 +377,10 @@ class PaymentRequestPermissionHelper {
// ------------------ Sub-widgets ------------------
// Header widget
class _Header extends StatefulWidget {
final PaymentRequestData request;
final Color Function(String) colorParser;
final Color Function(String?) colorParser;
final VoidCallback? onEdit;
final EmployeeInfo? employeeInfo;
@ -382,7 +398,7 @@ class _Header extends StatefulWidget {
class _HeaderState extends State<_Header> with UIMixin {
@override
Widget build(BuildContext context) {
final statusColor = widget.colorParser(widget.request.expenseStatus.color);
final statusColor = widget.colorParser(widget.request.expenseStatus?.color);
final canEdit = widget.employeeInfo != null &&
PaymentRequestPermissionHelper.canEditPaymentRequest(
@ -395,7 +411,7 @@ class _HeaderState extends State<_Header> with UIMixin {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
MyText.bodyMedium(
'ID: ${widget.request.paymentRequestUID}',
'ID: ${widget.request.paymentRequestUID ?? '-'}',
fontWeight: 700,
fontSize: 14,
),
@ -422,7 +438,7 @@ class _HeaderState extends State<_Header> with UIMixin {
Expanded(
child: MyText.bodySmall(
DateTimeUtils.convertUtcToLocal(
widget.request.createdAt.toIso8601String(),
widget.request.createdAt?.toIso8601String() ?? '',
format: 'dd MMM yyyy'),
fontWeight: 600,
overflow: TextOverflow.ellipsis,
@ -443,7 +459,7 @@ class _HeaderState extends State<_Header> with UIMixin {
SizedBox(
width: 100,
child: MyText.labelSmall(
widget.request.expenseStatus.displayName,
widget.request.expenseStatus?.displayName ?? '-',
color: statusColor,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
@ -459,11 +475,10 @@ class _HeaderState extends State<_Header> with UIMixin {
}
}
// ------------------ Logs, Parties, Details, Documents ------------------
// Logs widget
class _Logs extends StatelessWidget {
final List<UpdateLog> logs;
final Color Function(String) colorParser;
final Color Function(String?) colorParser;
const _Logs({required this.logs, required this.colorParser});
@override
@ -490,21 +505,25 @@ class _Logs extends StatelessWidget {
? colorParser(log.status!.color)
: Colors.grey;
final comment = log.comment;
final nextStatusName = log.nextStatus.name;
final comment = log.comment ?? '';
final nextStatusName = log.nextStatus?.name ?? '';
final updatedBy = log.updatedBy;
final first = updatedBy?.firstName ?? '';
final last = updatedBy?.lastName ?? '';
final initials =
'${updatedBy.firstName.isNotEmpty == true ? updatedBy.firstName[0] : ''}'
'${updatedBy.lastName.isNotEmpty == true ? updatedBy.lastName[0] : ''}';
final name = '${updatedBy.firstName} ${updatedBy.lastName}';
'${first.isNotEmpty ? first[0] : ''}${last.isNotEmpty ? last[0] : ''}';
final name = ((first + ' ' + last).trim().isNotEmpty)
? '$first $last'
: '-';
final timestamp = log.updatedAt
.toUtc()
.add(const Duration(hours: 5, minutes: 30));
final timeAgo = timeago.format(timestamp);
final updatedAt = log.updatedAt;
final timeAgo = (updatedAt != null)
? timeago.format(updatedAt.toUtc().add(const Duration(hours: 5, minutes: 30)))
: '-';
final nextStatusColor = colorParser(log.nextStatus.color);
final nextStatusColor =
colorParser(log.nextStatus?.color ?? '');
return TimelineTile(
alignment: TimelineAlign.start,
@ -583,6 +602,7 @@ class _Logs extends StatelessWidget {
}
}
// Parties widget
class _Parties extends StatelessWidget {
final PaymentRequestData request;
const _Parties({required this.request});
@ -602,7 +622,7 @@ class _Parties extends StatelessWidget {
children: [
MyText.labelMedium("Payee", fontWeight: 600),
MySpacing.height(2),
MyText.bodyMedium(request.payee),
MyText.bodyMedium(request.payee ?? '-'),
],
),
),
@ -612,7 +632,7 @@ class _Parties extends StatelessWidget {
children: [
MyText.labelMedium("Project", fontWeight: 600),
MySpacing.height(2),
MyText.bodyMedium(request.project.name),
MyText.bodyMedium(request.project?.name ?? '-'),
],
),
),
@ -623,98 +643,105 @@ class _Parties extends StatelessWidget {
}
}
// Details table widget
class _DetailsTable extends StatelessWidget {
final PaymentRequestData request;
const _DetailsTable({required this.request});
String _formatCurrencyAmount(String? symbol, double? amount) {
final sym = symbol ?? '';
final amt = amount ?? 0.0;
return '$sym ${amt.toStringAsFixed(2)}';
}
@override
Widget build(BuildContext context) {
final currencySymbol = request.currency?.symbol ?? '';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Basic Info
_labelValueRow("Payment Request ID:", request.paymentRequestUID),
if (request.paidTransactionId != null &&
request.paidTransactionId!.isNotEmpty)
_labelValueRow("Transaction ID:", request.paidTransactionId!),
_labelValueRow("Payee:", request.payee),
_labelValueRow("Project:", request.project.name),
_labelValueRow("Expense Category:", request.expenseCategory.name),
_labelValueRow("Payment Request ID:", request.paymentRequestUID ?? '-'),
if ((request.paidTransactionId ?? '').isNotEmpty)
_labelValueRow("Transaction ID:", request.paidTransactionId ?? ''),
_labelValueRow("Payee:", request.payee ?? '-'),
_labelValueRow("Project:", request.project?.name ?? '-'),
_labelValueRow("Expense Category:", request.expenseCategory?.name ?? '-'),
// Amounts
_labelValueRow("Amount:",
"${request.currency.symbol} ${request.amount.toStringAsFixed(2)}"),
_labelValueRow("Amount:", _formatCurrencyAmount(currencySymbol, request.amount)),
if (request.baseAmount != null)
_labelValueRow("Base Amount:",
"${request.currency.symbol} ${request.baseAmount!.toStringAsFixed(2)}"),
_labelValueRow("Base Amount:", _formatCurrencyAmount(currencySymbol, request.baseAmount)),
if (request.taxAmount != null)
_labelValueRow("Tax Amount:",
"${request.currency.symbol} ${request.taxAmount!.toStringAsFixed(2)}"),
if (request.expenseCategory.noOfPersonsRequired)
_labelValueRow("Tax Amount:", _formatCurrencyAmount(currencySymbol, request.taxAmount)),
if (request.expenseCategory?.noOfPersonsRequired == true)
_labelValueRow("Additional Persons Required:", "Yes"),
if (request.expenseCategory.isAttachmentRequried)
if (request.expenseCategory?.isAttachmentRequried == true)
_labelValueRow("Attachment Required:", "Yes"),
// Dates
_labelValueRow(
"Due Date:",
DateTimeUtils.convertUtcToLocal(request.dueDate.toIso8601String(),
DateTimeUtils.convertUtcToLocal(
request.dueDate?.toIso8601String() ?? '',
format: 'dd MMM yyyy')),
_labelValueRow(
"Created At:",
DateTimeUtils.convertUtcToLocal(request.createdAt.toIso8601String(),
DateTimeUtils.convertUtcToLocal(
request.createdAt?.toIso8601String() ?? '',
format: 'dd MMM yyyy')),
_labelValueRow(
"Updated At:",
DateTimeUtils.convertUtcToLocal(request.updatedAt.toIso8601String(),
DateTimeUtils.convertUtcToLocal(
request.updatedAt?.toIso8601String() ?? '',
format: 'dd MMM yyyy')),
// Payment Info
if (request.paidAt != null)
_labelValueRow(
"Transaction Date:",
DateTimeUtils.convertUtcToLocal(request.paidAt!.toIso8601String(),
format: 'dd MMM yyyy')),
"Transaction Date:",
DateTimeUtils.convertUtcToLocal(
request.paidAt?.toIso8601String() ?? '',
format: 'dd MMM yyyy'),
),
if (request.paidBy != null)
_labelValueRow("Paid By:",
"${request.paidBy!.firstName} ${request.paidBy!.lastName}"),
_labelValueRow("Paid By:", "${request.paidBy?.firstName ?? ''} ${request.paidBy?.lastName ?? ''}".trim()),
// Flags
_labelValueRow(
"Advance Payment:", request.isAdvancePayment ? "Yes" : "No"),
"Advance Payment:", (request.isAdvancePayment ?? false) ? "Yes" : "No"),
_labelValueRow(
"Expense Created:", request.isExpenseCreated ? "Yes" : "No"),
_labelValueRow("Active:", request.isActive ? "Yes" : "No"),
"Expense Created:", (request.isExpenseCreated ?? false) ? "Yes" : "No"),
_labelValueRow("Active:", (request.isActive ?? false) ? "Yes" : "No"),
// Recurring Payment Info
if (request.recurringPayment != null) ...[
const SizedBox(height: 6),
MyText.bodySmall("Recurring Payment Info:", fontWeight: 600),
_labelValueRow(
"Recurring ID:", request.recurringPayment!.recurringPaymentUID),
_labelValueRow("Amount:",
"${request.currency.symbol} ${request.recurringPayment!.amount.toStringAsFixed(2)}"),
_labelValueRow("Variable Amount:",
request.recurringPayment!.isVariable ? "Yes" : "No"),
_labelValueRow("Recurring ID:", request.recurringPayment?.recurringPaymentUID ?? '-'),
_labelValueRow("Amount:", _formatCurrencyAmount(currencySymbol, request.recurringPayment?.amount)),
_labelValueRow("Variable Amount:", (request.recurringPayment?.isVariable ?? false) ? "Yes" : "No"),
],
// Description & Attachments
_labelValueRow("Description:", request.description),
_labelValueRow(
"Attachment:", request.attachments.isNotEmpty ? "Yes" : "No"),
_labelValueRow("Description:", request.description ?? '-'),
_labelValueRow("Attachment:", (request.attachments ?? []).isNotEmpty ? "Yes" : "No"),
],
);
}
}
// Documents widget
class _Documents extends StatelessWidget {
final List<Attachment> documents;
const _Documents({required this.documents});
@override
Widget build(BuildContext context) {
if (documents.isEmpty)
if (documents.isEmpty) {
return MyText.bodyMedium('No Documents', color: Colors.grey);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -728,26 +755,36 @@ class _Documents extends StatelessWidget {
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final doc = documents[index];
final isImage = doc.contentType.startsWith('image/');
final contentType = doc.contentType ?? '';
final isImage = contentType.startsWith('image/');
return GestureDetector(
onTap: () async {
final imageDocs = documents
.where((d) => d.contentType.startsWith('image/'))
.where((d) => (d.contentType ?? '').startsWith('image/'))
.toList();
final initialIndex =
imageDocs.indexWhere((d) => d.id == doc.id);
imageDocs.indexWhere((d) => (d.id ?? '') == (doc.id ?? ''));
if (isImage && imageDocs.isNotEmpty && initialIndex != -1) {
showDialog(
context: context,
builder: (_) => ImageViewerDialog(
imageSources: imageDocs.map((e) => e.url).toList(),
imageSources: imageDocs.map((e) => e.url ?? '').toList(),
initialIndex: initialIndex,
),
);
} else {
final Uri url = Uri.parse(doc.url);
final urlStr = doc.url ?? '';
if (urlStr.isEmpty) {
showAppSnackbar(
title: 'Error',
message: 'Document URL is missing.',
type: SnackbarType.error,
);
return;
}
final Uri url = Uri.parse(urlStr);
if (await canLaunchUrl(url)) {
await launchUrl(url, mode: LaunchMode.externalApplication);
} else {
@ -774,7 +811,7 @@ class _Documents extends StatelessWidget {
const SizedBox(width: 7),
Expanded(
child: MyText.bodySmall(
doc.fileName,
doc.fileName ?? (doc.url ?? '-'),
overflow: TextOverflow.ellipsis,
),
),