import 'dart:async'; import 'dart:io' show Platform; import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; import '../app/app_controller.dart'; import '../core/reader_sources.dart'; import '../core/hwp_source.dart' show HwpSource; import '../core/vfs.dart'; import '../l10n/tr.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 { await Navigator.of(context).push(MaterialPageRoute( builder: (_) => ReaderPage(c: c, source: source, start: start, autoPlay: autoPlay), )); await source.dispose(); } /// 그림 · 만화 · PDF 보기. /// - 화면 오른쪽 (1/3) 을 누르면 다음 장, 왼쪽을 누르면 이전 장, 가운데를 누르면 위 · 아래 막대 보이기 · 숨기기 /// (오른쪽에서 왼쪽으로 넘기기를 켜면 반대). 좌우로 밀어도 넘어간다. /// - 위 막대: 계속 보기 (117) · 두 쪽 나누기 (119) · 회전 · 상하 반전 (120) · 한 쪽 맞추기 · 좌우 맞추기, 넘기는 방향, /// 어둡게 · 밝게, (PDF) 페이지 지우기 · 그림 넣기 · 저장. 좁으면 못 들어간 버튼은 ⋮ 메뉴로. /// 이 화면은 모듈 (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; final int start; /// X-plore 맞추기 (관리자 지시 10/20): 폴더 [사진 슬라이드쇼] - [계속 보기] (117) 를 켠 채 연다 final bool autoPlay; const ReaderPage({super.key, required this.c, required this.source, this.start = 0, this.autoPlay = false}); /// 볼륨 키로 넘기기를 쓰는 기기인지 (Android - 시험에서 바꿈) static bool Function() volumeKeysSupported = () => Platform.isAndroid; /// 계속 보기 동안 화면 켜 두기 (시험에서 바꿈) static Future Function(bool on) keepScreenOn = (on) => on ? WakelockPlus.enable() : WakelockPlus.disable(); @override State createState() => _ReaderPageState(); } class _ReaderPageState extends State { AppController get c => widget.c; ReaderSource get src => widget.source; late final PageController _pages; /// 보는 쪽 ([_v] 의 번호) late int _index; bool _bars = true; late final _images = ReaderImageCache(src); int _width = 0; /// PDF 는 페이지를 지우거나 넣으면 다시 그린다 int _version = 0; /// X-plore 맞추기 (관리자 지시 10/20): 아래 갤러리 줄 - 그림 원본 (폴더 · ZIP) 에서만 bool get _pictures => src is ImageFilesSource || src is ZipImagesSource; final _gallery = ScrollController(); /// [다시 시도] 를 누른 횟수 (장마다) - 그 장의 화면을 새로 만든다 (28 지적: 같은 키면 실패한 그림을 그대로 다시 썼다) final _retry = {}; @override void initState() { super.initState(); _rebuildPages(); final i = indexOfPage(_v, widget.start); _index = i < 0 ? 0 : i; _pages = PageController(initialPage: _index); _applySystemBars(); // 224: 앱으로 돌아올 때 그사이 밖에서 지운 그림을 목록에서 뺀다 _life = AppLifecycleListener(onResume: () => unawaited(dropMissingFiles())); if (widget.autoPlay) WidgetsBinding.instance.addPostFrameCallback((_) => mounted ? _play(true) : null); } late final AppLifecycleListener _life; /// 224: 폴더의 그림 중 없어진 것 (앱 밖에서 지움) 을 목록에서 뺀다. 보던 그림이 없어졌으면 알리고 다음 그림으로, /// 남은 것이 없으면 닫는다 (예전에는 지운 그림이 그대로 보였다). 시험에서도 부른다 @visibleForTesting Future dropMissingFiles() async { final s = src; if (s is! ImageFilesSource || !mounted) return; // 보던 그림 (없어졌으면 그 뒤의 남은 그림) 의 새 번호 final r = removeMissingPaths(s.paths, _cur.page, vMissingSync); if (r == null) return; _images.clearPages(); _dims.clear(); if (s.paths.isEmpty) { _snack(tr('파일이 없어졌습니다')); Navigator.of(context).maybePop(); return; } setState(() { _v = const []; _rebuildPages(); final i = indexOfPage(_v, r.newPage); _index = i < 0 ? 0 : i; }); _jumpLater(); if (r.curGone) _snack(tr('파일이 없어졌습니다')); } /// 보기 화면은 위 · 아래 시스템 막대까지 그림으로 (30: 설정으로 막대를 그대로 둘 수 있다) void _applySystemBars() => SystemChrome.setEnabledSystemUIMode( c.settings.readerSystemBars ? SystemUiMode.edgeToEdge : SystemUiMode.immersiveSticky); // ───────── 쪽 나누기 (119) · 회전 · 반전 (120) ───────── /// 보이는 쪽들 List _v = const []; ReaderSlot get _cur => slotAt(_v, _index); /// 원본 그림의 크기 (읽어 보고 안 것) final _dims = {}; /// 시계 방향 90° 회전 수 · 상하 반전 (이 보기 화면을 닫을 때까지 모든 쪽에, 파일은 그대로) int _turns = 0; bool _flipV = false; /// 이 장을 반씩 나눠 볼지. 자동은 두 쪽 스캔일 가능성이 높을 때만 ([autoSplitsPage]) 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 { await c.updateSettings((x) => x.readerSplit = 'off'); if (!mounted) return; setState(() { _resetZoom(); _rebuildPages(); }); } /// 보이는 쪽 목록을 다시 만든다. 보던 장은 그대로 보이게 (쪽 번호가 밀리면 그 자리로 옮긴다) void _rebuildPages() { final cur = _v.isEmpty ? null : _cur; _v = buildSlots(src.length, _splits); if (cur == null) return; final to = relocateSlot(_v, cur, _index); if (to != _index) { _index = to; _jumpLater(); } } void _jumpLater() => WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _pages.hasClients && _pages.page?.round() != _index) _pages.jumpToPage(_index); }); /// 읽은 그림의 크기를 기억한다 (자동 나누기 판단) void _learn(int page, ReaderImage r) { if (_dims.containsKey(page)) return; final v = _version; 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); })); } void _rotate(int d) => setState(() { _turns = (_turns + d) % 4; _resetZoom(); _rebuildPages(); }); Future _cycleSplit() async { await c.updateSettings((x) => x.readerSplit = switch (x.readerSplit) { 'auto' => 'on', 'on' => 'off', _ => 'auto' }); if (!mounted) return; setState(() { _resetZoom(); _rebuildPages(); }); } // ───────── 계속 보기 (117) ───────── late final _show = ReaderSlideshow(seconds: () => c.settings.readerAutoSeconds, onTick: _tick); bool get _playing => _show.playing; /// 계속 보기를 멈추게 한 누름은 장 넘기기 · 막대 숨기기로 쓰지 않는다 bool _swallowTap = false; /// 다 보인 장의 이름 (계속 보기 시계는 그 장이 보인 뒤부터 잰다) String get _curKey => '$_version:$_index'; /// [i] 장이 화면에 다 그려졌다 (그림 첫 장면 · 실패 글). 지금 장이면 계속 보기 시계를 시작 void _markShown(int i) { if (!mounted || i != _index) return; _show.markShown('$_version:$i'); } void _play(bool on) { // 지금 장이 이미 보이면 바로 재기 시작, 아직 받는 중이면 보인 뒤에 ([_markShown]) if (!_show.set(on, _v.isNotEmpty ? _curKey : null)) return; setState(() {}); unawaited(ReaderPage.keepScreenOn(on).catchError((_) {})); } void _tick() { if (!mounted) return; if (_index >= _v.length - 1) { _play(false); _snack(tr('마지막 장이라 계속 보기를 멈췄습니다.')); return; } _pages.jumpToPage(_index + 1); // 다음 장이 이미 받아 둔 것이라 다시 그리지 않으면 그 자리에서 알린다 WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _show.rearmIfShown(_curKey); }); } Future _setSeconds(int d) async { await c.updateSettings((x) => x.readerAutoSeconds = (x.readerAutoSeconds + d).clamp(1, 60)); if (!mounted) return; setState(() {}); if (_playing) _play(true); // 새 간격으로 (지금 장이 보이면 처음부터 다시) } // ───────── 확대 (30) ───────── final _zoom = ReaderZoom(); void _finger(int d) { if (_zoom.finger(d)) setState(() {}); } void _setZoomed(bool z) { if (z != _zoom.zoomed) setState(() => _zoom.zoomed = z); } void _onZoomEnd(int i) { final s = _zoom.scaleOf(i); _setZoomed(s > 1.01); if (i >= _v.length) return; final slot = _v[i]; final want = _images.sharpWidthFor(i, s, slotBaseWidth(slot, _width)); if (want == null) return; // 확대한 배율에 맞춰 더 크게 다시 그린다 (흐릿하지 않게) setState(() => _images.sharpen(i, slot.page, want)); } void _resetZoom() { _zoom.reset(); _images.clearSharp(); } @override void dispose() { _gallery.dispose(); _life.dispose(); _show.dispose(); if (_playing) unawaited(ReaderPage.keepScreenOn(false).catchError((_) {})); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); _images.dispose(); _zoom.reset(); _pages.dispose(); super.dispose(); } Future _load(int page) => _images.full(page, _width, onLoaded: (r) { if (mounted) _learn(page, r); }); /// 보는 장 가까이만 남기고 (메모리) 다음 장은 미리 읽는다. 앞 장도 크기를 알아 둔다 (자동 나누기로 쪽 번호가 밀리지 않게) void _trim() { final page = _cur.page; _images.trim(page); for (final n in [page + 1, page - 1]) { if (n >= 0 && n < src.length) unawaited(_load(n).then((_) {}, onError: (_) {})); } } /// 다시 읽기 (PDF 페이지를 바꿨을 때). [page] 를 보던 자리로 void _reset({int? page}) { final keep = page ?? _cur.page; _images.clearPages(); _dims.clear(); _resetZoom(); _version++; _v = const []; _rebuildPages(); 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) { final step = volumeKeyStep(e, enabled: c.settings.readerVolumeKeys && ReaderPage.volumeKeysSupported()); if (step == null) return KeyEventResult.ignored; if (step != 0) _go(step); return KeyEventResult.handled; } void _go(int delta) { final to = (_index + delta).clamp(0, math.max(0, _v.length - 1)).toInt(); if (to == _index) { ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar( duration: const Duration(seconds: 1), content: Text(delta > 0 ? tr('마지막 장입니다.') : tr('첫 장입니다.')))); return; } _pages.jumpToPage(to); } void _onTap(TapUpDetails d, double width) { if (_swallowTap) { _swallowTap = false; return; } final step = tapZone(d.localPosition.dx, width, rtl: c.settings.readerRtl); if (step != 0) { _go(step); } else { setState(() => _bars = !_bars); } } /// 그림을 누르거나 밀면 계속 보기를 멈춘다 (117) void _onPointerDown() { _finger(1); _swallowTap = _playing; if (_playing) { _play(false); _snack(tr('계속 보기를 멈췄습니다.')); } } Widget _page(int i, Size box) { final slot = _v[i]; 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; }), ); } Future _updateSettings(void Function(dynamic s) f) async { await c.updateSettings((s) => f(s)); if (mounted) setState(() {}); } // ───────── PDF 편집 (reader_pdf_edit - 창은 지금 장면이 끝난 뒤에) ───────── PdfSource? get _pdf => src is PdfSource ? src as PdfSource : null; Future _deletePage() async { final page = await deletePdfPage(context, _pdf!, () => _cur.page, _snack); if (page == null || !mounted) return; setState(() => _reset(page: page)); } Future _insertImages() async { 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 { if (await savePdf(_pdf!, _snack) && mounted) setState(() {}); } void _snack(String text) { if (!mounted) return; final m = ScaffoldMessenger.maybeOf(context); m?.hideCurrentSnackBar(); m?.showSnackBar(SnackBar(duration: const Duration(seconds: 2), content: Text(text))); } // ───────── 화면 ───────── /// 위 막대의 버튼들 (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), ), ); /// 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 { await c.updateSettings((x) => x.readerGallery = !x.readerGallery); if (!mounted) return; setState(() {}); WidgetsBinding.instance.addPostFrameCallback((_) => _scrollGallery(animate: false)); } /// [page] 번째 장 (파일) 으로 (두 쪽 나눠 보고 있으면 그 장의 첫 쪽) void _goPage(int page) { final i = indexOfPage(_v, page); if (i >= 0 && _pages.hasClients) _pages.jumpToPage(i); } /// 갤러리 줄을 지금 장이 가운데 오게 void _scrollGallery({bool animate = true}) => scrollGalleryTo(_gallery, _cur.page, animate: animate); Widget _bottomBar() { final n = src.length; 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, ); } @override Widget build(BuildContext context) { final s = c.settings; return PopScope( canPop: _pdf?.dirty != true, onPopInvokedWithResult: (didPop, _) async { if (didPop) return; if (await confirmPdfLeave(context, _pdf, _save) && context.mounted) { _pdf?.dirty = false; Navigator.of(context).pop(); } }, child: Focus( autofocus: true, onKeyEvent: _onKey, child: Scaffold( 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, ), ), ), ); } }