diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 69d693e..616f737 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -750,9 +750,11 @@ "되살리고 다시 시작": "Restore and restart", "되살리기": "Restore", "두 번 넣은 비밀번호가 다릅니다": "The two passwords don't match", + "두 쪽 나눔 끄기 (한 장으로 보기)": "Turn off two-page split (show as one)", "두 쪽 나눠 보기": "Split two-page spreads", "두 쪽 나눠 보기: 끄기 (누르면 자동)": "Split two-page spreads: off (tap for auto)", "두 쪽 나눠 보기: 자동 - 가로로 긴 그림만 (누르면 켜기)": "Split two-page spreads: auto - wide images only (tap to turn on)", + "두 쪽 나눠 보기: 자동 - 두 쪽 스캔으로 보이는 그림만 (누르면 켜기)": "Two-page split: Auto - only images that look like two-page scans (tap to turn on)", "두 쪽 나눠 보기: 켜기 (누르면 끄기)": "Split two-page spreads: on (tap to turn off)", "두 창": "Two panes", "두 창 배치": "Two-pane arrangement", @@ -1368,6 +1370,7 @@ "원본이 대상 안에 있어도 맞추기 (안쪽 → 바깥)": "Sync even when the source is inside the target (inner → outer)", "원본이 대상 안에 있으면 지우기 (--delete) 를 함께 쓸 수 없습니다 (대상의 다른 파일이 모두 지워집니다).": "When the source is inside the target, delete (--delete) can't be used (all other files in the target would be deleted).", "원본이 대상 안에 있으면 지우기 포함으로 맞출 수 없습니다": "Can't sync with delete when the source is inside the target", + "원본이 대상 폴더 안에 있습니다: 원본의 내용이 대상 폴더 바로 아래로 옮겨집니다 (원본 파일은 지워지고, 원본 폴더는 아래에서 고른 대로).": "The source is inside the destination folder: its contents are moved directly into the destination folder (source files are removed; the source folder is handled as chosen below).", "원본이 대상 폴더 안에 있습니다: 원본의 내용이 대상 폴더 바로 아래에 복사됩니다 (원본 폴더는 그대로 남음).": "The source is inside the target folder: its contents are copied directly under the target folder (the source folder stays).", "원본이 대상 폴더 안에 있습니다: 원본의 내용이 대상 폴더 바로 아래에 복사됩니다 (지우기는 쓸 수 없음).": "The source is inside the target folder: its contents are copied directly under the target folder (delete can't be used).", "원본이 바뀌면 대상에 맞춥니다. 일정을 정하면 그 시간에만 맞추고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다.": "When the source changes, the target is synced. With a schedule, sync happens only in that time; changes outside it are collected and synced at the next start time.", @@ -1907,6 +1910,7 @@ "파일이 보이지 않습니다: \"모든 파일에 대한 접근\" 권한이 없습니다": "Files are not shown: \"All files access\" is not allowed", "파일이 손상되었습니다 (SHA-256 다름): {0}": "The file is damaged (SHA-256 mismatch): {0}", "파일이 없습니다 (지워졌거나 옮겨짐)": "File not found (deleted or moved)", + "파일이 없어졌습니다": "The file is gone", "팝업 끄기": "Close pop-up", "팝업 보기": "Pop-up view", "패키지 라이선스": "Package licenses", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 4c6dcf6..03c3a3c 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -750,9 +750,11 @@ "되살리고 다시 시작": "復元して再起動", "되살리기": "元に戻す", "두 번 넣은 비밀번호가 다릅니다": "2 回入力したパスワードが違います", + "두 쪽 나눔 끄기 (한 장으로 보기)": "見開き分割をオフ (1 枚で表示)", "두 쪽 나눠 보기": "見開きを分けて表示", "두 쪽 나눠 보기: 끄기 (누르면 자동)": "見開きを分けて表示: オフ (押すと自動)", "두 쪽 나눠 보기: 자동 - 가로로 긴 그림만 (누르면 켜기)": "見開きを分けて表示: 自動 - 横長の画像のみ (押すとオン)", + "두 쪽 나눠 보기: 자동 - 두 쪽 스캔으로 보이는 그림만 (누르면 켜기)": "見開き分割: 自動 - 見開きスキャンらしい画像だけ (押すとオン)", "두 쪽 나눠 보기: 켜기 (누르면 끄기)": "見開きを分けて表示: オン (押すとオフ)", "두 창": "2 ペイン", "두 창 배치": "2 ペインの配置", @@ -1368,6 +1370,7 @@ "원본이 대상 안에 있어도 맞추기 (안쪽 → 바깥)": "元が対象の中にあっても同期 (内側 → 外側)", "원본이 대상 안에 있으면 지우기 (--delete) 를 함께 쓸 수 없습니다 (대상의 다른 파일이 모두 지워집니다).": "元が対象の中にある場合、削除 (--delete) は使えません (対象の他のファイルがすべて削除されます)。", "원본이 대상 안에 있으면 지우기 포함으로 맞출 수 없습니다": "元が対象の中にある場合、削除を含めて同期できません", + "원본이 대상 폴더 안에 있습니다: 원본의 내용이 대상 폴더 바로 아래로 옮겨집니다 (원본 파일은 지워지고, 원본 폴더는 아래에서 고른 대로).": "元のフォルダーが移動先の中にあります: 中身は移動先フォルダーの直下に移動します (元のファイルは削除され、元のフォルダーは下で選んだとおり)。", "원본이 대상 폴더 안에 있습니다: 원본의 내용이 대상 폴더 바로 아래에 복사됩니다 (원본 폴더는 그대로 남음).": "元が対象フォルダーの中にあります: 元の内容が対象フォルダーの直下にコピーされます (元フォルダーはそのまま)。", "원본이 대상 폴더 안에 있습니다: 원본의 내용이 대상 폴더 바로 아래에 복사됩니다 (지우기는 쓸 수 없음).": "元が対象フォルダーの中にあります: 元の内容が対象フォルダーの直下にコピーされます (削除は使えません)。", "원본이 바뀌면 대상에 맞춥니다. 일정을 정하면 그 시간에만 맞추고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다.": "元が変わると対象に合わせます。予定を決めるとその時間だけ同期し、それ以外は変更を貯めて次の開始時刻に同期します。", @@ -1907,6 +1910,7 @@ "파일이 보이지 않습니다: \"모든 파일에 대한 접근\" 권한이 없습니다": "ファイルが表示されません: 「すべてのファイルへのアクセス」権限がありません", "파일이 손상되었습니다 (SHA-256 다름): {0}": "ファイルが破損しています (SHA-256 不一致): {0}", "파일이 없습니다 (지워졌거나 옮겨짐)": "ファイルがありません (削除または移動)", + "파일이 없어졌습니다": "ファイルがなくなりました", "팝업 끄기": "ポップアップを閉じる", "팝업 보기": "ポップアップ表示", "패키지 라이선스": "パッケージのライセンス", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 05858bb..f50da36 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -750,9 +750,11 @@ "되살리고 다시 시작": "恢复并重新启动", "되살리기": "恢复", "두 번 넣은 비밀번호가 다릅니다": "两次输入的密码不一致", + "두 쪽 나눔 끄기 (한 장으로 보기)": "关闭双页拆分(整张显示)", "두 쪽 나눠 보기": "拆分双页", "두 쪽 나눠 보기: 끄기 (누르면 자동)": "拆分双页: 关闭 (点按切换为自动)", "두 쪽 나눠 보기: 자동 - 가로로 긴 그림만 (누르면 켜기)": "拆分双页: 自动 - 仅横向图片 (点按开启)", + "두 쪽 나눠 보기: 자동 - 두 쪽 스캔으로 보이는 그림만 (누르면 켜기)": "双页拆分:自动 - 仅拆分看起来像双页扫描的图片(点按开启)", "두 쪽 나눠 보기: 켜기 (누르면 끄기)": "拆分双页: 开启 (点按关闭)", "두 창": "双窗格", "두 창 배치": "双窗格排列", @@ -1368,6 +1370,7 @@ "원본이 대상 안에 있어도 맞추기 (안쪽 → 바깥)": "源位于目标内时也同步 (内 → 外)", "원본이 대상 안에 있으면 지우기 (--delete) 를 함께 쓸 수 없습니다 (대상의 다른 파일이 모두 지워집니다).": "源位于目标内时不能使用删除 (--delete) (会删除目标中的其他所有文件)。", "원본이 대상 안에 있으면 지우기 포함으로 맞출 수 없습니다": "源位于目标内时无法包含删除同步", + "원본이 대상 폴더 안에 있습니다: 원본의 내용이 대상 폴더 바로 아래로 옮겨집니다 (원본 파일은 지워지고, 원본 폴더는 아래에서 고른 대로).": "源文件夹位于目标文件夹内:其内容将移到目标文件夹下(源文件会被删除,源文件夹按下方的选择处理)。", "원본이 대상 폴더 안에 있습니다: 원본의 내용이 대상 폴더 바로 아래에 복사됩니다 (원본 폴더는 그대로 남음).": "源位于目标文件夹内: 源的内容会复制到目标文件夹下 (源文件夹保留)。", "원본이 대상 폴더 안에 있습니다: 원본의 내용이 대상 폴더 바로 아래에 복사됩니다 (지우기는 쓸 수 없음).": "源位于目标文件夹内: 源的内容会复制到目标文件夹下 (不能使用删除)。", "원본이 바뀌면 대상에 맞춥니다. 일정을 정하면 그 시간에만 맞추고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다.": "源变化时同步到目标。设定计划后只在该时间同步,其他时间的变化会累积到下次开始时间再同步。", @@ -1907,6 +1910,7 @@ "파일이 보이지 않습니다: \"모든 파일에 대한 접근\" 권한이 없습니다": "文件不可见: 没有“所有文件访问”权限", "파일이 손상되었습니다 (SHA-256 다름): {0}": "文件已损坏(SHA-256 不一致): {0}", "파일이 없습니다 (지워졌거나 옮겨짐)": "找不到文件 (已删除或移动)", + "파일이 없어졌습니다": "文件已不存在", "팝업 끄기": "关闭弹出窗口", "팝업 보기": "弹出窗口观看", "패키지 라이선스": "软件包许可", diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index e357066..63ef48c 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -2112,8 +2112,12 @@ class _ExplorerPageState extends State { ], Text(tr('폴더 안의 것을 맞춥니다 (대상에 같은 이름의 폴더를 새로 만들지 않음).'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + // 222: 이동이면 동작대로 (예전에는 이동일 때도 "복사 · 원본 폴더는 그대로" 라고 썼다) if (nested) - Text(tr('원본이 대상 폴더 안에 있습니다: 원본의 내용이 대상 폴더 바로 아래에 복사됩니다 (원본 폴더는 그대로 남음).'), + Text( + removeSource && !both + ? tr('원본이 대상 폴더 안에 있습니다: 원본의 내용이 대상 폴더 바로 아래로 옮겨집니다 (원본 파일은 지워지고, 원본 폴더는 아래에서 고른 대로).') + : tr('원본이 대상 폴더 안에 있습니다: 원본의 내용이 대상 폴더 바로 아래에 복사됩니다 (원본 폴더는 그대로 남음).'), style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)), // 1280×720 등 낮은 화면에서도 보이게: 원본 지우기 체크 · 경고는 비교 목록보다 위에 (178) if (both) ...[ diff --git a/lib/ui/reader_page.dart b/lib/ui/reader_page.dart index c3bb639..e50188c 100644 --- a/lib/ui/reader_page.dart +++ b/lib/ui/reader_page.dart @@ -29,6 +29,17 @@ typedef ReaderSlot = ({int page, int part}); /// (오른쪽에서 왼쪽으로 넘기기를 켜면 반대). 좌우로 밀어도 넘어간다. /// - 위 막대: 계속 보기 (117) · 두 쪽 나누기 (119) · 회전 · 상하 반전 (120) · 한 쪽 맞추기 · 좌우 맞추기, 넘기는 방향, /// 어둡게 · 밝게, (PDF) 페이지 지우기 · 그림 넣기 · 저장. 좁으면 못 들어간 버튼은 ⋮ 메뉴로. +/// 223: "두 쪽 나눠 보기: 자동" 이 이 장 ([size], 회전 반영) 을 나눌지 - 두 쪽 스캔일 가능성이 높을 때만. +/// [bundle] (ZIP · CBZ · PDF 로 연 묶음): 가로로 긴 그림은 나눈다 (만화 두 쪽 스캔이 흔함). +/// 폴더의 그림들: 같은 폴더의 다른 그림 ([others], 크기를 아는 것) 대부분 (3/4 이상, 2장 이상) 이 세로인데 이것만 가로일 때만 - +/// 평범한 가로 사진은 나누지 않는다 (예전에는 모두 반으로 잘라 "그림이 잘렸다" 고 보였다) +bool autoSplitsPage(Size size, List others, {required bool bundle}) { + if (size.width <= size.height) return false; + if (bundle) return true; + final tall = others.where((s) => s.height > s.width).length; + return others.length >= 2 && tall * 4 >= others.length * 3; +} + class ReaderPage extends StatefulWidget { final AppController c; final ReaderSource source; @@ -64,6 +75,59 @@ class _ReaderPageState extends State { _index = i < 0 ? 0 : i; _pages = PageController(initialPage: _index); _applySystemBars(); + // 224: 앱으로 돌아올 때 그사이 밖에서 지운 그림을 목록에서 뺀다 + _life = AppLifecycleListener(onResume: () => unawaited(dropMissingFiles())); + } + + late final AppLifecycleListener _life; + + /// 224: 폴더의 그림 중 없어진 것 (앱 밖에서 지움) 을 목록에서 뺀다. 보던 그림이 없어졌으면 알리고 다음 그림으로, + /// 남은 것이 없으면 닫는다 (예전에는 지운 그림이 그대로 보였다). 시험에서도 부른다 + @visibleForTesting + Future dropMissingFiles() async { + final s = src; + if (s is! ImageFilesSource) return; + final gone = { + for (var i = 0; i < s.paths.length; i++) + if (vMissingSync(s.paths[i])) i, + }; + if (gone.isEmpty || !mounted) return; + final curPage = _cur.page; + final curGone = gone.contains(curPage); + // 보던 그림 (없어졌으면 그 뒤의 남은 그림) 의 새 번호 + var keep = curPage; + while (keep < s.paths.length && gone.contains(keep)) { + keep++; + } + if (keep >= s.paths.length) { + keep = curPage; + while (keep >= 0 && gone.contains(keep)) { + keep--; + } + } + final newPage = keep < 0 ? -1 : keep - gone.where((g) => g < keep).length; + for (final g in gone.toList()..sort((a, b) => b - a)) { + s.paths.removeAt(g); + } + for (final f in [..._cache.values, ..._cacheHalf.values]) { + unawaited(f.then((x) => x.dispose(), onError: (_) {})); + } + _cache.clear(); + _cacheHalf.clear(); + _dims.clear(); + if (s.paths.isEmpty) { + _snack(tr('파일이 없어졌습니다')); + Navigator.of(context).maybePop(); + return; + } + setState(() { + _v = const []; + _rebuildPages(); + final i = _v.indexWhere((e) => e.page == newPage); + _index = i < 0 ? 0 : i; + }); + _jumpLater(); + if (curGone) _snack(tr('파일이 없어졌습니다')); } /// 보기 화면은 위 · 아래 시스템 막대까지 그림으로 (30: 설정으로 막대를 그대로 둘 수 있다) @@ -83,7 +147,7 @@ class _ReaderPageState extends State { int _turns = 0; bool _flipV = false; - /// 이 장을 반씩 나눠 볼지 (자동: 회전한 뒤 가로가 세로보다 긴 그림만) + /// 이 장을 반씩 나눠 볼지. 자동은 두 쪽 스캔일 가능성이 높을 때만 ([autoSplitsPage]) bool _splits(int page) { switch (c.settings.readerSplit) { case 'on': @@ -93,7 +157,19 @@ class _ReaderPageState extends State { } final d = _dims[page]; if (d == null) return false; - return _turns.isOdd ? d.height > d.width : d.width > d.height; + Size turned(Size s) => _turns.isOdd ? Size(s.height, s.width) : s; + return autoSplitsPage(turned(d), [for (final e in _dims.entries) if (e.key != page) turned(e.value)], + bundle: src is! ImageFilesSource); + } + + /// 223: 나눠 본 장에서 바로 한 장으로 (설정을 "끄기" 로) + Future _splitOff() async { + await c.updateSettings((x) => x.readerSplit = 'off'); + if (!mounted) return; + setState(() { + _resetZoom(); + _rebuildPages(); + }); } /// 보이는 쪽 목록을 다시 만든다. 보던 장은 그대로 보이게 (쪽 번호가 밀리면 그 자리로 옮긴다) @@ -259,6 +335,7 @@ class _ReaderPageState extends State { @override void dispose() { + _life.dispose(); _timer?.cancel(); if (_playing) unawaited(ReaderPage.keepScreenOn(false).catchError((_) {})); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); @@ -508,9 +585,11 @@ class _ReaderPageState extends State { final split = switch (s.readerSplit) { 'on' => tr('두 쪽 나눠 보기: 켜기 (누르면 끄기)'), 'off' => tr('두 쪽 나눠 보기: 끄기 (누르면 자동)'), - _ => tr('두 쪽 나눠 보기: 자동 - 가로로 긴 그림만 (누르면 켜기)'), + _ => tr('두 쪽 나눠 보기: 자동 - 두 쪽 스캔으로 보이는 그림만 (누르면 켜기)'), }; return [ + // 223: 나눠 보고 있으면 바로 한 장으로 (자동이 사진을 잘라 보인다고 여기지 않게 맨 앞에) + if (_cur.part >= 0) one(_Act(const Icon(Icons.crop_original), tr('두 쪽 나눔 끄기 (한 장으로 보기)'), _splitOff)), // 117: 계속 보기 · 간격 (▲ 늘리기 · ▼ 줄이기) one(_Act(Icon(_playing ? Icons.pause : Icons.play_arrow), _playing ? tr('계속 보기 멈추기') : trf('계속 보기 ({0}초마다 다음 장)', [secs]), () => _play(!_playing), diff --git a/test/dismiss_test.dart b/test/dismiss_test.dart index 0d0b486..f408b81 100644 --- a/test/dismiss_test.dart +++ b/test/dismiss_test.dart @@ -218,8 +218,12 @@ void main() { await settle(tester, () => find.text('rsync').evaluate().isNotEmpty && find.textContaining('비교하는 중').evaluate().isEmpty, rounds: 300); expect(find.text('rsync'), findsWidgets); + // 222: 원본이 대상 안 - 복사면 "복사됩니다 · 원본 폴더 그대로", 이동이면 "옮겨집니다 · 원본 파일은 지워짐" + expect(find.textContaining('바로 아래에 복사됩니다'), findsOneWidget); await tester.tap(find.text('원본 파일 지우기 (--remove-source-files)')); await tester.pump(); + expect(find.textContaining('바로 아래로 옮겨집니다 (원본 파일은 지워지고'), findsOneWidget); + expect(find.textContaining('바로 아래에 복사됩니다'), findsNothing); expect(find.text('원본이 대상 안에 있으면 지우기 (--delete) 를 함께 쓸 수 없습니다 (대상의 다른 파일이 모두 지워집니다).'), findsOneWidget); expect(tester.widget(find.widgetWithText(FilledButton, '이동')).onPressed, isNull); await tester.tap(find.text('취소').last); diff --git a/test/reader_more_test.dart b/test/reader_more_test.dart index a13ad4e..d7c7c02 100644 --- a/test/reader_more_test.dart +++ b/test/reader_more_test.dart @@ -28,6 +28,14 @@ class _Sized extends ReaderSource { } } +/// 223: 폴더의 그림들처럼 (자동 나누기는 다른 그림과 견주어 판단) +class _FolderSized extends ImageFilesSource { + final List images; + _FolderSized(this.images) : super([for (var i = 0; i < images.length; i++) 'p$i.png'], tempDir: '.', title: 'photos'); + @override + Future load(int i, {required int maxWidth}) async => ReaderImage(image: images[i].clone()); +} + void main() { late List screenOn; setUp(() { @@ -90,7 +98,7 @@ void main() { expect(find.text('2-2 / 3장'), findsOneWidget); // 버튼: 자동 → 켜기 (모든 장) → 끄기 → 자동 - await t.tap(find.byTooltip('두 쪽 나눠 보기: 자동 - 가로로 긴 그림만 (누르면 켜기)')); + await t.tap(find.byTooltip('두 쪽 나눠 보기: 자동 - 두 쪽 스캔으로 보이는 그림만 (누르면 켜기)')); await t.pumpAndSettle(); expect(c.settings.readerSplit, 'on'); expect(find.text('2-2 / 3장'), findsOneWidget, reason: '보던 쪽 그대로'); @@ -105,6 +113,38 @@ void main() { expect(c.settings.readerSplit, 'auto'); }); + test('223: 자동 나누기 판단 - 묶음 (ZIP · CBZ · PDF) 의 가로 그림은 나눔, 폴더는 다른 그림 대부분이 세로일 때만', () { + const wide = Size(480, 320), tall = Size(320, 480); + expect(autoSplitsPage(wide, const [], bundle: true), isTrue); + expect(autoSplitsPage(tall, const [], bundle: true), isFalse); + // 사진 폴더: 가로 사진 한 장 · 가로 사진들 사이 → 나누지 않음 + expect(autoSplitsPage(wide, const [], bundle: false), isFalse); + expect(autoSplitsPage(wide, const [wide, wide, tall], bundle: false), isFalse); + // 만화 폴더 (낱장 세로들 사이의 펼침 한 장) → 나눔 + expect(autoSplitsPage(wide, const [tall, tall, tall, tall], bundle: false), isTrue); + expect(autoSplitsPage(wide, const [tall], bundle: false), isFalse, reason: '비교할 그림이 너무 적으면 나누지 않음'); + }); + + testWidgets('223: 폴더의 평범한 가로 사진 (480×320) 은 자동이어도 반으로 자르지 않는다', (t) async { + final c = controller(); + final images = []; + await t.runAsync(() async => images.add(await createTestImage(width: 480, height: 320))); + await open(t, c, _FolderSized(images)); + expect(find.text('1 / 1'), findsOneWidget, reason: '"1-1 / 1장" 처럼 나뉘지 않음'); + expect(find.byTooltip('두 쪽 나눔 끄기 (한 장으로 보기)'), findsNothing); + }); + + testWidgets('223: 나눠 본 장에서는 위 막대의 [두 쪽 나눔 끄기] 로 바로 한 장으로', (t) async { + final c = controller(); + final src = await source(t, [(200, 100)]); + await open(t, c, src); + expect(find.text('1-1 / 1장'), findsOneWidget); + await t.tap(find.byTooltip('두 쪽 나눔 끄기 (한 장으로 보기)')); + await t.pumpAndSettle(); + expect(c.settings.readerSplit, 'off'); + expect(find.text('1 / 1'), findsOneWidget); + }); + testWidgets('119: 오른쪽 → 왼쪽으로 읽으면 오른쪽 반부터', (t) async { final c = controller()..settings.readerRtl = true; final src = await source(t, [(200, 100)]); diff --git a/test/reader_test.dart b/test/reader_test.dart index b7c8d74..7a43932 100644 --- a/test/reader_test.dart +++ b/test/reader_test.dart @@ -69,6 +69,45 @@ void main() { expect(AppSettings.fromJson({}).imageExts, defaultImageExtensions); }); + testWidgets('224: 보는 동안 밖에서 지운 그림 - 앱으로 돌아오면 "파일이 없어졌습니다" 와 다음 그림, 다 없어지면 닫는다', (tester) async { + tester.view.physicalSize = const Size(900, 1200); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final files = [ + for (final (i, n) in ['a.png', 'b.png', 'c.png'].indexed) + (File(p.join(tmp.path, n))..writeAsBytesSync(_png(i * 80, 0, 0))).path, + ]; + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + final src = ImageFilesSource([...files], tempDir: tmp.path, title: 'imgs'); + await tester.pumpWidget(MaterialApp( + home: Builder( + builder: (ctx) => TextButton( + onPressed: () => Navigator.of(ctx).push(MaterialPageRoute(builder: (_) => ReaderPage(c: c, source: src, start: 1))), + child: const Text('open'))))); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + expect(find.text('2 / 3'), findsOneWidget); + expect(find.text('b.png'), findsOneWidget); + // 보던 b 를 밖에서 지움 → 돌아오면 c 로 + await tester.runAsync(() => File(files[1]).delete()); + await ((tester.state(find.byType(ReaderPage)) as dynamic).dropMissingFiles() as Future); + await tester.pumpAndSettle(); + expect(find.text('파일이 없어졌습니다'), findsOneWidget); + expect(find.text('c.png'), findsOneWidget); + expect(find.text('2 / 2'), findsOneWidget); + // 나머지도 다 지우면 보기 화면을 닫는다 + await tester.runAsync(() async { + await File(files[0]).delete(); + await File(files[2]).delete(); + }); + await ((tester.state(find.byType(ReaderPage)) as dynamic).dropMissingFiles() as Future); + await tester.pumpAndSettle(); + expect(find.byType(ReaderPage), findsNothing); + expect(find.text('open'), findsOneWidget); + ScaffoldMessenger.of(tester.element(find.text('open'))).clearSnackBars(); + await tester.pumpAndSettle(); + }); + testWidgets('그림 보기: 오른쪽을 누르면 다음, 왼쪽을 누르면 이전, 가운데는 막대 숨기기 · 오른쪽→왼쪽 넘기기', (tester) async { tester.view.physicalSize = const Size(900, 1200); tester.view.devicePixelRatio = 1;