bharat_erp/lib/modules/assets/presentation/screens/asset_alerts_screen.dart
2026-07-24 14:01:50 +05:30

591 lines
19 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 '../../../../shared/models/asset_model.dart';
import '../../../../shared/widgets/app_card.dart';
import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_pagination.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/page_header.dart';
import '../../../../shared/widgets/app_segmented_tab_bar.dart';
import '../providers/assets_provider.dart';
class AssetAlertsScreen extends ConsumerStatefulWidget {
const AssetAlertsScreen({super.key});
@override
ConsumerState<AssetAlertsScreen> createState() => _AssetAlertsScreenState();
}
class _AssetAlertsScreenState extends ConsumerState<AssetAlertsScreen>
with SingleTickerProviderStateMixin {
late final TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final alertsAsync = ref.watch(assetAlertsProvider);
return Padding(
padding: const EdgeInsets.all(24),
child: alertsAsync.when(
loading: () => const AppLoadingView(message: 'Loading alerts...'),
error: (e, _) => ErrorView.fromFailure(
e is Failure ? e : Failure.unknown(message: e.toString()),
onRetry: () => ref.invalidate(assetAlertsProvider),
),
data: (state) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PageHeader(
title: 'Asset Alerts',
subtitle: 'Expiry and service due notifications',
actions: [
OutlinedButton.icon(
onPressed: () => context.go(RouteConstants.assets),
icon: const Icon(Icons.inventory_2_outlined),
label: const Text('Asset Master'),
),
],
),
AppSegmentedTabBar(
controller: _tabController,
tabs: const [
AppSegmentedTab(
label: 'Expiry Alerts',
icon: Icons.event_busy_outlined,
),
AppSegmentedTab(
label: 'Service Alerts',
icon: Icons.build_circle_outlined,
),
],
),
const SizedBox(height: 12),
Expanded(
child: TabBarView(
controller: _tabController,
clipBehavior: Clip.none,
children: [
_ExpiryAlertsTab(state: state),
_ServiceAlertsTab(state: state),
],
),
),
],
),
),
);
}
}
class _ExpiryAlertsTab extends ConsumerWidget {
const _ExpiryAlertsTab({required this.state});
final AssetAlertsState state;
@override
Widget build(BuildContext context, WidgetRef ref) {
final notifier = ref.read(assetAlertsProvider.notifier);
final dateFormat = DateFormat('dd MMM yyyy');
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 12,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
SizedBox(
width: 160,
child: AppDropdown<int>(
label: 'Days Ahead',
isDense: true,
value: state.expiryDays,
options: const [7, 15, 30, 60, 90]
.map((d) => AppDropdownOption(value: d, label: '$d Days'))
.toList(),
onChanged: (v) {
if (v != null) notifier.setExpiryDays(v);
},
),
),
SizedBox(
width: 180,
child: AppDropdown<String?>(
label: 'Type',
isDense: true,
value: state.expiryType,
options: const [
AppDropdownOption(value: null, label: 'All'),
AppDropdownOption(value: 'AMC', label: 'AMC'),
AppDropdownOption(value: 'INSURANCE', label: 'Insurance'),
AppDropdownOption(value: 'WARRANTY', label: 'Warranty'),
],
onChanged: notifier.setExpiryType,
),
),
Padding(
padding: const EdgeInsets.only(top: 8),
child: IconButton(
onPressed: () => notifier.refresh(),
icon: const Icon(Icons.refresh),
tooltip: 'Refresh',
),
),
],
),
const SizedBox(height: 16),
_AlertsOverview(
total: state.expiryAlerts.length,
label: 'Expiry Alerts In Selected Window',
),
const SizedBox(height: 12),
Expanded(
child: state.expiryAlerts.isEmpty
? const AppEmptyState(
title: 'No expiry alerts',
description: 'No AMC, insurance or warranty expiries in this window.',
icon: Icons.event_available_outlined,
)
: ListView.separated(
itemCount: state.expiryAlerts.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final alert = state.expiryAlerts[index];
return _AlertCard(
alert: alert,
dateLabel: alert.expiryDate != null
? dateFormat.format(alert.expiryDate!)
: null,
onTap: alert.assetId != null
? () => context.push('${RouteConstants.assets}/${alert.assetId}')
: null,
);
},
),
),
AppPagination(
currentPage: state.expiryPage,
totalPages: state.expiryTotalPages,
totalItems: state.expiryAlerts.length,
pageSize: 20,
onPageChanged: notifier.setExpiryPage,
onPageSizeChanged: (_) {},
),
],
);
}
}
class _ServiceAlertsTab extends ConsumerWidget {
const _ServiceAlertsTab({required this.state});
final AssetAlertsState state;
@override
Widget build(BuildContext context, WidgetRef ref) {
final notifier = ref.read(assetAlertsProvider.notifier);
final dateFormat = DateFormat('dd MMM yyyy');
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 12,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
SizedBox(
width: 200,
child: AppDropdown<String?>(
label: 'Status',
isDense: true,
value: state.serviceStatus,
options: const [
AppDropdownOption(value: null, label: 'All'),
AppDropdownOption(value: 'OVERDUE', label: 'Overdue'),
AppDropdownOption(value: 'DUE_THIS_WEEK', label: 'Due This Week'),
AppDropdownOption(value: 'DUE_THIS_MONTH', label: 'Due This Month'),
AppDropdownOption(value: 'UPCOMING', label: 'Upcoming'),
],
onChanged: notifier.setServiceStatus,
),
),
Padding(
padding: const EdgeInsets.only(top: 8),
child: IconButton(
onPressed: () => notifier.refresh(),
icon: const Icon(Icons.refresh),
tooltip: 'Refresh',
),
),
],
),
const SizedBox(height: 16),
_AlertsOverview(
total: state.serviceAlerts.length,
label: 'Service Reminders',
),
const SizedBox(height: 12),
Expanded(
child: state.serviceAlerts.isEmpty
? const AppEmptyState(
title: 'No service alerts',
description: 'All services are up to date.',
icon: Icons.build_circle_outlined,
)
: ListView.separated(
itemCount: state.serviceAlerts.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final alert = state.serviceAlerts[index];
return _AlertCard(
alert: alert,
dateLabel: alert.dueDate != null
? dateFormat.format(alert.dueDate!)
: null,
onTap: alert.assetId != null
? () => context.push('${RouteConstants.assets}/${alert.assetId}')
: null,
);
},
),
),
],
);
}
}
class _AlertCard extends StatelessWidget {
const _AlertCard({
required this.alert,
this.dateLabel,
this.onTap,
});
final AssetAlertModel alert;
final String? dateLabel;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final visual = _alertVisualStyle(alert);
final subtitleParts = [
if (alert.assetCode?.trim().isNotEmpty == true) alert.assetCode!.trim(),
if (alert.locationName?.trim().isNotEmpty == true)
alert.locationName!.trim(),
if (dateLabel != null) 'Due: $dateLabel',
];
return AppCard(
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: onTap,
child: LayoutBuilder(
builder: (context, constraints) {
final isCompact = constraints.maxWidth < 680;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: visual.color.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
_alertTypeIcon(alert.type),
color: visual.color,
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (isCompact) ...[
Text(
alert.assetName ?? alert.title ?? 'Asset ${alert.assetId ?? ''}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
Align(
alignment: Alignment.centerRight,
child: Text(
visual.description,
textAlign: TextAlign.right,
style: theme.textTheme.bodySmall?.copyWith(
color: visual.color,
fontWeight: FontWeight.w600,
),
),
),
] else
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
alert.assetName ??
alert.title ??
'Asset ${alert.assetId ?? ''}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(width: 10),
Flexible(
child: Text(
visual.description,
textAlign: TextAlign.right,
style: theme.textTheme.bodySmall?.copyWith(
color: visual.color,
fontWeight: FontWeight.w600,
),
),
),
],
),
if (subtitleParts.isNotEmpty) ...[
const SizedBox(height: 3),
Text(
subtitleParts.join(' · '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 6,
children: [
if (alert.type?.trim().isNotEmpty == true)
_AlertTag(
label: alert.type!.replaceAll('_', ' ').toUpperCase(),
color: theme.colorScheme.primary,
),
if (alert.status?.trim().isNotEmpty == true)
_AlertTag(
label: alert.status!.replaceAll('_', ' '),
color: theme.colorScheme.secondary,
),
],
),
],
),
),
],
),
);
},
),
),
);
}
}
class _AlertTag extends StatelessWidget {
const _AlertTag({
required this.label,
required this.color,
this.isEmphasized = false,
});
final String label;
final Color color;
final bool isEmphasized;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: color.withValues(alpha: isEmphasized ? 0.16 : 0.12),
borderRadius: BorderRadius.circular(999),
),
child: Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: color,
fontWeight: FontWeight.w700,
letterSpacing: 0.2,
),
),
);
}
}
class _AlertVisualStyle {
const _AlertVisualStyle({
required this.levelLabel,
required this.description,
required this.color,
});
final String levelLabel;
final String description;
final Color color;
}
_AlertVisualStyle _alertVisualStyle(AssetAlertModel alert) {
final days = alert.daysRemaining;
if (days == null) {
return const _AlertVisualStyle(
levelLabel: 'warning',
description: 'Check this asset notification.',
color: Color(0xFF2563EB),
);
}
if (days <= -2) {
final expiredDays = days.abs();
return _AlertVisualStyle(
levelLabel: 'warning',
description: 'Expired $expiredDays day${expiredDays == 1 ? '' : 's'} ago',
color: const Color(0xFFDC2626),
);
}
if (days == -1) {
return const _AlertVisualStyle(
levelLabel: 'warning',
description: 'Expired yesterday',
color: Color(0xFFEA580C),
);
}
if (days == 0) {
return const _AlertVisualStyle(
levelLabel: 'warning',
description: 'Expires today. Immediate action required.',
color: Color(0xFFCA8A04),
);
}
return _AlertVisualStyle(
levelLabel: 'warning',
description: 'Expires in $days day${days == 1 ? '' : 's'}',
color: const Color(0xFF16A34A),
);
}
IconData _alertTypeIcon(String? type) {
switch (type?.toUpperCase()) {
case 'AMC':
return Icons.handshake_outlined;
case 'INSURANCE':
return Icons.shield_outlined;
case 'WARRANTY':
return Icons.verified_user_outlined;
case 'SERVICE':
return Icons.build_outlined;
default:
return Icons.notifications_outlined;
}
}
class _AlertsOverview extends StatelessWidget {
const _AlertsOverview({
required this.total,
required this.label,
});
final int total;
final String label;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(10),
),
child: Wrap(
spacing: 12,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
'$total alerts',
style: theme.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w700),
),
Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const _LegendDot(color: Color(0xFFDC2626), label: 'Expired'),
const _LegendDot(color: Color(0xFFEA580C), label: 'Yesterday'),
const _LegendDot(color: Color(0xFFCA8A04), label: 'Today'),
const _LegendDot(color: Color(0xFF16A34A), label: 'Upcoming'),
],
),
);
}
}
class _LegendDot extends StatelessWidget {
const _LegendDot({
required this.color,
required this.label,
});
final Color color;
final String label;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 6),
Text(label, style: Theme.of(context).textTheme.labelSmall),
],
);
}
}