505 lines
19 KiB
Dart
505 lines
19 KiB
Dart
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<void> openReader(BuildContext context, AppController c, ReaderSource source, {int start = 0, bool autoPlay = false}) async {
|
|
await Navigator.of(context).push(MaterialPageRoute<void>(
|
|
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<void> Function(bool on) keepScreenOn = (on) => on ? WakelockPlus.enable() : WakelockPlus.disable();
|
|
|
|
@override
|
|
State<ReaderPage> createState() => _ReaderPageState();
|
|
}
|
|
|
|
class _ReaderPageState extends State<ReaderPage> {
|
|
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 = <int, int>{};
|
|
|
|
@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<void> 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<ReaderSlot> _v = const [];
|
|
ReaderSlot get _cur => slotAt(_v, _index);
|
|
|
|
/// 원본 그림의 크기 (읽어 보고 안 것)
|
|
final _dims = <int, Size>{};
|
|
|
|
/// 시계 방향 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<void> _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<void> _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<void> _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<ReaderImage> _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<void> _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<void> _deletePage() async {
|
|
final page = await deletePdfPage(context, _pdf!, () => _cur.page, _snack);
|
|
if (page == null || !mounted) return;
|
|
setState(() => _reset(page: page));
|
|
}
|
|
|
|
Future<void> _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<void> _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<ReaderBarItem> _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<void> _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<void> _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,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|