나누기: reader_page (1,151) 를 보기 화면 모듈 10개로 (→ 504)

- reader/reader_slots: 쪽 나누기 · 슬롯 계산 (순수)
- reader/reader_images: 그림 기억 (통째 · 반쪽 · 확대 · 작은 그림, dispose 를 맡음)
- reader/reader_controls: 계속 보기 시계 · 확대 · 볼륨 키 · 누른 자리
- reader/reader_page_view · reader_stage: 한 장 보기 · PageView 무대 (키 자리 그대로)
- reader/reader_bars · reader_bar_items: 위 · 아래 막대 · 갤러리 줄
- reader/reader_dialogs · reader_pdf_edit · reader_ai: Exif · PDF 편집 · AI
- reader_page 는 State 연결과 build 만, ReaderSlot · autoSplitsPage 는 export 로 그대로
- 약속 시험 test/reader_modules_test.dart (6개)
동작 그대로 (문구 · 툴팁 · 키 · 배치, 설정 이름 그대로). analyze 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
split_pages
RyuJongJin 2026-10-22 18:57:20 +09:00
parent 74987293bd
commit 95f9925ef0
12 changed files with 1575 additions and 796 deletions

View File

@ -0,0 +1,41 @@
// 보기 화면 AI 모듈: 이 장 해상도 올리기 (121) · 품질 올리기 · AI 그림 바탕 (123)
//
// 약속
// - 입력: 화면의 [BuildContext] (닫혔는지 context.mounted 로 본다) · 앱 ([AppController] - 설정 · 서비스) · 그림 원본 · 장 번호
// - 출력: [upscaleReaderPage] 는 올린 그림으로 바꿔 볼 경로 (설정이 '올린 것으로 보기' 이고 이 기기의 그림 파일일 때만, 아니면 null).
// [readerAiSource] 는 AI 그림의 바탕이 될 이 기기의 그림 경로 (없으면 null)
// - 오류: 파일을 못 꺼내거나 취소하면 null. AI 창 (ai_upscale_ui) 의 오류 처리를 그대로 쓴다
// 원본 · ZIP 은 그대로 두고 새 파일로 저장한다. 다른 보기 화면도 다시 쓸 수 있다.
import 'package:flutter/widgets.dart';
import '../../app/app_controller.dart';
import '../../core/reader_sources.dart';
import '../../core/vfs.dart';
import '../ai_upscale_ui.dart';
/// [page] 장의 해상도를 올려 새 파일로 저장한다. 저장한 뒤 올린 것으로 계속 볼 경로 (설정) - 이 기기의 그림 파일 목록일 때만
Future<String?> upscaleReaderPage(BuildContext context, AppController c, ReaderSource src, int page) async {
final file = await readerPageFile(c, src, page);
if (file == null || !context.mounted) return null;
final saved = await upscaleOne(context, c, input: file.$1, nameFrom: file.$2, outDir: file.$3);
if (saved == null || !context.mounted) return null;
final s = src;
if (c.settings.aiUpAfter == 'upscaled' && s is ImageFilesSource && !isDav(s.paths[page])) return saved;
return null;
}
/// [page] 장의 품질을 올려 새 파일로 저장한다
Future<void> qualityReaderPage(BuildContext context, AppController c, ReaderSource src, int page) async {
final file = await readerPageFile(c, src, page);
if (file == null || !context.mounted) return;
await qualityUp(context, c, input: file.$1, nameFrom: file.$2, outDir: file.$3);
}
/// 지금 보는 그림 파일 (이 기기의 그림 파일일 때, AI 그림 컴포넌트가 켜져 있을 때). [hasPages] 볼 장이 있는지
String? readerAiSource(AppController c, ReaderSource src, int page, {required bool hasPages}) {
final s = src;
if (!c.settings.components.contains('aiimage') || s is! ImageFilesSource || !hasPages) return null;
final path = s.paths[page];
return isDav(path) ? null : path;
}

View File

