From 6eb0dfc70a284b58be4aabd140de2efe67788533 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:37:24 +0900 Subject: [PATCH] =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=C2=B7?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=20=EC=88=9C=EC=84=9C=20=C2=B7=20=EC=A2=8C?= =?UTF-8?q?=EC=9A=B0=EB=A1=9C=20=EB=B0=80=EC=96=B4=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=20=C2=B7=20=EC=95=8C=EB=A6=BC=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=EC=BD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app/components.dart: 기능 묶음 (MKV · 웹 브라우저 · 파일 탐색기 · Rsync · 다운로드 · 이미지/PDF/ZIP 보기 · 문서 미리보기). 설정 components (설치한 것) · navOrder (화면 순서) · swipeNav. - 위쪽 이동 버튼: 설치한 화면만 순서대로 (하나뿐이면 그것만). 홈 화면을 제거했으면 설치된 첫 화면. - SwipeNav: 화면 가운데를 왼쪽으로 밀면 다음, 오른쪽으로 밀면 이전 화면 (끝 다음은 처음). - 환경 설정 > 컴포넌트: 켜기 · 끄기, 화면 순서 (끌어서), 좌우로 밀기 켜기 · 끄기. - Android 작업 알림: 받는 중이면 내려받기 아이콘, 동기화 · 작업 중이면 좌우로 오가는 화살표 (animation-list). - 시험: components_test (설치 · 순서 · 저장, 버튼 줄, 좌우로 밀기). Co-Authored-By: Claude Opus 5.5 --- .../com/jj/jj_mkvmaker/KeepAliveService.kt | 13 +- .../kotlin/com/jj/jj_mkvmaker/MainActivity.kt | 8 +- .../src/main/res/drawable/ic_stat_sync_0.xml | 5 + .../src/main/res/drawable/ic_stat_sync_1.xml | 5 + .../src/main/res/drawable/ic_stat_sync_2.xml | 5 + .../src/main/res/drawable/ic_stat_sync_3.xml | 5 + .../src/main/res/drawable/ic_stat_sync_4.xml | 5 + .../src/main/res/drawable/ic_stat_sync_5.xml | 5 + .../main/res/drawable/ic_stat_sync_anim.xml | 10 + assets/l10n/en.json | 16 ++ assets/l10n/ja.json | 16 ++ assets/l10n/zh-Hans.json | 16 ++ lib/app/components.dart | 53 +++++ lib/app/settings.dart | 20 ++ lib/main.dart | 23 +- lib/platform/android/android_keep_alive.dart | 6 +- lib/ui/app_actions.dart | 201 ++++++++++++------ lib/ui/browser_page.dart | 6 +- lib/ui/component_settings.dart | 83 ++++++++ lib/ui/downloads_page.dart | 5 +- lib/ui/explorer_page.dart | 5 +- lib/ui/home_page.dart | 3 +- lib/ui/settings_page.dart | 7 +- test/components_test.dart | 89 ++++++++ test/queue_test.dart | 5 +- 25 files changed, 520 insertions(+), 95 deletions(-) create mode 100644 android/app/src/main/res/drawable/ic_stat_sync_0.xml create mode 100644 android/app/src/main/res/drawable/ic_stat_sync_1.xml create mode 100644 android/app/src/main/res/drawable/ic_stat_sync_2.xml create mode 100644 android/app/src/main/res/drawable/ic_stat_sync_3.xml create mode 100644 android/app/src/main/res/drawable/ic_stat_sync_4.xml create mode 100644 android/app/src/main/res/drawable/ic_stat_sync_5.xml create mode 100644 android/app/src/main/res/drawable/ic_stat_sync_anim.xml create mode 100644 lib/app/components.dart create mode 100644 lib/ui/component_settings.dart create mode 100644 test/components_test.dart diff --git a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/KeepAliveService.kt b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/KeepAliveService.kt index c5b3581..a1542de 100644 --- a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/KeepAliveService.kt +++ b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/KeepAliveService.kt @@ -23,7 +23,8 @@ class KeepAliveService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { val text = intent?.getStringExtra(EXTRA_TEXT) ?: "" val progress = intent?.getIntExtra(EXTRA_PROGRESS, -1) ?: -1 - val n = notification(this, text, progress) + val icon = intent?.getStringExtra(EXTRA_ICON) ?: "" + val n = notification(this, text, progress, icon) running = true if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { // 실시간 동기화는 앱이 켜져 있는 내내 지켜봐야 하므로 하루 6시간 제한이 있는 dataSync 대신 specialUse (Android 14+) @@ -58,17 +59,18 @@ class KeepAliveService : Service() { const val NOTIFICATION_ID = 1 const val EXTRA_TEXT = "text" const val EXTRA_PROGRESS = "progress" + const val EXTRA_ICON = "icon" /// 서비스가 떠 있는지 (떠 있으면 알림만 바꾼다 - 백그라운드에서는 서비스를 새로 시작할 수 없음) @Volatile var running = false - fun update(context: Context, text: String, progress: Int) { + fun update(context: Context, text: String, progress: Int, icon: String) { val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - nm.notify(NOTIFICATION_ID, notification(context, text, progress)) + nm.notify(NOTIFICATION_ID, notification(context, text, progress, icon)) } - private fun notification(context: Context, text: String, progress: Int): Notification { + private fun notification(context: Context, text: String, progress: Int, icon: String): Notification { val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && nm.getNotificationChannel(CHANNEL_ID) == null) { nm.createNotificationChannel( @@ -91,7 +93,8 @@ class KeepAliveService : Service() { } else { @Suppress("DEPRECATION") Notification.Builder(context) } - b.setSmallIcon(android.R.drawable.stat_sys_download) + // 다운로드 중이면 내려받기 아이콘, 그 밖 (실시간 동기화 · MKV · AI 자막) 은 좌우로 오가는 화살표 + b.setSmallIcon(if (icon == "download") android.R.drawable.stat_sys_download else R.drawable.ic_stat_sync_anim) .setContentTitle("JJ_MKVMaker 작업 중") .setContentText(text) .setContentIntent(open) diff --git a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt index ff73307..ccac968 100644 --- a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt +++ b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt @@ -142,7 +142,8 @@ class MainActivity : FlutterActivity() { ) "setAppIcon" -> result.success(setAppIcon(call.argument("id") ?: "")) "keepAlive" -> { - keepAlive(call.argument("text") ?: "", call.argument("progress") ?: -1) + keepAlive(call.argument("text") ?: "", call.argument("progress") ?: -1, + call.argument("icon") ?: "") result.success(null) } "stopKeepAlive" -> { @@ -409,7 +410,7 @@ class MainActivity : FlutterActivity() { /// 작업 진행 알림 (포그라운드 서비스) 시작 · 갱신. 처음 한 번 알림 권한을 묻는다 (Android 13+, 거절해도 작업은 계속) private var askedNotifications = false - private fun keepAlive(text: String, progress: Int) { + private fun keepAlive(text: String, progress: Int, icon: String) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !askedNotifications && !isDestroyed && checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED ) { @@ -417,12 +418,13 @@ class MainActivity : FlutterActivity() { requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 2) } if (KeepAliveService.running) { - KeepAliveService.update(applicationContext, text, progress) + KeepAliveService.update(applicationContext, text, progress, icon) return } val i = Intent(applicationContext, KeepAliveService::class.java) .putExtra(KeepAliveService.EXTRA_TEXT, text) .putExtra(KeepAliveService.EXTRA_PROGRESS, progress) + .putExtra(KeepAliveService.EXTRA_ICON, icon) try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) applicationContext.startForegroundService(i) else applicationContext.startService(i) diff --git a/android/app/src/main/res/drawable/ic_stat_sync_0.xml b/android/app/src/main/res/drawable/ic_stat_sync_0.xml new file mode 100644 index 0000000..19056c2 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_stat_sync_0.xml @@ -0,0 +1,5 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_stat_sync_1.xml b/android/app/src/main/res/drawable/ic_stat_sync_1.xml new file mode 100644 index 0000000..fd881fd --- /dev/null +++ b/android/app/src/main/res/drawable/ic_stat_sync_1.xml @@ -0,0 +1,5 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_stat_sync_2.xml b/android/app/src/main/res/drawable/ic_stat_sync_2.xml new file mode 100644 index 0000000..59adbcd --- /dev/null +++ b/android/app/src/main/res/drawable/ic_stat_sync_2.xml @@ -0,0 +1,5 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_stat_sync_3.xml b/android/app/src/main/res/drawable/ic_stat_sync_3.xml new file mode 100644 index 0000000..93da4df --- /dev/null +++ b/android/app/src/main/res/drawable/ic_stat_sync_3.xml @@ -0,0 +1,5 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_stat_sync_4.xml b/android/app/src/main/res/drawable/ic_stat_sync_4.xml new file mode 100644 index 0000000..59adbcd --- /dev/null +++ b/android/app/src/main/res/drawable/ic_stat_sync_4.xml @@ -0,0 +1,5 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_stat_sync_5.xml b/android/app/src/main/res/drawable/ic_stat_sync_5.xml new file mode 100644 index 0000000..fd881fd --- /dev/null +++ b/android/app/src/main/res/drawable/ic_stat_sync_5.xml @@ -0,0 +1,5 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_stat_sync_anim.xml b/android/app/src/main/res/drawable/ic_stat_sync_anim.xml new file mode 100644 index 0000000..60619eb --- /dev/null +++ b/android/app/src/main/res/drawable/ic_stat_sync_anim.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/l10n/en.json b/assets/l10n/en.json index daac668..3422d50 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -74,6 +74,7 @@ "Chrome 엔진은 프로그램을 다시 시작하면 쓸 수 있습니다. 지금 다시 시작할까요?": "The Chrome engine can be used after restarting the program. Restart now?", "Chrome 엔진을 받을 수 없습니다 (HTTP {0})": "Cannot download the Chrome engine (HTTP {0})", "Chrome 엔진을 시작할 수 없습니다. 환경 설정에서 Edge 로 바꿔 쓰세요.\n{0}": "Cannot start the Chrome engine. Switch to Edge in Settings.\n{0}", + "DOC · DOCX · HWP · XLS · XLSX 를 PDF 로 바꿔 보기 (변환기를 받음)": "View DOC · DOCX · HWP · XLS · XLSX converted to PDF (downloads a converter)", "Edge (내장)": "Edge (built-in)", "Edge 에서 가져오기": "Import from Edge", "Edge 에서 가져옴": "Imported from Edge", @@ -139,6 +140,7 @@ "Windows 기본 브라우저": "Windows default browser", "Windows 기본 프로그램": "Windows default program", "Windows 탐색기": "Windows Explorer", + "YouTube · 토렌트 다운로드 목록": "YouTube · torrent download list", "YouTube → yt-dlp, 마그넷 · .torrent → aria2 (완료 후 올려 주기 안 함)": "YouTube → yt-dlp, magnet · .torrent → aria2 (no seeding after completion)", "YouTube 가 로봇 확인을 요구합니다. 환경 설정 > 다운로드 > YouTube 쿠키 에서 브라우저(Firefox 권장) 또는 cookies.txt 를 지정한 뒤 다시 받으세요.": "YouTube is asking for a robot check. In Settings > Downloads > YouTube cookies, choose a browser (Firefox recommended) or a cookies.txt, then download again.", "YouTube 광고 배너 숨기기": "Hide YouTube ad banners", @@ -278,6 +280,7 @@ "권한 허용 화면 열기": "Open the permission screen", "그 파일 하나만": "Only that file", "그리스어": "Greek", + "그림 · 만화 보기, PDF 보기 · 페이지 편집, ZIP 목록 · 만화 보기": "Images · comic reading, PDF view · page editing, ZIP list · comic reading", "글을 격자에 반영": "Apply text to grid", "글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 (지금 {0}%)": "Text · button size. Change it any time with − · + on the top bar; press the number in the middle to return to this size (now {0}%)", "금": "Fri", @@ -422,6 +425,7 @@ "동시 다운로드 수": "Simultaneous downloads", "동시에 처리할 개수 (0 = 무한)": "Number processed at once (0 = unlimited)", "동영상": "Video", + "동영상 + 자막을 MKV 로 합치기 · AI 자막 · 화면 보정": "Merge video + subtitles into MKV · AI subtitles · picture adjustments", "동영상 {0}개 · 전체 선택": "{0} videos · select all", "동영상 목록 · 창 위치 저장": "Save video list · window position", "동영상 목록에 추가: {0}": "Added to the video list: {0}", @@ -442,6 +446,7 @@ "두 창": "Two panes", "두 창 배치": "Two-pane arrangement", "두 창 사이": "Between panes", + "두 창 파일 탐색기 · 복사 · 이동 · WebDAV": "Dual-pane file explorer · copy · move · WebDAV", "뒤로": "Back", "등록": "Register", "등록 중 일부가 실패했습니다.": "Some registrations failed.", @@ -491,6 +496,7 @@ "목록의 동영상 {0}개 모두": "All {0} videos in the list", "몽골어": "Mongolian", "무한": "Unlimited", + "문서 미리보기": "Document preview", "문자셋 경고": "Charset warning", "미리보기: 목록에서 동영상을 고르세요": "Preview: choose a video in the list", "미얀마어": "Burmese", @@ -627,6 +633,7 @@ "설치가 끝나면 프로그램이 다시 시작됩니다.": "The program restarts when the installation finishes.", "설치됨": "Installed", "설치하는 중": "Installing", + "설치하면 필요한 파일을 내려받습니다 (준비 중)": "Installing downloads the files it needs (coming soon)", "설치하지 못했습니다.\n{0}": "Could not install.\n{0}", "성공: {0}": "Succeeded: {0}", "세로 4:5": "Portrait 4:5", @@ -703,6 +710,7 @@ "앱 아이콘": "App icon", "앱 안 브라우저 (권장)": "In-app browser (recommended)", "앱 안 브라우저의 로그인 사용 (브라우저 화면에서 YouTube 에 로그인하세요)": "Use the in-app browser's sign-in (sign in to YouTube on the browser screen)", + "앱 안 웹 브라우저 · 즐겨찾기 · 동영상 다운로드": "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", "앱에 들어 있는 것 (기본)": "Built into the app (default)", @@ -806,6 +814,7 @@ "위 폴더": "Parent folder", "위로": "Up", "위아래": "Top and bottom", + "위쪽 이동 버튼과 좌우로 밀기의 순서입니다. 줄을 끌어 바꿉니다.": "Order of the top navigation buttons and of swiping. Drag a row to change it.", "유형": "Type", "음성": "Audio", "음성 {0}개": "{0} audio", @@ -872,6 +881,7 @@ "이미 받는 중이거나 받을 수 없는 주소입니다.": "Already downloading or a URL that cannot be downloaded.", "이미 이 폴더에 있습니다: {0}": "Already in this folder: {0}", "이미 있는 폴더입니다: {0}": "The folder already exists: {0}", + "이미지 · PDF · ZIP 보기": "Image · PDF · ZIP viewer", "이미지 자막은 번역할 수 없습니다": "Image subtitles cannot be translated", "이미지 자막은 편집할 수 없습니다": "Image subtitles cannot be edited", "이전": "Previous", @@ -1002,6 +1012,8 @@ "좌 → 우": "Left → right", "좌 ⇄ 우": "Left ⇄ right", "좌우": "Side by side", + "좌우 두 폴더 맞추기 (rsync) · 모니터링 · 실시간 동기화 (lsync)": "Sync two folders (rsync) · monitoring · live sync (lsync)", + "좌우로 밀어 화면 이동": "Swipe left/right to switch screens", "주말 종일": "Weekends, all day", "주소": "Address", "주소 입력 또는 검색 (Ctrl+L)": "Enter an address or search (Ctrl+L)", @@ -1094,6 +1106,7 @@ "취소했습니다.": "Cancelled.", "칠한 시간에만 동기화하고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다. 칸을 누르거나 끌어서 칠합니다 (요일 이름 · 시 숫자를 누르면 그 줄 전체).": "Syncs only in the painted hours; changes outside are collected and synced at the next start. Tap or drag cells to paint (tap a weekday name · hour number for the whole row · column).", "칠한 시간이 없어 동기화하지 않습니다.": "No hours painted, so nothing will sync.", + "컴포넌트": "Components", "켜져 있는 창에서 (기본)": "In the open window (default)", "코덱": "Codec", "크기": "Size", @@ -1206,15 +1219,18 @@ "화": "Tue", "화면": "Display", "화면 · 색 보정": "Frame · color", + "화면 가운데를 왼쪽으로 밀면 다음 화면, 오른쪽으로 밀면 이전 화면 (마지막 다음은 처음으로).": "Swipe left in the middle of the screen for the next screen, right for the previous one (after the last comes the first).", "화면 글자 전체를 이 기기에서 번역합니다. 몇 분 걸릴 수 있습니다.": "Translates all of the program's text on this device. It may take a few minutes.", "화면 모양 따라": "Follow screen shape", "화면 방향": "Screen orientation", "화면 비율": "Aspect ratio", "화면 비율 (가로 · 세로) · 회전 · 밝기 · 대비 · 채도 · 색온도": "Aspect ratio (landscape · portrait) · rotation · brightness · contrast · saturation · temperature", + "화면 순서": "Screen order", "화면 언어": "Display language", "화면 작게": "Smaller", "화면 크게": "Larger", "화면 크기": "Size", + "화면이 있는 컴포넌트는 하나 이상 남아 있어야 합니다.": "At least one component with a screen must stay installed.", "화질": "Quality", "확인": "OK", "확인 · 압축 푸는 중…": "Verifying · extracting…", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 405d822..fb58c9b 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -74,6 +74,7 @@ "Chrome 엔진은 프로그램을 다시 시작하면 쓸 수 있습니다. 지금 다시 시작할까요?": "Chrome エンジンはプログラムを再起動すると使えます。今すぐ再起動しますか?", "Chrome 엔진을 받을 수 없습니다 (HTTP {0})": "Chrome エンジンをダウンロードできません(HTTP {0})", "Chrome 엔진을 시작할 수 없습니다. 환경 설정에서 Edge 로 바꿔 쓰세요.\n{0}": "Chrome エンジンを起動できません。環境設定で Edge に切り替えてください。\n{0}", + "DOC · DOCX · HWP · XLS · XLSX 를 PDF 로 바꿔 보기 (변환기를 받음)": "DOC・DOCX・HWP・XLS・XLSX を PDF に変換して表示 (変換ツールをダウンロード)", "Edge (내장)": "Edge(内蔵)", "Edge 에서 가져오기": "Edge から取り込む", "Edge 에서 가져옴": "Edge から取り込み", @@ -139,6 +140,7 @@ "Windows 기본 브라우저": "Windows の既定のブラウザー", "Windows 기본 프로그램": "Windows の既定のプログラム", "Windows 탐색기": "Windows エクスプローラー", + "YouTube · 토렌트 다운로드 목록": "YouTube・トレント ダウンロード一覧", "YouTube → yt-dlp, 마그넷 · .torrent → aria2 (완료 후 올려 주기 안 함)": "YouTube → yt-dlp、マグネット・.torrent → aria2(完了後のアップロードなし)", "YouTube 가 로봇 확인을 요구합니다. 환경 설정 > 다운로드 > YouTube 쿠키 에서 브라우저(Firefox 권장) 또는 cookies.txt 를 지정한 뒤 다시 받으세요.": "YouTube がロボット確認を求めています。環境設定 > ダウンロード > YouTube クッキー でブラウザー(Firefox 推奨)または cookies.txt を指定してから、もう一度ダウンロードしてください。", "YouTube 광고 배너 숨기기": "YouTube の広告バナーを隠す", @@ -278,6 +280,7 @@ "권한 허용 화면 열기": "権限の許可画面を開く", "그 파일 하나만": "そのファイルだけ", "그리스어": "ギリシャ語", + "그림 · 만화 보기, PDF 보기 · 페이지 편집, ZIP 목록 · 만화 보기": "画像・マンガ表示、PDF 表示・ページ編集、ZIP 一覧・マンガ表示", "글을 격자에 반영": "テキストをグリッドに反映", "글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 (지금 {0}%)": "文字・ボタンの大きさです。上部バーの − ・ + でその都度変更でき、中央の数字を押すとこの大きさに戻ります(現在 {0}%)", "금": "金", @@ -422,6 +425,7 @@ "동시 다운로드 수": "同時ダウンロード数", "동시에 처리할 개수 (0 = 무한)": "同時に処理する数(0 = 無制限)", "동영상": "動画", + "동영상 + 자막을 MKV 로 합치기 · AI 자막 · 화면 보정": "動画 + 字幕を MKV に結合・AI 字幕・画面補正", "동영상 {0}개 · 전체 선택": "動画 {0}個・すべて選択", "동영상 목록 · 창 위치 저장": "動画リスト・ウィンドウ位置を保存", "동영상 목록에 추가: {0}": "動画リストに追加: {0}", @@ -442,6 +446,7 @@ "두 창": "2 ペイン", "두 창 배치": "2 ペインの配置", "두 창 사이": "ペインの間", + "두 창 파일 탐색기 · 복사 · 이동 · WebDAV": "2 画面ファイルエクスプローラー・コピー・移動・WebDAV", "뒤로": "戻る", "등록": "登録", "등록 중 일부가 실패했습니다.": "登録の一部に失敗しました。", @@ -491,6 +496,7 @@ "목록의 동영상 {0}개 모두": "リストの動画 {0}個すべて", "몽골어": "モンゴル語", "무한": "無制限", + "문서 미리보기": "文書プレビュー", "문자셋 경고": "文字コードの警告", "미리보기: 목록에서 동영상을 고르세요": "プレビュー: リストで動画を選んでください", "미얀마어": "ミャンマー語", @@ -627,6 +633,7 @@ "설치가 끝나면 프로그램이 다시 시작됩니다.": "インストールが終わるとプログラムが再起動します。", "설치됨": "インストール済み", "설치하는 중": "インストール中", + "설치하면 필요한 파일을 내려받습니다 (준비 중)": "インストールすると必要なファイルをダウンロードします (準備中)", "설치하지 못했습니다.\n{0}": "インストールできませんでした。\n{0}", "성공: {0}": "成功: {0}", "세로 4:5": "縦 4:5", @@ -703,6 +710,7 @@ "앱 아이콘": "アプリアイコン", "앱 안 브라우저 (권장)": "アプリ内ブラウザー(推奨)", "앱 안 브라우저의 로그인 사용 (브라우저 화면에서 YouTube 에 로그인하세요)": "アプリ内ブラウザーのログインを使う(ブラウザー画面で YouTube にログインしてください)", + "앱 안 웹 브라우저 · 즐겨찾기 · 동영상 다운로드": "アプリ内 Web ブラウザー・ブックマーク・動画ダウンロード", "앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "アプリ内ウェブブラウザー:[スキップ]を代わりに押し、スキップできない広告は音を消して早送りします(スキップ中は画面上部に「広告をスキップ中…」)", "앱 지우기": "アプリを削除", "앱에 들어 있는 것 (기본)": "アプリに内蔵 (既定)", @@ -806,6 +814,7 @@ "위 폴더": "上のフォルダー", "위로": "上へ", "위아래": "上下", + "위쪽 이동 버튼과 좌우로 밀기의 순서입니다. 줄을 끌어 바꿉니다.": "上部の移動ボタンと左右スワイプの順番です。行をドラッグして変更します。", "유형": "種類", "음성": "音声", "음성 {0}개": "音声 {0}個", @@ -872,6 +881,7 @@ "이미 받는 중이거나 받을 수 없는 주소입니다.": "既にダウンロード中か、ダウンロードできない URL です。", "이미 이 폴더에 있습니다: {0}": "すでにこのフォルダーにあります: {0}", "이미 있는 폴더입니다: {0}": "既にあるフォルダーです: {0}", + "이미지 · PDF · ZIP 보기": "画像・PDF・ZIP ビューアー", "이미지 자막은 번역할 수 없습니다": "画像字幕は翻訳できません", "이미지 자막은 편집할 수 없습니다": "画像字幕は編集できません", "이전": "前へ", @@ -1002,6 +1012,8 @@ "좌 → 우": "左 → 右", "좌 ⇄ 우": "左 ⇄ 右", "좌우": "左右", + "좌우 두 폴더 맞추기 (rsync) · 모니터링 · 실시간 동기화 (lsync)": "左右 2 つのフォルダーを同期 (rsync)・モニタリング・リアルタイム同期 (lsync)", + "좌우로 밀어 화면 이동": "左右スワイプで画面を移動", "주말 종일": "週末 終日", "주소": "アドレス", "주소 입력 또는 검색 (Ctrl+L)": "アドレスを入力または検索(Ctrl+L)", @@ -1094,6 +1106,7 @@ "취소했습니다.": "キャンセルしました。", "칠한 시간에만 동기화하고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다. 칸을 누르거나 끌어서 칠합니다 (요일 이름 · 시 숫자를 누르면 그 줄 전체).": "塗った時間だけ同期し、それ以外は変更を貯めて次の開始時刻に同期します。マスをタップまたはドラッグして塗ります (曜日名 · 時の数字をタップするとその行 · 列全体)。", "칠한 시간이 없어 동기화하지 않습니다.": "塗った時間がないため同期しません。", + "컴포넌트": "コンポーネント", "켜져 있는 창에서 (기본)": "開いているウィンドウで(既定)", "코덱": "コーデック", "크기": "サイズ", @@ -1206,15 +1219,18 @@ "화": "火", "화면": "画面", "화면 · 색 보정": "画面・カラー補正", + "화면 가운데를 왼쪽으로 밀면 다음 화면, 오른쪽으로 밀면 이전 화면 (마지막 다음은 처음으로).": "画面中央を左へスワイプで次の画面、右へで前の画面 (最後の次は最初へ)。", "화면 글자 전체를 이 기기에서 번역합니다. 몇 분 걸릴 수 있습니다.": "画面の文字をすべてこの端末で翻訳します。数分かかることがあります。", "화면 모양 따라": "画面の形に合わせる", "화면 방향": "画面の向き", "화면 비율": "画面比率", "화면 비율 (가로 · 세로) · 회전 · 밝기 · 대비 · 채도 · 색온도": "画面比率(横・縦)・回転・明るさ・コントラスト・彩度・色温度", + "화면 순서": "画面の順番", "화면 언어": "表示言語", "화면 작게": "画面を小さく", "화면 크게": "画面を大きく", "화면 크기": "画面サイズ", + "화면이 있는 컴포넌트는 하나 이상 남아 있어야 합니다.": "画面のあるコンポーネントを少なくとも1つ残す必要があります。", "화질": "画質", "확인": "OK", "확인 · 압축 푸는 중…": "確認・展開中…", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index be51ee0..9ffb2e5 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -74,6 +74,7 @@ "Chrome 엔진은 프로그램을 다시 시작하면 쓸 수 있습니다. 지금 다시 시작할까요?": "重新启动程序后即可使用 Chrome 引擎。现在重新启动吗?", "Chrome 엔진을 받을 수 없습니다 (HTTP {0})": "无法下载 Chrome 引擎(HTTP {0})", "Chrome 엔진을 시작할 수 없습니다. 환경 설정에서 Edge 로 바꿔 쓰세요.\n{0}": "无法启动 Chrome 引擎。请在设置中切换为 Edge。\n{0}", + "DOC · DOCX · HWP · XLS · XLSX 를 PDF 로 바꿔 보기 (변환기를 받음)": "将 DOC · DOCX · HWP · XLS · XLSX 转为 PDF 查看 (需下载转换器)", "Edge (내장)": "Edge(内置)", "Edge 에서 가져오기": "从 Edge 导入", "Edge 에서 가져옴": "从 Edge 导入", @@ -139,6 +140,7 @@ "Windows 기본 브라우저": "Windows 默认浏览器", "Windows 기본 프로그램": "Windows 默认程序", "Windows 탐색기": "Windows 资源管理器", + "YouTube · 토렌트 다운로드 목록": "YouTube · 种子下载列表", "YouTube → yt-dlp, 마그넷 · .torrent → aria2 (완료 후 올려 주기 안 함)": "YouTube → yt-dlp,磁力链接 · .torrent → aria2(完成后不做种)", "YouTube 가 로봇 확인을 요구합니다. 환경 설정 > 다운로드 > YouTube 쿠키 에서 브라우저(Firefox 권장) 또는 cookies.txt 를 지정한 뒤 다시 받으세요.": "YouTube 要求进行机器人验证。请在 设置 > 下载 > YouTube Cookie 中指定浏览器(推荐 Firefox)或 cookies.txt 后重新下载。", "YouTube 광고 배너 숨기기": "隐藏 YouTube 广告横幅", @@ -278,6 +280,7 @@ "권한 허용 화면 열기": "打开权限授予界面", "그 파일 하나만": "仅该文件", "그리스어": "希腊语", + "그림 · 만화 보기, PDF 보기 · 페이지 편집, ZIP 목록 · 만화 보기": "图片 · 漫画阅读,PDF 查看 · 页面编辑,ZIP 列表 · 漫画阅读", "글을 격자에 반영": "将文本应用到网格", "글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 (지금 {0}%)": "文字 · 按钮大小。可随时用顶部栏的 − · + 调整,点击中间的数字恢复为此大小(当前 {0}%)", "금": "五", @@ -422,6 +425,7 @@ "동시 다운로드 수": "同时下载数", "동시에 처리할 개수 (0 = 무한)": "同时处理的数量(0 = 无限制)", "동영상": "视频", + "동영상 + 자막을 MKV 로 합치기 · AI 자막 · 화면 보정": "将视频 + 字幕合并为 MKV · AI 字幕 · 画面调整", "동영상 {0}개 · 전체 선택": "视频 {0} 个 · 全选", "동영상 목록 · 창 위치 저장": "保存视频列表 · 窗口位置", "동영상 목록에 추가: {0}": "已加入视频列表: {0}", @@ -442,6 +446,7 @@ "두 창": "双窗格", "두 창 배치": "双窗格排列", "두 창 사이": "窗格之间", + "두 창 파일 탐색기 · 복사 · 이동 · WebDAV": "双窗格文件浏览器 · 复制 · 移动 · WebDAV", "뒤로": "后退", "등록": "注册", "등록 중 일부가 실패했습니다.": "部分注册失败。", @@ -491,6 +496,7 @@ "목록의 동영상 {0}개 모두": "列表中的全部 {0} 个视频", "몽골어": "蒙古语", "무한": "无限制", + "문서 미리보기": "文档预览", "문자셋 경고": "字符集警告", "미리보기: 목록에서 동영상을 고르세요": "预览: 请在列表中选择视频", "미얀마어": "缅甸语", @@ -627,6 +633,7 @@ "설치가 끝나면 프로그램이 다시 시작됩니다.": "安装完成后程序将重新启动。", "설치됨": "已安装", "설치하는 중": "正在安装", + "설치하면 필요한 파일을 내려받습니다 (준비 중)": "安装时会下载所需文件 (准备中)", "설치하지 못했습니다.\n{0}": "未能安装。\n{0}", "성공: {0}": "成功: {0}", "세로 4:5": "竖屏 4:5", @@ -703,6 +710,7 @@ "앱 아이콘": "应用图标", "앱 안 브라우저 (권장)": "应用内浏览器(推荐)", "앱 안 브라우저의 로그인 사용 (브라우저 화면에서 YouTube 에 로그인하세요)": "使用应用内浏览器的登录(请在浏览器界面登录 YouTube)", + "앱 안 웹 브라우저 · 즐겨찾기 · 동영상 다운로드": "应用内浏览器 · 书签 · 视频下载", "앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "应用内网页浏览器: 代为点击[跳过],无法跳过的广告会静音并快进(跳过时屏幕顶部显示“正在跳过广告…”)", "앱 지우기": "卸载应用", "앱에 들어 있는 것 (기본)": "应用内置 (默认)", @@ -806,6 +814,7 @@ "위 폴더": "上级文件夹", "위로": "向上", "위아래": "上下", + "위쪽 이동 버튼과 좌우로 밀기의 순서입니다. 줄을 끌어 바꿉니다.": "顶部导航按钮和左右滑动的顺序。拖动行即可更改。", "유형": "类型", "음성": "音频", "음성 {0}개": "音频 {0} 个", @@ -872,6 +881,7 @@ "이미 받는 중이거나 받을 수 없는 주소입니다.": "已在下载中,或该地址无法下载。", "이미 이 폴더에 있습니다: {0}": "已在此文件夹中: {0}", "이미 있는 폴더입니다: {0}": "文件夹已存在: {0}", + "이미지 · PDF · ZIP 보기": "图片 · PDF · ZIP 查看", "이미지 자막은 번역할 수 없습니다": "图像字幕无法翻译", "이미지 자막은 편집할 수 없습니다": "图像字幕无法编辑", "이전": "上一个", @@ -1002,6 +1012,8 @@ "좌 → 우": "左 → 右", "좌 ⇄ 우": "左 ⇄ 右", "좌우": "左右", + "좌우 두 폴더 맞추기 (rsync) · 모니터링 · 실시간 동기화 (lsync)": "同步左右两个文件夹 (rsync) · 监控 · 实时同步 (lsync)", + "좌우로 밀어 화면 이동": "左右滑动切换界面", "주말 종일": "周末 全天", "주소": "地址", "주소 입력 또는 검색 (Ctrl+L)": "输入地址或搜索(Ctrl+L)", @@ -1094,6 +1106,7 @@ "취소했습니다.": "已取消。", "칠한 시간에만 동기화하고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다. 칸을 누르거나 끌어서 칠합니다 (요일 이름 · 시 숫자를 누르면 그 줄 전체).": "只在涂色的时间同步,其他时间的变化累积到下次开始再同步。点按或拖动格子涂色 (点按星期名 · 小时数字可选整行 · 整列)。", "칠한 시간이 없어 동기화하지 않습니다.": "没有涂色的时间,因此不会同步。", + "컴포넌트": "组件", "켜져 있는 창에서 (기본)": "在已打开的窗口中(默认)", "코덱": "编解码器", "크기": "大小", @@ -1206,15 +1219,18 @@ "화": "二", "화면": "显示", "화면 · 색 보정": "画面 · 色彩", + "화면 가운데를 왼쪽으로 밀면 다음 화면, 오른쪽으로 밀면 이전 화면 (마지막 다음은 처음으로).": "在屏幕中间向左滑动进入下一个界面,向右滑动返回上一个界面 (最后一个之后回到第一个)。", "화면 글자 전체를 이 기기에서 번역합니다. 몇 분 걸릴 수 있습니다.": "将在此设备上翻译所有界面文字。可能需要几分钟。", "화면 모양 따라": "跟随屏幕形状", "화면 방향": "屏幕方向", "화면 비율": "画面比例", "화면 비율 (가로 · 세로) · 회전 · 밝기 · 대비 · 채도 · 색온도": "画面比例(横屏 · 竖屏)· 旋转 · 亮度 · 对比度 · 饱和度 · 色温", + "화면 순서": "界面顺序", "화면 언어": "界面语言", "화면 작게": "缩小界面", "화면 크게": "放大界面", "화면 크기": "画面大小", + "화면이 있는 컴포넌트는 하나 이상 남아 있어야 합니다.": "至少要保留一个带界面的组件。", "화질": "画质", "확인": "确定", "확인 · 압축 푸는 중…": "正在校验 · 解压…", diff --git a/lib/app/components.dart b/lib/app/components.dart new file mode 100644 index 0000000..76cad0f --- /dev/null +++ b/lib/app/components.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; + +/// 앱의 기능 묶음 (컴포넌트). 코드는 앱에 들어 있고, 환경 설정 > 컴포넌트에서 설치 (필요한 파일을 받아 켬) · 제거 (끔) 한다. +/// 화면이 있는 것 ([page]) 은 위쪽 이동 버튼 · 좌우로 밀기로 오간다 (순서는 [AppSettings.navOrder]). +class AppComponent { + final String id; + + /// 화면 이름 (tr() 로 번역) + final String name; + final String description; + final IconData icon; + + /// 위쪽 이동 버튼 · 좌우로 밀기로 오가는 화면 + final bool page; + + /// 설치할 때 받아야 하는 파일이 있음 (GitHub 릴리스의 components.json) + final bool needsDownload; + + /// 처음 설치할 때부터 켜져 있음 + final bool builtIn; + + const AppComponent(this.id, this.name, this.description, this.icon, + {this.page = true, this.needsDownload = false, this.builtIn = true}); + + static const mkv = AppComponent('mkv', 'MKV 만들기', '동영상 + 자막을 MKV 로 합치기 · AI 자막 · 화면 보정', Icons.video_library_outlined); + static const browser = AppComponent('browser', '웹 브라우저', '앱 안 웹 브라우저 · 즐겨찾기 · 동영상 다운로드', Icons.public); + static const explorer = AppComponent('explorer', '파일 탐색기', '두 창 파일 탐색기 · 복사 · 이동 · WebDAV', Icons.folder_copy_outlined); + static const rsync = AppComponent('rsync', 'Rsync', '좌우 두 폴더 맞추기 (rsync) · 모니터링 · 실시간 동기화 (lsync)', Icons.sync_alt); + static const downloads = AppComponent('downloads', '다운로드', 'YouTube · 토렌트 다운로드 목록', Icons.download_for_offline_outlined); + static const viewer = AppComponent('viewer', '이미지 · PDF · ZIP 보기', '그림 · 만화 보기, PDF 보기 · 페이지 편집, ZIP 목록 · 만화 보기', + Icons.photo_library_outlined, page: false); + static const docs = AppComponent('docs', '문서 미리보기', 'DOC · DOCX · HWP · XLS · XLSX 를 PDF 로 바꿔 보기 (변환기를 받음)', + Icons.description_outlined, page: false, needsDownload: true, builtIn: false); + + static const all = [mkv, browser, explorer, rsync, downloads, viewer, docs]; + + /// 화면 있는 것의 기본 순서 (좌우로 밀기 · 위쪽 버튼) + static const defaultOrder = ['mkv', 'browser', 'explorer', 'rsync', 'downloads']; + + static AppComponent? byId(String id) => all.where((c) => c.id == id).firstOrNull; + + /// 처음 설치 때 켜진 것 + static List get defaultInstalled => [for (final c in all) if (c.builtIn) c.id]; + + /// 설치된 화면들을 [order] 순서로 (순서에 없는 것은 기본 순서대로 뒤에) + static List pages(List installed, List order) { + final ids = [ + ...order.where(defaultOrder.contains), + ...defaultOrder.where((x) => !order.contains(x)), + ]; + return [for (final id in ids) if (installed.contains(id)) byId(id)!]; + } +} diff --git a/lib/app/settings.dart b/lib/app/settings.dart index cade1d6..d620533 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; +import 'components.dart'; import '../core/download_detect.dart'; import '../core/encode_options.dart'; import '../core/playlist.dart'; @@ -332,6 +333,15 @@ class AppSettings { /// Rsync 화면에서 마지막으로 연 폴더 (왼쪽 · 오른쪽) List rsyncPaths = []; + /// 설치한 (켠) 컴포넌트 id (app/components.dart). 처음엔 기본으로 들어 있는 것 모두 + List components = AppComponent.defaultInstalled; + + /// 화면 순서 (위쪽 이동 버튼 · 화면 가운데를 좌우로 밀어 이동): 마지막 다음은 처음으로 + List navOrder = AppComponent.defaultOrder; + + /// 화면 가운데를 좌우로 밀어 다음 · 이전 화면으로 + bool swipeNav = true; + /// WebDAV 서버 (파일 탐색기 · Rsync 화면 위쪽 "SD 카드" 옆 탭). 비밀번호는 이 설정 파일에만. List webdavServers = []; @@ -444,6 +454,9 @@ class AppSettings { 'copyMethodFile': copyMethodFile, 'copyMethodFolder': copyMethodFolder, 'rsyncPaths': rsyncPaths, + 'components': components, + 'navOrder': navOrder, + 'swipeNav': swipeNav, 'webdavServers': [for (final x in webdavServers) x.toJson()], 'rsyncOptions': rsyncOptions, 'robocopyOptions': robocopyOptions, @@ -550,6 +563,13 @@ class AppSettings { ..copyMethodFile = _method(j['copyMethodFile']) ..copyMethodFolder = _method(j['copyMethodFolder']) ..rsyncPaths = [for (final x in (j['rsyncPaths'] as List?) ?? const []) '$x'] + ..components = j['components'] is List + ? [for (final x in j['components'] as List) if (AppComponent.byId('$x') != null) '$x'] + : AppComponent.defaultInstalled + ..navOrder = j['navOrder'] is List + ? [for (final x in j['navOrder'] as List) if (AppComponent.defaultOrder.contains('$x')) '$x'] + : AppComponent.defaultOrder + ..swipeNav = j['swipeNav'] as bool? ?? true ..webdavServers = [ for (final x in (j['webdavServers'] as List?) ?? const []) if (x is Map) DavServer.fromJson(x), diff --git a/lib/main.dart b/lib/main.dart index 1f87b71..bf0b82f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -34,7 +34,6 @@ import 'ui/app_actions.dart'; import 'ui/app_drop.dart'; import 'ui/browser_page.dart'; import 'ui/downloads_page.dart'; -import 'ui/explorer_page.dart'; import 'ui/exit_dialog.dart'; import 'ui/home_page.dart'; import 'ui/monitor_page.dart' show askLiveSyncStart; @@ -295,13 +294,11 @@ Future main(List args) async { content: Text(trf('지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 ' '{0}:{1}). ' '작업 기록에 남겨 두었습니다.', [crashed.$2.hour.toString().padLeft(2, '0'), crashed.$2.minute.toString().padLeft(2, '0')])), )); } - // 시작 화면을 "웹 브라우저" · "파일 탐색기" 로 정했으면 그 화면을 연다 (MKV 화면은 그 아래에 있음) - if (controller.settings.startScreen == 'browser') { - final nav = navigatorKey.currentState; - if (nav != null) unawaited(BrowserPage.open(nav, c: controller, downloads: downloads, bookmarks: bookmarks)); - } else if (controller.settings.startScreen == 'files') { - final nav = navigatorKey.currentState; - if (nav != null) unawaited(ExplorerPage.open(nav, c: controller)); + // 홈 화면이 MKV 화면이 아니면 (또는 MKV 만들기를 제거했으면) 그 화면을 연다 (MKV 화면은 그 아래에 있음) + final home = AppNavButtons.homeId(controller.settings); + final nav0 = navigatorKey.currentState; + if (home != 'mkv' && nav0 != null) { + unawaited(AppNavButtons.openPage(nav0, home, c: controller, downloads: downloads, bookmarks: bookmarks)); } if (controller.settings.liveSyncOnStart == 'ask' && ctx.mounted) await askLiveSyncStart(ctx, live); // 다른 버전을 쓰다가 이 버전으로 돌아왔으면 보관해 둔 이 버전의 설정을 되살릴지 @@ -481,12 +478,10 @@ Future runAndroid(String dataDir) async { return; } } catch (_) {} - if (controller.settings.startScreen == 'browser') { - final nav = navigatorKey.currentState; - if (nav != null) unawaited(BrowserPage.open(nav, c: controller, downloads: downloads, bookmarks: bookmarks)); - } else if (controller.settings.startScreen == 'files') { - final nav = navigatorKey.currentState; - if (nav != null) unawaited(ExplorerPage.open(nav, c: controller)); + final home = AppNavButtons.homeId(controller.settings); + final nav0 = navigatorKey.currentState; + if (home != 'mkv' && nav0 != null) { + unawaited(AppNavButtons.openPage(nav0, home, c: controller, downloads: downloads, bookmarks: bookmarks)); } // 다시 켤 때 실시간 동기화를 골라서 시작 final ctx0 = navigatorKey.currentContext; diff --git a/lib/platform/android/android_keep_alive.dart b/lib/platform/android/android_keep_alive.dart index c4d2826..673b00d 100644 --- a/lib/platform/android/android_keep_alive.dart +++ b/lib/platform/android/android_keep_alive.dart @@ -62,9 +62,11 @@ class AndroidKeepAlive { _last = ''; return; } - final key = '$text|$progress'; + // 알림 아이콘: 받는 중이면 내려받기, 아니면 (동기화 · 작업) 좌우로 오가는 화살표 + final icon = running > 0 ? 'download' : 'sync'; + final key = '$text|$progress|$icon'; if (_on && key == _last) return; - await _ch.invokeMethod('keepAlive', {'text': text, 'progress': progress}); + await _ch.invokeMethod('keepAlive', {'text': text, 'progress': progress, 'icon': icon}); _on = true; _last = key; } catch (_) {} diff --git a/lib/ui/app_actions.dart b/lib/ui/app_actions.dart index c096d12..553de4b 100644 --- a/lib/ui/app_actions.dart +++ b/lib/ui/app_actions.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../app/app_controller.dart'; import '../app/bookmarks_controller.dart'; +import '../app/components.dart'; import '../app/download_manager.dart'; import '../app/settings.dart'; import '../services/app_shell.dart' show appIconButtonAsset; @@ -100,37 +101,108 @@ class AppNavButtons extends StatelessWidget { this.onExplorerPage = false, this.onRsyncPage = false}); + /// 버튼 줄의 가장 넓은 폭 (JJ · 화면 5개 · 뒤로) static const double width = 7 * 40; /// MKV 화면 (맨 처음 화면) 까지 돌아가기 static void toMkv(BuildContext context) => Navigator.of(context).popUntil((r) => r.isFirst); - /// 홈 화면으로: MKV 화면까지 돌아간 뒤, 홈 화면이 웹 브라우저 · 파일 탐색기면 그 화면을 연다 + /// 홈 화면 (환경 설정의 "홈 화면") 의 컴포넌트 id. 그 컴포넌트를 제거했으면 설치된 첫 화면. + static String homeId(AppSettings s) { + final want = switch (s.startScreen) { 'browser' => 'browser', 'files' => 'explorer', _ => 'mkv' }; + final pages = AppComponent.pages(s.components, s.navOrder); + if (pages.any((p) => p.id == want)) return want; + return pages.isEmpty ? 'mkv' : pages.first.id; + } + + /// 그 화면으로 (이미 열려 있으면 그 화면으로 돌아감). 'mkv' 는 맨 처음 화면. + static Future openPage(NavigatorState nav, String id, + {required AppController c, DownloadManager? downloads, BookmarksController? bookmarks}) async { + switch (id) { + case 'mkv': + nav.popUntil((r) => r.isFirst); + case 'browser': + if (bookmarks != null) await BrowserPage.open(nav, c: c, downloads: downloads, bookmarks: bookmarks); + case 'explorer': + await ExplorerPage.open(nav, c: c); + case 'rsync': + await ExplorerPage.openRsync(nav, c: c); + case 'downloads': + if (downloads != null) await DownloadsPage.open(nav, downloads); + } + } + + /// 이 창에서 열 수 있는 화면 (브라우저 · 다운로드가 없는 새 창에서는 그 둘을 뺀다) + static List pagesOf(AppScope scope) => [ + for (final p in AppComponent.pages(scope.controller.settings.components, scope.controller.settings.navOrder)) + if ((p.id != 'browser' || scope.bookmarks != null) && (p.id != 'downloads' || scope.downloads != null)) p, + ]; + + static void open(BuildContext context, String id) { + final scope = AppScope.maybeOf(context); + if (scope == null) return; + openPage(Navigator.of(context), id, c: scope.controller, downloads: scope.downloads, bookmarks: scope.bookmarks); + } + + /// 홈 화면으로: MKV 화면까지 돌아간 뒤, 홈 화면이 다른 화면이면 그 화면을 연다 static void toHome(BuildContext context) { final scope = AppScope.maybeOf(context); final nav = Navigator.of(context); nav.popUntil((r) => r.isFirst); if (scope == null) return; - final start = scope.controller.settings.startScreen; - if (start == 'browser' && scope.bookmarks != null) { - BrowserPage.open(nav, c: scope.controller, downloads: scope.downloads, bookmarks: scope.bookmarks!); - } else if (start == 'files') { - ExplorerPage.open(nav, c: scope.controller); - } + final id = homeId(scope.controller.settings); + if (id != 'mkv') open(context, id); + } + + /// 지금 화면에서 [step] 만큼 (−1 이전 · +1 다음) 옮긴 화면으로. 끝 다음은 처음으로. + static void step(BuildContext context, String current, int step) { + final scope = AppScope.maybeOf(context); + if (scope == null) return; + final pages = pagesOf(scope); + if (pages.length < 2) return; + var i = pages.indexWhere((p) => p.id == current); + if (i < 0) i = 0; + open(context, pages[(i + step) % pages.length].id); } @override Widget build(BuildContext context) { final scope = AppScope.maybeOf(context); + // 컴포넌트 설치 · 순서를 바꾸면 바로 다시 그린다 (이 위젯은 const 로 쓰여 부모가 다시 그려도 그대로이므로) + if (scope == null) return _build(context, null); + return ListenableBuilder(listenable: scope.controller, builder: (context, _) => _build(context, scope)); + } + + Widget _build(BuildContext context, AppScope? scope) { final nav = Navigator.maybeOf(context); final atRoot = !(nav?.canPop() ?? false); - final start = scope?.controller.settings.startScreen; - final homeName = start == 'browser' && scope?.bookmarks != null - ? tr('웹 브라우저') - : start == 'files' - ? tr('파일 탐색기') - : tr('MKV 화면'); - final downloads = scope?.downloads; + final current = onDownloadsPage + ? 'downloads' + : onBrowserPage + ? 'browser' + : onExplorerPage + ? 'explorer' + : onRsyncPage + ? 'rsync' + : atRoot + ? 'mkv' + : ''; + final pages = scope == null ? const [] : pagesOf(scope); + final homeName = switch (scope == null ? 'mkv' : homeId(scope.controller.settings)) { + 'browser' => tr('웹 브라우저'), + 'explorer' => tr('파일 탐색기'), + 'rsync' => 'Rsync', + 'downloads' => tr('다운로드 목록'), + _ => tr('MKV 화면'), + }; + String tip(String id, bool here) => switch (id) { + 'mkv' => here ? tr('MKV 화면 (지금 여기)') : tr('MKV 화면으로'), + 'browser' => here ? tr('웹 브라우저 (지금 여기)') : tr('웹 브라우저'), + 'explorer' => here ? tr('파일 탐색기 (지금 여기)') : tr('파일 탐색기'), + 'rsync' => here ? tr('Rsync (지금 여기)') : 'Rsync', + 'downloads' => here ? tr('다운로드 목록 (지금 여기)') : tr('다운로드 목록'), + _ => id, + }; Widget btn(Widget icon, String tip, VoidCallback? f, {bool here = false}) => SizedBox( width: 40, height: 40, @@ -142,57 +214,60 @@ class AppNavButtons extends StatelessWidget { onPressed: f, ), ); - return SizedBox( - width: width, - child: Row(children: [ + return Row(mainAxisSize: MainAxisSize.min, children: [ + btn( + Image.asset(appIconButtonAsset(scope?.controller.settings.appIcon), + width: 26, height: 26, filterQuality: FilterQuality.medium), + trf('홈 화면 ({0}) · 환경 설정에서 바꿈', [homeName]), + scope == null ? null : () => toHome(context), + ), + // 설치한 화면들 (환경 설정 > 컴포넌트 의 순서). 하나뿐이면 버튼 줄은 그것만. + for (final p in pages) btn( - Image.asset(appIconButtonAsset(scope?.controller.settings.appIcon), - width: 26, height: 26, filterQuality: FilterQuality.medium), - trf('홈 화면 ({0}) · 환경 설정에서 바꿈', [homeName]), - scope == null ? null : () => toHome(context), + Icon(p.icon, color: p.id == current ? JjColors.accent : null), + tip(p.id, p.id == current), + p.id == current ? null : () => open(context, p.id), + here: p.id == current, ), - btn( - Icon(Icons.video_library_outlined, color: atRoot ? JjColors.accent : null), - atRoot ? tr('MKV 화면 (지금 여기)') : tr('MKV 화면으로'), - atRoot ? null : () => toMkv(context), - here: atRoot, - ), - // 웹 브라우저: MKV 화면 버튼 다음 (모든 화면 같은 자리) - btn( - Icon(Icons.public, color: onBrowserPage ? JjColors.accent : null), - onBrowserPage ? tr('웹 브라우저 (지금 여기)') : tr('웹 브라우저'), - scope?.bookmarks == null || onBrowserPage - ? null - : () => BrowserPage.open(Navigator.of(context), - c: scope!.controller, downloads: scope.downloads, bookmarks: scope.bookmarks!), - here: onBrowserPage, - ), - // 파일 탐색기: 웹 브라우저 다음 - btn( - Icon(Icons.folder_copy_outlined, color: onExplorerPage ? JjColors.accent : null), - onExplorerPage ? tr('파일 탐색기 (지금 여기)') : tr('파일 탐색기'), - scope == null || onExplorerPage ? null : () => ExplorerPage.open(Navigator.of(context), c: scope.controller), - here: onExplorerPage, - ), - // Rsync: 파일 탐색기 다음 (좌우 두 창 · 폴더 하나씩 골라 → ← ↔) - btn( - Icon(Icons.sync_alt, color: onRsyncPage ? JjColors.accent : null), - onRsyncPage ? tr('Rsync (지금 여기)') : 'Rsync', - scope == null || onRsyncPage ? null : () => ExplorerPage.openRsync(Navigator.of(context), c: scope.controller), - here: onRsyncPage, - ), - btn(const Icon(Icons.arrow_back), tr('뒤로'), - // 브라우저 화면: 뒤로 키는 웹 페이지 뒤로 (PopScope) 이지만 이 버튼은 화면 이동이라 바로 닫는다 - atRoot ? null : () => onBrowserPage ? Navigator.pop(context) : Navigator.maybePop(context)), - btn( - Icon(Icons.download_for_offline_outlined, color: onDownloadsPage ? JjColors.accent : null), - onDownloadsPage ? tr('다운로드 목록 (지금 여기)') : tr('다운로드 목록'), - downloads == null || onDownloadsPage - ? null - : () => DownloadsPage.open(Navigator.of(context), downloads), - here: onDownloadsPage, - ), - ]), + btn(const Icon(Icons.arrow_back), tr('뒤로'), + // 브라우저 화면: 뒤로 키는 웹 페이지 뒤로 (PopScope) 이지만 이 버튼은 화면 이동이라 바로 닫는다 + atRoot ? null : () => onBrowserPage ? Navigator.pop(context) : Navigator.maybePop(context)), + ]); + } +} + +/// 화면 가운데를 좌우로 밀면 다음 · 이전 화면으로 (환경 설정 > 컴포넌트 의 순서, 끝 다음은 처음). +/// 안쪽의 옆으로 밀리는 것 (가로 목록 · 막대) 이 먼저 받고, 위 · 아래 가장자리 (1/5) 에서 시작한 것은 무시한다. +class SwipeNav extends StatefulWidget { + final String current; + final Widget child; + const SwipeNav({super.key, required this.current, required this.child}); + + @override + State createState() => _SwipeNavState(); +} + +class _SwipeNavState extends State { + bool _middle = false; + + @override + Widget build(BuildContext context) { + final scope = AppScope.maybeOf(context); + if (scope == null) return widget.child; + return GestureDetector( + behavior: HitTestBehavior.translucent, + onHorizontalDragStart: (d) { + final h = context.size?.height ?? 0; + _middle = h > 0 && d.localPosition.dy > h * 0.2 && d.localPosition.dy < h * 0.8; + }, + onHorizontalDragEnd: (d) { + final v = d.primaryVelocity ?? 0; + // 켜고 끄기는 밀 때 확인 (이 위젯은 설정이 바뀌어도 다시 그려지지 않을 수 있음) + if (!scope.controller.settings.swipeNav || !_middle || v.abs() < 500) return; + // 왼쪽으로 밀면 다음 화면, 오른쪽으로 밀면 이전 화면 + AppNavButtons.step(context, widget.current, v < 0 ? 1 : -1); + }, + child: widget.child, ); } } diff --git a/lib/ui/browser_page.dart b/lib/ui/browser_page.dart index 6fafe39..18eef9e 100644 --- a/lib/ui/browser_page.dart +++ b/lib/ui/browser_page.dart @@ -432,7 +432,10 @@ class _BrowserPageState extends State with RouteAware { child: Focus( autofocus: true, child: Scaffold( - body: ListenableBuilder( + // 웹 페이지 위에서의 밀기는 웹 페이지가 받으므로, 위쪽 막대 · 즐겨찾기 줄에서 좌우로 밀면 화면 이동 + body: SwipeNav( + current: 'browser', + child: ListenableBuilder( listenable: Listenable.merge([bm, widget.c]), builder: (context, _) => Column(children: [ _toolbar(), @@ -502,6 +505,7 @@ class _BrowserPageState extends State with RouteAware { ])), ]), ), + ), ), ), ), diff --git a/lib/ui/component_settings.dart b/lib/ui/component_settings.dart new file mode 100644 index 0000000..641500c --- /dev/null +++ b/lib/ui/component_settings.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; + +import '../app/app_controller.dart'; +import '../app/components.dart'; +import '../l10n/tr.dart'; +import 'setting_tile.dart'; +import 'theme.dart'; + +/// 환경 설정 > 컴포넌트: 기능 묶음 설치 (켜기) · 제거 (끄기), 화면 순서 (끌어서 바꿈), 좌우로 밀어 화면 이동. +/// 화면이 하나만 남으면 위쪽 이동 버튼 줄에는 그것만 보인다. +class ComponentSettings extends StatelessWidget { + final AppController c; + const ComponentSettings({super.key, required this.c}); + + @override + Widget build(BuildContext context) { + final s = c.settings; + final installed = s.components; + final pages = AppComponent.pages(installed, s.navOrder); + + Future setInstalled(AppComponent x, bool on) async { + // 화면 있는 것이 하나도 남지 않게는 못 한다 + if (!on && x.page && pages.length <= 1 && pages.first.id == x.id) { + ScaffoldMessenger.maybeOf(context) + ?.showSnackBar(SnackBar(content: Text(tr('화면이 있는 컴포넌트는 하나 이상 남아 있어야 합니다.')))); + return; + } + await c.updateSettings((v) => v.components = [ + for (final id in v.components) if (id != x.id) id, + if (on) x.id, + ]); + } + + return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + SwitchListTile( + value: s.swipeNav, + onChanged: (v) => c.updateSettings((x) => x.swipeNav = v), + title: Text(tr('좌우로 밀어 화면 이동')), + subtitle: Text(tr('화면 가운데를 왼쪽으로 밀면 다음 화면, 오른쪽으로 밀면 이전 화면 (마지막 다음은 처음으로).')), + ), + for (final x in AppComponent.all) + SettingTile( + leading: Icon(x.icon, color: installed.contains(x.id) ? JjColors.accent : JjColors.textDim), + title: Text(tr(x.name)), + subtitle: Text(x.needsDownload ? '${tr(x.description)}\n${tr('설치하면 필요한 파일을 내려받습니다 (준비 중)')}' : tr(x.description)), + trailing: Switch( + value: installed.contains(x.id), + onChanged: x.needsDownload && !installed.contains(x.id) ? null : (v) => setInstalled(x, v), + ), + ), + const Divider(), + SettingTile( + leading: const Icon(Icons.swap_vert), + title: Text(tr('화면 순서')), + subtitle: Text(tr('위쪽 이동 버튼과 좌우로 밀기의 순서입니다. 줄을 끌어 바꿉니다.')), + ), + ReorderableListView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + buildDefaultDragHandles: false, + onReorderItem: (from, to) { + final ids = [for (final p in pages) p.id]; + ids.insert(to, ids.removeAt(from)); + // 설치하지 않은 화면은 원래 자리 순서대로 뒤에 + c.updateSettings((v) => v.navOrder = [...ids, ...AppComponent.defaultOrder.where((x) => !ids.contains(x))]); + }, + children: [ + for (final (i, p) in pages.indexed) + ReorderableDragStartListener( + key: ValueKey(p.id), + index: i, + child: ListTile( + dense: true, + leading: Text('${i + 1}', style: const TextStyle(color: JjColors.textDim)), + title: Row(children: [Icon(p.icon, size: 18), const SizedBox(width: 10), Text(tr(p.name))]), + trailing: const Icon(Icons.drag_handle), + ), + ), + ], + ), + ]); + } +} diff --git a/lib/ui/downloads_page.dart b/lib/ui/downloads_page.dart index 4cc5e70..767f619 100644 --- a/lib/ui/downloads_page.dart +++ b/lib/ui/downloads_page.dart @@ -75,7 +75,9 @@ class _DownloadsPageState extends State { final hasSel = d.selected.isNotEmpty; final allSel = d.tasks.isNotEmpty && d.selected.length == d.tasks.length; return Scaffold( - body: Column(children: [ + body: SwipeNav( + current: 'downloads', + child: Column(children: [ AppTopBar( nav: const AppNavButtons(onDownloadsPage: true), actions: const AppActions(), @@ -182,6 +184,7 @@ class _DownloadsPageState extends State { ), ), ]), + ), ); }, ); diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 5b5b065..5eeb93b 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -1544,7 +1544,9 @@ class _ExplorerPageState extends State { child: Focus( autofocus: true, child: Scaffold( - body: ListenableBuilder( + body: SwipeNav( + current: widget.rsync ? 'rsync' : 'explorer', + child: ListenableBuilder( listenable: c, builder: (context, _) { _dropRemovedServers(); @@ -1560,6 +1562,7 @@ class _ExplorerPageState extends State { ]); }, ), + ), ), ), ); diff --git a/lib/ui/home_page.dart b/lib/ui/home_page.dart index 4da1edb..5eed681 100644 --- a/lib/ui/home_page.dart +++ b/lib/ui/home_page.dart @@ -47,7 +47,8 @@ class HomePage extends StatelessWidget { listenable: c, builder: (context, _) => Scaffold( // 끌어다 놓기는 앱 전체에서 받는다 (ui/app_drop.dart 의 AppDropArea - 어느 화면에서 놓아도 이 목록에 추가) - body: SizedBox( + body: SwipeNav( + current: 'mkv', child: Column( children: [ _TopBar(c: c, downloads: downloads, onExit: onExit, bookmarks: bookmarks), diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index 14db67d..373b20a 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -28,6 +28,7 @@ import 'explorer_look.dart' show ExplorerStyle; import 'theme.dart'; import '../l10n/tr.dart'; import 'setting_tile.dart'; +import 'component_settings.dart'; /// 동시 작업 수 고르기: 1 · 5 · 10 · 무한(0) · 직접 입력 class CountSelector extends StatelessWidget { @@ -326,6 +327,7 @@ class _SettingsPageState extends State { ), ), ]), + _group('components', Icons.extension_outlined, tr('컴포넌트'), [ComponentSettings(c: c)]), _group('display', Icons.aspect_ratio, tr('화면'), [ if (!desk) SettingTile( @@ -852,12 +854,13 @@ class _SettingsPageState extends State { final _groupKeys = {}; static const _groupIds = [ - 'general', 'display', 'mkv', 'subtitle', 'play', 'browser', 'files', 'rsync', 'download', 'run', 'cleanup', 'about', // + 'general', 'components', 'display', 'mkv', 'subtitle', 'play', 'browser', 'files', 'rsync', 'download', 'run', 'cleanup', 'about', // ]; String _groupTitle(String id) => switch (id) { 'general' => tr('일반'), 'display' => tr('화면'), + 'components' => tr('컴포넌트'), 'mkv' => tr('MKV 만들기'), 'subtitle' => tr('자막 (AI · 인터넷)'), 'play' => tr('재생'), @@ -905,7 +908,7 @@ class _SettingsPageState extends State { child: Row(children: [ Icon(icon, size: 20, color: JjColors.accent), const SizedBox(width: 10), - Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), + Text(title, key: ValueKey('group-title:$id'), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), ]), ), ...children, diff --git a/test/components_test.dart b/test/components_test.dart new file mode 100644 index 0000000..5077c6f --- /dev/null +++ b/test/components_test.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/components.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/app_actions.dart'; +import 'package:jj_mkvmaker/ui/home_page.dart'; + +AppController _plain() => + AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + +void main() { + test('컴포넌트: 설치한 화면만 순서대로 · 홈 화면은 제거되면 첫 화면 · 설정 저장', () { + final s = AppSettings(); + expect(s.components, containsAll(['mkv', 'browser', 'explorer', 'rsync', 'downloads', 'viewer'])); + expect(s.components, isNot(contains('docs'))); // 변환기를 받아야 하는 것은 처음엔 꺼짐 + expect([for (final p in AppComponent.pages(s.components, s.navOrder)) p.id], + ['mkv', 'browser', 'explorer', 'rsync', 'downloads']); + s + ..navOrder = ['explorer', 'mkv'] + ..components = ['mkv', 'explorer', 'downloads']; + expect([for (final p in AppComponent.pages(s.components, s.navOrder)) p.id], ['explorer', 'mkv', 'downloads']); + s.startScreen = 'browser'; // 제거한 화면 → 설치된 첫 화면 + expect(AppNavButtons.homeId(s), 'explorer'); + final back = AppSettings.fromJson(s.toJson()); + expect(back.components, ['mkv', 'explorer', 'downloads']); + expect(back.navOrder, ['explorer', 'mkv']); + // 예전 설정 (컴포넌트 없음) 은 기본 + expect(AppSettings.fromJson({}).components, AppComponent.defaultInstalled); + }); + + testWidgets('위쪽 이동 버튼: 설치한 화면만 · 설정을 바꾸면 바로 · 하나뿐이면 그것만', (tester) async { + tester.view.physicalSize = const Size(1500, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final c = _plain(); + await tester.pumpWidget(MaterialApp( + builder: (context, child) => AppScope(controller: c, child: child!), + home: HomePage(c: c), + )); + expect(find.byTooltip('MKV 화면 (지금 여기)'), findsOneWidget); + expect(find.byTooltip('파일 탐색기'), findsOneWidget); + expect(find.byTooltip('Rsync'), findsOneWidget); + await c.updateSettings((s) => s.components = ['mkv', 'explorer']); + await tester.pump(); + expect(find.byTooltip('Rsync'), findsNothing); + expect(find.byTooltip('파일 탐색기'), findsOneWidget); + await c.updateSettings((s) => s.components = ['mkv']); + await tester.pump(); + expect(find.byTooltip('파일 탐색기'), findsNothing); + expect(find.byTooltip('MKV 화면 (지금 여기)'), findsOneWidget); + }); + + testWidgets('화면 가운데를 좌우로 밀면 순서대로 다음 · 이전 화면 (끝 다음은 처음)', (tester) async { + tester.view.physicalSize = const Size(1500, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final c = _plain(); + c.settings + ..components = ['mkv', 'explorer', 'rsync'] + ..navOrder = ['mkv', 'explorer', 'rsync']; + await tester.pumpWidget(MaterialApp( + builder: (context, child) => AppScope(controller: c, child: child!), + home: HomePage(c: c), + )); + Future swipe(double dx) async { + await tester.flingFrom(const Offset(750, 450), Offset(dx, 0), 2000); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + } + + await swipe(-600); // 왼쪽으로 → 다음 (파일 탐색기) + expect(find.byTooltip('파일 탐색기 (지금 여기)'), findsOneWidget); + await swipe(-600); + expect(find.byTooltip('Rsync (지금 여기)'), findsOneWidget); + await swipe(-600); // 마지막 다음은 처음 + expect(find.byTooltip('MKV 화면 (지금 여기)'), findsOneWidget); + await swipe(600); // 오른쪽으로 → 이전 (끝으로) + expect(find.byTooltip('Rsync (지금 여기)'), findsOneWidget); + // 끄면 밀어도 그대로 + await c.updateSettings((s) => s.swipeNav = false); + await tester.pump(); + await swipe(600); + expect(find.byTooltip('Rsync (지금 여기)'), findsOneWidget); + }); +} diff --git a/test/queue_test.dart b/test/queue_test.dart index 66524d6..0871c61 100644 --- a/test/queue_test.dart +++ b/test/queue_test.dart @@ -215,8 +215,9 @@ void main() { for (final g in groups) { await tester.tap(find.widgetWithText(ActionChip, g)); await tester.pumpAndSettle(); - // 묶음 제목 (버튼이 아닌 카드 안의 글) 이 화면 위쪽에 보인다 - final title = find.descendant(of: find.byType(Card), matching: find.text(g)).first; + // 묶음 제목 (버튼이 아닌 카드 머리의 글) 이 화면 위쪽에 보인다 (컴포넌트 묶음의 같은 이름 줄과 헷갈리지 않게 머리 글만) + final title = find.byWidgetPredicate((w) => + w is Text && w.data == g && w.key is ValueKey && (w.key as ValueKey).value.startsWith('group-title:')); final y = tester.getTopLeft(title).dy; expect(y, inInclusiveRange(0, 400), reason: '$g 묶음 제목 위치 $y'); }