설정 A 분야 6: 보기 화면 (ReaderSettings) - 키 9 · zipComicV2 표시

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
modular
RyuJongJin 2026-10-22 23:16:46 +09:00
parent 506803ca65
commit 6054daf17a
11 changed files with 148 additions and 120 deletions

View File

@ -30,7 +30,7 @@ void main() {
(File(p.join(tmp.path, 'p$i.png'))..writeAsBytesSync(img.encodePng(img.Image(width: 300, height: 200)))).path, (File(p.join(tmp.path, 'p$i.png'))..writeAsBytesSync(img.encodePng(img.Image(width: 300, height: 200)))).path,
]; ];
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings.readerGallery = gallery; c.settings.reader.gallery = gallery;
Future<void> wait(int ms) async { Future<void> wait(int ms) async {
for (var i = 0; i < ms ~/ 100; i++) { for (var i = 0; i < ms ~/ 100; i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 100))); await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 100)));

View File

@ -15,6 +15,7 @@ import '../services/app_shell.dart' show appIconOf;
import '../core/remote_idle.dart'; import '../core/remote_idle.dart';
import 'settings/records.dart'; import 'settings/records.dart';
import 'settings/settings_group.dart'; import 'settings/settings_group.dart';
import 'settings/reader_settings.dart';
import 'settings/ai_image_settings.dart'; import 'settings/ai_image_settings.dart';
import 'settings/copy_settings.dart'; import 'settings/copy_settings.dart';
import 'settings/tor_settings.dart'; import 'settings/tor_settings.dart';
@ -24,6 +25,7 @@ import 'settings/explorer_settings.dart';
export 'settings/records.dart'; export 'settings/records.dart';
export 'settings/settings_group.dart'; export 'settings/settings_group.dart';
export 'settings/settings_store.dart'; export 'settings/settings_store.dart';
export 'settings/reader_settings.dart';
export 'settings/ai_image_settings.dart'; export 'settings/ai_image_settings.dart';
export 'settings/copy_settings.dart'; export 'settings/copy_settings.dart';
export 'settings/tor_settings.dart'; export 'settings/tor_settings.dart';
@ -254,30 +256,13 @@ class AppSettings {
/// 그림 보기 (만화 보기) 대상 확장자 (파일 탐색기에서 두 번 누르면 보기로 연다) /// 그림 보기 (만화 보기) 대상 확장자 (파일 탐색기에서 두 번 누르면 보기로 연다)
List<String> imageExts = [...defaultImageExtensions]; List<String> imageExts = [...defaultImageExtensions];
/// 보기: 'page' 한 쪽 맞추기 · 'width' 좌우 맞추기 (세로로 밀어 봄)
String readerFit = 'page';
/// 보기: 오른쪽에서 왼쪽으로 넘김 (일본 만화)
bool readerRtl = false;
/// 보기: 밝기 (-0.7 어둡게 ~ 0.7 밝게)
double readerBrightness = 0;
/// 30 · 101: 보기 화면에서 위 · 아래 시스템 막대 (시계 · 알림 · 뒤로) 를 그대로 보이기 (기본: 보임 - 기기의 평소 동작, 화면 가득은 버튼 · 설정으로)
bool readerSystemBars = true;
/// 117: 보기 화면 "계속 보기" 간격 (초, 1 ~ 60)
int readerAutoSeconds = 3;
/// X-plore 맞추기 (관리자 지시 10/20): 그림 보기 화면 아래 갤러리 줄 (썸네일, 누르면 그 장으로). 처음 끔
bool readerGallery = false;
/// X-plore 맞추기 (관리자 지시 10/20): 보기 화면에서 볼륨 키로 넘기기 (아래 = 다음 장, 위 = 이전 장, Android). 처음 끔.
/// 보기 화면에서만 가로챈다 (동영상 · 다른 화면의 볼륨은 그대로)
bool readerVolumeKeys = false;
/// 119: 한 장에 두 쪽이 붙은 그림을 반씩 나눠 보기 - 'auto' 가로가 세로보다 긴 그림만 · 'on' 늘 · 'off' 안 함
String readerSplit = 'auto';
/// 99 (Windows): 시작 메뉴에 등록 - 알림이 JJ_MKVMaker 로 보이고 눌러서 열린다 /// 99 (Windows): 시작 메뉴에 등록 - 알림이 JJ_MKVMaker 로 보이고 눌러서 열린다
bool startMenuShortcut = true; bool startMenuShortcut = true;
@ -322,8 +307,6 @@ class AppSettings {
/// 내장 플레이어 자막 글자 크기 배율 (0.5 ~ 2.5, 기본 1) /// 내장 플레이어 자막 글자 크기 배율 (0.5 ~ 2.5, 기본 1)
double subtitleScale = 1.0; double subtitleScale = 1.0;
/// ZIP · CBZ 를 두 번 누르면 안의 그림을 만화처럼 보기 (끄면 목록). 기본은 목록 (사용자 지시)
bool zipComic = false;
/// WebDAV 서버 (파일 탐색기 · Rsync 화면 위쪽 "SD 카드" 옆 탭). 비밀번호는 이 설정 파일에만. /// WebDAV 서버 (파일 탐색기 · Rsync 화면 위쪽 "SD 카드" 옆 탭). 비밀번호는 이 설정 파일에만.
List<DavServer> webdavServers = []; List<DavServer> webdavServers = [];
@ -434,8 +417,11 @@ class AppSettings {
/// AI 그림 · 해상도 올리기 (123 · 121) /// AI 그림 · 해상도 올리기 (123 · 121)
final aiImage = AiImageSettings(); final aiImage = AiImageSettings();
/// 보기 화면 (그림 · 만화 · PDF · 문서)
final reader = ReaderSettings();
/// 설정 분야들 (설정 A) - 저마다 자기 키를 읽고 쓴다. toJson · fromJson 은 이 목록을 돌며 합치기만 한다 /// 설정 분야들 (설정 A) - 저마다 자기 키를 읽고 쓴다. toJson · fromJson 은 이 목록을 돌며 합치기만 한다
List<SettingsGroup> get groups => [explorer, network, tor, copy, aiImage]; List<SettingsGroup> get groups => [explorer, network, tor, copy, aiImage, reader];
/// [plainSecrets]: 안전 저장소를 쓸 수 없을 때만 (예전 파일에 있던 비밀 값을 잃지 않게) 예전처럼 파일에 둔다 /// [plainSecrets]: 안전 저장소를 쓸 수 없을 때만 (예전 파일에 있던 비밀 값을 잃지 않게) 예전처럼 파일에 둔다
Map<String, Object?> toJson({bool plainSecrets = false}) { Map<String, Object?> toJson({bool plainSecrets = false}) {
@ -513,14 +499,6 @@ class AppSettings {
'navOrder': navOrder, 'navOrder': navOrder,
'swipeNav': swipeNav, 'swipeNav': swipeNav,
'imageExts': imageExts, 'imageExts': imageExts,
'readerFit': readerFit,
'readerRtl': readerRtl,
'readerBrightness': readerBrightness,
'readerSystemBars': readerSystemBars,
'readerAutoSeconds': readerAutoSeconds,
'readerGallery': readerGallery,
'readerVolumeKeys': readerVolumeKeys,
'readerSplit': readerSplit,
'startMenuShortcut': startMenuShortcut, 'startMenuShortcut': startMenuShortcut,
'swipeWrap': swipeWrap, 'swipeWrap': swipeWrap,
'browserSwipeHinted': browserSwipeHinted, 'browserSwipeHinted': browserSwipeHinted,
@ -535,9 +513,6 @@ class AppSettings {
// 116: 예전 판의 표시 ('allFilesHintShown') 는 권한이 없어진 것을 몰라 다시 띄우지 못했다 → 새 이름으로 한 번 더 // 116: 예전 판의 표시 ('allFilesHintShown') 는 권한이 없어진 것을 몰라 다시 띄우지 못했다 → 새 이름으로 한 번 더
'allFilesHint2': allFilesHintShown, 'allFilesHint2': allFilesHintShown,
'subtitleScale': subtitleScale, 'subtitleScale': subtitleScale,
'zipComic': zipComic,
// 예전 기본값 (만화 보기 켜짐) 은 지시와 반대였으므로 한 번 꺼진 상태 (목록) 로
'zipComicV2': true,
'webdavServers': [ 'webdavServers': [
for (final x in webdavServers) {...x.toJson(), if (plainSecrets) 'password': x.password}, for (final x in webdavServers) {...x.toJson(), if (plainSecrets) 'password': x.password},
], ],
@ -668,14 +643,6 @@ class AppSettings {
..imageExts = j['imageExts'] is List ..imageExts = j['imageExts'] is List
? [for (final x in j['imageExts'] as List) '$x'.toLowerCase()] ? [for (final x in j['imageExts'] as List) '$x'.toLowerCase()]
: [...defaultImageExtensions] : [...defaultImageExtensions]
..readerFit = j['readerFit'] == 'width' ? 'width' : 'page'
..readerRtl = j['readerRtl'] as bool? ?? false
..readerBrightness = ((j['readerBrightness'] as num?)?.toDouble() ?? 0).clamp(-0.7, 0.7)
..readerSystemBars = j['readerSystemBars'] != false
..readerAutoSeconds = ((j['readerAutoSeconds'] as num?)?.toInt() ?? 3).clamp(1, 60)
..readerGallery = j['readerGallery'] as bool? ?? false
..readerVolumeKeys = j['readerVolumeKeys'] as bool? ?? false
..readerSplit = const ['auto', 'on', 'off'].contains(j['readerSplit']) ? j['readerSplit'] as String : 'auto'
..startMenuShortcut = j['startMenuShortcut'] != false ..startMenuShortcut = j['startMenuShortcut'] != false
..swipeWrap = j['swipeWrap'] != false ..swipeWrap = j['swipeWrap'] != false
..browserSwipeHinted = j['browserSwipeHinted'] == true ..browserSwipeHinted = j['browserSwipeHinted'] == true
@ -691,7 +658,6 @@ class AppSettings {
..davExternalOpen = const ['fetch', 'url'].contains(j['davExternalOpen']) ? j['davExternalOpen'] as String : 'ask' ..davExternalOpen = const ['fetch', 'url'].contains(j['davExternalOpen']) ? j['davExternalOpen'] as String : 'ask'
..allFilesHintShown = j['allFilesHint2'] == true ..allFilesHintShown = j['allFilesHint2'] == true
..subtitleScale = ((j['subtitleScale'] as num?)?.toDouble() ?? 1.0).clamp(0.5, 2.5) ..subtitleScale = ((j['subtitleScale'] as num?)?.toDouble() ?? 1.0).clamp(0.5, 2.5)
..zipComic = j['zipComic'] == true && j['zipComicV2'] == true
..webdavServers = [ ..webdavServers = [
for (final x in (j['webdavServers'] as List?) ?? const []) for (final x in (j['webdavServers'] as List?) ?? const [])
if (x is Map) DavServer.fromJson(x), if (x is Map) DavServer.fromJson(x),
@ -803,7 +769,7 @@ class AppSettings {
// 53 · 28: 앱 안 브라우저 쿠키를 쓰던 사람에게 넘기는 사이트가 넓어졌음을 한 번 알린다 // 53 · 28: 앱 안 브라우저 쿠키를 쓰던 사람에게 넘기는 사이트가 넓어졌음을 한 번 알린다
if (j['cookieScopeV2'] != true && j.isNotEmpty && (j['ytCookiesBrowser'] ?? internalBrowserCookies) == internalBrowserCookies) if (j['cookieScopeV2'] != true && j.isNotEmpty && (j['ytCookiesBrowser'] ?? internalBrowserCookies) == internalBrowserCookies)
'cookieScope', 'cookieScope',
if (j['zipComicV2'] != true && j['zipComic'] != false && j.containsKey('imageExts') && !s.zipComic) 'zipComic', if (j['zipComicV2'] != true && j['zipComic'] != false && j.containsKey('imageExts') && !s.reader.zipComic) 'zipComic',
if (j['backgroundV2'] != true && j['runInBackground'] == false && s.runInBackground && Platform.isAndroid) if (j['backgroundV2'] != true && j['runInBackground'] == false && s.runInBackground && Platform.isAndroid)
'background', 'background',
// 193: 그 설정이 생기기 전 판 (설정 파일은 있는데 그 항목이 없음 - 예: 10.03) 에서 올라온 사람이 바로 겪는 동작 변화. // 193: 그 설정이 생기기 전 판 (설정 파일은 있는데 그 항목이 없음 - 예: 10.03) 에서 올라온 사람이 바로 겪는 동작 변화.

View File

@ -0,0 +1,62 @@
import 'settings_group.dart';
/// 약속 (설정 A): 환경 설정 > 보기 화면 (그림 · 만화 · PDF · 문서) - 자기 키 (이름 · 처음 값 · 읽기 · 쓰기) 를 스스로 갖는다.
/// 설정 하나를 더할 때는 이 파일만 고친다. 저장 파일의 키 이름은 예전 그대로 (평평한 키)
class ReaderSettings implements SettingsGroup {
/// 보기: 'page' 한 쪽 맞추기 · 'width' 좌우 맞추기 (세로로 밀어 봄)
String fit = 'page';
/// 보기: 오른쪽에서 왼쪽으로 넘김 (일본 만화)
bool rtl = false;
/// 보기: 밝기 (-0.7 어둡게 ~ 0.7 밝게)
double brightness = 0;
/// 30 · 101: 보기 화면에서 위 · 아래 시스템 막대 (시계 · 알림 · 뒤로) 를 그대로 보이기 (기본: 보임 - 기기의 평소 동작, 화면 가득은 버튼 · 설정으로)
bool systemBars = true;
/// 117: 보기 화면 "계속 보기" 간격 (초, 1 ~ 60)
int autoSeconds = 3;
/// X-plore 맞추기 (관리자 지시 10/20): 그림 보기 화면 아래 갤러리 줄 (썸네일, 누르면 그 장으로). 처음 끔
bool gallery = false;
/// X-plore 맞추기 (관리자 지시 10/20): 보기 화면에서 볼륨 키로 넘기기 (아래 = 다음 장, 위 = 이전 장, Android). 처음 끔.
/// 보기 화면에서만 가로챈다 (동영상 · 다른 화면의 볼륨은 그대로)
bool volumeKeys = false;
/// 119: 한 장에 두 쪽이 붙은 그림을 반씩 나눠 보기 - 'auto' 가로가 세로보다 긴 그림만 · 'on' 늘 · 'off' 안 함
String split = 'auto';
/// ZIP · CBZ 를 두 번 누르면 안의 그림을 만화처럼 보기 (끄면 목록). 기본은 목록 (사용자 지시)
bool zipComic = false;
@override
void write(Map<String, Object?> j, {required bool plainSecrets}) {
j['readerFit'] = fit;
j['readerRtl'] = rtl;
j['readerBrightness'] = brightness;
j['readerSystemBars'] = systemBars;
j['readerAutoSeconds'] = autoSeconds;
j['readerGallery'] = gallery;
j['readerVolumeKeys'] = volumeKeys;
j['readerSplit'] = split;
j['zipComic'] = zipComic;
// 예전 기본값 (만화 보기 켜짐) 은 지시와 반대였으므로 한 번 꺼진 상태 (목록) 로
j['zipComicV2'] = true;
}
@override
void read(Map<String, dynamic> j) {
fit = j['readerFit'] == 'width' ? 'width' : 'page';
rtl = j['readerRtl'] as bool? ?? false;
brightness = ((j['readerBrightness'] as num?)?.toDouble() ?? 0).clamp(-0.7, 0.7);
systemBars = j['readerSystemBars'] != false;
autoSeconds = ((j['readerAutoSeconds'] as num?)?.toInt() ?? 3).clamp(1, 60);
gallery = j['readerGallery'] as bool? ?? false;
volumeKeys = j['readerVolumeKeys'] as bool? ?? false;
split = const ['auto', 'on', 'off'].contains(j['readerSplit']) ? j['readerSplit'] as String : 'auto';
zipComic = j['zipComic'] == true && j['zipComicV2'] == true;
}
}

View File

@ -44,8 +44,8 @@ class ViewerSettings extends StatelessWidget {
), ),
SwitchListTile( SwitchListTile(
secondary: const Icon(Icons.auto_stories_outlined), secondary: const Icon(Icons.auto_stories_outlined),
value: s.zipComic, value: s.reader.zipComic,
onChanged: (v) => c.updateSettings((x) => x.zipComic = v), onChanged: (v) => c.updateSettings((x) => x.reader.zipComic = v),
title: Text(tr('ZIP · CBZ 를 만화로 보기')), title: Text(tr('ZIP · CBZ 를 만화로 보기')),
subtitle: Text(tr('켜면 두 번 눌렀을 때 안의 그림을 바로 넘겨 봅니다. 끄면 목록 (골라서 풀기) 을 엽니다.')), subtitle: Text(tr('켜면 두 번 눌렀을 때 안의 그림을 바로 넘겨 봅니다. 끄면 목록 (골라서 풀기) 을 엽니다.')),
), ),
@ -55,20 +55,20 @@ class ViewerSettings extends StatelessWidget {
title: Text(tr('두 쪽 나눠 보기')), title: Text(tr('두 쪽 나눠 보기')),
subtitle: Text(tr('한 장에 두 쪽이 붙은 그림을 반씩 넘겨 봅니다 (읽는 방향의 쪽부터)')), subtitle: Text(tr('한 장에 두 쪽이 붙은 그림을 반씩 넘겨 봅니다 (읽는 방향의 쪽부터)')),
trailing: DropdownButton<String>( trailing: DropdownButton<String>(
value: s.readerSplit, value: s.reader.split,
items: [ items: [
DropdownMenuItem(value: 'auto', child: Text(tr('자동 (가로로 긴 그림만)'))), DropdownMenuItem(value: 'auto', child: Text(tr('자동 (가로로 긴 그림만)'))),
DropdownMenuItem(value: 'on', child: Text(tr('켜기'))), DropdownMenuItem(value: 'on', child: Text(tr('켜기'))),
DropdownMenuItem(value: 'off', child: Text(tr('끄기'))), DropdownMenuItem(value: 'off', child: Text(tr('끄기'))),
], ],
onChanged: (v) => c.updateSettings((x) => x.readerSplit = v!), onChanged: (v) => c.updateSettings((x) => x.reader.split = v!),
), ),
), ),
// X-plore 맞추기 (관리자 지시 10/20): 아래 갤러리 줄 (보기 화면 위 막대에서도 켜고 끔) // X-plore 맞추기 (관리자 지시 10/20): 아래 갤러리 줄 (보기 화면 위 막대에서도 켜고 끔)
SwitchListTile( SwitchListTile(
secondary: const Icon(Icons.view_carousel_outlined), secondary: const Icon(Icons.view_carousel_outlined),
value: s.readerGallery, value: s.reader.gallery,
onChanged: (v) => c.updateSettings((x) => x.readerGallery = v), onChanged: (v) => c.updateSettings((x) => x.reader.gallery = v),
title: Text(tr('그림 보기 아래 갤러리 줄')), title: Text(tr('그림 보기 아래 갤러리 줄')),
subtitle: Text(tr('그림을 볼 때 아래에 작은 그림 줄을 보입니다 (누르면 그 장으로)')), subtitle: Text(tr('그림을 볼 때 아래에 작은 그림 줄을 보입니다 (누르면 그 장으로)')),
), ),
@ -76,8 +76,8 @@ class ViewerSettings extends StatelessWidget {
if (Platform.isAndroid) if (Platform.isAndroid)
SwitchListTile( SwitchListTile(
secondary: const Icon(Icons.volume_down_outlined), secondary: const Icon(Icons.volume_down_outlined),
value: s.readerVolumeKeys, value: s.reader.volumeKeys,
onChanged: (v) => c.updateSettings((x) => x.readerVolumeKeys = v), onChanged: (v) => c.updateSettings((x) => x.reader.volumeKeys = v),
title: Text(tr('볼륨 키로 넘기기')), title: Text(tr('볼륨 키로 넘기기')),
subtitle: Text(tr('보기 화면에서 볼륨 아래 = 다음 장, 위 = 이전 장 (동영상 · 다른 화면의 볼륨은 그대로)')), subtitle: Text(tr('보기 화면에서 볼륨 아래 = 다음 장, 위 = 이전 장 (동영상 · 다른 화면의 볼륨은 그대로)')),
), ),
@ -87,9 +87,9 @@ class ViewerSettings extends StatelessWidget {
title: Text(tr('계속 보기 간격')), title: Text(tr('계속 보기 간격')),
subtitle: Text(tr('보기 화면의 [계속 보기] 가 다음 장으로 넘기는 간격')), subtitle: Text(tr('보기 화면의 [계속 보기] 가 다음 장으로 넘기는 간격')),
trailing: DropdownButton<int>( trailing: DropdownButton<int>(
value: s.readerAutoSeconds, value: s.reader.autoSeconds,
items: [for (var n = 1; n <= 60; n++) DropdownMenuItem(value: n, child: Text(trf('{0}초', [n])))], items: [for (var n = 1; n <= 60; n++) DropdownMenuItem(value: n, child: Text(trf('{0}초', [n])))],
onChanged: (v) => c.updateSettings((x) => x.readerAutoSeconds = v!), onChanged: (v) => c.updateSettings((x) => x.reader.autoSeconds = v!),
), ),
), ),
]); ]);

View File

@ -254,7 +254,7 @@ class ExplorerOpener {
Future<void> openZipFile(BuildContext context, String path, ExplorerPane? pane) async { Future<void> openZipFile(BuildContext context, String path, ExplorerPane? pane) async {
try { try {
if (ctl.c.settings.zipComic && await openZipComic(context, ctl.c, path)) return; if (ctl.c.settings.reader.zipComic && await openZipComic(context, ctl.c, path)) return;
} catch (e) { } catch (e) {
ctl.host.snack(trf('열 수 없습니다: {0}', [e])); ctl.host.snack(trf('열 수 없습니다: {0}', [e]));
return; return;

View File

@ -65,9 +65,9 @@ List<ReaderBarItem> readerBarItems(BuildContext context, AppController c, Reader
final pictures = src is ImageFilesSource || src is ZipImagesSource; final pictures = src is ImageFilesSource || src is ZipImagesSource;
final aiOn = c.settings.components.contains('aiimage'); final aiOn = c.settings.components.contains('aiimage');
final playing = st.playing; final playing = st.playing;
final secs = s.readerAutoSeconds; final secs = s.reader.autoSeconds;
ReaderBarItem one(ReaderAct a) => ReaderBarItem(48, [a]); ReaderBarItem one(ReaderAct a) => ReaderBarItem(48, [a]);
final split = switch (s.readerSplit) { final split = switch (s.reader.split) {
'on' => tr('두 쪽 나눠 보기: 켜기 (누르면 끄기)'), 'on' => tr('두 쪽 나눠 보기: 켜기 (누르면 끄기)'),
'off' => tr('두 쪽 나눠 보기: 끄기 (누르면 자동)'), 'off' => tr('두 쪽 나눠 보기: 끄기 (누르면 자동)'),
_ => tr('두 쪽 나눠 보기: 자동 - 두 쪽 스캔으로 보이는 그림만 (누르면 켜기)'), _ => tr('두 쪽 나눠 보기: 자동 - 두 쪽 스캔으로 보이는 그림만 (누르면 켜기)'),
@ -94,30 +94,30 @@ List<ReaderBarItem> readerBarItems(BuildContext context, AppController c, Reader
if (src is ImageFilesSource) one(ReaderAct(const Icon(Icons.info_outline), tr('사진 정보 (Exif)'), on.photoInfo)), if (src is ImageFilesSource) one(ReaderAct(const Icon(Icons.info_outline), tr('사진 정보 (Exif)'), on.photoInfo)),
// X-plore 맞추기 (관리자 지시 10/20): 아래 갤러리 줄 켜고 끄기 (그림 원본만) // X-plore 맞추기 (관리자 지시 10/20): 아래 갤러리 줄 켜고 끄기 (그림 원본만)
if (pictures) if (pictures)
one(ReaderAct(const Icon(Icons.view_carousel_outlined), s.readerGallery ? tr('아래 갤러리 줄 숨기기') : tr('아래 갤러리 줄 보이기'), one(ReaderAct(const Icon(Icons.view_carousel_outlined), s.reader.gallery ? tr('아래 갤러리 줄 숨기기') : tr('아래 갤러리 줄 보이기'),
on.toggleGallery, on: s.readerGallery)), on.toggleGallery, on: s.reader.gallery)),
// 119 · 120 // 119 · 120
one(ReaderAct(const Icon(Icons.vertical_split_outlined), split, on.cycleSplit, on: s.readerSplit != 'off')), one(ReaderAct(const Icon(Icons.vertical_split_outlined), split, on.cycleSplit, on: s.reader.split != 'off')),
one(ReaderAct(const Icon(Icons.rotate_right), tr('90° 회전 (시계 방향)'), () => on.rotate(1), on: st.turns != 0)), one(ReaderAct(const Icon(Icons.rotate_right), tr('90° 회전 (시계 방향)'), () => on.rotate(1), on: st.turns != 0)),
one(ReaderAct(const Icon(Icons.rotate_left), tr('반대로 90° 회전'), () => on.rotate(3), on: st.turns != 0)), one(ReaderAct(const Icon(Icons.rotate_left), tr('반대로 90° 회전'), () => on.rotate(3), on: st.turns != 0)),
one(ReaderAct(const RotatedBox(quarterTurns: 1, child: Icon(Icons.flip)), tr('상하 반전'), on.toggleFlip, on: st.flipV)), one(ReaderAct(const RotatedBox(quarterTurns: 1, child: Icon(Icons.flip)), tr('상하 반전'), on.toggleFlip, on: st.flipV)),
one(ReaderAct(const Icon(Icons.fit_screen_outlined), tr('한 쪽 맞추기'), () => on.updateSettings((x) => x.readerFit = 'page'), one(ReaderAct(const Icon(Icons.fit_screen_outlined), tr('한 쪽 맞추기'), () => on.updateSettings((x) => x.reader.fit = 'page'),
on: s.readerFit == 'page')), on: s.reader.fit == 'page')),
one(ReaderAct(const Icon(Icons.width_full_outlined), tr('좌우 맞추기 (세로로 밀어 봄)'), one(ReaderAct(const Icon(Icons.width_full_outlined), tr('좌우 맞추기 (세로로 밀어 봄)'),
() => on.updateSettings((x) => x.readerFit = 'width'), on: s.readerFit == 'width')), () => on.updateSettings((x) => x.reader.fit = 'width'), on: s.reader.fit == 'width')),
one(ReaderAct(const Icon(Icons.swap_horiz), one(ReaderAct(const Icon(Icons.swap_horiz),
s.readerRtl ? tr('넘기는 방향: 오른쪽 → 왼쪽 (만화)') : tr('넘기는 방향: 왼쪽 → 오른쪽'), s.reader.rtl ? tr('넘기는 방향: 오른쪽 → 왼쪽 (만화)') : tr('넘기는 방향: 왼쪽 → 오른쪽'),
() => on.updateSettings((x) => x.readerRtl = !x.readerRtl), on: s.readerRtl)), () => on.updateSettings((x) => x.reader.rtl = !x.reader.rtl), on: s.reader.rtl)),
one(ReaderAct(const Icon(Icons.brightness_low), tr('어둡게'), one(ReaderAct(const Icon(Icons.brightness_low), tr('어둡게'),
() => on.updateSettings((x) => x.readerBrightness = (x.readerBrightness - 0.1).clamp(-0.7, 0.7)))), () => on.updateSettings((x) => x.reader.brightness = (x.reader.brightness - 0.1).clamp(-0.7, 0.7)))),
one(ReaderAct(const Icon(Icons.brightness_high), tr('밝게'), one(ReaderAct(const Icon(Icons.brightness_high), tr('밝게'),
() => on.updateSettings((x) => x.readerBrightness = (x.readerBrightness + 0.1).clamp(-0.7, 0.7)))), () => on.updateSettings((x) => x.reader.brightness = (x.reader.brightness + 0.1).clamp(-0.7, 0.7)))),
// 30: 시계 · 알림 · 뒤로 막대를 그대로 / 화면 가득 // 30: 시계 · 알림 · 뒤로 막대를 그대로 / 화면 가득
one(ReaderAct(Icon(s.readerSystemBars ? Icons.fullscreen : Icons.fullscreen_exit), one(ReaderAct(Icon(s.reader.systemBars ? Icons.fullscreen : Icons.fullscreen_exit),
s.readerSystemBars ? tr('화면 가득 (시스템 막대 숨기기)') : tr('시스템 막대 보이기 (시계 · 알림 · 뒤로)'), () async { s.reader.systemBars ? tr('화면 가득 (시스템 막대 숨기기)') : tr('시스템 막대 보이기 (시계 · 알림 · 뒤로)'), () async {
await on.updateSettings((x) => x.readerSystemBars = !x.readerSystemBars); await on.updateSettings((x) => x.reader.systemBars = !x.reader.systemBars);
on.applySystemBars(); on.applySystemBars();
}, on: s.readerSystemBars)), }, on: s.reader.systemBars)),
// AI 는 막대가 좁으면 먼저 ⋮ 로 (늘 쓰는 버튼이 막대에 남게) // AI 는 막대가 좁으면 먼저 ⋮ 로 (늘 쓰는 버튼이 막대에 남게)
// 121: AI 해상도 올리기 (이 장 · 모든 장) - 새 파일로 저장, 원본 · ZIP 은 그대로 // 121: AI 해상도 올리기 (이 장 · 모든 장) - 새 파일로 저장, 원본 · ZIP 은 그대로
if (aiOn && src is! PdfSource) ...[ if (aiOn && src is! PdfSource) ...[

View File

@ -123,7 +123,7 @@ class _ReaderPageState extends State<ReaderPage> {
/// 보기 화면은 위 · 아래 시스템 막대까지 그림으로 (30: 설정으로 막대를 그대로 둘 수 있다) /// 보기 화면은 위 · 아래 시스템 막대까지 그림으로 (30: 설정으로 막대를 그대로 둘 수 있다)
void _applySystemBars() => SystemChrome.setEnabledSystemUIMode( void _applySystemBars() => SystemChrome.setEnabledSystemUIMode(
c.settings.readerSystemBars ? SystemUiMode.edgeToEdge : SystemUiMode.immersiveSticky); c.settings.reader.systemBars ? SystemUiMode.edgeToEdge : SystemUiMode.immersiveSticky);
// ───────── 쪽 나누기 (119) · 회전 · 반전 (120) ───────── // ───────── 쪽 나누기 (119) · 회전 · 반전 (120) ─────────
@ -139,14 +139,14 @@ class _ReaderPageState extends State<ReaderPage> {
bool _flipV = false; bool _flipV = false;
/// 이 장을 반씩 나눠 볼지. 자동은 두 쪽 스캔일 가능성이 높을 때만 ([autoSplitsPage]) /// 이 장을 반씩 나눠 볼지. 자동은 두 쪽 스캔일 가능성이 높을 때만 ([autoSplitsPage])
bool _splits(int page) => splitsPage(c.settings.readerSplit, page, _dims, bool _splits(int page) => splitsPage(c.settings.reader.split, page, _dims,
turns: _turns, turns: _turns,
// 관리자 지시 (10/20) · 내 판단: HWP · HWPX 문서의 가로 쪽 (가로 표 등) 은 만화 두 쪽 스캔이 아니라 나누지 않는다 // 관리자 지시 (10/20) · 내 판단: HWP · HWPX 문서의 가로 쪽 (가로 표 등) 은 만화 두 쪽 스캔이 아니라 나누지 않는다
bundle: src is! ImageFilesSource && src is! HwpSource); bundle: src is! ImageFilesSource && src is! HwpSource);
/// 223: 나눠 본 장에서 바로 한 장으로 (설정을 "끄기" 로) /// 223: 나눠 본 장에서 바로 한 장으로 (설정을 "끄기" 로)
Future<void> _splitOff() async { Future<void> _splitOff() async {
await c.updateSettings((x) => x.readerSplit = 'off'); await c.updateSettings((x) => x.reader.split = 'off');
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_resetZoom(); _resetZoom();
@ -177,7 +177,7 @@ class _ReaderPageState extends State<ReaderPage> {
unawaited(ReaderImageCache.imageSize(r).then((size) { unawaited(ReaderImageCache.imageSize(r).then((size) {
if (!mounted || size == null || v != _version || _dims.containsKey(page)) return; if (!mounted || size == null || v != _version || _dims.containsKey(page)) return;
_dims[page] = size; _dims[page] = size;
if (c.settings.readerSplit == 'auto') setState(_rebuildPages); if (c.settings.reader.split == 'auto') setState(_rebuildPages);
})); }));
} }
@ -188,7 +188,7 @@ class _ReaderPageState extends State<ReaderPage> {
}); });
Future<void> _cycleSplit() async { Future<void> _cycleSplit() async {
await c.updateSettings((x) => x.readerSplit = switch (x.readerSplit) { 'auto' => 'on', 'on' => 'off', _ => 'auto' }); await c.updateSettings((x) => x.reader.split = switch (x.reader.split) { 'auto' => 'on', 'on' => 'off', _ => 'auto' });
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_resetZoom(); _resetZoom();
@ -198,7 +198,7 @@ class _ReaderPageState extends State<ReaderPage> {
// ───────── 계속 보기 (117) ───────── // ───────── 계속 보기 (117) ─────────
late final _show = ReaderSlideshow(seconds: () => c.settings.readerAutoSeconds, onTick: _tick); late final _show = ReaderSlideshow(seconds: () => c.settings.reader.autoSeconds, onTick: _tick);
bool get _playing => _show.playing; bool get _playing => _show.playing;
/// 계속 보기를 멈추게 한 누름은 장 넘기기 · 막대 숨기기로 쓰지 않는다 /// 계속 보기를 멈추게 한 누름은 장 넘기기 · 막대 숨기기로 쓰지 않는다
@ -235,7 +235,7 @@ class _ReaderPageState extends State<ReaderPage> {
} }
Future<void> _setSeconds(int d) async { Future<void> _setSeconds(int d) async {
await c.updateSettings((x) => x.readerAutoSeconds = (x.readerAutoSeconds + d).clamp(1, 60)); await c.updateSettings((x) => x.reader.autoSeconds = (x.reader.autoSeconds + d).clamp(1, 60));
if (!mounted) return; if (!mounted) return;
setState(() {}); setState(() {});
if (_playing) _play(true); // 새 간격으로 (지금 장이 보이면 처음부터 다시) if (_playing) _play(true); // 새 간격으로 (지금 장이 보이면 처음부터 다시)
@ -311,7 +311,7 @@ class _ReaderPageState extends State<ReaderPage> {
/// X-plore 맞추기 (관리자 지시 10/20): 볼륨 키로 넘기기 (설정을 켰고 Android 일 때만 - 처리한 키는 시스템 볼륨을 바꾸지 않는다) /// X-plore 맞추기 (관리자 지시 10/20): 볼륨 키로 넘기기 (설정을 켰고 Android 일 때만 - 처리한 키는 시스템 볼륨을 바꾸지 않는다)
KeyEventResult _onKey(FocusNode _, KeyEvent e) { KeyEventResult _onKey(FocusNode _, KeyEvent e) {
final step = volumeKeyStep(e, enabled: c.settings.readerVolumeKeys && ReaderPage.volumeKeysSupported()); final step = volumeKeyStep(e, enabled: c.settings.reader.volumeKeys && ReaderPage.volumeKeysSupported());
if (step == null) return KeyEventResult.ignored; if (step == null) return KeyEventResult.ignored;
if (step != 0) _go(step); if (step != 0) _go(step);
return KeyEventResult.handled; return KeyEventResult.handled;
@ -332,7 +332,7 @@ class _ReaderPageState extends State<ReaderPage> {
_swallowTap = false; _swallowTap = false;
return; return;
} }
final step = tapZone(d.localPosition.dx, width, rtl: c.settings.readerRtl); final step = tapZone(d.localPosition.dx, width, rtl: c.settings.reader.rtl);
if (step != 0) { if (step != 0) {
_go(step); _go(step);
} else { } else {
@ -357,7 +357,7 @@ class _ReaderPageState extends State<ReaderPage> {
imageKey: '$_version:${slot.page}:${slot.part}:${_retry[slot.page] ?? 0}', imageKey: '$_version:${slot.page}:${slot.part}:${_retry[slot.page] ?? 0}',
image: _images.sharp(i) ?? (slot.part >= 0 ? _images.half(slot.page, _width) : _load(slot.page)), image: _images.sharp(i) ?? (slot.part >= 0 ? _images.half(slot.page, _width) : _load(slot.page)),
zoom: _zoom.of(i), zoomed: _zoom.zoomed, holdPage: _zoom.holdPage, zoom: _zoom.of(i), zoomed: _zoom.zoomed, holdPage: _zoom.holdPage,
turns: _turns, flipV: _flipV, rtl: c.settings.readerRtl, fit: c.settings.readerFit, box: box, turns: _turns, flipV: _flipV, rtl: c.settings.reader.rtl, fit: c.settings.reader.fit, box: box,
onShown: () => _markShown(i), onZoomed: _setZoomed, onZoomEnd: () => _onZoomEnd(i), onShown: () => _markShown(i), onZoomed: _setZoomed, onZoomEnd: () => _onZoomEnd(i),
// 관리자 지시 (10/20, HWP 2단계): [다시 시도] - 그 장의 기억을 지우고 다시 부른다 // 관리자 지시 (10/20, HWP 2단계): [다시 시도] - 그 장의 기억을 지우고 다시 부른다
onRetry: () => setState(() { onRetry: () => setState(() {
@ -430,7 +430,7 @@ class _ReaderPageState extends State<ReaderPage> {
} }
Future<void> _toggleGallery() async { Future<void> _toggleGallery() async {
await c.updateSettings((x) => x.readerGallery = !x.readerGallery); await c.updateSettings((x) => x.reader.gallery = !x.reader.gallery);
if (!mounted) return; if (!mounted) return;
setState(() {}); setState(() {});
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollGallery(animate: false)); WidgetsBinding.instance.addPostFrameCallback((_) => _scrollGallery(animate: false));
@ -449,9 +449,9 @@ class _ReaderPageState extends State<ReaderPage> {
final n = src.length; final n = src.length;
return ReaderBottomBar( return ReaderBottomBar(
pages: n, slots: _v.length, index: _index, label: slotLabel(_cur), pages: n, slots: _v.length, index: _index, label: slotLabel(_cur),
pageName: n == 0 ? '' : src.pageName(_cur.page), rtl: c.settings.readerRtl, onSlide: _pages.jumpToPage, pageName: n == 0 ? '' : src.pageName(_cur.page), rtl: c.settings.reader.rtl, onSlide: _pages.jumpToPage,
gallery: _pictures && c.settings.readerGallery && n > 0 gallery: _pictures && c.settings.reader.gallery && n > 0
? ReaderGalleryStrip(controller: _gallery, count: n, current: _cur.page, rtl: c.settings.readerRtl, ? ReaderGalleryStrip(controller: _gallery, count: n, current: _cur.page, rtl: c.settings.reader.rtl,
thumb: _images.thumb, onTap: _goPage) thumb: _images.thumb, onTap: _goPage)
: null, : null,
); );
@ -478,9 +478,9 @@ class _ReaderPageState extends State<ReaderPage> {
version: _version, version: _version,
count: _v.length, count: _v.length,
controller: _pages, controller: _pages,
rtl: s.readerRtl, rtl: s.reader.rtl,
holdPage: _zoom.holdPage, holdPage: _zoom.holdPage,
brightness: s.readerBrightness, brightness: s.reader.brightness,
onWidth: (w) => _width = w, onWidth: (w) => _width = w,
onPointerDown: _onPointerDown, onPointerDown: _onPointerDown,
onPointerUp: () => _finger(-1), onPointerUp: () => _finger(-1),

View File

@ -28,7 +28,7 @@ void main() {
final s = AppSettings(); final s = AppSettings();
expect([ expect([
s.startMenuShortcut, s.copy.syncStopToast, s.swipeWrap, s.migrationNotice, s.allowInnerToOuter, s.startMenuShortcut, s.copy.syncStopToast, s.swipeWrap, s.migrationNotice, s.allowInnerToOuter,
s.rememberPasswords, s.recycleOnDelete, s.readerSystemBars, s.rememberPasswords, s.recycleOnDelete, s.reader.systemBars,
], everyElement(isTrue)); ], everyElement(isTrue));
expect(s.cleanupPrechecked, ['work', 'download']); expect(s.cleanupPrechecked, ['work', 'download']);
expect(s.tripleTapAction, 'move'); expect(s.tripleTapAction, 'move');
@ -37,7 +37,7 @@ void main() {
expect([s.webPauseOnLeave, s.autoCheckUpdates, s.youtubeAdSkip, s.youtubeAdHide, s.runInBackground, s.swipeNav], expect([s.webPauseOnLeave, s.autoCheckUpdates, s.youtubeAdSkip, s.youtubeAdHide, s.runInBackground, s.swipeNav],
everyElement(isTrue)); everyElement(isTrue));
// 탐색기 처음 배치 = 두 창 (규칙 7 · 관리자 결정 3, 10/22) // 탐색기 처음 배치 = 두 창 (규칙 7 · 관리자 결정 3, 10/22)
expect([s.copy.liveSyncOnStart, s.copy.liveSyncIntervalSec, s.explorer.layout, s.zipComic], ['auto', 30, 'dual', false]); 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([AppSettings.uiScaleMin, AppSettings.uiScaleMax], [0.5, 2.5]);
// 바꾼 값은 남는다 // 바꾼 값은 남는다
final b = AppSettings.fromJson((AppSettings() final b = AppSettings.fromJson((AppSettings()

View File

@ -84,15 +84,15 @@ void main() {
test('117 · 119 설정: 처음 값 3초 · 자동, 1 ~ 60 초', () { test('117 · 119 설정: 처음 값 3초 · 자동, 1 ~ 60 초', () {
final s = AppSettings.fromJson({}); final s = AppSettings.fromJson({});
expect((s.readerAutoSeconds, s.readerSplit), (3, 'auto')); expect((s.reader.autoSeconds, s.reader.split), (3, 'auto'));
final b = AppSettings.fromJson((AppSettings() final b = AppSettings.fromJson((AppSettings()
..readerAutoSeconds = 12 ..reader.autoSeconds = 12
..readerSplit = 'on') ..reader.split = 'on')
.toJson()); .toJson());
expect((b.readerAutoSeconds, b.readerSplit), (12, 'on')); expect((b.reader.autoSeconds, b.reader.split), (12, 'on'));
expect(AppSettings.fromJson({'readerAutoSeconds': 500}).readerAutoSeconds, 60); expect(AppSettings.fromJson({'readerAutoSeconds': 500}).reader.autoSeconds, 60);
expect(AppSettings.fromJson({'readerAutoSeconds': 0}).readerAutoSeconds, 1); expect(AppSettings.fromJson({'readerAutoSeconds': 0}).reader.autoSeconds, 1);
expect(AppSettings.fromJson({'readerSplit': 'x'}).readerSplit, 'auto'); expect(AppSettings.fromJson({'readerSplit': 'x'}).reader.split, 'auto');
}); });
testWidgets('119: 자동이면 가로로 긴 그림만 반씩 (12-1 · 12-2), 표지 (세로) 는 통째 · 반쪽은 두 배 너비로 읽음', (t) async { testWidgets('119: 자동이면 가로로 긴 그림만 반씩 (12-1 · 12-2), 표지 (세로) 는 통째 · 반쪽은 두 배 너비로 읽음', (t) async {
@ -113,17 +113,17 @@ void main() {
// 버튼: 자동 → 켜기 (모든 장) → 끄기 → 자동 // 버튼: 자동 → 켜기 (모든 장) → 끄기 → 자동
await t.tap(find.byTooltip('두 쪽 나눠 보기: 자동 - 두 쪽 스캔으로 보이는 그림만 (누르면 켜기)')); await t.tap(find.byTooltip('두 쪽 나눠 보기: 자동 - 두 쪽 스캔으로 보이는 그림만 (누르면 켜기)'));
await t.pumpAndSettle(); await t.pumpAndSettle();
expect(c.settings.readerSplit, 'on'); expect(c.settings.reader.split, 'on');
expect(find.text('2-2 / 3장'), findsOneWidget, reason: '보던 쪽 그대로'); expect(find.text('2-2 / 3장'), findsOneWidget, reason: '보던 쪽 그대로');
await tapAt(t, 850); await tapAt(t, 850);
expect(find.text('3-1 / 3장'), findsOneWidget); expect(find.text('3-1 / 3장'), findsOneWidget);
await t.tap(find.byTooltip('두 쪽 나눠 보기: 켜기 (누르면 끄기)')); await t.tap(find.byTooltip('두 쪽 나눠 보기: 켜기 (누르면 끄기)'));
await t.pumpAndSettle(); await t.pumpAndSettle();
expect(c.settings.readerSplit, 'off'); expect(c.settings.reader.split, 'off');
expect(find.text('3 / 3'), findsOneWidget); expect(find.text('3 / 3'), findsOneWidget);
await t.tap(find.byTooltip('두 쪽 나눠 보기: 끄기 (누르면 자동)')); await t.tap(find.byTooltip('두 쪽 나눠 보기: 끄기 (누르면 자동)'));
await t.pumpAndSettle(); await t.pumpAndSettle();
expect(c.settings.readerSplit, 'auto'); expect(c.settings.reader.split, 'auto');
}); });
test('223: 자동 나누기 판단 - 묶음 (ZIP · CBZ · PDF) 의 가로 그림은 나눔, 폴더는 다른 그림 대부분이 세로일 때만', () { test('223: 자동 나누기 판단 - 묶음 (ZIP · CBZ · PDF) 의 가로 그림은 나눔, 폴더는 다른 그림 대부분이 세로일 때만', () {
@ -154,12 +154,12 @@ void main() {
expect(find.text('1-1 / 1장'), findsOneWidget); expect(find.text('1-1 / 1장'), findsOneWidget);
await t.tap(find.byTooltip('두 쪽 나눔 끄기 (한 장으로 보기)')); await t.tap(find.byTooltip('두 쪽 나눔 끄기 (한 장으로 보기)'));
await t.pumpAndSettle(); await t.pumpAndSettle();
expect(c.settings.readerSplit, 'off'); expect(c.settings.reader.split, 'off');
expect(find.text('1 / 1'), findsOneWidget); expect(find.text('1 / 1'), findsOneWidget);
}); });
testWidgets('119: 오른쪽 → 왼쪽으로 읽으면 오른쪽 반부터', (t) async { testWidgets('119: 오른쪽 → 왼쪽으로 읽으면 오른쪽 반부터', (t) async {
final c = controller()..settings.readerRtl = true; final c = controller()..settings.reader.rtl = true;
final src = await source(t, [(200, 100)]); final src = await source(t, [(200, 100)]);
await open(t, c, src); await open(t, c, src);
expect(find.text('1-1 / 1장'), findsOneWidget); expect(find.text('1-1 / 1장'), findsOneWidget);
@ -199,13 +199,13 @@ void main() {
await open(t, c, src); await open(t, c, src);
await t.tap(find.byTooltip('계속 보기 간격 늘리기 (지금 3초)')); await t.tap(find.byTooltip('계속 보기 간격 늘리기 (지금 3초)'));
await t.pumpAndSettle(); await t.pumpAndSettle();
expect(c.settings.readerAutoSeconds, 4); expect(c.settings.reader.autoSeconds, 4);
expect(find.text('4초'), findsOneWidget); expect(find.text('4초'), findsOneWidget);
await t.tap(find.byTooltip('계속 보기 간격 줄이기 (지금 4초)')); await t.tap(find.byTooltip('계속 보기 간격 줄이기 (지금 4초)'));
await t.pumpAndSettle(); await t.pumpAndSettle();
await t.tap(find.byTooltip('계속 보기 간격 줄이기 (지금 3초)')); await t.tap(find.byTooltip('계속 보기 간격 줄이기 (지금 3초)'));
await t.pumpAndSettle(); await t.pumpAndSettle();
expect(c.settings.readerAutoSeconds, 2); expect(c.settings.reader.autoSeconds, 2);
await t.tap(find.byTooltip('계속 보기 (2초마다 다음 장)')); await t.tap(find.byTooltip('계속 보기 (2초마다 다음 장)'));
await t.pump(); await t.pump();
@ -298,7 +298,7 @@ void main() {
} }
await t.tap(find.text('넘기는 방향: 왼쪽 → 오른쪽')); await t.tap(find.text('넘기는 방향: 왼쪽 → 오른쪽'));
await t.pumpAndSettle(); await t.pumpAndSettle();
expect(c.settings.readerRtl, isTrue); expect(c.settings.reader.rtl, isTrue);
// 넓으면 메뉴 없이 모두 // 넓으면 메뉴 없이 모두
await open(t, c, src, width: 1200); await open(t, c, src, width: 1200);
expect(find.byTooltip('더 보기'), findsNothing); expect(find.byTooltip('더 보기'), findsNothing);

View File

@ -58,12 +58,12 @@ void main() {
test('보기 설정 저장 · 읽기', () { test('보기 설정 저장 · 읽기', () {
final s = AppSettings() final s = AppSettings()
..imageExts = ['png', 'webp'] ..imageExts = ['png', 'webp']
..readerFit = 'width' ..reader.fit = 'width'
..readerRtl = true ..reader.rtl = true
..readerBrightness = 0.3 ..reader.brightness = 0.3
..zipComic = false; ..reader.zipComic = false;
final b = AppSettings.fromJson(s.toJson()); final b = AppSettings.fromJson(s.toJson());
expect([b.imageExts, b.readerFit, b.readerRtl, b.readerBrightness, b.zipComic], [ expect([b.imageExts, b.reader.fit, b.reader.rtl, b.reader.brightness, b.reader.zipComic], [
['png', 'webp'], 'width', true, 0.3, false, ['png', 'webp'], 'width', true, 0.3, false,
]); ]);
expect(AppSettings.fromJson({}).imageExts, defaultImageExtensions); expect(AppSettings.fromJson({}).imageExts, defaultImageExtensions);
@ -140,15 +140,15 @@ void main() {
// 만화 (오른쪽 → 왼쪽): 왼쪽을 누르면 다음 // 만화 (오른쪽 → 왼쪽): 왼쪽을 누르면 다음
await tester.tap(find.byTooltip('넘기는 방향: 왼쪽 → 오른쪽')); await tester.tap(find.byTooltip('넘기는 방향: 왼쪽 → 오른쪽'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(c.settings.readerRtl, isTrue); expect(c.settings.reader.rtl, isTrue);
await tapAt(50); await tapAt(50);
expect(find.text('3 / 3'), findsOneWidget); expect(find.text('3 / 3'), findsOneWidget);
// 맞추기 · 밝기 // 맞추기 · 밝기
await tester.tap(find.byTooltip('좌우 맞추기 (세로로 밀어 봄)')); await tester.tap(find.byTooltip('좌우 맞추기 (세로로 밀어 봄)'));
await tester.tap(find.byTooltip('밝게')); await tester.tap(find.byTooltip('밝게'));
await tester.pump(); await tester.pump();
expect(c.settings.readerFit, 'width'); expect(c.settings.reader.fit, 'width');
expect(c.settings.readerBrightness, closeTo(0.1, 1e-9)); expect(c.settings.reader.brightness, closeTo(0.1, 1e-9));
}); });
testWidgets('30: 두 손가락 확대 → 밀어도 장이 넘어가지 않음 · 더 크게 다시 그림 · 시스템 막대 보이기 설정', (tester) async { testWidgets('30: 두 손가락 확대 → 밀어도 장이 넘어가지 않음 · 더 크게 다시 그림 · 시스템 막대 보이기 설정', (tester) async {
@ -181,20 +181,20 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('1 / 2'), findsOneWidget); expect(find.text('1 / 2'), findsOneWidget);
// 101: 기본은 시스템 막대 보임, 버튼으로 화면 가득 (설정에 남음) // 101: 기본은 시스템 막대 보임, 버튼으로 화면 가득 (설정에 남음)
expect(c.settings.readerSystemBars, isTrue); expect(c.settings.reader.systemBars, isTrue);
expect(AppSettings.fromJson({}).readerSystemBars, isTrue); expect(AppSettings.fromJson({}).reader.systemBars, isTrue);
await tester.tap(find.byTooltip('화면 가득 (시스템 막대 숨기기)')); await tester.tap(find.byTooltip('화면 가득 (시스템 막대 숨기기)'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(c.settings.readerSystemBars, isFalse); expect(c.settings.reader.systemBars, isFalse);
expect(AppSettings.fromJson(c.settings.toJson()).readerSystemBars, isFalse); expect(AppSettings.fromJson(c.settings.toJson()).reader.systemBars, isFalse);
}); });
testWidgets('X-plore 맞추기: 아래 갤러리 줄 - 처음은 끔, 켜면 썸네일 · 누르면 그 장으로, 위 막대에서 끄기', (tester) async { testWidgets('X-plore 맞추기: 아래 갤러리 줄 - 처음은 끔, 켜면 썸네일 · 누르면 그 장으로, 위 막대에서 끄기', (tester) async {
tester.view.physicalSize = const Size(1400, 1200); tester.view.physicalSize = const Size(1400, 1200);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset); addTearDown(tester.view.reset);
expect(AppSettings().readerGallery, isFalse, reason: '처음 값은 지금 동작 그대로'); expect(AppSettings().reader.gallery, isFalse, reason: '처음 값은 지금 동작 그대로');
expect(AppSettings.fromJson({'readerGallery': true}).readerGallery, isTrue); expect(AppSettings.fromJson({'readerGallery': true}).reader.gallery, isTrue);
final files = [ final files = [
for (final (i, n) in ['a.png', 'b.png', 'c.png'].indexed) (File(p.join(tmp.path, n))..writeAsBytesSync(_png(i * 80, 0, 0))).path, for (final (i, n) in ['a.png', 'b.png', 'c.png'].indexed) (File(p.join(tmp.path, n))..writeAsBytesSync(_png(i * 80, 0, 0))).path,
]; ];
@ -204,7 +204,7 @@ void main() {
expect(find.byKey(const ValueKey('thumb0')), findsNothing, reason: '처음은 끔'); expect(find.byKey(const ValueKey('thumb0')), findsNothing, reason: '처음은 끔');
await tester.tap(find.byTooltip('아래 갤러리 줄 보이기')); await tester.tap(find.byTooltip('아래 갤러리 줄 보이기'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(c.settings.readerGallery, isTrue); expect(c.settings.reader.gallery, isTrue);
expect(find.byKey(const ValueKey('thumb2')), findsOneWidget); expect(find.byKey(const ValueKey('thumb2')), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('thumb2'))); await tester.tap(find.byKey(const ValueKey('thumb2')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@ -222,7 +222,7 @@ void main() {
final was = ReaderPage.volumeKeysSupported; final was = ReaderPage.volumeKeysSupported;
addTearDown(() => ReaderPage.volumeKeysSupported = was); addTearDown(() => ReaderPage.volumeKeysSupported = was);
ReaderPage.volumeKeysSupported = () => true; ReaderPage.volumeKeysSupported = () => true;
expect(AppSettings().readerVolumeKeys, isFalse); expect(AppSettings().reader.volumeKeys, isFalse);
final files = [ final files = [
for (final (i, n) in ['a.png', 'b.png', 'c.png'].indexed) (File(p.join(tmp.path, n))..writeAsBytesSync(_png(i * 80, 0, 0))).path, for (final (i, n) in ['a.png', 'b.png', 'c.png'].indexed) (File(p.join(tmp.path, n))..writeAsBytesSync(_png(i * 80, 0, 0))).path,
]; ];
@ -234,7 +234,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('1 / 3'), findsOneWidget); expect(find.text('1 / 3'), findsOneWidget);
// 켬 // 켬
c.settings.readerVolumeKeys = true; c.settings.reader.volumeKeys = true;
expect(await tester.sendKeyEvent(LogicalKeyboardKey.audioVolumeDown), isTrue, reason: '처리함 - 시스템 볼륨은 바뀌지 않음'); expect(await tester.sendKeyEvent(LogicalKeyboardKey.audioVolumeDown), isTrue, reason: '처리함 - 시스템 볼륨은 바뀌지 않음');
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('2 / 3'), findsOneWidget); expect(find.text('2 / 3'), findsOneWidget);

View File

@ -143,7 +143,7 @@ void main() {
expect(old.screenOrientation, 'auto'); expect(old.screenOrientation, 'auto');
// 규칙 7 · 결정 3 (10/22): 처음 배치가 두 창이 되어 예전 'dual' 은 그대로 (옮겼다는 알림 없음) // 규칙 7 · 결정 3 (10/22): 처음 배치가 두 창이 되어 예전 'dual' 은 그대로 (옮겼다는 알림 없음)
expect(old.explorer.layout, 'dual'); expect(old.explorer.layout, 'dual');
expect(old.zipComic, isFalse); expect(old.reader.zipComic, isFalse);
expect(old.migrated, containsAll(['orientation', 'zipComic'])); expect(old.migrated, containsAll(['orientation', 'zipComic']));
expect(old.migrated, isNot(contains('explorerLayout'))); expect(old.migrated, isNot(contains('explorerLayout')));
// 다시 저장 · 읽기: 이제 사용자가 고른 값으로 보고 그대로 · 알림 없음 // 다시 저장 · 읽기: 이제 사용자가 고른 값으로 보고 그대로 · 알림 없음