@ -0,0 +1,134 @@
// 보기 화면 위 막대의 버튼 목록 모듈
//
// 약속
// - 입력: 앱 ([AppController] - 설정을 읽고 바꿈) · 그림 원본 · 지금 상태 ([ReaderBarState]) · 누를 때 부를 일 ([ReaderBarHandlers])
// - 출력: 위 막대 칸 목록 ([ReaderBarItem]) - 앞의 것일수록 먼저 보이고, 못 들어가면 ⋮ 메뉴로 ([ReaderTopBar])
// - 오류: 던지지 않는다. 쓸 수 없는 버튼은 onTap 이 null (흐리게)
// 버튼의 순서 · 글 · 아이콘은 이 모듈 한 곳에서 정한다. 다른 보기 화면도 다시 쓸 수 있다.
import 'package:flutter/material.dart';
import '../../app/app_controller.dart';
import '../../core/reader_sources.dart';
import '../../l10n/tr.dart';
import '../ai_image_page.dart';
import '../ai_upscale_ui.dart';
import 'reader_bars.dart';
/// 버튼 모양을 정하는 지금 상태
typedef ReaderBarState = ({bool halfShown, bool playing, int turns, bool flipV, String? aiSource});
/// 버튼을 누를 때 부를 일 (화면이 준다)
class ReaderBarHandlers {
const ReaderBarHandlers({
required this.splitOff,
required this.play,
required this.setSeconds,
required this.save,
required this.deletePage,
required this.insertImages,
required this.photoInfo,
required this.toggleGallery,
required this.cycleSplit,
required this.rotate,
required this.toggleFlip,
required this.updateSettings,
required this.applySystemBars,
required this.upscalePage,
required this.qualityPage,
});
final VoidCallback splitOff;
final void Function(bool on) play;
final void Function(int d) setSeconds;
final VoidCallback save;
final VoidCallback deletePage;
final VoidCallback insertImages;
final VoidCallback photoInfo;
final VoidCallback toggleGallery;
final VoidCallback cycleSplit;
final void Function(int d) rotate;
final VoidCallback toggleFlip;
/// 설정을 바꾸고 화면을 다시 그린다
final Future<void> Function(void Function(dynamic s) f) updateSettings;
final VoidCallback applySystemBars;
final VoidCallback upscalePage;
final VoidCallback qualityPage;
}
/// 위 막대의 버튼들 (앞의 것일수록 먼저 보이고, 못 들어가면 ⋮ 메뉴로). [context] 는 AI 창을 띄울 화면
List<ReaderBarItem> readerBarItems(BuildContext context, AppController c, ReaderSource src, ReaderBarState st,
ReaderBarHandlers on) {
final s = c.settings;
final pdf = src is PdfSource ? src : null;
final pictures = src is ImageFilesSource || src is ZipImagesSource;
final aiOn = c.settings.components.contains('aiimage');
final playing = st.playing;
final secs = s.readerAutoSeconds;
ReaderBarItem one(ReaderAct a) => ReaderBarItem(48, [a]);
final split = switch (s.readerSplit) {
'on' => tr('두 쪽 나눠 보기: 켜기 (누르면 끄기)'),
'off' => tr('두 쪽 나눠 보기: 끄기 (누르면 자동)'),
_ => tr('두 쪽 나눠 보기: 자동 - 두 쪽 스캔으로 보이는 그림만 (누르면 켜기)'),
};
return [
// 223: 나눠 보고 있으면 바로 한 장으로 (자동이 사진을 잘라 보인다고 여기지 않게 맨 앞에)
if (st.halfShown) one(ReaderAct(const Icon(Icons.crop_original), tr('두 쪽 나눔 끄기 (한 장으로 보기)'), on.splitOff)),
// 117: 계속 보기 · 간격 (▲ 늘리기 · ▼ 줄이기)
one(ReaderAct(Icon(playing ? Icons.pause : Icons.play_arrow),
playing ? tr('계속 보기 멈추기') : trf('계속 보기 ({0}초마다 다음 장)', [secs]), () => on.play(!playing),
on: playing)),
ReaderBarItem(112, [
ReaderAct(const Icon(Icons.keyboard_arrow_up), trf('계속 보기 간격 늘리기 (지금 {0}초)', [secs]),
secs < 60 ? () => on.setSeconds(1) : null),
ReaderAct(const Icon(Icons.keyboard_arrow_down), trf('계속 보기 간격 줄이기 (지금 {0}초)', [secs]),
secs > 1 ? () => on.setSeconds(-1) : null),
], seconds: secs),
if (pdf != null) ...[
one(ReaderAct(const Icon(Icons.save_outlined), tr('저장'), pdf.dirty ? on.save : null)),
one(ReaderAct(const Icon(Icons.delete_outline), tr('이 페이지 지우기'), on.deletePage)),
one(ReaderAct(const Icon(Icons.add_photo_alternate_outlined), tr('그림을 페이지로 넣기 (이 페이지 뒤)'), on.insertImages)),
],
// X-plore 맞추기 (관리자 지시 10/20): 사진 정보 (Exif) - 폴더의 그림만
if (src is ImageFilesSource) one(ReaderAct(const Icon(Icons.info_outline), tr('사진 정보 (Exif)'), on.photoInfo)),
// X-plore 맞추기 (관리자 지시 10/20): 아래 갤러리 줄 켜고 끄기 (그림 원본만)
if (pictures)
one(ReaderAct(const Icon(Icons.view_carousel_outlined), s.readerGallery ? tr('아래 갤러리 줄 숨기기') : tr('아래 갤러리 줄 보이기'),
on.toggleGallery, on: s.readerGallery)),
// 119 · 120
one(ReaderAct(const Icon(Icons.vertical_split_outlined), split, on.cycleSplit, on: s.readerSplit != 'off')),
one(ReaderAct(const Icon(Icons.rotate_right), tr('90° 회전 (시계 방향)'), () => on.rotate(1), on: st.turns != 0)),
one(ReaderAct(const Icon(Icons.rotate_left), tr('반대로 90° 회전'), () => on.rotate(3), on: st.turns != 0)),
one(ReaderAct(const RotatedBox(quarterTurns: 1, child: Icon(Icons.flip)), tr('상하 반전'), on.toggleFlip, on: st.flipV)),
one(ReaderAct(const Icon(Icons.fit_screen_outlined), tr('한 쪽 맞추기'), () => on.updateSettings((x) => x.readerFit = 'page'),
on: s.readerFit == 'page')),
one(ReaderAct(const Icon(Icons.width_full_outlined), tr('좌우 맞추기 (세로로 밀어 봄)'),
() => on.updateSettings((x) => x.readerFit = 'width'), on: s.readerFit == 'width')),
one(ReaderAct(const Icon(Icons.swap_horiz),
s.readerRtl ? tr('넘기는 방향: 오른쪽 → 왼쪽 (만화)') : tr('넘기는 방향: 왼쪽 → 오른쪽'),
() => on.updateSettings((x) => x.readerRtl = !x.readerRtl), on: s.readerRtl)),
one(ReaderAct(const Icon(Icons.brightness_low), tr('어둡게'),
() => on.updateSettings((x) => x.readerBrightness = (x.readerBrightness - 0.1).clamp(-0.7, 0.7)))),
one(ReaderAct(const Icon(Icons.brightness_high), tr('밝게'),
() => on.updateSettings((x) => x.readerBrightness = (x.readerBrightness + 0.1).clamp(-0.7, 0.7)))),
// 30: 시계 · 알림 · 뒤로 막대를 그대로 / 화면 가득
one(ReaderAct(Icon(s.readerSystemBars ? Icons.fullscreen : Icons.fullscreen_exit),
s.readerSystemBars ? tr('화면 가득 (시스템 막대 숨기기)') : tr('시스템 막대 보이기 (시계 · 알림 · 뒤로)'), () async {
await on.updateSettings((x) => x.readerSystemBars = !x.readerSystemBars);
on.applySystemBars();
}, on: s.readerSystemBars)),
// AI 는 막대가 좁으면 먼저 ⋮ 로 (늘 쓰는 버튼이 막대에 남게)
// 121: AI 해상도 올리기 (이 장 · 모든 장) - 새 파일로 저장, 원본 · ZIP 은 그대로
if (aiOn && src is! PdfSource) ...[
one(ReaderAct(const Icon(Icons.hd_outlined), tr('AI 해상도 올리기 (이 장)'), on.upscalePage)),
one(ReaderAct(const Icon(Icons.burst_mode_outlined), tr('AI 해상도 올리기 (모든 장)'), () => upscaleAll(context, c, src))),
// 관리자 판단 (10/17): [품질 올리기] - 설정으로 켰을 때만
if (c.settings.aiQualityButton) one(ReaderAct(const Icon(Icons.auto_fix_high), tr('품질 올리기 (이 장)'), on.qualityPage)),
],
// 123: 이 그림을 바탕으로 AI 그림 (그림 → 그림, 원본은 그대로)
if (st.aiSource case final path?)
one(ReaderAct(const Icon(Icons.auto_awesome_outlined), tr('AI 그림으로 (이 그림을 바탕으로)'),
() => AiImagePage.open(Navigator.of(context), c: c, initImage: path))),
];
}

View File

@ -0,0 +1,269 @@
// 보기 화면 막대 모듈: 위 막대 (넘치면 ⋮) · 아래 막대 (쪽 번호 · 밀대 · 쪽 이름) · 갤러리 줄
//
// 약속
// - 입력: 버튼 목록 ([ReaderBarItem] - 앞의 것일수록 먼저 보임) · 제목 · 쪽 수 · 지금 쪽 · 넘기는 방향 · 작은 그림 (Future)
// - 출력: 막대 위젯. 누름은 받은 콜백 ([ReaderAct.onTap] · onSlide · onTap) 으로만 알린다
// - 오류: 던지지 않는다. 작은 그림을 못 읽으면 깨진 그림 아이콘
// 다른 보기 화면도 다시 쓸 수 있다.
import 'package:flutter/material.dart';
import '../../core/reader_sources.dart';
import '../../l10n/tr.dart';
import '../theme.dart';
/// 위 · 아래 막대 색 (그림 위 · 검은 여백 위 어디서나 같은 띠로 보이게 거의 불투명)
const readerBarColor = Color(0xF0222428);
/// 갤러리 줄의 한 칸 너비
const readerThumbExtent = 64.0;
/// 위 막대 버튼 하나 (⋮ 메뉴에서는 [icon] 과 [tip] 으로 한 줄)
class ReaderAct {
final Widget icon;
final String tip;
final VoidCallback? onTap;
final bool on;
const ReaderAct(this.icon, this.tip, this.onTap, {this.on = false});
}
/// 위 막대의 한 칸 ([width] 만큼 자리를 차지). [seconds] 가 있으면 ▲ · 초 · ▼ 묶음
class ReaderBarItem {
final double width;
final List<ReaderAct> acts;
final int? seconds;
const ReaderBarItem(this.width, this.acts, {this.seconds});
}
/// [room] 너비에 앞에서부터 들어가는 칸 수. 모두 들어가지 않으면 ⋮ (48) 자리를 빼고 센다
int fitBarItems(List<ReaderBarItem> items, double room) {
if (items.fold<double>(0, (a, x) => a + x.width) <= room) return items.length;
var used = 48.0; // ⋮
var shown = 0;
while (shown < items.length && used + items[shown].width <= room) {
used += items[shown].width;
shown++;
}
return shown;
}
/// 막대 위의 버튼 하나
Widget readerBarButton(ReaderAct a, {bool compact = false}) => IconButton(
tooltip: a.tip,
// 막대는 밝은 화면에서도 어두운 띠 - 강조는 어두운 화면의 청록 (밝은 화면의 진한 청록은 어두운 띠 위에서 안 보임)
color: a.on ? JjPalette.dark.accent : Colors.white,
disabledColor: Colors.white38,
icon: a.icon,
visualDensity: compact ? VisualDensity.compact : null,
constraints: compact ? const BoxConstraints(minWidth: 36, minHeight: 40) : null,
padding: compact ? EdgeInsets.zero : null,
onPressed: a.onTap,
);
/// 위 막대: [닫기] · 제목 · 버튼 (오른쪽 끝, 못 들어간 것은 ⋮ 메뉴 - 없애지 않는다)
class ReaderTopBar extends StatelessWidget {
const ReaderTopBar({super.key, required this.title, required this.items});
final String title;
/// 버튼들 (그릴 때마다 부른다)
final List<ReaderBarItem> Function() items;
@override
Widget build(BuildContext context) {
return Container(
// 113: 반투명 검정은 그림 위에서만 보이고 옆의 검은 여백에서는 안 보여 막대가 갈라져 보였다 → 거의 불투명한 어두운 회색 한 줄
color: readerBarColor,
padding: EdgeInsets.only(top: MediaQuery.paddingOf(context).top),
child: SizedBox(
height: 52,
child: LayoutBuilder(builder: (context, box) {
final items = this.items();
// 닫기 · 제목 (조금이라도) 을 뺀 자리에 들어가는 만큼 앞에서부터, 나머지는 ⋮ 메뉴 (없애지 않는다)
final shown = fitBarItems(items, box.maxWidth - 48 - 72);
final rest = [for (final x in items.skip(shown)) ...x.acts];
return Row(children: [
readerBarButton(ReaderAct(const Icon(Icons.arrow_back), tr('닫기'), () => Navigator.maybePop(context))),
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white, fontSize: 14),
),
),
// 버튼은 오른쪽 끝에 붙인다 (113)
for (final x in items.take(shown))
if (x.seconds != null)
Row(mainAxisSize: MainAxisSize.min, children: [
readerBarButton(x.acts[0], compact: true),
SizedBox(
width: 40,
child: Text(trf('{0}초', [x.seconds!]),
textAlign: TextAlign.center, style: const TextStyle(color: Colors.white, fontSize: 13)),
),
readerBarButton(x.acts[1], compact: true),
])
else
readerBarButton(x.acts.single),
if (rest.isNotEmpty)
PopupMenuButton<int>(
tooltip: tr('더 보기'),
icon: const Icon(Icons.more_vert, color: Colors.white),
onSelected: (k) => rest[k].onTap?.call(),
itemBuilder: (_) => [
for (final (k, a) in rest.indexed)
PopupMenuItem<int>(
value: k,
enabled: a.onTap != null,
child: Row(children: [
IconTheme.merge(data: IconThemeData(color: a.on ? JjColors.accent : null), child: a.icon),
const SizedBox(width: 12),
Flexible(child: Text(a.tip)),
]),
),
],
),
]);
}),
),
);
}
}
/// 아래 막대: 쪽 번호 ("2-1 / 3장") · 밀대 · 쪽 이름 (오른쪽 끝). [gallery] 가 있으면 그 위에
class ReaderBottomBar extends StatelessWidget {
const ReaderBottomBar({
super.key,
required this.pages,
required this.slots,
required this.index,
required this.label,
required this.pageName,
required this.rtl,
required this.onSlide,
this.gallery,
});
/// 원본 장 수 · 보이는 쪽 수 · 지금 쪽 번호
final int pages;
final int slots;
final int index;
/// 지금 쪽 이름 ("12" · "12-1") · 파일 이름
final String label;
final String pageName;
final bool rtl;
final ValueChanged<int> onSlide;
final Widget? gallery;
@override
Widget build(BuildContext context) {
final n = pages;
final count = slots;
return Container(
color: readerBarColor,
padding: EdgeInsets.only(bottom: MediaQuery.paddingOf(context).bottom, left: 12, right: 12),
child: Column(mainAxisSize: MainAxisSize.min, children: [
?gallery,
Row(children: [
// 125: 반으로 나눈 쪽이 있으면 전체 수가 파일 수 (장) 임을 단위로 ("2-1 / 3장")
Text(
count != n ? trf('{0} / {1}장', [label, n]) : '${n == 0 ? 0 : label} / $n',
style: const TextStyle(color: Colors.white, fontFamily: 'Consolas')),
Expanded(
child: count < 2
? const SizedBox(height: 48)
: Directionality(
textDirection: rtl ? TextDirection.rtl : TextDirection.ltr,
child: Slider(
value: index.clamp(0, count - 1).toDouble(),
max: (count - 1).toDouble(),
divisions: count - 1,
onChanged: (v) => onSlide(v.round()),
// ⑦ 어두운 막대 위 - 밝은 화면에서도 어두운 화면의 청록 (null 이면 테마 그대로)
activeColor: JjColors.isLight ? JjPalette.dark.accent : null,
),
),
),
// 113: 쪽 이름은 오른쪽 끝 (넓은 화면에서 가운데로 떨어지지 않게)
ConstrainedBox(
constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.3),
child: Text(pageName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.end,
style: const TextStyle(color: Colors.white70, fontSize: 12)),
),
]),
]),
);
}
}
/// 갤러리 줄을 [page] 장이 가운데 오게 할 위치 ([viewport] 보이는 너비, [max] 끝 위치)
double galleryOffset(int page, double viewport, double max) =>
(page * readerThumbExtent - viewport / 2 + readerThumbExtent / 2).clamp(0.0, max);
/// 갤러리 줄 [g] 를 [page] 장이 가운데 오게 ([animate] 면 0.2초 동안). 줄이 화면에 없으면 아무것도 안 함
void scrollGalleryTo(ScrollController g, int page, {bool animate = true}) {
if (!g.hasClients) return;
final pos = g.position;
final to = galleryOffset(page, pos.viewportDimension, pos.maxScrollExtent);
animate ? g.animateTo(to, duration: const Duration(milliseconds: 200), curve: Curves.easeOut) : g.jumpTo(to);
}
/// X-plore 맞추기 (관리자 지시 10/20): 아래 갤러리 줄 (칸마다 ValueKey('thumb$i'), 지금 장은 굵은 청록 테두리)
class ReaderGalleryStrip extends StatelessWidget {
const ReaderGalleryStrip({
super.key,
required this.controller,
required this.count,
required this.current,
required this.rtl,
required this.thumb,
required this.onTap,
});
final ScrollController controller;
final int count;
final int current;
final bool rtl;
final Future<ReaderImage> Function(int page) thumb;
final ValueChanged<int> onTap;
@override
Widget build(BuildContext context) => SizedBox(
height: 76,
child: ListView.builder(
controller: controller,
scrollDirection: Axis.horizontal,
reverse: rtl,
itemExtent: readerThumbExtent,
itemCount: count,
itemBuilder: (_, i) => GestureDetector(
key: ValueKey('thumb$i'),
onTap: () => onTap(i),
child: Container(
margin: const EdgeInsets.all(3),
decoration: BoxDecoration(
border: Border.all(color: i == current ? JjPalette.dark.accent : Colors.white24, width: i == current ? 2 : 1)),
child: FutureBuilder<ReaderImage>(
future: thumb(i),
builder: (_, snap) {
final r = snap.data;
if (snap.hasError) return const Icon(Icons.broken_image_outlined, color: Colors.white38, size: 20);
if (r == null) return const SizedBox();
return r.image != null
? RawImage(image: r.image, fit: BoxFit.cover)
: Image(
image: r.provider!,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => const Icon(Icons.broken_image_outlined, color: Colors.white38, size: 20));
},
),
),
),
),
);
}

View File

@ -0,0 +1,123 @@
// 보기 화면 조작 모듈: 계속 보기 시계 (117) · 확대 상태 (30) · 볼륨 키 · 누른 자리
//
// 약속
// - 입력: 간격 (초, 부를 때마다 읽음) · 다 보인 장의 이름 (예: '판:번호') · 손가락 수 변화 · 키 이벤트 · 누른 x 와 화면 너비
// - 출력: [ReaderSlideshow] 는 간격이 지나면 [ReaderSlideshow.onTick] 을 부른다 (장이 다 보인 뒤부터 잼).
// [ReaderZoom] 은 쪽마다 확대 컨트롤러와 "확대 중 · 두 손가락" 상태. [volumeKeyStep] · [tapZone] 은 넘길 방향
// - 오류: 던지지 않는다. 화면 갱신 (setState) 은 부른 쪽이 돌려받은 값을 보고 한다
// 다른 보기 화면 (동영상 사진 슬라이드 등) 도 다시 쓸 수 있다.
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
/// 계속 보기 (117). 관리자 지시 10/20 (28 검토): 시계는 지금 장이 다 보인 뒤부터 잰다 -
/// WebDAV 처럼 느린 원본에서 받는 동안 시간이 흘러 보지도 못한 장을 넘겼다
class ReaderSlideshow {
ReaderSlideshow({required this.seconds, required this.onTick});
/// 간격 (초) - 시계를 걸 때마다 읽는다
final int Function() seconds;
/// 간격이 지났다 (계속 보는 중일 때만)
final void Function() onTick;
bool _playing = false;
bool get playing => _playing;
Timer? _timer;
/// 다 보인 장 ('판:번호')
String? shownKey;
void _start() {
_timer?.cancel();
_timer = Timer(Duration(seconds: seconds()), _fire);
}
void _fire() {
_timer = null;
if (_playing) onTick();
}
/// [key] 장이 화면에 다 그려졌다 (그림 첫 장면 · 실패 글). 계속 보는 중이면 시계를 시작
void markShown(String key) {
if (shownKey == key) return;
shownKey = key;
if (_playing && _timer == null) _start();
}
/// 켜기 · 끄기 (켤 때 [current] 장이 이미 보였으면 바로 재기 시작, 아니면 보인 뒤에). 켬 · 끔이 바뀌었으면 true
/// ([current] null = 볼 장 없음)
bool set(bool on, String? current) {
_timer?.cancel();
_timer = null;
if (on && current != null && shownKey == current) _start();
if (on == _playing) return false;
_playing = on;
return true;
}
/// 넘긴 뒤 [current] 장이 곧바로 보였는데 (캐시) 알림을 놓쳤으면 시계를 다시 건다
void rearmIfShown(String current) {
if (_playing && _timer == null && shownKey == current) _start();
}
void dispose() => _timer?.cancel();
}
/// 확대 (30): 쪽마다 확대 상태 (보는 쪽만 쓰고, 쪽을 넘기면 [reset]) · 두 손가락
class ReaderZoom {
final _zoom = <int, TransformationController>{};
/// [slot] 쪽의 확대 컨트롤러
TransformationController of(int slot) => _zoom.putIfAbsent(slot, TransformationController.new);
/// [slot] 쪽의 지금 배율
double scaleOf(int slot) => of(slot).value.getMaxScaleOnAxis();
/// 확대해 보고 있는지 (그동안은 밀어도 장이 넘어가지 않고 그림을 옮긴다)
bool zoomed = false;
/// 화면에 닿은 손가락 수: 둘이면 (벌리고 오므리기) 옆으로 움직여도 장을 넘기지 않고 확대 · 축소
int _fingers = 0;
bool get pinching => _fingers >= 2;
/// 확대 중이거나 두 손가락이면 장을 넘기지 않는다
bool get holdPage => zoomed || pinching;
/// 손가락 수 변화 ([d] +1 · -1). 두 손가락 상태가 바뀌었으면 true
bool finger(int d) {
final was = pinching;
_fingers = math.max(0, _fingers + d);
return was != pinching;
}
/// 모든 쪽의 확대를 원래대로 (컨트롤러를 버림)
void reset() {
for (final z in _zoom.values) {
z.dispose();
}
_zoom.clear();
zoomed = false;
}
}
/// X-plore 맞추기 (관리자 지시 10/20): 볼륨 키로 넘기기. [enabled] (설정 · 기기) 가 아니거나 볼륨 키가 아니면 null (처리 안 함).
/// 볼륨 키면 넘길 방향 (아래 +1 · 위 -1, 키를 뗄 때는 0) - 처리한 키는 시스템 볼륨을 바꾸지 않는다
int? volumeKeyStep(KeyEvent e, {required bool enabled}) {
if (!enabled) return null;
final k = e.logicalKey;
if (k != LogicalKeyboardKey.audioVolumeDown && k != LogicalKeyboardKey.audioVolumeUp) return null;
if (e is KeyDownEvent || e is KeyRepeatEvent) return k == LogicalKeyboardKey.audioVolumeDown ? 1 : -1;
return 0;
}
/// 화면을 누른 자리 ([x], 너비 [width]): 오른쪽 1/3 은 다음 (+1), 왼쪽 1/3 은 이전 (-1), 가운데는 0 (막대 보이기 · 숨기기).
/// [rtl] (오른쪽 → 왼쪽으로 넘기기) 이면 좌우 반대
int tapZone(double x, double width, {required bool rtl}) {
if (x > width * 2 / 3) return rtl ? -1 : 1;
if (x < width / 3) return rtl ? 1 : -1;
return 0;
}

View File

