W JjField 2: 도움 글 글 칸을 JjField 하나로 (SecretField 도 그 위) + 캡처 흔들림 b6 · p3 · xptools 고정

- 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 <noreply@anthropic.com>
modular
RyuJongJin 2026-10-24 22:53:11 +09:00
parent 36069a8707
commit 2d9968929d
18 changed files with 328 additions and 119 deletions

View File

@ -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<NavigatorState>();
await t.pumpWidget(RepaintBoundary(
key: key,
child: MaterialApp(
theme: buildTheme(), debugShowCheckedModeBanner: false, navigatorKey: nav, home: const Scaffold(body: SizedBox.expand())),
));
Future<void> settle() async {
for (var i = 0; i < 6; i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 40)));
await t.pump(const Duration(milliseconds: 100));
}
}
Future<void> 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<String>(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;
});
}
}
}

View File

@ -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 }',
]);
}

View File

@ -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<NavigatorState>();
final shotKey = GlobalKey();

View File

@ -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<NavigatorState>();
final shotKey = GlobalKey();
await t.pumpWidget(RepaintBoundary(

View File

@ -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 = <String>[];
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<NavigatorState>();

View File

@ -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<AiImagePage> 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),

View File

@ -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<AiFile> aiMissingFiles(AiStore store) => [
@ -488,34 +489,28 @@ Future<void> 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<String>(
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(

View File

@ -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(() {}),
),
),

View File

@ -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<PatternDialog> {
@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: [

View File

@ -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<void> 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)

View File

@ -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<FtpServer?> 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<FtpServer?> 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(

87
lib/ui/jj_field.dart Normal file
View File

@ -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<String>? onChanged;
final ValueChanged<String>? 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,
),
);
}

View File

@ -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<String> 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),

View File

@ -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<SecretField> {
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,
),
);
}
}

View File

@ -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<SftpServer?> 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<SftpServer?> 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,

View File

@ -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<SmbServer?> 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<SmbServer?> 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)

View File

@ -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<DavServer?> 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<DavServer?> 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,

View File

@ -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<void> 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<TextField>(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<Text>(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);
});
}
}
}