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 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( 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 confirmAppHide(BuildContext context, ExplorerController ctl, List targets) async { var dontAsk = false; final ok = await showDialog( 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.explorerHideConfirm = false); return true; } /// X-plore 맞추기 (관리자 지시 10/20): 폴더에 빈 .nomedia 파일을 넣거나 지운다 (Android 갤러리 · 음악 앱에서 숨기기 · 다시 보이기). /// 그 폴더의 파일은 그대로 - 무엇이 바뀌는지 먼저 묻는다. 갤러리에 반영되는 때는 그 앱이 다시 읽을 때다 Future setNomedia(BuildContext context, ExplorerController ctl, ExplorerPane pane, String dir, {required bool on}) async { final ok = await showDialog( 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 { 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 createState() => MenuIconRowState(); } class MenuIconRowState extends State { @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)), ), ]), ), ), ), ), ), ]), ); }