@ -0,0 +1,146 @@
// 보기 화면 창 모듈: 사진 정보 (Exif) · PDF 페이지 지우기 확인 · 저장 묻기 · PDF 에 넣을 그림 고르기
//
// 약속
// - 입력: 그림 경로 · 임시 폴더 · 페이지 번호 (0 부터) · 앱 ([AppController] - 파일 고르기)
// - 출력: [readPhotoFile] 은 사진 정보 · 파일 크기 · 오류 (셋 중 읽은 것). 창들은 고른 답 (닫으면 null)
// - 오류: [readPhotoFile] 은 던지지 않고 오류를 돌려준다. 창은 던지지 않는다
// 창은 부른 쪽이 지금 장면이 끝난 뒤 (endOfFrame) 띄운다 (28 지적: 누른 이벤트 안에서 띄우면 의미 트리 assertion).
// 다른 화면도 다시 쓸 수 있다.
import 'dart:io' show File;
import 'package:flutter/material.dart';
import '../../app/app_controller.dart';
import '../../core/file_ops.dart' show formatSize;
import '../../core/photo_info.dart';
import '../../core/reader_sources.dart';
import '../../core/vfs.dart';
import '../../l10n/tr.dart';
import '../theme.dart';
/// 지금 장면이 끝난 뒤 (창 띄우기 전 - 28 지적 (10/20): 위 막대 단추를 누른 그 이벤트 안에서 창을 띄우면 (장을 손으로 넘긴 뒤)
/// 창을 닫을 때 디버그 판에서 의미 트리 assertion ('node.built') 이 났다. 부른 쪽은 뒤에 mounted 확인)
Future<void> afterFrame() => WidgetsBinding.instance.endOfFrame;
/// X-plore 맞추기 (관리자 지시 10/20): 폴더 그림 [src] 의 지금 장 ([page] - 창을 띄울 때 읽음) 사진 정보 창.
/// 지금 장면이 끝난 뒤에 띄운다 (Windows 통합 시험에서 재현 4번 → 0번). 폴더 그림이 아니거나 볼 장이 없으면 아무것도 안 함
Future<void> showReaderPhotoInfo(BuildContext context, ReaderSource src, int Function() page, {required bool Function() hasPages}) async {
await afterFrame();
if (!context.mounted) return;
final s = src;
if (s is! ImageFilesSource || !hasPages()) return;
final path = s.paths[page()];
final r = await readPhotoFile(path, s.tempDir);
if (!context.mounted) return;
await showPhotoInfoDialog(context, path: path, info: r.info, size: r.size, error: r.error);
}
/// [path] 그림 (WebDAV 면 [tempDir] 에 받아서) 의 사진 정보와 파일 크기. 못 읽으면 [error]
Future<({PhotoInfo? info, int? size, Object? error})> readPhotoFile(String path, String tempDir) async {
PhotoInfo? info;
int? size;
Object? error;
try {
final local = await vLocalCopy(path, tempDir);
final f = File(local);
size = await f.length();
final bytes = await f.readAsBytes();
info = readPhotoInfo(bytes);
} catch (e) {
error = e;
}
return (info: info, size: size, error: error);
}
/// X-plore 맞추기 (관리자 지시 10/20): 사진 정보 (찍은 날 · 기기 · 크기 · 위치). 위치는 [위치 보기] 를 누를 때만
/// 보이고, 지도 · 인터넷으로 보내지 않는다 (링크 없음)
Future<void> showPhotoInfoDialog(BuildContext context,
{required String path, required PhotoInfo? info, required int? size, required Object? error}) async {
var showLocation = false;
await showDialog<void>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, set) {
final i = info;
Widget row(String k, Widget v) => Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
SizedBox(width: 90, child: Text(k, style: TextStyle(color: JjColors.textDim))),
Expanded(child: v),
]),
);
return AlertDialog(
scrollable: true,
title: Text(tr('사진 정보 (Exif)')),
content: SizedBox(
width: 420,
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
row(tr('파일'), SelectableText(vBasename(path))),
if (i == null)
Text(trf('읽을 수 없습니다: {0}', [error]))
else ...[
row(tr('크기'), Text([
if (i.width != null) trf('{0} × {1} 픽셀', [i.width!, i.height!]),
if (size != null) formatSize(size),
].join(' · '))),
row(tr('찍은 날'), Text(i.taken ?? '-')),
row(tr('기기'), Text(i.device ?? '-')),
row(
tr('위치'),
!i.hasLocation
? const Text('-')
: showLocation
? SelectableText(i.locationText)
: Align(
alignment: Alignment.centerLeft,
child: TextButton(onPressed: () => set(() => showLocation = true), child: Text(tr('위치 보기'))))),
if (i.hasLocation)
Text(tr('위치는 이 기기에서만 보이며 지도 · 인터넷으로 보내지 않습니다'),
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
if (!i.hasExif)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(tr('이 그림에는 Exif 정보가 없습니다 (JPEG 사진만 읽음)'),
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
),
],
]),
),
actions: [TextButton(onPressed: () => Navigator.of(ctx).pop(), child: Text(tr('닫기')))],
);
},
),
);
}
/// PDF [page] (0 부터) 페이지를 지울지 묻는다. 지우기 = true
Future<bool?> confirmPdfPageDelete(BuildContext context, int page) => showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(tr('페이지 지우기')),
content: Text(trf('{0} 페이지를 지울까요? (저장해야 파일에 반영됩니다)', [page + 1])),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('지우기'))),
],
),
);
/// 저장하지 않은 변경을 어떻게 할지: 'save' · 'discard' · 'cancel' (닫으면 null)
Future<String?> askSaveChanges(BuildContext context) => showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(tr('저장하지 않은 변경')),
content: Text(tr('페이지를 바꾼 것을 저장할까요?')),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, 'cancel'), child: Text(tr('취소'))),
TextButton(onPressed: () => Navigator.pop(ctx, 'discard'), child: Text(tr('저장 안 함'))),
FilledButton(onPressed: () => Navigator.pop(ctx, 'save'), child: Text(tr('저장'))),
],
),
);
/// PDF 에 넣을 그림을 고른다 (설정의 그림 확장자). 고르지 않으면 빈 목록
Future<List<String>> pickPdfImages(AppController c) =>
c.services.storage.pickFiles(title: tr('PDF 에 넣을 그림 선택'), extensions: c.settings.imageExts);

View File

@ -0,0 +1,135 @@
// 보기 화면 그림 기억 모듈
//
// 약속
// - 입력: 그림 원본 ([ReaderSource]) · 장 번호 · 읽을 너비 (기기 픽셀)
// - 출력: 장마다 한 번만 읽은 그림 ([ReaderImage] 의 Future) - 통째 · 반쪽 (두 배 너비) · 확대해 다시 그린 것 · 작은 그림.
// [imageSize] 는 그림의 원래 크기 (모르면 null)
// - 오류: 읽기 실패는 Future 의 오류로 그대로 돌려준다 (기억에 남아 같은 실패를 다시 보인다 - 지우려면 [forget]).
// 버릴 때 dispose 실패는 삼킨다
// 이 모듈이 그림을 버리는 (dispose) 유일한 곳이다. 다른 보기 화면도 다시 쓸 수 있다.
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/widgets.dart';
import '../../core/reader_sources.dart';
class ReaderImageCache {
ReaderImageCache(this.source);
/// 읽을 원본 (폴더 그림 목록처럼 장이 바뀌어도 같은 객체)
final ReaderSource source;
final _full = <int, Future<ReaderImage>>{};
/// 반쪽으로 보는 장 (두 배 너비)
final _half = <int, Future<ReaderImage>>{};
/// 확대한 배율에 맞춰 더 크게 다시 그린 쪽 (쪽 번호 - 흐릿하지 않게)
final _sharp = <int, Future<ReaderImage>>{};
final _sharpWidth = <int, int>{};
/// 아래 갤러리 줄의 작은 그림 (장 번호)
final _thumbs = <int, Future<ReaderImage>>{};
static void _drop(Future<ReaderImage> f) => unawaited(f.then((x) => x.dispose(), onError: (_) {}));
/// [page] 장 통째 ([width] 너비). 처음 읽을 때만 [onLoaded] 를 부른다 (크기 알아 두기 등)
Future<ReaderImage> full(int page, int width, {void Function(ReaderImage r)? onLoaded}) => _full.putIfAbsent(page, () {
final f = source.load(page, maxWidth: width);
f.then((r) => onLoaded?.call(r), onError: (_) {});
return f;
});
/// [page] 장을 반쪽으로 볼 때 (화면 너비 [width] 의 두 배, 8192 까지)
Future<ReaderImage> half(int page, int width) =>
_half.putIfAbsent(page, () => source.load(page, maxWidth: math.min(width * 2, 8192)));
/// [slot] 쪽을 확대해 다시 그린 것 (없으면 null)
Future<ReaderImage>? sharp(int slot) => _sharp[slot];
/// 작은 그림 (160 너비)
Future<ReaderImage> thumb(int page) => _thumbs.putIfAbsent(page, () => source.load(page, maxWidth: 160));
/// 확대 배율 [scale] 에 맞는 너비. 더 크게 다시 그릴 만큼이 아니면 null ([base] 쪽 기본 너비)
int? sharpWidthFor(int slot, double scale, int base) {
if (scale < 1.3) return null;
final want = (base * scale).round().clamp(base, 8192);
if (want <= (_sharpWidth[slot] ?? base) * 1.2) return null;
return want;
}
/// [slot] 쪽 ([page] 장) 을 [width] 너비로 다시 그린다. 앞의 것은 새것을 건 뒤에 버린다
void sharpen(int slot, int page, int width) {
_sharpWidth[slot] = width;
final old = _sharp[slot];
_sharp[slot] = source.load(page, maxWidth: width);
if (old != null) _drop(old);
}
/// 확대해 다시 그린 것을 모두 버린다
void clearSharp() {
for (final f in _sharp.values) {
_drop(f);
}
_sharp.clear();
_sharpWidth.clear();
}
/// 통째 · 반쪽을 모두 버린다 (장이 바뀌었을 때)
void clearPages() {
for (final f in [..._full.values, ..._half.values]) {
_drop(f);
}
_full.clear();
_half.clear();
}
/// [다시 시도]: [page] 장과 [slot] 쪽의 기억을 잊는다 (버리지 않음 - 아직 화면에 있을 수 있다)
void forget(int page, int slot) {
_full.remove(page);
_half.remove(page);
_sharp.remove(slot);
_sharpWidth.remove(slot);
}
/// [page] 가까이 (앞뒤 3장) 만 남기고 버린다 (메모리)
void trim(int page) {
for (final m in [_full, _half]) {
for (final k in m.keys.toList()) {
if ((k - page).abs() > 3) _drop(m.remove(k)!);
}
}
}
/// 모두 버린다 (작은 그림 → 통째 · 반쪽 → 확대한 것 차례)
void dispose() {
for (final f in _thumbs.values) {
_drop(f);
}
clearPages();
clearSharp();
}
/// 그림의 원래 크기 (그려 둔 그림이면 바로, 아니면 한 번 풀어 보고). 못 읽으면 null
static Future<Size?> imageSize(ReaderImage r) {
final img = r.image;
if (img != null) return Future.value(Size(img.width.toDouble(), img.height.toDouble()));
final provider = r.provider;
if (provider == null) return Future.value(null);
final done = Completer<Size?>();
final stream = provider.resolve(ImageConfiguration.empty);
late final ImageStreamListener l;
l = ImageStreamListener((info, _) {
if (!done.isCompleted) done.complete(Size(info.image.width.toDouble(), info.image.height.toDouble()));
info.dispose();
stream.removeListener(l);
}, onError: (_, _) {
if (!done.isCompleted) done.complete(null);
stream.removeListener(l);
});
stream.addListener(l);
return done.future;
}
}

