361 lines
16 KiB
Dart
361 lines
16 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import 'dart:io';
|
|
|
|
import '../app/app_controller.dart';
|
|
import '../app/component_store.dart';
|
|
import '../app/components.dart';
|
|
import '../app/tor_bundle.dart';
|
|
import '../app/tor_service.dart';
|
|
import '../l10n/tr.dart';
|
|
import 'setting_tile.dart';
|
|
import 'theme.dart';
|
|
|
|
/// 환경 설정 > 파일 탐색기 > 보기: 그림으로 볼 확장자 · ZIP 만화 보기 (이미지 · PDF · ZIP 보기 컴포넌트)
|
|
class ViewerSettings extends StatelessWidget {
|
|
final AppController c;
|
|
const ViewerSettings({super.key, required this.c});
|
|
|
|
static const _choices = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'avif', 'heic', 'tif', 'tiff'];
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final s = c.settings;
|
|
if (!s.components.contains('viewer')) return const SizedBox();
|
|
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
|
SettingTile(
|
|
leading: const Icon(Icons.photo_library_outlined),
|
|
title: Text(tr('그림 보기 대상')),
|
|
subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
Text(tr('이 확장자의 파일을 두 번 누르면 그림 보기로 엽니다 (오른쪽을 누르면 다음, 왼쪽을 누르면 이전 그림).')),
|
|
const SizedBox(height: 6),
|
|
Wrap(spacing: 6, runSpacing: 6, children: [
|
|
for (final e in _choices)
|
|
FilterChip(
|
|
label: Text(e),
|
|
selected: s.imageExts.contains(e),
|
|
onSelected: (on) => c.updateSettings((x) => x.imageExts = [
|
|
for (final y in x.imageExts) if (y != e) y,
|
|
if (on) e,
|
|
]),
|
|
),
|
|
]),
|
|
]),
|
|
),
|
|
SwitchListTile(
|
|
secondary: const Icon(Icons.auto_stories_outlined),
|
|
value: s.zipComic,
|
|
onChanged: (v) => c.updateSettings((x) => x.zipComic = v),
|
|
title: Text(tr('ZIP · CBZ 를 만화로 보기')),
|
|
subtitle: Text(tr('켜면 두 번 눌렀을 때 안의 그림을 바로 넘겨 봅니다. 끄면 목록 (골라서 풀기) 을 엽니다.')),
|
|
),
|
|
// 119: 한 장에 두 쪽이 붙은 그림 (보기 화면 위쪽 막대에서도 바꿀 수 있음)
|
|
SettingTile(
|
|
leading: const Icon(Icons.vertical_split_outlined),
|
|
title: Text(tr('두 쪽 나눠 보기')),
|
|
subtitle: Text(tr('한 장에 두 쪽이 붙은 그림을 반씩 넘겨 봅니다 (읽는 방향의 쪽부터)')),
|
|
trailing: DropdownButton<String>(
|
|
value: s.readerSplit,
|
|
items: [
|
|
DropdownMenuItem(value: 'auto', child: Text(tr('자동 (가로로 긴 그림만)'))),
|
|
DropdownMenuItem(value: 'on', child: Text(tr('켜기'))),
|
|
DropdownMenuItem(value: 'off', child: Text(tr('끄기'))),
|
|
],
|
|
onChanged: (v) => c.updateSettings((x) => x.readerSplit = v!),
|
|
),
|
|
),
|
|
// X-plore 맞추기 (관리자 지시 10/20): 아래 갤러리 줄 (보기 화면 위 막대에서도 켜고 끔)
|
|
SwitchListTile(
|
|
secondary: const Icon(Icons.view_carousel_outlined),
|
|
value: s.readerGallery,
|
|
onChanged: (v) => c.updateSettings((x) => x.readerGallery = v),
|
|
title: Text(tr('그림 보기 아래 갤러리 줄')),
|
|
subtitle: Text(tr('그림을 볼 때 아래에 작은 그림 줄을 보입니다 (누르면 그 장으로)')),
|
|
),
|
|
// X-plore 맞추기 (관리자 지시 10/20): 볼륨 키로 넘기기 (Android)
|
|
if (Platform.isAndroid)
|
|
SwitchListTile(
|
|
secondary: const Icon(Icons.volume_down_outlined),
|
|
value: s.readerVolumeKeys,
|
|
onChanged: (v) => c.updateSettings((x) => x.readerVolumeKeys = v),
|
|
title: Text(tr('볼륨 키로 넘기기')),
|
|
subtitle: Text(tr('보기 화면에서 볼륨 아래 = 다음 장, 위 = 이전 장 (동영상 · 다른 화면의 볼륨은 그대로)')),
|
|
),
|
|
// 117: 계속 보기 간격
|
|
SettingTile(
|
|
leading: const Icon(Icons.slideshow_outlined),
|
|
title: Text(tr('계속 보기 간격')),
|
|
subtitle: Text(tr('보기 화면의 [계속 보기] 가 다음 장으로 넘기는 간격')),
|
|
trailing: DropdownButton<int>(
|
|
value: s.readerAutoSeconds,
|
|
items: [for (var n = 1; n <= 60; n++) DropdownMenuItem(value: n, child: Text(trf('{0}초', [n])))],
|
|
onChanged: (v) => c.updateSettings((x) => x.readerAutoSeconds = v!),
|
|
),
|
|
),
|
|
]);
|
|
}
|
|
}
|
|
|
|
/// 내려받아 설치하는 컴포넌트 켜기 (진행 창 · 취소) · 끄기 (지우기)
|
|
Future<void> _setDownloaded(BuildContext context, AppComponent x, bool on) async {
|
|
final c = (context.findAncestorWidgetOfExactType<ComponentSettings>())!.c;
|
|
final store = ComponentStore.shared;
|
|
if (!on) {
|
|
final ok = await showDialog<bool>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
scrollable: true,
|
|
title: Text(trf('{0} 제거', [tr(x.name)])),
|
|
content: Text(tr('내려받은 변환기 파일을 지웁니다. 다시 켜면 다시 내려받습니다.')),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
|
|
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('지우기'))),
|
|
],
|
|
),
|
|
);
|
|
if (ok != true) return;
|
|
await store.remove(x.id);
|
|
await c.updateSettings((v) => v.components = [for (final id in v.components) if (id != x.id) id]);
|
|
return;
|
|
}
|
|
final step = ValueNotifier<(String, double?)>((tr('목록을 받는 중'), null));
|
|
Object? error;
|
|
var done = false;
|
|
if (!context.mounted) return;
|
|
final dialog = showDialog<void>(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (ctx) => AlertDialog(
|
|
scrollable: true,
|
|
title: Text(trf('{0} 설치', [tr(x.name)])),
|
|
content: ValueListenableBuilder<(String, double?)>(
|
|
valueListenable: step,
|
|
builder: (_, v, _) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
Text(v.$1),
|
|
const SizedBox(height: 10),
|
|
LinearProgressIndicator(value: v.$2),
|
|
]),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () {
|
|
store.cancel();
|
|
},
|
|
child: Text(tr('취소')),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
try {
|
|
// 단계 글 ("받는 중: 파일") 은 ComponentStore 가 화면 언어로 만들어 보낸다 (59)
|
|
await store.install(x.id, onProgress: (s, d) => step.value = (s, d));
|
|
done = true;
|
|
} catch (e) {
|
|
error = e;
|
|
}
|
|
if (context.mounted) Navigator.of(context).pop();
|
|
await dialog;
|
|
step.dispose();
|
|
if (done) {
|
|
await c.updateSettings((v) => v.components = [for (final id in v.components) if (id != x.id) id, x.id]);
|
|
}
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(
|
|
content: Text(done ? trf('{0} 을(를) 설치했습니다.', [tr(x.name)]) : trf('설치하지 못했습니다: {0}', [error]))));
|
|
}
|
|
}
|
|
|
|
/// 환경 설정 > 컴포넌트: 기능 묶음 설치 (켜기) · 제거 (끄기), 화면 순서 (끌어서 바꿈), 좌우로 밀어 화면 이동.
|
|
/// 화면이 하나만 남으면 위쪽 이동 버튼 줄에는 그것만 보인다.
|
|
class ComponentSettings extends StatelessWidget {
|
|
final AppController c;
|
|
const ComponentSettings({super.key, required this.c});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final s = c.settings;
|
|
final installed = s.components;
|
|
final pages = AppComponent.pages(installed, s.navOrder);
|
|
|
|
Future<void> setInstalled(AppComponent x, bool on) async {
|
|
if (x.needsDownload) return _setDownloaded(context, x, on);
|
|
if (x.id == TorBundle.id) return _setTor(context, c, on);
|
|
// 화면 있는 것이 하나도 남지 않게는 못 한다
|
|
if (!on && x.page && pages.length <= 1 && pages.first.id == x.id) {
|
|
ScaffoldMessenger.maybeOf(context)
|
|
?.showSnackBar(SnackBar(content: Text(tr('화면이 있는 컴포넌트는 하나 이상 남아 있어야 합니다.'))));
|
|
return;
|
|
}
|
|
await c.updateSettings((v) => v.components = [
|
|
for (final id in v.components) if (id != x.id) id,
|
|
if (on) x.id,
|
|
]);
|
|
}
|
|
|
|
// [from] 번째 화면을 [to] 자리로 (설치하지 않은 화면은 원래 순서대로 뒤에)
|
|
void move(int from, int to) {
|
|
final ids = [for (final p in pages) p.id];
|
|
ids.insert(to, ids.removeAt(from));
|
|
// 아직 켜지 않은 Tor 는 순서에 넣지 않는다 (켜면 웹 브라우저 바로 뒤에 - AppComponent.fullOrder).
|
|
// 225: 이 판이 모르는 화면 (다른 판의 것) 은 지우지 않고 그 자리에
|
|
c.updateSettings((v) => v.navOrder = AppComponent.reorder(v.navOrder,
|
|
[...ids, ...AppComponent.defaultOrder.where((x) => !ids.contains(x) && x != AppComponent.tor.id)]));
|
|
}
|
|
|
|
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
|
SwitchListTile(
|
|
value: s.swipeNav,
|
|
onChanged: (v) => c.updateSettings((x) => x.swipeNav = v),
|
|
title: Text(tr('좌우로 밀어 화면 이동')),
|
|
subtitle: Text(tr('화면 가운데를 왼쪽으로 밀면 다음 화면, 오른쪽으로 밀면 이전 화면 (마지막 다음은 처음으로).')),
|
|
),
|
|
// 102 · 66: 끝에서 처음으로 돌기 (기본 켜짐)
|
|
SwitchListTile(
|
|
value: s.swipeWrap,
|
|
onChanged: s.swipeNav ? (v) => c.updateSettings((x) => x.swipeWrap = v) : null,
|
|
title: Text(tr('끝에서 처음으로 돌기')),
|
|
subtitle: Text(tr('마지막 화면에서 더 밀면 첫 화면으로 (끄면 끝에서 멈춤)')),
|
|
),
|
|
for (final x in AppComponent.all)
|
|
SettingTile(
|
|
leading: Icon(x.icon, color: installed.contains(x.id) ? JjColors.accent : JjColors.textDim),
|
|
title: Text(tr(x.name)),
|
|
subtitle: Text(x.needsDownload
|
|
? '${tr(x.description)}\n${Platform.isWindows ? tr('켜면 변환기를 내려받아 설치합니다 (약 420MB). 끄면 지웁니다.') : tr('Windows 에서만 쓸 수 있습니다. 이 기기에서는 문서를 다른 앱으로 엽니다.')}'
|
|
: x.id == TorBundle.id
|
|
? '${tr(x.description)}\n${_torNote(installed.contains(x.id))}'
|
|
: tr(x.description)),
|
|
trailing: Switch(
|
|
value: installed.contains(x.id),
|
|
onChanged: x.needsDownload && !Platform.isWindows ? null : (v) => setInstalled(x, v),
|
|
),
|
|
),
|
|
const Divider(),
|
|
SettingTile(
|
|
leading: const Icon(Icons.swap_vert),
|
|
title: Text(tr('화면 순서')),
|
|
subtitle: Text(tr('위쪽 이동 버튼과 좌우로 밀기의 순서입니다. ▲ ▼ 로 바꿉니다.')),
|
|
),
|
|
for (final (i, p) in pages.indexed)
|
|
ListTile(
|
|
key: ValueKey(p.id),
|
|
dense: true,
|
|
leading: Text('${i + 1}', style: const TextStyle(color: JjColors.textDim)),
|
|
title: Row(children: [Icon(p.icon, size: 18), const SizedBox(width: 10), Flexible(child: Text(tr(p.name)))]),
|
|
trailing: Row(mainAxisSize: MainAxisSize.min, children: [
|
|
IconButton(
|
|
tooltip: tr('위로'),
|
|
icon: const Icon(Icons.arrow_upward, size: 18),
|
|
onPressed: i == 0 ? null : () => move(i, i - 1),
|
|
),
|
|
IconButton(
|
|
tooltip: tr('아래로'),
|
|
icon: const Icon(Icons.arrow_downward, size: 18),
|
|
onPressed: i == pages.length - 1 ? null : () => move(i, i + 1),
|
|
),
|
|
]),
|
|
),
|
|
]);
|
|
}
|
|
}
|
|
|
|
/// Tor 컴포넌트 안내 줄: 판 · 받을 크기 (Windows) · 앱에 들어 있음 (Android)
|
|
String _torNote(bool on) {
|
|
if (Platform.isWindows) {
|
|
final v = TorBundle.installedVersion();
|
|
return on && v != null
|
|
? trf('설치됨: Tor Expert Bundle {0} (Tor 공식 배포). 끄면 지웁니다.', [v])
|
|
: trf('켜면 Tor 공식 배포처에서 Tor Expert Bundle {0} (tor {1}) 을 내려받습니다 (약 {2}MB, SHA-256 확인). 끄면 지웁니다.',
|
|
[TorBundle.version, TorBundle.torVersion, TorBundle.approxDownloadMb]);
|
|
}
|
|
return trf('앱에 들어 있습니다 (Tor {0}, tor {1}). 켜도 Tor 화면을 열 때까지 아무것도 시작하지 않습니다.',
|
|
[TorBundle.version, TorBundle.torVersion]);
|
|
}
|
|
|
|
/// Tor 켜기 · 끄기. Windows 는 켤 때 공식 번들을 받아 설치 (진행 창 · 취소), 끌 때 Tor 를 멈추고 지운다.
|
|
/// Android 는 APK 에 들어 있는 tor 가 있는지만 확인하고 켠다 (처음 꺼짐)
|
|
Future<void> _setTor(BuildContext context, AppController c, bool on) async {
|
|
final name = tr(AppComponent.tor.name);
|
|
if (!on) {
|
|
await TorService.instance.stop();
|
|
if (Platform.isWindows) {
|
|
if (!context.mounted) return;
|
|
final ok = await showDialog<bool>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
scrollable: true,
|
|
title: Text(trf('{0} 제거', [name])),
|
|
content: Text(tr('내려받은 Tor 파일을 지웁니다. 다시 켜면 다시 내려받습니다.')),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
|
|
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('지우기'))),
|
|
],
|
|
),
|
|
);
|
|
if (ok != true) return;
|
|
try {
|
|
await TorBundle.removeWindows();
|
|
} catch (e) {
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(trf('지우지 못했습니다: {0}', [e]))));
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
await c.updateSettings((v) => v.components = [for (final id in v.components) if (id != TorBundle.id) id]);
|
|
return;
|
|
}
|
|
if (Platform.isAndroid) {
|
|
if (await TorBundle.androidExe() == null) {
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.maybeOf(context)
|
|
?.showSnackBar(SnackBar(content: Text(tr('이 앱 (APK) 에는 Tor 가 들어 있지 않습니다.'))));
|
|
}
|
|
return;
|
|
}
|
|
} else if (TorBundle.windowsExe == null || TorBundle.installedVersion() != TorBundle.version) {
|
|
final step = ValueNotifier<(String, double?)>((tr('준비 중'), null));
|
|
var cancel = false;
|
|
Object? error;
|
|
if (!context.mounted) return;
|
|
final dialog = showDialog<void>(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (ctx) => AlertDialog(
|
|
scrollable: true,
|
|
title: Text(trf('{0} 설치', [name])),
|
|
content: ValueListenableBuilder<(String, double?)>(
|
|
valueListenable: step,
|
|
builder: (_, v, _) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
Text(v.$1),
|
|
const SizedBox(height: 10),
|
|
LinearProgressIndicator(value: v.$2),
|
|
]),
|
|
),
|
|
actions: [TextButton(onPressed: () => cancel = true, child: Text(tr('취소')))],
|
|
),
|
|
);
|
|
try {
|
|
c.note(trf('Tor 내려받기 시작: {0}', [TorBundle.urls(TorBundle.windowsFile).first]));
|
|
await TorBundle.installWindows(onProgress: (s, d) => step.value = (s, d), isCancelled: () => cancel);
|
|
c.note(trf('Tor 설치 완료: {0}', [TorBundle.dir.path]));
|
|
} catch (e) {
|
|
error = e;
|
|
c.note(trf('Tor 설치 실패: {0}', [e]));
|
|
}
|
|
if (context.mounted) Navigator.of(context).pop();
|
|
await dialog;
|
|
step.dispose();
|
|
if (error != null) {
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(trf('설치하지 못했습니다: {0}', [error]))));
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
await c.updateSettings((v) => v.components = [for (final id in v.components) if (id != TorBundle.id) id, TorBundle.id]);
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(trf('{0} 을(를) 설치했습니다.', [name]))));
|
|
}
|
|
}
|