Merge branch 'split_pages' into modular

modular
RyuJongJin 2026-10-22 20:12:13 +09:00
commit ef530e8450
18 changed files with 2912 additions and 1893 deletions

View File

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

View File

@ -0,0 +1,359 @@
/// 실시간 동기화 (lsync) 카드 위젯 (모니터링 > lsync 탭, 다른 화면도 그대로 넣어 씀).
/// 약속:
/// - LivePairCard(c, live, pair, onFinalize, onShowCopyList): 쌍의 상태 · 일정 · 맞출 것 · 지우기 확인 · 단추.
/// live 가 null 이면 (실시간 동기화 꺼짐) 세기 · 맞추기 단추를 끈다. 설정 저장은 replaceLiveSyncPair 로.
/// 탭 이동 같은 화면 일은 하지 않고 onFinalize · onShowCopyList 로 부르는 쪽에 맡긴다.
/// - LiveSourceProblemPanel(live, pair, error): 원본 문제 안내와 [다시 시도] · [지울 목록 보고 결정].
/// 화면 상태를 읽지 않는다. 설정은 읽기만 (liveSyncPairs 쓰기는 live_sync_flow 를 거침).
library;
import 'package:flutter/material.dart';
import '../../app/app_controller.dart';
import '../../app/copy_center.dart';
import '../../app/live_sync.dart';
import '../../app/settings.dart';
import '../../core/secret_gate.dart';
import '../../core/file_ops.dart';
import '../../core/sync_tools.dart';
import '../../core/vfs.dart';
import '../../l10n/tr.dart';
import '../copy_sync_settings.dart' show editLiveSyncPairDialog, removeLiveSyncPair;
import '../path_label.dart';
import '../schedule_editor.dart';
import '../sync_preview_view.dart' show unbreakableOption;
import '../theme.dart';
import 'live_sync_flow.dart';
import 'transfer_widgets.dart';
/// 원본 문제 안내 (68 · 70): 원본을 읽지 못함 · 원본이 빔 · 마스터 비밀번호 기다림
class LiveSourceProblemPanel extends StatelessWidget {
final LiveSync live;
final LiveSyncPair pair;
final SourceUnreadableException error;
const LiveSourceProblemPanel({super.key, required this.live, required this.pair, required this.error});
@override
Widget build(BuildContext context) {
final x = pair;
final e = error;
final k = LiveSync.keyOf(x);
final dels = live.emptyDeletes[k] ?? const <String>[];
final running = live.isRunning(x);
// 128: 마스터 비밀번호를 기다리는 중 - 고장이 아니라 넣으면 이어서 맞춘다
if (e.locked) {
return Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(12, 10, 12, 8),
decoration: BoxDecoration(
color: JjColors.accent.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: JjColors.accent.withValues(alpha: 0.5)),
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Icon(Icons.lock_clock_outlined, color: JjColors.accent, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(tr('마스터 비밀번호가 필요해 기다리는 중'),
style: TextStyle(fontWeight: FontWeight.w600, color: JjColors.accent)),
),
]),
const SizedBox(height: 4),
Text(tr('이 서버의 저장된 비밀번호를 쓰려면 마스터 비밀번호가 필요합니다. 넣으면 이어서 맞춥니다. 대상 파일은 건드리지 않았습니다.'),
style: const TextStyle(fontSize: 12)),
const SizedBox(height: 6),
FilledButton.tonalIcon(
onPressed: running
? null
: () async {
if (await SecretGate.pass(force: true)) await live.syncNow(x);
},
icon: const Icon(Icons.lock_open, size: 18),
label: Text(tr('마스터 비밀번호 넣기')),
),
]),
);
}
return Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(12, 10, 12, 8),
decoration: BoxDecoration(
color: JjColors.alert.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: JjColors.alert.withValues(alpha: 0.6)),
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Icon(e.empty ? Icons.folder_off_outlined : Icons.error_outline, color: JjColors.alert, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
e.empty ? tr('원본 폴더가 비어 있어 멈췄습니다') : tr('원본을 읽을 수 없어 멈췄습니다'),
style: TextStyle(fontWeight: FontWeight.w600, color: JjColors.alert),
),
),
]),
const SizedBox(height: 4),
Text(tr('대상 파일은 지우지 않았습니다.'), style: const TextStyle(fontSize: 12)),
Text(
e.empty
? trf('원본에는 아무것도 없는데 대상에는 {0}개가 있습니다. SD 카드가 빠졌거나 네트워크 · 권한 문제일 수 있습니다. '
'일부러 원본을 비웠다면 지울 목록을 보고 [그래도 맞추기] 를 누르세요.', [dels.length])
: tr('SD 카드가 꽂혀 있는지, 네트워크 (NAS · VPN) 가 연결되어 있는지, 폴더 권한이 있는지 확인한 뒤 [다시 시도] 를 누르세요.'),
style: const TextStyle(fontSize: 12),
),
const SizedBox(height: 4),
Text('${vDisplay(x.source)}${e.detail.isEmpty ? '' : ' · ${e.detail}'}',
style: TextStyle(fontSize: 11, color: JjColors.textDim)),
const SizedBox(height: 6),
Wrap(spacing: 8, runSpacing: 4, children: [
FilledButton.tonalIcon(
onPressed: running ? null : () => live.syncNow(x),
icon: const Icon(Icons.refresh, size: 18),
label: Text(tr('다시 시도')),
),
if (e.empty && dels.isNotEmpty)
OutlinedButton(
onPressed: running ? null : () => confirmEmptySourceSync(context, live, x),
child: Text(tr('지울 목록 보고 결정')),
),
]),
]),
);
}
}
/// 실시간 동기화 한 쌍의 카드
class LivePairCard extends StatelessWidget {
final AppController c;
final LiveSync? live;
final LiveSyncPair pair;
/// [최종 정리] 를 눌렀을 때
final VoidCallback onFinalize;
/// [복사 · rsync 로 이동] 으로 옮긴 뒤 (모니터링에서는 복사 · rsync 탭으로)
final VoidCallback onShowCopyList;
const LivePairCard(
{super.key, required this.c, required this.live, required this.pair, required this.onFinalize, required this.onShowCopyList});
@override
Widget build(BuildContext context) {
final live = this.live;
final x = pair;
final k = LiveSync.keyOf(x);
final pend = live?.pending[k];
final st = live?.status[k];
final active = LiveSync.activeNow(x);
final running = live?.isRunning(x) ?? false;
final held = live?.isPaused(x) ?? false;
// 257: 칩은 길게 누르기 · 마우스로 전체를 본다. 관리자 지시 (10/20, 28 지적): 좁은 화면 (폰 세로) 에서는 경로 줄에 끼면
// "rsync · 복…" 으로 잘려 경로 아래 따로 한 줄로 (넓으면 예전처럼 경로 옆)
final narrow = MediaQuery.sizeOf(context).width < 600;
final chipText = [tr(CopyMethod.of(x.method).label), tr('복사로 맞춤'), if (x.delete) tr('지우기 포함')].join(' · ');
final chip = Tooltip(
message: chipText,
child: Chip(
visualDensity: VisualDensity.compact,
// 243: 실시간 동기화는 늘 복사로 맞춘다 (원본은 지우지 않음 - 원본을 비우려면 [최종 정리])
label: Text(chipText, maxLines: 1, overflow: TextOverflow.ellipsis),
),
);
return Card(
color: JjColors.panel,
margin: const EdgeInsets.only(bottom: 10),
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 10, 10, 10),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Icon(
running
? Icons.sync
: !x.enabled
? Icons.sync_disabled
: held
? Icons.pause_circle_outline
: active
? Icons.sync
: Icons.bedtime_outlined,
color: x.enabled && !held ? (active ? JjColors.accent : JjColors.warning) : JjColors.textDim),
const SizedBox(width: 8),
Expanded(
// 67: 끝 폴더가 보이게 짧게 · 69: 길게 누르면 (오른쪽 클릭) 고치기 · 지우기
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onLongPressStart: (d) => showLivePairMenu(context, c, x, d.globalPosition),
onSecondaryTapDown: (d) => showLivePairMenu(context, c, x, d.globalPosition),
child: Tooltip(
// 길게 누르기는 메뉴에 (마우스를 올리면 전체 경로)
triggerMode: TooltipTriggerMode.manual,
message: '${vDisplay(x.source)}\n→ ${vDisplay(x.target)}',
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(shortPath(x.source), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.w600)),
Text('→ ${shortPath(x.target)}', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.w600)),
]),
),
),
),
// 257: 폰 세로에서 이 줄이 넘쳤다 - 칩은 남는 폭만큼 (넘치면 … 로 줄임, 전체는 길게 누르기 · 마우스)
if (!narrow) Flexible(child: chip),
// 이번 실행 동안 멈춤 / 시작 (설정의 켜기 · 끄기는 오른쪽 스위치)
if (live != null && x.enabled)
IconButton(
tooltip: held ? tr('시작') : tr('멈춤 (이번 실행 동안)'),
icon: Icon(held ? Icons.play_arrow : Icons.pause, color: held ? JjColors.accent : null),
onPressed: () => held ? live.resume(x) : live.pause(x),
),
Switch(value: x.enabled, onChanged: (v) => replaceLiveSyncPair(c, x, x.copyWith(enabled: v))),
]),
if (narrow) Padding(padding: const EdgeInsets.only(left: 32, top: 2), child: chip),
if (held)
Padding(
padding: const EdgeInsets.only(left: 32, top: 2),
child: Text(tr('멈춤 (이번 실행 동안) · 시작 버튼을 누르면 다시 동기화합니다'),
style: TextStyle(fontSize: 12, color: JjColors.warning)),
),
// 42: 지우기를 아직 확인하지 않았고 지울 것이 있다 → 지우지 않고 맞추는 중. 목록을 보고 결정
if (live != null && (live.toDelete[k]?.isNotEmpty ?? false))
Padding(
padding: const EdgeInsets.only(left: 32, top: 4),
child: Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [
Icon(Icons.warning_amber_rounded, size: 16, color: JjColors.warning),
Text(trf('대상에만 있는 {0}개를 지울지 확인이 필요합니다 (확인 전에는 지우지 않음)', [live.toDelete[k]!.length]),
style: TextStyle(fontSize: 12, color: JjColors.warning)),
OutlinedButton(
onPressed: () => confirmLiveDeletes(context, live, x),
child: Text(tr('지울 목록 보고 결정')),
),
]),
),
// P0 (관리자 3a 10/20): 미리 볼 수 없는 거르기 규칙 → 저절로 지우지 않고 보내기만. 지우기는 확인을 거쳐 한 번씩
if (live != null && live.noAutoDelete[k] != null)
Padding(
padding: const EdgeInsets.only(left: 32, top: 4),
child: Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [
Icon(Icons.block, size: 16, color: JjColors.alert),
Text(tr('이 옵션의 일부 거르기 규칙은 미리 볼 수 없어 자동으로 지우지 않습니다'),
style: TextStyle(fontSize: 12, color: JjColors.alert, fontWeight: FontWeight.w600)),
Text('(${live.noAutoDelete[k]!.map(unbreakableOption).join(' · ')})', style: TextStyle(fontSize: 12, color: JjColors.alert)),
OutlinedButton(
onPressed: () => confirmLiveDeleteNow(context, live, x),
child: Text(tr('지금 지우기 (확인)')),
),
]),
),
Padding(
padding: const EdgeInsets.only(left: 32, top: 4),
child: Wrap(spacing: 16, runSpacing: 6, crossAxisAlignment: WrapCrossAlignment.center, children: [
// 일정: 계속 / 시간 지정 (cron) + 작은 격자
InkWell(
onTap: () async {
final r = await editSchedule(context, x.schedule);
if (r != null) await replaceLiveSyncPair(c, x, x.copyWith(schedule: r));
},
borderRadius: BorderRadius.circular(6),
child: Padding(
padding: const EdgeInsets.all(4),
child: Row(mainAxisSize: MainAxisSize.min, children: [
WeekGridPreview(lines: x.schedule, width: 120),
const SizedBox(width: 8),
// 257: 폰 세로에서 글이 줄을 바꿀 수 있게 (넘쳤다)
Flexible(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
Text(scheduleSummary(x.schedule), style: TextStyle(fontSize: 12, color: active ? JjColors.accent : JjColors.warning)),
if (x.schedule.isNotEmpty)
Text(x.schedule.join(' | '), style: TextStyle(fontSize: 11, fontFamily: 'monospace', color: JjColors.textDim)),
Text(tr('누르면 일정 바꾸기'), style: TextStyle(fontSize: 10, color: JjColors.textDim)),
]),
),
]),
),
),
if (st != null)
Text('${tr('마지막')}: ${st.$1.hour.toString().padLeft(2, '0')}:${st.$1.minute.toString().padLeft(2, '0')} ${st.$2}',
style: TextStyle(fontSize: 12, color: live?.problems[k] != null ? JjColors.alert : JjColors.textDim)),
DiskSpaceInfo(path: x.target),
]),
),
// 68 · 70: 원본을 읽지 못함 / 원본이 비어 멈춤 - "맞출 것 없음" 대신 눈에 띄게, 할 일과 [다시 시도]
if (live?.problems[k] case final prob?)
Padding(padding: const EdgeInsets.only(left: 32, top: 8), child: LiveSourceProblemPanel(live: live!, pair: x, error: prob))
else
// 맞출 것 (바뀐 파일) - 자동으로 다시 셈
Padding(
padding: const EdgeInsets.only(left: 32, top: 8),
child: pend == null
? Text(tr('맞출 것을 세는 중…'), style: TextStyle(fontSize: 12, color: JjColors.textDim))
: pend.isEmpty
// 244: 대상에만 있는 것이 있으면 "같습니다" 가 아니라 사실대로
? Text(
(live?.onlyTarget[k] ?? 0) > 0
? trf('원본의 것은 모두 대상에 있습니다 (대상에만 있는 것 {0}개는 그대로 둠)', [live!.onlyTarget[k]])
: tr('맞출 것 없음 (원본과 대상이 같습니다)'),
style: TextStyle(fontSize: 12, color: JjColors.positive))
: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(
trf('맞출 것 {0}개{1}', [pend.length >= 500 ? '500+' : pend.length, active ? '' : tr(' · 다음 시작 시간에 맞춥니다')]),
style: TextStyle(fontSize: 12, color: JjColors.warning),
),
const SizedBox(height: 4),
Container(
constraints: const BoxConstraints(maxHeight: 140),
decoration: BoxDecoration(color: JjColors.bg, borderRadius: BorderRadius.circular(6)),
padding: const EdgeInsets.all(8),
child: ListView(shrinkWrap: true, children: [
for (final f in pend.take(100))
Text(f,
style: TextStyle(
fontSize: 11.5, fontFamily: 'monospace', color: f.startsWith('− ') ? JjColors.alert : null)),
if (pend.length > 100) Text(trf('… 외 {0}개', [pend.length - 100]), style: const TextStyle(fontSize: 11)),
]),
),
]),
),
const SizedBox(height: 6),
// 257: 폰 세로에서 [고치기] · [지우기] 가 화면 밖으로 밀려 누를 수 없었다 - 줄을 바꾼다 (오른쪽 정렬은 그대로)
Wrap(alignment: WrapAlignment.end, crossAxisAlignment: WrapCrossAlignment.center, spacing: 2, runSpacing: 2, children: [
TextButton.icon(
onPressed: live == null ? null : () => live.refreshPending(x),
icon: const Icon(Icons.refresh, size: 18),
label: Text(tr('다시 세기')),
),
FilledButton.icon(
onPressed: live == null || running ? null : () => live.syncNow(x),
icon: const Icon(Icons.sync, size: 18),
label: Text(tr('지금 맞추기')),
),
TextButton.icon(
onPressed: running ? null : onFinalize,
icon: const Icon(Icons.cleaning_services_outlined, size: 18),
label: Text(tr('최종 정리')),
),
TextButton.icon(
onPressed: () async {
await CopyCenter.of(c).fromLiveSync(x);
if (context.mounted) {
onShowCopyList();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(tr('복사 · rsync 목록으로 옮겼습니다.'))));
}
},
icon: const Icon(Icons.swap_horiz, size: 18),
label: Text(tr('복사 · rsync 로 이동')),
),
IconButton(
tooltip: tr('고치기'),
icon: const Icon(Icons.edit_outlined),
onPressed: () => editLiveSyncPairDialog(context, c, x),
),
IconButton(
tooltip: tr('지우기'),
icon: Icon(Icons.delete_outline, color: JjColors.textDim),
onPressed: () => removeLiveSyncPair(context, c, x),
),
]),
]),
),
);
}
}

