WIP 77 길게 누르기 메뉴: 위 아이콘 줄 (복사 · 이동 · 이름 변경 · 삭제 · 공유), 여럿이면 "n개 항목" · 삭제도 표시한 것 모두, 경로 복사 · 즐겨찾기에 추가, Android 공유 (검토 대기)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음6
RyuJongJin 2026-10-12 09:18:44 +09:00
parent 8228d04a6e
commit 1c6b008332
9 changed files with 302 additions and 11 deletions

View File

@ -179,6 +179,7 @@ class MainActivity : FlutterActivity() {
"openWith" -> result.success(
openWith(call.argument<String>("path") ?: "", call.argument<Boolean>("choose") ?: false)
)
"shareFiles" -> result.success(shareFiles(call.argument<List<String>>("paths") ?: emptyList()))
"setAppIcon" -> result.success(setAppIcon(call.argument<String>("id") ?: ""))
"keepAlive" -> {
keepAlive(call.argument<String>("text") ?: "", call.argument<Int>("progress") ?: -1,
@ -421,6 +422,49 @@ class MainActivity : FlutterActivity() {
}
}
/// 77: 파일 탐색기의 [공유] - 파일 하나는 ACTION_SEND, 여럿은 ACTION_SEND_MULTIPLE (공유 창, 이 앱은 뺌)
private fun shareFiles(paths: List<String>): Boolean {
val uris = ArrayList<android.net.Uri>()
val mimes = mutableSetOf<String>()
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

View File

@ -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",

View File

@ -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}",
"폴더를 자기 안으로 복사 · 이동할 수 없습니다": "フォルダーを自分自身の中にコピー · 移動できません",

View File

@ -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}",
"폴더를 자기 안으로 복사 · 이동할 수 없습니다": "不能将文件夹复制或移动到其自身内部",

View File

@ -62,6 +62,19 @@ class AndroidShell extends NoopShell {
}
}
/// 77: Android 공유 창 (MainActivity "shareFiles")
@override
bool get canShare => true;
@override
Future<bool> shareFiles(List<String> paths) async {
try {
return await _ch.invokeMethod<bool>('shareFiles', {'paths': paths}) ?? false;
} catch (_) {
return false;
}
}
/// 환경 설정의 확장자별 재생 프로그램: Android 는 'system' (기본 앱) 만 있다
@override
Future<void> openExternal(String program, List<String> files) async {

View File

@ -369,6 +369,13 @@ class DesktopShell with WindowListener implements AppShell {
Future<void> openUrl(String url, {String browser = 'system'}) => openInBrowser(url, browser);
/// 탐색기로 폴더를 열고 파일을 선택. 파일이 없으면 폴더만 (그것도 없으면 가장 가까운 위 폴더).
/// 77: Windows 판은 공유 창을 아직 쓰지 않는다 (메뉴에 [공유] 를 두지 않음)
@override
bool get canShare => false;
@override
Future<bool> shareFiles(List<String> paths) async => false;
/// 기본 연결 프로그램 (탐색기에서 두 번 누른 것과 같음) 또는 "연결 프로그램" 고르기 창
@override
Future<bool> openWith(String path, {bool choose = false}) async {

View File

@ -70,6 +70,10 @@ abstract class AppShell {
/// 열지 못하면 false.
Future<bool> openWith(String path, {bool choose = false});
/// 77: 파일을 다른 앱으로 보내기 (공유 창). 이 기기에서 못 하면 [canShare] 가 false
bool get canShare;
Future<bool> shareFiles(List<String> paths);
/// 설치된 외부 브라우저 ('chrome', 'firefox', 'edge', 'whale' 중)
List<String> installedBrowsers();
@ -139,6 +143,10 @@ class NoopShell implements AppShell {
@override
Future<bool> openWith(String path, {bool choose = false}) async => false;
@override
bool get canShare => false;
@override
Future<bool> shareFiles(List<String> paths) async => false;
@override
List<String> installedBrowsers() => const [];
@override
Future<List<String>> missingTools() async => const [];

View File

@ -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<ExplorerPage> {
);
}
/// 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<ExplorerPage> {
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<ExplorerPage> {
('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<ExplorerPage> {
('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<String>(
context: context,
position: RelativeRect.fromLTRB(at.dx, at.dy, size.width - at.dx, size.height - at.dy),
items: [
// 77: 여러 개를 골랐으면 무엇에 하는지 먼저
if (many)
PopupMenuItem<String>(
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<ExplorerPage> {
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<ExplorerPage> {
}
}
/// 77: 공유 - 파일만 (폴더는 보낼 수 없어 빼고 알림). WebDAV 파일은 받아서
Future<void> _share(List<String> paths) async {
final files = [for (final x in paths) if (!vIsDirSync(x)) x];
if (files.isEmpty) {
_snack(tr('폴더는 공유할 수 없습니다. 파일을 고르세요.'));
return;
}
final local = <String>[];
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<String> {
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)),
),
]),
),
),
),
),
),
]),
);
}

View File

@ -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');