diff --git a/lib/ui/copy_sync_settings.dart b/lib/ui/copy_sync_settings.dart index 0345b4f..61a89b5 100644 --- a/lib/ui/copy_sync_settings.dart +++ b/lib/ui/copy_sync_settings.dart @@ -62,15 +62,29 @@ class _CopySyncSettingsState extends State { 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 = {}; + + @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 { 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 { ), ), ); + } @override Widget build(BuildContext context) => widget.rsync ? _rsyncPart() : _explorerPart(); diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 576ed60..e357066 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -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 { /// 창 위쪽: 저장 장치 고르기 /// 창 위 줄: [뒤로 · 앞으로 · 즐겨찾기] · 저장 장치 탭 (넘치면 옆으로 밀기) · [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)), diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index ae34986..a0e657f 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -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 { 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 { ]), ), // 묶음 바로가기 (누르면 그 묶음으로). 스크롤해도 위에 그대로 + if (!cramped) Padding( padding: EdgeInsets.fromLTRB(isCompact(context) ? 8 : 24, 12, isCompact(context) ? 8 : 24, 0), child: _groupBar(desk), diff --git a/test/explorer_parallel_test.dart b/test/explorer_parallel_test.dart index 3e9ef20..aa392b2 100644 --- a/test/explorer_parallel_test.dart +++ b/test/explorer_parallel_test.dart @@ -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)); diff --git a/test/settings_text_focus_test.dart b/test/settings_text_focus_test.dart new file mode 100644 index 0000000..1634ee2 --- /dev/null +++ b/test/settings_text_focus_test.dart @@ -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); + }); +}