diff --git a/lib/ui/monitor/copy_task_card.dart b/lib/ui/monitor/copy_task_card.dart new file mode 100644 index 0000000..9a7fd16 --- /dev/null +++ b/lib/ui/monitor/copy_task_card.dart @@ -0,0 +1,425 @@ +/// 복사 · 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), + ), + ]), + ]), + ), + ); + } +} diff --git a/lib/ui/monitor/live_pair_card.dart b/lib/ui/monitor/live_pair_card.dart new file mode 100644 index 0000000..423968b --- /dev/null +++ b/lib/ui/monitor/live_pair_card.dart @@ -0,0 +1,359 @@ +/// 실시간 동기화 (lsync) 카드 위젯 (모니터링 > lsync 탭, 다른 화면도 그대로 넣어 씀). +/// 약속: +/// - LivePairCard(c, live, pair, onFinalize, onShowCopyList): 쌍의 상태 · 일정 · 맞출 것 · 지우기 확인 · 단추. +/// live 가 null 이면 (실시간 동기화 꺼짐) 세기 · 맞추기 단추를 끈다. 설정 저장은 replaceLiveSyncPair 로. +/// 탭 이동 같은 화면 일은 하지 않고 onFinalize · onShowCopyList 로 부르는 쪽에 맡긴다. +/// - LiveSourceProblemPanel(live, pair, error): 원본 문제 안내와 [다시 시도] · [지울 목록 보고 결정]. +/// 화면 상태를 읽지 않는다. 설정은 읽기만 (liveSyncPairs 쓰기는 live_sync_flow 를 거침). +library; + + +import 'package:flutter/material.dart'; + +import '../../app/app_controller.dart'; +import '../../app/copy_center.dart'; +import '../../app/live_sync.dart'; +import '../../app/settings.dart'; +import '../../core/secret_gate.dart'; +import '../../core/file_ops.dart'; +import '../../core/sync_tools.dart'; +import '../../core/vfs.dart'; +import '../../l10n/tr.dart'; +import '../copy_sync_settings.dart' show editLiveSyncPairDialog, removeLiveSyncPair; +import '../path_label.dart'; +import '../schedule_editor.dart'; +import '../sync_preview_view.dart' show unbreakableOption; +import '../theme.dart'; +import 'live_sync_flow.dart'; +import 'transfer_widgets.dart'; + +/// 원본 문제 안내 (68 · 70): 원본을 읽지 못함 · 원본이 빔 · 마스터 비밀번호 기다림 +class LiveSourceProblemPanel extends StatelessWidget { + final LiveSync live; + final LiveSyncPair pair; + final SourceUnreadableException error; + const LiveSourceProblemPanel({super.key, required this.live, required this.pair, required this.error}); + + @override + Widget build(BuildContext context) { + final x = pair; + final e = error; + final k = LiveSync.keyOf(x); + final dels = live.emptyDeletes[k] ?? const []; + final running = live.isRunning(x); + // 128: 마스터 비밀번호를 기다리는 중 - 고장이 아니라 넣으면 이어서 맞춘다 + if (e.locked) { + return Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(12, 10, 12, 8), + decoration: BoxDecoration( + color: JjColors.accent.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: JjColors.accent.withValues(alpha: 0.5)), + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Icon(Icons.lock_clock_outlined, color: JjColors.accent, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text(tr('마스터 비밀번호가 필요해 기다리는 중'), + style: TextStyle(fontWeight: FontWeight.w600, color: JjColors.accent)), + ), + ]), + const SizedBox(height: 4), + Text(tr('이 서버의 저장된 비밀번호를 쓰려면 마스터 비밀번호가 필요합니다. 넣으면 이어서 맞춥니다. 대상 파일은 건드리지 않았습니다.'), + style: const TextStyle(fontSize: 12)), + const SizedBox(height: 6), + FilledButton.tonalIcon( + onPressed: running + ? null + : () async { + if (await SecretGate.pass(force: true)) await live.syncNow(x); + }, + icon: const Icon(Icons.lock_open, size: 18), + label: Text(tr('마스터 비밀번호 넣기')), + ), + ]), + ); + } + return Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(12, 10, 12, 8), + decoration: BoxDecoration( + color: JjColors.alert.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: JjColors.alert.withValues(alpha: 0.6)), + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Icon(e.empty ? Icons.folder_off_outlined : Icons.error_outline, color: JjColors.alert, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + e.empty ? tr('원본 폴더가 비어 있어 멈췄습니다') : tr('원본을 읽을 수 없어 멈췄습니다'), + style: TextStyle(fontWeight: FontWeight.w600, color: JjColors.alert), + ), + ), + ]), + const SizedBox(height: 4), + Text(tr('대상 파일은 지우지 않았습니다.'), style: const TextStyle(fontSize: 12)), + Text( + e.empty + ? trf('원본에는 아무것도 없는데 대상에는 {0}개가 있습니다. SD 카드가 빠졌거나 네트워크 · 권한 문제일 수 있습니다. ' + '일부러 원본을 비웠다면 지울 목록을 보고 [그래도 맞추기] 를 누르세요.', [dels.length]) + : tr('SD 카드가 꽂혀 있는지, 네트워크 (NAS · VPN) 가 연결되어 있는지, 폴더 권한이 있는지 확인한 뒤 [다시 시도] 를 누르세요.'), + style: const TextStyle(fontSize: 12), + ), + const SizedBox(height: 4), + Text('${vDisplay(x.source)}${e.detail.isEmpty ? '' : ' · ${e.detail}'}', + style: TextStyle(fontSize: 11, color: JjColors.textDim)), + const SizedBox(height: 6), + Wrap(spacing: 8, runSpacing: 4, children: [ + FilledButton.tonalIcon( + onPressed: running ? null : () => live.syncNow(x), + icon: const Icon(Icons.refresh, size: 18), + label: Text(tr('다시 시도')), + ), + if (e.empty && dels.isNotEmpty) + OutlinedButton( + onPressed: running ? null : () => confirmEmptySourceSync(context, live, x), + child: Text(tr('지울 목록 보고 결정')), + ), + ]), + ]), + ); + } + +} + +/// 실시간 동기화 한 쌍의 카드 +class LivePairCard extends StatelessWidget { + final AppController c; + final LiveSync? live; + final LiveSyncPair pair; + /// [최종 정리] 를 눌렀을 때 + final VoidCallback onFinalize; + /// [복사 · rsync 로 이동] 으로 옮긴 뒤 (모니터링에서는 복사 · rsync 탭으로) + final VoidCallback onShowCopyList; + const LivePairCard( + {super.key, required this.c, required this.live, required this.pair, required this.onFinalize, required this.onShowCopyList}); + + @override + Widget build(BuildContext context) { + final live = this.live; + final x = pair; + final k = LiveSync.keyOf(x); + final pend = live?.pending[k]; + final st = live?.status[k]; + final active = LiveSync.activeNow(x); + final running = live?.isRunning(x) ?? false; + final held = live?.isPaused(x) ?? false; + // 257: 칩은 길게 누르기 · 마우스로 전체를 본다. 관리자 지시 (10/20, 28 지적): 좁은 화면 (폰 세로) 에서는 경로 줄에 끼면 + // "rsync · 복…" 으로 잘려 경로 아래 따로 한 줄로 (넓으면 예전처럼 경로 옆) + final narrow = MediaQuery.sizeOf(context).width < 600; + final chipText = [tr(CopyMethod.of(x.method).label), tr('복사로 맞춤'), if (x.delete) tr('지우기 포함')].join(' · '); + final chip = Tooltip( + message: chipText, + child: Chip( + visualDensity: VisualDensity.compact, + // 243: 실시간 동기화는 늘 복사로 맞춘다 (원본은 지우지 않음 - 원본을 비우려면 [최종 정리]) + label: Text(chipText, maxLines: 1, overflow: TextOverflow.ellipsis), + ), + ); + 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( + running + ? Icons.sync + : !x.enabled + ? Icons.sync_disabled + : held + ? Icons.pause_circle_outline + : active + ? Icons.sync + : Icons.bedtime_outlined, + color: x.enabled && !held ? (active ? JjColors.accent : JjColors.warning) : JjColors.textDim), + const SizedBox(width: 8), + Expanded( + // 67: 끝 폴더가 보이게 짧게 · 69: 길게 누르면 (오른쪽 클릭) 고치기 · 지우기 + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onLongPressStart: (d) => showLivePairMenu(context, c, x, d.globalPosition), + onSecondaryTapDown: (d) => showLivePairMenu(context, c, x, d.globalPosition), + child: Tooltip( + // 길게 누르기는 메뉴에 (마우스를 올리면 전체 경로) + triggerMode: TooltipTriggerMode.manual, + message: '${vDisplay(x.source)}\n→ ${vDisplay(x.target)}', + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(shortPath(x.source), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.w600)), + Text('→ ${shortPath(x.target)}', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.w600)), + ]), + ), + ), + ), + // 257: 폰 세로에서 이 줄이 넘쳤다 - 칩은 남는 폭만큼 (넘치면 … 로 줄임, 전체는 길게 누르기 · 마우스) + if (!narrow) Flexible(child: chip), + // 이번 실행 동안 멈춤 / 시작 (설정의 켜기 · 끄기는 오른쪽 스위치) + if (live != null && x.enabled) + IconButton( + tooltip: held ? tr('시작') : tr('멈춤 (이번 실행 동안)'), + icon: Icon(held ? Icons.play_arrow : Icons.pause, color: held ? JjColors.accent : null), + onPressed: () => held ? live.resume(x) : live.pause(x), + ), + Switch(value: x.enabled, onChanged: (v) => replaceLiveSyncPair(c, x, x.copyWith(enabled: v))), + ]), + if (narrow) Padding(padding: const EdgeInsets.only(left: 32, top: 2), child: chip), + if (held) + Padding( + padding: const EdgeInsets.only(left: 32, top: 2), + child: Text(tr('멈춤 (이번 실행 동안) · 시작 버튼을 누르면 다시 동기화합니다'), + style: TextStyle(fontSize: 12, color: JjColors.warning)), + ), + // 42: 지우기를 아직 확인하지 않았고 지울 것이 있다 → 지우지 않고 맞추는 중. 목록을 보고 결정 + if (live != null && (live.toDelete[k]?.isNotEmpty ?? false)) + Padding( + padding: const EdgeInsets.only(left: 32, top: 4), + child: Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ + Icon(Icons.warning_amber_rounded, size: 16, color: JjColors.warning), + Text(trf('대상에만 있는 {0}개를 지울지 확인이 필요합니다 (확인 전에는 지우지 않음)', [live.toDelete[k]!.length]), + style: TextStyle(fontSize: 12, color: JjColors.warning)), + OutlinedButton( + onPressed: () => confirmLiveDeletes(context, live, x), + child: Text(tr('지울 목록 보고 결정')), + ), + ]), + ), + // P0 (관리자 3a 10/20): 미리 볼 수 없는 거르기 규칙 → 저절로 지우지 않고 보내기만. 지우기는 확인을 거쳐 한 번씩 + if (live != null && live.noAutoDelete[k] != null) + Padding( + padding: const EdgeInsets.only(left: 32, top: 4), + child: Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ + Icon(Icons.block, size: 16, color: JjColors.alert), + Text(tr('이 옵션의 일부 거르기 규칙은 미리 볼 수 없어 자동으로 지우지 않습니다'), + style: TextStyle(fontSize: 12, color: JjColors.alert, fontWeight: FontWeight.w600)), + Text('(${live.noAutoDelete[k]!.map(unbreakableOption).join(' · ')})', style: TextStyle(fontSize: 12, color: JjColors.alert)), + OutlinedButton( + onPressed: () => confirmLiveDeleteNow(context, live, x), + child: Text(tr('지금 지우기 (확인)')), + ), + ]), + ), + Padding( + padding: const EdgeInsets.only(left: 32, top: 4), + child: Wrap(spacing: 16, runSpacing: 6, crossAxisAlignment: WrapCrossAlignment.center, children: [ + // 일정: 계속 / 시간 지정 (cron) + 작은 격자 + InkWell( + onTap: () async { + final r = await editSchedule(context, x.schedule); + if (r != null) await replaceLiveSyncPair(c, x, x.copyWith(schedule: r)); + }, + borderRadius: BorderRadius.circular(6), + child: Padding( + padding: const EdgeInsets.all(4), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + WeekGridPreview(lines: x.schedule, width: 120), + const SizedBox(width: 8), + // 257: 폰 세로에서 글이 줄을 바꿀 수 있게 (넘쳤다) + Flexible( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + Text(scheduleSummary(x.schedule), style: TextStyle(fontSize: 12, color: active ? JjColors.accent : JjColors.warning)), + if (x.schedule.isNotEmpty) + Text(x.schedule.join(' | '), style: TextStyle(fontSize: 11, fontFamily: 'monospace', color: JjColors.textDim)), + Text(tr('누르면 일정 바꾸기'), style: TextStyle(fontSize: 10, color: JjColors.textDim)), + ]), + ), + ]), + ), + ), + if (st != null) + Text('${tr('마지막')}: ${st.$1.hour.toString().padLeft(2, '0')}:${st.$1.minute.toString().padLeft(2, '0')} ${st.$2}', + style: TextStyle(fontSize: 12, color: live?.problems[k] != null ? JjColors.alert : JjColors.textDim)), + DiskSpaceInfo(path: x.target), + ]), + ), + // 68 · 70: 원본을 읽지 못함 / 원본이 비어 멈춤 - "맞출 것 없음" 대신 눈에 띄게, 할 일과 [다시 시도] + if (live?.problems[k] case final prob?) + Padding(padding: const EdgeInsets.only(left: 32, top: 8), child: LiveSourceProblemPanel(live: live!, pair: x, error: prob)) + else + // 맞출 것 (바뀐 파일) - 자동으로 다시 셈 + Padding( + padding: const EdgeInsets.only(left: 32, top: 8), + child: pend == null + ? Text(tr('맞출 것을 세는 중…'), style: TextStyle(fontSize: 12, color: JjColors.textDim)) + : pend.isEmpty + // 244: 대상에만 있는 것이 있으면 "같습니다" 가 아니라 사실대로 + ? Text( + (live?.onlyTarget[k] ?? 0) > 0 + ? trf('원본의 것은 모두 대상에 있습니다 (대상에만 있는 것 {0}개는 그대로 둠)', [live!.onlyTarget[k]]) + : tr('맞출 것 없음 (원본과 대상이 같습니다)'), + style: TextStyle(fontSize: 12, color: JjColors.positive)) + : Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text( + trf('맞출 것 {0}개{1}', [pend.length >= 500 ? '500+' : pend.length, active ? '' : tr(' · 다음 시작 시간에 맞춥니다')]), + style: TextStyle(fontSize: 12, color: JjColors.warning), + ), + const SizedBox(height: 4), + Container( + constraints: const BoxConstraints(maxHeight: 140), + decoration: BoxDecoration(color: JjColors.bg, borderRadius: BorderRadius.circular(6)), + padding: const EdgeInsets.all(8), + child: ListView(shrinkWrap: true, children: [ + for (final f in pend.take(100)) + Text(f, + style: TextStyle( + fontSize: 11.5, fontFamily: 'monospace', color: f.startsWith('− ') ? JjColors.alert : null)), + if (pend.length > 100) Text(trf('… 외 {0}개', [pend.length - 100]), style: const TextStyle(fontSize: 11)), + ]), + ), + ]), + ), + const SizedBox(height: 6), + // 257: 폰 세로에서 [고치기] · [지우기] 가 화면 밖으로 밀려 누를 수 없었다 - 줄을 바꾼다 (오른쪽 정렬은 그대로) + Wrap(alignment: WrapAlignment.end, crossAxisAlignment: WrapCrossAlignment.center, spacing: 2, runSpacing: 2, children: [ + TextButton.icon( + onPressed: live == null ? null : () => live.refreshPending(x), + icon: const Icon(Icons.refresh, size: 18), + label: Text(tr('다시 세기')), + ), + FilledButton.icon( + onPressed: live == null || running ? null : () => live.syncNow(x), + icon: const Icon(Icons.sync, size: 18), + label: Text(tr('지금 맞추기')), + ), + TextButton.icon( + onPressed: running ? null : onFinalize, + icon: const Icon(Icons.cleaning_services_outlined, size: 18), + label: Text(tr('최종 정리')), + ), + TextButton.icon( + onPressed: () async { + await CopyCenter.of(c).fromLiveSync(x); + if (context.mounted) { + onShowCopyList(); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(tr('복사 · rsync 목록으로 옮겼습니다.')))); + } + }, + icon: const Icon(Icons.swap_horiz, size: 18), + label: Text(tr('복사 · rsync 로 이동')), + ), + IconButton( + tooltip: tr('고치기'), + icon: const Icon(Icons.edit_outlined), + onPressed: () => editLiveSyncPairDialog(context, c, x), + ), + IconButton( + tooltip: tr('지우기'), + icon: Icon(Icons.delete_outline, color: JjColors.textDim), + onPressed: () => removeLiveSyncPair(context, c, x), + ), + ]), + ]), + ), + ); + } +} diff --git a/lib/ui/monitor/live_sync_flow.dart b/lib/ui/monitor/live_sync_flow.dart new file mode 100644 index 0000000..3f2688f --- /dev/null +++ b/lib/ui/monitor/live_sync_flow.dart @@ -0,0 +1,283 @@ +/// 실시간 동기화 (lsync) 흐름과 확인 창. 화면 상태 없이 (context · AppController · LiveSync · 쌍) 만 받는다. +/// 약속: +/// - confirmLiveDeletes · confirmLiveDeleteNow · confirmEmptySourceSync: 지울 목록을 보이고, 고른 대로만 +/// live.decideDelete / deleteNow / syncEmptyAnyway 를 부른다. 취소면 아무것도 하지 않음. +/// - askLiveSyncStart: 켜 둔 쌍 가운데 이번 실행에 시작할 것을 고르게 해 live.runOnly 로 시작. +/// - replaceLiveSyncPair: 같은 원본 · 대상 쌍만 바꾸고 순서는 그대로 저장. +/// - showLivePairMenu: 카드의 고치기 · 지우기 메뉴. +/// - finalizeLiveSync: 최종 정리 (rsync --remove-source-files + 빈 폴더 지우기). 작업이 시작되면 true. +/// rsync 를 못 찾거나 시작하지 못하면 SnackBar 로 알리고 false. +library; + +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import '../../app/app_controller.dart'; +import '../../app/copy_center.dart'; +import '../../app/live_sync.dart'; +import '../../app/settings.dart'; +import '../../core/vfs.dart'; +import '../../l10n/tr.dart'; +import '../copy_sync_settings.dart' show editLiveSyncPairDialog, removeLiveSyncPair; +import '../rsync_setup.dart'; +import '../schedule_editor.dart'; +import '../sync_preview_view.dart' show FilterLimitNote, confirmDeletingRun, keepOptionsTogether; +import '../theme.dart'; +import 'transfer_widgets.dart'; + +/// 실시간 동기화 "지우기" 확인 창 (42): 대상에만 있어 지워질 항목을 보여 주고 [지우기 포함으로 맞추기] · [지우지 않기] · [나중에] +Future confirmLiveDeletes(BuildContext context, LiveSync live, LiveSyncPair x) async { + final list = live.toDelete[LiveSync.keyOf(x)] ?? const []; + if (list.isEmpty) return; + final pick = await _liveDeleteDialog(context, live, x, list, live.toDeleteExcluded[LiveSync.keyOf(x)] ?? const [], now: false); + if (pick == null) return; + await live.decideDelete(x, delete: pick); +} + +/// P0 (관리자 3a 10/20): 미리 볼 수 없는 거르기 규칙이 있어 자동으로 지우지 않는 쌍의 [지금 지우기 (확인)]. +/// 빨간 FilterLimitNote 와 [알고 실행합니다] 체크를 거쳐 이번 한 번만 지운다 (다음 자동 맞추기는 다시 보내기만) +Future confirmLiveDeleteNow(BuildContext context, LiveSync live, LiveSyncPair x) async { + final (list, excl) = await live.previewDeletes(x); + if (!context.mounted) return; + final pick = await _liveDeleteDialog(context, live, x, list, excl, now: true); + if (pick == true) await live.deleteNow(x); +} + +Future _liveDeleteDialog(BuildContext context, LiveSync live, LiveSyncPair x, List list, List exclIn, + {required bool now}) { + // 관리자 3a 조건 (10/20): --delete-excluded 때문에 지워지는 것 (거른 것 · 거른 것이 든 폴더) 은 따로 묶어 알고 누르게 + final excl = exclIn.toSet(); + final plain = [for (final n in list) if (!excl.contains(n)) n]; + final filtered = [for (final n in list) if (excl.contains(n)) n]; + // P0: 미리 볼 수 없는 규칙 - 목록이 실제와 다를 수 있음을 빨갛게, 체크해야 [지우고 맞추기] + final limits = live.unsupportedOf(x); + var ack = false; + return showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, set) => AlertDialog( + title: Text(list.isEmpty ? tr('대상에서 지울까요?') : trf('대상에서 {0}개를 지울까요?', [list.length])), + content: SizedBox( + width: 560, + // 세로 폰에서도 목록 (특히 거른 것 묶음) 이 더 보이게 화면 높이를 따라 (360 ~ 520) + height: (MediaQuery.sizeOf(ctx).height * 0.55).clamp(360.0, 520.0), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (limits.isNotEmpty) ...[ + FilterLimitNote(unsupported: limits, deletes: true, ack: ack, onAck: (v) => set(() => ack = v)), + const SizedBox(height: 4), + ], + // 관리자 3a 판단 (10/20): 확인한 쌍인데 옵션이 바뀌어 지울 것이 늘어 다시 묻는 것 + if (!now && live.reasked.contains(LiveSync.keyOf(x))) ...[ + Text(tr('지우기를 확인한 뒤 rsync 옵션이 바뀌어 지울 것이 늘었습니다. 다시 확인해 주세요.'), + style: TextStyle(fontWeight: FontWeight.w600, color: JjColors.warning)), + const SizedBox(height: 6), + ], + Text(trf('"{0}" 에는 없고 대상 "{1}" 에만 있는 항목입니다. 지우기 포함으로 맞추면 대상에서 지워집니다 (되돌릴 수 없음).', + [vDisplay(x.source), vDisplay(x.target)])), + const SizedBox(height: 8), + Expanded( + child: ListView(children: [ + if (list.isEmpty) + Text(tr('미리 본 지울 것은 없습니다 - 위 규칙 때문에 실제로는 지워질 수 있습니다'), + style: TextStyle(fontSize: 12, color: JjColors.textDim)), + for (final n in plain.take(500)) Text(n, style: const TextStyle(fontSize: 12, fontFamily: 'Consolas')), + if (plain.length > 500) Text(trf('… 외 {0}개', [plain.length - 500])), + if (filtered.isNotEmpty) ...[ + if (plain.isNotEmpty) const SizedBox(height: 10), + Text(keepOptionsTogether(trf('거른 것 {0}개도 지워집니다 (--delete-excluded)', [filtered.length])), + style: TextStyle(fontWeight: FontWeight.w600, color: JjColors.danger)), + Text(keepOptionsTogether(tr('rsync 옵션에서 뺀 것 (--exclude 등) 이지만 --delete-excluded 가 있어 대상에서 지웁니다. 지키려면 옵션에서 --delete-excluded 를 빼세요.')), + style: TextStyle(fontSize: 12, color: JjColors.textDim)), + const SizedBox(height: 4), + for (final n in filtered.take(500)) Text(n, style: TextStyle(fontSize: 12, fontFamily: 'Consolas', color: JjColors.danger)), + if (filtered.length > 500) Text(trf('… 외 {0}개', [filtered.length - 500])), + ], + ]), + ), + ]), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('나중에'))), + if (!now) TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('지우지 않기 (지우기 끄기)'))), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: JjColors.danger), + onPressed: limits.isNotEmpty && !ack ? null : () => Navigator.pop(ctx, true), + child: Text(list.isEmpty ? tr('지우고 맞추기') : trf('{0}개 지우고 맞추기', [list.length])), + ), + ], + ), + ), + ); +} + +/// 70: 원본이 비어 멈춘 쌍 - 지워질 목록을 보여 주고 [그래도 맞추기] 로 확인받는다 (42 와 같은 창) +Future confirmEmptySourceSync(BuildContext context, LiveSync live, LiveSyncPair x) async { + final list = live.emptyDeletes[LiveSync.keyOf(x)] ?? const []; + if (list.isEmpty) return; + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(trf('원본이 비어 있습니다. 대상에서 {0}개를 지울까요?', [list.length])), + content: SizedBox( + width: 560, + height: 360, + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(trf('"{0}" 이 비어 있어, 맞추면 대상 "{1}" 의 아래 항목이 모두 지워집니다 (되돌릴 수 없음). ' + '원본을 일부러 비운 경우에만 누르세요.', [vDisplay(x.source), vDisplay(x.target)])), + const SizedBox(height: 8), + Expanded( + child: ListView(children: [ + for (final n in list.take(500)) Text(n, style: const TextStyle(fontSize: 12, fontFamily: 'Consolas')), + if (list.length > 500) Text(trf('… 외 {0}개', [list.length - 500])), + ]), + ), + ]), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: JjColors.danger), + onPressed: () => Navigator.pop(ctx, true), + child: Text(trf('그래도 맞추기 ({0}개 지움)', [list.length])), + ), + ], + ), + ); + if (ok == true) await live.syncEmptyAnyway(x); +} + +/// 앱을 다시 켤 때 ([AppSettings.liveSyncOnStart] 'ask'): 어느 실시간 동기화를 시작할지 고르기. +/// 고르지 않은 것은 이번 실행 동안 멈춤 (모니터링 > lsync 에서 ▶ 로 시작). +Future askLiveSyncStart(BuildContext context, LiveSync live) async { + final pairs = live.c.settings.liveSyncPairs.where((x) => x.enabled).toList(); + if (pairs.isEmpty) return; + final pick = {for (final x in pairs) LiveSync.keyOf(x)}; + final ok = await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, set) => AlertDialog( + title: Text(tr('실시간 동기화 시작')), + content: SizedBox( + width: 520, + child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(tr('시작할 동기화를 고르세요. 고르지 않은 것은 이번 실행 동안 멈춥니다 (모니터링 > lsync 에서 시작).'), + style: TextStyle(fontSize: 12, color: JjColors.textDim)), + const SizedBox(height: 8), + Flexible( + child: ListView(shrinkWrap: true, children: [ + for (final x in pairs) + CheckboxListTile( + dense: true, + contentPadding: EdgeInsets.zero, + value: pick.contains(LiveSync.keyOf(x)), + onChanged: (v) => set(() => v == true ? pick.add(LiveSync.keyOf(x)) : pick.remove(LiveSync.keyOf(x))), + title: Text('${vDisplay(x.source)} → ${vDisplay(x.target)}', maxLines: 2, overflow: TextOverflow.ellipsis), + subtitle: Text(scheduleSummary(x.schedule)), + ), + ]), + ), + ]), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('모두 나중에'))), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('고른 것 시작'))), + ], + ), + ), + ); + if (ok == true) live.runOnly(pairs.where((x) => pick.contains(LiveSync.keyOf(x)))); +} + +/// 같은 원본 · 대상 쌍만 [now] 로 바꿔 저장 (나머지 순서 그대로) +Future replaceLiveSyncPair(AppController c, LiveSyncPair old, LiveSyncPair now) => c.updateSettings((x) => x.liveSyncPairs = [ + for (final y in x.liveSyncPairs) y.source == old.source && y.target == old.target ? now : y, + ]); + +/// 69: 카드의 길게 누르기 · 오른쪽 클릭 메뉴 +Future showLivePairMenu(BuildContext context, AppController c, LiveSyncPair x, Offset at) async { + final pick = await showMenu( + context: context, + position: RelativeRect.fromLTRB(at.dx, at.dy, at.dx, at.dy), + items: [ + PopupMenuItem(value: 'edit', child: Text(tr('고치기'))), + PopupMenuItem(value: 'remove', child: Text(tr('지우기'))), + ], + ); + if (!context.mounted) return; + if (pick == 'edit') { + await editLiveSyncPairDialog(context, c, x); + } else if (pick == 'remove') { + await removeLiveSyncPair(context, c, x); + } +} + +/// 최종 정리: 원본의 것을 모두 대상으로 옮기고 (rsync -avHPOg --remove-source-files 원본/ 대상/) +/// 빈 폴더를 지운다 (find 원본/ -type d -empty -delete). 원본 폴더를 남길지 · 지울지만 고른다. +/// 원본이 비므로 이 실시간 동기화는 끈다 (켜 둔 채 "지우기 포함" 이면 대상까지 비워 버린다). +/// 작업이 시작되면 true (진행은 복사 · rsync 목록에서 봄 - 그쪽으로 넘기는 일은 부르는 쪽이 한다). +Future finalizeLiveSync(BuildContext context, AppController c, LiveSyncPair x) async { + final live = LiveSync.instance; + final messenger = ScaffoldMessenger.of(context); + if (live?.isRunning(x) ?? false) { + messenger.showSnackBar(SnackBar(content: Text(tr('지금 맞추는 중입니다. 끝난 뒤에 하세요.')))); + return false; + } + var prune = 'keep'; + final go = await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, set) => AlertDialog( + scrollable: true, + title: Text(tr('최종 정리')), + content: SizedBox( + width: 600, + child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(tr('원본의 파일을 모두 대상으로 옮기고 (원본에서 지움), 원본에 남은 빈 폴더를 지웁니다.')), + const SizedBox(height: 10), + SelectableText( + 'rsync -avHPOg --remove-source-files ${x.source}/ ${x.target}/\n' + 'find ${x.source}/ -type d -empty -delete', + style: TextStyle(fontFamily: 'monospace', fontSize: 12, color: JjColors.textDim)), + const SizedBox(height: 14), + Text(tr('원본 폴더'), style: const TextStyle(fontWeight: FontWeight.w600)), + const SizedBox(height: 6), + PruneChoice(value: prune, onChanged: (v) => set(() => prune = v)), + const SizedBox(height: 12), + Text(tr('원본이 비므로 이 실시간 동기화는 끕니다. 진행은 [복사 · rsync] 탭에서 봅니다.'), + style: TextStyle(fontSize: 12, color: JjColors.warning)), + ]), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('최종 정리'))), + ], + ), + ), + ); + if (go != true || !context.mounted) return false; + var exe = await rsyncExecutable(c.settings); + if (exe == null && Platform.isWindows && c.settings.rsyncSource == 'download' && context.mounted) { + exe = await installRsyncWithDialog(context); + } + if (exe == null) { + messenger.showSnackBar(SnackBar(content: Text(tr('rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > Rsync 에서 확인하세요.')))); + return false; + } + final center = CopyCenter.of(c); + var t = await center.remember([x.source], x.target, move: true, contents: true, method: 'rsync'); + // 처음이면 정해진 옵션 (모니터링에서 고친 것이 있으면 그것) + t = t.copyWith(prune: prune, options: t.lastRun.isEmpty ? '-avHPOg' : null); + await center.update(t); + // 104: 고친 옵션에 지우기가 있으면 미리 보기를 거친다 + if (!context.mounted || !await confirmDeletingRun(context, t) || !context.mounted) return false; + await replaceLiveSyncPair(c, x, x.copyWith(enabled: false)); + c.note(trf('rsync 시작 ({0}): {1} → {2}', [tr('실시간 동기화의 [최종 정리]'), x.source, x.target])); + final job = await center.start(t, rsyncExe: exe); + if (!context.mounted) return false; + if (job == null) { + messenger.showSnackBar(SnackBar(content: Text(center.tasks.firstWhere((y) => y.id == t.id).lastMessage))); + return false; + } + return true; +} diff --git a/lib/ui/monitor/transfer_widgets.dart b/lib/ui/monitor/transfer_widgets.dart new file mode 100644 index 0000000..36e9e06 --- /dev/null +++ b/lib/ui/monitor/transfer_widgets.dart @@ -0,0 +1,145 @@ +/// 모니터링 공용 작은 부품 (복사 · rsync 카드와 lsync 카드가 함께 씀, 다른 화면도 씀). +/// 약속: +/// - formatRunStamp(iso): ISO 시각 → "M/D HH:MM". 날짜가 아니면 ''. 오류를 던지지 않음. +/// - DiskSpaceInfo(path): 경로 (없으면 있는 위 폴더) 의 디스크 남은 / 전체. 못 재면 "대상 디스크: 알 수 없음". +/// - TransferProgressLines(job): 작업의 진행 두 줄 (전체 · 지금 폴더). job 이 바뀌면 다시 그림. +/// - pruneLabel(v) · PruneChoice: 이동 뒤 원본 정리 값 '' · 'keep' · 'all' 의 이름과 고르기 칩. +/// 설정 · 화면 상태를 읽지 않는다. +library; + +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:path/path.dart' as p; + +import '../../app/copy_center.dart'; +import '../../app/transfer_job.dart'; +import '../../core/file_ops.dart'; +import '../../l10n/tr.dart'; +import '../theme.dart'; + +/// 실행 시각 (ISO) 을 "월/일 시:분" 으로. 날짜가 아니면 빈 글. +String formatRunStamp(String iso) { + final d = DateTime.tryParse(iso); + if (d == null) return ''; + String two(int x) => x.toString().padLeft(2, '0'); + return '${d.month}/${d.day} ${two(d.hour)}:${two(d.minute)}'; +} + +/// 대상 디스크 남은 용량 / 전체 +class DiskSpaceInfo extends StatelessWidget { + final String path; + const DiskSpaceInfo({super.key, required this.path}); + + @override + Widget build(BuildContext context) { + var dir = path; + while (!Directory(dir).existsSync() && p.dirname(dir) != dir) { + dir = p.dirname(dir); + } + return FutureBuilder<(int, int)?>( + future: diskSpace(dir), + builder: (_, s) { + final d = s.data; + if (d == null) return Text(tr('대상 디스크: 알 수 없음'), style: TextStyle(fontSize: 12, color: JjColors.textDim)); + final (free, total) = d; + final used = total == 0 ? 0.0 : (total - free) / total; + return Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.storage, size: 14, color: JjColors.textDim), + const SizedBox(width: 4), + // 241 시험에서 찾음: 좁은 화면 (폭 500) 에서 넘쳤다 - 글을 줄인다 + Flexible( + child: Text(trf('대상 디스크: 남은 {0} / 전체 {1}', [formatSize(free), formatSize(total)]), + maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12)), + ), + const SizedBox(width: 8), + SizedBox( + width: 90, + child: ClipRRect( + borderRadius: BorderRadius.circular(3), + child: LinearProgressIndicator( + minHeight: 6, + value: used, + color: used > 0.9 ? JjColors.alert : JjColors.accent, + backgroundColor: JjColors.border, + ), + ), + ), + ]); + }, + ); + } +} + +/// 진행 두 줄 (위: 전체 항목 · 아래: 지금 폴더의 파일) +class TransferProgressLines extends StatelessWidget { + final TransferJob job; + const TransferProgressLines({super.key, required this.job}); + + @override + Widget build(BuildContext context) => ListenableBuilder( + listenable: job, + builder: (_, _) { + final cur = (job.index < job.total ? job.index : job.total - 1).clamp(0, job.total - 1); + String pct(double v) => '${(v * 100).round()}%'; + Widget line(String label, double v, String right) => Padding( + padding: const EdgeInsets.only(top: 4), + child: Row(children: [ + SizedBox(width: 220, child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12))), + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(3), + child: LinearProgressIndicator( + minHeight: 7, value: job.counting ? null : v, color: JjColors.accent, backgroundColor: JjColors.border), + ), + ), + SizedBox( + width: 130, + child: Text(right, textAlign: TextAlign.end, style: TextStyle(fontSize: 12, color: JjColors.textDim))), + ]), + ); + final speed = !job.finished && job.speed != null ? '${formatSize(job.speed!.round())}/s' : ''; + // 위: 지금 복사하는 파일 (그 파일의 진행률 · 속도) / 아래: 폴더 전체 (파일 수) + final fileLabel = job.counting + ? tr('파일 세는 중…') + : job.pruning + ? (job.finished ? trf('빈 폴더 {0}개 지움', [job.pruned]) : tr('원본의 빈 폴더 정리 중…')) + : job.finished + ? tr('끝') + : job.currentFile.isEmpty + ? tr('시작하는 중…') + : job.currentFile.split('/').last; + final fileValue = job.finished ? 1.0 : (job.filePercent ?? 0); + return Column(children: [ + Tooltip( + message: job.currentFile, + child: line(fileLabel, fileValue, + [if (job.filePercent != null && !job.finished) pct(fileValue), if (speed.isNotEmpty) speed].join(' · ')), + ), + line(trf('{0} 전체', [p.basename(job.sources[cur])]), job.folderProgress, + '${pct(job.folderProgress)} · ${job.checkTotal > 0 && !job.finished ? trf('항목 {0}/{1}', [job.checked, job.checkTotal]) : trf('파일 {0}/{1}', [job.doneFiles, job.allFiles])}'), + ]); + }, + ); +} + +/// 이동 (rsync --remove-source-files) 뒤 원본 정리 이름 +String pruneLabel(String v) => switch (v) { + 'keep' => tr('빈 폴더 지움 · 원본 폴더는 남김'), + 'all' => tr('빈 폴더 지움 · 원본 폴더도 지움'), + _ => tr('빈 폴더 그대로'), + }; + +/// 원본 정리 고르기: 원본 폴더를 남길지 · 지울지 (빈 폴더는 지움 = find 원본/ -type d -empty -delete) +class PruneChoice extends StatelessWidget { + final String value; + final ValueChanged onChanged; + final bool allowNone; + const PruneChoice({super.key, required this.value, required this.onChanged, this.allowNone = false}); + + @override + Widget build(BuildContext context) => Wrap(spacing: 6, runSpacing: 6, children: [ + for (final v in [if (allowNone) '', 'keep', 'all']) + ChoiceChip(label: Text(pruneLabel(v)), selected: value == v, onSelected: (_) => onChanged(v)), + ]); +} diff --git a/lib/ui/monitor_page.dart b/lib/ui/monitor_page.dart index 41822ce..59ef0f3 100644 --- a/lib/ui/monitor_page.dart +++ b/lib/ui/monitor_page.dart @@ -1,29 +1,20 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:path/path.dart' as p; import '../app/app_controller.dart'; -import '../app/copy_center.dart'; import '../app/live_sync.dart'; import '../app/settings.dart'; -import '../app/transfer_job.dart'; -import '../core/download_detect.dart' show redactUrlSecrets; -import '../core/secret_gate.dart'; -import '../core/file_ops.dart'; -import '../core/sync_tools.dart'; -import '../core/vfs.dart'; import '../l10n/tr.dart'; import 'app_actions.dart'; -import 'copy_sync_settings.dart' show addLiveSyncPairDialog, editLiveSyncPairDialog, removeLiveSyncPair, LiveSyncRunOptions; -import 'path_label.dart'; -import 'rsync_setup.dart'; -import 'schedule_editor.dart'; -import '../core/sync_preview.dart' show optionsRemoveSource; -import 'sync_preview_view.dart' show FilterLimitNote, OptionProblemNote, OptionTextFormatter, confirmDeletingRun, effectiveRunText, keepOptionsTogether, unbreakableOption; +import 'copy_sync_settings.dart' show addLiveSyncPairDialog, LiveSyncRunOptions; +import 'monitor/copy_task_card.dart'; +import 'monitor/live_pair_card.dart'; +import 'monitor/live_sync_flow.dart'; import 'theme.dart'; +// 모니터링의 부품은 lib/ui/monitor/ 의 모듈에 있다. 예전부터 이 파일에서 가져다 쓰던 이름은 그대로 내보낸다. +export 'monitor/live_sync_flow.dart' show confirmLiveDeletes, confirmLiveDeleteNow, confirmEmptySourceSync, askLiveSyncStart; +export 'monitor/transfer_widgets.dart' show TransferProgressLines, PruneChoice, pruneLabel; + /// 모니터링 (파일 탐색기 > 모니터링): 복사 · rsync 와 lsync (실시간 동기화) 를 따로 보여 준다. class MonitorPage extends StatelessWidget { @@ -67,7 +58,7 @@ class MonitorPage extends StatelessWidget { }), Expanded( child: TabBarView(children: [ - _CopyTab(c: c), + CopyTaskList(c: c), _LiveTab(c: c), ]), ), @@ -76,650 +67,6 @@ class MonitorPage extends StatelessWidget { ); } -/// 실시간 동기화 "지우기" 확인 창 (42): 대상에만 있어 지워질 항목을 보여 주고 [지우기 포함으로 맞추기] · [지우지 않기] · [나중에] -Future confirmLiveDeletes(BuildContext context, LiveSync live, LiveSyncPair x) async { - final list = live.toDelete[LiveSync.keyOf(x)] ?? const []; - if (list.isEmpty) return; - final pick = await _liveDeleteDialog(context, live, x, list, live.toDeleteExcluded[LiveSync.keyOf(x)] ?? const [], now: false); - if (pick == null) return; - await live.decideDelete(x, delete: pick); -} - -/// P0 (관리자 3a 10/20): 미리 볼 수 없는 거르기 규칙이 있어 자동으로 지우지 않는 쌍의 [지금 지우기 (확인)]. -/// 빨간 FilterLimitNote 와 [알고 실행합니다] 체크를 거쳐 이번 한 번만 지운다 (다음 자동 맞추기는 다시 보내기만) -Future confirmLiveDeleteNow(BuildContext context, LiveSync live, LiveSyncPair x) async { - final (list, excl) = await live.previewDeletes(x); - if (!context.mounted) return; - final pick = await _liveDeleteDialog(context, live, x, list, excl, now: true); - if (pick == true) await live.deleteNow(x); -} - -Future _liveDeleteDialog(BuildContext context, LiveSync live, LiveSyncPair x, List list, List exclIn, - {required bool now}) { - // 관리자 3a 조건 (10/20): --delete-excluded 때문에 지워지는 것 (거른 것 · 거른 것이 든 폴더) 은 따로 묶어 알고 누르게 - final excl = exclIn.toSet(); - final plain = [for (final n in list) if (!excl.contains(n)) n]; - final filtered = [for (final n in list) if (excl.contains(n)) n]; - // P0: 미리 볼 수 없는 규칙 - 목록이 실제와 다를 수 있음을 빨갛게, 체크해야 [지우고 맞추기] - final limits = live.unsupportedOf(x); - var ack = false; - return showDialog( - context: context, - builder: (ctx) => StatefulBuilder( - builder: (ctx, set) => AlertDialog( - title: Text(list.isEmpty ? tr('대상에서 지울까요?') : trf('대상에서 {0}개를 지울까요?', [list.length])), - content: SizedBox( - width: 560, - // 세로 폰에서도 목록 (특히 거른 것 묶음) 이 더 보이게 화면 높이를 따라 (360 ~ 520) - height: (MediaQuery.sizeOf(ctx).height * 0.55).clamp(360.0, 520.0), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (limits.isNotEmpty) ...[ - FilterLimitNote(unsupported: limits, deletes: true, ack: ack, onAck: (v) => set(() => ack = v)), - const SizedBox(height: 4), - ], - // 관리자 3a 판단 (10/20): 확인한 쌍인데 옵션이 바뀌어 지울 것이 늘어 다시 묻는 것 - if (!now && live.reasked.contains(LiveSync.keyOf(x))) ...[ - Text(tr('지우기를 확인한 뒤 rsync 옵션이 바뀌어 지울 것이 늘었습니다. 다시 확인해 주세요.'), - style: TextStyle(fontWeight: FontWeight.w600, color: JjColors.warning)), - const SizedBox(height: 6), - ], - Text(trf('"{0}" 에는 없고 대상 "{1}" 에만 있는 항목입니다. 지우기 포함으로 맞추면 대상에서 지워집니다 (되돌릴 수 없음).', - [vDisplay(x.source), vDisplay(x.target)])), - const SizedBox(height: 8), - Expanded( - child: ListView(children: [ - if (list.isEmpty) - Text(tr('미리 본 지울 것은 없습니다 - 위 규칙 때문에 실제로는 지워질 수 있습니다'), - style: TextStyle(fontSize: 12, color: JjColors.textDim)), - for (final n in plain.take(500)) Text(n, style: const TextStyle(fontSize: 12, fontFamily: 'Consolas')), - if (plain.length > 500) Text(trf('… 외 {0}개', [plain.length - 500])), - if (filtered.isNotEmpty) ...[ - if (plain.isNotEmpty) const SizedBox(height: 10), - Text(keepOptionsTogether(trf('거른 것 {0}개도 지워집니다 (--delete-excluded)', [filtered.length])), - style: TextStyle(fontWeight: FontWeight.w600, color: JjColors.danger)), - Text(keepOptionsTogether(tr('rsync 옵션에서 뺀 것 (--exclude 등) 이지만 --delete-excluded 가 있어 대상에서 지웁니다. 지키려면 옵션에서 --delete-excluded 를 빼세요.')), - style: TextStyle(fontSize: 12, color: JjColors.textDim)), - const SizedBox(height: 4), - for (final n in filtered.take(500)) Text(n, style: TextStyle(fontSize: 12, fontFamily: 'Consolas', color: JjColors.danger)), - if (filtered.length > 500) Text(trf('… 외 {0}개', [filtered.length - 500])), - ], - ]), - ), - ]), - ), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('나중에'))), - if (!now) TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('지우지 않기 (지우기 끄기)'))), - FilledButton( - style: FilledButton.styleFrom(backgroundColor: JjColors.danger), - onPressed: limits.isNotEmpty && !ack ? null : () => Navigator.pop(ctx, true), - child: Text(list.isEmpty ? tr('지우고 맞추기') : trf('{0}개 지우고 맞추기', [list.length])), - ), - ], - ), - ), - ); -} - -/// 70: 원본이 비어 멈춘 쌍 - 지워질 목록을 보여 주고 [그래도 맞추기] 로 확인받는다 (42 와 같은 창) -Future confirmEmptySourceSync(BuildContext context, LiveSync live, LiveSyncPair x) async { - final list = live.emptyDeletes[LiveSync.keyOf(x)] ?? const []; - if (list.isEmpty) return; - final ok = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text(trf('원본이 비어 있습니다. 대상에서 {0}개를 지울까요?', [list.length])), - content: SizedBox( - width: 560, - height: 360, - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(trf('"{0}" 이 비어 있어, 맞추면 대상 "{1}" 의 아래 항목이 모두 지워집니다 (되돌릴 수 없음). ' - '원본을 일부러 비운 경우에만 누르세요.', [vDisplay(x.source), vDisplay(x.target)])), - const SizedBox(height: 8), - Expanded( - child: ListView(children: [ - for (final n in list.take(500)) Text(n, style: const TextStyle(fontSize: 12, fontFamily: 'Consolas')), - if (list.length > 500) Text(trf('… 외 {0}개', [list.length - 500])), - ]), - ), - ]), - ), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), - FilledButton( - style: FilledButton.styleFrom(backgroundColor: JjColors.danger), - onPressed: () => Navigator.pop(ctx, true), - child: Text(trf('그래도 맞추기 ({0}개 지움)', [list.length])), - ), - ], - ), - ); - if (ok == true) await live.syncEmptyAnyway(x); -} - -String _hm(String iso) { - final d = DateTime.tryParse(iso); - if (d == null) return ''; - String two(int x) => x.toString().padLeft(2, '0'); - return '${d.month}/${d.day} ${two(d.hour)}:${two(d.minute)}'; -} - -/// 대상 디스크 남은 용량 / 전체 -class _DiskInfo extends StatelessWidget { - final String path; - const _DiskInfo({required this.path}); - - @override - Widget build(BuildContext context) { - var dir = path; - while (!Directory(dir).existsSync() && p.dirname(dir) != dir) { - dir = p.dirname(dir); - } - return FutureBuilder<(int, int)?>( - future: diskSpace(dir), - builder: (_, s) { - final d = s.data; - if (d == null) return Text(tr('대상 디스크: 알 수 없음'), style: TextStyle(fontSize: 12, color: JjColors.textDim)); - final (free, total) = d; - final used = total == 0 ? 0.0 : (total - free) / total; - return Row(mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.storage, size: 14, color: JjColors.textDim), - const SizedBox(width: 4), - // 241 시험에서 찾음: 좁은 화면 (폭 500) 에서 넘쳤다 - 글을 줄인다 - Flexible( - child: Text(trf('대상 디스크: 남은 {0} / 전체 {1}', [formatSize(free), formatSize(total)]), - maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12)), - ), - const SizedBox(width: 8), - SizedBox( - width: 90, - child: ClipRRect( - borderRadius: BorderRadius.circular(3), - child: LinearProgressIndicator( - minHeight: 6, - value: used, - color: used > 0.9 ? JjColors.alert : JjColors.accent, - backgroundColor: JjColors.border, - ), - ), - ), - ]); - }, - ); - } -} - -/// 진행 두 줄 (위: 전체 항목 · 아래: 지금 폴더의 파일) -class TransferProgressLines extends StatelessWidget { - final TransferJob job; - const TransferProgressLines({super.key, required this.job}); - - @override - Widget build(BuildContext context) => ListenableBuilder( - listenable: job, - builder: (_, _) { - final cur = (job.index < job.total ? job.index : job.total - 1).clamp(0, job.total - 1); - String pct(double v) => '${(v * 100).round()}%'; - Widget line(String label, double v, String right) => Padding( - padding: const EdgeInsets.only(top: 4), - child: Row(children: [ - SizedBox(width: 220, child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12))), - Expanded( - child: ClipRRect( - borderRadius: BorderRadius.circular(3), - child: LinearProgressIndicator( - minHeight: 7, value: job.counting ? null : v, color: JjColors.accent, backgroundColor: JjColors.border), - ), - ), - SizedBox( - width: 130, - child: Text(right, textAlign: TextAlign.end, style: TextStyle(fontSize: 12, color: JjColors.textDim))), - ]), - ); - final speed = !job.finished && job.speed != null ? '${formatSize(job.speed!.round())}/s' : ''; - // 위: 지금 복사하는 파일 (그 파일의 진행률 · 속도) / 아래: 폴더 전체 (파일 수) - final fileLabel = job.counting - ? tr('파일 세는 중…') - : job.pruning - ? (job.finished ? trf('빈 폴더 {0}개 지움', [job.pruned]) : tr('원본의 빈 폴더 정리 중…')) - : job.finished - ? tr('끝') - : job.currentFile.isEmpty - ? tr('시작하는 중…') - : job.currentFile.split('/').last; - final fileValue = job.finished ? 1.0 : (job.filePercent ?? 0); - return Column(children: [ - Tooltip( - message: job.currentFile, - child: line(fileLabel, fileValue, - [if (job.filePercent != null && !job.finished) pct(fileValue), if (speed.isNotEmpty) speed].join(' · ')), - ), - line(trf('{0} 전체', [p.basename(job.sources[cur])]), job.folderProgress, - '${pct(job.folderProgress)} · ${job.checkTotal > 0 && !job.finished ? trf('항목 {0}/{1}', [job.checked, job.checkTotal]) : trf('파일 {0}/{1}', [job.doneFiles, job.allFiles])}'), - ]); - }, - ); -} - -// ───────── 복사 · rsync ───────── - -class _CopyTab extends StatelessWidget { - final AppController c; - const _CopyTab({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) _CopyCard(key: ValueKey(t.id), c: c, task: t)], - ); - }, - ); - } -} - -/// 이동 (rsync --remove-source-files) 뒤 원본 정리 이름 -String pruneLabel(String v) => switch (v) { - 'keep' => tr('빈 폴더 지움 · 원본 폴더는 남김'), - 'all' => tr('빈 폴더 지움 · 원본 폴더도 지움'), - _ => tr('빈 폴더 그대로'), - }; - -/// 원본 정리 고르기: 원본 폴더를 남길지 · 지울지 (빈 폴더는 지움 = find 원본/ -type d -empty -delete) -class PruneChoice extends StatelessWidget { - final String value; - final ValueChanged onChanged; - final bool allowNone; - const PruneChoice({super.key, required this.value, required this.onChanged, this.allowNone = false}); - - @override - Widget build(BuildContext context) => Wrap(spacing: 6, runSpacing: 6, children: [ - for (final v in [if (allowNone) '', 'keep', 'all']) - ChoiceChip(label: Text(pruneLabel(v)), selected: value == v, onSelected: (_) => onChanged(v)), - ]); -} - -class _CopyCard extends StatefulWidget { - final AppController c; - final CopyTask task; - const _CopyCard({super.key, required this.c, required this.task}); - - @override - State<_CopyCard> createState() => _CopyCardState(); -} - -class _CopyCardState extends State<_CopyCard> { - 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}개', [_hm(t.lastRun), t.lastFiles])} · ${trf('{0}개는 원본에 남음 (건너뜀)', [t.lastLeft])}' - ), - 'done' => (Icons.check_circle, JjColors.positive, trf('끝 · {0} · 파일 {1}개', [_hm(t.lastRun), t.lastFiles])), - 'failed' => (Icons.error, JjColors.alert, trf('실패 · {0}', [_hm(t.lastRun)])), - 'cancelled' => (Icons.pause_circle, JjColors.warning, trf('멈춤 · {0}', [_hm(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)), - _DiskInfo(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), - ), - ]), - ]), - ), - ); - } -} - // ───────── lsync (실시간 동기화) ───────── class _LiveTab extends StatefulWidget { @@ -745,77 +92,9 @@ class _LiveTabState extends State<_LiveTab> { } } - Future _replace(LiveSyncPair old, LiveSyncPair now) => c.updateSettings((x) => x.liveSyncPairs = [ - for (final y in x.liveSyncPairs) y.source == old.source && y.target == old.target ? now : y, - ]); - - /// 최종 정리: 원본의 것을 모두 대상으로 옮기고 (rsync -avHPOg --remove-source-files 원본/ 대상/) - /// 빈 폴더를 지운다 (find 원본/ -type d -empty -delete). 원본 폴더를 남길지 · 지울지만 고른다. - /// 원본이 비므로 이 실시간 동기화는 끈다 (켜 둔 채 "지우기 포함" 이면 대상까지 비워 버린다). + /// 최종 정리를 시작했으면 진행을 볼 수 있게 복사 · rsync 탭으로 Future _finalize(LiveSyncPair x) async { - final live = LiveSync.instance; - final messenger = ScaffoldMessenger.of(context); - if (live?.isRunning(x) ?? false) { - messenger.showSnackBar(SnackBar(content: Text(tr('지금 맞추는 중입니다. 끝난 뒤에 하세요.')))); - return; - } - var prune = 'keep'; - final go = await showDialog( - context: context, - builder: (ctx) => StatefulBuilder( - builder: (ctx, set) => AlertDialog( - scrollable: true, - title: Text(tr('최종 정리')), - content: SizedBox( - width: 600, - child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(tr('원본의 파일을 모두 대상으로 옮기고 (원본에서 지움), 원본에 남은 빈 폴더를 지웁니다.')), - const SizedBox(height: 10), - SelectableText( - 'rsync -avHPOg --remove-source-files ${x.source}/ ${x.target}/\n' - 'find ${x.source}/ -type d -empty -delete', - style: TextStyle(fontFamily: 'monospace', fontSize: 12, color: JjColors.textDim)), - const SizedBox(height: 14), - Text(tr('원본 폴더'), style: const TextStyle(fontWeight: FontWeight.w600)), - const SizedBox(height: 6), - PruneChoice(value: prune, onChanged: (v) => set(() => prune = v)), - const SizedBox(height: 12), - Text(tr('원본이 비므로 이 실시간 동기화는 끕니다. 진행은 [복사 · rsync] 탭에서 봅니다.'), - style: TextStyle(fontSize: 12, color: JjColors.warning)), - ]), - ), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), - FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('최종 정리'))), - ], - ), - ), - ); - if (go != true || !mounted) return; - var exe = await rsyncExecutable(c.settings); - if (exe == null && Platform.isWindows && c.settings.rsyncSource == 'download' && mounted) { - exe = await installRsyncWithDialog(context); - } - if (exe == null) { - messenger.showSnackBar(SnackBar(content: Text(tr('rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > Rsync 에서 확인하세요.')))); - return; - } - final center = CopyCenter.of(c); - var t = await center.remember([x.source], x.target, move: true, contents: true, method: 'rsync'); - // 처음이면 정해진 옵션 (모니터링에서 고친 것이 있으면 그것) - t = t.copyWith(prune: prune, options: t.lastRun.isEmpty ? '-avHPOg' : null); - await center.update(t); - // 104: 고친 옵션에 지우기가 있으면 미리 보기를 거친다 - if (!mounted || !await confirmDeletingRun(context, t) || !mounted) return; - await _replace(x, x.copyWith(enabled: false)); - c.note(trf('rsync 시작 ({0}): {1} → {2}', [tr('실시간 동기화의 [최종 정리]'), x.source, x.target])); - final job = await center.start(t, rsyncExe: exe); - if (!mounted) return; - if (job == null) { - messenger.showSnackBar(SnackBar(content: Text(center.tasks.firstWhere((y) => y.id == t.id).lastMessage))); - return; - } - DefaultTabController.of(context).animateTo(0); + if (await finalizeLiveSync(context, c, x) && mounted) DefaultTabController.of(context).animateTo(0); } @override @@ -854,373 +133,16 @@ class _LiveTabState extends State<_LiveTab> { child: Center(child: Text(tr('실시간 동기화가 없습니다. [추가] 하거나 복사 · rsync 탭에서 [lsync 로 이동] 하세요.'), style: TextStyle(color: JjColors.textDim))), ), - for (final x in pairs) _liveCard(context, live, x), + for (final x in pairs) + LivePairCard( + c: c, + live: live, + pair: x, + onFinalize: () => _finalize(x), + onShowCopyList: () => DefaultTabController.of(context).animateTo(0), + ), ]); }, ); } - - /// 69: 카드의 길게 누르기 · 오른쪽 클릭 메뉴 - Future _pairMenu(BuildContext context, LiveSyncPair x, Offset at) async { - final pick = await showMenu( - context: context, - position: RelativeRect.fromLTRB(at.dx, at.dy, at.dx, at.dy), - items: [ - PopupMenuItem(value: 'edit', child: Text(tr('고치기'))), - PopupMenuItem(value: 'remove', child: Text(tr('지우기'))), - ], - ); - if (!context.mounted) return; - if (pick == 'edit') { - await editLiveSyncPairDialog(context, c, x); - } else if (pick == 'remove') { - await removeLiveSyncPair(context, c, x); - } - } - - /// 원본 문제 안내 (68 · 70) - Widget _problemPanel(BuildContext context, LiveSync live, LiveSyncPair x, SourceUnreadableException e) { - final k = LiveSync.keyOf(x); - final dels = live.emptyDeletes[k] ?? const []; - final running = live.isRunning(x); - // 128: 마스터 비밀번호를 기다리는 중 - 고장이 아니라 넣으면 이어서 맞춘다 - if (e.locked) { - return Container( - width: double.infinity, - padding: const EdgeInsets.fromLTRB(12, 10, 12, 8), - decoration: BoxDecoration( - color: JjColors.accent.withValues(alpha: 0.10), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: JjColors.accent.withValues(alpha: 0.5)), - ), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - Icon(Icons.lock_clock_outlined, color: JjColors.accent, size: 20), - const SizedBox(width: 8), - Expanded( - child: Text(tr('마스터 비밀번호가 필요해 기다리는 중'), - style: TextStyle(fontWeight: FontWeight.w600, color: JjColors.accent)), - ), - ]), - const SizedBox(height: 4), - Text(tr('이 서버의 저장된 비밀번호를 쓰려면 마스터 비밀번호가 필요합니다. 넣으면 이어서 맞춥니다. 대상 파일은 건드리지 않았습니다.'), - style: const TextStyle(fontSize: 12)), - const SizedBox(height: 6), - FilledButton.tonalIcon( - onPressed: running - ? null - : () async { - if (await SecretGate.pass(force: true)) await live.syncNow(x); - }, - icon: const Icon(Icons.lock_open, size: 18), - label: Text(tr('마스터 비밀번호 넣기')), - ), - ]), - ); - } - return Container( - width: double.infinity, - padding: const EdgeInsets.fromLTRB(12, 10, 12, 8), - decoration: BoxDecoration( - color: JjColors.alert.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: JjColors.alert.withValues(alpha: 0.6)), - ), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - Icon(e.empty ? Icons.folder_off_outlined : Icons.error_outline, color: JjColors.alert, size: 20), - const SizedBox(width: 8), - Expanded( - child: Text( - e.empty ? tr('원본 폴더가 비어 있어 멈췄습니다') : tr('원본을 읽을 수 없어 멈췄습니다'), - style: TextStyle(fontWeight: FontWeight.w600, color: JjColors.alert), - ), - ), - ]), - const SizedBox(height: 4), - Text(tr('대상 파일은 지우지 않았습니다.'), style: const TextStyle(fontSize: 12)), - Text( - e.empty - ? trf('원본에는 아무것도 없는데 대상에는 {0}개가 있습니다. SD 카드가 빠졌거나 네트워크 · 권한 문제일 수 있습니다. ' - '일부러 원본을 비웠다면 지울 목록을 보고 [그래도 맞추기] 를 누르세요.', [dels.length]) - : tr('SD 카드가 꽂혀 있는지, 네트워크 (NAS · VPN) 가 연결되어 있는지, 폴더 권한이 있는지 확인한 뒤 [다시 시도] 를 누르세요.'), - style: const TextStyle(fontSize: 12), - ), - const SizedBox(height: 4), - Text('${vDisplay(x.source)}${e.detail.isEmpty ? '' : ' · ${e.detail}'}', - style: TextStyle(fontSize: 11, color: JjColors.textDim)), - const SizedBox(height: 6), - Wrap(spacing: 8, runSpacing: 4, children: [ - FilledButton.tonalIcon( - onPressed: running ? null : () => live.syncNow(x), - icon: const Icon(Icons.refresh, size: 18), - label: Text(tr('다시 시도')), - ), - if (e.empty && dels.isNotEmpty) - OutlinedButton( - onPressed: running ? null : () => confirmEmptySourceSync(context, live, x), - child: Text(tr('지울 목록 보고 결정')), - ), - ]), - ]), - ); - } - - Widget _liveCard(BuildContext context, LiveSync? live, LiveSyncPair x) { - final k = LiveSync.keyOf(x); - final pend = live?.pending[k]; - final st = live?.status[k]; - final active = LiveSync.activeNow(x); - final running = live?.isRunning(x) ?? false; - final held = live?.isPaused(x) ?? false; - // 257: 칩은 길게 누르기 · 마우스로 전체를 본다. 관리자 지시 (10/20, 28 지적): 좁은 화면 (폰 세로) 에서는 경로 줄에 끼면 - // "rsync · 복…" 으로 잘려 경로 아래 따로 한 줄로 (넓으면 예전처럼 경로 옆) - final narrow = MediaQuery.sizeOf(context).width < 600; - final chipText = [tr(CopyMethod.of(x.method).label), tr('복사로 맞춤'), if (x.delete) tr('지우기 포함')].join(' · '); - final chip = Tooltip( - message: chipText, - child: Chip( - visualDensity: VisualDensity.compact, - // 243: 실시간 동기화는 늘 복사로 맞춘다 (원본은 지우지 않음 - 원본을 비우려면 [최종 정리]) - label: Text(chipText, maxLines: 1, overflow: TextOverflow.ellipsis), - ), - ); - 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( - running - ? Icons.sync - : !x.enabled - ? Icons.sync_disabled - : held - ? Icons.pause_circle_outline - : active - ? Icons.sync - : Icons.bedtime_outlined, - color: x.enabled && !held ? (active ? JjColors.accent : JjColors.warning) : JjColors.textDim), - const SizedBox(width: 8), - Expanded( - // 67: 끝 폴더가 보이게 짧게 · 69: 길게 누르면 (오른쪽 클릭) 고치기 · 지우기 - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onLongPressStart: (d) => _pairMenu(context, x, d.globalPosition), - onSecondaryTapDown: (d) => _pairMenu(context, x, d.globalPosition), - child: Tooltip( - // 길게 누르기는 메뉴에 (마우스를 올리면 전체 경로) - triggerMode: TooltipTriggerMode.manual, - message: '${vDisplay(x.source)}\n→ ${vDisplay(x.target)}', - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(shortPath(x.source), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.w600)), - Text('→ ${shortPath(x.target)}', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.w600)), - ]), - ), - ), - ), - // 257: 폰 세로에서 이 줄이 넘쳤다 - 칩은 남는 폭만큼 (넘치면 … 로 줄임, 전체는 길게 누르기 · 마우스) - if (!narrow) Flexible(child: chip), - // 이번 실행 동안 멈춤 / 시작 (설정의 켜기 · 끄기는 오른쪽 스위치) - if (live != null && x.enabled) - IconButton( - tooltip: held ? tr('시작') : tr('멈춤 (이번 실행 동안)'), - icon: Icon(held ? Icons.play_arrow : Icons.pause, color: held ? JjColors.accent : null), - onPressed: () => held ? live.resume(x) : live.pause(x), - ), - Switch(value: x.enabled, onChanged: (v) => _replace(x, x.copyWith(enabled: v))), - ]), - if (narrow) Padding(padding: const EdgeInsets.only(left: 32, top: 2), child: chip), - if (held) - Padding( - padding: const EdgeInsets.only(left: 32, top: 2), - child: Text(tr('멈춤 (이번 실행 동안) · 시작 버튼을 누르면 다시 동기화합니다'), - style: TextStyle(fontSize: 12, color: JjColors.warning)), - ), - // 42: 지우기를 아직 확인하지 않았고 지울 것이 있다 → 지우지 않고 맞추는 중. 목록을 보고 결정 - if (live != null && (live.toDelete[k]?.isNotEmpty ?? false)) - Padding( - padding: const EdgeInsets.only(left: 32, top: 4), - child: Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ - Icon(Icons.warning_amber_rounded, size: 16, color: JjColors.warning), - Text(trf('대상에만 있는 {0}개를 지울지 확인이 필요합니다 (확인 전에는 지우지 않음)', [live.toDelete[k]!.length]), - style: TextStyle(fontSize: 12, color: JjColors.warning)), - OutlinedButton( - onPressed: () => confirmLiveDeletes(context, live, x), - child: Text(tr('지울 목록 보고 결정')), - ), - ]), - ), - // P0 (관리자 3a 10/20): 미리 볼 수 없는 거르기 규칙 → 저절로 지우지 않고 보내기만. 지우기는 확인을 거쳐 한 번씩 - if (live != null && live.noAutoDelete[k] != null) - Padding( - padding: const EdgeInsets.only(left: 32, top: 4), - child: Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ - Icon(Icons.block, size: 16, color: JjColors.alert), - Text(tr('이 옵션의 일부 거르기 규칙은 미리 볼 수 없어 자동으로 지우지 않습니다'), - style: TextStyle(fontSize: 12, color: JjColors.alert, fontWeight: FontWeight.w600)), - Text('(${live.noAutoDelete[k]!.map(unbreakableOption).join(' · ')})', style: TextStyle(fontSize: 12, color: JjColors.alert)), - OutlinedButton( - onPressed: () => confirmLiveDeleteNow(context, live, x), - child: Text(tr('지금 지우기 (확인)')), - ), - ]), - ), - Padding( - padding: const EdgeInsets.only(left: 32, top: 4), - child: Wrap(spacing: 16, runSpacing: 6, crossAxisAlignment: WrapCrossAlignment.center, children: [ - // 일정: 계속 / 시간 지정 (cron) + 작은 격자 - InkWell( - onTap: () async { - final r = await editSchedule(context, x.schedule); - if (r != null) await _replace(x, x.copyWith(schedule: r)); - }, - borderRadius: BorderRadius.circular(6), - child: Padding( - padding: const EdgeInsets.all(4), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - WeekGridPreview(lines: x.schedule, width: 120), - const SizedBox(width: 8), - // 257: 폰 세로에서 글이 줄을 바꿀 수 있게 (넘쳤다) - Flexible( - child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text(scheduleSummary(x.schedule), style: TextStyle(fontSize: 12, color: active ? JjColors.accent : JjColors.warning)), - if (x.schedule.isNotEmpty) - Text(x.schedule.join(' | '), style: TextStyle(fontSize: 11, fontFamily: 'monospace', color: JjColors.textDim)), - Text(tr('누르면 일정 바꾸기'), style: TextStyle(fontSize: 10, color: JjColors.textDim)), - ]), - ), - ]), - ), - ), - if (st != null) - Text('${tr('마지막')}: ${st.$1.hour.toString().padLeft(2, '0')}:${st.$1.minute.toString().padLeft(2, '0')} ${st.$2}', - style: TextStyle(fontSize: 12, color: live?.problems[k] != null ? JjColors.alert : JjColors.textDim)), - _DiskInfo(path: x.target), - ]), - ), - // 68 · 70: 원본을 읽지 못함 / 원본이 비어 멈춤 - "맞출 것 없음" 대신 눈에 띄게, 할 일과 [다시 시도] - if (live?.problems[k] case final prob?) - Padding(padding: const EdgeInsets.only(left: 32, top: 8), child: _problemPanel(context, live!, x, prob)) - else - // 맞출 것 (바뀐 파일) - 자동으로 다시 셈 - Padding( - padding: const EdgeInsets.only(left: 32, top: 8), - child: pend == null - ? Text(tr('맞출 것을 세는 중…'), style: TextStyle(fontSize: 12, color: JjColors.textDim)) - : pend.isEmpty - // 244: 대상에만 있는 것이 있으면 "같습니다" 가 아니라 사실대로 - ? Text( - (live?.onlyTarget[k] ?? 0) > 0 - ? trf('원본의 것은 모두 대상에 있습니다 (대상에만 있는 것 {0}개는 그대로 둠)', [live!.onlyTarget[k]]) - : tr('맞출 것 없음 (원본과 대상이 같습니다)'), - style: TextStyle(fontSize: 12, color: JjColors.positive)) - : Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - trf('맞출 것 {0}개{1}', [pend.length >= 500 ? '500+' : pend.length, active ? '' : tr(' · 다음 시작 시간에 맞춥니다')]), - style: TextStyle(fontSize: 12, color: JjColors.warning), - ), - const SizedBox(height: 4), - Container( - constraints: const BoxConstraints(maxHeight: 140), - decoration: BoxDecoration(color: JjColors.bg, borderRadius: BorderRadius.circular(6)), - padding: const EdgeInsets.all(8), - child: ListView(shrinkWrap: true, children: [ - for (final f in pend.take(100)) - Text(f, - style: TextStyle( - fontSize: 11.5, fontFamily: 'monospace', color: f.startsWith('− ') ? JjColors.alert : null)), - if (pend.length > 100) Text(trf('… 외 {0}개', [pend.length - 100]), style: const TextStyle(fontSize: 11)), - ]), - ), - ]), - ), - const SizedBox(height: 6), - // 257: 폰 세로에서 [고치기] · [지우기] 가 화면 밖으로 밀려 누를 수 없었다 - 줄을 바꾼다 (오른쪽 정렬은 그대로) - Wrap(alignment: WrapAlignment.end, crossAxisAlignment: WrapCrossAlignment.center, spacing: 2, runSpacing: 2, children: [ - TextButton.icon( - onPressed: live == null ? null : () => live.refreshPending(x), - icon: const Icon(Icons.refresh, size: 18), - label: Text(tr('다시 세기')), - ), - FilledButton.icon( - onPressed: live == null || running ? null : () => live.syncNow(x), - icon: const Icon(Icons.sync, size: 18), - label: Text(tr('지금 맞추기')), - ), - TextButton.icon( - onPressed: running ? null : () => _finalize(x), - icon: const Icon(Icons.cleaning_services_outlined, size: 18), - label: Text(tr('최종 정리')), - ), - TextButton.icon( - onPressed: () async { - await CopyCenter.of(c).fromLiveSync(x); - if (context.mounted) { - DefaultTabController.of(context).animateTo(0); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(tr('복사 · rsync 목록으로 옮겼습니다.')))); - } - }, - icon: const Icon(Icons.swap_horiz, size: 18), - label: Text(tr('복사 · rsync 로 이동')), - ), - IconButton( - tooltip: tr('고치기'), - icon: const Icon(Icons.edit_outlined), - onPressed: () => editLiveSyncPairDialog(context, c, x), - ), - IconButton( - tooltip: tr('지우기'), - icon: Icon(Icons.delete_outline, color: JjColors.textDim), - onPressed: () => removeLiveSyncPair(context, c, x), - ), - ]), - ]), - ), - ); - } -} - -/// 앱을 다시 켤 때 ([AppSettings.liveSyncOnStart] 'ask'): 어느 실시간 동기화를 시작할지 고르기. -/// 고르지 않은 것은 이번 실행 동안 멈춤 (모니터링 > lsync 에서 ▶ 로 시작). -Future askLiveSyncStart(BuildContext context, LiveSync live) async { - final pairs = live.c.settings.liveSyncPairs.where((x) => x.enabled).toList(); - if (pairs.isEmpty) return; - final pick = {for (final x in pairs) LiveSync.keyOf(x)}; - final ok = await showDialog( - context: context, - builder: (ctx) => StatefulBuilder( - builder: (ctx, set) => AlertDialog( - title: Text(tr('실시간 동기화 시작')), - content: SizedBox( - width: 520, - child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(tr('시작할 동기화를 고르세요. 고르지 않은 것은 이번 실행 동안 멈춥니다 (모니터링 > lsync 에서 시작).'), - style: TextStyle(fontSize: 12, color: JjColors.textDim)), - const SizedBox(height: 8), - Flexible( - child: ListView(shrinkWrap: true, children: [ - for (final x in pairs) - CheckboxListTile( - dense: true, - contentPadding: EdgeInsets.zero, - value: pick.contains(LiveSync.keyOf(x)), - onChanged: (v) => set(() => v == true ? pick.add(LiveSync.keyOf(x)) : pick.remove(LiveSync.keyOf(x))), - title: Text('${vDisplay(x.source)} → ${vDisplay(x.target)}', maxLines: 2, overflow: TextOverflow.ellipsis), - subtitle: Text(scheduleSummary(x.schedule)), - ), - ]), - ), - ]), - ), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('모두 나중에'))), - FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('고른 것 시작'))), - ], - ), - ), - ); - if (ok == true) live.runOnly(pairs.where((x) => pick.contains(LiveSync.keyOf(x)))); } diff --git a/test/monitor_modules_test.dart b/test/monitor_modules_test.dart new file mode 100644 index 0000000..3b8e487 --- /dev/null +++ b/test/monitor_modules_test.dart @@ -0,0 +1,106 @@ +// 모니터링 모듈 (lib/ui/monitor/) 의 약속 시험. 실제 AppController (settingsStore 없음 - 사용자 설정 저장소를 쓰지 않음) · +// 실제 시험 폴더 · 실제 위젯으로 확인한다. +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/copy_center.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/monitor/copy_task_card.dart'; +import 'package:jj_mkvmaker/ui/monitor/live_pair_card.dart'; +import 'package:jj_mkvmaker/ui/monitor/live_sync_flow.dart'; +import 'package:jj_mkvmaker/ui/monitor/transfer_widgets.dart'; +import 'package:path/path.dart' as p; + +AppController _controller() => + AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + +void main() { + late Directory tmp; + setUp(() => tmp = Directory.systemTemp.createTempSync('jj_monmod_')); + tearDown(() => tmp.deleteSync(recursive: true)); + + test('transfer_widgets: formatRunStamp 는 날짜면 "M/D HH:MM", 아니면 빈 글', () { + expect(formatRunStamp('2026-10-22T09:05:00'), '10/22 09:05'); + expect(formatRunStamp('x'), ''); + expect(formatRunStamp(''), ''); + }); + + testWidgets('transfer_widgets: PruneChoice 는 고른 값을 돌려주고, allowNone 일 때만 "그대로" 칩', (tester) async { + String? got; + await tester.pumpWidget(MaterialApp(home: Scaffold(body: PruneChoice(value: 'keep', onChanged: (v) => got = v)))); + expect(find.byType(ChoiceChip), findsNWidgets(2)); + await tester.tap(find.text(pruneLabel('all'))); + expect(got, 'all'); + await tester.pumpWidget(MaterialApp(home: Scaffold(body: PruneChoice(value: '', allowNone: true, onChanged: (v) => got = v)))); + expect(find.byType(ChoiceChip), findsNWidgets(3)); + await tester.tap(find.text(pruneLabel(''))); + expect(got, ''); + }); + + testWidgets('transfer_widgets: DiskSpaceInfo 는 없는 경로면 있는 위 폴더의 디스크를 잰다', (tester) async { + await tester.pumpWidget(MaterialApp(home: Scaffold(body: DiskSpaceInfo(path: p.join(tmp.path, 'none', 'deeper'))))); + await tester.runAsync(() => Future.delayed(const Duration(seconds: 2))); + await tester.pump(); + expect(find.textContaining('대상 디스크: 남은'), findsOneWidget); + }); + + test('live_sync_flow: replaceLiveSyncPair 는 같은 쌍만 바꾸고 순서는 그대로', () async { + final c = _controller(); + final a = LiveSyncPair(p.join(tmp.path, 'a'), p.join(tmp.path, 'A')); + final b = LiveSyncPair(p.join(tmp.path, 'b'), p.join(tmp.path, 'B')); + final d = LiveSyncPair(p.join(tmp.path, 'd'), p.join(tmp.path, 'D')); + c.settings.liveSyncPairs = [a, b, d]; + await replaceLiveSyncPair(c, b, b.copyWith(enabled: false)); + expect(c.settings.liveSyncPairs.map((x) => x.source), [a.source, b.source, d.source]); + expect(c.settings.liveSyncPairs.map((x) => x.enabled), [true, false, true]); + }); + + testWidgets('copy_task_card: 작업이 없으면 안내, 있으면 작업마다 카드', (tester) async { + final c = _controller(); + tester.view.physicalSize = const Size(1500, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget(MaterialApp(home: Scaffold(body: CopyTaskList(c: c)))); + expect(find.textContaining('실행한 rsync 가 없습니다'), findsOneWidget); + await CopyCenter.of(c).remember([tmp.path], p.join(tmp.path, '..'), method: 'builtin'); + await tester.pump(); + expect(find.byType(CopyTaskCard), findsOneWidget); + expect(find.text('lsync 로 이동'), findsOneWidget); + }); + + testWidgets('live_pair_card: 화면 일은 부르는 쪽 콜백으로 (최종 정리 · 복사 목록으로)', (tester) async { + final c = _controller(); + final x = LiveSyncPair(tmp.path, p.join(tmp.path, 'x'), method: 'builtin'); + c.settings.liveSyncPairs = [x]; + tester.view.physicalSize = const Size(1500, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + var finalize = 0, shown = 0; + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: ListView(children: [ + LivePairCard(c: c, live: null, pair: x, onFinalize: () => finalize++, onShowCopyList: () => shown++), + ]), + ), + )); + // live 가 없으면 세기 · 맞추기는 꺼짐 + expect(tester.widget(find.ancestor(of: find.text('지금 맞추기'), matching: find.byWidgetPredicate((w) => w is FilledButton))).onPressed, isNull); + await tester.tap(find.text('최종 정리')); + expect(finalize, 1); + // 스위치는 replaceLiveSyncPair 로 저장 + await tester.tap(find.byType(Switch)); + await tester.pump(); + expect(c.settings.liveSyncPairs.single.enabled, isFalse); + // 복사 목록으로 옮기면 lsync 쌍은 빠지고 복사 작업이 생긴 뒤 부르는 쪽 콜백 + await tester.tap(find.text('복사 · rsync 로 이동')); + await tester.pumpAndSettle(); + expect(shown, 1); + expect(c.settings.liveSyncPairs, isEmpty); + expect(c.settings.copyTasks.single.dest, x.target); + }); +}