JJ_MKVMaker/lib/ui/monitor/copy_task_card.dart

426 lines
21 KiB
Dart

/// 복사 · rsync 작업 목록과 카드 (모니터링 > 복사 · rsync 탭, 다른 화면도 그대로 넣어 씀).
/// 약속:
/// - CopyTaskList(c): CopyCenter.of(c) 의 작업을 카드로 보임. 없으면 안내 글. 작업 · 설정이 바뀌면 다시 그림.
/// - CopyTaskCard(c, task): 한 작업의 옵션 고치기 · 저장 · 실행 · 멈춤 · 실패 원문 · lsync 로 옮기기.
/// 카드가 고치던 칸은 카드 자신의 상태 (key 를 ValueKey(task.id) 로 주어야 목록이 바뀌어도 남음).
/// 실패는 SnackBar · 창으로 알리고, CopyCenter.start 의 예외는 그대로 밖으로 나간다.
/// 설정은 읽기만 한다 (copySkipNewer · rsyncSource · rsyncOptions · robocopyOptions).
library;
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../app/app_controller.dart';
import '../../app/copy_center.dart';
import '../../app/live_sync.dart';
import '../../app/settings.dart';
import '../../core/download_detect.dart' show redactUrlSecrets;
import '../../core/file_ops.dart';
import '../../core/sync_tools.dart';
import '../../core/vfs.dart';
import '../../l10n/tr.dart';
import '../path_label.dart';
import '../rsync_setup.dart';
import '../../core/sync_preview.dart' show optionsRemoveSource;
import '../sync_preview_view.dart' show OptionProblemNote, OptionTextFormatter, confirmDeletingRun, effectiveRunText;
import '../theme.dart';
import 'transfer_widgets.dart';
class CopyTaskList extends StatelessWidget {
final AppController c;
const CopyTaskList({super.key, required this.c});
@override
Widget build(BuildContext context) {
final center = CopyCenter.of(c);
return ListenableBuilder(
listenable: Listenable.merge([center, c]),
builder: (context, _) {
final tasks = center.tasks;
if (tasks.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(tr('실행한 rsync 가 없습니다. Rsync 화면에서 → · ← · ⇄ 로 실행하면 여기에 자동으로 등록됩니다.'),
textAlign: TextAlign.center, style: TextStyle(color: JjColors.textDim)),
),
);
}
return ListView(
padding: const EdgeInsets.all(12),
children: [for (final t in tasks) CopyTaskCard(key: ValueKey(t.id), c: c, task: t)],
);
},
);
}
}
class CopyTaskCard extends StatefulWidget {
final AppController c;
final CopyTask task;
const CopyTaskCard({super.key, required this.c, required this.task});
@override
State<CopyTaskCard> createState() => _CopyTaskCardState();
}
class _CopyTaskCardState extends State<CopyTaskCard> {
CopyCenter get center => CopyCenter.of(widget.c);
late String _method = widget.task.method;
late final _options = TextEditingController(text: widget.task.options);
late final _bw = TextEditingController(text: '${widget.task.bandwidthKBps}');
late bool _once = widget.task.once;
late String _prune = widget.task.prune;
bool get _dirty =>
_prune != widget.task.prune ||
_method != widget.task.method ||
_options.text.trim() != widget.task.options ||
(int.tryParse(_bw.text) ?? 0) != widget.task.bandwidthKBps ||
_once != widget.task.once;
@override
void dispose() {
_options.dispose();
_bw.dispose();
super.dispose();
}
/// 237: 실패 원문 보기 · 복사
Future<void> _showFailure(CopyTask t) {
final detail = redactUrlSecrets(t.lastDetail);
return showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(t.lastMessage),
content: SizedBox(
width: 560,
child: SelectableText(detail, style: const TextStyle(fontFamily: 'monospace', fontSize: 12)),
),
actions: [
TextButton(
onPressed: () async {
await Clipboard.setData(ClipboardData(text: detail));
if (ctx.mounted) Navigator.pop(ctx);
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(tr('복사했습니다'))));
},
child: Text(tr('복사')),
),
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('닫기'))),
],
),
);
}
Future<void> _run(CopyTask t) async {
// 236: 옵션 칸의 포커스를 푼다 - 남아 있으면 폰 · 태블릿에서 키보드가 올라와 끝 알림을 가린다
FocusManager.instance.primaryFocus?.unfocus();
// 104: 지우기가 들어 있으면 Rsync 화면과 같은 미리 보기를 거친다 (그사이 대상에 생긴 파일을 묻지 않고 지우지 않게).
// 210: 이동 (원본 지우기) 도 - 카드의 [실행] 은 따로 묻는 창이 없어 곧바로 원본을 지웠다. 복사만은 지금처럼 바로
if (!await confirmDeletingRun(context, t, includeMove: true, skipNewer: widget.c.settings.copySkipNewer) || !mounted) return;
widget.c.note(trf('rsync 시작 ({0}): {1} → {2}', [tr('모니터링의 [실행]'), t.sources.join(', '), t.dest]));
String? exe;
if (t.method == 'rsync') {
exe = await rsyncExecutable(widget.c.settings);
if (exe == null && Platform.isWindows && widget.c.settings.rsyncSource == 'download' && mounted) {
exe = await installRsyncWithDialog(context);
}
if (exe == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(tr('rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > 파일 탐색기에서 경로를 확인하세요.'))));
}
return;
}
}
final problem = transferProblem(t.sources, t.dest, move: t.move, contents: t.contents);
await center.start(t, rsyncExe: exe);
if (problem != null && mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problem)));
}
/// 칸에 보이는 (고친) 옵션으로 만든 작업
CopyTask _edited() => widget.task.copyWith(
method: _method,
options: _options.text.trim(),
bandwidthKBps: int.tryParse(_bw.text) ?? 0,
once: _once,
prune: _prune,
);
/// 114: [실행] 을 눌렀는데 저장하지 않은 고침이 있으면 묻는다 (칸에 보이는 것과 다른 옵션으로 몰래 돌지 않게).
/// 저장하고 실행하면 그 옵션으로 (지우기가 있으면 미리 보기 창)
Future<void> _runPressed(CopyTask t) async {
if (!_dirty) return _run(t);
final pick = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
actionsOverflowDirection: VerticalDirection.down,
title: Text(tr('고친 옵션을 저장하지 않았습니다')),
content: Text(trf('칸에 보이는 옵션: {0}\n저장된 옵션: {1}\n\n고친 옵션을 저장하고 실행할까요?', [
'${_method == 'builtin' ? tr('현재 방식') : _method} ${_options.text.trim()}',
// 178: 실제로 쓰는 옵션 (원본 지우기는 이동일 때만)
effectiveRunText(t.method, t.options, move: t.move, skipNewer: widget.c.settings.copySkipNewer),
])),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('취소'))),
TextButton(onPressed: () => Navigator.pop(ctx, 'old'), child: Text(tr('저장된 옵션으로 실행'))),
FilledButton(onPressed: () => Navigator.pop(ctx, 'save'), child: Text(tr('저장하고 실행'))),
],
),
);
if (!mounted || pick == null) return;
if (pick == 'old') return _run(t);
final edited = _edited();
await center.update(edited);
if (mounted) await _run(edited);
}
/// 옵션 저장 → 지금 실행할지 묻기 (현재 유지 / 반영 후 실행)
Future<void> _save() async {
final t = _edited();
await center.update(t);
if (!mounted) return;
final running = center.isRunning(t.id);
final go = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(tr('옵션을 저장했습니다')),
content: Text(running
// 233: 이동 카드는 "이동" 으로
? (t.move
? tr('지금 도는 이동은 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?')
: tr('지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?'))
: (t.move
? tr('이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?')
: tr('이 복사를 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?'))),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('현재 유지'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('설정 반영 후 실행'))),
],
),
);
if (go != true) return;
if (running) {
final old = center.jobs[t.id]!;
old.cancel();
await old.done;
}
await _run(t);
}
/// lsync 로: 끝나지 않은 것 (실행 중 · 실패 · 취소 · 아직 안 함) 도 옮길 수 있다 - 남은 것은 lsync 가 이어서 맞춘다.
/// 실행 중이면 물어본 뒤 멈추고 옮긴다.
Future<void> _toLive(CopyTask t) async {
final running = center.isRunning(t.id);
// 243: 이동 카드 - 실시간 동기화는 원본을 지우지 않는다 (107). 옮기기 전에 알린다
if (running || t.move) {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(tr('lsync 로 이동')),
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
if (running) Text(tr('지금 실행 중입니다. 멈추고 lsync (실시간 동기화) 로 옮길까요? 남은 것은 lsync 가 이어서 맞춥니다.')),
if (running && t.move) const SizedBox(height: 10),
if (t.move)
Text(tr('실시간 동기화는 원본을 지우지 않습니다 (복사로 맞춤). 원본을 비우려면 lsync 의 [최종 정리] 를 쓰세요.'),
style: TextStyle(color: JjColors.warning)),
]),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(running ? tr('멈추고 옮기기') : tr('옮기기'))),
],
),
);
if (ok != true || !mounted) return;
}
if (running) {
final job = center.jobs[t.id]!;
job.cancel();
await job.done;
if (!mounted) return;
}
final n = await center.toLiveSync(t);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(n == 0 ? tr('폴더만 lsync 로 옮길 수 있습니다.') : trf('{0}개 폴더를 lsync (실시간 동기화) 로 옮겼습니다.', [n]))));
}
@override
Widget build(BuildContext context) {
final t = widget.task;
final job = center.jobs[t.id];
final running = job != null && !job.finished;
final s = widget.c.settings;
final (icon, color, status) = running
? (Icons.sync, JjColors.accent, job.move ? tr('옮기는 중') : tr('복사하는 중'))
: switch (t.lastResult) {
// 235: 이동인데 원본에 남은 것이 있으면 "끝" 만 보이지 않게 (주황 · 남은 수)
'done' when t.lastLeft > 0 => (
Icons.warning_amber,
JjColors.warning,
'${trf('끝 · {0} · 파일 {1}개', [formatRunStamp(t.lastRun), t.lastFiles])} · ${trf('{0}개는 원본에 남음 (건너뜀)', [t.lastLeft])}'
),
'done' => (Icons.check_circle, JjColors.positive, trf('끝 · {0} · 파일 {1}개', [formatRunStamp(t.lastRun), t.lastFiles])),
'failed' => (Icons.error, JjColors.alert, trf('실패 · {0}', [formatRunStamp(t.lastRun)])),
'cancelled' => (Icons.pause_circle, JjColors.warning, trf('멈춤 · {0}', [formatRunStamp(t.lastRun)])),
_ => (Icons.schedule, JjColors.textDim, tr('아직 실행 안 함')),
};
final optsHint = switch (CopyMethod.of(_method)) {
CopyMethod.rsync => defaultRsyncOptions,
CopyMethod.robocopy => defaultRobocopyOptions,
CopyMethod.builtin => tr('(현재 방식은 옵션 없음)'),
};
return Card(
color: JjColors.panel,
margin: const EdgeInsets.only(bottom: 10),
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 10, 10, 10),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Icon(icon, color: color, size: 20),
const SizedBox(width: 8),
Expanded(
// 112: 끝 폴더가 보이게 짧게, 전체 경로는 작게
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(
t.sources.length == 1
? shortPath(t.sources.first)
: trf('{0} 외 {1}개', [shortPath(t.sources.first), t.sources.length - 1]),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600),
),
Text('→ ${shortPath(t.dest)}', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.w600)),
Text('${vDisplay(t.sources.first)}${t.contents ? '/' : ''} → ${vDisplay(t.dest)}',
maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 11, color: JjColors.textDim)),
]),
),
Chip(
visualDensity: VisualDensity.compact,
label: Text('${t.move ? tr('이동') : tr('복사')} · ${tr(CopyMethod.of(t.method).label)}'),
),
]),
Padding(
padding: const EdgeInsets.only(left: 28, top: 2),
child: Wrap(spacing: 16, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: [
Text(status, style: TextStyle(fontSize: 12, color: color)),
DiskSpaceInfo(path: t.dest),
]),
),
if (t.lastResult == 'failed' && !running && t.lastMessage.isNotEmpty)
Padding(
padding: const EdgeInsets.only(left: 28, top: 4),
child: Wrap(crossAxisAlignment: WrapCrossAlignment.center, spacing: 4, children: [
Text(t.lastMessage, maxLines: 3, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 11, color: JjColors.alert)),
// 237: 원문 (예외 이름 · 명령 줄) 은 [자세히] 에서 - 보고 복사해 물어볼 수 있게 (다운로드 실패 줄 136 과 같게)
if (t.lastDetail.trim().isNotEmpty)
TextButton(
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact, padding: const EdgeInsets.symmetric(horizontal: 6), minimumSize: const Size(0, 28)),
onPressed: () => _showFailure(t),
child: Text(tr('자세히')),
),
]),
),
if (job != null && (running || job.finished && job.error == null))
Padding(padding: const EdgeInsets.only(left: 28, top: 6), child: TransferProgressLines(job: job)),
const SizedBox(height: 8),
// 옵션 (고치면 [저장] → 지금 실행할지 묻기)
Padding(
padding: const EdgeInsets.only(left: 28),
child: Wrap(spacing: 10, runSpacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [
DropdownButton<String>(
value: _method,
items: [
for (final m in CopyMethod.values)
if (copyMethodAvailable(m, s) || m.name == _method) DropdownMenuItem(value: m.name, child: Text(tr(m.label))),
],
onChanged: (v) => setState(() {
if (v != _method && _options.text.trim().isEmpty || _options.text.trim() == defaultRsyncOptions || _options.text.trim() == defaultRobocopyOptions) {
_options.text = v == 'robocopy' ? s.robocopyOptions : v == 'rsync' ? s.rsyncOptions : '';
}
_method = v!;
}),
),
SizedBox(
width: 320,
child: TextField(
controller: _options,
inputFormatters: const [OptionTextFormatter()],
enabled: _method != 'builtin',
style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
decoration: InputDecoration(isDense: true, border: const OutlineInputBorder(), labelText: tr('옵션'), hintText: optsHint),
onChanged: (_) => setState(() {}),
),
),
// 209: 실제로 실행할 것 - 이동 / 복사를 분명히 (칸의 원본 지우기 옵션은 이동일 때만 쓰인다)
SizedBox(
width: 460,
child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
Text(
'${t.move ? tr('이동') : tr('복사')} · ${trf('실제 실행: {0}', [effectiveRunText(_method, _options.text.trim(), move: t.move, skipNewer: s.copySkipNewer)])}',
style: TextStyle(fontSize: 12, color: t.move ? JjColors.warning : JjColors.textDim),
),
// 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 옵션 (실행은 작업이 막는다)
if (_method == 'rsync' && optionProblems(_options.text).isNotEmpty) OptionProblemNote(optionProblems(_options.text)),
if (!t.move && optionsRemoveSource(_options.text))
Text(tr('복사 작업이라 칸의 원본 지우기 옵션 (--remove-source-files · /MOV) 은 쓰지 않습니다. 옮기려면 Rsync 화면에서 [원본 파일 지우기] 를 켜세요'),
style: TextStyle(fontSize: 12, color: JjColors.warning)),
]),
),
SizedBox(
width: 130,
child: TextField(
controller: _bw,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: InputDecoration(isDense: true, border: const OutlineInputBorder(), labelText: tr('속도 제한'), suffixText: 'KB/s'),
onChanged: (_) => setState(() {}),
),
),
if (t.sources.length > 1 && _method == 'rsync')
FilterChip(label: Text(tr('한 번에')), selected: _once, onSelected: (v) => setState(() => _once = v)),
// 이동 (원본 파일 지우기) 이면: 끝난 뒤 원본의 빈 폴더 · 원본 폴더 정리
if (t.move && _method == 'rsync')
DropdownButton<String>(
value: _prune,
items: [for (final v in ['', 'keep', 'all']) DropdownMenuItem(value: v, child: Text(pruneLabel(v)))],
onChanged: (v) => setState(() => _prune = v!),
),
if (_dirty) FilledButton.tonal(onPressed: _save, child: Text(tr('저장'))),
]),
),
const SizedBox(height: 6),
// 257 · 253: 폰 세로 · 글자가 크면 버튼이 화면 밖으로 넘치지 않게 줄을 바꾼다 (오른쪽 정렬은 그대로)
Wrap(alignment: WrapAlignment.end, crossAxisAlignment: WrapCrossAlignment.center, spacing: 6, runSpacing: 4, children: [
if (running)
TextButton.icon(onPressed: () => center.cancel(t.id), icon: const Icon(Icons.stop, size: 18), label: Text(tr('취소')))
else
FilledButton.icon(
// 관리자 지시 (10/20, 28 지적): 옵션이 막히면 (빨간 안내가 보이면) 확인 창처럼 [실행] 도 끈다 - 누르면 실패로 끝났다
onPressed: _method == 'rsync' && optionProblems(_options.text).isNotEmpty ? null : () => _runPressed(t),
icon: const Icon(Icons.play_arrow, size: 18),
label: Text(t.lastResult == 'cancelled' || t.lastResult == 'failed' ? tr('재개') : tr('실행')),
),
TextButton.icon(
onPressed: () => _toLive(t),
icon: const Icon(Icons.sync_alt, size: 18),
label: Text(tr('lsync 로 이동')),
),
IconButton(
tooltip: tr('목록에서 지우기 (파일은 그대로)'),
icon: Icon(Icons.delete_outline, color: JjColors.textDim),
onPressed: () => center.remove(t.id),
),
]),
]),
),
);
}
}