View File

@ -0,0 +1,283 @@
/// 실시간 동기화 (lsync) 흐름과 확인 창. 화면 상태 없이 (context · AppController · LiveSync · 쌍) 만 받는다.
/// 약속:
/// - confirmLiveDeletes · confirmLiveDeleteNow · confirmEmptySourceSync: 지울 목록을 보이고, 고른 대로만
/// live.decideDelete / deleteNow / syncEmptyAnyway 를 부른다. 취소면 아무것도 하지 않음.
/// - askLiveSyncStart: 켜 둔 쌍 가운데 이번 실행에 시작할 것을 고르게 해 live.runOnly 로 시작.
/// - replaceLiveSyncPair: 같은 원본 · 대상 쌍만 바꾸고 순서는 그대로 저장.
/// - showLivePairMenu: 카드의 고치기 · 지우기 메뉴.
/// - finalizeLiveSync: 최종 정리 (rsync --remove-source-files + 빈 폴더 지우기). 작업이 시작되면 true.
/// rsync 를 못 찾거나 시작하지 못하면 SnackBar 로 알리고 false.
library;
import 'dart:io';
import 'package:flutter/material.dart';
import '../../app/app_controller.dart';
import '../../app/copy_center.dart';
import '../../app/live_sync.dart';
import '../../app/settings.dart';
import '../../core/vfs.dart';
import '../../l10n/tr.dart';
import '../copy_sync_settings.dart' show editLiveSyncPairDialog, removeLiveSyncPair;
import '../rsync_setup.dart';
import '../schedule_editor.dart';
import '../sync_preview_view.dart' show FilterLimitNote, confirmDeletingRun, keepOptionsTogether;
import '../theme.dart';
import 'transfer_widgets.dart';
/// 실시간 동기화 "지우기" 확인 창 (42): 대상에만 있어 지워질 항목을 보여 주고 [지우기 포함으로 맞추기] · [지우지 않기] · [나중에]
Future<void> confirmLiveDeletes(BuildContext context, LiveSync live, LiveSyncPair x) async {
final list = live.toDelete[LiveSync.keyOf(x)] ?? const <String>[];
if (list.isEmpty) return;
final pick = await _liveDeleteDialog(context, live, x, list, live.toDeleteExcluded[LiveSync.keyOf(x)] ?? const [], now: false);
if (pick == null) return;
await live.decideDelete(x, delete: pick);
}
/// P0 (관리자 3a 10/20): 미리 볼 수 없는 거르기 규칙이 있어 자동으로 지우지 않는 쌍의 [지금 지우기 (확인)].
/// 빨간 FilterLimitNote 와 [알고 실행합니다] 체크를 거쳐 이번 한 번만 지운다 (다음 자동 맞추기는 다시 보내기만)
Future<void> confirmLiveDeleteNow(BuildContext context, LiveSync live, LiveSyncPair x) async {
final (list, excl) = await live.previewDeletes(x);
if (!context.mounted) return;
final pick = await _liveDeleteDialog(context, live, x, list, excl, now: true);
if (pick == true) await live.deleteNow(x);
}
Future<bool?> _liveDeleteDialog(BuildContext context, LiveSync live, LiveSyncPair x, List<String> list, List<String> exclIn,
{required bool now}) {
// 관리자 3a 조건 (10/20): --delete-excluded 때문에 지워지는 것 (거른 것 · 거른 것이 든 폴더) 은 따로 묶어 알고 누르게
final excl = exclIn.toSet();
final plain = [for (final n in list) if (!excl.contains(n)) n];
final filtered = [for (final n in list) if (excl.contains(n)) n];
// P0: 미리 볼 수 없는 규칙 - 목록이 실제와 다를 수 있음을 빨갛게, 체크해야 [지우고 맞추기]
final limits = live.unsupportedOf(x);
var ack = false;
return showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, set) => AlertDialog(
title: Text(list.isEmpty ? tr('대상에서 지울까요?') : trf('대상에서 {0}개를 지울까요?', [list.length])),
content: SizedBox(
width: 560,
// 세로 폰에서도 목록 (특히 거른 것 묶음) 이 더 보이게 화면 높이를 따라 (360 ~ 520)
height: (MediaQuery.sizeOf(ctx).height * 0.55).clamp(360.0, 520.0),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
if (limits.isNotEmpty) ...[
FilterLimitNote(unsupported: limits, deletes: true, ack: ack, onAck: (v) => set(() => ack = v)),
const SizedBox(height: 4),
],
// 관리자 3a 판단 (10/20): 확인한 쌍인데 옵션이 바뀌어 지울 것이 늘어 다시 묻는 것
if (!now && live.reasked.contains(LiveSync.keyOf(x))) ...[
Text(tr('지우기를 확인한 뒤 rsync 옵션이 바뀌어 지울 것이 늘었습니다. 다시 확인해 주세요.'),
style: TextStyle(fontWeight: FontWeight.w600, color: JjColors.warning)),
const SizedBox(height: 6),
],
Text(trf('"{0}" 에는 없고 대상 "{1}" 에만 있는 항목입니다. 지우기 포함으로 맞추면 대상에서 지워집니다 (되돌릴 수 없음).',
[vDisplay(x.source), vDisplay(x.target)])),
const SizedBox(height: 8),
Expanded(
child: ListView(children: [
if (list.isEmpty)
Text(tr('미리 본 지울 것은 없습니다 - 위 규칙 때문에 실제로는 지워질 수 있습니다'),
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
for (final n in plain.take(500)) Text(n, style: const TextStyle(fontSize: 12, fontFamily: 'Consolas')),
if (plain.length > 500) Text(trf('… 외 {0}개', [plain.length - 500])),
if (filtered.isNotEmpty) ...[
if (plain.isNotEmpty) const SizedBox(height: 10),
Text(keepOptionsTogether(trf('거른 것 {0}개도 지워집니다 (--delete-excluded)', [filtered.length])),
style: TextStyle(fontWeight: FontWeight.w600, color: JjColors.danger)),
Text(keepOptionsTogether(tr('rsync 옵션에서 뺀 것 (--exclude 등) 이지만 --delete-excluded 가 있어 대상에서 지웁니다. 지키려면 옵션에서 --delete-excluded 를 빼세요.')),
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
const SizedBox(height: 4),
for (final n in filtered.take(500)) Text(n, style: TextStyle(fontSize: 12, fontFamily: 'Consolas', color: JjColors.danger)),
if (filtered.length > 500) Text(trf('… 외 {0}개', [filtered.length - 500])),
],
]),
),
]),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('나중에'))),
if (!now) TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('지우지 않기 (지우기 끄기)'))),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: JjColors.danger),
onPressed: limits.isNotEmpty && !ack ? null : () => Navigator.pop(ctx, true),
child: Text(list.isEmpty ? tr('지우고 맞추기') : trf('{0}개 지우고 맞추기', [list.length])),
),
],
),
),
);
}
/// 70: 원본이 비어 멈춘 쌍 - 지워질 목록을 보여 주고 [그래도 맞추기] 로 확인받는다 (42 와 같은 창)
Future<void> confirmEmptySourceSync(BuildContext context, LiveSync live, LiveSyncPair x) async {
final list = live.emptyDeletes[LiveSync.keyOf(x)] ?? const <String>[];
if (list.isEmpty) return;
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(trf('원본이 비어 있습니다. 대상에서 {0}개를 지울까요?', [list.length])),
content: SizedBox(
width: 560,
height: 360,
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(trf('"{0}" 이 비어 있어, 맞추면 대상 "{1}" 의 아래 항목이 모두 지워집니다 (되돌릴 수 없음). '
'원본을 일부러 비운 경우에만 누르세요.', [vDisplay(x.source), vDisplay(x.target)])),
const SizedBox(height: 8),
Expanded(
child: ListView(children: [
for (final n in list.take(500)) Text(n, style: const TextStyle(fontSize: 12, fontFamily: 'Consolas')),
if (list.length > 500) Text(trf('… 외 {0}개', [list.length - 500])),
]),
),
]),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: JjColors.danger),
onPressed: () => Navigator.pop(ctx, true),
child: Text(trf('그래도 맞추기 ({0}개 지움)', [list.length])),
),
],
),
);
if (ok == true) await live.syncEmptyAnyway(x);
}
/// 앱을 다시 켤 때 ([AppSettings.liveSyncOnStart] 'ask'): 어느 실시간 동기화를 시작할지 고르기.
/// 고르지 않은 것은 이번 실행 동안 멈춤 (모니터링 > lsync 에서 ▶ 로 시작).
Future<void> askLiveSyncStart(BuildContext context, LiveSync live) async {
final pairs = live.c.settings.liveSyncPairs.where((x) => x.enabled).toList();
if (pairs.isEmpty) return;
final pick = {for (final x in pairs) LiveSync.keyOf(x)};
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, set) => AlertDialog(
title: Text(tr('실시간 동기화 시작')),
content: SizedBox(
width: 520,
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(tr('시작할 동기화를 고르세요. 고르지 않은 것은 이번 실행 동안 멈춥니다 (모니터링 > lsync 에서 시작).'),
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
const SizedBox(height: 8),
Flexible(
child: ListView(shrinkWrap: true, children: [
for (final x in pairs)
CheckboxListTile(
dense: true,
contentPadding: EdgeInsets.zero,
value: pick.contains(LiveSync.keyOf(x)),
onChanged: (v) => set(() => v == true ? pick.add(LiveSync.keyOf(x)) : pick.remove(LiveSync.keyOf(x))),
title: Text('${vDisplay(x.source)} → ${vDisplay(x.target)}', maxLines: 2, overflow: TextOverflow.ellipsis),
subtitle: Text(scheduleSummary(x.schedule)),
),
]),
),
]),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('모두 나중에'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('고른 것 시작'))),
],
),
),
);
if (ok == true) live.runOnly(pairs.where((x) => pick.contains(LiveSync.keyOf(x))));
}
/// 같은 원본 · 대상 쌍만 [now] 로 바꿔 저장 (나머지 순서 그대로)
Future<void> replaceLiveSyncPair(AppController c, LiveSyncPair old, LiveSyncPair now) => c.updateSettings((x) => x.liveSyncPairs = [
for (final y in x.liveSyncPairs) y.source == old.source && y.target == old.target ? now : y,
]);
/// 69: 카드의 길게 누르기 · 오른쪽 클릭 메뉴
Future<void> showLivePairMenu(BuildContext context, AppController c, LiveSyncPair x, Offset at) async {
final pick = await showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(at.dx, at.dy, at.dx, at.dy),
items: [
PopupMenuItem(value: 'edit', child: Text(tr('고치기'))),
PopupMenuItem(value: 'remove', child: Text(tr('지우기'))),
],
);
if (!context.mounted) return;
if (pick == 'edit') {
await editLiveSyncPairDialog(context, c, x);
} else if (pick == 'remove') {
await removeLiveSyncPair(context, c, x);
}
}
/// 최종 정리: 원본의 것을 모두 대상으로 옮기고 (rsync -avHPOg --remove-source-files 원본/ 대상/)
/// 빈 폴더를 지운다 (find 원본/ -type d -empty -delete). 원본 폴더를 남길지 · 지울지만 고른다.
/// 원본이 비므로 이 실시간 동기화는 끈다 (켜 둔 채 "지우기 포함" 이면 대상까지 비워 버린다).
/// 작업이 시작되면 true (진행은 복사 · rsync 목록에서 봄 - 그쪽으로 넘기는 일은 부르는 쪽이 한다).
Future<bool> finalizeLiveSync(BuildContext context, AppController c, LiveSyncPair x) async {
final live = LiveSync.instance;
final messenger = ScaffoldMessenger.of(context);
if (live?.isRunning(x) ?? false) {
messenger.showSnackBar(SnackBar(content: Text(tr('지금 맞추는 중입니다. 끝난 뒤에 하세요.'))));
return false;
}
var prune = 'keep';
final go = await showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, set) => AlertDialog(
scrollable: true,
title: Text(tr('최종 정리')),
content: SizedBox(
width: 600,
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(tr('원본의 파일을 모두 대상으로 옮기고 (원본에서 지움), 원본에 남은 빈 폴더를 지웁니다.')),
const SizedBox(height: 10),
SelectableText(
'rsync -avHPOg --remove-source-files ${x.source}/ ${x.target}/\n'
'find ${x.source}/ -type d -empty -delete',
style: TextStyle(fontFamily: 'monospace', fontSize: 12, color: JjColors.textDim)),
const SizedBox(height: 14),
Text(tr('원본 폴더'), style: const TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 6),
PruneChoice(value: prune, onChanged: (v) => set(() => prune = v)),
const SizedBox(height: 12),
Text(tr('원본이 비므로 이 실시간 동기화는 끕니다. 진행은 [복사 · rsync] 탭에서 봅니다.'),
style: TextStyle(fontSize: 12, color: JjColors.warning)),
]),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('최종 정리'))),
],
),
),
);
if (go != true || !context.mounted) return false;
var exe = await rsyncExecutable(c.settings);
if (exe == null && Platform.isWindows && c.settings.rsyncSource == 'download' && context.mounted) {
exe = await installRsyncWithDialog(context);
}
if (exe == null) {
messenger.showSnackBar(SnackBar(content: Text(tr('rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > Rsync 에서 확인하세요.'))));
return false;
}
final center = CopyCenter.of(c);
var t = await center.remember([x.source], x.target, move: true, contents: true, method: 'rsync');
// 처음이면 정해진 옵션 (모니터링에서 고친 것이 있으면 그것)
t = t.copyWith(prune: prune, options: t.lastRun.isEmpty ? '-avHPOg' : null);
await center.update(t);
// 104: 고친 옵션에 지우기가 있으면 미리 보기를 거친다
if (!context.mounted || !await confirmDeletingRun(context, t) || !context.mounted) return false;
await replaceLiveSyncPair(c, x, x.copyWith(enabled: false));
c.note(trf('rsync 시작 ({0}): {1} → {2}', [tr('실시간 동기화의 [최종 정리]'), x.source, x.target]));
final job = await center.start(t, rsyncExe: exe);
if (!context.mounted) return false;
if (job == null) {
messenger.showSnackBar(SnackBar(content: Text(center.tasks.firstWhere((y) => y.id == t.id).lastMessage)));
return false;
}
return true;
}

