fix(flutter): 쓰기 · 검색 입력칸에 테마의 둥근 테두리가 그려지던 것을 고치고 칸 줄을 보드에 맞춘다

InputDecoration.collapsed 는 enabledBorder · focusedBorder 를 비워 두어 TextField 가
앱 테마의 둥근 테두리로 채운다. 그래서 받는 사람 · 제목 · 본문 · 검색 칸에 글자를
감싼 테두리가 그려지고 초점이면 칸 줄의 아래 선과 청록 테두리가 겹쳤다
(11-12 배포 뒤 사용자 보고). 여섯 테두리를 모두 없앤 borderlessInputDecoration 으로
바꾸고, lib 에 InputDecoration.collapsed 가 다시 들어오면 시험이 실패하게 한다.

함께 칸 줄을 보드 App1Compose 에 맞췄다: 칸 이름 15 · 간격 6 · 오른쪽 여백 4,
칩이 여러 줄이면 칸 이름과 [참조 펼치기]를 첫 칩 줄에, 안내 글 색 · 칩 ✕ 색,
본문 칸 최소 높이(원본 메일이 있으면 150 · 없으면 서명이 있어도 240).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-12 08:10:34 +09:00
parent 94ec659b78
commit bd916ccbed
9 changed files with 562 additions and 29 deletions
@@ -0,0 +1,131 @@
import 'dart:io';
import 'package:arasystem_app/core/theme/app_theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../support/input_borders.dart';
/// 테두리 없는 입력칸 꾸밈 — 칸 줄이 아래 선 · 초점을 따로 그리는 칸(쓰기 화면 · 검색 막대)이 앱 테마의 둥근 테두리를 물려받지 않는다
void main() {
final ThemeData theme = AppTheme.light();
test(
'앱 테마를 입혀도 여섯 테두리가 모두 없고 바탕 · 안쪽 여백도 없다 — collapsed 는 테마의 둥근 테두리를 물려받는다',
() {
final InputDecoration bare = borderlessInputDecoration(
hintText: '제목을 입력해 주세요',
).applyDefaults(theme.inputDecorationTheme);
for (final InputBorder? border in <InputBorder?>[
bare.border,
bare.enabledBorder,
bare.focusedBorder,
bare.disabledBorder,
bare.errorBorder,
bare.focusedErrorBorder,
]) {
expect(border, InputBorder.none);
}
expect(bare.filled, isFalse);
expect(bare.contentPadding, EdgeInsets.zero);
expect(bare.isCollapsed, isTrue);
expect(bare.hintText, '제목을 입력해 주세요');
// 까닭 — collapsed 는 border 만 없애고 비워 둔 칸을 TextField 가 테마의 둥근 테두리로 채운다
final InputDecoration collapsed = const InputDecoration.collapsed(
hintText: '제목을 입력해 주세요',
).applyDefaults(theme.inputDecorationTheme);
expect(collapsed.border, InputBorder.none);
expect(collapsed.enabledBorder, isA<OutlineInputBorder>());
expect(collapsed.focusedBorder, isA<OutlineInputBorder>());
},
);
/// 앱 테마로 칸 하나를 그린다 — [enabled] 가 거짓이면 꺼진 칸
Future<FocusNode> pumpField(
WidgetTester tester,
InputDecoration decoration, {
bool enabled = true,
}) async {
final FocusNode focusNode = FocusNode();
addTearDown(focusNode.dispose);
await tester.pumpWidget(
MaterialApp(
theme: theme,
home: Scaffold(
body: TextField(
key: const ValueKey<String>('field'),
focusNode: focusNode,
enabled: enabled,
decoration: decoration,
),
),
),
);
return focusNode;
}
final Finder field = find.byKey(const ValueKey<String>('field'));
testWidgets('앱 테마로 그린 칸 — 평소 · 초점 · 꺼짐 어느 때나 InputDecorator 가 받는 테두리가 없다', (
WidgetTester tester,
) async {
final FocusNode focusNode = await pumpField(
tester,
borderlessInputDecoration(hintText: '제목'),
);
InputDecorator decorator = expectBorderlessInput(
tester,
field,
reason: '평소',
);
expect(decorator.isFocused, isFalse);
expect(decorator.decoration.enabled, isTrue);
focusNode.requestFocus();
await tester.pump();
decorator = expectBorderlessInput(tester, field, reason: '초점');
expect(decorator.isFocused, isTrue);
await pumpField(tester, borderlessInputDecoration(), enabled: false);
decorator = expectBorderlessInput(tester, field, reason: '꺼짐');
expect(decorator.decoration.enabled, isFalse);
});
testWidgets('견줌 — collapsed 로 그린 칸은 평소에 회색, 초점에 청록 둥근 테두리를 그린다(이 시험이 잡는 것)', (
WidgetTester tester,
) async {
final FocusNode focusNode = await pumpField(
tester,
const InputDecoration.collapsed(hintText: '제목'),
);
InputDecorator decorator() => tester.widget<InputDecorator>(
find.descendant(of: field, matching: find.byType(InputDecorator)),
);
expect(isDrawnInputBorder(shownInputBorder(decorator())), isTrue);
focusNode.requestFocus();
await tester.pump();
final InputBorder? focused = shownInputBorder(decorator());
expect(isDrawnInputBorder(focused), isTrue);
expect(focused!.borderSide.color, theme.colorScheme.primary);
});
test(
'lib 의 입력칸은 InputDecoration.collapsed 를 쓰지 않는다 — 테두리 없는 칸은 borderlessInputDecoration',
() {
final List<String> found = <String>[];
for (final FileSystemEntity entity in Directory(
'lib',
).listSync(recursive: true)) {
if (entity is! File || !entity.path.endsWith('.dart')) continue;
final List<String> lines = entity.readAsLinesSync();
for (int index = 0; index < lines.length; index++) {
if (lines[index].contains('InputDecoration.collapsed(')) {
found.add('${entity.path}:${index + 1}');
}
}
}
expect(found, isEmpty);
},
);
}
@@ -28,6 +28,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import '../../support/fakes.dart';
import '../../support/input_borders.dart';
import '../../support/login_fakes.dart';
import '../../support/mail_fakes.dart';
import '../../support/mail_harness.dart';
@@ -187,6 +188,24 @@ void main() {
expect(h.mail.searchQueries, isEmpty);
});
testWidgets(
'검색 칸은 테두리를 그리지 않는다 — 초점이 있을 때 · 놓은 뒤(앱 테마의 둥근 테두리를 물려받지 않는다)',
(WidgetTester tester) async {
await openSearch(tester);
expect(
expectBorderlessInput(tester, field(), reason: '초점').isFocused,
isTrue,
);
FocusManager.instance.primaryFocus?.unfocus();
await tester.pumpAndSettle();
expect(
expectBorderlessInput(tester, field(), reason: '평소').isFocused,
isFalse,
);
},
);
testWidgets(
'[검색] → 뼈대("찾는 중") → 결과 — 개수 줄 · 제목 · 미리 보기의 맞은 말이 노랑, 자판을 내린다',
(WidgetTester tester) async {
@@ -32,6 +32,7 @@ import 'package:flutter_test/flutter_test.dart';
import '../../support/compose_fakes.dart';
import '../../support/fakes.dart';
import '../../support/input_borders.dart';
import '../../support/login_fakes.dart';
import '../../support/mail_fakes.dart';
import '../../support/mail_harness.dart';
@@ -500,6 +501,241 @@ void main() {
});
});
group('입력칸 모양', () {
testWidgets(
'칸마다 테두리를 그리지 않는다 — 평소 · 초점 · 꺼짐(보내는 중) 모두 칸 줄의 아래 선뿐(앱 테마의 둥근 테두리를 물려받지 않는다)',
(WidgetTester tester) async {
await start(tester);
await open(tester, MailRoutes.compose);
await tapTooltip(tester, MailComposeTexts.expandCopies);
await addRecipient(tester, 'doyun@x.kr');
await tester.enterText(subjectField, '계약');
await tester.enterText(bodyField, '안녕하세요');
final List<(String, Finder)> fields = <(String, Finder)>[
('받는 사람', field(RecipientFieldKind.to)),
('참조', field(RecipientFieldKind.cc)),
('숨은참조', field(RecipientFieldKind.bcc)),
('제목', subjectField),
('본문', bodyField),
];
FocusManager.instance.primaryFocus?.unfocus();
await tester.pumpAndSettle();
for (final (String name, Finder finder) in fields) {
final InputDecorator decorator = expectBorderlessInput(
tester,
finder,
reason: '$name 평소',
);
expect(decorator.isFocused, isFalse);
expect(decorator.decoration.enabled, isTrue);
}
for (final (String name, Finder finder) in fields) {
tester.widget<TextField>(finder).focusNode!.requestFocus();
await tester.pumpAndSettle();
final InputDecorator decorator = expectBorderlessInput(
tester,
finder,
reason: '$name 초점',
);
expect(decorator.isFocused, isTrue);
}
// 보내는 동안은 칸을 막는다
final Completer<void> gate = Completer<void>();
c.sendGate = gate;
await tester.tap(find.byTooltip(MailComposeTexts.send));
await tester.pump();
for (final (String name, Finder finder) in fields) {
final InputDecorator decorator = expectBorderlessInput(
tester,
finder,
reason: '$name 꺼짐',
);
expect(decorator.decoration.enabled, isFalse);
}
gate.complete();
await tester.pumpAndSettle();
expect(c.sends, hasLength(1));
},
);
/// 받는 사람 칸 줄
Finder toRow() => find.descendant(
of: find.byKey(const ValueKey<RecipientFieldKind>(RecipientFieldKind.to)),
matching: find.byType(ComposeFieldRow),
);
Finder removeChip(String name) =>
find.byTooltip(MailComposeTexts.remove(name));
double centerY(WidgetTester tester, Finder finder) =>
tester.getCenter(finder).dy;
testWidgets(
'칸 줄 — 칸 이름 15 · 칸 이름과 칸 사이 6 · 오른쪽 여백 4 · 안내 글은 보드의 placeholder 색 · 칩 ✕ 는 #4A5363 (보드 App1Compose)',
(WidgetTester tester) async {
await start(tester);
await open(
tester,
MailRoutes.compose,
extra: const ComposePrefill(to: <String>['이도윤 <doyun@x.kr>']),
);
await tapTooltip(tester, MailComposeTexts.expandCopies);
for (final String label in <String>[
fieldLabelOf(RecipientFieldKind.to),
fieldLabelOf(RecipientFieldKind.cc),
fieldLabelOf(RecipientFieldKind.bcc),
MailComposeTexts.subject,
]) {
// 보드 14 → 앱의 크기 대응 15 (앱 디자인 README 「쓰기」)
expect(
tester.widget<Text>(find.text(label)).style?.fontSize,
15,
reason: label,
);
}
final Rect label = tester.getRect(find.text(MailComposeTexts.subject));
final Rect subject = tester.getRect(subjectField);
final Rect row = tester.getRect(
find.ancestor(
of: subjectField,
matching: find.byType(ComposeFieldRow),
),
);
expect(subject.left, label.left + 72 + 6);
expect(subject.right, row.right - 4);
expect(row.height, 56);
// 안내 글 — 보드의 placeholder #8A94A3(검색 칸 안내 글과 같은 색)
final InputDecoration? subjectHint = tester
.widget<TextField>(subjectField)
.decoration;
final InputDecoration? bodyHint = tester
.widget<TextField>(bodyField)
.decoration;
expect(subjectHint?.hintStyle?.color, AppColors.borderInput);
expect(bodyHint?.hintStyle?.color, AppColors.borderInput);
expect(AppColors.borderInput, const Color(0xFF8A94A3));
final IconButton remove = tester.widget<IconButton>(
find.ancestor(
of: removeChip('이도윤'),
matching: find.byType(IconButton),
),
);
expect(remove.color, AppColors.textSecondary);
expect(AppColors.textSecondary, const Color(0xFF4A5363));
},
);
testWidgets(
'받는 사람 칩이 여러 줄이면 칸 이름 · [참조 펼치기]는 첫 칩 줄에 맞춘다 — 한 줄이면 줄의 가운데 그대로',
(WidgetTester tester) async {
await start(tester);
await open(
tester,
MailRoutes.compose,
extra: const ComposePrefill(
to: <String>[
'이도윤 <doyun@x.kr>',
'박준호 <junho@x.kr>',
'최서연 <seoyeon@x.kr>',
],
),
);
FocusManager.instance.primaryFocus?.unfocus();
await tester.pumpAndSettle();
final Finder label = find.text(fieldLabelOf(RecipientFieldKind.to));
final Finder toggle = find.byTooltip(MailComposeTexts.expandCopies);
// 두 줄 — 셋째 칩은 아래 줄. 칸 이름 · 단추는 첫 칩 줄의 가운데(줄 전체의 가운데가 아니다)
final double firstLine = centerY(tester, removeChip('이도윤'));
expect(centerY(tester, removeChip('최서연')), greaterThan(firstLine + 40));
expect(
centerY(tester, label),
moreOrLessEquals(firstLine, epsilon: 0.5),
);
expect(
centerY(tester, toggle),
moreOrLessEquals(firstLine, epsilon: 0.5),
);
expect(
tester.getRect(toRow()).top + 4 + 24,
moreOrLessEquals(firstLine, epsilon: 0.5),
);
// 한 줄 — 칩 · 칸 이름 · 단추가 줄(56)의 가운데
await tester.tap(removeChip('최서연'));
await tester.pumpAndSettle();
await tester.tap(removeChip('박준호'));
await tester.pumpAndSettle();
FocusManager.instance.primaryFocus?.unfocus();
await tester.pumpAndSettle();
final Rect row = tester.getRect(toRow());
expect(row.height, 56);
for (final Finder finder in <Finder>[
label,
toggle,
removeChip('이도윤'),
]) {
expect(
centerY(tester, finder),
moreOrLessEquals(row.center.dy, epsilon: 0.5),
);
}
},
);
/// 본문 칸의 최소 높이 — 본문 칸을 감싼 ConstrainedBox(가장 가까운 것)
Finder bodyBox() => find
.ancestor(of: bodyField, matching: find.byType(ConstrainedBox))
.first;
testWidgets('본문 칸 최소 높이 — 새 메일은 서명이 있어도 240, 원본 메일이 있는 답장은 150 (보드)', (
WidgetTester tester,
) async {
expect(composeBodyMinHeightOf(hasQuote: false), 240);
expect(composeBodyMinHeightOf(hasQuote: true), 150);
c.signature = <String, Object?>{
'bodyHtml': null,
'body': '김아라\n경영지원팀',
'isForNew': true,
'isForReply': true,
};
h.mail.put(
kClientsId,
messageJson(
'r1',
mailboxId: kClientsId,
subject: '유지보수 계약',
isRead: true,
),
detail: <String, Object?>{'textBody': '원문 첫 줄'},
);
await start(tester);
await open(tester, MailRoutes.compose);
expect(find.text('김아라'), findsOneWidget);
expect(
tester.widget<ConstrainedBox>(bodyBox()).constraints.minHeight,
240,
);
expect(tester.getSize(bodyBox()).height, 240);
await tapTooltip(tester, MailComposeTexts.close);
expect(find.byType(MessageComposePage), findsNothing);
await open(tester, MailRoutes.composeReply('r1'));
expect(find.text('김아라'), findsOneWidget);
expect(find.textContaining('이도윤 · '), findsOneWidget);
expect(
tester.widget<ConstrainedBox>(bodyBox()).constraints.minHeight,
150,
);
});
});
group('받는 사람', () {
testWidgets('쉼표 · [다음]에 칩 — 붙여 넣은 여러 줄도 나누고, 틀린 주소는 빨간 칩, ✕ 로 뺀다', (
WidgetTester tester,
+52
View File
@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
/// InputDecorator 가 지금 상태(꺼짐 · 초점 · 평소, 오류)에서 그릴 테두리 — SDK `input_decorator.dart` 가 고르는 차례와 같다
/// (상태의 테두리가 없으면 `border`)
InputBorder? shownInputBorder(InputDecorator decorator) {
final InputDecoration decoration = decorator.decoration;
final bool error = decoration.errorText != null || decoration.error != null;
final InputBorder? border = !decoration.enabled
? (error ? decoration.errorBorder : decoration.disabledBorder)
: decorator.isFocused
? (error ? decoration.focusedErrorBorder : decoration.focusedBorder)
: (error ? decoration.errorBorder : decoration.enabledBorder);
return border ?? decoration.border;
}
/// 선이 보이는 테두리인가 — 없음([InputBorder.none]) · 두께 0 은 보이지 않는다
bool isDrawnInputBorder(InputBorder? border) =>
border != null &&
border.borderSide.style != BorderStyle.none &&
border.borderSide.width > 0;
/// [field](TextField 를 찾는 Finder) 의 InputDecorator 가 받은 꾸밈에 보이는 테두리 · 바탕이 없다 — 여섯 테두리와 지금 상태의 테두리를 본다
/// (앱 테마의 입력칸 테두리를 물려받지 않았는지). 상태(초점 · 꺼짐)를 더 볼 수 있게 그 InputDecorator 를 돌려준다
InputDecorator expectBorderlessInput(
WidgetTester tester,
Finder field, {
String? reason,
}) {
final InputDecorator decorator = tester.widget<InputDecorator>(
find.descendant(of: field, matching: find.byType(InputDecorator)),
);
final InputDecoration decoration = decorator.decoration;
final Map<String, InputBorder?> borders = <String, InputBorder?>{
'border': decoration.border,
'enabledBorder': decoration.enabledBorder,
'focusedBorder': decoration.focusedBorder,
'disabledBorder': decoration.disabledBorder,
'errorBorder': decoration.errorBorder,
'focusedErrorBorder': decoration.focusedErrorBorder,
'지금 상태': shownInputBorder(decorator),
};
borders.forEach((String name, InputBorder? border) {
expect(
isDrawnInputBorder(border),
isFalse,
reason: '${reason ?? ''} $name: $border',
);
});
expect(decoration.filled, isFalse, reason: reason);
return decorator;
}