WIP X-plore e: Android 앱 휴지통 · 85 진행 패널 시험 흔들림 · X-plore 캡처 시험 (검토 대기)

- e (관리자 지시): Android 는 저장 장치마다 .jj_trash 에 보관 (처음 14일, 설정 0 = 끔 - 예전처럼 바로 영구 삭제).
  지우기 = 휴지통으로 · [되돌리기] 알림 · 휴지통 화면 (되살리기 · 영구 삭제 · 비우기), 켤 때 기한 지난 것 정리.
  휴지통으로 못 옮긴 것은 영구 삭제할지 따로 묻는다 (몰래 영구 삭제하지 않음). Windows 의 [휴지통] 버튼은 시스템 휴지통을 연다.
- 내 판단: 한 창 "갈 곳" 줄은 전체 경로 대신 폴더 이름 (좁은 화면에서 잘림).
- 85 시험 흔들림 (관리자 지시): pumpAndSettle 대신 조건 기다리기 · 칩 onTap 직접 (3번 연속 통과).
- integration_test/windows_xplore_shot_test.dart: X-plore P1 화면 캡처 (ko/en × 412/960).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
xplore_p3
RyuJongJin 2026-10-18 00:28:06 +09:00
parent c2c43825d6
commit f4760cb4d7
11 changed files with 769 additions and 11 deletions

View File

@ -497,6 +497,7 @@
"{0}개 · {1}": "{0} · {1}",
"{0}개 · {1} …": "{0} items · {1} …",
"{0}개 · 주소 {1}개": "{0} · {1} links",
"{0}개 고름": "{0} selected",
"{0}개 더 보기": "Show {0} more",
"{0}개 맞춤": "{0} synced",
"{0}개 보임": "{0} shown",
@ -526,12 +527,15 @@
"{0}개는 휴지통에 들어가지 않아 영구 삭제했습니다 (Windows 가 묻고 고른 대로).": "{0} didn't fit in the Recycle Bin and were deleted permanently (as chosen when Windows asked).",
"{0}개를 \"기타 즐겨찾기\" 에 가져왔습니다.": "Imported {0} into \"Other bookmarks\".",
"{0}개를 묶었습니다: {1}": "Compressed {0} files: {1}",
"{0}개를 영구 삭제할까요?": "Delete {0} items permanently?",
"{0}개를 영구 삭제했습니다.": "Permanently deleted {0} item(s).",
"{0}개를 옮겼습니다 → {1}": "Moved {0} → {1}",
"{0}개를 옮겼습니다 → {1} ({2})": "Moved {0} → {1} ({2})",
"{0}개를 옮겼습니다.": "Moved {0} items.",
"{0}개를 원래 자리로 되살렸습니다": "Restored {0} items to their original location",
"{0}개를 찾아 여기서 멈췄습니다. 이름을 더 자세히 넣거나 아래 폴더로 들어가 찾으세요.": "Stopped after finding {0}. Enter a more specific name or search inside a subfolder.",
"{0}개를 풀었습니다: {1}": "Extracted {0}: {1}",
"{0}개를 휴지통으로 옮기지 못했습니다": "Couldn't move {0} items to the trash",
"{0}개에서 멈췄습니다": "Stopped at {0}",
"{0}개의 이름을 바꿨습니다.": "Renamed {0} items.",
"{0}배": "{0}×",
@ -539,6 +543,7 @@
"{0}분": "{0} min",
"{0}분 {1}초": "{0} min {1} s",
"{0}시간 {1}분": "{0} h {1} min",
"{0}일": "{0} days",
"{0}일 안": "within {0} days",
"{0}일 지남": "older than {0} days",
"{0}장 모두 해상도 올리기": "Upscale all {0} pages",
@ -750,6 +755,7 @@
"끌어 놓기 기본": "Drag and drop default",
"끌어다 놓은 항목에 동영상이 없습니다.": "There are no videos in the dropped items.",
"끌어서 다른 창으로": "Drag to the other pane",
"끔": "Off",
"끝": "End",
"끝 (오른쪽 · 아래)": "Edge (right · bottom)",
"끝 = 현재": "End = now",
@ -987,6 +993,8 @@
"되돌릴 수 없습니다.": "This can't be undone.",
"되살리고 다시 시작": "Restore and restart",
"되살리기": "Restore",
"되살리지 못함: {0}": "Couldn't restore: {0}",
"되살림": "restored",
"두 번 넣은 비밀번호가 다릅니다": "The two passwords don't match",
"두 쪽 나눔 끄기 (한 장으로 보기)": "Turn off two-page split (show as one)",
"두 쪽 나눠 보기": "Split two-page spreads",
@ -1318,6 +1326,7 @@
"비어 있는 폴더": "Empty folder",
"비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "This folder is empty.\nPress [Add bookmark] · [New folder] above, or drag bookmarks onto a folder on the left.",
"비어 있음": "Empty",
"비우기": "Empty",
"비우면 지움": "Empty = remove",
"빈 이름": "Empty name",
"빈 폴더 {0}개 지움": "Deleted {0} empty folders",
@ -1563,6 +1572,7 @@
"앱 안 웹 브라우저 · 즐겨찾기 · 동영상 다운로드": "In-app web browser · bookmarks · video downloads",
"앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "In-app web browser: presses [Skip] for you, and mutes and fast-forwards ads that can't be skipped (\"Skipping ad…\" shows at the top while skipping)",
"앱 지우기": "Uninstall app",
"앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).": "Kept in the app trash for {0} days, then deleted (restore from [Trash] in the explorer).",
"앱에 들어 있는 것 (기본)": "Built into the app (default)",
"앱에 들어 있습니다 (Tor {0}, tor {1}). 켜도 Tor 화면을 열 때까지 아무것도 시작하지 않습니다.": "Built into the app (Tor {0}, tor {1}). Turning it on starts nothing until the Tor screen is opened.",
"앱을 다시 설치하자 Android 자동 백업 (Google 백업) 이 이전 설정을 되살렸습니다.\n· 동영상 목록 {0}개\n· WebDAV 서버: {1}": "After reinstalling the app, Android Auto Backup (Google backup) restored your previous settings.\n· {0} videos in the list\n· WebDAV servers: {1}",
@ -1617,6 +1627,7 @@
"열기 · 재생": "Open · Play",
"영구 삭제": "Delete permanently",
"영구 삭제 ({0}개, 되돌릴 수 없음)": "Delete permanently ({0}, cannot be undone)",
"영구 삭제하면 되살릴 수 없습니다.": "Permanently deleted items can't be restored.",
"영상": "Video",
"영상 {0}": "Video {0}",
"영상 · 음성 합치는 중": "Merging video · audio",
@ -2024,6 +2035,7 @@
"저장 안 함": "Don't save",
"저장 위치": "Save locations",
"저장 장치 · 서버 · 즐겨찾기를 트리 맨 위에": "Storage, servers and favorites at the top of the tree",
"저장 장치를 알 수 없습니다": "Unknown storage",
"저장 폴더": "Save folder",
"저장 형식": "Save format",
"저장: {0}": "Save: {0}",
@ -2182,6 +2194,8 @@
"지우지 못했습니다: {0}": "Could not delete: {0}",
"지우지 않고 맞춤 · 대상에만 있는 {0}개를 지울지 확인 필요": "Synced without deleting · confirm whether to delete {0} items only in the target",
"지우지 않기 (지우기 끄기)": "Don't delete (turn off delete)",
"지운 것은 {0}일 동안 여기에 두었다가 저절로 지웁니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).": "Deleted items are kept here for {0} days and then removed automatically (Settings > File explorer > Keep in trash).",
"지운 것을 저장 장치의 앱 휴지통에 두었다가 이 날이 지나면 지웁니다. 끄면 바로 영구 삭제": "Deleted items go to the app trash on that storage and are removed after this many days. Off = delete permanently right away",
"지울 목록 보고 결정": "Review the list and decide",
"지울 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "Choose items to delete (tap, or mark with the circle on the right).",
"지원하지 않는 protobuf 형식 (wire type {0}, 위치 {1})": "Unsupported protobuf format (wire type {0}, position {1})",
@ -2540,10 +2554,20 @@
"회전 없음": "No rotation",
"회차": "Episode",
"휴대폰 · 태블릿은 AV1 · VP9 을 하드웨어로 풀지 못해 끊기거나 배터리를 많이 쓰는 일이 있습니다. YouTube 의 H.264 는 1080p 까지라 더 큰 화질을 고르면 크기가 먼저입니다.": "Phones and tablets often can't decode AV1/VP9 in hardware, causing stutter or heavy battery use. YouTube's H.264 goes up to 1080p, so for higher qualities size comes first.",
"휴지통": "Trash",
"휴지통 · {0}개 · {1}": "Trash · {0} items · {1}",
"휴지통 보관": "Keep in trash",
"휴지통 비우기": "Empty trash",
"휴지통에서 지운 것은 되살릴 수 없습니다.": "Items deleted from the trash can't be restored.",
"휴지통에서 찾지 못했습니다 (이미 비웠거나 되돌림)": "Not found in the Recycle Bin (already emptied or restored)",
"휴지통으로": "To Recycle Bin",
"휴지통으로 보내기": "Move to Recycle Bin",
"휴지통으로 옮기지 못했습니다: {0} ({1})": "Couldn't move to the trash: {0} ({1})",
"휴지통을 거치지 않고 영구 삭제": "Delete permanently without the trash",
"휴지통을 거치지 않고 영구 삭제 (Shift+Delete)": "Delete permanently without the Recycle Bin (Shift+Delete)",
"휴지통이 꺼져 있습니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).": "The trash is off (Settings > File explorer > Keep in trash).",
"휴지통이 꺼져 있습니다 - 지우면 바로 영구 삭제됩니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).": "The trash is off - deleting removes items permanently right away (Settings > File explorer > Keep in trash).",
"휴지통이 비어 있습니다": "The trash is empty",
"휴지통이 없는 곳입니다": "This location has no Recycle Bin",
"휴지통이 없습니다": "No Recycle Bin",
"히브리어": "Hebrew",

