From 2553107aa586dfb63b230fd9f0ebcb41dcca8fd4 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:10:04 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A2=81=EC=9D=80=20=ED=99=94=EB=A9=B4=20(?= =?UTF-8?q?=EC=A0=91=EC=9D=80=20=ED=8F=B4=EB=93=9C=20=C2=B7=20=ED=9C=B4?= =?UTF-8?q?=EB=8C=80=ED=8F=B0=20=EC=84=B8=EB=A1=9C)=20=EB=B0=B0=EC=B9=98?= =?UTF-8?q?=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AppTopBar: 폭 720 미만이면 위쪽 막대를 두 줄로 (위: 화면 이동 · 화면 크기 · 설정 · 종료, 아래: 화면의 도구). 모든 화면 (MKV · 브라우저 · 탐색기 · Rsync · 모니터링 · 다운로드 · 설정 · 플레이어 · 자막 편집 · 작업 현황 · 즐겨찾기) 에 적용. 접은 폴드에서 설정 · 종료 버튼이 화면 밖으로 밀려 누를 수 없던 것. - MKV 화면: 좁으면 목록 위 · 자세히 보기 아래, 변환 설정 줄은 옆으로 밀어 보기, 자막 버튼은 줄바꿈. - 대화 상자 37개: 내용이 화면보다 길면 스크롤 (가로 폴드에서 버튼이 내용과 겹치던 것). - SettingTile: 좁은 화면에서 설정 줄의 고르기 상자 · 입력 칸을 설명 아래로. - 다운로드 도구 줄 옆으로 밀어 보기, 빈 목록 안내 글 여백. Co-Authored-By: Claude Opus 5.5 --- lib/ui/app_actions.dart | 43 +++++++++- lib/ui/bookmark_ui.dart | 20 ++--- lib/ui/browser_page.dart | 47 ++++++----- lib/ui/cef_setup.dart | 3 + lib/ui/copy_sync_settings.dart | 22 ++--- lib/ui/downloads_page.dart | 32 +++++--- lib/ui/explorer_page.dart | 20 +++-- lib/ui/home_page.dart | 136 ++++++++++++++++++------------- lib/ui/language_settings.dart | 8 +- lib/ui/monitor_page.dart | 55 +++++++------ lib/ui/player_page.dart | 30 +++---- lib/ui/rsync_setup.dart | 2 + lib/ui/setting_tile.dart | 64 +++++++++++++++ lib/ui/settings_page.dart | 81 +++++++++--------- lib/ui/setup_dialog.dart | 3 + lib/ui/subtitle_editor_page.dart | 14 ++-- lib/ui/translate_dialog.dart | 1 + lib/ui/update_dialog.dart | 5 ++ lib/ui/version_restore.dart | 1 + lib/ui/webdav_settings.dart | 6 +- lib/ui/work_panel.dart | 13 +-- 21 files changed, 387 insertions(+), 219 deletions(-) create mode 100644 lib/ui/setting_tile.dart diff --git a/lib/ui/app_actions.dart b/lib/ui/app_actions.dart index 509a7da..c096d12 100644 --- a/lib/ui/app_actions.dart +++ b/lib/ui/app_actions.dart @@ -17,6 +17,46 @@ import '../l10n/tr.dart'; const double appBarHeight = 56; const double appBarRightPadding = 12; +/// 이보다 좁으면 (접은 폴드 · 휴대폰 세로 등) 위쪽 막대를 두 줄로: 위 = 화면 이동 · 설정 · 종료, 아래 = 화면의 도구 +const double compactWidth = 720; +bool isCompact(BuildContext context) => MediaQuery.sizeOf(context).width < compactWidth; + +/// 모든 화면의 위쪽 막대: [nav] [middle (남는 너비)] [actions]. +/// 좁은 화면에서는 두 줄 - 위: [nav] … [actions], 아래: [middle] 이 한 줄을 다 쓴다. +class AppTopBar extends StatelessWidget { + final Widget nav; + final Widget middle; + final Widget actions; + const AppTopBar({super.key, required this.nav, required this.middle, required this.actions}); + + @override + Widget build(BuildContext context) { + if (!isCompact(context)) { + return Container( + height: appBarHeight, + color: JjColors.panel, + padding: const EdgeInsets.only(left: 8, right: appBarRightPadding), + child: Row(children: [nav, const SizedBox(width: 8), Expanded(child: middle), actions]), + ); + } + return Container( + color: JjColors.panel, + padding: const EdgeInsets.only(left: 4, right: 4), + child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + // 아주 좁으면 왼쪽 이동 버튼 묶음을 줄여서라도 설정 · 종료가 밀려나지 않게 + SizedBox( + height: 48, + child: Row(children: [ + Expanded(child: Align(alignment: Alignment.centerLeft, child: FittedBox(fit: BoxFit.scaleDown, child: nav))), + actions, + ]), + ), + SizedBox(height: 48, child: Padding(padding: const EdgeInsets.only(left: 6), child: middle)), + ]), + ); + } +} + /// 앱 전체에서 쓰는 것 (환경 설정을 열 컨트롤러 · 종료 동작) 을 화면들에 내려 준다 class AppScope extends InheritedWidget { final AppController controller; @@ -321,9 +361,10 @@ class AppActions extends StatelessWidget { final exit = onExit ?? scope?.onExit; if (controller == null && exit == null) return const SizedBox(); final usage = controller?.usage; + final compact = isCompact(context); return Row(mainAxisSize: MainAxisSize.min, children: [ // CPU · MEM: 변환 · AI 작업이 PC 를 얼마나 쓰는지 (PC 전체 기준) - if (usage != null && showUsage) UsageView(usage: usage), + if (usage != null && showUsage && !compact) UsageView(usage: usage), // 화면 크기: − 100% + if (controller != null) ScaleButtons(c: controller), const SizedBox(width: 4), diff --git a/lib/ui/bookmark_ui.dart b/lib/ui/bookmark_ui.dart index a6b0f0c..d26cb86 100644 --- a/lib/ui/bookmark_ui.dart +++ b/lib/ui/bookmark_ui.dart @@ -160,6 +160,7 @@ Future showNewBookmarkDialog(BuildContext context, BookmarksController bm, final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( + scrollable: true, title: Text(tr('즐겨찾기 추가')), content: SizedBox( width: 440, @@ -204,6 +205,7 @@ Future askText(BuildContext context, String title, String label, String final r = await showDialog( context: context, builder: (ctx) => AlertDialog( + scrollable: true, title: Text(title), content: SizedBox( width: 380, @@ -468,17 +470,16 @@ class _BookmarkManagerPageState extends State { }, ); - Widget _topBar() => Container( - height: appBarHeight, - color: JjColors.panel, - padding: const EdgeInsets.only(left: 8, right: appBarRightPadding), - child: Row(children: [ - const AppNavButtons(), - const SizedBox(width: 8), + Widget _topBar() => AppTopBar( + nav: const AppNavButtons(), + actions: const AppActions(), + middle: Row(children: [ const Icon(Icons.bookmarks_outlined, color: JjColors.accent), const SizedBox(width: 8), - Text(tr('즐겨찾기 관리자'), style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), - const SizedBox(width: 24), + if (!isCompact(context)) ...[ + Text(tr('즐겨찾기 관리자'), style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + const SizedBox(width: 24), + ], Expanded( child: TextField( controller: _search, @@ -530,7 +531,6 @@ class _BookmarkManagerPageState extends State { } }, ), - const AppActions(), ]), ); diff --git a/lib/ui/browser_page.dart b/lib/ui/browser_page.dart index 6fd72cd..6fafe39 100644 --- a/lib/ui/browser_page.dart +++ b/lib/ui/browser_page.dart @@ -516,15 +516,16 @@ class _BrowserPageState extends State with RouteAware { icon: Icon(i, size: 20, color: color), onPressed: f, ); - return Container( - height: appBarHeight, - color: JjColors.panel, - padding: const EdgeInsets.only(left: 8, right: appBarRightPadding), - child: Row(children: [ - const AppNavButtons(onBrowserPage: true), - const SizedBox(width: 8), - Container(width: 1, height: 24, color: JjColors.border), - const SizedBox(width: 4), + // 좁은 화면: 둘째 줄에 페이지 이동 · 주소 · 다운로드 (외부 브라우저 · 작업 현황은 빼고, 다운로드는 아이콘만) + final compact = isCompact(context); + return AppTopBar( + nav: const AppNavButtons(onBrowserPage: true), + actions: AppActions(c: widget.c), + middle: Row(children: [ + if (!compact) ...[ + Container(width: 1, height: 24, color: JjColors.border), + const SizedBox(width: 4), + ], // 웹 페이지 이동 (앱 화면 이동인 왼쪽 버튼과 구분되는 모양) btn(Icons.arrow_back_ios_new, tr('이전 페이지 (Alt+←)'), _canBack ? () => _nav?.back() : null), btn(Icons.arrow_forward_ios, tr('다음 페이지 (Alt+→)'), _canFwd ? () => _nav?.forward() : null), @@ -565,14 +566,22 @@ class _BrowserPageState extends State with RouteAware { () => _setTranslate(!_trOn, force: true), color: _trOn ? JjColors.accent : null), const SizedBox(width: 4), - FilledButton.icon( - onPressed: _canDownload ? _download : null, - icon: const Icon(Icons.download, size: 18), - label: Text(tr('다운로드')), - ), + if (compact) + IconButton.filled( + tooltip: tr('다운로드'), + visualDensity: VisualDensity.compact, + onPressed: _canDownload ? _download : null, + icon: const Icon(Icons.download, size: 18), + ) + else + FilledButton.icon( + onPressed: _canDownload ? _download : null, + icon: const Icon(Icons.download, size: 18), + label: Text(tr('다운로드')), + ), const SizedBox(width: 4), - btn(Icons.open_in_new, tr('외부 브라우저로 열기'), () => _openExternal(_url)), - if (widget.c.busy && !_work) + if (!compact) btn(Icons.open_in_new, tr('외부 브라우저로 열기'), () => _openExternal(_url)), + if (widget.c.busy && !_work && !compact) InkWell( onTap: _toggleWork, borderRadius: BorderRadius.circular(6), @@ -581,11 +590,11 @@ class _BrowserPageState extends State with RouteAware { child: JobIndicator(c: widget.c, compact: true), ), ), - btn(_work ? Icons.view_sidebar : Icons.view_sidebar_outlined, tr('작업 현황 보기 · 화면 분할 (Ctrl+Shift+J)'), - _toggleWork, color: widget.c.busy ? JjColors.accent : null), + if (!compact) + btn(_work ? Icons.view_sidebar : Icons.view_sidebar_outlined, tr('작업 현황 보기 · 화면 분할 (Ctrl+Shift+J)'), + _toggleWork, color: widget.c.busy ? JjColors.accent : null), btn(_panel ? Icons.bookmarks : Icons.bookmarks_outlined, tr('즐겨찾기 관리 (Ctrl+Shift+B)'), () => setState(() => _panel = !_panel)), - AppActions(c: widget.c), ]), ); } diff --git a/lib/ui/cef_setup.dart b/lib/ui/cef_setup.dart index 8624578..cf9cc86 100644 --- a/lib/ui/cef_setup.dart +++ b/lib/ui/cef_setup.dart @@ -12,6 +12,7 @@ Future chooseChromeEngine(BuildContext context, AppController c) async { final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( + scrollable: true, title: Text(tr('내장 Chrome 엔진 내려받기')), content: SizedBox( width: 460, @@ -40,6 +41,7 @@ Future chooseChromeEngine(BuildContext context, AppController c) async { final restart = await showDialog( context: context, builder: (ctx) => AlertDialog( + scrollable: true, title: Text(tr('다시 시작')), content: Text(tr('Chrome 엔진은 프로그램을 다시 시작하면 쓸 수 있습니다. 지금 다시 시작할까요?')), actions: [ @@ -97,6 +99,7 @@ class _InstallProgressState extends State<_InstallProgress> { @override Widget build(BuildContext context) => AlertDialog( + scrollable: true, title: Text(tr('내장 Chrome 엔진')), content: SizedBox( width: 420, diff --git a/lib/ui/copy_sync_settings.dart b/lib/ui/copy_sync_settings.dart index 26a616e..66d84ca 100644 --- a/lib/ui/copy_sync_settings.dart +++ b/lib/ui/copy_sync_settings.dart @@ -14,6 +14,7 @@ import 'folder_picker.dart'; import 'rsync_setup.dart'; import 'schedule_editor.dart'; import 'theme.dart'; +import 'setting_tile.dart'; /// 환경 설정의 복사 · 동기화 부분. /// - 파일 탐색기 ([rsync] false): 파일 / 폴더 복사 방법 (현재 방식 · robocopy), robocopy 옵션, 속도 제한 @@ -58,7 +59,7 @@ class _CopySyncSettingsState extends State { String _valid(String v) => v == 'robocopy' && Platform.isWindows ? v : 'builtin'; /// 옵션 입력칸 (바꾸면 바로 저장, ↺ 로 기본값) - Widget _options(String title, String hint, String value, String def, void Function(AppSettings, String) set) => ListTile( + Widget _options(String title, String hint, String value, String def, void Function(AppSettings, String) set) => SettingTile( title: Text(title), subtitle: Text(hint), trailing: SizedBox( @@ -95,7 +96,7 @@ class _CopySyncSettingsState extends State { return Column(children: [ const Divider(height: 1), if (desk) ...[ - ListTile( + SettingTile( title: Text(tr('파일 복사 · 이동 방법')), subtitle: Text(tr('파일만 골랐을 때')), trailing: DropdownButton( @@ -104,7 +105,7 @@ class _CopySyncSettingsState extends State { onChanged: (v) => c.updateSettings((x) => x.copyMethodFile = v!), ), ), - ListTile( + SettingTile( title: Text(tr('폴더 복사 · 이동 방법')), subtitle: Text(tr('폴더가 들어 있을 때')), trailing: DropdownButton( @@ -116,7 +117,7 @@ class _CopySyncSettingsState extends State { _options(tr('robocopy 옵션'), tr('예: /E /COPY:DAT /DCOPY:T /R:2 /W:2. 이동은 /MOVE 를 자동으로 붙임'), s.robocopyOptions, defaultRobocopyOptions, (x, v) => x.robocopyOptions = v), ], - ListTile( + SettingTile( title: Text(tr('속도 제한 (KB/s)')), subtitle: Text(tr('0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. ' '옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)')), @@ -142,7 +143,7 @@ class _CopySyncSettingsState extends State { return Column(children: [ _options(tr('rsync 옵션'), tr('예: -avPog (보관 · 자세히 · 진행 · 소유자 · 그룹). 속도 제한은 파일 탐색기의 속도 제한을 함께 씀'), s.rsyncOptions, defaultRsyncOptions, (x, v) => x.rsyncOptions = v), - ListTile( + SettingTile( title: Text(tr('rsync 가져오기')), subtitle: Text(_checking ? tr('확인 중…') @@ -187,7 +188,7 @@ class _CopySyncSettingsState extends State { ]), ), if (s.rsyncSource == 'custom') - ListTile( + SettingTile( title: Text(tr('rsync 실행 파일')), subtitle: Text(s.rsyncPath.isEmpty ? tr('고르지 않음') : s.rsyncPath), trailing: TextButton( @@ -226,6 +227,7 @@ class _LiveSyncTile extends StatelessWidget { context: context, builder: (ctx) => StatefulBuilder( builder: (ctx, set) => AlertDialog( + scrollable: true, title: Text(tr('실시간 동기화 추가')), content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('$src\n→ $dst', style: const TextStyle(fontSize: 13)), @@ -242,7 +244,7 @@ class _LiveSyncTile extends StatelessWidget { title: Text(tr('원본에 없는 것을 대상에서 지우기')), subtitle: Text(tr('rsync --delete · robocopy /PURGE. 끄면 대상에 더하기 · 바꾸기만')), ), - ListTile( + SettingTile( contentPadding: EdgeInsets.zero, leading: WeekGridPreview(lines: schedule, width: 96), title: Text(scheduleSummary(schedule)), @@ -273,7 +275,7 @@ class _LiveSyncTile extends StatelessWidget { final live = LiveSync.instance; final pairs = c.settings.liveSyncPairs; Widget body() => Column(children: [ - ListTile( + SettingTile( title: Text(tr('실시간 동기화 (lsyncd 처럼)')), subtitle: Text(Platform.isWindows ? tr('원본 폴더가 바뀌면 곧바로 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안). 시작할 때 한 번 맞춥니다.') @@ -288,7 +290,7 @@ class _LiveSyncTile extends StatelessWidget { for (var i = 0; i < pairs.length; i++) Padding( padding: const EdgeInsets.only(left: 16), - child: ListTile( + child: SettingTile( dense: true, leading: Icon(Icons.sync, color: pairs[i].enabled ? JjColors.accent : JjColors.textDim), title: Text('${pairs[i].source} → ${pairs[i].target}', maxLines: 2, overflow: TextOverflow.ellipsis), @@ -389,7 +391,7 @@ class LiveSyncRunOptions extends StatelessWidget { title: Text(tr('백그라운드로 실행')), subtitle: Text(backgroundHelp()), ), - ListTile( + SettingTile( dense: true, title: Text(tr('앱을 다시 켤 때 동기화')), subtitle: Text(tr('바로 시작: 켜진 동기화를 모두 시작 · 골라서 시작: 켤 때 고르기 · 시작 안 함: 모니터링 > lsync 의 시작 버튼으로')), diff --git a/lib/ui/downloads_page.dart b/lib/ui/downloads_page.dart index 7ab6d71..4cc5e70 100644 --- a/lib/ui/downloads_page.dart +++ b/lib/ui/downloads_page.dart @@ -76,16 +76,13 @@ class _DownloadsPageState extends State { final allSel = d.tasks.isNotEmpty && d.selected.length == d.tasks.length; return Scaffold( body: Column(children: [ - Container( - height: appBarHeight, - color: JjColors.panel, - padding: const EdgeInsets.only(left: 8, right: appBarRightPadding), - child: Row(children: [ - const AppNavButtons(onDownloadsPage: true), - const SizedBox(width: 8), + AppTopBar( + nav: const AppNavButtons(onDownloadsPage: true), + actions: const AppActions(), + middle: Row(children: [ Text(trf('다운로드 ({0})', [d.tasks.length]), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), - const SizedBox(width: 20), + SizedBox(width: isCompact(context) ? 8 : 20), Expanded( child: TextField( controller: _url, @@ -103,7 +100,6 @@ class _DownloadsPageState extends State { ), const SizedBox(width: 8), FilledButton(onPressed: _add, child: Text(tr('추가'))), - const AppActions(), ]), ), Container( @@ -126,7 +122,12 @@ class _DownloadsPageState extends State { ), ]), ), - const Spacer(), + // 좁으면 옆으로 밀어 볼 수 있게 (오른쪽 끝부터 보임) + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + reverse: true, + child: Row(children: [ if (d.addToEditList != null) ...[ _btn(Icons.playlist_add, tr('동영상 추가'), hasSel && d.selectedVideoFiles.isNotEmpty ? () => _addToEditList(context) : null), @@ -158,18 +159,23 @@ class _DownloadsPageState extends State { const SizedBox(width: 12), _btn(Icons.cleaning_services_outlined, tr('완료 정리'), d.tasks.any((t) => !t.unfinished) ? d.cleanupFinished : null), + ]), + ), + ), ]), ), const Divider(height: 1), Expanded( child: d.tasks.isEmpty - ? Center( + ? Padding( + padding: const EdgeInsets.all(24), + child: Center( child: Text( Platform.isAndroid ? tr('다운로드가 없습니다.\nYouTube 주소 · 마그넷 링크를 위 칸에 붙여 넣거나, 웹 브라우저에서 [다운로드] 를 누르세요.') : tr('다운로드가 없습니다.\nYouTube 주소나 마그넷 링크를 복사(Ctrl+C)하면 자동으로 받습니다.'), textAlign: TextAlign.center, - style: const TextStyle(color: JjColors.textDim))) + style: const TextStyle(color: JjColors.textDim)))) : ListView.builder( itemCount: d.tasks.length, itemBuilder: (_, i) => _DownloadRow(d: d, t: d.tasks[i]), @@ -206,6 +212,7 @@ class _DownloadsPageState extends State { final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( + scrollable: true, title: Text(tr('삭제')), content: Text(trf('선택한 {0}개를 목록에서 삭제합니다.\n받는 중인 항목은 중지하고 받던 파일도 지웁니다. (완료된 파일은 남습니다)', [n])), actions: [ @@ -366,6 +373,7 @@ class _ExitDialog extends StatelessWidget { final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( + scrollable: true, title: Text(tr('종료')), content: Text(tr('다운로드를 종료하시겠습니까?')), actions: [ diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 88955a6..5b5b065 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -555,6 +555,7 @@ class _ExplorerPageState extends State { context: context, barrierDismissible: false, builder: (ctx) => AlertDialog( + scrollable: true, title: Text(tr('WebDAV 에서 받는 중')), content: ValueListenableBuilder<(int, int)>( valueListenable: progress, @@ -860,6 +861,7 @@ class _ExplorerPageState extends State { final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( + scrollable: true, title: Text(tr('삭제')), // 무엇을 지우는지 늘 보여 준다 (여러 개면 앞의 8개 이름 · 폴더) content: Text(paths.length == 1 @@ -983,6 +985,7 @@ class _ExplorerPageState extends State { final go = await showDialog( context: context, builder: (ctx) => AlertDialog( + scrollable: true, title: Text(move ? tr('이동') : tr('복사')), content: Text('${trf('{0}개 항목을 다음 폴더로 {1}\n{2}', [sources.length, move ? tr('옮길까요?') : tr('복사할까요?'), vDisplay(dest)])}' '\n\n${trf('방법: {0}', [tr(method.label)])}' @@ -1069,6 +1072,7 @@ class _ExplorerPageState extends State { final go = await showDialog( context: context, builder: (ctx) => StatefulBuilder(builder: (ctx, set) => AlertDialog( + scrollable: true, title: Text(both ? tr('rsync 양쪽 (⇄)') : 'rsync'), content: SizedBox( width: 560, @@ -1280,6 +1284,7 @@ class _ExplorerPageState extends State { return showDialog( context: context, builder: (ctx) => AlertDialog( + scrollable: true, title: Text(title), content: TextField( controller: ctl, @@ -1304,6 +1309,7 @@ class _ExplorerPageState extends State { await showDialog( context: context, builder: (ctx) => AlertDialog( + scrollable: true, title: Text(e.name), content: SelectableText([ e.path, @@ -1337,6 +1343,7 @@ class _ExplorerPageState extends State { context: context, builder: (ctx) => StatefulBuilder( builder: (ctx, set) => AlertDialog( + scrollable: true, title: Text(tr('정렬 기준')), content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Wrap(spacing: 6, children: [ @@ -1407,6 +1414,7 @@ class _ExplorerPageState extends State { ]), ]); return AlertDialog( + scrollable: true, title: Text(tr('창 배치')), content: SizedBox( width: 420, @@ -1557,13 +1565,10 @@ class _ExplorerPageState extends State { ); } - Widget _topBar() => Container( - height: appBarHeight, - color: JjColors.panel, - padding: const EdgeInsets.only(left: 8, right: appBarRightPadding), - child: Row(children: [ - AppNavButtons(onExplorerPage: !widget.rsync, onRsyncPage: widget.rsync), - const SizedBox(width: 8), + Widget _topBar() => AppTopBar( + nav: AppNavButtons(onExplorerPage: !widget.rsync, onRsyncPage: widget.rsync), + actions: AppActions(c: c), + middle: Row(children: [ Text(widget.rsync ? 'Rsync' : tr('파일 탐색기'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), const SizedBox(width: 12), Expanded( @@ -1586,7 +1591,6 @@ class _ExplorerPageState extends State { ), ], ), - AppActions(c: c), ]), ); diff --git a/lib/ui/home_page.dart b/lib/ui/home_page.dart index d68d4a4..4da1edb 100644 --- a/lib/ui/home_page.dart +++ b/lib/ui/home_page.dart @@ -55,18 +55,34 @@ class HomePage extends StatelessWidget { _EncodeBar(c: c), const Divider(height: 1), Expanded( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SizedBox(width: 320, child: _VideoList(c: c)), - const VerticalDivider(width: 1), - Expanded( - child: c.selected == null - ? const _EmptyHint() - : _VideoDetail(c: c, v: c.selected!), - ), - ], - ), + // 좁은 화면 (접은 폴드 · 휴대폰 세로): 목록을 위에, 자세히 보기를 아래에 + child: isCompact(context) + ? Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + height: (MediaQuery.sizeOf(context).height * 0.32).clamp(160.0, 420.0), + child: _VideoList(c: c)), + const Divider(height: 1), + Expanded( + child: c.selected == null + ? const _EmptyHint() + : _VideoDetail(c: c, v: c.selected!), + ), + ], + ) + : Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox(width: 320, child: _VideoList(c: c)), + const VerticalDivider(width: 1), + Expanded( + child: c.selected == null + ? const _EmptyHint() + : _VideoDetail(c: c, v: c.selected!), + ), + ], + ), ), const Divider(height: 1), if (c.settings.showLog) @@ -121,6 +137,7 @@ class _TopBar extends StatelessWidget { final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( + scrollable: true, title: Text(tr('MKV 만들기')), content: Text(trf('선택한 동영상이 없습니다.\n목록 전체 {0}개 ' '(이미 만든 것을 빼면 {1}개) 를 MKV 로 만들까요?\n\n' '일부만 만들려면 취소하고 동영상 목록에서 체크하세요.', [c.videos.length, todo])), actions: [ @@ -145,6 +162,7 @@ class _TopBar extends StatelessWidget { final count = c.checked.isEmpty ? '' : ' (${c.checked.length})'; // 창 너비에 맞춰 차례로 줄인다: 제목 글 → 왼쪽 버튼을 아이콘만 → 재생 · 브라우저를 아이콘만 → CPU · MEM 숨김. // (오른쪽의 화면 크기 · 환경 설정 · 종료 버튼은 어떤 너비에서도 밀려나지 않게) + final compact = isCompact(context); return LayoutBuilder(builder: (context, box) { final hasAi = c.aiAvailable; final hasPlayer = c.services.createMediaPlayer != null; @@ -152,18 +170,20 @@ class _TopBar extends StatelessWidget { // 아주 좁을 때: 다운로드 상자 숨김 (왼쪽 공통 버튼으로 열 수 있음) → MKV 만들기도 아이콘만 var dlBox = downloads != null, mkvText = true; // 각 부분의 너비 (실제 화면에서 잰 값) + // 좁은 화면: 이동 버튼 · 설정 · 종료는 첫 줄에 있으므로 둘째 줄 (가운데 도구) 만 잰다 double need() => - 8 + appBarRightPadding + AppNavButtons.width + 8 + (title ? 126 : 0) + 24 + + (compact ? 0 : 8 + appBarRightPadding + AppNavButtons.width + 8) + (title ? 126 : 0) + 24 + (leftText ? 153 + (hasAi ? 161 + 298 : 0) : 40 + (hasAi ? 96 : 0)) + 8 + (c.busy ? (jobText ? 350 : 60) : 0) + (hasPlayer ? (rightText ? 201 : 48) : 0) + (mkvText ? 166 + (c.busy && jobText ? 60 : 0) : 48) + (rightText ? 166 : 48) + // 결과 폴더 (dlBox ? 162 : 0) + - (usage ? 220 : 0) + 96 + 92 + + (compact ? 0 : (usage ? 220 : 0) + 96 + 92) + (c.checked.isEmpty ? 0 : 3 * 26) + 8; // 여유 final w = box.maxWidth; + if (compact) title = false; if (need() > w) title = false; if (need() > w) jobText = false; if (need() > w) leftText = false; @@ -189,17 +209,15 @@ class _TopBar extends StatelessWidget { ); } - return Container( - height: appBarHeight, - color: JjColors.panel, - padding: const EdgeInsets.only(left: 8, right: appBarRightPadding), - child: Row( + return AppTopBar( + // 모든 화면 공통: [JJ 홈] [MKV 화면] [뒤로] [다운로드 목록] + nav: const AppNavButtons(), + // 화면 크기 · 환경 설정 · 종료: 모든 화면에서 같은 자리 + actions: AppActions(c: c, onExit: onExit, showUsage: usage), + middle: Row( children: [ - // 모든 화면 공통: [JJ 홈] [MKV 화면] [뒤로] [다운로드 목록] - const AppNavButtons(), - const SizedBox(width: 8), if (title) const Text('JJ_MKVMaker', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700)), - const SizedBox(width: 24), + SizedBox(width: compact ? 0 : 24), // 왼쪽 묶음: 그래도 모자라면 옆으로 밀어 볼 수 있게 Expanded( child: SingleChildScrollView( @@ -271,8 +289,6 @@ class _TopBar extends StatelessWidget { const SizedBox(width: 12), _DownloadBox(d: downloads!), ], - // 화면 크기 · 환경 설정 · 종료: 모든 화면에서 같은 자리 - AppActions(c: c, onExit: onExit, showUsage: usage), ], ), ); @@ -369,11 +385,8 @@ class _EncodeBar extends StatelessWidget { onChanged: locked ? null : (v) => onChanged(v as T), ); - return Container( - height: 44, - color: JjColors.panel, - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row(children: [ + final compact = isCompact(context); + final row = Row(children: [ label(tr('화면 크기')), drop(s.resolution, ResolutionChoice.values, (r) => r.label, c.setResolution), const SizedBox(width: 20), @@ -396,7 +409,7 @@ class _EncodeBar extends StatelessWidget { ), ), const SizedBox(width: 12), - Expanded( + if (!compact) Expanded( child: Text( s.adjusts ? s.adjustSummary @@ -408,7 +421,12 @@ class _EncodeBar extends StatelessWidget { fontSize: 12, color: up > 0 && s.reencode ? JjColors.danger : JjColors.textDim), ), ), - ]), + ]); + return Container( + height: 44, + color: JjColors.panel, + padding: EdgeInsets.symmetric(horizontal: compact ? 8 : 16), + child: compact ? SingleChildScrollView(scrollDirection: Axis.horizontal, child: row) : row, ); } } @@ -751,8 +769,9 @@ class _VideoDetail extends StatelessWidget { } Widget _list(BuildContext context, MediaInfo? info, StreamInfo? video, int audioCount, bool locked) { + final side = isCompact(context) ? 12.0 : 20.0; return ListView( - padding: const EdgeInsets.fromLTRB(20, 20, 20, 88), + padding: EdgeInsets.fromLTRB(side, side, side, 88), children: [ Row(children: [ Expanded( @@ -799,30 +818,33 @@ class _VideoDetail extends StatelessWidget { ), ], const SizedBox(height: 24), - Row(children: [ - Text(tr('자막'), - style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), - const Spacer(), - if (c.aiAvailable) ...[ - FilledButton.icon( - onPressed: locked || c.ffmpegVersion == null ? null : () => showAiDialog(context, c, v), - icon: const Icon(Icons.auto_awesome, size: 18), - label: Text(tr('AI 자막 만들기')), - ), - const SizedBox(width: 8), - ], - if (c.subtitleProvider != null) ...[ - OutlinedButton.icon( - onPressed: locked ? null : () => showSubtitleSearch(context, c, v), - icon: const Icon(Icons.travel_explore, size: 18), - label: Text(tr('인터넷 자막 찾기')), - ), - const SizedBox(width: 8), - ], - OutlinedButton.icon( - onPressed: locked ? null : () => c.pickSubtitlesFor(v), - icon: const Icon(Icons.subtitles_outlined, size: 18), - label: Text(tr('자막 파일 추가')), + // 좁으면 버튼이 다음 줄로 (오른쪽 정렬) + Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text(tr('자막'), style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), + ), + const SizedBox(width: 16), + Expanded( + child: Wrap(alignment: WrapAlignment.end, spacing: 8, runSpacing: 8, children: [ + if (c.aiAvailable) + FilledButton.icon( + onPressed: locked || c.ffmpegVersion == null ? null : () => showAiDialog(context, c, v), + icon: const Icon(Icons.auto_awesome, size: 18), + label: Text(tr('AI 자막 만들기')), + ), + if (c.subtitleProvider != null) + OutlinedButton.icon( + onPressed: locked ? null : () => showSubtitleSearch(context, c, v), + icon: const Icon(Icons.travel_explore, size: 18), + label: Text(tr('인터넷 자막 찾기')), + ), + OutlinedButton.icon( + onPressed: locked ? null : () => c.pickSubtitlesFor(v), + icon: const Icon(Icons.subtitles_outlined, size: 18), + label: Text(tr('자막 파일 추가')), + ), + ]), ), ]), const SizedBox(height: 8), diff --git a/lib/ui/language_settings.dart b/lib/ui/language_settings.dart index bdf2b13..c51a0bd 100644 --- a/lib/ui/language_settings.dart +++ b/lib/ui/language_settings.dart @@ -5,6 +5,7 @@ import '../core/languages.dart'; import '../l10n/tr.dart'; import '../services/ai_services.dart'; import 'theme.dart'; +import 'setting_tile.dart'; /// 환경 설정 맨 위: 화면 언어 고르기 · 언어 추가 (AI 자동 번역) · 더한 언어 삭제 class LanguageSettings extends StatelessWidget { @@ -16,7 +17,7 @@ class LanguageSettings extends StatelessWidget { builder: (context, _) { final added = i18n.available.where((c) => !I18nController.builtIn.contains(c)).toList(); return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - ListTile( + SettingTile( leading: const Icon(Icons.translate, color: JjColors.accent), // 읽을 수 없는 언어를 골라도 찾을 수 있게 영어를 함께 title: Text(uiLanguage == 'en' ? 'Language' : '${tr('화면 언어')} · Language'), @@ -45,7 +46,7 @@ class LanguageSettings extends StatelessWidget { ]), ), for (final code in added) - ListTile( + SettingTile( dense: true, contentPadding: const EdgeInsets.fromLTRB(72, 0, 16, 0), title: Text(I18nController.nativeName(code)), @@ -73,7 +74,7 @@ Future addLanguageDialog(BuildContext context) async { height: 420, child: ListView(children: [ for (final l in i18n.addable) - ListTile( + SettingTile( title: Text(I18nController.nativeName(l.code)), subtitle: Text('${l.name} (${l.code})'), onTap: () => Navigator.pop(ctx, l), @@ -91,6 +92,7 @@ Future addLanguageDialog(BuildContext context) async { context: context, barrierDismissible: false, builder: (ctx) => AlertDialog( + scrollable: true, title: Text(trf('{0} 추가', [I18nController.nativeName(pick.code)])), content: ValueListenableBuilder<(String, double)>( valueListenable: state, diff --git a/lib/ui/monitor_page.dart b/lib/ui/monitor_page.dart index 7c664a8..854e9a2 100644 --- a/lib/ui/monitor_page.dart +++ b/lib/ui/monitor_page.dart @@ -34,28 +34,31 @@ class MonitorPage extends StatelessWidget { initialIndex: initialTab, child: Scaffold( body: Column(children: [ - Container( - height: appBarHeight, - color: JjColors.panel, - padding: const EdgeInsets.only(left: 8, right: appBarRightPadding), - child: Row(children: [ - const AppNavButtons(), - const SizedBox(width: 8), - Text(tr('모니터링'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), - const SizedBox(width: 16), - Expanded( - child: TabBar( - isScrollable: true, - tabAlignment: TabAlignment.start, - tabs: [ - Tab(icon: const Icon(Icons.copy_all_outlined, size: 18), text: tr('복사 · rsync')), - Tab(icon: const Icon(Icons.sync, size: 18), text: tr('lsync (실시간 동기화)')), - ], + Builder(builder: (context) { + // 좁은 화면: 둘째 줄 (높이 48) 에 맞게 탭에 아이콘을 옆으로 + final compact = isCompact(context); + Tab tab(IconData icon, String text) => compact + ? Tab(height: 46, child: Row(mainAxisSize: MainAxisSize.min, children: [Icon(icon, size: 16), const SizedBox(width: 6), Text(text)])) + : Tab(icon: Icon(icon, size: 18), text: text); + return AppTopBar( + nav: const AppNavButtons(), + actions: AppActions(c: c), + middle: Row(children: [ + Text(tr('모니터링'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + const SizedBox(width: 16), + Expanded( + child: TabBar( + isScrollable: true, + tabAlignment: TabAlignment.start, + tabs: [ + tab(Icons.copy_all_outlined, tr('복사 · rsync')), + tab(Icons.sync, tr('lsync (실시간 동기화)')), + ], + ), ), - ), - AppActions(c: c), - ]), - ), + ]), + ); + }), Expanded( child: TabBarView(children: [ _CopyTab(c: c), @@ -182,8 +185,11 @@ class _CopyTab extends StatelessWidget { final tasks = center.tasks; if (tasks.isEmpty) { return Center( - child: Text(tr('실행한 rsync 가 없습니다. Rsync 화면에서 → · ← · ⇄ 로 실행하면 여기에 자동으로 등록됩니다.'), - style: const TextStyle(color: JjColors.textDim)), + child: Padding( + padding: const EdgeInsets.all(24), + child: Text(tr('실행한 rsync 가 없습니다. Rsync 화면에서 → · ← · ⇄ 로 실행하면 여기에 자동으로 등록됩니다.'), + textAlign: TextAlign.center, style: const TextStyle(color: JjColors.textDim)), + ), ); } return ListView( @@ -281,6 +287,7 @@ class _CopyCardState extends State<_CopyCard> { final go = await showDialog( context: context, builder: (ctx) => AlertDialog( + scrollable: true, title: Text(tr('옵션을 저장했습니다')), content: Text(running ? tr('지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?') @@ -307,6 +314,7 @@ class _CopyCardState extends State<_CopyCard> { final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( + scrollable: true, title: Text(tr('lsync 로 이동')), content: Text(tr('지금 실행 중입니다. 멈추고 lsync (실시간 동기화) 로 옮길까요? 남은 것은 lsync 가 이어서 맞춥니다.')), actions: [ @@ -509,6 +517,7 @@ class _LiveTabState extends State<_LiveTab> { context: context, builder: (ctx) => StatefulBuilder( builder: (ctx, set) => AlertDialog( + scrollable: true, title: Text(tr('최종 정리')), content: SizedBox( width: 600, diff --git a/lib/ui/player_page.dart b/lib/ui/player_page.dart index 94dfd37..92d5f1c 100644 --- a/lib/ui/player_page.dart +++ b/lib/ui/player_page.dart @@ -272,25 +272,21 @@ class _PlayerPageState extends State { ); } - Widget _header() => Container( - height: appBarHeight, - color: JjColors.panel, - padding: const EdgeInsets.only(left: 8, right: appBarRightPadding), - child: Row(children: [ - const AppNavButtons(), - const SizedBox(width: 8), - Expanded( - child: ValueListenableBuilder( - valueListenable: pl.state, - builder: (_, s, _) => Text( - '${p.basename(_currentFile)} (${s.index + 1}/${pl.playlist.length})', - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 14), - ), + Widget _header() => AppTopBar( + nav: const AppNavButtons(), + actions: AppActions(c: c), + middle: Align( + alignment: Alignment.centerLeft, + child: ValueListenableBuilder( + valueListenable: pl.state, + builder: (_, s, _) => Text( + '${p.basename(_currentFile)} (${s.index + 1}/${pl.playlist.length})', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 14), ), ), - AppActions(c: c), - ]), + ), ); Widget _videoArea() { diff --git a/lib/ui/rsync_setup.dart b/lib/ui/rsync_setup.dart index 82979b3..6f016ce 100644 --- a/lib/ui/rsync_setup.dart +++ b/lib/ui/rsync_setup.dart @@ -10,6 +10,7 @@ Future installRsyncWithDialog(BuildContext context) async { final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( + scrollable: true, title: Text(tr('rsync 내려받기')), content: Text(tr('rsync 는 처음 쓸 때 내려받습니다 (MSYS2 공식 저장소의 rsync 3.5.1 · 약 8MB, 파일마다 SHA256 확인).\n' '설정 폴더의 rsync 에 두어 업데이트해도 남습니다. 지금 받을까요?')), @@ -25,6 +26,7 @@ Future installRsyncWithDialog(BuildContext context) async { context: context, barrierDismissible: false, builder: (_) => AlertDialog( + scrollable: true, title: Text(tr('rsync 받는 중')), content: ValueListenableBuilder<(double, String)>( valueListenable: progress, diff --git a/lib/ui/setting_tile.dart b/lib/ui/setting_tile.dart new file mode 100644 index 0000000..7b41301 --- /dev/null +++ b/lib/ui/setting_tile.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; + +import 'app_actions.dart' show isCompact; + +/// 환경 설정의 한 줄 (ListTile 과 같은 쓰임새). +/// 좁은 화면 (접은 폴드 · 휴대폰 세로) 에서는 오른쪽의 넓은 조절 (고르기 상자 · 입력 칸 · 버튼) 을 설명 아래로 내려 +/// 제목 · 설명이 한두 글자 폭으로 눌리지 않게 한다. 스위치 · 체크 상자 · 아이콘 버튼은 그대로 오른쪽에. +class SettingTile extends StatelessWidget { + final Widget? leading; + final Widget? title; + final Widget? subtitle; + final Widget? trailing; + final bool isThreeLine; + final bool enabled; + final bool? dense; + final VoidCallback? onTap; + final EdgeInsetsGeometry? contentPadding; + + const SettingTile({ + super.key, + this.leading, + this.title, + this.subtitle, + this.trailing, + this.isThreeLine = false, + this.enabled = true, + this.dense, + this.onTap, + this.contentPadding, + }); + + static bool _small(Widget w) => w is Switch || w is Checkbox || w is Icon || w is IconButton || w is Radio; + + @override + Widget build(BuildContext context) { + final t = trailing; + if (t == null || _small(t) || !isCompact(context)) { + return ListTile( + leading: leading, + title: title, + subtitle: subtitle, + trailing: t, + isThreeLine: isThreeLine, + enabled: enabled, + dense: dense, + onTap: onTap, + contentPadding: contentPadding, + ); + } + return ListTile( + leading: leading, + title: title, + subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + ?subtitle, + const SizedBox(height: 6), + Align(alignment: Alignment.centerLeft, child: t), + ]), + enabled: enabled, + dense: dense, + onTap: onTap, + contentPadding: contentPadding, + ); + } +} diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index c500346..14db67d 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -27,6 +27,7 @@ import 'webdav_settings.dart'; import 'explorer_look.dart' show ExplorerStyle; import 'theme.dart'; import '../l10n/tr.dart'; +import 'setting_tile.dart'; /// 동시 작업 수 고르기: 1 · 5 · 10 · 무한(0) · 직접 입력 class CountSelector extends StatelessWidget { @@ -82,6 +83,7 @@ class _CountInputDialogState extends State<_CountInputDialog> { @override Widget build(BuildContext context) => AlertDialog( + scrollable: true, title: Text(tr('직접 입력')), content: TextField( controller: _ctrl, @@ -122,7 +124,7 @@ class _ExternalPlayers extends StatelessWidget { : map.entries.map((e) => '${e.key}: ${e.value == 'system' ? tr('기본 프로그램') : e.value.split(RegExp(r'[\\/]')).last}').join(', ')), children: [ for (final ext in videoExtensions) - ListTile( + SettingTile( dense: true, title: Text('.$ext'), trailing: DropdownButton( @@ -197,7 +199,7 @@ class _ContextMenuTileState extends State<_ContextMenuTile> { } @override - Widget build(BuildContext context) => ListTile( + Widget build(BuildContext context) => SettingTile( title: Text(tr('탐색기 오른쪽 클릭 메뉴')), subtitle: Text(_registered == true ? tr('등록됨: "JJ_MKVMaker 로 재생" · "JJ_MKVMaker 로 자막 만들기" (Windows 11: 더 많은 옵션 표시)') @@ -255,36 +257,35 @@ class _SettingsPageState extends State { final desk = !Platform.isAndroid; return Scaffold( body: Column(children: [ - Container( - height: appBarHeight, - color: JjColors.panel, - padding: const EdgeInsets.only(left: 8, right: appBarRightPadding), - child: Row(children: [ - const AppNavButtons(), - const SizedBox(width: 8), + AppTopBar( + nav: const AppNavButtons(), + actions: AppActions(c: c, onSettingsPage: true), + middle: Row(children: [ Text(tr('환경 설정'), style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), const Spacer(), - Text(tr('바꾸면 바로 저장됩니다'), style: TextStyle(fontSize: 12, color: JjColors.textDim)), + Flexible( + child: Text(tr('바꾸면 바로 저장됩니다'), + maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: JjColors.textDim)), + ), const SizedBox(width: 8), - AppActions(c: c, onSettingsPage: true), ]), ), // 묶음 바로가기 (누르면 그 묶음으로). 스크롤해도 위에 그대로 Padding( - padding: const EdgeInsets.fromLTRB(24, 12, 24, 0), + padding: EdgeInsets.fromLTRB(isCompact(context) ? 8 : 24, 12, isCompact(context) ? 8 : 24, 0), child: _groupBar(desk), ), Expanded( // 모든 묶음을 한 번에 그린다 (ListView 는 화면 밖 묶음을 그리지 않아 바로가기로 갈 수 없었음) child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(24, 4, 24, 16), + padding: EdgeInsets.fromLTRB(isCompact(context) ? 4 : 24, 4, isCompact(context) ? 4 : 24, 16), child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _group('general', Icons.tune, tr('일반'), [ // 화면 언어 (읽을 수 없는 언어를 골라도 바로 찾아 되돌릴 수 있게 맨 위) const LanguageSettings(), _appIconTile(c, desk), if (c.services.updater != null) ...[ - ListTile( + SettingTile( leading: const Icon(Icons.system_update_alt, color: JjColors.accent), title: FutureBuilder( future: c.services.updater!.currentVersion(), @@ -311,7 +312,7 @@ class _SettingsPageState extends State { title: Text(tr('시작할 때 새 버전 확인 (하루 한 번)')), ), ], - ListTile( + SettingTile( title: Text(tr('홈 화면')), subtitle: Text(tr('프로그램을 켰을 때, 그리고 위쪽 왼쪽 JJ 아이콘을 눌렀을 때 보일 화면')), trailing: DropdownButton( @@ -327,7 +328,7 @@ class _SettingsPageState extends State { ]), _group('display', Icons.aspect_ratio, tr('화면'), [ if (!desk) - ListTile( + SettingTile( title: Text(tr('화면 방향')), subtitle: Text(tr('가로 고정 · 세로 고정, 또는 자동 (기기를 돌리는 대로, 기기의 자동 회전 설정을 따름)')), trailing: DropdownButton( @@ -343,7 +344,7 @@ class _SettingsPageState extends State { }, ), ), - ListTile( + SettingTile( title: Text(tr('기본 화면 크기')), subtitle: Text(trf('글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 ' '(지금 {0}%)', [(s.uiScale * 100).round()])), trailing: DropdownButton( @@ -366,7 +367,7 @@ class _SettingsPageState extends State { ), ]), _group('mkv', Icons.movie_creation_outlined, tr('MKV 만들기'), [ - ListTile( + SettingTile( title: Text(tr('동시 MKV 변환 수')), subtitle: Text(tr('여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)')), trailing: CountSelector( @@ -374,7 +375,7 @@ class _SettingsPageState extends State { onChanged: (n) => c.updateSettings((x) => x.maxParallelJobs = n), ), ), - ListTile( + SettingTile( title: Text(tr('마지막 인코딩 설정을 기억합니다')), subtitle: Text(trf('{0} · {1}' '{2} (메인 화면 상단에서 변경)', [c.encode.resolution.label, c.encode.codec.label, c.encode.reencode ? ' · ${c.encode.quality.label}' : ''])), ), @@ -432,7 +433,7 @@ class _SettingsPageState extends State { (v) => c.updateSettings((x) => x.openSubtitlesPassword = v), obscure: true), ]), _group('play', Icons.play_circle_outline, tr('재생'), [ - ListTile( + SettingTile( title: Text(tr('동영상 하나를 재생할 때')), subtitle: Text(tr('시리즈: file_001 · file_002, S01E01 · S01E02 처럼 번호만 다른 파일')), trailing: DropdownButton( @@ -443,7 +444,7 @@ class _SettingsPageState extends State { onChanged: (m) => c.updateSettings((x) => x.playlistMode = m!), ), ), - ListTile( + SettingTile( title: Text(desk ? tr('탐색기에서 동영상을 열 때') : tr('다른 앱에서 동영상을 열 때')), subtitle: Text(desk ? tr('더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)') @@ -458,7 +459,7 @@ class _SettingsPageState extends State { ), ), if (desk) ...[ - ListTile( + SettingTile( title: Text(tr('탐색기에서 연 동영상을 재생할 창')), subtitle: Text(tr('프로그램이 이미 켜져 있을 때. 새 창은 재생만 하는 창이며 닫으면 그 창만 끝납니다')), trailing: DropdownButton( @@ -509,7 +510,7 @@ class _SettingsPageState extends State { '끄면 페이지 번역 · 동영상 찾기도 멈춥니다.')), ), if (desk) ...[ - ListTile( + SettingTile( title: Text(tr('브라우저 엔진 (앱 안)')), subtitle: Text( trf('Edge: Windows 에 들어 있어 따로 설치하지 않습니다. 여기서 YouTube 에 로그인하면 다운로드에도 그 로그인이 쓰입니다.\n' 'Chrome: 고르면 약 {0}MB 를 내려받고 다시 시작한 뒤 쓸 수 있습니다 ' '(Google 로그인은 막힐 수 있음).' '{1}' '{2}', [CefRuntime.approxDownloadMb, CefRuntime.installed ? trf(' 지금 설치됨 ({0}MB)', [CefRuntime.installedMb()]) : '', s.browserEngine == 'chrome' && !CefRuntime.readyThisRun ? tr(' · 다시 시작해야 Chrome 으로 바뀝니다') : ''])), @@ -544,7 +545,7 @@ class _SettingsPageState extends State { ), ]), ), - ListTile( + SettingTile( title: Text(tr('외부 브라우저')), subtitle: Text(tr('"외부 브라우저로 열기" 에 쓸 브라우저 (이 PC 에 설치된 것)')), trailing: DropdownButton( @@ -566,7 +567,7 @@ class _SettingsPageState extends State { ], ]), _group('files', Icons.folder_copy_outlined, tr('파일 탐색기'), [ - ListTile( + SettingTile( title: Text(tr('누르기')), subtitle: Text(tr('길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). ' 'Ctrl + 클릭: 여러 개 고르기')), @@ -579,7 +580,7 @@ class _SettingsPageState extends State { onChanged: (v) => c.updateSettings((x) => x.explorerClick = v!), ), ), - ListTile( + SettingTile( title: Text(tr('스타일')), subtitle: Text(tr('아이콘 · 목록 모양: X-plore (썸네일 · 두 줄) · Windows 탐색기 (컬러 아이콘 · 열) · ' 'Total Commander (촘촘한 목록 · [폴더])')), @@ -592,7 +593,7 @@ class _SettingsPageState extends State { onChanged: (v) => c.updateSettings((x) => x.explorerStyle = v!), ), ), - ListTile( + SettingTile( title: Text(tr('창 배치')), subtitle: Text(tr('파일 탐색기 위쪽 ⋮ 메뉴에서도 바꿀 수 있습니다 (좌우 · 위아래 · 버튼 줄 위치 · 버튼 구성)')), trailing: DropdownButton( @@ -628,7 +629,7 @@ class _SettingsPageState extends State { title: Text(tr('완료시 자동 동영상추가')), subtitle: Text(tr('다 받는 대로 MKV 만들기의 동영상 목록에 넣고, 다운로드 목록에서는 뺍니다 (다운로드 화면의 체크 상자와 같음)')), ), - ListTile( + SettingTile( title: Text(tr('YouTube 받을 형식')), subtitle: Text(tr('기본: MP4. 음성만 받으면 MP3 · M4A 로 저장')), trailing: DropdownButton( @@ -637,7 +638,7 @@ class _SettingsPageState extends State { onChanged: (v) => c.updateSettings((x) => x.ytContainer = v!), ), ), - ListTile( + SettingTile( enabled: !s.ytContainer.audioOnly, title: Text(tr('YouTube 화질')), subtitle: Text(tr('고른 크기 이하에서 가장 좋은 화질 (영상에 없는 크기면 가까운 것)')), @@ -653,7 +654,7 @@ class _SettingsPageState extends State { title: Text(tr('재생목록 주소면 목록 전체 받기')), subtitle: Text(tr('영상마다 한 줄씩 보여 주고 jj_yt-dlp\\재생목록 이름\\ 에 저장 (믹스 목록 제외)')), ), - ListTile( + SettingTile( title: Text(tr('YouTube 쿠키 (로봇 확인이 나올 때)')), subtitle: Text(s.ytCookiesFile.isNotEmpty ? 'cookies.txt: ${s.ytCookiesFile}' @@ -694,7 +695,7 @@ class _SettingsPageState extends State { ), ]), ), - ListTile( + SettingTile( title: Text(tr('동시 다운로드 수')), subtitle: Text(tr('나머지는 대기했다가 차례로 받습니다')), trailing: CountSelector( @@ -715,7 +716,7 @@ class _SettingsPageState extends State { ]), if (desk) _group('run', Icons.power_settings_new, tr('실행 · 종료'), [ - ListTile( + SettingTile( title: Text(tr('종료 (창 닫기 ✕ · 종료 버튼) 를 누르면')), subtitle: Text(trf('백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {0} 또는 트레이 아이콘으로 다시 엽니다.\n' '완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)', [s.showHotkey])), isThreeLine: true, @@ -735,7 +736,7 @@ class _SettingsPageState extends State { title: Text(tr('최소화하면 트레이로 (백그라운드 실행)')), subtitle: Text(tr('트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다')), ), - ListTile( + SettingTile( title: Text(tr('창 보이기 / 숨기기 단축키')), subtitle: _hotkeyError == null ? Text(tr('어디서나 누르면 JJ_MKVMaker 창이 나타납니다')) @@ -757,7 +758,7 @@ class _SettingsPageState extends State { ), ]), _group('cleanup', Icons.cleaning_services_outlined, tr('저장 공간 정리'), [ - ListTile( + SettingTile( title: Text(tr('임시 파일 · 남은 조각 정리')), subtitle: Text(tr('작업하다 남은 임시 파일 · 받다 만 다운로드 (.part 등) · 받다 만 AI 모델 · ' '업데이트하고 남은 파일 · 지난 작업 기록을 찾아 지웁니다. ' @@ -772,7 +773,7 @@ class _SettingsPageState extends State { ]), _group('about', Icons.info_outline, tr('프로그램 정보'), [ if (desk) ...[ - ListTile( + SettingTile( title: Text(tr('필수 프로그램 점검')), subtitle: Text(tr('FFmpeg · yt-dlp · aria2 · Deno 가 없으면 내려받아 설치합니다')), trailing: OutlinedButton( @@ -781,7 +782,7 @@ class _SettingsPageState extends State { ), ), ], - ListTile( + SettingTile( title: Text(tr('오픈 소스 라이선스')), subtitle: Text(tr('포함된 구성 요소와 라이선스 (THIRD_PARTY_NOTICES.txt)')), trailing: Row(mainAxisSize: MainAxisSize.min, children: [ @@ -813,7 +814,7 @@ class _SettingsPageState extends State { final s = c.settings; void update(List Function(List) f) => c.updateSettings((x) => x.moveTargets = f([...x.moveTargets])); - return ListTile( + return SettingTile( key: ValueKey('move_${i}_${t.dir}'), leading: const Icon(Icons.drive_file_move_outline, color: JjColors.accent), title: TextFormField( @@ -916,7 +917,7 @@ class _SettingsPageState extends State { /// 앱 아이콘 고르기: 그림을 누르면 바로 바뀐다 Widget _appIconTile(AppController c, bool desk) { final now = appIconOf(c.settings.appIcon); - return ListTile( + return SettingTile( title: Text(tr('앱 아이콘')), subtitle: Padding( padding: const EdgeInsets.only(top: 4), @@ -969,7 +970,7 @@ class _SettingsPageState extends State { Widget _textTile(String title, String value, String hint, ValueChanged onSave, {bool obscure = false}) => - ListTile( + SettingTile( title: Text(title), subtitle: Text(hint), trailing: SizedBox( @@ -993,7 +994,7 @@ class _SettingsPageState extends State { required VoidCallback onPick, required VoidCallback onReset, }) => - ListTile( + SettingTile( title: Text(title), subtitle: Text(value ?? defaultText, maxLines: 2, overflow: TextOverflow.ellipsis), trailing: Row(mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/ui/setup_dialog.dart b/lib/ui/setup_dialog.dart index bb86663..154e09c 100644 --- a/lib/ui/setup_dialog.dart +++ b/lib/ui/setup_dialog.dart @@ -19,6 +19,7 @@ Future checkRequiredTools(BuildContext context, AppShell shell, {bool quie final go = await showDialog( context: context, builder: (ctx) => AlertDialog( + scrollable: true, title: Text(tr('필수 프로그램 설치')), content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(tr('아래 프로그램이 없어 일부 기능을 쓸 수 없습니다. 공식 배포처에서 내려받아 프로그램 폴더에 설치할까요?')), @@ -41,6 +42,7 @@ Future checkRequiredTools(BuildContext context, AppShell shell, {bool quie context: context, barrierDismissible: false, builder: (ctx) => AlertDialog( + scrollable: true, title: Text(tr('내려받는 중')), content: ValueListenableBuilder<(String, double)>( valueListenable: progress, @@ -63,6 +65,7 @@ Future checkRequiredTools(BuildContext context, AppShell shell, {bool quie await showDialog( context: context, builder: (ctx) => AlertDialog( + scrollable: true, title: Text(tr('설치 실패')), content: SelectableText(trf('인터넷 연결을 확인한 뒤 환경 설정 > 필수 프로그램 점검에서 다시 시도하세요.\n\n{0}', [error])), actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('닫기')))], diff --git a/lib/ui/subtitle_editor_page.dart b/lib/ui/subtitle_editor_page.dart index 944352e..d6d8f52 100644 --- a/lib/ui/subtitle_editor_page.dart +++ b/lib/ui/subtitle_editor_page.dart @@ -186,6 +186,7 @@ class _SubtitleEditorPageState extends State { final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( + scrollable: true, title: Text(tr('문자셋 경고')), content: Text(trf('{0} 로 표현할 수 없는 글자 {1}개가 ' '"?" 로 바뀝니다.\n계속 저장할까요? (UTF-8 을 권장합니다)', [saveCharsets[e.saveCharset], lost])), actions: [ @@ -236,6 +237,7 @@ class _SubtitleEditorPageState extends State { final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( + scrollable: true, title: Text(tr('저장하지 않은 변경')), content: Text(tr('변경 내용을 저장하지 않고 나갈까요?')), actions: [ @@ -479,14 +481,11 @@ class _SubtitleEditorPageState extends State { ); } - Widget _toolbar(BuildContext context) => Container( - height: appBarHeight, - color: JjColors.panel, - padding: const EdgeInsets.only(left: 8, right: appBarRightPadding), - child: Row( + Widget _toolbar(BuildContext context) => AppTopBar( + nav: const AppNavButtons(), + actions: const AppActions(), + middle: Row( children: [ - const AppNavButtons(), - const SizedBox(width: 4), Expanded( child: Text( trf('자막 편집 · {0}', [_entry.displayName]), @@ -550,7 +549,6 @@ class _SubtitleEditorPageState extends State { ]), ), ), - const AppActions(), ], ), ); diff --git a/lib/ui/translate_dialog.dart b/lib/ui/translate_dialog.dart index d4d4bd4..8136153 100644 --- a/lib/ui/translate_dialog.dart +++ b/lib/ui/translate_dialog.dart @@ -52,6 +52,7 @@ class _TranslateDialogState extends State<_TranslateDialog> { final src = widget.s.language; final extra = languages.where((l) => !_targets.contains(l) && l.code != src.code).toList(); return AlertDialog( + scrollable: true, title: Text(tr('자막 번역')), content: SizedBox( width: 480, diff --git a/lib/ui/update_dialog.dart b/lib/ui/update_dialog.dart index 72dc69d..93a5366 100644 --- a/lib/ui/update_dialog.dart +++ b/lib/ui/update_dialog.dart @@ -263,6 +263,7 @@ Future _download(BuildContext context, Updater up, ReleaseInfo r) async context: context, barrierDismissible: false, builder: (_) => AlertDialog( + scrollable: true, title: Text(trf('v{0} 받는 중', [r.version])), content: ValueListenableBuilder( valueListenable: progress, @@ -317,6 +318,7 @@ Future installRelease(BuildContext context, AppController c, ReleaseInfo r final go = await showDialog( context: context, builder: (ctx) => AlertDialog( + scrollable: true, title: Text(tr('설치 준비 완료')), content: Text(trf('v{0} 을 받았고 파일 검증(SHA256)을 마쳤습니다.\n' '{1}', [ r.version, @@ -347,6 +349,7 @@ Future installRelease(BuildContext context, AppController c, ReleaseInfo r final again = await showDialog( context: context, builder: (ctx) => AlertDialog( + scrollable: true, title: Text(tr('설치 허용 필요')), content: Text(tr('설정 화면에서 "이 출처의 앱 설치 허용" 을 켠 뒤 돌아와 [설치 계속] 을 누르세요. ' '받은 파일을 그대로 씁니다 (다시 받지 않음).')), @@ -395,6 +398,7 @@ Future _androidRollback(BuildContext context, AppController c, Updater up, final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( + scrollable: true, title: Text(trf('예전 버전 v{0} 으로 되돌리기', [r.version])), content: SizedBox( width: 560, @@ -432,6 +436,7 @@ Future _androidRollback(BuildContext context, AppController c, Updater up, final go = await showDialog( context: context, builder: (ctx) => AlertDialog( + scrollable: true, title: Text(tr('저장했습니다')), content: Text(trf('설치 파일: {0}\n이제 앱을 지운 뒤, 파일 앱에서 이 파일을 눌러 설치하세요.', [saved])), actions: [ diff --git a/lib/ui/version_restore.dart b/lib/ui/version_restore.dart index 9cf3229..ff823da 100644 --- a/lib/ui/version_restore.dart +++ b/lib/ui/version_restore.dart @@ -37,6 +37,7 @@ Future checkVersionRestore( final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( + scrollable: true, title: Text(tr('설정 되살리기')), content: SizedBox(width: 520, child: Text(message!)), actions: [ diff --git a/lib/ui/webdav_settings.dart b/lib/ui/webdav_settings.dart index 2265be8..dc3f7e2 100644 --- a/lib/ui/webdav_settings.dart +++ b/lib/ui/webdav_settings.dart @@ -4,6 +4,7 @@ import '../app/app_controller.dart'; import '../core/webdav.dart'; import '../l10n/tr.dart'; import 'theme.dart'; +import 'setting_tile.dart'; /// 환경 설정 > 파일 탐색기 > WebDAV: 서버 목록 (추가 · 고치기 · 지우기 · 연결 확인). /// 서버마다 파일 탐색기 · Rsync 화면 위쪽 "SD 카드" 옆에 탭이 생긴다. @@ -15,7 +16,7 @@ class WebDavSettings extends StatelessWidget { Widget build(BuildContext context) { final servers = c.settings.webdavServers; return Column(children: [ - ListTile( + SettingTile( leading: const Icon(Icons.cloud_outlined), title: const Text('WebDAV'), subtitle: Text(tr('NAS · 클라우드의 WebDAV 폴더를 파일 탐색기 · Rsync 화면에서 열고 복사 · 동기화합니다 ' @@ -29,7 +30,7 @@ class WebDavSettings extends StatelessWidget { for (final s in servers) Padding( padding: const EdgeInsets.only(left: 16), - child: ListTile( + child: SettingTile( dense: true, leading: const Icon(Icons.cloud, color: JjColors.accent), title: Text(s.label), @@ -100,6 +101,7 @@ Future editDavServer(BuildContext context, AppController c, {DavServ final ok = Uri.tryParse(url.text.trim())?.hasAuthority ?? false; return AlertDialog( + scrollable: true, title: Text(old == null ? tr('WebDAV 서버 추가') : tr('WebDAV 서버 고치기')), content: SizedBox( width: 520, diff --git a/lib/ui/work_panel.dart b/lib/ui/work_panel.dart index 2f1251f..58811ff 100644 --- a/lib/ui/work_panel.dart +++ b/lib/ui/work_panel.dart @@ -57,16 +57,11 @@ class _WorkStatusPageState extends State { @override Widget build(BuildContext context) => Scaffold( body: Column(children: [ - Container( - height: appBarHeight, - color: JjColors.panel, - padding: const EdgeInsets.only(left: 8, right: appBarRightPadding), - child: Row(children: [ - const AppNavButtons(), - const SizedBox(width: 8), + AppTopBar( + nav: const AppNavButtons(), + actions: AppActions(c: widget.c), + middle: Row(children: [ Text(tr('작업 현황'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), - const Spacer(), - AppActions(c: widget.c), ]), ), Expanded(