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