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