M4 1묶음: "Android 인지" 전역 함수 둘 (앱 관리자 단추 · 볼륨 키) 을 PlatformInfo 로 + WakeLock (보기 화면 화면 켜 두기) 을 주입으로

- ExplorerButton.explorerValues · defaultOrder · fromSettings 는 android 를 받는다 (부르는 쪽이 c.services.platform.isAndroid) -
  예전 바꿀 수 있는 전역 함수 appsButtonSupported
- 보기 화면 볼륨 키는 c.services.platform.isAndroid (예전 ReaderPage.volumeKeysSupported)
- PlatformServices.keepScreenOn (처음 값 WakelockPlus - 예전 ReaderPage.keepScreenOn)
- 시험: app_manager · explorer_archive_trash · explorer_conflict · reader · reader_more 는 주입으로 (2회 통과). 볼륨 키 시험은
  Android 조립과 Windows 조립 두 화면으로
- 사용자가 보게 될 동작: 없음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
modular
RyuJongJin 2026-10-25 18:00:31 +09:00
parent 39b76e2423
commit 23cc9f6e5b
11 changed files with 46 additions and 46 deletions

View File

@ -32,6 +32,7 @@ PlatformServices shotServices() {
vault: r.vault, vault: r.vault,
privileged: r.privileged, privileged: r.privileged,
isMetered: r.isMetered, isMetered: r.isMetered,
keepScreenOn: r.keepScreenOn,
updater: r.updater, updater: r.updater,
usage: r.usage == null ? null : const FixedUsage(), usage: r.usage == null ? null : const FixedUsage(),
models: r.models, models: r.models,

View File

@ -38,6 +38,7 @@ import 'subtitle_provider.dart';
import 'system_usage.dart'; import 'system_usage.dart';
import 'updater.dart'; import 'updater.dart';
import '../l10n/tr.dart'; import '../l10n/tr.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
/// 플랫폼별 구현을 한 곳에서 고른다. /// 플랫폼별 구현을 한 곳에서 고른다.
/// Android 이식 시 이 파일에 분기만 추가하면 된다. /// Android 이식 시 이 파일에 분기만 추가하면 된다.
@ -91,6 +92,9 @@ class PlatformServices {
/// 지금 쓰는 망이 데이터 요금제인지 (원격 썸네일 "Wi-Fi 에서만" - 설계 M3 ThumbPolicy). 처음 값 = 실제 (Android 만 묻고 PC 는 false) /// 지금 쓰는 망이 데이터 요금제인지 (원격 썸네일 "Wi-Fi 에서만" - 설계 M3 ThumbPolicy). 처음 값 = 실제 (Android 만 묻고 PC 는 false)
final Future<bool> Function() isMetered; final Future<bool> Function() isMetered;
/// 화면을 켜 둔다 / 놓는다 (보기 화면의 자동 넘기기 - 설계 M4 WakeLock). 처음 값 = WakelockPlus
final Future<void> Function(bool on) keepScreenOn;
/// 이 기기 (Android · Windows · 메모리) - 기기마다 다르게 도는 곳은 이것을 본다 (M1, 처음 값 = 이 기기) /// 이 기기 (Android · Windows · 메모리) - 기기마다 다르게 도는 곳은 이것을 본다 (M1, 처음 값 = 이 기기)
final PlatformInfo platform; final PlatformInfo platform;
@ -118,9 +122,11 @@ class PlatformServices {
VaultLocation? vault, VaultLocation? vault,
this.privileged, this.privileged,
Future<bool> Function()? isMetered, Future<bool> Function()? isMetered,
Future<void> Function(bool on)? keepScreenOn,
}) : models = models ?? ModelStore(), }) : models = models ?? ModelStore(),
vault = vault ?? VaultLocation.under(storage.tempDirectory), vault = vault ?? VaultLocation.under(storage.tempDirectory),
isMetered = isMetered ?? deviceMetered, isMetered = isMetered ?? deviceMetered,
keepScreenOn = keepScreenOn ?? _wakeLock,
platform = platform ?? PlatformInfo.host, platform = platform ?? PlatformInfo.host,
shell = shell ?? NoopShell(); shell = shell ?? NoopShell();
@ -192,3 +198,5 @@ class PlatformServices {
throw UnsupportedError(trf('아직 지원하지 않는 플랫폼입니다: {0}', [Platform.operatingSystem])); throw UnsupportedError(trf('아직 지원하지 않는 플랫폼입니다: {0}', [Platform.operatingSystem]));
} }
} }
Future<void> _wakeLock(bool on) => on ? WakelockPlus.enable() : WakelockPlus.disable();