View File

@ -497,6 +497,7 @@
"{0}개 · {1}": "{0}個・{1}",
"{0}개 · {1} …": "{0} 件 · {1} …",
"{0}개 · 주소 {1}개": "{0}個・URL {1}個",
"{0}개 고름": "{0} 件選択",
"{0}개 더 보기": "さらに {0} 件表示",
"{0}개 맞춤": "{0} 件を同期",
"{0}개 보임": "{0} 件表示",
@ -526,12 +527,15 @@
"{0}개는 휴지통에 들어가지 않아 영구 삭제했습니다 (Windows 가 묻고 고른 대로).": "{0} 件はごみ箱に入らなかったため完全に削除しました (Windows の確認で選んだとおり)。",
"{0}개를 \"기타 즐겨찾기\" 에 가져왔습니다.": "{0}個を「その他のブックマーク」に取り込みました。",
"{0}개를 묶었습니다: {1}": "{0} 件をまとめました: {1}",
"{0}개를 영구 삭제할까요?": "{0} 件を完全に削除しますか?",
"{0}개를 영구 삭제했습니다.": "{0} 件を完全に削除しました。",
"{0}개를 옮겼습니다 → {1}": "{0}個を移動しました → {1}",
"{0}개를 옮겼습니다 → {1} ({2})": "{0}個を移動しました → {1}({2})",
"{0}개를 옮겼습니다.": "{0} 件を移動しました。",
"{0}개를 원래 자리로 되살렸습니다": "{0} 件を元の場所に戻しました",
"{0}개를 찾아 여기서 멈췄습니다. 이름을 더 자세히 넣거나 아래 폴더로 들어가 찾으세요.": "{0} 件見つけたところで止めました。名前をより詳しく入力するか、下のフォルダーに入って探してください。",
"{0}개를 풀었습니다: {1}": "{0} 個を展開しました: {1}",
"{0}개를 휴지통으로 옮기지 못했습니다": "{0} 件をゴミ箱に移せませんでした",
"{0}개에서 멈췄습니다": "{0} 件で停止しました",
"{0}개의 이름을 바꿨습니다.": "{0} 件の名前を変更しました。",
"{0}배": "{0} 倍",
@ -539,6 +543,7 @@
"{0}분": "{0} 分",
"{0}분 {1}초": "{0}分 {1}秒",
"{0}시간 {1}분": "{0}時間 {1}分",
"{0}일": "{0} 日",
"{0}일 안": "{0} 日以内",
"{0}일 지남": "{0} 日以上前",
"{0}장 모두 해상도 올리기": "{0} ページすべてを高解像度化",
@ -750,6 +755,7 @@
"끌어 놓기 기본": "ドラッグ&ドロップの既定",
"끌어다 놓은 항목에 동영상이 없습니다.": "ドロップした項目に動画がありません。",
"끌어서 다른 창으로": "ドラッグしてもう一方のウィンドウへ",
"끔": "オフ",
"끝": "終了",
"끝 (오른쪽 · 아래)": "端 (右 · 下)",
"끝 = 현재": "終了 = 現在",
@ -987,6 +993,8 @@
"되돌릴 수 없습니다.": "元に戻せません。",
"되살리고 다시 시작": "復元して再起動",
"되살리기": "元に戻す",
"되살리지 못함: {0}": "元に戻せませんでした: {0}",
"되살림": "復元",
"두 번 넣은 비밀번호가 다릅니다": "2 回入力したパスワードが違います",
"두 쪽 나눔 끄기 (한 장으로 보기)": "見開き分割をオフ (1 枚で表示)",
"두 쪽 나눠 보기": "見開きを分けて表示",
@ -1318,6 +1326,7 @@
"비어 있는 폴더": "空のフォルダー",
"비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "空のフォルダーです。\n上の[ブックマーク追加]・[新しいフォルダー]を押すか、ブックマークを左のフォルダーにドラッグしてください。",
"비어 있음": "空",
"비우기": "空にする",
"비우면 지움": "空なら削除",
"빈 이름": "空の名前",
"빈 폴더 {0}개 지움": "空フォルダー {0} 個を削除",
@ -1563,6 +1572,7 @@
"앱 안 웹 브라우저 · 즐겨찾기 · 동영상 다운로드": "アプリ内 Web ブラウザー・ブックマーク・動画ダウンロード",
"앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "アプリ内ウェブブラウザー:[スキップ]を代わりに押し、スキップできない広告は音を消して早送りします(スキップ中は画面上部に「広告をスキップ中…」)",
"앱 지우기": "アプリを削除",
"앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).": "アプリのゴミ箱に {0} 日間置いてから削除します (エクスプローラーの [ゴミ箱] から復元できます)。",
"앱에 들어 있는 것 (기본)": "アプリに内蔵 (既定)",
"앱에 들어 있습니다 (Tor {0}, tor {1}). 켜도 Tor 화면을 열 때까지 아무것도 시작하지 않습니다.": "アプリに内蔵されています (Tor {0}、tor {1})。オンにしても Tor 画面を開くまで何も起動しません。",
"앱을 다시 설치하자 Android 자동 백업 (Google 백업) 이 이전 설정을 되살렸습니다.\n· 동영상 목록 {0}개\n· WebDAV 서버: {1}": "アプリを再インストールしたため、Android 自動バックアップ (Google バックアップ) が以前の設定を復元しました。\n・動画リスト {0} 件\n・WebDAV サーバー: {1}",
@ -1617,6 +1627,7 @@
"열기 · 재생": "開く・再生",
"영구 삭제": "完全に削除",
"영구 삭제 ({0}개, 되돌릴 수 없음)": "完全に削除 ({0} 件、元に戻せません)",
"영구 삭제하면 되살릴 수 없습니다.": "完全に削除すると元に戻せません。",
"영상": "映像",
"영상 {0}": "映像 {0}",
"영상 · 음성 합치는 중": "映像・音声を結合中",
@ -2024,6 +2035,7 @@
"저장 안 함": "保存しない",
"저장 위치": "保存先",
"저장 장치 · 서버 · 즐겨찾기를 트리 맨 위에": "ストレージ・サーバー・お気に入りをツリーの最上位に",
"저장 장치를 알 수 없습니다": "ストレージが分かりません",
"저장 폴더": "保存フォルダー",
"저장 형식": "保存形式",
"저장: {0}": "保存: {0}",
@ -2182,6 +2194,8 @@
"지우지 못했습니다: {0}": "削除できませんでした: {0}",
"지우지 않고 맞춤 · 대상에만 있는 {0}개를 지울지 확인 필요": "削除せずに同期・対象にだけある {0} 個を削除するか確認が必要",
"지우지 않기 (지우기 끄기)": "削除しない (削除をオフ)",
"지운 것은 {0}일 동안 여기에 두었다가 저절로 지웁니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).": "削除したものはここに {0} 日間置いてから自動で削除します (設定 > ファイルエクスプローラー > ゴミ箱の保管)。",
"지운 것을 저장 장치의 앱 휴지통에 두었다가 이 날이 지나면 지웁니다. 끄면 바로 영구 삭제": "削除したものはそのストレージのアプリのゴミ箱に置き、この日数が過ぎると削除します。オフにするとすぐに完全削除",
"지울 목록 보고 결정": "削除する一覧を見て決める",
"지울 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "削除する項目を選んでください (タップ、または右の丸でマーク)。",
"지원하지 않는 protobuf 형식 (wire type {0}, 위치 {1})": "対応していない protobuf 形式(wire type {0}、位置 {1})",
@ -2540,10 +2554,20 @@
"회전 없음": "回転なし",
"회차": "話数",
"휴대폰 · 태블릿은 AV1 · VP9 을 하드웨어로 풀지 못해 끊기거나 배터리를 많이 쓰는 일이 있습니다. YouTube 의 H.264 는 1080p 까지라 더 큰 화질을 고르면 크기가 먼저입니다.": "スマホ・タブレットは AV1・VP9 をハードウェアで再生できず、途切れたりバッテリーを多く使うことがあります。YouTube の H.264 は 1080p までなので、それより高い画質を選ぶとサイズが優先です。",
"휴지통": "ゴミ箱",
"휴지통 · {0}개 · {1}": "ゴミ箱 · {0} 件 · {1}",
"휴지통 보관": "ゴミ箱の保管",
"휴지통 비우기": "ゴミ箱を空にする",
"휴지통에서 지운 것은 되살릴 수 없습니다.": "ゴミ箱から削除したものは元に戻せません。",
"휴지통에서 찾지 못했습니다 (이미 비웠거나 되돌림)": "ごみ箱に見つかりません (空にしたか元に戻した)",
"휴지통으로": "ごみ箱へ",
"휴지통으로 보내기": "ごみ箱に移動",
"휴지통으로 옮기지 못했습니다: {0} ({1})": "ゴミ箱に移せませんでした: {0} ({1})",
"휴지통을 거치지 않고 영구 삭제": "ゴミ箱を使わずに完全に削除",
"휴지통을 거치지 않고 영구 삭제 (Shift+Delete)": "ごみ箱を使わずに完全に削除 (Shift+Delete)",
"휴지통이 꺼져 있습니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).": "ゴミ箱はオフです (設定 > ファイルエクスプローラー > ゴミ箱の保管)。",
"휴지통이 꺼져 있습니다 - 지우면 바로 영구 삭제됩니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).": "ゴミ箱はオフです - 削除するとすぐに完全削除されます (設定 > ファイルエクスプローラー > ゴミ箱の保管)。",
"휴지통이 비어 있습니다": "ゴミ箱は空です",
"휴지통이 없는 곳입니다": "ごみ箱がない場所です",
"휴지통이 없습니다": "ごみ箱がありません",
"히브리어": "ヘブライ語",

