WIP X-plore 비교 5-1 ⑦ 밝은 테마 바탕: 색이 테마를 따름 · 설정 "화면 밝기" (시험) (검토 대기)

관리자 지시 10/21 (⑦ (가) 바탕): 처음 값은 어둡게 (예전 색 그대로), 설정 "화면 밝기: 어두운 화면 / 밝은 화면 / 기기 따라" 에 "시험" 표시.
- JjColors 를 두 벌 (JjPalette.dark = 예전 값 그대로 · light) 에서 고르는 값으로. 그래서 JjColors 를 쓰던 const 는 뺐다 (기계적으로, 62개 파일 294곳)
- 밝기를 바꾸면 테마와 함께 화면 조각 전체를 다시 그림 (상태는 그대로) - JjColors 를 직접 읽는 곳도 바로 새 색
- 기기 따라: 기기 밝기가 바뀌면 따라감. 모르는 값 · 없는 값은 어둡게
- 밝은 색은 바탕 · 칸 위 글 · 흐린 글 · 강조 · 경고 · 성공 대비 4.5:1 이상 (시험으로 확인)
- 화면별 맞추기 ((나): 탐색기 → 보기 → 설정 → 나머지) 는 다음 커밋들에서

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
xplore_p3b
RyuJongJin 2026-10-21 14:08:47 +09:00
parent ab735396ac
commit 9324e4100f
69 changed files with 623 additions and 325 deletions

View File

@ -861,6 +861,7 @@
"기계 번역": "Machine translation", "기계 번역": "Machine translation",
"기기": "device", "기기": "device",
"기기 DNS": "Device DNS", "기기 DNS": "Device DNS",
"기기 따라": "Follow device",
"기기 안에서 그리기 (stable-diffusion.cpp · SD1.5 + LCM)": "Draw on this device (stable-diffusion.cpp · SD1.5 + LCM)", "기기 안에서 그리기 (stable-diffusion.cpp · SD1.5 + LCM)": "Draw on this device (stable-diffusion.cpp · SD1.5 + LCM)",
"기기 안에서 그리려면 모델을 받아야 합니다 (SD1.5 + LCM, 약 4.4GB).": "To draw on this device, download the model first (SD1.5 + LCM, about 4.4 GB).", "기기 안에서 그리려면 모델을 받아야 합니다 (SD1.5 + LCM, 약 4.4GB).": "To draw on this device, download the model first (SD1.5 + LCM, about 4.4 GB).",
"기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "To draw on this device, download the model first ({0})", "기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "To draw on this device, download the model first ({0})",
@ -1453,7 +1454,9 @@
"받지 않음": "Off", "받지 않음": "Off",
"받지 않음 (처음 쓸 때 내려받기 {0})": "Not downloaded (downloads on first use, {0})", "받지 않음 (처음 쓸 때 내려받기 {0})": "Not downloaded (downloads on first use, {0})",
"밝게": "Brighter", "밝게": "Brighter",
"밝게 · 기기 따라는 아직 시험 중입니다 - 일부 화면의 색이 덜 맞을 수 있습니다": "Light and Follow device are still in testing - colors on some screens may not fit yet",
"밝기": "Brightness", "밝기": "Brightness",
"밝은 화면": "Light",
"밤 0~6시": "Night 0-6", "밤 0~6시": "Night 0-6",
"방법: {0}": "Method: {0}", "방법: {0}": "Method: {0}",
"방식": "Type", "방식": "Type",
@ -1826,6 +1829,7 @@
"시작할 때 새 버전 확인 (하루 한 번)": "Check for a new version at startup (once a day)", "시작할 때 새 버전 확인 (하루 한 번)": "Check for a new version at startup (once a day)",
"시작할 때마다 설정 창 보기": "Show the setup window at every start", "시작할 때마다 설정 창 보기": "Show the setup window at every start",
"시즌": "Season", "시즌": "Season",
"시험": "Beta",
"시험판": "Pre-release", "시험판": "Pre-release",
"실시간 동기화 (lsyncd 처럼)": "Live sync (like lsyncd)", "실시간 동기화 (lsyncd 처럼)": "Live sync (like lsyncd)",
"실시간 동기화 ({0}): {1} → {2}": "Live sync ({0}): {1} → {2}", "실시간 동기화 ({0}): {1} → {2}": "Live sync ({0}): {1} → {2}",
@ -1955,6 +1959,7 @@
"약하게 (0.15)": "Light (0.15)", "약하게 (0.15)": "Light (0.15)",
"양쪽 (⇄) 에서는 원본 파일을 지우지 않습니다 (옵션의 --remove-source-files 는 쓰지 않음).": "Both ways (⇄) never deletes source files (--remove-source-files in the options is not used).", "양쪽 (⇄) 에서는 원본 파일을 지우지 않습니다 (옵션의 --remove-source-files 는 쓰지 않음).": "Both ways (⇄) never deletes source files (--remove-source-files in the options is not used).",
"양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).": "Both ways: newer files on the receiving side are not overwritten (-u).", "양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).": "Both ways: newer files on the receiving side are not overwritten (-u).",
"어두운 화면": "Dark",
"어둡게": "Darker", "어둡게": "Darker",
"어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "Press anywhere to bring up the JJ_MKVMaker window", "어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "Press anywhere to bring up the JJ_MKVMaker window",
"언어": "Language", "언어": "Language",
@ -3036,6 +3041,7 @@
"화면 가운데를 좌우로 밀면 다른 화면 (파일 탐색기 · 다운로드 등) 으로 넘어갑니다. 끄기: 환경 설정 > 컴포넌트 > 좌우로 밀어 화면 이동": "Swiping left or right in the middle of the screen now switches to other screens (file explorer, downloads, etc.). To turn off: Settings > Components > Swipe left/right to switch screens", "화면 가운데를 좌우로 밀면 다른 화면 (파일 탐색기 · 다운로드 등) 으로 넘어갑니다. 끄기: 환경 설정 > 컴포넌트 > 좌우로 밀어 화면 이동": "Swiping left or right in the middle of the screen now switches to other screens (file explorer, downloads, etc.). To turn off: Settings > Components > Swipe left/right to switch screens",
"화면 글자 전체를 이 기기에서 번역합니다. 몇 분 걸릴 수 있습니다.": "Translates all of the program's text on this device. It may take a few minutes.", "화면 글자 전체를 이 기기에서 번역합니다. 몇 분 걸릴 수 있습니다.": "Translates all of the program's text on this device. It may take a few minutes.",
"화면 모양 따라": "Follow screen shape", "화면 모양 따라": "Follow screen shape",
"화면 밝기": "Screen brightness",
"화면 방향": "Screen orientation", "화면 방향": "Screen orientation",
"화면 방향: 가로 고정 → 자동 (기기를 돌리는 대로). 되돌리기: 환경 설정 > 화면 > 화면 방향": "Screen orientation: landscape only → auto (follows the device). To revert: Settings > Display > Screen orientation", "화면 방향: 가로 고정 → 자동 (기기를 돌리는 대로). 되돌리기: 환경 설정 > 화면 > 화면 방향": "Screen orientation: landscape only → auto (follows the device). To revert: Settings > Display > Screen orientation",
"화면 비율": "Aspect ratio", "화면 비율": "Aspect ratio",

View File

@ -861,6 +861,7 @@
"기계 번역": "機械翻訳", "기계 번역": "機械翻訳",
"기기": "端末", "기기": "端末",
"기기 DNS": "端末の DNS", "기기 DNS": "端末の DNS",
"기기 따라": "端末に合わせる",
"기기 안에서 그리기 (stable-diffusion.cpp · SD1.5 + LCM)": "この端末で描く (stable-diffusion.cpp・SD1.5 + LCM)", "기기 안에서 그리기 (stable-diffusion.cpp · SD1.5 + LCM)": "この端末で描く (stable-diffusion.cpp・SD1.5 + LCM)",
"기기 안에서 그리려면 모델을 받아야 합니다 (SD1.5 + LCM, 약 4.4GB).": "この端末で描くにはモデルのダウンロードが必要です (SD1.5 + LCM、約 4.4GB)。", "기기 안에서 그리려면 모델을 받아야 합니다 (SD1.5 + LCM, 약 4.4GB).": "この端末で描くにはモデルのダウンロードが必要です (SD1.5 + LCM、約 4.4GB)。",
"기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "この端末で描くにはモデルのダウンロードが必要です ({0})", "기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "この端末で描くにはモデルのダウンロードが必要です ({0})",
@ -1453,7 +1454,9 @@
"받지 않음": "取得しない", "받지 않음": "取得しない",
"받지 않음 (처음 쓸 때 내려받기 {0})": "未ダウンロード (初回使用時にダウンロード {0})", "받지 않음 (처음 쓸 때 내려받기 {0})": "未ダウンロード (初回使用時にダウンロード {0})",
"밝게": "明るく", "밝게": "明るく",
"밝게 · 기기 따라는 아직 시험 중입니다 - 일부 화면의 색이 덜 맞을 수 있습니다": "明るい・端末に合わせるはまだ試験中です - 一部の画面の色が合わない場合があります",
"밝기": "明るさ", "밝기": "明るさ",
"밝은 화면": "明るい",
"밤 0~6시": "夜 0~6 時", "밤 0~6시": "夜 0~6 時",
"방법: {0}": "方法: {0}", "방법: {0}": "方法: {0}",
"방식": "方式", "방식": "方式",
@ -1826,6 +1829,7 @@
"시작할 때 새 버전 확인 (하루 한 번)": "起動時に新しいバージョンを確認(1日1回)", "시작할 때 새 버전 확인 (하루 한 번)": "起動時に新しいバージョンを確認(1日1回)",
"시작할 때마다 설정 창 보기": "起動するたびに設定ウィンドウを表示", "시작할 때마다 설정 창 보기": "起動するたびに設定ウィンドウを表示",
"시즌": "シーズン", "시즌": "シーズン",
"시험": "試験中",
"시험판": "プレリリース", "시험판": "プレリリース",
"실시간 동기화 (lsyncd 처럼)": "リアルタイム同期 (lsyncd のように)", "실시간 동기화 (lsyncd 처럼)": "リアルタイム同期 (lsyncd のように)",
"실시간 동기화 ({0}): {1} → {2}": "リアルタイム同期 ({0}): {1} → {2}", "실시간 동기화 ({0}): {1} → {2}": "リアルタイム同期 ({0}): {1} → {2}",
@ -1955,6 +1959,7 @@
"약하게 (0.15)": "弱め (0.15)", "약하게 (0.15)": "弱め (0.15)",
"양쪽 (⇄) 에서는 원본 파일을 지우지 않습니다 (옵션의 --remove-source-files 는 쓰지 않음).": "双方向 (⇄) では元のファイルを削除しません (オプションの --remove-source-files は使いません)。", "양쪽 (⇄) 에서는 원본 파일을 지우지 않습니다 (옵션의 --remove-source-files 는 쓰지 않음).": "双方向 (⇄) では元のファイルを削除しません (オプションの --remove-source-files は使いません)。",
"양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).": "双方向: 受け取る側の方が新しいファイルは上書きしません (-u)。", "양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).": "双方向: 受け取る側の方が新しいファイルは上書きしません (-u)。",
"어두운 화면": "暗い",
"어둡게": "暗く", "어둡게": "暗く",
"어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "どこからでも押すと JJ_MKVMaker ウィンドウが表示されます", "어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "どこからでも押すと JJ_MKVMaker ウィンドウが表示されます",
"언어": "言語", "언어": "言語",
@ -3036,6 +3041,7 @@
"화면 가운데를 좌우로 밀면 다른 화면 (파일 탐색기 · 다운로드 등) 으로 넘어갑니다. 끄기: 환경 설정 > 컴포넌트 > 좌우로 밀어 화면 이동": "画面の中央を左右にスワイプすると別の画面 (ファイルエクスプローラー · ダウンロードなど) に切り替わります。オフにする: 環境設定 > コンポーネント > 左右スワイプで画面を移動", "화면 가운데를 좌우로 밀면 다른 화면 (파일 탐색기 · 다운로드 등) 으로 넘어갑니다. 끄기: 환경 설정 > 컴포넌트 > 좌우로 밀어 화면 이동": "画面の中央を左右にスワイプすると別の画面 (ファイルエクスプローラー · ダウンロードなど) に切り替わります。オフにする: 環境設定 > コンポーネント > 左右スワイプで画面を移動",
"화면 글자 전체를 이 기기에서 번역합니다. 몇 분 걸릴 수 있습니다.": "画面の文字をすべてこの端末で翻訳します。数分かかることがあります。", "화면 글자 전체를 이 기기에서 번역합니다. 몇 분 걸릴 수 있습니다.": "画面の文字をすべてこの端末で翻訳します。数分かかることがあります。",
"화면 모양 따라": "画面の形に合わせる", "화면 모양 따라": "画面の形に合わせる",
"화면 밝기": "画面の明るさ",
"화면 방향": "画面の向き", "화면 방향": "画面の向き",
"화면 방향: 가로 고정 → 자동 (기기를 돌리는 대로). 되돌리기: 환경 설정 > 화면 > 화면 방향": "画面の向き: 横向き固定 → 自動 (端末の向きに合わせる)。元に戻す: 環境設定 > 画面 > 画面の向き", "화면 방향: 가로 고정 → 자동 (기기를 돌리는 대로). 되돌리기: 환경 설정 > 화면 > 화면 방향": "画面の向き: 横向き固定 → 自動 (端末の向きに合わせる)。元に戻す: 環境設定 > 画面 > 画面の向き",
"화면 비율": "画面比率", "화면 비율": "画面比率",

View File

@ -861,6 +861,7 @@
"기계 번역": "机器翻译", "기계 번역": "机器翻译",
"기기": "设备", "기기": "设备",
"기기 DNS": "设备 DNS", "기기 DNS": "设备 DNS",
"기기 따라": "跟随设备",
"기기 안에서 그리기 (stable-diffusion.cpp · SD1.5 + LCM)": "在本设备上绘制 (stable-diffusion.cpp · SD1.5 + LCM)", "기기 안에서 그리기 (stable-diffusion.cpp · SD1.5 + LCM)": "在本设备上绘制 (stable-diffusion.cpp · SD1.5 + LCM)",
"기기 안에서 그리려면 모델을 받아야 합니다 (SD1.5 + LCM, 약 4.4GB).": "要在本设备上绘制,需要先下载模型 (SD1.5 + LCM,约 4.4GB)。", "기기 안에서 그리려면 모델을 받아야 합니다 (SD1.5 + LCM, 약 4.4GB).": "要在本设备上绘制,需要先下载模型 (SD1.5 + LCM,约 4.4GB)。",
"기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "要在本设备上绘制,需要先下载模型 ({0})", "기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "要在本设备上绘制,需要先下载模型 ({0})",
@ -1453,7 +1454,9 @@
"받지 않음": "不获取", "받지 않음": "不获取",
"받지 않음 (처음 쓸 때 내려받기 {0})": "未下载 (首次使用时下载 {0})", "받지 않음 (처음 쓸 때 내려받기 {0})": "未下载 (首次使用时下载 {0})",
"밝게": "调亮", "밝게": "调亮",
"밝게 · 기기 따라는 아직 시험 중입니다 - 일부 화면의 색이 덜 맞을 수 있습니다": "浅色和跟随设备仍在测试中 - 部分界面的颜色可能还不协调",
"밝기": "亮度", "밝기": "亮度",
"밝은 화면": "浅色",
"밤 0~6시": "夜间 0~6 点", "밤 0~6시": "夜间 0~6 点",
"방법: {0}": "方式: {0}", "방법: {0}": "方式: {0}",
"방식": "方式", "방식": "方式",
@ -1826,6 +1829,7 @@
"시작할 때 새 버전 확인 (하루 한 번)": "启动时检查新版本(每天一次)", "시작할 때 새 버전 확인 (하루 한 번)": "启动时检查新版本(每天一次)",
"시작할 때마다 설정 창 보기": "每次启动时显示设置窗口", "시작할 때마다 설정 창 보기": "每次启动时显示设置窗口",
"시즌": "季", "시즌": "季",
"시험": "测试",
"시험판": "预发布", "시험판": "预发布",
"실시간 동기화 (lsyncd 처럼)": "实时同步 (类似 lsyncd)", "실시간 동기화 (lsyncd 처럼)": "实时同步 (类似 lsyncd)",
"실시간 동기화 ({0}): {1} → {2}": "实时同步 ({0}): {1} → {2}", "실시간 동기화 ({0}): {1} → {2}": "实时同步 ({0}): {1} → {2}",
@ -1955,6 +1959,7 @@
"약하게 (0.15)": "弱 (0.15)", "약하게 (0.15)": "弱 (0.15)",
"양쪽 (⇄) 에서는 원본 파일을 지우지 않습니다 (옵션의 --remove-source-files 는 쓰지 않음).": "双向 (⇄) 不会删除源文件(不使用选项中的 --remove-source-files)。", "양쪽 (⇄) 에서는 원본 파일을 지우지 않습니다 (옵션의 --remove-source-files 는 쓰지 않음).": "双向 (⇄) 不会删除源文件(不使用选项中的 --remove-source-files)。",
"양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).": "双向: 接收端较新的文件不会被覆盖 (-u)。", "양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).": "双向: 接收端较新的文件不会被覆盖 (-u)。",
"어두운 화면": "深色",
"어둡게": "调暗", "어둡게": "调暗",
"어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "在任何地方按下即可显示 JJ_MKVMaker 窗口", "어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "在任何地方按下即可显示 JJ_MKVMaker 窗口",
"언어": "语言", "언어": "语言",
@ -3036,6 +3041,7 @@
"화면 가운데를 좌우로 밀면 다른 화면 (파일 탐색기 · 다운로드 등) 으로 넘어갑니다. 끄기: 환경 설정 > 컴포넌트 > 좌우로 밀어 화면 이동": "在屏幕中间左右滑动会切换到其他屏幕(文件浏览器 · 下载等)。关闭:设置 > 组件 > 左右滑动切换界面", "화면 가운데를 좌우로 밀면 다른 화면 (파일 탐색기 · 다운로드 등) 으로 넘어갑니다. 끄기: 환경 설정 > 컴포넌트 > 좌우로 밀어 화면 이동": "在屏幕中间左右滑动会切换到其他屏幕(文件浏览器 · 下载等)。关闭:设置 > 组件 > 左右滑动切换界面",
"화면 글자 전체를 이 기기에서 번역합니다. 몇 분 걸릴 수 있습니다.": "将在此设备上翻译所有界面文字。可能需要几分钟。", "화면 글자 전체를 이 기기에서 번역합니다. 몇 분 걸릴 수 있습니다.": "将在此设备上翻译所有界面文字。可能需要几分钟。",
"화면 모양 따라": "跟随屏幕形状", "화면 모양 따라": "跟随屏幕形状",
"화면 밝기": "界面亮度",
"화면 방향": "屏幕方向", "화면 방향": "屏幕方向",
"화면 방향: 가로 고정 → 자동 (기기를 돌리는 대로). 되돌리기: 환경 설정 > 화면 > 화면 방향": "屏幕方向: 固定横屏 → 自动 (随设备旋转)。恢复: 设置 > 屏幕 > 屏幕方向", "화면 방향: 가로 고정 → 자동 (기기를 돌리는 대로). 되돌리기: 환경 설정 > 화면 > 화면 방향": "屏幕方向: 固定横屏 → 自动 (随设备旋转)。恢复: 设置 > 屏幕 > 屏幕方向",
"화면 비율": "画面比例", "화면 비율": "画面比例",

View File

