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" -> result.success(
openWith(call.argument<String>("path") ?: "", call.argument<Boolean>("choose") ?: false) 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") ?: "")) "setAppIcon" -> result.success(setAppIcon(call.argument<String>("id") ?: ""))
"keepAlive" -> { "keepAlive" -> {
keepAlive(call.argument<String>("text") ?: "", call.argument<Int>("progress") ?: -1, 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 { private fun openFolder(path: String): Boolean {
// 내장 저장소 (primary) 또는 SD 카드 · USB (/storage/XXXX-XXXX → 문서 ID "XXXX-XXXX:...") // 내장 저장소 (primary) 또는 SD 카드 · USB (/storage/XXXX-XXXX → 문서 ID "XXXX-XXXX:...")
val root = Environment.getExternalStorageDirectory().path val root = Environment.getExternalStorageDirectory().path

View File

@ -467,6 +467,7 @@
"{0}개 · 주소 {1}개": "{0} · {1} links", "{0}개 · 주소 {1}개": "{0} · {1} links",
"{0}개 맞춤": "{0} synced", "{0}개 맞춤": "{0} synced",
"{0}개 보임": "{0} shown", "{0}개 보임": "{0} shown",
"{0}개 삭제": "Delete {0} items",
"{0}개 선택": "{0} selected", "{0}개 선택": "{0} selected",
"{0}개 중 {1}개를 지우지 못했습니다": "Couldn't delete {1} of {0}", "{0}개 중 {1}개를 지우지 못했습니다": "Couldn't delete {1} of {0}",
"{0}개 지우고 맞추기": "Delete {0} and sync", "{0}개 지우고 맞추기": "Delete {0} and sync",
@ -577,11 +578,15 @@
"결과 폴더 열기: {0}": "Open output folder: {0}", "결과 폴더 열기: {0}": "Open output folder: {0}",
"결과물 이동 실패: {0} ({1})": "Failed to move result: {0} ({1})", "결과물 이동 실패: {0} ({1})": "Failed to move result: {0} ({1})",
"경로": "Path", "경로": "Path",
"경로 {0}개를 복사했습니다": "Copied {0} paths",
"경로 복사": "Copy path",
"경로 복사 ({0}개)": "Copy paths ({0})",
"경로 입력": "Enter path", "경로 입력": "Enter path",
"경로 입력 (예: D:\\영상 · \\\\NAS\\공유)": "Enter path (e.g. D:\\Videos · \\\\NAS\\share)", "경로 입력 (예: D:\\영상 · \\\\NAS\\공유)": "Enter path (e.g. D:\\Videos · \\\\NAS\\share)",
"경로 입력 · WebDAV": "Enter path · WebDAV", "경로 입력 · 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 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", "경로가 너무 깁니다 (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",
"계속 (앱이 켜져 있는 동안)": "Always (while the app is running)", "계속 (앱이 켜져 있는 동안)": "Always (while the app is running)",
"계속 받기": "Keep downloading", "계속 받기": "Keep downloading",
@ -606,6 +611,9 @@
"골라서 시작": "Choose and start", "골라서 시작": "Choose and start",
"공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.": "Cannot roll back: shared storage (Download) is not available.", "공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.": "Cannot roll back: shared storage (Download) is not available.",
"공용 폴더에 받은 파일 (동영상 · 그림 등)": "Files downloaded to shared folders (videos, images, etc.)", "공용 폴더에 받은 파일 (동영상 · 그림 등)": "Files downloaded to shared folders (videos, images, etc.)",
"공유": "Share",
"공유 (다른 앱으로 보내기)": "Share (send to another app)",
"공유 창을 열 수 없습니다.": "Can't open the share sheet.",
"관리 메뉴": "Manage menu", "관리 메뉴": "Manage menu",
"관리자 권한으로 실행 중이라 Chrome 엔진을 쓸 수 없습니다 (보안)": "The Chrome engine can't be used because the app is running as administrator (security)", "관리자 권한으로 실행 중이라 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.", "관리자 권한으로 실행할 때만 쓰입니다. 끄면 (기본) 그때 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…", "즐겨찾기 추가…": "Add bookmark…",
"즐겨찾기 추가됨": "Bookmark added", "즐겨찾기 추가됨": "Bookmark added",
"즐겨찾기 표시줄": "Bookmarks bar", "즐겨찾기 표시줄": "Bookmarks bar",
"즐겨찾기에 넣었습니다: {0}": "Added to favorites: {0}",
"즐겨찾기에 추가": "Add to favorites",
"즐겨찾기에서 빼기 (폴더는 그대로)": "Remove from favorites (the folder stays)", "즐겨찾기에서 빼기 (폴더는 그대로)": "Remove from favorites (the folder stays)",
"지금": "Current", "지금": "Current",
"지금 {0}%": "Now {0}%", "지금 {0}%": "Now {0}%",
@ -2174,6 +2184,7 @@
"폴더": "Folder", "폴더": "Folder",
"폴더 + 파일 목록": "Folders + file list", "폴더 + 파일 목록": "Folders + file list",
"폴더 {0}개 (크기 빼고)": "{0} folders (size not counted)", "폴더 {0}개 (크기 빼고)": "{0} folders (size not counted)",
"폴더 {0}개는 빼고 파일만 보냈습니다.": "Sent files only ({0} folders left out).",
"폴더 고르기": "Choose folder", "폴더 고르기": "Choose folder",
"폴더 만들기": "New folder", "폴더 만들기": "New folder",
"폴더 복사 · 이동 방법": "Folder copy · move method", "폴더 복사 · 이동 방법": "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})", "폴더가 없거나 열 수 없습니다. 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}", "폴더가 없거나 열 수 없습니다: {0}": "The folder doesn't exist or can't be opened: {0}",
"폴더가 없습니다": "The folder does not exist", "폴더가 없습니다": "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.", "폴더는 보여도 그 안의 동영상 · 파일은 이 권한이 있어야 보입니다. 허용 화면에서 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}", "폴더를 만들 수 없습니다: {0}": "Cannot create the folder: {0}",
"폴더를 자기 안으로 복사 · 이동할 수 없습니다": "Cannot copy or move a folder into itself", "폴더를 자기 안으로 복사 · 이동할 수 없습니다": "Cannot copy or move a folder into itself",

View File

@ -467,6 +467,7 @@
"{0}개 · 주소 {1}개": "{0}個・URL {1}個", "{0}개 · 주소 {1}개": "{0}個・URL {1}個",
"{0}개 맞춤": "{0} 件を同期", "{0}개 맞춤": "{0} 件を同期",
"{0}개 보임": "{0} 件表示", "{0}개 보임": "{0} 件表示",
"{0}개 삭제": "{0} 件を削除",
"{0}개 선택": "{0}個選択", "{0}개 선택": "{0}個選択",
"{0}개 중 {1}개를 지우지 못했습니다": "{0} 個中 {1} 個を削除できませんでした", "{0}개 중 {1}개를 지우지 못했습니다": "{0} 個中 {1} 個を削除できませんでした",
"{0}개 지우고 맞추기": "{0} 個を削除して同期", "{0}개 지우고 맞추기": "{0} 個を削除して同期",
@ -577,11 +578,15 @@
"결과 폴더 열기: {0}": "出力フォルダーを開く: {0}", "결과 폴더 열기: {0}": "出力フォルダーを開く: {0}",
"결과물 이동 실패: {0} ({1})": "成果物の移動失敗: {0}({1})", "결과물 이동 실패: {0} ({1})": "成果物の移動失敗: {0}({1})",
"경로": "パス", "경로": "パス",
"경로 {0}개를 복사했습니다": "{0} 件のパスをコピーしました",
"경로 복사": "パスをコピー",
"경로 복사 ({0}개)": "パスをコピー ({0} 件)",
"경로 입력": "パスを入力", "경로 입력": "パスを入力",
"경로 입력 (예: D:\\영상 · \\\\NAS\\공유)": "パスを入力 (例: D:\\動画・\\\\NAS\\共有)", "경로 입력 (예: D:\\영상 · \\\\NAS\\공유)": "パスを入力 (例: D:\\動画・\\\\NAS\\共有)",
"경로 입력 · WebDAV": "パス入力 · WebDAV", "경로 입력 · WebDAV": "パス入力 · WebDAV",
"경로가 너무 길어 휴지통에 넣을 수 없습니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다).": "パスが長すぎてごみ箱に入れられません (Windows のごみ箱は 260 文字を超えるパスを受け付けません)。", "경로가 너무 길어 휴지통에 넣을 수 없습니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다).": "パスが長すぎてごみ箱に入れられません (Windows のごみ箱は 260 文字を超えるパスを受け付けません)。",
"경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "パスが長すぎます (Windows のごみ箱は 260 文字を超えるパスを受け付けません) - 完全に削除はできます", "경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "パスが長すぎます (Windows のごみ箱は 260 文字を超えるパスを受け付けません) - 完全に削除はできます",
"경로를 복사했습니다: {0}": "パスをコピーしました: {0}",
"계속": "常時", "계속": "常時",
"계속 (앱이 켜져 있는 동안)": "常時 (アプリの起動中)", "계속 (앱이 켜져 있는 동안)": "常時 (アプリの起動中)",
"계속 받기": "ダウンロードを続ける", "계속 받기": "ダウンロードを続ける",
@ -606,6 +611,9 @@
"골라서 시작": "選んで開始", "골라서 시작": "選んで開始",
"공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.": "共有ストレージ (Download) を使えないため戻せません。", "공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.": "共有ストレージ (Download) を使えないため戻せません。",
"공용 폴더에 받은 파일 (동영상 · 그림 등)": "共有フォルダーにダウンロードしたファイル (動画・画像など)", "공용 폴더에 받은 파일 (동영상 · 그림 등)": "共有フォルダーにダウンロードしたファイル (動画・画像など)",
"공유": "共有",
"공유 (다른 앱으로 보내기)": "共有 (他のアプリに送る)",
"공유 창을 열 수 없습니다.": "共有画面を開けません。",
"관리 메뉴": "管理メニュー", "관리 메뉴": "管理メニュー",
"관리자 권한으로 실행 중이라 Chrome 엔진을 쓸 수 없습니다 (보안)": "管理者権限で実行中のため Chrome エンジンを使えません (セキュリティ)", "관리자 권한으로 실행 중이라 Chrome 엔진을 쓸 수 없습니다 (보안)": "管理者権限で実行中のため Chrome エンジンを使えません (セキュリティ)",
"관리자 권한으로 실행할 때만 쓰입니다. 끄면 (기본) 그때 Chrome 엔진을 시작하지 않고 Edge 를 권합니다.": "管理者権限で実行するときだけ使われます。オフ (既定) の場合、そのときは Chrome エンジンを起動せず Edge をおすすめします。", "관리자 권한으로 실행할 때만 쓰입니다. 끄면 (기본) 그때 Chrome 엔진을 시작하지 않고 Edge 를 권합니다.": "管理者権限で実行するときだけ使われます。オフ (既定) の場合、そのときは Chrome エンジンを起動せず Edge をおすすめします。",
@ -1956,6 +1964,8 @@
"즐겨찾기 추가…": "ブックマークを追加…", "즐겨찾기 추가…": "ブックマークを追加…",
"즐겨찾기 추가됨": "ブックマークを追加しました", "즐겨찾기 추가됨": "ブックマークを追加しました",
"즐겨찾기 표시줄": "ブックマークバー", "즐겨찾기 표시줄": "ブックマークバー",
"즐겨찾기에 넣었습니다: {0}": "お気に入りに追加しました: {0}",
"즐겨찾기에 추가": "お気に入りに追加",
"즐겨찾기에서 빼기 (폴더는 그대로)": "お気に入りから外す (フォルダーはそのまま)", "즐겨찾기에서 빼기 (폴더는 그대로)": "お気に入りから外す (フォルダーはそのまま)",
"지금": "現在", "지금": "現在",
"지금 {0}%": "現在 {0}%", "지금 {0}%": "現在 {0}%",
@ -2174,6 +2184,7 @@
"폴더": "フォルダー", "폴더": "フォルダー",
"폴더 + 파일 목록": "フォルダー + ファイル一覧", "폴더 + 파일 목록": "フォルダー + ファイル一覧",
"폴더 {0}개 (크기 빼고)": "フォルダー {0} 件 (サイズ除く)", "폴더 {0}개 (크기 빼고)": "フォルダー {0} 件 (サイズ除く)",
"폴더 {0}개는 빼고 파일만 보냈습니다.": "ファイルだけ送りました (フォルダー {0} 件は除外)。",
"폴더 고르기": "フォルダーを選択", "폴더 고르기": "フォルダーを選択",
"폴더 만들기": "フォルダーを作成", "폴더 만들기": "フォルダーを作成",
"폴더 복사 · 이동 방법": "フォルダーのコピー · 移動方法", "폴더 복사 · 이동 방법": "フォルダーのコピー · 移動方法",
@ -2192,6 +2203,7 @@
"폴더가 없거나 열 수 없습니다. Windows 의 네트워크 공유는 \\\\서버\\공유 형식입니다 (바꾼 경로: {0})": "フォルダーが存在しないか開けません。Windows のネットワーク共有は \\\\サーバー\\共有 の形式です (変換したパス: {0})", "폴더가 없거나 열 수 없습니다. Windows 의 네트워크 공유는 \\\\서버\\공유 형식입니다 (바꾼 경로: {0})": "フォルダーが存在しないか開けません。Windows のネットワーク共有は \\\\サーバー\\共有 の形式です (変換したパス: {0})",
"폴더가 없거나 열 수 없습니다: {0}": "フォルダーがないか、開けません: {0}", "폴더가 없거나 열 수 없습니다: {0}": "フォルダーがないか、開けません: {0}",
"폴더가 없습니다": "フォルダーがありません", "폴더가 없습니다": "フォルダーがありません",
"폴더는 공유할 수 없습니다. 파일을 고르세요.": "フォルダーは共有できません。ファイルを選んでください。",
"폴더는 보여도 그 안의 동영상 · 파일은 이 권한이 있어야 보입니다. 허용 화면에서 JJ_MKVMaker 를 켜고 돌아오세요.": "フォルダーは見えても、中の動画・ファイルはこの権限が必要です。許可画面で JJ_MKVMaker をオンにして戻ってください。", "폴더는 보여도 그 안의 동영상 · 파일은 이 권한이 있어야 보입니다. 허용 화면에서 JJ_MKVMaker 를 켜고 돌아오세요.": "フォルダーは見えても、中の動画・ファイルはこの権限が必要です。許可画面で JJ_MKVMaker をオンにして戻ってください。",
"폴더를 만들 수 없습니다: {0}": "フォルダーを作成できません: {0}", "폴더를 만들 수 없습니다: {0}": "フォルダーを作成できません: {0}",
"폴더를 자기 안으로 복사 · 이동할 수 없습니다": "フォルダーを自分自身の中にコピー · 移動できません", "폴더를 자기 안으로 복사 · 이동할 수 없습니다": "フォルダーを自分自身の中にコピー · 移動できません",

View File

@ -467,6 +467,7 @@
"{0}개 · 주소 {1}개": "{0} 个 · 地址 {1} 个", "{0}개 · 주소 {1}개": "{0} 个 · 地址 {1} 个",
"{0}개 맞춤": "已同步 {0} 个", "{0}개 맞춤": "已同步 {0} 个",
"{0}개 보임": "显示 {0} 项", "{0}개 보임": "显示 {0} 项",
"{0}개 삭제": "删除 {0} 项",
"{0}개 선택": "已选 {0} 个", "{0}개 선택": "已选 {0} 个",
"{0}개 중 {1}개를 지우지 못했습니다": "{0} 个中有 {1} 个无法删除", "{0}개 중 {1}개를 지우지 못했습니다": "{0} 个中有 {1} 个无法删除",
"{0}개 지우고 맞추기": "删除 {0} 个并同步", "{0}개 지우고 맞추기": "删除 {0} 个并同步",
@ -577,11 +578,15 @@
"결과 폴더 열기: {0}": "打开输出文件夹: {0}", "결과 폴더 열기: {0}": "打开输出文件夹: {0}",
"결과물 이동 실패: {0} ({1})": "成品移动失败: {0}({1})", "결과물 이동 실패: {0} ({1})": "成品移动失败: {0}({1})",
"경로": "路径", "경로": "路径",
"경로 {0}개를 복사했습니다": "已复制 {0} 个路径",
"경로 복사": "复制路径",
"경로 복사 ({0}개)": "复制路径 ({0} 个)",
"경로 입력": "输入路径", "경로 입력": "输入路径",
"경로 입력 (예: D:\\영상 · \\\\NAS\\공유)": "输入路径 (例: D:\\视频 · \\\\NAS\\共享)", "경로 입력 (예: D:\\영상 · \\\\NAS\\공유)": "输入路径 (例: D:\\视频 · \\\\NAS\\共享)",
"경로 입력 · WebDAV": "输入路径 · WebDAV", "경로 입력 · WebDAV": "输入路径 · WebDAV",
"경로가 너무 길어 휴지통에 넣을 수 없습니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다).": "路径过长,无法放入回收站 (Windows 回收站不接受超过 260 个字符的路径)。", "경로가 너무 길어 휴지통에 넣을 수 없습니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다).": "路径过长,无法放入回收站 (Windows 回收站不接受超过 260 个字符的路径)。",
"경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "路径过长 (Windows 回收站不接受超过 260 个字符的路径) - 可以永久删除", "경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "路径过长 (Windows 回收站不接受超过 260 个字符的路径) - 可以永久删除",
"경로를 복사했습니다: {0}": "已复制路径: {0}",
"계속": "持续", "계속": "持续",
"계속 (앱이 켜져 있는 동안)": "持续 (应用运行期间)", "계속 (앱이 켜져 있는 동안)": "持续 (应用运行期间)",
"계속 받기": "继续下载", "계속 받기": "继续下载",
@ -606,6 +611,9 @@
"골라서 시작": "选择后开始", "골라서 시작": "选择后开始",
"공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.": "无法使用公共存储 (Download),因此无法回退。", "공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.": "无法使用公共存储 (Download),因此无法回退。",
"공용 폴더에 받은 파일 (동영상 · 그림 등)": "下载到公共文件夹的文件 (视频、图片等)", "공용 폴더에 받은 파일 (동영상 · 그림 등)": "下载到公共文件夹的文件 (视频、图片等)",
"공유": "分享",
"공유 (다른 앱으로 보내기)": "分享 (发送到其他应用)",
"공유 창을 열 수 없습니다.": "无法打开分享窗口。",
"관리 메뉴": "管理菜单", "관리 메뉴": "管理菜单",
"관리자 권한으로 실행 중이라 Chrome 엔진을 쓸 수 없습니다 (보안)": "应用正以管理员权限运行,无法使用 Chrome 引擎(安全原因)", "관리자 권한으로 실행 중이라 Chrome 엔진을 쓸 수 없습니다 (보안)": "应用正以管理员权限运行,无法使用 Chrome 引擎(安全原因)",
"관리자 권한으로 실행할 때만 쓰입니다. 끄면 (기본) 그때 Chrome 엔진을 시작하지 않고 Edge 를 권합니다.": "仅在以管理员权限运行时使用。关闭时(默认)届时不启动 Chrome 引擎,并建议使用 Edge。", "관리자 권한으로 실행할 때만 쓰입니다. 끄면 (기본) 그때 Chrome 엔진을 시작하지 않고 Edge 를 권합니다.": "仅在以管理员权限运行时使用。关闭时(默认)届时不启动 Chrome 引擎,并建议使用 Edge。",
@ -1956,6 +1964,8 @@
"즐겨찾기 추가…": "添加书签…", "즐겨찾기 추가…": "添加书签…",
"즐겨찾기 추가됨": "已添加书签", "즐겨찾기 추가됨": "已添加书签",
"즐겨찾기 표시줄": "书签栏", "즐겨찾기 표시줄": "书签栏",
"즐겨찾기에 넣었습니다: {0}": "已添加到收藏: {0}",
"즐겨찾기에 추가": "添加到收藏",
"즐겨찾기에서 빼기 (폴더는 그대로)": "从收藏中移除(文件夹保留)", "즐겨찾기에서 빼기 (폴더는 그대로)": "从收藏中移除(文件夹保留)",
"지금": "当前", "지금": "当前",
"지금 {0}%": "当前 {0}%", "지금 {0}%": "当前 {0}%",
@ -2174,6 +2184,7 @@
"폴더": "文件夹", "폴더": "文件夹",
"폴더 + 파일 목록": "文件夹 + 文件列表", "폴더 + 파일 목록": "文件夹 + 文件列表",
"폴더 {0}개 (크기 빼고)": "{0} 个文件夹(不计大小)", "폴더 {0}개 (크기 빼고)": "{0} 个文件夹(不计大小)",
"폴더 {0}개는 빼고 파일만 보냈습니다.": "只发送了文件 (排除了 {0} 个文件夹)。",
"폴더 고르기": "选择文件夹", "폴더 고르기": "选择文件夹",
"폴더 만들기": "新建文件夹", "폴더 만들기": "新建文件夹",
"폴더 복사 · 이동 방법": "文件夹复制 · 移动方式", "폴더 복사 · 이동 방법": "文件夹复制 · 移动方式",
@ -2192,6 +2203,7 @@
"폴더가 없거나 열 수 없습니다. Windows 의 네트워크 공유는 \\\\서버\\공유 형식입니다 (바꾼 경로: {0})": "文件夹不存在或无法打开。Windows 的网络共享格式为 \\\\服务器\\共享(转换后的路径:{0})", "폴더가 없거나 열 수 없습니다. Windows 의 네트워크 공유는 \\\\서버\\공유 형식입니다 (바꾼 경로: {0})": "文件夹不存在或无法打开。Windows 的网络共享格式为 \\\\服务器\\共享(转换后的路径:{0})",
"폴더가 없거나 열 수 없습니다: {0}": "文件夹不存在或无法打开: {0}", "폴더가 없거나 열 수 없습니다: {0}": "文件夹不存在或无法打开: {0}",
"폴더가 없습니다": "文件夹不存在", "폴더가 없습니다": "文件夹不存在",
"폴더는 공유할 수 없습니다. 파일을 고르세요.": "文件夹无法分享。请选择文件。",
"폴더는 보여도 그 안의 동영상 · 파일은 이 권한이 있어야 보입니다. 허용 화면에서 JJ_MKVMaker 를 켜고 돌아오세요.": "虽然能看到文件夹,但其中的视频和文件需要此权限。请在权限界面开启 JJ_MKVMaker 后返回。", "폴더는 보여도 그 안의 동영상 · 파일은 이 권한이 있어야 보입니다. 허용 화면에서 JJ_MKVMaker 를 켜고 돌아오세요.": "虽然能看到文件夹,但其中的视频和文件需要此权限。请在权限界面开启 JJ_MKVMaker 后返回。",
"폴더를 만들 수 없습니다: {0}": "无法创建文件夹: {0}", "폴더를 만들 수 없습니다: {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' (기본 앱) 만 있다 /// 환경 설정의 확장자별 재생 프로그램: Android 는 'system' (기본 앱) 만 있다
@override @override
Future<void> openExternal(String program, List<String> files) async { 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); 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 @override
Future<bool> openWith(String path, {bool choose = false}) async { Future<bool> openWith(String path, {bool choose = false}) async {

View File

@ -70,6 +70,10 @@ abstract class AppShell {
/// 열지 못하면 false. /// 열지 못하면 false.
Future<bool> openWith(String path, {bool choose = 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' 중) /// 설치된 외부 브라우저 ('chrome', 'firefox', 'edge', 'whale' 중)
List<String> installedBrowsers(); List<String> installedBrowsers();
@ -139,6 +143,10 @@ class NoopShell implements AppShell {
@override @override
Future<bool> openWith(String path, {bool choose = false}) async => false; Future<bool> openWith(String path, {bool choose = false}) async => false;
@override @override
bool get canShare => false;
@override
Future<bool> shareFiles(List<String> paths) async => false;
@override
List<String> installedBrowsers() => const []; List<String> installedBrowsers() => const [];
@override @override
Future<List<String>> missingTools() async => const []; 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/app_controller.dart';
import '../app/copy_center.dart'; import '../app/copy_center.dart';
import '../app/live_sync.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 '../app/transfer_job.dart';
import '../core/secret_gate.dart'; import '../core/secret_gate.dart';
import '../core/sync_tools.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 는 그대로 /// 화면에 보일 경로 (5 · 14): Android 는 "SD 카드 › JJ_sdtest › jj_mkv", WebDAV 는 "☁ 서버 › 폴더", Windows 는 그대로
String _displayPath(String path) { String _displayPath(String path) {
if (isDav(path)) { if (isDav(path)) {
@ -1276,6 +1287,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
final viewable = viewer && !e.isDir && final viewable = viewer && !e.isDir &&
(c.settings.imageExts.contains(ext) || ext == 'pdf' || zipExtensions.contains(ext)); (c.settings.imageExts.contains(ext) || ext == 'pdf' || zipExtensions.contains(ext));
final dual = _dual; 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 화면은 폴더만 보이므로 폴더에 맞는 것만 (고르기 · 열기 · 이름 변경 · 삭제 · 정보) // 25: Rsync 화면은 폴더만 보이므로 폴더에 맞는 것만 (고르기 · 열기 · 이름 변경 · 삭제 · 정보)
final items = widget.rsync final items = widget.rsync
? <(String, IconData, String)>[ ? <(String, IconData, String)>[
@ -1289,6 +1303,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
('info', Icons.info_outline, tr('정보')), ('info', Icons.info_outline, tr('정보')),
] ]
: <(String, IconData, String)>[ : <(String, IconData, String)>[
// 77: 복사 · 이동 · 이름 변경 · 삭제 · 공유는 위의 아이콘 줄 ([_menuIcons]). 아래는 상황별 항목
// 74: 폰에서 범위 고르기 - 첫 항목을 표시한 뒤 마지막 항목을 길게 눌러 // 74: 폰에서 범위 고르기 - 첫 항목을 표시한 뒤 마지막 항목을 길게 눌러
if (_selecting && _canRange(pane, e)) ('range', Icons.unfold_more, tr('여기까지 범위 고르기')), if (_selecting && _canRange(pane, e)) ('range', Icons.unfold_more, tr('여기까지 범위 고르기')),
if (e.isDir) ('open', Icons.folder_open, tr('열기')), if (e.isDir) ('open', Icons.folder_open, tr('열기')),
@ -1303,23 +1318,41 @@ class _ExplorerPageState extends State<ExplorerPage> {
('toPdf', Icons.picture_as_pdf_outlined, ('toPdf', Icons.picture_as_pdf_outlined,
images.length == 1 ? tr('PDF 로 만들기') : trf('그림 {0}장을 PDF 로 만들기', [images.length])), images.length == 1 ? tr('PDF 로 만들기') : trf('그림 {0}장을 PDF 로 만들기', [images.length])),
('select', Icons.check_circle_outline, tr('선택')), ('select', Icons.check_circle_outline, tr('선택')),
('rename', Icons.drive_file_rename_outline, tr('이름 변경')), // 두 창이면 아이콘 줄의 복사 · 이동은 다른 창으로 - 담아 두었다가 원하는 폴더에 붙여넣기는 여기
if (dual) ('copy', Icons.copy_outlined, tr('다른 창으로 복사')), if (dual) ('clipCopy', Icons.content_copy, tr('복사 (붙여넣기로)')),
if (dual) ('move', Icons.drive_file_move_outline, tr('다른 창으로 이동')), if (dual) ('clipMove', Icons.content_cut, tr('이동 (붙여넣기로)')),
// 어느 배치에서나: 담아 두었다가 원하는 폴더에서 붙여넣기
('clipCopy', Icons.content_copy, dual ? tr('복사 (붙여넣기로)') : tr('복사')),
('clipMove', Icons.content_cut, dual ? tr('이동 (붙여넣기로)') : tr('이동')),
if (_clip.isNotEmpty) if (_clip.isNotEmpty)
('paste', Icons.content_paste, trf('{0} 에 붙여넣기 ({1}개)', [vBasename(e.isDir ? e.path : vDirname(e.path)), _clip.length])), ('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('파일 관리자에서 보기')), if (!dav && (!Platform.isAndroid || e.isDir)) ('reveal', Icons.folder_outlined, tr('파일 관리자에서 보기')),
('info', Icons.info_outline, 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 size = MediaQuery.sizeOf(context);
final pick = await showMenu<String>( final pick = await showMenu<String>(
context: context, context: context,
position: RelativeRect.fromLTRB(at.dx, at.dy, size.width - at.dx, size.height - at.dy), position: RelativeRect.fromLTRB(at.dx, at.dy, size.width - at.dx, size.height - at.dy),
items: [ 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) for (final (id, icon, label) in items)
PopupMenuItem( PopupMenuItem(
value: id, value: id,
@ -1371,7 +1404,19 @@ class _ExplorerPageState extends State<ExplorerPage> {
case 'paste': case 'paste':
await _paste(pane, e.isDir ? e.path : vDirname(e.path)); await _paste(pane, e.isDir ? e.path : vDirname(e.path));
case 'delete': 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': case 'view':
await _open(e.path, pane: pane); await _open(e.path, pane: pane);
case 'toPdf': 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]; _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 tester.longPress(find.text('doc.txt'));
await calm(tester); await calm(tester);
expect(find.text('다른 창으로 복사'), findsOneWidget); // 77: 복사 · 이동 · 이름 변경 · 삭제는 메뉴 위 아이콘 줄 (두 창이면 복사는 다른 창으로)
expect(find.byTooltip('다른 창으로 복사'), findsOneWidget);
expect(find.text('삭제'), findsWidgets); expect(find.text('삭제'), findsWidgets);
await act(tester, () => tester.tap(find.text('다른 앱으로 열기'))); await act(tester, () => tester.tap(find.text('다른 앱으로 열기')));
await settle(tester, () => shell.opened.length == 2); await settle(tester, () => shell.opened.length == 2);
@ -193,7 +194,7 @@ void main() {
// 이 배치에도 복사 · 이동: 길게 눌러 [복사] 로 담고 → 다른 폴더를 골라 [붙여넣기] // 이 배치에도 복사 · 이동: 길게 눌러 [복사] 로 담고 → 다른 폴더를 골라 [붙여넣기]
await tester.longPress(find.text('inner.txt')); await tester.longPress(find.text('inner.txt'));
await calm(tester); await calm(tester);
expect(find.text('다른 창으로 복사'), findsNothing); expect(find.byTooltip('다른 창으로 복사'), findsNothing);
await tester.tap(find.text('복사').last); await tester.tap(find.text('복사').last);
await calm(tester); await calm(tester);
await act(tester, () => tester.tap(find.text('..'))); 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'); 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 { testWidgets('75: 창마다 뒤로 · 앞으로 - 버튼 · Alt+← / → · 마우스 4 · 5번', (tester) async {
await open(tester); await open(tester);
final sub = p.join(left, 'sub'); final sub = p.join(left, 'sub');