View File

@ -0,0 +1,145 @@
/// 모니터링 공용 작은 부품 (복사 · rsync 카드와 lsync 카드가 함께 씀, 다른 화면도 씀).
/// 약속:
/// - formatRunStamp(iso): ISO 시각 → "M/D HH:MM". 날짜가 아니면 ''. 오류를 던지지 않음.
/// - DiskSpaceInfo(path): 경로 (없으면 있는 위 폴더) 의 디스크 남은 / 전체. 못 재면 "대상 디스크: 알 수 없음".
/// - TransferProgressLines(job): 작업의 진행 두 줄 (전체 · 지금 폴더). job 이 바뀌면 다시 그림.
/// - pruneLabel(v) · PruneChoice: 이동 뒤 원본 정리 값 '' · 'keep' · 'all' 의 이름과 고르기 칩.
/// 설정 · 화면 상태를 읽지 않는다.
library;
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path/path.dart' as p;
import '../../app/copy_center.dart';
import '../../app/transfer_job.dart';
import '../../core/file_ops.dart';
import '../../l10n/tr.dart';
import '../theme.dart';
/// 실행 시각 (ISO) 을 "월/일 시:분" 으로. 날짜가 아니면 빈 글.
String formatRunStamp(String iso) {
final d = DateTime.tryParse(iso);
if (d == null) return '';
String two(int x) => x.toString().padLeft(2, '0');
return '${d.month}/${d.day} ${two(d.hour)}:${two(d.minute)}';
}
/// 대상 디스크 남은 용량 / 전체
class DiskSpaceInfo extends StatelessWidget {
final String path;
const DiskSpaceInfo({super.key, required this.path});
@override
Widget build(BuildContext context) {
var dir = path;
while (!Directory(dir).existsSync() && p.dirname(dir) != dir) {
dir = p.dirname(dir);
}
return FutureBuilder<(int, int)?>(
future: diskSpace(dir),
builder: (_, s) {
final d = s.data;
if (d == null) return Text(tr('대상 디스크: 알 수 없음'), style: TextStyle(fontSize: 12, color: JjColors.textDim));
final (free, total) = d;
final used = total == 0 ? 0.0 : (total - free) / total;
return Row(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.storage, size: 14, color: JjColors.textDim),
const SizedBox(width: 4),
// 241 시험에서 찾음: 좁은 화면 (폭 500) 에서 넘쳤다 - 글을 줄인다
Flexible(
child: Text(trf('대상 디스크: 남은 {0} / 전체 {1}', [formatSize(free), formatSize(total)]),
maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12)),
),
const SizedBox(width: 8),
SizedBox(
width: 90,
child: ClipRRect(
borderRadius: BorderRadius.circular(3),
child: LinearProgressIndicator(
minHeight: 6,
value: used,
color: used > 0.9 ? JjColors.alert : JjColors.accent,
backgroundColor: JjColors.border,
),
),
),
]);
},
);
}
}
/// 진행 두 줄 (위: 전체 항목 · 아래: 지금 폴더의 파일)
class TransferProgressLines extends StatelessWidget {
final TransferJob job;
const TransferProgressLines({super.key, required this.job});
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: job,
builder: (_, _) {
final cur = (job.index < job.total ? job.index : job.total - 1).clamp(0, job.total - 1);
String pct(double v) => '${(v * 100).round()}%';
Widget line(String label, double v, String right) => Padding(
padding: const EdgeInsets.only(top: 4),
child: Row(children: [
SizedBox(width: 220, child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12))),
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(3),
child: LinearProgressIndicator(
minHeight: 7, value: job.counting ? null : v, color: JjColors.accent, backgroundColor: JjColors.border),
),
),
SizedBox(
width: 130,
child: Text(right, textAlign: TextAlign.end, style: TextStyle(fontSize: 12, color: JjColors.textDim))),
]),
);
final speed = !job.finished && job.speed != null ? '${formatSize(job.speed!.round())}/s' : '';
// 위: 지금 복사하는 파일 (그 파일의 진행률 · 속도) / 아래: 폴더 전체 (파일 수)
final fileLabel = job.counting
? tr('파일 세는 중…')
: job.pruning
? (job.finished ? trf('빈 폴더 {0}개 지움', [job.pruned]) : tr('원본의 빈 폴더 정리 중…'))
: job.finished
? tr('끝')
: job.currentFile.isEmpty
? tr('시작하는 중…')
: job.currentFile.split('/').last;
final fileValue = job.finished ? 1.0 : (job.filePercent ?? 0);
return Column(children: [
Tooltip(
message: job.currentFile,
child: line(fileLabel, fileValue,
[if (job.filePercent != null && !job.finished) pct(fileValue), if (speed.isNotEmpty) speed].join(' · ')),
),
line(trf('{0} 전체', [p.basename(job.sources[cur])]), job.folderProgress,
'${pct(job.folderProgress)} · ${job.checkTotal > 0 && !job.finished ? trf('항목 {0}/{1}', [job.checked, job.checkTotal]) : trf('파일 {0}/{1}', [job.doneFiles, job.allFiles])}'),
]);
},
);
}
/// 이동 (rsync --remove-source-files) 뒤 원본 정리 이름
String pruneLabel(String v) => switch (v) {
'keep' => tr('빈 폴더 지움 · 원본 폴더는 남김'),
'all' => tr('빈 폴더 지움 · 원본 폴더도 지움'),
_ => tr('빈 폴더 그대로'),
};
/// 원본 정리 고르기: 원본 폴더를 남길지 · 지울지 (빈 폴더는 지움 = find 원본/ -type d -empty -delete)
class PruneChoice extends StatelessWidget {
final String value;
final ValueChanged<String> onChanged;
final bool allowNone;
const PruneChoice({super.key, required this.value, required this.onChanged, this.allowNone = false});
@override
Widget build(BuildContext context) => Wrap(spacing: 6, runSpacing: 6, children: [
for (final v in [if (allowNone) '', 'keep', 'all'])
ChoiceChip(label: Text(pruneLabel(v)), selected: value == v, onSelected: (_) => onChanged(v)),
]);
}

