/// 복사 · 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 createState() => _CopyTaskCardState(); } class _CopyTaskCardState extends State { 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 _showFailure(CopyTask t) { final detail = redactUrlSecrets(t.lastDetail); return showDialog( 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 _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 _runPressed(CopyTask t) async { if (!_dirty) return _run(t); final pick = await showDialog( 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 _save() async { final t = _edited(); await center.update(t); if (!mounted) return; final running = center.isRunning(t.id); final go = await showDialog( 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 _toLive(CopyTask t) async { final running = center.isRunning(t.id); // 243: 이동 카드 - 실시간 동기화는 원본을 지우지 않는다 (107). 옮기기 전에 알린다 if (running || t.move) { final ok = await showDialog( 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( 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( 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), ), ]), ]), ), ); } }