diff --git a/flutter/lib/core/theme/app_spacing.dart b/flutter/lib/core/theme/app_spacing.dart index 5c32e74..7d9f965 100644 --- a/flutter/lib/core/theme/app_spacing.dart +++ b/flutter/lib/core/theme/app_spacing.dart @@ -171,10 +171,10 @@ abstract final class AppSize { /// 쓰기 화면의 첨부 줄 파일 아이콘 칸 static const double composeFileIconBox = 36; - /// 본문 칸의 최소 높이 — 새 메일(원본 메일이 아래에 없다) + /// 본문 칸의 최소 높이 — 원본 메일이 아래에 없는 쓰기(새 메일 등 — 서명은 있어도) static const double composeBodyMinHeight = 240; - /// 본문 칸의 최소 높이 — 답장 · 전달(아래에 서명 · 원본 메일) + /// 본문 칸의 최소 높이 — 답장 · 전달(아래에 원본 메일) static const double composeBodyMinHeightWithQuote = 150; /// 원본 메일 접힌 줄의 최소 높이 diff --git a/flutter/lib/core/theme/app_theme.dart b/flutter/lib/core/theme/app_theme.dart index 5ff7e50..d6f445b 100644 --- a/flutter/lib/core/theme/app_theme.dart +++ b/flutter/lib/core/theme/app_theme.dart @@ -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()!` 로 쓴다 @immutable class AppExtraColors extends ThemeExtension { diff --git a/flutter/lib/features/mail/presentation/pages/message_compose_page.dart b/flutter/lib/features/mail/presentation/pages/message_compose_page.dart index bccd38f..4c676cd 100644 --- a/flutter/lib/features/mail/presentation/pages/message_compose_page.dart +++ b/flutter/lib/features/mail/presentation/pages/message_compose_page.dart @@ -489,9 +489,7 @@ class _MessageComposePageState extends ConsumerState { 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 { } } +/// 본문 칸의 최소 높이 — 원본 메일(인용)이 아래에 있으면(답장 · 전달) 낮게(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()!; 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), ), ), ), diff --git a/flutter/lib/features/mail/presentation/widgets/compose_recipient_field.dart b/flutter/lib/features/mail/presentation/widgets/compose_recipient_field.dart index 736fc7f..d328c85 100644 --- a/flutter/lib/features/mail/presentation/widgets/compose_recipient_field.dart +++ b/flutter/lib/features/mail/presentation/widgets/compose_recipient_field.dart @@ -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 { 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 { smartQuotesType: SmartQuotesType.disabled, inputFormatters: const [_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()!; 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: [ - 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: [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()!; 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), diff --git a/flutter/lib/features/mail/presentation/widgets/mail_search_bar.dart b/flutter/lib/features/mail/presentation/widgets/mail_search_bar.dart index 3682ce8..8b7facd 100644 --- a/flutter/lib/features/mail/presentation/widgets/mail_search_bar.dart +++ b/flutter/lib/features/mail/presentation/widgets/mail_search_bar.dart @@ -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, diff --git a/flutter/test/core/theme/borderless_input_decoration_test.dart b/flutter/test/core/theme/borderless_input_decoration_test.dart new file mode 100644 index 0000000..4143067 --- /dev/null +++ b/flutter/test/core/theme/borderless_input_decoration_test.dart @@ -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 [ + 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()); + expect(collapsed.focusedBorder, isA()); + }, + ); + + /// 앱 테마로 칸 하나를 그린다 — [enabled] 가 거짓이면 꺼진 칸 + Future 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('field'), + focusNode: focusNode, + enabled: enabled, + decoration: decoration, + ), + ), + ), + ); + return focusNode; + } + + final Finder field = find.byKey(const ValueKey('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( + 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 found = []; + for (final FileSystemEntity entity in Directory( + 'lib', + ).listSync(recursive: true)) { + if (entity is! File || !entity.path.endsWith('.dart')) continue; + final List 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); + }, + ); +} diff --git a/flutter/test/features/mail/mail_search_page_test.dart b/flutter/test/features/mail/mail_search_page_test.dart index 35bd524..c7a828c 100644 --- a/flutter/test/features/mail/mail_search_page_test.dart +++ b/flutter/test/features/mail/mail_search_page_test.dart @@ -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 { diff --git a/flutter/test/features/mail/message_compose_page_test.dart b/flutter/test/features/mail/message_compose_page_test.dart index 46612e0..490b8de 100644 --- a/flutter/test/features/mail/message_compose_page_test.dart +++ b/flutter/test/features/mail/message_compose_page_test.dart @@ -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(finder).focusNode!.requestFocus(); + await tester.pumpAndSettle(); + final InputDecorator decorator = expectBorderlessInput( + tester, + finder, + reason: '$name 초점', + ); + expect(decorator.isFocused, isTrue); + } + + // 보내는 동안은 칸을 막는다 + final Completer gate = Completer(); + 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.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: ['이도윤 ']), + ); + await tapTooltip(tester, MailComposeTexts.expandCopies); + + for (final String label in [ + fieldLabelOf(RecipientFieldKind.to), + fieldLabelOf(RecipientFieldKind.cc), + fieldLabelOf(RecipientFieldKind.bcc), + MailComposeTexts.subject, + ]) { + // 보드 14 → 앱의 크기 대응 15 (앱 디자인 README 「쓰기」) + expect( + tester.widget(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(subjectField) + .decoration; + final InputDecoration? bodyHint = tester + .widget(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( + 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: [ + '이도윤 ', + '박준호 ', + '최서연 ', + ], + ), + ); + 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 [ + 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 = { + 'bodyHtml': null, + 'body': '김아라\n경영지원팀', + 'isForNew': true, + 'isForReply': true, + }; + h.mail.put( + kClientsId, + messageJson( + 'r1', + mailboxId: kClientsId, + subject: '유지보수 계약', + isRead: true, + ), + detail: {'textBody': '원문 첫 줄'}, + ); + await start(tester); + await open(tester, MailRoutes.compose); + + expect(find.text('김아라'), findsOneWidget); + expect( + tester.widget(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(bodyBox()).constraints.minHeight, + 150, + ); + }); + }); + group('받는 사람', () { testWidgets('쉼표 · [다음]에 칩 — 붙여 넣은 여러 줄도 나누고, 틀린 주소는 빨간 칩, ✕ 로 뺀다', ( WidgetTester tester, diff --git a/flutter/test/support/input_borders.dart b/flutter/test/support/input_borders.dart new file mode 100644 index 0000000..6eebfec --- /dev/null +++ b/flutter/test/support/input_borders.dart @@ -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( + find.descendant(of: field, matching: find.byType(InputDecorator)), + ); + final InputDecoration decoration = decorator.decoration; + final Map borders = { + '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; +}