설정 글 칸이 입력 중에 끊기지 않게 · 키보드가 열리면 위 줄 접기 (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
parent
954b86b84c
commit
3ffaf23955
|
|
@ -62,15 +62,29 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
|
||||||
|
|
||||||
String _valid(String v) => v == 'robocopy' && Platform.isWindows ? v : 'builtin';
|
String _valid(String v) => v == 'robocopy' && Platform.isWindows ? v : 'builtin';
|
||||||
|
|
||||||
/// 옵션 입력칸 (바꾸면 바로 저장, ↺ 로 기본값)
|
/// 옵션 입력칸의 글 (칸마다 하나 - 213: 칸을 새로 만들지 않고 글만 바꾼다)
|
||||||
Widget _options(String title, String hint, String value, String def, void Function(AppSettings, String) set) => SettingTile(
|
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),
|
title: Text(title),
|
||||||
subtitle: Text(hint),
|
subtitle: Text(hint),
|
||||||
trailing: SizedBox(
|
trailing: SizedBox(
|
||||||
width: 380,
|
width: 380,
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
key: ValueKey('$title|$value' == '$title|$def' ? '$title-def' : title),
|
controller: ctl,
|
||||||
initialValue: value,
|
scrollPadding: const EdgeInsets.all(80),
|
||||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
|
style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
isDense: true,
|
isDense: true,
|
||||||
|
|
@ -79,8 +93,8 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
|
||||||
tooltip: trf('기본값으로 ({0})', [def]),
|
tooltip: trf('기본값으로 ({0})', [def]),
|
||||||
icon: const Icon(Icons.restart_alt, size: 18),
|
icon: const Icon(Icons.restart_alt, size: 18),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
|
ctl.text = def;
|
||||||
await c.updateSettings((x) => set(x, def));
|
await c.updateSettings((x) => set(x, def));
|
||||||
setState(() {});
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -89,6 +103,7 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => widget.rsync ? _rsyncPart() : _explorerPart();
|
Widget build(BuildContext context) => widget.rsync ? _rsyncPart() : _explorerPart();
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,10 @@ import 'explorer_bookmarks.dart';
|
||||||
///
|
///
|
||||||
/// [rsync] 면 Rsync 화면: 같은 모양이지만 늘 좌우 두 창 · 폴더만 · 창마다 폴더 하나만 고른다.
|
/// [rsync] 면 Rsync 화면: 같은 모양이지만 늘 좌우 두 창 · 폴더만 · 창마다 폴더 하나만 고른다.
|
||||||
/// 가운데 → (왼쪽 → 오른쪽) · ← (오른쪽 → 왼쪽) · ⇄ (둘 다 함께) 로 rsync, [모니터링] 에 실행한 것이 남는다.
|
/// 가운데 → (왼쪽 → 오른쪽) · ← (오른쪽 → 왼쪽) · ⇄ (둘 다 함께) 로 rsync, [모니터링] 에 실행한 것이 남는다.
|
||||||
|
/// 202 · 221: 창 위 줄을 [← → ★] · 탭 · [WebDAV · 경로 입력] 으로 펴는 가장 작은 창 폭. 이보다 좁으면 끝 버튼은 [⋮] 하나 -
|
||||||
|
/// 폰 세로 (약 400) 에서 탭 칸이 130px 남짓이라 두 번째 저장소 (SD 카드) 가 잘려 보였다
|
||||||
|
const double paneHeaderWideWidth = 480;
|
||||||
|
|
||||||
/// 191: Rsync 화면이 좌우 두 창 + 가운데 버튼 줄로 보이는 가장 작은 폭 (이보다 좁으면 창 하나씩)
|
/// 191: Rsync 화면이 좌우 두 창 + 가운데 버튼 줄로 보이는 가장 작은 폭 (이보다 좁으면 창 하나씩)
|
||||||
const double rsyncTwoPaneMinWidth = 560;
|
const double rsyncTwoPaneMinWidth = 560;
|
||||||
|
|
||||||
|
|
@ -3091,13 +3095,13 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
||||||
/// 창 위쪽: 저장 장치 고르기
|
/// 창 위쪽: 저장 장치 고르기
|
||||||
/// 창 위 줄: [뒤로 · 앞으로 · 즐겨찾기] · 저장 장치 탭 (넘치면 옆으로 밀기) · [WebDAV · 경로 입력].
|
/// 창 위 줄: [뒤로 · 앞으로 · 즐겨찾기] · 저장 장치 탭 (넘치면 옆으로 밀기) · [WebDAV · 경로 입력].
|
||||||
/// 202: 드라이브가 많아 넘쳐도 끝의 버튼은 늘 보이게 (예전에는 줄 전체가 한 목록이라 화면 밖으로 밀려 누를 수 없었다)
|
/// 202: 드라이브가 많아 넘쳐도 끝의 버튼은 늘 보이게 (예전에는 줄 전체가 한 목록이라 화면 밖으로 밀려 누를 수 없었다)
|
||||||
/// 좁은 창 (위아래 두 창 · 폰) 은 앞 버튼도 탭과 함께 밀고, 끝 버튼은 [⋮] 하나로 (늘 보임)
|
/// 좁은 창 (위아래 두 창 · 폰, [paneHeaderWideWidth] 미만) 은 앞 버튼도 탭과 함께 밀고, 끝 버튼은 [⋮] 하나로 (늘 보임)
|
||||||
Widget _paneHeader(_Pane pane) => SizedBox(
|
Widget _paneHeader(_Pane pane) => SizedBox(
|
||||||
height: 40,
|
height: 40,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
builder: (context, box) => box.maxWidth >= 320
|
builder: (context, box) => box.maxWidth >= paneHeaderWideWidth
|
||||||
? Row(children: [
|
? Row(children: [
|
||||||
..._headerStart(pane),
|
..._headerStart(pane),
|
||||||
Expanded(child: _driveTabs(pane)),
|
Expanded(child: _driveTabs(pane)),
|
||||||
|
|
|
||||||
|
|
@ -224,6 +224,9 @@ class _ContextMenuTileState extends State<_ContextMenuTile> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 환경 설정
|
/// 환경 설정
|
||||||
|
/// 213: 키보드가 열린 뒤 남는 높이가 이보다 작으면 설정 화면의 위 줄을 접는다
|
||||||
|
const double settingsCrampedHeight = 360;
|
||||||
|
|
||||||
class SettingsPage extends StatefulWidget {
|
class SettingsPage extends StatefulWidget {
|
||||||
final AppController c;
|
final AppController c;
|
||||||
const SettingsPage({super.key, required this.c});
|
const SettingsPage({super.key, required this.c});
|
||||||
|
|
@ -300,8 +303,13 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||||
final s = c.settings;
|
final s = c.settings;
|
||||||
// Android: 창 · 트레이 · 탐색기 · 다운로드 (yt-dlp · aria2) · 내장 Chrome 설정은 없음
|
// Android: 창 · 트레이 · 탐색기 · 다운로드 (yt-dlp · aria2) · 내장 Chrome 설정은 없음
|
||||||
final desk = !Platform.isAndroid;
|
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(
|
return Scaffold(
|
||||||
body: Column(children: [
|
body: Column(children: [
|
||||||
|
if (!cramped)
|
||||||
AppTopBar(
|
AppTopBar(
|
||||||
nav: const AppNavButtons(),
|
nav: const AppNavButtons(),
|
||||||
actions: AppActions(c: c, onSettingsPage: true),
|
actions: AppActions(c: c, onSettingsPage: true),
|
||||||
|
|
@ -316,6 +324,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
// 묶음 바로가기 (누르면 그 묶음으로). 스크롤해도 위에 그대로
|
// 묶음 바로가기 (누르면 그 묶음으로). 스크롤해도 위에 그대로
|
||||||
|
if (!cramped)
|
||||||
Padding(
|
Padding(
|
||||||
padding: EdgeInsets.fromLTRB(isCompact(context) ? 8 : 24, 12, isCompact(context) ? 8 : 24, 0),
|
padding: EdgeInsets.fromLTRB(isCompact(context) ? 8 : 24, 12, isCompact(context) ? 8 : 24, 0),
|
||||||
child: _groupBar(desk),
|
child: _groupBar(desk),
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,34 @@ void main() {
|
||||||
expect(tester.takeException(), isNull);
|
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 {
|
testWidgets('25: Rsync 화면 폴더도 길게 누르면 메뉴 (고르기 · 열기 · 이름 변경 · 삭제 · 정보)', (tester) async {
|
||||||
final tmp = Directory.systemTemp.createTempSync('jj_rsync_menu_');
|
final tmp = Directory.systemTemp.createTempSync('jj_rsync_menu_');
|
||||||
addTearDown(() => tmp.deleteSync(recursive: true));
|
addTearDown(() => tmp.deleteSync(recursive: true));
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue