marco.pms.mobileapp/lib/view/finance/payment_request_detail_screen.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,
),
),
],
),
],
),
),
);
},
)
],
);
}
}