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
parent
36069a8707
commit
2d9968929d
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }',
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(() {}),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue