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