View File

@ -103,7 +103,7 @@ class ExplorerPanesBody extends StatelessWidget {
// X-plore b: 길게 누르기 = 짝 동작 (Rsync 화면은 없음) // X-plore b: 길게 누르기 = 짝 동작 (Rsync 화면은 없음)
onLongPress: ctl.rsync ? null : buttons.longPress, onLongPress: ctl.rsync ? null : buttons.longPress,
// Rsync 화면: 늘 같은 버튼 (→ ← ⇄ 모니터링) 을 두 창 사이에 // Rsync 화면: 늘 같은 버튼 (→ ← ⇄ 모니터링) 을 두 창 사이에
buttons: ctl.rsync ? ExplorerButton.rsyncButtons : ExplorerButton.fromSettings(s.explorer.buttons), buttons: ctl.rsync ? ExplorerButton.rsyncButtons : ExplorerButton.fromSettings(s.explorer.buttons, android: ctl.c.services.platform.isAndroid),
vertical: side && !rsyncOne, vertical: side && !rsyncOne,
panesSide: side, panesSide: side,
onPressed: buttons.press, onPressed: buttons.press,

View File

@ -1,4 +1,3 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -61,30 +60,28 @@ enum ExplorerButton {
/// Rsync 화면에만 있는 버튼 (파일 탐색기 버튼 구성에서는 빼기) /// Rsync 화면에만 있는 버튼 (파일 탐색기 버튼 구성에서는 빼기)
bool get rsyncOnly => this == toRight || this == toLeft || this == both || this == monitor; bool get rsyncOnly => this == toRight || this == toLeft || this == both || this == monitor;
/// 파일 탐색기에서 고를 수 있는 버튼 /// 파일 탐색기에서 고를 수 있는 버튼. [앱 관리자] 는 Android 에서만 ([android] = c.services.platform.isAndroid - 설계 M4, 예전 전역 함수 appsButtonSupported)
static List<ExplorerButton> get explorerValues => [for (final b in values) if (!b.rsyncOnly && (b != apps || appsButtonSupported())) b]; static List<ExplorerButton> explorerValues({required bool android}) =>
[for (final b in values) if (!b.rsyncOnly && (b != apps || android)) b];
/// 앱 관리자 버튼을 쓰는 기기 (Android - 시험에서 바꿈)
static bool Function() appsButtonSupported = () => Platform.isAndroid;
/// Rsync 화면의 버튼 (늘 이대로, 두 창 사이) /// Rsync 화면의 버튼 (늘 이대로, 두 창 사이)
// 208: 방향 실행 (Rsync 화면의 핵심) 을 앞에 - 좁아서 [더 보기] 로 가는 것은 뒤쪽 // 208: 방향 실행 (Rsync 화면의 핵심) 을 앞에 - 좁아서 [더 보기] 로 가는 것은 뒤쪽
static const rsyncButtons = [toRight, toLeft, both, monitor, up, refresh, newFolder, bookmarks]; static const rsyncButtons = [toRight, toLeft, both, monitor, up, refresh, newFolder, bookmarks];
/// 147 (관리자): 처음 값 (버튼 구성을 바꾸지 않았을 때) 은 자주 쓰는 것부터 - 좁은 화면에서 [더 보기] 로 가는 것은 뒤쪽 /// 147 (관리자): 처음 값 (버튼 구성을 바꾸지 않았을 때) 은 자주 쓰는 것부터 - 좁은 화면에서 [더 보기] 로 가는 것은 뒤쪽
static List<ExplorerButton> get defaultOrder { static List<ExplorerButton> defaultOrder({required bool android}) {
const first = [copy, move, delete, newFolder, rename, select, search, bookmarks, orient]; const first = [copy, move, delete, newFolder, rename, select, search, bookmarks, orient];
return [...first, for (final b in explorerValues) if (!first.contains(b)) b]; return [...first, for (final b in explorerValues(android: android)) if (!first.contains(b)) b];
} }
static List<ExplorerButton> fromSettings(List<String> names) { static List<ExplorerButton> fromSettings(List<String> names, {required bool android}) {
final out = [ final out = [
for (final n in names) for (final n in names)
for (final b in explorerValues) for (final b in explorerValues(android: android))
if (b.name == n) b, if (b.name == n) b,
]; ];
// 사용자가 정한 순서는 그대로 (비어 있을 때만 처음 값) // 사용자가 정한 순서는 그대로 (비어 있을 때만 처음 값)
return out.isEmpty ? defaultOrder : out; return out.isEmpty ? defaultOrder(android: android) : out;
} }
} }

