feat(flutter): 메일 쓰기 화면 1차를 만든다 (App1Compose)

새 메일 · 답장 · 전체 답장 · 전달 · 이어 쓰기 · mailto 를 연다. 글만 쓰고,
보낼 때 웹과 같은 서식 본문(기본 글꼴 div · 서명 경계 div · "원본 메일"
인용)과 텍스트 본문을 함께 만든다 — 원문 인용은 글로 만든 덩이다.

- 임시 저장은 웹 규칙(3초 · 30초 · 10초 · 한 번에 하나)에 더해 앱이 안
  보이게 되면 바로 저장한다
- 보내기는 같은 Idempotency-Key 규칙(성공 · MAIL_005 에만 새 키), 보낸 뒤
  시작한 화면에 [보내기 취소] 마감 알림 · 남은 시간 막대
- 서명은 따로 된 칸([서명 빼기] · [서명 넣기]), 원본 메일은 접힌 채
- 리뷰 H1: 진행 중인 저장을 기다리는 사이 화면을 떠나면 메일이 나가지
  않던 것 — keepAlive 를 기다림 앞에서 잡고 떠나도 그대로 보낸다
- 리뷰 M1 · M2 · M3: 저장본 읽기는 읽기 화면과 같은 한도 · isolate,
  웹 저장본의 정렬 · 들여쓰기는 남의 저장본 길로, 확인 창의 거짓 "저장했습니다"
