From 1b09030b80401c944d4cfcc60c3b2a7da9bf1536 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sun, 25 Oct 2026 13:01:19 +0900 Subject: [PATCH] =?UTF-8?q?W=20FloatingAction:=20=EB=96=A0=20=EC=9E=88?= =?UTF-8?q?=EB=8A=94=20=EB=8B=A8=EC=B6=94=20=EC=9E=90=EB=A6=AC=20=ED=8C=90?= =?UTF-8?q?=EB=8B=A8=EC=9D=84=20FloatingActions=20=ED=95=98=EB=82=98?= =?UTF-8?q?=EB=A1=9C=20+=20=EC=9E=91=EC=97=85=20=EA=B8=B0=EB=A1=9D=20?= =?UTF-8?q?=EC=8B=9C=EA=B3=84=20=EC=A3=BC=EC=9E=85=20+=20=EC=BA=A1?= =?UTF-8?q?=EC=B2=98=20=ED=9D=94=EB=93=A4=EB=A6=BC=20=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/ui/floating_actions.dart: 좁거나 낮으면 (글자 배율로 나눈 높이 500 미만 - F7) 내용 안 단추 줄, 넓으면 오른쪽 아래에 떠 있고 내용 아래를 단추 높이만큼 비움 (206). MKV 자세히 [이동] 단추가 부르기만 한다 (예전에는 화면 안에 판단 · Stack 이 있었음). 보이는 것 그대로 - MessageText 점검: 문장 잇기는 이미 joinSentences (tr.dart) 하나 - 손으로 빈칸 잇는 곳 없음, 바꾸지 않음 - 주입: PlatformServices.clock → AppLog(now:) (처음 값 실제 시계 - 작업 기록의 [시:분:초]) - 시험: floating_actions_contract_test (떠 있음 / 내용 안 · 비울 높이 · 첫 단추 맨 아래 · floatingInline 4 경우) - 캡처 흔들림 (시험 쪽): mkvdetail (폴더 · 시각 · 기록 시각) · bigtext (JJ_SHOT_TMP 아래 · 드라이브 남은 공간 shotDevice). b3_52 의 받는 중 파일 (.jjpart) 시각은 찍기 전에 고정해 보았으나 복사가 아직 쓰는 중이라 목록이 읽은 시각이 남아 그대로 (까닭을 시험에 적음) Co-Authored-By: Claude Opus 5.5 --- integration_test/support/shot_services.dart | 1 + integration_test/windows_b3_shot_test.dart | 4 +- .../windows_bigtext_shot_test.dart | 11 +-- .../windows_mkvdetail_shot_test.dart | 7 +- lib/app/app_controller.dart | 2 +- lib/app/controller/app_log.dart | 6 +- lib/services/platform_services.dart | 4 ++ lib/ui/floating_actions.dart | 48 +++++++++++++ lib/ui/home/video_detail.dart | 35 +++------- test/floating_actions_contract_test.dart | 68 +++++++++++++++++++ 10 files changed, 150 insertions(+), 36 deletions(-) create mode 100644 lib/ui/floating_actions.dart create mode 100644 test/floating_actions_contract_test.dart diff --git a/integration_test/support/shot_services.dart b/integration_test/support/shot_services.dart index 0d592a0..6edfb55 100644 --- a/integration_test/support/shot_services.dart +++ b/integration_test/support/shot_services.dart @@ -28,6 +28,7 @@ PlatformServices shotServices() { createUpscaler: r.createUpscaler, findLanSmb: r.findLanSmb, diskSpace: (_) async => (107374182400, 536870912000), + clock: r.clock, updater: r.updater, usage: r.usage == null ? null : const FixedUsage(), models: r.models, diff --git a/integration_test/windows_b3_shot_test.dart b/integration_test/windows_b3_shot_test.dart index 080a73a..9fdb13d 100644 --- a/integration_test/windows_b3_shot_test.dart +++ b/integration_test/windows_b3_shot_test.dart @@ -150,7 +150,9 @@ void main() { testWidgets('52: 복사 중에 다른 화면에 갔다 와도 진행 막대', (t) async { // 다른 화면에 갔다 와도 아직 복사 중이게 (약 25초) File(p.join(left, 'big.bin')).writeAsBytesSync(List.filled(2 * 1024 * 1024, 7)); - pinTestTimes(tmp); // 오른쪽에 생기는 받는 중 파일 (.jjpart) 의 시각은 실제 복사가 만든 때라 그대로 둔다 + pinTestTimes(tmp); + // 받는 중 파일 (.jjpart) 의 시각은 그대로 둔다: 관리자 지시 (10/25) 로 찍기 바로 전에 고정해 보았으나, 복사가 (80 KB/s 로) + // 아직 쓰는 중이라 목록이 읽은 시각이 쓴 때 (지금) 로 남아 2장 (52_back_progress · 52_done_refreshed) 은 실행한 분에 따라 다르다 c.settings.copy.bandwidthKBps = 80; await show(t, ExplorerPage(device: device, c: c)); await selectAndPress(t, 'big.bin', '복사'); diff --git a/integration_test/windows_bigtext_shot_test.dart b/integration_test/windows_bigtext_shot_test.dart index a275caf..1de5747 100644 --- a/integration_test/windows_bigtext_shot_test.dart +++ b/integration_test/windows_bigtext_shot_test.dart @@ -21,6 +21,7 @@ import 'package:jj_mkvmaker/ui/settings_page.dart'; import 'package:jj_mkvmaker/ui/theme.dart'; import 'package:path/path.dart' as p; +import 'support/shot_services.dart'; import 'support/temp_dir.dart'; /// 253 · 255 · 256 (관리자 지시 10/21): test/large_text_test 의 화면들을 실제 글꼴로 - 글자 1.6배 (412×900 · 900×412 · 1280×800) 와 @@ -32,8 +33,8 @@ void main() { final out = Platform.environment['JJ_SHOT_DIR'] ?? ''; final pages = { 'mkv': (c, d) => HomePage(c: c), - 'explorer': (c, d) => ExplorerPage(c: c), - 'rsync': (c, d) => ExplorerPage(c: c, rsync: true), + 'explorer': (c, d) => ExplorerPage(c: c, device: shotDevice()), + 'rsync': (c, d) => ExplorerPage(c: c, rsync: true, device: shotDevice()), 'monitor': (c, d) => MonitorPage(c: c), 'downloads': (c, d) => DownloadsPage(d: d), 'settings': (c, d) => SettingsPage(c: c), @@ -53,7 +54,9 @@ void main() { await t.runAsync(() => i18n.apply(lang, save: false)); addTearDown(() => i18n.apply('ko', save: false)); // 관리자 지시 (10/24): 폴더 이름 · 기록 시각을 실행마다 같게 (캡처가 흔들리지 않게 - 시험 쪽에서만) - final tmp = testFixedTempDir('jj_bigtext_fixed'); + // 트리에 둘레 폴더도 보여 JJ_SHOT_TMP (캡처용으로 따로 둔 폴더) 아래에 둔다 + final base = Platform.environment['JJ_SHOT_TMP'] ?? ''; + final tmp = testFixedTempDir('jj_bigtext_fixed', base: base); appTempInto(tmp); final video = p.join(tmp.path, '아주 긴 이름의 동영상 파일 - 시험용 2026.mp4'); File(video).writeAsStringSync('v'); @@ -73,7 +76,7 @@ void main() { File(f).writeAsStringSync('v'); c.videos.add(VideoItem(f)); } - pinTestTimes(tmp); + pinTestTimes(base.isEmpty ? tmp : Directory(base)); for (var i = 1; i <= 3; i++) { c.note('시험 기록 $i', at: DateTime(2026, 10, 22, 9, i)); } diff --git a/integration_test/windows_mkvdetail_shot_test.dart b/integration_test/windows_mkvdetail_shot_test.dart index f56c0f1..b6ae5a3 100644 --- a/integration_test/windows_mkvdetail_shot_test.dart +++ b/integration_test/windows_mkvdetail_shot_test.dart @@ -61,12 +61,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_mkvdetail_'); + // 관리자 지시 (10/24): 폴더 이름 · 시각을 실행마다 같게 (캡처가 흔들리지 않게 - 시험 쪽에서만) + final tmp = testFixedTempDir('jj_mkvdetail_fixed'); appTempInto(tmp); final file = (File(p.join(tmp.path, '[시험] 아주 긴 이름의 동영상 - 1화 고화질.mp4'))..writeAsBytesSync([0])).path; // 같은 폴더의 외부 자막 (문자셋 고르기까지 든 줄 - F6) File(p.join(tmp.path, '[시험] 아주 긴 이름의 동영상 - 1화 고화질.ko.srt')).writeAsStringSync('1\n00:00:01,000 --> 00:00:02,000\n안녕\n'); - final c = AppController(PlatformServices(mediaTool: _Tool(), storage: DesktopStorageService())); + pinTestTimes(tmp); + // 작업 기록의 [시:분:초] 도 정한 시각으로 (시험 쪽에서만) + final c = AppController(PlatformServices(mediaTool: _Tool(), storage: DesktopStorageService(), clock: () => DateTime(2026, 10, 22, 9))); await t.runAsync(() async { await c.init(); await c.addVideos([file]); diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index fe6a2ad..a96d19b 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -55,7 +55,7 @@ class AppController extends ChangeNotifier { // ───────── 모듈 ───────── - final _logBook = AppLog(); + late final _logBook = AppLog(now: services.clock); late final _jobs = JobQueue(onChanged: notifyListeners, log: _log); late final _files = SubtitleFiles(services.storage, _tool); late final _probe = VideoProbe(storage: services.storage, tool: _tool, files: _files, log: _log, fileAccess: () => fileAccess); diff --git a/lib/app/controller/app_log.dart b/lib/app/controller/app_log.dart index 25f1fbc..9110c54 100644 --- a/lib/app/controller/app_log.dart +++ b/lib/app/controller/app_log.dart @@ -6,13 +6,17 @@ import 'dart:io'; class AppLog { final List lines = []; + /// 지금 시각 (처음 값 = 실제 시계 - PlatformServices.clock 이 있으면 그것) + final DateTime Function() now; + AppLog({DateTime Function()? now}) : now = now ?? DateTime.now; + /// 작업 기록을 파일에도 남길 곳 (설정 폴더의 app.log) String? file; static const maxLines = 500, maxFileBytes = 2 * 1024 * 1024; void add(String msg, {DateTime? at}) { - final t = at ?? DateTime.now(); + final t = at ?? now(); String two(int n) => n.toString().padLeft(2, '0'); lines.add('[${two(t.hour)}:${two(t.minute)}:${two(t.second)}] $msg'); if (lines.length > maxLines) lines.removeAt(0); diff --git a/lib/services/platform_services.dart b/lib/services/platform_services.dart index a23b573..a07fa19 100644 --- a/lib/services/platform_services.dart +++ b/lib/services/platform_services.dart @@ -75,6 +75,9 @@ class PlatformServices { /// 디스크 남은 · 전체 바이트 재기 (없으면 실제 디스크 - copy_center 의 diskSpace). 작업 현황의 "대상 디스크" 줄 final Future<(int, int)?> Function(String path)? diskSpace; + /// 지금 시각 (없으면 실제 시계). 작업 기록의 [시:분:초] + final DateTime Function()? clock; + /// 이 기기 (Android · Windows · 메모리) - 기기마다 다르게 도는 곳은 이것을 본다 (M1, 처음 값 = 이 기기) final PlatformInfo platform; @@ -93,6 +96,7 @@ class PlatformServices { this.createUpscaler, this.findLanSmb, this.diskSpace, + this.clock, this.updater, this.usage, ModelStore? models, diff --git a/lib/ui/floating_actions.dart b/lib/ui/floating_actions.dart new file mode 100644 index 0000000..aad2839 --- /dev/null +++ b/lib/ui/floating_actions.dart @@ -0,0 +1,48 @@ +/// 약속 (W 공용 위젯, 설계 7-2 FloatingAction - 떠 있는 단추 묶음): 단추들 · 내용 → 단추가 내용을 가리지 않는 자리. +/// 입력: [buttons] (첫 단추가 맨 아래 - 예전 [이동] 자리, 다음은 그 위로) · [content] (안에 넣을 단추 줄 · 아래에 비울 높이를 받아 그림) · +/// [inline] (true 면 떠 있지 않고 내용 안 - 좁은 화면 · 낮은 화면 등, [floatingInline] 이 정해 준다). +/// 출력: inline 이면 내용 끝에 오른쪽으로 줄바꿈하는 단추 줄 (아래 여백 24), 아니면 오른쪽 아래에 떠 있고 내용 아래를 단추 높이만큼 비움 +/// (끝까지 내리면 가리지 않게). 오류 없음. 쓰는 곳: MKV 자세히 [이동] 단추 (206 · F7). 화면은 부르기만 한다 +library; + +import 'package:flutter/material.dart'; + +/// 단추 하나의 높이 (FloatingActionButton.extended 56 + 사이 10) +const floatingActionRow = 66.0; + +/// 떠 있으면 가리는 화면: 좁거나 ([compact]) 높이가 낮을 때 (글자 배율로 나눠 본다 - 글자가 크면 같은 높이에 들어가는 줄이 적다, F7) +bool floatingInline(BuildContext context, {required bool compact, double minHeight = 500}) => + compact || MediaQuery.sizeOf(context).height < minHeight * MediaQuery.textScalerOf(context).scale(1); + +class FloatingActions extends StatelessWidget { + const FloatingActions({super.key, required this.buttons, required this.content, required this.inline}); + + final List buttons; + final Widget Function(Widget? inlineRow, double bottomGap) content; + final bool inline; + + @override + Widget build(BuildContext context) { + if (inline) { + final row = Wrap(alignment: WrapAlignment.end, spacing: 10, runSpacing: 10, children: buttons); + return content(row, 24); + } + return Stack(children: [ + content(null, 16 + buttons.length * floatingActionRow + 16), + Positioned( + right: 16, + bottom: 16, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + for (var i = buttons.length - 1; i >= 0; i--) ...[ + buttons[i], + if (i > 0) const SizedBox(height: 10), + ], + ], + ), + ), + ]); + } +} diff --git a/lib/ui/home/video_detail.dart b/lib/ui/home/video_detail.dart index f033b78..3b22c2c 100644 --- a/lib/ui/home/video_detail.dart +++ b/lib/ui/home/video_detail.dart @@ -17,6 +17,7 @@ import '../../l10n/tr.dart'; import 'move_to_target.dart'; import 'subtitle_row.dart'; import '../notice_band.dart'; +import '../floating_actions.dart'; class VideoDetailPane extends StatelessWidget { final AppController c; @@ -52,35 +53,15 @@ class VideoDetailPane extends StatelessWidget { ); // 206: 좁은 화면 (폰 세로) 은 자세히 칸이 낮아 떠 있는 버튼이 칩 (음성 n개 등) 을 가렸다 - 버튼을 칩 줄 아래에 놓는다 // F7 (관리자 승인 10/21, 15 찾음): 높이가 낮은 화면 (폰 가로 등) 도 - 떠 있는 버튼이 [자막 파일 추가] 를 반쯤 가렸다. - // 높이는 글자 배율로 나눠 본다 (글자가 크면 같은 높이에 들어가는 줄이 적다 - 960×600 · 글자 1.6 에서도 가렸다) - if (isCompact(context) || MediaQuery.sizeOf(context).height < 500 * MediaQuery.textScalerOf(context).scale(1)) { - final row = Wrap(alignment: WrapAlignment.end, spacing: 10, runSpacing: 10, children: [ + // 자리 판단 · 그리기는 공용 FloatingActions (넓은 화면은 떠 있는 버튼 높이만큼 목록 아래를 비운다) + return FloatingActions( + inline: floatingInline(context, compact: isCompact(context)), + buttons: [ if (buttons.isEmpty) button(null), for (final t in buttons) button(t), - ]); - return _list(context, info, video, audioCount, locked, moveButtons: row, bottom: 24); - } - // 넓은 화면: 떠 있는 버튼 높이만큼 목록 아래를 비워 끝까지 내리면 가리지 않게 (버튼이 여러 개면 그만큼) - final stacked = buttons.isEmpty ? 1 : buttons.length; - return Stack(children: [ - _list(context, info, video, audioCount, locked, bottom: 16 + stacked * 66.0 + 16), - Positioned( - right: 16, - bottom: 16, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - if (buttons.isEmpty) button(null), - // 첫 번째 버튼이 맨 아래 (예전 [이동] 자리), 다음 버튼은 그 위로 - for (var i = buttons.length - 1; i >= 0; i--) ...[ - button(buttons[i]), - if (i > 0) const SizedBox(height: 10), - ], - ], - ), - ), - ]); + ], + content: (row, bottom) => _list(context, info, video, audioCount, locked, moveButtons: row, bottom: bottom), + ); } Widget _list(BuildContext context, MediaInfo? info, StreamInfo? video, int audioCount, bool locked, diff --git a/test/floating_actions_contract_test.dart b/test/floating_actions_contract_test.dart new file mode 100644 index 0000000..b0ccbb0 --- /dev/null +++ b/test/floating_actions_contract_test.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/ui/floating_actions.dart'; + +/// W 공용 위젯 약속 (FloatingActions): 좁거나 낮은 화면은 내용 안 단추 줄 (떠 있지 않음), 넓으면 오른쪽 아래에 떠 있고 +/// 내용 아래를 단추 높이만큼 비움 · 첫 단추가 맨 아래. 위젯 하나에 대해 한 번만 둔다 (설계 7-2) +void main() { + Widget fab(String n) => FloatingActionButton.extended(heroTag: null, onPressed: () {}, icon: const Icon(Icons.drive_file_move_outline), label: Text(n)); + + Future<(Widget?, double)> show(WidgetTester t, {required bool inline, Size size = const Size(960, 600), double scale = 1}) async { + t.view.physicalSize = size; + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + Widget? gotRow; + var gotGap = -1.0; + await t.pumpWidget(MaterialApp( + home: MediaQuery( + data: MediaQueryData(size: size, textScaler: TextScaler.linear(scale)), + child: Scaffold( + body: FloatingActions( + inline: inline, + buttons: [fab('이동'), fab('NAS')], + content: (row, gap) { + gotRow = row; + gotGap = gap; + return ListView(children: [const SizedBox(height: 100, child: Text('내용')), ?row]); + }, + ), + ), + ), + )); + return (gotRow, gotGap); + } + + testWidgets('넓으면 떠 있음: 내용 아래를 단추 높이만큼 비우고, 첫 단추가 맨 아래', (t) async { + final (row, gap) = await show(t, inline: false); + expect(row, isNull); + expect(gap, 16 + 2 * floatingActionRow + 16); + expect(t.getRect(find.text('이동')).top, greaterThan(t.getRect(find.text('NAS')).top), reason: '첫 단추가 아래'); + expect(t.getRect(find.text('이동')).bottom, greaterThan(600 - 60), reason: '오른쪽 아래'); + }); + + testWidgets('좁거나 낮으면 내용 안 단추 줄 (떠 있지 않음) · 아래 여백 24', (t) async { + final (row, gap) = await show(t, inline: true, size: const Size(412, 900)); + expect(row, isNotNull); + expect(gap, 24); + expect(find.byType(Positioned), findsNothing); + expect(t.takeException(), isNull); + }); + + testWidgets('floatingInline: 좁음 · 높이 500 미만 · 글자 배율로 나눈 높이 (F7 - 960×600 · 1.6 배)', (t) async { + late BuildContext ctx; + for (final (size, scale, compact, want) in [ + (const Size(960, 800), 1.0, false, false), + (const Size(960, 800), 1.0, true, true), + (const Size(900, 412), 1.0, false, true), + (const Size(960, 600), 1.6, false, true), + ]) { + await t.pumpWidget(MediaQuery( + data: MediaQueryData(size: size, textScaler: TextScaler.linear(scale)), + child: Builder(builder: (c) { + ctx = c; + return const SizedBox(); + }))); + expect(floatingInline(ctx, compact: compact), want, reason: '$size × $scale compact=$compact'); + } + }); +}