import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/constants/enums.dart'; import '../../../../core/errors/failure.dart'; import '../../../../core/utils/formatters.dart'; import '../../../../core/utils/responsive_utils.dart'; import '../../../../shared/models/audit_log_model.dart'; import '../../../../shared/providers/permissions_provider.dart'; import '../../../../shared/utils/file_download_helper.dart'; import '../../../../shared/widgets/app_card.dart'; import '../../../../shared/widgets/app_data_table.dart'; import '../../../../shared/widgets/app_date_range_popup.dart'; import '../../../../shared/widgets/app_dropdown.dart'; import '../../../../shared/widgets/app_empty_state.dart'; import '../../../../shared/widgets/app_filter_date_field.dart'; import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_pagination.dart'; import '../../../../shared/widgets/app_responsive_filter_bar.dart'; import '../../../../shared/widgets/app_search_field.dart'; import '../../../../shared/widgets/app_search_filter_toggle.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/app_side_panel.dart'; import '../../../../shared/widgets/app_status_chip.dart'; import '../../../../shared/widgets/app_table_action_icon.dart'; import '../../../../shared/widgets/app_table_column_selector.dart'; import '../../../../shared/widgets/app_table_shell.dart'; import '../../../../shared/providers/table_column_prefs_provider.dart'; import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/page_header.dart'; import '../providers/audit_provider.dart'; import '../widgets/audit_log_detail_panel.dart'; import '../../../../shared/widgets/app_toast.dart'; class AuditLogsScreen extends ConsumerStatefulWidget { const AuditLogsScreen({super.key}); @override ConsumerState createState() => _AuditLogsScreenState(); } class _AuditLogsScreenState extends ConsumerState { final _searchController = TextEditingController(); bool _filtersExpanded = false; @override void dispose() { _searchController.dispose(); super.dispose(); } Future _exportLogs() async { final file = await ref.read(auditLogsListProvider.notifier).exportLogs(); if (!mounted) return; if (file == null) { final error = ref.read(auditLogsListProvider).valueOrNull?.actionError; if (error != null) { showAppToastFromSnackBar(context, SnackBar(content: Text(error))); } return; } final saved = await downloadFile( bytes: file.bytes, fileName: file.fileName, ); if (!mounted) return; showAppToastFromSnackBar(context, SnackBar( content: Text(saved ? 'Downloaded ${file.fileName}' : 'Export cancelled'), ), ); } Future _viewLog(AuditLogEntryModel log) async { ref.invalidate(auditLogDetailProvider(log.id)); await showSidePanel( context, AuditLogDetailPanel(logId: log.id), width: 560, ); } Future _pickDateRange(AuditLogListQuery query) async { final now = DateTime.now(); final initial = (query.dateFrom != null && query.dateTo != null) ? DateTimeRange(start: query.dateFrom!, end: query.dateTo!) : null; final picked = await showAppDateRangePopup( context: context, firstDate: DateTime(now.year - 5), lastDate: DateTime(now.year + 1), initialDateRange: initial, helpText: 'Filter by date range', ); if (picked == null) return; final start = DateTime(picked.start.year, picked.start.month, picked.start.day); final end = DateTime( picked.end.year, picked.end.month, picked.end.day, 23, 59, 59, ); ref.read(auditLogsListProvider.notifier).setDateRange(start, end); } @override Widget build(BuildContext context) { final logsAsync = ref.watch(auditLogsListProvider); final canExport = ref.can('audit_logs', PermissionAction.export); ref.listen(auditLogsListProvider, (prev, next) { final error = next.valueOrNull?.actionError; if (error != null && error != prev?.valueOrNull?.actionError) { showAppToastFromSnackBar(context, SnackBar(content: Text(error))); } }); return Padding( padding: const EdgeInsets.all(24), child: logsAsync.when( loading: () => const AppLoadingView(message: 'Loading audit logs...'), error: (error, _) => ErrorView.fromFailure( error is Failure ? error : Failure.unknown(message: error.toString()), onRetry: () => ref.invalidate(auditLogsListProvider), ), data: (state) { final notifier = ref.read(auditLogsListProvider.notifier); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ PageHeader( title: 'Audit Logs', subtitle: 'System activity and change history', actions: [ AppSearchFilterButton( expanded: _filtersExpanded, onPressed: () => setState( () => _filtersExpanded = !_filtersExpanded, ), ), const SizedBox(width: 8), AppTableColumnSelectorButton( tableId: _AuditDataTable.tableId, columns: _AuditDataTable.columnOptions, ), if (canExport) ...[ const SizedBox(width: 8), OutlinedButton.icon( onPressed: state.isExporting || !state.query.hasActiveFilter ? null : _exportLogs, icon: state.isExporting ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.download_outlined), label: Text(state.isExporting ? 'Exporting...' : 'Export'), ), ], ], ), Expanded( child: AppTableShell( toolbarExpanded: _filtersExpanded, toolbar: _FiltersBar( searchController: _searchController, filters: state.filters, query: state.query, onSearch: notifier.setSearch, onTableChanged: notifier.setTableName, onActionChanged: notifier.setAction, onPerformerChanged: notifier.setPerformedBy, onPickDateRange: () => _pickDateRange(state.query), onClearDateRange: () => notifier.setDateRange(null, null), onReset: () { _searchController.clear(); notifier.resetFilters(); }, ), footer: AppPagination( currentPage: state.query.page, totalPages: state.totalPages, totalItems: state.total, pageSize: state.query.limit, itemsOnPage: state.items.length, itemLabel: 'audit logs', onPageChanged: notifier.setPage, onPageSizeChanged: notifier.setPageSize, ), child: RefreshIndicator( onRefresh: notifier.refresh, child: state.filtersRequired && state.items.isEmpty ? ListView( physics: const AlwaysScrollableScrollPhysics(), children: const [ SizedBox( height: 260, child: AppEmptyState( title: 'Apply a filter to view logs', description: 'Select a table, action, user, or date range to load audit history.', icon: Icons.filter_alt_outlined, ), ), ], ) : context.isMobile ? (state.items.isEmpty ? ListView( physics: const AlwaysScrollableScrollPhysics(), children: const [ SizedBox( height: 260, child: AppEmptyState( title: 'No audit logs found', description: 'Try adjusting filters or expanding the date range.', icon: Icons.history_outlined, ), ), ], ) : _AuditCardList( items: state.items, onView: _viewLog, )) : _AuditDataTable( items: state.items, onView: _viewLog, onEnsureFullDataset: () => notifier.ensureColumnSearchDataset(), onColumnSearchCleared: () => notifier.clearColumnSearchDataset(), ), ), ), ), ], ); }, ), ); } } class _FiltersBar extends StatelessWidget { const _FiltersBar({ required this.searchController, required this.filters, required this.query, required this.onSearch, required this.onTableChanged, required this.onActionChanged, required this.onPerformerChanged, required this.onPickDateRange, required this.onClearDateRange, required this.onReset, }); final TextEditingController searchController; final AuditLogFilterOptions filters; final AuditLogListQuery query; final ValueChanged onSearch; final ValueChanged onTableChanged; final ValueChanged onActionChanged; final ValueChanged onPerformerChanged; final VoidCallback onPickDateRange; final VoidCallback onClearDateRange; final VoidCallback onReset; String get _dateValue { if (query.dateFrom == null && query.dateTo == null) return ''; final from = DateFormatter.displayDate(query.dateFrom); final to = DateFormatter.displayDate(query.dateTo); return '$from – $to'; } bool get _dateEmpty => query.dateFrom == null && query.dateTo == null; @override Widget build(BuildContext context) { final searchField = AppSearchField( controller: searchController, hint: 'Search table, action, request ID...', onChanged: onSearch, onClear: () => onSearch(''), ); final tableDropdown = AppSearchableDropdown( label: 'Table', value: query.tableName, searchHint: 'Search table...', isDense: true, options: [ const AppDropdownOption(value: null, label: 'All Tables'), ...filters.tableNames.map( (name) => AppDropdownOption( value: name, label: humanizeLabel(name), ), ), ], onChanged: onTableChanged, ); final actionDropdown = AppSearchableDropdown( label: 'Action', value: query.action, searchHint: 'Search action...', isDense: true, options: [ const AppDropdownOption(value: null, label: 'All Actions'), ...filters.actions.map( (action) => AppDropdownOption(value: action, label: action), ), ], onChanged: onActionChanged, ); final performerDropdown = AppSearchableDropdown( label: 'Performed By', value: query.performedBy, searchHint: 'Search user...', isDense: true, options: [ const AppDropdownOption(value: null, label: 'All Users'), ...filters.performers.map( (user) => AppDropdownOption( value: int.tryParse(user.id), label: user.label, ), ), ], onChanged: onPerformerChanged, ); final dateField = AppFilterDateField( label: 'Date Range', value: _dateValue, placeholder: 'Select range', icon: Icons.date_range_outlined, isEmpty: _dateEmpty, onTap: onPickDateRange, onClear: _dateEmpty ? null : onClearDateRange, ); final theme = Theme.of(context); final iconColor = theme.colorScheme.secondary; final resetButton = IconButton( tooltip: 'Reset', color: iconColor, disabledColor: iconColor.withValues(alpha: 0.38), onPressed: query.hasActiveFilter ? onReset : null, icon: const Icon(Icons.restart_alt, size: 20), ); return AppResponsiveFilterGrid( fields: [ searchField, tableDropdown, actionDropdown, performerDropdown, dateField, ], trailing: resetButton, ); } } class _AuditDataTable extends ConsumerWidget { const _AuditDataTable({ required this.items, required this.onView, this.onEnsureFullDataset, this.onColumnSearchCleared, }); static const tableId = 'audit_logs'; static List get columnOptions => const [ AppTableColumnOption(id: 'when', label: 'When', required: true), AppTableColumnOption(id: 'action', label: 'Action'), AppTableColumnOption(id: 'table', label: 'Table'), AppTableColumnOption(id: 'record', label: 'Record'), AppTableColumnOption(id: 'performed_by', label: 'Performed By'), AppTableColumnOption(id: 'request_id', label: 'Request ID'), AppTableColumnOption(id: 'changes', label: 'Changes'), ]; final List items; final void Function(AuditLogEntryModel log) onView; final Future Function()? onEnsureFullDataset; final VoidCallback? onColumnSearchCleared; static String _changesText(AuditLogEntryModel row) { final parts = []; if (row.hasOldValue) parts.add('old'); if (row.hasNewValue) parts.add('new'); return parts.isEmpty ? '' : parts.join(' / '); } List> _allColumns() { return [ AppDataColumn( id: 'when', label: 'When', sortKey: 'when', locked: true, flex: 2, searchText: (row) => DateFormatter.searchableDate(row.performedAt), sortValue: (row) => row.performedAt, cellBuilder: (_, row) => AppTableCell.text( DateFormatter.displayDateTime(row.performedAt), ), ), AppDataColumn( id: 'action', label: 'Action', sortKey: 'action', flex: 1, searchText: (row) => row.action, cellBuilder: (_, row) => AppTableCell.child( AppStatusChip( status: row.action, compact: true, forTable: true, ), ), ), AppDataColumn( id: 'table', label: 'Table', sortKey: 'table', flex: 2, searchText: (row) => humanizeLabel(row.tableName), cellBuilder: (_, row) => AppTableCell.text(humanizeLabel(row.tableName)), ), AppDataColumn( id: 'record', label: 'Record', sortKey: 'record', flex: 1, searchText: (row) => row.recordId ?? '', cellBuilder: (_, row) => AppTableCell.text(row.recordId), ), AppDataColumn( id: 'performed_by', label: 'Performed By', sortKey: 'performed_by', flex: 2, searchText: (row) => row.performerLabel, cellBuilder: (_, row) => AppTableCell.text(row.performerLabel), ), AppDataColumn( id: 'request_id', label: 'Request ID', sortKey: 'request_id', flex: 2, searchText: (row) => row.requestId ?? '', cellBuilder: (_, row) => AppTableCell.text(row.requestId), ), AppDataColumn( id: 'changes', label: 'Changes', sortKey: 'changes', flex: 1, searchText: _changesText, cellBuilder: (_, row) { final text = _changesText(row); return AppTableCell.text(text.isEmpty ? '—' : text); }, ), AppDataColumn( id: 'actions', label: 'Actions', flex: 1, alignment: Alignment.centerRight, enableSearch: false, locked: true, includeInColumnSelector: false, cellBuilder: (_, row) => AppTableActions( children: [ AppTableActionIcon( tooltip: 'View', icon: Icons.visibility_outlined, onPressed: () => onView(row), ), ], ), ), ]; } @override Widget build(BuildContext context, WidgetRef ref) { final prefs = ref.watch(tableColumnPrefsProvider(tableId)); final columns = resolveAppDataColumns(_allColumns(), prefs); return AppDataTable( wrapInCard: false, rows: items, emptyMessage: 'No audit logs found', onEnsureFullDataset: onEnsureFullDataset, onColumnSearchCleared: onColumnSearchCleared, columns: columns, ); } } class _AuditCardList extends StatelessWidget { const _AuditCardList({ required this.items, required this.onView, }); final List items; final void Function(AuditLogEntryModel log) onView; @override Widget build(BuildContext context) { return ListView.separated( itemCount: items.length, separatorBuilder: (_, __) => const SizedBox(height: 12), itemBuilder: (context, index) { final log = items[index]; return AppCard( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text( humanizeLabel(log.tableName), style: Theme.of(context).textTheme.titleMedium, ), ), AppStatusChip(status: log.action, compact: true), ], ), const SizedBox(height: 4), Text(DateFormatter.displayDateTime(log.performedAt)), Text('Record: ${log.recordId ?? '—'}'), Text(log.performerLabel), if (log.requestId != null) Text('Request: ${log.requestId}'), const SizedBox(height: 8), Align( alignment: Alignment.centerRight, child: TextButton.icon( onPressed: () => onView(log), icon: const Icon(Icons.visibility_outlined, size: 18), label: const Text('View'), ), ), ], ), ), ); }, ); } }