744 lines
26 KiB
Dart
744 lines
26 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;
|
|
import 'package:marco/model/expense/comment_bottom_sheet.dart';
|
|
import 'package:marco/helpers/services/storage/local_storage.dart';
|
|
import 'package:marco/model/employees/employee_info.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/make_expense_bottom_sheet.dart';
|
|
import 'package:marco/model/finance/add_payment_request_bottom_sheet.dart';
|
|
|
|
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.put(PermissionController());
|
|
final RxBool canSubmit = false.obs;
|
|
bool _checkedPermission = false;
|
|
|
|
EmployeeInfo? employeeInfo;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
controller.init(widget.paymentRequestId);
|
|
_loadEmployeeInfo();
|
|
}
|
|
|
|
void _checkPermissionToSubmit(PaymentRequestData request) {
|
|
const draftStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7';
|
|
final isCreatedByCurrentUser = employeeInfo?.id == request.createdBy.id;
|
|
final hasDraftNextStatus =
|
|
request.nextStatus.any((s) => s.id == draftStatusId);
|
|
canSubmit.value = isCreatedByCurrentUser && hasDraftNextStatus;
|
|
}
|
|
|
|
Future<void> _loadEmployeeInfo() async {
|
|
employeeInfo = await LocalStorage.getEmployeeInfo();
|
|
setState(() {});
|
|
}
|
|
|
|
Color _parseColor(String hexColor) {
|
|
String hex = hexColor.toUpperCase().replaceAll('#', '');
|
|
if (hex.length == 6) hex = 'FF$hex';
|
|
return Color(int.parse(hex, radix: 16));
|
|
}
|
|
|
|
void _openEditPaymentRequestBottomSheet(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
|
|
.map((a) => {
|
|
"url": a.url,
|
|
"fileName": a.fileName,
|
|
"documentId": a.id,
|
|
"contentType": a.contentType,
|
|
})
|
|
.toList(),
|
|
},
|
|
onUpdated: () async {
|
|
await controller.fetchPaymentRequestDetail();
|
|
},
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
backgroundColor: Colors.white,
|
|
appBar: _buildAppBar(),
|
|
body: SafeArea(child: Obx(() {
|
|
if (controller.isLoading.value &&
|
|
controller.paymentRequest.value == null) {
|
|
return SkeletonLoaders.paymentRequestDetailSkeletonLoader();
|
|
}
|
|
|
|
final request = controller.paymentRequest.value;
|
|
|
|
if (controller.errorMessage.isNotEmpty) {
|
|
return Center(
|
|
child: MyText.bodyMedium(controller.errorMessage.value));
|
|
}
|
|
|
|
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),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
})),
|
|
bottomNavigationBar: _buildBottomActionBar(),
|
|
);
|
|
}
|
|
|
|
Widget _buildBottomActionBar() {
|
|
return Obx(() {
|
|
final request = controller.paymentRequest.value;
|
|
if (request == null ||
|
|
controller.isLoading.value ||
|
|
employeeInfo == null) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
if (!_checkedPermission) {
|
|
_checkedPermission = true;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_checkPermissionToSubmit(request);
|
|
});
|
|
}
|
|
|
|
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;
|
|
}
|
|
return permissionController
|
|
.hasAnyPermission(status.permissionIds ?? []);
|
|
}).toList();
|
|
|
|
return SafeArea(
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
border: Border(top: BorderSide(color: Colors.grey.shade300)),
|
|
),
|
|
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
|
child: Wrap(
|
|
alignment: WrapAlignment.center,
|
|
spacing: 10,
|
|
runSpacing: 10,
|
|
children: availableStatuses.map((status) {
|
|
final color = _parseColor(status.color);
|
|
|
|
return ElevatedButton(
|
|
style: ElevatedButton.styleFrom(
|
|
padding:
|
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
|
backgroundColor: color,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
onPressed: () async {
|
|
if (status.id == reimbursementStatusId) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius:
|
|
BorderRadius.vertical(top: Radius.circular(5)),
|
|
),
|
|
builder: (ctx) => UpdatePaymentRequestWithReimbursement(
|
|
expenseId: request.paymentRequestUID,
|
|
statusId: status.id,
|
|
onClose: () {},
|
|
),
|
|
);
|
|
} else if (status.id ==
|
|
'b8586f67-dc19-49c3-b4af-224149efe1d3') {
|
|
showCreateExpenseBottomSheet(
|
|
statusId: status.id,
|
|
);
|
|
} else {
|
|
final comment = await showCommentBottomSheet(
|
|
context, status.displayName);
|
|
if (comment == null || comment.trim().isEmpty) return;
|
|
|
|
final success = await controller.updatePaymentRequestStatus(
|
|
statusId: status.id,
|
|
comment: comment.trim(),
|
|
);
|
|
|
|
showAppSnackbar(
|
|
title: success ? 'Success' : 'Error',
|
|
message: success
|
|
? 'Status updated successfully'
|
|
: 'Failed to update status',
|
|
type: success ? SnackbarType.success : SnackbarType.error,
|
|
);
|
|
|
|
if (success) await controller.fetchPaymentRequestDetail();
|
|
}
|
|
},
|
|
child: MyText.bodySmall(
|
|
status.displayName,
|
|
color: Colors.white,
|
|
fontWeight: 600,
|
|
),
|
|
);
|
|
}).toList(),
|
|
),
|
|
),
|
|
);
|
|
});
|
|
}
|
|
|
|
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],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
// ------------------ Sub-widgets ------------------
|
|
|
|
class _Header extends StatelessWidget with UIMixin {
|
|
final PaymentRequestData request;
|
|
final Color Function(String) colorParser;
|
|
final VoidCallback? onEdit;
|
|
final EmployeeInfo? employeeInfo;
|
|
|
|
_Header({
|
|
required this.request,
|
|
required this.colorParser,
|
|
this.onEdit,
|
|
this.employeeInfo,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final statusColor = colorParser(request.expenseStatus.color);
|
|
|
|
final canEdit = employeeInfo != null &&
|
|
PaymentRequestPermissionHelper.canEditPaymentRequest(
|
|
employeeInfo, request);
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
MyText.bodyMedium(
|
|
'ID: ${request.paymentRequestUID}',
|
|
fontWeight: 700,
|
|
fontSize: 14,
|
|
),
|
|
if (canEdit)
|
|
IconButton(
|
|
onPressed: onEdit,
|
|
icon: Icon(Icons.edit, color: contentTheme.primary),
|
|
tooltip: "Edit Payment Request",
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
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(
|
|
width: 100,
|
|
child: MyText.labelSmall(
|
|
request.expenseStatus.displayName,
|
|
color: statusColor,
|
|
fontWeight: 600,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
// ------------------ Logs, Parties, Details, Documents ------------------
|
|
|
|
class _Logs extends StatelessWidget {
|
|
final List<UpdateLog> logs;
|
|
final Color Function(String) colorParser;
|
|
const _Logs({required this.logs, required this.colorParser});
|
|
|
|
DateTime _parseTimestamp(DateTime ts) => 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];
|
|
|
|
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 {
|
|
final PaymentRequestData request;
|
|
const _Parties({required this.request});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
MyText.bodySmall("Parties:", fontWeight: 600),
|
|
MySpacing.height(8),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
MyText.labelMedium("Payee", fontWeight: 600),
|
|
MySpacing.height(2),
|
|
MyText.bodyMedium(request.payee),
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
MyText.labelMedium("Project", fontWeight: 600),
|
|
MySpacing.height(2),
|
|
MyText.bodyMedium(request.project.name),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
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"),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Documents extends StatelessWidget {
|
|
final List<Attachment> 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),
|
|
MySpacing.height(12),
|
|
ListView.separated(
|
|
shrinkWrap: true,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
itemCount: documents.length,
|
|
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
|
itemBuilder: (context, index) {
|
|
final doc = documents[index];
|
|
final isImage = doc.contentType.startsWith('image/');
|
|
|
|
return GestureDetector(
|
|
onTap: () async {
|
|
final imageDocs = documents
|
|
.where((d) => d.contentType.startsWith('image/'))
|
|
.toList();
|
|
final initialIndex =
|
|
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(),
|
|
initialIndex: initialIndex,
|
|
),
|
|
);
|
|
} else {
|
|
final Uri url = Uri.parse(doc.url);
|
|
if (await canLaunchUrl(url)) {
|
|
await launchUrl(url, mode: LaunchMode.externalApplication);
|
|
} else {
|
|
showAppSnackbar(
|
|
title: 'Error',
|
|
message: 'Could not open document.',
|
|
type: SnackbarType.error,
|
|
);
|
|
}
|
|
}
|
|
},
|
|
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(isImage ? Icons.image : Icons.insert_drive_file,
|
|
size: 20, color: Colors.grey[600]),
|
|
const SizedBox(width: 7),
|
|
Expanded(
|
|
child: MyText.bodySmall(
|
|
doc.fileName,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
// Utility widget for 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),
|
|
),
|
|
],
|
|
),
|
|
);
|