147 lines
7.1 KiB
Dart
147 lines
7.1 KiB
Dart
// 보기 화면 창 모듈: 사진 정보 (Exif) · PDF 페이지 지우기 확인 · 저장 묻기 · PDF 에 넣을 그림 고르기
|
||
//
|
||
// 약속
|
||
// - 입력: 그림 경로 · 임시 폴더 · 페이지 번호 (0 부터) · 앱 ([AppController] - 파일 고르기)
|
||
// - 출력: [readPhotoFile] 은 사진 정보 · 파일 크기 · 오류 (셋 중 읽은 것). 창들은 고른 답 (닫으면 null)
|
||
// - 오류: [readPhotoFile] 은 던지지 않고 오류를 돌려준다. 창은 던지지 않는다
|
||
// 창은 부른 쪽이 지금 장면이 끝난 뒤 (endOfFrame) 띄운다 (28 지적: 누른 이벤트 안에서 띄우면 의미 트리 assertion).
|
||
// 다른 화면도 다시 쓸 수 있다.
|
||
|
||
import 'dart:io' show File;
|
||
|
||
import 'package:flutter/material.dart';
|
||
|
||
import '../../app/app_controller.dart';
|
||
import '../../core/file_ops.dart' show formatSize;
|
||
import '../../core/photo_info.dart';
|
||
import '../../core/reader_sources.dart';
|
||
import '../../core/vfs.dart';
|
||
import '../../l10n/tr.dart';
|
||
import '../theme.dart';
|
||
|
||
/// 지금 장면이 끝난 뒤 (창 띄우기 전 - 28 지적 (10/20): 위 막대 단추를 누른 그 이벤트 안에서 창을 띄우면 (장을 손으로 넘긴 뒤)
|
||
/// 창을 닫을 때 디버그 판에서 의미 트리 assertion ('node.built') 이 났다. 부른 쪽은 뒤에 mounted 확인)
|
||
Future<void> afterFrame() => WidgetsBinding.instance.endOfFrame;
|
||
|
||
/// X-plore 맞추기 (관리자 지시 10/20): 폴더 그림 [src] 의 지금 장 ([page] - 창을 띄울 때 읽음) 사진 정보 창.
|
||
/// 지금 장면이 끝난 뒤에 띄운다 (Windows 통합 시험에서 재현 4번 → 0번). 폴더 그림이 아니거나 볼 장이 없으면 아무것도 안 함
|
||
Future<void> showReaderPhotoInfo(BuildContext context, ReaderSource src, int Function() page, {required bool Function() hasPages}) async {
|
||
await afterFrame();
|
||
if (!context.mounted) return;
|
||
final s = src;
|
||
if (s is! ImageFilesSource || !hasPages()) return;
|
||
final path = s.paths[page()];
|
||
final r = await readPhotoFile(path, s.tempDir);
|
||
if (!context.mounted) return;
|
||
await showPhotoInfoDialog(context, path: path, info: r.info, size: r.size, error: r.error);
|
||
}
|
||
|
||
/// [path] 그림 (WebDAV 면 [tempDir] 에 받아서) 의 사진 정보와 파일 크기. 못 읽으면 [error]
|
||
Future<({PhotoInfo? info, int? size, Object? error})> readPhotoFile(String path, String tempDir) async {
|
||
PhotoInfo? info;
|
||
int? size;
|
||
Object? error;
|
||
try {
|
||
final local = await vLocalCopy(path, tempDir);
|
||
final f = File(local);
|
||
size = await f.length();
|
||
final bytes = await f.readAsBytes();
|
||
info = readPhotoInfo(bytes);
|
||
} catch (e) {
|
||
error = e;
|
||
}
|
||
return (info: info, size: size, error: error);
|
||
}
|
||
|
||
/// X-plore 맞추기 (관리자 지시 10/20): 사진 정보 (찍은 날 · 기기 · 크기 · 위치). 위치는 [위치 보기] 를 누를 때만
|
||
/// 보이고, 지도 · 인터넷으로 보내지 않는다 (링크 없음)
|
||
Future<void> showPhotoInfoDialog(BuildContext context,
|
||
{required String path, required PhotoInfo? info, required int? size, required Object? error}) async {
|
||
var showLocation = false;
|
||
await showDialog<void>(
|
||
context: context,
|
||
builder: (ctx) => StatefulBuilder(
|
||
builder: (ctx, set) {
|
||
final i = info;
|
||
Widget row(String k, Widget v) => Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
SizedBox(width: 90, child: Text(k, style: TextStyle(color: JjColors.textDim))),
|
||
Expanded(child: v),
|
||
]),
|
||
);
|
||
return AlertDialog(
|
||
scrollable: true,
|
||
title: Text(tr('사진 정보 (Exif)')),
|
||
content: SizedBox(
|
||
width: 420,
|
||
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
row(tr('파일'), SelectableText(vBasename(path))),
|
||
if (i == null)
|
||
Text(trf('읽을 수 없습니다: {0}', [error]))
|
||
else ...[
|
||
row(tr('크기'), Text([
|
||
if (i.width != null) trf('{0} × {1} 픽셀', [i.width!, i.height!]),
|
||
if (size != null) formatSize(size),
|
||
].join(' · '))),
|
||
row(tr('찍은 날'), Text(i.taken ?? '-')),
|
||
row(tr('기기'), Text(i.device ?? '-')),
|
||
row(
|
||
tr('위치'),
|
||
!i.hasLocation
|
||
? const Text('-')
|
||
: showLocation
|
||
? SelectableText(i.locationText)
|
||
: Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: TextButton(onPressed: () => set(() => showLocation = true), child: Text(tr('위치 보기'))))),
|
||
if (i.hasLocation)
|
||
Text(tr('위치는 이 기기에서만 보이며 지도 · 인터넷으로 보내지 않습니다'),
|
||
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||
if (!i.hasExif)
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 6),
|
||
child: Text(tr('이 그림에는 Exif 정보가 없습니다 (JPEG 사진만 읽음)'),
|
||
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||
),
|
||
],
|
||
]),
|
||
),
|
||
actions: [TextButton(onPressed: () => Navigator.of(ctx).pop(), child: Text(tr('닫기')))],
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
/// PDF [page] (0 부터) 페이지를 지울지 묻는다. 지우기 = true
|
||
Future<bool?> confirmPdfPageDelete(BuildContext context, int page) => showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
title: Text(tr('페이지 지우기')),
|
||
content: Text(trf('{0} 페이지를 지울까요? (저장해야 파일에 반영됩니다)', [page + 1])),
|
||
actions: [
|
||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
|
||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('지우기'))),
|
||
],
|
||
),
|
||
);
|
||
|
||
/// 저장하지 않은 변경을 어떻게 할지: 'save' · 'discard' · 'cancel' (닫으면 null)
|
||
Future<String?> askSaveChanges(BuildContext context) => 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('저장'))),
|
||
],
|
||
),
|
||
);
|
||
|
||
/// PDF 에 넣을 그림을 고른다 (설정의 그림 확장자). 고르지 않으면 빈 목록
|
||
Future<List<String>> pickPdfImages(AppController c) =>
|
||
c.services.storage.pickFiles(title: tr('PDF 에 넣을 그림 선택'), extensions: c.settings.imageExts);
|