설정 글 칸이 입력 중에 끊기지 않게 · 키보드가 열리면 위 줄 접기 (213) · 좁은 창 위 줄 기준 480 (221)

- 213 (관리자 지시, 입력이 안 됨): 설정의 rsync · robocopy 옵션 칸이 값이 기본값과 같아지거나 달라지는 순간 새로 만들어져 포커스를 잃었다.
  칸마다 컨트롤러를 두고 고정 key, ↺ 는 칸의 글을 바꾼다.
- 213: 폰 가로에서 키보드가 열려 남는 높이가 360 미만이면 설정 화면의 위 줄 둘 (막대 · 묶음 바로가기) 을 접는다.
- 221 (관리자 지시): 폰 세로 (창 약 400dp) 에서 창 위 줄이 넓은 배치라 탭 칸이 130dp 남짓이어서 두 번째 저장소 (SD 카드) 가 끝에서 잘려 보였다.
  넓은 배치 기준을 320 → 480 으로 올려, 그보다 좁으면 끝 버튼을 [⋮] 하나로 모으고 탭 칸을 넓힌다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fix_mkv
RyuJongJin 2026-10-11 21:39:34 +09:00
parent 954b86b84c
commit 3ffaf23955
5 changed files with 129 additions and 7 deletions

View File

@ -62,15 +62,29 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
String _valid(String v) => v == 'robocopy' && Platform.isWindows ? v : 'builtin';
/// 옵션 입력칸 (바꾸면 바로 저장, ↺ 로 기본값)
Widget _options(String title, String hint, String value, String def, void Function(AppSettings, String) set) => SettingTile(
/// 옵션 입력칸의 글 (칸마다 하나 - 213: 칸을 새로 만들지 않고 글만 바꾼다)
final _optionText = <String, TextEditingController>{};
@override
void dispose() {
for (final x in _optionText.values) {
x.dispose();
}
super.dispose();
}
/// 옵션 입력칸 (바꾸면 바로 저장, ↺ 로 기본값).
/// 213: 예전에는 값이 기본값과 같아지거나 달라지는 순간 칸을 새로 만들어 (↺ 로 되돌릴 때 글을 바꾸려고) 포커스를 잃고 입력이 끊겼다
Widget _options(String title, String hint, String value, String def, void Function(AppSettings, String) set) {
final ctl = _optionText.putIfAbsent(title, () => TextEditingController(text: value));
return SettingTile(
title: Text(title),
subtitle: Text(hint),
trailing: SizedBox(
width: 380,
child: TextFormField(
key: ValueKey('$title|$value' == '$title|$def' ? '$title-def' : title),
initialValue: value,
controller: ctl,
scrollPadding: const EdgeInsets.all(80),
style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
decoration: InputDecoration(
isDense: true,
@ -79,8 +93,8 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
tooltip: trf('기본값으로 ({0})', [def]),
icon: const Icon(Icons.restart_alt, size: 18),
onPressed: () async {
ctl.text = def;
await c.updateSettings((x) => set(x, def));
setState(() {});
},
),
),
@ -89,6 +103,7 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
),
),
);
}
@override
Widget build(BuildContext context) => widget.rsync ? _rsyncPart() : _explorerPart();

View File

