W JjTitleBar 2: 창 · 판 안의 제목 줄 6곳을 JjHeaderRow 로 + F11 · G1 · G2 를 새 구조로 옮김 (7247aeb · a22832d, after_1020)

- lib/ui/jj_header_row.dart: 아이콘 · 제목 (titleLines 줄까지, "…") · 설명 (넓으면 옆, 좁으면 제목 아래 - 숨기지 않음) ·
  단추 (늘 오른쪽 끝에 다 보임, 좁으면 narrowActions). 다운로드 창 · 재생 목록 판 · 즐겨찾기 판 · 작업 현황 판 ·
  저장 공간 정리 창 · 화면 · 색 보정이 부르기만 한다
- F11 (7247aeb): 화면 · 색 보정 제목 줄 - 560 미만이면 안내 글은 제목 아래, [모두 초기화] 는 같은 이름의 아이콘 단추, ✕ 늘 보임
- G1 (a22832d): 다운로드 종료 창 제목 두 줄까지 · ✕ 늘 보임
- G2 (a22832d): jjTitleBar 에 lowerActions - AI 비교는 600 미만이면 원본 / 올린 것 고르기만 둘째 줄 가운데, [저장] 은 그대로.
  앱 안 폴더 고르기는 단추 셋이 함께 둘째 줄로
- 시험: jj_header_row_contract_test · windows_hdr360_shot_test (a22832d 에서 옮김 - 앞 판은 360 에서 47px 넘쳐 실패)
- 캡처 흔들림 고정 (시험 쪽): bigtext (폴더 · 기록 시각 · 대상 디스크) · magnet (움직이는 "준비 중…" 막대, TickerMode)
- 주입: AppController.note(at:) (처음 값 지금) · PlatformServices.diskSpace + DiskSpaceInfo(space:) (처음 값 실제 디스크)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
modular
RyuJongJin 2026-10-25 03:28:46 +09:00
parent 21e1d90f82
commit 48a2ba56dc
21 changed files with 360 additions and 92 deletions

View File

@ -52,12 +52,15 @@ void main() {
addTearDown(t.view.reset); addTearDown(t.view.reset);
await t.runAsync(() => i18n.apply(lang, save: false)); await t.runAsync(() => i18n.apply(lang, save: false));
addTearDown(() => i18n.apply('ko', save: false)); addTearDown(() => i18n.apply('ko', save: false));
final tmp = testTempDir('jj_bigtext_'); // 관리자 지시 (10/24): 폴더 이름 · 기록 시각을 실행마다 같게 (캡처가 흔들리지 않게 - 시험 쪽에서만)
final tmp = testFixedTempDir('jj_bigtext_fixed');
appTempInto(tmp); appTempInto(tmp);
final video = p.join(tmp.path, '아주 긴 이름의 동영상 파일 - 시험용 2026.mp4'); final video = p.join(tmp.path, '아주 긴 이름의 동영상 파일 - 시험용 2026.mp4');
File(video).writeAsStringSync('v'); File(video).writeAsStringSync('v');
Directory(p.join(tmp.path, 'sub')).createSync(); 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 c.settings
..explorer.paths = [tmp.path, tmp.path] ..explorer.paths = [tmp.path, tmp.path]
..copy.rsyncPaths = [tmp.path, tmp.path] ..copy.rsyncPaths = [tmp.path, tmp.path]
@ -70,8 +73,9 @@ void main() {
File(f).writeAsStringSync('v'); File(f).writeAsStringSync('v');
c.videos.add(VideoItem(f)); c.videos.add(VideoItem(f));
} }
pinTestTimes(tmp);
for (var i = 1; i <= 3; i++) { 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; c.selected = c.videos.first;
final d = DownloadManager(backends: const [], settings: () => c.settings, readClipboard: () async => null); final d = DownloadManager(backends: const [], settings: () => c.settings, readClipboard: () async => null);

View File

@ -83,6 +83,7 @@ void main() {
createSubtitleProviders: r.createSubtitleProviders, createSubtitleProviders: r.createSubtitleProviders,
createUpscaler: r.createUpscaler, createUpscaler: r.createUpscaler,
findLanSmb: r.findLanSmb, findLanSmb: r.findLanSmb,
diskSpace: r.diskSpace,
updater: r.updater, updater: r.updater,
usage: r.usage == null ? null : const _FixedUsage(), usage: r.usage == null ? null : const _FixedUsage(),
models: r.models, models: r.models,

View File

@ -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<PlaylistEntry>)?> expandPlaylist(String url) async => null;
@override
Future<void> start(DownloadTask t, void Function() changed) async {
t.state = DownloadState.downloading;
changed();
}
@override
Future<void> pause(DownloadTask t) async {}
@override
Future<void> cancel(DownloadTask t) async {}
@override
Future<void> 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<NavigatorState>();
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<void> settle([int n = 8]) async {
for (var i = 0; i < n; i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 50)));
await t.pump();
}
}
Future<void> 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<bool>(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');
});
}
}
}

View File

@ -71,9 +71,10 @@ void main() {
..['stallReasons'] = ['dns', 'firewall']; ..['stallReasons'] = ['dns', 'firewall'];
d.useSystemDns = () async {}; d.useSystemDns = () async {};
final shotKey = GlobalKey(); final shotKey = GlobalKey();
// 움직이는 "준비 중…" 막대는 찍히는 때에 따라 달라 멈춰 둔다 (TickerMode - 캡처가 흔들리지 않게, 시험 쪽에서만 · 관리자 10/24)
await t.pumpWidget(RepaintBoundary( await t.pumpWidget(RepaintBoundary(
key: shotKey, 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++) { for (var i = 0; i < 6; i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 60))); await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 60)));

View File

@ -484,15 +484,15 @@ class AppController extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// 작업 기록에 한 줄 남기기 (앱의 다른 부분에서 알릴 것이 있을 때) /// 작업 기록에 한 줄 남기기 (앱의 다른 부분에서 알릴 것이 있을 때). [at] 적은 때 (처음 값 = 지금 - [AppLog.add] 와 같음)
void note(String msg) => _log(msg); void note(String msg, {DateTime? at}) => _log(msg, at: at);
/// 작업 기록을 파일에도 남길 곳 (설정 폴더의 app.log - [AppLog]) /// 작업 기록을 파일에도 남길 곳 (설정 폴더의 app.log - [AppLog])
String? get logFile => _logBook.file; String? get logFile => _logBook.file;
set logFile(String? v) => _logBook.file = v; set logFile(String? v) => _logBook.file = v;
void _log(String msg) { void _log(String msg, {DateTime? at}) {
_logBook.add(msg); _logBook.add(msg, at: at);
notifyListeners(); notifyListeners();
} }
} }

View File

