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