View File

@ -497,6 +497,7 @@
"{0}개 · {1}": "{0} 个 · {1}",
"{0}개 · {1} …": "{0} 项 · {1} …",
"{0}개 · 주소 {1}개": "{0} 个 · 地址 {1} 个",
"{0}개 고름": "已选 {0} 个",
"{0}개 더 보기": "再显示 {0} 个",
"{0}개 맞춤": "已同步 {0} 个",
"{0}개 보임": "显示 {0} 项",
@ -526,12 +527,15 @@
"{0}개는 휴지통에 들어가지 않아 영구 삭제했습니다 (Windows 가 묻고 고른 대로).": "{0} 个项目无法放入回收站,已永久删除 (按 Windows 询问时的选择)。",
"{0}개를 \"기타 즐겨찾기\" 에 가져왔습니다.": "已将 {0} 个导入“其他书签”。",
"{0}개를 묶었습니다: {1}": "已压缩 {0} 个文件: {1}",
"{0}개를 영구 삭제할까요?": "要永久删除 {0} 项吗?",
"{0}개를 영구 삭제했습니다.": "已永久删除 {0} 个项目。",
"{0}개를 옮겼습니다 → {1}": "已移动 {0} 个 → {1}",
"{0}개를 옮겼습니다 → {1} ({2})": "已移动 {0} 个 → {1}({2})",
"{0}개를 옮겼습니다.": "已移动 {0} 项。",
"{0}개를 원래 자리로 되살렸습니다": "已将 {0} 项还原到原位置",
"{0}개를 찾아 여기서 멈췄습니다. 이름을 더 자세히 넣거나 아래 폴더로 들어가 찾으세요.": "找到 {0} 个后已停止。请输入更具体的名称或进入下级文件夹查找。",
"{0}개를 풀었습니다: {1}": "已解压 {0} 个: {1}",
"{0}개를 휴지통으로 옮기지 못했습니다": "无法将 {0} 项移到回收站",
"{0}개에서 멈췄습니다": "已在 {0} 个处暂停",
"{0}개의 이름을 바꿨습니다.": "已重命名 {0} 项。",
"{0}배": "{0} 倍",
@ -539,6 +543,7 @@
"{0}분": "{0} 分钟",
"{0}분 {1}초": "{0} 分 {1} 秒",
"{0}시간 {1}분": "{0} 小时 {1} 分",
"{0}일": "{0} 天",
"{0}일 안": "{0} 天内",
"{0}일 지남": "超过 {0} 天",
"{0}장 모두 해상도 올리기": "提高全部 {0} 页的分辨率",
@ -750,6 +755,7 @@
"끌어 놓기 기본": "拖放默认操作",
"끌어다 놓은 항목에 동영상이 없습니다.": "拖入的项目中没有视频。",
"끌어서 다른 창으로": "拖到另一个窗格",
"끔": "关闭",
"끝": "结束",
"끝 (오른쪽 · 아래)": "边缘 (右 · 下)",
"끝 = 현재": "结束 = 当前",
@ -987,6 +993,8 @@
"되돌릴 수 없습니다.": "无法撤销。",
"되살리고 다시 시작": "恢复并重新启动",
"되살리기": "恢复",
"되살리지 못함: {0}": "无法还原: {0}",
"되살림": "已还原",
"두 번 넣은 비밀번호가 다릅니다": "两次输入的密码不一致",
"두 쪽 나눔 끄기 (한 장으로 보기)": "关闭双页拆分(整张显示)",
"두 쪽 나눠 보기": "拆分双页",
@ -1318,6 +1326,7 @@
"비어 있는 폴더": "空文件夹",
"비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "这是空文件夹。\n请点击上方的[添加书签] · [新建文件夹],或将书签拖到左侧文件夹。",
"비어 있음": "空",
"비우기": "清空",
"비우면 지움": "留空则删除",
"빈 이름": "空名称",
"빈 폴더 {0}개 지움": "已删除 {0} 个空文件夹",
@ -1563,6 +1572,7 @@
"앱 안 웹 브라우저 · 즐겨찾기 · 동영상 다운로드": "应用内浏览器 · 书签 · 视频下载",
"앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "应用内网页浏览器: 代为点击[跳过],无法跳过的广告会静音并快进(跳过时屏幕顶部显示“正在跳过广告…”)",
"앱 지우기": "卸载应用",
"앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).": "在应用回收站保留 {0} 天后删除 (可在资源管理器的 [回收站] 中还原)。",
"앱에 들어 있는 것 (기본)": "应用内置 (默认)",
"앱에 들어 있습니다 (Tor {0}, tor {1}). 켜도 Tor 화면을 열 때까지 아무것도 시작하지 않습니다.": "已内置于应用 (Tor {0},tor {1})。开启后在打开 Tor 画面之前不会启动任何内容。",
"앱을 다시 설치하자 Android 자동 백업 (Google 백업) 이 이전 설정을 되살렸습니다.\n· 동영상 목록 {0}개\n· WebDAV 서버: {1}": "重新安装应用后,Android 自动备份 (Google 备份) 恢复了之前的设置。\n· 视频列表 {0} 个\n· WebDAV 服务器: {1}",
@ -1617,6 +1627,7 @@
"열기 · 재생": "打开 · 播放",
"영구 삭제": "永久删除",
"영구 삭제 ({0}개, 되돌릴 수 없음)": "永久删除 ({0} 个,无法撤销)",
"영구 삭제하면 되살릴 수 없습니다.": "永久删除后无法还原。",
"영상": "视频",
"영상 {0}": "视频 {0}",
"영상 · 음성 합치는 중": "正在合并视频 · 音频",
@ -2024,6 +2035,7 @@
"저장 안 함": "不保存",
"저장 위치": "保存位置",
"저장 장치 · 서버 · 즐겨찾기를 트리 맨 위에": "将存储 · 服务器 · 收藏放在树的最上层",
"저장 장치를 알 수 없습니다": "未知的存储",
"저장 폴더": "保存文件夹",
"저장 형식": "保存格式",
"저장: {0}": "保存: {0}",
@ -2182,6 +2194,8 @@
"지우지 못했습니다: {0}": "无法删除: {0}",
"지우지 않고 맞춤 · 대상에만 있는 {0}개를 지울지 확인 필요": "已同步但未删除 · 需确认是否删除仅在目标中的 {0} 个项目",
"지우지 않기 (지우기 끄기)": "不删除 (关闭删除)",
"지운 것은 {0}일 동안 여기에 두었다가 저절로 지웁니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).": "删除的项目会在此保留 {0} 天后自动删除 (设置 > 文件资源管理器 > 回收站保留)。",
"지운 것을 저장 장치의 앱 휴지통에 두었다가 이 날이 지나면 지웁니다. 끄면 바로 영구 삭제": "删除的项目放入该存储的应用回收站,超过此天数后删除。关闭则立即永久删除",
"지울 목록 보고 결정": "查看要删除的列表并决定",
"지울 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "请选择要删除的项目 (点按,或用右侧圆圈标记)。",
"지원하지 않는 protobuf 형식 (wire type {0}, 위치 {1})": "不支持的 protobuf 格式(wire type {0},位置 {1})",
@ -2540,10 +2554,20 @@
"회전 없음": "不旋转",
"회차": "集数",
"휴대폰 · 태블릿은 AV1 · VP9 을 하드웨어로 풀지 못해 끊기거나 배터리를 많이 쓰는 일이 있습니다. YouTube 의 H.264 는 1080p 까지라 더 큰 화질을 고르면 크기가 먼저입니다.": "手机 · 平板常无法硬件解码 AV1 · VP9,会卡顿或耗电。YouTube 的 H.264 最高 1080p,选择更高画质时以尺寸优先。",
"휴지통": "回收站",
"휴지통 · {0}개 · {1}": "回收站 · {0} 项 · {1}",
"휴지통 보관": "回收站保留",
"휴지통 비우기": "清空回收站",
"휴지통에서 지운 것은 되살릴 수 없습니다.": "从回收站删除的项目无法还原。",
"휴지통에서 찾지 못했습니다 (이미 비웠거나 되돌림)": "在回收站中找不到 (已清空或已恢复)",
"휴지통으로": "移到回收站",
"휴지통으로 보내기": "移到回收站",
"휴지통으로 옮기지 못했습니다: {0} ({1})": "无法移到回收站: {0} ({1})",
"휴지통을 거치지 않고 영구 삭제": "不经回收站永久删除",
"휴지통을 거치지 않고 영구 삭제 (Shift+Delete)": "不经过回收站永久删除 (Shift+Delete)",
"휴지통이 꺼져 있습니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).": "回收站已关闭 (设置 > 文件资源管理器 > 回收站保留)。",
"휴지통이 꺼져 있습니다 - 지우면 바로 영구 삭제됩니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).": "回收站已关闭 - 删除会立即永久删除 (设置 > 文件资源管理器 > 回收站保留)。",
"휴지통이 비어 있습니다": "回收站为空",
"휴지통이 없는 곳입니다": "此位置没有回收站",
"휴지통이 없습니다": "没有回收站",
"히브리어": "希伯来语",