View File

@ -0,0 +1,177 @@
// 보기 화면의 한 쪽 위젯 · 실패 판
//
// 약속
// - 입력: 보일 쪽 ([ReaderSlot]) · 읽는 중인 그림 (Future) · 확대 컨트롤러와 상태 · 회전 · 반전 · 넘기는 방향 · 맞추기 ('page' · 'width') · 화면 크기
// - 출력: 두 손가락 확대 (InteractiveViewer) 안에 그림 (받는 동안 돌이, 실패하면 이유 · [자세히] · [다시 시도]).
// 다 보이면 (그림 첫 장면 · 실패 글) [ReaderPageView.onShown] 을 장면이 끝난 뒤 부른다
// - 오류: 읽기 실패는 화면 글로 보인다 (던지지 않음). [다시 시도] 는 그림 캐시를 비우고 [ReaderPageView.onRetry]
// 다른 보기 화면도 다시 쓸 수 있다.
import 'dart:async';
import 'package:flutter/material.dart';
import '../../core/hwp_source.dart' show HwpPdfException;
import '../../core/reader_sources.dart';
import '../../l10n/tr.dart';
import 'reader_slots.dart';
class ReaderPageView extends StatelessWidget {
const ReaderPageView({
super.key,
required this.slot,
required this.imageKey,
required this.image,
required this.zoom,
required this.zoomed,
required this.holdPage,
required this.turns,
required this.flipV,
required this.rtl,
required this.fit,
required this.box,
required this.onShown,
required this.onZoomed,
required this.onZoomEnd,
required this.onRetry,
});
final ReaderSlot slot;
/// 그림 위젯의 키 (판 · 장 · 쪽 · 다시 시도 수 - 바뀌면 새로 그린다)
final String imageKey;
final Future<ReaderImage> image;
final TransformationController zoom;
final bool zoomed;
final bool holdPage;
final int turns;
final bool flipV;
final bool rtl;
final String fit;
final Size box;
final VoidCallback onShown;
/// 확대 중인지가 바뀌었다
final ValueChanged<bool> onZoomed;
final VoidCallback onZoomEnd;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
final half = slot.part >= 0;
// 확대해 다시 그린 것이 있으면 그것 (다 그릴 때까지는 앞의 그림을 그대로 보인다)
final page = FutureBuilder<ReaderImage>(
key: ValueKey(imageKey),
future: image,
builder: (context, snap) {
// 계속 보기: 받는 동안은 시계를 재지 않고, 보인 (또는 실패 글이 보인) 뒤부터
void shown() => WidgetsBinding.instance.addPostFrameCallback((_) => onShown());
if (snap.hasError) {
shown();
return Center(child: ReaderFailurePanel(error: snap.error, onRetry: onRetry));
}
final r = snap.data;
if (r == null) return const Center(child: CircularProgressIndicator());
if (r.image != null) shown();
Widget picture = r.image != null
? RawImage(image: r.image, filterQuality: FilterQuality.medium)
: Image(image: r.provider!, gaplessPlayback: true, filterQuality: FilterQuality.medium,
frameBuilder: (_, child, frame, sync) {
if (frame != null || sync) shown();
return child;
},
errorBuilder: (_, e, _) {
shown();
return ReaderFailurePanel(error: e, provider: r.provider, onRetry: onRetry);
});
// 120: 회전 · 상하 반전 (보기만, 파일은 그대로)
if (turns != 0) picture = RotatedBox(quarterTurns: turns, child: picture);
if (flipV) picture = Transform.flip(flipY: true, child: picture);
// 119: 반쪽 - 읽는 방향의 쪽부터 (오른쪽 → 왼쪽이면 오른쪽 반이 먼저)
if (half) {
final right = (slot.part == 0) == rtl;
picture = ClipRect(
child: Align(
alignment: right ? Alignment.centerRight : Alignment.centerLeft, widthFactor: 0.5, child: picture),
);
}
if (fit == 'width') {
return SingleChildScrollView(
physics: holdPage ? const NeverScrollableScrollPhysics() : null,
child: SizedBox(width: box.width, child: FittedBox(fit: BoxFit.fitWidth, child: picture)));
}
return SizedBox.expand(child: FittedBox(fit: BoxFit.contain, child: picture));
},
);
// 30: 두 손가락으로 확대 · 축소 (확대했을 때만 그림을 끌어 옮긴다)
return InteractiveViewer(
transformationController: zoom,
minScale: 1,
maxScale: 6,
panEnabled: zoomed,
onInteractionUpdate: (_) {
final z = zoom.value.getMaxScaleOnAxis() > 1.01;
if (z != zoomed) onZoomed(z);
},
onInteractionEnd: (_) => onZoomEnd(),
child: page,
);
}
}
/// 실패 판에 보일 글과 [자세히] 원문 (없으면 ''). [provider]: 그림 파일 · ZIP 의 그림을 읽다 (디코드) 실패한 것 -
/// 화면 글은 종류에 맞게, 원문은 [자세히] (28 지적)
({String text, String detail}) readerFailureText(Object? e, {ImageProvider? provider}) {
final text = e is HwpPdfException
? trf('열 수 없습니다: {0}', [e.message])
: provider != null
? tr('그림 파일을 읽을 수 없습니다 - 깨졌거나 지원하지 않는 형식')
: trf('열 수 없습니다: {0}', [e]);
final detail = e is HwpPdfException ? e.detail : (provider != null ? '$e' : '');
return (text: text, detail: detail);
}
/// 원문을 고를 수 있는 창 ([닫기])
Future<void> showReaderErrorDetail(BuildContext context, String detail) => showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
content: SelectableText(detail, style: const TextStyle(fontSize: 12)),
actions: [TextButton(onPressed: () => Navigator.of(ctx).pop(), child: Text(tr('닫기')))],
),
);
/// 관리자 지시 (10/20, HWP 2단계): 이유와 [다시 시도] - 그 장의 기억을 지우고 다시 부른다 (시간 제한을 넘긴 HWP 묶음 등)
class ReaderFailurePanel extends StatelessWidget {
const ReaderFailurePanel({super.key, required this.error, this.provider, required this.onRetry});
final Object? error;
final ImageProvider? provider;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
final (:text, :detail) = readerFailureText(error, provider: provider);
return SizedBox(
width: 320,
height: 160,
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Text(text, textAlign: TextAlign.center, style: const TextStyle(color: Colors.white70)),
const SizedBox(height: 8),
// 관리자 지시 (10/20, 28 지적): 변환기 · 그림 디코드 원문은 화면 글 대신 [자세히] 에
if (detail.isNotEmpty)
TextButton(
onPressed: () => showReaderErrorDetail(context, detail),
child: Text(tr('자세히')),
),
OutlinedButton(
onPressed: () {
// 그림은 Flutter 의 그림 캐시에 실패가 남아 같은 키로 다시 쓰인다 - 비우고 (ResizeImage 의 키째) 다시 읽는다
final p = provider;
if (p != null) unawaited(p.evict());
onRetry();
},
child: Text(tr('다시 시도')),
),
]));
}
}

