W FloatingAction: 떠 있는 단추 자리 판단을 FloatingActions 하나로 + 작업 기록 시계 주입 + 캡처 흔들림 고정
- 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 <noreply@anthropic.com>modular
parent
5a65dbf693
commit
1b09030b80
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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', '복사');
|
||||
|
|
|
|||
|
|
@ -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 = <String, Widget Function(AppController, DownloadManager)>{
|
||||
'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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -6,13 +6,17 @@ import 'dart:io';
|
|||
class AppLog {
|
||||
final List<String> 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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<Widget> 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),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue