1647 lines
56 KiB
Dart
1647 lines
56 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:go_router/go_router.dart';
|
||
import 'package:intl/intl.dart';
|
||
|
||
import '../../../../core/constants/enums.dart';
|
||
import '../../../../core/constants/route_constants.dart';
|
||
import '../../../../core/errors/failure.dart';
|
||
import '../../../../core/utils/formatters.dart';
|
||
import '../../../../shared/models/asset_model.dart';
|
||
import '../../../../shared/models/entity_attachment_model.dart';
|
||
import '../../../../shared/models/user_management_models.dart';
|
||
import '../../../../shared/providers/permissions_provider.dart';
|
||
import '../../../../shared/widgets/app_card.dart';
|
||
import '../../../../shared/widgets/app_confirmation_dialog.dart';
|
||
import '../../../../shared/widgets/app_empty_state.dart';
|
||
import '../../../../shared/widgets/app_loading_view.dart';
|
||
import '../../../../shared/widgets/app_status_chip.dart';
|
||
import '../../../../shared/widgets/can_permission.dart';
|
||
import '../../../../shared/widgets/detail_overview_widgets.dart';
|
||
import '../../../../shared/widgets/document_preview_dialog.dart';
|
||
import '../../../../shared/widgets/entity_attachments_card.dart';
|
||
import '../../../../shared/widgets/error_view.dart';
|
||
import '../../../../shared/widgets/page_header.dart';
|
||
import '../../../../shared/widgets/app_side_panel.dart';
|
||
import '../../../../shared/widgets/app_segmented_tab_bar.dart';
|
||
import '../providers/assets_provider.dart';
|
||
import '../providers/asset_form_lookups_provider.dart';
|
||
import '../utils/maintenance_due_display.dart';
|
||
import 'asset_form_screen.dart';
|
||
import '../widgets/asset_maintenance_panel.dart';
|
||
import '../widgets/asset_side_panels.dart';
|
||
import '../../../../shared/widgets/app_toast.dart';
|
||
|
||
class AssetDetailScreen extends ConsumerStatefulWidget {
|
||
const AssetDetailScreen({super.key, required this.assetId});
|
||
|
||
final String assetId;
|
||
|
||
@override
|
||
ConsumerState<AssetDetailScreen> createState() => _AssetDetailScreenState();
|
||
}
|
||
|
||
class _AssetDetailScreenState extends ConsumerState<AssetDetailScreen>
|
||
with SingleTickerProviderStateMixin {
|
||
late final TabController _tabController;
|
||
bool _requestedFreshLoad = false;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_tabController = TabController(length: 4, vsync: this);
|
||
}
|
||
|
||
@override
|
||
void didChangeDependencies() {
|
||
super.didChangeDependencies();
|
||
if (_requestedFreshLoad) return;
|
||
_requestedFreshLoad = true;
|
||
// Always hit GET /assets/{id} (+ related) when opening view.
|
||
ref.invalidate(assetDetailProvider(widget.assetId));
|
||
}
|
||
|
||
@override
|
||
void didUpdateWidget(covariant AssetDetailScreen oldWidget) {
|
||
super.didUpdateWidget(oldWidget);
|
||
if (oldWidget.assetId != widget.assetId) {
|
||
ref.invalidate(assetDetailProvider(widget.assetId));
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_tabController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final detailAsync = ref.watch(assetDetailProvider(widget.assetId));
|
||
final canEdit = ref.can('assets', PermissionAction.update);
|
||
final canDelete = ref.can('assets', PermissionAction.delete);
|
||
|
||
return detailAsync.when(
|
||
loading: () => const AppLoadingView(message: 'Loading asset details...'),
|
||
error: (e, _) => ErrorView.fromFailure(
|
||
e is Failure ? e : Failure.unknown(message: e.toString()),
|
||
onRetry: () => ref.invalidate(assetDetailProvider(widget.assetId)),
|
||
),
|
||
data: (state) => Padding(
|
||
padding: const EdgeInsets.all(24),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
PageHeader(
|
||
leading: IconButton(
|
||
icon: const Icon(Icons.arrow_back),
|
||
tooltip: 'Back to Asset Master',
|
||
onPressed: () => context.go(RouteConstants.assets),
|
||
),
|
||
title: state.asset.assetName,
|
||
titleTrailing: AppStatusChip(
|
||
status: state.asset.status ?? 'IN_USE',
|
||
compact: true,
|
||
),
|
||
subtitle: state.asset.assetCode ?? 'Asset ID: ${state.asset.id}',
|
||
actions: [
|
||
if (canEdit)
|
||
OutlinedButton.icon(
|
||
onPressed: () =>
|
||
openAssetForm(context, ref, assetId: widget.assetId),
|
||
icon: const Icon(Icons.edit_outlined),
|
||
label: const Text('Edit'),
|
||
),
|
||
if (canEdit) ...[
|
||
const SizedBox(width: 8),
|
||
OutlinedButton.icon(
|
||
onPressed: _showTransferDialog,
|
||
icon: const Icon(Icons.swap_horiz),
|
||
label: const Text('Transfer'),
|
||
),
|
||
],
|
||
if (canDelete) ...[
|
||
const SizedBox(width: 8),
|
||
OutlinedButton.icon(
|
||
onPressed: _deleteAsset,
|
||
icon: const Icon(Icons.delete_outline),
|
||
label: const Text('Delete'),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
const SizedBox(height: 16),
|
||
AppSegmentedTabBar(
|
||
controller: _tabController,
|
||
tabs: const [
|
||
AppSegmentedTab(
|
||
label: 'Overview',
|
||
icon: Icons.dashboard_outlined,
|
||
),
|
||
AppSegmentedTab(
|
||
label: 'AMC',
|
||
icon: Icons.handshake_outlined,
|
||
),
|
||
AppSegmentedTab(
|
||
label: 'Service Visits',
|
||
icon: Icons.build_outlined,
|
||
),
|
||
AppSegmentedTab(
|
||
label: 'Insurance',
|
||
icon: Icons.health_and_safety_outlined,
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 16),
|
||
Expanded(
|
||
child: TabBarView(
|
||
controller: _tabController,
|
||
children: [
|
||
_OverviewTab(
|
||
assetId: widget.assetId,
|
||
asset: state.asset,
|
||
attachments: state.attachments,
|
||
canUpload: canEdit,
|
||
canDelete: canDelete,
|
||
onOpenTransferHistory: _openTransferHistoryPanel,
|
||
),
|
||
_AmcTab(assetId: widget.assetId, contracts: state.amcContracts),
|
||
_ServiceVisitsTab(
|
||
assetId: widget.assetId,
|
||
visits: state.serviceVisits,
|
||
amcContracts: state.amcContracts,
|
||
),
|
||
_InsuranceTab(assetId: widget.assetId, policies: state.insurancePolicies),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<void> _deleteAsset() async {
|
||
final confirmed = await showAppConfirmationDialog(
|
||
context: context,
|
||
title: 'Delete Asset',
|
||
message: 'Do you want to delete this asset?',
|
||
confirmLabel: 'Delete',
|
||
isDestructive: true,
|
||
);
|
||
if (confirmed != true || !mounted) return;
|
||
|
||
final deleted = await ref.read(assetDetailProvider(widget.assetId).notifier).deleteAsset();
|
||
if (deleted && mounted) context.go(RouteConstants.assets);
|
||
}
|
||
|
||
Future<void> _showTransferDialog() async {
|
||
final transferred = await showSidePanel<bool>(
|
||
context,
|
||
TransferAssetPanel(assetId: widget.assetId),
|
||
width: 520,
|
||
);
|
||
if (transferred == true && mounted) {
|
||
showAppToastFromSnackBar(context,
|
||
const SnackBar(content: Text('Asset transferred successfully')),
|
||
);
|
||
}
|
||
}
|
||
|
||
Future<void> _openTransferHistoryPanel() async {
|
||
await showSidePanel<void>(
|
||
context,
|
||
TransferHistoryPanel(assetId: widget.assetId),
|
||
width: 520,
|
||
);
|
||
}
|
||
}
|
||
|
||
class _OverviewTab extends ConsumerWidget {
|
||
const _OverviewTab({
|
||
required this.assetId,
|
||
required this.asset,
|
||
required this.attachments,
|
||
required this.canUpload,
|
||
required this.canDelete,
|
||
required this.onOpenTransferHistory,
|
||
});
|
||
|
||
final String assetId;
|
||
final AssetModel asset;
|
||
final List<EntityAttachmentModel> attachments;
|
||
final bool canUpload;
|
||
final bool canDelete;
|
||
final VoidCallback onOpenTransferHistory;
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final theme = Theme.of(context);
|
||
final dateFormat = DateFormat('dd MMM yyyy');
|
||
final users =
|
||
ref.watch(assetFormLookupsProvider).valueOrNull?.users ?? const [];
|
||
final maintenanceInchargeLabel = _userLabel(
|
||
asset.maintenanceInchargeUserId,
|
||
users,
|
||
);
|
||
final daysUntilDueDisplay = MaintenanceDueDisplay.fromDaysUntilDue(
|
||
asset.maintenance?.daysUntilDue,
|
||
);
|
||
final retainedPct = _retainedPercentage(
|
||
currentValue: asset.resolvedCurrentValue,
|
||
purchaseCost: asset.purchaseCost,
|
||
);
|
||
final scheme = theme.colorScheme;
|
||
final activeColor =
|
||
asset.isActive ? const Color(0xFF16A34A) : scheme.onSurfaceVariant;
|
||
|
||
return SingleChildScrollView(
|
||
child: Center(
|
||
child: ConstrainedBox(
|
||
constraints: const BoxConstraints(maxWidth: 1200),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
AppCard(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(24),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
'Asset Details',
|
||
style: theme.textTheme.labelLarge?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
),
|
||
Container(
|
||
width: 8,
|
||
height: 8,
|
||
decoration: BoxDecoration(
|
||
color: activeColor,
|
||
shape: BoxShape.circle,
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: activeColor.withValues(alpha: 0.35),
|
||
blurRadius: 6,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Text(
|
||
asset.isActive ? 'Active' : 'Inactive',
|
||
style: theme.textTheme.labelMedium?.copyWith(
|
||
color: activeColor,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 16),
|
||
|
||
// 1) Identity
|
||
DetailOverviewSection(
|
||
title: 'Identity',
|
||
child: DetailInfoGrid(
|
||
items: [
|
||
DetailInfoItem('Asset Name', asset.assetName),
|
||
DetailInfoItem('Asset Code', asset.assetCode ?? '—'),
|
||
DetailInfoItem('Location', asset.locationName ?? '—'),
|
||
DetailInfoItem(
|
||
'Asset Category',
|
||
asset.assetCategoryName ?? '—',
|
||
),
|
||
DetailInfoItem(
|
||
'Subcategory',
|
||
asset.assetSubcategoryName ?? '—',
|
||
),
|
||
DetailInfoItem(
|
||
'Manufacturer',
|
||
asset.manufacturer ?? '—',
|
||
),
|
||
DetailInfoItem(
|
||
'Brand / Model',
|
||
asset.brandModel ?? '—',
|
||
),
|
||
DetailInfoItem(
|
||
'Serial Number',
|
||
asset.serialNumber ?? '—',
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
// 2) Purchase & Warranty
|
||
DetailOverviewSection(
|
||
title: 'Purchase & Warranty',
|
||
child: DetailInfoGrid(
|
||
items: [
|
||
DetailInfoItem(
|
||
'Purchase Date - Warranty Expiry',
|
||
_purchaseWarrantyRange(
|
||
purchaseDate: asset.purchaseDate,
|
||
warrantyExpiryDate: asset.warrantyExpiryDate,
|
||
dateFormat: dateFormat,
|
||
),
|
||
),
|
||
DetailInfoItem(
|
||
'Purchase Cost',
|
||
asset.purchaseCost != null
|
||
? CurrencyFormatter.format(asset.purchaseCost)
|
||
: '—',
|
||
),
|
||
DetailInfoItem.widget(
|
||
'PO Number',
|
||
_DocumentNumberLink(
|
||
label: asset.poNumber,
|
||
enabled: asset.poId != null &&
|
||
(asset.poNumber?.trim().isNotEmpty ??
|
||
false),
|
||
onTap: () {
|
||
final poId = asset.poId;
|
||
if (poId == null) return;
|
||
showPurchaseOrderPreviewDialog(
|
||
context,
|
||
purchaseOrderId: poId.toString(),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
DetailInfoItem.widget(
|
||
'Purchase Receipt Number',
|
||
_DocumentNumberLink(
|
||
label: asset.grnNumber,
|
||
enabled: asset.grnId != null &&
|
||
(asset.grnNumber?.trim().isNotEmpty ??
|
||
false),
|
||
onTap: () {
|
||
final grnId = asset.grnId;
|
||
if (grnId == null) return;
|
||
showGrnPreviewDialog(
|
||
context,
|
||
grnId: grnId.toString(),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
// 4) Valuation & Depreciation
|
||
DetailOverviewSection(
|
||
title: 'Valuation & Depreciation',
|
||
child: _ValuationDepreciationCard(
|
||
currentValue: asset.resolvedCurrentValue,
|
||
purchaseCost: asset.purchaseCost,
|
||
retainedPct: retainedPct,
|
||
commencementDate: asset.commencementDate,
|
||
usefulLifeYears: asset.usefulLifeYears,
|
||
depreciationMethod: asset.depreciationMethod,
|
||
depreciationRate: asset.depreciationRate,
|
||
annualDepreciation:
|
||
asset.depreciation?.annualDepreciation,
|
||
depreciatedAmount: asset.depreciatedAmount,
|
||
salvageValue: asset.salvageValue,
|
||
salvagePercentage: asset.salvagePercentage,
|
||
dateFormat: dateFormat,
|
||
),
|
||
),
|
||
|
||
// 5) Maintenance
|
||
DetailOverviewSection(
|
||
title: 'Maintenance',
|
||
showDivider: false,
|
||
child: DetailInfoGrid(
|
||
items: [
|
||
DetailInfoItem(
|
||
'Maintenance Incharge',
|
||
maintenanceInchargeLabel,
|
||
),
|
||
DetailInfoItem(
|
||
'Maintenance Frequency',
|
||
asset.maintenanceFrequencyInDays != null
|
||
? '${asset.maintenanceFrequencyInDays} days'
|
||
: '—',
|
||
),
|
||
DetailInfoItem.widget(
|
||
'Next Due Date',
|
||
_nextDueDateValue(
|
||
theme: theme,
|
||
nextDueDate: asset.maintenance?.nextDueDate,
|
||
dateFormat: dateFormat,
|
||
daysUntilDueDisplay: daysUntilDueDisplay,
|
||
),
|
||
),
|
||
DetailInfoItem(
|
||
'Condition',
|
||
assetConditionLabel(asset.condition),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
if (asset.maintenanceFrequencyInDays != null ||
|
||
asset.maintenanceChecklistJson?.isNotEmpty == true ||
|
||
asset.maintenance != null) ...[
|
||
const Padding(
|
||
padding: EdgeInsets.symmetric(vertical: 16),
|
||
child: Divider(height: 1),
|
||
),
|
||
AssetRecentMaintenanceLogsSection(
|
||
assetId: assetId,
|
||
leadingActions: [
|
||
OutlinedButton.icon(
|
||
onPressed: () async {
|
||
final saved = await openSubmitMaintenancePanel(
|
||
context,
|
||
ref,
|
||
asset: asset,
|
||
);
|
||
if (saved == true && context.mounted) {
|
||
ref.invalidate(myMaintenanceProvider);
|
||
ref.invalidate(assetDetailProvider(assetId));
|
||
showAppToastFromSnackBar(
|
||
context,
|
||
const SnackBar(
|
||
content:
|
||
Text('Maintenance log submitted'),
|
||
),
|
||
);
|
||
}
|
||
},
|
||
icon: const Icon(Icons.checklist_outlined),
|
||
label: const Text('Log Maintenance'),
|
||
),
|
||
],
|
||
trailingActions: [
|
||
OutlinedButton.icon(
|
||
onPressed: onOpenTransferHistory,
|
||
icon: const Icon(Icons.history, size: 18),
|
||
label: const Text('Transfer History'),
|
||
),
|
||
],
|
||
),
|
||
] else ...[
|
||
const SizedBox(height: 8),
|
||
Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: OutlinedButton.icon(
|
||
onPressed: onOpenTransferHistory,
|
||
icon: const Icon(Icons.history, size: 18),
|
||
label: const Text('Transfer History'),
|
||
),
|
||
),
|
||
],
|
||
if (asset.remarks?.trim().isNotEmpty == true) ...[
|
||
const Padding(
|
||
padding: EdgeInsets.symmetric(vertical: 20),
|
||
child: Divider(height: 1),
|
||
),
|
||
DetailInfoGrid(
|
||
items: [DetailInfoItem('Remarks', asset.remarks!)],
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
EntityAttachmentsCard(
|
||
attachments: attachments,
|
||
canUpload: canUpload,
|
||
canDelete: canDelete,
|
||
subtitleWhenEditable:
|
||
'PDF, JPEG, PNG, or WebP · invoices, warranty cards, photos',
|
||
subtitleWhenReadonly: 'Supporting documents for this asset',
|
||
emptyUploadHint:
|
||
'No attachments yet. Upload invoices, warranty cards, or photos.',
|
||
onUpload: ({required bytes, required filename}) async {
|
||
await ref
|
||
.read(assetDetailProvider(assetId).notifier)
|
||
.uploadAttachment(bytes: bytes, filename: filename);
|
||
},
|
||
onDownload: (id) => ref
|
||
.read(assetDetailProvider(assetId).notifier)
|
||
.downloadAttachment(id),
|
||
onDelete: (id) => ref
|
||
.read(assetDetailProvider(assetId).notifier)
|
||
.deleteAttachment(id),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
String _purchaseWarrantyRange({
|
||
required DateTime? purchaseDate,
|
||
required DateTime? warrantyExpiryDate,
|
||
required DateFormat dateFormat,
|
||
}) {
|
||
final purchase =
|
||
purchaseDate != null ? dateFormat.format(purchaseDate) : '—';
|
||
final warranty =
|
||
warrantyExpiryDate != null ? dateFormat.format(warrantyExpiryDate) : '—';
|
||
return '$purchase - $warranty';
|
||
}
|
||
|
||
class _DocumentNumberLink extends StatelessWidget {
|
||
const _DocumentNumberLink({
|
||
required this.label,
|
||
required this.enabled,
|
||
required this.onTap,
|
||
});
|
||
|
||
final String? label;
|
||
final bool enabled;
|
||
final VoidCallback onTap;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final text = label?.trim().isNotEmpty == true ? label!.trim() : '—';
|
||
if (!enabled) {
|
||
return Text(
|
||
text,
|
||
style: theme.textTheme.titleSmall?.copyWith(
|
||
color: theme.colorScheme.onSurface,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
);
|
||
}
|
||
|
||
return InkWell(
|
||
onTap: onTap,
|
||
borderRadius: BorderRadius.circular(4),
|
||
child: Text(
|
||
text,
|
||
style: theme.textTheme.titleSmall?.copyWith(
|
||
color: theme.colorScheme.primary,
|
||
fontWeight: FontWeight.w700,
|
||
decoration: TextDecoration.underline,
|
||
decorationColor: theme.colorScheme.primary.withValues(alpha: 0.45),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
Widget _nextDueDateValue({
|
||
required ThemeData theme,
|
||
required DateTime? nextDueDate,
|
||
required DateFormat dateFormat,
|
||
required MaintenanceDueDisplay? daysUntilDueDisplay,
|
||
}) {
|
||
final dateLabel =
|
||
nextDueDate != null ? dateFormat.format(nextDueDate) : '—';
|
||
final valueStyle = theme.textTheme.titleSmall?.copyWith(
|
||
color: theme.colorScheme.onSurface,
|
||
fontWeight: FontWeight.w600,
|
||
);
|
||
|
||
if (daysUntilDueDisplay == null) {
|
||
return Text(dateLabel, style: valueStyle);
|
||
}
|
||
|
||
return Text.rich(
|
||
TextSpan(
|
||
style: valueStyle,
|
||
children: [
|
||
TextSpan(text: dateLabel),
|
||
TextSpan(
|
||
text: ' (${daysUntilDueDisplay.label})',
|
||
style: valueStyle?.copyWith(
|
||
color: daysUntilDueDisplay.color,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
double? _retainedPercentage({
|
||
required double? currentValue,
|
||
required double? purchaseCost,
|
||
}) {
|
||
if (currentValue == null || purchaseCost == null || purchaseCost <= 0) {
|
||
return null;
|
||
}
|
||
return (currentValue / purchaseCost * 100).clamp(0, 100);
|
||
}
|
||
|
||
class _ValuationDepreciationCard extends StatelessWidget {
|
||
const _ValuationDepreciationCard({
|
||
required this.currentValue,
|
||
required this.purchaseCost,
|
||
required this.retainedPct,
|
||
required this.commencementDate,
|
||
required this.usefulLifeYears,
|
||
required this.depreciationMethod,
|
||
required this.depreciationRate,
|
||
required this.annualDepreciation,
|
||
required this.depreciatedAmount,
|
||
required this.salvageValue,
|
||
required this.salvagePercentage,
|
||
required this.dateFormat,
|
||
});
|
||
|
||
final double? currentValue;
|
||
final double? purchaseCost;
|
||
final double? retainedPct;
|
||
final DateTime? commencementDate;
|
||
final int? usefulLifeYears;
|
||
final String? depreciationMethod;
|
||
final double? depreciationRate;
|
||
final double? annualDepreciation;
|
||
final double? depreciatedAmount;
|
||
final double? salvageValue;
|
||
final double? salvagePercentage;
|
||
final DateFormat dateFormat;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final scheme = theme.colorScheme;
|
||
final onCard = scheme.onPrimary;
|
||
final muted = onCard.withValues(alpha: 0.72);
|
||
final progress = retainedPct == null ? 0.0 : retainedPct! / 100;
|
||
|
||
final subtitleParts = <String>[];
|
||
if (purchaseCost != null) {
|
||
subtitleParts.add(
|
||
'of ${CurrencyFormatter.format(purchaseCost)} purchase cost',
|
||
);
|
||
}
|
||
if (commencementDate != null) {
|
||
subtitleParts.add('commenced ${dateFormat.format(commencementDate!)}');
|
||
}
|
||
|
||
final methodLabel = depreciationMethod == null
|
||
? '—'
|
||
: '$depreciationMethod'
|
||
'${depreciationRate != null ? ' ($depreciationRate%)' : ''}';
|
||
|
||
final detailItems = <(String, String)>[
|
||
(
|
||
'Commencement Date',
|
||
commencementDate != null ? dateFormat.format(commencementDate!) : '—',
|
||
),
|
||
(
|
||
'Useful Life',
|
||
usefulLifeYears != null ? '$usefulLifeYears years' : '—',
|
||
),
|
||
('Depreciation Method', methodLabel),
|
||
(
|
||
'Annual Depreciation',
|
||
annualDepreciation != null
|
||
? CurrencyFormatter.format(annualDepreciation)
|
||
: '—',
|
||
),
|
||
(
|
||
'Depreciated Amount',
|
||
depreciatedAmount != null
|
||
? CurrencyFormatter.format(depreciatedAmount)
|
||
: '—',
|
||
),
|
||
(
|
||
'Salvage Value',
|
||
salvageValue != null ? CurrencyFormatter.format(salvageValue) : '—',
|
||
),
|
||
(
|
||
'Salvage Percentage',
|
||
salvagePercentage != null ? '$salvagePercentage%' : '—',
|
||
),
|
||
];
|
||
|
||
return Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.all(20),
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(16),
|
||
gradient: LinearGradient(
|
||
begin: Alignment.topLeft,
|
||
end: Alignment.bottomRight,
|
||
colors: [
|
||
scheme.primary,
|
||
Color.lerp(scheme.primary, scheme.secondary, 0.55)!,
|
||
scheme.secondary,
|
||
],
|
||
stops: const [0, 0.55, 1],
|
||
),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: scheme.primary.withValues(alpha: 0.28),
|
||
blurRadius: 18,
|
||
offset: const Offset(0, 8),
|
||
),
|
||
],
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.center,
|
||
children: [
|
||
SizedBox(
|
||
width: 88,
|
||
height: 88,
|
||
child: Stack(
|
||
alignment: Alignment.center,
|
||
children: [
|
||
SizedBox(
|
||
width: 88,
|
||
height: 88,
|
||
child: CircularProgressIndicator(
|
||
value: progress,
|
||
strokeWidth: 7,
|
||
backgroundColor: onCard.withValues(alpha: 0.18),
|
||
color: Color.lerp(scheme.secondary, Colors.white, 0.45)!,
|
||
strokeCap: StrokeCap.round,
|
||
),
|
||
),
|
||
Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
retainedPct == null
|
||
? '—'
|
||
: '${retainedPct!.toStringAsFixed(2)}%',
|
||
style: theme.textTheme.titleMedium?.copyWith(
|
||
color: onCard,
|
||
fontWeight: FontWeight.w800,
|
||
),
|
||
),
|
||
Text(
|
||
'RETAINED',
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: muted,
|
||
fontWeight: FontWeight.w600,
|
||
letterSpacing: 0.6,
|
||
fontSize: 9,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: 18),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'CURRENT VALUE',
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: muted,
|
||
fontWeight: FontWeight.w600,
|
||
letterSpacing: 0.8,
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
currentValue != null
|
||
? CurrencyFormatter.format(currentValue)
|
||
: '—',
|
||
style: theme.textTheme.headlineSmall?.copyWith(
|
||
color: onCard,
|
||
fontWeight: FontWeight.w800,
|
||
),
|
||
),
|
||
if (subtitleParts.isNotEmpty) ...[
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
subtitleParts.join(' · '),
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: muted,
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 18),
|
||
Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: onCard.withValues(alpha: 0.10),
|
||
borderRadius: BorderRadius.circular(12),
|
||
border: Border.all(color: onCard.withValues(alpha: 0.12)),
|
||
),
|
||
child: LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
final cols = constraints.maxWidth < 560
|
||
? 2
|
||
: constraints.maxWidth < 820
|
||
? 3
|
||
: 4;
|
||
const spacing = 16.0;
|
||
final colWidth = (constraints.maxWidth - spacing * (cols - 1)) /
|
||
cols;
|
||
return Wrap(
|
||
spacing: spacing,
|
||
runSpacing: 14,
|
||
children: [
|
||
for (final item in detailItems)
|
||
SizedBox(
|
||
width: colWidth,
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
item.$1,
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: muted,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
item.$2,
|
||
style: theme.textTheme.bodyMedium?.copyWith(
|
||
color: onCard,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
String _userLabel(int? userId, List<FilterOptionModel> users) {
|
||
if (userId == null || userId <= 0) return '—';
|
||
final id = userId.toString();
|
||
for (final user in users) {
|
||
if (user.id == id && user.name.trim().isNotEmpty) {
|
||
return user.name.trim();
|
||
}
|
||
}
|
||
return id;
|
||
}
|
||
|
||
class _AmcTab extends ConsumerWidget {
|
||
const _AmcTab({required this.assetId, required this.contracts});
|
||
|
||
final String assetId;
|
||
final List<AmcContractModel> contracts;
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final vendors = ref.watch(assetFormLookupsProvider).valueOrNull?.vendors ??
|
||
const <FilterOptionModel>[];
|
||
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
CanPermission(
|
||
module: 'assets',
|
||
action: PermissionAction.create,
|
||
child: Align(
|
||
alignment: Alignment.centerRight,
|
||
child: ElevatedButton.icon(
|
||
onPressed: () => _openAddAmcPanel(context, ref),
|
||
icon: const Icon(Icons.add),
|
||
label: const Text('Add AMC'),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
Expanded(
|
||
child: contracts.isEmpty
|
||
? const AppEmptyState(
|
||
title: 'No AMC contracts',
|
||
description: 'Add annual maintenance contracts for this asset.',
|
||
icon: Icons.handyman_outlined,
|
||
)
|
||
: GridView.builder(
|
||
padding: EdgeInsets.zero,
|
||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||
maxCrossAxisExtent: 320,
|
||
mainAxisExtent: 175,
|
||
crossAxisSpacing: 16,
|
||
mainAxisSpacing: 16,
|
||
),
|
||
itemCount: contracts.length,
|
||
itemBuilder: (context, index) {
|
||
return _AssetAmcCard(
|
||
contract: contracts[index],
|
||
vendors: vendors,
|
||
onEdit: () => _openEditAmcPanel(
|
||
context,
|
||
ref,
|
||
contracts[index].id,
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Future<void> _openAddAmcPanel(BuildContext context, WidgetRef ref) async {
|
||
final saved = await showSidePanel<bool>(
|
||
context,
|
||
AddAmcPanel(assetId: assetId),
|
||
width: 520,
|
||
);
|
||
if (saved == true && context.mounted) {
|
||
showAppToastFromSnackBar(context,
|
||
const SnackBar(content: Text('AMC contract created')),
|
||
);
|
||
}
|
||
}
|
||
|
||
Future<void> _openEditAmcPanel(
|
||
BuildContext context,
|
||
WidgetRef ref,
|
||
String contractId,
|
||
) async {
|
||
final saved = await showSidePanel<bool>(
|
||
context,
|
||
AddAmcPanel(assetId: assetId, contractId: contractId),
|
||
width: 520,
|
||
);
|
||
if (saved == true && context.mounted) {
|
||
showAppToastFromSnackBar(context,
|
||
const SnackBar(content: Text('AMC contract updated')),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
class _ServiceVisitsTab extends ConsumerWidget {
|
||
const _ServiceVisitsTab({
|
||
required this.assetId,
|
||
required this.visits,
|
||
required this.amcContracts,
|
||
});
|
||
|
||
final String assetId;
|
||
final List<ServiceVisitModel> visits;
|
||
final List<AmcContractModel> amcContracts;
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
CanPermission(
|
||
module: 'assets',
|
||
action: PermissionAction.create,
|
||
child: Align(
|
||
alignment: Alignment.centerRight,
|
||
child: ElevatedButton.icon(
|
||
onPressed: () => _openLogVisitPanel(context, ref),
|
||
icon: const Icon(Icons.add),
|
||
label: const Text('Log Visit'),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
Expanded(
|
||
child: visits.isEmpty
|
||
? const AppEmptyState(
|
||
title: 'No service visits',
|
||
description: 'Log service visits and maintenance work for this asset.',
|
||
icon: Icons.build_outlined,
|
||
)
|
||
: GridView.builder(
|
||
padding: EdgeInsets.zero,
|
||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||
maxCrossAxisExtent: 320,
|
||
mainAxisExtent: 175,
|
||
crossAxisSpacing: 16,
|
||
mainAxisSpacing: 16,
|
||
),
|
||
itemCount: visits.length,
|
||
itemBuilder: (context, index) {
|
||
return _AssetServiceVisitCard(
|
||
visit: visits[index],
|
||
onEdit: () => _openEditVisitPanel(
|
||
context,
|
||
ref,
|
||
visits[index].id,
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Future<void> _openLogVisitPanel(BuildContext context, WidgetRef ref) async {
|
||
final saved = await showSidePanel<bool>(
|
||
context,
|
||
LogServiceVisitPanel(
|
||
assetId: assetId,
|
||
amcContracts: amcContracts,
|
||
),
|
||
width: 560,
|
||
);
|
||
if (saved == true && context.mounted) {
|
||
showAppToastFromSnackBar(context,
|
||
const SnackBar(content: Text('Service visit logged')),
|
||
);
|
||
}
|
||
}
|
||
|
||
Future<void> _openEditVisitPanel(
|
||
BuildContext context,
|
||
WidgetRef ref,
|
||
String visitId,
|
||
) async {
|
||
final saved = await showSidePanel<bool>(
|
||
context,
|
||
LogServiceVisitPanel(
|
||
assetId: assetId,
|
||
visitId: visitId,
|
||
amcContracts: amcContracts,
|
||
),
|
||
width: 560,
|
||
);
|
||
if (saved == true && context.mounted) {
|
||
showAppToastFromSnackBar(context,
|
||
const SnackBar(content: Text('Service visit updated')),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
class _InsuranceTab extends ConsumerWidget {
|
||
const _InsuranceTab({required this.assetId, required this.policies});
|
||
|
||
final String assetId;
|
||
final List<InsurancePolicyModel> policies;
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
CanPermission(
|
||
module: 'assets',
|
||
action: PermissionAction.create,
|
||
child: Align(
|
||
alignment: Alignment.centerRight,
|
||
child: ElevatedButton.icon(
|
||
onPressed: () => _openAddInsurancePanel(context, ref),
|
||
icon: const Icon(Icons.add),
|
||
label: const Text('Add Policy'),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
Expanded(
|
||
child: policies.isEmpty
|
||
? const AppEmptyState(
|
||
title: 'No insurance policies',
|
||
description: 'Add insurance policies for this asset.',
|
||
icon: Icons.shield_outlined,
|
||
)
|
||
: GridView.builder(
|
||
padding: EdgeInsets.zero,
|
||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||
maxCrossAxisExtent: 320,
|
||
mainAxisExtent: 175,
|
||
crossAxisSpacing: 16,
|
||
mainAxisSpacing: 16,
|
||
),
|
||
itemCount: policies.length,
|
||
itemBuilder: (context, index) {
|
||
return _AssetInsuranceCard(
|
||
policy: policies[index],
|
||
onEdit: () => _openEditInsurancePanel(
|
||
context,
|
||
ref,
|
||
policies[index].id,
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Future<void> _openAddInsurancePanel(BuildContext context, WidgetRef ref) async {
|
||
final saved = await showSidePanel<bool>(
|
||
context,
|
||
AddInsurancePanel(assetId: assetId),
|
||
width: 520,
|
||
);
|
||
if (saved == true && context.mounted) {
|
||
showAppToastFromSnackBar(context,
|
||
const SnackBar(content: Text('Insurance policy created')),
|
||
);
|
||
}
|
||
}
|
||
|
||
Future<void> _openEditInsurancePanel(
|
||
BuildContext context,
|
||
WidgetRef ref,
|
||
String policyId,
|
||
) async {
|
||
final saved = await showSidePanel<bool>(
|
||
context,
|
||
AddInsurancePanel(assetId: assetId, policyId: policyId),
|
||
width: 520,
|
||
);
|
||
if (saved == true && context.mounted) {
|
||
showAppToastFromSnackBar(context,
|
||
const SnackBar(content: Text('Insurance policy updated')),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
class _AssetAmcCard extends StatelessWidget {
|
||
const _AssetAmcCard({
|
||
required this.contract,
|
||
required this.vendors,
|
||
this.onEdit,
|
||
});
|
||
|
||
final AmcContractModel contract;
|
||
final List<FilterOptionModel> vendors;
|
||
final VoidCallback? onEdit;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final dateFormat = DateFormat('dd MMM yyyy');
|
||
final statusColor = _assetStatusColor(contract.status, theme);
|
||
final vendor = _contractVendorLabel(contract, vendors);
|
||
|
||
return AppCard(
|
||
elevation: 0,
|
||
enableHover: true,
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Container(
|
||
width: 36,
|
||
height: 36,
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFCA8A04).withValues(alpha: 0.12),
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: const Icon(
|
||
Icons.handyman_outlined,
|
||
color: Color(0xFFCA8A04),
|
||
size: 20,
|
||
),
|
||
),
|
||
const Spacer(),
|
||
if (onEdit != null)
|
||
CanPermission(
|
||
module: 'assets',
|
||
action: PermissionAction.update,
|
||
child: IconButton(
|
||
icon: const Icon(Icons.edit_outlined, size: 18),
|
||
tooltip: 'Edit',
|
||
visualDensity: VisualDensity.compact,
|
||
padding: EdgeInsets.zero,
|
||
constraints: const BoxConstraints(
|
||
minWidth: 32,
|
||
minHeight: 32,
|
||
),
|
||
onPressed: onEdit,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
contract.contractNo?.trim().isNotEmpty == true
|
||
? contract.contractNo!
|
||
: 'AMC #${contract.id}',
|
||
style: theme.textTheme.titleSmall?.copyWith(
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
vendor,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Row(
|
||
children: [
|
||
Icon(
|
||
Icons.calendar_today_outlined,
|
||
size: 14,
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
const SizedBox(width: 4),
|
||
Expanded(
|
||
child: Text(
|
||
'${dateFormat.format(contract.startDate)} – '
|
||
'${dateFormat.format(contract.endDate)}',
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const Spacer(),
|
||
Row(
|
||
children: [
|
||
if (contract.annualCost != null)
|
||
Text(
|
||
'₹${contract.annualCost}',
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
const Spacer(),
|
||
Container(
|
||
width: 6,
|
||
height: 6,
|
||
decoration: BoxDecoration(
|
||
color: statusColor,
|
||
shape: BoxShape.circle,
|
||
),
|
||
),
|
||
const SizedBox(width: 6),
|
||
Text(
|
||
_assetStatusLabel(contract.status),
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: statusColor,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _AssetServiceVisitCard extends StatelessWidget {
|
||
const _AssetServiceVisitCard({
|
||
required this.visit,
|
||
this.onEdit,
|
||
});
|
||
|
||
final ServiceVisitModel visit;
|
||
final VoidCallback? onEdit;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final dateFormat = DateFormat('dd MMM yyyy');
|
||
final statusColor = _assetStatusColor(visit.status, theme);
|
||
|
||
return AppCard(
|
||
elevation: 0,
|
||
enableHover: true,
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Container(
|
||
width: 36,
|
||
height: 36,
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFF2563EB).withValues(alpha: 0.12),
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: const Icon(
|
||
Icons.build_outlined,
|
||
color: Color(0xFF2563EB),
|
||
size: 20,
|
||
),
|
||
),
|
||
const Spacer(),
|
||
if (onEdit != null)
|
||
CanPermission(
|
||
module: 'assets',
|
||
action: PermissionAction.update,
|
||
child: IconButton(
|
||
icon: const Icon(Icons.edit_outlined, size: 18),
|
||
tooltip: 'Edit',
|
||
visualDensity: VisualDensity.compact,
|
||
padding: EdgeInsets.zero,
|
||
constraints: const BoxConstraints(
|
||
minWidth: 32,
|
||
minHeight: 32,
|
||
),
|
||
onPressed: onEdit,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
visit.visitType,
|
||
style: theme.textTheme.titleSmall?.copyWith(
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
if (visit.workDone?.trim().isNotEmpty == true) ...[
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
visit.workDone!,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
],
|
||
const SizedBox(height: 8),
|
||
Row(
|
||
children: [
|
||
Icon(
|
||
Icons.event_outlined,
|
||
size: 14,
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
const SizedBox(width: 4),
|
||
Expanded(
|
||
child: Text(
|
||
dateFormat.format(visit.visitDate),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const Spacer(),
|
||
Row(
|
||
children: [
|
||
if (visit.nextServiceDate != null)
|
||
Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(
|
||
Icons.schedule_outlined,
|
||
size: 14,
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
const SizedBox(width: 4),
|
||
Text(
|
||
dateFormat.format(visit.nextServiceDate!),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const Spacer(),
|
||
Container(
|
||
width: 6,
|
||
height: 6,
|
||
decoration: BoxDecoration(
|
||
color: statusColor,
|
||
shape: BoxShape.circle,
|
||
),
|
||
),
|
||
const SizedBox(width: 6),
|
||
Text(
|
||
_assetStatusLabel(visit.status),
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: statusColor,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _AssetInsuranceCard extends StatelessWidget {
|
||
const _AssetInsuranceCard({
|
||
required this.policy,
|
||
this.onEdit,
|
||
});
|
||
|
||
final InsurancePolicyModel policy;
|
||
final VoidCallback? onEdit;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final dateFormat = DateFormat('dd MMM yyyy');
|
||
final statusColor = _assetStatusColor(policy.status, theme);
|
||
|
||
return AppCard(
|
||
elevation: 0,
|
||
enableHover: true,
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Container(
|
||
width: 36,
|
||
height: 36,
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFF16A34A).withValues(alpha: 0.12),
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: const Icon(
|
||
Icons.shield_outlined,
|
||
color: Color(0xFF16A34A),
|
||
size: 20,
|
||
),
|
||
),
|
||
const Spacer(),
|
||
if (onEdit != null)
|
||
CanPermission(
|
||
module: 'assets',
|
||
action: PermissionAction.update,
|
||
child: IconButton(
|
||
icon: const Icon(Icons.edit_outlined, size: 18),
|
||
tooltip: 'Edit',
|
||
visualDensity: VisualDensity.compact,
|
||
padding: EdgeInsets.zero,
|
||
constraints: const BoxConstraints(
|
||
minWidth: 32,
|
||
minHeight: 32,
|
||
),
|
||
onPressed: onEdit,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
policy.policyNo,
|
||
style: theme.textTheme.titleSmall?.copyWith(
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
policy.insurerName,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Row(
|
||
children: [
|
||
Icon(
|
||
Icons.calendar_today_outlined,
|
||
size: 14,
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
const SizedBox(width: 4),
|
||
Expanded(
|
||
child: Text(
|
||
'${dateFormat.format(policy.policyStartDate)} – '
|
||
'${dateFormat.format(policy.policyEndDate)}',
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const Spacer(),
|
||
Row(
|
||
children: [
|
||
if (policy.sumInsured != null)
|
||
Text(
|
||
'₹${policy.sumInsured}',
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
const Spacer(),
|
||
Container(
|
||
width: 6,
|
||
height: 6,
|
||
decoration: BoxDecoration(
|
||
color: statusColor,
|
||
shape: BoxShape.circle,
|
||
),
|
||
),
|
||
const SizedBox(width: 6),
|
||
Text(
|
||
_assetStatusLabel(policy.status),
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: statusColor,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
Color _assetStatusColor(String status, ThemeData theme) {
|
||
switch (status.toLowerCase()) {
|
||
case 'active':
|
||
case 'completed':
|
||
return const Color(0xFF16A34A);
|
||
case 'pending':
|
||
case 'scheduled':
|
||
return const Color(0xFFCA8A04);
|
||
case 'cancelled':
|
||
case 'inactive':
|
||
case 'expired':
|
||
return theme.colorScheme.onSurfaceVariant;
|
||
default:
|
||
return const Color(0xFF2563EB);
|
||
}
|
||
}
|
||
|
||
String _contractVendorLabel(
|
||
AmcContractModel contract,
|
||
List<FilterOptionModel> vendors,
|
||
) {
|
||
final direct = contract.vendorName?.trim();
|
||
if (direct != null && direct.isNotEmpty) return direct;
|
||
|
||
final vendorId = contract.vendorId;
|
||
if (vendorId != null) {
|
||
final id = vendorId.toString();
|
||
for (final vendor in vendors) {
|
||
if (vendor.id == id && vendor.name.trim().isNotEmpty) {
|
||
return vendor.name.trim();
|
||
}
|
||
}
|
||
}
|
||
|
||
return '—';
|
||
}
|
||
|
||
String _assetStatusLabel(String status) {
|
||
if (status.isEmpty) return '—';
|
||
final normalized = status.replaceAll('_', ' ').toLowerCase();
|
||
return normalized.split(' ').map((word) {
|
||
if (word.isEmpty) return word;
|
||
return '${word[0].toUpperCase()}${word.substring(1)}';
|
||
}).join(' ');
|
||
}
|