View File

@ -0,0 +1,84 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/app/i18n_controller.dart';
import 'package:jj_mkvmaker/app/settings.dart';
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/explorer_page.dart';
import 'package:jj_mkvmaker/ui/theme.dart';
import 'package:path/path.dart' as p;
/// X-plore 비교 P1 (관리자 지시 10/18): X-plore 스타일 탐색기를 PNG 로 (JJ_SHOT_DIR, 없으면 임시 폴더).
/// 트리 맨 위 저장 장치 · 즐겨찾기 (c) · 표시 동그라미 늘 보이기 (⚖) · 한 창의 "갈 곳" 줄 (a).
/// 크기 412 (폰 세로, 한 창) · 960 (두 창), 언어 ko · en. 시험 파일은 임시 폴더에만, 설정은 메모리에만.
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_xplore');
for (final lang in ['ko', 'en']) {
for (final (w, h) in [(412, 900), (960, 600)]) {
testWidgets('X-plore 화면 $lang ${w}x$h', (t) async {
t.view.physicalSize = Size(w.toDouble(), h.toDouble());
t.view.devicePixelRatio = 1;
addTearDown(t.view.reset);
await t.runAsync(() => i18n.apply(lang, save: false));
addTearDown(() => i18n.apply('ko', save: false));
final tmp = Directory.systemTemp.createTempSync('jj_xpshot_');
addTearDown(() => tmp.deleteSync(recursive: true));
final a = Directory(p.join(tmp.path, 'Videos'))..createSync();
final b = Directory(p.join(tmp.path, 'Backup'))..createSync();
for (final n in ['Show S01', 'Movies']) {
Directory(p.join(a.path, n)).createSync();
}
File(p.join(a.path, 'Show S01', 'ep01.mkv')).writeAsBytesSync(List.filled(5000, 0));
File(p.join(a.path, 'notes.txt')).writeAsStringSync('memo');
ExplorerPage.debugVolumes = () => [(a.path, 'C:'), (b.path, 'D:')];
addTearDown(() => ExplorerPage.debugVolumes = null);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..explorerStyle = 'xplore'
..explorerLayout = w < 720 ? 'single' : 'dual'
..explorerOnePaneCopy = 'other'
..explorerPaths = [a.path, b.path]
..folderBookmarks = [FolderBookmark(name: 'Show S01', path: p.join(a.path, 'Show S01'))];
final shotKey = GlobalKey();
await t.runAsync(() => t.pumpWidget(RepaintBoundary(
key: shotKey,
child: MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: ExplorerPage(c: c)),
)));
Future<void> settle([int n = 12]) async {
for (var i = 0; i < n; i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 60)));
await t.pump();
}
}
Future<void> shot(String name) async {
await settle(3);
final r = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
final img = await t.runAsync(() => r.toImage());
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
Directory(out).createSync(recursive: true);
File(p.join(out, 'xplore_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
}
await settle(20);
// Show S01 펼치기 · 즐겨찾기 펼치기
await t.tap(find.text('Show S01').first);
await settle();
await t.tap(find.byKey(const ValueKey<String>('k2:\u0000favorites')).first);
await settle();
await shot('tree');
await t.pumpWidget(const SizedBox());
await settle(4);
});
}
}
}

View File

