설정 A 분야 1: 파일 탐색기 (ExplorerSettings) - 키 31 · 옮김 표시 3

- lib/app/settings/explorer_settings.dart: 탐색기 설정의 이름 · 처음 값 · 읽기 · 쓰기를 한 곳에
- AppSettings 는 explorer 분야를 갖고 분야 목록을 돌며 합치기만 (toJson · fromJson 에서 탐색기 키를 지움)
- 부르는 곳: s.explorerLayout → s.explorer.layout 꼴 (옛 이름 getter 없음), 저장 키 이름은 그대로
- 고침: fromJson 이 바뀐 기본값 알림 (_migrated) 을 분야를 읽기 전에 계산하던 것 - 분야를 먼저 읽음
- JSON 약속 시험: 알림 목록 (__migrated) · 옛 판 설정 경우를 더함, 기준은 옛 판 (db45f80) 에서 다시 만듦

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
modular
RyuJongJin 2026-10-22 22:08:55 +09:00
parent f9f67f11f4
commit 428fe99a82
72 changed files with 894 additions and 569 deletions

View File

@ -20,7 +20,7 @@ void main() {
final env = Platform.environment;
final out = env['JJ_SHOT_DIR'] ?? Directory.systemTemp.path;
final c = AppController(PlatformServices.create());
c.settings.explorerPaths = [env['JJ_SHOT_LEFT'] ?? Directory.current.path, env['JJ_SHOT_RIGHT'] ?? Directory.current.path];
c.settings.explorer.paths = [env['JJ_SHOT_LEFT'] ?? Directory.current.path, env['JJ_SHOT_RIGHT'] ?? Directory.current.path];
final key = GlobalKey();
final light = env['JJ_SHOT_THEME'] == 'light';
JjColors.use(light ? Brightness.light : Brightness.dark);
@ -33,8 +33,8 @@ void main() {
('split', const Size(1600, 900), 'split', 'windows'),
]) {
c.settings
..explorerLayout = layout
..explorerStyle = style;
..explorer.layout = layout
..explorer.style = style;
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
await tester.runAsync(() => tester.pumpWidget(MaterialApp(

View File

@ -29,7 +29,7 @@ void main() {
Directory(p.join(tmp.path, '폴더 하나')).createSync();
Directory(p.join(tmp.path, 'Another folder with a long name')).createSync();
final c = AppController(PlatformServices.create());
c.settings.explorerPaths = [tmp.path, tmp.path];
c.settings.explorer.paths = [tmp.path, tmp.path];
final key = GlobalKey();
// 폰처럼 (Android 는 트리 펼치기 화살표가 44 로 넓다 - Windows 로 그리면 폰보다 넉넉해 보인다)
debugDefaultTargetPlatformOverride = TargetPlatform.android;
@ -37,10 +37,10 @@ void main() {
for (final style in ['xplore', 'windows', 'totalcmd']) {
for (final (scale, spacing) in [(100, 'normal'), (150, 'wide'), (80, 'compact')]) {
c.settings
..explorerLayout = layout
..explorerStyle = style
..explorerTextScale = scale
..explorerRowSpacing = spacing;
..explorer.layout = layout
..explorer.style = style
..explorer.textScale = scale
..explorer.rowSpacing = spacing;
tester.view.physicalSize = Size(width, 900);
tester.view.devicePixelRatio = 1;
await tester.runAsync(() => tester.pumpWidget(MaterialApp(

View File

@ -35,7 +35,7 @@ void main() {
final c = AppController(PlatformServices.create());
c.settings
..rsyncPaths = [tmp.path, tmp.path]
..explorerStyle = 'windows';
..explorer.style = 'windows';
final key = GlobalKey();
tester.view.physicalSize = const Size(1500, 900);
tester.view.devicePixelRatio = 1;

View File

@ -79,7 +79,7 @@ void main() {
..createSync(recursive: true)
..writeAsStringSync('old');
c = AppController(PlatformServices.create());
c.settings.explorerPaths = [left, right];
c.settings.explorer.paths = [left, right];
// 휴지통: 기본은 가짜 (화면 흐름만), JJ_TEST_REAL_RECYCLE=1 이면 실제 휴지통
useTestRecycleBin();
});

View File

@ -47,8 +47,8 @@ void main() {
..writeAsStringSync('x');
c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..explorerPaths = [left, right]
..explorerDropAction = 'ask';
..explorer.paths = [left, right]
..explorer.dropAction = 'ask';
});
tearDown(() async {
for (var i = 0;; i++) {
@ -221,7 +221,7 @@ void main() {
testWidgets('75: 마우스 4 · 5 번은 마우스가 올라간 창에 · Alt+← / →', (t) async {
await open(t);
String at(int i) => c.settings.explorerPaths[i];
String at(int i) => c.settings.explorer.paths[i];
final sub = p.join(left, 'sub'), r1 = p.join(right, 'r1');
await t.tap(find.text('r1'));
await until(t, () => at(1) == r1 && find.text('x.txt').evaluate().isNotEmpty);

View File

@ -59,7 +59,7 @@ void main() {
Directory(p.join(tmp.path, 'sub')).createSync();
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..explorerPaths = [tmp.path, tmp.path]
..explorer.paths = [tmp.path, tmp.path]
..rsyncPaths = [tmp.path, tmp.path]
..copyTasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, 'sub'), method: 'rsync', options: '-av')]
..liveSyncPairs = [LiveSyncPair(tmp.path, p.join(tmp.path, 'x'))];

View File

@ -90,9 +90,9 @@ void main() {
File(p.join(dir.path, 'keep_$tag.txt')).writeAsStringSync('x');
final c = controller();
c.settings
..explorerLayout = 'single'
..explorerClick = 'select'
..explorerPaths = [dir.path, dir.path];
..explorer.layout = 'single'
..explorer.click = 'select'
..explorer.paths = [dir.path, dir.path];
await show(t, size, ExplorerPage(c: c));
await tapText(t, 'recycle_me_$tag.txt');
await t.sendKeyEvent(LogicalKeyboardKey.delete);
@ -119,9 +119,9 @@ void main() {
for (final (tag, size) in sizes) {
final c = controller();
c.settings
..explorerLayout = 'single'
..explorerClick = 'select'
..explorerPaths = [share.path, share.path];
..explorer.layout = 'single'
..explorer.click = 'select'
..explorer.paths = [share.path, share.path];
await show(t, size, ExplorerPage(c: c));
await settle(t, 20);
await tapText(t, 'on_share.txt');
@ -142,9 +142,9 @@ void main() {
for (final (tag, size) in sizes) {
final c = controller();
c.settings
..explorerLayout = 'single'
..explorerClick = 'select'
..explorerPaths = [deep, deep];
..explorer.layout = 'single'
..explorer.click = 'select'
..explorer.paths = [deep, deep];
await show(t, size, ExplorerPage(c: c));
await settle(t, 20);
final f = find.textContaining('long_name_file');
@ -185,8 +185,8 @@ void main() {
for (final (tag, size) in sizes) {
final c = controller();
c.settings
..explorerLayout = 'single'
..explorerPaths = [tmp.path, tmp.path];
..explorer.layout = 'single'
..explorer.paths = [tmp.path, tmp.path];
await show(t, size, ExplorerPage(c: c));
Future<void> enter(String text) async {
await t.tap(find.byTooltip('경로 입력').first);

View File

@ -54,10 +54,10 @@ void main() {
for (final w in widths) {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..explorerStyle = style
..explorerLayout = 'single'
..explorerTextScale = list
..explorerPaths = [dir.path, dir.path];
..explorer.style = style
..explorer.layout = 'single'
..explorer.textScale = list
..explorer.paths = [dir.path, dir.path];
t.view.physicalSize = Size(w, 800);
t.view.devicePixelRatio = 1;
await t.runAsync(() => t.pumpWidget(RepaintBoundary(

View File

@ -47,7 +47,7 @@ void main() {
..writeAsStringSync('NEWER');
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..explorerPaths = [left, right]
..explorer.paths = [left, right]
..copyConflict = 'overwrite';
final root = p.rootPrefix(tmp.path);
device.volumes = () => [(root, root.replaceAll(RegExp(r'[\\/]+$'), ''))];

View File

@ -50,7 +50,7 @@ void main() {
device.volumes = () => [(tmp.path, 'T:')];
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..explorerOrientation = 'side'
..explorer.orientation = 'side'
..rsyncPaths = [l, r];
final shotKey = GlobalKey();
await t.pumpWidget(MaterialApp(

View File

@ -52,10 +52,10 @@ void main() {
device.volumes = () => [(a.path, 'C:'), (b.path, 'D:')];
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..explorerStyle = 'xplore'
..explorerLayout = w < 720 ? 'single' : 'dual'
..explorerOnePaneCopy = 'other'
..explorerPaths = [a.path, b.path]
..explorer.style = 'xplore'
..explorer.layout = w < 720 ? 'single' : 'dual'
..explorer.onePaneCopy = 'other'
..explorer.paths = [a.path, b.path]
..folderBookmarks = [FolderBookmark(name: 'Show S01', path: p.join(a.path, 'Show S01'))];
final shotKey = GlobalKey();
await t.runAsync(() => t.pumpWidget(RepaintBoundary(

View File

@ -17,10 +17,12 @@ import '../services/app_shell.dart' show appIconOf;
import '../core/remote_idle.dart';
import 'settings/records.dart';
import 'settings/settings_group.dart';
import 'settings/explorer_settings.dart';
export 'settings/records.dart';
export 'settings/settings_group.dart';
export 'settings/settings_store.dart';
export 'settings/explorer_settings.dart';
/// 환경 설정 (settings.json 에 저장)
class AppSettings {
@ -181,82 +183,27 @@ class AppSettings {
bool torNoticeSeen = false;
// ── 파일 탐색기 (X-plore 참고) ──
/// 창: 'dual' 두 창 (기본 - 폰 세로도, 규칙 7 · 결정 3 10/22) · 'split' 왼쪽 폴더 트리 + 오른쪽 그 폴더의 파일 목록 ·
/// 'single' 한 창 · 'auto' 화면 크기 따라 (폰 세로는 한 창)
String explorerLayout = 'dual';
/// X-plore 비교 a: 한 창일 때 복사 · 이동 - 'clip' 담아 두었다가 붙여넣기 (지금 그대로, 처음 값) /
/// 'other' 숨은 다른 창의 폴더로 바로 (X-plore 처럼, 위에 "갈 곳" 줄). 예전 동작과 달라 처음 값은 그대로 (관리자 결정)
String explorerOnePaneCopy = 'clip';
/// 펼친 폴더를 다시 누르면 접기 (규칙 3 "폴더 한 번 누르기 = 펼치기 · 접기", 처음 값 켬 - E3 10/22).
/// 두 번 누르기의 두 번째는 접지 않는다 ([ClickPolicy] repeat)
bool explorerRetapCollapse = true;
/// X-plore 비교 40 (관리자 지시 10/20): 파일 탐색기 목록만의 글자 크기 (%, 80~150, 처음 100 = 지금 그대로).
/// 앱 화면 크기 · 기기 글자 크기와 곱해진다
int explorerTextScale = 100;
static const explorerTextScaleMin = 80, explorerTextScaleMax = 150;
/// X-plore 비교 40: 목록 줄 간격 'compact' 좁게 · 'normal' 보통 (처음 값 = 지금 그대로) · 'wide' 넓게
String explorerRowSpacing = 'normal';
/// X-plore 비교 i: 일괄 이름 바꾸기의 마지막 규칙 (다음에 창이 그 값으로 열림, 비면 처음 값)
Map<String, dynamic> batchRenameLast = {};
/// X-plore 비교 c: X-plore 스타일에서 저장 장치 · 서버 · 즐겨찾기를 트리의 맨 위 항목으로 (위 줄 탭 · ★ 는 그대로 함께).
/// 처음 값 켬 (X-plore 스타일에서만 보임 - 다른 스타일은 그대로)
bool explorerRootsInTree = true;
/// X-plore 스타일에서 줄 끝 표시를 늘 보이기. 처음 값 끔 - 규칙 4 "선택 표시는 선택 모드에서만" (E3 10/22)
bool explorerAlwaysMarks = false;
/// X-plore 비교 ⚖: X-plore 스타일 탐색기 안에서 옆으로 밀기 = 창 바꾸기 (설정으로만, 처음 값 끔 - 10/8 사용자 결정 "화면 가운데를 밀면 화면 이동" 을 지킴, 관리자 판단 10/18)
bool explorerSwipePanes = false;
/// X-plore 비교 e (관리자 결정): Android 앱 휴지통 보관 일 (처음 14일, 0 = 끔 - 예전처럼 바로 영구 삭제). Windows 는 시스템 휴지통
int androidTrashDays = 14;
/// 누르기: 'select' 한 번 = 선택 · 두 번 = 열기 (기본, 두 기기 - 규칙 2) / 'open' 한 번 = 바로 열기.
/// 길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴.
String explorerClick = 'select';
/// 모양: 'xplore' (기본) · 'windows' Windows 탐색기 · 'totalcmd' Total Commander.
/// 규칙 1 의 Windows 처음 값 (Total Commander) 과 저장된 값 묻기 (결정 1) 는 E6 (TC 모양) 과 함께 (관리자 결정 10/22)
String explorerStyle = 'xplore';
/// 두 창 배치: 'side' 좌우 (기본, 118 - 폰 세로에서도) · 'stacked' 위아래 · 'auto' 화면 모양 따라 (가로로 넓으면 좌우, 세로로 길면 위아래)
String explorerOrientation = 'side';
/// 기능 버튼 줄: 'middle' 두 창 사이 (기본) · 'edge' 오른쪽 (위아래 배치면 아래) 끝 · 'hidden' 숨김
String explorerToolbar = 'middle';
/// 기능 버튼 줄에 보일 버튼과 순서 (ExplorerButton 이름). 비어 있으면 기본 구성.
List<String> explorerButtons = [];
/// 정렬 · 숨긴 항목
String explorerSort = 'name';
bool explorerSortDesc = false;
/// 84: 정렬을 폴더마다 기억 (처음: 끔 - 모든 폴더가 같은 정렬). 켜면 열 머리 · 정렬 창이 지금 폴더에만 쓰고 [explorerFolderSorts] 에 둔다
bool explorerSortPerFolder = false;
/// X-plore 비교 5-1 ① (관리자 지시 10/21): 종류별 기본 정렬 - 사진 · 동영상이 많은 폴더는 날짜 (새것 먼저), 음악이 많은 폴더는 이름.
/// 처음 끔 (지금 정렬 그대로). 켜도 그 폴더에서 정렬을 바꾸면 그 폴더는 바꾼 정렬을 기억한다 ([explorerFolderSorts])
bool explorerSortByKind = false;
/// X-plore 비교 5-1 ② (관리자 지시 10/21): 원격 (WebDAV · SFTP · SMB · FTP) 썸네일 - 'wifi' 데이터 요금제가 아닌 망에서만 (처음 값) ·
/// 'always' 늘 · 'off' 받지 않음. X-plore 모양의 목록에서 보이는 줄의 그림 · 동영상만, 동시에 2개까지
String explorerRemoteThumbs = 'wifi';
/// 원격 썸네일 캐시 상한 (MB, 처음 200) - 넘으면 오래 쓰지 않은 것부터 지운다
int explorerRemoteThumbCacheMb = 200;
static const remoteThumbCacheChoices = [50, 100, 200, 500, 1000];
/// X-plore 비교 5-1 ⑥ (관리자 지시 10/21): 파일 탐색기 단축키 중 사용자가 바꾼 것만 (동작 → 키, '' = 그 동작은 단축키 없음).
/// 비어 있으면 (처음) 스타일대로 (83) - ui/explorer_keys.dart
Map<String, String> explorerKeyOverrides = {};
/// X-plore 비교 5-2 암호 보관함 (사용자 결정 10/21, 관리자 결정): 처음 끔. 보관함 자체는 설정이 아니라 그 폴더 (vault.json · *.jjv) 에 있다 -
/// 설정을 처음 상태로 돌려도 보관함은 그대로 (끄기만 됨)
@ -274,17 +221,9 @@ class AppSettings {
/// 앱이 뒤로 간 뒤 이만큼 (초) 지나면 다시 잠금 (처음 60)
int vaultAutoLockSeconds = 60;
/// 84: 폴더 → "기준|desc" (예: "size|1"). 오래된 것부터 지워 300개까지
Map<String, String> explorerFolderSorts = {};
/// 80: 폴더 크기 계산 - 'sort' 크기순 정렬일 때만 (처음 값) · 'always' 늘 · 'off' 안 함 (길게 누르기 [폴더 크기 계산] 은 늘 됨)
String explorerFolderSize = 'sort';
/// 82: 창 사이 끌어 놓기 - 'ask' 묻기 (처음 값, 관리자 판단) · 'copy' 복사 · 'move' 이동
String explorerDropAction = 'ask';
/// 85: 탐색기의 복사 · 이동 진행 패널을 숨김 (아래 작은 표시만). 처음 값: 보임
bool explorerProgressHidden = false;
/// 89: Wi-Fi 공유 - 저절로 끄는 시간 (분, 0 = 끄지 않음). 처음 값 30분 (관리자 지시 10/17)
int wifiShareMinutes = 30;
@ -297,14 +236,8 @@ class AppSettings {
/// 86: ZIP 으로 묶기의 압축 정도 (1 빠르게 · 6 보통 (처음 값) · 9 작게) - 마지막에 고른 것을 기억
int zipLevel = 6;
bool explorerShowHidden = false;
/// X-plore 숨기기 (관리자 지시 10/18): 파일 탐색기 목록에서 숨긴 파일 · 폴더의 경로 (파일 자체는 바꾸지 않는다).
/// 지우기 · 이동 대상에서는 빠지지 않는다. 목록 맨 아래 [숨긴 항목 보이기] 로 다시 본다
List<String> explorerHiddenItems = [];
/// X-plore 맞추기 (관리자 지시 10/20): [목록에서 숨기기] 전에 무엇이 바뀌는지 확인 창 (처음 켬, 창의 [다음부터 묻지 않기] · 환경 설정에서 끔)
bool explorerHideConfirm = true;
// ── 복사 · 이동 (파일 탐색기) · rsync · lsync (Rsync 화면) ──
/// 파일 탐색기의 복사 · 이동 방법: 'builtin' 현재 방식 (기본) · 'robocopy' (Windows). 파일만 고를 때 / 폴더가 들어 있을 때.
@ -533,11 +466,7 @@ class AppSettings {
}
}
/// 마지막으로 연 폴더 (왼쪽 · 오른쪽 창)
List<String> explorerPaths = [];
/// 최근에 연 폴더 (내역, 최신이 앞)
List<String> explorerHistory = [];
/// 앱 안 브라우저의 데이터 폴더 (로그인 · 쿠키). 실행 중에 정해지며 저장하지 않음
String webViewDataDir = '';
@ -575,8 +504,11 @@ class AppSettings {
AppSettings();
/// 파일 탐색기 (X-plore 참고)
final explorer = ExplorerSettings();
/// 설정 분야들 (설정 A) - 저마다 자기 키를 읽고 쓴다. toJson · fromJson 은 이 목록을 돌며 합치기만 한다
List<SettingsGroup> get groups => const [];
List<SettingsGroup> get groups => [explorer];
/// [plainSecrets]: 안전 저장소를 쓸 수 없을 때만 (예전 파일에 있던 비밀 값을 잃지 않게) 예전처럼 파일에 둔다
Map<String, Object?> toJson({bool plainSecrets = false}) {
@ -647,50 +579,15 @@ class AppSettings {
'torHomeUrl': torHomeUrl,
'torJavaScript': torJavaScript,
'torNoticeSeen': torNoticeSeen,
'explorerLayout': explorerLayout,
'explorerOnePaneCopy': explorerOnePaneCopy,
'explorerRetapCollapse': explorerRetapCollapse,
'explorerTextScale': explorerTextScale,
'explorerRowSpacing': explorerRowSpacing,
'batchRenameLast': batchRenameLast,
'explorerRootsInTree': explorerRootsInTree,
'explorerAlwaysMarks': explorerAlwaysMarks,
'explorerSwipePanes': explorerSwipePanes,
'androidTrashDays': androidTrashDays,
'explorerClick': explorerClick,
// 예전 기본값 (두 창 · 한 번 누르면 선택) 은 사용자가 고른 값이 아니므로 한 번 새 기본값으로 옮긴다
'explorerV2': true,
// E3 (10/22): 선택 규칙 (사용자 지시 모음) 의 처음 값으로 한 번 옮겼음
'explorerRulesV1': true,
'explorerStyle': explorerStyle,
'explorerOrientation': explorerOrientation,
'explorerOrientV3': true,
'explorerToolbar': explorerToolbar,
'explorerButtons': explorerButtons,
'explorerSort': explorerSort,
'explorerSortDesc': explorerSortDesc,
'explorerFolderSize': explorerFolderSize,
'explorerDropAction': explorerDropAction,
'explorerProgressHidden': explorerProgressHidden,
'zipLevel': zipLevel,
'wifiShareMinutes': wifiShareMinutes,
'wifiShareTailscale': wifiShareTailscale,
'wifiShareFolder': wifiShareFolder,
'explorerSortPerFolder': explorerSortPerFolder,
'explorerSortByKind': explorerSortByKind,
'explorerRemoteThumbs': explorerRemoteThumbs,
'explorerRemoteThumbCacheMb': explorerRemoteThumbCacheMb,
'explorerKeyOverrides': explorerKeyOverrides,
'vaultEnabled': vaultEnabled,
'shizukuEnabled': shizukuEnabled,
'rootEnabled': rootEnabled,
'vaultMasterCopy': vaultMasterCopy,
'vaultAutoLockSeconds': vaultAutoLockSeconds,
'explorerFolderSorts': explorerFolderSorts,
'explorerShowHidden': explorerShowHidden,
'explorerHiddenItems': explorerHiddenItems,
'explorerHideConfirm': explorerHideConfirm,
'explorerPaths': explorerPaths,
'copyMethodFile': copyMethodFile,
'copyConflict': copyConflict,
'mkvExisting': mkvExisting,
@ -779,7 +676,6 @@ class AppSettings {
'backgroundV2': true,
'liveSyncOnStart': liveSyncOnStart,
'copyTasks': [for (final x in copyTasks) x.toJson()],
'explorerHistory': explorerHistory,
'autoCheckUpdates': autoCheckUpdates,
'lastUpdateCheck': lastUpdateCheck,
'lastUpdateCheckVersion': lastUpdateCheckVersion,
@ -896,70 +792,15 @@ class AppSettings {
: defaultTorHome
..torJavaScript = j['torJavaScript'] != false
..torNoticeSeen = j['torNoticeSeen'] == true
// 'auto' = 넓은 화면은 두 창, 좁은 화면 (폰 세로) 은 한 창. 예전 기본 'dual' 은 한 번 'auto' 로
..explorerOnePaneCopy = j['explorerOnePaneCopy'] == 'other' ? 'other' : 'clip'
// E3: 규칙 이전 판이 저장한 false 는 예전 처음 값 - 한 번 규칙 3 (켬) 으로
..explorerRetapCollapse = j['explorerRulesV1'] == true ? j['explorerRetapCollapse'] != false : true
// 값이 없거나 틀리면 지금 그대로 (100% · 보통), 범위 밖은 끝으로
..explorerTextScale = j['explorerTextScale'] is num
? (j['explorerTextScale'] as num).round().clamp(explorerTextScaleMin, explorerTextScaleMax)
: 100
..explorerRowSpacing = const {'compact', 'normal', 'wide'}.contains(j['explorerRowSpacing']) ? j['explorerRowSpacing'] as String : 'normal'
..batchRenameLast = j['batchRenameLast'] is Map ? Map<String, dynamic>.from(j['batchRenameLast'] as Map) : {}
..explorerRootsInTree = j['explorerRootsInTree'] != false
// E3: 규칙 이전 판이 저장한 true 는 예전 처음 값 - 한 번 규칙 4 (끔) 로
..explorerAlwaysMarks = j['explorerRulesV1'] == true && j['explorerAlwaysMarks'] == true
..explorerSwipePanes = j['explorerSwipePanes'] == true
..androidTrashDays = switch (j['androidTrashDays']) { final int d when d >= 0 && d <= 365 => d, _ => 14 }
// E3 (규칙 7 · 결정 3): 처음 값 두 창. 규칙 이전 판의 'auto' 는 예전 처음 값이라 한 번 두 창으로
..explorerLayout = switch (j['explorerLayout']) {
'single' || 'split' => j['explorerLayout'] as String,
'auto' when j['explorerRulesV1'] == true => 'auto',
_ => 'dual',
}
// 규칙 2: 두 기기 모두 한 번 = 선택. 규칙 이전 Android 의 'open' 은 예전 처음 값이라 한 번 'select' 로
..explorerClick = j['explorerClick'] == 'open' && (j['explorerRulesV1'] == true || !Platform.isAndroid) ? 'open' : 'select'
..explorerStyle = const ['windows', 'totalcmd'].contains(j['explorerStyle']) ? j['explorerStyle'] as String : 'xplore'
// 118: 예전 기본 '화면 모양 따라' 는 한 번 좌우로 옮긴다 (사용자가 고른 위아래는 그대로)
..explorerOrientation = j['explorerOrientation'] == 'stacked'
? 'stacked'
: j['explorerOrientation'] == 'auto' && j['explorerOrientV3'] == true
? 'auto'
: 'side'
..explorerToolbar = const ['edge', 'hidden'].contains(j['explorerToolbar']) ? j['explorerToolbar'] as String : 'middle'
..explorerButtons = [for (final x in (j['explorerButtons'] as List?) ?? const []) '$x']
..explorerSort = const ['date', 'size', 'type'].contains(j['explorerSort']) ? j['explorerSort'] as String : 'name'
..explorerSortDesc = j['explorerSortDesc'] as bool? ?? false
..explorerFolderSize = const ['sort', 'always', 'off'].contains(j['explorerFolderSize']) ? j['explorerFolderSize'] as String : 'sort'
..explorerSortPerFolder = j['explorerSortPerFolder'] == true
..explorerSortByKind = j['explorerSortByKind'] == true
..explorerRemoteThumbs = const {'off', 'wifi', 'always'}.contains(j['explorerRemoteThumbs']) ? j['explorerRemoteThumbs'] as String : 'wifi'
..vaultEnabled = j['vaultEnabled'] == true
..shizukuEnabled = j['shizukuEnabled'] == true
..rootEnabled = j['rootEnabled'] == true
..vaultMasterCopy = j['vaultMasterCopy'] == true
..vaultAutoLockSeconds = j['vaultAutoLockSeconds'] is num ? (j['vaultAutoLockSeconds'] as num).round().clamp(0, 3600) : 60
..explorerKeyOverrides = j['explorerKeyOverrides'] is Map
? {for (final e in (j['explorerKeyOverrides'] as Map).entries) if (e.key is String && e.value is String) e.key as String: e.value as String}
: {}
..explorerRemoteThumbCacheMb = j['explorerRemoteThumbCacheMb'] is num
? (j['explorerRemoteThumbCacheMb'] as num).round().clamp(10, 10000)
: 200
..explorerProgressHidden = j['explorerProgressHidden'] == true
..zipLevel = const [1, 6, 9].contains(j['zipLevel']) ? j['zipLevel'] as int : 6
..wifiShareMinutes = const [0, 10, 30, 60, 120].contains(j['wifiShareMinutes']) ? j['wifiShareMinutes'] as int : 30
..wifiShareTailscale = j['wifiShareTailscale'] is bool ? j['wifiShareTailscale'] as bool : true
..wifiShareFolder = j['wifiShareFolder'] is String ? j['wifiShareFolder'] as String : ''
..explorerDropAction = const ['ask', 'copy', 'move'].contains(j['explorerDropAction']) ? j['explorerDropAction'] as String : 'ask'
..explorerFolderSorts = {
if (j['explorerFolderSorts'] is Map)
for (final e in (j['explorerFolderSorts'] as Map).entries)
if (e.key is String && e.value is String) e.key as String: e.value as String,
}
..explorerShowHidden = j['explorerShowHidden'] as bool? ?? false
..explorerHiddenItems = [for (final x in (j['explorerHiddenItems'] as List?) ?? const []) if (x is String && x.isNotEmpty) x]
..explorerHideConfirm = j['explorerHideConfirm'] as bool? ?? true
..explorerPaths = [for (final x in (j['explorerPaths'] as List?) ?? const []) '$x']
..copyMethodFile = _method(j['copyMethodFile'])
..copyConflict = const ['rename', 'overwrite', 'skip'].contains(j['copyConflict']) ? j['copyConflict'] as String : 'ask'
..mkvExisting = const ['rename', 'overwrite', 'skip'].contains(j['mkvExisting']) ? j['mkvExisting'] as String : 'ask'
@ -1079,7 +920,6 @@ class AppSettings {
for (final x in (j['copyTasks'] as List?) ?? const [])
if (x is Map && x['dest'] is String) CopyTask.fromJson(x),
]
..explorerHistory = [for (final x in (j['explorerHistory'] as List?) ?? const []) '$x']
..autoCheckUpdates = j['autoCheckUpdates'] as bool? ?? true
..lastUpdateCheck = j['lastUpdateCheck'] as String? ?? ''
..lastUpdateCheckVersion = j['lastUpdateCheckVersion'] as String? ?? ''
@ -1103,10 +943,11 @@ class AppSettings {
..aiSource = j['aiSource'] as String? ?? 'und'
..aiTargets = (j['aiTargets'] as List?)?.cast<String>() ?? ['ko', 'en', 'ja']
..aiWhisper = j['aiWhisper'] as String? ?? 'whisper-base';
s.migrated = _migrated(j, s);
// 분야를 먼저 읽는다 - 바뀐 기본값 알림 ([_migrated]) 이 분야의 값을 본다
for (final g in s.groups) {
g.read(j);
}
s.migrated = _migrated(j, s);
s.extra = Map.of(j);
return s;
}
@ -1163,8 +1004,8 @@ class AppSettings {
static List<String> _migrated(Map<String, dynamic> j, AppSettings s) => [
if (j['orientationV2'] != true && j['screenOrientation'] == 'landscape' && s.screenOrientation == 'auto')
'orientation',
if (j['explorerV2'] != true && j['explorerLayout'] == 'dual' && s.explorerLayout == 'auto') 'explorerLayout',
if (j['explorerV2'] != true && j['explorerClick'] == 'select' && s.explorerClick == 'open') 'explorerClick',
if (j['explorerV2'] != true && j['explorerLayout'] == 'dual' && s.explorer.layout == 'auto') 'explorerLayout',
if (j['explorerV2'] != true && j['explorerClick'] == 'select' && s.explorer.click == 'open') 'explorerClick',
if (j['explorerOrientV3'] != true && j['explorerOrientation'] == 'auto') 'explorerOrientation',
if (j['aiImageV1'] != true && j['components'] is List && !(j['components'] as List).contains('aiimage')) 'aiImage',
// 53 · 28: 앱 안 브라우저 쿠키를 쓰던 사람에게 넘기는 사이트가 넓어졌음을 한 번 알린다
@ -1175,7 +1016,7 @@ class AppSettings {
'background',
// 193: 그 설정이 생기기 전 판 (설정 파일은 있는데 그 항목이 없음 - 예: 10.03) 에서 올라온 사람이 바로 겪는 동작 변화.
// 예전 판에는 키가 없어 위의 "예전 값 → 새 값" 조건에 걸리지 않았다 (P11: 탐색기에서 한 번 눌렀는데 바로 열려 놀람)
if (j.isNotEmpty && !j.containsKey('explorerClick') && s.explorerClick == 'open') 'explorerClickNew',
if (j.isNotEmpty && !j.containsKey('explorerClick') && s.explorer.click == 'open') 'explorerClickNew',
// E3 (10/22): 선택 규칙의 처음 값으로 옮긴 것 - 규칙 이전 판에서 올라온 사람에게 한 번
if (j.isNotEmpty && j['explorerRulesV1'] != true && j['explorerAlwaysMarks'] != false) 'explorerMarksRule',
if (j.isNotEmpty && j['explorerRulesV1'] != true && j['explorerRetapCollapse'] != true) 'explorerRetapRule',

View File

@ -0,0 +1,211 @@
import 'dart:io';
import 'settings_group.dart';
/// 약속 (설정 A): 환경 설정 > 파일 탐색기 (X-plore 참고) - 자기 키 (이름 · 처음 값 · 읽기 · 쓰기) 를 스스로 갖는다.
/// 설정 하나를 더할 때는 이 파일만 고친다. 저장 파일의 키 이름은 예전 그대로 (평평한 키)
class ExplorerSettings implements SettingsGroup {
/// 창: 'dual' 두 창 (기본 - 폰 세로도, 규칙 7 · 결정 3 10/22) · 'split' 왼쪽 폴더 트리 + 오른쪽 그 폴더의 파일 목록 ·
/// 'single' 한 창 · 'auto' 화면 크기 따라 (폰 세로는 한 창)
String layout = 'dual';
/// X-plore 비교 a: 한 창일 때 복사 · 이동 - 'clip' 담아 두었다가 붙여넣기 (지금 그대로, 처음 값) /
/// 'other' 숨은 다른 창의 폴더로 바로 (X-plore 처럼, 위에 "갈 곳" 줄). 예전 동작과 달라 처음 값은 그대로 (관리자 결정)
String onePaneCopy = 'clip';
/// 펼친 폴더를 다시 누르면 접기 (규칙 3 "폴더 한 번 누르기 = 펼치기 · 접기", 처음 값 켬 - E3 10/22).
/// 두 번 누르기의 두 번째는 접지 않는다 ([ClickPolicy] repeat)
bool retapCollapse = true;
/// X-plore 비교 40 (관리자 지시 10/20): 파일 탐색기 목록만의 글자 크기 (%, 80~150, 처음 100 = 지금 그대로).
/// 앱 화면 크기 · 기기 글자 크기와 곱해진다
int textScale = 100;
static const textScaleMin = 80, textScaleMax = 150;
/// X-plore 비교 40: 목록 줄 간격 'compact' 좁게 · 'normal' 보통 (처음 값 = 지금 그대로) · 'wide' 넓게
String rowSpacing = 'normal';
/// X-plore 비교 i: 일괄 이름 바꾸기의 마지막 규칙 (다음에 창이 그 값으로 열림, 비면 처음 값)
Map<String, dynamic> batchRenameLast = {};
/// X-plore 비교 c: X-plore 스타일에서 저장 장치 · 서버 · 즐겨찾기를 트리의 맨 위 항목으로 (위 줄 탭 · ★ 는 그대로 함께).
/// 처음 값 켬 (X-plore 스타일에서만 보임 - 다른 스타일은 그대로)
bool rootsInTree = true;
/// X-plore 스타일에서 줄 끝 표시를 늘 보이기. 처음 값 끔 - 규칙 4 "선택 표시는 선택 모드에서만" (E3 10/22)
bool alwaysMarks = false;
/// X-plore 비교 ⚖: X-plore 스타일 탐색기 안에서 옆으로 밀기 = 창 바꾸기 (설정으로만, 처음 값 끔 - 10/8 사용자 결정 "화면 가운데를 밀면 화면 이동" 을 지킴, 관리자 판단 10/18)
bool swipePanes = false;
/// X-plore 비교 e (관리자 결정): Android 앱 휴지통 보관 일 (처음 14일, 0 = 끔 - 예전처럼 바로 영구 삭제). Windows 는 시스템 휴지통
int androidTrashDays = 14;
/// 누르기: 'select' 한 번 = 선택 · 두 번 = 열기 (기본, 두 기기 - 규칙 2) / 'open' 한 번 = 바로 열기.
/// 길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴.
String click = 'select';
/// 모양: 'xplore' (기본) · 'windows' Windows 탐색기 · 'totalcmd' Total Commander.
/// 규칙 1 의 Windows 처음 값 (Total Commander) 과 저장된 값 묻기 (결정 1) 는 E6 (TC 모양) 과 함께 (관리자 결정 10/22)
String style = 'xplore';
/// 두 창 배치: 'side' 좌우 (기본, 118 - 폰 세로에서도) · 'stacked' 위아래 · 'auto' 화면 모양 따라 (가로로 넓으면 좌우, 세로로 길면 위아래)
String orientation = 'side';
/// 기능 버튼 줄: 'middle' 두 창 사이 (기본) · 'edge' 오른쪽 (위아래 배치면 아래) 끝 · 'hidden' 숨김
String toolbar = 'middle';
/// 기능 버튼 줄에 보일 버튼과 순서 (ExplorerButton 이름). 비어 있으면 기본 구성.
List<String> buttons = [];
/// 정렬 · 숨긴 항목
String sort = 'name';
bool sortDesc = false;
/// 84: 정렬을 폴더마다 기억 (처음: 끔 - 모든 폴더가 같은 정렬). 켜면 열 머리 · 정렬 창이 지금 폴더에만 쓰고 [folderSorts] 에 둔다
bool sortPerFolder = false;
/// X-plore 비교 5-1 ① (관리자 지시 10/21): 종류별 기본 정렬 - 사진 · 동영상이 많은 폴더는 날짜 (새것 먼저), 음악이 많은 폴더는 이름.
/// 처음 끔 (지금 정렬 그대로). 켜도 그 폴더에서 정렬을 바꾸면 그 폴더는 바꾼 정렬을 기억한다 ([folderSorts])
bool sortByKind = false;
/// X-plore 비교 5-1 ② (관리자 지시 10/21): 원격 (WebDAV · SFTP · SMB · FTP) 썸네일 - 'wifi' 데이터 요금제가 아닌 망에서만 (처음 값) ·
/// 'always' 늘 · 'off' 받지 않음. X-plore 모양의 목록에서 보이는 줄의 그림 · 동영상만, 동시에 2개까지
String remoteThumbs = 'wifi';
/// 원격 썸네일 캐시 상한 (MB, 처음 200) - 넘으면 오래 쓰지 않은 것부터 지운다
int remoteThumbCacheMb = 200;
static const remoteThumbCacheChoices = [50, 100, 200, 500, 1000];
/// X-plore 비교 5-1 ⑥ (관리자 지시 10/21): 파일 탐색기 단축키 중 사용자가 바꾼 것만 (동작 → 키, '' = 그 동작은 단축키 없음).
/// 비어 있으면 (처음) 스타일대로 (83) - ui/explorer_keys.dart
Map<String, String> keyOverrides = {};
/// 84: 폴더 → "기준|desc" (예: "size|1"). 오래된 것부터 지워 300개까지
Map<String, String> folderSorts = {};
/// 80: 폴더 크기 계산 - 'sort' 크기순 정렬일 때만 (처음 값) · 'always' 늘 · 'off' 안 함 (길게 누르기 [폴더 크기 계산] 은 늘 됨)
String folderSize = 'sort';
/// 82: 창 사이 끌어 놓기 - 'ask' 묻기 (처음 값, 관리자 판단) · 'copy' 복사 · 'move' 이동
String dropAction = 'ask';
/// 85: 탐색기의 복사 · 이동 진행 패널을 숨김 (아래 작은 표시만). 처음 값: 보임
bool progressHidden = false;
bool showHidden = false;
/// X-plore 숨기기 (관리자 지시 10/18): 파일 탐색기 목록에서 숨긴 파일 · 폴더의 경로 (파일 자체는 바꾸지 않는다).
/// 지우기 · 이동 대상에서는 빠지지 않는다. 목록 맨 아래 [숨긴 항목 보이기] 로 다시 본다
List<String> hiddenItems = [];
/// X-plore 맞추기 (관리자 지시 10/20): [목록에서 숨기기] 전에 무엇이 바뀌는지 확인 창 (처음 켬, 창의 [다음부터 묻지 않기] · 환경 설정에서 끔)
bool hideConfirm = true;
/// 마지막으로 연 폴더 (왼쪽 · 오른쪽 창)
List<String> paths = [];
/// 최근에 연 폴더 (내역, 최신이 앞)
List<String> history = [];
@override
void write(Map<String, Object?> j, {required bool plainSecrets}) {
j['explorerLayout'] = layout;
j['explorerOnePaneCopy'] = onePaneCopy;
j['explorerRetapCollapse'] = retapCollapse;
j['explorerTextScale'] = textScale;
j['explorerRowSpacing'] = rowSpacing;
j['batchRenameLast'] = batchRenameLast;
j['explorerRootsInTree'] = rootsInTree;
j['explorerAlwaysMarks'] = alwaysMarks;
j['explorerSwipePanes'] = swipePanes;
j['androidTrashDays'] = androidTrashDays;
j['explorerClick'] = click;
j['explorerStyle'] = style;
j['explorerOrientation'] = orientation;
j['explorerToolbar'] = toolbar;
j['explorerButtons'] = buttons;
j['explorerSort'] = sort;
j['explorerSortDesc'] = sortDesc;
j['explorerSortPerFolder'] = sortPerFolder;
j['explorerSortByKind'] = sortByKind;
j['explorerRemoteThumbs'] = remoteThumbs;
j['explorerRemoteThumbCacheMb'] = remoteThumbCacheMb;
j['explorerKeyOverrides'] = keyOverrides;
j['explorerFolderSorts'] = folderSorts;
j['explorerFolderSize'] = folderSize;
j['explorerDropAction'] = dropAction;
j['explorerProgressHidden'] = progressHidden;
j['explorerShowHidden'] = showHidden;
j['explorerHiddenItems'] = hiddenItems;
j['explorerHideConfirm'] = hideConfirm;
j['explorerPaths'] = paths;
j['explorerHistory'] = history;
// 예전 기본값 (두 창 · 한 번 누르면 선택) 은 사용자가 고른 값이 아니므로 한 번 새 기본값으로 옮긴다
j['explorerV2'] = true;
// E3 (10/22): 선택 규칙 (사용자 지시 모음) 의 처음 값으로 한 번 옮겼음
j['explorerRulesV1'] = true;
j['explorerOrientV3'] = true;
}
@override
void read(Map<String, dynamic> j) {
// 'auto' = 넓은 화면은 두 창, 좁은 화면 (폰 세로) 은 한 창. 예전 기본 'dual' 은 한 번 'auto' 로
onePaneCopy = j['explorerOnePaneCopy'] == 'other' ? 'other' : 'clip';
// E3: 규칙 이전 판이 저장한 false 는 예전 처음 값 - 한 번 규칙 3 (켬) 으로
retapCollapse = j['explorerRulesV1'] == true ? j['explorerRetapCollapse'] != false : true;
// 값이 없거나 틀리면 지금 그대로 (100% · 보통), 범위 밖은 끝으로
textScale = j['explorerTextScale'] is num
? (j['explorerTextScale'] as num).round().clamp(textScaleMin, textScaleMax)
: 100;
rowSpacing = const {'compact', 'normal', 'wide'}.contains(j['explorerRowSpacing']) ? j['explorerRowSpacing'] as String : 'normal';
batchRenameLast = j['batchRenameLast'] is Map ? Map<String, dynamic>.from(j['batchRenameLast'] as Map) : {};
rootsInTree = j['explorerRootsInTree'] != false;
// E3: 규칙 이전 판이 저장한 true 는 예전 처음 값 - 한 번 규칙 4 (끔) 로
alwaysMarks = j['explorerRulesV1'] == true && j['explorerAlwaysMarks'] == true;
swipePanes = j['explorerSwipePanes'] == true;
androidTrashDays = switch (j['androidTrashDays']) { final int d when d >= 0 && d <= 365 => d, _ => 14 };
// E3 (규칙 7 · 결정 3): 처음 값 두 창. 규칙 이전 판의 'auto' 는 예전 처음 값이라 한 번 두 창으로
layout = switch (j['explorerLayout']) {
'single' || 'split' => j['explorerLayout'] as String,
'auto' when j['explorerRulesV1'] == true => 'auto',
_ => 'dual',
};
// 규칙 2: 두 기기 모두 한 번 = 선택. 규칙 이전 Android 의 'open' 은 예전 처음 값이라 한 번 'select' 로
click = j['explorerClick'] == 'open' && (j['explorerRulesV1'] == true || !Platform.isAndroid) ? 'open' : 'select';
style = const ['windows', 'totalcmd'].contains(j['explorerStyle']) ? j['explorerStyle'] as String : 'xplore';
// 118: 예전 기본 '화면 모양 따라' 는 한 번 좌우로 옮긴다 (사용자가 고른 위아래는 그대로)
orientation = j['explorerOrientation'] == 'stacked'
? 'stacked'
: j['explorerOrientation'] == 'auto' && j['explorerOrientV3'] == true
? 'auto'
: 'side';
toolbar = const ['edge', 'hidden'].contains(j['explorerToolbar']) ? j['explorerToolbar'] as String : 'middle';
buttons = [for (final x in (j['explorerButtons'] as List?) ?? const []) '$x'];
sort = const ['date', 'size', 'type'].contains(j['explorerSort']) ? j['explorerSort'] as String : 'name';
sortDesc = j['explorerSortDesc'] as bool? ?? false;
folderSize = const ['sort', 'always', 'off'].contains(j['explorerFolderSize']) ? j['explorerFolderSize'] as String : 'sort';
sortPerFolder = j['explorerSortPerFolder'] == true;
sortByKind = j['explorerSortByKind'] == true;
remoteThumbs = const {'off', 'wifi', 'always'}.contains(j['explorerRemoteThumbs']) ? j['explorerRemoteThumbs'] as String : 'wifi';
keyOverrides = j['explorerKeyOverrides'] is Map
? {for (final e in (j['explorerKeyOverrides'] as Map).entries) if (e.key is String && e.value is String) e.key as String: e.value as String}
: {};
remoteThumbCacheMb = j['explorerRemoteThumbCacheMb'] is num
? (j['explorerRemoteThumbCacheMb'] as num).round().clamp(10, 10000)
: 200;
progressHidden = j['explorerProgressHidden'] == true;
dropAction = const ['ask', 'copy', 'move'].contains(j['explorerDropAction']) ? j['explorerDropAction'] as String : 'ask';
folderSorts = {
if (j['explorerFolderSorts'] is Map)
for (final e in (j['explorerFolderSorts'] as Map).entries)
if (e.key is String && e.value is String) e.key as String: e.value as String,
};
showHidden = j['explorerShowHidden'] as bool? ?? false;
hiddenItems = [for (final x in (j['explorerHiddenItems'] as List?) ?? const []) if (x is String && x.isNotEmpty) x];
hideConfirm = j['explorerHideConfirm'] as bool? ?? true;
paths = [for (final x in (j['explorerPaths'] as List?) ?? const []) '$x'];
history = [for (final x in (j['explorerHistory'] as List?) ?? const []) '$x'];
}
}

View File

@ -55,7 +55,7 @@ class ExplorerDeleteFlow {
var permanent = !canRecycle || shift;
// X-plore 숨기기: 지우는 폴더 안에 목록에서 숨긴 것이 있으면 알린다 (숨긴 것도 함께 지워진다)
final hiddenInside = [
for (final h in ctl.c.settings.explorerHiddenItems)
for (final h in ctl.c.settings.explorer.hiddenItems)
if (paths.any((x) => !samePath(h, x) && isSameOrInside(h, x))) h
].length;
final otherMarks = [for (final x in ctl.panes) if (!identical(x, pane)) x.sel.marked.length].fold(0, (a, b) => a + b);
@ -100,7 +100,7 @@ class ExplorerDeleteFlow {
if (canTrash && !permanent)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(trf('앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).', [ctl.c.settings.androidTrashDays]),
child: Text(trf('앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).', [ctl.c.settings.explorer.androidTrashDays]),
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
),
if (canRecycle)
@ -231,7 +231,7 @@ class ExplorerDeleteFlow {
}
/// X-plore 비교 e: Android 앱 휴지통 (보관 일이 0 이면 null - 바로 영구 삭제)
AppTrash? get appTrash => androidTrash() && ctl.c.settings.androidTrashDays > 0
AppTrash? get appTrash => androidTrash() && ctl.c.settings.explorer.androidTrashDays > 0
? AppTrash([for (final (v, _) in ctl.local) v])
: null;
@ -256,7 +256,7 @@ class ExplorerDeleteFlow {
return;
}
final back = await Navigator.of(context).push<List<String>>(
MaterialPageRoute(builder: (_) => TrashPage(trash: trash, days: ctl.c.settings.androidTrashDays)));
MaterialPageRoute(builder: (_) => TrashPage(trash: trash, days: ctl.c.settings.explorer.androidTrashDays)));
if (back != null && back.isNotEmpty && context.mounted) await ctl.refreshAll(back.map(p.dirname));
}

View File

@ -45,7 +45,7 @@ class ExplorerDrops {
ctl
..active = to
..update();
final mode = ctl.c.settings.explorerDropAction;
final mode = ctl.c.settings.explorer.dropAction;
final move = mode == 'ask' ? await askDropAction(context, load.paths, dest) : mode == 'move';
if (move == null || !context.mounted) return;
// 되돌리기에 쓸 것: 대상에 이미 있던 이름 (합친 폴더는 통째로 되돌리지 않게)

View File

@ -88,7 +88,7 @@ class ExplorerTopBar extends StatelessWidget {
child: Row(children: [Icon(b.icon, size: 18), const SizedBox(width: 12), Text(tr(b.label))]),
),
// X-plore 숨기기: 큰 폴더를 펼쳐 목록 맨 아래가 멀 때도 (관리자 결정 10/18)
if (!ctl.rsync && ctl.c.settings.explorerHiddenItems.isNotEmpty)
if (!ctl.rsync && ctl.c.settings.explorer.hiddenItems.isNotEmpty)
PopupMenuItem(
value: 'appHidden',
child: Row(children: [
@ -97,7 +97,7 @@ class ExplorerTopBar extends StatelessWidget {
Flexible(
child: Text(ctl.showAppHidden
? tr('목록에서 숨긴 항목 다시 숨기기')
: trf('목록에서 숨긴 항목 보이기 ({0}개)', [ctl.c.settings.explorerHiddenItems.length])),
: trf('목록에서 숨긴 항목 보이기 ({0}개)', [ctl.c.settings.explorer.hiddenItems.length])),
),
]),
),

View File

@ -28,7 +28,7 @@ class ExplorerPanesBody extends StatelessWidget {
@override
Widget build(BuildContext context) {
final s = ctl.c.settings;
final shape = '${s.explorerStyle}|${s.explorerLayout}|${s.explorerOrientation}|${s.explorerToolbar}';
final shape = '${s.explorer.style}|${s.explorer.layout}|${s.explorer.orientation}|${s.explorer.toolbar}';
if (ctl.ready && shape != layout.shape) {
if (layout.shape.isNotEmpty) {
for (final pane in ctl.panes) {
@ -41,10 +41,10 @@ class ExplorerPanesBody extends StatelessWidget {
return LayoutBuilder(builder: (context, box) {
// 두 창이 다 들어가는지: 트리 (화살표 · 아이콘 · 여백 78 + 이름 40) + 목록 (아이콘 · ✔ · 여백 104 + 이름 50), 이름은 글자 배율만큼.
// 1배면 360 폭에도 들어가고, 기기 글자 1.3 × 150% (약 2배) 면 412 폭에도 모자란다
final wantSplit = !ctl.rsync && s.explorerLayout == 'split';
final wantSplit = !ctl.rsync && s.explorer.layout == 'split';
final textScale = MediaQuery.textScalerOf(context).scale(14) / 14 * layout.listTextScale;
final barW = s.explorerToolbar == 'hidden' ? 0.0 : 72.0;
final sideNow = switch (s.explorerOrientation) { 'side' => true, 'stacked' => false, _ => box.maxWidth >= box.maxHeight };
final barW = s.explorer.toolbar == 'hidden' ? 0.0 : 72.0;
final sideNow = switch (s.explorer.orientation) { 'side' => true, 'stacked' => false, _ => box.maxWidth >= box.maxHeight };
final tightWas = layout.splitTight;
layout.splitTight = wantSplit && sideNow && box.maxWidth - barW < 182 + 90 * textScale;
// 바뀌면 이 builder 밖 (버튼 이름 "두 창" 등) 도 한 번 다시 그린다
@ -57,7 +57,7 @@ class ExplorerPanesBody extends StatelessWidget {
// 폭이 바뀌면 (좁은 한 창 ↔ 두 창) 이 builder 만 다시 불린다 - 그때도 새로 붙은 창을 보이게
if (ctl.ready) layout.revealNewViews();
final side = !(dual || split) ||
switch (s.explorerOrientation) {
switch (s.explorer.orientation) {
'side' => true,
'stacked' => false,
_ => box.maxWidth >= box.maxHeight,
@ -66,13 +66,13 @@ class ExplorerPanesBody extends StatelessWidget {
// (두 창 + 가운데 버튼 줄이면 창이 140px 남짓이라 폴더 이름이 잘렸다)
final rsyncOne = ctl.rsync && side && box.maxWidth < rsyncTwoPaneMinWidth;
layout.narrowSplit = split && side && box.maxWidth < ExplorerLayout.narrowSplitWidth;
final bar = s.explorerToolbar == 'hidden' && !ctl.rsync
final bar = s.explorer.toolbar == 'hidden' && !ctl.rsync
? null
: ExplorerToolbar(
// X-plore b: 길게 누르기 = 짝 동작 (Rsync 화면은 없음)
onLongPress: ctl.rsync ? null : buttons.longPress,
// Rsync 화면: 늘 같은 버튼 (→ ← ⇄ 모니터링) 을 두 창 사이에
buttons: ctl.rsync ? ExplorerButton.rsyncButtons : ExplorerButton.fromSettings(s.explorerButtons),
buttons: ctl.rsync ? ExplorerButton.rsyncButtons : ExplorerButton.fromSettings(s.explorer.buttons),
vertical: side && !rsyncOne,
panesSide: side,
onPressed: buttons.press,
@ -81,12 +81,12 @@ class ExplorerPanesBody extends StatelessWidget {
_ => true,
},
selected: (b) =>
(b == ExplorerButton.hidden && s.explorerShowHidden) || (b == ExplorerButton.select && sel.selecting),
(b == ExplorerButton.hidden && s.explorer.showHidden) || (b == ExplorerButton.select && sel.selecting),
twoPanes: dual || split,
pinned: {ExplorerButton.orient, if (flows.transfers.clip.isNotEmpty) ExplorerButton.paste},
);
final middle = bar != null && (ctl.rsync || s.explorerToolbar == 'middle');
final edge = bar != null && !ctl.rsync && s.explorerToolbar == 'edge';
final middle = bar != null && (ctl.rsync || s.explorer.toolbar == 'middle');
final edge = bar != null && !ctl.rsync && s.explorer.toolbar == 'edge';
// 180: 창마다 같은 열쇠 - 가로 ↔ 세로 배치가 바뀌어도 (창 크기 · 작업 현황이 열림) 창을 새로 만들지 않는다 (스크롤이 맨 위로 가지 않게)
Widget keep(int k, Widget w) => KeyedSubtree(key: layout.viewKeys[k], child: w);
if (rsyncOne) {

View File

@ -225,14 +225,14 @@ class ExplorerButtons {
case ExplorerButton.sort:
await flows.dialogs.sortDialog(cmd.context);
case ExplorerButton.hidden:
await ctl.c.updateSettings((s) => s.explorerShowHidden = !s.explorerShowHidden);
await ctl.c.updateSettings((s) => s.explorer.showHidden = !s.explorer.showHidden);
for (final x in ctl.panes) {
x.cache.clear();
for (final d in x.expanded.toList()) {
await ctl.load(x, d, force: true);
}
}
ctl.host.snack(ctl.c.settings.explorerShowHidden ? tr('숨은 항목을 보입니다.') : tr('숨은 항목을 감춥니다.'));
ctl.host.snack(ctl.c.settings.explorer.showHidden ? tr('숨은 항목을 보입니다.') : tr('숨은 항목을 감춥니다.'));
case ExplorerButton.history:
await flows.dialogs.historyDialog(cmd.context, pane);
case ExplorerButton.bookmarks:

View File

@ -147,7 +147,7 @@ class ExplorerController extends ChangeNotifier {
final s = sizes.of(e.path);
if (s != null) return s.label;
if (sizes.busy(e.path)) return '…';
final mode = c.settings.explorerFolderSize;
final mode = c.settings.explorer.folderSize;
if ((mode == 'always' || (mode == 'sort' && sortOf(q.current).$1 == 'size')) &&
samePath(vDirname(e.path), q.current) &&
!isDav(e.path)) {
@ -165,7 +165,7 @@ class ExplorerController extends ChangeNotifier {
Future<void> load(ExplorerPane q, String dir, {bool force = false}) async {
final r = await loadDir(q, dir,
force: force,
showHidden: c.settings.explorerShowHidden,
showHidden: c.settings.explorer.showHidden,
sort: sorted,
inside: _insideArchiveList,
osBlocked: (d) => isAndroidRestricted(d) ? androidRestrictedError : null,
@ -272,15 +272,15 @@ class ExplorerController extends ChangeNotifier {
/// [dir] 의 정렬 (기준, 거꾸로) - 폴더마다 기억한 것 → 종류별 기본 정렬 → 전체 정렬
(String, bool) sortOf(String dir, {List<FileEntry>? list}) {
final s = c.settings;
if (s.explorerSortPerFolder || s.explorerSortByKind) {
final v = s.explorerFolderSorts[_sortKey(dir)];
if (s.explorer.sortPerFolder || s.explorer.sortByKind) {
final v = s.explorer.folderSorts[_sortKey(dir)];
if (v != null && v.contains('|')) return (v.split('|').first, v.endsWith('|1'));
}
if (s.explorerSortByKind) {
if (s.explorer.sortByKind) {
final k = _kindSort(dir, list: list);
if (k != null) return k;
}
return (s.explorerSort, s.explorerSortDesc);
return (s.explorer.sort, s.explorer.sortDesc);
}
/// X-plore 비교 5-1 ①: 파일의 반 넘게가 사진 · 동영상이면 날짜 (새것 먼저), 음악이면 이름, 그 밖은 null
@ -301,19 +301,19 @@ class ExplorerController extends ChangeNotifier {
/// 정렬을 바꾼다 - "폴더마다 기억" (또는 종류별 정렬이 쓰이는 폴더) 이면 [dir] 에만, 아니면 전체. 모든 창을 다시 줄 세운다
Future<void> setSort(String dir, String by, bool desc) async {
final kindHere = c.settings.explorerSortByKind && _kindSort(dir) != null;
final kindHere = c.settings.explorer.sortByKind && _kindSort(dir) != null;
await c.updateSettings((x) {
if (x.explorerSortPerFolder || kindHere) {
final m = Map<String, String>.of(x.explorerFolderSorts)..remove(_sortKey(dir));
if (x.explorer.sortPerFolder || kindHere) {
final m = Map<String, String>.of(x.explorer.folderSorts)..remove(_sortKey(dir));
m[_sortKey(dir)] = '$by|${desc ? 1 : 0}';
while (m.length > 300) {
m.remove(m.keys.first);
}
x.explorerFolderSorts = m;
x.explorer.folderSorts = m;
} else {
x
..explorerSort = by
..explorerSortDesc = desc;
..explorer.sort = by
..explorer.sortDesc = desc;
}
});
resortAll();
@ -409,18 +409,18 @@ class ExplorerController extends ChangeNotifier {
if (rsync) {
unawaited(c.updateSettings((s) => s.rsyncPaths = paths));
} else {
final hist = [dir, ...c.settings.explorerHistory.where((h) => !samePath(h, dir))].take(30).toList();
final hist = [dir, ...c.settings.explorer.history.where((h) => !samePath(h, dir))].take(30).toList();
unawaited(c.updateSettings((s) => s
..explorerPaths = paths
..explorerHistory = hist));
..explorer.paths = paths
..explorer.history = hist));
}
if (i >= 0) active = i;
}
// ───────── 줄 목록 · 목록에서 숨기기 ─────────
ExplorerStyle get look => ExplorerStyle.of(c.settings.explorerStyle);
bool get rootsInTree => !rsync && look == ExplorerStyle.xplore && c.settings.explorerRootsInTree;
ExplorerStyle get look => ExplorerStyle.of(c.settings.explorer.style);
bool get rootsInTree => !rsync && look == ExplorerStyle.xplore && c.settings.explorer.rootsInTree;
List<TreeRow> rows(ExplorerPane q, {bool foldersOnly = false}) => buildTreeRows(q,
foldersOnly: foldersOnly,
@ -430,12 +430,12 @@ class ExplorerController extends ChangeNotifier {
showHidden: showAppHidden,
browsable: arc.contains);
bool appHidden(String path) => hidden.isHidden(c.settings.explorerHiddenItems, path);
bool appHidden(String path) => hidden.isHidden(c.settings.explorer.hiddenItems, path);
/// 옮기거나 이름을 바꾼 것은 숨긴 목록도 새 경로로, 지운 것은 목록에서 뺀다 (관리자 결정 10/18)
Future<void> followHidden({Iterable<(String, String)> moved = const [], Iterable<String> gone = const []}) async {
final out = await HiddenList.follow(c.settings.explorerHiddenItems, moved: moved, gone: gone);
if (out != null) await c.updateSettings((s) => s.explorerHiddenItems = out);
final out = await HiddenList.follow(c.settings.explorer.hiddenItems, moved: moved, gone: gone);
if (out != null) await c.updateSettings((s) => s.explorer.hiddenItems = out);
}
void toggleAppHidden() {

View File

@ -40,7 +40,7 @@ class ExplorerJobs extends ChangeNotifier {
/// X-plore 숨기기: 옮긴 것은 숨긴 목록도 새 자리로 (원래 자리에 없고 새 자리에 있는 것만 - 같은 이름 바꾸기 · 실패는 그대로)
Future<void> followMoved(TransferJob job) async {
if (!job.move || job.contents || ctl.c.settings.explorerHiddenItems.isEmpty) return;
if (!job.move || job.contents || ctl.c.settings.explorer.hiddenItems.isEmpty) return;
final moved = <(String, String)>[];
for (final s in job.sources) {
final to = vJoin(job.dest, vBasename(s));

View File

@ -52,11 +52,11 @@ class ExplorerLayout {
});
/// X-plore 비교 40: 목록 글자 크기 (설정 %) · 줄 간격을 넣은 줄 높이
double get listTextScale => ctl.c.settings.explorerTextScale / 100;
double rowHeight() => ctl.look.rowHeightOf(context(), textScale: listTextScale, spacing: rowSpacingFactor(ctl.c.settings.explorerRowSpacing));
double get listTextScale => ctl.c.settings.explorer.textScale / 100;
double rowHeight() => ctl.look.rowHeightOf(context(), textScale: listTextScale, spacing: rowSpacingFactor(ctl.c.settings.explorer.rowSpacing));
/// 왼쪽 폴더 트리 + 오른쪽 파일 목록 배치
bool get split => !ctl.rsync && ctl.c.settings.explorerLayout == 'split' && !splitTight;
bool get split => !ctl.rsync && ctl.c.settings.explorer.layout == 'split' && !splitTight;
/// 관리자 지시 10/20 (28 검토): 글자가 커서 (기기 글자 × 목록 글자 크기) 좁은 화면에 트리 · 목록 두 창이 다 들어가지 않으면
/// 그동안만 한 창 (트리 + 위 경로 막대) 으로 보인다 - 고른 배치 (설정) 는 그대로, 화면이 넓어지거나 글자가 작아지면 다시 두 창. [ExplorerPanesBody] 가 정한다
@ -70,18 +70,18 @@ class ExplorerLayout {
/// 좌우 두 창 (Rsync 화면은 늘)
bool get dual =>
ctl.rsync ||
ctl.c.settings.explorerLayout == 'dual' ||
ctl.c.settings.explorer.layout == 'dual' ||
// 자동: 넓으면 두 창, 좁은 화면 (폰 세로) 은 한 창 - 같은 목록이 반씩 잘려 두 번 보이지 않게
(ctl.c.settings.explorerLayout == 'auto' && !isCompact(context()));
(ctl.c.settings.explorer.layout == 'auto' && !isCompact(context()));
/// 트리 줄. [foldersOnly]: 폴더만 ("폴더 + 파일 목록" 배치의 왼쪽)
/// X-plore 비교 c: 트리 맨 위에 저장 장치 · 서버 · 즐겨찾기 (X-plore 스타일 · 설정)
/// X-plore 비교 ⚖: X-plore 스타일에서 표시 동그라미 늘 보이기 (설정)
bool get alwaysMarks => !ctl.rsync && ctl.look == ExplorerStyle.xplore && ctl.c.settings.explorerAlwaysMarks;
bool get alwaysMarks => !ctl.rsync && ctl.look == ExplorerStyle.xplore && ctl.c.settings.explorer.alwaysMarks;
/// X-plore 비교 ⚖: X-plore 스타일 탐색기 안에서 밀기 = 창 바꾸기 (설정)
bool get swipePanes => !ctl.rsync && ctl.look == ExplorerStyle.xplore && ctl.c.settings.explorerSwipePanes;
bool get swipePanes => !ctl.rsync && ctl.look == ExplorerStyle.xplore && ctl.c.settings.explorer.swipePanes;
/// 밀어서 창 바꾸기: 두 창이면 다른 창을 활성으로, 한 창이면 숨은 창과 바꿔 보인다
bool switchPane(int dir) {
@ -104,5 +104,5 @@ class ExplorerLayout {
ExplorerPane? get other => dual || hiddenTarget ? ctl.panes[1 - ctl.active] : null;
/// X-plore 비교 a: 한 창인데 "숨은 다른 창으로" 복사 · 이동 (위에 갈 곳 줄)
bool get hiddenTarget => !ctl.rsync && !dual && !split && ctl.c.settings.explorerOnePaneCopy == 'other';
bool get hiddenTarget => !ctl.rsync && !dual && !split && ctl.c.settings.explorer.onePaneCopy == 'other';
}

View File

@ -30,7 +30,7 @@ class ExplorerSelect extends ChangeNotifier {
bool selecting = false;
/// 한 번 누르면 선택 · 두 번 누르면 열기 (아니면 한 번에 바로 열기)
bool get selectMode => ctl.c.settings.explorerClick != 'open';
bool get selectMode => ctl.c.settings.explorer.click != 'open';
/// 마지막으로 누른 폴더 · 시각 (두 번 누르기의 두 번째를 가리려고)
(String, DateTime)? _lastFolderTap;
@ -83,7 +83,7 @@ class ExplorerSelect extends ChangeNotifier {
list: list,
repeat: repeat,
tapOpens: !selectMode,
retapCollapses: ctl.c.settings.explorerRetapCollapse);
retapCollapses: ctl.c.settings.explorer.retapCollapse);
await _act(pane, row, act, list: list);
}

View File

@ -112,7 +112,7 @@ Future<bool> confirmAppHide(BuildContext context, ExplorerController ctl, List<S
),
);
if (ok != true) return false;
if (dontAsk) await ctl.c.updateSettings((s) => s.explorerHideConfirm = false);
if (dontAsk) await ctl.c.updateSettings((s) => s.explorer.hideConfirm = false);
return true;
}

View File

@ -233,9 +233,9 @@ class ExplorerItemMenu {
pane.changed();
}
case 'appHide':
if (ctl.c.settings.explorerHideConfirm && !await confirmAppHide(context, ctl, targets)) return;
await ctl.c.updateSettings((s) => s.explorerHiddenItems = [
...s.explorerHiddenItems,
if (ctl.c.settings.explorer.hideConfirm && !await confirmAppHide(context, ctl, targets)) return;
await ctl.c.updateSettings((s) => s.explorer.hiddenItems = [
...s.explorer.hiddenItems,
for (final x in targets)
if (!ctl.appHidden(x)) x,
]);
@ -249,8 +249,8 @@ class ExplorerItemMenu {
: trf('목록에서 숨겼습니다 ({0}개). 파일은 그대로이고, 목록 맨 아래의 [숨긴 항목 보이기] 로 다시 볼 수 있습니다.', [targets.length]));
case 'appUnhide':
final keys = {for (final x in targets) HiddenList.keyOf(x)};
await ctl.c.updateSettings((s) => s.explorerHiddenItems = [
for (final x in s.explorerHiddenItems)
await ctl.c.updateSettings((s) => s.explorer.hiddenItems = [
for (final x in s.explorer.hiddenItems)
if (!keys.contains(HiddenList.keyOf(x))) x,
]);
for (final q in ctl.panes) {

View File

@ -117,7 +117,7 @@ class ExplorerInput {
if (const {'delete', 'deletePermanent', 'rename', 'refresh', 'filter', 'up', 'back', 'forward', 'switchPane'}.contains(e.key))
e.key: e.value,
}
: effectiveExplorerKeys(tc: _tcKeys, overrides: ctl.c.settings.explorerKeyOverrides);
: effectiveExplorerKeys(tc: _tcKeys, overrides: ctl.c.settings.explorer.keyOverrides);
return {
for (final e in keys.entries)
if (cb[e.key] != null)

View File

@ -131,7 +131,7 @@ class ExplorerOpener {
// 폴더를 읽지 못하면 (E10: 이제 오류로 알림) 고른 그림 하나만
List<FileEntry> list;
try {
list = pane?.cache[dir] ?? ctl.sorted(await listEntries(dir, showHidden: ctl.c.settings.explorerShowHidden), dir);
list = pane?.cache[dir] ?? ctl.sorted(await listEntries(dir, showHidden: ctl.c.settings.explorer.showHidden), dir);
} catch (_) {
list = const [];
}
@ -150,7 +150,7 @@ class ExplorerOpener {
Future<void> slideshow(BuildContext context, ExplorerPane pane, String dir) async {
List<FileEntry> list;
try {
list = pane.cache[dir] ?? ctl.sorted(await listEntries(dir, showHidden: ctl.c.settings.explorerShowHidden), dir);
list = pane.cache[dir] ?? ctl.sorted(await listEntries(dir, showHidden: ctl.c.settings.explorer.showHidden), dir);
} catch (e) {
ctl.host.snack(trf('폴더를 읽을 수 없습니다: {0}', [e]));
return;

View File

@ -51,8 +51,8 @@ class ExplorerRenames {
});
final done = await showBatchRename(context, ordered,
isDir: vIsDirSync,
initial: RenameRule.fromJson(ctl.c.settings.batchRenameLast),
onRun: (r) => ctl.c.updateSettings((s) => s.batchRenameLast = r.toJson()));
initial: RenameRule.fromJson(ctl.c.settings.explorer.batchRenameLast),
onRun: (r) => ctl.c.updateSettings((s) => s.explorer.batchRenameLast = r.toJson()));
if (done == null || done.isEmpty || !context.mounted) return;
await _after(pane, done);
if (!context.mounted) return;

View File

@ -299,7 +299,7 @@ class ExplorerRowView extends StatelessWidget {
if (up) return Icon(Icons.arrow_upward, size: look.iconSize, color: JjColors.textDim);
if (isRoot) return Icon(isDav(e.path) ? Icons.cloud : Icons.sd_storage, color: JjColors.accent, size: look.iconSize - 2);
// X-plore 비교 5-1 ② (관리자 지시 10/21): 원격은 설정 (처음 값 Wi-Fi 에서만) 에 따라 받아서 - 받기 전 · 못 받으면 아이콘
if (look.rich && isDav(e.path) && ctl.c.settings.explorerRemoteThumbs != 'off' && remoteThumbKind(e)) {
if (look.rich && isDav(e.path) && ctl.c.settings.explorer.remoteThumbs != 'off' && remoteThumbKind(e)) {
final (icon, color) = fileIcon(look, e, open: open);
return RemoteThumb(
key: ValueKey('rthumb:${e.path}'), c: ctl.c, entry: e, fallback: Icon(icon, size: 28, color: color));

View File

@ -85,7 +85,7 @@ class ExplorerSearch extends ChangeNotifier {
builder: (_) => SearchResultsPage(
root: root,
cond: cond,
showHidden: ctl.c.settings.explorerShowHidden,
showHidden: ctl.c.settings.explorer.showHidden,
otherDir: other,
onOpen: (e) => opener.open(context, e.path, pane: pane),
initial: initial,

View File

@ -21,7 +21,7 @@ class ExplorerTransferPanel extends StatelessWidget {
@override
Widget build(BuildContext context) {
// 85: 숨겼으면 오른쪽 아래 작은 표시 "복사 · 이동 n개 · 45%" (누르면 다시 펼침)
if (jobs.isNotEmpty && c.settings.explorerProgressHidden) {
if (jobs.isNotEmpty && c.settings.explorer.progressHidden) {
return Align(
alignment: Alignment.bottomRight,
child: Padding(
@ -39,7 +39,7 @@ class ExplorerTransferPanel extends StatelessWidget {
running: run.length,
percent: pct,
failed: failed,
onTap: () => c.updateSettings((x) => x.explorerProgressHidden = false));
onTap: () => c.updateSettings((x) => x.explorer.progressHidden = false));
},
),
),
@ -69,7 +69,7 @@ class ExplorerTransferPanel extends StatelessWidget {
height: 28,
child: TextButton.icon(
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
onPressed: () => c.updateSettings((x) => x.explorerProgressHidden = true),
onPressed: () => c.updateSettings((x) => x.explorer.progressHidden = true),
icon: const Icon(Icons.expand_more, size: 18),
label: Text(tr('숨기기'), style: const TextStyle(fontSize: 12)),
),

View File

@ -38,7 +38,7 @@ class ExplorerViewDialogs {
ChoiceChip(label: Text(label), selected: by == v, onSelected: (_) => set(() => by = v)),
]),
SwitchListTile(value: desc, title: Text(tr('거꾸로')), onChanged: (x) => set(() => desc = x)),
if (ctl.c.settings.explorerSortPerFolder)
if (ctl.c.settings.explorer.sortPerFolder)
Text(trf('이 폴더에만: {0}', [ctl.displayPath(dir)]), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
]),
actions: [
@ -74,7 +74,7 @@ class ExplorerViewDialogs {
}
Future<void> historyDialog(BuildContext context, ExplorerPane pane) async {
final hist = ctl.c.settings.explorerHistory.where(reachable).toList();
final hist = ctl.c.settings.explorer.history.where(reachable).toList();
final pick = await showDialog<String>(
context: context,
builder: (ctx) => SimpleDialog(
@ -97,17 +97,17 @@ class ExplorerViewDialogs {
final s = ctl.c.settings;
if (!twoPanes()) {
await ctl.c.updateSettings((x) => x
..explorerLayout = 'dual'
..explorerOrientation = 'side');
..explorer.layout = 'dual'
..explorer.orientation = 'side');
} else {
// 지금 보이는 배치의 반대 ('화면 모양 따라' 였으면 지금 모양에서 정함)
final size = MediaQuery.sizeOf(context);
final nowSide = switch (s.explorerOrientation) {
final nowSide = switch (s.explorer.orientation) {
'side' => true,
'stacked' => false,
_ => size.width >= size.height,
};
await ctl.c.updateSettings((x) => x.explorerOrientation = nowSide ? 'stacked' : 'side');
await ctl.c.updateSettings((x) => x.explorer.orientation = nowSide ? 'stacked' : 'side');
}
if (!context.mounted) return;
ctl.update();
@ -118,7 +118,7 @@ class ExplorerViewDialogs {
Future<void> layoutDialog(BuildContext context) async {
final s = ctl.c.settings;
var layout = s.explorerLayout, orient = s.explorerOrientation, bar = s.explorerToolbar;
var layout = s.explorer.layout, orient = s.explorer.orientation, bar = s.explorer.toolbar;
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
@ -181,9 +181,9 @@ class ExplorerViewDialogs {
);
if (ok != true) return;
await ctl.c.updateSettings((x) => x
..explorerLayout = layout
..explorerOrientation = orient
..explorerToolbar = bar);
..explorer.layout = layout
..explorer.orientation = orient
..explorer.toolbar = bar);
if (layout == 'single') {
ctl
..active = 0
@ -196,7 +196,7 @@ class ExplorerViewDialogs {
}
Future<void> buttonsDialog(BuildContext context) async {
final current = ExplorerButton.fromSettings(ctl.c.settings.explorerButtons);
final current = ExplorerButton.fromSettings(ctl.c.settings.explorer.buttons);
final order = [...current, ...ExplorerButton.explorerValues.where((b) => !current.contains(b))];
final on = {...current};
final ok = await showDialog<bool>(
@ -251,6 +251,6 @@ class ExplorerViewDialogs {
);
if (ok != true) return;
final names = [for (final b in order) if (on.contains(b)) b.name];
await ctl.c.updateSettings((x) => x.explorerButtons = names.isEmpty ? [ExplorerButton.layout.name] : names);
await ctl.c.updateSettings((x) => x.explorer.buttons = names.isEmpty ? [ExplorerButton.layout.name] : names);
}
}

View File

@ -16,7 +16,7 @@ class ExplorerKeysPage extends StatelessWidget {
static Future<void> open(BuildContext context, AppController c) =>
Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => ExplorerKeysPage(c: c)));
bool get _tc => ExplorerStyle.of(c.settings.explorerStyle) == ExplorerStyle.totalcmd;
bool get _tc => ExplorerStyle.of(c.settings.explorer.style) == ExplorerStyle.totalcmd;
Future<void> _change(BuildContext context, String id) async {
final picked = await showDialog<SingleActivator?>(
@ -24,7 +24,7 @@ class ExplorerKeysPage extends StatelessWidget {
builder: (_) => _KeyCaptureDialog(c: c, action: id, tc: _tc),
);
if (picked == null) return;
await c.updateSettings((x) => x.explorerKeyOverrides = {...x.explorerKeyOverrides, id: _none(picked) ? '' : keySpec(picked)});
await c.updateSettings((x) => x.explorer.keyOverrides = {...x.explorer.keyOverrides, id: _none(picked) ? '' : keySpec(picked)});
}
static bool _none(SingleActivator a) => a.trigger == LogicalKeyboardKey.abort;
@ -33,7 +33,7 @@ class ExplorerKeysPage extends StatelessWidget {
Widget build(BuildContext context) => ListenableBuilder(
listenable: c,
builder: (context, _) {
final ov = c.settings.explorerKeyOverrides;
final ov = c.settings.explorer.keyOverrides;
final keys = effectiveExplorerKeys(tc: _tc, overrides: ov);
return Scaffold(
appBar: AppBar(
@ -41,7 +41,7 @@ class ExplorerKeysPage extends StatelessWidget {
actions: [
if (ov.isNotEmpty)
TextButton(
onPressed: () => c.updateSettings((x) => x.explorerKeyOverrides = {}),
onPressed: () => c.updateSettings((x) => x.explorer.keyOverrides = {}),
child: Text(tr('모두 처음 값으로')),
),
],
@ -51,7 +51,7 @@ class ExplorerKeysPage extends StatelessWidget {
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(
trf('지금 스타일 ({0}) 의 키입니다. 바꾼 키는 모든 스타일에 쓰이고, 다른 동작과 겹치면 그 동작의 키는 없어집니다. Esc 는 바꿀 수 없습니다',
[ExplorerStyle.of(c.settings.explorerStyle).label]),
[ExplorerStyle.of(c.settings.explorer.style).label]),
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
),
for (final id in explorerKeyActions)
@ -65,7 +65,7 @@ class ExplorerKeysPage extends StatelessWidget {
trailing: Wrap(spacing: 4, children: [
if (ov.containsKey(id))
TextButton(
onPressed: () => c.updateSettings((x) => x.explorerKeyOverrides = {...x.explorerKeyOverrides}..remove(id)),
onPressed: () => c.updateSettings((x) => x.explorer.keyOverrides = {...x.explorer.keyOverrides}..remove(id)),
child: Text(tr('처음 값으로')),
),
OutlinedButton(onPressed: () => _change(context, id), child: Text(tr('바꾸기'))),
@ -101,7 +101,7 @@ class _KeyCaptureDialogState extends State<_KeyCaptureDialog> {
@override
Widget build(BuildContext context) {
final k = _key;
final clash = k == null ? null : keyConflict(widget.action, k, tc: widget.tc, overrides: widget.c.settings.explorerKeyOverrides);
final clash = k == null ? null : keyConflict(widget.action, k, tc: widget.tc, overrides: widget.c.settings.explorer.keyOverrides);
return AlertDialog(
title: Text(trf('{0} - 단축키', [explorerKeyLabel(widget.action)])),
content: Focus(

View File

@ -148,8 +148,8 @@ class _ExplorerPageState extends State<ExplorerPage> implements ExplorerHost, Ex
pane.addListener(_onPane);
}
_resumeReload = AppLifecycleListener(onResume: () => unawaited(_ctl.reloadOpen(localOnly: true)));
_hiddenSeen = c.settings.explorerHiddenItems;
_kindSeen = c.settings.explorerSortByKind;
_hiddenSeen = c.settings.explorer.hiddenItems;
_kindSeen = c.settings.explorer.sortByKind;
c.addListener(_onSettings);
_init();
}
@ -182,9 +182,9 @@ class _ExplorerPageState extends State<ExplorerPage> implements ExplorerHost, Ex
_ctl.local = await widget.device.listVolumes();
if (_ctl.local.isEmpty) _ctl.local = [(Platform.isWindows ? r'C:\' : '/', Platform.isWindows ? 'C:' : '/')];
// X-plore 비교 e: 보관 일이 지난 휴지통 항목을 지운다 (뒤에서)
if (_flows.deletes.appTrash case final t?) unawaited(t.purgeOlderThan(c.settings.androidTrashDays).catchError((_) => 0));
if (_flows.deletes.appTrash case final t?) unawaited(t.purgeOlderThan(c.settings.explorer.androidTrashDays).catchError((_) => 0));
if (Platform.isAndroid) unawaited(_access.checkAccess());
final saved = widget.rsync ? c.settings.rsyncPaths : c.settings.explorerPaths;
final saved = widget.rsync ? c.settings.rsyncPaths : c.settings.explorer.paths;
// 창마다 따로 읽는다: 한 창이 WebDAV 서버를 기다려도 다른 창 (이 기기 파일) 은 바로 보이게
// 165: 지난 폴더가 느린 네트워크 드라이브여도 화면이 멈추지 않게 (뒤에서 확인, 오래 걸리면 그냥 열어 본다 - 못 읽으면 그 창에 이유)
final reach = await Future.wait(
@ -238,7 +238,7 @@ class _ExplorerPageState extends State<ExplorerPage> implements ExplorerHost, Ex
void _onSettings() {
if (!mounted) return;
if (_kindSeen != null && _kindSeen != c.settings.explorerSortByKind) {
if (_kindSeen != null && _kindSeen != c.settings.explorer.sortByKind) {
for (final pane in _ctl.panes) {
for (final k in pane.cache.keys.toList()) {
pane.cache[k] = _ctl.sorted(pane.cache[k]!, k);
@ -246,8 +246,8 @@ class _ExplorerPageState extends State<ExplorerPage> implements ExplorerHost, Ex
pane.changed();
}
}
_kindSeen = c.settings.explorerSortByKind;
final now = c.settings.explorerHiddenItems;
_kindSeen = c.settings.explorer.sortByKind;
final now = c.settings.explorer.hiddenItems;
if (identical(now, _hiddenSeen)) return;
_hiddenSeen = now;
setState(() {});

View File

@ -12,10 +12,10 @@ import 'theme.dart';
Future<void> showHiddenItems(BuildContext context, AppController c) => showDialog<void>(
context: context,
builder: (ctx) => StatefulBuilder(builder: (ctx, set) {
final list = c.settings.explorerHiddenItems;
final list = c.settings.explorer.hiddenItems;
Future<void> unhide(List<String> which) async {
await c.updateSettings((s) => s.explorerHiddenItems = [
for (final x in s.explorerHiddenItems)
await c.updateSettings((s) => s.explorer.hiddenItems = [
for (final x in s.explorer.hiddenItems)
if (!which.any((w) => samePath(w, x))) x,
]);
set(() {});

View File

@ -23,7 +23,7 @@ Future<RemoteThumbs> remoteThumbsOf(AppController c) => _hub ??= () async {
final dir = Directory(p.join(await c.services.storage.tempDirectory(), 'jj_rthumbs'));
final h = RemoteThumbs(
dir: dir,
maxBytes: () => c.settings.explorerRemoteThumbCacheMb * 1024 * 1024,
maxBytes: () => c.settings.explorer.remoteThumbCacheMb * 1024 * 1024,
allowed: () => remoteThumbsAllowedNow(c),
make: (path, out) => _make(c, path, out),
);
@ -75,7 +75,7 @@ bool _metered = true;
/// 지금 받아도 되는지: 'off' 아니오 · 'always' 예 · 'wifi' (처음 값) 는 데이터 요금제가 아닌 망에서만 (10초 동안 기억)
Future<bool> remoteThumbsAllowedNow(AppController c) async {
switch (c.settings.explorerRemoteThumbs) {
switch (c.settings.explorer.remoteThumbs) {
case 'off':
return false;
case 'always':

View File

@ -56,13 +56,13 @@ class _FilesSettingsSectionState extends State<FilesSettingsSection> {
subtitle: Text(tr('길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). '
'Ctrl + 클릭: 여러 개 고르기')),
trailing: DropdownButton<String>(
value: s.explorerClick,
value: s.explorer.click,
items: [
DropdownMenuItem(
value: 'select', child: Text(desk ? tr('한 번: 선택 · 두 번: 실행 (기본)') : tr('한 번: 선택 · 두 번: 실행'))),
DropdownMenuItem(value: 'open', child: Text(desk ? tr('한 번: 바로 실행') : tr('한 번: 바로 실행 (기본)'))),
],
onChanged: (v) => c.updateSettings((x) => x.explorerClick = v!),
onChanged: (v) => c.updateSettings((x) => x.explorer.click = v!),
),
),
// 82: 창 사이 끌어 놓기 (처음: 묻기)
@ -70,13 +70,13 @@ class _FilesSettingsSectionState extends State<FilesSettingsSection> {
title: Text(tr('끌어 놓기 기본')),
subtitle: Text(tr('다른 창으로 끌어 놓았을 때. 어느 쪽이든 복사 · 이동 확인 창 (같은 이름 등) 은 그대로 뜹니다')),
trailing: DropdownButton<String>(
value: s.explorerDropAction,
value: s.explorer.dropAction,
items: [
DropdownMenuItem(value: 'ask', child: Text(tr('묻기 (기본)'))),
DropdownMenuItem(value: 'copy', child: Text(tr('복사'))),
DropdownMenuItem(value: 'move', child: Text(tr('이동'))),
],
onChanged: (v) => c.updateSettings((x) => x.explorerDropAction = v ?? 'ask'),
onChanged: (v) => c.updateSettings((x) => x.explorer.dropAction = v ?? 'ask'),
),
),
// 84: 정렬을 폴더마다 기억 (처음: 끔)
@ -84,8 +84,8 @@ class _FilesSettingsSectionState extends State<FilesSettingsSection> {
title: Text(tr('정렬을 폴더마다 기억')),
subtitle: Text(tr('켜면 열 머리 · 정렬 기준에서 바꾼 정렬이 그 폴더에만 쓰입니다. 끄면 모든 폴더가 같은 정렬입니다')),
trailing: Switch(
value: s.explorerSortPerFolder,
onChanged: (v) => c.updateSettings((x) => x.explorerSortPerFolder = v),
value: s.explorer.sortPerFolder,
onChanged: (v) => c.updateSettings((x) => x.explorer.sortPerFolder = v),
),
),
// X-plore 비교 5-2 (사용자 결정 10/21): 시스템 폴더 보기 (Shizuku, 읽기 전용) - Android, 켤 때마다 경고
@ -133,8 +133,8 @@ class _FilesSettingsSectionState extends State<FilesSettingsSection> {
title: Text(tr('종류별 기본 정렬')),
subtitle: Text(tr('사진 · 동영상이 많은 폴더는 날짜 (새것 먼저), 음악이 많은 폴더는 이름으로. 그 폴더에서 정렬을 바꾸면 그 폴더는 바꾼 정렬을 기억합니다')),
trailing: Switch(
value: s.explorerSortByKind,
onChanged: (v) => c.updateSettings((x) => x.explorerSortByKind = v),
value: s.explorer.sortByKind,
onChanged: (v) => c.updateSettings((x) => x.explorer.sortByKind = v),
),
),
// X-plore 비교 5-1 ② (관리자 지시 10/21): 원격 썸네일 · 캐시
@ -142,25 +142,25 @@ class _FilesSettingsSectionState extends State<FilesSettingsSection> {
title: Text(tr('원격 썸네일')),
subtitle: Text(tr('WebDAV · SFTP · SMB · FTP 의 그림 · 동영상을 X-plore 모양 목록에서 작은 그림으로. 보이는 줄만, 동시에 2개까지 받습니다')),
trailing: DropdownButton<String>(
value: s.explorerRemoteThumbs,
value: s.explorer.remoteThumbs,
items: [
DropdownMenuItem(value: 'off', child: Text(tr('받지 않음'))),
DropdownMenuItem(value: 'wifi', child: Text(tr('Wi-Fi 에서만 (기본)'))),
DropdownMenuItem(value: 'always', child: Text(tr('늘 (모바일 데이터에서도)'))),
],
onChanged: (v) => c.updateSettings((x) => x.explorerRemoteThumbs = v!),
onChanged: (v) => c.updateSettings((x) => x.explorer.remoteThumbs = v!),
),
),
SettingTile(
title: Text(tr('원격 썸네일 캐시')),
subtitle: Text(tr('넘으면 오래 보지 않은 것부터 지웁니다')),
trailing: DropdownButton<int>(
value: s.explorerRemoteThumbCacheMb,
value: s.explorer.remoteThumbCacheMb,
items: [
for (final v in {...AppSettings.remoteThumbCacheChoices, s.explorerRemoteThumbCacheMb}.toList()..sort())
for (final v in {...ExplorerSettings.remoteThumbCacheChoices, s.explorer.remoteThumbCacheMb}.toList()..sort())
DropdownMenuItem(value: v, child: Text(v == 200 ? trf('{0}MB (기본)', [v]) : '${v}MB')),
],
onChanged: (v) => c.updateSettings((x) => x.explorerRemoteThumbCacheMb = v!),
onChanged: (v) => c.updateSettings((x) => x.explorer.remoteThumbCacheMb = v!),
),
),
RemoteThumbCacheTile(c: c),
@ -170,22 +170,22 @@ class _FilesSettingsSectionState extends State<FilesSettingsSection> {
subtitle: Text(tr('폴더 안 파일 크기의 합을 목록에 보입니다. 큰 폴더 · NAS 는 읽는 데 오래 걸릴 수 있습니다. '
'길게 누르기 [폴더 크기 계산] 은 늘 됩니다')),
trailing: DropdownButton<String>(
value: s.explorerFolderSize,
value: s.explorer.folderSize,
items: [
DropdownMenuItem(value: 'sort', child: Text(tr('크기순 정렬일 때만 (기본)'))),
DropdownMenuItem(value: 'always', child: Text(tr('늘 계산'))),
DropdownMenuItem(value: 'off', child: Text(tr('계산 안 함'))),
],
onChanged: (v) => c.updateSettings((x) => x.explorerFolderSize = v ?? 'sort'),
onChanged: (v) => c.updateSettings((x) => x.explorer.folderSize = v ?? 'sort'),
),
),
// X-plore 비교 5-1 ⑥ (관리자 지시 10/21): 단축키 바꾸기 (키보드가 있는 PC 에서만 보임)
if (desk)
ListTile(
title: Text(tr('파일 탐색기 단축키')),
subtitle: Text(s.explorerKeyOverrides.isEmpty
subtitle: Text(s.explorer.keyOverrides.isEmpty
? tr('스타일대로 (바꾼 것 없음)')
: trf('바꾼 것 {0}개', [s.explorerKeyOverrides.length])),
: trf('바꾼 것 {0}개', [s.explorer.keyOverrides.length])),
trailing: const Icon(Icons.chevron_right),
onTap: () => ExplorerKeysPage.open(context, c),
),
@ -200,26 +200,26 @@ class _FilesSettingsSectionState extends State<FilesSettingsSection> {
'그 밖: Ctrl+C · X · V (복사 · 잘라내기 · 붙여넣기) · Ctrl+A 모두 표시 · F5 새로 고침'),
].join('\n')),
trailing: DropdownButton<String>(
value: s.explorerStyle,
value: s.explorer.style,
items: [
for (final st in ExplorerStyle.values)
DropdownMenuItem(value: st.name, child: Text(tr(st.label))),
],
onChanged: (v) => c.updateSettings((x) => x.explorerStyle = v!),
onChanged: (v) => c.updateSettings((x) => x.explorer.style = v!),
),
),
SettingTile(
title: Text(tr('창 배치')),
subtitle: Text(tr('파일 탐색기 위쪽 ⋮ 메뉴에서도 바꿀 수 있습니다 (좌우 · 위아래 · 버튼 줄 위치 · 버튼 구성)')),
trailing: DropdownButton<String>(
value: s.explorerLayout,
value: s.explorer.layout,
items: [
DropdownMenuItem(value: 'auto', child: Text(tr('화면 크기 따라'))),
DropdownMenuItem(value: 'dual', child: Text(tr('두 창 (기본)'))),
DropdownMenuItem(value: 'split', child: Text(tr('폴더 + 파일 목록'))),
DropdownMenuItem(value: 'single', child: Text(tr('한 창'))),
],
onChanged: (v) => c.updateSettings((x) => x.explorerLayout = v!),
onChanged: (v) => c.updateSettings((x) => x.explorer.layout = v!),
),
),
// X-plore 비교 e: Android 앱 휴지통 보관 일
@ -228,95 +228,95 @@ class _FilesSettingsSectionState extends State<FilesSettingsSection> {
title: Text(tr('휴지통 보관')),
subtitle: Text(tr('지운 것을 저장 장치의 앱 휴지통에 두었다가 이 날이 지나면 지웁니다. 끄면 바로 영구 삭제')),
trailing: DropdownButton<int>(
value: const [0, 3, 7, 14, 30, 60].contains(s.androidTrashDays) ? s.androidTrashDays : 14,
value: const [0, 3, 7, 14, 30, 60].contains(s.explorer.androidTrashDays) ? s.explorer.androidTrashDays : 14,
items: [
for (final d in const [0, 3, 7, 14, 30, 60])
DropdownMenuItem(value: d, child: Text(d == 0 ? tr('끔') : trf('{0}일', [d]))),
],
onChanged: (v) => c.updateSettings((x) => x.androidTrashDays = v!),
onChanged: (v) => c.updateSettings((x) => x.explorer.androidTrashDays = v!),
),
),
// X-plore 비교 ⚖ (관리자 결정): X-plore 스타일의 표시 동그라미 · 밀기 · 폴더 표시 규칙
SwitchListTile(
title: Text(tr('X-plore 스타일: 선택 표시 늘 보이기')),
subtitle: Text(tr('끄면 (처음 값) [선택] 을 켰을 때만 보입니다. 다른 스타일은 늘 선택 모드에서만')),
value: s.explorerAlwaysMarks,
onChanged: (v) => c.updateSettings((x) => x.explorerAlwaysMarks = v),
value: s.explorer.alwaysMarks,
onChanged: (v) => c.updateSettings((x) => x.explorer.alwaysMarks = v),
),
SwitchListTile(
title: Text(tr('X-plore 스타일: 옆으로 밀면 창 바꾸기')),
subtitle: Text(tr('탐색기 안에서 밀면 다른 창으로. 다른 화면으로 가려면 위 막대에서 밉니다')),
value: s.explorerSwipePanes,
onChanged: (v) => c.updateSettings((x) => x.explorerSwipePanes = v),
value: s.explorer.swipePanes,
onChanged: (v) => c.updateSettings((x) => x.explorer.swipePanes = v),
),
// X-plore 비교 c: 저장 장치 · 서버 · 즐겨찾기를 트리 맨 위에 (X-plore 스타일)
SwitchListTile(
title: Text(tr('저장 장치 · 서버 · 즐겨찾기를 트리 맨 위에')),
subtitle: Text(tr('X-plore 스타일에서 X-plore 처럼 한 트리에 보입니다 (위 줄 탭 · ★ 는 그대로)')),
value: s.explorerRootsInTree,
onChanged: (v) => c.updateSettings((x) => x.explorerRootsInTree = v),
value: s.explorer.rootsInTree,
onChanged: (v) => c.updateSettings((x) => x.explorer.rootsInTree = v),
),
// X-plore 비교 d: 지금 폴더를 다시 누르면 접기
SwitchListTile(
title: Text(tr('펼친 폴더를 다시 누르면 접기')),
subtitle: Text(tr('처음 값 켬. 끄면 접기 · 펼치기는 › 화살표로만 (두 번 눌러도 목록이 움직이지 않음)')),
value: s.explorerRetapCollapse,
onChanged: (v) => c.updateSettings((x) => x.explorerRetapCollapse = v),
value: s.explorer.retapCollapse,
onChanged: (v) => c.updateSettings((x) => x.explorer.retapCollapse = v),
),
// X-plore 비교 40 (관리자 지시 10/20): 목록만의 글자 크기 · 줄 간격 (세 스타일 모두, 앱 화면 크기 · 기기 글자 크기와 곱함)
SettingTile(
title: Text(tr('목록 글자 크기')),
subtitle: Text(tr('파일 탐색기 목록의 이름 · 크기 글자만. 앱 화면 크기 · 기기 글자 크기와 곱해집니다')),
trailing: DropdownButton<int>(
value: s.explorerTextScale,
value: s.explorer.textScale,
items: [
for (var v = AppSettings.explorerTextScaleMin; v <= AppSettings.explorerTextScaleMax; v += 10)
for (var v = ExplorerSettings.textScaleMin; v <= ExplorerSettings.textScaleMax; v += 10)
DropdownMenuItem(value: v, child: Text(v == 100 ? trf('{0}% (기본)', [v]) : '$v%')),
if (s.explorerTextScale % 10 != 0)
DropdownMenuItem(value: s.explorerTextScale, child: Text('${s.explorerTextScale}%')),
if (s.explorer.textScale % 10 != 0)
DropdownMenuItem(value: s.explorer.textScale, child: Text('${s.explorer.textScale}%')),
],
onChanged: (v) => c.updateSettings((x) => x.explorerTextScale = v!),
onChanged: (v) => c.updateSettings((x) => x.explorer.textScale = v!),
),
),
SettingTile(
title: Text(tr('줄 간격')),
subtitle: Text(tr('파일 탐색기 목록의 줄 높이. 좁게는 손가락으로 누르기 어려울 수 있습니다')),
trailing: DropdownButton<String>(
value: s.explorerRowSpacing,
value: s.explorer.rowSpacing,
items: [
DropdownMenuItem(value: 'compact', child: Text(tr('좁게'))),
DropdownMenuItem(value: 'normal', child: Text(tr('보통 (기본)'))),
DropdownMenuItem(value: 'wide', child: Text(tr('넓게'))),
],
onChanged: (v) => c.updateSettings((x) => x.explorerRowSpacing = v!),
onChanged: (v) => c.updateSettings((x) => x.explorer.rowSpacing = v!),
),
),
// X-plore 숨기기: 목록에서 숨긴 것 보기 · 풀기 (관리자 결정 10/18)
ListTile(
title: Text(trf('목록에서 숨긴 항목 ({0}개)', [s.explorerHiddenItems.length])),
title: Text(trf('목록에서 숨긴 항목 ({0}개)', [s.explorer.hiddenItems.length])),
subtitle: Text(tr('파일 탐색기의 길게 누르기 [목록에서 숨기기] 로 숨긴 것. 파일은 그대로이고 목록에서만 빠집니다')),
trailing: const Icon(Icons.chevron_right),
enabled: s.explorerHiddenItems.isNotEmpty,
enabled: s.explorer.hiddenItems.isNotEmpty,
onTap: () => showHiddenItems(context, c),
),
// X-plore 맞추기 (관리자 지시 10/20): 숨기기 전 확인 창
SwitchListTile(
title: Text(tr('목록에서 숨기기 전에 묻기')),
subtitle: Text(tr('무엇이 바뀌는지 (목록에서만 빠지고 파일은 그대로) 확인 창을 띄웁니다')),
value: s.explorerHideConfirm,
onChanged: (v) => c.updateSettings((x) => x.explorerHideConfirm = v),
value: s.explorer.hideConfirm,
onChanged: (v) => c.updateSettings((x) => x.explorer.hideConfirm = v),
),
// X-plore 비교 a: 한 창일 때 복사 · 이동이 갈 곳
SettingTile(
title: Text(tr('한 창일 때 복사 · 이동')),
subtitle: Text(tr('숨은 다른 창으로: X-plore 처럼 위에 "갈 곳" 을 늘 보이고 그 폴더로 바로 복사 · 이동합니다')),
trailing: DropdownButton<String>(
value: s.explorerOnePaneCopy,
value: s.explorer.onePaneCopy,
items: [
DropdownMenuItem(value: 'clip', child: Text(tr('담아 두고 붙여넣기 (기본)'))),
DropdownMenuItem(value: 'other', child: Text(tr('숨은 다른 창으로'))),
],
onChanged: (v) => c.updateSettings((x) => x.explorerOnePaneCopy = v!),
onChanged: (v) => c.updateSettings((x) => x.explorer.onePaneCopy = v!),
),
),
// 복사 · 이동 방법 (현재 방식 · robocopy) · 속도 제한

View File

@ -35,7 +35,7 @@ void main() {
File(p.join(tmp.path, 'a.txt')).writeAsStringSync('a');
Directory(p.join(tmp.path, 'sub')).createSync();
c.settings
..explorerPaths = [tmp.path, tmp.path]
..explorer.paths = [tmp.path, tmp.path]
..rsyncPaths = [tmp.path, tmp.path]
..copyTasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, 'sub'), method: 'rsync', options: '-av')]
..liveSyncPairs = [LiveSyncPair(tmp.path, p.join(tmp.path, 'x'))];

View File

@ -37,7 +37,7 @@ void main() {
expect([s.webPauseOnLeave, s.autoCheckUpdates, s.youtubeAdSkip, s.youtubeAdHide, s.runInBackground, s.swipeNav],
everyElement(isTrue));
// 탐색기 처음 배치 = 두 창 (규칙 7 · 관리자 결정 3, 10/22)
expect([s.liveSyncOnStart, s.liveSyncIntervalSec, s.explorerLayout, s.zipComic], ['auto', 30, 'dual', false]);
expect([s.liveSyncOnStart, s.liveSyncIntervalSec, s.explorer.layout, s.zipComic], ['auto', 30, 'dual', false]);
expect([AppSettings.uiScaleMin, AppSettings.uiScaleMax], [0.5, 2.5]);
// 바꾼 값은 남는다
final b = AppSettings.fromJson((AppSettings()
@ -145,6 +145,6 @@ void main() {
// 강제 종료 뒤 다시 켬 = 파일을 새로 읽음
final again = await tester.runAsync(() => SettingsStore(file, MemorySecretStore()).load());
expect(again!.migrated, isEmpty);
expect(again.explorerOrientation, 'side');
expect(again.explorer.orientation, 'side');
});
}

View File

@ -126,8 +126,8 @@ void main() {
..createSync(recursive: true)
..writeAsStringSync('d');
c.settings
..explorerLayout = 'single'
..explorerPaths = [dir, dir];
..explorer.layout = 'single'
..explorer.paths = [dir, dir];
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
@ -366,7 +366,7 @@ void main() {
final center = CopyCenter.of(c);
c.settings
..rsyncOptions = '-avPog'
..explorerLayout = 'single'
..explorer.layout = 'single'
..rsyncPaths = [outer, tmp.path];
// 이동 (원본 파일 지우기) 작업만 --delete 를 기억
await center.remember([inner], outer, contents: true, method: 'rsync', move: true).then((t) => center.update(t.copyWith(options: '-avPog --delete')));

View File

@ -113,7 +113,7 @@ void main() {
device.trash = true;
device.volumes = () => [(tmp.path, 'T:')];
// [휴지통] 버튼이 줄에 보이게 (처음 구성은 좁으면 [더 보기] 로 간다)
c.settings.explorerButtons = ['trash', for (final b in ExplorerButton.defaultOrder) if (b != ExplorerButton.trash) b.name];
c.settings.explorer.buttons = ['trash', for (final b in ExplorerButton.defaultOrder) if (b != ExplorerButton.trash) b.name];
addTearDown(() {
});
await open(tester);
@ -180,7 +180,7 @@ void main() {
}, skip: !Platform.isWindows);
testWidgets('바로 실행 모드: 폴더를 누르면 펼치고, 파일을 누르면 바로 실행', (tester) async {
c.settings.explorerClick = 'open';
c.settings.explorer.click = 'open';
await open(tester);
await act(tester, () => tester.tap(find.text('sub')));
await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty);

View File

@ -149,7 +149,7 @@ void main() {
host.show(() => ExplorerTransferPanel(c: ctl.c, jobs: [job], rsync: false), ctl.c);
await t.pump();
expect(find.textContaining('완료'), findsOneWidget);
await t.runAsync(() => ctl.c.updateSettings((s) => s.explorerProgressHidden = true));
await t.runAsync(() => ctl.c.updateSettings((s) => s.explorer.progressHidden = true));
await t.pump();
expect([find.byType(TransferProgressChip).evaluate().length, find.textContaining('완료').evaluate().length], [1, 0]);
});

View File

@ -40,7 +40,7 @@ void main() {
File(p.join(left, 'doc.txt')).setLastModifiedSync(DateTime(2026, 10, 9, 12));
File(p.join(right, 'doc.txt')).setLastModifiedSync(DateTime(2026, 10, 1, 8));
c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings.explorerPaths = [left, right];
c.settings.explorer.paths = [left, right];
final root = p.rootPrefix(tmp.path);
device.volumes = () => [(root, root.replaceAll(RegExp(r'[\\/]+$'), ''))];
// 휴지통: 기본은 가짜 (흐름만), JJ_TEST_REAL_RECYCLE=1 이면 실제 휴지통
@ -294,7 +294,7 @@ void main() {
const locked = r'C:\System Volume Information';
if (!Platform.isWindows || !Directory(locked).existsSync()) return;
if (skipIfReadable(locked)) return;
c.settings.explorerPaths = [locked, right];
c.settings.explorer.paths = [locked, right];
device.volumes = () => [(r'C:\', 'C:'), (p.rootPrefix(tmp.path), p.rootPrefix(tmp.path).substring(0, 2))];
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
@ -306,7 +306,7 @@ void main() {
});
testWidgets('135: 두 창이 같은 폴더 - [이동] 은 "이미 이 폴더에 있습니다" 창 · [복사] 는 사본을 만들지 묻는다', (tester) async {
c.settings.explorerPaths = [left, left];
c.settings.explorer.paths = [left, left];
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
@ -343,7 +343,7 @@ void main() {
testWidgets('135 보충: 섞어 고른 [이동] - 이미 그 폴더에 있는 것만 건너뛰고 나머지는 옮긴다 (확인 창 · 끝난 알림에 적음)', (tester) async {
File(p.join(left, 'a.txt')).writeAsStringSync('a');
File(p.join(right, 'b.txt')).writeAsStringSync('b');
c.settings.explorerPaths = [tmp.path, right]; // 왼쪽 창은 left · right 를 함께 보는 위 폴더
c.settings.explorer.paths = [tmp.path, right]; // 왼쪽 창은 left · right 를 함께 보는 위 폴더
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
@ -375,7 +375,7 @@ void main() {
tester.view.physicalSize = const Size(1200, 560);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final before = c.settings.explorerShowHidden;
final before = c.settings.explorer.showHidden;
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(device: device, c: c))));
await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty);
expect(tester.takeException(), isNull, reason: '넘침 없음');
@ -385,8 +385,8 @@ void main() {
await calm(tester);
expect(find.text('숨은 항목 표시'), findsOneWidget);
await tester.runAsync(() => tester.tap(find.text('숨은 항목 표시')));
await settle(tester, () => c.settings.explorerShowHidden != before, rounds: 20);
expect(c.settings.explorerShowHidden, !before);
await settle(tester, () => c.settings.explorer.showHidden != before, rounds: 20);
expect(c.settings.explorer.showHidden, !before);
// 창 배치 버튼은 좁아도 늘 줄에 (사용자 요청) · 처음 값은 자주 쓰는 것부터 (복사 · 이동 · 삭제 …)
expect(find.byTooltip('창 배치: 좌우 (누르면 위아래)'), findsOneWidget);
expect(ExplorerButton.fromSettings(const []).take(9).map((b) => b.name),
@ -499,7 +499,7 @@ void main() {
testWidgets('37-2: 보던 폴더가 밖에서 지워지면 새로 읽을 때 남아 있는 상위 폴더로 옮기고 알린다', (tester) async {
final deep = Directory(p.join(left, 'a', 'b'))..createSync(recursive: true);
c.settings.explorerPaths = [deep.path, right];
c.settings.explorer.paths = [deep.path, right];
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);

View File

@ -48,7 +48,7 @@ void main() {
expect(q.expanded, containsAll([a, show]));
expect([for (final e in q.cache[show]!) p.basename(e.path)], ['ep1.mkv']);
expect(host.revealed, [show], reason: 'Host 에 스크롤 부탁');
expect(ctl.c.settings.explorerPaths.first, show, reason: '마지막 폴더 기억');
expect(ctl.c.settings.explorer.paths.first, show, reason: '마지막 폴더 기억');
await t.runAsync(() => ctl.goTo(q, b));
expect(q.root, b, reason: '다른 장치');
await t.runAsync(() => ctl.travel(q, forward: false));

View File

@ -29,7 +29,7 @@ void main() {
..writeAsStringSync('x');
Directory(p.join(tmp.path, 'right')).createSync();
c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings.explorerPaths = [p.join(tmp.path, 'left'), p.join(tmp.path, 'right')];
c.settings.explorer.paths = [p.join(tmp.path, 'left'), p.join(tmp.path, 'right')];
stuck = Completer<bool>();
dead = {r'R:\'};
// T: 시험 폴더 (정상) · Q: 응답 없는 네트워크 드라이브 · R: 연결 안 된 드라이브
@ -80,8 +80,8 @@ void main() {
device.volumes = () => [...empties, (tmp.path, 'T:')];
device.probe = (path) => Future.value(!dead.contains(path));
c.settings
..explorerLayout = 'dual'
..explorerOrientation = 'side';
..explorer.layout = 'dual'
..explorer.orientation = 'side';
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);

View File

@ -173,12 +173,12 @@ void main() {
await tester.pump();
await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '확인')));
await calm(tester);
expect([c.settings.explorerLayout, c.settings.explorerToolbar], ['single', 'hidden']);
expect([c.settings.explorer.layout, c.settings.explorer.toolbar], ['single', 'hidden']);
expect(find.text('상위 폴더'), findsNothing); // 버튼 줄 숨김
expect(find.text('doc.txt'), findsOneWidget); // 한 창 (왼쪽만)
// ⋮ 메뉴에서 버튼 구성: "찾기" 빼기
await c.updateSettings((s) => s.explorerToolbar = 'middle');
await c.updateSettings((s) => s.explorer.toolbar = 'middle');
await calm(tester);
await tester.tap(find.byTooltip('창 배치 · 버튼 구성'));
await calm(tester);
@ -191,14 +191,14 @@ void main() {
await tester.pump();
await tester.tap(find.widgetWithText(FilledButton, '확인'));
await calm(tester);
expect(c.settings.explorerButtons, isNot(contains('addMkv')));
expect(c.settings.explorerButtons, contains('copy'));
expect(c.settings.explorer.buttons, isNot(contains('addMkv')));
expect(c.settings.explorer.buttons, contains('copy'));
expect(find.text('MKV 목록에 추가'), findsNothing);
// 설정 저장 · 읽기
final back = AppSettings.fromJson(c.settings.toJson());
expect([back.explorerLayout, back.explorerToolbar, back.explorerButtons],
[c.settings.explorerLayout, c.settings.explorerToolbar, c.settings.explorerButtons]);
expect([back.explorer.layout, back.explorer.toolbar, back.explorer.buttons],
[c.settings.explorer.layout, c.settings.explorer.toolbar, c.settings.explorer.buttons]);
});
testWidgets('Rsync 화면: 늘 좌우 두 창 · 폴더만 · 창마다 하나만 고르기 · → ← ⇄ · 모니터링', (tester) async {
@ -207,7 +207,7 @@ void main() {
File(p.join(right, 'r.txt')).writeAsStringSync('r');
File(p.join(right, 'rsub', 'back.txt')).writeAsStringSync('b'); // ⇄ 비교에서 ← 로 건너갈 것
c.settings
..explorerLayout = 'single' // 파일 탐색기 배치와 상관없이 두 창
..explorer.layout = 'single' // 파일 탐색기 배치와 상관없이 두 창
..rsyncPaths = [left, right];
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;

View File

@ -17,7 +17,7 @@ void main() {
testWidgets('X-plore 숨기기: 길게 누르기 [목록에서 숨기기] → 목록에서만 빠짐 (파일 그대로) · 맨 아래 [숨긴 항목 보이기] · [숨기기 풀기]', (tester) async {
// 폴더 + 파일 목록 배치: 오른쪽 목록은 그 폴더의 것만이라 짧다 (트리는 Temp 폴더 줄이 많아 맨 아래가 멀다)
c.settings.explorerLayout = 'split';
c.settings.explorer.layout = 'split';
await open(tester);
final doc = p.join(left, 'doc.txt');
await tester.longPress(find.text('doc.txt'));
@ -30,7 +30,7 @@ void main() {
await settle(tester, () => find.text('doc.txt').evaluate().isEmpty);
expect(find.text('doc.txt'), findsNothing);
expect(File(doc).existsSync(), isTrue, reason: '파일은 바꾸지 않는다');
expect(c.settings.explorerHiddenItems, [doc]);
expect(c.settings.explorer.hiddenItems, [doc]);
// 목록 맨 아래에 [숨긴 항목 1개 보이기]
expect(find.text('숨긴 항목 1개 보이기'), findsOneWidget);
await idle(tester);
@ -44,14 +44,14 @@ void main() {
expect(find.text('목록에서 숨기기'), findsNothing);
await act(tester, () => tester.tap(find.text('숨기기 풀기')));
await settle(tester, () => find.textContaining('숨긴 항목').evaluate().isEmpty);
expect(c.settings.explorerHiddenItems, isEmpty);
expect(c.settings.explorer.hiddenItems, isEmpty);
expect(find.text('doc.txt'), findsOneWidget);
expect(find.ancestor(of: find.text('doc.txt'), matching: find.byType(Opacity)), findsNothing);
});
testWidgets('X-plore 숨기기 확인 창: [취소] 면 그대로 · [다음부터 묻지 않기] 를 고르면 다음엔 묻지 않고 바로 숨김 (설정 저장)', (tester) async {
c.settings.explorerLayout = 'split';
expect(AppSettings().explorerHideConfirm, isTrue, reason: '처음은 묻기');
c.settings.explorer.layout = 'split';
expect(AppSettings().explorer.hideConfirm, isTrue, reason: '처음은 묻기');
File(p.join(left, 'two.txt')).writeAsStringSync('2');
await open(tester);
await tester.longPress(find.text('doc.txt'));
@ -60,7 +60,7 @@ void main() {
await calm(tester);
await act(tester, () => tester.tap(find.text('취소')));
await calm(tester);
expect(c.settings.explorerHiddenItems, isEmpty, reason: '취소하면 숨기지 않는다');
expect(c.settings.explorer.hiddenItems, isEmpty, reason: '취소하면 숨기지 않는다');
expect(find.text('doc.txt'), findsOneWidget);
// 다음부터 묻지 않기 + 숨기기
await tester.longPress(find.text('doc.txt'));
@ -70,16 +70,16 @@ void main() {
await act(tester, () => tester.tap(find.text('다음부터 묻지 않기')));
await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '숨기기')));
await settle(tester, () => find.text('doc.txt').evaluate().isEmpty);
expect(c.settings.explorerHideConfirm, isFalse);
expect(AppSettings.fromJson(c.settings.toJson()).explorerHideConfirm, isFalse);
expect(AppSettings.fromJson({}).explorerHideConfirm, isTrue, reason: '예전 설정 (값 없음) 은 묻기');
expect(c.settings.explorer.hideConfirm, isFalse);
expect(AppSettings.fromJson(c.settings.toJson()).explorer.hideConfirm, isFalse);
expect(AppSettings.fromJson({}).explorer.hideConfirm, isTrue, reason: '예전 설정 (값 없음) 은 묻기');
// 이제는 창 없이 바로
await tester.longPress(find.text('two.txt'));
await calm(tester);
await act(tester, () => tester.tap(find.text('목록에서 숨기기')));
await settle(tester, () => find.text('two.txt').evaluate().isEmpty);
expect(find.textContaining('파일 탐색기 목록에서만 빠집니다'), findsNothing);
expect(c.settings.explorerHiddenItems, [p.join(left, 'doc.txt'), p.join(left, 'two.txt')]);
expect(c.settings.explorer.hiddenItems, [p.join(left, 'doc.txt'), p.join(left, 'two.txt')]);
});
testWidgets('X-plore 맞추기: 폴더 길게 누르기 [갤러리 · 음악 앱에서 숨기기 (.nomedia)] - 확인 창 뒤 빈 .nomedia, 다시 누르면 지우기 · 안의 파일은 그대로', (tester) async {
@ -129,7 +129,7 @@ void main() {
});
testWidgets('X-plore 숨기기: 숨긴 것이 든 폴더를 지울 때 확인 창에 알림 (숨긴 것도 함께 지워진다)', (tester) async {
c.settings.explorerHiddenItems = [p.join(left, 'sub', 'inner.txt')];
c.settings.explorer.hiddenItems = [p.join(left, 'sub', 'inner.txt')];
await open(tester);
await tester.longPress(find.text('sub'));
await calm(tester);
@ -150,7 +150,7 @@ void main() {
});
testWidgets('X-plore 숨기기: 이름을 바꾸면 숨긴 목록도 새 경로로 · 영구로 지우면 목록에서 뺀다 (관리자 결정)', (tester) async {
c.settings.explorerHiddenItems = [p.join(left, 'sub', 'inner.txt')];
c.settings.explorer.hiddenItems = [p.join(left, 'sub', 'inner.txt')];
await open(tester);
await tester.longPress(find.text('sub'));
await calm(tester);
@ -159,7 +159,7 @@ void main() {
await act(tester, () => tester.enterText(find.byType(TextField), 'sub2'));
await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '확인')));
await settle(tester, () => find.text('sub2').evaluate().isNotEmpty);
expect(c.settings.explorerHiddenItems, [p.join(left, 'sub2', 'inner.txt')]);
expect(c.settings.explorer.hiddenItems, [p.join(left, 'sub2', 'inner.txt')]);
await idle(tester);
await tester.longPress(find.text('sub2'));
await calm(tester);
@ -169,29 +169,29 @@ void main() {
await calm(tester);
await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '영구 삭제')));
await settle(tester, () => find.text('sub2').evaluate().isEmpty);
await settle(tester, () => c.settings.explorerHiddenItems.isEmpty);
expect(c.settings.explorerHiddenItems, isEmpty);
await settle(tester, () => c.settings.explorer.hiddenItems.isEmpty);
expect(c.settings.explorer.hiddenItems, isEmpty);
});
testWidgets('X-plore 숨기기: 다른 창으로 옮기면 숨긴 목록도 새 자리로 (그 안의 것도)', (tester) async {
c.settings.explorerHiddenItems = [p.join(left, 'sub', 'inner.txt')];
c.settings.explorer.hiddenItems = [p.join(left, 'sub', 'inner.txt')];
await open(tester);
await tester.longPress(find.text('sub'));
await calm(tester);
await act(tester, () => tester.tap(find.byTooltip('다른 창으로 이동').last));
await settle(tester, () => find.widgetWithText(FilledButton, '이동').evaluate().isNotEmpty);
await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '이동')));
await settle(tester, () => c.settings.explorerHiddenItems.first != p.join(left, 'sub', 'inner.txt'));
await settle(tester, () => c.settings.explorer.hiddenItems.first != p.join(left, 'sub', 'inner.txt'));
expect(File(p.join(right, 'sub', 'inner.txt')).existsSync(), isTrue);
expect(c.settings.explorerHiddenItems, [p.join(right, 'sub', 'inner.txt')]);
expect(c.settings.explorer.hiddenItems, [p.join(right, 'sub', 'inner.txt')]);
await idle(tester);
});
testWidgets('X-plore 숨기기: 위 막대 ⋮ [목록에서 숨긴 항목 보이기] · 환경 설정의 [목록에서 숨긴 항목] 에서 풀기', (tester) async {
final doc = p.join(left, 'doc.txt');
c.settings
..explorerLayout = 'split'
..explorerHiddenItems = [doc];
..explorer.layout = 'split'
..explorer.hiddenItems = [doc];
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
@ -214,7 +214,7 @@ void main() {
expect(find.text('목록에서 숨긴 항목 (1개)'), findsOneWidget);
await act(tester, () => tester.tap(find.byTooltip('숨기기 풀기')));
await calm(tester);
expect(c.settings.explorerHiddenItems, isEmpty);
expect(c.settings.explorer.hiddenItems, isEmpty);
expect(find.text('숨긴 항목이 없습니다'), findsOneWidget);
await act(tester, () => tester.tap(find.text('닫기')));
await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty);
@ -224,8 +224,8 @@ void main() {
testWidgets('80: 크기순 정렬이면 폴더 크기를 계산해 보이고, "안 함" 이어도 길게 누르기 [폴더 크기 계산]', (tester) async {
File(p.join(left, 'sub', 'big.bin')).writeAsBytesSync(List.filled(3000, 1));
c.settings
..explorerSort = 'size'
..explorerSortDesc = true;
..explorer.sort = 'size'
..explorer.sortDesc = true;
await open(tester);
// sub = inner.txt (5) + big.bin (3000)
await settle(tester, () => find.text('3KB').evaluate().isNotEmpty);
@ -241,8 +241,8 @@ void main() {
testWidgets('80: "계산 안 함" - 크기를 보이지 않다가 [폴더 크기 계산] 을 누르면', (tester) async {
File(p.join(left, 'sub', 'big.bin')).writeAsBytesSync(List.filled(3000, 1));
c.settings
..explorerSort = 'size'
..explorerFolderSize = 'off';
..explorer.sort = 'size'
..explorer.folderSize = 'off';
await open(tester);
await settle(tester, () => false, rounds: 15);
expect(find.text('3KB'), findsNothing);

View File

@ -20,7 +20,7 @@ void main() {
testWidgets('83: Total Commander 모양 단축키 - F5 복사 · F7 새 폴더 · Space/Insert 표시 (Space 는 빠른 필터가 아님)', (tester) async {
File(p.join(left, 'e.txt')).writeAsStringSync('e');
c.settings.explorerStyle = 'totalcmd';
c.settings.explorer.style = 'totalcmd';
await open(tester);
await act(tester, () => tester.tap(find.text('doc')));
await settle(tester, () => false, rounds: 15);
@ -51,7 +51,7 @@ void main() {
if (bin is! FakeRecycleBin) return markTestSkipped('실제 휴지통 (JJ_TEST_REAL_RECYCLE=1) 에서는 건너뜀');
if (!Platform.isWindows) return markTestSkipped('휴지통은 Windows 만 (Android 는 앱 휴지통 시험)');
final victim = File(p.join(left, 'tcdel.txt'))..writeAsStringSync('x');
c.settings.explorerStyle = 'totalcmd';
c.settings.explorer.style = 'totalcmd';
await open(tester);
await settle(tester, () => find.text('tcdel').evaluate().isNotEmpty);
await act(tester, () => tester.tap(find.text('tcdel')));
@ -80,7 +80,7 @@ void main() {
});
testWidgets('83: Windows 탐색기 모양 단축키 - Ctrl+A 모두 표시 · Ctrl+C 담기 · Ctrl+V 붙여넣기', (tester) async {
c.settings.explorerStyle = 'windows';
c.settings.explorer.style = 'windows';
await open(tester);
await act(tester, () => tester.tap(find.text('doc.txt')));
await settle(tester, () => false, rounds: 15);
@ -151,7 +151,7 @@ void main() {
File(p.join(right, 'doc.txt')).writeAsStringSync('old');
File(p.join(left, 'e.txt')).writeAsStringSync('e');
c.settings
..explorerDropAction = 'move'
..explorer.dropAction = 'move'
..copyConflict = 'overwrite';
await open(tester);
// 전체 시험 중 (부하) 에는 목록을 늦게 읽어 e.txt 를 못 찾았다 - 두 파일이 보일 때까지
@ -177,7 +177,7 @@ void main() {
..createSync(recursive: true)
..writeAsStringSync('mine');
c.settings
..explorerDropAction = 'move'
..explorer.dropAction = 'move'
..copyConflict = 'overwrite';
await open(tester);
await act(tester, () => tester.tap(find.text('선택').first));
@ -232,21 +232,21 @@ void main() {
}
await act(tester, () => tester.tap(find.text('숨기기')));
await until(() => c.settings.explorerProgressHidden && find.text('복사 · 이동 끝').evaluate().isNotEmpty);
expect(c.settings.explorerProgressHidden, isTrue, reason: '숨김은 기억');
await until(() => c.settings.explorer.progressHidden && find.text('복사 · 이동 끝').evaluate().isNotEmpty);
expect(c.settings.explorer.progressHidden, isTrue, reason: '숨김은 기억');
expect(find.text('숨기기'), findsNothing);
expect(find.text('복사 · 이동 끝'), findsOneWidget, reason: '아래 작은 표시');
// 작은 표시를 누름 (아래 알림이 덮고 있어도 - 알림이 닫히기를 기다리면 그사이 작은 표시가 내려갈 수 있다)
tester.widget<TransferProgressChip>(find.byType(TransferProgressChip)).onTap();
await until(() => !c.settings.explorerProgressHidden);
await until(() => !c.settings.explorer.progressHidden);
ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars();
expect(c.settings.explorerProgressHidden, isFalse, reason: '누르면 다시 펼침');
expect(c.settings.explorer.progressHidden, isFalse, reason: '누르면 다시 펼침');
ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars();
await calm(tester);
});
testWidgets('81: 빠른 보기 (글 파일 앞부분) - Windows 는 TC 키 F3 (규칙 1), Android 는 Space · 필터에 치는 중에는 그냥 빈칸', (tester) async {
c.settings.explorerStyle = 'windows';
c.settings.explorer.style = 'windows';
await open(tester);
await act(tester, () => tester.tap(find.text('doc.txt')));
await settle(tester, () => false, rounds: 15);
@ -270,7 +270,7 @@ void main() {
await calm(tester);
// TC: F3
await c.updateSettings((s) => s.explorerStyle = 'totalcmd');
await c.updateSettings((s) => s.explorer.style = 'totalcmd');
await calm(tester);
await act(tester, () => tester.tap(find.text('doc')));
await settle(tester, () => false, rounds: 15);
@ -284,7 +284,7 @@ void main() {
// P0 (15 지적 10/17): 글자 칸에 치는 중에는 파일 단축키가 키를 가로채지 않는다 (예전: 필터 칸의 Delete → 파일 지우기 창)
for (final style in const ['windows', 'xplore', 'totalcmd']) {
testWidgets('P0 $style: 빠른 필터 칸에서 Ctrl+A · C · V · Delete · Space · F8 · Backspace 는 파일에 쓰이지 않는다', (tester) async {
c.settings.explorerStyle = style;
c.settings.explorer.style = style;
await open(tester);
await act(tester, () => tester.tap(find.text(style == 'totalcmd' ? 'doc' : 'doc.txt')));
await settle(tester, () => false, rounds: 15);
@ -345,35 +345,35 @@ void main() {
testWidgets('84: 열 머리를 누르면 그 기준으로 · 다시 누르면 거꾸로 (처음: 모든 폴더 같은 정렬)', (tester) async {
File(p.join(left, 'a_big.txt')).writeAsStringSync('x' * 50);
c.settings.explorerStyle = 'windows';
c.settings.explorer.style = 'windows';
await open(tester);
double y(String name) => tester.getTopLeft(find.text(name).first).dy;
expect(y('a_big.txt'), lessThan(y('doc.txt')), reason: '처음: 이름순');
await act(tester, () => tester.tap(find.text('크기').first));
await calm(tester);
expect((c.settings.explorerSort, c.settings.explorerSortDesc), ('size', false));
expect((c.settings.explorer.sort, c.settings.explorer.sortDesc), ('size', false));
expect(y('doc.txt'), lessThan(y('a_big.txt')), reason: '크기 오름차순: doc.txt (5) 가 먼저');
await act(tester, () => tester.tap(find.text('크기').first));
await calm(tester);
expect(c.settings.explorerSortDesc, isTrue, reason: '같은 열을 다시 누르면 거꾸로');
expect(c.settings.explorer.sortDesc, isTrue, reason: '같은 열을 다시 누르면 거꾸로');
// (거꾸로면 트리에 펼쳐진 위 폴더 (Temp 등) 의 차례도 바뀌어 시험 폴더가 화면 밖으로 갈 수 있어 줄 위치는 보지 않는다)
expect(find.byIcon(Icons.arrow_drop_down), findsWidgets, reason: '지금 정렬 열에 ▼');
});
testWidgets('84: "정렬을 폴더마다 기억" 을 켜면 그 폴더에만 (전체 정렬 · 다른 창은 그대로)', (tester) async {
c.settings
..explorerStyle = 'totalcmd'
..explorerSortPerFolder = true;
..explorer.style = 'totalcmd'
..explorer.sortPerFolder = true;
await open(tester);
// 왼쪽 창에서 날짜 열
await act(tester, () => tester.tap(find.text('날짜').first));
await calm(tester);
expect(c.settings.explorerSort, 'name', reason: '전체 정렬은 그대로');
expect(c.settings.explorerFolderSorts.values, contains('date|0'));
expect(c.settings.explorerFolderSorts.length, 1);
expect(c.settings.explorer.sort, 'name', reason: '전체 정렬은 그대로');
expect(c.settings.explorer.folderSorts.values, contains('date|0'));
expect(c.settings.explorer.folderSorts.length, 1);
// 저장 · 읽기
expect(AppSettings.fromJson(c.settings.toJson()).explorerFolderSorts, c.settings.explorerFolderSorts);
expect(AppSettings.fromJson(c.settings.toJson()).explorerSortPerFolder, isTrue);
expect(AppSettings.fromJson({'explorerFolderSorts': 'broken'}).explorerFolderSorts, isEmpty, reason: '틀린 값은 비움');
expect(AppSettings.fromJson(c.settings.toJson()).explorer.folderSorts, c.settings.explorer.folderSorts);
expect(AppSettings.fromJson(c.settings.toJson()).explorer.sortPerFolder, isTrue);
expect(AppSettings.fromJson({'explorerFolderSorts': 'broken'}).explorer.folderSorts, isEmpty, reason: '틀린 값은 비움');
});
}

View File

@ -53,10 +53,10 @@ void main() {
expect(keyConflict('copy', const SingleActivator(LogicalKeyboardKey.escape), tc: false, overrides: {}), 'escape');
expect(keyConflict('copy', const SingleActivator(k, control: true), tc: false, overrides: {}), isNull);
// 설정 저장 · 읽기 · 틀린 꼴은 비움
final back = AppSettings.fromJson((AppSettings()..explorerKeyOverrides = {'copy': spec}).toJson());
expect(back.explorerKeyOverrides, {'copy': spec});
expect(AppSettings.fromJson({'explorerKeyOverrides': 'x'}).explorerKeyOverrides, isEmpty);
expect(AppSettings().explorerKeyOverrides, isEmpty);
final back = AppSettings.fromJson((AppSettings()..explorer.keyOverrides = {'copy': spec}).toJson());
expect(back.explorer.keyOverrides, {'copy': spec});
expect(AppSettings.fromJson({'explorerKeyOverrides': 'x'}).explorer.keyOverrides, isEmpty);
expect(AppSettings().explorer.keyOverrides, isEmpty);
});
late Directory tmp;
@ -71,9 +71,9 @@ void main() {
addTearDown(tester.view.reset);
final c = ctl();
c.settings
..explorerPaths = [tmp.path, tmp.path]
..explorerStyle = 'windows'
..explorerKeyOverrides = {'newFolder': keySpec(const SingleActivator(k, control: true, shift: true))};
..explorer.paths = [tmp.path, tmp.path]
..explorer.style = 'windows'
..explorer.keyOverrides = {'newFolder': keySpec(const SingleActivator(k, control: true, shift: true))};
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c))));
for (var i = 0; i < 20; i++) {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 30)));
@ -99,7 +99,7 @@ void main() {
tester.view.physicalSize = const Size(1000, 1600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final c = ctl()..settings.explorerStyle = 'windows';
final c = ctl()..settings.explorer.style = 'windows';
await tester.pumpWidget(MaterialApp(home: ExplorerKeysPage(c: c)));
Future<void> change(String label) async {
await tester.tap(find.descendant(of: find.widgetWithText(ListTile, label), matching: find.text('바꾸기')));
@ -112,18 +112,18 @@ void main() {
expect(find.textContaining('이미 "새로 고침" 에 쓰는 키입니다'), findsOneWidget);
await tester.tap(find.text('저장'));
await tester.pumpAndSettle();
expect(c.settings.explorerKeyOverrides['copy'], keySpec(const SingleActivator(LogicalKeyboardKey.f5)));
expect(c.settings.explorer.keyOverrides['copy'], keySpec(const SingleActivator(LogicalKeyboardKey.f5)));
expect(find.descendant(of: find.widgetWithText(ListTile, '새로 고침'), matching: find.text('(없음)')), findsOneWidget,
reason: '겹친 동작은 키를 잃음');
// 처음 값으로
await tester.tap(find.descendant(of: find.widgetWithText(ListTile, '다른 창으로 복사'), matching: find.text('처음 값으로')));
await tester.pumpAndSettle();
expect(c.settings.explorerKeyOverrides, isEmpty);
expect(c.settings.explorer.keyOverrides, isEmpty);
// 단축키 없음
await change('붙여넣기');
await tester.tap(find.text('단축키 없음'));
await tester.pumpAndSettle();
expect(c.settings.explorerKeyOverrides['paste'], '');
expect(c.settings.explorer.keyOverrides['paste'], '');
// Esc 는 저장할 수 없음
await change('삭제');
await tester.sendKeyEvent(LogicalKeyboardKey.escape);

View File

@ -31,7 +31,7 @@ void main() {
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings.explorerPaths = [tmp.path, tmp.path];
c.settings.explorer.paths = [tmp.path, tmp.path];
// 기기 글꼴 · 글자 크기가 시험 글꼴보다 크다 (S10 일본어에서 잘림) - 글자 크기를 키운 경우도
await tester.runAsync(() => tester.pumpWidget(MaterialApp(
builder: (ctx, child) => MediaQuery(data: MediaQuery.of(ctx).copyWith(textScaler: TextScaler.linear(scale)), child: child!),

View File

@ -70,15 +70,15 @@ void main() {
testWidgets('창 배치: 두 창 = 창 둘 + 버튼 줄 · 한 창 = 창 하나 · 폴더 + 파일 목록 = 트리 + 목록 · 좁은 Rsync = [왼쪽 | 오른쪽]', (t) async {
final h = await open(t);
expect([find.byType(ExplorerPaneView).evaluate().length, find.byType(ExplorerToolbar).evaluate().length], [2, 1]);
h.ctl.c.settings.explorerLayout = 'single';
h.ctl.c.settings.explorer.layout = 'single';
h.redraw();
await t.pump();
expect(find.byType(ExplorerPaneView).evaluate().length, 1);
h.ctl.c.settings.explorerLayout = 'split';
h.ctl.c.settings.explorer.layout = 'split';
h.redraw();
await t.pump();
expect([find.byType(ExplorerPaneView).evaluate().length, find.byType(ExplorerListView).evaluate().length], [1, 1]);
h.ctl.c.settings.explorerToolbar = 'hidden';
h.ctl.c.settings.explorer.toolbar = 'hidden';
h.redraw();
await t.pump();
expect(find.byType(ExplorerToolbar), findsNothing, reason: '버튼 줄 숨김');

View File

@ -77,11 +77,11 @@ void main() {
final s = m.ctl.c.settings;
m.ctl.active = 0;
expect([m.layout.dual, m.layout.split, identical(m.layout.other, m.ctl.panes[1]), m.layout.rowHeight() > 0], [true, false, true, true]);
s.explorerLayout = 'single';
s.explorer.layout = 'single';
expect([m.layout.dual, m.layout.hiddenTarget, m.layout.other], [false, false, null], reason: '처음 값은 담아 두기');
s.explorerOnePaneCopy = 'other';
s.explorer.onePaneCopy = 'other';
expect([m.layout.hiddenTarget, identical(m.layout.other, m.ctl.panes[1])], [true, true]);
s.explorerLayout = 'split';
s.explorer.layout = 'split';
expect(m.layout.split, isTrue);
m.layout.splitTight = true;
expect(m.layout.split, isFalse);
@ -106,7 +106,7 @@ void main() {
testWidgets('보기 창: 정렬 기준 고르고 확인 = 그 정렬 · 버튼 구성 [취소] = 그대로', (t) async {
final m = await open(t);
final before = [...m.ctl.c.settings.explorerButtons];
final before = [...m.ctl.c.settings.explorer.buttons];
unawaited(m.flows.dialogs.sortDialog(m.host.context));
await t.pump();
await t.tap(find.text('크기'));
@ -118,7 +118,7 @@ void main() {
await t.pump();
await t.tap(find.text('취소'));
await t.pump();
expect(m.ctl.c.settings.explorerButtons, before);
expect(m.ctl.c.settings.explorer.buttons, before);
});
testWidgets('이름 바꾸기: 이름 창 → 실제로 바뀜 · 초점이 새 이름으로 / Wi-Fi 공유는 원격 폴더면 알림', (t) async {

View File

@ -78,10 +78,10 @@ void main() {
});
testWidgets('X-plore a: 한 창 + "숨은 다른 창으로" - 위에 갈 곳 줄 · [복사] 는 그 폴더로 · [지금 폴더로] 로 바꾸기 (처음 값은 담아 두기)', (tester) async {
expect(AppSettings().explorerOnePaneCopy, 'clip', reason: '처음 값은 지금 그대로');
expect(AppSettings().explorer.onePaneCopy, 'clip', reason: '처음 값은 지금 그대로');
c.settings
..explorerLayout = 'single'
..explorerOnePaneCopy = 'other';
..explorer.layout = 'single'
..explorer.onePaneCopy = 'other';
await open(tester);
expect(find.textContaining('갈 곳: '), findsOneWidget);
expect(find.textContaining('갈 곳: right'), findsOneWidget, reason: '숨은 창의 폴더 이름 (전체 경로는 풍선 도움말)');
@ -94,8 +94,8 @@ void main() {
expect(File(p.join(right, 'doc.txt')).readAsStringSync(), 'hello');
// [지금 폴더로] → 갈 곳이 지금 보는 폴더 (left)
await act(tester, () => tester.tap(find.text('지금 폴더로')));
await settle(tester, () => c.settings.explorerPaths[1] == left, rounds: 50);
expect(c.settings.explorerPaths[1], left);
await settle(tester, () => c.settings.explorer.paths[1] == left, rounds: 50);
expect(c.settings.explorer.paths[1], left);
ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars();
await calm(tester);
await tester.pump(const Duration(seconds: 2));
@ -195,7 +195,7 @@ void main() {
});
testWidgets('규칙 3: 펼친 폴더를 다시 누르면 접힘 · 두 번 누르기의 두 번째는 접지 않음 (처음 값 켬)', (tester) async {
expect(AppSettings().explorerRetapCollapse, isTrue, reason: '규칙 3 (10/22)');
expect(AppSettings().explorer.retapCollapse, isTrue, reason: '규칙 3 (10/22)');
await open(tester);
await act(tester, () => tester.tap(find.text('sub')));
await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty);
@ -215,7 +215,7 @@ void main() {
testWidgets('X-plore c: X-plore 스타일 트리 맨 위에 다른 저장 장치 · 즐겨찾기 - 누르면 그곳으로, 즐겨찾기는 펼침', (tester) async {
device.volumes = () => [(left, 'L:'), (right, 'R:')];
c.settings
..explorerStyle = 'xplore'
..explorer.style = 'xplore'
..folderBookmarks = [FolderBookmark(name: '내 즐겨찾기', path: p.join(left, 'sub'))];
await open(tester);
final otherRoot = find.byKey(ValueKey<String>('k1:$right'));
@ -227,27 +227,27 @@ void main() {
await calm(tester);
expect(find.text('내 즐겨찾기'), findsOneWidget);
await act(tester, () => tester.tap(find.text('내 즐겨찾기')));
await settle(tester, () => c.settings.explorerPaths.first == p.join(left, 'sub'), rounds: 80);
expect(c.settings.explorerPaths.first, p.join(left, 'sub'));
await settle(tester, () => c.settings.explorer.paths.first == p.join(left, 'sub'), rounds: 80);
expect(c.settings.explorer.paths.first, p.join(left, 'sub'));
await act(tester, () => tester.tap(find.byKey(ValueKey<String>('k1:$right')).first));
await settle(tester, () => c.settings.explorerPaths.first == right, rounds: 80);
expect(c.settings.explorerPaths.first, right, reason: '다른 저장 장치 줄 = 그 저장 장치로');
await settle(tester, () => c.settings.explorer.paths.first == right, rounds: 80);
expect(c.settings.explorer.paths.first, right, reason: '다른 저장 장치 줄 = 그 저장 장치로');
// 다른 스타일은 그대로 (맨 위 항목 없음)
await tester.runAsync(() => c.updateSettings((x) => x.explorerStyle = 'windows'));
await tester.runAsync(() => c.updateSettings((x) => x.explorer.style = 'windows'));
await calm(tester);
expect(find.byKey(const ValueKey<String>('k2:\u0000favorites')), findsNothing);
});
testWidgets('X-plore ⚖: 탐색기 안에서 옆으로 밀기 = 창 바꾸기 (한 창이면 숨은 창과 바꿈)', (tester) async {
expect(AppSettings().explorerSwipePanes, isFalse, reason: '처음 값은 끔 - 화면 가운데를 밀면 화면 이동 (10/8 사용자 결정)');
c.settings.explorerSwipePanes = true;
c.settings.explorerLayout = 'single';
expect(AppSettings().explorer.swipePanes, isFalse, reason: '처음 값은 끔 - 화면 가운데를 밀면 화면 이동 (10/8 사용자 결정)');
c.settings.explorer.swipePanes = true;
c.settings.explorer.layout = 'single';
await open(tester);
expect(c.settings.explorerPaths, [left, right]);
expect(c.settings.explorer.paths, [left, right]);
// 화면 가운데 높이에서 (위 · 아래 가장자리 1/5 는 밀기로 보지 않는다 - SwipeNav)
await tester.flingFrom(tester.getCenter(find.byType(ExplorerPage)), const Offset(-400, 0), 1500);
await settle(tester, () => c.settings.explorerPaths.first == right, rounds: 60);
expect(c.settings.explorerPaths, [right, left], reason: '숨은 창이 보이는 창으로');
await settle(tester, () => c.settings.explorer.paths.first == right, rounds: 60);
expect(c.settings.explorer.paths, [right, left], reason: '숨은 창이 보이는 창으로');
ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars();
await tester.pump(const Duration(seconds: 1));
});

View File

@ -29,7 +29,7 @@ void main() {
Directory(right).createSync();
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..explorerPaths = [left, right]
..explorer.paths = [left, right]
..copyBandwidthKBps = 512; // 큰 파일이 6초쯤 걸리게
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
@ -69,7 +69,7 @@ void main() {
device.volumes = () => [(tmp.path, 'T:')];
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..explorerOrientation = 'side'
..explorer.orientation = 'side'
..rsyncPaths = [l, r];
tester.view.physicalSize = const Size(400, 860);
tester.view.devicePixelRatio = 1;
@ -109,7 +109,7 @@ void main() {
device.volumes = () => [(tmp.path, 'T:')];
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..explorerOrientation = 'side'
..explorer.orientation = 'side'
..rsyncPaths = [l, r];
tester.view.physicalSize = const Size(412, 900);
tester.view.devicePixelRatio = 1;
@ -145,7 +145,7 @@ void main() {
device.volumes = () => [(tmp.path, 'T:')];
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..explorerOrientation = 'side'
..explorer.orientation = 'side'
..rsyncPaths = [l, r];
tester.view.physicalSize = const Size(860, 400);
tester.view.devicePixelRatio = 1;
@ -167,7 +167,7 @@ void main() {
device.volumes = () => [(inner, '내장 저장소'), (sd, 'SD 카드')];
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..explorerOrientation = 'side'
..explorer.orientation = 'side'
..rsyncPaths = [p.join(inner, 'L'), p.join(sd, 'R')];
tester.view.physicalSize = const Size(412, 880);
tester.view.devicePixelRatio = 1;
@ -193,7 +193,7 @@ void main() {
Directory(p.join(r, 'rsub')).createSync(recursive: true);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..explorerLayout = 'single'
..explorer.layout = 'single'
..rsyncPaths = [l, r];
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
@ -226,8 +226,8 @@ void main() {
File(p.join(l, 'top.txt')).writeAsStringSync('t');
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..explorerLayout = 'single'
..explorerPaths = [l, l];
..explorer.layout = 'single'
..explorer.paths = [l, l];
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
@ -259,8 +259,8 @@ void main() {
}
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..explorerLayout = 'single'
..explorerPaths = [dl, dl];
..explorer.layout = 'single'
..explorer.paths = [dl, dl];
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
@ -293,9 +293,9 @@ void main() {
File(p.join(l, 'zz_file.txt')).writeAsStringSync('t');
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..explorerLayout = 'dual'
..explorerOrientation = 'stacked'
..explorerPaths = [l, l];
..explorer.layout = 'dual'
..explorer.orientation = 'stacked'
..explorer.paths = [l, l];
tester.view.physicalSize = const Size(420, 640);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
@ -311,17 +311,17 @@ void main() {
});
test('118: 두 창 배치 처음 값은 좌우 · 예전 "화면 모양 따라" 는 한 번 좌우로 (알림) · 고른 위아래는 그대로', () {
expect(AppSettings().explorerOrientation, 'side');
expect(AppSettings.fromJson({}).explorerOrientation, 'side');
expect(AppSettings().explorer.orientation, 'side');
expect(AppSettings.fromJson({}).explorer.orientation, 'side');
final old = AppSettings.fromJson({'explorerOrientation': 'auto'});
expect(old.explorerOrientation, 'side');
expect(old.explorer.orientation, 'side');
expect(old.migrated, contains('explorerOrientation'));
final stacked = AppSettings.fromJson({'explorerOrientation': 'stacked'});
expect(stacked.explorerOrientation, 'stacked');
expect(stacked.explorer.orientation, 'stacked');
expect(stacked.migrated, isNot(contains('explorerOrientation')));
// 옮긴 뒤 사용자가 다시 '화면 모양 따라' 를 고르면 그대로
final again = AppSettings.fromJson((AppSettings()..explorerOrientation = 'auto').toJson());
expect(again.explorerOrientation, 'auto');
final again = AppSettings.fromJson((AppSettings()..explorer.orientation = 'auto').toJson());
expect(again.explorer.orientation, 'auto');
expect(again.migrated, isEmpty);
});
@ -334,8 +334,8 @@ void main() {
..writeAsStringSync('a');
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..explorerLayout = 'single'
..explorerPaths = [l, l];
..explorer.layout = 'single'
..explorer.paths = [l, l];
// 폰 세로처럼 좁고 긴 화면
tester.view.physicalSize = const Size(500, 1000);
tester.view.devicePixelRatio = 1;
@ -356,15 +356,15 @@ void main() {
}
await press('두 창으로 보기 (좌우)', '두 창으로');
expect((c.settings.explorerLayout, c.settings.explorerOrientation), ('dual', 'side'), reason: '폰 세로에서도 좌우');
expect((c.settings.explorer.layout, c.settings.explorer.orientation), ('dual', 'side'), reason: '폰 세로에서도 좌우');
expect(find.text('a.txt'), findsNWidgets(2));
final left = tester.getCenter(find.text('a.txt').first), right = tester.getCenter(find.text('a.txt').last);
expect(right.dx, greaterThan(left.dx));
await press('창 배치: 좌우 (누르면 위아래)', '좌우 ⇆');
expect(c.settings.explorerOrientation, 'stacked');
expect(c.settings.explorer.orientation, 'stacked');
final top = tester.getCenter(find.text('a.txt').first), bottom = tester.getCenter(find.text('a.txt').last);
expect(bottom.dy, greaterThan(top.dy));
await press('창 배치: 위아래 (누르면 좌우)', '위아래 ⇅');
expect(c.settings.explorerOrientation, 'side');
expect(c.settings.explorer.orientation, 'side');
});
}

View File

@ -17,8 +17,8 @@ void main() {
testWidgets('폴더 + 파일 목록 배치 · Windows 탐색기 / Total Commander 스타일', (tester) async {
c.settings
..explorerLayout = 'split'
..explorerStyle = 'windows';
..explorer.layout = 'split'
..explorer.style = 'windows';
await open(tester);
// 왼쪽 트리는 폴더만, 오른쪽 목록은 고른 폴더 (left) 의 내용
expect(find.text('sub'), findsNWidgets(2)); // 트리 + 목록
@ -62,7 +62,7 @@ void main() {
expect(find.text('inner.txt'), findsOneWidget); // 목록에 바로 보임
// Total Commander: [폴더] · <DIR> · 확장자 칸
await c.updateSettings((s) => s.explorerStyle = 'totalcmd');
await c.updateSettings((s) => s.explorer.style = 'totalcmd');
await settle(tester, () => find.text('[sub]').evaluate().length == 2);
expect(find.text('[sub]'), findsNWidgets(2));
expect(find.text('<DIR>'), findsOneWidget);
@ -115,7 +115,7 @@ void main() {
expect(find.byTooltip('표시 지우기'), findsNothing);
expect(find.byTooltip('표시 (여러 개 고르기)'), findsNothing);
// 설정 "늘 보이기" 를 켜면 X-plore 스타일은 선택 모드 밖에서도 보임
await tester.runAsync(() => c.updateSettings((x) => x.explorerAlwaysMarks = true));
await tester.runAsync(() => c.updateSettings((x) => x.explorer.alwaysMarks = true));
await settle(tester, () => false, rounds: 5);
expect(find.byTooltip('표시 (여러 개 고르기)'), findsWidgets);
});
@ -333,7 +333,7 @@ void main() {
testWidgets('75: 창마다 뒤로 · 앞으로 - 버튼 · Alt+← / → · 마우스 4 · 5번', (tester) async {
await open(tester);
final sub = p.join(left, 'sub');
String now() => c.settings.explorerPaths.first;
String now() => c.settings.explorer.paths.first;
await act(tester, () => tester.tap(find.text('sub').first));
await settle(tester, () => find.text('inner.txt').evaluate().isNotEmpty);
// 뒤로 (버튼): 왼쪽 창은 left 로
@ -374,7 +374,7 @@ void main() {
await open(tester);
final r1 = p.join(right, 'r1');
final sub = p.join(left, 'sub');
String at(int i) => c.settings.explorerPaths[i];
String at(int i) => c.settings.explorer.paths[i];
await act(tester, () => tester.tap(find.text('r1')));
await settle(tester, () => at(1) == r1 && find.text('x.txt').evaluate().isNotEmpty, rounds: 50);
// 왼쪽 창이 활성
@ -399,7 +399,7 @@ void main() {
testWidgets('75: 지워진 폴더로 앞으로 - 그 항목은 버리고 지금 폴더 그대로 · 이유를 알림', (tester) async {
await open(tester);
final sub = p.join(left, 'sub');
String now() => c.settings.explorerPaths.first;
String now() => c.settings.explorer.paths.first;
await act(tester, () => tester.tap(find.text('sub')));
await settle(tester, () => now() == sub, rounds: 50);
await act(tester, () => tester.tap(find.byTooltip('뒤로 (Alt+←)').first));

View File

@ -52,11 +52,11 @@ void main() {
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(pp, null));
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..explorerPaths = [dir, dir]
..explorerLayout = 'split'
..explorerStyle = 'windows'
..explorerSort = sort
..explorerSortByKind = kind;
..explorer.paths = [dir, dir]
..explorer.layout = 'split'
..explorer.style = 'windows'
..explorer.sort = sort
..explorer.sortByKind = kind;
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c))));
for (var i = 0; i < 300 && (i < 10 || find.text(wait).evaluate().isEmpty); i++) {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 30)));
@ -77,9 +77,9 @@ void main() {
}
test('설정: 처음 끔 · 저장 · 읽기 · 예전 설정 (값 없음) 은 끔', () {
expect(AppSettings().explorerSortByKind, isFalse);
expect(AppSettings.fromJson((AppSettings()..explorerSortByKind = true).toJson()).explorerSortByKind, isTrue);
expect(AppSettings.fromJson({}).explorerSortByKind, isFalse);
expect(AppSettings().explorer.sortByKind, isFalse);
expect(AppSettings.fromJson((AppSettings()..explorer.sortByKind = true).toJson()).explorer.sortByKind, isTrue);
expect(AppSettings.fromJson({}).explorer.sortByKind, isFalse);
});
testWidgets('끄면 (처음) 사진 폴더도 지금 정렬 (이름) 그대로', (tester) async {
@ -98,8 +98,8 @@ void main() {
await tester.pump();
}
expect(order(tester, ['a.jpg', 'b.jpg', 'c.mp4', 'd.jpg']), ['a.jpg', 'b.jpg', 'c.mp4', 'd.jpg'], reason: '누른 것이 바로 보인다');
expect(c.settings.explorerFolderSorts.values, contains('name|0'));
expect((c.settings.explorerSort, c.settings.explorerSortDesc), ('name', false), reason: '전체 정렬은 그대로');
expect(c.settings.explorer.folderSorts.values, contains('name|0'));
expect((c.settings.explorer.sort, c.settings.explorer.sortDesc), ('name', false), reason: '전체 정렬은 그대로');
await close(tester);
});

View File

@ -37,7 +37,7 @@ void main() {
device.volumes = () => [(tmp.path, 'T:')];
c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), shell: NoopShell()));
c.settings
..explorerPaths = [left, right]
..explorer.paths = [left, right]
..copyMethodFile = 'builtin'
..copyMethodFolder = 'builtin';
});
@ -56,7 +56,7 @@ void main() {
for (final layout in ['dual', 'split']) {
for (final selecting in [false, true]) {
testWidgets('180: 복사해도 자리 · 스크롤 그대로 ($layout${selecting ? ' · 선택 모드' : ''})', (tester) async {
c.settings.explorerLayout = layout;
c.settings.explorer.layout = layout;
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
@ -120,8 +120,8 @@ void main() {
testWidgets('180: 창 크기가 바뀌어 두 창이 가로 ↔ 세로로 바뀌어도 스크롤 그대로', (tester) async {
c.settings
..explorerLayout = 'dual'
..explorerOrientation = 'auto';
..explorer.layout = 'dual'
..explorer.orientation = 'auto';
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
@ -146,8 +146,8 @@ void main() {
testWidgets('183: [새로 고침] 은 두 창 모두 (같은 폴더를 보는 다른 창에 밖에서 지운 파일이 남지 않게)', (tester) async {
c.settings
..explorerLayout = 'dual'
..explorerPaths = [left, left];
..explorer.layout = 'dual'
..explorer.paths = [left, left];
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
@ -162,8 +162,8 @@ void main() {
testWidgets('232: 앱으로 돌아오면 보이는 폴더를 다시 읽는다 (다른 앱에서 지우고 · 만든 것이 바로 보이게)', (tester) async {
c.settings
..explorerLayout = 'dual'
..explorerPaths = [left, left];
..explorer.layout = 'dual'
..explorer.paths = [left, left];
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);

View File

@ -21,16 +21,16 @@ void main() {
test('설정: 처음은 100% · 보통 (지금 그대로), 저장 · 읽기, 틀린 값은 그대로 쪽으로 · 범위 밖은 끝으로', () {
final s = AppSettings();
expect((s.explorerTextScale, s.explorerRowSpacing), (100, 'normal'));
expect((s.explorer.textScale, s.explorer.rowSpacing), (100, 'normal'));
s
..explorerTextScale = 130
..explorerRowSpacing = 'wide';
..explorer.textScale = 130
..explorer.rowSpacing = 'wide';
final back = AppSettings.fromJson(s.toJson());
expect((back.explorerTextScale, back.explorerRowSpacing), (130, 'wide'));
expect(AppSettings.fromJson({}).explorerTextScale, 100);
expect(AppSettings.fromJson({'explorerTextScale': 'x', 'explorerRowSpacing': 'huge'}).explorerRowSpacing, 'normal');
expect(AppSettings.fromJson({'explorerTextScale': 300}).explorerTextScale, 150);
expect(AppSettings.fromJson({'explorerTextScale': 10}).explorerTextScale, 80);
expect((back.explorer.textScale, back.explorer.rowSpacing), (130, 'wide'));
expect(AppSettings.fromJson({}).explorer.textScale, 100);
expect(AppSettings.fromJson({'explorerTextScale': 'x', 'explorerRowSpacing': 'huge'}).explorer.rowSpacing, 'normal');
expect(AppSettings.fromJson({'explorerTextScale': 300}).explorer.textScale, 150);
expect(AppSettings.fromJson({'explorerTextScale': 10}).explorer.textScale, 80);
});
/// [name] 글자의 실제 글자 크기 (기기 · 목록 글자 크기를 곱한 것)
@ -54,14 +54,14 @@ void main() {
Directory(p.join(tmp.path, 'sub')).createSync();
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..explorerPaths = [tmp.path, tmp.path]
..explorer.paths = [tmp.path, tmp.path]
// 폴더 + 파일 목록: 오른쪽 목록은 그 폴더의 것만이라 짧다 (트리는 Temp 의 다른 폴더가 많아 아래로 밀린다)
..explorerLayout = 'split'
..explorerStyle = style
..explorer.layout = 'split'
..explorer.style = style
// ✔ 칸 자리를 보는 시험: 표시를 늘 보이게 (설정 - 처음 값은 규칙 4 로 선택 모드에서만)
..explorerAlwaysMarks = true
..explorerTextScale = scale
..explorerRowSpacing = spacing;
..explorer.alwaysMarks = true
..explorer.textScale = scale
..explorer.rowSpacing = spacing;
await tester.runAsync(() => tester.pumpWidget(MaterialApp(
builder: (ctx, child) => MediaQuery(data: MediaQuery.of(ctx).copyWith(textScaler: TextScaler.linear(device)), child: child!),
home: ExplorerPage(c: c))));
@ -119,7 +119,7 @@ void main() {
final c = await open(tester, style, sub, device: 1.3, scale: 150, spacing: 'wide');
await tester.pump();
expect(tester.takeException(), isNull);
expect(c.settings.explorerLayout, 'split', reason: '고른 배치는 바꾸지 않는다');
expect(c.settings.explorer.layout, 'split', reason: '고른 배치는 바꾸지 않는다');
expect(find.text(sub), findsOneWidget, reason: '트리 · 목록 두 창이 아니라 한 창');
expect(find.text(a), findsOneWidget, reason: '한 창 트리에 파일도 보인다');
final para = tester.renderObject<RenderParagraph>(find.text(sub));
@ -156,7 +156,7 @@ void main() {
final sub = style == 'totalcmd' ? '[sub]' : 'sub';
final c = await open(tester, style, sub, device: 1.3, scale: 150);
await tester.pump();
expect(c.settings.explorerLayout, 'split');
expect(c.settings.explorer.layout, 'split');
expect(tester.takeException(), isNull);
expect(find.text(type), findsNothing, reason: '좁아서 유형 열 숨김');
expect(find.text('크기'), findsOneWidget, reason: '크기는 남김');

View File

@ -72,7 +72,7 @@ void main() {
}
testWidgets('파일 탐색기: WebDAV 탭 → 서버 추가 (연결 확인) → 목록 · 열기 (받아서) · 로컬 → WebDAV 복사', (tester) async {
c.settings.explorerPaths = [left, left];
c.settings.explorer.paths = [left, left];
await pump(tester, ExplorerPage(device: device, c: c));
await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty);
// 서버가 없으면 위쪽 저장 장치 옆에 "WebDAV" 탭 (창마다)
@ -165,7 +165,7 @@ void main() {
addTearDown(() => SecretGate.check = null);
c.settings
..webdavServers = [s]
..explorerPaths = [left, left]
..explorer.paths = [left, left]
..folderBookmarks = [const FolderBookmark(name: 'NAS 원격', path: 'dav://nas/원격')];
DavRegistry.configure([s]);
await pump(tester, ExplorerPage(device: device, c: c));

View File

@ -0,0 +1,254 @@
{
"__migrated": [
"orientation",
"explorerOrientation",
"cookieScope",
"explorerMarksRule",
"explorerRetapRule",
"explorerLayoutRule",
"swipeNav",
"dnsForced",
"copySkipNewer"
],
"addFinishedDownloads": true,
"aiAgreed": [],
"aiAutoDevice": "",
"aiAutoDeviceFor": "",
"aiCfg": 1.0,
"aiCount": 1,
"aiDevice": "auto",
"aiEngine": "local",
"aiHeight": 512,
"aiImageV1": true,
"aiNegative": "",
"aiPrompt": "",
"aiQualityButton": false,
"aiQualityStrength": "normal",
"aiRecent": [],
"aiSaveDir": "",
"aiServices": [],
"aiSource": "und",
"aiSteps": 4,
"aiStrength": 0.6,
"aiTaesd": true,
"aiTargets": [
"ko",
"en",
"ja"
],
"aiUpAfter": "upscaled",
"aiUpDir": "",
"aiUpFormat": "same",
"aiUpJpgQuality": 92,
"aiUpMemory": "normal",
"aiUpModel": "auto",
"aiUpScale": 2,
"aiUpSecPerMp": {},
"aiWhisper": "whisper-base",
"aiWidth": 512,
"allFilesHint2": false,
"allowInnerToOuter": true,
"androidTrashDays": 14,
"appIcon": "yellow",
"askAiOptions": true,
"autoCheckUpdates": true,
"backgroundV2": true,
"batchRenameLast": {},
"browserEngine": "edge",
"browserSwipeHinted": false,
"cefWhenElevated": false,
"cleanupPrechecked": [
"work",
"download"
],
"clipboardWatch": true,
"closeAction": "background",
"components": [
"mkv",
"browser",
"explorer",
"rsync",
"downloads",
"aiimage",
"viewer"
],
"cookieScopeV2": true,
"copyBandwidthKBps": 0,
"copyConflict": "ask",
"copyMethodFile": "builtin",
"copyMethodFolder": "builtin",
"copyRunMode": "each",
"copySkipNewer": true,
"copyTasks": [],
"davExternalOpen": "ask",
"dnsDohUrl": "https://dns.google/dns-query",
"dnsMode": "custom",
"dnsPrimary": "8.8.8.8",
"dnsSecondary": "8.8.4.4",
"downloadRoot": null,
"encode": {
"brightness": 0,
"codec": "copy",
"contrast": 0,
"fit": "fill",
"frame": "original",
"quality": "normal",
"resolution": "original",
"rotate": "none",
"saturation": 0,
"temperature": 0
},
"explorerAlwaysMarks": false,
"explorerButtons": [],
"explorerClick": "open",
"explorerDropAction": "ask",
"explorerFolderSize": "sort",
"explorerFolderSorts": {},
"explorerHiddenItems": [],
"explorerHideConfirm": true,
"explorerHistory": [],
"explorerKeyOverrides": {},
"explorerLayout": "dual",
"explorerOnePaneCopy": "clip",
"explorerOrientV3": true,
"explorerOrientation": "side",
"explorerPaths": [],
"explorerProgressHidden": false,
"explorerRemoteThumbCacheMb": 200,
"explorerRemoteThumbs": "wifi",
"explorerRetapCollapse": true,
"explorerRootsInTree": true,
"explorerRowSpacing": "normal",
"explorerRulesV1": true,
"explorerShowHidden": false,
"explorerSort": "name",
"explorerSortByKind": false,
"explorerSortDesc": false,
"explorerSortPerFolder": false,
"explorerStyle": "xplore",
"explorerSwipePanes": false,
"explorerTextScale": 100,
"explorerToolbar": "middle",
"explorerV2": true,
"externalBrowser": "system",
"externalPlayers": {},
"folderBookmarks": [],
"ftpServers": [],
"homeUrl": "https://www.youtube.com/",
"imageExts": [
"jpg",
"jpeg",
"png",
"gif",
"webp",
"bmp"
],
"lastRunVersion": "",
"lastUpdateCheck": "",
"lastUpdateCheckVersion": "",
"liveSyncIntervalSec": 30,
"liveSyncOnStart": "auto",
"liveSyncPairs": [],
"loginCookieSites": [
"youtube.com",
"google.com",
"instagram.com",
"x.com",
"twitter.com",
"naver.com"
],
"maxParallelDownloads": 3,
"maxParallelJobs": 5,
"migrationNotice": true,
"minimizeToTray": true,
"mkvCompactList": "auto",
"mkvExisting": "ask",
"mkvOutputRoot": null,
"moveTargets": [],
"navOrder": [
"mkv",
"browser",
"tor",
"explorer",
"rsync",
"downloads",
"aiimage"
],
"openFileAction": "play",
"openFileWindow": "same",
"openSubtitlesSaved": {
"key": false,
"password": false,
"user": false
},
"orientationV2": true,
"playlistMode": "series",
"preferredSubtitleLanguage": "",
"readerAutoSeconds": 3,
"readerBrightness": 0.0,
"readerFit": "page",
"readerGallery": false,
"readerRtl": false,
"readerSplit": "auto",
"readerSystemBars": true,
"readerVolumeKeys": false,
"recycleOnDelete": true,
"rememberPasswords": true,
"remoteIdleSeconds": 60,
"robocopyLinksV1": true,
"robocopyOptions": "/E /COPY:DAT /DCOPY:T /R:2 /W:2",
"rollbackKeepModels": "",
"rootEnabled": false,
"rsyncOptions": "-avPog",
"rsyncPath": "",
"rsyncPaths": [],
"rsyncSource": "download",
"runInBackground": true,
"screenOrientation": "auto",
"sftpServers": [],
"shizukuEnabled": false,
"showHotkey": "Ctrl+Shift+X",
"showLog": true,
"skippedVersion": "",
"smbRequireSecure": false,
"smbServers": [],
"startMenuShortcut": true,
"startScreen": "home",
"subtitleScale": 1.0,
"swipeNav": true,
"swipeWrap": true,
"syncStopToast": true,
"torHomeUrl": "https://check.torproject.org/",
"torIdleMinutes": 2,
"torJavaScript": true,
"torKeepData": false,
"torNoticeSeen": false,
"torrentStallSeconds": 60,
"tripleTapAction": "move",
"uiBrightness": "dark",
"uiLanguage": "ko",
"uiLanguagesAdded": [],
"uiScale": 1.0,
"uiScaleDefault": 1.0,
"vaultAutoLockSeconds": 60,
"vaultEnabled": false,
"vaultMasterCopy": false,
"webJavaScript": true,
"webPauseOnLeave": true,
"webTranslate": false,
"webdavServers": [],
"wifiShareFolder": "",
"wifiShareMinutes": 30,
"wifiShareTailscale": true,
"youtubeAdHide": true,
"youtubeAdSkip": true,
"ytContainer": "mp4",
"ytCookiesBrowser": "*internal",
"ytCookiesFile": "",
"ytExpandPlaylists": true,
"ytPreferH264": false,
"ytQuality": "best",
"zipComic": false,
"zipComicV2": true,
"zipLevel": 6
}

View File

@ -1,4 +1,9 @@
{
"__migrated": [
"zipComic",
"explorerMarksRule",
"explorerRetapRule"
],
"addFinishedDownloads": false,
"aiAgreed": [],
"aiAutoDevice": "x_",

View File

@ -52,7 +52,7 @@ void main() {
Directory(p.join(tmp.path, 'sub')).createSync();
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..explorerPaths = [tmp.path, tmp.path]
..explorer.paths = [tmp.path, tmp.path]
..rsyncPaths = [tmp.path, tmp.path]
..copyTasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, 'sub'), method: 'rsync', options: '-av')]
..liveSyncPairs = [LiveSyncPair(tmp.path, p.join(tmp.path, 'x'))];

View File

@ -311,14 +311,14 @@ void main() {
settingsStore: store);
await c.init();
// 보통 저장: 모르는 키 · 다른 판의 열쇠 남음 (40)
await c.updateSettings((s) => s.explorerLayout = 'dual');
await c.updateSettings((s) => s.explorer.layout = 'dual');
Map<String, dynamic> saved() => jsonDecode(file.readAsStringSync()) as Map<String, dynamic>;
expect(saved()['zz_new_version'], {'keep': 1});
expect(secrets.values['nfs.pw.n1'], 'new-version-pw');
// 앱 설정 전체: 모르는 키 없음 · 다른 판의 열쇠는 아직 남음 (비밀번호 체크를 안 함)
await resetAllSettings(c);
expect(saved().containsKey('zz_new_version'), isFalse);
expect(saved()['explorerLayout'], AppSettings().explorerLayout);
expect(saved()['explorerLayout'], AppSettings().explorer.layout);
expect(secrets.values['nfs.pw.n1'], 'new-version-pw', reason: '[저장된 실제 비밀번호] 를 고르지 않으면 남김');
// 저장된 실제 비밀번호 · API 키: 다른 판의 열쇠도 지움 · 마스터 (sec.) 는 남김
await clearStoredSecrets(c);
@ -327,9 +327,9 @@ void main() {
});
test('최상으로 "앱 설정 전체" 를 지우면 처음 상태', () async {
final c = controller()..settings.explorerLayout = 'single';
final c = controller()..settings.explorer.layout = 'single';
await resetAllSettings(c);
expect(c.settings.explorerLayout, AppSettings().explorerLayout);
expect(c.settings.explorer.layout, AppSettings().explorer.layout);
expect(c.settings.webdavServers, isEmpty);
});
});

View File

@ -44,13 +44,13 @@ void main() {
test('설정: 처음 값 Wi-Fi 에서만 · 캐시 200MB, 틀린 값은 처음 값으로', () {
final s = AppSettings();
expect((s.explorerRemoteThumbs, s.explorerRemoteThumbCacheMb), ('wifi', 200));
expect(AppSettings.fromJson({'explorerRemoteThumbs': 'x', 'explorerRemoteThumbCacheMb': 'y'}).explorerRemoteThumbs, 'wifi');
expect((s.explorer.remoteThumbs, s.explorer.remoteThumbCacheMb), ('wifi', 200));
expect(AppSettings.fromJson({'explorerRemoteThumbs': 'x', 'explorerRemoteThumbCacheMb': 'y'}).explorer.remoteThumbs, 'wifi');
final back = AppSettings.fromJson((AppSettings()
..explorerRemoteThumbs = 'off'
..explorerRemoteThumbCacheMb = 500)
..explorer.remoteThumbs = 'off'
..explorer.remoteThumbCacheMb = 500)
.toJson());
expect((back.explorerRemoteThumbs, back.explorerRemoteThumbCacheMb), ('off', 500));
expect((back.explorer.remoteThumbs, back.explorer.remoteThumbCacheMb), ('off', 500));
});
/// 파일 읽기 · 쓰기 (실제 IO) 를 기다린다 - [ok] 가 될 때까지 (5초까지)
@ -140,9 +140,9 @@ void main() {
activeMeteredOf = () async => false;
resetRemoteThumbNetForTest();
expect(await remoteThumbsAllowedNow(c), isTrue, reason: 'Wi-Fi (집 Wi-Fi 위의 Tailscale 포함)');
c.settings.explorerRemoteThumbs = 'off';
c.settings.explorer.remoteThumbs = 'off';
expect(await remoteThumbsAllowedNow(c), isFalse);
c.settings.explorerRemoteThumbs = 'always';
c.settings.explorer.remoteThumbs = 'always';
activeMeteredOf = () async => true;
resetRemoteThumbNetForTest();
expect(await remoteThumbsAllowedNow(c), isTrue);
@ -196,7 +196,7 @@ void main() {
expect(await (DavPath.parse('dav://nas/v.bin').client as DavClient).rangeSupported('/v.bin'), isFalse);
final calls = <List<String>>[];
final c = AppController(PlatformServices(mediaTool: _Ffmpeg(calls), storage: DesktopStorageService()));
c.settings.explorerRemoteThumbs = 'always';
c.settings.explorer.remoteThumbs = 'always';
resetRemoteThumbsForTest();
addTearDown(resetRemoteThumbsForTest);
final h = await remoteThumbsOf(c);
@ -247,7 +247,7 @@ void main() {
await tool.runFfmpeg(['-y', '-f', 'lavfi', '-i', 'testsrc=size=320x240:rate=10', '-t', '7', '-pix_fmt', 'yuv420p', mp4]);
server.ranges = true;
final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService()));
c.settings.explorerRemoteThumbs = 'always';
c.settings.explorer.remoteThumbs = 'always';
resetRemoteThumbsForTest();
addTearDown(resetRemoteThumbsForTest);
final h = await remoteThumbsOf(c);

View File

@ -130,8 +130,8 @@ void main() {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..rootEnabled = on
..explorerPaths = [tmp.path, tmp.path]
..explorerLayout = 'single';
..explorer.paths = [tmp.path, tmp.path]
..explorer.layout = 'single';
return c;
}

View File

@ -38,7 +38,7 @@ void main() {
c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..webdavServers = [s]
..explorerLayout = 'single'
..explorer.layout = 'single'
..rsyncPaths = [left, 'dav://nas/'];
DavRegistry.configure([s]);
});

View File

@ -24,7 +24,11 @@ void main() {
return const JsonEncoder.withIndent(' ').convert(sort(v));
}
String roundTrip(String text) => canonical(AppSettings.fromJson(jsonDecode(text) as Map<String, dynamic>).toJson());
/// 읽고 다시 쓴 것 + 읽을 때 정한 "바뀐 기본값 알림" 목록 (파일에는 없지만 읽기의 결과)
String roundTrip(String text) {
final s = AppSettings.fromJson(jsonDecode(text) as Map<String, dynamic>);
return canonical({...s.toJson(), '__migrated': s.migrated});
}
/// 모든 키의 값을 바꾼 입력 (bool 은 반대, 수는 +7, 글은 앞에 x_, 안쪽 묶음도) - 읽기의 검사 · 고침도 함께 지나게
Object? change(Object? v) => switch (v) {
@ -54,6 +58,16 @@ void main() {
expect(canonical(AppSettings().toJson()), read(defFile));
});
test('옛 판 설정 (옮김 표시 없음 · 예전 처음 값): 읽은 값과 바뀐 기본값 알림이 나누기 전 판과 같다', () {
const legacy = {'explorerLayout': 'auto', 'explorerClick': 'open', 'explorerOrientation': 'auto', 'screenOrientation': 'landscape'};
final outFile = File(p.join(fixtures, 'settings_golden_legacy_out.json'));
if (write) {
outFile.writeAsStringSync(roundTrip(jsonEncode(legacy)));
return;
}
expect(roundTrip(jsonEncode(legacy)), outFile.readAsStringSync().replaceAll('\r\n', '\n'));
});
final expectPath = Platform.environment['JJ_USER_SETTINGS_EXPECT'];
test('사용자 설정 파일 (복사본): 읽고 쓰면 나누기 전 판과 같다', () {
final user = File(p.join(Platform.environment['APPDATA'] ?? '', 'com.jj', 'jj_mkvmaker', 'settings.json'));

View File

@ -142,16 +142,16 @@ void main() {
});
expect(old.screenOrientation, 'auto');
// 규칙 7 · 결정 3 (10/22): 처음 배치가 두 창이 되어 예전 'dual' 은 그대로 (옮겼다는 알림 없음)
expect(old.explorerLayout, 'dual');
expect(old.explorer.layout, 'dual');
expect(old.zipComic, isFalse);
expect(old.migrated, containsAll(['orientation', 'zipComic']));
expect(old.migrated, isNot(contains('explorerLayout')));
// 다시 저장 · 읽기: 이제 사용자가 고른 값으로 보고 그대로 · 알림 없음
final again = AppSettings.fromJson((old
..explorerLayout = 'dual'
..explorer.layout = 'dual'
..screenOrientation = 'landscape')
.toJson());
expect(again.explorerLayout, 'dual');
expect(again.explorer.layout, 'dual');
expect(again.screenOrientation, 'landscape');
expect(again.migrated, isEmpty);
// 처음 설치 (빈 설정) 는 알릴 것 없음
@ -166,16 +166,16 @@ void main() {
'explorerRetapCollapse': false,
'explorerClick': 'open',
});
expect([old.explorerLayout, old.explorerAlwaysMarks, old.explorerRetapCollapse], ['dual', false, true]);
expect(old.explorerClick, Platform.isAndroid ? 'select' : 'open'); // Windows 의 'open' 은 사용자가 고른 값
expect([old.explorer.layout, old.explorer.alwaysMarks, old.explorer.retapCollapse], ['dual', false, true]);
expect(old.explorer.click, Platform.isAndroid ? 'select' : 'open'); // Windows 의 'open' 은 사용자가 고른 값
expect(old.migrated, containsAll(['explorerLayoutRule', 'explorerMarksRule', 'explorerRetapRule']));
// 저장한 뒤 사용자가 다시 고른 값은 그대로 · 다시 묻지 않음
final again = AppSettings.fromJson((old
..explorerLayout = 'auto'
..explorerAlwaysMarks = true
..explorerRetapCollapse = false)
..explorer.layout = 'auto'
..explorer.alwaysMarks = true
..explorer.retapCollapse = false)
.toJson());
expect([again.explorerLayout, again.explorerAlwaysMarks, again.explorerRetapCollapse], ['auto', true, false]);
expect([again.explorer.layout, again.explorer.alwaysMarks, again.explorer.retapCollapse], ['auto', true, false]);
expect(again.migrated, isEmpty);
});

View File

@ -52,7 +52,7 @@ void main() {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
await tester.runAsync(() => c.updateSettings((x) => x
..sftpServers = [SftpServer(id: 's1', name: 'NAS', host: '127.0.0.1', port: s!.port, user: 'jj', password: 'pw')]
..explorerPaths = ['sftp://s1/', local.path]));
..explorer.paths = ['sftp://s1/', local.path]));
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);

View File

@ -113,8 +113,8 @@ void main() {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..shizukuEnabled = on
..explorerPaths = [tmp.path, tmp.path]
..explorerLayout = 'single';
..explorer.paths = [tmp.path, tmp.path]
..explorer.layout = 'single';
return c;
}

View File

@ -65,7 +65,7 @@ void useExplorerFixture() {
_device = TestExplorerDevice();
_c = AppController(PlatformServices(
mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), shell: shell));
c.settings.explorerPaths = [left, right];
c.settings.explorer.paths = [left, right];
});
// Windows: 방금 닫은 파일 (65 의 잠금 시험) 이나 색인 · 백신이 잠깐 잡고 있으면 지우기가 실패한다 - 될 때까지 기다린다
tearDown(() async {

View File

@ -187,7 +187,7 @@ void main() {
..createSync(recursive: true)
..writeAsStringSync('r');
c.settings
..explorerLayout = 'single'
..explorer.layout = 'single'
..rsyncOptions = '-avPog --delete'
..rsyncPaths = [l, r];
tester.view.physicalSize = const Size(1600, 900);
@ -221,7 +221,7 @@ void main() {
old.setLastModifiedSync(DateTime.now().subtract(const Duration(hours: 1)));
final writing = File(p.join(dir, 'now.mkv.jjpart'))..writeAsStringSync('half'); // 지금 쓰는 중 (방금 바뀜)
expect(isPartialFile('a.jjsync'), isTrue);
c.settings.explorerPaths = [dir, dir];
c.settings.explorer.paths = [dir, dir];
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);

View File

@ -215,8 +215,8 @@ void main() {
for (final on in [false, true]) {
final c = ctl()
..settings.vaultEnabled = on
..settings.explorerPaths = [dir.path, dir.path]
..settings.explorerLayout = 'split';
..settings.explorer.paths = [dir.path, dir.path]
..settings.explorer.layout = 'split';
await t.runAsync(() => t.pumpWidget(MaterialApp(home: ExplorerPage(key: UniqueKey(), c: c))));
await until(t, () => find.text('a.txt').evaluate().isNotEmpty);
await t.longPress(find.text('a.txt').last);