@ -342,6 +342,11 @@ class AppSettings {
double uiScaleDefault = 1.0; double uiScaleDefault = 1.0;
// 18: 50 ~ 250% (사용자 결정) // 18: 50 ~ 250% (사용자 결정)
static const double uiScaleMin = 0.5, uiScaleMax = 2.5; static const double uiScaleMin = 0.5, uiScaleMax = 2.5;
/// X-plore 비교 5-1 ⑦ 화면 밝기 (관리자 지시 10/21): 'dark' 어둡게 (처음 값 - 예전 그대로) · 'light' 밝게 · 'system' 기기 따라.
/// 다 끝날 때까지 설정에 "시험" 표시. 모르는 값은 어둡게
String uiBrightness = 'dark';
static const uiBrightnessModes = ['dark', 'light', 'system'];
static double clampUiScale(num? v) => static double clampUiScale(num? v) =>
((v ?? 1.0).toDouble().clamp(uiScaleMin, uiScaleMax) * 20).round() / 20; ((v ?? 1.0).toDouble().clamp(uiScaleMin, uiScaleMax) * 20).round() / 20;
@ -846,6 +851,7 @@ class AppSettings {
'startScreen': startScreen, 'startScreen': startScreen,
'uiScale': uiScale, 'uiScale': uiScale,
'uiScaleDefault': uiScaleDefault, 'uiScaleDefault': uiScaleDefault,
'uiBrightness': uiBrightness,
'openFileAction': openFileAction, 'openFileAction': openFileAction,
'screenOrientation': screenOrientation, 'screenOrientation': screenOrientation,
// 기본이 가로 고정이던 때의 값은 한 번 자동으로 옮긴다 (사용자가 정한 적 없는 가로 고정) // 기본이 가로 고정이던 때의 값은 한 번 자동으로 옮긴다 (사용자가 정한 적 없는 가로 고정)
@ -1075,6 +1081,7 @@ class AppSettings {
..startScreen = j['startScreen'] as String? ?? 'home' ..startScreen = j['startScreen'] as String? ?? 'home'
..uiScale = clampUiScale(j['uiScale'] as num?) ..uiScale = clampUiScale(j['uiScale'] as num?)
..uiScaleDefault = clampUiScale(j['uiScaleDefault'] as num?) ..uiScaleDefault = clampUiScale(j['uiScaleDefault'] as num?)
..uiBrightness = uiBrightnessModes.contains(j['uiBrightness']) ? j['uiBrightness'] as String : 'dark'
..openFileAction = j['openFileAction'] == 'add' ? 'add' : 'play' ..openFileAction = j['openFileAction'] == 'add' ? 'add' : 'play'
..uiLanguage = j['uiLanguage'] as String? ?? 'ko' ..uiLanguage = j['uiLanguage'] as String? ?? 'ko'
..appIcon = appIconOf(j['appIcon'] as String?) ..appIcon = appIconOf(j['appIcon'] as String?)

View File

@ -834,10 +834,10 @@ class JjMkvMakerApp extends StatelessWidget {
]; ];
@override @override
Widget build(BuildContext context) => ListenableBuilder( Widget build(BuildContext context) => _BrightnessHost(
c: controller,
// 화면 언어를 바꾸면 (i18n) Flutter 기본 글도 그 언어로 // 화면 언어를 바꾸면 (i18n) Flutter 기본 글도 그 언어로
listenable: i18n, builder: (context) => ListenableBuilder(listenable: i18n, builder: (context, _) => _app(context)),
builder: (context, _) => _app(context),
); );
Widget _app(BuildContext context) { Widget _app(BuildContext context) {
@ -849,6 +849,8 @@ class JjMkvMakerApp extends StatelessWidget {
title: appTitle, title: appTitle,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: buildTheme(), theme: buildTheme(),
// 밝기 바꿈은 화면 전체를 다시 그려 바로 (전환 그림 없이 - JjColors 를 직접 읽는 곳과 같은 때 바뀌게)
themeAnimationDuration: Duration.zero,
navigatorKey: navigatorKey, navigatorKey: navigatorKey,
scaffoldMessengerKey: messengerKey, scaffoldMessengerKey: messengerKey,
navigatorObservers: [browserRouteObserver], navigatorObservers: [browserRouteObserver],
@ -864,6 +866,51 @@ class JjMkvMakerApp extends StatelessWidget {
} }
} }
/// X-plore 비교 5-1 ⑦: 환경 설정 "화면 밝기" · 기기 밝기를 따라 [JjColors] 를 바꾸고 화면 전체를 다시 그린다 (상태는 그대로)
class _BrightnessHost extends StatefulWidget {
final AppController c;
final WidgetBuilder builder;
const _BrightnessHost({required this.c, required this.builder});
@override
State<_BrightnessHost> createState() => _BrightnessHostState();
}
class _BrightnessHostState extends State<_BrightnessHost> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
widget.c.addListener(_check);
JjColors.use(_want());
}
@override
void dispose() {
widget.c.removeListener(_check);
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
Brightness _want() =>
resolveBrightness(widget.c.settings.uiBrightness, WidgetsBinding.instance.platformDispatcher.platformBrightness);
@override
void didChangePlatformBrightness() => _check();
void _check() {
if (!mounted || !JjColors.use(_want())) return;
setState(() {});
// JjColors 를 직접 읽는 화면 조각도 새 색으로 (그 조각들은 Theme 이 바뀌어도 다시 그려지지 않는다)
WidgetsBinding.instance.addPostFrameCallback((_) {
final root = WidgetsBinding.instance.rootElement;
if (root != null) rebuildAllElements(root);
});
}
@override
Widget build(BuildContext context) => widget.builder(context);
}
/// Cupertino 글이 없는 언어만 영어로 (GlobalCupertinoLocalizations 뒤에 두어 그쪽이 아는 언어는 그쪽이 먼저) /// Cupertino 글이 없는 언어만 영어로 (GlobalCupertinoLocalizations 뒤에 두어 그쪽이 아는 언어는 그쪽이 먼저)
class _CupertinoFallbackDelegate extends LocalizationsDelegate<CupertinoLocalizations> { class _CupertinoFallbackDelegate extends LocalizationsDelegate<CupertinoLocalizations> {
const _CupertinoFallbackDelegate(); const _CupertinoFallbackDelegate();

View File

@ -94,7 +94,7 @@ Future<Language?> pickSourceLanguage(BuildContext context, Language detected) =>
Padding( Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 8), padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
child: Text(tr('처음부터 정하려면 AI 자막 창의 "원어 (영상 속 언어)" 에서 고르세요.'), child: Text(tr('처음부터 정하려면 AI 자막 창의 "원어 (영상 속 언어)" 에서 고르세요.'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
), ),
for (final l in languages) for (final l in languages)
SimpleDialogOption( SimpleDialogOption(
@ -172,7 +172,7 @@ class AiPlanSummary extends StatelessWidget {
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 2), padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
SizedBox(width: 84, child: Text(k, style: const TextStyle(fontSize: 12, color: JjColors.textDim))), SizedBox(width: 84, child: Text(k, style: TextStyle(fontSize: 12, color: JjColors.textDim))),
Expanded(child: Text(v, style: const TextStyle(fontSize: 13))), Expanded(child: Text(v, style: const TextStyle(fontSize: 13))),
]), ]),
), ),
@ -221,7 +221,7 @@ class _AiDialogState extends State<_AiDialog> {
/// 259: 번역이 없으면 받지 않는다 · 감지한 뒤에야 알 수 있으면 그때 받는다 /// 259: 번역이 없으면 받지 않는다 · 감지한 뒤에야 알 수 있으면 그때 받는다
Widget _nllbStatus() { Widget _nllbStatus() {
if (_installed[nllbModel.id] != false || _o.surelyNeedsTranslation) return _status(nllbModel); if (_installed[nllbModel.id] != false || _o.surelyNeedsTranslation) return _status(nllbModel);
const style = TextStyle(fontSize: 11, color: JjColors.textDim); final style = TextStyle(fontSize: 11, color: JjColors.textDim);
if (!_o.needsTranslation) return Text(tr('이번에는 쓰지 않음 (번역 없음)'), style: style); if (!_o.needsTranslation) return Text(tr('이번에는 쓰지 않음 (번역 없음)'), style: style);
return Text(trf('감지한 원어가 만들 언어와 다를 때만 내려받기 {0}', [nllbModel.sizeLabel]), style: style); return Text(trf('감지한 원어가 만들 언어와 다를 때만 내려받기 {0}', [nllbModel.sizeLabel]), style: style);
} }
@ -239,7 +239,7 @@ class _AiDialogState extends State<_AiDialog> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text(trf('영상의 음성을 인식하고 선택한 언어로 번역합니다. 모든 처리는 이 {0}에서 이루어집니다.', [Platform.isAndroid ? tr('기기') : 'PC']), Text(trf('영상의 음성을 인식하고 선택한 언어로 번역합니다. 모든 처리는 이 {0}에서 이루어집니다.', [Platform.isAndroid ? tr('기기') : 'PC']),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
const SizedBox(height: 10), const SizedBox(height: 10),
// 138: 무엇을 할지 먼저 (바꾸면 따라 바뀐다) // 138: 무엇을 할지 먼저 (바꾸면 따라 바뀐다)
AiPlanSummary(videos: _all ? widget.c.videos : widget.videos, o: _o, thenBuild: widget.thenBuild), AiPlanSummary(videos: _all ? widget.c.videos : widget.videos, o: _o, thenBuild: widget.thenBuild),

View File

@ -293,7 +293,7 @@ class _AiImagePageState extends State<AiImagePage> with RouteAware {
onPressed: () => setState(() => _showDetail = !_showDetail), onPressed: () => setState(() => _showDetail = !_showDetail),
child: Text(_showDetail ? tr('자세히 닫기') : tr('자세히')), child: Text(_showDetail ? tr('자세히 닫기') : tr('자세히')),
), ),
if (_showDetail) SelectableText(_detail, style: const TextStyle(fontSize: 11, color: JjColors.textDim)), if (_showDetail) SelectableText(_detail, style: TextStyle(fontSize: 11, color: JjColors.textDim)),
]), ]),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -374,7 +374,7 @@ class _AiImagePageState extends State<AiImagePage> with RouteAware {
alignment: Alignment.center, alignment: Alignment.center,
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: Text(tr('파일이 없습니다 (지워졌거나 옮겨짐)'), child: Text(tr('파일이 없습니다 (지워졌거나 옮겨짐)'),
textAlign: TextAlign.center, style: const TextStyle(fontSize: 12, color: JjColors.textDim)), textAlign: TextAlign.center, style: TextStyle(fontSize: 12, color: JjColors.textDim)),
), ),
TextButton( TextButton(
onPressed: () { onPressed: () {
@ -393,7 +393,7 @@ class _AiImagePageState extends State<AiImagePage> with RouteAware {
child: Image.file(File(g.path), width: 160, height: 160, fit: BoxFit.cover), child: Image.file(File(g.path), width: 160, height: 160, fit: BoxFit.cover),
), ),
Row(mainAxisSize: MainAxisSize.min, children: [ Row(mainAxisSize: MainAxisSize.min, children: [
Text(trf('시드 {0}', [g.seed]), style: const TextStyle(fontSize: 11, color: JjColors.textDim)), Text(trf('시드 {0}', [g.seed]), style: TextStyle(fontSize: 11, color: JjColors.textDim)),
IconButton( IconButton(
tooltip: tr('같은 조건으로 다시 (시드 그대로)'), tooltip: tr('같은 조건으로 다시 (시드 그대로)'),
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,

View File

@ -111,7 +111,7 @@ class _AiSettingsState extends State<AiSettings> {
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(tr(f.terms!)), Text(tr(f.terms!)),
const SizedBox(height: 8), const SizedBox(height: 8),
SelectableText(f.licenseUrl, style: const TextStyle(fontSize: 12, color: JjColors.textDim)), SelectableText(f.licenseUrl, style: TextStyle(fontSize: 12, color: JjColors.textDim)),
if (f.licenseAsset != null) if (f.licenseAsset != null)
TextButton( TextButton(
onPressed: () => showLicenseDocument(ctx, trf('{0} 라이선스', [tr(f.license)]), f.licenseAsset!), onPressed: () => showLicenseDocument(ctx, trf('{0} 라이선스', [tr(f.license)]), f.licenseAsset!),

View File

@ -84,7 +84,7 @@ class _AiSubtitleModelsState extends State<AiSubtitleModels> {
if (_error != null) if (_error != null)
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0), padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
child: Text(_error!, style: const TextStyle(fontSize: 12, color: JjColors.danger)), child: Text(_error!, style: TextStyle(fontSize: 12, color: JjColors.danger)),
), ),
]); ]);
}, },

View File

@ -122,7 +122,7 @@ Future<bool> _download(BuildContext context, AiStore store, List<AiFile> files)
for (final f in files) ...[ for (final f in files) ...[
Text(f.name, style: const TextStyle(fontSize: 13)), Text(f.name, style: const TextStyle(fontSize: 13)),
Text(store.isFileInstalled(f) ? tr('받음') : store.progressText(f.id) ?? tr('준비하는 중'), Text(store.isFileInstalled(f) ? tr('받음') : store.progressText(f.id) ?? tr('준비하는 중'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
const SizedBox(height: 8), const SizedBox(height: 8),
], ],
]), ]),
@ -225,11 +225,11 @@ Future<String?> upscaleOne(BuildContext context, AppController c, {required Stri
: '${(v * 100).round()}% · ${trf('약 {0} 남음', [AiJobs.durationText(d)])}', : '${(v * 100).round()}% · ${trf('약 {0} 남음', [AiJobs.durationText(d)])}',
style: const TextStyle(fontSize: 12)), style: const TextStyle(fontSize: 12)),
), ),
if (estimate != null) Text(estimate, style: const TextStyle(fontSize: 12, color: JjColors.textDim)), if (estimate != null) Text(estimate, style: TextStyle(fontSize: 12, color: JjColors.textDim)),
if (cpuAnime) ...[ if (cpuAnime) ...[
const SizedBox(height: 10), const SizedBox(height: 10),
Text(tr("사진에는 '사진' 모델이 더 낫지만, 이 기기 (그래픽카드 없음) 에서는 한 장에 약 20분 걸립니다."), Text(tr("사진에는 '사진' 모델이 더 낫지만, 이 기기 (그래픽카드 없음) 에서는 한 장에 약 20분 걸립니다."),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
TextButton( TextButton(
onPressed: () { onPressed: () {
toPhoto = true; toPhoto = true;

View File

@ -256,7 +256,7 @@ class _FileBrowserState extends State<_FileBrowser> {
dir == null || vol == null dir == null || vol == null
? '' ? ''
: (atRoot ? _volumeName(vol) : '${_volumeName(vol)} / ${p.relative(dir, from: root)}'), : (atRoot ? _volumeName(vol) : '${_volumeName(vol)} / ${p.relative(dir, from: root)}'),
style: const TextStyle(color: JjColors.textDim), style: TextStyle(color: JjColors.textDim),
), ),
), ),
const Divider(height: 1), const Divider(height: 1),
@ -264,7 +264,7 @@ class _FileBrowserState extends State<_FileBrowser> {
child: _dirs.isEmpty && _files.isEmpty child: _dirs.isEmpty && _files.isEmpty
? Center( ? Center(
child: Text(widget.folder ? tr('하위 폴더가 없습니다') : tr('이 폴더에는 고를 파일이 없습니다'), child: Text(widget.folder ? tr('하위 폴더가 없습니다') : tr('이 폴더에는 고를 파일이 없습니다'),
style: const TextStyle(color: JjColors.textDim))) style: TextStyle(color: JjColors.textDim)))
: ListView(children: [ : ListView(children: [
for (final d in _dirs) for (final d in _dirs)
ListTile( ListTile(
@ -312,7 +312,7 @@ class _FileBrowserState extends State<_FileBrowser> {
tr('지금은 듀얼 앱 (복제한 앱) 입니다. Android 가 듀얼 앱에는 이 권한을 주지 않을 수 있습니다. ' tr('지금은 듀얼 앱 (복제한 앱) 입니다. Android 가 듀얼 앱에는 이 권한을 주지 않을 수 있습니다. '
'허용 화면에서 켤 수 없으면 배지 없는 원래 JJ_MKVMaker 아이콘으로 여세요.'), '허용 화면에서 켤 수 없으면 배지 없는 원래 JJ_MKVMaker 아이콘으로 여세요.'),
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle(color: JjColors.textDim), style: TextStyle(color: JjColors.textDim),
), ),
], ],
const SizedBox(height: 16), const SizedBox(height: 16),

View File

@ -490,7 +490,7 @@ class UsageView extends StatelessWidget {
width: width, width: width,
child: Text.rich( child: Text.rich(
TextSpan(children: [ TextSpan(children: [
TextSpan(text: '$label ', style: const TextStyle(color: JjColors.textDim)), TextSpan(text: '$label ', style: TextStyle(color: JjColors.textDim)),
TextSpan( TextSpan(
text: value, text: value,
style: TextStyle( style: TextStyle(
@ -679,7 +679,7 @@ class AppActions extends StatelessWidget {
PopupMenuItem( PopupMenuItem(
value: 'exit', value: 'exit',
child: ListTile( child: ListTile(
leading: const Icon(Icons.power_settings_new, color: JjColors.danger), leading: Icon(Icons.power_settings_new, color: JjColors.danger),
title: Text(tr('종료')), title: Text(tr('종료')),
dense: true)), dense: true)),
], ],
@ -706,7 +706,7 @@ class AppActions extends StatelessWidget {
if (exit != null) if (exit != null)
IconButton( IconButton(
tooltip: tr('종료'), tooltip: tr('종료'),
icon: const Icon(Icons.power_settings_new, color: JjColors.danger), icon: Icon(Icons.power_settings_new, color: JjColors.danger),
onPressed: exit, onPressed: exit,
), ),
]); ]);

View File

@ -83,7 +83,7 @@ class _AppManagerPageState extends State<AppManagerPage> {
ListTile(leading: const Icon(Icons.info_outline), title: Text(tr('앱 정보 (시스템 화면)')), onTap: () => Navigator.pop(ctx, 'details')), ListTile(leading: const Icon(Icons.info_outline), title: Text(tr('앱 정보 (시스템 화면)')), onTap: () => Navigator.pop(ctx, 'details')),
if (!a.system) if (!a.system)
ListTile( ListTile(
leading: const Icon(Icons.delete_outline, color: JjColors.danger), leading: Icon(Icons.delete_outline, color: JjColors.danger),
title: Text(tr('지우기 (시스템 창에서 확인)')), title: Text(tr('지우기 (시스템 창에서 확인)')),
onTap: () => Navigator.pop(ctx, 'uninstall')), onTap: () => Navigator.pop(ctx, 'uninstall')),
]), ]),
@ -110,7 +110,7 @@ class _AppManagerPageState extends State<AppManagerPage> {
body: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ body: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Text(tr('보이는 앱만 나옵니다 (모든 앱 목록 권한은 쓰지 않음)'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)), child: Text(tr('보이는 앱만 나옵니다 (모든 앱 목록 권한은 쓰지 않음)'), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
), ),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.symmetric(horizontal: 12),
@ -133,7 +133,7 @@ class _AppManagerPageState extends State<AppManagerPage> {
), ),
]), ]),
), ),
if (_error != null) Padding(padding: const EdgeInsets.all(16), child: Text(_error!, style: const TextStyle(color: JjColors.danger))), if (_error != null) Padding(padding: const EdgeInsets.all(16), child: Text(_error!, style: TextStyle(color: JjColors.danger))),
if (apps == null && _error == null) const Expanded(child: Center(child: CircularProgressIndicator())), if (apps == null && _error == null) const Expanded(child: Center(child: CircularProgressIndicator())),
if (apps != null) if (apps != null)
Expanded( Expanded(

View File

@ -45,7 +45,7 @@ Future<String?> askArchivePassword(BuildContext context, String name, {bool wron
title: Text(tr('비밀번호')), title: Text(tr('비밀번호')),
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(trf('"{0}" 은(는) 비밀번호가 걸려 있습니다.', [name])), Text(trf('"{0}" 은(는) 비밀번호가 걸려 있습니다.', [name])),
if (wrong) Text(tr('비밀번호가 틀렸습니다'), style: const TextStyle(color: JjColors.danger)), if (wrong) Text(tr('비밀번호가 틀렸습니다'), style: TextStyle(color: JjColors.danger)),
TextField( TextField(
controller: ctl, controller: ctl,
autofocus: true, autofocus: true,
@ -183,7 +183,7 @@ Future<String?> extractArchiveFlow(BuildContext context, AppController c, String
const SizedBox(height: 8), const SizedBox(height: 8),
Text(trf('밖으로 나가는 경로라 풀지 않습니다 ({0}개): {1}', Text(trf('밖으로 나가는 경로라 풀지 않습니다 ({0}개): {1}',
[plan.unsafe.length, plan.unsafe.take(5).map((x) => '${x.$1} (${x.$2})').join(', ')]), [plan.unsafe.length, plan.unsafe.take(5).map((x) => '${x.$1} (${x.$2})').join(', ')]),
style: const TextStyle(color: JjColors.danger, fontSize: 12)), style: TextStyle(color: JjColors.danger, fontSize: 12)),
], ],
if (plan.badNames.isNotEmpty) ...[ if (plan.badNames.isNotEmpty) ...[
const SizedBox(height: 6), const SizedBox(height: 6),
@ -198,11 +198,11 @@ Future<String?> extractArchiveFlow(BuildContext context, AppController c, String
], ],
if (plan.encrypted) ...[ if (plan.encrypted) ...[
const SizedBox(height: 6), const SizedBox(height: 6),
Text(tr('비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)), Text(tr('비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)'), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
], ],
const SizedBox(height: 10), const SizedBox(height: 10),
_placeChoice(ctx, choices, dest, (d) => set(() => dest = d)), _placeChoice(ctx, choices, dest, (d) => set(() => dest = d)),
Text(vDisplay(dest), maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: JjColors.textDim)), Text(vDisplay(dest), maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: JjColors.textDim)),
const SizedBox(height: 6), const SizedBox(height: 6),
// 좁은 화면 · 큰 글자면 고르기 상자를 다음 줄로 (412 · 글자 1.6배에서 화면 밖으로 나갔다 - 253 시험) // 좁은 화면 · 큰 글자면 고르기 상자를 다음 줄로 (412 · 글자 1.6배에서 화면 밖으로 나갔다 - 253 시험)
Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [
@ -227,7 +227,7 @@ Future<String?> extractArchiveFlow(BuildContext context, AppController c, String
// 관리자 판단: 풀린 크기가 빈 공간보다 크면 풀기 전에 알리고 묻는다 (압축 폭탄 · 공간 부족) // 관리자 판단: 풀린 크기가 빈 공간보다 크면 풀기 전에 알리고 묻는다 (압축 폭탄 · 공간 부족)
if (short) if (short)
Text(trf('풀린 크기 ({0}) 가 남은 공간 ({1}) 보다 큽니다. 그래도 풀까요?', [formatSize(plan.totalSize), formatSize(f)]), Text(trf('풀린 크기 ({0}) 가 남은 공간 ({1}) 보다 큽니다. 그래도 풀까요?', [formatSize(plan.totalSize), formatSize(f)]),
style: const TextStyle(color: JjColors.danger)), style: TextStyle(color: JjColors.danger)),
]), ]),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
@ -351,8 +351,8 @@ Future<String?> createZipFlow(BuildContext context, AppController c, List<String
]), ]),
const SizedBox(height: 6), const SizedBox(height: 6),
_placeChoice(ctx, choices, where, (d) => set(() => where = d)), _placeChoice(ctx, choices, where, (d) => set(() => where = d)),
Text(trf('만들 파일: {0}', [p.basename(target())]), style: const TextStyle(fontSize: 12, color: JjColors.textDim)), Text(trf('만들 파일: {0}', [p.basename(target())]), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
Text(tr('링크 · junction 은 넣지 않습니다'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)), Text(tr('링크 · junction 은 넣지 않습니다'), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
const SizedBox(height: 10), const SizedBox(height: 10),
TextField( TextField(
controller: pass, controller: pass,
@ -387,11 +387,11 @@ Future<String?> createZipFlow(BuildContext context, AppController c, List<String
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text(tr('비밀번호를 잊으면 이 ZIP 을 풀 수 없습니다 (앱이 기억하지 않습니다). 파일 이름 목록은 암호화되지 않습니다.'), Text(tr('비밀번호를 잊으면 이 ZIP 을 풀 수 없습니다 (앱이 기억하지 않습니다). 파일 이름 목록은 암호화되지 않습니다.'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
// zip_aes: 7-Zip 등 다른 프로그램은 영문 밖 글자 ZIP 비밀번호를 받지 않는다 (10/17 시험) - 막지는 않고 알린다 // zip_aes: 7-Zip 등 다른 프로그램은 영문 밖 글자 ZIP 비밀번호를 받지 않는다 (10/17 시험) - 막지는 않고 알린다
if (pass.text.codeUnits.any((c) => c > 0x7e)) if (pass.text.codeUnits.any((c) => c > 0x7e))
Text(tr('한글 등 영문 밖 글자가 든 비밀번호는 이 앱에서는 풀리지만 7-Zip 등 다른 프로그램에서는 풀리지 않을 수 있습니다. 다른 기기 · 프로그램과 나눌 ZIP 은 영문 · 숫자 · 기호 비밀번호를 쓰세요.'), Text(tr('한글 등 영문 밖 글자가 든 비밀번호는 이 앱에서는 풀리지만 7-Zip 등 다른 프로그램에서는 풀리지 않을 수 있습니다. 다른 기기 · 프로그램과 나눌 ZIP 은 영문 · 숫자 · 기호 비밀번호를 쓰세요.'),
style: const TextStyle(fontSize: 12, color: JjColors.danger)), style: TextStyle(fontSize: 12, color: JjColors.danger)),
], ],
]), ]),
actions: [ actions: [

View File

@ -50,7 +50,7 @@ class AudioNowPlaying extends StatelessWidget {
valueListenable: state, valueListenable: state,
builder: (_, s, _) => Text( builder: (_, s, _) => Text(
'${_t(s.position)} / ${_t(s.duration)}', '${_t(s.position)} / ${_t(s.duration)}',
style: const TextStyle(color: JjColors.textDim, fontSize: 14, fontFeatures: [FontFeature.tabularFigures()]), style: TextStyle(color: JjColors.textDim, fontSize: 14, fontFeatures: [FontFeature.tabularFigures()]),
), ),
), ),
]), ]),
@ -61,7 +61,7 @@ class _Note extends StatelessWidget {
const _Note(); const _Note();
@override @override
Widget build(BuildContext context) => const AspectRatio( Widget build(BuildContext context) => AspectRatio(
aspectRatio: 1, aspectRatio: 1,
child: DecoratedBox( child: DecoratedBox(
decoration: BoxDecoration(color: Color(0xFF1E1E1E), borderRadius: BorderRadius.all(Radius.circular(12))), decoration: BoxDecoration(color: Color(0xFF1E1E1E), borderRadius: BorderRadius.all(Radius.circular(12))),

View File

@ -245,7 +245,7 @@ class _BatchRenameDialogState extends State<_BatchRenameDialog> {
PopupMenuItem(value: t, child: Text('$t $label')), PopupMenuItem(value: t, child: Text('$t $label')),
], ],
// 412 · 큰 글자에서 이 줄이 넘쳐 (253 시험) 글 대신 작은 아이콘 // 412 · 큰 글자에서 이 줄이 넘쳐 (253 시험) 글 대신 작은 아이콘
child: const Padding( child: Padding(
padding: EdgeInsets.symmetric(horizontal: 4, vertical: 6), padding: EdgeInsets.symmetric(horizontal: 4, vertical: 6),
child: Icon(Icons.music_note_outlined, size: 20, color: JjColors.accent), child: Icon(Icons.music_note_outlined, size: 20, color: JjColors.accent),
), ),
@ -317,7 +317,7 @@ class _BatchRenameDialogState extends State<_BatchRenameDialog> {
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Expanded( Expanded(
child: Text(r.oldName, child: Text(r.oldName,
maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: JjColors.textDim)), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: JjColors.textDim)),
), ),
const Padding(padding: EdgeInsets.symmetric(horizontal: 6), child: Text('→', style: TextStyle(fontSize: 12))), const Padding(padding: EdgeInsets.symmetric(horizontal: 6), child: Text('→', style: TextStyle(fontSize: 12))),
Expanded( Expanded(
@ -339,7 +339,7 @@ class _BatchRenameDialogState extends State<_BatchRenameDialog> {
if (_error != null) if (_error != null)
Padding( Padding(
padding: const EdgeInsets.only(top: 8), padding: const EdgeInsets.only(top: 8),
child: Text(_error!, style: const TextStyle(fontSize: 12, color: JjColors.danger)), child: Text(_error!, style: TextStyle(fontSize: 12, color: JjColors.danger)),
), ),
]), ]),
), ),

View File

@ -135,7 +135,7 @@ class _BookmarkEditorState extends State<_BookmarkEditor> {
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context, 'del'), onPressed: () => Navigator.pop(context, 'del'),
child: Text(widget.justAdded ? tr('추가 취소') : tr('삭제'), style: const TextStyle(color: JjColors.danger)), child: Text(widget.justAdded ? tr('추가 취소') : tr('삭제'), style: TextStyle(color: JjColors.danger)),
), ),
if (!widget.justAdded) TextButton(onPressed: () => Navigator.pop(context), child: Text(tr('취소'))), if (!widget.justAdded) TextButton(onPressed: () => Navigator.pop(context), child: Text(tr('취소'))),
FilledButton(onPressed: _save, child: Text(widget.justAdded ? tr('완료') : tr('저장'))), FilledButton(onPressed: _save, child: Text(widget.justAdded ? tr('완료') : tr('저장'))),
@ -177,7 +177,7 @@ Future<void> showNewBookmarkDialog(BuildContext context, BookmarksController bm,
Align( Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: Text(trf('폴더: {0}', [bm.tree.find(parentId)?.title ?? '']), child: Text(trf('폴더: {0}', [bm.tree.find(parentId)?.title ?? '']),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
), ),
]), ]),
), ),
@ -218,7 +218,7 @@ Future<String?> askText(BuildContext context, String title, String label, String
), ),
if (help != null) ...[ if (help != null) ...[
const SizedBox(height: 6), const SizedBox(height: 6),
Text(help, style: const TextStyle(fontSize: 12, color: JjColors.textDim)), Text(help, style: TextStyle(fontSize: 12, color: JjColors.textDim)),
], ],
]), ]),
), ),
@ -370,7 +370,7 @@ class _BookmarkDraggableState extends State<BookmarkDraggable> {
child: Row(mainAxisSize: MainAxisSize.min, children: [ child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(n.isFolder ? Icons.folder : Icons.public, size: 16, color: n.isFolder ? Colors.amber : null), Icon(n.isFolder ? Icons.folder : Icons.public, size: 16, color: n.isFolder ? Colors.amber : null),
const SizedBox(width: 6), const SizedBox(width: 6),
Text(n.title, style: const TextStyle(fontSize: 12, color: JjColors.text)), Text(n.title, style: TextStyle(fontSize: 12, color: JjColors.text)),
]), ]),
), ),
); );
@ -509,7 +509,7 @@ class _BookmarkManagerPageState extends State<BookmarkManagerPage> {
nav: const AppNavButtons(), nav: const AppNavButtons(),
actions: const AppActions(), actions: const AppActions(),
middle: Row(children: [ middle: Row(children: [
const Icon(Icons.bookmarks_outlined, color: JjColors.accent), Icon(Icons.bookmarks_outlined, color: JjColors.accent),
const SizedBox(width: 8), const SizedBox(width: 8),
if (!isCompact(context)) ...[ if (!isCompact(context)) ...[
Text(tr('즐겨찾기 관리자'), style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), Text(tr('즐겨찾기 관리자'), style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
@ -644,7 +644,7 @@ class _BookmarkManagerPageState extends State<BookmarkManagerPage> {
Expanded( Expanded(
child: Wrap(crossAxisAlignment: WrapCrossAlignment.center, children: [ child: Wrap(crossAxisAlignment: WrapCrossAlignment.center, children: [
for (final (i, f) in path.indexed) ...[ for (final (i, f) in path.indexed) ...[
if (i > 0) const Icon(Icons.chevron_right, size: 16, color: JjColors.textDim), if (i > 0) Icon(Icons.chevron_right, size: 16, color: JjColors.textDim),
BookmarkFolderDrop( BookmarkFolderDrop(
bm: bm, bm: bm,
folderId: f.id, folderId: f.id,
@ -704,7 +704,7 @@ class _BookmarkManagerPageState extends State<BookmarkManagerPage> {
leading: Row(mainAxisSize: MainAxisSize.min, children: [ leading: Row(mainAxisSize: MainAxisSize.min, children: [
ReorderableDragStartListener( ReorderableDragStartListener(
index: i, index: i,
child: const Icon(Icons.drag_indicator, size: 18, color: JjColors.textDim), child: Icon(Icons.drag_indicator, size: 18, color: JjColors.textDim),
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
// 아이콘을 끌면 다른 폴더로 옮기기 // 아이콘을 끌면 다른 폴더로 옮기기

View File

@ -273,7 +273,7 @@ class _BrowserPageState extends State<BrowserPage> with RouteAware {
textAlign: TextAlign.center, style: const TextStyle(fontSize: 16)), textAlign: TextAlign.center, style: const TextStyle(fontSize: 16)),
const SizedBox(height: 8), const SizedBox(height: 8),
Text(tr('인터넷 연결이나 환경 설정 > 네트워크 · DNS 의 서버 주소를 확인하세요.'), Text(tr('인터넷 연결이나 환경 설정 > 네트워크 · DNS 의 서버 주소를 확인하세요.'),
textAlign: TextAlign.center, style: const TextStyle(fontSize: 13, color: JjColors.textDim)), textAlign: TextAlign.center, style: TextStyle(fontSize: 13, color: JjColors.textDim)),
const SizedBox(height: 16), const SizedBox(height: 16),
Wrap(spacing: 8, runSpacing: 8, alignment: WrapAlignment.center, children: [ Wrap(spacing: 8, runSpacing: 8, alignment: WrapAlignment.center, children: [
FilledButton( FilledButton(
@ -468,7 +468,7 @@ class _BrowserPageState extends State<BrowserPage> with RouteAware {
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(12, 2, 4, 2), padding: const EdgeInsets.fromLTRB(12, 2, 4, 2),
child: Row(children: [ child: Row(children: [
const Icon(Icons.swipe, size: 16, color: JjColors.accent), Icon(Icons.swipe, size: 16, color: JjColors.accent),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text(tr('웹 브라우저에서는 위쪽 막대를 좌우로 밀면 다른 화면으로 옮깁니다 (가운데는 페이지가 밀립니다).'), child: Text(tr('웹 브라우저에서는 위쪽 막대를 좌우로 밀면 다른 화면으로 옮깁니다 (가운데는 페이지가 밀립니다).'),
@ -489,12 +489,12 @@ class _BrowserPageState extends State<BrowserPage> with RouteAware {
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(12, 2, 4, 2), padding: const EdgeInsets.fromLTRB(12, 2, 4, 2),
child: Row(children: [ child: Row(children: [
const Icon(Icons.timer_outlined, size: 16, color: JjColors.textDim), Icon(Icons.timer_outlined, size: 16, color: JjColors.textDim),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
trf('Tor 대기 중 ({0}분 뒤 끔) - 이 브라우저는 Tor 를 쓰지 않습니다', [(at.difference(DateTime.now()).inSeconds / 60).ceil().clamp(0, 60)]), trf('Tor 대기 중 ({0}분 뒤 끔) - 이 브라우저는 Tor 를 쓰지 않습니다', [(at.difference(DateTime.now()).inSeconds / 60).ceil().clamp(0, 60)]),
style: const TextStyle(fontSize: 12, color: JjColors.textDim), style: TextStyle(fontSize: 12, color: JjColors.textDim),
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
@ -1336,7 +1336,7 @@ class _CefElevatedBlocked extends StatelessWidget {
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560), constraints: const BoxConstraints(maxWidth: 560),
child: Column(mainAxisSize: MainAxisSize.min, children: [ child: Column(mainAxisSize: MainAxisSize.min, children: [
const Icon(Icons.admin_panel_settings_outlined, size: 40, color: JjColors.textDim), Icon(Icons.admin_panel_settings_outlined, size: 40, color: JjColors.textDim),
const SizedBox(height: 12), const SizedBox(height: 12),
Text(tr('관리자 권한으로 실행 중이라 Chrome 엔진을 쓸 수 없습니다 (보안)'), Text(tr('관리자 권한으로 실행 중이라 Chrome 엔진을 쓸 수 없습니다 (보안)'),
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), textAlign: TextAlign.center), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), textAlign: TextAlign.center),
@ -1344,7 +1344,7 @@ class _CefElevatedBlocked extends StatelessWidget {
Text( Text(
tr('내장 Chrome 엔진은 샌드박스가 꺼져 있어 관리자 권한으로 돌리면 위험합니다. ' tr('내장 Chrome 엔진은 샌드박스가 꺼져 있어 관리자 권한으로 돌리면 위험합니다. '
'Edge 엔진을 쓰거나, 관리자 권한 없이 프로그램을 실행하세요.'), 'Edge 엔진을 쓰거나, 관리자 권한 없이 프로그램을 실행하세요.'),
style: const TextStyle(color: JjColors.textDim), style: TextStyle(color: JjColors.textDim),
textAlign: TextAlign.center), textAlign: TextAlign.center),
const SizedBox(height: 16), const SizedBox(height: 16),
Wrap(spacing: 12, runSpacing: 8, alignment: WrapAlignment.center, children: [ Wrap(spacing: 12, runSpacing: 8, alignment: WrapAlignment.center, children: [
@ -1471,12 +1471,12 @@ class _CefViewState extends State<_CefView> {
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520), constraints: const BoxConstraints(maxWidth: 520),
child: Column(mainAxisSize: MainAxisSize.min, children: [ child: Column(mainAxisSize: MainAxisSize.min, children: [
const Icon(Icons.error_outline, size: 40, color: JjColors.textDim), Icon(Icons.error_outline, size: 40, color: JjColors.textDim),
const SizedBox(height: 12), const SizedBox(height: 12),
Text(tr('Chrome 엔진을 시작하지 못했습니다'), Text(tr('Chrome 엔진을 시작하지 못했습니다'),
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), textAlign: TextAlign.center), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), textAlign: TextAlign.center),
const SizedBox(height: 8), const SizedBox(height: 8),
Text('$_error', style: const TextStyle(color: JjColors.textDim), textAlign: TextAlign.center), Text('$_error', style: TextStyle(color: JjColors.textDim), textAlign: TextAlign.center),
const SizedBox(height: 16), const SizedBox(height: 16),
Wrap(spacing: 12, runSpacing: 8, alignment: WrapAlignment.center, children: [ Wrap(spacing: 12, runSpacing: 8, alignment: WrapAlignment.center, children: [
if (widget.onUseEdge != null) if (widget.onUseEdge != null)
@ -1785,7 +1785,7 @@ class _BookmarkPanelState extends State<BookmarkPanel> {
Padding( Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 4, 0), padding: const EdgeInsets.fromLTRB(12, 8, 4, 0),
child: Row(children: [ child: Row(children: [
const Icon(Icons.bookmarks_outlined, size: 18, color: JjColors.accent), Icon(Icons.bookmarks_outlined, size: 18, color: JjColors.accent),
const SizedBox(width: 6), const SizedBox(width: 6),
Text(tr('즐겨찾기'), style: TextStyle(fontWeight: FontWeight.w600)), Text(tr('즐겨찾기'), style: TextStyle(fontWeight: FontWeight.w600)),
const Spacer(), const Spacer(),
@ -1816,7 +1816,7 @@ class _BookmarkPanelState extends State<BookmarkPanel> {
padding: const EdgeInsets.fromLTRB(8, 4, 8, 4), padding: const EdgeInsets.fromLTRB(8, 4, 8, 4),
child: Wrap(crossAxisAlignment: WrapCrossAlignment.center, children: [ child: Wrap(crossAxisAlignment: WrapCrossAlignment.center, children: [
for (final (i, n) in _path().indexed) ...[ for (final (i, n) in _path().indexed) ...[
if (i > 0) const Icon(Icons.chevron_right, size: 14, color: JjColors.textDim), if (i > 0) Icon(Icons.chevron_right, size: 14, color: JjColors.textDim),
BookmarkFolderDrop( BookmarkFolderDrop(
bm: bm, bm: bm,
folderId: n.id, folderId: n.id,
@ -1903,7 +1903,7 @@ class _BookmarkPanelState extends State<BookmarkPanel> {
leading: Row(mainAxisSize: MainAxisSize.min, children: [ leading: Row(mainAxisSize: MainAxisSize.min, children: [
ReorderableDragStartListener( ReorderableDragStartListener(
index: i, index: i,
child: const Icon(Icons.drag_indicator, size: 16, color: JjColors.textDim), child: Icon(Icons.drag_indicator, size: 16, color: JjColors.textDim),
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
// 아이콘을 끌어 폴더 줄 · 위 경로에 놓으면 그 폴더로 // 아이콘을 끌어 폴더 줄 · 위 경로에 놓으면 그 폴더로

View File

@ -109,7 +109,7 @@ class _InstallProgressState extends State<_InstallProgress> {
const SizedBox(height: 8), const SizedBox(height: 8),
LinearProgressIndicator(value: _p <= 0 ? null : _p), LinearProgressIndicator(value: _p <= 0 ? null : _p),
] else ] else
Text(trf('설치하지 못했습니다.\n{0}', [_error]), style: const TextStyle(color: JjColors.danger)), Text(trf('설치하지 못했습니다.\n{0}', [_error]), style: TextStyle(color: JjColors.danger)),
]), ]),
), ),
actions: [ actions: [

View File

@ -59,7 +59,7 @@ class _CleanupDialogState extends State<_CleanupDialog> {
final groups = _groups; final groups = _groups;
return AlertDialog( return AlertDialog(
title: Row(children: [ title: Row(children: [
const Icon(Icons.cleaning_services_outlined, color: JjColors.accent), Icon(Icons.cleaning_services_outlined, color: JjColors.accent),
const SizedBox(width: 10), const SizedBox(width: 10),
Text(tr('저장 공간 정리')), Text(tr('저장 공간 정리')),
]), ]),
@ -85,7 +85,7 @@ class _CleanupDialogState extends State<_CleanupDialog> {
Expanded(child: Text(g.title)), Expanded(child: Text(g.title)),
Text( Text(
g.items.isEmpty ? tr('없음') : trf('{0}개 · {1}', [g.items.length, Cleaner.sizeText(g.bytes)]), g.items.isEmpty ? tr('없음') : trf('{0}개 · {1}', [g.items.length, Cleaner.sizeText(g.bytes)]),
style: const TextStyle(fontSize: 13, color: JjColors.textDim), style: TextStyle(fontSize: 13, color: JjColors.textDim),
), ),
]), ]),
subtitle: Text(g.skipped ?? g.hint, subtitle: Text(g.skipped ?? g.hint,

View File

@ -240,7 +240,7 @@ class ComponentSettings extends StatelessWidget {
ListTile( ListTile(
key: ValueKey(p.id), key: ValueKey(p.id),
dense: true, dense: true,
leading: Text('${i + 1}', style: const TextStyle(color: JjColors.textDim)), leading: Text('${i + 1}', style: TextStyle(color: JjColors.textDim)),
title: Row(children: [Icon(p.icon, size: 18), const SizedBox(width: 10), Flexible(child: Text(tr(p.name)))]), title: Row(children: [Icon(p.icon, size: 18), const SizedBox(width: 10), Flexible(child: Text(tr(p.name)))]),
trailing: Row(mainAxisSize: MainAxisSize.min, children: [ trailing: Row(mainAxisSize: MainAxisSize.min, children: [
IconButton( IconButton(

View File

@ -219,7 +219,7 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
) )
: IconButton( : IconButton(
tooltip: tr('내려받은 rsync 지우기'), tooltip: tr('내려받은 rsync 지우기'),
icon: const Icon(Icons.delete_outline, color: JjColors.textDim), icon: Icon(Icons.delete_outline, color: JjColors.textDim),
onPressed: () async { onPressed: () async {
await RsyncInstaller.uninstall(); await RsyncInstaller.uninstall();
await _check(); await _check();
@ -303,7 +303,7 @@ class _LiveSyncTile extends StatelessWidget {
Expanded( Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('$label ${shortPath(path)}', style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)), Text('$label ${shortPath(path)}', style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
Text(vDisplay(path), style: const TextStyle(fontSize: 11, color: JjColors.textDim)), Text(vDisplay(path), style: TextStyle(fontSize: 11, color: JjColors.textDim)),
]), ]),
), ),
TextButton( TextButton(
@ -474,7 +474,7 @@ class _LiveSyncTile extends StatelessWidget {
), ),
IconButton( IconButton(
tooltip: tr('지우기'), tooltip: tr('지우기'),
icon: const Icon(Icons.delete_outline, size: 18, color: JjColors.textDim), icon: Icon(Icons.delete_outline, size: 18, color: JjColors.textDim),
onPressed: () => removeLiveSyncPair(context, c, pairs[i]), onPressed: () => removeLiveSyncPair(context, c, pairs[i]),
), ),
]), ]),
@ -525,7 +525,7 @@ class LiveSyncRunOptions extends StatelessWidget {
), ),
// 257: 폰 세로에서 넘쳤다 - 모자라면 고르기 상자를 아래 줄로 // 257: 폰 세로에서 넘쳤다 - 모자라면 고르기 상자를 아래 줄로
Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [
Text(tr('다시 켤 때 동기화'), style: const TextStyle(fontSize: 13, color: JjColors.textDim)), Text(tr('다시 켤 때 동기화'), style: TextStyle(fontSize: 13, color: JjColors.textDim)),
onStart, onStart,
]), ]),
]); ]);

View File

@ -30,7 +30,7 @@ Future<String?> fetchDav(BuildContext context, AppController c, String path) asy
LinearProgressIndicator(value: v.$2 > 0 ? v.$1 / v.$2 : null), LinearProgressIndicator(value: v.$2 > 0 ? v.$1 / v.$2 : null),
const SizedBox(height: 6), const SizedBox(height: 6),
Text('${formatSize(v.$1)}${v.$2 > 0 ? ' / ${formatSize(v.$2)}' : ''}', Text('${formatSize(v.$1)}${v.$2 > 0 ? ' / ${formatSize(v.$2)}' : ''}',
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
]), ]),
), ),
actions: [ actions: [

View File

@ -200,9 +200,9 @@ class _DlnaPageState extends State<DlnaPage> {
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Text(tr('보기만 합니다 - 같은 Wi-Fi · 유선 망의 미디어 서버만 찾고, 올리기 · 지우기는 없습니다'), child: Text(tr('보기만 합니다 - 같은 Wi-Fi · 유선 망의 미디어 서버만 찾고, 올리기 · 지우기는 없습니다'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
), ),
if (_error != null) Padding(padding: const EdgeInsets.all(16), child: Text(_error!, style: const TextStyle(color: JjColors.danger))), if (_error != null) Padding(padding: const EdgeInsets.all(16), child: Text(_error!, style: TextStyle(color: JjColors.danger))),
if (s == null) Expanded(child: _serverList()) else Expanded(child: _entryList()), if (s == null) Expanded(child: _serverList()) else Expanded(child: _entryList()),
]), ]),
), ),
@ -234,7 +234,7 @@ class _DlnaPageState extends State<DlnaPage> {
ListTile( ListTile(
leading: const Icon(Icons.dns_outlined), leading: const Icon(Icons.dns_outlined),
title: Text(x.name), title: Text(x.name),
subtitle: Text(x.host, style: const TextStyle(color: JjColors.textDim)), subtitle: Text(x.host, style: TextStyle(color: JjColors.textDim)),
onTap: () => _open(x, '0', x.name), onTap: () => _open(x, '0', x.name),
), ),
]); ]);

View File

@ -133,7 +133,7 @@ class _DownloadsPageState extends State<DownloadsPage> {
Padding( Padding(
padding: const EdgeInsets.only(right: 8), padding: const EdgeInsets.only(right: 8),
child: Text(hasSel ? trf('{0}개 선택', [d.selected.length]) : tr('전체 선택'), child: Text(hasSel ? trf('{0}개 선택', [d.selected.length]) : tr('전체 선택'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
), ),
]), ]),
), ),
@ -185,7 +185,7 @@ class _DownloadsPageState extends State<DownloadsPage> {
? tr('다운로드가 없습니다.\nYouTube 주소 · 마그넷 링크를 위 칸에 붙여 넣거나, 웹 브라우저에서 [다운로드] 를 누르세요.') ? tr('다운로드가 없습니다.\nYouTube 주소 · 마그넷 링크를 위 칸에 붙여 넣거나, 웹 브라우저에서 [다운로드] 를 누르세요.')
: tr('다운로드가 없습니다.\nYouTube 주소나 마그넷 링크를 복사(Ctrl+C)하면 자동으로 받습니다.'), : tr('다운로드가 없습니다.\nYouTube 주소나 마그넷 링크를 복사(Ctrl+C)하면 자동으로 받습니다.'),
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle(color: JjColors.textDim)))) style: TextStyle(color: JjColors.textDim))))
: ListView.builder( : ListView.builder(
itemCount: d.tasks.length, itemCount: d.tasks.length,
itemBuilder: (_, i) => _DownloadRow(d: d, t: d.tasks[i]), itemBuilder: (_, i) => _DownloadRow(d: d, t: d.tasks[i]),
@ -274,7 +274,7 @@ class _DownloadRow extends StatelessWidget {
width: 560, width: 560,
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
// 서명 · 키가 든 주소 부분은 가린다 // 서명 · 키가 든 주소 부분은 가린다
SelectableText(redactUrlSecrets(t.source), style: const TextStyle(fontSize: 12, color: JjColors.textDim)), SelectableText(redactUrlSecrets(t.source), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
const SizedBox(height: 8), const SizedBox(height: 8),
SelectableText(redactUrlSecrets(t.error ?? ''), style: const TextStyle(fontFamily: 'monospace', fontSize: 12)), SelectableText(redactUrlSecrets(t.error ?? ''), style: const TextStyle(fontFamily: 'monospace', fontSize: 12)),
]), ]),
@ -300,7 +300,7 @@ class _DownloadRow extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: d.selected.contains(t.id) ? JjColors.accent.withValues(alpha: 0.08) : null, color: d.selected.contains(t.id) ? JjColors.accent.withValues(alpha: 0.08) : null,
border: const Border(bottom: BorderSide(color: JjColors.border)), border: Border(bottom: BorderSide(color: JjColors.border)),
), ),
child: Row(children: [ child: Row(children: [
Checkbox(value: d.selected.contains(t.id), onChanged: (_) => d.toggle(t)), Checkbox(value: d.selected.contains(t.id), onChanged: (_) => d.toggle(t)),
@ -328,7 +328,7 @@ class _DownloadRow extends StatelessWidget {
]) ])
else if (t.state == DownloadState.failed) else if (t.state == DownloadState.failed)
Wrap(crossAxisAlignment: WrapCrossAlignment.center, spacing: 4, children: [ Wrap(crossAxisAlignment: WrapCrossAlignment.center, spacing: 4, children: [
Text('$label · ${_why(t)}', style: const TextStyle(fontSize: 12, color: JjColors.danger)), Text('$label · ${_why(t)}', style: TextStyle(fontSize: 12, color: JjColors.danger)),
if ((t.error ?? '').trim().isNotEmpty) if ((t.error ?? '').trim().isNotEmpty)
TextButton( TextButton(
style: _small, style: _small,
@ -356,7 +356,7 @@ class _DownloadRow extends StatelessWidget {
), ),
IconButton( IconButton(
tooltip: tr('폴더 열기'), tooltip: tr('폴더 열기'),
icon: const Icon(Icons.folder_open, size: 18, color: JjColors.textDim), icon: Icon(Icons.folder_open, size: 18, color: JjColors.textDim),
onPressed: () { onPressed: () {
if (Platform.isWindows) { if (Platform.isWindows) {
Process.run('explorer', [t.dir]); Process.run('explorer', [t.dir]);

View File

@ -35,7 +35,7 @@ class _CloseChoiceState extends State<_CloseChoice> {
Text(trf('지금: {0}', [widget.running]), style: const TextStyle(fontSize: 13)), Text(trf('지금: {0}', [widget.running]), style: const TextStyle(fontSize: 13)),
const SizedBox(height: 10), const SizedBox(height: 10),
Text(trf('· 백그라운드로: 창만 숨기고 다운로드 · 변환을 계속합니다 ({0} 또는 트레이 아이콘으로 다시 열기)\n' '· 모두 종료: 다운로드 · 변환을 멈추고 프로그램을 끝냅니다', [widget.hotkey]), Text(trf('· 백그라운드로: 창만 숨기고 다운로드 · 변환을 계속합니다 ({0} 또는 트레이 아이콘으로 다시 열기)\n' '· 모두 종료: 다운로드 · 변환을 멈추고 프로그램을 끝냅니다', [widget.hotkey]),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
const SizedBox(height: 8), const SizedBox(height: 8),
InkWell( InkWell(
onTap: () => setState(() => _remember = !_remember), onTap: () => setState(() => _remember = !_remember),
@ -125,7 +125,7 @@ class _ExitProgressState extends State<_ExitProgress> {
Text(trf('바로 전 작업: {0}', [widget.lastWork]), Text(trf('바로 전 작업: {0}', [widget.lastWork]),
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
const SizedBox(height: 12), const SizedBox(height: 12),
for (final (i, s) in widget.steps.indexed) for (final (i, s) in widget.steps.indexed)
Padding( Padding(
@ -135,10 +135,10 @@ class _ExitProgressState extends State<_ExitProgress> {
width: 18, width: 18,
height: 18, height: 18,
child: i < _done child: i < _done
? const Icon(Icons.check_circle, size: 18, color: JjColors.success) ? Icon(Icons.check_circle, size: 18, color: JjColors.success)
: i == _done : i == _done
? const CircularProgressIndicator(strokeWidth: 2) ? const CircularProgressIndicator(strokeWidth: 2)
: const Icon(Icons.circle_outlined, size: 18, color: JjColors.textDim), : Icon(Icons.circle_outlined, size: 18, color: JjColors.textDim),
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(

View File

@ -88,7 +88,7 @@ class _BookmarksDialogState extends State<_BookmarksDialog> {
if (mine.isEmpty) if (mine.isEmpty)
Padding( Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
child: Text(tr('아직 없습니다. 아래에서 지금 폴더를 넣으세요.'), style: const TextStyle(color: JjColors.textDim)), child: Text(tr('아직 없습니다. 아래에서 지금 폴더를 넣으세요.'), style: TextStyle(color: JjColors.textDim)),
), ),
for (final b in mine) for (final b in mine)
ListTile( ListTile(

View File

@ -75,10 +75,10 @@ Widget dragFeedback(List<String> paths) => Material(
border: Border.all(color: JjColors.accent), border: Border.all(color: JjColors.accent),
), ),
child: Row(mainAxisSize: MainAxisSize.min, children: [ child: Row(mainAxisSize: MainAxisSize.min, children: [
const Icon(Icons.drive_file_move_outline, size: 16, color: JjColors.accent), Icon(Icons.drive_file_move_outline, size: 16, color: JjColors.accent),
const SizedBox(width: 6), const SizedBox(width: 6),
Text(paths.length == 1 ? vBasename(paths.first) : trf('{0}개 · {1} …', [paths.length, vBasename(paths.first)]), Text(paths.length == 1 ? vBasename(paths.first) : trf('{0}개 · {1} …', [paths.length, vBasename(paths.first)]),
style: const TextStyle(fontSize: 12, color: JjColors.text)), style: TextStyle(fontSize: 12, color: JjColors.text)),
]), ]),
), ),
); );

View File

@ -79,14 +79,14 @@ class _ExplorerGridPageState extends State<ExplorerGridPage> {
picture = FutureBuilder<File?>( picture = FutureBuilder<File?>(
future: widget.videoThumb(e), future: widget.videoThumb(e),
builder: (_, s) => s.data == null builder: (_, s) => s.data == null
? const Center(child: Icon(Icons.movie_outlined, size: 40, color: JjColors.textDim)) ? Center(child: Icon(Icons.movie_outlined, size: 40, color: JjColors.textDim))
: Image.file(s.data!, fit: BoxFit.cover, cacheWidth: px), : Image.file(s.data!, fit: BoxFit.cover, cacheWidth: px),
); );
} else { } else {
picture = Image.file(File(e.path), picture = Image.file(File(e.path),
fit: BoxFit.cover, fit: BoxFit.cover,
cacheWidth: px, cacheWidth: px,
errorBuilder: (_, _, _) => const Center(child: Icon(Icons.broken_image_outlined, size: 40, color: JjColors.textDim))); errorBuilder: (_, _, _) => Center(child: Icon(Icons.broken_image_outlined, size: 40, color: JjColors.textDim)));
} }
return Tooltip( return Tooltip(
message: e.name, message: e.name,

View File

@ -52,7 +52,7 @@ class ExplorerKeysPage extends StatelessWidget {
child: Text( child: Text(
trf('지금 스타일 ({0}) 의 키입니다. 바꾼 키는 모든 스타일에 쓰이고, 다른 동작과 겹치면 그 동작의 키는 없어집니다. Esc 는 바꿀 수 없습니다', trf('지금 스타일 ({0}) 의 키입니다. 바꾼 키는 모든 스타일에 쓰이고, 다른 동작과 겹치면 그 동작의 키는 없어집니다. Esc 는 바꿀 수 없습니다',
[ExplorerStyle.of(c.settings.explorerStyle).label]), [ExplorerStyle.of(c.settings.explorerStyle).label]),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
), ),
for (final id in explorerKeyActions) for (final id in explorerKeyActions)
ListTile( ListTile(
@ -123,7 +123,7 @@ class _KeyCaptureDialogState extends State<_KeyCaptureDialog> {
if (clash == 'escape') if (clash == 'escape')
Padding( Padding(
padding: const EdgeInsets.only(top: 8), padding: const EdgeInsets.only(top: 8),
child: Text(tr('Esc 는 선택 · 빠른 필터를 닫는 키라 쓸 수 없습니다'), style: const TextStyle(color: JjColors.danger, fontSize: 12)), child: Text(tr('Esc 는 선택 · 빠른 필터를 닫는 키라 쓸 수 없습니다'), style: TextStyle(color: JjColors.danger, fontSize: 12)),
) )
else if (clash != null) else if (clash != null)
Padding( Padding(

View File

@ -177,7 +177,7 @@ class ExplorerColumnsHeader extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
const st = TextStyle(fontSize: 11.5, color: JjColors.textDim); final st = TextStyle(fontSize: 11.5, color: JjColors.textDim);
Widget cell(String t, int flex, {TextAlign align = TextAlign.start, String? by}) { Widget cell(String t, int flex, {TextAlign align = TextAlign.start, String? by}) {
final on = by != null && by == sortBy; final on = by != null && by == sortBy;
final label = Row( final label = Row(
@ -208,7 +208,7 @@ class ExplorerColumnsHeader extends StatelessWidget {
return Container( return Container(
height: 24, height: 24,
padding: EdgeInsets.only(left: 10, right: markColumn ? 40 : 4), padding: EdgeInsets.only(left: 10, right: markColumn ? 40 : 4),
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: JjColors.border))), decoration: BoxDecoration(border: Border(bottom: BorderSide(color: JjColors.border))),
child: Row( child: Row(
children: style == ExplorerStyle.windows children: style == ExplorerStyle.windows
? [ ? [

View File

@ -743,7 +743,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
Expanded(child: Text(title, style: const TextStyle(fontWeight: FontWeight.w600))), Expanded(child: Text(title, style: const TextStyle(fontWeight: FontWeight.w600))),
]), ]),
const SizedBox(height: 4), const SizedBox(height: 4),
Text(body, style: const TextStyle(fontSize: 12, color: JjColors.textDim)), Text(body, style: TextStyle(fontSize: 12, color: JjColors.textDim)),
Wrap(spacing: 8, children: actions), Wrap(spacing: 8, children: actions),
]), ]),
); );
@ -2094,7 +2094,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(children: [ child: Row(children: [
const Icon(Icons.wifi_tethering, size: 18, color: JjColors.success), Icon(Icons.wifi_tethering, size: 18, color: JjColors.success),
const SizedBox(width: 6), const SizedBox(width: 6),
Expanded( Expanded(
child: Text( child: Text(
@ -2265,7 +2265,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
enabled: false, enabled: false,
height: 32, height: 32,
child: Text(trf('{0}개 항목', [targets.length]), child: Text(trf('{0}개 항목', [targets.length]),
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: JjColors.accent)), style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: JjColors.accent)),
), ),
if (icons.isNotEmpty) ...[_MenuIconRow(icons), const PopupMenuDivider(height: 1)], if (icons.isNotEmpty) ...[_MenuIconRow(icons), const PopupMenuDivider(height: 1)],
for (final (id, icon, label) in items) for (final (id, icon, label) in items)
@ -2618,7 +2618,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: Row(children: [ child: Row(children: [
const Icon(Icons.arrow_forward, size: 18, color: JjColors.accent), Icon(Icons.arrow_forward, size: 18, color: JjColors.accent),
const SizedBox(width: 6), const SizedBox(width: 6),
// 412 캡처 (10/18): "갈 곳:" 은 늘 보이고, 폴더 이름이 길면 가운데를 줄인다 (전체 경로는 길게 눌러 · 마우스를 올려) // 412 캡처 (10/18): "갈 곳:" 은 늘 보이고, 폴더 이름이 길면 가운데를 줄인다 (전체 경로는 길게 눌러 · 마우스를 올려)
Expanded( Expanded(
@ -3008,7 +3008,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
Padding( Padding(
padding: const EdgeInsets.only(top: 8), padding: const EdgeInsets.only(top: 8),
child: Text(trf('앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).', [c.settings.androidTrashDays]), child: Text(trf('앱 휴지통에 {0}일 두었다가 지웁니다 (탐색기 [휴지통] 에서 되살리기).', [c.settings.androidTrashDays]),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
), ),
if (canRecycle) if (canRecycle)
CheckboxListTile( CheckboxListTile(
@ -3126,7 +3126,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
], ],
for (final (x, why) in failed.take(30)) ...[ for (final (x, why) in failed.take(30)) ...[
Text(vBasename(x), style: const TextStyle(fontWeight: FontWeight.w600)), Text(vBasename(x), style: const TextStyle(fontWeight: FontWeight.w600)),
Text('${vDirname(x)} · $why', style: const TextStyle(fontSize: 12, color: JjColors.textDim)), Text('${vDirname(x)} · $why', style: TextStyle(fontSize: 12, color: JjColors.textDim)),
const SizedBox(height: 4), const SizedBox(height: 4),
], ],
if (failed.length > 30) Text(trf('… 외 {0}개', [failed.length - 30])), if (failed.length > 30) Text(trf('… 외 {0}개', [failed.length - 30])),
@ -3485,7 +3485,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
child: Text( child: Text(
trf('환경 설정의 {0} 는 복사에서는 쓰지 않습니다 (이동은 [이동] 으로)', trf('환경 설정의 {0} 는 복사에서는 쓰지 않습니다 (이동은 [이동] 으로)',
[splitOptions(task.options).where(isSourceRemovingOption).join(' ')]), [splitOptions(task.options).where(isSourceRemovingOption).join(' ')]),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
), ),
FutureBuilder<List<NameClash>>( FutureBuilder<List<NameClash>>(
future: clashesFuture, future: clashesFuture,
@ -3493,7 +3493,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
if (snap.connectionState != ConnectionState.done) { if (snap.connectionState != ConnectionState.done) {
return Padding( return Padding(
padding: const EdgeInsets.only(top: 12), padding: const EdgeInsets.only(top: 12),
child: Text(tr('같은 이름이 있는지 확인하는 중 …'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)), child: Text(tr('같은 이름이 있는지 확인하는 중 …'), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
); );
} }
final clashes = snap.data ?? const <NameClash>[]; final clashes = snap.data ?? const <NameClash>[];
@ -3733,18 +3733,18 @@ class _ExplorerPageState extends State<ExplorerPage> {
Text(shortPath(t.sources.first), style: const TextStyle(fontWeight: FontWeight.w600)), Text(shortPath(t.sources.first), style: const TextStyle(fontWeight: FontWeight.w600)),
Text('→ ${shortPath(t.dest)}', style: const TextStyle(fontWeight: FontWeight.w600)), Text('→ ${shortPath(t.dest)}', style: const TextStyle(fontWeight: FontWeight.w600)),
Text('${vDisplay(t.sources.first)}/ → ${vDisplay(t.dest)}/', Text('${vDisplay(t.sources.first)}/ → ${vDisplay(t.dest)}/',
style: const TextStyle(fontSize: 11, color: JjColors.textDim)), style: TextStyle(fontSize: 11, color: JjColors.textDim)),
// 111: 이 쌍은 처음 실행 때 기억한 옵션을 쓴다 (환경 설정의 rsync 옵션을 바꿔도 그대로) // 111: 이 쌍은 처음 실행 때 기억한 옵션을 쓴다 (환경 설정의 rsync 옵션을 바꿔도 그대로)
if (center.tasks.any((x) => x.id == t.id)) if (center.tasks.any((x) => x.id == t.id))
Text(tr('이 폴더 쌍은 기억한 옵션을 씁니다 (환경 설정이 아니라 모니터링 > 복사 · rsync 에서 고칩니다)'), Text(tr('이 폴더 쌍은 기억한 옵션을 씁니다 (환경 설정이 아니라 모니터링 > 복사 · rsync 에서 고칩니다)'),
style: const TextStyle(fontSize: 11, color: JjColors.textDim)), style: TextStyle(fontSize: 11, color: JjColors.textDim)),
Text('rsync ${runOpts(tasks.indexOf(t))}${removeSource ? ' --remove-source-files' : ''}' Text('rsync ${runOpts(tasks.indexOf(t))}${removeSource ? ' --remove-source-files' : ''}'
'${t.bandwidthKBps > 0 ? ' · ${trf('속도 제한 {0} KB/s', [t.bandwidthKBps])}' : ''}', '${t.bandwidthKBps > 0 ? ' · ${trf('속도 제한 {0} KB/s', [t.bandwidthKBps])}' : ''}',
style: const TextStyle(fontSize: 12, fontFamily: 'monospace', color: JjColors.textDim)), style: TextStyle(fontSize: 12, fontFamily: 'monospace', color: JjColors.textDim)),
const SizedBox(height: 10), const SizedBox(height: 10),
], ],
Text(tr('폴더 안의 것을 맞춥니다 (대상에 같은 이름의 폴더를 새로 만들지 않음).'), Text(tr('폴더 안의 것을 맞춥니다 (대상에 같은 이름의 폴더를 새로 만들지 않음).'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
// 222: 이동이면 동작대로 (예전에는 이동일 때도 "복사 · 원본 폴더는 그대로" 라고 썼다) // 222: 이동이면 동작대로 (예전에는 이동일 때도 "복사 · 원본 폴더는 그대로" 라고 썼다)
if (nested) if (nested)
Text( Text(
@ -3755,7 +3755,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
// 1280×720 등 낮은 화면에서도 보이게: 원본 지우기 체크 · 경고는 비교 목록보다 위에 (178) // 1280×720 등 낮은 화면에서도 보이게: 원본 지우기 체크 · 경고는 비교 목록보다 위에 (178)
if (both) ...[ if (both) ...[
Text(keepOptionsTogether(tr('양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).')), Text(keepOptionsTogether(tr('양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).')),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
if (optionMove) if (optionMove)
Text(tr('양쪽 (⇄) 에서는 원본 파일을 지우지 않습니다 (옵션의 --remove-source-files 는 쓰지 않음).'), Text(tr('양쪽 (⇄) 에서는 원본 파일을 지우지 않습니다 (옵션의 --remove-source-files 는 쓰지 않음).'),
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)), style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)),
@ -3781,7 +3781,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent, fontWeight: FontWeight.w600)), style: const TextStyle(fontSize: 12, color: Colors.orangeAccent, fontWeight: FontWeight.w600)),
const SizedBox(height: 4), const SizedBox(height: 4),
Text(tr('끝난 뒤 원본의 빈 폴더를 지웁니다 (find 원본/ -type d -empty -delete). 원본 폴더는:'), Text(tr('끝난 뒤 원본의 빈 폴더를 지웁니다 (find 원본/ -type d -empty -delete). 원본 폴더는:'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
const SizedBox(height: 6), const SizedBox(height: 6),
PruneChoice(value: prune, onChanged: (v) => set(() => prune = v)), PruneChoice(value: prune, onChanged: (v) => set(() => prune = v)),
], ],
@ -3840,7 +3840,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
const SizedBox(height: 4), const SizedBox(height: 4),
if (_viaDav([l, r])) if (_viaDav([l, r]))
Text(tr('WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files · 거르기 (--exclude · --include · --filter · --delete-excluded) 를 따름).'), Text(tr('WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files · 거르기 (--exclude · --include · --filter · --delete-excluded) 를 따름).'),
style: const TextStyle(fontSize: 12, color: JjColors.accent)), style: TextStyle(fontSize: 12, color: JjColors.accent)),
]), ]),
), ),
actions: [ actions: [
@ -4027,7 +4027,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
const SizedBox(width: 10), const SizedBox(width: 10),
SizedBox( SizedBox(
width: 150, width: 150,
child: Text(right, textAlign: TextAlign.end, style: const TextStyle(fontSize: 12, color: JjColors.textDim)), child: Text(right, textAlign: TextAlign.end, style: TextStyle(fontSize: 12, color: JjColors.textDim)),
), ),
]), ]),
); );
@ -4040,7 +4040,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
Expanded( Expanded(
// Rsync 화면: 원본 → 대상 (⇄ 는 두 줄) // Rsync 화면: 원본 → 대상 (⇄ 는 두 줄)
child: Text(widget.rsync ? '${job.sources.join(', ')}/ → ${job.dest}/' : job.dest, child: Text(widget.rsync ? '${job.sources.join(', ')}/ → ${job.dest}/' : job.dest,
maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: JjColors.textDim)), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: JjColors.textDim)),
), ),
if (!job.finished) TextButton(onPressed: job.cancel, child: Text(tr('취소'))), if (!job.finished) TextButton(onPressed: job.cancel, child: Text(tr('취소'))),
]), ]),
@ -4240,11 +4240,11 @@ class _ExplorerPageState extends State<ExplorerPage> {
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(children: [ child: Row(children: [
const Icon(Icons.manage_search, size: 18, color: JjColors.accent), Icon(Icons.manage_search, size: 18, color: JjColors.accent),
const SizedBox(width: 6), const SizedBox(width: 6),
Expanded( Expanded(
child: Text(trf('찾기 결과 ({0}) · {1}', [back.cond.summary, back.root]), child: Text(trf('찾기 결과 ({0}) · {1}', [back.cond.summary, back.root]),
maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: JjColors.textDim)), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: JjColors.textDim)),
), ),
TextButton(onPressed: _backToSearch, child: Text(tr('찾기 결과로 돌아가기'))), TextButton(onPressed: _backToSearch, child: Text(tr('찾기 결과로 돌아가기'))),
IconButton( IconButton(
@ -4273,7 +4273,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
Text(tr('이 앱의 파일 탐색기 목록에서만 빠집니다. 파일은 지우거나 옮기거나 이름을 바꾸지 않고, 다른 앱 · 갤러리에서는 그대로 보입니다.')), Text(tr('이 앱의 파일 탐색기 목록에서만 빠집니다. 파일은 지우거나 옮기거나 이름을 바꾸지 않고, 다른 앱 · 갤러리에서는 그대로 보입니다.')),
const SizedBox(height: 6), const SizedBox(height: 6),
Text(tr('목록 맨 아래의 [숨긴 항목 보이기] 나 환경 설정 > 파일 탐색기에서 다시 볼 수 있습니다.'), Text(tr('목록 맨 아래의 [숨긴 항목 보이기] 나 환경 설정 > 파일 탐색기에서 다시 볼 수 있습니다.'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
CheckboxListTile( CheckboxListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading, controlAffinity: ListTileControlAffinity.leading,
@ -4310,7 +4310,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
: tr('이 폴더의 .nomedia 파일을 지웁니다. 갤러리 · 음악 앱이 이 폴더의 그림 · 동영상 · 음악을 다시 보이게 됩니다.')), : tr('이 폴더의 .nomedia 파일을 지웁니다. 갤러리 · 음악 앱이 이 폴더의 그림 · 동영상 · 음악을 다시 보이게 됩니다.')),
const SizedBox(height: 6), const SizedBox(height: 6),
Text(tr('폴더 안의 파일은 바꾸지 않습니다. 갤러리에 반영되기까지 잠깐 걸릴 수 있습니다.'), Text(tr('폴더 안의 파일은 바꾸지 않습니다. 갤러리에 반영되기까지 잠깐 걸릴 수 있습니다.'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
]), ]),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
@ -4359,7 +4359,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
]), ]),
SwitchListTile(value: desc, title: Text(tr('거꾸로')), onChanged: (x) => set(() => desc = x)), SwitchListTile(value: desc, title: Text(tr('거꾸로')), onChanged: (x) => set(() => desc = x)),
if (c.settings.explorerSortPerFolder) if (c.settings.explorerSortPerFolder)
Text(trf('이 폴더에만: {0}', [_displayPath(dir)]), style: const TextStyle(fontSize: 12, color: JjColors.textDim)), Text(trf('이 폴더에만: {0}', [_displayPath(dir)]), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
]), ]),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
@ -4468,7 +4468,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
Padding( Padding(
padding: const EdgeInsets.only(top: 6, left: 4), padding: const EdgeInsets.only(top: 6, left: 4),
child: Text(tr('왼쪽에 폴더, 오른쪽에 왼쪽에서 고른 폴더의 파일들을 보여 줍니다 (Windows 탐색기처럼).'), child: Text(tr('왼쪽에 폴더, 오른쪽에 왼쪽에서 고른 폴더의 파일들을 보여 줍니다 (Windows 탐색기처럼).'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
), ),
if (layout != 'single') if (layout != 'single')
group(tr('두 창 배치'), orient, [ group(tr('두 창 배치'), orient, [
@ -4485,7 +4485,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
Padding( Padding(
padding: const EdgeInsets.only(top: 8, left: 4), padding: const EdgeInsets.only(top: 8, left: 4),
child: Text(tr('숨겨도 위쪽 ⋮ 메뉴에서 창 배치 · 버튼 구성을 바꿀 수 있습니다.'), child: Text(tr('숨겨도 위쪽 ⋮ 메뉴에서 창 배치 · 버튼 구성을 바꿀 수 있습니다.'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
), ),
]), ]),
), ),
@ -4523,7 +4523,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
height: 460, height: 460,
child: Column(children: [ child: Column(children: [
Text(tr('보일 버튼을 고르고, 끌어서 순서를 바꿉니다.'), Text(tr('보일 버튼을 고르고, 끌어서 순서를 바꿉니다.'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
const SizedBox(height: 8), const SizedBox(height: 8),
Expanded( Expanded(
child: ReorderableListView( child: ReorderableListView(
@ -4710,7 +4710,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
), ),
], ],
child: Text(_displayPath(_pane.current), child: Text(_displayPath(_pane.current),
maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: JjColors.textDim)), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: JjColors.textDim)),
), ),
), ),
PopupMenuButton<Object>( PopupMenuButton<Object>(
@ -5082,8 +5082,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(mainAxisSize: MainAxisSize.min, children: [ child: Row(mainAxisSize: MainAxisSize.min, children: [
Text(tr('고르기 도구'), style: const TextStyle(fontSize: 13, color: JjColors.accent)), Text(tr('고르기 도구'), style: TextStyle(fontSize: 13, color: JjColors.accent)),
const Icon(Icons.arrow_drop_down, size: 18, color: JjColors.accent), Icon(Icons.arrow_drop_down, size: 18, color: JjColors.accent),
]), ]),
), ),
), ),
@ -5188,7 +5188,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
height: 40, height: 40,
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(_displayPath(dir), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: JjColors.textDim)), child: Text(_displayPath(dir), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: JjColors.textDim)),
), ),
const Divider(height: 1), const Divider(height: 1),
if (pane.filterOpen) _filterBar(pane, shown.length), if (pane.filterOpen) _filterBar(pane, shown.length),
@ -5207,7 +5207,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
child: pane.loading.contains(dir) && !pane.cache.containsKey(dir) child: pane.loading.contains(dir) && !pane.cache.containsKey(dir)
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: entries.isEmpty && !up : entries.isEmpty && !up
? Center(child: Text(tr('비어 있음'), style: const TextStyle(color: JjColors.textDim))) ? Center(child: Text(tr('비어 있음'), style: TextStyle(color: JjColors.textDim)))
: scaleListText(context, _listTextScale, ListView.builder( : scaleListText(context, _listTextScale, ListView.builder(
controller: pane.listScroll, controller: pane.listScroll,
itemCount: rows.length, itemCount: rows.length,
@ -5898,7 +5898,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
feedback: dragFeedback(_dragPaths(pane, e)), feedback: dragFeedback(_dragPaths(pane, e)),
child: Tooltip( child: Tooltip(
message: tr('끌어서 다른 창으로'), message: tr('끌어서 다른 창으로'),
child: const SizedBox(width: 28, height: 36, child: Icon(Icons.drag_indicator, size: 18, color: JjColors.textDim)), child: SizedBox(width: 28, height: 36, child: Icon(Icons.drag_indicator, size: 18, color: JjColors.textDim)),
), ),
), ),
// 표시 동그라미는 두 번 누르기 영역 밖 (기다리지 않고 바로) // 표시 동그라미는 두 번 누르기 영역 밖 (기다리지 않고 바로)
@ -6260,7 +6260,7 @@ class _MetaText extends StatelessWidget {
builder: (_, s) => Text.rich( builder: (_, s) => Text.rich(
TextSpan(children: [ TextSpan(children: [
if (lead.isNotEmpty) TextSpan(text: lead, style: leadStyle), if (lead.isNotEmpty) TextSpan(text: lead, style: leadStyle),
if ((s.data ?? '').isNotEmpty) TextSpan(text: '${lead.isEmpty ? '' : ' '}${s.data}', style: const TextStyle(fontSize: 11, color: JjColors.accent)), if ((s.data ?? '').isNotEmpty) TextSpan(text: '${lead.isEmpty ? '' : ' '}${s.data}', style: TextStyle(fontSize: 11, color: JjColors.accent)),
]), ]),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis), overflow: TextOverflow.ellipsis),
@ -6319,7 +6319,7 @@ class TransferProgressChip extends StatelessWidget {
child: Row(mainAxisSize: MainAxisSize.min, children: [ child: Row(mainAxisSize: MainAxisSize.min, children: [
// 멈춤은 진행 고리 대신 경고 표시 (꽉 찬 고리는 "끝났다" 로 보였다) // 멈춤은 진행 고리 대신 경고 표시 (꽉 찬 고리는 "끝났다" 로 보였다)
if (failed) if (failed)
const Icon(Icons.error_outline, size: 16, color: JjColors.danger) Icon(Icons.error_outline, size: 16, color: JjColors.danger)
else else
SizedBox( SizedBox(
width: 14, width: 14,
@ -6387,7 +6387,7 @@ class _Thumb extends StatelessWidget {
future: _Thumbs.of(c, entry), future: _Thumbs.of(c, entry),
builder: (_, s) { builder: (_, s) {
final f = s.data; final f = s.data;
if (f == null) return const Icon(Icons.movie_outlined, size: 28, color: JjColors.textDim); if (f == null) return Icon(Icons.movie_outlined, size: 28, color: JjColors.textDim);
return ClipRRect( return ClipRRect(
borderRadius: BorderRadius.circular(3), borderRadius: BorderRadius.circular(3),
child: Stack(alignment: Alignment.center, children: [ child: Stack(alignment: Alignment.center, children: [

View File

@ -224,7 +224,7 @@ class _ConditionsDialogState extends State<_ConditionsDialog> {
Padding( Padding(
padding: const EdgeInsets.only(top: 8), padding: const EdgeInsets.only(top: 8),
child: Text(tr('크기 · 바뀐 날 · 종류를 넣으면 파일만 찾습니다.'), child: Text(tr('크기 · 바뀐 날 · 종류를 넣으면 파일만 찾습니다.'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
), ),
]), ]),
), ),
@ -398,7 +398,7 @@ class _SearchResultsPageState extends State<SearchResultsPage> {
child: Align( child: Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: Text('${widget.cond.summary} · ${widget.root}', child: Text('${widget.cond.summary} · ${widget.root}',
maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: JjColors.textDim)), maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: JjColors.textDim)),
), ),
), ),
Expanded( Expanded(
@ -473,7 +473,7 @@ class _SearchResultsPageState extends State<SearchResultsPage> {
for (final (dir, why) in _skipped.take(50)) for (final (dir, why) in _skipped.take(50))
Align( Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: Text('$dir · $why', style: const TextStyle(fontSize: 11, color: JjColors.textDim)), child: Text('$dir · $why', style: TextStyle(fontSize: 11, color: JjColors.textDim)),
), ),
], ],
), ),
@ -483,7 +483,7 @@ class _SearchResultsPageState extends State<SearchResultsPage> {
SafeArea( SafeArea(
top: false, top: false,
child: Container( child: Container(
decoration: const BoxDecoration(border: Border(top: BorderSide(color: JjColors.border))), decoration: BoxDecoration(border: Border(top: BorderSide(color: JjColors.border))),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Wrap(spacing: 4, runSpacing: 2, crossAxisAlignment: WrapCrossAlignment.center, children: [ child: Wrap(spacing: 4, runSpacing: 2, crossAxisAlignment: WrapCrossAlignment.center, children: [
Text(_summary(_targets), style: const TextStyle(fontSize: 12)), Text(_summary(_targets), style: const TextStyle(fontSize: 12)),
@ -499,8 +499,8 @@ class _SearchResultsPageState extends State<SearchResultsPage> {
), ),
TextButton.icon( TextButton.icon(
onPressed: () => _act('delete'), onPressed: () => _act('delete'),
icon: const Icon(Icons.delete_outline, size: 18, color: JjColors.danger), icon: Icon(Icons.delete_outline, size: 18, color: JjColors.danger),
label: Text(tr('지우기'), style: const TextStyle(color: JjColors.danger)), label: Text(tr('지우기'), style: TextStyle(color: JjColors.danger)),
), ),
]), ]),
), ),

View File

@ -81,7 +81,7 @@ Future<void> showFileHash(BuildContext context, String path) async {
content: SizedBox( content: SizedBox(
width: 560, width: 560,
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
if (error != null) Text(trf('읽지 못했습니다: {0}', [error]), style: const TextStyle(color: JjColors.danger)), if (error != null) Text(trf('읽지 못했습니다: {0}', [error]), style: TextStyle(color: JjColors.danger)),
if (result == null && error == null) LinearProgressIndicator(value: progress), if (result == null && error == null) LinearProgressIndicator(value: progress),
if (result != null) ...[ if (result != null) ...[
line('MD5', result!.md5), line('MD5', result!.md5),
@ -196,7 +196,7 @@ class _HexViewerPageState extends State<HexViewerPage> {
child: Row(children: [ child: Row(children: [
Expanded( Expanded(
child: Text(trf('{0} / {1} 읽음', [formatSize(_data.length), formatSize(_size)]), child: Text(trf('{0} / {1} 읽음', [formatSize(_data.length), formatSize(_size)]),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
), ),
FilledButton.tonal(onPressed: _more, child: Text(tr('더 읽기'))), FilledButton.tonal(onPressed: _more, child: Text(tr('더 읽기'))),
]), ]),
@ -246,7 +246,7 @@ Future<void> showFolderProperties(BuildContext context, String dir) async {
builder: (_, s) { builder: (_, s) {
final r = s.data; final r = s.data;
return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
SelectableText(dir, style: const TextStyle(fontSize: 12, color: JjColors.textDim)), SelectableText(dir, style: TextStyle(fontSize: 12, color: JjColors.textDim)),
const SizedBox(height: 8), const SizedBox(height: 8),
if (r == null) const LinearProgressIndicator(), if (r == null) const LinearProgressIndicator(),
if (r != null) ...[ if (r != null) ...[

View File

@ -46,12 +46,12 @@ class FtpSettings extends StatelessWidget {
].join(' · '), ].join(' · '),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: s.tls ? null : const TextStyle(color: JjColors.danger)), style: s.tls ? null : TextStyle(color: JjColors.danger)),
trailing: Row(mainAxisSize: MainAxisSize.min, children: [ trailing: Row(mainAxisSize: MainAxisSize.min, children: [
IconButton(tooltip: tr('고치기'), icon: const Icon(Icons.edit_outlined, size: 18), onPressed: () => editFtpServer(context, c, old: s)), IconButton(tooltip: tr('고치기'), icon: const Icon(Icons.edit_outlined, size: 18), onPressed: () => editFtpServer(context, c, old: s)),
IconButton( IconButton(
tooltip: tr('지우기'), tooltip: tr('지우기'),
icon: const Icon(Icons.delete_outline, size: 18, color: JjColors.textDim), icon: Icon(Icons.delete_outline, size: 18, color: JjColors.textDim),
onPressed: () => deleteFtpServer(context, c, s), onPressed: () => deleteFtpServer(context, c, s),
), ),
]), ]),
@ -92,7 +92,7 @@ Future<bool> askTrustFtpCert(BuildContext context, String serverLabel, FtpCertEx
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
scrollable: true, scrollable: true,
icon: const Icon(Icons.gpp_bad_outlined, color: JjColors.danger), icon: Icon(Icons.gpp_bad_outlined, color: JjColors.danger),
title: Text(trf('"{0}" 서버의 인증서가 바뀌었습니다', [serverLabel])), title: Text(trf('"{0}" 서버의 인증서가 바뀌었습니다', [serverLabel])),
content: SelectableText( content: SelectableText(
'${tr('중간에서 가로채는 공격일 수 있어 연결하지 않았습니다. 서버 인증서를 바꾼 것이 확실할 때만, 서버 설정의 [믿은 인증서 지우기] 뒤 다시 연결하세요.')}\n\n' '${tr('중간에서 가로채는 공격일 수 있어 연결하지 않았습니다. 서버 인증서를 바꾼 것이 확실할 때만, 서버 설정의 [믿은 인증서 지우기] 뒤 다시 연결하세요.')}\n\n'
@ -223,7 +223,7 @@ Future<FtpServer?> editFtpServer(BuildContext context, AppController c, {FtpServ
), ),
]), ]),
const SizedBox(height: 8), const SizedBox(height: 8),
Text(tr('암호화'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)), Text(tr('암호화'), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
RadioGroup<String>( RadioGroup<String>(
groupValue: security, groupValue: security,
onChanged: (x) => set(() { onChanged: (x) => set(() {
@ -238,7 +238,7 @@ Future<FtpServer?> editFtpServer(BuildContext context, AppController c, {FtpServ
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
dense: true, dense: true,
value: v, value: v,
title: Text(ftpSecurityLabel(v), style: v == 'none' ? const TextStyle(color: JjColors.danger) : null), title: Text(ftpSecurityLabel(v), style: v == 'none' ? TextStyle(color: JjColors.danger) : null),
), ),
]), ]),
), ),
@ -248,7 +248,7 @@ Future<FtpServer?> editFtpServer(BuildContext context, AppController c, {FtpServ
color: JjColors.danger.withValues(alpha: 0.12), color: JjColors.danger.withValues(alpha: 0.12),
child: Text( child: Text(
tr('⚠ 평문 FTP 는 아이디 · 비밀번호 · 파일이 암호화되지 않은 채 오갑니다. 같은 망의 다른 사람이 볼 수 있습니다. 서버가 FTPS 를 못 할 때만, 믿을 수 있는 집 안 망에서만 쓰세요.'), tr('⚠ 평문 FTP 는 아이디 · 비밀번호 · 파일이 암호화되지 않은 채 오갑니다. 같은 망의 다른 사람이 볼 수 있습니다. 서버가 FTPS 를 못 할 때만, 믿을 수 있는 집 안 망에서만 쓰세요.'),
style: const TextStyle(fontSize: 12, color: JjColors.danger)), style: TextStyle(fontSize: 12, color: JjColors.danger)),
), ),
TextField(controller: user, decoration: InputDecoration(labelText: tr('아이디 (비우면 익명)'))), TextField(controller: user, decoration: InputDecoration(labelText: tr('아이디 (비우면 익명)'))),
TextField( TextField(
@ -273,7 +273,7 @@ Future<FtpServer?> editFtpServer(BuildContext context, AppController c, {FtpServ
fingerprint.isEmpty fingerprint.isEmpty
? tr('인증서: 공인 인증서면 그대로 연결, 자체 서명이면 [연결 확인] 때 지문을 보이고 묻습니다') ? tr('인증서: 공인 인증서면 그대로 연결, 자체 서명이면 [연결 확인] 때 지문을 보이고 묻습니다')
: '${tr('믿은 인증서')}: $fingerprint', : '${tr('믿은 인증서')}: $fingerprint',
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
if (fingerprint.isNotEmpty) if (fingerprint.isNotEmpty)
Align( Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,

View File

@ -28,7 +28,7 @@ Future<void> showHiddenItems(BuildContext context, AppController c) => showDialo
width: 520, width: 520,
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(tr('풀면 파일 탐색기 목록에 다시 보입니다. 파일 자체는 바꾸지 않습니다.'), Text(tr('풀면 파일 탐색기 목록에 다시 보입니다. 파일 자체는 바꾸지 않습니다.'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
const SizedBox(height: 8), const SizedBox(height: 8),
if (list.isEmpty) Text(tr('숨긴 항목이 없습니다')), if (list.isEmpty) Text(tr('숨긴 항목이 없습니다')),
for (final x in list) for (final x in list)

View File

@ -155,7 +155,7 @@ class _TopBar extends StatelessWidget {
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(trf('지금: {0}', [targets.map((v) => v.fileName).join(', ')])), Text(trf('지금: {0}', [targets.map((v) => v.fileName).join(', ')])),
const SizedBox(height: 4), const SizedBox(height: 4),
Text(tr('하나씩 멈추려면 목록 줄의 ■ 를 누르세요.'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)), Text(tr('하나씩 멈추려면 목록 줄의 ■ 를 누르세요.'), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
]), ]),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('그대로 두기'))), TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('그대로 두기'))),
@ -185,7 +185,7 @@ class _TopBar extends StatelessWidget {
Text(trf('지금: {0}', [now.isEmpty ? (c.currentJob ?? '') : now])), Text(trf('지금: {0}', [now.isEmpty ? (c.currentJob ?? '') : now])),
const SizedBox(height: 4), const SizedBox(height: 4),
Text(trf('남은 것: MKV 만들기 {0}개{1}', [inBatch, c.pendingJobs.isEmpty ? '' : ' · ${c.pendingJobs.join(' · ')}']), Text(trf('남은 것: MKV 만들기 {0}개{1}', [inBatch, c.pendingJobs.isEmpty ? '' : ' · ${c.pendingJobs.join(' · ')}']),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
]), ]),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('그대로 두기'))), TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('그대로 두기'))),
@ -218,7 +218,7 @@ class _TopBar extends StatelessWidget {
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(trf('지금: {0}', [c.currentJob ?? ''])), Text(trf('지금: {0}', [c.currentJob ?? ''])),
const SizedBox(height: 4), const SizedBox(height: 4),
Text(trf('대기 중: {0}', [c.pendingJobs.join(' · ')]), style: const TextStyle(fontSize: 12, color: JjColors.textDim)), Text(trf('대기 중: {0}', [c.pendingJobs.join(' · ')]), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
]), ]),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('닫기'))), TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('닫기'))),
@ -395,7 +395,7 @@ class _TopBar extends StatelessWidget {
// 138: AI 버튼은 [MKV 만들기] 와 떨어뜨려 맨 뒤에, 구분선 너머로 // 138: AI 버튼은 [MKV 만들기] 와 떨어뜨려 맨 뒤에, 구분선 너머로
if (hasAi) ...[ if (hasAi) ...[
const SizedBox(width: 8), const SizedBox(width: 8),
const SizedBox(height: 28, child: VerticalDivider(width: 16, color: JjColors.border)), SizedBox(height: 28, child: VerticalDivider(width: 16, color: JjColors.border)),
...aiButtons(true), ...aiButtons(true),
], ],
]), ]),
@ -568,7 +568,7 @@ class _EncodeBar extends StatelessWidget {
Widget label(String t) => Padding( Widget label(String t) => Padding(
padding: const EdgeInsets.only(right: 6), padding: const EdgeInsets.only(right: 6),
child: Text(t, style: const TextStyle(fontSize: 12, color: JjColors.textDim)), child: Text(t, style: TextStyle(fontSize: 12, color: JjColors.textDim)),
); );
// 253: 글자가 크면 고르기 상자가 화면보다 넓어져 넘쳤다 - 제 폭 (가장 긴 항목) 을 쓰되 화면 폭을 넘지 않게 (글은 …) // 253: 글자가 크면 고르기 상자가 화면보다 넓어져 넘쳤다 - 제 폭 (가장 긴 항목) 을 쓰되 화면 폭을 넘지 않게 (글은 …)
final maxDrop = (MediaQuery.sizeOf(context).width - 40).clamp(120.0, double.infinity); final maxDrop = (MediaQuery.sizeOf(context).width - 40).clamp(120.0, double.infinity);
@ -582,7 +582,7 @@ class _EncodeBar extends StatelessWidget {
isDense: true, isDense: true,
isExpanded: true, isExpanded: true,
underline: const SizedBox(), underline: const SizedBox(),
style: const TextStyle(fontSize: 13, color: JjColors.text), style: TextStyle(fontSize: 13, color: JjColors.text),
items: [ items: [
for (final i in items) for (final i in items)
DropdownMenuItem( DropdownMenuItem(
@ -741,7 +741,7 @@ class _VideoList extends StatelessWidget {
? trf('동영상 {0}개 · 전체 선택', [c.videos.length]) ? trf('동영상 {0}개 · 전체 선택', [c.videos.length])
: trf('선택 {0} / {1}개', [c.checked.length, c.videos.length]), : trf('선택 {0} / {1}개', [c.checked.length, c.videos.length]),
maxLines: 1, maxLines: 1,
style: const TextStyle(color: JjColors.textDim, fontSize: 12)), style: TextStyle(color: JjColors.textDim, fontSize: 12)),
), ),
), ),
), ),
@ -753,14 +753,14 @@ class _VideoList extends StatelessWidget {
tooltip: tr('선택한 동영상을 목록에서 제거'), tooltip: tr('선택한 동영상을 목록에서 제거'),
iconSize: 18, iconSize: 18,
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
icon: const Icon(Icons.playlist_remove, color: JjColors.textDim), icon: Icon(Icons.playlist_remove, color: JjColors.textDim),
onPressed: c.removeChecked, onPressed: c.removeChecked,
), ),
IconButton( IconButton(
tooltip: tr('모두 지우기'), tooltip: tr('모두 지우기'),
iconSize: 18, iconSize: 18,
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
icon: const Icon(Icons.clear_all, color: JjColors.textDim), icon: Icon(Icons.clear_all, color: JjColors.textDim),
// 46: 확인 뒤 (목록에서만 빼고 파일은 그대로) // 46: 확인 뒤 (목록에서만 빼고 파일은 그대로)
onPressed: c.busy || c.videos.isEmpty onPressed: c.busy || c.videos.isEmpty
? null ? null
@ -917,10 +917,10 @@ class _VideoTile extends StatelessWidget {
Padding( Padding(
padding: const EdgeInsets.only(top: 3), padding: const EdgeInsets.only(top: 3),
child: Text(v.phase!, child: Text(v.phase!,
style: const TextStyle(fontSize: 11, color: JjColors.accent)), style: TextStyle(fontSize: 11, color: JjColors.accent)),
), ),
] else if (v.phase != null) ] else if (v.phase != null)
Text(v.phase!, style: const TextStyle(fontSize: 11, color: JjColors.textDim)) Text(v.phase!, style: TextStyle(fontSize: 11, color: JjColors.textDim))
else else
Text( Text(
v.status == JobStatus.failed v.status == JobStatus.failed
@ -943,7 +943,7 @@ class _VideoTile extends StatelessWidget {
tooltip: c.inCurrentBatch(v) ? tr('다시 만들기 대기로 (지금 묶음이 끝난 뒤)') : tr('다시 만들기 대기로'), tooltip: c.inCurrentBatch(v) ? tr('다시 만들기 대기로 (지금 묶음이 끝난 뒤)') : tr('다시 만들기 대기로'),
iconSize: 16, iconSize: 16,
onPressed: c.inCurrentBatch(v) ? null : () => c.resetStatus(v), onPressed: c.inCurrentBatch(v) ? null : () => c.resetStatus(v),
icon: const Icon(Icons.refresh, color: JjColors.textDim), icon: Icon(Icons.refresh, color: JjColors.textDim),
), ),
// 240: 만드는 중인 동영상 하나만 취소 (다른 것 · 남은 것은 그대로) // 240: 만드는 중인 동영상 하나만 취소 (다른 것 · 남은 것은 그대로)
if (v.status == JobStatus.running && c.buildingMkv) if (v.status == JobStatus.running && c.buildingMkv)
@ -951,13 +951,13 @@ class _VideoTile extends StatelessWidget {
tooltip: tr('이 동영상만 취소'), tooltip: tr('이 동영상만 취소'),
iconSize: 16, iconSize: 16,
onPressed: () => c.cancelVideos([v]), onPressed: () => c.cancelVideos([v]),
icon: const Icon(Icons.stop, color: JjColors.danger), icon: Icon(Icons.stop, color: JjColors.danger),
), ),
IconButton( IconButton(
tooltip: tr('목록에서 제거'), tooltip: tr('목록에서 제거'),
iconSize: 16, iconSize: 16,
onPressed: v.status == JobStatus.running ? null : () => c.removeVideo(v), onPressed: v.status == JobStatus.running ? null : () => c.removeVideo(v),
icon: const Icon(Icons.close, color: JjColors.textDim), icon: Icon(Icons.close, color: JjColors.textDim),
), ),
], ],
), ),
@ -973,16 +973,16 @@ class _StatusIcon extends StatelessWidget {
@override @override
Widget build(BuildContext context) => switch (s) { Widget build(BuildContext context) => switch (s) {
JobStatus.ready => JobStatus.ready =>
const Icon(Icons.movie_outlined, size: 20, color: JjColors.textDim), Icon(Icons.movie_outlined, size: 20, color: JjColors.textDim),
JobStatus.running => JobStatus.running =>
const Icon(Icons.autorenew, size: 20, color: JjColors.accent), Icon(Icons.autorenew, size: 20, color: JjColors.accent),
JobStatus.done => JobStatus.done =>
const Icon(Icons.check_circle, size: 20, color: JjColors.success), Icon(Icons.check_circle, size: 20, color: JjColors.success),
JobStatus.failed => JobStatus.failed =>
const Icon(Icons.error, size: 20, color: JjColors.danger), Icon(Icons.error, size: 20, color: JjColors.danger),
// 187: 취소는 실패가 아니라 회색 // 187: 취소는 실패가 아니라 회색
JobStatus.cancelled => JobStatus.cancelled =>
const Icon(Icons.cancel_outlined, size: 20, color: JjColors.textDim), Icon(Icons.cancel_outlined, size: 20, color: JjColors.textDim),
}; };
} }
@ -1032,12 +1032,12 @@ class _PickHint extends StatelessWidget {
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column(mainAxisSize: MainAxisSize.min, children: [ child: Column(mainAxisSize: MainAxisSize.min, children: [
const Icon(Icons.touch_app_outlined, size: 40, color: JjColors.textDim), Icon(Icons.touch_app_outlined, size: 40, color: JjColors.textDim),
const SizedBox(height: 8), const SizedBox(height: 8),
Text(tr('고른 동영상이 없습니다'), textAlign: TextAlign.center, style: const TextStyle(color: JjColors.textDim)), Text(tr('고른 동영상이 없습니다'), textAlign: TextAlign.center, style: TextStyle(color: JjColors.textDim)),
const SizedBox(height: 4), const SizedBox(height: 4),
Text(tr('목록에서 동영상을 누르면 여기에 자세히 보입니다'), Text(tr('목록에서 동영상을 누르면 여기에 자세히 보입니다'),
textAlign: TextAlign.center, style: const TextStyle(color: JjColors.textDim, fontSize: 12)), textAlign: TextAlign.center, style: TextStyle(color: JjColors.textDim, fontSize: 12)),
]), ]),
), ),
); );
@ -1056,10 +1056,10 @@ class _EmptyHint extends StatelessWidget {
children: [ children: [
Icon(Icons.video_library_outlined, size: 56, color: JjColors.textDim), Icon(Icons.video_library_outlined, size: 56, color: JjColors.textDim),
const SizedBox(height: 12), const SizedBox(height: 12),
Text(tr('"동영상 추가" 로 파일을 선택하세요'), textAlign: TextAlign.center, style: const TextStyle(color: JjColors.textDim)), Text(tr('"동영상 추가" 로 파일을 선택하세요'), textAlign: TextAlign.center, style: TextStyle(color: JjColors.textDim)),
const SizedBox(height: 4), const SizedBox(height: 4),
Text(tr('같은 폴더의 자막은 자동으로 추가됩니다'), Text(tr('같은 폴더의 자막은 자동으로 추가됩니다'),
textAlign: TextAlign.center, style: const TextStyle(color: JjColors.textDim, fontSize: 12)), textAlign: TextAlign.center, style: TextStyle(color: JjColors.textDim, fontSize: 12)),
if (onAdd != null) ...[ if (onAdd != null) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
FilledButton.icon(onPressed: onAdd, icon: const Icon(Icons.add), label: Text(tr('동영상 추가'))), FilledButton.icon(onPressed: onAdd, icon: const Icon(Icons.add), label: Text(tr('동영상 추가'))),
@ -1142,7 +1142,7 @@ class _VideoDetail extends StatelessWidget {
? null ? null
: Wrap(crossAxisAlignment: WrapCrossAlignment.center, spacing: 8, children: [ : Wrap(crossAxisAlignment: WrapCrossAlignment.center, spacing: 8, children: [
Text(trf('감지한 언어: {0} (자동 감지)', [languageOf(v.aiDetected).name]), Text(trf('감지한 언어: {0} (자동 감지)', [languageOf(v.aiDetected).name]),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
TextButton( TextButton(
onPressed: c.busy onPressed: c.busy
? null ? null
@ -1207,7 +1207,7 @@ class _VideoDetail extends StatelessWidget {
]), ]),
const SizedBox(height: 4), const SizedBox(height: 4),
SelectableText(v.path, SelectableText(v.path,
style: const TextStyle(color: JjColors.textDim, fontSize: 12)), style: TextStyle(color: JjColors.textDim, fontSize: 12)),
const SizedBox(height: 12), const SizedBox(height: 12),
Wrap(spacing: 8, runSpacing: 8, children: [ Wrap(spacing: 8, runSpacing: 8, children: [
if (info?.duration != null) _Chip(trf('길이 {0}', [_fmt(info!.duration!)])), if (info?.duration != null) _Chip(trf('길이 {0}', [_fmt(info!.duration!)])),
@ -1237,7 +1237,7 @@ class _VideoDetail extends StatelessWidget {
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
), ),
child: SelectableText(v.message!, child: SelectableText(v.message!,
style: const TextStyle(color: JjColors.danger, fontSize: 12)), style: TextStyle(color: JjColors.danger, fontSize: 12)),
), ),
], ],
if (!compact) ...[ if (!compact) ...[
@ -1254,7 +1254,7 @@ class _VideoDetail extends StatelessWidget {
_SubtitleRow(c: c, v: v, s: s, locked: locked), _SubtitleRow(c: c, v: v, s: s, locked: locked),
const SizedBox(height: 24), const SizedBox(height: 24),
Text(trf('출력: {0}', [outputMkvPath(v.path)]), Text(trf('출력: {0}', [outputMkvPath(v.path)]),
style: const TextStyle(color: JjColors.textDim, fontSize: 12)), style: TextStyle(color: JjColors.textDim, fontSize: 12)),
], ],
); );
} }
@ -1345,14 +1345,14 @@ class _SubtitleRow extends StatelessWidget {
onPressed: locked || off || !c.canEdit(s) onPressed: locked || off || !c.canEdit(s)
? null ? null
: () => openSubtitleEditor(context, c, v, s), : () => openSubtitleEditor(context, c, v, s),
icon: const Icon(Icons.edit_note, color: JjColors.accent), icon: Icon(Icons.edit_note, color: JjColors.accent),
), ),
if (c.services.createTranslator != null) if (c.services.createTranslator != null)
IconButton( IconButton(
tooltip: c.canEdit(s) ? trf('다른 언어로 번역 (AI · 이 {0}에서)', [Platform.isAndroid ? tr('기기') : 'PC']) : tr('이미지 자막은 번역할 수 없습니다'), tooltip: c.canEdit(s) ? trf('다른 언어로 번역 (AI · 이 {0}에서)', [Platform.isAndroid ? tr('기기') : 'PC']) : tr('이미지 자막은 번역할 수 없습니다'),
iconSize: 18, iconSize: 18,
onPressed: locked || off || !c.canEdit(s) ? null : () => showTranslateDialog(context, c, v, s), onPressed: locked || off || !c.canEdit(s) ? null : () => showTranslateDialog(context, c, v, s),
icon: const Icon(Icons.translate, color: JjColors.accent), icon: Icon(Icons.translate, color: JjColors.accent),
), ),
IconButton( IconButton(
tooltip: embedded ? (off ? tr('되살리기') : tr('삭제 (출력에서 제외)')) : tr('목록에서 제거'), tooltip: embedded ? (off ? tr('되살리기') : tr('삭제 (출력에서 제외)')) : tr('목록에서 제거'),
@ -1381,7 +1381,7 @@ class _LanguageDropdown extends StatelessWidget {
value: items.contains(value) ? value : undetermined, value: items.contains(value) ? value : undetermined,
isDense: true, isDense: true,
underline: const SizedBox(), underline: const SizedBox(),
style: const TextStyle(fontSize: 12, color: JjColors.text), style: TextStyle(fontSize: 12, color: JjColors.text),
items: [ items: [
for (final l in items) for (final l in items)
DropdownMenuItem( DropdownMenuItem(
@ -1404,7 +1404,7 @@ class _CharsetDropdown extends StatelessWidget {
value: supportedCharsets.containsKey(value) ? value : 'UTF-8', value: supportedCharsets.containsKey(value) ? value : 'UTF-8',
isDense: true, isDense: true,
underline: const SizedBox(), underline: const SizedBox(),
style: const TextStyle(fontSize: 12, color: JjColors.text), style: TextStyle(fontSize: 12, color: JjColors.text),
items: [ items: [
for (final e in supportedCharsets.entries) for (final e in supportedCharsets.entries)
DropdownMenuItem(value: e.key, child: Text(e.value)), DropdownMenuItem(value: e.key, child: Text(e.value)),
@ -1433,7 +1433,7 @@ class _LogPanel extends StatelessWidget {
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 44, 0), padding: const EdgeInsets.fromLTRB(16, 12, 44, 0),
child: Text(tr('작업 기록이 아직 없습니다. MKV 만들기 · 자막 · 다운로드를 하면 여기에 차례로 남습니다.'), child: Text(tr('작업 기록이 아직 없습니다. MKV 만들기 · 자막 · 다운로드를 하면 여기에 차례로 남습니다.'),
maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: JjColors.textDim)), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: JjColors.textDim)),
), ),
), ),
Positioned.fill( Positioned.fill(
@ -1443,7 +1443,7 @@ class _LogPanel extends StatelessWidget {
itemCount: logs.length, itemCount: logs.length,
itemBuilder: (_, i) => SelectableText( itemBuilder: (_, i) => SelectableText(
logs[logs.length - 1 - i], logs[logs.length - 1 - i],
style: const TextStyle( style: TextStyle(
fontSize: 12, fontFamily: 'Consolas', color: JjColors.textDim), fontSize: 12, fontFamily: 'Consolas', color: JjColors.textDim),
), ),
), ),
@ -1456,7 +1456,7 @@ class _LogPanel extends StatelessWidget {
tooltip: tr('작업 기록 숨기기'), tooltip: tr('작업 기록 숨기기'),
iconSize: 16, iconSize: 16,
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
icon: const Icon(Icons.close, color: JjColors.textDim), icon: Icon(Icons.close, color: JjColors.textDim),
onPressed: onClose, onPressed: onClose,
), ),
), ),
@ -1483,10 +1483,10 @@ class _LogStrip extends StatelessWidget {
child: Text(last, child: Text(last,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 11, fontFamily: 'Consolas', color: JjColors.textDim)), style: TextStyle(fontSize: 11, fontFamily: 'Consolas', color: JjColors.textDim)),
), ),
Text(tr('작업 기록 보기'), style: TextStyle(fontSize: 11, color: JjColors.accent)), Text(tr('작업 기록 보기'), style: TextStyle(fontSize: 11, color: JjColors.accent)),
const Icon(Icons.expand_less, size: 16, color: JjColors.accent), Icon(Icons.expand_less, size: 16, color: JjColors.accent),
const SizedBox(width: 12), const SizedBox(width: 12),
]), ]),
), ),

View File

@ -19,7 +19,7 @@ class LanguageSettings extends StatelessWidget {
final added = i18n.available.where((c) => !I18nController.builtIn.contains(c)).toList(); final added = i18n.available.where((c) => !I18nController.builtIn.contains(c)).toList();
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
SettingTile( SettingTile(
leading: const Icon(Icons.translate, color: JjColors.accent), leading: Icon(Icons.translate, color: JjColors.accent),
// 읽을 수 없는 언어를 골라도 찾을 수 있게 영어를 함께 // 읽을 수 없는 언어를 골라도 찾을 수 있게 영어를 함께
title: Text(uiLanguage == 'en' ? 'Language' : '${tr('화면 언어')} · Language'), title: Text(uiLanguage == 'en' ? 'Language' : '${tr('화면 언어')} · Language'),
subtitle: Text(tr('프로그램의 모든 글자를 고른 언어로 보여 줍니다. 목록에 없는 언어는 [언어 추가] 로 ' subtitle: Text(tr('프로그램의 모든 글자를 고른 언어로 보여 줍니다. 목록에 없는 언어는 [언어 추가] 로 '
@ -58,7 +58,7 @@ class LanguageSettings extends StatelessWidget {
), ),
if (!i18n.canTranslate) if (!i18n.canTranslate)
Text(tr('(AI 번역을 쓸 수 없는 기기라 언어를 더할 수 없습니다)'), Text(tr('(AI 번역을 쓸 수 없는 기기라 언어를 더할 수 없습니다)'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
]), ]),
), ),
for (final code in added) for (final code in added)
@ -118,7 +118,7 @@ Future<void> addLanguageDialog(BuildContext context) async {
LinearProgressIndicator(value: s.$2), LinearProgressIndicator(value: s.$2),
const SizedBox(height: 8), const SizedBox(height: 8),
Text(tr('화면 글자 전체를 이 기기에서 번역합니다. 몇 분 걸릴 수 있습니다.'), Text(tr('화면 글자 전체를 이 기기에서 번역합니다. 몇 분 걸릴 수 있습니다.'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
const NllbLicenseNote(), const NllbLicenseNote(),
]), ]),
), ),

View File

@ -104,7 +104,7 @@ class NllbLicenseNote extends StatelessWidget {
@override @override
Widget build(BuildContext context) => Wrap(crossAxisAlignment: WrapCrossAlignment.center, children: [ Widget build(BuildContext context) => Wrap(crossAxisAlignment: WrapCrossAlignment.center, children: [
Text(tr('번역 모델 라이선스: CC BY-NC 4.0 (비상업적 이용만 허용)'), Text(tr('번역 모델 라이선스: CC BY-NC 4.0 (비상업적 이용만 허용)'),
style: const TextStyle(fontSize: 11, color: JjColors.textDim)), style: TextStyle(fontSize: 11, color: JjColors.textDim)),
TextButton( TextButton(
style: TextButton.styleFrom(visualDensity: VisualDensity.compact, padding: const EdgeInsets.symmetric(horizontal: 6)), style: TextButton.styleFrom(visualDensity: VisualDensity.compact, padding: const EdgeInsets.symmetric(horizontal: 6)),
onPressed: () => showLicenseDocument(context, 'CC BY-NC 4.0', nllbLicenseAsset), onPressed: () => showLicenseDocument(context, 'CC BY-NC 4.0', nllbLicenseAsset),

View File

@ -262,7 +262,7 @@ Future<void> _done(BuildContext context, AppController c, MasterLock lock, List<
final setNew = await showDialog<bool>( final setNew = await showDialog<bool>(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
icon: const Icon(Icons.check_circle_outline, color: JjColors.accent), icon: Icon(Icons.check_circle_outline, color: JjColors.accent),
title: Text(tr('초기화했습니다')), title: Text(tr('초기화했습니다')),
content: Text(done.map((x) => '• $x').join('\n')), content: Text(done.map((x) => '• $x').join('\n')),
actions: [ actions: [
@ -314,7 +314,7 @@ Future<bool> setPasswordDialog(BuildContext context, AppController c, MasterLock
'저장된 비밀번호를 지우고 초기화할 수 있습니다.')), '저장된 비밀번호를 지우고 초기화할 수 있습니다.')),
const SizedBox(height: 8), const SizedBox(height: 8),
Text(tr('이 기기에는 원문이 아니라 되돌릴 수 없는 해시만 남습니다.'), Text(tr('이 기기에는 원문이 아니라 되돌릴 수 없는 해시만 남습니다.'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
TextField( TextField(
controller: a, controller: a,
obscureText: true, obscureText: true,

View File

@ -130,17 +130,17 @@ Future<bool?> _liveDeleteDialog(BuildContext context, LiveSync live, LiveSyncPai
child: ListView(children: [ child: ListView(children: [
if (list.isEmpty) if (list.isEmpty)
Text(tr('미리 본 지울 것은 없습니다 - 위 규칙 때문에 실제로는 지워질 수 있습니다'), Text(tr('미리 본 지울 것은 없습니다 - 위 규칙 때문에 실제로는 지워질 수 있습니다'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
for (final n in plain.take(500)) Text(n, style: const TextStyle(fontSize: 12, fontFamily: 'Consolas')), for (final n in plain.take(500)) Text(n, style: const TextStyle(fontSize: 12, fontFamily: 'Consolas')),
if (plain.length > 500) Text(trf('… 외 {0}개', [plain.length - 500])), if (plain.length > 500) Text(trf('… 외 {0}개', [plain.length - 500])),
if (filtered.isNotEmpty) ...[ if (filtered.isNotEmpty) ...[
if (plain.isNotEmpty) const SizedBox(height: 10), if (plain.isNotEmpty) const SizedBox(height: 10),
Text(keepOptionsTogether(trf('거른 것 {0}개도 지워집니다 (--delete-excluded)', [filtered.length])), Text(keepOptionsTogether(trf('거른 것 {0}개도 지워집니다 (--delete-excluded)', [filtered.length])),
style: const TextStyle(fontWeight: FontWeight.w600, color: JjColors.danger)), style: TextStyle(fontWeight: FontWeight.w600, color: JjColors.danger)),
Text(keepOptionsTogether(tr('rsync 옵션에서 뺀 것 (--exclude 등) 이지만 --delete-excluded 가 있어 대상에서 지웁니다. 지키려면 옵션에서 --delete-excluded 를 빼세요.')), Text(keepOptionsTogether(tr('rsync 옵션에서 뺀 것 (--exclude 등) 이지만 --delete-excluded 가 있어 대상에서 지웁니다. 지키려면 옵션에서 --delete-excluded 를 빼세요.')),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
const SizedBox(height: 4), const SizedBox(height: 4),
for (final n in filtered.take(500)) Text(n, style: const TextStyle(fontSize: 12, fontFamily: 'Consolas', color: JjColors.danger)), for (final n in filtered.take(500)) Text(n, style: TextStyle(fontSize: 12, fontFamily: 'Consolas', color: JjColors.danger)),
if (filtered.length > 500) Text(trf('… 외 {0}개', [filtered.length - 500])), if (filtered.length > 500) Text(trf('… 외 {0}개', [filtered.length - 500])),
], ],
]), ]),
@ -219,11 +219,11 @@ class _DiskInfo extends StatelessWidget {
future: diskSpace(dir), future: diskSpace(dir),
builder: (_, s) { builder: (_, s) {
final d = s.data; final d = s.data;
if (d == null) return Text(tr('대상 디스크: 알 수 없음'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)); if (d == null) return Text(tr('대상 디스크: 알 수 없음'), style: TextStyle(fontSize: 12, color: JjColors.textDim));
final (free, total) = d; final (free, total) = d;
final used = total == 0 ? 0.0 : (total - free) / total; final used = total == 0 ? 0.0 : (total - free) / total;
return Row(mainAxisSize: MainAxisSize.min, children: [ return Row(mainAxisSize: MainAxisSize.min, children: [
const Icon(Icons.storage, size: 14, color: JjColors.textDim), Icon(Icons.storage, size: 14, color: JjColors.textDim),
const SizedBox(width: 4), const SizedBox(width: 4),
// 241 시험에서 찾음: 좁은 화면 (폭 500) 에서 넘쳤다 - 글을 줄인다 // 241 시험에서 찾음: 좁은 화면 (폭 500) 에서 넘쳤다 - 글을 줄인다
Flexible( Flexible(
@ -273,7 +273,7 @@ class TransferProgressLines extends StatelessWidget {
), ),
SizedBox( SizedBox(
width: 130, width: 130,
child: Text(right, textAlign: TextAlign.end, style: const TextStyle(fontSize: 12, color: JjColors.textDim))), child: Text(right, textAlign: TextAlign.end, style: TextStyle(fontSize: 12, color: JjColors.textDim))),
]), ]),
); );
final speed = !job.finished && job.speed != null ? '${formatSize(job.speed!.round())}/s' : ''; final speed = !job.finished && job.speed != null ? '${formatSize(job.speed!.round())}/s' : '';
@ -319,7 +319,7 @@ class _CopyTab extends StatelessWidget {
child: Padding( child: Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Text(tr('실행한 rsync 가 없습니다. Rsync 화면에서 → · ← · ⇄ 로 실행하면 여기에 자동으로 등록됩니다.'), child: Text(tr('실행한 rsync 가 없습니다. Rsync 화면에서 → · ← · ⇄ 로 실행하면 여기에 자동으로 등록됩니다.'),
textAlign: TextAlign.center, style: const TextStyle(color: JjColors.textDim)), textAlign: TextAlign.center, style: TextStyle(color: JjColors.textDim)),
), ),
); );
} }
@ -593,7 +593,7 @@ class _CopyCardState extends State<_CopyCard> {
), ),
Text('→ ${shortPath(t.dest)}', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.w600)), Text('→ ${shortPath(t.dest)}', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.w600)),
Text('${vDisplay(t.sources.first)}${t.contents ? '/' : ''} → ${vDisplay(t.dest)}', Text('${vDisplay(t.sources.first)}${t.contents ? '/' : ''} → ${vDisplay(t.dest)}',
maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11, color: JjColors.textDim)), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 11, color: JjColors.textDim)),
]), ]),
), ),
Chip( Chip(
@ -710,7 +710,7 @@ class _CopyCardState extends State<_CopyCard> {
), ),
IconButton( IconButton(
tooltip: tr('목록에서 지우기 (파일은 그대로)'), tooltip: tr('목록에서 지우기 (파일은 그대로)'),
icon: const Icon(Icons.delete_outline, color: JjColors.textDim), icon: Icon(Icons.delete_outline, color: JjColors.textDim),
onPressed: () => center.remove(t.id), onPressed: () => center.remove(t.id),
), ),
]), ]),
@ -774,7 +774,7 @@ class _LiveTabState extends State<_LiveTab> {
SelectableText( SelectableText(
'rsync -avHPOg --remove-source-files ${x.source}/ ${x.target}/\n' 'rsync -avHPOg --remove-source-files ${x.source}/ ${x.target}/\n'
'find ${x.source}/ -type d -empty -delete', 'find ${x.source}/ -type d -empty -delete',
style: const TextStyle(fontFamily: 'monospace', fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontFamily: 'monospace', fontSize: 12, color: JjColors.textDim)),
const SizedBox(height: 14), const SizedBox(height: 14),
Text(tr('원본 폴더'), style: const TextStyle(fontWeight: FontWeight.w600)), Text(tr('원본 폴더'), style: const TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 6), const SizedBox(height: 6),
@ -830,7 +830,7 @@ class _LiveTabState extends State<_LiveTab> {
Expanded( Expanded(
child: Text( child: Text(
tr('원본이 바뀌면 대상에 맞춥니다. 일정을 정하면 그 시간에만 맞추고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다.'), tr('원본이 바뀌면 대상에 맞춥니다. 일정을 정하면 그 시간에만 맞추고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다.'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim), style: TextStyle(fontSize: 12, color: JjColors.textDim),
), ),
), ),
TextButton.icon(onPressed: () => addLiveSyncPairDialog(context, c), icon: const Icon(Icons.add), label: Text(tr('추가'))), TextButton.icon(onPressed: () => addLiveSyncPairDialog(context, c), icon: const Icon(Icons.add), label: Text(tr('추가'))),
@ -852,7 +852,7 @@ class _LiveTabState extends State<_LiveTab> {
Padding( Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Center(child: Text(tr('실시간 동기화가 없습니다. [추가] 하거나 복사 · rsync 탭에서 [lsync 로 이동] 하세요.'), child: Center(child: Text(tr('실시간 동기화가 없습니다. [추가] 하거나 복사 · rsync 탭에서 [lsync 로 이동] 하세요.'),
style: const TextStyle(color: JjColors.textDim))), style: TextStyle(color: JjColors.textDim))),
), ),
for (final x in pairs) _liveCard(context, live, x), for (final x in pairs) _liveCard(context, live, x),
]); ]);
@ -895,11 +895,11 @@ class _LiveTabState extends State<_LiveTab> {
), ),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [ Row(children: [
const Icon(Icons.lock_clock_outlined, color: JjColors.accent, size: 20), Icon(Icons.lock_clock_outlined, color: JjColors.accent, size: 20),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text(tr('마스터 비밀번호가 필요해 기다리는 중'), child: Text(tr('마스터 비밀번호가 필요해 기다리는 중'),
style: const TextStyle(fontWeight: FontWeight.w600, color: JjColors.accent)), style: TextStyle(fontWeight: FontWeight.w600, color: JjColors.accent)),
), ),
]), ]),
const SizedBox(height: 4), const SizedBox(height: 4),
@ -948,7 +948,7 @@ class _LiveTabState extends State<_LiveTab> {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text('${vDisplay(x.source)}${e.detail.isEmpty ? '' : ' · ${e.detail}'}', Text('${vDisplay(x.source)}${e.detail.isEmpty ? '' : ' · ${e.detail}'}',
style: const TextStyle(fontSize: 11, color: JjColors.textDim)), style: TextStyle(fontSize: 11, color: JjColors.textDim)),
const SizedBox(height: 6), const SizedBox(height: 6),
Wrap(spacing: 8, runSpacing: 4, children: [ Wrap(spacing: 8, runSpacing: 4, children: [
FilledButton.tonalIcon( FilledButton.tonalIcon(
@ -1088,8 +1088,8 @@ class _LiveTabState extends State<_LiveTab> {
child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
Text(scheduleSummary(x.schedule), style: TextStyle(fontSize: 12, color: active ? JjColors.accent : Colors.orangeAccent)), Text(scheduleSummary(x.schedule), style: TextStyle(fontSize: 12, color: active ? JjColors.accent : Colors.orangeAccent)),
if (x.schedule.isNotEmpty) if (x.schedule.isNotEmpty)
Text(x.schedule.join(' | '), style: const TextStyle(fontSize: 11, fontFamily: 'monospace', color: JjColors.textDim)), Text(x.schedule.join(' | '), style: TextStyle(fontSize: 11, fontFamily: 'monospace', color: JjColors.textDim)),
Text(tr('누르면 일정 바꾸기'), style: const TextStyle(fontSize: 10, color: JjColors.textDim)), Text(tr('누르면 일정 바꾸기'), style: TextStyle(fontSize: 10, color: JjColors.textDim)),
]), ]),
), ),
]), ]),
@ -1109,7 +1109,7 @@ class _LiveTabState extends State<_LiveTab> {
Padding( Padding(
padding: const EdgeInsets.only(left: 32, top: 8), padding: const EdgeInsets.only(left: 32, top: 8),
child: pend == null child: pend == null
? Text(tr('맞출 것을 세는 중…'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)) ? Text(tr('맞출 것을 세는 중…'), style: TextStyle(fontSize: 12, color: JjColors.textDim))
: pend.isEmpty : pend.isEmpty
// 244: 대상에만 있는 것이 있으면 "같습니다" 가 아니라 사실대로 // 244: 대상에만 있는 것이 있으면 "같습니다" 가 아니라 사실대로
? Text( ? Text(
@ -1173,7 +1173,7 @@ class _LiveTabState extends State<_LiveTab> {
), ),
IconButton( IconButton(
tooltip: tr('지우기'), tooltip: tr('지우기'),
icon: const Icon(Icons.delete_outline, color: JjColors.textDim), icon: Icon(Icons.delete_outline, color: JjColors.textDim),
onPressed: () => removeLiveSyncPair(context, c, x), onPressed: () => removeLiveSyncPair(context, c, x),
), ),
]), ]),
@ -1198,7 +1198,7 @@ Future<void> askLiveSyncStart(BuildContext context, LiveSync live) async {
width: 520, width: 520,
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(tr('시작할 동기화를 고르세요. 고르지 않은 것은 이번 실행 동안 멈춥니다 (모니터링 > lsync 에서 시작).'), Text(tr('시작할 동기화를 고르세요. 고르지 않은 것은 이번 실행 동안 멈춥니다 (모니터링 > lsync 에서 시작).'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
const SizedBox(height: 8), const SizedBox(height: 8),
Flexible( Flexible(
child: ListView(shrinkWrap: true, children: [ child: ListView(shrinkWrap: true, children: [

View File

@ -228,7 +228,7 @@ class _DnsSettingsState extends State<DnsSettings> {
label: Text(tr('지금 확인')), label: Text(tr('지금 확인')),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded(child: Text(_testing ? tr('확인하는 중…') : _test ?? '', style: const TextStyle(fontSize: 12, color: JjColors.textDim))), Expanded(child: Text(_testing ? tr('확인하는 중…') : _test ?? '', style: TextStyle(fontSize: 12, color: JjColors.textDim))),
]), ]),
), ),
SettingTile( SettingTile(
@ -247,7 +247,7 @@ class _DnsSettingsState extends State<DnsSettings> {
if (desk && s.browserEngine == 'chrome' && CefRuntime.readyThisRun) if (desk && s.browserEngine == 'chrome' && CefRuntime.readyThisRun)
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Text(tr('Chrome 엔진은 앱을 다시 시작하면 바뀐 설정을 씁니다.'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)), child: Text(tr('Chrome 엔진은 앱을 다시 시작하면 바뀐 설정을 씁니다.'), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
), ),
]); ]);
} }

View File

@ -379,7 +379,7 @@ class _PlayerPageState extends State<PlayerPage> {
Widget item(String label, VoidCallback? onTap, {String? key, bool checked = false}) => MenuItemButton( Widget item(String label, VoidCallback? onTap, {String? key, bool checked = false}) => MenuItemButton(
onPressed: onTap, onPressed: onTap,
leadingIcon: SizedBox(width: 18, child: checked ? const Icon(Icons.check, size: 16) : null), leadingIcon: SizedBox(width: 18, child: checked ? const Icon(Icons.check, size: 16) : null),
trailingIcon: key == null ? null : Text(key, style: const TextStyle(fontSize: 11, color: JjColors.textDim)), trailingIcon: key == null ? null : Text(key, style: TextStyle(fontSize: 11, color: JjColors.textDim)),
child: Text(label), child: Text(label),
); );
return [ return [

View File

@ -124,7 +124,7 @@ class _QuickLookState extends State<QuickLook> {
Widget _info(List<String> lines) => Center( Widget _info(List<String> lines) => Center(
child: Column(mainAxisSize: MainAxisSize.min, children: [ child: Column(mainAxisSize: MainAxisSize.min, children: [
Icon(_e.isDir ? Icons.folder : Icons.insert_drive_file_outlined, size: 56, color: JjColors.textDim), Icon(_e.isDir ? Icons.folder : Icons.insert_drive_file_outlined, size: 56, color: JjColors.textDim),
for (final l in lines) Text(l, textAlign: TextAlign.center, style: const TextStyle(color: JjColors.textDim)), for (final l in lines) Text(l, textAlign: TextAlign.center, style: TextStyle(color: JjColors.textDim)),
]), ]),
); );
@ -136,7 +136,7 @@ class _QuickLookState extends State<QuickLook> {
builder: (_, s) => s.connectionState != ConnectionState.done builder: (_, s) => s.connectionState != ConnectionState.done
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: s.data == null : s.data == null
? const Center(child: Icon(Icons.movie_outlined, size: 56, color: JjColors.textDim)) ? Center(child: Icon(Icons.movie_outlined, size: 56, color: JjColors.textDim))
: Image.file(s.data!, fit: BoxFit.contain), : Image.file(s.data!, fit: BoxFit.contain),
), ),
), ),
@ -145,9 +145,9 @@ class _QuickLookState extends State<QuickLook> {
// 큰 파일 · 느린 디스크에서도 창이 멈추지 않게 (기다리는 동안 "읽는 중", 20초 넘으면 못 읽음) // 큰 파일 · 느린 디스크에서도 창이 멈추지 않게 (기다리는 동안 "읽는 중", 20초 넘으면 못 읽음)
future: widget.probe(e.path).timeout(const Duration(seconds: 20)), future: widget.probe(e.path).timeout(const Duration(seconds: 20)),
builder: (_, s) { builder: (_, s) {
if (s.connectionState != ConnectionState.done) return Text(tr('정보를 읽는 중…'), style: const TextStyle(color: JjColors.textDim)); if (s.connectionState != ConnectionState.done) return Text(tr('정보를 읽는 중…'), style: TextStyle(color: JjColors.textDim));
final info = s.data; final info = s.data;
if (info == null) return Text(tr('정보를 읽지 못했습니다'), style: const TextStyle(color: JjColors.textDim)); if (info == null) return Text(tr('정보를 읽지 못했습니다'), style: TextStyle(color: JjColors.textDim));
final v = info.ofType('video').where((x) => (x.width ?? 0) > 0).firstOrNull; final v = info.ofType('video').where((x) => (x.width ?? 0) > 0).firstOrNull;
final audio = info.ofType('audio'); final audio = info.ofType('audio');
final subs = info.ofType('subtitle'); final subs = info.ofType('subtitle');
@ -189,7 +189,7 @@ class _QuickLookState extends State<QuickLook> {
title: Row(children: [ title: Row(children: [
Expanded(child: Text(e.name, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 16))), Expanded(child: Text(e.name, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 16))),
IconButton(tooltip: tr('앞 항목 (←)'), onPressed: _i > 0 ? () => _go(-1) : null, icon: const Icon(Icons.chevron_left)), IconButton(tooltip: tr('앞 항목 (←)'), onPressed: _i > 0 ? () => _go(-1) : null, icon: const Icon(Icons.chevron_left)),
Text('${_i + 1}/${widget.entries.length}', style: const TextStyle(fontSize: 12, color: JjColors.textDim)), Text('${_i + 1}/${widget.entries.length}', style: TextStyle(fontSize: 12, color: JjColors.textDim)),
IconButton( IconButton(
tooltip: tr('다음 항목 (→)'), tooltip: tr('다음 항목 (→)'),
onPressed: _i < widget.entries.length - 1 ? () => _go(1) : null, onPressed: _i < widget.entries.length - 1 ? () => _go(1) : null,
@ -203,7 +203,7 @@ class _QuickLookState extends State<QuickLook> {
[if (!e.isDir) formatSize(e.size), if (e.modified.year > 1) _date(e.modified), vDirname(e.path)].join(' · '), [if (!e.isDir) formatSize(e.size), if (e.modified.year > 1) _date(e.modified), vDirname(e.path)].join(' · '),
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12, color: JjColors.textDim), style: TextStyle(fontSize: 12, color: JjColors.textDim),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Expanded(child: KeyedSubtree(key: ValueKey(e.path), child: _body(e))), Expanded(child: KeyedSubtree(key: ValueKey(e.path), child: _body(e))),

View File

@ -920,7 +920,7 @@ class _ReaderPageState extends State<ReaderPage> {
Widget row(String k, Widget v) => Padding( Widget row(String k, Widget v) => Padding(
padding: const EdgeInsets.symmetric(vertical: 3), padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
SizedBox(width: 90, child: Text(k, style: const TextStyle(color: JjColors.textDim))), SizedBox(width: 90, child: Text(k, style: TextStyle(color: JjColors.textDim))),
Expanded(child: v), Expanded(child: v),
]), ]),
); );
@ -951,12 +951,12 @@ class _ReaderPageState extends State<ReaderPage> {
child: TextButton(onPressed: () => set(() => showLocation = true), child: Text(tr('위치 보기'))))), child: TextButton(onPressed: () => set(() => showLocation = true), child: Text(tr('위치 보기'))))),
if (i.hasLocation) if (i.hasLocation)
Text(tr('위치는 이 기기에서만 보이며 지도 · 인터넷으로 보내지 않습니다'), Text(tr('위치는 이 기기에서만 보이며 지도 · 인터넷으로 보내지 않습니다'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
if (!i.hasExif) if (!i.hasExif)
Padding( Padding(
padding: const EdgeInsets.only(top: 6), padding: const EdgeInsets.only(top: 6),
child: Text(tr('이 그림에는 Exif 정보가 없습니다 (JPEG 사진만 읽음)'), child: Text(tr('이 그림에는 Exif 정보가 없습니다 (JPEG 사진만 읽음)'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
), ),
], ],
]), ]),

View File

@ -116,7 +116,7 @@ class _RemoteThumbState extends State<RemoteThumb> {
Image.file(f, width: 56, height: 44, fit: BoxFit.cover, errorBuilder: (_, _, _) => widget.fallback), Image.file(f, width: 56, height: 44, fit: BoxFit.cover, errorBuilder: (_, _, _) => widget.fallback),
if (isVideoFile(widget.entry.path)) const Icon(Icons.play_arrow, size: 18, color: Colors.white70), if (isVideoFile(widget.entry.path)) const Icon(Icons.play_arrow, size: 18, color: Colors.white70),
// 원격에서 온 것 표시 (작게) // 원격에서 온 것 표시 (작게)
const Positioned(right: 1, bottom: 1, child: Icon(Icons.cloud_outlined, size: 10, color: JjColors.textDim)), Positioned(right: 1, bottom: 1, child: Icon(Icons.cloud_outlined, size: 10, color: JjColors.textDim)),
]), ]),
); );
} }

View File

@ -31,7 +31,7 @@ Future<String?> installRsyncWithDialog(BuildContext context) async {
content: ValueListenableBuilder<(double, String)>( content: ValueListenableBuilder<(double, String)>(
valueListenable: progress, valueListenable: progress,
builder: (_, v, _) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ builder: (_, v, _) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(v.$2, style: const TextStyle(fontSize: 12, color: JjColors.textDim)), Text(v.$2, style: TextStyle(fontSize: 12, color: JjColors.textDim)),
const SizedBox(height: 8), const SizedBox(height: 8),
LinearProgressIndicator(value: v.$1), LinearProgressIndicator(value: v.$1),
]), ]),

View File

@ -101,7 +101,7 @@ class _ScheduleDialogState extends State<_ScheduleDialog> {
? tr('앱이 켜져 있는 동안 늘 동기화합니다.') ? tr('앱이 켜져 있는 동안 늘 동기화합니다.')
: tr('칠한 시간에만 동기화하고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다. ' : tr('칠한 시간에만 동기화하고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다. '
'칸을 누르거나 끌어서 칠합니다 (요일 이름 · 시 숫자를 누르면 그 줄 전체).'), '칸을 누르거나 끌어서 칠합니다 (요일 이름 · 시 숫자를 누르면 그 줄 전체).'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim), style: TextStyle(fontSize: 12, color: JjColors.textDim),
), ),
if (!_always) ...[ if (!_always) ...[
const SizedBox(height: 10), const SizedBox(height: 10),
@ -136,7 +136,7 @@ class _ScheduleDialogState extends State<_ScheduleDialog> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text(lines.isEmpty ? tr('칠한 시간이 없어 동기화하지 않습니다.') : scheduleSummary(lines), Text(lines.isEmpty ? tr('칠한 시간이 없어 동기화하지 않습니다.') : scheduleSummary(lines),
style: const TextStyle(fontSize: 12, color: JjColors.accent)), style: TextStyle(fontSize: 12, color: JjColors.accent)),
], ],
]), ]),
), ),
@ -215,7 +215,7 @@ class _WeekGridState extends State<_WeekGrid> {
child: InkWell( child: InkWell(
onTap: () => _toggleCol(h), onTap: () => _toggleCol(h),
child: Center( child: Center(
child: Text(h % 3 == 0 ? '$h' : '', style: const TextStyle(fontSize: 10, color: JjColors.textDim)), child: Text(h % 3 == 0 ? '$h' : '', style: TextStyle(fontSize: 10, color: JjColors.textDim)),
), ),
), ),
), ),

View File

@ -348,7 +348,7 @@ class _SettingsPageState extends State<SettingsPage> {
Expanded( Expanded(
child: Center( child: Center(
child: Column(mainAxisSize: MainAxisSize.min, children: [ child: Column(mainAxisSize: MainAxisSize.min, children: [
const Icon(Icons.lock_outline, size: 48, color: JjColors.textDim), Icon(Icons.lock_outline, size: 48, color: JjColors.textDim),
const SizedBox(height: 12), const SizedBox(height: 12),
Text(tr('마스터 비밀번호로 잠겨 있습니다')), Text(tr('마스터 비밀번호로 잠겨 있습니다')),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -416,7 +416,7 @@ class _SettingsPageState extends State<SettingsPage> {
_appIconTile(c, desk), _appIconTile(c, desk),
if (c.services.updater != null) ...[ if (c.services.updater != null) ...[
SettingTile( SettingTile(
leading: const Icon(Icons.system_update_alt, color: JjColors.accent), leading: Icon(Icons.system_update_alt, color: JjColors.accent),
title: FutureBuilder<String>( title: FutureBuilder<String>(
future: c.services.updater!.currentVersion(), future: c.services.updater!.currentVersion(),
builder: (_, v) => Text('JJ_MKVMaker v${v.data ?? '…'}'), builder: (_, v) => Text('JJ_MKVMaker v${v.data ?? '…'}'),
@ -497,6 +497,28 @@ class _SettingsPageState extends State<SettingsPage> {
..uiScale = v), ..uiScale = v),
), ),
), ),
// X-plore 비교 5-1 ⑦ (관리자 지시 10/21): 화면 밝기 - 처음 값 어둡게 (예전 그대로), 다 끝날 때까지 "시험"
SettingTile(
title: Row(mainAxisSize: MainAxisSize.min, children: [
Flexible(child: Text(tr('화면 밝기'))),
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(border: Border.all(color: Colors.orangeAccent), borderRadius: BorderRadius.circular(4)),
child: Text(tr('시험'), style: const TextStyle(fontSize: 11, color: Colors.orangeAccent)),
),
]),
subtitle: Text(tr('밝게 · 기기 따라는 아직 시험 중입니다 - 일부 화면의 색이 덜 맞을 수 있습니다')),
trailing: DropdownButton<String>(
value: s.uiBrightness,
items: [
DropdownMenuItem(value: 'dark', child: Text(tr('어두운 화면'))),
DropdownMenuItem(value: 'light', child: Text(tr('밝은 화면'))),
DropdownMenuItem(value: 'system', child: Text(tr('기기 따라'))),
],
onChanged: (v) => c.updateSettings((x) => x.uiBrightness = v!),
),
),
]), ]),
_group('mkv', Icons.movie_creation_outlined, tr('MKV 만들기'), [ _group('mkv', Icons.movie_creation_outlined, tr('MKV 만들기'), [
// 61: 화면 언어와 다른 언어의 자막을 기본으로 원할 수 있다 (예: 화면은 일본어, 자막은 한국어) // 61: 화면 언어와 다른 언어의 자막을 기본으로 원할 수 있다 (예: 화면은 일본어, 자막은 한국어)
@ -592,7 +614,7 @@ class _SettingsPageState extends State<SettingsPage> {
s.moveTargets.isEmpty s.moveTargets.isEmpty
? tr('없음: 세부 정보의 [이동] 을 처음 누를 때 폴더를 골라 하나 만듭니다') ? tr('없음: 세부 정보의 [이동] 을 처음 누를 때 폴더를 골라 하나 만듭니다')
: tr('표시 이름은 바로 고칠 수 있습니다. 동영상 줄을 세 번 누르면 첫 번째 버튼의 폴더로 옮깁니다'), : tr('표시 이름은 바로 고칠 수 있습니다. 동영상 줄을 세 번 누르면 첫 번째 버튼의 폴더로 옮깁니다'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim), style: TextStyle(fontSize: 12, color: JjColors.textDim),
), ),
), ),
]), ]),
@ -726,7 +748,7 @@ class _SettingsPageState extends State<SettingsPage> {
if (s.browserEngine == 'edge' && CefRuntime.installed && !CefRuntime.readyThisRun) if (s.browserEngine == 'edge' && CefRuntime.installed && !CefRuntime.readyThisRun)
IconButton( IconButton(
tooltip: tr('내려받은 Chrome 엔진 지우기'), tooltip: tr('내려받은 Chrome 엔진 지우기'),
icon: const Icon(Icons.delete_outline, color: JjColors.textDim), icon: Icon(Icons.delete_outline, color: JjColors.textDim),
onPressed: () async { onPressed: () async {
final ok = await CefRuntime.uninstall(); final ok = await CefRuntime.uninstall();
c.note(ok ? tr('내장 Chrome 엔진을 지웠습니다.') : tr('내장 Chrome 엔진을 지우지 못했습니다 (사용 중).')); c.note(ok ? tr('내장 Chrome 엔진을 지웠습니다.') : tr('내장 Chrome 엔진을 지우지 못했습니다 (사용 중).'));
@ -1242,7 +1264,7 @@ class _SettingsPageState extends State<SettingsPage> {
Text( Text(
tr('로그인 쿠키는 다운로드 (yt-dlp) 가 쓰도록 이 기기의 앱 폴더에 cookies_youtube.txt 로 저장됩니다 (평문). ' tr('로그인 쿠키는 다운로드 (yt-dlp) 가 쓰도록 이 기기의 앱 폴더에 cookies_youtube.txt 로 저장됩니다 (평문). '
'이 파일을 가진 사람은 그 계정에 로그인할 수 있으니 기기를 남에게 줄 때는 지우세요. 고른 사이트의 쿠키만 넘깁니다:'), '이 파일을 가진 사람은 그 계정에 로그인할 수 있으니 기기를 남에게 줄 때는 지우세요. 고른 사이트의 쿠키만 넘깁니다:'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
const SizedBox(height: 6), const SizedBox(height: 6),
Wrap(spacing: 6, runSpacing: 6, children: [ Wrap(spacing: 6, runSpacing: 6, children: [
for (final d in loginCookieDomains) for (final d in loginCookieDomains)
@ -1332,7 +1354,7 @@ class _SettingsPageState extends State<SettingsPage> {
title: Text(tr('창 보이기 / 숨기기 단축키')), title: Text(tr('창 보이기 / 숨기기 단축키')),
subtitle: _hotkeyError == null subtitle: _hotkeyError == null
? Text(tr('어디서나 누르면 JJ_MKVMaker 창이 나타납니다')) ? Text(tr('어디서나 누르면 JJ_MKVMaker 창이 나타납니다'))
: Text(_hotkeyError!, style: const TextStyle(color: JjColors.danger)), : Text(_hotkeyError!, style: TextStyle(color: JjColors.danger)),
trailing: SizedBox( trailing: SizedBox(
width: 260, width: 260,
child: Row(children: [ child: Row(children: [
@ -1386,7 +1408,7 @@ class _SettingsPageState extends State<SettingsPage> {
_group('about', Icons.info_outline, tr('프로그램 정보'), [ _group('about', Icons.info_outline, tr('프로그램 정보'), [
// 164: 지금 버전과 [최신 버전 확인] (결과는 버튼 옆에) // 164: 지금 버전과 [최신 버전 확인] (결과는 버튼 옆에)
SettingTile( SettingTile(
leading: const Icon(Icons.info_outline, color: JjColors.accent), leading: Icon(Icons.info_outline, color: JjColors.accent),
title: FutureBuilder<String>( title: FutureBuilder<String>(
future: c.services.updater?.currentVersion() ?? Future.value(''), future: c.services.updater?.currentVersion() ?? Future.value(''),
builder: (_, v) => Text(v.data == null || v.data!.isEmpty ? 'JJ_MKVMaker' : 'JJ_MKVMaker v${v.data}'), builder: (_, v) => Text(v.data == null || v.data!.isEmpty ? 'JJ_MKVMaker' : 'JJ_MKVMaker v${v.data}'),
@ -1435,7 +1457,7 @@ class _SettingsPageState extends State<SettingsPage> {
c.updateSettings((x) => x.moveTargets = f([...x.moveTargets])); c.updateSettings((x) => x.moveTargets = f([...x.moveTargets]));
return SettingTile( return SettingTile(
key: ValueKey('move_${i}_${t.dir}'), key: ValueKey('move_${i}_${t.dir}'),
leading: const Icon(Icons.drive_file_move_outline, color: JjColors.accent), leading: Icon(Icons.drive_file_move_outline, color: JjColors.accent),
title: TextFormField( title: TextFormField(
initialValue: t.name, initialValue: t.name,
decoration: InputDecoration(isDense: true, labelText: tr('표시 이름')), decoration: InputDecoration(isDense: true, labelText: tr('표시 이름')),
@ -1606,7 +1628,7 @@ class _SettingsPageState extends State<SettingsPage> {
Widget _subTitle(String t) => Padding( Widget _subTitle(String t) => Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Text(t, style: const TextStyle(fontSize: 13, color: JjColors.accent, fontWeight: FontWeight.w600)), child: Text(t, style: TextStyle(fontSize: 13, color: JjColors.accent, fontWeight: FontWeight.w600)),
); );

View File

@ -38,7 +38,7 @@ class SftpSettings extends StatelessWidget {
padding: const EdgeInsets.only(left: 16), padding: const EdgeInsets.only(left: 16),
child: SettingTile( child: SettingTile(
dense: true, dense: true,
leading: const Icon(Icons.vpn_key, color: JjColors.accent), leading: Icon(Icons.vpn_key, color: JjColors.accent),
title: Text(s.label), title: Text(s.label),
subtitle: Text( subtitle: Text(
[ [
@ -58,7 +58,7 @@ class SftpSettings extends StatelessWidget {
), ),
IconButton( IconButton(
tooltip: tr('지우기'), tooltip: tr('지우기'),
icon: const Icon(Icons.delete_outline, size: 18, color: JjColors.textDim), icon: Icon(Icons.delete_outline, size: 18, color: JjColors.textDim),
onPressed: () => deleteSftpServer(context, c, s), onPressed: () => deleteSftpServer(context, c, s),
), ),
]), ]),
@ -94,7 +94,7 @@ Future<bool> askTrustHostKey(BuildContext context, String serverLabel, SftpHostK
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
scrollable: true, scrollable: true,
icon: const Icon(Icons.gpp_bad_outlined, color: JjColors.danger), icon: Icon(Icons.gpp_bad_outlined, color: JjColors.danger),
title: Text(trf('"{0}" 서버의 키가 바뀌었습니다', [serverLabel])), title: Text(trf('"{0}" 서버의 키가 바뀌었습니다', [serverLabel])),
content: SizedBox( content: SizedBox(
width: 520, width: 520,
@ -290,7 +290,7 @@ Future<SftpServer?> editSftpServer(BuildContext context, AppController c, {SftpS
onPressed: pickKey, onPressed: pickKey,
), ),
if (key.isNotEmpty) ...[ if (key.isNotEmpty) ...[
Text(tr('개인 키 있음'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)), Text(tr('개인 키 있음'), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
TextButton(onPressed: () => set(() => key = ''), child: Text(tr('개인 키 빼기'))), TextButton(onPressed: () => set(() => key = ''), child: Text(tr('개인 키 빼기'))),
], ],
]), ]),
@ -316,7 +316,7 @@ Future<SftpServer?> editSftpServer(BuildContext context, AppController c, {SftpS
fingerprint.isEmpty fingerprint.isEmpty
? tr('서버 키: 아직 확인 안 함 ([연결 확인] 을 누르면 지문을 보이고 묻습니다)') ? tr('서버 키: 아직 확인 안 함 ([연결 확인] 을 누르면 지문을 보이고 묻습니다)')
: '${tr('믿은 서버 키')}: $fingerprint${keyType.isEmpty ? '' : ' ($keyType)'}', : '${tr('믿은 서버 키')}: $fingerprint${keyType.isEmpty ? '' : ' ($keyType)'}',
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
if (fingerprint.isNotEmpty) if (fingerprint.isNotEmpty)
Align( Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,

View File

@ -49,7 +49,7 @@ class SmbSettings extends StatelessWidget {
padding: const EdgeInsets.only(left: 16), padding: const EdgeInsets.only(left: 16),
child: SettingTile( child: SettingTile(
dense: true, dense: true,
leading: const Icon(Icons.dns, color: JjColors.accent), leading: Icon(Icons.dns, color: JjColors.accent),
title: Text(s.label), title: Text(s.label),
subtitle: Text( subtitle: Text(
[ [
@ -66,7 +66,7 @@ class SmbSettings extends StatelessWidget {
), ),
IconButton( IconButton(
tooltip: tr('지우기'), tooltip: tr('지우기'),
icon: const Icon(Icons.delete_outline, size: 18, color: JjColors.textDim), icon: Icon(Icons.delete_outline, size: 18, color: JjColors.textDim),
onPressed: () => deleteSmbServer(context, c, s), onPressed: () => deleteSmbServer(context, c, s),
), ),
]), ]),
@ -320,14 +320,14 @@ Future<LanHost?> pickLanSmbServer(BuildContext context) async {
width: 460, width: 460,
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(tr('먼저 mDNS 로 묻고 (이름을 알리는 NAS), 이어서 이 기기가 연결된 망 (Wi-Fi · 유선) 의 기기들에 SMB (445) 연결만 해 보고 바로 닫습니다. 파일 · 계정 정보는 보내지 않습니다.'), Text(tr('먼저 mDNS 로 묻고 (이름을 알리는 NAS), 이어서 이 기기가 연결된 망 (Wi-Fi · 유선) 의 기기들에 SMB (445) 연결만 해 보고 바로 닫습니다. 파일 · 계정 정보는 보내지 않습니다.'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
const SizedBox(height: 10), const SizedBox(height: 10),
if (list == null && error == null) ...[ if (list == null && error == null) ...[
LinearProgressIndicator(value: total > 0 ? done / total : null), LinearProgressIndicator(value: total > 0 ? done / total : null),
const SizedBox(height: 4), const SizedBox(height: 4),
Text(total > 0 ? trf('{0} / {1} 확인', [done, total]) : tr('준비하는 중'), style: const TextStyle(fontSize: 12)), Text(total > 0 ? trf('{0} / {1} 확인', [done, total]) : tr('준비하는 중'), style: const TextStyle(fontSize: 12)),
], ],
if (error != null) Text('$error', style: const TextStyle(color: JjColors.danger)), if (error != null) Text('$error', style: TextStyle(color: JjColors.danger)),
if (list != null && list.isEmpty) Text(tr('찾지 못했습니다. NAS 가 켜져 있고 같은 망에 있는지 확인하거나, 주소를 직접 넣으세요.')), if (list != null && list.isEmpty) Text(tr('찾지 못했습니다. NAS 가 켜져 있고 같은 망에 있는지 확인하거나, 주소를 직접 넣으세요.')),
if (list != null) if (list != null)
for (final h in list) for (final h in list)

View File

@ -151,7 +151,7 @@ class _SqliteViewerPageState extends State<SqliteViewerPage> {
Padding( Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 0), padding: const EdgeInsets.fromLTRB(12, 8, 12, 0),
child: Text(tr('읽기 전용 - 이 앱은 파일을 바꾸지 않습니다. 쓰는 중인 바뀐 것 (-wal) 은 보이지 않을 수 있습니다'), child: Text(tr('읽기 전용 - 이 앱은 파일을 바꾸지 않습니다. 쓰는 중인 바뀐 것 (-wal) 은 보이지 않을 수 있습니다'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
), ),
if (_tables.isNotEmpty) if (_tables.isNotEmpty)
Padding( Padding(
@ -166,11 +166,11 @@ class _SqliteViewerPageState extends State<SqliteViewerPage> {
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
if (_count != null) Text(trf('{0}행', [_count!]), style: const TextStyle(color: JjColors.textDim)), if (_count != null) Text(trf('{0}행', [_count!]), style: TextStyle(color: JjColors.textDim)),
]), ]),
), ),
if (_error != null) if (_error != null)
Padding(padding: const EdgeInsets.all(16), child: Text(_error!, style: const TextStyle(color: JjColors.danger))), Padding(padding: const EdgeInsets.all(16), child: Text(_error!, style: TextStyle(color: JjColors.danger))),
if (_busy && _rows.isEmpty) const Padding(padding: EdgeInsets.all(24), child: Center(child: CircularProgressIndicator())), if (_busy && _rows.isEmpty) const Padding(padding: EdgeInsets.all(24), child: Center(child: CircularProgressIndicator())),
if (!_busy && _error == null && _tables.isEmpty) Padding(padding: const EdgeInsets.all(16), child: Text(tr('테이블이 없습니다'))), if (!_busy && _error == null && _tables.isEmpty) Padding(padding: const EdgeInsets.all(16), child: Text(tr('테이블이 없습니다'))),
if (t != null && _rows.isNotEmpty) if (t != null && _rows.isNotEmpty)

View File

@ -158,7 +158,7 @@ class _StorageAnalysisPageState extends State<StorageAnalysisPage> {
Text(_error != null ? trf('분석하지 못했습니다: {0}', [_error!]) : trf('읽는 중 · 폴더 {0}개 · {1}', [_dirs, formatSize(_bytes)]), Text(_error != null ? trf('분석하지 못했습니다: {0}', [_error!]) : trf('읽는 중 · 폴더 {0}개 · {1}', [_dirs, formatSize(_bytes)]),
textAlign: TextAlign.center), textAlign: TextAlign.center),
const SizedBox(height: 4), const SizedBox(height: 4),
Text(vDisplay(widget.root), style: const TextStyle(fontSize: 12, color: JjColors.textDim)), Text(vDisplay(widget.root), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
]), ]),
), ),
) )
@ -176,7 +176,7 @@ class _StorageAnalysisPageState extends State<StorageAnalysisPage> {
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(vDisplay(_cur), maxLines: 2, overflow: TextOverflow.ellipsis), Text(vDisplay(_cur), maxLines: 2, overflow: TextOverflow.ellipsis),
Text(trf('{0} · 파일 {1}개', [formatSize(s.sizeOf(_cur)), s.filesOf(_cur)]), Text(trf('{0} · 파일 {1}개', [formatSize(s.sizeOf(_cur)), s.filesOf(_cur)]),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
]), ]),
), ),
TextButton.icon( TextButton.icon(

View File

@ -453,7 +453,7 @@ class _SubtitleEditorPageState extends State<SubtitleEditorPage> {
value: _rate, value: _rate,
isDense: true, isDense: true,
underline: const SizedBox(), underline: const SizedBox(),
style: const TextStyle(fontSize: 12, color: JjColors.text), style: TextStyle(fontSize: 12, color: JjColors.text),
items: [ items: [
for (final r in const [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]) for (final r in const [0.5, 0.75, 1.0, 1.25, 1.5, 2.0])
DropdownMenuItem(value: r, child: Text('${r}x')), DropdownMenuItem(value: r, child: Text('${r}x')),
@ -529,7 +529,7 @@ class _SubtitleEditorPageState extends State<SubtitleEditorPage> {
value: e.saveCharset, value: e.saveCharset,
isDense: true, isDense: true,
underline: const SizedBox(), underline: const SizedBox(),
style: const TextStyle(fontSize: 13, color: JjColors.text), style: TextStyle(fontSize: 13, color: JjColors.text),
items: [ items: [
for (final c in saveCharsets.entries) for (final c in saveCharsets.entries)
DropdownMenuItem(value: c.key, child: Text(c.value)), DropdownMenuItem(value: c.key, child: Text(c.value)),
@ -591,7 +591,7 @@ class _HeaderRow extends StatelessWidget {
); );
} }
const _hdr = TextStyle(fontSize: 12, color: JjColors.textDim); final _hdr = TextStyle(fontSize: 12, color: JjColors.textDim);
class _CueRow extends StatefulWidget { class _CueRow extends StatefulWidget {
final int index; final int index;
@ -661,7 +661,7 @@ class _CueRowState extends State<_CueRow> {
? JjColors.accent.withValues(alpha: 0.12) ? JjColors.accent.withValues(alpha: 0.12)
: null, : null,
border: Border( border: Border(
bottom: const BorderSide(color: JjColors.border), bottom: BorderSide(color: JjColors.border),
left: BorderSide( left: BorderSide(
color: widget.active ? JjColors.accent : Colors.transparent, width: 3), color: widget.active ? JjColors.accent : Colors.transparent, width: 3),
), ),
@ -701,13 +701,13 @@ class _CueRowState extends State<_CueRow> {
IconButton( IconButton(
tooltip: tr('아래에 줄 추가'), tooltip: tr('아래에 줄 추가'),
iconSize: 18, iconSize: 18,
icon: const Icon(Icons.add, color: JjColors.textDim), icon: Icon(Icons.add, color: JjColors.textDim),
onPressed: () => e.insertAfter(widget.index), onPressed: () => e.insertAfter(widget.index),
), ),
IconButton( IconButton(
tooltip: tr('줄 삭제'), tooltip: tr('줄 삭제'),
iconSize: 18, iconSize: 18,
icon: const Icon(Icons.delete_outline, color: JjColors.textDim), icon: Icon(Icons.delete_outline, color: JjColors.textDim),
onPressed: () => e.removeAt(widget.index), onPressed: () => e.removeAt(widget.index),
), ),
]), ]),

View File

@ -129,7 +129,7 @@ class _SearchDialogState extends State<_SearchDialog> {
if (_error != null) if (_error != null)
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
child: SelectableText(_error!, style: const TextStyle(color: JjColors.danger)), child: SelectableText(_error!, style: TextStyle(color: JjColors.danger)),
), ),
Expanded(child: _list()), Expanded(child: _list()),
]), ]),

View File

@ -93,7 +93,7 @@ class _SyncPreviewViewState extends State<SyncPreviewView> {
return Row(children: [ return Row(children: [
const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)), const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)),
const SizedBox(width: 8), const SizedBox(width: 8),
Text(tr('무엇이 바뀌는지 비교하는 중…'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)), Text(tr('무엇이 바뀌는지 비교하는 중…'), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
]); ]);
} }
int n(PreviewAction a, [bool? right]) => int n(PreviewAction a, [bool? right]) =>
@ -244,10 +244,10 @@ Future<bool> confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM
for (final s in t.sources) ...[ for (final s in t.sources) ...[
Text(shortPath(s), style: const TextStyle(fontWeight: FontWeight.w600)), Text(shortPath(s), style: const TextStyle(fontWeight: FontWeight.w600)),
Text('→ ${shortPath(dstOf(s))}', style: const TextStyle(fontWeight: FontWeight.w600)), Text('→ ${shortPath(dstOf(s))}', style: const TextStyle(fontWeight: FontWeight.w600)),
Text('${vDisplay(s)} → ${vDisplay(dstOf(s))}', style: const TextStyle(fontSize: 11, color: JjColors.textDim)), Text('${vDisplay(s)} → ${vDisplay(dstOf(s))}', style: TextStyle(fontSize: 11, color: JjColors.textDim)),
], ],
// 230: 칸에 적힌 옵션이 아니라 실제로 실행할 것 (원본 지우기 옵션은 이동일 때만 붙는다) // 230: 칸에 적힌 옵션이 아니라 실제로 실행할 것 (원본 지우기 옵션은 이동일 때만 붙는다)
Text(effectiveRunText(t.method, t.options, move: t.move, skipNewer: skipNewer), style: const TextStyle(fontSize: 12, fontFamily: 'monospace', color: JjColors.textDim)), Text(effectiveRunText(t.method, t.options, move: t.move, skipNewer: skipNewer), style: TextStyle(fontSize: 12, fontFamily: 'monospace', color: JjColors.textDim)),
// 210: 이동이면 실행 뒤 원본에서 지워진다는 것을 분명히 // 210: 이동이면 실행 뒤 원본에서 지워진다는 것을 분명히
if (t.move) if (t.move)
Padding( Padding(

View File

@ -289,7 +289,7 @@ class _TextViewerPageState extends State<TextViewerPage> {
child: Row(children: [ child: Row(children: [
Expanded( Expanded(
child: Text(trf('{0} / {1} 읽음', [formatSize(_bytes.length), formatSize(_size)]), child: Text(trf('{0} / {1} 읽음', [formatSize(_bytes.length), formatSize(_size)]),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
), ),
FilledButton.tonal(onPressed: _more, child: Text(tr('더 읽기'))), FilledButton.tonal(onPressed: _more, child: Text(tr('더 읽기'))),
]), ]),

View File

@ -1,29 +1,113 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// CapCut 참고 어두운 테마 /// 화면 색 한 벌 (어두운 · 밝은)
class JjColors { class JjPalette {
static const bg = Color(0xFF141417); final Brightness brightness;
static const panel = Color(0xFF1E1E23); final Color bg, panel, panelHigh, border, accent, text, textDim, danger, success;
static const panelHigh = Color(0xFF28282F);
static const border = Color(0xFF2E2E36); /// 강조색 바탕 위 글자 (버튼 등)
static const accent = Color(0xFF00C4CC); // CapCut 계열 청록 final Color onAccent;
static const text = Color(0xFFE8E8EC); const JjPalette({
static const textDim = Color(0xFF8E8E99); required this.brightness,
static const danger = Color(0xFFFF5C6C); required this.bg,
static const success = Color(0xFF3DD68C); required this.panel,
required this.panelHigh,
required this.border,
required this.accent,
required this.text,
required this.textDim,
required this.danger,
required this.success,
required this.onAccent,
});
/// CapCut 참고 어두운 테마 (처음 값 - 예전 그대로)
static const dark = JjPalette(
brightness: Brightness.dark,
bg: Color(0xFF141417),
panel: Color(0xFF1E1E23),
panelHigh: Color(0xFF28282F),
border: Color(0xFF2E2E36),
accent: Color(0xFF00C4CC), // CapCut 계열 청록
text: Color(0xFFE8E8EC),
textDim: Color(0xFF8E8E99),
danger: Color(0xFFFF5C6C),
success: Color(0xFF3DD68C),
onAccent: Colors.black,
);
/// X-plore 비교 5-1 ⑦ 밝은 테마 (관리자 지시 10/21, 시험). 흰 바탕에서 글자 대비 4.5:1 이상이 되게 강조 · 경고 · 성공을 어둡게
static const light = JjPalette(
brightness: Brightness.light,
bg: Color(0xFFF4F4F6),
panel: Color(0xFFFFFFFF),
panelHigh: Color(0xFFE9E9EE),
border: Color(0xFFD3D3DB),
accent: Color(0xFF00747A),
text: Color(0xFF1B1B20),
textDim: Color(0xFF5E5E6A),
danger: Color(0xFFC62833),
success: Color(0xFF186B42),
onAccent: Colors.white,
);
} }
ThemeData buildTheme() { /// 앱 전체의 색. 테마 (환경 설정 "화면 밝기") 에 따라 [use] 로 바뀐다 - 그래서 const 로 쓰지 않는다
class JjColors {
static JjPalette _p = JjPalette.dark;
/// 지금 쓰는 색 한 벌
static JjPalette get palette => _p;
static Brightness get brightness => _p.brightness;
static bool get isLight => _p.brightness == Brightness.light;
/// 색 한 벌을 바꾼다 (바꿨으면 true). 화면 전체를 다시 그리는 것은 부르는 쪽 ([rebuildAllElements])
static bool use(Brightness b) {
final next = b == Brightness.light ? JjPalette.light : JjPalette.dark;
if (identical(next, _p)) return false;
_p = next;
return true;
}
static Color get bg => _p.bg;
static Color get panel => _p.panel;
static Color get panelHigh => _p.panelHigh;
static Color get border => _p.border;
static Color get accent => _p.accent;
static Color get text => _p.text;
static Color get textDim => _p.textDim;
static Color get danger => _p.danger;
static Color get success => _p.success;
static Color get onAccent => _p.onAccent;
}
/// 색을 바꾼 뒤 모든 화면 조각을 다시 그린다 (상태는 그대로 - [JjColors] 를 직접 읽는 곳도 새 색으로)
void rebuildAllElements(Element root) {
void mark(Element e) {
e.markNeedsBuild();
e.visitChildren(mark);
}
mark(root);
}
/// 환경 설정 "화면 밝기" 값 ('dark' 처음 값 · 'light' · 'system') → 밝기
Brightness resolveBrightness(String mode, Brightness platform) => switch (mode) {
'light' => Brightness.light,
'system' => platform,
_ => Brightness.dark,
};
ThemeData buildTheme([Brightness? brightness]) {
final c = brightness == null ? JjColors.palette : (brightness == Brightness.light ? JjPalette.light : JjPalette.dark);
final light = c.brightness == Brightness.light;
final base = ThemeData( final base = ThemeData(
brightness: Brightness.dark, brightness: c.brightness,
useMaterial3: true, useMaterial3: true,
colorScheme: const ColorScheme.dark( colorScheme: light
primary: JjColors.accent, ? ColorScheme.light(primary: c.accent, secondary: c.accent, surface: c.panel, error: c.danger, onPrimary: c.onAccent)
secondary: JjColors.accent, : ColorScheme.dark(primary: c.accent, secondary: c.accent, surface: c.panel, error: c.danger),
surface: JjColors.panel, scaffoldBackgroundColor: c.bg,
error: JjColors.danger,
),
scaffoldBackgroundColor: JjColors.bg,
fontFamily: 'Malgun Gothic', fontFamily: 'Malgun Gothic',
); );
return base.copyWith( return base.copyWith(
@ -35,22 +119,22 @@ ThemeData buildTheme() {
TargetPlatform.macOS: NavSlideTransitionsBuilder(), TargetPlatform.macOS: NavSlideTransitionsBuilder(),
TargetPlatform.iOS: NavSlideTransitionsBuilder(), TargetPlatform.iOS: NavSlideTransitionsBuilder(),
}), }),
dividerColor: JjColors.border, dividerColor: c.border,
textTheme: base.textTheme.apply( textTheme: base.textTheme.apply(
bodyColor: JjColors.text, bodyColor: c.text,
displayColor: JjColors.text, displayColor: c.text,
), ),
filledButtonTheme: FilledButtonThemeData( filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: JjColors.accent, backgroundColor: c.accent,
foregroundColor: Colors.black, foregroundColor: c.onAccent,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
), ),
), ),
outlinedButtonTheme: OutlinedButtonThemeData( outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
foregroundColor: JjColors.text, foregroundColor: c.text,
side: const BorderSide(color: JjColors.border), side: BorderSide(color: c.border),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
), ),
), ),

View File

@ -162,7 +162,7 @@ class _SubtitleTimelineState extends State<SubtitleTimeline> {
top: 3, top: 3,
child: IgnorePointer( child: IgnorePointer(
child: Text(trf('보기 {0}초 · Ctrl+휠 확대/축소', [_window.toStringAsFixed(_window < 10 ? 1 : 0)]), child: Text(trf('보기 {0}초 · Ctrl+휠 확대/축소', [_window.toStringAsFixed(_window < 10 ? 1 : 0)]),
style: const TextStyle(fontSize: 10, color: JjColors.textDim)), style: TextStyle(fontSize: 10, color: JjColors.textDim)),
), ),
), ),
], ],
@ -304,7 +304,7 @@ class _RulerPainter extends CustomPainter {
if (major) { if (major) {
tp.text = TextSpan( tp.text = TextSpan(
text: _label(t, step), text: _label(t, step),
style: const TextStyle(fontSize: 10, color: JjColors.textDim)); style: TextStyle(fontSize: 10, color: JjColors.textDim));
tp.layout(); tp.layout();
tp.paint(canvas, Offset(x + 3, 1)); tp.paint(canvas, Offset(x + 3, 1));
} }

View File

@ -634,7 +634,7 @@ class _TorPageState extends State<TorPage> with WebProxyVisibility {
const SizedBox(height: 12), const SizedBox(height: 12),
Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), textAlign: TextAlign.center), Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), textAlign: TextAlign.center),
const SizedBox(height: 8), const SizedBox(height: 8),
Text(body, style: const TextStyle(color: JjColors.textDim), textAlign: TextAlign.center), Text(body, style: TextStyle(color: JjColors.textDim), textAlign: TextAlign.center),
]), ]),
), ),
), ),

View File

@ -67,7 +67,7 @@ class _TranslateDialogState extends State<_TranslateDialog> {
src == undetermined src == undetermined
? tr('원어: 자동 감지 (글자로 추정)') ? tr('원어: 자동 감지 (글자로 추정)')
: trf('원어: {0} ({1})', [src.name, src.code]), : trf('원어: {0} ({1})', [src.name, src.code]),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
const SizedBox(height: 16), const SizedBox(height: 16),
Text(tr('번역할 언어 (jj_mkv\\파일명_언어코드.srt 로 저장 후 MKV 에 추가)'), Text(tr('번역할 언어 (jj_mkv\\파일명_언어코드.srt 로 저장 후 MKV 에 추가)'),
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600)), style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),

View File

@ -103,8 +103,8 @@ class _TrashPageState extends State<TrashPage> {
if (items != null && items.isNotEmpty) if (items != null && items.isNotEmpty)
TextButton.icon( TextButton.icon(
onPressed: _busy ? null : () => _purge(items, all: true), onPressed: _busy ? null : () => _purge(items, all: true),
icon: const Icon(Icons.delete_forever_outlined, color: JjColors.danger), icon: Icon(Icons.delete_forever_outlined, color: JjColors.danger),
label: Text(tr('비우기'), style: const TextStyle(color: JjColors.danger)), label: Text(tr('비우기'), style: TextStyle(color: JjColors.danger)),
), ),
], ],
), ),
@ -115,7 +115,7 @@ class _TrashPageState extends State<TrashPage> {
widget.days > 0 widget.days > 0
? trf('지운 것은 {0}일 동안 여기에 두었다가 저절로 지웁니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).', [widget.days]) ? trf('지운 것은 {0}일 동안 여기에 두었다가 저절로 지웁니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).', [widget.days])
: tr('휴지통이 꺼져 있습니다 - 지우면 바로 영구 삭제됩니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).'), : tr('휴지통이 꺼져 있습니다 - 지우면 바로 영구 삭제됩니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim), style: TextStyle(fontSize: 12, color: JjColors.textDim),
), ),
), ),
if (_busy) const LinearProgressIndicator(minHeight: 2), if (_busy) const LinearProgressIndicator(minHeight: 2),
@ -151,7 +151,7 @@ class _TrashPageState extends State<TrashPage> {
Expanded(child: Text(trf('{0}개 고름', [chosen.length]))), Expanded(child: Text(trf('{0}개 고름', [chosen.length]))),
TextButton( TextButton(
onPressed: _busy ? null : () => _purge(chosen), onPressed: _busy ? null : () => _purge(chosen),
child: Text(tr('영구 삭제'), style: const TextStyle(color: JjColors.danger)), child: Text(tr('영구 삭제'), style: TextStyle(color: JjColors.danger)),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
FilledButton.icon( FilledButton.icon(

View File

@ -75,7 +75,7 @@ Future<void> checkForUpdate(BuildContext context, AppController c, {bool manual
Text(trf('지금 쓰는 버전: v{0} → 새 버전: v{1}', [current, r.version])), Text(trf('지금 쓰는 버전: v{0} → 새 버전: v{1}', [current, r.version])),
if (r.zipSize > 0) if (r.zipSize > 0)
Text(trf('받는 크기: 약 {0}MB · 받은 파일 · AI 모델 · 설정은 그대로 둡니다', [(r.zipSize / 1e6).round()]), Text(trf('받는 크기: 약 {0}MB · 받은 파일 · AI 모델 · 설정은 그대로 둡니다', [(r.zipSize / 1e6).round()]),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
const SizedBox(height: 12), const SizedBox(height: 12),
Text(tr('바뀐 내용'), style: const TextStyle(fontWeight: FontWeight.w600)), Text(tr('바뀐 내용'), style: const TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 4), const SizedBox(height: 4),
@ -160,7 +160,7 @@ class _UpdateCheckButtonState extends State<UpdateCheckButton> {
Widget build(BuildContext context) => Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ Widget build(BuildContext context) => Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [
OutlinedButton(onPressed: _busy ? null : _check, child: Text(tr('최신 버전 확인'))), OutlinedButton(onPressed: _busy ? null : _check, child: Text(tr('최신 버전 확인'))),
if (_busy) const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)), if (_busy) const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)),
if (_result != null) Text(_result!, style: const TextStyle(fontSize: 12, color: JjColors.textDim)), if (_result != null) Text(_result!, style: TextStyle(fontSize: 12, color: JjColors.textDim)),
]); ]);
} }
@ -220,7 +220,7 @@ class _VersionPickerState extends State<_VersionPicker> {
Padding( Padding(
padding: const EdgeInsets.fromLTRB(4, 0, 4, 8), padding: const EdgeInsets.fromLTRB(4, 0, 4, 8),
child: Text(trf('지금 쓰는 버전: v{0}', [widget.current]), child: Text(trf('지금 쓰는 버전: v{0}', [widget.current]),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
), ),
for (final r in all) for (final r in all)
ListTile( ListTile(

View File

@ -343,7 +343,7 @@ class _VaultPageState extends State<VaultPage> with WidgetsBindingObserver {
Text(trf('넣을 곳: {0}', [_cwdLabel()]), style: const TextStyle(fontSize: 12.5)), Text(trf('넣을 곳: {0}', [_cwdLabel()]), style: const TextStyle(fontSize: 12.5)),
if (remote) if (remote)
Text(tr('원격 파일은 이 기기의 앱 전용 폴더에 받아 넣고, 받은 임시 파일은 바로 지웁니다'), Text(tr('원격 파일은 이 기기의 앱 전용 폴더에 받아 넣고, 받은 임시 파일은 바로 지웁니다'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
for (final f in plan.failed.entries.take(3)) for (final f in plan.failed.entries.take(3))
Text(trf('읽지 못해 빼고 넣습니다: {0} ({1})', [vBasename(f.key), f.value]), Text(trf('읽지 못해 빼고 넣습니다: {0} ({1})', [vBasename(f.key), f.value]),
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent), maxLines: 2, overflow: TextOverflow.ellipsis), style: const TextStyle(fontSize: 12, color: Colors.orangeAccent), maxLines: 2, overflow: TextOverflow.ellipsis),
@ -853,7 +853,7 @@ class _VaultPageState extends State<VaultPage> with WidgetsBindingObserver {
Widget _unlockForm() { Widget _unlockForm() {
final waiting = _waitUntil != null && DateTime.now().isBefore(_waitUntil!); final waiting = _waitUntil != null && DateTime.now().isBefore(_waitUntil!);
return ListView(padding: const EdgeInsets.all(16), children: [ return ListView(padding: const EdgeInsets.all(16), children: [
const Icon(Icons.lock_outline, size: 48, color: JjColors.textDim), Icon(Icons.lock_outline, size: 48, color: JjColors.textDim),
const SizedBox(height: 8), const SizedBox(height: 8),
TextField( TextField(
controller: _pw, controller: _pw,
@ -898,7 +898,7 @@ class _VaultPageState extends State<VaultPage> with WidgetsBindingObserver {
Expanded( Expanded(
child: _cwd.isEmpty child: _cwd.isEmpty
? Text(tr('넣기는 파일 탐색기에서 파일 · 폴더를 길게 눌러 [보관함에 넣기]'), ? Text(tr('넣기는 파일 탐색기에서 파일 · 폴더를 길게 눌러 [보관함에 넣기]'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)) style: TextStyle(fontSize: 12, color: JjColors.textDim))
: Text(_cwdLabel(), maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.w600)), : Text(_cwdLabel(), maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.w600)),
), ),
DropdownButton<String>( DropdownButton<String>(
@ -922,7 +922,7 @@ class _VaultPageState extends State<VaultPage> with WidgetsBindingObserver {
final f = _cwd.isEmpty ? dirs[i] : '$_cwd/${dirs[i]}'; final f = _cwd.isEmpty ? dirs[i] : '$_cwd/${dirs[i]}';
final n = v.filesUnder(f).length; final n = v.filesUnder(f).length;
return ListTile( return ListTile(
leading: const Icon(Icons.folder, color: JjColors.accent), leading: Icon(Icons.folder, color: JjColors.accent),
title: Text(dirs[i], maxLines: 1, overflow: TextOverflow.ellipsis), title: Text(dirs[i], maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(trf('파일 {0}개', [n])), subtitle: Text(trf('파일 {0}개', [n])),
onTap: _busy ? null : () => setState(() => _cwd = f), onTap: _busy ? null : () => setState(() => _cwd = f),
@ -947,7 +947,7 @@ class _VaultPageState extends State<VaultPage> with WidgetsBindingObserver {
} }
final e = list[i - dirs.length]; final e = list[i - dirs.length];
return ListTile( return ListTile(
leading: const Icon(Icons.lock, color: JjColors.accent), leading: Icon(Icons.lock, color: JjColors.accent),
title: Text(e.name, maxLines: 1, overflow: TextOverflow.ellipsis), title: Text(e.name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text('${formatSize(e.size)} · ${_day(e.added)}'), subtitle: Text('${formatSize(e.size)} · ${_day(e.added)}'),
onTap: _busy ? null : () => _view(e), onTap: _busy ? null : () => _view(e),

View File

@ -123,13 +123,13 @@ class _VideoAdjustState extends State<_VideoAdjust> {
color: JjColors.panelHigh, color: JjColors.panelHigh,
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8), padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
child: Row(children: [ child: Row(children: [
const Icon(Icons.tune, color: JjColors.accent, size: 20), Icon(Icons.tune, color: JjColors.accent, size: 20),
const SizedBox(width: 8), const SizedBox(width: 8),
Text(tr('화면 · 색 보정'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), Text(tr('화면 · 색 보정'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Text(tr('MKV 만들기에 적용됩니다 (영상을 다시 인코딩)'), child: Text(tr('MKV 만들기에 적용됩니다 (영상을 다시 인코딩)'),
overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: JjColors.textDim)), overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: JjColors.textDim)),
), ),
TextButton.icon( TextButton.icon(
onPressed: s.adjusts ? () => _set(s.resetAdjust()) : null, onPressed: s.adjusts ? () => _set(s.resetAdjust()) : null,
@ -161,12 +161,12 @@ class _VideoAdjustState extends State<_VideoAdjust> {
child: Stack(children: [ child: Stack(children: [
Positioned.fill( Positioned.fill(
child: v == null child: v == null
? Center(child: Text(tr('미리보기: 목록에서 동영상을 고르세요'), style: const TextStyle(color: JjColors.textDim))) ? Center(child: Text(tr('미리보기: 목록에서 동영상을 고르세요'), style: TextStyle(color: JjColors.textDim)))
: _error != null : _error != null
? Center( ? Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Text(_error!, style: const TextStyle(color: JjColors.danger, fontSize: 12)), child: Text(_error!, style: TextStyle(color: JjColors.danger, fontSize: 12)),
)) ))
: img == null : img == null
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
@ -209,7 +209,7 @@ class _VideoAdjustState extends State<_VideoAdjust> {
Widget _inspector() { Widget _inspector() {
Widget head(String t) => Padding( Widget head(String t) => Padding(
padding: const EdgeInsets.fromLTRB(0, 16, 0, 8), padding: const EdgeInsets.fromLTRB(0, 16, 0, 8),
child: Text(t, style: const TextStyle(fontSize: 13, color: JjColors.accent, fontWeight: FontWeight.w600)), child: Text(t, style: TextStyle(fontSize: 13, color: JjColors.accent, fontWeight: FontWeight.w600)),
); );
const frameIcons = { const frameIcons = {
FrameChoice.original: Icons.crop_original, FrameChoice.original: Icons.crop_original,
@ -258,7 +258,7 @@ class _VideoAdjustState extends State<_VideoAdjust> {
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
s.adjusts ? '${tr('적용')}: ${s.adjustSummary}' : tr('바꾼 것이 없습니다 (원본 그대로)'), s.adjusts ? '${tr('적용')}: ${s.adjustSummary}' : tr('바꾼 것이 없습니다 (원본 그대로)'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim), style: TextStyle(fontSize: 12, color: JjColors.textDim),
), ),
]), ]),
); );
@ -272,7 +272,7 @@ class _VideoAdjustState extends State<_VideoAdjust> {
Text(label, style: const TextStyle(fontSize: 13)), Text(label, style: const TextStyle(fontSize: 13)),
if (hint != null) ...[ if (hint != null) ...[
const SizedBox(width: 6), const SizedBox(width: 6),
Text(hint, style: const TextStyle(fontSize: 11, color: JjColors.textDim)), Text(hint, style: TextStyle(fontSize: 11, color: JjColors.textDim)),
], ],
const Spacer(), const Spacer(),
SizedBox( SizedBox(

View File

@ -37,7 +37,7 @@ class WebDavSettings extends StatelessWidget {
padding: const EdgeInsets.only(left: 16), padding: const EdgeInsets.only(left: 16),
child: SettingTile( child: SettingTile(
dense: true, dense: true,
leading: const Icon(Icons.cloud, color: JjColors.accent), leading: Icon(Icons.cloud, color: JjColors.accent),
title: Text(s.label), title: Text(s.label),
subtitle: Text( subtitle: Text(
'${s.url}${s.user.isEmpty ? '' : ' · ${s.user}'}${s.insecure ? ' · ${tr('인증서 확인 안 함')}' : ''}' '${s.url}${s.user.isEmpty ? '' : ' · ${s.user}'}${s.insecure ? ' · ${tr('인증서 확인 안 함')}' : ''}'
@ -51,7 +51,7 @@ class WebDavSettings extends StatelessWidget {
), ),
IconButton( IconButton(
tooltip: tr('지우기'), tooltip: tr('지우기'),
icon: const Icon(Icons.delete_outline, size: 18, color: JjColors.textDim), icon: Icon(Icons.delete_outline, size: 18, color: JjColors.textDim),
// 46: 확인 뒤 (저장된 비밀번호도 함께 지워진다) // 46: 확인 뒤 (저장된 비밀번호도 함께 지워진다)
onPressed: () => deleteDavServer(context, c, s), onPressed: () => deleteDavServer(context, c, s),
), ),

View File

@ -111,7 +111,7 @@ class _WifiSharePageState extends State<WifiSharePage> {
List<Widget> _offView() { List<Widget> _offView() {
final mins = widget.c.settings.wifiShareMinutes; final mins = widget.c.settings.wifiShareMinutes;
return [ return [
Text(tr('공유할 폴더'), style: const TextStyle(color: JjColors.textDim, fontSize: 12)), Text(tr('공유할 폴더'), style: TextStyle(color: JjColors.textDim, fontSize: 12)),
const SizedBox(height: 4), const SizedBox(height: 4),
SelectableText(widget.folder, style: const TextStyle(fontSize: 15)), SelectableText(widget.folder, style: const TextStyle(fontSize: 15)),
const SizedBox(height: 16), const SizedBox(height: 16),
@ -168,9 +168,9 @@ class _WifiSharePageState extends State<WifiSharePage> {
final left = s.endsAt?.difference(DateTime.now()); final left = s.endsAt?.difference(DateTime.now());
return [ return [
Row(children: [ Row(children: [
const Icon(Icons.wifi_tethering, color: JjColors.success), Icon(Icons.wifi_tethering, color: JjColors.success),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded(child: Text(tr('공유 중'), style: const TextStyle(fontSize: 16, color: JjColors.success))), Expanded(child: Text(tr('공유 중'), style: TextStyle(fontSize: 16, color: JjColors.success))),
FilledButton.icon( FilledButton.icon(
style: FilledButton.styleFrom(backgroundColor: JjColors.danger), style: FilledButton.styleFrom(backgroundColor: JjColors.danger),
onPressed: _stop, onPressed: _stop,
@ -179,15 +179,15 @@ class _WifiSharePageState extends State<WifiSharePage> {
), ),
]), ]),
const SizedBox(height: 8), const SizedBox(height: 8),
SelectableText(s.root, style: const TextStyle(color: JjColors.textDim, fontSize: 12)), SelectableText(s.root, style: TextStyle(color: JjColors.textDim, fontSize: 12)),
const SizedBox(height: 16), const SizedBox(height: 16),
Text(tr('접속 코드'), style: const TextStyle(color: JjColors.textDim, fontSize: 12)), Text(tr('접속 코드'), style: TextStyle(color: JjColors.textDim, fontSize: 12)),
SelectableText(s.code, style: const TextStyle(fontSize: 40, letterSpacing: 8, fontWeight: FontWeight.w600)), SelectableText(s.code, style: const TextStyle(fontSize: 40, letterSpacing: 8, fontWeight: FontWeight.w600)),
const SizedBox(height: 12), const SizedBox(height: 12),
if (hosts.isEmpty) if (hosts.isEmpty)
_note(Icons.wifi_off, tr('이 기기의 Wi-Fi 주소를 찾지 못했습니다. Wi-Fi 에 연결돼 있는지 확인하세요.'), color: JjColors.danger) _note(Icons.wifi_off, tr('이 기기의 Wi-Fi 주소를 찾지 못했습니다. Wi-Fi 에 연결돼 있는지 확인하세요.'), color: JjColors.danger)
else ...[ else ...[
Text(tr('다른 기기의 브라우저에서 이 주소를 여세요'), style: const TextStyle(color: JjColors.textDim, fontSize: 12)), Text(tr('다른 기기의 브라우저에서 이 주소를 여세요'), style: TextStyle(color: JjColors.textDim, fontSize: 12)),
for (final h in hosts) for (final h in hosts)
Row(children: [ Row(children: [
Expanded(child: SelectableText('http://$h:${s.port}', style: const TextStyle(fontSize: 16))), Expanded(child: SelectableText('http://$h:${s.port}', style: const TextStyle(fontSize: 16))),
@ -198,7 +198,7 @@ class _WifiSharePageState extends State<WifiSharePage> {
), ),
]), ]),
const SizedBox(height: 8), const SizedBox(height: 8),
Text(tr('또는 다른 기기의 카메라로 QR 을 찍으세요 (코드가 들어 있습니다)'), style: const TextStyle(color: JjColors.textDim, fontSize: 12)), Text(tr('또는 다른 기기의 카메라로 QR 을 찍으세요 (코드가 들어 있습니다)'), style: TextStyle(color: JjColors.textDim, fontSize: 12)),
const SizedBox(height: 8), const SizedBox(height: 8),
Center(child: QrView(data: s.urlWithCode(hosts.first), size: 200)), Center(child: QrView(data: s.urlWithCode(hosts.first), size: 200)),
], ],
@ -215,9 +215,9 @@ class _WifiSharePageState extends State<WifiSharePage> {
]), ]),
const Divider(height: 32), const Divider(height: 32),
Text(trf('접속 기록 ({0}) - 이 화면에만 보이고 저장하지 않습니다', [s.log.length]), Text(trf('접속 기록 ({0}) - 이 화면에만 보이고 저장하지 않습니다', [s.log.length]),
style: const TextStyle(color: JjColors.textDim, fontSize: 12)), style: TextStyle(color: JjColors.textDim, fontSize: 12)),
if (s.log.isEmpty) if (s.log.isEmpty)
Padding(padding: const EdgeInsets.symmetric(vertical: 8), child: Text(tr('아직 접속한 기기가 없습니다'), style: const TextStyle(color: JjColors.textDim))) Padding(padding: const EdgeInsets.symmetric(vertical: 8), child: Text(tr('아직 접속한 기기가 없습니다'), style: TextStyle(color: JjColors.textDim)))
else else
for (final a in s.log.take(50)) for (final a in s.log.take(50))
Padding( Padding(
@ -268,7 +268,9 @@ class _WifiSharePageState extends State<WifiSharePage> {
], ],
); );
Widget _note(IconData icon, String text, {Color color = JjColors.textDim}) => Padding( Widget _note(IconData icon, String text, {Color? color}) => _noteIn(icon, text, color ?? JjColors.textDim);
Widget _noteIn(IconData icon, String text, Color color) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Icon(icon, size: 18, color: color), Icon(icon, size: 18, color: color),

View File

@ -144,7 +144,7 @@ class WorkPanel extends StatelessWidget {
padding: const EdgeInsets.fromLTRB(12, 6, 4, 6), padding: const EdgeInsets.fromLTRB(12, 6, 4, 6),
child: Row( child: Row(
children: [ children: [
const Icon(Icons.dashboard_outlined, size: 18, color: JjColors.accent), Icon(Icons.dashboard_outlined, size: 18, color: JjColors.accent),
const SizedBox(width: 6), const SizedBox(width: 6),
Expanded( Expanded(
child: Text(tr('작업 현황'), style: TextStyle(fontWeight: FontWeight.w600)), child: Text(tr('작업 현황'), style: TextStyle(fontWeight: FontWeight.w600)),
@ -158,7 +158,7 @@ class WorkPanel extends StatelessWidget {
IconButton( IconButton(
tooltip: c.busy ? tr('MKV 만들기 (대기열에 추가)') : tr('MKV 만들기'), tooltip: c.busy ? tr('MKV 만들기 (대기열에 추가)') : tr('MKV 만들기'),
iconSize: 18, iconSize: 18,
icon: const Icon(Icons.play_arrow, color: JjColors.accent), icon: Icon(Icons.play_arrow, color: JjColors.accent),
onPressed: c.videos.isEmpty || c.ffmpegVersion == null onPressed: c.videos.isEmpty || c.ffmpegVersion == null
? null ? null
: c.buildAll, : c.buildAll,
@ -167,7 +167,7 @@ class WorkPanel extends StatelessWidget {
IconButton( IconButton(
tooltip: tr('모든 작업 취소'), tooltip: tr('모든 작업 취소'),
iconSize: 18, iconSize: 18,
icon: const Icon(Icons.stop, color: JjColors.danger), icon: Icon(Icons.stop, color: JjColors.danger),
onPressed: c.cancel, onPressed: c.cancel,
), ),
IconButton( IconButton(
@ -192,7 +192,7 @@ class WorkPanel extends StatelessWidget {
_title(tr('작업')), _title(tr('작업')),
// 123: AI 그림 (남은 장 · 예상 시간) // 123: AI 그림 (남은 장 · 예상 시간)
if (AiJobs.instance.statusLine case final ai?) ...[ if (AiJobs.instance.statusLine case final ai?) ...[
Text('▶ $ai', style: const TextStyle(fontSize: 12, color: JjColors.accent)), Text('▶ $ai', style: TextStyle(fontSize: 12, color: JjColors.accent)),
LinearProgressIndicator(value: AiJobs.instance.progress), LinearProgressIndicator(value: AiJobs.instance.progress),
], ],
// 157: 끝난 AI 일 - 어디에 저장했는지와 [열기] // 157: 끝난 AI 일 - 어디에 저장했는지와 [열기]
@ -209,12 +209,12 @@ class WorkPanel extends StatelessWidget {
else if (c.busy) ...[ else if (c.busy) ...[
Text( Text(
'▶ ${c.currentJob ?? tr('작업 중')}', '▶ ${c.currentJob ?? tr('작업 중')}',
style: const TextStyle(fontSize: 12, color: JjColors.accent), style: TextStyle(fontSize: 12, color: JjColors.accent),
), ),
for (final (i, j) in c.pendingJobs.indexed) for (final (i, j) in c.pendingJobs.indexed)
Text( Text(
trf('{0}. {1} (대기)', [i + 1, j]), trf('{0}. {1} (대기)', [i + 1, j]),
style: const TextStyle(fontSize: 12, color: JjColors.textDim), style: TextStyle(fontSize: 12, color: JjColors.textDim),
), ),
], ],
const SizedBox(height: 12), const SizedBox(height: 12),
@ -227,7 +227,7 @@ class WorkPanel extends StatelessWidget {
Expanded( Expanded(
child: Text( child: Text(
t, t,
style: const TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: JjColors.textDim, color: JjColors.textDim,
@ -248,7 +248,7 @@ class _Dim extends StatelessWidget {
padding: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(vertical: 4),
child: Text( child: Text(
text, text,
style: const TextStyle(fontSize: 12, color: JjColors.textDim), style: TextStyle(fontSize: 12, color: JjColors.textDim),
), ),
); );
} }
@ -434,7 +434,7 @@ class JobIndicator extends StatelessWidget {
maxLines: 1, maxLines: 1,
softWrap: false, softWrap: false,
overflow: TextOverflow.fade, overflow: TextOverflow.fade,
style: const TextStyle(fontSize: 12, color: JjColors.accent), style: TextStyle(fontSize: 12, color: JjColors.accent),
), ),
), ),
], ],
@ -450,7 +450,7 @@ class JobIndicator extends StatelessWidget {
: '${c.currentJob ?? tr('작업 중')}${p == null ? '' : ' ${(p * 100).round()}%'}${wait > 0 ? trf(' · 대기 {0}', [wait]) : ''}', : '${c.currentJob ?? tr('작업 중')}${p == null ? '' : ' ${(p * 100).round()}%'}${wait > 0 ? trf(' · 대기 {0}', [wait]) : ''}',
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12, color: JjColors.accent), style: TextStyle(fontSize: 12, color: JjColors.accent),
), ),
), ),
], ],

118
test/theme_test.dart Normal file
View File

@ -0,0 +1,118 @@
import 'dart:math';
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/settings.dart';
import 'package:jj_mkvmaker/main.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/theme.dart';
/// X-plore 비교 5-1 ⑦ 밝은 테마 (관리자 지시 10/21): 처음 값 어둡게 (예전 그대로) · 밝게 · 기기 따라 · 바꾸면 상태는 그대로 다시 그림
double _lum(Color c) {
double ch(double v) => v <= 0.03928 ? v / 12.92 : pow((v + 0.055) / 1.055, 2.4).toDouble();
return 0.2126 * ch(c.r) + 0.7152 * ch(c.g) + 0.0722 * ch(c.b);
}
/// WCAG 대비
double contrast(Color a, Color b) {
final x = _lum(a), y = _lum(b);
return (max(x, y) + 0.05) / (min(x, y) + 0.05);
}
class _Probe extends StatefulWidget {
const _Probe();
@override
State<_Probe> createState() => _ProbeState();
}
class _ProbeState extends State<_Probe> {
int taps = 0;
@override
Widget build(BuildContext context) => GestureDetector(
onTap: () => setState(() => taps++),
child: Text('probe $taps', style: TextStyle(color: JjColors.text)),
);
}
void main() {
tearDown(() => JjColors.use(Brightness.dark));
test('처음 값은 어둡게 (예전 색 그대로) · 모르는 값 · 없는 값도 어둡게 · 고른 값은 그대로', () {
expect(AppSettings().uiBrightness, 'dark');
expect(AppSettings.fromJson({}).uiBrightness, 'dark');
expect(AppSettings.fromJson({'uiBrightness': 'neon'}).uiBrightness, 'dark');
expect(AppSettings.fromJson({'uiBrightness': 'light'}).uiBrightness, 'light');
expect(AppSettings.fromJson(AppSettings.fromJson({'uiBrightness': 'system'}).toJson()).uiBrightness, 'system');
expect(JjColors.bg, const Color(0xFF141417));
expect(JjColors.accent, const Color(0xFF00C4CC));
expect(JjColors.textDim, const Color(0xFF8E8E99));
expect(resolveBrightness('dark', Brightness.light), Brightness.dark);
expect(resolveBrightness('light', Brightness.dark), Brightness.light);
expect(resolveBrightness('system', Brightness.light), Brightness.light);
expect(resolveBrightness('?', Brightness.light), Brightness.dark);
});
test('밝은 색: 바탕 · 칸 위 글자 대비 4.5:1 이상 (글 · 흐린 글 · 강조 · 경고 · 성공 · 강조 위 글자)', () {
const l = JjPalette.light;
for (final bg in [l.bg, l.panel, l.panelHigh]) {
for (final (name, fg) in [('text', l.text), ('textDim', l.textDim), ('accent', l.accent), ('danger', l.danger), ('success', l.success)]) {
expect(contrast(fg, bg), greaterThanOrEqualTo(4.5), reason: '$name on ${bg.toARGB32().toRadixString(16)}');
}
}
expect(contrast(l.onAccent, l.accent), greaterThanOrEqualTo(4.5));
expect(buildTheme(Brightness.light).brightness, Brightness.light);
expect(buildTheme(Brightness.light).scaffoldBackgroundColor, l.bg);
expect(buildTheme(Brightness.dark).scaffoldBackgroundColor, JjPalette.dark.bg);
});
testWidgets('색을 바꾸면 JjColors 를 직접 읽는 곳도 다시 그림 · 상태는 그대로', (t) async {
await t.pumpWidget(const MaterialApp(home: Scaffold(body: _Probe())));
await t.tap(find.text('probe 0'));
await t.pump();
expect(t.widget<Text>(find.text('probe 1')).style!.color, JjPalette.dark.text);
expect(JjColors.use(Brightness.light), isTrue);
expect(JjColors.use(Brightness.light), isFalse, reason: '같으면 바꾸지 않음');
rebuildAllElements(t.binding.rootElement!);
await t.pump();
expect(t.widget<Text>(find.text('probe 1')).style!.color, JjPalette.light.text, reason: '눌러 둔 수 (상태) 는 그대로');
});
testWidgets('앱: 설정 "화면 밝기" 를 밝게 → 바로 밝은 테마 · 기기 따라는 기기 밝기를 따라 바뀜 · 어둡게로 돌아옴', (t) async {
t.view.physicalSize = const Size(1400, 900);
t.view.devicePixelRatio = 1;
addTearDown(t.view.reset);
addTearDown(t.platformDispatcher.clearPlatformBrightnessTestValue);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
await t.runAsync(c.init);
await t.pumpWidget(JjMkvMakerApp(controller: c));
await t.pump();
BuildContext ctx() => t.element(find.byType(Scaffold).first);
expect(Theme.of(ctx()).brightness, Brightness.dark);
expect(JjColors.isLight, isFalse);
await t.runAsync(() => c.updateSettings((x) => x.uiBrightness = 'light'));
await t.pump();
await t.pump();
expect(JjColors.isLight, isTrue);
expect(Theme.of(ctx()).brightness, Brightness.light);
expect(Theme.of(ctx()).scaffoldBackgroundColor, JjPalette.light.bg);
// 기기 따라
t.platformDispatcher.platformBrightnessTestValue = Brightness.dark;
await t.runAsync(() => c.updateSettings((x) => x.uiBrightness = 'system'));
await t.pump();
await t.pump();
expect(JjColors.isLight, isFalse);
t.platformDispatcher.platformBrightnessTestValue = Brightness.light;
await t.pump();
await t.pump();
expect(JjColors.isLight, isTrue, reason: '기기 밝기가 바뀌면 따라감');
await t.runAsync(() => c.updateSettings((x) => x.uiBrightness = 'dark'));
await t.pump();
await t.pump();
expect(JjColors.isLight, isFalse);
expect(Theme.of(ctx()).brightness, Brightness.dark);
await t.pumpWidget(const SizedBox());
});
}