generated from ttipo/project-base
feat(flutter): 앱 첫 판 화면 — 로그인 · 메일 목록 · 읽기 · 쓰기 · 검색 · 새 메일 알림 #21
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)!,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ import 'package:arasystem_app/core/router/app_router.dart';
|
||||
import 'package:arasystem_app/core/utils/provider_retry.dart';
|
||||
import 'package:arasystem_app/features/auth/presentation/pages/login_page.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/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/features/settings/presentation/settings_texts.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -188,7 +188,7 @@ void main() {
|
||||
router.go(MailRoutes.compose);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(page(MailTexts.compose), findsOneWidget);
|
||||
expect(find.byType(MessageComposePage), findsOneWidget);
|
||||
expect(find.byType(NavigationBar), findsNothing);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
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_draft_body.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/compose_fit.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/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:html/dom.dart' as dom;
|
||||
import 'package:html/parser.dart' as html_parser;
|
||||
|
||||
const List<QuoteHeaderRow> _header = <QuoteHeaderRow>[
|
||||
QuoteHeaderRow('보낸 사람', '이도윤 <doyun@x.kr>'),
|
||||
];
|
||||
|
||||
/// [depth] 단으로 겹친 div 안의 글
|
||||
String _nestedDivs(int depth, String inner) =>
|
||||
'${'<div>' * depth}$inner${'</div>' * depth}';
|
||||
|
||||
/// 원문 인용 하나 — 머리 표 뒤에 [inner]
|
||||
String _mailQuote(String inner) =>
|
||||
'<blockquote><p>원본 메일</p><table><tbody><tr><td>보낸 사람</td><td>이도윤</td></tr></tbody></table>$inner</blockquote>';
|
||||
|
||||
/// 저장본 읽기(쓰기 화면 1차 리뷰 M1 · M2) · 처음 맞추기(L2) · 제목 길이(L3)
|
||||
void main() {
|
||||
group('HTML 노드를 되풀이 없이 — package:html 과 같은 글', () {
|
||||
const String sample =
|
||||
'<div style="a:"b"" data-x="1 & 2"><p>글 & <태그><br><img src="x.png" alt="그림"></p>'
|
||||
'<!-- 주석 --><pre>\n\n앞 줄바꿈</pre><script>if (a < b) {}</script><textarea>\n둘</textarea></div>';
|
||||
|
||||
test('직렬화 · 글 · 태그로 찾기가 package:html 과 같다', () {
|
||||
final dom.Element body = html_parser.parse(sample).body!;
|
||||
expect(innerHtmlOf(body), body.innerHtml);
|
||||
final dom.Element div = body.children.single;
|
||||
expect(outerHtmlOf(div), div.outerHtml);
|
||||
expect(nodeTextOf(div), div.text);
|
||||
expect(descendantElements(body, 'p'), body.querySelectorAll('p'));
|
||||
});
|
||||
|
||||
test('2만 단으로 겹친 요소도 스택이 넘치지 않는다', () {
|
||||
const int depth = 20000;
|
||||
// 파서는 깊이의 제곱으로 느리므로 요소를 손으로 잇는다
|
||||
final dom.Element body = dom.Element.tag('body');
|
||||
dom.Element parent = body;
|
||||
for (int level = 0; level < depth; level++) {
|
||||
final dom.Element child = dom.Element.tag('div');
|
||||
parent.append(child);
|
||||
parent = child;
|
||||
}
|
||||
parent.append(dom.Text('깊은 글'));
|
||||
final String html = innerHtmlOf(body);
|
||||
expect(html, startsWith('<div><div>'));
|
||||
expect(html.length, depth * '<div></div>'.length + '깊은 글'.length);
|
||||
expect(nodeTextOf(body), '깊은 글');
|
||||
expect(descendantElements(body, 'div'), hasLength(depth));
|
||||
});
|
||||
});
|
||||
|
||||
group('저장본 읽기 — 읽기 화면과 같은 한도 · 문턱(M1)', () {
|
||||
test('읽을 곳 — 짧고 얕으면 그 자리, 길거나 깊으면 isolate, 한도를 넘으면 읽지 않는다', () {
|
||||
expect(
|
||||
draftBodyPlaceOf(bodyHtml: '<p>짧은 글</p>', body: ''),
|
||||
DraftBodyPlace.inline,
|
||||
);
|
||||
expect(
|
||||
draftBodyPlaceOf(bodyHtml: null, body: '글'),
|
||||
DraftBodyPlace.inline,
|
||||
);
|
||||
expect(
|
||||
draftBodyPlaceOf(
|
||||
bodyHtml: '<p>${'가' * kMailBodyInlineMaxLength}</p>',
|
||||
body: '',
|
||||
),
|
||||
DraftBodyPlace.isolate,
|
||||
);
|
||||
// 짧아도 깊으면(중첩 비용이 그 자리 문턱을 넘으면) isolate
|
||||
expect(
|
||||
draftBodyPlaceOf(bodyHtml: _nestedDivs(120, 'x'), body: ''),
|
||||
DraftBodyPlace.isolate,
|
||||
);
|
||||
expect(
|
||||
draftBodyPlaceOf(bodyHtml: _nestedDivs(20000, 'x'), body: ''),
|
||||
DraftBodyPlace.oversized,
|
||||
);
|
||||
expect(
|
||||
draftBodyPlaceOf(
|
||||
bodyHtml: '<p>${'가' * kMaxSanitizedHtmlLength}</p>',
|
||||
body: '',
|
||||
),
|
||||
DraftBodyPlace.oversized,
|
||||
);
|
||||
});
|
||||
|
||||
test('2만 단 저장본 — 읽지 않고 남의 저장본으로: 본문 칸은 텍스트 본문, 원래 HTML 은 그대로 쥔다', () async {
|
||||
final String html = _nestedDivs(20000, '깊은 글');
|
||||
final DraftBody parsed = await buildDraftBody(
|
||||
bodyHtml: ' $html ',
|
||||
body: '깊은 글\n',
|
||||
);
|
||||
expect(parsed, isA<ForeignDraftBody>());
|
||||
final ForeignDraftBody foreign = parsed as ForeignDraftBody;
|
||||
expect(foreign.text, '깊은 글');
|
||||
expect(foreign.originalHtml, html);
|
||||
expect(foreign.originalText, '깊은 글\n');
|
||||
expect(foreign.hasFormatting, isTrue);
|
||||
expect(foreign.markedHtml, isNull);
|
||||
expect(foreign.quote, isNull);
|
||||
});
|
||||
|
||||
test('깊은 원문 인용이 든 저장본 — 다른 isolate 에서 읽고 원래 인용 HTML 을 쥔다', () async {
|
||||
final String html =
|
||||
'<div><p>앞 글</p>${_mailQuote(_nestedDivs(1500, '깊은 원문'))}</div>';
|
||||
expect(
|
||||
draftBodyPlaceOf(bodyHtml: html, body: ''),
|
||||
DraftBodyPlace.isolate,
|
||||
);
|
||||
final DraftBody parsed = await buildDraftBody(bodyHtml: html, body: '');
|
||||
expect(parsed, isA<AppDraftBody>());
|
||||
final AppDraftBody app = parsed as AppDraftBody;
|
||||
expect(app.text, '앞 글');
|
||||
expect(app.quote!.header.single.value, '이도윤');
|
||||
expect(plainLinesOf(app.quote!.blocks), <String>['깊은 원문']);
|
||||
expect(app.quote!.rawHtml, startsWith('<blockquote><p>원본 메일</p>'));
|
||||
});
|
||||
|
||||
test('바로 읽어도(한도 검사를 거치지 않아도) 깊은 인용 · 서명에서 스택이 넘치지 않는다', () {
|
||||
final String html =
|
||||
'<div><p>글</p><div>${_nestedDivs(2000, '서명')}</div>'
|
||||
'${_mailQuote(_nestedDivs(2000, '원문'))}</div>';
|
||||
final DraftBody parsed = draftBodyOf(bodyHtml: html, body: '');
|
||||
expect(parsed, isA<AppDraftBody>());
|
||||
final AppDraftBody app = parsed as AppDraftBody;
|
||||
expect(app.signature?.parts, const <SignaturePart>[
|
||||
SignatureTextPart('서명'),
|
||||
]);
|
||||
expect(
|
||||
app.quote!.rawHtml!.length,
|
||||
greaterThan(2000 * '<div></div>'.length),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('처음 맞추기 · 줄 자르기(L2)', () {
|
||||
test('처음 인용은 덩이 만들기와 함께 내 글 몫(5%)을 남기고 맞춘다', () {
|
||||
final ComposeQuote quote = initialQuoteOf(
|
||||
ComposeQuoteRequest(
|
||||
htmlBody: null,
|
||||
textBody: List<String>.filled(3000, '가' * 50).join('\n'),
|
||||
isBodyTruncated: false,
|
||||
header: _header,
|
||||
summary: '이도윤',
|
||||
signatureText: '김아라',
|
||||
signatureHtml: '<p style="margin: 0">김아라</p>',
|
||||
),
|
||||
);
|
||||
expect(quote.omitted, isTrue);
|
||||
expect(quote.blocks.length, lessThan(3000));
|
||||
final MailBodyOutput output = buildMailBody(
|
||||
ComposeBodyInput(
|
||||
text: '',
|
||||
signatureHtml: '<p style="margin: 0">김아라</p>',
|
||||
signatureText: '김아라',
|
||||
quote: quote,
|
||||
),
|
||||
);
|
||||
expect(BodyLimits.initial.fits(output), isTrue);
|
||||
|
||||
final ComposeQuote small = initialQuoteOf(
|
||||
const ComposeQuoteRequest(
|
||||
htmlBody: '<p>짧은 원문</p>',
|
||||
textBody: '짧은 원문',
|
||||
isBodyTruncated: true,
|
||||
header: _header,
|
||||
summary: '이도윤',
|
||||
),
|
||||
);
|
||||
expect(plainLinesOf(small.blocks), <String>['짧은 원문']);
|
||||
expect(small.omitted, isTrue);
|
||||
});
|
||||
|
||||
for (final (String name, String line) in <(String, String)>[
|
||||
('이스케이프되는 글자(& 5칸)', '&' * 200000),
|
||||
('탭(8칸까지 공백)', '${'\t' * 70000}끝'),
|
||||
('섞인 글', List<String>.filled(40000, 'a&\t<😀 ').join()),
|
||||
]) {
|
||||
test('긴 한 줄은 들어가는 만큼만 — 한 글자만 더 담아도 넘친다($name)', () {
|
||||
final ComposeQuote quote = ComposeQuote(
|
||||
header: _header,
|
||||
blocks: <QuoteBlock>[QuoteLine(line)],
|
||||
summary: '',
|
||||
);
|
||||
final BodyFit fit = fitMailBody(
|
||||
ComposeBodyInput(text: '', quote: quote),
|
||||
BodyLimits.full,
|
||||
);
|
||||
expect(BodyLimits.full.fits(fit.output!), isTrue);
|
||||
final String kept = (fit.quote!.blocks.single as QuoteLine).text;
|
||||
expect(kept.length, lessThan(line.length));
|
||||
final int last = kept.codeUnitAt(kept.length - 1);
|
||||
expect(last >= 0xD800 && last <= 0xDBFF, isFalse);
|
||||
// 다음 글자(짝 글자면 둘)까지 담으면 넘친다
|
||||
final int next =
|
||||
kept.length +
|
||||
(line.codeUnitAt(kept.length) >= 0xD800 &&
|
||||
line.codeUnitAt(kept.length) <= 0xDBFF
|
||||
? 2
|
||||
: 1);
|
||||
final MailBodyOutput longer = buildMailBody(
|
||||
ComposeBodyInput(
|
||||
text: '',
|
||||
quote: quote.withBlocks(<QuoteBlock>[
|
||||
QuoteLine(line.substring(0, next)),
|
||||
], omitted: true),
|
||||
),
|
||||
);
|
||||
expect(BodyLimits.full.fits(longer), isFalse);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
group('제목 길이 — UTF-16 단위(L3 · 서버 · 웹과 같다)', () {
|
||||
test('truncateUtf16 — 짝 글자를 가르지 않는다', () {
|
||||
expect(truncateUtf16('a' * 300, 255), 'a' * 255);
|
||||
expect(truncateUtf16('${'a' * 254}😀', 255), 'a' * 254);
|
||||
expect(truncateUtf16('짧은 글', 255), '짧은 글');
|
||||
});
|
||||
|
||||
TextEditingValue typed(
|
||||
String text, {
|
||||
TextRange composing = TextRange.empty,
|
||||
}) => TextEditingValue(
|
||||
text: text,
|
||||
selection: TextSelection.collapsed(offset: text.length),
|
||||
composing: composing,
|
||||
);
|
||||
|
||||
test('포매터 — 붙여 넣은 글은 뒤를 자르고, 이모지는 두 칸으로 센다', () {
|
||||
const Utf16LengthLimitFormatter formatter = Utf16LengthLimitFormatter(10);
|
||||
expect(
|
||||
formatter.formatEditUpdate(typed(''), typed('a' * 30)).text,
|
||||
'a' * 10,
|
||||
);
|
||||
expect(
|
||||
formatter.formatEditUpdate(typed(''), typed('😀' * 8)).text,
|
||||
'😀' * 5,
|
||||
);
|
||||
expect(
|
||||
formatter.formatEditUpdate(typed('a' * 9), typed('${'a' * 9}😀')).text,
|
||||
'a' * 9,
|
||||
);
|
||||
});
|
||||
|
||||
test('포매터 — 한도만큼 찼으면 더 치는 글을 받지 않고, 한글을 조합하는 동안은 막지 않는다', () {
|
||||
const Utf16LengthLimitFormatter formatter = Utf16LengthLimitFormatter(5);
|
||||
final TextEditingValue full = typed('abcde');
|
||||
expect(formatter.formatEditUpdate(full, typed('abXcde')), full);
|
||||
final TextEditingValue composing = typed(
|
||||
'abcde한',
|
||||
composing: const TextRange(start: 5, end: 6),
|
||||
);
|
||||
expect(formatter.formatEditUpdate(full, composing), composing);
|
||||
// 조합이 끝나면 자른다
|
||||
expect(
|
||||
formatter.formatEditUpdate(composing, typed('abcde한')).text,
|
||||
'abcde',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:arasystem_app/core/api/api_paths.dart';
|
||||
import 'package:arasystem_app/core/people/person_address.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/repositories/compose_repository.dart';
|
||||
import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../support/fakes.dart';
|
||||
import '../../support/mail_fakes.dart';
|
||||
|
||||
const MailComposePayload _payload = MailComposePayload(
|
||||
to: <PersonAddress>[PersonAddress(email: 'doyun@x.kr', name: '이도윤')],
|
||||
cc: <PersonAddress>[],
|
||||
bcc: <PersonAddress>[],
|
||||
subject: '계약',
|
||||
body: '본문',
|
||||
bodyHtml: '<div>본문</div>',
|
||||
draftId: 'd1',
|
||||
);
|
||||
|
||||
Map<String, Object?> _savedJson(String id) => <String, Object?>{
|
||||
'id': id,
|
||||
'attachments': <Object?>[attachmentJson('att-$id')],
|
||||
'updatedAt': '2026-11-07T00:00:00.000Z',
|
||||
};
|
||||
|
||||
/// 쓰기 화면의 저장소 — 계약 2 · 3 · 4절의 경로 · 본문 · 응답 읽기 (인터셉터 줄을 그대로 탄다)
|
||||
void main() {
|
||||
late ApiHarness h;
|
||||
late ComposeRepository repository;
|
||||
|
||||
setUp(() async {
|
||||
h = ApiHarness();
|
||||
await h.prepare();
|
||||
await h.signIn();
|
||||
repository = ComposeRepository(h.dio);
|
||||
});
|
||||
|
||||
test(
|
||||
'저장 — 처음은 POST /drafts, 그다음은 PUT /drafts/{id}. 받기 한도 60초 · 알리지 않는다',
|
||||
() async {
|
||||
h.server.on(
|
||||
'POST',
|
||||
ComposeApiPaths.drafts,
|
||||
(_) => FakeResponse.ok(_savedJson('d2')),
|
||||
);
|
||||
h.server.on(
|
||||
'PUT',
|
||||
'/drafts/d%2F1',
|
||||
(_) => FakeResponse.ok(_savedJson('d3')),
|
||||
);
|
||||
|
||||
final DraftSaved created = await repository.createDraft(
|
||||
_payload.withoutDraftId(),
|
||||
);
|
||||
expect(created.id, 'd2');
|
||||
expect(created.attachments.single.id, 'att-d2');
|
||||
final RequestOptions post = h.server.requests.single;
|
||||
expect(post.data, _payload.withoutDraftId().toJson());
|
||||
expect(post.receiveTimeout, kMailSendTimeout);
|
||||
expect(bearerTokenOf(post), 'access-1');
|
||||
|
||||
final DraftSaved updated = await repository.updateDraft(
|
||||
'd/1',
|
||||
_payload.withoutDraftId(),
|
||||
);
|
||||
expect(updated.id, 'd3');
|
||||
|
||||
h.server.on(
|
||||
'PUT',
|
||||
'/drafts/gone',
|
||||
(_) => FakeResponse.fail(409, 'MAIL_008'),
|
||||
);
|
||||
expect(
|
||||
apiErrorOf(
|
||||
await errorOf(repository.updateDraft('gone', _payload)),
|
||||
).code,
|
||||
'MAIL_008',
|
||||
);
|
||||
expect(h.notifier.shownCodes, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test('저장본 받기 — 모양이 틀리면 SYS_001, 실패는 알리지 않는다(화면이 [다시 시도]를 그린다)', () async {
|
||||
h.server.on(
|
||||
'GET',
|
||||
'/drafts/d1',
|
||||
(_) => FakeResponse.ok(<String, Object?>{'id': 'd1'}),
|
||||
);
|
||||
expect(
|
||||
apiErrorOf(await errorOf(repository.getDraft('d1'))).code,
|
||||
'SYS_001',
|
||||
);
|
||||
h.server.on('GET', '/drafts/d2', (_) => FakeResponse.fail(503, 'MAIL_001'));
|
||||
expect(
|
||||
apiErrorOf(await errorOf(repository.getDraft('d2'))).code,
|
||||
'MAIL_001',
|
||||
);
|
||||
expect(h.notifier.shownCodes, isEmpty);
|
||||
});
|
||||
|
||||
test(
|
||||
'보내기 — Idempotency-Key · 받기 한도 60초, 칸 안에 보일 코드만 알리지 않는다, 서버 시각을 준다',
|
||||
() async {
|
||||
final DateTime serverNow = DateTime.utc(2026, 11, 7, 9);
|
||||
h.server.serverTime = serverNow;
|
||||
h.server.on(
|
||||
'POST',
|
||||
MailApiPaths.messages,
|
||||
(_) => FakeResponse(201, <String, Object?>{
|
||||
'success': true,
|
||||
'data': <String, Object?>{
|
||||
'id': 'sent-1',
|
||||
'rejectedRecipients': <Object?>[
|
||||
<String, Object?>{'email': 'a@x.kr', 'reason': 'not-found'},
|
||||
],
|
||||
'pendingSend': pendingJson(
|
||||
serverNow.add(const Duration(seconds: 10)),
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
final ({SentMessage sent, DateTime? serverTime}) result = await repository
|
||||
.sendMessage(
|
||||
_payload,
|
||||
idempotencyKey: 'key-1',
|
||||
silentCodes: const <String>{'MAIL_002'},
|
||||
);
|
||||
|
||||
expect(result.sent.id, 'sent-1');
|
||||
expect(
|
||||
result.sent.rejectedRecipients.single.reason,
|
||||
RejectedReason.notFound,
|
||||
);
|
||||
expect(
|
||||
result.sent.pendingSend?.sendAt,
|
||||
serverNow.add(const Duration(seconds: 10)),
|
||||
);
|
||||
expect(result.serverTime, serverNow);
|
||||
final RequestOptions sent = h.server.requests.single;
|
||||
expect(sent.headers[kIdempotencyHeader], 'key-1');
|
||||
expect(sent.receiveTimeout, kMailSendTimeout);
|
||||
expect(sent.data, _payload.toJson());
|
||||
|
||||
h.server.on(
|
||||
'POST',
|
||||
MailApiPaths.messages,
|
||||
(_) => FakeResponse.fail(400, 'MAIL_002'),
|
||||
);
|
||||
expect(
|
||||
apiErrorOf(
|
||||
await errorOf(
|
||||
repository.sendMessage(
|
||||
_payload,
|
||||
idempotencyKey: 'key-1',
|
||||
silentCodes: const <String>{'MAIL_002'},
|
||||
),
|
||||
),
|
||||
).code,
|
||||
'MAIL_002',
|
||||
);
|
||||
expect(h.notifier.shownCodes, isEmpty);
|
||||
|
||||
h.server.on(
|
||||
'POST',
|
||||
MailApiPaths.messages,
|
||||
(_) => FakeResponse.fail(503, 'MAIL_001'),
|
||||
);
|
||||
await errorOf(
|
||||
repository.sendMessage(
|
||||
_payload,
|
||||
idempotencyKey: 'key-1',
|
||||
silentCodes: const <String>{'MAIL_002'},
|
||||
),
|
||||
);
|
||||
expect(h.notifier.shownCodes, <String>['MAIL_001']);
|
||||
},
|
||||
);
|
||||
|
||||
test('토큰이 끝나 401 이면 갱신한 뒤 같은 키로 다시 보낸다(서버가 두 번 내보내지 않게)', () async {
|
||||
int next = 2;
|
||||
h.server.on(
|
||||
'POST',
|
||||
ApiPaths.appRefresh,
|
||||
(_) => FakeResponse.ok(<String, Object?>{
|
||||
...sessionJson(),
|
||||
'tokens': tokensJson(next++, now: h.clock.now),
|
||||
}),
|
||||
);
|
||||
h.server.on(
|
||||
'POST',
|
||||
MailApiPaths.messages,
|
||||
(RequestOptions options) => bearerTokenOf(options) == 'access-2'
|
||||
? const FakeResponse(201, <String, Object?>{
|
||||
'success': true,
|
||||
'data': <String, Object?>{'id': 'sent-1'},
|
||||
})
|
||||
: FakeResponse.fail(401, 'AUTH_001'),
|
||||
);
|
||||
|
||||
final ({SentMessage sent, DateTime? serverTime}) result = await repository
|
||||
.sendMessage(_payload, idempotencyKey: 'key-9');
|
||||
|
||||
expect(result.sent.id, 'sent-1');
|
||||
expect(result.sent.pendingSend, isNull);
|
||||
final List<RequestOptions> sends = h.server.received(
|
||||
'POST',
|
||||
MailApiPaths.messages,
|
||||
);
|
||||
expect(sends, hasLength(2));
|
||||
expect(
|
||||
sends.map((RequestOptions r) => r.headers[kIdempotencyHeader]),
|
||||
<Object?>['key-9', 'key-9'],
|
||||
);
|
||||
});
|
||||
|
||||
test('서명 · 서명 그림 · 보내기 설정 — 알리지 않는다, 그림은 한도를 넘으면 끊는다', () async {
|
||||
h.server.on(
|
||||
'GET',
|
||||
ComposeApiPaths.mailSignature,
|
||||
(_) => FakeResponse.ok(<String, Object?>{
|
||||
'bodyHtml': '<p>김아라</p>',
|
||||
'body': '김아라',
|
||||
'isForNew': true,
|
||||
'isForReply': false,
|
||||
'updatedAt': '2026-11-01T00:00:00.000Z',
|
||||
}),
|
||||
);
|
||||
final MailSignature signature = await repository.getSignature();
|
||||
expect(signature.bodyHtml, '<p>김아라</p>');
|
||||
expect(signature.isForReply, isFalse);
|
||||
|
||||
h.server.on(
|
||||
'GET',
|
||||
ComposeApiPaths.signatureImage('img-1'),
|
||||
(_) => FakeResponse.bytes(kTinyPng, contentType: 'image/png'),
|
||||
);
|
||||
final Uint8List bytes = await repository.fetchSignatureImage(
|
||||
'img-1',
|
||||
maxBytes: 1024,
|
||||
);
|
||||
expect(bytes, kTinyPng);
|
||||
expect(bearerTokenOf(h.server.requests.last), 'access-1');
|
||||
await errorOf(repository.fetchSignatureImage('img-1', maxBytes: 4));
|
||||
|
||||
h.server.on(
|
||||
'GET',
|
||||
ComposeApiPaths.mailSettings,
|
||||
(_) => FakeResponse.ok(<String, Object?>{'undoSendSeconds': 10}),
|
||||
);
|
||||
expect(await repository.undoSendSeconds(), 10);
|
||||
h.server.on(
|
||||
'GET',
|
||||
ComposeApiPaths.mailSettings,
|
||||
(_) => FakeResponse.ok(<String, Object?>{}),
|
||||
);
|
||||
expect(
|
||||
apiErrorOf(await errorOf(repository.undoSendSeconds())).code,
|
||||
'SYS_001',
|
||||
);
|
||||
expect(h.notifier.shownCodes, isEmpty);
|
||||
});
|
||||
|
||||
test(
|
||||
'받는 사람 추천 · 그룹 · 구성원(다음 쪽까지) — 추천 · 그룹은 알리지 않는다, 고른 그룹의 실패는 알린다',
|
||||
() async {
|
||||
h.server.on(
|
||||
'GET',
|
||||
ComposeApiPaths.recipientSuggestions,
|
||||
(_) => FakeResponse.ok(<String, Object?>{
|
||||
'items': <Object?>[
|
||||
<String, Object?>{
|
||||
'name': '이도윤',
|
||||
'email': 'doyun@x.kr',
|
||||
'source': 'directory',
|
||||
},
|
||||
<String, Object?>{'email': 'x'},
|
||||
<String, Object?>{'name': 1, 'email': 'bad@x.kr'},
|
||||
<String, Object?>{'email': 'new@x.kr', 'source': 'future'},
|
||||
],
|
||||
'pageInfo': <String, Object?>{'hasNext': true, 'nextCursor': 'c'},
|
||||
}),
|
||||
);
|
||||
final RecipientSuggestionPage page = await repository
|
||||
.listRecipientSuggestions(query: '도윤', limit: 16);
|
||||
expect(page.items.map((RecipientSuggestion s) => s.email), <String>[
|
||||
'doyun@x.kr',
|
||||
'x',
|
||||
'new@x.kr',
|
||||
]);
|
||||
expect(page.items.last.source, RecipientSource.recent);
|
||||
expect(page.hasNext, isTrue);
|
||||
expect(h.server.requests.last.queryParameters, <String, Object?>{
|
||||
'q': '도윤',
|
||||
'limit': 16,
|
||||
});
|
||||
|
||||
await repository.listRecipientSuggestions();
|
||||
expect(h.server.requests.last.queryParameters, <String, Object?>{
|
||||
'limit': kRecipientSuggestionPreload,
|
||||
});
|
||||
|
||||
h.server.on(
|
||||
'GET',
|
||||
ComposeApiPaths.contactGroups,
|
||||
(_) => FakeResponse.ok(<String, Object?>{
|
||||
'items': <Object?>[
|
||||
<String, Object?>{'id': 'g1', 'name': '거래처', 'memberCount': 3},
|
||||
],
|
||||
'pageInfo': <String, Object?>{'hasNext': false, 'nextCursor': null},
|
||||
}),
|
||||
);
|
||||
expect((await repository.listContactGroups()).single.name, '거래처');
|
||||
|
||||
h.server.on('GET', ComposeApiPaths.contacts, (RequestOptions options) {
|
||||
final bool first = options.queryParameters['cursor'] == null;
|
||||
return FakeResponse.ok(<String, Object?>{
|
||||
'items': <Object?>[
|
||||
<String, Object?>{
|
||||
'name': first ? '박지훈' : '최서연',
|
||||
'emails': <Object?>[
|
||||
<String, Object?>{
|
||||
'address': first ? 'jihoon@x.kr' : 'seoyeon@x.kr',
|
||||
'label': 'work',
|
||||
},
|
||||
],
|
||||
},
|
||||
if (!first)
|
||||
<String, Object?>{'name': '주소 없음', 'emails': <Object?>[]},
|
||||
],
|
||||
'pageInfo': <String, Object?>{
|
||||
'hasNext': first,
|
||||
'nextCursor': first ? 'p2' : null,
|
||||
},
|
||||
});
|
||||
});
|
||||
final List<GroupMember> members = await repository.listGroupMembers('g1');
|
||||
expect(members, <GroupMember>[
|
||||
(name: '박지훈', email: 'jihoon@x.kr'),
|
||||
(name: '최서연', email: 'seoyeon@x.kr'),
|
||||
(name: '주소 없음', email: null),
|
||||
]);
|
||||
final List<RequestOptions> pages = h.server.received(
|
||||
'GET',
|
||||
ComposeApiPaths.contacts,
|
||||
);
|
||||
expect(pages.first.queryParameters, <String, Object?>{
|
||||
'groupId': 'g1',
|
||||
'limit': kContactsPageSize,
|
||||
});
|
||||
expect(pages.last.queryParameters['cursor'], 'p2');
|
||||
|
||||
h.server.on(
|
||||
'GET',
|
||||
ComposeApiPaths.contacts,
|
||||
(_) => FakeResponse.fail(503, 'MAIL_001'),
|
||||
);
|
||||
await errorOf(repository.listGroupMembers('g1'));
|
||||
expect(h.notifier.shownCodes, <String>['MAIL_001']);
|
||||
},
|
||||
);
|
||||
|
||||
test('대용량 파일 상태 — 없으면 RES_001(알리지 않는다)', () async {
|
||||
h.server.on(
|
||||
'GET',
|
||||
ComposeApiPaths.fileUpload('u1'),
|
||||
(_) => FakeResponse.ok(<String, Object?>{
|
||||
'id': 'u1',
|
||||
'status': 'ready',
|
||||
'name': 'a.zip',
|
||||
'size': 5,
|
||||
'scanStatus': 'clean',
|
||||
}),
|
||||
);
|
||||
final DraftLargeFile file = await repository.getLargeFile('u1');
|
||||
expect(file.status, DraftLargeFileStatus.ready);
|
||||
expect(file.infected, isFalse);
|
||||
expect(
|
||||
apiErrorOf(await errorOf(repository.getLargeFile('u2'))).code,
|
||||
'RES_001',
|
||||
);
|
||||
expect(h.notifier.shownCodes, isEmpty);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,920 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:arasystem_app/core/people/person_address.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/message_detail.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/compose_body.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/compose_draft_body.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/compose_fit.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/compose_reply.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/compose_send.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/compose_signature.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/mail_quote.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../support/mail_fakes.dart';
|
||||
|
||||
const String _nbsp = ' ';
|
||||
const String _wrapper =
|
||||
'<div style="font-family: \'Malgun Gothic\', \'맑은 고딕\', \'Apple SD Gothic Neo\', sans-serif; font-size: 14px; line-height: 1.75">';
|
||||
const String _p = '<p style="margin: 0">';
|
||||
const String _mailQuoteOpen =
|
||||
'<blockquote style="margin: 24px 0 0; padding: 2px 0 2px 14px; border-left: 2px solid #D0D5DD; color: #4A5363">';
|
||||
const String _nestOpen =
|
||||
'<blockquote style="margin: 0; padding: 2px 0 2px 14px; border-left: 2px solid #D0D5DD; color: #4A5363">';
|
||||
|
||||
const List<QuoteHeaderRow> _header = <QuoteHeaderRow>[
|
||||
QuoteHeaderRow('보낸 사람', '이도윤 <doyun@x.kr>'),
|
||||
QuoteHeaderRow('날짜', '2026. 11. 5. (목) 오전 10:42'),
|
||||
QuoteHeaderRow('제목', '계약'),
|
||||
];
|
||||
|
||||
const ComposeQuote _quote = ComposeQuote(
|
||||
header: _header,
|
||||
blocks: <QuoteBlock>[
|
||||
QuoteLine('안녕하세요'),
|
||||
QuoteLine(''),
|
||||
QuoteNest(<QuoteBlock>[QuoteLine('이전 글')]),
|
||||
],
|
||||
summary: '이도윤 · 11월 5일 (목) 오전 10:42',
|
||||
);
|
||||
|
||||
/// 시간대와 상관없이 같은 벽시계 — 지역 시각을 UTC 글로(읽으면 다시 그 지역 시각)
|
||||
String _localIso(int year, int month, int day, int hour, int minute) =>
|
||||
DateTime(year, month, day, hour, minute).toUtc().toIso8601String();
|
||||
|
||||
MessageDetail _detail(Map<String, Object?> overrides) =>
|
||||
MessageDetail.tryParse(<String, Object?>{
|
||||
...messageJson('m1', mailboxId: kInboxId),
|
||||
'cc': <Object?>[],
|
||||
'bcc': <Object?>[],
|
||||
'replyTo': <Object?>[],
|
||||
'htmlBody': null,
|
||||
'textBody': '본문',
|
||||
'hasBlockedImages': false,
|
||||
'attachments': <Object?>[],
|
||||
...overrides,
|
||||
})!;
|
||||
|
||||
void main() {
|
||||
group('보내는 본문 — 서식 본문 + 텍스트 (짝: 웹 mail-serialize.ts)', () {
|
||||
test('내 글만 — 줄마다 문단, 빈 줄은 <br>, 끝의 빈 줄은 보내지 않는다', () {
|
||||
final MailBodyOutput output = buildMailBody(
|
||||
const ComposeBodyInput(text: 'a\n\nb\n\n'),
|
||||
);
|
||||
expect(output.html, '$_wrapper${_p}a</p>$_p<br></p>${_p}b</p></div>');
|
||||
expect(output.text, 'a\n\nb');
|
||||
expect(output.quoteStart, isNull);
|
||||
});
|
||||
|
||||
test('쓴 글이 없으면 텍스트 메일(서식 본문 없음)', () {
|
||||
final MailBodyOutput output = buildMailBody(
|
||||
const ComposeBodyInput(text: '\n \n'),
|
||||
);
|
||||
expect(output.html, isNull);
|
||||
expect(output.text, '');
|
||||
});
|
||||
|
||||
test('글은 이스케이프한다 — 꺾쇠 · & 가 태그가 되지 않는다', () {
|
||||
expect(
|
||||
buildMailBody(const ComposeBodyInput(text: 'x < y & <b>z</b>')).html,
|
||||
'$_wrapper${_p}x < y & <b>z</b></p></div>',
|
||||
);
|
||||
});
|
||||
|
||||
test('여러 칸 띄어쓰기 · 줄 앞 공백 · 탭은 nbsp 와 번갈아(웹 preserveSpaces 와 같은 글)', () {
|
||||
expect(
|
||||
preserveSpaces(' 1. 일시 : 10월 27일'),
|
||||
'$_nbsp 1. 일시 $_nbsp $_nbsp: 10월 27일',
|
||||
);
|
||||
expect(preserveSpaces('\tx'), '$_nbsp $_nbsp $_nbsp $_nbsp x');
|
||||
expect(preserveSpaces('ab\ty'), 'ab $_nbsp $_nbsp ${_nbsp}y');
|
||||
expect(preserveSpaces('한 칸 띄어쓰기'), '한 칸 띄어쓰기');
|
||||
// 텍스트 본문의 공백은 그대로다
|
||||
expect(
|
||||
buildMailBody(const ComposeBodyInput(text: ' 들여 쓴 줄')).text,
|
||||
' 들여 쓴 줄',
|
||||
);
|
||||
});
|
||||
|
||||
test('서명 — 빈 문단 하나 뒤 속성 없는 경계 div, 텍스트는 빈 줄 뒤 텍스트 서명', () {
|
||||
final MailBodyOutput output = buildMailBody(
|
||||
const ComposeBodyInput(
|
||||
text: 'hi',
|
||||
signatureHtml: '$_p김아라</p>',
|
||||
signatureText: '김아라\n경영지원팀',
|
||||
),
|
||||
);
|
||||
expect(
|
||||
output.html,
|
||||
'$_wrapper${_p}hi</p>$_p<br></p><div>$_p김아라</p></div></div>',
|
||||
);
|
||||
expect(output.text, 'hi\n\n김아라\n경영지원팀');
|
||||
});
|
||||
|
||||
test('원문 인용 — "원본 메일" 문단 + 머리 표 + 문단 · 안쪽 인용, 텍스트는 "> " 줄', () {
|
||||
final MailBodyOutput output = buildMailBody(
|
||||
const ComposeBodyInput(text: 'ok', quote: _quote),
|
||||
);
|
||||
const String label =
|
||||
'<td style="padding: 0 12px 0 0; color: #667085; vertical-align: top; white-space: nowrap">';
|
||||
const String value = '<td style="padding: 0; vertical-align: top">';
|
||||
expect(
|
||||
output.html,
|
||||
'$_wrapper${_p}ok</p>'
|
||||
'$_mailQuoteOpen'
|
||||
'<p style="margin: 0; font-size: 12.5px"><strong>원본 메일</strong></p>'
|
||||
'<table style="margin: 0 0 12px; border-collapse: collapse; font-size: 12.5px; line-height: 1.6"><tbody>'
|
||||
'<tr>$label보낸 사람</td>$value이도윤 <doyun@x.kr></td></tr>'
|
||||
'<tr>$label날짜</td>${value}2026. 11. 5. (목) 오전 10:42</td></tr>'
|
||||
'<tr>$label제목</td>$value계약</td></tr>'
|
||||
'</tbody></table>'
|
||||
'$_p안녕하세요</p>$_p<br></p>$_nestOpen$_p이전 글</p></blockquote>'
|
||||
'</blockquote></div>',
|
||||
);
|
||||
expect(
|
||||
output.text,
|
||||
'ok\n'
|
||||
'> 원본 메일\n'
|
||||
'> 보낸 사람: 이도윤 <doyun@x.kr>\n'
|
||||
'> 날짜: 2026. 11. 5. (목) 오전 10:42\n'
|
||||
'> 제목: 계약\n'
|
||||
'>\n'
|
||||
'> 안녕하세요\n'
|
||||
'>\n'
|
||||
'> > 이전 글',
|
||||
);
|
||||
expect(output.quoteStart, 'ok\n'.length);
|
||||
expect(output.text.substring(output.quoteStart!), startsWith('> 원본 메일'));
|
||||
expect(quoteStartInText(output.text), output.quoteStart);
|
||||
});
|
||||
|
||||
test('대용량 파일이 있으면 원문 인용 바로 앞에 인용 경계 표시', () {
|
||||
final String html = buildMailBody(
|
||||
const ComposeBodyInput(
|
||||
text: 'ok',
|
||||
signatureHtml: '$_p서명</p>',
|
||||
quote: _quote,
|
||||
quoteStartMark: true,
|
||||
),
|
||||
).html!;
|
||||
expect(html, contains('</div>$kQuoteStartMarker$_mailQuoteOpen'));
|
||||
});
|
||||
|
||||
test('줄인 인용은 끝에 생략 표시 · 남의 저장본의 인용은 원래 HTML 그대로', () {
|
||||
final MailBodyOutput omitted = buildMailBody(
|
||||
ComposeBodyInput(
|
||||
text: '',
|
||||
quote: _quote.withBlocks(const <QuoteBlock>[
|
||||
QuoteLine('앞부분'),
|
||||
], omitted: true),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
omitted.html,
|
||||
endsWith('$_p앞부분</p>$_p$kQuoteOmittedMark</p></blockquote></div>'),
|
||||
);
|
||||
expect(omitted.text, endsWith('> 앞부분\n> $kQuoteOmittedMark'));
|
||||
|
||||
const String raw = '<blockquote><p>원본 메일</p><table></table></blockquote>';
|
||||
final MailBodyOutput kept = buildMailBody(
|
||||
const ComposeBodyInput(
|
||||
text: 'x',
|
||||
quote: ComposeQuote(
|
||||
header: _header,
|
||||
blocks: <QuoteBlock>[QuoteLine('글')],
|
||||
summary: '',
|
||||
rawHtml: raw,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(kept.html, '$_wrapper${_p}x</p>$raw</div>');
|
||||
});
|
||||
});
|
||||
|
||||
group('길이 맞추기 (짝: 웹 fit-draft.ts)', () {
|
||||
ComposeQuote longQuote(int lines, {int width = 100}) => ComposeQuote(
|
||||
header: _header,
|
||||
blocks: <QuoteBlock>[
|
||||
for (int index = 0; index < lines; index++) QuoteLine('가' * width),
|
||||
],
|
||||
summary: '',
|
||||
);
|
||||
|
||||
test('한도 안이면 그대로', () {
|
||||
final BodyFit fit = fitMailBody(
|
||||
const ComposeBodyInput(text: 'ok', quote: _quote),
|
||||
BodyLimits.full,
|
||||
);
|
||||
expect(fit.quote, _quote);
|
||||
expect(fit.output?.text, startsWith('ok\n> 원본 메일'));
|
||||
});
|
||||
|
||||
test('처음 초안은 내 글 몫(5%)을 남긴다 — 95,000 · 475,000', () {
|
||||
expect(BodyLimits.initial.text, 95000);
|
||||
expect(BodyLimits.initial.html, 475000);
|
||||
final BodyFit fit = fitMailBody(
|
||||
ComposeBodyInput(text: '', quote: longQuote(2000)),
|
||||
BodyLimits.initial,
|
||||
);
|
||||
expect(fit.output!.text.length, lessThanOrEqualTo(95000));
|
||||
expect(fit.output!.html!.length, lessThanOrEqualTo(475000));
|
||||
expect(fit.quote!.omitted, isTrue);
|
||||
expect(fit.quote!.blocks.length, lessThan(2000));
|
||||
expect(fit.output!.text, endsWith('> $kQuoteOmittedMark'));
|
||||
});
|
||||
|
||||
test('보낼 때는 한도 그대로 다시 잰다 — 넘으면 인용을 뒤에서 덜어 낸다', () {
|
||||
final BodyFit fit = fitMailBody(
|
||||
ComposeBodyInput(text: '내 글' * 10000, quote: longQuote(2000)),
|
||||
BodyLimits.full,
|
||||
);
|
||||
expect(fit.output!.text.length, lessThanOrEqualTo(kMailBodyTextMax));
|
||||
expect(fit.output!.text, startsWith('내 글' * 10000));
|
||||
expect(fit.quote!.omitted, isTrue);
|
||||
});
|
||||
|
||||
test('내 글만으로 넘으면 보내지 않는다(MAIL_007 — 결과 없음)', () {
|
||||
expect(
|
||||
fitMailBody(
|
||||
ComposeBodyInput(text: 'a' * (kMailBodyTextMax + 1), quote: _quote),
|
||||
BodyLimits.full,
|
||||
).output,
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
fitMailBody(
|
||||
ComposeBodyInput(text: 'a' * (kMailBodyTextMax + 1)),
|
||||
BodyLimits.full,
|
||||
).output,
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('긴 한 줄짜리 원문은 들어가는 만큼 글자 단위로 — 짝 글자를 가르지 않는다', () {
|
||||
final BodyFit fit = fitMailBody(
|
||||
ComposeBodyInput(text: '', quote: longQuote(1, width: 200000)),
|
||||
BodyLimits.full,
|
||||
);
|
||||
final QuoteLine line = fit.quote!.blocks.single as QuoteLine;
|
||||
expect(line.text.length, greaterThan(90000));
|
||||
expect(fit.output!.text.length, lessThanOrEqualTo(kMailBodyTextMax));
|
||||
|
||||
final BodyFit emoji = fitMailBody(
|
||||
ComposeBodyInput(
|
||||
text: '',
|
||||
quote: ComposeQuote(
|
||||
header: _header,
|
||||
blocks: <QuoteBlock>[QuoteLine('😀' * 60000)],
|
||||
summary: '',
|
||||
),
|
||||
),
|
||||
BodyLimits.full,
|
||||
);
|
||||
final String cut = (emoji.quote!.blocks.single as QuoteLine).text;
|
||||
final int last = cut.codeUnitAt(cut.length - 1);
|
||||
expect(last >= 0xD800 && last <= 0xDBFF, isFalse);
|
||||
});
|
||||
|
||||
test('대용량 첨부 덩이 몫 — 파일마다 이름 + 24, 머리 600', () {
|
||||
expect(largeFileBlockTextReserve(const <String>[]), 0);
|
||||
expect(
|
||||
largeFileBlockTextReserve(const <String>['a.zip', '계약.pdf']),
|
||||
600 + 5 + 24 + 6 + 24,
|
||||
);
|
||||
expect(BodyLimits.full.reserveText(1000).text, kMailBodyTextMax - 1000);
|
||||
});
|
||||
});
|
||||
|
||||
group('원문 → 인용 덩이 (화면에 보이는 글과 같게)', () {
|
||||
List<String> lines(String html) => plainLinesOf(quoteBlocksFromHtml(html));
|
||||
|
||||
test('블록 · <br> 은 줄, 이어진 공백은 한 칸, 앞뒤 빈 줄은 뺀다', () {
|
||||
expect(lines('<p> 안녕 하세요 </p><div>둘째<br>셋째</div><p></p>'), <String>[
|
||||
'안녕 하세요',
|
||||
'둘째',
|
||||
'셋째',
|
||||
]);
|
||||
});
|
||||
|
||||
test('목록은 "- " · "1. ", 칸이 여럿인 표 줄은 " | ", 링크는 "글 (주소)"', () {
|
||||
expect(
|
||||
lines(
|
||||
'<ul><li>사과</li><li>배</li></ul><ol start="3"><li>셋</li></ol>'
|
||||
'<table><tr><td>이름</td><td>값</td></tr></table>'
|
||||
'<p><a href="https://ara.example/x">문서</a> · <a href="javascript:x">위험</a></p>',
|
||||
),
|
||||
<String>[
|
||||
'- 사과',
|
||||
'- 배',
|
||||
'3. 셋',
|
||||
'이름 | 값',
|
||||
'문서 (https://ara.example/x) · 위험',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('원문 속 인용은 안쪽 인용, 스크립트 · 스타일 · 그림의 글은 뺀다', () {
|
||||
final List<QuoteBlock> blocks = quoteBlocksFromHtml(
|
||||
'<p>위</p><blockquote><p>안쪽</p></blockquote>'
|
||||
'<style>p{}</style><script>x()</script><img alt="그림">',
|
||||
);
|
||||
expect(blocks, const <QuoteBlock>[
|
||||
QuoteLine('위'),
|
||||
QuoteNest(<QuoteBlock>[QuoteLine('안쪽')]),
|
||||
]);
|
||||
expect(quoteDisplayLines(blocks), <QuoteDisplayLine>[
|
||||
(text: '위', depth: 0),
|
||||
(text: '안쪽', depth: 1),
|
||||
]);
|
||||
});
|
||||
|
||||
test('읽기 화면과 같은 본문 고르기 — 그림뿐인 서식 본문이면 글 본문, 잘라 보낸 본문이면 생략 표시', () {
|
||||
final QuoteSource onlyImage = quoteSourceOf(
|
||||
htmlBody: '<p><img src="cid:a"></p>',
|
||||
textBody: '글 본문\n둘째 줄',
|
||||
isBodyTruncated: false,
|
||||
);
|
||||
expect(plainLinesOf(onlyImage.blocks), <String>['글 본문', '둘째 줄']);
|
||||
|
||||
final QuoteSource truncated = quoteSourceOf(
|
||||
htmlBody: '<p>서식 본문</p>',
|
||||
textBody: '글 본문',
|
||||
isBodyTruncated: true,
|
||||
);
|
||||
expect(plainLinesOf(truncated.blocks), <String>['서식 본문']);
|
||||
expect(truncated.truncated, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('저장본 다시 열기 (계약 2절 "앱이 만드는 모양")', () {
|
||||
const String signatureHtml = '$_p김아라</p>$_p경영지원팀</p>';
|
||||
|
||||
test('앱이 만든 모양 — 내 글 · 서명 · 인용으로 되살린다(nbsp 는 공백으로)', () {
|
||||
final MailBodyOutput output = buildMailBody(
|
||||
const ComposeBodyInput(
|
||||
text: '첫 줄\n 들여 쓴 줄\n\n끝',
|
||||
signatureHtml: signatureHtml,
|
||||
signatureText: '김아라',
|
||||
quote: _quote,
|
||||
),
|
||||
);
|
||||
final DraftBody parsed = draftBodyOf(
|
||||
bodyHtml: output.html,
|
||||
body: output.text,
|
||||
);
|
||||
expect(parsed, isA<AppDraftBody>());
|
||||
final AppDraftBody app = parsed as AppDraftBody;
|
||||
expect(app.text, '첫 줄\n 들여 쓴 줄\n\n끝');
|
||||
expect(app.signature?.html, signatureHtml);
|
||||
expect(app.signature?.parts, const <SignaturePart>[
|
||||
SignatureTextPart('김아라'),
|
||||
SignatureTextPart('경영지원팀'),
|
||||
]);
|
||||
expect(app.quote!.header, _header);
|
||||
expect(app.quote!.blocks, _quote.blocks);
|
||||
expect(app.quote!.rawHtml, isNotNull);
|
||||
expect(app.quote!.summary, '이도윤 · 2026. 11. 5. (목) 오전 10:42');
|
||||
});
|
||||
|
||||
test('서버 정화로 style 띄어쓰기 · 경계 표시가 바뀌어도 앱 모양으로 알아본다', () {
|
||||
// 서버의 보내는 정화가 내는 꼴 — style 은 "속성:값" 을 ; 로 잇고 data-* 는 지운다
|
||||
final String html =
|
||||
buildMailBody(
|
||||
const ComposeBodyInput(
|
||||
text: '글',
|
||||
signatureHtml: signatureHtml,
|
||||
quote: _quote,
|
||||
quoteStartMark: true,
|
||||
),
|
||||
).html!
|
||||
.replaceAll(
|
||||
kMailBodyWrapperStyle,
|
||||
"font-family:'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif;font-size:14px;line-height:1.75",
|
||||
)
|
||||
.replaceAll('margin: 0', 'margin:0')
|
||||
.replaceAll(' data-ara-quote-start=""', '');
|
||||
final DraftBody parsed = draftBodyOf(bodyHtml: html, body: '');
|
||||
expect(parsed, isA<AppDraftBody>());
|
||||
expect((parsed as AppDraftBody).text, '글');
|
||||
expect(parsed.quote, isNotNull);
|
||||
});
|
||||
|
||||
test('서식이 있는 남의 저장본 — 인용 앞은 글로, 안내 줄을 보인다(원래 HTML 은 쥔다)', () {
|
||||
const String html =
|
||||
'<div><p>안녕 <strong>굵게</strong></p><p>서명</p>'
|
||||
'<blockquote><p>원본 메일</p><table><tr><td>보낸 사람</td><td>이도윤 <d@x.kr></td></tr></table><p>원문</p></blockquote></div>';
|
||||
final DraftBody parsed = draftBodyOf(bodyHtml: html, body: '원래 텍스트');
|
||||
expect(parsed, isA<ForeignDraftBody>());
|
||||
final ForeignDraftBody foreign = parsed as ForeignDraftBody;
|
||||
expect(foreign.text, '안녕 굵게\n서명');
|
||||
expect(foreign.hasFormatting, isTrue);
|
||||
expect(foreign.originalHtml, html);
|
||||
expect(foreign.originalText, '원래 텍스트');
|
||||
expect(foreign.quote!.header.single.value, '이도윤 <d@x.kr>');
|
||||
expect(foreign.quote!.summary, '이도윤');
|
||||
expect(foreign.quote!.rawHtml, isNotNull);
|
||||
});
|
||||
|
||||
test('서식이 없는 남의 저장본은 안내하지 않는다 · 서식 본문이 없으면 텍스트 그대로', () {
|
||||
final DraftBody plain = draftBodyOf(
|
||||
bodyHtml: '<div><div>a</div><p>b</p></div>',
|
||||
body: '',
|
||||
);
|
||||
expect(plain, isA<ForeignDraftBody>());
|
||||
expect((plain as ForeignDraftBody).hasFormatting, isFalse);
|
||||
expect(plain.text, 'a\nb');
|
||||
final DraftBody text = draftBodyOf(bodyHtml: null, body: '글 본문\n\n');
|
||||
expect(text, isA<AppDraftBody>());
|
||||
expect((text as AppDraftBody).text, '글 본문');
|
||||
});
|
||||
|
||||
test('남의 저장본은 원문 인용 앞에 인용 경계 표시를 넣은 HTML 을 읽을 때 한 번 만들어 둔다', () {
|
||||
const String html =
|
||||
'<div><p>a <b>b</b></p><blockquote><p>원본 메일</p><table></table></blockquote></div>';
|
||||
final ForeignDraftBody foreign =
|
||||
draftBodyOf(bodyHtml: html, body: 'a b') as ForeignDraftBody;
|
||||
expect(
|
||||
foreign.markedHtml,
|
||||
'<div><p>a <b>b</b></p><div data-ara-quote-start=""></div><blockquote><p>원본 메일</p><table></table></blockquote></div>',
|
||||
);
|
||||
expect(foreign.originalHtml, html);
|
||||
final ForeignDraftBody noQuote =
|
||||
draftBodyOf(bodyHtml: '<p><b>인용 없음</b></p>', body: '')
|
||||
as ForeignDraftBody;
|
||||
expect(noQuote.markedHtml, isNull);
|
||||
});
|
||||
|
||||
// 웹 저장본 표본 — 문단의 정렬 · 들여쓰기 · 글자 꾸밈 · 다른 감싸개는 앱 모양이 아니다(말없이 지우지 않게 원래 HTML 을 쥐고 안내한다)
|
||||
for (final (String name, String html) in <(String, String)>[
|
||||
('가운데 정렬', '<div><p style="margin: 0; text-align: center">가운데</p></div>'),
|
||||
('들여쓰기', '<div><p style="margin: 0; margin-left: 40px">들여 쓴 줄</p></div>'),
|
||||
('굵게', '<div><p style="margin: 0">글 <strong>굵게</strong></p></div>'),
|
||||
('문단의 다른 속성', '<div><p style="margin: 0" dir="rtl">글</p></div>'),
|
||||
(
|
||||
'꾸민 감싸개',
|
||||
'<div style="font-size: 18px"><p style="margin: 0">글</p></div>',
|
||||
),
|
||||
(
|
||||
'꾸민 서명 div',
|
||||
'<div><p>글</p><div style="color: #0E7C86"><p>서명</p></div></div>',
|
||||
),
|
||||
]) {
|
||||
test('웹 저장본 — $name 은 남의 저장본(원래 HTML 을 쥐고 안내 줄)', () {
|
||||
final DraftBody parsed = draftBodyOf(bodyHtml: html, body: '글');
|
||||
expect(parsed, isA<ForeignDraftBody>(), reason: name);
|
||||
final ForeignDraftBody foreign = parsed as ForeignDraftBody;
|
||||
expect(foreign.hasFormatting, isTrue, reason: name);
|
||||
expect(foreign.originalHtml, html, reason: name);
|
||||
});
|
||||
}
|
||||
|
||||
test('꾸밈 없는 문단(속성 없음 · margin:0 하나)만 든 웹 저장본은 앱 모양으로 연다', () {
|
||||
final DraftBody parsed = draftBodyOf(
|
||||
bodyHtml:
|
||||
'<div style="font-family: \'Malgun Gothic\', \'맑은 고딕\', \'Apple SD Gothic Neo\', sans-serif; font-size: 14px; line-height: 1.75">'
|
||||
'<p style="margin: 0;">첫 줄</p><p>둘째 줄</p></div>',
|
||||
body: '',
|
||||
);
|
||||
expect(parsed, isA<AppDraftBody>());
|
||||
expect((parsed as AppDraftBody).text, '첫 줄\n둘째 줄');
|
||||
});
|
||||
});
|
||||
|
||||
group('서명 (계약 2절 "서명" — 짝: 웹 mail-signature.ts)', () {
|
||||
test('새 메일은 isForNew, 답장 · 전달은 isForReply 일 때만', () {
|
||||
const MailSignature signature = MailSignature(
|
||||
body: '김아라',
|
||||
isForNew: false,
|
||||
isForReply: true,
|
||||
);
|
||||
expect(composeSignatureOf(signature, forReply: false), isNull);
|
||||
expect(composeSignatureOf(signature, forReply: true), isNotNull);
|
||||
expect(composeSignatureOf(null, forReply: true), isNull);
|
||||
expect(
|
||||
composeSignatureOf(const MailSignature(body: ' '), forReply: true),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('텍스트 서명은 줄마다 문단', () {
|
||||
final ComposeSignature signature = composeSignatureOf(
|
||||
const MailSignature(body: '김아라\n경영지원팀'),
|
||||
forReply: false,
|
||||
)!;
|
||||
expect(signature.html, '$_p김아라</p>$_p경영지원팀</p>');
|
||||
expect(signature.text, '김아라\n경영지원팀');
|
||||
expect(signature.parts, const <SignaturePart>[
|
||||
SignatureTextPart('김아라'),
|
||||
SignatureTextPart('경영지원팀'),
|
||||
]);
|
||||
});
|
||||
|
||||
test('서식 서명 — 문단에 margin: 0 을 앞에, 미리 보기는 글 줄과 서명 그림', () {
|
||||
const String id = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d';
|
||||
final ComposeSignature signature = composeSignatureOf(
|
||||
const MailSignature(
|
||||
bodyHtml:
|
||||
'<p><strong>김아라</strong> 경영지원팀</p>'
|
||||
'<p style="margin:0"><img src="/api/mail-signature/images/$id" width="320" alt="로고"></p>'
|
||||
'<p><img src="https://tracker.example/x.png"></p>',
|
||||
body: '김아라 경영지원팀',
|
||||
),
|
||||
forReply: false,
|
||||
)!;
|
||||
expect(
|
||||
signature.html,
|
||||
startsWith('<p style="margin: 0"><strong>김아라</strong>'),
|
||||
);
|
||||
expect(signature.html, contains('<p style="margin:0"><img'));
|
||||
expect(signature.text, '김아라 경영지원팀');
|
||||
expect(signature.parts, const <SignaturePart>[
|
||||
SignatureTextPart('김아라 경영지원팀'),
|
||||
SignatureImagePart(id: id, width: 320, alt: '로고'),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
group('답장 · 전달 (짝: 웹 mail-reply.ts · mail-forward.ts · mail-draft.ts)', () {
|
||||
test('제목 — 접두어가 있으면 그대로, 255자에 맞춘다(짝 글자를 가르지 않는다)', () {
|
||||
expect(buildReplySubject('계약'), 'Re: 계약');
|
||||
expect(buildReplySubject(' RE : 계약'), 'RE : 계약');
|
||||
expect(buildReplySubject('회신: 계약'), '회신: 계약');
|
||||
expect(buildReplySubject(''), 'Re:');
|
||||
expect(buildForwardSubject('계약'), 'Fwd: 계약');
|
||||
expect(buildForwardSubject('FW: 계약'), 'FW: 계약');
|
||||
expect(buildForwardSubject('전달: 계약'), '전달: 계약');
|
||||
expect(fitDraftSubject('a' * 300), 'a' * 255);
|
||||
expect(fitDraftSubject('${'a' * 254}😀'), 'a' * 254);
|
||||
});
|
||||
|
||||
const String me = 'ara.kim@arasystem.it.kr';
|
||||
Map<String, Object?> person(String name, String email) => <String, Object?>{
|
||||
'name': name,
|
||||
'email': email,
|
||||
};
|
||||
|
||||
List<String> emails(List<PersonAddress> list) => <String>[
|
||||
for (final PersonAddress address in list) address.email,
|
||||
];
|
||||
|
||||
test('답장은 보낸 사람(Reply-To 가 있으면 그 주소), 전체 답장은 받는 사람 · 참조까지 — 나는 뺀다', () {
|
||||
final MessageDetail message = _detail(<String, Object?>{
|
||||
'from': person('이도윤', 'doyun@x.kr'),
|
||||
'to': <Object?>[
|
||||
person('김아라', 'ARA.KIM@arasystem.it.kr'),
|
||||
person('박지훈', 'jihoon@x.kr'),
|
||||
],
|
||||
'cc': <Object?>[
|
||||
person('최서연', 'seoyeon@x.kr'),
|
||||
person('', 'jihoon@x.kr'),
|
||||
],
|
||||
});
|
||||
final ({List<PersonAddress> to, List<PersonAddress> cc}) reply =
|
||||
buildReplyRecipients(message, me, all: false);
|
||||
expect(emails(reply.to), <String>['doyun@x.kr']);
|
||||
expect(reply.cc, isEmpty);
|
||||
|
||||
final ({List<PersonAddress> to, List<PersonAddress> cc}) all =
|
||||
buildReplyRecipients(message, me, all: true);
|
||||
expect(emails(all.to), <String>['doyun@x.kr', 'jihoon@x.kr']);
|
||||
expect(emails(all.cc), <String>['seoyeon@x.kr']);
|
||||
|
||||
final MessageDetail withReplyTo = _detail(<String, Object?>{
|
||||
'from': person('이도윤', 'doyun@x.kr'),
|
||||
'replyTo': <Object?>[person('지원팀', 'help@x.kr')],
|
||||
});
|
||||
expect(
|
||||
emails(buildReplyRecipients(withReplyTo, me, all: false).to),
|
||||
<String>['help@x.kr'],
|
||||
);
|
||||
});
|
||||
|
||||
test('내가 보낸 메일에 답장하면 원래 받는 사람에게, 나에게만 보냈으면 나에게', () {
|
||||
final MessageDetail own = _detail(<String, Object?>{
|
||||
'from': person('김아라', me),
|
||||
'to': <Object?>[person('이도윤', 'doyun@x.kr')],
|
||||
'replyTo': <Object?>[person('다른 곳', 'else@x.kr')],
|
||||
});
|
||||
expect(emails(buildReplyRecipients(own, me, all: false).to), <String>[
|
||||
'doyun@x.kr',
|
||||
]);
|
||||
final MessageDetail toSelf = _detail(<String, Object?>{
|
||||
'from': person('김아라', me),
|
||||
'to': <Object?>[person('김아라', me)],
|
||||
});
|
||||
expect(emails(buildReplyRecipients(toSelf, me, all: false).to), <String>[
|
||||
me,
|
||||
]);
|
||||
});
|
||||
|
||||
test('Reply-To 가 나뿐이면 원래 받는 사람에게', () {
|
||||
final MessageDetail message = _detail(<String, Object?>{
|
||||
'from': person('이도윤', 'doyun@x.kr'),
|
||||
'replyTo': <Object?>[person('김아라', me)],
|
||||
'to': <Object?>[person('김아라', me), person('박지훈', 'jihoon@x.kr')],
|
||||
});
|
||||
expect(emails(buildReplyRecipients(message, me, all: false).to), <String>[
|
||||
'jihoon@x.kr',
|
||||
]);
|
||||
});
|
||||
|
||||
test('인용 머리 — 날짜는 웹과 같은 글, 전달은 받는 사람 · 참조도', () {
|
||||
expect(
|
||||
formatQuoteDate(DateTime(2026, 11, 5, 10, 42)),
|
||||
'2026. 11. 5. (목) 오전 10:42',
|
||||
);
|
||||
expect(
|
||||
formatQuoteDate(DateTime(2026, 11, 5, 15, 7)),
|
||||
'2026. 11. 5. (목) 오후 3:07',
|
||||
);
|
||||
final MessageDetail message = _detail(<String, Object?>{
|
||||
'from': person('이도윤', 'doyun@x.kr'),
|
||||
'to': <Object?>[person('김아라', me)],
|
||||
'cc': <Object?>[person('', 'cc@x.kr')],
|
||||
'subject': '',
|
||||
'sentAt': _localIso(2026, 11, 5, 10, 42),
|
||||
});
|
||||
expect(replyQuoteRows(message), const <QuoteHeaderRow>[
|
||||
QuoteHeaderRow('보낸 사람', '이도윤 <doyun@x.kr>'),
|
||||
QuoteHeaderRow('날짜', '2026. 11. 5. (목) 오전 10:42'),
|
||||
QuoteHeaderRow('제목', '(제목 없음)'),
|
||||
]);
|
||||
expect(forwardQuoteRows(message), const <QuoteHeaderRow>[
|
||||
QuoteHeaderRow('보낸 사람', '이도윤 <doyun@x.kr>'),
|
||||
QuoteHeaderRow('날짜', '2026. 11. 5. (목) 오전 10:42'),
|
||||
QuoteHeaderRow('받는 사람', '김아라 <ara.kim@arasystem.it.kr>'),
|
||||
QuoteHeaderRow('참조', 'cc@x.kr'),
|
||||
QuoteHeaderRow('제목', '(제목 없음)'),
|
||||
]);
|
||||
// 접힌 줄의 둘째 줄 — 올해면 연도 없이, 아니면 연도까지
|
||||
expect(
|
||||
quoteSummaryOf(message, now: DateTime(2026, 12, 1)),
|
||||
'이도윤 · 11월 5일 (목) 오전 10:42',
|
||||
);
|
||||
expect(
|
||||
quoteSummaryOf(message, now: DateTime(2027, 1, 2)),
|
||||
'이도윤 · 2026년 11월 5일 (목) 오전 10:42',
|
||||
);
|
||||
});
|
||||
|
||||
test('이어 쓰는 저장본의 쓰기 종류 — 답장할 메일 · 전달 제목 · 새 메일', () {
|
||||
expect(draftKindOf(inReplyTo: 'm1', subject: '계약'), ComposeKind.reply);
|
||||
expect(
|
||||
draftKindOf(inReplyTo: null, subject: 'Fwd: 계약'),
|
||||
ComposeKind.forward,
|
||||
);
|
||||
expect(draftKindOf(inReplyTo: '', subject: '계약'), ComposeKind.newMail);
|
||||
});
|
||||
});
|
||||
|
||||
group(
|
||||
'보내기 규칙 (짝: 웹 idempotency-key.ts · mail-send-result.ts · pending-send.ts · mail-attachment.ts)',
|
||||
() {
|
||||
test('발송 키 — UUID v4 꼴, 매번 다르다', () {
|
||||
final RegExp uuid = RegExp(
|
||||
r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$',
|
||||
);
|
||||
expect(createSendKey(), matches(uuid));
|
||||
expect(createSendKey(math.Random(1)), matches(uuid));
|
||||
expect(createSendKey(), isNot(createSendKey()));
|
||||
});
|
||||
|
||||
test('키를 바꾸는 것은 성공과 MAIL_005 뿐 — 그 밖의 실패는 같은 키로 다시 보낸다', () {
|
||||
expect(shouldRenewSendKey(kSendOutcomeSent), isTrue);
|
||||
expect(shouldRenewSendKey('MAIL_005'), isTrue);
|
||||
for (final String code in <String>[
|
||||
'MAIL_001',
|
||||
'MAIL_002',
|
||||
'SYS_001',
|
||||
'REQ_001',
|
||||
'MAIL_007',
|
||||
]) {
|
||||
expect(shouldRenewSendKey(code), isFalse, reason: code);
|
||||
}
|
||||
});
|
||||
|
||||
test('받지 못한 사람 — 두 명까지 주소 · 까닭, 나머지는 "외 N명"', () {
|
||||
expect(partialSendMessage(const <RejectedRecipient>[]), isNull);
|
||||
expect(
|
||||
partialSendMessage(const <RejectedRecipient>[
|
||||
RejectedRecipient(email: 'a@x.kr', reason: RejectedReason.notFound),
|
||||
RejectedRecipient(email: 'b@x.kr', reason: RejectedReason.unknown),
|
||||
RejectedRecipient(email: 'c@x.kr', reason: RejectedReason.refused),
|
||||
]),
|
||||
'보냈지만 3명은 받지 못했습니다. a@x.kr (없는 주소), b@x.kr 외 1명',
|
||||
);
|
||||
expect(
|
||||
partialSendMessage(const <RejectedRecipient>[
|
||||
RejectedRecipient(
|
||||
email: 'a@x.kr',
|
||||
reason: RejectedReason.temporary,
|
||||
),
|
||||
], pending: true),
|
||||
'보냈지만 1명은 받지 못합니다. a@x.kr (지금은 받을 수 없음)',
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'보내기 취소 마감 — 서버 시각으로 sendAt − 2초, 모르면 설정의 초 − 1초(모르면 30초)와 sendAt − 이 기기 시각 가운데 짧은 것(30초를 넘지 않는다)',
|
||||
() {
|
||||
final DateTime sendAt = DateTime.utc(2026, 11, 7, 9, 0, 10);
|
||||
final DateTime device = DateTime.utc(2026, 11, 7, 9, 0, 0);
|
||||
expect(
|
||||
undoSendRemaining(
|
||||
sendAt: sendAt,
|
||||
serverNow: DateTime.utc(2026, 11, 7, 9, 0, 0),
|
||||
undoSendSeconds: 30,
|
||||
deviceNow: device,
|
||||
),
|
||||
const Duration(seconds: 8),
|
||||
);
|
||||
expect(
|
||||
undoSendRemaining(
|
||||
sendAt: sendAt,
|
||||
serverNow: null,
|
||||
undoSendSeconds: 5,
|
||||
deviceNow: device,
|
||||
),
|
||||
const Duration(seconds: 4),
|
||||
);
|
||||
// 설정을 모르면 sendAt 까지(이 기기 시각), 다만 30초를 넘지 않게 — 웹 undoToastDeadline 과 같다
|
||||
expect(
|
||||
undoSendRemaining(
|
||||
sendAt: sendAt,
|
||||
serverNow: null,
|
||||
undoSendSeconds: null,
|
||||
deviceNow: device,
|
||||
),
|
||||
const Duration(seconds: 10),
|
||||
);
|
||||
expect(
|
||||
undoSendRemaining(
|
||||
sendAt: sendAt,
|
||||
serverNow: null,
|
||||
undoSendSeconds: null,
|
||||
deviceNow: device.subtract(const Duration(hours: 1)),
|
||||
),
|
||||
kUndoSendMax,
|
||||
);
|
||||
// 설정을 알아도 sendAt 을 넘지 않는다(이 기기 시계가 빠르면 일찍 닫힌다 — 보낸 뒤에 취소를 보이지 않는 쪽)
|
||||
expect(
|
||||
undoSendRemaining(
|
||||
sendAt: sendAt,
|
||||
serverNow: null,
|
||||
undoSendSeconds: 30,
|
||||
deviceNow: device.add(const Duration(seconds: 7)),
|
||||
),
|
||||
const Duration(seconds: 3),
|
||||
);
|
||||
expect(
|
||||
undoSendRemaining(
|
||||
sendAt: sendAt,
|
||||
serverNow: DateTime.utc(2026, 11, 7, 8, 0),
|
||||
undoSendSeconds: null,
|
||||
deviceNow: device,
|
||||
),
|
||||
kUndoSendMax,
|
||||
);
|
||||
expect(
|
||||
undoSendRemaining(
|
||||
sendAt: sendAt,
|
||||
serverNow: sendAt.subtract(const Duration(seconds: 1)),
|
||||
undoSendSeconds: 10,
|
||||
deviceNow: device,
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('이미 붙은 첨부의 사전 검사 — 개수 → 형식 → 합계(서버와 같은 차례)', () {
|
||||
expect(
|
||||
precheckCarriedAttachment(
|
||||
name: '계약.PDF',
|
||||
size: 10,
|
||||
attachedCount: 0,
|
||||
attachedBytes: 0,
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
precheckCarriedAttachment(
|
||||
name: 'a.pdf',
|
||||
size: 10,
|
||||
attachedCount: 20,
|
||||
attachedBytes: 0,
|
||||
),
|
||||
'MAIL_006',
|
||||
);
|
||||
expect(
|
||||
precheckCarriedAttachment(
|
||||
name: 'a.exe',
|
||||
size: 10,
|
||||
attachedCount: 0,
|
||||
attachedBytes: 0,
|
||||
),
|
||||
'FILE_001',
|
||||
);
|
||||
expect(
|
||||
precheckCarriedAttachment(
|
||||
name: 'noext',
|
||||
size: 10,
|
||||
attachedCount: 0,
|
||||
attachedBytes: 0,
|
||||
),
|
||||
'FILE_001',
|
||||
);
|
||||
expect(
|
||||
precheckCarriedAttachment(
|
||||
name: 'a.zip',
|
||||
size: 2,
|
||||
attachedCount: 1,
|
||||
attachedBytes: kMailAttachmentTotalMaxBytes - 1,
|
||||
),
|
||||
'FILE_002',
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
group('발송 · 저장 본문 (계약 2절)', () {
|
||||
test('빈 선택 칸은 싣지 않는다 · 저장본으로 남길 때는 draftId 를 뺀다', () {
|
||||
const MailComposePayload payload = MailComposePayload(
|
||||
to: <PersonAddress>[PersonAddress(email: 'a@x.kr', name: '가')],
|
||||
cc: <PersonAddress>[],
|
||||
bcc: <PersonAddress>[],
|
||||
subject: '제목',
|
||||
body: '본문',
|
||||
draftId: 'd1',
|
||||
);
|
||||
expect(payload.toJson(), <String, Object?>{
|
||||
'to': <Object?>[
|
||||
<String, Object?>{'name': '가', 'email': 'a@x.kr'},
|
||||
],
|
||||
'cc': <Object?>[],
|
||||
'bcc': <Object?>[],
|
||||
'subject': '제목',
|
||||
'body': '본문',
|
||||
'attachments': <Object?>[],
|
||||
'draftId': 'd1',
|
||||
});
|
||||
expect(payload.withoutDraftId().toJson().containsKey('draftId'), isFalse);
|
||||
|
||||
const MailComposePayload full = MailComposePayload(
|
||||
to: <PersonAddress>[],
|
||||
cc: <PersonAddress>[],
|
||||
bcc: <PersonAddress>[],
|
||||
subject: '',
|
||||
body: 'x',
|
||||
bodyHtml: '<div>x</div>',
|
||||
attachments: <ComposeAttachmentRef>[(id: 'att1', name: '계약.pdf')],
|
||||
inReplyTo: 'm1',
|
||||
largeFileIds: <String>['u1'],
|
||||
bodyQuoteStart: 3,
|
||||
);
|
||||
expect(full.toJson(), containsPair('bodyHtml', '<div>x</div>'));
|
||||
expect(full.toJson(), containsPair('inReplyTo', 'm1'));
|
||||
expect(full.toJson(), containsPair('largeFileIds', <String>['u1']));
|
||||
expect(full.toJson(), containsPair('bodyQuoteStart', 3));
|
||||
expect(full.toJson()['attachments'], <Object?>[
|
||||
<String, Object?>{'id': 'att1', 'name': '계약.pdf'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('저장본 응답 — 대용량 파일 상태 · 위험 판정을 읽는다(모양이 틀린 항목만 버린다)', () {
|
||||
final Draft? draft = Draft.tryParse(<String, Object?>{
|
||||
'id': 'd1',
|
||||
'to': <Object?>[],
|
||||
'subject': '',
|
||||
'body': '',
|
||||
'updatedAt': '2026-11-05T01:00:00.000Z',
|
||||
'largeFiles': <Object?>[
|
||||
<String, Object?>{
|
||||
'id': 'u1',
|
||||
'status': 'ready',
|
||||
'name': 'a.zip',
|
||||
'size': 10,
|
||||
'scanStatus': 'infected',
|
||||
},
|
||||
<String, Object?>{'id': 'u2', 'status': 'missing'},
|
||||
<String, Object?>{'id': 'u3', 'status': 'weird'},
|
||||
],
|
||||
});
|
||||
expect(draft, isNotNull);
|
||||
expect(draft!.cc, isEmpty);
|
||||
expect(draft.largeFiles, const <DraftLargeFile>[
|
||||
DraftLargeFile(
|
||||
id: 'u1',
|
||||
status: DraftLargeFileStatus.ready,
|
||||
name: 'a.zip',
|
||||
size: 10,
|
||||
infected: true,
|
||||
),
|
||||
DraftLargeFile(id: 'u2', status: DraftLargeFileStatus.missing),
|
||||
]);
|
||||
expect(Draft.tryParse(<String, Object?>{'id': 'd1'}), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:arasystem_app/core/api/api_error_mapper.dart';
|
||||
import 'package:arasystem_app/core/people/person_address.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/presentation/providers/draft_autosave.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// 저장 한 번의 결과 — 문([gate])을 두면 열릴 때까지 기다린다
|
||||
class _Server {
|
||||
final List<String> calls = <String>[];
|
||||
|
||||
/// 다음 저장들이 이 코드로 실패한다(차례대로 하나씩 쓴다)
|
||||
final List<String> failures = <String>[];
|
||||
Completer<void>? gate;
|
||||
int _next = 0;
|
||||
|
||||
Future<DraftSaved> save(String call) async {
|
||||
calls.add(call);
|
||||
final Completer<void>? wait = gate;
|
||||
if (wait != null) await wait.future;
|
||||
if (failures.isNotEmpty) {
|
||||
throw apiFailure(RequestOptions(path: '/drafts'), failures.removeAt(0));
|
||||
}
|
||||
_next++;
|
||||
return DraftSaved(
|
||||
id: 'd$_next',
|
||||
attachments: const [],
|
||||
updatedAt: DateTime.utc(2026, 11, 7),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 자동 저장 시험대 — 바뀜 여부는 [changed], 저장이 끝나면 저장한 판으로 삼는다(바뀜 없음)
|
||||
class _Harness {
|
||||
_Harness() {
|
||||
autosave = DraftAutosave(
|
||||
canSave: () => allowed,
|
||||
hasChanges: () => changed,
|
||||
prepare: () {
|
||||
final int version = this.version;
|
||||
return rejected == null
|
||||
? DraftSaveRequest(
|
||||
payload: _payload,
|
||||
commit: (DraftSaved _) {
|
||||
// 저장하는 동안 더 바꿨으면 그대로 바뀜
|
||||
if (this.version == version) changed = false;
|
||||
},
|
||||
)
|
||||
: DraftSaveRejected(rejected!);
|
||||
},
|
||||
create: (MailComposePayload _) => server.save('POST'),
|
||||
update: (String id, MailComposePayload _) => server.save('PUT $id'),
|
||||
canFallback: () => fallback,
|
||||
onSaved: (String? previousId, DraftSaved saved, bool replaced) =>
|
||||
saved_.add((previousId, saved.id, replaced)),
|
||||
onFailed: (String code, DraftSaveTrigger trigger) =>
|
||||
failed.add((code, trigger)),
|
||||
);
|
||||
}
|
||||
|
||||
final _Server server = _Server();
|
||||
late final DraftAutosave autosave;
|
||||
bool changed = false;
|
||||
bool allowed = true;
|
||||
bool fallback = true;
|
||||
String? rejected;
|
||||
int version = 0;
|
||||
final List<(String?, String, bool)> saved_ = <(String?, String, bool)>[];
|
||||
final List<(String, DraftSaveTrigger)> failed =
|
||||
<(String, DraftSaveTrigger)>[];
|
||||
|
||||
static const MailComposePayload _payload = MailComposePayload(
|
||||
to: <PersonAddress>[],
|
||||
cc: <PersonAddress>[],
|
||||
bcc: <PersonAddress>[],
|
||||
subject: '제목',
|
||||
body: '본문',
|
||||
);
|
||||
|
||||
/// 사용자가 무언가 바꿨다
|
||||
void edit() {
|
||||
changed = true;
|
||||
version++;
|
||||
autosave.touch();
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
// 시간은 Timer 로만 잰다 — 시험 시간(testWidgets 의 가짜 시계)을 넘긴다
|
||||
testWidgets('입력이 3초 멈추면 저장한다 — 처음은 POST, 그다음은 PUT {직전 id}', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final _Harness h = _Harness();
|
||||
h.edit();
|
||||
await tester.pump(const Duration(seconds: 2));
|
||||
expect(h.server.calls, isEmpty);
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
expect(h.server.calls, <String>['POST']);
|
||||
expect(h.autosave.draftId, 'd1');
|
||||
expect(h.autosave.status, DraftSaveStatus.saved);
|
||||
|
||||
await tester.pump(const Duration(seconds: 10));
|
||||
h.edit();
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
expect(h.server.calls, <String>['POST', 'PUT d1']);
|
||||
expect(h.saved_.last, ('d1', 'd2', false));
|
||||
h.autosave.dispose();
|
||||
});
|
||||
|
||||
testWidgets('저장은 10초에 한 번까지 — 그사이 바뀐 것은 문이 열릴 때 저장한다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final _Harness h = _Harness();
|
||||
h.edit();
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
expect(h.server.calls, <String>['POST']);
|
||||
h.edit();
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
// 첫 저장을 시작한 지 3초 — 아직 문이 닫혀 있다(시작한 뒤 10초에 열린다)
|
||||
expect(h.server.calls, <String>['POST']);
|
||||
await tester.pump(const Duration(seconds: 6));
|
||||
expect(h.server.calls, <String>['POST']);
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
expect(h.server.calls, <String>['POST', 'PUT d1']);
|
||||
h.autosave.dispose();
|
||||
});
|
||||
|
||||
testWidgets('쉬지 않고 써도 처음 바꾼 뒤 30초가 지나면 저장한다', (WidgetTester tester) async {
|
||||
final _Harness h = _Harness();
|
||||
for (int second = 0; second < 30; second++) {
|
||||
h.edit();
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
}
|
||||
expect(h.server.calls, <String>['POST']);
|
||||
h.autosave.dispose();
|
||||
});
|
||||
|
||||
testWidgets('한 번에 하나 — 저장하는 동안 바뀐 것은 끝난 뒤 다시', (WidgetTester tester) async {
|
||||
final _Harness h = _Harness();
|
||||
h.server.gate = Completer<void>();
|
||||
h.edit();
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
expect(h.autosave.saving, isTrue);
|
||||
h.edit();
|
||||
await tester.pump(const Duration(seconds: 15));
|
||||
expect(h.server.calls, <String>['POST']);
|
||||
h.server.gate!.complete();
|
||||
h.server.gate = null;
|
||||
await tester.pump();
|
||||
// 끝났지만 그사이 바뀐 것이 남았다 — 문(10초)이 이미 열렸으므로 바로
|
||||
expect(h.server.calls, <String>['POST', 'PUT d1']);
|
||||
await tester.pump();
|
||||
expect(h.changed, isFalse);
|
||||
h.autosave.dispose();
|
||||
});
|
||||
|
||||
testWidgets('옛 저장본이 다른 곳에서 바뀌었거나 없으면 새로 남긴다(MAIL_008 · RES_001)', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final _Harness h = _Harness();
|
||||
h.autosave.reset(existingId: 'old');
|
||||
h.server.failures.add('MAIL_008');
|
||||
h.edit();
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
expect(h.server.calls, <String>['PUT old', 'POST']);
|
||||
expect(h.autosave.status, DraftSaveStatus.replaced);
|
||||
expect(h.saved_.single, ('old', 'd1', true));
|
||||
|
||||
// 세션이 바뀌었으면 새로 남기지 않는다
|
||||
final _Harness other = _Harness()..fallback = false;
|
||||
other.autosave.reset(existingId: 'old');
|
||||
other.server.failures.add('RES_001');
|
||||
other.edit();
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
expect(other.server.calls, <String>['PUT old']);
|
||||
expect(other.failed.single, ('RES_001', DraftSaveTrigger.auto));
|
||||
h.autosave.dispose();
|
||||
other.autosave.dispose();
|
||||
});
|
||||
|
||||
testWidgets('실을 수 없는 첨부가 든 남의 저장본은 첫 저장을 POST 로(원본을 지우지 않는다)', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final _Harness h = _Harness();
|
||||
h.autosave.reset(existingId: 'foreign', keepOriginal: true);
|
||||
expect(h.autosave.draftId, isNull);
|
||||
h.edit();
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
expect(h.server.calls, <String>['POST']);
|
||||
h.autosave.dispose();
|
||||
});
|
||||
|
||||
testWidgets('다시 해 보면 나아질 실패는 15 · 30 · 60초 뒤 저절로, 그 밖은 바뀔 때까지 기다린다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final _Harness h = _Harness();
|
||||
h.server.failures.addAll(<String>['MAIL_001', 'SYS_001']);
|
||||
h.edit();
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
expect(h.server.calls, <String>['POST']);
|
||||
expect(h.autosave.status, DraftSaveStatus.failed);
|
||||
await tester.pump(const Duration(seconds: 14));
|
||||
expect(h.server.calls, hasLength(1));
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
expect(h.server.calls, hasLength(2));
|
||||
await tester.pump(const Duration(seconds: 29));
|
||||
expect(h.server.calls, hasLength(2));
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
expect(h.server.calls, hasLength(3));
|
||||
expect(h.autosave.status, DraftSaveStatus.saved);
|
||||
|
||||
final _Harness quota = _Harness();
|
||||
quota.server.failures.add('MAIL_004');
|
||||
quota.edit();
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
await tester.pump(const Duration(minutes: 5));
|
||||
expect(quota.server.calls, <String>['POST']);
|
||||
expect(quota.failed.single.$1, 'MAIL_004');
|
||||
h.autosave.dispose();
|
||||
quota.autosave.dispose();
|
||||
});
|
||||
|
||||
testWidgets('보낼 수 없는 본문(MAIL_007)은 요청 없이 실패로 둔다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final _Harness h = _Harness()..rejected = 'MAIL_007';
|
||||
h.edit();
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
expect(h.server.calls, isEmpty);
|
||||
expect(h.failed.single, ('MAIL_007', DraftSaveTrigger.auto));
|
||||
h.autosave.dispose();
|
||||
});
|
||||
|
||||
testWidgets('앱이 안 보이게 되면 10초 간격을 기다리지 않고 바로 저장한다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final _Harness h = _Harness();
|
||||
h.edit();
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
h.edit();
|
||||
h.autosave.saveOnHide();
|
||||
await tester.pump();
|
||||
expect(h.server.calls, <String>['POST', 'PUT d1']);
|
||||
// 바꾼 것이 없으면 저장하지 않는다
|
||||
h.autosave.saveOnHide();
|
||||
await tester.pump();
|
||||
expect(h.server.calls, hasLength(2));
|
||||
h.autosave.dispose();
|
||||
});
|
||||
|
||||
testWidgets('떠나기 전 — 진행 중인 저장을 기다린 뒤 남은 변경을 저장한다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final _Harness h = _Harness();
|
||||
h.server.gate = Completer<void>();
|
||||
h.edit();
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
h.edit();
|
||||
bool? result;
|
||||
unawaited(
|
||||
h.autosave.saveBeforeLeave().then((bool saved) => result = saved),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(h.server.calls, <String>['POST']);
|
||||
h.server.gate!.complete();
|
||||
h.server.gate = null;
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(h.server.calls, <String>['POST', 'PUT d1']);
|
||||
expect(result, isTrue);
|
||||
h.autosave.dispose();
|
||||
});
|
||||
|
||||
testWidgets('멈춘 동안(보내기 · 나가기 확인)은 저장하지 않고, 진행 중인 저장은 settle 로 기다린다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final _Harness h = _Harness();
|
||||
h.server.gate = Completer<void>();
|
||||
h.edit();
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
h.autosave.suspend();
|
||||
h.edit();
|
||||
bool settled = false;
|
||||
unawaited(h.autosave.settle().then((void _) => settled = true));
|
||||
await tester.pump(const Duration(seconds: 40));
|
||||
expect(settled, isFalse);
|
||||
h.server.gate!.complete();
|
||||
h.server.gate = null;
|
||||
await tester.pump();
|
||||
expect(settled, isTrue);
|
||||
await tester.pump(const Duration(seconds: 40));
|
||||
expect(h.server.calls, <String>['POST']);
|
||||
|
||||
h.autosave.resume();
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
expect(h.server.calls, <String>['POST', 'PUT d1']);
|
||||
h.autosave.dispose();
|
||||
expect(h.autosave.hasPendingTimers, isFalse);
|
||||
});
|
||||
|
||||
test('양식 없이 한 번 — PUT 이 막히면 같은 세션일 때만 POST 로 남긴다', () async {
|
||||
final _Server server = _Server();
|
||||
server.failures.add('MAIL_008');
|
||||
const MailComposePayload payload = _Harness._payload;
|
||||
final DraftSaved? saved = await saveDraftDetached(
|
||||
payload: payload,
|
||||
draftId: 'd0',
|
||||
create: (MailComposePayload _) => server.save('POST'),
|
||||
update: (String id, MailComposePayload _) => server.save('PUT $id'),
|
||||
stillValid: () => true,
|
||||
);
|
||||
expect(saved?.id, 'd1');
|
||||
expect(server.calls, <String>['PUT d0', 'POST']);
|
||||
|
||||
server.failures.add('RES_001');
|
||||
expect(
|
||||
await saveDraftDetached(
|
||||
payload: payload,
|
||||
draftId: 'd1',
|
||||
create: (MailComposePayload _) => server.save('POST'),
|
||||
update: (String id, MailComposePayload _) => server.save('PUT $id'),
|
||||
stillValid: () => false,
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
expect(server.calls, <String>['PUT d0', 'POST', 'PUT d1']);
|
||||
});
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import 'package:arasystem_app/features/mail/data/repositories/mail_repository.da
|
||||
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/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/features/mail/presentation/providers/mail_list_controller.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_state_watcher.dart';
|
||||
@@ -18,7 +19,6 @@ import 'package:arasystem_app/features/mail/presentation/widgets/mail_list_state
|
||||
import 'package:arasystem_app/features/mail/presentation/widgets/mailbox_drawer.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/widgets/message_row.dart';
|
||||
import 'package:arasystem_app/features/settings/settings_routes.dart';
|
||||
import 'package:arasystem_app/shared/widgets/placeholder_page.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
@@ -77,14 +77,14 @@ void main() {
|
||||
MailListPage currentPage(WidgetTester tester) =>
|
||||
tester.widget<MailListPage>(find.byType(MailListPage));
|
||||
|
||||
/// 위에 쌓아 연 전체 화면(읽기 화면 · 쓰기 자리)의 주소 — `push` 는 라우터의 주소를 바꾸지 않는다
|
||||
/// 위에 쌓아 연 전체 화면(읽기 화면 · 쓰기 화면)의 주소 — `push` 는 라우터의 주소를 바꾸지 않는다
|
||||
String pushedLocation(WidgetTester tester) {
|
||||
final Finder reader = find.byType(MessageReaderPage);
|
||||
return GoRouterState.of(
|
||||
tester.element(
|
||||
reader.evaluate().isNotEmpty
|
||||
? reader.last
|
||||
: find.byType(PlaceholderPage).last,
|
||||
: find.byType(MessageComposePage).last,
|
||||
),
|
||||
).uri.toString();
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,13 +13,13 @@ import 'package:arasystem_app/features/mail/domain/mail_html.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/message_reader_view.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/features/mail/presentation/providers/mailboxes_provider.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/message_reader_controller.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/message_row.dart';
|
||||
import 'package:arasystem_app/shared/widgets/placeholder_page.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -94,7 +94,7 @@ void main() {
|
||||
String? lastDocument() => h.webView.last.lastHtml;
|
||||
|
||||
String pushedLocation(WidgetTester tester) => GoRouterState.of(
|
||||
tester.element(find.byType(PlaceholderPage).last),
|
||||
tester.element(find.byType(MessageComposePage).last),
|
||||
).uri.toString();
|
||||
|
||||
group('머리 · 본문', () {
|
||||
@@ -684,7 +684,7 @@ void main() {
|
||||
// 쓰기 화면 · 바깥 링크 · mailto 를 열지 않는다
|
||||
await tester.tap(find.text(MailReaderTexts.reply));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(PlaceholderPage), findsNothing);
|
||||
expect(find.byType(MessageComposePage), findsNothing);
|
||||
expect(
|
||||
await h.webView.last.navigate('mailto:kim@example.com'),
|
||||
NavigationDecision.prevent,
|
||||
@@ -694,7 +694,7 @@ void main() {
|
||||
NavigationDecision.prevent,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(PlaceholderPage), findsNothing);
|
||||
expect(find.byType(MessageComposePage), findsNothing);
|
||||
expect(h.links.launched, isEmpty);
|
||||
|
||||
// 그사이 위에 다른 화면이 열렸다 — 옮기기가 끝나도 그 화면을 닫지 않는다
|
||||
@@ -704,7 +704,7 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(h.mail.mailboxOf('m1'), kTrashId);
|
||||
expect(find.byType(PlaceholderPage), findsOneWidget);
|
||||
expect(find.byType(MessageComposePage), findsOneWidget);
|
||||
expect(pushedLocation(tester), MailRoutes.compose);
|
||||
h.router.pop();
|
||||
await tester.pumpAndSettle();
|
||||
@@ -1042,7 +1042,7 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final GoRouterState compose = GoRouterState.of(
|
||||
tester.element(find.byType(PlaceholderPage).last),
|
||||
tester.element(find.byType(MessageComposePage).last),
|
||||
);
|
||||
expect(compose.uri.path, MailRoutes.compose);
|
||||
expect(
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:arasystem_app/features/mail/data/repositories/compose_repository.dart';
|
||||
import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import 'fakes.dart';
|
||||
import 'mail_fakes.dart';
|
||||
|
||||
/// 받은 발송 하나 — 본문과 중복 발송 방지 키
|
||||
typedef SentRequest = ({Map<String, Object?> body, String? key});
|
||||
|
||||
/// 쓰기 화면의 API 를 흉내 내는 가짜 뒤쪽 — 임시 저장(POST · PUT · GET) · 발송 · 서명 · 서명 그림 · 보내기 설정 ·
|
||||
/// 받는 사람 추천 · 주소록 그룹 · 구성원 · 대용량 파일 상태.
|
||||
///
|
||||
/// 저장본은 메모리에 둔다 — PUT 은 새 id 로 바꾸고 옛것을 지운다(계약 2절). 실패([draftFailure] · [sendFailure])와
|
||||
/// 문([saveGate] · [sendGate])으로 응답을 늦추거나 실패시킨다.
|
||||
class FakeComposeBackend {
|
||||
FakeComposeBackend(this.server, this.mail);
|
||||
|
||||
final FakeServer server;
|
||||
final FakeMailBackend mail;
|
||||
|
||||
/// 저장본 — id 마다 계약의 `Draft` 칸
|
||||
final Map<String, Map<String, Object?>> drafts =
|
||||
<String, Map<String, Object?>>{};
|
||||
|
||||
/// 받은 저장 요청 차례대로 — `POST` · `PUT {id}` 와 본문
|
||||
final List<({String call, Map<String, Object?> body})> saves =
|
||||
<({String call, Map<String, Object?> body})>[];
|
||||
|
||||
/// 받은 발송 차례대로
|
||||
final List<SentRequest> sends = <SentRequest>[];
|
||||
|
||||
/// 다음 저장들이 이 응답으로 실패한다(차례대로 하나씩 쓴다)
|
||||
final List<FakeResponse> draftFailures = <FakeResponse>[];
|
||||
|
||||
/// 다음 발송들이 이 응답으로 실패한다(차례대로 하나씩 쓴다)
|
||||
final List<FakeResponse> sendFailures = <FakeResponse>[];
|
||||
|
||||
/// 있으면 저장 응답이 이것이 열릴 때까지 기다린다(한 번 쓰면 비운다)
|
||||
Completer<void>? saveGate;
|
||||
|
||||
/// 있으면 발송 응답이 이것이 열릴 때까지 기다린다(한 번 쓰면 비운다)
|
||||
Completer<void>? sendGate;
|
||||
|
||||
/// 발송의 붙잡기 — 보내기 취소를 기다리는 초(0 이면 바로 보냈다)
|
||||
int undoSeconds = 10;
|
||||
|
||||
/// 받지 못한 사람 — 발송 응답의 `rejectedRecipients`
|
||||
List<Map<String, Object?>> rejected = <Map<String, Object?>>[];
|
||||
|
||||
/// 내 서명 — null 이면 없음(404)
|
||||
Map<String, Object?>? signature;
|
||||
|
||||
/// 서명 그림 — id 마다 바이트
|
||||
final Map<String, List<int>> signatureImages = <String, List<int>>{};
|
||||
|
||||
/// 받는 사람 추천 — 받아 두기(q 없음)와 묻기(q)에 같은 것을 준다(q 는 이름 · 주소에 든 것만)
|
||||
List<Map<String, Object?>> suggestions = <Map<String, Object?>>[];
|
||||
|
||||
/// 받아 두기(q 없음)의 다음 쪽이 있다 — 있으면 쓰기 화면이 모자랄 때 서버에 q 로 묻는다
|
||||
bool suggestionsHasNext = false;
|
||||
|
||||
/// 주소록 그룹과 그 구성원(`Contact` 꼴)
|
||||
List<Map<String, Object?>> groups = <Map<String, Object?>>[];
|
||||
final Map<String, List<Map<String, Object?>>> members =
|
||||
<String, List<Map<String, Object?>>>{};
|
||||
|
||||
/// 대용량 파일의 지금 상태 — id 마다(없으면 404)
|
||||
final Map<String, Map<String, Object?>> largeFiles =
|
||||
<String, Map<String, Object?>>{};
|
||||
|
||||
/// 있으면 대용량 파일 상태 응답이 이것이 열릴 때까지 기다린다(한 번 쓰면 비운다)
|
||||
Completer<void>? largeFileGate;
|
||||
|
||||
int _nextDraft = 0;
|
||||
int _nextSent = 0;
|
||||
|
||||
static final RegExp _draftPath = RegExp(r'^/drafts/([^/]+)$');
|
||||
static final RegExp _imagePath = RegExp(r'^/mail-signature/images/([^/]+)$');
|
||||
static final RegExp _filePath = RegExp(r'^/file-uploads/([^/]+)$');
|
||||
|
||||
String _idOf(RegExp pattern, String path) =>
|
||||
Uri.decodeComponent(pattern.firstMatch(path)!.group(1)!);
|
||||
|
||||
List<RequestOptions> get suggestionRequests =>
|
||||
server.received('GET', ComposeApiPaths.recipientSuggestions);
|
||||
|
||||
/// 저장본 하나를 둔다 — 칸은 계약 2절 `Draft`
|
||||
void putDraft(
|
||||
String id, {
|
||||
List<Map<String, Object?>> to = const <Map<String, Object?>>[],
|
||||
List<Map<String, Object?>> cc = const <Map<String, Object?>>[],
|
||||
List<Map<String, Object?>> bcc = const <Map<String, Object?>>[],
|
||||
String subject = '쓰던 메일',
|
||||
String body = '',
|
||||
String? bodyHtml,
|
||||
List<Map<String, Object?>> attachments = const <Map<String, Object?>>[],
|
||||
String? inReplyTo,
|
||||
List<Map<String, Object?>> largeFiles = const <Map<String, Object?>>[],
|
||||
}) {
|
||||
drafts[id] = <String, Object?>{
|
||||
'id': id,
|
||||
'to': to,
|
||||
'cc': cc,
|
||||
'bcc': bcc,
|
||||
'subject': subject,
|
||||
'body': body,
|
||||
'bodyHtml': bodyHtml,
|
||||
'attachments': attachments,
|
||||
'inReplyTo': inReplyTo,
|
||||
'updatedAt': kTestNow.toUtc().toIso8601String(),
|
||||
'largeFiles': largeFiles,
|
||||
};
|
||||
}
|
||||
|
||||
/// 경로마다 처리기를 건다
|
||||
void install() {
|
||||
server.on('POST', ComposeApiPaths.drafts, (RequestOptions options) {
|
||||
return _save('POST', options, null);
|
||||
});
|
||||
server.onPattern('PUT', _draftPath, (RequestOptions options) {
|
||||
final String id = _idOf(_draftPath, options.path);
|
||||
return _save('PUT $id', options, id);
|
||||
});
|
||||
server.onPattern('GET', _draftPath, (RequestOptions options) {
|
||||
final Map<String, Object?>? draft =
|
||||
drafts[_idOf(_draftPath, options.path)];
|
||||
return draft == null
|
||||
? FakeResponse.fail(404, 'RES_001')
|
||||
: FakeResponse.ok(draft);
|
||||
});
|
||||
server.on('POST', MailApiPaths.messages, _send);
|
||||
server.on('GET', ComposeApiPaths.mailSignature, (_) {
|
||||
final Map<String, Object?>? current = signature;
|
||||
return current == null
|
||||
? FakeResponse.fail(404, 'RES_001')
|
||||
: FakeResponse.ok(current);
|
||||
});
|
||||
server.onPattern('GET', _imagePath, (RequestOptions options) {
|
||||
final List<int>? bytes = signatureImages[_idOf(_imagePath, options.path)];
|
||||
return bytes == null
|
||||
? FakeResponse.fail(404, 'RES_001')
|
||||
: FakeResponse.bytes(bytes, contentType: 'image/png');
|
||||
});
|
||||
server.on(
|
||||
'GET',
|
||||
ComposeApiPaths.mailSettings,
|
||||
(_) => FakeResponse.ok(<String, Object?>{'undoSendSeconds': undoSeconds}),
|
||||
);
|
||||
server.on('GET', ComposeApiPaths.recipientSuggestions, (
|
||||
RequestOptions options,
|
||||
) {
|
||||
final Object? q = options.queryParameters['q'];
|
||||
final List<Map<String, Object?>> items = q is String
|
||||
? <Map<String, Object?>>[
|
||||
for (final Map<String, Object?> item in suggestions)
|
||||
if ('${item['name']} ${item['email']}'.contains(q)) item,
|
||||
]
|
||||
: suggestions;
|
||||
final bool hasNext = q is! String && suggestionsHasNext;
|
||||
return FakeResponse.ok(<String, Object?>{
|
||||
'items': items,
|
||||
'pageInfo': <String, Object?>{
|
||||
'hasNext': hasNext,
|
||||
'nextCursor': hasNext ? 'next' : null,
|
||||
},
|
||||
});
|
||||
});
|
||||
server.on(
|
||||
'GET',
|
||||
ComposeApiPaths.contactGroups,
|
||||
(_) => FakeResponse.ok(<String, Object?>{
|
||||
'items': groups,
|
||||
'pageInfo': <String, Object?>{'hasNext': false, 'nextCursor': null},
|
||||
}),
|
||||
);
|
||||
server.on('GET', ComposeApiPaths.contacts, (RequestOptions options) {
|
||||
final Object? groupId = options.queryParameters['groupId'];
|
||||
final List<Map<String, Object?>>? list = members[groupId];
|
||||
if (list == null) return FakeResponse.fail(404, 'RES_001');
|
||||
return FakeResponse.ok(<String, Object?>{
|
||||
'items': list,
|
||||
'pageInfo': <String, Object?>{'hasNext': false, 'nextCursor': null},
|
||||
});
|
||||
});
|
||||
server.onPattern('GET', _filePath, (RequestOptions options) async {
|
||||
final Completer<void>? gate = largeFileGate;
|
||||
if (gate != null) {
|
||||
largeFileGate = null;
|
||||
await gate.future;
|
||||
}
|
||||
final Map<String, Object?>? file =
|
||||
largeFiles[_idOf(_filePath, options.path)];
|
||||
return file == null
|
||||
? FakeResponse.fail(404, 'RES_001')
|
||||
: FakeResponse.ok(file);
|
||||
});
|
||||
}
|
||||
|
||||
Future<FakeResponse> _save(
|
||||
String call,
|
||||
RequestOptions options,
|
||||
String? previousId,
|
||||
) async {
|
||||
final Map<String, Object?> body = options.data! as Map<String, Object?>;
|
||||
saves.add((call: call, body: body));
|
||||
final Completer<void>? gate = saveGate;
|
||||
if (gate != null) {
|
||||
saveGate = null;
|
||||
await gate.future;
|
||||
}
|
||||
if (draftFailures.isNotEmpty) return draftFailures.removeAt(0);
|
||||
if (previousId != null && !drafts.containsKey(previousId)) {
|
||||
return FakeResponse.fail(404, 'RES_001');
|
||||
}
|
||||
if (previousId != null) drafts.remove(previousId);
|
||||
final String id = 'saved-${++_nextDraft}';
|
||||
final List<Object?> attachments = <Object?>[
|
||||
for (final Map<String, Object?> item
|
||||
in (body['attachments']! as List<Object?>)
|
||||
.cast<Map<String, Object?>>())
|
||||
attachmentJson('$id-${item['id']}', name: item['name']! as String),
|
||||
];
|
||||
drafts[id] = <String, Object?>{
|
||||
...body,
|
||||
'id': id,
|
||||
'attachments': attachments,
|
||||
'updatedAt': kTestNow.toUtc().toIso8601String(),
|
||||
'largeFiles': <Object?>[
|
||||
for (final Object? fileId
|
||||
in (body['largeFileIds'] as List<Object?>?) ?? <Object?>[])
|
||||
<String, Object?>{
|
||||
'id': fileId,
|
||||
'status': 'ready',
|
||||
'name': '$fileId.zip',
|
||||
'size': 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
return FakeResponse.ok(<String, Object?>{
|
||||
'id': id,
|
||||
'attachments': attachments,
|
||||
'updatedAt': kTestNow.toUtc().toIso8601String(),
|
||||
});
|
||||
}
|
||||
|
||||
Future<FakeResponse> _send(RequestOptions options) async {
|
||||
final Map<String, Object?> body = options.data! as Map<String, Object?>;
|
||||
sends.add((
|
||||
body: body,
|
||||
key: options.headers[kIdempotencyHeader] as String?,
|
||||
));
|
||||
final Completer<void>? gate = sendGate;
|
||||
if (gate != null) {
|
||||
sendGate = null;
|
||||
await gate.future;
|
||||
}
|
||||
if (sendFailures.isNotEmpty) return sendFailures.removeAt(0);
|
||||
final Object? draftId = body['draftId'];
|
||||
if (draftId is String) drafts.remove(draftId);
|
||||
final String id = 'sent-${++_nextSent}';
|
||||
final DateTime now = server.serverTime ?? kTestNow;
|
||||
// 보낸편지함에 넣어 둔다 — [보내기 취소]가 그 메일을 임시보관함으로 옮긴다
|
||||
mail.put(
|
||||
kSentId,
|
||||
messageJson(
|
||||
id,
|
||||
mailboxId: kSentId,
|
||||
subject: body['subject']! as String,
|
||||
pendingSend: undoSeconds > 0
|
||||
? pendingJson(now.add(Duration(seconds: undoSeconds)))
|
||||
: null,
|
||||
),
|
||||
);
|
||||
if (undoSeconds > 0) {
|
||||
// 취소하면 보낸 내용 그대로 임시 저장본이 된다
|
||||
drafts[id] = <String, Object?>{
|
||||
...body,
|
||||
'id': id,
|
||||
'attachments': <Object?>[],
|
||||
'updatedAt': now.toUtc().toIso8601String(),
|
||||
'largeFiles': <Object?>[],
|
||||
}..remove('draftId');
|
||||
}
|
||||
return FakeResponse(201, <String, Object?>{
|
||||
'success': true,
|
||||
'data': <String, Object?>{
|
||||
'id': id,
|
||||
'rejectedRecipients': rejected,
|
||||
'pendingSend': undoSeconds > 0
|
||||
? pendingJson(now.add(Duration(seconds: undoSeconds)))
|
||||
: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user