417 lines
14 KiB
Dart
417 lines
14 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/route_constants.dart';
|
|
import '../../../../core/errors/failure.dart';
|
|
import '../../../../core/utils/formatters.dart';
|
|
import '../../../../core/utils/responsive_utils.dart';
|
|
import '../../../../shared/models/asset_model.dart';
|
|
import '../../../../shared/providers/table_column_prefs_provider.dart';
|
|
import '../../../../shared/widgets/app_data_table.dart';
|
|
import '../../../../shared/widgets/app_empty_state.dart';
|
|
import '../../../../shared/widgets/app_form_toggle_field.dart';
|
|
import '../../../../shared/widgets/app_loading_view.dart';
|
|
import '../../../../shared/widgets/app_pagination.dart';
|
|
import '../../../../shared/widgets/app_table_action_icon.dart';
|
|
import '../../../../shared/widgets/app_table_column_selector.dart';
|
|
import '../../../../shared/widgets/app_toast.dart';
|
|
import '../../../../shared/widgets/error_view.dart';
|
|
import '../../../../shared/widgets/page_header.dart';
|
|
import '../providers/assets_provider.dart';
|
|
import '../utils/maintenance_due_display.dart';
|
|
import '../widgets/asset_maintenance_panel.dart';
|
|
|
|
class AssetMaintenanceScreen extends ConsumerStatefulWidget {
|
|
const AssetMaintenanceScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<AssetMaintenanceScreen> createState() =>
|
|
_AssetMaintenanceScreenState();
|
|
}
|
|
|
|
class _AssetMaintenanceScreenState
|
|
extends ConsumerState<AssetMaintenanceScreen> {
|
|
bool _requestedFreshLoad = false;
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
if (_requestedFreshLoad) return;
|
|
_requestedFreshLoad = true;
|
|
// Always refetch My Maintenance when opening this screen.
|
|
ref.invalidate(myMaintenanceProvider);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final maintenanceAsync = ref.watch(myMaintenanceProvider);
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: maintenanceAsync.when(
|
|
loading: () =>
|
|
const AppLoadingView(message: 'Loading maintenance assets...'),
|
|
error: (e, _) => ErrorView.fromFailure(
|
|
e is Failure ? e : Failure.unknown(message: e.toString()),
|
|
onRetry: () => ref.invalidate(myMaintenanceProvider),
|
|
),
|
|
data: (state) => _MyMaintenanceBody(state: state),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MyMaintenanceBody extends ConsumerWidget {
|
|
const _MyMaintenanceBody({required this.state});
|
|
|
|
final MyMaintenanceState state;
|
|
|
|
Future<void> _openSubmit(
|
|
BuildContext context,
|
|
WidgetRef ref,
|
|
AssetModel asset,
|
|
) async {
|
|
final saved = await openSubmitMaintenancePanel(context, ref, asset: asset);
|
|
if (!context.mounted) return;
|
|
if (saved == true) {
|
|
await ref.read(myMaintenanceProvider.notifier).refresh();
|
|
if (!context.mounted) return;
|
|
showAppToastFromSnackBar(
|
|
context,
|
|
const SnackBar(content: Text('Maintenance log submitted')),
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final notifier = ref.read(myMaintenanceProvider.notifier);
|
|
final isWide = context.isDesktop;
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
PageHeader(
|
|
title: 'My Maintenance',
|
|
subtitle: 'Assets assigned to you for checklist-based maintenance',
|
|
actions: [
|
|
AppTableColumnSelectorButton(
|
|
tableId: _MaintenanceTable.tableId,
|
|
columns: _MaintenanceTable.columnOptions,
|
|
),
|
|
const SizedBox(width: 8),
|
|
OutlinedButton.icon(
|
|
onPressed: () => context.go(RouteConstants.assets),
|
|
icon: const Icon(Icons.inventory_2_outlined),
|
|
label: const Text('Asset Master'),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Wrap(
|
|
spacing: 16,
|
|
runSpacing: 8,
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
children: [
|
|
AppFormToggleField(
|
|
label: 'Due only',
|
|
subtitle: 'Show assets with maintenance due',
|
|
value: state.dueOnly,
|
|
onChanged: notifier.setDueOnly,
|
|
),
|
|
IconButton(
|
|
onPressed: state.isRefreshing ? null : () => notifier.refresh(),
|
|
icon: state.isRefreshing
|
|
? const SizedBox(
|
|
width: 18,
|
|
height: 18,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.refresh),
|
|
tooltip: 'Refresh',
|
|
),
|
|
Text(
|
|
'${state.total} asset${state.total == 1 ? '' : 's'}',
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
Expanded(
|
|
child: state.assets.isEmpty
|
|
? AppEmptyState(
|
|
title: state.dueOnly
|
|
? 'No due maintenance'
|
|
: 'No maintenance assets',
|
|
description: state.dueOnly
|
|
? 'Nothing is due right now. Turn off “Due only” to see all assigned assets.'
|
|
: 'Assets where you are the maintenance in-charge will appear here.',
|
|
icon: Icons.build_circle_outlined,
|
|
)
|
|
: isWide
|
|
? _MaintenanceTable(
|
|
assets: state.assets,
|
|
onSubmit: (asset) => _openSubmit(context, ref, asset),
|
|
onView: (asset) =>
|
|
context.push('${RouteConstants.assets}/${asset.id}'),
|
|
)
|
|
: _MaintenanceMobileList(
|
|
assets: state.assets,
|
|
onSubmit: (asset) => _openSubmit(context, ref, asset),
|
|
onView: (asset) =>
|
|
context.push('${RouteConstants.assets}/${asset.id}'),
|
|
),
|
|
),
|
|
AppPagination(
|
|
currentPage: state.page,
|
|
totalPages: state.totalPages,
|
|
totalItems: state.total,
|
|
pageSize: state.limit,
|
|
itemsOnPage: state.assets.length,
|
|
onPageChanged: notifier.setPage,
|
|
onPageSizeChanged: notifier.setPageSize,
|
|
itemLabel: 'assets',
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MaintenanceTable extends ConsumerWidget {
|
|
const _MaintenanceTable({
|
|
required this.assets,
|
|
required this.onSubmit,
|
|
required this.onView,
|
|
});
|
|
|
|
static const tableId = 'my_maintenance';
|
|
|
|
static List<AppTableColumnOption> get columnOptions => const [
|
|
AppTableColumnOption(
|
|
id: 'asset_code',
|
|
label: 'Asset Code',
|
|
required: true,
|
|
),
|
|
AppTableColumnOption(id: 'asset_name', label: 'Asset Name'),
|
|
AppTableColumnOption(id: 'next_due', label: 'Next Due'),
|
|
AppTableColumnOption(id: 'status', label: 'Status'),
|
|
];
|
|
|
|
final List<AssetModel> assets;
|
|
final void Function(AssetModel asset) onSubmit;
|
|
final void Function(AssetModel asset) onView;
|
|
|
|
List<AppDataColumn<AssetModel>> _allColumns() {
|
|
final dateFormat = DateFormat('dd MMM yyyy');
|
|
|
|
return [
|
|
AppDataColumn(
|
|
id: 'asset_code',
|
|
label: 'Asset Code',
|
|
sortKey: 'asset_code',
|
|
locked: true,
|
|
flex: 1,
|
|
searchText: (asset) => asset.assetCode ?? '',
|
|
cellBuilder: (_, asset) => AppTableCell.link(
|
|
asset.assetCode,
|
|
onTap: () => onView(asset),
|
|
),
|
|
),
|
|
AppDataColumn(
|
|
id: 'asset_name',
|
|
label: 'Asset Name',
|
|
sortKey: 'asset_name',
|
|
flex: 2,
|
|
searchText: (asset) => asset.assetName,
|
|
cellBuilder: (_, asset) => Text(asset.assetName),
|
|
),
|
|
AppDataColumn(
|
|
id: 'next_due',
|
|
label: 'Next Due',
|
|
sortKey: 'next_due',
|
|
flex: 1,
|
|
searchText: (asset) => DateFormatter.displayDate(
|
|
asset.maintenance?.nextDueDate,
|
|
),
|
|
sortValue: (asset) => asset.maintenance?.nextDueDate,
|
|
cellBuilder: (_, asset) {
|
|
final nextDue = asset.maintenance?.nextDueDate;
|
|
return Text(
|
|
nextDue != null ? dateFormat.format(nextDue) : '—',
|
|
);
|
|
},
|
|
),
|
|
AppDataColumn(
|
|
id: 'status',
|
|
label: 'Status',
|
|
sortKey: 'status',
|
|
flex: 1,
|
|
searchText: (asset) =>
|
|
asset.maintenance?.isDue == true ? 'due' : 'ok',
|
|
cellBuilder: (_, asset) => _DueBadge(
|
|
isDue: asset.maintenance?.isDue == true,
|
|
daysUntilDue: asset.maintenance?.daysUntilDue,
|
|
),
|
|
),
|
|
AppDataColumn(
|
|
id: 'actions',
|
|
label: 'Actions',
|
|
flex: 1,
|
|
alignment: Alignment.centerRight,
|
|
enableSearch: false,
|
|
locked: true,
|
|
includeInColumnSelector: false,
|
|
cellBuilder: (_, asset) => AppTableActions(
|
|
children: [
|
|
AppTableActionIcon(
|
|
tooltip: 'Submit checklist',
|
|
icon: Icons.checklist_outlined,
|
|
onPressed: () => onSubmit(asset),
|
|
),
|
|
AppTableActionIcon(
|
|
tooltip: 'View asset',
|
|
icon: Icons.visibility_outlined,
|
|
onPressed: () => onView(asset),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
];
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final prefs = ref.watch(tableColumnPrefsProvider(tableId));
|
|
final columns = resolveAppDataColumns(_allColumns(), prefs);
|
|
|
|
return AppDataTable<AssetModel>(
|
|
wrapInCard: false,
|
|
columns: columns,
|
|
rows: assets,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MaintenanceMobileList extends StatelessWidget {
|
|
const _MaintenanceMobileList({
|
|
required this.assets,
|
|
required this.onSubmit,
|
|
required this.onView,
|
|
});
|
|
|
|
final List<AssetModel> assets;
|
|
final void Function(AssetModel asset) onSubmit;
|
|
final void Function(AssetModel asset) onView;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final dateFormat = DateFormat('dd MMM yyyy');
|
|
|
|
return ListView.separated(
|
|
itemCount: assets.length,
|
|
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
|
itemBuilder: (context, index) {
|
|
final asset = assets[index];
|
|
final nextDue = asset.maintenance?.nextDueDate;
|
|
final subtitleParts = [
|
|
if (asset.assetCode?.trim().isNotEmpty == true)
|
|
asset.assetCode!.trim(),
|
|
if (nextDue != null) 'Next due: ${dateFormat.format(nextDue)}',
|
|
];
|
|
|
|
return Material(
|
|
color: theme.colorScheme.surfaceContainerHighest.withValues(
|
|
alpha: 0.35,
|
|
),
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(12),
|
|
onTap: () => onSubmit(asset),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
asset.assetName,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: theme.textTheme.titleSmall?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
if (subtitleParts.isNotEmpty) ...[
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
subtitleParts.join(' · '),
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: 8),
|
|
_DueBadge(
|
|
isDue: asset.maintenance?.isDue == true,
|
|
daysUntilDue: asset.maintenance?.daysUntilDue,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
IconButton(
|
|
tooltip: 'View asset',
|
|
onPressed: () => onView(asset),
|
|
icon: const Icon(Icons.visibility_outlined),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DueBadge extends StatelessWidget {
|
|
const _DueBadge({
|
|
required this.isDue,
|
|
this.daysUntilDue,
|
|
});
|
|
|
|
final bool isDue;
|
|
final int? daysUntilDue;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final due = MaintenanceDueDisplay.fromDaysUntilDue(daysUntilDue);
|
|
final color = due?.color ??
|
|
(isDue ? const Color(0xFFDC2626) : const Color(0xFF16A34A));
|
|
final label = due?.label ?? (isDue ? 'Due' : 'On track');
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
|
decoration: BoxDecoration(
|
|
color: color.withValues(alpha: 0.12),
|
|
borderRadius: BorderRadius.circular(999),
|
|
),
|
|
child: Text(
|
|
label,
|
|
style: theme.textTheme.labelSmall?.copyWith(
|
|
color: color,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: 0.2,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|