From 8335a3eac749e780cd0942163cef7fe8a13a0731 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Thu, 22 Oct 2026 07:53:09 +0900 Subject: [PATCH] =?UTF-8?q?E4:=20=ED=83=90=EC=83=89=EA=B8=B0=20=EB=B6=88?= =?UTF-8?q?=ED=8E=B8=20=EA=B3=A0=EC=B9=A8=20-=20=EA=B3=B5=EC=9A=A9=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20showNotice=20(13=EA=B3=B3)=20=C2=B7=20?= =?UTF-8?q?=EA=B3=B5=EC=9A=A9=20=ED=99=95=EC=9D=B8=20=EC=B0=BD=20confirmDi?= =?UTF-8?q?alog=20=C2=B7=20=EB=91=90=20=EC=B0=BD=20=EC=B2=98=EC=9D=8C=20?= =?UTF-8?q?=ED=8F=B4=EB=8D=94=20=C2=B7=20=EB=8B=A4=EB=A5=B8=20=EC=B0=BD=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=20=EC=95=88=EB=82=B4=20=C2=B7=20=EC=A7=80?= =?UTF-8?q?=EA=B8=88=20=EC=A4=84=20=EA=B0=95=EC=A1=B0=20=C2=B7=20=ED=8F=B0?= =?UTF-8?q?=20=EB=91=90=20=EC=B0=BD=20=EB=84=98=EC=B9=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 점검 (wiki 파일 탐색기 사용 점검 10-22) U1 · U2 · U4 · U5 · U7 · U8, 관리자 승인 10/22 - U2: lib/ui/notice.dart showNotice - 단추가 있는 알림은 앱 전체 한 값 noticeDuration (10초) 뒤 저절로 닫힘, 모두 ✕. 단추 있는 SnackBar 13곳을 모두 옮김. 사용자가 닫을 때까지 두는 것은 248 DNS · 198 감지한 언어, 부르는 쪽이 닫는 것은 AI 시작 전 [취소] · 다운로드 준비 (untilClosed) - U7: lib/ui/confirm_dialog.dart confirmDialog - [확인] 만 true · Enter = 첫 초점, 되돌릴 수 없는 일 (영구 삭제 · 늘 덮어쓰기) 은 첫 초점 [취소] (관리자 결정). 탐색기 지우기 · 영구 삭제 2곳 · 복사 · 이동 · 같은 폴더를 옮김 (나머지는 W) - U4: 복사 · 이동 · 지우기 확인 창에 "다른 창에 N개 표시됨 (이번 대상 아님)" - 다른 창 표시는 그대로 (관리자 결정) - U1: 저장된 경로가 없을 때만 오른쪽 창은 두 번째 저장 장치 - U5: 지금 줄은 진한 바탕 + 왼쪽 강조 막대 - U8: rc2 e785ba9 (TailText) 로 이미 됨 - 412 캡처로 확인 - 폰 세로 두 창 (E3 규칙 7) 넘침 2곳: 아래 표시 줄은 공용 lib/ui/adaptive_row.dart AdaptiveRow (좁으면 두 줄), 진행 패널 이름 칸은 min(300, 폭 45%). 넓은 화면은 그대로 - explorer_page 는 시각을 생성자로 받음 (clock, 처음 값 DateTime.now) - 폴더 두 번 누르기 판단을 시험이 정한 시각으로 - 600줄 규칙: NameClash · clashCompareText · fileDateText 를 lib/core/name_clash.dart 로 (explorer_page 6,605 → 6,590), 시험 도우미 testNow · refreshKey 를 test/support/explorer_clock_keys.dart 로 (explorer_test 2,011 → 2,009) 기대값을 바꾼 시험 (근거): - browser_test 실행 취소 알림 5초 → noticeDuration: 5초는 정해진 값이 아님. 10/5 967b729 (HANDOFF 12) 가 정한 것은 "버튼 달린 알림은 저절로 사라짐" 이고 앱은 시간을 정하지 않아 Flutter 기본 4초였다. 성질은 지키고 시간은 관리자가 승인한 앱 전체 한 값 (U2) - explorer_test "두 창: …" 다시 눌러도 접지 않음 (189) → 다시 누르면 접힘: 선택 규칙 노트 규칙 3 "폴더 한 번 누르기 = 펼치기 · 접기" (노트 머리: 관리자 판단과 다르면 이 노트를 따름) · E3 승인. E3 에서는 두 번째 누르기가 600ms 안에 들어와 우연히 통과했었음 → 시각 주입 (testNow) 으로 고침 확인: analyze 0 · 전체 단위 2회 통과 1,306 · 건너뜀 17 (E4 실패 0) · 점검 통합 시험 (1920 · 960 · 412) 2회 통과, 넘침 0, 두 회 파일 결과 같음. 약속 시험 notice 4 · confirm 3 (지문 notice.dart 29b3c951 · confirm_dialog.dart ff964f5f) Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 1 + assets/l10n/ja.json | 1 + assets/l10n/zh-Hans.json | 1 + lib/core/name_clash.dart | 30 ++ lib/main.dart | 14 +- lib/ui/adaptive_row.dart | 22 + lib/ui/added_to_mkv_notice.dart | 22 +- lib/ui/ai_dialog.dart | 30 +- lib/ui/ai_upscale_ui.dart | 7 +- lib/ui/bookmark_ui.dart | 12 +- lib/ui/browser_page.dart | 13 +- lib/ui/confirm_dialog.dart | 52 ++ lib/ui/explorer_page.dart | 711 ++++++++++++------------- lib/ui/notice.dart | 38 ++ test/browser_test.dart | 6 +- test/confirm_dialog_contract_test.dart | 79 +++ test/explorer_test.dart | 38 +- test/notice_contract_test.dart | 67 +++ test/support/explorer_clock_keys.dart | 20 + 19 files changed, 725 insertions(+), 439 deletions(-) create mode 100644 lib/core/name_clash.dart create mode 100644 lib/ui/adaptive_row.dart create mode 100644 lib/ui/confirm_dialog.dart create mode 100644 lib/ui/notice.dart create mode 100644 test/confirm_dialog_contract_test.dart create mode 100644 test/notice_contract_test.dart create mode 100644 test/support/explorer_clock_keys.dart diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 7dc699c..424fcea 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -1032,6 +1032,7 @@ "다른 저장소로는 이름만 바꿀 수 없습니다": "Cannot just rename onto another storage", "다른 창 폴더": "Other pane's folder", "다른 창 폴더로": "To the other pane's folder", + "다른 창에 {0}개 표시됨 (이번 대상 아님)": "{0} marked in the other pane (not part of this action)", "다른 창에서 열기": "Open in other pane", "다른 창으로 끌어 놓았을 때. 어느 쪽이든 복사 · 이동 확인 창 (같은 이름 등) 은 그대로 뜹니다": "When you drop onto the other pane. Either way, the copy/move confirmation (same names etc.) still appears", "다른 창으로 복사": "Copy to other pane", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index e4a0142..b05d2a0 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -1032,6 +1032,7 @@ "다른 저장소로는 이름만 바꿀 수 없습니다": "別のストレージへは名前の変更だけではできません", "다른 창 폴더": "もう一方のウィンドウのフォルダー", "다른 창 폴더로": "もう一方の画面のフォルダーへ", + "다른 창에 {0}개 표시됨 (이번 대상 아님)": "もう一方の画面に {0} 個のマーク (今回の対象外)", "다른 창에서 열기": "もう一方のペインで開く", "다른 창으로 끌어 놓았을 때. 어느 쪽이든 복사 · 이동 확인 창 (같은 이름 등) 은 그대로 뜹니다": "もう一方のウィンドウにドロップしたとき。どちらでもコピー・移動の確認 (同じ名前など) は表示されます", "다른 창으로 복사": "もう一方のペインへコピー", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 40ab8b0..64714fd 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -1032,6 +1032,7 @@ "다른 저장소로는 이름만 바꿀 수 없습니다": "不能仅通过重命名移到其他存储", "다른 창 폴더": "另一窗格的文件夹", "다른 창 폴더로": "到另一窗格的文件夹", + "다른 창에 {0}개 표시됨 (이번 대상 아님)": "另一窗格中已标记 {0} 个 (不在本次操作内)", "다른 창에서 열기": "在另一窗格中打开", "다른 창으로 끌어 놓았을 때. 어느 쪽이든 복사 · 이동 확인 창 (같은 이름 등) 은 그대로 뜹니다": "拖放到另一个窗格时。无论哪种,都会显示复制/移动确认 (同名等)", "다른 창으로 복사": "复制到另一窗格", diff --git a/lib/core/name_clash.dart b/lib/core/name_clash.dart new file mode 100644 index 0000000..79eb59c --- /dev/null +++ b/lib/core/name_clash.dart @@ -0,0 +1,30 @@ +import '../l10n/tr.dart'; +import 'file_ops.dart' show formatSize; +import 'vfs.dart' show vBasename; + +/// 약속: 같은 이름 비교 글 ([clashCompareText]) · 파일 날짜 글 ([fileDateText]). 순수 함수, 오류 없음. +/// 탐색기 복사 확인 창 · 끌어 놓기 · 붙여넣기가 함께 쓴다. + +/// 172: 같은 이름 - 보낼 것 (복사 · 이동, [here]) 과 대상 폴더에 이미 있는 것 ([there]) +typedef NameClash = ({String src, ({bool isDir, int size, DateTime modified})? here, ({bool isDir, int size, DateTime modified}) there}); + +/// 172: "이름: 보낼 것 1.2 MB · 2026-10-09 14:00 / 있는 것 900 KB · 2026-10-01 09:00 (보낼 것이 더 새것)" +String clashCompareText(NameClash x) { + String one(({bool isDir, int size, DateTime modified}) s) => + [if (!s.isDir) formatSize(s.size) else tr('폴더'), if (s.modified.year > 1) fileDateText(s.modified)].join(' · '); + final h = x.here, t = x.there; + final newer = h == null || h.modified.year <= 1 || t.modified.year <= 1 + ? '' + : h.modified.isAfter(t.modified.add(const Duration(seconds: 2))) + ? tr(' (보낼 것이 더 새것)') + : t.modified.isAfter(h.modified.add(const Duration(seconds: 2))) + ? tr(' (있는 것이 더 새것)') + : tr(' (바뀐 날 같음)'); + return '${vBasename(x.src)}: ${trf('보낼 것 {0} / 있는 것 {1}', [h == null ? '?' : one(h), one(t)])}$newer'; +} + +/// 203: 바뀐 날을 모름 (잠긴 시스템 파일은 1970-01-01 로 온다) 은 "—" +String fileDateText(DateTime d) => d.year <= 1970 + ? '—' + : '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')} ' + '${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}'; diff --git a/lib/main.dart b/lib/main.dart index 3e803c4..1127b7c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -71,6 +71,7 @@ import 'ui/work_panel.dart'; import 'l10n/tr.dart'; import 'app/i18n_controller.dart'; import 'platform/android/android_updater.dart'; +import 'ui/notice.dart'; /// 제목 표시줄 글: "JJ_MKVMaker v1.2.3" String appTitle = 'JJ_MKVMaker'; @@ -520,18 +521,15 @@ void watchDnsFailure(GlobalKey messengerKey, AppControll if (f == null) return; // 248: 같은 안내를 보이는 화면 (브라우저 덮개 · 설정의 DNS 칸) 이 열려 있으면 띄우지 않는다 (상태는 설정 · 작업 기록 줄에) if (NetGuard.instance.noticeBlockers.value > 0) return; - messengerKey.currentState?.showSnackBar(SnackBar( + showNotice( + messengerKey.currentState, // 248: 버튼이 있는 안내라 사용자가 닫거나 누를 때까지 둔다 (한 번만 뜨고, 상태는 설정 · 작업 현황에 남음 - 249) - persist: true, - showCloseIcon: true, + life: NoticeLife.untilClosed, // 사용자에게는 무엇이 안 되고 무엇을 할지만 (예외 원문은 작업 기록에 - NetGuard.reportError). // 상태가 풀리거나 같은 안내를 보이는 화면이 열리면 스스로 닫힌다 (248) content: DnsFailureNotice(failure: f), - action: SnackBarAction( - label: tr('시스템 DNS 로 바꾸기'), - onPressed: () => c.updateSettings((x) => x.dnsMode = 'system'), - ), - )); + action: (tr('시스템 DNS 로 바꾸기'), () => c.updateSettings((x) => x.dnsMode = 'system')), + ); }); } diff --git a/lib/ui/adaptive_row.dart b/lib/ui/adaptive_row.dart new file mode 100644 index 0000000..f1d1b75 --- /dev/null +++ b/lib/ui/adaptive_row.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; + +/// 약속 (설계 §7-2 AdaptiveRow, 점검 10/22): 앞 묶음 [lead] 과 뒤 묶음 [trail] 을 놓는다. +/// 폭이 [minWidth] 이상이면 한 줄 (앞 · 빈칸 · 뒤), 모자라면 두 줄 (앞 줄 아래에 뒤 묶음을 줄바꿈하며 오른쪽 정렬). +/// 넘치지 않는다 (좁은 창 - 폰 세로의 두 창). 넓은 화면의 모양은 한 줄 Row 그대로. +class AdaptiveRow extends StatelessWidget { + const AdaptiveRow({super.key, required this.lead, required this.trail, required this.minWidth}); + + final List lead; + final List trail; + final double minWidth; + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, box) => box.maxWidth >= minWidth + ? Row(children: [...lead, const Spacer(), ...trail]) + : Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: lead), + Wrap(alignment: WrapAlignment.end, children: trail), + ]), + ); +} diff --git a/lib/ui/added_to_mkv_notice.dart b/lib/ui/added_to_mkv_notice.dart index c397287..6d506aa 100644 --- a/lib/ui/added_to_mkv_notice.dart +++ b/lib/ui/added_to_mkv_notice.dart @@ -3,27 +3,25 @@ import 'package:flutter/material.dart'; import '../app/app_controller.dart'; import '../core/file_ops.dart' show samePath; import '../l10n/tr.dart'; +import 'notice.dart'; /// "완료 시 자동 동영상 추가" 로 다 받은 동영상을 MKV 목록에 넣고 다운로드 목록에서 뺐을 때의 알림. /// 줄이 갑자기 사라져 "다운로드 (0)" 이 된 이유를 알 수 있게 [보기] (MKV 화면 · 그 동영상) 를 함께 (관리자 판단, 931) void showAddedToMkvNotice(GlobalKey messenger, GlobalKey navigator, AppController c, {required String title, required List files, required int added}) { - final m = messenger.currentState; - if (m == null) return; - m.showSnackBar(SnackBar( - duration: const Duration(seconds: 8), - showCloseIcon: true, - content: Text(added > 0 + showNotice( + messenger.currentState, + text: added > 0 ? trf('다 받은 {0} 을 MKV 목록에 넣고 다운로드 목록에서 뺐습니다 (받은 파일은 그대로)', [title]) - : trf('다 받은 {0} 은 이미 MKV 목록에 있어 다운로드 목록에서 뺐습니다 (받은 파일은 그대로)', [title])), - action: SnackBarAction( - label: tr('보기'), - onPressed: () { + : trf('다 받은 {0} 은 이미 MKV 목록에 있어 다운로드 목록에서 뺐습니다 (받은 파일은 그대로)', [title]), + action: ( + tr('보기'), + () { // MKV 화면 (맨 처음 화면) 으로 가서 그 동영상을 보이게 navigator.currentState?.popUntil((r) => r.isFirst); final v = c.videos.where((x) => files.any((f) => samePath(f, x.path))).firstOrNull; if (v != null) c.select(v); - }, + } ), - )); + ); } diff --git a/lib/ui/ai_dialog.dart b/lib/ui/ai_dialog.dart index e49270a..0c434b4 100644 --- a/lib/ui/ai_dialog.dart +++ b/lib/ui/ai_dialog.dart @@ -10,6 +10,7 @@ import 'existing_output_dialog.dart'; import 'license_texts.dart' show NllbLicenseNote; import 'theme.dart'; import '../l10n/tr.dart'; +import 'notice.dart'; /// AI 자막 만들기 설정 창 /// [targets] 를 주면 그 동영상들만 (탐색기 "자막 만들기" 메뉴 · 여러 개 선택) @@ -44,12 +45,13 @@ Future showAiDialog(BuildContext context, AppController c, VideoItem curre final messenger = ScaffoldMessenger.maybeOf(context); if (messenger == null) return run(list, c.aiOptions); var cancelled = false; - messenger.showSnackBar(SnackBar( - duration: aiStartDelay, - content: Text(aiStartNotice(list, c.aiOptions, thenBuild: thenBuild)), - action: SnackBarAction(label: tr('취소'), onPressed: () => cancelled = true), - )); + // 시작까지 [취소] 를 받다가 시작하면 닫는다 (부르는 쪽이 닫음) + final bar = showNotice(messenger, + life: NoticeLife.untilClosed, + text: aiStartNotice(list, c.aiOptions, thenBuild: thenBuild), + action: (tr('취소'), () => cancelled = true)); await Future.delayed(aiStartDelay); + bar?.close(); if (cancelled) return; return run(list, c.aiOptions); } @@ -67,22 +69,22 @@ void showDetectedLanguage(ScaffoldMessengerState messenger, OverlayState? overla final names = found.length == 1 ? found.single.$2.name : found.map((x) => '${x.$2.name} (${x.$1.fileName})').join(' · '); - messenger.showSnackBar(SnackBar( + showNotice( + messenger, // 248 과 같게: 버튼이 있는 안내는 사용자가 닫을 때까지 ([✕]). 나중에는 MKV 목록의 그 동영상 세부 정보에서도 - persist: true, - showCloseIcon: true, - content: Text(trf('감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요', [names])), + life: NoticeLife.untilClosed, + text: trf('감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요', [names]), action: overlay == null ? null - : SnackBarAction( - label: tr('다른 언어로 다시'), - onPressed: () async { + : ( + tr('다른 언어로 다시'), + () async { if (!overlay.mounted) return; final lang = await pickSourceLanguage(overlay.context, found.first.$2); if (lang != null) await onRetry(lang); - }, + } ), - )); + ); } /// 198: 원어를 골라 다시 만들 때의 언어 고르기 (원어를 처음부터 정하는 곳도 알린다) diff --git a/lib/ui/ai_upscale_ui.dart b/lib/ui/ai_upscale_ui.dart index dea56b9..60ceaae 100644 --- a/lib/ui/ai_upscale_ui.dart +++ b/lib/ui/ai_upscale_ui.dart @@ -19,6 +19,7 @@ import 'ai_image_page.dart' show aiSaveDir; import 'confirm.dart'; import 'explorer_page.dart' show ExplorerPage; import 'theme.dart'; +import 'notice.dart'; UpscaleOptions upscaleOptionsOf(AppController c, {String? outDir}) { final s = c.settings; @@ -417,11 +418,7 @@ Future upscaleAll(BuildContext context, AppController c, ReaderSource src) } catch (_) {} } final dir = lastDir; - messenger?.showSnackBar(SnackBar( - duration: const Duration(seconds: 10), - content: Text(text), - action: dir == null ? null : SnackBarAction(label: tr('열기'), onPressed: () => openFolderInApp(nav, c, dir)), - )); + showNotice(messenger, text: text, action: dir == null ? null : (tr('열기'), () => openFolderInApp(nav, c, dir))); } /// 원본 / 올린 것을 바꿔 보며 비교 (확대해서 볼 수 있음). [저장] 이면 true, 저장하지 않고 닫으면 false diff --git a/lib/ui/bookmark_ui.dart b/lib/ui/bookmark_ui.dart index 64a5a93..41cdb6e 100644 --- a/lib/ui/bookmark_ui.dart +++ b/lib/ui/bookmark_ui.dart @@ -9,6 +9,7 @@ import '../core/bookmarks.dart'; import 'app_actions.dart'; import 'theme.dart'; import '../l10n/tr.dart'; +import 'notice.dart'; // 즐겨찾기 공용 화면 조각 (Chrome 의 즐겨찾기를 참고): // - 즐겨찾기 수정 창: 이름 · 주소 · 저장할 폴더 (+ 새 폴더) @@ -237,13 +238,10 @@ Future askText(BuildContext context, String title, String label, String void removeBookmarkWithUndo(BuildContext context, BookmarksController bm, BookmarkNode n) { if (!bm.remove(n.id)) return; // 되돌리기는 바로 보여야 하므로 앞서 떠 있던 알림은 치운다 - ScaffoldMessenger.of(context) - ..clearSnackBars() - ..showSnackBar(SnackBar( - content: Text(n.isFolder ? trf('폴더 삭제: {0} (안의 {1}개 포함)', [n.title, bm.tree.countLinks(n)]) : trf('삭제: {0}', [n.title])), - persist: false, // Flutter 3.47+: [action] 이 있으면 기본은 안 사라짐 → duration 대로 닫기 - action: SnackBarAction(label: tr('실행 취소'), onPressed: bm.undoRemove), - )); + showNotice(ScaffoldMessenger.of(context), + clearOthers: true, + text: n.isFolder ? trf('폴더 삭제: {0} (안의 {1}개 포함)', [n.title, bm.tree.countLinks(n)]) : trf('삭제: {0}', [n.title]), + action: (tr('실행 취소'), bm.undoRemove)); } /// 즐겨찾기 오른쪽 클릭 메뉴 (Chrome 과 같은 항목). [n] 이 null 이면 빈 곳 (폴더 [folderId] 에 추가만). diff --git a/lib/ui/browser_page.dart b/lib/ui/browser_page.dart index eb8dfc6..b69a559 100644 --- a/lib/ui/browser_page.dart +++ b/lib/ui/browser_page.dart @@ -36,6 +36,7 @@ import 'theme.dart'; import 'bottom_inset_hold.dart'; import 'work_panel.dart'; import '../l10n/tr.dart'; +import 'notice.dart'; /// 브라우저 조작 (앱 안 웹뷰) abstract class WebNav { @@ -647,13 +648,11 @@ class _BrowserPageState extends State with RouteAware { // 알림은 앱 전체에 떠 있으므로 이 브라우저 화면이 아니라 앱의 Navigator 로 연다 // (화면을 오가 이 브라우저 화면이 닫혀도 "목록 보기" 가 된다) final nav = Navigator.of(context); - final bar = m.showSnackBar(SnackBar( - content: Text(trf('다운로드 추가: {0}', [_title.isEmpty ? _url : _title])), - duration: const Duration(minutes: 10), - showCloseIcon: true, // [✕] 로 바로 닫기 - persist: false, // Flutter 3.47+: [action] 이 있으면 기본은 안 사라짐 → duration 대로 닫기 - action: SnackBarAction(label: tr('목록 보기'), onPressed: () => DownloadsPage.open(nav, d)), - )); + // 받기 준비가 끝날 때까지 두고 아래 check 가 닫는다 ([✕] 로 바로 닫기) + final bar = showNotice(m, + life: NoticeLife.untilClosed, + text: trf('다운로드 추가: {0}', [_title.isEmpty ? _url : _title]), + action: (tr('목록 보기'), () => DownloadsPage.open(nav, d)))!; // 받기 준비가 끝나면 (진행률이 나오거나 · 끝 · 실패 · 취소 · 목록에서 지움) 알림을 닫는다 void check() { final ready = !d.tasks.contains(t) || diff --git a/lib/ui/confirm_dialog.dart b/lib/ui/confirm_dialog.dart new file mode 100644 index 0000000..ff964f5 --- /dev/null +++ b/lib/ui/confirm_dialog.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; + +import '../l10n/tr.dart'; +import 'theme.dart'; + +/// 확인 창 하나 (E4 U7, 관리자 승인 10/22). 화면은 내용만 넘긴다. +/// +/// 약속 +/// - 돌려주는 값: [확인] 을 누른 때만 true. [취소] · Esc · 바깥 누르기 · 뒤로는 false. +/// - Enter = 첫 초점 단추. 되돌릴 수 없는 일 ([danger] 가 창을 열 때 true) 은 첫 초점이 [취소] (실수 막기), +/// 그 밖은 [확인] (관리자 결정 10/22). +/// - [danger] 가 창 안에서 바뀌면 (예: "영구 삭제" 체크) [확인] 단추만 빨강으로 바뀐다. 첫 초점은 연 때 그대로. +/// - [title] · [body] · [confirmLabel] 은 창 안에서 [set] 으로 다시 그려진다 (체크 상자 등). +Future confirmDialog( + BuildContext context, { + required String Function() title, + required Widget Function(BuildContext ctx, StateSetter set) body, + required String Function() confirmLabel, + String? cancelLabel, + bool Function()? danger, + Widget? icon, +}) async { + final dangerAtOpen = danger?.call() ?? false; + final ok = await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, set) { + final red = danger?.call() ?? false; + return AlertDialog( + scrollable: true, + icon: icon, + title: Text(title()), + content: body(ctx, set), + actions: [ + TextButton( + autofocus: dangerAtOpen, + onPressed: () => Navigator.pop(ctx, false), + child: Text(cancelLabel ?? tr('취소')), + ), + FilledButton( + autofocus: !dangerAtOpen, + style: red ? FilledButton.styleFrom(backgroundColor: JjColors.alert) : null, + onPressed: () => Navigator.pop(ctx, true), + child: Text(confirmLabel()), + ), + ], + ); + }, + ), + ); + return ok == true; +} diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index d7d9d28..455d029 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -83,6 +83,12 @@ import 'batch_rename_dialog.dart'; import '../core/app_trash.dart'; import '../core/folder_size.dart'; import '../core/batch_rename.dart'; +import 'notice.dart'; +import 'confirm_dialog.dart'; +import '../core/name_clash.dart'; +import 'adaptive_row.dart'; + +export '../core/name_clash.dart' show NameClash, clashCompareText, fileDateText; /// 파일 탐색기 (X-plore 참고): 두 창 (트리 목록) + 가운데 기능 버튼 줄. /// @@ -108,7 +114,10 @@ double treeChevronWidth(TargetPlatform platform) => class ExplorerPage extends StatefulWidget { final AppController c; final bool rsync; - const ExplorerPage({super.key, required this.c, this.rsync = false}); + + /// 지금 시각 (폴더 두 번 누르기의 두 번째인지 가르는 데). 시험은 정한 시각을 넘긴다 - 실제 시간에 기대 우연히 통과하지 않게 + final DateTime Function() clock; + const ExplorerPage({super.key, required this.c, this.rsync = false, this.clock = DateTime.now}); /// 165: 드라이브를 열 수 있는지 (시험에서 응답 없는 드라이브로 바꾼다). 화면을 멈추지 않는 비동기 @visibleForTesting @@ -532,8 +541,10 @@ class _ExplorerPageState extends State { // 165: 지난 폴더가 느린 네트워크 드라이브여도 화면이 멈추지 않게 (뒤에서 확인, 오래 걸리면 그냥 열어 본다 - 못 읽으면 그 창에 이유) final reach = await Future.wait( [for (var i = 0; i < 2; i++) i < saved.length ? _reachableAsync(saved[i]) : Future.value(false)]); + // U1 (점검 10/22): 저장된 경로가 없는 창은 창마다 다른 저장 장치 (오른쪽 = 두 번째, 없으면 첫째) - 두 창이 같은 목록을 + // 두 번 보이면 [복사] 의 "다른 창으로" 가 같은 곳이 되어 헷갈렸다. 저장된 경로가 있으면 그대로 final wants = [ - for (var i = 0; i < 2; i++) reach[i] ? saved[i] : _local[0].$1, + for (var i = 0; i < 2; i++) reach[i] ? saved[i] : _local[i < _local.length ? i : 0].$1, ]; // 162: "이 폴더를 탐색기로" 로 열렸으면 첫 창은 그 폴더 (지난 폴더 대신) final asked = widget.rsync ? null : ExplorerPage.goToRequest.value; @@ -1628,7 +1639,7 @@ class _ExplorerPageState extends State { return; } // 폴더는 두 번 누르기를 기다리지 않는다 - 짧은 사이의 두 번째 누르기로 방금 펼친 것을 접지 않게 - final now = DateTime.now(); + final now = widget.clock(); final last = _lastFolderTap; _lastFolderTap = (e.path, now); final repeat = last != null && samePath(last.$1, e.path) && now.difference(last.$2) < kDoubleTapTimeout * 2; @@ -2362,12 +2373,11 @@ class _ExplorerPageState extends State { ..forget(e.path) ..request(e.path); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text(isDav(e.path) - ? trf('"{0}" 크기를 계산합니다. 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다.', [e.name]) - : trf('"{0}" 크기를 계산합니다.', [e.name])), - action: SnackBarAction(label: tr('취소'), onPressed: () => _sizes.cancel(e.path)), - )); + showNotice(ScaffoldMessenger.of(context), + text: isDav(e.path) + ? trf('"{0}" 크기를 계산합니다. 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다.', [e.name]) + : trf('"{0}" 크기를 계산합니다.', [e.name]), + action: (tr('취소'), () => _sizes.cancel(e.path))); } case 'rename': if (_isMarked(pane, e.path) && pane.sel.marked.length > 1) { @@ -2900,21 +2910,19 @@ class _ExplorerPageState extends State { if (done == null || done.isEmpty || !mounted) return; await _afterBatchRename(pane, done); if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text(trf('{0}개의 이름을 바꿨습니다.', [done.length])), - duration: const Duration(seconds: 10), - action: SnackBarAction( - label: tr('되돌리기'), - onPressed: () async { - try { - final back = await runBatchRename([for (final (orig, now) in done) (now, vBasename(orig))]); - if (mounted) await _afterBatchRename(pane, back); - } on BatchRenameException catch (e) { - _snack(trf('되돌리지 못했습니다: {0}', [e])); + showNotice(ScaffoldMessenger.of(context), + text: trf('{0}개의 이름을 바꿨습니다.', [done.length]), + action: ( + tr('되돌리기'), + () async { + try { + final back = await runBatchRename([for (final (orig, now) in done) (now, vBasename(orig))]); + if (mounted) await _afterBatchRename(pane, back); + } on BatchRenameException catch (e) { + _snack(trf('되돌리지 못했습니다: {0}', [e])); + } } - }, - ), - )); + )); } /// 바꾼 뒤: 목록을 다시 읽고, 표시 · 초점을 새 이름으로 @@ -2956,70 +2964,68 @@ class _ExplorerPageState extends State { for (final h in c.settings.explorerHiddenItems) if (paths.any((x) => !samePath(h, x) && isSameOrInside(h, x))) h ].length; - final ok = await showDialog( - context: context, - builder: (ctx) => StatefulBuilder( - builder: (ctx, set) => AlertDialog( - scrollable: true, - title: Text(permanent ? tr('영구 삭제') : tr('휴지통으로 보내기')), - // 무엇을 지우는지 늘 보여 준다 (여러 개면 앞의 8개 이름 · 폴더) - content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(paths.length == 1 - ? '${permanent ? trf('"{0}" 을(를) 지울까요? 되돌릴 수 없습니다.', [vBasename(paths.first)]) : trf('"{0}" 을(를) 휴지통으로 보낼까요?', [vBasename(paths.first)])}' - '\n${vDirname(paths.first)}' - : [ - permanent - ? trf('{0}개 항목을 지울까요? 되돌릴 수 없습니다.', [paths.length]) - : trf('{0}개 항목을 휴지통으로 보낼까요?', [paths.length]), - for (final x in paths.take(8)) '· ${vBasename(x)}', - if (paths.length > 8) trf('… 외 {0}개', [paths.length - 8]), - {for (final x in paths) vDirname(x)}.join('\n'), - ].join('\n')), - // 79-b: 폴더가 있으면 몇 개인지 (안의 것도 함께 지워진다) - if (paths.any(vIsDirSync)) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text(trf('그중 폴더 {0}개 - 안의 파일 · 폴더도 모두 함께', [paths.where(vIsDirSync).length]), - style: TextStyle(fontSize: 12, color: JjColors.warning)), - ), - if (hiddenInside > 0) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text(trf('지우는 폴더 안에 목록에서 숨긴 항목 {0}개가 있습니다 - 함께 지워집니다', [hiddenInside]), - style: TextStyle(fontSize: 12, color: JjColors.warning)), - ), - if (local && !canRecycle) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text(tr('이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.'), - style: TextStyle(fontSize: 12, color: JjColors.warning)), - ), - if (canTrash && !permanent) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text(trf('앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).', [c.settings.androidTrashDays]), - style: TextStyle(fontSize: 12, color: JjColors.textDim)), - ), - if (canRecycle) - CheckboxListTile( - contentPadding: EdgeInsets.zero, - value: permanent, - onChanged: (v) => set(() => permanent = v ?? false), - title: Text(canTrash ? tr('휴지통을 거치지 않고 영구 삭제') : tr('휴지통을 거치지 않고 영구 삭제 (Shift+Delete)')), - ), - ]), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), - FilledButton( - style: FilledButton.styleFrom(backgroundColor: permanent ? Colors.red.shade700 : null), - onPressed: () => Navigator.pop(ctx, true), - child: Text(permanent ? tr('영구 삭제') : tr('휴지통으로')), - ), - ], - ), - ), + final otherMarks = [for (final x in _panes) if (!identical(x, pane)) x.sel.marked.length].fold(0, (a, b) => a + b); + // U7: 확인 창은 공용 confirmDialog - 영구 삭제로 열면 Enter 의 첫 초점은 [취소] + final ok = await confirmDialog( + context, + title: () => permanent ? tr('영구 삭제') : tr('휴지통으로 보내기'), + danger: () => permanent, + confirmLabel: () => permanent ? tr('영구 삭제') : tr('휴지통으로'), + // 무엇을 지우는지 늘 보여 준다 (여러 개면 앞의 8개 이름 · 폴더) + body: (ctx, set) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(paths.length == 1 + ? '${permanent ? trf('"{0}" 을(를) 지울까요? 되돌릴 수 없습니다.', [vBasename(paths.first)]) : trf('"{0}" 을(를) 휴지통으로 보낼까요?', [vBasename(paths.first)])}' + '\n${vDirname(paths.first)}' + : [ + permanent + ? trf('{0}개 항목을 지울까요? 되돌릴 수 없습니다.', [paths.length]) + : trf('{0}개 항목을 휴지통으로 보낼까요?', [paths.length]), + for (final x in paths.take(8)) '· ${vBasename(x)}', + if (paths.length > 8) trf('… 외 {0}개', [paths.length - 8]), + {for (final x in paths) vDirname(x)}.join('\n'), + ].join('\n')), + // 79-b: 폴더가 있으면 몇 개인지 (안의 것도 함께 지워진다) + if (paths.any(vIsDirSync)) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text(trf('그중 폴더 {0}개 - 안의 파일 · 폴더도 모두 함께', [paths.where(vIsDirSync).length]), + style: TextStyle(fontSize: 12, color: JjColors.warning)), + ), + if (hiddenInside > 0) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text(trf('지우는 폴더 안에 목록에서 숨긴 항목 {0}개가 있습니다 - 함께 지워집니다', [hiddenInside]), + style: TextStyle(fontSize: 12, color: JjColors.warning)), + ), + if (local && !canRecycle) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text(tr('이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.'), + style: TextStyle(fontSize: 12, color: JjColors.warning)), + ), + if (canTrash && !permanent) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text(trf('앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).', [c.settings.androidTrashDays]), + style: TextStyle(fontSize: 12, color: JjColors.textDim)), + ), + if (canRecycle) + CheckboxListTile( + contentPadding: EdgeInsets.zero, + value: permanent, + onChanged: (v) => set(() => permanent = v ?? false), + title: Text(canTrash ? tr('휴지통을 거치지 않고 영구 삭제') : tr('휴지통을 거치지 않고 영구 삭제 (Shift+Delete)')), + ), + // U4: 다른 창의 표시는 이번 대상이 아님 + if (otherMarks > 0) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text(trf('다른 창에 {0}개 표시됨 (이번 대상 아님)', [otherMarks]), + style: TextStyle(fontSize: 12, color: JjColors.textDim)), + ), + ]), ); - if (ok != true) return; + if (!ok) return; // 하나씩: 실패해도 나머지는 계속. 실제로 어떻게 됐는지 세어 그대로 알린다 (94) final failed = <(String, String)>[]; // 144: 경로가 너무 길어 휴지통이 받지 않은 것 (이유는 한 번, [영구 삭제] 로 바로 지울 수 있게) @@ -3066,28 +3072,21 @@ class _ExplorerPageState extends State { } // X-plore 비교 e: 휴지통으로 옮기지 못한 것 (다른 저장 장치 · 권한 등) - 몰래 지우지 않고 영구 삭제할지 묻는다 if (notTrashed.isNotEmpty && mounted) { - final nuke = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - scrollable: true, - icon: Icon(Icons.warning_amber_rounded, color: JjColors.warning), - title: Text(trf('{0}개를 휴지통으로 옮기지 못했습니다', [notTrashed.length])), - content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - for (final (x, why) in notTrashed.take(10)) Text('· ${vBasename(x)} - $why', style: const TextStyle(fontSize: 12)), - const SizedBox(height: 8), - Text(tr('영구 삭제하면 되살릴 수 없습니다.')), - ]), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('그대로 두기'))), - FilledButton( - style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700), - onPressed: () => Navigator.pop(ctx, true), - child: Text(trf('영구 삭제 ({0}개, 되돌릴 수 없음)', [notTrashed.length])), - ), - ], - ), + // U7: 되돌릴 수 없는 일 - Enter 의 첫 초점은 [그대로 두기] + final nuke = await confirmDialog( + context, + icon: Icon(Icons.warning_amber_rounded, color: JjColors.warning), + title: () => trf('{0}개를 휴지통으로 옮기지 못했습니다', [notTrashed.length]), + danger: () => true, + cancelLabel: tr('그대로 두기'), + confirmLabel: () => trf('영구 삭제 ({0}개, 되돌릴 수 없음)', [notTrashed.length]), + body: (ctx, set) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + for (final (x, why) in notTrashed.take(10)) Text('· ${vBasename(x)} - $why', style: const TextStyle(fontSize: 12)), + const SizedBox(height: 8), + Text(tr('영구 삭제하면 되살릴 수 없습니다.')), + ]), ); - if (nuke == true) { + if (nuke) { for (final (x, _) in notTrashed) { try { await FileOps().delete([x]); @@ -3098,45 +3097,50 @@ class _ExplorerPageState extends State { } } if ((failed.isNotEmpty || tooLong.isNotEmpty) && mounted) { - final nukeLong = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - scrollable: true, - icon: Icon(Icons.error_outline, color: JjColors.alert), - title: Text(trf('{0}개 중 {1}개를 지우지 못했습니다', [paths.length, failed.length + tooLong.length])), - content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - // 144: 긴 경로는 이유 한 줄 + 이름만 (영구 삭제는 아래 버튼으로) - if (tooLong.isNotEmpty) ...[ - Text(tr('경로가 너무 길어 휴지통에 넣을 수 없습니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다).'), - style: const TextStyle(fontWeight: FontWeight.w600)), - const SizedBox(height: 4), - for (final x in tooLong.take(30)) Text('· ${vBasename(x)}', style: const TextStyle(fontSize: 12)), - if (tooLong.length > 30) Text(trf('… 외 {0}개', [tooLong.length - 30])), - const SizedBox(height: 8), - ], - for (final (x, why) in failed.take(30)) ...[ - Text(vBasename(x), style: const TextStyle(fontWeight: FontWeight.w600)), - Text('${vDirname(x)} · $why', style: TextStyle(fontSize: 12, color: JjColors.textDim)), - const SizedBox(height: 4), - ], - if (failed.length > 30) Text(trf('… 외 {0}개', [failed.length - 30])), - const SizedBox(height: 6), - // 이유를 이미 위에 적었으므로 짐작하는 말은 빼고, 실제로 지운 것이 있을 때만 "나머지는 지웠습니다" - if (failed.length + tooLong.length < paths.length) Text(tr('나머지는 지웠습니다.'), style: const TextStyle(fontSize: 12)), - ]), - actions: [ - if (tooLong.isNotEmpty) ...[ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('그대로 두기'))), - FilledButton( - style: FilledButton.styleFrom(backgroundColor: JjColors.alert), - onPressed: () => Navigator.pop(ctx, true), - child: Text(trf('영구 삭제 ({0}개, 되돌릴 수 없음)', [tooLong.length])), + final failTitle = trf('{0}개 중 {1}개를 지우지 못했습니다', [paths.length, failed.length + tooLong.length]); + final failIcon = Icon(Icons.error_outline, color: JjColors.alert); + Widget failBody() => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + // 144: 긴 경로는 이유 한 줄 + 이름만 (영구 삭제는 아래 버튼으로) + if (tooLong.isNotEmpty) ...[ + Text(tr('경로가 너무 길어 휴지통에 넣을 수 없습니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다).'), + style: const TextStyle(fontWeight: FontWeight.w600)), + const SizedBox(height: 4), + for (final x in tooLong.take(30)) Text('· ${vBasename(x)}', style: const TextStyle(fontSize: 12)), + if (tooLong.length > 30) Text(trf('… 외 {0}개', [tooLong.length - 30])), + const SizedBox(height: 8), + ], + for (final (x, why) in failed.take(30)) ...[ + Text(vBasename(x), style: const TextStyle(fontWeight: FontWeight.w600)), + Text('${vDirname(x)} · $why', style: TextStyle(fontSize: 12, color: JjColors.textDim)), + const SizedBox(height: 4), + ], + if (failed.length > 30) Text(trf('… 외 {0}개', [failed.length - 30])), + const SizedBox(height: 6), + // 이유를 이미 위에 적었으므로 짐작하는 말은 빼고, 실제로 지운 것이 있을 때만 "나머지는 지웠습니다" + if (failed.length + tooLong.length < paths.length) Text(tr('나머지는 지웠습니다.'), style: const TextStyle(fontSize: 12)), + ]); + // U7: 긴 경로를 영구 삭제할지 묻는 것은 되돌릴 수 없는 일 - Enter 의 첫 초점은 [그대로 두기]. 아니면 알림만 + final nukeLong = tooLong.isNotEmpty + ? await confirmDialog( + context, + icon: failIcon, + title: () => failTitle, + danger: () => true, + cancelLabel: tr('그대로 두기'), + confirmLabel: () => trf('영구 삭제 ({0}개, 되돌릴 수 없음)', [tooLong.length]), + body: (ctx, set) => failBody(), + ) + : await showDialog( + context: context, + builder: (ctx) => AlertDialog( + scrollable: true, + icon: failIcon, + title: Text(failTitle), + content: failBody(), + actions: [FilledButton(autofocus: true, onPressed: () => Navigator.pop(ctx), child: Text(tr('확인')))], ), - ] else - FilledButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('확인'))), - ], - ), - ); + ) == + true; // 144: 사용자가 [영구 삭제] 를 고른 긴 경로만 지운다 if (nukeLong == true) { final notDeleted = []; @@ -3160,15 +3164,13 @@ class _ExplorerPageState extends State { if (kept > 0) trf('{0}개는 지우지 않았습니다 (취소).', [kept]), ]); // 148: 휴지통으로 보낸 것은 바로 [되돌리기] - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - duration: const Duration(seconds: 10), - content: Text(text), - action: trashed.isNotEmpty - ? SnackBarAction(label: tr('되돌리기'), onPressed: () => _undoTrash(trashed)) - : sent.isEmpty - ? null - : SnackBarAction(label: tr('되돌리기'), onPressed: () => _undoRecycle(sent, since)), - )); + showNotice(ScaffoldMessenger.of(context), + text: text, + action: trashed.isNotEmpty + ? (tr('되돌리기'), () => _undoTrash(trashed)) + : sent.isEmpty + ? null + : (tr('되돌리기'), () => _undoRecycle(sent, since))); } // X-plore 숨기기: 영구로 지운 것 (그 안 포함) 은 숨긴 목록에서 뺀다. 휴지통으로 보낸 것은 되돌리기 뒤에도 숨김이 되게 남긴다 await _followHidden(gone: [ @@ -3407,25 +3409,24 @@ class _ExplorerPageState extends State { skippedHere = here; sources = [for (final x in sources) if (!here.contains(x)) x]; } else if (here.isNotEmpty && (move || here.length == sources.length)) { - final ok = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - scrollable: true, - title: Text(move ? tr('이미 이 폴더에 있습니다') : tr('같은 폴더입니다')), - content: Text(move - ? trf('{0} 은(는) 이미 이 폴더에 있어 옮길 것이 없습니다.\n{1}', [_namesOf(here), vDisplay(dest)]) - : trf('같은 폴더에 사본을 만들까요? (이름 (2))\n{0}', [_namesOf(here)])), - actions: [ - if (move) - FilledButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('확인'))) - else ...[ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), - FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('사본 만들기'))), - ], - ], - ), - ); - if (ok != true || !mounted) return false; + if (move) { + // 알림만 ([확인] 에 첫 초점 - Enter 로 닫기) + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + scrollable: true, + title: Text(tr('이미 이 폴더에 있습니다')), + content: Text(trf('{0} 은(는) 이미 이 폴더에 있어 옮길 것이 없습니다.\n{1}', [_namesOf(here), vDisplay(dest)])), + actions: [FilledButton(autofocus: true, onPressed: () => Navigator.pop(ctx), child: Text(tr('확인')))], + ), + ); + return false; + } + final ok = await confirmDialog(context, + title: () => tr('같은 폴더입니다'), + confirmLabel: () => tr('사본 만들기'), + body: (ctx, set) => Text(trf('같은 폴더에 사본을 만들까요? (이름 (2))\n{0}', [_namesOf(here)]))); + if (!ok || !mounted) return false; sameFolderCopy = true; // 이미 물었으니 아래 확인 창은 건너뛴다 } // 원본 · 대상이 없거나 (다른 곳에서 지움 - 목록을 새로 고침) 폴더를 자기 안으로 넣으려 하면 시작하지 않는다 @@ -3457,102 +3458,106 @@ class _ExplorerPageState extends State { _ => null, }; var conflict = always ?? NameConflict.rename; - final go = sameFolderCopy ? true : await showDialog( - context: context, - builder: (ctx) => StatefulBuilder( - builder: (ctx, setInner) => AlertDialog( - scrollable: true, - title: Text(move ? tr('이동') : tr('복사')), - content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - // 61: 문장 조각을 이어 붙이지 않는다 (언어마다 어순이 달라 "…로 {복사할까요?}" 가 깨졌다) - Text('${move ? trf('{0}개 항목을 다음 폴더로 옮길까요?\n{1}', [sources.length, vDisplay(dest)]) : trf('{0}개 항목을 다음 폴더로 복사할까요?\n{1}', [sources.length, vDisplay(dest)])}' - '\n${_namesOf(sources)}' - '${skippedHere.isEmpty ? '' : '\n${trf('이미 이 폴더에 있어 건너뜀: {0}', [_namesOf(skippedHere)])}'}' - '\n\n${trf('방법: {0}', [tr(method.label)])}' - '${method == CopyMethod.builtin ? '' : ' · ${withoutSourceRemovingOptions(task.options)}${move ? (method == CopyMethod.rsync ? ' --remove-source-files' : ' /MOVE') : ''}'}' - '${task.bandwidthKBps > 0 ? ' · ${trf('속도 제한 {0} KB/s', [task.bandwidthKBps])}' : ''}'), - // 178 보충: 환경 설정의 원본 지우기 옵션은 복사에서 뺀다 - 왜 빠졌는지 알림 - if (!move && method != CopyMethod.builtin && optionsRemoveSource(task.options)) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - trf('환경 설정의 {0} 는 복사에서는 쓰지 않습니다 (이동은 [이동] 으로)', - [splitOptions(task.options).where(isSourceRemovingOption).join(' ')]), - style: TextStyle(fontSize: 12, color: JjColors.textDim)), - ), - FutureBuilder>( - future: clashesFuture, - builder: (ctx, snap) { - if (snap.connectionState != ConnectionState.done) { - return Padding( - padding: const EdgeInsets.only(top: 12), - child: Text(tr('같은 이름이 있는지 확인하는 중 …'), style: TextStyle(fontSize: 12, color: JjColors.textDim)), - ); - } - final clashes = snap.data ?? const []; - if (clashes.isEmpty) return const SizedBox.shrink(); - return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SizedBox(height: 12), - Text(trf('같은 이름이 이미 있습니다: {0}', [_namesOf([for (final x in clashes) x.src])]), - style: TextStyle(color: JjColors.warning)), - // 172: 두 파일의 크기 · 바뀐 날 (3개까지) - for (final x in clashes.take(3)) ...[ - Padding( - padding: const EdgeInsets.only(top: 4, left: 8), - child: Text(clashCompareText(x), style: const TextStyle(fontSize: 12)), - ), - // 229: 폴더를 합치면 안쪽에서 받는 쪽이 더 새 파일 - 건너뛰는지 (환경 설정) 미리 - if (x.there.isDir && !isDav(x.src) && !isDav(dest)) - FutureBuilder( - future: FileOps.countNewerInside(x.src, vJoin(dest, vBasename(x.src))), - builder: (_, n) => Padding( - padding: const EdgeInsets.only(left: 20), - child: Text( - n.connectionState != ConnectionState.done - ? tr('안쪽 파일을 확인하는 중 …') - : (n.data ?? 0) == 0 - ? '' - : c.settings.copySkipNewer - ? trf('안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)', [n.data]) - : trf('안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다', [n.data]), - style: TextStyle(fontSize: 12, color: JjColors.warning), - ), - ), + // U4 (점검 10/22, 관리자 결정): 다른 창의 표시는 그대로 두고 이번 대상이 아님을 알린다 + final otherMarks = [for (final x in _panes) if (!identical(x, pane)) x.sel.marked.length].fold(0, (a, b) => a + b); + // U7: 공용 confirmDialog - 늘 덮어쓰기 설정이면 (되돌릴 수 없음) Enter 의 첫 초점은 [취소] + final go = sameFolderCopy + ? true + : await confirmDialog( + context, + title: () => move ? tr('이동') : tr('복사'), + confirmLabel: () => move ? tr('이동') : tr('복사'), + danger: () => conflict == NameConflict.overwrite, + body: (ctx, setInner) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + // 61: 문장 조각을 이어 붙이지 않는다 (언어마다 어순이 달라 "…로 {복사할까요?}" 가 깨졌다) + Text('${move ? trf('{0}개 항목을 다음 폴더로 옮길까요?\n{1}', [sources.length, vDisplay(dest)]) : trf('{0}개 항목을 다음 폴더로 복사할까요?\n{1}', [sources.length, vDisplay(dest)])}' + '\n${_namesOf(sources)}' + '${skippedHere.isEmpty ? '' : '\n${trf('이미 이 폴더에 있어 건너뜀: {0}', [_namesOf(skippedHere)])}'}' + '\n\n${trf('방법: {0}', [tr(method.label)])}' + '${method == CopyMethod.builtin ? '' : ' · ${withoutSourceRemovingOptions(task.options)}${move ? (method == CopyMethod.rsync ? ' --remove-source-files' : ' /MOVE') : ''}'}' + '${task.bandwidthKBps > 0 ? ' · ${trf('속도 제한 {0} KB/s', [task.bandwidthKBps])}' : ''}'), + // 178 보충: 환경 설정의 원본 지우기 옵션은 복사에서 뺀다 - 왜 빠졌는지 알림 + if (!move && method != CopyMethod.builtin && optionsRemoveSource(task.options)) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + trf('환경 설정의 {0} 는 복사에서는 쓰지 않습니다 (이동은 [이동] 으로)', + [splitOptions(task.options).where(isSourceRemovingOption).join(' ')]), + style: TextStyle(fontSize: 12, color: JjColors.textDim)), + ), + FutureBuilder>( + future: clashesFuture, + builder: (ctx, snap) { + if (snap.connectionState != ConnectionState.done) { + return Padding( + padding: const EdgeInsets.only(top: 12), + child: Text(tr('같은 이름이 있는지 확인하는 중 …'), style: TextStyle(fontSize: 12, color: JjColors.textDim)), + ); + } + final clashes = snap.data ?? const []; + if (clashes.isEmpty) return const SizedBox.shrink(); + return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + const SizedBox(height: 12), + Text(trf('같은 이름이 이미 있습니다: {0}', [_namesOf([for (final x in clashes) x.src])]), + style: TextStyle(color: JjColors.warning)), + // 172: 두 파일의 크기 · 바뀐 날 (3개까지) + for (final x in clashes.take(3)) ...[ + Padding( + padding: const EdgeInsets.only(top: 4, left: 8), + child: Text(clashCompareText(x), style: const TextStyle(fontSize: 12)), + ), + // 229: 폴더를 합치면 안쪽에서 받는 쪽이 더 새 파일 - 건너뛰는지 (환경 설정) 미리 + if (x.there.isDir && !isDav(x.src) && !isDav(dest)) + FutureBuilder( + future: FileOps.countNewerInside(x.src, vJoin(dest, vBasename(x.src))), + builder: (_, n) => Padding( + padding: const EdgeInsets.only(left: 20), + child: Text( + n.connectionState != ConnectionState.done + ? tr('안쪽 파일을 확인하는 중 …') + : (n.data ?? 0) == 0 + ? '' + : c.settings.copySkipNewer + ? trf('안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)', [n.data]) + : trf('안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다', [n.data]), + style: TextStyle(fontSize: 12, color: JjColors.warning), ), - ], - if (always != null) - Text( - trf('환경 설정대로 {0} (환경 설정 > 파일 탐색기 > 같은 이름이 있을 때)', [ - switch (always) { - NameConflict.rename => tr('이름을 바꿉니다'), - NameConflict.overwrite => tr('덮어씁니다'), - NameConflict.skip => tr('건너뜁니다'), - } - ]), - style: const TextStyle(fontSize: 12)) - else - RadioGroup( - groupValue: conflict, - onChanged: (x) => setInner(() => conflict = x ?? conflict), - child: Column(mainAxisSize: MainAxisSize.min, children: [ - for (final (v, label) in [ - (NameConflict.rename, tr('이름 바꾸기 (예: 이름 (2))')), - (NameConflict.overwrite, tr('덮어쓰기 (원래 파일은 없어집니다)')), - (NameConflict.skip, tr('건너뛰기')), - ]) - RadioListTile(dense: true, contentPadding: EdgeInsets.zero, value: v, title: Text(label)), - ]), + ), ), - ]); - }, - ), - ]), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), - FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(move ? tr('이동') : tr('복사'))), - ], + ], + if (always != null) + Text( + trf('환경 설정대로 {0} (환경 설정 > 파일 탐색기 > 같은 이름이 있을 때)', [ + switch (always) { + NameConflict.rename => tr('이름을 바꿉니다'), + NameConflict.overwrite => tr('덮어씁니다'), + NameConflict.skip => tr('건너뜁니다'), + } + ]), + style: const TextStyle(fontSize: 12)) + else + RadioGroup( + groupValue: conflict, + onChanged: (x) => setInner(() => conflict = x ?? conflict), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + for (final (v, label) in [ + (NameConflict.rename, tr('이름 바꾸기 (예: 이름 (2))')), + (NameConflict.overwrite, tr('덮어쓰기 (원래 파일은 없어집니다)')), + (NameConflict.skip, tr('건너뛰기')), + ]) + RadioListTile(dense: true, contentPadding: EdgeInsets.zero, value: v, title: Text(label)), + ]), + ), + ]); + }, ), - ), + if (otherMarks > 0) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text(trf('다른 창에 {0}개 표시됨 (이번 대상 아님)', [otherMarks]), + style: TextStyle(fontSize: 12, color: JjColors.textDim)), + ), + ]), ); if (go != true || !mounted) return false; // 104: 설정의 옵션에 지우기 (robocopy /MIR · /PURGE 등) 가 있으면 미리 보기를 거친다 @@ -3597,17 +3602,17 @@ class _ExplorerPageState extends State { ]); // 85: 끝나면 [폴더 열기] - 앱 탐색기에서 넣은 폴더로 (그 폴더를 보고 있는 창, 없으면 지금 창) if (error == null && mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text(doneText), - action: SnackBarAction( - label: tr('폴더 열기'), - onPressed: () { - final show = _panes.where((x) => samePath(x.current, dest)).firstOrNull ?? _pane; - setState(() => _active = _panes.indexOf(show)); - unawaited(_goTo(show, dest, fresh: true)); - }, - ), - )); + // U2 (점검 10/22): 끝 알림은 정해진 시간 뒤 닫힘 - 아래 표시 줄을 가리지 않게 + showNotice(ScaffoldMessenger.of(context), + text: doneText, + action: ( + tr('폴더 열기'), + () { + final show = _panes.where((x) => samePath(x.current, dest)).firstOrNull ?? _pane; + setState(() => _active = _panes.indexOf(show)); + unawaited(_goTo(show, dest, fresh: true)); + } + )); } else { _snack(doneText); } @@ -3998,11 +4003,12 @@ class _ExplorerPageState extends State { : job.move ? tr('옮기는 중') : tr('복사하는 중'); + final labelMax = MediaQuery.sizeOf(context).width * 0.45; Widget line(String label, double value, String right) => Padding( padding: const EdgeInsets.only(top: 6), child: Row(children: [ SizedBox( - width: 300, + width: math.min(300, labelMax), // 좁은 화면 (폰 세로) 은 폭의 45% 까지 child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12.5)), ), const SizedBox(width: 10), @@ -4998,17 +5004,13 @@ class _ExplorerPageState extends State { } if (back.isEmpty || !mounted) return; final moved = back.values.fold(0, (a, l) => a + l.length); - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar(SnackBar( - duration: const Duration(seconds: 10), - content: Text(joinSentences([ + showNotice(ScaffoldMessenger.of(context)..hideCurrentSnackBar(), + text: joinSentences([ trf('{0}개를 옮겼습니다.', [moved]), if (notUndoable > 0) trf('합친 폴더 {0}개는 무엇을 옮겼는지 몰라 되돌리지 않습니다.', [notUndoable]), if (overwritten > 0) trf('같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.', [overwritten]), - ])), - action: SnackBarAction(label: tr('되돌리기'), onPressed: () => unawaited(_undoMove(_panes[load.from], back))), - )); + ]), + action: (tr('되돌리기'), () => unawaited(_undoMove(_panes[load.from], back)))); } /// 82: 되돌리기 - 원래 폴더마다 지금의 이동으로 (같은 이름이 생겼으면 지금의 같은 이름 창) @@ -5040,63 +5042,66 @@ class _ExplorerPageState extends State { } } final focusedExt = pane.focused == null || byPath[pane.focused]?.isDir != false ? '' : extOf(pane.focused!); + final info = [ + Icon(Icons.checklist, size: 18, color: JjColors.accent), + const SizedBox(width: 6), + Flexible( + child: Text( + [ + trf('{0}개 표시함', [pane.sel.marked.length]), + if (size > 0) formatSize(size), + if (folders > 0) trf('폴더 {0}개 (크기 빼고)', [folders]), + ].join(' · '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12)), + ), + ]; + final actions = [ + PopupMenuButton( + tooltip: tr('고르기 도구'), + onSelected: (v) { + switch (v) { + case 'invert': + _invertMarks(pane, dir); + case 'pattern': + _markPattern(pane, dir); + case 'sameExt': + _markWhere(pane, dir, (x) => !x.isDir && extOf(x.path) == focusedExt); + } + }, + itemBuilder: (_) => [ + PopupMenuItem(value: 'invert', child: Text(tr('반전 (고른 것 ↔ 안 고른 것)'))), + PopupMenuItem(value: 'pattern', child: Text(tr('패턴으로 고르기 (예: *.srt)'))), + PopupMenuItem( + value: 'sameExt', + enabled: focusedExt.isNotEmpty, + child: Text(focusedExt.isEmpty ? tr('같은 확장자 모두') : trf('같은 확장자 모두 (.{0})', [focusedExt])), + ), + ], + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Text(tr('고르기 도구'), style: TextStyle(fontSize: 13, color: JjColors.accent)), + Icon(Icons.arrow_drop_down, size: 18, color: JjColors.accent), + ]), + ), + ), + TextButton( + onPressed: () => setState(() { + final dir = listDir ?? pane.current; + pane.sel.addAll([for (final x in _shown(pane, dir)) x.path]); + }), + child: Text(tr('모두 선택')), + ), + TextButton(onPressed: () => setState(pane.sel.clear), child: Text(tr('표시 지우기'))), + TextButton(onPressed: () => _setSelecting(false), child: Text(tr('선택 끝'))), + ]; return Container( color: JjColors.panel, padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - child: Row(children: [ - Icon(Icons.checklist, size: 18, color: JjColors.accent), - const SizedBox(width: 6), - Flexible( - child: Text( - [ - trf('{0}개 표시함', [pane.sel.marked.length]), - if (size > 0) formatSize(size), - if (folders > 0) trf('폴더 {0}개 (크기 빼고)', [folders]), - ].join(' · '), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 12)), - ), - const Spacer(), - PopupMenuButton( - tooltip: tr('고르기 도구'), - onSelected: (v) { - switch (v) { - case 'invert': - _invertMarks(pane, dir); - case 'pattern': - _markPattern(pane, dir); - case 'sameExt': - _markWhere(pane, dir, (x) => !x.isDir && extOf(x.path) == focusedExt); - } - }, - itemBuilder: (_) => [ - PopupMenuItem(value: 'invert', child: Text(tr('반전 (고른 것 ↔ 안 고른 것)'))), - PopupMenuItem(value: 'pattern', child: Text(tr('패턴으로 고르기 (예: *.srt)'))), - PopupMenuItem( - value: 'sameExt', - enabled: focusedExt.isNotEmpty, - child: Text(focusedExt.isEmpty ? tr('같은 확장자 모두') : trf('같은 확장자 모두 (.{0})', [focusedExt])), - ), - ], - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - Text(tr('고르기 도구'), style: TextStyle(fontSize: 13, color: JjColors.accent)), - Icon(Icons.arrow_drop_down, size: 18, color: JjColors.accent), - ]), - ), - ), - TextButton( - onPressed: () => setState(() { - final dir = listDir ?? pane.current; - pane.sel.addAll([for (final x in _shown(pane, dir)) x.path]); - }), - child: Text(tr('모두 선택')), - ), - TextButton(onPressed: () => setState(pane.sel.clear), child: Text(tr('표시 지우기'))), - TextButton(onPressed: () => _setSelecting(false), child: Text(tr('선택 끝'))), - ]), + // 좁은 창 (폰 세로의 두 창 - 규칙 7, 점검 10/22) 은 두 줄로 + child: AdaptiveRow(lead: info, trail: actions, minWidth: 560), ); } @@ -5880,11 +5885,13 @@ class _ExplorerPageState extends State { onSecondaryTapUp: menu ? (d) => _menu(pane, e, d.globalPosition) : null, onLongPressStart: menu ? (d) => _menu(pane, e, d.globalPosition) : null, child: Material( + // U5 (점검 10/22): 지금 줄 (한 번 누른 것 - 표시가 없을 때 [복사] 등의 대상) 은 진한 바탕 + 왼쪽 강조 막대 color: marked ? JjColors.accent.withValues(alpha: 0.18) : isFocused - ? JjColors.panel + ? JjColors.accent.withValues(alpha: 0.09) : Colors.transparent, + shape: isFocused ? Border(left: BorderSide(color: JjColors.accent, width: 3)) : null, child: Row(children: [ Expanded( child: InkWell( @@ -5993,31 +6000,9 @@ class _ExplorerPageState extends State { } } -/// 172: 같은 이름 - 보낼 것 (복사 · 이동, [here]) 과 대상 폴더에 이미 있는 것 ([there]) -typedef NameClash = ({String src, ({bool isDir, int size, DateTime modified})? here, ({bool isDir, int size, DateTime modified}) there}); - -/// 172: "이름: 보낼 것 1.2 MB · 2026-10-09 14:00 / 있는 것 900 KB · 2026-10-01 09:00 (보낼 것이 더 새것)" -String clashCompareText(NameClash x) { - String one(({bool isDir, int size, DateTime modified}) s) => - [if (!s.isDir) formatSize(s.size) else tr('폴더'), if (s.modified.year > 1) _date(s.modified)].join(' · '); - final h = x.here, t = x.there; - final newer = h == null || h.modified.year <= 1 || t.modified.year <= 1 - ? '' - : h.modified.isAfter(t.modified.add(const Duration(seconds: 2))) - ? tr(' (보낼 것이 더 새것)') - : t.modified.isAfter(h.modified.add(const Duration(seconds: 2))) - ? tr(' (있는 것이 더 새것)') - : tr(' (바뀐 날 같음)'); - return '${vBasename(x.src)}: ${trf('보낼 것 {0} / 있는 것 {1}', [h == null ? '?' : one(h), one(t)])}$newer'; -} - /// 203: 바뀐 날을 모름 (잠긴 시스템 파일은 1970-01-01 로 온다) 은 "—" String _date(DateTime d) => fileDateText(d); -String fileDateText(DateTime d) => d.year <= 1970 - ? '—' - : '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')} ' - '${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}'; /// 기능 버튼 줄 (세로: 두 창 사이 · 가로: 위아래 배치일 때) class _Toolbar extends StatelessWidget { diff --git a/lib/ui/notice.dart b/lib/ui/notice.dart new file mode 100644 index 0000000..29b3c95 --- /dev/null +++ b/lib/ui/notice.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; + +/// 단추가 있는 아래 알림 (SnackBar) 을 띄우는 곳은 이것 하나 (E4 U2, 관리자 승인 10/22). +/// +/// 약속 +/// - [NoticeLife.timed] (기본): [noticeDuration] 뒤 저절로 닫힌다. 앱 전체 한 값이다. +/// (Flutter 3.29 부터 단추가 있는 SnackBar 는 기본으로 닫히지 않아, 끝 알림이 다음 일을 하는 내내 아래 줄을 가렸다 - 점검 U2) +/// - [NoticeLife.untilClosed]: 사용자가 ✕ 를 누르거나 부르는 쪽이 돌려받은 것으로 닫을 때까지 둔다. +/// 상태가 남는 중요한 안내 (248 DNS · 198 감지한 언어) 와, 부르는 쪽이 일이 끝나면 닫는 것 (다운로드 준비 · 시작 전 [취소]) 만. +/// - 두 경우 모두 ✕ 로 바로 닫을 수 있다. [action] 을 누르면 알림이 닫히고 그 일을 한다. +/// - [clearOthers] 면 앞서 떠 있던 알림을 치우고 바로 보인다 (되돌리기처럼 바로 보여야 하는 것). +/// - [messenger] 가 없으면 아무것도 하지 않고 null. +const noticeDuration = Duration(seconds: 10); + +enum NoticeLife { timed, untilClosed } + +/// 알림의 단추 (이름 · 누르면 할 일) +typedef NoticeAction = (String label, VoidCallback onPressed); + +ScaffoldFeatureController? showNotice( + ScaffoldMessengerState? messenger, { + String? text, + Widget? content, + NoticeAction? action, + NoticeLife life = NoticeLife.timed, + bool clearOthers = false, +}) { + assert((text == null) != (content == null), 'text 와 content 중 하나만'); // l10n-skip + if (messenger == null) return null; + if (clearOthers) messenger.clearSnackBars(); + return messenger.showSnackBar(SnackBar( + content: content ?? Text(text!), + duration: life == NoticeLife.timed ? noticeDuration : const Duration(days: 1), + persist: life == NoticeLife.untilClosed, + showCloseIcon: true, + action: action == null ? null : SnackBarAction(label: action.$1, onPressed: action.$2), + )); +} diff --git a/test/browser_test.dart b/test/browser_test.dart index 760d6ae..6edc260 100644 --- a/test/browser_test.dart +++ b/test/browser_test.dart @@ -21,6 +21,7 @@ import 'package:jj_mkvmaker/ui/downloads_page.dart'; import 'package:path/path.dart' as p; import 'support/temp_dirs.dart'; +import 'package:jj_mkvmaker/ui/notice.dart'; class _Backend implements DownloadBackend { @override @@ -563,13 +564,12 @@ void main() { await tester.tap(find.text('실행 취소')); await tester.pumpAndSettle(); expect(bm.tree.findByUrl('https://www.youtube.com/watch?v=abc'), isNotNull); - // 실행 취소 알림은 누르지 않으면 몇 초 뒤 저절로 사라진다 (Flutter 3.47+ 는 action 이 있으면 기본이 안 사라짐) - await tester.tap(find.byTooltip('더 보기').last); + await tester.tap(find.byTooltip('더 보기').last); // 실행 취소 알림은 noticeDuration (E4 U2) 뒤 저절로 사라진다 await tester.pumpAndSettle(); await tester.tap(find.text('삭제').last); await tester.pumpAndSettle(); expect(find.text('실행 취소'), findsOneWidget); - await tester.pump(const Duration(seconds: 5)); + await tester.pump(noticeDuration + const Duration(seconds: 1)); await tester.pumpAndSettle(); expect(find.text('실행 취소'), findsNothing); bm.undoRemove(); diff --git a/test/confirm_dialog_contract_test.dart b/test/confirm_dialog_contract_test.dart new file mode 100644 index 0000000..141ab8e --- /dev/null +++ b/test/confirm_dialog_contract_test.dart @@ -0,0 +1,79 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/ui/confirm_dialog.dart'; + +/// E4 U7 약속 시험 (모듈 하나에 한 장): lib/ui/confirm_dialog.dart 맨 위 약속을 그대로 본다. +void main() { + testWidgets('[확인] 만 true · [취소] · Esc · 바깥 누르기는 false', (tester) async { + for (final (name, close, want) in <(String, Future Function(), bool)>[ + ('확인', () => tester.tap(find.text('확인 단추')), true), + ('취소', () => tester.tap(find.text('취소')), false), + ('Esc', () => tester.sendKeyEvent(LogicalKeyboardKey.escape), false), + ('바깥', () => tester.tapAt(const Offset(5, 5)), false), + ]) { + late BuildContext ctx; + await tester.pumpWidget(MaterialApp(home: Builder(builder: (c) { + ctx = c; + return const Scaffold(body: SizedBox()); + }))); + bool? got; + unawaited(confirmDialog(ctx, title: () => '복사', confirmLabel: () => '확인 단추', body: (c, set) => const Text('내용')) + .then((v) => got = v)); + await tester.pumpAndSettle(); + await close(); + await tester.pumpAndSettle(); + expect(got, want, reason: name); + } + }); + + testWidgets('Enter = 첫 초점: 보통은 [확인], 되돌릴 수 없는 일은 [취소] (관리자 결정 10/22)', (tester) async { + for (final (danger, want) in [(false, true), (true, false)]) { + late BuildContext ctx; + await tester.pumpWidget(MaterialApp(home: Builder(builder: (c) { + ctx = c; + return const Scaffold(body: SizedBox()); + }))); + bool? got; + unawaited(confirmDialog(ctx, + title: () => '지우기', confirmLabel: () => '확인 단추', danger: () => danger, body: (c, set) => const Text('내용')) + .then((v) => got = v)); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect(got, want, reason: 'danger=$danger'); + } + }); + + testWidgets('창 안에서 바뀌면 제목 · 빨강이 따라 바뀌고 첫 초점은 연 때 그대로', (tester) async { + final flag = ValueNotifier(false); + late BuildContext ctx; + await tester.pumpWidget(MaterialApp(home: Builder(builder: (c) { + ctx = c; + return const Scaffold(body: SizedBox()); + }))); + bool? got; + unawaited(confirmDialog(ctx, + title: () => flag.value ? '영구 삭제' : '휴지통으로 보내기', + confirmLabel: () => flag.value ? '영구 삭제 단추' : '휴지통으로', + danger: () => flag.value, + body: (c, set) => CheckboxListTile( + value: flag.value, onChanged: (v) => set(() => flag.value = v ?? false), title: const Text('영구로'))) + .then((v) => got = v)); + await tester.pumpAndSettle(); + expect(find.text('휴지통으로 보내기'), findsOneWidget); + FilledButton confirm() => tester.widget(find.byType(FilledButton)); + expect(confirm().style, isNull, reason: '보통 단추'); + await tester.tap(find.text('영구로')); + await tester.pumpAndSettle(); + expect(find.text('영구 삭제'), findsOneWidget, reason: '제목이 바뀜'); + expect(find.text('영구 삭제 단추'), findsOneWidget); + expect(confirm().style, isNotNull, reason: '빨강'); + expect(tester.widget(find.byType(FilledButton)).autofocus, isTrue, reason: '첫 초점은 연 때 그대로 ([확인])'); + await tester.tap(find.text('영구 삭제 단추')); + await tester.pumpAndSettle(); + expect(got, isTrue); + }); +} diff --git a/test/explorer_test.dart b/test/explorer_test.dart index 68b60cb..63550e0 100644 --- a/test/explorer_test.dart +++ b/test/explorer_test.dart @@ -27,6 +27,7 @@ import 'package:jj_mkvmaker/ui/path_label.dart'; import 'package:image/image.dart' as img; import 'package:jj_mkvmaker/ui/reader_page.dart' show ReaderPage; import 'package:path/path.dart' as p; +import 'support/explorer_clock_keys.dart'; import 'support/fake_recycle_bin.dart'; import 'package:jj_mkvmaker/platform/windows/recycle_bin.dart' show recycleBinForTest; import 'package:jj_mkvmaker/ui/sync_preview_view.dart' show keepOptionsTogether; @@ -85,7 +86,7 @@ void main() { tester.view.physicalSize = const Size(1600, 900); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); - await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c)))); + await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c, clock: () => testNow)))); await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty); } @@ -99,16 +100,19 @@ void main() { expect(find.text('모니터링'), findsNothing); expect(find.text('좌 → 우'), findsNothing); - // 폴더를 한 번 누르면 바로 펼친다. 189: 다시 눌러도 (두 번 누름 포함) 접지 않는다 - 접기 · 펼치기는 화살표로 + // 폴더를 한 번 누르면 바로 펼친다. 규칙 3 (10/22): 조금 뒤 다시 누르면 접고, 또 누르면 펼친다 + // (예전 189 "다시 눌러도 접지 않음" 은 규칙 3 으로 바뀜. 두 번 누르기의 두 번째는 접지 않음 - 규칙 3 시험) await act(tester, () => tester.tap(find.text('sub'))); await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty); expect(find.text('inner.txt'), findsOneWidget); + testNow = testNow.add(const Duration(seconds: 1)); // 두 번 누르기 시간이 지난 뒤 await act(tester, () => tester.tap(find.text('sub'))); - await settle(tester, () => false, rounds: 15); - expect(find.text('inner.txt'), findsOneWidget, reason: '이름을 다시 눌러도 그대로'); - await doubleTap(tester, find.text('sub')); - await settle(tester, () => false, rounds: 15); - expect(find.text('inner.txt'), findsOneWidget, reason: '두 번 눌러도 그대로'); + await settle(tester, () => find.text('inner.txt').evaluate().isEmpty); + expect(find.text('inner.txt'), findsNothing, reason: '규칙 3: 다시 누르면 접힘'); + testNow = testNow.add(const Duration(seconds: 1)); + await act(tester, () => tester.tap(find.text('sub'))); + await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty); + expect(find.text('inner.txt'), findsOneWidget, reason: '또 누르면 펼침'); await act(tester, () => tester.tap(find.descendant( of: find.ancestor(of: find.text('sub'), matching: find.byType(InkWell)).first, matching: find.byIcon(Icons.expand_more)))); await settle(tester, () => find.text('inner.txt').evaluate().isEmpty); @@ -271,8 +275,13 @@ void main() { await act(tester, () => tester.tap(find.text('sub'))); await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty); expect(find.text('inner.txt'), findsOneWidget); - // 조금 뒤 다시 누름 - 접힘 - await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 800))); + // 두 번 누르기 시간 안의 두 번째 누르기 - 접지 않음 + testNow = testNow.add(const Duration(milliseconds: 100)); + await act(tester, () => tester.tap(find.text('sub'))); + await settle(tester, () => false, rounds: 15); + expect(find.text('inner.txt'), findsOneWidget, reason: '두 번 누르기의 두 번째'); + // 시간이 지난 뒤 다시 누름 - 접힘 + testNow = testNow.add(const Duration(seconds: 1)); await act(tester, () => tester.tap(find.text('sub'))); await settle(tester, () => find.text('inner.txt').evaluate().isEmpty); expect(find.text('inner.txt'), findsNothing); @@ -1951,17 +1960,6 @@ Finder markOf(String name) => find.descendant( ); /// 누르기 등을 실제 비동기 구역에서 (그 때 시작한 파일 작업이 테스트의 가짜 시간에 묶이지 않게) -/// 새로 고침 키: Windows 는 TC 키 Ctrl+R (규칙 1 - F5 는 복사), 그 밖은 F5 -Future refreshKey(WidgetTester tester) async { - if (!Platform.isWindows) { - await tester.sendKeyEvent(LogicalKeyboardKey.f5); - return; - } - await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); - await tester.sendKeyEvent(LogicalKeyboardKey.keyR); - await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); -} - Future act(WidgetTester tester, Future Function() f) async { await tester.runAsync(f); await tester.pump(); diff --git a/test/notice_contract_test.dart b/test/notice_contract_test.dart new file mode 100644 index 0000000..39c200a --- /dev/null +++ b/test/notice_contract_test.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/ui/notice.dart'; + +/// E4 U2 약속 시험 (모듈 하나에 한 장): lib/ui/notice.dart 맨 위 약속을 그대로 본다. +void main() { + late ScaffoldMessengerState messenger; + Future open(WidgetTester tester) async { + final key = GlobalKey(); + await tester.pumpWidget(MaterialApp(scaffoldMessengerKey: key, home: const Scaffold(body: SizedBox()))); + messenger = key.currentState!; + } + + testWidgets('단추가 있어도 timed 는 noticeDuration 뒤 저절로 닫힘 (U2) · 앱 전체 한 값', (tester) async { + await open(tester); + var pressed = 0; + showNotice(messenger, text: '복사했습니다', action: ('폴더 열기', () => pressed++)); + await tester.pumpAndSettle(); + expect(find.text('복사했습니다'), findsOneWidget); + expect(find.byIcon(Icons.close), findsOneWidget, reason: '✕ 로 바로 닫기'); + await tester.pump(noticeDuration - const Duration(seconds: 1)); + expect(find.text('복사했습니다'), findsOneWidget, reason: '정해진 시간 전에는 남음'); + await tester.pump(const Duration(seconds: 2)); + await tester.pumpAndSettle(); + expect(find.text('복사했습니다'), findsNothing); + expect(pressed, 0); + expect(noticeDuration, const Duration(seconds: 10)); + }); + + testWidgets('단추를 누르면 그 일을 하고 닫힘', (tester) async { + await open(tester); + var pressed = 0; + showNotice(messenger, text: '지웠습니다', action: ('되돌리기', () => pressed++)); + await tester.pumpAndSettle(); + await tester.tap(find.text('되돌리기')); + await tester.pumpAndSettle(); + expect(pressed, 1); + expect(find.text('지웠습니다'), findsNothing); + }); + + testWidgets('untilClosed 는 오래 지나도 남고 ✕ · 부르는 쪽이 닫음', (tester) async { + await open(tester); + showNotice(messenger, life: NoticeLife.untilClosed, content: const Text('DNS 안내'), action: ('바꾸기', () {})); + await tester.pumpAndSettle(); + await tester.pump(const Duration(minutes: 30)); + expect(find.text('DNS 안내'), findsOneWidget); + await tester.tap(find.byIcon(Icons.close)); + await tester.pumpAndSettle(); + expect(find.text('DNS 안내'), findsNothing); + final bar = showNotice(messenger, life: NoticeLife.untilClosed, text: '준비 중'); + await tester.pumpAndSettle(); + bar!.close(); + await tester.pumpAndSettle(); + expect(find.text('준비 중'), findsNothing); + }); + + testWidgets('clearOthers 는 앞 알림을 치우고 바로 보임 · messenger 가 없으면 null', (tester) async { + await open(tester); + showNotice(messenger, life: NoticeLife.untilClosed, text: '앞 알림'); + await tester.pumpAndSettle(); + showNotice(messenger, text: '되돌리기 알림', clearOthers: true, action: ('실행 취소', () {})); + await tester.pumpAndSettle(); + expect(find.text('앞 알림'), findsNothing); + expect(find.text('되돌리기 알림'), findsOneWidget); + expect(showNotice(null, text: 'x'), isNull); + }); +} diff --git a/test/support/explorer_clock_keys.dart b/test/support/explorer_clock_keys.dart new file mode 100644 index 0000000..0156b37 --- /dev/null +++ b/test/support/explorer_clock_keys.dart @@ -0,0 +1,20 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// 탐색기 시험 도우미 (E4): 지금 시각 · 새로 고침 키 + +/// 탐색기에 넘기는 지금 시각 (두 번 누르기 판단) - 시험이 정한 만큼만 흐른다 +DateTime testNow = DateTime(2026, 10, 22, 9); + +/// 새로 고침 키: Windows 는 TC 키 Ctrl+R (규칙 1 - F5 는 복사), 그 밖은 F5 +Future refreshKey(WidgetTester tester) async { + if (!Platform.isWindows) { + await tester.sendKeyEvent(LogicalKeyboardKey.f5); + return; + } + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyR); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); +}