E4: 탐색기 불편 고침 - 공용 알림 showNotice (13곳) · 공용 확인 창 confirmDialog · 두 창 처음 폴더 · 다른 창 표시 안내 · 지금 줄 강조 · 폰 두 창 넘침

점검 (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 <noreply@anthropic.com>
split_pages
RyuJongJin 2026-10-22 07:53:09 +09:00
parent 9ba39e5ed6
commit 8335a3eac7
19 changed files with 725 additions and 439 deletions

View File

@ -1032,6 +1032,7 @@
"다른 저장소로는 이름만 바꿀 수 없습니다": "Cannot just rename onto another storage", "다른 저장소로는 이름만 바꿀 수 없습니다": "Cannot just rename onto another storage",
"다른 창 폴더": "Other pane's folder", "다른 창 폴더": "Other pane's folder",
"다른 창 폴더로": "To the 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", "다른 창에서 열기": "Open in other pane",
"다른 창으로 끌어 놓았을 때. 어느 쪽이든 복사 · 이동 확인 창 (같은 이름 등) 은 그대로 뜹니다": "When you drop onto the other pane. Either way, the copy/move confirmation (same names etc.) still appears", "다른 창으로 끌어 놓았을 때. 어느 쪽이든 복사 · 이동 확인 창 (같은 이름 등) 은 그대로 뜹니다": "When you drop onto the other pane. Either way, the copy/move confirmation (same names etc.) still appears",
"다른 창으로 복사": "Copy to other pane", "다른 창으로 복사": "Copy to other pane",

View File

@ -1032,6 +1032,7 @@
"다른 저장소로는 이름만 바꿀 수 없습니다": "別のストレージへは名前の変更だけではできません", "다른 저장소로는 이름만 바꿀 수 없습니다": "別のストレージへは名前の変更だけではできません",
"다른 창 폴더": "もう一方のウィンドウのフォルダー", "다른 창 폴더": "もう一方のウィンドウのフォルダー",
"다른 창 폴더로": "もう一方の画面のフォルダーへ", "다른 창 폴더로": "もう一方の画面のフォルダーへ",
"다른 창에 {0}개 표시됨 (이번 대상 아님)": "もう一方の画面に {0} 個のマーク (今回の対象外)",
"다른 창에서 열기": "もう一方のペインで開く", "다른 창에서 열기": "もう一方のペインで開く",
"다른 창으로 끌어 놓았을 때. 어느 쪽이든 복사 · 이동 확인 창 (같은 이름 등) 은 그대로 뜹니다": "もう一方のウィンドウにドロップしたとき。どちらでもコピー・移動の確認 (同じ名前など) は表示されます", "다른 창으로 끌어 놓았을 때. 어느 쪽이든 복사 · 이동 확인 창 (같은 이름 등) 은 그대로 뜹니다": "もう一方のウィンドウにドロップしたとき。どちらでもコピー・移動の確認 (同じ名前など) は表示されます",
"다른 창으로 복사": "もう一方のペインへコピー", "다른 창으로 복사": "もう一方のペインへコピー",

View File

@ -1032,6 +1032,7 @@
"다른 저장소로는 이름만 바꿀 수 없습니다": "不能仅通过重命名移到其他存储", "다른 저장소로는 이름만 바꿀 수 없습니다": "不能仅通过重命名移到其他存储",
"다른 창 폴더": "另一窗格的文件夹", "다른 창 폴더": "另一窗格的文件夹",
"다른 창 폴더로": "到另一窗格的文件夹", "다른 창 폴더로": "到另一窗格的文件夹",
"다른 창에 {0}개 표시됨 (이번 대상 아님)": "另一窗格中已标记 {0} 个 (不在本次操作内)",
"다른 창에서 열기": "在另一窗格中打开", "다른 창에서 열기": "在另一窗格中打开",
"다른 창으로 끌어 놓았을 때. 어느 쪽이든 복사 · 이동 확인 창 (같은 이름 등) 은 그대로 뜹니다": "拖放到另一个窗格时。无论哪种,都会显示复制/移动确认 (同名等)", "다른 창으로 끌어 놓았을 때. 어느 쪽이든 복사 · 이동 확인 창 (같은 이름 등) 은 그대로 뜹니다": "拖放到另一个窗格时。无论哪种,都会显示复制/移动确认 (同名等)",
"다른 창으로 복사": "复制到另一窗格", "다른 창으로 복사": "复制到另一窗格",

30
lib/core/name_clash.dart Normal file
View File

@ -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')}';

View File

@ -71,6 +71,7 @@ import 'ui/work_panel.dart';
import 'l10n/tr.dart'; import 'l10n/tr.dart';
import 'app/i18n_controller.dart'; import 'app/i18n_controller.dart';
import 'platform/android/android_updater.dart'; import 'platform/android/android_updater.dart';
import 'ui/notice.dart';
/// 제목 표시줄 글: "JJ_MKVMaker v1.2.3" /// 제목 표시줄 글: "JJ_MKVMaker v1.2.3"
String appTitle = 'JJ_MKVMaker'; String appTitle = 'JJ_MKVMaker';
@ -520,18 +521,15 @@ void watchDnsFailure(GlobalKey<ScaffoldMessengerState> messengerKey, AppControll
if (f == null) return; if (f == null) return;
// 248: 같은 안내를 보이는 화면 (브라우저 덮개 · 설정의 DNS 칸) 이 열려 있으면 띄우지 않는다 (상태는 설정 · 작업 기록 줄에) // 248: 같은 안내를 보이는 화면 (브라우저 덮개 · 설정의 DNS 칸) 이 열려 있으면 띄우지 않는다 (상태는 설정 · 작업 기록 줄에)
if (NetGuard.instance.noticeBlockers.value > 0) return; if (NetGuard.instance.noticeBlockers.value > 0) return;
messengerKey.currentState?.showSnackBar(SnackBar( showNotice(
messengerKey.currentState,
// 248: 버튼이 있는 안내라 사용자가 닫거나 누를 때까지 둔다 (한 번만 뜨고, 상태는 설정 · 작업 현황에 남음 - 249) // 248: 버튼이 있는 안내라 사용자가 닫거나 누를 때까지 둔다 (한 번만 뜨고, 상태는 설정 · 작업 현황에 남음 - 249)
persist: true, life: NoticeLife.untilClosed,
showCloseIcon: true,
// 사용자에게는 무엇이 안 되고 무엇을 할지만 (예외 원문은 작업 기록에 - NetGuard.reportError). // 사용자에게는 무엇이 안 되고 무엇을 할지만 (예외 원문은 작업 기록에 - NetGuard.reportError).
// 상태가 풀리거나 같은 안내를 보이는 화면이 열리면 스스로 닫힌다 (248) // 상태가 풀리거나 같은 안내를 보이는 화면이 열리면 스스로 닫힌다 (248)
content: DnsFailureNotice(failure: f), content: DnsFailureNotice(failure: f),
action: SnackBarAction( action: (tr('시스템 DNS 로 바꾸기'), () => c.updateSettings((x) => x.dnsMode = 'system')),
label: tr('시스템 DNS 로 바꾸기'), );
onPressed: () => c.updateSettings((x) => x.dnsMode = 'system'),
),
));
}); });
} }

22
lib/ui/adaptive_row.dart Normal file
View File

@ -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<Widget> lead;
final List<Widget> 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),
]),
);
}

View File

@ -3,27 +3,25 @@ import 'package:flutter/material.dart';
import '../app/app_controller.dart'; import '../app/app_controller.dart';
import '../core/file_ops.dart' show samePath; import '../core/file_ops.dart' show samePath;
import '../l10n/tr.dart'; import '../l10n/tr.dart';
import 'notice.dart';
/// "완료 시 자동 동영상 추가" 로 다 받은 동영상을 MKV 목록에 넣고 다운로드 목록에서 뺐을 때의 알림. /// "완료 시 자동 동영상 추가" 로 다 받은 동영상을 MKV 목록에 넣고 다운로드 목록에서 뺐을 때의 알림.
/// 줄이 갑자기 사라져 "다운로드 (0)" 이 된 이유를 알 수 있게 [보기] (MKV 화면 · 그 동영상) 를 함께 (관리자 판단, 931) /// 줄이 갑자기 사라져 "다운로드 (0)" 이 된 이유를 알 수 있게 [보기] (MKV 화면 · 그 동영상) 를 함께 (관리자 판단, 931)
void showAddedToMkvNotice(GlobalKey<ScaffoldMessengerState> messenger, GlobalKey<NavigatorState> navigator, void showAddedToMkvNotice(GlobalKey<ScaffoldMessengerState> messenger, GlobalKey<NavigatorState> navigator,
AppController c, {required String title, required List<String> files, required int added}) { AppController c, {required String title, required List<String> files, required int added}) {
final m = messenger.currentState; showNotice(
if (m == null) return; messenger.currentState,
m.showSnackBar(SnackBar( text: added > 0
duration: const Duration(seconds: 8),
showCloseIcon: true,
content: Text(added > 0
? trf('다 받은 {0} 을 MKV 목록에 넣고 다운로드 목록에서 뺐습니다 (받은 파일은 그대로)', [title]) ? trf('다 받은 {0} 을 MKV 목록에 넣고 다운로드 목록에서 뺐습니다 (받은 파일은 그대로)', [title])
: trf('다 받은 {0} 은 이미 MKV 목록에 있어 다운로드 목록에서 뺐습니다 (받은 파일은 그대로)', [title])), : trf('다 받은 {0} 은 이미 MKV 목록에 있어 다운로드 목록에서 뺐습니다 (받은 파일은 그대로)', [title]),
action: SnackBarAction( action: (
label: tr('보기'), tr('보기'),
onPressed: () { () {
// MKV 화면 (맨 처음 화면) 으로 가서 그 동영상을 보이게 // MKV 화면 (맨 처음 화면) 으로 가서 그 동영상을 보이게
navigator.currentState?.popUntil((r) => r.isFirst); navigator.currentState?.popUntil((r) => r.isFirst);
final v = c.videos.where((x) => files.any((f) => samePath(f, x.path))).firstOrNull; final v = c.videos.where((x) => files.any((f) => samePath(f, x.path))).firstOrNull;
if (v != null) c.select(v); if (v != null) c.select(v);
}, }
), ),
)); );
} }

View File

@ -10,6 +10,7 @@ import 'existing_output_dialog.dart';
import 'license_texts.dart' show NllbLicenseNote; import 'license_texts.dart' show NllbLicenseNote;
import 'theme.dart'; import 'theme.dart';
import '../l10n/tr.dart'; import '../l10n/tr.dart';
import 'notice.dart';
/// AI 자막 만들기 설정 창 /// AI 자막 만들기 설정 창
/// [targets] 를 주면 그 동영상들만 (탐색기 "자막 만들기" 메뉴 · 여러 개 선택) /// [targets] 를 주면 그 동영상들만 (탐색기 "자막 만들기" 메뉴 · 여러 개 선택)
@ -44,12 +45,13 @@ Future<void> showAiDialog(BuildContext context, AppController c, VideoItem curre
final messenger = ScaffoldMessenger.maybeOf(context); final messenger = ScaffoldMessenger.maybeOf(context);
if (messenger == null) return run(list, c.aiOptions); if (messenger == null) return run(list, c.aiOptions);
var cancelled = false; var cancelled = false;
messenger.showSnackBar(SnackBar( // 시작까지 [취소] 를 받다가 시작하면 닫는다 (부르는 쪽이 닫음)
duration: aiStartDelay, final bar = showNotice(messenger,
content: Text(aiStartNotice(list, c.aiOptions, thenBuild: thenBuild)), life: NoticeLife.untilClosed,
action: SnackBarAction(label: tr('취소'), onPressed: () => cancelled = true), text: aiStartNotice(list, c.aiOptions, thenBuild: thenBuild),
)); action: (tr('취소'), () => cancelled = true));
await Future<void>.delayed(aiStartDelay); await Future<void>.delayed(aiStartDelay);
bar?.close();
if (cancelled) return; if (cancelled) return;
return run(list, c.aiOptions); return run(list, c.aiOptions);
} }
@ -67,22 +69,22 @@ void showDetectedLanguage(ScaffoldMessengerState messenger, OverlayState? overla
final names = found.length == 1 final names = found.length == 1
? found.single.$2.name ? found.single.$2.name
: found.map((x) => '${x.$2.name} (${x.$1.fileName})').join(' · '); : found.map((x) => '${x.$2.name} (${x.$1.fileName})').join(' · ');
messenger.showSnackBar(SnackBar( showNotice(
messenger,
// 248 과 같게: 버튼이 있는 안내는 사용자가 닫을 때까지 ([✕]). 나중에는 MKV 목록의 그 동영상 세부 정보에서도 // 248 과 같게: 버튼이 있는 안내는 사용자가 닫을 때까지 ([✕]). 나중에는 MKV 목록의 그 동영상 세부 정보에서도
persist: true, life: NoticeLife.untilClosed,
showCloseIcon: true, text: trf('감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요', [names]),
content: Text(trf('감지한 언어: {0} - 틀렸으면 다른 언어로 다시 만드세요', [names])),
action: overlay == null action: overlay == null
? null ? null
: SnackBarAction( : (
label: tr('다른 언어로 다시'), tr('다른 언어로 다시'),
onPressed: () async { () async {
if (!overlay.mounted) return; if (!overlay.mounted) return;
final lang = await pickSourceLanguage(overlay.context, found.first.$2); final lang = await pickSourceLanguage(overlay.context, found.first.$2);
if (lang != null) await onRetry(lang); if (lang != null) await onRetry(lang);
}, }
), ),
)); );
} }
/// 198: 원어를 골라 다시 만들 때의 언어 고르기 (원어를 처음부터 정하는 곳도 알린다) /// 198: 원어를 골라 다시 만들 때의 언어 고르기 (원어를 처음부터 정하는 곳도 알린다)

View File

@ -19,6 +19,7 @@ import 'ai_image_page.dart' show aiSaveDir;
import 'confirm.dart'; import 'confirm.dart';
import 'explorer_page.dart' show ExplorerPage; import 'explorer_page.dart' show ExplorerPage;
import 'theme.dart'; import 'theme.dart';
import 'notice.dart';
UpscaleOptions upscaleOptionsOf(AppController c, {String? outDir}) { UpscaleOptions upscaleOptionsOf(AppController c, {String? outDir}) {
final s = c.settings; final s = c.settings;
@ -417,11 +418,7 @@ Future<void> upscaleAll(BuildContext context, AppController c, ReaderSource src)
} catch (_) {} } catch (_) {}
} }
final dir = lastDir; final dir = lastDir;
messenger?.showSnackBar(SnackBar( showNotice(messenger, text: text, action: dir == null ? null : (tr('열기'), () => openFolderInApp(nav, c, dir)));
duration: const Duration(seconds: 10),
content: Text(text),
action: dir == null ? null : SnackBarAction(label: tr('열기'), onPressed: () => openFolderInApp(nav, c, dir)),
));
} }
/// 원본 / 올린 것을 바꿔 보며 비교 (확대해서 볼 수 있음). [저장] 이면 true, 저장하지 않고 닫으면 false /// 원본 / 올린 것을 바꿔 보며 비교 (확대해서 볼 수 있음). [저장] 이면 true, 저장하지 않고 닫으면 false

View File

@ -9,6 +9,7 @@ import '../core/bookmarks.dart';
import 'app_actions.dart'; import 'app_actions.dart';
import 'theme.dart'; import 'theme.dart';
import '../l10n/tr.dart'; import '../l10n/tr.dart';
import 'notice.dart';
// 즐겨찾기 공용 화면 조각 (Chrome 의 즐겨찾기를 참고): // 즐겨찾기 공용 화면 조각 (Chrome 의 즐겨찾기를 참고):
// - 즐겨찾기 수정 창: 이름 · 주소 · 저장할 폴더 (+ 새 폴더) // - 즐겨찾기 수정 창: 이름 · 주소 · 저장할 폴더 (+ 새 폴더)
@ -237,13 +238,10 @@ Future<String?> askText(BuildContext context, String title, String label, String
void removeBookmarkWithUndo(BuildContext context, BookmarksController bm, BookmarkNode n) { void removeBookmarkWithUndo(BuildContext context, BookmarksController bm, BookmarkNode n) {
if (!bm.remove(n.id)) return; if (!bm.remove(n.id)) return;
// 되돌리기는 바로 보여야 하므로 앞서 떠 있던 알림은 치운다 // 되돌리기는 바로 보여야 하므로 앞서 떠 있던 알림은 치운다
ScaffoldMessenger.of(context) showNotice(ScaffoldMessenger.of(context),
..clearSnackBars() clearOthers: true,
..showSnackBar(SnackBar( text: n.isFolder ? trf('폴더 삭제: {0} (안의 {1}개 포함)', [n.title, bm.tree.countLinks(n)]) : trf('삭제: {0}', [n.title]),
content: Text(n.isFolder ? trf('폴더 삭제: {0} (안의 {1}개 포함)', [n.title, bm.tree.countLinks(n)]) : trf('삭제: {0}', [n.title])), action: (tr('실행 취소'), bm.undoRemove));
persist: false, // Flutter 3.47+: [action] 이 있으면 기본은 안 사라짐 → duration 대로 닫기
action: SnackBarAction(label: tr('실행 취소'), onPressed: bm.undoRemove),
));
} }
/// 즐겨찾기 오른쪽 클릭 메뉴 (Chrome 과 같은 항목). [n] 이 null 이면 빈 곳 (폴더 [folderId] 에 추가만). /// 즐겨찾기 오른쪽 클릭 메뉴 (Chrome 과 같은 항목). [n] 이 null 이면 빈 곳 (폴더 [folderId] 에 추가만).

View File

@ -36,6 +36,7 @@ import 'theme.dart';
import 'bottom_inset_hold.dart'; import 'bottom_inset_hold.dart';
import 'work_panel.dart'; import 'work_panel.dart';
import '../l10n/tr.dart'; import '../l10n/tr.dart';
import 'notice.dart';
/// 브라우저 조작 (앱 안 웹뷰) /// 브라우저 조작 (앱 안 웹뷰)
abstract class WebNav { abstract class WebNav {
@ -647,13 +648,11 @@ class _BrowserPageState extends State<BrowserPage> with RouteAware {
// 알림은 앱 전체에 떠 있으므로 이 브라우저 화면이 아니라 앱의 Navigator 로 연다 // 알림은 앱 전체에 떠 있으므로 이 브라우저 화면이 아니라 앱의 Navigator 로 연다
// (화면을 오가 이 브라우저 화면이 닫혀도 "목록 보기" 가 된다) // (화면을 오가 이 브라우저 화면이 닫혀도 "목록 보기" 가 된다)
final nav = Navigator.of(context); final nav = Navigator.of(context);
final bar = m.showSnackBar(SnackBar( // 받기 준비가 끝날 때까지 두고 아래 check 가 닫는다 ([✕] 로 바로 닫기)
content: Text(trf('다운로드 추가: {0}', [_title.isEmpty ? _url : _title])), final bar = showNotice(m,
duration: const Duration(minutes: 10), life: NoticeLife.untilClosed,
showCloseIcon: true, // [✕] 로 바로 닫기 text: trf('다운로드 추가: {0}', [_title.isEmpty ? _url : _title]),
persist: false, // Flutter 3.47+: [action] 이 있으면 기본은 안 사라짐 → duration 대로 닫기 action: (tr('목록 보기'), () => DownloadsPage.open(nav, d)))!;
action: SnackBarAction(label: tr('목록 보기'), onPressed: () => DownloadsPage.open(nav, d)),
));
// 받기 준비가 끝나면 (진행률이 나오거나 · 끝 · 실패 · 취소 · 목록에서 지움) 알림을 닫는다 // 받기 준비가 끝나면 (진행률이 나오거나 · 끝 · 실패 · 취소 · 목록에서 지움) 알림을 닫는다
void check() { void check() {
final ready = !d.tasks.contains(t) || final ready = !d.tasks.contains(t) ||

View File

@ -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<bool> 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<bool>(
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;
}

View File

@ -83,6 +83,12 @@ import 'batch_rename_dialog.dart';
import '../core/app_trash.dart'; import '../core/app_trash.dart';
import '../core/folder_size.dart'; import '../core/folder_size.dart';
import '../core/batch_rename.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 참고): 두 창 (트리 목록) + 가운데 기능 버튼 줄. /// 파일 탐색기 (X-plore 참고): 두 창 (트리 목록) + 가운데 기능 버튼 줄.
/// ///
@ -108,7 +114,10 @@ double treeChevronWidth(TargetPlatform platform) =>
class ExplorerPage extends StatefulWidget { class ExplorerPage extends StatefulWidget {
final AppController c; final AppController c;
final bool rsync; 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: 드라이브를 열 수 있는지 (시험에서 응답 없는 드라이브로 바꾼다). 화면을 멈추지 않는 비동기 /// 165: 드라이브를 열 수 있는지 (시험에서 응답 없는 드라이브로 바꾼다). 화면을 멈추지 않는 비동기
@visibleForTesting @visibleForTesting
@ -532,8 +541,10 @@ class _ExplorerPageState extends State<ExplorerPage> {
// 165: 지난 폴더가 느린 네트워크 드라이브여도 화면이 멈추지 않게 (뒤에서 확인, 오래 걸리면 그냥 열어 본다 - 못 읽으면 그 창에 이유) // 165: 지난 폴더가 느린 네트워크 드라이브여도 화면이 멈추지 않게 (뒤에서 확인, 오래 걸리면 그냥 열어 본다 - 못 읽으면 그 창에 이유)
final reach = await Future.wait( final reach = await Future.wait(
[for (var i = 0; i < 2; i++) i < saved.length ? _reachableAsync(saved[i]) : Future.value(false)]); [for (var i = 0; i < 2; i++) i < saved.length ? _reachableAsync(saved[i]) : Future.value(false)]);
// U1 (점검 10/22): 저장된 경로가 없는 창은 창마다 다른 저장 장치 (오른쪽 = 두 번째, 없으면 첫째) - 두 창이 같은 목록을
// 두 번 보이면 [복사] 의 "다른 창으로" 가 같은 곳이 되어 헷갈렸다. 저장된 경로가 있으면 그대로
final wants = [ 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: "이 폴더를 탐색기로" 로 열렸으면 첫 창은 그 폴더 (지난 폴더 대신) // 162: "이 폴더를 탐색기로" 로 열렸으면 첫 창은 그 폴더 (지난 폴더 대신)
final asked = widget.rsync ? null : ExplorerPage.goToRequest.value; final asked = widget.rsync ? null : ExplorerPage.goToRequest.value;
@ -1628,7 +1639,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
return; return;
} }
// 폴더는 두 번 누르기를 기다리지 않는다 - 짧은 사이의 두 번째 누르기로 방금 펼친 것을 접지 않게 // 폴더는 두 번 누르기를 기다리지 않는다 - 짧은 사이의 두 번째 누르기로 방금 펼친 것을 접지 않게
final now = DateTime.now(); final now = widget.clock();
final last = _lastFolderTap; final last = _lastFolderTap;
_lastFolderTap = (e.path, now); _lastFolderTap = (e.path, now);
final repeat = last != null && samePath(last.$1, e.path) && now.difference(last.$2) < kDoubleTapTimeout * 2; final repeat = last != null && samePath(last.$1, e.path) && now.difference(last.$2) < kDoubleTapTimeout * 2;
@ -2362,12 +2373,11 @@ class _ExplorerPageState extends State<ExplorerPage> {
..forget(e.path) ..forget(e.path)
..request(e.path); ..request(e.path);
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar( showNotice(ScaffoldMessenger.of(context),
content: Text(isDav(e.path) text: isDav(e.path)
? trf('"{0}" 크기를 계산합니다. 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다.', [e.name]) ? trf('"{0}" 크기를 계산합니다. 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다.', [e.name])
: trf('"{0}" 크기를 계산합니다.', [e.name])), : trf('"{0}" 크기를 계산합니다.', [e.name]),
action: SnackBarAction(label: tr('취소'), onPressed: () => _sizes.cancel(e.path)), action: (tr('취소'), () => _sizes.cancel(e.path)));
));
} }
case 'rename': case 'rename':
if (_isMarked(pane, e.path) && pane.sel.marked.length > 1) { if (_isMarked(pane, e.path) && pane.sel.marked.length > 1) {
@ -2900,21 +2910,19 @@ class _ExplorerPageState extends State<ExplorerPage> {
if (done == null || done.isEmpty || !mounted) return; if (done == null || done.isEmpty || !mounted) return;
await _afterBatchRename(pane, done); await _afterBatchRename(pane, done);
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar( showNotice(ScaffoldMessenger.of(context),
content: Text(trf('{0}개의 이름을 바꿨습니다.', [done.length])), text: trf('{0}개의 이름을 바꿨습니다.', [done.length]),
duration: const Duration(seconds: 10), action: (
action: SnackBarAction( tr('되돌리기'),
label: tr('되돌리기'), () async {
onPressed: () async { try {
try { final back = await runBatchRename([for (final (orig, now) in done) (now, vBasename(orig))]);
final back = await runBatchRename([for (final (orig, now) in done) (now, vBasename(orig))]); if (mounted) await _afterBatchRename(pane, back);
if (mounted) await _afterBatchRename(pane, back); } on BatchRenameException catch (e) {
} on BatchRenameException catch (e) { _snack(trf('되돌리지 못했습니다: {0}', [e]));
_snack(trf('되돌리지 못했습니다: {0}', [e])); }
} }
}, ));
),
));
} }
/// 바꾼 뒤: 목록을 다시 읽고, 표시 · 초점을 새 이름으로 /// 바꾼 뒤: 목록을 다시 읽고, 표시 · 초점을 새 이름으로
@ -2956,70 +2964,68 @@ class _ExplorerPageState extends State<ExplorerPage> {
for (final h in c.settings.explorerHiddenItems) for (final h in c.settings.explorerHiddenItems)
if (paths.any((x) => !samePath(h, x) && isSameOrInside(h, x))) h if (paths.any((x) => !samePath(h, x) && isSameOrInside(h, x))) h
].length; ].length;
final ok = await showDialog<bool>( final otherMarks = [for (final x in _panes) if (!identical(x, pane)) x.sel.marked.length].fold(0, (a, b) => a + b);
context: context, // U7: 확인 창은 공용 confirmDialog - 영구 삭제로 열면 Enter 의 첫 초점은 [취소]
builder: (ctx) => StatefulBuilder( final ok = await confirmDialog(
builder: (ctx, set) => AlertDialog( context,
scrollable: true, title: () => permanent ? tr('영구 삭제') : tr('휴지통으로 보내기'),
title: Text(permanent ? tr('영구 삭제') : tr('휴지통으로 보내기')), danger: () => permanent,
// 무엇을 지우는지 늘 보여 준다 (여러 개면 앞의 8개 이름 · 폴더) confirmLabel: () => permanent ? tr('영구 삭제') : tr('휴지통으로'),
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ // 무엇을 지우는지 늘 보여 준다 (여러 개면 앞의 8개 이름 · 폴더)
Text(paths.length == 1 body: (ctx, set) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
? '${permanent ? trf('"{0}" 을(를) 지울까요? 되돌릴 수 없습니다.', [vBasename(paths.first)]) : trf('"{0}" 을(를) 휴지통으로 보낼까요?', [vBasename(paths.first)])}' Text(paths.length == 1
'\n${vDirname(paths.first)}' ? '${permanent ? trf('"{0}" 을(를) 지울까요? 되돌릴 수 없습니다.', [vBasename(paths.first)]) : trf('"{0}" 을(를) 휴지통으로 보낼까요?', [vBasename(paths.first)])}'
: [ '\n${vDirname(paths.first)}'
permanent : [
? trf('{0}개 항목을 지울까요? 되돌릴 수 없습니다.', [paths.length]) permanent
: trf('{0}개 항목을 휴지통으로 보낼까요?', [paths.length]), ? trf('{0}개 항목을 지울까요? 되돌릴 수 없습니다.', [paths.length])
for (final x in paths.take(8)) '· ${vBasename(x)}', : trf('{0}개 항목을 휴지통으로 보낼까요?', [paths.length]),
if (paths.length > 8) trf('… 외 {0}개', [paths.length - 8]), for (final x in paths.take(8)) '· ${vBasename(x)}',
{for (final x in paths) vDirname(x)}.join('\n'), if (paths.length > 8) trf('… 외 {0}개', [paths.length - 8]),
].join('\n')), {for (final x in paths) vDirname(x)}.join('\n'),
// 79-b: 폴더가 있으면 몇 개인지 (안의 것도 함께 지워진다) ].join('\n')),
if (paths.any(vIsDirSync)) // 79-b: 폴더가 있으면 몇 개인지 (안의 것도 함께 지워진다)
Padding( if (paths.any(vIsDirSync))
padding: const EdgeInsets.only(top: 8), Padding(
child: Text(trf('그중 폴더 {0}개 - 안의 파일 · 폴더도 모두 함께', [paths.where(vIsDirSync).length]), padding: const EdgeInsets.only(top: 8),
style: TextStyle(fontSize: 12, color: JjColors.warning)), child: Text(trf('그중 폴더 {0}개 - 안의 파일 · 폴더도 모두 함께', [paths.where(vIsDirSync).length]),
), style: TextStyle(fontSize: 12, color: JjColors.warning)),
if (hiddenInside > 0) ),
Padding( if (hiddenInside > 0)
padding: const EdgeInsets.only(top: 8), Padding(
child: Text(trf('지우는 폴더 안에 목록에서 숨긴 항목 {0}개가 있습니다 - 함께 지워집니다', [hiddenInside]), padding: const EdgeInsets.only(top: 8),
style: TextStyle(fontSize: 12, color: JjColors.warning)), child: Text(trf('지우는 폴더 안에 목록에서 숨긴 항목 {0}개가 있습니다 - 함께 지워집니다', [hiddenInside]),
), style: TextStyle(fontSize: 12, color: JjColors.warning)),
if (local && !canRecycle) ),
Padding( if (local && !canRecycle)
padding: const EdgeInsets.only(top: 8), Padding(
child: Text(tr('이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.'), padding: const EdgeInsets.only(top: 8),
style: TextStyle(fontSize: 12, color: JjColors.warning)), child: Text(tr('이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.'),
), style: TextStyle(fontSize: 12, color: JjColors.warning)),
if (canTrash && !permanent) ),
Padding( if (canTrash && !permanent)
padding: const EdgeInsets.only(top: 8), Padding(
child: Text(trf('앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).', [c.settings.androidTrashDays]), padding: const EdgeInsets.only(top: 8),
style: TextStyle(fontSize: 12, color: JjColors.textDim)), child: Text(trf('앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).', [c.settings.androidTrashDays]),
), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
if (canRecycle) ),
CheckboxListTile( if (canRecycle)
contentPadding: EdgeInsets.zero, CheckboxListTile(
value: permanent, contentPadding: EdgeInsets.zero,
onChanged: (v) => set(() => permanent = v ?? false), value: permanent,
title: Text(canTrash ? tr('휴지통을 거치지 않고 영구 삭제') : tr('휴지통을 거치지 않고 영구 삭제 (Shift+Delete)')), onChanged: (v) => set(() => permanent = v ?? false),
), title: Text(canTrash ? tr('휴지통을 거치지 않고 영구 삭제') : tr('휴지통을 거치지 않고 영구 삭제 (Shift+Delete)')),
]), ),
actions: [ // U4: 다른 창의 표시는 이번 대상이 아님
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), if (otherMarks > 0)
FilledButton( Padding(
style: FilledButton.styleFrom(backgroundColor: permanent ? Colors.red.shade700 : null), padding: const EdgeInsets.only(top: 8),
onPressed: () => Navigator.pop(ctx, true), child: Text(trf('다른 창에 {0}개 표시됨 (이번 대상 아님)', [otherMarks]),
child: Text(permanent ? tr('영구 삭제') : tr('휴지통으로')), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
), ),
], ]),
),
),
); );
if (ok != true) return; if (!ok) return;
// 하나씩: 실패해도 나머지는 계속. 실제로 어떻게 됐는지 세어 그대로 알린다 (94) // 하나씩: 실패해도 나머지는 계속. 실제로 어떻게 됐는지 세어 그대로 알린다 (94)
final failed = <(String, String)>[]; final failed = <(String, String)>[];
// 144: 경로가 너무 길어 휴지통이 받지 않은 것 (이유는 한 번, [영구 삭제] 로 바로 지울 수 있게) // 144: 경로가 너무 길어 휴지통이 받지 않은 것 (이유는 한 번, [영구 삭제] 로 바로 지울 수 있게)
@ -3066,28 +3072,21 @@ class _ExplorerPageState extends State<ExplorerPage> {
} }
// X-plore 비교 e: 휴지통으로 옮기지 못한 것 (다른 저장 장치 · 권한 등) - 몰래 지우지 않고 영구 삭제할지 묻는다 // X-plore 비교 e: 휴지통으로 옮기지 못한 것 (다른 저장 장치 · 권한 등) - 몰래 지우지 않고 영구 삭제할지 묻는다
if (notTrashed.isNotEmpty && mounted) { if (notTrashed.isNotEmpty && mounted) {
final nuke = await showDialog<bool>( // U7: 되돌릴 수 없는 일 - Enter 의 첫 초점은 [그대로 두기]
context: context, final nuke = await confirmDialog(
builder: (ctx) => AlertDialog( context,
scrollable: true, icon: Icon(Icons.warning_amber_rounded, color: JjColors.warning),
icon: Icon(Icons.warning_amber_rounded, color: JjColors.warning), title: () => trf('{0}개를 휴지통으로 옮기지 못했습니다', [notTrashed.length]),
title: Text(trf('{0}개를 휴지통으로 옮기지 못했습니다', [notTrashed.length])), danger: () => true,
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ cancelLabel: tr('그대로 두기'),
for (final (x, why) in notTrashed.take(10)) Text('· ${vBasename(x)} - $why', style: const TextStyle(fontSize: 12)), confirmLabel: () => trf('영구 삭제 ({0}개, 되돌릴 수 없음)', [notTrashed.length]),
const SizedBox(height: 8), body: (ctx, set) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(tr('영구 삭제하면 되살릴 수 없습니다.')), for (final (x, why) in notTrashed.take(10)) Text('· ${vBasename(x)} - $why', style: const TextStyle(fontSize: 12)),
]), const SizedBox(height: 8),
actions: [ Text(tr('영구 삭제하면 되살릴 수 없습니다.')),
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])),
),
],
),
); );
if (nuke == true) { if (nuke) {
for (final (x, _) in notTrashed) { for (final (x, _) in notTrashed) {
try { try {
await FileOps().delete([x]); await FileOps().delete([x]);
@ -3098,45 +3097,50 @@ class _ExplorerPageState extends State<ExplorerPage> {
} }
} }
if ((failed.isNotEmpty || tooLong.isNotEmpty) && mounted) { if ((failed.isNotEmpty || tooLong.isNotEmpty) && mounted) {
final nukeLong = await showDialog<bool>( final failTitle = trf('{0}개 중 {1}개를 지우지 못했습니다', [paths.length, failed.length + tooLong.length]);
context: context, final failIcon = Icon(Icons.error_outline, color: JjColors.alert);
builder: (ctx) => AlertDialog( Widget failBody() => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
scrollable: true, // 144: 긴 경로는 이유 한 줄 + 이름만 (영구 삭제는 아래 버튼으로)
icon: Icon(Icons.error_outline, color: JjColors.alert), if (tooLong.isNotEmpty) ...[
title: Text(trf('{0}개 중 {1}개를 지우지 못했습니다', [paths.length, failed.length + tooLong.length])), Text(tr('경로가 너무 길어 휴지통에 넣을 수 없습니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다).'),
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ style: const TextStyle(fontWeight: FontWeight.w600)),
// 144: 긴 경로는 이유 한 줄 + 이름만 (영구 삭제는 아래 버튼으로) const SizedBox(height: 4),
if (tooLong.isNotEmpty) ...[ for (final x in tooLong.take(30)) Text('· ${vBasename(x)}', style: const TextStyle(fontSize: 12)),
Text(tr('경로가 너무 길어 휴지통에 넣을 수 없습니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다).'), if (tooLong.length > 30) Text(trf('… 외 {0}개', [tooLong.length - 30])),
style: const TextStyle(fontWeight: FontWeight.w600)), const SizedBox(height: 8),
const SizedBox(height: 4), ],
for (final x in tooLong.take(30)) Text('· ${vBasename(x)}', style: const TextStyle(fontSize: 12)), for (final (x, why) in failed.take(30)) ...[
if (tooLong.length > 30) Text(trf('… 외 {0}개', [tooLong.length - 30])), Text(vBasename(x), style: const TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 8), Text('${vDirname(x)} · $why', style: TextStyle(fontSize: 12, color: JjColors.textDim)),
], const SizedBox(height: 4),
for (final (x, why) in failed.take(30)) ...[ ],
Text(vBasename(x), style: const TextStyle(fontWeight: FontWeight.w600)), if (failed.length > 30) Text(trf('… 외 {0}개', [failed.length - 30])),
Text('${vDirname(x)} · $why', style: TextStyle(fontSize: 12, color: JjColors.textDim)), const SizedBox(height: 6),
const SizedBox(height: 4), // 이유를 이미 위에 적었으므로 짐작하는 말은 빼고, 실제로 지운 것이 있을 때만 "나머지는 지웠습니다"
], if (failed.length + tooLong.length < paths.length) Text(tr('나머지는 지웠습니다.'), style: const TextStyle(fontSize: 12)),
if (failed.length > 30) Text(trf('… 외 {0}개', [failed.length - 30])), ]);
const SizedBox(height: 6), // U7: 긴 경로를 영구 삭제할지 묻는 것은 되돌릴 수 없는 일 - Enter 의 첫 초점은 [그대로 두기]. 아니면 알림만
// 이유를 이미 위에 적었으므로 짐작하는 말은 빼고, 실제로 지운 것이 있을 때만 "나머지는 지웠습니다" final nukeLong = tooLong.isNotEmpty
if (failed.length + tooLong.length < paths.length) Text(tr('나머지는 지웠습니다.'), style: const TextStyle(fontSize: 12)), ? await confirmDialog(
]), context,
actions: [ icon: failIcon,
if (tooLong.isNotEmpty) ...[ title: () => failTitle,
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('그대로 두기'))), danger: () => true,
FilledButton( cancelLabel: tr('그대로 두기'),
style: FilledButton.styleFrom(backgroundColor: JjColors.alert), confirmLabel: () => trf('영구 삭제 ({0}개, 되돌릴 수 없음)', [tooLong.length]),
onPressed: () => Navigator.pop(ctx, true), body: (ctx, set) => failBody(),
child: Text(trf('영구 삭제 ({0}개, 되돌릴 수 없음)', [tooLong.length])), )
: await showDialog<bool>(
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: 사용자가 [영구 삭제] 를 고른 긴 경로만 지운다 // 144: 사용자가 [영구 삭제] 를 고른 긴 경로만 지운다
if (nukeLong == true) { if (nukeLong == true) {
final notDeleted = <String>[]; final notDeleted = <String>[];
@ -3160,15 +3164,13 @@ class _ExplorerPageState extends State<ExplorerPage> {
if (kept > 0) trf('{0}개는 지우지 않았습니다 (취소).', [kept]), if (kept > 0) trf('{0}개는 지우지 않았습니다 (취소).', [kept]),
]); ]);
// 148: 휴지통으로 보낸 것은 바로 [되돌리기] // 148: 휴지통으로 보낸 것은 바로 [되돌리기]
ScaffoldMessenger.of(context).showSnackBar(SnackBar( showNotice(ScaffoldMessenger.of(context),
duration: const Duration(seconds: 10), text: text,
content: Text(text), action: trashed.isNotEmpty
action: trashed.isNotEmpty ? (tr('되돌리기'), () => _undoTrash(trashed))
? SnackBarAction(label: tr('되돌리기'), onPressed: () => _undoTrash(trashed)) : sent.isEmpty
: sent.isEmpty ? null
? null : (tr('되돌리기'), () => _undoRecycle(sent, since)));
: SnackBarAction(label: tr('되돌리기'), onPressed: () => _undoRecycle(sent, since)),
));
} }
// X-plore 숨기기: 영구로 지운 것 (그 안 포함) 은 숨긴 목록에서 뺀다. 휴지통으로 보낸 것은 되돌리기 뒤에도 숨김이 되게 남긴다 // X-plore 숨기기: 영구로 지운 것 (그 안 포함) 은 숨긴 목록에서 뺀다. 휴지통으로 보낸 것은 되돌리기 뒤에도 숨김이 되게 남긴다
await _followHidden(gone: [ await _followHidden(gone: [
@ -3407,25 +3409,24 @@ class _ExplorerPageState extends State<ExplorerPage> {
skippedHere = here; skippedHere = here;
sources = [for (final x in sources) if (!here.contains(x)) x]; sources = [for (final x in sources) if (!here.contains(x)) x];
} else if (here.isNotEmpty && (move || here.length == sources.length)) { } else if (here.isNotEmpty && (move || here.length == sources.length)) {
final ok = await showDialog<bool>( if (move) {
context: context, // 알림만 ([확인] 에 첫 초점 - Enter 로 닫기)
builder: (ctx) => AlertDialog( await showDialog<void>(
scrollable: true, context: context,
title: Text(move ? tr('이미 이 폴더에 있습니다') : tr('같은 폴더입니다')), builder: (ctx) => AlertDialog(
content: Text(move scrollable: true,
? trf('{0} 은(는) 이미 이 폴더에 있어 옮길 것이 없습니다.\n{1}', [_namesOf(here), vDisplay(dest)]) title: Text(tr('이미 이 폴더에 있습니다')),
: trf('같은 폴더에 사본을 만들까요? (이름 (2))\n{0}', [_namesOf(here)])), content: Text(trf('{0} 은(는) 이미 이 폴더에 있어 옮길 것이 없습니다.\n{1}', [_namesOf(here), vDisplay(dest)])),
actions: [ actions: [FilledButton(autofocus: true, onPressed: () => Navigator.pop(ctx), child: Text(tr('확인')))],
if (move) ),
FilledButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('확인'))) );
else ...[ return false;
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), }
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('사본 만들기'))), final ok = await confirmDialog(context,
], title: () => tr('같은 폴더입니다'),
], confirmLabel: () => tr('사본 만들기'),
), body: (ctx, set) => Text(trf('같은 폴더에 사본을 만들까요? (이름 (2))\n{0}', [_namesOf(here)])));
); if (!ok || !mounted) return false;
if (ok != true || !mounted) return false;
sameFolderCopy = true; // 이미 물었으니 아래 확인 창은 건너뛴다 sameFolderCopy = true; // 이미 물었으니 아래 확인 창은 건너뛴다
} }
// 원본 · 대상이 없거나 (다른 곳에서 지움 - 목록을 새로 고침) 폴더를 자기 안으로 넣으려 하면 시작하지 않는다 // 원본 · 대상이 없거나 (다른 곳에서 지움 - 목록을 새로 고침) 폴더를 자기 안으로 넣으려 하면 시작하지 않는다
@ -3457,102 +3458,106 @@ class _ExplorerPageState extends State<ExplorerPage> {
_ => null, _ => null,
}; };
var conflict = always ?? NameConflict.rename; var conflict = always ?? NameConflict.rename;
final go = sameFolderCopy ? true : await showDialog<bool>( // U4 (점검 10/22, 관리자 결정): 다른 창의 표시는 그대로 두고 이번 대상이 아님을 알린다
context: context, final otherMarks = [for (final x in _panes) if (!identical(x, pane)) x.sel.marked.length].fold(0, (a, b) => a + b);
builder: (ctx) => StatefulBuilder( // U7: 공용 confirmDialog - 늘 덮어쓰기 설정이면 (되돌릴 수 없음) Enter 의 첫 초점은 [취소]
builder: (ctx, setInner) => AlertDialog( final go = sameFolderCopy
scrollable: true, ? true
title: Text(move ? tr('이동') : tr('복사')), : await confirmDialog(
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ context,
// 61: 문장 조각을 이어 붙이지 않는다 (언어마다 어순이 달라 "…로 {복사할까요?}" 가 깨졌다) title: () => move ? tr('이동') : tr('복사'),
Text('${move ? trf('{0}개 항목을 다음 폴더로 옮길까요?\n{1}', [sources.length, vDisplay(dest)]) : trf('{0}개 항목을 다음 폴더로 복사할까요?\n{1}', [sources.length, vDisplay(dest)])}' confirmLabel: () => move ? tr('이동') : tr('복사'),
'\n${_namesOf(sources)}' danger: () => conflict == NameConflict.overwrite,
'${skippedHere.isEmpty ? '' : '\n${trf('이미 이 폴더에 있어 건너뜀: {0}', [_namesOf(skippedHere)])}'}' body: (ctx, setInner) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
'\n\n${trf('방법: {0}', [tr(method.label)])}' // 61: 문장 조각을 이어 붙이지 않는다 (언어마다 어순이 달라 "…로 {복사할까요?}" 가 깨졌다)
'${method == CopyMethod.builtin ? '' : ' · ${withoutSourceRemovingOptions(task.options)}${move ? (method == CopyMethod.rsync ? ' --remove-source-files' : ' /MOVE') : ''}'}' Text('${move ? trf('{0}개 항목을 다음 폴더로 옮길까요?\n{1}', [sources.length, vDisplay(dest)]) : trf('{0}개 항목을 다음 폴더로 복사할까요?\n{1}', [sources.length, vDisplay(dest)])}'
'${task.bandwidthKBps > 0 ? ' · ${trf('속도 제한 {0} KB/s', [task.bandwidthKBps])}' : ''}'), '\n${_namesOf(sources)}'
// 178 보충: 환경 설정의 원본 지우기 옵션은 복사에서 뺀다 - 왜 빠졌는지 알림 '${skippedHere.isEmpty ? '' : '\n${trf('이미 이 폴더에 있어 건너뜀: {0}', [_namesOf(skippedHere)])}'}'
if (!move && method != CopyMethod.builtin && optionsRemoveSource(task.options)) '\n\n${trf('방법: {0}', [tr(method.label)])}'
Padding( '${method == CopyMethod.builtin ? '' : ' · ${withoutSourceRemovingOptions(task.options)}${move ? (method == CopyMethod.rsync ? ' --remove-source-files' : ' /MOVE') : ''}'}'
padding: const EdgeInsets.only(top: 6), '${task.bandwidthKBps > 0 ? ' · ${trf('속도 제한 {0} KB/s', [task.bandwidthKBps])}' : ''}'),
child: Text( // 178 보충: 환경 설정의 원본 지우기 옵션은 복사에서 뺀다 - 왜 빠졌는지 알림
trf('환경 설정의 {0} 는 복사에서는 쓰지 않습니다 (이동은 [이동] 으로)', if (!move && method != CopyMethod.builtin && optionsRemoveSource(task.options))
[splitOptions(task.options).where(isSourceRemovingOption).join(' ')]), Padding(
style: TextStyle(fontSize: 12, color: JjColors.textDim)), padding: const EdgeInsets.only(top: 6),
), child: Text(
FutureBuilder<List<NameClash>>( trf('환경 설정의 {0} 는 복사에서는 쓰지 않습니다 (이동은 [이동] 으로)',
future: clashesFuture, [splitOptions(task.options).where(isSourceRemovingOption).join(' ')]),
builder: (ctx, snap) { style: TextStyle(fontSize: 12, color: JjColors.textDim)),
if (snap.connectionState != ConnectionState.done) { ),
return Padding( FutureBuilder<List<NameClash>>(
padding: const EdgeInsets.only(top: 12), future: clashesFuture,
child: Text(tr('같은 이름이 있는지 확인하는 중 …'), style: TextStyle(fontSize: 12, color: JjColors.textDim)), builder: (ctx, snap) {
); if (snap.connectionState != ConnectionState.done) {
} return Padding(
final clashes = snap.data ?? const <NameClash>[]; padding: const EdgeInsets.only(top: 12),
if (clashes.isEmpty) return const SizedBox.shrink(); child: Text(tr('같은 이름이 있는지 확인하는 중 …'), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ );
const SizedBox(height: 12), }
Text(trf('같은 이름이 이미 있습니다: {0}', [_namesOf([for (final x in clashes) x.src])]), final clashes = snap.data ?? const <NameClash>[];
style: TextStyle(color: JjColors.warning)), if (clashes.isEmpty) return const SizedBox.shrink();
// 172: 두 파일의 크기 · 바뀐 날 (3개까지) return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
for (final x in clashes.take(3)) ...[ const SizedBox(height: 12),
Padding( Text(trf('같은 이름이 이미 있습니다: {0}', [_namesOf([for (final x in clashes) x.src])]),
padding: const EdgeInsets.only(top: 4, left: 8), style: TextStyle(color: JjColors.warning)),
child: Text(clashCompareText(x), style: const TextStyle(fontSize: 12)), // 172: 두 파일의 크기 · 바뀐 날 (3개까지)
), for (final x in clashes.take(3)) ...[
// 229: 폴더를 합치면 안쪽에서 받는 쪽이 더 새 파일 - 건너뛰는지 (환경 설정) 미리 Padding(
if (x.there.isDir && !isDav(x.src) && !isDav(dest)) padding: const EdgeInsets.only(top: 4, left: 8),
FutureBuilder<int>( child: Text(clashCompareText(x), style: const TextStyle(fontSize: 12)),
future: FileOps.countNewerInside(x.src, vJoin(dest, vBasename(x.src))), ),
builder: (_, n) => Padding( // 229: 폴더를 합치면 안쪽에서 받는 쪽이 더 새 파일 - 건너뛰는지 (환경 설정) 미리
padding: const EdgeInsets.only(left: 20), if (x.there.isDir && !isDav(x.src) && !isDav(dest))
child: Text( FutureBuilder<int>(
n.connectionState != ConnectionState.done future: FileOps.countNewerInside(x.src, vJoin(dest, vBasename(x.src))),
? tr('안쪽 파일을 확인하는 중 …') builder: (_, n) => Padding(
: (n.data ?? 0) == 0 padding: const EdgeInsets.only(left: 20),
? '' child: Text(
: c.settings.copySkipNewer n.connectionState != ConnectionState.done
? trf('안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)', [n.data]) ? tr('안쪽 파일을 확인하는 중 …')
: trf('안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다', [n.data]), : (n.data ?? 0) == 0
style: TextStyle(fontSize: 12, color: JjColors.warning), ? ''
), : 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<NameConflict>(
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<NameConflict>(dense: true, contentPadding: EdgeInsets.zero, value: v, title: Text(label)),
]),
), ),
]); ],
}, if (always != null)
), Text(
]), trf('환경 설정대로 {0} (환경 설정 > 파일 탐색기 > 같은 이름이 있을 때)', [
actions: [ switch (always) {
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), NameConflict.rename => tr('이름을 바꿉니다'),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(move ? tr('이동') : tr('복사'))), NameConflict.overwrite => tr('덮어씁니다'),
], NameConflict.skip => tr('건너뜁니다'),
}
]),
style: const TextStyle(fontSize: 12))
else
RadioGroup<NameConflict>(
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<NameConflict>(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; if (go != true || !mounted) return false;
// 104: 설정의 옵션에 지우기 (robocopy /MIR · /PURGE 등) 가 있으면 미리 보기를 거친다 // 104: 설정의 옵션에 지우기 (robocopy /MIR · /PURGE 등) 가 있으면 미리 보기를 거친다
@ -3597,17 +3602,17 @@ class _ExplorerPageState extends State<ExplorerPage> {
]); ]);
// 85: 끝나면 [폴더 열기] - 앱 탐색기에서 넣은 폴더로 (그 폴더를 보고 있는 창, 없으면 지금 창) // 85: 끝나면 [폴더 열기] - 앱 탐색기에서 넣은 폴더로 (그 폴더를 보고 있는 창, 없으면 지금 창)
if (error == null && mounted) { if (error == null && mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar( // U2 (점검 10/22): 끝 알림은 정해진 시간 뒤 닫힘 - 아래 표시 줄을 가리지 않게
content: Text(doneText), showNotice(ScaffoldMessenger.of(context),
action: SnackBarAction( text: doneText,
label: tr('폴더 열기'), action: (
onPressed: () { tr('폴더 열기'),
final show = _panes.where((x) => samePath(x.current, dest)).firstOrNull ?? _pane; () {
setState(() => _active = _panes.indexOf(show)); final show = _panes.where((x) => samePath(x.current, dest)).firstOrNull ?? _pane;
unawaited(_goTo(show, dest, fresh: true)); setState(() => _active = _panes.indexOf(show));
}, unawaited(_goTo(show, dest, fresh: true));
), }
)); ));
} else { } else {
_snack(doneText); _snack(doneText);
} }
@ -3998,11 +4003,12 @@ class _ExplorerPageState extends State<ExplorerPage> {
: job.move : job.move
? tr('옮기는 중') ? tr('옮기는 중')
: tr('복사하는 중'); : tr('복사하는 중');
final labelMax = MediaQuery.sizeOf(context).width * 0.45;
Widget line(String label, double value, String right) => Padding( Widget line(String label, double value, String right) => Padding(
padding: const EdgeInsets.only(top: 6), padding: const EdgeInsets.only(top: 6),
child: Row(children: [ child: Row(children: [
SizedBox( SizedBox(
width: 300, width: math.min(300, labelMax), // 좁은 화면 (폰 세로) 은 폭의 45% 까지
child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12.5)), child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12.5)),
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
@ -4998,17 +5004,13 @@ class _ExplorerPageState extends State<ExplorerPage> {
} }
if (back.isEmpty || !mounted) return; if (back.isEmpty || !mounted) return;
final moved = back.values.fold(0, (a, l) => a + l.length); final moved = back.values.fold(0, (a, l) => a + l.length);
ScaffoldMessenger.of(context) showNotice(ScaffoldMessenger.of(context)..hideCurrentSnackBar(),
..hideCurrentSnackBar() text: joinSentences([
..showSnackBar(SnackBar(
duration: const Duration(seconds: 10),
content: Text(joinSentences([
trf('{0}개를 옮겼습니다.', [moved]), trf('{0}개를 옮겼습니다.', [moved]),
if (notUndoable > 0) trf('합친 폴더 {0}개는 무엇을 옮겼는지 몰라 되돌리지 않습니다.', [notUndoable]), if (notUndoable > 0) trf('합친 폴더 {0}개는 무엇을 옮겼는지 몰라 되돌리지 않습니다.', [notUndoable]),
if (overwritten > 0) trf('같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.', [overwritten]), 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: 되돌리기 - 원래 폴더마다 지금의 이동으로 (같은 이름이 생겼으면 지금의 같은 이름 창) /// 82: 되돌리기 - 원래 폴더마다 지금의 이동으로 (같은 이름이 생겼으면 지금의 같은 이름 창)
@ -5040,63 +5042,66 @@ class _ExplorerPageState extends State<ExplorerPage> {
} }
} }
final focusedExt = pane.focused == null || byPath[pane.focused]?.isDir != false ? '' : extOf(pane.focused!); final focusedExt = pane.focused == null || byPath[pane.focused]?.isDir != false ? '' : extOf(pane.focused!);
final info = <Widget>[
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 = <Widget>[
PopupMenuButton<String>(
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( return Container(
color: JjColors.panel, color: JjColors.panel,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
child: Row(children: [ // 좁은 창 (폰 세로의 두 창 - 규칙 7, 점검 10/22) 은 두 줄로
Icon(Icons.checklist, size: 18, color: JjColors.accent), child: AdaptiveRow(lead: info, trail: actions, minWidth: 560),
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<String>(
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('선택 끝'))),
]),
); );
} }
@ -5880,11 +5885,13 @@ class _ExplorerPageState extends State<ExplorerPage> {
onSecondaryTapUp: menu ? (d) => _menu(pane, e, d.globalPosition) : null, onSecondaryTapUp: menu ? (d) => _menu(pane, e, d.globalPosition) : null,
onLongPressStart: menu ? (d) => _menu(pane, e, d.globalPosition) : null, onLongPressStart: menu ? (d) => _menu(pane, e, d.globalPosition) : null,
child: Material( child: Material(
// U5 (점검 10/22): 지금 줄 (한 번 누른 것 - 표시가 없을 때 [복사] 등의 대상) 은 진한 바탕 + 왼쪽 강조 막대
color: marked color: marked
? JjColors.accent.withValues(alpha: 0.18) ? JjColors.accent.withValues(alpha: 0.18)
: isFocused : isFocused
? JjColors.panel ? JjColors.accent.withValues(alpha: 0.09)
: Colors.transparent, : Colors.transparent,
shape: isFocused ? Border(left: BorderSide(color: JjColors.accent, width: 3)) : null,
child: Row(children: [ child: Row(children: [
Expanded( Expanded(
child: InkWell( child: InkWell(
@ -5993,31 +6000,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
} }
} }
/// 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 로 온다) 은 "—" /// 203: 바뀐 날을 모름 (잠긴 시스템 파일은 1970-01-01 로 온다) 은 "—"
String _date(DateTime d) => fileDateText(d); 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 { class _Toolbar extends StatelessWidget {

38
lib/ui/notice.dart Normal file
View File

@ -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<SnackBar, SnackBarClosedReason>? 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),
));
}

View File

@ -21,6 +21,7 @@ import 'package:jj_mkvmaker/ui/downloads_page.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'support/temp_dirs.dart'; import 'support/temp_dirs.dart';
import 'package:jj_mkvmaker/ui/notice.dart';
class _Backend implements DownloadBackend { class _Backend implements DownloadBackend {
@override @override
@ -563,13 +564,12 @@ void main() {
await tester.tap(find.text('실행 취소')); await tester.tap(find.text('실행 취소'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(bm.tree.findByUrl('https://www.youtube.com/watch?v=abc'), isNotNull); expect(bm.tree.findByUrl('https://www.youtube.com/watch?v=abc'), isNotNull);
// 실행 취소 알림은 누르지 않으면 몇 초 뒤 저절로 사라진다 (Flutter 3.47+ 는 action 이 있으면 기본이 안 사라짐) await tester.tap(find.byTooltip('더 보기').last); // 실행 취소 알림은 noticeDuration (E4 U2) 뒤 저절로 사라진다
await tester.tap(find.byTooltip('더 보기').last);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('삭제').last); await tester.tap(find.text('삭제').last);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('실행 취소'), findsOneWidget); expect(find.text('실행 취소'), findsOneWidget);
await tester.pump(const Duration(seconds: 5)); await tester.pump(noticeDuration + const Duration(seconds: 1));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('실행 취소'), findsNothing); expect(find.text('실행 취소'), findsNothing);
bm.undoRemove(); bm.undoRemove();

View File

@ -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<void> 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<FilledButton>(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<FilledButton>(find.byType(FilledButton)).autofocus, isTrue, reason: '첫 초점은 연 때 그대로 ([확인])');
await tester.tap(find.text('영구 삭제 단추'));
await tester.pumpAndSettle();
expect(got, isTrue);
});
}

View File

@ -27,6 +27,7 @@ import 'package:jj_mkvmaker/ui/path_label.dart';
import 'package:image/image.dart' as img; import 'package:image/image.dart' as img;
import 'package:jj_mkvmaker/ui/reader_page.dart' show ReaderPage; import 'package:jj_mkvmaker/ui/reader_page.dart' show ReaderPage;
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'support/explorer_clock_keys.dart';
import 'support/fake_recycle_bin.dart'; import 'support/fake_recycle_bin.dart';
import 'package:jj_mkvmaker/platform/windows/recycle_bin.dart' show recycleBinForTest; import 'package:jj_mkvmaker/platform/windows/recycle_bin.dart' show recycleBinForTest;
import 'package:jj_mkvmaker/ui/sync_preview_view.dart' show keepOptionsTogether; 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.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset); 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); await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty);
} }
@ -99,16 +100,19 @@ void main() {
expect(find.text('모니터링'), findsNothing); expect(find.text('모니터링'), findsNothing);
expect(find.text('좌 → 우'), findsNothing); expect(find.text('좌 → 우'), findsNothing);
// 폴더를 한 번 누르면 바로 펼친다. 189: 다시 눌러도 (두 번 누름 포함) 접지 않는다 - 접기 · 펼치기는 화살표로 // 폴더를 한 번 누르면 바로 펼친다. 규칙 3 (10/22): 조금 뒤 다시 누르면 접고, 또 누르면 펼친다
// (예전 189 "다시 눌러도 접지 않음" 은 규칙 3 으로 바뀜. 두 번 누르기의 두 번째는 접지 않음 - 규칙 3 시험)
await act(tester, () => tester.tap(find.text('sub'))); await act(tester, () => tester.tap(find.text('sub')));
await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty); await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty);
expect(find.text('inner.txt'), findsOneWidget); expect(find.text('inner.txt'), findsOneWidget);
testNow = testNow.add(const Duration(seconds: 1)); // 두 번 누르기 시간이 지난 뒤
await act(tester, () => tester.tap(find.text('sub'))); await act(tester, () => tester.tap(find.text('sub')));
await settle(tester, () => false, rounds: 15); await settle(tester, () => find.text('inner.txt').evaluate().isEmpty);
expect(find.text('inner.txt'), findsOneWidget, reason: '이름을 다시 눌러도 그대로'); expect(find.text('inner.txt'), findsNothing, reason: '규칙 3: 다시 누르면 접힘');
await doubleTap(tester, find.text('sub')); testNow = testNow.add(const Duration(seconds: 1));
await settle(tester, () => false, rounds: 15); await act(tester, () => tester.tap(find.text('sub')));
expect(find.text('inner.txt'), findsOneWidget, reason: '두 번 눌러도 그대로'); await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty);
expect(find.text('inner.txt'), findsOneWidget, reason: '또 누르면 펼침');
await act(tester, () => tester.tap(find.descendant( 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)))); 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); await settle(tester, () => find.text('inner.txt').evaluate().isEmpty);
@ -271,8 +275,13 @@ void main() {
await act(tester, () => tester.tap(find.text('sub'))); await act(tester, () => tester.tap(find.text('sub')));
await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty); await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty);
expect(find.text('inner.txt'), findsOneWidget); expect(find.text('inner.txt'), findsOneWidget);
// 조금 뒤 다시 누름 - 접힘 // 두 번 누르기 시간 안의 두 번째 누르기 - 접지 않음
await tester.runAsync(() => Future<void>.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 act(tester, () => tester.tap(find.text('sub')));
await settle(tester, () => find.text('inner.txt').evaluate().isEmpty); await settle(tester, () => find.text('inner.txt').evaluate().isEmpty);
expect(find.text('inner.txt'), findsNothing); expect(find.text('inner.txt'), findsNothing);
@ -1951,17 +1960,6 @@ Finder markOf(String name) => find.descendant(
); );
/// 누르기 등을 실제 비동기 구역에서 (그 때 시작한 파일 작업이 테스트의 가짜 시간에 묶이지 않게) /// 누르기 등을 실제 비동기 구역에서 (그 때 시작한 파일 작업이 테스트의 가짜 시간에 묶이지 않게)
/// 새로 고침 키: Windows 는 TC 키 Ctrl+R (규칙 1 - F5 는 복사), 그 밖은 F5
Future<void> 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<void> act(WidgetTester tester, Future<void> Function() f) async { Future<void> act(WidgetTester tester, Future<void> Function() f) async {
await tester.runAsync(f); await tester.runAsync(f);
await tester.pump(); await tester.pump();

View File

@ -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<void> open(WidgetTester tester) async {
final key = GlobalKey<ScaffoldMessengerState>();
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);
});
}

View File

@ -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<void> 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);
}