JJ_MKVMaker/lib/ui/monitor_page.dart

796 lines
36 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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/file_ops.dart';
import '../core/sync_tools.dart';
import '../l10n/tr.dart';
import 'app_actions.dart';
import 'copy_sync_settings.dart' show addLiveSyncPairDialog, LiveSyncRunOptions;
import 'rsync_setup.dart';
import 'schedule_editor.dart';
import 'theme.dart';
/// 모니터링 (파일 탐색기 > 모니터링): 복사 · rsync 와 lsync (실시간 동기화) 를 따로 보여 준다.
class MonitorPage extends StatelessWidget {
final AppController c;
final int initialTab;
const MonitorPage({super.key, required this.c, this.initialTab = 0});
static Future<void> open(BuildContext context, AppController c, {int tab = 0}) => Navigator.of(context)
.push(MaterialPageRoute<void>(builder: (_) => MonitorPage(c: c, initialTab: tab)));
@override
Widget build(BuildContext context) => DefaultTabController(
length: 2,
initialIndex: initialTab,
child: Scaffold(
body: Column(children: [
Container(
height: appBarHeight,
color: JjColors.panel,
padding: const EdgeInsets.only(left: 8, right: appBarRightPadding),
child: Row(children: [
const AppNavButtons(),
const SizedBox(width: 8),
Text(tr('모니터링'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(width: 16),
Expanded(
child: TabBar(
isScrollable: true,
tabAlignment: TabAlignment.start,
tabs: [
Tab(icon: const Icon(Icons.copy_all_outlined, size: 18), text: tr('복사 · rsync')),
Tab(icon: const Icon(Icons.sync, size: 18), text: tr('lsync (실시간 동기화)')),
],
),
),
AppActions(c: c),
]),
),
Expanded(
child: TabBarView(children: [
_CopyTab(c: c),
_LiveTab(c: c),
]),
),
]),
),
);
}
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: const 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: [
const Icon(Icons.storage, size: 14, color: JjColors.textDim),
const SizedBox(width: 4),
Text(trf('대상 디스크: 남은 {0} / 전체 {1}', [formatSize(free), formatSize(total)]), 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 ? Colors.redAccent : 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: const 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.allDone, 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: Text(tr('실행한 rsync 가 없습니다. Rsync 화면에서 → · ← · ⇄ 로 실행하면 여기에 자동으로 등록됩니다.'),
style: const 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<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)),
]);
}
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();
}
Future<void> _run(CopyTask t) async {
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);
await center.start(t, rsyncExe: exe);
if (problem != null && mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problem)));
}
/// 옵션 저장 → 지금 실행할지 묻기 (현재 유지 / 반영 후 실행)
Future<void> _save() async {
final t = widget.task.copyWith(
method: _method,
options: _options.text.trim(),
bandwidthKBps: int.tryParse(_bw.text) ?? 0,
once: _once,
prune: _prune,
);
await center.update(t);
if (!mounted) return;
final running = center.isRunning(t.id);
final go = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(tr('옵션을 저장했습니다')),
content: Text(running
? 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 {
if (center.isRunning(t.id)) {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(tr('lsync 로 이동')),
content: Text(tr('지금 실행 중입니다. 멈추고 lsync (실시간 동기화) 로 옮길까요? 남은 것은 lsync 가 이어서 맞춥니다.')),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('멈추고 옮기기'))),
],
),
);
if (ok != true) return;
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) {
'done' => (Icons.check_circle, Colors.greenAccent, trf('끝 · {0} · 파일 {1}개', [_hm(t.lastRun), t.lastFiles])),
'failed' => (Icons.error, Colors.redAccent, trf('실패 · {0}', [_hm(t.lastRun)])),
'cancelled' => (Icons.pause_circle, Colors.orangeAccent, 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(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(
'${t.sources.length == 1 ? t.sources.first : trf('{0} 외 {1}개', [p.basename(t.sources.first), t.sources.length - 1])}'
'${t.contents ? '/' : ''}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600),
),
Text('→ ${t.dest}', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.w600)),
]),
),
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: Text(t.lastMessage, maxLines: 3, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11, color: Colors.redAccent)),
),
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,
enabled: _method != 'builtin',
style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
decoration: InputDecoration(isDense: true, border: const OutlineInputBorder(), labelText: tr('옵션'), hintText: optsHint),
onChanged: (_) => setState(() {}),
),
),
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),
Row(children: [
const Spacer(),
if (running)
TextButton.icon(onPressed: () => center.cancel(t.id), icon: const Icon(Icons.stop, size: 18), label: Text(tr('취소')))
else
FilledButton.icon(
onPressed: () => _run(t),
icon: const Icon(Icons.play_arrow, size: 18),
label: Text(t.lastResult == 'cancelled' || t.lastResult == 'failed' ? tr('재개') : tr('실행')),
),
const SizedBox(width: 6),
TextButton.icon(
onPressed: () => _toLive(t),
icon: const Icon(Icons.sync_alt, size: 18),
label: Text(tr('lsync 로 이동')),
),
IconButton(
tooltip: tr('목록에서 지우기 (파일은 그대로)'),
icon: const Icon(Icons.delete_outline, color: JjColors.textDim),
onPressed: () => center.remove(t.id),
),
]),
]),
),
);
}
}
// ───────── lsync (실시간 동기화) ─────────
class _LiveTab extends StatefulWidget {
final AppController c;
const _LiveTab({required this.c});
@override
State<_LiveTab> createState() => _LiveTabState();
}
class _LiveTabState extends State<_LiveTab> {
AppController get c => widget.c;
@override
void initState() {
super.initState();
// 열 때 맞출 것을 새로 센다
final live = LiveSync.instance;
if (live != null) {
for (final x in c.settings.liveSyncPairs) {
live.refreshPending(x);
}
}
}
Future<void> _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). 원본 폴더를 남길지 · 지울지만 고른다.
/// 원본이 비므로 이 실시간 동기화는 끈다 (켜 둔 채 "지우기 포함" 이면 대상까지 비워 버린다).
Future<void> _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<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, set) => AlertDialog(
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: const 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: const TextStyle(fontSize: 12, color: Colors.orangeAccent)),
]),
),
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;
}
await _replace(x, x.copyWith(enabled: 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);
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);
}
@override
Widget build(BuildContext context) {
final live = LiveSync.instance;
return ListenableBuilder(
listenable: Listenable.merge([c, ?live]),
builder: (context, _) {
final pairs = c.settings.liveSyncPairs;
return ListView(padding: const EdgeInsets.all(12), children: [
Row(children: [
Expanded(
child: Text(
tr('원본이 바뀌면 대상에 맞춥니다. 일정을 정하면 그 시간에만 맞추고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다.'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim),
),
),
TextButton.icon(onPressed: () => addLiveSyncPairDialog(context, c), icon: const Icon(Icons.add), label: Text(tr('추가'))),
]),
// 백그라운드로 실행 · 다시 켤 때 동기화 (환경 설정과 같은 값) · 모두 시작 / 멈춤
Wrap(spacing: 12, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: [
LiveSyncRunOptions(c: c, dense: true),
if (live != null && pairs.any((x) => x.enabled))
live.paused.isEmpty
? TextButton.icon(
onPressed: live.pauseAll, icon: const Icon(Icons.pause, size: 18), label: Text(tr('모두 멈춤')))
: FilledButton.tonalIcon(
onPressed: live.resumeAll,
icon: const Icon(Icons.play_arrow, size: 18),
label: Text(trf('모두 시작 (멈춘 것 {0}개)', [pairs.where(live.isPaused).length]))),
]),
const SizedBox(height: 8),
if (pairs.isEmpty)
Padding(
padding: const EdgeInsets.all(24),
child: Center(child: Text(tr('실시간 동기화가 없습니다. [추가] 하거나 복사 · rsync 탭에서 [lsync 로 이동] 하세요.'),
style: const TextStyle(color: JjColors.textDim))),
),
for (final x in pairs) _liveCard(context, live, x),
]);
},
);
}
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;
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 : Colors.orangeAccent) : JjColors.textDim),
const SizedBox(width: 8),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('${x.source}/', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.w600)),
Text('→ ${x.target}/', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.w600)),
]),
),
Chip(
visualDensity: VisualDensity.compact,
label: Text([tr(CopyMethod.of(x.method).label), if (x.delete) tr('지우기 포함')].join(' · ')),
),
// 이번 실행 동안 멈춤 / 시작 (설정의 켜기 · 끄기는 오른쪽 스위치)
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 (held)
Padding(
padding: const EdgeInsets.only(left: 32, top: 2),
child: Text(tr('멈춤 (이번 실행 동안) · 시작 버튼을 누르면 다시 동기화합니다'),
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)),
),
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),
Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
Text(scheduleSummary(x.schedule), style: TextStyle(fontSize: 12, color: active ? JjColors.accent : Colors.orangeAccent)),
if (x.schedule.isNotEmpty)
Text(x.schedule.join(' | '), style: const TextStyle(fontSize: 11, fontFamily: 'monospace', color: JjColors.textDim)),
Text(tr('누르면 일정 바꾸기'), style: const 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: const TextStyle(fontSize: 12, color: JjColors.textDim)),
_DiskInfo(path: x.target),
]),
),
// 맞출 것 (바뀐 파일) - 자동으로 다시 셈
Padding(
padding: const EdgeInsets.only(left: 32, top: 8),
child: pend == null
? Text(tr('맞출 것을 세는 중…'), style: const TextStyle(fontSize: 12, color: JjColors.textDim))
: pend.isEmpty
? Text(tr('맞출 것 없음 (원본과 대상이 같습니다)'), style: const TextStyle(fontSize: 12, color: Colors.greenAccent))
: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(
trf('맞출 것 {0}개{1}', [pend.length >= 500 ? '500+' : pend.length, active ? '' : tr(' · 다음 시작 시간에 맞춥니다')]),
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent),
),
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('− ') ? Colors.redAccent : null)),
if (pend.length > 100) Text(trf('… 외 {0}개', [pend.length - 100]), style: const TextStyle(fontSize: 11)),
]),
),
]),
),
const SizedBox(height: 6),
Row(children: [
const Spacer(),
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('지금 맞추기')),
),
const SizedBox(width: 6),
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.delete_outline, color: JjColors.textDim),
onPressed: () => c.updateSettings((y) => y.liveSyncPairs = [
for (final o in y.liveSyncPairs) if (!(o.source == x.source && o.target == x.target)) o,
]),
),
]),
]),
),
);
}
}
/// 앱을 다시 켤 때 ([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: const 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('${x.source} → ${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))));
}