191 lines
6.4 KiB
Dart
191 lines
6.4 KiB
Dart
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(' · ');
|
|
}
|
|
}
|