W DangerDialog: 확인 창 두 벌 (confirm.dart · confirm_dialog.dart) 을 confirmDialog 하나로 + 체크해야 켜짐 · 첫 초점 [취소] 를 공용으로

- confirm_dialog.dart 하나: confirmAction (글만 묻는 꼴) 은 confirmDialog 를 부른다 (confirm.dart 지움, 16 파일 import 바꿈).
  빨강은 테마 위험 색 (danger) 하나로 - 지우기 · 덮어쓰기 확인의 빨강이 다른 지우기 단추와 같아짐 (FF5252 → FF5C6C)
- 새 약속: cancelFirst (빨강이 아니어도 되돌리기 어려운 일은 첫 초점 [취소] - Enter 로 실행 안 됨) · canConfirm (체크 · 두 비밀번호가
  같아야 [확인] 켜짐). 시스템 폴더 · 루트 보기 켜기 (설정), 루트 탭 경고 (탐색기), 보관함 비밀번호 바꾸기가 부르기만 한다.
  창마다 글 · 단추 이름은 그대로
- confirmAction 도 같은 규칙: danger 면 첫 초점 [취소], 아니면 [확인] (관리자 결정 10/22)
- 시험: confirm_dialog_contract_test 에 cancelFirst · canConfirm · confirmAction 3개 (관리자 10/25 조건)
- 주입: LiveSync(now:) (처음 값 실제 시계 - 카드의 "마지막: HH:MM"). 시험 쪽 고정: b3 (폴더 · 시각 · CPU · 디스크) ·
  lsync_p0 (폴더 · 시각 · 대상 디스크). b3_52 의 받는 중 파일 (.jjpart) 시각은 실제 복사가 만든 때라 그대로 (흔들림 2장 남음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
modular
RyuJongJin 2026-10-25 11:05:26 +09:00
parent d1081f337e
commit 5a65dbf693
26 changed files with 177 additions and 134 deletions

View File

@ -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', '복사');

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,33 +0,0 @@
import 'package:flutter/material.dart';
import '../l10n/tr.dart';
import 'theme.dart';
/// 되돌리기 어려운 일 전에 한 번 묻는다 (46 · 47 등). [danger] 면 확인 버튼이 빨강. 확인하면 true.
/// [cancel]: 하지 않는 쪽 버튼 이름 (처음 값 "취소" - 확인 버튼이 "… 취소" 면 헷갈리므로 바꾼다, 185)
Future<bool> confirmAction(
BuildContext context, {
required String title,
required String body,
required String ok,
bool danger = true,
String? cancel,
}) async {
final r = await showDialog<bool>(
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;
}

View File

@ -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<bool> confirmDialog(
BuildContext context, {
required String Function() title,
@ -18,9 +21,11 @@ Future<bool> 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<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
@ -33,14 +38,14 @@ Future<bool> 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<bool> confirmDialog(
);
return ok == true;
}
/// 글만으로 묻는 확인 창 ([confirmDialog] 와 같은 창 - W DangerDialog 로 하나로 모음, 예전 confirm.dart).
/// 되돌리기 어려운 일 전에 한 번 묻는다 (46 · 47 등). [danger] 면 [확인] 이 빨강 · 첫 초점은 [취소]. 확인하면 true.
/// [cancel]: 하지 않는 쪽 단추 이름 (처음 값 "취소" - 확인 단추가 "… 취소" 면 헷갈리므로 바꾼다, 185)
Future<bool> 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,
);

View File

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

View File

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

View File

@ -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<void> openRoot(BuildContext context, ExplorerPane pane) async {
if (!rootWarned) {
final ok = await showDialog<bool>(
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 {

View File

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

View File

@ -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] 이 없으면 첫 번째 버튼,

View File

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

View File

@ -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: 마스터 비밀번호 입력 창.

View File

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

View File

@ -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<RemoteThumbCacheTile> {
}
/// X-plore 비교 5-2: 시스템 폴더 · 루트 보기를 켤 때 경고 (체크해야 [켜기]). 켜면 true
Future<bool> confirmReadOnlyView(BuildContext context, {required IconData icon, required String title, required String body}) async {
final ok = await showDialog<bool>(
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<bool> 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;
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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<String?> pickVaultTarget(BuildContext context, List<String> folders, {Str
/// 보관함 비밀번호 바꾸기 창. 글자 칸은 창이 닫히는 그림이 끝난 뒤에 버린다 (닫는 중에 버리면 오류)
Future<String?> askVaultNewPassword(BuildContext context) async {
final a = TextEditingController(), b = TextEditingController();
final ok = await showDialog<bool>(
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<void>.delayed(const Duration(seconds: 1), () {
a.dispose();
b.dispose();

View File

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

View File

@ -76,4 +76,62 @@ void main() {
await tester.pumpAndSettle();
expect(got, isTrue);
});
Future<BuildContext> 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<FilledButton>(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<FilledButton>(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<FilledButton>(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<FilledButton>(find.byType(FilledButton)).style != null, danger, reason: '빨강 = danger');
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
expect(got, want, reason: 'danger=$danger');
}
});
}

View File

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