import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; import '../../core/models/models.dart'; import '../../core/network/api_exception.dart'; import '../../core/theme/theme_x.dart'; import '../../core/widgets/backup_target_form_dialog.dart'; import '../../core/widgets/backup_targets_list.dart'; import '../../core/widgets/empty_state.dart'; import '../../core/widgets/error_banner.dart'; import '../../core/widgets/sparkline.dart'; import '../../data/repositories/node_repository.dart'; import 'widgets/backup_config_form_dialog.dart'; import 'widgets/backup_history_table.dart'; class BackupsScreen extends ConsumerWidget { const BackupsScreen({super.key}); Future _runNow(BuildContext context, WidgetRef ref) async { final repo = ref.read(nodeRepositoryProvider); if (repo == null) return; try { await repo.runBackup(); ref.invalidate(backupConfigProvider); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Backup started'))); } } catch (e) { final message = e is ApiException ? e.userMessage : e.toString(); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); } } } Future _addTarget(BuildContext context, WidgetRef ref) { return showBackupTargetFormDialog( context, onSave: (draft) async { final repo = ref.read(nodeRepositoryProvider); if (repo == null) throw const ApiException.unknown('No active connection.'); await repo.addBackupTarget(draft); ref.invalidate(backupConfigProvider); }, ); } Future _editTarget(BuildContext context, WidgetRef ref, BackupTarget target) { return showBackupTargetFormDialog( context, existing: target, onSave: (draft) async { final repo = ref.read(nodeRepositoryProvider); if (repo == null) throw const ApiException.unknown('No active connection.'); await repo.updateBackupTarget(target.id, draft); ref.invalidate(backupConfigProvider); }, ); } Future _deleteTarget(BuildContext context, WidgetRef ref, BackupTarget target) async { final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Remove backup target?'), content: Text('This removes "${target.name}" from the node\'s backup targets.'), actions: [ TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel')), FilledButton( style: FilledButton.styleFrom(backgroundColor: context.status.critical), onPressed: () => Navigator.of(context).pop(true), child: const Text('Remove'), ), ], ), ); if (confirmed != true) return; final repo = ref.read(nodeRepositoryProvider); if (repo == null) return; try { await repo.deleteBackupTarget(target.id); ref.invalidate(backupConfigProvider); } catch (e) { final message = e is ApiException ? e.userMessage : e.toString(); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); } } } Future _runTarget(WidgetRef ref, BackupTarget target) async { final repo = ref.read(nodeRepositoryProvider); if (repo == null) throw const ApiException.unknown('No active connection.'); await repo.runBackupTarget(target.id); } Future _fetchProgress(WidgetRef ref, BackupTarget target) async { final repo = ref.read(nodeRepositoryProvider); if (repo == null) return const RunState(); return repo.getBackupTargetProgress(target.id); } @override Widget build(BuildContext context, WidgetRef ref) { final configAsync = ref.watch(backupConfigProvider); return configAsync.when( loading: () => const Center(child: CircularProgressIndicator()), error: (error, stackTrace) => Center( child: ErrorBanner( message: error is ApiException ? error.userMessage : error.toString(), onRetry: () => ref.invalidate(backupConfigProvider), ), ), data: (config) { if (config == null) { return const Center( child: EmptyState(icon: Icons.cloud_outlined, title: 'No connection configured'), ); } return ListView( padding: const EdgeInsets.all(24), children: [ _ConfigCard(config: config, onRunNow: () => _runNow(context, ref)), const SizedBox(height: 22), Text('BACKUP TARGETS', style: context.text.titleSmall), const SizedBox(height: 10), Card( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: BackupTargetsList( targets: config.targets, onAdd: () => _addTarget(context, ref), onEdit: (target) => _editTarget(context, ref, target), onDelete: (target) => _deleteTarget(context, ref, target), onRun: (target) => _runTarget(ref, target), fetchProgress: (target) => _fetchProgress(ref, target), onSettled: (target, state) => ref.invalidate(backupConfigProvider), ), ), ), const SizedBox(height: 22), _DurationTrendCard(history: config.history), const SizedBox(height: 22), Text('EXECUTION HISTORY', style: context.text.titleSmall), const SizedBox(height: 10), BackupHistoryTable(history: config.history, targets: config.targets), ], ); }, ); } } class _ConfigCard extends StatelessWidget { const _ConfigCard({required this.config, required this.onRunNow}); final BackupConfig config; final VoidCallback onRunNow; @override Widget build(BuildContext context) { final dateFormat = DateFormat('EEE, MMM d · HH:mm'); return Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text('Node backup configuration', style: context.text.titleMedium), Row( children: [ OutlinedButton.icon( onPressed: () => showBackupConfigFormDialog(context, existing: config), icon: const Icon(Icons.edit_outlined, size: 16), label: const Text('Edit'), ), const SizedBox(width: 8), FilledButton.icon( onPressed: onRunNow, icon: const Icon(Icons.cloud_outlined, size: 16), label: const Text('Run all now'), ), ], ), ], ), const Divider(height: 24), _kv(context, 'Source path', config.sourcePath?.isNotEmpty == true ? config.sourcePath! : 'Not set'), _kv( context, 'Next run', config.nextRun == null ? 'Not scheduled' : dateFormat.format(config.nextRun!.toLocal()), ), _kv( context, 'Last run', config.lastRun == null ? 'Never' : dateFormat.format(config.lastRun!.toLocal()), ), ], ), ), ); } Widget _kv(BuildContext context, String label, String value) { return Padding( padding: const EdgeInsets.only(bottom: 10), child: Row( children: [ SizedBox(width: 120, child: Text(label, style: context.text.bodySmall)), Expanded(child: Text(value, style: context.text.bodyMedium)), ], ), ); } } class _DurationTrendCard extends StatelessWidget { const _DurationTrendCard({required this.history}); final List history; @override Widget build(BuildContext context) { final sorted = [...history]..sort((a, b) => a.timestamp.compareTo(b.timestamp)); final recent = sorted.length > 10 ? sorted.sublist(sorted.length - 10) : sorted; if (recent.isEmpty) { return const SizedBox.shrink(); } final values = [for (final e in recent) e.durationSeconds.toDouble()]; final latest = recent.last.durationSeconds; final avg = values.reduce((a, b) => a + b) / values.length; return Card( child: Padding( padding: const EdgeInsets.all(16), child: Row( children: [ Sparkline(values: values), const SizedBox(width: 20), Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text('Duration trend', style: context.text.titleMedium), const SizedBox(height: 4), Text( 'Latest ${latest}s · last ${recent.length}-run avg ${avg.toStringAsFixed(1)}s', style: context.text.bodySmall, ), ], ), ], ), ), ); } }