initial commit
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
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(' · ');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user