export 'settings/settings_tiles.dart' show CountSelector; import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import '../app/app_controller.dart'; import 'app_actions.dart'; import 'theme.dart'; import '../l10n/tr.dart'; import 'component_settings.dart'; import 'security_settings.dart'; import 'network_settings.dart'; import 'ai_settings.dart'; import '../app/master_lock.dart'; 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: 키보드가 열린 뒤 남는 높이가 이보다 작으면 설정 화면의 위 줄을 접는다 const double settingsCrampedHeight = 360; class SettingsPage extends StatefulWidget { final AppController c; /// 열자마자 이 묶음으로 스크롤 (예: Tor 화면의 [Tor 설정] → 'tor') final String? initialGroup; const SettingsPage({super.key, required this.c, this.initialGroup}); @override State createState() => _SettingsPageState(); } class _SettingsPageState extends State { AppController get c => widget.c; /// 124: 마스터 비밀번호를 넣기 전에는 설정을 보이지 않는다 (정해 두었고 묻는 때가 "물어보지 않기" 가 아니면) bool _locked = MasterLock.instance?.needsPrompt() ?? false; @override void initState() { super.initState(); if (_locked) WidgetsBinding.instance.addPostFrameCallback((_) => _unlock()); final g = widget.initialGroup; if (g != null) { WidgetsBinding.instance.addPostFrameCallback((_) { final ctx = _groupKeys[g]?.currentContext; if (ctx != null) Scrollable.ensureVisible(ctx, alignment: 0); }); } } Future _unlock() async { final lock = MasterLock.instance; if (lock == null || !mounted) return; // 128: 시작 창 · 배경 작업과 같은 창 하나로 (사용자가 연 것이니 취소한 적이 있어도 묻는다) final ok = await lock.ensure(force: true, reason: tr('환경 설정을 열려면 마스터 비밀번호를 넣으세요. 앱을 끌 때까지 다시 묻지 않습니다.')); if (mounted) setState(() => _locked = !ok && lock.needsPrompt()); } @override Widget build(BuildContext context) { if (_locked) { return Scaffold( body: Column(children: [ AppTopBar(nav: const AppNavButtons(), actions: AppActions(c: c, onSettingsPage: true), middle: Text(tr('환경 설정'))), Expanded( child: Center( child: Column(mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.lock_outline, size: 48, color: JjColors.textDim), const SizedBox(height: 12), Text(tr('마스터 비밀번호로 잠겨 있습니다')), const SizedBox(height: 12), FilledButton(onPressed: _unlock, child: Text(tr('마스터 비밀번호 넣기'))), ]), ), ), ]), ); } return ListenableBuilder( listenable: c, builder: (context, _) { final s = c.settings; // Android: 창 · 트레이 · 탐색기 · 다운로드 (yt-dlp · aria2) · 내장 Chrome 설정은 없음 final desk = !Platform.isAndroid; // 213: 폰 가로에서 키보드가 열리면 남는 높이가 작아 위 줄 둘 (막대 · 묶음 바로가기) 이 화면을 다 차지해 // 넣는 칸이 보이지 않았다 - 그동안은 두 줄을 접는다 (키보드를 닫으면 다시 보임) final mq = MediaQuery.of(context); final cramped = mq.viewInsets.bottom > 0 && mq.size.height - mq.viewInsets.bottom < settingsCrampedHeight; return Scaffold( body: Column(children: [ if (!cramped) AppTopBar( nav: const AppNavButtons(), actions: AppActions(c: c, onSettingsPage: true), middle: Row(children: [ Text(tr('환경 설정'), style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), const Spacer(), Flexible( // 251: 영어 등 긴 언어에서 잘리지 않게 두 줄까지 child: Text(tr('바꾸면 바로 저장됩니다'), maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: JjColors.textDim)), ), const SizedBox(width: 8), ]), ), // 묶음 바로가기 (누르면 그 묶음으로). 스크롤해도 위에 그대로 if (!cramped) Padding( padding: EdgeInsets.fromLTRB(isCompact(context) ? 8 : 24, 12, isCompact(context) ? 8 : 24, 0), child: _groupBar(desk), ), Expanded( // 모든 묶음을 한 번에 그린다 (ListView 는 화면 밖 묶음을 그리지 않아 바로가기로 갈 수 없었음) 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('일반'), [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('화면'), [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.display.components.contains('aiimage')) _group('ai', Icons.auto_awesome_outlined, tr('AI 그림'), [AiSettings(c: c)]), _group('play', Icons.play_circle_outline, tr('재생'), [PlaySettingsSection(c: c, desk: desk)]), _group('browser', Icons.public, tr('웹 브라우저'), [BrowserSettingsSection(c: c, desk: desk)]), // 사용자 요청 10/10: Tor 브라우저 · DNS 강제 if (s.display.components.contains('tor')) _group('tor', Icons.vpn_lock_outlined, tr('Tor 브라우저'), [TorSettings(c: c)]), _group('network', Icons.dns_outlined, tr('네트워크 · DNS'), [DnsSettings(c: c)]), _group('files', Icons.folder_copy_outlined, tr('파일 탐색기'), [FilesSettingsSection(c: c, desk: desk)]), // Rsync 화면: rsync 옵션 · 가져오기 · 실시간 동기화 (lsync) · 백그라운드로 실행 _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('실행 · 종료'), [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)]), ]), ), ), ]), ); }, ); } /// 묶음 이름 → 위치 (바로가기 버튼으로 그 묶음까지 스크롤) final _groupKeys = {}; static const _groupIds = [ 'general', 'security', 'components', 'display', 'mkv', 'subtitle', 'ai', 'play', 'browser', 'tor', 'network', 'files', 'rsync', 'download', 'run', // 'cleanup', 'about', ]; String _groupTitle(String id) => switch (id) { 'general' => tr('일반'), 'security' => tr('보안'), 'display' => tr('화면'), 'components' => tr('컴포넌트'), 'mkv' => tr('MKV 만들기'), 'subtitle' => tr('자막 (AI · 인터넷)'), 'ai' => tr('AI 그림'), 'play' => tr('재생'), 'browser' => tr('웹 브라우저'), 'tor' => tr('Tor 브라우저'), 'network' => tr('네트워크 · DNS'), 'files' => tr('파일 탐색기'), 'rsync' => 'Rsync', 'download' => tr('다운로드'), 'run' => tr('실행 · 종료'), 'cleanup' => tr('저장 공간 정리'), _ => tr('프로그램 정보'), }; /// 맨 위 바로가기: 묶음 이름을 누르면 그 묶음으로 스크롤 Widget _groupBar(bool desk) { final chips = [ for (final id in _groupIds) if ((id != 'run' || desk) && (id != 'ai' || c.settings.display.components.contains('aiimage')) && (id != 'tor' || c.settings.display.components.contains('tor'))) ActionChip( label: Text(_groupTitle(id), style: const TextStyle(fontSize: 13)), onPressed: () { final ctx = _groupKeys[id]?.currentContext; if (ctx != null) { // 그 묶음의 제목이 맨 위에 오게 Scrollable.ensureVisible(ctx, alignment: 0, duration: const Duration(milliseconds: 300), curve: Curves.easeOut); } }, ), ]; // 255: 좁은 화면 (세로 폰) 은 한 줄로 옆으로 밀게 - 여러 줄로 감기면 위에 늘 고정된 4줄이 화면 절반을 차지해 내용이 반만 보였다 if (isCompact(context)) { return Padding( padding: const EdgeInsets.only(bottom: 4), child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row(children: [ for (final (i, w) in chips.indexed) ...[if (i > 0) const SizedBox(width: 8), w], ]), ), ); } return Padding( padding: const EdgeInsets.only(bottom: 8), child: Wrap(spacing: 8, runSpacing: 8, children: chips), ); } /// 묶음 하나: 제목 (아이콘) 과 항목들을 한 카드에 Widget _group(String id, IconData icon, String title, List children) => Card( key: _groupKeys.putIfAbsent(id, GlobalKey.new), margin: const EdgeInsets.only(bottom: 16), color: JjColors.panel, clipBehavior: Clip.antiAlias, child: Padding( padding: const EdgeInsets.only(bottom: 8), child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Container( color: JjColors.panelHigh, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), child: Row(children: [ Icon(icon, size: 20, color: JjColors.accent), const SizedBox(width: 10), Text(title, key: ValueKey('group-title:$id'), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), ]), ), ...children, ]), ), ); }