diff --git a/integration_test/windows_bigtext_shot_test.dart b/integration_test/windows_bigtext_shot_test.dart index bcd3318..a275caf 100644 --- a/integration_test/windows_bigtext_shot_test.dart +++ b/integration_test/windows_bigtext_shot_test.dart @@ -52,12 +52,15 @@ void main() { addTearDown(t.view.reset); await t.runAsync(() => i18n.apply(lang, save: false)); addTearDown(() => i18n.apply('ko', save: false)); - final tmp = testTempDir('jj_bigtext_'); + // 관리자 지시 (10/24): 폴더 이름 · 기록 시각을 실행마다 같게 (캡처가 흔들리지 않게 - 시험 쪽에서만) + final tmp = testFixedTempDir('jj_bigtext_fixed'); appTempInto(tmp); final video = p.join(tmp.path, '아주 긴 이름의 동영상 파일 - 시험용 2026.mp4'); File(video).writeAsStringSync('v'); Directory(p.join(tmp.path, 'sub')).createSync(); - final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + // 대상 디스크 줄은 이 PC 의 실제 남은 용량이라 실행마다 달라 정한 값으로 (시험 쪽에서만) + final c = AppController(PlatformServices( + mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), diskSpace: (_) async => (107374182400, 536870912000))); c.settings ..explorer.paths = [tmp.path, tmp.path] ..copy.rsyncPaths = [tmp.path, tmp.path] @@ -70,8 +73,9 @@ void main() { File(f).writeAsStringSync('v'); c.videos.add(VideoItem(f)); } + pinTestTimes(tmp); for (var i = 1; i <= 3; i++) { - c.note('시험 기록 $i'); + c.note('시험 기록 $i', at: DateTime(2026, 10, 22, 9, i)); } c.selected = c.videos.first; final d = DownloadManager(backends: const [], settings: () => c.settings, readClipboard: () async => null); diff --git a/integration_test/windows_check_shot_test.dart b/integration_test/windows_check_shot_test.dart index 4593820..4317fb2 100644 --- a/integration_test/windows_check_shot_test.dart +++ b/integration_test/windows_check_shot_test.dart @@ -83,6 +83,7 @@ void main() { createSubtitleProviders: r.createSubtitleProviders, createUpscaler: r.createUpscaler, findLanSmb: r.findLanSmb, + diskSpace: r.diskSpace, updater: r.updater, usage: r.usage == null ? null : const _FixedUsage(), models: r.models, diff --git a/integration_test/windows_hdr360_shot_test.dart b/integration_test/windows_hdr360_shot_test.dart new file mode 100644 index 0000000..16fe3a7 --- /dev/null +++ b/integration_test/windows_hdr360_shot_test.dart @@ -0,0 +1,121 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; +import 'package:integration_test/integration_test.dart'; +import 'package:jj_mkvmaker/app/download_manager.dart'; +import 'package:jj_mkvmaker/app/i18n_controller.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/core/download_detect.dart'; +import 'package:jj_mkvmaker/l10n/tr.dart'; +import 'package:jj_mkvmaker/services/downloader.dart'; +import 'package:jj_mkvmaker/ui/ai_upscale/upscale_compare.dart'; +import 'package:jj_mkvmaker/ui/downloads_page.dart'; +import 'package:jj_mkvmaker/ui/theme.dart'; +import 'package:path/path.dart' as p; + +import 'support/temp_dir.dart'; + +/// 받는 중에서 멈춰 있는 받기 (네트워크 없음) +class _Backend implements DownloadBackend { + @override + DownloadKind get kind => DownloadKind.video; + @override + Future<(String, List)?> expandPlaylist(String url) async => null; + @override + Future start(DownloadTask t, void Function() changed) async { + t.state = DownloadState.downloading; + changed(); + } + + @override + Future pause(DownloadTask t) async {} + @override + Future cancel(DownloadTask t) async {} + @override + Future shutdown() async {} +} + +/// a22832d (after_1020) 에서 옮김 - 관리자 지시 (10/21, F11 다음): "제목 + 단추 + ✕" 모양의 다른 창도 360 에서 - ① 다운로드 종료 확인 창 ② AI 해상도 비교 화면. +/// 넘치지 않고 ✕ (닫기 · 종료) 와 [저장] 이 화면 안. ko · ja × 360 · 412 +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final out = Platform.environment['JJ_SHOT_DIR'] ?? ''; + + for (final lang in ['ko', 'ja']) { + for (final (w, h) in [(360, 780), (412, 900)]) { + testWidgets('제목 줄 $lang ${w}x$h', (t) async { + t.view.physicalSize = Size(w.toDouble(), h.toDouble()); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + await t.runAsync(() => i18n.apply(lang, save: false)); + addTearDown(() => i18n.apply('ko', save: false)); + final tmp = testTempDir('jj_hdr360_'); + final shotKey = GlobalKey(); + final navKey = GlobalKey(); + await t.pumpWidget(MaterialApp( + theme: buildTheme(), + debugShowCheckedModeBanner: false, + navigatorKey: navKey, + builder: (context, child) => RepaintBoundary(key: shotKey, child: child), + home: const Scaffold(body: SizedBox.expand()), + )); + Future settle([int n = 8]) async { + for (var i = 0; i < n; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 50))); + await t.pump(); + } + } + + Future shot(String name) async { + await settle(3); + if (out.isNotEmpty) { + final b = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final im = await t.runAsync(() => b.toImage()); + final bytes = await t.runAsync(() => im!.toByteData(format: ui.ImageByteFormat.png)); + Directory(out).createSync(recursive: true); + File(p.join(out, 'hdr_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List()); + } + expect(t.takeException(), isNull, reason: '$name 넘침'); + } + + void inside(Finder f, String what) { + final r = t.getRect(f); + expect(r.left >= -0.5 && r.right <= w + 0.5, isTrue, reason: '$what 화면 안 ($r)'); + } + + // ① 다운로드 종료 확인 창 (받는 중 2개) + final s = AppSettings(); s.download.root = tmp.path; // 나눈 설정 (download.root) + final d = DownloadManager(backends: [_Backend()], settings: () => s, readClipboard: () async => null); + addTearDown(d.dispose); + d.addPage('https://cdn.example.com/a')!.title = 'アップロードされた とても長い 動画のタイトル - 第1話 高画質版'; + d.addPage('https://cdn.example.com/b')!.title = 'Test video 2'; + await settle(); + unawaited(confirmExit(navKey.currentContext!, d)); + await settle(); + inside(find.byTooltip(tr('종료')), '✕ (종료)'); + await shot('1_exit'); + navKey.currentState!.popUntil((r) => r.isFirst); + await settle(); + + // ② AI 해상도 비교 화면 - 긴 그림 이름 + final a = p.join(tmp.path, 'a.png'); + final b = p.join(tmp.path, 'b.png'); + File(a).writeAsBytesSync(img.encodePng(img.Image(width: 40, height: 30))); + File(b).writeAsBytesSync(img.encodePng(img.Image(width: 80, height: 60))); + unawaited(navKey.currentState!.push(MaterialPageRoute( + builder: (_) => UpscaleCompareView(original: a, upscaled: b, title: '아주 긴 이름의 만화 스캔 - 제1권 012쪽_x2.png')))); + await settle(12); + inside(find.byTooltip(tr('닫기')), '✕ (닫기)'); + inside(find.text(tr('저장')), '[저장]'); + inside(find.text(tr('올린 것')), '[올린 것]'); + expect(t.getRect(find.text('아주 긴 이름의 만화 스캔 - 제1권 012쪽_x2.png')).width > 40, isTrue, reason: '제목이 보임'); + await shot('2_compare'); + }); + } + } +} diff --git a/integration_test/windows_magnet_shot_test.dart b/integration_test/windows_magnet_shot_test.dart index f42bd5a..1d2d932 100644 --- a/integration_test/windows_magnet_shot_test.dart +++ b/integration_test/windows_magnet_shot_test.dart @@ -71,9 +71,10 @@ void main() { ..['stallReasons'] = ['dns', 'firewall']; d.useSystemDns = () async {}; final shotKey = GlobalKey(); + // 움직이는 "준비 중…" 막대는 찍히는 때에 따라 달라 멈춰 둔다 (TickerMode - 캡처가 흔들리지 않게, 시험 쪽에서만 · 관리자 10/24) await t.pumpWidget(RepaintBoundary( key: shotKey, - child: MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: DownloadsPage(d: d)), + child: MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: TickerMode(enabled: false, child: DownloadsPage(d: d))), )); for (var i = 0; i < 6; i++) { await t.runAsync(() => Future.delayed(const Duration(milliseconds: 60))); diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index 4e30df3..fe6a2ad 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -484,15 +484,15 @@ class AppController extends ChangeNotifier { notifyListeners(); } - /// 작업 기록에 한 줄 남기기 (앱의 다른 부분에서 알릴 것이 있을 때) - void note(String msg) => _log(msg); + /// 작업 기록에 한 줄 남기기 (앱의 다른 부분에서 알릴 것이 있을 때). [at] 적은 때 (처음 값 = 지금 - [AppLog.add] 와 같음) + void note(String msg, {DateTime? at}) => _log(msg, at: at); /// 작업 기록을 파일에도 남길 곳 (설정 폴더의 app.log - [AppLog]) String? get logFile => _logBook.file; set logFile(String? v) => _logBook.file = v; - void _log(String msg) { - _logBook.add(msg); + void _log(String msg, {DateTime? at}) { + _logBook.add(msg, at: at); notifyListeners(); } } diff --git a/lib/services/platform_services.dart b/lib/services/platform_services.dart index 440d3b2..a23b573 100644 --- a/lib/services/platform_services.dart +++ b/lib/services/platform_services.dart @@ -72,6 +72,9 @@ class PlatformServices { /// 같은 망의 SMB 서버 찾기 (없으면 이 기기의 사설망을 실제로 훑는다 - smb_settings) final Future> Function({bool Function()? cancelled, void Function(int done, int total)? onProgress})? findLanSmb; + /// 디스크 남은 · 전체 바이트 재기 (없으면 실제 디스크 - copy_center 의 diskSpace). 작업 현황의 "대상 디스크" 줄 + final Future<(int, int)?> Function(String path)? diskSpace; + /// 이 기기 (Android · Windows · 메모리) - 기기마다 다르게 도는 곳은 이것을 본다 (M1, 처음 값 = 이 기기) final PlatformInfo platform; @@ -89,6 +92,7 @@ class PlatformServices { this.createSubtitleProviders, this.createUpscaler, this.findLanSmb, + this.diskSpace, this.updater, this.usage, ModelStore? models, diff --git a/lib/ui/ai_upscale/upscale_compare.dart b/lib/ui/ai_upscale/upscale_compare.dart index 4ff9db8..dbc70ac 100644 --- a/lib/ui/ai_upscale/upscale_compare.dart +++ b/lib/ui/ai_upscale/upscale_compare.dart @@ -94,13 +94,14 @@ class UpscaleCompareViewState extends State { Widget _page(BuildContext context) { return Scaffold( backgroundColor: Colors.black, - // 원본 · 올린 것 고르기가 넓어 좁으면 제목 아래 줄로 + // 관리자 지시 (10/21, 15 찾음 G2 - a22832d 는 after_1020 에만 있어 다시): 360 에서 위 막대가 넘쳐 제목이 안 보이고 [저장] 글자가 잘렸다 - + // 폭 600 미만이면 원본 / 올린 것 고르기를 위 막대 아래 둘째 줄로, [저장] 은 글자 그대로. 넓으면 그대로 appBar: jjTitleBar( context, - twoLinesBelow: 480, + twoLinesBelow: 600, leading: IconButton(tooltip: tr('닫기'), icon: const Icon(Icons.close), onPressed: _close), title: widget.title, - actions: [ + lowerActions: [ SegmentedButton( segments: [ ButtonSegment(value: false, label: Text(tr('원본'))), @@ -110,6 +111,8 @@ class UpscaleCompareViewState extends State { onSelectionChanged: (v) => setState(() => _up = v.first), ), const SizedBox(width: 8), + ], + actions: [ FilledButton.icon( onPressed: () => Navigator.pop(context, true), icon: const Icon(Icons.save_outlined, size: 18), diff --git a/lib/ui/android_file_browser.dart b/lib/ui/android_file_browser.dart index 00ba655..c4c2c69 100644 --- a/lib/ui/android_file_browser.dart +++ b/lib/ui/android_file_browser.dart @@ -161,7 +161,7 @@ class _FileBrowserState extends State<_FileBrowser> { if (!didPop && dir != null) _open(p.dirname(dir)); }, child: Scaffold( - // 새 폴더 · 고르기 단추가 넓어 좁으면 제목 아래 줄로 + // [폴더 만들기] · [이 폴더 선택] 이 넓어 좁으면 제목 아래 줄로 (412 ja 에서 위 막대가 넘쳤다) appBar: jjTitleBar( context, twoLinesBelow: 480, @@ -172,7 +172,7 @@ class _FileBrowserState extends State<_FileBrowser> { onPressed: () => Navigator.of(context).pop(), ), title: widget.title, - actions: [ + lowerActions: [ // 숨긴 항목 (. 으로 시작) 보이기 / 숨기기 IconButton( tooltip: _showHidden ? tr('숨긴 항목 숨기기') : tr('숨긴 항목 (. 으로 시작) 보이기'), diff --git a/lib/ui/browser/bookmark_panel.dart b/lib/ui/browser/bookmark_panel.dart index 5ebe77b..c6a91cf 100644 --- a/lib/ui/browser/bookmark_panel.dart +++ b/lib/ui/browser/bookmark_panel.dart @@ -8,6 +8,7 @@ import '../../l10n/tr.dart'; import '../bookmark_ui.dart'; import '../key_hint.dart'; import '../theme.dart'; +import '../jj_header_row.dart'; // ───────── 즐겨찾기 관리 패널 (Chrome 즐겨찾기 관리자 참고) ───────── @@ -65,20 +66,21 @@ class _BookmarkPanelState extends State { child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( padding: const EdgeInsets.fromLTRB(12, 8, 4, 0), - child: Row(children: [ - Icon(Icons.bookmarks_outlined, size: 18, color: JjColors.accent), - const SizedBox(width: 6), - Text(tr('즐겨찾기'), style: TextStyle(fontWeight: FontWeight.w600)), - const Spacer(), - if (widget.onOpenManager != null) - IconButton( - tooltip: keyHint(tr('즐겨찾기 관리자'), 'Ctrl+Shift+O'), - iconSize: 18, - icon: const Icon(Icons.open_in_full), - onPressed: () => widget.onOpenManager!(folderId: _folderId), - ), - IconButton(tooltip: tr('닫기'), iconSize: 18, icon: const Icon(Icons.close), onPressed: widget.onClose), - ]), + child: JjHeaderRow( + icon: Icon(Icons.bookmarks_outlined, size: 18, color: JjColors.accent), + title: tr('즐겨찾기'), + style: const TextStyle(fontWeight: FontWeight.w600), + actions: [ + if (widget.onOpenManager != null) + IconButton( + tooltip: keyHint(tr('즐겨찾기 관리자'), 'Ctrl+Shift+O'), + iconSize: 18, + icon: const Icon(Icons.open_in_full), + onPressed: () => widget.onOpenManager!(folderId: _folderId), + ), + IconButton(tooltip: tr('닫기'), iconSize: 18, icon: const Icon(Icons.close), onPressed: widget.onClose), + ], + ), ), // 최상위 폴더 선택 Padding( diff --git a/lib/ui/cleanup_dialog.dart b/lib/ui/cleanup_dialog.dart index 0ffa384..c169565 100644 --- a/lib/ui/cleanup_dialog.dart +++ b/lib/ui/cleanup_dialog.dart @@ -5,6 +5,7 @@ import '../app/cleanup.dart'; import '../app/download_manager.dart'; import '../l10n/tr.dart'; import 'theme.dart'; +import 'jj_header_row.dart'; /// 저장 공간 정리: 찾은 묶음별 크기를 보여 주고, 고른 것만 지운다 Future showCleanup(BuildContext context, AppController c, DownloadManager? downloads) async { @@ -58,11 +59,7 @@ class _CleanupDialogState extends State<_CleanupDialog> { Widget build(BuildContext context) { final groups = _groups; return AlertDialog( - title: Row(children: [ - Icon(Icons.cleaning_services_outlined, color: JjColors.accent), - const SizedBox(width: 10), - Text(tr('저장 공간 정리')), - ]), + title: JjHeaderRow(icon: Icon(Icons.cleaning_services_outlined, color: JjColors.accent), title: tr('저장 공간 정리')), content: SizedBox( width: 560, child: groups == null diff --git a/lib/ui/downloads_page.dart b/lib/ui/downloads_page.dart index f6feae5..2da79b1 100644 --- a/lib/ui/downloads_page.dart +++ b/lib/ui/downloads_page.dart @@ -9,6 +9,7 @@ import 'app_actions.dart'; import 'confirm.dart'; import 'theme.dart'; import '../l10n/tr.dart'; +import 'jj_header_row.dart'; /// 다운로드 목록 class DownloadsPage extends StatefulWidget { @@ -510,15 +511,13 @@ class _ExitDialog extends StatelessWidget { final active = d.tasks.where((t) => t.unfinished).toList(); return AlertDialog( titlePadding: const EdgeInsets.fromLTRB(24, 16, 8, 0), - title: Row(children: [ - Text(trf('받는 중인 다운로드 {0}개', [active.length])), - const Spacer(), - IconButton( - tooltip: tr('종료'), - icon: const Icon(Icons.close), - onPressed: () => _quit(context), - ), - ]), + // 관리자 지시 (10/21, 15 찾음 G1 - a22832d 는 after_1020 에만 있어 다시): 360 에서 제목이 넘쳐 개수와 [✕] 가 잘렸다 - + // 제목은 두 줄까지 감싸고 [✕] 는 늘 보이게 + title: JjHeaderRow( + title: trf('받는 중인 다운로드 {0}개', [active.length]), + titleLines: 2, + actions: [IconButton(tooltip: tr('종료'), icon: const Icon(Icons.close), onPressed: () => _quit(context))], + ), content: SizedBox( width: 520, height: 300, diff --git a/lib/ui/jj_header_row.dart b/lib/ui/jj_header_row.dart new file mode 100644 index 0000000..d2001f8 --- /dev/null +++ b/lib/ui/jj_header_row.dart @@ -0,0 +1,69 @@ +/// 약속 (W 공용 위젯, 설계 7-2 JjTitleBar - 창 · 판 안의 제목 줄): 아이콘 · 제목 · 설명 · 단추들 → 한 줄. +/// 입력: 제목 ([titleLines] 줄까지 - 처음 1, 넘치면 "…") · 앞 아이콘 · 설명 (넓으면 제목 옆, 좁으면 제목 아래 2줄까지 - 숨기지 않음) · 단추들 (늘 오른쪽 끝에 다 보임) · +/// [narrowBelow] (이 폭보다 좁으면 좁은 꼴) · [narrowActions] (좁을 때 쓸 단추들 - 예: 글 단추 → 같은 이름의 아이콘 단추) · 제목 글꼴. +/// 출력: Row (넘치지 않음 - 줄이는 것은 제목 · 설명뿐). 오류 없음. +/// 쓰는 곳: 다운로드 창 · 재생 목록 판 · 즐겨찾기 판 · 작업 현황 판 · 저장 공간 정리 창 · 화면 · 색 보정. 화면은 부르기만 한다. +/// 쪽 전체의 위 막대는 [jjTitleBar] +library; + +import 'package:flutter/material.dart'; + +import 'theme.dart'; + +class JjHeaderRow extends StatelessWidget { + const JjHeaderRow({ + super.key, + required this.title, + this.icon, + this.subtitle, + this.actions = const [], + this.narrowActions, + this.narrowBelow = 0, + this.style, + this.titleLines = 1, + }); + + final String title; + + /// 제목 앞 아이콘 (크기 · 색은 부르는 쪽이 정한다) + final Widget? icon; + final String? subtitle; + final List actions; + final List? narrowActions; + final double narrowBelow; + + /// 제목 글꼴 (null 이면 둘레의 글꼴 - 창 제목이면 창 제목 글꼴) + final TextStyle? style; + final int titleLines; + + static TextStyle get _subStyle => TextStyle(fontSize: 12, color: JjColors.textDim); + + // 좁은 꼴 · 설명이 없으면 LayoutBuilder 없이 (창 제목은 Flutter 가 내재 폭을 재는데, LayoutBuilder 는 그것을 못 한다) + @override + Widget build(BuildContext context) => narrowBelow <= 0 && subtitle == null + ? _row(false, double.infinity) + : LayoutBuilder(builder: (context, box) => _row(box.maxWidth < narrowBelow, box.maxWidth)); + + Widget _row(bool narrow, double width) { + final head = Text(title, maxLines: titleLines, overflow: TextOverflow.ellipsis, style: style); + return Row(children: [ + if (icon != null) ...[icon!, const SizedBox(width: 8)], + if (subtitle == null) + Expanded(child: head) + else if (narrow) + Expanded( + child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + head, + Text(subtitle!, maxLines: 2, overflow: TextOverflow.ellipsis, style: _subStyle), + ]), + ) + else ...[ + // 제목은 쓰는 만큼 (반까지), 설명이 나머지 - 단추는 오른쪽 끝에 붙는다 + ConstrainedBox(constraints: BoxConstraints(maxWidth: width / 2), child: head), + const SizedBox(width: 12), + Expanded(child: Text(subtitle!, maxLines: 1, overflow: TextOverflow.ellipsis, style: _subStyle)), + ], + ...(narrow ? narrowActions ?? actions : actions), + ]); + } +} diff --git a/lib/ui/jj_title_bar.dart b/lib/ui/jj_title_bar.dart index 26fa8ea..ac7211c 100644 --- a/lib/ui/jj_title_bar.dart +++ b/lib/ui/jj_title_bar.dart @@ -1,8 +1,10 @@ /// 약속 (W 공용 위젯, 설계 7-2 JjTitleBar - 쪽 위 막대): 제목 · 앞 단추 · 뒤 단추들 → 위 막대 (Scaffold.appBar). /// 입력: 제목 글 (한 줄 - 넘치면 끝을 "…", [path] 면 앞을 "…" 로 줄여 끝 (지금 폴더) 이 보임 - [TailText]) · 앞 단추 (없으면 Flutter 처음 값: -/// 돌아갈 곳이 있으면 화면 언어의 뒤로) · 뒤 단추들 · [twoLinesBelow] (화면 폭이 이보다 좁으면 뒤 단추들을 제목 아래 둘째 줄로 - 넓은 단추를 가진 쪽). +/// 돌아갈 곳이 있으면 화면 언어의 뒤로) · 뒤 단추들 [actions] (늘 위 막대에) · 내려갈 단추들 [lowerActions] (넓으면 [actions] 앞에, +/// 화면 폭이 [twoLinesBelow] 보다 좁으면 제목 아래 둘째 줄 가운데로 - 넓은 고르기 · 글 단추). /// 출력: AppBar (모양은 앱 테마). 둘째 줄도 넘치면 그 줄만 줄여 모두 보이게 하며, 단추를 숨기지 않는다. 오류 없음. -/// 쓰는 곳: 휴지통 · 보관함 · 저장 공간 · 압축 · 글 보기 · 찾기 결과 · DLNA · AI 비교 등 쪽 위 막대. 화면은 부르기만 한다 +/// 쓰는 곳: 휴지통 · 보관함 · 저장 공간 · 압축 · 글 보기 · 찾기 결과 · DLNA · AI 비교 (G2) · 앱 안 폴더 고르기 등 쪽 위 막대. 화면은 부르기만 한다. +/// 창 · 판 안의 제목 줄은 [JjHeaderRow] library; import 'package:flutter/material.dart'; @@ -15,23 +17,21 @@ PreferredSizeWidget jjTitleBar( bool path = false, Widget? leading, List actions = const [], + List lowerActions = const [], double twoLinesBelow = 0, }) { - final twoLines = actions.isNotEmpty && MediaQuery.sizeOf(context).width < twoLinesBelow; + final twoLines = lowerActions.isNotEmpty && MediaQuery.sizeOf(context).width < twoLinesBelow; return AppBar( leading: leading, title: path ? TailText(title) : Text(title, maxLines: 1, overflow: TextOverflow.ellipsis), - actions: twoLines ? null : actions, + actions: twoLines ? actions : [...lowerActions, ...actions], bottom: twoLines ? PreferredSize( - preferredSize: const Size.fromHeight(48), + preferredSize: const Size.fromHeight(52), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Align( - alignment: Alignment.centerRight, - // 둘째 줄에도 다 안 들어가면 (좁은 폭 · 큰 글자) 줄 전체를 그만큼만 줄인다 - 단추를 숨기거나 화면 밖으로 밀지 않음 - child: FittedBox(fit: BoxFit.scaleDown, child: Row(mainAxisSize: MainAxisSize.min, children: actions)), - ), + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + // 둘째 줄에도 다 안 들어가면 (좁은 폭 · 큰 글자) 줄 전체를 그만큼만 줄인다 - 단추를 숨기거나 화면 밖으로 밀지 않음 + child: Center(child: FittedBox(fit: BoxFit.scaleDown, child: Row(mainAxisSize: MainAxisSize.min, children: lowerActions))), ), ) : null, diff --git a/lib/ui/monitor/copy_task_card.dart b/lib/ui/monitor/copy_task_card.dart index 4ecd6b2..d869ec1 100644 --- a/lib/ui/monitor/copy_task_card.dart +++ b/lib/ui/monitor/copy_task_card.dart @@ -309,7 +309,7 @@ class _CopyTaskCardState extends State { padding: const EdgeInsets.only(left: 28, top: 2), child: Wrap(spacing: 16, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: [ Text(status, style: TextStyle(fontSize: 12, color: color)), - DiskSpaceInfo(path: t.dest), + DiskSpaceInfo(path: t.dest, space: widget.c.services.diskSpace), ]), ), if (t.lastResult == 'failed' && !running && t.lastMessage.isNotEmpty) diff --git a/lib/ui/monitor/live_pair_card.dart b/lib/ui/monitor/live_pair_card.dart index bd51558..7c93c59 100644 --- a/lib/ui/monitor/live_pair_card.dart +++ b/lib/ui/monitor/live_pair_card.dart @@ -239,7 +239,7 @@ class LivePairCard extends StatelessWidget { if (st != null) Text('${tr('마지막')}: ${st.$1.hour.toString().padLeft(2, '0')}:${st.$1.minute.toString().padLeft(2, '0')} ${st.$2}', style: TextStyle(fontSize: 12, color: live?.problems[k] != null ? JjColors.alert : JjColors.textDim)), - DiskSpaceInfo(path: x.target), + DiskSpaceInfo(path: x.target, space: c.services.diskSpace), ]), ), // 68 · 70: 원본을 읽지 못함 / 원본이 비어 멈춤 - "맞출 것 없음" 대신 눈에 띄게, 할 일과 [다시 시도] diff --git a/lib/ui/monitor/transfer_widgets.dart b/lib/ui/monitor/transfer_widgets.dart index 36e9e06..4196454 100644 --- a/lib/ui/monitor/transfer_widgets.dart +++ b/lib/ui/monitor/transfer_widgets.dart @@ -1,7 +1,7 @@ /// 모니터링 공용 작은 부품 (복사 · rsync 카드와 lsync 카드가 함께 씀, 다른 화면도 씀). /// 약속: /// - formatRunStamp(iso): ISO 시각 → "M/D HH:MM". 날짜가 아니면 ''. 오류를 던지지 않음. -/// - DiskSpaceInfo(path): 경로 (없으면 있는 위 폴더) 의 디스크 남은 / 전체. 못 재면 "대상 디스크: 알 수 없음". +/// - DiskSpaceInfo(path, space?): 경로 (없으면 있는 위 폴더) 의 디스크 남은 / 전체 (space 로 재는 함수를 줄 수 있음 - PlatformServices.diskSpace). 못 재면 "대상 디스크: 알 수 없음". /// - TransferProgressLines(job): 작업의 진행 두 줄 (전체 · 지금 폴더). job 이 바뀌면 다시 그림. /// - pruneLabel(v) · PruneChoice: 이동 뒤 원본 정리 값 '' · 'keep' · 'all' 의 이름과 고르기 칩. /// 설정 · 화면 상태를 읽지 않는다. @@ -29,7 +29,10 @@ String formatRunStamp(String iso) { /// 대상 디스크 남은 용량 / 전체 class DiskSpaceInfo extends StatelessWidget { final String path; - const DiskSpaceInfo({super.key, required this.path}); + + /// 재는 함수 (null 이면 실제 디스크 - [diskSpace]) + final Future<(int, int)?> Function(String path)? space; + const DiskSpaceInfo({super.key, required this.path, this.space}); @override Widget build(BuildContext context) { @@ -38,7 +41,7 @@ class DiskSpaceInfo extends StatelessWidget { dir = p.dirname(dir); } return FutureBuilder<(int, int)?>( - future: diskSpace(dir), + future: (space ?? diskSpace)(dir), builder: (_, s) { final d = s.data; if (d == null) return Text(tr('대상 디스크: 알 수 없음'), style: TextStyle(fontSize: 12, color: JjColors.textDim)); diff --git a/lib/ui/player_page.dart b/lib/ui/player_page.dart index 1c4343a..dc7bde3 100644 --- a/lib/ui/player_page.dart +++ b/lib/ui/player_page.dart @@ -17,6 +17,7 @@ import 'audio_now_playing.dart'; import 'dav_external.dart'; import 'theme.dart'; import '../l10n/tr.dart'; +import 'jj_header_row.dart'; /// 파일들을 재생 (외부 프로그램이 지정된 확장자면 그 프로그램으로, [internal] 이면 늘 내장 플레이어) Future playFiles(BuildContext context, AppController c, List files, @@ -544,16 +545,13 @@ class _PlayerPageState extends State { child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( padding: const EdgeInsets.fromLTRB(12, 10, 4, 6), - child: Row(children: [ - Text(trf('재생 목록 {0}개', [pl.playlist.length]), style: const TextStyle(fontSize: 13)), - const Spacer(), - IconButton( - iconSize: 18, - tooltip: tr('닫기'), - icon: const Icon(Icons.close), - onPressed: () => setState(() => _showList = false), - ), - ]), + child: JjHeaderRow( + title: trf('재생 목록 {0}개', [pl.playlist.length]), + style: const TextStyle(fontSize: 13), + actions: [ + IconButton(iconSize: 18, tooltip: tr('닫기'), icon: const Icon(Icons.close), onPressed: () => setState(() => _showList = false)), + ], + ), ), const Divider(height: 1), Expanded( diff --git a/lib/ui/video_adjust_dialog.dart b/lib/ui/video_adjust_dialog.dart index 56cd908..836eda4 100644 --- a/lib/ui/video_adjust_dialog.dart +++ b/lib/ui/video_adjust_dialog.dart @@ -11,6 +11,7 @@ import '../core/models.dart'; import '../l10n/tr.dart'; import 'encode_notice.dart'; import 'theme.dart'; +import 'jj_header_row.dart'; /// 화면 · 색 보정 (파이널 컷의 뷰어 + 인스펙터처럼): 왼쪽은 지금 보고 있는 동영상의 한 장면 미리보기, /// 오른쪽은 화면 비율 · 맞추기 · 회전 · 색 보정. 바꾸면 바로 MKV 만들기 설정에 들어간다. @@ -117,27 +118,27 @@ class _VideoAdjustState extends State<_VideoAdjust> { final wide = MediaQuery.sizeOf(context).width >= 760; final viewer = _viewer(); final inspector = _inspector(); + final reset = s.adjusts ? () => _set(s.resetAdjust()) : null; + final close = IconButton(tooltip: tr('닫기'), icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)); return Column(children: [ // 제목 줄 Container( color: JjColors.panelHigh, padding: const EdgeInsets.fromLTRB(16, 8, 8, 8), - child: Row(children: [ - Icon(Icons.tune, color: JjColors.accent, size: 20), - const SizedBox(width: 8), - Text(tr('화면 · 색 보정'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), - const SizedBox(width: 12), - Expanded( - child: Text(tr('MKV 만들기에 적용됩니다 (영상을 다시 인코딩)'), - overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: JjColors.textDim)), - ), - TextButton.icon( - onPressed: s.adjusts ? () => _set(s.resetAdjust()) : null, - icon: const Icon(Icons.restart_alt, size: 18), - label: Text(tr('모두 초기화')), - ), - IconButton(tooltip: tr('닫기'), icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)), - ]), + // 관리자 지시 (10/21, 15 찾음 F11 - 7247aeb 는 after_1020 에만 있어 다시): 360 에서 제목 줄이 넘쳐 [✕] 가 화면 밖으로 밀렸다. + // 좁으면 제목은 줄이고 (…) 안내 글은 제목 아래 줄로, [모두 초기화] 는 같은 이름의 아이콘 단추, [✕] 는 늘 보임. 넓으면 그대로 + child: JjHeaderRow( + icon: Icon(Icons.tune, color: JjColors.accent, size: 20), + title: tr('화면 · 색 보정'), + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + subtitle: tr('MKV 만들기에 적용됩니다 (영상을 다시 인코딩)'), + narrowBelow: 560, + actions: [ + TextButton.icon(onPressed: reset, icon: const Icon(Icons.restart_alt, size: 18), label: Text(tr('모두 초기화'))), + close, + ], + narrowActions: [IconButton(tooltip: tr('모두 초기화'), icon: const Icon(Icons.restart_alt), onPressed: reset), close], + ), ), Expanded( child: wide diff --git a/lib/ui/work_panel.dart b/lib/ui/work_panel.dart index f3d0ee1..997da32 100644 --- a/lib/ui/work_panel.dart +++ b/lib/ui/work_panel.dart @@ -11,6 +11,7 @@ import 'downloads_page.dart'; import 'network_settings.dart' show DnsFailureLine; import 'theme.dart'; import '../l10n/tr.dart'; +import 'jj_header_row.dart'; /// "작업 현황" 화면 (전체 화면): Android 작업 알림 · 위쪽 작업 표시를 누르면 연다 class WorkStatusPage extends StatefulWidget { @@ -142,13 +143,11 @@ class WorkPanel extends StatelessWidget { Widget _header(BuildContext context) => Padding( padding: const EdgeInsets.fromLTRB(12, 6, 4, 6), - child: Row( - children: [ - Icon(Icons.dashboard_outlined, size: 18, color: JjColors.accent), - const SizedBox(width: 6), - Expanded( - child: Text(tr('작업 현황'), style: TextStyle(fontWeight: FontWeight.w600)), - ), + child: JjHeaderRow( + icon: Icon(Icons.dashboard_outlined, size: 18, color: JjColors.accent), + title: tr('작업 현황'), + style: const TextStyle(fontWeight: FontWeight.w600), + actions: [ IconButton( tooltip: tr('동영상 추가'), iconSize: 18, diff --git a/test/jj_header_row_contract_test.dart b/test/jj_header_row_contract_test.dart new file mode 100644 index 0000000..8919957 --- /dev/null +++ b/test/jj_header_row_contract_test.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/ui/jj_header_row.dart'; + +/// W 공용 위젯 약속 (JjHeaderRow): 제목은 [titleLines] 줄까지 (넘치면 "…") · 단추는 늘 오른쪽 끝에 다 보임 · +/// 좁으면 설명은 제목 아래 (숨기지 않음) 와 좁은 단추 · 300 · 360 폭 · 글자 배율 2 에서 넘치지 않음. 위젯 하나에 대해 한 번만 둔다 (설계 7-2) +void main() { + const long = 'アップロードされた とても長い 動画のタイトル - 第1話 高画質版 받는 중인 다운로드 12개'; + const sub = 'MKV 만들기에 적용됩니다 (영상을 다시 인코딩)'; + + Future show(WidgetTester t, Widget row, {double width = 360, double scale = 1}) async { + t.view.physicalSize = Size(width, 600); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + await t.pumpWidget(MaterialApp( + home: MediaQuery( + data: MediaQueryData(size: Size(width, 600), textScaler: TextScaler.linear(scale)), + child: Scaffold(body: Column(children: [row])), + ), + )); + } + + Widget row({int lines = 1}) => JjHeaderRow( + icon: const Icon(Icons.tune, size: 20), + title: long, + titleLines: lines, + subtitle: sub, + narrowBelow: 560, + actions: [ + TextButton.icon(onPressed: () {}, icon: const Icon(Icons.restart_alt), label: const Text('모두 초기화')), + IconButton(tooltip: '닫기', icon: const Icon(Icons.close), onPressed: () {}), + ], + narrowActions: [ + IconButton(tooltip: '모두 초기화', icon: const Icon(Icons.restart_alt), onPressed: () {}), + IconButton(tooltip: '닫기', icon: const Icon(Icons.close), onPressed: () {}), + ], + ); + + for (final width in [300.0, 360.0]) { + for (final scale in [1.0, 2.0]) { + testWidgets('폭 $width · 배율 $scale: 좁은 꼴 - ✕ 화면 안 · 설명은 제목 아래 · 넘침 없음', (t) async { + await show(t, row(), width: width, scale: scale); + expect(t.takeException(), isNull); + expect(t.getRect(find.byTooltip('닫기')).right, lessThanOrEqualTo(width)); + expect(find.byTooltip('모두 초기화'), findsOneWidget, reason: '좁으면 같은 이름의 아이콘 단추'); + expect(t.getRect(find.text(sub)).top, greaterThanOrEqualTo(t.getRect(find.text(long)).bottom), reason: '설명은 제목 아래'); + expect(t.widget(find.text(long)).maxLines, 1); + }); + } + } + + testWidgets('넓으면 한 줄 - 설명은 제목 옆 · 글 단추', (t) async { + await show(t, row(), width: 960); + expect(t.takeException(), isNull); + expect(find.text('모두 초기화'), findsOneWidget); + expect((t.getRect(find.text(sub)).center.dy - t.getRect(find.text(long)).center.dy).abs(), lessThan(4)); + expect(t.getRect(find.byTooltip('닫기')).right, greaterThan(940), reason: '단추는 오른쪽 끝에 붙음 (가운데에 뜨지 않음)'); + }); + + testWidgets('titleLines 2: 제목이 두 줄까지 (G1 다운로드 종료 창)', (t) async { + await show(t, row(lines: 2), width: 360); + expect(t.takeException(), isNull); + expect(t.widget(find.text(long)).maxLines, 2); + }); +} diff --git a/test/jj_title_bar_contract_test.dart b/test/jj_title_bar_contract_test.dart index ba96c76..bd9cc58 100644 --- a/test/jj_title_bar_contract_test.dart +++ b/test/jj_title_bar_contract_test.dart @@ -38,7 +38,7 @@ void main() { }); testWidgets('폭 $width · 배율 $scale: 넓은 단추는 좁으면 둘째 줄 - 모두 보이고 넘치지 않음', (t) async { - await show(t, (ctx) => jjTitleBar(ctx, title: long, actions: wide(), twoLinesBelow: 480), width: width, scale: scale); + await show(t, (ctx) => jjTitleBar(ctx, title: long, lowerActions: wide(), actions: [IconButton(icon: const Icon(Icons.save), onPressed: () {})], twoLinesBelow: 480), width: width, scale: scale); expect(t.takeException(), isNull); final title = t.getRect(find.text(long)); for (final f in [find.text('새 폴더'), find.text('이 폴더 고르기'), find.byIcon(Icons.visibility_off_outlined)]) { @@ -47,12 +47,13 @@ void main() { expect(r.top, greaterThan(title.bottom), reason: '제목 아래 줄'); expect([r.left >= 0, r.right <= width], [true, true], reason: '화면 안에 다 보임'); } + expect(t.getRect(find.byIcon(Icons.save)).bottom, lessThanOrEqualTo(title.bottom + 20), reason: '[actions] 는 위 줄에 남음'); }); } } testWidgets('넓은 화면에서는 한 줄 (둘째 줄 없음)', (t) async { - await show(t, (ctx) => jjTitleBar(ctx, title: '제목', actions: wide(), twoLinesBelow: 480), width: 960); + await show(t, (ctx) => jjTitleBar(ctx, title: '제목', lowerActions: wide(), twoLinesBelow: 480), width: 960); expect(t.takeException(), isNull); expect((t.getRect(find.text('새 폴더')).center.dy - t.getRect(find.text('제목')).center.dy).abs(), lessThan(4)); });