From 80e48d318d2338c8e5b9edb216ec48d017a61120 Mon Sep 17 00:00:00 2001 From: ttipo Date: Sat, 7 Nov 2026 02:11:00 +0900 Subject: [PATCH] =?UTF-8?q?feat(flutter):=20=EB=A9=94=EC=9D=BC=20=EC=93=B0?= =?UTF-8?q?=EA=B8=B0=20=ED=99=94=EB=A9=B4=201=EC=B0=A8=EB=A5=BC=20?= =?UTF-8?q?=EB=A7=8C=EB=93=A0=EB=8B=A4=20(App1Compose)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 새 메일 · 답장 · 전체 답장 · 전달 · 이어 쓰기 · mailto 를 연다. 글만 쓰고, 보낼 때 웹과 같은 서식 본문(기본 글꼴 div · 서명 경계 div · "원본 메일" 인용)과 텍스트 본문을 함께 만든다 — 원문 인용은 글로 만든 덩이다. - 임시 저장은 웹 규칙(3초 · 30초 · 10초 · 한 번에 하나)에 더해 앱이 안 보이게 되면 바로 저장한다 - 보내기는 같은 Idempotency-Key 규칙(성공 · MAIL_005 에만 새 키), 보낸 뒤 시작한 화면에 [보내기 취소] 마감 알림 · 남은 시간 막대 - 서명은 따로 된 칸([서명 빼기] · [서명 넣기]), 원본 메일은 접힌 채 - 리뷰 H1: 진행 중인 저장을 기다리는 사이 화면을 떠나면 메일이 나가지 않던 것 — keepAlive 를 기다림 앞에서 잡고 떠나도 그대로 보낸다 - 리뷰 M1 · M2 · M3: 저장본 읽기는 읽기 화면과 같은 한도 · isolate, 웹 저장본의 정렬 · 들여쓰기는 남의 저장본 길로, 확인 창의 거짓 "저장했습니다" - 첨부 고르기 · 올리기는 2차(지금은 전달 원본 · 저장본의 첨부만 싣고 뺀다) Co-Authored-By: Claude Opus 5.5 --- flutter/lib/core/theme/app_colors.dart | 9 + flutter/lib/core/theme/app_spacing.dart | 43 + flutter/lib/core/theme/app_theme.dart | 30 + flutter/lib/core/utils/utf16_length.dart | 53 + .../mail/data/models/compose_models.dart | 333 ++++ .../mail/data/models/compose_payload.dart | 81 + .../mail/data/models/message_detail.dart | 10 + .../data/repositories/compose_repository.dart | 265 +++ .../features/mail/domain/compose_body.dart | 362 ++++ .../mail/domain/compose_draft_body.dart | 422 +++++ .../lib/features/mail/domain/compose_fit.dart | 229 +++ .../features/mail/domain/compose_reply.dart | 183 ++ .../features/mail/domain/compose_send.dart | 145 ++ .../mail/domain/compose_signature.dart | 229 +++ .../lib/features/mail/domain/html_nodes.dart | 97 + .../lib/features/mail/domain/mail_quote.dart | 514 ++++++ flutter/lib/features/mail/mail_routes.dart | 17 +- .../mail/presentation/mail_texts.dart | 99 +- .../pages/message_compose_page.dart | 670 +++++++ .../pages/message_reader_page.dart | 16 +- .../providers/compose_controller.dart | 1586 +++++++++++++++++ .../presentation/providers/compose_state.dart | 258 +++ .../providers/draft_autosave.dart | 409 +++++ .../providers/mail_toast_provider.dart | 36 +- .../providers/signature_image_provider.dart | 27 + .../presentation/widgets/compose_bars.dart | 173 ++ .../widgets/compose_recipient_field.dart | 731 ++++++++ .../widgets/compose_sections.dart | 698 ++++++++ .../widgets/mail_compose_area.dart | 157 +- .../lib/shared/widgets/confirm_dialog.dart | 67 +- flutter/test/app_test.dart | 4 +- .../features/mail/compose_reading_test.dart | 271 +++ .../mail/compose_repository_test.dart | 390 ++++ .../features/mail/compose_rules_test.dart | 920 ++++++++++ .../features/mail/draft_autosave_test.dart | 331 ++++ .../features/mail/mail_list_page_test.dart | 6 +- .../mail/message_compose_page_test.dart | 1113 ++++++++++++ .../mail/message_reader_page_test.dart | 12 +- flutter/test/support/compose_fakes.dart | 298 ++++ 39 files changed, 11209 insertions(+), 85 deletions(-) create mode 100644 flutter/lib/core/utils/utf16_length.dart create mode 100644 flutter/lib/features/mail/data/models/compose_models.dart create mode 100644 flutter/lib/features/mail/data/models/compose_payload.dart create mode 100644 flutter/lib/features/mail/data/repositories/compose_repository.dart create mode 100644 flutter/lib/features/mail/domain/compose_body.dart create mode 100644 flutter/lib/features/mail/domain/compose_draft_body.dart create mode 100644 flutter/lib/features/mail/domain/compose_fit.dart create mode 100644 flutter/lib/features/mail/domain/compose_reply.dart create mode 100644 flutter/lib/features/mail/domain/compose_send.dart create mode 100644 flutter/lib/features/mail/domain/compose_signature.dart create mode 100644 flutter/lib/features/mail/domain/html_nodes.dart create mode 100644 flutter/lib/features/mail/domain/mail_quote.dart create mode 100644 flutter/lib/features/mail/presentation/pages/message_compose_page.dart create mode 100644 flutter/lib/features/mail/presentation/providers/compose_controller.dart create mode 100644 flutter/lib/features/mail/presentation/providers/compose_state.dart create mode 100644 flutter/lib/features/mail/presentation/providers/draft_autosave.dart create mode 100644 flutter/lib/features/mail/presentation/providers/signature_image_provider.dart create mode 100644 flutter/lib/features/mail/presentation/widgets/compose_bars.dart create mode 100644 flutter/lib/features/mail/presentation/widgets/compose_recipient_field.dart create mode 100644 flutter/lib/features/mail/presentation/widgets/compose_sections.dart create mode 100644 flutter/test/features/mail/compose_reading_test.dart create mode 100644 flutter/test/features/mail/compose_repository_test.dart create mode 100644 flutter/test/features/mail/compose_rules_test.dart create mode 100644 flutter/test/features/mail/draft_autosave_test.dart create mode 100644 flutter/test/features/mail/message_compose_page_test.dart create mode 100644 flutter/test/support/compose_fakes.dart diff --git a/flutter/lib/core/theme/app_colors.dart b/flutter/lib/core/theme/app_colors.dart index a6d49be..1b41a73 100644 --- a/flutter/lib/core/theme/app_colors.dart +++ b/flutter/lib/core/theme/app_colors.dart @@ -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); diff --git a/flutter/lib/core/theme/app_spacing.dart b/flutter/lib/core/theme/app_spacing.dart index 853fe7a..aff05e2 100644 --- a/flutter/lib/core/theme/app_spacing.dart +++ b/flutter/lib/core/theme/app_spacing.dart @@ -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; } diff --git a/flutter/lib/core/theme/app_theme.dart b/flutter/lib/core/theme/app_theme.dart index 8e6a075..8132840 100644 --- a/flutter/lib/core/theme/app_theme.dart +++ b/flutter/lib/core/theme/app_theme.dart @@ -197,8 +197,12 @@ class AppExtraColors extends ThemeExtension { 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 { 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 { /// 옅은 나눔선 — 화면 맨 아래 안내 글 위 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 { 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 { 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 { 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)!, diff --git a/flutter/lib/core/utils/utf16_length.dart b/flutter/lib/core/utils/utf16_length.dart new file mode 100644 index 0000000..b4dc7a6 --- /dev/null +++ b/flutter/lib/core/utils/utf16_length.dart @@ -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), + ); + } +} diff --git a/flutter/lib/features/mail/data/models/compose_models.dart b/flutter/lib/features/mail/data/models/compose_models.dart new file mode 100644 index 0000000..6b0cf63 --- /dev/null +++ b/flutter/lib/features/mail/data/models/compose_models.dart @@ -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) 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 [], + this.inReplyTo, + this.largeFiles = const [], + }); + + /// 저장할 때마다 바뀐다 + final String id; + final List to; + final List cc; + final List bcc; + final String subject; + + /// 텍스트 대체 본문 + final String body; + + /// 서식 본문 — 보내는 정화를 거친 값. 없거나 비면 null + final String? bodyHtml; + + /// 이 판의 첨부 — id 가 저장할 때마다 바뀐다 + final List attachments; + + /// 답장할 메일 id — 없거나 원 메일이 사라졌으면 null + final String? inReplyTo; + final DateTime updatedAt; + final List largeFiles; + + static Draft? tryParse(Object? json) { + if (json is! Map) return null; + final Object? id = json['id']; + final List? to = MailAddress.tryParseList(json['to']); + final List? cc = MailAddress.tryParseList( + json['cc'] ?? [], + ); + final List? bcc = MailAddress.tryParseList( + json['bcc'] ?? [], + ); + 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? 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 + ? List.unmodifiable([ + for (final Object? item in largeFiles) + ?DraftLargeFile.tryParse(item), + ]) + : const [], + ); + } +} + +/// 저장한 결과 — `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 attachments; + final DateTime updatedAt; + + static DraftSaved? tryParse(Object? json) { + if (json is! Map) return null; + final Object? id = json['id']; + final Object? updatedAt = json['updatedAt']; + final DateTime? updated = updatedAt is String + ? DateTime.tryParse(updatedAt) + : null; + final List? 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? _attachments(Object? value) { + if (value == null) return const []; + if (value is! List) return null; + final List items = []; + for (final Object? item in value) { + final MessageAttachment? attachment = MessageAttachment.tryParse(item); + if (attachment == null) return null; + items.add(attachment); + } + return List.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) 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 [], + this.pendingSend, + }); + + final String id; + final List rejectedRecipients; + + /// 보내기 취소를 기다리는 중이면 그 정보 — 바로 보냈으면 null + final PendingSend? pendingSend; + + static SentMessage? tryParse(Object? json) { + if (json is! Map) 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 + ? List.unmodifiable([ + for (final Object? item in rejected) + ?RejectedRecipient.tryParse(item), + ]) + : const [], + 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 — 서명 그림 `` 포함) + final String? bodyHtml; + + /// 텍스트 서명 — 텍스트 대체 본문에 넣는다 + final String? body; + + /// 새 메일에 넣는다 + final bool isForNew; + + /// 답장 · 전달에 넣는다(인용 위) + final bool isForReply; + + static MailSignature? tryParse(Object? json) { + if (json is! Map) 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) return null; + final Object? seconds = json['undoSendSeconds']; + if (seconds is! num || seconds < 0) return null; + return seconds.toInt(); +} diff --git a/flutter/lib/features/mail/data/models/compose_payload.dart b/flutter/lib/features/mail/data/models/compose_payload.dart new file mode 100644 index 0000000..04e72e2 --- /dev/null +++ b/flutter/lib/features/mail/data/models/compose_payload.dart @@ -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 [], + this.inReplyTo, + this.draftId, + this.largeFileIds = const [], + this.bodyQuoteStart, + }); + + final List to; + final List cc; + final List bcc; + final String subject; + + /// 텍스트 대체 본문 + final String body; + + /// 서식 본문 — null 이면 텍스트 메일 + final String? bodyHtml; + final List attachments; + + /// 답장할 메일 id — 답장만(전달에는 싣지 않는다) + final String? inReplyTo; + + /// 이어 쓴 저장본 — 보내기만(보낸 뒤 서버가 지운다) + final String? draftId; + + /// 이 메일에 싣는 내 대용량 파일 — 저장본에 이미 있던 것(앱은 첫 판에서 새로 올리지 않는다) + final List 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 toJson() => { + 'to': [for (final PersonAddress a in to) a.toJson()], + 'cc': [for (final PersonAddress a in cc) a.toJson()], + 'bcc': [for (final PersonAddress a in bcc) a.toJson()], + 'subject': subject, + 'body': body, + 'bodyHtml': ?bodyHtml, + 'attachments': [ + for (final ComposeAttachmentRef attachment in attachments) + {'id': attachment.id, 'name': attachment.name}, + ], + 'inReplyTo': ?inReplyTo, + 'draftId': ?draftId, + if (largeFileIds.isNotEmpty) 'largeFileIds': largeFileIds, + 'bodyQuoteStart': ?bodyQuoteStart, + }; +} diff --git a/flutter/lib/features/mail/data/models/message_detail.dart b/flutter/lib/features/mail/data/models/message_detail.dart index 46f1e39..8db1244 100644 --- a/flutter/lib/features/mail/data/models/message_detail.dart +++ b/flutter/lib/features/mail/data/models/message_detail.dart @@ -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 [], this.bcc = const [], + this.replyTo = const [], this.sentAt, this.htmlBody, this.textBody, @@ -129,6 +131,9 @@ class MessageDetail { /// 숨은참조 — 내가 보낸 메일에만 온다 final List bcc; + + /// 답장 받을 주소(`Reply-To`) — 없으면 빈 목록. 답장은 있으면 이 주소로 간다(쓰기 화면 — 웹 `buildReplyRecipients`) + final List 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? cc = _addresses(json['cc']); final List? bcc = _addresses(json['bcc']); + final List? 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?, diff --git a/flutter/lib/features/mail/data/repositories/compose_repository.dart b/flutter/lib/features/mail/data/repositories/compose_repository.dart new file mode 100644 index 0000000..7bba60d --- /dev/null +++ b/flutter/lib/features/mail/data/repositories/compose_repository.dart @@ -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 items, + bool hasNext, +}); + +/// 쓰기 화면의 API — 계약 2절(임시 저장 · 발송 · 서명 · 받는 사람 추천 · 보내기 설정) · 3절(그룹 · 구성원) · 4절(대용량 파일 상태). +/// +/// 응답 언래핑 · 에러 코드 · 알림은 인터셉터가 맡는다 — 여기서는 읽기만 하고 문구를 만들지 않는다. +/// 실패는 늘 `DioException`(`error` 에 `ApiException`)이다 — 응답 모양이 계약과 다르면 `SYS_001`. +class ComposeRepository { + const ComposeRepository(this._dio); + + final Dio _dio; + + /// 임시 저장본 — 알림은 띄우지 않는다(받지 못하면 화면이 [다시 시도]를 그린다) + Future getDraft(String id) async { + final Response response = await _dio.get( + ComposeApiPaths.draft(id), + options: ApiOptions.silent(), + ); + final Draft? draft = Draft.tryParse(response.data); + if (draft == null) throw _malformed(response); + return draft; + } + + /// 새 저장본 — 자동 저장은 알리지 않는다([silent]). 받기 한도는 발송과 같다 + Future createDraft( + MailComposePayload payload, { + bool silent = true, + }) async { + final Response response = await _dio.post( + ComposeApiPaths.drafts, + data: payload.toJson(), + options: _longOptions(silent: silent), + ); + return _saved(response); + } + + /// 저장본 바꾸기 — 응답의 새 id 로 바꿔 쓴다. 다른 곳에서 바뀌었으면 409 `MAIL_008`, 초안이 아니면 404 `RES_001` + Future updateDraft( + String id, + MailComposePayload payload, { + bool silent = true, + }) async { + final Response response = await _dio.put( + 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 silentCodes = const {}, + }) async { + final Response response = await _dio.post( + MailApiPaths.messages, + data: payload.toJson(), + options: Options( + receiveTimeout: kMailSendTimeout, + headers: {kIdempotencyHeader: idempotencyKey}, + extra: {kSilentExtraKey: silentCodes}, + ), + ); + final SentMessage? sent = SentMessage.tryParse(response.data); + if (sent == null) throw _malformed(response); + return (sent: sent, serverTime: serverTimeOf(response)); + } + + /// 내 서명 — 알리지 않는다(서명 없이 쓴다) + Future getSignature() async { + final Response response = await _dio.get( + ComposeApiPaths.mailSignature, + options: ApiOptions.silent(), + ); + final MailSignature? signature = MailSignature.tryParse(response.data); + if (signature == null) throw _malformed(response); + return signature; + } + + /// 서명 그림 하나(바이트) — 알리지 않는다(그 그림을 숨긴다). [maxBytes] 를 넘게 오면 끊는다 + Future fetchSignatureImage( + String id, { + required int maxBytes, + }) async { + final CancelToken cancel = CancelToken(); + final Response> response = await _dio.get>( + ComposeApiPaths.signatureImage(id), + options: Options( + responseType: ResponseType.bytes, + extra: {kSilentExtraKey: true}, + ), + cancelToken: cancel, + onReceiveProgress: (int received, int total) { + if (received > maxBytes || total > maxBytes) cancel.cancel(); + }, + ); + final List? data = response.data; + if (data == null) { + throw apiFailure(response.requestOptions, kFallbackCode); + } + return data is Uint8List ? data : Uint8List.fromList(data); + } + + /// 받는 사람 추천 — 보조 정보라 알리지 않는다. [query] 가 있으면 서버가 같은 맞추기로 거른다(계약 2절) + Future listRecipientSuggestions({ + String? query, + int limit = kRecipientSuggestionPreload, + CancelToken? cancelToken, + }) async { + final Response response = await _dio.get( + ComposeApiPaths.recipientSuggestions, + queryParameters: {'q': ?query, 'limit': limit}, + options: ApiOptions.silent(), + cancelToken: cancelToken, + ); + final Object? data = response.data; + final Object? items = data is Map ? data['items'] : null; + final MailPageInfo? pageInfo = data is Map + ? MailPageInfo.tryParse(data['pageInfo']) + : null; + if (items is! List || pageInfo == null) throw _malformed(response); + return ( + items: List.unmodifiable([ + for (final Object? item in items) ?RecipientSuggestion.tryParse(item), + ]), + hasNext: pageInfo.hasNext, + ); + } + + /// 내 주소록 그룹(한 쪽 — 100개까지) — 알리지 않는다(그룹 줄 없이 주소 추천만) + Future> listContactGroups() async { + final Response response = await _dio.get( + ComposeApiPaths.contactGroups, + queryParameters: {'limit': kContactsPageSize}, + options: ApiOptions.silent(), + ); + final MailPage? page = + MailPage.tryParse( + response.data, + ContactGroupSummary.tryParse, + ); + if (page == null) throw _malformed(response); + return page.items; + } + + /// 그룹 구성원 — 100명씩 다음 쪽까지(최대 [kGroupMemberPagesMax] 쪽). 고른 일이라 실패는 인터셉터가 알린다 + Future> listGroupMembers(String groupId) async { + final List members = []; + String? cursor; + for (int page = 0; page < kGroupMemberPagesMax; page++) { + final Response response = await _dio.get( + ComposeApiPaths.contacts, + queryParameters: { + 'groupId': groupId, + 'limit': kContactsPageSize, + 'cursor': ?cursor, + }, + ); + final MailPage? result = MailPage.tryParse( + 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 undoSendSeconds() async { + final Response response = await _dio.get( + ComposeApiPaths.mailSettings, + options: ApiOptions.silent(), + ); + final int? seconds = undoSendSecondsOf(response.data); + if (seconds == null) throw _malformed(response); + return seconds; + } + + /// 대용량 파일 하나의 지금 상태 — 없으면 404 `RES_001`(지웠거나 기간이 지남). 알리지 않는다 + Future getLargeFile(String id) async { + final Response response = await _dio.get( + 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: {if (silent) kSilentExtraKey: true}, + ); + + DraftSaved _saved(Response response) { + final DraftSaved? saved = DraftSaved.tryParse(response.data); + if (saved == null) throw _malformed(response); + return saved; + } + + DioException _malformed(Response response) => + apiFailure(response.requestOptions, kFallbackCode); +} diff --git a/flutter/lib/features/mail/domain/compose_body.dart b/flutter/lib/features/mail/domain/compose_body.dart new file mode 100644 index 0000000..e827590 --- /dev/null +++ b/flutter/lib/features/mail/domain/compose_body.dart @@ -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 (속성 없음 — 서버 정화가 data-* 를 지운다) +///
← 원본 메일 — 웹이 다시 열 때 isMailQuoteBlock 으로 알아본다 +///

원본 메일

머리
+///

원문 한 줄

…(원문 속 인용은 안쪽 blockquote) +///
+///
+/// ``` +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 = '
'; + +/// 텍스트 본문의 인용 줄 머리 (웹 `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 header; + final List blocks; + + /// 접힌 줄의 둘째 줄 — "이도윤 · 11월 5일 (목) 오전 10:42" + final String summary; + + /// 끝에 생략 표시를 둔다 — 길어서 줄였거나 서버가 읽기 한도에서 잘라 보냈다 + final bool omitted; + + /// 그대로 실을 인용 HTML — 웹 · 다른 프로그램이 만든 저장본의 인용(서식을 지킨다). null 이면 [blocks] 로 다시 쓴다 + final String? rawHtml; + + /// 덩이를 바꾼 인용 — 원래 HTML 은 버린다(덩이로 다시 쓴다) + ComposeQuote withBlocks(List 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 composeLinesOf(String text) => + text.replaceAll('\r\n', '\n').replaceAll('\r', '\n').split('\n'); + +/// 보낼 본문을 만든다 — 모양은 위 머리말. 인용이 [ComposeQuote.rawHtml] 을 가지면 그것을 그대로 싣는다 +MailBodyOutput buildMailBody(ComposeBodyInput input) { + final List lines = composeLinesOf(input.text); + final String? signatureHtml = input.signatureHtml; + final ComposeQuote? quote = input.quote; + final bool tail = signatureHtml != null || quote != null; + // 뒤에 아무것도 없으면 끝의 빈 줄은 보내지 않는다(웹 trimTrailingEmptyParagraphs) + final List htmlLines = tail ? lines : _withoutTrailingEmpty(lines); + final String? html = !tail && htmlLines.isEmpty + ? null + : _bodyHtml(htmlLines, signatureHtml, quote, input.quoteStartMark); + + final List textLines = [...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 _withoutTrailingEmpty(List lines) { + int end = lines.length; + while (end > 0 && lines[end - 1].trim().isEmpty) { + end--; + } + return lines.sublist(0, end); +} + +String _bodyHtml( + List lines, + String? signatureHtml, + ComposeQuote? quote, + bool quoteStartMark, +) { + final StringBuffer out = StringBuffer('
'); + 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('
'); + return out.toString(); +} + +/// 문단 하나 — 빈 줄은 `
` 로 한 줄 높이를 지킨다 +String paragraphHtml(String line) => + '

${_lineHtml(line)}

'; + +String _lineHtml(String line) => + line.isEmpty ? '
' : escapeHtmlText(preserveSpaces(line)); + +/// 서명 경계 div — 속성 없는 `
` 하나(계약 2절 "앱이 만드는 모양") +String signatureBlockHtml(String signatureHtml) => '
$signatureHtml
'; + +/// 원문 인용 HTML — "원본 메일" 머리 문단 + 머리 표 + 덩이 + (생략 표시) +String quoteHtml(ComposeQuote quote) { + final StringBuffer out = StringBuffer() + ..write('
') + ..write(quoteShellHeadHtml(quote.header)); + for (final QuoteBlock block in quote.blocks) { + out.write(quoteBlockHtml(block)); + } + if (quote.omitted) out.write(paragraphHtml(kQuoteOmittedMark)); + out.write('
'); + return out.toString(); +} + +/// 인용 머리 — "원본 메일" 문단과 머리 표 +String quoteShellHeadHtml(List header) { + final StringBuffer out = StringBuffer() + ..write( + '

', + ) + ..write(escapeHtmlText(kQuoteHeading)) + ..write('

') + ..write(''); + for (final QuoteHeaderRow row in header) { + out + ..write(''); + } + out.write('
') + ..write(escapeHtmlText(preserveSpaces(row.label))) + ..write('') + ..write(escapeHtmlText(preserveSpaces(row.value))) + ..write('
'); + 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 children): + out.write('
'); + children.forEach(write); + out.write('
'); + } + } + + write(block); + return out.toString(); +} + +/// 원문 인용의 텍스트 줄 — "> 원본 메일" · "> 항목: 값" · ">" · 원문 줄 · (생략 표시). 웹 `docToText` 의 인용과 같은 꼴 +List quoteTextLines(ComposeQuote quote) => [ + 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 quoteBlockTextLines(QuoteBlock block, int depth) { + final List lines = []; + void visit(QuoteBlock item, int level) { + switch (item) { + case QuoteLine(:final String text): + lines.add(quotePrefixed(text, level)); + case QuoteNest(:final List children): + for (final QuoteBlock child in children) { + visit(child, level + 1); + } + } + } + + visit(block, depth); + return lines; +} diff --git a/flutter/lib/features/mail/domain/compose_draft_body.dart b/flutter/lib/features/mail/domain/compose_draft_body.dart new file mode 100644 index 0000000..528b37f --- /dev/null +++ b/flutter/lib/features/mail/domain/compose_draft_body.dart @@ -0,0 +1,422 @@ +/// 임시 저장본 · 보내기 취소로 되돌린 메일을 다시 열 때의 본문 — 순수 함수 (계약 2절 "임시 저장"). +/// +/// - **앱이 만든 모양**(`compose_body.dart` 의 머리말): 감싸는 div(앱의 글꼴 style — 없어도 된다) 아래가 +/// "글 · `
` 만 든 문단들 → 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 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 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 nodes, dom.Element? wrapper}) _contentOf(dom.Element root) { + final List 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 _meaningful(List nodes) => [ + 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 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 는 속성이 없을 때, `
` 은 늘 +bool _isPlainBlock(dom.Element element) { + final Map 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 nodes) { + final List lines = []; + 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? 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; + +/// 글 · `
` 만 든 문단의 줄 — 다른 태그가 있으면 null. 끝의 `
` 은 줄을 더하지 않는다(`


` 는 빈 줄 하나) +List? _plainParagraphLines(dom.Element paragraph) { + final StringBuffer current = StringBuffer(); + final List lines = []; + 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 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 before = quoteIndex < 0 + ? nodes + : nodes.sublist(0, quoteIndex); + final List 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 nodes) { + final List stack = [...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 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 children = blockquote.children; + final dom.Element table = children[1]; + final List header = [ + 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 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.unmodifiable(header), + blocks: List.unmodifiable(blocks), + omitted: omitted, + summary: quoteSummaryOfHeader(header), + rawHtml: keepHtml ? outerHtmlOf(blockquote) : null, + ); +} + +List _cellsOf(dom.Element row) => [ + for (final dom.Element cell in row.children) + if (cell.localName == 'td' || cell.localName == 'th') + nodeTextOf(cell).replaceAll(' ', ' ').trim(), +]; + +/// 보낸 사람 머리 줄의 이름 — `이도윤 ` → "이도윤" (이름이 없으면 주소) +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 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 [?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|\$)'); diff --git a/flutter/lib/features/mail/domain/compose_fit.dart b/flutter/lib/features/mail/domain/compose_fit.dart new file mode 100644 index 0000000..0409338 --- /dev/null +++ b/flutter/lib/features/mail/domain/compose_fit.dart @@ -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 [], 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 kept = []; + 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.unmodifiable(kept), + omitted: true, + ); + MailBodyOutput output = buildMailBody(input.withQuote(fitted)); + while (kept.isNotEmpty && !limits.fits(output)) { + kept.removeLast(); + fitted = quote.withBlocks( + List.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 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 [], omitted: true); +} + +/// [initialQuoteOf] 를 알맞은 곳에서 — 길거나 깊은 원문은 다른 isolate 에서(읽기 화면의 정화와 같은 문턱 — `mailBodyBuildPlaceOf`) +Future 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; diff --git a/flutter/lib/features/mail/domain/compose_reply.dart b/flutter/lib/features/mail/domain/compose_reply.dart new file mode 100644 index 0000000..cd1a272 --- /dev/null +++ b/flutter/lib/features/mail/domain/compose_reply.dart @@ -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 to, List 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 originalTo = [ + for (final MailAddress address in message.summary.to) address.person, + ]; + final List originalCc = [ + for (final MailAddress address in message.cc) address.person, + ]; + + if (from != null && me.isNotEmpty && !notMe(from)) { + // 내가 보낸 메일 — 그 메일을 받은 사람에게(Reply-To 는 보지 않는다) + final List recipients = uniqueAddresses( + originalTo, + ).where(notMe).toList(); + final List to = recipients.isNotEmpty + ? recipients + : [from]; + if (!all) return (to: to, cc: const []); + return (to: to, cc: _ccOf(originalCc, to, notMe)); + } + + final List primary = message.replyTo.isNotEmpty + ? [for (final MailAddress a in message.replyTo) a.person] + : [?from]; + final List others = primary.where(notMe).toList(); + // 답장할 주소가 나뿐이면(Reply-To 가 나) 원래 받는 사람에게 + final List base = others.isNotEmpty + ? others + : originalTo.where(notMe).toList(); + if (!all) return (to: uniqueAddresses(base), cc: const []); + final List to = uniqueAddresses([ + ...base, + ...originalTo, + ]).where(notMe).toList(); + return (to: to, cc: _ccOf(originalCc, to, notMe)); +} + +List _ccOf( + List cc, + List 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 replyQuoteRows(MessageDetail message) => [ + ..._fromRows(message), + _dateRow(message), + _subjectRow(message), +]; + +/// 전달 머리 — 보낸 사람 · 날짜 · 받는 사람 · 참조 · 제목 +List forwardQuoteRows( + MessageDetail message, +) => [ + ..._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 _fromRows(MessageDetail message) { + final MailAddress? from = message.summary.from; + return from == null + ? const [] + : [ + 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 [?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; +} diff --git a/flutter/lib/features/mail/domain/compose_send.dart b/flutter/lib/features/mail/domain/compose_send.dart new file mode 100644 index 0000000..e8abb96 --- /dev/null +++ b/flutter/lib/features/mail/domain/compose_send.dart @@ -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 bytes = List.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 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 kMailAttachmentExtensions = { + '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 names) { + if (names.isEmpty) return 0; + return names.fold(600, (int sum, String name) => sum + name.length + 24); +} diff --git a/flutter/lib/features/mail/domain/compose_signature.dart b/flutter/lib/features/mail/domain/compose_signature.dart new file mode 100644 index 0000000..d78c961 --- /dev/null +++ b/flutter/lib/features/mail/domain/compose_signature.dart @@ -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 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: [ + for (final String line in composeLinesOf(text)) paragraphHtml(line), + ].join(), + text: text, + parts: [ + 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 parts = body == null + ? const [] + : _partsOf(body); + return ComposeSignature( + html: html, + text: text ?? plainLinesOf(quoteBlocksFromHtml(html)).join('\n'), + parts: List.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 _inlineTags = { + 'span', 'strong', 'b', 'em', 'i', 'u', 's', 'a', 'font', 'small', 'big', // +}; + +/// 미리 보기 조각 — 블록 · `
` 은 줄, 서명 그림은 그 자리에. 빈 줄은 하나로 줄이고 앞뒤 빈 줄은 뺀다 +List _partsOf(dom.Element root) { + final List parts = []; + 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 stack = [...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'] ?? '', + ); +} diff --git a/flutter/lib/features/mail/domain/html_nodes.dart b/flutter/lib/features/mail/domain/html_nodes.dart new file mode 100644 index 0000000..6f4b932 --- /dev/null +++ b/flutter/lib/features/mail/domain/html_nodes.dart @@ -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 stack = [...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 descendantElements(dom.Element root, String tag) { + final List found = []; + final List stack = [...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, [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 nodes) { + final List stack = [...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(''); + 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(''); + stack.addAll(item.nodes.reversed); + } +} diff --git a/flutter/lib/features/mail/domain/mail_quote.dart b/flutter/lib/features/mail/domain/mail_quote.dart new file mode 100644 index 0000000..ed25e01 --- /dev/null +++ b/flutter/lib/features/mail/domain/mail_quote.dart @@ -0,0 +1,514 @@ +/// 답장 · 전달의 원문 인용 — 받은 본문 → 글 덩이 (순수 함수 · 계약 2절 "서식 본문" · "답장·전달 초안의 길이"). +/// +/// 앱은 원문을 서식째 옮기지 않고 **글로 만든 덩이**로 인용한다(2026-11-06 정함) — 미리 보기에 보인 글과 보내는 HTML 이 같고, +/// 보내는 정화의 값 형식 규칙을 앱에 옮기지 않아도 된다. 잃는 것은 원문의 표 · 색 · 링크 모양이다(글은 남는다). +/// +/// - 어느 본문을 쓸지는 읽기 화면과 같다: 서식 본문(`displayedHtmlBody`)에서 그림을 빼고 보이는 글이 있으면 그것, 없으면 textBody. +/// 길이 · 중첩 비용이 읽기 화면의 한도(`kMaxSanitizedHtmlLength` · `kMaxHtmlNestingCost`)를 넘어도 textBody 다. +/// - 블록 · `
` 은 줄, 목록은 "- " · "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 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 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 blocks = quoteBlocksFromHtml(html); + if (hasVisibleQuoteText(blocks)) { + return (blocks: blocks, truncated: isBodyTruncated); + } + } + return ( + blocks: quoteBlocksFromText(textBody ?? ''), + truncated: isBodyTruncated, + ); +} + +/// 글 본문 → 줄마다 한 덩이(빈 줄 포함 — 끝의 빈 줄은 뺀다) +List quoteBlocksFromText(String text) { + final List lines = text + .replaceAll('\r\n', '\n') + .replaceAll('\r', '\n') + .split('\n'); + final List blocks = [ + for (final String line in lines) QuoteLine(line), + ]; + _trimEmptyEdges(blocks); + return blocks; +} + +/// 덩이에 보이는 글이 있는가(공백뿐인 줄은 글이 아니다) +bool hasVisibleQuoteText(List blocks) { + final List stack = [...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 children): + stack.addAll(children); + } + } + return false; +} + +/// 덩이 → 글 줄 — 안쪽 인용은 "> " 를 붙인다(빈 줄은 ">" — 웹 `docToText` 의 인용 줄과 같다). 서식이 있는 저장본의 본문 칸에 넣는다 +List plainLinesOf(List blocks) { + final List lines = []; + void visit(List 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 children): + visit(children, depth + 1); + } + } + } + + visit(blocks, 0); + return lines; +} + +/// 펼친 원본 메일의 한 줄 — 글과 원문 속 인용의 단(0 이 원문 바로 아래) +typedef QuoteDisplayLine = ({String text, int depth}); + +/// 덩이 → 화면에 그릴 줄 — 쓰기 화면이 펼친 원본 메일을 줄마다 그린다(긴 원문도 보이는 줄만 만든다). 단마다 왼쪽에 인용 막대 +List quoteDisplayLines(List blocks) { + final List lines = []; + void visit(List 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 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'; +} + +/// 서식 본문 → 덩이. 문서로 읽어 `` 아래만 본다(조각으로 읽으면 맨 위 요소가 많을 때 제곱으로 느리다 — `sanitizeMailHtml` 과 같다) +List quoteBlocksFromHtml(String html) { + final dom.Element? body = html_parser.parse(html).body; + if (body == null) return []; + return quoteBlocksFromNodes(body.nodes); +} + +/// DOM 조각 → 덩이 — 저장본 다시 열기도 쓴다(인용 앞 · 원문 인용 안) +List quoteBlocksFromNodes(Iterable nodes) { + final _QuoteTextBuilder builder = _QuoteTextBuilder(); + for (final dom.Node node in nodes) { + builder.walk(node, 0); + } + return builder.finish(); +} + +/// 앞뒤의 빈 줄을 뺀다(안쪽 인용 안도) +void _trimEmptyEdges(List 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 _dropped = { + '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 _blocks = { + '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 을 읽어 글 덩이를 만든다 — 블록 경계 · `
` 에서 줄을 끊고, 이어진 공백은 한 칸으로 줄인다(화면에 보이는 글과 같게) +class _QuoteTextBuilder { + final List _root = []; + late List _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 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 previous = _target; + final List inner = []; + _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 cells = [ + 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 texts = [ + 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 stack = [...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 stack = [...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 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] 면 빈 줄도(`
`) + 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(); + } +} diff --git a/flutter/lib/features/mail/mail_routes.dart b/flutter/lib/features/mail/mail_routes.dart index 69c42c8..95ee29b 100644 --- a/flutter/lib/features/mail/mail_routes.dart +++ b/flutter/lib/features/mail/mail_routes.dart @@ -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, + }, + ), ), ], ), diff --git a/flutter/lib/features/mail/presentation/mail_texts.dart b/flutter/lib/features/mail/presentation/mail_texts.dart index 97b4b57..e0486bd 100644 --- a/flutter/lib/features/mail/presentation/mail_texts.dart +++ b/flutter/lib/features/mail/presentation/mail_texts.dart @@ -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`). 보드에 없는 상태(붙잡힌 메일 · 본문 안내 · 받지 못함 · 첨부 열기 · 삭제 확인)의 글은 diff --git a/flutter/lib/features/mail/presentation/pages/message_compose_page.dart b/flutter/lib/features/mail/presentation/pages/message_compose_page.dart new file mode 100644 index 0000000..157030d --- /dev/null +++ b/flutter/lib/features/mail/presentation/pages/message_compose_page.dart @@ -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 createState() => _MessageComposePageState(); +} + +class _MessageComposePageState extends ConsumerState { + /// 화면마다 다른 수 — 같은 주소의 쓰기 화면을 다시 열어도 앞 화면의 쓰기(보내는 중인 것 포함)와 섞이지 않게 + 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 _recipientFocus = + { + 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 _lines = const []; + + @override + void initState() { + super.initState(); + ref.listenManual(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 _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 _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 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 _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: [ + SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _recipientField( + state, + RecipientFieldKind.to, + trailing: _CopiesToggle( + open: state.copiesOpen, + onPressed: enabled ? controller.toggleCopies : null, + ), + ), + if (state.copiesOpen) ...[ + _recipientField(state, RecipientFieldKind.cc), + _recipientField(state, RecipientFieldKind.bcc), + ], + if (recipientError != null) + ComposeAlert( + key: const ValueKey('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('compose-alert-subject'), + message: ApiException.fromCode('VAL_001').message, + ), + if (state.foreignNotice) + const ReaderNotice( + key: ValueKey('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('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(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()!; + 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()!; + 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('compose-subject'), + controller: controller, + focusNode: focusNode, + enabled: enabled, + textInputAction: TextInputAction.next, + inputFormatters: const [ + 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()!; + 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('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: [ + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: MailListError( + error: error, + onRetry: onRetry, + onLeave: onLeave, + leaveLabel: MailComposeTexts.close, + ), + ), + ), + ], + ); + } +} diff --git a/flutter/lib/features/mail/presentation/pages/message_reader_page.dart b/flutter/lib/features/mail/presentation/pages/message_reader_page.dart index 2715af6..42809e8 100644 --- a/flutter/lib/features/mail/presentation/pages/message_reader_page.dart +++ b/flutter/lib/features/mail/presentation/pages/message_reader_page.dart @@ -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 { 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 { ), }, // 삭제 · 이동 · 스팸 등을 하는 동안은 쓰기 화면을 열지 않는다 — 끝나면 목록으로 돌아간다 - bottomNavigationBar: detail == null || scheduled + // 알림 줄 — 아래 막대가 있으면 막대가 안전 영역을 이미 뺐다 + floatingActionButton: MailComposeArea( + key: const ValueKey('reader-toast-area'), + showCompose: false, + safeInsets: actionBar ? safeInsets.copyWith(bottom: 0) : safeInsets, + ), + floatingActionButtonLocation: const MailComposeAreaLocation(), + bottomNavigationBar: !actionBar ? null : ReaderActionBar( onReply: busy diff --git a/flutter/lib/features/mail/presentation/providers/compose_controller.dart b/flutter/lib/features/mail/presentation/providers/compose_controller.dart new file mode 100644 index 0000000..ef0d4f1 --- /dev/null +++ b/flutter/lib/features/mail/presentation/providers/compose_controller.dart @@ -0,0 +1,1586 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/api/api_exception.dart'; +import 'package:arasystem_app/core/api/dio_provider.dart'; +import 'package:arasystem_app/core/api/error_notifier.dart'; +import 'package:arasystem_app/core/people/address_input.dart'; +import 'package:arasystem_app/core/people/people_texts.dart'; +import 'package:arasystem_app/core/people/person_address.dart'; +import 'package:arasystem_app/core/people/recipient_match.dart'; +import 'package:arasystem_app/core/people/recipient_suggestion.dart'; +import 'package:arasystem_app/core/storage/token_store.dart'; +import 'package:arasystem_app/core/storage/token_store_provider.dart'; +import 'package:arasystem_app/core/utils/clock.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_address.dart'; +import 'package:arasystem_app/features/mail/data/models/mailbox.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:arasystem_app/features/mail/data/repositories/compose_repository.dart'; +import 'package:arasystem_app/features/mail/data/repositories/mail_repository.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_request.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/mail_routes.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/draft_autosave.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_state_watcher.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_toast_provider.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mailboxes_provider.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart'; +import 'package:go_router/go_router.dart'; + +/// 쓰기 화면의 저장소 — 인터셉터(언래핑 · Bearer · 갱신 · 알림)가 걸린 `dioProvider` 의 Dio +final Provider composeRepositoryProvider = + Provider( + (Ref ref) => ComposeRepository(ref.watch(dioProvider)), + ); + +/// 답장 · 전달의 처음 인용 만들기 — 원문 → 덩이 → 처음 맞추기, 큰 원문은 다른 isolate 에서([buildInitialQuote]). 시험은 덮어쓸 수 있다 +typedef ComposeQuoteBuilder = + Future Function(ComposeQuoteRequest request); + +final Provider composeQuoteBuilderProvider = + Provider((Ref ref) => buildInitialQuote); + +/// 저장본 본문 읽기 — 읽기 화면과 같은 한도 · 문턱으로 그 자리 · 다른 isolate · 읽지 않음([buildDraftBody]). 시험은 덮어쓸 수 있다 +typedef DraftBodyBuilder = + Future Function({ + required String? bodyHtml, + required String body, + }); + +final Provider composeDraftBodyBuilderProvider = + Provider((Ref ref) => buildDraftBody); + +/// 옛 저장본 id → 새 id — 저장할 때마다 id 가 바뀐다(계약 2절). 목록의 옛 줄을 눌러도 마지막 저장본이 열리게 세션 동안 메모리에 적는다. +/// 메일을 보는 직원이 바뀌면 비운다(웹은 주소를 `?draft=` 로 맞추지만 앱에는 주소 줄이 없다) +final Provider draftSuccessorsProvider = + Provider((Ref ref) { + ref.watch(mailAccountProvider); + return DraftSuccessors(); + }); + +/// 옛 저장본 id → 새 id (웹 `mail.store` 의 `recordDraftSuccessor` · `latestDraftId`) +class DraftSuccessors { + final Map _next = {}; + + void record(String previousId, String nextId) { + if (previousId != nextId) _next[previousId] = nextId; + } + + /// 마지막 저장본 — 이어진 기록을 끝까지 따라간다(돌아오는 고리는 끊는다) + String latest(String id) { + final Set seen = {id}; + String current = id; + while (true) { + final String? next = _next[current]; + if (next == null || !seen.add(next)) return current; + current = next; + } + } +} + +/// 보내기 결과 — 화면이 할 일 +enum ComposeSendOutcome { + /// 아무것도(보내지 않았거나 화면이 이미 없다) + none, + + /// 보냈다 — 시작한 화면으로 돌아간다 + sent, + + /// 받는 사람 칸으로 초점 + focusRecipients, + + /// 제목 칸으로 초점 + focusSubject, + + /// 본문 칸으로 초점 + focusBody, +} + +/// 화면이 칸에 직접 보이는 보내기 실패 — 알림을 끈다(화면이 이미 없으면 알림으로 대신 알린다) +const Set kComposeInlineCodes = { + 'MAIL_002', + 'MAIL_010', + 'MAIL_004', + 'MAIL_007', + 'MAIL_016', +}; + +/// 서버에 다시 묻기 전에 기다리는 시간 (웹 `RECIPIENT_SEARCH_DELAY_MS`) +const Duration kRecipientSearchDelay = Duration(milliseconds: 250); + +/// 서버가 받는 검색 글자 길이 (계약: q 1~100자) +const int _searchQueryMaxLength = 100; + +/// 쓰기 화면 — 화면(State)마다 하나([ComposeArgs.instance]). 화면이 있는 동안만 둔다(autoDispose) — 보내는 동안은 응답까지 산다(keepAlive). +final NotifierProviderFamily +composeProvider = NotifierProvider.autoDispose + .family( + ComposeController.new, + ); + +enum _LoadState { idle, loading, done, failed } + +/// 마지막으로 저장한(저장한 적 없으면 연 때의) 양식 — 이것과 다르면 자동 저장하고 떠나기 전에 저장한다(웹 `compose-changes.ts`) +@immutable +class _FormSnapshot { + const _FormSnapshot({ + required this.to, + required this.cc, + required this.bcc, + required this.subject, + required this.body, + required this.signature, + required this.attachments, + required this.largeFiles, + }); + + final List to; + final List cc; + final List bcc; + final String subject; + final String body; + final bool signature; + + /// 싣는 첨부의 화면 이름(key) + final List attachments; + final List largeFiles; + + _FormSnapshot withSignature(bool value) => _FormSnapshot( + to: to, + cc: cc, + bcc: bcc, + subject: subject, + body: body, + signature: value, + attachments: attachments, + largeFiles: largeFiles, + ); + + bool sameAs(_FormSnapshot other) => + subject == other.subject && + body == other.body && + signature == other.signature && + listEquals(to, other.to) && + listEquals(cc, other.cc) && + listEquals(bcc, other.bcc) && + listEquals(attachments, other.attachments) && + listEquals(largeFiles, other.largeFiles); +} + +/// 쓰기 화면 — 계약 2절 "발송 본문" · "임시 저장" · "서명" · "받는 사람 추천" · "보내기 취소" · 3절 "메일과 함께" · 4절 "발송 · 임시 저장과 함께" +/// (웹 `pages/mail/ComposePage.vue` 와 같은 규칙 — 앱 디자인 README 「쓰기」) +/// +/// - 여는 길: 새 메일(mailto 로 채운 것 포함) · 답장 · 전체 답장 · 전달(원본과 서명을 함께 받는다) · 이어 쓰기(저장본 — 옛 id 면 마지막 저장본). +/// 원본 · 저장본을 받지 못하면 [다시 시도] 화면이다. 처음 채운 내용은 "바꿈"이 아니다. +/// - 본문은 글만(서식 편집은 다음 판) — 보낼 때 서식 본문 + 텍스트를 함께 만든다(`compose_body.dart`). 원문 인용은 글로 만든 덩이이고 +/// 처음에는 한도의 95%, 보내기 · 저장 직전에 100% 로 맞춘다(`compose_fit.dart`). +/// - 임시 저장은 [DraftAutosave] — 쓰는 동안은 보이지 않고, 저장한 채 나가면 화면이 "임시보관함에 저장했습니다."를 알린다. +/// - 보내기: 칸에 쓰다 만 주소 확정 → 사전 검사 → 진행 중인 저장 기다리기 → 길이 다시 재기 → POST(같은 키 — 키를 바꾸는 것은 성공과 `MAIL_005` 뿐). +/// 보내는 동안 화면을 떠나도 응답까지 산다 — 결과는 알림으로만, 실패하면 같은 세션일 때만 쓰던 내용을 저장본으로 남긴다. +/// - 늦은 응답: 양식 세대([_formGeneration]) · 세션 세대(토큰 보관)가 바뀌었으면 버린다. 메일을 보는 직원이 바뀌면 하던 일을 모두 멈춘다. +class ComposeController extends Notifier { + ComposeController(this.args); + + final ComposeArgs args; + + /// 제목 · 본문 · 받는 사람 칸에 쓰는 글 — 화면의 글 칸이 이것을 쓴다(조합 중인 한글도 들어 있다) + final TextEditingController subject = TextEditingController(); + final TextEditingController body = TextEditingController(); + final Map inputs = + { + for (final RecipientFieldKind kind in RecipientFieldKind.values) + kind: TextEditingController(), + }; + + late final DraftAutosave _autosave; + late final DraftSuccessors _successors; + String? _account; + int _session = 0; + bool _ended = false; + int _formGeneration = 0; + _FormSnapshot? _baseline; + String _sendKey = createSendKey(); + String? _inReplyTo; + + /// 웹 · 다른 프로그램이 만든 저장본 — 본문을 고치지 않으면 원래 HTML 그대로 보낸다 + ForeignDraftBody? _foreign; + int _nextAttachmentKey = 0; + int? _undoSendSeconds; + bool _savedOnce = false; + + /// 이 화면이 저장하며 서버에서 지운 옛 저장본 — 떠날 때 목록에서 뺀다 + final Set _replacedIds = {}; + bool _sent = false; + + /// 화면이 붙어 있다 — 떠나면 거짓(보내기 결과는 알림으로만) + bool _attached = true; + + /// 마지막으로 본 제목 · 본문 글 — 글이 바뀐 때만 바꿈으로 본다 + String _subjectText = ''; + String _bodyText = ''; + + // ── 받는 사람 추천 ── + List _pool = const []; + bool _poolComplete = false; + _LoadState _poolState = _LoadState.idle; + final Set _asked = {}; + Timer? _searchTimer; + CancelToken? _searchCancel; + List _groups = const []; + _LoadState _groupsState = _LoadState.idle; + int _groupPicks = 0; + final Map> _pendingGroups = + >{}; + final Map _lastGroupResult = + {}; + + ComposeRepository get _repository => ref.read(composeRepositoryProvider); + + MailRepository get _mail => ref.read(mailRepositoryProvider); + + Clock get _clock => ref.read(clockProvider); + + TokenStore get _tokens => ref.read(tokenStoreProvider); + + @override + ComposeState build() { + _account = ref.read(mailAccountProvider); + _session = _tokens.sessionGeneration; + _successors = ref.read(draftSuccessorsProvider); + _autosave = DraftAutosave( + canSave: _canSave, + hasChanges: _hasDraftChanges, + prepare: _prepareSave, + create: (MailComposePayload payload) => _repository.createDraft(payload), + update: (String id, MailComposePayload payload) => + _repository.updateDraft(id, payload), + canFallback: _sameSession, + onSaved: _onSaved, + onFailed: _onSaveFailed, + ); + ref + ..listen(mailAccountProvider, (String? _, String? next) { + if (next != _account) _endSession(); + }) + // 보낸 뒤 알림([보내기 취소])을 띄우는 동안 알림 자리를 쥔다 — 목록 · 읽기 화면이 이어 쥔다 + ..listen(mailToastProvider, (MailToast? _, MailToast? _) {}) + ..onDispose(_dispose); + + final ComposeIntent intent = args.intent; + if (_account == null) { + return ComposeState( + status: ComposeStatus.failed, + error: ApiException.fromCode('AUTH_002'), + ); + } + _preloadSuggestions(); + _preloadGroups(); + unawaited(_loadUndoSeconds()); + + final ComposeState initial; + switch (intent) { + case NewComposeIntent(): + initial = _newMailState(); + unawaited(Future.microtask(_loadNewSignature)); + case ReplyComposeIntent(): + initial = ComposeState( + kind: intent.all ? ComposeKind.replyAll : ComposeKind.reply, + ); + unawaited(Future.microtask(() => _loadSource(_formGeneration))); + case ForwardComposeIntent(): + initial = const ComposeState(kind: ComposeKind.forward); + unawaited(Future.microtask(() => _loadSource(_formGeneration))); + case DraftComposeIntent(): + initial = const ComposeState(titleKnown: false); + unawaited(Future.microtask(() => _loadSource(_formGeneration))); + } + _subjectText = subject.text; + _bodyText = body.text; + subject.addListener(_onTextChanged); + body.addListener(_onTextChanged); + for (final TextEditingController input in inputs.values) { + input.addListener(_onInputChanged); + } + return initial; + } + + // ── 열기 ─────────────────────────────────────────── + + /// 새 메일 — mailto 로 채운 받는 사람 · 제목 · 본문은 "바꿈"이 아니다. 숨은참조가 있으면 펼쳐 보인다(링크가 몰래 넣을 수 있다) + ComposeState _newMailState() { + final ComposePrefill? prefill = args.prefill; + List tokens(List raw) => mergeRecipientTokens( + const [], + [for (final String value in raw) ?parseRecipient(value)], + ); + final List to = tokens(prefill?.to ?? const []); + final List cc = tokens(prefill?.cc ?? const []); + final List bcc = tokens(prefill?.bcc ?? const []); + subject.text = prefill?.subject ?? ''; + body.text = prefill?.body ?? ''; + final ComposeState initial = ComposeState( + status: ComposeStatus.ready, + to: to, + cc: cc, + bcc: bcc, + copiesOpen: cc.isNotEmpty || bcc.isNotEmpty, + ); + _baseline = _snapshotOf(initial); + return initial; + } + + /// 새 메일의 서명 — 따로 된 칸이라 받는 동안에도 쓸 수 있다. 아직 저장하지 않았으면 처음 연 모양으로 본다(바꿈이 아니다) + Future _loadNewSignature() async { + final int generation = _formGeneration; + final MailSignature? signature = await _fetchSignature(); + if (!_alive(generation)) return; + final ComposeSignature? prepared = composeSignatureOf( + signature, + forReply: false, + ); + if (prepared == null) return; + state = state.copyWith(signature: prepared, signatureIncluded: true); + final _FormSnapshot? baseline = _baseline; + if (baseline != null && !_savedOnce && !_autosave.saving) { + _baseline = baseline.withSignature(true); + } + _emitUnsaved(); + } + + /// [다시 시도] — 원본 · 저장본을 다시 받는다 + Future retry() async { + if (!ref.mounted || + _ended || + _account == null || + state.status != ComposeStatus.failed) { + return; + } + _formGeneration++; + state = ComposeState( + kind: state.kind, + titleKnown: args.intent is! DraftComposeIntent, + ); + await _loadSource(_formGeneration); + } + + Future _loadSource(int generation) async { + switch (args.intent) { + case ReplyComposeIntent(:final String messageId, :final bool all): + await _loadFromMessage( + messageId, + generation, + kind: all ? ComposeKind.replyAll : ComposeKind.reply, + ); + case ForwardComposeIntent(:final String messageId): + await _loadFromMessage( + messageId, + generation, + kind: ComposeKind.forward, + ); + case DraftComposeIntent(:final String draftId): + await _loadDraft(draftId, generation); + case NewComposeIntent(): + break; + } + } + + /// 답장 · 전달 — 원본과 서명을 함께 받아 채운다 + Future _loadFromMessage( + String messageId, + int generation, { + required ComposeKind kind, + }) async { + final Future signatureFuture = _fetchSignature(); + try { + final ({MessageDetail detail, DateTime? serverTime}) result = await _mail + .getMessage(messageId); + final MailSignature? signature = await signatureFuture; + if (!_alive(generation)) return; + final MessageDetail detail = result.detail; + final bool forward = kind == ComposeKind.forward; + final ComposeSignature? prepared = composeSignatureOf( + signature, + forReply: true, + ); + // 원문 → 덩이 → 처음 맞추기(내 글 몫 5% 를 남긴다)를 한 번에 — 큰 원문은 다른 isolate 에서 + final ComposeQuote quote = await ref.read(composeQuoteBuilderProvider)( + ComposeQuoteRequest( + htmlBody: detail.htmlBody, + textBody: detail.textBody, + isBodyTruncated: detail.isBodyTruncated, + header: forward ? forwardQuoteRows(detail) : replyQuoteRows(detail), + summary: quoteSummaryOf(detail, now: _clock()), + signatureHtml: prepared?.html, + signatureText: prepared?.text, + ), + ); + if (!_alive(generation)) return; + List to = const []; + List cc = const []; + List attachments = const []; + if (forward) { + subject.text = fitDraftSubject( + buildForwardSubject(detail.summary.subject), + ); + // 원본 첨부는 다시 올리지 않고 id 를 그대로 보낸다 — 본문 그림(isInline)도(인용에서 그림이 빠진다) + attachments = _carried(detail.attachments); + } else { + final ({List to, List cc}) recipients = + buildReplyRecipients( + detail, + _account ?? '', + all: kind == ComposeKind.replyAll, + ); + to = recipients.to.map(tokenFromAddress).toList(); + cc = recipients.cc.map(tokenFromAddress).toList(); + subject.text = fitDraftSubject( + buildReplySubject(detail.summary.subject), + ); + _inReplyTo = detail.id; + } + state = state.copyWith( + status: ComposeStatus.ready, + kind: kind, + titleKnown: true, + to: to, + cc: cc, + copiesOpen: cc.isNotEmpty, + attachments: attachments, + signature: prepared, + signatureIncluded: prepared != null, + quote: quote, + ); + _baseline = _snapshotOf(state); + _emitUnsaved(); + } on DioException catch (error) { + _loadFailed(generation, toApiException(error)); + } on Object { + // 원문을 읽다 생긴 그 밖의 실패(인용 만들기 등) — 뼈대에 머물지 않고 [다시 시도] + _loadFailed(generation, ApiException.fromCode(kFallbackCode)); + } + } + + void _loadFailed(int generation, ApiException error) { + if (!_alive(generation)) return; + state = state.copyWith(status: ComposeStatus.failed, error: error); + } + + /// 이어 쓰기 — 저장본을 받아 채운다(서명은 다시 넣지 않는다 — 저장본에 이미 있다). 이 세션에서 다시 저장해 바뀐 옛 id 면 마지막 저장본 + Future _loadDraft(String requestedId, int generation) async { + final String id = _successors.latest(requestedId); + try { + final Draft draft = await _repository.getDraft(id); + if (!_alive(generation)) return; + // 길거나 깊은 본문은 다른 isolate 에서, 한도를 넘으면 읽지 않고 남의 저장본으로(읽기 화면과 같은 한도) + final DraftBody parsed = await ref.read(composeDraftBodyBuilderProvider)( + bodyHtml: draft.bodyHtml, + body: draft.body, + ); + if (!_alive(generation)) return; + ComposeSignature? signature; + ComposeQuote? quote; + bool notice = false; + _foreign = null; + switch (parsed) { + case AppDraftBody(): + body.text = parsed.text; + signature = parsed.signature; + quote = parsed.quote; + case ForeignDraftBody(): + body.text = parsed.text; + _foreign = parsed; + quote = parsed.quote; + notice = parsed.hasFormatting; + } + subject.text = draft.subject; + _inReplyTo = draft.inReplyTo; + final List attachments = _carried(draft.attachments); + final List to = draft.to + .map((MailAddress a) => tokenFromAddress(a.person)) + .toList(); + final List cc = draft.cc + .map((MailAddress a) => tokenFromAddress(a.person)) + .toList(); + final List bcc = draft.bcc + .map((MailAddress a) => tokenFromAddress(a.person)) + .toList(); + state = state.copyWith( + status: ComposeStatus.ready, + kind: draftKindOf(inReplyTo: draft.inReplyTo, subject: draft.subject), + titleKnown: true, + to: to, + cc: cc, + bcc: bcc, + copiesOpen: cc.isNotEmpty || bcc.isNotEmpty, + attachments: attachments, + largeFiles: draft.largeFiles, + signature: signature, + signatureIncluded: signature != null, + quote: quote, + foreignNotice: notice, + ); + // 우리가 실을 수 없는 첨부가 든 남의 저장본은 원본을 지우지 않는다 — 첫 저장을 POST 로 + _autosave.reset( + existingId: draft.id, + keepOriginal: attachments.any((ComposeAttachment a) => !a.sendable), + ); + _baseline = _snapshotOf(state); + _emitUnsaved(); + } on DioException catch (error) { + _loadFailed(generation, toApiException(error)); + } on Object { + // 저장본을 읽다 생긴 그 밖의 실패(본문 읽기 · isolate 등) — 뼈대에 머물지 않고 [다시 시도] + _loadFailed(generation, ApiException.fromCode(kFallbackCode)); + } + } + + /// 이미 붙은 첨부 — 개수 · 형식 · 합계 사전 검사(서버와 같은 차례). 걸린 것은 싣지 않는다("함께 보낼 수 없음") + List _carried(List attachments) { + int count = 0; + int bytes = 0; + final List items = []; + for (final MessageAttachment attachment in attachments) { + final String? rejected = precheckCarriedAttachment( + name: attachment.name, + size: attachment.size, + attachedCount: count, + attachedBytes: bytes, + ); + if (rejected == null) { + count++; + bytes += attachment.size; + } + items.add( + ComposeAttachment( + key: _nextAttachmentKey++, + id: attachment.id, + name: attachment.name, + size: attachment.size, + problem: rejected == null + ? null + : ComposeAttachmentProblem.unsendable, + ), + ); + } + return List.unmodifiable(items); + } + + Future _fetchSignature() async { + try { + return await _repository.getSignature(); + } on DioException { + // 서명 없이 쓴다 — 알리지 않는다 + return null; + } + } + + Future _loadUndoSeconds() async { + try { + final int seconds = await _repository.undoSendSeconds(); + if (ref.mounted) _undoSendSeconds = seconds; + } on DioException { + // 모르면 응답의 서버 시각 · 30초로 센다 + } + } + + // ── 받는 사람 ─────────────────────────────────────── + + /// 칸의 글이 바뀌었다 — 구분자(쉼표 · 세미콜론 · 줄바꿈, 따옴표 · 꺾쇠 밖)까지는 칩으로, 남은 글은 그대로. 모자라면 서버에 묻는다 + void onRecipientChanged(RecipientFieldKind kind, String text) { + if (!ref.mounted || state.status != ComposeStatus.ready) return; + final ({List complete, String rest}) split = splitPendingRecipients( + text, + ); + if (split.complete.isNotEmpty) { + _addTokens(kind, [ + for (final String raw in split.complete) ?parseRecipient(raw), + ]); + _setInput(kind, split.rest); + } + final String query = inputs[kind]!.text; + if (query.trim().isNotEmpty && state.groupStatus.containsKey(kind)) { + _lastGroupResult.remove(kind); + _showGroupStatus(kind); + } + search(query); + } + + /// 칸에 쓰다 만 글을 칩으로 — 칸을 벗어날 때 · [다음] · 보내기 · 나가기 전 + void commitRecipientInput(RecipientFieldKind kind) { + if (!ref.mounted || state.status != ComposeStatus.ready) return; + final String text = inputs[kind]!.text; + if (text.trim().isEmpty) return; + _setInput(kind, ''); + _addTokens(kind, [ + for (final String raw in splitRecipientInput(text)) ?parseRecipient(raw), + ]); + } + + void commitAllRecipientInputs() { + for (final RecipientFieldKind kind in RecipientFieldKind.values) { + commitRecipientInput(kind); + } + } + + void removeRecipient(RecipientFieldKind kind, RecipientToken token) { + if (!ref.mounted) return; + final String key = recipientTokenKey(token); + _setTokens(kind, [ + for (final RecipientToken item in state.tokensOf(kind)) + if (recipientTokenKey(item) != key) item, + ]); + } + + void toggleCopies() { + if (!ref.mounted) return; + state = state.copyWith(copiesOpen: !state.copiesOpen); + } + + /// 추천을 넣는다 — 주소는 "이름 <주소>" 칩으로, 그룹은 구성원 모두 + void pickOption(RecipientFieldKind kind, RecipientOption option) { + if (!ref.mounted) return; + switch (option) { + case AddressOption(:final RecipientMatch match): + _setInput(kind, ''); + _addTokens(kind, [ + tokenFromAddress(match.suggestion.address), + ]); + case GroupOption(:final RecipientGroupMatch match): + unawaited(pickGroup(kind, match.group)); + } + } + + /// 그룹을 넣는다 — 구성원을 받아 대표 주소를 칩으로. 이메일이 없는 구성원 · 이미 넣은 주소(세 칸 모두)는 빼고 칸 아래에 알린다. + /// 그룹마다 따로 기다려 끝나는 대로 넣는다. 받지 못하면(인터셉터가 알렸다) 그 그룹의 글을 지운다 + Future pickGroup( + RecipientFieldKind kind, + ContactGroupSummary group, + ) async { + if (!ref.mounted || _ended) return; + _setInput(kind, ''); + final int generation = _formGeneration; + final int pick = ++_groupPicks; + (_pendingGroups[kind] ??= {})[pick] = group.name; + _showGroupStatus(kind); + final ComposeRepository repository = _repository; + List? members; + try { + members = await repository.listGroupMembers(group.id); + } on DioException { + members = null; + } + if (!_alive(generation) || !_sameSession()) return; + _pendingGroups[kind]?.remove(pick); + if (members == null) { + _lastGroupResult.remove(kind); + _showGroupStatus(kind); + return; + } + final GroupRecipients recipients = groupRecipientsOf(members); + final Set added = _addedEmails(); + final List fresh = [ + for (final PersonAddress address in recipients.addresses) + if (!added.contains(normalizeEmail(address.email))) address, + ]; + if (fresh.isNotEmpty) { + _addTokens(kind, fresh.map(tokenFromAddress).toList()); + } + _lastGroupResult[kind] = PeopleTexts.groupInserted( + group.name, + fieldLabelOf(kind), + added: fresh.length, + withoutEmail: recipients.withoutEmail, + already: recipients.addresses.length - fresh.length, + ); + _showGroupStatus(kind); + } + + /// 칸 아래 글 — 받는 중인 그룹이 있으면 마지막으로 고른 그룹의 "불러오는 중", 없으면 마지막에 끝난 그룹의 결과 + void _showGroupStatus(RecipientFieldKind kind) { + final Map? pending = _pendingGroups[kind]; + final String? loading = pending == null || pending.isEmpty + ? null + : pending.values.last; + final String? text = loading != null + ? PeopleTexts.groupLoading(loading) + : _lastGroupResult[kind]; + final Map next = { + ...state.groupStatus, + }; + if (text == null || text.isEmpty) { + next.remove(kind); + } else { + next[kind] = text; + } + state = state.copyWith(groupStatus: next); + } + + void _addTokens(RecipientFieldKind kind, List tokens) { + if (tokens.isEmpty) return; + _setTokens(kind, mergeRecipientTokens(state.tokensOf(kind), tokens)); + } + + void _setTokens(RecipientFieldKind kind, List tokens) { + final List value = List.unmodifiable( + tokens, + ); + state = switch (kind) { + RecipientFieldKind.to => state.copyWith(to: value), + RecipientFieldKind.cc => state.copyWith(cc: value), + RecipientFieldKind.bcc => state.copyWith(bcc: value), + }.copyWith(clearRecipientRejection: true); + _autosave.touch(); + _emitUnsaved(); + } + + /// 칸의 글을 바꾼다 — 조합 중인 한글을 먼저 끝낸다(칩을 만들며 칸을 비울 때) + void _setInput(RecipientFieldKind kind, String text) { + final TextEditingController input = inputs[kind]!; + input.value = TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), + ); + } + + Set _addedEmails() => { + for (final RecipientToken token in state.allRecipients) + if (token.valid) normalizeEmail(token.address.email), + }; + + /// 친 글자에 맞는 추천 줄 — 주소록 → 그룹 → 사내 → 최근 주고받은 주소, 모두 합쳐 8개 + List optionsFor(String query) => arrangeRecipientOptions( + filterRecipientSuggestions(query, _pool, _addedEmails()), + matchContactGroups(query, _groups), + ); + + /// 쓰기 화면을 열 때 · 받는 사람 칸에 초점이 올 때 — 받는 중이거나 받았으면 아무것도 하지 않는다(실패했으면 다시) + void preloadSuggestions() { + _preloadSuggestions(); + _preloadGroups(); + } + + void _preloadSuggestions() { + if (_poolState == _LoadState.loading || _poolState == _LoadState.done) { + return; + } + _poolState = _LoadState.loading; + final ComposeRepository repository = _repository; + unawaited(() async { + try { + final RecipientSuggestionPage page = await repository + .listRecipientSuggestions(); + if (!ref.mounted || _ended) return; + _poolState = _LoadState.done; + _poolComplete = !page.hasNext; + _merge(page.items); + } on DioException { + if (ref.mounted) _poolState = _LoadState.failed; + } + }()); + } + + void _preloadGroups() { + if (_groupsState == _LoadState.loading || _groupsState == _LoadState.done) { + return; + } + _groupsState = _LoadState.loading; + final ComposeRepository repository = _repository; + unawaited(() async { + try { + final List groups = await repository + .listContactGroups(); + if (!ref.mounted || _ended) return; + _groupsState = _LoadState.done; + _groups = groups; + state = state.copyWith( + suggestionsRevision: state.suggestionsRevision + 1, + ); + } on DioException { + if (ref.mounted) _groupsState = _LoadState.failed; + } + }()); + } + + void _merge(List items) { + final Set known = { + for (final RecipientSuggestion item in _pool) normalizeEmail(item.email), + }; + final List added = [ + for (final RecipientSuggestion item in items) + if (known.add(normalizeEmail(item.email))) item, + ]; + if (added.isEmpty) return; + _pool = List.unmodifiable([ + ..._pool, + ...added, + ]); + state = state.copyWith(suggestionsRevision: state.suggestionsRevision + 1); + } + + /// 맞는 것이 모자라면 250ms 뒤 서버에 묻는다(그사이 또 치면 마지막 글자만 — 늦게 온 응답은 버린다) + void search(String query) { + _cancelSearch(); + final String text = query.trim(); + if (text.isEmpty || text.length > _searchQueryMaxLength || _poolComplete) { + return; + } + if (_asked.contains(text)) return; + if (filterRecipientSuggestions(text, _pool, _addedEmails()).length >= + kRecipientSuggestionLimit) { + return; + } + _searchTimer = Timer(kRecipientSearchDelay, () => unawaited(_ask(text))); + } + + Future _ask(String query) async { + _searchTimer = null; + _asked.add(query); + final CancelToken cancel = _searchCancel = CancelToken(); + try { + final RecipientSuggestionPage page = await _repository + .listRecipientSuggestions( + query: query, + limit: kRecipientSuggestionLimit * 2, + cancelToken: cancel, + ); + if (!ref.mounted || _ended || cancel.isCancelled) return; + _merge(page.items); + } on DioException { + // 보조 정보 — 못 받으면 받아 둔 목록만 보인다 + _asked.remove(query); + } + } + + void _cancelSearch() { + _searchTimer?.cancel(); + _searchTimer = null; + _searchCancel?.cancel(); + _searchCancel = null; + } + + // ── 서명 · 첨부 ───────────────────────────────────── + + void removeSignature() => _setSignature(false); + + void insertSignature() => _setSignature(true); + + void _setSignature(bool included) { + if (!ref.mounted || state.signature == null) return; + state = state.copyWith(signatureIncluded: included); + _autosave.touch(); + _emitUnsaved(); + } + + void removeAttachment(int key) { + if (!ref.mounted) return; + state = state.copyWith( + attachments: [ + for (final ComposeAttachment item in state.attachments) + if (item.key != key) item, + ], + ); + _autosave.touch(); + _emitUnsaved(); + } + + void removeLargeFile(String id) { + if (!ref.mounted) return; + state = state.copyWith( + largeFiles: [ + for (final DraftLargeFile file in state.largeFiles) + if (file.id != id) file, + ], + ); + _autosave.touch(); + _emitUnsaved(); + } + + /// [보내기]를 막는 까닭 — 빼야 하는 파일이 먼저(위험 · 지움 · 찾지 못한 첨부), 그다음 웹에서 올리다 멈춘 파일. 막지 않으면 null + String? get sendBlockReason { + final List large = state.largeFiles; + if (large.any((DraftLargeFile f) => f.infected)) { + return MailComposeTexts.largeInfected; + } + if (large.any( + (DraftLargeFile f) => f.status == DraftLargeFileStatus.missing, + )) { + return MailComposeTexts.largeMissing; + } + if (_hasMissingAttachments) { + return ApiException.fromCode('MAIL_004').message; + } + if (large.any( + (DraftLargeFile f) => f.status == DraftLargeFileStatus.uploading, + )) { + return MailComposeTexts.largeUploading; + } + return null; + } + + bool get _hasMissingAttachments => state.attachments.any( + (ComposeAttachment a) => a.problem == ComposeAttachmentProblem.missing, + ); + + bool get _hasMissingLargeFiles => state.largeFiles.any( + (DraftLargeFile f) => f.status == DraftLargeFileStatus.missing, + ); + + /// 서버가 첨부를 찾지 못했다(`MAIL_004`) — 어느 것인지 모르니 싣던 첨부를 모두 "찾지 못함"으로(웹과 같다) + void _markAttachmentsMissing() { + state = state.copyWith( + attachments: [ + for (final ComposeAttachment item in state.attachments) + item.sendable + ? item.copyWith(problem: ComposeAttachmentProblem.missing) + : item, + ], + ); + _emitUnsaved(); + } + + /// 대용량 파일을 쓸 수 없다(`MAIL_016`) — 파일마다 다시 묻는다(없으면 지웠거나 기간이 지난 것) + Future _recheckLargeFiles() async { + final int generation = _formGeneration; + final ComposeRepository repository = _repository; + final List next = []; + for (final DraftLargeFile file in state.largeFiles) { + try { + final DraftLargeFile fresh = await repository.getLargeFile(file.id); + next.add(file.withStatus(fresh.status, infected: fresh.infected)); + } on DioException catch (error) { + next.add( + apiErrorCodeOf(error) == 'RES_001' + ? file.withStatus(DraftLargeFileStatus.missing) + : file, + ); + } + } + if (!_alive(generation)) return; + // 묻는 동안 사용자가 뺀 파일은 되살리지 않는다 — 지금 목록에 남은 것만 새 상태로 + final Map rechecked = { + for (final DraftLargeFile file in next) file.id: file, + }; + state = state.copyWith( + largeFiles: List.unmodifiable([ + for (final DraftLargeFile file in state.largeFiles) + rechecked[file.id] ?? file, + ]), + ); + _emitUnsaved(); + } + + // ── 바꿈 · 저장 ───────────────────────────────────── + + _FormSnapshot _snapshotOf(ComposeState of) => _FormSnapshot( + to: [for (final RecipientToken t in of.to) t.label], + cc: [for (final RecipientToken t in of.cc) t.label], + bcc: [for (final RecipientToken t in of.bcc) t.label], + subject: subject.text, + body: body.text, + signature: of.signature != null && of.signatureIncluded, + attachments: [ + for (final ComposeAttachment a in of.attachments) + if (a.sendable) a.key, + ], + largeFiles: [for (final DraftLargeFile f in of.largeFiles) f.id], + ); + + bool _hasDraftChanges() { + final _FormSnapshot? baseline = _baseline; + if (baseline == null || !ref.mounted) return false; + return !_snapshotOf(state).sameAs(baseline); + } + + /// 칸에 아직 칩이 되지 않은 글이 있다 — 떠나면 사라진다 + bool get _typingRecipient => inputs.values.any( + (TextEditingController input) => input.text.trim().isNotEmpty, + ); + + /// 떠나면 잃는 것이 있다 — 저장하지 않은 변경 · 칸에 쓰다 만 주소 · 찾지 못한 첨부(그동안은 저장하지 않는다) + bool get hasUnsavedChanges { + if (!ref.mounted || + _ended || + _sent || + state.status != ComposeStatus.ready) { + return false; + } + return _hasDraftChanges() || + _typingRecipient || + _hasMissingAttachments || + _hasMissingLargeFiles; + } + + void _emitUnsaved() { + if (!ref.mounted) return; + final bool unsaved = hasUnsavedChanges; + if (state.unsaved != unsaved) state = state.copyWith(unsaved: unsaved); + } + + void _onTextChanged() { + if (!ref.mounted) return; + // 초점 · 선택만 바뀐 것은 바꿈이 아니다(칸에 초점이 오면 커서 자리가 정해진다 — 길이 안내를 지우지 않게) + if (subject.text == _subjectText && body.text == _bodyText) return; + _subjectText = subject.text; + _bodyText = body.text; + final ComposeLengthError? error = state.lengthError; + if (error != null) state = state.copyWith(clearLengthError: true); + _autosave.touch(); + _emitUnsaved(); + } + + void _onInputChanged() { + if (!ref.mounted) return; + _emitUnsaved(); + } + + bool _sameSession() => + ref.mounted && !_ended && _tokens.sessionGeneration == _session; + + bool _alive(int generation) => + ref.mounted && !_ended && generation == _formGeneration; + + bool _canSave() => + _sameSession() && + state.status == ComposeStatus.ready && + !state.sending && + !_sent && + !_hasMissingAttachments && + !_hasMissingLargeFiles; + + /// 보낼 본문 — 남의 저장본을 고치지 않았으면 원래 그대로(대용량 파일이 있으면 인용 경계 표시만 넣는다), 아니면 칸으로 만든다 + BodyFit _bodyPlan(BodyLimits limits) { + final ForeignDraftBody? foreign = _foreign; + final bool large = state.hasLargeFiles; + if (foreign != null && body.text == foreign.text) { + final String? original = foreign.originalHtml; + return ( + output: MailBodyOutput( + // 인용 경계 표시를 넣은 HTML 은 읽을 때 한 번 만들어 두었다(저장 · 보내기마다 다시 읽지 않게) + html: large ? (foreign.markedHtml ?? original) : original, + text: foreign.originalText, + quoteStart: large ? quoteStartInText(foreign.originalText) : null, + ), + quote: state.quote, + ); + } + final ComposeSignature? signature = state.signatureIncluded + ? state.signature + : null; + return fitMailBody( + ComposeBodyInput( + text: body.text, + signatureHtml: signature?.html, + signatureText: signature?.text, + quote: state.quote, + quoteStartMark: large, + ), + limits, + ); + } + + List get _sendableAttachments => [ + for (final ComposeAttachment item in state.attachments) + if (item.sendable) item, + ]; + + MailComposePayload _payloadOf( + MailBodyOutput output, + List attachments, { + required bool forSend, + }) { + // 저장본에는 형식이 틀린 주소를 싣지 않는다 — 서버가 저장본 전체를 거절한다(칸에는 남는다) + List addresses(List tokens) => + [ + for (final RecipientToken token in tokens) + if (forSend || token.valid) token.address, + ]; + return MailComposePayload( + to: addresses(state.to), + cc: addresses(state.cc), + bcc: addresses(state.bcc), + subject: forSend ? subject.text.trim() : subject.text, + body: output.text, + bodyHtml: output.html, + attachments: [ + for (final ComposeAttachment item in attachments) + (id: item.id, name: item.name), + ], + inReplyTo: _inReplyTo, + draftId: forSend ? _autosave.draftId : null, + largeFileIds: [ + for (final DraftLargeFile f in state.largeFiles) f.id, + ], + bodyQuoteStart: state.hasLargeFiles ? output.quoteStart : null, + ); + } + + /// 저장 한 번 — 이 순간의 양식 · 본문을 싣고, 끝나면 그것을 "저장한 것"으로 삼는다. + /// 본문이 보내기와 같은 한도를 넘으면(인용을 줄여도) 보내지 않는다 — 서버도 `MAIL_007` 로 거절한다 + DraftSavePlan? _prepareSave() { + if (!ref.mounted || state.status != ComposeStatus.ready) return null; + final BodyFit plan = _bodyPlan(BodyLimits.full); + final MailBodyOutput? output = plan.output; + if (output == null) return const DraftSaveRejected('MAIL_007'); + if (plan.quote != state.quote) state = state.copyWith(quote: plan.quote); + final _FormSnapshot snapshot = _snapshotOf(state); + final List carried = _sendableAttachments; + final int generation = _formGeneration; + return DraftSaveRequest( + payload: _payloadOf(output, carried, forSend: false), + commit: (DraftSaved saved) { + if (!_alive(generation)) return; + _baseline = snapshot; + _savedOnce = true; + _applySavedAttachments(carried, saved.attachments); + // 저장한 차례 — 나가기 확인 창이 "그사이 저장이 끝났다"를 이것으로 안다 + state = state.copyWith(saveCount: state.saveCount + 1); + _emitUnsaved(); + }, + ); + } + + /// 저장할 때마다 첨부 id 가 바뀐다 — 실은 차례대로 새 id 로(다음 저장 · 보내기는 새 id 로) + void _applySavedAttachments( + List carried, + List saved, + ) { + if (saved.length != carried.length) return; + final Map ids = { + for (int index = 0; index < carried.length; index++) + carried[index].key: saved[index].id, + }; + state = state.copyWith( + attachments: [ + for (final ComposeAttachment item in state.attachments) + ids.containsKey(item.key) ? item.copyWith(id: ids[item.key]) : item, + ], + ); + } + + void _onSaved(String? previousId, DraftSaved saved, bool replaced) { + if (previousId == null) return; + _successors.record(previousId, saved.id); + // PUT 이 옛것을 지웠다 — 떠날 때 목록에서 뺀다(새로 남긴 것이면 옛것은 그대로 남는다) + if (!replaced) _replacedIds.add(previousId); + } + + void _onSaveFailed(String code, DraftSaveTrigger trigger) { + if (!ref.mounted || _ended) return; + switch (code) { + case 'MAIL_004': + _markAttachmentsMissing(); + case 'MAIL_016': + unawaited(_recheckLargeFiles()); + case 'MAIL_007': + state = state.copyWith(lengthError: ComposeLengthError.body); + } + } + + /// 떠나기 전 — 남은 변경을 저장한다(5초까지만 기다린다 — 넘으면 저장은 뒤에서 이어진다). 저장했거나 잃을 것이 없으면 참 + Future saveBeforeLeave() async { + if (!ref.mounted || _ended) return true; + commitAllRecipientInputs(); + final Completer result = Completer(); + final Timer timer = Timer(kDraftLeaveSaveWait, () { + if (!result.isCompleted) result.complete(false); + }); + unawaited( + _autosave.saveBeforeLeave().then((bool saved) { + if (!result.isCompleted) result.complete(saved); + }), + ); + final bool saved = await result.future; + timer.cancel(); + return saved && !hasUnsavedChanges; + } + + /// 앱이 안 보이게 됐다 — 바꾼 것이 있으면 바로 저장한다 + void saveOnHide() { + if (!ref.mounted || _ended) return; + _autosave.saveOnHide(); + } + + /// 나가기 확인을 여는 동안 자동 저장을 멈춘다(그 전에 나간 저장은 이어진다) + void suspendAutosave() => _autosave.suspend(); + + void resumeAutosave() { + if (ref.mounted && !_ended && !state.sending) _autosave.resume(); + } + + /// 떠날 때 알릴까 — 이 화면이 저장했고 보내지 않았다 + bool get savedForLeave => _savedOnce && !_sent && !state.sending && !_ended; + + /// 화면이 사라진다 — 더 저장하지 않는다(진행 중인 저장 · 보내기는 끝까지 받는다). 저장으로 지운 옛 저장본은 목록에서 뺀다. + /// 위젯을 정리하는 중에 불리므로 상태는 바꾸지 않는다 + void detach() { + if (!_attached) return; + _attached = false; + _cancelSearch(); + if (!_sent && !_sendingNow) _autosave.stop(); + final Set removed = {..._replacedIds}; + if ((removed.isNotEmpty || _savedOnce) && !_ended && ref.mounted) { + final MailStateWatcher watcher = ref.read( + mailStateWatcherProvider.notifier, + ); + scheduleMicrotask(() => watcher.markChanged(removedIds: removed)); + } + } + + bool _sendingNow = false; + + // ── 보내기 ───────────────────────────────────────── + + /// 보낸다 — 위 머리말의 차례. [router] 는 보낸 뒤 알림의 단추(보내기 취소 · 보낸편지함 보기)가 쓴다. + /// + /// 보내기를 누른 뒤에는 화면을 떠나도 끝까지 간다(웹과 같다) — 진행 중인 저장을 기다리는 동안부터 응답까지 프로바이더를 붙잡는다. + /// 떠난 뒤 보내기 전에 멈추면(기다린 저장이 첨부를 찾지 못함 등) 그 코드의 사전 문구를 전역 알림으로 띄우고, 저장할 수 있으면 쓰던 내용을 저장본으로 남긴다 + Future send({required GoRouter router}) async { + if (!ref.mounted || + _ended || + state.status != ComposeStatus.ready || + state.sending || + _sent) { + return ComposeSendOutcome.none; + } + commitAllRecipientInputs(); + state = state.copyWith( + attempted: true, + clearRecipientRejection: true, + clearLengthError: true, + ); + if (state.to.isEmpty || + state.hasInvalidRecipient || + state.tooManyRecipients) { + return ComposeSendOutcome.focusRecipients; + } + final String? blocked = sendBlockReason; + if (blocked != null) { + ref.read(mailNoticeSinkProvider)(blocked); + return ComposeSendOutcome.none; + } + if (subject.text.trim().length > kComposeMaxSubject) { + state = state.copyWith(lengthError: ComposeLengthError.subject); + return ComposeSendOutcome.focusSubject; + } + // 본문 길이는 기다리기 전에 잰다(웹과 같다) — 보내는 동안은 칸을 막으므로 기다린 뒤에도 같다. + // 서버가 대용량 첨부 덩이를 넣은 뒤 다시 재므로 그만큼 텍스트 한도를 남긴다 + final BodyFit plan = _bodyPlan( + BodyLimits.full.reserveText( + largeFileBlockTextReserve([ + for (final DraftLargeFile f in state.largeFiles) f.name ?? '', + ]), + ), + ); + final MailBodyOutput? output = plan.output; + if (output == null) { + state = state.copyWith(lengthError: ComposeLengthError.body); + return ComposeSendOutcome.focusBody; + } + if (plan.quote != state.quote) state = state.copyWith(quote: plan.quote); + + final int generation = _formGeneration; + final int session = _session; + final TokenStore tokens = _tokens; + // 이제부터 화면을 떠나도 응답까지 산다(진행 중인 저장을 기다리는 동안 포함) — 쓰는 것은 미리 쥔다 + final KeepAliveLink keepAlive = ref.keepAlive(); + final ComposeRepository repository = _repository; + final MailRepository mail = _mail; + final MailToastNotifier toast = ref.read(mailToastProvider.notifier); + final void Function(String text) notice = ref.read(mailNoticeSinkProvider); + final ErrorNotifier errors = ref.read(errorNotifierProvider); + final MailStateWatcher watcher = ref.read( + mailStateWatcherProvider.notifier, + ); + final DraftSuccessors successors = _successors; + bool stillValid() => tokens.sessionGeneration == session; + _sendingNow = true; + state = state.copyWith(sending: true); + _emitUnsaved(); + try { + // 진행 중인 저장을 기다린다 — 보내는 본문의 draftId · 첨부 id 가 최신이어야 한다 + _autosave.suspend(); + await _autosave.settle(); + // 기다리는 사이 세션이 바뀌었다 — 보내지 않는다(다른 직원의 토큰으로 나가지 않게) + if (!ref.mounted || + _ended || + generation != _formGeneration || + !stillValid()) { + _endSending(); + return ComposeSendOutcome.none; + } + // 기다린 저장이 첨부를 찾지 못했다 — 지금 첨부로 보내면 모르는 사이 첨부 없이 나간다 + final String? blockedAfter = sendBlockReason; + if (blockedAfter != null) { + _endSending(); + if (_attached) { + notice(blockedAfter); + } else { + // 화면이 이미 없다 — 보내지 않았다는 것을 그 코드의 사전 문구로 알리고, 저장할 수 있으면 쓰던 내용을 남긴다 + errors.show( + ApiException.fromCode( + _hasMissingAttachments ? 'MAIL_004' : 'MAIL_016', + ), + ); + if (!_hasMissingAttachments && !_hasMissingLargeFiles) { + unawaited( + _keepUnsent( + _payloadOf(output, _sendableAttachments, forSend: false), + draftId: _autosave.draftId, + repository: repository, + watcher: watcher, + successors: successors, + stillValid: stillValid, + ), + ); + } + } + return ComposeSendOutcome.none; + } + final MailComposePayload payload = _payloadOf( + output, + _sendableAttachments, + forSend: true, + ); + final Mailbox? sentBox = ref + .read(mailboxesProvider) + .byRole(MailboxRole.sent); + final String key = _sendKey; + try { + final ({SentMessage sent, DateTime? serverTime}) result = + await repository.sendMessage( + payload, + idempotencyKey: key, + silentCodes: kComposeInlineCodes, + ); + if (!stillValid()) return ComposeSendOutcome.none; + _announceSent( + result.sent, + serverTime: result.serverTime, + router: router, + toast: toast, + notice: notice, + mail: mail, + watcher: watcher, + tokens: tokens, + session: session, + sentBox: sentBox, + ); + watcher.markChanged( + removedIds: {?payload.draftId, ..._replacedIds}, + ); + if (!ref.mounted) return ComposeSendOutcome.none; + _sent = true; + _sendingNow = false; + _sendKey = createSendKey(); + _autosave.stop(); + state = state.copyWith(sending: false, unsaved: false); + return _attached && generation == _formGeneration + ? ComposeSendOutcome.sent + : ComposeSendOutcome.none; + } on DioException catch (error) { + final ApiException failure = toApiException(error); + if (!stillValid()) return ComposeSendOutcome.none; + if (!ref.mounted || !_attached || generation != _formGeneration) { + // 화면이 이미 없다 — 칸 안 안내 대신 알림으로, 쓰던 내용은 조용히 저장본으로(같은 세션일 때만) + if (kComposeInlineCodes.contains(failure.code)) errors.show(failure); + unawaited( + _keepUnsent( + payload.withoutDraftId(), + draftId: payload.draftId, + repository: repository, + watcher: watcher, + successors: successors, + stillValid: stillValid, + ), + ); + _endSending(); + return ComposeSendOutcome.none; + } + if (shouldRenewSendKey(failure.code)) _sendKey = createSendKey(); + _endSending(); + switch (failure.code) { + case 'MAIL_002': + case 'MAIL_010': + state = state.copyWith(recipientRejection: failure.code); + return ComposeSendOutcome.focusRecipients; + case 'MAIL_007': + state = state.copyWith(lengthError: ComposeLengthError.body); + return ComposeSendOutcome.focusBody; + case 'MAIL_004': + _markAttachmentsMissing(); + notice(failure.message); + return ComposeSendOutcome.none; + case 'MAIL_016': + await _recheckLargeFiles(); + final String? reason = ref.mounted ? sendBlockReason : null; + notice(reason ?? failure.message); + return ComposeSendOutcome.none; + } + // 그 밖의 실패는 인터셉터가 알렸다 — 쓴 내용은 그대로 둔다(같은 키로 다시 누른다) + return ComposeSendOutcome.none; + } + } finally { + keepAlive.close(); + } + } + + /// 보내지 못했다 — 쓰던 대로 두고 자동 저장을 다시 켠다 + void _endSending() { + _sendingNow = false; + if (!ref.mounted) return; + state = state.copyWith(sending: false); + if (_attached && !_ended) _autosave.resume(); + _emitUnsaved(); + } + + /// 보냈다고 알린다 — 보내기 취소를 기다리는 발송이면 [보내기 취소] + 남은 시간 막대(마감은 서버 시계로), 아니면 [보낸편지함 보기]. + /// 받는 사람 일부가 받지 못했으면 그 사람을 적는다(계약 2절 "받는 사람별 결과") + void _announceSent( + SentMessage sent, { + required DateTime? serverTime, + required GoRouter router, + required MailToastNotifier toast, + required void Function(String text) notice, + required MailRepository mail, + required MailStateWatcher watcher, + required TokenStore tokens, + required int session, + required Mailbox? sentBox, + }) { + final PendingSend? pending = sent.pendingSend; + if (pending != null && pending.kind == PendingSendKind.undo) { + final Duration? remaining = undoSendRemaining( + sendAt: pending.sendAt, + serverNow: serverTime, + undoSendSeconds: _undoSendSeconds, + deviceNow: _clock(), + ); + if (remaining != null) { + toast.offer( + partialSendMessage(sent.rejectedRecipients, pending: true) ?? + MailComposeTexts.sent, + actionLabel: MailComposeTexts.undoSend, + deadline: remaining, + onAction: () => _undoSend( + sent.id, + router: router, + mail: mail, + watcher: watcher, + notice: notice, + tokens: tokens, + session: session, + ), + ); + return; + } + } + final String text = + partialSendMessage(sent.rejectedRecipients) ?? MailComposeTexts.sent; + if (sentBox == null) { + notice(text); + return; + } + toast.offer( + text, + actionLabel: MailComposeTexts.viewSent, + onAction: () async { + if (tokens.sessionGeneration != session) return; + router.go(MailRoutes.mailbox(sentBox.id)); + }, + ); + } + + /// 보내는 중에 떠났고 보내지 못했다 — 보내려던 내용을 조용히 저장본으로 남긴다(세션이 그대로일 때만). + /// [payload] 는 저장 본문(draftId 없음), [draftId] 는 이어 쓰던 저장본 — 있으면 PUT, 막히면 새로 + static Future _keepUnsent( + MailComposePayload payload, { + required String? draftId, + required ComposeRepository repository, + required MailStateWatcher watcher, + required DraftSuccessors successors, + required bool Function() stillValid, + }) async { + if (!stillValid()) return; + final DraftSaved? saved = await saveDraftDetached( + payload: payload, + draftId: draftId, + create: (MailComposePayload body) => repository.createDraft(body), + update: (String id, MailComposePayload body) => + repository.updateDraft(id, body), + stillValid: stillValid, + ); + if (saved == null || !stillValid()) return; + if (draftId != null) successors.record(draftId, saved.id); + watcher.markChanged(); + } + + // ── 세션 · 정리 ───────────────────────────────────── + + /// 메일을 보는 직원이 바뀌었다(로그아웃 · 세션 끝 · 다른 직원) — 묻지도 저장하지도 않는다 + void _endSession() { + _ended = true; + _autosave.stop(); + _cancelSearch(); + if (!ref.mounted) return; + // 잃을 것이 없다고 본다 — 화면이 묻지 않고 닫히게(다른 직원의 임시보관함에 남기지 않는다). 나가기 확인 창도 알리지 않고 닫는다 + state = state.copyWith(ended: true); + _emitUnsaved(); + } + + void _dispose() { + _autosave.dispose(); + _cancelSearch(); + subject.dispose(); + body.dispose(); + for (final TextEditingController input in inputs.values) { + input.dispose(); + } + } +} + +/// [보내기 취소] — 붙잡아 둔 발송을 취소하고 그 메일(임시보관함)로 쓰기 화면을 다시 연다(새 키 — 새 양식). +/// 알림이 쓰기 화면보다 오래 남으므로 화면 없이 돈다. 만든 세션에서만 움직인다. 실패(`MAIL_014` 등)는 인터셉터가 알렸다 +Future _undoSend( + String sentId, { + required GoRouter router, + required MailRepository mail, + required MailStateWatcher watcher, + required void Function(String text) notice, + required TokenStore tokens, + required int session, +}) async { + if (tokens.sessionGeneration != session) return; + try { + final String draftId = await mail.cancelPendingSend(sentId); + if (tokens.sessionGeneration != session) return; + watcher.markChanged(removedIds: {sentId}); + unawaited(router.push(MailRoutes.composeDraft(draftId))); + notice(MailComposeTexts.undone); + } on DioException { + if (tokens.sessionGeneration != session) return; + // 이미 보냈다 · 없다 — 목록을 맞춘다 + watcher.markChanged(); + } +} + +/// 받는 사람 칸의 이름 — "받는 사람" · "참조" · "숨은참조" +String fieldLabelOf(RecipientFieldKind kind) => switch (kind) { + RecipientFieldKind.to => MailComposeTexts.to, + RecipientFieldKind.cc => MailComposeTexts.cc, + RecipientFieldKind.bcc => MailComposeTexts.bcc, +}; diff --git a/flutter/lib/features/mail/presentation/providers/compose_state.dart b/flutter/lib/features/mail/presentation/providers/compose_state.dart new file mode 100644 index 0000000..5d6bebb --- /dev/null +++ b/flutter/lib/features/mail/presentation/providers/compose_state.dart @@ -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 [], + this.cc = const [], + this.bcc = const [], + this.copiesOpen = false, + this.attachments = const [], + this.largeFiles = const [], + this.signature, + this.signatureIncluded = false, + this.quote, + this.foreignNotice = false, + this.attempted = false, + this.recipientRejection, + this.lengthError, + this.sending = false, + this.groupStatus = const {}, + 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 to; + final List cc; + final List bcc; + + /// 참조 · 숨은참조 칸을 펼쳤다 + final bool copiesOpen; + final List attachments; + final List 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 groupStatus; + + /// 받아 둔 추천이 바뀔 때마다 오른다 — 칸이 추천을 다시 거른다 + final int suggestionsRevision; + + /// 떠나면 잃는 것이 있다 — 저장하지 않은 변경 · 칸에 쓰다 만 주소 · 찾지 못한 첨부 + final bool unsaved; + + /// 이 양식을 저장한 차례 — 저장이 끝날 때마다 오른다(나가기 확인 창이 그사이 끝난 저장을 안다) + final int saveCount; + + /// 메일을 보는 직원이 바뀌었다(로그아웃 · 세션 끝 · 다른 직원) — 묻지도 저장하지도 알리지도 않는다 + final bool ended; + + List tokensOf(RecipientFieldKind kind) => switch (kind) { + RecipientFieldKind.to => to, + RecipientFieldKind.cc => cc, + RecipientFieldKind.bcc => bcc, + }; + + List get allRecipients => [ + ...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? to, + List? cc, + List? bcc, + bool? copiesOpen, + List? attachments, + List? 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? 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, + ); +} diff --git a/flutter/lib/features/mail/presentation/providers/draft_autosave.dart b/flutter/lib/features/mail/presentation/providers/draft_autosave.dart new file mode 100644 index 0000000..a188f25 --- /dev/null +++ b/flutter/lib/features/mail/presentation/providers/draft_autosave.dart @@ -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 kDraftRetryDelays = [ + Duration(seconds: 15), + Duration(seconds: 30), + Duration(seconds: 60), +]; + +/// 저절로 다시 해 볼 실패 — 메일 서버 장애 · 메일함 용량 · 요청 한도 · 네트워크 (웹 `DRAFT_RETRY_CODES`) +const Set kDraftRetryCodes = { + 'MAIL_001', + 'MAIL_003', + 'REQ_001', + 'SYS_001', +}; + +/// 옛 저장본이 다른 곳에서 바뀌었거나 없을 때 — 막지 않고 지금 내용을 새 저장본으로 남긴다 (웹 `REPLACED_CODES`) +const Set kDraftReplacedCodes = {'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 Function(MailComposePayload payload) create; + final Future 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? _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 settle() async { + Future? current = _inFlight; + while (current != null) { + await current; + if (identical(_inFlight, current)) return; + current = _inFlight; + } + } + + /// 떠나기 전 — 남은 변경을 저장한다. 저장한 그대로면(또는 저장에 성공하면) 참 + Future 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 _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 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 _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 saveDraftDetached({ + required MailComposePayload payload, + required String? draftId, + required Future Function(MailComposePayload payload) create, + required Future 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; + } +} diff --git a/flutter/lib/features/mail/presentation/providers/mail_toast_provider.dart b/flutter/lib/features/mail/presentation/providers/mail_toast_provider.dart index 7c9e9ac..d121d3c 100644 --- a/flutter/lib/features/mail/presentation/providers/mail_toast_provider.dart +++ b/flutter/lib/features/mail/presentation/providers/mail_toast_provider.dart @@ -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 mailToastProvider = /// /// - 단추가 있는 알림([offer])은 화면 아래 줄 하나 — 새 단추 알림이 앞의 것을 바꾼다. 10초 뒤 닫는다. /// 화면 낭독기를 쓰는 동안은 저절로 닫지 않고, 끄면 그때부터 10초를 센다(Flutter SnackBar 와 같은 결). +/// - 마감이 있는 알림([offer] 의 `deadline` — [보내기 취소])은 그 시각에 닫는다 — 화면 낭독기를 쓰는 동안에도(마감 뒤에는 취소할 수 없다). /// - 단추 없는 알림([notify] — "고를 수 없습니다" · 영구 삭제 결과 등)은 전역 알림 자리에 띄운다 — /// 떠 있는 [되돌리기]를 바꿔 끼우지 않아 되돌릴 길을 잃지 않는다(2026-11-06 리뷰에서 정함). /// - API 실패는 여기 두지 않는다 — 인터셉터의 전역 알림이 띄운다. @@ -89,18 +99,33 @@ class MailToastNotifier extends Notifier { ref.read(mailNoticeSinkProvider)(text); } - /// 단추가 있는 알림 — 앞의 단추 알림을 바꾼다 + /// 단추가 있는 알림 — 앞의 단추 알림을 바꾼다. [deadline] 이 있으면 그만큼 뒤에 닫는다(붙잡지 않는다) void offer( String text, { required String actionLabel, required Future 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 { void _onHoldChanged() { final MailToast? toast = state; - if (toast == null) return; + // 마감이 있는 알림은 붙잡지 않는다 — 그 시각에 닫는다 + if (toast == null || toast.deadline != null) return; if (_holding) { _cancelTimer(); } else if (_timer == null) { diff --git a/flutter/lib/features/mail/presentation/providers/signature_image_provider.dart b/flutter/lib/features/mail/presentation/providers/signature_image_provider.dart new file mode 100644 index 0000000..bad49a0 --- /dev/null +++ b/flutter/lib/features/mail/presentation/providers/signature_image_provider.dart @@ -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 signatureImageProvider = + FutureProvider.autoDispose.family(( + Ref ref, + String id, + ) async { + ref.watch(mailAccountProvider); + try { + return await ref + .read(composeRepositoryProvider) + .fetchSignatureImage(id, maxBytes: kSignatureImageMaxBytes); + } on DioException { + return null; + } + }); diff --git a/flutter/lib/features/mail/presentation/widgets/compose_bars.dart b/flutter/lib/features/mail/presentation/widgets/compose_bars.dart new file mode 100644 index 0000000..5753c77 --- /dev/null +++ b/flutter/lib/features/mail/presentation/widgets/compose_bars.dart @@ -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()!; + 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: [ + 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()!; + 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: [ + 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), + ), + ), + ], + ), + ), + ); + } +} diff --git a/flutter/lib/features/mail/presentation/widgets/compose_recipient_field.dart b/flutter/lib/features/mail/presentation/widgets/compose_recipient_field.dart new file mode 100644 index 0000000..736fc7f --- /dev/null +++ b/flutter/lib/features/mail/presentation/widgets/compose_recipient_field.dart @@ -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 tokens; + final TextEditingController controller; + final FocusNode focusNode; + + /// 친 글자에 맞는 추천 줄 + final List Function(String query) optionsFor; + + /// 받아 둔 추천이 바뀔 때마다 오른다 — 목록을 다시 거른다 + final int revision; + final ValueChanged onChanged; + + /// 칸에 쓰다 만 글을 칩으로 + final VoidCallback onCommit; + final ValueChanged onRemove; + final ValueChanged onPick; + + /// 빈 칸에서 [다음] — 다음 칸으로 + final VoidCallback onSubmitEmpty; + + /// 칸에 초점이 왔다 — 추천을 받지 못했으면 다시 받는다 + final VoidCallback? onFocused; + + /// 서버가 받는 사람을 거절했다 — 주소가 든 칸은 형식이 맞아도 잘못된 입력으로 읽힌다(어느 주소인지는 모른다) + final bool rejected; + + /// 그룹을 넣은 뒤(또는 받는 동안) 칸 아래 글 + final String? status; + final Widget? trailing; + final bool enabled; + + @override + State createState() => _ComposeRecipientFieldState(); +} + +class _ComposeRecipientFieldState extends State { + 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 _options() { + if (!_focused || _dismissed || _query.trim().isEmpty || !widget.enabled) { + return const []; + } + return widget.optionsFor(_query); + } + + /// 미리 골라 둔 추천 — 완전한 주소를 쳤으면 없다 + int _activeIndex(List options) => + options.isEmpty || isValidEmail(_query) ? -1 : 0; + + void _onNext() { + final List 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()!; + final List 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: [ + 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: [ + 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 [_LineBreaksToCommas()], + style: theme.textTheme.bodyLarge, + decoration: const InputDecoration.collapsed(hintText: ''), + onChanged: widget.onChanged, + onEditingComplete: _onNext, + ), + ); + } + + Widget _overlay( + BuildContext context, + List 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()!; + 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: [ + 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()!; + 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: [ + 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 options; + final int active; + final double maxHeight; + final ValueChanged onPick; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme scheme = theme.colorScheme; + final List rows = []; + 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()!; + 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()!; + final TextStyle? primaryStyle = theme.textTheme.bodyMedium; + final TextStyle? secondaryStyle = theme.textTheme.bodySmall?.copyWith( + color: extra.textMuted, + ); + final ({ + List primary, + List? 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: [ + 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: [ + _Highlighted(parts: view.primary, style: primaryStyle), + if (view.secondary case final List parts) + _Highlighted(parts: parts, style: secondaryStyle), + ], + ), + ), + ], + ), + ), + ), + ); + } + + static ({ + List primary, + List? 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: [(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 emailParts = [ + ...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 + : [(text: detail, strong: false)]), + initial: initialOf(name, fallback: email), + isGroup: false, + semantics: [if (named) name, email, ?detail].join(', '), + ); + } + } +} + +/// 맞은 글자를 굵게 — 글 조각으로(HTML 없음) +class _Highlighted extends StatelessWidget { + const _Highlighted({required this.parts, required this.style}); + + final List parts; + final TextStyle? style; + + @override + Widget build(BuildContext context) { + return Text.rich( + TextSpan( + children: [ + 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, + ); + } +} diff --git a/flutter/lib/features/mail/presentation/widgets/compose_sections.dart b/flutter/lib/features/mail/presentation/widgets/compose_sections.dart new file mode 100644 index 0000000..21870f0 --- /dev/null +++ b/flutter/lib/features/mail/presentation/widgets/compose_sections.dart @@ -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 attachments; + final List largeFiles; + final ValueChanged onRemoveAttachment; + final ValueChanged onRemoveLargeFile; + + /// 거짓이면 ✕ 를 막는다(보내는 중) + final bool enabled; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + 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: [ + 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) ...[ + const SizedBox(height: AppSpacing.s8), + _ComposeFileRow( + key: ValueKey('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) ...[ + const SizedBox(height: AppSpacing.s8), + _ComposeFileRow( + key: ValueKey('compose-large-file-${file.id}'), + icon: Icons.cloud_outlined, + name: file.name ?? MailComposeTexts.largeUnknownName, + detail: [ + 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()!; + 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: [ + 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: [ + 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()!; + 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: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + 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()!; + 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: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + 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 lines; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme scheme = theme.colorScheme; + final AppExtraColors extra = theme.extension()!; + 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 header; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme scheme = theme.colorScheme; + final AppExtraColors extra = theme.extension()!; + final TextStyle? valueStyle = theme.textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + 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: [ + 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 _widths = [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: [ + for (final double width in _widths) ...[ + FractionallySizedBox( + widthFactor: width, + child: Container( + height: AppSpacing.s12, + decoration: BoxDecoration( + color: bar, + borderRadius: BorderRadius.circular(AppRadius.xs), + ), + ), + ), + const SizedBox(height: AppSpacing.s20), + ], + ], + ), + ), + ), + ); + } +} diff --git a/flutter/lib/features/mail/presentation/widgets/mail_compose_area.dart b/flutter/lib/features/mail/presentation/widgets/mail_compose_area.dart index 32704d3..cb4dc8b 100644 --- a/flutter/lib/features/mail/presentation/widgets/mail_compose_area.dart +++ b/flutter/lib/features/mail/presentation/widgets/mail_compose_area.dart @@ -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: [ - 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: [ + ConstrainedBox( + constraints: const BoxConstraints( + minHeight: AppSize.toastMinHeight, + ), + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.s16, + AppSpacing.s2, + AppSpacing.s4, + AppSpacing.s2, + ), + child: Row( + children: [ + 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(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()!; + 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, ), ), ); diff --git a/flutter/lib/shared/widgets/confirm_dialog.dart b/flutter/lib/shared/widgets/confirm_dialog.dart index f55b3f8..118a03a 100644 --- a/flutter/lib/shared/widgets/confirm_dialog.dart +++ b/flutter/lib/shared/widgets/confirm_dialog.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; /// 확인 창 — 앱 보드 `App1Shell` 의 [로그아웃] 확인과 같은 모양(모서리 16 · 제목 굵게 · 오른쪽 아래에 [취소] · 주 단추). /// /// 되돌릴 수 없는 일의 확인이라 **처음 초점은 [취소]** 다(웹 대화상자 규칙). [danger] 면 주 단추가 빨간 채움이다. +/// [stayIsPrimary] 면 [취소] 쪽이 주 단추(채움)이고 [확인]은 글자 단추다 — 쓰기 화면의 나가기 확인([계속 쓰기]가 처음 초점이자 주 단추 — 웹 `ComposeLeaveDialog`). /// [확인]을 누르면 참, [취소] · 뒤로 가기 · 바깥 누르기는 거짓. Future showConfirmDialog( BuildContext context, { @@ -14,6 +15,7 @@ Future showConfirmDialog( required String confirmLabel, required String cancelLabel, bool danger = false, + bool stayIsPrimary = false, }) async { final bool? confirmed = await showDialog( context: context, @@ -23,6 +25,7 @@ Future 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: [ - 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 + ? [ + 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), + ), + ] + : [ + 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), + ), + ], ); } } diff --git a/flutter/test/app_test.dart b/flutter/test/app_test.dart index 7a83d46..1e9af23 100644 --- a/flutter/test/app_test.dart +++ b/flutter/test/app_test.dart @@ -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); }); diff --git a/flutter/test/features/mail/compose_reading_test.dart b/flutter/test/features/mail/compose_reading_test.dart new file mode 100644 index 0000000..d13d3dc --- /dev/null +++ b/flutter/test/features/mail/compose_reading_test.dart @@ -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 _header = [ + QuoteHeaderRow('보낸 사람', '이도윤 '), +]; + +/// [depth] 단으로 겹친 div 안의 글 +String _nestedDivs(int depth, String inner) => + '${'
' * depth}$inner${'
' * depth}'; + +/// 원문 인용 하나 — 머리 표 뒤에 [inner] +String _mailQuote(String inner) => + '

원본 메일

보낸 사람이도윤
$inner
'; + +/// 저장본 읽기(쓰기 화면 1차 리뷰 M1 · M2) · 처음 맞추기(L2) · 제목 길이(L3) +void main() { + group('HTML 노드를 되풀이 없이 — package:html 과 같은 글', () { + const String sample = + '

글 & <태그>
그림

' + '
\n\n앞 줄바꿈
'; + + 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('
')); + expect(html.length, depth * '
'.length + '깊은 글'.length); + expect(nodeTextOf(body), '깊은 글'); + expect(descendantElements(body, 'div'), hasLength(depth)); + }); + }); + + group('저장본 읽기 — 읽기 화면과 같은 한도 · 문턱(M1)', () { + test('읽을 곳 — 짧고 얕으면 그 자리, 길거나 깊으면 isolate, 한도를 넘으면 읽지 않는다', () { + expect( + draftBodyPlaceOf(bodyHtml: '

짧은 글

', body: ''), + DraftBodyPlace.inline, + ); + expect( + draftBodyPlaceOf(bodyHtml: null, body: '글'), + DraftBodyPlace.inline, + ); + expect( + draftBodyPlaceOf( + bodyHtml: '

${'가' * kMailBodyInlineMaxLength}

', + 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: '

${'가' * kMaxSanitizedHtmlLength}

', + body: '', + ), + DraftBodyPlace.oversized, + ); + }); + + test('2만 단 저장본 — 읽지 않고 남의 저장본으로: 본문 칸은 텍스트 본문, 원래 HTML 은 그대로 쥔다', () async { + final String html = _nestedDivs(20000, '깊은 글'); + final DraftBody parsed = await buildDraftBody( + bodyHtml: ' $html ', + body: '깊은 글\n', + ); + expect(parsed, isA()); + 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 = + '

앞 글

${_mailQuote(_nestedDivs(1500, '깊은 원문'))}
'; + expect( + draftBodyPlaceOf(bodyHtml: html, body: ''), + DraftBodyPlace.isolate, + ); + final DraftBody parsed = await buildDraftBody(bodyHtml: html, body: ''); + expect(parsed, isA()); + final AppDraftBody app = parsed as AppDraftBody; + expect(app.text, '앞 글'); + expect(app.quote!.header.single.value, '이도윤'); + expect(plainLinesOf(app.quote!.blocks), ['깊은 원문']); + expect(app.quote!.rawHtml, startsWith('

원본 메일

')); + }); + + test('바로 읽어도(한도 검사를 거치지 않아도) 깊은 인용 · 서명에서 스택이 넘치지 않는다', () { + final String html = + '

글

${_nestedDivs(2000, '서명')}
' + '${_mailQuote(_nestedDivs(2000, '원문'))}
'; + final DraftBody parsed = draftBodyOf(bodyHtml: html, body: ''); + expect(parsed, isA()); + final AppDraftBody app = parsed as AppDraftBody; + expect(app.signature?.parts, const [ + SignatureTextPart('서명'), + ]); + expect( + app.quote!.rawHtml!.length, + greaterThan(2000 * '
'.length), + ); + }); + }); + + group('처음 맞추기 · 줄 자르기(L2)', () { + test('처음 인용은 덩이 만들기와 함께 내 글 몫(5%)을 남기고 맞춘다', () { + final ComposeQuote quote = initialQuoteOf( + ComposeQuoteRequest( + htmlBody: null, + textBody: List.filled(3000, '가' * 50).join('\n'), + isBodyTruncated: false, + header: _header, + summary: '이도윤', + signatureText: '김아라', + signatureHtml: '

김아라

', + ), + ); + expect(quote.omitted, isTrue); + expect(quote.blocks.length, lessThan(3000)); + final MailBodyOutput output = buildMailBody( + ComposeBodyInput( + text: '', + signatureHtml: '

김아라

', + signatureText: '김아라', + quote: quote, + ), + ); + expect(BodyLimits.initial.fits(output), isTrue); + + final ComposeQuote small = initialQuoteOf( + const ComposeQuoteRequest( + htmlBody: '

짧은 원문

', + textBody: '짧은 원문', + isBodyTruncated: true, + header: _header, + summary: '이도윤', + ), + ); + expect(plainLinesOf(small.blocks), ['짧은 원문']); + expect(small.omitted, isTrue); + }); + + for (final (String name, String line) in <(String, String)>[ + ('이스케이프되는 글자(& 5칸)', '&' * 200000), + ('탭(8칸까지 공백)', '${'\t' * 70000}끝'), + ('섞인 글', List.filled(40000, 'a&\t<😀 ').join()), + ]) { + test('긴 한 줄은 들어가는 만큼만 — 한 글자만 더 담아도 넘친다($name)', () { + final ComposeQuote quote = ComposeQuote( + header: _header, + blocks: [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([ + 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', + ); + }); + }); +} diff --git a/flutter/test/features/mail/compose_repository_test.dart b/flutter/test/features/mail/compose_repository_test.dart new file mode 100644 index 0000000..c269a0e --- /dev/null +++ b/flutter/test/features/mail/compose_repository_test.dart @@ -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(email: 'doyun@x.kr', name: '이도윤')], + cc: [], + bcc: [], + subject: '계약', + body: '본문', + bodyHtml: '
본문
', + draftId: 'd1', +); + +Map _savedJson(String id) => { + 'id': id, + 'attachments': [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({'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, { + 'success': true, + 'data': { + 'id': 'sent-1', + 'rejectedRecipients': [ + {'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 {'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 {'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 {'MAIL_002'}, + ), + ); + expect(h.notifier.shownCodes, ['MAIL_001']); + }, + ); + + test('토큰이 끝나 401 이면 갱신한 뒤 같은 키로 다시 보낸다(서버가 두 번 내보내지 않게)', () async { + int next = 2; + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.ok({ + ...sessionJson(), + 'tokens': tokensJson(next++, now: h.clock.now), + }), + ); + h.server.on( + 'POST', + MailApiPaths.messages, + (RequestOptions options) => bearerTokenOf(options) == 'access-2' + ? const FakeResponse(201, { + 'success': true, + 'data': {'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 sends = h.server.received( + 'POST', + MailApiPaths.messages, + ); + expect(sends, hasLength(2)); + expect( + sends.map((RequestOptions r) => r.headers[kIdempotencyHeader]), + ['key-9', 'key-9'], + ); + }); + + test('서명 · 서명 그림 · 보내기 설정 — 알리지 않는다, 그림은 한도를 넘으면 끊는다', () async { + h.server.on( + 'GET', + ComposeApiPaths.mailSignature, + (_) => FakeResponse.ok({ + 'bodyHtml': '

김아라

', + 'body': '김아라', + 'isForNew': true, + 'isForReply': false, + 'updatedAt': '2026-11-01T00:00:00.000Z', + }), + ); + final MailSignature signature = await repository.getSignature(); + expect(signature.bodyHtml, '

김아라

'); + 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({'undoSendSeconds': 10}), + ); + expect(await repository.undoSendSeconds(), 10); + h.server.on( + 'GET', + ComposeApiPaths.mailSettings, + (_) => FakeResponse.ok({}), + ); + expect( + apiErrorOf(await errorOf(repository.undoSendSeconds())).code, + 'SYS_001', + ); + expect(h.notifier.shownCodes, isEmpty); + }); + + test( + '받는 사람 추천 · 그룹 · 구성원(다음 쪽까지) — 추천 · 그룹은 알리지 않는다, 고른 그룹의 실패는 알린다', + () async { + h.server.on( + 'GET', + ComposeApiPaths.recipientSuggestions, + (_) => FakeResponse.ok({ + 'items': [ + { + 'name': '이도윤', + 'email': 'doyun@x.kr', + 'source': 'directory', + }, + {'email': 'x'}, + {'name': 1, 'email': 'bad@x.kr'}, + {'email': 'new@x.kr', 'source': 'future'}, + ], + 'pageInfo': {'hasNext': true, 'nextCursor': 'c'}, + }), + ); + final RecipientSuggestionPage page = await repository + .listRecipientSuggestions(query: '도윤', limit: 16); + expect(page.items.map((RecipientSuggestion s) => s.email), [ + 'doyun@x.kr', + 'x', + 'new@x.kr', + ]); + expect(page.items.last.source, RecipientSource.recent); + expect(page.hasNext, isTrue); + expect(h.server.requests.last.queryParameters, { + 'q': '도윤', + 'limit': 16, + }); + + await repository.listRecipientSuggestions(); + expect(h.server.requests.last.queryParameters, { + 'limit': kRecipientSuggestionPreload, + }); + + h.server.on( + 'GET', + ComposeApiPaths.contactGroups, + (_) => FakeResponse.ok({ + 'items': [ + {'id': 'g1', 'name': '거래처', 'memberCount': 3}, + ], + 'pageInfo': {'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({ + 'items': [ + { + 'name': first ? '박지훈' : '최서연', + 'emails': [ + { + 'address': first ? 'jihoon@x.kr' : 'seoyeon@x.kr', + 'label': 'work', + }, + ], + }, + if (!first) + {'name': '주소 없음', 'emails': []}, + ], + 'pageInfo': { + 'hasNext': first, + 'nextCursor': first ? 'p2' : null, + }, + }); + }); + final List members = await repository.listGroupMembers('g1'); + expect(members, [ + (name: '박지훈', email: 'jihoon@x.kr'), + (name: '최서연', email: 'seoyeon@x.kr'), + (name: '주소 없음', email: null), + ]); + final List pages = h.server.received( + 'GET', + ComposeApiPaths.contacts, + ); + expect(pages.first.queryParameters, { + '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, ['MAIL_001']); + }, + ); + + test('대용량 파일 상태 — 없으면 RES_001(알리지 않는다)', () async { + h.server.on( + 'GET', + ComposeApiPaths.fileUpload('u1'), + (_) => FakeResponse.ok({ + '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); + }); +} diff --git a/flutter/test/features/mail/compose_rules_test.dart b/flutter/test/features/mail/compose_rules_test.dart new file mode 100644 index 0000000..fd97654 --- /dev/null +++ b/flutter/test/features/mail/compose_rules_test.dart @@ -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 = + '
'; +const String _p = '

'; +const String _mailQuoteOpen = + '

'; +const String _nestOpen = + '
'; + +const List _header = [ + QuoteHeaderRow('보낸 사람', '이도윤 '), + QuoteHeaderRow('날짜', '2026. 11. 5. (목) 오전 10:42'), + QuoteHeaderRow('제목', '계약'), +]; + +const ComposeQuote _quote = ComposeQuote( + header: _header, + blocks: [ + QuoteLine('안녕하세요'), + QuoteLine(''), + QuoteNest([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 overrides) => + MessageDetail.tryParse({ + ...messageJson('m1', mailboxId: kInboxId), + 'cc': [], + 'bcc': [], + 'replyTo': [], + 'htmlBody': null, + 'textBody': '본문', + 'hasBlockedImages': false, + 'attachments': [], + ...overrides, + })!; + +void main() { + group('보내는 본문 — 서식 본문 + 텍스트 (짝: 웹 mail-serialize.ts)', () { + test('내 글만 — 줄마다 문단, 빈 줄은
, 끝의 빈 줄은 보내지 않는다', () { + final MailBodyOutput output = buildMailBody( + const ComposeBodyInput(text: 'a\n\nb\n\n'), + ); + expect(output.html, '$_wrapper${_p}a

$_p

${_p}b

'); + 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 & z')).html, + '$_wrapper${_p}x < y & <b>z</b>

', + ); + }); + + 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김아라

', + signatureText: '김아라\n경영지원팀', + ), + ); + expect( + output.html, + '$_wrapper${_p}hi

$_p

$_p김아라

', + ); + expect(output.text, 'hi\n\n김아라\n경영지원팀'); + }); + + test('원문 인용 — "원본 메일" 문단 + 머리 표 + 문단 · 안쪽 인용, 텍스트는 "> " 줄', () { + final MailBodyOutput output = buildMailBody( + const ComposeBodyInput(text: 'ok', quote: _quote), + ); + const String label = + ''; + const String value = ''; + expect( + output.html, + '$_wrapper${_p}ok

' + '$_mailQuoteOpen' + '

원본 메일

' + '' + '$label보낸 사람$value이도윤 <doyun@x.kr>' + '$label날짜${value}2026. 11. 5. (목) 오전 10:42' + '$label제목$value계약' + '
' + '$_p안녕하세요

$_p

$_nestOpen$_p이전 글

' + '', + ); + expect( + output.text, + 'ok\n' + '> 원본 메일\n' + '> 보낸 사람: 이도윤 \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서명

', + quote: _quote, + quoteStartMark: true, + ), + ).html!; + expect(html, contains('$kQuoteStartMarker$_mailQuoteOpen')); + }); + + test('줄인 인용은 끝에 생략 표시 · 남의 저장본의 인용은 원래 HTML 그대로', () { + final MailBodyOutput omitted = buildMailBody( + ComposeBodyInput( + text: '', + quote: _quote.withBlocks(const [ + QuoteLine('앞부분'), + ], omitted: true), + ), + ); + expect( + omitted.html, + endsWith('$_p앞부분

$_p$kQuoteOmittedMark

'), + ); + expect(omitted.text, endsWith('> 앞부분\n> $kQuoteOmittedMark')); + + const String raw = '

원본 메일

'; + final MailBodyOutput kept = buildMailBody( + const ComposeBodyInput( + text: 'x', + quote: ComposeQuote( + header: _header, + blocks: [QuoteLine('글')], + summary: '', + rawHtml: raw, + ), + ), + ); + expect(kept.html, '$_wrapper${_p}x

$raw'); + }); + }); + + group('길이 맞추기 (짝: 웹 fit-draft.ts)', () { + ComposeQuote longQuote(int lines, {int width = 100}) => ComposeQuote( + header: _header, + blocks: [ + 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: [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 []), 0); + expect( + largeFileBlockTextReserve(const ['a.zip', '계약.pdf']), + 600 + 5 + 24 + 6 + 24, + ); + expect(BodyLimits.full.reserveText(1000).text, kMailBodyTextMax - 1000); + }); + }); + + group('원문 → 인용 덩이 (화면에 보이는 글과 같게)', () { + List lines(String html) => plainLinesOf(quoteBlocksFromHtml(html)); + + test('블록 ·
은 줄, 이어진 공백은 한 칸, 앞뒤 빈 줄은 뺀다', () { + expect(lines('

안녕 하세요

둘째
셋째

'), [ + '안녕 하세요', + '둘째', + '셋째', + ]); + }); + + test('목록은 "- " · "1. ", 칸이 여럿인 표 줄은 " | ", 링크는 "글 (주소)"', () { + expect( + lines( + '
  • 사과
  • 배
  1. 셋
' + '
이름값
' + '

문서 · 위험

', + ), + [ + '- 사과', + '- 배', + '3. 셋', + '이름 | 값', + '문서 (https://ara.example/x) · 위험', + ], + ); + }); + + test('원문 속 인용은 안쪽 인용, 스크립트 · 스타일 · 그림의 글은 뺀다', () { + final List blocks = quoteBlocksFromHtml( + '

위

안쪽

' + '그림', + ); + expect(blocks, const [ + QuoteLine('위'), + QuoteNest([QuoteLine('안쪽')]), + ]); + expect(quoteDisplayLines(blocks), [ + (text: '위', depth: 0), + (text: '안쪽', depth: 1), + ]); + }); + + test('읽기 화면과 같은 본문 고르기 — 그림뿐인 서식 본문이면 글 본문, 잘라 보낸 본문이면 생략 표시', () { + final QuoteSource onlyImage = quoteSourceOf( + htmlBody: '

', + textBody: '글 본문\n둘째 줄', + isBodyTruncated: false, + ); + expect(plainLinesOf(onlyImage.blocks), ['글 본문', '둘째 줄']); + + final QuoteSource truncated = quoteSourceOf( + htmlBody: '

서식 본문

', + textBody: '글 본문', + isBodyTruncated: true, + ); + expect(plainLinesOf(truncated.blocks), ['서식 본문']); + expect(truncated.truncated, isTrue); + }); + }); + + group('저장본 다시 열기 (계약 2절 "앱이 만드는 모양")', () { + const String signatureHtml = '$_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()); + final AppDraftBody app = parsed as AppDraftBody; + expect(app.text, '첫 줄\n 들여 쓴 줄\n\n끝'); + expect(app.signature?.html, signatureHtml); + expect(app.signature?.parts, const [ + 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()); + expect((parsed as AppDraftBody).text, '글'); + expect(parsed.quote, isNotNull); + }); + + test('서식이 있는 남의 저장본 — 인용 앞은 글로, 안내 줄을 보인다(원래 HTML 은 쥔다)', () { + const String html = + '

안녕 굵게

서명

' + '

원본 메일

보낸 사람이도윤 <d@x.kr>

원문

'; + final DraftBody parsed = draftBodyOf(bodyHtml: html, body: '원래 텍스트'); + expect(parsed, isA()); + 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, '이도윤 '); + expect(foreign.quote!.summary, '이도윤'); + expect(foreign.quote!.rawHtml, isNotNull); + }); + + test('서식이 없는 남의 저장본은 안내하지 않는다 · 서식 본문이 없으면 텍스트 그대로', () { + final DraftBody plain = draftBodyOf( + bodyHtml: '
a

b

', + body: '', + ); + expect(plain, isA()); + expect((plain as ForeignDraftBody).hasFormatting, isFalse); + expect(plain.text, 'a\nb'); + final DraftBody text = draftBodyOf(bodyHtml: null, body: '글 본문\n\n'); + expect(text, isA()); + expect((text as AppDraftBody).text, '글 본문'); + }); + + test('남의 저장본은 원문 인용 앞에 인용 경계 표시를 넣은 HTML 을 읽을 때 한 번 만들어 둔다', () { + const String html = + '

a b

원본 메일

'; + final ForeignDraftBody foreign = + draftBodyOf(bodyHtml: html, body: 'a b') as ForeignDraftBody; + expect( + foreign.markedHtml, + '

a b

원본 메일

', + ); + expect(foreign.originalHtml, html); + final ForeignDraftBody noQuote = + draftBodyOf(bodyHtml: '

인용 없음

', body: '') + as ForeignDraftBody; + expect(noQuote.markedHtml, isNull); + }); + + // 웹 저장본 표본 — 문단의 정렬 · 들여쓰기 · 글자 꾸밈 · 다른 감싸개는 앱 모양이 아니다(말없이 지우지 않게 원래 HTML 을 쥐고 안내한다) + for (final (String name, String html) in <(String, String)>[ + ('가운데 정렬', '

가운데

'), + ('들여쓰기', '

들여 쓴 줄

'), + ('굵게', '

글 굵게

'), + ('문단의 다른 속성', '

글

'), + ( + '꾸민 감싸개', + '

글

', + ), + ( + '꾸민 서명 div', + '

글

서명

', + ), + ]) { + test('웹 저장본 — $name 은 남의 저장본(원래 HTML 을 쥐고 안내 줄)', () { + final DraftBody parsed = draftBodyOf(bodyHtml: html, body: '글'); + expect(parsed, isA(), 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: + '
' + '

첫 줄

둘째 줄

', + body: '', + ); + expect(parsed, isA()); + 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경영지원팀

'); + expect(signature.text, '김아라\n경영지원팀'); + expect(signature.parts, const [ + SignatureTextPart('김아라'), + SignatureTextPart('경영지원팀'), + ]); + }); + + test('서식 서명 — 문단에 margin: 0 을 앞에, 미리 보기는 글 줄과 서명 그림', () { + const String id = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d'; + final ComposeSignature signature = composeSignatureOf( + const MailSignature( + bodyHtml: + '

김아라 경영지원팀

' + '

로고

' + '

', + body: '김아라 경영지원팀', + ), + forReply: false, + )!; + expect( + signature.html, + startsWith('

김아라'), + ); + expect(signature.html, contains('

[ + 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 person(String name, String email) => { + 'name': name, + 'email': email, + }; + + List emails(List list) => [ + for (final PersonAddress address in list) address.email, + ]; + + test('답장은 보낸 사람(Reply-To 가 있으면 그 주소), 전체 답장은 받는 사람 · 참조까지 — 나는 뺀다', () { + final MessageDetail message = _detail({ + 'from': person('이도윤', 'doyun@x.kr'), + 'to': [ + person('김아라', 'ARA.KIM@arasystem.it.kr'), + person('박지훈', 'jihoon@x.kr'), + ], + 'cc': [ + person('최서연', 'seoyeon@x.kr'), + person('', 'jihoon@x.kr'), + ], + }); + final ({List to, List cc}) reply = + buildReplyRecipients(message, me, all: false); + expect(emails(reply.to), ['doyun@x.kr']); + expect(reply.cc, isEmpty); + + final ({List to, List cc}) all = + buildReplyRecipients(message, me, all: true); + expect(emails(all.to), ['doyun@x.kr', 'jihoon@x.kr']); + expect(emails(all.cc), ['seoyeon@x.kr']); + + final MessageDetail withReplyTo = _detail({ + 'from': person('이도윤', 'doyun@x.kr'), + 'replyTo': [person('지원팀', 'help@x.kr')], + }); + expect( + emails(buildReplyRecipients(withReplyTo, me, all: false).to), + ['help@x.kr'], + ); + }); + + test('내가 보낸 메일에 답장하면 원래 받는 사람에게, 나에게만 보냈으면 나에게', () { + final MessageDetail own = _detail({ + 'from': person('김아라', me), + 'to': [person('이도윤', 'doyun@x.kr')], + 'replyTo': [person('다른 곳', 'else@x.kr')], + }); + expect(emails(buildReplyRecipients(own, me, all: false).to), [ + 'doyun@x.kr', + ]); + final MessageDetail toSelf = _detail({ + 'from': person('김아라', me), + 'to': [person('김아라', me)], + }); + expect(emails(buildReplyRecipients(toSelf, me, all: false).to), [ + me, + ]); + }); + + test('Reply-To 가 나뿐이면 원래 받는 사람에게', () { + final MessageDetail message = _detail({ + 'from': person('이도윤', 'doyun@x.kr'), + 'replyTo': [person('김아라', me)], + 'to': [person('김아라', me), person('박지훈', 'jihoon@x.kr')], + }); + expect(emails(buildReplyRecipients(message, me, all: false).to), [ + '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({ + 'from': person('이도윤', 'doyun@x.kr'), + 'to': [person('김아라', me)], + 'cc': [person('', 'cc@x.kr')], + 'subject': '', + 'sentAt': _localIso(2026, 11, 5, 10, 42), + }); + expect(replyQuoteRows(message), const [ + QuoteHeaderRow('보낸 사람', '이도윤 '), + QuoteHeaderRow('날짜', '2026. 11. 5. (목) 오전 10:42'), + QuoteHeaderRow('제목', '(제목 없음)'), + ]); + expect(forwardQuoteRows(message), const [ + QuoteHeaderRow('보낸 사람', '이도윤 '), + QuoteHeaderRow('날짜', '2026. 11. 5. (목) 오전 10:42'), + QuoteHeaderRow('받는 사람', '김아라 '), + 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 [ + 'MAIL_001', + 'MAIL_002', + 'SYS_001', + 'REQ_001', + 'MAIL_007', + ]) { + expect(shouldRenewSendKey(code), isFalse, reason: code); + } + }); + + test('받지 못한 사람 — 두 명까지 주소 · 까닭, 나머지는 "외 N명"', () { + expect(partialSendMessage(const []), isNull); + expect( + partialSendMessage(const [ + 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( + 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(email: 'a@x.kr', name: '가')], + cc: [], + bcc: [], + subject: '제목', + body: '본문', + draftId: 'd1', + ); + expect(payload.toJson(), { + 'to': [ + {'name': '가', 'email': 'a@x.kr'}, + ], + 'cc': [], + 'bcc': [], + 'subject': '제목', + 'body': '본문', + 'attachments': [], + 'draftId': 'd1', + }); + expect(payload.withoutDraftId().toJson().containsKey('draftId'), isFalse); + + const MailComposePayload full = MailComposePayload( + to: [], + cc: [], + bcc: [], + subject: '', + body: 'x', + bodyHtml: '

x
', + attachments: [(id: 'att1', name: '계약.pdf')], + inReplyTo: 'm1', + largeFileIds: ['u1'], + bodyQuoteStart: 3, + ); + expect(full.toJson(), containsPair('bodyHtml', '
x
')); + expect(full.toJson(), containsPair('inReplyTo', 'm1')); + expect(full.toJson(), containsPair('largeFileIds', ['u1'])); + expect(full.toJson(), containsPair('bodyQuoteStart', 3)); + expect(full.toJson()['attachments'], [ + {'id': 'att1', 'name': '계약.pdf'}, + ]); + }); + + test('저장본 응답 — 대용량 파일 상태 · 위험 판정을 읽는다(모양이 틀린 항목만 버린다)', () { + final Draft? draft = Draft.tryParse({ + 'id': 'd1', + 'to': [], + 'subject': '', + 'body': '', + 'updatedAt': '2026-11-05T01:00:00.000Z', + 'largeFiles': [ + { + 'id': 'u1', + 'status': 'ready', + 'name': 'a.zip', + 'size': 10, + 'scanStatus': 'infected', + }, + {'id': 'u2', 'status': 'missing'}, + {'id': 'u3', 'status': 'weird'}, + ], + }); + expect(draft, isNotNull); + expect(draft!.cc, isEmpty); + expect(draft.largeFiles, const [ + DraftLargeFile( + id: 'u1', + status: DraftLargeFileStatus.ready, + name: 'a.zip', + size: 10, + infected: true, + ), + DraftLargeFile(id: 'u2', status: DraftLargeFileStatus.missing), + ]); + expect(Draft.tryParse({'id': 'd1'}), isNull); + }); + }); +} diff --git a/flutter/test/features/mail/draft_autosave_test.dart b/flutter/test/features/mail/draft_autosave_test.dart new file mode 100644 index 0000000..357af43 --- /dev/null +++ b/flutter/test/features/mail/draft_autosave_test.dart @@ -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 calls = []; + + /// 다음 저장들이 이 코드로 실패한다(차례대로 하나씩 쓴다) + final List failures = []; + Completer? gate; + int _next = 0; + + Future save(String call) async { + calls.add(call); + final Completer? 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: [], + cc: [], + bcc: [], + 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, ['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, ['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, ['POST']); + h.edit(); + await tester.pump(const Duration(seconds: 3)); + // 첫 저장을 시작한 지 3초 — 아직 문이 닫혀 있다(시작한 뒤 10초에 열린다) + expect(h.server.calls, ['POST']); + await tester.pump(const Duration(seconds: 6)); + expect(h.server.calls, ['POST']); + await tester.pump(const Duration(seconds: 1)); + expect(h.server.calls, ['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, ['POST']); + h.autosave.dispose(); + }); + + testWidgets('한 번에 하나 — 저장하는 동안 바뀐 것은 끝난 뒤 다시', (WidgetTester tester) async { + final _Harness h = _Harness(); + h.server.gate = Completer(); + 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, ['POST']); + h.server.gate!.complete(); + h.server.gate = null; + await tester.pump(); + // 끝났지만 그사이 바뀐 것이 남았다 — 문(10초)이 이미 열렸으므로 바로 + expect(h.server.calls, ['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, ['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, ['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, ['POST']); + h.autosave.dispose(); + }); + + testWidgets('다시 해 보면 나아질 실패는 15 · 30 · 60초 뒤 저절로, 그 밖은 바뀔 때까지 기다린다', ( + WidgetTester tester, + ) async { + final _Harness h = _Harness(); + h.server.failures.addAll(['MAIL_001', 'SYS_001']); + h.edit(); + await tester.pump(const Duration(seconds: 3)); + expect(h.server.calls, ['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, ['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, ['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(); + 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, ['POST']); + h.server.gate!.complete(); + h.server.gate = null; + await tester.pump(); + await tester.pump(); + expect(h.server.calls, ['POST', 'PUT d1']); + expect(result, isTrue); + h.autosave.dispose(); + }); + + testWidgets('멈춘 동안(보내기 · 나가기 확인)은 저장하지 않고, 진행 중인 저장은 settle 로 기다린다', ( + WidgetTester tester, + ) async { + final _Harness h = _Harness(); + h.server.gate = Completer(); + 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, ['POST']); + + h.autosave.resume(); + await tester.pump(const Duration(seconds: 3)); + expect(h.server.calls, ['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, ['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, ['PUT d0', 'POST', 'PUT d1']); + }); +} diff --git a/flutter/test/features/mail/mail_list_page_test.dart b/flutter/test/features/mail/mail_list_page_test.dart index 7a7d5b2..5b2253f 100644 --- a/flutter/test/features/mail/mail_list_page_test.dart +++ b/flutter/test/features/mail/mail_list_page_test.dart @@ -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(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(); } diff --git a/flutter/test/features/mail/message_compose_page_test.dart b/flutter/test/features/mail/message_compose_page_test.dart new file mode 100644 index 0000000..915bb8d --- /dev/null +++ b/flutter/test/features/mail/message_compose_page_test.dart @@ -0,0 +1,1113 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/api_paths.dart'; +import 'package:arasystem_app/core/api/error_lexicon.dart'; +import 'package:arasystem_app/core/people/people_texts.dart'; +import 'package:arasystem_app/core/theme/app_colors.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_provider.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_request.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/mail/presentation/providers/compose_controller.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/compose_state.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/compose_recipient_field.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/mail_compose_area.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/reader_bars.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/misc.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/compose_fakes.dart'; +import '../../support/fakes.dart'; +import '../../support/login_fakes.dart'; +import '../../support/mail_fakes.dart'; +import '../../support/mail_harness.dart'; + +/// 쓰기 화면 — 앱 보드 `App1Compose` (가짜 서버 · 가짜 시계로 앱 전체를 띄운다) +void main() { + late MailAppHarness h; + late FakeComposeBackend c; + + // 원문 인용 · 저장본 본문은 시험에서 늘 이 isolate 에서 만든다(가짜 시계 안에서 다른 isolate 를 기다리지 않게) + Future inlineQuote(ComposeQuoteRequest request) async => + initialQuoteOf(request); + + /// 저장본 본문 읽기 — 시험이 바꿔 끼운다(실패 · 다시 시도) + late DraftBodyBuilder draftBuilder; + + setUp(() { + h = MailAppHarness(); + c = FakeComposeBackend(h.server, h.mail); + draftBuilder = ({required String? bodyHtml, required String body}) async => + draftBodyOf(bodyHtml: bodyHtml, body: body); + h.overrides.addAll([ + composeQuoteBuilderProvider.overrideWithValue(inlineQuote), + composeDraftBodyBuilderProvider.overrideWithValue( + ({required String? bodyHtml, required String body}) => + draftBuilder(bodyHtml: bodyHtml, body: body), + ), + ]); + h.server.serverTime = kTestNow; + }); + + 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}$', + ); + + Future start(WidgetTester tester) async { + c.install(); + if (!h.mail.messages.containsKey(kInboxId)) h.mail.seed(kInboxId, 2); + await h.pump(tester); + } + + Future open( + WidgetTester tester, + String location, { + Object? extra, + }) async { + unawaited(h.router.push(location, extra: extra)); + await tester.pumpAndSettle(); + } + + Finder field(RecipientFieldKind kind) => find.descendant( + of: find.byKey(ValueKey(kind)), + matching: find.byType(TextField), + ); + final Finder subjectField = find.byKey( + const ValueKey('compose-subject'), + ); + final Finder bodyField = find.byKey(const ValueKey('compose-body')); + + /// 칩 · 칸 안 글 + Finder chip(String text) => find.descendant( + of: find.byType(ComposeRecipientField), + matching: find.text(text), + ); + String textOf(WidgetTester tester, Finder finder) => + tester.widget(finder).controller!.text; + bool focused(WidgetTester tester, Finder finder) => + tester.widget(finder).focusNode!.hasFocus; + Finder snack(String text) => + find.descendant(of: find.byType(SnackBar), matching: find.text(text)); + Finder toast(String text) => find.descendant( + of: find.byType(MailToastCard), + matching: find.text(text), + ); + + Future tapTooltip(WidgetTester tester, String tooltip) async { + await tester.tap(find.byTooltip(tooltip)); + await tester.pumpAndSettle(); + } + + Future addRecipient(WidgetTester tester, String address) async { + await tester.enterText(field(RecipientFieldKind.to), '$address,'); + await tester.pump(); + } + + group('열기', () { + testWidgets( + '새 메일 — "새 메일" · 받는 사람 칸에 초점 · 추천 · 그룹을 미리 받는다 · [파일 첨부]는 준비 중', + (WidgetTester tester) async { + await start(tester); + await open(tester, MailRoutes.compose); + + expect(find.text(MailComposeTexts.newMail), findsOneWidget); + expect(find.byType(NavigationBar), findsNothing); + expect(focused(tester, field(RecipientFieldKind.to)), isTrue); + expect(c.suggestionRequests.single.queryParameters, { + 'limit': 100, + }); + expect(h.server.received('GET', '/contact-groups'), hasLength(1)); + // 참조 · 숨은참조는 접혀 있다 + expect( + find.byKey(const ValueKey(RecipientFieldKind.cc)), + findsNothing, + ); + + await tapTooltip(tester, MailComposeTexts.attach); + expect(snack(MailComposeTexts.attachNotReady), findsOneWidget); + + await tapTooltip(tester, MailComposeTexts.expandCopies); + expect(field(RecipientFieldKind.cc), findsOneWidget); + expect(field(RecipientFieldKind.bcc), findsOneWidget); + expect(find.byTooltip(MailComposeTexts.collapseCopies), findsOneWidget); + }, + ); + + testWidgets('mailto 로 연 새 메일 — 받는 사람 · 숨은참조(펼쳐 보인다) · 제목 · 본문을 채운다', ( + WidgetTester tester, + ) async { + await start(tester); + await open( + tester, + MailRoutes.compose, + extra: const ComposePrefill( + to: ['kim@example.com'], + bcc: ['hidden@example.com'], + subject: 'hi', + body: '본문', + ), + ); + + expect(chip('kim@example.com'), findsOneWidget); + expect(chip('hidden@example.com'), findsOneWidget); + expect(textOf(tester, subjectField), 'hi'); + expect(textOf(tester, bodyField), '본문'); + // 받는 사람 · 제목이 있으니 본문에 초점 + expect(focused(tester, bodyField), isTrue); + }); + + testWidgets( + '답장 — 원본과 서명을 받아 채운다: 받는 사람 · "Re:" 제목 · 서명 · 원본 메일(접힘 — 누르면 펼친다)', + (WidgetTester tester) async { + c.signature = { + 'bodyHtml': null, + 'body': '김아라\n경영지원팀', + 'isForNew': false, + 'isForReply': true, + }; + h.mail.put( + kClientsId, + messageJson( + 'r1', + mailboxId: kClientsId, + subject: '유지보수 계약', + isRead: true, + ), + detail: {'textBody': '원문 첫 줄\n\n> 앞선 글'}, + ); + await start(tester); + await open(tester, MailRoutes.composeReply('r1')); + + expect(find.text(MailComposeTexts.reply), findsOneWidget); + expect(chip('이도윤'), findsOneWidget); + expect(textOf(tester, subjectField), 'Re: 유지보수 계약'); + expect(focused(tester, bodyField), isTrue); + expect(find.text('김아라'), findsOneWidget); + expect(find.text('경영지원팀'), findsOneWidget); + expect(find.textContaining('이도윤 · '), findsOneWidget); + expect(find.text('원문 첫 줄'), findsNothing); + + await tester.tap(find.text(MailComposeTexts.quote).first); + await tester.pumpAndSettle(); + expect(find.text('원문 첫 줄'), findsOneWidget); + expect(find.text('> 앞선 글'), findsOneWidget); + expect(find.text('보낸 사람'), findsOneWidget); + expect(find.text('이도윤 '), findsOneWidget); + expect(find.text('유지보수 계약'), findsOneWidget); + }, + ); + + testWidgets('전달 — 원본 첨부를 싣는다(형식이 맞지 않는 첨부는 까닭을 적고 싣지 않는다)', ( + WidgetTester tester, + ) async { + h.mail.put( + kInboxId, + messageJson('f1', mailboxId: kInboxId, subject: '견적', isRead: true), + detail: { + 'attachments': [ + attachmentJson('a1', name: '견적서.pdf'), + attachmentJson( + 'a2', + name: 'setup.exe', + type: 'application/octet-stream', + ), + attachmentJson( + 'a3', + name: 'logo.png', + type: 'image/png', + isInline: true, + size: 1024, + ), + ], + }, + ); + await start(tester); + await open(tester, MailRoutes.composeForward('f1')); + + expect(find.text(MailComposeTexts.forward), findsOneWidget); + expect(textOf(tester, subjectField), 'Fwd: 견적'); + expect(focused(tester, field(RecipientFieldKind.to)), isTrue); + expect(find.text(MailComposeTexts.attachmentsTitle(3)), findsOneWidget); + expect(findCopy(MailComposeTexts.unsendable), findsOneWidget); + + await tapTooltip(tester, MailComposeTexts.remove('logo.png')); + expect(find.text('logo.png'), findsNothing); + expect(find.text(MailComposeTexts.attachmentsTitle(2)), findsOneWidget); + + await addRecipient(tester, 'doyun@x.kr'); + await tapTooltip(tester, MailComposeTexts.send); + + final Map body = c.sends.single.body; + expect(body['attachments'], [ + {'id': 'a1', 'name': '견적서.pdf'}, + ]); + expect(body.containsKey('inReplyTo'), isFalse); + expect(body['subject'], 'Fwd: 견적'); + }); + + testWidgets('원본을 받지 못하면 [다시 시도] · 없는 저장본이면 [닫기]', ( + WidgetTester tester, + ) async { + c.putDraft( + 'd3', + to: >[addressJson('박준호', 'junho@example.com')], + ); + int calls = 0; + h.server.on('GET', '/drafts/d3', (_) { + calls++; + return calls == 1 + ? FakeResponse.fail(503, 'MAIL_001') + : FakeResponse.ok(c.drafts['d3']); + }); + await start(tester); + await open(tester, MailRoutes.composeDraft('d3')); + + expect(find.text(MailComposeTexts.loadingTitle), findsOneWidget); + expect(findCopy(ErrorLexicon.getMessage('MAIL_001')), findsOneWidget); + // 저장본은 화면이 알리므로 전역 알림은 없다 + expect(h.notifier.shownCodes, isEmpty); + + await tester.tap(find.text(MailListTexts.retry)); + await tester.pumpAndSettle(); + expect(chip('박준호'), findsOneWidget); + expect(find.text(MailComposeTexts.newMail), findsOneWidget); + + h.router.pop(); + await tester.pumpAndSettle(); + await open(tester, MailRoutes.composeDraft('gone')); + expect(findCopy(ErrorLexicon.getMessage('RES_001')), findsOneWidget); + await tester.tap(find.widgetWithText(TextButton, MailComposeTexts.close)); + await tester.pumpAndSettle(); + expect(find.byType(MessageComposePage), findsNothing); + expect(find.byType(MailListPage), findsOneWidget); + }); + }); + + group('리뷰 고침 (쓰기 화면 1차 리뷰)', () { + testWidgets('저장본 본문을 읽다 실패하면(네트워크 밖의 실패) 뼈대에 머물지 않고 [다시 시도]', ( + WidgetTester tester, + ) async { + c.putDraft( + 'd8', + to: >[addressJson('박준호', 'junho@example.com')], + subject: '읽기 실패', + bodyHtml: '

글

', + body: '글', + ); + draftBuilder = + ({required String? bodyHtml, required String body}) async => + throw StateError('읽지 못함'); + await start(tester); + await open(tester, MailRoutes.composeDraft('d8')); + + expect(findCopy(ErrorLexicon.getMessage('SYS_001')), findsOneWidget); + expect(find.text(MailListTexts.retry), findsOneWidget); + + draftBuilder = + ({required String? bodyHtml, required String body}) async => + draftBodyOf(bodyHtml: bodyHtml, body: body); + await tester.tap(find.text(MailListTexts.retry)); + await tester.pumpAndSettle(); + expect(chip('박준호'), findsOneWidget); + expect(textOf(tester, subjectField), '읽기 실패'); + }); + + testWidgets('진행 중인 저장을 기다리는 사이 떠나도 그대로 보낸다 — 보내는 동안은 칸을 막는다', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + await addRecipient(tester, 'doyun@x.kr'); + await tester.enterText(bodyField, '느린 저장 뒤에 보낼 글'); + final Completer gate = Completer(); + c.saveGate = gate; + await tester.pump(const Duration(seconds: 3)); + expect(c.saves.single.call, 'POST'); + + await tester.tap(find.byTooltip(MailComposeTexts.send)); + await tester.pump(); + expect(find.bySemanticsLabel(MailComposeTexts.sending), findsOneWidget); + expect(tester.widget(bodyField).enabled, isFalse); + expect(tester.widget(subjectField).enabled, isFalse); + expect( + tester.widget(field(RecipientFieldKind.to)).enabled, + isFalse, + ); + + await tapTooltip(tester, MailComposeTexts.close); + expect(find.byType(MessageComposePage), findsNothing); + expect(c.sends, isEmpty); + + gate.complete(); + await tester.pumpAndSettle(); + expect(c.sends, hasLength(1)); + expect(c.sends.single.body['draftId'], 'saved-1'); + expect(c.sends.single.body['body'], '느린 저장 뒤에 보낼 글'); + expect(toast(MailComposeTexts.sent), findsOneWidget); + }); + + testWidgets('떠난 뒤 기다린 저장이 첨부를 찾지 못하면 보내지 않고 그 코드의 사전 문구로 알린다', ( + WidgetTester tester, + ) async { + h.mail.put( + kInboxId, + messageJson('f2', mailboxId: kInboxId, subject: '자료', isRead: true), + detail: { + 'attachments': [attachmentJson('a1', name: '견적서.pdf')], + }, + ); + await start(tester); + await open(tester, MailRoutes.composeForward('f2')); + await addRecipient(tester, 'doyun@x.kr'); + await tester.enterText(bodyField, '자료 보냅니다'); + final Completer gate = Completer(); + c.saveGate = gate; + await tester.pump(const Duration(seconds: 3)); + await tester.tap(find.byTooltip(MailComposeTexts.send)); + await tester.pump(); + await tapTooltip(tester, MailComposeTexts.close); + + c.draftFailures.add(FakeResponse.fail(400, 'MAIL_004')); + gate.complete(); + await tester.pumpAndSettle(); + + expect(c.sends, isEmpty); + expect(h.notifier.shownCodes, ['MAIL_004']); + // 찾지 못한 첨부가 있으면 저장본으로도 남기지 않는다(서버가 같은 까닭으로 거절한다) + expect(c.saves, hasLength(1)); + }); + + testWidgets('대용량 파일을 다시 묻는 사이 뺀 파일은 되살아나지 않는다(MAIL_016)', ( + WidgetTester tester, + ) async { + Map file(String id) => { + 'id': id, + 'status': 'ready', + 'name': '$id.zip', + 'size': 1048576, + }; + c.putDraft( + 'd9', + to: >[addressJson('박준호', 'junho@example.com')], + subject: '자료', + body: '본문', + largeFiles: >[file('u1'), file('u2')], + ); + c.largeFiles['u1'] = file('u1'); + c.largeFiles['u2'] = file('u2'); + c.sendFailures.add(FakeResponse.fail(400, 'MAIL_016')); + final Completer gate = Completer(); + c.largeFileGate = gate; + await start(tester); + await open(tester, MailRoutes.composeDraft('d9')); + + await tester.tap(find.byTooltip(MailComposeTexts.send)); + await tester.pumpAndSettle(); + expect(c.sends, hasLength(1)); + // 다시 묻는 동안 u2 를 뺀다 + await tapTooltip(tester, MailComposeTexts.remove('u2.zip')); + expect(find.text('u2.zip'), findsNothing); + + gate.complete(); + await tester.pumpAndSettle(); + expect(find.text('u1.zip'), findsOneWidget); + expect(find.text('u2.zip'), findsNothing); + expect(snack(ErrorLexicon.getMessage('MAIL_016')), findsOneWidget); + }); + + testWidgets('나가기 확인 창이 떠 있는 동안 세션이 끝나면 창만 닫고 "저장했습니다"를 알리지 않는다', ( + WidgetTester tester, + ) async { + h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok()); + await start(tester); + await open(tester, MailRoutes.compose); + await tester.enterText(bodyField, '저장하지 못한 글'); + c.draftFailures.add(FakeResponse.fail(503, 'MAIL_001')); + await tapTooltip(tester, MailComposeTexts.close); + expect(find.text(MailComposeTexts.leaveTitle), findsOneWidget); + + unawaited(h.container.read(sessionProvider.notifier).signOut()); + await tester.pumpAndSettle(); + + expect(find.text(MailComposeTexts.leaveTitle), findsNothing); + expect(snack(MailComposeTexts.savedOnLeave), findsNothing); + expect(c.saves, hasLength(1)); + }); + + testWidgets('떠나기 전 저장을 기다리는 동안은 [보내기]를 막는다', (WidgetTester tester) async { + await start(tester); + await open(tester, MailRoutes.compose); + await addRecipient(tester, 'doyun@x.kr'); + await tester.enterText(bodyField, '떠나며 저장할 글'); + final Completer gate = Completer(); + c.saveGate = gate; + + await tester.tap(find.byTooltip(MailComposeTexts.close)); + await tester.pump(); + await tester.tap(find.byTooltip(MailComposeTexts.send)); + await tester.pump(); + expect(c.sends, isEmpty); + expect(find.bySemanticsLabel(MailComposeTexts.sending), findsNothing); + + gate.complete(); + await tester.pumpAndSettle(); + expect(find.byType(MessageComposePage), findsNothing); + expect(snack(MailComposeTexts.savedOnLeave), findsOneWidget); + expect(c.sends, isEmpty); + }); + }); + + group('받는 사람', () { + testWidgets('쉼표 · [다음]에 칩 — 붙여 넣은 여러 줄도 나누고, 틀린 주소는 빨간 칩, ✕ 로 뺀다', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + + await addRecipient(tester, 'doyun@x.kr'); + expect(chip('doyun@x.kr'), findsOneWidget); + expect(textOf(tester, field(RecipientFieldKind.to)), isEmpty); + + await tester.enterText( + field(RecipientFieldKind.to), + '"Kim, Ara" \njihoon@x.kr\nbad@', + ); + await tester.pump(); + expect(chip('Kim, Ara'), findsOneWidget); + expect(chip('jihoon@x.kr'), findsOneWidget); + expect(textOf(tester, field(RecipientFieldKind.to)), 'bad@'); + + await tester.testTextInput.receiveAction(TextInputAction.next); + await tester.pump(); + expect(chip('bad@'), findsOneWidget); + expect(tester.widget(chip('bad@')).style?.color, AppColors.danger); + + // 틀린 주소가 있으면 보내지 않고 칸 아래 띠 + await tapTooltip(tester, MailComposeTexts.send); + expect(c.sends, isEmpty); + expect(findCopy(ErrorLexicon.getMessage('MAIL_002')), findsOneWidget); + + await tapTooltip(tester, MailComposeTexts.remove('bad@')); + expect(chip('bad@'), findsNothing); + expect(findCopy(ErrorLexicon.getMessage('MAIL_002')), findsNothing); + }); + + testWidgets('받는 사람이 없으면 보내지 않고 칸 아래 띠 · 받는 사람 칸으로 초점', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + await tester.tap(subjectField); + await tester.pump(); + + await tapTooltip(tester, MailComposeTexts.send); + + expect(c.sends, isEmpty); + expect(findCopy(ErrorLexicon.getMessage('MAIL_002')), findsOneWidget); + expect(focused(tester, field(RecipientFieldKind.to)), isTrue); + }); + + testWidgets( + '추천 — 묶음 이름 줄(그룹 · 사내 · 최근 주고받은 주소), 고르면 칩 · 그룹은 구성원을 넣고 칸 아래에 알린다', + (WidgetTester tester) async { + c.suggestions = >[ + { + 'name': '이도윤', + 'email': 'doyun.lee@arasystem.it.kr', + 'source': 'directory', + }, + { + 'name': '최도윤', + 'email': 'choi@partner.example', + 'source': 'recent', + }, + ]; + c.suggestionsHasNext = true; + c.groups = >[ + {'id': 'g1', 'name': '도윤 팀', 'memberCount': 2}, + ]; + c.members['g1'] = >[ + { + 'name': '박지훈', + 'emails': [ + {'address': 'jihoon@x.kr', 'label': 'work'}, + ], + }, + {'name': '주소 없음', 'emails': []}, + ]; + await start(tester); + await open(tester, MailRoutes.compose); + + await tester.enterText(field(RecipientFieldKind.to), '도윤'); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('그룹'), findsOneWidget); + expect(find.text('사내'), findsOneWidget); + expect(find.text('최근 주고받은 주소'), findsOneWidget); + expect(find.text(PeopleTexts.groupDetail(2)), findsOneWidget); + // 받아 둔 것이 모자라면 250ms 뒤 서버에도 묻는다 + expect(c.suggestionRequests.last.queryParameters['q'], '도윤'); + + await tester.tap(find.text('도윤 팀')); + await tester.pumpAndSettle(); + expect(chip('박지훈'), findsOneWidget); + expect( + findCopy('‘도윤 팀’ 그룹의 1명을 받는 사람에 넣었습니다. 주소가 없는 1명은 뺐습니다.'), + findsOneWidget, + ); + + await tester.enterText(field(RecipientFieldKind.to), '도윤'); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.text('이도윤')); + await tester.pumpAndSettle(); + expect(chip('이도윤'), findsOneWidget); + expect(textOf(tester, field(RecipientFieldKind.to)), isEmpty); + // 넣은 주소는 다시 보이지 않는다 + await tester.enterText(field(RecipientFieldKind.to), '도윤'); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('사내'), findsNothing); + expect(find.text('최근 주고받은 주소'), findsOneWidget); + }, + ); + + testWidgets('[다음]은 첫 추천을 넣는다 — 완전한 주소를 쳤으면 친 그대로', ( + WidgetTester tester, + ) async { + c.suggestions = >[ + { + 'name': '이도윤', + 'email': 'doyun.lee@arasystem.it.kr', + 'source': 'directory', + }, + ]; + await start(tester); + await open(tester, MailRoutes.compose); + + await tester.enterText(field(RecipientFieldKind.to), 'doyun'); + await tester.pump(const Duration(milliseconds: 300)); + await tester.testTextInput.receiveAction(TextInputAction.next); + await tester.pumpAndSettle(); + expect(chip('이도윤'), findsOneWidget); + + await tester.enterText( + field(RecipientFieldKind.to), + 'doyun.lee@other.example', + ); + await tester.pump(const Duration(milliseconds: 300)); + await tester.testTextInput.receiveAction(TextInputAction.next); + await tester.pumpAndSettle(); + expect(chip('doyun.lee@other.example'), findsOneWidget); + }); + }); + + group('보내기', () { + testWidgets( + '보내면 시작한 화면으로 — "메일을 보냈습니다." · [보내기 취소] · 남은 시간 막대, 본문은 서식 + 텍스트', + (WidgetTester tester) async { + c.signature = { + 'bodyHtml': null, + 'body': '김아라\n경영지원팀', + 'isForNew': true, + 'isForReply': true, + }; + await start(tester); + await open(tester, MailRoutes.compose); + + expect(find.text('김아라'), findsOneWidget); + await addRecipient(tester, 'doyun@x.kr'); + await tester.enterText(subjectField, '계약'); + await tester.enterText(bodyField, '안녕하세요'); + await tapTooltip(tester, MailComposeTexts.send); + + expect(find.byType(MessageComposePage), findsNothing); + expect(find.byType(MailListPage), findsOneWidget); + expect(toast(MailComposeTexts.sent), findsOneWidget); + expect(toast(MailComposeTexts.undoSend), findsOneWidget); + expect( + find.descendant( + of: find.byType(MailToastCard), + matching: find.byType(LinearProgressIndicator), + ), + findsOneWidget, + ); + + final SentRequest sent = c.sends.single; + expect(sent.key, matches(uuid)); + expect(sent.body['to'], [ + {'name': null, 'email': 'doyun@x.kr'}, + ]); + expect(sent.body['subject'], '계약'); + expect(sent.body['body'], '안녕하세요\n\n김아라\n경영지원팀'); + expect( + sent.body['bodyHtml'], + contains( + '

안녕하세요


' + '

김아라

경영지원팀

', + ), + ); + expect(sent.body.containsKey('draftId'), isFalse); + // 쓰는 동안 저장할 틈(3초)이 없었다 + expect(c.saves, isEmpty); + + // 마감(서버 시각으로 10초 − 2초)이 지나면 닫힌다 + await tester.pump(const Duration(seconds: 8)); + await tester.pumpAndSettle(); + expect(find.byType(MailToastCard), findsNothing); + }, + ); + + testWidgets('[보내기 취소] — 발송을 취소하고 그 메일로 쓰기 화면을 다시 연다(서명까지 돌아온다)', ( + WidgetTester tester, + ) async { + c.signature = { + 'bodyHtml': null, + 'body': '김아라', + 'isForNew': true, + 'isForReply': true, + }; + await start(tester); + await open(tester, MailRoutes.compose); + await addRecipient(tester, 'doyun@x.kr'); + await tester.enterText(subjectField, '계약'); + await tester.enterText(bodyField, '안녕하세요'); + await tapTooltip(tester, MailComposeTexts.send); + + await tester.tap(toast(MailComposeTexts.undoSend)); + await tester.pumpAndSettle(); + + expect(h.mail.cancellations, ['sent-1']); + expect(find.byType(MessageComposePage), findsOneWidget); + expect(snack(MailComposeTexts.undone), findsOneWidget); + expect(chip('doyun@x.kr'), findsOneWidget); + expect(textOf(tester, subjectField), '계약'); + expect(textOf(tester, bodyField), '안녕하세요'); + expect(find.byTooltip(MailComposeTexts.removeSignature), findsOneWidget); + + // 다시 보내면 새 키(새 양식)로, 이어 쓴 저장본 id 를 싣는다 + await tapTooltip(tester, MailComposeTexts.send); + expect(c.sends, hasLength(2)); + expect(c.sends.last.key, isNot(c.sends.first.key)); + expect(c.sends.last.body['draftId'], 'sent-1'); + }); + + testWidgets('바로 보냈으면(보내기 취소 0초) [보낸편지함 보기], 받지 못한 사람은 그 주소 · 까닭', ( + WidgetTester tester, + ) async { + c.undoSeconds = 0; + c.rejected = >[ + {'email': 'nobody@x.kr', 'reason': 'not-found'}, + ]; + await start(tester); + await open(tester, MailRoutes.compose); + await addRecipient(tester, 'doyun@x.kr'); + await addRecipient(tester, 'nobody@x.kr'); + await tapTooltip(tester, MailComposeTexts.send); + + expect(toast('보냈지만 1명은 받지 못했습니다. nobody@x.kr (없는 주소)'), findsOneWidget); + await tester.tap(toast(MailComposeTexts.viewSent)); + await tester.pumpAndSettle(); + expect(h.location(), MailRoutes.mailbox(kSentId)); + }); + + testWidgets('답장을 보내면 읽기 화면으로 — 알림은 아래 막대 위 · 본문에 원문 인용 · inReplyTo', ( + WidgetTester tester, + ) async { + h.mail.put( + kInboxId, + messageJson('r1', mailboxId: kInboxId, subject: '계약', isRead: true), + detail: {'textBody': '원문 줄'}, + ); + await start(tester); + await open(tester, MailRoutes.message('r1')); + await tester.tap(find.text(MailReaderTexts.reply)); + await tester.pumpAndSettle(); + await tester.enterText(bodyField, '확인했습니다'); + await tapTooltip(tester, MailComposeTexts.send); + + expect(find.byType(MessageReaderPage), findsOneWidget); + final Rect card = tester.getRect(find.byType(MailToastCard)); + final Rect bar = tester.getRect(find.byType(ReaderActionBar)); + expect(card.bottom, lessThanOrEqualTo(bar.top - 12)); + + final Map body = c.sends.single.body; + expect(body['inReplyTo'], 'r1'); + expect(body['subject'], 'Re: 계약'); + expect( + body['body'], + startsWith('확인했습니다\n> 원본 메일\n> 보낸 사람: 이도윤 \n'), + ); + expect(body['body'], endsWith('> 제목: 계약\n>\n> 원문 줄')); + expect( + body['bodyHtml'], + contains( + '

원본 메일

', + ), + ); + }); + + testWidgets( + '서버가 받는 사람을 거절하면(MAIL_002) 알림 없이 칸 아래 띠, 본문이 길면(MAIL_007) 본문 아래 띠', + (WidgetTester tester) async { + c.sendFailures.addAll([ + FakeResponse.fail(400, 'MAIL_002'), + FakeResponse.fail(400, 'MAIL_007'), + ]); + await start(tester); + await open(tester, MailRoutes.compose); + await addRecipient(tester, 'doyun@x.kr'); + await tester.enterText(bodyField, '본문'); + + await tapTooltip(tester, MailComposeTexts.send); + expect(find.byType(MessageComposePage), findsOneWidget); + expect(findCopy(ErrorLexicon.getMessage('MAIL_002')), findsOneWidget); + expect(focused(tester, field(RecipientFieldKind.to)), isTrue); + + await tapTooltip(tester, MailComposeTexts.send); + expect(findCopy(ErrorLexicon.getMessage('MAIL_002')), findsNothing); + expect(findCopy(ErrorLexicon.getMessage('MAIL_007')), findsOneWidget); + expect(focused(tester, bodyField), isTrue); + expect(h.notifier.shownCodes, isEmpty); + // 칸 안 실패는 같은 키로 다시 보낸다 + expect(c.sends[0].key, c.sends[1].key); + }, + ); + + testWidgets('그 밖의 실패는 알림 · 같은 키로 다시 보낸다, MAIL_005 뒤에는 새 키', ( + WidgetTester tester, + ) async { + c.sendFailures.addAll([ + FakeResponse.fail(503, 'MAIL_001'), + FakeResponse.fail(409, 'MAIL_005'), + ]); + await start(tester); + await open(tester, MailRoutes.compose); + await addRecipient(tester, 'doyun@x.kr'); + + await tapTooltip(tester, MailComposeTexts.send); + expect(h.notifier.shownCodes, ['MAIL_001']); + await tapTooltip(tester, MailComposeTexts.send); + await tapTooltip(tester, MailComposeTexts.send); + + expect(c.sends, hasLength(3)); + expect(c.sends[1].key, c.sends[0].key); + expect(c.sends[2].key, isNot(c.sends[1].key)); + expect(find.byType(MessageComposePage), findsNothing); + }); + + testWidgets('보내는 동안 떠나도 응답까지 기다려 알린다 — 떠난 뒤 실패하면 쓰던 내용을 저장본으로', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + await addRecipient(tester, 'doyun@x.kr'); + await tester.enterText(bodyField, '첫 메일'); + final Completer gate = Completer(); + c.sendGate = gate; + await tester.tap(find.byTooltip(MailComposeTexts.send)); + await tester.pump(); + expect(find.bySemanticsLabel(MailComposeTexts.sending), findsOneWidget); + + await tapTooltip(tester, MailComposeTexts.close); + expect(find.byType(MessageComposePage), findsNothing); + expect(find.text(MailComposeTexts.leaveTitle), findsNothing); + gate.complete(); + await tester.pumpAndSettle(); + expect(toast(MailComposeTexts.sent), findsOneWidget); + expect(c.saves, isEmpty); + + // 두 번째 — 떠난 뒤 받는 사람 거절(칸 안 실패) → 알림으로, 쓰던 내용은 저장본으로 + await open(tester, MailRoutes.compose); + await addRecipient(tester, 'jihoon@x.kr'); + await tester.enterText(bodyField, '둘째 메일'); + final Completer second = Completer(); + c.sendGate = second; + c.sendFailures.add(FakeResponse.fail(400, 'MAIL_002')); + await tester.tap(find.byTooltip(MailComposeTexts.send)); + await tester.pump(); + await tapTooltip(tester, MailComposeTexts.close); + second.complete(); + await tester.pumpAndSettle(); + + expect(h.notifier.shownCodes, ['MAIL_002']); + expect(c.saves.single.call, 'POST'); + expect(c.saves.single.body['body'], '둘째 메일'); + expect(c.saves.single.body.containsKey('draftId'), isFalse); + }); + + testWidgets('웹에서 올리다 멈춘 대용량 파일이 있으면 보내지 않고 까닭을 알린다 — 빼면 보낸다', ( + WidgetTester tester, + ) async { + c.putDraft( + 'd5', + to: >[addressJson('박준호', 'junho@example.com')], + subject: '자료', + body: '본문', + largeFiles: >[ + { + 'id': 'u1', + 'status': 'uploading', + 'name': 'big.zip', + 'size': 31457280, + }, + { + 'id': 'u2', + 'status': 'ready', + 'name': 'ok.zip', + 'size': 1048576, + }, + ], + ); + await start(tester); + await open(tester, MailRoutes.composeDraft('d5')); + + expect(find.text('big.zip'), findsOneWidget); + expect(find.text('30 MB · ${MailReaderTexts.largeFile}'), findsOneWidget); + expect(findCopy(MailComposeTexts.largeUploading), findsOneWidget); + + await tapTooltip(tester, MailComposeTexts.send); + expect(snack(MailComposeTexts.largeUploading), findsOneWidget); + expect(c.sends, isEmpty); + + await tapTooltip(tester, MailComposeTexts.remove('big.zip')); + await tapTooltip(tester, MailComposeTexts.send); + final Map body = c.sends.single.body; + expect(body['largeFileIds'], ['u2']); + expect(body['draftId'], isNotNull); + }); + + testWidgets('저장본의 제목이 255자를 넘으면 보내지 않고 제목 아래 띠(VAL_001) · 제목으로 초점', ( + WidgetTester tester, + ) async { + c.putDraft( + 'd6', + to: >[addressJson('박준호', 'junho@example.com')], + subject: 'a' * 300, + ); + await start(tester); + await open(tester, MailRoutes.composeDraft('d6')); + + await tapTooltip(tester, MailComposeTexts.send); + expect(c.sends, isEmpty); + expect(findCopy(ErrorLexicon.getMessage('VAL_001')), findsOneWidget); + expect(focused(tester, subjectField), isTrue); + }); + }); + + group('임시 저장 · 나가기', () { + testWidgets('이어 쓰기 — 칸 · 서명을 되살리고, 바꾸면 3초 뒤 PUT {id}. [서명 빼기] 뒤에는 [서명 넣기]', ( + WidgetTester tester, + ) async { + c.putDraft( + 'd1', + to: >[addressJson('박준호', 'junho@example.com')], + subject: '쓰던 메일', + body: '쓰던 본문\n\n김아라', + bodyHtml: + '

쓰던 본문

' + '


김아라

', + ); + await start(tester); + await open(tester, MailRoutes.composeDraft('d1')); + + expect(find.text(MailComposeTexts.newMail), findsOneWidget); + expect(chip('박준호'), findsOneWidget); + expect(textOf(tester, subjectField), '쓰던 메일'); + expect(textOf(tester, bodyField), '쓰던 본문'); + expect(find.text('김아라'), findsOneWidget); + expect(c.saves, isEmpty); + + await tapTooltip(tester, MailComposeTexts.removeSignature); + expect(find.text('김아라'), findsNothing); + expect(find.text(MailComposeTexts.insertSignature), findsOneWidget); + await tester.pump(const Duration(seconds: 3)); + expect(c.saves.single.call, 'PUT d1'); + expect(c.saves.single.body['body'], '쓰던 본문'); + + await tester.tap(find.text(MailComposeTexts.insertSignature)); + await tester.pumpAndSettle(); + expect(find.text('김아라'), findsOneWidget); + + // 바꾼 것이 남은 채 닫으면 저장하고 닫는다 — 간격(10초)을 기다리지 않는다 + await tapTooltip(tester, MailComposeTexts.close); + expect(c.saves.last.call, 'PUT saved-1'); + expect(find.byType(MessageComposePage), findsNothing); + expect(snack(MailComposeTexts.savedOnLeave), findsOneWidget); + }); + + testWidgets('서식이 있는 남의 저장본 — 안내 줄, 본문을 고치지 않으면 원래 HTML 그대로 저장한다', ( + WidgetTester tester, + ) async { + const String html = '

안녕 굵게

'; + c.putDraft( + 'd2', + to: >[addressJson('박준호', 'junho@example.com')], + subject: '서식', + body: '안녕 굵게', + bodyHtml: html, + ); + await start(tester); + await open(tester, MailRoutes.composeDraft('d2')); + + expect(findCopy(MailComposeTexts.foreignDraft), findsOneWidget); + expect(textOf(tester, bodyField), '안녕 굵게'); + + await tester.enterText(subjectField, '서식 고침'); + await tester.pump(const Duration(seconds: 3)); + expect(c.saves.single.body['bodyHtml'], html); + expect(c.saves.single.body['body'], '안녕 굵게'); + + await tester.enterText(bodyField, '안녕 굵게 더함'); + await tester.pump(const Duration(seconds: 10)); + expect(c.saves.last.body['bodyHtml'], isNot(html)); + expect(c.saves.last.body['body'], '안녕 굵게 더함'); + }); + + testWidgets('바꾼 것이 없으면 뒤로 가기에 바로 닫힌다(묻지 않고 알리지 않는다)', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.byType(MessageComposePage), findsNothing); + expect(find.text(MailComposeTexts.leaveTitle), findsNothing); + expect(find.byType(SnackBar), findsNothing); + expect(c.saves, isEmpty); + }); + + testWidgets('바꾼 채 뒤로 가기 — 쓰다 만 주소까지 칩으로 만들어 저장한 뒤 닫고 알린다', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + await tester.enterText(field(RecipientFieldKind.to), 'doyun@x.kr'); + await tester.pump(); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + expect(find.byType(MessageComposePage), findsNothing); + expect(c.saves.single.call, 'POST'); + expect(c.saves.single.body['to'], [ + {'name': null, 'email': 'doyun@x.kr'}, + ]); + expect(snack(MailComposeTexts.savedOnLeave), findsOneWidget); + }); + + testWidgets('저장하지 못하면 묻는다 — [계속 쓰기]가 주 단추, [나가기]는 알리지 않고 닫는다', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + await tester.enterText(bodyField, '잃을 수 있는 글'); + c.draftFailures.add(FakeResponse.fail(503, 'MAIL_001')); + + await tapTooltip(tester, MailComposeTexts.close); + expect(find.text(MailComposeTexts.leaveTitle), findsOneWidget); + expect( + find.widgetWithText(FilledButton, MailComposeTexts.stay), + findsOneWidget, + ); + expect( + find.widgetWithText(TextButton, MailComposeTexts.leave), + findsOneWidget, + ); + + await tester.tap(find.text(MailComposeTexts.stay)); + await tester.pumpAndSettle(); + expect(find.byType(MessageComposePage), findsOneWidget); + expect(textOf(tester, bodyField), '잃을 수 있는 글'); + + c.draftFailures.add(FakeResponse.fail(503, 'MAIL_001')); + await tapTooltip(tester, MailComposeTexts.close); + await tester.tap(find.text(MailComposeTexts.leave)); + await tester.pumpAndSettle(); + expect(find.byType(MessageComposePage), findsNothing); + expect(snack(MailComposeTexts.savedOnLeave), findsNothing); + expect(h.notifier.shownCodes, isEmpty); + }); + + testWidgets('5초 안에 저장이 끝나지 않아 물었는데 그사이 끝나면 창을 닫고 떠난다', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + await tester.enterText(bodyField, '느린 저장'); + final Completer gate = Completer(); + c.saveGate = gate; + + await tester.tap(find.byTooltip(MailComposeTexts.close)); + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + expect(find.text(MailComposeTexts.leaveTitle), findsOneWidget); + + gate.complete(); + await tester.pumpAndSettle(); + expect(find.text(MailComposeTexts.leaveTitle), findsNothing); + expect(find.byType(MessageComposePage), findsNothing); + expect(snack(MailComposeTexts.savedOnLeave), findsOneWidget); + }); + + testWidgets('메일을 보는 직원이 바뀌면(로그아웃) 묻지도 저장하지도 않는다', ( + WidgetTester tester, + ) async { + h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok()); + await start(tester); + await open(tester, MailRoutes.compose); + await addRecipient(tester, 'doyun@x.kr'); + await tester.enterText(bodyField, '로그아웃 전에 쓰던 글'); + + unawaited(h.container.read(sessionProvider.notifier).signOut()); + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 30)); + + expect(find.byType(MessageComposePage), findsNothing); + expect(find.text(MailComposeTexts.leaveTitle), findsNothing); + expect(c.saves, isEmpty); + expect(snack(MailComposeTexts.savedOnLeave), findsNothing); + }); + + testWidgets('앱이 안 보이게 되면 바꾼 것을 바로 저장한다', (WidgetTester tester) async { + await start(tester); + await open(tester, MailRoutes.compose); + await tester.enterText(bodyField, '홈으로 나가기 전'); + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden); + await tester.pump(const Duration(milliseconds: 100)); + expect(c.saves.single.call, 'POST'); + expect(c.saves.single.body['body'], '홈으로 나가기 전'); + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pumpAndSettle(); + }); + + testWidgets('저장할 때마다 바뀌는 id — 목록의 옛 줄을 눌러도 마지막 저장본이 열린다', ( + WidgetTester tester, + ) async { + c.putDraft( + 'd7', + to: >[addressJson('박준호', 'junho@example.com')], + subject: '처음', + ); + await start(tester); + await open(tester, MailRoutes.composeDraft('d7')); + await tester.enterText(subjectField, '고친 제목'); + await tapTooltip(tester, MailComposeTexts.close); + expect(c.saves.single.call, 'PUT d7'); + expect(c.drafts.containsKey('d7'), isFalse); + + await open(tester, MailRoutes.composeDraft('d7')); + expect(textOf(tester, subjectField), '고친 제목'); + expect(h.server.received('GET', '/drafts/saved-1'), hasLength(1)); + }); + }); +} diff --git a/flutter/test/features/mail/message_reader_page_test.dart b/flutter/test/features/mail/message_reader_page_test.dart index 0e3443f..ce69581 100644 --- a/flutter/test/features/mail/message_reader_page_test.dart +++ b/flutter/test/features/mail/message_reader_page_test.dart @@ -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( diff --git a/flutter/test/support/compose_fakes.dart b/flutter/test/support/compose_fakes.dart new file mode 100644 index 0000000..2e048ff --- /dev/null +++ b/flutter/test/support/compose_fakes.dart @@ -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 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> drafts = + >{}; + + /// 받은 저장 요청 차례대로 — `POST` · `PUT {id}` 와 본문 + final List<({String call, Map body})> saves = + <({String call, Map body})>[]; + + /// 받은 발송 차례대로 + final List sends = []; + + /// 다음 저장들이 이 응답으로 실패한다(차례대로 하나씩 쓴다) + final List draftFailures = []; + + /// 다음 발송들이 이 응답으로 실패한다(차례대로 하나씩 쓴다) + final List sendFailures = []; + + /// 있으면 저장 응답이 이것이 열릴 때까지 기다린다(한 번 쓰면 비운다) + Completer? saveGate; + + /// 있으면 발송 응답이 이것이 열릴 때까지 기다린다(한 번 쓰면 비운다) + Completer? sendGate; + + /// 발송의 붙잡기 — 보내기 취소를 기다리는 초(0 이면 바로 보냈다) + int undoSeconds = 10; + + /// 받지 못한 사람 — 발송 응답의 `rejectedRecipients` + List> rejected = >[]; + + /// 내 서명 — null 이면 없음(404) + Map? signature; + + /// 서명 그림 — id 마다 바이트 + final Map> signatureImages = >{}; + + /// 받는 사람 추천 — 받아 두기(q 없음)와 묻기(q)에 같은 것을 준다(q 는 이름 · 주소에 든 것만) + List> suggestions = >[]; + + /// 받아 두기(q 없음)의 다음 쪽이 있다 — 있으면 쓰기 화면이 모자랄 때 서버에 q 로 묻는다 + bool suggestionsHasNext = false; + + /// 주소록 그룹과 그 구성원(`Contact` 꼴) + List> groups = >[]; + final Map>> members = + >>{}; + + /// 대용량 파일의 지금 상태 — id 마다(없으면 404) + final Map> largeFiles = + >{}; + + /// 있으면 대용량 파일 상태 응답이 이것이 열릴 때까지 기다린다(한 번 쓰면 비운다) + Completer? 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 get suggestionRequests => + server.received('GET', ComposeApiPaths.recipientSuggestions); + + /// 저장본 하나를 둔다 — 칸은 계약 2절 `Draft` + void putDraft( + String id, { + List> to = const >[], + List> cc = const >[], + List> bcc = const >[], + String subject = '쓰던 메일', + String body = '', + String? bodyHtml, + List> attachments = const >[], + String? inReplyTo, + List> largeFiles = const >[], + }) { + drafts[id] = { + '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? 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? current = signature; + return current == null + ? FakeResponse.fail(404, 'RES_001') + : FakeResponse.ok(current); + }); + server.onPattern('GET', _imagePath, (RequestOptions options) { + final List? 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({'undoSendSeconds': undoSeconds}), + ); + server.on('GET', ComposeApiPaths.recipientSuggestions, ( + RequestOptions options, + ) { + final Object? q = options.queryParameters['q']; + final List> items = q is String + ? >[ + for (final Map item in suggestions) + if ('${item['name']} ${item['email']}'.contains(q)) item, + ] + : suggestions; + final bool hasNext = q is! String && suggestionsHasNext; + return FakeResponse.ok({ + 'items': items, + 'pageInfo': { + 'hasNext': hasNext, + 'nextCursor': hasNext ? 'next' : null, + }, + }); + }); + server.on( + 'GET', + ComposeApiPaths.contactGroups, + (_) => FakeResponse.ok({ + 'items': groups, + 'pageInfo': {'hasNext': false, 'nextCursor': null}, + }), + ); + server.on('GET', ComposeApiPaths.contacts, (RequestOptions options) { + final Object? groupId = options.queryParameters['groupId']; + final List>? list = members[groupId]; + if (list == null) return FakeResponse.fail(404, 'RES_001'); + return FakeResponse.ok({ + 'items': list, + 'pageInfo': {'hasNext': false, 'nextCursor': null}, + }); + }); + server.onPattern('GET', _filePath, (RequestOptions options) async { + final Completer? gate = largeFileGate; + if (gate != null) { + largeFileGate = null; + await gate.future; + } + final Map? file = + largeFiles[_idOf(_filePath, options.path)]; + return file == null + ? FakeResponse.fail(404, 'RES_001') + : FakeResponse.ok(file); + }); + } + + Future _save( + String call, + RequestOptions options, + String? previousId, + ) async { + final Map body = options.data! as Map; + saves.add((call: call, body: body)); + final Completer? 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 attachments = [ + for (final Map item + in (body['attachments']! as List) + .cast>()) + attachmentJson('$id-${item['id']}', name: item['name']! as String), + ]; + drafts[id] = { + ...body, + 'id': id, + 'attachments': attachments, + 'updatedAt': kTestNow.toUtc().toIso8601String(), + 'largeFiles': [ + for (final Object? fileId + in (body['largeFileIds'] as List?) ?? []) + { + 'id': fileId, + 'status': 'ready', + 'name': '$fileId.zip', + 'size': 1, + }, + ], + }; + return FakeResponse.ok({ + 'id': id, + 'attachments': attachments, + 'updatedAt': kTestNow.toUtc().toIso8601String(), + }); + } + + Future _send(RequestOptions options) async { + final Map body = options.data! as Map; + sends.add(( + body: body, + key: options.headers[kIdempotencyHeader] as String?, + )); + final Completer? 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] = { + ...body, + 'id': id, + 'attachments': [], + 'updatedAt': now.toUtc().toIso8601String(), + 'largeFiles': [], + }..remove('draftId'); + } + return FakeResponse(201, { + 'success': true, + 'data': { + 'id': id, + 'rejectedRecipients': rejected, + 'pendingSend': undoSeconds > 0 + ? pendingJson(now.add(Duration(seconds: undoSeconds))) + : null, + }, + }); + } +}