From 2d9968929dd3d79668de0960d64003297c818c26 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:53:11 +0900 Subject: [PATCH] =?UTF-8?q?W=20JjField=202:=20=EB=8F=84=EC=9B=80=20?= =?UTF-8?q?=EA=B8=80=20=EA=B8=80=20=EC=B9=B8=EC=9D=84=20JjField=20?= =?UTF-8?q?=ED=95=98=EB=82=98=EB=A1=9C=20(SecretField=20=EB=8F=84=20?= =?UTF-8?q?=EA=B7=B8=20=EC=9C=84)=20+=20=EC=BA=A1=EC=B2=98=20=ED=9D=94?= =?UTF-8?q?=EB=93=A4=EB=A6=BC=20b6=20=C2=B7=20p3=20=C2=B7=20xptools=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/ui/jj_field.dart: 칸 이름 짧게 · 도움 글 칸 아래 3줄까지 (helperLines 로 늘림) · 오류 글 · 칸 끝 단추 · 여러 줄 · 테두리 · 가림. WebDAV · FTP · SFTP · SMB 서버 창 · AI 서비스 · 해시 비교 · 시드 · 패턴으로 고르기 · 동기화 일정 · 이름 틀이 부르기만 한다. SecretField 는 JjField 위에 눈 단추만 더한다 - 바뀐 동작: 패턴으로 고르기 · 동기화 일정 창의 도움 글이 한 줄에서 "…" 로 잘리던 것이 3줄까지 보임 (F2 와 같은 결함) - 시험: jj_field_contract_test · field_places_shot_test (패턴 · 일정 창, 캡처가 없던 곳) - 시험 쪽 고정: b6 · p3 · xptools 도 고정 폴더 · 시각 (pinTestTimes 가 만든 · 바뀐 · 열어 본 시각 모두) Co-Authored-By: Claude Opus 5.5 --- integration_test/field_places_shot_test.dart | 75 ++++++++++++++++ integration_test/support/temp_dir.dart | 8 +- integration_test/windows_b6_shot_test.dart | 4 +- integration_test/windows_p3_shot_test.dart | 4 +- .../windows_xplore_tools_shot_test.dart | 4 +- lib/ui/ai_image_page.dart | 7 +- lib/ui/ai_settings.dart | 31 +++---- lib/ui/batch_rename_dialog.dart | 15 ++-- lib/ui/explorer/marks_bar.dart | 5 +- lib/ui/file_tools.dart | 7 +- lib/ui/ftp_settings.dart | 22 ++--- lib/ui/jj_field.dart | 87 +++++++++++++++++++ lib/ui/schedule_editor.dart | 15 ++-- lib/ui/secret_field.dart | 31 +++---- lib/ui/sftp_settings.dart | 22 ++--- lib/ui/smb_settings.dart | 27 +++--- lib/ui/webdav_settings.dart | 14 ++- test/jj_field_contract_test.dart | 69 +++++++++++++++ 18 files changed, 328 insertions(+), 119 deletions(-) create mode 100644 integration_test/field_places_shot_test.dart create mode 100644 lib/ui/jj_field.dart create mode 100644 test/jj_field_contract_test.dart diff --git a/integration_test/field_places_shot_test.dart b/integration_test/field_places_shot_test.dart new file mode 100644 index 0000000..2e100e5 --- /dev/null +++ b/integration_test/field_places_shot_test.dart @@ -0,0 +1,75 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:jj_mkvmaker/app/i18n_controller.dart'; +import 'package:jj_mkvmaker/ui/explorer/marks_bar.dart' show PatternDialog; +import 'package:jj_mkvmaker/ui/schedule_editor.dart'; +import 'package:jj_mkvmaker/ui/theme.dart'; +import 'package:path/path.dart' as p; + +/// W JjField (관리자 10/24): 글 칸을 모을 때 캡처가 없던 곳을 찍는다 - 패턴으로 고르기 창 · 동기화 일정 창 (cron 글 칸의 도움 글). +/// 412 · 960 × ko · ja. 모으기 전 판과 뒤 판을 같은 이름으로 찍어 견준다 (JJ_SHOT_DIR) +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + // 글 칸 커서가 깜빡여 캡처가 실행마다 달라지지 않게 (시험 쪽에서만) + EditableText.debugDeterministicCursor = true; + final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_field'); + + for (final (w, h) in [(412, 900), (960, 700)]) { + for (final lang in ['ko', 'ja']) { + testWidgets('글 칸 자리 $lang ${w}x$h', (t) async { + t.view.physicalSize = Size(w.toDouble(), h.toDouble()); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + await t.runAsync(() => i18n.apply(lang, save: false)); + addTearDown(() => i18n.apply('ko', save: false)); + final key = GlobalKey(); + final nav = GlobalKey(); + await t.pumpWidget(RepaintBoundary( + key: key, + child: MaterialApp( + theme: buildTheme(), debugShowCheckedModeBanner: false, navigatorKey: nav, home: const Scaffold(body: SizedBox.expand())), + )); + + Future settle() async { + for (var i = 0; i < 6; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 40))); + await t.pump(const Duration(milliseconds: 100)); + } + } + + Future shot(String name) async { + expect(t.takeException(), isNull, reason: '$name 넘침'); + final b = key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final img = await t.runAsync(() => b.toImage()); + final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png)); + Directory(out).createSync(recursive: true); + File(p.join(out, 'field_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List()); + } + + // 1. 패턴으로 고르기 + final pattern = showDialog(context: nav.currentContext!, builder: (_) => const PatternDialog()); + await settle(); + await shot('pattern'); + Navigator.of(nav.currentContext!).pop(); + await settle(); + await pattern; + + // 2. 동기화 일정 (시간 지정 - cron 글 칸과 도움 글이 보이게) + final schedule = editSchedule(nav.currentContext!, const ['0 9-17 * * 1-5']); + await settle(); + final scroll = find.byType(Scrollable).last; + await t.scrollUntilVisible(find.byIcon(Icons.check), 200, scrollable: scroll); + await settle(); + await shot('schedule'); + Navigator.of(nav.currentContext!).pop(); + await settle(); + await schedule; + }); + } + } +} diff --git a/integration_test/support/temp_dir.dart b/integration_test/support/temp_dir.dart index af86d33..7a02637 100644 --- a/integration_test/support/temp_dir.dart +++ b/integration_test/support/temp_dir.dart @@ -62,8 +62,8 @@ Directory testFixedTempDir(String name, {String? base}) { return dir; } -/// 캡처용: [dir] 아래 파일 · 폴더의 바뀐 시각을 [at] 으로 고정 (목록의 날짜 칸이 실행마다 같게). -/// Dart 는 폴더 시각을 못 바꿔 폴더는 PowerShell 로 (Windows 만). 긴 경로는 \\?\ 로 +/// 캡처용: [dir] 와 그 아래 파일 · 폴더의 시각 (바뀐 · 만든 · 열어 본) 을 [at] 으로 고정 (목록 · 속성 창의 날짜가 실행마다 같게). +/// Dart 는 파일의 바뀐 시각만 바꿀 수 있어 나머지는 PowerShell 로 (Windows 만). 깊은 경로의 파일 바뀐 시각은 Dart 가 맡는다. 긴 경로는 \\?\ 로 void pinTestTimes(Directory dir, {DateTime? at}) { final when = at ?? DateTime(2026, 10, 22, 9); final path = dir.absolute.path; @@ -74,9 +74,11 @@ void pinTestTimes(Directory dir, {DateTime? at}) { if (!Platform.isWindows) return; String two(int v) => v.toString().padLeft(2, '0'); final stamp = '${when.year}-${two(when.month)}-${two(when.day)} ${two(when.hour)}:${two(when.minute)}'; + // 속성 창은 만든 때 · 열어 본 때도 보여 세 시각 모두 (파일 · 폴더, 맨 위 폴더 포함) Process.runSync('powershell', [ '-NoProfile', '-Command', - "Get-ChildItem -LiteralPath '$path' -Recurse -Directory -Force | ForEach-Object { \$_.LastWriteTime = [datetime]'$stamp' }", + "\$t = [datetime]'$stamp'; @(Get-Item -LiteralPath '$path' -Force) + @(Get-ChildItem -LiteralPath '$path' -Recurse -Force) | " + 'ForEach-Object { \$_.CreationTime = \$t; \$_.LastWriteTime = \$t; \$_.LastAccessTime = \$t }', ]); } diff --git a/integration_test/windows_b6_shot_test.dart b/integration_test/windows_b6_shot_test.dart index 56363f3..f333866 100644 --- a/integration_test/windows_b6_shot_test.dart +++ b/integration_test/windows_b6_shot_test.dart @@ -39,7 +39,8 @@ void main() { addTearDown(t.view.reset); await t.runAsync(() => i18n.apply(lang, save: false)); addTearDown(() => i18n.apply('ko', save: false)); - final tmp = testTempDir('jj_b6shot_'); + // 관리자 지시 (10/24): 폴더 이름 · 시각을 실행마다 같게 (캡처가 흔들리지 않게 - 시험 쪽에서만) + final tmp = testFixedTempDir('jj_b6shot_fixed'); // 시험 파일 File(p.join(tmp.path, 'Show', 'ep01.mkv')) ..createSync(recursive: true) @@ -50,6 +51,7 @@ void main() { File(p.join(tmp.path, 'docs', 'note.txt')) ..createSync(recursive: true) ..writeAsStringSync('메모 note'); + pinTestTimes(tmp); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final navKey = GlobalKey(); final shotKey = GlobalKey(); diff --git a/integration_test/windows_p3_shot_test.dart b/integration_test/windows_p3_shot_test.dart index e9b18ad..8c891db 100644 --- a/integration_test/windows_p3_shot_test.dart +++ b/integration_test/windows_p3_shot_test.dart @@ -26,13 +26,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_p3shot_'); + // 관리자 지시 (10/24): 폴더 이름 · 시각을 실행마다 같게 (캡처가 흔들리지 않게 - 시험 쪽에서만) + final tmp = testFixedTempDir('jj_p3shot_fixed'); final file = File(p.join(tmp.path, '영상 폴더', 'ep01 긴 이름의 동영상 파일.mkv')) ..createSync(recursive: true) ..writeAsBytesSync(List.generate(5000, (i) => i % 256)); File(p.join(tmp.path, '영상 폴더', '속', 'a.txt')) ..createSync(recursive: true) ..writeAsStringSync('hello'); + pinTestTimes(tmp); final navKey = GlobalKey(); final shotKey = GlobalKey(); await t.pumpWidget(RepaintBoundary( diff --git a/integration_test/windows_xplore_tools_shot_test.dart b/integration_test/windows_xplore_tools_shot_test.dart index 82e9497..a5fa200 100644 --- a/integration_test/windows_xplore_tools_shot_test.dart +++ b/integration_test/windows_xplore_tools_shot_test.dart @@ -34,7 +34,8 @@ void main() { addTearDown(t.view.reset); await t.runAsync(() => i18n.apply(lang, save: false)); addTearDown(() => i18n.apply('ko', save: false)); - final tmp = testTempDir('jj_xptshot_'); + // 관리자 지시 (10/24): 폴더 이름 · 시각을 실행마다 같게 (캡처가 흔들리지 않게 - 시험 쪽에서만) + final tmp = testFixedTempDir('jj_xptshot_fixed'); // 그림 (색이 다른 작은 그림) · 동영상 이름 · 글 (CP949) final pics = []; for (var i = 0; i < 7; i++) { @@ -52,6 +53,7 @@ void main() { File(p.join(d.path, 'big.bin')).writeAsBytesSync(List.filled(size, 0)); } File(p.join(tmp.path, 'disk', 'loose.bin')).writeAsBytesSync(List.filled(80000, 0)); + pinTestTimes(tmp); final shotKey = GlobalKey(); final nav = GlobalKey(); diff --git a/lib/ui/ai_image_page.dart b/lib/ui/ai_image_page.dart index 6da7b48..3b778ba 100644 --- a/lib/ui/ai_image_page.dart +++ b/lib/ui/ai_image_page.dart @@ -19,6 +19,7 @@ import 'browser_page.dart' show browserRouteObserver; import 'reader_page.dart'; import 'theme.dart'; import 'notice_band.dart'; +import 'jj_field.dart'; /// 123: AI 그림 - 글 → 그림 · 그림 → 그림. 기기 안 (SD1.5 + LCM) 이 기본이고, 사용자가 추가한 서버 · 서비스도 같은 화면에서. /// 결과는 새 파일로 저장하고 (원본을 덮지 않음), 옆에 프롬프트 · 시드 · 모델을 json 으로 남긴다. @@ -259,11 +260,7 @@ class _AiImagePageState extends State with RouteAware { // 141: 이름이 잘리지 않게 짧게 · 넉넉히 (설명은 아래 줄) SizedBox( width: 190, - child: TextField( - controller: _seed, - keyboardType: TextInputType.number, - decoration: InputDecoration(labelText: tr('시드'), helperText: tr('-1 은 아무렇게나'), isDense: true), - ), + child: JjField(controller: _seed, keyboardType: TextInputType.number, label: tr('시드'), helper: tr('-1 은 아무렇게나'), isDense: true), ), ]), const SizedBox(height: 8), diff --git a/lib/ui/ai_settings.dart b/lib/ui/ai_settings.dart index ebaf69d..1e48b2c 100644 --- a/lib/ui/ai_settings.dart +++ b/lib/ui/ai_settings.dart @@ -20,6 +20,7 @@ import 'theme.dart'; import '../app/ai_upscale.dart' show deviceMemTotalMb, upscaleMemoryLines; import 'notice_band.dart'; import 'secret_field.dart'; +import 'jj_field.dart'; /// 기기 안에서 그리는 데 아직 받지 않은 파일 (Windows 는 실행 파일까지, TAESD 포함) - AI 그림 화면 · 받기 화면이 같은 값을 보인다 (139) List aiMissingFiles(AiStore store) => [ @@ -488,34 +489,28 @@ Future editAiService(BuildContext context, AppController c, {AiService? ol child: Column(mainAxisSize: MainAxisSize.min, children: [ NoticeBand( icon: Icons.public, color: JjColors.warning, margin: EdgeInsets.zero, title: tr('이 서비스로 그리면 그림 · 프롬프트가 기기 밖 (이 주소) 으로 나갑니다.')), - TextField(controller: name, decoration: InputDecoration(labelText: tr('이름'))), + JjField(controller: name, label: tr('이름')), DropdownButtonFormField( initialValue: kind, decoration: InputDecoration(labelText: tr('방식')), items: [for (final k in AiService.kinds) DropdownMenuItem(value: k, child: Text(_kindName(k)))], onChanged: (v) => set(() => kind = v ?? 'a1111'), ), - TextField( + // 관리자 지시 (10/21, 15 찾음 F2): 412 ja 에서 한 줄로 잘려 어떤 옵션으로 켜야 하는지가 안 보였다 - 도움 글은 3줄까지 (JjField) + JjField( controller: url, keyboardType: TextInputType.url, - decoration: InputDecoration( - labelText: tr('주소'), - helperText: switch (kind) { - 'comfy' => tr('예: http://100.x.x.x:8188 (ComfyUI)'), - 'openai' => tr('예: https://api.openai.com (경로 /v1/images/generations 를 붙여 부름)'), - _ => tr('예: http://100.x.x.x:7860 (--api 로 켠 Automatic1111 · Forge)'), - }, - // 관리자 지시 (10/21, 15 찾음 F2): 412 ja 에서 한 줄로 잘려 어떤 옵션으로 켜야 하는지가 안 보였다 - 다른 창의 도움 글처럼 3줄까지 - helperMaxLines: 3, - ), + label: tr('주소'), + helper: switch (kind) { + 'comfy' => tr('예: http://100.x.x.x:8188 (ComfyUI)'), + 'openai' => tr('예: https://api.openai.com (경로 /v1/images/generations 를 붙여 부름)'), + _ => tr('예: http://100.x.x.x:7860 (--api 로 켠 Automatic1111 · Forge)'), + }, ), - TextField( + JjField( controller: model, - decoration: InputDecoration( - labelText: tr('모델 (선택)'), - helperText: kind == 'comfy' ? tr('체크포인트 파일 이름 (예: sd_xl_turbo_1.0_fp16.safetensors)') : null, - helperMaxLines: 3, - ), + label: tr('모델 (선택)'), + helper: kind == 'comfy' ? tr('체크포인트 파일 이름 (예: sd_xl_turbo_1.0_fp16.safetensors)') : null, ), // 관리자 지시 (10/21, 15 찾음 F1): 칸 이름은 짧게, 괄호 설명은 칸 아래 도움 글로 - 412 폭 en · ja 에서 👁 자리 때문에 이름이 "…" 로 잘렸다 SecretField( diff --git a/lib/ui/batch_rename_dialog.dart b/lib/ui/batch_rename_dialog.dart index e0451e3..4787b8c 100644 --- a/lib/ui/batch_rename_dialog.dart +++ b/lib/ui/batch_rename_dialog.dart @@ -7,6 +7,7 @@ import '../core/photo_date.dart'; import '../core/vfs.dart'; import '../l10n/tr.dart'; import 'theme.dart'; +import 'jj_field.dart'; /// 78: 일괄 이름 바꾸기 창 - 규칙을 넣으면 바로 미리 보기 (원래 → 새 이름, 문제는 빨갛게), [바꾸기] 뒤 결과. /// 돌려주는 값: 바꾼 것 (원래 경로, 새 경로) - 탐색기가 [되돌리기] 에 쓴다. 취소하면 null. @@ -188,16 +189,14 @@ class _BatchRenameDialogState extends State<_BatchRenameDialog> { // 이름 틀 + [N] [C] 넣기 Row(children: [ Expanded( - child: TextField( + // 폭 412 에서 긴 이름이 잘려 (일본어) 이름은 짧게, 설명은 아래 줄에 (넣는 법이 길어 4줄까지) + child: JjField( controller: _template, enabled: !_running, - decoration: InputDecoration( - // 폭 412 에서 긴 이름이 잘려 (일본어) 이름은 짧게, 설명은 아래 줄에 - labelText: tr('이름 틀'), - helperText: tr('[N] 원래 이름 · [N2-5] 그 2~5 번째 글자 · [C] 번호 · [D] 날짜 · [T] 시각 · ♪ 음악 태그 · 확장자는 그대로 예: Show S01E[C]'), - helperMaxLines: 4, - isDense: true, - ), + label: tr('이름 틀'), + helper: tr('[N] 원래 이름 · [N2-5] 그 2~5 번째 글자 · [C] 번호 · [D] 날짜 · [T] 시각 · ♪ 음악 태그 · 확장자는 그대로 예: Show S01E[C]'), + helperLines: 4, + isDense: true, onChanged: (_) => setState(() {}), ), ), diff --git a/lib/ui/explorer/marks_bar.dart b/lib/ui/explorer/marks_bar.dart index 0ba960f..f22ef59 100644 --- a/lib/ui/explorer/marks_bar.dart +++ b/lib/ui/explorer/marks_bar.dart @@ -9,6 +9,7 @@ import '../adaptive_row.dart'; import '../theme.dart'; import 'explorer_pane.dart'; import 'explorer_select.dart'; +import '../jj_field.dart'; /// 약속 (E5d-4): 선택 모드의 아래 막대 - 표시한 수 · 합계 크기 (읽어 둔 목록 기준, 폴더 크기는 빼고) · /// [고르기 도구 (반전 · 패턴 · 같은 확장자)] · [모두 선택] · [표시 지우기] · [선택 끝]. 고르는 일은 [ExplorerSelect]. @@ -127,10 +128,10 @@ class PatternDialogState extends State { @override Widget build(BuildContext context) => AlertDialog( title: Text(tr('패턴으로 고르기')), - content: TextField( + content: JjField( controller: _ctl, autofocus: true, - decoration: InputDecoration(helperText: tr('* 는 아무 글자, ? 는 한 글자. 여러 개는 ; 로 (예: *.srt; *.ass)')), + helper: tr('* 는 아무 글자, ? 는 한 글자. 여러 개는 ; 로 (예: *.srt; *.ass)'), onSubmitted: (v) => Navigator.pop(context, v), ), actions: [ diff --git a/lib/ui/file_tools.dart b/lib/ui/file_tools.dart index 996d8eb..b4fcbf8 100644 --- a/lib/ui/file_tools.dart +++ b/lib/ui/file_tools.dart @@ -10,6 +10,7 @@ import '../core/file_attributes.dart'; import '../core/file_ops.dart' show formatSize; import '../l10n/tr.dart'; import 'theme.dart'; +import 'jj_field.dart'; /// X-plore 비교 P3: 해시 · 16진 보기 · 폴더 속성 (읽기만 - 파일을 바꾸지 않는다) @@ -87,9 +88,11 @@ Future showFileHash(BuildContext context, String path) async { line('MD5', result!.md5), line('SHA-256', result!.sha256), const SizedBox(height: 10), - TextField( + JjField( controller: compare, - decoration: InputDecoration(labelText: tr('비교할 해시'), helperText: tr('받은 곳에서 알려 준 해시를 붙여넣으세요'), helperMaxLines: 3, isDense: true), + label: tr('비교할 해시'), + helper: tr('받은 곳에서 알려 준 해시를 붙여넣으세요'), + isDense: true, onChanged: (_) => set(() {}), ), if (match != null) diff --git a/lib/ui/ftp_settings.dart b/lib/ui/ftp_settings.dart index 7c904a7..1a92ec3 100644 --- a/lib/ui/ftp_settings.dart +++ b/lib/ui/ftp_settings.dart @@ -11,6 +11,7 @@ import 'theme.dart'; import 'webdav_settings.dart' show davServerUsers; import 'notice_band.dart'; import 'secret_field.dart'; +import 'jj_field.dart'; /// l: 환경 설정 > 파일 탐색기 > FTP · FTPS: 서버 목록 (추가 · 고치기 · 지우기 · 연결 확인). SFTP 와 같은 틀. /// 서버마다 파일 탐색기 · Rsync 화면 위쪽 (WebDAV 옆) 에 탭이 생긴다. 평문 FTP 는 목록 · 탭 · 창에 "암호화 안 됨" 을 늘 보인다 @@ -202,25 +203,21 @@ Future editFtpServer(BuildContext context, AppController c, {FtpServ content: SizedBox( width: 520, child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - TextField(controller: name, decoration: InputDecoration(labelText: tr('이름'), helperText: tr('탭에 보입니다 · 비우면 주소'), helperMaxLines: 3)), + JjField(controller: name, label: tr('이름'), helper: tr('탭에 보입니다 · 비우면 주소')), Row(children: [ Expanded( - child: TextField( + child: JjField( controller: host, keyboardType: TextInputType.url, - decoration: InputDecoration(labelText: tr('서버 주소'), helperText: tr('예: nas.local · 192.168.0.10 · 100.x.y.z (Tailscale)'), helperMaxLines: 3), + label: tr('서버 주소'), + helper: tr('예: nas.local · 192.168.0.10 · 100.x.y.z (Tailscale)'), onChanged: (_) => set(() {}), ), ), const SizedBox(width: 8), SizedBox( width: 80, - child: TextField( - controller: port, - keyboardType: TextInputType.number, - decoration: InputDecoration(labelText: tr('포트')), - onChanged: (_) => set(() {}), - ), + child: JjField(controller: port, keyboardType: TextInputType.number, label: tr('포트'), onChanged: (_) => set(() {})), ), ]), const SizedBox(height: 8), @@ -245,12 +242,9 @@ Future editFtpServer(BuildContext context, AppController c, {FtpServ ), if (security == 'none') NoticeBand(icon: Icons.lock_open, color: JjColors.danger, margin: EdgeInsets.zero, title: tr('평문 FTP: 암호화 안 됨'), body: tr('평문 FTP 는 아이디 · 비밀번호 · 파일이 암호화되지 않은 채 오갑니다. 같은 망의 다른 사람이 볼 수 있습니다. 서버가 FTPS 를 못 할 때만, 믿을 수 있는 집 안 망에서만 쓰세요.')), - TextField(controller: user, decoration: InputDecoration(labelText: tr('아이디 (비우면 익명)'))), + JjField(controller: user, label: tr('아이디 (비우면 익명)')), SecretField(controller: pass, label: tr('비밀번호')), - TextField( - controller: root, - decoration: InputDecoration(labelText: tr('시작 폴더'), helperText: tr('비우면 로그인한 곳 · 예: /volume1/video · 탭의 맨 위가 됩니다'), helperMaxLines: 3), - ), + JjField(controller: root, label: tr('시작 폴더'), helper: tr('비우면 로그인한 곳 · 예: /volume1/video · 탭의 맨 위가 됩니다')), if (security != 'none') ...[ const SizedBox(height: 6), Text( diff --git a/lib/ui/jj_field.dart b/lib/ui/jj_field.dart new file mode 100644 index 0000000..58942d1 --- /dev/null +++ b/lib/ui/jj_field.dart @@ -0,0 +1,87 @@ +/// 약속 (W 공용 위젯, 설계 7-2 JjField - 글 칸): 칸 이름은 짧게 · 설명은 칸 아래 도움 글 (3줄까지, 넘으면 "…") · 오류 글은 칸 아래. +/// 입력: 글 칸 · 칸 이름 · 도움 글 · 오류 글 · 키보드 종류 · 바뀜 / 넣기 알림 · 칸 끝 단추 · 여러 줄 · 테두리 상자 · 가림. +/// 출력: Material 글 칸 하나 (모양은 앱 테마를 따른다). 오류 없음. +/// 쓰는 곳: 서버 설정 (WebDAV · FTP · SFTP · SMB) · AI 서비스 · 해시 비교 · 시드 · 패턴 · 일정 · 이름 틀 · [SecretField]. 화면은 부르기만 한다 +library; + +import 'package:flutter/material.dart'; + +class JjField extends StatelessWidget { + const JjField({ + super.key, + required this.controller, + this.label, + this.helper, + this.helperLines = 3, + this.errorText, + this.keyboardType, + this.onChanged, + this.onSubmitted, + this.isDense = false, + this.enabled = true, + this.autofocus = false, + this.suffix, + this.minLines, + this.maxLines = 1, + this.style, + this.outlined = false, + this.obscureText = false, + this.plainText = false, + }); + + final TextEditingController controller; + + /// 칸 이름 (짧게 - 설명은 [helper] 로) + final String? label; + + /// 칸 아래 도움 글. [helperLines] 줄까지 (처음 3 - 412 폭 ja 에서 한 줄이면 잘렸다, F2) + final String? helper; + final int helperLines; + final String? errorText; + final TextInputType? keyboardType; + final ValueChanged? onChanged; + final ValueChanged? onSubmitted; + final bool isDense; + final bool enabled; + final bool autofocus; + + /// 칸 끝 단추 (예: 눈 단추 · 반영 단추) + final Widget? suffix; + final int? minLines; + final int? maxLines; + final TextStyle? style; + + /// 테두리 상자 (여러 줄 글 등) + final bool outlined; + + /// 가린 글 (비밀번호 - [SecretField] 가 쓴다) + final bool obscureText; + + /// 자동 고침 · 추천 끔 (비밀번호 · 키 등 - [SecretField] 는 늘 켠다) + final bool plainText; + + @override + Widget build(BuildContext context) => TextField( + controller: controller, + keyboardType: keyboardType, + obscureText: obscureText, + autocorrect: !plainText, + enableSuggestions: !plainText, + enabled: enabled, + autofocus: autofocus, + minLines: minLines, + maxLines: obscureText ? 1 : maxLines, + style: style, + onChanged: onChanged, + onSubmitted: onSubmitted, + decoration: InputDecoration( + labelText: label, + helperText: helper, + helperMaxLines: helperLines, + errorText: errorText, + isDense: isDense, + border: outlined ? const OutlineInputBorder() : null, + suffixIcon: suffix, + ), + ); +} diff --git a/lib/ui/schedule_editor.dart b/lib/ui/schedule_editor.dart index fb786a3..11e1778 100644 --- a/lib/ui/schedule_editor.dart +++ b/lib/ui/schedule_editor.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../core/cron_window.dart'; import '../l10n/tr.dart'; import 'theme.dart'; +import 'jj_field.dart'; /// 요일 이름 (0 = 일요일) List weekdayNames() => [tr('일'), tr('월'), tr('화'), tr('수'), tr('목'), tr('금'), tr('토')]; @@ -120,18 +121,16 @@ class _ScheduleDialogState extends State<_ScheduleDialog> { const SizedBox(height: 12), Text(tr('cron (분 시 일 월 요일) · 한 줄에 하나'), style: const TextStyle(fontWeight: FontWeight.w600)), const SizedBox(height: 4), - TextField( + JjField( controller: _text, minLines: 2, maxLines: 5, style: const TextStyle(fontFamily: 'monospace', fontSize: 13), - decoration: InputDecoration( - isDense: true, - border: const OutlineInputBorder(), - errorText: _error, - helperText: tr('예: 0 9-17 * * 1-5 (평일 9시~17시 59분) · 0 0-5 * * * (매일 0~5시). 분은 무시하고 1시간 단위'), - suffixIcon: IconButton(tooltip: tr('글을 격자에 반영'), icon: const Icon(Icons.check), onPressed: _applyText), - ), + isDense: true, + outlined: true, + errorText: _error, + helper: tr('예: 0 9-17 * * 1-5 (평일 9시~17시 59분) · 0 0-5 * * * (매일 0~5시). 분은 무시하고 1시간 단위'), + suffix: IconButton(tooltip: tr('글을 격자에 반영'), icon: const Icon(Icons.check), onPressed: _applyText), onSubmitted: (_) => _applyText(), ), const SizedBox(height: 8), diff --git a/lib/ui/secret_field.dart b/lib/ui/secret_field.dart index c70e452..c2046e9 100644 --- a/lib/ui/secret_field.dart +++ b/lib/ui/secret_field.dart @@ -7,6 +7,7 @@ library; import 'package:flutter/material.dart'; import '../l10n/tr.dart'; +import 'jj_field.dart'; class SecretField extends StatefulWidget { const SecretField({ @@ -46,26 +47,22 @@ class _SecretFieldState extends State { final outside = widget.hidden != null; final hidden = widget.hidden ?? _hidden; final toggle = outside ? widget.onToggle : () => setState(() => _hidden = !_hidden); - return TextField( + return JjField( controller: widget.controller, + label: widget.label, + helper: widget.helper, + errorText: widget.errorText, + isDense: widget.isDense, obscureText: hidden, - autocorrect: false, - enableSuggestions: false, + plainText: true, onChanged: widget.onChanged, - decoration: InputDecoration( - labelText: widget.label, - helperText: widget.helper, - helperMaxLines: 3, - errorText: widget.errorText, - isDense: widget.isDense, - suffixIcon: toggle == null - ? null - : IconButton( - tooltip: hidden ? tr('보이기') : tr('가리기'), - icon: Icon(hidden ? Icons.visibility_outlined : Icons.visibility_off_outlined, size: 18), - onPressed: toggle, - ), - ), + suffix: toggle == null + ? null + : IconButton( + tooltip: hidden ? tr('보이기') : tr('가리기'), + icon: Icon(hidden ? Icons.visibility_outlined : Icons.visibility_off_outlined, size: 18), + onPressed: toggle, + ), ); } } diff --git a/lib/ui/sftp_settings.dart b/lib/ui/sftp_settings.dart index 466f763..0ab6818 100644 --- a/lib/ui/sftp_settings.dart +++ b/lib/ui/sftp_settings.dart @@ -12,6 +12,7 @@ import 'setting_tile.dart'; import 'theme.dart'; import 'webdav_settings.dart' show davServerUsers; import 'secret_field.dart'; +import 'jj_field.dart'; /// 88: 환경 설정 > 파일 탐색기 > SFTP: 서버 목록 (추가 · 고치기 · 지우기 · 연결 확인). /// 서버마다 파일 탐색기 · Rsync 화면 위쪽 (WebDAV 옆) 에 탭이 생긴다. @@ -253,28 +254,24 @@ Future editSftpServer(BuildContext context, AppController c, {SftpS content: SizedBox( width: 520, child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - TextField(controller: name, decoration: InputDecoration(labelText: tr('이름'), helperText: tr('탭에 보입니다 · 비우면 주소'), helperMaxLines: 3)), + JjField(controller: name, label: tr('이름'), helper: tr('탭에 보입니다 · 비우면 주소')), Row(children: [ Expanded( - child: TextField( + child: JjField( controller: host, keyboardType: TextInputType.url, - decoration: InputDecoration(labelText: tr('서버 주소'), helperText: tr('예: nas.local · 192.168.0.10 · 100.x.y.z (Tailscale)'), helperMaxLines: 3), + label: tr('서버 주소'), + helper: tr('예: nas.local · 192.168.0.10 · 100.x.y.z (Tailscale)'), onChanged: (_) => set(() {}), ), ), const SizedBox(width: 8), SizedBox( width: 80, - child: TextField( - controller: port, - keyboardType: TextInputType.number, - decoration: InputDecoration(labelText: tr('포트')), - onChanged: (_) => set(() {}), - ), + child: JjField(controller: port, keyboardType: TextInputType.number, label: tr('포트'), onChanged: (_) => set(() {})), ), ]), - TextField(controller: user, decoration: InputDecoration(labelText: tr('아이디'))), + JjField(controller: user, label: tr('아이디')), // 관리자 지시 (10/21, 15 찾음 F1): 칸 이름은 짧게, 괄호 설명은 칸 아래 도움 글로 - 412 폭 en · ja 에서 👁 자리 때문에 이름이 "…" 로 잘렸다. // 눈 단추 하나가 비밀번호 · 개인 키 암호를 함께 보이고 가린다 SecretField( @@ -297,10 +294,7 @@ Future editSftpServer(BuildContext context, AppController c, {SftpS ]), if (key.isNotEmpty) SecretField(controller: passphrase, label: tr('개인 키 암호'), helper: tr('없으면 비웁니다'), hidden: hide), - TextField( - controller: root, - decoration: InputDecoration(labelText: tr('시작 폴더'), helperText: tr('비우면 홈 폴더 · 예: /volume1/video · 탭의 맨 위가 됩니다'), helperMaxLines: 3), - ), + JjField(controller: root, label: tr('시작 폴더'), helper: tr('비우면 홈 폴더 · 예: /volume1/video · 탭의 맨 위가 됩니다')), CheckboxListTile( contentPadding: EdgeInsets.zero, value: legacy, diff --git a/lib/ui/smb_settings.dart b/lib/ui/smb_settings.dart index acd1047..cbd83b9 100644 --- a/lib/ui/smb_settings.dart +++ b/lib/ui/smb_settings.dart @@ -11,6 +11,7 @@ import 'setting_tile.dart'; import 'theme.dart'; import 'webdav_settings.dart' show davServerUsers; import 'secret_field.dart'; +import 'jj_field.dart'; /// 88: 환경 설정 > 파일 탐색기 > SMB (Android 만): 서버 목록 (추가 · 고치기 · 지우기 · 연결 확인). /// Windows 는 운영체제가 \\서버\공유 를 연다 (경로 입력 · 네트워크 드라이브) - 사용자 결정 88-1. @@ -185,25 +186,21 @@ Future editSmbServer(BuildContext context, AppController c, {SmbServ content: SizedBox( width: 520, child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - TextField(controller: name, decoration: InputDecoration(labelText: tr('이름'), helperText: tr('탭에 보입니다 · 비우면 주소'), helperMaxLines: 3)), + JjField(controller: name, label: tr('이름'), helper: tr('탭에 보입니다 · 비우면 주소')), Row(children: [ Expanded( - child: TextField( + child: JjField( controller: host, keyboardType: TextInputType.url, - decoration: InputDecoration(labelText: tr('서버 주소'), helperText: tr('예: nas.local · 192.168.0.10 · 100.x.y.z (Tailscale)'), helperMaxLines: 3), + label: tr('서버 주소'), + helper: tr('예: nas.local · 192.168.0.10 · 100.x.y.z (Tailscale)'), onChanged: (_) => set(() {}), ), ), const SizedBox(width: 8), SizedBox( width: 80, - child: TextField( - controller: port, - keyboardType: TextInputType.number, - decoration: InputDecoration(labelText: tr('포트')), - onChanged: (_) => set(() {}), - ), + child: JjField(controller: port, keyboardType: TextInputType.number, label: tr('포트'), onChanged: (_) => set(() {})), ), ]), // X-plore j: 같은 망 (Wi-Fi · 유선) 에서 SMB 서버 찾기 - 누를 때만 훑는다 @@ -222,15 +219,11 @@ Future editSmbServer(BuildContext context, AppController c, {SmbServ }, ), ), - TextField( - controller: share, - decoration: InputDecoration(labelText: tr('공유 이름'), helperText: tr('예: video · home (\\\\서버\\공유 의 공유)'), helperMaxLines: 3), - onChanged: (_) => set(() {}), - ), - TextField(controller: root, decoration: InputDecoration(labelText: tr('시작 폴더'), helperText: tr('공유 안의 폴더 · 비우면 공유의 맨 위'), helperMaxLines: 3)), - TextField(controller: user, decoration: InputDecoration(labelText: tr('아이디 (비우면 게스트)'))), + JjField(controller: share, label: tr('공유 이름'), helper: tr('예: video · home (\\\\서버\\공유 의 공유)'), onChanged: (_) => set(() {})), + JjField(controller: root, label: tr('시작 폴더'), helper: tr('공유 안의 폴더 · 비우면 공유의 맨 위')), + JjField(controller: user, label: tr('아이디 (비우면 게스트)')), SecretField(controller: pass, label: tr('비밀번호')), - TextField(controller: domain, decoration: InputDecoration(labelText: tr('도메인 · 작업 그룹'), helperText: tr('보통 비워 둡니다'), helperMaxLines: 3)), + JjField(controller: domain, label: tr('도메인 · 작업 그룹'), helper: tr('보통 비워 둡니다')), const SizedBox(height: 8), if (testing) const LinearProgressIndicator(), if (result != null) diff --git a/lib/ui/webdav_settings.dart b/lib/ui/webdav_settings.dart index c553601..d3fee21 100644 --- a/lib/ui/webdav_settings.dart +++ b/lib/ui/webdav_settings.dart @@ -11,6 +11,7 @@ import 'file_error.dart'; import 'theme.dart'; import 'setting_tile.dart'; import 'secret_field.dart'; +import 'jj_field.dart'; /// 환경 설정 > 파일 탐색기 > WebDAV: 서버 목록 (추가 · 고치기 · 지우기 · 연결 확인). /// 서버마다 파일 탐색기 · Rsync 화면 위쪽 "SD 카드" 옆에 탭이 생긴다. @@ -158,15 +159,12 @@ Future editDavServer(BuildContext context, AppController c, {DavServ content: SizedBox( width: 520, child: Column(mainAxisSize: MainAxisSize.min, children: [ - TextField(controller: name, decoration: InputDecoration(labelText: tr('이름'), helperText: tr('탭에 보입니다 · 비우면 주소'), helperMaxLines: 3)), - TextField( + JjField(controller: name, label: tr('이름'), helper: tr('탭에 보입니다 · 비우면 주소')), + JjField( controller: url, keyboardType: TextInputType.url, - decoration: InputDecoration( - labelText: tr('주소'), - helperText: tr('예: https://nas.local:5006/home · https://cloud.example.com/remote.php/dav/files/아이디'), - helperMaxLines: 3, - ), + label: tr('주소'), + helper: tr('예: https://nas.local:5006/home · https://cloud.example.com/remote.php/dav/files/아이디'), onChanged: (_) => set(() {}), ), // 56: http:// 는 아이디 · 비밀번호와 파일이 암호화되지 않고 그대로 간다 @@ -184,7 +182,7 @@ Future editDavServer(BuildContext context, AppController c, {DavServ ), ]), ), - TextField(controller: user, decoration: InputDecoration(labelText: tr('아이디'))), + JjField(controller: user, label: tr('아이디')), SecretField(controller: pass, label: tr('비밀번호')), CheckboxListTile( contentPadding: EdgeInsets.zero, diff --git a/test/jj_field_contract_test.dart b/test/jj_field_contract_test.dart new file mode 100644 index 0000000..184dcfc --- /dev/null +++ b/test/jj_field_contract_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/ui/jj_field.dart'; + +/// W 공용 위젯 약속 (JjField): 도움 글은 칸 아래 3줄까지 (넘으면 거기서 끊김) · 오류 글은 칸 아래 · 좁은 폭 · 큰 글자에서 넘치지 않음 · +/// 처음은 일반 글 칸 그대로 (자동 고침 · 추천 켬, 한 줄). 위젯 하나에 대해 한 번만 둔다 (설계 7-2) +void main() { + const long = 'サーバーのアドレスを入力します。例: http://100.x.x.x:7860 (--api で起動した Automatic1111 · Forge) ' + 'ポートまで含めて入力してください。空にするとこの項目は使いません。'; + + Future show(WidgetTester t, Widget child, {double width = 412, double scale = 1}) async { + t.view.physicalSize = Size(width, 900); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + await t.pumpWidget(MaterialApp( + home: MediaQuery( + data: MediaQueryData(size: Size(width, 900), textScaler: TextScaler.linear(scale)), + child: Scaffold(body: Column(children: [child])), + ), + )); + } + + testWidgets('처음 값: 일반 글 칸 (자동 고침 · 추천 켬 · 한 줄 · 가리지 않음)', (t) async { + await show(t, JjField(controller: TextEditingController(), label: '이름')); + final f = t.widget(find.byType(TextField)); + expect([f.autocorrect, f.enableSuggestions, f.obscureText, f.maxLines], [true, true, false, 1]); + expect(f.decoration!.helperMaxLines, 3); + }); + + testWidgets('도움 글이 길면 3줄까지 보이고 [helperLines] 로 늘릴 수 있음', (t) async { + for (final lines in [3, 4]) { + await show(t, JjField(controller: TextEditingController(), label: '주소', helper: long, helperLines: lines), width: 300); + final helper = find.text(long); + expect(helper, findsOneWidget); + expect(t.widget(helper).maxLines, lines); + expect(t.takeException(), isNull); + } + }); + + testWidgets('오류 글 · 칸 끝 단추가 보이고, 넣기 (Enter) 를 알림', (t) async { + String? submitted; + final c = TextEditingController(text: '0 9 * * *'); + await show( + t, + JjField( + controller: c, + errorText: '알 수 없는 줄', + outlined: true, + minLines: 2, + maxLines: 5, + suffix: IconButton(icon: const Icon(Icons.check), onPressed: () {}), + onSubmitted: (v) => submitted = v, + )); + expect(find.text('알 수 없는 줄'), findsOneWidget); + expect(find.byIcon(Icons.check), findsOneWidget); + await t.showKeyboard(find.byType(TextField)); + await t.testTextInput.receiveAction(TextInputAction.done); + expect(submitted, '0 9 * * *'); + }); + + for (final width in [300.0, 412.0]) { + for (final scale in [1.0, 2.0]) { + testWidgets('폭 $width · 배율 $scale: 긴 이름 · 도움 글도 넘치지 않음', (t) async { + await show(t, JjField(controller: TextEditingController(), label: 'Server address (host name or IP)', helper: long), width: width, scale: scale); + expect(t.takeException(), isNull); + }); + } + } +}