설정 A 분야 13: 화면 · 앱 모양 (DisplaySettings) - 키 13 · clampUiScale → DisplaySettings.clampScale

- ComponentManifest 의 자기 필드 components 는 그대로 (같은 이름이라 잘못 바뀐 것을 되돌림)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
modular
RyuJongJin 2026-10-23 00:06:30 +09:00
parent 8d26d1af1e
commit c5aa8c657b
37 changed files with 298 additions and 264 deletions

View File

@ -126,7 +126,7 @@ void main() {
expect(TorBundle.windowsExe, isNotNull);
expect(TorBundle.installedVersion(), TorBundle.version);
c.settings
..components = [...c.settings.components, 'tor']
..display.components = [...c.settings.display.components, 'tor']
..tor.noticeSeen = true;
await tester.pumpWidget(MaterialApp(
builder: (context, child) => AppScope(controller: c, bookmarks: bm, child: child!),

View File

@ -56,7 +56,7 @@ void main() {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..webViewDataDir = dataDir
..uiLanguage = 'ko'
..display.language = 'ko'
..browser.translate = true;
final bm = BookmarksController(p.join(dataDir, 'bm.json'));
await tester.pumpWidget(MaterialApp(home: BrowserPage(c: c, bookmarks: bm, initialUrl: url)));

View File

@ -45,8 +45,8 @@ void main() {
AppController plain() {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..components = ['mkv', 'explorer', 'rsync']
..navOrder = ['mkv', 'explorer', 'rsync'];
..display.components = ['mkv', 'explorer', 'rsync']
..display.navOrder = ['mkv', 'explorer', 'rsync'];
return c;
}
@ -90,8 +90,8 @@ void main() {
final c = plain();
// 실제 앱처럼 웹 브라우저도 화면 목록에 (그래야 위쪽 막대에 브라우저 아이콘이 있다)
c.settings
..components = ['mkv', 'browser', 'explorer', 'rsync']
..navOrder = ['mkv', 'browser', 'explorer', 'rsync'];
..display.components = ['mkv', 'browser', 'explorer', 'rsync']
..display.navOrder = ['mkv', 'browser', 'explorer', 'rsync'];
final base = Platform.environment['JJ_SHOT_TMP'];
final dir = testTempDir('jj_shot_662_', base: base);
final bm = BookmarksController(p.join(dir.path, 'bookmarks.json'));

View File

@ -50,7 +50,7 @@ void main() {
t.view.physicalSize = const Size(1280, 800);
t.view.devicePixelRatio = 1;
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
if (Platform.environment['JJ_SHOT_THEME'] == 'light') c.settings.uiBrightness = 'light';
if (Platform.environment['JJ_SHOT_THEME'] == 'light') c.settings.display.brightness = 'light';
await t.runAsync(() => i18n.apply(lang, save: false));
await t.runAsync(() => t.pumpWidget(RepaintBoundary(key: shotKey, child: JjMkvMakerApp(controller: c, navigatorKey: navKey))));
await settle(t);
@ -92,7 +92,7 @@ void main() {
t.view.devicePixelRatio = 1;
addTearDown(t.view.reset);
final c = AppController(PlatformServices.create());
if (Platform.environment['JJ_SHOT_THEME'] == 'light') c.settings.uiBrightness = 'light';
if (Platform.environment['JJ_SHOT_THEME'] == 'light') c.settings.display.brightness = 'light';
for (var i = 0; i < checked; i++) {
final v = VideoItem(p.join(Directory.systemTemp.path, 'jj_i18n_none', 'v$i.mp4'));
c.videos.add(v);

View File

@ -61,7 +61,7 @@ void main() {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..tor.noticeSeen = true
..components = [...c.settings.components, 'tor'];
..display.components = [...c.settings.display.components, 'tor'];
final key = GlobalKey();
Future<void> shot(String name, Size size, Widget home, {Future<void> Function()? then}) async {

View File

@ -45,7 +45,7 @@ void main() {
createRecognizer: () => throw UnimplementedError(),
createTranslator: () => throw UnimplementedError(),
));
c.settings.showLog = true;
c.settings.display.showLog = true;
c.settings.moveTargets = [MoveTarget('보관', p.join(tmp.path, '보관'))];
for (var i = 1; i <= 4; i++) {
c.videos.add(VideoItem(p.join(tmp.path, '아주 긴 이름의 동영상 파일 $i.mp4')));

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
/// 앱의 기능 묶음 (컴포넌트). 코드는 앱에 들어 있고, 환경 설정 > 컴포넌트에서 설치 (필요한 파일을 받아 켬) · 제거 (끔) 한다.
/// 화면이 있는 것 ([page]) 은 위쪽 이동 버튼 · 좌우로 밀기로 오간다 (순서는 [AppSettings.navOrder]).
/// 화면이 있는 것 ([page]) 은 위쪽 이동 버튼 · 좌우로 밀기로 오간다 (순서는 [AppSettings.display.navOrder]).
class AppComponent {
final String id;

View File

@ -84,7 +84,7 @@ class I18nController extends ChangeNotifier {
}
/// 고를 수 있는 언어: 기본 + 더한 것
List<String> get available => [...builtIn, ...?_app?.settings.uiLanguagesAdded];
List<String> get available => [...builtIn, ...?_app?.settings.display.languagesAdded];
/// 더할 수 있는 언어 (AI 번역 모델이 아는 언어 중 아직 없는 것)
List<Language> get addable => [
@ -119,7 +119,7 @@ class I18nController extends ChangeNotifier {
}
/// 설정에서 고른 값 ('system' 이면 시스템 언어 따르기)
String get chosen => _app?.settings.uiLanguage ?? uiLanguage;
String get chosen => _app?.settings.display.language ?? uiLanguage;
/// 화면 언어 바꾸기 (설정에 저장하고 화면 전체를 다시 그린다). [system] 이면 기기 언어를 따른다
Future<void> apply(String code, {bool save = true}) async {
@ -129,8 +129,8 @@ class I18nController extends ChangeNotifier {
final dict = code == 'ko' ? const <String, String>{} : await _read(code);
final fallback = code == 'ko' || code == 'en' ? const <String, String>{} : await _read('en');
setTranslations(code, dict, fallback);
if (save && _app != null && _c.settings.uiLanguage != want) {
await _c.updateSettings((x) => x.uiLanguage = want);
if (save && _app != null && _c.settings.display.language != want) {
await _c.updateSettings((x) => x.display.language = want);
}
rebuildAll();
notifyListeners();
@ -179,7 +179,7 @@ class I18nController extends ChangeNotifier {
await Directory(_dir).create(recursive: true);
await File(p.join(_dir, '${l.code}.json'))
.writeAsString(const JsonEncoder.withIndent(' ').convert(dict));
await _c.updateSettings((x) => x.uiLanguagesAdded = [...x.uiLanguagesAdded, l.code]);
await _c.updateSettings((x) => x.display.languagesAdded = [...x.display.languagesAdded, l.code]);
} finally {
await translator.dispose();
}
@ -192,7 +192,7 @@ class I18nController extends ChangeNotifier {
try {
await File(p.join(_dir, '$code.json')).delete();
} catch (_) {}
await _c.updateSettings((x) => x.uiLanguagesAdded = [...x.uiLanguagesAdded]..remove(code));
await _c.updateSettings((x) => x.display.languagesAdded = [...x.display.languagesAdded]..remove(code));
if (uiLanguage == code) {
await apply('ko');
} else {

View File

@ -2,14 +2,13 @@ import 'dart:io';
import 'package:path/path.dart' as p;
import 'components.dart';
import '../core/reader_sources.dart' show defaultImageExtensions;
import '../core/download_detect.dart';
import '../core/encode_options.dart';
import '../core/playlist.dart';
import '../services/app_shell.dart' show appIconOf;
import 'settings/records.dart';
import 'settings/settings_group.dart';
import 'settings/display_settings.dart';
import 'settings/remote_settings.dart';
import 'settings/explorer_tool_settings.dart';
import 'settings/vault_settings.dart';
@ -26,6 +25,7 @@ import 'settings/explorer_settings.dart';
export 'settings/records.dart';
export 'settings/settings_group.dart';
export 'settings/settings_store.dart';
export 'settings/display_settings.dart';
export 'settings/remote_settings.dart';
export 'settings/explorer_tool_settings.dart';
export 'settings/vault_settings.dart';
@ -78,44 +78,19 @@ class AppSettings {
/// 동시에 만드는 MKV 수 (0 = 무제한)
int maxParallelJobs = 5;
/// 메인 화면 아래 작업 기록 창 보이기
bool showLog = true;
/// 폰 세로 (좁은 화면) MKV 목록 크기: 'auto' 목록은 내용만큼 · 자세히 칸에 최소 높이 (처음 값) · 'small' · 'large' (관리자 판단 10/17)
String mkvCompactList = 'auto';
/// 처음 화면: 'home' = 지금처럼 MKV 화면 (기본), 'browser' = 웹 브라우저 ([browser.homeUrl]), 'files' = 파일 탐색기
String startScreen = 'home';
/// 화면 (글자 · 버튼) 크기 배율. [uiScale] 은 지금 크기 (위쪽 막대의 − · + 로 바꿈),
/// [uiScaleDefault] 는 기본 크기 (환경 설정에서 정함, 가운데 숫자를 누르면 이 크기로 돌아감)
double uiScale = 1.0;
double uiScaleDefault = 1.0;
// 18: 50 ~ 250% (사용자 결정)
static const double uiScaleMin = 0.5, uiScaleMax = 2.5;
/// X-plore 비교 5-1 ⑦ 화면 밝기 (관리자 지시 10/21): 'dark' 어둡게 (처음 값 - 예전 그대로) · 'light' 밝게 · 'system' 기기 따라.
/// 다 끝날 때까지 설정에 "시험" 표시. 모르는 값은 어둡게
String uiBrightness = 'dark';
static const uiBrightnessModes = ['dark', 'light', 'system'];
static double clampUiScale(num? v) =>
((v ?? 1.0).toDouble().clamp(uiScaleMin, uiScaleMax) * 20).round() / 20;
/// 탐색기에서 동영상을 열었을 때 (더블클릭 · 연결 프로그램): 'play' 바로 재생 / 'add' 편집 목록에 추가
String openFileAction = 'play';
/// Android 화면 방향: 'auto' 기기 방향 따라 (기본) / 'landscape' 가로 고정 / 'portrait' 세로 고정.
/// 값이 없거나 틀리면 자동 (제한 없는 쪽). 고정은 사용자가 환경 설정에서 고를 때만.
String screenOrientation = 'auto';
/// 화면 언어 (ko · en · ja · zh-Hans, 또는 더한 언어)
String uiLanguage = 'system';
/// 앱 아이콘 (appIconIds 중 하나: 'yellow' 기본 · 'black' · 'film_jj' · 'film' · 'blue')
String appIcon = 'yellow';
/// 더한 화면 언어 (AI 로 자동 번역한 사전이 설정 폴더 l10n 에 있음)
List<String> uiLanguagesAdded = [];
/// 프로그램이 켜져 있을 때 탐색기에서 연 동영상 재생: 'same' 켜져 있는 창에서 / 'new' 새 재생 창
String openFileWindow = 'same';
@ -186,14 +161,8 @@ class AppSettings {
/// 설치한 (켠) 컴포넌트 id (app/components.dart). 처음엔 기본으로 들어 있는 것 모두
List<String> components = AppComponent.defaultInstalled;
/// 화면 순서 (위쪽 이동 버튼 · 화면 가운데를 좌우로 밀어 이동): 마지막 다음은 처음으로
List<String> navOrder = AppComponent.defaultOrder;
/// 화면 가운데를 좌우로 밀어 다음 · 이전 화면으로
bool swipeNav = true;
/// 그림 보기 (만화 보기) 대상 확장자 (파일 탐색기에서 두 번 누르면 보기로 연다)
List<String> imageExts = [...defaultImageExtensions];
@ -210,8 +179,6 @@ class AppSettings {
// ── 102: 사용자에게 물어 정한 것 · 관리자 1차 판단 (기본값 = 그 답) ──
/// 66: 좌우로 밀 때 끝에서 처음으로 (끄면 끝에서 멈춤)
bool swipeWrap = true;
/// 저장 공간 정리 창에서 미리 체크할 묶음 (작업 임시 파일 · 받다 만 다운로드)
@ -336,8 +303,11 @@ class AppSettings {
/// 원격 서버 (WebDAV · SFTP · SMB · FTP)
final remote = RemoteSettings();
/// 화면 · 앱 모양 (배율 · 밝기 · 방향 · 언어 · 앱 아이콘 · 컴포넌트 · 화면 순서 · 밀기)
final display = DisplaySettings();
/// 설정 분야들 (설정 A) - 저마다 자기 키를 읽고 쓴다. toJson · fromJson 은 이 목록을 돌며 합치기만 한다
List<SettingsGroup> get groups => [explorer, network, tor, copy, aiImage, reader, download, browser, run, vault, tools, remote];
List<SettingsGroup> get groups => [explorer, network, tor, copy, aiImage, reader, download, browser, run, vault, tools, remote, display];
/// [plainSecrets]: 안전 저장소를 쓸 수 없을 때만 (예전 파일에 있던 비밀 값을 잃지 않게) 예전처럼 파일에 둔다
Map<String, Object?> toJson({bool plainSecrets = false}) {
@ -361,28 +331,12 @@ class AppSettings {
},
'cookieScopeV2': true,
'maxParallelJobs': maxParallelJobs,
'showLog': showLog,
'mkvCompactList': mkvCompactList,
'startScreen': startScreen,
'uiScale': uiScale,
'uiScaleDefault': uiScaleDefault,
'uiBrightness': uiBrightness,
'openFileAction': openFileAction,
'screenOrientation': screenOrientation,
// 기본이 가로 고정이던 때의 값은 한 번 자동으로 옮긴다 (사용자가 정한 적 없는 가로 고정)
'orientationV2': true,
'uiLanguage': uiLanguage,
'appIcon': appIcon,
'uiLanguagesAdded': uiLanguagesAdded,
'openFileWindow': openFileWindow,
'mkvExisting': mkvExisting,
'components': components,
'aiImageV1': true,
'robocopyLinksV1': true,
'navOrder': navOrder,
'swipeNav': swipeNav,
'imageExts': imageExts,
'swipeWrap': swipeWrap,
'cleanupPrechecked': cleanupPrechecked,
'migrationNotice': migrationNotice,
'allowInnerToOuter': allowInnerToOuter,
@ -439,39 +393,13 @@ class AppSettings {
..openSubtitlesUser = j['openSubtitlesUser'] as String? ?? ''
..openSubtitlesPassword = j['openSubtitlesPassword'] as String? ?? ''
..maxParallelJobs = (j['maxParallelJobs'] as num?)?.toInt() ?? 5
..showLog = j['showLog'] as bool? ?? true
..mkvCompactList = const ['auto', 'small', 'large'].contains(j['mkvCompactList']) ? j['mkvCompactList'] as String : 'auto'
..startScreen = j['startScreen'] as String? ?? 'home'
..uiScale = clampUiScale(j['uiScale'] as num?)
..uiScaleDefault = clampUiScale(j['uiScaleDefault'] as num?)
..uiBrightness = uiBrightnessModes.contains(j['uiBrightness']) ? j['uiBrightness'] as String : 'dark'
..openFileAction = j['openFileAction'] == 'add' ? 'add' : 'play'
..uiLanguage = j['uiLanguage'] as String? ?? 'ko'
..appIcon = appIconOf(j['appIcon'] as String?)
..uiLanguagesAdded = [for (final x in (j['uiLanguagesAdded'] as List?) ?? const []) '$x']
..screenOrientation = j['orientationV2'] == true && const ['landscape', 'portrait', 'auto'].contains(j['screenOrientation'])
? j['screenOrientation'] as String
: 'auto'
..openFileWindow = j['openFileWindow'] == 'new' ? 'new' : 'same'
..mkvExisting = const ['rename', 'overwrite', 'skip'].contains(j['mkvExisting']) ? j['mkvExisting'] as String : 'ask'
..components = j['components'] is List
? [
// 225: 이 판이 모르는 구성 요소 (새 판의 Tor 등) 도 지우지 않고 그대로 둔다 - 예전 판을 거쳐 다시 올려도 켠 것이 남게 (40 과 같은 원칙).
// 화면 · 버튼은 아는 것만 쓴다 (AppComponent.pages · contains)
for (final x in j['components'] as List) if (x is String && x.isNotEmpty) x,
// 123: 새 화면 "AI 그림" 은 예전 설정에도 한 번 켜서 넣는다 (끄기: 환경 설정 > 컴포넌트)
if (j['aiImageV1'] != true && !(j['components'] as List).contains('aiimage')) 'aiimage',
]
: AppComponent.defaultInstalled
..navOrder = j['navOrder'] is List
// 225: 모르는 화면 id 도 순서에 그대로 (그 판으로 돌아가면 사용자가 정한 순서대로)
? [for (final x in j['navOrder'] as List) if (x is String && x.isNotEmpty) x]
: AppComponent.defaultOrder
..swipeNav = j['swipeNav'] as bool? ?? true
..imageExts = j['imageExts'] is List
? [for (final x in j['imageExts'] as List) '$x'.toLowerCase()]
: [...defaultImageExtensions]
..swipeWrap = j['swipeWrap'] != false
..cleanupPrechecked = j['cleanupPrechecked'] is List
? [for (final x in j['cleanupPrechecked'] as List) '$x']
: ['work', 'download']
@ -559,7 +487,7 @@ class AppSettings {
}
static List<String> _migrated(Map<String, dynamic> j, AppSettings s) => [
if (j['orientationV2'] != true && j['screenOrientation'] == 'landscape' && s.screenOrientation == 'auto')
if (j['orientationV2'] != true && j['screenOrientation'] == 'landscape' && s.display.orientation == 'auto')
'orientation',
if (j['explorerV2'] != true && j['explorerLayout'] == 'dual' && s.explorer.layout == 'auto') 'explorerLayout',
if (j['explorerV2'] != true && j['explorerClick'] == 'select' && s.explorer.click == 'open') 'explorerClick',
@ -580,8 +508,8 @@ class AppSettings {
if (j.isNotEmpty && j['explorerRulesV1'] != true && Platform.isAndroid && j['explorerClick'] != 'select')
'explorerClickRule',
if (j.isNotEmpty && j['explorerRulesV1'] != true && (j['explorerLayout'] ?? 'auto') == 'auto') 'explorerLayoutRule',
if (j.isNotEmpty && !j.containsKey('screenOrientation') && s.screenOrientation == 'auto' && Platform.isAndroid) 'orientation',
if (j.isNotEmpty && !j.containsKey('swipeNav') && s.swipeNav) 'swipeNav',
if (j.isNotEmpty && !j.containsKey('screenOrientation') && s.display.orientation == 'auto' && Platform.isAndroid) 'orientation',
if (j.isNotEmpty && !j.containsKey('swipeNav') && s.display.swipeNav) 'swipeNav',
if (j.isNotEmpty && !j.containsKey('runInBackground') && s.run.inBackground && Platform.isAndroid) 'background',
// DNS 강제 (사용자 요청 10/10, 처음 값 직접 지정 8.8.8.8 · 8.8.4.4) 가 없던 판에서 올라오면 이름을 묻는 서버가 바뀐다 - 193 과 같이 한 번 알림
if (j.isNotEmpty && !j.containsKey('dnsMode') && s.network.mode == 'custom') 'dnsForced',

View File

@ -0,0 +1,106 @@
import '../components.dart';
import '../../services/app_shell.dart';
import 'settings_group.dart';
/// 약속 (설정 A): 환경 설정 > 화면 · 앱 모양 (배율 · 밝기 · 방향 · 언어 · 앱 아이콘 · 컴포넌트 · 화면 순서 · 밀기) - 자기 키 (이름 · 처음 값 · 읽기 · 쓰기) 를 스스로 갖는다.
/// 설정 하나를 더할 때는 이 파일만 고친다. 저장 파일의 키 이름은 예전 그대로 (평평한 키)
class DisplaySettings implements SettingsGroup {
/// 메인 화면 아래 작업 기록 창 보이기
bool showLog = true;
/// 처음 화면: 'home' = 지금처럼 MKV 화면 (기본), 'browser' = 웹 브라우저 ([browser.homeUrl]), 'files' = 파일 탐색기
String startScreen = 'home';
/// 화면 (글자 · 버튼) 크기 배율. [scale] 은 지금 크기 (위쪽 막대의 − · + 로 바꿈),
/// [scaleDefault] 는 기본 크기 (환경 설정에서 정함, 가운데 숫자를 누르면 이 크기로 돌아감)
double scale = 1.0;
double scaleDefault = 1.0;
/// X-plore 비교 5-1 ⑦ 화면 밝기 (관리자 지시 10/21): 'dark' 어둡게 (처음 값 - 예전 그대로) · 'light' 밝게 · 'system' 기기 따라.
/// 다 끝날 때까지 설정에 "시험" 표시. 모르는 값은 어둡게
String brightness = 'dark';
static const brightnessModes = ['dark', 'light', 'system'];
/// Android 화면 방향: 'auto' 기기 방향 따라 (기본) / 'landscape' 가로 고정 / 'portrait' 세로 고정.
/// 값이 없거나 틀리면 자동 (제한 없는 쪽). 고정은 사용자가 환경 설정에서 고를 때만.
String orientation = 'auto';
/// 화면 언어 (ko · en · ja · zh-Hans, 또는 더한 언어)
String language = 'system';
/// 앱 아이콘 (appIconIds 중 하나: 'yellow' 기본 · 'black' · 'film_jj' · 'film' · 'blue')
String appIcon = 'yellow';
/// 더한 화면 언어 (AI 로 자동 번역한 사전이 설정 폴더 l10n 에 있음)
List<String> languagesAdded = [];
/// 설치한 (켠) 컴포넌트 id (app/components.dart). 처음엔 기본으로 들어 있는 것 모두
List<String> components = AppComponent.defaultInstalled;
/// 화면 순서 (위쪽 이동 버튼 · 화면 가운데를 좌우로 밀어 이동): 마지막 다음은 처음으로
List<String> navOrder = AppComponent.defaultOrder;
/// 화면 가운데를 좌우로 밀어 다음 · 이전 화면으로
bool swipeNav = true;
/// 66: 좌우로 밀 때 끝에서 처음으로 (끄면 끝에서 멈춤)
bool swipeWrap = true;
// 18: 50 ~ 250% (사용자 결정)
static const double scaleMin = 0.5, scaleMax = 2.5;
static double clampScale(num? v) =>
((v ?? 1.0).toDouble().clamp(scaleMin, scaleMax) * 20).round() / 20;
@override
void write(Map<String, Object?> j, {required bool plainSecrets}) {
j['showLog'] = showLog;
j['startScreen'] = startScreen;
j['uiScale'] = scale;
j['uiScaleDefault'] = scaleDefault;
j['uiBrightness'] = brightness;
j['screenOrientation'] = orientation;
j['uiLanguage'] = language;
j['appIcon'] = appIcon;
j['uiLanguagesAdded'] = languagesAdded;
j['components'] = components;
j['navOrder'] = navOrder;
j['swipeNav'] = swipeNav;
j['swipeWrap'] = swipeWrap;
// 기본이 가로 고정이던 때의 값은 한 번 자동으로 옮긴다 (사용자가 정한 적 없는 가로 고정)
j['orientationV2'] = true;
j['aiImageV1'] = true;
}
@override
void read(Map<String, dynamic> j) {
showLog = j['showLog'] as bool? ?? true;
startScreen = j['startScreen'] as String? ?? 'home';
scale = clampScale(j['uiScale'] as num?);
scaleDefault = clampScale(j['uiScaleDefault'] as num?);
brightness = brightnessModes.contains(j['uiBrightness']) ? j['uiBrightness'] as String : 'dark';
language = j['uiLanguage'] as String? ?? 'ko';
appIcon = appIconOf(j['appIcon'] as String?);
languagesAdded = [for (final x in (j['uiLanguagesAdded'] as List?) ?? const []) '$x'];
orientation = j['orientationV2'] == true && const ['landscape', 'portrait', 'auto'].contains(j['screenOrientation'])
? j['screenOrientation'] as String
: 'auto';
components = j['components'] is List
? [
// 225: 이 판이 모르는 구성 요소 (새 판의 Tor 등) 도 지우지 않고 그대로 둔다 - 예전 판을 거쳐 다시 올려도 켠 것이 남게 (40 과 같은 원칙).
// 화면 · 버튼은 아는 것만 쓴다 (AppComponent.pages · contains)
for (final x in j['components'] as List) if (x is String && x.isNotEmpty) x,
// 123: 새 화면 "AI 그림" 은 예전 설정에도 한 번 켜서 넣는다 (끄기: 환경 설정 > 컴포넌트)
if (j['aiImageV1'] != true && !(j['components'] as List).contains('aiimage')) 'aiimage',
]
: AppComponent.defaultInstalled;
navOrder = j['navOrder'] is List
// 225: 모르는 화면 id 도 순서에 그대로 (그 판으로 돌아가면 사용자가 정한 순서대로)
? [for (final x in j['navOrder'] as List) if (x is String && x.isNotEmpty) x]
: AppComponent.defaultOrder;
swipeNav = j['swipeNav'] as bool? ?? true;
swipeWrap = j['swipeWrap'] != false;
}
}

View File

@ -105,7 +105,7 @@ Future<void> main(List<String> args) async {
final claimed = await instance.claim(request, waitForExit: args.contains('--restart'), forward: !newWindow);
if (claimed == ClaimResult.unresponsive) {
// 같은 데이터 위에 창이 두 개 뜨지 않게 묻는다 (처음 고른 것은 [닫기] - 관리자 결정 10/12)
await i18n.apply(startSettings.uiLanguage, save: false);
await i18n.apply(startSettings.display.language, save: false);
if (askOpenAnyway(appTitle)) {
await instance.takeOverAnyway(request);
} else {
@ -151,7 +151,7 @@ Future<void> main(List<String> args) async {
final live = LiveSync(controller)..start(hold: controller.settings.copy.liveSyncOnStart != 'auto');
// 화면 언어 (환경 설정 > 화면 언어)
i18n.init(controller, dataDir);
await i18n.apply(controller.settings.uiLanguage, save: false);
await i18n.apply(controller.settings.display.language, save: false);
final bookmarks = BookmarksController();
await bookmarks.load();
@ -259,7 +259,7 @@ Future<void> main(List<String> args) async {
if (nav != null) unawaited(DownloadsPage.open(nav, downloads));
},
);
await services.shell.setAppIcon(controller.settings.appIcon);
await services.shell.setAppIcon(controller.settings.display.appIcon);
// 99: 시작 메뉴 바로 가기 (앱 ID) · jjmkvmaker:// 연결 - 알림이 JJ_MKVMaker 로 보이고 눌러서 열린다 (설정에서 끔)
StartMenu.setProcessAppId();
final shell0 = services.shell;
@ -598,9 +598,9 @@ Future<void> runAndroid(String dataDir) async {
final live = LiveSync(controller)..start(hold: controller.settings.copy.liveSyncOnStart != 'auto');
// 화면 언어 (환경 설정 > 화면 언어)
i18n.init(controller, dataDir);
await i18n.apply(controller.settings.uiLanguage, save: false);
await applyScreenOrientation(controller.settings.screenOrientation);
unawaited(services.shell.setAppIcon(controller.settings.appIcon));
await i18n.apply(controller.settings.display.language, save: false);
await applyScreenOrientation(controller.settings.display.orientation);
unawaited(services.shell.setAppIcon(controller.settings.display.appIcon));
final bookmarks = BookmarksController();
await bookmarks.load();
final navigatorKey = GlobalKey<NavigatorState>();
@ -649,7 +649,7 @@ Future<void> runAndroid(String dataDir) async {
var hadAccess = await AndroidAccess.hasAllFiles();
if (hadAccess) await allFilesGuideDue(controller, hasAccess: true);
AppLifecycleListener(onResume: () async {
await applyScreenOrientation(controller.settings.screenOrientation);
await applyScreenOrientation(controller.settings.display.orientation);
final now = await AndroidAccess.hasAllFiles();
if (now && !hadAccess) await controller.reanalyzeFailed();
if (now) await allFilesGuideDue(controller, hasAccess: true);
@ -772,7 +772,7 @@ Future<void> runSecondWindow(List<String> files, String dataDir) async {
await setupNetwork(controller, dataDir, second: true);
// 화면 언어 (환경 설정 > 화면 언어)
i18n.init(controller, dataDir);
await i18n.apply(controller.settings.uiLanguage, save: false);
await i18n.apply(controller.settings.display.language, save: false);
final store = SettingsStore();
controller.settings = await store.load();
final navigatorKey = GlobalKey<NavigatorState>();
@ -893,7 +893,7 @@ class _BrightnessHostState extends State<_BrightnessHost> with WidgetsBindingObs
}
Brightness _want() =>
resolveBrightness(widget.c.settings.uiBrightness, WidgetsBinding.instance.platformDispatcher.platformBrightness);
resolveBrightness(widget.c.settings.display.brightness, WidgetsBinding.instance.platformDispatcher.platformBrightness);
@override
void didChangePlatformBrightness() => _check();

View File

@ -203,8 +203,8 @@ class AppNavButtons extends StatelessWidget {
/// 홈 화면 (환경 설정의 "홈 화면") 의 컴포넌트 id. 그 컴포넌트를 제거했으면 설치된 첫 화면.
static String homeId(AppSettings s) {
final want = switch (s.startScreen) { 'browser' => 'browser', 'files' => 'explorer', 'tor' => 'tor', _ => 'mkv' };
final pages = AppComponent.pages(s.components, s.navOrder);
final want = switch (s.display.startScreen) { 'browser' => 'browser', 'files' => 'explorer', 'tor' => 'tor', _ => 'mkv' };
final pages = AppComponent.pages(s.display.components, s.display.navOrder);
if (pages.any((p) => p.id == want)) return want;
return pages.isEmpty ? 'mkv' : pages.first.id;
}
@ -232,7 +232,7 @@ class AppNavButtons extends StatelessWidget {
/// 이 창에서 열 수 있는 화면 (브라우저 · 다운로드가 없는 새 창에서는 그 둘을 뺀다)
static List<AppComponent> pagesOf(AppScope scope) => [
for (final p in AppComponent.pages(scope.controller.settings.components, scope.controller.settings.navOrder))
for (final p in AppComponent.pages(scope.controller.settings.display.components, scope.controller.settings.display.navOrder))
if ((p.id != 'browser' || scope.bookmarks != null) && (p.id != 'downloads' || scope.downloads != null)) p,
];
@ -263,7 +263,7 @@ class AppNavButtons extends StatelessWidget {
final j = i + step;
// 102 · 66: 끝에서 처음으로 돌기를 끄면 끝에서 멈춘다
final wraps = j < 0 || j >= pages.length;
if (!scope.controller.settings.swipeWrap && wraps) {
if (!scope.controller.settings.display.swipeWrap && wraps) {
_hint(context, step > 0 ? tr('마지막 화면입니다') : tr('첫 화면입니다'));
return;
}
@ -409,7 +409,7 @@ class AppNavButtons extends StatelessWidget {
};
return Row(mainAxisSize: MainAxisSize.min, children: [
btn(
Image.asset(appIconButtonAsset(scope?.controller.settings.appIcon),
Image.asset(appIconButtonAsset(scope?.controller.settings.display.appIcon),
width: labels ? 22 : 26, height: labels ? 22 : 26, filterQuality: FilterQuality.medium),
trf('홈 화면 ({0}) · 환경 설정에서 바꿈', [homeName]),
scope == null ? null : () => toHome(context),
@ -463,7 +463,7 @@ class _SwipeNavState extends State<SwipeNav> {
onHorizontalDragEnd: (d) {
final v = d.primaryVelocity ?? 0;
// 켜고 끄기는 밀 때 확인 (이 위젯은 설정이 바뀌어도 다시 그려지지 않을 수 있음)
if (!scope.controller.settings.swipeNav || !_middle || v.abs() < 500) return;
if (!scope.controller.settings.display.swipeNav || !_middle || v.abs() < 500) return;
// 왼쪽으로 밀면 다음 화면, 오른쪽으로 밀면 이전 화면
AppNavButtons.step(context, widget.current, v < 0 ? 1 : -1);
},
@ -536,13 +536,13 @@ class ScaleButtons extends StatelessWidget {
final AppController c;
const ScaleButtons({super.key, required this.c});
void _set(double v) => c.updateSettings((s) => s.uiScale = AppSettings.clampUiScale(v));
void _set(double v) => c.updateSettings((s) => s.display.scale = DisplaySettings.clampScale(v));
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: c,
builder: (context, _) {
final s = c.settings.uiScale;
final s = c.settings.display.scale;
Widget btn(IconData icon, String tip, VoidCallback? f) => SizedBox(
width: 26,
height: 32,
@ -557,12 +557,12 @@ class ScaleButtons extends StatelessWidget {
return Padding(
padding: const EdgeInsets.only(left: 6),
child: Row(mainAxisSize: MainAxisSize.min, children: [
btn(Icons.remove, tr('화면 작게'), s <= AppSettings.uiScaleMin + 0.001 ? null : () => _set(s - 0.1)),
btn(Icons.remove, tr('화면 작게'), s <= DisplaySettings.scaleMin + 0.001 ? null : () => _set(s - 0.1)),
Tooltip(
message: trf('기본 크기 ({0}%) 로 · 기본 크기는 환경 설정 > 화면 에서', [(c.settings.uiScaleDefault * 100).round()]),
message: trf('기본 크기 ({0}%) 로 · 기본 크기는 환경 설정 > 화면 에서', [(c.settings.display.scaleDefault * 100).round()]),
child: InkWell(
borderRadius: BorderRadius.circular(4),
onTap: () => _set(c.settings.uiScaleDefault),
onTap: () => _set(c.settings.display.scaleDefault),
child: SizedBox(
width: 40,
height: 28,
@ -573,14 +573,14 @@ class ScaleButtons extends StatelessWidget {
),
),
),
btn(Icons.add, tr('화면 크게'), s >= AppSettings.uiScaleMax - 0.001 ? null : () => _set(s + 0.1)),
btn(Icons.add, tr('화면 크게'), s >= DisplaySettings.scaleMax - 0.001 ? null : () => _set(s + 0.1)),
]),
);
},
);
}
/// 화면 전체를 [AppSettings.uiScale] 배로 그린다 (안쪽은 그만큼 작은 · 큰 화면이라고 여기고 배치).
/// 화면 전체를 [AppSettings.display.scale] 배로 그린다 (안쪽은 그만큼 작은 · 큰 화면이라고 여기고 배치).
/// 앱의 builder 에서 한 번 감싼다 - 대화 상자 · 메뉴 · 안내 글까지 모두 같은 배율.
class UiScaler extends StatelessWidget {
final AppController c;
@ -591,7 +591,7 @@ class UiScaler extends StatelessWidget {
Widget build(BuildContext context) => ListenableBuilder(
listenable: c,
builder: (context, _) {
final s = c.settings.uiScale;
final s = c.settings.display.scale;
if ((s - 1).abs() < 0.001) return child;
return LayoutBuilder(builder: (context, box) {
final size = box.biggest / s;
@ -655,11 +655,11 @@ class AppActions extends StatelessWidget {
final s = controller?.settings;
switch (v) {
case 'bigger' when s != null:
controller!.updateSettings((x) => x.uiScale = AppSettings.clampUiScale(s.uiScale + 0.1));
controller!.updateSettings((x) => x.display.scale = DisplaySettings.clampScale(s.display.scale + 0.1));
case 'smaller' when s != null:
controller!.updateSettings((x) => x.uiScale = AppSettings.clampUiScale(s.uiScale - 0.1));
controller!.updateSettings((x) => x.display.scale = DisplaySettings.clampScale(s.display.scale - 0.1));
case 'default' when s != null:
controller!.updateSettings((x) => x.uiScale = s.uiScaleDefault);
controller!.updateSettings((x) => x.display.scale = s.display.scaleDefault);
case 'exit':
exit?.call();
}
@ -676,8 +676,8 @@ class AppActions extends StatelessWidget {
value: 'default',
child: ListTile(
leading: const Icon(Icons.fit_screen_outlined),
title: Text(trf('기본 크기 ({0}%)', [(controller.settings.uiScaleDefault * 100).round()])),
subtitle: Text(trf('지금 {0}%', [(controller.settings.uiScale * 100).round()])),
title: Text(trf('기본 크기 ({0}%)', [(controller.settings.display.scaleDefault * 100).round()])),
subtitle: Text(trf('지금 {0}%', [(controller.settings.display.scale * 100).round()])),
dense: true)),
],
if (exit != null) ...[

View File

@ -381,10 +381,10 @@ class _BrowserPageState extends State<BrowserPage> with RouteAware {
if (s.browser.translate != _lastTranslate) {
_lastTranslate = s.browser.translate;
_setTranslate(_lastTranslate, force: false);
} else if (s.uiLanguage != _lastLang && _trOn) {
} else if (s.display.language != _lastLang && _trOn) {
_translateNow(); // 다른 언어로 다시 번역 (스크립트가 앞의 번역을 되돌린 뒤 번역)
}
_lastLang = s.uiLanguage;
_lastLang = s.display.language;
}
void _setTranslate(bool on, {required bool force}) {
@ -450,13 +450,13 @@ class _BrowserPageState extends State<BrowserPage> with RouteAware {
final s = widget.c.settings;
_lastTranslate = s.browser.translate;
_lastJs = s.browser.javaScript;
_lastLang = s.uiLanguage;
_lastLang = s.display.language;
_applyCookieExport();
widget.c.addListener(_onSettings);
if (_trOn) _translateNow();
// 66-2: 브라우저 가운데는 페이지가 좌우로 밀리므로 화면 옮기기는 위쪽 막대에서 - 처음 한 번만 알린다.
// 알림 (SnackBar) 은 다른 알림 (다운로드 등) 을 뒤로 밀어 기다리게 하므로 위쪽 막대 아래의 한 줄로
if (s.swipeNav && !s.browser.swipeHinted) {
if (s.display.swipeNav && !s.browser.swipeHinted) {
_swipeHint = true;
WidgetsBinding.instance.addPostFrameCallback((_) => widget.c.updateSettings((x) => x.browser.swipeHinted = true));
}
@ -520,7 +520,7 @@ class _BrowserPageState extends State<BrowserPage> with RouteAware {
/// Tor 브라우저로: 켜져 있으면 Tor 화면, 아니면 짧은 안내와 [환경 설정 > 컴포넌트]
Future<void> _openTor() async {
if (widget.c.settings.components.contains('tor')) {
if (widget.c.settings.display.components.contains('tor')) {
await TorPage.open(Navigator.of(context), c: widget.c);
return;
}
@ -883,7 +883,7 @@ class _BrowserPageState extends State<BrowserPage> with RouteAware {
Icons.translate,
_trOn
? tr('원문 보기 (번역 끄기)')
: trf('이 페이지를 {0}(으)로 번역', [I18nController.nativeName(widget.c.settings.uiLanguage)]),
: trf('이 페이지를 {0}(으)로 번역', [I18nController.nativeName(widget.c.settings.display.language)]),
() => _setTranslate(!_trOn, force: true),
color: _trOn ? JjColors.accent : null),
const SizedBox(width: 4),

View File

@ -21,7 +21,7 @@ class ViewerSettings extends StatelessWidget {
@override
Widget build(BuildContext context) {
final s = c.settings;
if (!s.components.contains('viewer')) return const SizedBox();
if (!s.display.components.contains('viewer')) return const SizedBox();
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
SettingTile(
leading: const Icon(Icons.photo_library_outlined),
@ -115,7 +115,7 @@ Future<void> _setDownloaded(BuildContext context, AppComponent x, bool on) async
);
if (ok != true) return;
await store.remove(x.id);
await c.updateSettings((v) => v.components = [for (final id in v.components) if (id != x.id) id]);
await c.updateSettings((v) => v.display.components = [for (final id in v.display.components) if (id != x.id) id]);
return;
}
final step = ValueNotifier<(String, double?)>((tr('목록을 받는 중'), null));
@ -157,7 +157,7 @@ Future<void> _setDownloaded(BuildContext context, AppComponent x, bool on) async
await dialog;
step.dispose();
if (done) {
await c.updateSettings((v) => v.components = [for (final id in v.components) if (id != x.id) id, x.id]);
await c.updateSettings((v) => v.display.components = [for (final id in v.display.components) if (id != x.id) id, x.id]);
}
if (context.mounted) {
ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(
@ -174,8 +174,8 @@ class ComponentSettings extends StatelessWidget {
@override
Widget build(BuildContext context) {
final s = c.settings;
final installed = s.components;
final pages = AppComponent.pages(installed, s.navOrder);
final installed = s.display.components;
final pages = AppComponent.pages(installed, s.display.navOrder);
Future<void> setInstalled(AppComponent x, bool on) async {
if (x.needsDownload) return _setDownloaded(context, x, on);
@ -186,8 +186,8 @@ class ComponentSettings extends StatelessWidget {
?.showSnackBar(SnackBar(content: Text(tr('화면이 있는 컴포넌트는 하나 이상 남아 있어야 합니다.'))));
return;
}
await c.updateSettings((v) => v.components = [
for (final id in v.components) if (id != x.id) id,
await c.updateSettings((v) => v.display.components = [
for (final id in v.display.components) if (id != x.id) id,
if (on) x.id,
]);
}
@ -198,21 +198,21 @@ class ComponentSettings extends StatelessWidget {
ids.insert(to, ids.removeAt(from));
// 아직 켜지 않은 Tor 는 순서에 넣지 않는다 (켜면 웹 브라우저 바로 뒤에 - AppComponent.fullOrder).
// 225: 이 판이 모르는 화면 (다른 판의 것) 은 지우지 않고 그 자리에
c.updateSettings((v) => v.navOrder = AppComponent.reorder(v.navOrder,
c.updateSettings((v) => v.display.navOrder = AppComponent.reorder(v.display.navOrder,
[...ids, ...AppComponent.defaultOrder.where((x) => !ids.contains(x) && x != AppComponent.tor.id)]));
}
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
SwitchListTile(
value: s.swipeNav,
onChanged: (v) => c.updateSettings((x) => x.swipeNav = v),
value: s.display.swipeNav,
onChanged: (v) => c.updateSettings((x) => x.display.swipeNav = v),
title: Text(tr('좌우로 밀어 화면 이동')),
subtitle: Text(tr('화면 가운데를 왼쪽으로 밀면 다음 화면, 오른쪽으로 밀면 이전 화면 (마지막 다음은 처음으로).')),
),
// 102 · 66: 끝에서 처음으로 돌기 (기본 켜짐)
SwitchListTile(
value: s.swipeWrap,
onChanged: s.swipeNav ? (v) => c.updateSettings((x) => x.swipeWrap = v) : null,
value: s.display.swipeWrap,
onChanged: s.display.swipeNav ? (v) => c.updateSettings((x) => x.display.swipeWrap = v) : null,
title: Text(tr('끝에서 처음으로 돌기')),
subtitle: Text(tr('마지막 화면에서 더 밀면 첫 화면으로 (끄면 끝에서 멈춤)')),
),
@ -302,7 +302,7 @@ Future<void> _setTor(BuildContext context, AppController c, bool on) async {
return;
}
}
await c.updateSettings((v) => v.components = [for (final id in v.components) if (id != TorBundle.id) id]);
await c.updateSettings((v) => v.display.components = [for (final id in v.display.components) if (id != TorBundle.id) id]);
return;
}
if (Platform.isAndroid) {
@ -353,7 +353,7 @@ Future<void> _setTor(BuildContext context, AppController c, bool on) async {
return;
}
}
await c.updateSettings((v) => v.components = [for (final id in v.components) if (id != TorBundle.id) id, TorBundle.id]);
await c.updateSettings((v) => v.display.components = [for (final id in v.display.components) if (id != TorBundle.id) id, TorBundle.id]);
if (context.mounted) {
ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(trf('{0} 을(를) 설치했습니다.', [name]))));
}

View File

@ -50,7 +50,7 @@ class ExplorerItemMenu {
final audio = !e.isDir && isAudioFile(e.path);
final dav = isDav(e.path);
// 그림 → PDF: 표시한 것 중 그림 (누른 것이 표시에 없으면 누른 것만)
final viewer = ctl.c.settings.components.contains('viewer');
final viewer = ctl.c.settings.display.components.contains('viewer');
final picked = pane.sel.has(e.path) ? pane.sel.marked : [e.path];
final images = [for (final x in picked) if (ctl.c.settings.imageExts.contains(extOf(x))) x];
final ext = extOf(e.path);

View File

@ -57,7 +57,7 @@ class ExplorerOpener {
}
// 여는 길은 file_opener (E5c) 가 고른다
switch (openRouteFor(path,
components: ctl.c.settings.components,
components: ctl.c.settings.display.components,
imageExts: ctl.c.settings.imageExts,
android: Platform.isAndroid,
windows: Platform.isWindows,

View File

@ -95,7 +95,7 @@ class HomePage extends StatelessWidget {
const Divider(height: 1),
// 249: DNS 서버에 닿지 않는 동안 작업 기록 위에 (아래 안내를 놓쳐도 MKV 화면에서 보고 바꿀 수 있게)
Padding(padding: const EdgeInsets.symmetric(horizontal: 12), child: DnsFailureLine(c: c, compact: isCompact(context))),
if (c.settings.showLog)
if (c.settings.display.showLog)
SizedBox(
// 화면이 낮은 기기 (휴대폰 가로) 에서 목록 · 자세히 보기를 가리지 않도록 화면 높이에 맞춘다.
// 256: 폰 가로 (높이 500 미만) 는 더 작게 - 목록이 한 줄만 보였다
@ -107,13 +107,13 @@ class HomePage extends StatelessWidget {
: (MediaQuery.sizeOf(context).height * 0.22).clamp(80.0, 140.0),
child: _LogPanel(
logs: c.logs,
onClose: () => c.updateSettings((s) => s.showLog = false),
onClose: () => c.updateSettings((s) => s.display.showLog = false),
),
)
else
_LogStrip(
last: c.logs.isEmpty ? '' : c.logs.last,
onOpen: () => c.updateSettings((s) => s.showLog = true),
onOpen: () => c.updateSettings((s) => s.display.showLog = true),
),
],
),

View File

@ -35,7 +35,7 @@ Future<void> qualityReaderPage(BuildContext context, AppController c, ReaderSour
/// 지금 보는 그림 파일 (이 기기의 그림 파일일 때, AI 그림 컴포넌트가 켜져 있을 때). [hasPages] 볼 장이 있는지
String? readerAiSource(AppController c, ReaderSource src, int page, {required bool hasPages}) {
final s = src;
if (!c.settings.components.contains('aiimage') || s is! ImageFilesSource || !hasPages) return null;
if (!c.settings.display.components.contains('aiimage') || s is! ImageFilesSource || !hasPages) return null;
final path = s.paths[page];
return isDav(path) ? null : path;
}

View File

@ -63,7 +63,7 @@ List<ReaderBarItem> readerBarItems(BuildContext context, AppController c, Reader
final s = c.settings;
final pdf = src is PdfSource ? src : null;
final pictures = src is ImageFilesSource || src is ZipImagesSource;
final aiOn = c.settings.components.contains('aiimage');
final aiOn = c.settings.display.components.contains('aiimage');
final playing = st.playing;
final secs = s.reader.autoSeconds;
ReaderBarItem one(ReaderAct a) => ReaderBarItem(48, [a]);

View File

@ -59,7 +59,7 @@ class _BrowserSettingsSectionState extends State<BrowserSettingsSection> {
subtitle: Text(trf(
'다른 언어로 된 페이지를 화면 언어 ({0}) 로 번역합니다. 페이지의 글을 Google 번역으로 보냅니다. '
'주소창 옆 번역 버튼으로 원문 · 번역을 바꿀 수 있습니다.',
[I18nController.nativeName(s.uiLanguage)])),
[I18nController.nativeName(s.display.language)])),
),
SwitchListTile(
value: s.browser.javaScript,

View File

@ -77,15 +77,15 @@ class _GeneralSettingsSectionState extends State<GeneralSettingsSection> {
title: Text(tr('홈 화면')),
subtitle: Text(tr('프로그램을 켰을 때, 그리고 위쪽 왼쪽 JJ 아이콘을 눌렀을 때 보일 화면')),
trailing: DropdownButton<String>(
value: s.startScreen,
value: s.display.startScreen,
items: [
DropdownMenuItem(value: 'home', child: Text(tr('MKV 화면 (기본)'))),
DropdownMenuItem(value: 'browser', child: Text(tr('웹 브라우저 (홈 주소)'))),
DropdownMenuItem(value: 'files', child: Text(tr('파일 탐색기'))),
if (s.components.contains('tor') || s.startScreen == 'tor')
if (s.display.components.contains('tor') || s.display.startScreen == 'tor')
DropdownMenuItem(value: 'tor', child: Text(tr('Tor 브라우저'))),
],
onChanged: (v) => c.updateSettings((x) => x.startScreen = v!),
onChanged: (v) => c.updateSettings((x) => x.display.startScreen = v!),
),
),
]);
@ -118,37 +118,37 @@ class _DisplaySettingsSectionState extends State<DisplaySettingsSection> {
title: Text(tr('화면 방향')),
subtitle: Text(tr('가로 고정 · 세로 고정, 또는 자동 (기기를 돌리는 대로, 기기의 자동 회전 설정을 따름)')),
trailing: DropdownButton<String>(
value: s.screenOrientation,
value: s.display.orientation,
items: [
DropdownMenuItem(value: 'auto', child: Text(tr('자동 (기본)'))),
DropdownMenuItem(value: 'landscape', child: Text(tr('가로 고정'))),
DropdownMenuItem(value: 'portrait', child: Text(tr('세로 고정'))),
],
onChanged: (v) {
c.updateSettings((x) => x.screenOrientation = v!);
c.updateSettings((x) => x.display.orientation = v!);
applyScreenOrientation(v!);
},
),
),
SettingTile(
title: Text(tr('기본 화면 크기')),
subtitle: Text(trf('글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 ' '(지금 {0}%)', [(s.uiScale * 100).round()])),
subtitle: Text(trf('글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 ' '(지금 {0}%)', [(s.display.scale * 100).round()])),
trailing: DropdownButton<double>(
value: s.uiScaleDefault,
value: s.display.scaleDefault,
items: [
for (var v = AppSettings.uiScaleMin; v <= AppSettings.uiScaleMax + 0.001; v += 0.1)
for (var v = DisplaySettings.scaleMin; v <= DisplaySettings.scaleMax + 0.001; v += 0.1)
DropdownMenuItem(
value: AppSettings.clampUiScale(v),
value: DisplaySettings.clampScale(v),
child: Text('${(v * 100).round()}%${(v - 1).abs() < 0.001 ? tr(' (보통)') : ''}')),
// 5% 단위로 저장된 값이 목록에 없을 때
if (((s.uiScaleDefault * 100).round() % 10) != 0)
if (((s.display.scaleDefault * 100).round() % 10) != 0)
DropdownMenuItem(
value: s.uiScaleDefault, child: Text('${(s.uiScaleDefault * 100).round()}%')),
value: s.display.scaleDefault, child: Text('${(s.display.scaleDefault * 100).round()}%')),
],
// 기본 크기를 바꾸면 지금 크기도 그 크기로
onChanged: (v) => c.updateSettings((x) => x
..uiScaleDefault = v!
..uiScale = v),
..display.scaleDefault = v!
..display.scale = v),
),
),
// X-plore 비교 5-1 ⑦ (관리자 지시 10/21): 화면 밝기 - 처음 값 어둡게 (예전 그대로), 다 끝날 때까지 "시험"
@ -164,13 +164,13 @@ class _DisplaySettingsSectionState extends State<DisplaySettingsSection> {
]),
subtitle: Text(tr('밝은 화면 · 기기 따라는 아직 시험 중입니다 - 일부 화면의 색이 덜 맞을 수 있습니다')),
trailing: DropdownButton<String>(
value: s.uiBrightness,
value: s.display.brightness,
items: [
DropdownMenuItem(value: 'dark', child: Text(tr('어두운 화면'))),
DropdownMenuItem(value: 'light', child: Text(tr('밝은 화면'))),
DropdownMenuItem(value: 'system', child: Text(tr('기기 따라'))),
],
onChanged: (v) => c.updateSettings((x) => x.uiBrightness = v!),
onChanged: (v) => c.updateSettings((x) => x.display.brightness = v!),
),
),
]);
@ -186,7 +186,7 @@ class AppIconTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
final now = appIconOf(c.settings.appIcon);
final now = appIconOf(c.settings.display.appIcon);
return SettingTile(
title: Text(tr('앱 아이콘')),
subtitle: Padding(
@ -205,7 +205,7 @@ class AppIconTile extends StatelessWidget {
onTap: id == now
? null
: () {
c.updateSettings((x) => x.appIcon = id);
c.updateSettings((x) => x.display.appIcon = id);
c.services.shell.setAppIcon(id);
},
child: Container(

View File

@ -129,12 +129,12 @@ class _SettingsPageState extends State<SettingsPage> {
_group('mkv', Icons.movie_creation_outlined, tr('MKV 만들기'), [MkvSettingsSection(c: c, desk: desk)]),
_group('subtitle', Icons.subtitles_outlined, tr('자막 (AI · 인터넷)'), [SubtitleSettingsSection(c: c, desk: desk)]),
// 123 · 121: 모델 받기 · 처리 장치 · 서버 · 서비스
if (s.components.contains('aiimage'))
if (s.display.components.contains('aiimage'))
_group('ai', Icons.auto_awesome_outlined, tr('AI 그림'), [AiSettings(c: c)]),
_group('play', Icons.play_circle_outline, tr('재생'), [PlaySettingsSection(c: c, desk: desk)]),
_group('browser', Icons.public, tr('웹 브라우저'), [BrowserSettingsSection(c: c, desk: desk)]),
// 사용자 요청 10/10: Tor 브라우저 · DNS 강제
if (s.components.contains('tor'))
if (s.display.components.contains('tor'))
_group('tor', Icons.vpn_lock_outlined, tr('Tor 브라우저'), [TorSettings(c: c)]),
_group('network', Icons.dns_outlined, tr('네트워크 · DNS'), [DnsSettings(c: c)]),
_group('files', Icons.folder_copy_outlined, tr('파일 탐색기'), [FilesSettingsSection(c: c, desk: desk)]),
@ -187,8 +187,8 @@ class _SettingsPageState extends State<SettingsPage> {
final chips = [
for (final id in _groupIds)
if ((id != 'run' || desk) &&
(id != 'ai' || c.settings.components.contains('aiimage')) &&
(id != 'tor' || c.settings.components.contains('tor')))
(id != 'ai' || c.settings.display.components.contains('aiimage')) &&
(id != 'tor' || c.settings.display.components.contains('tor')))
ActionChip(
label: Text(_groupTitle(id), style: const TextStyle(fontSize: 13)),
onPressed: () {

View File

@ -477,7 +477,7 @@ void main() {
final s = AppSettings();
expect((s.aiImage.engine, s.aiImage.device, s.aiImage.taesd, s.aiImage.width, s.aiImage.height, s.aiImage.steps, s.aiImage.cfg, s.aiImage.count),
('local', 'auto', true, 512, 512, 4, 1.0, 1));
expect(s.components, contains('aiimage'));
expect(s.display.components, contains('aiimage'));
final b = AppSettings.fromJson((AppSettings()
..aiImage.width = 768
..aiImage.steps = 8
@ -485,9 +485,9 @@ void main() {
.toJson());
expect((b.aiImage.width, b.aiImage.steps, b.aiImage.taesd), (768, 8, false));
final old = AppSettings.fromJson({'components': ['mkv', 'browser']});
expect(old.components, contains('aiimage'));
expect(old.display.components, contains('aiimage'));
expect(old.migrated, contains('aiImage'));
final off = AppSettings.fromJson((AppSettings()..components = ['mkv']).toJson());
expect(off.components, isNot(contains('aiimage')), reason: '사용자가 끈 것은 그대로');
final off = AppSettings.fromJson((AppSettings()..display.components = ['mkv']).toJson());
expect(off.display.components, isNot(contains('aiimage')), reason: '사용자가 끈 것은 그대로');
});
}

View File

@ -6,11 +6,11 @@ import 'package:jj_mkvmaker/services/app_shell.dart';
void main() {
test('앱 아이콘 설정: 기본은 노랑, 저장 · 불러오기, 모르는 값은 기본으로', () {
expect(AppSettings().appIcon, 'yellow');
final s = AppSettings()..appIcon = 'black';
expect(AppSettings.fromJson(s.toJson()).appIcon, 'black');
expect(AppSettings.fromJson({'appIcon': 'nope'}).appIcon, 'yellow');
expect(AppSettings.fromJson({}).appIcon, 'yellow');
expect(AppSettings().display.appIcon, 'yellow');
final s = AppSettings()..display.appIcon = 'black';
expect(AppSettings.fromJson(s.toJson()).display.appIcon, 'black');
expect(AppSettings.fromJson({'appIcon': 'nope'}).display.appIcon, 'yellow');
expect(AppSettings.fromJson({}).display.appIcon, 'yellow');
});
test('아이콘마다 그림 파일 (설정 미리보기 · 앱 안 버튼 · Windows ico · Android 런처) 이 있다', () {

View File

@ -728,7 +728,7 @@ void main() {
expect(find.byTooltip('MKV 화면 (지금 여기)'), findsOneWidget);
// 홈 화면을 웹 브라우저로 정하면 JJ 아이콘이 브라우저를 연다 (MKV 버튼은 그대로 있음)
c.settings.startScreen = 'browser';
c.settings.display.startScreen = 'browser';
await tester.tap(find.byTooltip('다운로드 목록'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip(RegExp('^홈 화면 \\(웹 브라우저\\)')));
@ -909,7 +909,7 @@ void main() {
await tester.pump();
await tester.pump();
expect(tester.takeException(), isNull, reason: '두 줄로 바뀐 뒤에는 넘침 없음');
final pct = tester.getRect(find.text('${(c.settings.uiScale * 100).round()}%'));
final pct = tester.getRect(find.text('${(c.settings.display.scale * 100).round()}%'));
for (final tip in ['작업 취소', '결과 폴더 열기: ${outputDirFor(file)}']) {
final f = find.byTooltip(tip);
expect(f, findsWidgets, reason: tip);

View File

@ -18,36 +18,36 @@ AppController _plain() =>
void main() {
test('컴포넌트: 설치한 화면만 순서대로 · 홈 화면은 제거되면 첫 화면 · 설정 저장', () {
final s = AppSettings();
expect(s.components, containsAll(['mkv', 'browser', 'explorer', 'rsync', 'downloads', 'viewer']));
expect(s.components, isNot(contains('docs'))); // 변환기를 받아야 하는 것은 처음엔 꺼짐
expect([for (final p in AppComponent.pages(s.components, s.navOrder)) p.id],
expect(s.display.components, containsAll(['mkv', 'browser', 'explorer', 'rsync', 'downloads', 'viewer']));
expect(s.display.components, isNot(contains('docs'))); // 변환기를 받아야 하는 것은 처음엔 꺼짐
expect([for (final p in AppComponent.pages(s.display.components, s.display.navOrder)) p.id],
['mkv', 'browser', 'explorer', 'rsync', 'downloads', 'aiimage']);
s
..navOrder = ['explorer', 'mkv']
..components = ['mkv', 'explorer', 'downloads'];
expect([for (final p in AppComponent.pages(s.components, s.navOrder)) p.id], ['explorer', 'mkv', 'downloads']);
s.startScreen = 'browser'; // 제거한 화면 → 설치된 첫 화면
..display.navOrder = ['explorer', 'mkv']
..display.components = ['mkv', 'explorer', 'downloads'];
expect([for (final p in AppComponent.pages(s.display.components, s.display.navOrder)) p.id], ['explorer', 'mkv', 'downloads']);
s.display.startScreen = 'browser'; // 제거한 화면 → 설치된 첫 화면
expect(AppNavButtons.homeId(s), 'explorer');
final back = AppSettings.fromJson(s.toJson());
expect(back.components, ['mkv', 'explorer', 'downloads']);
expect(back.navOrder, ['explorer', 'mkv']);
expect(back.display.components, ['mkv', 'explorer', 'downloads']);
expect(back.display.navOrder, ['explorer', 'mkv']);
// 예전 설정 (컴포넌트 없음) 은 기본
expect(AppSettings.fromJson({}).components, AppComponent.defaultInstalled);
expect(AppSettings.fromJson({}).display.components, AppComponent.defaultInstalled);
});
test('225: 이 판이 모르는 구성 요소 · 화면 id 는 지우지 않는다 (예전 판을 거쳐 다시 올려도 켠 것이 남게)', () {
final j = {'aiImageV1': true, 'components': ['mkv', 'browser', 'future_screen'], 'navOrder': ['browser', 'future_screen', 'mkv']};
final s = AppSettings.fromJson(j);
expect(s.components, ['mkv', 'browser', 'future_screen']);
expect(s.navOrder, ['browser', 'future_screen', 'mkv']);
expect(s.display.components, ['mkv', 'browser', 'future_screen']);
expect(s.display.navOrder, ['browser', 'future_screen', 'mkv']);
// 화면 · 버튼에는 아는 것만
expect([for (final p in AppComponent.pages(s.components, s.navOrder)) p.id], ['browser', 'mkv']);
expect([for (final p in AppComponent.pages(s.display.components, s.display.navOrder)) p.id], ['browser', 'mkv']);
// 저장했다 다시 읽어도 그대로
final back = AppSettings.fromJson(s.toJson());
expect(back.components, contains('future_screen'));
expect(back.navOrder, contains('future_screen'));
expect(back.display.components, contains('future_screen'));
expect(back.display.navOrder, contains('future_screen'));
// 순서 바꾸기 (explorer 를 맨 앞으로): future_screen 은 둘째 자리 그대로
expect(AppComponent.reorder(s.navOrder, ['explorer', 'mkv', 'browser']), ['explorer', 'future_screen', 'mkv', 'browser']);
expect(AppComponent.reorder(s.display.navOrder, ['explorer', 'mkv', 'browser']), ['explorer', 'future_screen', 'mkv', 'browser']);
// 예전 순서에 없던 아는 화면은 뒤에
expect(AppComponent.reorder(['future_screen', 'mkv'], ['mkv', 'explorer']), ['future_screen', 'mkv', 'explorer']);
});
@ -67,7 +67,7 @@ void main() {
g.failure.value = DnsFailure('192.0.2.1', 'x', DateTime(2026, 10, 12, 1, 35));
await tester.pump();
expect(find.text('DNS 서버 (192.0.2.1) 에 닿지 않음 (01:35부터)'), findsOneWidget);
await c.updateSettings((s) => s.showLog = !s.showLog);
await c.updateSettings((s) => s.display.showLog = !s.display.showLog);
await tester.pump();
expect(find.text('DNS 서버 (192.0.2.1) 에 닿지 않음 (01:35부터)'), findsOneWidget);
await tester.tap(find.text('시스템 DNS 로 바꾸기'));
@ -87,11 +87,11 @@ void main() {
expect(find.byTooltip('MKV 화면 (지금 여기)'), findsOneWidget);
expect(find.byTooltip('파일 탐색기'), findsOneWidget);
expect(find.byTooltip('Rsync'), findsOneWidget);
await c.updateSettings((s) => s.components = ['mkv', 'explorer']);
await c.updateSettings((s) => s.display.components = ['mkv', 'explorer']);
await tester.pump();
expect(find.byTooltip('Rsync'), findsNothing);
expect(find.byTooltip('파일 탐색기'), findsOneWidget);
await c.updateSettings((s) => s.components = ['mkv']);
await c.updateSettings((s) => s.display.components = ['mkv']);
await tester.pump();
expect(find.byTooltip('파일 탐색기'), findsNothing);
expect(find.byTooltip('MKV 화면 (지금 여기)'), findsOneWidget);
@ -110,15 +110,15 @@ void main() {
// 2번째 (웹 브라우저) 를 위로 → 맨 앞
await tester.tap(find.byTooltip('위로').at(1));
await tester.pump();
expect(c.settings.navOrder.take(2), ['browser', 'mkv']);
expect(c.settings.display.navOrder.take(2), ['browser', 'mkv']);
// 맨 앞 (웹 브라우저) 를 아래로 → 다시 2번째
await tester.tap(find.byTooltip('아래로').first);
await tester.pump();
expect(c.settings.navOrder.take(2), ['mkv', 'browser']);
expect(c.settings.display.navOrder.take(2), ['mkv', 'browser']);
// 마지막 (AI 그림) 을 위로 → 5번째
await tester.tap(find.byTooltip('위로').last);
await tester.pump();
expect(c.settings.navOrder, ['mkv', 'browser', 'explorer', 'rsync', 'aiimage', 'downloads']);
expect(c.settings.display.navOrder, ['mkv', 'browser', 'explorer', 'rsync', 'aiimage', 'downloads']);
});
testWidgets('화면 가운데를 좌우로 밀면 순서대로 다음 · 이전 화면 (끝 다음은 처음)', (tester) async {
@ -127,8 +127,8 @@ void main() {
addTearDown(tester.view.reset);
final c = _plain();
c.settings
..components = ['mkv', 'explorer', 'rsync']
..navOrder = ['mkv', 'explorer', 'rsync'];
..display.components = ['mkv', 'explorer', 'rsync']
..display.navOrder = ['mkv', 'explorer', 'rsync'];
await tester.pumpWidget(MaterialApp(
builder: (context, child) => AppScope(controller: c, child: child!),
home: HomePage(c: c),
@ -157,7 +157,7 @@ void main() {
await swipe(600); // 오른쪽으로 → 이전 (끝으로)
expect(find.byTooltip('Rsync (지금 여기)'), findsOneWidget);
// 102: 끝에서 처음으로 돌기를 끄면 끝에서 멈춘다
await c.updateSettings((s) => s.swipeWrap = false);
await c.updateSettings((s) => s.display.swipeWrap = false);
await tester.pump();
await swipe(-600);
expect(find.byTooltip('Rsync (지금 여기)'), findsOneWidget);
@ -166,9 +166,9 @@ void main() {
expect(find.byTooltip('파일 탐색기 (지금 여기)'), findsOneWidget);
await swipe(-600);
expect(find.byTooltip('Rsync (지금 여기)'), findsOneWidget);
await c.updateSettings((s) => s.swipeWrap = true);
await c.updateSettings((s) => s.display.swipeWrap = true);
// 끄면 밀어도 그대로
await c.updateSettings((s) => s.swipeNav = false);
await c.updateSettings((s) => s.display.swipeNav = false);
await tester.pump();
await swipe(600);
expect(find.byTooltip('Rsync (지금 여기)'), findsOneWidget);
@ -185,8 +185,8 @@ void main() {
addTearDown(tester.view.reset);
final c = _plain();
c.settings
..components = ['mkv', 'explorer']
..navOrder = ['mkv', 'explorer'];
..display.components = ['mkv', 'explorer']
..display.navOrder = ['mkv', 'explorer'];
final navKey = GlobalKey<NavigatorState>();
await tester.pumpWidget(MaterialApp(
navigatorKey: navKey,

View File

@ -27,23 +27,23 @@ void main() {
test('기본값 = 정한 답 · 저장 · 읽기', () {
final s = AppSettings();
expect([
s.run.startMenuShortcut, s.copy.syncStopToast, s.swipeWrap, s.migrationNotice, s.allowInnerToOuter,
s.run.startMenuShortcut, s.copy.syncStopToast, s.display.swipeWrap, s.migrationNotice, s.allowInnerToOuter,
s.rememberPasswords, s.recycleOnDelete, s.reader.systemBars,
], everyElement(isTrue));
expect(s.cleanupPrechecked, ['work', 'download']);
expect(s.tripleTapAction, 'move');
expect(s.remote.davExternalOpen, 'ask');
// 이미 있던 설정의 기본값도 결정대로
expect([s.browser.pauseOnLeave, s.run.autoCheckUpdates, s.browser.adSkip, s.browser.adHide, s.run.inBackground, s.swipeNav],
expect([s.browser.pauseOnLeave, s.run.autoCheckUpdates, s.browser.adSkip, s.browser.adHide, s.run.inBackground, s.display.swipeNav],
everyElement(isTrue));
// 탐색기 처음 배치 = 두 창 (규칙 7 · 관리자 결정 3, 10/22)
expect([s.copy.liveSyncOnStart, s.copy.liveSyncIntervalSec, s.explorer.layout, s.reader.zipComic], ['auto', 30, 'dual', false]);
expect([AppSettings.uiScaleMin, AppSettings.uiScaleMax], [0.5, 2.5]);
expect([DisplaySettings.scaleMin, DisplaySettings.scaleMax], [0.5, 2.5]);
// 바꾼 값은 남는다
final b = AppSettings.fromJson((AppSettings()
..run.startMenuShortcut = false
..copy.syncStopToast = false
..swipeWrap = false
..display.swipeWrap = false
..migrationNotice = false
..allowInnerToOuter = false
..rememberPasswords = false
@ -52,7 +52,7 @@ void main() {
..tripleTapAction = 'none'
..remote.davExternalOpen = 'url')
.toJson());
expect([b.run.startMenuShortcut, b.copy.syncStopToast, b.swipeWrap, b.migrationNotice, b.allowInnerToOuter, b.rememberPasswords,
expect([b.run.startMenuShortcut, b.copy.syncStopToast, b.display.swipeWrap, b.migrationNotice, b.allowInnerToOuter, b.rememberPasswords,
b.recycleOnDelete], everyElement(isFalse));
expect([b.cleanupPrechecked, b.tripleTapAction, b.remote.davExternalOpen], [['logs'], 'none', 'url']);
});

View File

@ -86,7 +86,7 @@ void main() {
test('언어 바꾸기: tr · trf · 선택 목록 이름 · 언어 이름이 그 언어로, 한국어로 되돌리기', () async {
expect(tr('MKV 만들기'), 'MKV 만들기');
await i18n.apply('en');
expect(c.settings.uiLanguage, 'en');
expect(c.settings.display.language, 'en');
expect(tr('MKV 만들기'), 'Make MKV');
expect(trf('동영상 {0}개 · 전체 선택', [3]), '3 videos · select all');
expect(PlaylistMode.folder.label, 'All videos in the same folder');
@ -96,7 +96,7 @@ void main() {
expect(tr('환경 설정'), '環境設定');
await i18n.apply('ko');
expect(tr('환경 설정'), '환경 설정');
expect(AppSettings.fromJson(c.settings.toJson()).uiLanguage, 'ko');
expect(AppSettings.fromJson(c.settings.toJson()).display.language, 'ko');
});
test('58 · 102: 시스템 언어 따르기 - 설정에는 system, 화면은 기기 언어 (들어 있지 않으면 English), 처음 설치의 기본', () async {
@ -106,10 +106,10 @@ void main() {
expect(I18nController.systemCode('zh_CN'), 'zh-Hans');
expect(I18nController.systemCode('en_US'), 'en');
expect(I18nController.systemCode('fr_FR'), 'en');
expect(AppSettings().uiLanguage, I18nController.system); // 처음 설치
expect(AppSettings.fromJson({'uiLanguage': 'ko'}).uiLanguage, 'ko'); // 고른 언어는 그대로
expect(AppSettings().display.language, I18nController.system); // 처음 설치
expect(AppSettings.fromJson({'uiLanguage': 'ko'}).display.language, 'ko'); // 고른 언어는 그대로
await i18n.apply(I18nController.system);
expect(c.settings.uiLanguage, I18nController.system);
expect(c.settings.display.language, I18nController.system);
expect(uiLanguage, I18nController.systemCode());
await i18n.apply('ko');
});
@ -130,14 +130,14 @@ void main() {
expect(i18n.addable.map((l) => l.code), contains('fr'));
expect(i18n.addable.map((l) => l.code), isNot(contains('en')));
await i18n.addLanguage(fr);
expect(c.settings.uiLanguagesAdded, ['fr']);
expect(c.settings.display.languagesAdded, ['fr']);
expect(i18n.available, ['ko', 'en', 'ja', 'zh-Hans', 'fr']);
expect(File(p.join(dir.path, 'l10n', 'fr.json')).existsSync(), isTrue);
await i18n.apply('fr');
expect(tr('취소'), '[fra_Latn] Cancel');
expect(trf('{0}개 선택', [2]), '[fra_Latn] 2 selected');
await i18n.removeLanguage('fr');
expect(c.settings.uiLanguagesAdded, isEmpty);
expect(c.settings.display.languagesAdded, isEmpty);
expect(uiLanguage, 'ko', reason: '쓰던 언어를 지우면 한국어로');
expect(File(p.join(dir.path, 'l10n', 'fr.json')).existsSync(), isFalse);
});

View File

@ -20,7 +20,7 @@ void main() {
createTranslator: () => throw UnimplementedError(),
));
c.settings
..showLog = true
..display.showLog = true
..mkvCompactList = size;
for (var i = 0; i < n; i++) {
c.videos.add(VideoItem('D:/v/v$i.mp4'));

View File

@ -14,7 +14,7 @@ void main() {
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings.showLog = true;
c.settings.display.showLog = true;
for (var i = 1; i <= 6; i++) {
c.videos.add(VideoItem('D:\\v\\영상$i.mp4'));
}

View File

@ -22,7 +22,7 @@ void main() {
createRecognizer: () => throw UnimplementedError(),
createTranslator: () => throw UnimplementedError(),
));
c.settings.showLog = true;
c.settings.display.showLog = true;
c.settings.moveTargets = [MoveTarget('보관', r'D:\보관')];
for (var i = 1; i <= 4; i++) {
c.videos.add(VideoItem('D:/v/아주 긴 이름의 동영상 파일 $i.mp4'));

View File

@ -163,14 +163,14 @@ void main() {
test('Android 화면 방향: 기본 자동, 가로 · 세로 고정 저장, 예전 기본 (가로 고정) 은 한 번 자동으로', () {
final s = AppSettings();
expect(s.screenOrientation, 'auto');
expect(s.display.orientation, 'auto');
for (final m in ['portrait', 'auto', 'landscape']) {
s.screenOrientation = m;
expect(AppSettings.fromJson(s.toJson()).screenOrientation, m);
s.display.orientation = m;
expect(AppSettings.fromJson(s.toJson()).display.orientation, m);
}
expect(AppSettings.fromJson({'screenOrientation': 'sideways', 'orientationV2': true}).screenOrientation, 'auto');
expect(AppSettings.fromJson({'screenOrientation': 'sideways', 'orientationV2': true}).display.orientation, 'auto');
// 예전 설정 파일 (기본 가로 고정 시절) → 자동
expect(AppSettings.fromJson({'screenOrientation': 'landscape'}).screenOrientation, 'auto');
expect(AppSettings.fromJson({'screenOrientation': 'landscape'}).display.orientation, 'auto');
});
});

View File

@ -200,11 +200,11 @@ void main() {
expect(find.byTooltip('작업 기록 숨기기'), findsOneWidget);
await tester.tap(find.byTooltip('작업 기록 숨기기'));
await tester.pump();
expect(c.settings.showLog, isFalse);
expect(c.settings.display.showLog, isFalse);
expect(find.text('작업 기록 보기'), findsOneWidget);
await tester.tap(find.text('작업 기록 보기'));
await tester.pump();
expect(c.settings.showLog, isTrue);
expect(c.settings.display.showLog, isTrue);
expect(find.byTooltip('작업 기록 숨기기'), findsOneWidget);
});

View File

@ -25,16 +25,16 @@ class _Shell extends NoopShell {
void main() {
test('화면 크기 설정: 범위 · 5% 단위 · 저장', () {
expect(AppSettings.clampUiScale(null), 1.0);
expect(AppSettings.clampUiScale(0.1), 0.5);
expect(AppSettings.clampUiScale(9), 2.5);
expect(AppSettings.clampUiScale(2.5), 2.5);
expect(AppSettings.clampUiScale(1.1000000001), 1.1);
expect(DisplaySettings.clampScale(null), 1.0);
expect(DisplaySettings.clampScale(0.1), 0.5);
expect(DisplaySettings.clampScale(9), 2.5);
expect(DisplaySettings.clampScale(2.5), 2.5);
expect(DisplaySettings.clampScale(1.1000000001), 1.1);
final s = AppSettings()
..uiScale = 1.2
..uiScaleDefault = 0.9;
..display.scale = 1.2
..display.scaleDefault = 0.9;
final back = AppSettings.fromJson(s.toJson());
expect([back.uiScale, back.uiScaleDefault], [1.2, 0.9]);
expect([back.display.scale, back.display.scaleDefault], [1.2, 0.9]);
});
testWidgets('화면 크기 버튼: − · + 로 10% 씩, 숫자를 누르면 기본 크기로, 화면이 실제로 커진다', (tester) async {
@ -61,7 +61,7 @@ void main() {
await tester.pump();
await tester.tap(find.byTooltip('화면 크게'));
await tester.pump();
expect(c.settings.uiScale, 1.2);
expect(c.settings.display.scale, 1.2);
expect(find.text('120%'), findsOneWidget);
// 화면에 그려지는 크기가 1.2 배
final r = tester.getRect(find.byKey(const Key('box')));
@ -71,17 +71,17 @@ void main() {
await tester.tap(find.byTooltip('화면 작게'));
await tester.pump();
expect(c.settings.uiScale, 1.1);
expect(c.settings.display.scale, 1.1);
// 기본 크기 90% 로 정해 두고 숫자를 누르면 그 크기로
c.settings.uiScaleDefault = 0.9;
c.settings.display.scaleDefault = 0.9;
await tester.tap(find.text('110%'));
await tester.pump();
expect(c.settings.uiScale, 0.9);
expect(c.settings.display.scale, 0.9);
expect(find.text('90%'), findsOneWidget);
// 끝까지 줄이면 − 가 꺼진다
c.settings.uiScale = AppSettings.uiScaleMin;
c.settings.display.scale = DisplaySettings.scaleMin;
c.updateSettings((_) {});
await tester.pump();
expect(tester.widget<IconButton>(find.widgetWithIcon(IconButton, Icons.remove)).onPressed, isNull);

View File

@ -140,7 +140,7 @@ void main() {
'imageExts': ['jpg'],
'runInBackground': false,
});
expect(old.screenOrientation, 'auto');
expect(old.display.orientation, 'auto');
// 규칙 7 · 결정 3 (10/22): 처음 배치가 두 창이 되어 예전 'dual' 은 그대로 (옮겼다는 알림 없음)
expect(old.explorer.layout, 'dual');
expect(old.reader.zipComic, isFalse);
@ -149,10 +149,10 @@ void main() {
// 다시 저장 · 읽기: 이제 사용자가 고른 값으로 보고 그대로 · 알림 없음
final again = AppSettings.fromJson((old
..explorer.layout = 'dual'
..screenOrientation = 'landscape')
..display.orientation = 'landscape')
.toJson());
expect(again.explorer.layout, 'dual');
expect(again.screenOrientation, 'landscape');
expect(again.display.orientation, 'landscape');
expect(again.migrated, isEmpty);
// 처음 설치 (빈 설정) 는 알릴 것 없음
expect(AppSettings.fromJson({}).migrated, isEmpty);

View File

@ -41,11 +41,11 @@ void main() {
tearDown(() => JjColors.use(Brightness.dark));
test('처음 값은 어둡게 (예전 색 그대로) · 모르는 값 · 없는 값도 어둡게 · 고른 값은 그대로', () {
expect(AppSettings().uiBrightness, 'dark');
expect(AppSettings.fromJson({}).uiBrightness, 'dark');
expect(AppSettings.fromJson({'uiBrightness': 'neon'}).uiBrightness, 'dark');
expect(AppSettings.fromJson({'uiBrightness': 'light'}).uiBrightness, 'light');
expect(AppSettings.fromJson(AppSettings.fromJson({'uiBrightness': 'system'}).toJson()).uiBrightness, 'system');
expect(AppSettings().display.brightness, 'dark');
expect(AppSettings.fromJson({}).display.brightness, 'dark');
expect(AppSettings.fromJson({'uiBrightness': 'neon'}).display.brightness, 'dark');
expect(AppSettings.fromJson({'uiBrightness': 'light'}).display.brightness, 'light');
expect(AppSettings.fromJson(AppSettings.fromJson({'uiBrightness': 'system'}).toJson()).display.brightness, 'system');
expect(JjColors.bg, const Color(0xFF141417));
expect(JjColors.accent, const Color(0xFF00C4CC));
expect(JjColors.textDim, const Color(0xFF8E8E99));
@ -98,7 +98,7 @@ void main() {
BuildContext ctx() => t.element(find.byType(Scaffold).first);
expect(Theme.of(ctx()).brightness, Brightness.dark);
expect(JjColors.isLight, isFalse);
await t.runAsync(() => c.updateSettings((x) => x.uiBrightness = 'light'));
await t.runAsync(() => c.updateSettings((x) => x.display.brightness = 'light'));
await t.pump();
await t.pump();
expect(JjColors.isLight, isTrue);
@ -106,7 +106,7 @@ void main() {
expect(Theme.of(ctx()).scaffoldBackgroundColor, JjPalette.light.bg);
// 기기 따라
t.platformDispatcher.platformBrightnessTestValue = Brightness.dark;
await t.runAsync(() => c.updateSettings((x) => x.uiBrightness = 'system'));
await t.runAsync(() => c.updateSettings((x) => x.display.brightness = 'system'));
await t.pump();
await t.pump();
expect(JjColors.isLight, isFalse);
@ -114,7 +114,7 @@ void main() {
await t.pump();
await t.pump();
expect(JjColors.isLight, isTrue, reason: '기기 밝기가 바뀌면 따라감');
await t.runAsync(() => c.updateSettings((x) => x.uiBrightness = 'dark'));
await t.runAsync(() => c.updateSettings((x) => x.display.brightness = 'dark'));
await t.pump();
await t.pump();
expect(JjColors.isLight, isFalse);

View File

@ -70,9 +70,9 @@ void main() {
final ids = [for (final p in AppComponent.pages(['mkv', 'browser', 'tor', 'explorer'], ['explorer', 'mkv', 'browser'])) p.id];
expect(ids, ['explorer', 'mkv', 'browser', 'tor']);
expect([for (final p in AppComponent.pages(['mkv', 'browser', 'tor'], ['browser', 'mkv'])) p.id], ['browser', 'tor', 'mkv']);
final s = AppSettings()..startScreen = 'tor';
final s = AppSettings()..display.startScreen = 'tor';
expect(AppNavButtons.homeId(s), 'mkv', reason: '설치하지 않았으면 첫 화면');
s.components = [...s.components, 'tor'];
s.display.components = [...s.display.components, 'tor'];
expect(AppNavButtons.homeId(s), 'tor');
});
@ -165,7 +165,7 @@ void main() {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..tor.noticeSeen = true
..components = [...c.settings.components, 'tor'];
..display.components = [...c.settings.display.components, 'tor'];
await t.pumpWidget(MaterialApp(
navigatorKey: navKey,
builder: (context, child) => AppScope(controller: c, child: child!),