@ -53,6 +53,10 @@ import 'explorer_bookmarks.dart';
///
/// [rsync] 면 Rsync 화면: 같은 모양이지만 늘 좌우 두 창 · 폴더만 · 창마다 폴더 하나만 고른다.
/// 가운데 → (왼쪽 → 오른쪽) · ← (오른쪽 → 왼쪽) · ⇄ (둘 다 함께) 로 rsync, [모니터링] 에 실행한 것이 남는다.
/// 202 · 221: 창 위 줄을 [← → ★] · 탭 · [WebDAV · 경로 입력] 으로 펴는 가장 작은 창 폭. 이보다 좁으면 끝 버튼은 [⋮] 하나 -
/// 폰 세로 (약 400) 에서 탭 칸이 130px 남짓이라 두 번째 저장소 (SD 카드) 가 잘려 보였다
const double paneHeaderWideWidth = 480;
/// 191: Rsync 화면이 좌우 두 창 + 가운데 버튼 줄로 보이는 가장 작은 폭 (이보다 좁으면 창 하나씩)
const double rsyncTwoPaneMinWidth = 560;
@ -3091,13 +3095,13 @@ class _ExplorerPageState extends State<ExplorerPage> {
/// 창 위쪽: 저장 장치 고르기
/// 창 위 줄: [뒤로 · 앞으로 · 즐겨찾기] · 저장 장치 탭 (넘치면 옆으로 밀기) · [WebDAV · 경로 입력].
/// 202: 드라이브가 많아 넘쳐도 끝의 버튼은 늘 보이게 (예전에는 줄 전체가 한 목록이라 화면 밖으로 밀려 누를 수 없었다)
/// 좁은 창 (위아래 두 창 · 폰) 은 앞 버튼도 탭과 함께 밀고, 끝 버튼은 [⋮] 하나로 (늘 보임)
/// 좁은 창 (위아래 두 창 · 폰, [paneHeaderWideWidth] 미만) 은 앞 버튼도 탭과 함께 밀고, 끝 버튼은 [⋮] 하나로 (늘 보임)
Widget _paneHeader(_Pane pane) => SizedBox(
height: 40,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: LayoutBuilder(
builder: (context, box) => box.maxWidth >= 320
builder: (context, box) => box.maxWidth >= paneHeaderWideWidth
? Row(children: [
..._headerStart(pane),
Expanded(child: _driveTabs(pane)),

View File

@ -224,6 +224,9 @@ class _ContextMenuTileState extends State<_ContextMenuTile> {
}
/// 환경 설정
/// 213: 키보드가 열린 뒤 남는 높이가 이보다 작으면 설정 화면의 위 줄을 접는다
const double settingsCrampedHeight = 360;
class SettingsPage extends StatefulWidget {
final AppController c;
const SettingsPage({super.key, required this.c});
@ -300,8 +303,13 @@ class _SettingsPageState extends State<SettingsPage> {
final s = c.settings;
// Android: 창 · 트레이 · 탐색기 · 다운로드 (yt-dlp · aria2) · 내장 Chrome 설정은 없음
final desk = !Platform.isAndroid;
// 213: 폰 가로에서 키보드가 열리면 남는 높이가 작아 위 줄 둘 (막대 · 묶음 바로가기) 이 화면을 다 차지해
// 넣는 칸이 보이지 않았다 - 그동안은 두 줄을 접는다 (키보드를 닫으면 다시 보임)
final mq = MediaQuery.of(context);
final cramped = mq.viewInsets.bottom > 0 && mq.size.height - mq.viewInsets.bottom < settingsCrampedHeight;
return Scaffold(
body: Column(children: [
if (!cramped)
AppTopBar(
nav: const AppNavButtons(),
actions: AppActions(c: c, onSettingsPage: true),
@ -316,6 +324,7 @@ class _SettingsPageState extends State<SettingsPage> {
]),
),
// 묶음 바로가기 (누르면 그 묶음으로). 스크롤해도 위에 그대로
if (!cramped)
Padding(
padding: EdgeInsets.fromLTRB(isCompact(context) ? 8 : 24, 12, isCompact(context) ? 8 : 24, 0),
child: _groupBar(desk),

View File

@ -117,6 +117,34 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('221: 폰 세로 Rsync (한 창) 의 저장소 탭은 글이 다 보인다 (SD 카드가 잘리지 않음)', (tester) async {
final tmp = Directory.systemTemp.createTempSync('jj_rsync_tabs_');
addTearDown(() => tmp.deleteSync(recursive: true));
final inner = p.join(tmp.path, 'inner'), sd = p.join(tmp.path, 'sd');
Directory(p.join(inner, 'L')).createSync(recursive: true);
Directory(p.join(sd, 'R')).createSync(recursive: true);
ExplorerPage.debugVolumes = () => [(inner, '내장 저장소'), (sd, 'SD 카드')];
addTearDown(() => ExplorerPage.debugVolumes = null);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..explorerOrientation = 'side'
..rsyncPaths = [p.join(inner, 'L'), p.join(sd, 'R')];
tester.view.physicalSize = const Size(412, 880);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c, rsync: true))));
await settle(tester, () => find.text('SD 카드').evaluate().isNotEmpty);
// 탭 글은 눌리지 않는다 (글 폭 그대로) - 넘치면 옆으로 밀어서 다 보인다 (202 와 같은 방식)
final plain = TextPainter(text: const TextSpan(text: 'SD 카드'), textDirection: TextDirection.ltr)..layout();
expect(tester.getSize(find.text('SD 카드').first).width, greaterThanOrEqualTo(plain.width * 0.9), reason: '"(" 처럼 눌리지 않음');
final tabs = find.ancestor(of: find.text('SD 카드').first, matching: find.byType(ListView)).first;
await tester.drag(tabs, const Offset(-400, 0));
await settle(tester, () => false, rounds: 10);
expect(tester.getRect(find.text('SD 카드').first).right, lessThanOrEqualTo(tester.getRect(tabs).right + 1), reason: '밀면 끝까지 보임');
expect(find.byTooltip('경로 입력 · WebDAV'), findsOneWidget, reason: '끝 버튼은 [⋮] 하나');
expect(tester.takeException(), isNull);
});
testWidgets('25: Rsync 화면 폴더도 길게 누르면 메뉴 (고르기 · 열기 · 이름 변경 · 삭제 · 정보)', (tester) async {
final tmp = Directory.systemTemp.createTempSync('jj_rsync_menu_');
addTearDown(() => tmp.deleteSync(recursive: true));

View File

@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/core/sync_tools.dart' show defaultRsyncOptions;
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/copy_sync_settings.dart';
import 'package:jj_mkvmaker/ui/settings_page.dart';
/// 213: 설정의 rsync 옵션 칸 - 값이 기본값과 같아지거나 달라지는 순간에도 포커스를 잃지 않고 이어서 쓸 수 있다
void main() {
testWidgets('213: 기본값 ("-avPog") 을 지나며 쳐도 칸이 그대로 (포커스 · 입력 이어짐) · ↺ 는 칸 안의 글도 되돌린다', (tester) async {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings.rsyncOptions = '-avPo';
tester.view.physicalSize = const Size(1400, 1600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: SingleChildScrollView(
// 설정 화면처럼 설정이 바뀌면 다시 그린다
child: ListenableBuilder(listenable: c, builder: (_, _) => CopySyncSettings(c: c, rsync: true)),
),
),
));
await tester.pump();
final field = find.byType(TextFormField).first;
await tester.tap(field);
await tester.pump();
final first = tester.state(field);
// 한 글자씩: "-avPo" → "-avPog" (기본값과 같아짐) → "-avPog " → "-avPog -"
for (final t in ['-avPog', '-avPog ', '-avPog -']) {
await tester.enterText(field, t);
await tester.pump();
expect(tester.state(find.byType(TextFormField).first), same(first), reason: '칸을 새로 만들지 않음 ($t)');
expect(tester.testTextInput.hasAnyClients, isTrue, reason: '포커스 그대로 ($t)');
}
expect(c.settings.rsyncOptions, '-avPog -');
// ↺: 칸 안의 글도 기본값으로
await tester.tap(find.byTooltip('기본값으로 ($defaultRsyncOptions)').first);
await tester.pump();
expect(c.settings.rsyncOptions, defaultRsyncOptions);
expect(find.text(defaultRsyncOptions), findsWidgets);
});
testWidgets('213: 폰 가로에서 키보드가 열리면 설정 화면의 위 줄 (막대 · 묶음 바로가기) 을 접고, 닫으면 다시', (tester) async {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
tester.view.physicalSize = const Size(900, 420);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(MaterialApp(home: SettingsPage(c: c)));
await tester.pumpAndSettle();
expect(find.widgetWithText(ActionChip, '일반'), findsOneWidget);
expect(find.text('바꾸면 바로 저장됩니다'), findsOneWidget);
// 키보드 (남는 높이 420 - 280 = 140)
tester.view.viewInsets = const FakeViewPadding(bottom: 280);
addTearDown(tester.view.resetViewInsets);
await tester.pump();
expect(find.widgetWithText(ActionChip, '일반'), findsNothing);
expect(find.text('바꾸면 바로 저장됩니다'), findsNothing);
tester.view.resetViewInsets();
await tester.pump();
expect(find.widgetWithText(ActionChip, '일반'), findsOneWidget);
});
}