From d537d1c3e16c7e7b0fbcf54f5db8f179b4ddecc8 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:56:37 +0900 Subject: [PATCH] =?UTF-8?q?=EC=84=A4=EC=A0=95=20B:=20settings=5Fpage=20(1,?= =?UTF-8?q?697)=20=EB=A5=BC=20=EB=B6=84=EC=95=BC=20=EC=84=B9=EC=85=98=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=EB=A1=9C=20(=E2=86=92=20282)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/ui/settings/settings_tiles.dart: 작은 제목 · 글 넣는 줄 · 폴더 줄 · CountSelector - general_sections (일반 · 화면 · 앱 아이콘) · media_sections (MKV · 자막 · 재생 · 이동 버튼 · 외부 플레이어 · 오른쪽 클릭 메뉴) - browser_section · files_section (원격 썸네일 캐시 · 읽기 전용 보기 경고) · transfer_sections (Rsync · 다운로드) - app_sections (실행 · 종료 (단축키) · 저장 공간 정리 · 프로그램 정보) - 페이지는 틀 · 잠금 · 분야 막대 · 묶음 카드만, CountSelector 는 그대로 내보냄 - 약속 시험: test/settings_sections_contract_test.dart - 캡처: integration_test/settings_sections_shot_test.dart (ko · en · ja × 412 · 960, 전체) - 나누기 전 판과 픽셀까지 같음 (2회) Co-Authored-By: Claude Opus 5.5 --- .../settings_sections_shot_test.dart | 45 + lib/ui/settings/app_sections.dart | 236 +++ lib/ui/settings/browser_section.dart | 148 ++ lib/ui/settings/files_section.dart | 422 +++++ lib/ui/settings/general_sections.dart | 234 +++ lib/ui/settings/media_sections.dart | 423 +++++ lib/ui/settings/settings_tiles.dart | 125 ++ lib/ui/settings/transfer_sections.dart | 239 +++ lib/ui/settings_page.dart | 1491 +---------------- test/settings_sections_contract_test.dart | 82 + 10 files changed, 1973 insertions(+), 1472 deletions(-) create mode 100644 integration_test/settings_sections_shot_test.dart create mode 100644 lib/ui/settings/app_sections.dart create mode 100644 lib/ui/settings/browser_section.dart create mode 100644 lib/ui/settings/files_section.dart create mode 100644 lib/ui/settings/general_sections.dart create mode 100644 lib/ui/settings/media_sections.dart create mode 100644 lib/ui/settings/settings_tiles.dart create mode 100644 lib/ui/settings/transfer_sections.dart create mode 100644 test/settings_sections_contract_test.dart diff --git a/integration_test/settings_sections_shot_test.dart b/integration_test/settings_sections_shot_test.dart new file mode 100644 index 0000000..c132020 --- /dev/null +++ b/integration_test/settings_sections_shot_test.dart @@ -0,0 +1,45 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/i18n_controller.dart' show i18n; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/settings_page.dart'; +import 'package:jj_mkvmaker/ui/theme.dart'; + +/// 설정 B: 환경 설정 화면 전체 (모든 분야) 를 실제 글꼴로 3개 언어 × 412 · 960 폭으로 그려 PNG 로 (JJ_SHOT_DIR). +/// 나누기 전후 같은지 픽셀로 견준다. 넘침 (RenderFlex overflow) 이 있으면 시험이 실패한다. 설정은 메모리에만 (저장소 없음) +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('환경 설정 전체: ko · en · ja × 412 · 960', (tester) async { + final out = Platform.environment['JJ_SHOT_DIR'] ?? Directory.systemTemp.path; + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + final key = GlobalKey(); + for (final lang in ['ko', 'en', 'ja']) { + await tester.runAsync(() => i18n.apply(lang, save: false)); + for (final w in [412.0, 960.0]) { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = Size(w, 900); + await tester.pumpWidget(MaterialApp(theme: buildTheme(), home: RepaintBoundary(key: key, child: SettingsPage(c: c)))); + await tester.pumpAndSettle(); + // 모든 분야가 한 장에 들어오게 화면을 늘린다 + final scroll = tester.state(find.byType(Scrollable).last).position; + tester.view.physicalSize = Size(w, (900 + scroll.maxScrollExtent).clamp(900, 30000).toDouble()); + await tester.pumpAndSettle(); + final boundary = key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final image = await tester.runAsync(() => boundary.toImage()); + final bytes = await tester.runAsync(() => image!.toByteData(format: ui.ImageByteFormat.png)); + File('$out/settings_${lang}_${w.toInt()}.png').writeAsBytesSync(bytes!.buffer.asUint8List()); + } + } + await tester.runAsync(() => i18n.apply('ko', save: false)); + tester.view.reset(); + }); +} diff --git a/lib/ui/settings/app_sections.dart b/lib/ui/settings/app_sections.dart new file mode 100644 index 0000000..554f2bf --- /dev/null +++ b/lib/ui/settings/app_sections.dart @@ -0,0 +1,236 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import '../../app/app_controller.dart'; +import '../../services/app_shell.dart'; +import '../setup_dialog.dart'; +import '../update_dialog.dart'; +import '../../platform/windows/desktop_shell.dart' show DesktopShell; +import '../../platform/windows/start_menu.dart'; +import '../app_actions.dart'; +import '../cleanup_dialog.dart'; +import '../theme.dart'; +import '../../l10n/tr.dart'; +import '../setting_tile.dart'; +import '../license_texts.dart'; + +// 약속 (설정 B): 환경 설정의 분야 - 실행 · 종료 · 저장 공간 정리 · 프로그램 정보. 바꾸면 바로 설정에 저장 (c.updateSettings) + +/// 환경 설정 > 실행 · 종료 +class RunSettingsSection extends StatefulWidget { + const RunSettingsSection({super.key, required this.c, required this.desk}); + + final AppController c; + + /// PC (Android 가 아님) - 창 · 트레이 · 탐색기 · 다운로드 · 내장 Chrome 설정 + final bool desk; + + @override + State createState() => _RunSettingsSectionState(); +} + +class _RunSettingsSectionState extends State { + AppController get c => widget.c; + bool get desk => widget.desk; + + late final _hotkey = TextEditingController(text: c.settings.showHotkey); + String? _hotkeyError; + + @override + void dispose() { + _hotkey.dispose(); + super.dispose(); + } + + Future _applyHotkey() async { + final text = _hotkey.text.trim(); + if (parseHotkey(text) == null) { + setState(() => _hotkeyError = tr('예: Ctrl+Shift+X (Ctrl·Shift·Alt·Win + 영문자·숫자·F1~F12)')); + return; + } + final ok = await c.services.shell.setHotkey(text); + setState(() => _hotkeyError = ok ? null : tr('이 단축키를 등록할 수 없습니다 (다른 프로그램이 사용 중일 수 있음)')); + if (ok) await c.updateSettings((s) => s.showHotkey = text); + } + + @override + Widget build(BuildContext context) { + final s = c.settings; + return Column(crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ + if (desk) + SwitchListTile( + value: s.startMenuShortcut, + onChanged: (v) async { + await c.updateSettings((x) => x.startMenuShortcut = v); + if (v) { + final ok = await StartMenu.ensure(); + final sh = c.services.shell; + if (sh is DesktopShell) sh.toastAppReady = ok; + } else { + await StartMenu.remove(); + final sh = c.services.shell; + if (sh is DesktopShell) sh.toastAppReady = false; + } + }, + title: Text(tr('시작 메뉴에 등록')), + subtitle: Text(StartMenu.userAreaLocked() + ? tr('시험용 데이터 폴더 (JJ_MKVMAKER_DATA) 로 켜져 시작 메뉴 바로 가기 · jjmkvmaker:// 연결은 건드리지 않습니다') + : tr('알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)')), + ), + SettingTile( + title: Text(tr('종료 (창 닫기 ✕ · 종료 버튼) 를 누르면')), + subtitle: Text(trf('백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {0} 또는 트레이 아이콘으로 다시 엽니다.\n' '완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)', [s.showHotkey])), + isThreeLine: true, + trailing: DropdownButton( + value: s.closeAction, + items: [ + DropdownMenuItem(value: 'background', child: Text(tr('백그라운드로 계속 (기본)'))), + DropdownMenuItem(value: 'quit', child: Text(tr('다운로드 · 변환도 모두 종료 (묻지 않음)'))), + DropdownMenuItem(value: 'ask', child: Text(tr('매번 고르기'))), + ], + onChanged: (v) => c.updateSettings((x) => x.closeAction = v!), + ), + ), + SwitchListTile( + value: s.minimizeToTray, + onChanged: (v) => c.updateSettings((x) => x.minimizeToTray = v), + title: Text(tr('최소화하면 트레이로 (백그라운드 실행)')), + subtitle: Text(tr('트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다')), + ), + SettingTile( + title: Text(tr('창 보이기 / 숨기기 단축키')), + subtitle: _hotkeyError == null + ? Text(tr('어디서나 누르면 JJ_MKVMaker 창이 나타납니다')) + : Text(_hotkeyError!, style: TextStyle(color: JjColors.danger)), + trailing: SizedBox( + width: 260, + child: Row(children: [ + Expanded( + child: TextField( + controller: _hotkey, + decoration: const InputDecoration(isDense: true, border: OutlineInputBorder()), + onSubmitted: (_) => _applyHotkey(), + ), + ), + const SizedBox(width: 8), + OutlinedButton(onPressed: _applyHotkey, child: Text(tr('적용'))), + ]), + ), + ), + ]); + } +} + +/// 환경 설정 > 저장 공간 정리 +class CleanupSettingsSection extends StatefulWidget { + const CleanupSettingsSection({super.key, required this.c, required this.desk}); + + final AppController c; + + /// PC (Android 가 아님) - 창 · 트레이 · 탐색기 · 다운로드 · 내장 Chrome 설정 + final bool desk; + + @override + State createState() => _CleanupSettingsSectionState(); +} + +class _CleanupSettingsSectionState extends State { + AppController get c => widget.c; + bool get desk => widget.desk; + + @override + Widget build(BuildContext context) { + final s = c.settings; + return Column(crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ + SettingTile( + title: Text(tr('정리 창에서 미리 체크할 것')), + subtitle: Wrap(spacing: 6, runSpacing: 4, children: [ + for (final (id, label) in [ + ('work', tr('작업 임시 파일')), + ('download', tr('받다 만 다운로드')), + ('models', tr('받다 만 AI 모델')), + ('update', tr('업데이트 · 설치하고 남은 파일')), + ('logs', tr('지난 작업 기록')), + ]) + FilterChip( + label: Text(label), + selected: s.cleanupPrechecked.contains(id), + onSelected: (on) => c.updateSettings((x) => x.cleanupPrechecked = [ + for (final y in x.cleanupPrechecked) if (y != id) y, + if (on) id, + ]), + ), + ]), + ), + SettingTile( + title: Text(tr('임시 파일 · 남은 조각 정리')), + subtitle: Text(tr('작업하다 남은 임시 파일 · 받다 만 다운로드 (.part 등) · 받다 만 AI 모델 · ' + '업데이트하고 남은 파일 · 지난 작업 기록을 찾아 지웁니다. ' + '받은 동영상 · 만든 MKV · 자막은 지우지 않고, 진행 중인 작업 · 다운로드가 쓰는 것은 건너뜁니다')), + isThreeLine: true, + trailing: FilledButton.icon( + onPressed: () => showCleanup(context, c, AppScope.maybeOf(context)?.downloads), + icon: const Icon(Icons.cleaning_services_outlined, size: 18), + label: Text(tr('정리…')), + ), + ), + ]); + } +} + +/// 환경 설정 > 프로그램 정보 +class AboutSettingsSection extends StatefulWidget { + const AboutSettingsSection({super.key, required this.c, required this.desk}); + + final AppController c; + + /// PC (Android 가 아님) - 창 · 트레이 · 탐색기 · 다운로드 · 내장 Chrome 설정 + final bool desk; + + @override + State createState() => _AboutSettingsSectionState(); +} + +class _AboutSettingsSectionState extends State { + AppController get c => widget.c; + bool get desk => widget.desk; + + @override + Widget build(BuildContext context) { + return Column(crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ + // 164: 지금 버전과 [최신 버전 확인] (결과는 버튼 옆에) + SettingTile( + leading: Icon(Icons.info_outline, color: JjColors.accent), + title: FutureBuilder( + future: c.services.updater?.currentVersion() ?? Future.value(''), + builder: (_, v) => Text(v.data == null || v.data!.isEmpty ? 'JJ_MKVMaker' : 'JJ_MKVMaker v${v.data}'), + ), + trailing: c.services.updater == null ? null : UpdateCheckButton(c: c), + ), + if (desk) ...[ + SettingTile( + title: Text(tr('필수 프로그램 점검')), + subtitle: Text(tr('FFmpeg · yt-dlp · aria2 · Deno 가 없으면 내려받아 설치합니다')), + trailing: OutlinedButton( + onPressed: () => checkRequiredTools(context, c.services.shell, quietIfOk: false), + child: Text(tr('점검')), + ), + ), + ], + SettingTile( + title: Text(tr('오픈 소스 라이선스')), + subtitle: Text(tr('포함된 구성 요소와 라이선스 (GPL · LGPL 원문 포함)')), + // 64: Android 에서도 (앱 안에서 연다) + trailing: Wrap(spacing: 6, runSpacing: 6, children: [ + OutlinedButton( + onPressed: () => showNoticesDocument(context), + child: Text(tr('고지 문서')), + ), + OutlinedButton( + onPressed: () => showLicensePage(context: context, applicationName: 'JJ_MKVMaker'), + child: Text(tr('패키지 라이선스')), + ), + ]), + ), + ]); + } +} diff --git a/lib/ui/settings/browser_section.dart b/lib/ui/settings/browser_section.dart new file mode 100644 index 0000000..638409c --- /dev/null +++ b/lib/ui/settings/browser_section.dart @@ -0,0 +1,148 @@ +import 'package:flutter/material.dart'; +import '../../app/app_controller.dart'; +import '../../app/i18n_controller.dart' show I18nController; +import '../../platform/windows/cef_runtime.dart'; +import '../../platform/windows/elevation.dart'; +import '../cef_setup.dart'; +import '../theme.dart'; +import '../../l10n/tr.dart'; +import '../setting_tile.dart'; +import 'settings_tiles.dart'; + +// 약속 (설정 B): 환경 설정의 분야 - 웹 브라우저. 바꾸면 바로 설정에 저장 (c.updateSettings) + +/// 환경 설정 > 웹 브라우저 +class BrowserSettingsSection extends StatefulWidget { + const BrowserSettingsSection({super.key, required this.c, required this.desk}); + + final AppController c; + + /// PC (Android 가 아님) - 창 · 트레이 · 탐색기 · 다운로드 · 내장 Chrome 설정 + final bool desk; + + @override + State createState() => _BrowserSettingsSectionState(); +} + +class _BrowserSettingsSectionState extends State { + AppController get c => widget.c; + bool get desk => widget.desk; + + @override + Widget build(BuildContext context) { + final s = c.settings; + return Column(crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ + settingsTextTile(tr('홈 주소'), s.homeUrl, tr('웹 브라우저의 시작 · 🏠 주소'), + (v) => c.updateSettings((x) => x.homeUrl = v.trim().isEmpty ? 'https://www.youtube.com/' : v.trim())), + SwitchListTile( + value: s.youtubeAdSkip, + onChanged: (v) => c.updateSettings((x) => x.youtubeAdSkip = v), + title: Text(tr('YouTube 광고 자동 건너뛰기')), + subtitle: Text(tr('앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 ' '(넘기는 동안 화면 위쪽에 "광고 건너뛰는 중…")')), + ), + SwitchListTile( + value: s.youtubeAdHide, + onChanged: (v) => c.updateSettings((x) => x.youtubeAdHide = v), + title: Text(tr('YouTube 광고 배너 숨기기')), + subtitle: Text(tr('목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다')), + ), + SwitchListTile( + value: s.webPauseOnLeave, + onChanged: (v) => c.updateSettings((x) => x.webPauseOnLeave = v), + title: Text(tr('다른 화면으로 가면 페이지 소리 멈춤')), + subtitle: Text(tr('끄면 환경 설정 · 다운로드 목록 · 다른 화면을 보는 동안에도 웹 페이지의 소리가 계속 들립니다')), + ), + SwitchListTile( + value: s.webTranslate, + onChanged: (v) => c.updateSettings((x) => x.webTranslate = v), + title: Text(tr('웹 페이지 자동 번역')), + subtitle: Text(trf( + '다른 언어로 된 페이지를 화면 언어 ({0}) 로 번역합니다. 페이지의 글을 Google 번역으로 보냅니다. ' + '주소창 옆 번역 버튼으로 원문 · 번역을 바꿀 수 있습니다.', + [I18nController.nativeName(s.uiLanguage)])), + ), + SwitchListTile( + value: s.webJavaScript, + onChanged: (v) => c.updateSettings((x) => x.webJavaScript = v), + title: Text(tr('JavaScript 사용')), + subtitle: Text(desk + ? tr('끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). ' + 'Chrome 엔진은 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.') + : tr('끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). ' + '끄면 페이지 번역 · 동영상 찾기도 멈춥니다.')), + ), + if (desk) ...[ + SettingTile( + title: Text(tr('브라우저 엔진 (앱 안)')), + subtitle: Text( + trf('Edge: Windows 에 들어 있어 따로 설치하지 않습니다. 여기서 YouTube 에 로그인하면 다운로드에도 그 로그인이 쓰입니다.\n' 'Chrome: 고르면 약 {0}MB 를 내려받고 다시 시작한 뒤 쓸 수 있습니다 ' '(Google 로그인은 막힐 수 있음).' '{1}' '{2}', [CefRuntime.approxDownloadMb, CefRuntime.installed ? trf(' 지금 설치됨 ({0}MB)', [CefRuntime.installedMb()]) : '', s.browserEngine == 'chrome' && !CefRuntime.readyThisRun ? tr(' · 다시 시작해야 Chrome 으로 바뀝니다') : ''])), + isThreeLine: true, + trailing: Row(mainAxisSize: MainAxisSize.min, children: [ + if (s.browserEngine == 'edge' && CefRuntime.installed && !CefRuntime.readyThisRun) + IconButton( + tooltip: tr('내려받은 Chrome 엔진 지우기'), + icon: Icon(Icons.delete_outline, color: JjColors.textDim), + onPressed: () async { + final ok = await CefRuntime.uninstall(); + c.note(ok ? tr('내장 Chrome 엔진을 지웠습니다.') : tr('내장 Chrome 엔진을 지우지 못했습니다 (사용 중).')); + setState(() {}); + }, + ), + DropdownButton( + value: s.browserEngine, + items: [ + DropdownMenuItem(value: 'edge', child: Text(tr('Edge (내장)'))), + DropdownMenuItem( + value: 'chrome', + child: Text(CefRuntime.installed ? tr('Chrome (내장)') : tr('Chrome (내려받기)'))), + ], + onChanged: (v) async { + if (v == 'chrome') { + await chooseChromeEngine(context, c); + if (mounted) setState(() {}); + } else { + await c.updateSettings((x) => x.browserEngine = 'edge'); + } + }, + ), + ]), + ), + // 207: 관리자 권한일 때도 Chrome 엔진 쓰기 (처음 꺼짐, 켤 때 경고) + SwitchListTile( + value: s.cefWhenElevated, + onChanged: (v) async { + if (v) { + await confirmCefWhenElevated(context, c); + } else { + await c.updateSettings((x) => x.cefWhenElevated = false); + } + if (mounted) setState(() {}); + }, + title: Text(tr('관리자 권한일 때도 Chrome 엔진 쓰기 (권장하지 않음)')), + subtitle: Text(Elevation.elevated + ? tr('지금 관리자 권한으로 실행 중입니다. 끄면 (기본) Chrome 엔진을 시작하지 않고 Edge 를 권합니다. 켜면 샌드박스 없이 관리자 권한으로 웹 페이지를 돌립니다.') + : tr('관리자 권한으로 실행할 때만 쓰입니다. 끄면 (기본) 그때 Chrome 엔진을 시작하지 않고 Edge 를 권합니다.')), + ), + SettingTile( + title: Text(tr('외부 브라우저')), + subtitle: Text(tr('"외부 브라우저로 열기" 에 쓸 브라우저 (이 PC 에 설치된 것)')), + trailing: DropdownButton( + value: s.externalBrowser, + items: [ + DropdownMenuItem(value: 'system', child: Text(tr('Windows 기본 브라우저'))), + for (final b in {...c.services.shell.installedBrowsers(), if (s.externalBrowser != 'system') s.externalBrowser}) + DropdownMenuItem(value: b, child: Text(switch (b) { + 'chrome' => 'Chrome', + 'firefox' => 'Firefox', + 'edge' => 'Edge', + 'whale' => 'Whale', + _ => b, + })), + ], + onChanged: (v) => c.updateSettings((x) => x.externalBrowser = v!), + ), + ), + ], + ]); + } +} diff --git a/lib/ui/settings/files_section.dart b/lib/ui/settings/files_section.dart new file mode 100644 index 0000000..a009993 --- /dev/null +++ b/lib/ui/settings/files_section.dart @@ -0,0 +1,422 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:flutter/material.dart'; +import '../../app/app_controller.dart'; +import '../../core/file_ops.dart' show formatSize; +import '../../app/settings.dart'; +import '../copy_sync_settings.dart'; +import '../sftp_settings.dart'; +import '../ftp_settings.dart'; +import '../smb_settings.dart'; +import '../webdav_settings.dart'; +import '../explorer_look.dart' show ExplorerStyle; +import '../explorer_keys_page.dart'; +import '../../core/shizuku.dart' show rootSupported, shizukuSupported; +import '../theme.dart'; +import '../../l10n/tr.dart'; +import '../setting_tile.dart'; +import '../component_settings.dart'; +import '../remote_thumb_view.dart' show remoteThumbsOf; +import '../hidden_items_dialog.dart'; +import '../../core/remote_idle.dart'; + +// 약속 (설정 B): 환경 설정의 분야 - 파일 탐색기. 바꾸면 바로 설정에 저장 (c.updateSettings) + +/// 환경 설정 > 파일 탐색기 +class FilesSettingsSection extends StatefulWidget { + const FilesSettingsSection({super.key, required this.c, required this.desk}); + + final AppController c; + + /// PC (Android 가 아님) - 창 · 트레이 · 탐색기 · 다운로드 · 내장 Chrome 설정 + final bool desk; + + @override + State createState() => _FilesSettingsSectionState(); +} + +class _FilesSettingsSectionState extends State { + AppController get c => widget.c; + bool get desk => widget.desk; + + @override + Widget build(BuildContext context) { + final s = c.settings; + return Column(crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ + if (desk) + SwitchListTile( + value: s.recycleOnDelete, + onChanged: (v) => c.updateSettings((x) => x.recycleOnDelete = v), + title: Text(tr('지우면 휴지통으로')), + subtitle: Text(tr('켜면 휴지통으로 보내 되살릴 수 있습니다 (Shift+Delete 는 영구 삭제). 끄면 늘 영구 삭제로 묻습니다. ' + '네트워크 드라이브 · USB 메모리는 휴지통이 없어 늘 영구 삭제입니다')), + ), + SettingTile( + title: Text(tr('누르기')), + subtitle: Text(tr('길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). ' + 'Ctrl + 클릭: 여러 개 고르기')), + trailing: DropdownButton( + value: s.explorerClick, + items: [ + DropdownMenuItem( + value: 'select', child: Text(desk ? tr('한 번: 선택 · 두 번: 실행 (기본)') : tr('한 번: 선택 · 두 번: 실행'))), + DropdownMenuItem(value: 'open', child: Text(desk ? tr('한 번: 바로 실행') : tr('한 번: 바로 실행 (기본)'))), + ], + onChanged: (v) => c.updateSettings((x) => x.explorerClick = v!), + ), + ), + // 82: 창 사이 끌어 놓기 (처음: 묻기) + SettingTile( + title: Text(tr('끌어 놓기 기본')), + subtitle: Text(tr('다른 창으로 끌어 놓았을 때. 어느 쪽이든 복사 · 이동 확인 창 (같은 이름 등) 은 그대로 뜹니다')), + trailing: DropdownButton( + value: s.explorerDropAction, + items: [ + DropdownMenuItem(value: 'ask', child: Text(tr('묻기 (기본)'))), + DropdownMenuItem(value: 'copy', child: Text(tr('복사'))), + DropdownMenuItem(value: 'move', child: Text(tr('이동'))), + ], + onChanged: (v) => c.updateSettings((x) => x.explorerDropAction = v ?? 'ask'), + ), + ), + // 84: 정렬을 폴더마다 기억 (처음: 끔) + SettingTile( + title: Text(tr('정렬을 폴더마다 기억')), + subtitle: Text(tr('켜면 열 머리 · 정렬 기준에서 바꾼 정렬이 그 폴더에만 쓰입니다. 끄면 모든 폴더가 같은 정렬입니다')), + trailing: Switch( + value: s.explorerSortPerFolder, + onChanged: (v) => c.updateSettings((x) => x.explorerSortPerFolder = v), + ), + ), + // X-plore 비교 5-2 (사용자 결정 10/21): 시스템 폴더 보기 (Shizuku, 읽기 전용) - Android, 켤 때마다 경고 + if (shizukuSupported()) + SettingTile( + title: Text(tr('시스템 폴더 보기 (Shizuku · 읽기 전용)')), + subtitle: Text(tr('Shizuku 앱이 있으면 Android/data · obb 처럼 막힌 폴더를 읽기만 합니다. 탐색기 위 탭 [시스템 (읽기 전용)]')), + trailing: Switch( + value: s.shizukuEnabled, + onChanged: (v) async { + if (v && + !await confirmReadOnlyView(context, + icon: Icons.admin_panel_settings_outlined, + title: tr('시스템 폴더 보기를 켤까요?'), + body: tr('Shizuku (adb 권한) 로 다른 앱의 폴더 (Android/data · obb) 를 볼 수 있게 됩니다. 그 안에는 다른 앱의 ' + '대화 · 로그인 정보 같은 개인 데이터가 있을 수 있습니다.'))) { + return; + } + await c.updateSettings((x) => x.shizukuEnabled = v); + }, + ), + ), + // X-plore 비교 5-2 (관리자 결정 10/21): 루트 폴더 보기 (su, 읽기 전용) - Shizuku 와 같은 틀 + if (rootSupported()) + SettingTile( + title: Text(tr('루트 폴더 보기 (su · 읽기 전용)')), + subtitle: Text(tr('루팅한 기기에서 su 로 시스템 · 다른 앱의 폴더를 읽기만 합니다. 탐색기 위 탭 [루트 (읽기 전용)]')), + trailing: Switch( + value: s.rootEnabled, + onChanged: (v) async { + if (v && + !await confirmReadOnlyView(context, + icon: Icons.security, + title: tr('루트 폴더 보기를 켤까요?'), + body: tr('루트 (su) 권한으로 시스템 · 다른 앱의 폴더 전체 (/data 등) 를 볼 수 있게 됩니다. 다른 앱의 개인 자료 ' + '(대화 · 로그인 정보 · 비밀번호 등) 도 보일 수 있습니다.'))) { + return; + } + await c.updateSettings((x) => x.rootEnabled = v); + }, + ), + ), + // X-plore 비교 5-1 ① (관리자 지시 10/21): 종류별 기본 정렬 (처음: 끔) + SettingTile( + title: Text(tr('종류별 기본 정렬')), + subtitle: Text(tr('사진 · 동영상이 많은 폴더는 날짜 (새것 먼저), 음악이 많은 폴더는 이름으로. 그 폴더에서 정렬을 바꾸면 그 폴더는 바꾼 정렬을 기억합니다')), + trailing: Switch( + value: s.explorerSortByKind, + onChanged: (v) => c.updateSettings((x) => x.explorerSortByKind = v), + ), + ), + // X-plore 비교 5-1 ② (관리자 지시 10/21): 원격 썸네일 · 캐시 + SettingTile( + title: Text(tr('원격 썸네일')), + subtitle: Text(tr('WebDAV · SFTP · SMB · FTP 의 그림 · 동영상을 X-plore 모양 목록에서 작은 그림으로. 보이는 줄만, 동시에 2개까지 받습니다')), + trailing: DropdownButton( + value: s.explorerRemoteThumbs, + items: [ + DropdownMenuItem(value: 'off', child: Text(tr('받지 않음'))), + DropdownMenuItem(value: 'wifi', child: Text(tr('Wi-Fi 에서만 (기본)'))), + DropdownMenuItem(value: 'always', child: Text(tr('늘 (모바일 데이터에서도)'))), + ], + onChanged: (v) => c.updateSettings((x) => x.explorerRemoteThumbs = v!), + ), + ), + SettingTile( + title: Text(tr('원격 썸네일 캐시')), + subtitle: Text(tr('넘으면 오래 보지 않은 것부터 지웁니다')), + trailing: DropdownButton( + value: s.explorerRemoteThumbCacheMb, + items: [ + for (final v in {...AppSettings.remoteThumbCacheChoices, s.explorerRemoteThumbCacheMb}.toList()..sort()) + DropdownMenuItem(value: v, child: Text(v == 200 ? trf('{0}MB (기본)', [v]) : '${v}MB')), + ], + onChanged: (v) => c.updateSettings((x) => x.explorerRemoteThumbCacheMb = v!), + ), + ), + RemoteThumbCacheTile(c: c), + // 80: 폴더 크기 (필요할 때 계산하고 이번 실행 동안 기억) + SettingTile( + title: Text(tr('폴더 크기')), + subtitle: Text(tr('폴더 안 파일 크기의 합을 목록에 보입니다. 큰 폴더 · NAS 는 읽는 데 오래 걸릴 수 있습니다. ' + '길게 누르기 [폴더 크기 계산] 은 늘 됩니다')), + trailing: DropdownButton( + value: s.explorerFolderSize, + items: [ + DropdownMenuItem(value: 'sort', child: Text(tr('크기순 정렬일 때만 (기본)'))), + DropdownMenuItem(value: 'always', child: Text(tr('늘 계산'))), + DropdownMenuItem(value: 'off', child: Text(tr('계산 안 함'))), + ], + onChanged: (v) => c.updateSettings((x) => x.explorerFolderSize = v ?? 'sort'), + ), + ), + // X-plore 비교 5-1 ⑥ (관리자 지시 10/21): 단축키 바꾸기 (키보드가 있는 PC 에서만 보임) + if (desk) + ListTile( + title: Text(tr('파일 탐색기 단축키')), + subtitle: Text(s.explorerKeyOverrides.isEmpty + ? tr('스타일대로 (바꾼 것 없음)') + : trf('바꾼 것 {0}개', [s.explorerKeyOverrides.length])), + trailing: const Icon(Icons.chevron_right), + onTap: () => ExplorerKeysPage.open(context, c), + ), + SettingTile( + title: Text(tr('스타일')), + // 83: 스타일마다 단축키 - PC 에서만 (194: 키보드가 없는 폰 · 태블릿에는 쓸모없는 긴 글, 15 지적) + subtitle: Text([ + tr('아이콘 · 목록 모양: X-plore (썸네일 · 두 줄) · Windows 탐색기 (컬러 아이콘 · 열) · ' + 'Total Commander (촘촘한 목록 · [폴더])'), + if (desk) + tr('단축키 - Total Commander: F5 복사 · F6 이동 · F7 새 폴더 · F8 지우기 · Insert/Space 표시 · Ctrl+R 새로 고침. ' + '그 밖: Ctrl+C · X · V (복사 · 잘라내기 · 붙여넣기) · Ctrl+A 모두 표시 · F5 새로 고침'), + ].join('\n')), + trailing: DropdownButton( + value: s.explorerStyle, + items: [ + for (final st in ExplorerStyle.values) + DropdownMenuItem(value: st.name, child: Text(tr(st.label))), + ], + onChanged: (v) => c.updateSettings((x) => x.explorerStyle = v!), + ), + ), + SettingTile( + title: Text(tr('창 배치')), + subtitle: Text(tr('파일 탐색기 위쪽 ⋮ 메뉴에서도 바꿀 수 있습니다 (좌우 · 위아래 · 버튼 줄 위치 · 버튼 구성)')), + trailing: DropdownButton( + value: s.explorerLayout, + items: [ + DropdownMenuItem(value: 'auto', child: Text(tr('화면 크기 따라'))), + DropdownMenuItem(value: 'dual', child: Text(tr('두 창 (기본)'))), + DropdownMenuItem(value: 'split', child: Text(tr('폴더 + 파일 목록'))), + DropdownMenuItem(value: 'single', child: Text(tr('한 창'))), + ], + onChanged: (v) => c.updateSettings((x) => x.explorerLayout = v!), + ), + ), + // X-plore 비교 e: Android 앱 휴지통 보관 일 + if (Platform.isAndroid) + SettingTile( + title: Text(tr('휴지통 보관')), + subtitle: Text(tr('지운 것을 저장 장치의 앱 휴지통에 두었다가 이 날이 지나면 지웁니다. 끄면 바로 영구 삭제')), + trailing: DropdownButton( + value: const [0, 3, 7, 14, 30, 60].contains(s.androidTrashDays) ? s.androidTrashDays : 14, + items: [ + for (final d in const [0, 3, 7, 14, 30, 60]) + DropdownMenuItem(value: d, child: Text(d == 0 ? tr('끔') : trf('{0}일', [d]))), + ], + onChanged: (v) => c.updateSettings((x) => x.androidTrashDays = v!), + ), + ), + // X-plore 비교 ⚖ (관리자 결정): X-plore 스타일의 표시 동그라미 · 밀기 · 폴더 표시 규칙 + SwitchListTile( + title: Text(tr('X-plore 스타일: 선택 표시 늘 보이기')), + subtitle: Text(tr('끄면 (처음 값) [선택] 을 켰을 때만 보입니다. 다른 스타일은 늘 선택 모드에서만')), + value: s.explorerAlwaysMarks, + onChanged: (v) => c.updateSettings((x) => x.explorerAlwaysMarks = v), + ), + SwitchListTile( + title: Text(tr('X-plore 스타일: 옆으로 밀면 창 바꾸기')), + subtitle: Text(tr('탐색기 안에서 밀면 다른 창으로. 다른 화면으로 가려면 위 막대에서 밉니다')), + value: s.explorerSwipePanes, + onChanged: (v) => c.updateSettings((x) => x.explorerSwipePanes = v), + ), + // X-plore 비교 c: 저장 장치 · 서버 · 즐겨찾기를 트리 맨 위에 (X-plore 스타일) + SwitchListTile( + title: Text(tr('저장 장치 · 서버 · 즐겨찾기를 트리 맨 위에')), + subtitle: Text(tr('X-plore 스타일에서 X-plore 처럼 한 트리에 보입니다 (위 줄 탭 · ★ 는 그대로)')), + value: s.explorerRootsInTree, + onChanged: (v) => c.updateSettings((x) => x.explorerRootsInTree = v), + ), + // X-plore 비교 d: 지금 폴더를 다시 누르면 접기 + SwitchListTile( + title: Text(tr('펼친 폴더를 다시 누르면 접기')), + subtitle: Text(tr('처음 값 켬. 끄면 접기 · 펼치기는 › 화살표로만 (두 번 눌러도 목록이 움직이지 않음)')), + value: s.explorerRetapCollapse, + onChanged: (v) => c.updateSettings((x) => x.explorerRetapCollapse = v), + ), + // X-plore 비교 40 (관리자 지시 10/20): 목록만의 글자 크기 · 줄 간격 (세 스타일 모두, 앱 화면 크기 · 기기 글자 크기와 곱함) + SettingTile( + title: Text(tr('목록 글자 크기')), + subtitle: Text(tr('파일 탐색기 목록의 이름 · 크기 글자만. 앱 화면 크기 · 기기 글자 크기와 곱해집니다')), + trailing: DropdownButton( + value: s.explorerTextScale, + items: [ + for (var v = AppSettings.explorerTextScaleMin; v <= AppSettings.explorerTextScaleMax; v += 10) + DropdownMenuItem(value: v, child: Text(v == 100 ? trf('{0}% (기본)', [v]) : '$v%')), + if (s.explorerTextScale % 10 != 0) + DropdownMenuItem(value: s.explorerTextScale, child: Text('${s.explorerTextScale}%')), + ], + onChanged: (v) => c.updateSettings((x) => x.explorerTextScale = v!), + ), + ), + SettingTile( + title: Text(tr('줄 간격')), + subtitle: Text(tr('파일 탐색기 목록의 줄 높이. 좁게는 손가락으로 누르기 어려울 수 있습니다')), + trailing: DropdownButton( + value: s.explorerRowSpacing, + items: [ + DropdownMenuItem(value: 'compact', child: Text(tr('좁게'))), + DropdownMenuItem(value: 'normal', child: Text(tr('보통 (기본)'))), + DropdownMenuItem(value: 'wide', child: Text(tr('넓게'))), + ], + onChanged: (v) => c.updateSettings((x) => x.explorerRowSpacing = v!), + ), + ), + // X-plore 숨기기: 목록에서 숨긴 것 보기 · 풀기 (관리자 결정 10/18) + ListTile( + title: Text(trf('목록에서 숨긴 항목 ({0}개)', [s.explorerHiddenItems.length])), + subtitle: Text(tr('파일 탐색기의 길게 누르기 [목록에서 숨기기] 로 숨긴 것. 파일은 그대로이고 목록에서만 빠집니다')), + trailing: const Icon(Icons.chevron_right), + enabled: s.explorerHiddenItems.isNotEmpty, + onTap: () => showHiddenItems(context, c), + ), + // X-plore 맞추기 (관리자 지시 10/20): 숨기기 전 확인 창 + SwitchListTile( + title: Text(tr('목록에서 숨기기 전에 묻기')), + subtitle: Text(tr('무엇이 바뀌는지 (목록에서만 빠지고 파일은 그대로) 확인 창을 띄웁니다')), + value: s.explorerHideConfirm, + onChanged: (v) => c.updateSettings((x) => x.explorerHideConfirm = v), + ), + // X-plore 비교 a: 한 창일 때 복사 · 이동이 갈 곳 + SettingTile( + title: Text(tr('한 창일 때 복사 · 이동')), + subtitle: Text(tr('숨은 다른 창으로: X-plore 처럼 위에 "갈 곳" 을 늘 보이고 그 폴더로 바로 복사 · 이동합니다')), + trailing: DropdownButton( + value: s.explorerOnePaneCopy, + items: [ + DropdownMenuItem(value: 'clip', child: Text(tr('담아 두고 붙여넣기 (기본)'))), + DropdownMenuItem(value: 'other', child: Text(tr('숨은 다른 창으로'))), + ], + onChanged: (v) => c.updateSettings((x) => x.explorerOnePaneCopy = v!), + ), + ), + // 복사 · 이동 방법 (현재 방식 · robocopy) · 속도 제한 + CopySyncSettings(c: c), + // WebDAV 서버 (탐색기 · Rsync 화면 위쪽 탭) + WebDavSettings(c: c), + // 88: SFTP 서버 (WebDAV 옆 탭) + SftpSettings(c: c), + // l: FTP · FTPS 서버 + FtpSettings(c: c), + // 88: SMB (Android 만 - Windows 는 운영체제가 \\서버\공유 를 연다) + if (Platform.isAndroid) SmbSettings(c: c), + // 관리자 판단 (10/20): 원격 서버가 멈췄을 때 기다리는 시간 + SettingTile( + leading: const Icon(Icons.hourglass_bottom_outlined), + title: Text(tr('원격 서버 응답 기다리기')), + subtitle: Text(tr('WebDAV · SFTP · FTP · SMB 에서 받거나 올리는 중에 이 시간 동안 아무것도 오가지 않으면 실패로 알립니다 (서버가 멈춰도 계속 기다리지 않게)')), + trailing: DropdownButton( + value: RemoteIdle.choices.contains(s.remoteIdleSeconds) ? s.remoteIdleSeconds : RemoteIdle.defaultSeconds, + items: [ + for (final n in RemoteIdle.choices) + DropdownMenuItem(value: n, child: Text(n == RemoteIdle.defaultSeconds ? trf('{0}초 (기본)', [n]) : trf('{0}초', [n]))), + ], + onChanged: (v) => c.updateSettings((x) => x.remoteIdleSeconds = v!), + ), + ), + ViewerSettings(c: c), + ]); + } +} + +/// X-plore 비교 5-1 ② (관리자 지시 10/21): 원격 썸네일 캐시의 크기 · [캐시 지우기] (다시 보면 다시 받음) +class RemoteThumbCacheTile extends StatefulWidget { + final AppController c; + const RemoteThumbCacheTile({super.key, required this.c}); + + @override + State createState() => _RemoteThumbCacheTileState(); +} + +class _RemoteThumbCacheTileState extends State { + int? _bytes; + + @override + void initState() { + super.initState(); + unawaited(_measure()); + } + + Future _measure() async { + final n = await (await remoteThumbsOf(widget.c)).size(); + if (mounted) setState(() => _bytes = n); + } + + @override + Widget build(BuildContext context) => ListTile( + title: Text(tr('원격 썸네일 캐시 지우기')), + subtitle: Text(_bytes == null ? tr('크기를 재는 중 …') : trf('지금 {0} · 다시 보면 다시 받습니다', [formatSize(_bytes!)])), + trailing: TextButton( + onPressed: _bytes == null || _bytes == 0 + ? null + : () async { + await (await remoteThumbsOf(widget.c)).clear(); + await _measure(); + }, + child: Text(tr('캐시 지우기')), + ), + ); +} + +/// X-plore 비교 5-2: 시스템 폴더 · 루트 보기를 켤 때 경고 (체크해야 [켜기]). 켜면 true +Future confirmReadOnlyView(BuildContext context, {required IconData icon, required String title, required String body}) async { + final ok = await showDialog( + context: context, + builder: (ctx) { + var sure = false; + return StatefulBuilder( + builder: (ctx, set) => AlertDialog( + scrollable: true, + icon: Icon(icon, color: JjColors.warning), + title: Text(title), + content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(body), + const SizedBox(height: 6), + Text(tr('이 앱은 읽기만 합니다 - 쓰기 · 지우기 · 이름 바꾸기 · 권한 바꾸기는 하지 않습니다. 꺼낸 (복사한) 파일은 고른 곳에만 둡니다.')), + CheckboxListTile( + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + value: sure, + onChanged: (x) => set(() => sure = x ?? false), + title: Text(tr('위 내용을 알고 켭니다')), + ), + ]), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), + FilledButton(onPressed: sure ? () => Navigator.pop(ctx, true) : null, child: Text(tr('켜기'))), + ], + ), + ); + }, + ); + return ok == true; +} diff --git a/lib/ui/settings/general_sections.dart b/lib/ui/settings/general_sections.dart new file mode 100644 index 0000000..5fc47f6 --- /dev/null +++ b/lib/ui/settings/general_sections.dart @@ -0,0 +1,234 @@ +import 'package:flutter/material.dart'; +import '../../app/app_controller.dart'; +import '../../services/app_shell.dart'; +import '../update_dialog.dart'; +import '../../app/settings.dart'; +import '../../platform/android/android_shell.dart' show applyScreenOrientation; +import '../language_settings.dart'; +import '../theme.dart'; +import '../../l10n/tr.dart'; +import '../setting_tile.dart'; + +// 약속 (설정 B): 환경 설정의 분야 - 일반 (언어 · 알림 · 앱 아이콘) · 화면. 바꾸면 바로 설정에 저장 (c.updateSettings) + +/// 환경 설정 > 일반 +class GeneralSettingsSection extends StatefulWidget { + const GeneralSettingsSection({super.key, required this.c, required this.desk}); + + final AppController c; + + /// PC (Android 가 아님) - 창 · 트레이 · 탐색기 · 다운로드 · 내장 Chrome 설정 + final bool desk; + + @override + State createState() => _GeneralSettingsSectionState(); +} + +class _GeneralSettingsSectionState extends State { + AppController get c => widget.c; + bool get desk => widget.desk; + + @override + Widget build(BuildContext context) { + final s = c.settings; + return Column(crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ + // 화면 언어 (읽을 수 없는 언어를 골라도 바로 찾아 되돌릴 수 있게 맨 위) + const LanguageSettings(), + // 102: 물어 정한 것 · 1차 판단은 모두 여기서 바꿀 수 있다 (기본값 = 그 답) + SwitchListTile( + value: s.migrationNotice, + onChanged: (v) => c.updateSettings((x) => x.migrationNotice = v), + title: Text(tr('업데이트로 바뀐 기본값 알림')), + subtitle: Text(tr('새 버전이 예전 기본값을 바꿨으면 켤 때 한 번 무엇이 바뀌었는지 알려 줍니다')), + ), + SwitchListTile( + value: s.rememberPasswords, + onChanged: (v) => c.updateSettings((x) => x.rememberPasswords = v), + title: Text(tr('비밀번호 기억 (안전 저장소)')), + subtitle: Text(tr('WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다')), + ), + AppIconTile(c: c, desk: desk), + if (c.services.updater != null) ...[ + SettingTile( + leading: Icon(Icons.system_update_alt, color: JjColors.accent), + title: FutureBuilder( + future: c.services.updater!.currentVersion(), + builder: (_, v) => Text('JJ_MKVMaker v${v.data ?? '…'}'), + ), + subtitle: Text(tr('GitHub 에 올려 둔 모든 버전 중에서 골라 설치합니다 (새 버전으로 · 예전 정상 버전으로 되돌리기). ' + '바꾸기 전 버전의 설정은 보관해 두었다가, 그 버전으로 돌아오면 되살립니다')), + isThreeLine: true, + trailing: Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ + UpdateCheckButton(c: c), + FilledButton.icon( + onPressed: () => chooseVersion(context, c), + icon: const Icon(Icons.system_update_alt, size: 18), + label: Text(tr('업데이트 · 버전 고르기')), + ), + ]), + ), + SwitchListTile( + value: s.autoCheckUpdates, + onChanged: (v) => c.updateSettings((x) => x.autoCheckUpdates = v), + title: Text(tr('시작할 때 새 버전 확인 (하루 한 번)')), + ), + ], + SettingTile( + title: Text(tr('홈 화면')), + subtitle: Text(tr('프로그램을 켰을 때, 그리고 위쪽 왼쪽 JJ 아이콘을 눌렀을 때 보일 화면')), + trailing: DropdownButton( + value: s.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') + DropdownMenuItem(value: 'tor', child: Text(tr('Tor 브라우저'))), + ], + onChanged: (v) => c.updateSettings((x) => x.startScreen = v!), + ), + ), + ]); + } +} + +/// 환경 설정 > 화면 +class DisplaySettingsSection extends StatefulWidget { + const DisplaySettingsSection({super.key, required this.c, required this.desk}); + + final AppController c; + + /// PC (Android 가 아님) - 창 · 트레이 · 탐색기 · 다운로드 · 내장 Chrome 설정 + final bool desk; + + @override + State createState() => _DisplaySettingsSectionState(); +} + +class _DisplaySettingsSectionState extends State { + AppController get c => widget.c; + bool get desk => widget.desk; + + @override + Widget build(BuildContext context) { + final s = c.settings; + return Column(crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ + if (!desk) + SettingTile( + title: Text(tr('화면 방향')), + subtitle: Text(tr('가로 고정 · 세로 고정, 또는 자동 (기기를 돌리는 대로, 기기의 자동 회전 설정을 따름)')), + trailing: DropdownButton( + value: s.screenOrientation, + 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!); + applyScreenOrientation(v!); + }, + ), + ), + SettingTile( + title: Text(tr('기본 화면 크기')), + subtitle: Text(trf('글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 ' '(지금 {0}%)', [(s.uiScale * 100).round()])), + trailing: DropdownButton( + value: s.uiScaleDefault, + items: [ + for (var v = AppSettings.uiScaleMin; v <= AppSettings.uiScaleMax + 0.001; v += 0.1) + DropdownMenuItem( + value: AppSettings.clampUiScale(v), + child: Text('${(v * 100).round()}%${(v - 1).abs() < 0.001 ? tr(' (보통)') : ''}')), + // 5% 단위로 저장된 값이 목록에 없을 때 + if (((s.uiScaleDefault * 100).round() % 10) != 0) + DropdownMenuItem( + value: s.uiScaleDefault, child: Text('${(s.uiScaleDefault * 100).round()}%')), + ], + // 기본 크기를 바꾸면 지금 크기도 그 크기로 + onChanged: (v) => c.updateSettings((x) => x + ..uiScaleDefault = v! + ..uiScale = v), + ), + ), + // X-plore 비교 5-1 ⑦ (관리자 지시 10/21): 화면 밝기 - 처음 값 어둡게 (예전 그대로), 다 끝날 때까지 "시험" + SettingTile( + title: Row(mainAxisSize: MainAxisSize.min, children: [ + Flexible(child: Text(tr('화면 밝기'))), + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration(border: Border.all(color: JjColors.warning), borderRadius: BorderRadius.circular(4)), + child: Text(tr('시험'), style: TextStyle(fontSize: 11, color: JjColors.warning)), + ), + ]), + subtitle: Text(tr('밝은 화면 · 기기 따라는 아직 시험 중입니다 - 일부 화면의 색이 덜 맞을 수 있습니다')), + trailing: DropdownButton( + value: s.uiBrightness, + 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!), + ), + ), + ]); + } +} + +/// 앱 아이콘 고르기: 그림을 누르면 바로 바뀐다 +class AppIconTile extends StatelessWidget { + const AppIconTile({super.key, required this.c, required this.desk}); + + final AppController c; + final bool desk; + + @override + Widget build(BuildContext context) { + final now = appIconOf(c.settings.appIcon); + return SettingTile( + title: Text(tr('앱 아이콘')), + subtitle: Padding( + padding: const EdgeInsets.only(top: 4), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(desk + ? tr('창 · 작업 표시줄 · 트레이와 앱 안 왼쪽 위 버튼의 아이콘입니다 (실행 파일 · 바탕 화면 바로 가기 아이콘은 그대로)') + : tr('앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다')), + const SizedBox(height: 8), + Wrap(spacing: 10, runSpacing: 10, children: [ + for (final id in appIconIds) + Tooltip( + message: id == appIconIds.first ? '${_name(id)} (${tr('기본')})' : _name(id), + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: id == now + ? null + : () { + c.updateSettings((x) => x.appIcon = id); + c.services.shell.setAppIcon(id); + }, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all(color: id == now ? JjColors.accent : Colors.transparent, width: 3), + ), + child: Image.asset(appIconPreviewAsset(id), width: 56, height: 56, filterQuality: FilterQuality.medium), + ), + ), + ), + ]), + ]), + ), + ); + } + + static String _name(String id) => switch (id) { + 'yellow' => tr('노랑'), + 'black' => tr('검정'), + 'film_jj' => tr('필름 + JJ'), + 'film' => tr('필름'), + _ => tr('처음 아이콘 (파랑)'), + }; +} diff --git a/lib/ui/settings/media_sections.dart b/lib/ui/settings/media_sections.dart new file mode 100644 index 0000000..912652a --- /dev/null +++ b/lib/ui/settings/media_sections.dart @@ -0,0 +1,423 @@ +import 'dart:async'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:path/path.dart' as p; +import '../../app/app_controller.dart'; +import '../../core/playlist.dart'; +import '../../core/subtitle_detector.dart'; +import '../../app/settings.dart'; +import '../folder_picker.dart'; +import '../theme.dart'; +import '../../l10n/tr.dart'; +import '../setting_tile.dart'; +import '../ai_subtitle_models.dart'; +import '../subtitle_language_picker.dart'; +import 'settings_tiles.dart'; + +// 약속 (설정 B): 환경 설정의 분야 - MKV 만들기 · 자막 · 재생. 바꾸면 바로 설정에 저장 (c.updateSettings) + +/// 환경 설정 > MKV 만들기 +class MkvSettingsSection extends StatefulWidget { + const MkvSettingsSection({super.key, required this.c, required this.desk}); + + final AppController c; + + /// PC (Android 가 아님) - 창 · 트레이 · 탐색기 · 다운로드 · 내장 Chrome 설정 + final bool desk; + + @override + State createState() => _MkvSettingsSectionState(); +} + +class _MkvSettingsSectionState extends State { + AppController get c => widget.c; + bool get desk => widget.desk; + + @override + Widget build(BuildContext context) { + final s = c.settings; + return Column(crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ + // 61: 화면 언어와 다른 언어의 자막을 기본으로 원할 수 있다 (예: 화면은 일본어, 자막은 한국어) + SettingTile( + title: Text(tr('기본 자막 언어')), + subtitle: Text(tr('MKV 의 기본 자막 트랙 · 플레이어가 먼저 켜는 자막')), + trailing: SubtitleLanguagePicker( + value: s.preferredSubtitleLanguage, + onChanged: (v) => c.updateSettings((x) => x.preferredSubtitleLanguage = v), + ), + ), + SettingTile( + title: Text(tr('동영상을 세 번 누르면')), + subtitle: Text(tr('한 번: 보기 · 두 번: 재생 · 세 번: 이동 폴더로 옮기기 (옮기기 전에 묻습니다)')), + trailing: DropdownButton( + value: s.tripleTapAction, + items: [ + DropdownMenuItem(value: 'move', child: Text(tr('이동 폴더로 (확인 뒤, 기본)'))), + DropdownMenuItem(value: 'none', child: Text(tr('아무것도 안 함'))), + ], + onChanged: (v) => c.updateSettings((x) => x.tripleTapAction = v!), + ), + ), + // P0: 결과 MKV 가 이미 있을 때 (처음 값: 묻기) + SettingTile( + title: Text(tr('만들 MKV 가 이미 있으면')), + subtitle: Text(tr('덮어쓰기여도 다 만든 뒤에만 바꿉니다 (도중에 취소 · 실패하면 예전 MKV 그대로)')), + trailing: DropdownButton( + value: const ['ask', 'overwrite', 'skip', 'rename'].contains(s.mkvExisting) ? s.mkvExisting : 'ask', + items: [ + DropdownMenuItem(value: 'ask', child: Text(tr('묻기'))), + DropdownMenuItem(value: 'overwrite', child: Text(tr('덮어쓰기'))), + DropdownMenuItem(value: 'skip', child: Text(tr('건너뛰기'))), + DropdownMenuItem(value: 'rename', child: Text(tr('다른 이름 (이름 (2).mkv)'))), + ], + onChanged: (v) => c.updateSettings((x) => x.mkvExisting = v ?? 'ask'), + ), + ), + // 15 지적 · 관리자 판단 (10/17): 폰 세로에서 목록 · 자세히 칸 나누기 + SettingTile( + title: Text(tr('폰 세로 목록 크기')), + subtitle: Text(tr('좁은 화면에서 동영상 목록이 차지하는 높이 (자동: 목록은 줄 수만큼, 자세히 칸 자리는 남김)')), + trailing: DropdownButton( + value: s.mkvCompactList, + items: [ + DropdownMenuItem(value: 'auto', child: Text(tr('자동 (기본)'))), + DropdownMenuItem(value: 'small', child: Text(tr('작게'))), + DropdownMenuItem(value: 'large', child: Text(tr('크게'))), + ], + onChanged: (v) => c.updateSettings((x) => x.mkvCompactList = v ?? 'auto'), + ), + ), + SettingTile( + title: Text(tr('동시 MKV 변환 수')), + subtitle: Text(tr('여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)')), + trailing: CountSelector( + value: s.maxParallelJobs, + onChanged: (n) => c.updateSettings((x) => x.maxParallelJobs = n), + ), + ), + SettingTile( + title: Text(tr('마지막 인코딩 설정을 기억합니다')), + subtitle: Text(trf('{0} · {1}' '{2} (메인 화면 상단에서 변경)', [c.encode.resolution.label, c.encode.codec.label, c.encode.reencode ? ' · ${c.encode.quality.label}' : ''])), + ), + settingsFolderTile( + title: tr('MKV · 자막 (jj_mkv)'), + value: s.mkvOutputRoot, + defaultText: tr('동영상이 있는 폴더 아래 jj_mkv (기본)'), + onPick: () async { + final d = await pickFolder(context, tr('MKV · 자막 저장 위치'), s.mkvOutputRoot); + if (d != null) await c.updateSettings((x) => x.mkvOutputRoot = d); + }, + onReset: () => c.updateSettings((x) => x.mkvOutputRoot = null), + ), + settingsSubTitle(tr('이동 버튼 (세부 정보 오른쪽 아래, 위로 하나씩)')), + for (var i = 0; i < s.moveTargets.length; i++) MoveTargetTile(c: c, index: i, target: s.moveTargets[i]), + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), + child: Row(children: [ + OutlinedButton.icon( + onPressed: () async { + final d = await pickFolder(context, tr('이동 버튼의 폴더')); + if (d == null) return; + final name = p.basename(d).isEmpty ? tr('이동') : p.basename(d); + await c.updateSettings((x) => x.moveTargets = [...x.moveTargets, MoveTarget(name, d)]); + }, + icon: const Icon(Icons.add, size: 18), + label: Text(tr('이동 버튼 추가')), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + s.moveTargets.isEmpty + ? tr('없음: 세부 정보의 [이동] 을 처음 누를 때 폴더를 골라 하나 만듭니다') + : tr('표시 이름은 바로 고칠 수 있습니다. 동영상 줄을 세 번 누르면 첫 번째 버튼의 폴더로 옮깁니다'), + style: TextStyle(fontSize: 12, color: JjColors.textDim), + ), + ), + ]), + ), + ]); + } +} + +/// 환경 설정 > 자막 (AI · 인터넷) +class SubtitleSettingsSection extends StatefulWidget { + const SubtitleSettingsSection({super.key, required this.c, required this.desk}); + + final AppController c; + + /// PC (Android 가 아님) - 창 · 트레이 · 탐색기 · 다운로드 · 내장 Chrome 설정 + final bool desk; + + @override + State createState() => _SubtitleSettingsSectionState(); +} + +class _SubtitleSettingsSectionState extends State { + AppController get c => widget.c; + bool get desk => widget.desk; + + @override + Widget build(BuildContext context) { + final s = c.settings; + return Column(crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ + SwitchListTile( + value: s.askAiOptions, + onChanged: (v) => c.updateSettings((x) => x.askAiOptions = v), + title: Text(tr('시작할 때마다 설정 창 보기')), + subtitle: Text(trf('끄면 마지막 설정으로 바로 시작: 원어 {0}, ' '언어 {1}, {2}', [c.aiOptions.source.name, c.aiOptions.targets.map((t) => t.code).join('/'), c.aiOptions.whisper.label])), + ), + // 260: 받은 자막 모델 크기 · 지우기 + settingsSubTitle(tr('받은 AI 자막 모델')), + AiSubtitleModels(c: c), + settingsSubTitle(tr('인터넷 자막 (OpenSubtitles.com)')), + settingsTextTile(tr('API 키 (필수, 무료)'), s.openSubtitlesKey, + tr('가입 → 프로필 → API consumers → New consumer'), + (v) => c.updateSettings((x) => x.openSubtitlesKey = v.trim())), + settingsTextTile(tr('아이디 (선택)'), s.openSubtitlesUser, tr('로그인하면 하루 받기 횟수가 늘어납니다'), + (v) => c.updateSettings((x) => x.openSubtitlesUser = v.trim())), + settingsTextTile(tr('비밀번호 (선택)'), s.openSubtitlesPassword, trf('이 {0}의 안전 저장소에 저장됩니다 (설정 파일에는 쓰지 않음)', [desk ? 'PC' : tr('기기')]), + (v) => c.updateSettings((x) => x.openSubtitlesPassword = v), obscure: true), + ]); + } +} + +/// 환경 설정 > 재생 +class PlaySettingsSection extends StatefulWidget { + const PlaySettingsSection({super.key, required this.c, required this.desk}); + + final AppController c; + + /// PC (Android 가 아님) - 창 · 트레이 · 탐색기 · 다운로드 · 내장 Chrome 설정 + final bool desk; + + @override + State createState() => _PlaySettingsSectionState(); +} + +class _PlaySettingsSectionState extends State { + AppController get c => widget.c; + bool get desk => widget.desk; + + @override + Widget build(BuildContext context) { + final s = c.settings; + return Column(crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ + SettingTile( + title: Text(tr('로그인이 필요한 WebDAV 동영상을 다른 앱으로 열 때')), + subtitle: Text(tr('다른 앱에는 아이디 · 비밀번호를 넘기지 않습니다. 받아서 열기는 어느 앱에서나 열리고, 주소로 열기는 아이디를 묻는 앱 (VLC 등) 만 열립니다')), + trailing: DropdownButton( + value: s.davExternalOpen, + items: [ + DropdownMenuItem(value: 'ask', child: Text(tr('매번 묻기 (기본)'))), + DropdownMenuItem(value: 'fetch', child: Text(tr('늘 받아서 열기'))), + DropdownMenuItem(value: 'url', child: Text(tr('늘 주소로 열기'))), + ], + onChanged: (v) => c.updateSettings((x) => x.davExternalOpen = v!), + ), + ), + SettingTile( + title: Text(tr('동영상 하나를 재생할 때')), + subtitle: Text(tr('시리즈: file_001 · file_002, S01E01 · S01E02 처럼 번호만 다른 파일')), + trailing: DropdownButton( + value: s.playlistMode, + items: [ + for (final m in PlaylistMode.values) DropdownMenuItem(value: m, child: Text(m.label)), + ], + onChanged: (m) => c.updateSettings((x) => x.playlistMode = m!), + ), + ), + SettingTile( + title: Text(desk ? tr('탐색기에서 동영상을 열 때') : tr('다른 앱에서 동영상을 열 때')), + subtitle: Text(desk + ? tr('더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)') + : tr('파일 앱의 "다음으로 열기" · 공유에서 JJ_MKVMaker 를 골랐을 때')), + trailing: DropdownButton( + value: s.openFileAction, + items: [ + DropdownMenuItem(value: 'play', child: Text(tr('바로 재생 (기본)'))), + DropdownMenuItem(value: 'add', child: Text(tr('편집 목록에 추가 (MKV 만들기 대상)'))), + ], + onChanged: (v) => c.updateSettings((x) => x.openFileAction = v!), + ), + ), + if (desk) ...[ + SettingTile( + title: Text(tr('탐색기에서 연 동영상을 재생할 창')), + subtitle: Text(tr('프로그램이 이미 켜져 있을 때. 새 창은 재생만 하는 창이며 닫으면 그 창만 끝납니다')), + trailing: DropdownButton( + value: s.openFileWindow, + items: [ + DropdownMenuItem(value: 'same', child: Text(tr('켜져 있는 창에서 (기본)'))), + DropdownMenuItem(value: 'new', child: Text(tr('새 창에서'))), + ], + onChanged: (v) => c.updateSettings((x) => x.openFileWindow = v!), + ), + ), + ExternalPlayersTile(c: c), + ContextMenuTile(c: c), + ], + ]); + } +} + +/// 이동 버튼 하나: 표시 이름 (바로 고침) · 폴더 · 순서 올리기 · 폴더 바꾸기 · 지우기 +class MoveTargetTile extends StatelessWidget { + const MoveTargetTile({super.key, required this.c, required this.index, required this.target}); + + final AppController c; + final int index; + final MoveTarget target; + + @override + Widget build(BuildContext context) { + final i = index, t = target; + final s = c.settings; + void update(List Function(List) f) => + c.updateSettings((x) => x.moveTargets = f([...x.moveTargets])); + return SettingTile( + key: ValueKey('move_${i}_${t.dir}'), + leading: Icon(Icons.drive_file_move_outline, color: JjColors.accent), + title: TextFormField( + initialValue: t.name, + decoration: InputDecoration(isDense: true, labelText: tr('표시 이름')), + onChanged: (v) => update((l) => l..[i] = t.copyWith(name: v.trim().isEmpty ? tr('이동') : v.trim())), + ), + subtitle: Padding( + padding: const EdgeInsets.only(top: 4), + child: Text(t.dir, maxLines: 2, overflow: TextOverflow.ellipsis), + ), + trailing: Row(mainAxisSize: MainAxisSize.min, children: [ + IconButton( + tooltip: tr('위로'), + icon: const Icon(Icons.arrow_upward, size: 18), + onPressed: i == 0 ? null : () => update((l) => l..insert(i - 1, l.removeAt(i))), + ), + OutlinedButton( + onPressed: () async { + final d = await pickFolder(context, tr('이동 버튼의 폴더'), t.dir); + if (d != null) update((l) => l..[i] = t.copyWith(dir: d)); + }, + child: Text(tr('폴더 선택')), + ), + IconButton( + tooltip: tr('이 이동 버튼 지우기'), + icon: const Icon(Icons.delete_outline), + onPressed: s.moveTargets.length > i ? () => update((l) => l..removeAt(i)) : null, + ), + ]), + ); + } +} + +/// 확장자별 재생 프로그램 (내장 / 기본 프로그램 / VLC / 직접 지정) +class ExternalPlayersTile extends StatelessWidget { + final AppController c; + const ExternalPlayersTile({super.key, required this.c}); + + @override + Widget build(BuildContext context) { + final map = c.settings.externalPlayers; + final vlc = c.services.shell.vlcPath; + Future set(String ext, String? v) => c.updateSettings((x) { + if (v == null || v.isEmpty) { + x.externalPlayers.remove(ext); + } else { + x.externalPlayers[ext] = v; + } + }); + + return ExpansionTile( + title: Text(tr('확장자별 재생 프로그램')), + subtitle: Text(map.isEmpty + ? tr('모두 내장 플레이어') + : map.entries.map((e) => '${e.key}: ${e.value == 'system' ? tr('기본 프로그램') : e.value.split(RegExp(r'[\\/]')).last}').join(', ')), + children: [ + for (final ext in videoExtensions) + SettingTile( + dense: true, + title: Text('.$ext'), + trailing: DropdownButton( + value: map[ext] ?? '', + items: [ + DropdownMenuItem(value: '', child: Text(tr('내장 플레이어'))), + DropdownMenuItem(value: 'system', child: Text(tr('Windows 기본 프로그램'))), + if (vlc != null) DropdownMenuItem(value: vlc, child: const Text('VLC')), + if (map[ext] != null && map[ext] != 'system' && map[ext] != vlc) + DropdownMenuItem(value: map[ext], child: Text(map[ext]!.split(RegExp(r'[\\/]')).last)), + DropdownMenuItem(value: '*pick', child: Text(tr('프로그램 직접 선택…'))), + ], + onChanged: (v) async { + if (v == '*pick') { + final r = await FilePicker.pickFile( + dialogTitle: trf('.{0} 재생 프로그램 (exe)', [ext]), type: FileType.custom, allowedExtensions: const ['exe']); + if (r?.path != null) await set(ext, r!.path); + } else { + await set(ext, v); + } + }, + ), + ), + if (vlc == null) + Padding( + padding: EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Text(tr('VLC 가 설치되어 있지 않습니다. 설치하면 목록에 나타납니다 (videolan.org).'), + style: TextStyle(fontSize: 12, color: JjColors.textDim)), + ), + ], + ); + } +} + +/// 탐색기 오른쪽 클릭 메뉴 등록 / 해제 +class ContextMenuTile extends StatefulWidget { + final AppController c; + const ContextMenuTile({super.key, required this.c}); + + @override + State createState() => _ContextMenuTileState(); +} + +class _ContextMenuTileState extends State { + bool? _registered; + bool _busy = false; + + @override + void initState() { + super.initState(); + _refresh(); + } + + Future _refresh() async { + final r = await widget.c.services.shell.isContextMenuRegistered(); + if (mounted) setState(() => _registered = r); + } + + Future _toggle() async { + setState(() => _busy = true); + final shell = widget.c.services.shell; + if (_registered == true) { + await shell.unregisterContextMenu(); + } else { + final ok = await shell.registerContextMenu(); + if (!ok && mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(tr('등록 중 일부가 실패했습니다.')))); + } + } + await _refresh(); + if (mounted) setState(() => _busy = false); + } + + @override + Widget build(BuildContext context) => SettingTile( + title: Text(tr('탐색기 오른쪽 클릭 메뉴')), + subtitle: Text(_registered == true + ? tr('등록됨: "JJ_MKVMaker 로 재생" · "JJ_MKVMaker 로 자막 만들기" (Windows 11: 더 많은 옵션 표시)') + : tr('동영상 파일을 오른쪽 클릭해 바로 재생하거나 자막을 만들 수 있게 합니다 (현재 사용자만)')), + trailing: _registered == null + ? null + : OutlinedButton( + onPressed: _busy ? null : _toggle, + child: Text(_registered! ? tr('해제') : tr('등록')), + ), + ); +} diff --git a/lib/ui/settings/settings_tiles.dart b/lib/ui/settings/settings_tiles.dart new file mode 100644 index 0000000..f31c29e --- /dev/null +++ b/lib/ui/settings/settings_tiles.dart @@ -0,0 +1,125 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../theme.dart'; +import '../../l10n/tr.dart'; +import '../setting_tile.dart'; + +// 약속 (설정 B): 환경 설정 분야들이 같이 쓰는 줄 - 작은 제목 · 글 넣는 줄 · 폴더 고르는 줄 · 개수 고르기 (1 · 5 · 10 · 무한 · 직접) + +/// 묶음 안의 작은 제목 +Widget settingsSubTitle(String t) => Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Text(t, style: TextStyle(fontSize: 13, color: JjColors.accent, fontWeight: FontWeight.w600)), + ); + +/// 글 넣는 줄 - 칸을 벗어나거나 Enter · 바꿀 때마다 저장 +Widget settingsTextTile(String title, String value, String hint, ValueChanged onSave, + {bool obscure = false}) => + SettingTile( + title: Text(title), + subtitle: Text(hint), + trailing: SizedBox( + width: 320, + child: TextFormField( + initialValue: value, + obscureText: obscure, + decoration: const InputDecoration(isDense: true, border: OutlineInputBorder()), + // 입력칸을 벗어나거나 Enter 를 누르면 저장 + onFieldSubmitted: onSave, + onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), + onChanged: onSave, + ), + ), + ); + +/// 폴더 고르는 줄 - [폴더 선택] · 고른 것이 있으면 [기본값] +Widget settingsFolderTile({ + required String title, + required String? value, + required String defaultText, + required VoidCallback onPick, + required VoidCallback onReset, +}) => + SettingTile( + title: Text(title), + subtitle: Text(value ?? defaultText, maxLines: 2, overflow: TextOverflow.ellipsis), + trailing: Row(mainAxisSize: MainAxisSize.min, children: [ + OutlinedButton(onPressed: onPick, child: Text(tr('폴더 선택'))), + if (value != null) ...[ + const SizedBox(width: 6), + TextButton(onPressed: onReset, child: Text(tr('기본값'))), + ], + ]), + ); + +/// 동시 작업 수 고르기: 1 · 5 · 10 · 무한(0) · 직접 입력 +class CountSelector extends StatelessWidget { + final int value; + final ValueChanged onChanged; + const CountSelector({super.key, required this.value, required this.onChanged}); + + static const _presets = [1, 5, 10, 0]; + static String label(int n) => n <= 0 ? tr('무한') : trf('{0}개', [n]); + + Future _custom(BuildContext context) async { + // 목록 메뉴가 완전히 닫힌 뒤 입력 창을 연다 + await Future.delayed(Duration.zero); + if (!context.mounted) return; + final n = await showDialog( + context: context, + builder: (_) => _CountInputDialog(initial: value), + ); + if (n != null && n >= 0) onChanged(n); + } + + @override + Widget build(BuildContext context) { + final items = {..._presets, value}; + return DropdownButton( + value: value, + items: [ + for (final n in items) DropdownMenuItem(value: n, child: Text(label(n))), + DropdownMenuItem(value: -1, child: Text(tr('직접 입력…'))), + ], + onChanged: (n) => n == -1 ? _custom(context) : onChanged(n!), + ); + } +} + +/// 개수 직접 입력 창 (입력칸은 창이 닫히는 애니메이션이 끝난 뒤 정리) +class _CountInputDialog extends StatefulWidget { + final int initial; + const _CountInputDialog({required this.initial}); + + @override + State<_CountInputDialog> createState() => _CountInputDialogState(); +} + +class _CountInputDialogState extends State<_CountInputDialog> { + late final _ctrl = TextEditingController(text: widget.initial > 0 ? '${widget.initial}' : ''); + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => AlertDialog( + scrollable: true, + title: Text(tr('직접 입력')), + content: TextField( + controller: _ctrl, + autofocus: true, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: InputDecoration(labelText: tr('동시에 처리할 개수 (0 = 무한)'), border: OutlineInputBorder()), + onSubmitted: (t) => Navigator.pop(context, int.tryParse(t)), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: Text(tr('취소'))), + FilledButton(onPressed: () => Navigator.pop(context, int.tryParse(_ctrl.text)), child: Text(tr('확인'))), + ], + ); +} diff --git a/lib/ui/settings/transfer_sections.dart b/lib/ui/settings/transfer_sections.dart new file mode 100644 index 0000000..d365ada --- /dev/null +++ b/lib/ui/settings/transfer_sections.dart @@ -0,0 +1,239 @@ +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import '../../app/app_controller.dart'; +import '../../core/download_detect.dart'; +import '../folder_picker.dart'; +import '../copy_sync_settings.dart'; +import '../../l10n/tr.dart'; +import '../setting_tile.dart'; +import '../browser_page.dart' show deleteExportedCookies; +import '../toast_status.dart'; +import 'settings_tiles.dart'; + +// 약속 (설정 B): 환경 설정의 분야 - Rsync · 다운로드. 바꾸면 바로 설정에 저장 (c.updateSettings) + +/// 환경 설정 > Rsync +class RsyncSettingsSection extends StatefulWidget { + const RsyncSettingsSection({super.key, required this.c, required this.desk}); + + final AppController c; + + /// PC (Android 가 아님) - 창 · 트레이 · 탐색기 · 다운로드 · 내장 Chrome 설정 + final bool desk; + + @override + State createState() => _RsyncSettingsSectionState(); +} + +class _RsyncSettingsSectionState extends State { + AppController get c => widget.c; + bool get desk => widget.desk; + + @override + Widget build(BuildContext context) { + final s = c.settings; + return Column(crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ + CopySyncSettings(c: c, rsync: true), + SwitchListTile( + value: s.allowInnerToOuter, + onChanged: (v) => c.updateSettings((x) => x.allowInnerToOuter = v), + title: Text(tr('원본이 대상 안에 있어도 맞추기 (안쪽 → 바깥)')), + subtitle: Text(tr('확인 창에서 알린 뒤 허용합니다. 지우기 (--delete) 가 있으면 늘 막습니다. 끄면 늘 막습니다')), + ), + if (desk) + SwitchListTile( + value: s.syncStopToast, + onChanged: (v) => c.updateSettings((x) => x.syncStopToast = v), + title: Text(tr('동기화가 멈추면 Windows 알림')), + subtitle: Text(tr('창을 트레이에 둔 채 실시간 동기화가 원본을 못 읽어 멈추면 알림을 띄웁니다 (트레이 툴팁 경고는 늘)')), + ), + // Windows 에서 알림이 꺼져 있으면 알리고 설정을 열어 준다 (바꾸지는 않음) + if (desk && s.syncStopToast) const ToastStatusHint(), + ]); + } +} + +/// 환경 설정 > 다운로드 +class DownloadSettingsSection extends StatefulWidget { + const DownloadSettingsSection({super.key, required this.c, required this.desk}); + + final AppController c; + + /// PC (Android 가 아님) - 창 · 트레이 · 탐색기 · 다운로드 · 내장 Chrome 설정 + final bool desk; + + @override + State createState() => _DownloadSettingsSectionState(); +} + +class _DownloadSettingsSectionState extends State { + AppController get c => widget.c; + bool get desk => widget.desk; + + @override + Widget build(BuildContext context) { + final s = c.settings; + return Column(crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ + if (desk) + SwitchListTile( + value: s.clipboardWatch, + onChanged: (v) => c.updateSettings((x) => x.clipboardWatch = v), + title: Text(tr('복사(Ctrl+C)한 주소 자동 다운로드')), + subtitle: Text(tr('YouTube → yt-dlp, 마그넷 · .torrent → aria2 (완료 후 올려 주기 안 함)')), + ), + SwitchListTile( + value: s.addFinishedDownloads, + onChanged: (v) => c.updateSettings((x) => x.addFinishedDownloads = v), + title: Text(tr('완료 시 자동 동영상 추가')), + subtitle: Text(tr('다 받는 대로 MKV 만들기의 동영상 목록에 넣고, 다운로드 목록에서는 뺍니다 (다운로드 화면의 체크 상자와 같음)')), + ), + // 관리자 지시 (10/20): 시작되지 않는 마그넷 · 토렌트 알림 (처음 60초) + SettingTile( + title: Text(tr('마그넷 · 토렌트가 시작되지 않으면 알리기')), + subtitle: Text(tr('메타데이터나 피어를 이 시간 동안 못 받으면 그 줄에 이유 후보와 [다시 시도] 를 보입니다')), + trailing: DropdownButton( + value: s.torrentStallSeconds, + items: [ + for (final v in const [30, 60, 120, 300]) DropdownMenuItem(value: v, child: Text(trf('{0}초', [v]))), + DropdownMenuItem(value: 0, child: Text(tr('알리지 않음'))), + ], + onChanged: (v) => c.updateSettings((x) => x.torrentStallSeconds = v!), + ), + ), + SettingTile( + title: Text(tr('YouTube 받을 형식')), + subtitle: Text(tr('기본: MP4. 음성만 받으면 MP3 · M4A 로 저장')), + trailing: DropdownButton( + value: s.ytContainer, + items: [for (final v in YtContainer.values) DropdownMenuItem(value: v, child: Text(v.label))], + onChanged: (v) => c.updateSettings((x) => x.ytContainer = v!), + ), + ), + SettingTile( + enabled: !s.ytContainer.audioOnly, + title: Text(tr('YouTube 화질')), + subtitle: Text(tr('고른 크기 이하에서 가장 좋은 화질 (영상에 없는 크기면 가까운 것)')), + trailing: DropdownButton( + value: s.ytQuality, + items: [for (final v in YtQuality.values) DropdownMenuItem(value: v, child: Text(v.label))], + onChanged: s.ytContainer.audioOnly ? null : (v) => c.updateSettings((x) => x.ytQuality = v!), + ), + ), + // 36: 같은 크기면 H.264 먼저 (Android 기본 켜짐) + SwitchListTile( + value: s.ytPreferH264, + onChanged: s.ytContainer.audioOnly ? null : (v) => c.updateSettings((x) => x.ytPreferH264 = v), + title: Text(tr('같은 화질이면 H.264 먼저 받기')), + subtitle: Text(tr('휴대폰 · 태블릿은 AV1 · VP9 을 하드웨어로 풀지 못해 끊기거나 배터리를 많이 쓰는 일이 있습니다. ' + 'YouTube 의 H.264 는 1080p 까지라 더 큰 화질을 고르면 크기가 먼저입니다.')), + ), + SwitchListTile( + value: s.ytExpandPlaylists, + onChanged: (v) => c.updateSettings((x) => x.ytExpandPlaylists = v), + title: Text(tr('재생목록 주소면 목록 전체 받기')), + subtitle: Text(tr('영상마다 한 줄씩 보여 주고 jj_yt-dlp\\재생목록 이름\\ 에 저장 (믹스 목록 제외)')), + ), + SettingTile( + title: Text(tr('YouTube 쿠키 (로봇 확인이 나올 때)')), + subtitle: Text(s.ytCookiesFile.isNotEmpty + ? 'cookies.txt: ${s.ytCookiesFile}' + : s.ytCookiesBrowser == internalBrowserCookies + ? tr('앱 안 브라우저의 로그인 사용 (브라우저 화면에서 YouTube 에 로그인하세요)') + : s.ytCookiesBrowser.isNotEmpty + ? trf('{0} 브라우저의 YouTube 로그인 쿠키 사용 (Chrome · Edge 는 브라우저를 닫아야 읽힐 수 있음)', [s.ytCookiesBrowser]) + : tr('사용 안 함. "Sign in to confirm you\'re not a bot" 오류가 나오면 설정하세요')), + trailing: Row(mainAxisSize: MainAxisSize.min, children: [ + DropdownButton( + value: s.ytCookiesFile.isNotEmpty ? '*file' : s.ytCookiesBrowser, + items: [ + DropdownMenuItem(value: '', child: Text(tr('사용 안 함'))), + DropdownMenuItem(value: internalBrowserCookies, child: Text(tr('앱 안 브라우저 (권장)'))), + // PC 브라우저의 쿠키는 Windows 에서만 + if (desk) + for (final b in const ['firefox', 'chrome', 'edge', 'brave', 'whale', 'opera']) + DropdownMenuItem(value: b, child: Text(b[0].toUpperCase() + b.substring(1))), + DropdownMenuItem(value: '*file', child: Text(tr('cookies.txt 파일…'))), + ], + onChanged: (v) async { + if (v == '*file') { + final f = await FilePicker.pickFile( + dialogTitle: tr('cookies.txt (Netscape 형식)'), + type: FileType.custom, + allowedExtensions: const ['txt']); + if (f?.path != null) { + await c.updateSettings((x) => x + ..ytCookiesFile = f!.path! + ..ytCookiesBrowser = ''); + } + } else { + await c.updateSettings((x) => x + ..ytCookiesBrowser = v ?? '' + ..ytCookiesFile = ''); + } + // 131: 앱 안 브라우저 로그인을 더 쓰지 않으면 내보내 둔 평문 쿠키 파일을 지운다 + if (c.settings.ytCookiesBrowser != internalBrowserCookies) { + await deleteExportedCookies(c.settings.webViewDataDir); + if (mounted) setState(() {}); + } + }, + ), + ]), + ), + // 53: 앱 안 브라우저 쿠키 - 어디에 어떻게 저장되는지 · 넘길 사이트 고르기 · 지우기 + // 131: 쓰지 않게 바꿨어도 파일이 남아 있으면 지우는 버튼은 보인다 + if (s.ytCookiesBrowser == internalBrowserCookies || s.internalCookieFile.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text( + tr('로그인 쿠키는 다운로드 (yt-dlp) 가 쓰도록 이 기기의 앱 폴더에 cookies_youtube.txt 로 저장됩니다 (평문). ' + '이 파일을 가진 사람은 그 계정에 로그인할 수 있으니 기기를 남에게 줄 때는 지우세요. 고른 사이트의 쿠키만 넘깁니다:'), + // 관리자 지시 (10/21, 131 확인): 12px 흐린 회색은 읽기 힘들었다 - 위아래 설명 글 (ListTile 부제목) 과 같은 크기 · 색으로 + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant)), + const SizedBox(height: 6), + Wrap(spacing: 6, runSpacing: 6, children: [ + for (final d in loginCookieDomains) + FilterChip( + label: Text(d), + selected: s.loginCookieSites.contains(d), + onSelected: (on) => c.updateSettings((x) => x.loginCookieSites = [ + for (final y in x.loginCookieSites) if (y != d) y, + if (on) d, + ]), + ), + OutlinedButton.icon( + icon: const Icon(Icons.cookie_outlined, size: 18), + label: Text(tr('쿠키 파일 지우기')), + onPressed: () async { + await deleteExportedCookies(s.webViewDataDir); + if (!context.mounted) return; + setState(() {}); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(tr('내보낸 쿠키 파일을 지웠습니다. 브라우저의 로그인까지 지우려면 ' + '웹 브라우저 화면의 [쿠키 · 방문 기록 지우기] 를 누르세요.')))); + }, + ), + ]), + ]), + ), + SettingTile( + title: Text(tr('동시 다운로드 수')), + subtitle: Text(tr('나머지는 대기했다가 차례로 받습니다')), + trailing: CountSelector( + value: s.maxParallelDownloads, + onChanged: (n) => c.updateSettings((x) => x.maxParallelDownloads = n), + ), + ), + settingsFolderTile( + title: tr('다운로드 (jj_yt-dlp · jj_aria2)'), + value: s.downloadRoot, + defaultText: trf('{0} (기본): {1}', [desk ? tr('프로그램 폴더') : tr('내장 저장소'), s.resolvedDownloadRoot()]), + onPick: () async { + final d = await pickFolder(context, tr('다운로드 위치'), s.resolvedDownloadRoot()); + if (d != null) await c.updateSettings((x) => x.downloadRoot = d); + }, + onReset: () => c.updateSettings((x) => x.downloadRoot = null), + ), + ]); + } +} diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index c91bd89..7a0c873 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -1,279 +1,25 @@ +export 'settings/settings_tiles.dart' show CountSelector; import 'dart:async'; import 'dart:io'; -import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:path/path.dart' as p; import '../app/app_controller.dart'; -import '../app/i18n_controller.dart' show I18nController; -import '../core/download_detect.dart'; -import '../core/file_ops.dart' show formatSize; -import '../core/playlist.dart'; -import '../core/subtitle_detector.dart'; -import '../services/app_shell.dart'; -import 'setup_dialog.dart'; -import 'update_dialog.dart'; -import '../app/settings.dart'; -import '../platform/windows/cef_runtime.dart'; -import '../platform/windows/elevation.dart'; -import '../platform/windows/desktop_shell.dart' show DesktopShell; -import '../platform/windows/start_menu.dart'; -import '../platform/android/android_shell.dart' show applyScreenOrientation; -import 'folder_picker.dart'; -import 'language_settings.dart'; import 'app_actions.dart'; -import 'cef_setup.dart'; -import 'cleanup_dialog.dart'; -import 'copy_sync_settings.dart'; -import 'sftp_settings.dart'; -import 'ftp_settings.dart'; -import 'smb_settings.dart'; -import 'webdav_settings.dart'; -import 'explorer_look.dart' show ExplorerStyle; -import 'explorer_keys_page.dart'; -import '../core/shizuku.dart' show rootSupported, shizukuSupported; import 'theme.dart'; import '../l10n/tr.dart'; -import 'setting_tile.dart'; import 'component_settings.dart'; -import 'browser_page.dart' show deleteExportedCookies; import 'security_settings.dart'; -import 'remote_thumb_view.dart' show remoteThumbsOf; import 'network_settings.dart'; import 'ai_settings.dart'; -import 'ai_subtitle_models.dart'; import '../app/master_lock.dart'; -import 'toast_status.dart'; -import 'license_texts.dart'; -import 'subtitle_language_picker.dart'; -import 'hidden_items_dialog.dart'; -import '../core/remote_idle.dart'; - -/// 동시 작업 수 고르기: 1 · 5 · 10 · 무한(0) · 직접 입력 -class CountSelector extends StatelessWidget { - final int value; - final ValueChanged onChanged; - const CountSelector({super.key, required this.value, required this.onChanged}); - - static const _presets = [1, 5, 10, 0]; - static String label(int n) => n <= 0 ? tr('무한') : trf('{0}개', [n]); - - Future _custom(BuildContext context) async { - // 목록 메뉴가 완전히 닫힌 뒤 입력 창을 연다 - await Future.delayed(Duration.zero); - if (!context.mounted) return; - final n = await showDialog( - context: context, - builder: (_) => _CountInputDialog(initial: value), - ); - if (n != null && n >= 0) onChanged(n); - } - - @override - Widget build(BuildContext context) { - final items = {..._presets, value}; - return DropdownButton( - value: value, - items: [ - for (final n in items) DropdownMenuItem(value: n, child: Text(label(n))), - DropdownMenuItem(value: -1, child: Text(tr('직접 입력…'))), - ], - onChanged: (n) => n == -1 ? _custom(context) : onChanged(n!), - ); - } -} - -/// 개수 직접 입력 창 (입력칸은 창이 닫히는 애니메이션이 끝난 뒤 정리) -/// X-plore 비교 5-1 ② (관리자 지시 10/21): 원격 썸네일 캐시의 크기 · [캐시 지우기] (다시 보면 다시 받음) -class _RemoteThumbCacheTile extends StatefulWidget { - final AppController c; - const _RemoteThumbCacheTile({required this.c}); - - @override - State<_RemoteThumbCacheTile> createState() => _RemoteThumbCacheTileState(); -} - -class _RemoteThumbCacheTileState extends State<_RemoteThumbCacheTile> { - int? _bytes; - - @override - void initState() { - super.initState(); - unawaited(_measure()); - } - - Future _measure() async { - final n = await (await remoteThumbsOf(widget.c)).size(); - if (mounted) setState(() => _bytes = n); - } - - @override - Widget build(BuildContext context) => ListTile( - title: Text(tr('원격 썸네일 캐시 지우기')), - subtitle: Text(_bytes == null ? tr('크기를 재는 중 …') : trf('지금 {0} · 다시 보면 다시 받습니다', [formatSize(_bytes!)])), - trailing: TextButton( - onPressed: _bytes == null || _bytes == 0 - ? null - : () async { - await (await remoteThumbsOf(widget.c)).clear(); - await _measure(); - }, - child: Text(tr('캐시 지우기')), - ), - ); -} - -class _CountInputDialog extends StatefulWidget { - final int initial; - const _CountInputDialog({required this.initial}); - - @override - State<_CountInputDialog> createState() => _CountInputDialogState(); -} - -class _CountInputDialogState extends State<_CountInputDialog> { - late final _ctrl = TextEditingController(text: widget.initial > 0 ? '${widget.initial}' : ''); - - @override - void dispose() { - _ctrl.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) => AlertDialog( - scrollable: true, - title: Text(tr('직접 입력')), - content: TextField( - controller: _ctrl, - autofocus: true, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - decoration: InputDecoration(labelText: tr('동시에 처리할 개수 (0 = 무한)'), border: OutlineInputBorder()), - onSubmitted: (t) => Navigator.pop(context, int.tryParse(t)), - ), - actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: Text(tr('취소'))), - FilledButton(onPressed: () => Navigator.pop(context, int.tryParse(_ctrl.text)), child: Text(tr('확인'))), - ], - ); -} - -/// 확장자별 재생 프로그램 (내장 / 기본 프로그램 / VLC / 직접 지정) -class _ExternalPlayers extends StatelessWidget { - final AppController c; - const _ExternalPlayers({required this.c}); - - @override - Widget build(BuildContext context) { - final map = c.settings.externalPlayers; - final vlc = c.services.shell.vlcPath; - Future set(String ext, String? v) => c.updateSettings((x) { - if (v == null || v.isEmpty) { - x.externalPlayers.remove(ext); - } else { - x.externalPlayers[ext] = v; - } - }); - - return ExpansionTile( - title: Text(tr('확장자별 재생 프로그램')), - subtitle: Text(map.isEmpty - ? tr('모두 내장 플레이어') - : map.entries.map((e) => '${e.key}: ${e.value == 'system' ? tr('기본 프로그램') : e.value.split(RegExp(r'[\\/]')).last}').join(', ')), - children: [ - for (final ext in videoExtensions) - SettingTile( - dense: true, - title: Text('.$ext'), - trailing: DropdownButton( - value: map[ext] ?? '', - items: [ - DropdownMenuItem(value: '', child: Text(tr('내장 플레이어'))), - DropdownMenuItem(value: 'system', child: Text(tr('Windows 기본 프로그램'))), - if (vlc != null) DropdownMenuItem(value: vlc, child: const Text('VLC')), - if (map[ext] != null && map[ext] != 'system' && map[ext] != vlc) - DropdownMenuItem(value: map[ext], child: Text(map[ext]!.split(RegExp(r'[\\/]')).last)), - DropdownMenuItem(value: '*pick', child: Text(tr('프로그램 직접 선택…'))), - ], - onChanged: (v) async { - if (v == '*pick') { - final r = await FilePicker.pickFile( - dialogTitle: trf('.{0} 재생 프로그램 (exe)', [ext]), type: FileType.custom, allowedExtensions: const ['exe']); - if (r?.path != null) await set(ext, r!.path); - } else { - await set(ext, v); - } - }, - ), - ), - if (vlc == null) - Padding( - padding: EdgeInsets.fromLTRB(16, 0, 16, 8), - child: Text(tr('VLC 가 설치되어 있지 않습니다. 설치하면 목록에 나타납니다 (videolan.org).'), - style: TextStyle(fontSize: 12, color: JjColors.textDim)), - ), - ], - ); - } -} - -/// 탐색기 오른쪽 클릭 메뉴 등록 / 해제 -class _ContextMenuTile extends StatefulWidget { - final AppController c; - const _ContextMenuTile({required this.c}); - - @override - State<_ContextMenuTile> createState() => _ContextMenuTileState(); -} - -class _ContextMenuTileState extends State<_ContextMenuTile> { - bool? _registered; - bool _busy = false; - - @override - void initState() { - super.initState(); - _refresh(); - } - - Future _refresh() async { - final r = await widget.c.services.shell.isContextMenuRegistered(); - if (mounted) setState(() => _registered = r); - } - - Future _toggle() async { - setState(() => _busy = true); - final shell = widget.c.services.shell; - if (_registered == true) { - await shell.unregisterContextMenu(); - } else { - final ok = await shell.registerContextMenu(); - if (!ok && mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(tr('등록 중 일부가 실패했습니다.')))); - } - } - await _refresh(); - if (mounted) setState(() => _busy = false); - } - - @override - Widget build(BuildContext context) => SettingTile( - title: Text(tr('탐색기 오른쪽 클릭 메뉴')), - subtitle: Text(_registered == true - ? tr('등록됨: "JJ_MKVMaker 로 재생" · "JJ_MKVMaker 로 자막 만들기" (Windows 11: 더 많은 옵션 표시)') - : tr('동영상 파일을 오른쪽 클릭해 바로 재생하거나 자막을 만들 수 있게 합니다 (현재 사용자만)')), - trailing: _registered == null - ? null - : OutlinedButton( - onPressed: _busy ? null : _toggle, - child: Text(_registered! ? tr('해제') : tr('등록')), - ), - ); -} +import 'settings/general_sections.dart'; +import 'settings/media_sections.dart'; +import 'settings/browser_section.dart'; +import 'settings/files_section.dart'; +import 'settings/transfer_sections.dart'; +import 'settings/app_sections.dart'; /// 환경 설정 /// 213: 키보드가 열린 뒤 남는 높이가 이보다 작으면 설정 화면의 위 줄을 접는다 @@ -292,8 +38,6 @@ class SettingsPage extends StatefulWidget { class _SettingsPageState extends State { AppController get c => widget.c; - late final _hotkey = TextEditingController(text: c.settings.showHotkey); - String? _hotkeyError; /// 124: 마스터 비밀번호를 넣기 전에는 설정을 보이지 않는다 (정해 두었고 묻는 때가 "물어보지 않기" 가 아니면) bool _locked = MasterLock.instance?.needsPrompt() ?? false; @@ -319,26 +63,6 @@ class _SettingsPageState extends State { if (mounted) setState(() => _locked = !ok && lock.needsPrompt()); } - @override - void dispose() { - _hotkey.dispose(); - super.dispose(); - } - - /// 폴더 고르기. Android 는 앱 안 화면 (내장 저장소 · SD 카드 · USB 를 실제 경로로 고를 수 있음) - Future _pickDir(String title, [String? initial]) => pickFolder(context, title, initial); - - Future _applyHotkey() async { - final text = _hotkey.text.trim(); - if (parseHotkey(text) == null) { - setState(() => _hotkeyError = tr('예: Ctrl+Shift+X (Ctrl·Shift·Alt·Win + 영문자·숫자·F1~F12)')); - return; - } - final ok = await c.services.shell.setHotkey(text); - setState(() => _hotkeyError = ok ? null : tr('이 단축키를 등록할 수 없습니다 (다른 프로그램이 사용 중일 수 있음)')); - if (ok) await c.updateSettings((s) => s.showHotkey = text); - } - @override Widget build(BuildContext context) { if (_locked) { @@ -397,1041 +121,30 @@ class _SettingsPageState extends State { child: SingleChildScrollView( padding: EdgeInsets.fromLTRB(isCompact(context) ? 4 : 24, 4, isCompact(context) ? 4 : 24, 16), child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _group('general', Icons.tune, tr('일반'), [ - // 화면 언어 (읽을 수 없는 언어를 골라도 바로 찾아 되돌릴 수 있게 맨 위) - const LanguageSettings(), - // 102: 물어 정한 것 · 1차 판단은 모두 여기서 바꿀 수 있다 (기본값 = 그 답) - SwitchListTile( - value: s.migrationNotice, - onChanged: (v) => c.updateSettings((x) => x.migrationNotice = v), - title: Text(tr('업데이트로 바뀐 기본값 알림')), - subtitle: Text(tr('새 버전이 예전 기본값을 바꿨으면 켤 때 한 번 무엇이 바뀌었는지 알려 줍니다')), - ), - SwitchListTile( - value: s.rememberPasswords, - onChanged: (v) => c.updateSettings((x) => x.rememberPasswords = v), - title: Text(tr('비밀번호 기억 (안전 저장소)')), - subtitle: Text(tr('WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다')), - ), - _appIconTile(c, desk), - if (c.services.updater != null) ...[ - SettingTile( - leading: Icon(Icons.system_update_alt, color: JjColors.accent), - title: FutureBuilder( - future: c.services.updater!.currentVersion(), - builder: (_, v) => Text('JJ_MKVMaker v${v.data ?? '…'}'), - ), - subtitle: Text(tr('GitHub 에 올려 둔 모든 버전 중에서 골라 설치합니다 (새 버전으로 · 예전 정상 버전으로 되돌리기). ' - '바꾸기 전 버전의 설정은 보관해 두었다가, 그 버전으로 돌아오면 되살립니다')), - isThreeLine: true, - trailing: Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ - UpdateCheckButton(c: c), - FilledButton.icon( - onPressed: () => chooseVersion(context, c), - icon: const Icon(Icons.system_update_alt, size: 18), - label: Text(tr('업데이트 · 버전 고르기')), - ), - ]), - ), - SwitchListTile( - value: s.autoCheckUpdates, - onChanged: (v) => c.updateSettings((x) => x.autoCheckUpdates = v), - title: Text(tr('시작할 때 새 버전 확인 (하루 한 번)')), - ), - ], - SettingTile( - title: Text(tr('홈 화면')), - subtitle: Text(tr('프로그램을 켰을 때, 그리고 위쪽 왼쪽 JJ 아이콘을 눌렀을 때 보일 화면')), - trailing: DropdownButton( - value: s.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') - DropdownMenuItem(value: 'tor', child: Text(tr('Tor 브라우저'))), - ], - onChanged: (v) => c.updateSettings((x) => x.startScreen = v!), - ), - ), - ]), + _group('general', Icons.tune, tr('일반'), [GeneralSettingsSection(c: c, desk: desk)]), // 124: 최상 · 마스터 비밀번호 _group('security', Icons.lock_outline, tr('보안'), [SecuritySettings(c: c)]), _group('components', Icons.extension_outlined, tr('컴포넌트'), [ComponentSettings(c: c)]), - _group('display', Icons.aspect_ratio, tr('화면'), [ - if (!desk) - SettingTile( - title: Text(tr('화면 방향')), - subtitle: Text(tr('가로 고정 · 세로 고정, 또는 자동 (기기를 돌리는 대로, 기기의 자동 회전 설정을 따름)')), - trailing: DropdownButton( - value: s.screenOrientation, - 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!); - applyScreenOrientation(v!); - }, - ), - ), - SettingTile( - title: Text(tr('기본 화면 크기')), - subtitle: Text(trf('글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 ' '(지금 {0}%)', [(s.uiScale * 100).round()])), - trailing: DropdownButton( - value: s.uiScaleDefault, - items: [ - for (var v = AppSettings.uiScaleMin; v <= AppSettings.uiScaleMax + 0.001; v += 0.1) - DropdownMenuItem( - value: AppSettings.clampUiScale(v), - child: Text('${(v * 100).round()}%${(v - 1).abs() < 0.001 ? tr(' (보통)') : ''}')), - // 5% 단위로 저장된 값이 목록에 없을 때 - if (((s.uiScaleDefault * 100).round() % 10) != 0) - DropdownMenuItem( - value: s.uiScaleDefault, child: Text('${(s.uiScaleDefault * 100).round()}%')), - ], - // 기본 크기를 바꾸면 지금 크기도 그 크기로 - onChanged: (v) => c.updateSettings((x) => x - ..uiScaleDefault = v! - ..uiScale = v), - ), - ), - // X-plore 비교 5-1 ⑦ (관리자 지시 10/21): 화면 밝기 - 처음 값 어둡게 (예전 그대로), 다 끝날 때까지 "시험" - SettingTile( - title: Row(mainAxisSize: MainAxisSize.min, children: [ - Flexible(child: Text(tr('화면 밝기'))), - const SizedBox(width: 6), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), - decoration: BoxDecoration(border: Border.all(color: JjColors.warning), borderRadius: BorderRadius.circular(4)), - child: Text(tr('시험'), style: TextStyle(fontSize: 11, color: JjColors.warning)), - ), - ]), - subtitle: Text(tr('밝은 화면 · 기기 따라는 아직 시험 중입니다 - 일부 화면의 색이 덜 맞을 수 있습니다')), - trailing: DropdownButton( - value: s.uiBrightness, - 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!), - ), - ), - ]), - _group('mkv', Icons.movie_creation_outlined, tr('MKV 만들기'), [ - // 61: 화면 언어와 다른 언어의 자막을 기본으로 원할 수 있다 (예: 화면은 일본어, 자막은 한국어) - SettingTile( - title: Text(tr('기본 자막 언어')), - subtitle: Text(tr('MKV 의 기본 자막 트랙 · 플레이어가 먼저 켜는 자막')), - trailing: SubtitleLanguagePicker( - value: s.preferredSubtitleLanguage, - onChanged: (v) => c.updateSettings((x) => x.preferredSubtitleLanguage = v), - ), - ), - SettingTile( - title: Text(tr('동영상을 세 번 누르면')), - subtitle: Text(tr('한 번: 보기 · 두 번: 재생 · 세 번: 이동 폴더로 옮기기 (옮기기 전에 묻습니다)')), - trailing: DropdownButton( - value: s.tripleTapAction, - items: [ - DropdownMenuItem(value: 'move', child: Text(tr('이동 폴더로 (확인 뒤, 기본)'))), - DropdownMenuItem(value: 'none', child: Text(tr('아무것도 안 함'))), - ], - onChanged: (v) => c.updateSettings((x) => x.tripleTapAction = v!), - ), - ), - // P0: 결과 MKV 가 이미 있을 때 (처음 값: 묻기) - SettingTile( - title: Text(tr('만들 MKV 가 이미 있으면')), - subtitle: Text(tr('덮어쓰기여도 다 만든 뒤에만 바꿉니다 (도중에 취소 · 실패하면 예전 MKV 그대로)')), - trailing: DropdownButton( - value: const ['ask', 'overwrite', 'skip', 'rename'].contains(s.mkvExisting) ? s.mkvExisting : 'ask', - items: [ - DropdownMenuItem(value: 'ask', child: Text(tr('묻기'))), - DropdownMenuItem(value: 'overwrite', child: Text(tr('덮어쓰기'))), - DropdownMenuItem(value: 'skip', child: Text(tr('건너뛰기'))), - DropdownMenuItem(value: 'rename', child: Text(tr('다른 이름 (이름 (2).mkv)'))), - ], - onChanged: (v) => c.updateSettings((x) => x.mkvExisting = v ?? 'ask'), - ), - ), - // 15 지적 · 관리자 판단 (10/17): 폰 세로에서 목록 · 자세히 칸 나누기 - SettingTile( - title: Text(tr('폰 세로 목록 크기')), - subtitle: Text(tr('좁은 화면에서 동영상 목록이 차지하는 높이 (자동: 목록은 줄 수만큼, 자세히 칸 자리는 남김)')), - trailing: DropdownButton( - value: s.mkvCompactList, - items: [ - DropdownMenuItem(value: 'auto', child: Text(tr('자동 (기본)'))), - DropdownMenuItem(value: 'small', child: Text(tr('작게'))), - DropdownMenuItem(value: 'large', child: Text(tr('크게'))), - ], - onChanged: (v) => c.updateSettings((x) => x.mkvCompactList = v ?? 'auto'), - ), - ), - SettingTile( - title: Text(tr('동시 MKV 변환 수')), - subtitle: Text(tr('여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)')), - trailing: CountSelector( - value: s.maxParallelJobs, - onChanged: (n) => c.updateSettings((x) => x.maxParallelJobs = n), - ), - ), - SettingTile( - title: Text(tr('마지막 인코딩 설정을 기억합니다')), - subtitle: Text(trf('{0} · {1}' '{2} (메인 화면 상단에서 변경)', [c.encode.resolution.label, c.encode.codec.label, c.encode.reencode ? ' · ${c.encode.quality.label}' : ''])), - ), - _folderTile( - title: tr('MKV · 자막 (jj_mkv)'), - value: s.mkvOutputRoot, - defaultText: tr('동영상이 있는 폴더 아래 jj_mkv (기본)'), - onPick: () async { - final d = await _pickDir(tr('MKV · 자막 저장 위치'), s.mkvOutputRoot); - if (d != null) await c.updateSettings((x) => x.mkvOutputRoot = d); - }, - onReset: () => c.updateSettings((x) => x.mkvOutputRoot = null), - ), - _subTitle(tr('이동 버튼 (세부 정보 오른쪽 아래, 위로 하나씩)')), - for (var i = 0; i < s.moveTargets.length; i++) _moveTargetTile(i, s.moveTargets[i]), - Padding( - padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), - child: Row(children: [ - OutlinedButton.icon( - onPressed: () async { - final d = await _pickDir(tr('이동 버튼의 폴더')); - if (d == null) return; - final name = p.basename(d).isEmpty ? tr('이동') : p.basename(d); - await c.updateSettings((x) => x.moveTargets = [...x.moveTargets, MoveTarget(name, d)]); - }, - icon: const Icon(Icons.add, size: 18), - label: Text(tr('이동 버튼 추가')), - ), - const SizedBox(width: 12), - Expanded( - child: Text( - s.moveTargets.isEmpty - ? tr('없음: 세부 정보의 [이동] 을 처음 누를 때 폴더를 골라 하나 만듭니다') - : tr('표시 이름은 바로 고칠 수 있습니다. 동영상 줄을 세 번 누르면 첫 번째 버튼의 폴더로 옮깁니다'), - style: TextStyle(fontSize: 12, color: JjColors.textDim), - ), - ), - ]), - ), - ]), - _group('subtitle', Icons.subtitles_outlined, tr('자막 (AI · 인터넷)'), [ - SwitchListTile( - value: s.askAiOptions, - onChanged: (v) => c.updateSettings((x) => x.askAiOptions = v), - title: Text(tr('시작할 때마다 설정 창 보기')), - subtitle: Text(trf('끄면 마지막 설정으로 바로 시작: 원어 {0}, ' '언어 {1}, {2}', [c.aiOptions.source.name, c.aiOptions.targets.map((t) => t.code).join('/'), c.aiOptions.whisper.label])), - ), - // 260: 받은 자막 모델 크기 · 지우기 - _subTitle(tr('받은 AI 자막 모델')), - AiSubtitleModels(c: c), - _subTitle(tr('인터넷 자막 (OpenSubtitles.com)')), - _textTile(tr('API 키 (필수, 무료)'), s.openSubtitlesKey, - tr('가입 → 프로필 → API consumers → New consumer'), - (v) => c.updateSettings((x) => x.openSubtitlesKey = v.trim())), - _textTile(tr('아이디 (선택)'), s.openSubtitlesUser, tr('로그인하면 하루 받기 횟수가 늘어납니다'), - (v) => c.updateSettings((x) => x.openSubtitlesUser = v.trim())), - _textTile(tr('비밀번호 (선택)'), s.openSubtitlesPassword, trf('이 {0}의 안전 저장소에 저장됩니다 (설정 파일에는 쓰지 않음)', [desk ? 'PC' : tr('기기')]), - (v) => c.updateSettings((x) => x.openSubtitlesPassword = v), obscure: true), - ]), + _group('display', Icons.aspect_ratio, tr('화면'), [DisplaySettingsSection(c: c, desk: desk)]), + _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')) _group('ai', Icons.auto_awesome_outlined, tr('AI 그림'), [AiSettings(c: c)]), - _group('play', Icons.play_circle_outline, tr('재생'), [ - SettingTile( - title: Text(tr('로그인이 필요한 WebDAV 동영상을 다른 앱으로 열 때')), - subtitle: Text(tr('다른 앱에는 아이디 · 비밀번호를 넘기지 않습니다. 받아서 열기는 어느 앱에서나 열리고, 주소로 열기는 아이디를 묻는 앱 (VLC 등) 만 열립니다')), - trailing: DropdownButton( - value: s.davExternalOpen, - items: [ - DropdownMenuItem(value: 'ask', child: Text(tr('매번 묻기 (기본)'))), - DropdownMenuItem(value: 'fetch', child: Text(tr('늘 받아서 열기'))), - DropdownMenuItem(value: 'url', child: Text(tr('늘 주소로 열기'))), - ], - onChanged: (v) => c.updateSettings((x) => x.davExternalOpen = v!), - ), - ), - SettingTile( - title: Text(tr('동영상 하나를 재생할 때')), - subtitle: Text(tr('시리즈: file_001 · file_002, S01E01 · S01E02 처럼 번호만 다른 파일')), - trailing: DropdownButton( - value: s.playlistMode, - items: [ - for (final m in PlaylistMode.values) DropdownMenuItem(value: m, child: Text(m.label)), - ], - onChanged: (m) => c.updateSettings((x) => x.playlistMode = m!), - ), - ), - SettingTile( - title: Text(desk ? tr('탐색기에서 동영상을 열 때') : tr('다른 앱에서 동영상을 열 때')), - subtitle: Text(desk - ? tr('더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)') - : tr('파일 앱의 "다음으로 열기" · 공유에서 JJ_MKVMaker 를 골랐을 때')), - trailing: DropdownButton( - value: s.openFileAction, - items: [ - DropdownMenuItem(value: 'play', child: Text(tr('바로 재생 (기본)'))), - DropdownMenuItem(value: 'add', child: Text(tr('편집 목록에 추가 (MKV 만들기 대상)'))), - ], - onChanged: (v) => c.updateSettings((x) => x.openFileAction = v!), - ), - ), - if (desk) ...[ - SettingTile( - title: Text(tr('탐색기에서 연 동영상을 재생할 창')), - subtitle: Text(tr('프로그램이 이미 켜져 있을 때. 새 창은 재생만 하는 창이며 닫으면 그 창만 끝납니다')), - trailing: DropdownButton( - value: s.openFileWindow, - items: [ - DropdownMenuItem(value: 'same', child: Text(tr('켜져 있는 창에서 (기본)'))), - DropdownMenuItem(value: 'new', child: Text(tr('새 창에서'))), - ], - onChanged: (v) => c.updateSettings((x) => x.openFileWindow = v!), - ), - ), - _ExternalPlayers(c: c), - _ContextMenuTile(c: c), - ], - ]), - _group('browser', Icons.public, tr('웹 브라우저'), [ - _textTile(tr('홈 주소'), s.homeUrl, tr('웹 브라우저의 시작 · 🏠 주소'), - (v) => c.updateSettings((x) => x.homeUrl = v.trim().isEmpty ? 'https://www.youtube.com/' : v.trim())), - SwitchListTile( - value: s.youtubeAdSkip, - onChanged: (v) => c.updateSettings((x) => x.youtubeAdSkip = v), - title: Text(tr('YouTube 광고 자동 건너뛰기')), - subtitle: Text(tr('앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 ' '(넘기는 동안 화면 위쪽에 "광고 건너뛰는 중…")')), - ), - SwitchListTile( - value: s.youtubeAdHide, - onChanged: (v) => c.updateSettings((x) => x.youtubeAdHide = v), - title: Text(tr('YouTube 광고 배너 숨기기')), - subtitle: Text(tr('목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다')), - ), - SwitchListTile( - value: s.webPauseOnLeave, - onChanged: (v) => c.updateSettings((x) => x.webPauseOnLeave = v), - title: Text(tr('다른 화면으로 가면 페이지 소리 멈춤')), - subtitle: Text(tr('끄면 환경 설정 · 다운로드 목록 · 다른 화면을 보는 동안에도 웹 페이지의 소리가 계속 들립니다')), - ), - SwitchListTile( - value: s.webTranslate, - onChanged: (v) => c.updateSettings((x) => x.webTranslate = v), - title: Text(tr('웹 페이지 자동 번역')), - subtitle: Text(trf( - '다른 언어로 된 페이지를 화면 언어 ({0}) 로 번역합니다. 페이지의 글을 Google 번역으로 보냅니다. ' - '주소창 옆 번역 버튼으로 원문 · 번역을 바꿀 수 있습니다.', - [I18nController.nativeName(s.uiLanguage)])), - ), - SwitchListTile( - value: s.webJavaScript, - onChanged: (v) => c.updateSettings((x) => x.webJavaScript = v), - title: Text(tr('JavaScript 사용')), - subtitle: Text(desk - ? tr('끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). ' - 'Chrome 엔진은 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.') - : tr('끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). ' - '끄면 페이지 번역 · 동영상 찾기도 멈춥니다.')), - ), - if (desk) ...[ - SettingTile( - title: Text(tr('브라우저 엔진 (앱 안)')), - subtitle: Text( - trf('Edge: Windows 에 들어 있어 따로 설치하지 않습니다. 여기서 YouTube 에 로그인하면 다운로드에도 그 로그인이 쓰입니다.\n' 'Chrome: 고르면 약 {0}MB 를 내려받고 다시 시작한 뒤 쓸 수 있습니다 ' '(Google 로그인은 막힐 수 있음).' '{1}' '{2}', [CefRuntime.approxDownloadMb, CefRuntime.installed ? trf(' 지금 설치됨 ({0}MB)', [CefRuntime.installedMb()]) : '', s.browserEngine == 'chrome' && !CefRuntime.readyThisRun ? tr(' · 다시 시작해야 Chrome 으로 바뀝니다') : ''])), - isThreeLine: true, - trailing: Row(mainAxisSize: MainAxisSize.min, children: [ - if (s.browserEngine == 'edge' && CefRuntime.installed && !CefRuntime.readyThisRun) - IconButton( - tooltip: tr('내려받은 Chrome 엔진 지우기'), - icon: Icon(Icons.delete_outline, color: JjColors.textDim), - onPressed: () async { - final ok = await CefRuntime.uninstall(); - c.note(ok ? tr('내장 Chrome 엔진을 지웠습니다.') : tr('내장 Chrome 엔진을 지우지 못했습니다 (사용 중).')); - setState(() {}); - }, - ), - DropdownButton( - value: s.browserEngine, - items: [ - DropdownMenuItem(value: 'edge', child: Text(tr('Edge (내장)'))), - DropdownMenuItem( - value: 'chrome', - child: Text(CefRuntime.installed ? tr('Chrome (내장)') : tr('Chrome (내려받기)'))), - ], - onChanged: (v) async { - if (v == 'chrome') { - await chooseChromeEngine(context, c); - if (mounted) setState(() {}); - } else { - await c.updateSettings((x) => x.browserEngine = 'edge'); - } - }, - ), - ]), - ), - // 207: 관리자 권한일 때도 Chrome 엔진 쓰기 (처음 꺼짐, 켤 때 경고) - SwitchListTile( - value: s.cefWhenElevated, - onChanged: (v) async { - if (v) { - await confirmCefWhenElevated(context, c); - } else { - await c.updateSettings((x) => x.cefWhenElevated = false); - } - if (mounted) setState(() {}); - }, - title: Text(tr('관리자 권한일 때도 Chrome 엔진 쓰기 (권장하지 않음)')), - subtitle: Text(Elevation.elevated - ? tr('지금 관리자 권한으로 실행 중입니다. 끄면 (기본) Chrome 엔진을 시작하지 않고 Edge 를 권합니다. 켜면 샌드박스 없이 관리자 권한으로 웹 페이지를 돌립니다.') - : tr('관리자 권한으로 실행할 때만 쓰입니다. 끄면 (기본) 그때 Chrome 엔진을 시작하지 않고 Edge 를 권합니다.')), - ), - SettingTile( - title: Text(tr('외부 브라우저')), - subtitle: Text(tr('"외부 브라우저로 열기" 에 쓸 브라우저 (이 PC 에 설치된 것)')), - trailing: DropdownButton( - value: s.externalBrowser, - items: [ - DropdownMenuItem(value: 'system', child: Text(tr('Windows 기본 브라우저'))), - for (final b in {...c.services.shell.installedBrowsers(), if (s.externalBrowser != 'system') s.externalBrowser}) - DropdownMenuItem(value: b, child: Text(switch (b) { - 'chrome' => 'Chrome', - 'firefox' => 'Firefox', - 'edge' => 'Edge', - 'whale' => 'Whale', - _ => b, - })), - ], - onChanged: (v) => c.updateSettings((x) => x.externalBrowser = v!), - ), - ), - ], - ]), + _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')) _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('파일 탐색기'), [ - if (desk) - SwitchListTile( - value: s.recycleOnDelete, - onChanged: (v) => c.updateSettings((x) => x.recycleOnDelete = v), - title: Text(tr('지우면 휴지통으로')), - subtitle: Text(tr('켜면 휴지통으로 보내 되살릴 수 있습니다 (Shift+Delete 는 영구 삭제). 끄면 늘 영구 삭제로 묻습니다. ' - '네트워크 드라이브 · USB 메모리는 휴지통이 없어 늘 영구 삭제입니다')), - ), - SettingTile( - title: Text(tr('누르기')), - subtitle: Text(tr('길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). ' - 'Ctrl + 클릭: 여러 개 고르기')), - trailing: DropdownButton( - value: s.explorerClick, - items: [ - DropdownMenuItem( - value: 'select', child: Text(desk ? tr('한 번: 선택 · 두 번: 실행 (기본)') : tr('한 번: 선택 · 두 번: 실행'))), - DropdownMenuItem(value: 'open', child: Text(desk ? tr('한 번: 바로 실행') : tr('한 번: 바로 실행 (기본)'))), - ], - onChanged: (v) => c.updateSettings((x) => x.explorerClick = v!), - ), - ), - // 82: 창 사이 끌어 놓기 (처음: 묻기) - SettingTile( - title: Text(tr('끌어 놓기 기본')), - subtitle: Text(tr('다른 창으로 끌어 놓았을 때. 어느 쪽이든 복사 · 이동 확인 창 (같은 이름 등) 은 그대로 뜹니다')), - trailing: DropdownButton( - value: s.explorerDropAction, - items: [ - DropdownMenuItem(value: 'ask', child: Text(tr('묻기 (기본)'))), - DropdownMenuItem(value: 'copy', child: Text(tr('복사'))), - DropdownMenuItem(value: 'move', child: Text(tr('이동'))), - ], - onChanged: (v) => c.updateSettings((x) => x.explorerDropAction = v ?? 'ask'), - ), - ), - // 84: 정렬을 폴더마다 기억 (처음: 끔) - SettingTile( - title: Text(tr('정렬을 폴더마다 기억')), - subtitle: Text(tr('켜면 열 머리 · 정렬 기준에서 바꾼 정렬이 그 폴더에만 쓰입니다. 끄면 모든 폴더가 같은 정렬입니다')), - trailing: Switch( - value: s.explorerSortPerFolder, - onChanged: (v) => c.updateSettings((x) => x.explorerSortPerFolder = v), - ), - ), - // X-plore 비교 5-2 (사용자 결정 10/21): 시스템 폴더 보기 (Shizuku, 읽기 전용) - Android, 켤 때마다 경고 - if (shizukuSupported()) - SettingTile( - title: Text(tr('시스템 폴더 보기 (Shizuku · 읽기 전용)')), - subtitle: Text(tr('Shizuku 앱이 있으면 Android/data · obb 처럼 막힌 폴더를 읽기만 합니다. 탐색기 위 탭 [시스템 (읽기 전용)]')), - trailing: Switch( - value: s.shizukuEnabled, - onChanged: (v) async { - if (v && - !await confirmReadOnlyView(context, - icon: Icons.admin_panel_settings_outlined, - title: tr('시스템 폴더 보기를 켤까요?'), - body: tr('Shizuku (adb 권한) 로 다른 앱의 폴더 (Android/data · obb) 를 볼 수 있게 됩니다. 그 안에는 다른 앱의 ' - '대화 · 로그인 정보 같은 개인 데이터가 있을 수 있습니다.'))) { - return; - } - await c.updateSettings((x) => x.shizukuEnabled = v); - }, - ), - ), - // X-plore 비교 5-2 (관리자 결정 10/21): 루트 폴더 보기 (su, 읽기 전용) - Shizuku 와 같은 틀 - if (rootSupported()) - SettingTile( - title: Text(tr('루트 폴더 보기 (su · 읽기 전용)')), - subtitle: Text(tr('루팅한 기기에서 su 로 시스템 · 다른 앱의 폴더를 읽기만 합니다. 탐색기 위 탭 [루트 (읽기 전용)]')), - trailing: Switch( - value: s.rootEnabled, - onChanged: (v) async { - if (v && - !await confirmReadOnlyView(context, - icon: Icons.security, - title: tr('루트 폴더 보기를 켤까요?'), - body: tr('루트 (su) 권한으로 시스템 · 다른 앱의 폴더 전체 (/data 등) 를 볼 수 있게 됩니다. 다른 앱의 개인 자료 ' - '(대화 · 로그인 정보 · 비밀번호 등) 도 보일 수 있습니다.'))) { - return; - } - await c.updateSettings((x) => x.rootEnabled = v); - }, - ), - ), - // X-plore 비교 5-1 ① (관리자 지시 10/21): 종류별 기본 정렬 (처음: 끔) - SettingTile( - title: Text(tr('종류별 기본 정렬')), - subtitle: Text(tr('사진 · 동영상이 많은 폴더는 날짜 (새것 먼저), 음악이 많은 폴더는 이름으로. 그 폴더에서 정렬을 바꾸면 그 폴더는 바꾼 정렬을 기억합니다')), - trailing: Switch( - value: s.explorerSortByKind, - onChanged: (v) => c.updateSettings((x) => x.explorerSortByKind = v), - ), - ), - // X-plore 비교 5-1 ② (관리자 지시 10/21): 원격 썸네일 · 캐시 - SettingTile( - title: Text(tr('원격 썸네일')), - subtitle: Text(tr('WebDAV · SFTP · SMB · FTP 의 그림 · 동영상을 X-plore 모양 목록에서 작은 그림으로. 보이는 줄만, 동시에 2개까지 받습니다')), - trailing: DropdownButton( - value: s.explorerRemoteThumbs, - items: [ - DropdownMenuItem(value: 'off', child: Text(tr('받지 않음'))), - DropdownMenuItem(value: 'wifi', child: Text(tr('Wi-Fi 에서만 (기본)'))), - DropdownMenuItem(value: 'always', child: Text(tr('늘 (모바일 데이터에서도)'))), - ], - onChanged: (v) => c.updateSettings((x) => x.explorerRemoteThumbs = v!), - ), - ), - SettingTile( - title: Text(tr('원격 썸네일 캐시')), - subtitle: Text(tr('넘으면 오래 보지 않은 것부터 지웁니다')), - trailing: DropdownButton( - value: s.explorerRemoteThumbCacheMb, - items: [ - for (final v in {...AppSettings.remoteThumbCacheChoices, s.explorerRemoteThumbCacheMb}.toList()..sort()) - DropdownMenuItem(value: v, child: Text(v == 200 ? trf('{0}MB (기본)', [v]) : '${v}MB')), - ], - onChanged: (v) => c.updateSettings((x) => x.explorerRemoteThumbCacheMb = v!), - ), - ), - _RemoteThumbCacheTile(c: c), - // 80: 폴더 크기 (필요할 때 계산하고 이번 실행 동안 기억) - SettingTile( - title: Text(tr('폴더 크기')), - subtitle: Text(tr('폴더 안 파일 크기의 합을 목록에 보입니다. 큰 폴더 · NAS 는 읽는 데 오래 걸릴 수 있습니다. ' - '길게 누르기 [폴더 크기 계산] 은 늘 됩니다')), - trailing: DropdownButton( - value: s.explorerFolderSize, - items: [ - DropdownMenuItem(value: 'sort', child: Text(tr('크기순 정렬일 때만 (기본)'))), - DropdownMenuItem(value: 'always', child: Text(tr('늘 계산'))), - DropdownMenuItem(value: 'off', child: Text(tr('계산 안 함'))), - ], - onChanged: (v) => c.updateSettings((x) => x.explorerFolderSize = v ?? 'sort'), - ), - ), - // X-plore 비교 5-1 ⑥ (관리자 지시 10/21): 단축키 바꾸기 (키보드가 있는 PC 에서만 보임) - if (desk) - ListTile( - title: Text(tr('파일 탐색기 단축키')), - subtitle: Text(s.explorerKeyOverrides.isEmpty - ? tr('스타일대로 (바꾼 것 없음)') - : trf('바꾼 것 {0}개', [s.explorerKeyOverrides.length])), - trailing: const Icon(Icons.chevron_right), - onTap: () => ExplorerKeysPage.open(context, c), - ), - SettingTile( - title: Text(tr('스타일')), - // 83: 스타일마다 단축키 - PC 에서만 (194: 키보드가 없는 폰 · 태블릿에는 쓸모없는 긴 글, 15 지적) - subtitle: Text([ - tr('아이콘 · 목록 모양: X-plore (썸네일 · 두 줄) · Windows 탐색기 (컬러 아이콘 · 열) · ' - 'Total Commander (촘촘한 목록 · [폴더])'), - if (desk) - tr('단축키 - Total Commander: F5 복사 · F6 이동 · F7 새 폴더 · F8 지우기 · Insert/Space 표시 · Ctrl+R 새로 고침. ' - '그 밖: Ctrl+C · X · V (복사 · 잘라내기 · 붙여넣기) · Ctrl+A 모두 표시 · F5 새로 고침'), - ].join('\n')), - trailing: DropdownButton( - value: s.explorerStyle, - items: [ - for (final st in ExplorerStyle.values) - DropdownMenuItem(value: st.name, child: Text(tr(st.label))), - ], - onChanged: (v) => c.updateSettings((x) => x.explorerStyle = v!), - ), - ), - SettingTile( - title: Text(tr('창 배치')), - subtitle: Text(tr('파일 탐색기 위쪽 ⋮ 메뉴에서도 바꿀 수 있습니다 (좌우 · 위아래 · 버튼 줄 위치 · 버튼 구성)')), - trailing: DropdownButton( - value: s.explorerLayout, - items: [ - DropdownMenuItem(value: 'auto', child: Text(tr('화면 크기 따라'))), - DropdownMenuItem(value: 'dual', child: Text(tr('두 창 (기본)'))), - DropdownMenuItem(value: 'split', child: Text(tr('폴더 + 파일 목록'))), - DropdownMenuItem(value: 'single', child: Text(tr('한 창'))), - ], - onChanged: (v) => c.updateSettings((x) => x.explorerLayout = v!), - ), - ), - // X-plore 비교 e: Android 앱 휴지통 보관 일 - if (Platform.isAndroid) - SettingTile( - title: Text(tr('휴지통 보관')), - subtitle: Text(tr('지운 것을 저장 장치의 앱 휴지통에 두었다가 이 날이 지나면 지웁니다. 끄면 바로 영구 삭제')), - trailing: DropdownButton( - value: const [0, 3, 7, 14, 30, 60].contains(s.androidTrashDays) ? s.androidTrashDays : 14, - items: [ - for (final d in const [0, 3, 7, 14, 30, 60]) - DropdownMenuItem(value: d, child: Text(d == 0 ? tr('끔') : trf('{0}일', [d]))), - ], - onChanged: (v) => c.updateSettings((x) => x.androidTrashDays = v!), - ), - ), - // X-plore 비교 ⚖ (관리자 결정): X-plore 스타일의 표시 동그라미 · 밀기 · 폴더 표시 규칙 - SwitchListTile( - title: Text(tr('X-plore 스타일: 선택 표시 늘 보이기')), - subtitle: Text(tr('끄면 (처음 값) [선택] 을 켰을 때만 보입니다. 다른 스타일은 늘 선택 모드에서만')), - value: s.explorerAlwaysMarks, - onChanged: (v) => c.updateSettings((x) => x.explorerAlwaysMarks = v), - ), - SwitchListTile( - title: Text(tr('X-plore 스타일: 옆으로 밀면 창 바꾸기')), - subtitle: Text(tr('탐색기 안에서 밀면 다른 창으로. 다른 화면으로 가려면 위 막대에서 밉니다')), - value: s.explorerSwipePanes, - onChanged: (v) => c.updateSettings((x) => x.explorerSwipePanes = v), - ), - // X-plore 비교 c: 저장 장치 · 서버 · 즐겨찾기를 트리 맨 위에 (X-plore 스타일) - SwitchListTile( - title: Text(tr('저장 장치 · 서버 · 즐겨찾기를 트리 맨 위에')), - subtitle: Text(tr('X-plore 스타일에서 X-plore 처럼 한 트리에 보입니다 (위 줄 탭 · ★ 는 그대로)')), - value: s.explorerRootsInTree, - onChanged: (v) => c.updateSettings((x) => x.explorerRootsInTree = v), - ), - // X-plore 비교 d: 지금 폴더를 다시 누르면 접기 - SwitchListTile( - title: Text(tr('펼친 폴더를 다시 누르면 접기')), - subtitle: Text(tr('처음 값 켬. 끄면 접기 · 펼치기는 › 화살표로만 (두 번 눌러도 목록이 움직이지 않음)')), - value: s.explorerRetapCollapse, - onChanged: (v) => c.updateSettings((x) => x.explorerRetapCollapse = v), - ), - // X-plore 비교 40 (관리자 지시 10/20): 목록만의 글자 크기 · 줄 간격 (세 스타일 모두, 앱 화면 크기 · 기기 글자 크기와 곱함) - SettingTile( - title: Text(tr('목록 글자 크기')), - subtitle: Text(tr('파일 탐색기 목록의 이름 · 크기 글자만. 앱 화면 크기 · 기기 글자 크기와 곱해집니다')), - trailing: DropdownButton( - value: s.explorerTextScale, - items: [ - for (var v = AppSettings.explorerTextScaleMin; v <= AppSettings.explorerTextScaleMax; v += 10) - DropdownMenuItem(value: v, child: Text(v == 100 ? trf('{0}% (기본)', [v]) : '$v%')), - if (s.explorerTextScale % 10 != 0) - DropdownMenuItem(value: s.explorerTextScale, child: Text('${s.explorerTextScale}%')), - ], - onChanged: (v) => c.updateSettings((x) => x.explorerTextScale = v!), - ), - ), - SettingTile( - title: Text(tr('줄 간격')), - subtitle: Text(tr('파일 탐색기 목록의 줄 높이. 좁게는 손가락으로 누르기 어려울 수 있습니다')), - trailing: DropdownButton( - value: s.explorerRowSpacing, - items: [ - DropdownMenuItem(value: 'compact', child: Text(tr('좁게'))), - DropdownMenuItem(value: 'normal', child: Text(tr('보통 (기본)'))), - DropdownMenuItem(value: 'wide', child: Text(tr('넓게'))), - ], - onChanged: (v) => c.updateSettings((x) => x.explorerRowSpacing = v!), - ), - ), - // X-plore 숨기기: 목록에서 숨긴 것 보기 · 풀기 (관리자 결정 10/18) - ListTile( - title: Text(trf('목록에서 숨긴 항목 ({0}개)', [s.explorerHiddenItems.length])), - subtitle: Text(tr('파일 탐색기의 길게 누르기 [목록에서 숨기기] 로 숨긴 것. 파일은 그대로이고 목록에서만 빠집니다')), - trailing: const Icon(Icons.chevron_right), - enabled: s.explorerHiddenItems.isNotEmpty, - onTap: () => showHiddenItems(context, c), - ), - // X-plore 맞추기 (관리자 지시 10/20): 숨기기 전 확인 창 - SwitchListTile( - title: Text(tr('목록에서 숨기기 전에 묻기')), - subtitle: Text(tr('무엇이 바뀌는지 (목록에서만 빠지고 파일은 그대로) 확인 창을 띄웁니다')), - value: s.explorerHideConfirm, - onChanged: (v) => c.updateSettings((x) => x.explorerHideConfirm = v), - ), - // X-plore 비교 a: 한 창일 때 복사 · 이동이 갈 곳 - SettingTile( - title: Text(tr('한 창일 때 복사 · 이동')), - subtitle: Text(tr('숨은 다른 창으로: X-plore 처럼 위에 "갈 곳" 을 늘 보이고 그 폴더로 바로 복사 · 이동합니다')), - trailing: DropdownButton( - value: s.explorerOnePaneCopy, - items: [ - DropdownMenuItem(value: 'clip', child: Text(tr('담아 두고 붙여넣기 (기본)'))), - DropdownMenuItem(value: 'other', child: Text(tr('숨은 다른 창으로'))), - ], - onChanged: (v) => c.updateSettings((x) => x.explorerOnePaneCopy = v!), - ), - ), - // 복사 · 이동 방법 (현재 방식 · robocopy) · 속도 제한 - CopySyncSettings(c: c), - // WebDAV 서버 (탐색기 · Rsync 화면 위쪽 탭) - WebDavSettings(c: c), - // 88: SFTP 서버 (WebDAV 옆 탭) - SftpSettings(c: c), - // l: FTP · FTPS 서버 - FtpSettings(c: c), - // 88: SMB (Android 만 - Windows 는 운영체제가 \\서버\공유 를 연다) - if (Platform.isAndroid) SmbSettings(c: c), - // 관리자 판단 (10/20): 원격 서버가 멈췄을 때 기다리는 시간 - SettingTile( - leading: const Icon(Icons.hourglass_bottom_outlined), - title: Text(tr('원격 서버 응답 기다리기')), - subtitle: Text(tr('WebDAV · SFTP · FTP · SMB 에서 받거나 올리는 중에 이 시간 동안 아무것도 오가지 않으면 실패로 알립니다 (서버가 멈춰도 계속 기다리지 않게)')), - trailing: DropdownButton( - value: RemoteIdle.choices.contains(s.remoteIdleSeconds) ? s.remoteIdleSeconds : RemoteIdle.defaultSeconds, - items: [ - for (final n in RemoteIdle.choices) - DropdownMenuItem(value: n, child: Text(n == RemoteIdle.defaultSeconds ? trf('{0}초 (기본)', [n]) : trf('{0}초', [n]))), - ], - onChanged: (v) => c.updateSettings((x) => x.remoteIdleSeconds = v!), - ), - ), - ViewerSettings(c: c), - ]), + _group('files', Icons.folder_copy_outlined, tr('파일 탐색기'), [FilesSettingsSection(c: c, desk: desk)]), // Rsync 화면: rsync 옵션 · 가져오기 · 실시간 동기화 (lsync) · 백그라운드로 실행 - _group('rsync', Icons.sync_alt, 'Rsync', [ - CopySyncSettings(c: c, rsync: true), - SwitchListTile( - value: s.allowInnerToOuter, - onChanged: (v) => c.updateSettings((x) => x.allowInnerToOuter = v), - title: Text(tr('원본이 대상 안에 있어도 맞추기 (안쪽 → 바깥)')), - subtitle: Text(tr('확인 창에서 알린 뒤 허용합니다. 지우기 (--delete) 가 있으면 늘 막습니다. 끄면 늘 막습니다')), - ), - if (desk) - SwitchListTile( - value: s.syncStopToast, - onChanged: (v) => c.updateSettings((x) => x.syncStopToast = v), - title: Text(tr('동기화가 멈추면 Windows 알림')), - subtitle: Text(tr('창을 트레이에 둔 채 실시간 동기화가 원본을 못 읽어 멈추면 알림을 띄웁니다 (트레이 툴팁 경고는 늘)')), - ), - // Windows 에서 알림이 꺼져 있으면 알리고 설정을 열어 준다 (바꾸지는 않음) - if (desk && s.syncStopToast) const ToastStatusHint(), - ]), - _group('download', Icons.download_outlined, tr('다운로드'), [ - if (desk) - SwitchListTile( - value: s.clipboardWatch, - onChanged: (v) => c.updateSettings((x) => x.clipboardWatch = v), - title: Text(tr('복사(Ctrl+C)한 주소 자동 다운로드')), - subtitle: Text(tr('YouTube → yt-dlp, 마그넷 · .torrent → aria2 (완료 후 올려 주기 안 함)')), - ), - SwitchListTile( - value: s.addFinishedDownloads, - onChanged: (v) => c.updateSettings((x) => x.addFinishedDownloads = v), - title: Text(tr('완료 시 자동 동영상 추가')), - subtitle: Text(tr('다 받는 대로 MKV 만들기의 동영상 목록에 넣고, 다운로드 목록에서는 뺍니다 (다운로드 화면의 체크 상자와 같음)')), - ), - // 관리자 지시 (10/20): 시작되지 않는 마그넷 · 토렌트 알림 (처음 60초) - SettingTile( - title: Text(tr('마그넷 · 토렌트가 시작되지 않으면 알리기')), - subtitle: Text(tr('메타데이터나 피어를 이 시간 동안 못 받으면 그 줄에 이유 후보와 [다시 시도] 를 보입니다')), - trailing: DropdownButton( - value: s.torrentStallSeconds, - items: [ - for (final v in const [30, 60, 120, 300]) DropdownMenuItem(value: v, child: Text(trf('{0}초', [v]))), - DropdownMenuItem(value: 0, child: Text(tr('알리지 않음'))), - ], - onChanged: (v) => c.updateSettings((x) => x.torrentStallSeconds = v!), - ), - ), - SettingTile( - title: Text(tr('YouTube 받을 형식')), - subtitle: Text(tr('기본: MP4. 음성만 받으면 MP3 · M4A 로 저장')), - trailing: DropdownButton( - value: s.ytContainer, - items: [for (final v in YtContainer.values) DropdownMenuItem(value: v, child: Text(v.label))], - onChanged: (v) => c.updateSettings((x) => x.ytContainer = v!), - ), - ), - SettingTile( - enabled: !s.ytContainer.audioOnly, - title: Text(tr('YouTube 화질')), - subtitle: Text(tr('고른 크기 이하에서 가장 좋은 화질 (영상에 없는 크기면 가까운 것)')), - trailing: DropdownButton( - value: s.ytQuality, - items: [for (final v in YtQuality.values) DropdownMenuItem(value: v, child: Text(v.label))], - onChanged: s.ytContainer.audioOnly ? null : (v) => c.updateSettings((x) => x.ytQuality = v!), - ), - ), - // 36: 같은 크기면 H.264 먼저 (Android 기본 켜짐) - SwitchListTile( - value: s.ytPreferH264, - onChanged: s.ytContainer.audioOnly ? null : (v) => c.updateSettings((x) => x.ytPreferH264 = v), - title: Text(tr('같은 화질이면 H.264 먼저 받기')), - subtitle: Text(tr('휴대폰 · 태블릿은 AV1 · VP9 을 하드웨어로 풀지 못해 끊기거나 배터리를 많이 쓰는 일이 있습니다. ' - 'YouTube 의 H.264 는 1080p 까지라 더 큰 화질을 고르면 크기가 먼저입니다.')), - ), - SwitchListTile( - value: s.ytExpandPlaylists, - onChanged: (v) => c.updateSettings((x) => x.ytExpandPlaylists = v), - title: Text(tr('재생목록 주소면 목록 전체 받기')), - subtitle: Text(tr('영상마다 한 줄씩 보여 주고 jj_yt-dlp\\재생목록 이름\\ 에 저장 (믹스 목록 제외)')), - ), - SettingTile( - title: Text(tr('YouTube 쿠키 (로봇 확인이 나올 때)')), - subtitle: Text(s.ytCookiesFile.isNotEmpty - ? 'cookies.txt: ${s.ytCookiesFile}' - : s.ytCookiesBrowser == internalBrowserCookies - ? tr('앱 안 브라우저의 로그인 사용 (브라우저 화면에서 YouTube 에 로그인하세요)') - : s.ytCookiesBrowser.isNotEmpty - ? trf('{0} 브라우저의 YouTube 로그인 쿠키 사용 (Chrome · Edge 는 브라우저를 닫아야 읽힐 수 있음)', [s.ytCookiesBrowser]) - : tr('사용 안 함. "Sign in to confirm you\'re not a bot" 오류가 나오면 설정하세요')), - trailing: Row(mainAxisSize: MainAxisSize.min, children: [ - DropdownButton( - value: s.ytCookiesFile.isNotEmpty ? '*file' : s.ytCookiesBrowser, - items: [ - DropdownMenuItem(value: '', child: Text(tr('사용 안 함'))), - DropdownMenuItem(value: internalBrowserCookies, child: Text(tr('앱 안 브라우저 (권장)'))), - // PC 브라우저의 쿠키는 Windows 에서만 - if (desk) - for (final b in const ['firefox', 'chrome', 'edge', 'brave', 'whale', 'opera']) - DropdownMenuItem(value: b, child: Text(b[0].toUpperCase() + b.substring(1))), - DropdownMenuItem(value: '*file', child: Text(tr('cookies.txt 파일…'))), - ], - onChanged: (v) async { - if (v == '*file') { - final f = await FilePicker.pickFile( - dialogTitle: tr('cookies.txt (Netscape 형식)'), - type: FileType.custom, - allowedExtensions: const ['txt']); - if (f?.path != null) { - await c.updateSettings((x) => x - ..ytCookiesFile = f!.path! - ..ytCookiesBrowser = ''); - } - } else { - await c.updateSettings((x) => x - ..ytCookiesBrowser = v ?? '' - ..ytCookiesFile = ''); - } - // 131: 앱 안 브라우저 로그인을 더 쓰지 않으면 내보내 둔 평문 쿠키 파일을 지운다 - if (c.settings.ytCookiesBrowser != internalBrowserCookies) { - await deleteExportedCookies(c.settings.webViewDataDir); - if (mounted) setState(() {}); - } - }, - ), - ]), - ), - // 53: 앱 안 브라우저 쿠키 - 어디에 어떻게 저장되는지 · 넘길 사이트 고르기 · 지우기 - // 131: 쓰지 않게 바꿨어도 파일이 남아 있으면 지우는 버튼은 보인다 - if (s.ytCookiesBrowser == internalBrowserCookies || s.internalCookieFile.isNotEmpty) - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - tr('로그인 쿠키는 다운로드 (yt-dlp) 가 쓰도록 이 기기의 앱 폴더에 cookies_youtube.txt 로 저장됩니다 (평문). ' - '이 파일을 가진 사람은 그 계정에 로그인할 수 있으니 기기를 남에게 줄 때는 지우세요. 고른 사이트의 쿠키만 넘깁니다:'), - // 관리자 지시 (10/21, 131 확인): 12px 흐린 회색은 읽기 힘들었다 - 위아래 설명 글 (ListTile 부제목) 과 같은 크기 · 색으로 - style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant)), - const SizedBox(height: 6), - Wrap(spacing: 6, runSpacing: 6, children: [ - for (final d in loginCookieDomains) - FilterChip( - label: Text(d), - selected: s.loginCookieSites.contains(d), - onSelected: (on) => c.updateSettings((x) => x.loginCookieSites = [ - for (final y in x.loginCookieSites) if (y != d) y, - if (on) d, - ]), - ), - OutlinedButton.icon( - icon: const Icon(Icons.cookie_outlined, size: 18), - label: Text(tr('쿠키 파일 지우기')), - onPressed: () async { - await deleteExportedCookies(s.webViewDataDir); - if (!context.mounted) return; - setState(() {}); - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text(tr('내보낸 쿠키 파일을 지웠습니다. 브라우저의 로그인까지 지우려면 ' - '웹 브라우저 화면의 [쿠키 · 방문 기록 지우기] 를 누르세요.')))); - }, - ), - ]), - ]), - ), - SettingTile( - title: Text(tr('동시 다운로드 수')), - subtitle: Text(tr('나머지는 대기했다가 차례로 받습니다')), - trailing: CountSelector( - value: s.maxParallelDownloads, - onChanged: (n) => c.updateSettings((x) => x.maxParallelDownloads = n), - ), - ), - _folderTile( - title: tr('다운로드 (jj_yt-dlp · jj_aria2)'), - value: s.downloadRoot, - defaultText: trf('{0} (기본): {1}', [desk ? tr('프로그램 폴더') : tr('내장 저장소'), s.resolvedDownloadRoot()]), - onPick: () async { - final d = await _pickDir(tr('다운로드 위치'), s.resolvedDownloadRoot()); - if (d != null) await c.updateSettings((x) => x.downloadRoot = d); - }, - onReset: () => c.updateSettings((x) => x.downloadRoot = null), - ), - ]), + _group('rsync', Icons.sync_alt, 'Rsync', [RsyncSettingsSection(c: c, desk: desk)]), + _group('download', Icons.download_outlined, tr('다운로드'), [DownloadSettingsSection(c: c, desk: desk)]), if (desk) - _group('run', Icons.power_settings_new, tr('실행 · 종료'), [ - if (desk) - SwitchListTile( - value: s.startMenuShortcut, - onChanged: (v) async { - await c.updateSettings((x) => x.startMenuShortcut = v); - if (v) { - final ok = await StartMenu.ensure(); - final sh = c.services.shell; - if (sh is DesktopShell) sh.toastAppReady = ok; - } else { - await StartMenu.remove(); - final sh = c.services.shell; - if (sh is DesktopShell) sh.toastAppReady = false; - } - }, - title: Text(tr('시작 메뉴에 등록')), - subtitle: Text(StartMenu.userAreaLocked() - ? tr('시험용 데이터 폴더 (JJ_MKVMAKER_DATA) 로 켜져 시작 메뉴 바로 가기 · jjmkvmaker:// 연결은 건드리지 않습니다') - : tr('알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)')), - ), - SettingTile( - title: Text(tr('종료 (창 닫기 ✕ · 종료 버튼) 를 누르면')), - subtitle: Text(trf('백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {0} 또는 트레이 아이콘으로 다시 엽니다.\n' '완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)', [s.showHotkey])), - isThreeLine: true, - trailing: DropdownButton( - value: s.closeAction, - items: [ - DropdownMenuItem(value: 'background', child: Text(tr('백그라운드로 계속 (기본)'))), - DropdownMenuItem(value: 'quit', child: Text(tr('다운로드 · 변환도 모두 종료 (묻지 않음)'))), - DropdownMenuItem(value: 'ask', child: Text(tr('매번 고르기'))), - ], - onChanged: (v) => c.updateSettings((x) => x.closeAction = v!), - ), - ), - SwitchListTile( - value: s.minimizeToTray, - onChanged: (v) => c.updateSettings((x) => x.minimizeToTray = v), - title: Text(tr('최소화하면 트레이로 (백그라운드 실행)')), - subtitle: Text(tr('트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다')), - ), - SettingTile( - title: Text(tr('창 보이기 / 숨기기 단축키')), - subtitle: _hotkeyError == null - ? Text(tr('어디서나 누르면 JJ_MKVMaker 창이 나타납니다')) - : Text(_hotkeyError!, style: TextStyle(color: JjColors.danger)), - trailing: SizedBox( - width: 260, - child: Row(children: [ - Expanded( - child: TextField( - controller: _hotkey, - decoration: const InputDecoration(isDense: true, border: OutlineInputBorder()), - onSubmitted: (_) => _applyHotkey(), - ), - ), - const SizedBox(width: 8), - OutlinedButton(onPressed: _applyHotkey, child: Text(tr('적용'))), - ]), - ), - ), - ]), - _group('cleanup', Icons.cleaning_services_outlined, tr('저장 공간 정리'), [ - SettingTile( - title: Text(tr('정리 창에서 미리 체크할 것')), - subtitle: Wrap(spacing: 6, runSpacing: 4, children: [ - for (final (id, label) in [ - ('work', tr('작업 임시 파일')), - ('download', tr('받다 만 다운로드')), - ('models', tr('받다 만 AI 모델')), - ('update', tr('업데이트 · 설치하고 남은 파일')), - ('logs', tr('지난 작업 기록')), - ]) - FilterChip( - label: Text(label), - selected: s.cleanupPrechecked.contains(id), - onSelected: (on) => c.updateSettings((x) => x.cleanupPrechecked = [ - for (final y in x.cleanupPrechecked) if (y != id) y, - if (on) id, - ]), - ), - ]), - ), - SettingTile( - title: Text(tr('임시 파일 · 남은 조각 정리')), - subtitle: Text(tr('작업하다 남은 임시 파일 · 받다 만 다운로드 (.part 등) · 받다 만 AI 모델 · ' - '업데이트하고 남은 파일 · 지난 작업 기록을 찾아 지웁니다. ' - '받은 동영상 · 만든 MKV · 자막은 지우지 않고, 진행 중인 작업 · 다운로드가 쓰는 것은 건너뜁니다')), - isThreeLine: true, - trailing: FilledButton.icon( - onPressed: () => showCleanup(context, c, AppScope.maybeOf(context)?.downloads), - icon: const Icon(Icons.cleaning_services_outlined, size: 18), - label: Text(tr('정리…')), - ), - ), - ]), - _group('about', Icons.info_outline, tr('프로그램 정보'), [ - // 164: 지금 버전과 [최신 버전 확인] (결과는 버튼 옆에) - SettingTile( - leading: Icon(Icons.info_outline, color: JjColors.accent), - title: FutureBuilder( - future: c.services.updater?.currentVersion() ?? Future.value(''), - builder: (_, v) => Text(v.data == null || v.data!.isEmpty ? 'JJ_MKVMaker' : 'JJ_MKVMaker v${v.data}'), - ), - trailing: c.services.updater == null ? null : UpdateCheckButton(c: c), - ), - if (desk) ...[ - SettingTile( - title: Text(tr('필수 프로그램 점검')), - subtitle: Text(tr('FFmpeg · yt-dlp · aria2 · Deno 가 없으면 내려받아 설치합니다')), - trailing: OutlinedButton( - onPressed: () => checkRequiredTools(context, c.services.shell, quietIfOk: false), - child: Text(tr('점검')), - ), - ), - ], - SettingTile( - title: Text(tr('오픈 소스 라이선스')), - subtitle: Text(tr('포함된 구성 요소와 라이선스 (GPL · LGPL 원문 포함)')), - // 64: Android 에서도 (앱 안에서 연다) - trailing: Wrap(spacing: 6, runSpacing: 6, children: [ - OutlinedButton( - onPressed: () => showNoticesDocument(context), - child: Text(tr('고지 문서')), - ), - OutlinedButton( - onPressed: () => showLicensePage(context: context, applicationName: 'JJ_MKVMaker'), - child: Text(tr('패키지 라이선스')), - ), - ]), - ), - ]), + _group('run', Icons.power_settings_new, tr('실행 · 종료'), [RunSettingsSection(c: c, desk: desk)]), + _group('cleanup', Icons.cleaning_services_outlined, tr('저장 공간 정리'), [CleanupSettingsSection(c: c, desk: desk)]), + _group('about', Icons.info_outline, tr('프로그램 정보'), [AboutSettingsSection(c: c, desk: desk)]), ]), ), ), @@ -1441,45 +154,6 @@ class _SettingsPageState extends State { ); } - /// 이동 버튼 하나: 표시 이름 (바로 고침) · 폴더 · 순서 올리기 · 폴더 바꾸기 · 지우기 - Widget _moveTargetTile(int i, MoveTarget t) { - final s = c.settings; - void update(List Function(List) f) => - c.updateSettings((x) => x.moveTargets = f([...x.moveTargets])); - return SettingTile( - key: ValueKey('move_${i}_${t.dir}'), - leading: Icon(Icons.drive_file_move_outline, color: JjColors.accent), - title: TextFormField( - initialValue: t.name, - decoration: InputDecoration(isDense: true, labelText: tr('표시 이름')), - onChanged: (v) => update((l) => l..[i] = t.copyWith(name: v.trim().isEmpty ? tr('이동') : v.trim())), - ), - subtitle: Padding( - padding: const EdgeInsets.only(top: 4), - child: Text(t.dir, maxLines: 2, overflow: TextOverflow.ellipsis), - ), - trailing: Row(mainAxisSize: MainAxisSize.min, children: [ - IconButton( - tooltip: tr('위로'), - icon: const Icon(Icons.arrow_upward, size: 18), - onPressed: i == 0 ? null : () => update((l) => l..insert(i - 1, l.removeAt(i))), - ), - OutlinedButton( - onPressed: () async { - final d = await _pickDir(tr('이동 버튼의 폴더'), t.dir); - if (d != null) update((l) => l..[i] = t.copyWith(dir: d)); - }, - child: Text(tr('폴더 선택')), - ), - IconButton( - tooltip: tr('이 이동 버튼 지우기'), - icon: const Icon(Icons.delete_outline), - onPressed: s.moveTargets.length > i ? () => update((l) => l..removeAt(i)) : null, - ), - ]), - ); - } - /// 묶음 이름 → 위치 (바로가기 버튼으로 그 묶음까지 스크롤) final _groupKeys = {}; @@ -1567,131 +241,4 @@ class _SettingsPageState extends State { ]), ), ); - - /// 묶음 안의 작은 제목 - /// 앱 아이콘 고르기: 그림을 누르면 바로 바뀐다 - Widget _appIconTile(AppController c, bool desk) { - final now = appIconOf(c.settings.appIcon); - return SettingTile( - title: Text(tr('앱 아이콘')), - subtitle: Padding( - padding: const EdgeInsets.only(top: 4), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(desk - ? tr('창 · 작업 표시줄 · 트레이와 앱 안 왼쪽 위 버튼의 아이콘입니다 (실행 파일 · 바탕 화면 바로 가기 아이콘은 그대로)') - : tr('앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다')), - const SizedBox(height: 8), - Wrap(spacing: 10, runSpacing: 10, children: [ - for (final id in appIconIds) - Tooltip( - message: id == appIconIds.first ? '${_appIconName(id)} (${tr('기본')})' : _appIconName(id), - child: InkWell( - borderRadius: BorderRadius.circular(14), - onTap: id == now - ? null - : () { - c.updateSettings((x) => x.appIcon = id); - c.services.shell.setAppIcon(id); - }, - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(14), - border: Border.all(color: id == now ? JjColors.accent : Colors.transparent, width: 3), - ), - child: Image.asset(appIconPreviewAsset(id), width: 56, height: 56, filterQuality: FilterQuality.medium), - ), - ), - ), - ]), - ]), - ), - ); - } - - static String _appIconName(String id) => switch (id) { - 'yellow' => tr('노랑'), - 'black' => tr('검정'), - 'film_jj' => tr('필름 + JJ'), - 'film' => tr('필름'), - _ => tr('처음 아이콘 (파랑)'), - }; - - Widget _subTitle(String t) => Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: Text(t, style: TextStyle(fontSize: 13, color: JjColors.accent, fontWeight: FontWeight.w600)), - ); - - - Widget _textTile(String title, String value, String hint, ValueChanged onSave, - {bool obscure = false}) => - SettingTile( - title: Text(title), - subtitle: Text(hint), - trailing: SizedBox( - width: 320, - child: TextFormField( - initialValue: value, - obscureText: obscure, - decoration: const InputDecoration(isDense: true, border: OutlineInputBorder()), - // 입력칸을 벗어나거나 Enter 를 누르면 저장 - onFieldSubmitted: onSave, - onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), - onChanged: onSave, - ), - ), - ); - - Widget _folderTile({ - required String title, - required String? value, - required String defaultText, - required VoidCallback onPick, - required VoidCallback onReset, - }) => - SettingTile( - title: Text(title), - subtitle: Text(value ?? defaultText, maxLines: 2, overflow: TextOverflow.ellipsis), - trailing: Row(mainAxisSize: MainAxisSize.min, children: [ - OutlinedButton(onPressed: onPick, child: Text(tr('폴더 선택'))), - if (value != null) ...[ - const SizedBox(width: 6), - TextButton(onPressed: onReset, child: Text(tr('기본값'))), - ], - ]), - ); -} - -/// X-plore 비교 5-2: 시스템 폴더 · 루트 보기를 켤 때 경고 (체크해야 [켜기]). 켜면 true -Future confirmReadOnlyView(BuildContext context, {required IconData icon, required String title, required String body}) async { - final ok = await showDialog( - context: context, - builder: (ctx) { - var sure = false; - return StatefulBuilder( - builder: (ctx, set) => AlertDialog( - scrollable: true, - icon: Icon(icon, color: JjColors.warning), - title: Text(title), - content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(body), - const SizedBox(height: 6), - Text(tr('이 앱은 읽기만 합니다 - 쓰기 · 지우기 · 이름 바꾸기 · 권한 바꾸기는 하지 않습니다. 꺼낸 (복사한) 파일은 고른 곳에만 둡니다.')), - CheckboxListTile( - contentPadding: EdgeInsets.zero, - controlAffinity: ListTileControlAffinity.leading, - value: sure, - onChanged: (x) => set(() => sure = x ?? false), - title: Text(tr('위 내용을 알고 켭니다')), - ), - ]), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), - FilledButton(onPressed: sure ? () => Navigator.pop(ctx, true) : null, child: Text(tr('켜기'))), - ], - ), - ); - }, - ); - return ok == true; } diff --git a/test/settings_sections_contract_test.dart b/test/settings_sections_contract_test.dart new file mode 100644 index 0000000..ae88e10 --- /dev/null +++ b/test/settings_sections_contract_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/settings/app_sections.dart'; +import 'package:jj_mkvmaker/ui/settings/browser_section.dart'; +import 'package:jj_mkvmaker/ui/settings/files_section.dart'; +import 'package:jj_mkvmaker/ui/settings/general_sections.dart'; +import 'package:jj_mkvmaker/ui/settings/media_sections.dart'; +import 'package:jj_mkvmaker/ui/settings/settings_tiles.dart'; +import 'package:jj_mkvmaker/ui/settings/transfer_sections.dart'; + +/// 설정 B 약속 시험: lib/ui/settings/ 의 분야 · 공용 줄 (실제 화면 · 실제 설정 - 저장소 없음) +void main() { + AppController app() => AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + + Future show(WidgetTester t, Widget w) async { + t.view.physicalSize = const Size(1200, 4000); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + await t.pumpWidget(MaterialApp(home: Scaffold(body: SingleChildScrollView(child: w)))); + await t.pump(); + } + + final sections = { + 'general': (c, d) => GeneralSettingsSection(c: c, desk: d), + 'display': (c, d) => DisplaySettingsSection(c: c, desk: d), + 'mkv': (c, d) => MkvSettingsSection(c: c, desk: d), + 'subtitle': (c, d) => SubtitleSettingsSection(c: c, desk: d), + 'play': (c, d) => PlaySettingsSection(c: c, desk: d), + 'browser': (c, d) => BrowserSettingsSection(c: c, desk: d), + 'files': (c, d) => FilesSettingsSection(c: c, desk: d), + 'rsync': (c, d) => RsyncSettingsSection(c: c, desk: d), + 'download': (c, d) => DownloadSettingsSection(c: c, desk: d), + 'run': (c, d) => RunSettingsSection(c: c, desk: d), + 'cleanup': (c, d) => CleanupSettingsSection(c: c, desk: d), + 'about': (c, d) => AboutSettingsSection(c: c, desk: d), + }; + for (final e in sections.entries) { + testWidgets('분야 ${e.key}: PC · Android 갈래 모두 그려짐 (줄이 하나 이상)', (t) async { + for (final desk in [true, false]) { + await show(t, e.value(app(), desk)); + expect(find.byType(ListTile).evaluate().length + find.byType(SwitchListTile).evaluate().length + find.byType(Text).evaluate().length, + greaterThan(0)); + } + }); + } + + testWidgets('일반: 스위치를 바꾸면 바로 설정에 (업데이트로 바뀐 기본값 알림)', (t) async { + final c = app(); + final before = c.settings.migrationNotice; + await show(t, GeneralSettingsSection(c: c, desk: true)); + await t.tap(find.widgetWithText(SwitchListTile, '업데이트로 바뀐 기본값 알림')); + await t.pump(); + expect(c.settings.migrationNotice, !before); + }); + + testWidgets('공용 줄: 개수 고르기 · 글 넣는 줄 저장 · 폴더 줄 [기본값] 은 고른 것이 있을 때만', (t) async { + int? count; + final saved = []; + var reset = 0; + await show( + t, + Column(children: [ + CountSelector(value: 1, onChanged: (n) => count = n), + settingsTextTile('이름', '', '설명', saved.add), + settingsFolderTile(title: '폴더 A', value: null, defaultText: '처음 값', onPick: () {}, onReset: () => reset++), + settingsFolderTile(title: '폴더 B', value: r'D:\x', defaultText: '처음 값', onPick: () {}, onReset: () => reset++), + ])); + await t.tap(find.text(CountSelector.label(1))); + await t.pumpAndSettle(); + await t.tap(find.text(CountSelector.label(5)).last); + await t.pumpAndSettle(); + await t.enterText(find.byType(TextFormField), 'abc'); + expect([count, saved.last], [5, 'abc']); + expect(find.text('기본값'), findsOneWidget, reason: '고른 것이 있는 폴더 줄만'); + await t.tap(find.text('기본값')); + expect(reset, 1); + }); +}