@ -416,6 +416,9 @@ class AppSettings {
/// X-plore 비교 ⚖: X-plore 스타일 탐색기 안에서 옆으로 밀기 = 창 바꾸기 (처음 값 켬). 화면 사이 이동은 위 막대에서 밀기 (10/8 결정 유지)
bool explorerSwipePanes = true;
/// X-plore 비교 e (관리자 결정): Android 앱 휴지통 보관 일 (처음 14일, 0 = 끔 - 예전처럼 바로 영구 삭제). Windows 는 시스템 휴지통
int androidTrashDays = 14;
/// 누르기: 'select' 한 번 = 선택 · 두 번 = 열기 (기본) / 'open' 한 번 = 바로 열기.
/// 길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴.
String explorerClick = Platform.isAndroid ? 'open' : 'select';
@ -773,6 +776,7 @@ class AppSettings {
'explorerAlwaysMarks': explorerAlwaysMarks,
'explorerFolderMarkRule': explorerFolderMarkRule,
'explorerSwipePanes': explorerSwipePanes,
'androidTrashDays': androidTrashDays,
'explorerClick': explorerClick,
// 예전 기본값 (두 창 · 한 번 누르면 선택) 은 사용자가 고른 값이 아니므로 한 번 새 기본값으로 옮긴다
'explorerV2': true,
@ -988,6 +992,7 @@ class AppSettings {
..explorerAlwaysMarks = j['explorerAlwaysMarks'] != false
..explorerFolderMarkRule = j['explorerFolderMarkRule'] == 'xplore' ? 'xplore' : 'tree'
..explorerSwipePanes = j['explorerSwipePanes'] != false
..androidTrashDays = switch (j['androidTrashDays']) { final int d when d >= 0 && d <= 365 => d, _ => 14 }
..explorerLayout = const ['single', 'split', 'auto'].contains(j['explorerLayout'])
? j['explorerLayout'] as String
: j['explorerLayout'] == 'dual' && j['explorerV2'] == true

184
lib/core/app_trash.dart Normal file
View File

@ -0,0 +1,184 @@
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:path/path.dart' as p;
import '../l10n/tr.dart';
import 'file_ops.dart' show isSameOrInside, samePath;
/// X-plore 비교 e (관리자 결정 10/18): Android 앱 휴지통. Windows 는 시스템 휴지통 그대로.
/// - 저장 장치마다 맨 위의 `.jj_trash` 폴더 (같은 저장 장치 안에서 이름만 바꿔 옮긴다 - 빠르고 공간이 들지 않는다).
/// - 항목마다 `<시각>_<무작위>/` 폴더에 원래 것 + `info.json` (원래 경로 · 지운 때 · 폴더인지 · 크기).
/// - `.nomedia` 를 두어 갤러리 · 음악 앱이 휴지통의 그림 · 동영상을 보이지 않게.
/// - 보관 일 (설정, 처음 14일) 이 지나면 지운다. 0 = 휴지통 끔 (늘 영구 삭제, 예전처럼).
const trashFolderName = '.jj_trash';
class TrashItem {
/// 휴지통 안의 항목 폴더 (`.jj_trash/<id>`)
final String entryDir;
/// 원래 경로
final String original;
final DateTime deletedAt;
final bool isDir;
final int size;
const TrashItem({required this.entryDir, required this.original, required this.deletedAt, required this.isDir, required this.size});
String get name => p.basename(original);
/// 휴지통 안에 있는 원래 것
String get stored => p.join(entryDir, name);
}
/// 휴지통으로 옮기지 못함 (다른 저장 장치 · 권한 · 공간). 부르는 쪽이 영구 삭제할지 묻는다
class TrashMoveException implements Exception {
final String path;
final Object cause;
const TrashMoveException(this.path, this.cause);
@override
String toString() => trf('휴지통으로 옮기지 못했습니다: {0} ({1})', [p.basename(path), cause]);
}
class AppTrash {
/// 저장 장치 맨 위들 (Android: /storage/emulated/0 · /storage/XXXX-XXXX)
final List<String> volumeRoots;
AppTrash(this.volumeRoots);
/// [path] 가 있는 저장 장치의 휴지통 폴더 (어느 저장 장치에도 없으면 null)
String? trashDirFor(String path) {
String? best;
for (final r in volumeRoots) {
if (isSameOrInside(path, r) && (best == null || r.length > best.length)) best = r;
}
return best == null ? null : p.join(best, trashFolderName);
}
/// 휴지통 안의 것인지 (휴지통 폴더 자신 포함 - 다시 휴지통으로 보내지 않게)
bool isInTrash(String path) {
final t = trashDirFor(path);
return t != null && isSameOrInside(path, t);
}
static Future<int> _sizeOf(String path) async {
final t = await FileSystemEntity.type(path, followLinks: false);
if (t == FileSystemEntityType.file) return File(path).length();
if (t != FileSystemEntityType.directory) return 0;
var n = 0;
await for (final e in Directory(path).list(recursive: true, followLinks: false).handleError((_) {})) {
if (e is File) {
try {
n += await e.length();
} catch (_) {}
}
}
return n;
}
/// [path] 를 휴지통으로 (같은 저장 장치 안 이름 바꾸기). 못 옮기면 [TrashMoveException] - 원래 것은 그대로
Future<TrashItem> moveToTrash(String path, {DateTime? now}) async {
final trash = trashDirFor(path);
if (trash == null) throw TrashMoveException(path, tr('저장 장치를 알 수 없습니다'));
final when = now ?? DateTime.now();
final id = '${when.millisecondsSinceEpoch}_${Random().nextInt(1 << 30).toRadixString(36)}';
final entry = Directory(p.join(trash, id));
final isDir = await FileSystemEntity.isDirectory(path);
final size = await _sizeOf(path);
try {
await entry.create(recursive: true);
final nomedia = File(p.join(trash, '.nomedia'));
if (!await nomedia.exists()) await nomedia.writeAsString('');
// 정보 먼저 (옮긴 뒤 앱이 꺼져도 어디서 왔는지 남게)
final info = TrashItem(entryDir: entry.path, original: path, deletedAt: when, isDir: isDir, size: size);
await File(p.join(entry.path, 'info.json')).writeAsString(jsonEncode({
'original': path,
'deletedAt': when.toIso8601String(),
'isDir': isDir,
'size': size,
}));
if (isDir) {
await Directory(path).rename(info.stored);
} else {
await File(path).rename(info.stored);
}
return info;
} catch (e) {
// 옮기지 못했으면 (이름 바꾸기가 마지막 일이라 원래 것은 제자리) 만든 항목 폴더만 지운다
try {
if (await entry.exists()) await entry.delete(recursive: true);
} catch (_) {}
throw TrashMoveException(path, e);
}
}
/// 휴지통의 항목들 (지운 때가 최근인 것부터)
Future<List<TrashItem>> list() async {
final out = <TrashItem>[];
for (final r in volumeRoots) {
final dir = Directory(p.join(r, trashFolderName));
if (!await dir.exists()) continue;
await for (final e in dir.list(followLinks: false).handleError((_) {})) {
if (e is! Directory) continue;
try {
final j = jsonDecode(await File(p.join(e.path, 'info.json')).readAsString()) as Map<String, dynamic>;
out.add(TrashItem(
entryDir: e.path,
original: j['original'] as String,
deletedAt: DateTime.parse(j['deletedAt'] as String),
isDir: j['isDir'] == true,
size: (j['size'] as num?)?.toInt() ?? 0,
));
} catch (_) {
// 정보가 없는 항목 (깨짐) 은 건너뛴다 - 지우지 않는다
}
}
}
out.sort((a, b) => b.deletedAt.compareTo(a.deletedAt));
return out;
}
/// 되살리기: 원래 자리로. 그 자리에 같은 이름이 있으면 "이름 (되살림).확장자". 돌려준 것은 되살린 경로
Future<String> restore(TrashItem item) async {
var target = item.original;
if (await FileSystemEntity.type(target, followLinks: false) != FileSystemEntityType.notFound) {
final ext = item.isDir ? '' : p.extension(target);
final base = item.isDir ? target : p.join(p.dirname(target), p.basenameWithoutExtension(target));
target = '$base (${tr('되살림')})$ext';
for (var i = 2; await FileSystemEntity.type(target, followLinks: false) != FileSystemEntityType.notFound; i++) {
target = '$base (${tr('되살림')} $i)$ext';
}
}
await Directory(p.dirname(target)).create(recursive: true);
if (item.isDir) {
await Directory(item.stored).rename(target);
} else {
await File(item.stored).rename(target);
}
await Directory(item.entryDir).delete(recursive: true);
return target;
}
/// 영구 삭제
Future<void> purge(TrashItem item) => Directory(item.entryDir).delete(recursive: true);
/// 보관 일이 지난 것을 지운다. 지운 수
Future<int> purgeOlderThan(int days, {DateTime? now}) async {
final limit = (now ?? DateTime.now()).subtract(Duration(days: days));
var n = 0;
for (final x in await list()) {
if (x.deletedAt.isBefore(limit)) {
try {
await purge(x);
n++;
} catch (_) {}
}
}
return n;
}
/// 휴지통 크기 합
Future<int> totalSize() async => (await list()).fold<int>(0, (a, b) => a + b.size);
/// [path] 가 휴지통 폴더 자신인지 (탐색기 목록에서 숨김 · 지우기 막기에)
bool isTrashRoot(String path) => volumeRoots.any((r) => samePath(path, p.join(r, trashFolderName)));
}

View File

@ -31,6 +31,7 @@ import 'rsync_setup.dart';
import 'theme.dart';
import 'file_error.dart';
import 'folder_picker.dart' show pickFolderOrDav;
import 'trash_page.dart';
import 'dav_external.dart';
import '../platform/windows/recycle_bin.dart';
import '../core/sync_preview.dart';
@ -51,6 +52,7 @@ import 'archive_ui.dart';
import 'storage_page.dart';
import '../core/archive_ops.dart' show ArchiveKind, archiveKindOf;
import 'batch_rename_dialog.dart';
import '../core/app_trash.dart';
import '../core/folder_size.dart';
import '../core/batch_rename.dart';
@ -92,6 +94,10 @@ class ExplorerPage extends StatefulWidget {
@visibleForTesting
static bool? debugAndroidPaths;
/// 시험: Android 앱 휴지통 (X-plore e) 을 Windows 에서. null 이면 실제 기기
@visibleForTesting
static bool? debugAndroidTrash;
/// 시험: 경로 입력 창이 넣은 경로를 고쳐 쓰고 확인하는 것 (갈 경로, 칸 아래 이유)
@visibleForTesting
static Future<(String?, String?)> checkPathForTest(String input) => _ExplorerPageState.checkEnteredPath(input);
@ -165,7 +171,9 @@ enum ExplorerButton {
layout(Icons.view_quilt_outlined, '창 배치'),
buttons(Icons.tune, '버튼 구성'),
// 87: 지금 폴더 (드라이브) 의 큰 폴더 · 큰 파일
storage(Icons.pie_chart_outline, '저장 공간 분석');
storage(Icons.pie_chart_outline, '저장 공간 분석'),
// X-plore 비교 e: 휴지통 (Android 앱 휴지통 · Windows 는 시스템 휴지통 열기)
trash(Icons.restore_from_trash_outlined, '휴지통');
final IconData icon;
final String label;
@ -433,6 +441,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
Future<void> _init() async {
_local = await _loadVolumes();
if (_local.isEmpty) _local = [(Platform.isWindows ? r'C:\' : '/', Platform.isWindows ? 'C:' : '/')];
// X-plore 비교 e: 보관 일이 지난 휴지통 항목을 지운다 (뒤에서)
if (_appTrash case final t?) unawaited(t.purgeOlderThan(c.settings.androidTrashDays).catchError((_) => 0));
if (Platform.isAndroid) unawaited(_checkAccess());
final saved = widget.rsync ? c.settings.rsyncPaths : c.settings.explorerPaths;
// 창마다 따로 읽는다: 한 창이 WebDAV 서버를 기다려도 다른 창 (이 기기 파일) 은 바로 보이게
@ -1781,6 +1791,10 @@ class _ExplorerPageState extends State<ExplorerPage> {
/// X-plore 비교 a: 한 창인데 "숨은 다른 창으로" 복사 · 이동 (위에 갈 곳 줄)
bool get _hiddenTarget => !widget.rsync && !_dual && !_split && c.settings.explorerOnePaneCopy == 'other';
/// 폴더 이름 (저장 장치 맨 위면 그 이름) - 좁은 줄에 경로 대신 (전체 경로는 풍선 도움말)
String _folderName(String dir) =>
_volumes.firstWhere((v) => samePath(v.$1, dir), orElse: () => (dir, vBasename(dir).isEmpty ? _displayPath(dir) : vBasename(dir))).$2;
/// X-plore 비교 a: 한 창일 때 맨 위의 "→ 갈 곳" 줄 - 숨은 창의 폴더 · [지금 폴더로] · [다른 폴더]
Widget _targetBar() {
final t = _panes[1 - _active];
@ -1794,7 +1808,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
Expanded(
child: Tooltip(
message: _displayPath(t.current),
child: Text(trf('복사 · 이동 갈 곳: {0}', [_displayPath(t.current)]),
child: Text(trf('복사 · 이동 갈 곳: {0}', [_folderName(t.current)]),
maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12.5)),
),
),
@ -1957,6 +1971,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
await _search(pane);
case ExplorerButton.storage:
await _storage(pane, pane.current);
case ExplorerButton.trash:
await _openTrash();
case ExplorerButton.sort:
await _sortDialog();
case ExplorerButton.hidden:
@ -2099,7 +2115,10 @@ class _ExplorerPageState extends State<ExplorerPage> {
}
// 휴지통이 있는 곳: Windows 의 고정 디스크 (94: 네트워크 드라이브 · \\NAS · USB 메모리는 휴지통이 없어 처음부터 영구 삭제로 묻는다)
final local = Platform.isWindows && paths.every((x) => !isDav(x));
final canRecycle = local && c.settings.recycleOnDelete && paths.every(hasRecycleBin);
// X-plore 비교 e: Android 는 앱 휴지통 (저장 장치의 .jj_trash, 보관 일이 0 이면 끔)
final trash = _appTrash;
final canTrash = trash != null && paths.every((x) => !isDav(x) && trash.trashDirFor(x) != null && !trash.isInTrash(x));
final canRecycle = (local && c.settings.recycleOnDelete && paths.every(hasRecycleBin)) || canTrash;
var permanent = !canRecycle || shift;
final ok = await showDialog<bool>(
context: context,
@ -2133,12 +2152,18 @@ class _ExplorerPageState extends State<ExplorerPage> {
child: Text(tr('이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.'),
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)),
),
if (canTrash && !permanent)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(trf('앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).', [c.settings.androidTrashDays]),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
),
if (canRecycle)
CheckboxListTile(
contentPadding: EdgeInsets.zero,
value: permanent,
onChanged: (v) => set(() => permanent = v ?? false),
title: Text(tr('휴지통을 거치지 않고 영구 삭제 (Shift+Delete)')),
title: Text(canTrash ? tr('휴지통을 거치지 않고 영구 삭제') : tr('휴지통을 거치지 않고 영구 삭제 (Shift+Delete)')),
),
]),
actions: [
@ -2161,7 +2186,19 @@ class _ExplorerPageState extends State<ExplorerPage> {
// 148: 휴지통으로 보낸 것 (되돌리기용) - 정보 파일 시각은 초 단위라 조금 앞에서부터
final since = DateTime.now().subtract(const Duration(seconds: 2));
final sent = <String>[];
// X-plore 비교 e: 앱 휴지통으로 보낸 것 · 옮기지 못한 것 (영구 삭제할지 묻는다)
final trashed = <TrashItem>[];
final notTrashed = <(String, String)>[];
for (final x in paths) {
if (!permanent && canTrash) {
try {
trashed.add(await trash.moveToTrash(x));
recycled++;
} on TrashMoveException catch (e) {
notTrashed.add((x, '${e.cause}'));
}
continue;
}
try {
if (!permanent) {
switch (recycle(x)) {
@ -2182,6 +2219,39 @@ class _ExplorerPageState extends State<ExplorerPage> {
failed.add((x, e is FileSystemException ? (e.osError?.message ?? e.message) : '$e'));
}
}
// X-plore 비교 e: 휴지통으로 옮기지 못한 것 (다른 저장 장치 · 권한 등) - 몰래 지우지 않고 영구 삭제할지 묻는다
if (notTrashed.isNotEmpty && mounted) {
final nuke = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
icon: const Icon(Icons.warning_amber_rounded, color: Colors.orangeAccent),
title: Text(trf('{0}개를 휴지통으로 옮기지 못했습니다', [notTrashed.length])),
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
for (final (x, why) in notTrashed.take(10)) Text('· ${vBasename(x)} - $why', style: const TextStyle(fontSize: 12)),
const SizedBox(height: 8),
Text(tr('영구 삭제하면 되살릴 수 없습니다.')),
]),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('그대로 두기'))),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
onPressed: () => Navigator.pop(ctx, true),
child: Text(trf('영구 삭제 ({0}개, 되돌릴 수 없음)', [notTrashed.length])),
),
],
),
);
if (nuke == true) {
for (final (x, _) in notTrashed) {
try {
await FileOps().delete([x]);
} catch (e) {
failed.add((x, '$e'));
}
}
}
}
if ((failed.isNotEmpty || tooLong.isNotEmpty) && mounted) {
final nukeLong = await showDialog<bool>(
context: context,
@ -2248,7 +2318,11 @@ class _ExplorerPageState extends State<ExplorerPage> {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
duration: const Duration(seconds: 10),
content: Text(text),
action: sent.isEmpty ? null : SnackBarAction(label: tr('되돌리기'), onPressed: () => _undoRecycle(sent, since)),
action: trashed.isNotEmpty
? SnackBarAction(label: tr('되돌리기'), onPressed: () => _undoTrash(trashed))
: sent.isEmpty
? null
: SnackBarAction(label: tr('되돌리기'), onPressed: () => _undoRecycle(sent, since)),
));
}
for (final x in _panes) {
@ -2260,6 +2334,45 @@ class _ExplorerPageState extends State<ExplorerPage> {
await _refreshAll(paths.map(p.dirname));
}
/// X-plore 비교 e: Android 앱 휴지통 (보관 일이 0 이면 null - 바로 영구 삭제)
AppTrash? get _appTrash => (ExplorerPage.debugAndroidTrash ?? Platform.isAndroid) && c.settings.androidTrashDays > 0
? AppTrash([for (final (v, _) in _local) v])
: null;
/// X-plore 비교 e: 방금 앱 휴지통으로 보낸 것을 원래 자리로
Future<void> _undoTrash(List<TrashItem> items) async {
final trash = _appTrash;
if (trash == null) return;
final back = <String>[];
for (final i in items) {
try {
back.add(await trash.restore(i));
} catch (_) {}
}
await _refreshAll(back.map(p.dirname));
_snack(trf('{0}개를 원래 자리로 되살렸습니다', [back.length]));
}
/// 시험: [휴지통] 열기
@visibleForTesting
Future<void> debugOpenTrash() => _openTrash();
/// X-plore 비교 e: [휴지통] - Android 는 앱 휴지통 화면, Windows 는 시스템 휴지통
Future<void> _openTrash() async {
final trash = _appTrash;
if (trash == null) {
if (Platform.isWindows) {
await Process.start('explorer.exe', ['shell:RecycleBinFolder']);
} else {
_snack(tr('휴지통이 꺼져 있습니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).'));
}
return;
}
final back = await Navigator.of(context).push<List<String>>(
MaterialPageRoute(builder: (_) => TrashPage(trash: trash, days: c.settings.androidTrashDays)));
if (back != null && back.isNotEmpty && mounted) await _refreshAll(back.map(p.dirname));
}
/// 148: 방금 휴지통으로 보낸 것을 원래 자리로 (원래 자리에 같은 이름이 생겼으면 그것은 두고 알림)
Future<void> _undoRecycle(List<String> paths, DateTime since) async {
final failed = <String>[];

View File

@ -838,6 +838,20 @@ class _SettingsPageState extends State<SettingsPage> {
onChanged: (v) => c.updateSettings((x) => x.explorerLayout = v!),
),
),
// X-plore 비교 e: Android 앱 휴지통 보관 일
if (Platform.isAndroid)
SettingTile(
title: Text(tr('휴지통 보관')),
subtitle: Text(tr('지운 것을 저장 장치의 앱 휴지통에 두었다가 이 날이 지나면 지웁니다. 끄면 바로 영구 삭제')),
trailing: DropdownButton<int>(
value: const [0, 3, 7, 14, 30, 60].contains(s.androidTrashDays) ? s.androidTrashDays : 14,
items: [
for (final d in const [0, 3, 7, 14, 30, 60])
DropdownMenuItem(value: d, child: Text(d == 0 ? tr('끔') : trf('{0}일', [d]))),
],
onChanged: (v) => c.updateSettings((x) => x.androidTrashDays = v!),
),
),
// X-plore 비교 ⚖ (관리자 결정): X-plore 스타일의 표시 동그라미 · 밀기 · 폴더 표시 규칙
SwitchListTile(
title: Text(tr('X-plore 스타일: 표시 동그라미 늘 보이기')),

169
lib/ui/trash_page.dart Normal file
View File

@ -0,0 +1,169 @@
import 'package:flutter/material.dart';
import '../core/app_trash.dart';
import '../core/file_ops.dart' show formatSize;
import '../l10n/tr.dart';
import 'confirm.dart';
import 'theme.dart';
/// X-plore 비교 e: 앱 휴지통 화면 (Android) - 항목 · 원래 자리 · 지운 때, 골라서 되살리기 · 영구 삭제, 모두 비우기.
/// 돌려주는 값: 되살린 경로들 (탐색기가 그 폴더를 새로 읽는다)
class TrashPage extends StatefulWidget {
final AppTrash trash;
/// 보관 일 (0 = 끔) - 안내 줄에
final int days;
const TrashPage({super.key, required this.trash, required this.days});
@override
State<TrashPage> createState() => _TrashPageState();
}
class _TrashPageState extends State<TrashPage> {
List<TrashItem>? _items;
final _picked = <String>{};
final _restored = <String>[];
bool _busy = false;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final items = await widget.trash.list();
if (!mounted) return;
setState(() {
_items = items;
_picked.removeWhere((x) => !items.any((i) => i.entryDir == x));
});
}
List<TrashItem> get _chosen => [for (final i in _items ?? const <TrashItem>[]) if (_picked.contains(i.entryDir)) i];
void _say(String t) => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t)));
Future<void> _restore(List<TrashItem> items) async {
setState(() => _busy = true);
var n = 0;
final fails = <String>[];
for (final i in items) {
try {
_restored.add(await widget.trash.restore(i));
n++;
} catch (e) {
fails.add('${i.name}: $e');
}
}
await _load();
if (!mounted) return;
setState(() => _busy = false);
_say([
if (n > 0) trf('{0}개를 원래 자리로 되살렸습니다', [n]),
if (fails.isNotEmpty) trf('되살리지 못함: {0}', [fails.take(3).join(' · ')]),
].join(' · '));
}
Future<void> _purge(List<TrashItem> items, {bool all = false}) async {
final ok = await confirmAction(
context,
title: all ? tr('휴지통 비우기') : trf('{0}개를 영구 삭제할까요?', [items.length]),
body: tr('휴지통에서 지운 것은 되살릴 수 없습니다.'),
ok: tr('영구 삭제'),
);
if (!ok || !mounted) return;
setState(() => _busy = true);
for (final i in items) {
try {
await widget.trash.purge(i);
} catch (_) {}
}
await _load();
if (mounted) setState(() => _busy = false);
}
String _date(DateTime d) =>
'${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')} ${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}';
@override
Widget build(BuildContext context) {
final items = _items;
final chosen = _chosen;
final total = (items ?? const <TrashItem>[]).fold<int>(0, (a, b) => a + b.size);
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) Navigator.pop(context, _restored);
},
child: Scaffold(
appBar: AppBar(
title: Text(items == null ? tr('휴지통') : trf('휴지통 · {0}개 · {1}', [items.length, formatSize(total)])),
actions: [
if (items != null && items.isNotEmpty)
TextButton.icon(
onPressed: _busy ? null : () => _purge(items, all: true),
icon: const Icon(Icons.delete_forever_outlined, color: JjColors.danger),
label: Text(tr('비우기'), style: const TextStyle(color: JjColors.danger)),
),
],
),
body: Column(children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Text(
widget.days > 0
? trf('지운 것은 {0}일 동안 여기에 두었다가 저절로 지웁니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).', [widget.days])
: tr('휴지통이 꺼져 있습니다 - 지우면 바로 영구 삭제됩니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim),
),
),
if (_busy) const LinearProgressIndicator(minHeight: 2),
Expanded(
child: items == null
? const Center(child: CircularProgressIndicator())
: items.isEmpty
? Center(child: Text(tr('휴지통이 비어 있습니다')))
: ListView.builder(
itemCount: items.length,
itemBuilder: (_, k) {
final i = items[k];
final on = _picked.contains(i.entryDir);
return CheckboxListTile(
value: on,
onChanged: (v) => setState(() => v == true ? _picked.add(i.entryDir) : _picked.remove(i.entryDir)),
secondary: Icon(i.isDir ? Icons.folder : Icons.insert_drive_file_outlined,
color: i.isDir ? Colors.amber : null),
title: Text(i.name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text('${i.original}\n${_date(i.deletedAt)} · ${formatSize(i.size)}',
maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11)),
isThreeLine: true,
);
},
),
),
if (chosen.isNotEmpty)
SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.all(8),
child: Row(children: [
Expanded(child: Text(trf('{0}개 고름', [chosen.length]))),
TextButton(
onPressed: _busy ? null : () => _purge(chosen),
child: Text(tr('영구 삭제'), style: const TextStyle(color: JjColors.danger)),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: _busy ? null : () => _restore(chosen),
icon: const Icon(Icons.restore),
label: Text(tr('되살리기')),
),
]),
),
),
]),
),
);
}
}

