Files

191 lines
6.4 KiB
Dart
Raw Permalink Normal View History

2026-07-27 14:42:23 +02:00
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<void> Function() onRun;
final Future<RunState> Function() fetchProgress;
final void Function(RunState finalState) onSettled;
final VoidCallback onEdit;
final VoidCallback onDelete;
@override
State<BackupTargetRow> createState() => _BackupTargetRowState();
}
class _BackupTargetRowState extends State<BackupTargetRow> {
Timer? _timer;
RunState? _runState;
bool _starting = false;
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
Future<void> _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<void> _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 = <String>[
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(' · ');
}
}