generated from ttipo/project-base
fix(flutter): 첨부만 있는 메일의 읽기 화면과 쓰기 · 검색 입력칸을 고친다 (앱 1.0.1) #27
@@ -171,10 +171,10 @@ abstract final class AppSize {
|
||||
/// 쓰기 화면의 첨부 줄 파일 아이콘 칸
|
||||
static const double composeFileIconBox = 36;
|
||||
|
||||
/// 본문 칸의 최소 높이 — 새 메일(원본 메일이 아래에 없다)
|
||||
/// 본문 칸의 최소 높이 — 원본 메일이 아래에 없는 쓰기(새 메일 등 — 서명은 있어도)
|
||||
static const double composeBodyMinHeight = 240;
|
||||
|
||||
/// 본문 칸의 최소 높이 — 답장 · 전달(아래에 서명 · 원본 메일)
|
||||
/// 본문 칸의 최소 높이 — 답장 · 전달(아래에 원본 메일)
|
||||
static const double composeBodyMinHeightWithQuote = 150;
|
||||
|
||||
/// 원본 메일 접힌 줄의 최소 높이
|
||||
|
||||
@@ -231,6 +231,34 @@ abstract final class AppTheme {
|
||||
);
|
||||
}
|
||||
|
||||
/// 테두리 · 바탕 · 안쪽 여백이 없는 입력칸 꾸밈 — 칸을 담은 줄이 아래 선 · 초점 표시를 따로 그리는 칸
|
||||
/// (쓰기 화면의 받는 사람 · 제목 · 본문 — `ComposeFieldRow`, 메일 검색 막대).
|
||||
///
|
||||
/// `InputDecoration.collapsed` 는 쓰지 않는다 — collapsed 는 `border` 만 없애고 `enabledBorder` · `focusedBorder` 등을 비워 두는데,
|
||||
/// TextField 가 그 빈 칸을 테마의 입력칸 테두리(위 `inputDecorationTheme` — 둥근 테두리 · 초점은 청록 2)로 채우고, InputDecorator 는
|
||||
/// `border` 보다 이 칸들을 먼저 쓴다. 그래서 여섯 테두리를 모두 [InputBorder.none] 으로 적는다
|
||||
/// (2026-11-12 — 쓰기 화면의 칸마다 글자를 바짝 감싼 둥근 테두리가 그려지고, 초점이 오면 칸 줄의 아래 선과 청록 테두리가 겹쳤다).
|
||||
/// 여백 · 높이는 collapsed 와 같다(안쪽 여백 0 · `isCollapsed`). [hint] 와 [hintText] 는 하나만 준다
|
||||
InputDecoration borderlessInputDecoration({
|
||||
String? hintText,
|
||||
TextStyle? hintStyle,
|
||||
Widget? hint,
|
||||
}) => InputDecoration(
|
||||
hintText: hintText,
|
||||
hintStyle: hintStyle,
|
||||
hint: hint,
|
||||
isCollapsed: true,
|
||||
isDense: false,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
filled: false,
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
disabledBorder: InputBorder.none,
|
||||
errorBorder: InputBorder.none,
|
||||
focusedErrorBorder: InputBorder.none,
|
||||
);
|
||||
|
||||
/// Material 색 역할에 없는 웹 토큰 — `Theme.of(context).extension<AppExtraColors>()!` 로 쓴다
|
||||
@immutable
|
||||
class AppExtraColors extends ThemeExtension<AppExtraColors> {
|
||||
|
||||
@@ -489,9 +489,7 @@ class _MessageComposePageState extends ConsumerState<MessageComposePage> {
|
||||
focusNode: _bodyFocus,
|
||||
enabled: enabled,
|
||||
error: lengthError == ComposeLengthError.body,
|
||||
minHeight: quote != null || signature != null
|
||||
? AppSize.composeBodyMinHeightWithQuote
|
||||
: AppSize.composeBodyMinHeight,
|
||||
minHeight: composeBodyMinHeightOf(hasQuote: quote != null),
|
||||
),
|
||||
if (lengthError == ComposeLengthError.body)
|
||||
ComposeAlert(
|
||||
@@ -556,6 +554,12 @@ class _MessageComposePageState extends ConsumerState<MessageComposePage> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 본문 칸의 최소 높이 — 원본 메일(인용)이 아래에 있으면(답장 · 전달) 낮게(150), 없으면(새 메일 등) 서명이 있어도 높게(240).
|
||||
/// 보드 `App1Compose` 의 「새 메일 · 받는 사람 추천」(서명 있음 — 240)과 「답장 · 참조 펼침 · 첨부 · 원본 접힘」(서명 · 원본 메일 — 150)
|
||||
double composeBodyMinHeightOf({required bool hasQuote}) => hasQuote
|
||||
? AppSize.composeBodyMinHeightWithQuote
|
||||
: AppSize.composeBodyMinHeight;
|
||||
|
||||
/// 올리지 못한 고른 파일의 수
|
||||
int failedUploadsOf(ComposeState state) => state.attachments
|
||||
.where(
|
||||
@@ -627,7 +631,6 @@ class _SubjectRow extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
|
||||
final TextStyle? style = theme.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: AppTypography.semiBold,
|
||||
);
|
||||
@@ -653,11 +656,13 @@ class _SubjectRow extends StatelessWidget {
|
||||
Utf16LengthLimitFormatter(kComposeMaxSubject),
|
||||
],
|
||||
style: style,
|
||||
decoration: InputDecoration.collapsed(
|
||||
// 테두리는 칸 줄(ComposeFieldRow)의 아래 선뿐이다 — 테마의 입력칸 테두리를 물려받지 않게
|
||||
// 안내 글은 보드의 placeholder 색(#8A94A3 — 테마의 outline, 검색 칸 안내 글과 같다)
|
||||
decoration: borderlessInputDecoration(
|
||||
hintText: MailComposeTexts.subjectHint,
|
||||
hintStyle: style?.copyWith(
|
||||
fontWeight: AppTypography.regular,
|
||||
color: extra.textMuted,
|
||||
color: theme.colorScheme.outline,
|
||||
),
|
||||
),
|
||||
onSubmitted: (String _) => onSubmitted(),
|
||||
@@ -669,7 +674,7 @@ class _SubjectRow extends StatelessWidget {
|
||||
}
|
||||
|
||||
/// 본문 칸 — 글만(서식 편집은 다음 판). 비어 있는 아래쪽을 눌러도 칸에 초점이 간다.
|
||||
/// 아래에 서명 · 원본 메일이 있으면 낮게(150), 없으면 높게(240) 둔다(보드)
|
||||
/// 아래에 원본 메일이 있으면 낮게(150), 없으면 서명이 있어도 높게(240) 둔다(보드 — [composeBodyMinHeightOf])
|
||||
class _BodyField extends StatelessWidget {
|
||||
const _BodyField({
|
||||
required this.controller,
|
||||
@@ -718,9 +723,9 @@ class _BodyField extends StatelessWidget {
|
||||
textInputAction: TextInputAction.newline,
|
||||
maxLines: null,
|
||||
style: style,
|
||||
decoration: InputDecoration.collapsed(
|
||||
decoration: borderlessInputDecoration(
|
||||
hintText: MailComposeTexts.bodyHint,
|
||||
hintStyle: style?.copyWith(color: extra.textMuted),
|
||||
hintStyle: style?.copyWith(color: theme.colorScheme.outline),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:arasystem_app/core/people/address_input.dart';
|
||||
import 'package:arasystem_app/core/people/people_texts.dart';
|
||||
import 'package:arasystem_app/core/people/recipient_match.dart';
|
||||
@@ -192,6 +194,10 @@ class _ComposeRecipientFieldState extends State<ComposeRecipientField> {
|
||||
error: invalid,
|
||||
onTap: widget.enabled ? widget.focusNode.requestFocus : null,
|
||||
trailing: widget.trailing,
|
||||
// 칩이 있으면 첫 줄은 칩 줄 — 여러 줄이어도 칸 이름 · [참조 펼치기]가 첫 칩 줄에 맞는다
|
||||
firstLineHeight: widget.tokens.isEmpty
|
||||
? null
|
||||
: AppSize.recipientChipHeight,
|
||||
child: LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
return Wrap(
|
||||
@@ -263,7 +269,8 @@ class _ComposeRecipientFieldState extends State<ComposeRecipientField> {
|
||||
smartQuotesType: SmartQuotesType.disabled,
|
||||
inputFormatters: const <TextInputFormatter>[_LineBreaksToCommas()],
|
||||
style: theme.textTheme.bodyLarge,
|
||||
decoration: const InputDecoration.collapsed(hintText: ''),
|
||||
// 테두리는 칸 줄(ComposeFieldRow)의 아래 선뿐이다 — 테마의 입력칸 테두리를 물려받지 않게
|
||||
decoration: borderlessInputDecoration(hintText: ''),
|
||||
onChanged: widget.onChanged,
|
||||
onEditingComplete: _onNext,
|
||||
),
|
||||
@@ -342,7 +349,12 @@ class _LineBreaksToCommas extends TextInputFormatter {
|
||||
}
|
||||
}
|
||||
|
||||
/// 쓰기 화면의 칸 줄 — 왼쪽 칸 이름(72) · 가운데 칸 · 오른쪽 단추, 아래 선(초점이 있으면 청록 2) (보드 `App1Compose`)
|
||||
/// 쓰기 화면의 칸 줄 — 왼쪽 칸 이름(72 · 글자 15) · 간격 6 · 가운데 칸 · 오른쪽 단추, 아래 선(초점이 있으면 청록 2)
|
||||
/// (보드 `App1Compose` — 최소 높이 56 · 여백 위 4 · 오른쪽 4 · 아래 4 · 왼쪽 16).
|
||||
///
|
||||
/// 칸 이름과 오른쪽 단추는 **첫 줄**([lineHeight] — 최소 높이에서 위아래 여백을 뺀 48)의 세로 가운데에 둔다 —
|
||||
/// 받는 사람 칩이 여러 줄이어도 첫 줄에 남는다(줄 전체의 가운데로 내려가지 않게 — 2026-11-12). 칸이 한 줄이면 줄의 가운데 그대로다.
|
||||
/// 아래 선은 자리를 차지하지 않게 그린다 — 초점 · 오류로 굵어져도 칸이 움직이지 않는다(보드의 inset 그림자와 같다)
|
||||
class ComposeFieldRow extends StatelessWidget {
|
||||
const ComposeFieldRow({
|
||||
required this.label,
|
||||
@@ -351,9 +363,14 @@ class ComposeFieldRow extends StatelessWidget {
|
||||
this.error = false,
|
||||
this.trailing,
|
||||
this.onTap,
|
||||
this.firstLineHeight,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// 첫 줄의 높이 — 줄의 최소 높이(56)에서 위아래 여백(4)을 뺀 자리
|
||||
static const double lineHeight =
|
||||
AppSize.composeRowMinHeight - AppSpacing.s4 * 2;
|
||||
|
||||
final String label;
|
||||
final Widget child;
|
||||
final bool focused;
|
||||
@@ -363,12 +380,20 @@ class ComposeFieldRow extends StatelessWidget {
|
||||
final Widget? trailing;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// 칸 안 첫 줄의 높이(받는 사람 칩 40) — 주면 칸을 위아래로 ([lineHeight] − 이 값) / 2 씩 띄워,
|
||||
/// 칩이 여러 줄이어도 첫 칩 줄이 칸 이름 · 오른쪽 단추와 같은 높이에 온다(한 줄이면 줄을 꼭 채워 지금처럼 가운데)
|
||||
final double? firstLineHeight;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final ColorScheme scheme = theme.colorScheme;
|
||||
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
|
||||
final Widget? action = trailing;
|
||||
final double? inner = firstLineHeight;
|
||||
final double inset = inner == null
|
||||
? 0
|
||||
: math.max(0, (lineHeight - inner) / 2);
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: onTap,
|
||||
@@ -376,13 +401,13 @@ class ComposeFieldRow extends StatelessWidget {
|
||||
constraints: const BoxConstraints(
|
||||
minHeight: AppSize.composeRowMinHeight,
|
||||
),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.s16,
|
||||
AppSpacing.s4,
|
||||
action == null ? AppSpacing.s16 : AppSpacing.s4,
|
||||
AppSpacing.s4,
|
||||
AppSpacing.s4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
foregroundDecoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: error
|
||||
@@ -395,20 +420,32 @@ class ComposeFieldRow extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
SizedBox(
|
||||
width: AppSize.composeLabelWidth,
|
||||
child: ExcludeSemantics(
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: extra.textMuted,
|
||||
_FirstLine(
|
||||
child: SizedBox(
|
||||
width: AppSize.composeLabelWidth,
|
||||
child: ExcludeSemantics(
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: extra.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(child: child),
|
||||
?action,
|
||||
const SizedBox(width: AppSpacing.s6),
|
||||
Expanded(
|
||||
child: _FirstLine(
|
||||
stretch: true,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: inset),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (action != null) _FirstLine(child: action),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -416,6 +453,31 @@ class ComposeFieldRow extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 칸 줄의 첫 줄 자리 — [ComposeFieldRow.lineHeight] 안의 세로 가운데에 둔다. 더 높으면(칩이 여러 줄 · 큰 글자) 위에서부터
|
||||
class _FirstLine extends StatelessWidget {
|
||||
const _FirstLine({required this.child, this.stretch = false});
|
||||
|
||||
final Widget child;
|
||||
|
||||
/// 폭을 다 쓴다(가운데 칸)
|
||||
final bool stretch;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: ComposeFieldRow.lineHeight),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: stretch
|
||||
? CrossAxisAlignment.stretch
|
||||
: CrossAxisAlignment.start,
|
||||
children: <Widget>[child],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 받는 사람 칩 — 이름(없으면 주소) · ✕. 형식이 틀린 주소는 빨갛게
|
||||
class _RecipientChip extends StatelessWidget {
|
||||
const _RecipientChip({required this.token, required this.onRemove});
|
||||
@@ -427,7 +489,6 @@ class _RecipientChip extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final ColorScheme scheme = theme.colorScheme;
|
||||
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
|
||||
final String? name = token.address.name?.trim();
|
||||
final String shown = !token.valid
|
||||
? token.label
|
||||
@@ -470,7 +531,8 @@ class _RecipientChip extends StatelessWidget {
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
iconSize: AppSize.iconTiny,
|
||||
color: token.valid ? extra.textStrong : scheme.error,
|
||||
// 보드의 ✕ #4A5363 — 보조 글자색(onSurfaceVariant)
|
||||
color: token.valid ? scheme.onSurfaceVariant : scheme.error,
|
||||
tooltip: MailComposeTexts.remove(shown),
|
||||
onPressed: onRemove,
|
||||
icon: const Icon(Icons.close),
|
||||
|
||||
@@ -94,8 +94,8 @@ class MailSearchBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
Utf16LengthLimitFormatter(kMailSearchMaxLength),
|
||||
],
|
||||
style: style,
|
||||
decoration: InputDecoration.collapsed(
|
||||
hintText: null,
|
||||
// 위 막대 안의 칸 — 테마의 입력칸 테두리를 물려받지 않게
|
||||
decoration: borderlessInputDecoration(
|
||||
hint: ExcludeSemantics(
|
||||
child: Text(
|
||||
MailSearchTexts.box,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user