View File

@ -197,8 +197,9 @@ class ExplorerViewDialogs {
} }
Future<void> buttonsDialog(BuildContext context) async { Future<void> buttonsDialog(BuildContext context) async {
final current = ExplorerButton.fromSettings(ctl.c.settings.explorer.buttons); final android = ctl.c.services.platform.isAndroid;
final order = [...current, ...ExplorerButton.explorerValues.where((b) => !current.contains(b))]; final current = ExplorerButton.fromSettings(ctl.c.settings.explorer.buttons, android: android);
final order = [...current, ...ExplorerButton.explorerValues(android: android).where((b) => !current.contains(b))];
final on = {...current}; final on = {...current};
final ok = await showDialog<bool>( final ok = await showDialog<bool>(
context: context, context: context,
@ -237,10 +238,10 @@ class ExplorerViewDialogs {
onPressed: () => set(() { onPressed: () => set(() {
order order
..clear() ..clear()
..addAll(ExplorerButton.defaultOrder); ..addAll(ExplorerButton.defaultOrder(android: android));
on on
..clear() ..clear()
..addAll(ExplorerButton.explorerValues); ..addAll(ExplorerButton.explorerValues(android: android));
}), }),
child: Text(tr('기본값')), child: Text(tr('기본값')),
), ),

View File

@ -1,10 +1,8 @@
import 'dart:async'; import 'dart:async';
import 'dart:io' show Platform;
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import '../app/app_controller.dart'; import '../app/app_controller.dart';
import '../core/reader_sources.dart'; import '../core/reader_sources.dart';
@ -49,11 +47,7 @@ class ReaderPage extends StatefulWidget {
final bool autoPlay; final bool autoPlay;
const ReaderPage({super.key, required this.c, required this.source, this.start = 0, this.autoPlay = false}); const ReaderPage({super.key, required this.c, required this.source, this.start = 0, this.autoPlay = false});
/// 볼륨 키로 넘기기를 쓰는 기기인지 (Android - 시험에서 바꿈)
static bool Function() volumeKeysSupported = () => Platform.isAndroid;
/// 계속 보기 동안 화면 켜 두기 (시험에서 바꿈)
static Future<void> Function(bool on) keepScreenOn = (on) => on ? WakelockPlus.enable() : WakelockPlus.disable();
@override @override
State<ReaderPage> createState() => _ReaderPageState(); State<ReaderPage> createState() => _ReaderPageState();
@ -217,7 +211,7 @@ class _ReaderPageState extends State<ReaderPage> {
// 지금 장이 이미 보이면 바로 재기 시작, 아직 받는 중이면 보인 뒤에 ([_markShown]) // 지금 장이 이미 보이면 바로 재기 시작, 아직 받는 중이면 보인 뒤에 ([_markShown])
if (!_show.set(on, _v.isNotEmpty ? _curKey : null)) return; if (!_show.set(on, _v.isNotEmpty ? _curKey : null)) return;
setState(() {}); setState(() {});
unawaited(ReaderPage.keepScreenOn(on).catchError((_) {})); unawaited(c.services.keepScreenOn(on).catchError((_) {}));
} }
void _tick() { void _tick() {
@ -274,7 +268,7 @@ class _ReaderPageState extends State<ReaderPage> {
_gallery.dispose(); _gallery.dispose();
_life.dispose(); _life.dispose();
_show.dispose(); _show.dispose();
if (_playing) unawaited(ReaderPage.keepScreenOn(false).catchError((_) {})); if (_playing) unawaited(c.services.keepScreenOn(false).catchError((_) {}));
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
_images.dispose(); _images.dispose();
_zoom.reset(); _zoom.reset();
@ -311,7 +305,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.reader.volumeKeys && ReaderPage.volumeKeysSupported()); final step = volumeKeyStep(e, enabled: c.settings.reader.volumeKeys && c.services.platform.isAndroid);
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;

View File

@ -110,11 +110,8 @@ void main() {
}); });
test('가운데 버튼 [앱 관리자] 는 Android 에서만 고를 수 있음', () { test('가운데 버튼 [앱 관리자] 는 Android 에서만 고를 수 있음', () {
final was = ExplorerButton.appsButtonSupported; // 설계 M4: Android 인지는 부르는 쪽이 넘긴다 (c.services.platform.isAndroid)
addTearDown(() => ExplorerButton.appsButtonSupported = was); expect(ExplorerButton.explorerValues(android: false), isNot(contains(ExplorerButton.apps)));
ExplorerButton.appsButtonSupported = () => false; expect(ExplorerButton.explorerValues(android: true), contains(ExplorerButton.apps));
expect(ExplorerButton.explorerValues, isNot(contains(ExplorerButton.apps)));
ExplorerButton.appsButtonSupported = () => true;
expect(ExplorerButton.explorerValues, contains(ExplorerButton.apps));
}); });
} }

