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