@ -72,6 +72,9 @@ class PlatformServices {
/// 같은 망의 SMB 서버 찾기 (없으면 이 기기의 사설망을 실제로 훑는다 - smb_settings) /// 같은 망의 SMB 서버 찾기 (없으면 이 기기의 사설망을 실제로 훑는다 - smb_settings)
final Future<List<LanHost>> Function({bool Function()? cancelled, void Function(int done, int total)? onProgress})? findLanSmb; final Future<List<LanHost>> 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, 처음 값 = 이 기기) /// 이 기기 (Android · Windows · 메모리) - 기기마다 다르게 도는 곳은 이것을 본다 (M1, 처음 값 = 이 기기)
final PlatformInfo platform; final PlatformInfo platform;
@ -89,6 +92,7 @@ class PlatformServices {
this.createSubtitleProviders, this.createSubtitleProviders,
this.createUpscaler, this.createUpscaler,
this.findLanSmb, this.findLanSmb,
this.diskSpace,
this.updater, this.updater,
this.usage, this.usage,
ModelStore? models, ModelStore? models,

View File

@ -94,13 +94,14 @@ class UpscaleCompareViewState extends State<UpscaleCompareView> {
Widget _page(BuildContext context) { Widget _page(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: Colors.black, backgroundColor: Colors.black,
// 원본 · 올린 것 고르기가 넓어 좁으면 제목 아래 줄로 // 관리자 지시 (10/21, 15 찾음 G2 - a22832d 는 after_1020 에만 있어 다시): 360 에서 위 막대가 넘쳐 제목이 안 보이고 [저장] 글자가 잘렸다 -
// 폭 600 미만이면 원본 / 올린 것 고르기를 위 막대 아래 둘째 줄로, [저장] 은 글자 그대로. 넓으면 그대로
appBar: jjTitleBar( appBar: jjTitleBar(
context, context,
twoLinesBelow: 480, twoLinesBelow: 600,
leading: IconButton(tooltip: tr('닫기'), icon: const Icon(Icons.close), onPressed: _close), leading: IconButton(tooltip: tr('닫기'), icon: const Icon(Icons.close), onPressed: _close),
title: widget.title, title: widget.title,
actions: [ lowerActions: [
SegmentedButton<bool>( SegmentedButton<bool>(
segments: [ segments: [
ButtonSegment(value: false, label: Text(tr('원본'))), ButtonSegment(value: false, label: Text(tr('원본'))),
@ -110,6 +111,8 @@ class UpscaleCompareViewState extends State<UpscaleCompareView> {
onSelectionChanged: (v) => setState(() => _up = v.first), onSelectionChanged: (v) => setState(() => _up = v.first),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
],
actions: [
FilledButton.icon( FilledButton.icon(
onPressed: () => Navigator.pop(context, true), onPressed: () => Navigator.pop(context, true),
icon: const Icon(Icons.save_outlined, size: 18), icon: const Icon(Icons.save_outlined, size: 18),

View File

@ -161,7 +161,7 @@ class _FileBrowserState extends State<_FileBrowser> {
if (!didPop && dir != null) _open(p.dirname(dir)); if (!didPop && dir != null) _open(p.dirname(dir));
}, },
child: Scaffold( child: Scaffold(
// 새 폴더 · 고르기 단추가 넓어 좁으면 제목 아래 줄로 // [폴더 만들기] · [이 폴더 선택] 이 넓어 좁으면 제목 아래 줄로 (412 ja 에서 위 막대가 넘쳤다)
appBar: jjTitleBar( appBar: jjTitleBar(
context, context,
twoLinesBelow: 480, twoLinesBelow: 480,
@ -172,7 +172,7 @@ class _FileBrowserState extends State<_FileBrowser> {
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
), ),
title: widget.title, title: widget.title,
actions: [ lowerActions: [
// 숨긴 항목 (. 으로 시작) 보이기 / 숨기기 // 숨긴 항목 (. 으로 시작) 보이기 / 숨기기
IconButton( IconButton(
tooltip: _showHidden ? tr('숨긴 항목 숨기기') : tr('숨긴 항목 (. 으로 시작) 보이기'), tooltip: _showHidden ? tr('숨긴 항목 숨기기') : tr('숨긴 항목 (. 으로 시작) 보이기'),

View File

@ -8,6 +8,7 @@ import '../../l10n/tr.dart';
import '../bookmark_ui.dart'; import '../bookmark_ui.dart';
import '../key_hint.dart'; import '../key_hint.dart';
import '../theme.dart'; import '../theme.dart';
import '../jj_header_row.dart';
// ───────── 즐겨찾기 관리 패널 (Chrome 즐겨찾기 관리자 참고) ───────── // ───────── 즐겨찾기 관리 패널 (Chrome 즐겨찾기 관리자 참고) ─────────
@ -65,11 +66,11 @@ class _BookmarkPanelState extends State<BookmarkPanel> {
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
Padding( Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 4, 0), padding: const EdgeInsets.fromLTRB(12, 8, 4, 0),
child: Row(children: [ child: JjHeaderRow(
Icon(Icons.bookmarks_outlined, size: 18, color: JjColors.accent), icon: Icon(Icons.bookmarks_outlined, size: 18, color: JjColors.accent),
const SizedBox(width: 6), title: tr('즐겨찾기'),
Text(tr('즐겨찾기'), style: TextStyle(fontWeight: FontWeight.w600)), style: const TextStyle(fontWeight: FontWeight.w600),
const Spacer(), actions: [
if (widget.onOpenManager != null) if (widget.onOpenManager != null)
IconButton( IconButton(
tooltip: keyHint(tr('즐겨찾기 관리자'), 'Ctrl+Shift+O'), tooltip: keyHint(tr('즐겨찾기 관리자'), 'Ctrl+Shift+O'),
@ -78,7 +79,8 @@ class _BookmarkPanelState extends State<BookmarkPanel> {
onPressed: () => widget.onOpenManager!(folderId: _folderId), onPressed: () => widget.onOpenManager!(folderId: _folderId),
), ),
IconButton(tooltip: tr('닫기'), iconSize: 18, icon: const Icon(Icons.close), onPressed: widget.onClose), IconButton(tooltip: tr('닫기'), iconSize: 18, icon: const Icon(Icons.close), onPressed: widget.onClose),
]), ],
),
), ),
// 최상위 폴더 선택 // 최상위 폴더 선택
Padding( Padding(

View File

@ -5,6 +5,7 @@ import '../app/cleanup.dart';
import '../app/download_manager.dart'; import '../app/download_manager.dart';
import '../l10n/tr.dart'; import '../l10n/tr.dart';
import 'theme.dart'; import 'theme.dart';
import 'jj_header_row.dart';
/// 저장 공간 정리: 찾은 묶음별 크기를 보여 주고, 고른 것만 지운다 /// 저장 공간 정리: 찾은 묶음별 크기를 보여 주고, 고른 것만 지운다
Future<void> showCleanup(BuildContext context, AppController c, DownloadManager? downloads) async { Future<void> showCleanup(BuildContext context, AppController c, DownloadManager? downloads) async {
@ -58,11 +59,7 @@ class _CleanupDialogState extends State<_CleanupDialog> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final groups = _groups; final groups = _groups;
return AlertDialog( return AlertDialog(
title: Row(children: [ title: JjHeaderRow(icon: Icon(Icons.cleaning_services_outlined, color: JjColors.accent), title: tr('저장 공간 정리')),
Icon(Icons.cleaning_services_outlined, color: JjColors.accent),
const SizedBox(width: 10),
Text(tr('저장 공간 정리')),
]),
content: SizedBox( content: SizedBox(
width: 560, width: 560,
child: groups == null child: groups == null

View File

@ -9,6 +9,7 @@ import 'app_actions.dart';
import 'confirm.dart'; import 'confirm.dart';
import 'theme.dart'; import 'theme.dart';
import '../l10n/tr.dart'; import '../l10n/tr.dart';
import 'jj_header_row.dart';
/// 다운로드 목록 /// 다운로드 목록
class DownloadsPage extends StatefulWidget { class DownloadsPage extends StatefulWidget {
@ -510,15 +511,13 @@ class _ExitDialog extends StatelessWidget {
final active = d.tasks.where((t) => t.unfinished).toList(); final active = d.tasks.where((t) => t.unfinished).toList();
return AlertDialog( return AlertDialog(
titlePadding: const EdgeInsets.fromLTRB(24, 16, 8, 0), titlePadding: const EdgeInsets.fromLTRB(24, 16, 8, 0),
title: Row(children: [ // 관리자 지시 (10/21, 15 찾음 G1 - a22832d 는 after_1020 에만 있어 다시): 360 에서 제목이 넘쳐 개수와 [✕] 가 잘렸다 -
Text(trf('받는 중인 다운로드 {0}개', [active.length])), // 제목은 두 줄까지 감싸고 [✕] 는 늘 보이게
const Spacer(), title: JjHeaderRow(
IconButton( title: trf('받는 중인 다운로드 {0}개', [active.length]),
tooltip: tr('종료'), titleLines: 2,
icon: const Icon(Icons.close), actions: [IconButton(tooltip: tr('종료'), icon: const Icon(Icons.close), onPressed: () => _quit(context))],
onPressed: () => _quit(context),
), ),
]),
content: SizedBox( content: SizedBox(
width: 520, width: 520,
height: 300, height: 300,

69
lib/ui/jj_header_row.dart Normal file
View File

@ -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<Widget> actions;
final List<Widget>? 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),
]);
}
}

View File

@ -1,8 +1,10 @@
/// 약속 (W 공용 위젯, 설계 7-2 JjTitleBar - 쪽 위 막대): 제목 · 앞 단추 · 뒤 단추들 → 위 막대 (Scaffold.appBar). /// 약속 (W 공용 위젯, 설계 7-2 JjTitleBar - 쪽 위 막대): 제목 · 앞 단추 · 뒤 단추들 → 위 막대 (Scaffold.appBar).
/// 입력: 제목 글 (한 줄 - 넘치면 끝을 "…", [path] 면 앞을 "…" 로 줄여 끝 (지금 폴더) 이 보임 - [TailText]) · 앞 단추 (없으면 Flutter 처음 값: /// 입력: 제목 글 (한 줄 - 넘치면 끝을 "…", [path] 면 앞을 "…" 로 줄여 끝 (지금 폴더) 이 보임 - [TailText]) · 앞 단추 (없으면 Flutter 처음 값:
/// 돌아갈 곳이 있으면 화면 언어의 뒤로) · 뒤 단추들 · [twoLinesBelow] (화면 폭이 이보다 좁으면 뒤 단추들을 제목 아래 둘째 줄로 - 넓은 단추를 가진 쪽). /// 돌아갈 곳이 있으면 화면 언어의 뒤로) · 뒤 단추들 [actions] (늘 위 막대에) · 내려갈 단추들 [lowerActions] (넓으면 [actions] 앞에,
/// 화면 폭이 [twoLinesBelow] 보다 좁으면 제목 아래 둘째 줄 가운데로 - 넓은 고르기 · 글 단추).
/// 출력: AppBar (모양은 앱 테마). 둘째 줄도 넘치면 그 줄만 줄여 모두 보이게 하며, 단추를 숨기지 않는다. 오류 없음. /// 출력: AppBar (모양은 앱 테마). 둘째 줄도 넘치면 그 줄만 줄여 모두 보이게 하며, 단추를 숨기지 않는다. 오류 없음.
/// 쓰는 곳: 휴지통 · 보관함 · 저장 공간 · 압축 · 글 보기 · 찾기 결과 · DLNA · AI 비교 등 쪽 위 막대. 화면은 부르기만 한다 /// 쓰는 곳: 휴지통 · 보관함 · 저장 공간 · 압축 · 글 보기 · 찾기 결과 · DLNA · AI 비교 (G2) · 앱 안 폴더 고르기 등 쪽 위 막대. 화면은 부르기만 한다.
/// 창 · 판 안의 제목 줄은 [JjHeaderRow]
library; library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -15,23 +17,21 @@ PreferredSizeWidget jjTitleBar(
bool path = false, bool path = false,
Widget? leading, Widget? leading,
List<Widget> actions = const [], List<Widget> actions = const [],
List<Widget> lowerActions = const [],
double twoLinesBelow = 0, double twoLinesBelow = 0,
}) { }) {
final twoLines = actions.isNotEmpty && MediaQuery.sizeOf(context).width < twoLinesBelow; final twoLines = lowerActions.isNotEmpty && MediaQuery.sizeOf(context).width < twoLinesBelow;
return AppBar( return AppBar(
leading: leading, leading: leading,
title: path ? TailText(title) : Text(title, maxLines: 1, overflow: TextOverflow.ellipsis), title: path ? TailText(title) : Text(title, maxLines: 1, overflow: TextOverflow.ellipsis),
actions: twoLines ? null : actions, actions: twoLines ? actions : [...lowerActions, ...actions],
bottom: twoLines bottom: twoLines
? PreferredSize( ? PreferredSize(
preferredSize: const Size.fromHeight(48), preferredSize: const Size.fromHeight(52),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4), padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
child: Align(
alignment: Alignment.centerRight,
// 둘째 줄에도 다 안 들어가면 (좁은 폭 · 큰 글자) 줄 전체를 그만큼만 줄인다 - 단추를 숨기거나 화면 밖으로 밀지 않음 // 둘째 줄에도 다 안 들어가면 (좁은 폭 · 큰 글자) 줄 전체를 그만큼만 줄인다 - 단추를 숨기거나 화면 밖으로 밀지 않음
child: FittedBox(fit: BoxFit.scaleDown, child: Row(mainAxisSize: MainAxisSize.min, children: actions)), child: Center(child: FittedBox(fit: BoxFit.scaleDown, child: Row(mainAxisSize: MainAxisSize.min, children: lowerActions))),
),
), ),
) )
: null, : null,

View File

@ -309,7 +309,7 @@ class _CopyTaskCardState extends State<CopyTaskCard> {
padding: const EdgeInsets.only(left: 28, top: 2), padding: const EdgeInsets.only(left: 28, top: 2),
child: Wrap(spacing: 16, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: [ child: Wrap(spacing: 16, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: [
Text(status, style: TextStyle(fontSize: 12, color: color)), 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) if (t.lastResult == 'failed' && !running && t.lastMessage.isNotEmpty)

View File

@ -239,7 +239,7 @@ class LivePairCard extends StatelessWidget {
if (st != null) if (st != null)
Text('${tr('마지막')}: ${st.$1.hour.toString().padLeft(2, '0')}:${st.$1.minute.toString().padLeft(2, '0')} ${st.$2}', 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)), 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: 원본을 읽지 못함 / 원본이 비어 멈춤 - "맞출 것 없음" 대신 눈에 띄게, 할 일과 [다시 시도] // 68 · 70: 원본을 읽지 못함 / 원본이 비어 멈춤 - "맞출 것 없음" 대신 눈에 띄게, 할 일과 [다시 시도]

View File

@ -1,7 +1,7 @@
/// 모니터링 공용 작은 부품 (복사 · rsync 카드와 lsync 카드가 함께 씀, 다른 화면도 씀). /// 모니터링 공용 작은 부품 (복사 · rsync 카드와 lsync 카드가 함께 씀, 다른 화면도 씀).
/// 약속: /// 약속:
/// - formatRunStamp(iso): ISO 시각 → "M/D HH:MM". 날짜가 아니면 ''. 오류를 던지지 않음. /// - formatRunStamp(iso): ISO 시각 → "M/D HH:MM". 날짜가 아니면 ''. 오류를 던지지 않음.
/// - DiskSpaceInfo(path): 경로 (없으면 있는 위 폴더) 의 디스크 남은 / 전체. 못 재면 "대상 디스크: 알 수 없음". /// - DiskSpaceInfo(path, space?): 경로 (없으면 있는 위 폴더) 의 디스크 남은 / 전체 (space 로 재는 함수를 줄 수 있음 - PlatformServices.diskSpace). 못 재면 "대상 디스크: 알 수 없음".
/// - TransferProgressLines(job): 작업의 진행 두 줄 (전체 · 지금 폴더). job 이 바뀌면 다시 그림. /// - TransferProgressLines(job): 작업의 진행 두 줄 (전체 · 지금 폴더). job 이 바뀌면 다시 그림.
/// - pruneLabel(v) · PruneChoice: 이동 뒤 원본 정리 값 '' · 'keep' · 'all' 의 이름과 고르기 칩. /// - pruneLabel(v) · PruneChoice: 이동 뒤 원본 정리 값 '' · 'keep' · 'all' 의 이름과 고르기 칩.
/// 설정 · 화면 상태를 읽지 않는다. /// 설정 · 화면 상태를 읽지 않는다.
@ -29,7 +29,10 @@ String formatRunStamp(String iso) {
/// 대상 디스크 남은 용량 / 전체 /// 대상 디스크 남은 용량 / 전체
class DiskSpaceInfo extends StatelessWidget { class DiskSpaceInfo extends StatelessWidget {
final String path; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -38,7 +41,7 @@ class DiskSpaceInfo extends StatelessWidget {
dir = p.dirname(dir); dir = p.dirname(dir);
} }
return FutureBuilder<(int, int)?>( return FutureBuilder<(int, int)?>(
future: diskSpace(dir), future: (space ?? diskSpace)(dir),
builder: (_, s) { builder: (_, s) {
final d = s.data; final d = s.data;
if (d == null) return Text(tr('대상 디스크: 알 수 없음'), style: TextStyle(fontSize: 12, color: JjColors.textDim)); if (d == null) return Text(tr('대상 디스크: 알 수 없음'), style: TextStyle(fontSize: 12, color: JjColors.textDim));

View File

@ -17,6 +17,7 @@ import 'audio_now_playing.dart';
import 'dav_external.dart'; import 'dav_external.dart';
import 'theme.dart'; import 'theme.dart';
import '../l10n/tr.dart'; import '../l10n/tr.dart';
import 'jj_header_row.dart';
/// 파일들을 재생 (외부 프로그램이 지정된 확장자면 그 프로그램으로, [internal] 이면 늘 내장 플레이어) /// 파일들을 재생 (외부 프로그램이 지정된 확장자면 그 프로그램으로, [internal] 이면 늘 내장 플레이어)
Future<void> playFiles(BuildContext context, AppController c, List<String> files, Future<void> playFiles(BuildContext context, AppController c, List<String> files,
@ -544,16 +545,13 @@ class _PlayerPageState extends State<PlayerPage> {
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
Padding( Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 4, 6), padding: const EdgeInsets.fromLTRB(12, 10, 4, 6),
child: Row(children: [ child: JjHeaderRow(
Text(trf('재생 목록 {0}개', [pl.playlist.length]), style: const TextStyle(fontSize: 13)), title: trf('재생 목록 {0}개', [pl.playlist.length]),
const Spacer(), style: const TextStyle(fontSize: 13),
IconButton( actions: [
iconSize: 18, IconButton(iconSize: 18, tooltip: tr('닫기'), icon: const Icon(Icons.close), onPressed: () => setState(() => _showList = false)),
tooltip: tr('닫기'), ],
icon: const Icon(Icons.close),
onPressed: () => setState(() => _showList = false),
), ),
]),
), ),
const Divider(height: 1), const Divider(height: 1),
Expanded( Expanded(

View File

@ -11,6 +11,7 @@ import '../core/models.dart';
import '../l10n/tr.dart'; import '../l10n/tr.dart';
import 'encode_notice.dart'; import 'encode_notice.dart';
import 'theme.dart'; import 'theme.dart';
import 'jj_header_row.dart';
/// 화면 · 색 보정 (파이널 컷의 뷰어 + 인스펙터처럼): 왼쪽은 지금 보고 있는 동영상의 한 장면 미리보기, /// 화면 · 색 보정 (파이널 컷의 뷰어 + 인스펙터처럼): 왼쪽은 지금 보고 있는 동영상의 한 장면 미리보기,
/// 오른쪽은 화면 비율 · 맞추기 · 회전 · 색 보정. 바꾸면 바로 MKV 만들기 설정에 들어간다. /// 오른쪽은 화면 비율 · 맞추기 · 회전 · 색 보정. 바꾸면 바로 MKV 만들기 설정에 들어간다.
@ -117,27 +118,27 @@ class _VideoAdjustState extends State<_VideoAdjust> {
final wide = MediaQuery.sizeOf(context).width >= 760; final wide = MediaQuery.sizeOf(context).width >= 760;
final viewer = _viewer(); final viewer = _viewer();
final inspector = _inspector(); 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: [ return Column(children: [
// 제목 줄 // 제목 줄
Container( Container(
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: [ // 관리자 지시 (10/21, 15 찾음 F11 - 7247aeb 는 after_1020 에만 있어 다시): 360 에서 제목 줄이 넘쳐 [✕] 가 화면 밖으로 밀렸다.
Icon(Icons.tune, color: JjColors.accent, size: 20), // 좁으면 제목은 줄이고 (…) 안내 글은 제목 아래 줄로, [모두 초기화] 는 같은 이름의 아이콘 단추, [✕] 는 늘 보임. 넓으면 그대로
const SizedBox(width: 8), child: JjHeaderRow(
Text(tr('화면 · 색 보정'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), icon: Icon(Icons.tune, color: JjColors.accent, size: 20),
const SizedBox(width: 12), title: tr('화면 · 색 보정'),
Expanded( style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
child: Text(tr('MKV 만들기에 적용됩니다 (영상을 다시 인코딩)'), subtitle: tr('MKV 만들기에 적용됩니다 (영상을 다시 인코딩)'),
overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: JjColors.textDim)), 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],
), ),
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)),
]),
), ),
Expanded( Expanded(
child: wide child: wide

View File

@ -11,6 +11,7 @@ import 'downloads_page.dart';
import 'network_settings.dart' show DnsFailureLine; import 'network_settings.dart' show DnsFailureLine;
import 'theme.dart'; import 'theme.dart';
import '../l10n/tr.dart'; import '../l10n/tr.dart';
import 'jj_header_row.dart';
/// "작업 현황" 화면 (전체 화면): Android 작업 알림 · 위쪽 작업 표시를 누르면 연다 /// "작업 현황" 화면 (전체 화면): Android 작업 알림 · 위쪽 작업 표시를 누르면 연다
class WorkStatusPage extends StatefulWidget { class WorkStatusPage extends StatefulWidget {
@ -142,13 +143,11 @@ class WorkPanel extends StatelessWidget {
Widget _header(BuildContext context) => Padding( Widget _header(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(12, 6, 4, 6), padding: const EdgeInsets.fromLTRB(12, 6, 4, 6),
child: Row( child: JjHeaderRow(
children: [ icon: Icon(Icons.dashboard_outlined, size: 18, color: JjColors.accent),
Icon(Icons.dashboard_outlined, size: 18, color: JjColors.accent), title: tr('작업 현황'),
const SizedBox(width: 6), style: const TextStyle(fontWeight: FontWeight.w600),
Expanded( actions: [
child: Text(tr('작업 현황'), style: TextStyle(fontWeight: FontWeight.w600)),
),
IconButton( IconButton(
tooltip: tr('동영상 추가'), tooltip: tr('동영상 추가'),
iconSize: 18, iconSize: 18,

View File

@ -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<void> 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<Text>(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<Text>(find.text(long)).maxLines, 2);
});
}

View File

@ -38,7 +38,7 @@ void main() {
}); });
testWidgets('폭 $width · 배율 $scale: 넓은 단추는 좁으면 둘째 줄 - 모두 보이고 넘치지 않음', (t) async { 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); expect(t.takeException(), isNull);
final title = t.getRect(find.text(long)); final title = t.getRect(find.text(long));
for (final f in [find.text('새 폴더'), find.text('이 폴더 고르기'), find.byIcon(Icons.visibility_off_outlined)]) { 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.top, greaterThan(title.bottom), reason: '제목 아래 줄');
expect([r.left >= 0, r.right <= width], [true, true], 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 { 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.takeException(), isNull);
expect((t.getRect(find.text('새 폴더')).center.dy - t.getRect(find.text('제목')).center.dy).abs(), lessThan(4)); expect((t.getRect(find.text('새 폴더')).center.dy - t.getRect(find.text('제목')).center.dy).abs(), lessThan(4));
}); });