Merge branch 'split_pages' into modular
commit
ef530e8450
|
|
@ -0,0 +1,425 @@
|
|||
/// 복사 · rsync 작업 목록과 카드 (모니터링 > 복사 · rsync 탭, 다른 화면도 그대로 넣어 씀).
|
||||
/// 약속:
|
||||
/// - CopyTaskList(c): CopyCenter.of(c) 의 작업을 카드로 보임. 없으면 안내 글. 작업 · 설정이 바뀌면 다시 그림.
|
||||
/// - CopyTaskCard(c, task): 한 작업의 옵션 고치기 · 저장 · 실행 · 멈춤 · 실패 원문 · lsync 로 옮기기.
|
||||
/// 카드가 고치던 칸은 카드 자신의 상태 (key 를 ValueKey(task.id) 로 주어야 목록이 바뀌어도 남음).
|
||||
/// 실패는 SnackBar · 창으로 알리고, CopyCenter.start 의 예외는 그대로 밖으로 나간다.
|
||||
/// 설정은 읽기만 한다 (copySkipNewer · rsyncSource · rsyncOptions · robocopyOptions).
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../app/app_controller.dart';
|
||||
import '../../app/copy_center.dart';
|
||||
import '../../app/live_sync.dart';
|
||||
import '../../app/settings.dart';
|
||||
import '../../core/download_detect.dart' show redactUrlSecrets;
|
||||
import '../../core/file_ops.dart';
|
||||
import '../../core/sync_tools.dart';
|
||||
import '../../core/vfs.dart';
|
||||
import '../../l10n/tr.dart';
|
||||
import '../path_label.dart';
|
||||
import '../rsync_setup.dart';
|
||||
import '../../core/sync_preview.dart' show optionsRemoveSource;
|
||||
import '../sync_preview_view.dart' show OptionProblemNote, OptionTextFormatter, confirmDeletingRun, effectiveRunText;
|
||||
import '../theme.dart';
|
||||
import 'transfer_widgets.dart';
|
||||
|
||||
class CopyTaskList extends StatelessWidget {
|
||||
final AppController c;
|
||||
const CopyTaskList({super.key, required this.c});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final center = CopyCenter.of(c);
|
||||
return ListenableBuilder(
|
||||
listenable: Listenable.merge([center, c]),
|
||||
builder: (context, _) {
|
||||
final tasks = center.tasks;
|
||||
if (tasks.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(tr('실행한 rsync 가 없습니다. Rsync 화면에서 → · ← · ⇄ 로 실행하면 여기에 자동으로 등록됩니다.'),
|
||||
textAlign: TextAlign.center, style: TextStyle(color: JjColors.textDim)),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
children: [for (final t in tasks) CopyTaskCard(key: ValueKey(t.id), c: c, task: t)],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CopyTaskCard extends StatefulWidget {
|
||||
final AppController c;
|
||||
final CopyTask task;
|
||||
const CopyTaskCard({super.key, required this.c, required this.task});
|
||||
|
||||
@override
|
||||
State<CopyTaskCard> createState() => _CopyTaskCardState();
|
||||
}
|
||||
|
||||
class _CopyTaskCardState extends State<CopyTaskCard> {
|
||||
CopyCenter get center => CopyCenter.of(widget.c);
|
||||
late String _method = widget.task.method;
|
||||
late final _options = TextEditingController(text: widget.task.options);
|
||||
late final _bw = TextEditingController(text: '${widget.task.bandwidthKBps}');
|
||||
late bool _once = widget.task.once;
|
||||
late String _prune = widget.task.prune;
|
||||
|
||||
bool get _dirty =>
|
||||
_prune != widget.task.prune ||
|
||||
_method != widget.task.method ||
|
||||
_options.text.trim() != widget.task.options ||
|
||||
(int.tryParse(_bw.text) ?? 0) != widget.task.bandwidthKBps ||
|
||||
_once != widget.task.once;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_options.dispose();
|
||||
_bw.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 237: 실패 원문 보기 · 복사
|
||||
Future<void> _showFailure(CopyTask t) {
|
||||
final detail = redactUrlSecrets(t.lastDetail);
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
scrollable: true,
|
||||
title: Text(t.lastMessage),
|
||||
content: SizedBox(
|
||||
width: 560,
|
||||
child: SelectableText(detail, style: const TextStyle(fontFamily: 'monospace', fontSize: 12)),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await Clipboard.setData(ClipboardData(text: detail));
|
||||
if (ctx.mounted) Navigator.pop(ctx);
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(tr('복사했습니다'))));
|
||||
},
|
||||
child: Text(tr('복사')),
|
||||
),
|
||||
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('닫기'))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _run(CopyTask t) async {
|
||||
// 236: 옵션 칸의 포커스를 푼다 - 남아 있으면 폰 · 태블릿에서 키보드가 올라와 끝 알림을 가린다
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
// 104: 지우기가 들어 있으면 Rsync 화면과 같은 미리 보기를 거친다 (그사이 대상에 생긴 파일을 묻지 않고 지우지 않게).
|
||||
// 210: 이동 (원본 지우기) 도 - 카드의 [실행] 은 따로 묻는 창이 없어 곧바로 원본을 지웠다. 복사만은 지금처럼 바로
|
||||
if (!await confirmDeletingRun(context, t, includeMove: true, skipNewer: widget.c.settings.copySkipNewer) || !mounted) return;
|
||||
widget.c.note(trf('rsync 시작 ({0}): {1} → {2}', [tr('모니터링의 [실행]'), t.sources.join(', '), t.dest]));
|
||||
String? exe;
|
||||
if (t.method == 'rsync') {
|
||||
exe = await rsyncExecutable(widget.c.settings);
|
||||
if (exe == null && Platform.isWindows && widget.c.settings.rsyncSource == 'download' && mounted) {
|
||||
exe = await installRsyncWithDialog(context);
|
||||
}
|
||||
if (exe == null) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(tr('rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > 파일 탐색기에서 경로를 확인하세요.'))));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
final problem = transferProblem(t.sources, t.dest, move: t.move, contents: t.contents);
|
||||
await center.start(t, rsyncExe: exe);
|
||||
if (problem != null && mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problem)));
|
||||
}
|
||||
|
||||
/// 칸에 보이는 (고친) 옵션으로 만든 작업
|
||||
CopyTask _edited() => widget.task.copyWith(
|
||||
method: _method,
|
||||
options: _options.text.trim(),
|
||||
bandwidthKBps: int.tryParse(_bw.text) ?? 0,
|
||||
once: _once,
|
||||
prune: _prune,
|
||||
);
|
||||
|
||||
/// 114: [실행] 을 눌렀는데 저장하지 않은 고침이 있으면 묻는다 (칸에 보이는 것과 다른 옵션으로 몰래 돌지 않게).
|
||||
/// 저장하고 실행하면 그 옵션으로 (지우기가 있으면 미리 보기 창)
|
||||
Future<void> _runPressed(CopyTask t) async {
|
||||
if (!_dirty) return _run(t);
|
||||
final pick = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
scrollable: true,
|
||||
actionsOverflowDirection: VerticalDirection.down,
|
||||
title: Text(tr('고친 옵션을 저장하지 않았습니다')),
|
||||
content: Text(trf('칸에 보이는 옵션: {0}\n저장된 옵션: {1}\n\n고친 옵션을 저장하고 실행할까요?', [
|
||||
'${_method == 'builtin' ? tr('현재 방식') : _method} ${_options.text.trim()}',
|
||||
// 178: 실제로 쓰는 옵션 (원본 지우기는 이동일 때만)
|
||||
effectiveRunText(t.method, t.options, move: t.move, skipNewer: widget.c.settings.copySkipNewer),
|
||||
])),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('취소'))),
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, 'old'), child: Text(tr('저장된 옵션으로 실행'))),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, 'save'), child: Text(tr('저장하고 실행'))),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (!mounted || pick == null) return;
|
||||
if (pick == 'old') return _run(t);
|
||||
final edited = _edited();
|
||||
await center.update(edited);
|
||||
if (mounted) await _run(edited);
|
||||
}
|
||||
|
||||
/// 옵션 저장 → 지금 실행할지 묻기 (현재 유지 / 반영 후 실행)
|
||||
Future<void> _save() async {
|
||||
final t = _edited();
|
||||
await center.update(t);
|
||||
if (!mounted) return;
|
||||
final running = center.isRunning(t.id);
|
||||
final go = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
scrollable: true,
|
||||
title: Text(tr('옵션을 저장했습니다')),
|
||||
content: Text(running
|
||||
// 233: 이동 카드는 "이동" 으로
|
||||
? (t.move
|
||||
? tr('지금 도는 이동은 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?')
|
||||
: tr('지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?'))
|
||||
: (t.move
|
||||
? tr('이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?')
|
||||
: tr('이 복사를 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?'))),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('현재 유지'))),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('설정 반영 후 실행'))),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (go != true) return;
|
||||
if (running) {
|
||||
final old = center.jobs[t.id]!;
|
||||
old.cancel();
|
||||
await old.done;
|
||||
}
|
||||
await _run(t);
|
||||
}
|
||||
|
||||
/// lsync 로: 끝나지 않은 것 (실행 중 · 실패 · 취소 · 아직 안 함) 도 옮길 수 있다 - 남은 것은 lsync 가 이어서 맞춘다.
|
||||
/// 실행 중이면 물어본 뒤 멈추고 옮긴다.
|
||||
Future<void> _toLive(CopyTask t) async {
|
||||
final running = center.isRunning(t.id);
|
||||
// 243: 이동 카드 - 실시간 동기화는 원본을 지우지 않는다 (107). 옮기기 전에 알린다
|
||||
if (running || t.move) {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
scrollable: true,
|
||||
title: Text(tr('lsync 로 이동')),
|
||||
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
if (running) Text(tr('지금 실행 중입니다. 멈추고 lsync (실시간 동기화) 로 옮길까요? 남은 것은 lsync 가 이어서 맞춥니다.')),
|
||||
if (running && t.move) const SizedBox(height: 10),
|
||||
if (t.move)
|
||||
Text(tr('실시간 동기화는 원본을 지우지 않습니다 (복사로 맞춤). 원본을 비우려면 lsync 의 [최종 정리] 를 쓰세요.'),
|
||||
style: TextStyle(color: JjColors.warning)),
|
||||
]),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(running ? tr('멈추고 옮기기') : tr('옮기기'))),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true || !mounted) return;
|
||||
}
|
||||
if (running) {
|
||||
final job = center.jobs[t.id]!;
|
||||
job.cancel();
|
||||
await job.done;
|
||||
if (!mounted) return;
|
||||
}
|
||||
final n = await center.toLiveSync(t);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(n == 0 ? tr('폴더만 lsync 로 옮길 수 있습니다.') : trf('{0}개 폴더를 lsync (실시간 동기화) 로 옮겼습니다.', [n]))));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = widget.task;
|
||||
final job = center.jobs[t.id];
|
||||
final running = job != null && !job.finished;
|
||||
final s = widget.c.settings;
|
||||
final (icon, color, status) = running
|
||||
? (Icons.sync, JjColors.accent, job.move ? tr('옮기는 중') : tr('복사하는 중'))
|
||||
: switch (t.lastResult) {
|
||||
// 235: 이동인데 원본에 남은 것이 있으면 "끝" 만 보이지 않게 (주황 · 남은 수)
|
||||
'done' when t.lastLeft > 0 => (
|
||||
Icons.warning_amber,
|
||||
JjColors.warning,
|
||||
'${trf('끝 · {0} · 파일 {1}개', [formatRunStamp(t.lastRun), t.lastFiles])} · ${trf('{0}개는 원본에 남음 (건너뜀)', [t.lastLeft])}'
|
||||
),
|
||||
'done' => (Icons.check_circle, JjColors.positive, trf('끝 · {0} · 파일 {1}개', [formatRunStamp(t.lastRun), t.lastFiles])),
|
||||
'failed' => (Icons.error, JjColors.alert, trf('실패 · {0}', [formatRunStamp(t.lastRun)])),
|
||||
'cancelled' => (Icons.pause_circle, JjColors.warning, trf('멈춤 · {0}', [formatRunStamp(t.lastRun)])),
|
||||
_ => (Icons.schedule, JjColors.textDim, tr('아직 실행 안 함')),
|
||||
};
|
||||
final optsHint = switch (CopyMethod.of(_method)) {
|
||||
CopyMethod.rsync => defaultRsyncOptions,
|
||||
CopyMethod.robocopy => defaultRobocopyOptions,
|
||||
CopyMethod.builtin => tr('(현재 방식은 옵션 없음)'),
|
||||
};
|
||||
return Card(
|
||||
color: JjColors.panel,
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 10, 10, 10),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
Icon(icon, color: color, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
// 112: 끝 폴더가 보이게 짧게, 전체 경로는 작게
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(
|
||||
t.sources.length == 1
|
||||
? shortPath(t.sources.first)
|
||||
: trf('{0} 외 {1}개', [shortPath(t.sources.first), t.sources.length - 1]),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
Text('→ ${shortPath(t.dest)}', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
Text('${vDisplay(t.sources.first)}${t.contents ? '/' : ''} → ${vDisplay(t.dest)}',
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 11, color: JjColors.textDim)),
|
||||
]),
|
||||
),
|
||||
Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
label: Text('${t.move ? tr('이동') : tr('복사')} · ${tr(CopyMethod.of(t.method).label)}'),
|
||||
),
|
||||
]),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 28, top: 2),
|
||||
child: Wrap(spacing: 16, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: [
|
||||
Text(status, style: TextStyle(fontSize: 12, color: color)),
|
||||
DiskSpaceInfo(path: t.dest),
|
||||
]),
|
||||
),
|
||||
if (t.lastResult == 'failed' && !running && t.lastMessage.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 28, top: 4),
|
||||
child: Wrap(crossAxisAlignment: WrapCrossAlignment.center, spacing: 4, children: [
|
||||
Text(t.lastMessage, maxLines: 3, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 11, color: JjColors.alert)),
|
||||
// 237: 원문 (예외 이름 · 명령 줄) 은 [자세히] 에서 - 보고 복사해 물어볼 수 있게 (다운로드 실패 줄 136 과 같게)
|
||||
if (t.lastDetail.trim().isNotEmpty)
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact, padding: const EdgeInsets.symmetric(horizontal: 6), minimumSize: const Size(0, 28)),
|
||||
onPressed: () => _showFailure(t),
|
||||
child: Text(tr('자세히')),
|
||||
),
|
||||
]),
|
||||
),
|
||||
if (job != null && (running || job.finished && job.error == null))
|
||||
Padding(padding: const EdgeInsets.only(left: 28, top: 6), child: TransferProgressLines(job: job)),
|
||||
const SizedBox(height: 8),
|
||||
// 옵션 (고치면 [저장] → 지금 실행할지 묻기)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 28),
|
||||
child: Wrap(spacing: 10, runSpacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [
|
||||
DropdownButton<String>(
|
||||
value: _method,
|
||||
items: [
|
||||
for (final m in CopyMethod.values)
|
||||
if (copyMethodAvailable(m, s) || m.name == _method) DropdownMenuItem(value: m.name, child: Text(tr(m.label))),
|
||||
],
|
||||
onChanged: (v) => setState(() {
|
||||
if (v != _method && _options.text.trim().isEmpty || _options.text.trim() == defaultRsyncOptions || _options.text.trim() == defaultRobocopyOptions) {
|
||||
_options.text = v == 'robocopy' ? s.robocopyOptions : v == 'rsync' ? s.rsyncOptions : '';
|
||||
}
|
||||
_method = v!;
|
||||
}),
|
||||
),
|
||||
SizedBox(
|
||||
width: 320,
|
||||
child: TextField(
|
||||
controller: _options,
|
||||
inputFormatters: const [OptionTextFormatter()],
|
||||
enabled: _method != 'builtin',
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
|
||||
decoration: InputDecoration(isDense: true, border: const OutlineInputBorder(), labelText: tr('옵션'), hintText: optsHint),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
),
|
||||
// 209: 실제로 실행할 것 - 이동 / 복사를 분명히 (칸의 원본 지우기 옵션은 이동일 때만 쓰인다)
|
||||
SizedBox(
|
||||
width: 460,
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
|
||||
Text(
|
||||
'${t.move ? tr('이동') : tr('복사')} · ${trf('실제 실행: {0}', [effectiveRunText(_method, _options.text.trim(), move: t.move, skipNewer: s.copySkipNewer)])}',
|
||||
style: TextStyle(fontSize: 12, color: t.move ? JjColors.warning : JjColors.textDim),
|
||||
),
|
||||
// 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 옵션 (실행은 작업이 막는다)
|
||||
if (_method == 'rsync' && optionProblems(_options.text).isNotEmpty) OptionProblemNote(optionProblems(_options.text)),
|
||||
if (!t.move && optionsRemoveSource(_options.text))
|
||||
Text(tr('복사 작업이라 칸의 원본 지우기 옵션 (--remove-source-files · /MOV) 은 쓰지 않습니다. 옮기려면 Rsync 화면에서 [원본 파일 지우기] 를 켜세요'),
|
||||
style: TextStyle(fontSize: 12, color: JjColors.warning)),
|
||||
]),
|
||||
),
|
||||
SizedBox(
|
||||
width: 130,
|
||||
child: TextField(
|
||||
controller: _bw,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
decoration: InputDecoration(isDense: true, border: const OutlineInputBorder(), labelText: tr('속도 제한'), suffixText: 'KB/s'),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
),
|
||||
if (t.sources.length > 1 && _method == 'rsync')
|
||||
FilterChip(label: Text(tr('한 번에')), selected: _once, onSelected: (v) => setState(() => _once = v)),
|
||||
// 이동 (원본 파일 지우기) 이면: 끝난 뒤 원본의 빈 폴더 · 원본 폴더 정리
|
||||
if (t.move && _method == 'rsync')
|
||||
DropdownButton<String>(
|
||||
value: _prune,
|
||||
items: [for (final v in ['', 'keep', 'all']) DropdownMenuItem(value: v, child: Text(pruneLabel(v)))],
|
||||
onChanged: (v) => setState(() => _prune = v!),
|
||||
),
|
||||
if (_dirty) FilledButton.tonal(onPressed: _save, child: Text(tr('저장'))),
|
||||
]),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
// 257 · 253: 폰 세로 · 글자가 크면 버튼이 화면 밖으로 넘치지 않게 줄을 바꾼다 (오른쪽 정렬은 그대로)
|
||||
Wrap(alignment: WrapAlignment.end, crossAxisAlignment: WrapCrossAlignment.center, spacing: 6, runSpacing: 4, children: [
|
||||
if (running)
|
||||
TextButton.icon(onPressed: () => center.cancel(t.id), icon: const Icon(Icons.stop, size: 18), label: Text(tr('취소')))
|
||||
else
|
||||
FilledButton.icon(
|
||||
// 관리자 지시 (10/20, 28 지적): 옵션이 막히면 (빨간 안내가 보이면) 확인 창처럼 [실행] 도 끈다 - 누르면 실패로 끝났다
|
||||
onPressed: _method == 'rsync' && optionProblems(_options.text).isNotEmpty ? null : () => _runPressed(t),
|
||||
icon: const Icon(Icons.play_arrow, size: 18),
|
||||
label: Text(t.lastResult == 'cancelled' || t.lastResult == 'failed' ? tr('재개') : tr('실행')),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () => _toLive(t),
|
||||
icon: const Icon(Icons.sync_alt, size: 18),
|
||||
label: Text(tr('lsync 로 이동')),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: tr('목록에서 지우기 (파일은 그대로)'),
|
||||
icon: Icon(Icons.delete_outline, color: JjColors.textDim),
|
||||
onPressed: () => center.remove(t.id),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,359 @@
|
|||
/// 실시간 동기화 (lsync) 카드 위젯 (모니터링 > lsync 탭, 다른 화면도 그대로 넣어 씀).
|
||||
/// 약속:
|
||||
/// - LivePairCard(c, live, pair, onFinalize, onShowCopyList): 쌍의 상태 · 일정 · 맞출 것 · 지우기 확인 · 단추.
|
||||
/// live 가 null 이면 (실시간 동기화 꺼짐) 세기 · 맞추기 단추를 끈다. 설정 저장은 replaceLiveSyncPair 로.
|
||||
/// 탭 이동 같은 화면 일은 하지 않고 onFinalize · onShowCopyList 로 부르는 쪽에 맡긴다.
|
||||
/// - LiveSourceProblemPanel(live, pair, error): 원본 문제 안내와 [다시 시도] · [지울 목록 보고 결정].
|
||||
/// 화면 상태를 읽지 않는다. 설정은 읽기만 (liveSyncPairs 쓰기는 live_sync_flow 를 거침).
|
||||
library;
|
||||
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../app/app_controller.dart';
|
||||
import '../../app/copy_center.dart';
|
||||
import '../../app/live_sync.dart';
|
||||
import '../../app/settings.dart';
|
||||
import '../../core/secret_gate.dart';
|
||||
import '../../core/file_ops.dart';
|
||||
import '../../core/sync_tools.dart';
|
||||
import '../../core/vfs.dart';
|
||||
import '../../l10n/tr.dart';
|
||||
import '../copy_sync_settings.dart' show editLiveSyncPairDialog, removeLiveSyncPair;
|
||||
import '../path_label.dart';
|
||||
import '../schedule_editor.dart';
|
||||
import '../sync_preview_view.dart' show unbreakableOption;
|
||||
import '../theme.dart';
|
||||
import 'live_sync_flow.dart';
|
||||
import 'transfer_widgets.dart';
|
||||
|
||||
/// 원본 문제 안내 (68 · 70): 원본을 읽지 못함 · 원본이 빔 · 마스터 비밀번호 기다림
|
||||
class LiveSourceProblemPanel extends StatelessWidget {
|
||||
final LiveSync live;
|
||||
final LiveSyncPair pair;
|
||||
final SourceUnreadableException error;
|
||||
const LiveSourceProblemPanel({super.key, required this.live, required this.pair, required this.error});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final x = pair;
|
||||
final e = error;
|
||||
final k = LiveSync.keyOf(x);
|
||||
final dels = live.emptyDeletes[k] ?? const <String>[];
|
||||
final running = live.isRunning(x);
|
||||
// 128: 마스터 비밀번호를 기다리는 중 - 고장이 아니라 넣으면 이어서 맞춘다
|
||||
if (e.locked) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 8),
|
||||
decoration: BoxDecoration(
|
||||
color: JjColors.accent.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: JjColors.accent.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
Icon(Icons.lock_clock_outlined, color: JjColors.accent, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(tr('마스터 비밀번호가 필요해 기다리는 중'),
|
||||
style: TextStyle(fontWeight: FontWeight.w600, color: JjColors.accent)),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 4),
|
||||
Text(tr('이 서버의 저장된 비밀번호를 쓰려면 마스터 비밀번호가 필요합니다. 넣으면 이어서 맞춥니다. 대상 파일은 건드리지 않았습니다.'),
|
||||
style: const TextStyle(fontSize: 12)),
|
||||
const SizedBox(height: 6),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: running
|
||||
? null
|
||||
: () async {
|
||||
if (await SecretGate.pass(force: true)) await live.syncNow(x);
|
||||
},
|
||||
icon: const Icon(Icons.lock_open, size: 18),
|
||||
label: Text(tr('마스터 비밀번호 넣기')),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 8),
|
||||
decoration: BoxDecoration(
|
||||
color: JjColors.alert.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: JjColors.alert.withValues(alpha: 0.6)),
|
||||
),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
Icon(e.empty ? Icons.folder_off_outlined : Icons.error_outline, color: JjColors.alert, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
e.empty ? tr('원본 폴더가 비어 있어 멈췄습니다') : tr('원본을 읽을 수 없어 멈췄습니다'),
|
||||
style: TextStyle(fontWeight: FontWeight.w600, color: JjColors.alert),
|
||||
),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 4),
|
||||
Text(tr('대상 파일은 지우지 않았습니다.'), style: const TextStyle(fontSize: 12)),
|
||||
Text(
|
||||
e.empty
|
||||
? trf('원본에는 아무것도 없는데 대상에는 {0}개가 있습니다. SD 카드가 빠졌거나 네트워크 · 권한 문제일 수 있습니다. '
|
||||
'일부러 원본을 비웠다면 지울 목록을 보고 [그래도 맞추기] 를 누르세요.', [dels.length])
|
||||
: tr('SD 카드가 꽂혀 있는지, 네트워크 (NAS · VPN) 가 연결되어 있는지, 폴더 권한이 있는지 확인한 뒤 [다시 시도] 를 누르세요.'),
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text('${vDisplay(x.source)}${e.detail.isEmpty ? '' : ' · ${e.detail}'}',
|
||||
style: TextStyle(fontSize: 11, color: JjColors.textDim)),
|
||||
const SizedBox(height: 6),
|
||||
Wrap(spacing: 8, runSpacing: 4, children: [
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: running ? null : () => live.syncNow(x),
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
label: Text(tr('다시 시도')),
|
||||
),
|
||||
if (e.empty && dels.isNotEmpty)
|
||||
OutlinedButton(
|
||||
onPressed: running ? null : () => confirmEmptySourceSync(context, live, x),
|
||||
child: Text(tr('지울 목록 보고 결정')),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// 실시간 동기화 한 쌍의 카드
|
||||
class LivePairCard extends StatelessWidget {
|
||||
final AppController c;
|
||||
final LiveSync? live;
|
||||
final LiveSyncPair pair;
|
||||
/// [최종 정리] 를 눌렀을 때
|
||||
final VoidCallback onFinalize;
|
||||
/// [복사 · rsync 로 이동] 으로 옮긴 뒤 (모니터링에서는 복사 · rsync 탭으로)
|
||||
final VoidCallback onShowCopyList;
|
||||
const LivePairCard(
|
||||
{super.key, required this.c, required this.live, required this.pair, required this.onFinalize, required this.onShowCopyList});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final live = this.live;
|
||||
final x = pair;
|
||||
final k = LiveSync.keyOf(x);
|
||||
final pend = live?.pending[k];
|
||||
final st = live?.status[k];
|
||||
final active = LiveSync.activeNow(x);
|
||||
final running = live?.isRunning(x) ?? false;
|
||||
final held = live?.isPaused(x) ?? false;
|
||||
// 257: 칩은 길게 누르기 · 마우스로 전체를 본다. 관리자 지시 (10/20, 28 지적): 좁은 화면 (폰 세로) 에서는 경로 줄에 끼면
|
||||
// "rsync · 복…" 으로 잘려 경로 아래 따로 한 줄로 (넓으면 예전처럼 경로 옆)
|
||||
final narrow = MediaQuery.sizeOf(context).width < 600;
|
||||
final chipText = [tr(CopyMethod.of(x.method).label), tr('복사로 맞춤'), if (x.delete) tr('지우기 포함')].join(' · ');
|
||||
final chip = Tooltip(
|
||||
message: chipText,
|
||||
child: Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
// 243: 실시간 동기화는 늘 복사로 맞춘다 (원본은 지우지 않음 - 원본을 비우려면 [최종 정리])
|
||||
label: Text(chipText, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
);
|
||||
return Card(
|
||||
color: JjColors.panel,
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 10, 10, 10),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
Icon(
|
||||
running
|
||||
? Icons.sync
|
||||
: !x.enabled
|
||||
? Icons.sync_disabled
|
||||
: held
|
||||
? Icons.pause_circle_outline
|
||||
: active
|
||||
? Icons.sync
|
||||
: Icons.bedtime_outlined,
|
||||
color: x.enabled && !held ? (active ? JjColors.accent : JjColors.warning) : JjColors.textDim),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
// 67: 끝 폴더가 보이게 짧게 · 69: 길게 누르면 (오른쪽 클릭) 고치기 · 지우기
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onLongPressStart: (d) => showLivePairMenu(context, c, x, d.globalPosition),
|
||||
onSecondaryTapDown: (d) => showLivePairMenu(context, c, x, d.globalPosition),
|
||||
child: Tooltip(
|
||||
// 길게 누르기는 메뉴에 (마우스를 올리면 전체 경로)
|
||||
triggerMode: TooltipTriggerMode.manual,
|
||||
message: '${vDisplay(x.source)}\n→ ${vDisplay(x.target)}',
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(shortPath(x.source), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
Text('→ ${shortPath(x.target)}', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 257: 폰 세로에서 이 줄이 넘쳤다 - 칩은 남는 폭만큼 (넘치면 … 로 줄임, 전체는 길게 누르기 · 마우스)
|
||||
if (!narrow) Flexible(child: chip),
|
||||
// 이번 실행 동안 멈춤 / 시작 (설정의 켜기 · 끄기는 오른쪽 스위치)
|
||||
if (live != null && x.enabled)
|
||||
IconButton(
|
||||
tooltip: held ? tr('시작') : tr('멈춤 (이번 실행 동안)'),
|
||||
icon: Icon(held ? Icons.play_arrow : Icons.pause, color: held ? JjColors.accent : null),
|
||||
onPressed: () => held ? live.resume(x) : live.pause(x),
|
||||
),
|
||||
Switch(value: x.enabled, onChanged: (v) => replaceLiveSyncPair(c, x, x.copyWith(enabled: v))),
|
||||
]),
|
||||
if (narrow) Padding(padding: const EdgeInsets.only(left: 32, top: 2), child: chip),
|
||||
if (held)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 32, top: 2),
|
||||
child: Text(tr('멈춤 (이번 실행 동안) · 시작 버튼을 누르면 다시 동기화합니다'),
|
||||
style: TextStyle(fontSize: 12, color: JjColors.warning)),
|
||||
),
|
||||
// 42: 지우기를 아직 확인하지 않았고 지울 것이 있다 → 지우지 않고 맞추는 중. 목록을 보고 결정
|
||||
if (live != null && (live.toDelete[k]?.isNotEmpty ?? false))
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 32, top: 4),
|
||||
child: Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [
|
||||
Icon(Icons.warning_amber_rounded, size: 16, color: JjColors.warning),
|
||||
Text(trf('대상에만 있는 {0}개를 지울지 확인이 필요합니다 (확인 전에는 지우지 않음)', [live.toDelete[k]!.length]),
|
||||
style: TextStyle(fontSize: 12, color: JjColors.warning)),
|
||||
OutlinedButton(
|
||||
onPressed: () => confirmLiveDeletes(context, live, x),
|
||||
child: Text(tr('지울 목록 보고 결정')),
|
||||
),
|
||||
]),
|
||||
),
|
||||
// P0 (관리자 3a 10/20): 미리 볼 수 없는 거르기 규칙 → 저절로 지우지 않고 보내기만. 지우기는 확인을 거쳐 한 번씩
|
||||
if (live != null && live.noAutoDelete[k] != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 32, top: 4),
|
||||
child: Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [
|
||||
Icon(Icons.block, size: 16, color: JjColors.alert),
|
||||
Text(tr('이 옵션의 일부 거르기 규칙은 미리 볼 수 없어 자동으로 지우지 않습니다'),
|
||||
style: TextStyle(fontSize: 12, color: JjColors.alert, fontWeight: FontWeight.w600)),
|
||||
Text('(${live.noAutoDelete[k]!.map(unbreakableOption).join(' · ')})', style: TextStyle(fontSize: 12, color: JjColors.alert)),
|
||||
OutlinedButton(
|
||||
onPressed: () => confirmLiveDeleteNow(context, live, x),
|
||||
child: Text(tr('지금 지우기 (확인)')),
|
||||
),
|
||||
]),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 32, top: 4),
|
||||
child: Wrap(spacing: 16, runSpacing: 6, crossAxisAlignment: WrapCrossAlignment.center, children: [
|
||||
// 일정: 계속 / 시간 지정 (cron) + 작은 격자
|
||||
InkWell(
|
||||
onTap: () async {
|
||||
final r = await editSchedule(context, x.schedule);
|
||||
if (r != null) await replaceLiveSyncPair(c, x, x.copyWith(schedule: r));
|
||||
},
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
WeekGridPreview(lines: x.schedule, width: 120),
|
||||
const SizedBox(width: 8),
|
||||
// 257: 폰 세로에서 글이 줄을 바꿀 수 있게 (넘쳤다)
|
||||
Flexible(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
|
||||
Text(scheduleSummary(x.schedule), style: TextStyle(fontSize: 12, color: active ? JjColors.accent : JjColors.warning)),
|
||||
if (x.schedule.isNotEmpty)
|
||||
Text(x.schedule.join(' | '), style: TextStyle(fontSize: 11, fontFamily: 'monospace', color: JjColors.textDim)),
|
||||
Text(tr('누르면 일정 바꾸기'), style: TextStyle(fontSize: 10, color: JjColors.textDim)),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
if (st != null)
|
||||
Text('${tr('마지막')}: ${st.$1.hour.toString().padLeft(2, '0')}:${st.$1.minute.toString().padLeft(2, '0')} ${st.$2}',
|
||||
style: TextStyle(fontSize: 12, color: live?.problems[k] != null ? JjColors.alert : JjColors.textDim)),
|
||||
DiskSpaceInfo(path: x.target),
|
||||
]),
|
||||
),
|
||||
// 68 · 70: 원본을 읽지 못함 / 원본이 비어 멈춤 - "맞출 것 없음" 대신 눈에 띄게, 할 일과 [다시 시도]
|
||||
if (live?.problems[k] case final prob?)
|
||||
Padding(padding: const EdgeInsets.only(left: 32, top: 8), child: LiveSourceProblemPanel(live: live!, pair: x, error: prob))
|
||||
else
|
||||
// 맞출 것 (바뀐 파일) - 자동으로 다시 셈
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 32, top: 8),
|
||||
child: pend == null
|
||||
? Text(tr('맞출 것을 세는 중…'), style: TextStyle(fontSize: 12, color: JjColors.textDim))
|
||||
: pend.isEmpty
|
||||
// 244: 대상에만 있는 것이 있으면 "같습니다" 가 아니라 사실대로
|
||||
? Text(
|
||||
(live?.onlyTarget[k] ?? 0) > 0
|
||||
? trf('원본의 것은 모두 대상에 있습니다 (대상에만 있는 것 {0}개는 그대로 둠)', [live!.onlyTarget[k]])
|
||||
: tr('맞출 것 없음 (원본과 대상이 같습니다)'),
|
||||
style: TextStyle(fontSize: 12, color: JjColors.positive))
|
||||
: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(
|
||||
trf('맞출 것 {0}개{1}', [pend.length >= 500 ? '500+' : pend.length, active ? '' : tr(' · 다음 시작 시간에 맞춥니다')]),
|
||||
style: TextStyle(fontSize: 12, color: JjColors.warning),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxHeight: 140),
|
||||
decoration: BoxDecoration(color: JjColors.bg, borderRadius: BorderRadius.circular(6)),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: ListView(shrinkWrap: true, children: [
|
||||
for (final f in pend.take(100))
|
||||
Text(f,
|
||||
style: TextStyle(
|
||||
fontSize: 11.5, fontFamily: 'monospace', color: f.startsWith('− ') ? JjColors.alert : null)),
|
||||
if (pend.length > 100) Text(trf('… 외 {0}개', [pend.length - 100]), style: const TextStyle(fontSize: 11)),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
// 257: 폰 세로에서 [고치기] · [지우기] 가 화면 밖으로 밀려 누를 수 없었다 - 줄을 바꾼다 (오른쪽 정렬은 그대로)
|
||||
Wrap(alignment: WrapAlignment.end, crossAxisAlignment: WrapCrossAlignment.center, spacing: 2, runSpacing: 2, children: [
|
||||
TextButton.icon(
|
||||
onPressed: live == null ? null : () => live.refreshPending(x),
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
label: Text(tr('다시 세기')),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: live == null || running ? null : () => live.syncNow(x),
|
||||
icon: const Icon(Icons.sync, size: 18),
|
||||
label: Text(tr('지금 맞추기')),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: running ? null : onFinalize,
|
||||
icon: const Icon(Icons.cleaning_services_outlined, size: 18),
|
||||
label: Text(tr('최종 정리')),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () async {
|
||||
await CopyCenter.of(c).fromLiveSync(x);
|
||||
if (context.mounted) {
|
||||
onShowCopyList();
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(tr('복사 · rsync 목록으로 옮겼습니다.'))));
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.swap_horiz, size: 18),
|
||||
label: Text(tr('복사 · rsync 로 이동')),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: tr('고치기'),
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
onPressed: () => editLiveSyncPairDialog(context, c, x),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: tr('지우기'),
|
||||
icon: Icon(Icons.delete_outline, color: JjColors.textDim),
|
||||
onPressed: () => removeLiveSyncPair(context, c, x),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,283 @@
|
|||
/// 실시간 동기화 (lsync) 흐름과 확인 창. 화면 상태 없이 (context · AppController · LiveSync · 쌍) 만 받는다.
|
||||
/// 약속:
|
||||
/// - confirmLiveDeletes · confirmLiveDeleteNow · confirmEmptySourceSync: 지울 목록을 보이고, 고른 대로만
|
||||
/// live.decideDelete / deleteNow / syncEmptyAnyway 를 부른다. 취소면 아무것도 하지 않음.
|
||||
/// - askLiveSyncStart: 켜 둔 쌍 가운데 이번 실행에 시작할 것을 고르게 해 live.runOnly 로 시작.
|
||||
/// - replaceLiveSyncPair: 같은 원본 · 대상 쌍만 바꾸고 순서는 그대로 저장.
|
||||
/// - showLivePairMenu: 카드의 고치기 · 지우기 메뉴.
|
||||
/// - finalizeLiveSync: 최종 정리 (rsync --remove-source-files + 빈 폴더 지우기). 작업이 시작되면 true.
|
||||
/// rsync 를 못 찾거나 시작하지 못하면 SnackBar 로 알리고 false.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../app/app_controller.dart';
|
||||
import '../../app/copy_center.dart';
|
||||
import '../../app/live_sync.dart';
|
||||
import '../../app/settings.dart';
|
||||
import '../../core/vfs.dart';
|
||||
import '../../l10n/tr.dart';
|
||||
import '../copy_sync_settings.dart' show editLiveSyncPairDialog, removeLiveSyncPair;
|
||||
import '../rsync_setup.dart';
|
||||
import '../schedule_editor.dart';
|
||||
import '../sync_preview_view.dart' show FilterLimitNote, confirmDeletingRun, keepOptionsTogether;
|
||||
import '../theme.dart';
|
||||
import 'transfer_widgets.dart';
|
||||
|
||||
/// 실시간 동기화 "지우기" 확인 창 (42): 대상에만 있어 지워질 항목을 보여 주고 [지우기 포함으로 맞추기] · [지우지 않기] · [나중에]
|
||||
Future<void> confirmLiveDeletes(BuildContext context, LiveSync live, LiveSyncPair x) async {
|
||||
final list = live.toDelete[LiveSync.keyOf(x)] ?? const <String>[];
|
||||
if (list.isEmpty) return;
|
||||
final pick = await _liveDeleteDialog(context, live, x, list, live.toDeleteExcluded[LiveSync.keyOf(x)] ?? const [], now: false);
|
||||
if (pick == null) return;
|
||||
await live.decideDelete(x, delete: pick);
|
||||
}
|
||||
|
||||
/// P0 (관리자 3a 10/20): 미리 볼 수 없는 거르기 규칙이 있어 자동으로 지우지 않는 쌍의 [지금 지우기 (확인)].
|
||||
/// 빨간 FilterLimitNote 와 [알고 실행합니다] 체크를 거쳐 이번 한 번만 지운다 (다음 자동 맞추기는 다시 보내기만)
|
||||
Future<void> confirmLiveDeleteNow(BuildContext context, LiveSync live, LiveSyncPair x) async {
|
||||
final (list, excl) = await live.previewDeletes(x);
|
||||
if (!context.mounted) return;
|
||||
final pick = await _liveDeleteDialog(context, live, x, list, excl, now: true);
|
||||
if (pick == true) await live.deleteNow(x);
|
||||
}
|
||||
|
||||
Future<bool?> _liveDeleteDialog(BuildContext context, LiveSync live, LiveSyncPair x, List<String> list, List<String> exclIn,
|
||||
{required bool now}) {
|
||||
// 관리자 3a 조건 (10/20): --delete-excluded 때문에 지워지는 것 (거른 것 · 거른 것이 든 폴더) 은 따로 묶어 알고 누르게
|
||||
final excl = exclIn.toSet();
|
||||
final plain = [for (final n in list) if (!excl.contains(n)) n];
|
||||
final filtered = [for (final n in list) if (excl.contains(n)) n];
|
||||
// P0: 미리 볼 수 없는 규칙 - 목록이 실제와 다를 수 있음을 빨갛게, 체크해야 [지우고 맞추기]
|
||||
final limits = live.unsupportedOf(x);
|
||||
var ack = false;
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, set) => AlertDialog(
|
||||
title: Text(list.isEmpty ? tr('대상에서 지울까요?') : trf('대상에서 {0}개를 지울까요?', [list.length])),
|
||||
content: SizedBox(
|
||||
width: 560,
|
||||
// 세로 폰에서도 목록 (특히 거른 것 묶음) 이 더 보이게 화면 높이를 따라 (360 ~ 520)
|
||||
height: (MediaQuery.sizeOf(ctx).height * 0.55).clamp(360.0, 520.0),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
if (limits.isNotEmpty) ...[
|
||||
FilterLimitNote(unsupported: limits, deletes: true, ack: ack, onAck: (v) => set(() => ack = v)),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
// 관리자 3a 판단 (10/20): 확인한 쌍인데 옵션이 바뀌어 지울 것이 늘어 다시 묻는 것
|
||||
if (!now && live.reasked.contains(LiveSync.keyOf(x))) ...[
|
||||
Text(tr('지우기를 확인한 뒤 rsync 옵션이 바뀌어 지울 것이 늘었습니다. 다시 확인해 주세요.'),
|
||||
style: TextStyle(fontWeight: FontWeight.w600, color: JjColors.warning)),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
Text(trf('"{0}" 에는 없고 대상 "{1}" 에만 있는 항목입니다. 지우기 포함으로 맞추면 대상에서 지워집니다 (되돌릴 수 없음).',
|
||||
[vDisplay(x.source), vDisplay(x.target)])),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: ListView(children: [
|
||||
if (list.isEmpty)
|
||||
Text(tr('미리 본 지울 것은 없습니다 - 위 규칙 때문에 실제로는 지워질 수 있습니다'),
|
||||
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||
for (final n in plain.take(500)) Text(n, style: const TextStyle(fontSize: 12, fontFamily: 'Consolas')),
|
||||
if (plain.length > 500) Text(trf('… 외 {0}개', [plain.length - 500])),
|
||||
if (filtered.isNotEmpty) ...[
|
||||
if (plain.isNotEmpty) const SizedBox(height: 10),
|
||||
Text(keepOptionsTogether(trf('거른 것 {0}개도 지워집니다 (--delete-excluded)', [filtered.length])),
|
||||
style: TextStyle(fontWeight: FontWeight.w600, color: JjColors.danger)),
|
||||
Text(keepOptionsTogether(tr('rsync 옵션에서 뺀 것 (--exclude 등) 이지만 --delete-excluded 가 있어 대상에서 지웁니다. 지키려면 옵션에서 --delete-excluded 를 빼세요.')),
|
||||
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||
const SizedBox(height: 4),
|
||||
for (final n in filtered.take(500)) Text(n, style: TextStyle(fontSize: 12, fontFamily: 'Consolas', color: JjColors.danger)),
|
||||
if (filtered.length > 500) Text(trf('… 외 {0}개', [filtered.length - 500])),
|
||||
],
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('나중에'))),
|
||||
if (!now) TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('지우지 않기 (지우기 끄기)'))),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(backgroundColor: JjColors.danger),
|
||||
onPressed: limits.isNotEmpty && !ack ? null : () => Navigator.pop(ctx, true),
|
||||
child: Text(list.isEmpty ? tr('지우고 맞추기') : trf('{0}개 지우고 맞추기', [list.length])),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 70: 원본이 비어 멈춘 쌍 - 지워질 목록을 보여 주고 [그래도 맞추기] 로 확인받는다 (42 와 같은 창)
|
||||
Future<void> confirmEmptySourceSync(BuildContext context, LiveSync live, LiveSyncPair x) async {
|
||||
final list = live.emptyDeletes[LiveSync.keyOf(x)] ?? const <String>[];
|
||||
if (list.isEmpty) return;
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(trf('원본이 비어 있습니다. 대상에서 {0}개를 지울까요?', [list.length])),
|
||||
content: SizedBox(
|
||||
width: 560,
|
||||
height: 360,
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(trf('"{0}" 이 비어 있어, 맞추면 대상 "{1}" 의 아래 항목이 모두 지워집니다 (되돌릴 수 없음). '
|
||||
'원본을 일부러 비운 경우에만 누르세요.', [vDisplay(x.source), vDisplay(x.target)])),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: ListView(children: [
|
||||
for (final n in list.take(500)) Text(n, style: const TextStyle(fontSize: 12, fontFamily: 'Consolas')),
|
||||
if (list.length > 500) Text(trf('… 외 {0}개', [list.length - 500])),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(backgroundColor: JjColors.danger),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: Text(trf('그래도 맞추기 ({0}개 지움)', [list.length])),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true) await live.syncEmptyAnyway(x);
|
||||
}
|
||||
|
||||
/// 앱을 다시 켤 때 ([AppSettings.liveSyncOnStart] 'ask'): 어느 실시간 동기화를 시작할지 고르기.
|
||||
/// 고르지 않은 것은 이번 실행 동안 멈춤 (모니터링 > lsync 에서 ▶ 로 시작).
|
||||
Future<void> askLiveSyncStart(BuildContext context, LiveSync live) async {
|
||||
final pairs = live.c.settings.liveSyncPairs.where((x) => x.enabled).toList();
|
||||
if (pairs.isEmpty) return;
|
||||
final pick = {for (final x in pairs) LiveSync.keyOf(x)};
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, set) => AlertDialog(
|
||||
title: Text(tr('실시간 동기화 시작')),
|
||||
content: SizedBox(
|
||||
width: 520,
|
||||
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(tr('시작할 동기화를 고르세요. 고르지 않은 것은 이번 실행 동안 멈춥니다 (모니터링 > lsync 에서 시작).'),
|
||||
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||
const SizedBox(height: 8),
|
||||
Flexible(
|
||||
child: ListView(shrinkWrap: true, children: [
|
||||
for (final x in pairs)
|
||||
CheckboxListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: pick.contains(LiveSync.keyOf(x)),
|
||||
onChanged: (v) => set(() => v == true ? pick.add(LiveSync.keyOf(x)) : pick.remove(LiveSync.keyOf(x))),
|
||||
title: Text('${vDisplay(x.source)} → ${vDisplay(x.target)}', maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Text(scheduleSummary(x.schedule)),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('모두 나중에'))),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('고른 것 시작'))),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (ok == true) live.runOnly(pairs.where((x) => pick.contains(LiveSync.keyOf(x))));
|
||||
}
|
||||
|
||||
/// 같은 원본 · 대상 쌍만 [now] 로 바꿔 저장 (나머지 순서 그대로)
|
||||
Future<void> replaceLiveSyncPair(AppController c, LiveSyncPair old, LiveSyncPair now) => c.updateSettings((x) => x.liveSyncPairs = [
|
||||
for (final y in x.liveSyncPairs) y.source == old.source && y.target == old.target ? now : y,
|
||||
]);
|
||||
|
||||
/// 69: 카드의 길게 누르기 · 오른쪽 클릭 메뉴
|
||||
Future<void> showLivePairMenu(BuildContext context, AppController c, LiveSyncPair x, Offset at) async {
|
||||
final pick = await showMenu<String>(
|
||||
context: context,
|
||||
position: RelativeRect.fromLTRB(at.dx, at.dy, at.dx, at.dy),
|
||||
items: [
|
||||
PopupMenuItem(value: 'edit', child: Text(tr('고치기'))),
|
||||
PopupMenuItem(value: 'remove', child: Text(tr('지우기'))),
|
||||
],
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
if (pick == 'edit') {
|
||||
await editLiveSyncPairDialog(context, c, x);
|
||||
} else if (pick == 'remove') {
|
||||
await removeLiveSyncPair(context, c, x);
|
||||
}
|
||||
}
|
||||
|
||||
/// 최종 정리: 원본의 것을 모두 대상으로 옮기고 (rsync -avHPOg --remove-source-files 원본/ 대상/)
|
||||
/// 빈 폴더를 지운다 (find 원본/ -type d -empty -delete). 원본 폴더를 남길지 · 지울지만 고른다.
|
||||
/// 원본이 비므로 이 실시간 동기화는 끈다 (켜 둔 채 "지우기 포함" 이면 대상까지 비워 버린다).
|
||||
/// 작업이 시작되면 true (진행은 복사 · rsync 목록에서 봄 - 그쪽으로 넘기는 일은 부르는 쪽이 한다).
|
||||
Future<bool> finalizeLiveSync(BuildContext context, AppController c, LiveSyncPair x) async {
|
||||
final live = LiveSync.instance;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
if (live?.isRunning(x) ?? false) {
|
||||
messenger.showSnackBar(SnackBar(content: Text(tr('지금 맞추는 중입니다. 끝난 뒤에 하세요.'))));
|
||||
return false;
|
||||
}
|
||||
var prune = 'keep';
|
||||
final go = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, set) => AlertDialog(
|
||||
scrollable: true,
|
||||
title: Text(tr('최종 정리')),
|
||||
content: SizedBox(
|
||||
width: 600,
|
||||
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(tr('원본의 파일을 모두 대상으로 옮기고 (원본에서 지움), 원본에 남은 빈 폴더를 지웁니다.')),
|
||||
const SizedBox(height: 10),
|
||||
SelectableText(
|
||||
'rsync -avHPOg --remove-source-files ${x.source}/ ${x.target}/\n'
|
||||
'find ${x.source}/ -type d -empty -delete',
|
||||
style: TextStyle(fontFamily: 'monospace', fontSize: 12, color: JjColors.textDim)),
|
||||
const SizedBox(height: 14),
|
||||
Text(tr('원본 폴더'), style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 6),
|
||||
PruneChoice(value: prune, onChanged: (v) => set(() => prune = v)),
|
||||
const SizedBox(height: 12),
|
||||
Text(tr('원본이 비므로 이 실시간 동기화는 끕니다. 진행은 [복사 · rsync] 탭에서 봅니다.'),
|
||||
style: TextStyle(fontSize: 12, color: JjColors.warning)),
|
||||
]),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('최종 정리'))),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (go != true || !context.mounted) return false;
|
||||
var exe = await rsyncExecutable(c.settings);
|
||||
if (exe == null && Platform.isWindows && c.settings.rsyncSource == 'download' && context.mounted) {
|
||||
exe = await installRsyncWithDialog(context);
|
||||
}
|
||||
if (exe == null) {
|
||||
messenger.showSnackBar(SnackBar(content: Text(tr('rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > Rsync 에서 확인하세요.'))));
|
||||
return false;
|
||||
}
|
||||
final center = CopyCenter.of(c);
|
||||
var t = await center.remember([x.source], x.target, move: true, contents: true, method: 'rsync');
|
||||
// 처음이면 정해진 옵션 (모니터링에서 고친 것이 있으면 그것)
|
||||
t = t.copyWith(prune: prune, options: t.lastRun.isEmpty ? '-avHPOg' : null);
|
||||
await center.update(t);
|
||||
// 104: 고친 옵션에 지우기가 있으면 미리 보기를 거친다
|
||||
if (!context.mounted || !await confirmDeletingRun(context, t) || !context.mounted) return false;
|
||||
await replaceLiveSyncPair(c, x, x.copyWith(enabled: false));
|
||||
c.note(trf('rsync 시작 ({0}): {1} → {2}', [tr('실시간 동기화의 [최종 정리]'), x.source, x.target]));
|
||||
final job = await center.start(t, rsyncExe: exe);
|
||||
if (!context.mounted) return false;
|
||||
if (job == null) {
|
||||
messenger.showSnackBar(SnackBar(content: Text(center.tasks.firstWhere((y) => y.id == t.id).lastMessage)));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
/// 모니터링 공용 작은 부품 (복사 · rsync 카드와 lsync 카드가 함께 씀, 다른 화면도 씀).
|
||||
/// 약속:
|
||||
/// - formatRunStamp(iso): ISO 시각 → "M/D HH:MM". 날짜가 아니면 ''. 오류를 던지지 않음.
|
||||
/// - DiskSpaceInfo(path): 경로 (없으면 있는 위 폴더) 의 디스크 남은 / 전체. 못 재면 "대상 디스크: 알 수 없음".
|
||||
/// - TransferProgressLines(job): 작업의 진행 두 줄 (전체 · 지금 폴더). job 이 바뀌면 다시 그림.
|
||||
/// - pruneLabel(v) · PruneChoice: 이동 뒤 원본 정리 값 '' · 'keep' · 'all' 의 이름과 고르기 칩.
|
||||
/// 설정 · 화면 상태를 읽지 않는다.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../../app/copy_center.dart';
|
||||
import '../../app/transfer_job.dart';
|
||||
import '../../core/file_ops.dart';
|
||||
import '../../l10n/tr.dart';
|
||||
import '../theme.dart';
|
||||
|
||||
/// 실행 시각 (ISO) 을 "월/일 시:분" 으로. 날짜가 아니면 빈 글.
|
||||
String formatRunStamp(String iso) {
|
||||
final d = DateTime.tryParse(iso);
|
||||
if (d == null) return '';
|
||||
String two(int x) => x.toString().padLeft(2, '0');
|
||||
return '${d.month}/${d.day} ${two(d.hour)}:${two(d.minute)}';
|
||||
}
|
||||
|
||||
/// 대상 디스크 남은 용량 / 전체
|
||||
class DiskSpaceInfo extends StatelessWidget {
|
||||
final String path;
|
||||
const DiskSpaceInfo({super.key, required this.path});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var dir = path;
|
||||
while (!Directory(dir).existsSync() && p.dirname(dir) != dir) {
|
||||
dir = p.dirname(dir);
|
||||
}
|
||||
return FutureBuilder<(int, int)?>(
|
||||
future: diskSpace(dir),
|
||||
builder: (_, s) {
|
||||
final d = s.data;
|
||||
if (d == null) return Text(tr('대상 디스크: 알 수 없음'), style: TextStyle(fontSize: 12, color: JjColors.textDim));
|
||||
final (free, total) = d;
|
||||
final used = total == 0 ? 0.0 : (total - free) / total;
|
||||
return Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Icon(Icons.storage, size: 14, color: JjColors.textDim),
|
||||
const SizedBox(width: 4),
|
||||
// 241 시험에서 찾음: 좁은 화면 (폭 500) 에서 넘쳤다 - 글을 줄인다
|
||||
Flexible(
|
||||
child: Text(trf('대상 디스크: 남은 {0} / 전체 {1}', [formatSize(free), formatSize(total)]),
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 90,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
child: LinearProgressIndicator(
|
||||
minHeight: 6,
|
||||
value: used,
|
||||
color: used > 0.9 ? JjColors.alert : JjColors.accent,
|
||||
backgroundColor: JjColors.border,
|
||||
),
|
||||
),
|
||||
),
|
||||
]);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 진행 두 줄 (위: 전체 항목 · 아래: 지금 폴더의 파일)
|
||||
class TransferProgressLines extends StatelessWidget {
|
||||
final TransferJob job;
|
||||
const TransferProgressLines({super.key, required this.job});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ListenableBuilder(
|
||||
listenable: job,
|
||||
builder: (_, _) {
|
||||
final cur = (job.index < job.total ? job.index : job.total - 1).clamp(0, job.total - 1);
|
||||
String pct(double v) => '${(v * 100).round()}%';
|
||||
Widget line(String label, double v, String right) => Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Row(children: [
|
||||
SizedBox(width: 220, child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12))),
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
child: LinearProgressIndicator(
|
||||
minHeight: 7, value: job.counting ? null : v, color: JjColors.accent, backgroundColor: JjColors.border),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 130,
|
||||
child: Text(right, textAlign: TextAlign.end, style: TextStyle(fontSize: 12, color: JjColors.textDim))),
|
||||
]),
|
||||
);
|
||||
final speed = !job.finished && job.speed != null ? '${formatSize(job.speed!.round())}/s' : '';
|
||||
// 위: 지금 복사하는 파일 (그 파일의 진행률 · 속도) / 아래: 폴더 전체 (파일 수)
|
||||
final fileLabel = job.counting
|
||||
? tr('파일 세는 중…')
|
||||
: job.pruning
|
||||
? (job.finished ? trf('빈 폴더 {0}개 지움', [job.pruned]) : tr('원본의 빈 폴더 정리 중…'))
|
||||
: job.finished
|
||||
? tr('끝')
|
||||
: job.currentFile.isEmpty
|
||||
? tr('시작하는 중…')
|
||||
: job.currentFile.split('/').last;
|
||||
final fileValue = job.finished ? 1.0 : (job.filePercent ?? 0);
|
||||
return Column(children: [
|
||||
Tooltip(
|
||||
message: job.currentFile,
|
||||
child: line(fileLabel, fileValue,
|
||||
[if (job.filePercent != null && !job.finished) pct(fileValue), if (speed.isNotEmpty) speed].join(' · ')),
|
||||
),
|
||||
line(trf('{0} 전체', [p.basename(job.sources[cur])]), job.folderProgress,
|
||||
'${pct(job.folderProgress)} · ${job.checkTotal > 0 && !job.finished ? trf('항목 {0}/{1}', [job.checked, job.checkTotal]) : trf('파일 {0}/{1}', [job.doneFiles, job.allFiles])}'),
|
||||
]);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 이동 (rsync --remove-source-files) 뒤 원본 정리 이름
|
||||
String pruneLabel(String v) => switch (v) {
|
||||
'keep' => tr('빈 폴더 지움 · 원본 폴더는 남김'),
|
||||
'all' => tr('빈 폴더 지움 · 원본 폴더도 지움'),
|
||||
_ => tr('빈 폴더 그대로'),
|
||||
};
|
||||
|
||||
/// 원본 정리 고르기: 원본 폴더를 남길지 · 지울지 (빈 폴더는 지움 = find 원본/ -type d -empty -delete)
|
||||
class PruneChoice extends StatelessWidget {
|
||||
final String value;
|
||||
final ValueChanged<String> onChanged;
|
||||
final bool allowNone;
|
||||
const PruneChoice({super.key, required this.value, required this.onChanged, this.allowNone = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Wrap(spacing: 6, runSpacing: 6, children: [
|
||||
for (final v in [if (allowNone) '', 'keep', 'all'])
|
||||
ChoiceChip(label: Text(pruneLabel(v)), selected: value == v, onSelected: (_) => onChanged(v)),
|
||||
]);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,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;
|
||||
}
|
||||
|
|
@ -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))),
|
||||
];
|
||||
}
|
||||
|
|
@ -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));
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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('다시 시도')),
|
||||
),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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());
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue