215 lines
10 KiB
Dart
215 lines
10 KiB
Dart
import 'dart:io';
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:path/path.dart' as p;
|
||
|
||
import '../../core/file_attributes.dart' show readFileAttributes;
|
||
import '../../core/file_ops.dart' show FileEntry, FileOps, formatSize;
|
||
import '../../core/ftp.dart' show FtpRegistry;
|
||
import '../../core/name_clash.dart' show fileDateText;
|
||
import '../../core/playlist.dart' show isVideoFile;
|
||
import '../../core/sftp.dart' show SftpRegistry;
|
||
import '../../core/smb.dart' show SmbRegistry;
|
||
import '../../core/vfs.dart';
|
||
import '../../core/webdav.dart' show DavRegistry;
|
||
import '../../l10n/tr.dart';
|
||
import '../theme.dart';
|
||
import 'explorer_controller.dart';
|
||
import 'explorer_pane.dart';
|
||
import 'file_thumbs.dart' show VideoMeta;
|
||
|
||
// 약속 (E5d-5): 줄 메뉴의 항목 하나씩 하는 일 (정보 · 경로 복사 · 목록에서 숨기기 확인 · .nomedia) 과 메뉴 위 아이콘 줄.
|
||
// 묻는 창은 [취소] 면 아무것도 바꾸지 않는다. 못 하면 까닭을 알린다 (Host)
|
||
|
||
/// 77: [경로 복사] 에 넣을 것 - 실제 경로 (Android 도 "SD 카드 ›" 가 아닌 /storage/…), WebDAV 는 서버 주소 (아이디 · 비밀번호는 빼고)
|
||
String copyablePath(ExplorerController ctl, String path) {
|
||
if (!isDav(path)) return path;
|
||
final d = DavPath.parse(path);
|
||
// 88: SFTP 는 sftp://아이디@서버:포트/경로 (시작 폴더를 비웠으면 홈 기준 ~/)
|
||
if (d.isSftp) {
|
||
final s = SftpRegistry.server(d.server);
|
||
if (s == null) return ctl.displayPath(path);
|
||
final base = s.root.isEmpty ? '/~' : (s.root.startsWith('/') ? s.root : '/${s.root}');
|
||
return 'sftp://${s.user.isEmpty ? '' : '${s.user}@'}${s.host}${s.port == 22 ? '' : ':${s.port}'}$base${d.isRoot ? '' : d.rel}';
|
||
}
|
||
// l: FTP 는 ftp://아이디@서버:포트/경로 (암호화 990 은 ftps://, 비밀번호 없음)
|
||
if (d.isFtp) {
|
||
final s = FtpRegistry.server(d.server);
|
||
if (s == null) return ctl.displayPath(path);
|
||
final implicit = s.security == 'implicit';
|
||
final r = s.root.trim().replaceAll(RegExp(r'^/+|/+$'), '');
|
||
return '${implicit ? 'ftps' : 'ftp'}://${s.user.isEmpty ? '' : '${Uri.encodeComponent(s.user)}@'}${s.host}'
|
||
'${s.port == (implicit ? 990 : 21) ? '' : ':${s.port}'}${r.isEmpty ? '' : '/$r'}${d.isRoot ? '' : d.rel}';
|
||
}
|
||
// 88: SMB 는 smb://서버/공유/경로 (아이디 · 비밀번호 없음)
|
||
if (d.isSmb) {
|
||
final s = SmbRegistry.server(d.server);
|
||
if (s == null) return ctl.displayPath(path);
|
||
final r = s.root.trim().replaceAll(r'\', '/').replaceAll(RegExp(r'^/+|/+$'), '');
|
||
return 'smb://${s.host}${s.port == 445 ? '' : ':${s.port}'}/${s.share}${r.isEmpty ? '' : '/$r'}${d.isRoot ? '' : d.rel}';
|
||
}
|
||
try {
|
||
return DavRegistry.client(d.server).uriOf(d.rel).replace(userInfo: '').toString();
|
||
} catch (_) {
|
||
return ctl.displayPath(path);
|
||
}
|
||
}
|
||
|
||
/// 정보 창 - 경로 · 크기 · 만든 · 바뀐 날 · 속성 · 동영상 정보 (보기만)
|
||
Future<void> showFileInfo(BuildContext context, ExplorerController ctl, FileEntry e) async {
|
||
final size = e.isDir ? await FileOps.totalSize([e.path]) : e.size;
|
||
final meta = !e.isDir && !isDav(e.path) && isVideoFile(e.path) ? await VideoMeta.of(ctl.c, e.path) : null;
|
||
// 관리자 지시 10/18: 만든 때 · 숨김 · 읽기 전용 · 시스템 (보기만, 이 기기의 것만)
|
||
final attrs = isDav(e.path) ? null : readFileAttributes(e.path);
|
||
if (!context.mounted) return;
|
||
await showDialog<void>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
scrollable: true,
|
||
title: Text(e.name),
|
||
content: SelectableText([
|
||
e.path,
|
||
'${tr('크기')}: ${formatSize(size)}',
|
||
if (attrs?.created != null) '${tr('만든 날')}: ${fileDateText(attrs!.created!)}',
|
||
if (e.modified.year > 1) '${tr('바뀐 날')}: ${fileDateText(e.modified)}',
|
||
if (attrs != null) '${tr('속성')}: ${attrs.labels.isEmpty ? tr('없음') : attrs.labels.join(' · ')}',
|
||
if (meta != null && meta.isNotEmpty) '${tr('동영상')}: $meta',
|
||
].join('\n')),
|
||
actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('닫기')))],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// X-plore 숨기기: 목록에서 숨기기 전에 무엇인지 알리고 묻기 ([다음부터 묻지 않기] 는 설정에)
|
||
Future<bool> confirmAppHide(BuildContext context, ExplorerController ctl, List<String> targets) async {
|
||
var dontAsk = false;
|
||
final ok = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => StatefulBuilder(
|
||
builder: (ctx, set) => AlertDialog(
|
||
scrollable: true,
|
||
title: Text(targets.length > 1 ? trf('{0}개 목록에서 숨기기', [targets.length]) : tr('목록에서 숨기기')),
|
||
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
if (targets.length == 1) Text(vBasename(targets.single), style: const TextStyle(fontWeight: FontWeight.bold)),
|
||
const SizedBox(height: 6),
|
||
Text(tr('이 앱의 파일 탐색기 목록에서만 빠집니다. 파일은 지우거나 옮기거나 이름을 바꾸지 않고, 다른 앱 · 갤러리에서는 그대로 보입니다.')),
|
||
const SizedBox(height: 6),
|
||
Text(tr('목록 맨 아래의 [숨긴 항목 보이기] 나 환경 설정 > 파일 탐색기에서 다시 볼 수 있습니다.'),
|
||
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||
CheckboxListTile(
|
||
contentPadding: EdgeInsets.zero,
|
||
controlAffinity: ListTileControlAffinity.leading,
|
||
value: dontAsk,
|
||
onChanged: (v) => set(() => dontAsk = v ?? false),
|
||
title: 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 false;
|
||
if (dontAsk) await ctl.c.updateSettings((s) => s.explorer.hideConfirm = false);
|
||
return true;
|
||
}
|
||
|
||
/// X-plore 맞추기 (관리자 지시 10/20): 폴더에 빈 .nomedia 파일을 넣거나 지운다 (Android 갤러리 · 음악 앱에서 숨기기 · 다시 보이기).
|
||
/// 그 폴더의 파일은 그대로 - 무엇이 바뀌는지 먼저 묻는다. 갤러리에 반영되는 때는 그 앱이 다시 읽을 때다
|
||
Future<void> setNomedia(BuildContext context, ExplorerController ctl, ExplorerPane pane, String dir, {required bool on}) async {
|
||
final ok = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
scrollable: true,
|
||
title: Text(on ? tr('갤러리 · 음악 앱에서 숨기기 (.nomedia)') : tr('갤러리 · 음악 앱에 다시 보이기 (.nomedia 지우기)')),
|
||
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Text(vBasename(dir), style: const TextStyle(fontWeight: FontWeight.bold)),
|
||
const SizedBox(height: 6),
|
||
Text(on
|
||
? tr('이 폴더에 빈 .nomedia 파일을 만듭니다. 갤러리 · 음악 앱이 이 폴더 (안의 폴더 포함) 의 그림 · 동영상 · 음악을 보이지 않게 됩니다.')
|
||
: tr('이 폴더의 .nomedia 파일을 지웁니다. 갤러리 · 음악 앱이 이 폴더의 그림 · 동영상 · 음악을 다시 보이게 됩니다.')),
|
||
const SizedBox(height: 6),
|
||
Text(tr('폴더 안의 파일은 바꾸지 않습니다. 갤러리에 반영되기까지 잠깐 걸릴 수 있습니다.'),
|
||
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||
]),
|
||
actions: [
|
||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
|
||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(on ? tr('숨기기') : tr('다시 보이기'))),
|
||
],
|
||
),
|
||
);
|
||
if (ok != true || !context.mounted) return;
|
||
final f = File(p.join(dir, '.nomedia'));
|
||
try {
|
||
if (on) {
|
||
if (!await f.exists()) await f.writeAsBytes(const []);
|
||
} else if (await f.exists()) {
|
||
await f.delete();
|
||
}
|
||
} catch (err) {
|
||
ctl.host.snack(trf('바꾸지 못했습니다: {0}', [err]));
|
||
return;
|
||
}
|
||
// 관리자 지시 10/20: 갤러리가 바로 알도록 .nomedia 와 그 폴더를 다시 읽게 (넣을 때 · 지울 때 모두, 실패해도 조용히)
|
||
await ctl.c.services.shell.mediaScan([f.path, dir]);
|
||
await ctl.refreshAll([dir]);
|
||
pane.changed();
|
||
ctl.host.snack(on ? tr('갤러리 · 음악 앱에서 숨겼습니다 (.nomedia). 파일은 그대로입니다.') : tr('.nomedia 를 지웠습니다. 갤러리 · 음악 앱에 다시 보입니다.'));
|
||
}
|
||
|
||
/// 77: 길게 누르기 메뉴 위의 아이콘 줄 - (id, 아이콘, 짧은 이름, 풍선 도움말, 누를 수 있음).
|
||
/// 폰에서도 무엇인지 알 수 있게 아이콘 아래에 짧은 이름
|
||
class MenuIconRow extends PopupMenuEntry<String> {
|
||
final List<(String, IconData, String, String, bool)> items;
|
||
const MenuIconRow(this.items, {super.key});
|
||
|
||
@override
|
||
double get height => 60;
|
||
|
||
@override
|
||
bool represents(String? value) => false;
|
||
|
||
@override
|
||
State<MenuIconRow> createState() => MenuIconRowState();
|
||
}
|
||
|
||
class MenuIconRowState extends State<MenuIconRow> {
|
||
@override
|
||
Widget build(BuildContext context) => Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||
for (final (id, icon, label, tip, on) in widget.items)
|
||
Tooltip(
|
||
message: tip,
|
||
child: InkWell(
|
||
borderRadius: BorderRadius.circular(6),
|
||
onTap: on ? () => Navigator.pop(context, id) : null,
|
||
child: Semantics(
|
||
button: true,
|
||
enabled: on,
|
||
label: tip,
|
||
excludeSemantics: true,
|
||
child: SizedBox(
|
||
width: 56,
|
||
height: 56,
|
||
child: Opacity(
|
||
opacity: on ? 1 : 0.38,
|
||
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||
Icon(icon, size: 20),
|
||
const SizedBox(height: 2),
|
||
FittedBox(
|
||
fit: BoxFit.scaleDown,
|
||
child: Text(label, maxLines: 1, style: const TextStyle(fontSize: 11)),
|
||
),
|
||
]),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
]),
|
||
);
|
||
}
|