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",
"다른 창 폴더": "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",

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

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 '../core/file_ops.dart' show samePath;
import '../l10n/tr.dart';
import 'notice.dart';
/// "완료 시 자동 동영상 추가" 로 다 받은 동영상을 MKV 목록에 넣고 다운로드 목록에서 뺐을 때의 알림.
/// 줄이 갑자기 사라져 "다운로드 (0)" 이 된 이유를 알 수 있게 [보기] (MKV 화면 · 그 동영상) 를 함께 (관리자 판단, 931)
void showAddedToMkvNotice(GlobalKey<ScaffoldMessengerState> messenger, GlobalKey<NavigatorState> navigator,
AppController c, {required String title, required List<String> 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);
},
}
),
));
);
}

View File

@ -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<void> 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<void>.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: 원어를 골라 다시 만들 때의 언어 고르기 (원어를 처음부터 정하는 곳도 알린다)

View File

@ -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<void> 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

View File

@ -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<String?> 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] 에 추가만).

View File

@ -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<BrowserPage> 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) ||

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/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<ExplorerPage> {
// 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<ExplorerPage> {
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<ExplorerPage> {
..forget(e.path)
..request(e.path);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(isDav(e.path)
showNotice(ScaffoldMessenger.of(context),
text: isDav(e.path)
? trf('"{0}" 크기를 계산합니다. 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다.', [e.name])
: trf('"{0}" 크기를 계산합니다.', [e.name])),
action: SnackBarAction(label: tr('취소'), onPressed: () => _sizes.cancel(e.path)),
));
: trf('"{0}" 크기를 계산합니다.', [e.name]),
action: (tr('취소'), () => _sizes.cancel(e.path)));
}
case 'rename':
if (_isMarked(pane, e.path) && pane.sel.marked.length > 1) {
@ -2900,20 +2910,18 @@ class _ExplorerPageState extends State<ExplorerPage> {
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 {
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,14 +2964,15 @@ class _ExplorerPageState extends State<ExplorerPage> {
for (final h in c.settings.explorerHiddenItems)
if (paths.any((x) => !samePath(h, x) && isSameOrInside(h, x))) h
].length;
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, set) => AlertDialog(
scrollable: true,
title: 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개 이름 · 폴더)
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
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)}'
@ -3007,19 +3016,16 @@ class _ExplorerPageState extends State<ExplorerPage> {
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)),
),
]),
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('휴지통으로')),
),
],
),
),
);
if (ok != true) return;
if (!ok) return;
// 하나씩: 실패해도 나머지는 계속. 실제로 어떻게 됐는지 세어 그대로 알린다 (94)
final failed = <(String, String)>[];
// 144: 경로가 너무 길어 휴지통이 받지 않은 것 (이유는 한 번, [영구 삭제] 로 바로 지울 수 있게)
@ -3066,28 +3072,21 @@ class _ExplorerPageState extends State<ExplorerPage> {
}
// X-plore 비교 e: 휴지통으로 옮기지 못한 것 (다른 저장 장치 · 권한 등) - 몰래 지우지 않고 영구 삭제할지 묻는다
if (notTrashed.isNotEmpty && mounted) {
final nuke = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
// U7: 되돌릴 수 없는 일 - Enter 의 첫 초점은 [그대로 두기]
final nuke = await confirmDialog(
context,
icon: Icon(Icons.warning_amber_rounded, color: JjColors.warning),
title: Text(trf('{0}개를 휴지통으로 옮기지 못했습니다', [notTrashed.length])),
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
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('영구 삭제하면 되살릴 수 없습니다.')),
]),
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])),
),
],
),
);
if (nuke == true) {
if (nuke) {
for (final (x, _) in notTrashed) {
try {
await FileOps().delete([x]);
@ -3098,13 +3097,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
}
}
if ((failed.isNotEmpty || tooLong.isNotEmpty) && mounted) {
final nukeLong = await showDialog<bool>(
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: [
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자 넘는 경로를 받지 않습니다).'),
@ -3123,20 +3118,29 @@ class _ExplorerPageState extends State<ExplorerPage> {
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])),
]);
// 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<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: 사용자가 [영구 삭제] 를 고른 긴 경로만 지운다
if (nukeLong == true) {
final notDeleted = <String>[];
@ -3160,15 +3164,13 @@ class _ExplorerPageState extends State<ExplorerPage> {
if (kept > 0) trf('{0}개는 지우지 않았습니다 (취소).', [kept]),
]);
// 148: 휴지통으로 보낸 것은 바로 [되돌리기]
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
duration: const Duration(seconds: 10),
content: Text(text),
showNotice(ScaffoldMessenger.of(context),
text: text,
action: trashed.isNotEmpty
? SnackBarAction(label: tr('되돌리기'), onPressed: () => _undoTrash(trashed))
? (tr('되돌리기'), () => _undoTrash(trashed))
: sent.isEmpty
? null
: SnackBarAction(label: tr('되돌리기'), onPressed: () => _undoRecycle(sent, since)),
));
: (tr('되돌리기'), () => _undoRecycle(sent, since)));
}
// X-plore 숨기기: 영구로 지운 것 (그 안 포함) 은 숨긴 목록에서 뺀다. 휴지통으로 보낸 것은 되돌리기 뒤에도 숨김이 되게 남긴다
await _followHidden(gone: [
@ -3407,25 +3409,24 @@ class _ExplorerPageState extends State<ExplorerPage> {
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<bool>(
if (move) {
// 알림만 ([확인] 에 첫 초점 - Enter 로 닫기)
await showDialog<void>(
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('사본 만들기'))),
],
],
title: Text(tr('이미 이 폴더에 있습니다')),
content: Text(trf('{0} 은(는) 이미 이 폴더에 있어 옮길 것이 없습니다.\n{1}', [_namesOf(here), vDisplay(dest)])),
actions: [FilledButton(autofocus: true, onPressed: () => Navigator.pop(ctx), child: Text(tr('확인')))],
),
);
if (ok != true || !mounted) return false;
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,13 +3458,17 @@ class _ExplorerPageState extends State<ExplorerPage> {
_ => null,
};
var conflict = always ?? NameConflict.rename;
final go = sameFolderCopy ? true : await showDialog<bool>(
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: [
// 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)}'
@ -3546,13 +3551,13 @@ class _ExplorerPageState extends State<ExplorerPage> {
]);
},
),
if (otherMarks > 0)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(trf('다른 창에 {0}개 표시됨 (이번 대상 아님)', [otherMarks]),
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
),
]),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(move ? tr('이동') : tr('복사'))),
],
),
),
);
if (go != true || !mounted) return false;
// 104: 설정의 옵션에 지우기 (robocopy /MIR · /PURGE 등) 가 있으면 미리 보기를 거친다
@ -3597,16 +3602,16 @@ class _ExplorerPageState extends State<ExplorerPage> {
]);
// 85: 끝나면 [폴더 열기] - 앱 탐색기에서 넣은 폴더로 (그 폴더를 보고 있는 창, 없으면 지금 창)
if (error == null && mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(doneText),
action: SnackBarAction(
label: tr('폴더 열기'),
onPressed: () {
// 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<ExplorerPage> {
: 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<ExplorerPage> {
}
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,10 +5042,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
}
}
final focusedExt = pane.focused == null || byPath[pane.focused]?.isDir != false ? '' : extOf(pane.focused!);
return Container(
color: JjColors.panel,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
child: Row(children: [
final info = <Widget>[
Icon(Icons.checklist, size: 18, color: JjColors.accent),
const SizedBox(width: 6),
Flexible(
@ -5057,7 +5056,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12)),
),
const Spacer(),
];
final actions = <Widget>[
PopupMenuButton<String>(
tooltip: tr('고르기 도구'),
onSelected: (v) {
@ -5096,7 +5096,12 @@ class _ExplorerPageState extends State<ExplorerPage> {
),
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),
// 좁은 창 (폰 세로의 두 창 - 규칙 7, 점검 10/22) 은 두 줄로
child: AdaptiveRow(lead: info, trail: actions, minWidth: 560),
);
}
@ -5880,11 +5885,13 @@ class _ExplorerPageState extends State<ExplorerPage> {
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<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 로 온다) 은 "—"
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 {

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 '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();

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: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<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 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<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 {
await tester.runAsync(f);
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);
}