- 첨부 고르기 · 올리기는 2차(지금은 전달 원본 · 저장본의 첨부만 싣고 뺀다)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 02:11:00 +09:00
parent 695a343878
commit 80e48d318d
39 changed files with 11209 additions and 85 deletions
+9
View File
@@ -42,6 +42,12 @@ abstract final class AppColors {
/// `--color-on-brand-link` — 브랜드 바탕 위 링크 · 알림 안 단추
static const Color onBrandLink = Color(0xFF9ED6DB);
/// `--color-on-brand-track` rgba(255, 255, 255, 0.18) — 알림 안 남은 시간 막대의 바탕 ([보내기 취소])
static const Color onBrandTrack = Color(0x2EFFFFFF);
/// `--color-on-brand-progress` — 알림 안 남은 시간 막대 ([보내기 취소]가 남은 동안)
static const Color onBrandProgress = Color(0xFF7FD3DA);
// ── 글자 ──
/// `--color-text`
static const Color text = Color(0xFF171C26);
@@ -99,6 +105,9 @@ abstract final class AppColors {
/// `--color-danger-surface`
static const Color dangerSurface = Color(0xFFFEF3F2);
/// `--color-danger-border` — 칸 안내 띠의 아래 선 (쓰기 화면의 받는 사람 · 제목 · 본문 오류)
static const Color dangerBorder = Color(0xFFFBD7D3);
/// `--color-warning-surface`
static const Color warningSurface = Color(0xFFFFF7E6);
+43
View File
@@ -142,4 +142,47 @@ abstract final class AppSize {
/// 더 보기 메뉴의 폭
static const double readerMenuWidth = 224;
// ── 앱 보드에서 정한 크기 (`App1Compose` — 2026-11-05 확정) ──
/// 쓰기 화면의 칸 줄(받는 사람 · 참조 · 숨은참조 · 제목) 최소 높이
static const double composeRowMinHeight = 56;
/// 칸 줄 왼쪽의 이름 폭("받는 사람" · "숨은참조")
static const double composeLabelWidth = 72;
/// 받는 사람 칩 높이
static const double recipientChipHeight = 40;
/// 받는 사람 칩의 ✕ — 누르는 곳 40 (밀도 예외 — 앱 디자인 README "크기 · 글꼴", 사용자 확정)
static const double recipientChipRemove = 40;
/// 받는 사람 추천 줄의 최소 높이
static const double suggestionRowMinHeight = 60;
/// 받는 사람 추천 줄의 동그란 사진
static const double suggestionAvatar = 36;
/// 받는 사람 추천 목록의 최대 높이 — 넘으면 그 안에서 스크롤한다(자판 위 자리가 모자라면 더 줄인다)
static const double suggestionListMaxHeight = 300;
/// 쓰기 화면의 첨부 줄 최소 높이
static const double composeAttachmentRowMinHeight = 56;
/// 쓰기 화면의 첨부 줄 파일 아이콘 칸
static const double composeFileIconBox = 36;
/// 본문 칸의 최소 높이 — 새 메일(원본 메일이 아래에 없다)
static const double composeBodyMinHeight = 240;
/// 본문 칸의 최소 높이 — 답장 · 전달(아래에 서명 · 원본 메일)
static const double composeBodyMinHeightWithQuote = 150;
/// 원본 메일 접힌 줄의 최소 높이
static const double quoteToggleMinHeight = 56;
/// 인용 막대 폭 — 원본 메일 · 원문 속 인용
static const double quoteBarWidth = 2;
/// 알림 안 남은 시간 막대의 두께 ([보내기 취소])
static const double toastCountdownHeight = 3;
}
+30
View File
@@ -197,8 +197,12 @@ class AppExtraColors extends ThemeExtension<AppExtraColors> {
required this.textStrong,
required this.iconMuted,
required this.borderSubtle,
required this.borderStrong,
required this.onBrandMuted,
required this.onBrandLink,
required this.onBrandTrack,
required this.onBrandProgress,
required this.dangerBorder,
required this.warningSurface,
required this.warningText,
required this.warningBorder,
@@ -214,8 +218,12 @@ class AppExtraColors extends ThemeExtension<AppExtraColors> {
textStrong: AppColors.textStrong,
iconMuted: AppColors.iconMuted,
borderSubtle: AppColors.borderSubtle,
borderStrong: AppColors.borderStrong,
onBrandMuted: AppColors.onBrandMuted,
onBrandLink: AppColors.onBrandLink,
onBrandTrack: AppColors.onBrandTrack,
onBrandProgress: AppColors.onBrandProgress,
dangerBorder: AppColors.dangerBorder,
warningSurface: AppColors.warningSurface,
warningText: AppColors.warningText,
warningBorder: AppColors.warningBorder,
@@ -238,12 +246,22 @@ class AppExtraColors extends ThemeExtension<AppExtraColors> {
/// 옅은 나눔선 — 화면 맨 아래 안내 글 위
final Color borderSubtle;
/// 짙은 나눔선 — 쓰기 화면의 인용 막대 · 서명 위 점선 (`--color-border-strong`)
final Color borderStrong;
/// 브랜드 바탕 위 보조 글 (로그인 첫 화면의 소개 글)
final Color onBrandMuted;
/// 브랜드 바탕 위 링크 · 단추 ([다른 계정으로 로그인])
final Color onBrandLink;
/// 브랜드 바탕 알림 안의 남은 시간 막대 — 바탕 · 채움 ([보내기 취소])
final Color onBrandTrack;
final Color onBrandProgress;
/// 칸 안내 띠(바탕 `errorContainer`)의 아래 선
final Color dangerBorder;
/// 노란 안내 줄
final Color warningSurface;
final Color warningText;
@@ -266,8 +284,12 @@ class AppExtraColors extends ThemeExtension<AppExtraColors> {
Color? textStrong,
Color? iconMuted,
Color? borderSubtle,
Color? borderStrong,
Color? onBrandMuted,
Color? onBrandLink,
Color? onBrandTrack,
Color? onBrandProgress,
Color? dangerBorder,
Color? warningSurface,
Color? warningText,
Color? warningBorder,
@@ -282,8 +304,12 @@ class AppExtraColors extends ThemeExtension<AppExtraColors> {
textStrong: textStrong ?? this.textStrong,
iconMuted: iconMuted ?? this.iconMuted,
borderSubtle: borderSubtle ?? this.borderSubtle,
borderStrong: borderStrong ?? this.borderStrong,
onBrandMuted: onBrandMuted ?? this.onBrandMuted,
onBrandLink: onBrandLink ?? this.onBrandLink,
onBrandTrack: onBrandTrack ?? this.onBrandTrack,
onBrandProgress: onBrandProgress ?? this.onBrandProgress,
dangerBorder: dangerBorder ?? this.dangerBorder,
warningSurface: warningSurface ?? this.warningSurface,
warningText: warningText ?? this.warningText,
warningBorder: warningBorder ?? this.warningBorder,
@@ -303,8 +329,12 @@ class AppExtraColors extends ThemeExtension<AppExtraColors> {
textStrong: Color.lerp(textStrong, other.textStrong, t)!,
iconMuted: Color.lerp(iconMuted, other.iconMuted, t)!,
borderSubtle: Color.lerp(borderSubtle, other.borderSubtle, t)!,
borderStrong: Color.lerp(borderStrong, other.borderStrong, t)!,
onBrandMuted: Color.lerp(onBrandMuted, other.onBrandMuted, t)!,
onBrandLink: Color.lerp(onBrandLink, other.onBrandLink, t)!,
onBrandTrack: Color.lerp(onBrandTrack, other.onBrandTrack, t)!,
onBrandProgress: Color.lerp(onBrandProgress, other.onBrandProgress, t)!,
dangerBorder: Color.lerp(dangerBorder, other.dangerBorder, t)!,
warningSurface: Color.lerp(warningSurface, other.warningSurface, t)!,
warningText: Color.lerp(warningText, other.warningText, t)!,
warningBorder: Color.lerp(warningBorder, other.warningBorder, t)!,
+53
View File
@@ -0,0 +1,53 @@
/// 글 길이를 UTF-16 단위로 다룬다 — 서버(`MaxCodeUnits`) · 웹(`maxlength` · `.length`)과 같은 단위다.
///
/// Flutter 의 `LengthLimitingTextInputFormatter` 는 글자(grapheme) 단위로 세어 이모지가 든 글을 서버보다 길게 받아 준다 —
/// 서버와 같은 한도를 거는 칸은 이 포매터를 쓴다.
library;
import 'package:flutter/services.dart';
/// 앞에서 [maxLength](UTF-16)까지 — 높은 짝 글자에서 끝나면 그 앞에서 자른다(짝 글자를 가르지 않는다)
String truncateUtf16(String text, int maxLength) {
if (text.length <= maxLength) return text;
int end = maxLength < 0 ? 0 : maxLength;
if (end > 0) {
final int last = text.codeUnitAt(end - 1);
if (last >= 0xD800 && last <= 0xDBFF) end--;
}
return text.substring(0, end);
}
/// 칸에 치는 글을 UTF-16 [maxLength] 까지 막는 입력 포매터.
///
/// - 한글을 조합하는 동안은 막지 않고 조합이 끝나면 자른다(조합 중인 글자를 깨지 않게 — 안드로이드의 기본과 같은 결).
/// - 이미 한도만큼 차 있고 커서만 있으면 더 치는 글을 받지 않는다(가운데에 친 글자가 끝의 글자를 밀어내지 않게 — Flutter 와 같다).
/// - 그 밖에 넘으면 뒤를 자른다(붙여 넣기). 프로그램이 넣은 글(저장본 · mailto)은 포매터를 거치지 않으므로 보내기 전 검사가 막는다
class Utf16LengthLimitFormatter extends TextInputFormatter {
const Utf16LengthLimitFormatter(this.maxLength);
final int maxLength;
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
if (newValue.composing.isValid || newValue.text.length <= maxLength) {
return newValue;
}
if (oldValue.text.length == maxLength && oldValue.selection.isCollapsed) {
return oldValue;
}
final String text = truncateUtf16(newValue.text, maxLength);
final TextSelection selection = newValue.selection;
return TextEditingValue(
text: text,
selection: selection.isValid
? selection.copyWith(
baseOffset: selection.baseOffset.clamp(0, text.length),
extentOffset: selection.extentOffset.clamp(0, text.length),
)
: TextSelection.collapsed(offset: text.length),
);
}
}
@@ -0,0 +1,333 @@
/// 쓰기 화면의 응답 모양 — 계약 2절 `Draft` · `DraftSaved` · `SentMessage` · `MailSignature` · `MailSettings`, 4절 `LargeFile` · `MissingLargeFile`.
///
/// 모델은 손으로 쓴다(flutter 스킬 "모델 생성기"). 모양이 틀리면 [tryParse] 가 null — 저장소가 `SYS_001` 로 던진다.
/// 나중 단계에 더해진 칸(`largeFiles` · `pendingSend` · `rejectedRecipients`)은 없으면 빈 목록 · null 로 읽는다(웹과 같다).
library;
import 'package:arasystem_app/features/mail/data/models/mail_address.dart';
import 'package:arasystem_app/features/mail/data/models/message_detail.dart';
import 'package:arasystem_app/features/mail/data/models/message_summary.dart';
import 'package:flutter/foundation.dart';
/// 저장본에 실린 대용량 파일의 상태 — 다 올림 · 올리는 중(웹에서 올리다 멈춤) · 없음(지웠거나 기간이 지남)
enum DraftLargeFileStatus { ready, uploading, missing }
/// 저장본에 실린 대용량 파일 하나 — `Draft.largeFiles` 의 `LargeFile | MissingLargeFile` (계약 4절 "임시 저장").
/// 앱은 첫 판에서 올리지 않는다 — 이름 · 크기와 상태를 보이고, 다시 저장 · 보낼 때 id 를 빠뜨리지 않고 싣거나 뺀다
@immutable
class DraftLargeFile {
const DraftLargeFile({
required this.id,
required this.status,
this.name,
this.size,
this.infected = false,
});
/// 올리기 id = 파일 id — 저장해도 바뀌지 않는다
final String id;
final DraftLargeFileStatus status;
/// 없는 파일은 기록이 남아 있을 때만(지운 뒤 30일) 이름 · 크기가 있다
final String? name;
final int? size;
/// 악성 검사에서 위험 — 보낼 수 없다(저장은 된다)
final bool infected;
DraftLargeFile withStatus(DraftLargeFileStatus next, {bool? infected}) =>
DraftLargeFile(
id: id,
status: next,
name: name,
size: size,
infected: infected ?? this.infected,
);
/// 모양이 틀리면 null — 그 항목만 버린다(11단계 칸이라 메일 전체를 열지 못하게 하지 않는다)
static DraftLargeFile? tryParse(Object? json) {
if (json is! Map<String, Object?>) return null;
final Object? id = json['id'];
final Object? status = json['status'];
final Object? name = json['name'];
final Object? size = json['size'];
if (id is! String || id.isEmpty) return null;
final DraftLargeFileStatus? parsed = switch (status) {
'ready' => DraftLargeFileStatus.ready,
'uploading' => DraftLargeFileStatus.uploading,
'missing' => DraftLargeFileStatus.missing,
_ => null,
};
if (parsed == null) return null;
return DraftLargeFile(
id: id,
status: parsed,
name: name is String ? name : null,
size: size is num ? size.toInt() : null,
infected: json['scanStatus'] == 'infected',
);
}
@override
bool operator ==(Object other) =>
other is DraftLargeFile &&
other.id == id &&
other.status == status &&
other.name == name &&
other.size == size &&
other.infected == infected;
@override
int get hashCode => Object.hash(id, status, name, size, infected);
}
/// 임시 저장본 — `GET /api/drafts/{id}`
@immutable
class Draft {
const Draft({
required this.id,
required this.to,
required this.cc,
required this.bcc,
required this.subject,
required this.body,
required this.updatedAt,
this.bodyHtml,
this.attachments = const <MessageAttachment>[],
this.inReplyTo,
this.largeFiles = const <DraftLargeFile>[],
});
/// 저장할 때마다 바뀐다
final String id;
final List<MailAddress> to;
final List<MailAddress> cc;
final List<MailAddress> bcc;
final String subject;
/// 텍스트 대체 본문
final String body;
/// 서식 본문 — 보내는 정화를 거친 값. 없거나 비면 null
final String? bodyHtml;
/// 이 판의 첨부 — id 가 저장할 때마다 바뀐다
final List<MessageAttachment> attachments;
/// 답장할 메일 id — 없거나 원 메일이 사라졌으면 null
final String? inReplyTo;
final DateTime updatedAt;
final List<DraftLargeFile> largeFiles;
static Draft? tryParse(Object? json) {
if (json is! Map<String, Object?>) return null;
final Object? id = json['id'];
final List<MailAddress>? to = MailAddress.tryParseList(json['to']);
final List<MailAddress>? cc = MailAddress.tryParseList(
json['cc'] ?? <Object?>[],
);
final List<MailAddress>? bcc = MailAddress.tryParseList(
json['bcc'] ?? <Object?>[],
);
final Object? subject = json['subject'];
final Object? body = json['body'];
final Object? bodyHtml = json['bodyHtml'];
final Object? inReplyTo = json['inReplyTo'];
final Object? updatedAt = json['updatedAt'];
final DateTime? updated = updatedAt is String
? DateTime.tryParse(updatedAt)
: null;
final List<MessageAttachment>? attachments = _attachments(
json['attachments'],
);
if (id is! String ||
id.isEmpty ||
to == null ||
cc == null ||
bcc == null ||
subject is! String ||
(body != null && body is! String) ||
(bodyHtml != null && bodyHtml is! String) ||
(inReplyTo != null && inReplyTo is! String) ||
updated == null ||
attachments == null) {
return null;
}
final Object? largeFiles = json['largeFiles'];
return Draft(
id: id,
to: to,
cc: cc,
bcc: bcc,
subject: subject,
body: body as String? ?? '',
bodyHtml: bodyHtml as String?,
attachments: attachments,
inReplyTo: inReplyTo as String?,
updatedAt: updated.toUtc(),
largeFiles: largeFiles is List<Object?>
? List<DraftLargeFile>.unmodifiable(<DraftLargeFile>[
for (final Object? item in largeFiles)
?DraftLargeFile.tryParse(item),
])
: const <DraftLargeFile>[],
);
}
}
/// 저장한 결과 — `POST /api/drafts` · `PUT /api/drafts/{id}` (새 id · 이 판의 첨부)
@immutable
class DraftSaved {
const DraftSaved({
required this.id,
required this.attachments,
required this.updatedAt,
});
final String id;
final List<MessageAttachment> attachments;
final DateTime updatedAt;
static DraftSaved? tryParse(Object? json) {
if (json is! Map<String, Object?>) return null;
final Object? id = json['id'];
final Object? updatedAt = json['updatedAt'];
final DateTime? updated = updatedAt is String
? DateTime.tryParse(updatedAt)
: null;
final List<MessageAttachment>? attachments = _attachments(
json['attachments'],
);
if (id is! String || id.isEmpty || updated == null || attachments == null) {
return null;
}
return DraftSaved(
id: id,
attachments: attachments,
updatedAt: updated.toUtc(),
);
}
}
List<MessageAttachment>? _attachments(Object? value) {
if (value == null) return const <MessageAttachment>[];
if (value is! List<Object?>) return null;
final List<MessageAttachment> items = <MessageAttachment>[];
for (final Object? item in value) {
final MessageAttachment? attachment = MessageAttachment.tryParse(item);
if (attachment == null) return null;
items.add(attachment);
}
return List<MessageAttachment>.unmodifiable(items);
}
/// 받지 못한 까닭 — 없는 주소 · 받는 쪽이 거절 · 지금은 받을 수 없음 (모르는 값은 [unknown] — 주소만 적는다)
enum RejectedReason { notFound, refused, temporary, unknown }
/// 엔진이 받지 않은 받는 사람 — 계약 2절 "받는 사람별 결과"
@immutable
class RejectedRecipient {
const RejectedRecipient({required this.email, required this.reason});
final String email;
final RejectedReason reason;
static RejectedRecipient? tryParse(Object? json) {
if (json is! Map<String, Object?>) return null;
final Object? email = json['email'];
if (email is! String || email.isEmpty) return null;
return RejectedRecipient(
email: email,
reason: switch (json['reason']) {
'not-found' => RejectedReason.notFound,
'refused' => RejectedReason.refused,
'temporary' => RejectedReason.temporary,
_ => RejectedReason.unknown,
},
);
}
}
/// 보낸 결과 — `POST /api/messages` 201 (보낸편지함의 메일 id · 받지 못한 사람 · 붙잡아 둔 발송)
@immutable
class SentMessage {
const SentMessage({
required this.id,
this.rejectedRecipients = const <RejectedRecipient>[],
this.pendingSend,
});
final String id;
final List<RejectedRecipient> rejectedRecipients;
/// 보내기 취소를 기다리는 중이면 그 정보 — 바로 보냈으면 null
final PendingSend? pendingSend;
static SentMessage? tryParse(Object? json) {
if (json is! Map<String, Object?>) return null;
final Object? id = json['id'];
if (id is! String || id.isEmpty) return null;
final Object? rejected = json['rejectedRecipients'];
return SentMessage(
id: id,
rejectedRecipients: rejected is List<Object?>
? List<RejectedRecipient>.unmodifiable(<RejectedRecipient>[
for (final Object? item in rejected)
?RejectedRecipient.tryParse(item),
])
: const <RejectedRecipient>[],
pendingSend: PendingSend.tryParse(json['pendingSend']),
);
}
}
/// 내 서명 — `GET /api/mail-signature` (계약 2절 "서명")
@immutable
class MailSignature {
const MailSignature({
this.bodyHtml,
this.body,
this.isForNew = true,
this.isForReply = true,
});
/// 서명 서식(서버가 정화한 HTML — 서명 그림 `<img src="/api/mail-signature/images/{id}">` 포함)
final String? bodyHtml;
/// 텍스트 서명 — 텍스트 대체 본문에 넣는다
final String? body;
/// 새 메일에 넣는다
final bool isForNew;
/// 답장 · 전달에 넣는다(인용 위)
final bool isForReply;
static MailSignature? tryParse(Object? json) {
if (json is! Map<String, Object?>) return null;
final Object? bodyHtml = json['bodyHtml'];
final Object? body = json['body'];
final Object? isForNew = json['isForNew'];
final Object? isForReply = json['isForReply'];
if ((bodyHtml != null && bodyHtml is! String) ||
(body != null && body is! String) ||
isForNew is! bool ||
isForReply is! bool) {
return null;
}
return MailSignature(
bodyHtml: bodyHtml as String?,
body: body as String?,
isForNew: isForNew,
isForReply: isForReply,
);
}
}
/// 보내기 취소 시간 — `GET /api/mail-settings` 의 `undoSendSeconds`(0 · 5 · 10 · 20 · 30). 모양이 틀리면 null
int? undoSendSecondsOf(Object? json) {
if (json is! Map<String, Object?>) return null;
final Object? seconds = json['undoSendSeconds'];
if (seconds is! num || seconds < 0) return null;
return seconds.toInt();
}
@@ -0,0 +1,81 @@
import 'package:arasystem_app/core/people/person_address.dart';
import 'package:flutter/foundation.dart';
/// 보낼 첨부 하나 — 발송 · 임시 저장 본문의 `attachments[]` (`{ id, name }` — 전달 원본 · 저장본의 첨부는 그 id 를 그대로)
typedef ComposeAttachmentRef = ({String id, String name});
/// 발송 · 임시 저장 본문 — 계약 2절 "발송 본문" · "임시 저장"(같은 칸, 저장은 받는 사람 0명도 되고 `draftId` 가 없다) ·
/// 4절 "발송 · 임시 저장과 함께"(`largeFileIds` · `bodyQuoteStart`).
///
/// 비어 있는 선택 칸은 싣지 않는다(서버는 빠진 칸과 null 을 같게 보지만 계약의 예처럼 뺀다).
@immutable
class MailComposePayload {
const MailComposePayload({
required this.to,
required this.cc,
required this.bcc,
required this.subject,
required this.body,
this.bodyHtml,
this.attachments = const <ComposeAttachmentRef>[],
this.inReplyTo,
this.draftId,
this.largeFileIds = const <String>[],
this.bodyQuoteStart,
});
final List<PersonAddress> to;
final List<PersonAddress> cc;
final List<PersonAddress> bcc;
final String subject;
/// 텍스트 대체 본문
final String body;
/// 서식 본문 — null 이면 텍스트 메일
final String? bodyHtml;
final List<ComposeAttachmentRef> attachments;
/// 답장할 메일 id — 답장만(전달에는 싣지 않는다)
final String? inReplyTo;
/// 이어 쓴 저장본 — 보내기만(보낸 뒤 서버가 지운다)
final String? draftId;
/// 이 메일에 싣는 내 대용량 파일 — 저장본에 이미 있던 것(앱은 첫 판에서 새로 올리지 않는다)
final List<String> largeFileIds;
/// 텍스트 본문에서 원문 인용이 시작하는 자리 — 대용량 파일이 있을 때만(서버가 덩이를 넣을 자리)
final int? bodyQuoteStart;
/// 같은 내용의 저장 본문 — `draftId` 를 뺀다(보내지 못한 메일을 저장본으로 남길 때)
MailComposePayload withoutDraftId() => MailComposePayload(
to: to,
cc: cc,
bcc: bcc,
subject: subject,
body: body,
bodyHtml: bodyHtml,
attachments: attachments,
inReplyTo: inReplyTo,
largeFileIds: largeFileIds,
bodyQuoteStart: bodyQuoteStart,
);
Map<String, Object?> toJson() => <String, Object?>{
'to': <Object?>[for (final PersonAddress a in to) a.toJson()],
'cc': <Object?>[for (final PersonAddress a in cc) a.toJson()],
'bcc': <Object?>[for (final PersonAddress a in bcc) a.toJson()],
'subject': subject,
'body': body,
'bodyHtml': ?bodyHtml,
'attachments': <Object?>[
for (final ComposeAttachmentRef attachment in attachments)
<String, Object?>{'id': attachment.id, 'name': attachment.name},
],
'inReplyTo': ?inReplyTo,
'draftId': ?draftId,
if (largeFileIds.isNotEmpty) 'largeFileIds': largeFileIds,
'bodyQuoteStart': ?bodyQuoteStart,
};
}
@@ -106,12 +106,14 @@ class MessageLargeFile {
///
/// 목록 칸([summary])과 읽기 칸을 함께 둔다. 9단계 뒤에 더해진 칸(`imagesShown` · `isHtmlBodyOmitted` · `isBodyTruncated` ·
/// `largeFiles` · 첨부의 `isInline`)은 없으면 거짓 · 빈 목록으로 읽는다(웹과 같다). 대용량 첨부는 하나가 틀려도 그것만 뺀다.
/// `replyTo` 도 없으면 빈 목록이다(쓰기 화면의 답장 받는 사람이 쓴다).
@immutable
class MessageDetail {
const MessageDetail({
required this.summary,
this.cc = const <MailAddress>[],
this.bcc = const <MailAddress>[],
this.replyTo = const <MailAddress>[],
this.sentAt,
this.htmlBody,
this.textBody,
@@ -129,6 +131,9 @@ class MessageDetail {
/// 숨은참조 — 내가 보낸 메일에만 온다
final List<MailAddress> bcc;
/// 답장 받을 주소(`Reply-To`) — 없으면 빈 목록. 답장은 있으면 이 주소로 간다(쓰기 화면 — 웹 `buildReplyRecipients`)
final List<MailAddress> replyTo;
final DateTime? sentAt;
/// 서버가 정화한 HTML — 앱은 한 번 더 정화한다(`mail_html.dart`)
@@ -162,6 +167,7 @@ class MessageDetail {
summary: next,
cc: cc,
bcc: bcc,
replyTo: replyTo,
sentAt: sentAt,
htmlBody: htmlBody,
textBody: textBody,
@@ -178,6 +184,7 @@ class MessageDetail {
summary: summary,
cc: cc,
bcc: bcc,
replyTo: replyTo,
sentAt: sentAt,
htmlBody: other.htmlBody,
textBody: other.textBody,
@@ -196,6 +203,7 @@ class MessageDetail {
if (summary == null) return null;
final List<MailAddress>? cc = _addresses(json['cc']);
final List<MailAddress>? bcc = _addresses(json['bcc']);
final List<MailAddress>? replyTo = _addresses(json['replyTo']);
final Object? sentAt = json['sentAt'];
final Object? htmlBody = json['htmlBody'];
final Object? textBody = json['textBody'];
@@ -209,6 +217,7 @@ class MessageDetail {
final DateTime? sent = sentAt is String ? DateTime.tryParse(sentAt) : null;
if (cc == null ||
bcc == null ||
replyTo == null ||
(sentAt != null && sent == null) ||
(htmlBody != null && htmlBody is! String) ||
(textBody != null && textBody is! String) ||
@@ -223,6 +232,7 @@ class MessageDetail {
summary: summary,
cc: cc,
bcc: bcc,
replyTo: replyTo,
sentAt: sent?.toUtc(),
htmlBody: htmlBody as String?,
textBody: textBody as String?,
@@ -0,0 +1,265 @@
import 'dart:typed_data';
import 'package:arasystem_app/core/api/api_error_mapper.dart';
import 'package:arasystem_app/core/api/request_flags.dart';
import 'package:arasystem_app/core/api/server_time.dart';
import 'package:arasystem_app/core/people/recipient_suggestion.dart';
import 'package:arasystem_app/features/mail/data/models/compose_models.dart';
import 'package:arasystem_app/features/mail/data/models/compose_payload.dart';
import 'package:arasystem_app/features/mail/data/models/mail_page.dart';
import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart';
import 'package:dio/dio.dart';
/// 쓰기 화면의 API 경로 — baseUrl(`…/api`) 뒤에 붙는다(계약 2 · 3 · 4절). 경로는 대소문자를 가린다
abstract final class ComposeApiPaths {
static const String drafts = '/drafts';
/// 임시 저장본 하나 — id 는 경로 칸으로 인코딩한다
static String draft(String id) => '$drafts/${Uri.encodeComponent(id)}';
static const String recipientSuggestions = '/recipient-suggestions';
static const String contactGroups = '/contact-groups';
/// 그룹 구성원 — `GET /api/contacts?groupId=`
static const String contacts = '/contacts';
static const String mailSignature = '/mail-signature';
/// 서명 그림 — 본인 그림만 내준다(표준 래퍼 없는 그림)
static String signatureImage(String id) =>
'$mailSignature/images/${Uri.encodeComponent(id)}';
static const String mailSettings = '/mail-settings';
/// 대용량 파일 하나의 상태 — 발송이 `MAIL_016` 이면 파일마다 다시 묻는다(계약 4절)
static String fileUpload(String id) =>
'/file-uploads/${Uri.encodeComponent(id)}';
}
/// 발송 · 저장의 받기 한도 — 서버가 55~56초 안에 답한다(계약 2절). 기본 한도(15초)로 끊으면 화면은 실패로 보는데 서버는 이미 보냈을 수 있다
/// (웹 `MAIL_SEND_TIMEOUT_MS` 와 같은 60초)
const Duration kMailSendTimeout = Duration(seconds: 60);
/// 중복 발송 방지 키 머리 (계약 2절)
const String kIdempotencyHeader = 'Idempotency-Key';
/// 처음 받아 두는 추천 수 — 계약의 최대 limit (웹 `RECIPIENT_SUGGESTION_PRELOAD`)
const int kRecipientSuggestionPreload = 100;
/// 주소록 쪽 크기 — 그룹 목록 · 구성원 (웹 `CONTACTS_PAGE_SIZE`)
const int kContactsPageSize = 100;
/// 구성원을 받는 최대 쪽 수 — 100명씩 10쪽(그룹 하나의 구성원은 500명까지 — 웹 `CONTACT_GROUP_MEMBER_PAGES_MAX`)
const int kGroupMemberPagesMax = 10;
/// 받는 사람 추천 한 쪽
typedef RecipientSuggestionPage = ({
List<RecipientSuggestion> items,
bool hasNext,
});
/// 쓰기 화면의 API — 계약 2절(임시 저장 · 발송 · 서명 · 받는 사람 추천 · 보내기 설정) · 3절(그룹 · 구성원) · 4절(대용량 파일 상태).
///
/// 응답 언래핑 · 에러 코드 · 알림은 인터셉터가 맡는다 — 여기서는 읽기만 하고 문구를 만들지 않는다.
/// 실패는 늘 `DioException`(`error` 에 `ApiException`)이다 — 응답 모양이 계약과 다르면 `SYS_001`.
class ComposeRepository {
const ComposeRepository(this._dio);
final Dio _dio;
/// 임시 저장본 — 알림은 띄우지 않는다(받지 못하면 화면이 [다시 시도]를 그린다)
Future<Draft> getDraft(String id) async {
final Response<Object?> response = await _dio.get<Object?>(
ComposeApiPaths.draft(id),
options: ApiOptions.silent(),
);
final Draft? draft = Draft.tryParse(response.data);
if (draft == null) throw _malformed(response);
return draft;
}
/// 새 저장본 — 자동 저장은 알리지 않는다([silent]). 받기 한도는 발송과 같다
Future<DraftSaved> createDraft(
MailComposePayload payload, {
bool silent = true,
}) async {
final Response<Object?> response = await _dio.post<Object?>(
ComposeApiPaths.drafts,
data: payload.toJson(),
options: _longOptions(silent: silent),
);
return _saved(response);
}
/// 저장본 바꾸기 — 응답의 새 id 로 바꿔 쓴다. 다른 곳에서 바뀌었으면 409 `MAIL_008`, 초안이 아니면 404 `RES_001`
Future<DraftSaved> updateDraft(
String id,
MailComposePayload payload, {
bool silent = true,
}) async {
final Response<Object?> response = await _dio.put<Object?>(
ComposeApiPaths.draft(id),
data: payload.toJson(),
options: _longOptions(silent: silent),
);
return _saved(response);
}
/// 보낸다 — 같은 메일을 다시 보낼 때도 같은 [idempotencyKey]. 화면이 칸에 보이는 코드([silentCodes])는 알리지 않는다.
/// 응답의 서버 시각(`Date`)도 함께 준다 — 보내기 취소 알림의 마감을 서버 시계로 잰다
Future<({SentMessage sent, DateTime? serverTime})> sendMessage(
MailComposePayload payload, {
required String idempotencyKey,
Set<String> silentCodes = const <String>{},
}) async {
final Response<Object?> response = await _dio.post<Object?>(
MailApiPaths.messages,
data: payload.toJson(),
options: Options(
receiveTimeout: kMailSendTimeout,
headers: <String, Object?>{kIdempotencyHeader: idempotencyKey},
extra: <String, Object?>{kSilentExtraKey: silentCodes},
),
);
final SentMessage? sent = SentMessage.tryParse(response.data);
if (sent == null) throw _malformed(response);
return (sent: sent, serverTime: serverTimeOf(response));
}
/// 내 서명 — 알리지 않는다(서명 없이 쓴다)
Future<MailSignature> getSignature() async {
final Response<Object?> response = await _dio.get<Object?>(
ComposeApiPaths.mailSignature,
options: ApiOptions.silent(),
);
final MailSignature? signature = MailSignature.tryParse(response.data);
if (signature == null) throw _malformed(response);
return signature;
}
/// 서명 그림 하나(바이트) — 알리지 않는다(그 그림을 숨긴다). [maxBytes] 를 넘게 오면 끊는다
Future<Uint8List> fetchSignatureImage(
String id, {
required int maxBytes,
}) async {
final CancelToken cancel = CancelToken();
final Response<List<int>> response = await _dio.get<List<int>>(
ComposeApiPaths.signatureImage(id),
options: Options(
responseType: ResponseType.bytes,
extra: <String, Object?>{kSilentExtraKey: true},
),
cancelToken: cancel,
onReceiveProgress: (int received, int total) {
if (received > maxBytes || total > maxBytes) cancel.cancel();
},
);
final List<int>? data = response.data;
if (data == null) {
throw apiFailure(response.requestOptions, kFallbackCode);
}
return data is Uint8List ? data : Uint8List.fromList(data);
}
/// 받는 사람 추천 — 보조 정보라 알리지 않는다. [query] 가 있으면 서버가 같은 맞추기로 거른다(계약 2절)
Future<RecipientSuggestionPage> listRecipientSuggestions({
String? query,
int limit = kRecipientSuggestionPreload,
CancelToken? cancelToken,
}) async {
final Response<Object?> response = await _dio.get<Object?>(
ComposeApiPaths.recipientSuggestions,
queryParameters: <String, Object?>{'q': ?query, 'limit': limit},
options: ApiOptions.silent(),
cancelToken: cancelToken,
);
final Object? data = response.data;
final Object? items = data is Map<String, Object?> ? data['items'] : null;
final MailPageInfo? pageInfo = data is Map<String, Object?>
? MailPageInfo.tryParse(data['pageInfo'])
: null;
if (items is! List<Object?> || pageInfo == null) throw _malformed(response);
return (
items: List<RecipientSuggestion>.unmodifiable(<RecipientSuggestion>[
for (final Object? item in items) ?RecipientSuggestion.tryParse(item),
]),
hasNext: pageInfo.hasNext,
);
}
/// 내 주소록 그룹(한 쪽 — 100개까지) — 알리지 않는다(그룹 줄 없이 주소 추천만)
Future<List<ContactGroupSummary>> listContactGroups() async {
final Response<Object?> response = await _dio.get<Object?>(
ComposeApiPaths.contactGroups,
queryParameters: <String, Object?>{'limit': kContactsPageSize},
options: ApiOptions.silent(),
);
final MailPage<ContactGroupSummary>? page =
MailPage.tryParse<ContactGroupSummary>(
response.data,
ContactGroupSummary.tryParse,
);
if (page == null) throw _malformed(response);
return page.items;
}
/// 그룹 구성원 — 100명씩 다음 쪽까지(최대 [kGroupMemberPagesMax] 쪽). 고른 일이라 실패는 인터셉터가 알린다
Future<List<GroupMember>> listGroupMembers(String groupId) async {
final List<GroupMember> members = <GroupMember>[];
String? cursor;
for (int page = 0; page < kGroupMemberPagesMax; page++) {
final Response<Object?> response = await _dio.get<Object?>(
ComposeApiPaths.contacts,
queryParameters: <String, Object?>{
'groupId': groupId,
'limit': kContactsPageSize,
'cursor': ?cursor,
},
);
final MailPage<GroupMember>? result = MailPage.tryParse<GroupMember>(
response.data,
groupMemberOf,
);
if (result == null) throw _malformed(response);
members.addAll(result.items);
if (!result.pageInfo.hasMore) break;
cursor = result.pageInfo.nextCursor;
}
return members;
}
/// 보내기 취소 시간(초) — 알리지 않는다(알림의 마감을 다른 길로 센다)
Future<int> undoSendSeconds() async {
final Response<Object?> response = await _dio.get<Object?>(
ComposeApiPaths.mailSettings,
options: ApiOptions.silent(),
);
final int? seconds = undoSendSecondsOf(response.data);
if (seconds == null) throw _malformed(response);
return seconds;
}
/// 대용량 파일 하나의 지금 상태 — 없으면 404 `RES_001`(지웠거나 기간이 지남). 알리지 않는다
Future<DraftLargeFile> getLargeFile(String id) async {
final Response<Object?> response = await _dio.get<Object?>(
ComposeApiPaths.fileUpload(id),
options: ApiOptions.silent(),
);
final DraftLargeFile? file = DraftLargeFile.tryParse(response.data);
if (file == null) throw _malformed(response);
return file;
}
Options _longOptions({required bool silent}) => Options(
receiveTimeout: kMailSendTimeout,
extra: <String, Object?>{if (silent) kSilentExtraKey: true},
);
DraftSaved _saved(Response<Object?> response) {
final DraftSaved? saved = DraftSaved.tryParse(response.data);
if (saved == null) throw _malformed(response);
return saved;
}
DioException _malformed(Response<Object?> response) =>
apiFailure(response.requestOptions, kFallbackCode);
}
@@ -0,0 +1,362 @@
/// 앱이 만드는 보내는 본문 — 서식 본문(`bodyHtml`)과 같은 내용의 텍스트 대체 본문(`body`) (순수 함수 · 계약 2절 "서식 본문").
///
/// **짝: 웹 `frontend/src/components/mail/editor/mail-serialize.ts`** — [MailHtmlStyles] 는 `MAIL_HTML_STYLES` 와 글자까지 같고,
/// 감싸는 div 의 글꼴 · 크기 · 줄 간격은 `mail-editor.ts` 의 `MAIL_BODY_FONT_FAMILY` · `_SIZE_PX` · `_LINE_HEIGHT`, 공백은 `preserveSpaces` 와 같은 nbsp 바꾸기,
/// 텍스트는 `docToText` 와 같은 꼴(원문 인용 줄 "> " · 머리 줄 "항목: 값" · 머리 뒤 빈 줄 · 끝의 빈 줄 빼기)이다. 한쪽을 고치면 둘 다.
///
/// 앱이 만드는 서식 본문은 고정 모양 셋뿐이다(서식 편집은 다음 판):
/// ```html
/// <div style="font-family: …; font-size: 14px; line-height: 1.75">
/// <p style="margin: 0">내 글 한 줄</p>… ← 빈 줄은 <p style="margin: 0"><br></p>
/// <p style="margin: 0"><br></p><div>서명</div> ← 서명 경계 div (속성 없음 — 서버 정화가 data-* 를 지운다)
/// <blockquote style="margin: 24px 0 0; …"> ← 원본 메일 — 웹이 다시 열 때 isMailQuoteBlock 으로 알아본다
/// <p style="margin: 0; font-size: 12.5px"><strong>원본 메일</strong></p><table …>머리</table>
/// <p style="margin: 0">원문 한 줄</p>…(원문 속 인용은 안쪽 blockquote)
/// </blockquote>
/// </div>
/// ```
library;
import 'package:arasystem_app/features/mail/domain/mail_html.dart';
import 'package:arasystem_app/features/mail/domain/mail_quote.dart';
import 'package:flutter/foundation.dart';
/// 본문 기본 글꼴 — 맑은 고딕 스택 (웹 `MAIL_BODY_FONT_FAMILY`)
const String kMailBodyFontFamily =
"'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif";
/// 감싸는 div 의 모양 — 글꼴 · 14px · 줄 간격 1.75 (웹 `toMailHtml` 과 같은 글)
const String kMailBodyWrapperStyle =
'font-family: $kMailBodyFontFamily; font-size: 14px; line-height: 1.75';
/// 받는 쪽에서도 편집기와 같은 모양이 되도록 넣는 인라인 style — 웹 `MAIL_HTML_STYLES` 와 같은 글(앱이 쓰는 것만 옮겼다 — 목록 · 표 · 가로줄은 앱이 만들지 않는다)
abstract final class MailHtmlStyles {
static const String _quoteBar =
'padding: 2px 0 2px 14px; border-left: 2px solid #D0D5DD; color: #4A5363';
static const String paragraph = 'margin: 0';
/// 원문 속 인용
static const String quote = 'margin: 0; $_quoteBar';
/// 원문 인용 — 내 글과 24 띄운다
static const String mailQuote = 'margin: 24px 0 0; $_quoteBar';
/// "원본 메일" — 문단 여백([paragraph])이 앞에 붙는다(웹 `applyMailStyles` 가 문단마다 앞에 넣는 차례와 같다)
static const String quoteHeading = 'font-size: 12.5px';
static const String quoteHeaderTable =
'margin: 0 0 12px; border-collapse: collapse; font-size: 12.5px; line-height: 1.6';
static const String quoteHeaderLabel =
'padding: 0 12px 0 0; color: #667085; vertical-align: top; white-space: nowrap';
static const String quoteHeaderValue = 'padding: 0; vertical-align: top';
}
/// 대용량 첨부의 인용 경계 표시 — 서버가 첫 표시 자리에 덩이를 넣고 표시는 지운다(계약 4절 "발송 · 임시 저장과 함께")
const String kQuoteStartMarker = '<div data-ara-quote-start=""></div>';
/// 텍스트 본문의 인용 줄 머리 (웹 `MAIL_QUOTE_LINE_PREFIX`)
const String kQuoteLinePrefix = '> ';
/// 인용 머리 표의 "항목: 값" (웹 `HEADER_SEPARATOR`)
const String _headerSeparator = ': ';
const String _nbsp = '\u00A0';
/// 탭이 닿는 칸 — 웹 편집 영역의 탭 간격(8)과 같다
const int kComposeTabStop = 8;
/// 여러 칸 띄어쓰기 · 줄 앞 공백(탭 포함)이 받는 쪽에서도 보이게 nbsp 로 바꾼다 — 웹 `preserveSpaces` 와 같다.
/// 줄 앞 공백과 공백 바로 뒤의 공백을 nbsp 로(보통 공백과 번갈아 — 긴 공백에서도 줄이 바뀐다), 탭은 다음 8칸 자리까지의 공백으로.
/// 단어 사이 공백 한 칸은 그대로다. 텍스트 대체 본문(`body`)에는 쓰지 않는다
String preserveSpaces(String line) {
final StringBuffer out = StringBuffer();
bool atLineStart = true;
bool afterSpace = false;
int column = 0;
void space() {
final bool keep = atLineStart || afterSpace;
out.write(keep ? _nbsp : ' ');
afterSpace = !keep;
atLineStart = false;
column++;
}
for (final int rune in line.runes) {
if (rune == 0x20) {
space();
} else if (rune == 0x09) {
final int width = kComposeTabStop - (column % kComposeTabStop);
for (int step = 0; step < width; step++) {
space();
}
} else {
out.writeCharCode(rune);
atLineStart = false;
afterSpace = false;
column++;
}
}
return out.toString();
}
/// 쓰기 화면의 원문 인용 — 머리 · 덩이 · 생략 표시
@immutable
class ComposeQuote {
const ComposeQuote({
required this.header,
required this.blocks,
required this.summary,
this.omitted = false,
this.rawHtml,
});
final List<QuoteHeaderRow> header;
final List<QuoteBlock> blocks;
/// 접힌 줄의 둘째 줄 — "이도윤 · 11월 5일 (목) 오전 10:42"
final String summary;
/// 끝에 생략 표시를 둔다 — 길어서 줄였거나 서버가 읽기 한도에서 잘라 보냈다
final bool omitted;
/// 그대로 실을 인용 HTML — 웹 · 다른 프로그램이 만든 저장본의 인용(서식을 지킨다). null 이면 [blocks] 로 다시 쓴다
final String? rawHtml;
/// 덩이를 바꾼 인용 — 원래 HTML 은 버린다(덩이로 다시 쓴다)
ComposeQuote withBlocks(List<QuoteBlock> next, {required bool omitted}) =>
ComposeQuote(
header: header,
blocks: next,
summary: summary,
omitted: omitted,
);
@override
bool operator ==(Object other) =>
other is ComposeQuote &&
listEquals(other.header, header) &&
listEquals(other.blocks, blocks) &&
other.summary == summary &&
other.omitted == omitted &&
other.rawHtml == rawHtml;
@override
int get hashCode => Object.hash(
Object.hashAll(header),
Object.hashAll(blocks),
summary,
omitted,
rawHtml,
);
}
/// 보낼 본문의 재료 — 내 글 · 서명 · 원문 인용
@immutable
class ComposeBodyInput {
const ComposeBodyInput({
required this.text,
this.signatureHtml,
this.signatureText,
this.quote,
this.quoteStartMark = false,
});
/// 내 글 — 칸에 쓴 그대로(줄바꿈은 \n)
final String text;
/// 서명 경계 div 안에 둘 서명 HTML — null 이면 서명 없음
final String? signatureHtml;
/// 서명의 텍스트 — 텍스트 대체 본문에 넣는다
final String? signatureText;
final ComposeQuote? quote;
/// 원문 인용 바로 앞에 인용 경계 표시를 둔다 — 대용량 파일이 있을 때(서버가 그 자리에 덩이를 넣는다)
final bool quoteStartMark;
ComposeBodyInput withQuote(ComposeQuote? next) => ComposeBodyInput(
text: text,
signatureHtml: signatureHtml,
signatureText: signatureText,
quote: next,
quoteStartMark: quoteStartMark,
);
}
/// 보낼 본문 — [html] 이 null 이면 텍스트 메일(쓴 글이 없을 때)
@immutable
class MailBodyOutput {
const MailBodyOutput({
required this.html,
required this.text,
this.quoteStart,
});
final String? html;
final String text;
/// 텍스트 본문에서 원문 인용이 시작하는 자리(JS 문자열 위치 — 0 이거나 바로 앞이 줄바꿈). 인용이 없으면 null
final int? quoteStart;
}
/// 쓴 글의 줄 — \r\n · \r 은 \n 으로
List<String> composeLinesOf(String text) =>
text.replaceAll('\r\n', '\n').replaceAll('\r', '\n').split('\n');
/// 보낼 본문을 만든다 — 모양은 위 머리말. 인용이 [ComposeQuote.rawHtml] 을 가지면 그것을 그대로 싣는다
MailBodyOutput buildMailBody(ComposeBodyInput input) {
final List<String> lines = composeLinesOf(input.text);
final String? signatureHtml = input.signatureHtml;
final ComposeQuote? quote = input.quote;
final bool tail = signatureHtml != null || quote != null;
// 뒤에 아무것도 없으면 끝의 빈 줄은 보내지 않는다(웹 trimTrailingEmptyParagraphs)
final List<String> htmlLines = tail ? lines : _withoutTrailingEmpty(lines);
final String? html = !tail && htmlLines.isEmpty
? null
: _bodyHtml(htmlLines, signatureHtml, quote, input.quoteStartMark);
final List<String> textLines = <String>[...lines];
if (signatureHtml != null) {
textLines.add('');
final String signatureText = input.signatureText ?? '';
if (signatureText.isNotEmpty) {
textLines.addAll(composeLinesOf(signatureText));
}
}
int? quoteLine;
if (quote != null) {
quoteLine = textLines.length;
textLines.addAll(quoteTextLines(quote));
}
final String text = textLines.join('\n').trimRight();
int? quoteStart;
if (quoteLine != null) {
int start = 0;
for (int index = 0; index < quoteLine; index++) {
start += textLines[index].length + 1;
}
quoteStart = start <= text.length ? start : null;
}
return MailBodyOutput(html: html, text: text, quoteStart: quoteStart);
}
List<String> _withoutTrailingEmpty(List<String> lines) {
int end = lines.length;
while (end > 0 && lines[end - 1].trim().isEmpty) {
end--;
}
return lines.sublist(0, end);
}
String _bodyHtml(
List<String> lines,
String? signatureHtml,
ComposeQuote? quote,
bool quoteStartMark,
) {
final StringBuffer out = StringBuffer('<div style="$kMailBodyWrapperStyle">');
for (final String line in lines) {
out.write(paragraphHtml(line));
}
if (signatureHtml != null) {
out
..write(paragraphHtml(''))
..write(signatureBlockHtml(signatureHtml));
}
if (quote != null) {
if (quoteStartMark) out.write(kQuoteStartMarker);
out.write(quote.rawHtml ?? quoteHtml(quote));
}
out.write('</div>');
return out.toString();
}
/// 문단 하나 — 빈 줄은 `<br>` 로 한 줄 높이를 지킨다
String paragraphHtml(String line) =>
'<p style="${MailHtmlStyles.paragraph}">${_lineHtml(line)}</p>';
String _lineHtml(String line) =>
line.isEmpty ? '<br>' : escapeHtmlText(preserveSpaces(line));
/// 서명 경계 div — 속성 없는 `<div>` 하나(계약 2절 "앱이 만드는 모양")
String signatureBlockHtml(String signatureHtml) => '<div>$signatureHtml</div>';
/// 원문 인용 HTML — "원본 메일" 머리 문단 + 머리 표 + 덩이 + (생략 표시)
String quoteHtml(ComposeQuote quote) {
final StringBuffer out = StringBuffer()
..write('<blockquote style="${MailHtmlStyles.mailQuote}">')
..write(quoteShellHeadHtml(quote.header));
for (final QuoteBlock block in quote.blocks) {
out.write(quoteBlockHtml(block));
}
if (quote.omitted) out.write(paragraphHtml(kQuoteOmittedMark));
out.write('</blockquote>');
return out.toString();
}
/// 인용 머리 — "원본 메일" 문단과 머리 표
String quoteShellHeadHtml(List<QuoteHeaderRow> header) {
final StringBuffer out = StringBuffer()
..write(
'<p style="${MailHtmlStyles.paragraph}; ${MailHtmlStyles.quoteHeading}"><strong>',
)
..write(escapeHtmlText(kQuoteHeading))
..write('</strong></p>')
..write('<table style="${MailHtmlStyles.quoteHeaderTable}"><tbody>');
for (final QuoteHeaderRow row in header) {
out
..write('<tr><td style="${MailHtmlStyles.quoteHeaderLabel}">')
..write(escapeHtmlText(preserveSpaces(row.label)))
..write('</td><td style="${MailHtmlStyles.quoteHeaderValue}">')
..write(escapeHtmlText(preserveSpaces(row.value)))
..write('</td></tr>');
}
out.write('</tbody></table>');
return out.toString();
}
/// 덩이 하나의 HTML — 글 줄은 문단, 원문 속 인용은 안쪽 blockquote
String quoteBlockHtml(QuoteBlock block) {
final StringBuffer out = StringBuffer();
void write(QuoteBlock item) {
switch (item) {
case QuoteLine(:final String text):
out.write(paragraphHtml(text));
case QuoteNest(:final List<QuoteBlock> children):
out.write('<blockquote style="${MailHtmlStyles.quote}">');
children.forEach(write);
out.write('</blockquote>');
}
}
write(block);
return out.toString();
}
/// 원문 인용의 텍스트 줄 — "> 원본 메일" · "> 항목: 값" · ">" · 원문 줄 · (생략 표시). 웹 `docToText` 의 인용과 같은 꼴
List<String> quoteTextLines(ComposeQuote quote) => <String>[
quotePrefixed(kQuoteHeading, 1),
for (final QuoteHeaderRow row in quote.header)
quotePrefixed('${row.label}$_headerSeparator${row.value}', 1),
quotePrefixed('', 1),
for (final QuoteBlock block in quote.blocks) ...quoteBlockTextLines(block, 1),
if (quote.omitted) quotePrefixed(kQuoteOmittedMark, 1),
];
/// 덩이 하나의 텍스트 줄 — [depth] 단의 인용 머리를 붙인다
List<String> quoteBlockTextLines(QuoteBlock block, int depth) {
final List<String> lines = <String>[];
void visit(QuoteBlock item, int level) {
switch (item) {
case QuoteLine(:final String text):
lines.add(quotePrefixed(text, level));
case QuoteNest(:final List<QuoteBlock> children):
for (final QuoteBlock child in children) {
visit(child, level + 1);
}
}
}
visit(block, depth);
return lines;
}
@@ -0,0 +1,422 @@
/// 임시 저장본 · 보내기 취소로 되돌린 메일을 다시 열 때의 본문 — 순수 함수 (계약 2절 "임시 저장").
///
/// - **앱이 만든 모양**(`compose_body.dart` 의 머리말): 감싸는 div(앱의 글꼴 style — 없어도 된다) 아래가
/// "글 · `<br>` 만 든 문단들 → div 하나(서명, 속성 없음 · 선택) → 원본 메일 인용(선택)"이면 내 글 칸 · 서명 칸 · 인용으로 되살린다.
/// 문단은 속성이 없거나 `style` 이 `margin:0` 하나뿐이어야 한다(서버의 보내는 정화가 띄어쓰기를 바꾸므로 공백을 빼고 견준다 —
/// 웹 저장본의 정렬 · 들여쓰기가 말없이 사라지지 않게). 서명 앞의 빈 문단 하나는 앱이 넣은 띄움이라 뺀다. nbsp 는 공백으로 되돌린다.
/// - **그 밖**(웹 · 다른 메일 프로그램이 만든 저장본): 원문 인용 앞부분을 글로 바꿔 본문 칸에 넣는다 — 서명은 그 글의 일부가 되고 [서명 빼기]는 없다.
/// 원래 HTML · 텍스트는 쥐고 있다가 본문을 고치지 않으면 그대로 다시 보낸다. 서식(글자 꾸밈 · 링크 · 목록 · 표 · 그림 · 문단의 정렬 등)이 있었으면
/// 열 때 안내 줄을 보인다. 원문 인용은 HTML 그대로 실어 보내고(서식을 지킨다), 화면에는 글로 보인다.
/// - 원문 인용은 웹과 같은 규칙으로 알아본다(`mail-extensions.ts` 의 `isMailQuoteBlock`): 맨 위 단계의 blockquote 이고 첫 자식이 "원본 메일" 문단, 바로 뒤가 표.
/// - **읽는 곳**([buildDraftBody]): 읽기 화면과 같은 한도 · 문턱이다 — 길이 · 중첩 비용 한도를 넘으면 읽지 않고 남의 저장본으로
/// ([oversizedDraftBody]), 길거나 깊으면 다른 isolate 에서, 짧고 얕으면 그 자리에서. 되풀이하는 `package:html` 의 `text` · `innerHtml` 등은
/// 쓰지 않는다(`html_nodes.dart`).
library;
import 'dart:isolate';
import 'package:arasystem_app/features/mail/domain/compose_body.dart';
import 'package:arasystem_app/features/mail/domain/compose_signature.dart';
import 'package:arasystem_app/features/mail/domain/html_nodes.dart';
import 'package:arasystem_app/features/mail/domain/mail_html.dart';
import 'package:arasystem_app/features/mail/domain/mail_quote.dart';
import 'package:flutter/foundation.dart';
import 'package:html/dom.dart' as dom;
import 'package:html/parser.dart' as html_parser;
/// 다시 연 본문
sealed class DraftBody {
const DraftBody();
}
/// 앱이 만든 모양 — 칸마다 되살린다
@immutable
final class AppDraftBody extends DraftBody {
const AppDraftBody({required this.text, this.signature, this.quote});
final String text;
/// 서명 경계 div 안의 서명 — 없으면 서명 없음
final ComposeSignature? signature;
final ComposeQuote? quote;
}
/// 웹 · 다른 프로그램이 만든 모양 — 인용 앞부분을 글로
@immutable
final class ForeignDraftBody extends DraftBody {
const ForeignDraftBody({
required this.text,
required this.originalText,
required this.hasFormatting,
this.originalHtml,
this.markedHtml,
this.quote,
});
/// 본문 칸에 넣을 글 — 인용 앞부분(서명 포함)
final String text;
/// 원래 서식 본문 — 본문을 고치지 않으면 그대로 보낸다
final String? originalHtml;
/// [originalHtml] 의 원문 인용 바로 앞에 인용 경계 표시를 넣은 것 — 대용량 파일이 있을 때 그대로 보낸다(읽을 때 한 번만 만든다).
/// 원문 인용이 없거나 읽지 않은 저장본이면 null
final String? markedHtml;
/// 원래 텍스트 본문
final String originalText;
/// 서식(글자 꾸밈 · 링크 · 목록 · 표 · 그림 · 문단의 정렬 등)이 있었다 — 안내 줄을 보인다
final bool hasFormatting;
/// 원문 인용 — [ComposeQuote.rawHtml] 에 원래 HTML
final ComposeQuote? quote;
}
/// 저장본 본문을 읽을 곳 — 읽기 화면과 같은 한도 · 문턱(`mail_html.dart`)
enum DraftBodyPlace {
/// 그 자리 — 짧고 얕다(서식 본문 6KB 미만 · 중첩 비용 5,000 이하, 또는 서식 본문이 없다)
inline,
/// 다른 isolate 에서 — 길거나 깊다
isolate,
/// 읽지 않는다 — 길이(2,000,000자) · 중첩 비용(8,000,000) 한도를 넘었다([oversizedDraftBody])
oversized,
}
/// 읽을 곳 — 비용은 한도를 넘는 순간 멈춰 세므로 고르는 일 자체는 짧다
DraftBodyPlace draftBodyPlaceOf({
required String? bodyHtml,
required String body,
}) {
final String? html = displayedHtmlBody(bodyHtml)?.trim();
if (html == null) return DraftBodyPlace.inline;
if (html.length > kMaxSanitizedHtmlLength ||
htmlNestingCost(html, limit: kMaxHtmlNestingCost) > kMaxHtmlNestingCost) {
return DraftBodyPlace.oversized;
}
return mailBodyBuildPlaceOf(htmlBody: html, textBody: body) ==
MailBodyBuildPlace.inline
? DraftBodyPlace.inline
: DraftBodyPlace.isolate;
}
/// 저장본 본문을 알맞은 곳에서 읽는다 — 위 [draftBodyPlaceOf]
Future<DraftBody> buildDraftBody({
required String? bodyHtml,
required String body,
}) async {
switch (draftBodyPlaceOf(bodyHtml: bodyHtml, body: body)) {
case DraftBodyPlace.inline:
return draftBodyOf(bodyHtml: bodyHtml, body: body);
case DraftBodyPlace.isolate:
return Isolate.run(() => draftBodyOf(bodyHtml: bodyHtml, body: body));
case DraftBodyPlace.oversized:
return oversizedDraftBody(html: bodyHtml!.trim(), body: body);
}
}
/// 한도를 넘는 저장본 — 읽지 않는다: 본문 칸은 텍스트 본문, 원래 HTML 은 쥐고 있다가 고치지 않으면 그대로 다시 보낸다(서식이 있다고 보고 안내한다)
ForeignDraftBody oversizedDraftBody({
required String html,
required String body,
}) => ForeignDraftBody(
text: body.trimRight(),
originalHtml: html,
originalText: body,
hasFormatting: true,
);
/// 저장본의 본문을 읽는다 — 서식 본문이 없으면 텍스트 그대로 본문 칸에(서식이 없어 잃을 것이 없다). 한도는 부르는 쪽이 본다([buildDraftBody])
DraftBody draftBodyOf({required String? bodyHtml, required String body}) {
final String? html = displayedHtmlBody(bodyHtml)?.trim();
if (html == null) return AppDraftBody(text: body.trimRight());
final dom.Element? root = html_parser.parse(html).body;
if (root == null) return AppDraftBody(text: body.trimRight());
final ({List<dom.Node> nodes, dom.Element? wrapper}) content = _contentOf(
root,
);
final bool appWrapper =
content.wrapper == null || _isAppWrapper(content.wrapper!);
if (appWrapper) {
final AppDraftBody? app = _appBodyOf(content.nodes);
if (app != null) return app;
}
return _foreignBodyOf(
root,
content.nodes,
originalHtml: html,
originalText: body,
formattedWrapper: !appWrapper,
);
}
/// 감싸는 div 하나면 그 안과 그 div, 아니면 body 의 자식
({List<dom.Node> nodes, dom.Element? wrapper}) _contentOf(dom.Element root) {
final List<dom.Node> meaningful = _meaningful(root.nodes);
if (meaningful.length == 1) {
final dom.Node only = meaningful.single;
if (only is dom.Element && only.localName == 'div') {
return (nodes: _meaningful(only.nodes), wrapper: only);
}
}
return (nodes: meaningful, wrapper: null);
}
/// 공백뿐인 글 · 주석은 뺀다
List<dom.Node> _meaningful(List<dom.Node> nodes) => <dom.Node>[
for (final dom.Node node in nodes)
if (node is dom.Element ||
(node is dom.Text && node.data.trim().isNotEmpty))
node,
];
/// 공백을 뺀 style 글 — 끝의 `;` 도 뺀다(서버 정화는 `margin:0` · 앱은 `margin: 0`)
String _compactStyle(String style) {
final String compact = style.replaceAll(RegExp(r'\s+'), '');
return compact.endsWith(';')
? compact.substring(0, compact.length - 1)
: compact;
}
final String _appWrapperStyle = _compactStyle(kMailBodyWrapperStyle);
final String _appParagraphStyle = _compactStyle(MailHtmlStyles.paragraph);
/// 감싸는 div 가 앱의 것인가 — 속성이 없거나 style 이 앱의 글꼴 · 크기 · 줄 간격과 같다
bool _isAppWrapper(dom.Element wrapper) {
final Map<Object, String> attributes = wrapper.attributes;
if (attributes.isEmpty) return true;
final String? style = attributes['style'];
return attributes.length == 1 &&
style != null &&
_compactStyle(style) == _appWrapperStyle;
}
/// 꾸밈 없는 문단 · 감싸개인가 — 문단은 속성이 없거나 `style` 이 `margin:0` 하나, div 는 속성이 없을 때, `<br>` 은 늘
bool _isPlainBlock(dom.Element element) {
final Map<Object, String> attributes = element.attributes;
switch (element.localName) {
case 'br':
return true;
case 'div':
return attributes.isEmpty;
case 'p':
if (attributes.isEmpty) return true;
final String? style = attributes['style'];
return attributes.length == 1 &&
style != null &&
_compactStyle(style) == _appParagraphStyle;
}
return false;
}
/// 앱이 만든 모양이면 칸마다 — 아니면 null
AppDraftBody? _appBodyOf(List<dom.Node> nodes) {
final List<String> lines = <String>[];
int index = 0;
while (index < nodes.length) {
final dom.Node node = nodes[index];
if (node is! dom.Element || node.localName != 'p') break;
if (!_isPlainBlock(node)) return null;
final List<String>? paragraph = _plainParagraphLines(node);
if (paragraph == null) return null;
lines.addAll(paragraph);
index++;
}
ComposeSignature? signature;
if (index < nodes.length) {
final dom.Node node = nodes[index];
if (node is dom.Element && node.localName == 'div' && !_isEmptyDiv(node)) {
// 앱의 서명 경계는 속성 없는 div 다 — 꾸민 div 는 앱 모양이 아니다
if (node.attributes.isNotEmpty) return null;
signature = composeSignatureFromHtml(innerHtmlOf(node));
// 서명 앞의 빈 문단 하나는 앱이 넣은 띄움이다
if (lines.isNotEmpty && lines.last.isEmpty) lines.removeLast();
index++;
}
}
// 인용 경계 표시(서버가 지운다 — 남았으면 빈 div)는 건너뛴다
while (index < nodes.length && _isEmptyDiv(nodes[index])) {
index++;
}
ComposeQuote? quote;
if (index < nodes.length) {
final dom.Node node = nodes[index];
if (node is! dom.Element || !isMailQuoteBlock(node)) return null;
// 원래 HTML 을 쥔다 — 웹이 만든 저장본이 이 모양이어도 인용의 서식을 잃지 않게(줄여야 할 때만 덩이로 다시 쓴다)
quote = composeQuoteOf(node, keepHtml: true);
index++;
}
if (index < nodes.length) return null;
return AppDraftBody(
text: lines.join('\n'),
signature: signature,
quote: quote,
);
}
bool _isEmptyDiv(dom.Node node) =>
node is dom.Element &&
node.localName == 'div' &&
_meaningful(node.nodes).isEmpty;
/// 글 · `<br>` 만 든 문단의 줄 — 다른 태그가 있으면 null. 끝의 `<br>` 은 줄을 더하지 않는다(`<p><br></p>` 는 빈 줄 하나)
List<String>? _plainParagraphLines(dom.Element paragraph) {
final StringBuffer current = StringBuffer();
final List<String> lines = <String>[];
for (final dom.Node child in paragraph.nodes) {
if (child is dom.Text) {
current.write(child.data.replaceAll(' ', ' '));
} else if (child is dom.Element && child.localName == 'br') {
lines.add(current.toString());
current.clear();
} else if (child is dom.Element || child is! dom.Comment) {
return null;
}
}
final String rest = current.toString();
if (rest.isNotEmpty || lines.isEmpty) lines.add(rest);
return lines;
}
/// 웹 · 다른 프로그램의 모양 — 맨 위 단계의 첫 원문 인용을 찾고, 그 앞을 글로. 인용 뒤에 다른 것이 있으면 인용으로 보지 않는다(모두 글로).
/// 원문 인용이 있으면 그 앞에 인용 경계 표시를 넣은 HTML 도 이때 한 번 만든다([ForeignDraftBody.markedHtml])
ForeignDraftBody _foreignBodyOf(
dom.Element root,
List<dom.Node> nodes, {
required String originalHtml,
required String originalText,
required bool formattedWrapper,
}) {
int quoteIndex = -1;
for (int index = 0; index < nodes.length; index++) {
final dom.Node node = nodes[index];
if (node is dom.Element && isMailQuoteBlock(node)) {
quoteIndex = index;
break;
}
}
if (quoteIndex >= 0 && quoteIndex != nodes.length - 1) quoteIndex = -1;
final List<dom.Node> before = quoteIndex < 0
? nodes
: nodes.sublist(0, quoteIndex);
final List<QuoteBlock> blocks = quoteBlocksFromNodes(before);
final ComposeQuote? quote = quoteIndex < 0
? null
: composeQuoteOf(nodes[quoteIndex] as dom.Element, keepHtml: true);
String? markedHtml;
if (quoteIndex >= 0) {
// 다른 것을 다 읽은 뒤에 표시를 넣는다(문서를 바꾸므로)
final dom.Node blockquote = nodes[quoteIndex];
blockquote.parentNode?.insertBefore(
dom.Element.tag('div')..attributes['data-ara-quote-start'] = '',
blockquote,
);
markedHtml = innerHtmlOf(root);
}
return ForeignDraftBody(
text: plainLinesOf(blocks).join('\n'),
originalHtml: originalHtml,
markedHtml: markedHtml,
originalText: originalText,
hasFormatting: formattedWrapper || _hasFormatting(before),
quote: quote,
);
}
/// 서식이 있는가 — 꾸밈 없는 문단 · 감싸개 · 줄바꿈([_isPlainBlock])이 아닌 요소(글자 꾸밈 · 링크 · 목록 · 인용 · 표 · 그림 · 정렬한 문단 등)
bool _hasFormatting(List<dom.Node> nodes) {
final List<dom.Node> stack = <dom.Node>[...nodes];
while (stack.isNotEmpty) {
final dom.Node node = stack.removeLast();
if (node is! dom.Element) continue;
if (!_isPlainBlock(node)) return true;
stack.addAll(node.nodes);
}
return false;
}
/// 웹과 같은 원문 인용 판정 — 첫 자식이 "원본 메일" 문단이고 바로 뒤가 표(맨 위 단계에서만 부른다)
bool isMailQuoteBlock(dom.Element element) {
if (element.localName != 'blockquote') return false;
final List<dom.Element> children = element.children;
if (children.length < 2) return false;
final dom.Element first = children[0];
return first.localName == 'p' &&
nodeTextOf(first).trim() == kQuoteHeading &&
children[1].localName == 'table';
}
/// 원문 인용 요소 → 쓰기 화면의 인용 — 머리 표의 칸 · 그 뒤의 덩이. [keepHtml] 이면 원래 HTML 을 쥔다(그대로 다시 싣는다)
ComposeQuote composeQuoteOf(dom.Element blockquote, {required bool keepHtml}) {
final List<dom.Element> children = blockquote.children;
final dom.Element table = children[1];
final List<QuoteHeaderRow> header = <QuoteHeaderRow>[
for (final dom.Element row in descendantElements(table, 'tr'))
if (_cellsOf(row) case [final String label, final String value, ...])
QuoteHeaderRow(label, value),
];
// 머리 표 뒤의 것 — 문단 · 안쪽 인용
final int tableIndex = blockquote.nodes.indexOf(table);
final List<QuoteBlock> blocks = quoteBlocksFromNodes(
blockquote.nodes.sublist(tableIndex + 1),
);
bool omitted = false;
if (blocks.isNotEmpty &&
blocks.last is QuoteLine &&
(blocks.last as QuoteLine).text.trim() == kQuoteOmittedMark) {
blocks.removeLast();
omitted = true;
}
return ComposeQuote(
header: List<QuoteHeaderRow>.unmodifiable(header),
blocks: List<QuoteBlock>.unmodifiable(blocks),
omitted: omitted,
summary: quoteSummaryOfHeader(header),
rawHtml: keepHtml ? outerHtmlOf(blockquote) : null,
);
}
List<String> _cellsOf(dom.Element row) => <String>[
for (final dom.Element cell in row.children)
if (cell.localName == 'td' || cell.localName == 'th')
nodeTextOf(cell).replaceAll(' ', ' ').trim(),
];
/// 보낸 사람 머리 줄의 이름 — `이도윤 <doyun@…>` → "이도윤" (이름이 없으면 주소)
String senderNameOf(String value) {
final int open = value.lastIndexOf('<');
if (open > 0 && value.endsWith('>')) {
final String name = value.substring(0, open).trim();
if (name.isNotEmpty) return name;
}
return value.trim();
}
/// 접힌 인용 줄의 둘째 줄 — 다시 연 저장본은 머리 표의 글로("이도윤 · 2026. 11. 5. (목) 오전 10:42")
String quoteSummaryOfHeader(List<QuoteHeaderRow> header) {
String? from;
String? date;
for (final QuoteHeaderRow row in header) {
if (row.label == kQuoteFromLabel) from ??= senderNameOf(row.value);
if (row.label == kQuoteDateLabel) date ??= row.value;
}
return <String>[?from, ?date].join(' · ');
}
/// 인용 머리의 칸 이름 — 웹 `mail-quote.ts` 와 같다
const String kQuoteFromLabel = '보낸 사람';
const String kQuoteDateLabel = '날짜';
const String kQuoteToLabel = '받는 사람';
const String kQuoteCcLabel = '참조';
const String kQuoteSubjectLabel = '제목';
/// 텍스트 본문에서 원문 인용이 시작하는 자리 — "> 원본 메일" 줄의 처음(웹 `docToText` 의 인용 머리 줄). 없으면 null
int? quoteStartInText(String text) {
final RegExpMatch? match = _quoteHeadingLine.firstMatch(text);
if (match == null) return null;
return match.start + (match.group(1)?.length ?? 0);
}
final RegExp _quoteHeadingLine = RegExp('(^|\n)> $kQuoteHeading(?=\n|\$)');
@@ -0,0 +1,229 @@
/// 답장 · 전달 초안을 계약 한도에 맞춘다 — 순수 함수 (계약 2절 "답장·전달 초안의 길이").
///
/// **짝: 웹 `frontend/src/components/mail/editor/fit-draft.ts`** — 같은 한도 · 같은 내 글 몫(5%) · 같은 생략 표시.
/// 텍스트 100,000자 · 서식 본문 500,000자(JS 문자열 길이 — Dart 도 UTF-16 길이다). 넘으면 원문 인용을 뒤에서 덜어 내고 끝에 생략 표시를 둔다.
/// - 처음 초안: 한도에서 내 글 몫(5% — 텍스트 5,000 · 서식 25,000)을 남기고 맞춘다 — 꽉 채우면 몇 글자만 써도 보낼 수 없다.
/// - 보내기 · 저장 직전: 한도 그대로 다시 잰다. 넘으면 인용을 더 줄인다. 내 글 + 서명 + 인용 머리만으로 넘으면 보내지 않는다(`MAIL_007`).
/// - 덩이 단위로 앞에서부터 담고, 넘친 덩이가 글 한 줄이면 들어가는 만큼 글자 단위로 담는다(짝 글자를 가르지 않는다).
/// 두 한도 안이면 JSON 본문은 2MB 안쪽이라 4MB 한도(발송 · 저장)는 따로 재지 않는다.
/// - 답장 · 전달을 열 때는 원문 → 덩이 만들기와 처음 맞추기를 한 곳에서 한다([buildInitialQuote] — 큰 원문은 다른 isolate 에서).
library;
import 'dart:isolate';
import 'package:arasystem_app/features/mail/domain/compose_body.dart';
import 'package:arasystem_app/features/mail/domain/mail_html.dart';
import 'package:arasystem_app/features/mail/domain/mail_quote.dart';
import 'package:flutter/foundation.dart';
/// 텍스트 본문 한도 (웹 `MAIL_BODY_MAX_LENGTH`)
const int kMailBodyTextMax = 100000;
/// 서식 본문 한도 (웹 `MAIL_BODY_HTML_MAX_LENGTH`)
const int kMailBodyHtmlMax = 500000;
/// 처음 초안을 맞출 때 내 글 몫으로 남기는 비율 (웹 `DRAFT_WRITING_RESERVE` — 한도의 5%)
const double kDraftWritingReserveRatio = 0.05;
/// 본문 한도
@immutable
class BodyLimits {
const BodyLimits({required this.text, required this.html});
final int text;
final int html;
/// 보내기 · 저장 — 한도 그대로
static const BodyLimits full = BodyLimits(
text: kMailBodyTextMax,
html: kMailBodyHtmlMax,
);
/// 처음 초안 — 내 글 몫을 뺀 만큼 (95,000 · 475,000)
static final BodyLimits initial = BodyLimits(
text:
kMailBodyTextMax -
(kMailBodyTextMax * kDraftWritingReserveRatio).round(),
html:
kMailBodyHtmlMax -
(kMailBodyHtmlMax * kDraftWritingReserveRatio).round(),
);
/// 텍스트 한도에서 [reserve] 를 뺀다 — 서버가 대용량 첨부 덩이를 넣은 뒤 다시 잰다(계약 4절)
BodyLimits reserveText(int reserve) =>
BodyLimits(text: text - reserve, html: html);
bool fits(MailBodyOutput output) =>
output.text.length <= text && (output.html?.length ?? 0) <= html;
}
/// 맞춘 결과 — [output] 이 null 이면 인용을 다 덜어도 넘는다(내 글만으로 넘음 — `MAIL_007`)
typedef BodyFit = ({MailBodyOutput? output, ComposeQuote? quote});
/// 본문을 [limits] 에 맞춘다 — 맞으면 그대로, 넘으면 인용을 줄인 본문과 줄인 인용
BodyFit fitMailBody(ComposeBodyInput input, BodyLimits limits) {
final MailBodyOutput whole = buildMailBody(input);
if (limits.fits(whole)) return (output: whole, quote: input.quote);
final ComposeQuote? quote = input.quote;
if (quote == null) return (output: null, quote: null);
// 머리와 생략 표시만 둔 본문이 쓰는 길이 — 남는 만큼 원문 덩이를 담는다
final ComposeBodyInput baseInput = input.withQuote(
quote.withBlocks(const <QuoteBlock>[], omitted: true),
);
final MailBodyOutput base = buildMailBody(baseInput);
if (!limits.fits(base)) return (output: null, quote: null);
int textBudget = limits.text - base.text.length;
int htmlBudget = limits.html - (base.html?.length ?? 0);
final List<QuoteBlock> kept = <QuoteBlock>[];
for (final QuoteBlock block in quote.blocks) {
final int textLength = _textLength(block);
final int htmlLength = quoteBlockHtml(block).length;
if (textLength <= textBudget && htmlLength <= htmlBudget) {
kept.add(block);
textBudget -= textLength;
htmlBudget -= htmlLength;
continue;
}
// 넘친 덩이가 글 한 줄이면 들어가는 만큼 담는다(긴 한 줄짜리 원문도 비지 않게)
if (block is QuoteLine) {
final QuoteLine? part = _cutLine(block, textBudget, htmlBudget);
if (part != null) kept.add(part);
}
break;
}
// 덩이마다 잰 값은 실제와 어긋나지 않지만(덩이는 서로 독립이다) 다시 재고, 넘치면 끝에서 하나씩 뺀다
ComposeQuote fitted = quote.withBlocks(
List<QuoteBlock>.unmodifiable(kept),
omitted: true,
);
MailBodyOutput output = buildMailBody(input.withQuote(fitted));
while (kept.isNotEmpty && !limits.fits(output)) {
kept.removeLast();
fitted = quote.withBlocks(
List<QuoteBlock>.unmodifiable(kept),
omitted: true,
);
output = buildMailBody(input.withQuote(fitted));
}
if (!limits.fits(output)) return (output: null, quote: null);
return (output: output, quote: fitted);
}
/// 답장 · 전달의 처음 인용을 만드는 데 드는 것 — 받은 본문 · 인용 머리 · 서명(처음 맞출 때 그 몫을 함께 잰다)
@immutable
class ComposeQuoteRequest {
const ComposeQuoteRequest({
required this.htmlBody,
required this.textBody,
required this.isBodyTruncated,
required this.header,
required this.summary,
this.signatureHtml,
this.signatureText,
});
final String? htmlBody;
final String? textBody;
/// 서버가 읽기 한도에서 잘라 보냈다 — 끝에 생략 표시
final bool isBodyTruncated;
final List<QuoteHeaderRow> header;
/// 접힌 줄의 둘째 줄
final String summary;
final String? signatureHtml;
final String? signatureText;
}
/// 처음 맞춘 인용 — 원문 → 덩이([quoteSourceOf]) → 내 글 몫(5%)을 남기고 맞춘다. 인용 머리만으로도 넘으면 덩이 없이 생략 표시만
ComposeQuote initialQuoteOf(ComposeQuoteRequest request) {
final QuoteSource source = quoteSourceOf(
htmlBody: request.htmlBody,
textBody: request.textBody,
isBodyTruncated: request.isBodyTruncated,
);
final ComposeQuote whole = ComposeQuote(
header: request.header,
blocks: source.blocks,
omitted: source.truncated,
summary: request.summary,
);
final BodyFit fit = fitMailBody(
ComposeBodyInput(
text: '',
signatureHtml: request.signatureHtml,
signatureText: request.signatureText,
quote: whole,
),
BodyLimits.initial,
);
return fit.quote ?? whole.withBlocks(const <QuoteBlock>[], omitted: true);
}
/// [initialQuoteOf] 를 알맞은 곳에서 — 길거나 깊은 원문은 다른 isolate 에서(읽기 화면의 정화와 같은 문턱 — `mailBodyBuildPlaceOf`)
Future<ComposeQuote> buildInitialQuote(ComposeQuoteRequest request) async {
final MailBodyBuildPlace place = mailBodyBuildPlaceOf(
htmlBody: request.htmlBody,
textBody: request.textBody,
);
if (place == MailBodyBuildPlace.inline) return initialQuoteOf(request);
return Isolate.run(() => initialQuoteOf(request));
}
/// 덩이 하나가 텍스트 본문에 더하는 길이 — 줄마다 인용 머리 · 줄바꿈
int _textLength(QuoteBlock block) {
int length = 0;
for (final String line in quoteBlockTextLines(block, 1)) {
length += line.length + 1;
}
return length;
}
/// 글 한 줄을 두 예산 안에 드는 만큼 앞에서 자른다 — 하나도 안 들면 null. 짝 글자(이모지 등) 가운데는 자르지 않는다.
/// 한 번 훑으며 글자마다 더해지는 길이를 센다(자를 때마다 전체를 다시 만들지 않는다):
/// 텍스트는 "> " + 글 + 줄바꿈, 서식 본문은 문단 태그 + 공백 바꾸기(탭은 다음 칸까지 — [preserveSpaces]) · 이스케이프(`&` 5 · `<` `>` 4)
QuoteLine? _cutLine(QuoteLine line, int textBudget, int htmlBudget) {
final String text = line.text;
// 문단 태그의 길이 — 글 한 글자짜리 문단에서 그 글자를 뺀다
int html = paragraphHtml('x').length - 1;
int column = 0;
int best = 0;
int index = 0;
while (index < text.length) {
final int unit = text.codeUnitAt(index);
int units = 1;
int added;
if (unit == _tab) {
added = kComposeTabStop - column % kComposeTabStop;
column += added;
} else {
if (unit >= 0xD800 && unit <= 0xDBFF && index + 1 < text.length) {
final int next = text.codeUnitAt(index + 1);
if (next >= 0xDC00 && next <= 0xDFFF) units = 2;
}
added = switch (unit) {
_ampersand => 5,
_lessThan || _greaterThan => 4,
_ => units,
};
column++;
}
html += added;
final int end = index + units;
if (_quoteLineTextLength(end) > textBudget || html > htmlBudget) break;
best = end;
index = end;
}
return best == 0 ? null : QuoteLine(text.substring(0, best));
}
/// 글 [length] 자 한 줄이 인용 1단의 텍스트 본문에 더하는 길이 — "> " + 글 + 줄바꿈
int _quoteLineTextLength(int length) => kQuoteLinePrefix.length + length + 1;
const int _tab = 0x09;
const int _ampersand = 0x26;
const int _lessThan = 0x3C;
const int _greaterThan = 0x3E;
@@ -0,0 +1,183 @@
/// 답장 · 전달 초안 — 받는 사람 · 제목 · 인용 머리 (순수 함수).
///
/// **짝: 웹 `frontend/src/shared/utils/mail-reply.ts` · `mail-forward.ts` · `mail-quote.ts` · `mail-draft.ts`** — 같은 규칙을 옮겼다(한쪽을 고치면 둘 다).
/// - 답장: Reply-To 가 있으면 그 주소, 없으면 보낸 사람. 전체 답장은 거기에 원래 받는 사람(받는 사람 칸) · 원래 참조(참조 칸).
/// 어느 쪽이든 나 자신은 뺀다(주소는 앞뒤 공백 · 대소문자를 가리지 않는다). 내가 보낸 메일에 답장하면 원래 받는 사람에게(나에게만 보낸 메일이면 나에게).
/// - 제목: "Re: " · "Fwd: " — 이미 접두어("Re:" · "RE :" · "회신:" / "Fwd:" · "FW:" · "전달:")가 있으면 그대로, 255자에 맞춘다(짝 글자를 가르지 않는다).
/// - 인용 머리: 답장은 보낸 사람 · 날짜 · 제목, 전달은 보낸 사람 · 날짜 · 받는 사람 · 참조 · 제목. 날짜는 웹과 같은 글("2026. 11. 5. (목) 오전 10:42").
library;
import 'package:arasystem_app/core/people/address_input.dart';
import 'package:arasystem_app/core/people/person_address.dart';
import 'package:arasystem_app/core/utils/korean_format.dart';
import 'package:arasystem_app/core/utils/utf16_length.dart';
import 'package:arasystem_app/features/mail/data/models/mail_address.dart';
import 'package:arasystem_app/features/mail/data/models/message_detail.dart';
import 'package:arasystem_app/features/mail/domain/compose_draft_body.dart';
import 'package:arasystem_app/features/mail/domain/compose_request.dart';
import 'package:arasystem_app/features/mail/domain/mail_quote.dart';
/// 쓰기 종류 — 화면 이름이 갈린다
enum ComposeKind { newMail, reply, replyAll, forward }
/// 답장 접두어 (웹 `MAIL_REPLY_PREFIX`)
const String kReplyPrefix = 'Re: ';
/// 전달 접두어 (웹 `MAIL_FORWARD_PREFIX`)
const String kForwardPrefix = 'Fwd: ';
/// 제목이 없을 때 인용 머리의 제목 (웹 `NO_SUBJECT`)
const String kQuoteNoSubject = '(제목 없음)';
final RegExp _replyPrefix = RegExp(r'^\s*(?:re|회신)\s*:', caseSensitive: false);
final RegExp _forwardPrefix = RegExp(
r'^\s*(?:fwd?|전달)\s*:',
caseSensitive: false,
);
extension MailAddressPerson on MailAddress {
PersonAddress get person => PersonAddress(email: email, name: name);
}
String buildReplySubject(String subject) {
final String trimmed = subject.trim();
if (_replyPrefix.hasMatch(trimmed)) return trimmed;
return '$kReplyPrefix$trimmed'.trimRight();
}
String buildForwardSubject(String subject) {
final String trimmed = subject.trim();
if (_forwardPrefix.hasMatch(trimmed)) return trimmed;
return '$kForwardPrefix$trimmed'.trimRight();
}
/// 전달 제목인가 — 이어 쓰는 저장본의 쓰기 화면 이름을 정할 때
bool isForwardSubject(String subject) => _forwardPrefix.hasMatch(subject);
/// 제목 — 255자를 넘으면 자른다("Re: " · "Fwd: " 를 붙여 넘친 경우)
String fitDraftSubject(String subject) =>
truncateUtf16(subject, kComposeMaxSubject);
/// 답장 받는 사람 — 위 머리말의 규칙 (짝: 웹 `buildReplyRecipients`)
({List<PersonAddress> to, List<PersonAddress> cc}) buildReplyRecipients(
MessageDetail message,
String myEmail, {
required bool all,
}) {
final String me = normalizeEmail(myEmail);
bool notMe(PersonAddress address) => normalizeEmail(address.email) != me;
final PersonAddress? from = message.summary.from?.person;
final List<PersonAddress> originalTo = <PersonAddress>[
for (final MailAddress address in message.summary.to) address.person,
];
final List<PersonAddress> originalCc = <PersonAddress>[
for (final MailAddress address in message.cc) address.person,
];
if (from != null && me.isNotEmpty && !notMe(from)) {
// 내가 보낸 메일 — 그 메일을 받은 사람에게(Reply-To 는 보지 않는다)
final List<PersonAddress> recipients = uniqueAddresses(
originalTo,
).where(notMe).toList();
final List<PersonAddress> to = recipients.isNotEmpty
? recipients
: <PersonAddress>[from];
if (!all) return (to: to, cc: const <PersonAddress>[]);
return (to: to, cc: _ccOf(originalCc, to, notMe));
}
final List<PersonAddress> primary = message.replyTo.isNotEmpty
? <PersonAddress>[for (final MailAddress a in message.replyTo) a.person]
: <PersonAddress>[?from];
final List<PersonAddress> others = primary.where(notMe).toList();
// 답장할 주소가 나뿐이면(Reply-To 가 나) 원래 받는 사람에게
final List<PersonAddress> base = others.isNotEmpty
? others
: originalTo.where(notMe).toList();
if (!all) return (to: uniqueAddresses(base), cc: const <PersonAddress>[]);
final List<PersonAddress> to = uniqueAddresses(<PersonAddress>[
...base,
...originalTo,
]).where(notMe).toList();
return (to: to, cc: _ccOf(originalCc, to, notMe));
}
List<PersonAddress> _ccOf(
List<PersonAddress> cc,
List<PersonAddress> to,
bool Function(PersonAddress address) notMe,
) => uniqueAddresses(cc)
.where(
(PersonAddress address) =>
notMe(address) &&
!to.any((PersonAddress target) => sameAddress(target, address)),
)
.toList();
/// 인용 머리의 날짜 — "2026. 11. 5. (목) 오전 10:42" (웹 `formatMailDateTime` — ko-KR 의 연 · 월 · 일 · 요일 · 시각). 기기 시간대로
String formatQuoteDate(DateTime at) {
final DateTime local = at.toLocal();
return '${koreanNumericDate(local)} (${koreanWeekday(local)}) ${koreanTimeOfDay(local)}';
}
/// 답장 머리 — 보낸 사람 · 날짜 · 제목
List<QuoteHeaderRow> replyQuoteRows(MessageDetail message) => <QuoteHeaderRow>[
..._fromRows(message),
_dateRow(message),
_subjectRow(message),
];
/// 전달 머리 — 보낸 사람 · 날짜 · 받는 사람 · 참조 · 제목
List<QuoteHeaderRow> forwardQuoteRows(
MessageDetail message,
) => <QuoteHeaderRow>[
..._fromRows(message),
_dateRow(message),
if (message.summary.to.isNotEmpty)
QuoteHeaderRow(
kQuoteToLabel,
formatAddressList(message.summary.to.map((MailAddress a) => a.person)),
),
if (message.cc.isNotEmpty)
QuoteHeaderRow(
kQuoteCcLabel,
formatAddressList(message.cc.map((MailAddress a) => a.person)),
),
_subjectRow(message),
];
List<QuoteHeaderRow> _fromRows(MessageDetail message) {
final MailAddress? from = message.summary.from;
return from == null
? const <QuoteHeaderRow>[]
: <QuoteHeaderRow>[
QuoteHeaderRow(kQuoteFromLabel, formatAddress(from.person)),
];
}
QuoteHeaderRow _dateRow(MessageDetail message) => QuoteHeaderRow(
kQuoteDateLabel,
formatQuoteDate(message.sentAt ?? message.summary.receivedAt),
);
QuoteHeaderRow _subjectRow(MessageDetail message) => QuoteHeaderRow(
kQuoteSubjectLabel,
message.summary.subject.isEmpty ? kQuoteNoSubject : message.summary.subject,
);
/// 접힌 인용 줄의 둘째 줄 — "이도윤 · 11월 5일 (목) 오전 10:42"(보드 `App1Compose` — 올해가 아니면 연도까지)
String quoteSummaryOf(MessageDetail message, {required DateTime now}) {
final MailAddress? from = message.summary.from;
final DateTime local = (message.sentAt ?? message.summary.receivedAt)
.toLocal();
final String date = local.year == now.toLocal().year
? koreanScheduleTime(local)
: koreanFullDateTime(local);
return <String>[?from?.displayName, date].join(' · ');
}
/// 이어 쓰는 저장본의 쓰기 종류 — 답장할 메일이 있으면 답장, 제목이 전달이면 전달, 아니면 새 메일 (웹 `draftKindOf`)
ComposeKind draftKindOf({required String? inReplyTo, required String subject}) {
if (inReplyTo != null && inReplyTo.isNotEmpty) return ComposeKind.reply;
return isForwardSubject(subject) ? ComposeKind.forward : ComposeKind.newMail;
}
@@ -0,0 +1,145 @@
/// 보내기의 규칙 — 중복 발송 방지 키 · 받지 못한 사람 문장 · 보내기 취소 알림의 마감 · 첨부 사전 검사 (순수 함수).
///
/// 짝: 웹 `shared/utils/idempotency-key.ts`(`shouldRenewSendKey`) · `uuid.ts` · `mail-send-result.ts`(`partialSendMessage`) ·
/// `pending-send.ts`(`undoToastDeadline`) · `mail-attachment.ts`(`precheckAttachment`) — 같은 규칙을 옮겼다(한쪽을 고치면 둘 다).
library;
import 'dart:math' as math;
import 'package:arasystem_app/core/utils/korean_format.dart';
import 'package:arasystem_app/features/mail/data/models/compose_models.dart';
/// 보내기 한 번의 결과 — 성공은 이 글, 실패는 그 에러 코드 (웹 `SendOutcome`)
const String kSendOutcomeSent = 'sent';
/// 새 발송 키 — UUID v4(무작위 128비트 중 122비트, `Random.secure`). 새 의존성 없이 만든다
String createSendKey([math.Random? random]) {
final math.Random source = random ?? math.Random.secure();
final List<int> bytes = List<int>.generate(16, (_) => source.nextInt(256));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
final String hex = bytes
.map((int byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-${hex.substring(12, 16)}-'
'${hex.substring(16, 20)}-${hex.substring(20)}';
}
/// 보내기 결과를 보고 다음 보내기에 새 키를 써야 하는가 — 계약 2절 "중복 발송 방지".
/// 키를 바꾸는 것은 **성공과 `MAIL_005` 뿐**이다. 그 밖의 실패는 키를 그대로 둔다(나갔을 수 있다 — 같은 키로 다시 보내야 서버가 두 번 내보내지 않는다)
bool shouldRenewSendKey(String outcome) =>
outcome == kSendOutcomeSent || outcome == 'MAIL_005';
/// 알림에 주소 · 까닭을 적는 사람 수 — 더 있으면 " 외 N명" (웹 `MAIL_PARTIAL_SEND_LISTED`)
const int kPartialSendListed = 2;
/// 받지 못한 까닭 (웹 `MAIL_REJECTED_REASON_TEXT`)
String? rejectedReasonText(RejectedReason reason) => switch (reason) {
RejectedReason.notFound => '없는 주소',
RejectedReason.refused => '받는 쪽이 거절',
RejectedReason.temporary => '지금은 받을 수 없음',
RejectedReason.unknown => null,
};
/// 받는 사람 일부가 받지 못한 채 보냈을 때의 알림 — 받지 못한 사람이 없으면 null.
/// "보냈지만 3명은 받지 못했습니다. a@… (없는 주소), b@… (받는 쪽이 거절) 외 1명" — 보내기 취소를 기다리는 발송이면([pending]) "받지 못합니다"
String? partialSendMessage(
List<RejectedRecipient> rejected, {
bool pending = false,
}) {
if (rejected.isEmpty) return null;
final String listed = rejected
.take(kPartialSendListed)
.map((RejectedRecipient recipient) {
final String? reason = rejectedReasonText(recipient.reason);
return reason == null
? recipient.email
: '${recipient.email} ($reason)';
})
.join(', ');
final int others = rejected.length - kPartialSendListed;
final String tail = others > 0 ? ' 외 ${formatCount(others)}명' : '';
final String count = formatCount(rejected.length);
return pending
? '보냈지만 $count명은 받지 못합니다. $listed$tail'
: '보냈지만 $count명은 받지 못했습니다. $listed$tail';
}
/// [보내기 취소] 알림이 남을 수 있는 가장 긴 시간 — 설정의 가장 긴 값(30초 — 웹 `UNDO_SEND_MAX_MS`)
const Duration kUndoSendMax = Duration(seconds: 30);
/// 설정한 초로 잴 때 남겨 두는 몫 — 닫히기 직전에 누른 취소가 서버에 닿기 전에 풀리지 않게(웹 `UNDO_SEND_DEADLINE_MARGIN_MS`)
const Duration kUndoSendMargin = Duration(seconds: 1);
/// 서버 시각으로 잴 때 남겨 두는 몫 — [kUndoSendMargin] 에 1초를 더 둔다: 응답의 `Date` 는 초 아래를 버린 값이고,
/// 응답이 오는 동안 지난 시간도 빼지 않았다(앱에서 정함 — 웹은 서버 시각을 쓰지 않는다)
const Duration kUndoSendServerMargin = Duration(seconds: 2);
/// [보내기 취소]를 누를 수 있는 남은 시간 — 서버 시계로 잰다(기기 시계가 틀려도 늘거나 줄지 않게).
/// - 응답의 서버 시각을 알면 `sendAt − 서버 시각 − 2초`
/// - 모르면 설정의 초(`GET /api/mail-settings`) − 1초, 그것도 모르면 30초 — 어느 쪽이든 `sendAt − 이 기기 시각`도 넘지 않게(웹 `undoToastDeadline` 처럼 sendAt 을 상한으로)
/// 어느 쪽이든 30초를 넘지 않는다. 남은 시간이 없으면 null — [보내기 취소] 없이 알린다
Duration? undoSendRemaining({
required DateTime sendAt,
required DateTime? serverNow,
required int? undoSendSeconds,
required DateTime deviceNow,
}) {
Duration remaining;
if (serverNow != null) {
remaining = sendAt.difference(serverNow) - kUndoSendServerMargin;
} else {
remaining = undoSendSeconds != null && undoSendSeconds > 0
? Duration(seconds: undoSendSeconds) - kUndoSendMargin
: kUndoSendMax;
final Duration untilSend = sendAt.difference(deviceNow);
if (untilSend < remaining) remaining = untilSend;
}
if (remaining <= Duration.zero) return null;
return remaining > kUndoSendMax ? kUndoSendMax : remaining;
}
// ── 첨부 사전 검사 (계약 2절 "첨부 업로드 한도" — 전달하는 원본 · 저장본의 첨부) ──
/// 메일 한 통의 첨부 개수 한도 (웹 `MAIL_MAX_ATTACHMENTS`)
const int kMailMaxAttachments = 20;
/// 파일당 크기 대체값 — 서버 기본값과 같다(앱은 첫 판에서 올리지 않아 안내에만 쓴다 — 웹 `MAIL_ATTACHMENT_FALLBACK_MAX_BYTES`)
const int kMailAttachmentMaxBytes = 20 * 1024 * 1024;
/// 첨부 합계 대체값 — 서버 기본값과 같다(웹 `MAIL_ATTACHMENT_TOTAL_FALLBACK_MAX_BYTES`)
const int kMailAttachmentTotalMaxBytes = 25 * 1024 * 1024;
/// 허용 형식(확장자) — 계약 2절 (웹 `MAIL_ATTACHMENT_EXTENSIONS`)
const Set<String> kMailAttachmentExtensions = <String>{
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'hwp', 'hwpx', 'txt', //
'csv', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'zip',
};
/// 확장자 — 소문자, 없으면 빈 글
String extensionOf(String name) {
final int dot = name.lastIndexOf('.');
return dot == -1 ? '' : name.substring(dot + 1).toLowerCase();
}
/// 이미 붙은 첨부를 보낼 수 있는가 — 개수(`MAIL_006`) → 형식(`FILE_001`) → 합계(`FILE_002`) 차례(서버와 같다).
/// 전달하는 원본 첨부는 파일당 한도를 보지 않는다(서버도 확장자와 합계만 본다). 걸리면 그 코드, 아니면 null
String? precheckCarriedAttachment({
required String name,
required int size,
required int attachedCount,
required int attachedBytes,
int totalMaxBytes = kMailAttachmentTotalMaxBytes,
}) {
if (attachedCount + 1 > kMailMaxAttachments) return 'MAIL_006';
if (!kMailAttachmentExtensions.contains(extensionOf(name))) return 'FILE_001';
if (attachedBytes + size > totalMaxBytes) return 'FILE_002';
return null;
}
/// 서버가 덩이를 넣은 뒤 다시 재므로 보내기 직전에 텍스트 한도에서 남기는 몫 — 파일마다 이름 + 24, 머리 600
/// (웹 `largeFileBlockTextReserve` · `LARGE_FILE_BLOCK_TEXT_PER_FILE` · `_BASE`)
int largeFileBlockTextReserve(Iterable<String> names) {
if (names.isEmpty) return 0;
return names.fold<int>(600, (int sum, String name) => sum + name.length + 24);
}
@@ -0,0 +1,229 @@
/// 쓰기 화면의 서명 — 넣을 서명 고르기 · 보낼 HTML · 미리 보기 (순수 함수 · 계약 2절 "서명").
///
/// - 새 메일은 `isForNew`, 답장 · 전달은 `isForReply` 일 때만 넣는다. 서식 서명(`bodyHtml`)이 있으면 그것, 없으면 텍스트 서명을 문단으로
/// (짝: 웹 `shared/utils/mail-signature.ts` 의 `signatureHtmlFor`). 이어 쓰는 저장본에는 다시 넣지 않는다(저장본에 이미 있다).
/// - 보낼 때는 서명 경계 div(`compose_body.dart`) 안에 서버가 준 HTML 을 그대로 두고, 문단에 `margin: 0` 만 앞에 넣는다(받는 쪽의 문단 여백).
/// 텍스트 대체 본문에는 텍스트 서명(`body`)을 — 없으면 HTML 의 글을 넣는다. 서명 그림 주소를 `cid:` 로 바꾸는 일은 서버가 한다.
/// - 미리 보기는 서명 HTML 의 글 줄과 서명 그림이다. 굵게 · 색 같은 모양은 미리 보기에서 빼고, 보낸 메일에는 그대로 들어간다.
library;
import 'package:arasystem_app/features/mail/data/models/compose_models.dart';
import 'package:arasystem_app/features/mail/domain/compose_body.dart';
import 'package:arasystem_app/features/mail/domain/html_nodes.dart';
import 'package:arasystem_app/features/mail/domain/mail_quote.dart';
import 'package:flutter/foundation.dart';
import 'package:html/dom.dart' as dom;
import 'package:html/parser.dart' as html_parser;
/// 서명 그림 주소 — `/api/mail-signature/images/{id}` (짝: 백엔드 `html/signature-images.ts` · 웹 `outgoing-html-rules.ts` 의 그림 규칙)
final RegExp _signatureImageSrc = RegExp(
r'^/api/mail-signature/images/([0-9a-fA-F-]{1,64})$',
);
/// 서명 그림 한 장의 최대 크기 — 계약 2절 "서명"(300KB)
const int kSignatureImageMaxBytes = 300 * 1024;
/// 서명 그림의 폭 상한 — 계약 2절(600)
const int kSignatureImageMaxWidth = 600;
/// 미리 보기의 한 조각
@immutable
sealed class SignaturePart {
const SignaturePart();
}
/// 글 한 줄
final class SignatureTextPart extends SignaturePart {
const SignatureTextPart(this.text);
final String text;
@override
bool operator ==(Object other) =>
other is SignatureTextPart && other.text == text;
@override
int get hashCode => text.hashCode;
}
/// 서명 그림 — [width] 는 HTML 의 폭(없으면 null), [alt] 는 화면 낭독기가 읽는다
final class SignatureImagePart extends SignaturePart {
const SignatureImagePart({required this.id, this.width, this.alt = ''});
final String id;
final int? width;
final String alt;
@override
bool operator ==(Object other) =>
other is SignatureImagePart &&
other.id == id &&
other.width == width &&
other.alt == alt;
@override
int get hashCode => Object.hash(id, width, alt);
}
/// 쓰기 화면에 넣을 서명
@immutable
class ComposeSignature {
const ComposeSignature({
required this.html,
required this.text,
required this.parts,
});
/// 서명 경계 div 안에 둘 HTML
final String html;
/// 텍스트 대체 본문에 넣을 글
final String text;
/// 미리 보기 — 글 줄 · 그림
final List<SignaturePart> parts;
@override
bool operator ==(Object other) =>
other is ComposeSignature &&
other.html == html &&
other.text == text &&
listEquals(other.parts, parts);
@override
int get hashCode => Object.hash(html, text, Object.hashAll(parts));
}
/// 이 쓰기 종류에 넣을 서명 — 넣기로 하지 않았거나 내용이 없으면 null
ComposeSignature? composeSignatureOf(
MailSignature? signature, {
required bool forReply,
}) {
if (signature == null) return null;
final bool enabled = forReply ? signature.isForReply : signature.isForNew;
if (!enabled) return null;
final String html = signature.bodyHtml?.trim() ?? '';
final String text = signature.body?.trimRight() ?? '';
if (html.isNotEmpty) {
return composeSignatureFromHtml(
_withParagraphMargins(html),
text: text.trim().isEmpty ? null : text,
);
}
if (text.trim().isEmpty) return null;
return ComposeSignature(
html: <String>[
for (final String line in composeLinesOf(text)) paragraphHtml(line),
].join(),
text: text,
parts: <SignaturePart>[
for (final String line in composeLinesOf(text)) SignatureTextPart(line),
],
);
}
/// 서명 HTML(경계 div 안의 것) → 쓰기 화면의 서명 — 다시 연 저장본의 서명도 이것으로. [text] 가 없으면 HTML 의 글
ComposeSignature composeSignatureFromHtml(String html, {String? text}) {
final dom.Element? body = html_parser.parse(html).body;
final List<SignaturePart> parts = body == null
? const <SignaturePart>[]
: _partsOf(body);
return ComposeSignature(
html: html,
text: text ?? plainLinesOf(quoteBlocksFromHtml(html)).join('\n'),
parts: List<SignaturePart>.unmodifiable(parts),
);
}
/// 문단마다 `margin: 0` 을 앞에 — 이미 margin 으로 시작하면 그대로(서버가 정화한 서명은 `margin:0` 을 가졌다)
String _withParagraphMargins(String html) {
final dom.Element? body = html_parser.parse(html).body;
if (body == null) return html;
bool changed = false;
for (final dom.Element paragraph in descendantElements(body, 'p')) {
final String style = paragraph.attributes['style']?.trim() ?? '';
if (style.toLowerCase().startsWith('margin')) continue;
paragraph.attributes['style'] = style.isEmpty
? MailHtmlStyles.paragraph
: '${MailHtmlStyles.paragraph}; $style';
changed = true;
}
return changed ? innerHtmlOf(body) : html;
}
/// 줄을 나누지 않는 태그 — 그 밖의 요소는 블록으로 본다
const Set<String> _inlineTags = <String>{
'span', 'strong', 'b', 'em', 'i', 'u', 's', 'a', 'font', 'small', 'big', //
};
/// 미리 보기 조각 — 블록 · `<br>` 은 줄, 서명 그림은 그 자리에. 빈 줄은 하나로 줄이고 앞뒤 빈 줄은 뺀다
List<SignaturePart> _partsOf(dom.Element root) {
final List<SignaturePart> parts = <SignaturePart>[];
final StringBuffer line = StringBuffer();
void flush({bool force = false}) {
final String text = line.toString().replaceAll(RegExp(r'\s+'), ' ').trim();
line.clear();
if (text.isEmpty && !force) return;
if (text.isEmpty &&
(parts.isEmpty || parts.last == const SignatureTextPart(''))) {
return;
}
parts.add(SignatureTextPart(text));
}
// 되풀이 없이 — 닫는 표시(null)로 블록 끝을 안다
final List<dom.Node?> stack = <dom.Node?>[...root.nodes.reversed];
while (stack.isNotEmpty) {
final dom.Node? node = stack.removeLast();
if (node == null) {
flush();
continue;
}
if (node is dom.Text) {
line.write(node.data);
continue;
}
if (node is! dom.Element) continue;
final String name = (node.localName ?? '').toLowerCase();
if (name == 'br') {
flush(force: true);
continue;
}
if (name == 'img') {
final SignatureImagePart? image = _imageOf(node);
if (image != null) {
flush();
parts.add(image);
}
continue;
}
if (name == 'style' || name == 'script') continue;
if (_inlineTags.contains(name)) {
stack.addAll(node.nodes.reversed);
continue;
}
flush();
stack
..add(null)
..addAll(node.nodes.reversed);
}
flush();
while (parts.isNotEmpty && parts.last == const SignatureTextPart('')) {
parts.removeLast();
}
return parts;
}
SignatureImagePart? _imageOf(dom.Element image) {
final String src = image.attributes['src']?.trim() ?? '';
final RegExpMatch? match = _signatureImageSrc.firstMatch(src);
if (match == null) return null;
final int? width = int.tryParse(image.attributes['width']?.trim() ?? '');
return SignatureImagePart(
id: match.group(1)!,
width: width != null && width > 0 && width <= kSignatureImageMaxWidth
? width
: null,
alt: image.attributes['alt'] ?? '',
);
}
@@ -0,0 +1,97 @@
/// HTML 노드를 되풀이 없이 다루는 도구 — 글 모으기 · 태그로 찾기 · 직렬화 (순수 함수).
///
/// `package:html` 의 `text` · `querySelectorAll` · `innerHtml` · `outerHtml` 은 깊이만큼 되풀이한다 — 깊게 겹친 저장본 · 원문 하나로
/// 스택이 넘칠 수 있어 쓰기 화면은 이 함수들을 쓴다. 직렬화는 `package:html` 과 같은 글을 낸다(같은 이스케이프 · 빈 요소 규칙).
library;
import 'package:html/dom.dart' as dom;
import 'package:html/dom_parsing.dart'
show htmlSerializeEscape, isVoidElement, writeTextNodeAsHtml;
/// 노드 안의 글을 문서 차례로 모은다 — `Node.text` 와 같은 글(모든 글 노드)
String nodeTextOf(dom.Node root) {
if (root is dom.Text) return root.data;
final StringBuffer out = StringBuffer();
final List<dom.Node> stack = <dom.Node>[...root.nodes.reversed];
while (stack.isNotEmpty) {
final dom.Node node = stack.removeLast();
if (node is dom.Text) {
out.write(node.data);
} else if (node is dom.Element) {
stack.addAll(node.nodes.reversed);
}
}
return out.toString();
}
/// [root] 아래(자기 빼고)에서 이름이 [tag] 인 요소 — 문서 차례로 (`querySelectorAll(tag)` 와 같은 결과)
List<dom.Element> descendantElements(dom.Element root, String tag) {
final List<dom.Element> found = <dom.Element>[];
final List<dom.Element> stack = <dom.Element>[...root.children.reversed];
while (stack.isNotEmpty) {
final dom.Element element = stack.removeLast();
if (element.localName == tag) found.add(element);
stack.addAll(element.children.reversed);
}
return found;
}
/// 노드 하나의 HTML — `outerHtml` 과 같은 글
String outerHtmlOf(dom.Node node) {
final StringBuffer out = StringBuffer();
_write(out, <dom.Node>[node]);
return out.toString();
}
/// 요소 안의 HTML — `innerHtml` 과 같은 글
String innerHtmlOf(dom.Node node) {
final StringBuffer out = StringBuffer();
_write(out, node.nodes);
return out.toString();
}
/// 노드들을 차례로 적는다 — 스택에는 노드와 닫는 태그(글)가 함께 든다
void _write(StringBuffer out, List<dom.Node> nodes) {
final List<Object> stack = <Object>[...nodes.reversed];
while (stack.isNotEmpty) {
final Object item = stack.removeLast();
if (item is String) {
out.write(item);
continue;
}
if (item is dom.Text) {
writeTextNodeAsHtml(out, item);
continue;
}
if (item is dom.Comment) {
out.write('<!--${item.data}-->');
continue;
}
if (item is! dom.Element) {
// 문서 형식 선언 등 — 쓰기 화면이 다루는 조각에는 없다
if (item is dom.Node) stack.addAll(item.nodes.reversed);
continue;
}
final String name = item.localName ?? '';
out
..write('<')
..write(name);
item.attributes.forEach((Object key, String value) {
out
..write(' ')
..write(key)
..write('="')
..write(htmlSerializeEscape(value, attributeMode: true))
..write('"');
});
out.write('>');
if (item.nodes.isNotEmpty &&
(name == 'pre' || name == 'textarea' || name == 'listing')) {
// 파서가 맨 앞 줄바꿈 하나를 지우므로 남아 있으면 둘이었다 — 하나를 되돌린다(`package:html` 과 같다)
final dom.Node first = item.nodes.first;
if (first is dom.Text && first.data.startsWith('\n')) out.write('\n');
}
if (!isVoidElement(name)) stack.add('</$name>');
stack.addAll(item.nodes.reversed);
}
}
@@ -0,0 +1,514 @@
/// 답장 · 전달의 원문 인용 — 받은 본문 → 글 덩이 (순수 함수 · 계약 2절 "서식 본문" · "답장·전달 초안의 길이").
///
/// 앱은 원문을 서식째 옮기지 않고 **글로 만든 덩이**로 인용한다(2026-11-06 정함) — 미리 보기에 보인 글과 보내는 HTML 이 같고,
/// 보내는 정화의 값 형식 규칙을 앱에 옮기지 않아도 된다. 잃는 것은 원문의 표 · 색 · 링크 모양이다(글은 남는다).
///
/// - 어느 본문을 쓸지는 읽기 화면과 같다: 서식 본문(`displayedHtmlBody`)에서 그림을 빼고 보이는 글이 있으면 그것, 없으면 textBody.
/// 길이 · 중첩 비용이 읽기 화면의 한도(`kMaxSanitizedHtmlLength` · `kMaxHtmlNestingCost`)를 넘어도 textBody 다.
/// - 블록 · `<br>` 은 줄, 목록은 "- " · "1. ", 칸이 여럿인 표의 줄은 칸을 " | " 로 잇고(칸 안에 블록이 있으면 칸마다 줄),
/// 원문 속 인용은 안쪽 인용([QuoteNest] — [kQuoteMaxDepth] 단까지), 링크는 "글 (주소)", 그림 · 스타일 · 스크립트의 글은 뺀다.
/// - 서버가 읽기 한도에서 잘라 보냈으면(`isBodyTruncated`) 인용 끝에 생략 표시를 둔다(웹과 같다).
/// - 큰 원문은 다른 isolate 에서 만든다(읽기 화면의 정화와 같은 기준 — `compose_fit.dart` 의 `buildInitialQuote` 가 처음 맞추기와 함께).
library;
import 'package:arasystem_app/features/mail/domain/mail_html.dart';
import 'package:flutter/foundation.dart';
import 'package:html/dom.dart' as dom;
import 'package:html/parser.dart' as html_parser;
/// 인용 끝의 생략 표시 — 웹 `MAIL_QUOTE_OMITTED_MARK` 와 같다
const String kQuoteOmittedMark = '(원문이 길어 이하 생략)';
/// 인용 머리 문단의 글 — 웹 `MAIL_QUOTE_HEADING` 과 같다(웹이 다시 열 때 이 글과 바로 뒤의 표로 원문 인용을 알아본다)
const String kQuoteHeading = '원본 메일';
/// 원문 속 인용을 안쪽 인용으로 두는 최대 단 — 더 깊은 것은 그 단에 펼친다(되풀이 깊이를 묶는다)
const int kQuoteMaxDepth = 50;
/// DOM 을 되풀이로 내려가는 최대 깊이 — 더 깊은 곳은 글만 모은다(스택이 넘치지 않게)
const int _walkMaxDepth = 200;
/// 원문 인용의 머리 줄 — "보낸 사람" · "날짜" · "제목" (전달은 "받는 사람" · "참조"도)
@immutable
class QuoteHeaderRow {
const QuoteHeaderRow(this.label, this.value);
final String label;
final String value;
@override
bool operator ==(Object other) =>
other is QuoteHeaderRow && other.label == label && other.value == value;
@override
int get hashCode => Object.hash(label, value);
}
/// 원문 인용의 덩이 — 글 한 줄 또는 원문 속 인용
@immutable
sealed class QuoteBlock {
const QuoteBlock();
}
/// 글 한 줄 — 빈 줄은 빈 글
final class QuoteLine extends QuoteBlock {
const QuoteLine(this.text);
final String text;
@override
bool operator ==(Object other) => other is QuoteLine && other.text == text;
@override
int get hashCode => text.hashCode;
@override
String toString() => 'QuoteLine($text)';
}
/// 원문 속 인용 — 안의 덩이
final class QuoteNest extends QuoteBlock {
const QuoteNest(this.children);
final List<QuoteBlock> children;
@override
bool operator ==(Object other) =>
other is QuoteNest && listEquals(other.children, children);
@override
int get hashCode => Object.hashAll(children);
@override
String toString() => 'QuoteNest($children)';
}
/// 인용할 원문 — 덩이와, 서버가 잘라 보낸 본문인지
typedef QuoteSource = ({List<QuoteBlock> blocks, bool truncated});
/// 받은 본문 → 인용할 덩이 — 고르는 규칙은 위 머리말
QuoteSource quoteSourceOf({
required String? htmlBody,
required String? textBody,
required bool isBodyTruncated,
}) {
final String? html = displayedHtmlBody(htmlBody);
if (html != null &&
html.length <= kMaxSanitizedHtmlLength &&
htmlNestingCost(html, limit: kMaxHtmlNestingCost) <=
kMaxHtmlNestingCost) {
final List<QuoteBlock> blocks = quoteBlocksFromHtml(html);
if (hasVisibleQuoteText(blocks)) {
return (blocks: blocks, truncated: isBodyTruncated);
}
}
return (
blocks: quoteBlocksFromText(textBody ?? ''),
truncated: isBodyTruncated,
);
}
/// 글 본문 → 줄마다 한 덩이(빈 줄 포함 — 끝의 빈 줄은 뺀다)
List<QuoteBlock> quoteBlocksFromText(String text) {
final List<String> lines = text
.replaceAll('\r\n', '\n')
.replaceAll('\r', '\n')
.split('\n');
final List<QuoteBlock> blocks = <QuoteBlock>[
for (final String line in lines) QuoteLine(line),
];
_trimEmptyEdges(blocks);
return blocks;
}
/// 덩이에 보이는 글이 있는가(공백뿐인 줄은 글이 아니다)
bool hasVisibleQuoteText(List<QuoteBlock> blocks) {
final List<QuoteBlock> stack = <QuoteBlock>[...blocks];
while (stack.isNotEmpty) {
final QuoteBlock block = stack.removeLast();
switch (block) {
case QuoteLine(:final String text):
if (text.trim().isNotEmpty) return true;
case QuoteNest(:final List<QuoteBlock> children):
stack.addAll(children);
}
}
return false;
}
/// 덩이 → 글 줄 — 안쪽 인용은 "> " 를 붙인다(빈 줄은 ">" — 웹 `docToText` 의 인용 줄과 같다). 서식이 있는 저장본의 본문 칸에 넣는다
List<String> plainLinesOf(List<QuoteBlock> blocks) {
final List<String> lines = <String>[];
void visit(List<QuoteBlock> items, int depth) {
for (final QuoteBlock block in items) {
switch (block) {
case QuoteLine(:final String text):
lines.add(quotePrefixed(text, depth));
case QuoteNest(:final List<QuoteBlock> children):
visit(children, depth + 1);
}
}
}
visit(blocks, 0);
return lines;
}
/// 펼친 원본 메일의 한 줄 — 글과 원문 속 인용의 단(0 이 원문 바로 아래)
typedef QuoteDisplayLine = ({String text, int depth});
/// 덩이 → 화면에 그릴 줄 — 쓰기 화면이 펼친 원본 메일을 줄마다 그린다(긴 원문도 보이는 줄만 만든다). 단마다 왼쪽에 인용 막대
List<QuoteDisplayLine> quoteDisplayLines(List<QuoteBlock> blocks) {
final List<QuoteDisplayLine> lines = <QuoteDisplayLine>[];
void visit(List<QuoteBlock> items, int depth) {
for (final QuoteBlock block in items) {
switch (block) {
case QuoteLine(:final String text):
lines.add((text: text, depth: depth));
case QuoteNest(:final List<QuoteBlock> children):
visit(children, depth + 1);
}
}
}
visit(blocks, 0);
return lines;
}
/// 인용 [depth] 단의 줄 — "> " 를 단마다(빈 줄은 마지막 ">" 만 — 웹 `MAIL_QUOTE_LINE_PREFIX.trimEnd()`)
String quotePrefixed(String text, int depth) {
if (depth <= 0) return text;
if (text.isEmpty) return '${'> ' * (depth - 1)}>';
return '${'> ' * depth}$text';
}
/// 서식 본문 → 덩이. 문서로 읽어 `<body>` 아래만 본다(조각으로 읽으면 맨 위 요소가 많을 때 제곱으로 느리다 — `sanitizeMailHtml` 과 같다)
List<QuoteBlock> quoteBlocksFromHtml(String html) {
final dom.Element? body = html_parser.parse(html).body;
if (body == null) return <QuoteBlock>[];
return quoteBlocksFromNodes(body.nodes);
}
/// DOM 조각 → 덩이 — 저장본 다시 열기도 쓴다(인용 앞 · 원문 인용 안)
List<QuoteBlock> quoteBlocksFromNodes(Iterable<dom.Node> nodes) {
final _QuoteTextBuilder builder = _QuoteTextBuilder();
for (final dom.Node node in nodes) {
builder.walk(node, 0);
}
return builder.finish();
}
/// 앞뒤의 빈 줄을 뺀다(안쪽 인용 안도)
void _trimEmptyEdges(List<QuoteBlock> blocks) {
bool isEmptyLine(QuoteBlock block) =>
block is QuoteLine && block.text.trim().isEmpty;
while (blocks.isNotEmpty && isEmptyLine(blocks.first)) {
blocks.removeAt(0);
}
while (blocks.isNotEmpty && isEmptyLine(blocks.last)) {
blocks.removeLast();
}
}
/// 내용째 버리는 태그 — 읽기 화면의 정화와 같은 결(글이 보이지 않는 것)
const Set<String> _dropped = <String>{
'script', 'style', 'template', 'iframe', 'frame', 'frameset', 'object', //
'embed', 'applet', 'param', 'noscript', 'noembed', 'noframes', 'svg', //
'math', 'datalist', 'title', 'head', 'xmp', 'plaintext', 'audio', //
'video', 'source', 'track', 'canvas', 'map', 'area', 'link', 'meta', //
'base', 'input', 'select', 'dialog', 'slot', 'img', 'button', 'textarea',
};
/// 줄을 나누는 블록 태그
const Set<String> _blocks = <String>{
'p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'address', 'article', //
'aside', 'section', 'header', 'footer', 'center', 'dd', 'dt', 'dl', //
'figure', 'figcaption', 'main', 'nav', 'details', 'summary', 'hgroup', //
'form', 'fieldset', 'legend', 'caption', 'table', 'thead', 'tbody', //
'tfoot', 'td', 'th', 'menu', 'dir', 'ul', 'ol', 'li', 'tr', 'hr', //
'blockquote', 'pre', 'body', 'html',
};
/// 공백 글자 — HTML 이 한 칸으로 줄이는 것(nbsp 는 줄이지 않는다)
bool _isCollapsible(int unit) =>
unit == 0x20 ||
unit == 0x09 ||
unit == 0x0A ||
unit == 0x0C ||
unit == 0x0D;
final RegExp _safeLink = RegExp(r'^(?:https?|mailto):', caseSensitive: false);
/// 목록 한 칸의 머리 — 첫 줄은 [marker], 이어지는 줄은 그 폭의 빈칸
class _ItemLevel {
_ItemLevel(this.marker);
final String marker;
bool used = false;
}
class _ListLevel {
_ListLevel({required this.ordered, required this.next});
final bool ordered;
int next;
}
/// 원문 HTML 을 읽어 글 덩이를 만든다 — 블록 경계 · `<br>` 에서 줄을 끊고, 이어진 공백은 한 칸으로 줄인다(화면에 보이는 글과 같게)
class _QuoteTextBuilder {
final List<QuoteBlock> _root = <QuoteBlock>[];
late List<QuoteBlock> _target = _root;
final StringBuffer _line = StringBuffer();
/// 지금 줄에 글자가 있다
bool _hasText = false;
/// 줄인 공백이 남았다 — 다음 글자 앞에 한 칸
bool _space = false;
int _preDepth = 0;
int _quoteDepth = 0;
final List<_ItemLevel> _items = <_ItemLevel>[];
final List<_ListLevel> _lists = <_ListLevel>[];
List<QuoteBlock> finish() {
_boundary();
_trimEmptyEdges(_root);
return _root;
}
void walk(dom.Node node, int depth) {
if (node is dom.Text) {
_text(node.data);
return;
}
if (node is! dom.Element) return;
final String name = (node.localName ?? '').toLowerCase();
if (_dropped.contains(name)) return;
if (depth > _walkMaxDepth) {
// 아주 깊은 곳은 글만 모은다 — 되풀이로 더 내려가지 않는다
_text(_textOf(node));
return;
}
switch (name) {
case 'br':
_emit(force: true);
case 'hr':
_boundary();
_target.add(QuoteLine('${_prefix()}---'));
case 'blockquote':
_blockquote(node, depth);
case 'ul':
case 'ol':
_list(node, depth, ordered: name == 'ol');
case 'li':
_item(node, depth);
case 'tr':
_row(node, depth);
case 'pre':
_boundary();
_preDepth++;
_children(node, depth);
_preDepth--;
_boundary();
case 'a':
_link(node, depth);
default:
final bool block = _blocks.contains(name);
if (block) _boundary();
_children(node, depth);
if (block) _boundary();
}
}
void _children(dom.Element element, int depth) {
for (final dom.Node child in element.nodes) {
walk(child, depth + 1);
}
}
void _blockquote(dom.Element element, int depth) {
_boundary();
if (_quoteDepth >= kQuoteMaxDepth) {
_children(element, depth);
_boundary();
return;
}
final List<QuoteBlock> previous = _target;
final List<QuoteBlock> inner = <QuoteBlock>[];
_target = inner;
_quoteDepth++;
_children(element, depth);
_boundary();
_quoteDepth--;
_target = previous;
_trimEmptyEdges(inner);
if (inner.isNotEmpty) _target.add(QuoteNest(inner));
}
void _list(dom.Element element, int depth, {required bool ordered}) {
_boundary();
final int start = int.tryParse(element.attributes['start'] ?? '') ?? 1;
_lists.add(_ListLevel(ordered: ordered, next: start));
_children(element, depth);
_lists.removeLast();
_boundary();
}
void _item(dom.Element element, int depth) {
_boundary();
final _ListLevel? list = _lists.isEmpty ? null : _lists.last;
final String marker = list != null && list.ordered
? '${list.next++}. '
: '- ';
_items.add(_ItemLevel(marker));
_children(element, depth);
_boundary();
_items.removeLast();
}
/// 표의 한 줄 — 칸이 하나면 그 안을 그대로, 여럿이고 칸마다 글뿐이면 " | " 로 이은 한 줄, 칸 안에 블록이 있으면 칸마다 줄
void _row(dom.Element row, int depth) {
_boundary();
final List<dom.Element> cells = <dom.Element>[
for (final dom.Element child in row.children)
if (child.localName == 'td' || child.localName == 'th') child,
];
if (cells.length > 1 && cells.every(_isInlineOnly)) {
final List<String> texts = <String>[
for (final dom.Element cell in cells)
if (_collapse(_textOf(cell)) case final String text
when text.isNotEmpty)
text,
];
if (texts.isNotEmpty) {
_target.add(QuoteLine('${_prefix()}${texts.join(' | ')}'));
}
return;
}
for (final dom.Element cell in cells) {
_boundary();
_children(cell, depth);
_boundary();
}
}
void _link(dom.Element element, int depth) {
_children(element, depth);
final String? href = element.attributes['href']?.trim();
if (href == null || href.isEmpty || !_safeLink.hasMatch(href)) return;
final String text = _collapse(_textOf(element));
if (text.isEmpty || href == text || href == 'mailto:$text') return;
_write(' ($href)');
}
/// 요소 안의 글 — 되풀이 없이 모은다(package:html 의 `text` 는 깊이만큼 되풀이한다). 버리는 태그의 글은 뺀다
static String _textOf(dom.Node root) {
final StringBuffer out = StringBuffer();
final List<dom.Node> stack = <dom.Node>[...root.nodes.reversed];
while (stack.isNotEmpty) {
final dom.Node node = stack.removeLast();
if (node is dom.Text) {
out.write(node.data);
continue;
}
if (node is! dom.Element) continue;
if (_dropped.contains((node.localName ?? '').toLowerCase())) continue;
stack.addAll(node.nodes.reversed);
}
return out.toString();
}
/// 칸 안이 글뿐인가 — 블록 · 줄바꿈이 없다
static bool _isInlineOnly(dom.Element cell) {
final List<dom.Element> stack = <dom.Element>[...cell.children];
while (stack.isNotEmpty) {
final dom.Element element = stack.removeLast();
final String name = (element.localName ?? '').toLowerCase();
if (name == 'br' || _blocks.contains(name)) return false;
stack.addAll(element.children);
}
return true;
}
static String _collapse(String text) {
final StringBuffer out = StringBuffer();
bool space = false;
for (int index = 0; index < text.length; index++) {
final int unit = text.codeUnitAt(index);
if (_isCollapsible(unit)) {
space = out.isNotEmpty;
continue;
}
if (space) out.write(' ');
space = false;
out.writeCharCode(unit);
}
return out.toString();
}
void _text(String data) {
if (_preDepth > 0) {
final List<String> parts = data
.replaceAll('\r\n', '\n')
.replaceAll('\r', '\n')
.split('\n');
for (int index = 0; index < parts.length; index++) {
if (index > 0) _emit(force: true);
if (parts[index].isNotEmpty) {
_line.write(parts[index]);
_hasText = true;
}
}
return;
}
for (int index = 0; index < data.length; index++) {
final int unit = data.codeUnitAt(index);
if (_isCollapsible(unit)) {
if (_hasText) _space = true;
continue;
}
if (_space) _line.write(' ');
_space = false;
_line.writeCharCode(unit);
_hasText = true;
}
}
/// 지금 줄 뒤에 글을 붙인다(링크 주소)
void _write(String text) {
if (!_hasText) return;
_space = false;
_line.write(text);
}
/// 블록 경계 — 글이 있으면 줄을 끝낸다(빈 블록은 줄을 더하지 않는다)
void _boundary() => _emit(force: false);
/// 줄을 끝낸다 — [force] 면 빈 줄도(`<br>`)
void _emit({required bool force}) {
if (!_hasText && !force) {
_space = false;
return;
}
final String text = _hasText ? _line.toString() : '';
_target.add(QuoteLine(text.isEmpty ? '' : '${_prefix()}$text'));
_line.clear();
_hasText = false;
_space = false;
}
/// 목록 칸의 머리 — 겹친 목록은 바깥 칸의 폭만큼 들인다
String _prefix() {
if (_items.isEmpty) return '';
final StringBuffer out = StringBuffer();
for (final _ItemLevel level in _items) {
if (level.used) {
out.write(' ' * level.marker.length);
} else {
out.write(level.marker);
level.used = true;
}
}
return out.toString();
}
}
+10 -7
View File
@@ -1,10 +1,9 @@
import 'package:arasystem_app/core/modules/app_modules.dart';
import 'package:arasystem_app/core/modules/module_routes.dart';
import 'package:arasystem_app/features/mail/domain/compose_request.dart';
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
import 'package:arasystem_app/features/mail/presentation/pages/mail_list_page.dart';
import 'package:arasystem_app/features/mail/presentation/pages/message_compose_page.dart';
import 'package:arasystem_app/features/mail/presentation/pages/message_reader_page.dart';
import 'package:arasystem_app/shared/widgets/placeholder_page.dart';
import 'package:flutter/widgets.dart';
import 'package:go_router/go_router.dart';
@@ -48,9 +47,8 @@ abstract final class MailRoutes {
/// 메일 모듈의 라우트 묶음 — core/router/module_registry.dart 가 모은다.
///
/// 목록은 앱 보드 `App1MailList`, 읽기는 `App1Reader` 화면이다. 쓰기는 아직 자리 화면이다 — 보드(`App1Compose`)대로 만들면
/// builder 를 `features/mail/presentation/pages/` 의 화면으로 바꾼다(경로는 그대로 — 여는 까닭은 `composeIntentOf(state.uri.queryParameters)`,
/// mailto 로 채울 값은 `state.extra` 의 [ComposePrefill]).
/// 목록은 앱 보드 `App1MailList`, 읽기는 `App1Reader`, 쓰기는 `App1Compose` 화면이다.
/// 쓰기를 여는 까닭은 주소의 쿼리(`composeIntentOf(state.uri.queryParameters)`), mailto 로 채울 값은 `state.extra` 의 [ComposePrefill] 이다.
final ModuleRoutes mailModuleRoutes = ModuleRoutes(
id: AppModuleId.mail,
// 알림 · 깊은 링크의 id 는 메일 id 다 — 새 메일 알림("새 메일 N통")처럼 id 가 없으면 첫 화면
@@ -77,8 +75,13 @@ final ModuleRoutes mailModuleRoutes = ModuleRoutes(
GoRoute(
path: 'compose',
parentNavigatorKey: context.rootNavigatorKey,
builder: (BuildContext _, GoRouterState _) =>
const PlaceholderPage(title: MailTexts.compose),
builder: (BuildContext _, GoRouterState state) => MessageComposePage(
intent: composeIntentOf(state.uri.queryParameters),
prefill: switch (state.extra) {
final ComposePrefill prefill => prefill,
_ => null,
},
),
),
],
),
@@ -11,10 +11,101 @@ import 'package:arasystem_app/core/utils/korean_text.dart';
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
import 'package:arasystem_app/features/mail/domain/mail_body_notices.dart';
/// 메일 모듈의 화면 이름 — 아직 자리 화면인 쓰기가 쓴다
abstract final class MailTexts {
/// 메일 쓰기 (전체 화면)
static const String compose = '메일 쓰기';
/// 쓰기 화면 (보드 `App1Compose`). 보드에 없는 상태(불러오기 · 받지 못함 · 칸 안내 · 첨부 상태 · 나가기 확인 · 보낸 뒤 알림)의 글은
/// 웹 상수(`frontend/src/shared/constants/mail.ts` · `mail-send-later.ts` · `large-file.ts`)를 그대로 옮겼다 — 앱에서 새로 쓴 글은 그 줄에 적었다
abstract final class MailComposeTexts {
// ── 위 막대 ──
static const String newMail = '새 메일';
static const String reply = '답장';
static const String replyAll = '전체 답장';
static const String forward = '전달';
/// 저장본을 받기 전의 이름 (웹 `COMPOSE_LOADING_TITLE`)
static const String loadingTitle = '메일 쓰기';
static const String close = '닫기';
static const String attach = '파일 첨부';
static const String send = '보내기';
/// 보내는 동안의 [보내기] — 화면 낭독기 (앱에서 새로 씀)
static const String sending = '보내는 중입니다.';
/// 앱은 첫 판에서 파일을 올리지 않는다 — [파일 첨부]를 누르면 (앱에서 새로 씀 · 메일 찾기의 "아직 준비 중"과 같은 결)
static const String attachNotReady =
'앱에서 파일 첨부는 아직 준비 중입니다. 지금은 웹메일에서 첨부해 주세요.';
// ── 칸 ──
static const String to = '받는 사람';
static const String cc = '참조';
static const String bcc = '숨은참조';
static const String subject = '제목';
static const String subjectHint = '제목을 입력해 주세요';
/// 본문 칸 — 화면 낭독기
static const String body = '본문';
static const String bodyHint = '내용을 입력해 주세요';
static const String expandCopies = '참조, 숨은참조 펼치기';
static const String collapseCopies = '참조, 숨은참조 접기';
/// 칩 · 첨부의 ✕ — 화면 낭독기 "‘이도윤’ 빼기"
static String remove(String name) => '‘$name’ 빼기';
/// 추천 목록 — 화면 낭독기 "받는 사람 추천"
static String suggestions(String field) => '$field 추천';
// ── 첨부 ──
static String attachmentsTitle(int count) => '첨부 ${formatCount(count)}개';
/// 함께 보낼 수 없는 첨부(형식 · 개수 · 합계) (앱에서 새로 씀)
static const String unsendable = '이 첨부는 함께 보낼 수 없습니다. 빼고 보내 주세요.';
/// 웹에서 올리다 멈춘 대용량 파일 (앱에서 새로 씀)
static const String largeUploading =
'웹메일에서 올리다 멈춘 파일입니다. 웹메일에서 마저 올리거나 빼 주세요.';
/// 지웠거나 기간이 지난 대용량 파일 (앱에서 새로 씀)
static const String largeMissing = '지웠거나 기간이 지난 파일입니다. 빼고 보내 주세요.';
/// 위험 판정 대용량 파일 (앱에서 새로 씀)
static const String largeInfected = '위험한 파일이라 보낼 수 없습니다. 빼 주세요.';
/// 기록까지 지워져 이름을 모르는 대용량 파일 (앱에서 새로 씀)
static const String largeUnknownName = '이름을 알 수 없는 파일';
// ── 서명 · 원본 · 저장본 ──
static const String signature = '서명';
static const String removeSignature = '서명 빼기';
/// [서명 빼기] 뒤 그 자리 (앱에서 새로 씀 — 보드에 없다)
static const String insertSignature = '서명 넣기';
/// 대체 글이 없는 서명 그림 — 화면 낭독기
static const String signatureImage = '서명 그림';
static const String quote = '원본 메일';
/// 서식이나 그림이 있는 남의 저장본 — 열 때 안내 줄 (앱에서 새로 씀)
static const String foreignDraft =
'서식이나 그림이 있는 임시 저장본입니다. 앱에서 본문을 고치면 글자만 남으니, 서식을 지키려면 웹메일에서 이어 써 주세요.';
/// 저장본을 받는 동안 — 화면 낭독기 (앱에서 새로 씀)
static const String loading = '쓰던 메일을 불러오는 중';
/// 답장 · 전달할 원본을 받는 동안 — 화면 낭독기 (앱에서 새로 씀)
static const String loadingSource = '원본 메일을 불러오는 중';
// ── 보내기 · 나가기 ──
static const String sent = '메일을 보냈습니다.';
static const String undoSend = '보내기 취소';
static const String viewSent = '보낸편지함 보기';
static const String undone = '보내기를 취소했습니다. 쓰던 메일로 돌아왔습니다.';
/// 저장한 채 나갔을 때 (앱에서 새로 씀 — 저장 상태는 쓰는 동안 보이지 않는다)
static const String savedOnLeave = '임시보관함에 저장했습니다.';
// 나가기 확인 (웹 `COMPOSE_LEAVE_DIALOG` — [계속 쓰기]가 처음 초점이자 주 단추)
static const String leaveTitle = '저장하지 못한 내용이 있습니다';
static const String leaveMessage = '이 화면을 나가면 마지막으로 바꾼 내용이 사라집니다.';
static const String stay = '계속 쓰기';
static const String leave = '나가기';
}
/// 읽기 화면 (보드 `App1Reader`). 보드에 없는 상태(붙잡힌 메일 · 본문 안내 · 받지 못함 · 첨부 열기 · 삭제 확인)의 글은
@@ -0,0 +1,670 @@
import 'dart:async';
import 'package:arasystem_app/core/api/api_exception.dart';
import 'package:arasystem_app/core/people/address_input.dart';
import 'package:arasystem_app/core/people/recipient_match.dart';
import 'package:arasystem_app/core/theme/app_spacing.dart';
import 'package:arasystem_app/core/theme/app_theme.dart';
import 'package:arasystem_app/core/theme/app_typography.dart';
import 'package:arasystem_app/core/utils/utf16_length.dart';
import 'package:arasystem_app/features/mail/domain/compose_body.dart';
import 'package:arasystem_app/features/mail/domain/compose_reply.dart';
import 'package:arasystem_app/features/mail/domain/compose_request.dart';
import 'package:arasystem_app/features/mail/domain/compose_signature.dart';
import 'package:arasystem_app/features/mail/domain/mail_quote.dart';
import 'package:arasystem_app/features/mail/mail_routes.dart';
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
import 'package:arasystem_app/features/mail/presentation/providers/compose_controller.dart';
import 'package:arasystem_app/features/mail/presentation/providers/compose_state.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mail_toast_provider.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/compose_bars.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/compose_recipient_field.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/compose_sections.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/mail_list_states.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/reader_notices.dart';
import 'package:arasystem_app/shared/widgets/confirm_dialog.dart';
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
/// 본문 칸의 줄 간격 (보드 `App1Compose` 의 `line-height: 1.7`)
const double _bodyLineHeight = 1.7;
/// 메일 쓰기 — 앱 보드 `App1Compose` (앱 디자인 README 「쓰기」). 탭 막대 없는 전체 화면이다. 규칙은 [ComposeController] 머리말.
///
/// - 위 막대: ✕(나가기) · 제목(새 메일 · 답장 · 전체 답장 · 전달 — 저장본을 받기 전에는 "메일 쓰기") · [파일 첨부](첫 판은 준비 중 알림) · [보내기].
/// - 칸: 받는 사람(오른쪽에 참조 · 숨은참조 펼치기) · 참조 · 숨은참조 · 제목 · (서식이 있는 남의 저장본 안내) · 첨부 · 본문 · 서명 · 원본 메일(접힘 — 누르면 펼친다).
/// 열면 비어 있는 첫 칸(받는 사람 → 제목 → 본문)에 초점을 둔다.
/// - 보내기 전 검사 · 서버의 칸 안 실패(받는 사람 · 제목 · 본문 길이)는 그 칸 아래 띠로 알리고 그 칸으로 초점을 옮긴다.
/// 보내면 시작한 화면으로 돌아간다 — 알림("메일을 보냈습니다." · [보내기 취소] · 남은 시간 막대)은 그 화면이 그린다.
/// 보내는 동안은 칸 · 단추를 막는다(✕ · 뒤로는 그대로 — 떠나도 끝까지 보낸다). 떠나기 전 저장을 기다리는 동안은 [보내기]를 막는다.
/// - 나가기(✕ · 뒤로 · 몸짓): 잃을 것이 없으면 그대로 닫는다(예측 뒤로 가기가 산다 — 이 화면이 저장한 적이 있으면 "임시보관함에 저장했습니다.").
/// 있으면 쓰다 만 주소를 칩으로 만들고 저장(5초까지)한 뒤 닫고 같은 알림을, 저장하지 못했으면 묻는다 — [계속 쓰기]가 처음 초점이자 주 단추이고
/// 묻는 사이 저장이 끝나면 그대로 닫는다. 보내는 중에는 묻지 않고 떠나게 둔다(결과는 알림으로).
/// - 앱이 안 보이게 되면(홈 · 다른 앱 · 화면 끄기) 바꾼 것을 바로 저장한다.
class MessageComposePage extends ConsumerStatefulWidget {
const MessageComposePage({required this.intent, this.prefill, super.key});
/// 여는 까닭 — 주소의 쿼리(`composeIntentOf`)
final ComposeIntent intent;
/// mailto 로 채울 값 — 새 메일만 쓴다
final ComposePrefill? prefill;
@override
ConsumerState<MessageComposePage> createState() => _MessageComposePageState();
}
class _MessageComposePageState extends ConsumerState<MessageComposePage> {
/// 화면마다 다른 수 — 같은 주소의 쓰기 화면을 다시 열어도 앞 화면의 쓰기(보내는 중인 것 포함)와 섞이지 않게
static int _nextInstance = 0;
late final ComposeArgs _args = ComposeArgs(
instance: _nextInstance++,
intent: widget.intent,
prefill: widget.intent is NewComposeIntent ? widget.prefill : null,
);
late final ComposeController _controller;
late final AppLifecycleListener _lifecycle;
final Map<RecipientFieldKind, FocusNode> _recipientFocus =
<RecipientFieldKind, FocusNode>{
for (final RecipientFieldKind kind in RecipientFieldKind.values)
kind: FocusNode(),
};
final FocusNode _subjectFocus = FocusNode();
final FocusNode _bodyFocus = FocusNode();
/// 원본 메일을 펼쳤다
bool _quoteOpen = false;
bool _autofocused = false;
/// 나가기를 하는 중(저장을 기다림 · 확인 창) — 겹쳐 누르지 않게
bool _leaving = false;
/// 이 화면이 스스로 닫는다 — 닫힌 뒤 "임시보관함에 저장했습니다."를 알릴지
bool _closing = false;
bool _announceSaved = false;
/// 나가기 확인 창이 열려 있다 — 그사이 저장이 끝나면 창을 닫고 떠난다
bool _askingLeave = false;
bool _savedWhileAsking = false;
/// 창을 열 때의 저장 차례 — 이보다 오르면 그사이 저장이 끝난 것이다
int _askedSaveCount = 0;
/// 펼친 원본 메일의 줄 — 인용이 바뀔 때만 다시 만든다
ComposeQuote? _linesQuote;
List<QuoteDisplayLine> _lines = const <QuoteDisplayLine>[];
@override
void initState() {
super.initState();
ref.listenManual<ComposeState>(composeProvider(_args), _onStateChanged);
_controller = ref.read(composeProvider(_args).notifier);
_lifecycle = AppLifecycleListener(onHide: _controller.saveOnHide);
if (ref.read(composeProvider(_args)).status == ComposeStatus.ready) {
_scheduleAutofocus();
}
}
@override
void dispose() {
_lifecycle.dispose();
_controller.detach();
for (final FocusNode node in _recipientFocus.values) {
node.dispose();
}
_subjectFocus.dispose();
_bodyFocus.dispose();
super.dispose();
}
void _onStateChanged(ComposeState? previous, ComposeState next) {
if (previous?.status != ComposeStatus.ready &&
next.status == ComposeStatus.ready) {
_scheduleAutofocus();
}
// 나가기 확인 창이 떠 있다(창이 이미 닫히는 중이면 건드리지 않는다)
if (!_askingLeave ||
!mounted ||
(ModalRoute.of(context)?.isCurrent ?? true)) {
return;
}
if (next.ended) {
// 세션이 끝났다 — 저장하지 않았다. 창만 닫고 알리지 않는다
_askingLeave = false;
Navigator.of(context, rootNavigator: true).pop(false);
} else if (next.saveCount > _askedSaveCount &&
!next.unsaved &&
!next.sending) {
// 묻는 사이 나간 저장이 끝났다(저장 차례가 올랐고 잃을 것이 없다) — 창을 닫고 떠난다
_askingLeave = false;
_savedWhileAsking = true;
Navigator.of(context, rootNavigator: true).pop(true);
}
}
/// 처음 다 채웠을 때 한 번 — 비어 있는 첫 칸에 초점(받는 사람 → 제목 → 본문)
void _scheduleAutofocus() {
if (_autofocused) return;
_autofocused = true;
WidgetsBinding.instance.addPostFrameCallback((Duration _) {
if (!mounted || _closing) return;
final ComposeState state = ref.read(composeProvider(_args));
if (state.status != ComposeStatus.ready) return;
final FocusNode target = state.to.isEmpty
? _recipientFocus[RecipientFieldKind.to]!
: (_controller.subject.text.trim().isEmpty
? _subjectFocus
: _bodyFocus);
target.requestFocus();
});
}
/// 다음 화면을 그린 뒤 초점 — 방금 펼친 칸 · 막 다시 켜진 칸도 받는다
void _focusAfterFrame(FocusNode node) {
WidgetsBinding.instance.addPostFrameCallback((Duration _) {
if (mounted && !_closing) node.requestFocus();
});
}
void _notice(String text) => ref.read(mailNoticeSinkProvider)(text);
// ── 나가기 ──
/// ✕ · 막힌 뒤로 가기 — 위 머리말의 차례. 떠나기 전 저장을 기다리는 동안은 [보내기]를 막는다
Future<void> _leave() async {
if (_leaving || _closing || !mounted) return;
final ComposeState state = ref.read(composeProvider(_args));
if (!state.unsaved || state.sending) {
_close(announceSaved: _controller.savedForLeave);
return;
}
setState(() => _leaving = true);
final bool saved = await _controller.saveBeforeLeave();
if (!mounted) return;
if (ref.read(composeProvider(_args)).ended) {
// 기다리는 사이 세션이 끝났다 — 저장하지 않았다. 묻지도 알리지도 않는다
setState(() => _leaving = false);
return;
}
if (saved) {
_leaving = false;
_close(announceSaved: true);
return;
}
// 저장하지 못했다(실패 · 5초 넘음 · 찾지 못한 첨부) — 묻는다. 묻는 동안은 새로 저장하지 않는다(나간 저장은 이어진다)
_controller.suspendAutosave();
_askingLeave = true;
_savedWhileAsking = false;
_askedSaveCount = ref.read(composeProvider(_args)).saveCount;
final bool leave = await showConfirmDialog(
context,
title: MailComposeTexts.leaveTitle,
message: MailComposeTexts.leaveMessage,
confirmLabel: MailComposeTexts.leave,
cancelLabel: MailComposeTexts.stay,
stayIsPrimary: true,
);
_askingLeave = false;
if (!mounted) return;
setState(() => _leaving = false);
if (leave) {
_close(announceSaved: _savedWhileAsking);
} else {
_controller.resumeAutosave();
}
}
/// 닫는다 — 쌓아 연 화면이면 뒤로, 주소로 바로 열었으면 메일 첫 화면으로
void _close({required bool announceSaved}) {
if (_closing || !mounted) return;
_closing = true;
_announceSaved = announceSaved;
if (context.canPop()) {
context.pop();
return;
}
if (announceSaved) _notice(MailComposeTexts.savedOnLeave);
context.go(MailRoutes.home);
}
/// 화면이 닫혔다(뒤로 · 몸짓 · [_close]) — 이 화면이 저장해 둔 것이 있으면 알린다
void _onClosed() {
final bool announce = _closing ? _announceSaved : _controller.savedForLeave;
_closing = true;
if (announce) _notice(MailComposeTexts.savedOnLeave);
}
// ── 보내기 ──
Future<void> _send() async {
if (_closing || _leaving) return;
final GoRouter router = GoRouter.of(context);
final ComposeSendOutcome outcome = await _controller.send(router: router);
if (!mounted || _closing) return;
switch (outcome) {
case ComposeSendOutcome.sent:
_close(announceSaved: false);
case ComposeSendOutcome.focusRecipients:
_focusRecipients();
case ComposeSendOutcome.focusSubject:
_focusAfterFrame(_subjectFocus);
case ComposeSendOutcome.focusBody:
_focusAfterFrame(_bodyFocus);
case ComposeSendOutcome.none:
break;
}
}
/// 받는 사람이 걸렸다 — 형식이 틀린 주소가 든 첫 칸(받는 사람이 비었으면 받는 사람)으로. 참조 · 숨은참조에 주소가 있으면 펼쳐 보인다
void _focusRecipients() {
final ComposeState state = ref.read(composeProvider(_args));
bool invalidIn(List<RecipientToken> tokens) =>
tokens.any((RecipientToken token) => !token.valid);
RecipientFieldKind target = RecipientFieldKind.to;
if (state.to.isNotEmpty && !invalidIn(state.to)) {
if (invalidIn(state.cc)) {
target = RecipientFieldKind.cc;
} else if (invalidIn(state.bcc)) {
target = RecipientFieldKind.bcc;
}
}
if (!state.copiesOpen && (state.cc.isNotEmpty || state.bcc.isNotEmpty)) {
_controller.toggleCopies();
}
_focusAfterFrame(_recipientFocus[target]!);
}
/// 빈 받는 사람 칸에서 [다음] — 다음 칸으로
void _focusNextAfter(RecipientFieldKind kind) {
final bool copiesOpen = ref.read(composeProvider(_args)).copiesOpen;
final FocusNode next = switch (kind) {
RecipientFieldKind.to =>
copiesOpen ? _recipientFocus[RecipientFieldKind.cc]! : _subjectFocus,
RecipientFieldKind.cc => _recipientFocus[RecipientFieldKind.bcc]!,
RecipientFieldKind.bcc => _subjectFocus,
};
next.requestFocus();
}
List<QuoteDisplayLine> _linesOf(ComposeQuote quote) {
if (!identical(quote, _linesQuote)) {
_linesQuote = quote;
_lines = quoteDisplayLines(quote.blocks);
}
return _lines;
}
@override
Widget build(BuildContext context) {
final ComposeState state = ref.watch(composeProvider(_args));
final bool ready = state.status == ComposeStatus.ready;
// 보내는 동안은 칸 · 단추를 막는다(보낸 메일에 들어가지 않는 고침이 생기지 않게) — ✕ · 뒤로는 그대로
final bool editable = ready && !state.sending;
return PopScope(
canPop: !state.unsaved || state.sending,
onPopInvokedWithResult: (bool didPop, Object? _) {
if (didPop) {
_onClosed();
} else {
unawaited(_leave());
}
},
child: Scaffold(
appBar: ComposeAppBar(
title: composeTitleOf(state),
onClose: () => unawaited(_leave()),
onAttach: editable
? () => _notice(MailComposeTexts.attachNotReady)
: null,
onSend: ready && !_leaving ? () => unawaited(_send()) : null,
sending: state.sending,
),
body: switch (state.status) {
ComposeStatus.loading => ComposeSkeleton(
label: widget.intent is DraftComposeIntent
? MailComposeTexts.loading
: MailComposeTexts.loadingSource,
),
ComposeStatus.failed => _ComposeError(
error: state.error ?? ApiException.fromCode('SYS_001'),
onRetry: () => unawaited(_controller.retry()),
onLeave: () => unawaited(_leave()),
),
ComposeStatus.ready => _form(context, state),
},
),
);
}
Widget _form(BuildContext context, ComposeState state) {
final ComposeController controller = _controller;
final bool enabled = !state.sending;
final ComposeQuote? quote = state.quote;
final ComposeSignature? signature = state.signature;
final String? recipientError = state.recipientErrorCode;
final ComposeLengthError? lengthError = state.lengthError;
return CustomScrollView(
slivers: <Widget>[
SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
_recipientField(
state,
RecipientFieldKind.to,
trailing: _CopiesToggle(
open: state.copiesOpen,
onPressed: enabled ? controller.toggleCopies : null,
),
),
if (state.copiesOpen) ...<Widget>[
_recipientField(state, RecipientFieldKind.cc),
_recipientField(state, RecipientFieldKind.bcc),
],
if (recipientError != null)
ComposeAlert(
key: const ValueKey<String>('compose-alert-recipients'),
message: ApiException.fromCode(recipientError).message,
),
_SubjectRow(
controller: controller.subject,
focusNode: _subjectFocus,
enabled: enabled,
error: lengthError == ComposeLengthError.subject,
onSubmitted: () => _bodyFocus.requestFocus(),
),
if (lengthError == ComposeLengthError.subject)
ComposeAlert(
key: const ValueKey<String>('compose-alert-subject'),
message: ApiException.fromCode('VAL_001').message,
),
if (state.foreignNotice)
const ReaderNotice(
key: ValueKey<String>('compose-foreign-notice'),
tone: ReaderNoticeTone.warning,
icon: Icons.info_outline,
text: MailComposeTexts.foreignDraft,
),
if (state.attachments.isNotEmpty || state.largeFiles.isNotEmpty)
ComposeAttachmentsSection(
attachments: state.attachments,
largeFiles: state.largeFiles,
onRemoveAttachment: controller.removeAttachment,
onRemoveLargeFile: controller.removeLargeFile,
enabled: enabled,
),
_BodyField(
controller: controller.body,
focusNode: _bodyFocus,
enabled: enabled,
error: lengthError == ComposeLengthError.body,
minHeight: quote != null || signature != null
? AppSize.composeBodyMinHeightWithQuote
: AppSize.composeBodyMinHeight,
),
if (lengthError == ComposeLengthError.body)
ComposeAlert(
key: const ValueKey<String>('compose-alert-body'),
message: ApiException.fromCode('MAIL_007').message,
),
if (signature != null)
state.signatureIncluded
? ComposeSignatureSection(
signature: signature,
onRemove: enabled ? controller.removeSignature : null,
)
: ComposeInsertSignature(
onInsert: enabled ? controller.insertSignature : null,
),
if (quote != null)
ComposeQuoteToggle(
summary: quote.summary,
open: _quoteOpen,
onToggle: () => setState(() => _quoteOpen = !_quoteOpen),
),
],
),
),
if (quote != null && _quoteOpen)
ComposeQuoteSliver(quote: quote, lines: _linesOf(quote)),
SliverToBoxAdapter(
child: SizedBox(
height: MediaQuery.paddingOf(context).bottom + AppSpacing.s24,
),
),
],
);
}
Widget _recipientField(
ComposeState state,
RecipientFieldKind kind, {
Widget? trailing,
}) {
final ComposeController controller = _controller;
return ComposeRecipientField(
key: ValueKey<RecipientFieldKind>(kind),
label: fieldLabelOf(kind),
tokens: state.tokensOf(kind),
controller: controller.inputs[kind]!,
focusNode: _recipientFocus[kind]!,
optionsFor: controller.optionsFor,
revision: state.suggestionsRevision,
onChanged: (String text) => controller.onRecipientChanged(kind, text),
onCommit: () => controller.commitRecipientInput(kind),
onRemove: (RecipientToken token) =>
controller.removeRecipient(kind, token),
onPick: (RecipientOption option) => controller.pickOption(kind, option),
onSubmitEmpty: () => _focusNextAfter(kind),
onFocused: controller.preloadSuggestions,
rejected: state.recipientRejection != null,
status: state.groupStatus[kind],
trailing: trailing,
enabled: !state.sending,
);
}
}
/// 위 막대의 제목 — 쓰기 종류(저장본을 받기 전에는 "메일 쓰기")
String composeTitleOf(ComposeState state) {
if (!state.titleKnown) return MailComposeTexts.loadingTitle;
return switch (state.kind) {
ComposeKind.newMail => MailComposeTexts.newMail,
ComposeKind.reply => MailComposeTexts.reply,
ComposeKind.replyAll => MailComposeTexts.replyAll,
ComposeKind.forward => MailComposeTexts.forward,
};
}
/// 받는 사람 줄 오른쪽 — 참조 · 숨은참조 펼치기 · 접기 (보드 — 48, 옅은 색)
class _CopiesToggle extends StatelessWidget {
const _CopiesToggle({required this.open, required this.onPressed});
final bool open;
/// null 이면 꺼진 단추(보내는 중)
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
final AppExtraColors extra = Theme.of(context).extension<AppExtraColors>()!;
return Semantics(
expanded: open,
child: IconButton(
icon: Icon(
open ? Icons.expand_less : Icons.expand_more,
size: AppSize.iconMedium,
),
tooltip: open
? MailComposeTexts.collapseCopies
: MailComposeTexts.expandCopies,
color: extra.textMuted,
onPressed: onPressed,
),
);
}
}
/// 제목 줄 — 굵은 글 · [다음]은 본문으로. 칸에 치는 글은 UTF-16 255자까지(웹 `maxlength` · 서버와 같은 단위 — 짝 글자는 가르지 않는다.
/// 저장본 · mailto 로 넘게 들어온 제목은 보내기 전 검사가 막는다)
class _SubjectRow extends StatelessWidget {
const _SubjectRow({
required this.controller,
required this.focusNode,
required this.enabled,
required this.error,
required this.onSubmitted,
});
final TextEditingController controller;
final FocusNode focusNode;
/// 거짓이면 막는다(보내는 중)
final bool enabled;
/// 길이가 한도를 넘었다 — 아래 선이 빨갛고 "잘못됨"으로 읽힌다
final bool error;
final VoidCallback onSubmitted;
@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,
);
return ListenableBuilder(
listenable: focusNode,
builder: (BuildContext context, Widget? _) => ComposeFieldRow(
label: MailComposeTexts.subject,
focused: focusNode.hasFocus,
error: error,
onTap: enabled ? focusNode.requestFocus : null,
child: Semantics(
label: MailComposeTexts.subject,
validationResult: error
? SemanticsValidationResult.invalid
: SemanticsValidationResult.none,
child: TextField(
key: const ValueKey<String>('compose-subject'),
controller: controller,
focusNode: focusNode,
enabled: enabled,
textInputAction: TextInputAction.next,
inputFormatters: const <TextInputFormatter>[
Utf16LengthLimitFormatter(kComposeMaxSubject),
],
style: style,
decoration: InputDecoration.collapsed(
hintText: MailComposeTexts.subjectHint,
hintStyle: style?.copyWith(
fontWeight: AppTypography.regular,
color: extra.textMuted,
),
),
onSubmitted: (String _) => onSubmitted(),
),
),
),
);
}
}
/// 본문 칸 — 글만(서식 편집은 다음 판). 비어 있는 아래쪽을 눌러도 칸에 초점이 간다.
/// 아래에 서명 · 원본 메일이 있으면 낮게(150), 없으면 높게(240) 둔다(보드)
class _BodyField extends StatelessWidget {
const _BodyField({
required this.controller,
required this.focusNode,
required this.enabled,
required this.error,
required this.minHeight,
});
final TextEditingController controller;
final FocusNode focusNode;
/// 거짓이면 막는다(보내는 중)
final bool enabled;
/// 길이가 한도를 넘었다(`MAIL_007`) — "잘못됨"으로 읽힌다
final bool error;
final double minHeight;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
final TextStyle? style = theme.textTheme.bodyLarge?.copyWith(
color: extra.textBody,
height: _bodyLineHeight,
);
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: enabled ? focusNode.requestFocus : null,
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: minHeight),
child: Padding(
padding: const EdgeInsets.all(AppSpacing.s16),
child: Semantics(
label: MailComposeTexts.body,
validationResult: error
? SemanticsValidationResult.invalid
: SemanticsValidationResult.none,
child: TextField(
key: const ValueKey<String>('compose-body'),
controller: controller,
focusNode: focusNode,
enabled: enabled,
keyboardType: TextInputType.multiline,
textInputAction: TextInputAction.newline,
maxLines: null,
style: style,
decoration: InputDecoration.collapsed(
hintText: MailComposeTexts.bodyHint,
hintStyle: style?.copyWith(color: extra.textMuted),
),
),
),
),
),
);
}
}
/// 원본 · 저장본을 받지 못함 — 사전 문구 · 문의 코드 · [다시 시도](없는 메일 · 저장본은 [닫기] — 다시 해도 같다)
class _ComposeError extends StatelessWidget {
const _ComposeError({
required this.error,
required this.onRetry,
required this.onLeave,
});
final ApiException error;
final VoidCallback onRetry;
final VoidCallback onLeave;
@override
Widget build(BuildContext context) {
return CustomScrollView(
slivers: <Widget>[
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: MailListError(
error: error,
onRetry: onRetry,
onLeave: onLeave,
leaveLabel: MailComposeTexts.close,
),
),
),
],
);
}
}
@@ -19,6 +19,7 @@ import 'package:arasystem_app/features/mail/presentation/providers/mailboxes_pro
import 'package:arasystem_app/features/mail/presentation/providers/message_reader_controller.dart';
import 'package:arasystem_app/features/mail/presentation/providers/message_reader_state.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/mail_body_view.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/mail_compose_area.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/mail_list_states.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/mail_move_sheet.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/reader_attachments.dart';
@@ -41,7 +42,9 @@ const double kReaderHeadMaxFraction = 0.5;
/// 머리가 길면(펼친 받는 사람 · 첨부 여럿) 남은 자리의 절반까지만 쓰고 머리 안에서 스크롤한다.
/// - **첨부 목록은 머리 안(안내 줄 아래 · 본문 위)이다** — 보드는 본문 아래지만, 본문이 WebView 안에서 스크롤하므로 아래에 두면
/// 늘 화면 아래 자리를 차지한다(쓰기 화면처럼 위에 둔다 — 앱에서 정함).
/// - 아래 행동 막대: 답장 · 전체 답장 · 전달 — 쓰기 화면(다음 차례)으로 그 메일 id 를 넘긴다(`MailRoutes.composeReply` 등). 예약한 메일에는 없다(웹과 같다).
/// - 아래 행동 막대: 답장 · 전체 답장 · 전달 — 쓰기 화면으로 그 메일 id 를 넘긴다(`MailRoutes.composeReply` 등). 예약한 메일에는 없다(웹과 같다).
/// - 아래 막대 위(없으면 화면 아래)에 단추 있는 알림 줄 자리 — 쓰기 화면에서 보내고 돌아오면 "메일을 보냈습니다. [보내기 취소]" + 남은 시간 막대
/// (보드 `App1Compose` 「보낸 뒤」 — 막대 위 12).
/// - 옮기기 · 스팸 · 휴지통 · 영구 삭제 · 발송 취소가 끝나면 목록으로 돌아간다(목록 · 메일함 개수는 컨트롤러가 맞춘다).
/// 그 일을 하는 동안은 아래 막대와 본문 링크(mailto 포함)를 막고, 돌아가기는 읽기 화면이 맨 위일 때만 한다(위에 연 화면을 닫지 않게).
class MessageReaderPage extends ConsumerStatefulWidget {
@@ -198,6 +201,8 @@ class _MessageReaderPageState extends ConsumerState<MessageReaderPage> {
final bool held = detail?.summary.isHeld ?? false;
final bool scheduled =
detail?.summary.pendingSend?.kind == PendingSendKind.scheduled;
final bool actionBar = detail != null && !scheduled;
final EdgeInsets safeInsets = MediaQuery.paddingOf(context);
return Scaffold(
appBar: ReaderAppBar(
@@ -236,7 +241,14 @@ class _MessageReaderPageState extends ConsumerState<MessageReaderPage> {
),
},
// 삭제 · 이동 · 스팸 등을 하는 동안은 쓰기 화면을 열지 않는다 — 끝나면 목록으로 돌아간다
bottomNavigationBar: detail == null || scheduled
// 알림 줄 — 아래 막대가 있으면 막대가 안전 영역을 이미 뺐다
floatingActionButton: MailComposeArea(
key: const ValueKey<String>('reader-toast-area'),
showCompose: false,
safeInsets: actionBar ? safeInsets.copyWith(bottom: 0) : safeInsets,
),
floatingActionButtonLocation: const MailComposeAreaLocation(),
bottomNavigationBar: !actionBar
? null
: ReaderActionBar(
onReply: busy
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,258 @@
import 'package:arasystem_app/core/api/api_exception.dart';
import 'package:arasystem_app/core/people/address_input.dart';
import 'package:arasystem_app/features/mail/data/models/compose_models.dart';
import 'package:arasystem_app/features/mail/domain/compose_body.dart';
import 'package:arasystem_app/features/mail/domain/compose_reply.dart';
import 'package:arasystem_app/features/mail/domain/compose_request.dart';
import 'package:arasystem_app/features/mail/domain/compose_signature.dart';
import 'package:flutter/foundation.dart';
/// 쓰기 화면을 여는 값 — 화면(State)마다 하나. [instance] 로 가른다(같은 주소의 쓰기 화면을 다시 열어도 앞 화면의 보내기와 섞이지 않게)
@immutable
class ComposeArgs {
const ComposeArgs({
required this.instance,
required this.intent,
this.prefill,
});
final int instance;
final ComposeIntent intent;
/// mailto 로 채울 값 — 새 메일만
final ComposePrefill? prefill;
@override
bool operator ==(Object other) =>
other is ComposeArgs && other.instance == instance;
@override
int get hashCode => instance.hashCode;
}
/// 원문 · 저장본을 받는 중 · 다 채움 · 받지 못함([다시 시도])
enum ComposeStatus { loading, ready, failed }
/// 받는 사람 칸 — 받는 사람 · 참조 · 숨은참조
enum RecipientFieldKind { to, cc, bcc }
/// 길이가 한도를 넘은 칸 — 제목 255자 · 본문(인용을 다 줄여도 넘음)
enum ComposeLengthError { subject, body }
/// 첨부 줄의 문제
enum ComposeAttachmentProblem {
/// 함께 보낼 수 없는 형식 · 개수 · 합계(사전 검사) — 싣지 않는다
unsendable,
/// 서버가 찾지 못했다(`MAIL_004`) — 빼기 전까지 저장 · 보내기를 막는다
missing,
}
/// 쓰기 화면의 일반 첨부 하나 — 전달하는 원본 · 저장본에 이미 있던 첨부(앱은 첫 판에서 새로 올리지 않는다)
@immutable
class ComposeAttachment {
const ComposeAttachment({
required this.key,
required this.id,
required this.name,
required this.size,
this.problem,
});
/// 화면 안의 이름 — 저장할 때마다 바뀌는 [id] 와 달리 그대로다
final int key;
final String id;
final String name;
final int size;
final ComposeAttachmentProblem? problem;
/// 저장 · 보내기에 싣는다
bool get sendable => problem == null;
ComposeAttachment copyWith({String? id, ComposeAttachmentProblem? problem}) =>
ComposeAttachment(
key: key,
id: id ?? this.id,
name: name,
size: size,
problem: problem ?? this.problem,
);
@override
bool operator ==(Object other) =>
other is ComposeAttachment &&
other.key == key &&
other.id == id &&
other.name == name &&
other.size == size &&
other.problem == problem;
@override
int get hashCode => Object.hash(key, id, name, size, problem);
}
/// 쓰기 화면의 상태 — 받는 사람 칩 · 첨부 · 서명 · 인용 · 칸 안내. 제목 · 본문 · 칸에 쓰는 글은 컨트롤러의 글 칸(TextEditingController)에 있다
@immutable
class ComposeState {
const ComposeState({
this.status = ComposeStatus.loading,
this.error,
this.kind = ComposeKind.newMail,
this.titleKnown = true,
this.to = const <RecipientToken>[],
this.cc = const <RecipientToken>[],
this.bcc = const <RecipientToken>[],
this.copiesOpen = false,
this.attachments = const <ComposeAttachment>[],
this.largeFiles = const <DraftLargeFile>[],
this.signature,
this.signatureIncluded = false,
this.quote,
this.foreignNotice = false,
this.attempted = false,
this.recipientRejection,
this.lengthError,
this.sending = false,
this.groupStatus = const <RecipientFieldKind, String>{},
this.suggestionsRevision = 0,
this.unsaved = false,
this.saveCount = 0,
this.ended = false,
});
final ComposeStatus status;
/// 받지 못했을 때의 실패
final ApiException? error;
final ComposeKind kind;
/// 쓰기 종류를 안다 — 저장본을 받기 전에는 거짓("메일 쓰기")
final bool titleKnown;
final List<RecipientToken> to;
final List<RecipientToken> cc;
final List<RecipientToken> bcc;
/// 참조 · 숨은참조 칸을 펼쳤다
final bool copiesOpen;
final List<ComposeAttachment> attachments;
final List<DraftLargeFile> largeFiles;
/// 넣을 수 있는 서명 — 없으면 서명 칸도 [서명 넣기]도 없다
final ComposeSignature? signature;
/// 서명을 넣는다 — 거짓이면 그 자리에 [서명 넣기]
final bool signatureIncluded;
final ComposeQuote? quote;
/// 서식이나 그림이 있는 남의 저장본 — 열 때 안내 줄
final bool foreignNotice;
/// 보내기를 한 번 눌렀다 — 받는 사람이 비었다는 안내는 그 뒤에만
final bool attempted;
/// 서버가 받는 사람을 거절했다 — `MAIL_002` · `MAIL_010` (받는 사람을 고치거나 다시 보내면 내린다)
final String? recipientRejection;
final ComposeLengthError? lengthError;
final bool sending;
/// 그룹을 넣은 뒤(또는 구성원을 받는 동안) 그 칸 아래 글
final Map<RecipientFieldKind, String> groupStatus;
/// 받아 둔 추천이 바뀔 때마다 오른다 — 칸이 추천을 다시 거른다
final int suggestionsRevision;
/// 떠나면 잃는 것이 있다 — 저장하지 않은 변경 · 칸에 쓰다 만 주소 · 찾지 못한 첨부
final bool unsaved;
/// 이 양식을 저장한 차례 — 저장이 끝날 때마다 오른다(나가기 확인 창이 그사이 끝난 저장을 안다)
final int saveCount;
/// 메일을 보는 직원이 바뀌었다(로그아웃 · 세션 끝 · 다른 직원) — 묻지도 저장하지도 알리지도 않는다
final bool ended;
List<RecipientToken> tokensOf(RecipientFieldKind kind) => switch (kind) {
RecipientFieldKind.to => to,
RecipientFieldKind.cc => cc,
RecipientFieldKind.bcc => bcc,
};
List<RecipientToken> get allRecipients => <RecipientToken>[
...to,
...cc,
...bcc,
];
bool get hasInvalidRecipient =>
allRecipients.any((RecipientToken token) => !token.valid);
/// 받는 사람 합계 100명 초과 — 서버도 `MAIL_002` 로 거절한다
bool get tooManyRecipients => allRecipients.length > kComposeMaxRecipients;
/// 보내기 전 검사에 걸린 받는 사람 — 형식이 틀린 주소, 보내기를 누른 뒤의 빈 받는 사람 · 100명 초과
bool get recipientPrecheckFailed =>
hasInvalidRecipient || (attempted && (to.isEmpty || tooManyRecipients));
/// 받는 사람 칸 아래 띠의 코드 — 없으면 null. 문구는 사전에서(보내기 전 검사는 `MAIL_002`)
String? get recipientErrorCode {
if (recipientPrecheckFailed) return 'MAIL_002';
return recipientRejection;
}
/// 보내기 · 저장에 싣는 대용량 파일이 하나라도 있다 — 인용 경계 표시를 둔다
bool get hasLargeFiles => largeFiles.isNotEmpty;
ComposeState copyWith({
ComposeStatus? status,
ApiException? error,
ComposeKind? kind,
bool? titleKnown,
List<RecipientToken>? to,
List<RecipientToken>? cc,
List<RecipientToken>? bcc,
bool? copiesOpen,
List<ComposeAttachment>? attachments,
List<DraftLargeFile>? largeFiles,
ComposeSignature? signature,
bool? signatureIncluded,
ComposeQuote? quote,
bool clearQuote = false,
bool? foreignNotice,
bool? attempted,
String? recipientRejection,
bool clearRecipientRejection = false,
ComposeLengthError? lengthError,
bool clearLengthError = false,
bool? sending,
Map<RecipientFieldKind, String>? groupStatus,
int? suggestionsRevision,
bool? unsaved,
int? saveCount,
bool? ended,
}) => ComposeState(
status: status ?? this.status,
error: error ?? this.error,
kind: kind ?? this.kind,
titleKnown: titleKnown ?? this.titleKnown,
to: to ?? this.to,
cc: cc ?? this.cc,
bcc: bcc ?? this.bcc,
copiesOpen: copiesOpen ?? this.copiesOpen,
attachments: attachments ?? this.attachments,
largeFiles: largeFiles ?? this.largeFiles,
signature: signature ?? this.signature,
signatureIncluded: signatureIncluded ?? this.signatureIncluded,
quote: clearQuote ? null : (quote ?? this.quote),
foreignNotice: foreignNotice ?? this.foreignNotice,
attempted: attempted ?? this.attempted,
recipientRejection: clearRecipientRejection
? null
: (recipientRejection ?? this.recipientRejection),
lengthError: clearLengthError ? null : (lengthError ?? this.lengthError),
sending: sending ?? this.sending,
groupStatus: groupStatus ?? this.groupStatus,
suggestionsRevision: suggestionsRevision ?? this.suggestionsRevision,
unsaved: unsaved ?? this.unsaved,
saveCount: saveCount ?? this.saveCount,
ended: ended ?? this.ended,
);
}
@@ -0,0 +1,409 @@
import 'dart:async';
import 'package:arasystem_app/core/api/api_error_mapper.dart';
import 'package:arasystem_app/features/mail/data/models/compose_models.dart';
import 'package:arasystem_app/features/mail/data/models/compose_payload.dart';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
/// 입력이 이만큼 멈추면 저장한다 (웹 `DRAFT_IDLE_SAVE_MS`)
const Duration kDraftIdleSave = Duration(seconds: 3);
/// 저장 사이의 최소 간격 — 10초에 한 번까지 (웹 `DRAFT_MIN_SAVE_INTERVAL_MS`)
const Duration kDraftMinSaveInterval = Duration(seconds: 10);
/// 쉬지 않고 쓰는 동안에도 — 마지막 저장을 시작한 뒤 처음 바꾼 때부터 이만큼 지나면 멈춤을 기다리지 않는다 (웹 `DRAFT_MAX_WAIT_MS`)
const Duration kDraftMaxWait = Duration(seconds: 30);
/// 떠나기 전 저장을 기다리는 시간의 상한 — 넘으면 "저장하지 못한 내용이 있습니다"를 묻는다(저장은 뒤에서 이어진다 — 웹 `DRAFT_LEAVE_SAVE_WAIT_MS`)
const Duration kDraftLeaveSaveWait = Duration(seconds: 5);
/// 저장이 실패했을 때 다시 해 보기까지 — 실패할수록 길게, 마지막 값에서 멈춘다 (웹 `DRAFT_RETRY_DELAYS_MS`)
const List<Duration> kDraftRetryDelays = <Duration>[
Duration(seconds: 15),
Duration(seconds: 30),
Duration(seconds: 60),
];
/// 저절로 다시 해 볼 실패 — 메일 서버 장애 · 메일함 용량 · 요청 한도 · 네트워크 (웹 `DRAFT_RETRY_CODES`)
const Set<String> kDraftRetryCodes = <String>{
'MAIL_001',
'MAIL_003',
'REQ_001',
'SYS_001',
};
/// 옛 저장본이 다른 곳에서 바뀌었거나 없을 때 — 막지 않고 지금 내용을 새 저장본으로 남긴다 (웹 `REPLACED_CODES`)
const Set<String> kDraftReplacedCodes = <String>{'MAIL_008', 'RES_001'};
/// 저장을 시작한 까닭
enum DraftSaveTrigger {
/// 입력 멈춤 · 30초 · 다시 해 보기
auto,
/// 화면을 떠나기 전
leave,
/// 앱이 안 보이게 됨(홈 · 다른 앱 · 화면 끄기) — 10초 간격을 기다리지 않는다
hide,
}
/// 저장 상태 — 화면에는 보이지 않는다(나갈 때만 알린다)
enum DraftSaveStatus { idle, saving, saved, failed, replaced }
/// 저장 한 번의 계획
sealed class DraftSavePlan {
const DraftSavePlan();
}
/// 저장 요청 — 보낼 본문과, 저장이 끝났을 때 그 내용을 "저장한 것"으로 삼는 함수
final class DraftSaveRequest extends DraftSavePlan {
const DraftSaveRequest({required this.payload, required this.commit});
final MailComposePayload payload;
final void Function(DraftSaved saved) commit;
}
/// 저장할 수 없는 내용 — 보내기 전 검사에 걸렸다(본문이 한도를 넘음 `MAIL_007`). 요청 없이 실패로 두고 저절로 다시 하지 않는다
final class DraftSaveRejected extends DraftSavePlan {
const DraftSaveRejected(this.code);
final String code;
}
/// 쓰기 화면의 임시 저장 — 계약 2절 "임시 저장" (**짝: 웹 `composables/useDraftSaving.ts`** — 같은 주기 · 같은 규칙, 상수도 같은 값).
///
/// - 바꾼 내용이 있으면 입력이 3초 멈출 때, 10초에 한 번까지. 쉬지 않고 쓰는 동안에도 처음 바꾼 뒤 30초가 지나면 저장한다. 한 번에 하나씩 —
/// 저장하는 동안 바뀐 것은 끝난 뒤 다시.
/// - 처음은 POST, 그다음은 PUT {직전 id}. 옛 저장본이 다른 곳에서 바뀌었거나 없으면(409 `MAIL_008` · 404 `RES_001`) POST 로 새로 남긴다(replaced).
/// - 실패는 알리지 않고 상태로만 두고, 다시 해 보면 나아질 실패([kDraftRetryCodes])는 15 · 30 · 60초 뒤 저절로 다시 한다.
/// - 앱이 안 보이게 되면([saveOnHide]) 10초 간격을 기다리지 않고 바로 저장한다(앱이 정리돼도 잃지 않게 — 앱에서 더함).
/// - 보내기 전에는 [suspend] 로 멈추고 [settle] 로 진행 중인 저장을 기다린다(draftId 가 최신이어야 한다).
///
/// 시간은 `Timer` 로만 잰다(10초 간격은 "닫힌 문" 타이머) — 시험은 시험 시간만 넘기면 된다(손 시계가 필요 없다).
class DraftAutosave {
DraftAutosave({
required this.canSave,
required this.hasChanges,
required this.prepare,
required this.create,
required this.update,
this.canFallback,
this.onSaved,
this.onFailed,
});
/// 지금 저장해도 되는가 — 채우는 중 · 보내는 중 아님 · 같은 세션 등
final bool Function() canSave;
/// 마지막으로 저장한(저장한 적 없으면 연) 뒤 바꾼 내용이 있다
final bool Function() hasChanges;
/// 지금 내용으로 저장 계획을 만든다 — 만들 수 없으면 null
final DraftSavePlan? Function() prepare;
final Future<DraftSaved> Function(MailComposePayload payload) create;
final Future<DraftSaved> Function(String id, MailComposePayload payload)
update;
/// PUT 이 막혀 새로 남기기 전에 — 거짓이면 남기지 않는다(그사이 세션이 바뀌었으면 다른 직원의 임시보관함으로 가지 않게)
final bool Function()? canFallback;
/// 저장이 끝났다 — [previousId] 는 이 저장이 바꾼(PUT 한) 저장본, 새로 만들었으면 null. 양식이 바뀐 뒤 끝난 저장도 부른다
final void Function(String? previousId, DraftSaved saved, bool replaced)?
onSaved;
/// 저장이 실패했다 — 찾지 못한 첨부(`MAIL_004`) · 본문 한도(`MAIL_007`) 등을 화면이 표시한다
final void Function(String code, DraftSaveTrigger trigger)? onFailed;
/// 마지막으로 저장한 저장본 id — 다음 저장은 이 id 로 PUT, 보내기는 draftId 로
String? draftId;
DraftSaveStatus status = DraftSaveStatus.idle;
/// 양식 세대 — [reset] 마다 올린다. 늦게 끝난 저장이 새 양식을 건드리지 않게
int _generation = 0;
Future<bool>? _inFlight;
Timer? _idle;
Timer? _maxWait;
Timer? _retry;
/// 10초 간격의 문 — 저장을 시작하면 닫히고 10초 뒤 열린다. 닫힌 동안 온 차례는 열릴 때 한다
Timer? _gate;
bool _gateWaiting = false;
int _retryCount = 0;
bool _queued = false;
bool _hideQueued = false;
bool _suspended = false;
bool _stopped = false;
/// 저장하는 중
bool get saving => _inFlight != null;
/// 내용이 바뀌었다 — 입력이 3초 멈추면 저장한다. 바꾼 내용이 처음 생긴 뒤 30초가 지나면 멈춤을 기다리지 않는다(그 타이머는 한 번만 건다)
void touch() {
if (_suspended || _stopped) return;
_cancelIdle();
if (!hasChanges()) {
_cancelMaxWait();
return;
}
_idle = Timer(kDraftIdleSave, _autosave);
_maxWait ??= Timer(kDraftMaxWait, () {
_maxWait = null;
_autosave();
});
}
/// 진행 중인 저장이 끝나기를 기다린다(보내기 · 떠나기 전)
Future<void> settle() async {
Future<bool>? current = _inFlight;
while (current != null) {
await current;
if (identical(_inFlight, current)) return;
current = _inFlight;
}
}
/// 떠나기 전 — 남은 변경을 저장한다. 저장한 그대로면(또는 저장에 성공하면) 참
Future<bool> saveBeforeLeave() async {
_cancelIdle();
await settle();
if (!hasChanges()) return true;
return _run(DraftSaveTrigger.leave);
}
/// 앱이 안 보이게 됐다 — 바꾼 것이 있으면 10초 간격을 기다리지 않고 바로(진행 중이면 끝난 뒤 바로)
void saveOnHide() {
if (_suspended || _stopped) return;
if (_inFlight != null) {
_hideQueued = true;
return;
}
if (!hasChanges()) return;
unawaited(_run(DraftSaveTrigger.hide));
}
/// 자동 저장을 멈춘다 — 보내기 · 나가기 확인 동안
void suspend() {
_suspended = true;
_cancelIdle();
_cancelMaxWait();
_cancelRetry();
_gateWaiting = false;
_hideQueued = false;
}
/// 다시 자동 저장한다 — 보내기가 실패했을 때 등
void resume() {
if (_stopped) return;
_suspended = false;
touch();
}
/// 더 저장하지 않는다 — 보냈거나 화면을 떠났다(진행 중인 저장은 끝까지 받아 draftId 를 맞춘다)
void stop() {
_stopped = true;
suspend();
}
/// 양식을 새로 채웠다 — 이어 쓰는 저장본이면 그 id 로 시작한다.
/// [keepOriginal] 이면 첫 저장을 POST 로(원본을 지우지 않는다 — 우리가 실을 수 없는 첨부가 든 남의 저장본)
void reset({String? existingId, bool keepOriginal = false}) {
_generation++;
_cancelIdle();
_cancelMaxWait();
_cancelRetry();
_gate?.cancel();
_gate = null;
_gateWaiting = false;
_inFlight = null;
_queued = false;
_hideQueued = false;
_suspended = false;
_stopped = false;
_retryCount = 0;
draftId = existingId != null && !keepOriginal ? existingId : null;
status = existingId != null ? DraftSaveStatus.saved : DraftSaveStatus.idle;
}
/// 타이머를 모두 거둔다 — 화면 · 프로바이더가 사라질 때
void dispose() {
stop();
_gate?.cancel();
_gate = null;
}
/// 자동 저장 차례 — 10초 안에 이미 저장을 시작했으면 문이 열릴 때 한다(입력 멈춤 · 30초 · 다시 해 보기가 모두 여기로 온다)
void _autosave() {
_cancelIdle();
if (_suspended || _stopped) return;
if (_inFlight != null) {
_queued = true;
return;
}
if (!hasChanges()) return;
if (_gate != null) {
_gateWaiting = true;
return;
}
unawaited(_run(DraftSaveTrigger.auto));
}
void _onGate() {
_gate = null;
if (!_gateWaiting) return;
_gateWaiting = false;
_autosave();
}
Future<bool> _run(DraftSaveTrigger trigger) async {
if (_stopped || !canSave()) return false;
final DraftSavePlan? plan = prepare();
if (plan == null) return false;
_cancelIdle();
_cancelMaxWait();
_cancelRetry();
_queued = false;
final DraftSaveRequest request;
switch (plan) {
case DraftSaveRejected(:final String code):
// 보내도 서버가 거절할 내용이다 — 요청 없이 실패로 둔다(내용이 바뀌면 touch 가 다시 잡는다)
status = DraftSaveStatus.failed;
onFailed?.call(code, trigger);
return false;
case final DraftSaveRequest planned:
request = planned;
}
final int started = _generation;
_gate?.cancel();
_gate = Timer(kDraftMinSaveInterval, _onGate);
_gateWaiting = false;
status = DraftSaveStatus.saving;
final String? previousId = draftId;
final Future<bool> attempt = _attempt(
request,
previousId,
started,
trigger,
);
_inFlight = attempt;
final bool ok = await attempt;
if (identical(_inFlight, attempt)) _inFlight = null;
if (started == _generation && !_stopped && !_suspended) {
if (_hideQueued) {
_hideQueued = false;
if (hasChanges()) unawaited(_run(DraftSaveTrigger.hide));
} else if (_queued) {
_queued = false;
_autosave();
}
}
return ok;
}
Future<bool> _attempt(
DraftSaveRequest request,
String? previousId,
int started,
DraftSaveTrigger trigger,
) async {
try {
final ({DraftSaved saved, bool replaced}) result = await _putOrCreate(
request.payload,
previousId,
);
onSaved?.call(previousId, result.saved, result.replaced);
if (started != _generation) return false;
draftId = result.saved.id;
status = result.replaced
? DraftSaveStatus.replaced
: DraftSaveStatus.saved;
_retryCount = 0;
request.commit(result.saved);
return true;
} on DioException catch (error) {
if (started != _generation) return false;
final String code = apiErrorCodeOf(error) ?? kFallbackCode;
status = DraftSaveStatus.failed;
onFailed?.call(code, trigger);
final bool retryable =
error.type != DioExceptionType.cancel &&
kDraftRetryCodes.contains(code);
if (retryable && !_suspended && !_stopped) _scheduleRetry();
return false;
}
}
/// id 가 있으면 PUT, 그 저장본이 다른 곳에서 바뀌었거나 없으면(`MAIL_008` · `RES_001`) 지금 내용을 POST 로 새로 남긴다 (웹 `putOrCreate`)
Future<({DraftSaved saved, bool replaced})> _putOrCreate(
MailComposePayload payload,
String? id,
) async {
if (id == null) return (saved: await create(payload), replaced: false);
try {
return (saved: await update(id, payload), replaced: false);
} on DioException catch (error) {
final bool Function()? fallbackAllowed = canFallback;
if (!kDraftReplacedCodes.contains(apiErrorCodeOf(error)) ||
(fallbackAllowed != null && !fallbackAllowed())) {
rethrow;
}
return (saved: await create(payload), replaced: true);
}
}
void _scheduleRetry() {
_cancelRetry();
final Duration delay =
kDraftRetryDelays[_retryCount.clamp(0, kDraftRetryDelays.length - 1)];
_retryCount++;
_retry = Timer(delay, () {
_retry = null;
_autosave();
});
}
void _cancelIdle() {
_idle?.cancel();
_idle = null;
}
void _cancelMaxWait() {
_maxWait?.cancel();
_maxWait = null;
}
void _cancelRetry() {
_retry?.cancel();
_retry = null;
}
@visibleForTesting
bool get hasPendingTimers =>
_idle != null || _maxWait != null || _retry != null || _gate != null;
}
/// 양식 없이 한 번 조용히 저장한다 — 보내는 중에 화면을 떠났고 발송이 실패했을 때 보내려던 내용을 남긴다(웹 `saveDraftDetached`).
/// 규칙은 자동 저장과 같다(PUT → `MAIL_008` · `RES_001` 이면 POST). [stillValid] 가 거짓이 되면(세션이 바뀜) 새로 남기지 않는다. 실패하면 null
Future<DraftSaved?> saveDraftDetached({
required MailComposePayload payload,
required String? draftId,
required Future<DraftSaved> Function(MailComposePayload payload) create,
required Future<DraftSaved> Function(String id, MailComposePayload payload)
update,
required bool Function() stillValid,
}) async {
try {
if (draftId == null) return await create(payload);
try {
return await update(draftId, payload);
} on DioException catch (error) {
if (!kDraftReplacedCodes.contains(apiErrorCodeOf(error)) ||
!stillValid()) {
return null;
}
return await create(payload);
}
} on DioException {
return null;
}
}
@@ -12,19 +12,28 @@ const Duration kMailToastDuration = Duration(seconds: 5);
/// 단추가 있는 알림([되돌리기])이 닫히기까지 — 단추를 찾아 누를 시간을 넉넉히(웹 `TOAST_ACTION_DURATION_MS`)
const Duration kMailToastActionDuration = Duration(seconds: 10);
/// 메일 화면 아래의 알림 줄 — 단추가 있는 알림만 둔다(보드 `App1MailList` 의 "메일 2통을 휴지통으로 옮겼습니다. [되돌리기]")
/// 메일 화면 아래의 알림 줄 — 단추가 있는 알림만 둔다(보드 `App1MailList` 의 "메일 2통을 휴지통으로 옮겼습니다. [되돌리기]",
/// `App1Compose` 의 "메일을 보냈습니다. [보내기 취소]" + 남은 시간 막대)
@immutable
class MailToast {
const MailToast({
required this.id,
required this.text,
required this.actionLabel,
this.shownAt,
this.deadline,
});
/// 알림마다 다르다 — 단추 · 닫기가 그 알림에만 듣게
final int id;
final String text;
final String actionLabel;
/// 띄운 때 — 남은 시간 막대의 처음 길이 (기기 시계 — `clockProvider`)
final DateTime? shownAt;
/// 마감 — 그때 닫힌다(화면 낭독기를 켠 동안에도 — 그 뒤에는 단추가 듣지 않는다). 있으면 남은 시간 막대를 그린다
final DateTime? deadline;
}
/// 단추 없는 알림을 띄우는 곳 — 앱의 전역 알림 자리(`ScaffoldMessenger` 스낵바, 테마의 알림 모양).
@@ -57,6 +66,7 @@ final NotifierProvider<MailToastNotifier, MailToast?> mailToastProvider =
///
/// - 단추가 있는 알림([offer])은 화면 아래 줄 하나 — 새 단추 알림이 앞의 것을 바꾼다. 10초 뒤 닫는다.
/// 화면 낭독기를 쓰는 동안은 저절로 닫지 않고, 끄면 그때부터 10초를 센다(Flutter SnackBar 와 같은 결).
/// - 마감이 있는 알림([offer] 의 `deadline` — [보내기 취소])은 그 시각에 닫는다 — 화면 낭독기를 쓰는 동안에도(마감 뒤에는 취소할 수 없다).
/// - 단추 없는 알림([notify] — "고를 수 없습니다" · 영구 삭제 결과 등)은 전역 알림 자리에 띄운다 —
/// 떠 있는 [되돌리기]를 바꿔 끼우지 않아 되돌릴 길을 잃지 않는다(2026-11-06 리뷰에서 정함).
/// - API 실패는 여기 두지 않는다 — 인터셉터의 전역 알림이 띄운다.
@@ -89,18 +99,33 @@ class MailToastNotifier extends Notifier<MailToast?> {
ref.read(mailNoticeSinkProvider)(text);
}
/// 단추가 있는 알림 — 앞의 단추 알림을 바꾼다
/// 단추가 있는 알림 — 앞의 단추 알림을 바꾼다. [deadline] 이 있으면 그만큼 뒤에 닫는다(붙잡지 않는다)
void offer(
String text, {
required String actionLabel,
required Future<void> Function() onAction,
Duration? deadline,
}) {
if (!ref.mounted) return;
final int id = ++_seq;
_cancelTimer();
_action = onAction;
state = MailToast(id: id, text: text, actionLabel: actionLabel);
if (!_holding) _startTimer(id);
final DateTime now = ref.read(clockProvider)();
state = MailToast(
id: id,
text: text,
actionLabel: actionLabel,
shownAt: now,
deadline: deadline == null ? null : now.add(deadline),
);
if (deadline != null) {
_timer = Timer(deadline, () {
_timer = null;
dismiss(id);
});
} else if (!_holding) {
_startTimer(id);
}
}
/// 닫는다 — [id] 의 알림이 아직 보일 때만
@@ -122,7 +147,8 @@ class MailToastNotifier extends Notifier<MailToast?> {
void _onHoldChanged() {
final MailToast? toast = state;
if (toast == null) return;
// 마감이 있는 알림은 붙잡지 않는다 — 그 시각에 닫는다
if (toast == null || toast.deadline != null) return;
if (_holding) {
_cancelTimer();
} else if (_timer == null) {
@@ -0,0 +1,27 @@
import 'dart:typed_data';
import 'package:arasystem_app/features/mail/domain/compose_signature.dart';
import 'package:arasystem_app/features/mail/presentation/providers/compose_controller.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart';
/// 서명 그림의 바이트 — 쓰기 화면의 서명 미리 보기가 그린다(계약 2절 "서명" — 본인 그림만, Bearer 로 받아 메모리에만 둔다).
///
/// 그림을 그리는 동안만 둔다(autoDispose). 받지 못하거나 300KB 를 넘게 오면 null — 그 그림만 숨긴다(알리지 않는다).
/// 메일을 보는 직원이 바뀌면 버린다
final FutureProviderFamily<Uint8List?, String> signatureImageProvider =
FutureProvider.autoDispose.family<Uint8List?, String>((
Ref ref,
String id,
) async {
ref.watch(mailAccountProvider);
try {
return await ref
.read(composeRepositoryProvider)
.fetchSignatureImage(id, maxBytes: kSignatureImageMaxBytes);
} on DioException {
return null;
}
});
@@ -0,0 +1,173 @@
import 'package:arasystem_app/core/theme/app_spacing.dart';
import 'package:arasystem_app/core/theme/app_theme.dart';
import 'package:arasystem_app/core/theme/app_typography.dart';
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
import 'package:arasystem_app/shared/widgets/keep_all_text.dart';
import 'package:flutter/material.dart';
/// [보내기] 자리의 도는 표시 크기 — 단추 아이콘(22)과 비슷하게
const double _sendingIndicatorSize = 20;
/// 도는 표시의 선 두께
const double _sendingIndicatorStroke = 2;
/// 쓰기 화면의 위 막대 — ✕ · 제목 · [파일 첨부] · [보내기] (보드 `App1Compose`, 높이 56 · 아래 옅은 선).
/// 보내는 동안은 [보내기] 자리에 도는 표시(화면 낭독기 "보내는 중입니다.")를 둔다 — ✕ 는 그대로 듣는다(보내는 중에 떠나도 된다)
class ComposeAppBar extends StatelessWidget implements PreferredSizeWidget {
const ComposeAppBar({
required this.title,
required this.onClose,
required this.onAttach,
required this.onSend,
this.sending = false,
super.key,
});
final String title;
final VoidCallback onClose;
/// [파일 첨부] — null 이면 꺼진 단추(받는 중 · 받지 못함 · 보내는 중)
final VoidCallback? onAttach;
/// [보내기] — null 이면 꺼진 단추(받는 중 · 받지 못함)
final VoidCallback? onSend;
final bool sending;
@override
Size get preferredSize => const Size.fromHeight(AppSize.readerBarHeight);
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final ColorScheme scheme = theme.colorScheme;
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
return AppBar(
toolbarHeight: AppSize.readerBarHeight,
automaticallyImplyLeading: false,
leadingWidth: AppSize.touchMin + AppSpacing.s4,
titleSpacing: AppSpacing.s4,
shape: Border(
bottom: BorderSide(
color: extra.borderSubtle,
width: AppSize.borderWidth,
),
),
leading: Padding(
padding: const EdgeInsets.only(left: AppSpacing.s4),
child: IconButton(
icon: const Icon(Icons.close, size: AppSize.iconLarge),
tooltip: MailComposeTexts.close,
color: extra.textStrong,
onPressed: onClose,
),
),
title: Semantics(
header: true,
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: AppTypography.bold,
),
),
),
actionsPadding: const EdgeInsets.only(right: AppSpacing.s4),
actions: <Widget>[
IconButton(
icon: const Icon(Icons.attach_file, size: AppSize.iconToolbar),
tooltip: MailComposeTexts.attach,
color: extra.textStrong,
onPressed: onAttach,
),
if (sending)
const _SendingIndicator()
else
IconButton(
icon: const Icon(Icons.send_outlined, size: AppSize.iconToolbar),
tooltip: MailComposeTexts.send,
color: scheme.primary,
onPressed: onSend,
),
],
);
}
}
/// 보내는 동안 [보내기] 자리 — 누를 수 없고, 화면 낭독기는 "보내는 중입니다."를 바로 읽는다
class _SendingIndicator extends StatelessWidget {
const _SendingIndicator();
@override
Widget build(BuildContext context) {
return Semantics(
liveRegion: true,
child: const SizedBox.square(
dimension: AppSize.touchMin,
child: Center(
child: SizedBox.square(
dimension: _sendingIndicatorSize,
child: CircularProgressIndicator(
strokeWidth: _sendingIndicatorStroke,
semanticsLabel: MailComposeTexts.sending,
),
),
),
),
);
}
}
/// 칸 안내 띠 — 받는 사람(`MAIL_002` · `MAIL_010`) · 제목 길이(`VAL_001`) · 본문 길이(`MAIL_007`). 문구는 사전에서 받는다
/// (웹 `ComposeAlert` — 옅은 빨강 바탕 · 아래 선 · 경고 아이콘). 나타날 때 화면 낭독기가 읽는다
class ComposeAlert extends StatelessWidget {
const ComposeAlert({required this.message, super.key});
final String message;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final ColorScheme scheme = theme.colorScheme;
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
return Semantics(
container: true,
liveRegion: true,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.s16,
vertical: AppSpacing.s8,
),
decoration: BoxDecoration(
color: scheme.errorContainer,
border: Border(
bottom: BorderSide(
color: extra.dangerBorder,
width: AppSize.borderWidth,
),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Padding(
padding: const EdgeInsets.only(top: AppSpacing.s2),
child: Icon(
Icons.error_outline,
size: AppSize.iconTiny,
color: scheme.error,
),
),
const SizedBox(width: AppSpacing.s8),
Expanded(
child: KeepAllText(
message,
style: theme.textTheme.bodySmall?.copyWith(color: scheme.error),
),
),
],
),
),
);
}
}
@@ -0,0 +1,731 @@
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';
import 'package:arasystem_app/core/theme/app_spacing.dart';
import 'package:arasystem_app/core/theme/app_theme.dart';
import 'package:arasystem_app/core/theme/app_typography.dart';
import 'package:arasystem_app/features/mail/presentation/mail_row_view.dart';
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
import 'package:arasystem_app/shared/widgets/keep_all_text.dart';
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter/services.dart';
/// 받는 사람 칸 한 줄 — 왼쪽에 칸 이름(72), 칩들 · 글 칸, 오른쪽에 [trailing] (보드 `App1Compose` 「새 메일 · 받는 사람 추천」).
///
/// - 칩을 만드는 때: 자판의 [다음] · 쉼표 · 세미콜론(따옴표 · 꺾쇠 안은 빼고) · 칸을 벗어날 때 · 추천을 고를 때 · 구분자가 든 글을 붙여 넣을 때.
/// 붙여 넣은 글의 줄바꿈은 쉼표로 바꾼다 — 한 줄 칸은 줄바꿈을 지워 주소가 붙어 버리므로 여러 줄 칸으로 두고 바꿔 받는다.
/// [다음]은 칸이 비었으면 다음 칸으로. 칩은 ✕(누르는 곳 40 — 밀도 예외)로 뺀다(빈 칸의 Backspace 로 빼기는 소프트 자판이 알려 주지 않아 넣지 않았다).
/// - 자판은 글 자판(추천 켬 · 자동 고침 끔) — 메일 주소 자판이나 추천 끔은 한글 입력을 막는다(로그인 화면에서 본 문제).
/// - 추천 목록은 칸 바로 아래에 겹쳐 연다(OverlayPortal) — 묶음 이름 줄(주소록 · 그룹 · 사내 · 최근 주고받은 주소), 맞은 글자는 굵게.
/// 처음에는 첫 추천을 골라 두고 [다음]이 그것을 넣는다 — 친 글자가 완전한 주소면 고르지 않는다([다음]은 친 주소 그대로).
/// 누르는 동안 칸의 초점을 지킨다(TextFieldTapRegion). 자판 위에 들 만큼만 높이를 쓴다.
class ComposeRecipientField extends StatefulWidget {
const ComposeRecipientField({
required this.label,
required this.tokens,
required this.controller,
required this.focusNode,
required this.optionsFor,
required this.revision,
required this.onChanged,
required this.onCommit,
required this.onRemove,
required this.onPick,
required this.onSubmitEmpty,
this.onFocused,
this.rejected = false,
this.status,
this.trailing,
this.enabled = true,
super.key,
});
final String label;
final List<RecipientToken> tokens;
final TextEditingController controller;
final FocusNode focusNode;
/// 친 글자에 맞는 추천 줄
final List<RecipientOption> Function(String query) optionsFor;
/// 받아 둔 추천이 바뀔 때마다 오른다 — 목록을 다시 거른다
final int revision;
final ValueChanged<String> onChanged;
/// 칸에 쓰다 만 글을 칩으로
final VoidCallback onCommit;
final ValueChanged<RecipientToken> onRemove;
final ValueChanged<RecipientOption> onPick;
/// 빈 칸에서 [다음] — 다음 칸으로
final VoidCallback onSubmitEmpty;
/// 칸에 초점이 왔다 — 추천을 받지 못했으면 다시 받는다
final VoidCallback? onFocused;
/// 서버가 받는 사람을 거절했다 — 주소가 든 칸은 형식이 맞아도 잘못된 입력으로 읽힌다(어느 주소인지는 모른다)
final bool rejected;
/// 그룹을 넣은 뒤(또는 받는 동안) 칸 아래 글
final String? status;
final Widget? trailing;
final bool enabled;
@override
State<ComposeRecipientField> createState() => _ComposeRecipientFieldState();
}
class _ComposeRecipientFieldState extends State<ComposeRecipientField> {
final OverlayPortalController _portal = OverlayPortalController();
final LayerLink _link = LayerLink();
final GlobalKey _rowKey = GlobalKey();
bool _dismissed = false;
bool _focused = false;
String _query = '';
@override
void initState() {
super.initState();
_query = widget.controller.text;
widget.focusNode.addListener(_onFocusChanged);
widget.controller.addListener(_onTextChanged);
// 목록 자리는 늘 연 채로 두고 비었으면 아무것도 그리지 않는다 — 그리는 중에 열고 닫지 않게
_portal.show();
}
@override
void didUpdateWidget(ComposeRecipientField oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.focusNode != widget.focusNode) {
oldWidget.focusNode.removeListener(_onFocusChanged);
widget.focusNode.addListener(_onFocusChanged);
}
if (oldWidget.controller != widget.controller) {
oldWidget.controller.removeListener(_onTextChanged);
widget.controller.addListener(_onTextChanged);
}
}
@override
void dispose() {
widget.focusNode.removeListener(_onFocusChanged);
widget.controller.removeListener(_onTextChanged);
super.dispose();
}
void _onFocusChanged() {
final bool focused = widget.focusNode.hasFocus;
if (focused == _focused) return;
setState(() {
_focused = focused;
_dismissed = false;
});
if (focused) {
widget.onFocused?.call();
} else {
widget.onCommit();
}
}
void _onTextChanged() {
final String text = widget.controller.text;
if (text == _query) return;
setState(() {
_query = text;
_dismissed = false;
});
}
List<RecipientOption> _options() {
if (!_focused || _dismissed || _query.trim().isEmpty || !widget.enabled) {
return const <RecipientOption>[];
}
return widget.optionsFor(_query);
}
/// 미리 골라 둔 추천 — 완전한 주소를 쳤으면 없다
int _activeIndex(List<RecipientOption> options) =>
options.isEmpty || isValidEmail(_query) ? -1 : 0;
void _onNext() {
final List<RecipientOption> options = _options();
final int active = _activeIndex(options);
if (widget.controller.text.trim().isEmpty) {
widget.onSubmitEmpty();
return;
}
if (active >= 0) {
widget.onPick(options[active]);
} else {
widget.onCommit();
}
// 넣은 뒤에도 칸에 남아 다음 주소를 이어 친다
widget.focusNode.requestFocus();
}
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
final List<RecipientOption> options = _options();
final int active = _activeIndex(options);
final bool invalid =
widget.tokens.any((RecipientToken t) => !t.valid) ||
(widget.rejected && widget.tokens.isNotEmpty);
final String? status = widget.status;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
OverlayPortal(
controller: _portal,
overlayChildBuilder: (BuildContext overlayContext) => options.isEmpty
? const SizedBox.shrink()
: _overlay(overlayContext, options, active),
child: CompositedTransformTarget(
link: _link,
child: ComposeFieldRow(
key: _rowKey,
label: widget.label,
focused: _focused,
error: invalid,
onTap: widget.enabled ? widget.focusNode.requestFocus : null,
trailing: widget.trailing,
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return Wrap(
spacing: AppSpacing.s6,
runSpacing: AppSpacing.s6,
crossAxisAlignment: WrapCrossAlignment.center,
children: <Widget>[
for (final RecipientToken token in widget.tokens)
_RecipientChip(
token: token,
onRemove: widget.enabled
? () => widget.onRemove(token)
: null,
),
ConstrainedBox(
constraints: BoxConstraints(
minWidth: widget.tokens.isEmpty
? constraints.maxWidth
: AppSize.composeLabelWidth,
maxWidth: constraints.maxWidth,
),
child: IntrinsicWidth(child: _input(theme, invalid)),
),
],
);
},
),
),
),
),
if (status != null && status.isNotEmpty)
Semantics(
liveRegion: true,
child: Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.s16,
AppSpacing.s6,
AppSpacing.s16,
AppSpacing.s6,
),
child: KeepAllText(
status,
style: theme.textTheme.bodySmall?.copyWith(
color: extra.textMuted,
),
),
),
),
],
);
}
Widget _input(ThemeData theme, bool invalid) {
return Semantics(
label: widget.label,
validationResult: invalid
? SemanticsValidationResult.invalid
: SemanticsValidationResult.none,
child: TextField(
controller: widget.controller,
focusNode: widget.focusNode,
enabled: widget.enabled,
maxLines: null,
keyboardType: TextInputType.text,
textInputAction: TextInputAction.next,
autocorrect: false,
enableSuggestions: true,
smartDashesType: SmartDashesType.disabled,
smartQuotesType: SmartQuotesType.disabled,
inputFormatters: const <TextInputFormatter>[_LineBreaksToCommas()],
style: theme.textTheme.bodyLarge,
decoration: const InputDecoration.collapsed(hintText: ''),
onChanged: widget.onChanged,
onEditingComplete: _onNext,
),
);
}
Widget _overlay(
BuildContext context,
List<RecipientOption> options,
int active,
) {
final MediaQueryData media = MediaQuery.of(context);
final RenderObject? row = _rowKey.currentContext?.findRenderObject();
double available = AppSize.suggestionListMaxHeight;
double width = media.size.width;
if (row is RenderBox && row.hasSize) {
final double bottom = row.localToGlobal(Offset(0, row.size.height)).dy;
available =
media.size.height - media.viewInsets.bottom - bottom - AppSpacing.s8;
width = row.size.width;
}
final double maxHeight = available.clamp(
AppSize.suggestionRowMinHeight,
AppSize.suggestionListMaxHeight,
);
return CompositedTransformFollower(
link: _link,
targetAnchor: Alignment.bottomLeft,
showWhenUnlinked: false,
child: Align(
alignment: Alignment.topLeft,
child: TextFieldTapRegion(
child: SizedBox(
width: width,
child: _SuggestionList(
label: MailComposeTexts.suggestions(widget.label),
options: options,
active: active,
maxHeight: maxHeight,
onPick: (RecipientOption option) {
widget.onPick(option);
widget.focusNode.requestFocus();
},
),
),
),
),
);
}
}
/// 여러 줄 칸에 붙여 넣은 줄바꿈 → 쉼표 — 주소를 나누는 구분자로 받는다(한 줄 칸은 줄바꿈을 지워 버린다)
class _LineBreaksToCommas extends TextInputFormatter {
const _LineBreaksToCommas();
static final RegExp _lineBreaks = RegExp(r'\r\n|\r|\n');
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
if (!newValue.text.contains(_lineBreaks)) return newValue;
final String text = newValue.text.replaceAll(_lineBreaks, ',');
final int cursor = newValue.selection.extentOffset.clamp(
0,
newValue.text.length,
);
final int shift =
newValue.text.substring(0, cursor).length -
newValue.text.substring(0, cursor).replaceAll(_lineBreaks, ',').length;
return TextEditingValue(
text: text,
selection: TextSelection.collapsed(offset: cursor - shift),
);
}
}
/// 쓰기 화면의 칸 줄 — 왼쪽 칸 이름(72) · 가운데 칸 · 오른쪽 단추, 아래 선(초점이 있으면 청록 2) (보드 `App1Compose`)
class ComposeFieldRow extends StatelessWidget {
const ComposeFieldRow({
required this.label,
required this.child,
this.focused = false,
this.error = false,
this.trailing,
this.onTap,
super.key,
});
final String label;
final Widget child;
final bool focused;
/// 잘못된 입력이 든 칸 — 아래 선이 빨갛다
final bool error;
final Widget? trailing;
final VoidCallback? onTap;
@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;
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: onTap,
child: Container(
constraints: const BoxConstraints(
minHeight: AppSize.composeRowMinHeight,
),
padding: EdgeInsets.fromLTRB(
AppSpacing.s16,
AppSpacing.s4,
action == null ? AppSpacing.s16 : AppSpacing.s4,
AppSpacing.s4,
),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: error
? scheme.error
: (focused ? scheme.primary : extra.borderSubtle),
width: focused || error
? AppSize.focusRingWidth
: AppSize.borderWidth,
),
),
),
child: Row(
children: <Widget>[
SizedBox(
width: AppSize.composeLabelWidth,
child: ExcludeSemantics(
child: Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
color: extra.textMuted,
),
),
),
),
Expanded(child: child),
?action,
],
),
),
);
}
}
/// 받는 사람 칩 — 이름(없으면 주소) · ✕. 형식이 틀린 주소는 빨갛게
class _RecipientChip extends StatelessWidget {
const _RecipientChip({required this.token, required this.onRemove});
final RecipientToken token;
final VoidCallback? onRemove;
@override
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
: (name != null && name.isNotEmpty ? name : token.address.email);
final Color foreground = token.valid ? scheme.onSurface : scheme.error;
return Container(
height: AppSize.recipientChipHeight,
padding: const EdgeInsets.only(left: AppSpacing.s14),
decoration: BoxDecoration(
color: token.valid
? scheme.surfaceContainerHigh
: scheme.errorContainer,
borderRadius: BorderRadius.circular(AppSize.recipientChipHeight / 2),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Flexible(
child: Text(
shown,
maxLines: 1,
overflow: TextOverflow.ellipsis,
semanticsLabel: token.valid
? formatAddress(token.address)
: shown,
style: theme.textTheme.bodyMedium?.copyWith(color: foreground),
),
),
SizedBox(
width: AppSize.recipientChipRemove,
height: AppSize.recipientChipRemove,
child: IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints.tightFor(
width: AppSize.recipientChipRemove,
height: AppSize.recipientChipRemove,
),
style: IconButton.styleFrom(
minimumSize: const Size.square(AppSize.recipientChipRemove),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
iconSize: AppSize.iconTiny,
color: token.valid ? extra.textStrong : scheme.error,
tooltip: MailComposeTexts.remove(shown),
onPressed: onRemove,
icon: const Icon(Icons.close),
),
),
],
),
);
}
}
/// 받는 사람 추천 목록 — 묶음 이름 줄 · 줄마다 동그란 사진 · 이름(맞은 글자 굵게) · 주소(+ 주소록 줄의 설명) · 그룹 줄은 아이콘 · "고르면 N명을…"
class _SuggestionList extends StatelessWidget {
const _SuggestionList({
required this.label,
required this.options,
required this.active,
required this.maxHeight,
required this.onPick,
});
final String label;
final List<RecipientOption> options;
final int active;
final double maxHeight;
final ValueChanged<RecipientOption> onPick;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final ColorScheme scheme = theme.colorScheme;
final List<Widget> rows = <Widget>[];
RecipientOptionGroup? group;
for (int index = 0; index < options.length; index++) {
final RecipientOption option = options[index];
final RecipientOptionGroup current = recipientOptionGroupOf(option);
if (current != group) {
group = current;
rows.add(_GroupLabel(PeopleTexts.groupLabel(current)));
}
rows.add(
_SuggestionRow(
option: option,
selected: index == active,
onTap: () => onPick(option),
),
);
}
return Semantics(
container: true,
explicitChildNodes: true,
label: label,
child: Material(
color: scheme.surface,
elevation: AppSpacing.s8,
shadowColor: scheme.shadow,
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxHeight),
child: ListView(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: AppSpacing.s6),
children: rows,
),
),
),
);
}
}
class _GroupLabel extends StatelessWidget {
const _GroupLabel(this.text);
final String text;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
return Semantics(
header: true,
child: Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.s16,
AppSpacing.s8,
AppSpacing.s16,
AppSpacing.s4,
),
child: Text(
text,
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: AppTypography.semiBold,
color: extra.textMuted,
),
),
),
);
}
}
class _SuggestionRow extends StatelessWidget {
const _SuggestionRow({
required this.option,
required this.selected,
required this.onTap,
});
final RecipientOption option;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final ColorScheme scheme = theme.colorScheme;
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
final TextStyle? primaryStyle = theme.textTheme.bodyMedium;
final TextStyle? secondaryStyle = theme.textTheme.bodySmall?.copyWith(
color: extra.textMuted,
);
final ({
List<HighlightPart> primary,
List<HighlightPart>? secondary,
String? initial,
bool isGroup,
String semantics,
})
view = _viewOf(option);
return Semantics(
button: true,
selected: selected,
label: view.semantics,
excludeSemantics: true,
onTap: onTap,
child: InkWell(
onTap: onTap,
child: Container(
constraints: const BoxConstraints(
minHeight: AppSize.suggestionRowMinHeight,
),
color: selected ? scheme.secondaryContainer : null,
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.s16,
vertical: AppSpacing.s6,
),
child: Row(
children: <Widget>[
Container(
width: AppSize.suggestionAvatar,
height: AppSize.suggestionAvatar,
alignment: Alignment.center,
decoration: BoxDecoration(
color: scheme.primaryContainer,
shape: BoxShape.circle,
),
child: view.isGroup
? Icon(
Icons.group_outlined,
size: AppSize.iconMedium,
color: scheme.onPrimaryContainer,
)
: Text(
view.initial ?? '',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: AppTypography.semiBold,
color: scheme.onPrimaryContainer,
),
),
),
const SizedBox(width: AppSpacing.s12),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_Highlighted(parts: view.primary, style: primaryStyle),
if (view.secondary case final List<HighlightPart> parts)
_Highlighted(parts: parts, style: secondaryStyle),
],
),
),
],
),
),
),
);
}
static ({
List<HighlightPart> primary,
List<HighlightPart>? secondary,
String? initial,
bool isGroup,
String semantics,
})
_viewOf(RecipientOption option) {
switch (option) {
case GroupOption(:final RecipientGroupMatch match):
final String detail = PeopleTexts.groupDetail(match.group.memberCount);
return (
primary: highlightParts(match.group.name, match.name),
secondary: <HighlightPart>[(text: detail, strong: false)],
initial: null,
isGroup: true,
semantics: '${match.group.name}, $detail',
);
case AddressOption(:final RecipientMatch match):
final String? name = match.suggestion.name;
final String email = match.suggestion.email;
final String? detail = contactSuggestionDetail(match.suggestion);
final List<HighlightPart> emailParts = <HighlightPart>[
...highlightParts(email, match.email),
if (detail != null) (text: ' · $detail', strong: false),
];
final bool named = name != null && name.isNotEmpty;
return (
primary: named
? highlightParts(name, match.name)
: highlightParts(email, match.email),
secondary: named
? emailParts
: (detail == null
? null
: <HighlightPart>[(text: detail, strong: false)]),
initial: initialOf(name, fallback: email),
isGroup: false,
semantics: <String>[if (named) name, email, ?detail].join(', '),
);
}
}
}
/// 맞은 글자를 굵게 — 글 조각으로(HTML 없음)
class _Highlighted extends StatelessWidget {
const _Highlighted({required this.parts, required this.style});
final List<HighlightPart> parts;
final TextStyle? style;
@override
Widget build(BuildContext context) {
return Text.rich(
TextSpan(
children: <InlineSpan>[
for (final HighlightPart part in parts)
TextSpan(
text: part.text,
style: part.strong
? const TextStyle(fontWeight: AppTypography.bold)
: null,
),
],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: style,
);
}
}
@@ -0,0 +1,698 @@
import 'dart:typed_data';
import 'package:arasystem_app/core/api/api_exception.dart';
import 'package:arasystem_app/core/theme/app_spacing.dart';
import 'package:arasystem_app/core/theme/app_theme.dart';
import 'package:arasystem_app/core/theme/app_typography.dart';
import 'package:arasystem_app/core/utils/file_size.dart';
import 'package:arasystem_app/features/mail/data/models/compose_models.dart';
import 'package:arasystem_app/features/mail/domain/compose_body.dart';
import 'package:arasystem_app/features/mail/domain/compose_signature.dart';
import 'package:arasystem_app/features/mail/domain/mail_quote.dart';
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
import 'package:arasystem_app/features/mail/presentation/providers/compose_state.dart';
import 'package:arasystem_app/features/mail/presentation/providers/signature_image_provider.dart';
import 'package:arasystem_app/shared/widgets/keep_all_text.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 서명 위 점선의 점 길이 · 사이 (보드 `App1Compose` 의 `1px dashed`)
const double _dashLength = 4;
const double _dashGap = 3;
/// 펼친 원본 메일의 머리 줄 사이 (보드의 `gap: 2px`)
const double _quoteHeaderRowGap = 2;
/// 원문 줄의 줄 간격 (보드의 `line-height: 1.7`)
const double _quoteLineHeight = 1.7;
// ── 첨부 ─────────────────────────────────────────────
/// 첨부 칸 — "첨부 N개" · 줄마다 파일 아이콘 · 이름 · 크기 · ✕ (보드 `App1Compose` 「답장 · 첨부」).
///
/// 앱은 첫 판에서 새로 올리지 않는다 — 전달하는 원본 · 이어 쓰는 저장본에 이미 있던 첨부만 싣고 뺀다.
/// 실을 수 없는 첨부 · 서버가 찾지 못한 첨부(`MAIL_004`) · 웹에서 올리다 멈췄거나 지웠거나 위험한 대용량 파일은 줄 아래에 빨갛게 까닭을 적는다
class ComposeAttachmentsSection extends StatelessWidget {
const ComposeAttachmentsSection({
required this.attachments,
required this.largeFiles,
required this.onRemoveAttachment,
required this.onRemoveLargeFile,
this.enabled = true,
super.key,
});
final List<ComposeAttachment> attachments;
final List<DraftLargeFile> largeFiles;
final ValueChanged<int> onRemoveAttachment;
final ValueChanged<String> onRemoveLargeFile;
/// 거짓이면 ✕ 를 막는다(보내는 중)
final bool enabled;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
return Container(
padding: const EdgeInsets.fromLTRB(
AppSpacing.s16,
AppSpacing.s10,
AppSpacing.s16,
AppSpacing.s12,
),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: extra.borderSubtle,
width: AppSize.borderWidth,
),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Semantics(
header: true,
child: Text(
MailComposeTexts.attachmentsTitle(
attachments.length + largeFiles.length,
),
style: theme.textTheme.labelMedium?.copyWith(
fontWeight: AppTypography.semiBold,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
for (final ComposeAttachment item in attachments) ...<Widget>[
const SizedBox(height: AppSpacing.s8),
_ComposeFileRow(
key: ValueKey<String>('compose-attachment-${item.key}'),
icon: Icons.insert_drive_file_outlined,
name: item.name,
detail: formatFileSize(item.size),
problem: switch (item.problem) {
ComposeAttachmentProblem.unsendable =>
MailComposeTexts.unsendable,
ComposeAttachmentProblem.missing => ApiException.fromCode(
'MAIL_004',
).message,
null => null,
},
onRemove: enabled ? () => onRemoveAttachment(item.key) : null,
),
],
for (final DraftLargeFile file in largeFiles) ...<Widget>[
const SizedBox(height: AppSpacing.s8),
_ComposeFileRow(
key: ValueKey<String>('compose-large-file-${file.id}'),
icon: Icons.cloud_outlined,
name: file.name ?? MailComposeTexts.largeUnknownName,
detail: <String>[
if (file.size case final int size) formatFileSize(size),
MailReaderTexts.largeFile,
].join(' · '),
problem: largeFileProblemOf(file),
onRemove: enabled ? () => onRemoveLargeFile(file.id) : null,
),
],
],
),
);
}
}
/// 대용량 파일 줄 아래의 까닭 — 보낼 수 있으면 null (차례: 위험 → 지움 → 올리다 멈춤)
String? largeFileProblemOf(DraftLargeFile file) {
if (file.infected) return MailComposeTexts.largeInfected;
return switch (file.status) {
DraftLargeFileStatus.missing => MailComposeTexts.largeMissing,
DraftLargeFileStatus.uploading => MailComposeTexts.largeUploading,
DraftLargeFileStatus.ready => null,
};
}
/// 쓰기 화면의 파일 줄 — 테두리 상자(모서리 12) · 아이콘 칸(36) · 이름 · 크기 · ✕(48), 문제가 있으면 그 아래 빨간 글
class _ComposeFileRow extends StatelessWidget {
const _ComposeFileRow({
required this.icon,
required this.name,
required this.detail,
required this.onRemove,
this.problem,
super.key,
});
final IconData icon;
final String name;
final String detail;
final String? problem;
/// ✕ — null 이면 꺼진 단추
final VoidCallback? onRemove;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final ColorScheme scheme = theme.colorScheme;
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
final String? reason = problem;
final bool failed = reason != null;
return Container(
constraints: const BoxConstraints(
minHeight: AppSize.composeAttachmentRowMinHeight,
),
padding: const EdgeInsets.fromLTRB(
AppSpacing.s10,
AppSpacing.s4,
AppSpacing.s4,
AppSpacing.s4,
),
decoration: BoxDecoration(
border: Border.all(
color: failed ? scheme.error : scheme.outlineVariant,
width: AppSize.borderWidth,
),
borderRadius: BorderRadius.circular(AppRadius.xl),
),
child: Row(
children: <Widget>[
ExcludeSemantics(
child: Container(
width: AppSize.composeFileIconBox,
height: AppSize.composeFileIconBox,
decoration: BoxDecoration(
color: failed
? scheme.errorContainer
: scheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Icon(
icon,
size: AppSize.iconSmall,
color: failed ? scheme.error : scheme.onSurfaceVariant,
),
),
),
const SizedBox(width: AppSpacing.s12),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.s4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: AppTypography.medium,
),
),
Text(
detail,
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: AppTypography.regular,
color: extra.textMuted,
),
),
if (reason != null)
Padding(
padding: const EdgeInsets.only(top: AppSpacing.s4),
child: KeepAllText(
reason,
style: theme.textTheme.bodySmall?.copyWith(
color: scheme.error,
),
),
),
],
),
),
),
IconButton(
icon: const Icon(Icons.close, size: AppSize.iconSmall),
tooltip: MailComposeTexts.remove(name),
color: theme.colorScheme.onSurfaceVariant,
onPressed: onRemove,
),
],
),
);
}
}
// ── 서명 ─────────────────────────────────────────────
/// 서명 칸 — 위 점선 · "서명" · 글 줄 · 서명 그림 · [서명 빼기](✕) (보드 `App1Compose`).
/// 굵게 · 색 같은 모양은 미리 보기에서 빼고 보낸 메일에는 그대로 들어간다. 그림은 Bearer 로 받아 그린다(받지 못하면 그 그림만 숨긴다)
class ComposeSignatureSection extends StatelessWidget {
const ComposeSignatureSection({
required this.signature,
required this.onRemove,
super.key,
});
final ComposeSignature signature;
/// [서명 빼기] — null 이면 꺼진 단추(보내는 중)
final VoidCallback? onRemove;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
final TextStyle? lineStyle = theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
height: AppTypography.lineHeightRelaxed,
);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.s16),
child: CustomPaint(
painter: _DashedTopBorder(color: extra.borderStrong),
child: Padding(
padding: const EdgeInsets.only(
top: AppSpacing.s12,
bottom: AppSpacing.s16,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(
MailComposeTexts.signature,
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: AppTypography.semiBold,
color: extra.textMuted,
),
),
const SizedBox(height: AppSpacing.s4),
for (final SignaturePart part in signature.parts)
switch (part) {
SignatureTextPart(:final String text) => Text(
text,
style: lineStyle,
),
SignatureImagePart() => Padding(
padding: const EdgeInsets.symmetric(
vertical: AppSpacing.s4,
),
child: _SignatureImage(part: part),
),
},
],
),
),
IconButton(
icon: const Icon(Icons.close, size: AppSize.iconSmall),
tooltip: MailComposeTexts.removeSignature,
color: extra.textMuted,
onPressed: onRemove,
),
],
),
),
),
);
}
}
/// 서명 그림 한 장 — HTML 의 폭(없으면 그림의 폭)을 화면 폭 안으로 줄여 그린다. 화면 낭독기는 대체 글(없으면 "서명 그림")을 읽는다
class _SignatureImage extends ConsumerWidget {
const _SignatureImage({required this.part});
final SignatureImagePart part;
@override
Widget build(BuildContext context, WidgetRef ref) {
final Uint8List? bytes = ref.watch(signatureImageProvider(part.id)).value;
if (bytes == null) return const SizedBox.shrink();
final String alt = part.alt.trim();
final int? width = part.width;
return Image.memory(
bytes,
width: width?.toDouble(),
fit: BoxFit.contain,
alignment: Alignment.centerLeft,
semanticLabel: alt.isEmpty ? MailComposeTexts.signatureImage : alt,
gaplessPlayback: true,
errorBuilder: (BuildContext _, Object _, StackTrace? _) =>
const SizedBox.shrink(),
);
}
}
/// [서명 빼기] 뒤 그 자리 — [서명 넣기] 한 줄 (보드에 없음 — 앱에서 정함)
class ComposeInsertSignature extends StatelessWidget {
const ComposeInsertSignature({required this.onInsert, super.key});
/// null 이면 꺼진 단추(보내는 중)
final VoidCallback? onInsert;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.s8,
0,
AppSpacing.s16,
AppSpacing.s8,
),
child: Align(
alignment: AlignmentDirectional.centerStart,
child: TextButton.icon(
onPressed: onInsert,
icon: const Icon(Icons.add, size: AppSize.iconSmall),
label: const Text(MailComposeTexts.insertSignature),
),
),
);
}
}
/// 서명 위 점선 — 위 가장자리에 1 두께
class _DashedTopBorder extends CustomPainter {
const _DashedTopBorder({required this.color});
final Color color;
@override
void paint(Canvas canvas, Size size) {
final Paint paint = Paint()
..color = color
..strokeWidth = AppSize.borderWidth;
const double y = AppSize.borderWidth / 2;
for (double x = 0; x < size.width; x += _dashLength + _dashGap) {
final double end = (x + _dashLength).clamp(0, size.width);
canvas.drawLine(Offset(x, y), Offset(end, y), paint);
}
}
@override
bool shouldRepaint(_DashedTopBorder oldDelegate) =>
oldDelegate.color != color;
}
// ── 원본 메일 ─────────────────────────────────────────
/// 원본 메일 접힌 줄 — "원본 메일" · "이도윤 · 11월 5일 (목) 오전 10:42" · 펼침 표시 (보드 `App1Compose`, 모서리 10 · 옅은 바탕).
/// 누르면 펼친다 — 화면 낭독기에는 펼침 상태(expanded)를 알린다
class ComposeQuoteToggle extends StatelessWidget {
const ComposeQuoteToggle({
required this.summary,
required this.open,
required this.onToggle,
super.key,
});
final String summary;
final bool open;
final VoidCallback onToggle;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final ColorScheme scheme = theme.colorScheme;
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
final BorderRadius radius = BorderRadius.circular(AppRadius.lg);
return Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.s16,
AppSpacing.s4,
AppSpacing.s16,
AppSpacing.s12,
),
child: Semantics(
button: true,
expanded: open,
label: summary.isEmpty
? MailComposeTexts.quote
: '${MailComposeTexts.quote}, $summary',
excludeSemantics: true,
onTap: onToggle,
child: Material(
color: scheme.surfaceContainer,
shape: RoundedRectangleBorder(
borderRadius: radius,
side: BorderSide(
color: scheme.outlineVariant,
width: AppSize.borderWidth,
),
),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onToggle,
child: ConstrainedBox(
constraints: const BoxConstraints(
minHeight: AppSize.quoteToggleMinHeight,
),
child: Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.s14,
AppSpacing.s6,
AppSpacing.s4,
AppSpacing.s6,
),
child: Row(
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(
MailComposeTexts.quote,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: AppTypography.semiBold,
color: extra.textStrong,
),
),
if (summary.isNotEmpty)
Text(
summary,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: AppTypography.regular,
color: extra.textMuted,
),
),
],
),
),
SizedBox(
width: AppSpacing.s40,
child: Icon(
open ? Icons.expand_less : Icons.expand_more,
size: AppSize.iconMedium,
color: extra.textMuted,
),
),
],
),
),
),
),
),
),
);
}
}
/// 펼친 원본 메일 — 왼쪽 인용 막대 · "원본 메일" · 머리 줄(보낸 사람 · 날짜 · 제목 …) · 원문 줄 · (줄였으면) 생략 표시.
/// 원문은 줄마다 그린다(보이는 줄만 만든다 — 긴 원문도 화면이 멈추지 않게). 원문 속 인용은 단마다 막대를 하나 더 그린다.
/// 스크롤 목록(CustomScrollView)의 조각이다
class ComposeQuoteSliver extends StatelessWidget {
const ComposeQuoteSliver({
required this.quote,
required this.lines,
super.key,
});
final ComposeQuote quote;
/// [quoteDisplayLines] 로 만든 원문 줄 — 화면이 쥐고 다시 만들지 않는다
final List<QuoteDisplayLine> lines;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final ColorScheme scheme = theme.colorScheme;
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
final TextStyle? lineStyle = theme.textTheme.bodyMedium?.copyWith(
color: scheme.onSurfaceVariant,
height: _quoteLineHeight,
);
// 머리(제목 · 머리 줄) 하나 + 원문 줄 + 생략 표시
final int count = 1 + lines.length + (quote.omitted ? 1 : 0);
return SliverPadding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.s16,
0,
AppSpacing.s16,
AppSpacing.s24,
),
sliver: SliverList.builder(
itemCount: count,
itemBuilder: (BuildContext context, int index) {
final Widget child;
if (index == 0) {
child = _QuoteHead(header: quote.header);
} else if (index <= lines.length) {
final QuoteDisplayLine line = lines[index - 1];
child = Padding(
padding: EdgeInsets.only(top: index == 1 ? AppSpacing.s10 : 0),
child: _nested(
line.depth,
extra.borderStrong,
Text(line.text, style: lineStyle),
),
);
} else {
child = Padding(
padding: EdgeInsets.only(top: lines.isEmpty ? AppSpacing.s10 : 0),
child: Text(
kQuoteOmittedMark,
style: lineStyle?.copyWith(color: extra.textMuted),
),
);
}
return _QuoteBar(color: extra.borderStrong, child: child);
},
),
);
}
/// 원문 속 인용 [depth] 단 — 단마다 막대 하나
static Widget _nested(int depth, Color color, Widget child) {
Widget result = child;
for (int level = 0; level < depth; level++) {
result = _QuoteBar(color: color, child: result);
}
return result;
}
}
/// 인용 막대 — 왼쪽 2 · 글은 14 띄운다
class _QuoteBar extends StatelessWidget {
const _QuoteBar({required this.color, required this.child});
final Color color;
final Widget child;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.only(left: AppSpacing.s14),
decoration: BoxDecoration(
border: Border(
left: BorderSide(color: color, width: AppSize.quoteBarWidth),
),
),
child: child,
);
}
}
/// 펼친 원본 메일의 머리 — "원본 메일" · 칸 이름(64) · 값
class _QuoteHead extends StatelessWidget {
const _QuoteHead({required this.header});
final List<QuoteHeaderRow> header;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final ColorScheme scheme = theme.colorScheme;
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
final TextStyle? valueStyle = theme.textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(
MailComposeTexts.quote,
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: AppTypography.bold,
color: scheme.onSurfaceVariant,
),
),
for (final QuoteHeaderRow row in header)
Padding(
padding: const EdgeInsets.only(top: _quoteHeaderRowGap),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
SizedBox(
width: AppSize.detailsLabelWidth,
child: Text(
row.label,
style: valueStyle?.copyWith(color: extra.textMuted),
),
),
const SizedBox(width: AppSpacing.s10),
Expanded(child: Text(row.value, style: valueStyle)),
],
),
),
],
);
}
}
// ── 불러오는 동안 ─────────────────────────────────────
/// 원본 · 저장본을 받는 동안 — 칸 줄 모양의 회색 막대(읽기 화면의 뼈대와 같은 결)
class ComposeSkeleton extends StatelessWidget {
const ComposeSkeleton({required this.label, super.key});
/// 화면 낭독기가 읽는 글 — "쓰던 메일을 불러오는 중" · "원본 메일을 불러오는 중"
final String label;
static const List<double> _widths = <double>[0.55, 0.8, 1, 1, 0.6];
@override
Widget build(BuildContext context) {
final Color bar = Theme.of(context).colorScheme.surfaceContainerHighest;
return Semantics(
label: label,
liveRegion: true,
child: ExcludeSemantics(
child: Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.s16,
AppSpacing.s20,
AppSpacing.s16,
0,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
for (final double width in _widths) ...<Widget>[
FractionallySizedBox(
widthFactor: width,
child: Container(
height: AppSpacing.s12,
decoration: BoxDecoration(
color: bar,
borderRadius: BorderRadius.circular(AppRadius.xs),
),
),
),
const SizedBox(height: AppSpacing.s20),
],
],
),
),
),
);
}
}
@@ -1,5 +1,7 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:arasystem_app/core/api/dio_provider.dart';
import 'package:arasystem_app/core/theme/app_spacing.dart';
import 'package:arasystem_app/core/theme/app_theme.dart';
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
@@ -18,6 +20,7 @@ const double _toastElevation = 8;
/// - 보드 `App1MailList`: [메일 쓰기]는 탭 막대 위 16(아래에서 88), 알림이 뜨면 알림(아래 12 · 좌우 12) 위로 12만큼 함께 올라간다.
/// - 넓은 단추 자리에 두는 까닭: Scaffold 는 전역 알림(API 실패 · 단추 없는 알림 스낵바)을 이 자리 **위에** 띄운다 — 단추 · 알림 줄을 가리지 않는다.
/// - 고르는 중에는 [메일 쓰기]를 숨긴다(보드 — 탭 막대도 숨는다). 알림 줄은 남는다.
/// - 읽기 화면은 알림 줄만 쓴다([showCompose] 거짓) — 쓰기 화면이 보낸 뒤 돌아온 읽기 화면의 "메일을 보냈습니다. [보내기 취소]" (보드 `App1Compose` 「보낸 뒤」 — 아래 행동 막대 위 12).
/// - 탭 막대가 숨으면(고르는 중 · 자판) 화면 아래가 시스템 내비게이션 자리까지 내려온다 — 안전 영역(아래 · 좌우, [safeInsets])만큼 띄운다.
/// Scaffold 는 이 자리에 안전 영역을 넘기지 않으므로(네 방향 모두 지운다) Scaffold 밖에서 읽어 넘겨받는다.
/// 탭 막대가 있을 때는 틀이 아래 여백을 이미 뺐다(0).
@@ -25,15 +28,16 @@ class MailComposeArea extends ConsumerWidget {
const MailComposeArea({
required this.showCompose,
required this.safeInsets,
required this.onCompose,
this.onCompose,
super.key,
});
/// [메일 쓰기]를 보인다 — 읽기 화면은 알림 줄만 쓴다(거짓)
final bool showCompose;
/// 화면의 안전 영역(아래 · 좌우) — Scaffold 를 감싼 화면의 `MediaQuery.paddingOf`
final EdgeInsets safeInsets;
final VoidCallback onCompose;
final VoidCallback? onCompose;
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -84,7 +88,7 @@ class MailComposeArea extends ConsumerWidget {
class _ComposeButton extends StatelessWidget {
const _ComposeButton({required this.onPressed});
final VoidCallback onPressed;
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
@@ -113,7 +117,8 @@ class _ComposeButton extends StatelessWidget {
}
}
/// 알림 줄 — 짙은 청록 바탕 · 흰 글 · 오른쪽 [되돌리기] (보드 `App1MailList` · 앱 테마의 알림 색과 같다)
/// 알림 줄 — 짙은 청록 바탕 · 흰 글 · 오른쪽 [되돌리기] (보드 `App1MailList` · 앱 테마의 알림 색과 같다).
/// 마감이 있는 알림([보내기 취소] — 보드 `App1Compose` "보낸 뒤")은 아래에 남은 시간 막대(3)를 그린다 — 장식이라 화면 낭독기에는 읽히지 않는다
class MailToastCard extends StatelessWidget {
const MailToastCard({required this.toast, required this.onAction, super.key});
@@ -133,43 +138,123 @@ class MailToastCard extends StatelessWidget {
elevation: _toastElevation,
shadowColor: scheme.shadow,
borderRadius: BorderRadius.circular(AppRadius.lg),
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: AppSize.toastMinHeight),
child: Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.s16,
AppSpacing.s2,
AppSpacing.s4,
AppSpacing.s2,
),
child: Row(
children: <Widget>[
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: AppSpacing.s10,
),
child: Text(
toast.text,
style: theme.textTheme.bodyMedium?.copyWith(
color: scheme.onPrimary,
clipBehavior: Clip.antiAlias,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
ConstrainedBox(
constraints: const BoxConstraints(
minHeight: AppSize.toastMinHeight,
),
child: Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.s16,
AppSpacing.s2,
AppSpacing.s4,
AppSpacing.s2,
),
child: Row(
children: <Widget>[
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: AppSpacing.s10,
),
child: Text(
toast.text,
style: theme.textTheme.bodyMedium?.copyWith(
color: scheme.onPrimary,
),
),
),
),
),
),
TextButton(
style: TextButton.styleFrom(
foregroundColor: extra.onBrandLink,
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.s12,
TextButton(
style: TextButton.styleFrom(
foregroundColor: extra.onBrandLink,
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.s12,
),
),
onPressed: onAction,
child: Text(toast.actionLabel),
),
),
onPressed: onAction,
child: Text(toast.actionLabel),
],
),
],
),
),
),
if (toast case MailToast(
:final DateTime shownAt,
:final DateTime deadline,
))
_ToastCountdown(
key: ValueKey<int>(toast.id),
shownAt: shownAt,
deadline: deadline,
),
],
),
),
);
}
}
/// 남은 시간 막대를 다시 그리는 간격 — 30초 막대도 매끄럽게 줄어 보이는 만큼.
/// 시험의 `pumpAndSettle`(100ms 씩)이 이 막대 때문에 끝없이 돌지 않게 그 간격보다 길게 둔다
const Duration kToastCountdownTick = Duration(milliseconds: 250);
/// 남은 시간 막대 — 마감까지 줄어든다(웹 `ToastCountdown` — 높이 3 · 밝은 청록). 시계는 `clockProvider`
class _ToastCountdown extends ConsumerStatefulWidget {
const _ToastCountdown({
required this.shownAt,
required this.deadline,
super.key,
});
final DateTime shownAt;
final DateTime deadline;
@override
ConsumerState<_ToastCountdown> createState() => _ToastCountdownState();
}
class _ToastCountdownState extends ConsumerState<_ToastCountdown> {
Timer? _timer;
@override
void initState() {
super.initState();
_timer = Timer.periodic(kToastCountdownTick, (Timer _) {
if (mounted) setState(() {});
});
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final AppExtraColors extra = Theme.of(context).extension<AppExtraColors>()!;
final DateTime now = ref.read(clockProvider)();
final int total = math.max(
1,
widget.deadline.difference(widget.shownAt).inMilliseconds,
);
final int left = widget.deadline
.difference(now)
.inMilliseconds
.clamp(0, total);
return ExcludeSemantics(
child: SizedBox(
height: AppSize.toastCountdownHeight,
child: LinearProgressIndicator(
value: left / total,
minHeight: AppSize.toastCountdownHeight,
backgroundColor: extra.onBrandTrack,
color: extra.onBrandProgress,
),
),
);
+47 -20
View File
@@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
/// 확인 창 — 앱 보드 `App1Shell` 의 [로그아웃] 확인과 같은 모양(모서리 16 · 제목 굵게 · 오른쪽 아래에 [취소] · 주 단추).
///
/// 되돌릴 수 없는 일의 확인이라 **처음 초점은 [취소]** 다(웹 대화상자 규칙). [danger] 면 주 단추가 빨간 채움이다.
/// [stayIsPrimary] 면 [취소] 쪽이 주 단추(채움)이고 [확인]은 글자 단추다 — 쓰기 화면의 나가기 확인([계속 쓰기]가 처음 초점이자 주 단추 — 웹 `ComposeLeaveDialog`).
/// [확인]을 누르면 참, [취소] · 뒤로 가기 · 바깥 누르기는 거짓.
Future<bool> showConfirmDialog(
BuildContext context, {
@@ -14,6 +15,7 @@ Future<bool> showConfirmDialog(
required String confirmLabel,
required String cancelLabel,
bool danger = false,
bool stayIsPrimary = false,
}) async {
final bool? confirmed = await showDialog<bool>(
context: context,
@@ -23,6 +25,7 @@ Future<bool> showConfirmDialog(
confirmLabel: confirmLabel,
cancelLabel: cancelLabel,
danger: danger,
stayIsPrimary: stayIsPrimary,
),
);
return confirmed ?? false;
@@ -35,6 +38,7 @@ class _ConfirmDialog extends StatelessWidget {
required this.confirmLabel,
required this.cancelLabel,
required this.danger,
required this.stayIsPrimary,
});
final String title;
@@ -42,6 +46,7 @@ class _ConfirmDialog extends StatelessWidget {
final String confirmLabel;
final String cancelLabel;
final bool danger;
final bool stayIsPrimary;
@override
Widget build(BuildContext context) {
@@ -87,26 +92,48 @@ class _ConfirmDialog extends StatelessWidget {
height: AppTypography.lineHeightRelaxed,
),
),
actions: <Widget>[
TextButton(
autofocus: true,
style: TextButton.styleFrom(
foregroundColor: scheme.onPrimaryContainer,
shape: buttonShape,
),
onPressed: () => Navigator.of(context).pop(false),
child: Text(cancelLabel),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: danger ? scheme.error : scheme.primary,
foregroundColor: danger ? scheme.onError : scheme.onPrimary,
shape: buttonShape,
),
onPressed: () => Navigator.of(context).pop(true),
child: Text(confirmLabel),
),
],
// 머무는 단추가 먼저(웹 `LeaveConfirmDialog` 와 같은 차례) — 실수로 눌러도 쓴 것이 남는다
actions: stayIsPrimary
? <Widget>[
FilledButton(
autofocus: true,
style: FilledButton.styleFrom(
backgroundColor: scheme.primary,
foregroundColor: scheme.onPrimary,
shape: buttonShape,
),
onPressed: () => Navigator.of(context).pop(false),
child: Text(cancelLabel),
),
TextButton(
style: TextButton.styleFrom(
foregroundColor: scheme.onPrimaryContainer,
shape: buttonShape,
),
onPressed: () => Navigator.of(context).pop(true),
child: Text(confirmLabel),
),
]
: <Widget>[
TextButton(
autofocus: true,
style: TextButton.styleFrom(
foregroundColor: scheme.onPrimaryContainer,
shape: buttonShape,
),
onPressed: () => Navigator.of(context).pop(false),
child: Text(cancelLabel),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: danger ? scheme.error : scheme.primary,
foregroundColor: danger ? scheme.onError : scheme.onPrimary,
shape: buttonShape,
),
onPressed: () => Navigator.of(context).pop(true),
child: Text(confirmLabel),
),
],
);
}
}