W JjTitleBar 2: 창 · 판 안의 제목 줄 6곳을 JjHeaderRow 로 + F11 · G1 · G2 를 새 구조로 옮김 (7247aeb · a22832d, after_1020)
- lib/ui/jj_header_row.dart: 아이콘 · 제목 (titleLines 줄까지, "…") · 설명 (넓으면 옆, 좁으면 제목 아래 - 숨기지 않음) · 단추 (늘 오른쪽 끝에 다 보임, 좁으면 narrowActions). 다운로드 창 · 재생 목록 판 · 즐겨찾기 판 · 작업 현황 판 · 저장 공간 정리 창 · 화면 · 색 보정이 부르기만 한다 - F11 (modular7247aeb): 화면 · 색 보정 제목 줄 - 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>
parent
21e1d90f82
commit
48a2ba56dc
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)));
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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('숨긴 항목 (. 으로 시작) 보이기'),
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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: 원본을 읽지 못함 / 원본이 비어 멈춤 - "맞출 것 없음" 대신 눈에 띄게, 할 일과 [다시 시도]
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue