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: rc2split_pagese785ba9(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/5967b729(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.dart29b3c951· confirm_dialog.dartff964f5f) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
parent
9ba39e5ed6
commit
8335a3eac7
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1032,6 +1032,7 @@
|
|||
"다른 저장소로는 이름만 바꿀 수 없습니다": "別のストレージへは名前の変更だけではできません",
|
||||
"다른 창 폴더": "もう一方のウィンドウのフォルダー",
|
||||
"다른 창 폴더로": "もう一方の画面のフォルダーへ",
|
||||
"다른 창에 {0}개 표시됨 (이번 대상 아님)": "もう一方の画面に {0} 個のマーク (今回の対象外)",
|
||||
"다른 창에서 열기": "もう一方のペインで開く",
|
||||
"다른 창으로 끌어 놓았을 때. 어느 쪽이든 복사 · 이동 확인 창 (같은 이름 등) 은 그대로 뜹니다": "もう一方のウィンドウにドロップしたとき。どちらでもコピー・移動の確認 (同じ名前など) は表示されます",
|
||||
"다른 창으로 복사": "もう一方のペインへコピー",
|
||||
|
|
|
|||
|
|
@ -1032,6 +1032,7 @@
|
|||
"다른 저장소로는 이름만 바꿀 수 없습니다": "不能仅通过重命名移到其他存储",
|
||||
"다른 창 폴더": "另一窗格的文件夹",
|
||||
"다른 창 폴더로": "到另一窗格的文件夹",
|
||||
"다른 창에 {0}개 표시됨 (이번 대상 아님)": "另一窗格中已标记 {0} 个 (不在本次操作内)",
|
||||
"다른 창에서 열기": "在另一窗格中打开",
|
||||
"다른 창으로 끌어 놓았을 때. 어느 쪽이든 복사 · 이동 확인 창 (같은 이름 등) 은 그대로 뜹니다": "拖放到另一个窗格时。无论哪种,都会显示复制/移动确认 (同名等)",
|
||||
"다른 창으로 복사": "复制到另一窗格",
|
||||
|
|
|
|||
|
|
@ -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')}';
|
||||
|
|
@ -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')),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
},
|
||||
),
|
||||
));
|
||||
}
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: 원어를 골라 다시 만들 때의 언어 고르기 (원어를 처음부터 정하는 곳도 알린다)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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] 에 추가만).
|
||||
|
|
|
|||
|
|
@ -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) ||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
));
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in New Issue