JJ_MKVMaker/lib/ui/settings/files_section.dart

423 lines
25 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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<FilesSettingsSection> createState() => _FilesSettingsSectionState();
}
class _FilesSettingsSectionState extends State<FilesSettingsSection> {
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<String>(
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<String>(
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<String>(
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<int>(
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<String>(
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<String>(
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<String>(
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<int>(
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<int>(
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<String>(
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<String>(
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<int>(
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<RemoteThumbCacheTile> createState() => _RemoteThumbCacheTileState();
}
class _RemoteThumbCacheTileState extends State<RemoteThumbCacheTile> {
int? _bytes;
@override
void initState() {
super.initState();
unawaited(_measure());
}
Future<void> _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<bool> confirmReadOnlyView(BuildContext context, {required IconData icon, required String title, required String body}) async {
final ok = await showDialog<bool>(
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;
}