245 lines
11 KiB
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.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 = <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.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<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,
|
|
]),
|
|
),
|
|
);
|
|
}
|