60
test/app_trash_test.dart Normal file
View File

@ -0,0 +1,60 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/core/app_trash.dart';
import 'package:path/path.dart' as p;
/// X-plore 비교 e: Android 앱 휴지통 (저장 장치의 .jj_trash) - Windows 임시 폴더로 시험
void main() {
late Directory tmp;
late AppTrash trash;
setUp(() {
tmp = Directory.systemTemp.createTempSync('jj_trash_e_');
trash = AppTrash([p.join(tmp.path, 'vol')]);
Directory(p.join(tmp.path, 'vol', 'a', 'sub')).createSync(recursive: true);
File(p.join(tmp.path, 'vol', 'a', 'f.txt')).writeAsStringSync('hello');
File(p.join(tmp.path, 'vol', 'a', 'sub', 'g.txt')).writeAsStringSync('gg');
});
tearDown(() => tmp.deleteSync(recursive: true));
test('파일 · 폴더를 휴지통으로 (같은 저장 장치의 .jj_trash, .nomedia) → 목록 → 되살리기 (자리에 같은 이름이면 "(되살림)")', () async {
final f = p.join(tmp.path, 'vol', 'a', 'f.txt');
final sub = p.join(tmp.path, 'vol', 'a', 'sub');
final i1 = await trash.moveToTrash(f, now: DateTime(2026, 10, 1));
final i2 = await trash.moveToTrash(sub, now: DateTime(2026, 10, 2));
expect(File(f).existsSync(), isFalse);
expect(Directory(sub).existsSync(), isFalse);
expect(File(p.join(tmp.path, 'vol', trashFolderName, '.nomedia')).existsSync(), isTrue);
expect(trash.isInTrash(i1.stored), isTrue);
final list = await trash.list();
expect(list.map((x) => x.original), [sub, f], reason: '최근 것부터');
expect((list.last.size, list.first.isDir, list.first.size), (5, true, 2));
// 자리에 같은 이름이 새로 생겼으면 그것은 두고 다른 이름으로
File(f).writeAsStringSync('new');
final back = await trash.restore(list.last);
expect(p.basename(back), 'f (되살림).txt');
expect(File(back).readAsStringSync(), 'hello');
expect(File(f).readAsStringSync(), 'new');
expect(await trash.restore(i2), sub);
expect(File(p.join(sub, 'g.txt')).readAsStringSync(), 'gg');
expect(await trash.list(), isEmpty);
});
test('보관 일이 지난 것만 지운다 · 영구 삭제 · 저장 장치 밖 · 휴지통 안의 것은 다시 보내지 않음', () async {
final f = p.join(tmp.path, 'vol', 'a', 'f.txt');
final g = p.join(tmp.path, 'vol', 'a', 'sub', 'g.txt');
await trash.moveToTrash(f, now: DateTime(2026, 9, 1));
await trash.moveToTrash(g, now: DateTime(2026, 10, 15));
expect(await trash.purgeOlderThan(14, now: DateTime(2026, 10, 18)), 1);
final left = await trash.list();
expect(left.single.original, g);
await trash.purge(left.single);
expect(await trash.list(), isEmpty);
// 어느 저장 장치에도 없는 경로 → 옮기지 못함 (원래 것 그대로)
final out = File(p.join(tmp.path, 'outside.txt'))..writeAsStringSync('x');
expect(() => trash.moveToTrash(out.path), throwsA(isA<TrashMoveException>()));
expect(out.existsSync(), isTrue);
expect(trash.isInTrash(p.join(tmp.path, 'vol', trashFolderName, 'x')), isTrue);
expect(trash.isTrashRoot(p.join(tmp.path, 'vol', trashFolderName)), isTrue);
});
}

