설정 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,
];
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 {
for (var i = 0; i < ms ~/ 100; i++) {
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 'settings/records.dart';
import 'settings/settings_group.dart';
import 'settings/reader_settings.dart';
import 'settings/ai_image_settings.dart';
import 'settings/copy_settings.dart';
import 'settings/tor_settings.dart';
@ -24,6 +25,7 @@ import 'settings/explorer_settings.dart';
export 'settings/records.dart';
export 'settings/settings_group.dart';
export 'settings/settings_store.dart';
export 'settings/reader_settings.dart';
export 'settings/ai_image_settings.dart';
export 'settings/copy_settings.dart';
export 'settings/tor_settings.dart';
@ -254,30 +256,13 @@ class AppSettings {
/// 그림 보기 (만화 보기) 대상 확장자 (파일 탐색기에서 두 번 누르면 보기로 연다)
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 로 보이고 눌러서 열린다
bool startMenuShortcut = true;
@ -322,8 +307,6 @@ class AppSettings {
/// 내장 플레이어 자막 글자 크기 배율 (0.5 ~ 2.5, 기본 1)
double subtitleScale = 1.0;
/// ZIP · CBZ 를 두 번 누르면 안의 그림을 만화처럼 보기 (끄면 목록). 기본은 목록 (사용자 지시)
bool zipComic = false;
/// WebDAV 서버 (파일 탐색기 · Rsync 화면 위쪽 "SD 카드" 옆 탭). 비밀번호는 이 설정 파일에만.
List<DavServer> webdavServers = [];
@ -434,8 +417,11 @@ class AppSettings {
/// AI 그림 · 해상도 올리기 (123 · 121)
final aiImage = AiImageSettings();
/// 보기 화면 (그림 · 만화 · PDF · 문서)
final reader = ReaderSettings();
/// 설정 분야들 (설정 A) - 저마다 자기 키를 읽고 쓴다. toJson · fromJson 은 이 목록을 돌며 합치기만 한다
List<SettingsGroup> get groups => [explorer, network, tor, copy, aiImage];
List<SettingsGroup> get groups => [explorer, network, tor, copy, aiImage, reader];
/// [plainSecrets]: 안전 저장소를 쓸 수 없을 때만 (예전 파일에 있던 비밀 값을 잃지 않게) 예전처럼 파일에 둔다
Map<String, Object?> toJson({bool plainSecrets = false}) {
@ -513,14 +499,6 @@ class AppSettings {
'navOrder': navOrder,
'swipeNav': swipeNav,
'imageExts': imageExts,
'readerFit': readerFit,
'readerRtl': readerRtl,
'readerBrightness': readerBrightness,
'readerSystemBars': readerSystemBars,
'readerAutoSeconds': readerAutoSeconds,
'readerGallery': readerGallery,
'readerVolumeKeys': readerVolumeKeys,
'readerSplit': readerSplit,
'startMenuShortcut': startMenuShortcut,
'swipeWrap': swipeWrap,
'browserSwipeHinted': browserSwipeHinted,
@ -535,9 +513,6 @@ class AppSettings {
// 116: 예전 판의 표시 ('allFilesHintShown') 는 권한이 없어진 것을 몰라 다시 띄우지 못했다 → 새 이름으로 한 번 더
'allFilesHint2': allFilesHintShown,
'subtitleScale': subtitleScale,
'zipComic': zipComic,
// 예전 기본값 (만화 보기 켜짐) 은 지시와 반대였으므로 한 번 꺼진 상태 (목록) 로
'zipComicV2': true,
'webdavServers': [
for (final x in webdavServers) {...x.toJson(), if (plainSecrets) 'password': x.password},
],
@ -668,14 +643,6 @@ class AppSettings {
..imageExts = j['imageExts'] is List
? [for (final x in j['imageExts'] as List) '$x'.toLowerCase()]
: [...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
..swipeWrap = j['swipeWrap'] != false
..browserSwipeHinted = j['browserSwipeHinted'] == true
@ -691,7 +658,6 @@ class AppSettings {
..davExternalOpen = const ['fetch', 'url'].contains(j['davExternalOpen']) ? j['davExternalOpen'] as String : 'ask'
..allFilesHintShown = j['allFilesHint2'] == true
..subtitleScale = ((j['subtitleScale'] as num?)?.toDouble() ?? 1.0).clamp(0.5, 2.5)
..zipComic = j['zipComic'] == true && j['zipComicV2'] == true
..webdavServers = [
for (final x in (j['webdavServers'] as List?) ?? const [])
if (x is Map) DavServer.fromJson(x),
@ -803,7 +769,7 @@ class AppSettings {
// 53 · 28: 앱 안 브라우저 쿠키를 쓰던 사람에게 넘기는 사이트가 넓어졌음을 한 번 알린다
if (j['cookieScopeV2'] != true && j.isNotEmpty && (j['ytCookiesBrowser'] ?? internalBrowserCookies) == internalBrowserCookies)
'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)
'background',
// 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(
secondary: const Icon(Icons.auto_stories_outlined),
value: s.zipComic,
onChanged: (v) => c.updateSettings((x) => x.zipComic = v),
value: s.reader.zipComic,
onChanged: (v) => c.updateSettings((x) => x.reader.zipComic = v),
title: Text(tr('ZIP · CBZ 를 만화로 보기')),
subtitle: Text(tr('켜면 두 번 눌렀을 때 안의 그림을 바로 넘겨 봅니다. 끄면 목록 (골라서 풀기) 을 엽니다.')),
),
@ -55,20 +55,20 @@ class ViewerSettings extends StatelessWidget {
title: Text(tr('두 쪽 나눠 보기')),
subtitle: Text(tr('한 장에 두 쪽이 붙은 그림을 반씩 넘겨 봅니다 (읽는 방향의 쪽부터)')),
trailing: DropdownButton<String>(
value: s.readerSplit,
value: s.reader.split,
items: [
DropdownMenuItem(value: 'auto', child: Text(tr('자동 (가로로 긴 그림만)'))),
DropdownMenuItem(value: 'on', 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): 아래 갤러리 줄 (보기 화면 위 막대에서도 켜고 끔)
SwitchListTile(
secondary: const Icon(Icons.view_carousel_outlined),
value: s.readerGallery,
onChanged: (v) => c.updateSettings((x) => x.readerGallery = v),
value: s.reader.gallery,
onChanged: (v) => c.updateSettings((x) => x.reader.gallery = v),
title: Text(tr('그림 보기 아래 갤러리 줄')),
subtitle: Text(tr('그림을 볼 때 아래에 작은 그림 줄을 보입니다 (누르면 그 장으로)')),
),
@ -76,8 +76,8 @@ class ViewerSettings extends StatelessWidget {
if (Platform.isAndroid)
SwitchListTile(
secondary: const Icon(Icons.volume_down_outlined),
value: s.readerVolumeKeys,
onChanged: (v) => c.updateSettings((x) => x.readerVolumeKeys = v),
value: s.reader.volumeKeys,
onChanged: (v) => c.updateSettings((x) => x.reader.volumeKeys = v),
title: Text(tr('볼륨 키로 넘기기')),
subtitle: Text(tr('보기 화면에서 볼륨 아래 = 다음 장, 위 = 이전 장 (동영상 · 다른 화면의 볼륨은 그대로)')),
),
@ -87,9 +87,9 @@ class ViewerSettings extends StatelessWidget {
title: Text(tr('계속 보기 간격')),
subtitle: Text(tr('보기 화면의 [계속 보기] 가 다음 장으로 넘기는 간격')),
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])))],
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 {
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) {
ctl.host.snack(trf('열 수 없습니다: {0}', [e]));
return;

View File

@ -65,9 +65,9 @@ List<ReaderBarItem> readerBarItems(BuildContext context, AppController c, Reader
final pictures = src is ImageFilesSource || src is ZipImagesSource;
final aiOn = c.settings.components.contains('aiimage');
final playing = st.playing;
final secs = s.readerAutoSeconds;
final secs = s.reader.autoSeconds;
ReaderBarItem one(ReaderAct a) => ReaderBarItem(48, [a]);
final split = switch (s.readerSplit) {
final split = switch (s.reader.split) {
'on' => tr('두 쪽 나눠 보기: 켜기 (누르면 끄기)'),
'off' => 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)),
// X-plore 맞추기 (관리자 지시 10/20): 아래 갤러리 줄 켜고 끄기 (그림 원본만)
if (pictures)
one(ReaderAct(const Icon(Icons.view_carousel_outlined), s.readerGallery ? tr('아래 갤러리 줄 숨기기') : tr('아래 갤러리 줄 보이기'),
on.toggleGallery, on: s.readerGallery)),
one(ReaderAct(const Icon(Icons.view_carousel_outlined), s.reader.gallery ? tr('아래 갤러리 줄 숨기기') : tr('아래 갤러리 줄 보이기'),
on.toggleGallery, on: s.reader.gallery)),
// 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_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 Icon(Icons.fit_screen_outlined), tr('한 쪽 맞추기'), () => on.updateSettings((x) => x.readerFit = 'page'),
on: s.readerFit == 'page')),
one(ReaderAct(const Icon(Icons.fit_screen_outlined), tr('한 쪽 맞추기'), () => on.updateSettings((x) => x.reader.fit = 'page'),
on: s.reader.fit == 'page')),
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),
s.readerRtl ? tr('넘기는 방향: 오른쪽 → 왼쪽 (만화)') : tr('넘기는 방향: 왼쪽 → 오른쪽'),
() => on.updateSettings((x) => x.readerRtl = !x.readerRtl), on: s.readerRtl)),
s.reader.rtl ? tr('넘기는 방향: 오른쪽 → 왼쪽 (만화)') : tr('넘기는 방향: 왼쪽 → 오른쪽'),
() => on.updateSettings((x) => x.reader.rtl = !x.reader.rtl), on: s.reader.rtl)),
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('밝게'),
() => 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: 시계 · 알림 · 뒤로 막대를 그대로 / 화면 가득
one(ReaderAct(Icon(s.readerSystemBars ? Icons.fullscreen : Icons.fullscreen_exit),
s.readerSystemBars ? tr('화면 가득 (시스템 막대 숨기기)') : tr('시스템 막대 보이기 (시계 · 알림 · 뒤로)'), () async {
await on.updateSettings((x) => x.readerSystemBars = !x.readerSystemBars);
one(ReaderAct(Icon(s.reader.systemBars ? Icons.fullscreen : Icons.fullscreen_exit),
s.reader.systemBars ? tr('화면 가득 (시스템 막대 숨기기)') : tr('시스템 막대 보이기 (시계 · 알림 · 뒤로)'), () async {
await on.updateSettings((x) => x.reader.systemBars = !x.reader.systemBars);
on.applySystemBars();
}, on: s.readerSystemBars)),
}, on: s.reader.systemBars)),
// AI 는 막대가 좁으면 먼저 ⋮ 로 (늘 쓰는 버튼이 막대에 남게)
// 121: AI 해상도 올리기 (이 장 · 모든 장) - 새 파일로 저장, 원본 · ZIP 은 그대로
if (aiOn && src is! PdfSource) ...[

View File

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

View File

@ -28,7 +28,7 @@ void main() {
final s = AppSettings();
expect([
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));
expect(s.cleanupPrechecked, ['work', 'download']);
expect(s.tripleTapAction, 'move');
@ -37,7 +37,7 @@ void main() {
expect([s.webPauseOnLeave, s.autoCheckUpdates, s.youtubeAdSkip, s.youtubeAdHide, s.runInBackground, s.swipeNav],
everyElement(isTrue));
// 탐색기 처음 배치 = 두 창 (규칙 7 · 관리자 결정 3, 10/22)
expect([s.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]);
// 바꾼 값은 남는다
final b = AppSettings.fromJson((AppSettings()

View File

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

View File

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

View File

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