File diff suppressed because it is too large Load Diff

View File

@ -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<String?> 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<void> 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;
}

View File

@ -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<void> Function(void Function(dynamic s) f) updateSettings;
final VoidCallback applySystemBars;
final VoidCallback upscalePage;
final VoidCallback qualityPage;
}
/// 위 막대의 버튼들 (앞의 것일수록 먼저 보이고, 못 들어가면 ⋮ 메뉴로). [context] 는 AI 창을 띄울 화면
List<ReaderBarItem> 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))),
];
}

View File

@ -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<ReaderAct> acts;
final int? seconds;
const ReaderBarItem(this.width, this.acts, {this.seconds});
}
/// [room] 너비에 앞에서부터 들어가는 칸 수. 모두 들어가지 않으면 ⋮ (48) 자리를 빼고 센다
int fitBarItems(List<ReaderBarItem> items, double room) {
if (items.fold<double>(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<ReaderBarItem> 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<int>(
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<int>(
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<int> 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<ReaderImage> Function(int page) thumb;
final ValueChanged<int> 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<ReaderImage>(
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));
},
),
),
),
),
);
}

View File

@ -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 = <int, TransformationController>{};
/// [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;
}

View File

@ -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<void> afterFrame() => WidgetsBinding.instance.endOfFrame;
/// X-plore 맞추기 (관리자 지시 10/20): 폴더 그림 [src] 의 지금 장 ([page] - 창을 띄울 때 읽음) 사진 정보 창.
/// 지금 장면이 끝난 뒤에 띄운다 (Windows 통합 시험에서 재현 4번 → 0번). 폴더 그림이 아니거나 볼 장이 없으면 아무것도 안 함
Future<void> 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<void> showPhotoInfoDialog(BuildContext context,
{required String path, required PhotoInfo? info, required int? size, required Object? error}) async {
var showLocation = false;
await showDialog<void>(
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<bool?> confirmPdfPageDelete(BuildContext context, int page) => showDialog<bool>(
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<String?> askSaveChanges(BuildContext context) => showDialog<String>(
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<List<String>> pickPdfImages(AppController c) =>
c.services.storage.pickFiles(title: tr('PDF 에 넣을 그림 선택'), extensions: c.settings.imageExts);

View File

@ -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 = <int, Future<ReaderImage>>{};
/// 반쪽으로 보는 장 (두 배 너비)
final _half = <int, Future<ReaderImage>>{};
/// 확대한 배율에 맞춰 더 크게 다시 그린 쪽 (쪽 번호 - 흐릿하지 않게)
final _sharp = <int, Future<ReaderImage>>{};
final _sharpWidth = <int, int>{};
/// 아래 갤러리 줄의 작은 그림 (장 번호)
final _thumbs = <int, Future<ReaderImage>>{};
static void _drop(Future<ReaderImage> f) => unawaited(f.then((x) => x.dispose(), onError: (_) {}));
/// [page] 장 통째 ([width] 너비). 처음 읽을 때만 [onLoaded] 를 부른다 (크기 알아 두기 등)
Future<ReaderImage> 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<ReaderImage> half(int page, int width) =>
_half.putIfAbsent(page, () => source.load(page, maxWidth: math.min(width * 2, 8192)));
/// [slot] 쪽을 확대해 다시 그린 것 (없으면 null)
Future<ReaderImage>? sharp(int slot) => _sharp[slot];
/// 작은 그림 (160 너비)
Future<ReaderImage> 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<Size?> 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<Size?>();
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;
}
}

View File

@ -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<ReaderImage> 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<bool> onZoomed;
final VoidCallback onZoomEnd;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
final half = slot.part >= 0;
// 확대해 다시 그린 것이 있으면 그것 (다 그릴 때까지는 앞의 그림을 그대로 보인다)
final page = FutureBuilder<ReaderImage>(
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<void> showReaderErrorDetail(BuildContext context, String detail) => showDialog<void>(
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('다시 시도')),
),
]));
}
}