View File

@ -113,7 +113,7 @@ void main() {
device.trash = true; device.trash = true;
device.volumes = () => [(tmp.path, 'T:')]; device.volumes = () => [(tmp.path, 'T:')];
// [휴지통] 버튼이 줄에 보이게 (처음 구성은 좁으면 [더 보기] 로 간다) // [휴지통] 버튼이 줄에 보이게 (처음 구성은 좁으면 [더 보기] 로 간다)
c.settings.explorer.buttons = ['trash', for (final b in ExplorerButton.defaultOrder) if (b != ExplorerButton.trash) b.name]; c.settings.explorer.buttons = ['trash', for (final b in ExplorerButton.defaultOrder(android: false)) if (b != ExplorerButton.trash) b.name];
addTearDown(() { addTearDown(() {
}); });
await open(tester); await open(tester);

View File

@ -390,9 +390,9 @@ void main() {
expect(c.settings.explorer.showHidden, !before); expect(c.settings.explorer.showHidden, !before);
// 창 배치 버튼은 좁아도 늘 줄에 (사용자 요청) · 처음 값은 자주 쓰는 것부터 (복사 · 이동 · 삭제 …) // 창 배치 버튼은 좁아도 늘 줄에 (사용자 요청) · 처음 값은 자주 쓰는 것부터 (복사 · 이동 · 삭제 …)
expect(find.byTooltip('창 배치: 좌우 (누르면 위아래)'), findsOneWidget); expect(find.byTooltip('창 배치: 좌우 (누르면 위아래)'), findsOneWidget);
expect(ExplorerButton.fromSettings(const []).take(9).map((b) => b.name), expect(ExplorerButton.fromSettings(const [], android: false).take(9).map((b) => b.name),
['copy', 'move', 'delete', 'newFolder', 'rename', 'select', 'search', 'bookmarks', 'orient']); ['copy', 'move', 'delete', 'newFolder', 'rename', 'select', 'search', 'bookmarks', 'orient']);
expect(ExplorerButton.fromSettings(const ['hidden', 'copy']), [ExplorerButton.hidden, ExplorerButton.copy], expect(ExplorerButton.fromSettings(const ['hidden', 'copy'], android: false), [ExplorerButton.hidden, ExplorerButton.copy],
reason: '사용자가 정한 순서는 그대로'); reason: '사용자가 정한 순서는 그대로');
// 넉넉한 화면에서는 [더 보기] 없이 모두 // 넉넉한 화면에서는 [더 보기] 없이 모두
tester.view.physicalSize = const Size(1600, 1400); tester.view.physicalSize = const Size(1600, 1400);

View File

@ -51,13 +51,11 @@ class _FolderSized extends ImageFilesSource {
void main() { void main() {
late List<bool> screenOn; late List<bool> screenOn;
setUp(() { setUp(() => screenOn = []);
screenOn = [];
ReaderPage.keepScreenOn = (on) async => screenOn.add(on);
});
AppController controller() => // 설계 M4 WakeLock: 화면 켜 두기는 PlatformServices.keepScreenOn 으로 넘긴다
AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); AppController controller() => AppController(PlatformServices(
mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), keepScreenOn: (on) async => screenOn.add(on)));
Future<_Sized> source(WidgetTester t, List<(int, int)> sizes) async { Future<_Sized> source(WidgetTester t, List<(int, int)> sizes) async {
final images = <ui.Image>[]; final images = <ui.Image>[];

View File

@ -13,6 +13,7 @@ import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/platform_services.dart'; import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/reader_page.dart'; import 'package:jj_mkvmaker/ui/reader_page.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:jj_mkvmaker/services/platform_info.dart';
Uint8List _png(int r, int g, int b, {int w = 40, int h = 60}) { Uint8List _png(int r, int g, int b, {int w = 40, int h = 60}) {
final im = img.Image(width: w, height: h); final im = img.Image(width: w, height: h);
@ -219,14 +220,13 @@ void main() {
tester.view.physicalSize = const Size(900, 1200); tester.view.physicalSize = const Size(900, 1200);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset); addTearDown(tester.view.reset);
final was = ReaderPage.volumeKeysSupported;
addTearDown(() => ReaderPage.volumeKeysSupported = was);
ReaderPage.volumeKeysSupported = () => true;
expect(AppSettings().reader.volumeKeys, 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,
]; ];
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); // 설계 M4: Android 인지는 PlatformInfo 주입 (예전 전역 함수 ReaderPage.volumeKeysSupported)
final c = AppController(
PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), platform: PlatformInfo.android));
await tester.pumpWidget(MaterialApp(home: ReaderPage(c: c, source: ImageFilesSource(files, tempDir: tmp.path, title: 'imgs')))); await tester.pumpWidget(MaterialApp(home: ReaderPage(c: c, source: ImageFilesSource(files, tempDir: tmp.path, title: 'imgs'))));
await tester.pump(); await tester.pump();
// 끔: 볼륨 키를 가로채지 않는다 (시스템 볼륨이 바뀜) // 끔: 볼륨 키를 가로채지 않는다 (시스템 볼륨이 바뀜)
@ -244,8 +244,12 @@ void main() {
await tester.sendKeyEvent(LogicalKeyboardKey.audioVolumeUp); await tester.sendKeyEvent(LogicalKeyboardKey.audioVolumeUp);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('2 / 3'), findsOneWidget); expect(find.text('2 / 3'), findsOneWidget);
// Android 가 아니면 쓰지 않음 // Android 가 아니면 쓰지 않음 (같은 설정 · Windows 조립)
ReaderPage.volumeKeysSupported = () => false; final win = AppController(
PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), platform: PlatformInfo.windows));
win.settings.reader.volumeKeys = true;
await tester.pumpWidget(MaterialApp(home: ReaderPage(c: win, source: ImageFilesSource(files, tempDir: tmp.path, title: 'imgs'))));
await tester.pump();
expect(await tester.sendKeyEvent(LogicalKeyboardKey.audioVolumeDown), isFalse); expect(await tester.sendKeyEvent(LogicalKeyboardKey.audioVolumeDown), isFalse);
await tester.pumpWidget(const SizedBox()); await tester.pumpWidget(const SizedBox());
}); });