JJ_MKVMaker/lib/ui/settings_page.dart

245 lines
11 KiB
Dart

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<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends State<SettingsPage> {
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<void> _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.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.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 = <String, GlobalKey>{};
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.components.contains('aiimage')) &&
(id != 'tor' || c.settings.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<Widget> 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,
]),
),
);
}