View File

@ -0,0 +1,78 @@
// 보기 화면 PDF 편집 모듈: 페이지 지우기 · 그림을 페이지로 넣기 · 저장 · 닫을 때 저장 묻기
//
// 약속
// - 입력: 창을 띄울 [BuildContext] (화면의 것 - 닫혔는지 context.mounted 로 본다) · [PdfSource] · 지금 페이지 (부를 때 읽음) ·
// 알림 글을 보일 [snack]
// - 출력: 바뀐 페이지 번호 (다시 그릴 자리) 또는 null (취소 · 화면 닫힘 · 실패). 저장 · 나가기는 bool
// - 오류: 넣기 · 저장 실패는 [snack] 으로 알리고 던지지 않는다. 페이지 지우기 실패는 그대로 던진다 (예전과 같음)
// 창은 지금 장면이 끝난 뒤 (endOfFrame) 띄운다 - 28 지적 (10/20): 위 막대 단추를 누른 그 이벤트 안에서 창을 띄우면
// (장을 손으로 넘긴 뒤) 창을 닫을 때 디버그 판에서 의미 트리 assertion ('node.built') 이 났다.
// 다른 PDF 화면도 다시 쓸 수 있다.
import 'package:flutter/widgets.dart';
import '../../app/app_controller.dart';
import '../../core/reader_sources.dart';
import '../../core/vfs.dart';
import '../../l10n/tr.dart';
import 'reader_dialogs.dart';
/// 지금 페이지를 묻고 지운다. 지운 페이지 번호 (취소 · 한 페이지뿐 · 화면 닫힘이면 null)
Future<int?> deletePdfPage(BuildContext context, PdfSource pdf, int Function() currentPage, void Function(String) snack) async {
await afterFrame();
if (!context.mounted) return null;
if (pdf.length <= 1) {
snack(tr('마지막 한 페이지는 지울 수 없습니다.'));
return null;
}
final page = currentPage();
final ok = await confirmPdfPageDelete(context, page);
if (ok != true) return null;
await pdf.deletePage(page);
if (!context.mounted) return null;
return page;
}
/// 그림을 골라 지금 페이지 뒤에 넣는다. 넣은 자리 (페이지) 와 장 수 (고르지 않음 · 실패 · 화면 닫힘이면 null)
Future<({int page, int count})?> insertPdfImages(
BuildContext context, AppController c, PdfSource pdf, int Function() currentPage, void Function(String) snack) async {
await afterFrame();
if (!context.mounted) return null;
final page = currentPage();
final files = await pickPdfImages(c);
if (files.isEmpty || !context.mounted) return null;
final temp = await c.services.storage.tempDirectory();
try {
await pdf.insertImages(files, after: page, tempDir: temp);
} catch (e) {
snack(trf('넣지 못했습니다: {0}', [e]));
return null;
}
return (page: page, count: files.length);
}
/// 넣은 뒤 알림 글
String insertedPdfImagesText(({int page, int count}) r) =>
trf('그림 {0}장을 {1} 페이지 뒤에 넣었습니다 (저장해야 파일에 반영됩니다).', [r.count, r.page + 1]);
/// 저장한다 (알림 글은 [snack]). 저장했으면 true
Future<bool> savePdf(PdfSource pdf, void Function(String) snack) async {
try {
final path = await pdf.save();
snack(trf('저장했습니다: {0}', [vDisplay(path)]));
return true;
} catch (e) {
snack(trf('저장하지 못했습니다: {0}', [e]));
return false;
}
}
/// 닫아도 되는지 (저장하지 않은 변경이 있으면 묻고, [저장] 이면 [save] 를 부른다). [pdf] 가 없거나 바뀐 것이 없으면 true
Future<bool> confirmPdfLeave(BuildContext context, PdfSource? pdf, Future<void> Function() save) async {
if (pdf == null || !pdf.dirty) return true;
await afterFrame();
if (!context.mounted) return false;
final r = await askSaveChanges(context);
if (r == 'save') await save();
return r == 'save' || r == 'discard';
}

View File