View File

@ -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<int?> 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<bool> 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<bool> confirmPdfLeave(BuildContext context, PdfSource? pdf, Future<void> 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';
}

View File

@ -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<Size> 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<int, Size> 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<ReaderSlot> 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<ReaderSlot> v, int index) => v.isEmpty ? (page: 0, part: -1) : v[index.clamp(0, v.length - 1)];
/// [page] 장의 첫 쪽 번호 (없으면 -1)
int indexOfPage(List<ReaderSlot> v, int page) => v.indexWhere((e) => e.page == page);
/// 쪽 목록을 새로 만든 뒤 보던 쪽 [cur] 의 새 자리. 같은 반쪽 → 같은 장의 다른 모양 (통째 ↔ 첫 반쪽) →
/// 옛 번호 [index] (범위 안으로) 차례로 찾는다
int relocateSlot(List<ReaderSlot> 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<int> 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<String> paths, int curPage, bool Function(String path) missing) {
final gone = <int>{
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(<double>[s, 0, 0, 0, o, 0, s, 0, 0, o, 0, 0, s, 0, o, 0, 0, 0, 1, 0]);
}

View File

@ -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<int> onWidth;
final VoidCallback onPointerDown;
/// 손가락을 뗐다 (또는 취소)
final VoidCallback onPointerUp;
final void Function(TapUpDetails d, double width) onTapUp;
final ValueChanged<int> 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!),
]);
});
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,106 @@
// 모니터링 모듈 (lib/ui/monitor/) 의 약속 시험. 실제 AppController (settingsStore 없음 - 사용자 설정 저장소를 쓰지 않음) ·
// 실제 시험 폴더 · 실제 위젯으로 확인한다.
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/app/copy_center.dart';
import 'package:jj_mkvmaker/app/settings.dart';
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/monitor/copy_task_card.dart';
import 'package:jj_mkvmaker/ui/monitor/live_pair_card.dart';
import 'package:jj_mkvmaker/ui/monitor/live_sync_flow.dart';
import 'package:jj_mkvmaker/ui/monitor/transfer_widgets.dart';
import 'package:path/path.dart' as p;
AppController _controller() =>
AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
void main() {
late Directory tmp;
setUp(() => tmp = Directory.systemTemp.createTempSync('jj_monmod_'));
tearDown(() => tmp.deleteSync(recursive: true));
test('transfer_widgets: formatRunStamp 는 날짜면 "M/D HH:MM", 아니면 빈 글', () {
expect(formatRunStamp('2026-10-22T09:05:00'), '10/22 09:05');
expect(formatRunStamp('x'), '');
expect(formatRunStamp(''), '');
});
testWidgets('transfer_widgets: PruneChoice 는 고른 값을 돌려주고, allowNone 일 때만 "그대로" 칩', (tester) async {
String? got;
await tester.pumpWidget(MaterialApp(home: Scaffold(body: PruneChoice(value: 'keep', onChanged: (v) => got = v))));
expect(find.byType(ChoiceChip), findsNWidgets(2));
await tester.tap(find.text(pruneLabel('all')));
expect(got, 'all');
await tester.pumpWidget(MaterialApp(home: Scaffold(body: PruneChoice(value: '', allowNone: true, onChanged: (v) => got = v))));
expect(find.byType(ChoiceChip), findsNWidgets(3));
await tester.tap(find.text(pruneLabel('')));
expect(got, '');
});
testWidgets('transfer_widgets: DiskSpaceInfo 는 없는 경로면 있는 위 폴더의 디스크를 잰다', (tester) async {
await tester.pumpWidget(MaterialApp(home: Scaffold(body: DiskSpaceInfo(path: p.join(tmp.path, 'none', 'deeper')))));
await tester.runAsync(() => Future<void>.delayed(const Duration(seconds: 2)));
await tester.pump();
expect(find.textContaining('대상 디스크: 남은'), findsOneWidget);
});
test('live_sync_flow: replaceLiveSyncPair 는 같은 쌍만 바꾸고 순서는 그대로', () async {
final c = _controller();
final a = LiveSyncPair(p.join(tmp.path, 'a'), p.join(tmp.path, 'A'));
final b = LiveSyncPair(p.join(tmp.path, 'b'), p.join(tmp.path, 'B'));
final d = LiveSyncPair(p.join(tmp.path, 'd'), p.join(tmp.path, 'D'));
c.settings.liveSyncPairs = [a, b, d];
await replaceLiveSyncPair(c, b, b.copyWith(enabled: false));
expect(c.settings.liveSyncPairs.map((x) => x.source), [a.source, b.source, d.source]);
expect(c.settings.liveSyncPairs.map((x) => x.enabled), [true, false, true]);
});
testWidgets('copy_task_card: 작업이 없으면 안내, 있으면 작업마다 카드', (tester) async {
final c = _controller();
tester.view.physicalSize = const Size(1500, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(MaterialApp(home: Scaffold(body: CopyTaskList(c: c))));
expect(find.textContaining('실행한 rsync 가 없습니다'), findsOneWidget);
await CopyCenter.of(c).remember([tmp.path], p.join(tmp.path, '..'), method: 'builtin');
await tester.pump();
expect(find.byType(CopyTaskCard), findsOneWidget);
expect(find.text('lsync 로 이동'), findsOneWidget);
});
testWidgets('live_pair_card: 화면 일은 부르는 쪽 콜백으로 (최종 정리 · 복사 목록으로)', (tester) async {
final c = _controller();
final x = LiveSyncPair(tmp.path, p.join(tmp.path, 'x'), method: 'builtin');
c.settings.liveSyncPairs = [x];
tester.view.physicalSize = const Size(1500, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
var finalize = 0, shown = 0;
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: ListView(children: [
LivePairCard(c: c, live: null, pair: x, onFinalize: () => finalize++, onShowCopyList: () => shown++),
]),
),
));
// live 가 없으면 세기 · 맞추기는 꺼짐
expect(tester.widget<FilledButton>(find.ancestor(of: find.text('지금 맞추기'), matching: find.byWidgetPredicate((w) => w is FilledButton))).onPressed, isNull);
await tester.tap(find.text('최종 정리'));
expect(finalize, 1);
// 스위치는 replaceLiveSyncPair 로 저장
await tester.tap(find.byType(Switch));
await tester.pump();
expect(c.settings.liveSyncPairs.single.enabled, isFalse);
// 복사 목록으로 옮기면 lsync 쌍은 빠지고 복사 작업이 생긴 뒤 부르는 쪽 콜백
await tester.tap(find.text('복사 · rsync 로 이동'));
await tester.pumpAndSettle();
expect(shown, 1);
expect(c.settings.liveSyncPairs, isEmpty);
expect(c.settings.copyTasks.single.dest, x.target);
});
}

View File

@ -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());
});
}