View File

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
@ -142,7 +143,7 @@ void main() {
..explorerOnePaneCopy = 'other';
await open(tester);
expect(find.textContaining('복사 · 이동 갈 곳'), findsOneWidget);
expect(find.textContaining(right), findsWidgets, reason: '숨은 창의 폴더');
expect(find.textContaining('갈 곳: right'), findsOneWidget, reason: '숨은 창의 폴더 이름 (전체 경로는 풍선 도움말)');
await act(tester, () => tester.tap(find.text('doc.txt')));
await settle(tester, () => false, rounds: 15);
await act(tester, () => tester.tap(find.text('복사').first));
@ -252,6 +253,54 @@ void main() {
await tester.pump(const Duration(seconds: 1));
});
testWidgets('X-plore e: Android 앱 휴지통 - 지우기 = 휴지통으로 · [되돌리기] · [휴지통] 화면에서 되살리기', (tester) async {
// 저장 장치는 시험 폴더 (실제 C:\ 에 .jj_trash 를 만들지 않게)
ExplorerPage.debugAndroidTrash = true;
ExplorerPage.debugVolumes = () => [(tmp.path, 'T:')];
addTearDown(() {
ExplorerPage.debugAndroidTrash = null;
ExplorerPage.debugVolumes = null;
});
await open(tester);
final doc = p.join(left, 'doc.txt');
await act(tester, () => tester.tap(find.text('doc.txt')));
await settle(tester, () => false, rounds: 10);
await act(tester, () => tester.tap(find.text('삭제').first));
await calm(tester);
expect(find.textContaining('앱 휴지통에 14일'), findsOneWidget);
await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '휴지통으로')));
await settle(tester, () => !File(doc).existsSync() && find.text('되돌리기').evaluate().isNotEmpty);
expect(File(doc).existsSync(), isFalse);
expect(Directory(p.join(tmp.path, '.jj_trash')).existsSync(), isTrue);
// [되돌리기]
await act(tester, () => tester.tap(find.text('되돌리기')));
await settle(tester, () => File(doc).existsSync());
expect(File(doc).readAsStringSync(), 'hello');
ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars();
await calm(tester);
// 다시 지우고 [휴지통] 화면에서 되살리기
await act(tester, () => tester.tap(find.text('doc.txt')));
await settle(tester, () => false, rounds: 10);
await act(tester, () => tester.tap(find.text('삭제').first));
await calm(tester);
await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '휴지통으로')));
await settle(tester, () => !File(doc).existsSync());
ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars();
await calm(tester);
// 휴지통 화면을 연다 (닫힐 때까지 기다리는 Future 라 기다리지 않는다)
unawaited((tester.state(find.byType(ExplorerPage)) as dynamic).debugOpenTrash() as Future<void>);
await settle(tester, () => find.byType(CheckboxListTile).evaluate().isNotEmpty);
await act(tester, () => tester.tap(find.byType(CheckboxListTile).first));
await calm(tester);
await act(tester, () => tester.tap(find.text('되살리기')));
await settle(tester, () => File(doc).existsSync());
expect(File(doc).readAsStringSync(), 'hello');
await tester.pageBack();
await calm(tester);
ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars();
await tester.pump(const Duration(seconds: 2));
});
testWidgets('178 보충: 설정의 원본 지우기 옵션은 복사 창에서 빠진 까닭을 알린다 (이동 창에는 없음)', (tester) async {
c.settings
..copyMethodFile = 'robocopy'
@ -1259,16 +1308,24 @@ void main() {
expect(find.text('폴더 열기'), findsOneWidget, reason: '끝난 알림에 [폴더 열기]');
// 패널은 끝난 뒤 잠깐 남는다 - [숨기기]
expect(find.text('숨기기'), findsOneWidget);
// 패널 · 작은 표시는 끝나고 1.2초 (시험 시계) 뒤 내려간다. pumpAndSettle (calm) 은 애니메이션이 있는 동안 시계를 그만큼
// 넘길 수 있어 (PC 가 바쁠 때 흔들림 - 관리자 지시), 정해진 횟수 대신 조건이 될 때까지 시계를 조금씩만 돌린다
Future<void> until(bool Function() done) async {
for (var i = 0; i < 100 && !done(); i++) {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 20)));
await tester.pump(const Duration(milliseconds: 10));
}
}
await act(tester, () => tester.tap(find.text('숨기기')));
await calm(tester);
await until(() => c.settings.explorerProgressHidden && find.text('복사 · 이동 끝').evaluate().isNotEmpty);
expect(c.settings.explorerProgressHidden, isTrue, reason: '숨김은 기억');
expect(find.text('숨기기'), findsNothing);
expect(find.text('복사 · 이동 끝'), findsOneWidget, reason: '아래 작은 표시');
// (아래 알림이 잠깐 작은 표시를 덮는다 - 알림을 닫고 누름)
// 작은 표시를 누름 (아래 알림이 덮고 있어도 - 알림이 닫히기를 기다리면 그사이 작은 표시가 내려갈 수 있다)
tester.widget<TransferProgressChip>(find.byType(TransferProgressChip)).onTap();
await until(() => !c.settings.explorerProgressHidden);
ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars();
await calm(tester);
await act(tester, () => tester.tap(find.text('복사 · 이동 끝')));
await calm(tester);
expect(c.settings.explorerProgressHidden, isFalse, reason: '누르면 다시 펼침');
ScaffoldMessenger.of(tester.element(find.byType(ExplorerPage))).clearSnackBars();
await calm(tester);