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 createState() => _FilesSettingsSectionState(); } class _FilesSettingsSectionState extends State { 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.general.recycleOnDelete, onChanged: (v) => c.updateSettings((x) => x.general.recycleOnDelete = v), title: Text(tr('지우면 휴지통으로')), subtitle: Text(tr('켜면 휴지통으로 보내 되살릴 수 있습니다 (Shift+Delete 는 영구 삭제). 끄면 늘 영구 삭제로 묻습니다. ' '네트워크 드라이브 · USB 메모리는 휴지통이 없어 늘 영구 삭제입니다')), ), SettingTile( title: Text(tr('누르기')), subtitle: Text(tr('길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). ' 'Ctrl + 클릭: 여러 개 고르기')), trailing: DropdownButton( value: s.explorer.click, items: [ DropdownMenuItem( value: 'select', child: Text(desk ? tr('한 번: 선택 · 두 번: 실행 (기본)') : tr('한 번: 선택 · 두 번: 실행'))), DropdownMenuItem(value: 'open', child: Text(desk ? tr('한 번: 바로 실행') : tr('한 번: 바로 실행 (기본)'))), ], onChanged: (v) => c.updateSettings((x) => x.explorer.click = v!), ), ), // 82: 창 사이 끌어 놓기 (처음: 묻기) SettingTile( title: Text(tr('끌어 놓기 기본')), subtitle: Text(tr('다른 창으로 끌어 놓았을 때. 어느 쪽이든 복사 · 이동 확인 창 (같은 이름 등) 은 그대로 뜹니다')), trailing: DropdownButton( value: s.explorer.dropAction, 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.explorer.dropAction = v ?? 'ask'), ), ), // 84: 정렬을 폴더마다 기억 (처음: 끔) SettingTile( title: Text(tr('정렬을 폴더마다 기억')), subtitle: Text(tr('켜면 열 머리 · 정렬 기준에서 바꾼 정렬이 그 폴더에만 쓰입니다. 끄면 모든 폴더가 같은 정렬입니다')), trailing: Switch( value: s.explorer.sortPerFolder, onChanged: (v) => c.updateSettings((x) => x.explorer.sortPerFolder = 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.tools.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.tools.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.tools.rootEnabled, onChanged: (v) async { if (v && !await confirmReadOnlyView(context, icon: Icons.security, title: tr('루트 폴더 보기를 켤까요?'), body: tr('루트 (su) 권한으로 시스템 · 다른 앱의 폴더 전체 (/data 등) 를 볼 수 있게 됩니다. 다른 앱의 개인 자료 ' '(대화 · 로그인 정보 · 비밀번호 등) 도 보일 수 있습니다.'))) { return; } await c.updateSettings((x) => x.tools.rootEnabled = v); }, ), ), // X-plore 비교 5-1 ① (관리자 지시 10/21): 종류별 기본 정렬 (처음: 끔) SettingTile( title: Text(tr('종류별 기본 정렬')), subtitle: Text(tr('사진 · 동영상이 많은 폴더는 날짜 (새것 먼저), 음악이 많은 폴더는 이름으로. 그 폴더에서 정렬을 바꾸면 그 폴더는 바꾼 정렬을 기억합니다')), trailing: Switch( value: s.explorer.sortByKind, onChanged: (v) => c.updateSettings((x) => x.explorer.sortByKind = v), ), ), // X-plore 비교 5-1 ② (관리자 지시 10/21): 원격 썸네일 · 캐시 SettingTile( title: Text(tr('원격 썸네일')), subtitle: Text(tr('WebDAV · SFTP · SMB · FTP 의 그림 · 동영상을 X-plore 모양 목록에서 작은 그림으로. 보이는 줄만, 동시에 2개까지 받습니다')), trailing: DropdownButton( value: s.explorer.remoteThumbs, 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.explorer.remoteThumbs = v!), ), ), SettingTile( title: Text(tr('원격 썸네일 캐시')), subtitle: Text(tr('넘으면 오래 보지 않은 것부터 지웁니다')), trailing: DropdownButton( value: s.explorer.remoteThumbCacheMb, items: [ for (final v in {...ExplorerSettings.remoteThumbCacheChoices, s.explorer.remoteThumbCacheMb}.toList()..sort()) DropdownMenuItem(value: v, child: Text(v == 200 ? trf('{0}MB (기본)', [v]) : '${v}MB')), ], onChanged: (v) => c.updateSettings((x) => x.explorer.remoteThumbCacheMb = v!), ), ), RemoteThumbCacheTile(c: c), // 80: 폴더 크기 (필요할 때 계산하고 이번 실행 동안 기억) SettingTile( title: Text(tr('폴더 크기')), subtitle: Text(tr('폴더 안 파일 크기의 합을 목록에 보입니다. 큰 폴더 · NAS 는 읽는 데 오래 걸릴 수 있습니다. ' '길게 누르기 [폴더 크기 계산] 은 늘 됩니다')), trailing: DropdownButton( value: s.explorer.folderSize, 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.explorer.folderSize = v ?? 'sort'), ), ), // X-plore 비교 5-1 ⑥ (관리자 지시 10/21): 단축키 바꾸기 (키보드가 있는 PC 에서만 보임) if (desk) ListTile( title: Text(tr('파일 탐색기 단축키')), subtitle: Text(s.explorer.keyOverrides.isEmpty ? tr('스타일대로 (바꾼 것 없음)') : trf('바꾼 것 {0}개', [s.explorer.keyOverrides.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( value: s.explorer.style, items: [ for (final st in ExplorerStyle.values) DropdownMenuItem(value: st.name, child: Text(tr(st.label))), ], // 직접 골랐으면 예전 모양을 묻지 않는다 (E6) onChanged: (v) => c.updateSettings((x) => x.explorer ..style = v! ..styleAskPending = false), ), ), SettingTile( title: Text(tr('창 배치')), subtitle: Text(tr('파일 탐색기 위쪽 ⋮ 메뉴에서도 바꿀 수 있습니다 (좌우 · 위아래 · 버튼 줄 위치 · 버튼 구성)')), trailing: DropdownButton( value: s.explorer.layout, 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.explorer.layout = v!), ), ), // X-plore 비교 e: Android 앱 휴지통 보관 일 if (Platform.isAndroid) SettingTile( title: Text(tr('휴지통 보관')), subtitle: Text(tr('지운 것을 저장 장치의 앱 휴지통에 두었다가 이 날이 지나면 지웁니다. 끄면 바로 영구 삭제')), trailing: DropdownButton( value: const [0, 3, 7, 14, 30, 60].contains(s.explorer.androidTrashDays) ? s.explorer.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.explorer.androidTrashDays = v!), ), ), // X-plore 비교 ⚖ (관리자 결정): X-plore 스타일의 표시 동그라미 · 밀기 · 폴더 표시 규칙 SwitchListTile( title: Text(tr('X-plore 스타일: 선택 표시 늘 보이기')), subtitle: Text(tr('끄면 (처음 값) [선택] 을 켰을 때만 보입니다. 다른 스타일은 늘 선택 모드에서만')), value: s.explorer.alwaysMarks, onChanged: (v) => c.updateSettings((x) => x.explorer.alwaysMarks = v), ), SwitchListTile( title: Text(tr('X-plore 스타일: 옆으로 밀면 창 바꾸기')), subtitle: Text(tr('탐색기 안에서 밀면 다른 창으로. 다른 화면으로 가려면 위 막대에서 밉니다')), value: s.explorer.swipePanes, onChanged: (v) => c.updateSettings((x) => x.explorer.swipePanes = v), ), // X-plore 비교 c: 저장 장치 · 서버 · 즐겨찾기를 트리 맨 위에 (X-plore 스타일) SwitchListTile( title: Text(tr('저장 장치 · 서버 · 즐겨찾기를 트리 맨 위에')), subtitle: Text(tr('X-plore 스타일에서 X-plore 처럼 한 트리에 보입니다 (위 줄 탭 · ★ 는 그대로)')), value: s.explorer.rootsInTree, onChanged: (v) => c.updateSettings((x) => x.explorer.rootsInTree = v), ), // X-plore 비교 d: 지금 폴더를 다시 누르면 접기 SwitchListTile( title: Text(tr('펼친 폴더를 다시 누르면 접기')), subtitle: Text(tr('처음 값 켬. 끄면 접기 · 펼치기는 › 화살표로만 (두 번 눌러도 목록이 움직이지 않음)')), value: s.explorer.retapCollapse, onChanged: (v) => c.updateSettings((x) => x.explorer.retapCollapse = v), ), // X-plore 비교 40 (관리자 지시 10/20): 목록만의 글자 크기 · 줄 간격 (세 스타일 모두, 앱 화면 크기 · 기기 글자 크기와 곱함) SettingTile( title: Text(tr('목록 글자 크기')), subtitle: Text(tr('파일 탐색기 목록의 이름 · 크기 글자만. 앱 화면 크기 · 기기 글자 크기와 곱해집니다')), trailing: DropdownButton( value: s.explorer.textScale, items: [ for (var v = ExplorerSettings.textScaleMin; v <= ExplorerSettings.textScaleMax; v += 10) DropdownMenuItem(value: v, child: Text(v == 100 ? trf('{0}% (기본)', [v]) : '$v%')), if (s.explorer.textScale % 10 != 0) DropdownMenuItem(value: s.explorer.textScale, child: Text('${s.explorer.textScale}%')), ], onChanged: (v) => c.updateSettings((x) => x.explorer.textScale = v!), ), ), SettingTile( title: Text(tr('줄 간격')), subtitle: Text(tr('파일 탐색기 목록의 줄 높이. 좁게는 손가락으로 누르기 어려울 수 있습니다')), trailing: DropdownButton( value: s.explorer.rowSpacing, 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.explorer.rowSpacing = v!), ), ), // X-plore 숨기기: 목록에서 숨긴 것 보기 · 풀기 (관리자 결정 10/18) ListTile( title: Text(trf('목록에서 숨긴 항목 ({0}개)', [s.explorer.hiddenItems.length])), subtitle: Text(tr('파일 탐색기의 길게 누르기 [목록에서 숨기기] 로 숨긴 것. 파일은 그대로이고 목록에서만 빠집니다')), trailing: const Icon(Icons.chevron_right), enabled: s.explorer.hiddenItems.isNotEmpty, onTap: () => showHiddenItems(context, c), ), // X-plore 맞추기 (관리자 지시 10/20): 숨기기 전 확인 창 SwitchListTile( title: Text(tr('목록에서 숨기기 전에 묻기')), subtitle: Text(tr('무엇이 바뀌는지 (목록에서만 빠지고 파일은 그대로) 확인 창을 띄웁니다')), value: s.explorer.hideConfirm, onChanged: (v) => c.updateSettings((x) => x.explorer.hideConfirm = v), ), // X-plore 비교 a: 한 창일 때 복사 · 이동이 갈 곳 SettingTile( title: Text(tr('한 창일 때 복사 · 이동')), subtitle: Text(tr('숨은 다른 창으로: X-plore 처럼 위에 "갈 곳" 을 늘 보이고 그 폴더로 바로 복사 · 이동합니다')), trailing: DropdownButton( value: s.explorer.onePaneCopy, items: [ DropdownMenuItem(value: 'clip', child: Text(tr('담아 두고 붙여넣기 (기본)'))), DropdownMenuItem(value: 'other', child: Text(tr('숨은 다른 창으로'))), ], onChanged: (v) => c.updateSettings((x) => x.explorer.onePaneCopy = 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( value: RemoteIdle.choices.contains(s.remote.idleSeconds) ? s.remote.idleSeconds : 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.remote.idleSeconds = 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 createState() => _RemoteThumbCacheTileState(); } class _RemoteThumbCacheTileState extends State { int? _bytes; @override void initState() { super.initState(); unawaited(_measure()); } Future _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 confirmReadOnlyView(BuildContext context, {required IconData icon, required String title, required String body}) async { final ok = await showDialog( 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; }