JJ_MKVMaker/lib/ui/reader/reader_page_view.dart

178 lines
7.2 KiB
Dart

// 보기 화면의 한 쪽 위젯 · 실패 판
//
// 약속
// - 입력: 보일 쪽 ([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('다시 시도')),
),
]));
}
}