diff --git a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt index dbafa9a..7ec8ff8 100644 --- a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt +++ b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt @@ -179,6 +179,7 @@ class MainActivity : FlutterActivity() { "openWith" -> result.success( openWith(call.argument("path") ?: "", call.argument("choose") ?: false) ) + "shareFiles" -> result.success(shareFiles(call.argument>("paths") ?: emptyList())) "setAppIcon" -> result.success(setAppIcon(call.argument("id") ?: "")) "keepAlive" -> { keepAlive(call.argument("text") ?: "", call.argument("progress") ?: -1, @@ -421,6 +422,49 @@ class MainActivity : FlutterActivity() { } } + /// 77: 파일 탐색기의 [공유] - 파일 하나는 ACTION_SEND, 여럿은 ACTION_SEND_MULTIPLE (공유 창, 이 앱은 뺌) + private fun shareFiles(paths: List): Boolean { + val uris = ArrayList() + val mimes = mutableSetOf() + for (p in paths) { + val f = File(p) + if (!f.isFile) continue + uris.add( + try { + androidx.core.content.FileProvider.getUriForFile(this, "$packageName.files", f) + } catch (e: Exception) { + return false + } + ) + mimes.add(android.webkit.MimeTypeMap.getSingleton().getMimeTypeFromExtension(f.extension.lowercase()) ?: "*/*") + } + if (uris.isEmpty()) return false + // 종류가 하나면 그것, 앞부분 (video/ 등) 만 같으면 video/*, 아니면 */* + val tops = mimes.map { it.substringBefore('/') }.toSet() + val mime = if (mimes.size == 1) mimes.first() else if (tops.size == 1 && tops.first() != "*") "${tops.first()}/*" else "*/*" + val send = if (uris.size == 1) { + Intent(Intent.ACTION_SEND).putExtra(Intent.EXTRA_STREAM, uris[0]) + } else { + Intent(Intent.ACTION_SEND_MULTIPLE).putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris) + } + send.type = mime + // 받는 앱이 모든 파일을 읽을 수 있게 (EXTRA_STREAM 만으로는 첫 것만 허락되는 앱이 있다) + val clip = android.content.ClipData.newRawUri(null, uris[0]) + for (i in 1 until uris.size) clip.addItem(android.content.ClipData.Item(uris[i])) + send.clipData = clip + send.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + return try { + startActivity( + Intent.createChooser(send, null) + .putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, arrayOf(android.content.ComponentName(this, MainActivity::class.java))) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + true + } catch (e: Exception) { + false + } + } + private fun openFolder(path: String): Boolean { // 내장 저장소 (primary) 또는 SD 카드 · USB (/storage/XXXX-XXXX → 문서 ID "XXXX-XXXX:...") val root = Environment.getExternalStorageDirectory().path diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 205b512..9828e50 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -467,6 +467,7 @@ "{0}개 · 주소 {1}개": "{0} · {1} links", "{0}개 맞춤": "{0} synced", "{0}개 보임": "{0} shown", + "{0}개 삭제": "Delete {0} items", "{0}개 선택": "{0} selected", "{0}개 중 {1}개를 지우지 못했습니다": "Couldn't delete {1} of {0}", "{0}개 지우고 맞추기": "Delete {0} and sync", @@ -577,11 +578,15 @@ "결과 폴더 열기: {0}": "Open output folder: {0}", "결과물 이동 실패: {0} ({1})": "Failed to move result: {0} ({1})", "경로": "Path", + "경로 {0}개를 복사했습니다": "Copied {0} paths", + "경로 복사": "Copy path", + "경로 복사 ({0}개)": "Copy paths ({0})", "경로 입력": "Enter path", "경로 입력 (예: D:\\영상 · \\\\NAS\\공유)": "Enter path (e.g. D:\\Videos · \\\\NAS\\share)", "경로 입력 · WebDAV": "Enter path · WebDAV", "경로가 너무 길어 휴지통에 넣을 수 없습니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다).": "The path is too long for the Recycle Bin (the Windows Recycle Bin does not accept paths over 260 characters).", "경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "The path is too long (the Windows Recycle Bin doesn't accept paths over 260 characters) - you can delete it permanently", + "경로를 복사했습니다: {0}": "Copied path: {0}", "계속": "Always", "계속 (앱이 켜져 있는 동안)": "Always (while the app is running)", "계속 받기": "Keep downloading", @@ -606,6 +611,9 @@ "골라서 시작": "Choose and start", "공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.": "Cannot roll back: shared storage (Download) is not available.", "공용 폴더에 받은 파일 (동영상 · 그림 등)": "Files downloaded to shared folders (videos, images, etc.)", + "공유": "Share", + "공유 (다른 앱으로 보내기)": "Share (send to another app)", + "공유 창을 열 수 없습니다.": "Can't open the share sheet.", "관리 메뉴": "Manage menu", "관리자 권한으로 실행 중이라 Chrome 엔진을 쓸 수 없습니다 (보안)": "The Chrome engine can't be used because the app is running as administrator (security)", "관리자 권한으로 실행할 때만 쓰입니다. 끄면 (기본) 그때 Chrome 엔진을 시작하지 않고 Edge 를 권합니다.": "Only used when running as administrator. When off (default), the Chrome engine isn't started then and Edge is suggested.", @@ -1956,6 +1964,8 @@ "즐겨찾기 추가…": "Add bookmark…", "즐겨찾기 추가됨": "Bookmark added", "즐겨찾기 표시줄": "Bookmarks bar", + "즐겨찾기에 넣었습니다: {0}": "Added to favorites: {0}", + "즐겨찾기에 추가": "Add to favorites", "즐겨찾기에서 빼기 (폴더는 그대로)": "Remove from favorites (the folder stays)", "지금": "Current", "지금 {0}%": "Now {0}%", @@ -2174,6 +2184,7 @@ "폴더": "Folder", "폴더 + 파일 목록": "Folders + file list", "폴더 {0}개 (크기 빼고)": "{0} folders (size not counted)", + "폴더 {0}개는 빼고 파일만 보냈습니다.": "Sent files only ({0} folders left out).", "폴더 고르기": "Choose folder", "폴더 만들기": "New folder", "폴더 복사 · 이동 방법": "Folder copy · move method", @@ -2192,6 +2203,7 @@ "폴더가 없거나 열 수 없습니다. Windows 의 네트워크 공유는 \\\\서버\\공유 형식입니다 (바꾼 경로: {0})": "The folder does not exist or cannot be opened. On Windows, network shares use the \\\\server\\share form (converted path: {0})", "폴더가 없거나 열 수 없습니다: {0}": "The folder doesn't exist or can't be opened: {0}", "폴더가 없습니다": "The folder does not exist", + "폴더는 공유할 수 없습니다. 파일을 고르세요.": "Folders can't be shared. Choose files.", "폴더는 보여도 그 안의 동영상 · 파일은 이 권한이 있어야 보입니다. 허용 화면에서 JJ_MKVMaker 를 켜고 돌아오세요.": "Folders are shown, but the videos and files inside need this permission. Turn on JJ_MKVMaker on the permission screen and come back.", "폴더를 만들 수 없습니다: {0}": "Cannot create the folder: {0}", "폴더를 자기 안으로 복사 · 이동할 수 없습니다": "Cannot copy or move a folder into itself", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 09d4b2a..5ecafd2 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -467,6 +467,7 @@ "{0}개 · 주소 {1}개": "{0}個・URL {1}個", "{0}개 맞춤": "{0} 件を同期", "{0}개 보임": "{0} 件表示", + "{0}개 삭제": "{0} 件を削除", "{0}개 선택": "{0}個選択", "{0}개 중 {1}개를 지우지 못했습니다": "{0} 個中 {1} 個を削除できませんでした", "{0}개 지우고 맞추기": "{0} 個を削除して同期", @@ -577,11 +578,15 @@ "결과 폴더 열기: {0}": "出力フォルダーを開く: {0}", "결과물 이동 실패: {0} ({1})": "成果物の移動失敗: {0}({1})", "경로": "パス", + "경로 {0}개를 복사했습니다": "{0} 件のパスをコピーしました", + "경로 복사": "パスをコピー", + "경로 복사 ({0}개)": "パスをコピー ({0} 件)", "경로 입력": "パスを入力", "경로 입력 (예: D:\\영상 · \\\\NAS\\공유)": "パスを入力 (例: D:\\動画・\\\\NAS\\共有)", "경로 입력 · WebDAV": "パス入力 · WebDAV", "경로가 너무 길어 휴지통에 넣을 수 없습니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다).": "パスが長すぎてごみ箱に入れられません (Windows のごみ箱は 260 文字を超えるパスを受け付けません)。", "경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "パスが長すぎます (Windows のごみ箱は 260 文字を超えるパスを受け付けません) - 完全に削除はできます", + "경로를 복사했습니다: {0}": "パスをコピーしました: {0}", "계속": "常時", "계속 (앱이 켜져 있는 동안)": "常時 (アプリの起動中)", "계속 받기": "ダウンロードを続ける", @@ -606,6 +611,9 @@ "골라서 시작": "選んで開始", "공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.": "共有ストレージ (Download) を使えないため戻せません。", "공용 폴더에 받은 파일 (동영상 · 그림 등)": "共有フォルダーにダウンロードしたファイル (動画・画像など)", + "공유": "共有", + "공유 (다른 앱으로 보내기)": "共有 (他のアプリに送る)", + "공유 창을 열 수 없습니다.": "共有画面を開けません。", "관리 메뉴": "管理メニュー", "관리자 권한으로 실행 중이라 Chrome 엔진을 쓸 수 없습니다 (보안)": "管理者権限で実行中のため Chrome エンジンを使えません (セキュリティ)", "관리자 권한으로 실행할 때만 쓰입니다. 끄면 (기본) 그때 Chrome 엔진을 시작하지 않고 Edge 를 권합니다.": "管理者権限で実行するときだけ使われます。オフ (既定) の場合、そのときは Chrome エンジンを起動せず Edge をおすすめします。", @@ -1956,6 +1964,8 @@ "즐겨찾기 추가…": "ブックマークを追加…", "즐겨찾기 추가됨": "ブックマークを追加しました", "즐겨찾기 표시줄": "ブックマークバー", + "즐겨찾기에 넣었습니다: {0}": "お気に入りに追加しました: {0}", + "즐겨찾기에 추가": "お気に入りに追加", "즐겨찾기에서 빼기 (폴더는 그대로)": "お気に入りから外す (フォルダーはそのまま)", "지금": "現在", "지금 {0}%": "現在 {0}%", @@ -2174,6 +2184,7 @@ "폴더": "フォルダー", "폴더 + 파일 목록": "フォルダー + ファイル一覧", "폴더 {0}개 (크기 빼고)": "フォルダー {0} 件 (サイズ除く)", + "폴더 {0}개는 빼고 파일만 보냈습니다.": "ファイルだけ送りました (フォルダー {0} 件は除外)。", "폴더 고르기": "フォルダーを選択", "폴더 만들기": "フォルダーを作成", "폴더 복사 · 이동 방법": "フォルダーのコピー · 移動方法", @@ -2192,6 +2203,7 @@ "폴더가 없거나 열 수 없습니다. Windows 의 네트워크 공유는 \\\\서버\\공유 형식입니다 (바꾼 경로: {0})": "フォルダーが存在しないか開けません。Windows のネットワーク共有は \\\\サーバー\\共有 の形式です (変換したパス: {0})", "폴더가 없거나 열 수 없습니다: {0}": "フォルダーがないか、開けません: {0}", "폴더가 없습니다": "フォルダーがありません", + "폴더는 공유할 수 없습니다. 파일을 고르세요.": "フォルダーは共有できません。ファイルを選んでください。", "폴더는 보여도 그 안의 동영상 · 파일은 이 권한이 있어야 보입니다. 허용 화면에서 JJ_MKVMaker 를 켜고 돌아오세요.": "フォルダーは見えても、中の動画・ファイルはこの権限が必要です。許可画面で JJ_MKVMaker をオンにして戻ってください。", "폴더를 만들 수 없습니다: {0}": "フォルダーを作成できません: {0}", "폴더를 자기 안으로 복사 · 이동할 수 없습니다": "フォルダーを自分自身の中にコピー · 移動できません", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 7d7dc7a..103d87a 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -467,6 +467,7 @@ "{0}개 · 주소 {1}개": "{0} 个 · 地址 {1} 个", "{0}개 맞춤": "已同步 {0} 个", "{0}개 보임": "显示 {0} 项", + "{0}개 삭제": "删除 {0} 项", "{0}개 선택": "已选 {0} 个", "{0}개 중 {1}개를 지우지 못했습니다": "{0} 个中有 {1} 个无法删除", "{0}개 지우고 맞추기": "删除 {0} 个并同步", @@ -577,11 +578,15 @@ "결과 폴더 열기: {0}": "打开输出文件夹: {0}", "결과물 이동 실패: {0} ({1})": "成品移动失败: {0}({1})", "경로": "路径", + "경로 {0}개를 복사했습니다": "已复制 {0} 个路径", + "경로 복사": "复制路径", + "경로 복사 ({0}개)": "复制路径 ({0} 个)", "경로 입력": "输入路径", "경로 입력 (예: D:\\영상 · \\\\NAS\\공유)": "输入路径 (例: D:\\视频 · \\\\NAS\\共享)", "경로 입력 · WebDAV": "输入路径 · WebDAV", "경로가 너무 길어 휴지통에 넣을 수 없습니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다).": "路径过长,无法放入回收站 (Windows 回收站不接受超过 260 个字符的路径)。", "경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "路径过长 (Windows 回收站不接受超过 260 个字符的路径) - 可以永久删除", + "경로를 복사했습니다: {0}": "已复制路径: {0}", "계속": "持续", "계속 (앱이 켜져 있는 동안)": "持续 (应用运行期间)", "계속 받기": "继续下载", @@ -606,6 +611,9 @@ "골라서 시작": "选择后开始", "공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.": "无法使用公共存储 (Download),因此无法回退。", "공용 폴더에 받은 파일 (동영상 · 그림 등)": "下载到公共文件夹的文件 (视频、图片等)", + "공유": "分享", + "공유 (다른 앱으로 보내기)": "分享 (发送到其他应用)", + "공유 창을 열 수 없습니다.": "无法打开分享窗口。", "관리 메뉴": "管理菜单", "관리자 권한으로 실행 중이라 Chrome 엔진을 쓸 수 없습니다 (보안)": "应用正以管理员权限运行,无法使用 Chrome 引擎(安全原因)", "관리자 권한으로 실행할 때만 쓰입니다. 끄면 (기본) 그때 Chrome 엔진을 시작하지 않고 Edge 를 권합니다.": "仅在以管理员权限运行时使用。关闭时(默认)届时不启动 Chrome 引擎,并建议使用 Edge。", @@ -1956,6 +1964,8 @@ "즐겨찾기 추가…": "添加书签…", "즐겨찾기 추가됨": "已添加书签", "즐겨찾기 표시줄": "书签栏", + "즐겨찾기에 넣었습니다: {0}": "已添加到收藏: {0}", + "즐겨찾기에 추가": "添加到收藏", "즐겨찾기에서 빼기 (폴더는 그대로)": "从收藏中移除(文件夹保留)", "지금": "当前", "지금 {0}%": "当前 {0}%", @@ -2174,6 +2184,7 @@ "폴더": "文件夹", "폴더 + 파일 목록": "文件夹 + 文件列表", "폴더 {0}개 (크기 빼고)": "{0} 个文件夹(不计大小)", + "폴더 {0}개는 빼고 파일만 보냈습니다.": "只发送了文件 (排除了 {0} 个文件夹)。", "폴더 고르기": "选择文件夹", "폴더 만들기": "新建文件夹", "폴더 복사 · 이동 방법": "文件夹复制 · 移动方式", @@ -2192,6 +2203,7 @@ "폴더가 없거나 열 수 없습니다. Windows 의 네트워크 공유는 \\\\서버\\공유 형식입니다 (바꾼 경로: {0})": "文件夹不存在或无法打开。Windows 的网络共享格式为 \\\\服务器\\共享(转换后的路径:{0})", "폴더가 없거나 열 수 없습니다: {0}": "文件夹不存在或无法打开: {0}", "폴더가 없습니다": "文件夹不存在", + "폴더는 공유할 수 없습니다. 파일을 고르세요.": "文件夹无法分享。请选择文件。", "폴더는 보여도 그 안의 동영상 · 파일은 이 권한이 있어야 보입니다. 허용 화면에서 JJ_MKVMaker 를 켜고 돌아오세요.": "虽然能看到文件夹,但其中的视频和文件需要此权限。请在权限界面开启 JJ_MKVMaker 后返回。", "폴더를 만들 수 없습니다: {0}": "无法创建文件夹: {0}", "폴더를 자기 안으로 복사 · 이동할 수 없습니다": "不能将文件夹复制或移动到其自身内部", diff --git a/lib/platform/android/android_shell.dart b/lib/platform/android/android_shell.dart index 7a025e9..091ce31 100644 --- a/lib/platform/android/android_shell.dart +++ b/lib/platform/android/android_shell.dart @@ -62,6 +62,19 @@ class AndroidShell extends NoopShell { } } + /// 77: Android 공유 창 (MainActivity "shareFiles") + @override + bool get canShare => true; + + @override + Future shareFiles(List paths) async { + try { + return await _ch.invokeMethod('shareFiles', {'paths': paths}) ?? false; + } catch (_) { + return false; + } + } + /// 환경 설정의 확장자별 재생 프로그램: Android 는 'system' (기본 앱) 만 있다 @override Future openExternal(String program, List files) async { diff --git a/lib/platform/windows/desktop_shell.dart b/lib/platform/windows/desktop_shell.dart index a8a4443..fec6ed2 100644 --- a/lib/platform/windows/desktop_shell.dart +++ b/lib/platform/windows/desktop_shell.dart @@ -369,6 +369,13 @@ class DesktopShell with WindowListener implements AppShell { Future openUrl(String url, {String browser = 'system'}) => openInBrowser(url, browser); /// 탐색기로 폴더를 열고 파일을 선택. 파일이 없으면 폴더만 (그것도 없으면 가장 가까운 위 폴더). + /// 77: Windows 판은 공유 창을 아직 쓰지 않는다 (메뉴에 [공유] 를 두지 않음) + @override + bool get canShare => false; + + @override + Future shareFiles(List paths) async => false; + /// 기본 연결 프로그램 (탐색기에서 두 번 누른 것과 같음) 또는 "연결 프로그램" 고르기 창 @override Future openWith(String path, {bool choose = false}) async { diff --git a/lib/services/app_shell.dart b/lib/services/app_shell.dart index 09731b6..73bcf18 100644 --- a/lib/services/app_shell.dart +++ b/lib/services/app_shell.dart @@ -70,6 +70,10 @@ abstract class AppShell { /// 열지 못하면 false. Future openWith(String path, {bool choose = false}); + /// 77: 파일을 다른 앱으로 보내기 (공유 창). 이 기기에서 못 하면 [canShare] 가 false + bool get canShare; + Future shareFiles(List paths); + /// 설치된 외부 브라우저 ('chrome', 'firefox', 'edge', 'whale' 중) List installedBrowsers(); @@ -139,6 +143,10 @@ class NoopShell implements AppShell { @override Future openWith(String path, {bool choose = false}) async => false; @override + bool get canShare => false; + @override + Future shareFiles(List paths) async => false; + @override List installedBrowsers() => const []; @override Future> missingTools() async => const []; diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 30bd57f..674cbe3 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -13,7 +13,7 @@ import 'package:path/path.dart' as p; import '../app/app_controller.dart'; import '../app/copy_center.dart'; import '../app/live_sync.dart'; -import '../app/settings.dart' show CopyTask; +import '../app/settings.dart' show CopyTask, FolderBookmark; import '../app/transfer_job.dart'; import '../core/secret_gate.dart'; import '../core/sync_tools.dart'; @@ -628,6 +628,17 @@ class _ExplorerPageState extends State { ); } + /// 77: [경로 복사] 에 넣을 것 - 실제 경로 (Android 도 "SD 카드 ›" 가 아닌 /storage/…), WebDAV 는 서버 주소 (아이디 · 비밀번호는 빼고) + String _copyablePath(String path) { + if (!isDav(path)) return path; + final d = DavPath.parse(path); + try { + return DavRegistry.client(d.server).uriOf(d.rel).replace(userInfo: '').toString(); + } catch (_) { + return _displayPath(path); + } + } + /// 화면에 보일 경로 (5 · 14): Android 는 "SD 카드 › JJ_sdtest › jj_mkv", WebDAV 는 "☁ 서버 › 폴더", Windows 는 그대로 String _displayPath(String path) { if (isDav(path)) { @@ -1276,6 +1287,9 @@ class _ExplorerPageState extends State { final viewable = viewer && !e.isDir && (c.settings.imageExts.contains(ext) || ext == 'pdf' || zipExtensions.contains(ext)); final dual = _dual; + // 77: 누른 것이 표시한 것 중 하나면 표시한 것 모두에 (복사 · 이동 · 삭제 · 공유 · 경로 복사) + final targets = _isMarked(pane, e.path) && pane.marked.length > 1 ? pane.marked.toList() : [e.path]; + final many = targets.length > 1; // 25: Rsync 화면은 폴더만 보이므로 폴더에 맞는 것만 (고르기 · 열기 · 이름 변경 · 삭제 · 정보) final items = widget.rsync ? <(String, IconData, String)>[ @@ -1289,6 +1303,7 @@ class _ExplorerPageState extends State { ('info', Icons.info_outline, tr('정보')), ] : <(String, IconData, String)>[ + // 77: 복사 · 이동 · 이름 변경 · 삭제 · 공유는 위의 아이콘 줄 ([_menuIcons]). 아래는 상황별 항목 // 74: 폰에서 범위 고르기 - 첫 항목을 표시한 뒤 마지막 항목을 길게 눌러 if (_selecting && _canRange(pane, e)) ('range', Icons.unfold_more, tr('여기까지 범위 고르기')), if (e.isDir) ('open', Icons.folder_open, tr('열기')), @@ -1303,23 +1318,41 @@ class _ExplorerPageState extends State { ('toPdf', Icons.picture_as_pdf_outlined, images.length == 1 ? tr('PDF 로 만들기') : trf('그림 {0}장을 PDF 로 만들기', [images.length])), ('select', Icons.check_circle_outline, tr('선택')), - ('rename', Icons.drive_file_rename_outline, tr('이름 변경')), - if (dual) ('copy', Icons.copy_outlined, tr('다른 창으로 복사')), - if (dual) ('move', Icons.drive_file_move_outline, tr('다른 창으로 이동')), - // 어느 배치에서나: 담아 두었다가 원하는 폴더에서 붙여넣기 - ('clipCopy', Icons.content_copy, dual ? tr('복사 (붙여넣기로)') : tr('복사')), - ('clipMove', Icons.content_cut, dual ? tr('이동 (붙여넣기로)') : tr('이동')), + // 두 창이면 아이콘 줄의 복사 · 이동은 다른 창으로 - 담아 두었다가 원하는 폴더에 붙여넣기는 여기 + if (dual) ('clipCopy', Icons.content_copy, tr('복사 (붙여넣기로)')), + if (dual) ('clipMove', Icons.content_cut, tr('이동 (붙여넣기로)')), if (_clip.isNotEmpty) ('paste', Icons.content_paste, trf('{0} 에 붙여넣기 ({1}개)', [vBasename(e.isDir ? e.path : vDirname(e.path)), _clip.length])), - ('delete', Icons.delete_outline, tr('삭제')), + ('copyPath', Icons.link, many ? trf('경로 복사 ({0}개)', [targets.length]) : tr('경로 복사')), + if (e.isDir && !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 = widget.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), + ('rename', Icons.drive_file_rename_outline, tr('이름 변경'), tr('이름 변경'), !many), + ('delete', Icons.delete_outline, tr('삭제'), many ? trf('{0}개 삭제', [targets.length]) : tr('삭제'), true), + if (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: const 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, @@ -1371,7 +1404,19 @@ class _ExplorerPageState extends State { case 'paste': await _paste(pane, e.isDir ? e.path : vDirname(e.path)); case 'delete': - await _delete(pane, [e.path]); + // 77: 메뉴 머리의 "n개 항목" 과 같게 (예전에는 표시한 것이 여럿이어도 누른 것 하나만 지웠다) + await _delete(pane, targets); + case 'share': + await _share(targets); + case 'copyPath': + final paths = targets.map(_copyablePath).toList(); + await Clipboard.setData(ClipboardData(text: paths.join('\n'))); + _snack(many ? trf('경로 {0}개를 복사했습니다', [paths.length]) : trf('경로를 복사했습니다: {0}', [paths.single])); + case 'bookmark': + final name = vBasename(e.path).isEmpty ? e.path : vBasename(e.path); + await c.updateSettings((s) => s.folderBookmarks = [...s.folderBookmarks, FolderBookmark(name: name, path: e.path)]); + _snack(trf('즐겨찾기에 넣었습니다: {0}', [name])); + setState(() {}); case 'view': await _open(e.path, pane: pane); case 'toPdf': @@ -1383,6 +1428,26 @@ class _ExplorerPageState extends State { } } + /// 77: 공유 - 파일만 (폴더는 보낼 수 없어 빼고 알림). WebDAV 파일은 받아서 + Future _share(List paths) async { + final files = [for (final x in paths) if (!vIsDirSync(x)) x]; + if (files.isEmpty) { + _snack(tr('폴더는 공유할 수 없습니다. 파일을 고르세요.')); + return; + } + final local = []; + for (final f in files) { + final l = await _fetch(f); + if (l == null || !mounted) return; + local.add(l); + } + if (!await c.services.shell.shareFiles(local)) { + _snack(tr('공유 창을 열 수 없습니다.')); + } else if (files.length < paths.length) { + _snack(trf('폴더 {0}개는 빼고 파일만 보냈습니다.', [paths.length - files.length])); + } + } + // ───────── 기능 ───────── _Pane get _pane => _panes[_active]; @@ -4266,3 +4331,57 @@ class _PathDialogState extends State<_PathDialog> { ], ); } + +/// 77: 길게 누르기 메뉴 위의 아이콘 줄 - (id, 아이콘, 짧은 이름, 풍선 도움말, 누를 수 있음). +/// 폰에서도 무엇인지 알 수 있게 아이콘 아래에 짧은 이름 +class _MenuIconRow extends PopupMenuEntry { + final List<(String, IconData, String, String, bool)> items; + const _MenuIconRow(this.items); + + @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)), + ), + ]), + ), + ), + ), + ), + ), + ]), + ); +} diff --git a/test/explorer_test.dart b/test/explorer_test.dart index 9d5dfa3..7f53bc8 100644 --- a/test/explorer_test.dart +++ b/test/explorer_test.dart @@ -113,7 +113,8 @@ void main() { // 길게 누르기 → 메뉴 → 다른 앱으로 열기 (고르기 창) await tester.longPress(find.text('doc.txt')); await calm(tester); - expect(find.text('다른 창으로 복사'), findsOneWidget); + // 77: 복사 · 이동 · 이름 변경 · 삭제는 메뉴 위 아이콘 줄 (두 창이면 복사는 다른 창으로) + expect(find.byTooltip('다른 창으로 복사'), findsOneWidget); expect(find.text('삭제'), findsWidgets); await act(tester, () => tester.tap(find.text('다른 앱으로 열기'))); await settle(tester, () => shell.opened.length == 2); @@ -193,7 +194,7 @@ void main() { // 이 배치에도 복사 · 이동: 길게 눌러 [복사] 로 담고 → 다른 폴더를 골라 [붙여넣기] await tester.longPress(find.text('inner.txt')); await calm(tester); - expect(find.text('다른 창으로 복사'), findsNothing); + expect(find.byTooltip('다른 창으로 복사'), findsNothing); await tester.tap(find.text('복사').last); await calm(tester); await act(tester, () => tester.tap(find.text('..'))); @@ -410,6 +411,69 @@ void main() { expect(find.textContaining('3개 표시함'), findsOneWidget, reason: 'b.srt · c.mkv · doc.txt'); }); + testWidgets('77: 길게 누르기 메뉴 - 위 아이콘 줄 · 여럿이면 "n개 항목" (이름 변경은 하나일 때만) · 경로 복사 · 즐겨찾기에 추가 · 삭제는 표시한 것 모두', (tester) async { + File(p.join(left, 'b.txt')).writeAsStringSync('b'); + String? clip; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'Clipboard.setData') clip = (call.arguments as Map)['text'] as String?; + return null; + }); + addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null)); + await open(tester); + await settle(tester, () => find.text('b.txt').evaluate().isNotEmpty); + + // 하나: 머리 없음 · 아이콘 줄 · 경로 복사 (실제 경로) + await tester.longPress(find.text('doc.txt').first); + await calm(tester); + expect(find.textContaining('개 항목'), findsNothing); + for (final t in ['다른 창으로 복사', '다른 창으로 이동', '이름 변경', '삭제']) { + expect(find.byTooltip(t), findsWidgets, reason: t); // 이름 변경 · 삭제는 아래 버튼 줄에도 + } + await tester.tap(find.text('경로 복사')); + await calm(tester); + expect(clip, p.join(left, 'doc.txt')); + + // 여럿: "2개 항목" · 이름 변경은 눌리지 않음 · 경로 복사는 둘 다 + await act(tester, () => tester.tap(find.text('선택').first)); + await act(tester, () => tester.tap(markOf('doc.txt'))); + await act(tester, () => tester.tap(markOf('b.txt'))); + await tester.longPress(find.text('b.txt').first); + await calm(tester); + expect(find.text('2개 항목'), findsOneWidget); + expect(find.byTooltip('2개 삭제'), findsOneWidget); + await tester.tap(find.byTooltip('이름 변경').last); + await calm(tester); + expect(find.text('2개 항목'), findsOneWidget, reason: '이름 변경은 눌리지 않아 메뉴 그대로'); + await tester.tap(find.text('경로 복사 (2개)')); + await calm(tester); + expect(clip!.split('\n'), unorderedEquals([p.join(left, 'doc.txt'), p.join(left, 'b.txt')])); + + // 삭제 (아이콘 줄) 는 표시한 것 모두 - 확인 창에서 취소 + await tester.longPress(find.text('b.txt').first); + await calm(tester); + await tester.tap(find.byTooltip('2개 삭제')); + await calm(tester); + expect(find.textContaining('2개 항목을'), findsOneWidget); + await tester.tap(find.text('취소').last); + await calm(tester); + expect(File(p.join(left, 'b.txt')).existsSync(), isTrue); + // 선택 끝 (아래 알림이 [선택 끝] 을 가릴 수 있어 Esc) + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await calm(tester); + + // 폴더: 즐겨찾기에 추가 (넣은 뒤에는 메뉴에서 빠짐) + await tester.longPress(find.text('sub').first); + await calm(tester); + await tester.tap(find.text('즐겨찾기에 추가')); + await calm(tester); + expect(c.settings.folderBookmarks.map((x) => x.path), contains(p.join(left, 'sub'))); + await tester.longPress(find.text('sub').first); + await calm(tester); + expect(find.text('즐겨찾기에 추가'), findsNothing); + await tester.tapAt(const Offset(5, 5)); + await calm(tester); + }); + testWidgets('75: 창마다 뒤로 · 앞으로 - 버튼 · Alt+← / → · 마우스 4 · 5번', (tester) async { await open(tester); final sub = p.join(left, 'sub');