import 'dart:async'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import '../models/models.dart'; import '../network/api_exception.dart'; import '../theme/theme_x.dart'; import 'mono_text.dart'; /// One backup target: identity + schedule/last-run caption, and either /// Run/Edit/Delete actions or — while a run it triggered is in flight — a /// live progress bar polled via [fetchProgress]. Polling is a plain local /// [Timer] scoped to this row's lifetime, not a Riverpod provider: progress /// only matters while this exact row is on screen and something is /// actively running, so there's nothing to share or keep alive beyond that. class BackupTargetRow extends StatefulWidget { const BackupTargetRow({ super.key, required this.target, required this.onRun, required this.fetchProgress, required this.onSettled, required this.onEdit, required this.onDelete, }); final BackupTarget target; final Future Function() onRun; final Future Function() fetchProgress; final void Function(RunState finalState) onSettled; final VoidCallback onEdit; final VoidCallback onDelete; @override State createState() => _BackupTargetRowState(); } class _BackupTargetRowState extends State { Timer? _timer; RunState? _runState; bool _starting = false; @override void dispose() { _timer?.cancel(); super.dispose(); } Future _handleRun() async { setState(() => _starting = true); try { await widget.onRun(); setState(() { _starting = false; _runState = const RunState(running: true); }); _poll(); _timer = Timer.periodic(const Duration(seconds: 2), (_) => _poll()); } catch (e) { if (!mounted) return; setState(() => _starting = false); final message = e is ApiException ? e.userMessage : e.toString(); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); } } Future _poll() async { final state = await widget.fetchProgress(); if (!mounted) return; setState(() => _runState = state); if (!state.running) { _timer?.cancel(); _timer = null; widget.onSettled(state); } } @override Widget build(BuildContext context) { final target = widget.target; final running = _runState?.running ?? false; return Padding( padding: const EdgeInsets.symmetric(vertical: 10), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Row( children: [ Text(target.name, style: context.text.labelLarge), const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), decoration: BoxDecoration( color: context.colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(4), ), child: Text(target.method.name, style: context.dataStyles.dataMonoSmall), ), ], ), const SizedBox(height: 3), MonoText(target.remote, small: true), const SizedBox(height: 3), Text(_caption(target), style: context.text.bodySmall), ], ), ), const SizedBox(width: 8), if (running) const SizedBox.shrink() else ...[ _starting ? const Padding( padding: EdgeInsets.all(8), child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)), ) : IconButton( tooltip: 'Run now', icon: const Icon(Icons.play_arrow_rounded, size: 20), onPressed: _handleRun, ), IconButton( tooltip: 'Edit', icon: const Icon(Icons.edit_outlined, size: 18), onPressed: widget.onEdit, ), IconButton( tooltip: 'Remove', icon: const Icon(Icons.delete_outline, size: 18), onPressed: widget.onDelete, ), ], ], ), if (running) ...[ const SizedBox(height: 8), ClipRRect( borderRadius: BorderRadius.circular(4), child: LinearProgressIndicator( value: (_runState!.percent > 0 && _runState!.percent <= 100) ? _runState!.percent / 100 : null, minHeight: 5, ), ), const SizedBox(height: 4), Row( children: [ Text( '${_runState!.percent.toStringAsFixed(0)}%', style: context.dataStyles.numericTabular.copyWith(fontSize: 12), ), if (_runState!.eta != null) ...[ const SizedBox(width: 8), Text('ETA ${_runState!.eta}', style: context.text.bodySmall), ], if (_runState!.message?.isNotEmpty == true) ...[ const SizedBox(width: 8), Expanded( child: MonoText(_runState!.message!, small: true, maxLines: 1), ), ], ], ), ], ], ), ); } String _caption(BackupTarget target) { final dateFormat = DateFormat('MMM d, HH:mm'); final parts = [ target.schedule?.isNotEmpty == true ? 'Schedule: ${target.schedule}' : 'Manual only', if (target.lastRun != null) 'last ${dateFormat.format(target.lastRun!.toLocal())}', if (target.nextRun != null) 'next ${dateFormat.format(target.nextRun!.toLocal())}', ]; return parts.join(' · '); } }