From 74987293bd85993dbbf1fd0e315c55964e622109 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:57:18 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=EB=82=98=EB=88=84=EA=B8=B0:=20monitor=5Fpa?= =?UTF-8?q?ge=20(1,226)=20=EB=A5=BC=20=EB=AA=A8=EB=8B=88=ED=84=B0=EB=A7=81?= =?UTF-8?q?=20=EB=AA=A8=EB=93=88=204=EA=B0=9C=EB=A1=9C=20(=E2=86=92=20148)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- lib/ui/monitor/copy_task_card.dart | 425 ++++++++++ lib/ui/monitor/live_pair_card.dart | 359 +++++++++ lib/ui/monitor/live_sync_flow.dart | 283 +++++++ lib/ui/monitor/transfer_widgets.dart | 145 ++++ lib/ui/monitor_page.dart | 1116 +------------------------- test/monitor_modules_test.dart | 106 +++ 6 files changed, 1337 insertions(+), 1097 deletions(-) create mode 100644 lib/ui/monitor/copy_task_card.dart create mode 100644 lib/ui/monitor/live_pair_card.dart create mode 100644 lib/ui/monitor/live_sync_flow.dart create mode 100644 lib/ui/monitor/transfer_widgets.dart create mode 100644 test/monitor_modules_test.dart 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); + }); +} From 95f9925ef0d4d56ff49730802782c744e0e485ec Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:57:20 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=EB=82=98=EB=88=84=EA=B8=B0:=20reader=5Fpag?= =?UTF-8?q?e=20(1,151)=20=EB=A5=BC=20=EB=B3=B4=EA=B8=B0=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EB=AA=A8=EB=93=88=2010=EA=B0=9C=EB=A1=9C=20(?= =?UTF-8?q?=E2=86=92=20504)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - reader/reader_slots: 쪽 나누기 · 슬롯 계산 (순수) - reader/reader_images: 그림 기억 (통째 · 반쪽 · 확대 · 작은 그림, dispose 를 맡음) - reader/reader_controls: 계속 보기 시계 · 확대 · 볼륨 키 · 누른 자리 - reader/reader_page_view · reader_stage: 한 장 보기 · PageView 무대 (키 자리 그대로) - reader/reader_bars · reader_bar_items: 위 · 아래 막대 · 갤러리 줄 - reader/reader_dialogs · reader_pdf_edit · reader_ai: Exif · PDF 편집 · AI - reader_page 는 State 연결과 build 만, ReaderSlot · autoSplitsPage 는 export 로 그대로 - 약속 시험 test/reader_modules_test.dart (6개) 동작 그대로 (문구 · 툴팁 · 키 · 배치, 설정 이름 그대로). analyze 0. Co-Authored-By: Claude Opus 5.5 --- lib/ui/reader/reader_ai.dart | 41 ++ lib/ui/reader/reader_bar_items.dart | 134 ++++ lib/ui/reader/reader_bars.dart | 269 ++++++++ lib/ui/reader/reader_controls.dart | 123 ++++ lib/ui/reader/reader_dialogs.dart | 146 +++++ lib/ui/reader/reader_images.dart | 135 ++++ lib/ui/reader/reader_page_view.dart | 177 ++++++ lib/ui/reader/reader_pdf_edit.dart | 78 +++ lib/ui/reader/reader_slots.dart | 110 ++++ lib/ui/reader/reader_stage.dart | 88 +++ lib/ui/reader_page.dart | 945 +++++----------------------- test/reader_modules_test.dart | 125 ++++ 12 files changed, 1575 insertions(+), 796 deletions(-) create mode 100644 lib/ui/reader/reader_ai.dart create mode 100644 lib/ui/reader/reader_bar_items.dart create mode 100644 lib/ui/reader/reader_bars.dart create mode 100644 lib/ui/reader/reader_controls.dart create mode 100644 lib/ui/reader/reader_dialogs.dart create mode 100644 lib/ui/reader/reader_images.dart create mode 100644 lib/ui/reader/reader_page_view.dart create mode 100644 lib/ui/reader/reader_pdf_edit.dart create mode 100644 lib/ui/reader/reader_slots.dart create mode 100644 lib/ui/reader/reader_stage.dart create mode 100644 test/reader_modules_test.dart diff --git a/lib/ui/reader/reader_ai.dart b/lib/ui/reader/reader_ai.dart new file mode 100644 index 0000000..511ff2c --- /dev/null +++ b/lib/ui/reader/reader_ai.dart @@ -0,0 +1,41 @@ +// 보기 화면 AI 모듈: 이 장 해상도 올리기 (121) · 품질 올리기 · AI 그림 바탕 (123) +// +// 약속 +// - 입력: 화면의 [BuildContext] (닫혔는지 context.mounted 로 본다) · 앱 ([AppController] - 설정 · 서비스) · 그림 원본 · 장 번호 +// - 출력: [upscaleReaderPage] 는 올린 그림으로 바꿔 볼 경로 (설정이 '올린 것으로 보기' 이고 이 기기의 그림 파일일 때만, 아니면 null). +// [readerAiSource] 는 AI 그림의 바탕이 될 이 기기의 그림 경로 (없으면 null) +// - 오류: 파일을 못 꺼내거나 취소하면 null. AI 창 (ai_upscale_ui) 의 오류 처리를 그대로 쓴다 +// 원본 · ZIP 은 그대로 두고 새 파일로 저장한다. 다른 보기 화면도 다시 쓸 수 있다. + +import 'package:flutter/widgets.dart'; + +import '../../app/app_controller.dart'; +import '../../core/reader_sources.dart'; +import '../../core/vfs.dart'; +import '../ai_upscale_ui.dart'; + +/// [page] 장의 해상도를 올려 새 파일로 저장한다. 저장한 뒤 올린 것으로 계속 볼 경로 (설정) - 이 기기의 그림 파일 목록일 때만 +Future upscaleReaderPage(BuildContext context, AppController c, ReaderSource src, int page) async { + final file = await readerPageFile(c, src, page); + if (file == null || !context.mounted) return null; + final saved = await upscaleOne(context, c, input: file.$1, nameFrom: file.$2, outDir: file.$3); + if (saved == null || !context.mounted) return null; + final s = src; + if (c.settings.aiUpAfter == 'upscaled' && s is ImageFilesSource && !isDav(s.paths[page])) return saved; + return null; +} + +/// [page] 장의 품질을 올려 새 파일로 저장한다 +Future qualityReaderPage(BuildContext context, AppController c, ReaderSource src, int page) async { + final file = await readerPageFile(c, src, page); + if (file == null || !context.mounted) return; + await qualityUp(context, c, input: file.$1, nameFrom: file.$2, outDir: file.$3); +} + +/// 지금 보는 그림 파일 (이 기기의 그림 파일일 때, AI 그림 컴포넌트가 켜져 있을 때). [hasPages] 볼 장이 있는지 +String? readerAiSource(AppController c, ReaderSource src, int page, {required bool hasPages}) { + final s = src; + if (!c.settings.components.contains('aiimage') || s is! ImageFilesSource || !hasPages) return null; + final path = s.paths[page]; + return isDav(path) ? null : path; +} diff --git a/lib/ui/reader/reader_bar_items.dart b/lib/ui/reader/reader_bar_items.dart new file mode 100644 index 0000000..305934d --- /dev/null +++ b/lib/ui/reader/reader_bar_items.dart @@ -0,0 +1,134 @@ +// 보기 화면 위 막대의 버튼 목록 모듈 +// +// 약속 +// - 입력: 앱 ([AppController] - 설정을 읽고 바꿈) · 그림 원본 · 지금 상태 ([ReaderBarState]) · 누를 때 부를 일 ([ReaderBarHandlers]) +// - 출력: 위 막대 칸 목록 ([ReaderBarItem]) - 앞의 것일수록 먼저 보이고, 못 들어가면 ⋮ 메뉴로 ([ReaderTopBar]) +// - 오류: 던지지 않는다. 쓸 수 없는 버튼은 onTap 이 null (흐리게) +// 버튼의 순서 · 글 · 아이콘은 이 모듈 한 곳에서 정한다. 다른 보기 화면도 다시 쓸 수 있다. + +import 'package:flutter/material.dart'; + +import '../../app/app_controller.dart'; +import '../../core/reader_sources.dart'; +import '../../l10n/tr.dart'; +import '../ai_image_page.dart'; +import '../ai_upscale_ui.dart'; +import 'reader_bars.dart'; + +/// 버튼 모양을 정하는 지금 상태 +typedef ReaderBarState = ({bool halfShown, bool playing, int turns, bool flipV, String? aiSource}); + +/// 버튼을 누를 때 부를 일 (화면이 준다) +class ReaderBarHandlers { + const ReaderBarHandlers({ + required this.splitOff, + required this.play, + required this.setSeconds, + required this.save, + required this.deletePage, + required this.insertImages, + required this.photoInfo, + required this.toggleGallery, + required this.cycleSplit, + required this.rotate, + required this.toggleFlip, + required this.updateSettings, + required this.applySystemBars, + required this.upscalePage, + required this.qualityPage, + }); + + final VoidCallback splitOff; + final void Function(bool on) play; + final void Function(int d) setSeconds; + final VoidCallback save; + final VoidCallback deletePage; + final VoidCallback insertImages; + final VoidCallback photoInfo; + final VoidCallback toggleGallery; + final VoidCallback cycleSplit; + final void Function(int d) rotate; + final VoidCallback toggleFlip; + + /// 설정을 바꾸고 화면을 다시 그린다 + final Future Function(void Function(dynamic s) f) updateSettings; + final VoidCallback applySystemBars; + final VoidCallback upscalePage; + final VoidCallback qualityPage; +} + +/// 위 막대의 버튼들 (앞의 것일수록 먼저 보이고, 못 들어가면 ⋮ 메뉴로). [context] 는 AI 창을 띄울 화면 +List readerBarItems(BuildContext context, AppController c, ReaderSource src, ReaderBarState st, + ReaderBarHandlers on) { + final s = c.settings; + final pdf = src is PdfSource ? src : null; + final pictures = src is ImageFilesSource || src is ZipImagesSource; + final aiOn = c.settings.components.contains('aiimage'); + final playing = st.playing; + final secs = s.readerAutoSeconds; + ReaderBarItem one(ReaderAct a) => ReaderBarItem(48, [a]); + final split = switch (s.readerSplit) { + 'on' => tr('두 쪽 나눠 보기: 켜기 (누르면 끄기)'), + 'off' => tr('두 쪽 나눠 보기: 끄기 (누르면 자동)'), + _ => tr('두 쪽 나눠 보기: 자동 - 두 쪽 스캔으로 보이는 그림만 (누르면 켜기)'), + }; + return [ + // 223: 나눠 보고 있으면 바로 한 장으로 (자동이 사진을 잘라 보인다고 여기지 않게 맨 앞에) + if (st.halfShown) one(ReaderAct(const Icon(Icons.crop_original), tr('두 쪽 나눔 끄기 (한 장으로 보기)'), on.splitOff)), + // 117: 계속 보기 · 간격 (▲ 늘리기 · ▼ 줄이기) + one(ReaderAct(Icon(playing ? Icons.pause : Icons.play_arrow), + playing ? tr('계속 보기 멈추기') : trf('계속 보기 ({0}초마다 다음 장)', [secs]), () => on.play(!playing), + on: playing)), + ReaderBarItem(112, [ + ReaderAct(const Icon(Icons.keyboard_arrow_up), trf('계속 보기 간격 늘리기 (지금 {0}초)', [secs]), + secs < 60 ? () => on.setSeconds(1) : null), + ReaderAct(const Icon(Icons.keyboard_arrow_down), trf('계속 보기 간격 줄이기 (지금 {0}초)', [secs]), + secs > 1 ? () => on.setSeconds(-1) : null), + ], seconds: secs), + if (pdf != null) ...[ + one(ReaderAct(const Icon(Icons.save_outlined), tr('저장'), pdf.dirty ? on.save : null)), + one(ReaderAct(const Icon(Icons.delete_outline), tr('이 페이지 지우기'), on.deletePage)), + one(ReaderAct(const Icon(Icons.add_photo_alternate_outlined), tr('그림을 페이지로 넣기 (이 페이지 뒤)'), on.insertImages)), + ], + // X-plore 맞추기 (관리자 지시 10/20): 사진 정보 (Exif) - 폴더의 그림만 + if (src is ImageFilesSource) one(ReaderAct(const Icon(Icons.info_outline), tr('사진 정보 (Exif)'), on.photoInfo)), + // X-plore 맞추기 (관리자 지시 10/20): 아래 갤러리 줄 켜고 끄기 (그림 원본만) + if (pictures) + one(ReaderAct(const Icon(Icons.view_carousel_outlined), s.readerGallery ? tr('아래 갤러리 줄 숨기기') : tr('아래 갤러리 줄 보이기'), + on.toggleGallery, on: s.readerGallery)), + // 119 · 120 + one(ReaderAct(const Icon(Icons.vertical_split_outlined), split, on.cycleSplit, on: s.readerSplit != 'off')), + one(ReaderAct(const Icon(Icons.rotate_right), tr('90° 회전 (시계 방향)'), () => on.rotate(1), on: st.turns != 0)), + one(ReaderAct(const Icon(Icons.rotate_left), tr('반대로 90° 회전'), () => on.rotate(3), on: st.turns != 0)), + one(ReaderAct(const RotatedBox(quarterTurns: 1, child: Icon(Icons.flip)), tr('상하 반전'), on.toggleFlip, on: st.flipV)), + one(ReaderAct(const Icon(Icons.fit_screen_outlined), tr('한 쪽 맞추기'), () => on.updateSettings((x) => x.readerFit = 'page'), + on: s.readerFit == 'page')), + one(ReaderAct(const Icon(Icons.width_full_outlined), tr('좌우 맞추기 (세로로 밀어 봄)'), + () => on.updateSettings((x) => x.readerFit = 'width'), on: s.readerFit == 'width')), + one(ReaderAct(const Icon(Icons.swap_horiz), + s.readerRtl ? tr('넘기는 방향: 오른쪽 → 왼쪽 (만화)') : tr('넘기는 방향: 왼쪽 → 오른쪽'), + () => on.updateSettings((x) => x.readerRtl = !x.readerRtl), on: s.readerRtl)), + one(ReaderAct(const Icon(Icons.brightness_low), tr('어둡게'), + () => on.updateSettings((x) => x.readerBrightness = (x.readerBrightness - 0.1).clamp(-0.7, 0.7)))), + one(ReaderAct(const Icon(Icons.brightness_high), tr('밝게'), + () => on.updateSettings((x) => x.readerBrightness = (x.readerBrightness + 0.1).clamp(-0.7, 0.7)))), + // 30: 시계 · 알림 · 뒤로 막대를 그대로 / 화면 가득 + one(ReaderAct(Icon(s.readerSystemBars ? Icons.fullscreen : Icons.fullscreen_exit), + s.readerSystemBars ? tr('화면 가득 (시스템 막대 숨기기)') : tr('시스템 막대 보이기 (시계 · 알림 · 뒤로)'), () async { + await on.updateSettings((x) => x.readerSystemBars = !x.readerSystemBars); + on.applySystemBars(); + }, on: s.readerSystemBars)), + // AI 는 막대가 좁으면 먼저 ⋮ 로 (늘 쓰는 버튼이 막대에 남게) + // 121: AI 해상도 올리기 (이 장 · 모든 장) - 새 파일로 저장, 원본 · ZIP 은 그대로 + if (aiOn && src is! PdfSource) ...[ + one(ReaderAct(const Icon(Icons.hd_outlined), tr('AI 해상도 올리기 (이 장)'), on.upscalePage)), + one(ReaderAct(const Icon(Icons.burst_mode_outlined), tr('AI 해상도 올리기 (모든 장)'), () => upscaleAll(context, c, src))), + // 관리자 판단 (10/17): [품질 올리기] - 설정으로 켰을 때만 + if (c.settings.aiQualityButton) one(ReaderAct(const Icon(Icons.auto_fix_high), tr('품질 올리기 (이 장)'), on.qualityPage)), + ], + // 123: 이 그림을 바탕으로 AI 그림 (그림 → 그림, 원본은 그대로) + if (st.aiSource case final path?) + one(ReaderAct(const Icon(Icons.auto_awesome_outlined), tr('AI 그림으로 (이 그림을 바탕으로)'), + () => AiImagePage.open(Navigator.of(context), c: c, initImage: path))), + ]; +} diff --git a/lib/ui/reader/reader_bars.dart b/lib/ui/reader/reader_bars.dart new file mode 100644 index 0000000..aaf539b --- /dev/null +++ b/lib/ui/reader/reader_bars.dart @@ -0,0 +1,269 @@ +// 보기 화면 막대 모듈: 위 막대 (넘치면 ⋮) · 아래 막대 (쪽 번호 · 밀대 · 쪽 이름) · 갤러리 줄 +// +// 약속 +// - 입력: 버튼 목록 ([ReaderBarItem] - 앞의 것일수록 먼저 보임) · 제목 · 쪽 수 · 지금 쪽 · 넘기는 방향 · 작은 그림 (Future) +// - 출력: 막대 위젯. 누름은 받은 콜백 ([ReaderAct.onTap] · onSlide · onTap) 으로만 알린다 +// - 오류: 던지지 않는다. 작은 그림을 못 읽으면 깨진 그림 아이콘 +// 다른 보기 화면도 다시 쓸 수 있다. + +import 'package:flutter/material.dart'; + +import '../../core/reader_sources.dart'; +import '../../l10n/tr.dart'; +import '../theme.dart'; + +/// 위 · 아래 막대 색 (그림 위 · 검은 여백 위 어디서나 같은 띠로 보이게 거의 불투명) +const readerBarColor = Color(0xF0222428); + +/// 갤러리 줄의 한 칸 너비 +const readerThumbExtent = 64.0; + +/// 위 막대 버튼 하나 (⋮ 메뉴에서는 [icon] 과 [tip] 으로 한 줄) +class ReaderAct { + final Widget icon; + final String tip; + final VoidCallback? onTap; + final bool on; + const ReaderAct(this.icon, this.tip, this.onTap, {this.on = false}); +} + +/// 위 막대의 한 칸 ([width] 만큼 자리를 차지). [seconds] 가 있으면 ▲ · 초 · ▼ 묶음 +class ReaderBarItem { + final double width; + final List acts; + final int? seconds; + const ReaderBarItem(this.width, this.acts, {this.seconds}); +} + +/// [room] 너비에 앞에서부터 들어가는 칸 수. 모두 들어가지 않으면 ⋮ (48) 자리를 빼고 센다 +int fitBarItems(List items, double room) { + if (items.fold(0, (a, x) => a + x.width) <= room) return items.length; + var used = 48.0; // ⋮ + var shown = 0; + while (shown < items.length && used + items[shown].width <= room) { + used += items[shown].width; + shown++; + } + return shown; +} + +/// 막대 위의 버튼 하나 +Widget readerBarButton(ReaderAct a, {bool compact = false}) => IconButton( + tooltip: a.tip, + // 막대는 밝은 화면에서도 어두운 띠 - 강조는 어두운 화면의 청록 (밝은 화면의 진한 청록은 어두운 띠 위에서 안 보임) + color: a.on ? JjPalette.dark.accent : Colors.white, + disabledColor: Colors.white38, + icon: a.icon, + visualDensity: compact ? VisualDensity.compact : null, + constraints: compact ? const BoxConstraints(minWidth: 36, minHeight: 40) : null, + padding: compact ? EdgeInsets.zero : null, + onPressed: a.onTap, + ); + +/// 위 막대: [닫기] · 제목 · 버튼 (오른쪽 끝, 못 들어간 것은 ⋮ 메뉴 - 없애지 않는다) +class ReaderTopBar extends StatelessWidget { + const ReaderTopBar({super.key, required this.title, required this.items}); + + final String title; + + /// 버튼들 (그릴 때마다 부른다) + final List Function() items; + + @override + Widget build(BuildContext context) { + return Container( + // 113: 반투명 검정은 그림 위에서만 보이고 옆의 검은 여백에서는 안 보여 막대가 갈라져 보였다 → 거의 불투명한 어두운 회색 한 줄 + color: readerBarColor, + padding: EdgeInsets.only(top: MediaQuery.paddingOf(context).top), + child: SizedBox( + height: 52, + child: LayoutBuilder(builder: (context, box) { + final items = this.items(); + // 닫기 · 제목 (조금이라도) 을 뺀 자리에 들어가는 만큼 앞에서부터, 나머지는 ⋮ 메뉴 (없애지 않는다) + final shown = fitBarItems(items, box.maxWidth - 48 - 72); + final rest = [for (final x in items.skip(shown)) ...x.acts]; + return Row(children: [ + readerBarButton(ReaderAct(const Icon(Icons.arrow_back), tr('닫기'), () => Navigator.maybePop(context))), + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white, fontSize: 14), + ), + ), + // 버튼은 오른쪽 끝에 붙인다 (113) + for (final x in items.take(shown)) + if (x.seconds != null) + Row(mainAxisSize: MainAxisSize.min, children: [ + readerBarButton(x.acts[0], compact: true), + SizedBox( + width: 40, + child: Text(trf('{0}초', [x.seconds!]), + textAlign: TextAlign.center, style: const TextStyle(color: Colors.white, fontSize: 13)), + ), + readerBarButton(x.acts[1], compact: true), + ]) + else + readerBarButton(x.acts.single), + if (rest.isNotEmpty) + PopupMenuButton( + tooltip: tr('더 보기'), + icon: const Icon(Icons.more_vert, color: Colors.white), + onSelected: (k) => rest[k].onTap?.call(), + itemBuilder: (_) => [ + for (final (k, a) in rest.indexed) + PopupMenuItem( + value: k, + enabled: a.onTap != null, + child: Row(children: [ + IconTheme.merge(data: IconThemeData(color: a.on ? JjColors.accent : null), child: a.icon), + const SizedBox(width: 12), + Flexible(child: Text(a.tip)), + ]), + ), + ], + ), + ]); + }), + ), + ); + } +} + +/// 아래 막대: 쪽 번호 ("2-1 / 3장") · 밀대 · 쪽 이름 (오른쪽 끝). [gallery] 가 있으면 그 위에 +class ReaderBottomBar extends StatelessWidget { + const ReaderBottomBar({ + super.key, + required this.pages, + required this.slots, + required this.index, + required this.label, + required this.pageName, + required this.rtl, + required this.onSlide, + this.gallery, + }); + + /// 원본 장 수 · 보이는 쪽 수 · 지금 쪽 번호 + final int pages; + final int slots; + final int index; + + /// 지금 쪽 이름 ("12" · "12-1") · 파일 이름 + final String label; + final String pageName; + final bool rtl; + final ValueChanged onSlide; + final Widget? gallery; + + @override + Widget build(BuildContext context) { + final n = pages; + final count = slots; + return Container( + color: readerBarColor, + padding: EdgeInsets.only(bottom: MediaQuery.paddingOf(context).bottom, left: 12, right: 12), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + ?gallery, + Row(children: [ + // 125: 반으로 나눈 쪽이 있으면 전체 수가 파일 수 (장) 임을 단위로 ("2-1 / 3장") + Text( + count != n ? trf('{0} / {1}장', [label, n]) : '${n == 0 ? 0 : label} / $n', + style: const TextStyle(color: Colors.white, fontFamily: 'Consolas')), + Expanded( + child: count < 2 + ? const SizedBox(height: 48) + : Directionality( + textDirection: rtl ? TextDirection.rtl : TextDirection.ltr, + child: Slider( + value: index.clamp(0, count - 1).toDouble(), + max: (count - 1).toDouble(), + divisions: count - 1, + onChanged: (v) => onSlide(v.round()), + // ⑦ 어두운 막대 위 - 밝은 화면에서도 어두운 화면의 청록 (null 이면 테마 그대로) + activeColor: JjColors.isLight ? JjPalette.dark.accent : null, + ), + ), + ), + // 113: 쪽 이름은 오른쪽 끝 (넓은 화면에서 가운데로 떨어지지 않게) + ConstrainedBox( + constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.3), + child: Text(pageName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + style: const TextStyle(color: Colors.white70, fontSize: 12)), + ), + ]), + ]), + ); + } +} + +/// 갤러리 줄을 [page] 장이 가운데 오게 할 위치 ([viewport] 보이는 너비, [max] 끝 위치) +double galleryOffset(int page, double viewport, double max) => + (page * readerThumbExtent - viewport / 2 + readerThumbExtent / 2).clamp(0.0, max); + +/// 갤러리 줄 [g] 를 [page] 장이 가운데 오게 ([animate] 면 0.2초 동안). 줄이 화면에 없으면 아무것도 안 함 +void scrollGalleryTo(ScrollController g, int page, {bool animate = true}) { + if (!g.hasClients) return; + final pos = g.position; + final to = galleryOffset(page, pos.viewportDimension, pos.maxScrollExtent); + animate ? g.animateTo(to, duration: const Duration(milliseconds: 200), curve: Curves.easeOut) : g.jumpTo(to); +} + +/// X-plore 맞추기 (관리자 지시 10/20): 아래 갤러리 줄 (칸마다 ValueKey('thumb$i'), 지금 장은 굵은 청록 테두리) +class ReaderGalleryStrip extends StatelessWidget { + const ReaderGalleryStrip({ + super.key, + required this.controller, + required this.count, + required this.current, + required this.rtl, + required this.thumb, + required this.onTap, + }); + + final ScrollController controller; + final int count; + final int current; + final bool rtl; + final Future Function(int page) thumb; + final ValueChanged onTap; + + @override + Widget build(BuildContext context) => SizedBox( + height: 76, + child: ListView.builder( + controller: controller, + scrollDirection: Axis.horizontal, + reverse: rtl, + itemExtent: readerThumbExtent, + itemCount: count, + itemBuilder: (_, i) => GestureDetector( + key: ValueKey('thumb$i'), + onTap: () => onTap(i), + child: Container( + margin: const EdgeInsets.all(3), + decoration: BoxDecoration( + border: Border.all(color: i == current ? JjPalette.dark.accent : Colors.white24, width: i == current ? 2 : 1)), + child: FutureBuilder( + future: thumb(i), + builder: (_, snap) { + final r = snap.data; + if (snap.hasError) return const Icon(Icons.broken_image_outlined, color: Colors.white38, size: 20); + if (r == null) return const SizedBox(); + return r.image != null + ? RawImage(image: r.image, fit: BoxFit.cover) + : Image( + image: r.provider!, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => const Icon(Icons.broken_image_outlined, color: Colors.white38, size: 20)); + }, + ), + ), + ), + ), + ); +} diff --git a/lib/ui/reader/reader_controls.dart b/lib/ui/reader/reader_controls.dart new file mode 100644 index 0000000..f484d57 --- /dev/null +++ b/lib/ui/reader/reader_controls.dart @@ -0,0 +1,123 @@ +// 보기 화면 조작 모듈: 계속 보기 시계 (117) · 확대 상태 (30) · 볼륨 키 · 누른 자리 +// +// 약속 +// - 입력: 간격 (초, 부를 때마다 읽음) · 다 보인 장의 이름 (예: '판:번호') · 손가락 수 변화 · 키 이벤트 · 누른 x 와 화면 너비 +// - 출력: [ReaderSlideshow] 는 간격이 지나면 [ReaderSlideshow.onTick] 을 부른다 (장이 다 보인 뒤부터 잼). +// [ReaderZoom] 은 쪽마다 확대 컨트롤러와 "확대 중 · 두 손가락" 상태. [volumeKeyStep] · [tapZone] 은 넘길 방향 +// - 오류: 던지지 않는다. 화면 갱신 (setState) 은 부른 쪽이 돌려받은 값을 보고 한다 +// 다른 보기 화면 (동영상 사진 슬라이드 등) 도 다시 쓸 수 있다. + +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +/// 계속 보기 (117). 관리자 지시 10/20 (28 검토): 시계는 지금 장이 다 보인 뒤부터 잰다 - +/// WebDAV 처럼 느린 원본에서 받는 동안 시간이 흘러 보지도 못한 장을 넘겼다 +class ReaderSlideshow { + ReaderSlideshow({required this.seconds, required this.onTick}); + + /// 간격 (초) - 시계를 걸 때마다 읽는다 + final int Function() seconds; + + /// 간격이 지났다 (계속 보는 중일 때만) + final void Function() onTick; + + bool _playing = false; + bool get playing => _playing; + Timer? _timer; + + /// 다 보인 장 ('판:번호') + String? shownKey; + + void _start() { + _timer?.cancel(); + _timer = Timer(Duration(seconds: seconds()), _fire); + } + + void _fire() { + _timer = null; + if (_playing) onTick(); + } + + /// [key] 장이 화면에 다 그려졌다 (그림 첫 장면 · 실패 글). 계속 보는 중이면 시계를 시작 + void markShown(String key) { + if (shownKey == key) return; + shownKey = key; + if (_playing && _timer == null) _start(); + } + + /// 켜기 · 끄기 (켤 때 [current] 장이 이미 보였으면 바로 재기 시작, 아니면 보인 뒤에). 켬 · 끔이 바뀌었으면 true + /// ([current] null = 볼 장 없음) + bool set(bool on, String? current) { + _timer?.cancel(); + _timer = null; + if (on && current != null && shownKey == current) _start(); + if (on == _playing) return false; + _playing = on; + return true; + } + + /// 넘긴 뒤 [current] 장이 곧바로 보였는데 (캐시) 알림을 놓쳤으면 시계를 다시 건다 + void rearmIfShown(String current) { + if (_playing && _timer == null && shownKey == current) _start(); + } + + void dispose() => _timer?.cancel(); +} + +/// 확대 (30): 쪽마다 확대 상태 (보는 쪽만 쓰고, 쪽을 넘기면 [reset]) · 두 손가락 +class ReaderZoom { + final _zoom = {}; + + /// [slot] 쪽의 확대 컨트롤러 + TransformationController of(int slot) => _zoom.putIfAbsent(slot, TransformationController.new); + + /// [slot] 쪽의 지금 배율 + double scaleOf(int slot) => of(slot).value.getMaxScaleOnAxis(); + + /// 확대해 보고 있는지 (그동안은 밀어도 장이 넘어가지 않고 그림을 옮긴다) + bool zoomed = false; + + /// 화면에 닿은 손가락 수: 둘이면 (벌리고 오므리기) 옆으로 움직여도 장을 넘기지 않고 확대 · 축소 + int _fingers = 0; + bool get pinching => _fingers >= 2; + + /// 확대 중이거나 두 손가락이면 장을 넘기지 않는다 + bool get holdPage => zoomed || pinching; + + /// 손가락 수 변화 ([d] +1 · -1). 두 손가락 상태가 바뀌었으면 true + bool finger(int d) { + final was = pinching; + _fingers = math.max(0, _fingers + d); + return was != pinching; + } + + /// 모든 쪽의 확대를 원래대로 (컨트롤러를 버림) + void reset() { + for (final z in _zoom.values) { + z.dispose(); + } + _zoom.clear(); + zoomed = false; + } +} + +/// X-plore 맞추기 (관리자 지시 10/20): 볼륨 키로 넘기기. [enabled] (설정 · 기기) 가 아니거나 볼륨 키가 아니면 null (처리 안 함). +/// 볼륨 키면 넘길 방향 (아래 +1 · 위 -1, 키를 뗄 때는 0) - 처리한 키는 시스템 볼륨을 바꾸지 않는다 +int? volumeKeyStep(KeyEvent e, {required bool enabled}) { + if (!enabled) return null; + final k = e.logicalKey; + if (k != LogicalKeyboardKey.audioVolumeDown && k != LogicalKeyboardKey.audioVolumeUp) return null; + if (e is KeyDownEvent || e is KeyRepeatEvent) return k == LogicalKeyboardKey.audioVolumeDown ? 1 : -1; + return 0; +} + +/// 화면을 누른 자리 ([x], 너비 [width]): 오른쪽 1/3 은 다음 (+1), 왼쪽 1/3 은 이전 (-1), 가운데는 0 (막대 보이기 · 숨기기). +/// [rtl] (오른쪽 → 왼쪽으로 넘기기) 이면 좌우 반대 +int tapZone(double x, double width, {required bool rtl}) { + if (x > width * 2 / 3) return rtl ? -1 : 1; + if (x < width / 3) return rtl ? 1 : -1; + return 0; +} diff --git a/lib/ui/reader/reader_dialogs.dart b/lib/ui/reader/reader_dialogs.dart new file mode 100644 index 0000000..249060b --- /dev/null +++ b/lib/ui/reader/reader_dialogs.dart @@ -0,0 +1,146 @@ +// 보기 화면 창 모듈: 사진 정보 (Exif) · PDF 페이지 지우기 확인 · 저장 묻기 · PDF 에 넣을 그림 고르기 +// +// 약속 +// - 입력: 그림 경로 · 임시 폴더 · 페이지 번호 (0 부터) · 앱 ([AppController] - 파일 고르기) +// - 출력: [readPhotoFile] 은 사진 정보 · 파일 크기 · 오류 (셋 중 읽은 것). 창들은 고른 답 (닫으면 null) +// - 오류: [readPhotoFile] 은 던지지 않고 오류를 돌려준다. 창은 던지지 않는다 +// 창은 부른 쪽이 지금 장면이 끝난 뒤 (endOfFrame) 띄운다 (28 지적: 누른 이벤트 안에서 띄우면 의미 트리 assertion). +// 다른 화면도 다시 쓸 수 있다. + +import 'dart:io' show File; + +import 'package:flutter/material.dart'; + +import '../../app/app_controller.dart'; +import '../../core/file_ops.dart' show formatSize; +import '../../core/photo_info.dart'; +import '../../core/reader_sources.dart'; +import '../../core/vfs.dart'; +import '../../l10n/tr.dart'; +import '../theme.dart'; + +/// 지금 장면이 끝난 뒤 (창 띄우기 전 - 28 지적 (10/20): 위 막대 단추를 누른 그 이벤트 안에서 창을 띄우면 (장을 손으로 넘긴 뒤) +/// 창을 닫을 때 디버그 판에서 의미 트리 assertion ('node.built') 이 났다. 부른 쪽은 뒤에 mounted 확인) +Future afterFrame() => WidgetsBinding.instance.endOfFrame; + +/// X-plore 맞추기 (관리자 지시 10/20): 폴더 그림 [src] 의 지금 장 ([page] - 창을 띄울 때 읽음) 사진 정보 창. +/// 지금 장면이 끝난 뒤에 띄운다 (Windows 통합 시험에서 재현 4번 → 0번). 폴더 그림이 아니거나 볼 장이 없으면 아무것도 안 함 +Future showReaderPhotoInfo(BuildContext context, ReaderSource src, int Function() page, {required bool Function() hasPages}) async { + await afterFrame(); + if (!context.mounted) return; + final s = src; + if (s is! ImageFilesSource || !hasPages()) return; + final path = s.paths[page()]; + final r = await readPhotoFile(path, s.tempDir); + if (!context.mounted) return; + await showPhotoInfoDialog(context, path: path, info: r.info, size: r.size, error: r.error); +} + +/// [path] 그림 (WebDAV 면 [tempDir] 에 받아서) 의 사진 정보와 파일 크기. 못 읽으면 [error] +Future<({PhotoInfo? info, int? size, Object? error})> readPhotoFile(String path, String tempDir) async { + PhotoInfo? info; + int? size; + Object? error; + try { + final local = await vLocalCopy(path, tempDir); + final f = File(local); + size = await f.length(); + final bytes = await f.readAsBytes(); + info = readPhotoInfo(bytes); + } catch (e) { + error = e; + } + return (info: info, size: size, error: error); +} + +/// X-plore 맞추기 (관리자 지시 10/20): 사진 정보 (찍은 날 · 기기 · 크기 · 위치). 위치는 [위치 보기] 를 누를 때만 +/// 보이고, 지도 · 인터넷으로 보내지 않는다 (링크 없음) +Future showPhotoInfoDialog(BuildContext context, + {required String path, required PhotoInfo? info, required int? size, required Object? error}) async { + var showLocation = false; + await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, set) { + final i = info; + Widget row(String k, Widget v) => Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + SizedBox(width: 90, child: Text(k, style: TextStyle(color: JjColors.textDim))), + Expanded(child: v), + ]), + ); + return AlertDialog( + scrollable: true, + title: Text(tr('사진 정보 (Exif)')), + content: SizedBox( + width: 420, + child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + row(tr('파일'), SelectableText(vBasename(path))), + if (i == null) + Text(trf('읽을 수 없습니다: {0}', [error])) + else ...[ + row(tr('크기'), Text([ + if (i.width != null) trf('{0} × {1} 픽셀', [i.width!, i.height!]), + if (size != null) formatSize(size), + ].join(' · '))), + row(tr('찍은 날'), Text(i.taken ?? '-')), + row(tr('기기'), Text(i.device ?? '-')), + row( + tr('위치'), + !i.hasLocation + ? const Text('-') + : showLocation + ? SelectableText(i.locationText) + : Align( + alignment: Alignment.centerLeft, + child: TextButton(onPressed: () => set(() => showLocation = true), child: Text(tr('위치 보기'))))), + if (i.hasLocation) + Text(tr('위치는 이 기기에서만 보이며 지도 · 인터넷으로 보내지 않습니다'), + style: TextStyle(fontSize: 12, color: JjColors.textDim)), + if (!i.hasExif) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text(tr('이 그림에는 Exif 정보가 없습니다 (JPEG 사진만 읽음)'), + style: TextStyle(fontSize: 12, color: JjColors.textDim)), + ), + ], + ]), + ), + actions: [TextButton(onPressed: () => Navigator.of(ctx).pop(), child: Text(tr('닫기')))], + ); + }, + ), + ); +} + +/// PDF [page] (0 부터) 페이지를 지울지 묻는다. 지우기 = true +Future confirmPdfPageDelete(BuildContext context, int page) => showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(tr('페이지 지우기')), + content: Text(trf('{0} 페이지를 지울까요? (저장해야 파일에 반영됩니다)', [page + 1])), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('지우기'))), + ], + ), + ); + +/// 저장하지 않은 변경을 어떻게 할지: 'save' · 'discard' · 'cancel' (닫으면 null) +Future askSaveChanges(BuildContext context) => showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(tr('저장하지 않은 변경')), + content: Text(tr('페이지를 바꾼 것을 저장할까요?')), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, 'cancel'), child: Text(tr('취소'))), + TextButton(onPressed: () => Navigator.pop(ctx, 'discard'), child: Text(tr('저장 안 함'))), + FilledButton(onPressed: () => Navigator.pop(ctx, 'save'), child: Text(tr('저장'))), + ], + ), + ); + +/// PDF 에 넣을 그림을 고른다 (설정의 그림 확장자). 고르지 않으면 빈 목록 +Future> pickPdfImages(AppController c) => + c.services.storage.pickFiles(title: tr('PDF 에 넣을 그림 선택'), extensions: c.settings.imageExts); diff --git a/lib/ui/reader/reader_images.dart b/lib/ui/reader/reader_images.dart new file mode 100644 index 0000000..53f8302 --- /dev/null +++ b/lib/ui/reader/reader_images.dart @@ -0,0 +1,135 @@ +// 보기 화면 그림 기억 모듈 +// +// 약속 +// - 입력: 그림 원본 ([ReaderSource]) · 장 번호 · 읽을 너비 (기기 픽셀) +// - 출력: 장마다 한 번만 읽은 그림 ([ReaderImage] 의 Future) - 통째 · 반쪽 (두 배 너비) · 확대해 다시 그린 것 · 작은 그림. +// [imageSize] 는 그림의 원래 크기 (모르면 null) +// - 오류: 읽기 실패는 Future 의 오류로 그대로 돌려준다 (기억에 남아 같은 실패를 다시 보인다 - 지우려면 [forget]). +// 버릴 때 dispose 실패는 삼킨다 +// 이 모듈이 그림을 버리는 (dispose) 유일한 곳이다. 다른 보기 화면도 다시 쓸 수 있다. + +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; + +import '../../core/reader_sources.dart'; + +class ReaderImageCache { + ReaderImageCache(this.source); + + /// 읽을 원본 (폴더 그림 목록처럼 장이 바뀌어도 같은 객체) + final ReaderSource source; + + final _full = >{}; + + /// 반쪽으로 보는 장 (두 배 너비) + final _half = >{}; + + /// 확대한 배율에 맞춰 더 크게 다시 그린 쪽 (쪽 번호 - 흐릿하지 않게) + final _sharp = >{}; + final _sharpWidth = {}; + + /// 아래 갤러리 줄의 작은 그림 (장 번호) + final _thumbs = >{}; + + static void _drop(Future f) => unawaited(f.then((x) => x.dispose(), onError: (_) {})); + + /// [page] 장 통째 ([width] 너비). 처음 읽을 때만 [onLoaded] 를 부른다 (크기 알아 두기 등) + Future full(int page, int width, {void Function(ReaderImage r)? onLoaded}) => _full.putIfAbsent(page, () { + final f = source.load(page, maxWidth: width); + f.then((r) => onLoaded?.call(r), onError: (_) {}); + return f; + }); + + /// [page] 장을 반쪽으로 볼 때 (화면 너비 [width] 의 두 배, 8192 까지) + Future half(int page, int width) => + _half.putIfAbsent(page, () => source.load(page, maxWidth: math.min(width * 2, 8192))); + + /// [slot] 쪽을 확대해 다시 그린 것 (없으면 null) + Future? sharp(int slot) => _sharp[slot]; + + /// 작은 그림 (160 너비) + Future thumb(int page) => _thumbs.putIfAbsent(page, () => source.load(page, maxWidth: 160)); + + /// 확대 배율 [scale] 에 맞는 너비. 더 크게 다시 그릴 만큼이 아니면 null ([base] 쪽 기본 너비) + int? sharpWidthFor(int slot, double scale, int base) { + if (scale < 1.3) return null; + final want = (base * scale).round().clamp(base, 8192); + if (want <= (_sharpWidth[slot] ?? base) * 1.2) return null; + return want; + } + + /// [slot] 쪽 ([page] 장) 을 [width] 너비로 다시 그린다. 앞의 것은 새것을 건 뒤에 버린다 + void sharpen(int slot, int page, int width) { + _sharpWidth[slot] = width; + final old = _sharp[slot]; + _sharp[slot] = source.load(page, maxWidth: width); + if (old != null) _drop(old); + } + + /// 확대해 다시 그린 것을 모두 버린다 + void clearSharp() { + for (final f in _sharp.values) { + _drop(f); + } + _sharp.clear(); + _sharpWidth.clear(); + } + + /// 통째 · 반쪽을 모두 버린다 (장이 바뀌었을 때) + void clearPages() { + for (final f in [..._full.values, ..._half.values]) { + _drop(f); + } + _full.clear(); + _half.clear(); + } + + /// [다시 시도]: [page] 장과 [slot] 쪽의 기억을 잊는다 (버리지 않음 - 아직 화면에 있을 수 있다) + void forget(int page, int slot) { + _full.remove(page); + _half.remove(page); + _sharp.remove(slot); + _sharpWidth.remove(slot); + } + + /// [page] 가까이 (앞뒤 3장) 만 남기고 버린다 (메모리) + void trim(int page) { + for (final m in [_full, _half]) { + for (final k in m.keys.toList()) { + if ((k - page).abs() > 3) _drop(m.remove(k)!); + } + } + } + + /// 모두 버린다 (작은 그림 → 통째 · 반쪽 → 확대한 것 차례) + void dispose() { + for (final f in _thumbs.values) { + _drop(f); + } + clearPages(); + clearSharp(); + } + + /// 그림의 원래 크기 (그려 둔 그림이면 바로, 아니면 한 번 풀어 보고). 못 읽으면 null + static Future imageSize(ReaderImage r) { + final img = r.image; + if (img != null) return Future.value(Size(img.width.toDouble(), img.height.toDouble())); + final provider = r.provider; + if (provider == null) return Future.value(null); + final done = Completer(); + final stream = provider.resolve(ImageConfiguration.empty); + late final ImageStreamListener l; + l = ImageStreamListener((info, _) { + if (!done.isCompleted) done.complete(Size(info.image.width.toDouble(), info.image.height.toDouble())); + info.dispose(); + stream.removeListener(l); + }, onError: (_, _) { + if (!done.isCompleted) done.complete(null); + stream.removeListener(l); + }); + stream.addListener(l); + return done.future; + } +} diff --git a/lib/ui/reader/reader_page_view.dart b/lib/ui/reader/reader_page_view.dart new file mode 100644 index 0000000..ee3347a --- /dev/null +++ b/lib/ui/reader/reader_page_view.dart @@ -0,0 +1,177 @@ +// 보기 화면의 한 쪽 위젯 · 실패 판 +// +// 약속 +// - 입력: 보일 쪽 ([ReaderSlot]) · 읽는 중인 그림 (Future) · 확대 컨트롤러와 상태 · 회전 · 반전 · 넘기는 방향 · 맞추기 ('page' · 'width') · 화면 크기 +// - 출력: 두 손가락 확대 (InteractiveViewer) 안에 그림 (받는 동안 돌이, 실패하면 이유 · [자세히] · [다시 시도]). +// 다 보이면 (그림 첫 장면 · 실패 글) [ReaderPageView.onShown] 을 장면이 끝난 뒤 부른다 +// - 오류: 읽기 실패는 화면 글로 보인다 (던지지 않음). [다시 시도] 는 그림 캐시를 비우고 [ReaderPageView.onRetry] +// 다른 보기 화면도 다시 쓸 수 있다. + +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../core/hwp_source.dart' show HwpPdfException; +import '../../core/reader_sources.dart'; +import '../../l10n/tr.dart'; +import 'reader_slots.dart'; + +class ReaderPageView extends StatelessWidget { + const ReaderPageView({ + super.key, + required this.slot, + required this.imageKey, + required this.image, + required this.zoom, + required this.zoomed, + required this.holdPage, + required this.turns, + required this.flipV, + required this.rtl, + required this.fit, + required this.box, + required this.onShown, + required this.onZoomed, + required this.onZoomEnd, + required this.onRetry, + }); + + final ReaderSlot slot; + + /// 그림 위젯의 키 (판 · 장 · 쪽 · 다시 시도 수 - 바뀌면 새로 그린다) + final String imageKey; + final Future image; + final TransformationController zoom; + final bool zoomed; + final bool holdPage; + final int turns; + final bool flipV; + final bool rtl; + final String fit; + final Size box; + final VoidCallback onShown; + + /// 확대 중인지가 바뀌었다 + final ValueChanged onZoomed; + final VoidCallback onZoomEnd; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + final half = slot.part >= 0; + // 확대해 다시 그린 것이 있으면 그것 (다 그릴 때까지는 앞의 그림을 그대로 보인다) + final page = FutureBuilder( + key: ValueKey(imageKey), + future: image, + builder: (context, snap) { + // 계속 보기: 받는 동안은 시계를 재지 않고, 보인 (또는 실패 글이 보인) 뒤부터 + void shown() => WidgetsBinding.instance.addPostFrameCallback((_) => onShown()); + if (snap.hasError) { + shown(); + return Center(child: ReaderFailurePanel(error: snap.error, onRetry: onRetry)); + } + final r = snap.data; + if (r == null) return const Center(child: CircularProgressIndicator()); + if (r.image != null) shown(); + Widget picture = r.image != null + ? RawImage(image: r.image, filterQuality: FilterQuality.medium) + : Image(image: r.provider!, gaplessPlayback: true, filterQuality: FilterQuality.medium, + frameBuilder: (_, child, frame, sync) { + if (frame != null || sync) shown(); + return child; + }, + errorBuilder: (_, e, _) { + shown(); + return ReaderFailurePanel(error: e, provider: r.provider, onRetry: onRetry); + }); + // 120: 회전 · 상하 반전 (보기만, 파일은 그대로) + if (turns != 0) picture = RotatedBox(quarterTurns: turns, child: picture); + if (flipV) picture = Transform.flip(flipY: true, child: picture); + // 119: 반쪽 - 읽는 방향의 쪽부터 (오른쪽 → 왼쪽이면 오른쪽 반이 먼저) + if (half) { + final right = (slot.part == 0) == rtl; + picture = ClipRect( + child: Align( + alignment: right ? Alignment.centerRight : Alignment.centerLeft, widthFactor: 0.5, child: picture), + ); + } + if (fit == 'width') { + return SingleChildScrollView( + physics: holdPage ? const NeverScrollableScrollPhysics() : null, + child: SizedBox(width: box.width, child: FittedBox(fit: BoxFit.fitWidth, child: picture))); + } + return SizedBox.expand(child: FittedBox(fit: BoxFit.contain, child: picture)); + }, + ); + // 30: 두 손가락으로 확대 · 축소 (확대했을 때만 그림을 끌어 옮긴다) + return InteractiveViewer( + transformationController: zoom, + minScale: 1, + maxScale: 6, + panEnabled: zoomed, + onInteractionUpdate: (_) { + final z = zoom.value.getMaxScaleOnAxis() > 1.01; + if (z != zoomed) onZoomed(z); + }, + onInteractionEnd: (_) => onZoomEnd(), + child: page, + ); + } +} + +/// 실패 판에 보일 글과 [자세히] 원문 (없으면 ''). [provider]: 그림 파일 · ZIP 의 그림을 읽다 (디코드) 실패한 것 - +/// 화면 글은 종류에 맞게, 원문은 [자세히] (28 지적) +({String text, String detail}) readerFailureText(Object? e, {ImageProvider? provider}) { + final text = e is HwpPdfException + ? trf('열 수 없습니다: {0}', [e.message]) + : provider != null + ? tr('그림 파일을 읽을 수 없습니다 - 깨졌거나 지원하지 않는 형식') + : trf('열 수 없습니다: {0}', [e]); + final detail = e is HwpPdfException ? e.detail : (provider != null ? '$e' : ''); + return (text: text, detail: detail); +} + +/// 원문을 고를 수 있는 창 ([닫기]) +Future showReaderErrorDetail(BuildContext context, String detail) => showDialog( + context: context, + builder: (ctx) => AlertDialog( + content: SelectableText(detail, style: const TextStyle(fontSize: 12)), + actions: [TextButton(onPressed: () => Navigator.of(ctx).pop(), child: Text(tr('닫기')))], + ), + ); + +/// 관리자 지시 (10/20, HWP 2단계): 이유와 [다시 시도] - 그 장의 기억을 지우고 다시 부른다 (시간 제한을 넘긴 HWP 묶음 등) +class ReaderFailurePanel extends StatelessWidget { + const ReaderFailurePanel({super.key, required this.error, this.provider, required this.onRetry}); + + final Object? error; + final ImageProvider? provider; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + final (:text, :detail) = readerFailureText(error, provider: provider); + return SizedBox( + width: 320, + height: 160, + child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + Text(text, textAlign: TextAlign.center, style: const TextStyle(color: Colors.white70)), + const SizedBox(height: 8), + // 관리자 지시 (10/20, 28 지적): 변환기 · 그림 디코드 원문은 화면 글 대신 [자세히] 에 + if (detail.isNotEmpty) + TextButton( + onPressed: () => showReaderErrorDetail(context, detail), + child: Text(tr('자세히')), + ), + OutlinedButton( + onPressed: () { + // 그림은 Flutter 의 그림 캐시에 실패가 남아 같은 키로 다시 쓰인다 - 비우고 (ResizeImage 의 키째) 다시 읽는다 + final p = provider; + if (p != null) unawaited(p.evict()); + onRetry(); + }, + child: Text(tr('다시 시도')), + ), + ])); + } +} diff --git a/lib/ui/reader/reader_pdf_edit.dart b/lib/ui/reader/reader_pdf_edit.dart new file mode 100644 index 0000000..d37b371 --- /dev/null +++ b/lib/ui/reader/reader_pdf_edit.dart @@ -0,0 +1,78 @@ +// 보기 화면 PDF 편집 모듈: 페이지 지우기 · 그림을 페이지로 넣기 · 저장 · 닫을 때 저장 묻기 +// +// 약속 +// - 입력: 창을 띄울 [BuildContext] (화면의 것 - 닫혔는지 context.mounted 로 본다) · [PdfSource] · 지금 페이지 (부를 때 읽음) · +// 알림 글을 보일 [snack] +// - 출력: 바뀐 페이지 번호 (다시 그릴 자리) 또는 null (취소 · 화면 닫힘 · 실패). 저장 · 나가기는 bool +// - 오류: 넣기 · 저장 실패는 [snack] 으로 알리고 던지지 않는다. 페이지 지우기 실패는 그대로 던진다 (예전과 같음) +// 창은 지금 장면이 끝난 뒤 (endOfFrame) 띄운다 - 28 지적 (10/20): 위 막대 단추를 누른 그 이벤트 안에서 창을 띄우면 +// (장을 손으로 넘긴 뒤) 창을 닫을 때 디버그 판에서 의미 트리 assertion ('node.built') 이 났다. +// 다른 PDF 화면도 다시 쓸 수 있다. + +import 'package:flutter/widgets.dart'; + +import '../../app/app_controller.dart'; +import '../../core/reader_sources.dart'; +import '../../core/vfs.dart'; +import '../../l10n/tr.dart'; +import 'reader_dialogs.dart'; + +/// 지금 페이지를 묻고 지운다. 지운 페이지 번호 (취소 · 한 페이지뿐 · 화면 닫힘이면 null) +Future deletePdfPage(BuildContext context, PdfSource pdf, int Function() currentPage, void Function(String) snack) async { + await afterFrame(); + if (!context.mounted) return null; + if (pdf.length <= 1) { + snack(tr('마지막 한 페이지는 지울 수 없습니다.')); + return null; + } + final page = currentPage(); + final ok = await confirmPdfPageDelete(context, page); + if (ok != true) return null; + await pdf.deletePage(page); + if (!context.mounted) return null; + return page; +} + +/// 그림을 골라 지금 페이지 뒤에 넣는다. 넣은 자리 (페이지) 와 장 수 (고르지 않음 · 실패 · 화면 닫힘이면 null) +Future<({int page, int count})?> insertPdfImages( + BuildContext context, AppController c, PdfSource pdf, int Function() currentPage, void Function(String) snack) async { + await afterFrame(); + if (!context.mounted) return null; + final page = currentPage(); + final files = await pickPdfImages(c); + if (files.isEmpty || !context.mounted) return null; + final temp = await c.services.storage.tempDirectory(); + try { + await pdf.insertImages(files, after: page, tempDir: temp); + } catch (e) { + snack(trf('넣지 못했습니다: {0}', [e])); + return null; + } + return (page: page, count: files.length); +} + +/// 넣은 뒤 알림 글 +String insertedPdfImagesText(({int page, int count}) r) => + trf('그림 {0}장을 {1} 페이지 뒤에 넣었습니다 (저장해야 파일에 반영됩니다).', [r.count, r.page + 1]); + +/// 저장한다 (알림 글은 [snack]). 저장했으면 true +Future savePdf(PdfSource pdf, void Function(String) snack) async { + try { + final path = await pdf.save(); + snack(trf('저장했습니다: {0}', [vDisplay(path)])); + return true; + } catch (e) { + snack(trf('저장하지 못했습니다: {0}', [e])); + return false; + } +} + +/// 닫아도 되는지 (저장하지 않은 변경이 있으면 묻고, [저장] 이면 [save] 를 부른다). [pdf] 가 없거나 바뀐 것이 없으면 true +Future confirmPdfLeave(BuildContext context, PdfSource? pdf, Future Function() save) async { + if (pdf == null || !pdf.dirty) return true; + await afterFrame(); + if (!context.mounted) return false; + final r = await askSaveChanges(context); + if (r == 'save') await save(); + return r == 'save' || r == 'discard'; +} diff --git a/lib/ui/reader/reader_slots.dart b/lib/ui/reader/reader_slots.dart new file mode 100644 index 0000000..915c750 --- /dev/null +++ b/lib/ui/reader/reader_slots.dart @@ -0,0 +1,110 @@ +// 보기 화면 쪽 계산 모듈 (순수 계산 - 위젯 · 상태 없음) +// +// 약속 +// - 입력: 원본 장 수 · 장마다 나눌지 판단 · 보이는 쪽 목록 · 그림 크기 · 회전 수 · 밝기 값 +// - 출력: 보이는 쪽 목록 ([ReaderSlot]) · 옮길 자리 번호 · 지운 뒤 남은 장 번호 · 쪽 이름 · 밝기 필터 +// ([removeMissingPaths] 만 받은 목록에서 없어진 경로를 뺀다) +// - 오류: 던지지 않는다. 찾을 수 없으면 -1 (또는 범위 안으로 맞춘 번호) 을 돌려준다 +// 다른 보기 화면도 그대로 다시 쓸 수 있다. + +import 'dart:math' as math; + +import 'package:flutter/painting.dart'; + +/// 보이는 한 쪽: 원본 [page] 장의 통째 (part -1) 또는 읽는 순서로 첫째 (0) · 둘째 (1) 반쪽 (119) +typedef ReaderSlot = ({int page, int part}); + +/// 223: "두 쪽 나눠 보기: 자동" 이 이 장 ([size], 회전 반영) 을 나눌지 - 두 쪽 스캔일 가능성이 높을 때만. +/// [bundle] (ZIP · CBZ · PDF 로 연 묶음): 가로로 긴 그림은 나눈다 (만화 두 쪽 스캔이 흔함). +/// 폴더의 그림들: 같은 폴더의 다른 그림 ([others], 크기를 아는 것) 대부분 (3/4 이상, 2장 이상) 이 세로인데 이것만 가로일 때만 - +/// 평범한 가로 사진은 나누지 않는다 (예전에는 모두 반으로 잘라 "그림이 잘렸다" 고 보였다) +bool autoSplitsPage(Size size, List others, {required bool bundle}) { + if (size.width <= size.height) return false; + if (bundle) return true; + final tall = others.where((s) => s.height > s.width).length; + return others.length >= 2 && tall * 4 >= others.length * 3; +} + +/// [turns] (시계 방향 90° 회전 수) 를 반영한 크기 +Size turnedSize(Size s, int turns) => turns.isOdd ? Size(s.height, s.width) : s; + +/// 이 장 ([page]) 을 반씩 나눠 볼지. [mode] 는 설정 값 ('on' · 'off' · 'auto'), [dims] 는 크기를 아는 장들. +/// 자동은 크기를 알 때만 [autoSplitsPage] 로 판단한다 +bool splitsPage(String mode, int page, Map dims, {required int turns, required bool bundle}) { + switch (mode) { + case 'on': + return true; + case 'off': + return false; + } + final d = dims[page]; + if (d == null) return false; + return autoSplitsPage(turnedSize(d, turns), [for (final e in dims.entries) if (e.key != page) turnedSize(e.value, turns)], + bundle: bundle); +} + +/// 원본 [length] 장으로 보이는 쪽 목록을 만든다 ([splits] 가 참인 장은 반쪽 둘) +List buildSlots(int length, bool Function(int page) splits) => [ + for (var i = 0; i < length; i++) + if (splits(i)) ...[(page: i, part: 0), (page: i, part: 1)] else (page: i, part: -1), + ]; + +/// [v] 에서 [index] 번 쪽 (범위 밖이면 끝으로 맞춤). 비었으면 첫 장 통째 +ReaderSlot slotAt(List v, int index) => v.isEmpty ? (page: 0, part: -1) : v[index.clamp(0, v.length - 1)]; + +/// [page] 장의 첫 쪽 번호 (없으면 -1) +int indexOfPage(List v, int page) => v.indexWhere((e) => e.page == page); + +/// 쪽 목록을 새로 만든 뒤 보던 쪽 [cur] 의 새 자리. 같은 반쪽 → 같은 장의 다른 모양 (통째 ↔ 첫 반쪽) → +/// 옛 번호 [index] (범위 안으로) 차례로 찾는다 +int relocateSlot(List v, ReaderSlot cur, int index) { + int find(int part) => v.indexWhere((e) => e.page == cur.page && e.part == part); + var to = find(cur.part); + if (to < 0) to = find(cur.part < 0 ? 0 : -1); + if (to < 0) to = index.clamp(0, math.max(0, v.length - 1)); + return to; +} + +/// 224: [count] 장 중 [gone] 을 뺀 뒤 보던 장 [curPage] (없어졌으면 그 뒤, 뒤에 없으면 앞의 남은 장) 의 새 번호. +/// 남은 장이 없으면 -1 +int pageAfterRemoval(int curPage, Set gone, int count) { + var keep = curPage; + while (keep < count && gone.contains(keep)) { + keep++; + } + if (keep >= count) { + keep = curPage; + while (keep >= 0 && gone.contains(keep)) { + keep--; + } + } + return keep < 0 ? -1 : keep - gone.where((g) => g < keep).length; +} + +/// 224: [paths] 에서 없어진 것 ([missing]) 을 뺀다 (목록을 바꿈). 없어진 것이 없으면 null, 있으면 보던 장 [curPage] 의 +/// 새 번호 ([pageAfterRemoval]) 와 보던 장도 없어졌는지 +({int newPage, bool curGone})? removeMissingPaths(List paths, int curPage, bool Function(String path) missing) { + final gone = { + for (var i = 0; i < paths.length; i++) + if (missing(paths[i])) i, + }; + if (gone.isEmpty) return null; + final newPage = pageAfterRemoval(curPage, gone, paths.length); + for (final g in gone.toList()..sort((a, b) => b - a)) { + paths.removeAt(g); + } + return (newPage: newPage, curGone: gone.contains(curPage)); +} + +/// 쪽 번호: 통째면 "12", 반쪽이면 "12-1" · "12-2" +String slotLabel(ReaderSlot s) => s.part < 0 ? '${s.page + 1}' : '${s.page + 1}-${s.part + 1}'; + +/// 반쪽은 두 배 너비로 읽는다 (반쪽이 화면 가득이어도 원본 해상도로). [width] 화면 너비 (기기 픽셀) +int slotBaseWidth(ReaderSlot s, int width) => s.part >= 0 ? math.min(width * 2, 8192) : width; + +/// 어둡게 · 밝게 ([b] -0.7 ~ 0.7): 음수는 색을 줄이고 양수는 더한다 +ColorFilter brightnessFilter(double b) { + final double s = 1 + math.min(b, 0.0); + final double o = math.max(b, 0.0) * 160; + return ColorFilter.matrix([s, 0, 0, 0, o, 0, s, 0, 0, o, 0, 0, s, 0, o, 0, 0, 0, 1, 0]); +} diff --git a/lib/ui/reader/reader_stage.dart b/lib/ui/reader/reader_stage.dart new file mode 100644 index 0000000..305e482 --- /dev/null +++ b/lib/ui/reader/reader_stage.dart @@ -0,0 +1,88 @@ +// 보기 화면 무대 위젯: 장 넘기기 (PageView) · 누름과 손가락 알림 · 밝기 · 위 · 아래 막대 자리 +// +// 약속 +// - 입력: 쪽 수 · 판 번호 (바뀌면 PageView 를 새로) · 장 컨트롤러 · 넘기는 방향 · 장을 붙잡을지 (확대 중) · 밝기 · +// 쪽 위젯을 만드는 [ReaderStage.page] · 막대 (보일 때만) +// - 출력: 검은 바탕에 그림 장들. 누름 · 손가락 · 넘김은 받은 콜백으로만 알린다. 그릴 때마다 화면 너비 (기기 픽셀, +// 320 ~ 4096) 를 [ReaderStage.onWidth] 로 알린다 (그림을 그 너비로 줄여 읽게) +// - 오류: 던지지 않는다. 쪽이 없으면 "볼 그림이 없습니다." +// 다른 보기 화면도 다시 쓸 수 있다. + +import 'package:flutter/material.dart'; + +import '../../l10n/tr.dart'; +import 'reader_slots.dart'; + +class ReaderStage extends StatelessWidget { + const ReaderStage({ + super.key, + required this.version, + required this.count, + required this.controller, + required this.rtl, + required this.holdPage, + required this.brightness, + required this.onWidth, + required this.onPointerDown, + required this.onPointerUp, + required this.onTapUp, + required this.onPageChanged, + required this.page, + this.topBar, + this.bottomBar, + }); + + final int version; + final int count; + final PageController controller; + final bool rtl; + final bool holdPage; + final double brightness; + final ValueChanged onWidth; + final VoidCallback onPointerDown; + + /// 손가락을 뗐다 (또는 취소) + final VoidCallback onPointerUp; + final void Function(TapUpDetails d, double width) onTapUp; + final ValueChanged onPageChanged; + final Widget Function(int i, Size box) page; + + /// 위 · 아래 막대 (null 이면 숨김) + final Widget? topBar; + final Widget? bottomBar; + + @override + Widget build(BuildContext context) => LayoutBuilder(builder: (context, box) { + // 화면 너비 (기기 픽셀) 에 맞춰 그림을 줄여 읽는다 + onWidth((box.maxWidth * MediaQuery.devicePixelRatioOf(context)).round().clamp(320, 4096)); + return Stack(children: [ + Positioned.fill( + child: Listener( + onPointerDown: (_) => onPointerDown(), + onPointerUp: (_) => onPointerUp(), + onPointerCancel: (_) => onPointerUp(), + child: GestureDetector( + onTapUp: (d) => onTapUp(d, box.maxWidth), + child: ColorFiltered( + colorFilter: brightnessFilter(brightness), + child: count == 0 + ? Center(child: Text(tr('볼 그림이 없습니다.'), style: const TextStyle(color: Colors.white70))) + : PageView.builder( + key: ValueKey(version), + controller: controller, + reverse: rtl, + // 확대해 보는 동안은 밀어도 넘기지 않는다 (그림 옮기기) + physics: holdPage ? const NeverScrollableScrollPhysics() : null, + itemCount: count, + onPageChanged: onPageChanged, + itemBuilder: (_, i) => page(i, box.biggest), + ), + ), + ), + ), + ), + if (topBar != null) Positioned(left: 0, right: 0, top: 0, child: topBar!), + if (bottomBar != null) Positioned(left: 0, right: 0, bottom: 0, child: bottomBar!), + ]); + }); +} diff --git a/lib/ui/reader_page.dart b/lib/ui/reader_page.dart index 4822304..88c9c47 100644 --- a/lib/ui/reader_page.dart +++ b/lib/ui/reader_page.dart @@ -1,5 +1,5 @@ import 'dart:async'; -import 'dart:io' show File, Platform; +import 'dart:io' show Platform; import 'dart:math' as math; import 'package:flutter/material.dart'; @@ -8,14 +8,21 @@ import 'package:wakelock_plus/wakelock_plus.dart'; import '../app/app_controller.dart'; import '../core/reader_sources.dart'; -import '../core/hwp_source.dart' show HwpPdfException, HwpSource; -import '../core/file_ops.dart' show formatSize; -import '../core/photo_info.dart'; +import '../core/hwp_source.dart' show HwpSource; import '../core/vfs.dart'; import '../l10n/tr.dart'; -import 'ai_image_page.dart'; -import 'ai_upscale_ui.dart'; -import 'theme.dart'; +import 'reader/reader_ai.dart'; +import 'reader/reader_bar_items.dart'; +import 'reader/reader_bars.dart'; +import 'reader/reader_controls.dart'; +import 'reader/reader_dialogs.dart'; +import 'reader/reader_images.dart'; +import 'reader/reader_page_view.dart'; +import 'reader/reader_pdf_edit.dart'; +import 'reader/reader_slots.dart'; +import 'reader/reader_stage.dart'; + +export 'reader/reader_slots.dart' show ReaderSlot, autoSplitsPage; /// 그림 · 만화 · PDF 보기를 연다. [start] 처음 볼 장. 닫으면 [source] 를 정리한다. Future openReader(BuildContext context, AppController c, ReaderSource source, {int start = 0, bool autoPlay = false}) async { @@ -25,25 +32,14 @@ Future openReader(BuildContext context, AppController c, ReaderSource sour await source.dispose(); } -/// 보이는 한 쪽: 원본 [page] 장의 통째 (part -1) 또는 읽는 순서로 첫째 (0) · 둘째 (1) 반쪽 (119) -typedef ReaderSlot = ({int page, int part}); - /// 그림 · 만화 · PDF 보기. /// - 화면 오른쪽 (1/3) 을 누르면 다음 장, 왼쪽을 누르면 이전 장, 가운데를 누르면 위 · 아래 막대 보이기 · 숨기기 /// (오른쪽에서 왼쪽으로 넘기기를 켜면 반대). 좌우로 밀어도 넘어간다. /// - 위 막대: 계속 보기 (117) · 두 쪽 나누기 (119) · 회전 · 상하 반전 (120) · 한 쪽 맞추기 · 좌우 맞추기, 넘기는 방향, /// 어둡게 · 밝게, (PDF) 페이지 지우기 · 그림 넣기 · 저장. 좁으면 못 들어간 버튼은 ⋮ 메뉴로. -/// 223: "두 쪽 나눠 보기: 자동" 이 이 장 ([size], 회전 반영) 을 나눌지 - 두 쪽 스캔일 가능성이 높을 때만. -/// [bundle] (ZIP · CBZ · PDF 로 연 묶음): 가로로 긴 그림은 나눈다 (만화 두 쪽 스캔이 흔함). -/// 폴더의 그림들: 같은 폴더의 다른 그림 ([others], 크기를 아는 것) 대부분 (3/4 이상, 2장 이상) 이 세로인데 이것만 가로일 때만 - -/// 평범한 가로 사진은 나누지 않는다 (예전에는 모두 반으로 잘라 "그림이 잘렸다" 고 보였다) -bool autoSplitsPage(Size size, List others, {required bool bundle}) { - if (size.width <= size.height) return false; - if (bundle) return true; - final tall = others.where((s) => s.height > s.width).length; - return others.length >= 2 && tall * 4 >= others.length * 3; -} - +/// 이 화면은 모듈 (lib/ui/reader/) 을 부르기만 한다: 쪽 계산 (reader_slots) · 그림 기억 (reader_images) · +/// 계속 보기 · 확대 · 키 (reader_controls) · 무대 (reader_stage) · 한 쪽 (reader_page_view) · 막대 (reader_bars · reader_bar_items) · +/// 창 (reader_dialogs) · PDF 편집 (reader_pdf_edit) · AI (reader_ai) class ReaderPage extends StatefulWidget { final AppController c; final ReaderSource source; @@ -71,7 +67,7 @@ class _ReaderPageState extends State { /// 보는 쪽 ([_v] 의 번호) late int _index; bool _bars = true; - final _cache = >{}; + late final _images = ReaderImageCache(src); int _width = 0; /// PDF 는 페이지를 지우거나 넣으면 다시 그린다 @@ -79,9 +75,7 @@ class _ReaderPageState extends State { /// X-plore 맞추기 (관리자 지시 10/20): 아래 갤러리 줄 - 그림 원본 (폴더 · ZIP) 에서만 bool get _pictures => src is ImageFilesSource || src is ZipImagesSource; - final _thumbs = >{}; final _gallery = ScrollController(); - static const _thumbExtent = 64.0; /// [다시 시도] 를 누른 횟수 (장마다) - 그 장의 화면을 새로 만든다 (28 지적: 같은 키면 실패한 그림을 그대로 다시 썼다) final _retry = {}; @@ -90,7 +84,7 @@ class _ReaderPageState extends State { void initState() { super.initState(); _rebuildPages(); - final i = _v.indexWhere((e) => e.page == widget.start); + final i = indexOfPage(_v, widget.start); _index = i < 0 ? 0 : i; _pages = PageController(initialPage: _index); _applySystemBars(); @@ -106,34 +100,11 @@ class _ReaderPageState extends State { @visibleForTesting Future dropMissingFiles() async { final s = src; - if (s is! ImageFilesSource) return; - final gone = { - for (var i = 0; i < s.paths.length; i++) - if (vMissingSync(s.paths[i])) i, - }; - if (gone.isEmpty || !mounted) return; - final curPage = _cur.page; - final curGone = gone.contains(curPage); + if (s is! ImageFilesSource || !mounted) return; // 보던 그림 (없어졌으면 그 뒤의 남은 그림) 의 새 번호 - var keep = curPage; - while (keep < s.paths.length && gone.contains(keep)) { - keep++; - } - if (keep >= s.paths.length) { - keep = curPage; - while (keep >= 0 && gone.contains(keep)) { - keep--; - } - } - final newPage = keep < 0 ? -1 : keep - gone.where((g) => g < keep).length; - for (final g in gone.toList()..sort((a, b) => b - a)) { - s.paths.removeAt(g); - } - for (final f in [..._cache.values, ..._cacheHalf.values]) { - unawaited(f.then((x) => x.dispose(), onError: (_) {})); - } - _cache.clear(); - _cacheHalf.clear(); + final r = removeMissingPaths(s.paths, _cur.page, vMissingSync); + if (r == null) return; + _images.clearPages(); _dims.clear(); if (s.paths.isEmpty) { _snack(tr('파일이 없어졌습니다')); @@ -143,11 +114,11 @@ class _ReaderPageState extends State { setState(() { _v = const []; _rebuildPages(); - final i = _v.indexWhere((e) => e.page == newPage); + final i = indexOfPage(_v, r.newPage); _index = i < 0 ? 0 : i; }); _jumpLater(); - if (curGone) _snack(tr('파일이 없어졌습니다')); + if (r.curGone) _snack(tr('파일이 없어졌습니다')); } /// 보기 화면은 위 · 아래 시스템 막대까지 그림으로 (30: 설정으로 막대를 그대로 둘 수 있다) @@ -158,7 +129,7 @@ class _ReaderPageState extends State { /// 보이는 쪽들 List _v = const []; - ReaderSlot get _cur => _v.isEmpty ? (page: 0, part: -1) : _v[_index.clamp(0, _v.length - 1)]; + ReaderSlot get _cur => slotAt(_v, _index); /// 원본 그림의 크기 (읽어 보고 안 것) final _dims = {}; @@ -168,20 +139,10 @@ class _ReaderPageState extends State { bool _flipV = false; /// 이 장을 반씩 나눠 볼지. 자동은 두 쪽 스캔일 가능성이 높을 때만 ([autoSplitsPage]) - bool _splits(int page) { - switch (c.settings.readerSplit) { - case 'on': - return true; - case 'off': - return false; - } - final d = _dims[page]; - if (d == null) return false; - Size turned(Size s) => _turns.isOdd ? Size(s.height, s.width) : s; - return autoSplitsPage(turned(d), [for (final e in _dims.entries) if (e.key != page) turned(e.value)], - // 관리자 지시 (10/20) · 내 판단: HWP · HWPX 문서의 가로 쪽 (가로 표 등) 은 만화 두 쪽 스캔이 아니라 나누지 않는다 - bundle: src is! ImageFilesSource && src is! HwpSource); - } + bool _splits(int page) => splitsPage(c.settings.readerSplit, page, _dims, + turns: _turns, + // 관리자 지시 (10/20) · 내 판단: HWP · HWPX 문서의 가로 쪽 (가로 표 등) 은 만화 두 쪽 스캔이 아니라 나누지 않는다 + bundle: src is! ImageFilesSource && src is! HwpSource); /// 223: 나눠 본 장에서 바로 한 장으로 (설정을 "끄기" 로) Future _splitOff() async { @@ -196,15 +157,9 @@ class _ReaderPageState extends State { /// 보이는 쪽 목록을 다시 만든다. 보던 장은 그대로 보이게 (쪽 번호가 밀리면 그 자리로 옮긴다) void _rebuildPages() { final cur = _v.isEmpty ? null : _cur; - _v = [ - for (var i = 0; i < src.length; i++) - if (_splits(i)) ...[(page: i, part: 0), (page: i, part: 1)] else (page: i, part: -1), - ]; + _v = buildSlots(src.length, _splits); if (cur == null) return; - int find(int part) => _v.indexWhere((e) => e.page == cur.page && e.part == part); - var to = find(cur.part); - if (to < 0) to = find(cur.part < 0 ? 0 : -1); - if (to < 0) to = _index.clamp(0, math.max(0, _v.length - 1)); + final to = relocateSlot(_v, cur, _index); if (to != _index) { _index = to; _jumpLater(); @@ -219,33 +174,13 @@ class _ReaderPageState extends State { void _learn(int page, ReaderImage r) { if (_dims.containsKey(page)) return; final v = _version; - unawaited(_sizeOf(r).then((size) { + unawaited(ReaderImageCache.imageSize(r).then((size) { if (!mounted || size == null || v != _version || _dims.containsKey(page)) return; _dims[page] = size; if (c.settings.readerSplit == 'auto') setState(_rebuildPages); })); } - static Future _sizeOf(ReaderImage r) { - final img = r.image; - if (img != null) return Future.value(Size(img.width.toDouble(), img.height.toDouble())); - final provider = r.provider; - if (provider == null) return Future.value(null); - final done = Completer(); - final stream = provider.resolve(ImageConfiguration.empty); - late final ImageStreamListener l; - l = ImageStreamListener((info, _) { - if (!done.isCompleted) done.complete(Size(info.image.width.toDouble(), info.image.height.toDouble())); - info.dispose(); - stream.removeListener(l); - }, onError: (_, _) { - if (!done.isCompleted) done.complete(null); - stream.removeListener(l); - }); - stream.addListener(l); - return done.future; - } - void _rotate(int d) => setState(() { _turns = (_turns + d) % 4; _resetZoom(); @@ -263,43 +198,30 @@ class _ReaderPageState extends State { // ───────── 계속 보기 (117) ───────── - bool _playing = false; - Timer? _timer; + late final _show = ReaderSlideshow(seconds: () => c.settings.readerAutoSeconds, onTick: _tick); + bool get _playing => _show.playing; /// 계속 보기를 멈추게 한 누름은 장 넘기기 · 막대 숨기기로 쓰지 않는다 bool _swallowTap = false; - /// 관리자 지시 10/20 (28 검토): 다 보인 장 ('$_version:$_index'). 계속 보기 시계는 그 장이 보인 뒤부터 잰다 - - /// WebDAV 처럼 느린 원본에서 받는 동안 시간이 흘러 보지도 못한 장을 넘겼다 - String? _shownKey; + /// 다 보인 장의 이름 (계속 보기 시계는 그 장이 보인 뒤부터 잰다) + String get _curKey => '$_version:$_index'; /// [i] 장이 화면에 다 그려졌다 (그림 첫 장면 · 실패 글). 지금 장이면 계속 보기 시계를 시작 void _markShown(int i) { if (!mounted || i != _index) return; - final key = '$_version:$i'; - if (_shownKey == key) return; - _shownKey = key; - if (_playing && _timer == null) _startTimer(); - } - - void _startTimer() { - _timer?.cancel(); - _timer = Timer(Duration(seconds: c.settings.readerAutoSeconds), _tick); + _show.markShown('$_version:$i'); } void _play(bool on) { - _timer?.cancel(); - _timer = null; // 지금 장이 이미 보이면 바로 재기 시작, 아직 받는 중이면 보인 뒤에 ([_markShown]) - if (on && _v.isNotEmpty && _shownKey == '$_version:$_index') _startTimer(); - if (on == _playing) return; - setState(() => _playing = on); + if (!_show.set(on, _v.isNotEmpty ? _curKey : null)) return; + setState(() {}); unawaited(ReaderPage.keepScreenOn(on).catchError((_) {})); } void _tick() { - _timer = null; - if (!mounted || !_playing) return; + if (!mounted) return; if (_index >= _v.length - 1) { _play(false); _snack(tr('마지막 장이라 계속 보기를 멈췄습니다.')); @@ -307,12 +229,9 @@ class _ReaderPageState extends State { } _pages.jumpToPage(_index + 1); // 다음 장이 이미 받아 둔 것이라 다시 그리지 않으면 그 자리에서 알린다 - WidgetsBinding.instance.addPostFrameCallback((_) => _rearmIfShown()); - } - - /// 넘긴 뒤 그 장이 곧바로 보였는데 (캐시) 알림을 놓쳤으면 시계를 다시 건다 - void _rearmIfShown() { - if (mounted && _playing && _timer == null && _shownKey == '$_version:$_index') _startTimer(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _show.rearmIfShown(_curKey); + }); } Future _setSeconds(int d) async { @@ -324,102 +243,53 @@ class _ReaderPageState extends State { // ───────── 확대 (30) ───────── - /// 쪽마다 확대 상태 (보는 쪽만 쓰고, 쪽을 넘기면 원래대로) - final _zoom = {}; - TransformationController _zoomOf(int i) => _zoom.putIfAbsent(i, TransformationController.new); + final _zoom = ReaderZoom(); - /// 확대해 보고 있는지 (그동안은 밀어도 장이 넘어가지 않고 그림을 옮긴다) - bool _zoomed = false; - - /// 화면에 닿은 손가락 수: 둘이면 (벌리고 오므리기) 옆으로 움직여도 장을 넘기지 않고 확대 · 축소 - int _fingers = 0; - bool get _pinching => _fingers >= 2; void _finger(int d) { - final was = _pinching; - _fingers = math.max(0, _fingers + d); - if (was != _pinching) setState(() {}); + if (_zoom.finger(d)) setState(() {}); } - bool get _holdPage => _zoomed || _pinching; - - /// 확대한 배율에 맞춰 더 크게 다시 그린 쪽 (흐릿하지 않게) - final _sharp = >{}; - final _sharpWidth = {}; - - /// 반쪽은 두 배 너비로 읽는다 (반쪽이 화면 가득이어도 원본 해상도로) - int _baseWidth(ReaderSlot s) => s.part >= 0 ? math.min(_width * 2, 8192) : _width; + void _setZoomed(bool z) { + if (z != _zoom.zoomed) setState(() => _zoom.zoomed = z); + } void _onZoomEnd(int i) { - final s = _zoomOf(i).value.getMaxScaleOnAxis(); - final zoomed = s > 1.01; - if (zoomed != _zoomed) setState(() => _zoomed = zoomed); - if (s < 1.3 || i >= _v.length) return; + final s = _zoom.scaleOf(i); + _setZoomed(s > 1.01); + if (i >= _v.length) return; final slot = _v[i]; - final base = _baseWidth(slot); - final want = (base * s).round().clamp(base, 8192); - if (want <= (_sharpWidth[i] ?? base) * 1.2) return; - _sharpWidth[i] = want; - final old = _sharp[i]; - setState(() { - _sharp[i] = src.load(slot.page, maxWidth: want); - }); - if (old != null) unawaited(old.then((x) => x.dispose(), onError: (_) {})); + final want = _images.sharpWidthFor(i, s, slotBaseWidth(slot, _width)); + if (want == null) return; + // 확대한 배율에 맞춰 더 크게 다시 그린다 (흐릿하지 않게) + setState(() => _images.sharpen(i, slot.page, want)); } void _resetZoom() { - for (final z in _zoom.values) { - z.dispose(); - } - _zoom.clear(); - for (final f in _sharp.values) { - unawaited(f.then((x) => x.dispose(), onError: (_) {})); - } - _sharp.clear(); - _sharpWidth.clear(); - _zoomed = false; + _zoom.reset(); + _images.clearSharp(); } @override void dispose() { - for (final f in _thumbs.values) { - unawaited(f.then((x) => x.dispose(), onError: (_) {})); - } _gallery.dispose(); _life.dispose(); - _timer?.cancel(); + _show.dispose(); if (_playing) unawaited(ReaderPage.keepScreenOn(false).catchError((_) {})); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); - for (final f in [..._cache.values, ..._cacheHalf.values]) { - unawaited(f.then((x) => x.dispose(), onError: (_) {})); - } - _resetZoom(); + _images.dispose(); + _zoom.reset(); _pages.dispose(); super.dispose(); } - Future _load(int page) => _cache.putIfAbsent(page, () { - final f = src.load(page, maxWidth: _width); - f.then((r) { - if (mounted) _learn(page, r); - }, onError: (_) {}); - return f; + Future _load(int page) => _images.full(page, _width, onLoaded: (r) { + if (mounted) _learn(page, r); }); - /// 반쪽으로 보는 장 (두 배 너비) - final _cacheHalf = >{}; - Future _loadHalf(int page) => - _cacheHalf.putIfAbsent(page, () => src.load(page, maxWidth: math.min(_width * 2, 8192))); - /// 보는 장 가까이만 남기고 (메모리) 다음 장은 미리 읽는다. 앞 장도 크기를 알아 둔다 (자동 나누기로 쪽 번호가 밀리지 않게) void _trim() { final page = _cur.page; - for (final m in [_cache, _cacheHalf]) { - for (final k in m.keys.toList()) { - if ((k - page).abs() > 3) { - unawaited(m.remove(k)!.then((x) => x.dispose(), onError: (_) {})); - } - } - } + _images.trim(page); for (final n in [page + 1, page - 1]) { if (n >= 0 && n < src.length) unawaited(_load(n).then((_) {}, onError: (_) {})); } @@ -428,27 +298,22 @@ class _ReaderPageState extends State { /// 다시 읽기 (PDF 페이지를 바꿨을 때). [page] 를 보던 자리로 void _reset({int? page}) { final keep = page ?? _cur.page; - for (final f in [..._cache.values, ..._cacheHalf.values]) { - unawaited(f.then((x) => x.dispose(), onError: (_) {})); - } - _cache.clear(); - _cacheHalf.clear(); + _images.clearPages(); _dims.clear(); _resetZoom(); _version++; _v = const []; _rebuildPages(); - final i = _v.indexWhere((e) => e.page == keep.clamp(0, math.max(0, src.length - 1))); + final i = indexOfPage(_v, keep.clamp(0, math.max(0, src.length - 1))); _index = i < 0 ? 0 : i; _jumpLater(); } /// X-plore 맞추기 (관리자 지시 10/20): 볼륨 키로 넘기기 (설정을 켰고 Android 일 때만 - 처리한 키는 시스템 볼륨을 바꾸지 않는다) KeyEventResult _onKey(FocusNode _, KeyEvent e) { - if (!c.settings.readerVolumeKeys || !ReaderPage.volumeKeysSupported()) return KeyEventResult.ignored; - final k = e.logicalKey; - if (k != LogicalKeyboardKey.audioVolumeDown && k != LogicalKeyboardKey.audioVolumeUp) return KeyEventResult.ignored; - if (e is KeyDownEvent || e is KeyRepeatEvent) _go(k == LogicalKeyboardKey.audioVolumeDown ? 1 : -1); + final step = volumeKeyStep(e, enabled: c.settings.readerVolumeKeys && ReaderPage.volumeKeysSupported()); + if (step == null) return KeyEventResult.ignored; + if (step != 0) _go(step); return KeyEventResult.handled; } @@ -467,12 +332,9 @@ class _ReaderPageState extends State { _swallowTap = false; return; } - final x = d.localPosition.dx; - final rtl = c.settings.readerRtl; - if (x > width * 2 / 3) { - _go(rtl ? -1 : 1); - } else if (x < width / 3) { - _go(rtl ? 1 : -1); + final step = tapZone(d.localPosition.dx, width, rtl: c.settings.readerRtl); + if (step != 0) { + _go(step); } else { setState(() => _bars = !_bars); } @@ -488,115 +350,20 @@ class _ReaderPageState extends State { } } - ColorFilter _brightness(double b) { - final double s = 1 + math.min(b, 0.0); - final double o = math.max(b, 0.0) * 160; - return ColorFilter.matrix([s, 0, 0, 0, o, 0, s, 0, 0, o, 0, 0, s, 0, o, 0, 0, 0, 1, 0]); - } - Widget _page(int i, Size box) { - final fit = c.settings.readerFit; final slot = _v[i]; - final half = slot.part >= 0; - // 관리자 지시 (10/20, HWP 2단계): 이유와 [다시 시도] - 그 장의 기억을 지우고 다시 부른다 (시간 제한을 넘긴 HWP 묶음 등) - // [provider]: 그림 파일 · ZIP 의 그림을 읽다 (디코드) 실패한 것 - 화면 글은 종류에 맞게, 원문은 [자세히] (28 지적) - Widget failed(Object? e, {ImageProvider? provider}) { - final text = e is HwpPdfException - ? trf('열 수 없습니다: {0}', [e.message]) - : provider != null - ? tr('그림 파일을 읽을 수 없습니다 - 깨졌거나 지원하지 않는 형식') - : trf('열 수 없습니다: {0}', [e]); - final detail = e is HwpPdfException ? e.detail : (provider != null ? '$e' : ''); - return SizedBox( - width: 320, - height: 160, - child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ - Text(text, textAlign: TextAlign.center, style: const TextStyle(color: Colors.white70)), - const SizedBox(height: 8), - // 관리자 지시 (10/20, 28 지적): 변환기 · 그림 디코드 원문은 화면 글 대신 [자세히] 에 - if (detail.isNotEmpty) - TextButton( - onPressed: () => showDialog( - context: context, - builder: (ctx) => AlertDialog( - content: SelectableText(detail, style: const TextStyle(fontSize: 12)), - actions: [TextButton(onPressed: () => Navigator.of(ctx).pop(), child: Text(tr('닫기')))], - ), - ), - child: Text(tr('자세히')), - ), - OutlinedButton( - onPressed: () { - // 그림은 Flutter 의 그림 캐시에 실패가 남아 같은 키로 다시 쓰인다 - 비우고 (ResizeImage 의 키째) 다시 읽는다 - if (provider != null) unawaited(provider.evict()); - setState(() { - _cache.remove(slot.page); - _cacheHalf.remove(slot.page); - _sharp.remove(i); - _sharpWidth.remove(i); - _retry[slot.page] = (_retry[slot.page] ?? 0) + 1; - }); - }, - child: Text(tr('다시 시도')), - ), - ])); - } - // 확대해 다시 그린 것이 있으면 그것 (다 그릴 때까지는 앞의 그림을 그대로 보인다) - final page = FutureBuilder( - key: ValueKey('$_version:${slot.page}:${slot.part}:${_retry[slot.page] ?? 0}'), - future: _sharp[i] ?? (half ? _loadHalf(slot.page) : _load(slot.page)), - builder: (context, snap) { - // 계속 보기: 받는 동안은 시계를 재지 않고, 보인 (또는 실패 글이 보인) 뒤부터 - void shown() => WidgetsBinding.instance.addPostFrameCallback((_) => _markShown(i)); - if (snap.hasError) { - shown(); - return Center(child: failed(snap.error)); - } - final r = snap.data; - if (r == null) return const Center(child: CircularProgressIndicator()); - if (r.image != null) shown(); - Widget picture = r.image != null - ? RawImage(image: r.image, filterQuality: FilterQuality.medium) - : Image(image: r.provider!, gaplessPlayback: true, filterQuality: FilterQuality.medium, - frameBuilder: (_, child, frame, sync) { - if (frame != null || sync) shown(); - return child; - }, - errorBuilder: (_, e, _) { - shown(); - return failed(e, provider: r.provider); - }); - // 120: 회전 · 상하 반전 (보기만, 파일은 그대로) - if (_turns != 0) picture = RotatedBox(quarterTurns: _turns, child: picture); - if (_flipV) picture = Transform.flip(flipY: true, child: picture); - // 119: 반쪽 - 읽는 방향의 쪽부터 (오른쪽 → 왼쪽이면 오른쪽 반이 먼저) - if (half) { - final right = (slot.part == 0) == c.settings.readerRtl; - picture = ClipRect( - child: Align( - alignment: right ? Alignment.centerRight : Alignment.centerLeft, widthFactor: 0.5, child: picture), - ); - } - if (fit == 'width') { - return SingleChildScrollView( - physics: _holdPage ? const NeverScrollableScrollPhysics() : null, - child: SizedBox(width: box.width, child: FittedBox(fit: BoxFit.fitWidth, child: picture))); - } - return SizedBox.expand(child: FittedBox(fit: BoxFit.contain, child: picture)); - }, - ); - // 30: 두 손가락으로 확대 · 축소 (확대했을 때만 그림을 끌어 옮긴다) - return InteractiveViewer( - transformationController: _zoomOf(i), - minScale: 1, - maxScale: 6, - panEnabled: _zoomed, - onInteractionUpdate: (_) { - final z = _zoomOf(i).value.getMaxScaleOnAxis() > 1.01; - if (z != _zoomed) setState(() => _zoomed = z); - }, - onInteractionEnd: (_) => _onZoomEnd(i), - child: page, + return ReaderPageView( + slot: slot, + imageKey: '$_version:${slot.page}:${slot.part}:${_retry[slot.page] ?? 0}', + image: _images.sharp(i) ?? (slot.part >= 0 ? _images.half(slot.page, _width) : _load(slot.page)), + zoom: _zoom.of(i), zoomed: _zoom.zoomed, holdPage: _zoom.holdPage, + turns: _turns, flipV: _flipV, rtl: c.settings.readerRtl, fit: c.settings.readerFit, box: box, + onShown: () => _markShown(i), onZoomed: _setZoomed, onZoomEnd: () => _onZoomEnd(i), + // 관리자 지시 (10/20, HWP 2단계): [다시 시도] - 그 장의 기억을 지우고 다시 부른다 + onRetry: () => setState(() { + _images.forget(slot.page, i); + _retry[slot.page] = (_retry[slot.page] ?? 0) + 1; + }), ); } @@ -605,87 +372,25 @@ class _ReaderPageState extends State { if (mounted) setState(() {}); } - // ───────── PDF 편집 ───────── + // ───────── PDF 편집 (reader_pdf_edit - 창은 지금 장면이 끝난 뒤에) ───────── PdfSource? get _pdf => src is PdfSource ? src as PdfSource : null; - /// 28 지적 (10/20): 위 막대 단추를 누른 그 이벤트 안에서 창을 띄우면 (장을 손으로 넘긴 뒤) 창을 닫을 때 디버그 판에서 - /// 의미 트리 assertion ('node.built') 이 났다 - 창은 지금 장면이 끝난 뒤에 띄운다 (부른 쪽은 뒤에 mounted 확인) - Future _afterFrame() => WidgetsBinding.instance.endOfFrame; - Future _deletePage() async { - await _afterFrame(); - if (!mounted) return; - final pdf = _pdf!; - if (pdf.length <= 1) { - _snack(tr('마지막 한 페이지는 지울 수 없습니다.')); - return; - } - final page = _cur.page; - final ok = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text(tr('페이지 지우기')), - content: Text(trf('{0} 페이지를 지울까요? (저장해야 파일에 반영됩니다)', [page + 1])), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), - FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('지우기'))), - ], - ), - ); - if (ok != true) return; - await pdf.deletePage(page); - if (!mounted) return; + final page = await deletePdfPage(context, _pdf!, () => _cur.page, _snack); + if (page == null || !mounted) return; setState(() => _reset(page: page)); } Future _insertImages() async { - await _afterFrame(); - if (!mounted) return; - final pdf = _pdf!; - final page = _cur.page; - final files = await c.services.storage.pickFiles(title: tr('PDF 에 넣을 그림 선택'), extensions: c.settings.imageExts); - if (files.isEmpty || !mounted) return; - final temp = await c.services.storage.tempDirectory(); - try { - await pdf.insertImages(files, after: page, tempDir: temp); - } catch (e) { - _snack(trf('넣지 못했습니다: {0}', [e])); - return; - } - setState(() => _reset(page: page)); - _snack(trf('그림 {0}장을 {1} 페이지 뒤에 넣었습니다 (저장해야 파일에 반영됩니다).', [files.length, page + 1])); + final r = await insertPdfImages(context, c, _pdf!, () => _cur.page, _snack); + if (r == null || !mounted) return; + setState(() => _reset(page: r.page)); + _snack(insertedPdfImagesText(r)); } Future _save() async { - try { - final path = await _pdf!.save(); - _snack(trf('저장했습니다: {0}', [vDisplay(path)])); - setState(() {}); - } catch (e) { - _snack(trf('저장하지 못했습니다: {0}', [e])); - } - } - - Future _confirmLeave() async { - final pdf = _pdf; - if (pdf == null || !pdf.dirty) return true; - await _afterFrame(); - if (!mounted) return false; - final r = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text(tr('저장하지 않은 변경')), - content: Text(tr('페이지를 바꾼 것을 저장할까요?')), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, 'cancel'), child: Text(tr('취소'))), - TextButton(onPressed: () => Navigator.pop(ctx, 'discard'), child: Text(tr('저장 안 함'))), - FilledButton(onPressed: () => Navigator.pop(ctx, 'save'), child: Text(tr('저장'))), - ], - ), - ); - if (r == 'save') await _save(); - return r == 'save' || r == 'discard'; + if (await savePdf(_pdf!, _snack) && mounted) setState(() {}); } void _snack(String text) { @@ -697,276 +402,31 @@ class _ReaderPageState extends State { // ───────── 화면 ───────── - /// 위 막대의 버튼들 (앞의 것일수록 먼저 보이고, 못 들어가면 ⋮ 메뉴로) - List<_BarItem> _barItems() { - final s = c.settings; - final pdf = _pdf; - final secs = s.readerAutoSeconds; - _BarItem one(_Act a) => _BarItem(48, [a]); - final split = switch (s.readerSplit) { - 'on' => tr('두 쪽 나눠 보기: 켜기 (누르면 끄기)'), - 'off' => tr('두 쪽 나눠 보기: 끄기 (누르면 자동)'), - _ => tr('두 쪽 나눠 보기: 자동 - 두 쪽 스캔으로 보이는 그림만 (누르면 켜기)'), - }; - return [ - // 223: 나눠 보고 있으면 바로 한 장으로 (자동이 사진을 잘라 보인다고 여기지 않게 맨 앞에) - if (_cur.part >= 0) one(_Act(const Icon(Icons.crop_original), tr('두 쪽 나눔 끄기 (한 장으로 보기)'), _splitOff)), - // 117: 계속 보기 · 간격 (▲ 늘리기 · ▼ 줄이기) - one(_Act(Icon(_playing ? Icons.pause : Icons.play_arrow), - _playing ? tr('계속 보기 멈추기') : trf('계속 보기 ({0}초마다 다음 장)', [secs]), () => _play(!_playing), - on: _playing)), - _BarItem(112, [ - _Act(const Icon(Icons.keyboard_arrow_up), trf('계속 보기 간격 늘리기 (지금 {0}초)', [secs]), - secs < 60 ? () => _setSeconds(1) : null), - _Act(const Icon(Icons.keyboard_arrow_down), trf('계속 보기 간격 줄이기 (지금 {0}초)', [secs]), - secs > 1 ? () => _setSeconds(-1) : null), - ], seconds: secs), - if (pdf != null) ...[ - one(_Act(const Icon(Icons.save_outlined), tr('저장'), pdf.dirty ? _save : null)), - one(_Act(const Icon(Icons.delete_outline), tr('이 페이지 지우기'), _deletePage)), - one(_Act(const Icon(Icons.add_photo_alternate_outlined), tr('그림을 페이지로 넣기 (이 페이지 뒤)'), _insertImages)), - ], - // X-plore 맞추기 (관리자 지시 10/20): 사진 정보 (Exif) - 폴더의 그림만 - if (src is ImageFilesSource) one(_Act(const Icon(Icons.info_outline), tr('사진 정보 (Exif)'), _showPhotoInfo)), - // X-plore 맞추기 (관리자 지시 10/20): 아래 갤러리 줄 켜고 끄기 (그림 원본만) - if (_pictures) - one(_Act(const Icon(Icons.view_carousel_outlined), s.readerGallery ? tr('아래 갤러리 줄 숨기기') : tr('아래 갤러리 줄 보이기'), - _toggleGallery, on: s.readerGallery)), - // 119 · 120 - one(_Act(const Icon(Icons.vertical_split_outlined), split, _cycleSplit, on: s.readerSplit != 'off')), - one(_Act(const Icon(Icons.rotate_right), tr('90° 회전 (시계 방향)'), () => _rotate(1), on: _turns != 0)), - one(_Act(const Icon(Icons.rotate_left), tr('반대로 90° 회전'), () => _rotate(3), on: _turns != 0)), - one(_Act(const RotatedBox(quarterTurns: 1, child: Icon(Icons.flip)), tr('상하 반전'), - () => setState(() => _flipV = !_flipV), on: _flipV)), - one(_Act(const Icon(Icons.fit_screen_outlined), tr('한 쪽 맞추기'), () => _updateSettings((x) => x.readerFit = 'page'), - on: s.readerFit == 'page')), - one(_Act(const Icon(Icons.width_full_outlined), tr('좌우 맞추기 (세로로 밀어 봄)'), - () => _updateSettings((x) => x.readerFit = 'width'), on: s.readerFit == 'width')), - one(_Act(const Icon(Icons.swap_horiz), - s.readerRtl ? tr('넘기는 방향: 오른쪽 → 왼쪽 (만화)') : tr('넘기는 방향: 왼쪽 → 오른쪽'), - () => _updateSettings((x) => x.readerRtl = !x.readerRtl), on: s.readerRtl)), - one(_Act(const Icon(Icons.brightness_low), tr('어둡게'), - () => _updateSettings((x) => x.readerBrightness = (x.readerBrightness - 0.1).clamp(-0.7, 0.7)))), - one(_Act(const Icon(Icons.brightness_high), tr('밝게'), - () => _updateSettings((x) => x.readerBrightness = (x.readerBrightness + 0.1).clamp(-0.7, 0.7)))), - // 30: 시계 · 알림 · 뒤로 막대를 그대로 / 화면 가득 - one(_Act(Icon(s.readerSystemBars ? Icons.fullscreen : Icons.fullscreen_exit), - s.readerSystemBars ? tr('화면 가득 (시스템 막대 숨기기)') : tr('시스템 막대 보이기 (시계 · 알림 · 뒤로)'), () async { - await _updateSettings((x) => x.readerSystemBars = !x.readerSystemBars); - _applySystemBars(); - }, on: s.readerSystemBars)), - // AI 는 막대가 좁으면 먼저 ⋮ 로 (늘 쓰는 버튼이 막대에 남게) - // 121: AI 해상도 올리기 (이 장 · 모든 장) - 새 파일로 저장, 원본 · ZIP 은 그대로 - if (_aiOn && src is! PdfSource) ...[ - one(_Act(const Icon(Icons.hd_outlined), tr('AI 해상도 올리기 (이 장)'), _upscalePage)), - one(_Act(const Icon(Icons.burst_mode_outlined), tr('AI 해상도 올리기 (모든 장)'), () => upscaleAll(context, c, src))), - // 관리자 판단 (10/17): [품질 올리기] - 설정으로 켰을 때만 - if (c.settings.aiQualityButton) one(_Act(const Icon(Icons.auto_fix_high), tr('품질 올리기 (이 장)'), _qualityPage)), - ], - // 123: 이 그림을 바탕으로 AI 그림 (그림 → 그림, 원본은 그대로) - if (_aiSource case final path?) - one(_Act(const Icon(Icons.auto_awesome_outlined), tr('AI 그림으로 (이 그림을 바탕으로)'), - () => AiImagePage.open(Navigator.of(context), c: c, initImage: path))), - ]; - } - - bool get _aiOn => c.settings.components.contains('aiimage'); - - Future _upscalePage() async { - final page = _cur.page; - final file = await readerPageFile(c, src, page); - if (file == null || !mounted) return; - final saved = await upscaleOne(context, c, input: file.$1, nameFrom: file.$2, outDir: file.$3); - if (saved == null || !mounted) return; - // 저장한 뒤 올린 것으로 계속 보기 (설정) - 이 기기의 그림 파일 목록일 때 - final s = src; - if (c.settings.aiUpAfter == 'upscaled' && s is ImageFilesSource && !isDav(s.paths[page])) { - setState(() { - s.paths[page] = saved; - _reset(page: page); - }); - } - } - - Future _qualityPage() async { - final file = await readerPageFile(c, src, _cur.page); - if (file == null || !mounted) return; - await qualityUp(context, c, input: file.$1, nameFrom: file.$2, outDir: file.$3); - } - - /// 지금 보는 그림 파일 (이 기기의 그림 파일일 때, AI 그림 컴포넌트가 켜져 있을 때) - String? get _aiSource { - final s = src; - if (!c.settings.components.contains('aiimage') || s is! ImageFilesSource || _v.isEmpty) return null; - final path = s.paths[_cur.page]; - return isDav(path) ? null : path; - } - - Widget _barButton(_Act a, {bool compact = false}) => IconButton( - tooltip: a.tip, - // 막대는 밝은 화면에서도 어두운 띠 - 강조는 어두운 화면의 청록 (밝은 화면의 진한 청록은 어두운 띠 위에서 안 보임) - color: a.on ? JjPalette.dark.accent : Colors.white, - disabledColor: Colors.white38, - icon: a.icon, - visualDensity: compact ? VisualDensity.compact : null, - constraints: compact ? const BoxConstraints(minWidth: 36, minHeight: 40) : null, - padding: compact ? EdgeInsets.zero : null, - onPressed: a.onTap, + /// 위 막대의 버튼들 (reader_bar_items) + List _barItems() => readerBarItems( + context, + c, + src, + (halfShown: _cur.part >= 0, playing: _playing, turns: _turns, flipV: _flipV, aiSource: readerAiSource(c, src, _cur.page, hasPages: _v.isNotEmpty)), + ReaderBarHandlers( + splitOff: _splitOff, play: _play, setSeconds: _setSeconds, cycleSplit: _cycleSplit, rotate: _rotate, + save: _save, deletePage: _deletePage, insertImages: _insertImages, toggleGallery: _toggleGallery, + photoInfo: () => showReaderPhotoInfo(context, src, () => _cur.page, hasPages: () => _v.isNotEmpty), + toggleFlip: () => setState(() => _flipV = !_flipV), + updateSettings: _updateSettings, applySystemBars: _applySystemBars, + upscalePage: _upscalePage, qualityPage: () => qualityReaderPage(context, c, src, _cur.page), + ), ); - Widget _topBar() { - final pdf = _pdf; - return Container( - // 113: 반투명 검정은 그림 위에서만 보이고 옆의 검은 여백에서는 안 보여 막대가 갈라져 보였다 → 거의 불투명한 어두운 회색 한 줄 - color: _barColor, - padding: EdgeInsets.only(top: MediaQuery.paddingOf(context).top), - child: SizedBox( - height: 52, - child: LayoutBuilder(builder: (context, box) { - final items = _barItems(); - // 닫기 · 제목 (조금이라도) 을 뺀 자리에 들어가는 만큼 앞에서부터, 나머지는 ⋮ 메뉴 (없애지 않는다) - final room = box.maxWidth - 48 - 72; - var shown = items.length; - if (items.fold(0, (a, x) => a + x.width) > room) { - var used = 48.0; // ⋮ - shown = 0; - while (shown < items.length && used + items[shown].width <= room) { - used += items[shown].width; - shown++; - } - } - final rest = [for (final x in items.skip(shown)) ...x.acts]; - return Row(children: [ - _barButton(_Act(const Icon(Icons.arrow_back), tr('닫기'), () => Navigator.maybePop(context))), - Expanded( - child: Text( - '${src.title}${pdf?.dirty == true ? ' *' : ''}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: Colors.white, fontSize: 14), - ), - ), - // 버튼은 오른쪽 끝에 붙인다 (113) - for (final x in items.take(shown)) - if (x.seconds != null) - Row(mainAxisSize: MainAxisSize.min, children: [ - _barButton(x.acts[0], compact: true), - SizedBox( - width: 40, - child: Text(trf('{0}초', [x.seconds!]), - textAlign: TextAlign.center, style: const TextStyle(color: Colors.white, fontSize: 13)), - ), - _barButton(x.acts[1], compact: true), - ]) - else - _barButton(x.acts.single), - if (rest.isNotEmpty) - PopupMenuButton( - tooltip: tr('더 보기'), - icon: const Icon(Icons.more_vert, color: Colors.white), - onSelected: (k) => rest[k].onTap?.call(), - itemBuilder: (_) => [ - for (final (k, a) in rest.indexed) - PopupMenuItem( - value: k, - enabled: a.onTap != null, - child: Row(children: [ - IconTheme.merge(data: IconThemeData(color: a.on ? JjColors.accent : null), child: a.icon), - const SizedBox(width: 12), - Flexible(child: Text(a.tip)), - ]), - ), - ], - ), - ]); - }), - ), - ); - } - - /// 위 · 아래 막대 색 (그림 위 · 검은 여백 위 어디서나 같은 띠로 보이게 거의 불투명) - static const _barColor = Color(0xF0222428); - - /// 쪽 번호: 통째면 "12", 반쪽이면 "12-1" · "12-2" - String _label(ReaderSlot s) => s.part < 0 ? '${s.page + 1}' : '${s.page + 1}-${s.part + 1}'; - - /// X-plore 맞추기 (관리자 지시 10/20): 지금 그림의 사진 정보 (찍은 날 · 기기 · 크기 · 위치). 위치는 [위치 보기] 를 누를 때만 - /// 보이고, 지도 · 인터넷으로 보내지 않는다 (링크 없음) - Future _showPhotoInfo() async { - // 지금 장면이 끝난 뒤에 띄운다 ([_afterFrame], Windows 통합 시험에서 재현 4번 → 0번) - await _afterFrame(); - if (!mounted) return; - final s = src; - if (s is! ImageFilesSource || _v.isEmpty) return; - final path = s.paths[_cur.page]; - PhotoInfo? info; - int? size; - Object? error; - try { - final local = await vLocalCopy(path, s.tempDir); - final f = File(local); - size = await f.length(); - final bytes = await f.readAsBytes(); - info = readPhotoInfo(bytes); - } catch (e) { - error = e; - } - if (!mounted) return; - var showLocation = false; - await showDialog( - context: context, - builder: (ctx) => StatefulBuilder( - builder: (ctx, set) { - final i = info; - Widget row(String k, Widget v) => Padding( - padding: const EdgeInsets.symmetric(vertical: 3), - child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ - SizedBox(width: 90, child: Text(k, style: TextStyle(color: JjColors.textDim))), - Expanded(child: v), - ]), - ); - return AlertDialog( - scrollable: true, - title: Text(tr('사진 정보 (Exif)')), - content: SizedBox( - width: 420, - child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - row(tr('파일'), SelectableText(vBasename(path))), - if (i == null) - Text(trf('읽을 수 없습니다: {0}', [error])) - else ...[ - row(tr('크기'), Text([ - if (i.width != null) trf('{0} × {1} 픽셀', [i.width!, i.height!]), - if (size != null) formatSize(size), - ].join(' · '))), - row(tr('찍은 날'), Text(i.taken ?? '-')), - row(tr('기기'), Text(i.device ?? '-')), - row( - tr('위치'), - !i.hasLocation - ? const Text('-') - : showLocation - ? SelectableText(i.locationText) - : Align( - alignment: Alignment.centerLeft, - child: TextButton(onPressed: () => set(() => showLocation = true), child: Text(tr('위치 보기'))))), - if (i.hasLocation) - Text(tr('위치는 이 기기에서만 보이며 지도 · 인터넷으로 보내지 않습니다'), - style: TextStyle(fontSize: 12, color: JjColors.textDim)), - if (!i.hasExif) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text(tr('이 그림에는 Exif 정보가 없습니다 (JPEG 사진만 읽음)'), - style: TextStyle(fontSize: 12, color: JjColors.textDim)), - ), - ], - ]), - ), - actions: [TextButton(onPressed: () => Navigator.of(ctx).pop(), child: Text(tr('닫기')))], - ); - }, - ), - ); + /// 121: AI 해상도 올리기 (reader_ai) - 저장한 뒤 올린 것으로 계속 보기 (설정) 면 그 장을 바꿔 다시 읽는다 + Future _upscalePage() async { + final page = _cur.page; + final saved = await upscaleReaderPage(context, c, src, page); + if (saved == null || !mounted) return; + setState(() { + (src as ImageFilesSource).paths[page] = saved; + _reset(page: page); + }); } Future _toggleGallery() async { @@ -978,93 +438,22 @@ class _ReaderPageState extends State { /// [page] 번째 장 (파일) 으로 (두 쪽 나눠 보고 있으면 그 장의 첫 쪽) void _goPage(int page) { - final i = _v.indexWhere((e) => e.page == page); + final i = indexOfPage(_v, page); if (i >= 0 && _pages.hasClients) _pages.jumpToPage(i); } /// 갤러리 줄을 지금 장이 가운데 오게 - void _scrollGallery({bool animate = true}) { - if (!_gallery.hasClients) return; - final pos = _gallery.position; - final to = (_cur.page * _thumbExtent - pos.viewportDimension / 2 + _thumbExtent / 2).clamp(0.0, pos.maxScrollExtent); - animate ? _gallery.animateTo(to, duration: const Duration(milliseconds: 200), curve: Curves.easeOut) : _gallery.jumpTo(to); - } - - Future _thumb(int i) => _thumbs.putIfAbsent(i, () => src.load(i, maxWidth: 160)); - - Widget _galleryStrip() => SizedBox( - height: 76, - child: ListView.builder( - controller: _gallery, - scrollDirection: Axis.horizontal, - reverse: c.settings.readerRtl, - itemExtent: _thumbExtent, - itemCount: src.length, - itemBuilder: (_, i) => GestureDetector( - key: ValueKey('thumb$i'), - onTap: () => _goPage(i), - child: Container( - margin: const EdgeInsets.all(3), - decoration: BoxDecoration( - border: Border.all(color: i == _cur.page ? JjPalette.dark.accent : Colors.white24, width: i == _cur.page ? 2 : 1)), - child: FutureBuilder( - future: _thumb(i), - builder: (_, snap) { - final r = snap.data; - if (snap.hasError) return const Icon(Icons.broken_image_outlined, color: Colors.white38, size: 20); - if (r == null) return const SizedBox(); - return r.image != null - ? RawImage(image: r.image, fit: BoxFit.cover) - : Image( - image: r.provider!, - fit: BoxFit.cover, - errorBuilder: (_, _, _) => const Icon(Icons.broken_image_outlined, color: Colors.white38, size: 20)); - }, - ), - ), - ), - ), - ); + void _scrollGallery({bool animate = true}) => scrollGalleryTo(_gallery, _cur.page, animate: animate); Widget _bottomBar() { final n = src.length; - final count = _v.length; - return Container( - color: _barColor, - padding: EdgeInsets.only(bottom: MediaQuery.paddingOf(context).bottom, left: 12, right: 12), - child: Column(mainAxisSize: MainAxisSize.min, children: [ - if (_pictures && c.settings.readerGallery && n > 0) _galleryStrip(), - Row(children: [ - // 125: 반으로 나눈 쪽이 있으면 전체 수가 파일 수 (장) 임을 단위로 ("2-1 / 3장") - Text( - count != n ? trf('{0} / {1}장', [_label(_cur), n]) : '${n == 0 ? 0 : _label(_cur)} / $n', - style: const TextStyle(color: Colors.white, fontFamily: 'Consolas')), - Expanded( - child: count < 2 - ? const SizedBox(height: 48) - : Directionality( - textDirection: c.settings.readerRtl ? TextDirection.rtl : TextDirection.ltr, - child: Slider( - value: _index.clamp(0, count - 1).toDouble(), - max: (count - 1).toDouble(), - divisions: count - 1, - onChanged: (v) => _pages.jumpToPage(v.round()), - // ⑦ 어두운 막대 위 - 밝은 화면에서도 어두운 화면의 청록 (null 이면 테마 그대로) - activeColor: JjColors.isLight ? JjPalette.dark.accent : null, - ), - ), - ), - // 113: 쪽 이름은 오른쪽 끝 (넓은 화면에서 가운데로 떨어지지 않게) - ConstrainedBox( - constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.3), - child: Text(n == 0 ? '' : src.pageName(_cur.page), - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.end, - style: const TextStyle(color: Colors.white70, fontSize: 12)), - ), - ]), - ]), + return ReaderBottomBar( + pages: n, slots: _v.length, index: _index, label: slotLabel(_cur), + pageName: n == 0 ? '' : src.pageName(_cur.page), rtl: c.settings.readerRtl, onSlide: _pages.jumpToPage, + gallery: _pictures && c.settings.readerGallery && n > 0 + ? ReaderGalleryStrip(controller: _gallery, count: n, current: _cur.page, rtl: c.settings.readerRtl, + thumb: _images.thumb, onTap: _goPage) + : null, ); } @@ -1075,7 +464,7 @@ class _ReaderPageState extends State { canPop: _pdf?.dirty != true, onPopInvokedWithResult: (didPop, _) async { if (didPop) return; - if (await _confirmLeave() && context.mounted) { + if (await confirmPdfLeave(context, _pdf, _save) && context.mounted) { _pdf?.dirty = false; Navigator.of(context).pop(); } @@ -1084,68 +473,32 @@ class _ReaderPageState extends State { autofocus: true, onKeyEvent: _onKey, child: Scaffold( - backgroundColor: Colors.black, - body: LayoutBuilder(builder: (context, box) { - // 화면 너비 (기기 픽셀) 에 맞춰 그림을 줄여 읽는다 - _width = (box.maxWidth * MediaQuery.devicePixelRatioOf(context)).round().clamp(320, 4096); - return Stack(children: [ - Positioned.fill( - child: Listener( - onPointerDown: (_) => _onPointerDown(), - onPointerUp: (_) => _finger(-1), - onPointerCancel: (_) => _finger(-1), - child: GestureDetector( - onTapUp: (d) => _onTap(d, box.maxWidth), - child: ColorFiltered( - colorFilter: _brightness(s.readerBrightness), - child: _v.isEmpty - ? Center(child: Text(tr('볼 그림이 없습니다.'), style: const TextStyle(color: Colors.white70))) - : PageView.builder( - key: ValueKey(_version), - controller: _pages, - reverse: s.readerRtl, - // 확대해 보는 동안은 밀어도 넘기지 않는다 (그림 옮기기) - physics: _holdPage ? const NeverScrollableScrollPhysics() : null, - itemCount: _v.length, - onPageChanged: (i) { - setState(() { - _index = i; - _resetZoom(); - _trim(); - }); - _scrollGallery(); - }, - itemBuilder: (_, i) => _page(i, box.biggest), - ), - ), - ), - ), - ), - if (_bars) ...[ - Positioned(left: 0, right: 0, top: 0, child: _topBar()), - Positioned(left: 0, right: 0, bottom: 0, child: _bottomBar()), - ], - ]); - }), + backgroundColor: Colors.black, + body: ReaderStage( + version: _version, + count: _v.length, + controller: _pages, + rtl: s.readerRtl, + holdPage: _zoom.holdPage, + brightness: s.readerBrightness, + onWidth: (w) => _width = w, + onPointerDown: _onPointerDown, + onPointerUp: () => _finger(-1), + onTapUp: _onTap, + onPageChanged: (i) { + setState(() { + _index = i; + _resetZoom(); + _trim(); + }); + _scrollGallery(); + }, + page: _page, + topBar: _bars ? ReaderTopBar(title: '${src.title}${_pdf?.dirty == true ? ' *' : ''}', items: _barItems) : null, + bottomBar: _bars ? _bottomBar() : null, + ), ), ), ); } } - -/// 위 막대 버튼 하나 (⋮ 메뉴에서는 [icon] 과 [tip] 으로 한 줄) -class _Act { - final Widget icon; - final String tip; - final VoidCallback? onTap; - final bool on; - const _Act(this.icon, this.tip, this.onTap, {this.on = false}); -} - -/// 위 막대의 한 칸 ([width] 만큼 자리를 차지). [seconds] 가 있으면 ▲ · 초 · ▼ 묶음 -class _BarItem { - final double width; - final List<_Act> acts; - final int? seconds; - const _BarItem(this.width, this.acts, {this.seconds}); -} diff --git a/test/reader_modules_test.dart b/test/reader_modules_test.dart new file mode 100644 index 0000000..8d863c3 --- /dev/null +++ b/test/reader_modules_test.dart @@ -0,0 +1,125 @@ +// 보기 화면 모듈 (lib/ui/reader/) 의 약속 시험 - 실제 구성 요소로 (가짜 원본 없음, 사용자 설정 저장소 쓰지 않음) +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; +import 'package:jj_mkvmaker/core/reader_sources.dart'; +import 'package:jj_mkvmaker/ui/reader/reader_bars.dart'; +import 'package:jj_mkvmaker/ui/reader/reader_controls.dart'; +import 'package:jj_mkvmaker/ui/reader/reader_images.dart'; +import 'package:jj_mkvmaker/ui/reader/reader_slots.dart'; +import 'package:path/path.dart' as p; + +String _png(Directory dir, String name, {int w = 40, int h = 60}) { + final im = img.Image(width: w, height: h); + img.fill(im, color: img.ColorRgb8(200, 10, 10)); + return (File(p.join(dir.path, name))..writeAsBytesSync(img.encodePng(im))).path; +} + +void main() { + late Directory tmp; + setUp(() => tmp = Directory.systemTemp.createTempSync('jj_reader_mod_')); + tearDown(() => tmp.deleteSync(recursive: true)); + + test('reader_slots: 쪽 만들기 · relocateSlot (반쪽 ↔ 통째) · pageAfterRemoval · 이름', () { + final split = buildSlots(3, (i) => i == 1); + expect(split, [(page: 0, part: -1), (page: 1, part: 0), (page: 1, part: 1), (page: 2, part: -1)]); + final whole = buildSlots(3, (_) => false); + // 둘째 반쪽을 보다 나누기를 끄면 그 장 통째로, 통째를 보다 켜면 첫 반쪽으로 + expect(relocateSlot(whole, (page: 1, part: 1), 2), 1); + expect(relocateSlot(split, (page: 2, part: -1), 2), 3); + // 장이 없어지면 옛 번호를 범위 안으로 + expect(relocateSlot(buildSlots(1, (_) => false), (page: 5, part: -1), 4), 0); + // 보던 1 이 없어지면 뒤의 2 (새 번호 1), 뒤가 다 없으면 앞, 모두 없으면 -1 + expect(pageAfterRemoval(1, {1}, 3), 1); + expect(pageAfterRemoval(2, {1, 2}, 3), 0); + expect(pageAfterRemoval(0, {0, 1}, 2), -1); + expect([slotLabel((page: 11, part: -1)), slotLabel((page: 11, part: 1))], ['12', '12-2']); + expect(slotAt(const [], 3), (page: 0, part: -1)); + expect(slotBaseWidth((page: 0, part: 0), 5000), 8192); + }); + + test('reader_slots: splitsPage - 켜기 · 끄기 · 자동 (회전 반영)', () { + final dims = {0: const Size(200, 100), 1: const Size(100, 200), 2: const Size(100, 200)}; + expect(splitsPage('on', 1, dims, turns: 0, bundle: false), isTrue); + expect(splitsPage('off', 0, dims, turns: 0, bundle: true), isFalse); + expect(splitsPage('auto', 0, dims, turns: 0, bundle: false), isTrue, reason: '세로 사이의 가로 한 장'); + expect(splitsPage('auto', 0, dims, turns: 1, bundle: false), isFalse, reason: '돌리면 세로'); + expect(splitsPage('auto', 9, dims, turns: 0, bundle: true), isFalse, reason: '크기를 모르면 나누지 않음'); + }); + + test('reader_controls: tapZone · volumeKeyStep', () { + expect([tapZone(90, 100, rtl: false), tapZone(10, 100, rtl: false), tapZone(50, 100, rtl: false)], [1, -1, 0]); + expect([tapZone(90, 100, rtl: true), tapZone(10, 100, rtl: true)], [-1, 1]); + const down = KeyDownEvent( + physicalKey: PhysicalKeyboardKey.audioVolumeDown, logicalKey: LogicalKeyboardKey.audioVolumeDown, timeStamp: Duration.zero); + const upRelease = KeyUpEvent( + physicalKey: PhysicalKeyboardKey.audioVolumeUp, logicalKey: LogicalKeyboardKey.audioVolumeUp, timeStamp: Duration.zero); + const other = KeyDownEvent(physicalKey: PhysicalKeyboardKey.keyA, logicalKey: LogicalKeyboardKey.keyA, timeStamp: Duration.zero); + expect(volumeKeyStep(down, enabled: true), 1); + expect(volumeKeyStep(upRelease, enabled: true), 0, reason: '뗄 때도 처리 (볼륨 안 바뀜)'); + expect(volumeKeyStep(down, enabled: false), isNull); + expect(volumeKeyStep(other, enabled: true), isNull); + }); + + testWidgets('reader_controls: 계속 보기 시계는 장이 보인 뒤부터 재고, 끄면 멈춘다', (tester) async { + var ticks = 0; + final show = ReaderSlideshow(seconds: () => 2, onTick: () => ticks++); + addTearDown(show.dispose); + expect(show.set(true, '0:0'), isTrue); + await tester.pump(const Duration(seconds: 3)); + expect(ticks, 0, reason: '아직 보이지 않은 장'); + show.markShown('0:0'); + await tester.pump(const Duration(seconds: 2)); + expect(ticks, 1); + show.markShown('0:1'); + expect(show.set(false, '0:1'), isTrue); + await tester.pump(const Duration(seconds: 3)); + expect(ticks, 1); + final z = ReaderZoom(); + expect([z.finger(1), z.finger(1), z.holdPage], [false, true, true]); + z.reset(); + }); + + testWidgets('reader_images: 장마다 한 번 읽기 · forget 뒤 새로 · 그림 크기 (실제 PNG)', (tester) async { + final src = ImageFilesSource([_png(tmp, 'a.png', w: 40, h: 60), _png(tmp, 'b.png')], tempDir: tmp.path, title: 't'); + final cache = ReaderImageCache(src); + final a = cache.full(0, 800); + expect(identical(a, cache.full(0, 800)), isTrue); + cache.forget(0, 0); + final b = cache.full(0, 800); + expect(identical(a, b), isFalse); + final size = await tester.runAsync(() async => ReaderImageCache.imageSize(await b)); + expect(size, const Size(40, 60)); + expect(cache.sharpWidthFor(0, 1.2, 800), isNull); + expect(cache.sharpWidthFor(0, 2, 800), 1600); + await tester.runAsync(() async { + await a; + cache.dispose(); + }); + }); + + testWidgets('reader_bars: fitBarItems (넘치면 ⋮ 자리 빼고) · 갤러리 줄 키 · 누름', (tester) async { + final items = [for (var i = 0; i < 5; i++) ReaderBarItem(48, [ReaderAct(const Icon(Icons.add), 't$i', () {})])]; + expect(fitBarItems(items, 240), 5); + expect(fitBarItems(items, 239), 3, reason: '48 (⋮) + 48 × 3 = 192'); + final src = ImageFilesSource([_png(tmp, 'a.png'), _png(tmp, 'b.png'), _png(tmp, 'c.png')], tempDir: tmp.path, title: 't'); + final cache = ReaderImageCache(src); + final gallery = ScrollController(); + addTearDown(gallery.dispose); + int? tapped; + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: ReaderGalleryStrip(controller: gallery, count: 3, current: 1, rtl: false, thumb: cache.thumb, onTap: (i) => tapped = i), + ), + )); + expect(find.byKey(const ValueKey('thumb2')), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('thumb2'))); + expect(tapped, 2); + expect(galleryOffset(1, 128, 1000), 32); + await tester.pumpWidget(const SizedBox()); + await tester.runAsync(() async => cache.dispose()); + }); +}