import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart' show Clipboard, ClipboardData; import 'package:path/path.dart' as p; import '../../app/settings.dart' show FolderBookmark; import '../../core/app_hidden.dart'; import '../../core/archive_ops.dart' show archiveKindOf; import '../../core/file_ops.dart'; import '../../core/playlist.dart' show isAudioFile, isVideoFile; import '../../core/reader_sources.dart' show extOf, zipExtensions; import '../../core/vfs.dart'; import '../../l10n/tr.dart'; import '../archive_ui.dart' show createZipFlow; import '../file_tools.dart' show showFileHash, showFolderProperties; import '../notice.dart'; import '../sqlite_viewer.dart' show openSqliteViewer, sqliteExtensions; import '../theme.dart'; import '../vault_page.dart' show openVault; import 'explorer_buttons.dart'; import 'explorer_controller.dart'; import 'explorer_flows.dart'; import 'explorer_pane.dart'; import 'explorer_select.dart'; import 'open_flow.dart' show ExplorerOpener; import 'item_actions.dart'; /// 약속 (E5d-5): 줄을 길게 누르거나 오른쪽 클릭하면 뜨는 메뉴. 누른 것이 표시한 것 중 하나면 표시한 것 모두에 한다 (77). /// 위 아이콘 줄 (복사 · 이동 · 이름 변경 · 삭제 · 공유) + 상황별 항목 (Rsync 화면은 폴더에 맞는 것만, 압축 안은 새 파일을 만드는 것은 숨김). /// 고른 것은 흐름 모듈 (열기 · 복사 · 이동 · 지우기) 이나 [ExplorerCommands] 로 한다. 고르지 않으면 아무것도 안 함 class ExplorerItemMenu { ExplorerItemMenu(this.ctl, this.sel, this.cmd, this.flows, {required this.nomedia}); final ExplorerController ctl; final ExplorerSelect sel; final ExplorerCommands cmd; final ExplorerFlows flows; /// .nomedia 항목을 보일 기기인지 (Android) final bool Function() nomedia; Future show(BuildContext context, ExplorerPane pane, FileEntry e, Offset at) async { ctl ..active = ctl.panes.indexOf(pane) ..update(); pane.focused = e.path; pane.changed(); final video = !e.isDir && isVideoFile(e.path); final audio = !e.isDir && isAudioFile(e.path); final dav = isDav(e.path); // 그림 → PDF: 표시한 것 중 그림 (누른 것이 표시에 없으면 누른 것만) final viewer = ctl.c.settings.components.contains('viewer'); final picked = pane.sel.has(e.path) ? pane.sel.marked : [e.path]; final images = [for (final x in picked) if (ctl.c.settings.imageExts.contains(extOf(x))) x]; final ext = extOf(e.path); final viewable = viewer && !e.isDir && (ctl.c.settings.imageExts.contains(ext) || ext == 'pdf' || zipExtensions.contains(ext)); final dual = cmd.dual; // 77: 누른 것이 표시한 것 중 하나면 표시한 것 모두에 (복사 · 이동 · 삭제 · 공유 · 경로 복사) final targets = sel.isMarked(pane, e.path) && pane.sel.marked.length > 1 ? pane.sel.marked : [e.path]; final many = targets.length > 1; // 데이터 검토 (10/18): 펼친 압축 안의 것은 실제 파일이 아니다 - 그것으로 새 파일을 만들거나 내놓는 항목 (ZIP 묶기 · Wi-Fi 공유 · PDF) 은 숨긴다 final inArchive = targets.any(flows.guard.insideArchive) || flows.guard.insideArchive(e.path); // 25: Rsync 화면은 폴더만 보이므로 폴더에 맞는 것만 (고르기 · 열기 · 이름 변경 · 삭제 · 정보) final items = ctl.rsync ? <(String, IconData, String)>[ ('pickRsync', Icons.check_circle_outline, sel.isMarked(pane, e.path) ? tr('고르기 취소') : tr('rsync 에 고르기')), ('open', Icons.folder_open, tr('열기')), ('openOther', Icons.vertical_split_outlined, tr('다른 창에서 열기')), ('rename', Icons.drive_file_rename_outline, tr('이름 변경')), ('delete', Icons.delete_outline, tr('삭제')), if (!dav) ('reveal', Icons.folder_outlined, tr('파일 관리자에서 보기')), ('info', Icons.info_outline, tr('정보')), ] : <(String, IconData, String)>[ // 77: 복사 · 이동 · 이름 변경 · 삭제 · 공유는 위의 아이콘 줄 ([_menuIcons]). 아래는 상황별 항목 // 74: 폰에서 범위 고르기 - 첫 항목을 표시한 뒤 마지막 항목을 길게 눌러 if (sel.selecting && sel.canRange(pane, e)) ('range', Icons.unfold_more, tr('여기까지 범위 고르기')), if (e.isDir) ('open', Icons.folder_open, tr('열기')), // 81: 빠른 보기 (PC 는 Space · TC 는 F3) if (!e.isDir) ('quickLook', Icons.preview_outlined, tr('미리 보기')), // 86: 압축 풀기 (ZIP · 7z · RAR) · ZIP 으로 묶기 (표시한 것 모두) if (!e.isDir && archiveKindOf(e.path) != null) ('extract', Icons.unarchive_outlined, tr('압축 풀기')), if (!dav && !inArchive) ('zip', Icons.archive_outlined, many ? trf('{0}개 ZIP 으로 묶기', [targets.length]) : tr('ZIP 으로 묶기')), // 87: 저장 공간 분석 (이 폴더 아래의 큰 폴더 · 큰 파일) if (e.isDir) ('storage', Icons.pie_chart_outline, tr('저장 공간 분석')), // 89: 이 폴더를 Wi-Fi 로 공유 (이 기기의 폴더만) if (e.isDir && !dav && !ctl.rsync && !inArchive) ('wifiShare', Icons.wifi_tethering, tr('Wi-Fi 로 공유')), // 80: 이 폴더 크기를 지금 계산 (설정이 "안 함" 이어도) if (e.isDir) ('folderSize', Icons.data_usage, ctl.sizes.busy(e.path) ? tr('폴더 크기 계산 멈추기') : tr('폴더 크기 계산')), // X-plore P3: 폴더 속성 (안의 파일 · 폴더 수 · 크기 · 만든 · 바뀐 때) · 파일 해시 (MD5 · SHA-256) - 이 기기의 것만 (원격은 다 받아야 해서 뺌) if (e.isDir && !dav) ('props', Icons.fact_check_outlined, tr('폴더 속성')), if (!e.isDir && !dav) ('hash', Icons.tag, tr('해시 (MD5 · SHA-256)')), if (e.isDir && dual) ('openOther', Icons.vertical_split_outlined, tr('다른 창에서 열기')), if (e.isDir) ('playFolder', Icons.play_circle_outline, tr('이 폴더의 동영상 재생')), // X-plore 맞추기 (관리자 지시 10/20): 이 폴더의 그림을 [계속 보기] 로 if (e.isDir && viewer) ('slideshow', Icons.slideshow_outlined, tr('사진 슬라이드쇼')), // X-plore 비교 g: 그림 · 동영상을 썸네일 격자로 if (e.isDir && !dav) ('grid', Icons.grid_view, tr('격자로 보기 (그림 · 동영상)')), if (video || audio) ('playInternal', Icons.play_circle_outline, tr('내장 플레이어로 재생')), if (viewable) ('view', Icons.visibility_outlined, tr('보기')), // X-plore 비교 h: 어떤 파일이든 글로 열어 보기 (읽기 전용) if (!e.isDir && !video && !audio) ('text', Icons.article_outlined, tr('글 뷰어로 보기')), // X-plore 비교 5-1 ④ (관리자 지시 10/21): SQLite (읽기 전용) if (!e.isDir && !inArchive && sqliteExtensions.contains(ext)) ('sqlite', Icons.table_chart_outlined, tr('SQLite 보기 (읽기 전용)')), // X-plore 비교 5-2 (사용자 결정 10/21): 암호 보관함에 넣기. 2단계 (관리자 지시 10/21): 폴더째 (안의 구조 그대로) · 원격 파일도 (받아서 넣음) if (ctl.c.settings.vaultEnabled && !inArchive) ('vaultAdd', Icons.enhanced_encryption_outlined, many ? trf('{0}개 보관함에 넣기', [targets.length]) : tr('보관함에 넣기')), if (!e.isDir) ('openWith', Icons.open_in_new, tr('다른 앱으로 열기')), if (!e.isDir && !video) ('openDefault', Icons.launch, tr('기본 앱으로 열기')), if (video && !dav) ('addMkv', Icons.playlist_add, tr('MKV 목록에 추가')), if (viewer && images.isNotEmpty && !inArchive) ('toPdf', Icons.picture_as_pdf_outlined, images.length == 1 ? tr('PDF 로 만들기') : trf('그림 {0}장을 PDF 로 만들기', [images.length])), ('select', Icons.check_circle_outline, tr('선택')), // X-plore 숨기기: 목록에서만 숨긴다 (파일은 그대로) · 숨긴 것을 보이고 있으면 풀기 targets.every(ctl.appHidden) ? ('appUnhide', Icons.visibility_outlined, many ? trf('{0}개 숨기기 풀기', [targets.length]) : tr('숨기기 풀기')) : ('appHide', Icons.visibility_off_outlined, many ? trf('{0}개 목록에서 숨기기', [targets.length]) : tr('목록에서 숨기기')), // X-plore 맞추기 (관리자 지시 10/20): 폴더에 .nomedia 를 두면 갤러리 · 음악 앱이 그 안의 그림 · 동영상 · 음악을 보이지 않는다 (Android) if (e.isDir && !dav && !inArchive && nomedia()) File(p.join(e.path, '.nomedia')).existsSync() ? ('nomediaOff', Icons.photo_library_outlined, tr('갤러리 · 음악 앱에 다시 보이기 (.nomedia 지우기)')) : ('nomediaOn', Icons.hide_image_outlined, tr('갤러리 · 음악 앱에서 숨기기 (.nomedia)')), // 두 창이면 아이콘 줄의 복사 · 이동은 다른 창으로 - 담아 두었다가 원하는 폴더에 붙여넣기는 여기 if (dual) ('clipCopy', Icons.content_copy, tr('복사 (붙여넣기로)')), if (dual) ('clipMove', Icons.content_cut, tr('이동 (붙여넣기로)')), if (flows.transfers.clip.isNotEmpty) ('paste', Icons.content_paste, trf('{0} 에 붙여넣기 ({1}개)', [vBasename(e.isDir ? e.path : vDirname(e.path)), flows.transfers.clip.items.length])), ('copyPath', Icons.link, many ? trf('경로 복사 ({0}개)', [targets.length]) : tr('경로 복사')), if (e.isDir && !ctl.c.settings.folderBookmarks.any((b) => !b.isPair && samePath(b.path, e.path))) ('bookmark', Icons.star_outline, tr('즐겨찾기에 추가')), if (!dav && (!Platform.isAndroid || e.isDir)) ('reveal', Icons.folder_outlined, tr('파일 관리자에서 보기')), ('info', Icons.info_outline, tr('정보')), ]; // 77: 위의 아이콘 줄 (X-plore · One Commander 처럼 자주 쓰는 것을 한 줄에) final icons = ctl.rsync ? const <(String, IconData, String, String, bool)>[] : <(String, IconData, String, String, bool)>[ (dual ? 'copy' : 'clipCopy', Icons.copy_outlined, tr('복사'), dual ? tr('다른 창으로 복사') : tr('복사 (붙여넣기로)'), true), (dual ? 'move' : 'clipMove', Icons.drive_file_move_outline, tr('이동'), dual ? tr('다른 창으로 이동') : tr('이동 (붙여넣기로)'), true), // 78: 여럿이면 일괄 이름 바꾸기 ('rename', Icons.drive_file_rename_outline, tr('이름 변경'), many ? trf('{0}개 이름 일괄 바꾸기', [targets.length]) : tr('이름 변경'), true), ('delete', Icons.delete_outline, tr('삭제'), many ? trf('{0}개 삭제', [targets.length]) : tr('삭제'), true), if (ctl.c.services.shell.canShare) ('share', Icons.share_outlined, tr('공유'), tr('공유 (다른 앱으로 보내기)'), targets.any((x) => !vIsDirSync(x))), ]; final size = MediaQuery.sizeOf(context); final pick = await showMenu( context: context, position: RelativeRect.fromLTRB(at.dx, at.dy, size.width - at.dx, size.height - at.dy), items: [ // 77: 여러 개를 골랐으면 무엇에 하는지 먼저 if (many) PopupMenuItem( enabled: false, height: 32, child: Text(trf('{0}개 항목', [targets.length]), style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: JjColors.accent)), ), if (icons.isNotEmpty) ...[MenuIconRow(icons), const PopupMenuDivider(height: 1)], for (final (id, icon, label) in items) PopupMenuItem( value: id, child: Row(children: [ Icon(icon, size: 18), const SizedBox(width: 12), Flexible(child: Text(label, overflow: TextOverflow.ellipsis)), ]), ), ], ); if (pick == null || !context.mounted) return; switch (pick) { case 'pickRsync': await sel.pick(pane, e); case 'open': await ctl.goTo(pane, e.path); case 'openOther': final other = ctl.panes[1 - ctl.panes.indexOf(pane)]; await ctl.goTo(other, e.path); ctl ..active = ctl.panes.indexOf(other) ..update(); case 'playFolder': await flows.opener.playFolder(context, e.path); case 'slideshow': await flows.opener.slideshow(context, pane, e.path); case 'playInternal': await flows.opener.play(context, [e.path], internal: true); case 'grid': await flows.opener.grid(context, pane, e.path); case 'text': await flows.opener.viewText(context, e.path); case 'sqlite': await openSqliteViewer(context, ctl.c, e.path); case 'vaultAdd': await openVault(context, ctl.c, add: targets, deleteOriginals: (paths, {required permanent}) { // 관리자 지시 (10/21): [영구 삭제] 를 고르면 휴지통을 거치지 않음 (Shift+Delete 와 같은 길 - 탐색기의 영구 삭제 확인 창을 한 번 더) flows.deletes.shiftDelete = permanent; return flows.deletes.run(context, pane, paths); }); await ctl.refreshAll([vDirname(e.path)]); case 'openWith': final local = await flows.opener.fetch(context, e.path); if (local != null && !await ctl.c.services.shell.openWith(local, choose: true)) ctl.host.snack(ExplorerOpener.noAppMessage(e.path)); case 'openDefault': final local = await flows.opener.fetch(context, e.path); if (local != null && !await ctl.c.services.shell.openWith(local)) ctl.host.snack(ExplorerOpener.noAppMessage(e.path)); case 'addMkv': await flows.opener.addMkv([e.path]); case 'range': sel.markRange(pane, e); case 'select': sel.setSelecting(true); if (!sel.isMarked(pane, e.path)) await sel.toggleMark(pane, e); case 'quickLook': await flows.opener.quickLook(context, pane, e.path); case 'extract': await flows.opener.extractArchive(context, e.path, pane); case 'storage': await flows.tools.openStorage(context, pane, e.path); case 'wifiShare': await flows.tools.openWifiShare(context, e.path); case 'zip': final items = outermostPaths(sel.isMarked(pane, e.path) && pane.sel.marked.length > 1 ? pane.sel.marked : [e.path]); final other = cmd.dual ? ctl.panes[1 - ctl.panes.indexOf(pane)].current : null; final made = await createZipFlow(context, ctl.c, items, dir: vDirname(items.first), otherDir: other); if (made != null && context.mounted) { await ctl.refreshAll([vDirname(made)]); pane.focused = made; pane.changed(); } case 'appHide': if (ctl.c.settings.explorerHideConfirm && !await confirmAppHide(context, ctl, targets)) return; await ctl.c.updateSettings((s) => s.explorerHiddenItems = [ ...s.explorerHiddenItems, for (final x in targets) if (!ctl.appHidden(x)) x, ]); for (final q in ctl.panes) { targets.forEach(q.sel.forget); q.changed(); } ctl.update(); ctl.host.snack(ctl.showAppHidden ? trf('목록에서 숨겼습니다 ({0}개). 파일은 그대로입니다.', [targets.length]) : trf('목록에서 숨겼습니다 ({0}개). 파일은 그대로이고, 목록 맨 아래의 [숨긴 항목 보이기] 로 다시 볼 수 있습니다.', [targets.length])); case 'appUnhide': final keys = {for (final x in targets) HiddenList.keyOf(x)}; await ctl.c.updateSettings((s) => s.explorerHiddenItems = [ for (final x in s.explorerHiddenItems) if (!keys.contains(HiddenList.keyOf(x))) x, ]); for (final q in ctl.panes) { q.changed(); } ctl.update(); case 'nomediaOn' || 'nomediaOff': await setNomedia(context, ctl, pane, e.path, on: pick == 'nomediaOn'); case 'props': await showFolderProperties(context, e.path); case 'hash': await showFileHash(context, e.path); case 'folderSize': if (ctl.sizes.busy(e.path)) { ctl.sizes.cancel(e.path); break; } // 사용자가 직접 시킨 것: 다른 폴더로 가도 끝까지 (멈추기는 아래 알림 · 같은 메뉴) ctl.sizes ..forget(e.path) ..request(e.path); if (context.mounted) { showNotice(ScaffoldMessenger.of(context), text: isDav(e.path) ? trf('"{0}" 크기를 계산합니다. 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다.', [e.name]) : trf('"{0}" 크기를 계산합니다.', [e.name]), action: (tr('취소'), () => ctl.sizes.cancel(e.path))); } case 'rename': if (sel.isMarked(pane, e.path) && pane.sel.marked.length > 1) { await flows.renames.batch(context, pane, pane.sel.marked); } else { await flows.renames.rename(context, pane, e.path); } case 'copy': await flows.transfers.transfer(context, pane, sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: false, otherDir: cmd.otherDir); case 'move': await flows.transfers.transfer(context, pane, sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: true, otherDir: cmd.otherDir); case 'clipCopy': flows.transfers.toClipboard(sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: false); case 'clipMove': flows.transfers.toClipboard(sel.isMarked(pane, e.path) ? pane.sel.marked : [e.path], move: true); case 'paste': await flows.transfers.paste(context, pane, e.isDir ? e.path : vDirname(e.path)); case 'delete': // 77: 메뉴 머리의 "n개 항목" 과 같게 (예전에는 표시한 것이 여럿이어도 누른 것 하나만 지웠다) await flows.deletes.run(context, pane, targets); case 'share': await flows.opener.share(context, targets); case 'copyPath': final paths = targets.map((x) => copyablePath(ctl, x)).toList(); await Clipboard.setData(ClipboardData(text: paths.join('\n'))); ctl.host.snack(many ? trf('경로 {0}개를 복사했습니다', [paths.length]) : trf('경로를 복사했습니다: {0}', [paths.single])); case 'bookmark': final name = vBasename(e.path).isEmpty ? e.path : vBasename(e.path); await ctl.c.updateSettings((s) => s.folderBookmarks = [...s.folderBookmarks, FolderBookmark(name: name, path: e.path)]); ctl.host.snack(trf('즐겨찾기에 넣었습니다: {0}', [name])); ctl.update(); case 'view': await flows.opener.open(context, e.path, pane: pane); case 'toPdf': await flows.opener.makePdf(context, images); case 'reveal': await ctl.c.services.shell.revealFile(e.path); case 'info': await showFileInfo(context, ctl, e); } } }