diff --git a/integration_test/windows_b3_shot_test.dart b/integration_test/windows_b3_shot_test.dart index cb3382c..080a73a 100644 --- a/integration_test/windows_b3_shot_test.dart +++ b/integration_test/windows_b3_shot_test.dart @@ -7,13 +7,13 @@ import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:jj_mkvmaker/app/app_controller.dart'; -import 'package:jj_mkvmaker/services/platform_services.dart'; import 'package:jj_mkvmaker/ui/explorer_page.dart'; import 'package:jj_mkvmaker/ui/theme.dart'; import 'package:path/path.dart' as p; import '../test/support/fake_recycle_bin.dart'; import '../test/support/recycle_leftovers.dart'; +import 'support/shot_services.dart'; import 'support/temp_dir.dart'; import '../test/support/test_explorer_device.dart'; @@ -25,10 +25,8 @@ void main() { setUp(() => device = TestExplorerDevice()); IntegrationTestWidgetsFlutterBinding.ensureInitialized(); final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_b3'); - Directory tempDir(String prefix) { - final base = Platform.environment['JJ_SHOT_TMP']; - return testTempDir(prefix, base: base); - } + // 관리자 지시 (10/24): 실행마다 다른 값 (폴더 이름 · 시각 · CPU · 메모리 · 디스크) 을 정한 값으로 (캡처가 흔들리지 않게 - 시험 쪽에서만) + Directory tempDir(String name) => testFixedTempDir(name, base: Platform.environment['JJ_SHOT_TMP']); final key = GlobalKey(); @@ -69,7 +67,7 @@ void main() { late String left, right; late AppController c; setUp(() { - tmp = tempDir('jj_shot_b3_'); + tmp = tempDir('jj_shot_b3_fixed'); left = p.join(tmp.path, 'left'); right = p.join(tmp.path, 'right'); File(p.join(left, 'doc.txt')) @@ -78,7 +76,9 @@ void main() { File(p.join(right, 'doc.txt')) ..createSync(recursive: true) ..writeAsStringSync('old'); - c = AppController(PlatformServices.create()); + pinTestTimes(tmp); + c = AppController(shotServices()); + device.space = (_) => (107374182400, 536870912000); c.settings.explorer.paths = [left, right]; c.settings.explorer.style = 'xplore'; // 이 캡처는 X-plore 모양 (E6 부터 Windows 처음 값은 Total Commander - 이름 · 확장자가 다른 칸) // 휴지통: 기본은 가짜 (화면 흐름만), JJ_TEST_REAL_RECYCLE=1 이면 실제 휴지통 @@ -150,6 +150,7 @@ void main() { testWidgets('52: 복사 중에 다른 화면에 갔다 와도 진행 막대', (t) async { // 다른 화면에 갔다 와도 아직 복사 중이게 (약 25초) File(p.join(left, 'big.bin')).writeAsBytesSync(List.filled(2 * 1024 * 1024, 7)); + pinTestTimes(tmp); // 오른쪽에 생기는 받는 중 파일 (.jjpart) 의 시각은 실제 복사가 만든 때라 그대로 둔다 c.settings.copy.bandwidthKBps = 80; await show(t, ExplorerPage(device: device, c: c)); await selectAndPress(t, 'big.bin', '복사'); diff --git a/integration_test/windows_lsync_p0_shot_test.dart b/integration_test/windows_lsync_p0_shot_test.dart index a0fea9b..a76f88d 100644 --- a/integration_test/windows_lsync_p0_shot_test.dart +++ b/integration_test/windows_lsync_p0_shot_test.dart @@ -37,7 +37,8 @@ void main() { addTearDown(t.view.reset); await t.runAsync(() => i18n.apply(lang, save: false)); addTearDown(() => i18n.apply('ko', save: false)); - final tmp = testTempDir('jj_lsyncp0_shot_'); + // 관리자 지시 (10/24): 실행마다 다른 값 (폴더 이름 · 시각 · CPU · 메모리 · 디스크) 을 정한 값으로 (캡처가 흔들리지 않게 - 시험 쪽에서만) + final tmp = testFixedTempDir('jj_lsyncp0_shot_fixed'); final src = p.join(tmp.path, 's'), dst = p.join(tmp.path, 'd'); for (final (dir, rel) in [(src, 'a.txt'), (src, 'b.txt'), (dst, 'a.txt'), (dst, 'old.log'), (dst, 'extra.txt')]) { File(p.join(dir, rel)) @@ -45,13 +46,14 @@ void main() { ..writeAsStringSync('$dir/$rel'); } File(p.join(tmp.path, 'rules.txt')).writeAsStringSync('H a.txt\n'); - final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + final c = AppController( + PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), diskSpace: (_) async => (107374182400, 536870912000))); c.settings ..copy.rsyncSource = 'custom' ..copy.rsyncPath = rsync ..copy.rsyncOptions = '-a --filter="merge ${toCygwinPath(p.join(tmp.path, 'rules.txt'))}"' ..copy.liveSyncPairs = [LiveSyncPair(src, dst, method: 'rsync', delete: true, deleteConfirmed: true)]; - final live = LiveSync(c)..start(); + final live = LiveSync(c, now: () => DateTime(2026, 10, 22, 9))..start(); addTearDown(live.dispose); final pair = c.settings.copy.liveSyncPairs.single; await t.runAsync(() => live.syncNow(pair)); diff --git a/lib/app/live_sync.dart b/lib/app/live_sync.dart index 4b48a90..e3876c0 100644 --- a/lib/app/live_sync.dart +++ b/lib/app/live_sync.dart @@ -26,7 +26,10 @@ export 'sync/sync_commands.dart' show bundledRsync, findBundledRsync, rsyncExecu /// 앱이 켜져 있는 동안만 동작하고, 시작할 때 한 번 맞춘다. class LiveSync extends ChangeNotifier { final AppController c; - LiveSync(this.c); + + /// 지금 시각 (처음 값 = 실제 시계). 카드의 "마지막: HH:MM" 에 남기는 때 - 캡처 시험이 정한 시각을 준다 + final DateTime Function() now; + LiveSync(this.c, {DateTime Function()? now}) : now = now ?? DateTime.now; static LiveSync? instance; @@ -271,12 +274,12 @@ class LiveSync extends ChangeNotifier { for (final x in pairs) { final k = keyOf(x); if (!isDav(x.source) && !Directory(x.source).existsSync()) { - status[k] = (DateTime.now(), tr('원본 폴더가 없습니다')); + status[k] = (now(), tr('원본 폴더가 없습니다')); continue; } // 대상이 원본 안이면 맞출 때마다 원본이 바뀌어 끝없이 돈다. 34: 원본이 대상 안 (안쪽 → 바깥) 은 지우기가 없을 때만 if (nestingProblem(x, allowInnerToOuter: s.general.allowInnerToOuter) case final nest?) { - status[k] = (DateTime.now(), nest); + status[k] = (now(), nest); continue; } if (Platform.isWindows && !isDav(x.source)) { @@ -333,7 +336,7 @@ class LiveSync extends ChangeNotifier { ? optionProblemMessage(bad) : nestingProblem(x, allowInnerToOuter: c.settings.general.allowInnerToOuter); if (nest != null) { - status[k] = (DateTime.now(), nest); + status[k] = (now(), nest); notifyListeners(); return; } @@ -400,10 +403,10 @@ class LiveSync extends ChangeNotifier { CopyMethod.robocopy => await _robocopy(x, s), }; status[k] = toDelete[k] != null - ? (DateTime.now(), trf('지우지 않고 맞춤 · 대상에만 있는 {0}개를 지울지 확인 필요', [toDelete[k]!.length])) + ? (now(), trf('지우지 않고 맞춤 · 대상에만 있는 {0}개를 지울지 확인 필요', [toDelete[k]!.length])) : ops.dirConflicts.isNotEmpty - ? (DateTime.now(), trf('{0}개 맞춤 · 같은 이름의 폴더가 있어 건너뜀 {1}개', [n < 0 ? 0 : n, ops.dirConflicts.length])) - : (DateTime.now(), n < 0 ? tr('맞춤') : trf('{0}개 맞춤', [n])); + ? (now(), trf('{0}개 맞춤 · 같은 이름의 폴더가 있어 건너뜀 {1}개', [n < 0 ? 0 : n, ops.dirConflicts.length])) + : (now(), n < 0 ? tr('맞춤') : trf('{0}개 맞춤', [n])); problems.remove(k); emptyDeletes.remove(k); pending[k] = await SyncDiff.of(_pendingPair(asked), filter: _filterOf(asked)); @@ -413,7 +416,7 @@ class LiveSync extends ChangeNotifier { // 68 · 70: 대상은 건드리지 않고 멈춤. 카드에 빨갛게 · 작업 알림에 · 로그에 problems[k] = e; status[k] = ( - DateTime.now(), + now(), e.locked ? tr('마스터 비밀번호가 필요해 기다리는 중') : e.empty @@ -430,7 +433,7 @@ class LiveSync extends ChangeNotifier { c.note(trf('실시간 동기화 멈춤: {0} → {1}: {2}', [x.source, x.target, e])); } catch (e) { // F18: 화면에는 화면 언어의 짧은 글 (예외 이름 · 영어 파일 오류 없이), 작업 기록에는 원문 그대로 (아래) - status[k] = (DateTime.now(), trf('실패: {0}', [userErrorText(e, sources: [x.source])])); + status[k] = (now(), trf('실패: {0}', [userErrorText(e, sources: [x.source])])); c.note(trf('실시간 동기화 실패: {0} → {1}: {2}', [x.source, x.target, e])); } finally { _running.remove(k); diff --git a/lib/ui/ai_settings.dart b/lib/ui/ai_settings.dart index 7160ee2..b84de0f 100644 --- a/lib/ui/ai_settings.dart +++ b/lib/ui/ai_settings.dart @@ -12,7 +12,7 @@ import '../core/secret_gate.dart'; import '../l10n/tr.dart'; import '../services/image_ai.dart'; import 'ai_image_page.dart' show aiSaveDir; -import 'confirm.dart'; +import 'confirm_dialog.dart'; import 'folder_picker.dart'; import 'license_texts.dart' show showLicenseDocument; import 'setting_tile.dart'; diff --git a/lib/ui/ai_subtitle_models.dart b/lib/ui/ai_subtitle_models.dart index f218b5f..3a31d45 100644 --- a/lib/ui/ai_subtitle_models.dart +++ b/lib/ui/ai_subtitle_models.dart @@ -4,7 +4,7 @@ import '../app/ai_local.dart' show AiStore; import '../app/app_controller.dart'; import '../l10n/tr.dart'; import '../services/model_store.dart'; -import 'confirm.dart'; +import 'confirm_dialog.dart'; import 'setting_tile.dart'; import 'theme.dart'; diff --git a/lib/ui/ai_upscale_ui.dart b/lib/ui/ai_upscale_ui.dart index 9f97b35..002383c 100644 --- a/lib/ui/ai_upscale_ui.dart +++ b/lib/ui/ai_upscale_ui.dart @@ -15,7 +15,7 @@ import '../core/reader_sources.dart'; import '../core/sd_cli.dart' show ImageGenRequest; import '../l10n/tr.dart'; import '../services/image_ai.dart' show ImageAiException; -import 'confirm.dart'; +import 'confirm_dialog.dart'; import 'explorer_page.dart' show ExplorerPage; import 'theme.dart'; import 'notice.dart'; diff --git a/lib/ui/browser/browser_actions.dart b/lib/ui/browser/browser_actions.dart index 7c95073..eff2e28 100644 --- a/lib/ui/browser/browser_actions.dart +++ b/lib/ui/browser/browser_actions.dart @@ -6,7 +6,7 @@ import '../../app/app_controller.dart'; import '../../app/download_manager.dart'; import '../../l10n/tr.dart'; import '../../services/downloader.dart' show DownloadState; -import '../confirm.dart'; +import '../confirm_dialog.dart'; import '../downloads_page.dart'; import '../notice.dart'; import '../settings_page.dart'; diff --git a/lib/ui/confirm.dart b/lib/ui/confirm.dart deleted file mode 100644 index 4de556d..0000000 --- a/lib/ui/confirm.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../l10n/tr.dart'; -import 'theme.dart'; - -/// 되돌리기 어려운 일 전에 한 번 묻는다 (46 · 47 등). [danger] 면 확인 버튼이 빨강. 확인하면 true. -/// [cancel]: 하지 않는 쪽 버튼 이름 (처음 값 "취소" - 확인 버튼이 "… 취소" 면 헷갈리므로 바꾼다, 185) -Future confirmAction( - BuildContext context, { - required String title, - required String body, - required String ok, - bool danger = true, - String? cancel, -}) async { - final r = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - scrollable: true, - title: Text(title), - content: Text(body), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(cancel ?? tr('취소'))), - FilledButton( - style: danger ? FilledButton.styleFrom(backgroundColor: JjColors.danger) : null, - onPressed: () => Navigator.pop(ctx, true), - child: Text(ok), - ), - ], - ), - ); - return r == true; -} diff --git a/lib/ui/confirm_dialog.dart b/lib/ui/confirm_dialog.dart index ff964f5..1c95461 100644 --- a/lib/ui/confirm_dialog.dart +++ b/lib/ui/confirm_dialog.dart @@ -9,8 +9,11 @@ import 'theme.dart'; /// - 돌려주는 값: [확인] 을 누른 때만 true. [취소] · Esc · 바깥 누르기 · 뒤로는 false. /// - Enter = 첫 초점 단추. 되돌릴 수 없는 일 ([danger] 가 창을 열 때 true) 은 첫 초점이 [취소] (실수 막기), /// 그 밖은 [확인] (관리자 결정 10/22). +/// - [cancelFirst] 를 주면 첫 초점을 따로 정한다 (빨강이 아니어도 되돌리기 어려운 일 - 루트 · 시스템 폴더 · 보관함 비밀번호 바꾸기는 true). +/// - [canConfirm] 이 false 인 동안 [확인] 은 꺼져 있다 (체크해야 켜짐 · 두 비밀번호가 같아야 켜짐 - 창 안에서 [set] 으로 다시 봄). /// - [danger] 가 창 안에서 바뀌면 (예: "영구 삭제" 체크) [확인] 단추만 빨강으로 바뀐다. 첫 초점은 연 때 그대로. /// - [title] · [body] · [confirmLabel] 은 창 안에서 [set] 으로 다시 그려진다 (체크 상자 등). +/// - 빨강은 테마의 위험 색 (JjColors.danger - 다른 지우기 단추와 같음). 글만 묻는 곳은 [confirmAction] (같은 창). Future confirmDialog( BuildContext context, { required String Function() title, @@ -18,9 +21,11 @@ Future confirmDialog( required String Function() confirmLabel, String? cancelLabel, bool Function()? danger, + bool? cancelFirst, + bool Function()? canConfirm, Widget? icon, }) async { - final dangerAtOpen = danger?.call() ?? false; + final cancelAtOpen = cancelFirst ?? danger?.call() ?? false; final ok = await showDialog( context: context, builder: (ctx) => StatefulBuilder( @@ -33,14 +38,14 @@ Future confirmDialog( content: body(ctx, set), actions: [ TextButton( - autofocus: dangerAtOpen, + autofocus: cancelAtOpen, 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), + autofocus: !cancelAtOpen, + style: red ? FilledButton.styleFrom(backgroundColor: JjColors.danger) : null, + onPressed: canConfirm?.call() ?? true ? () => Navigator.pop(ctx, true) : null, child: Text(confirmLabel()), ), ], @@ -50,3 +55,23 @@ Future confirmDialog( ); return ok == true; } + +/// 글만으로 묻는 확인 창 ([confirmDialog] 와 같은 창 - W DangerDialog 로 하나로 모음, 예전 confirm.dart). +/// 되돌리기 어려운 일 전에 한 번 묻는다 (46 · 47 등). [danger] 면 [확인] 이 빨강 · 첫 초점은 [취소]. 확인하면 true. +/// [cancel]: 하지 않는 쪽 단추 이름 (처음 값 "취소" - 확인 단추가 "… 취소" 면 헷갈리므로 바꾼다, 185) +Future confirmAction( + BuildContext context, { + required String title, + required String body, + required String ok, + bool danger = true, + String? cancel, +}) => + confirmDialog( + context, + title: () => title, + body: (_, _) => Text(body), + confirmLabel: () => ok, + cancelLabel: cancel, + danger: () => danger, + ); diff --git a/lib/ui/copy_sync_settings.dart b/lib/ui/copy_sync_settings.dart index 1b4e7ca..9d3abdc 100644 --- a/lib/ui/copy_sync_settings.dart +++ b/lib/ui/copy_sync_settings.dart @@ -12,7 +12,7 @@ import '../core/sync_tools.dart'; import '../core/vfs.dart' show vDisplay; import '../l10n/tr.dart'; import '../platform/windows/rsync_installer.dart'; -import 'confirm.dart'; +import 'confirm_dialog.dart'; import 'folder_picker.dart'; import 'path_label.dart'; import 'rsync_setup.dart'; diff --git a/lib/ui/downloads_page.dart b/lib/ui/downloads_page.dart index 2da79b1..564beb9 100644 --- a/lib/ui/downloads_page.dart +++ b/lib/ui/downloads_page.dart @@ -6,7 +6,7 @@ import '../app/download_manager.dart'; import '../core/download_detect.dart'; import '../services/downloader.dart'; import 'app_actions.dart'; -import 'confirm.dart'; +import 'confirm_dialog.dart'; import 'theme.dart'; import '../l10n/tr.dart'; import 'jj_header_row.dart'; diff --git a/lib/ui/explorer/access_flow.dart b/lib/ui/explorer/access_flow.dart index a6b71c9..8475ac3 100644 --- a/lib/ui/explorer/access_flow.dart +++ b/lib/ui/explorer/access_flow.dart @@ -9,6 +9,7 @@ import '../../platform/android/android_storage.dart'; import '../theme.dart'; import 'explorer_controller.dart'; import 'explorer_pane.dart'; +import '../confirm_dialog.dart'; /// 약속 (E5d-6): 저장 장치에 닿는 일 - Android "모든 파일에 대한 접근" 권한 ([allFiles] · 듀얼 앱, 앱으로 돌아오면 다시 확인), /// 시스템 폴더 (Shizuku) · 루트 (su) 탭 (이번 실행에서 처음이면 경고 → 켜져 있고 허락받았을 때만 연다, 읽기 전용), @@ -84,21 +85,17 @@ class ExplorerAccess extends ChangeNotifier { /// X-plore 비교 5-2: 루트 탭 - 이번 실행에서 처음이면 경고, su 가 있고 허락을 받았을 때만 "/" 로 (관리자 3a 결정) Future openRoot(BuildContext context, ExplorerPane pane) async { if (!rootWarned) { - final ok = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - scrollable: true, - icon: Icon(Icons.security, color: JjColors.warning), - title: Text(tr('루트 폴더 보기 (읽기 전용)')), - content: Text(tr('루트 (su) 권한으로 시스템 · 다른 앱의 폴더 전체를 봅니다. 다른 앱의 개인 자료 (대화 · 로그인 정보 · 비밀번호 등) 도 ' - '보일 수 있습니다. 이 앱은 읽기만 하고 (쓰기 · 지우기 · 이름 바꾸기 없음), 꺼낸 파일은 고른 곳에만 둡니다.')), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), - FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('알고 봅니다'))), - ], - ), + // 공용 확인 창: 첫 초점 [취소] (Enter 로 바로 열리지 않음) + final ok = await confirmDialog( + context, + icon: Icon(Icons.security, color: JjColors.warning), + title: () => tr('루트 폴더 보기 (읽기 전용)'), + body: (_, _) => Text(tr('루트 (su) 권한으로 시스템 · 다른 앱의 폴더 전체를 봅니다. 다른 앱의 개인 자료 (대화 · 로그인 정보 · 비밀번호 등) 도 ' + '보일 수 있습니다. 이 앱은 읽기만 하고 (쓰기 · 지우기 · 이름 바꾸기 없음), 꺼낸 파일은 고른 곳에만 둡니다.')), + confirmLabel: () => tr('알고 봅니다'), + cancelFirst: true, ); - if (ok != true || !context.mounted) return; + if (!ok || !context.mounted) return; rootWarned = true; } try { diff --git a/lib/ui/ftp_settings.dart b/lib/ui/ftp_settings.dart index 1a92ec3..f92e27e 100644 --- a/lib/ui/ftp_settings.dart +++ b/lib/ui/ftp_settings.dart @@ -5,7 +5,7 @@ import '../core/ftp.dart'; import '../core/secret_gate.dart'; import '../core/webdav.dart' show DavLockedException; import '../l10n/tr.dart'; -import 'confirm.dart'; +import 'confirm_dialog.dart'; import 'setting_tile.dart'; import 'theme.dart'; import 'webdav_settings.dart' show davServerUsers; diff --git a/lib/ui/home/move_to_target.dart b/lib/ui/home/move_to_target.dart index a13fffe..eebecf0 100644 --- a/lib/ui/home/move_to_target.dart +++ b/lib/ui/home/move_to_target.dart @@ -10,7 +10,7 @@ import '../../app/app_controller.dart'; import '../../core/models.dart'; import '../../app/settings.dart' show MoveTarget; import '../folder_picker.dart'; -import '../confirm.dart'; +import '../confirm_dialog.dart'; import '../../l10n/tr.dart'; /// 동영상을 이동 버튼의 폴더로 옮긴다 (세부 정보의 이동 버튼 · 세 번 누르기). [target] 이 없으면 첫 번째 버튼, diff --git a/lib/ui/home/video_list_view.dart b/lib/ui/home/video_list_view.dart index a817a64..a60fd93 100644 --- a/lib/ui/home/video_list_view.dart +++ b/lib/ui/home/video_list_view.dart @@ -11,7 +11,7 @@ import '../../core/models.dart'; import '../ai_dialog.dart'; import '../player_page.dart'; import '../subtitle_search_dialog.dart'; -import '../confirm.dart'; +import '../confirm_dialog.dart'; import '../theme.dart'; import '../../l10n/tr.dart'; import 'move_to_target.dart'; diff --git a/lib/ui/master_prompt.dart b/lib/ui/master_prompt.dart index 53d6743..d12fb54 100644 --- a/lib/ui/master_prompt.dart +++ b/lib/ui/master_prompt.dart @@ -8,7 +8,7 @@ import '../app/settings.dart'; import '../core/secret_gate.dart'; import '../services/secret_store.dart'; import '../l10n/tr.dart'; -import 'confirm.dart'; +import 'confirm_dialog.dart'; import 'theme.dart'; /// 124: 마스터 비밀번호 입력 창. diff --git a/lib/ui/security_settings.dart b/lib/ui/security_settings.dart index 051b6a2..e6e5b01 100644 --- a/lib/ui/security_settings.dart +++ b/lib/ui/security_settings.dart @@ -4,7 +4,7 @@ import '../app/app_controller.dart'; import '../app/master_lock.dart'; import '../l10n/tr.dart'; import 'theme.dart'; -import 'confirm.dart'; +import 'confirm_dialog.dart'; import 'master_prompt.dart'; import 'setting_tile.dart'; import 'vault_page.dart' show openVault; diff --git a/lib/ui/settings/files_section.dart b/lib/ui/settings/files_section.dart index 3bf7ba8..ec4f073 100644 --- a/lib/ui/settings/files_section.dart +++ b/lib/ui/settings/files_section.dart @@ -19,6 +19,7 @@ import '../component_settings.dart'; import '../remote_thumb_view.dart' show remoteThumbsOf; import '../hidden_items_dialog.dart'; import '../../core/remote_idle.dart'; +import '../confirm_dialog.dart'; // 약속 (설정 B): 환경 설정의 분야 - 파일 탐색기. 바꾸면 바로 설정에 저장 (c.updateSettings) @@ -391,35 +392,27 @@ class _RemoteThumbCacheTileState extends State { } /// X-plore 비교 5-2: 시스템 폴더 · 루트 보기를 켤 때 경고 (체크해야 [켜기]). 켜면 true -Future confirmReadOnlyView(BuildContext context, {required IconData icon, required String title, required String body}) async { - final ok = await showDialog( - context: context, - builder: (ctx) { - var sure = false; - return StatefulBuilder( - builder: (ctx, set) => AlertDialog( - scrollable: true, - icon: Icon(icon, color: JjColors.warning), - title: Text(title), - content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(body), - const SizedBox(height: 6), - Text(tr('이 앱은 읽기만 합니다 - 쓰기 · 지우기 · 이름 바꾸기 · 권한 바꾸기는 하지 않습니다. 꺼낸 (복사한) 파일은 고른 곳에만 둡니다.')), - CheckboxListTile( - contentPadding: EdgeInsets.zero, - controlAffinity: ListTileControlAffinity.leading, - value: sure, - onChanged: (x) => set(() => sure = x ?? false), - title: Text(tr('위 내용을 알고 켭니다')), - ), - ]), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), - FilledButton(onPressed: sure ? () => Navigator.pop(ctx, true) : null, child: Text(tr('켜기'))), - ], - ), - ); - }, +/// 공용 확인 창 (confirmDialog): 첫 초점 [취소] (Enter 로 바로 켜지지 않음) · 체크 전에는 [켜기] 꺼짐 +Future confirmReadOnlyView(BuildContext context, {required IconData icon, required String title, required String body}) { + var sure = false; + return confirmDialog( + context, + icon: Icon(icon, color: JjColors.warning), + title: () => title, + body: (ctx, set) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(body), + const SizedBox(height: 6), + Text(tr('이 앱은 읽기만 합니다 - 쓰기 · 지우기 · 이름 바꾸기 · 권한 바꾸기는 하지 않습니다. 꺼낸 (복사한) 파일은 고른 곳에만 둡니다.')), + CheckboxListTile( + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + value: sure, + onChanged: (x) => set(() => sure = x ?? false), + title: Text(tr('위 내용을 알고 켭니다')), + ), + ]), + confirmLabel: () => tr('켜기'), + cancelFirst: true, + canConfirm: () => sure, ); - return ok == true; } diff --git a/lib/ui/sftp_settings.dart b/lib/ui/sftp_settings.dart index 0ab6818..e401210 100644 --- a/lib/ui/sftp_settings.dart +++ b/lib/ui/sftp_settings.dart @@ -7,7 +7,7 @@ import '../core/secret_gate.dart'; import '../core/sftp.dart'; import '../core/webdav.dart' show DavLockedException; import '../l10n/tr.dart'; -import 'confirm.dart'; +import 'confirm_dialog.dart'; import 'setting_tile.dart'; import 'theme.dart'; import 'webdav_settings.dart' show davServerUsers; diff --git a/lib/ui/smb_settings.dart b/lib/ui/smb_settings.dart index cbd83b9..4e527cd 100644 --- a/lib/ui/smb_settings.dart +++ b/lib/ui/smb_settings.dart @@ -6,7 +6,7 @@ import '../core/mdns_smb.dart'; import '../core/secret_gate.dart'; import '../core/smb.dart'; import '../l10n/tr.dart'; -import 'confirm.dart'; +import 'confirm_dialog.dart'; import 'setting_tile.dart'; import 'theme.dart'; import 'webdav_settings.dart' show davServerUsers; diff --git a/lib/ui/trash_page.dart b/lib/ui/trash_page.dart index 8e85e14..2499ae5 100644 --- a/lib/ui/trash_page.dart +++ b/lib/ui/trash_page.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import '../core/app_trash.dart'; import '../core/file_ops.dart' show formatSize; import '../l10n/tr.dart'; -import 'confirm.dart'; +import 'confirm_dialog.dart'; import 'theme.dart'; import 'jj_title_bar.dart'; diff --git a/lib/ui/vault/vault_actions.dart b/lib/ui/vault/vault_actions.dart index 0d45845..af520cd 100644 --- a/lib/ui/vault/vault_actions.dart +++ b/lib/ui/vault/vault_actions.dart @@ -15,7 +15,7 @@ import '../../core/vault_store.dart'; import '../../core/vault_view.dart'; import '../../core/vfs.dart' show vDisplay; import '../../l10n/tr.dart'; -import '../confirm.dart' show confirmAction; +import '../confirm_dialog.dart' show confirmAction; import '../player_page.dart'; import '../reader_page.dart' show openReader; import '../theme.dart'; diff --git a/lib/ui/vault/vault_views.dart b/lib/ui/vault/vault_views.dart index 71ccdca..ae5baea 100644 --- a/lib/ui/vault/vault_views.dart +++ b/lib/ui/vault/vault_views.dart @@ -8,6 +8,7 @@ import '../../l10n/tr.dart'; import '../tail_text.dart'; import '../theme.dart'; import 'vault_session.dart'; +import '../confirm_dialog.dart'; /// 약속 (보관함 나누기): 잠긴 보관함 화면의 두 칸 - 새 보관함 만들기 [VaultCreateForm] · 열기 [VaultUnlockForm]. /// 글자 칸 ([TextEditingController]) 과 값은 화면이 갖고, 이 모듈은 그리기만 한다 (비밀번호를 읽거나 남기지 않음). @@ -294,23 +295,19 @@ Future pickVaultTarget(BuildContext context, List folders, {Str /// 보관함 비밀번호 바꾸기 창. 글자 칸은 창이 닫히는 그림이 끝난 뒤에 버린다 (닫는 중에 버리면 오류) Future askVaultNewPassword(BuildContext context) async { final a = TextEditingController(), b = TextEditingController(); - final ok = await showDialog( - context: context, - builder: (ctx) => StatefulBuilder( - builder: (ctx, set) => AlertDialog( - title: Text(tr('보관함 비밀번호 바꾸기')), - content: Column(mainAxisSize: MainAxisSize.min, children: [ - TextField(controller: a, obscureText: true, decoration: InputDecoration(labelText: tr('새 비밀번호')), onChanged: (_) => set(() {})), - TextField(controller: b, obscureText: true, decoration: InputDecoration(labelText: tr('한 번 더')), onChanged: (_) => set(() {})), - ]), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), - FilledButton(onPressed: a.text.isNotEmpty && a.text == b.text ? () => Navigator.pop(ctx, true) : null, child: Text(tr('바꾸기'))), - ], - ), - ), + // 공용 확인 창: 첫 초점 [취소] (Enter 로 바로 바뀌지 않음) · 두 칸이 같아야 [바꾸기] 켜짐 + final ok = await confirmDialog( + context, + title: () => tr('보관함 비밀번호 바꾸기'), + body: (ctx, set) => Column(mainAxisSize: MainAxisSize.min, children: [ + TextField(controller: a, obscureText: true, decoration: InputDecoration(labelText: tr('새 비밀번호')), onChanged: (_) => set(() {})), + TextField(controller: b, obscureText: true, decoration: InputDecoration(labelText: tr('한 번 더')), onChanged: (_) => set(() {})), + ]), + confirmLabel: () => tr('바꾸기'), + cancelFirst: true, + canConfirm: () => a.text.isNotEmpty && a.text == b.text, ); - final pw = ok == true ? a.text : null; + final pw = ok ? a.text : null; Future.delayed(const Duration(seconds: 1), () { a.dispose(); b.dispose(); diff --git a/lib/ui/webdav_settings.dart b/lib/ui/webdav_settings.dart index d3fee21..1c4958c 100644 --- a/lib/ui/webdav_settings.dart +++ b/lib/ui/webdav_settings.dart @@ -6,7 +6,7 @@ import '../core/vfs.dart'; import '../core/secret_gate.dart'; import '../core/webdav.dart'; import '../l10n/tr.dart'; -import 'confirm.dart'; +import 'confirm_dialog.dart'; import 'file_error.dart'; import 'theme.dart'; import 'setting_tile.dart'; diff --git a/test/confirm_dialog_contract_test.dart b/test/confirm_dialog_contract_test.dart index 141ab8e..f49f2db 100644 --- a/test/confirm_dialog_contract_test.dart +++ b/test/confirm_dialog_contract_test.dart @@ -76,4 +76,62 @@ void main() { await tester.pumpAndSettle(); expect(got, isTrue); }); + + Future host(WidgetTester tester) async { + late BuildContext ctx; + await tester.pumpWidget(MaterialApp(home: Builder(builder: (c) { + ctx = c; + return const Scaffold(body: SizedBox()); + }))); + return ctx; + } + + testWidgets('W DangerDialog (관리자 10/25): 빨강이 아니어도 되돌리기 어려운 일 (cancelFirst) 은 첫 초점 [취소] - Enter 로 실행되지 않음', (tester) async { + final ctx = await host(tester); + bool? got; + unawaited(confirmDialog(ctx, title: () => '루트 폴더 보기', confirmLabel: () => '알고 봅니다', cancelFirst: true, body: (c, set) => const Text('내용')) + .then((v) => got = v)); + await tester.pumpAndSettle(); + expect(tester.widget(find.byType(FilledButton)).style, isNull, reason: '빨강은 아님'); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect(got, isFalse); + }); + + testWidgets('W DangerDialog: canConfirm 이 false 인 동안 [확인] 꺼짐 (누르고 Enter 해도 실행 안 됨) - 체크하면 켜짐', (tester) async { + final ctx = await host(tester); + var sure = false; + bool? got; + unawaited(confirmDialog(ctx, + title: () => '시스템 폴더 보기', + confirmLabel: () => '켜기', + cancelFirst: true, + canConfirm: () => sure, + body: (c, set) => CheckboxListTile(value: sure, onChanged: (v) => set(() => sure = v ?? false), title: const Text('알고 켭니다'))) + .then((v) => got = v)); + await tester.pumpAndSettle(); + expect(tester.widget(find.byType(FilledButton)).onPressed, isNull, reason: '체크 전 꺼짐'); + await tester.tap(find.text('켜기')); + await tester.pumpAndSettle(); + expect(got, isNull, reason: '꺼진 단추는 아무것도 안 함'); + await tester.tap(find.text('알고 켭니다')); + await tester.pumpAndSettle(); + expect(tester.widget(find.byType(FilledButton)).onPressed, isNotNull, reason: '체크하면 켜짐'); + await tester.tap(find.text('켜기')); + await tester.pumpAndSettle(); + expect(got, isTrue); + }); + + testWidgets('confirmAction (글만 묻는 꼴) 도 같은 창: danger 면 빨강 · 첫 초점 [취소] (Enter → false), 아니면 Enter → true', (tester) async { + for (final (danger, want) in [(true, false), (false, true)]) { + final ctx = await host(tester); + bool? got; + unawaited(confirmAction(ctx, title: '영구 삭제', body: '되돌릴 수 없습니다', ok: '지우기', danger: danger).then((v) => got = v)); + await tester.pumpAndSettle(); + expect(tester.widget(find.byType(FilledButton)).style != null, danger, reason: '빨강 = danger'); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect(got, want, reason: 'danger=$danger'); + } + }); } diff --git a/test/dismiss_test.dart b/test/dismiss_test.dart index a814d4c..4e0a659 100644 --- a/test/dismiss_test.dart +++ b/test/dismiss_test.dart @@ -11,7 +11,7 @@ import 'package:jj_mkvmaker/app/transfer_job.dart' show userErrorText; import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; import 'package:jj_mkvmaker/services/platform_services.dart'; -import 'package:jj_mkvmaker/ui/confirm.dart'; +import 'package:jj_mkvmaker/ui/confirm_dialog.dart'; import 'package:jj_mkvmaker/ui/explorer_page.dart'; import 'package:jj_mkvmaker/ui/monitor_page.dart'; import 'package:jj_mkvmaker/ui/sync_preview_view.dart';