diff --git a/flutter/lib/core/people/recipient_match.dart b/flutter/lib/core/people/recipient_match.dart index 9351752..d2c8e78 100644 --- a/flutter/lib/core/people/recipient_match.dart +++ b/flutter/lib/core/people/recipient_match.dart @@ -10,11 +10,13 @@ /// Flutter 는 칸의 글(`TextEditingController.text`)에 조합 중인 글자가 들어 있어 그대로 맞춘다. /// - 친 글자가 완전한 주소면 같은 아이디(@ 앞)의 주소도 보인다 — 쓰기 화면은 그때 아무것도 미리 고르지 않는다. /// - 이미 넣은 주소는 뺀다. 묶음은 주소록 → 그룹 → 사내 → 최근 주고받은 주소, 묶음 안은 받은 차례 그대로, 모두 합쳐 최대 8개. -/// - 대소문자는 JS 의 `toLowerCase` 와 같게 낮춘다([jsLowerCase] — Dart 는 'İ' 를 한 글자로 낮춘다). 그리스 문자의 낱말 끝 시그마(ς)는 가르지 않는다(알려진 한계). +/// - 대소문자는 JS 의 `toLowerCase` 에 가깝게 낮춘다([jsLowerCase] — Dart 는 'İ' 를 한 글자로 낮춘다). 그리스 문자의 낱말 끝 시그마(ς)는 가르지 않고, +/// 유니코드 자료의 판이 달라 JS 와 다르게 낮추는 글자(437자 — 대부분 Unicode 16 의 새 글자)가 있다(알려진 한계). library; import 'package:arasystem_app/core/people/address_input.dart'; import 'package:arasystem_app/core/people/recipient_suggestion.dart'; +import 'package:arasystem_app/core/utils/js_case.dart'; import 'package:flutter/foundation.dart'; /// 추천 목록에 보이는 최대 개수 @@ -159,10 +161,6 @@ String? _initialOf(String char) { return syllable == null ? null : _initials[syllable.initial]; } -/// JS 의 `String.prototype.toLowerCase` 와 같은 결과 — 'İ'(U+0130) 는 두 글자(i + 윗점 U+0307)로 낮춘다. -/// Dart 의 `toLowerCase` 는 'i' 한 글자로 낮춰, 주소 길이가 달라지는 글자를 웹 · 서버와 다르게 맞추게 된다 -String jsLowerCase(String value) => value.replaceAll('İ', 'i̇').toLowerCase(); - /// 글자(코드 포인트) 단위로 나눈다 — 이모지 같은 짝 글자를 가르지 않는다(JS `Array.from`) List _codePoints(String value) => [ for (final int rune in value.runes) String.fromCharCode(rune), diff --git a/flutter/lib/core/theme/app_colors.dart b/flutter/lib/core/theme/app_colors.dart index 1b41a73..c51f380 100644 --- a/flutter/lib/core/theme/app_colors.dart +++ b/flutter/lib/core/theme/app_colors.dart @@ -123,6 +123,10 @@ abstract final class AppColors { /// `--color-star` — 중요 표시 static const Color star = Color(0xFFB7791F); + /// `--color-search-mark` — 검색에서 맞은 말의 바탕(글자색은 그대로 — 회색 미리 보기 줄의 강조 안만 [textSecondary]). + /// 짝: 웹 토큰 · 읽기 화면 본문 문서의 `mark` 색(`features/mail/domain/mail_html.dart` — WebView 는 앱 색을 읽지 못해 같은 값을 적는다) + static const Color searchMark = Color(0xFFFCE7A4); + /// 그림자 · 가림막의 바탕색 — `--shadow-*` 의 rgba(16, 24, 28, …) static const Color shadow = Color(0xFF10181C); diff --git a/flutter/lib/core/theme/app_spacing.dart b/flutter/lib/core/theme/app_spacing.dart index aff05e2..fe7c354 100644 --- a/flutter/lib/core/theme/app_spacing.dart +++ b/flutter/lib/core/theme/app_spacing.dart @@ -185,4 +185,14 @@ abstract final class AppSize { /// 알림 안 남은 시간 막대의 두께 ([보내기 취소]) static const double toastCountdownHeight = 3; + + // ── 앱 보드에서 정한 크기 (`App1Search` — 2026-11-07 확정) ── + /// 찾을 곳 줄의 높이 + static const double searchScopeRowHeight = 56; + + /// 찾을 곳 전환 칸의 보이는 높이 — 누르는 곳은 [touchMin] + static const double searchToggleHeight = 40; + + /// 결과 머리(개수 줄)의 높이 + static const double searchCountLineHeight = 36; } diff --git a/flutter/lib/core/theme/app_theme.dart b/flutter/lib/core/theme/app_theme.dart index 8132840..2f4f40b 100644 --- a/flutter/lib/core/theme/app_theme.dart +++ b/flutter/lib/core/theme/app_theme.dart @@ -210,6 +210,7 @@ class AppExtraColors extends ThemeExtension { required this.star, required this.rowChecked, required this.brandTintBorder, + required this.searchMark, }); static const AppExtraColors light = AppExtraColors( @@ -231,6 +232,7 @@ class AppExtraColors extends ThemeExtension { star: AppColors.star, rowChecked: AppColors.rowChecked, brandTintBorder: AppColors.brandTintBorder, + searchMark: AppColors.searchMark, ); /// 시각 · 개수 · 보조 설명 @@ -277,6 +279,9 @@ class AppExtraColors extends ThemeExtension { /// 여러 통 고르기 막대(바탕 `secondaryContainer`)의 아래 선 final Color brandTintBorder; + /// 검색에서 맞은 말의 바탕 (`--color-search-mark`) + final Color searchMark; + @override AppExtraColors copyWith({ Color? textMuted, @@ -297,6 +302,7 @@ class AppExtraColors extends ThemeExtension { Color? star, Color? rowChecked, Color? brandTintBorder, + Color? searchMark, }) { return AppExtraColors( textMuted: textMuted ?? this.textMuted, @@ -317,6 +323,7 @@ class AppExtraColors extends ThemeExtension { star: star ?? this.star, rowChecked: rowChecked ?? this.rowChecked, brandTintBorder: brandTintBorder ?? this.brandTintBorder, + searchMark: searchMark ?? this.searchMark, ); } @@ -342,6 +349,7 @@ class AppExtraColors extends ThemeExtension { star: Color.lerp(star, other.star, t)!, rowChecked: Color.lerp(rowChecked, other.rowChecked, t)!, brandTintBorder: Color.lerp(brandTintBorder, other.brandTintBorder, t)!, + searchMark: Color.lerp(searchMark, other.searchMark, t)!, ); } } diff --git a/flutter/lib/core/utils/js_case.dart b/flutter/lib/core/utils/js_case.dart new file mode 100644 index 0000000..084a5cd --- /dev/null +++ b/flutter/lib/core/utils/js_case.dart @@ -0,0 +1,12 @@ +/// 대소문자를 웹 · 서버(JS)에 가깝게 낮춘다 — 받는 사람 추천(`core/people`) · 메일 검색의 접기(`features/mail/domain/search_text.dart`)가 함께 쓴다. +library; + +/// JS 의 `String.prototype.toLowerCase` 를 흉내 낸다 — 'İ'(U+0130) 는 두 글자(i + 윗점 U+0307)로 낮춘다. +/// Dart 의 `toLowerCase` 는 'i' 한 글자로 낮춰, 길이가 달라지는 글자를 웹 · 서버와 다르게 맞추게 된다 +/// (그 밖에 글자 수를 바꾸며 낮추는 글자는 없다 — 유니코드 SpecialCasing 의 조건 없는 소문자는 이 하나뿐이다). +/// +/// **JS 와 완전히 같지는 않다**(2026-11-07 잼): Dart 와 JS(V8 · ICU)는 유니코드 자료의 판이 달라 437자를 다르게 낮춘다 — +/// 대부분 Unicode 16 에 새로 든 글자다(한글 · 한자 · 라틴 기본 글자는 같다). 알려진 한계 — 받는 사람 추천에서는 그 글자들의 +/// 대소문자를 웹과 다르게 맞출 수 있고, 메일 검색은 한 글자 판정에만 써서 결과에는 영향이 없다(맞추기는 서버가 한다) +String jsLowerCase(String value) => + value.replaceAll('\u0130', 'i\u0307').toLowerCase(); diff --git a/flutter/lib/features/mail/data/models/mail_search.dart b/flutter/lib/features/mail/data/models/mail_search.dart new file mode 100644 index 0000000..7ab64d9 --- /dev/null +++ b/flutter/lib/features/mail/data/models/mail_search.dart @@ -0,0 +1,259 @@ +import 'package:arasystem_app/features/mail/data/models/message_summary.dart'; +import 'package:arasystem_app/features/mail/domain/search_highlight.dart'; +import 'package:flutter/foundation.dart'; + +/// 목록 항목의 검색 강조 — 계약 2절 "검색 — 14단계"의 `searchHighlight`(색인 방식으로 찾았을 때만 값이 있다) +@immutable +class MessageListHighlight { + const MessageListHighlight({ + required this.subject, + required this.previewText, + required this.previewRanges, + }); + + /// 그 항목의 제목(서버가 NFC 로 실었다) 안의 맞은 곳 + final List subject; + + /// 보일 미리 보기 한 줄 — 본문에서 맞았으면 맞은 말 둘레(잘린 쪽에 "…"), 아니면 엔진의 미리 보기 + final String previewText; + + /// [previewText] 안의 맞은 곳 — 비었을 수 있다(인용 밖의 첫 글을 보일 때) + final List previewRanges; + + /// 모양이 틀리면 null — 강조만 버리고 항목은 살린다 + static MessageListHighlight? tryParse(Object? json) { + if (json is! Map) return null; + final List? subject = parseSearchRanges(json['subject']); + final Object? preview = json['preview']; + if (subject == null || preview is! Map) return null; + final Object? text = preview['text']; + final List? ranges = parseSearchRanges(preview['ranges']); + if (text is! String || ranges == null) return null; + return MessageListHighlight( + subject: subject, + previewText: text, + previewRanges: ranges, + ); + } + + @override + bool operator ==(Object other) => + other is MessageListHighlight && + listEquals(other.subject, subject) && + other.previewText == previewText && + listEquals(other.previewRanges, previewRanges); + + @override + int get hashCode => Object.hash( + Object.hashAll(subject), + previewText, + Object.hashAll(previewRanges), + ); +} + +/// 검색 결과 한 통 — 목록 칸([summary])과 그 응답의 강조 +@immutable +class MailSearchItem { + const MailSearchItem({ + required this.summary, + this.highlight, + this.highlightSupported = false, + }); + + final MessageSummary summary; + + /// 맞은 곳 — 색인 방식으로 찾았을 때만. 엔진 방식 · 모양이 틀린 강조는 null + final MessageListHighlight? highlight; + + /// 응답 항목에 `searchHighlight` 칸이 있었다(14단계 서버 — 값이 null 이어도) — 그 결과에서 연 메일만 강조 쿼리를 싣는다 + /// (14단계 앞 서버는 모르는 쿼리를 400 `VAL_001` 로 막는다) + final bool highlightSupported; + + String get id => summary.id; + + /// 읽기 화면이 바꾼 메일의 표시 · 메일함 · 붙잡힘만 덮는다 — 제목 · 미리 보기 · 강조는 이 응답의 것 그대로(강조 위치가 그 글에 묶여 있다) + MailSearchItem withStateOf(MessageSummary next) => MailSearchItem( + summary: summary.withStateOf(next), + highlight: highlight, + highlightSupported: highlightSupported, + ); + + /// 모양이 틀리면 null(목록 항목과 같은 규칙) — `searchHighlight` 만은 틀려도 강조 없는 것으로 읽는다 + static MailSearchItem? tryParse(Object? json) { + final MessageSummary? summary = MessageSummary.tryParse(json); + if (summary == null || json is! Map) return null; + final bool supported = json.containsKey('searchHighlight'); + return MailSearchItem( + summary: summary, + highlight: supported + ? MessageListHighlight.tryParse(json['searchHighlight']) + : null, + highlightSupported: supported, + ); + } + + @override + bool operator ==(Object other) => + other is MailSearchItem && + other.summary == summary && + other.highlight == highlight && + other.highlightSupported == highlightSupported; + + @override + int get hashCode => Object.hash(summary, highlight, highlightSupported); +} + +/// 읽기 화면의 검색 강조 — 강조 쿼리로 받은 `GET /api/messages/{id}` 의 `searchHighlight`. 위치는 그 응답의 글(NFC) 기준이다 +@immutable +class MessageDetailHighlight { + const MessageDetailHighlight({ + this.subject = const [], + this.attachments = const >{}, + this.textBody = const [], + }); + + /// 제목의 맞은 곳 + final List subject; + + /// 첨부 이름의 맞은 곳 — 첨부 id 마다(맞은 첨부만) + final Map> attachments; + + /// 글 본문(textBody)의 맞은 곳 — 서식 본문은 서버가 맞은 글 조각에 `` 를 넣어 보낸다 + final List textBody; + + /// 모양이 틀리면 null — 강조만 버리고 메일은 연다 + static MessageDetailHighlight? tryParse(Object? json) { + if (json is! Map) return null; + final List? subject = parseSearchRanges(json['subject']); + final List? textBody = parseSearchRanges(json['textBody']); + final Object? rawAttachments = json['attachments']; + if (subject == null || + textBody == null || + rawAttachments is! Map) { + return null; + } + final Map> attachments = + >{}; + for (final MapEntry entry in rawAttachments.entries) { + final List? ranges = parseSearchRanges(entry.value); + if (ranges == null) return null; + attachments[entry.key] = ranges; + } + return MessageDetailHighlight( + subject: subject, + attachments: Map>.unmodifiable(attachments), + textBody: textBody, + ); + } + + @override + bool operator ==(Object other) => + other is MessageDetailHighlight && + listEquals(other.subject, subject) && + _sameAttachments(other.attachments, attachments) && + listEquals(other.textBody, textBody); + + static bool _sameAttachments( + Map> a, + Map> b, + ) => + a.length == b.length && + a.entries.every( + (MapEntry> entry) => + b.containsKey(entry.key) && listEquals(b[entry.key], entry.value), + ); + + @override + int get hashCode => Object.hash( + Object.hashAll(subject), + Object.hashAllUnordered(attachments.keys), + Object.hashAll(textBody), + ); +} + +/// 읽기 화면에 넘기는 강조 쿼리 — 검색어와 글 조건 넷(조건은 2차)만, 빈 칸은 싣지 않는다 +/// (계약 2절 "검색 — 14단계" › 읽기 화면 — `after` · `before` · `hasAttachment` · `cursor` · `limit` 은 받지 않는다) +@immutable +class MessageHighlightQuery { + const MessageHighlightQuery({ + this.q = '', + this.from = '', + this.to = '', + this.subject = '', + this.body = '', + }); + + final String q; + final String from; + final String to; + final String subject; + final String body; + + /// 실을 칸이 없다 — 강조 쿼리 없이 받는다 + bool get isEmpty => + q.isEmpty && + from.isEmpty && + to.isEmpty && + subject.isEmpty && + body.isEmpty; + + /// 쿼리 칸 — 빈 칸은 뺀다(서버의 이름 그대로) + Map toQueryParameters() => { + if (q.isNotEmpty) 'q': q, + if (from.isNotEmpty) 'from': from, + if (to.isNotEmpty) 'to': to, + if (subject.isNotEmpty) 'subject': subject, + if (body.isNotEmpty) 'body': body, + }; + + @override + bool operator ==(Object other) => + other is MessageHighlightQuery && + other.q == q && + other.from == from && + other.to == to && + other.subject == subject && + other.body == body; + + @override + int get hashCode => Object.hash(q, from, to, subject, body); +} + +/// 지난 메일 검색 색인의 상태 — `GET /api/search-index` 의 `status` +enum SearchIndexStatus { + /// 지난 메일을 채우는 중 — 그동안의 검색은 엔진 방식(낱말이 통째로 같을 때만)이다 + building, + + /// 준비됐다 — 색인 방식(부분 일치 · 강조) + ready; + + /// 응답 칸을 읽는다 — 모르는 값은 null(안내 줄을 보이지 않는다) + static SearchIndexStatus? parse(Object? value) { + for (final SearchIndexStatus status in SearchIndexStatus.values) { + if (status.name == value) return status; + } + return null; + } +} + +/// 검색 화면을 여는 값 — 라우트의 `extra`. 시작한 메일함(null 이면 받은편지함 — 메일함 목록의 기본 메일함). +/// 검색어는 주소에 싣지 않는다(앱에는 새로고침 · 즐겨찾기가 없다 — 검색어가 남는 곳을 줄인다) +@immutable +class MailSearchStart { + const MailSearchStart({this.mailboxId}); + + final String? mailboxId; +} + +/// 검색 결과에서 연 메일 — 읽기 화면 라우트의 `extra`. +/// 읽기 화면은 [highlight] 로 받아 맞은 말을 칠하고, 머리에 "메일함 받은편지함" 줄을 둔다([scopeMailboxId] 에 있으면 그 메일함) +@immutable +class MailSearchOpen { + const MailSearchOpen({this.highlight, this.scopeMailboxId}); + + /// 강조 쿼리 — 14단계 서버의 결과([MailSearchItem.highlightSupported])에서만, 아니면 null(쿼리 없이 받는다) + final MessageHighlightQuery? highlight; + + /// 한 메일함에서 찾았으면 그 메일함 — 전체 메일함이면 null + final String? scopeMailboxId; +} diff --git a/flutter/lib/features/mail/data/models/message_detail.dart b/flutter/lib/features/mail/data/models/message_detail.dart index 8db1244..ada1862 100644 --- a/flutter/lib/features/mail/data/models/message_detail.dart +++ b/flutter/lib/features/mail/data/models/message_detail.dart @@ -1,4 +1,5 @@ import 'package:arasystem_app/features/mail/data/models/mail_address.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_search.dart'; import 'package:arasystem_app/features/mail/data/models/message_summary.dart'; import 'package:flutter/foundation.dart'; @@ -107,6 +108,7 @@ class MessageLargeFile { /// 목록 칸([summary])과 읽기 칸을 함께 둔다. 9단계 뒤에 더해진 칸(`imagesShown` · `isHtmlBodyOmitted` · `isBodyTruncated` · /// `largeFiles` · 첨부의 `isInline`)은 없으면 거짓 · 빈 목록으로 읽는다(웹과 같다). 대용량 첨부는 하나가 틀려도 그것만 뺀다. /// `replyTo` 도 없으면 빈 목록이다(쓰기 화면의 답장 받는 사람이 쓴다). +/// 14단계의 `searchHighlight`(강조 쿼리로 받았을 때만 값)는 없거나 모양이 틀리면 null 로 읽는다(메일은 연다). @immutable class MessageDetail { const MessageDetail({ @@ -123,6 +125,7 @@ class MessageDetail { this.isBodyTruncated = false, this.attachments = const [], this.largeFiles = const [], + this.searchHighlight, }); /// 목록과 같은 칸 — 읽음 · 중요 · 메일함 · 붙잡힌 발송 @@ -154,6 +157,9 @@ class MessageDetail { final List attachments; final List largeFiles; + /// 검색 강조 — 검색 결과에서 강조 쿼리로 받은 응답만(그 응답의 제목 · 첨부 이름 · 글 본문 기준), 그 밖에는 null + final MessageDetailHighlight? searchHighlight; + String get id => summary.id; /// 첨부 목록에 보일 것 — 본문 안에 그림으로 들어간 것은 뺀다(웹 `listedAttachments`) @@ -162,7 +168,7 @@ class MessageDetail { if (!attachment.isInline) attachment, ]; - /// 목록 칸만 바꾼다 — 읽음 · 중요 · 메일함 · 붙잡힌 발송(본문은 그대로) + /// 목록 칸만 바꾼다 — 읽음 · 중요 · 메일함 · 붙잡힌 발송(본문 · 강조는 그대로) MessageDetail withSummary(MessageSummary next) => MessageDetail( summary: next, cc: cc, @@ -177,9 +183,11 @@ class MessageDetail { isBodyTruncated: isBodyTruncated, attachments: attachments, largeFiles: largeFiles, + searchHighlight: searchHighlight, ); - /// 본문 칸만 [other] 의 것으로 — [이미지 보기]로 다시 받은 본문(읽음 · 중요 · 메일함은 지금 값 그대로 — 웹 `showImages`) + /// 본문 칸만 [other] 의 것으로 — [이미지 보기]로 다시 받은 본문(읽음 · 중요 · 메일함은 지금 값 그대로 — 웹 `showImages`). + /// 첨부와 검색 강조도 그 응답의 것으로 함께 바꾼다(강조 위치가 그 응답의 글에 묶여 있다) MessageDetail withBodyOf(MessageDetail other) => MessageDetail( summary: summary, cc: cc, @@ -194,6 +202,7 @@ class MessageDetail { isBodyTruncated: other.isBodyTruncated, attachments: other.attachments, largeFiles: other.largeFiles, + searchHighlight: other.searchHighlight, ); /// 모양이 틀리면 null — 저장소가 `SYS_001` 로 던진다 @@ -242,6 +251,7 @@ class MessageDetail { isBodyTruncated: truncated, attachments: attachments, largeFiles: _largeFiles(json['largeFiles']), + searchHighlight: MessageDetailHighlight.tryParse(json['searchHighlight']), ); } diff --git a/flutter/lib/features/mail/data/models/message_summary.dart b/flutter/lib/features/mail/data/models/message_summary.dart index 654e5f1..d7e069f 100644 --- a/flutter/lib/features/mail/data/models/message_summary.dart +++ b/flutter/lib/features/mail/data/models/message_summary.dart @@ -107,6 +107,24 @@ class MessageSummary { pendingSend: pendingSend, ); + /// [other] 의 표시(읽음 · 중요) · 메일함 · 붙잡힌 발송만 덮는다 — 제목 · 미리 보기 · 받은 시각은 이 요약 그대로 + /// (검색 결과는 강조 위치가 그 응답의 제목 · 미리 보기에 묶여 있다) + MessageSummary withStateOf(MessageSummary other) => MessageSummary( + id: id, + threadId: threadId, + mailboxIds: other.mailboxIds, + from: from, + to: to, + subject: subject, + preview: preview, + receivedAt: receivedAt, + isRead: other.isRead, + isFlagged: other.isFlagged, + hasAttachment: hasAttachment, + size: size, + pendingSend: other.pendingSend, + ); + /// 모양이 틀리면 null — `pendingSend` 만은 틀려도 없는 것으로 읽는다 static MessageSummary? tryParse(Object? json) { if (json is! Map) return null; diff --git a/flutter/lib/features/mail/data/repositories/mail_repository.dart b/flutter/lib/features/mail/data/repositories/mail_repository.dart index 9e71c72..6c875c0 100644 --- a/flutter/lib/features/mail/data/repositories/mail_repository.dart +++ b/flutter/lib/features/mail/data/repositories/mail_repository.dart @@ -8,6 +8,7 @@ import 'package:arasystem_app/core/api/server_time.dart'; import 'package:arasystem_app/core/files/downloaded_files.dart'; import 'package:arasystem_app/features/mail/data/models/mail_bulk_results.dart'; import 'package:arasystem_app/features/mail/data/models/mail_page.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_search.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'; @@ -27,6 +28,9 @@ abstract final class MailApiPaths { /// 새 메일 확인 — 불투명한 상태 글 static const String mailState = '/mail-state'; + /// 지난 메일 검색 색인의 상태 (14단계) — 조회지만 그 직원의 색인을 만들고 멈춤을 푼다(계약 2절 "검색 색인") + static const String searchIndex = '/search-index'; + /// 첨부 받기 — 표준 래퍼 없는 파일 static String attachment(String messageId, String attachmentId) => '${message(messageId)}/attachments/${Uri.encodeComponent(attachmentId)}'; @@ -98,18 +102,64 @@ class MailRepository { return page.withServerTime(serverTimeOf(response)); } + /// 메일 검색 — 받은 시각 내림차순 (계약 2절 "검색" · "검색 — 14단계"). [mailboxId] 가 없으면 휴지통 · 스팸메일함을 뺀 모든 메일함에서 찾는다. + /// [cursor] 는 해석하지 않고 그대로 보낸다(방식 · 범위 · 검색어가 그 안에 묶여 있다). 색인 방식은 `hasNext` 인데 항목이 [limit] 보다 적거나 0개일 수 있다. + /// [silent] 면 실패를 알리지 않는다(처음 찾는 쪽은 화면이 받지 못함을 직접 그린다). [cancelToken] 으로 끊은 요청은 알리지 않는다. + /// 응답의 서버 시각(`Date`)도 싣는다 — 결과의 붙잡힌 메일이 풀릴 때를 서버 시계로 잰다 + Future> searchMessages({ + required String q, + String? mailboxId, + String? cursor, + int limit = kMailPageSize, + bool silent = false, + CancelToken? cancelToken, + }) async { + final Response response = await _dio.get( + MailApiPaths.messages, + queryParameters: { + 'q': q, + 'mailboxId': ?mailboxId, + 'limit': limit, + 'cursor': ?cursor, + }, + options: silent ? ApiOptions.silent() : null, + cancelToken: cancelToken, + ); + final MailPage? page = MailPage.tryParse( + response.data, + MailSearchItem.tryParse, + ); + if (page == null) throw _malformed(response); + return page.withServerTime(serverTimeOf(response)); + } + + /// 지난 메일 검색 색인의 상태 — 모르는 값이면 null. 알림은 띄우지 않는다(받지 못하면 화면은 안내 줄을 보이지 않는다 — 계약) + Future searchIndexStatus() async { + final Response response = await _dio.get( + MailApiPaths.searchIndex, + options: ApiOptions.silent(), + ); + final Object? data = response.data; + if (data is! Map) throw _malformed(response); + return SearchIndexStatus.parse(data['status']); + } + /// 메일 한 통 — 조회만으로는 읽음 처리하지 않는다(계약 2절). [showImages] 면 외부 이미지를 보이는 본문(`images=show`). + /// [highlight] 가 있으면 검색 강조 쿼리(`q` 등 — 빈 칸은 빼고)를 함께 실어 맞은 말의 위치 · `` 를 받는다(14단계 서버만 받는다). /// 알림은 [silent] 로 끈다 — 처음 여는 메일은 화면이 받지 못함을 직접 그린다. 응답의 서버 시각(`Date`)도 함께 준다 Future<({MessageDetail detail, DateTime? serverTime})> getMessage( String id, { bool showImages = false, bool silent = true, + MessageHighlightQuery? highlight, }) async { + final Map query = { + if (showImages) kMailImagesQuery: kMailImagesShow, + ...?highlight?.toQueryParameters(), + }; final Response response = await _dio.get( MailApiPaths.message(id), - queryParameters: showImages - ? {kMailImagesQuery: kMailImagesShow} - : null, + queryParameters: query.isEmpty ? null : query, options: silent ? ApiOptions.silent() : null, ); final MessageDetail? detail = MessageDetail.tryParse(response.data); diff --git a/flutter/lib/features/mail/domain/mail_html.dart b/flutter/lib/features/mail/domain/mail_html.dart index 0812d29..694f160 100644 --- a/flutter/lib/features/mail/domain/mail_html.dart +++ b/flutter/lib/features/mail/domain/mail_html.dart @@ -14,6 +14,7 @@ library; import 'package:arasystem_app/features/mail/domain/mail_images.dart'; +import 'package:arasystem_app/features/mail/domain/search_highlight.dart'; import 'package:html/dom.dart' as dom; import 'package:html/parser.dart' as html_parser; @@ -127,30 +128,54 @@ String? displayedHtmlBody(String? htmlBody) => /// 본문 조각을 만든다 — HTML 이 있으면 정화하고, 없으면(공백뿐 포함) 텍스트를 이스케이프한다. /// [imagesShown] 이면 서버의 중계 주소 그림을 남긴다(그 밖의 바깥 주소 · 상대 경로 그림은 어느 때나 지운다). -/// 서식 본문이 길이 · 중첩 한도([kMaxSanitizedHtmlLength] · [kMaxHtmlNestingCost])를 넘으면 정화하지 않고 글자 본문을 보인다 +/// 서식 본문이 길이 · 중첩 한도([kMaxSanitizedHtmlLength] · [kMaxHtmlNestingCost])를 넘으면 정화하지 않고 글자 본문을 보인다. +/// [textHighlights] 는 글 본문의 검색에서 맞은 곳(검색 결과에서 연 메일 — 14단계) — 글 본문을 보일 때만 `` 로 넣는다 +/// (서식 본문은 서버가 정화한 뒤 맞은 글 조각에 `` 를 넣어 보냈고, 앱의 정화가 그대로 남긴다) SanitizedMailBody mailBodyFragment({ required String? htmlBody, required String? textBody, required bool imagesShown, + List textHighlights = const [], }) { final String? html = displayedHtmlBody(htmlBody); - if (html == null) return _textFragment(textBody); + if (html == null) { + return _textFragment(textBody, highlights: textHighlights); + } final String trimmed = html.trim(); if (trimmed.length > kMaxSanitizedHtmlLength || htmlNestingCost(trimmed, limit: kMaxHtmlNestingCost) > kMaxHtmlNestingCost) { - return _textFragment(textBody, htmlOmitted: true); + return _textFragment( + textBody, + htmlOmitted: true, + highlights: textHighlights, + ); } return sanitizeMailHtml(trimmed, imagesShown: imagesShown); } -SanitizedMailBody _textFragment(String? textBody, {bool htmlOmitted = false}) => - SanitizedMailBody( - fragment: - '
${escapeHtmlText(textBody ?? '')}
', - proxiedTokens: const [], - htmlOmitted: htmlOmitted, - ); +SanitizedMailBody _textFragment( + String? textBody, { + bool htmlOmitted = false, + List highlights = const [], +}) => SanitizedMailBody( + fragment: + '
${highlightedTextHtml(textBody ?? '', highlights)}
', + proxiedTokens: const [], + htmlOmitted: htmlOmitted, +); + +/// 글 → 이스케이프한 HTML — 맞은 곳은 속성 없는 `` 로(웹 `mail-html.ts` 의 `textBodyHtml` 과 같다). +/// 위치는 그 글(강조를 받은 응답의 textBody) 기준이다 — 글 밖 · 거꾸로 된 위치는 버린다([highlightSegments]) +String highlightedTextHtml(String text, List highlights) { + if (highlights.isEmpty) return escapeHtmlText(text); + final StringBuffer out = StringBuffer(); + for (final HighlightSegment segment in highlightSegments(text, highlights)) { + final String escaped = escapeHtmlText(segment.text); + out.write(segment.mark ? '$escaped' : escaped); + } + return out.toString(); +} /// 정화 전 비용 — 태그(여는 · 닫는)마다 그때의 깊이를 더한다. 글자를 한 번만 훑고 [limit] 을 넘으면 그 자리에서 멈춘다. /// @@ -486,7 +511,10 @@ const String _documentStyle = 'a{color:#0E7C86}' 'pre{white-space:pre-wrap}' 'blockquote{margin:0 0 0 4px;padding-left:12px;border-left:2px solid #98A2B3;color:#4A5363}' - '.mail-text{white-space:pre-wrap}'; + '.mail-text{white-space:pre-wrap}' + // 검색에서 맞은 말(14단계 — 서버가 서식 본문에 넣은 · 글 본문은 highlightedTextHtml). 짝: 웹 `MAIL_FRAME_STYLE` 의 mark · + // 웹 토큰 `--color-search-mark` · 앱 `AppColors.searchMark` — 한쪽을 고치면 셋 다. 보낸 사람이 본문에 쓴 도 같은 노랑이다(웹과 같은 알려진 한계) + 'mark{background:#FCE7A4;color:inherit;border-radius:2px;padding:0 1px}'; /// WebView 에 넣을 문서 — CSP 를 맨 앞에(어떤 내용보다 먼저 걸리게) 둔다 String buildMailDocument(String bodyFragment) => [ diff --git a/flutter/lib/features/mail/domain/mailbox_tree.dart b/flutter/lib/features/mail/domain/mailbox_tree.dart index 1ab4472..d291d00 100644 --- a/flutter/lib/features/mail/domain/mailbox_tree.dart +++ b/flutter/lib/features/mail/domain/mailbox_tree.dart @@ -105,6 +105,25 @@ Mailbox? findMailboxByRole(List mailboxes, MailboxRole role) { return null; } +/// 메일이 든 메일함 — [preferredId](검색한 메일함 등)에 들어 있으면 그것, 아니면 메일의 메일함 가운데 아는 첫 메일함. +/// 메일함 목록을 아직 못 받았거나 모르는 메일함뿐이면 null (웹 `mail-list-row.ts` 의 `mailboxOfMessage` 와 같다) +Mailbox? mailboxOfMessage( + List mailboxIds, + List mailboxes, { + String? preferredId, +}) { + final List ids = + preferredId != null && mailboxIds.contains(preferredId) + ? [preferredId] + : mailboxIds; + for (final String id in ids) { + for (final Mailbox mailbox in mailboxes) { + if (mailbox.id == id) return mailbox; + } + } + return null; +} + /// 주소 `/mail` 이 여는 메일함 — 받은편지함, 없으면 차례의 첫 메일함 Mailbox? defaultMailboxOf(List mailboxes) { final Mailbox? inbox = findMailboxByRole(mailboxes, MailboxRole.inbox); diff --git a/flutter/lib/features/mail/domain/search_highlight.dart b/flutter/lib/features/mail/domain/search_highlight.dart new file mode 100644 index 0000000..52e4c8b --- /dev/null +++ b/flutter/lib/features/mail/domain/search_highlight.dart @@ -0,0 +1,126 @@ +/// 검색 강조 — 서버가 준 맞은 곳(위치)으로 글을 조각낸다 (순수 함수 — 계약 2절 "검색 — 14단계"). +/// +/// **짝: 웹 `frontend/src/shared/utils/search-highlight.ts`**(버리는 위치 · 짝 글자 · 합치기가 같다). 한쪽을 고치면 둘 다. +/// 화면은 조각을 글 조각(`TextSpan`)으로 그리고(`widgets/highlighted_text.dart`), 읽기 화면의 글 본문은 조각을 이스케이프해 `` 로 넣는다(`mail_html.dart`). +/// +/// 웹과 다른 점: 웹은 글을 NFC 로 맞춘 뒤 자른다. Dart 에는 NFC 가 없어 **다시 맞추지 않는다** — 대신 위치는 늘 그 위치를 준 +/// 응답의 글에만 쓴다(목록은 그 결과 항목의 제목 · 미리 보기, 읽기 화면은 강조를 받은 응답의 제목 · 첨부 이름 · 글 본문 — 서버는 강조가 있으면 그 글을 NFC 로 싣는다). +library; + +/// 맞은 곳 — [시작, 끝) UTF-16 위치 +typedef SearchRange = ({int start, int end}); + +/// 그릴 조각 — [mark] 면 맞은 말 +typedef HighlightSegment = ({String text, bool mark}); + +/// 맞은 곳을 칠할 글 한 줄 — 글과 그 글 안의 맞은 곳(위치를 준 응답의 글이다 — 읽기 화면의 제목) +typedef HighlightedLine = ({String text, List ranges}); + +/// JS 의 `Number.MAX_SAFE_INTEGER` — 이보다 큰 소수 꼴 위치는 정수로 믿지 않는다 +const double _maxSafeInteger = 9007199254740991; + +/// 응답의 맞은 곳 목록(`[[start, end], ...]`)을 읽는다. +/// +/// 목록이 아니거나 칸이 두 수의 목록이 아니면 null — 모양이 틀린 강조는 통째로 버린다(부르는 쪽은 강조 없이 그린다 · 항목은 살린다). +/// 정수가 아닌 위치(소수 부분이 있는 수)는 그 칸만 버린다(웹 `Number.isInteger` 와 같다 — `2.0` 은 정수다). +/// 글 밖 · 거꾸로 된 위치는 여기서 버리지 않는다 — 글을 알아야 하므로 [highlightSegments] 가 버린다 +List? parseSearchRanges(Object? json) { + if (json is! List) return null; + final List ranges = []; + for (final Object? item in json) { + if (item is! List || item.length != 2) return null; + final Object? start = item[0]; + final Object? end = item[1]; + if (start is! num || end is! num) return null; + final int? from = _integerOf(start); + final int? to = _integerOf(end); + if (from == null || to == null) continue; + ranges.add((start: from, end: to)); + } + return List.unmodifiable(ranges); +} + +int? _integerOf(num value) { + if (value is int) return value; + final double number = value.toDouble(); + if (!number.isFinite || + number != number.truncateToDouble() || + number.abs() > _maxSafeInteger) { + return null; + } + return number.toInt(); +} + +/// 그 자리가 짝 글자(이모지 등 BMP 밖 글자)의 가운데다 +bool _splitsPair(String text, int at) { + if (at <= 0 || at >= text.length) return false; + final int before = text.codeUnitAt(at - 1); + final int after = text.codeUnitAt(at); + return before >= 0xD800 && + before <= 0xDBFF && + after >= 0xDC00 && + after <= 0xDFFF; +} + +/// 쓸 수 있는 위치만 — 0 이상 · 글 길이보다 앞에서 시작 · 시작 < 끝, 끝은 글 길이에서 자른다. 시작 차례로 놓고 겹치거나 맞닿은 것은 합친다. +/// 위치가 짝 글자를 가르면 그 글자를 통째로 넣는다 — 반쪽 글자가 깨져 보이지 않게 +List usableSearchRanges(List ranges, String text) { + final int length = text.length; + final List valid = []; + for (final SearchRange range in ranges) { + if (range.start < 0 || range.start >= length || range.end <= range.start) { + continue; + } + final int from = _splitsPair(text, range.start) + ? range.start - 1 + : range.start; + final int cut = range.end < length ? range.end : length; + valid.add((start: from, end: _splitsPair(text, cut) ? cut + 1 : cut)); + } + valid.sort( + (SearchRange a, SearchRange b) => + a.start != b.start ? a.start - b.start : a.end - b.end, + ); + final List merged = []; + for (final SearchRange range in valid) { + if (merged.isNotEmpty && range.start <= merged.last.end) { + final SearchRange last = merged.removeLast(); + merged.add(( + start: last.start, + end: range.end > last.end ? range.end : last.end, + )); + } else { + merged.add(range); + } + } + return merged; +} + +/// 글 → 조각 — 맞은 곳이 없으면 글 한 조각(빈 글이면 조각 없음). 글 밖 · 거꾸로 된 위치는 버린다 +List highlightSegments( + String text, + List? ranges, +) { + if (ranges == null || ranges.isEmpty) { + return text.isEmpty + ? const [] + : [(text: text, mark: false)]; + } + final List segments = []; + int cursor = 0; + for (final SearchRange range in usableSearchRanges(ranges, text)) { + if (range.start > cursor) { + segments.add((text: text.substring(cursor, range.start), mark: false)); + } + segments.add((text: text.substring(range.start, range.end), mark: true)); + cursor = range.end; + } + if (cursor < text.length) { + segments.add((text: text.substring(cursor), mark: false)); + } + return segments; +} + +/// 조각에 맞은 말이 있는가 — 없으면 화면은 강조 없는 글로 그린다 +bool hasMarkedSegment(List segments) => + segments.any((HighlightSegment segment) => segment.mark); diff --git a/flutter/lib/features/mail/domain/search_text.dart b/flutter/lib/features/mail/domain/search_text.dart new file mode 100644 index 0000000..6edf341 --- /dev/null +++ b/flutter/lib/features/mail/domain/search_text.dart @@ -0,0 +1,342 @@ +/// 메일 검색의 낱말 나누기 · 접기(정규화) · 글자 두 개 묶음 · 한도 — 순수 함수 (계약 2절 "검색" · "검색 — 14단계"). +/// +/// **짝: 웹 `frontend/src/shared/utils/search-match.ts`(낱말 나누기 · 접기 · 묶음 · 한 글자 판정) · `mail-search.ts`(다듬기 · 낱말 수), +/// 백엔드 `backend/src/shared/utils/search-query.ts` · `search-text.ts`.** 같은 규칙을 그대로 옮겼다 — 한쪽을 고치면 셋 다. +/// 표본 표는 하나다 — 앱 시험(`test/features/mail/search_text_test.dart`)이 백엔드 `backend/test/search-text-samples.ts` 의 +/// `FOLD_SAMPLES` · `GRAM_SAMPLES` 를 그대로 읽어 견준다(웹 · 백엔드 시험도 같은 표를 쓴다). +/// +/// 화면은 이것으로 한도(200자 · 낱말 8개)를 묻기 전에 보고, "한 글자 낱말뿐이라 본문을 찾지 않은 검색"을 알아본다(결과 머리의 한 줄). +/// 맞은 곳(강조)은 서버가 준다 — 여기서 셈하지 않는다. +/// +/// 웹 · 서버와 다른 점(알려진 한계 — 이 파일은 앱에서 한 글자 판정([isSingleCharSearch])에만 쓰여 검색 결과에는 영향이 없다. 맞추기는 서버가 한다): +/// - Dart 에는 유니코드 NFC 가 없다 — [composeHangul] 이 풀어 쓴 한글 자모(L V T)만 음절로 모은다. +/// 결합 문자가 붙은 라틴 글자(e + U+0301)는 모으지 않아 그런 글자가 든 검색의 한 글자 판정만 어긋날 수 있다(표의 NFD 행은 한글뿐이다). +/// - 유니코드 자료의 판이 JS(V8 · ICU)와 다르다(2026-11-07 잼) — 소문자([jsLowerCase] — 'İ' 만 JS 처럼 두 글자로 낮춘다)는 437자, +/// 정규식의 문자 · 숫자 · 결합 문자(`\p{L}` · `\p{N}` · `\p{M}`) 소속은 4,433자가 다르다. 대부분 Unicode 16 에 새로 든 글자다 +/// (한글 · 한자 · 라틴 기본 글자는 같다). 공백 글자([isSearchSpaceCode] — JS `\s`) · 다듬기(JS `trim`)는 같다. +library; + +import 'package:arasystem_app/core/utils/js_case.dart'; +import 'package:arasystem_app/core/utils/utf16_length.dart'; + +/// 검색어의 최대 길이 — 앞뒤 공백을 뺀 UTF-16 단위(서버 · 웹 `MAIL_SEARCH_MAX_LENGTH` 와 같다) +const int kMailSearchMaxLength = 200; + +/// 검색어 낱말(공백으로 나눈 조각)의 최대 수 — 넘으면 묻지 않는다(서버 · 웹 `MAIL_SEARCH_MAX_WORDS` 와 같다) +const int kMailSearchMaxWords = 8; + +/// 검색어를 나눈 조각의 종류 — 낱말 · 구절(큰따옴표 안) +enum SearchTermKind { word, phrase } + +/// 검색어를 나눈 조각 하나 +typedef SearchTerm = ({SearchTermKind kind, String text}); + +/// 큰따옴표 — 감싼 부분은 구절로 찾는다 +const String _quote = '"'; + +/// NUL(U+0000) — 서버는 색인과 같게 U+FFFD 로 1:1 바꿔 찾는다(판정 안에서만 바꾼다 — 보낼 때는 그대로) +const String _nul = '\u0000'; +const String _replacementCharacter = '\uFFFD'; + +/// 찾을 수 있는 글자 — 문자(\p{L})나 숫자(\p{N}). 하나도 없는 낱말 · 구절(`-` · `…` · 그림 문자뿐)은 버린다 +final RegExp _searchableCharacter = RegExp(r'[\p{L}\p{N}]', unicode: true); + +/// 낱말 앞뒤에 붙은, 문자 · 숫자가 아닌 글자 — 떼고 가운데 낱말로 찾는다('회의,' · '(회의)' → '회의'). +/// 뒤쪽은 "문자 · 숫자 · 결합 문자가 아닌 글자 + 그 뒤의 결합 문자"를 뗀다 — 글자에 붙은 결합 문자는 남기고, 그림 문자와 그 이체 선택자는 함께 뗀다 +final RegExp _wordEdgePunctuation = RegExp( + r'^[^\p{L}\p{N}]+|(?:[^\p{L}\p{N}\p{M}]\p{M}*)+$', + unicode: true, +); + +/// 공백 글자인가 — 낱말 나누기 · 묶음의 경계 · 다듬기(JS 정규식 `\s` · `String.prototype.trim` 과 같은 글자들: +/// 띄어쓰기 · 탭 · 줄바꿈 · NBSP · 전각 공백 · U+2000~U+200A 등). Dart 의 `trim` 은 U+0085 도 지워 쓰지 않는다 +bool isSearchSpaceCode(int code) { + if (code <= 0x20) return code == 0x20 || (code >= 0x09 && code <= 0x0D); + if (code < 0xA0) return false; + return code == 0xA0 || + code == 0x1680 || + (code >= 0x2000 && code <= 0x200A) || + code == 0x2028 || + code == 0x2029 || + code == 0x202F || + code == 0x205F || + code == 0x3000 || + code == 0xFEFF; +} + +/// 앞뒤의 공백 글자([isSearchSpaceCode])를 뺀다 — JS `trim` 과 같다 +String trimSearchSpaces(String text) { + int start = 0; + int end = text.length; + while (start < end && isSearchSpaceCode(text.codeUnitAt(start))) { + start++; + } + while (end > start && isSearchSpaceCode(text.codeUnitAt(end - 1))) { + end--; + } + return start == 0 && end == text.length ? text : text.substring(start, end); +} + +/// 공백 글자로 나눈 조각 — 빈 조각은 뺀다(웹 `text.split(/\s+/u).filter(...)`) +List splitSearchWords(String text) { + final List words = []; + int start = -1; + for (int index = 0; index < text.length; index++) { + if (isSearchSpaceCode(text.codeUnitAt(index))) { + if (start >= 0) words.add(text.substring(start, index)); + start = -1; + } else if (start < 0) { + start = index; + } + } + if (start >= 0) words.add(text.substring(start)); + return words; +} + +/// 검색 글 다듬기 — 앞뒤 공백을 빼고 서버 한도([kMailSearchMaxLength] — UTF-16)에서 자른다. +/// 이모지를 가운데서 자르게 되면 그 글자를 뺀다(웹 `normalizeSearchText` 와 같다) +String normalizeSearchText(String text) => trimSearchSpaces( + truncateUtf16(trimSearchSpaces(text), kMailSearchMaxLength), +); + +/// 낱말 수 — 띄어쓰기로 나눈 조각(따옴표와 상관없이 — 서버의 `countSearchWords` · 웹과 같다) +int countSearchWords(String text) => splitSearchWords(text).length; + +/// 검색어를 낱말 · 구절로 나눈다 — 나온 차례대로, 같은 낱말 · 같은 구절은 한 번만(서버 · 웹과 같다). +/// 큰따옴표 한 쌍 안은 구절(안의 여러 칸 공백은 한 칸으로), 짝이 없는 마지막 큰따옴표는 없는 것으로 본다. +/// 낱말 앞뒤의 문장부호는 떼고, 문자 · 숫자가 하나도 없는 낱말 · 구절은 버린다. 한글 자모를 모으고 NUL 은 U+FFFD 로 바꾼다 +List parseSearchTerms(String query) { + final List segments = composeHangul( + query, + ).replaceAll(_nul, _replacementCharacter).split(_quote); + final List terms = []; + final Set seen = {}; + void add(SearchTermKind kind, String text) { + if (!_searchableCharacter.hasMatch(text)) return; + if (!seen.add('${kind.name}\n$text')) return; + terms.add((kind: kind, text: text)); + } + + for (int index = 0; index < segments.length; index++) { + final String segment = segments[index]; + // 홀수 번째 조각은 따옴표 안이다 — 뒤에 닫는 따옴표가 있을 때만(마지막 조각이면 짝이 없다) + final bool isPhrase = index.isOdd && index < segments.length - 1; + if (isPhrase) { + add(SearchTermKind.phrase, splitSearchWords(segment).join(' ')); + continue; + } + for (final String word in splitSearchWords(segment)) { + add(SearchTermKind.word, word.replaceAll(_wordEdgePunctuation, '')); + } + } + return terms; +} + +// ── 접기 · 묶음 (짝: 웹 search-match.ts · 백엔드 search-text.ts) ───────────── + +/// 전각 ASCII 의 범위와 반각과의 차이 (U+FF01 ! ~ U+FF5E ~ → U+0021 ~ U+007E) +const int _fullwidthFirst = 0xFF01; +const int _fullwidthLast = 0xFF5E; +const int _fullwidthOffset = 0xFEE0; + +const int _asciiUpperA = 0x41; +const int _asciiUpperZ = 0x5A; +const int _asciiCaseOffset = 0x20; +const int _asciiLimit = 0x80; + +/// 소문자가 따로 없는 큰 묶음 — 한글 음절 · 자모, CJK 한자 · 기호(소문자로 바꾸지 않고 건너뛴다) +const int _hangulJamoFirst = 0x1100; +const int _hangulJamoLast = 0x11FF; +const int _cjkFirst = 0x2E80; +const int _cjkLast = 0x9FFF; +const int _hangulSyllableFirst = 0xAC00; +const int _hangulSyllableLast = 0xD7A3; + +const int _highSurrogateFirst = 0xD800; +const int _highSurrogateLast = 0xDBFF; +const int _lowSurrogateFirst = 0xDC00; +const int _lowSurrogateLast = 0xDFFF; +const int _bmpLast = 0xFFFF; + +const int _space = 0x20; + +bool _isHighSurrogate(int code) => + code >= _highSurrogateFirst && code <= _highSurrogateLast; + +bool _isLowSurrogate(int code) => + code >= _lowSurrogateFirst && code <= _lowSurrogateLast; + +/// [index] 에서 시작하는 글자의 UTF-16 폭 — 짝 글자(BMP 밖)면 2 +int _widthAt(String text, int index) { + final int code = text.codeUnitAt(index); + return _isHighSurrogate(code) && + index + 1 < text.length && + _isLowSurrogate(text.codeUnitAt(index + 1)) + ? 2 + : 1; +} + +/// 소문자가 따로 없는 글자인가(빠른 길 — 한글 · 한자) +bool _hasNoCase(int code) => + (code >= _hangulSyllableFirst && code <= _hangulSyllableLast) || + (code >= _cjkFirst && code <= _cjkLast) || + (code >= _hangulJamoFirst && code <= _hangulJamoLast); + +/// ASCII 한 글자(코드)의 접기 — 대문자만 소문자로 +int _foldAsciiCode(int code) => code >= _asciiUpperA && code <= _asciiUpperZ + ? code + _asciiCaseOffset + : code; + +/// ASCII 밖 한 코드 포인트의 접기 — 바뀌지 않으면 null. [char] 는 그 코드 포인트의 글(UTF-16 한두 단위) +String? _foldCodePoint(int code, String char) { + if (code >= _fullwidthFirst && code <= _fullwidthLast) { + return String.fromCharCode(_foldAsciiCode(code - _fullwidthOffset)); + } + if (_hasNoCase(code)) return null; + final String lower = jsLowerCase(char); + if (lower == char || lower.length != char.length) return null; + // 같은 UTF-16 길이여도 코드 포인트가 둘이 될 수 있다(BMP 두 글자) — 하나일 때만 + final int first = lower.runes.first; + final int firstWidth = first > _bmpLast ? 2 : 1; + return firstWidth == lower.length ? lower : null; +} + +/// 찾기 · 색인의 접기 글 — 한글 자모를 모은 뒤 코드 포인트 하나씩: 전각 ASCII → 반각, 소문자, 공백류 → 띄어쓰기. +/// 결과는 [composeHangul] 한 글과 UTF-16 길이 · 위치가 같다 +String foldText(String text) { + final String source = composeHangul(text); + final StringBuffer folded = StringBuffer(); + int runStart = 0; + int index = 0; + while (index < source.length) { + final int code = source.codeUnitAt(index); + final int width = _widthAt(source, index); + final String? replacement; + if (isSearchSpaceCode(code)) { + // 공백류는 모두 BMP 의 한 단위다 — 띄어쓰기 하나로 1:1 + replacement = code == _space ? null : ' '; + } else if (code < _asciiLimit) { + final int lowered = _foldAsciiCode(code); + replacement = lowered == code ? null : String.fromCharCode(lowered); + } else { + replacement = _foldCodePoint( + width == 2 ? _codePointAt(source, index) : code, + source.substring(index, index + width), + ); + } + if (replacement != null) { + folded + ..write(source.substring(runStart, index)) + ..write(replacement); + runStart = index + width; + } + index += width; + } + if (runStart == 0) return source; + folded.write(source.substring(runStart)); + return folded.toString(); +} + +/// [index] 의 짝 글자(높은 · 낮은 짝)를 코드 포인트로 +int _codePointAt(String text, int index) { + final int high = text.codeUnitAt(index); + final int low = text.codeUnitAt(index + 1); + return 0x10000 + + ((high - _highSurrogateFirst) << 10) + + (low - _lowSurrogateFirst); +} + +/// 글자 두 개 묶음 — 공백이 아닌 이웃한 코드 포인트 둘을 한 묶음으로, 나온 차례대로 겹치지 않게(같은 묶음은 한 번). +/// 접은 글([foldText])에 쓴다. 공백을 넘지 않는다 — "회의 일정" → 회의 · 일정. 한 글자 낱말은 묶음이 없다 +Set gramsOf(String fold, [Set? into]) { + final Set grams = into ?? {}; + String? previous; + int index = 0; + while (index < fold.length) { + final int code = fold.codeUnitAt(index); + final int width = _widthAt(fold, index); + if (isSearchSpaceCode(code)) { + previous = null; + } else { + final String char = fold.substring(index, index + width); + if (previous != null) grams.add('$previous$char'); + previous = char; + } + index += width; + } + return grams; +} + +/// 한 글자 낱말뿐인 검색인가 — 검색어(와 2차의 조건 칸)의 낱말 · 구절을 모두 접어도 글자 두 개 묶음이 하나도 없으면 그렇다 +/// (서버의 search-plan · 웹 `isSingleCharSearch` 와 같은 판정). 그때 서버(색인 방식)는 본문을 찾지 않고 제목 · 사람 · 첨부 이름에서만 찾는다. +/// [texts] 는 검색어 q(와 조건 칸)의 글 — 빈 글은 없는 것이다. 찾을 낱말이 하나도 없으면 아니다 +bool isSingleCharSearch(List texts) { + int terms = 0; + final Set grams = {}; + for (final String text in texts) { + for (final SearchTerm term in parseSearchTerms(text)) { + terms++; + gramsOf(foldText(term.text), grams); + if (grams.isNotEmpty) return false; + } + } + return terms > 0; +} + +// ── 한글 자모 모으기 (NFC 의 한글 부분 — 유니코드 한글 조합식) ───────────── + +const int _sBase = 0xAC00; +const int _lBase = 0x1100; +const int _vBase = 0x1161; +const int _tBase = 0x11A7; +const int _lCount = 19; +const int _vCount = 21; +const int _tCount = 28; +const int _nCount = _vCount * _tCount; +const int _sCount = _lCount * _nCount; + +/// 풀어 쓴 한글(첫소리 · 가운뎃소리 · 끝소리 자모 — 맥에서 온 글 등)을 음절로 모은다 — 유니코드 NFC 의 한글 부분과 같다. +/// 그 밖의 글자는 그대로 둔다(결합 문자의 정렬 · 합성은 하지 않는다). 바뀐 것이 없으면 같은 글을 돌려준다 +String composeHangul(String text) { + if (!_hasHangulJamo(text)) return text; + final StringBuffer out = StringBuffer(); + int? last; + for (int index = 0; index < text.length; index++) { + final int code = text.codeUnitAt(index); + final int? previous = last; + if (previous != null) { + final int lIndex = previous - _lBase; + final int vIndex = code - _vBase; + // 첫소리 + 가운뎃소리 → 받침 없는 음절 + if (lIndex >= 0 && lIndex < _lCount && vIndex >= 0 && vIndex < _vCount) { + last = _sBase + (lIndex * _vCount + vIndex) * _tCount; + continue; + } + // 받침 없는 음절 + 끝소리 → 받침 있는 음절 + final int sIndex = previous - _sBase; + final int tIndex = code - _tBase; + if (sIndex >= 0 && + sIndex < _sCount && + sIndex % _tCount == 0 && + tIndex > 0 && + tIndex < _tCount) { + last = previous + tIndex; + continue; + } + out.writeCharCode(previous); + } + last = code; + } + final int? tail = last; + if (tail != null) out.writeCharCode(tail); + return out.toString(); +} + +/// 모을 자모(첫소리 · 가운뎃소리 · 끝소리)가 하나라도 있는가 — 없으면 글을 다시 쓰지 않는다 +bool _hasHangulJamo(String text) { + for (int index = 0; index < text.length; index++) { + final int code = text.codeUnitAt(index); + if (code >= _lBase && code < _tBase + _tCount) return true; + } + return false; +} diff --git a/flutter/lib/features/mail/mail_routes.dart b/flutter/lib/features/mail/mail_routes.dart index 95ee29b..6ce7e8a 100644 --- a/flutter/lib/features/mail/mail_routes.dart +++ b/flutter/lib/features/mail/mail_routes.dart @@ -1,7 +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/data/models/mail_search.dart'; import 'package:arasystem_app/features/mail/domain/compose_request.dart'; import 'package:arasystem_app/features/mail/presentation/pages/mail_list_page.dart'; +import 'package:arasystem_app/features/mail/presentation/pages/mail_search_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:flutter/widgets.dart'; @@ -16,7 +18,11 @@ abstract final class MailRoutes { static String mailbox(String mailboxId) => '$home/mailboxes/${Uri.encodeComponent(mailboxId)}'; - /// 메일 읽기 (전체 화면 — 탭 막대 없이) + /// 메일 검색 (탭 안 — 연 목록 위에 쌓인다. 탭을 다시 누르면 닫힌다). 시작한 메일함은 라우트의 `extra`([MailSearchStart]), + /// 검색어는 주소에 싣지 않는다(화면 상태 — 앱에는 새로고침 · 즐겨찾기가 없다) + static String get search => '$home/search'; + + /// 메일 읽기 (전체 화면 — 탭 막대 없이). 검색 결과에서 열면 강조 쿼리 · 찾은 메일함을 `extra`([MailSearchOpen])로 넘긴다 static String message(String messageId) => '$home/messages/${Uri.encodeComponent(messageId)}'; @@ -47,8 +53,9 @@ abstract final class MailRoutes { /// 메일 모듈의 라우트 묶음 — core/router/module_registry.dart 가 모은다. /// -/// 목록은 앱 보드 `App1MailList`, 읽기는 `App1Reader`, 쓰기는 `App1Compose` 화면이다. +/// 목록은 앱 보드 `App1MailList`, 읽기는 `App1Reader`, 쓰기는 `App1Compose`, 검색은 `App1Search` 화면이다. /// 쓰기를 여는 까닭은 주소의 쿼리(`composeIntentOf(state.uri.queryParameters)`), mailto 로 채울 값은 `state.extra` 의 [ComposePrefill] 이다. +/// 검색의 시작한 메일함은 `state.extra` 의 [MailSearchStart], 검색 결과에서 연 메일의 강조 쿼리는 [MailSearchOpen] 이다. final ModuleRoutes mailModuleRoutes = ModuleRoutes( id: AppModuleId.mail, // 알림 · 깊은 링크의 id 는 메일 id 다 — 새 메일 알림("새 메일 N통")처럼 id 가 없으면 첫 화면 @@ -63,6 +70,15 @@ final ModuleRoutes mailModuleRoutes = ModuleRoutes( builder: (BuildContext _, GoRouterState state) => MailListPage(mailboxId: state.pathParameters['mailboxId']), ), + GoRoute( + path: 'search', + builder: (BuildContext _, GoRouterState state) => MailSearchPage( + originMailboxId: switch (state.extra) { + MailSearchStart(:final String? mailboxId) => mailboxId, + _ => null, + }, + ), + ), GoRoute( path: 'messages/:messageId', parentNavigatorKey: context.rootNavigatorKey, @@ -70,6 +86,10 @@ final ModuleRoutes mailModuleRoutes = ModuleRoutes( // 메일마다 새 화면 — 다른 메일로 바꿔 끼우면 앞 메일의 상태(펼침 · 받기)를 이어 쓰지 않는다 key: ValueKey(state.pathParameters['messageId']!), messageId: state.pathParameters['messageId']!, + search: switch (state.extra) { + final MailSearchOpen open => open, + _ => null, + }, ), ), GoRoute( diff --git a/flutter/lib/features/mail/presentation/mail_row_view.dart b/flutter/lib/features/mail/presentation/mail_row_view.dart index 2a6462e..bfc6fed 100644 --- a/flutter/lib/features/mail/presentation/mail_row_view.dart +++ b/flutter/lib/features/mail/presentation/mail_row_view.dart @@ -3,12 +3,16 @@ /// - 임시보관함: 보낸 사람 자리에 받는 사람(없으면 흐린 "(받는 사람 없음)"), 읽음 여부를 보이지 않는다(누르면 쓰기 화면에서 이어 쓴다). /// - 보낸편지함: "받는 사람: 박준호 외 2명". /// - 아직 보내지 않은 내 메일(9단계 `pendingSend`)은 시각 자리가 "보내는 중" · "예약 · 11월 7일 오전 9:00"(청록)이고 고를 수 없다. +/// - 검색 결과(보드 `App1Search`): 제목 · 미리 보기의 맞은 곳(14단계 — 미리 보기는 서버가 준 맞은 말 둘레 한 줄)을 싣는다. +/// 전체 메일함 결과는 줄마다 그 메일의 메일함 — 시각 앞에 메일함 이름, 임시보관함 줄도 "받는 사람: …"(웹 `rowWho` 의 mixed). library; import 'package:arasystem_app/core/utils/korean_format.dart'; import 'package:arasystem_app/features/mail/data/models/mail_address.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_search.dart'; import 'package:arasystem_app/features/mail/data/models/mailbox.dart'; import 'package:arasystem_app/features/mail/data/models/message_summary.dart'; +import 'package:arasystem_app/features/mail/domain/search_highlight.dart'; import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; import 'package:flutter/foundation.dart'; @@ -28,6 +32,9 @@ class MailRowView { required this.flagged, required this.hasAttachment, required this.held, + this.mailboxName, + this.subjectHighlights = const [], + this.previewHighlights = const [], }); /// 보낸 사람 자리 — 보낸편지함 · 임시보관함은 받는 사람 @@ -56,13 +63,23 @@ class MailRowView { /// 아직 보내지 않은 메일 — 고를 수 없다 final bool held; + /// 줄의 메일함 이름 — 전체 메일함 검색 결과에서만(시각 앞), 그 밖에는 null + final String? mailboxName; + + /// [subject] 안의 검색에서 맞은 곳 — 검색 결과에서만 + final List subjectHighlights; + + /// [preview] 안의 검색에서 맞은 곳 — 검색 결과에서만 + final List previewHighlights; + /// 동그란 사진(고르기 단추)의 화면 낭독기 이름 — "고르기: 제목" String get selectLabel => MailListTexts.selectOne(subject); - /// 줄의 화면 낭독기 글 — 안 읽음 · 보낸 사람 · 시각 · 제목 · 첨부 · 중요 · 미리 보기 차례 + /// 줄의 화면 낭독기 글 — 안 읽음 · 보낸 사람 · (메일함) · 시각 · 제목 · 첨부 · 중요 · 미리 보기 차례. 강조는 읽지 않는다 String get semanticsLabel => [ if (unread) MailListTexts.unreadPrefix, who, + ?mailboxName, time, subject, if (hasAttachment) MailListTexts.attachment, @@ -71,27 +88,33 @@ class MailRowView { ].join(', '); } -/// [message] 를 [role] 메일함의 줄로 보인다 — [now] 는 지금(시각 표기의 "오늘 · 어제"를 가른다) +/// [message] 를 [role] 메일함의 줄로 보인다 — [now] 는 지금(시각 표기의 "오늘 · 어제"를 가른다). +/// +/// 검색 결과는 [highlight](그 결과 항목의 강조 — 제목 · 미리 보기 한 줄의 맞은 곳)를 싣고, 전체 메일함 결과는 [mixed] 와 +/// 줄의 메일함 이름([mailboxName] — 시각 앞)을 준다. [role] 은 줄의 메일함 역할이다(전체 메일함이면 그 메일의 메일함) MailRowView mailRowViewOf( MessageSummary message, { required MailboxRole? role, required DateTime now, + bool mixed = false, + String? mailboxName, + MessageListHighlight? highlight, }) { final bool draft = role == MailboxRole.drafts; final ({String text, bool placeholder, String? initial}) who = _whoOf( message, role, + mixed: mixed, ); final PendingSend? pending = message.pendingSend; + final bool hasSubject = message.subject.isNotEmpty; return MailRowView( who: who.text, whoPlaceholder: who.placeholder, initial: who.initial, - subject: message.subject.isEmpty - ? MailListTexts.noSubject - : message.subject, - subjectPlaceholder: draft && message.subject.isEmpty, - preview: message.preview, + subject: hasSubject ? message.subject : MailListTexts.noSubject, + subjectPlaceholder: draft && !hasSubject, + preview: highlight?.previewText ?? message.preview, time: pending == null ? formatMailListTime(message.receivedAt, now) : pendingSendLabel(pending), @@ -100,13 +123,19 @@ MailRowView mailRowViewOf( flagged: message.isFlagged, hasAttachment: message.hasAttachment, held: message.isHeld, + mailboxName: mailboxName, + subjectHighlights: hasSubject && highlight != null + ? highlight.subject + : const [], + previewHighlights: highlight?.previewRanges ?? const [], ); } ({String text, bool placeholder, String? initial}) _whoOf( MessageSummary message, - MailboxRole? role, -) { + MailboxRole? role, { + required bool mixed, +}) { final List to = message.to; final MailAddress? firstRecipient = to.isEmpty ? null : to.first; switch (role) { @@ -123,7 +152,8 @@ MailRowView mailRowViewOf( ); } return ( - text: names, + // 전체 메일함 결과는 줄마다 메일함이 달라 받는 사람임을 밝힌다(웹 `rowWho` 의 mixed) + text: mixed ? '${MailListTexts.recipientPrefix}$names' : names, placeholder: false, initial: initialOf(firstRecipient.name, fallback: firstRecipient.email), ); diff --git a/flutter/lib/features/mail/presentation/mail_search_view.dart b/flutter/lib/features/mail/presentation/mail_search_view.dart new file mode 100644 index 0000000..1d95cbf --- /dev/null +++ b/flutter/lib/features/mail/presentation/mail_search_view.dart @@ -0,0 +1,97 @@ +/// 검색 화면의 표시 — 순수 함수 (보드 `App1Search` · `App1SearchStates` · 웹 `MailSearchHeader` · `MailSearchNotices` · `useMailListRows`). +/// +/// - 결과 머리: "‘계약’ · 4통" · 찾는 중 "‘계약’ · 찾는 중" · 전체 수를 모르면(`total` null) "‘계약’"(통 수를 보이지 않는다 — 계약). +/// - 안내 줄: 색인을 준비하는 중이면 그 줄, 준비됐고 한 글자 낱말뿐이면 한 글자 안내 — 둘이 함께 뜨지 않고, 준비 상태를 모르면 없다. +/// - 결과 줄: 한 메일함에서 찾았으면 그 메일함의 규칙(보낸편지함 · 임시보관함), 전체 메일함이면 줄마다 그 메일의 메일함(시각 앞에 이름). +library; + +import 'package:arasystem_app/features/mail/data/models/mail_search.dart'; +import 'package:arasystem_app/features/mail/data/models/mailbox.dart'; +import 'package:arasystem_app/features/mail/domain/mailbox_tree.dart'; +import 'package:arasystem_app/features/mail/domain/search_text.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/features/mail/presentation/providers/mail_search_state.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mailboxes_provider.dart'; + +/// 결과 머리 아래의 안내 줄 +enum MailSearchNotice { + /// 지난 메일을 검색할 수 있게 준비하는 중 + indexing, + + /// 한 글자 낱말뿐이라 본문은 찾지 않았다 + singleChar, +} + +/// 결과 머리(개수 줄)의 글 — 머리가 없는 단계(찾기 전 · 낱말이 너무 많음 · 받지 못함)면 null +String? searchCountLineOf(MailSearchState state) { + final String? query = state.query; + if (query == null || !state.showsHead) return null; + if (state.phase == MailSearchPhase.searching || state.refreshing) { + return MailSearchTexts.searching(query); + } + final int? total = state.total; + return total == null + ? MailSearchTexts.quoted(query) + : MailSearchTexts.count(query, total); +} + +/// 안내 줄 — 찾은 뒤에만. 한 글자 안내는 색인이 준비됐을 때만이라(그 전은 엔진 방식이 본문도 찾는다) 준비 중 줄과 함께 뜨지 않는다 +MailSearchNotice? searchNoticeOf(MailSearchState state) { + final String? query = state.query; + if (query == null || state.phase != MailSearchPhase.ready) return null; + return switch (state.indexStatus) { + SearchIndexStatus.building => MailSearchNotice.indexing, + SearchIndexStatus.ready => + isSingleCharSearch([query]) ? MailSearchNotice.singleChar : null, + null => null, + }; +} + +/// 찾을 곳 줄의 시작한 메일함 이름 — 메일함 목록을 받기 전이면 받은편지함 주소는 "받은편지함", 그 밖은 "메일함" +String searchOriginNameOf(String? originMailboxId, MailboxesState mailboxes) { + if (originMailboxId == null) { + return mailboxes.byId(mailboxes.defaultMailboxId)?.name ?? + MailListTexts.inbox; + } + return mailboxes.byId(originMailboxId)?.name ?? MailListTexts.mailboxFallback; +} + +/// 줄의 메일함 — 한 메일함에서 찾았으면 그 메일함, 전체 메일함이면 그 메일의 메일함 가운데 아는 첫 메일함(모르면 null) +Mailbox? searchRowMailboxOf( + MailSearchItem item, + MailSearchState state, + MailboxesState mailboxes, +) => state.searchedAll + ? mailboxOfMessage(item.summary.mailboxIds, mailboxes.items) + : mailboxes.byId(state.searchedMailboxId); + +/// 결과 줄 하나 — 목록 줄 그대로 + 맞은 말 강조, 전체 메일함이면 시각 앞에 메일함 이름 +MailRowView searchRowViewOf( + MailSearchItem item, { + required MailSearchState state, + required MailboxesState mailboxes, + required DateTime now, +}) { + final Mailbox? mailbox = searchRowMailboxOf(item, state, mailboxes); + return mailRowViewOf( + item.summary, + role: mailbox?.role, + now: now, + mixed: state.searchedAll, + mailboxName: state.searchedAll ? mailbox?.name : null, + highlight: item.highlight, + ); +} + +/// 결과에서 연 메일에 넘기는 값 — 강조 쿼리는 14단계 서버의 결과([MailSearchItem.highlightSupported])에서만 싣는다 +/// (14단계 앞 서버는 읽기 화면의 모르는 쿼리를 400 `VAL_001` 로 막는다). 찾은 메일함은 한 메일함에서 찾았을 때만 +MailSearchOpen searchOpenOf(MailSearchItem item, MailSearchState state) { + final String? query = state.query; + return MailSearchOpen( + highlight: item.highlightSupported && query != null + ? MessageHighlightQuery(q: query) + : null, + scopeMailboxId: state.searchedAll ? null : state.searchedMailboxId, + ); +} diff --git a/flutter/lib/features/mail/presentation/mail_texts.dart b/flutter/lib/features/mail/presentation/mail_texts.dart index 3506270..980a60b 100644 --- a/flutter/lib/features/mail/presentation/mail_texts.dart +++ b/flutter/lib/features/mail/presentation/mail_texts.dart @@ -295,10 +295,9 @@ abstract final class MailReaderTexts { abstract final class MailListTexts { // ── 위 막대 ── static const String openDrawer = '메일함 열기'; - static const String search = '메일 찾기'; - /// [찾기]를 눌렀을 때 — 검색 화면은 다음 차례다(보드에 없다) - static const String searchNotReady = '메일 찾기는 아직 준비 중입니다. 지금은 웹메일에서 찾아 주세요.'; + /// [찾기] — 검색 화면을 연다(보드 `App1Search`) + static const String search = '메일 찾기'; /// 메일함 이름을 받기 전의 제목 — 받은편지함 주소(`/mail`)는 [inbox] static const String mailboxFallback = '메일함'; @@ -388,6 +387,96 @@ abstract final class MailListTexts { static const String compose = '메일 쓰기'; } +/// 메일 검색 (보드 `App1Search` · `App1SearchStates` — 1차: 검색어 · 찾을 곳 · 결과 · 강조 · 이어 찾기 · 안내). +/// 글은 웹 상수(`frontend/src/shared/constants/mail.ts` 의 `MAIL_SEARCH_TEXT` · `mail-search.ts` 의 `SEARCH_NOTICE_TEXT`)를 그대로 옮겼다 — +/// 앱에서 새로 쓴 글은 그 줄에 적었다. 목록 줄 · 받지 못함의 글은 [MailListTexts] 를 함께 쓴다 +abstract final class MailSearchTexts { + // ── 위 막대 ── + /// 칸의 안내 글 · 화면 낭독기 이름 (웹 `box`) + static const String box = '메일 검색'; + + /// [←] — 화면 낭독기 + static const String close = '검색 닫기'; + + /// [✕] — 화면 낭독기 + static const String clear = '검색어 지우기'; + + // ── 찾을 곳 ── + static const String scopeLabel = '찾을 곳'; + static const String scopeAll = '전체 메일함'; + + /// 전체 메일함을 고르면 줄 아래에 + static const String scopeAllHint = '휴지통과 스팸메일함은 빼고 찾습니다'; + + // ── 결과 머리 ── + /// 전체 수를 모를 때(`total` null) — "‘계약’" + static String quoted(String query) => '‘$query’'; + + /// "‘계약’ · 4통" + static String count(String query, int count) => + '‘$query’ · ${formatCount(count)}통'; + + /// "‘계약’ · 찾는 중" + static String searching(String query) => '‘$query’ · 찾는 중'; + + /// 결과 목록 — 화면 낭독기 이름 (웹 `title`) + static const String results = '검색 결과'; + + /// 처음 찾는 동안의 뼈대 · 목록 끝에서 이어 찾는 동안 — 화면 낭독기 (웹 `loading`) + static const String loading = '찾는 중'; + + // ── 안내 줄 (웹 `SEARCH_NOTICE_TEXT`) ── + /// 지난 메일의 색인을 만드는 동안(`GET /search-index` 의 building) + static const String indexing = + '지난 메일을 검색할 수 있게 밤사이에 준비하고 있습니다. 메일이 많으면 며칠 걸릴 수 있고, 그동안은 낱말이 통째로 같은 메일만 찾습니다.'; + + /// 한 글자 낱말뿐이라 본문은 찾지 않았을 때(색인이 준비됐을 때만) + static const String singleChar = + '한 글자로만 찾을 때는 본문은 빼고 제목, 보낸 사람과 받는 사람, 첨부 파일 이름에서 찾습니다. 본문까지 찾으려면 두 글자 이상으로 찾아 주세요.'; + + // ── 결과 없음 ── + /// "‘해지 통보’에 맞는 메일이 없습니다" + static String emptyTitle(String query) => '‘$query’에 맞는 메일이 없습니다'; + + /// 검색어만으로 찾았을 때의 도움말 (웹 `emptyWordsHint` — 조건은 2차) + static const String emptyHint = '다른 말로 찾아 보세요.'; + + /// 색인을 준비하는 동안에만 더하는 한계 안내 (웹 `wordLimitHint`) + static const String wordLimitHint = + '준비가 끝나기 전에는 ‘서버이전’의 ‘서버’처럼 붙여 쓴 말의 일부나, 제목 맨 앞의 [공지]나 Re: 같은 말머리로는 찾지 못합니다.'; + + /// 한 메일함에서 찾았을 때 — 결과 없음 · 그 메일함이 그사이 지워짐(`RES_001`) + static const String searchAll = '전체 메일함에서 찾기'; + + // ── 아직 못 찾음 · 목록 끝 ── + static const String partialTitle = '아직 맞는 메일을 찾지 못했습니다'; + static const String partialHint = + '메일이 많아 한 번에 다 찾지 못했습니다. [더 찾기]를 누르면 이어서 찾습니다.'; + static const String more = '더 찾기'; + + /// [더 찾기]를 누른 동안 — 다시 누르지 못한다 + static const String moreBusy = '찾는 중…'; + + // ── 찾을 말이 너무 많음 ── + static const String tooManyWordsTitle = '찾을 말이 너무 많습니다'; + + /// (앱 1차 — 검색어만) 2차(조건)부터는 웹 글 "검색어와 조건에 넣은 낱말은 모두 합쳐 …" + static String tooManyWordsHint(int max) => + '검색어의 낱말은 ${formatCount(max)}개까지 찾을 수 있습니다. 낱말을 줄여 주세요.'; + + // ── 찾기 전 ── + /// 처음 연 화면 — 무엇을 찾는지 (앱에서 새로 씀) + static const String intro = + '보낸 사람, 받는 사람, 제목, 본문, 첨부 파일 이름에서 찾습니다. 여러 낱말을 넣으면 모두 들어간 메일을 찾습니다.'; + + // ── 결과 줄 · 읽기 화면 ── + /// 전체 메일함 결과 줄의 시각 앞 — "보낸편지함 ·" (보드) + static String rowMailbox(String name) => '$name ·'; + + /// 검색 결과에서 연 메일의 머리 — "메일함 받은편지함"의 이름 칸 (웹 `mailboxLine`) + static const String mailboxLine = '메일함'; +} + /// 메일함 서랍 (보드 `App1MailList` 「메일함 서랍」) abstract final class MailDrawerTexts { static const String title = '메일함'; diff --git a/flutter/lib/features/mail/presentation/pages/mail_list_page.dart b/flutter/lib/features/mail/presentation/pages/mail_list_page.dart index 51fe5d0..b6ac4ae 100644 --- a/flutter/lib/features/mail/presentation/pages/mail_list_page.dart +++ b/flutter/lib/features/mail/presentation/pages/mail_list_page.dart @@ -5,6 +5,7 @@ import 'package:arasystem_app/core/api/dio_provider.dart'; import 'package:arasystem_app/core/modules/app_modules.dart'; import 'package:arasystem_app/core/router/shell_bar.dart'; import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_search.dart'; import 'package:arasystem_app/features/mail/data/models/mailbox.dart'; import 'package:arasystem_app/features/mail/data/models/message_summary.dart'; import 'package:arasystem_app/features/mail/domain/mailbox_tree.dart'; @@ -28,7 +29,7 @@ import 'package:go_router/go_router.dart'; /// 메일 목록 — 앱 보드 `App1MailList` (앱 디자인 README 「메일 목록」). /// -/// - 위 막대: 서랍 열기 · 메일함 이름(아래에 안 읽은 수) · 찾기(아직 준비 중 — 알림만). 서랍은 탭 막대 위까지 덮는다. +/// - 위 막대: 서랍 열기 · 메일함 이름(아래에 안 읽은 수) · 찾기(검색 화면 — 이 메일함에서 시작한다). 서랍은 탭 막대 위까지 덮는다. /// - 줄을 누르면 읽기 화면(전체 화면 — 임시보관함은 쓰기 화면에서 이어 쓴다). 읽음 처리는 읽기 화면이 한다. /// - 사진을 누르거나 줄을 길게 누르면 고르기 — 위 막대가 고르기 막대가 되고 탭 막대 · [메일 쓰기]를 숨긴다. ✕ · 뒤로 가기로 나온다. /// - 당겨서 새로고침, 끝 근처에서 다음 쪽. 화면이 보이는 동안 1분마다 새 메일을 확인한다(목록 컨트롤러 · 새 메일 확인). @@ -81,8 +82,14 @@ class _MailListPageState extends ConsumerState { ); } - void _showSearchNotice() { - ref.read(mailToastProvider.notifier).notify(MailListTexts.searchNotReady); + /// [찾기] — 이 메일함에서 시작하는 검색 화면을 위에 쌓는다(받은편지함 주소는 메일함 목록의 기본 메일함) + void _openSearch(String? mailboxId) { + unawaited( + context.push( + MailRoutes.search, + extra: MailSearchStart(mailboxId: mailboxId), + ), + ); } void _toggle(MessageSummary message) { @@ -177,7 +184,7 @@ class _MailListPageState extends ConsumerState { ? MailListTexts.unread(mailbox.unreadCount) : null, onOpenDrawer: () => unawaited(_openDrawer(list.mailboxId)), - onSearch: _showSearchNotice, + onSearch: () => _openSearch(list.mailboxId ?? widget.mailboxId), ); return PopScope( diff --git a/flutter/lib/features/mail/presentation/pages/mail_search_page.dart b/flutter/lib/features/mail/presentation/pages/mail_search_page.dart new file mode 100644 index 0000000..6ce8ff1 --- /dev/null +++ b/flutter/lib/features/mail/presentation/pages/mail_search_page.dart @@ -0,0 +1,374 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/api_exception.dart'; +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_typography.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_search.dart'; +import 'package:arasystem_app/features/mail/data/models/mailbox.dart'; +import 'package:arasystem_app/features/mail/domain/search_text.dart'; +import 'package:arasystem_app/features/mail/mail_routes.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_search_view.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_search_controller.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_search_state.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: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_search_bar.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/mail_search_states.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/message_row.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +/// 메일 검색 — 앱 보드 `App1Search` · `App1SearchStates` (앱 디자인 README 「검색」 — 1차: 검색어 · 찾을 곳 · 결과 · 강조 · 이어 찾기 · 안내). +/// +/// - **탭 안 화면**이다 — 연 목록 위에 쌓이고 탭 막대가 보인다(자판이 열리면 틀이 숨긴다). 메일 탭을 다시 누르면 닫힌다. +/// 검색어는 주소에 싣지 않는다(화면 상태 — 시작한 메일함만 라우트의 `extra`). +/// - 열면 칸에 초점을 두고 자판을 올린다. 자판의 [검색]으로 찾고(빈 칸 · 공백뿐이면 아무 일 없이 자판을 둔다), 찾으면 자판을 내린다. +/// 결과를 끌면 자판을 내린다. [✕]는 칸과 결과를 비우고 칸에 초점을 둔다. [←] · 뒤로 가기가 검색을 닫는다. +/// - 찾을 곳은 찾기 전부터 보이고, 바꾸면 칸의 글(비었으면 보이는 결과의 검색어)로 바로 다시 찾는다 — 다시 찾으면 자판을 내린다. +/// - 결과 줄은 목록 줄 그대로(고르지 않는다 — 사진은 단추가 아니고 길게 눌러도 아무 일 없다). 누르면 읽기 화면(임시보관함은 쓰기 화면), +/// 14단계 서버의 결과면 강조 쿼리를 함께 넘긴다. 읽기 화면에서 지운 뒤 [되돌리기] 알림이 이 화면 아래에 뜬다([메일 쓰기]는 없다). +/// - 당겨서 새로고침 = 보이는 채로 다시 찾기. 새 메일 확인으로는 다시 찾지 않는다(컨트롤러). +class MailSearchPage extends ConsumerStatefulWidget { + const MailSearchPage({this.originMailboxId, super.key}); + + /// 시작한 메일함 — null 이면 받은편지함(메일함 목록의 기본 메일함) + final String? originMailboxId; + + @override + ConsumerState createState() => _MailSearchPageState(); +} + +class _MailSearchPageState extends ConsumerState { + /// 검색 화면마다 하나 — 같은 메일함에서 다시 열어도 앞 화면의 결과와 섞이지 않게 + static int _instances = 0; + + late final MailSearchArgs _args = MailSearchArgs( + instance: ++_instances, + originMailboxId: widget.originMailboxId, + ); + + final TextEditingController _field = TextEditingController(); + final FocusNode _focus = FocusNode(); + + MailSearchController get _controller => + ref.read(mailSearchProvider(_args).notifier); + + @override + void dispose() { + _field.dispose(); + _focus.dispose(); + super.dispose(); + } + + // ── 하는 일 ── + + /// 자판의 [검색] — 빈 칸 · 공백뿐이면 자판을 그대로 둔다 + void _submit(String text) { + if (_controller.submit(text)) _focus.unfocus(); + } + + /// [✕] — 칸과 결과를 비우고 칸에 초점(자판이 다시 올라온다) + void _clear() { + _field.clear(); + _controller.clear(); + _focus.requestFocus(); + } + + /// [←] — 검색을 닫는다(쌓아 연 화면이면 뒤로, 주소로 바로 열었으면 받은편지함으로) + void _close() { + if (context.canPop()) { + context.pop(); + } else { + context.go(MailRoutes.home); + } + } + + /// 찾을 곳 — 칸의 글(비었으면 보이는 결과의 검색어)로 바로 다시 찾는다. 다시 찾았으면 자판을 내린다(곳만 바꿔 두면 그대로) + void _setScope(bool all) { + if (_controller.setScope(all: all, fieldText: _field.text)) { + _focus.unfocus(); + } + } + + /// 결과 줄 — 읽기 화면(임시보관함은 쓰기 화면에서 이어 쓴다) + void _open(MailSearchItem item) { + final MailSearchState search = ref.read(mailSearchProvider(_args)); + final Mailbox? mailbox = searchRowMailboxOf( + item, + search, + ref.read(mailboxesProvider), + ); + unawaited( + mailbox?.role == MailboxRole.drafts + ? context.push(MailRoutes.composeDraft(item.id)) + : context.push( + MailRoutes.message(item.id), + extra: searchOpenOf(item, search), + ), + ); + } + + @override + Widget build(BuildContext context) { + final MailSearchState search = ref.watch(mailSearchProvider(_args)); + final MailboxesState mailboxes = ref.watch(mailboxesProvider); + // 세션이 바뀌면 컨트롤러는 처음으로 돌아간다 — 칸의 글도 비운다 + ref.listen(mailAccountProvider, (String? previous, String? next) { + if (previous != next) _field.clear(); + }); + final String? countLine = searchCountLineOf(search); + final MailSearchNotice? notice = searchNoticeOf(search); + return Scaffold( + appBar: MailSearchBar( + controller: _field, + focusNode: _focus, + onSubmitted: _submit, + onClear: _clear, + onClose: _close, + ), + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + MailSearchScopeBar( + originName: searchOriginNameOf(widget.originMailboxId, mailboxes), + allMailboxes: search.allMailboxes, + onChanged: _setScope, + ), + if (countLine != null) MailSearchCountLine(countLine), + if (notice != null) MailSearchNoticeLine(_noticeText(notice)), + Expanded( + child: _SearchBody( + search: search, + mailboxes: mailboxes, + onRefresh: _controller.refresh, + onRetry: () => unawaited(_controller.retry()), + onSearchAll: _controller.searchAll, + onLoadMore: () => unawaited(_controller.loadMore()), + onSearchMore: () => unawaited(_controller.searchMore()), + onOpen: _open, + ), + ), + ], + ), + // 읽기 화면에서 지우고 돌아오면 [되돌리기] 알림이 여기 뜬다 — [메일 쓰기]는 없다 + floatingActionButton: MailComposeArea( + key: const ValueKey('search-toast-area'), + showCompose: false, + safeInsets: MediaQuery.paddingOf(context), + ), + floatingActionButtonLocation: const MailComposeAreaLocation(), + ); + } +} + +/// 안내 줄의 글 +String _noticeText(MailSearchNotice notice) => switch (notice) { + MailSearchNotice.indexing => MailSearchTexts.indexing, + MailSearchNotice.singleChar => MailSearchTexts.singleChar, +}; + +/// 결과 자리 — 찾기 전 · 낱말이 너무 많음 · 찾는 중(뼈대) · 받지 못함 · 결과 없음 · 아직 못 찾음 · 결과. +/// 찾은 뒤에는 어느 상태에서도 당겨서 새로고침이 된다 +class _SearchBody extends StatelessWidget { + const _SearchBody({ + required this.search, + required this.mailboxes, + required this.onRefresh, + required this.onRetry, + required this.onSearchAll, + required this.onLoadMore, + required this.onSearchMore, + required this.onOpen, + }); + + final MailSearchState search; + final MailboxesState mailboxes; + final Future Function() onRefresh; + final VoidCallback onRetry; + final VoidCallback onSearchAll; + final VoidCallback onLoadMore; + final VoidCallback onSearchMore; + final ValueChanged onOpen; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme scheme = theme.colorScheme; + final String query = search.query ?? ''; + final Widget content; + switch (search.phase) { + case MailSearchPhase.idle: + return const Align( + alignment: Alignment.topLeft, + child: MailSearchIntro(), + ); + case MailSearchPhase.tooManyWords: + return const _FillViewport( + child: MailSearchTooManyWords(maxWords: kMailSearchMaxWords), + ); + case MailSearchPhase.searching: + content = ListView( + physics: const AlwaysScrollableScrollPhysics(), + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + children: const [ + MailListSkeleton(label: MailSearchTexts.loading), + ], + ); + case MailSearchPhase.failed: + final ApiException error = + search.error ?? ApiException.fromCode('SYS_001'); + content = _FillViewport( + child: MailListError( + error: error, + onRetry: onRetry, + // 찾던 메일함이 그사이 지워졌으면(RES_001) 다시 해도 같다 — 전체 메일함에서 찾는다 + onLeave: search.searchedAll ? null : onSearchAll, + leaveLabel: MailSearchTexts.searchAll, + titleStyle: theme.textTheme.titleSmall?.copyWith( + fontWeight: AppTypography.semiBold, + ), + ), + ); + // 아직 못 찾음 — 한 차례가 하나도 못 찾고 멈췄거나, 불러온 결과를 모두 뺐는데 서버에 더 있다(목록 끝이 없어 [더 찾기]로 잇는다) + case MailSearchPhase.ready + when search.items.isEmpty && search.waitsForMore: + content = _FillViewport( + child: MailSearchPartial( + busy: search.fill != MailSearchFill.none, + onMore: onSearchMore, + ), + ); + case MailSearchPhase.ready when search.items.isEmpty: + content = _FillViewport( + child: MailSearchEmpty( + query: query, + indexing: search.indexStatus == SearchIndexStatus.building, + onSearchAll: search.searchedAll ? null : onSearchAll, + ), + ); + case MailSearchPhase.ready: + content = _ResultList( + search: search, + mailboxes: mailboxes, + onLoadMore: onLoadMore, + onSearchMore: onSearchMore, + onOpen: onOpen, + ); + } + return RefreshIndicator( + onRefresh: onRefresh, + color: scheme.primary, + backgroundColor: scheme.surface, + semanticsLabel: MailSearchTexts.loading, + child: content, + ); + } +} + +/// 가운데 상태 — 화면 높이를 채우고도 당겨서 새로고침이 되게 스크롤 안에 둔다(끌면 자판을 내린다) +class _FillViewport extends StatelessWidget { + const _FillViewport({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + slivers: [ + SliverFillRemaining(hasScrollBody: false, child: Center(child: child)), + ], + ); + } +} + +/// 결과 목록 — 목록 줄 그대로(고르지 않는다) + 끝 줄(이어 찾는 중 · [다시 시도] · [더 찾기]) +class _ResultList extends ConsumerWidget { + const _ResultList({ + required this.search, + required this.mailboxes, + required this.onLoadMore, + required this.onSearchMore, + required this.onOpen, + }); + + final MailSearchState search; + final MailboxesState mailboxes; + final VoidCallback onLoadMore; + final VoidCallback onSearchMore; + final ValueChanged onOpen; + + @override + Widget build(BuildContext context, WidgetRef ref) { + // 시각 표기의 "오늘 · 어제"를 가르는 지금 — 시험은 손 시계로 덮어쓴다 + final DateTime now = ref.read(clockProvider)(); + // [되돌리기] 알림이 떠 있으면 마지막 줄이 가리지 않게(보드 「읽기 화면에서 지운 뒤」의 아래 여백 96) + final bool toast = ref.watch(mailToastProvider) != null; + final List items = search.items; + final Widget? footer = _footer(); + return Semantics( + container: true, + explicitChildNodes: true, + label: MailSearchTexts.results, + child: ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + padding: EdgeInsets.only( + bottom: toast ? AppSize.fabHeight + AppSpacing.s40 : AppSpacing.s24, + ), + itemCount: items.length + (footer == null ? 0 : 1), + itemBuilder: (BuildContext context, int index) { + if (index >= items.length) return footer!; + final MailSearchItem item = items[index]; + return MessageRow( + key: ValueKey(item.id), + view: searchRowViewOf( + item, + state: search, + mailboxes: mailboxes, + now: now, + ), + selected: false, + onTap: () => onOpen(item), + ); + }, + ), + ); + } + + /// 끝 줄 — [더 찾기](한 차례가 한 쪽을 못 채움) · [다시 시도](이어 찾다 실패) · 이어 찾는 중 · 끝 표시(보이면 다음 차례) + Widget? _footer() { + if (search.partial) { + return MailSearchMoreLine( + busy: search.fill == MailSearchFill.more, + onMore: onSearchMore, + ); + } + if (search.loadMoreFailed) { + return MailListFooter( + loading: false, + failed: true, + onReached: onLoadMore, + onRetry: onLoadMore, + loadingLabel: MailSearchTexts.loading, + ); + } + if (search.hasMore || search.fill == MailSearchFill.auto) { + return MailListFooter( + loading: search.fill == MailSearchFill.auto, + failed: false, + onReached: onLoadMore, + onRetry: onLoadMore, + loadingLabel: MailSearchTexts.loading, + ); + } + return null; + } +} 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 42809e8..d772425 100644 --- a/flutter/lib/features/mail/presentation/pages/message_reader_page.dart +++ b/flutter/lib/features/mail/presentation/pages/message_reader_page.dart @@ -4,12 +4,14 @@ import 'package:arasystem_app/core/api/api_exception.dart'; import 'package:arasystem_app/core/api/dio_provider.dart'; import 'package:arasystem_app/core/theme/app_spacing.dart'; import 'package:arasystem_app/features/mail/data/mail_link_launcher.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_search.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/domain/compose_request.dart'; import 'package:arasystem_app/features/mail/domain/mail_links.dart'; import 'package:arasystem_app/features/mail/domain/mailbox_tree.dart'; +import 'package:arasystem_app/features/mail/domain/search_highlight.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/attachment_downloads.dart'; @@ -47,12 +49,17 @@ const double kReaderHeadMaxFraction = 0.5; /// (보드 `App1Compose` 「보낸 뒤」 — 막대 위 12). /// - 옮기기 · 스팸 · 휴지통 · 영구 삭제 · 발송 취소가 끝나면 목록으로 돌아간다(목록 · 메일함 개수는 컨트롤러가 맞춘다). /// 그 일을 하는 동안은 아래 막대와 본문 링크(mailto 포함)를 막고, 돌아가기는 읽기 화면이 맨 위일 때만 한다(위에 연 화면을 닫지 않게). +/// - 검색 결과에서 열었으면([search] — 라우트의 `extra`) 강조 쿼리로 받아 맞은 말을 칠하고, 머리에 "메일함 받은편지함" 줄을 둔다 +/// (보드 `App1SearchStates` 「결과에서 연 메일」). 지우고 옮긴 뒤에는 검색 결과로 돌아간다. class MessageReaderPage extends ConsumerStatefulWidget { - const MessageReaderPage({required this.messageId, super.key}); + const MessageReaderPage({required this.messageId, this.search, super.key}); /// 주소의 메일 id final String messageId; + /// 검색 결과에서 열었다 — 강조 쿼리 · 찾은 메일함(그 밖에는 null) + final MailSearchOpen? search; + @override ConsumerState createState() => _MessageReaderPageState(); } @@ -61,8 +68,12 @@ class _MessageReaderPageState extends ConsumerState { /// 보내지 않은 메일의 삭제 확인 창이 열려 있다 — 그사이 발송이 풀리면 닫는다(보낸 메일의 삭제 확인으로 바뀌지 않게 — 웹과 같다) bool _heldDialogOpen = false; + /// 이 화면의 읽기 상태를 여는 값 — 메일 id · 검색 강조 쿼리 + MessageReaderArgs get _args => + MessageReaderArgs(widget.messageId, highlight: widget.search?.highlight); + MessageReaderController get _controller => - ref.read(messageReaderProvider(widget.messageId).notifier); + ref.read(messageReaderProvider(_args).notifier); /// 목록으로 — 쌓아 연 화면이면 뒤로, 주소로 바로 열었으면 받은편지함으로. /// 읽기 화면이 맨 위일 때만 닫는다 — 일이 끝나기를 기다리는 사이 위에 다른 화면이 열렸으면 그 화면을 닫지 않는다 @@ -154,7 +165,7 @@ class _MessageReaderPageState extends ConsumerState { void _onNavigation(MailNavigation navigation) { // 삭제 · 이동 · 스팸 등을 하는 동안은 열지 않는다 — 끝나면 목록으로 돌아간다 - if (ref.read(messageReaderProvider(widget.messageId)).busy != null) return; + if (ref.read(messageReaderProvider(_args)).busy != null) return; switch (navigation) { case OpenExternally(:final Uri uri): unawaited(_openExternally(uri)); @@ -177,13 +188,11 @@ class _MessageReaderPageState extends ConsumerState { @override Widget build(BuildContext context) { - final MessageReaderState reader = ref.watch( - messageReaderProvider(widget.messageId), - ); + final MessageReaderState reader = ref.watch(messageReaderProvider(_args)); final MailboxesState mailboxes = ref.watch(mailboxesProvider); // 받은편지함 위에서 연 화면이 사라질 때까지 [되돌리기] 알림을 쥔다(컨트롤러도 쥔다) ref.listen(mailToastProvider, (MailToast? _, MailToast? _) {}); - ref.listen(messageReaderProvider(widget.messageId), ( + ref.listen(messageReaderProvider(_args), ( MessageReaderState? previous, MessageReaderState next, ) { @@ -235,6 +244,13 @@ class _MessageReaderPageState extends ConsumerState { MessageReaderStatus.ready => _ReaderContent( messageId: widget.messageId, reader: reader, + mailboxName: widget.search == null || detail == null + ? null + : mailboxOfMessage( + detail.summary.mailboxIds, + mailboxes.items, + preferredId: widget.search?.scopeMailboxId, + )?.name, onShowImages: () => unawaited(_controller.showImages()), onCancelHeld: () => unawaited(_cancelHeld()), onNavigation: _onNavigation, @@ -338,6 +354,7 @@ class _ReaderContent extends ConsumerWidget { const _ReaderContent({ required this.messageId, required this.reader, + required this.mailboxName, required this.onShowImages, required this.onCancelHeld, required this.onNavigation, @@ -345,6 +362,9 @@ class _ReaderContent extends ConsumerWidget { final String messageId; final MessageReaderState reader; + + /// 검색 결과에서 연 메일의 메일함 이름 — 그 밖에는 null + final String? mailboxName; final VoidCallback onShowImages; final VoidCallback onCancelHeld; final void Function(MailNavigation navigation) onNavigation; @@ -376,7 +396,13 @@ class _ReaderContent extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - ReaderHeader(detail: detail, myEmail: myEmail, now: now), + ReaderHeader( + detail: detail, + myEmail: myEmail, + now: now, + subjectHighlight: reader.subjectHighlight, + mailboxName: mailboxName, + ), ReaderNotices( detail: detail, htmlOmitted: reader.htmlOmitted, @@ -389,6 +415,9 @@ class _ReaderContent extends ConsumerWidget { ReaderAttachments( attachments: attachments, largeFiles: detail.largeFiles, + nameHighlights: + detail.searchHighlight?.attachments ?? + const >{}, downloads: downloads, onDownload: (MessageAttachment attachment) => unawaited(downloader.download(attachment)), diff --git a/flutter/lib/features/mail/presentation/providers/mail_search_controller.dart b/flutter/lib/features/mail/presentation/providers/mail_search_controller.dart new file mode 100644 index 0000000..3d48e89 --- /dev/null +++ b/flutter/lib/features/mail/presentation/providers/mail_search_controller.dart @@ -0,0 +1,677 @@ +import 'dart:async'; +import 'dart:math' as math; + +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/utils/clock.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_page.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_search.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/mail_repository.dart'; +import 'package:arasystem_app/features/mail/domain/search_text.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_list_controller.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_search_state.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_state_watcher.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mailboxes_provider.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart'; + +/// 이어 찾는 한 차례에 보내는 요청의 최대 수 — 처음 찾을 때 · 목록 끝에 닿았을 때 · [더 찾기] 마다(웹 `MAIL_SEARCH_FILL_MAX_REQUESTS` 와 같다). +/// 서버(색인 방식)는 한 요청에 3,000통까지 보므로 5번이면 1만 5천 통을 훑는다 — 넘으면 사용자가 [더 찾기]로 잇는다(계약 2절 "이어 찾기") +const int kMailSearchFillMaxRequests = 5; + +/// 검색 화면 하나 — 화면(State)마다 하나([MailSearchArgs.instance]). 화면이 닫히면 버린다(autoDispose — 도는 요청도 끊는다) +final NotifierProviderFamily< + MailSearchController, + MailSearchState, + MailSearchArgs +> +mailSearchProvider = NotifierProvider.autoDispose + .family( + MailSearchController.new, + ); + +/// 메일 검색 — 계약 2절 "검색" · "검색 — 14단계"(이어 찾기 · 준비 상태), 웹 `stores/mail/list.ts` 의 검색 결과와 같은 규칙. +/// +/// - 자판의 [검색]으로만 찾는다(치는 동안 묻지 않는다). 낱말이 8개를 넘으면 묻지 않는다. 같은 검색어 · 같은 곳이면 보이는 채로 다시 찾는다. +/// - **이어 찾기**: 한 차례(처음 · 목록 끝에 닿음 · [더 찾기])에 한 쪽(20통)이 새로 차거나 더 없을 때까지 요청을 5번까지 보낸다. +/// 하나도 찾기 전에는 뼈대, 하나라도 찾으면 보이며 뒤에서 잇는다. 차례가 한 쪽을 못 채우고 끝났는데 더 있으면 저절로 잇지 않고 [더 찾기]를 둔다. +/// 다음 쪽의 `total` 이 null 이면 첫 쪽의 수를 남긴다. 같은 커서가 새 메일 없이 돌아오면 [다시 시도]로 멈춘다. +/// - **늦은 응답**: 검색 세대([_seq] — 새 검색 · 곳 바꿈 · 다시 찾기 · [✕] · 세션 바뀜마다)가 그대로일 때만 응답을 쓰고, +/// 앞 검색의 요청은 끊는다(화면을 닫아도 — 끊은 요청은 알리지 않는다). 준비 상태는 따로 세대([_indexSeq])를 둔다. +/// - **결과 갱신**: 새 메일 확인으로는 다시 찾지 않는다(보던 결과가 저절로 바뀌지 않게 — 웹과 같다). 이 앱의 읽기 화면이 바꾼 것만 +/// 바로 반영한다(`MailChange` 의 `updated` · `removedIds`) — 표시 · 메일함 · 붙잡힘만 덮고 제목 · 미리 보기 · 강조는 그대로, +/// 찾은 곳을 떠난 메일은 빼서 이 검색 동안 기억했다가 [되돌리기]로 돌아오면 받은 시각 차례의 그 자리에 다시 넣는다 +/// (불러온 범위 안에서 뺀 메일이다 — 서버 커서는 불러온 끝 줄의 다음부터 주므로 다음 쪽이 가져오지 않는다). +/// - **붙잡힌 메일**(보내는 중 · 예약): 보낼 시각 + 2초(서버 시계)에 그 줄만 다시 받아 표시 · 메일함 · 붙잡힘을 덮는다 — +/// 엔진이 풀어도 새 메일 확인의 상태는 바뀌지 않는다(목록 · 읽기 화면과 같은 규칙 · 웹 `usePendingSendRefresh`). 없어졌으면 뺀다. +/// - 세션이 바뀌면 처음(찾기 전)으로 돌아간다. +class MailSearchController extends Notifier { + MailSearchController(this.args); + + final MailSearchArgs args; + + int _seq = 0; + int _indexSeq = 0; + + /// 지금 검색 세대의 요청을 끊는 손잡이 + CancelToken? _cancel; + + /// 이 검색에서 찾은 곳을 떠나 뺀 메일 — [되돌리기]로 다시 들어오면 그 자리에 다시 넣는다(id 마다, 뺄 때의 모습) + final Map _removed = {}; + + /// 읽기 화면이 결과를 바꾼 횟수 — 그 전에 떠난 붙잡힌 줄 다시 받기의 응답은 옛 상태라 버린다 + int _localGeneration = 0; + + /// 서버 시계 − 기기 시계 — 받은 응답의 `Date` 로 잰다(붙잡힌 발송이 풀릴 때를 서버 시계로 잰다) + Duration _serverOffset = Duration.zero; + + /// 붙잡힌 줄을 다시 받을 때 + Timer? _heldTimer; + + /// 도는 붙잡힌 줄 다시 받기의 차례 번호 — null 이면 돌지 않는다(도는 동안은 다음 때를 잡지 않고 끝난 뒤에 잡는다) + int? _heldRun; + int _heldRuns = 0; + + /// 시각이 지난 채 다시 본 횟수 — 메일 · 보낼 시각마다([_heldKey]) + final Map _heldRechecks = {}; + + MailRepository get _repository => ref.read(mailRepositoryProvider); + + Clock get _clock => ref.read(clockProvider); + + @override + MailSearchState build() { + ref.watch(mailAccountProvider); + _seq++; + _indexSeq++; + _cancelRequests(); + _removed.clear(); + _serverOffset = Duration.zero; + _heldRun = null; + _heldRechecks.clear(); + _cancelHeldTimer(); + ref + ..onDispose(_cancelRequests) + ..onDispose(_cancelHeldTimer); + ref.listen(mailStateWatcherProvider, ( + MailChange? previous, + MailChange next, + ) { + // 처음부터 다시 만들 때의 0 은 바뀜이 아니다 + if (next.seq > (previous?.seq ?? 0)) _onLocalChange(next); + }); + return const MailSearchState(); + } + + void _cancelRequests() { + _cancel?.cancel(); + _cancel = null; + } + + /// 새 검색 세대 — 앞 세대의 요청을 끊고 새 손잡이를 둔다. 붙잡힌 줄 다시 보기도 새 결과가 오면 다시 잡는다 + /// (도는 다시 받기는 세대가 바뀌어 응답을 버린다 — 기다리지 않는다) + int _beginGeneration() { + _seq++; + _cancelRequests(); + _cancelHeldTimer(); + _heldRun = null; + _heldRechecks.clear(); + _cancel = CancelToken(); + return _seq; + } + + bool _alive(int seq) => ref.mounted && seq == _seq; + + // ── 하는 일 ───────────────────────────────────────── + + /// 자판의 [검색] — 빈 칸 · 공백뿐이면 아무 일도 하지 않고 거짓(자판도 그대로 둔다). + /// 낱말이 한도를 넘으면 묻지 않는다. 같은 검색어 · 같은 곳의 결과가 보이면 보이는 채로 다시 찾는다 + bool submit(String text) { + if (!ref.mounted) return false; + final String q = normalizeSearchText(text); + if (q.isEmpty) return false; + if (countSearchWords(q) > kMailSearchMaxWords) { + _beginGeneration(); + _removed.clear(); + state = MailSearchState( + phase: MailSearchPhase.tooManyWords, + allMailboxes: state.allMailboxes, + indexStatus: state.indexStatus, + ); + return true; + } + final MailSearchState current = state; + if (current.phase == MailSearchPhase.ready && + current.query == q && + current.searchedAll == current.allMailboxes) { + unawaited(_search(q, keepVisible: true)); + } else { + unawaited(_search(q)); + } + return true; + } + + /// 찾을 곳을 바꾼다 — 칸의 글([fieldText] — 비었으면 보이는 결과의 검색어)로 바로 다시 찾는다. 둘 다 없으면 곳만 바꿔 둔다. + /// 다시 찾았으면 참([submit] 과 같다 — 화면이 자판을 내린다) + bool setScope({required bool all, required String fieldText}) { + if (!ref.mounted || state.allMailboxes == all) return false; + state = state.copyWith(allMailboxes: all); + final String typed = normalizeSearchText(fieldText); + final String? q = typed.isNotEmpty ? typed : state.query; + return q != null && submit(q); + } + + /// [전체 메일함에서 찾기] — 보이는 결과의 검색어를 전체 메일함에서(결과 없음 · 찾던 메일함이 지워짐) + void searchAll() { + if (!ref.mounted) return; + final String? q = state.query; + state = state.copyWith(allMailboxes: true); + if (q != null) unawaited(_search(q)); + } + + /// [✕] — 결과를 비우고 찾기 전으로(찾을 곳은 그대로) + void clear() { + if (!ref.mounted) return; + _beginGeneration(); + _removed.clear(); + state = MailSearchState( + allMailboxes: state.allMailboxes, + indexStatus: state.indexStatus, + ); + } + + /// 당겨서 새로고침 — 보이는 채로 다시 찾는다(받지 못한 화면이면 [retry]) + Future refresh() async { + if (!ref.mounted) return; + final String? q = state.query; + if (q == null) return; + switch (state.phase) { + case MailSearchPhase.ready: + await _search(q, keepVisible: true); + case MailSearchPhase.failed: + await _search(q); + case MailSearchPhase.idle: + case MailSearchPhase.tooManyWords: + case MailSearchPhase.searching: + break; + } + } + + /// 받지 못함의 [다시 시도] — 처음부터 다시 찾는다 + Future retry() async { + if (!ref.mounted) return; + final String? q = state.query; + if (q != null) await _search(q); + } + + /// 목록 끝에 닿았다 · 끝의 [다시 시도] — 한 쪽을 채운 차례 뒤에만 다음 차례를 묻는다([더 찾기]가 필요하면 그것으로만) + Future loadMore() async { + if (!ref.mounted) return; + final MailSearchState current = state; + final CancelToken? cancel = _cancel; + if (current.phase != MailSearchPhase.ready || + current.fill != MailSearchFill.none || + current.refreshing || + current.partial || + !current.hasMore || + cancel == null) { + return; + } + state = current.copyWith(fill: MailSearchFill.auto, loadMoreFailed: false); + await _fillRound( + _seq, + cancel, + found: 0, + sent: 0, + fill: MailSearchFill.auto, + ); + } + + /// [더 찾기] — 한 쪽을 못 채우고 멈춘 차례 · 불러온 결과를 모두 뺀 뒤(목록 끝이 없다)를 잇는다(누른 동안은 다시 누르지 못한다) + Future searchMore() async { + if (!ref.mounted) return; + final MailSearchState current = state; + final CancelToken? cancel = _cancel; + if (current.phase != MailSearchPhase.ready || + current.fill != MailSearchFill.none || + current.refreshing || + !current.waitsForMore || + cancel == null) { + return; + } + state = current.copyWith(fill: MailSearchFill.more, loadMoreFailed: false); + await _fillRound( + _seq, + cancel, + found: 0, + sent: 0, + fill: MailSearchFill.more, + ); + } + + // ── 찾기 ────────────────────────────────────────── + + /// 첫 쪽을 묻고 이어 찾는다 — [keepVisible] 이면 보이는 결과를 둔 채(실패하면 그대로 두고 인터셉터가 알린다), + /// 아니면 뼈대부터(처음 찾는 쪽은 알리지 않는다 — 화면이 받지 못함을 그린다) + Future _search(String q, {bool keepVisible = false}) async { + final Ref ref = this.ref; + final int seq = _beginGeneration(); + final CancelToken cancel = _cancel!; + _removed.clear(); + final bool all = state.allMailboxes; + state = keepVisible + ? state.copyWith( + refreshing: true, + fill: MailSearchFill.none, + loadMoreFailed: false, + ) + : MailSearchState( + phase: MailSearchPhase.searching, + allMailboxes: all, + query: q, + searchedAll: all, + indexStatus: state.indexStatus, + ); + unawaited(_refreshIndexStatus()); + String? mailboxId; + if (!all) { + mailboxId = await _originMailboxId(); + if (!_alive(seq)) return; + if (mailboxId == null) { + // 시작한 메일함을 알 수 없다(메일함 목록을 받지 못함) — 그 실패를 받지 못함으로 보인다 + state = state.copyWith( + phase: MailSearchPhase.failed, + refreshing: false, + error: + ref.read(mailboxesProvider).failure ?? + ApiException.fromCode(kFallbackCode), + ); + return; + } + } + try { + final MailPage page = await _repository.searchMessages( + q: q, + mailboxId: mailboxId, + silent: !keepVisible, + cancelToken: cancel, + ); + if (!_alive(seq)) return; + _noteServerTime(page.serverTime); + final List items = _unique(page.items); + state = MailSearchState( + phase: items.isEmpty && page.pageInfo.hasMore + ? MailSearchPhase.searching + : MailSearchPhase.ready, + allMailboxes: state.allMailboxes, + query: q, + searchedAll: all, + searchedMailboxId: mailboxId, + items: List.unmodifiable(items), + pageInfo: page.pageInfo, + indexStatus: state.indexStatus, + ); + await _fillRound( + seq, + cancel, + found: items.length, + sent: 1, + fill: MailSearchFill.auto, + ); + } on DioException catch (error) { + if (!_alive(seq)) return; + if (keepVisible) { + state = state.copyWith(refreshing: false); + // 보이는 결과는 그대로다 — 붙잡힌 줄을 다시 볼 때도 다시 잡는다 + _scheduleHeldCheck(); + return; + } + state = state.copyWith( + phase: MailSearchPhase.failed, + error: toApiException(error), + ); + } + } + + /// 이어 찾는 한 차례 — 한 쪽(20통)이 새로 차거나 더 없거나 요청이 [kMailSearchFillMaxRequests] 번이 될 때까지 커서로 묻는다. + /// [found] · [sent] 는 이 차례에서 이미 찾은 수 · 보낸 요청 수. 하나도 찾기 전의 요청은 알리지 않는다(실패하면 받지 못함). + /// 그 뒤의 실패는 인터셉터가 알리고 받은 데까지 보인다(끝의 [다시 시도] — 아직 하나도 없으면 [더 찾기]) + Future _fillRound( + int seq, + CancelToken cancel, { + required int found, + required int sent, + required MailSearchFill fill, + }) async { + int added = found; + int requests = sent; + bool stalled = false; + bool failed = false; + while (_alive(seq) && + requests < kMailSearchFillMaxRequests && + added < kMailPageSize && + state.hasMore) { + final MailSearchState current = state; + final String cursor = current.pageInfo!.nextCursor!; + final bool beforeAny = current.phase == MailSearchPhase.searching; + if (current.fill != fill) state = current.copyWith(fill: fill); + final MailPage page; + try { + page = await _repository.searchMessages( + q: current.query!, + mailboxId: current.searchedMailboxId, + cursor: cursor, + silent: beforeAny, + cancelToken: cancel, + ); + } on DioException catch (error) { + if (!_alive(seq)) return; + if (beforeAny) { + state = state.copyWith( + phase: MailSearchPhase.failed, + fill: MailSearchFill.none, + error: toApiException(error), + ); + return; + } + failed = true; + break; + } + if (!_alive(seq)) return; + _noteServerTime(page.serverTime); + requests++; + final List fresh = _fresh(page.items); + // 이 검색에서 뺐던 메일이 다음 쪽에 왔다(다른 곳에서 되돌림) — 기억을 지우고 뺄 때 줄인 수를 되돌린다 + int revived = 0; + for (final MailSearchItem item in fresh) { + if (_removed.remove(item.id) != null) revived++; + } + added += fresh.length; + stalled = fresh.isEmpty && page.pageInfo.nextCursor == cursor; + final List items = [ + ...state.items, + ...fresh, + ]; + // 다음 쪽은 늘 null 이다(색인 방식) — 첫 쪽의 수를 남긴다 + final int? total = page.pageInfo.total ?? state.pageInfo?.total; + state = state.copyWith( + phase: items.isEmpty ? state.phase : MailSearchPhase.ready, + items: items, + pageInfo: page.pageInfo.copyWith( + total: total == null || page.pageInfo.total != null + ? total + : total + revived, + ), + ); + if (stalled) break; + } + if (!_alive(seq)) return; + final bool more = state.hasMore; + final bool empty = state.items.isEmpty; + final bool halted = failed || stalled; + state = state.copyWith( + phase: MailSearchPhase.ready, + fill: MailSearchFill.none, + refreshing: false, + partial: more && (halted ? empty : added < kMailPageSize), + loadMoreFailed: halted && !empty, + ); + _scheduleHeldCheck(); + } + + /// 첫 쪽 — 쪽 안에서 겹친 메일은 한 번만 + List _unique(List items) { + final Set seen = {}; + return [ + for (final MailSearchItem item in items) + if (seen.add(item.id)) item, + ]; + } + + /// 다음 쪽 — 지금 목록에 있는 메일은 건너뛴다(id — 그사이 새 메일이 와서 경계가 밀렸을 수 있다). + /// 이 검색에서 뺀 메일이어도 서버가 준 것이면 넣는다(다른 곳에서 되돌린 메일을 숨기지 않게) + List _fresh(List items) { + final Set known = { + for (final MailSearchItem item in state.items) item.id, + }; + return [ + for (final MailSearchItem item in items) + if (known.add(item.id)) item, + ]; + } + + /// 한 메일함에서 찾을 때의 그 메일함 — 시작한 메일함, 받은편지함 주소면 메일함 목록의 기본 메일함(받기 전이면 받아 본다) + Future _originMailboxId() async { + final String? fixed = args.originMailboxId; + if (fixed != null) return fixed; + final String? known = ref.read(mailboxesProvider).defaultMailboxId; + if (known != null) return known; + await ref.read(mailboxesProvider.notifier).reload(); + if (!ref.mounted) return null; + return ref.read(mailboxesProvider).defaultMailboxId; + } + + /// 지난 메일 검색 색인의 상태를 조용히 묻는다 — 찾을 때마다. 받지 못하면(실패 · 14단계 앞 서버) 모름(null) + Future _refreshIndexStatus() async { + final Ref ref = this.ref; + final int seq = ++_indexSeq; + final MailRepository repository = _repository; + SearchIndexStatus? status; + try { + status = await repository.searchIndexStatus(); + } on DioException { + status = null; + } + if (!ref.mounted || seq != _indexSeq) return; + state = state.copyWith(indexStatus: status); + } + + // ── 이 앱이 바꾼 것 반영 ───────────────────────────── + + /// 읽기 화면이 바꾼 메일 — 결과에 바로 반영한다(새 메일 확인 소식만으로는 다시 찾지 않는다) + void _onLocalChange(MailChange change) { + if (!ref.mounted || !change.hasLocalChanges) return; + _localGeneration++; + _applyChanges(updated: change.updated, removedIds: change.removedIds); + } + + /// 바뀐 메일을 결과에 덮는다 — 표시 · 메일함 · 붙잡힘만 덮고, 찾은 곳을 떠난 메일은 빼서 기억한다. + /// [restore] 면 기억한 메일이 다시 찾은 곳에 들어왔을 때 그 자리에 다시 넣는다([되돌리기] — 붙잡힌 줄 다시 받기는 지금 줄만 덮는다) + void _applyChanges({ + required List updated, + required Set removedIds, + bool restore = true, + }) { + final MailSearchState current = state; + if (current.phase != MailSearchPhase.ready) return; + final Map byId = { + for (final MessageSummary summary in updated) summary.id: summary, + }; + bool changed = false; + int removed = 0; + int restored = 0; + final List kept = []; + for (final MailSearchItem item in current.items) { + if (removedIds.contains(item.id)) { + changed = true; + removed++; + continue; + } + final MessageSummary? next = byId[item.id]; + if (next == null) { + kept.add(item); + continue; + } + final MailSearchItem refreshed = item.withStateOf(next); + if (_inScope(next.mailboxIds)) { + if (refreshed != item) changed = true; + kept.add(refreshed); + } else { + _removed[item.id] = refreshed; + removed++; + changed = true; + } + } + if (restore) { + // [되돌리기]로 다시 찾은 곳에 들어온 메일 — 불러온 범위 안에서 뺀 메일이라 늘 받은 시각 차례의 그 자리에 다시 넣는다 + // (불러온 끝 줄이어도 — 서버 커서는 그 줄 다음부터 준다). 이미 있으면(id) 넣지 않는다 + final Set listed = { + for (final MailSearchItem item in kept) item.id, + }; + for (final MessageSummary next in updated) { + final MailSearchItem? gone = _removed[next.id]; + if (gone == null || listed.contains(next.id)) continue; + if (!_inScope(next.mailboxIds)) { + _removed[next.id] = gone.withStateOf(next); + continue; + } + _removed.remove(next.id); + final MailSearchItem back = gone.withStateOf(next); + kept.insert(_placeOf(kept, back), back); + listed.add(back.id); + restored++; + changed = true; + } + } + removedIds.forEach(_removed.remove); + if (!changed) return; + final MailPageInfo? info = current.pageInfo; + final int? total = info?.total; + state = current.copyWith( + items: kept, + pageInfo: info != null && total != null && removed != restored + ? info.copyWith(total: math.max(0, total - removed + restored)) + : info, + ); + _scheduleHeldCheck(); + } + + /// 찾은 곳 안인가 — 한 메일함이면 그 메일함에 들었는지, 전체 메일함이면 휴지통 · 스팸메일함 밖의 메일함이 남았는지 + /// (다른 메일함으로 옮긴 메일은 남기고 메일함 표시만 바꾼다 — 웹과 같다). 메일함 역할을 아직 모르면 남긴다 + bool _inScope(List mailboxIds) { + final MailSearchState current = state; + if (!current.searchedAll) { + final String? scope = current.searchedMailboxId; + return scope != null && mailboxIds.contains(scope); + } + final MailboxesState mailboxes = ref.read(mailboxesProvider); + final String? trash = mailboxes.byRole(MailboxRole.trash)?.id; + final String? junk = mailboxes.byRole(MailboxRole.junk)?.id; + return mailboxIds.any((String id) => id != trash && id != junk); + } + + /// 받은 시각 내림차순에서 [item] 이 들 자리 — 같은 시각이면 그 뒤 + int _placeOf(List items, MailSearchItem item) { + final DateTime at = item.summary.receivedAt; + for (int index = 0; index < items.length; index++) { + if (items[index].summary.receivedAt.isBefore(at)) return index; + } + return items.length; + } + + // ── 붙잡힌 메일이 풀릴 때 ───────────────────────────── + + void _noteServerTime(DateTime? serverTime) { + if (serverTime != null) _serverOffset = serverTime.difference(_clock()); + } + + /// 서버 시계로 지금 — 받은 응답의 `Date` 로 잰 차이를 기기 시계에 더한다(없으면 기기 시계) + DateTime _serverNow() => _clock().add(_serverOffset); + + String _heldKey(String id, PendingSend pending) => + '$id\n${pending.sendAt.toIso8601String()}'; + + /// 결과의 붙잡힌 메일 가운데 가장 이른 보낼 시각 + 2초에 그 줄을 다시 받는다 — 시각이 지난 채 남으면 5초마다 3번 더 + /// (목록 컨트롤러와 같은 규칙). 다시 받는 중이면 끝난 뒤에 잡는다 + void _scheduleHeldCheck() { + _cancelHeldTimer(); + if (!ref.mounted || _heldRun != null) return; + final MailSearchState current = state; + if (current.phase != MailSearchPhase.ready) return; + final DateTime now = _serverNow(); + DateTime? next; + for (final MailSearchItem item in current.items) { + final PendingSend? pending = item.summary.pendingSend; + if (pending == null) continue; + final DateTime due = pending.sendAt.add(kHeldReleaseGrace); + final DateTime at; + if (due.isAfter(now)) { + at = due; + } else { + final int rechecks = _heldRechecks[_heldKey(item.id, pending)] ?? 0; + if (rechecks > kHeldReleaseMaxRechecks) continue; + at = now.add(kHeldReleaseRecheck); + } + if (next == null || at.isBefore(next)) next = at; + } + if (next == null) return; + _heldTimer = Timer(next.difference(now), _onHeldDue); + } + + void _onHeldDue() { + _heldTimer = null; + if (!ref.mounted) return; + final MailSearchState current = state; + if (current.phase != MailSearchPhase.ready) return; + final DateTime now = _serverNow(); + final List due = []; + for (final MailSearchItem item in current.items) { + final PendingSend? pending = item.summary.pendingSend; + if (pending == null || + pending.sendAt.add(kHeldReleaseGrace).isAfter(now)) { + continue; + } + final String key = _heldKey(item.id, pending); + final int rechecks = _heldRechecks[key] ?? 0; + if (rechecks > kHeldReleaseMaxRechecks) continue; + _heldRechecks[key] = rechecks + 1; + due.add(item.id); + } + if (due.isEmpty) { + _scheduleHeldCheck(); + return; + } + unawaited(_refreshHeld(due)); + } + + /// 붙잡힌 줄([ids])만 다시 받는다 — 표시 · 메일함 · 붙잡힘만 덮고(제목 · 미리 보기 · 강조는 그 검색 응답의 것 그대로), + /// 없어졌으면(`RES_001`) 뺀다. 실패는 알리지 않는다(다음 차례에 다시 본다). 그사이 읽기 화면이 결과를 바꿨으면 응답을 버린다(옛 상태) + Future _refreshHeld(List ids) async { + final Ref ref = this.ref; + final int seq = _seq; + final int generation = _localGeneration; + final int run = ++_heldRuns; + _heldRun = run; + final MailRepository repository = _repository; + final List updated = []; + final Set gone = {}; + for (final String id in ids) { + try { + final ({MessageDetail detail, DateTime? serverTime}) result = + await repository.getMessage(id); + if (!ref.mounted || seq != _seq) break; + _noteServerTime(result.serverTime); + updated.add(result.detail.summary); + } on DioException catch (error) { + if (apiErrorCodeOf(error) == 'RES_001') gone.add(id); + } + } + if (_heldRun == run) _heldRun = null; + if (!ref.mounted || seq != _seq) return; + if (generation == _localGeneration) { + _applyChanges(updated: updated, removedIds: gone, restore: false); + } + _scheduleHeldCheck(); + } + + void _cancelHeldTimer() { + _heldTimer?.cancel(); + _heldTimer = null; + } +} diff --git a/flutter/lib/features/mail/presentation/providers/mail_search_state.dart b/flutter/lib/features/mail/presentation/providers/mail_search_state.dart new file mode 100644 index 0000000..d9e249a --- /dev/null +++ b/flutter/lib/features/mail/presentation/providers/mail_search_state.dart @@ -0,0 +1,161 @@ +import 'package:arasystem_app/core/api/api_exception.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_page.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_search.dart'; +import 'package:flutter/foundation.dart'; + +/// 검색 화면을 여는 값 — 화면(State)마다 하나. [instance] 로 가른다(같은 메일함에서 검색을 다시 열어도 앞 화면의 결과와 섞이지 않게 — +/// 쓰기 화면의 `ComposeArgs` 와 같은 결). [originMailboxId] 는 시작한 메일함(null 이면 받은편지함 — 메일함 목록의 기본 메일함) +@immutable +class MailSearchArgs { + const MailSearchArgs({required this.instance, this.originMailboxId}); + + final int instance; + final String? originMailboxId; + + @override + bool operator ==(Object other) => + other is MailSearchArgs && other.instance == instance; + + @override + int get hashCode => instance.hashCode; +} + +/// 검색 화면의 단계 +enum MailSearchPhase { + /// 찾기 전 — 안내 글 + idle, + + /// 검색어의 낱말이 한도를 넘었다 — 묻지 않고 안내만 + tooManyWords, + + /// 처음 찾는 중 — 아직 하나도 찾지 못했다(뼈대) + searching, + + /// 찾았다 — 결과 · 결과 없음 · 아직 못 찾음 + ready, + + /// 처음 찾지 못했다 — 받지 못함 + [다시 시도] + failed, +} + +/// 도는 이어 찾기 차례 +enum MailSearchFill { + none, + + /// 처음 찾을 때 · 목록 끝에 닿았을 때 · 끝의 [다시 시도] — 목록 끝에 도는 표시 + auto, + + /// [더 찾기]를 눌렀다 — 단추가 "찾는 중…"(다시 누르지 못한다) + more, +} + +const Object _unset = Object(); + +/// 검색 화면 하나의 상태 +@immutable +class MailSearchState { + const MailSearchState({ + this.phase = MailSearchPhase.idle, + this.allMailboxes = false, + this.query, + this.searchedAll = false, + this.searchedMailboxId, + this.items = const [], + this.pageInfo, + this.error, + this.refreshing = false, + this.fill = MailSearchFill.none, + this.partial = false, + this.loadMoreFailed = false, + this.indexStatus, + }); + + final MailSearchPhase phase; + + /// 찾을 곳 — 전체 메일함(참) · 시작한 메일함(거짓). 다음 검색이 쓴다 + final bool allMailboxes; + + /// 보이는(찾는) 결과의 검색어 — 찾기 전 · 낱말이 너무 많음이면 null. 칸의 글을 고친 채 아직 찾지 않았어도 이 값이다 + final String? query; + + /// 보이는 결과를 전체 메일함에서 찾았다 — 줄마다 메일함이 다르다 + final bool searchedAll; + + /// 보이는 결과를 찾은 메일함 — 전체 메일함이면 null + final String? searchedMailboxId; + + /// 찾은 메일 — 받은 시각 내림차순(서버 차례) + final List items; + + /// 마지막으로 받은 쪽 — `total` 은 첫 쪽의 수(다음 쪽이 null 이면 남긴다 · 이 앱이 빼고 되살린 만큼 맞춘다) + final MailPageInfo? pageInfo; + + /// 처음 찾지 못한 까닭 (코드 · 추적 ID) + final ApiException? error; + + /// 보이는 채로 다시 찾는 중(같은 검색어로 다시 [검색] · 당겨서 새로고침) + final bool refreshing; + + /// 도는 이어 찾기 + final MailSearchFill fill; + + /// 한 차례(요청 5번)가 한 쪽을 채우지 못하고 끝났는데 더 찾을 것이 있다 — [더 찾기]로 사용자가 잇는다 + final bool partial; + + /// 이어 찾다 실패했다 · 같은 커서가 새 메일 없이 돌아왔다 — 목록 끝에 [다시 시도] + final bool loadMoreFailed; + + /// 지난 메일 검색 색인의 상태 — 받지 못했거나 모르면 null(안내 줄을 보이지 않는다) + final SearchIndexStatus? indexStatus; + + /// 전체 수 — 모르면 null(통 수를 보이지 않는다) + int? get total => pageInfo?.total; + + /// 서버에 더 있다 + bool get hasMore => pageInfo?.hasMore ?? false; + + /// [더 찾기]로만 잇는다 — 한 차례가 한 쪽을 못 채우고 멈췄거나, 불러온 결과를 모두 빼(읽기 화면에서 옮김 · 지움) 목록 끝이 없는데 서버에 더 있다 + bool get waitsForMore => hasMore && (partial || items.isEmpty); + + /// 결과 머리(개수 줄)를 보인다 — 찾는 중 · 찾은 뒤 + bool get showsHead => + phase == MailSearchPhase.searching || phase == MailSearchPhase.ready; + + MailSearchState copyWith({ + MailSearchPhase? phase, + bool? allMailboxes, + Object? query = _unset, + bool? searchedAll, + Object? searchedMailboxId = _unset, + List? items, + Object? pageInfo = _unset, + Object? error = _unset, + bool? refreshing, + MailSearchFill? fill, + bool? partial, + bool? loadMoreFailed, + Object? indexStatus = _unset, + }) => MailSearchState( + phase: phase ?? this.phase, + allMailboxes: allMailboxes ?? this.allMailboxes, + query: identical(query, _unset) ? this.query : query as String?, + searchedAll: searchedAll ?? this.searchedAll, + searchedMailboxId: identical(searchedMailboxId, _unset) + ? this.searchedMailboxId + : searchedMailboxId as String?, + items: items == null + ? this.items + : List.unmodifiable(items), + pageInfo: identical(pageInfo, _unset) + ? this.pageInfo + : pageInfo as MailPageInfo?, + error: identical(error, _unset) ? this.error : error as ApiException?, + refreshing: refreshing ?? this.refreshing, + fill: fill ?? this.fill, + partial: partial ?? this.partial, + loadMoreFailed: loadMoreFailed ?? this.loadMoreFailed, + indexStatus: identical(indexStatus, _unset) + ? this.indexStatus + : indexStatus as SearchIndexStatus?, + ); +} diff --git a/flutter/lib/features/mail/presentation/providers/message_reader_controller.dart b/flutter/lib/features/mail/presentation/providers/message_reader_controller.dart index eb583ca..12981c1 100644 --- a/flutter/lib/features/mail/presentation/providers/message_reader_controller.dart +++ b/flutter/lib/features/mail/presentation/providers/message_reader_controller.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:isolate'; import 'dart:math' as math; -import 'dart:typed_data'; import 'package:arasystem_app/core/api/api_error_mapper.dart'; import 'package:arasystem_app/core/api/api_exception.dart'; @@ -10,12 +9,14 @@ 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/mail_bulk_results.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_search.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/mail_repository.dart'; import 'package:arasystem_app/features/mail/domain/mail_html.dart'; import 'package:arasystem_app/features/mail/domain/mail_images.dart'; +import 'package:arasystem_app/features/mail/domain/search_highlight.dart'; import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; import 'package:arasystem_app/features/mail/presentation/providers/mail_list_controller.dart'; import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart'; @@ -24,15 +25,18 @@ import 'package:arasystem_app/features/mail/presentation/providers/mail_toast_pr import 'package:arasystem_app/features/mail/presentation/providers/mailboxes_provider.dart'; import 'package:arasystem_app/features/mail/presentation/providers/message_reader_state.dart'; import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/misc.dart'; -/// 본문 조각을 만드는 일 — 정화([mailBodyFragment]). 시험은 덮어쓸 수 있다 +/// 본문 조각을 만드는 일 — 정화([mailBodyFragment]). 시험은 덮어쓸 수 있다. +/// [textHighlights] 는 글 본문의 검색에서 맞은 곳(검색 결과에서 연 메일 — 없으면 빈 목록) typedef MailBodyBuilder = Future Function({ required String? htmlBody, required String? textBody, required bool imagesShown, + required List textHighlights, }); /// 본문 조각 만들기 — 짧고 얕은 본문은 그 자리에서, 길거나 깊은 본문은 [Isolate.run] 으로([mailBodyBuildPlaceOf]) @@ -45,11 +49,13 @@ Future buildMailBody({ required String? htmlBody, required String? textBody, required bool imagesShown, + required List textHighlights, }) async { SanitizedMailBody build() => mailBodyFragment( htmlBody: htmlBody, textBody: textBody, imagesShown: imagesShown, + textHighlights: textHighlights, ); final MailBodyBuildPlace place = mailBodyBuildPlaceOf( htmlBody: htmlBody, @@ -59,14 +65,34 @@ Future buildMailBody({ return Isolate.run(build); } -/// 메일 한 통의 읽기 화면 — 주소의 메일 id 마다 하나. 화면이 있는 동안만 둔다(autoDispose) — 다른 메일을 열면 앞 응답은 버린다. +/// 읽기 화면을 여는 값 — 주소의 메일 id 와, 검색 결과에서 열었으면 검색 강조 쿼리(라우트의 `extra` — 14단계 서버의 결과에서만) +@immutable +class MessageReaderArgs { + const MessageReaderArgs(this.messageId, {this.highlight}); + + final String messageId; + + /// 검색 강조 쿼리 — 있으면 그 쿼리로 받아 맞은 말을 칠한다([이미지 보기]도 같은 쿼리로) + final MessageHighlightQuery? highlight; + + @override + bool operator ==(Object other) => + other is MessageReaderArgs && + other.messageId == messageId && + other.highlight == highlight; + + @override + int get hashCode => Object.hash(messageId, highlight); +} + +/// 메일 한 통의 읽기 화면 — 여는 값([MessageReaderArgs])마다 하나. 화면이 있는 동안만 둔다(autoDispose) — 다른 메일을 열면 앞 응답은 버린다. final NotifierProviderFamily< MessageReaderController, MessageReaderState, - String + MessageReaderArgs > messageReaderProvider = NotifierProvider.autoDispose - .family( + .family( MessageReaderController.new, ); @@ -85,11 +111,17 @@ messageReaderProvider = NotifierProvider.autoDispose /// 세션이 바뀌었으면(토큰 보관의 세션 세대) 알림 · [되돌리기] · 목록 알리기를 하지 않는다. /// - 응답은 그 요청이 바꾼 칸(읽음 · 중요 · 메일함)만 지금 요약에 덮는다 — 그사이 다른 요청이 바꾼 칸 · 다시 받은 본문을 되돌리지 않게. /// - 목록 · 메일함 개수는 새 메일 확인의 소식([MailStateWatcher.markChanged] — 그렇게 맞춘 요약을 싣는다)으로 맞춘다. +/// - 검색 결과에서 열었으면(강조 쿼리 — 14단계) 그 쿼리로 받아 제목 · 첨부 이름 · 본문의 맞은 말을 칠한다. 제목 강조는 그 응답의 제목 글과 함께 쥔다 — +/// 붙잡힌 메일을 다시 봐 요약을 바꿔도 그 글로 그린다(Dart 에는 NFC 가 없어 다른 응답의 글에 위치를 쓰지 않는다). +/// [이미지 보기]는 같은 쿼리로 다시 받아 본문 · 첨부 · 강조를 함께 바꾼다. 답장 · 전달은 쓰기 화면이 쿼리 없이 따로 받는다. class MessageReaderController extends Notifier { - MessageReaderController(this.messageId); + MessageReaderController(this.args); + + /// 여는 값 — 메일 id · 검색 강조 쿼리 + final MessageReaderArgs args; /// 주소의 메일 id - final String messageId; + String get messageId => args.messageId; int _seq = 0; int _bodySeq = 0; @@ -142,12 +174,13 @@ class MessageReaderController extends Notifier { final Ref ref = this.ref; try { final ({MessageDetail detail, DateTime? serverTime}) result = - await _repository.getMessage(messageId); + await _repository.getMessage(messageId, highlight: args.highlight); if (!ref.mounted || seq != _seq) return; _noteServerTime(result.serverTime); state = MessageReaderState( status: MessageReaderStatus.ready, detail: result.detail, + subjectHighlight: _subjectHighlightOf(result.detail), ); _scheduleHeldCheck(); unawaited(_buildBody(result.detail)); @@ -175,6 +208,15 @@ class MessageReaderController extends Notifier { if (serverTime != null) _serverOffset = serverTime.difference(_clock()); } + /// 강조를 받은 응답의 제목 강조 — 맞은 곳이 없거나(제목에서 맞지 않음) 제목이 비었으면 null + HighlightedLine? _subjectHighlightOf(MessageDetail detail) { + final List ranges = + detail.searchHighlight?.subject ?? const []; + final String subject = detail.summary.subject; + if (ranges.isEmpty || subject.isEmpty) return null; + return (text: subject, ranges: ranges); + } + DateTime _serverNow() => _clock().add(_serverOffset); void _setSummary(MessageSummary Function(MessageSummary current) change) { @@ -212,11 +254,14 @@ class MessageReaderController extends Notifier { bool alive() => ref.mounted && bodySeq == _bodySeq; final MailBodyBuilder buildBody = ref.read(mailBodyBuilderProvider); SanitizedMailBody body; + final List textHighlights = + detail.searchHighlight?.textBody ?? const []; try { body = await buildBody( htmlBody: detail.htmlBody, textBody: detail.textBody, imagesShown: detail.imagesShown, + textHighlights: textHighlights, ); } on Object { // 정화가 뜻밖에 실패하면 서식 없이 글자 본문만 보인다 @@ -224,6 +269,7 @@ class MessageReaderController extends Notifier { htmlBody: null, textBody: detail.textBody, imagesShown: false, + textHighlights: textHighlights, ); } if (!alive()) return; @@ -309,6 +355,7 @@ class MessageReaderController extends Notifier { } /// [이미지 보기] — 이 메일을 `images=show` 로 다시 받아 본문만 바꾼다(읽음 · 중요 · 메일함은 지금 값 그대로 — 웹과 같다). + /// 검색 결과에서 열었으면 같은 강조 쿼리로 받아 본문 · 첨부 · 제목의 강조도 그 응답의 것으로 바꾼다(계약 2절 "검색 — 14단계"). /// 다시 열면 설정대로다. 실패는 인터셉터가 알리고 안내 줄은 그대로 남는다 Future showImages() async { final Ref ref = this.ref; @@ -327,12 +374,16 @@ class MessageReaderController extends Notifier { messageId, showImages: true, silent: false, + highlight: args.highlight, ); if (!ref.mounted || seq != _seq) return; final MessageDetail next = (state.detail ?? current).withBodyOf( result.detail, ); - state = state.copyWith(detail: next); + state = state.copyWith( + detail: next, + subjectHighlight: _subjectHighlightOf(result.detail), + ); await _buildBody(next); } on DioException { if (!ref.mounted || seq != _seq) return; diff --git a/flutter/lib/features/mail/presentation/providers/message_reader_state.dart b/flutter/lib/features/mail/presentation/providers/message_reader_state.dart index 5f9f064..674b025 100644 --- a/flutter/lib/features/mail/presentation/providers/message_reader_state.dart +++ b/flutter/lib/features/mail/presentation/providers/message_reader_state.dart @@ -1,7 +1,10 @@ import 'package:arasystem_app/core/api/api_exception.dart'; import 'package:arasystem_app/features/mail/data/models/message_detail.dart'; +import 'package:arasystem_app/features/mail/domain/search_highlight.dart'; import 'package:flutter/foundation.dart'; +const Object _unset = Object(); + /// 읽기 화면의 상태 enum MessageReaderStatus { /// 메일을 받는 중 @@ -37,6 +40,7 @@ class MessageReaderState { this.bodyOmitted = false, this.imagesLoading = false, this.busy, + this.subjectHighlight, }); final MessageReaderStatus status; @@ -60,6 +64,10 @@ class MessageReaderState { /// 하는 중인 일 — 없으면 null final MessageReaderBusy? busy; + /// 검색 결과에서 연 메일의 제목 강조 — 강조를 받은 응답의 제목 글과 그 맞은 곳(그 뒤에 요약을 다시 받아도 이 글로 그린다). + /// 강조가 없으면 null(머리는 지금 요약의 제목을 그대로 보인다) + final HighlightedLine? subjectHighlight; + /// 아직 보내지 않은 내 메일(보내기 취소를 기다림 · 예약) — 취소로만 보낸편지함을 떠난다 bool get isHeld => detail?.summary.isHeld ?? false; @@ -70,6 +78,7 @@ class MessageReaderState { bool? imagesLoading, MessageReaderBusy? busy, bool clearBusy = false, + Object? subjectHighlight = _unset, }) => MessageReaderState( status: status, detail: detail ?? this.detail, @@ -78,5 +87,8 @@ class MessageReaderState { bodyOmitted: bodyOmitted ?? this.bodyOmitted, imagesLoading: imagesLoading ?? this.imagesLoading, busy: clearBusy ? null : (busy ?? this.busy), + subjectHighlight: identical(subjectHighlight, _unset) + ? this.subjectHighlight + : subjectHighlight as HighlightedLine?, ); } diff --git a/flutter/lib/features/mail/presentation/widgets/highlighted_text.dart b/flutter/lib/features/mail/presentation/widgets/highlighted_text.dart new file mode 100644 index 0000000..90f0c0f --- /dev/null +++ b/flutter/lib/features/mail/presentation/widgets/highlighted_text.dart @@ -0,0 +1,55 @@ +import 'package:arasystem_app/core/theme/app_theme.dart'; +import 'package:arasystem_app/features/mail/domain/search_highlight.dart'; +import 'package:flutter/material.dart'; + +/// 검색에서 맞은 말을 칠한 글 — 글 조각(`TextSpan`)으로 그린다(웹 `HighlightedText.vue` — v-html 없이 `` 조각). +/// +/// - 맞은 말은 노랑 바탕(`--color-search-mark` #FCE7A4)만 칠하고 글자색은 그대로다. 회색 글(목록의 미리 보기)은 강조 안만 [markColor] 로 진하게 한다 +/// (보드 `App1Search` — 노랑 위 #667085 는 4.05:1 이라 #4A5363 6.3:1 로). +/// - 웹의 모서리 2 · 좌우 1px 은 글 조각에 줄 수 없어 뺐다(본문 WebView 의 `mark` 에는 있다). +/// - 위치는 [text] 의 UTF-16 [시작, 끝) — 글 밖 · 거꾸로 된 위치는 버리고, 짝 글자를 가르지 않는다([highlightSegments]). +/// - 화면 낭독기는 글만 읽는다(강조는 읽지 않는다). +class HighlightedText extends StatelessWidget { + const HighlightedText( + this.text, { + required this.ranges, + this.style, + this.markColor, + this.maxLines, + this.overflow, + super.key, + }); + + final String text; + final List ranges; + final TextStyle? style; + + /// 맞은 말의 글자색 — 없으면 둘레 글과 같다 + final Color? markColor; + final int? maxLines; + final TextOverflow? overflow; + + @override + Widget build(BuildContext context) { + final List segments = highlightSegments(text, ranges); + if (!hasMarkedSegment(segments)) { + return Text(text, style: style, maxLines: maxLines, overflow: overflow); + } + final AppExtraColors extra = Theme.of(context).extension()!; + final TextStyle mark = TextStyle( + backgroundColor: extra.searchMark, + color: markColor, + ); + return Text.rich( + TextSpan( + children: [ + for (final HighlightSegment segment in segments) + TextSpan(text: segment.text, style: segment.mark ? mark : null), + ], + ), + style: style, + maxLines: maxLines, + overflow: overflow, + ); + } +} diff --git a/flutter/lib/features/mail/presentation/widgets/mail_list_states.dart b/flutter/lib/features/mail/presentation/widgets/mail_list_states.dart index 107e311..9f9ccd9 100644 --- a/flutter/lib/features/mail/presentation/widgets/mail_list_states.dart +++ b/flutter/lib/features/mail/presentation/widgets/mail_list_states.dart @@ -40,7 +40,7 @@ class MailListEmpty extends StatelessWidget { Widget build(BuildContext context) { final ThemeData theme = Theme.of(context); final AppExtraColors extra = theme.extension()!; - return _StateLayout( + return MailStateLayout( icon: mailboxIconOf(role), iconColor: extra.textMuted, iconBackground: theme.colorScheme.surfaceContainerHigh, @@ -59,13 +59,15 @@ class MailListEmpty extends StatelessWidget { } /// 받지 못함 — 사전 문구 · 문의 코드 · [다시 시도](없는 메일함은 [받은편지함으로] — 다시 해도 같다) -/// (웹 `MailLoadError` 와 같은 결 — 문구는 에러 코드 사전에서만 온다). 읽기 화면도 쓴다(없는 메일은 [목록으로]) +/// (웹 `MailLoadError` 와 같은 결 — 문구는 에러 코드 사전에서만 온다). 읽기 화면도 쓴다(없는 메일은 [목록으로]), +/// 검색 화면도 쓴다(찾던 메일함이 지워졌으면 [전체 메일함에서 찾기] — 제목은 보드 `App1SearchStates` 대로 작게) class MailListError extends StatelessWidget { const MailListError({ required this.error, required this.onRetry, this.onLeave, this.leaveLabel = MailListTexts.toInbox, + this.titleStyle, super.key, }); @@ -78,6 +80,9 @@ class MailListError extends StatelessWidget { /// [onLeave] 단추의 이름 final String leaveLabel; + /// 제목 글자 — 없으면 [MailStateLayout] 의 기본(목록 보드의 크기) + final TextStyle? titleStyle; + @override Widget build(BuildContext context) { final ThemeData theme = Theme.of(context); @@ -85,11 +90,12 @@ class MailListError extends StatelessWidget { final VoidCallback? leave = onLeave; final bool notFound = error.code == 'RES_001' && leave != null; final String? trace = traceCodeOf(error.traceId); - return _StateLayout( + return MailStateLayout( icon: Icons.error_outline, iconColor: theme.colorScheme.error, iconBackground: theme.colorScheme.errorContainer, title: error.message, + titleStyle: titleStyle, children: [ if (trace != null) Text( @@ -114,13 +120,17 @@ String? traceCodeOf(String? traceId) { : value; } -class _StateLayout extends StatelessWidget { - const _StateLayout({ +/// 가운데 상태 화면의 틀 — 아이콘 원(72) · 제목(머리) · 그 아래 글 · 단추. 화면 가운데보다 조금 위에 온다. +/// 목록의 빈 메일함 · 받지 못함, 검색의 결과 없음 · 아직 못 찾음 · 찾을 말이 너무 많음이 쓴다 +class MailStateLayout extends StatelessWidget { + const MailStateLayout({ required this.icon, required this.iconColor, required this.iconBackground, required this.title, required this.children, + this.titleStyle, + super.key, }); final IconData icon; @@ -129,6 +139,9 @@ class _StateLayout extends StatelessWidget { final String title; final List children; + /// 제목 글자 — 없으면 `titleMedium`(목록 보드 17 → 18). 검색 보드는 15(`titleSmall`) + final TextStyle? titleStyle; + @override Widget build(BuildContext context) { final ThemeData theme = Theme.of(context); @@ -161,9 +174,11 @@ class _StateLayout extends StatelessWidget { header: true, child: KeepAllText( title, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: AppTypography.semiBold, - ), + style: + titleStyle ?? + theme.textTheme.titleMedium?.copyWith( + fontWeight: AppTypography.semiBold, + ), ), ), ), @@ -176,15 +191,18 @@ class _StateLayout extends StatelessWidget { } } -/// 처음 불러오는 동안의 뼈대 — 줄 모양의 회색 막대(화면 낭독기는 "메일 목록 불러오는 중") +/// 처음 불러오는 동안의 뼈대 — 줄 모양의 회색 막대(화면 낭독기는 "메일 목록 불러오는 중" — 검색은 "찾는 중") class MailListSkeleton extends StatelessWidget { - const MailListSkeleton({super.key}); + const MailListSkeleton({this.label = MailListTexts.loading, super.key}); + + /// 화면 낭독기 글 + final String label; @override Widget build(BuildContext context) { final Color bar = Theme.of(context).colorScheme.surfaceContainerHighest; return Semantics( - label: MailListTexts.loading, + label: label, liveRegion: true, child: ExcludeSemantics( child: Column( @@ -313,6 +331,7 @@ class MailListFooter extends StatefulWidget { required this.failed, required this.onReached, required this.onRetry, + this.loadingLabel = MailListTexts.loadingMore, super.key, }); @@ -321,6 +340,9 @@ class MailListFooter extends StatefulWidget { final VoidCallback onReached; final VoidCallback onRetry; + /// 도는 표시의 화면 낭독기 글 — 검색 결과는 "찾는 중" + final String loadingLabel; + @override State createState() => _MailListFooterState(); } @@ -356,7 +378,7 @@ class _MailListFooterState extends State { child: const Text(MailListTexts.retry), ) : Semantics( - label: MailListTexts.loadingMore, + label: widget.loadingLabel, child: const SizedBox.square( dimension: AppSize.iconLarge, child: CircularProgressIndicator( diff --git a/flutter/lib/features/mail/presentation/widgets/mail_search_bar.dart b/flutter/lib/features/mail/presentation/widgets/mail_search_bar.dart new file mode 100644 index 0000000..6d15ec7 --- /dev/null +++ b/flutter/lib/features/mail/presentation/widgets/mail_search_bar.dart @@ -0,0 +1,426 @@ +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/search_text.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/services.dart'; + +/// 검색 화면의 위 막대 — [←](검색 닫기) · 칸("메일 검색") · 글이 있으면 [✕](검색어 지우기) (보드 `App1Search`, 높이 64). +/// +/// 칸은 자판의 [검색]으로만 찾는다(치는 동안 묻지 않는다). 글 자판(메일 주소 자판은 한글을 막는다) · 자동 고침 끔 · 추천 켬 · +/// 둥근 따옴표 끔(큰따옴표가 둥근 따옴표가 되면 구절이 아니라 낱말로 찾는다). UTF-16 200자까지(서버와 같은 단위). +/// 붙여 넣은 줄바꿈은 띄어쓰기로 바꾼다(한 줄 칸은 Flutter 가 줄바꿈을 지워 낱말이 붙는다) — 긴 검색어는 두 줄까지 늘어난다. +/// [검색] 뒤 초점은 부르는 쪽이 정한다(빈 칸이면 자판을 그대로 둔다). +class MailSearchBar extends StatelessWidget implements PreferredSizeWidget { + const MailSearchBar({ + required this.controller, + required this.focusNode, + required this.onSubmitted, + required this.onClear, + required this.onClose, + super.key, + }); + + final TextEditingController controller; + final FocusNode focusNode; + final ValueChanged onSubmitted; + final VoidCallback onClear; + final VoidCallback onClose; + + @override + Size get preferredSize => const Size.fromHeight(AppSize.appBarHeight); + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + final TextStyle? style = theme.textTheme.bodyLarge; + return AppBar( + toolbarHeight: AppSize.appBarHeight, + automaticallyImplyLeading: false, + leadingWidth: AppSize.touchMin + AppSpacing.s8, + titleSpacing: AppSpacing.s4, + shape: Border( + bottom: BorderSide( + color: extra.borderSubtle, + width: AppSize.borderWidth, + ), + ), + leading: IconButton( + icon: const Icon(Icons.arrow_back, size: AppSize.iconLarge), + tooltip: MailSearchTexts.close, + color: extra.textStrong, + onPressed: onClose, + ), + // 칸의 이름은 이 묶음이 한 번 읽는다 — 보이는 안내 글은 낭독에서 뺀다(빈 칸에서 "메일 검색"을 두 번 읽지 않게 · + // 글을 쓴 뒤에도 이름이 남게) + title: Semantics( + label: MailSearchTexts.box, + child: TextField( + key: const ValueKey('mail-search-field'), + controller: controller, + focusNode: focusNode, + autofocus: true, + minLines: 1, + maxLines: 2, + keyboardType: TextInputType.text, + textInputAction: TextInputAction.search, + autocorrect: false, + enableSuggestions: true, + smartDashesType: SmartDashesType.disabled, + smartQuotesType: SmartQuotesType.disabled, + inputFormatters: const [ + _LineBreaksToSpaces(), + Utf16LengthLimitFormatter(kMailSearchMaxLength), + ], + style: style, + decoration: InputDecoration.collapsed( + hintText: null, + hint: ExcludeSemantics( + child: Text( + MailSearchTexts.box, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: style?.copyWith(color: theme.colorScheme.outline), + ), + ), + ), + onSubmitted: onSubmitted, + // 기본은 [검색] 뒤 초점을 놓는다 — 빈 칸이면 자판을 그대로 둬야 해서 부르는 쪽(onSubmitted)이 정한다 + onEditingComplete: () {}, + ), + ), + actionsPadding: const EdgeInsets.only(right: AppSpacing.s4), + actions: [ + ValueListenableBuilder( + valueListenable: controller, + builder: (BuildContext context, TextEditingValue value, Widget? _) => + value.text.isEmpty + ? const SizedBox.shrink() + : IconButton( + icon: const Icon(Icons.close, size: AppSize.iconToolbar), + tooltip: MailSearchTexts.clear, + color: extra.textStrong, + onPressed: onClear, + ), + ), + ], + ); + } +} + +/// 붙여 넣은 줄바꿈 → 띄어쓰기 — 낱말을 나누는 글자로 받는다(쓰기 화면의 받는 사람 칸과 같은 결) +class _LineBreaksToSpaces extends TextInputFormatter { + const _LineBreaksToSpaces(); + + 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 String before = newValue.text.substring(0, cursor); + final int shift = + before.length - before.replaceAll(_lineBreaks, ' ').length; + return TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: cursor - shift), + ); + } +} + +/// 찾을 곳 줄 — "찾을 곳" + 두 칸 전환 [{시작한 메일함} | 전체 메일함] (보드 `App1Search` — 높이 56 · 전환은 보이는 높이 40 · 누르는 곳 48). +/// 고른 칸은 옅은 청록 바탕 · 굵은 글(✓ 없음). 전체 메일함이면 줄 아래에 "휴지통과 스팸메일함은 빼고 찾습니다". +/// 화면 낭독기는 "찾을 곳" 묶음 안의 두 단추(고름 상태)로 읽는다 +class MailSearchScopeBar extends StatelessWidget { + const MailSearchScopeBar({ + required this.originName, + required this.allMailboxes, + required this.onChanged, + super.key, + }); + + /// 시작한 메일함의 이름 + final String originName; + + /// 전체 메일함을 골랐다 + final bool allMailboxes; + + /// 고른 곳 — 전체 메일함이면 참 + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + return DecoratedBox( + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerLow, + border: Border( + bottom: BorderSide( + color: extra.borderSubtle, + width: AppSize.borderWidth, + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + ConstrainedBox( + constraints: const BoxConstraints( + minHeight: AppSize.searchScopeRowHeight, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.s16), + child: Row( + children: [ + ExcludeSemantics( + child: Text( + MailSearchTexts.scopeLabel, + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: AppTypography.bold, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(width: AppSpacing.s12), + Expanded( + child: Semantics( + container: true, + explicitChildNodes: true, + label: MailSearchTexts.scopeLabel, + child: Row( + children: [ + Expanded( + child: _ScopeToggle( + label: originName, + selected: !allMailboxes, + first: true, + onTap: () => onChanged(false), + ), + ), + Expanded( + child: _ScopeToggle( + label: MailSearchTexts.scopeAll, + selected: allMailboxes, + first: false, + onTap: () => onChanged(true), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + if (allMailboxes) + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.s16, + 0, + AppSpacing.s16, + AppSpacing.s12, + ), + child: KeepAllText( + MailSearchTexts.scopeAllHint, + style: theme.textTheme.bodySmall?.copyWith( + color: extra.textMuted, + ), + ), + ), + ], + ), + ); + } +} + +/// 전환 칸 하나 — 누르는 곳 48 안의 보이는 40 칸. 두 칸은 가운데 선 하나를 나눠 쓴다(뒤 칸을 선 굵기만큼 앞으로 겹친다) +class _ScopeToggle extends StatelessWidget { + const _ScopeToggle({ + required this.label, + required this.selected, + required this.first, + required this.onTap, + }); + + final String label; + final bool selected; + + /// 앞 칸 — 왼쪽이 둥글다(뒤 칸은 오른쪽) + final bool first; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme scheme = theme.colorScheme; + final AppExtraColors extra = theme.extension()!; + const Radius round = Radius.circular(AppSize.searchToggleHeight / 2); + final BorderRadius radius = first + ? const BorderRadius.horizontal(left: round) + : const BorderRadius.horizontal(right: round); + final Widget toggle = Semantics( + container: true, + button: true, + selected: selected, + label: label, + onTap: onTap, + child: InkWell( + onTap: onTap, + excludeFromSemantics: true, + customBorder: RoundedRectangleBorder(borderRadius: radius), + child: SizedBox( + height: AppSize.touchMin, + child: Center( + child: Container( + height: AppSize.searchToggleHeight, + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.s12), + decoration: BoxDecoration( + color: selected ? scheme.primaryContainer : scheme.surface, + border: Border.all( + color: extra.borderStrong, + width: AppSize.borderWidth, + ), + borderRadius: radius, + ), + child: ExcludeSemantics( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: selected + ? AppTypography.bold + : AppTypography.medium, + color: selected + ? scheme.onPrimaryContainer + : extra.textStrong, + ), + ), + ), + ), + ), + ), + ), + ); + if (first) return toggle; + return Transform.translate( + offset: const Offset(-AppSize.borderWidth, 0), + child: toggle, + ); + } +} + +/// 결과 머리 — "‘계약’ · 4통"(높이 36 — 큰 글자면 글에 맞춰 늘어난다). 결과가 오면 화면 낭독기가 읽는다 +class MailSearchCountLine extends StatelessWidget { + const MailSearchCountLine(this.text, {super.key}); + + final String text; + + static const List _tabular = [ + FontFeature.tabularFigures(), + ]; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + return Semantics( + container: true, + liveRegion: true, + child: ConstrainedBox( + constraints: const BoxConstraints( + minHeight: AppSize.searchCountLineHeight, + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.s16, + vertical: AppSpacing.s4, + ), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: extra.textMuted, + fontFeatures: _tabular, + ), + ), + ), + ), + ), + ); + } +} + +/// 결과 머리 아래의 안내 줄 — 옅은 청록 바탕 · 청록 정보 아이콘 (검색 준비 중 · 한 글자 안내 — 보드 `App1Search`) +class MailSearchNoticeLine extends StatelessWidget { + const MailSearchNoticeLine(this.text, {super.key}); + + final String text; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + return Semantics( + container: true, + liveRegion: true, + child: DecoratedBox( + decoration: BoxDecoration( + color: theme.colorScheme.secondaryContainer, + border: Border( + bottom: BorderSide( + color: extra.brandTintBorder, + width: AppSize.borderWidth, + ), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.s16, + vertical: AppSpacing.s10, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: AppSpacing.s2), + child: Icon( + Icons.info_outline, + size: AppSize.iconTiny, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(width: AppSpacing.s8), + Expanded( + child: KeepAllText( + text, + style: theme.textTheme.bodySmall?.copyWith( + color: extra.textStrong, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/flutter/lib/features/mail/presentation/widgets/mail_search_states.dart b/flutter/lib/features/mail/presentation/widgets/mail_search_states.dart new file mode 100644 index 0000000..eb91f13 --- /dev/null +++ b/flutter/lib/features/mail/presentation/widgets/mail_search_states.dart @@ -0,0 +1,226 @@ +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/features/mail/presentation/widgets/mail_list_states.dart'; +import 'package:arasystem_app/shared/widgets/keep_all_text.dart'; +import 'package:flutter/material.dart'; + +/// 검색 화면의 상태 글 — 찾기 전 · 결과 없음 · 아직 못 찾음 · 찾을 말이 너무 많음 · 목록 끝의 [더 찾기] (보드 `App1Search` · `App1SearchStates`). +/// 가운데 상태는 목록과 같은 틀([MailStateLayout])이고, 제목은 보드대로 15(`titleSmall`) · 도움말 13(`bodySmall`)이다 +TextStyle? _stateTitleStyle(ThemeData theme) => + theme.textTheme.titleSmall?.copyWith(fontWeight: AppTypography.semiBold); + +TextStyle? _stateHintStyle(ThemeData theme) => + theme.textTheme.bodySmall?.copyWith( + color: theme.extension()!.textMuted, + height: AppTypography.lineHeightRelaxed, + ); + +/// 찾기 전 — 무엇을 찾는지 한 줄 안내(왼쪽 맞춤 · 위 16) +class MailSearchIntro extends StatelessWidget { + const MailSearchIntro({super.key}); + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.s16, + AppSpacing.s16, + AppSpacing.s16, + 0, + ), + child: KeepAllText(MailSearchTexts.intro, style: _stateHintStyle(theme)), + ); + } +} + +/// 결과 없음 — "‘해지 통보’에 맞는 메일이 없습니다" · 도움말 · (색인을 준비하는 중이면) 옛 한계 한 줄 · (한 메일함에서 찾았으면) [전체 메일함에서 찾기] +class MailSearchEmpty extends StatelessWidget { + const MailSearchEmpty({ + required this.query, + required this.indexing, + required this.onSearchAll, + super.key, + }); + + final String query; + + /// 지난 메일을 검색할 수 있게 준비하는 중 — 그동안 찾지 못하는 말의 예를 더한다 + final bool indexing; + + /// [전체 메일함에서 찾기] — 전체 메일함에서 찾았으면 null(단추 없음) + final VoidCallback? onSearchAll; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + final VoidCallback? searchAll = onSearchAll; + return MailStateLayout( + icon: Icons.search, + iconColor: extra.textMuted, + iconBackground: theme.colorScheme.surfaceContainerHigh, + title: MailSearchTexts.emptyTitle(query), + titleStyle: _stateTitleStyle(theme), + children: [ + KeepAllText(MailSearchTexts.emptyHint, style: _stateHintStyle(theme)), + if (indexing) ...[ + const SizedBox(height: AppSpacing.s8), + KeepAllText( + MailSearchTexts.wordLimitHint, + style: _stateHintStyle(theme), + ), + ], + if (searchAll != null) ...[ + const SizedBox(height: AppSpacing.s8), + TextButton( + onPressed: searchAll, + child: const Text(MailSearchTexts.searchAll), + ), + ], + ], + ); + } +} + +/// 아직 못 찾음 — 한 차례(요청 5번)가 하나도 찾지 못하고 끝났는데 더 찾을 것이 있다: 안내 · [더 찾기](누른 동안 "찾는 중…") +class MailSearchPartial extends StatelessWidget { + const MailSearchPartial({ + required this.busy, + required this.onMore, + super.key, + }); + + /// [더 찾기]를 눌러 찾는 중 + final bool busy; + final VoidCallback onMore; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + return MailStateLayout( + icon: Icons.search, + iconColor: extra.textMuted, + iconBackground: theme.colorScheme.surfaceContainerHigh, + title: MailSearchTexts.partialTitle, + titleStyle: _stateTitleStyle(theme), + children: [ + KeepAllText(MailSearchTexts.partialHint, style: _stateHintStyle(theme)), + const SizedBox(height: AppSpacing.s8), + MailSearchMoreButton(busy: busy, onMore: onMore), + ], + ); + } +} + +/// 찾을 말이 너무 많음 — 묻지 않고 안내만(낱말을 줄이라고) +class MailSearchTooManyWords extends StatelessWidget { + const MailSearchTooManyWords({required this.maxWords, super.key}); + + final int maxWords; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + return MailStateLayout( + icon: Icons.error_outline, + iconColor: extra.textMuted, + iconBackground: theme.colorScheme.surfaceContainerHigh, + title: MailSearchTexts.tooManyWordsTitle, + titleStyle: _stateTitleStyle(theme), + children: [ + KeepAllText( + MailSearchTexts.tooManyWordsHint(maxWords), + style: _stateHintStyle(theme), + ), + ], + ); + } +} + +/// 목록 끝 — 한 차례가 한 쪽을 못 채우고 멈췄다: 안내 한 줄 + [더 찾기] (보드 `App1SearchStates` 「목록 끝 · 더 찾기」 — 최소 높이 64) +class MailSearchMoreLine extends StatelessWidget { + const MailSearchMoreLine({ + required this.busy, + required this.onMore, + super.key, + }); + + final bool busy; + final VoidCallback onMore; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + return ConstrainedBox( + constraints: const BoxConstraints( + minHeight: AppSize.touchMin + AppSpacing.s16, + ), + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.s16, + AppSpacing.s8, + AppSpacing.s8, + AppSpacing.s8, + ), + child: Row( + children: [ + Expanded( + child: KeepAllText( + MailSearchTexts.partialHint, + style: _stateHintStyle(theme), + ), + ), + const SizedBox(width: AppSpacing.s8), + MailSearchMoreButton(busy: busy, onMore: onMore), + ], + ), + ), + ); + } +} + +/// [더 찾기] — 누른 동안은 "찾는 중…"(회색)이고 다시 누르지 못한다. 초점은 지킨다(꺼진 단추로 바꾸지 않고 누름만 받지 않는다 — +/// 화면 낭독기는 꺼진 것으로 읽는다) +class MailSearchMoreButton extends StatelessWidget { + const MailSearchMoreButton({ + required this.busy, + required this.onMore, + super.key, + }); + + final bool busy; + final VoidCallback onMore; + + @override + Widget build(BuildContext context) { + final AppExtraColors extra = Theme.of(context).extension()!; + final String label = busy ? MailSearchTexts.moreBusy : MailSearchTexts.more; + return Semantics( + container: true, + button: true, + enabled: !busy, + label: label, + onTap: busy ? null : onMore, + child: ExcludeSemantics( + child: TextButton( + // 누른 동안도 단추는 그대로 둔다(초점이 옮겨 가지 않게) — 누름만 받지 않는다 + onPressed: busy ? _ignore : onMore, + style: busy + ? TextButton.styleFrom( + foregroundColor: extra.textMuted, + overlayColor: Colors.transparent, + ) + : null, + child: Text(label), + ), + ), + ); + } + + static void _ignore() {} +} diff --git a/flutter/lib/features/mail/presentation/widgets/message_row.dart b/flutter/lib/features/mail/presentation/widgets/message_row.dart index 1c757af..f1d4b6d 100644 --- a/flutter/lib/features/mail/presentation/widgets/message_row.dart +++ b/flutter/lib/features/mail/presentation/widgets/message_row.dart @@ -3,20 +3,26 @@ 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/features/mail/presentation/widgets/highlighted_text.dart'; import 'package:flutter/material.dart'; +/// 전체 메일함 검색 결과 줄의 메일함 이름이 쓸 수 있는 폭 — 글 줄 폭의 이만큼(보드 `App1Search` 의 124 / 390 화면), 넘으면 말줄임 +const double _rowMailboxMaxFraction = 0.4; + /// 목록 줄 하나 — 보드 `App1MailList` 의 줄: 동그란 사진(40) · 보낸 사람 · 시각 / 제목 · 첨부 · 중요 / 미리 보기 한 줄. /// /// - 안 읽음은 굵은 글자로만 가른다(뱃지 없음 — 화면 낭독기는 앞에 "안 읽음"). /// - 사진을 누르면 고르기 · 풀기, 줄을 길게 누르면 고르기. 고르는 중에는 줄을 눌러도 고르기 · 풀기다(열지 않는다). /// - 아직 보내지 않은 메일은 고를 수 없다 — 사진 단추는 꺼진 것으로 읽히고, 눌러도 까닭만 알린다(부르는 쪽이). +/// - 검색 결과(보드 `App1Search`)는 고르지 않는다 — [onAvatarTap] · [onLongPress] 가 없으면 사진은 단추가 아니다. +/// 제목 · 미리 보기의 맞은 말은 노랑 바탕이고, 전체 메일함 결과는 시각 앞에 메일함 이름(시각과 같은 글자)을 둔다. class MessageRow extends StatelessWidget { const MessageRow({ required this.view, required this.selected, required this.onTap, - required this.onLongPress, - required this.onAvatarTap, + this.onLongPress, + this.onAvatarTap, super.key, }); @@ -25,58 +31,90 @@ class MessageRow extends StatelessWidget { /// 고른 줄 — 바탕이 옅은 청록이고 사진 자리에 체크 final bool selected; final VoidCallback onTap; - final VoidCallback onLongPress; - final VoidCallback onAvatarTap; + + /// 길게 누르기(고르기) — 없으면 길게 눌러도 아무 일 없다(검색 결과) + final VoidCallback? onLongPress; + + /// 사진 누르기(고르기 · 풀기) — 없으면 사진은 단추가 아니다(검색 결과) + final VoidCallback? onAvatarTap; @override Widget build(BuildContext context) { final ThemeData theme = Theme.of(context); final AppExtraColors extra = theme.extension()!; - return Semantics( - container: true, - selected: selected, - label: view.semanticsLabel, - child: Material( - color: selected ? extra.rowChecked : theme.colorScheme.surface, - child: InkWell( - onTap: onTap, - onLongPress: onLongPress, - child: DecoratedBox( - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: theme.colorScheme.surfaceContainerHigh, - width: AppSize.borderWidth, - ), - ), + final Widget ink = InkWell( + onTap: onTap, + onLongPress: onLongPress, + // 화면 낭독기의 누르기 · 길게 누르기는 위 Semantics 가 맡는다(받지 않는 길게 누르기는 알리지 않는다) + excludeFromSemantics: true, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: theme.colorScheme.surfaceContainerHigh, + width: AppSize.borderWidth, ), - child: Padding( - padding: const EdgeInsets.fromLTRB( - AppSpacing.s8, - AppSpacing.s10, - AppSpacing.s16, - AppSpacing.s10, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MailAvatarButton( - initial: view.initial, - selected: selected, - held: view.held, - label: view.selectLabel, - onTap: onAvatarTap, + ), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.s8, + AppSpacing.s10, + AppSpacing.s16, + AppSpacing.s10, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (onAvatarTap case final VoidCallback onAvatar) + MailAvatarButton( + initial: view.initial, + selected: selected, + held: view.held, + label: view.selectLabel, + onTap: onAvatar, + ) + else + ExcludeSemantics( + child: SizedBox.square( + dimension: AppSize.touchMin, + child: Center( + child: _AvatarFace( + initial: view.initial, + selected: selected, + ), + ), ), - const SizedBox(width: AppSpacing.s6), - Expanded(child: ExcludeSemantics(child: _RowTexts(view))), - ], - ), - ), + ), + const SizedBox(width: AppSpacing.s6), + Expanded(child: ExcludeSemantics(child: _RowTexts(view))), + ], ), ), ), ); + return Semantics( + container: true, + selected: selected, + label: view.semanticsLabel, + onTap: onTap, + onLongPress: onLongPress, + child: Material( + color: selected ? extra.rowChecked : theme.colorScheme.surface, + child: onLongPress == null + // 고르지 않는 줄(검색 결과) — 길게 누르기를 받아 둔다. 받지 않으면 손을 뗄 때 누르기로 읽혀 메일이 열린다 + // (검색 1차: 길게 눌러도 아무 일 없다). InkWell 의 길게 누르기는 진동하므로 쓰지 않는다 + ? GestureDetector( + onLongPress: _ignoreLongPress, + excludeFromSemantics: true, + child: ink, + ) + : ink, + ), + ); } + + static void _ignoreLongPress() {} } /// 동그란 사진 — 누르는 곳 48 안의 40 원(머리글자 · 고르면 청록 원에 체크). @@ -209,6 +247,11 @@ class _RowTexts extends StatelessWidget { final TextStyle? previewStyle = text.bodySmall?.copyWith( color: extra.textMuted, ); + final TextStyle? mailboxStyle = text.labelSmall?.copyWith( + fontWeight: AppTypography.regular, + color: extra.textMuted, + ); + final String? mailbox = view.mailboxName; return Padding( padding: const EdgeInsets.only(top: AppSpacing.s2), @@ -216,27 +259,44 @@ class _RowTexts extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, - children: [ - Expanded( - child: Text( - view.who, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: whoStyle, + LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) => Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Expanded( + child: Text( + view.who, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: whoStyle, + ), ), - ), - const SizedBox(width: AppSpacing.s8), - Text(view.time, maxLines: 1, style: timeStyle), - ], + const SizedBox(width: AppSpacing.s8), + if (mailbox != null) ...[ + ConstrainedBox( + constraints: BoxConstraints( + maxWidth: constraints.maxWidth * _rowMailboxMaxFraction, + ), + child: Text( + MailSearchTexts.rowMailbox(mailbox), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: mailboxStyle, + ), + ), + const SizedBox(width: AppSpacing.s4), + ], + Text(view.time, maxLines: 1, style: timeStyle), + ], + ), ), Row( children: [ Expanded( - child: Text( + child: HighlightedText( view.subject, + ranges: view.subjectHighlights, maxLines: 1, overflow: TextOverflow.ellipsis, style: subjectStyle, @@ -264,8 +324,11 @@ class _RowTexts extends StatelessWidget { ), ], ), - Text( + HighlightedText( view.preview, + ranges: view.previewHighlights, + // 회색 글 위 노랑은 대비가 낮아 강조 안만 진하게(웹 `--color-text-secondary` — 보드 `App1Search`) + markColor: scheme.onSurfaceVariant, maxLines: 1, overflow: TextOverflow.ellipsis, style: previewStyle, diff --git a/flutter/lib/features/mail/presentation/widgets/reader_attachments.dart b/flutter/lib/features/mail/presentation/widgets/reader_attachments.dart index 29bda44..0727b6f 100644 --- a/flutter/lib/features/mail/presentation/widgets/reader_attachments.dart +++ b/flutter/lib/features/mail/presentation/widgets/reader_attachments.dart @@ -3,8 +3,10 @@ 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/message_detail.dart'; +import 'package:arasystem_app/features/mail/domain/search_highlight.dart'; import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; import 'package:arasystem_app/features/mail/presentation/providers/attachment_downloads.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/highlighted_text.dart'; import 'package:arasystem_app/shared/widgets/keep_all_text.dart'; import 'package:flutter/material.dart'; @@ -12,6 +14,7 @@ import 'package:flutter/material.dart'; /// /// 받는 동안은 크기 자리에 진행 막대 · "받는 중 64%" 이고 단추가 [받기 취소](✕)다. /// 대용량 첨부(11단계)는 첫 판에서 받지 않는다 — 이름 · 크기와 "대용량 첨부"만 보이고, 목록 아래에 받는 길을 알린다. +/// 검색 결과에서 연 메일은 첨부 이름의 맞은 말을 칠한다([nameHighlights] — 그 응답의 첨부 이름 기준). class ReaderAttachments extends StatelessWidget { const ReaderAttachments({ required this.attachments, @@ -19,6 +22,7 @@ class ReaderAttachments extends StatelessWidget { required this.downloads, required this.onDownload, required this.onCancel, + this.nameHighlights = const >{}, super.key, }); @@ -31,6 +35,9 @@ class ReaderAttachments extends StatelessWidget { final ValueChanged onDownload; final ValueChanged onCancel; + /// 첨부 이름의 검색에서 맞은 곳 — 첨부 id 마다(맞은 첨부만) + final Map> nameHighlights; + @override Widget build(BuildContext context) { final ThemeData theme = Theme.of(context); @@ -74,6 +81,8 @@ class ReaderAttachments extends StatelessWidget { _AttachmentRow( key: ValueKey('attachment-${attachment.id}'), attachment: attachment, + nameHighlights: + nameHighlights[attachment.id] ?? const [], download: downloads[attachment.id], onDownload: () => onDownload(attachment), onCancel: () => onCancel(attachment), @@ -117,6 +126,7 @@ class ReaderAttachments extends StatelessWidget { class _AttachmentRow extends StatelessWidget { const _AttachmentRow({ required this.attachment, + required this.nameHighlights, required this.download, required this.onDownload, required this.onCancel, @@ -125,6 +135,9 @@ class _AttachmentRow extends StatelessWidget { final MessageAttachment attachment; + /// 이름 안의 검색에서 맞은 곳 + final List nameHighlights; + /// 받는 중이면 그 진행 — 아니면 null final AttachmentDownload? download; final VoidCallback onDownload; @@ -181,6 +194,7 @@ class _AttachmentRow extends StatelessWidget { return _FileRow( icon: Icons.insert_drive_file_outlined, name: attachment.name, + nameHighlights: nameHighlights, below: below, trailing: progress == null ? IconButton( @@ -208,11 +222,15 @@ class _FileRow extends StatelessWidget { required this.icon, required this.name, required this.below, + this.nameHighlights = const [], this.trailing, }); final IconData icon; final String name; + + /// [name] 안의 검색에서 맞은 곳 + final List nameHighlights; final Widget below; final Widget? trailing; @@ -261,8 +279,9 @@ class _FileRow extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text( + HighlightedText( name, + ranges: nameHighlights, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyMedium?.copyWith( diff --git a/flutter/lib/features/mail/presentation/widgets/reader_header.dart b/flutter/lib/features/mail/presentation/widgets/reader_header.dart index 1d18fb2..e1e4055 100644 --- a/flutter/lib/features/mail/presentation/widgets/reader_header.dart +++ b/flutter/lib/features/mail/presentation/widgets/reader_header.dart @@ -3,19 +3,24 @@ import 'package:arasystem_app/core/theme/app_theme.dart'; import 'package:arasystem_app/core/theme/app_typography.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/search_highlight.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/features/mail/presentation/message_reader_view.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/highlighted_text.dart'; import 'package:flutter/material.dart'; /// 읽기 화면의 머리 — 제목 · 보낸 사람(동그란 사진 40) · 시각 · "받는 사람 나 외 1명"(누르면 펼침) (보드 `App1Reader`). /// /// 펼치면 회색 상자에 보낸 사람 · 받는 사람 · 참조 · 숨은참조(내가 보낸 메일) · 날짜를 칸마다 이름 · 주소로 보인다. +/// 검색 결과에서 연 메일(보드 `App1SearchStates` 「결과에서 연 메일」)은 제목의 맞은 말을 칠하고, 받는 사람 아래에 "메일함 받은편지함" 줄을 둔다. class ReaderHeader extends StatefulWidget { const ReaderHeader({ required this.detail, required this.myEmail, required this.now, + this.subjectHighlight, + this.mailboxName, super.key, }); @@ -27,6 +32,12 @@ class ReaderHeader extends StatefulWidget { /// 시각 표기의 "오늘 · 어제"를 가르는 지금 final DateTime now; + /// 검색 강조 — 강조를 받은 응답의 제목 글과 맞은 곳. 있으면 이 글로 그린다 + final HighlightedLine? subjectHighlight; + + /// 검색 결과에서 연 메일의 메일함 이름 — "메일함 받은편지함" 줄(그 밖에는 null) + final String? mailboxName; + @override State createState() => _ReaderHeaderState(); } @@ -38,9 +49,12 @@ class _ReaderHeaderState extends State { Widget build(BuildContext context) { final ThemeData theme = Theme.of(context); final MessageDetail detail = widget.detail; - final String subject = detail.summary.subject.isEmpty - ? MailReaderTexts.noSubject - : detail.summary.subject; + final HighlightedLine? highlight = widget.subjectHighlight; + final String subject = + highlight?.text ?? + (detail.summary.subject.isEmpty + ? MailReaderTexts.noSubject + : detail.summary.subject); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, @@ -54,8 +68,9 @@ class _ReaderHeaderState extends State { ), child: Semantics( header: true, - child: Text( + child: HighlightedText( subject, + ranges: highlight?.ranges ?? const [], style: theme.textTheme.titleLarge?.copyWith( fontWeight: AppTypography.semiBold, ), @@ -73,6 +88,7 @@ class _ReaderHeaderState extends State { detail: detail, myEmail: widget.myEmail, now: widget.now, + mailboxName: widget.mailboxName, expanded: _expanded, onToggle: () => setState(() => _expanded = !_expanded), ), @@ -88,6 +104,7 @@ class _SenderRow extends StatelessWidget { required this.detail, required this.myEmail, required this.now, + required this.mailboxName, required this.expanded, required this.onToggle, }); @@ -95,6 +112,7 @@ class _SenderRow extends StatelessWidget { final MessageDetail detail; final String? myEmail; final DateTime now; + final String? mailboxName; final bool expanded; final VoidCallback onToggle; @@ -183,6 +201,7 @@ class _SenderRow extends StatelessWidget { ), ), ), + if (mailboxName case final String name) _MailboxLine(name: name), ], ), ), @@ -191,6 +210,42 @@ class _SenderRow extends StatelessWidget { } } +/// 검색 결과에서 연 메일의 "메일함 받은편지함" 줄 — 이름 칸은 흐리게(웹 3단계 `mailboxLine` · 보드 `App1SearchStates`) +class _MailboxLine extends StatelessWidget { + const _MailboxLine({required this.name}); + + final String name; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + return MergeSemantics( + child: Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + MailSearchTexts.mailboxLine, + style: theme.textTheme.bodySmall?.copyWith(color: extra.textMuted), + ), + const SizedBox(width: AppSpacing.s8), + Flexible( + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: extra.textStrong, + ), + ), + ), + ], + ), + ); + } +} + class _Avatar extends StatelessWidget { const _Avatar({required this.initial}); diff --git a/flutter/test/core/people/recipient_match_test.dart b/flutter/test/core/people/recipient_match_test.dart index a9bcaed..aa66b0d 100644 --- a/flutter/test/core/people/recipient_match_test.dart +++ b/flutter/test/core/people/recipient_match_test.dart @@ -3,6 +3,7 @@ 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/utils/js_case.dart'; import 'package:flutter_test/flutter_test.dart'; import 'recipient_match_samples.dart'; diff --git a/flutter/test/features/mail/mail_list_page_test.dart b/flutter/test/features/mail/mail_list_page_test.dart index 5b2253f..4f320ce 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/mail_search_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'; @@ -467,6 +468,7 @@ void main() { testWidgets('알림은 10초 뒤(단추가 없으면 5초 뒤) 저절로 닫힌다', (WidgetTester tester) async { h.mail.seed(kInboxId, 2); + h.mail.messages[kInboxId]!.add(_heldMessage(h.clock.now)); await h.pump(tester); await longPress(tester, '제목 m1'); await tester.tap(find.byTooltip(MailBulkTexts.delete)); @@ -481,11 +483,12 @@ void main() { await elapse(tester, const Duration(seconds: 1)); expect(find.byType(MailToastCard), findsNothing); - await tester.tap(find.byTooltip(MailListTexts.search)); + // 단추 없는 알림 — 고를 수 없는 메일의 까닭 + await tester.tap(avatarOf(_heldSubject)); await tester.pumpAndSettle(); - expect(noticeText(MailListTexts.searchNotReady), findsOneWidget); + expect(noticeText(MailListTexts.heldNotSelectable), findsOneWidget); await elapse(tester, kMailToastDuration - const Duration(seconds: 1)); - expect(noticeText(MailListTexts.searchNotReady), findsOneWidget); + expect(noticeText(MailListTexts.heldNotSelectable), findsOneWidget); await elapse(tester, const Duration(seconds: 1)); expect(find.byType(SnackBar), findsNothing); }); @@ -494,16 +497,17 @@ void main() { WidgetTester tester, ) async { h.mail.seed(kInboxId, 2); + h.mail.messages[kInboxId]!.add(_heldMessage(h.clock.now)); await h.pump(tester); await longPress(tester, '제목 m1'); await tester.tap(find.byTooltip(MailBulkTexts.delete)); await tester.pumpAndSettle(); - await tester.tap(find.byTooltip(MailListTexts.search)); + await tester.tap(avatarOf(_heldSubject)); await tester.pumpAndSettle(); expect(toastText('메일 1통을 휴지통으로 옮겼습니다.'), findsOneWidget); - expect(noticeText(MailListTexts.searchNotReady), findsOneWidget); + expect(noticeText(MailListTexts.heldNotSelectable), findsOneWidget); final Rect snack = tester.getRect(find.byType(SnackBar)); final Rect compose = tester.getRect( find.widgetWithText(FloatingActionButton, MailListTexts.compose), @@ -841,7 +845,7 @@ void main() { expect(find.text('거래처'), findsOneWidget); }); - testWidgets('[찾기]는 아직 준비 중이라 알리고, [메일 쓰기]는 쓰기 화면을 연다', ( + testWidgets('[찾기]는 검색 화면을 위에 쌓고(탭 막대는 그대로), [메일 쓰기]는 쓰기 화면을 연다', ( WidgetTester tester, ) async { h.mail.seed(kInboxId, 1); @@ -849,7 +853,11 @@ void main() { await tester.tap(find.byTooltip(MailListTexts.search)); await tester.pumpAndSettle(); - expect(noticeText(MailListTexts.searchNotReady), findsOneWidget); + expect(find.byType(MailSearchPage), findsOneWidget); + expect(find.byType(NavigationBar), findsOneWidget); + await tester.tap(find.byTooltip(MailSearchTexts.close)); + await tester.pumpAndSettle(); + expect(find.byType(MailSearchPage), findsNothing); await tester.tap(find.text(MailListTexts.compose)); await tester.pumpAndSettle(); @@ -1098,3 +1106,15 @@ void main() { expect(h.container.exists(mailStateWatcherProvider), isFalse); }); } + +/// 고를 수 없는 메일(내일 보낼 예약) — 사진을 누르면 단추 없는 알림이 뜬다 +const String _heldSubject = '예약한 메일'; + +Map _heldMessage(DateTime now) => messageJson( + 'held', + mailboxId: kInboxId, + subject: _heldSubject, + isRead: true, + receivedAt: now.subtract(const Duration(days: 1)), + pendingSend: pendingJson(now.add(const Duration(days: 1)), kind: 'scheduled'), +); diff --git a/flutter/test/features/mail/mail_repository_test.dart b/flutter/test/features/mail/mail_repository_test.dart index 7b74bc2..2d2c9cf 100644 --- a/flutter/test/features/mail/mail_repository_test.dart +++ b/flutter/test/features/mail/mail_repository_test.dart @@ -2,9 +2,11 @@ import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; +import 'package:arasystem_app/core/api/request_flags.dart'; import 'package:arasystem_app/core/files/downloaded_files.dart'; import 'package:arasystem_app/features/mail/data/models/mail_bulk_results.dart'; import 'package:arasystem_app/features/mail/data/models/mail_page.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_search.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'; @@ -498,4 +500,147 @@ void main() { expect(apiErrorOf(error).code, 'FILE_007'); }); }); + + group('검색 (계약 2절 "검색 — 14단계")', () { + test( + '검색어 · 메일함 · 쪽 크기 · 커서를 그대로 보낸다 — 전체 메일함이면 mailboxId 를 뺀다, silent 면 알리지 않는다', + () async { + h.server.on( + 'GET', + MailApiPaths.messages, + (_) => FakeResponse.ok({ + 'items': [ + { + ...messageJson('m1', mailboxId: kInboxId), + 'searchHighlight': null, + }, + ], + 'pageInfo': { + 'hasNext': true, + 'nextCursor': 'opaque==', + 'total': null, + }, + }), + ); + + final DateTime serverTime = DateTime.utc(2026, 11, 7, 2); + h.server.serverTime = serverTime; + + final MailPage page = await repository.searchMessages( + q: '계약', + mailboxId: kInboxId, + cursor: 'abc', + silent: true, + ); + expect(page.items.single.highlightSupported, isTrue); + expect(page.pageInfo.total, isNull); + // 결과의 붙잡힌 메일이 풀릴 때를 서버 시계로 잰다 + expect(page.serverTime, serverTime); + expect(h.server.requests.last.queryParameters, { + 'q': '계약', + 'mailboxId': kInboxId, + 'limit': kMailPageSize, + 'cursor': 'abc', + }); + expect(h.server.requests.last.extra[kSilentExtraKey], isTrue); + + await repository.searchMessages(q: '계약'); + expect(h.server.requests.last.queryParameters, { + 'q': '계약', + 'limit': kMailPageSize, + }); + expect(h.server.requests.last.extra[kSilentExtraKey], isNot(isTrue)); + }, + ); + + test('다음 쪽의 실패는 알린다 · 끊은 요청은 알리지 않는다', () async { + final Completer gate = Completer(); + h.server.on('GET', MailApiPaths.messages, (_) async { + await gate.future; + return FakeResponse.fail(503, 'MAIL_001'); + }); + final CancelToken cancel = CancelToken(); + final Future cancelled = errorOf( + repository.searchMessages(q: '계약', cursor: 'c1', cancelToken: cancel), + ); + await pumpEventsUntil(() => h.server.requests.isNotEmpty); + cancel.cancel(); + final Object error = await cancelled; + expect((error as DioException).type, DioExceptionType.cancel); + gate.complete(); + + expect( + apiErrorOf( + await errorOf(repository.searchMessages(q: '계약', cursor: 'c1')), + ).code, + 'MAIL_001', + ); + expect(h.notifier.shownCodes, ['MAIL_001']); + }); + + test( + '준비 상태 — building · ready, 모르는 값은 null, 받지 못하면(404) 알리지 않고 던진다', + () async { + Object? status = 'building'; + h.server.on( + 'GET', + MailApiPaths.searchIndex, + (_) => status == null + ? FakeResponse.fail(404, 'RES_001') + : FakeResponse.ok({ + 'status': status, + 'done': 3, + 'total': 10, + }), + ); + expect( + await repository.searchIndexStatus(), + SearchIndexStatus.building, + ); + status = 'ready'; + expect(await repository.searchIndexStatus(), SearchIndexStatus.ready); + status = 'paused'; + expect(await repository.searchIndexStatus(), isNull); + status = null; + expect( + apiErrorOf(await errorOf(repository.searchIndexStatus())).code, + 'RES_001', + ); + expect(h.notifier.shownCodes, isEmpty); + }, + ); + + test('메일 한 통 — 강조 쿼리는 빈 칸을 빼고 싣는다(외부 이미지와 함께도)', () async { + h.server.onPattern( + 'GET', + RegExp(r'^/messages/m1$'), + (_) => FakeResponse.ok({ + ...messageJson('m1', mailboxId: kInboxId), + 'searchHighlight': { + 'subject': [ + [0, 2], + ], + 'attachments': {}, + 'textBody': [], + }, + }), + ); + final ({MessageDetail detail, DateTime? serverTime}) result = + await repository.getMessage( + 'm1', + showImages: true, + highlight: const MessageHighlightQuery(q: '계약'), + ); + expect(h.server.requests.last.queryParameters, { + kMailImagesQuery: kMailImagesShow, + 'q': '계약', + }); + expect(result.detail.searchHighlight?.subject, <({int start, int end})>[ + (start: 0, end: 2), + ]); + + await repository.getMessage('m1'); + expect(h.server.requests.last.queryParameters, isEmpty); + }); + }); } diff --git a/flutter/test/features/mail/mail_search_controller_test.dart b/flutter/test/features/mail/mail_search_controller_test.dart new file mode 100644 index 0000000..3a27c7f --- /dev/null +++ b/flutter/test/features/mail/mail_search_controller_test.dart @@ -0,0 +1,729 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/request_flags.dart'; +import 'package:arasystem_app/core/utils/accessible_navigation.dart'; +import 'package:arasystem_app/core/utils/app_foreground.dart'; +import 'package:arasystem_app/core/utils/provider_retry.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_search.dart'; +import 'package:arasystem_app/features/mail/data/models/message_summary.dart'; +import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_search_controller.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_search_state.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_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/fakes.dart'; +import '../../support/mail_fakes.dart'; +import '../../support/mail_harness.dart'; +import '../../support/provider_harness.dart'; + +class _Foreground extends AppForeground { + @override + bool build() => true; +} + +class _NoScreenReader extends AccessibleNavigation { + @override + bool build() => false; +} + +/// 메일을 보는 직원 — 시험이 바꾼다(세션이 바뀜) +class _Account extends Notifier { + @override + String? build() => kMailTestAccount; + + void set(String? value) => state = value; +} + +final NotifierProvider<_Account, String?> _accountProvider = + NotifierProvider<_Account, String?>(_Account.new); + +/// 검색 응답 한 쪽 +FakeResponse _page( + List> items, { + String? next, + int? total, +}) => FakeResponse.ok({ + 'items': items, + 'pageInfo': { + 'hasNext': next != null, + 'nextCursor': next, + 'total': total, + }, +}); + +/// 받은편지함의 메일 [n] 통 — id 는 `1` 부터(앞이 최신) +List> _mails( + int n, { + String prefix = 'r', + int from = 1, + String mailboxId = kInboxId, +}) => >[ + for (int i = from; i < from + n; i++) + messageJson( + '$prefix$i', + mailboxId: mailboxId, + subject: '계약 $prefix$i', + receivedAt: kTestNow.subtract(Duration(minutes: i)), + isRead: true, + ), +]; + +/// 메일 검색 컨트롤러 — 계약 2절 "검색" · "검색 — 14단계"(이어 찾기 · 준비 상태 · 결과 반영) +void main() { + late ProviderHarness h; + late FakeMailBackend mail; + late ProviderContainer container; + int instance = 0; + + setUp(() async { + h = ProviderHarness(); + mail = FakeMailBackend(h.server)..install(); + await h.prepare(); + await h.store.save(tokensFor(1, now: h.clock.now)); + container = ProviderContainer( + overrides: [ + ...h.overrides, + mailAccountProvider.overrideWith( + (Ref ref) => ref.watch(_accountProvider), + ), + appForegroundProvider.overrideWith(_Foreground.new), + accessibleNavigationProvider.overrideWith(_NoScreenReader.new), + mailNoticeSinkProvider.overrideWithValue((String _) {}), + ], + retry: noAutomaticRetry, + ); + addTearDown(container.dispose); + }); + + /// 검색 화면 하나를 연다(화면이 보는 것처럼 구독한다) — 메일함 목록을 받을 때까지 기다린다 + Future open({String? origin}) async { + final MailSearchArgs args = MailSearchArgs( + instance: ++instance, + originMailboxId: origin, + ); + container + ..listen(mailboxesProvider, (MailboxesState? _, MailboxesState _) {}) + ..listen( + mailSearchProvider(args), + (MailSearchState? _, MailSearchState _) {}, + ); + await pumpEventsUntil( + () => container.read(mailboxesProvider).status == MailboxesStatus.ready, + reason: '메일함 목록', + ); + return args; + } + + MailSearchState stateOf(MailSearchArgs args) => + container.read(mailSearchProvider(args)); + + MailSearchController controllerOf(MailSearchArgs args) => + container.read(mailSearchProvider(args).notifier); + + /// 검색이 멈출 때까지(찾는 중 · 이어 찾는 중이 끝날 때까지) + Future settle(MailSearchArgs args) => pumpEventsUntil(() { + final MailSearchState s = stateOf(args); + return s.phase != MailSearchPhase.searching && + s.fill == MailSearchFill.none && + !s.refreshing; + }, reason: '검색'); + + List ids(MailSearchArgs args) => [ + for (final MailSearchItem item in stateOf(args).items) item.id, + ]; + + List searchRequests() => [ + for (final RequestOptions r in mail.listRequests) + if (r.queryParameters.containsKey('q')) r, + ]; + + MessageSummary summaryOf(Map json) => + MessageSummary.tryParse(json)!; + + group('찾기', () { + test('빈 칸 · 공백뿐이면 묻지 않고 거짓 — 찾기 전 그대로', () async { + final MailSearchArgs args = await open(); + expect(controllerOf(args).submit(''), isFalse); + expect(controllerOf(args).submit(' \u3000\n'), isFalse); + await pumpEventsUntil(() => true); + expect(searchRequests(), isEmpty); + expect(stateOf(args).phase, MailSearchPhase.idle); + }); + + test( + '받은편지함 주소에서 열면 기본 메일함에서 찾는다 — q · mailboxId · limit, 첫 쪽은 알리지 않는다', + () async { + mail.messages[kInboxId] = _mails(3); + final MailSearchArgs args = await open(); + expect(controllerOf(args).submit(' 계약 '), isTrue); + expect(stateOf(args).phase, MailSearchPhase.searching); + expect(stateOf(args).query, '계약'); + await settle(args); + + final RequestOptions request = searchRequests().single; + expect(request.queryParameters, { + 'q': '계약', + 'mailboxId': kInboxId, + 'limit': kMailPageSize, + }); + expect(request.extra[kSilentExtraKey], isTrue); + expect(stateOf(args).phase, MailSearchPhase.ready); + expect(ids(args), ['r1', 'r2', 'r3']); + expect(stateOf(args).total, 3); + expect(stateOf(args).searchedMailboxId, kInboxId); + }, + ); + + test('전체 메일함은 mailboxId 를 빼고 묻는다 — 시작한 메일함은 그 메일함', () async { + final MailSearchArgs args = await open(origin: kClientsId); + controllerOf(args).submit('계약'); + await settle(args); + expect(searchRequests().last.queryParameters['mailboxId'], kClientsId); + + controllerOf(args).setScope(all: true, fieldText: '계약'); + await settle(args); + expect( + searchRequests().last.queryParameters.containsKey('mailboxId'), + isFalse, + ); + expect(stateOf(args).searchedAll, isTrue); + }); + + test('낱말이 8개를 넘으면 묻지 않는다 — 찾을 말이 너무 많음', () async { + final MailSearchArgs args = await open(); + expect(controllerOf(args).submit('11월 정기 점검 일정 안내 메일 다시 보내 주세요'), isTrue); + await pumpEventsUntil(() => true); + expect(stateOf(args).phase, MailSearchPhase.tooManyWords); + expect(stateOf(args).query, isNull); + expect(searchRequests(), isEmpty); + }); + + test('첫 쪽을 받지 못하면 받지 못함(알리지 않는다 — 화면이 그린다) · [다시 시도]는 처음부터', () async { + mail.searchResponder = (_) => FakeResponse.fail(503, 'MAIL_001'); + final MailSearchArgs args = await open(); + controllerOf(args).submit('계약'); + await settle(args); + expect(stateOf(args).phase, MailSearchPhase.failed); + expect(stateOf(args).error?.code, 'MAIL_001'); + expect(h.notifier.shownCodes, isEmpty); + + mail.searchResponder = null; + mail.messages[kInboxId] = _mails(1); + await controllerOf(args).retry(); + await settle(args); + expect(stateOf(args).phase, MailSearchPhase.ready); + expect(ids(args), ['r1']); + }); + + test('찾던 메일함이 지워졌으면 RES_001 — [전체 메일함에서 찾기]는 보이는 검색어를 전체에서', () async { + final MailSearchArgs args = await open(origin: 'mb-gone'); + controllerOf(args).submit('계약'); + await settle(args); + expect(stateOf(args).error?.code, 'RES_001'); + + controllerOf(args).searchAll(); + await settle(args); + expect(stateOf(args).allMailboxes, isTrue); + expect(searchRequests().last.queryParameters['q'], '계약'); + expect( + searchRequests().last.queryParameters.containsKey('mailboxId'), + isFalse, + ); + }); + + test('같은 검색어 · 같은 곳이면 보이는 채로 다시 찾는다(결과를 두고) — 실패하면 그대로 두고 알린다', () async { + mail.messages[kInboxId] = _mails(2); + final MailSearchArgs args = await open(); + controllerOf(args).submit('계약'); + await settle(args); + + mail.searchResponder = (_) => FakeResponse.fail(503, 'MAIL_001'); + controllerOf(args).submit('계약'); + expect(stateOf(args).refreshing, isTrue); + expect(ids(args), ['r1', 'r2']); + await settle(args); + expect(stateOf(args).phase, MailSearchPhase.ready); + expect(ids(args), ['r1', 'r2']); + expect(h.notifier.shownCodes, ['MAIL_001']); + expect(searchRequests().last.extra[kSilentExtraKey], isNot(isTrue)); + }); + + test('[✕]는 결과를 비우고 찾기 전으로 — 찾을 곳은 그대로', () async { + mail.messages[kInboxId] = _mails(1); + final MailSearchArgs args = await open(); + controllerOf(args).setScope(all: true, fieldText: ''); + controllerOf(args).submit('계약'); + await settle(args); + controllerOf(args).clear(); + expect(stateOf(args).phase, MailSearchPhase.idle); + expect(stateOf(args).items, isEmpty); + expect(stateOf(args).allMailboxes, isTrue); + }); + + test('찾을 곳 바꾸기 — 칸의 글로, 칸이 비었으면 보이는 결과의 검색어로, 둘 다 없으면 곳만', () async { + final MailSearchArgs args = await open(); + controllerOf(args).setScope(all: true, fieldText: ''); + await pumpEventsUntil(() => true); + expect(searchRequests(), isEmpty); + expect(stateOf(args).allMailboxes, isTrue); + + controllerOf(args).setScope(all: false, fieldText: '견적'); + await settle(args); + expect(searchRequests().last.queryParameters['q'], '견적'); + + controllerOf(args).setScope(all: true, fieldText: ' '); + await settle(args); + expect(searchRequests().last.queryParameters['q'], '견적'); + expect(stateOf(args).searchedAll, isTrue); + }); + }); + + group('이어 찾기 (한 차례 5번 · 한 쪽 20통)', () { + test( + '하나도 못 찾고 hasNext 면 커서로 5번까지 — 그래도 없으면 [더 찾기](아직 못 찾음), 누르면 5번 더', + () async { + int calls = 0; + mail.searchResponder = (Map query) { + calls++; + if (calls <= 7) { + return _page(const >[], next: 'c$calls'); + } + return _page(_mails(2), total: null); + }; + final MailSearchArgs args = await open(); + controllerOf(args).submit('정산서'); + await settle(args); + expect(searchRequests(), hasLength(kMailSearchFillMaxRequests)); + expect(searchRequests()[1].queryParameters['cursor'], 'c1'); + expect( + [ + for (final RequestOptions r in searchRequests().skip(1)) + r.extra[kSilentExtraKey], + ], + everyElement(isTrue), + reason: '하나도 찾기 전의 이어 찾기는 알리지 않는다', + ); + expect(stateOf(args).phase, MailSearchPhase.ready); + expect(stateOf(args).items, isEmpty); + expect(stateOf(args).partial, isTrue); + + final Future more = controllerOf(args).searchMore(); + expect(stateOf(args).fill, MailSearchFill.more); + await more; + expect(searchRequests(), hasLength(8)); + expect(ids(args), ['r1', 'r2']); + expect(stateOf(args).partial, isFalse); + expect(stateOf(args).hasMore, isFalse); + }, + ); + + test( + '한 쪽이 차면 멈추고, 끝에 닿으면 다음 차례 — 다음 쪽의 total 이 null 이면 첫 쪽 수를 남긴다', + () async { + mail.searchResponder = (Map query) { + final Object? cursor = query['cursor']; + if (cursor == null) return _page(_mails(12), next: 'c1', total: 30); + if (cursor == 'c1') { + return _page(_mails(10, from: 13), next: 'c2'); + } + return _page(_mails(8, from: 23)); + }; + final MailSearchArgs args = await open(); + controllerOf(args).submit('계약'); + await settle(args); + expect(searchRequests(), hasLength(2)); + expect(stateOf(args).items, hasLength(22)); + expect(stateOf(args).total, 30); + expect(stateOf(args).partial, isFalse); + expect(stateOf(args).hasMore, isTrue); + expect(searchRequests()[1].extra[kSilentExtraKey], isNot(isTrue)); + + await controllerOf(args).loadMore(); + expect(searchRequests(), hasLength(3)); + expect(stateOf(args).items, hasLength(30)); + expect(stateOf(args).hasMore, isFalse); + expect(stateOf(args).total, 30); + }, + ); + + test('한 차례가 한 쪽을 못 채우고 끝났는데 더 있으면 저절로 잇지 않는다 — 목록 끝의 [더 찾기]', () async { + int calls = 0; + mail.searchResponder = (Map query) { + calls++; + return _page( + calls == 1 ? _mails(3) : const >[], + next: 'c$calls', + ); + }; + final MailSearchArgs args = await open(); + controllerOf(args).submit('견적'); + await settle(args); + expect(searchRequests(), hasLength(kMailSearchFillMaxRequests)); + expect(stateOf(args).items, hasLength(3)); + expect(stateOf(args).partial, isTrue); + + // 끝 표시가 불러도 잇지 않는다 — [더 찾기]로만 + await controllerOf(args).loadMore(); + expect(searchRequests(), hasLength(kMailSearchFillMaxRequests)); + }); + + test('같은 커서가 새 메일 없이 돌아오면 [다시 시도]로 멈춘다', () async { + mail.searchResponder = (Map query) => + query['cursor'] == null + ? _page(_mails(3), next: 'same', total: 10) + : _page(const >[], next: 'same'); + final MailSearchArgs args = await open(); + controllerOf(args).submit('계약'); + await settle(args); + expect(searchRequests(), hasLength(2)); + expect(stateOf(args).loadMoreFailed, isTrue); + expect(stateOf(args).partial, isFalse); + }); + + test('이어 찾다 실패하면 받은 데까지 보이고 끝에 [다시 시도] — 인터셉터가 알린다', () async { + int calls = 0; + mail.searchResponder = (Map query) { + calls++; + if (calls == 1) return _page(_mails(3), next: 'c1', total: 9); + if (calls == 2) return FakeResponse.fail(503, 'MAIL_001'); + return _page(_mails(6, from: 4)); + }; + final MailSearchArgs args = await open(); + controllerOf(args).submit('계약'); + await settle(args); + expect(ids(args), hasLength(3)); + expect(stateOf(args).loadMoreFailed, isTrue); + expect(h.notifier.shownCodes, ['MAIL_001']); + + await controllerOf(args).loadMore(); + expect(ids(args), hasLength(9)); + expect(stateOf(args).loadMoreFailed, isFalse); + expect(stateOf(args).hasMore, isFalse); + }); + + test('하나도 찾기 전의 이어 찾기가 실패하면 받지 못함(알리지 않는다)', () async { + int calls = 0; + mail.searchResponder = (_) { + calls++; + return calls == 1 + ? _page(const >[], next: 'c1') + : FakeResponse.fail(503, 'MAIL_001'); + }; + final MailSearchArgs args = await open(); + controllerOf(args).submit('계약'); + await settle(args); + expect(stateOf(args).phase, MailSearchPhase.failed); + expect(h.notifier.shownCodes, isEmpty); + }); + }); + + group('늦은 응답 · 세션', () { + test('새 검색을 하면 앞 검색의 요청을 끊고 그 응답은 버린다 — 끊은 요청은 알리지 않는다', () async { + mail.messages[kInboxId] = >[ + ..._mails(2), + ..._mails(1, prefix: 'q'), + ]; + final MailSearchArgs args = await open(); + final Completer slow = Completer(); + mail.searchGate = slow; + controllerOf(args).submit('r1'); + await pumpEventsUntil(() => searchRequests().length == 1); + controllerOf(args).submit('q1'); + await settle(args); + expect(stateOf(args).query, 'q1'); + expect(ids(args), ['q1']); + + slow.complete(); + await pumpEventsUntil(() => true); + expect(stateOf(args).query, 'q1'); + expect(ids(args), ['q1']); + expect(h.notifier.shownCodes, isEmpty); + }); + + test('화면이 닫히면(구독이 끊기면) 도는 요청을 끊는다 — 늦은 응답이 와도 아무 일 없다', () async { + final MailSearchArgs args = MailSearchArgs(instance: ++instance); + final ProviderSubscription subscription = container + .listen( + mailSearchProvider(args), + (MailSearchState? _, MailSearchState _) {}, + ); + container.listen( + mailboxesProvider, + (MailboxesState? _, MailboxesState _) {}, + ); + await pumpEventsUntil( + () => container.read(mailboxesProvider).status == MailboxesStatus.ready, + ); + final Completer slow = Completer(); + mail.searchGate = slow; + controllerOf(args).submit('계약'); + await pumpEventsUntil(() => searchRequests().length == 1); + subscription.close(); + await pumpEventsUntil(() => !container.exists(mailSearchProvider(args))); + slow.complete(); + await pumpEventsUntil(() => true); + expect(h.notifier.shownCodes, isEmpty); + }); + + test('세션이 바뀌면 처음(찾기 전)으로', () async { + mail.messages[kInboxId] = _mails(1); + final MailSearchArgs args = await open(); + controllerOf(args).submit('계약'); + await settle(args); + expect(stateOf(args).phase, MailSearchPhase.ready); + + container.read(_accountProvider.notifier).set('other@arasystem.it.kr'); + await pumpEventsUntil(() => stateOf(args).phase == MailSearchPhase.idle); + expect(stateOf(args).items, isEmpty); + }); + }); + + group('준비 상태 (GET /search-index)', () { + test( + '찾을 때마다 조용히 묻는다 — building · ready 를 담고, 받지 못하면(404 · 실패) 모름', + () async { + mail.messages[kInboxId] = _mails(1); + mail.searchIndex = FakeResponse.ok({ + 'status': 'building', + 'done': 3, + 'total': 10, + }); + final MailSearchArgs args = await open(); + controllerOf(args).submit('계약'); + await settle(args); + await pumpEventsUntil( + () => stateOf(args).indexStatus == SearchIndexStatus.building, + ); + expect(mail.searchIndexRequests, 1); + expect( + h.server + .received('GET', MailApiPaths.searchIndex) + .single + .extra[kSilentExtraKey], + isTrue, + ); + + mail.searchIndex = FakeResponse.fail(404, 'RES_001'); + controllerOf(args).submit('견적'); + await settle(args); + await pumpEventsUntil(() => stateOf(args).indexStatus == null); + expect(h.notifier.shownCodes, isEmpty); + + mail.searchIndex = FakeResponse.ok({ + 'status': 'paused', + }); + controllerOf(args).submit('보고'); + await settle(args); + await pumpEventsUntil(() => mail.searchIndexRequests == 3); + await pumpEventsUntil(() => true); + expect(stateOf(args).indexStatus, isNull); + }, + ); + }); + + group('읽기 화면이 바꾼 것 반영 (새 메일 확인으로는 다시 찾지 않는다)', () { + late MailSearchArgs args; + + Future searchInbox() async { + mail.messages[kInboxId] = _mails(3); + args = await open(); + controllerOf(args).submit('계약'); + await settle(args); + } + + MailStateWatcher watcher() => + container.read(mailStateWatcherProvider.notifier); + + test('읽음 · 중요는 표시만 덮는다 — 제목 · 미리 보기는 그 응답의 것 그대로', () async { + await searchInbox(); + final int before = searchRequests().length; + watcher().markChanged( + updated: [ + summaryOf({ + ...messageJson( + 'r2', + mailboxId: kInboxId, + subject: '바뀐 제목(NFC 아님)', + isFlagged: true, + ), + }), + ], + ); + final MailSearchItem item = stateOf(args).items[1]; + expect(item.summary.isFlagged, isTrue); + expect(item.summary.isRead, isFalse); + expect(item.summary.subject, '계약 r2'); + expect(searchRequests(), hasLength(before)); + }); + + test( + '한 메일함에서 찾았으면 그 메일함을 떠난 메일을 빼고 · [되돌리기]로 돌아오면 받은 시각 차례의 그 자리에', + () async { + await searchInbox(); + final Map moved = Map.of( + mail.messages[kInboxId]![1], + )..['mailboxIds'] = [kTrashId]; + watcher().markChanged(updated: [summaryOf(moved)]); + expect(ids(args), ['r1', 'r3']); + expect(stateOf(args).total, 2); + + final Map back = Map.of(moved) + ..['mailboxIds'] = [kInboxId]; + watcher().markChanged(updated: [summaryOf(back)]); + expect(ids(args), ['r1', 'r2', 'r3']); + expect(stateOf(args).total, 3); + }, + ); + + test( + '전체 메일함에서 찾았으면 휴지통 · 스팸메일함만 남은 메일을 빼고, 다른 메일함으로 옮긴 메일은 메일함만 바꾼다', + () async { + mail.messages[kInboxId] = _mails(2); + args = await open(); + controllerOf(args).setScope(all: true, fieldText: '계약'); + await settle(args); + + final Map toClients = Map.of( + mail.messages[kInboxId]![0], + )..['mailboxIds'] = [kClientsId]; + final Map toJunk = Map.of( + mail.messages[kInboxId]![1], + )..['mailboxIds'] = [kJunkId]; + watcher().markChanged( + updated: [summaryOf(toClients), summaryOf(toJunk)], + ); + expect(ids(args), ['r1']); + expect(stateOf(args).items.single.summary.mailboxIds, [ + kClientsId, + ]); + }, + ); + + test('영구 삭제 · 발송 취소(removedIds)는 빼고 기억하지 않는다 · 바뀐 것 없는 소식은 무시한다', () async { + await searchInbox(); + watcher().markChanged(removedIds: {'r1'}); + expect(ids(args), ['r2', 'r3']); + expect(stateOf(args).total, 2); + + watcher().markChanged(); + expect(ids(args), ['r2', 'r3']); + expect(searchRequests(), hasLength(1)); + }); + + test( + '불러온 끝 줄을 뺐다 [되돌리기]해도 그 자리(끝)에 다시 넣고 수도 되돌린다 — 다음 쪽은 그 줄 다음부터라 빠지지도 겹치지도 않는다', + () async { + mail.messages[kInboxId] = _mails(21); + args = await open(); + controllerOf(args).submit('계약'); + await settle(args); + expect(ids(args), hasLength(kMailPageSize)); + expect(stateOf(args).hasMore, isTrue); + expect(stateOf(args).total, 21); + + watcher().markChanged( + updated: [summaryOf(mail.move('r20', kTrashId))], + ); + expect(ids(args), hasLength(19)); + expect(stateOf(args).total, 20); + + watcher().markChanged( + updated: [summaryOf(mail.move('r20', kInboxId))], + ); + expect(ids(args), hasLength(kMailPageSize)); + expect(ids(args).last, 'r20'); + expect(stateOf(args).total, 21); + + await controllerOf(args).loadMore(); + expect(ids(args), [for (int i = 1; i <= 21; i++) 'r$i']); + expect(stateOf(args).hasMore, isFalse); + }, + ); + + test('1통만 찾고 [더 찾기]로 멈춘 결과에서 그 1통을 뺐다 되돌리면 다시 보인다 — [더 찾기]는 그대로', () async { + int calls = 0; + mail.searchResponder = (Map query) { + calls++; + return _page( + calls == 1 ? _mails(1) : const >[], + next: 'c$calls', + total: calls == 1 ? 3 : null, + ); + }; + args = await open(); + controllerOf(args).submit('계약'); + await settle(args); + expect(ids(args), ['r1']); + expect(stateOf(args).partial, isTrue); + + final Map moved = Map.of( + _mails(1).single, + )..['mailboxIds'] = [kTrashId]; + watcher().markChanged(updated: [summaryOf(moved)]); + expect(ids(args), isEmpty); + expect(stateOf(args).waitsForMore, isTrue); + expect(stateOf(args).total, 2); + + final Map back = Map.of(moved) + ..['mailboxIds'] = [kInboxId]; + watcher().markChanged(updated: [summaryOf(back)]); + expect(ids(args), ['r1']); + expect(stateOf(args).partial, isTrue); + expect(stateOf(args).total, 3); + expect(searchRequests(), hasLength(kMailSearchFillMaxRequests)); + }); + + test('불러온 결과를 모두 뺐는데 서버에 더 있으면 [더 찾기]로 잇는다 — 목록 끝이 없다', () async { + mail.messages[kInboxId] = _mails(21); + args = await open(); + controllerOf(args).submit('계약'); + await settle(args); + expect(stateOf(args).partial, isFalse); + + watcher().markChanged( + removedIds: {for (int i = 1; i <= kMailPageSize; i++) 'r$i'}, + ); + expect(ids(args), isEmpty); + expect(stateOf(args).waitsForMore, isTrue); + + final Future more = controllerOf(args).searchMore(); + expect(stateOf(args).fill, MailSearchFill.more); + await more; + expect(ids(args), ['r21']); + expect(stateOf(args).hasMore, isFalse); + expect(stateOf(args).waitsForMore, isFalse); + }); + + test('이 검색에서 뺀 메일이라도 서버가 다음 쪽에 주면 넣는다 — 겹침은 지금 목록의 id 로만 막는다', () async { + mail.searchResponder = (Map query) => + query['cursor'] == null + ? _page(_mails(20), next: 'c1', total: 21) + // 다른 곳에서 되돌린 r5 · 새 r21 — 이미 있는 r1 은 한 번만 + : _page(>[ + ..._mails(1), + ..._mails(1, from: 5), + ..._mails(1, from: 21), + ]); + args = await open(); + controllerOf(args).submit('계약'); + await settle(args); + final Map moved = Map.of(_mails(5).last) + ..['mailboxIds'] = [kTrashId]; + watcher().markChanged(updated: [summaryOf(moved)]); + expect(ids(args), hasLength(19)); + expect(stateOf(args).total, 20); + + await controllerOf(args).loadMore(); + expect(ids(args).sublist(19), ['r5', 'r21']); + expect(ids(args).where((String id) => id == 'r1'), hasLength(1)); + expect(stateOf(args).total, 21); + }); + }); +} diff --git a/flutter/test/features/mail/mail_search_page_test.dart b/flutter/test/features/mail/mail_search_page_test.dart new file mode 100644 index 0000000..d180816 --- /dev/null +++ b/flutter/test/features/mail/mail_search_page_test.dart @@ -0,0 +1,1125 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/error_lexicon.dart'; +import 'package:arasystem_app/core/modules/app_modules.dart'; +import 'package:arasystem_app/core/theme/app_colors.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/data/repositories/mail_repository.dart'; +import 'package:arasystem_app/features/mail/domain/search_text.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/mail_search_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_search_controller.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_state_watcher.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/mail_compose_area.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/mail_search_bar.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/mail_search_states.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/message_row.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/reader_header.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +import '../../support/fakes.dart'; +import '../../support/login_fakes.dart'; +import '../../support/mail_fakes.dart'; +import '../../support/mail_harness.dart'; + +/// 보드 `App1Search` 3번의 메일 — ⟦계약⟧ 이 맞은 말 +const String _doyunSubject = 'Re: 유지보수 계약 갱신 건'; +const String _doyunPreview = '말씀하신 조건으로 계약서 초안을 고쳐 첨부했습니다.'; +const String _hrSubject = '[공지] 계약직 채용 일정 안내'; +const String _jihoonSubject = '견적서 보내 드립니다'; + +/// 메일 검색 화면 (앱 보드 `App1Search` · `App1SearchStates`) — 로그인한 앱 전체를 가짜 서버로 띄워 본다 +void main() { + late MailAppHarness h; + + setUp(() => h = MailAppHarness()); + + Finder field() => find.byKey(const ValueKey('mail-search-field')); + + bool fieldFocused(WidgetTester tester) => + tester.widget(field()).focusNode!.hasFocus; + + Finder row(String subject) => + find.ancestor(of: find.text(subject), matching: find.byType(MessageRow)); + + /// [finder] 안의 글 조각 가운데 노랑 바탕(검색 강조)인 것 + List marksIn(WidgetTester tester, Finder finder) { + final List marks = []; + for (final Text text in tester.widgetList( + find.descendant(of: finder, matching: find.byType(Text)), + )) { + text.textSpan?.visitChildren((InlineSpan span) { + if (span is TextSpan && + span.style?.backgroundColor == AppColors.searchMark) { + marks.add(span.text ?? ''); + } + return true; + }); + } + return marks; + } + + /// 결과 목록의 스크롤 — 칸(두 줄 TextField)에도 Scrollable 이 있다 + Finder results() => find + .descendant(of: find.byType(ListView), matching: find.byType(Scrollable)) + .first; + + Finder toastText(String text) => find.descendant( + of: find.byType(MailToastCard), + matching: find.text(text), + ); + + Future elapse(WidgetTester tester, Duration duration) async { + h.clock.advance(duration); + await tester.pump(duration); + await tester.pumpAndSettle(); + } + + /// 목록([location] — 없으면 받은편지함)을 띄우고 [찾기]로 검색 화면을 연다 + Future openSearch(WidgetTester tester, {String? location}) async { + await h.pump(tester, location: location); + await tester.tap(find.byTooltip(MailListTexts.search)); + await tester.pumpAndSettle(); + } + + /// 칸에 [text] 를 쓰고 자판의 [검색] + Future search(WidgetTester tester, String text) async { + await tester.enterText(field(), text); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pumpAndSettle(); + } + + /// 보드 3번의 받은편지함 — 14단계 서버(강조 칸이 있다) + void seedContracts() { + final DateTime now = h.clock.now; + h.mail + ..searchHighlightField = true + ..put( + kInboxId, + messageJson( + 'm1', + mailboxId: kInboxId, + subject: _doyunSubject, + preview: _doyunPreview, + receivedAt: now.subtract(const Duration(minutes: 1)), + hasAttachment: true, + ), + ) + ..put( + kInboxId, + messageJson( + 'm2', + mailboxId: kInboxId, + fromName: '인사팀', + subject: _hrSubject, + receivedAt: now.subtract(const Duration(hours: 2)), + isRead: true, + ), + ) + ..put( + kInboxId, + messageJson( + 'm3', + mailboxId: kInboxId, + fromName: '박지훈', + subject: _jihoonSubject, + preview: '안녕하세요. 지난달 계약 기간에 맞춰 단가를 다시 계산했습니다.', + receivedAt: now.subtract(const Duration(days: 1)), + isRead: true, + ), + ); + h.mail.searchHighlights + ..['m1'] = { + 'subject': [ + [9, 11], + ], + 'preview': { + 'text': _doyunPreview, + 'ranges': [ + [10, 12], + ], + }, + } + ..['m2'] = { + 'subject': [ + [5, 7], + ], + 'preview': {'text': '채용 일정입니다', 'ranges': []}, + } + ..['m3'] = { + 'subject': [], + 'preview': { + 'text': '…지난달 계약 기간에 맞춰', + 'ranges': [ + [5, 7], + ], + }, + }; + } + + group('들어가고 나가기', () { + testWidgets('목록의 [찾기]는 탭 안에 검색 화면을 쌓는다 — 칸에 초점 · 찾기 전 안내 · 찾을 곳은 시작한 메일함', ( + WidgetTester tester, + ) async { + await openSearch(tester); + + expect(find.byType(MailSearchPage), findsOneWidget); + expect(find.byType(NavigationBar), findsOneWidget); + expect(fieldFocused(tester), isTrue); + expect(findCopy(MailSearchTexts.intro), findsOneWidget); + expect(find.byTooltip(MailSearchTexts.clear), findsNothing); + expect( + tester.getSemantics(find.widgetWithText(InkWell, '받은편지함').first), + isSemantics(isButton: true, isSelected: true), + ); + expect(find.text(MailSearchTexts.scopeAll), findsOneWidget); + expect(findCopy(MailSearchTexts.scopeAllHint), findsNothing); + expect(h.mail.searchQueries, isEmpty); + }); + + testWidgets( + '[검색] → 뼈대("찾는 중") → 결과 — 개수 줄 · 제목 · 미리 보기의 맞은 말이 노랑, 자판을 내린다', + (WidgetTester tester) async { + seedContracts(); + await openSearch(tester); + final Completer gate = Completer(); + h.mail.searchGate = gate; + await tester.enterText(field(), '계약'); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pump(); + + expect(find.text(MailSearchTexts.searching('계약')), findsOneWidget); + expect(find.bySemanticsLabel(MailSearchTexts.loading), findsWidgets); + expect(fieldFocused(tester), isFalse); + + gate.complete(); + await tester.pumpAndSettle(); + expect(find.text(MailSearchTexts.count('계약', 3)), findsOneWidget); + expect(marksIn(tester, row(_doyunSubject)), ['계약', '계약']); + expect(marksIn(tester, row(_hrSubject)), ['계약']); + // 본문에서만 맞은 메일 — 미리 보기를 서버의 맞은 말 둘레 한 줄로 + expect(find.text('…지난달 계약 기간에 맞춰'), findsOneWidget); + expect(marksIn(tester, row(_jihoonSubject)), ['계약']); + expect(h.mail.searchQueries.single, { + 'q': '계약', + 'mailboxId': kInboxId, + 'limit': kMailPageSize, + }); + }, + ); + + testWidgets('미리 보기 줄의 강조 안 글자는 진하게(#4A5363), 제목 강조는 글자색 그대로', ( + WidgetTester tester, + ) async { + seedContracts(); + await openSearch(tester); + await search(tester, '계약'); + + Color? markColorIn(String text) { + Color? found; + for (final Text widget in tester.widgetList(find.byType(Text))) { + widget.textSpan?.visitChildren((InlineSpan span) { + if (span is TextSpan && + span.text == '계약' && + span.style?.backgroundColor == AppColors.searchMark && + widget.textSpan!.toPlainText() == text) { + found = span.style?.color; + } + return true; + }); + } + return found; + } + + expect(markColorIn(_doyunPreview), AppColors.textSecondary); + expect(markColorIn(_doyunSubject), isNull); + }); + + testWidgets('빈 칸 · 공백뿐인 [검색]은 아무 일도 없다 — 자판도 그대로', ( + WidgetTester tester, + ) async { + await openSearch(tester); + await search(tester, ' \u3000 '); + expect(h.mail.searchQueries, isEmpty); + expect(fieldFocused(tester), isTrue); + expect(findCopy(MailSearchTexts.intro), findsOneWidget); + }); + + testWidgets('붙여 넣은 줄바꿈은 띄어쓰기로 바꾸고(낱말이 붙지 않게) 200자(UTF-16)까지 받는다', ( + WidgetTester tester, + ) async { + await openSearch(tester); + await tester.enterText(field(), '회의\r\n일정\n공유'); + await tester.pump(); + expect(tester.widget(field()).controller!.text, '회의 일정 공유'); + + await tester.enterText(field(), '가' * 250); + await tester.pump(); + expect( + tester.widget(field()).controller!.text.length, + kMailSearchMaxLength, + ); + }); + + testWidgets('낱말이 9개면 묻지 않고 "찾을 말이 너무 많습니다"', (WidgetTester tester) async { + await openSearch(tester); + await search(tester, '11월 정기 점검 일정 안내 메일 다시 보내 주세요'); + + expect(h.mail.searchQueries, isEmpty); + expect(findCopy(MailSearchTexts.tooManyWordsTitle), findsOneWidget); + expect( + findCopy(MailSearchTexts.tooManyWordsHint(kMailSearchMaxWords)), + findsOneWidget, + ); + expect(find.text(MailSearchTexts.searching('11월')), findsNothing); + }); + + testWidgets('[✕]는 칸과 결과를 비우고 칸에 초점 · [←]는 검색을 닫는다', ( + WidgetTester tester, + ) async { + seedContracts(); + await openSearch(tester); + await search(tester, '계약'); + expect(find.text(_doyunSubject), findsOneWidget); + + await tester.tap(find.byTooltip(MailSearchTexts.clear)); + await tester.pumpAndSettle(); + expect(tester.widget(field()).controller!.text, isEmpty); + expect(find.text(_doyunSubject), findsNothing); + expect(findCopy(MailSearchTexts.intro), findsOneWidget); + expect(fieldFocused(tester), isTrue); + + await tester.tap(find.byTooltip(MailSearchTexts.close)); + await tester.pumpAndSettle(); + expect(find.byType(MailSearchPage), findsNothing); + expect(find.byType(MailListPage), findsOneWidget); + }); + + testWidgets('뒤로 가기 · 메일 탭 다시 누르기도 검색을 닫는다', (WidgetTester tester) async { + await openSearch(tester); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.byType(MailSearchPage), findsNothing); + + await tester.tap(find.byTooltip(MailListTexts.search)); + await tester.pumpAndSettle(); + await tester.tap( + find.descendant( + of: find.byType(NavigationBar), + matching: find.text(findAppModule(AppModuleId.mail).label), + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(MailSearchPage), findsNothing); + }); + + testWidgets('다른 메일함 목록에서 열면 그 메일함에서 찾는다', (WidgetTester tester) async { + h.mail.put( + kClientsId, + messageJson('c1', mailboxId: kClientsId, subject: '거래처 계약'), + ); + await openSearch(tester, location: MailRoutes.mailbox(kClientsId)); + expect( + find.descendant( + of: find.byType(MailSearchScopeBar), + matching: find.text('거래처'), + ), + findsOneWidget, + ); + await search(tester, '계약'); + expect(h.mail.searchQueries.single['mailboxId'], kClientsId); + expect(find.text('거래처 계약'), findsOneWidget); + }); + }); + + group('찾을 곳 · 다시 찾기', () { + testWidgets('전체 메일함으로 바꾸면 칸의 글로 다시 찾는다 — 휴지통 · 스팸 안내, 줄마다 메일함 · 받는 사람', ( + WidgetTester tester, + ) async { + seedContracts(); + h.mail.put( + kSentId, + messageJson( + 's1', + mailboxId: kSentId, + subject: '유지보수 계약 문의', + to: >[addressJson('이도윤', 'doyun@example.com')], + isRead: true, + receivedAt: h.clock.now.subtract(const Duration(days: 9)), + ), + ); + h.mail.put( + kTrashId, + messageJson('t1', mailboxId: kTrashId, subject: '휴지통 계약'), + ); + await openSearch(tester); + await search(tester, '계약'); + expect(find.text('유지보수 계약 문의'), findsNothing); + + await tester.tap(find.text(MailSearchTexts.scopeAll)); + await tester.pumpAndSettle(); + + expect(h.mail.searchQueries.last.containsKey('mailboxId'), isFalse); + expect(findCopy(MailSearchTexts.scopeAllHint), findsOneWidget); + expect(find.text('${MailListTexts.recipientPrefix}이도윤'), findsOneWidget); + expect( + find.descendant( + of: row('유지보수 계약 문의'), + matching: find.text(MailSearchTexts.rowMailbox('보낸편지함')), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: row(_doyunSubject), + matching: find.text(MailSearchTexts.rowMailbox('받은편지함')), + ), + findsOneWidget, + ); + expect(find.text('휴지통 계약'), findsNothing); + expect(find.text(MailSearchTexts.count('계약', 4)), findsOneWidget); + }); + + testWidgets('칸이 비었으면 찾을 곳만 바꿔 둔다(자판도 그대로) — 다음 검색이 그 곳에서', ( + WidgetTester tester, + ) async { + await openSearch(tester); + await tester.tap(find.text(MailSearchTexts.scopeAll)); + await tester.pumpAndSettle(); + expect(h.mail.searchQueries, isEmpty); + expect(findCopy(MailSearchTexts.scopeAllHint), findsOneWidget); + expect(fieldFocused(tester), isTrue); + + await search(tester, '계약'); + expect(h.mail.searchQueries.single.containsKey('mailboxId'), isFalse); + }); + + testWidgets('칸을 지운 채 찾을 곳을 바꾸면 보이는 결과의 검색어로 다시 찾고 자판을 내린다', ( + WidgetTester tester, + ) async { + seedContracts(); + await openSearch(tester); + await search(tester, '계약'); + await tester.enterText(field(), ''); + await tester.pumpAndSettle(); + expect(fieldFocused(tester), isTrue); + + await tester.tap(find.text(MailSearchTexts.scopeAll)); + await tester.pumpAndSettle(); + expect(h.mail.searchQueries, hasLength(2)); + expect(h.mail.searchQueries.last['q'], '계약'); + expect(h.mail.searchQueries.last.containsKey('mailboxId'), isFalse); + expect(fieldFocused(tester), isFalse); + }); + + testWidgets('같은 검색어로 다시 [검색] · 당겨서 새로고침은 결과를 보인 채로 다시 찾는다', ( + WidgetTester tester, + ) async { + seedContracts(); + await openSearch(tester); + await search(tester, '계약'); + final Completer gate = Completer(); + h.mail.searchGate = gate; + await tester.showKeyboard(field()); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pump(); + expect(find.text(_doyunSubject), findsOneWidget); + expect(find.text(MailSearchTexts.searching('계약')), findsOneWidget); + gate.complete(); + await tester.pumpAndSettle(); + expect(h.mail.searchQueries, hasLength(2)); + + await tester.fling(find.text(_doyunSubject), const Offset(0, 400), 1000); + await tester.pumpAndSettle(); + expect(h.mail.searchQueries, hasLength(3)); + expect(find.text(_doyunSubject), findsOneWidget); + }); + + testWidgets('새 메일 확인으로는 다시 찾지 않는다 — 보던 결과가 저절로 바뀌지 않는다', ( + WidgetTester tester, + ) async { + seedContracts(); + await openSearch(tester); + await search(tester, '계약'); + h.mail + ..state = 'state-2' + ..arrive( + kInboxId, + messageJson('new', mailboxId: kInboxId, subject: '새 계약'), + ); + await elapse(tester, kMailStatePollInterval + const Duration(seconds: 1)); + expect(h.mail.searchQueries, hasLength(1)); + expect(find.text('새 계약'), findsNothing); + }); + }); + + group('상태 화면', () { + testWidgets('결과 없음 — 한 메일함이면 [전체 메일함에서 찾기], 전체 메일함 결과 없음에는 단추가 없다', ( + WidgetTester tester, + ) async { + await openSearch(tester); + await search(tester, '해지 통보'); + + expect(find.text(MailSearchTexts.count('해지 통보', 0)), findsOneWidget); + expect(findCopy(MailSearchTexts.emptyTitle('해지 통보')), findsOneWidget); + expect(findCopy(MailSearchTexts.emptyHint), findsOneWidget); + expect(findCopy(MailSearchTexts.wordLimitHint), findsNothing); + + await tester.tap(find.text(MailSearchTexts.searchAll)); + await tester.pumpAndSettle(); + expect(h.mail.searchQueries.last['q'], '해지 통보'); + expect(h.mail.searchQueries.last.containsKey('mailboxId'), isFalse); + expect(find.text(MailSearchTexts.searchAll), findsNothing); + }); + + testWidgets('검색 준비 중이면 안내 줄 · 결과 없음에 옛 한계 한 줄 — 준비 상태를 받지 못하면 아무 줄도 없다', ( + WidgetTester tester, + ) async { + h.mail.searchIndex = FakeResponse.ok({ + 'status': 'building', + 'done': 1, + 'total': 9, + }); + await openSearch(tester); + await search(tester, '서버'); + expect(findCopy(MailSearchTexts.indexing), findsOneWidget); + expect(findCopy(MailSearchTexts.wordLimitHint), findsOneWidget); + + h.mail.searchIndex = FakeResponse.fail(503, 'MAIL_001'); + await search(tester, '이전'); + expect(findCopy(MailSearchTexts.indexing), findsNothing); + expect(findCopy(MailSearchTexts.wordLimitHint), findsNothing); + expect(h.notifier.shownCodes, isEmpty); + }); + + testWidgets('한 글자로만 찾으면(색인이 준비됐을 때) 본문은 찾지 않았다고 알린다', ( + WidgetTester tester, + ) async { + h.mail.put( + kInboxId, + messageJson( + 'k1', + mailboxId: kInboxId, + fromName: '김민수', + subject: '김 회의', + ), + ); + await openSearch(tester); + await search(tester, '김'); + expect(findCopy(MailSearchTexts.singleChar), findsOneWidget); + + await search(tester, '김 회의'); + expect(findCopy(MailSearchTexts.singleChar), findsNothing); + }); + + testWidgets( + '받지 못함 — 사전 문구 · 문의 코드 · [다시 시도], 찾던 메일함이 지워졌으면 [전체 메일함에서 찾기]', + (WidgetTester tester) async { + h.mail.searchResponder = (_) => FakeResponse.fail(500, 'SYS_001'); + await openSearch(tester); + await search(tester, '계약'); + expect(findCopy(ErrorLexicon.getMessage('SYS_001')), findsOneWidget); + expect(find.text(MailListTexts.traceCode('trace-SY')), findsOneWidget); + expect(find.text(MailSearchTexts.searching('계약')), findsNothing); + expect(h.notifier.shownCodes, isEmpty); + + h.mail.searchResponder = (_) => FakeResponse.fail(404, 'RES_001'); + await tester.tap(find.text(MailListTexts.retry)); + await tester.pumpAndSettle(); + expect(find.text(MailSearchTexts.searchAll), findsOneWidget); + + h.mail.searchResponder = null; + await tester.tap(find.text(MailSearchTexts.searchAll)); + await tester.pumpAndSettle(); + expect(h.mail.searchQueries.last.containsKey('mailboxId'), isFalse); + expect(findCopy(MailSearchTexts.emptyTitle('계약')), findsOneWidget); + }, + ); + + testWidgets('아직 못 찾음 — [더 찾기]를 누른 동안 "찾는 중…"(다시 누르지 못한다) → 결과', ( + WidgetTester tester, + ) async { + int calls = 0; + h.mail.searchResponder = (Map query) { + calls++; + if (calls <= kMailSearchFillMaxRequests) { + return FakeResponse.ok({ + 'items': [], + 'pageInfo': { + 'hasNext': true, + 'nextCursor': 'c$calls', + 'total': null, + }, + }); + } + return FakeResponse.ok({ + 'items': [ + messageJson('p1', mailboxId: kInboxId, subject: '정산서 보냅니다'), + ], + 'pageInfo': { + 'hasNext': false, + 'nextCursor': null, + 'total': null, + }, + }); + }; + await openSearch(tester); + await tester.tap(find.text(MailSearchTexts.scopeAll)); + await tester.pumpAndSettle(); + await search(tester, '정산서'); + + expect(find.text(MailSearchTexts.quoted('정산서')), findsOneWidget); + expect(findCopy(MailSearchTexts.partialTitle), findsOneWidget); + expect(findCopy(MailSearchTexts.partialHint), findsOneWidget); + + final Completer gate = Completer(); + h.mail.searchGate = gate; + await tester.tap(find.text(MailSearchTexts.more)); + await tester.pump(); + expect(find.text(MailSearchTexts.moreBusy), findsOneWidget); + expect( + tester.getSemantics(find.text(MailSearchTexts.moreBusy)), + isSemantics(isButton: true, isEnabled: false), + ); + await tester.tap(find.text(MailSearchTexts.moreBusy)); + await tester.pump(); + + gate.complete(); + await tester.pumpAndSettle(); + expect(find.text('정산서 보냅니다'), findsOneWidget); + // 찾는 동안 다시 누른 [찾는 중…]은 요청을 더 보내지 않았다 + expect(h.mail.searchQueries, hasLength(kMailSearchFillMaxRequests + 1)); + expect(findCopy(MailSearchTexts.partialTitle), findsNothing); + }); + + testWidgets('목록 끝 — 한 차례가 한 쪽을 못 채우면 안내 줄 + [더 찾기], 누르면 이어 찾는다', ( + WidgetTester tester, + ) async { + int calls = 0; + h.mail.searchResponder = (_) { + calls++; + return FakeResponse.ok({ + 'items': [ + if (calls == 1) + messageJson('q1', mailboxId: kInboxId, subject: '견적 요청'), + if (calls == 6) + messageJson( + 'q2', + mailboxId: kInboxId, + subject: '견적 다시', + receivedAt: h.clock.now.subtract(const Duration(days: 30)), + ), + ], + 'pageInfo': { + 'hasNext': calls < 6, + 'nextCursor': calls < 6 ? 'c$calls' : null, + 'total': null, + }, + }); + }; + await openSearch(tester); + await search(tester, '견적'); + expect(find.text('견적 요청'), findsOneWidget); + expect(find.byType(MailSearchMoreLine), findsOneWidget); + expect(calls, kMailSearchFillMaxRequests); + + await tester.tap(find.text(MailSearchTexts.more)); + await tester.pumpAndSettle(); + expect(find.text('견적 다시'), findsOneWidget); + expect(find.byType(MailSearchMoreLine), findsNothing); + }); + + testWidgets('한 쪽을 채웠으면 끝 표시가 보일 때 다음 차례를 저절로 · 다음 쪽 실패는 끝의 [다시 시도]', ( + WidgetTester tester, + ) async { + final List> many = >[ + for (int i = 1; i <= 25; i++) + messageJson( + 'n$i', + mailboxId: kInboxId, + subject: '계약 $i', + receivedAt: h.clock.now.subtract(Duration(minutes: i)), + isRead: true, + ), + ]; + h.mail.messages[kInboxId] = many; + await openSearch(tester); + // 첫 쪽(20통 · 전체 25)은 기본 검색 그대로, 다음 쪽은 실패 + h.mail.searchResponder = (Map query) => + query['cursor'] == null + ? h.mail.defaultSearch(query) + : FakeResponse.fail(503, 'MAIL_001'); + await search(tester, '계약'); + expect(h.mail.searchQueries, hasLength(1)); + + await tester.scrollUntilVisible( + find.text(MailListTexts.retry), + 500, + scrollable: results(), + ); + await tester.pumpAndSettle(); + expect(h.mail.searchQueries, hasLength(2)); + expect(h.notifier.shownCodes, ['MAIL_001']); + + h.mail.searchResponder = null; + await tester.tap(find.text(MailListTexts.retry)); + await tester.pumpAndSettle(); + expect(h.mail.searchQueries, hasLength(3)); + // [다시 시도]는 받지 못한 그 커서로 다시 묻는다 + expect( + h.mail.searchQueries.last['cursor'], + FakeMailBackend.searchCursorAfter(many[19]), + ); + expect( + h.mail.searchQueries.last['cursor'], + h.mail.searchQueries[1]['cursor'], + ); + await tester.scrollUntilVisible( + find.text('계약 25'), + 500, + scrollable: results(), + ); + expect(find.text(MailSearchTexts.count('계약', 25)), findsOneWidget); + }); + }); + + group('결과에서 열기 · 결과 반영', () { + testWidgets('결과를 누르면 읽기 화면 — 강조 쿼리 · 메일함 줄, 제목 · 첨부 이름 · 글 본문을 칠한다', ( + WidgetTester tester, + ) async { + seedContracts(); + h.mail.highlightDetails['m1'] = { + 'textBody': '도윤입니다.\n말씀하신 조건으로 계약서 초안을 고쳐 첨부했습니다.', + 'attachments': [attachmentJson('a1', name: '계약서_초안_v2.pdf')], + 'searchHighlight': { + 'subject': [ + [9, 11], + ], + 'attachments': { + 'a1': [ + [0, 2], + ], + }, + 'textBody': [ + [17, 19], + ], + }, + }; + await openSearch(tester); + await search(tester, '계약'); + await tester.tap(find.text(_doyunSubject)); + await tester.pumpAndSettle(); + + expect(find.byType(MessageReaderPage), findsOneWidget); + expect(h.mail.detailQueries.last.query, {'q': '계약'}); + final Finder header = find.byType(ReaderHeader); + expect(marksIn(tester, header), ['계약']); + expect( + find.descendant( + of: header, + matching: find.text(MailSearchTexts.mailboxLine), + ), + findsOneWidget, + ); + expect( + find.descendant(of: header, matching: find.text('받은편지함')), + findsOneWidget, + ); + expect(marksIn(tester, find.byType(MessageReaderPage)), [ + '계약', + '계약', + ]); + final String document = h.webView.last.lastHtml!; + expect(document, contains('조건으로 계약서')); + expect(document, contains('mark{background:#FCE7A4;color:inherit')); + }); + + testWidgets('14단계 앞 서버(강조 칸 없음)의 결과는 강조 쿼리 없이 연다 — 메일함 줄은 있다', ( + WidgetTester tester, + ) async { + h.mail.put( + kInboxId, + messageJson('o1', mailboxId: kInboxId, subject: '옛 서버의 계약'), + ); + await openSearch(tester); + await search(tester, '계약'); + await tester.tap(find.text('옛 서버의 계약')); + await tester.pumpAndSettle(); + expect(h.mail.detailQueries.last.query, isEmpty); + expect(find.text(MailSearchTexts.mailboxLine), findsOneWidget); + }); + + testWidgets('[이미지 보기]는 같은 강조 쿼리로 다시 받는다', (WidgetTester tester) async { + seedContracts(); + h.mail + ..details['m1'] = { + 'htmlBody': '

계약 뉴스레터

', + 'hasBlockedImages': true, + } + ..shownImageDetails['m1'] = { + 'htmlBody': '

계약 뉴스레터

', + 'hasBlockedImages': false, + 'imagesShown': true, + }; + await openSearch(tester); + await search(tester, '계약'); + await tester.tap(find.text(_doyunSubject)); + await tester.pumpAndSettle(); + await tester.tap(find.text(MailReaderTexts.showImages)); + await tester.pumpAndSettle(); + expect(h.mail.detailQueries.last.query, { + kMailImagesQuery: kMailImagesShow, + 'q': '계약', + }); + expect(h.webView.last.lastHtml, contains('계약 뉴스레터')); + }); + + testWidgets('붙잡힌 메일을 다시 봐 요약을 바꿔도 제목 강조는 강조를 받은 응답의 글로 그린다', ( + WidgetTester tester, + ) async { + h.mail + ..searchHighlightField = true + ..put( + kInboxId, + messageJson( + 'h1', + mailboxId: kInboxId, + subject: '계약 안내', + pendingSend: pendingJson( + h.clock.now.add(const Duration(seconds: 5)), + ), + ), + ); + h.mail.highlightDetails['h1'] = { + 'searchHighlight': { + 'subject': [ + [0, 2], + ], + 'attachments': {}, + 'textBody': [], + }, + }; + await openSearch(tester); + await search(tester, '계약'); + await tester.tap(find.text('계약 안내')); + await tester.pumpAndSettle(); + expect(marksIn(tester, find.byType(ReaderHeader)), ['계약']); + + // 보낼 시각 + 2초 — 쿼리 없이 다시 받는다(그사이 제목 글이 바뀌었다) + h.mail.messageOf('h1')!['subject'] = '계약 안내 (고침)'; + await elapse(tester, const Duration(seconds: 8)); + expect(h.mail.detailQueries.last.query, isEmpty); + expect( + find.descendant( + of: find.byType(ReaderHeader), + matching: find.text('계약 안내'), + ), + findsOneWidget, + ); + expect(marksIn(tester, find.byType(ReaderHeader)), ['계약']); + }); + + testWidgets('임시보관함의 결과는 쓰기 화면에서 이어 쓴다', (WidgetTester tester) async { + h.mail.put( + kDraftsId, + messageJson('d1', mailboxId: kDraftsId, subject: '계약 초안 쓰는 중'), + ); + await openSearch(tester); + await tester.tap(find.text(MailSearchTexts.scopeAll)); + await tester.pumpAndSettle(); + await search(tester, '계약'); + await tester.tap(find.text('계약 초안 쓰는 중')); + await tester.pumpAndSettle(); + expect( + GoRouterState.of( + tester.element(find.byType(MessageComposePage).last), + ).uri.toString(), + MailRoutes.composeDraft('d1'), + ); + }); + + testWidgets('읽기 화면에서 읽으면 굵기가 풀리고, 휴지통으로 옮기면 빠진다 — [되돌리기]는 그 자리에 다시', ( + WidgetTester tester, + ) async { + seedContracts(); + await openSearch(tester); + await search(tester, '계약'); + Text whoOf(String subject) => tester.widget( + find.descendant(of: row(subject), matching: find.text('이도윤')), + ); + expect(whoOf(_doyunSubject).style?.fontWeight, AppTypography.bold); + + await tester.tap(find.text(_doyunSubject)); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip(MailReaderTexts.back)); + await tester.pumpAndSettle(); + expect(whoOf(_doyunSubject).style?.fontWeight, AppTypography.medium); + + await tester.tap(find.text(_doyunSubject)); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip(MailReaderTexts.delete)); + await tester.pumpAndSettle(); + expect(find.byType(MessageReaderPage), findsNothing); + expect(find.text(_doyunSubject), findsNothing); + expect(find.text(MailSearchTexts.count('계약', 2)), findsOneWidget); + expect(toastText(MailBulkTexts.moved(1, '휴지통')), findsOneWidget); + + await tester.tap(find.text(MailBulkTexts.undo)); + await tester.pumpAndSettle(); + expect(h.mail.mailboxOf('m1'), kInboxId); + expect(find.text(MailSearchTexts.count('계약', 3)), findsOneWidget); + final double doyun = tester.getTopLeft(row(_doyunSubject)).dy; + final double hr = tester.getTopLeft(row(_hrSubject)).dy; + expect(doyun, lessThan(hr)); + expect(marksIn(tester, row(_doyunSubject)), ['계약', '계약']); + expect(h.mail.searchQueries, hasLength(1)); + }); + + testWidgets('1통만 찾고 멈춘 결과를 지웠다 [되돌리기]하면 다시 보인다 — 아직 못 찾음 → 결과 + [더 찾기]', ( + WidgetTester tester, + ) async { + h.mail.put( + kInboxId, + messageJson('p1', mailboxId: kInboxId, subject: '정산서 보냅니다'), + ); + int calls = 0; + h.mail.searchResponder = (Map query) { + calls++; + return FakeResponse.ok({ + 'items': [ + if (calls == 1) Map.of(h.mail.messageOf('p1')!), + ], + 'pageInfo': { + 'hasNext': true, + 'nextCursor': 'c$calls', + 'total': null, + }, + }); + }; + await openSearch(tester); + await search(tester, '정산서'); + expect(find.byType(MailSearchMoreLine), findsOneWidget); + + await tester.tap(find.text('정산서 보냅니다')); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip(MailReaderTexts.delete)); + await tester.pumpAndSettle(); + expect(find.text('정산서 보냅니다'), findsNothing); + expect(findCopy(MailSearchTexts.partialTitle), findsOneWidget); + + await tester.tap(find.text(MailBulkTexts.undo)); + await tester.pumpAndSettle(); + expect(h.mail.mailboxOf('p1'), kInboxId); + expect(find.text('정산서 보냅니다'), findsOneWidget); + expect(find.byType(MailSearchMoreLine), findsOneWidget); + expect(findCopy(MailSearchTexts.partialTitle), findsNothing); + expect(calls, kMailSearchFillMaxRequests); + }); + + testWidgets('결과의 붙잡힌 메일은 보낼 시각 + 2초에 그 줄만 다시 받는다 — 표시만 덮고 제목 · 강조는 그대로', ( + WidgetTester tester, + ) async { + h.mail + ..searchHighlightField = true + ..put( + kSentId, + messageJson( + 's1', + mailboxId: kSentId, + subject: '계약 보내는 중', + isRead: true, + pendingSend: pendingJson( + h.clock.now.add(const Duration(seconds: 8)), + ), + ), + ); + h.mail.searchHighlights['s1'] = { + 'subject': [ + [0, 2], + ], + 'preview': {'text': '미리 보기 글', 'ranges': []}, + }; + await openSearch(tester, location: MailRoutes.mailbox(kSentId)); + await search(tester, '계약'); + Finder sending() => find.descendant( + of: row('계약 보내는 중'), + matching: find.text(MailListTexts.sending), + ); + expect(sending(), findsOneWidget); + final int before = h.mail.detailQueries.length; + h.mail.messageOf('s1')! + ..['pendingSend'] = null + ..['subject'] = '계약 보내는 중 (다른 글)'; + + await elapse(tester, const Duration(seconds: 9)); + expect(h.mail.detailQueries, hasLength(before)); + await elapse(tester, const Duration(seconds: 1)); + + expect(h.mail.detailQueries, hasLength(before + 1)); + expect(h.mail.detailQueries.last.id, 's1'); + expect(h.mail.detailQueries.last.query, isEmpty); + expect(sending(), findsNothing); + expect(marksIn(tester, row('계약 보내는 중')), ['계약']); + expect(h.mail.searchQueries, hasLength(1)); + expect(h.notifier.shownCodes, isEmpty); + }); + + testWidgets('붙잡힌 줄을 다시 받다 실패하면 5초 뒤 다시(알리지 않는다) · 메일이 없어졌으면(RES_001) 뺀다', ( + WidgetTester tester, + ) async { + h.mail + ..put( + kSentId, + messageJson( + 's1', + mailboxId: kSentId, + subject: '계약 보내는 중', + isRead: true, + pendingSend: pendingJson( + h.clock.now.add(const Duration(seconds: 8)), + ), + ), + ) + ..put( + kSentId, + messageJson( + 's2', + mailboxId: kSentId, + subject: '계약 보낸 메일', + isRead: true, + receivedAt: h.clock.now.subtract(const Duration(hours: 1)), + ), + ); + await openSearch(tester, location: MailRoutes.mailbox(kSentId)); + await search(tester, '계약'); + expect(find.text(MailSearchTexts.count('계약', 2)), findsOneWidget); + final int before = h.mail.detailQueries.length; + h.mail.detailFailure = FakeResponse.fail(503, 'MAIL_001'); + + await elapse(tester, const Duration(seconds: 10)); + expect(h.mail.detailQueries, hasLength(before + 1)); + expect(find.text(MailListTexts.sending), findsOneWidget); + expect(h.notifier.shownCodes, isEmpty); + + h.mail.detailFailure = FakeResponse.fail(404, 'RES_001'); + await elapse(tester, kHeldReleaseRecheck); + expect(h.mail.detailQueries, hasLength(before + 2)); + expect(find.text('계약 보내는 중'), findsNothing); + expect(find.text('계약 보낸 메일'), findsOneWidget); + expect(find.text(MailSearchTexts.count('계약', 1)), findsOneWidget); + expect(h.notifier.shownCodes, isEmpty); + }); + }); + + group('낭독', () { + testWidgets( + '개수 줄은 결과가 오면 읽고(live region) · 결과 줄은 메일함 이름까지 읽는다 · 사진은 단추가 아니다', + (WidgetTester tester) async { + seedContracts(); + await openSearch(tester); + await tester.tap(find.text(MailSearchTexts.scopeAll)); + await tester.pumpAndSettle(); + await search(tester, '계약'); + + expect( + tester.getSemantics(find.text(MailSearchTexts.count('계약', 3))), + isSemantics(isLiveRegion: true), + ); + expect( + tester.getSemantics(row(_hrSubject)).label, + contains('인사팀, 받은편지함, '), + ); + expect(find.byType(MailAvatarButton), findsNothing); + + // 길게 눌러도 아무 일 없다 — 고르지 않고 열지도 않는다 + await tester.longPress(find.text(_hrSubject)); + await tester.pumpAndSettle(); + expect(find.text(MailBulkTexts.selected(1)), findsNothing); + expect(find.byType(MessageReaderPage), findsNothing); + expect(find.byType(MailSearchScopeBar), findsOneWidget); + }, + ); + + testWidgets('찾을 곳은 "찾을 곳" 묶음 안의 두 단추 — 고른 칸이 고름 상태', ( + WidgetTester tester, + ) async { + await openSearch(tester); + expect( + tester.getSemantics( + find.widgetWithText(InkWell, MailSearchTexts.scopeAll), + ), + isSemantics(isButton: true, isSelected: false), + ); + await tester.tap(find.text(MailSearchTexts.scopeAll)); + await tester.pumpAndSettle(); + expect( + tester.getSemantics( + find.widgetWithText(InkWell, MailSearchTexts.scopeAll), + ), + isSemantics(isButton: true, isSelected: true), + ); + expect(find.bySemanticsLabel(MailSearchTexts.scopeLabel), findsWidgets); + }); + + testWidgets('검색 칸의 이름은 한 번만 읽는다 — 빈 칸 · 글을 쓴 뒤 모두(보이는 안내 글은 낭독에서 뺀다)', ( + WidgetTester tester, + ) async { + await openSearch(tester); + expect(find.text(MailSearchTexts.box), findsOneWidget); + expect(tester.getSemantics(field()).label, MailSearchTexts.box); + + await tester.enterText(field(), '계약'); + await tester.pump(); + final SemanticsNode node = tester.getSemantics(field()); + expect(node.label, MailSearchTexts.box); + expect(node.value, '계약'); + }); + }); + + group('크기', () { + Future countLineHeight(WidgetTester tester, double scale) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(scale)), + child: Scaffold( + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + MailSearchCountLine(MailSearchTexts.count('계약', 4)), + ], + ), + ), + ), + ), + ); + expect(tester.takeException(), isNull); + return tester.getSize(find.byType(MailSearchCountLine)).height; + } + + testWidgets('개수 줄은 높이 36 — 큰 글자에서는 글에 맞춰 늘어난다(잘리지 않는다)', ( + WidgetTester tester, + ) async { + expect(await countLineHeight(tester, 1), AppSize.searchCountLineHeight); + final double large = await countLineHeight(tester, 3); + expect(large, greaterThan(AppSize.searchCountLineHeight)); + expect( + large, + greaterThanOrEqualTo( + tester.getSize(find.text(MailSearchTexts.count('계약', 4))).height, + ), + ); + }); + }); +} diff --git a/flutter/test/features/mail/mail_search_view_test.dart b/flutter/test/features/mail/mail_search_view_test.dart new file mode 100644 index 0000000..5a1c069 --- /dev/null +++ b/flutter/test/features/mail/mail_search_view_test.dart @@ -0,0 +1,306 @@ +import 'package:arasystem_app/features/mail/data/models/mail_page.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_search.dart'; +import 'package:arasystem_app/features/mail/data/models/mailbox.dart'; +import 'package:arasystem_app/features/mail/domain/search_highlight.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_row_view.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_search_view.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_search_state.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mailboxes_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/fakes.dart'; +import '../../support/mail_fakes.dart'; + +final MailboxesState _mailboxes = MailboxesState( + status: MailboxesStatus.ready, + items: [ + for (final Map json in standardMailboxes()) + Mailbox.tryParse(json)!, + ], +); + +MailSearchItem _item( + String id, { + required String mailboxId, + String subject = '유지보수 계약 갱신 건', + String preview = '본문 첫 부분', + List> to = const >[], + bool isRead = true, + bool hasAttachment = false, + Map? highlight, + bool supported = true, +}) => MailSearchItem.tryParse({ + ...messageJson( + id, + mailboxId: mailboxId, + subject: subject, + preview: preview, + to: to, + isRead: isRead, + hasAttachment: hasAttachment, + ), + if (supported) 'searchHighlight': highlight, +})!; + +MailSearchState _ready({ + String query = '계약', + bool all = false, + String? mailboxId = kInboxId, + int? total = 4, + SearchIndexStatus? index, + bool refreshing = false, +}) => MailSearchState( + phase: MailSearchPhase.ready, + allMailboxes: all, + query: query, + searchedAll: all, + searchedMailboxId: all ? null : mailboxId, + pageInfo: MailPageInfo(hasNext: false, total: total), + indexStatus: index, + refreshing: refreshing, +); + +/// 검색 화면의 표시 — 결과 머리 · 안내 줄 · 결과 줄 · 연 메일에 넘기는 값 (보드 `App1Search`) +void main() { + group('결과 머리(개수 줄)', () { + test('찾은 뒤 "‘계약’ · 4통", 전체 수를 모르면 검색어만, 찾는 동안 · 다시 찾는 동안은 "찾는 중"', () { + expect(searchCountLineOf(_ready()), MailSearchTexts.count('계약', 4)); + expect( + searchCountLineOf(_ready(total: null)), + MailSearchTexts.quoted('계약'), + ); + expect( + searchCountLineOf(_ready(refreshing: true)), + MailSearchTexts.searching('계약'), + ); + expect( + searchCountLineOf( + const MailSearchState(phase: MailSearchPhase.searching, query: '계약'), + ), + MailSearchTexts.searching('계약'), + ); + }); + + test('찾기 전 · 낱말이 너무 많음 · 받지 못함에는 머리가 없다', () { + expect(searchCountLineOf(const MailSearchState()), isNull); + expect( + searchCountLineOf( + const MailSearchState(phase: MailSearchPhase.tooManyWords), + ), + isNull, + ); + expect( + searchCountLineOf( + const MailSearchState(phase: MailSearchPhase.failed, query: '계약'), + ), + isNull, + ); + }); + }); + + group('안내 줄', () { + test('준비 중이면 준비 중 줄, 준비됐고 한 글자 낱말뿐이면 한 글자 안내 — 함께 뜨지 않는다', () { + expect( + searchNoticeOf(_ready(index: SearchIndexStatus.building)), + MailSearchNotice.indexing, + ); + expect( + searchNoticeOf(_ready(query: '김', index: SearchIndexStatus.building)), + MailSearchNotice.indexing, + ); + expect( + searchNoticeOf(_ready(query: '김', index: SearchIndexStatus.ready)), + MailSearchNotice.singleChar, + ); + expect(searchNoticeOf(_ready(index: SearchIndexStatus.ready)), isNull); + }); + + test('준비 상태를 모르면(받지 못함 · 14단계 앞 서버) 아무 줄도 없다 · 찾는 동안에도 없다', () { + expect(searchNoticeOf(_ready(query: '김')), isNull); + expect( + searchNoticeOf( + const MailSearchState( + phase: MailSearchPhase.searching, + query: '김', + indexStatus: SearchIndexStatus.ready, + ), + ), + isNull, + ); + }); + }); + + group('찾을 곳의 시작한 메일함 이름', () { + test('받은편지함 주소는 기본 메일함 이름(받기 전이면 "받은편지함"), 그 밖은 그 메일함(모르면 "메일함")', () { + expect(searchOriginNameOf(null, _mailboxes), '받은편지함'); + expect( + searchOriginNameOf(null, const MailboxesState()), + MailListTexts.inbox, + ); + expect(searchOriginNameOf(kClientsId, _mailboxes), '거래처'); + expect( + searchOriginNameOf('mb-gone', _mailboxes), + MailListTexts.mailboxFallback, + ); + }); + }); + + group('결과 줄', () { + final DateTime now = kTestNow; + + test('한 메일함에서 찾으면 목록 줄 그대로 — 메일함 이름이 없다', () { + final MailRowView view = searchRowViewOf( + _item('m1', mailboxId: kInboxId), + state: _ready(), + mailboxes: _mailboxes, + now: now, + ); + expect(view.mailboxName, isNull); + expect(view.who, '이도윤'); + }); + + test('전체 메일함이면 줄마다 그 메일의 메일함 — 보낸편지함 · 임시보관함은 "받는 사람: …"', () { + final MailSearchState all = _ready(all: true); + final MailRowView sent = searchRowViewOf( + _item( + 's1', + mailboxId: kSentId, + to: >[addressJson('이도윤', 'doyun@example.com')], + ), + state: all, + mailboxes: _mailboxes, + now: now, + ); + expect(sent.mailboxName, '보낸편지함'); + expect(sent.who, '${MailListTexts.recipientPrefix}이도윤'); + final MailRowView draft = searchRowViewOf( + _item( + 'd1', + mailboxId: kDraftsId, + to: >[addressJson('박지훈', 'jihoon@example.com')], + ), + state: all, + mailboxes: _mailboxes, + now: now, + ); + expect(draft.mailboxName, '임시보관함'); + expect(draft.who, '${MailListTexts.recipientPrefix}박지훈'); + // 한 메일함(임시보관함)에서 찾으면 이름만 + final MailRowView draftOnly = searchRowViewOf( + _item( + 'd1', + mailboxId: kDraftsId, + to: >[addressJson('박지훈', 'jihoon@example.com')], + ), + state: _ready(mailboxId: kDraftsId), + mailboxes: _mailboxes, + now: now, + ); + expect(draftOnly.who, '박지훈'); + }); + + test('강조가 있으면 미리 보기를 서버의 맞은 말 둘레 한 줄로 바꾸고 제목 · 미리 보기의 맞은 곳을 싣는다', () { + final MailRowView view = searchRowViewOf( + _item( + 'm1', + mailboxId: kInboxId, + preview: '엔진 미리 보기', + highlight: { + 'subject': [ + [5, 7], + ], + 'preview': { + 'text': '…지난달 계약 기간에', + 'ranges': [ + [5, 7], + ], + }, + }, + ), + state: _ready(), + mailboxes: _mailboxes, + now: now, + ); + expect(view.preview, '…지난달 계약 기간에'); + expect(view.subjectHighlights, [(start: 5, end: 7)]); + expect(view.previewHighlights, [(start: 5, end: 7)]); + // 강조가 없으면 지금 미리 보기 + final MailRowView plain = searchRowViewOf( + _item('m2', mailboxId: kInboxId, preview: '엔진 미리 보기'), + state: _ready(), + mailboxes: _mailboxes, + now: now, + ); + expect(plain.preview, '엔진 미리 보기'); + expect(plain.subjectHighlights, isEmpty); + }); + + test('제목이 없으면 "(제목 없음)"에 강조를 싣지 않는다', () { + final MailRowView view = searchRowViewOf( + _item( + 'm1', + mailboxId: kInboxId, + subject: '', + highlight: { + 'subject': [ + [0, 2], + ], + 'preview': {'text': '계약', 'ranges': []}, + }, + ), + state: _ready(), + mailboxes: _mailboxes, + now: now, + ); + expect(view.subject, MailListTexts.noSubject); + expect(view.subjectHighlights, isEmpty); + }); + + test('낭독 글은 안 읽음 · 보낸 사람 · 메일함 · 시각 · 제목 · 첨부 · 미리 보기 차례 — 강조는 읽지 않는다', () { + final MailRowView view = searchRowViewOf( + _item( + 'm1', + mailboxId: kInboxId, + subject: '유지보수 계약', + preview: '계약서 초안', + isRead: false, + hasAttachment: true, + ), + state: _ready(all: true), + mailboxes: _mailboxes, + now: now, + ); + expect( + view.semanticsLabel, + [ + MailListTexts.unreadPrefix, + '이도윤', + '받은편지함', + formatMailListTime(kTestNow, now), + '유지보수 계약', + MailListTexts.attachment, + '계약서 초안', + ].join(', '), + ); + }); + }); + + group('결과에서 연 메일에 넘기는 값', () { + test('14단계 서버의 결과만 강조 쿼리를 싣는다 — 한 메일함이면 그 메일함도', () { + final MailSearchOpen open = searchOpenOf( + _item('m1', mailboxId: kInboxId, highlight: null), + _ready(), + ); + expect(open.highlight, const MessageHighlightQuery(q: '계약')); + expect(open.scopeMailboxId, kInboxId); + + final MailSearchOpen old = searchOpenOf( + _item('m1', mailboxId: kInboxId, supported: false), + _ready(all: true), + ); + expect(old.highlight, isNull); + expect(old.scopeMailboxId, isNull); + }); + }); +} diff --git a/flutter/test/features/mail/message_reader_page_test.dart b/flutter/test/features/mail/message_reader_page_test.dart index ce69581..9931a38 100644 --- a/flutter/test/features/mail/message_reader_page_test.dart +++ b/flutter/test/features/mail/message_reader_page_test.dart @@ -10,6 +10,7 @@ import 'package:arasystem_app/features/mail/data/repositories/mail_repository.da import 'package:arasystem_app/features/mail/domain/compose_request.dart'; import 'package:arasystem_app/features/mail/domain/mail_body_notices.dart'; import 'package:arasystem_app/features/mail/domain/mail_html.dart'; +import 'package:arasystem_app/features/mail/domain/search_highlight.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'; @@ -172,6 +173,7 @@ void main() { required String? htmlBody, required String? textBody, required bool imagesShown, + required List textHighlights, }) => building.future, ), ); @@ -1099,7 +1101,12 @@ void main() { ), findsNothing, ); - expect(h.container.exists(messageReaderProvider('m1')), isFalse); + expect( + h.container.exists( + messageReaderProvider(const MessageReaderArgs('m1')), + ), + isFalse, + ); }); testWidgets('로그아웃하면 읽기 화면이 사라지고 늦은 읽음 응답은 알리지 않는다', ( diff --git a/flutter/test/features/mail/search_highlight_test.dart b/flutter/test/features/mail/search_highlight_test.dart new file mode 100644 index 0000000..23c91ce --- /dev/null +++ b/flutter/test/features/mail/search_highlight_test.dart @@ -0,0 +1,376 @@ +import 'package:arasystem_app/features/mail/data/models/mail_search.dart'; +import 'package:arasystem_app/features/mail/data/models/message_detail.dart'; +import 'package:arasystem_app/features/mail/domain/mail_html.dart'; +import 'package:arasystem_app/features/mail/domain/search_highlight.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/mail_fakes.dart'; + +/// 조각을 "[맞은 말]" 꼴의 글로 — 시험에서 보기 쉽게 +String _marked(String text, List? ranges) => [ + for (final HighlightSegment segment in highlightSegments(text, ranges)) + segment.mark ? '[${segment.text}]' : segment.text, +].join(); + +List _ranges(List<(int, int)> pairs) => [ + for (final (int start, int end) in pairs) (start: start, end: end), +]; + +/// 검색 강조 — 조각 나누기(짝: 웹 `search-highlight.ts`) · 응답 읽기 · 글 본문의 `` +void main() { + group('highlightSegments (강조 조각)', () { + test('맞은 곳이 없으면 글 한 조각 — 빈 글이면 조각이 없다', () { + expect(highlightSegments('계약서 초안', null), [ + (text: '계약서 초안', mark: false), + ]); + expect( + highlightSegments('계약서 초안', const []), + [(text: '계약서 초안', mark: false)], + ); + expect(highlightSegments('', null), isEmpty); + }); + + test('여러 범위를 차례로 자른다 — 앞 · 끝 · 맞닿은 곳도', () { + expect( + _marked('Re: 유지보수 계약 갱신 건', _ranges(<(int, int)>[(12, 14), (4, 8)])), + 'Re: [유지보수] 계약 [갱신] 건', + ); + expect(_marked('계약서', _ranges(<(int, int)>[(0, 2)])), '[계약]서'); + expect(_marked('견적서와 계약', _ranges(<(int, int)>[(5, 7)])), '견적서와 [계약]'); + expect(_marked('계약서', _ranges(<(int, int)>[(0, 3)])), '[계약서]'); + }); + + test('겹치거나 맞닿은 범위는 합친다', () { + expect(_marked('aaaa', _ranges(<(int, int)>[(0, 2), (1, 3)])), '[aaa]a'); + expect(_marked('계약서', _ranges(<(int, int)>[(0, 2), (2, 3)])), '[계약서]'); + }); + + test('글 밖 · 거꾸로 된 위치는 버리고, 끝이 글을 넘으면 글 끝에서 자른다', () { + expect(_marked('계약', _ranges(<(int, int)>[(5, 7)])), '계약'); + expect( + _marked('계약', _ranges(<(int, int)>[(1, 1), (2, 1), (-1, 1), (2, 5)])), + '계약', + ); + expect(_marked('계약', _ranges(<(int, int)>[(1, 10)])), '계[약]'); + }); + + test('BMP 밖 글자 뒤의 위치도 UTF-16 그대로 쓴다', () { + expect( + _marked('\u{1F600}계약', _ranges(<(int, int)>[(2, 4)])), + '\u{1F600}[계약]', + ); + }); + + test('위치가 짝 글자(이모지)의 가운데를 가르면 그 글자를 통째로 넣는다 — 반쪽 글자를 만들지 않는다', () { + expect( + _marked('\u{1F600}계약', _ranges(<(int, int)>[(1, 4)])), + '[\u{1F600}계약]', + ); + expect( + _marked('\u{1F600}계약', _ranges(<(int, int)>[(0, 1)])), + '[\u{1F600}]계약', + ); + expect( + _marked('계\u{1F600}약', _ranges(<(int, int)>[(2, 3)])), + '계[\u{1F600}]약', + ); + expect( + _marked('계\u{1F600}약', _ranges(<(int, int)>[(0, 2)])), + '[계\u{1F600}]약', + ); + // 가른 자리를 넓힌 뒤 맞닿으면 합친다 + expect( + _marked('\u{1F600}\u{1F600}', _ranges(<(int, int)>[(0, 1), (3, 4)])), + '[\u{1F600}\u{1F600}]', + ); + final List segments = highlightSegments( + 'a\u{1F600}b\u{1F600}c', + _ranges(<(int, int)>[(2, 5)]), + ); + expect( + [for (final HighlightSegment s in segments) s.text], + ['a', '\u{1F600}b\u{1F600}', 'c'], + ); + }); + + test( + 'NFC 로 다시 맞추지 않는다 — 위치는 그 위치를 준 응답의 글에만 쓴다(서버가 강조가 있으면 NFC 로 싣는다)', + () { + // 같은 응답의 글(모은 한글)이면 맞고, 풀어 쓴 다른 글에 쓰면 글 그대로 자를 뿐이다(어긋남 — 그래서 쓰지 않는다) + expect(_marked('계약서', _ranges(<(int, int)>[(0, 2)])), '[계약]서'); + const String nfd = '\u1100\u1168\u110B\u1163\u11A8'; + expect(highlightSegments(nfd, null).single.text, nfd); + }, + ); + }); + + group('parseSearchRanges (응답의 맞은 곳 읽기)', () { + test('[[시작, 끝], …] 을 읽는다 — 소수 부분 없는 수(2.0)는 정수다', () { + expect( + parseSearchRanges([ + [0, 2], + [4.0, 6], + ]), + _ranges(<(int, int)>[(0, 2), (4, 6)]), + ); + expect(parseSearchRanges([]), isEmpty); + }); + + test('정수가 아닌 위치는 그 칸만 버린다(웹 Number.isInteger) — 무한 · 아주 큰 수도', () { + expect( + parseSearchRanges([ + [0.5, 1], + [1, 2], + [double.infinity, 3], + [1e300, 1e300], + ]), + _ranges(<(int, int)>[(1, 2)]), + ); + }); + + test('모양이 틀리면 null — 목록이 아님 · 칸이 두 수가 아님', () { + expect(parseSearchRanges(null), isNull); + expect(parseSearchRanges('0,2'), isNull); + expect( + parseSearchRanges([ + [0], + ]), + isNull, + ); + expect( + parseSearchRanges([ + [0, '2'], + ]), + isNull, + ); + expect( + parseSearchRanges([ + [0, 1, 2], + ]), + isNull, + ); + expect(parseSearchRanges([0, 2]), isNull); + }); + }); + + group('검색 결과 항목 (MailSearchItem)', () { + Map item({Object? highlight = _absent}) => + { + ...messageJson('m1', mailboxId: kInboxId, subject: '유지보수 계약 갱신 건'), + if (!identical(highlight, _absent)) 'searchHighlight': highlight, + }; + + test('searchHighlight 칸이 없으면(14단계 앞 서버) 강조 없음 · 강조 쿼리도 싣지 않는다', () { + final MailSearchItem parsed = MailSearchItem.tryParse(item())!; + expect(parsed.highlight, isNull); + expect(parsed.highlightSupported, isFalse); + }); + + test('칸이 null 이면(엔진 방식) 강조 없음 — 그래도 14단계 서버다', () { + final MailSearchItem parsed = MailSearchItem.tryParse( + item(highlight: null), + )!; + expect(parsed.highlight, isNull); + expect(parsed.highlightSupported, isTrue); + }); + + test('값이 있으면 제목 · 미리 보기 한 줄 · 그 맞은 곳을 읽는다', () { + final MailSearchItem parsed = MailSearchItem.tryParse( + item( + highlight: { + 'subject': [ + [5, 7], + ], + 'preview': { + 'text': '…지난달 계약 기간에 맞춰', + 'ranges': [ + [5, 7], + ], + }, + }, + ), + )!; + expect( + parsed.highlight, + MessageListHighlight( + subject: _ranges(<(int, int)>[(5, 7)]), + previewText: '…지난달 계약 기간에 맞춰', + previewRanges: _ranges(<(int, int)>[(5, 7)]), + ), + ); + }); + + test('강조의 모양이 틀리면 강조만 버리고 항목은 살린다 — 목록 칸이 틀리면 항목을 버린다', () { + for (final Object? broken in [ + 'x', + {'subject': [], 'preview': 'x'}, + { + 'subject': ['x'], + 'preview': {'text': 'a', 'ranges': []}, + }, + { + 'subject': [], + 'preview': {'text': 1, 'ranges': []}, + }, + ]) { + final MailSearchItem? parsed = MailSearchItem.tryParse( + item(highlight: broken), + ); + expect(parsed, isNotNull); + expect(parsed!.highlight, isNull); + expect(parsed.highlightSupported, isTrue); + } + expect( + MailSearchItem.tryParse({...item(), 'isRead': 'x'}), + isNull, + ); + }); + }); + + group('읽기 화면의 강조 (MessageDetail.searchHighlight)', () { + Map detailJson(Object? highlight) => { + ...messageJson('m1', mailboxId: kInboxId), + 'textBody': '본문', + 'attachments': [attachmentJson('a1', name: '계약서.pdf')], + 'searchHighlight': highlight, + }; + + test('제목 · 첨부(맞은 첨부만) · 글 본문의 맞은 곳을 읽는다', () { + final MessageDetail detail = MessageDetail.tryParse( + detailJson({ + 'subject': [ + [0, 2], + ], + 'attachments': { + 'a1': [ + [0, 2], + ], + }, + 'textBody': [], + }), + )!; + expect( + detail.searchHighlight, + MessageDetailHighlight( + subject: _ranges(<(int, int)>[(0, 2)]), + attachments: >{ + 'a1': _ranges(<(int, int)>[(0, 2)]), + }, + ), + ); + }); + + test('없거나 모양이 틀리면 강조 없이 메일을 연다', () { + expect(MessageDetail.tryParse(detailJson(null))!.searchHighlight, isNull); + for (final Object? broken in [ + {'subject': [], 'textBody': []}, + { + 'subject': [], + 'attachments': {'a1': 'x'}, + 'textBody': [], + }, + ]) { + final MessageDetail? detail = MessageDetail.tryParse( + detailJson(broken), + ); + expect(detail, isNotNull); + expect(detail!.searchHighlight, isNull); + } + }); + + test('[이미지 보기]의 본문 바꾸기는 강조도 그 응답의 것으로, 요약 바꾸기는 강조를 그대로 둔다', () { + final MessageDetail first = MessageDetail.tryParse( + detailJson({ + 'subject': [ + [0, 2], + ], + 'attachments': {}, + 'textBody': [], + }), + )!; + final MessageDetail plain = MessageDetail.tryParse(detailJson(null))!; + expect(first.withBodyOf(plain).searchHighlight, isNull); + expect( + first.withSummary(plain.summary).searchHighlight, + first.searchHighlight, + ); + }); + }); + + group('강조 쿼리 · 준비 상태', () { + test('읽기 화면에 넘기는 쿼리는 빈 칸을 뺀 다섯 이름뿐이다', () { + expect( + const MessageHighlightQuery(q: '계약').toQueryParameters(), + {'q': '계약'}, + ); + expect( + const MessageHighlightQuery(q: '계약', subject: '견적').toQueryParameters(), + {'q': '계약', 'subject': '견적'}, + ); + expect(const MessageHighlightQuery().isEmpty, isTrue); + }); + + test('준비 상태는 building · ready 만 — 모르는 값은 null(안내 줄을 보이지 않는다)', () { + expect(SearchIndexStatus.parse('building'), SearchIndexStatus.building); + expect(SearchIndexStatus.parse('ready'), SearchIndexStatus.ready); + expect(SearchIndexStatus.parse('paused'), isNull); + expect(SearchIndexStatus.parse(null), isNull); + }); + }); + + group('글 본문의 · 본문 문서의 mark 모양', () { + test('맞은 곳은 속성 없는 — 글은 조각마다 이스케이프한다', () { + expect( + highlightedTextHtml( + '<계약> & 계약서', + _ranges(<(int, int)>[(1, 3), (7, 9)]), + ), + '<계약> & 계약서', + ); + expect(highlightedTextHtml('', const []), '<b>'); + }); + + test('글 본문이면 강조를 넣고, 서식 본문이면 넣지 않는다(서버가 이미 넣었다 — 앱의 정화가 mark 를 남긴다)', () { + final SanitizedMailBody text = mailBodyFragment( + htmlBody: null, + textBody: '계약서를 보냈습니다', + imagesShown: false, + textHighlights: _ranges(<(int, int)>[(0, 2)]), + ); + expect(text.fragment, contains('계약서를')); + final SanitizedMailBody html = mailBodyFragment( + htmlBody: + '

계약서를 보냈습니다

', + textBody: '계약서를 보냈습니다', + imagesShown: false, + textHighlights: _ranges(<(int, int)>[(0, 2)]), + ); + expect( + html.fragment, + '

계약서를 보냈습니다

', + ); + }); + + test('서식 본문이 한도를 넘어 글 본문으로 보일 때도 글 본문의 위치로 강조한다', () { + final SanitizedMailBody omitted = mailBodyFragment( + htmlBody: '
${'
' * 4000}x${'
' * 4000}
', + textBody: '계약 안내', + imagesShown: false, + textHighlights: _ranges(<(int, int)>[(0, 2)]), + ); + expect(omitted.htmlOmitted, isTrue); + expect(omitted.fragment, contains('계약 안내')); + }); + + test('문서의 mark 는 웹과 같은 노랑 · 글자색 그대로 · 모서리 2 · 좌우 1px', () { + expect( + buildMailDocument(''), + contains( + 'mark{background:#FCE7A4;color:inherit;border-radius:2px;padding:0 1px}', + ), + ); + }); + }); +} + +const Object _absent = Object(); diff --git a/flutter/test/features/mail/search_text_test.dart b/flutter/test/features/mail/search_text_test.dart new file mode 100644 index 0000000..91d0b2d --- /dev/null +++ b/flutter/test/features/mail/search_text_test.dart @@ -0,0 +1,256 @@ +import 'package:arasystem_app/features/mail/domain/search_text.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/ts_table.dart'; + +/// 서버 · 웹과 같은 표본 표 — 백엔드 파일을 그대로 읽는다(표는 하나 — 세 쪽 시험이 같은 표를 본다) +const String _samplesPath = '../backend/test/search-text-samples.ts'; + +/// 표의 한 행 — [넣은 글, 답] +List<(String, Object?)> _rows(String name) => <(String, Object?)>[ + for (final Object? row in readTsArrayFile(_samplesPath, name)) + if (row case [final String input, final Object? answer]) + (input, answer) + else + throw FormatException('표 $name 의 행 모양이 다르다: $row'), +]; + +/// 메일 검색의 낱말 나누기 · 접기 · 묶음 · 한 글자 판정 · 한도 (짝: 웹 `search-match.ts` · `mail-search.ts` · 백엔드 `search-text.ts`) +void main() { + group('표 읽기 (test/support/ts_table.dart)', () { + test('작은따옴표 · 큰따옴표 글 · 이스케이프 · 주석 · 끝 쉼표 · 겹친 배열을 읽는다', () { + const String source = r''' +export const SAMPLE: readonly (readonly [a: string, b: readonly string[]])[] = [ + // 주석은 건너뛴다 + ['가\u3000나', ['\n', '\t', '\r', '\\', '\'', "\"", '\u{1F600}']], + /* 막힌 주석 */ ["끝", []], +]; +'''; + expect(readTsArray(source, 'SAMPLE'), [ + [ + '가\u3000나', + ['\n', '\t', '\r', r'\', "'", '"', '\u{1F600}'], + ], + ['끝', []], + ]); + }); + + test('모르는 꼴(템플릿 글 · 수)이나 없는 표는 실패한다 — 표의 모양이 바뀐 것을 알아챈다', () { + expect( + () => readTsArray('export const X = [`a`];', 'X'), + throwsFormatException, + ); + expect( + () => readTsArray('export const X = [1];', 'X'), + throwsFormatException, + ); + expect(() => readTsArray('', 'X'), throwsFormatException); + }); + + test('백엔드 표를 읽는다 — 특별한 행(풀어 쓴 한글 · \u0130 · BMP 밖 글자)이 들어 있다', () { + final List<(String, Object?)> fold = _rows('FOLD_SAMPLES'); + final List<(String, Object?)> grams = _rows('GRAM_SAMPLES'); + expect(fold.length, greaterThanOrEqualTo(10)); + expect(grams.length, greaterThanOrEqualTo(8)); + final List inputs = [ + for (final (String input, Object? _) in fold) input, + ]; + expect(inputs, contains('\u1112\u1161\u11AB\u1100\u1173\u11AF')); + expect(inputs, contains('\u0130stanbul')); + expect(inputs, contains('\u{10400}\u{1F600}')); + }); + }); + + group('foldText (접기 — 표 FOLD_SAMPLES)', () { + test('표의 모든 행 — 넣은 글을 접으면 답과 같다', () { + for (final (String input, Object? folded) in _rows('FOLD_SAMPLES')) { + expect(foldText(input), folded, reason: '행 ${input.runes.toList()}'); + } + }); + + test('접은 글은 (한글을 모은) 원문과 UTF-16 길이가 늘 같다 — BMP 의 모든 글자', () { + for (final (String input, Object? _) in _rows('FOLD_SAMPLES')) { + expect(foldText(input).length, composeHangul(input).length); + } + final List changedLength = []; + for (int code = 0; code <= 0xFFFF; code++) { + if (code >= 0xD800 && code <= 0xDFFF) continue; + final String char = String.fromCharCode(code); + if (foldText(char).length != char.length) changedLength.add(code); + } + expect(changedLength, isEmpty); + }); + + test('두 번 접어도 같다 · 짝이 없는 서로게이트는 그대로 둔다', () { + for (final (String input, Object? folded) in _rows('FOLD_SAMPLES')) { + expect(foldText(folded! as String), folded); + expect(foldText(foldText(input)), folded); + } + expect(foldText('a\uD800B'), 'a\uD800b'); + expect(foldText('\uDC00A'), '\uDC00a'); + }); + }); + + group('gramsOf (글자 두 개 묶음 — 표 GRAM_SAMPLES)', () { + test('표의 모든 행 — 나온 차례대로 겹치지 않게', () { + for (final (String fold, Object? grams) in _rows('GRAM_SAMPLES')) { + expect(gramsOf(fold).toList(), grams, reason: '행 $fold'); + } + }); + + test( + '공백은 정규식 \\s 와 같은 글자다(낱말 나누기와 같다) — Dart 의 trim 이 지우는 U+0085 는 아니다', + () { + final RegExp space = RegExp(r'\s', unicode: true); + final List differ = []; + for (int code = 0; code <= 0xFFFF; code++) { + if (code >= 0xD800 && code <= 0xDFFF) continue; + if (isSearchSpaceCode(code) != + space.hasMatch(String.fromCharCode(code))) { + differ.add(code); + } + } + expect(differ, isEmpty); + expect(isSearchSpaceCode(0x85), isFalse); + }, + ); + }); + + group('parseSearchTerms (검색어 나누기 — 짝: 백엔드 search-query.ts)', () { + List texts(String query) => [ + for (final SearchTerm term in parseSearchTerms(query)) term.text, + ]; + + test('띄어쓰기로 낱말을 나눈다 — 여러 칸 · 전각 공백 · 줄바꿈도 한 번의 띄어쓰기', () { + expect(parseSearchTerms(' 서버 이전\u3000일정\n공유 '), [ + (kind: SearchTermKind.word, text: '서버'), + (kind: SearchTermKind.word, text: '이전'), + (kind: SearchTermKind.word, text: '일정'), + (kind: SearchTermKind.word, text: '공유'), + ]); + }); + + test('큰따옴표 안은 구절 하나다 — 짝이 없는 마지막 따옴표는 없는 것으로 본다', () { + expect(parseSearchTerms('회의 "일정 공유" 서버'), [ + (kind: SearchTermKind.word, text: '회의'), + (kind: SearchTermKind.phrase, text: '일정 공유'), + (kind: SearchTermKind.word, text: '서버'), + ]); + expect(texts('회의 "일정 공유'), ['회의', '일정', '공유']); + expect(parseSearchTerms('"a b" "c'), [ + (kind: SearchTermKind.phrase, text: 'a b'), + (kind: SearchTermKind.word, text: 'c'), + ]); + }); + + test('문자 · 숫자가 없는 낱말 · 구절은 버리고, 낱말 앞뒤의 문장부호는 뗀다 — 같은 낱말은 한 번', () { + for (final String query in [ + '-', + '…', + '- … !!', + '"- …"', + '\u{1F600}', + '" "', + '""', + ]) { + expect(parseSearchTerms(query), isEmpty, reason: query); + } + expect(texts('회의, (회의) 회의. “회의” [공지] 2026. 회의!'), [ + '회의', + '공지', + '2026', + ]); + expect(parseSearchTerms('"회의, 일정."'), [ + (kind: SearchTermKind.phrase, text: '회의, 일정.'), + ]); + expect(parseSearchTerms('회의 회의 "일정" "일정" 일정'), [ + (kind: SearchTermKind.word, text: '회의'), + (kind: SearchTermKind.phrase, text: '일정'), + (kind: SearchTermKind.word, text: '일정'), + ]); + }); + + test('글자에 붙은 결합 문자는 남기고, 그림 문자와 그 이체 선택자는 함께 뗀다', () { + expect(texts('cafe\u0301!'), ['cafe\u0301']); + expect(texts('회의\u{1F600}\uFE0F'), ['회의']); + }); + + test('풀어 쓴 한글은 음절로 모으고 NUL 은 U+FFFD 로 바꾼다(서버의 색인과 같다)', () { + expect( + parseSearchTerms('\u1112\u1161\u11AB\u1100\u1173\u11AF'), + [(kind: SearchTermKind.word, text: '한글')], + ); + expect(parseSearchTerms('계\u0000약'), [ + (kind: SearchTermKind.word, text: '계\uFFFD약'), + ]); + }); + }); + + group('isSingleCharSearch (한 글자 낱말뿐인 검색 — 서버가 본문을 찾지 않는다)', () { + test('낱말이 모두 한 글자면 그렇다 — 여러 칸을 합쳐 본다', () { + expect(isSingleCharSearch(['김']), isTrue); + expect(isSingleCharSearch(['김 a 3']), isTrue); + expect(isSingleCharSearch(['김', '', '', '', '박']), isTrue); + // 한 글자 낱말로만 된 구절도 묶음이 없다 + expect(isSingleCharSearch(['"가 나"']), isTrue); + }); + + test('두 글자 이상 낱말이 하나라도 있으면 아니다 — 다른 칸에 있어도', () { + expect(isSingleCharSearch(['계약']), isFalse); + expect(isSingleCharSearch(['김 계약']), isFalse); + expect(isSingleCharSearch(['김', '', '', '견적', '']), isFalse); + expect(isSingleCharSearch(['"가나"']), isFalse); + // 풀어 쓴 한 글자도 모은 뒤 센다 + expect(isSingleCharSearch(['\u1100\u1161']), isTrue); + }); + + test('찾을 낱말이 없으면(빈 글 · 문장부호뿐) 아니다 — 앞뒤 문장부호는 뗀 뒤 센다', () { + expect(isSingleCharSearch([]), isFalse); + expect(isSingleCharSearch(['', ' ']), isFalse); + expect(isSingleCharSearch(['- …']), isFalse); + expect(isSingleCharSearch(['(김)']), isTrue); + }); + }); + + group('검색어 다듬기 · 한도', () { + test( + '앞뒤 공백(전각 공백 · 줄바꿈 포함)을 빼고 200자(UTF-16)에서 자른다 — U+0085 는 지우지 않는다(JS trim 과 같다)', + () { + expect(normalizeSearchText(' 계약 \u3000\n'), '계약'); + expect(normalizeSearchText('\u0085계약'), '\u0085계약'); + expect(normalizeSearchText('가' * 250).length, kMailSearchMaxLength); + expect(normalizeSearchText(''), ''); + expect(normalizeSearchText(' \u00A0 '), ''); + }, + ); + + test('이모지를 가운데서 자르게 되면 그 글자를 뺀다 · 자른 뒤 끝 공백도 뺀다', () { + final String cut = normalizeSearchText('${'가' * 199}\u{1F600}'); + expect(cut, '가' * 199); + expect(normalizeSearchText('${'가' * 199} 나').length, 199); + }); + + test('낱말 수는 따옴표와 상관없이 공백으로 나눈 조각이다(서버와 같은 기준)', () { + expect(countSearchWords('계약'), 1); + expect(countSearchWords(' "유지보수 계약" 갱신\u3000건 '), 4); + expect(countSearchWords(''), 0); + expect(countSearchWords('11월 정기 점검 일정 안내 메일 다시 보내 주세요'), 9); + }); + }); + + group('composeHangul (한글 자모 모으기 — NFC 의 한글 부분)', () { + test('첫소리 + 가운뎃소리(+ 끝소리)를 음절로, 받침 없는 음절 + 끝소리도 모은다', () { + expect(composeHangul('\u1112\u1161\u11AB'), '한'); + expect(composeHangul('가\u11AB'), '간'); + expect(composeHangul('a\u1100\u1161b'), 'a가b'); + }); + + test('모을 것이 없으면 같은 글 · 짝이 없는 자모는 그대로 · 받침 있는 음절에는 끝소리를 더 붙이지 않는다', () { + const String plain = '계약서 \u{1F600}'; + expect(identical(composeHangul(plain), plain), isTrue); + expect(composeHangul('\u1100'), '\u1100'); + expect(composeHangul('\u1161'), '\u1161'); + expect(composeHangul('간\u11AB'), '간\u11AB'); + }); + }); +} diff --git a/flutter/test/support/mail_fakes.dart b/flutter/test/support/mail_fakes.dart index 8cd476c..9e91c08 100644 --- a/flutter/test/support/mail_fakes.dart +++ b/flutter/test/support/mail_fakes.dart @@ -166,6 +166,46 @@ class FakeMailBackend { final List<({String id, Object? images})> detailRequests = <({String id, Object? images})>[]; + /// 받은 GET /messages/{id} 의 쿼리 전체 (메일 id · 쿼리 — 검색 강조 쿼리를 본다) + final List<({String id, Map query})> detailQueries = + <({String id, Map query})>[]; + + /// 검색 강조 쿼리(`q`)로 물었을 때 덮는 칸 — 메일 id 마다(`searchHighlight` · NFC 로 실은 제목 등) + final Map> highlightDetails = + >{}; + + // ── 검색 (GET /messages?q=… · GET /search-index) ── + + /// 검색 응답 — 있으면 이것으로 답한다(쿼리를 받는다). 없으면 메일 제목 · 미리 보기에 q 가 든 메일을 한 쪽씩([defaultSearch]) + FakeResponse Function(Map query)? searchResponder; + + /// 기본 검색이 항목에 `searchHighlight` 칸을 싣는다(14단계 서버) — 값은 [searchHighlights] 에서(없으면 null) + bool searchHighlightField = false; + + /// 기본 검색 항목의 `searchHighlight` — 메일 id 마다 + final Map> searchHighlights = + >{}; + + /// 있으면 다음 검색 응답이 이것이 끝날 때까지 기다린다(한 번 쓰면 비운다) + Completer? searchGate; + + /// `GET /search-index` 의 응답 — 기본은 준비됨 + FakeResponse searchIndex = FakeResponse.ok({ + 'status': 'ready', + 'done': 10, + 'total': 10, + }); + + /// 받은 검색 요청의 쿼리 (차례대로) + List> get searchQueries => >[ + for (final RequestOptions request in listRequests) + if (request.queryParameters.containsKey('q')) request.queryParameters, + ]; + + /// 받은 `GET /search-index` 의 수 + int get searchIndexRequests => + server.received('GET', MailApiPaths.searchIndex).length; + /// 받은 PATCH /messages/{id} (메일 id · 본문) final List<({String id, Map body})> singlePatches = <({String id, Map body})>[]; @@ -216,6 +256,18 @@ class FakeMailBackend { }); }); server.on('GET', MailApiPaths.messages, (RequestOptions options) async { + if (options.queryParameters.containsKey('q')) { + final Completer? gate = searchGate; + if (gate != null) { + searchGate = null; + await gate.future; + } + final FakeResponse Function(Map query)? responder = + searchResponder; + return responder == null + ? defaultSearch(options.queryParameters) + : responder(options.queryParameters); + } final Completer? gate = listGate; if (gate != null) { if (listGateOnce) listGate = null; @@ -301,9 +353,87 @@ class FakeMailBackend { if (failure != null) return failure; return FakeResponse.ok({'state': state}); }); + server.on('GET', MailApiPaths.searchIndex, (_) => searchIndex); _installReader(); } + /// 기본 검색 — 찾을 곳(메일함 · 없으면 휴지통 · 스팸메일함 밖 모두)의 메일 가운데 제목 · 미리 보기에 q 가 든 것을 + /// 받은 시각 내림차순(같으면 id 내림차순 — 서버 색인과 같다)으로 한 쪽씩. 커서는 그 쪽 끝 줄의 자리([searchCursorAfter]) — + /// 서버처럼 그 줄 **다음**부터 준다(그사이 앞쪽 메일이 빠지거나 되돌아와도 다음 쪽은 밀리지도 겹치지도 않는다). 전체 수는 첫 쪽에만 싣는다. + /// [searchResponder] 가 쪽마다 이것을 불러 섞어 쓸 수 있다 + FakeResponse defaultSearch(Map query) { + final String q = query['q']! as String; + final Object? mailboxId = query['mailboxId']; + final List> pool = >[ + for (final MapEntry>> entry + in messages.entries) + if (mailboxId == null + ? entry.key != kTrashId && entry.key != kJunkId + : entry.key == mailboxId) + ...entry.value, + ]; + if (mailboxId != null && + !mailboxes.any((Map m) => m['id'] == mailboxId)) { + return FakeResponse.fail(404, 'RES_001'); + } + final List> matched = + pool + .where( + (Map m) => + (m['subject']! as String).contains(q) || + (m['preview']! as String).contains(q), + ) + .toList() + ..sort((Map a, Map b) { + final int byTime = (b['receivedAt']! as String).compareTo( + a['receivedAt']! as String, + ); + return byTime != 0 + ? byTime + : (b['id']! as String).compareTo(a['id']! as String); + }); + final int limit = (query['limit'] as int?) ?? kMailPageSize; + final Object? cursor = query['cursor']; + final List> rest = cursor is String + ? matched.where(_afterSearchCursor(cursor)).toList() + : matched; + final List> page = rest.take(limit).toList(); + final bool hasNext = rest.length > page.length; + return FakeResponse.ok({ + 'items': >[ + for (final Map message in page) + { + ...message, + if (searchHighlightField) + 'searchHighlight': searchHighlights[message['id']], + }, + ], + 'pageInfo': { + 'hasNext': hasNext, + 'nextCursor': hasNext ? searchCursorAfter(page.last) : null, + 'total': cursor == null ? matched.length : null, + }, + }); + } + + /// 기본 검색의 커서 — [message] 줄 다음부터(`s:<받은 시각>|` — 서버 색인 커서처럼 받은 시각 · id 가 더 작은 쪽) + static String searchCursorAfter(Map message) => + 's:${message['receivedAt']}|${message['id']}'; + + /// 커서 자리 다음의 메일인가 + static bool Function(Map message) _afterSearchCursor( + String cursor, + ) { + final List parts = cursor.substring(2).split('|'); + final String at = parts[0]; + final String id = parts[1]; + return (Map message) { + final int byTime = (message['receivedAt']! as String).compareTo(at); + return byTime < 0 || + (byTime == 0 && (message['id']! as String).compareTo(id) < 0); + }; + } + static final RegExp _messagePath = RegExp(r'^/messages/([^/]+)$'); static final RegExp _attachmentPath = RegExp( r'^/messages/([^/]+)/attachments/([^/]+)$', @@ -319,6 +449,10 @@ class FakeMailBackend { final String id = _idOf(_messagePath, options.path); final Object? images = options.queryParameters[kMailImagesQuery]; detailRequests.add((id: id, images: images)); + detailQueries.add(( + id: id, + query: Map.of(options.queryParameters), + )); final Completer? gate = detailGate; if (gate != null) { detailGate = null; @@ -329,6 +463,7 @@ class FakeMailBackend { final Map? detail = detailJsonOf( id, showImages: images == kMailImagesShow, + highlighted: options.queryParameters.containsKey('q'), ); return detail == null ? FakeResponse.fail(404, 'RES_001') @@ -380,8 +515,13 @@ class FakeMailBackend { }); } - /// 메일 한 통의 읽기 칸 — 목록 칸 위에 [details] (와 외부 이미지를 보일 때 [shownImageDetails])를 덮는다. 없는 메일은 null - Map? detailJsonOf(String id, {bool showImages = false}) { + /// 메일 한 통의 읽기 칸 — 목록 칸 위에 [details] (와 외부 이미지를 보일 때 [shownImageDetails] · 강조 쿼리로 물었을 때 [highlightDetails])를 덮는다. + /// 없는 메일은 null + Map? detailJsonOf( + String id, { + bool showImages = false, + bool highlighted = false, + }) { final ({String mailboxId, Map message})? found = _find(id); if (found == null) return null; return { @@ -402,6 +542,7 @@ class FakeMailBackend { 'largeFiles': [], ...?details[id], if (showImages) ...?shownImageDetails[id], + if (highlighted) ...?highlightDetails[id], }; } @@ -418,6 +559,12 @@ class FakeMailBackend { /// 메일이 지금 든 메일함 (없으면 null) String? mailboxOf(String id) => _find(id)?.mailboxId; + /// 메일을 [mailboxId] 로 옮긴다(다른 화면 · 다른 기기에서 옮긴 것처럼) — 옮긴 뒤의 칸 + Map move(String id, String mailboxId) { + _apply(id, {'mailboxId': mailboxId}); + return _find(id)!.message; + } + /// 메일의 지금 칸 (없으면 null) Map? messageOf(String id) => _find(id)?.message; diff --git a/flutter/test/support/ts_table.dart b/flutter/test/support/ts_table.dart new file mode 100644 index 0000000..04de897 --- /dev/null +++ b/flutter/test/support/ts_table.dart @@ -0,0 +1,146 @@ +import 'dart:io'; + +/// 모노레포의 TypeScript 표본 표를 읽는다 — 시험 전용(앱 코드에서 쓰지 않는다). +/// +/// `export const NAME … = [` 부터 짝이 맞는 `]` 까지를 풀어 글(String) · 목록(List)의 나무로 돌려준다. +/// 읽는 것: 작은따옴표 · 큰따옴표 글, `\u` · `\u{…}` · `\n` · `\t` · `\r` · `\0` · `\\` · `\'` · `\"` 이스케이프, 대괄호 · 쉼표(끝 쉼표 포함) · `//` 주석 · `/* */` 주석. +/// 줄바꿈 모양(prettier)이 바뀌어도 읽는다. 그 밖의 꼴(템플릿 글 · 수 · 변수)이 나오면 [FormatException] — 표의 모양이 바뀐 것을 시험이 알아챈다. +List readTsArray(String source, String name) { + final int at = source.indexOf('export const $name'); + if (at < 0) throw FormatException('표 $name 이 없다'); + final int open = source.indexOf('= [', at); + if (open < 0) throw FormatException('표 $name 의 배열이 없다'); + final _TsReader reader = _TsReader(source, open + 2); + final Object? value = reader.readValue(); + if (value is! List) throw FormatException('표 $name 이 배열이 아니다'); + return value; +} + +/// [path](flutter/ 에서 본 상대 경로)의 TS 파일에서 [name] 표를 읽는다 — `flutter test` 는 flutter/ 에서 돈다 +List readTsArrayFile(String path, String name) => + readTsArray(File(path).readAsStringSync(), name); + +class _TsReader { + _TsReader(this._source, this._index); + + final String _source; + int _index; + + Object? readValue() { + _skip(); + _expectMore(); + final String char = _source[_index]; + if (char == '[') return _readArray(); + if (char == "'" || char == '"') return _readString(); + throw FormatException('모르는 값', _source, _index); + } + + List _readArray() { + _index++; + final List items = []; + while (true) { + _skip(); + _expectMore(); + if (_source[_index] == ']') { + _index++; + return items; + } + items.add(readValue()); + _skip(); + _expectMore(); + final String next = _source[_index]; + if (next == ',') { + _index++; + } else if (next != ']') { + throw FormatException('쉼표나 ] 가 와야 한다', _source, _index); + } + } + } + + String _readString() { + final String quote = _source[_index]; + _index++; + final StringBuffer out = StringBuffer(); + while (true) { + _expectMore(); + final String char = _source[_index]; + if (char == quote) { + _index++; + return out.toString(); + } + if (char == '\n' || char == '\r') { + throw FormatException('글 안에 줄바꿈', _source, _index); + } + if (char != r'\') { + out.write(char); + _index++; + continue; + } + _index++; + _expectMore(); + final String escape = _source[_index]; + _index++; + switch (escape) { + case 'n': + out.write('\n'); + case 't': + out.write('\t'); + case 'r': + out.write('\r'); + case '0': + out.write('\u0000'); + case r'\': + case "'": + case '"': + out.write(escape); + case 'u': + out.writeCharCode(_readUnicodeEscape()); + default: + throw FormatException('모르는 이스케이프 \\$escape', _source, _index); + } + } + } + + /// `\u` 뒤 — `{1F600}` 또는 네 자리 16진수 + int _readUnicodeEscape() { + _expectMore(); + if (_source[_index] == '{') { + final int close = _source.indexOf('}', _index); + if (close < 0) throw FormatException('닫히지 않은 \\u{', _source, _index); + final String hex = _source.substring(_index + 1, close); + _index = close + 1; + return int.parse(hex, radix: 16); + } + if (_index + 4 > _source.length) { + throw FormatException('짧은 \\u', _source, _index); + } + final String hex = _source.substring(_index, _index + 4); + _index += 4; + return int.parse(hex, radix: 16); + } + + /// 공백 · 주석을 건너뛴다 + void _skip() { + while (_index < _source.length) { + final String char = _source[_index]; + if (char == ' ' || char == '\n' || char == '\r' || char == '\t') { + _index++; + } else if (_source.startsWith('//', _index)) { + final int end = _source.indexOf('\n', _index); + _index = end < 0 ? _source.length : end + 1; + } else if (_source.startsWith('/*', _index)) { + final int end = _source.indexOf('*/', _index + 2); + if (end < 0) throw FormatException('닫히지 않은 주석', _source, _index); + _index = end + 2; + } else { + return; + } + } + } + + void _expectMore() { + if (_index >= _source.length) { + throw FormatException('표가 중간에 끝났다', _source, _index); + } + } +}