355 lines
13 KiB
Dart
355 lines
13 KiB
Dart
import 'dart:async';
|
|
import 'dart:math' as math;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
|
|
import '../app/app_controller.dart';
|
|
import '../core/reader_sources.dart';
|
|
import '../core/vfs.dart';
|
|
import '../l10n/tr.dart';
|
|
import 'theme.dart';
|
|
|
|
/// 그림 · 만화 · PDF 보기를 연다. [start] 처음 볼 장. 닫으면 [source] 를 정리한다.
|
|
Future<void> openReader(BuildContext context, AppController c, ReaderSource source, {int start = 0}) async {
|
|
await Navigator.of(context).push(MaterialPageRoute<void>(
|
|
builder: (_) => ReaderPage(c: c, source: source, start: start),
|
|
));
|
|
await source.dispose();
|
|
}
|
|
|
|
/// 그림 · 만화 · PDF 보기.
|
|
/// - 화면 오른쪽 (1/3) 을 누르면 다음 장, 왼쪽을 누르면 이전 장, 가운데를 누르면 위 · 아래 막대 보이기 · 숨기기
|
|
/// (오른쪽에서 왼쪽으로 넘기기를 켜면 반대). 좌우로 밀어도 넘어간다.
|
|
/// - 위 막대: 한 쪽 맞추기 · 좌우 맞추기, 넘기는 방향, 어둡게 · 밝게, (PDF) 페이지 지우기 · 그림 넣기 · 저장
|
|
class ReaderPage extends StatefulWidget {
|
|
final AppController c;
|
|
final ReaderSource source;
|
|
final int start;
|
|
const ReaderPage({super.key, required this.c, required this.source, this.start = 0});
|
|
|
|
@override
|
|
State<ReaderPage> createState() => _ReaderPageState();
|
|
}
|
|
|
|
class _ReaderPageState extends State<ReaderPage> {
|
|
AppController get c => widget.c;
|
|
ReaderSource get src => widget.source;
|
|
late final PageController _pages = PageController(initialPage: widget.start);
|
|
late int _index = widget.start;
|
|
bool _bars = true;
|
|
final _cache = <int, Future<ReaderImage>>{};
|
|
int _width = 0;
|
|
|
|
/// PDF 는 페이지를 지우거나 넣으면 다시 그린다
|
|
int _version = 0;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
// 보기 화면은 위 · 아래 시스템 막대까지 그림으로
|
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
|
for (final f in _cache.values) {
|
|
unawaited(f.then((x) => x.dispose(), onError: (_) {}));
|
|
}
|
|
_pages.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<ReaderImage> _load(int i) => _cache.putIfAbsent(i, () => src.load(i, maxWidth: _width));
|
|
|
|
/// 보는 장 가까이만 남기고 (메모리) 다음 장은 미리 읽는다
|
|
void _trim() {
|
|
for (final k in _cache.keys.toList()) {
|
|
if ((k - _index).abs() > 3) {
|
|
unawaited(_cache.remove(k)!.then((x) => x.dispose(), onError: (_) {}));
|
|
}
|
|
}
|
|
if (_index + 1 < src.length) unawaited(_load(_index + 1));
|
|
}
|
|
|
|
void _reset() {
|
|
for (final f in _cache.values) {
|
|
unawaited(f.then((x) => x.dispose(), onError: (_) {}));
|
|
}
|
|
_cache.clear();
|
|
_version++;
|
|
}
|
|
|
|
void _go(int delta) {
|
|
final to = (_index + delta).clamp(0, math.max(0, src.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) {
|
|
final x = d.localPosition.dx;
|
|
final rtl = c.settings.readerRtl;
|
|
if (x > width * 2 / 3) {
|
|
_go(rtl ? -1 : 1);
|
|
} else if (x < width / 3) {
|
|
_go(rtl ? 1 : -1);
|
|
} else {
|
|
setState(() => _bars = !_bars);
|
|
}
|
|
}
|
|
|
|
ColorFilter _brightness(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]);
|
|
}
|
|
|
|
Widget _page(int i, Size box) {
|
|
final fit = c.settings.readerFit;
|
|
return FutureBuilder<ReaderImage>(
|
|
key: ValueKey('$_version:$i'),
|
|
future: _load(i),
|
|
builder: (context, snap) {
|
|
if (snap.hasError) {
|
|
return Center(
|
|
child: Text(trf('열 수 없습니다: {0}', [snap.error]), style: const TextStyle(color: Colors.white70)));
|
|
}
|
|
final r = snap.data;
|
|
if (r == null) return const Center(child: CircularProgressIndicator());
|
|
Widget picture(BoxFit f, {double? width}) => r.image != null
|
|
? RawImage(image: r.image, fit: f, width: width)
|
|
: Image(image: r.provider!, fit: f, width: width, gaplessPlayback: true, filterQuality: FilterQuality.medium,
|
|
errorBuilder: (_, e, _) =>
|
|
Center(child: Text(trf('열 수 없습니다: {0}', [e]), style: const TextStyle(color: Colors.white70))));
|
|
if (fit == 'width') {
|
|
return SingleChildScrollView(child: picture(BoxFit.fitWidth, width: box.width));
|
|
}
|
|
return SizedBox.expand(child: picture(BoxFit.contain));
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> _updateSettings(void Function(dynamic s) f) async {
|
|
await c.updateSettings((s) => f(s));
|
|
if (mounted) setState(() {});
|
|
}
|
|
|
|
// ───────── PDF 편집 ─────────
|
|
|
|
PdfSource? get _pdf => src is PdfSource ? src as PdfSource : null;
|
|
|
|
Future<void> _deletePage() async {
|
|
final pdf = _pdf!;
|
|
if (pdf.length <= 1) {
|
|
_snack(tr('마지막 한 페이지는 지울 수 없습니다.'));
|
|
return;
|
|
}
|
|
final ok = await showDialog<bool>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: Text(tr('페이지 지우기')),
|
|
content: Text(trf('{0} 페이지를 지울까요? (저장해야 파일에 반영됩니다)', [_index + 1])),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
|
|
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('지우기'))),
|
|
],
|
|
),
|
|
);
|
|
if (ok != true) return;
|
|
await pdf.deletePage(_index);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_reset();
|
|
_index = _index.clamp(0, pdf.length - 1);
|
|
});
|
|
_pages.jumpToPage(_index);
|
|
}
|
|
|
|
Future<void> _insertImages() async {
|
|
final pdf = _pdf!;
|
|
final files = await c.services.storage.pickFiles(title: tr('PDF 에 넣을 그림 선택'), extensions: c.settings.imageExts);
|
|
if (files.isEmpty || !mounted) return;
|
|
final temp = await c.services.storage.tempDirectory();
|
|
try {
|
|
await pdf.insertImages(files, after: _index, tempDir: temp);
|
|
} catch (e) {
|
|
_snack(trf('넣지 못했습니다: {0}', [e]));
|
|
return;
|
|
}
|
|
setState(_reset);
|
|
_snack(trf('그림 {0}장을 {1} 페이지 뒤에 넣었습니다 (저장해야 파일에 반영됩니다).', [files.length, _index + 1]));
|
|
}
|
|
|
|
Future<void> _save() async {
|
|
try {
|
|
final path = await _pdf!.save();
|
|
_snack(trf('저장했습니다: {0}', [vDisplay(path)]));
|
|
setState(() {});
|
|
} catch (e) {
|
|
_snack(trf('저장하지 못했습니다: {0}', [e]));
|
|
}
|
|
}
|
|
|
|
Future<bool> _confirmLeave() async {
|
|
final pdf = _pdf;
|
|
if (pdf == null || !pdf.dirty) return true;
|
|
final r = await 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('저장'))),
|
|
],
|
|
),
|
|
);
|
|
if (r == 'save') await _save();
|
|
return r == 'save' || r == 'discard';
|
|
}
|
|
|
|
void _snack(String text) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(text)));
|
|
}
|
|
|
|
// ───────── 화면 ─────────
|
|
|
|
Widget _topBar() {
|
|
final s = c.settings;
|
|
final pdf = _pdf;
|
|
IconButton btn(IconData i, String tip, VoidCallback? f, {bool on = false}) => IconButton(
|
|
tooltip: tip,
|
|
color: on ? JjColors.accent : Colors.white,
|
|
icon: Icon(i),
|
|
onPressed: f,
|
|
);
|
|
return Container(
|
|
color: Colors.black.withValues(alpha: 0.75),
|
|
padding: EdgeInsets.only(top: MediaQuery.paddingOf(context).top),
|
|
child: SizedBox(
|
|
height: 52,
|
|
child: Row(children: [
|
|
btn(Icons.arrow_back, tr('닫기'), () => Navigator.maybePop(context)),
|
|
Expanded(
|
|
child: Text(
|
|
'${src.title}${pdf?.dirty == true ? ' *' : ''}',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(color: Colors.white, fontSize: 14),
|
|
),
|
|
),
|
|
// 좁아도 버튼은 옆으로 밀어 본다
|
|
Flexible(
|
|
flex: 3,
|
|
child: SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
reverse: true,
|
|
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
|
btn(Icons.fit_screen_outlined, tr('한 쪽 맞추기'), () => _updateSettings((x) => x.readerFit = 'page'),
|
|
on: s.readerFit == 'page'),
|
|
btn(Icons.width_full_outlined, tr('좌우 맞추기 (세로로 밀어 봄)'), () => _updateSettings((x) => x.readerFit = 'width'),
|
|
on: s.readerFit == 'width'),
|
|
btn(Icons.swap_horiz, s.readerRtl ? tr('넘기는 방향: 오른쪽 → 왼쪽 (만화)') : tr('넘기는 방향: 왼쪽 → 오른쪽'),
|
|
() => _updateSettings((x) => x.readerRtl = !x.readerRtl), on: s.readerRtl),
|
|
btn(Icons.brightness_low, tr('어둡게'),
|
|
() => _updateSettings((x) => x.readerBrightness = (x.readerBrightness - 0.1).clamp(-0.7, 0.7))),
|
|
btn(Icons.brightness_high, tr('밝게'),
|
|
() => _updateSettings((x) => x.readerBrightness = (x.readerBrightness + 0.1).clamp(-0.7, 0.7))),
|
|
if (pdf != null) ...[
|
|
btn(Icons.delete_outline, tr('이 페이지 지우기'), _deletePage),
|
|
btn(Icons.add_photo_alternate_outlined, tr('그림을 페이지로 넣기 (이 페이지 뒤)'), _insertImages),
|
|
btn(Icons.save_outlined, tr('저장'), pdf.dirty ? _save : null),
|
|
],
|
|
]),
|
|
),
|
|
),
|
|
]),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _bottomBar() {
|
|
final n = src.length;
|
|
return Container(
|
|
color: Colors.black.withValues(alpha: 0.75),
|
|
padding: EdgeInsets.only(bottom: MediaQuery.paddingOf(context).bottom, left: 12, right: 12),
|
|
child: Row(children: [
|
|
Text('${n == 0 ? 0 : _index + 1} / $n', style: const TextStyle(color: Colors.white, fontFamily: 'Consolas')),
|
|
Expanded(
|
|
child: n < 2
|
|
? const SizedBox(height: 48)
|
|
: Directionality(
|
|
textDirection: c.settings.readerRtl ? TextDirection.rtl : TextDirection.ltr,
|
|
child: Slider(
|
|
value: _index.toDouble(),
|
|
max: (n - 1).toDouble(),
|
|
divisions: n - 1,
|
|
onChanged: (v) => _pages.jumpToPage(v.round()),
|
|
),
|
|
),
|
|
),
|
|
Flexible(
|
|
child: Text(n == 0 ? '' : src.pageName(_index),
|
|
maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: Colors.white70, fontSize: 12)),
|
|
),
|
|
]),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final s = c.settings;
|
|
return PopScope(
|
|
canPop: _pdf?.dirty != true,
|
|
onPopInvokedWithResult: (didPop, _) async {
|
|
if (didPop) return;
|
|
if (await _confirmLeave() && context.mounted) {
|
|
_pdf?.dirty = false;
|
|
Navigator.of(context).pop();
|
|
}
|
|
},
|
|
child: Scaffold(
|
|
backgroundColor: Colors.black,
|
|
body: LayoutBuilder(builder: (context, box) {
|
|
// 화면 너비 (기기 픽셀) 에 맞춰 그림을 줄여 읽는다
|
|
_width = (box.maxWidth * MediaQuery.devicePixelRatioOf(context)).round().clamp(320, 4096);
|
|
return Stack(children: [
|
|
Positioned.fill(
|
|
child: GestureDetector(
|
|
onTapUp: (d) => _onTap(d, box.maxWidth),
|
|
child: ColorFiltered(
|
|
colorFilter: _brightness(s.readerBrightness),
|
|
child: src.length == 0
|
|
? Center(child: Text(tr('볼 그림이 없습니다.'), style: const TextStyle(color: Colors.white70)))
|
|
: PageView.builder(
|
|
key: ValueKey(_version),
|
|
controller: _pages,
|
|
reverse: s.readerRtl,
|
|
itemCount: src.length,
|
|
onPageChanged: (i) => setState(() {
|
|
_index = i;
|
|
_trim();
|
|
}),
|
|
itemBuilder: (_, i) => _page(i, box.biggest),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (_bars) ...[
|
|
Positioned(left: 0, right: 0, top: 0, child: _topBar()),
|
|
Positioned(left: 0, right: 0, bottom: 0, child: _bottomBar()),
|
|
],
|
|
]);
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
}
|