536 lines
19 KiB
Dart
536 lines
19 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:get/get.dart';
|
|
import 'package:marco/controller/finance/payment_request_detail_controller.dart';
|
|
import 'package:marco/controller/project_controller.dart';
|
|
import 'package:marco/controller/permission_controller.dart';
|
|
import 'package:marco/helpers/utils/date_time_utils.dart';
|
|
import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
|
|
import 'package:marco/helpers/widgets/my_spacing.dart';
|
|
import 'package:marco/helpers/widgets/my_text.dart';
|
|
import 'package:timeline_tile/timeline_tile.dart';
|
|
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
|
|
import 'package:marco/model/finance/payment_request_details_model.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
import 'package:marco/helpers/widgets/image_viewer_dialog.dart';
|
|
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
|
|
import 'package:timeago/timeago.dart' as timeago;
|
|
|
|
class PaymentRequestDetailScreen extends StatefulWidget {
|
|
final String paymentRequestId;
|
|
const PaymentRequestDetailScreen({super.key, required this.paymentRequestId});
|
|
|
|
@override
|
|
State<PaymentRequestDetailScreen> createState() =>
|
|
_PaymentRequestDetailScreenState();
|
|
}
|
|
|
|
class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
|
|
with UIMixin {
|
|
final controller = Get.put(PaymentRequestDetailController());
|
|
final projectController = Get.find<ProjectController>();
|
|
final permissionController = Get.find<PermissionController>();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
controller.init(widget.paymentRequestId);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
backgroundColor: Colors.white,
|
|
appBar: _buildAppBar(),
|
|
body: SafeArea(
|
|
child: Obx(() {
|
|
if (controller.isLoading.value) {
|
|
return SkeletonLoaders.paymentRequestDetailSkeletonLoader();
|
|
}
|
|
|
|
final request =
|
|
controller.paymentRequest.value as PaymentRequestData?;
|
|
if (controller.errorMessage.isNotEmpty || request == null) {
|
|
return Center(child: MyText.bodyMedium("No data to display."));
|
|
}
|
|
|
|
return MyRefreshIndicator(
|
|
onRefresh: controller.fetchPaymentRequestDetail,
|
|
child: SingleChildScrollView(
|
|
padding: EdgeInsets.fromLTRB(
|
|
12, 12, 12, 30 + MediaQuery.of(context).padding.bottom),
|
|
child: Center(
|
|
child: Container(
|
|
constraints: const BoxConstraints(maxWidth: 520),
|
|
child: Card(
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(5)),
|
|
elevation: 3,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
vertical: 14, horizontal: 14),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_Header(request: request),
|
|
const Divider(height: 30, thickness: 1.2),
|
|
|
|
// Move Logs here, right after header
|
|
_Logs(logs: request.updateLogs),
|
|
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),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
PreferredSizeWidget _buildAppBar() {
|
|
return PreferredSize(
|
|
preferredSize: const Size.fromHeight(72),
|
|
child: AppBar(
|
|
backgroundColor: const Color(0xFFF5F5F5),
|
|
elevation: 0.5,
|
|
automaticallyImplyLeading: false,
|
|
titleSpacing: 0,
|
|
title: Padding(
|
|
padding: MySpacing.xy(16, 0),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
IconButton(
|
|
icon: const Icon(Icons.arrow_back_ios_new,
|
|
color: Colors.black, size: 20),
|
|
onPressed: () => Get.back(),
|
|
),
|
|
MySpacing.width(8),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
MyText.titleLarge(
|
|
'Payment Request Details',
|
|
fontWeight: 700,
|
|
color: Colors.black,
|
|
),
|
|
MySpacing.height(2),
|
|
GetBuilder<ProjectController>(
|
|
builder: (_) {
|
|
final name = projectController.selectedProject?.name ??
|
|
'Select Project';
|
|
return Row(
|
|
children: [
|
|
const Icon(Icons.work_outline,
|
|
size: 14, color: Colors.grey),
|
|
MySpacing.width(4),
|
|
Expanded(
|
|
child: MyText.bodySmall(
|
|
name,
|
|
fontWeight: 600,
|
|
overflow: TextOverflow.ellipsis,
|
|
color: Colors.grey[700],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Header Row
|
|
class _Header extends StatelessWidget {
|
|
final PaymentRequestData request;
|
|
const _Header({required this.request});
|
|
|
|
// Helper to parse hex color string to Color
|
|
Color parseColorFromHex(String hexColor) {
|
|
hexColor = hexColor.toUpperCase().replaceAll("#", "");
|
|
if (hexColor.length == 6) {
|
|
hexColor = "FF" + hexColor; // Add alpha if missing
|
|
}
|
|
return Color(int.parse(hexColor, radix: 16));
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final statusColor = parseColorFromHex(request.expenseStatus.color);
|
|
|
|
return Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
// Left side: wrap in Expanded to prevent overflow
|
|
Expanded(
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.calendar_month, size: 18, color: Colors.grey),
|
|
MySpacing.width(6),
|
|
MyText.bodySmall('Created At:', fontWeight: 600),
|
|
MySpacing.width(6),
|
|
Expanded(
|
|
child: MyText.bodySmall(
|
|
DateTimeUtils.convertUtcToLocal(
|
|
request.createdAt.toIso8601String(),
|
|
format: 'dd MMM yyyy'),
|
|
fontWeight: 600,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// Right side: Status Chip
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
color: statusColor.withOpacity(0.15),
|
|
borderRadius: BorderRadius.circular(5)),
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.flag, size: 16, color: statusColor),
|
|
MySpacing.width(4),
|
|
SizedBox(
|
|
// Prevent overflow of long status text
|
|
width: 100,
|
|
child: MyText.labelSmall(
|
|
request.expenseStatus.displayName,
|
|
color: statusColor,
|
|
fontWeight: 600,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
// Horizontal label-value row
|
|
Widget labelValueRow(String label, String value) => Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SizedBox(
|
|
width: 120,
|
|
child: MyText.bodySmall(
|
|
label,
|
|
fontWeight: 600,
|
|
),
|
|
),
|
|
Expanded(
|
|
child: MyText.bodySmall(
|
|
value,
|
|
fontWeight: 500,
|
|
softWrap: true,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
// Parties Section
|
|
class _Parties extends StatelessWidget {
|
|
final PaymentRequestData request;
|
|
const _Parties({required this.request});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
labelValueRow('Project', request.project.name),
|
|
labelValueRow('Payee', request.payee),
|
|
labelValueRow('Created By',
|
|
'${request.createdBy.firstName} ${request.createdBy.lastName}'),
|
|
labelValueRow('Pre-Approved', request.isAdvancePayment ? 'Yes' : 'No'),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
// Details Table
|
|
class _DetailsTable extends StatelessWidget {
|
|
final PaymentRequestData request;
|
|
const _DetailsTable({required this.request});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
labelValueRow("Payment Request ID:", request.paymentRequestUID),
|
|
labelValueRow("Expense Category:", request.expenseCategory.name),
|
|
labelValueRow("Amount:",
|
|
"${request.currency.symbol} ${request.amount.toStringAsFixed(2)}"),
|
|
labelValueRow(
|
|
"Due Date:",
|
|
DateTimeUtils.convertUtcToLocal(request.dueDate.toIso8601String(),
|
|
format: 'dd MMM yyyy'),
|
|
),
|
|
labelValueRow("Description:", request.description),
|
|
labelValueRow(
|
|
"Attachment:", request.attachments.isNotEmpty ? "Yes" : "No"),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
// Documents Section
|
|
class _Documents extends StatelessWidget {
|
|
final List<dynamic> documents;
|
|
const _Documents({required this.documents});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (documents.isEmpty)
|
|
return MyText.bodyMedium('No Documents', color: Colors.grey);
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
MyText.bodySmall("Documents:", fontWeight: 600),
|
|
const SizedBox(height: 12),
|
|
ListView.separated(
|
|
shrinkWrap: true,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
itemCount: documents.length,
|
|
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
|
itemBuilder: (context, index) {
|
|
final doc = documents[index] as Map<String, dynamic>;
|
|
return GestureDetector(
|
|
onTap: () async {
|
|
final imageDocs = documents
|
|
.where((d) =>
|
|
(d['contentType'] as String).startsWith('image/'))
|
|
.toList();
|
|
|
|
final initialIndex =
|
|
imageDocs.indexWhere((d) => d['id'] == doc['id']);
|
|
|
|
if (imageDocs.isNotEmpty && initialIndex != -1) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (_) => ImageViewerDialog(
|
|
imageSources:
|
|
imageDocs.map((e) => e['url'] as String).toList(),
|
|
initialIndex: initialIndex,
|
|
),
|
|
);
|
|
} else {
|
|
final Uri url = Uri.parse(doc['url'] as String);
|
|
if (await canLaunchUrl(url)) {
|
|
await launchUrl(url, mode: LaunchMode.externalApplication);
|
|
} else {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Could not open document.')),
|
|
);
|
|
}
|
|
}
|
|
},
|
|
child: Container(
|
|
padding:
|
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: Colors.grey.shade300),
|
|
borderRadius: BorderRadius.circular(5),
|
|
color: Colors.grey.shade100,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
(doc['contentType'] as String).startsWith('image/')
|
|
? Icons.image
|
|
: Icons.insert_drive_file,
|
|
size: 20,
|
|
color: Colors.grey[600],
|
|
),
|
|
const SizedBox(width: 7),
|
|
Expanded(
|
|
child: MyText.labelSmall(
|
|
doc['fileName'] ?? '',
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Logs extends StatelessWidget {
|
|
final List<dynamic> logs;
|
|
const _Logs({required this.logs});
|
|
|
|
// Helper to parse hex color string to Color
|
|
Color parseColorFromHex(String hexColor) {
|
|
hexColor = hexColor.toUpperCase().replaceAll("#", "");
|
|
if (hexColor.length == 6) {
|
|
hexColor = "FF" + hexColor; // Add alpha for opacity if missing
|
|
}
|
|
return Color(int.parse(hexColor, radix: 16));
|
|
}
|
|
|
|
DateTime parseTimestamp(String ts) => DateTime.parse(ts);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (logs.isEmpty) 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] as Map<String, dynamic>;
|
|
final statusMap = log['status'] ?? {};
|
|
final status = statusMap['name'] ?? '';
|
|
final description = statusMap['description'] ?? '';
|
|
final comment = log['comment'] ?? '';
|
|
|
|
final nextStatusMap = log['nextStatus'] ?? {};
|
|
final nextStatusName = nextStatusMap['name'] ?? '';
|
|
|
|
final updatedBy = log['updatedBy'] ?? {};
|
|
final initials =
|
|
"${(updatedBy['firstName'] ?? '').isNotEmpty ? (updatedBy['firstName']![0]) : ''}"
|
|
"${(updatedBy['lastName'] ?? '').isNotEmpty ? (updatedBy['lastName']![0]) : ''}";
|
|
final name =
|
|
"${updatedBy['firstName'] ?? ''} ${updatedBy['lastName'] ?? ''}";
|
|
|
|
final timestamp = parseTimestamp(log['updatedAt']);
|
|
final timeAgo = timeago.format(timestamp);
|
|
|
|
final statusColor = statusMap['color'] != null
|
|
? parseColorFromHex(statusMap['color'])
|
|
: Colors.black;
|
|
|
|
final nextStatusColor = nextStatusMap['color'] != null
|
|
? parseColorFromHex(nextStatusMap['color'])
|
|
: Colors.blue.shade700;
|
|
|
|
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: [
|
|
// Status and time in one row
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
)
|
|
],
|
|
);
|
|
}
|
|
}
|