178 lines
7.2 KiB
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('다시 시도')),
|
|
),
|
|
]));
|
|
}
|
|
}
|