@ -0,0 +1,110 @@
// 보기 화면 쪽 계산 모듈 (순수 계산 - 위젯 · 상태 없음)
//
// 약속
// - 입력: 원본 장 수 · 장마다 나눌지 판단 · 보이는 쪽 목록 · 그림 크기 · 회전 수 · 밝기 값
// - 출력: 보이는 쪽 목록 ([ReaderSlot]) · 옮길 자리 번호 · 지운 뒤 남은 장 번호 · 쪽 이름 · 밝기 필터
// ([removeMissingPaths] 만 받은 목록에서 없어진 경로를 뺀다)
// - 오류: 던지지 않는다. 찾을 수 없으면 -1 (또는 범위 안으로 맞춘 번호) 을 돌려준다
// 다른 보기 화면도 그대로 다시 쓸 수 있다.
import 'dart:math' as math;
import 'package:flutter/painting.dart';
/// 보이는 한 쪽: 원본 [page] 장의 통째 (part -1) 또는 읽는 순서로 첫째 (0) · 둘째 (1) 반쪽 (119)
typedef ReaderSlot = ({int page, int part});
/// 223: "두 쪽 나눠 보기: 자동" 이 이 장 ([size], 회전 반영) 을 나눌지 - 두 쪽 스캔일 가능성이 높을 때만.
/// [bundle] (ZIP · CBZ · PDF 로 연 묶음): 가로로 긴 그림은 나눈다 (만화 두 쪽 스캔이 흔함).
/// 폴더의 그림들: 같은 폴더의 다른 그림 ([others], 크기를 아는 것) 대부분 (3/4 이상, 2장 이상) 이 세로인데 이것만 가로일 때만 -
/// 평범한 가로 사진은 나누지 않는다 (예전에는 모두 반으로 잘라 "그림이 잘렸다" 고 보였다)
bool autoSplitsPage(Size size, List<Size> others, {required bool bundle}) {
if (size.width <= size.height) return false;
if (bundle) return true;
final tall = others.where((s) => s.height > s.width).length;
return others.length >= 2 && tall * 4 >= others.length * 3;
}
/// [turns] (시계 방향 90° 회전 수) 를 반영한 크기
Size turnedSize(Size s, int turns) => turns.isOdd ? Size(s.height, s.width) : s;
/// 이 장 ([page]) 을 반씩 나눠 볼지. [mode] 는 설정 값 ('on' · 'off' · 'auto'), [dims] 는 크기를 아는 장들.
/// 자동은 크기를 알 때만 [autoSplitsPage] 로 판단한다
bool splitsPage(String mode, int page, Map<int, Size> dims, {required int turns, required bool bundle}) {
switch (mode) {
case 'on':
return true;
case 'off':
return false;
}
final d = dims[page];
if (d == null) return false;
return autoSplitsPage(turnedSize(d, turns), [for (final e in dims.entries) if (e.key != page) turnedSize(e.value, turns)],
bundle: bundle);
}
/// 원본 [length] 장으로 보이는 쪽 목록을 만든다 ([splits] 가 참인 장은 반쪽 둘)
List<ReaderSlot> buildSlots(int length, bool Function(int page) splits) => [
for (var i = 0; i < length; i++)
if (splits(i)) ...[(page: i, part: 0), (page: i, part: 1)] else (page: i, part: -1),
];
/// [v] 에서 [index] 번 쪽 (범위 밖이면 끝으로 맞춤). 비었으면 첫 장 통째
ReaderSlot slotAt(List<ReaderSlot> v, int index) => v.isEmpty ? (page: 0, part: -1) : v[index.clamp(0, v.length - 1)];
/// [page] 장의 첫 쪽 번호 (없으면 -1)
int indexOfPage(List<ReaderSlot> v, int page) => v.indexWhere((e) => e.page == page);
/// 쪽 목록을 새로 만든 뒤 보던 쪽 [cur] 의 새 자리. 같은 반쪽 → 같은 장의 다른 모양 (통째 ↔ 첫 반쪽) →
/// 옛 번호 [index] (범위 안으로) 차례로 찾는다
int relocateSlot(List<ReaderSlot> v, ReaderSlot cur, int index) {
int find(int part) => v.indexWhere((e) => e.page == cur.page && e.part == part);
var to = find(cur.part);
if (to < 0) to = find(cur.part < 0 ? 0 : -1);
if (to < 0) to = index.clamp(0, math.max(0, v.length - 1));
return to;
}
/// 224: [count] 장 중 [gone] 을 뺀 뒤 보던 장 [curPage] (없어졌으면 그 뒤, 뒤에 없으면 앞의 남은 장) 의 새 번호.
/// 남은 장이 없으면 -1
int pageAfterRemoval(int curPage, Set<int> gone, int count) {
var keep = curPage;
while (keep < count && gone.contains(keep)) {
keep++;
}
if (keep >= count) {
keep = curPage;
while (keep >= 0 && gone.contains(keep)) {
keep--;
}
}
return keep < 0 ? -1 : keep - gone.where((g) => g < keep).length;
}
/// 224: [paths] 에서 없어진 것 ([missing]) 을 뺀다 (목록을 바꿈). 없어진 것이 없으면 null, 있으면 보던 장 [curPage] 의
/// 새 번호 ([pageAfterRemoval]) 와 보던 장도 없어졌는지
({int newPage, bool curGone})? removeMissingPaths(List<String> paths, int curPage, bool Function(String path) missing) {
final gone = <int>{
for (var i = 0; i < paths.length; i++)
if (missing(paths[i])) i,
};
if (gone.isEmpty) return null;
final newPage = pageAfterRemoval(curPage, gone, paths.length);
for (final g in gone.toList()..sort((a, b) => b - a)) {
paths.removeAt(g);
}
return (newPage: newPage, curGone: gone.contains(curPage));
}
/// 쪽 번호: 통째면 "12", 반쪽이면 "12-1" · "12-2"
String slotLabel(ReaderSlot s) => s.part < 0 ? '${s.page + 1}' : '${s.page + 1}-${s.part + 1}';
/// 반쪽은 두 배 너비로 읽는다 (반쪽이 화면 가득이어도 원본 해상도로). [width] 화면 너비 (기기 픽셀)
int slotBaseWidth(ReaderSlot s, int width) => s.part >= 0 ? math.min(width * 2, 8192) : width;
/// 어둡게 · 밝게 ([b] -0.7 ~ 0.7): 음수는 색을 줄이고 양수는 더한다
ColorFilter brightnessFilter(double b) {
final double s = 1 + math.min(b, 0.0);
final double o = math.max(b, 0.0) * 160;
return ColorFilter.matrix(<double>[s, 0, 0, 0, o, 0, s, 0, 0, o, 0, 0, s, 0, o, 0, 0, 0, 1, 0]);
}

View File

@ -0,0 +1,88 @@
// 보기 화면 무대 위젯: 장 넘기기 (PageView) · 누름과 손가락 알림 · 밝기 · 위 · 아래 막대 자리
//
// 약속
// - 입력: 쪽 수 · 판 번호 (바뀌면 PageView 를 새로) · 장 컨트롤러 · 넘기는 방향 · 장을 붙잡을지 (확대 중) · 밝기 ·
// 쪽 위젯을 만드는 [ReaderStage.page] · 막대 (보일 때만)
// - 출력: 검은 바탕에 그림 장들. 누름 · 손가락 · 넘김은 받은 콜백으로만 알린다. 그릴 때마다 화면 너비 (기기 픽셀,
// 320 ~ 4096) 를 [ReaderStage.onWidth] 로 알린다 (그림을 그 너비로 줄여 읽게)
// - 오류: 던지지 않는다. 쪽이 없으면 "볼 그림이 없습니다."
// 다른 보기 화면도 다시 쓸 수 있다.
import 'package:flutter/material.dart';
import '../../l10n/tr.dart';
import 'reader_slots.dart';
class ReaderStage extends StatelessWidget {
const ReaderStage({
super.key,
required this.version,
required this.count,
required this.controller,
required this.rtl,
required this.holdPage,
required this.brightness,
required this.onWidth,
required this.onPointerDown,
required this.onPointerUp,
required this.onTapUp,
required this.onPageChanged,
required this.page,
this.topBar,
this.bottomBar,
});
final int version;
final int count;
final PageController controller;
final bool rtl;
final bool holdPage;
final double brightness;
final ValueChanged<int> onWidth;
final VoidCallback onPointerDown;
/// 손가락을 뗐다 (또는 취소)
final VoidCallback onPointerUp;
final void Function(TapUpDetails d, double width) onTapUp;
final ValueChanged<int> onPageChanged;
final Widget Function(int i, Size box) page;
/// 위 · 아래 막대 (null 이면 숨김)
final Widget? topBar;
final Widget? bottomBar;
@override
Widget build(BuildContext context) => LayoutBuilder(builder: (context, box) {
// 화면 너비 (기기 픽셀) 에 맞춰 그림을 줄여 읽는다
onWidth((box.maxWidth * MediaQuery.devicePixelRatioOf(context)).round().clamp(320, 4096));
return Stack(children: [
Positioned.fill(
child: Listener(
onPointerDown: (_) => onPointerDown(),
onPointerUp: (_) => onPointerUp(),
onPointerCancel: (_) => onPointerUp(),
child: GestureDetector(
onTapUp: (d) => onTapUp(d, box.maxWidth),
child: ColorFiltered(
colorFilter: brightnessFilter(brightness),
child: count == 0
? Center(child: Text(tr('볼 그림이 없습니다.'), style: const TextStyle(color: Colors.white70)))
: PageView.builder(
key: ValueKey(version),
controller: controller,
reverse: rtl,
// 확대해 보는 동안은 밀어도 넘기지 않는다 (그림 옮기기)
physics: holdPage ? const NeverScrollableScrollPhysics() : null,
itemCount: count,
onPageChanged: onPageChanged,
itemBuilder: (_, i) => page(i, box.biggest),
),
),
),
),
),
if (topBar != null) Positioned(left: 0, right: 0, top: 0, child: topBar!),
if (bottomBar != null) Positioned(left: 0, right: 0, bottom: 0, child: bottomBar!),
]);
});
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,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());
});
}