feat(flutter): 앱 첫 판 화면 — 로그인 · 메일 목록 · 읽기 · 쓰기 · 검색 · 새 메일 알림 #21

Merged
ttipo merged 34 commits from feat/app-push into main 2026-11-08 16:34:38 +09:00
42 changed files with 6697 additions and 142 deletions
Showing only changes of commit 37f078e65c - Show all commits
+3 -5
View File
@@ -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<String> _codePoints(String value) => <String>[
for (final int rune in value.runes) String.fromCharCode(rune),
+4
View File
@@ -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);
+10
View File
@@ -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;
}
+8
View File
@@ -210,6 +210,7 @@ class AppExtraColors extends ThemeExtension<AppExtraColors> {
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<AppExtraColors> {
star: AppColors.star,
rowChecked: AppColors.rowChecked,
brandTintBorder: AppColors.brandTintBorder,
searchMark: AppColors.searchMark,
);
/// 시각 · 개수 · 보조 설명
@@ -277,6 +279,9 @@ class AppExtraColors extends ThemeExtension<AppExtraColors> {
/// 여러 통 고르기 막대(바탕 `secondaryContainer`)의 아래 선
final Color brandTintBorder;
/// 검색에서 맞은 말의 바탕 (`--color-search-mark`)
final Color searchMark;
@override
AppExtraColors copyWith({
Color? textMuted,
@@ -297,6 +302,7 @@ class AppExtraColors extends ThemeExtension<AppExtraColors> {
Color? star,
Color? rowChecked,
Color? brandTintBorder,
Color? searchMark,
}) {
return AppExtraColors(
textMuted: textMuted ?? this.textMuted,
@@ -317,6 +323,7 @@ class AppExtraColors extends ThemeExtension<AppExtraColors> {
star: star ?? this.star,
rowChecked: rowChecked ?? this.rowChecked,
brandTintBorder: brandTintBorder ?? this.brandTintBorder,
searchMark: searchMark ?? this.searchMark,
);
}
@@ -342,6 +349,7 @@ class AppExtraColors extends ThemeExtension<AppExtraColors> {
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)!,
);
}
}
+12
View File
@@ -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();
@@ -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<SearchRange> subject;
/// 보일 미리 보기 한 줄 — 본문에서 맞았으면 맞은 말 둘레(잘린 쪽에 "…"), 아니면 엔진의 미리 보기
final String previewText;
/// [previewText] 안의 맞은 곳 — 비었을 수 있다(인용 밖의 첫 글을 보일 때)
final List<SearchRange> previewRanges;
/// 모양이 틀리면 null — 강조만 버리고 항목은 살린다
static MessageListHighlight? tryParse(Object? json) {
if (json is! Map<String, Object?>) return null;
final List<SearchRange>? subject = parseSearchRanges(json['subject']);
final Object? preview = json['preview'];
if (subject == null || preview is! Map<String, Object?>) return null;
final Object? text = preview['text'];
final List<SearchRange>? 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<String, Object?>) 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 <SearchRange>[],
this.attachments = const <String, List<SearchRange>>{},
this.textBody = const <SearchRange>[],
});
/// 제목의 맞은 곳
final List<SearchRange> subject;
/// 첨부 이름의 맞은 곳 — 첨부 id 마다(맞은 첨부만)
final Map<String, List<SearchRange>> attachments;
/// 글 본문(textBody)의 맞은 곳 — 서식 본문은 서버가 맞은 글 조각에 `<mark>` 를 넣어 보낸다
final List<SearchRange> textBody;
/// 모양이 틀리면 null — 강조만 버리고 메일은 연다
static MessageDetailHighlight? tryParse(Object? json) {
if (json is! Map<String, Object?>) return null;
final List<SearchRange>? subject = parseSearchRanges(json['subject']);
final List<SearchRange>? textBody = parseSearchRanges(json['textBody']);
final Object? rawAttachments = json['attachments'];
if (subject == null ||
textBody == null ||
rawAttachments is! Map<String, Object?>) {
return null;
}
final Map<String, List<SearchRange>> attachments =
<String, List<SearchRange>>{};
for (final MapEntry<String, Object?> entry in rawAttachments.entries) {
final List<SearchRange>? ranges = parseSearchRanges(entry.value);
if (ranges == null) return null;
attachments[entry.key] = ranges;
}
return MessageDetailHighlight(
subject: subject,
attachments: Map<String, List<SearchRange>>.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<String, List<SearchRange>> a,
Map<String, List<SearchRange>> b,
) =>
a.length == b.length &&
a.entries.every(
(MapEntry<String, List<SearchRange>> 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<String, String> toQueryParameters() => <String, String>{
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;
}
@@ -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 <MessageAttachment>[],
this.largeFiles = const <MessageLargeFile>[],
this.searchHighlight,
});
/// 목록과 같은 칸 — 읽음 · 중요 · 메일함 · 붙잡힌 발송
@@ -154,6 +157,9 @@ class MessageDetail {
final List<MessageAttachment> attachments;
final List<MessageLargeFile> 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']),
);
}
@@ -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<String, Object?>) return null;
@@ -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<MailPage<MailSearchItem>> searchMessages({
required String q,
String? mailboxId,
String? cursor,
int limit = kMailPageSize,
bool silent = false,
CancelToken? cancelToken,
}) async {
final Response<Object?> response = await _dio.get<Object?>(
MailApiPaths.messages,
queryParameters: <String, Object?>{
'q': q,
'mailboxId': ?mailboxId,
'limit': limit,
'cursor': ?cursor,
},
options: silent ? ApiOptions.silent() : null,
cancelToken: cancelToken,
);
final MailPage<MailSearchItem>? page = MailPage.tryParse<MailSearchItem>(
response.data,
MailSearchItem.tryParse,
);
if (page == null) throw _malformed(response);
return page.withServerTime(serverTimeOf(response));
}
/// 지난 메일 검색 색인의 상태 — 모르는 값이면 null. 알림은 띄우지 않는다(받지 못하면 화면은 안내 줄을 보이지 않는다 — 계약)
Future<SearchIndexStatus?> searchIndexStatus() async {
final Response<Object?> response = await _dio.get<Object?>(
MailApiPaths.searchIndex,
options: ApiOptions.silent(),
);
final Object? data = response.data;
if (data is! Map<String, Object?>) throw _malformed(response);
return SearchIndexStatus.parse(data['status']);
}
/// 메일 한 통 — 조회만으로는 읽음 처리하지 않는다(계약 2절). [showImages] 면 외부 이미지를 보이는 본문(`images=show`).
/// [highlight] 가 있으면 검색 강조 쿼리(`q` 등 — 빈 칸은 빼고)를 함께 실어 맞은 말의 위치 · `<mark>` 를 받는다(14단계 서버만 받는다).
/// 알림은 [silent] 로 끈다 — 처음 여는 메일은 화면이 받지 못함을 직접 그린다. 응답의 서버 시각(`Date`)도 함께 준다
Future<({MessageDetail detail, DateTime? serverTime})> getMessage(
String id, {
bool showImages = false,
bool silent = true,
MessageHighlightQuery? highlight,
}) async {
final Map<String, Object?> query = <String, Object?>{
if (showImages) kMailImagesQuery: kMailImagesShow,
...?highlight?.toQueryParameters(),
};
final Response<Object?> response = await _dio.get<Object?>(
MailApiPaths.message(id),
queryParameters: showImages
? <String, Object?>{kMailImagesQuery: kMailImagesShow}
: null,
queryParameters: query.isEmpty ? null : query,
options: silent ? ApiOptions.silent() : null,
);
final MessageDetail? detail = MessageDetail.tryParse(response.data);
+39 -11
View File
@@ -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단계) — 글 본문을 보일 때만 `<mark>` 로 넣는다
/// (서식 본문은 서버가 정화한 뒤 맞은 글 조각에 `<mark>` 를 넣어 보냈고, 앱의 정화가 그대로 남긴다)
SanitizedMailBody mailBodyFragment({
required String? htmlBody,
required String? textBody,
required bool imagesShown,
List<SearchRange> textHighlights = const <SearchRange>[],
}) {
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:
'<div class="mail-text">${escapeHtmlText(textBody ?? '')}</div>',
proxiedTokens: const <String>[],
htmlOmitted: htmlOmitted,
);
SanitizedMailBody _textFragment(
String? textBody, {
bool htmlOmitted = false,
List<SearchRange> highlights = const <SearchRange>[],
}) => SanitizedMailBody(
fragment:
'<div class="mail-text">${highlightedTextHtml(textBody ?? '', highlights)}</div>',
proxiedTokens: const <String>[],
htmlOmitted: htmlOmitted,
);
/// 글 → 이스케이프한 HTML — 맞은 곳은 속성 없는 `<mark>` 로(웹 `mail-html.ts` 의 `textBodyHtml` 과 같다).
/// 위치는 그 글(강조를 받은 응답의 textBody) 기준이다 — 글 밖 · 거꾸로 된 위치는 버린다([highlightSegments])
String highlightedTextHtml(String text, List<SearchRange> 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 ? '<mark>$escaped</mark>' : 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단계 — 서버가 서식 본문에 넣은 <mark> · 글 본문은 highlightedTextHtml). 짝: 웹 `MAIL_FRAME_STYLE` 의 mark ·
// 웹 토큰 `--color-search-mark` · 앱 `AppColors.searchMark` — 한쪽을 고치면 셋 다. 보낸 사람이 본문에 쓴 <mark> 도 같은 노랑이다(웹과 같은 알려진 한계)
'mark{background:#FCE7A4;color:inherit;border-radius:2px;padding:0 1px}';
/// WebView 에 넣을 문서 — CSP 를 맨 앞에(어떤 내용보다 먼저 걸리게) 둔다
String buildMailDocument(String bodyFragment) => <String>[
@@ -105,6 +105,25 @@ Mailbox? findMailboxByRole(List<Mailbox> mailboxes, MailboxRole role) {
return null;
}
/// 메일이 든 메일함 — [preferredId](검색한 메일함 등)에 들어 있으면 그것, 아니면 메일의 메일함 가운데 아는 첫 메일함.
/// 메일함 목록을 아직 못 받았거나 모르는 메일함뿐이면 null (웹 `mail-list-row.ts` 의 `mailboxOfMessage` 와 같다)
Mailbox? mailboxOfMessage(
List<String> mailboxIds,
List<Mailbox> mailboxes, {
String? preferredId,
}) {
final List<String> ids =
preferredId != null && mailboxIds.contains(preferredId)
? <String>[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<Mailbox> mailboxes) {
final Mailbox? inbox = findMailboxByRole(mailboxes, MailboxRole.inbox);
@@ -0,0 +1,126 @@
/// 검색 강조 — 서버가 준 맞은 곳(위치)으로 글을 조각낸다 (순수 함수 — 계약 2절 "검색 — 14단계").
///
/// **짝: 웹 `frontend/src/shared/utils/search-highlight.ts`**(버리는 위치 · 짝 글자 · 합치기가 같다). 한쪽을 고치면 둘 다.
/// 화면은 조각을 글 조각(`TextSpan`)으로 그리고(`widgets/highlighted_text.dart`), 읽기 화면의 글 본문은 조각을 이스케이프해 `<mark>` 로 넣는다(`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<SearchRange> ranges});
/// JS 의 `Number.MAX_SAFE_INTEGER` — 이보다 큰 소수 꼴 위치는 정수로 믿지 않는다
const double _maxSafeInteger = 9007199254740991;
/// 응답의 맞은 곳 목록(`[[start, end], ...]`)을 읽는다.
///
/// 목록이 아니거나 칸이 두 수의 목록이 아니면 null — 모양이 틀린 강조는 통째로 버린다(부르는 쪽은 강조 없이 그린다 · 항목은 살린다).
/// 정수가 아닌 위치(소수 부분이 있는 수)는 그 칸만 버린다(웹 `Number.isInteger` 와 같다 — `2.0` 은 정수다).
/// 글 밖 · 거꾸로 된 위치는 여기서 버리지 않는다 — 글을 알아야 하므로 [highlightSegments] 가 버린다
List<SearchRange>? parseSearchRanges(Object? json) {
if (json is! List<Object?>) return null;
final List<SearchRange> ranges = <SearchRange>[];
for (final Object? item in json) {
if (item is! List<Object?> || 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<SearchRange>.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<SearchRange> usableSearchRanges(List<SearchRange> ranges, String text) {
final int length = text.length;
final List<SearchRange> valid = <SearchRange>[];
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<SearchRange> merged = <SearchRange>[];
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<HighlightSegment> highlightSegments(
String text,
List<SearchRange>? ranges,
) {
if (ranges == null || ranges.isEmpty) {
return text.isEmpty
? const <HighlightSegment>[]
: <HighlightSegment>[(text: text, mark: false)];
}
final List<HighlightSegment> segments = <HighlightSegment>[];
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<HighlightSegment> segments) =>
segments.any((HighlightSegment segment) => segment.mark);
@@ -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<String> splitSearchWords(String text) {
final List<String> words = <String>[];
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<SearchTerm> parseSearchTerms(String query) {
final List<String> segments = composeHangul(
query,
).replaceAll(_nul, _replacementCharacter).split(_quote);
final List<SearchTerm> terms = <SearchTerm>[];
final Set<String> seen = <String>{};
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<String> gramsOf(String fold, [Set<String>? into]) {
final Set<String> grams = into ?? <String>{};
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<String> texts) {
int terms = 0;
final Set<String> grams = <String>{};
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;
}
+22 -2
View File
@@ -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<String>(state.pathParameters['messageId']!),
messageId: state.pathParameters['messageId']!,
search: switch (state.extra) {
final MailSearchOpen open => open,
_ => null,
},
),
),
GoRoute(
@@ -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 <SearchRange>[],
this.previewHighlights = const <SearchRange>[],
});
/// 보낸 사람 자리 — 보낸편지함 · 임시보관함은 받는 사람
@@ -56,13 +63,23 @@ class MailRowView {
/// 아직 보내지 않은 메일 — 고를 수 없다
final bool held;
/// 줄의 메일함 이름 — 전체 메일함 검색 결과에서만(시각 앞), 그 밖에는 null
final String? mailboxName;
/// [subject] 안의 검색에서 맞은 곳 — 검색 결과에서만
final List<SearchRange> subjectHighlights;
/// [preview] 안의 검색에서 맞은 곳 — 검색 결과에서만
final List<SearchRange> previewHighlights;
/// 동그란 사진(고르기 단추)의 화면 낭독기 이름 — "고르기: 제목"
String get selectLabel => MailListTexts.selectOne(subject);
/// 줄의 화면 낭독기 글 — 안 읽음 · 보낸 사람 · 시각 · 제목 · 첨부 · 중요 · 미리 보기 차례
/// 줄의 화면 낭독기 글 — 안 읽음 · 보낸 사람 · (메일함) · 시각 · 제목 · 첨부 · 중요 · 미리 보기 차례. 강조는 읽지 않는다
String get semanticsLabel => <String>[
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 <SearchRange>[],
previewHighlights: highlight?.previewRanges ?? const <SearchRange>[],
);
}
({String text, bool placeholder, String? initial}) _whoOf(
MessageSummary message,
MailboxRole? role,
) {
MailboxRole? role, {
required bool mixed,
}) {
final List<MailAddress> 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),
);
@@ -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(<String>[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,
);
}
@@ -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 = '메일함';
@@ -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<MailListPage> {
);
}
void _showSearchNotice() {
ref.read(mailToastProvider.notifier).notify(MailListTexts.searchNotReady);
/// [찾기] — 이 메일함에서 시작하는 검색 화면을 위에 쌓는다(받은편지함 주소는 메일함 목록의 기본 메일함)
void _openSearch(String? mailboxId) {
unawaited(
context.push<void>(
MailRoutes.search,
extra: MailSearchStart(mailboxId: mailboxId),
),
);
}
void _toggle(MessageSummary message) {
@@ -177,7 +184,7 @@ class _MailListPageState extends ConsumerState<MailListPage> {
? MailListTexts.unread(mailbox.unreadCount)
: null,
onOpenDrawer: () => unawaited(_openDrawer(list.mailboxId)),
onSearch: _showSearchNotice,
onSearch: () => _openSearch(list.mailboxId ?? widget.mailboxId),
);
return PopScope(
@@ -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<MailSearchPage> createState() => _MailSearchPageState();
}
class _MailSearchPageState extends ConsumerState<MailSearchPage> {
/// 검색 화면마다 하나 — 같은 메일함에서 다시 열어도 앞 화면의 결과와 섞이지 않게
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<void>(MailRoutes.composeDraft(item.id))
: context.push<void>(
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<String?>(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: <Widget>[
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<String>('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<void> Function() onRefresh;
final VoidCallback onRetry;
final VoidCallback onSearchAll;
final VoidCallback onLoadMore;
final VoidCallback onSearchMore;
final ValueChanged<MailSearchItem> 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 <Widget>[
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: <Widget>[
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<MailSearchItem> onOpen;
@override
Widget build(BuildContext context, WidgetRef ref) {
// 시각 표기의 "오늘 · 어제"를 가르는 지금 — 시험은 손 시계로 덮어쓴다
final DateTime now = ref.read(clockProvider)();
// [되돌리기] 알림이 떠 있으면 마지막 줄이 가리지 않게(보드 「읽기 화면에서 지운 뒤」의 아래 여백 96)
final bool toast = ref.watch(mailToastProvider) != null;
final List<MailSearchItem> 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<String>(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;
}
}
@@ -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<MessageReaderPage> createState() => _MessageReaderPageState();
}
@@ -61,8 +68,12 @@ class _MessageReaderPageState extends ConsumerState<MessageReaderPage> {
/// 보내지 않은 메일의 삭제 확인 창이 열려 있다 — 그사이 발송이 풀리면 닫는다(보낸 메일의 삭제 확인으로 바뀌지 않게 — 웹과 같다)
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<MessageReaderPage> {
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<MessageReaderPage> {
@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<MailToast?>(mailToastProvider, (MailToast? _, MailToast? _) {});
ref.listen<MessageReaderState>(messageReaderProvider(widget.messageId), (
ref.listen<MessageReaderState>(messageReaderProvider(_args), (
MessageReaderState? previous,
MessageReaderState next,
) {
@@ -235,6 +244,13 @@ class _MessageReaderPageState extends ConsumerState<MessageReaderPage> {
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: <Widget>[
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 <String, List<SearchRange>>{},
downloads: downloads,
onDownload: (MessageAttachment attachment) =>
unawaited(downloader.download(attachment)),
@@ -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, MailSearchState, MailSearchArgs>(
MailSearchController.new,
);
/// 메일 검색 — 계약 2절 "검색" · "검색 — 14단계"(이어 찾기 · 준비 상태), 웹 `stores/mail/list.ts` 의 검색 결과와 같은 규칙.
///
/// - 자판의 [검색]으로만 찾는다(치는 동안 묻지 않는다). 낱말이 8개를 넘으면 묻지 않는다. 같은 검색어 · 같은 곳이면 보이는 채로 다시 찾는다.
/// - **이어 찾기**: 한 차례(처음 · 목록 끝에 닿음 · [더 찾기])에 한 쪽(20통)이 새로 차거나 더 없을 때까지 요청을 5번까지 보낸다.
/// 하나도 찾기 전에는 뼈대, 하나라도 찾으면 보이며 뒤에서 잇는다. 차례가 한 쪽을 못 채우고 끝났는데 더 있으면 저절로 잇지 않고 [더 찾기]를 둔다.
/// 다음 쪽의 `total` 이 null 이면 첫 쪽의 수를 남긴다. 같은 커서가 새 메일 없이 돌아오면 [다시 시도]로 멈춘다.
/// - **늦은 응답**: 검색 세대([_seq] — 새 검색 · 곳 바꿈 · 다시 찾기 · [✕] · 세션 바뀜마다)가 그대로일 때만 응답을 쓰고,
/// 앞 검색의 요청은 끊는다(화면을 닫아도 — 끊은 요청은 알리지 않는다). 준비 상태는 따로 세대([_indexSeq])를 둔다.
/// - **결과 갱신**: 새 메일 확인으로는 다시 찾지 않는다(보던 결과가 저절로 바뀌지 않게 — 웹과 같다). 이 앱의 읽기 화면이 바꾼 것만
/// 바로 반영한다(`MailChange` 의 `updated` · `removedIds`) — 표시 · 메일함 · 붙잡힘만 덮고 제목 · 미리 보기 · 강조는 그대로,
/// 찾은 곳을 떠난 메일은 빼서 이 검색 동안 기억했다가 [되돌리기]로 돌아오면 받은 시각 차례의 그 자리에 다시 넣는다
/// (불러온 범위 안에서 뺀 메일이다 — 서버 커서는 불러온 끝 줄의 다음부터 주므로 다음 쪽이 가져오지 않는다).
/// - **붙잡힌 메일**(보내는 중 · 예약): 보낼 시각 + 2초(서버 시계)에 그 줄만 다시 받아 표시 · 메일함 · 붙잡힘을 덮는다 —
/// 엔진이 풀어도 새 메일 확인의 상태는 바뀌지 않는다(목록 · 읽기 화면과 같은 규칙 · 웹 `usePendingSendRefresh`). 없어졌으면 뺀다.
/// - 세션이 바뀌면 처음(찾기 전)으로 돌아간다.
class MailSearchController extends Notifier<MailSearchState> {
MailSearchController(this.args);
final MailSearchArgs args;
int _seq = 0;
int _indexSeq = 0;
/// 지금 검색 세대의 요청을 끊는 손잡이
CancelToken? _cancel;
/// 이 검색에서 찾은 곳을 떠나 뺀 메일 — [되돌리기]로 다시 들어오면 그 자리에 다시 넣는다(id 마다, 뺄 때의 모습)
final Map<String, MailSearchItem> _removed = <String, MailSearchItem>{};
/// 읽기 화면이 결과를 바꾼 횟수 — 그 전에 떠난 붙잡힌 줄 다시 받기의 응답은 옛 상태라 버린다
int _localGeneration = 0;
/// 서버 시계 − 기기 시계 — 받은 응답의 `Date` 로 잰다(붙잡힌 발송이 풀릴 때를 서버 시계로 잰다)
Duration _serverOffset = Duration.zero;
/// 붙잡힌 줄을 다시 받을 때
Timer? _heldTimer;
/// 도는 붙잡힌 줄 다시 받기의 차례 번호 — null 이면 돌지 않는다(도는 동안은 다음 때를 잡지 않고 끝난 뒤에 잡는다)
int? _heldRun;
int _heldRuns = 0;
/// 시각이 지난 채 다시 본 횟수 — 메일 · 보낼 시각마다([_heldKey])
final Map<String, int> _heldRechecks = <String, int>{};
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<MailChange>(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<void> 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<void> retry() async {
if (!ref.mounted) return;
final String? q = state.query;
if (q != null) await _search(q);
}
/// 목록 끝에 닿았다 · 끝의 [다시 시도] — 한 쪽을 채운 차례 뒤에만 다음 차례를 묻는다([더 찾기]가 필요하면 그것으로만)
Future<void> 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<void> 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<void> _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<MailSearchItem> page = await _repository.searchMessages(
q: q,
mailboxId: mailboxId,
silent: !keepVisible,
cancelToken: cancel,
);
if (!_alive(seq)) return;
_noteServerTime(page.serverTime);
final List<MailSearchItem> 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<MailSearchItem>.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<void> _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<MailSearchItem> 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<MailSearchItem> 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<MailSearchItem> items = <MailSearchItem>[
...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<MailSearchItem> _unique(List<MailSearchItem> items) {
final Set<String> seen = <String>{};
return <MailSearchItem>[
for (final MailSearchItem item in items)
if (seen.add(item.id)) item,
];
}
/// 다음 쪽 — 지금 목록에 있는 메일은 건너뛴다(id — 그사이 새 메일이 와서 경계가 밀렸을 수 있다).
/// 이 검색에서 뺀 메일이어도 서버가 준 것이면 넣는다(다른 곳에서 되돌린 메일을 숨기지 않게)
List<MailSearchItem> _fresh(List<MailSearchItem> items) {
final Set<String> known = <String>{
for (final MailSearchItem item in state.items) item.id,
};
return <MailSearchItem>[
for (final MailSearchItem item in items)
if (known.add(item.id)) item,
];
}
/// 한 메일함에서 찾을 때의 그 메일함 — 시작한 메일함, 받은편지함 주소면 메일함 목록의 기본 메일함(받기 전이면 받아 본다)
Future<String?> _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<void> _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<MessageSummary> updated,
required Set<String> removedIds,
bool restore = true,
}) {
final MailSearchState current = state;
if (current.phase != MailSearchPhase.ready) return;
final Map<String, MessageSummary> byId = <String, MessageSummary>{
for (final MessageSummary summary in updated) summary.id: summary,
};
bool changed = false;
int removed = 0;
int restored = 0;
final List<MailSearchItem> kept = <MailSearchItem>[];
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<String> listed = <String>{
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<String> 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<MailSearchItem> 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<String> due = <String>[];
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<void> _refreshHeld(List<String> 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<MessageSummary> updated = <MessageSummary>[];
final Set<String> gone = <String>{};
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;
}
}
@@ -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 <MailSearchItem>[],
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<MailSearchItem> 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<MailSearchItem>? 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<MailSearchItem>.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?,
);
}
@@ -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<SanitizedMailBody> Function({
required String? htmlBody,
required String? textBody,
required bool imagesShown,
required List<SearchRange> textHighlights,
});
/// 본문 조각 만들기 — 짧고 얕은 본문은 그 자리에서, 길거나 깊은 본문은 [Isolate.run] 으로([mailBodyBuildPlaceOf])
@@ -45,11 +49,13 @@ Future<SanitizedMailBody> buildMailBody({
required String? htmlBody,
required String? textBody,
required bool imagesShown,
required List<SearchRange> textHighlights,
}) async {
SanitizedMailBody build() => mailBodyFragment(
htmlBody: htmlBody,
textBody: textBody,
imagesShown: imagesShown,
textHighlights: textHighlights,
);
final MailBodyBuildPlace place = mailBodyBuildPlaceOf(
htmlBody: htmlBody,
@@ -59,14 +65,34 @@ Future<SanitizedMailBody> 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<MessageReaderController, MessageReaderState, String>(
.family<MessageReaderController, MessageReaderState, MessageReaderArgs>(
MessageReaderController.new,
);
@@ -85,11 +111,17 @@ messageReaderProvider = NotifierProvider.autoDispose
/// 세션이 바뀌었으면(토큰 보관의 세션 세대) 알림 · [되돌리기] · 목록 알리기를 하지 않는다.
/// - 응답은 그 요청이 바꾼 칸(읽음 · 중요 · 메일함)만 지금 요약에 덮는다 — 그사이 다른 요청이 바꾼 칸 · 다시 받은 본문을 되돌리지 않게.
/// - 목록 · 메일함 개수는 새 메일 확인의 소식([MailStateWatcher.markChanged] — 그렇게 맞춘 요약을 싣는다)으로 맞춘다.
/// - 검색 결과에서 열었으면(강조 쿼리 — 14단계) 그 쿼리로 받아 제목 · 첨부 이름 · 본문의 맞은 말을 칠한다. 제목 강조는 그 응답의 제목 글과 함께 쥔다 —
/// 붙잡힌 메일을 다시 봐 요약을 바꿔도 그 글로 그린다(Dart 에는 NFC 가 없어 다른 응답의 글에 위치를 쓰지 않는다).
/// [이미지 보기]는 같은 쿼리로 다시 받아 본문 · 첨부 · 강조를 함께 바꾼다. 답장 · 전달은 쓰기 화면이 쿼리 없이 따로 받는다.
class MessageReaderController extends Notifier<MessageReaderState> {
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<MessageReaderState> {
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<MessageReaderState> {
if (serverTime != null) _serverOffset = serverTime.difference(_clock());
}
/// 강조를 받은 응답의 제목 강조 — 맞은 곳이 없거나(제목에서 맞지 않음) 제목이 비었으면 null
HighlightedLine? _subjectHighlightOf(MessageDetail detail) {
final List<SearchRange> ranges =
detail.searchHighlight?.subject ?? const <SearchRange>[];
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<MessageReaderState> {
bool alive() => ref.mounted && bodySeq == _bodySeq;
final MailBodyBuilder buildBody = ref.read(mailBodyBuilderProvider);
SanitizedMailBody body;
final List<SearchRange> textHighlights =
detail.searchHighlight?.textBody ?? const <SearchRange>[];
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<MessageReaderState> {
htmlBody: null,
textBody: detail.textBody,
imagesShown: false,
textHighlights: textHighlights,
);
}
if (!alive()) return;
@@ -309,6 +355,7 @@ class MessageReaderController extends Notifier<MessageReaderState> {
}
/// [이미지 보기] — 이 메일을 `images=show` 로 다시 받아 본문만 바꾼다(읽음 · 중요 · 메일함은 지금 값 그대로 — 웹과 같다).
/// 검색 결과에서 열었으면 같은 강조 쿼리로 받아 본문 · 첨부 · 제목의 강조도 그 응답의 것으로 바꾼다(계약 2절 "검색 — 14단계").
/// 다시 열면 설정대로다. 실패는 인터셉터가 알리고 안내 줄은 그대로 남는다
Future<void> showImages() async {
final Ref ref = this.ref;
@@ -327,12 +374,16 @@ class MessageReaderController extends Notifier<MessageReaderState> {
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;
@@ -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?,
);
}
@@ -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 없이 `<mark>` 조각).
///
/// - 맞은 말은 노랑 바탕(`--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<SearchRange> ranges;
final TextStyle? style;
/// 맞은 말의 글자색 — 없으면 둘레 글과 같다
final Color? markColor;
final int? maxLines;
final TextOverflow? overflow;
@override
Widget build(BuildContext context) {
final List<HighlightSegment> segments = highlightSegments(text, ranges);
if (!hasMarkedSegment(segments)) {
return Text(text, style: style, maxLines: maxLines, overflow: overflow);
}
final AppExtraColors extra = Theme.of(context).extension<AppExtraColors>()!;
final TextStyle mark = TextStyle(
backgroundColor: extra.searchMark,
color: markColor,
);
return Text.rich(
TextSpan(
children: <InlineSpan>[
for (final HighlightSegment segment in segments)
TextSpan(text: segment.text, style: segment.mark ? mark : null),
],
),
style: style,
maxLines: maxLines,
overflow: overflow,
);
}
}
@@ -40,7 +40,7 @@ class MailListEmpty extends StatelessWidget {
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
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: <Widget>[
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<Widget> 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<MailListFooter> createState() => _MailListFooterState();
}
@@ -356,7 +378,7 @@ class _MailListFooterState extends State<MailListFooter> {
child: const Text(MailListTexts.retry),
)
: Semantics(
label: MailListTexts.loadingMore,
label: widget.loadingLabel,
child: const SizedBox.square(
dimension: AppSize.iconLarge,
child: CircularProgressIndicator(
@@ -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<String> 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<AppExtraColors>()!;
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<String>('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 <TextInputFormatter>[
_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: <Widget>[
ValueListenableBuilder<TextEditingValue>(
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<bool> onChanged;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
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: <Widget>[
ConstrainedBox(
constraints: const BoxConstraints(
minHeight: AppSize.searchScopeRowHeight,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.s16),
child: Row(
children: <Widget>[
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: <Widget>[
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<AppExtraColors>()!;
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<FontFeature> _tabular = <FontFeature>[
FontFeature.tabularFigures(),
];
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
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<AppExtraColors>()!;
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: <Widget>[
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,
),
),
),
],
),
),
),
);
}
}
@@ -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<AppExtraColors>()!.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<AppExtraColors>()!;
final VoidCallback? searchAll = onSearchAll;
return MailStateLayout(
icon: Icons.search,
iconColor: extra.textMuted,
iconBackground: theme.colorScheme.surfaceContainerHigh,
title: MailSearchTexts.emptyTitle(query),
titleStyle: _stateTitleStyle(theme),
children: <Widget>[
KeepAllText(MailSearchTexts.emptyHint, style: _stateHintStyle(theme)),
if (indexing) ...<Widget>[
const SizedBox(height: AppSpacing.s8),
KeepAllText(
MailSearchTexts.wordLimitHint,
style: _stateHintStyle(theme),
),
],
if (searchAll != null) ...<Widget>[
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<AppExtraColors>()!;
return MailStateLayout(
icon: Icons.search,
iconColor: extra.textMuted,
iconBackground: theme.colorScheme.surfaceContainerHigh,
title: MailSearchTexts.partialTitle,
titleStyle: _stateTitleStyle(theme),
children: <Widget>[
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<AppExtraColors>()!;
return MailStateLayout(
icon: Icons.error_outline,
iconColor: extra.textMuted,
iconBackground: theme.colorScheme.surfaceContainerHigh,
title: MailSearchTexts.tooManyWordsTitle,
titleStyle: _stateTitleStyle(theme),
children: <Widget>[
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: <Widget>[
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<AppExtraColors>()!;
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() {}
}
@@ -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<AppExtraColors>()!;
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: <Widget>[
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: <Widget>[
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: <Widget>[
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: <Widget>[
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: <Widget>[
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) ...<Widget>[
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: <Widget>[
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,
@@ -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 <String, List<SearchRange>>{},
super.key,
});
@@ -31,6 +35,9 @@ class ReaderAttachments extends StatelessWidget {
final ValueChanged<MessageAttachment> onDownload;
final ValueChanged<MessageAttachment> onCancel;
/// 첨부 이름의 검색에서 맞은 곳 — 첨부 id 마다(맞은 첨부만)
final Map<String, List<SearchRange>> nameHighlights;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
@@ -74,6 +81,8 @@ class ReaderAttachments extends StatelessWidget {
_AttachmentRow(
key: ValueKey<String>('attachment-${attachment.id}'),
attachment: attachment,
nameHighlights:
nameHighlights[attachment.id] ?? const <SearchRange>[],
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<SearchRange> 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 <SearchRange>[],
this.trailing,
});
final IconData icon;
final String name;
/// [name] 안의 검색에서 맞은 곳
final List<SearchRange> nameHighlights;
final Widget below;
final Widget? trailing;
@@ -261,8 +279,9 @@ class _FileRow extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(
HighlightedText(
name,
ranges: nameHighlights,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
@@ -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<ReaderHeader> createState() => _ReaderHeaderState();
}
@@ -38,9 +49,12 @@ class _ReaderHeaderState extends State<ReaderHeader> {
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<ReaderHeader> {
),
child: Semantics(
header: true,
child: Text(
child: HighlightedText(
subject,
ranges: highlight?.ranges ?? const <SearchRange>[],
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: AppTypography.semiBold,
),
@@ -73,6 +88,7 @@ class _ReaderHeaderState extends State<ReaderHeader> {
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<AppExtraColors>()!;
return MergeSemantics(
child: Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: <Widget>[
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});
@@ -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';
@@ -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<String, Object?> _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'),
);
@@ -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(<String, Object?>{
'items': <Object?>[
<String, Object?>{
...messageJson('m1', mailboxId: kInboxId),
'searchHighlight': null,
},
],
'pageInfo': <String, Object?>{
'hasNext': true,
'nextCursor': 'opaque==',
'total': null,
},
}),
);
final DateTime serverTime = DateTime.utc(2026, 11, 7, 2);
h.server.serverTime = serverTime;
final MailPage<MailSearchItem> 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, <String, Object?>{
'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, <String, Object?>{
'q': '계약',
'limit': kMailPageSize,
});
expect(h.server.requests.last.extra[kSilentExtraKey], isNot(isTrue));
},
);
test('다음 쪽의 실패는 알린다 · 끊은 요청은 알리지 않는다', () async {
final Completer<void> gate = Completer<void>();
h.server.on('GET', MailApiPaths.messages, (_) async {
await gate.future;
return FakeResponse.fail(503, 'MAIL_001');
});
final CancelToken cancel = CancelToken();
final Future<Object> 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, <String>['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(<String, Object?>{
'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(<String, Object?>{
...messageJson('m1', mailboxId: kInboxId),
'searchHighlight': <String, Object?>{
'subject': <Object?>[
<Object?>[0, 2],
],
'attachments': <String, Object?>{},
'textBody': <Object?>[],
},
}),
);
final ({MessageDetail detail, DateTime? serverTime}) result =
await repository.getMessage(
'm1',
showImages: true,
highlight: const MessageHighlightQuery(q: '계약'),
);
expect(h.server.requests.last.queryParameters, <String, Object?>{
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);
});
});
}
@@ -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<String?> {
@override
String? build() => kMailTestAccount;
void set(String? value) => state = value;
}
final NotifierProvider<_Account, String?> _accountProvider =
NotifierProvider<_Account, String?>(_Account.new);
/// 검색 응답 한 쪽
FakeResponse _page(
List<Map<String, Object?>> items, {
String? next,
int? total,
}) => FakeResponse.ok(<String, Object?>{
'items': items,
'pageInfo': <String, Object?>{
'hasNext': next != null,
'nextCursor': next,
'total': total,
},
});
/// 받은편지함의 메일 [n] 통 — id 는 `<prefix>1` 부터(앞이 최신)
List<Map<String, Object?>> _mails(
int n, {
String prefix = 'r',
int from = 1,
String mailboxId = kInboxId,
}) => <Map<String, Object?>>[
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: <Override>[
...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<MailSearchArgs> 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<void> settle(MailSearchArgs args) => pumpEventsUntil(() {
final MailSearchState s = stateOf(args);
return s.phase != MailSearchPhase.searching &&
s.fill == MailSearchFill.none &&
!s.refreshing;
}, reason: '검색');
List<String> ids(MailSearchArgs args) => <String>[
for (final MailSearchItem item in stateOf(args).items) item.id,
];
List<RequestOptions> searchRequests() => <RequestOptions>[
for (final RequestOptions r in mail.listRequests)
if (r.queryParameters.containsKey('q')) r,
];
MessageSummary summaryOf(Map<String, Object?> 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, <String, Object?>{
'q': '계약',
'mailboxId': kInboxId,
'limit': kMailPageSize,
});
expect(request.extra[kSilentExtraKey], isTrue);
expect(stateOf(args).phase, MailSearchPhase.ready);
expect(ids(args), <String>['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), <String>['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), <String>['r1', 'r2']);
await settle(args);
expect(stateOf(args).phase, MailSearchPhase.ready);
expect(ids(args), <String>['r1', 'r2']);
expect(h.notifier.shownCodes, <String>['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<String, Object?> query) {
calls++;
if (calls <= 7) {
return _page(const <Map<String, Object?>>[], 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(
<Object?>[
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<void> more = controllerOf(args).searchMore();
expect(stateOf(args).fill, MailSearchFill.more);
await more;
expect(searchRequests(), hasLength(8));
expect(ids(args), <String>['r1', 'r2']);
expect(stateOf(args).partial, isFalse);
expect(stateOf(args).hasMore, isFalse);
},
);
test(
'한 쪽이 차면 멈추고, 끝에 닿으면 다음 차례 — 다음 쪽의 total 이 null 이면 첫 쪽 수를 남긴다',
() async {
mail.searchResponder = (Map<String, Object?> 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<String, Object?> query) {
calls++;
return _page(
calls == 1 ? _mails(3) : const <Map<String, Object?>>[],
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<String, Object?> query) =>
query['cursor'] == null
? _page(_mails(3), next: 'same', total: 10)
: _page(const <Map<String, Object?>>[], 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<String, Object?> 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, <String>['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 <Map<String, Object?>>[], 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] = <Map<String, Object?>>[
..._mails(2),
..._mails(1, prefix: 'q'),
];
final MailSearchArgs args = await open();
final Completer<void> slow = Completer<void>();
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), <String>['q1']);
slow.complete();
await pumpEventsUntil(() => true);
expect(stateOf(args).query, 'q1');
expect(ids(args), <String>['q1']);
expect(h.notifier.shownCodes, isEmpty);
});
test('화면이 닫히면(구독이 끊기면) 도는 요청을 끊는다 — 늦은 응답이 와도 아무 일 없다', () async {
final MailSearchArgs args = MailSearchArgs(instance: ++instance);
final ProviderSubscription<MailSearchState> subscription = container
.listen(
mailSearchProvider(args),
(MailSearchState? _, MailSearchState _) {},
);
container.listen(
mailboxesProvider,
(MailboxesState? _, MailboxesState _) {},
);
await pumpEventsUntil(
() => container.read(mailboxesProvider).status == MailboxesStatus.ready,
);
final Completer<void> slow = Completer<void>();
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(<String, Object?>{
'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(<String, Object?>{
'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<void> 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: <MessageSummary>[
summaryOf(<String, Object?>{
...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<String, Object?> moved = Map<String, Object?>.of(
mail.messages[kInboxId]![1],
)..['mailboxIds'] = <String>[kTrashId];
watcher().markChanged(updated: <MessageSummary>[summaryOf(moved)]);
expect(ids(args), <String>['r1', 'r3']);
expect(stateOf(args).total, 2);
final Map<String, Object?> back = Map<String, Object?>.of(moved)
..['mailboxIds'] = <String>[kInboxId];
watcher().markChanged(updated: <MessageSummary>[summaryOf(back)]);
expect(ids(args), <String>['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<String, Object?> toClients = Map<String, Object?>.of(
mail.messages[kInboxId]![0],
)..['mailboxIds'] = <String>[kClientsId];
final Map<String, Object?> toJunk = Map<String, Object?>.of(
mail.messages[kInboxId]![1],
)..['mailboxIds'] = <String>[kJunkId];
watcher().markChanged(
updated: <MessageSummary>[summaryOf(toClients), summaryOf(toJunk)],
);
expect(ids(args), <String>['r1']);
expect(stateOf(args).items.single.summary.mailboxIds, <String>[
kClientsId,
]);
},
);
test('영구 삭제 · 발송 취소(removedIds)는 빼고 기억하지 않는다 · 바뀐 것 없는 소식은 무시한다', () async {
await searchInbox();
watcher().markChanged(removedIds: <String>{'r1'});
expect(ids(args), <String>['r2', 'r3']);
expect(stateOf(args).total, 2);
watcher().markChanged();
expect(ids(args), <String>['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: <MessageSummary>[summaryOf(mail.move('r20', kTrashId))],
);
expect(ids(args), hasLength(19));
expect(stateOf(args).total, 20);
watcher().markChanged(
updated: <MessageSummary>[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), <String>[for (int i = 1; i <= 21; i++) 'r$i']);
expect(stateOf(args).hasMore, isFalse);
},
);
test('1통만 찾고 [더 찾기]로 멈춘 결과에서 그 1통을 뺐다 되돌리면 다시 보인다 — [더 찾기]는 그대로', () async {
int calls = 0;
mail.searchResponder = (Map<String, Object?> query) {
calls++;
return _page(
calls == 1 ? _mails(1) : const <Map<String, Object?>>[],
next: 'c$calls',
total: calls == 1 ? 3 : null,
);
};
args = await open();
controllerOf(args).submit('계약');
await settle(args);
expect(ids(args), <String>['r1']);
expect(stateOf(args).partial, isTrue);
final Map<String, Object?> moved = Map<String, Object?>.of(
_mails(1).single,
)..['mailboxIds'] = <String>[kTrashId];
watcher().markChanged(updated: <MessageSummary>[summaryOf(moved)]);
expect(ids(args), isEmpty);
expect(stateOf(args).waitsForMore, isTrue);
expect(stateOf(args).total, 2);
final Map<String, Object?> back = Map<String, Object?>.of(moved)
..['mailboxIds'] = <String>[kInboxId];
watcher().markChanged(updated: <MessageSummary>[summaryOf(back)]);
expect(ids(args), <String>['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: <String>{for (int i = 1; i <= kMailPageSize; i++) 'r$i'},
);
expect(ids(args), isEmpty);
expect(stateOf(args).waitsForMore, isTrue);
final Future<void> more = controllerOf(args).searchMore();
expect(stateOf(args).fill, MailSearchFill.more);
await more;
expect(ids(args), <String>['r21']);
expect(stateOf(args).hasMore, isFalse);
expect(stateOf(args).waitsForMore, isFalse);
});
test('이 검색에서 뺀 메일이라도 서버가 다음 쪽에 주면 넣는다 — 겹침은 지금 목록의 id 로만 막는다', () async {
mail.searchResponder = (Map<String, Object?> query) =>
query['cursor'] == null
? _page(_mails(20), next: 'c1', total: 21)
// 다른 곳에서 되돌린 r5 · 새 r21 — 이미 있는 r1 은 한 번만
: _page(<Map<String, Object?>>[
..._mails(1),
..._mails(1, from: 5),
..._mails(1, from: 21),
]);
args = await open();
controllerOf(args).submit('계약');
await settle(args);
final Map<String, Object?> moved = Map<String, Object?>.of(_mails(5).last)
..['mailboxIds'] = <String>[kTrashId];
watcher().markChanged(updated: <MessageSummary>[summaryOf(moved)]);
expect(ids(args), hasLength(19));
expect(stateOf(args).total, 20);
await controllerOf(args).loadMore();
expect(ids(args).sublist(19), <String>['r5', 'r21']);
expect(ids(args).where((String id) => id == 'r1'), hasLength(1));
expect(stateOf(args).total, 21);
});
});
}
File diff suppressed because it is too large Load Diff
@@ -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: <Mailbox>[
for (final Map<String, Object?> json in standardMailboxes())
Mailbox.tryParse(json)!,
],
);
MailSearchItem _item(
String id, {
required String mailboxId,
String subject = '유지보수 계약 갱신 건',
String preview = '본문 첫 부분',
List<Map<String, Object?>> to = const <Map<String, Object?>>[],
bool isRead = true,
bool hasAttachment = false,
Map<String, Object?>? highlight,
bool supported = true,
}) => MailSearchItem.tryParse(<String, Object?>{
...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: <Map<String, Object?>>[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: <Map<String, Object?>>[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: <Map<String, Object?>>[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: <String, Object?>{
'subject': <Object?>[
<Object?>[5, 7],
],
'preview': <String, Object?>{
'text': '…지난달 계약 기간에',
'ranges': <Object?>[
<Object?>[5, 7],
],
},
},
),
state: _ready(),
mailboxes: _mailboxes,
now: now,
);
expect(view.preview, '…지난달 계약 기간에');
expect(view.subjectHighlights, <SearchRange>[(start: 5, end: 7)]);
expect(view.previewHighlights, <SearchRange>[(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: <String, Object?>{
'subject': <Object?>[
<Object?>[0, 2],
],
'preview': <String, Object?>{'text': '계약', 'ranges': <Object?>[]},
},
),
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,
<String>[
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);
});
});
}
@@ -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<SearchRange> 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('로그아웃하면 읽기 화면이 사라지고 늦은 읽음 응답은 알리지 않는다', (
@@ -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<SearchRange>? ranges) => <String>[
for (final HighlightSegment segment in highlightSegments(text, ranges))
segment.mark ? '[${segment.text}]' : segment.text,
].join();
List<SearchRange> _ranges(List<(int, int)> pairs) => <SearchRange>[
for (final (int start, int end) in pairs) (start: start, end: end),
];
/// 검색 강조 — 조각 나누기(짝: 웹 `search-highlight.ts`) · 응답 읽기 · 글 본문의 `<mark>`
void main() {
group('highlightSegments (강조 조각)', () {
test('맞은 곳이 없으면 글 한 조각 — 빈 글이면 조각이 없다', () {
expect(highlightSegments('계약서 초안', null), <HighlightSegment>[
(text: '계약서 초안', mark: false),
]);
expect(
highlightSegments('계약서 초안', const <SearchRange>[]),
<HighlightSegment>[(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<HighlightSegment> segments = highlightSegments(
'a\u{1F600}b\u{1F600}c',
_ranges(<(int, int)>[(2, 5)]),
);
expect(
<String>[for (final HighlightSegment s in segments) s.text],
<String>['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(<Object?>[
<Object?>[0, 2],
<Object?>[4.0, 6],
]),
_ranges(<(int, int)>[(0, 2), (4, 6)]),
);
expect(parseSearchRanges(<Object?>[]), isEmpty);
});
test('정수가 아닌 위치는 그 칸만 버린다(웹 Number.isInteger) — 무한 · 아주 큰 수도', () {
expect(
parseSearchRanges(<Object?>[
<Object?>[0.5, 1],
<Object?>[1, 2],
<Object?>[double.infinity, 3],
<Object?>[1e300, 1e300],
]),
_ranges(<(int, int)>[(1, 2)]),
);
});
test('모양이 틀리면 null — 목록이 아님 · 칸이 두 수가 아님', () {
expect(parseSearchRanges(null), isNull);
expect(parseSearchRanges('0,2'), isNull);
expect(
parseSearchRanges(<Object?>[
<Object?>[0],
]),
isNull,
);
expect(
parseSearchRanges(<Object?>[
<Object?>[0, '2'],
]),
isNull,
);
expect(
parseSearchRanges(<Object?>[
<Object?>[0, 1, 2],
]),
isNull,
);
expect(parseSearchRanges(<Object?>[0, 2]), isNull);
});
});
group('검색 결과 항목 (MailSearchItem)', () {
Map<String, Object?> item({Object? highlight = _absent}) =>
<String, Object?>{
...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: <String, Object?>{
'subject': <Object?>[
<Object?>[5, 7],
],
'preview': <String, Object?>{
'text': '…지난달 계약 기간에 맞춰',
'ranges': <Object?>[
<Object?>[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 <Object?>[
'x',
<String, Object?>{'subject': <Object?>[], 'preview': 'x'},
<String, Object?>{
'subject': <Object?>['x'],
'preview': <String, Object?>{'text': 'a', 'ranges': <Object?>[]},
},
<String, Object?>{
'subject': <Object?>[],
'preview': <String, Object?>{'text': 1, 'ranges': <Object?>[]},
},
]) {
final MailSearchItem? parsed = MailSearchItem.tryParse(
item(highlight: broken),
);
expect(parsed, isNotNull);
expect(parsed!.highlight, isNull);
expect(parsed.highlightSupported, isTrue);
}
expect(
MailSearchItem.tryParse(<String, Object?>{...item(), 'isRead': 'x'}),
isNull,
);
});
});
group('읽기 화면의 강조 (MessageDetail.searchHighlight)', () {
Map<String, Object?> detailJson(Object? highlight) => <String, Object?>{
...messageJson('m1', mailboxId: kInboxId),
'textBody': '본문',
'attachments': <Object?>[attachmentJson('a1', name: '계약서.pdf')],
'searchHighlight': highlight,
};
test('제목 · 첨부(맞은 첨부만) · 글 본문의 맞은 곳을 읽는다', () {
final MessageDetail detail = MessageDetail.tryParse(
detailJson(<String, Object?>{
'subject': <Object?>[
<Object?>[0, 2],
],
'attachments': <String, Object?>{
'a1': <Object?>[
<Object?>[0, 2],
],
},
'textBody': <Object?>[],
}),
)!;
expect(
detail.searchHighlight,
MessageDetailHighlight(
subject: _ranges(<(int, int)>[(0, 2)]),
attachments: <String, List<SearchRange>>{
'a1': _ranges(<(int, int)>[(0, 2)]),
},
),
);
});
test('없거나 모양이 틀리면 강조 없이 메일을 연다', () {
expect(MessageDetail.tryParse(detailJson(null))!.searchHighlight, isNull);
for (final Object? broken in <Object?>[
<String, Object?>{'subject': <Object?>[], 'textBody': <Object?>[]},
<String, Object?>{
'subject': <Object?>[],
'attachments': <String, Object?>{'a1': 'x'},
'textBody': <Object?>[],
},
]) {
final MessageDetail? detail = MessageDetail.tryParse(
detailJson(broken),
);
expect(detail, isNotNull);
expect(detail!.searchHighlight, isNull);
}
});
test('[이미지 보기]의 본문 바꾸기는 강조도 그 응답의 것으로, 요약 바꾸기는 강조를 그대로 둔다', () {
final MessageDetail first = MessageDetail.tryParse(
detailJson(<String, Object?>{
'subject': <Object?>[
<Object?>[0, 2],
],
'attachments': <String, Object?>{},
'textBody': <Object?>[],
}),
)!;
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(),
<String, String>{'q': '계약'},
);
expect(
const MessageHighlightQuery(q: '계약', subject: '견적').toQueryParameters(),
<String, String>{'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> · 본문 문서의 mark 모양', () {
test('맞은 곳은 속성 없는 <mark> — 글은 조각마다 이스케이프한다', () {
expect(
highlightedTextHtml(
'<계약> & 계약서',
_ranges(<(int, int)>[(1, 3), (7, 9)]),
),
'&lt;<mark>계약</mark>&gt; &amp; <mark>계약</mark>서',
);
expect(highlightedTextHtml('<b>', const <SearchRange>[]), '&lt;b&gt;');
});
test('글 본문이면 강조를 넣고, 서식 본문이면 넣지 않는다(서버가 이미 넣었다 — 앱의 정화가 mark 를 남긴다)', () {
final SanitizedMailBody text = mailBodyFragment(
htmlBody: null,
textBody: '계약서를 보냈습니다',
imagesShown: false,
textHighlights: _ranges(<(int, int)>[(0, 2)]),
);
expect(text.fragment, contains('<mark>계약</mark>서를'));
final SanitizedMailBody html = mailBodyFragment(
htmlBody:
'<p><mark>계약</mark>서를 <mark style="color:red">보냈</mark>습니다</p>',
textBody: '계약서를 보냈습니다',
imagesShown: false,
textHighlights: _ranges(<(int, int)>[(0, 2)]),
);
expect(
html.fragment,
'<p><mark>계약</mark>서를 <mark style="color:red">보냈</mark>습니다</p>',
);
});
test('서식 본문이 한도를 넘어 글 본문으로 보일 때도 글 본문의 위치로 강조한다', () {
final SanitizedMailBody omitted = mailBodyFragment(
htmlBody: '<div>${'<div>' * 4000}x${'</div>' * 4000}</div>',
textBody: '계약 안내',
imagesShown: false,
textHighlights: _ranges(<(int, int)>[(0, 2)]),
);
expect(omitted.htmlOmitted, isTrue);
expect(omitted.fragment, contains('<mark>계약</mark> 안내'));
});
test('문서의 mark 는 웹과 같은 노랑 · 글자색 그대로 · 모서리 2 · 좌우 1px', () {
expect(
buildMailDocument(''),
contains(
'mark{background:#FCE7A4;color:inherit;border-radius:2px;padding:0 1px}',
),
);
});
});
}
const Object _absent = Object();
@@ -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 <Object?>[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'), <Object?>[
<Object?>[
'가\u3000나',
<Object?>['\n', '\t', '\r', r'\', "'", '"', '\u{1F600}'],
],
<Object?>['끝', <Object?>[]],
]);
});
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<String> inputs = <String>[
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<int> changedLength = <int>[];
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<int> differ = <int>[];
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<String> texts(String query) => <String>[
for (final SearchTerm term in parseSearchTerms(query)) term.text,
];
test('띄어쓰기로 낱말을 나눈다 — 여러 칸 · 전각 공백 · 줄바꿈도 한 번의 띄어쓰기', () {
expect(parseSearchTerms(' 서버 이전\u3000일정\n공유 '), <SearchTerm>[
(kind: SearchTermKind.word, text: '서버'),
(kind: SearchTermKind.word, text: '이전'),
(kind: SearchTermKind.word, text: '일정'),
(kind: SearchTermKind.word, text: '공유'),
]);
});
test('큰따옴표 안은 구절 하나다 — 짝이 없는 마지막 따옴표는 없는 것으로 본다', () {
expect(parseSearchTerms('회의 "일정 공유" 서버'), <SearchTerm>[
(kind: SearchTermKind.word, text: '회의'),
(kind: SearchTermKind.phrase, text: '일정 공유'),
(kind: SearchTermKind.word, text: '서버'),
]);
expect(texts('회의 "일정 공유'), <String>['회의', '일정', '공유']);
expect(parseSearchTerms('"a b" "c'), <SearchTerm>[
(kind: SearchTermKind.phrase, text: 'a b'),
(kind: SearchTermKind.word, text: 'c'),
]);
});
test('문자 · 숫자가 없는 낱말 · 구절은 버리고, 낱말 앞뒤의 문장부호는 뗀다 — 같은 낱말은 한 번', () {
for (final String query in <String>[
'-',
'…',
'- … !!',
'"- …"',
'\u{1F600}',
'" "',
'""',
]) {
expect(parseSearchTerms(query), isEmpty, reason: query);
}
expect(texts('회의, (회의) 회의. “회의” [공지] 2026. 회의!'), <String>[
'회의',
'공지',
'2026',
]);
expect(parseSearchTerms('"회의, 일정."'), <SearchTerm>[
(kind: SearchTermKind.phrase, text: '회의, 일정.'),
]);
expect(parseSearchTerms('회의 회의 "일정" "일정" 일정'), <SearchTerm>[
(kind: SearchTermKind.word, text: '회의'),
(kind: SearchTermKind.phrase, text: '일정'),
(kind: SearchTermKind.word, text: '일정'),
]);
});
test('글자에 붙은 결합 문자는 남기고, 그림 문자와 그 이체 선택자는 함께 뗀다', () {
expect(texts('cafe\u0301!'), <String>['cafe\u0301']);
expect(texts('회의\u{1F600}\uFE0F'), <String>['회의']);
});
test('풀어 쓴 한글은 음절로 모으고 NUL 은 U+FFFD 로 바꾼다(서버의 색인과 같다)', () {
expect(
parseSearchTerms('\u1112\u1161\u11AB\u1100\u1173\u11AF'),
<SearchTerm>[(kind: SearchTermKind.word, text: '한글')],
);
expect(parseSearchTerms('계\u0000약'), <SearchTerm>[
(kind: SearchTermKind.word, text: '계\uFFFD약'),
]);
});
});
group('isSingleCharSearch (한 글자 낱말뿐인 검색 — 서버가 본문을 찾지 않는다)', () {
test('낱말이 모두 한 글자면 그렇다 — 여러 칸을 합쳐 본다', () {
expect(isSingleCharSearch(<String>['김']), isTrue);
expect(isSingleCharSearch(<String>['김 a 3']), isTrue);
expect(isSingleCharSearch(<String>['김', '', '', '', '박']), isTrue);
// 한 글자 낱말로만 된 구절도 묶음이 없다
expect(isSingleCharSearch(<String>['"가 나"']), isTrue);
});
test('두 글자 이상 낱말이 하나라도 있으면 아니다 — 다른 칸에 있어도', () {
expect(isSingleCharSearch(<String>['계약']), isFalse);
expect(isSingleCharSearch(<String>['김 계약']), isFalse);
expect(isSingleCharSearch(<String>['김', '', '', '견적', '']), isFalse);
expect(isSingleCharSearch(<String>['"가나"']), isFalse);
// 풀어 쓴 한 글자도 모은 뒤 센다
expect(isSingleCharSearch(<String>['\u1100\u1161']), isTrue);
});
test('찾을 낱말이 없으면(빈 글 · 문장부호뿐) 아니다 — 앞뒤 문장부호는 뗀 뒤 센다', () {
expect(isSingleCharSearch(<String>[]), isFalse);
expect(isSingleCharSearch(<String>['', ' ']), isFalse);
expect(isSingleCharSearch(<String>['- …']), isFalse);
expect(isSingleCharSearch(<String>['(김)']), 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');
});
});
}
+149 -2
View File
@@ -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<String, Object?> query})> detailQueries =
<({String id, Map<String, Object?> query})>[];
/// 검색 강조 쿼리(`q`)로 물었을 때 덮는 칸 — 메일 id 마다(`searchHighlight` · NFC 로 실은 제목 등)
final Map<String, Map<String, Object?>> highlightDetails =
<String, Map<String, Object?>>{};
// ── 검색 (GET /messages?q=… · GET /search-index) ──
/// 검색 응답 — 있으면 이것으로 답한다(쿼리를 받는다). 없으면 메일 제목 · 미리 보기에 q 가 든 메일을 한 쪽씩([defaultSearch])
FakeResponse Function(Map<String, Object?> query)? searchResponder;
/// 기본 검색이 항목에 `searchHighlight` 칸을 싣는다(14단계 서버) — 값은 [searchHighlights] 에서(없으면 null)
bool searchHighlightField = false;
/// 기본 검색 항목의 `searchHighlight` — 메일 id 마다
final Map<String, Map<String, Object?>> searchHighlights =
<String, Map<String, Object?>>{};
/// 있으면 다음 검색 응답이 이것이 끝날 때까지 기다린다(한 번 쓰면 비운다)
Completer<void>? searchGate;
/// `GET /search-index` 의 응답 — 기본은 준비됨
FakeResponse searchIndex = FakeResponse.ok(<String, Object?>{
'status': 'ready',
'done': 10,
'total': 10,
});
/// 받은 검색 요청의 쿼리 (차례대로)
List<Map<String, Object?>> get searchQueries => <Map<String, Object?>>[
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<String, Object?> body})> singlePatches =
<({String id, Map<String, Object?> body})>[];
@@ -216,6 +256,18 @@ class FakeMailBackend {
});
});
server.on('GET', MailApiPaths.messages, (RequestOptions options) async {
if (options.queryParameters.containsKey('q')) {
final Completer<void>? gate = searchGate;
if (gate != null) {
searchGate = null;
await gate.future;
}
final FakeResponse Function(Map<String, Object?> query)? responder =
searchResponder;
return responder == null
? defaultSearch(options.queryParameters)
: responder(options.queryParameters);
}
final Completer<void>? gate = listGate;
if (gate != null) {
if (listGateOnce) listGate = null;
@@ -301,9 +353,87 @@ class FakeMailBackend {
if (failure != null) return failure;
return FakeResponse.ok(<String, Object?>{'state': state});
});
server.on('GET', MailApiPaths.searchIndex, (_) => searchIndex);
_installReader();
}
/// 기본 검색 — 찾을 곳(메일함 · 없으면 휴지통 · 스팸메일함 밖 모두)의 메일 가운데 제목 · 미리 보기에 q 가 든 것을
/// 받은 시각 내림차순(같으면 id 내림차순 — 서버 색인과 같다)으로 한 쪽씩. 커서는 그 쪽 끝 줄의 자리([searchCursorAfter]) —
/// 서버처럼 그 줄 **다음**부터 준다(그사이 앞쪽 메일이 빠지거나 되돌아와도 다음 쪽은 밀리지도 겹치지도 않는다). 전체 수는 첫 쪽에만 싣는다.
/// [searchResponder] 가 쪽마다 이것을 불러 섞어 쓸 수 있다
FakeResponse defaultSearch(Map<String, Object?> query) {
final String q = query['q']! as String;
final Object? mailboxId = query['mailboxId'];
final List<Map<String, Object?>> pool = <Map<String, Object?>>[
for (final MapEntry<String, List<Map<String, Object?>>> entry
in messages.entries)
if (mailboxId == null
? entry.key != kTrashId && entry.key != kJunkId
: entry.key == mailboxId)
...entry.value,
];
if (mailboxId != null &&
!mailboxes.any((Map<String, Object?> m) => m['id'] == mailboxId)) {
return FakeResponse.fail(404, 'RES_001');
}
final List<Map<String, Object?>> matched =
pool
.where(
(Map<String, Object?> m) =>
(m['subject']! as String).contains(q) ||
(m['preview']! as String).contains(q),
)
.toList()
..sort((Map<String, Object?> a, Map<String, Object?> 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<Map<String, Object?>> rest = cursor is String
? matched.where(_afterSearchCursor(cursor)).toList()
: matched;
final List<Map<String, Object?>> page = rest.take(limit).toList();
final bool hasNext = rest.length > page.length;
return FakeResponse.ok(<String, Object?>{
'items': <Map<String, Object?>>[
for (final Map<String, Object?> message in page)
<String, Object?>{
...message,
if (searchHighlightField)
'searchHighlight': searchHighlights[message['id']],
},
],
'pageInfo': <String, Object?>{
'hasNext': hasNext,
'nextCursor': hasNext ? searchCursorAfter(page.last) : null,
'total': cursor == null ? matched.length : null,
},
});
}
/// 기본 검색의 커서 — [message] 줄 다음부터(`s:<받은 시각>|<id>` — 서버 색인 커서처럼 받은 시각 · id 가 더 작은 쪽)
static String searchCursorAfter(Map<String, Object?> message) =>
's:${message['receivedAt']}|${message['id']}';
/// 커서 자리 다음의 메일인가
static bool Function(Map<String, Object?> message) _afterSearchCursor(
String cursor,
) {
final List<String> parts = cursor.substring(2).split('|');
final String at = parts[0];
final String id = parts[1];
return (Map<String, Object?> 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<String, Object?>.of(options.queryParameters),
));
final Completer<void>? gate = detailGate;
if (gate != null) {
detailGate = null;
@@ -329,6 +463,7 @@ class FakeMailBackend {
final Map<String, Object?>? 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<String, Object?>? detailJsonOf(String id, {bool showImages = false}) {
/// 메일 한 통의 읽기 칸 — 목록 칸 위에 [details] (와 외부 이미지를 보일 때 [shownImageDetails] · 강조 쿼리로 물었을 때 [highlightDetails])를 덮는다.
/// 없는 메일은 null
Map<String, Object?>? detailJsonOf(
String id, {
bool showImages = false,
bool highlighted = false,
}) {
final ({String mailboxId, Map<String, Object?> message})? found = _find(id);
if (found == null) return null;
return <String, Object?>{
@@ -402,6 +542,7 @@ class FakeMailBackend {
'largeFiles': <Object?>[],
...?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<String, Object?> move(String id, String mailboxId) {
_apply(id, <String, Object?>{'mailboxId': mailboxId});
return _find(id)!.message;
}
/// 메일의 지금 칸 (없으면 null)
Map<String, Object?>? messageOf(String id) => _find(id)?.message;
+146
View File
@@ -0,0 +1,146 @@
import 'dart:io';
/// 모노레포의 TypeScript 표본 표를 읽는다 — 시험 전용(앱 코드에서 쓰지 않는다).
///
/// `export const NAME … = [` 부터 짝이 맞는 `]` 까지를 풀어 글(String) · 목록(List)의 나무로 돌려준다.
/// 읽는 것: 작은따옴표 · 큰따옴표 글, `\u` · `\u{…}` · `\n` · `\t` · `\r` · `\0` · `\\` · `\'` · `\"` 이스케이프, 대괄호 · 쉼표(끝 쉼표 포함) · `//` 주석 · `/* */` 주석.
/// 줄바꿈 모양(prettier)이 바뀌어도 읽는다. 그 밖의 꼴(템플릿 글 · 수 · 변수)이 나오면 [FormatException] — 표의 모양이 바뀐 것을 시험이 알아챈다.
List<Object?> 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<Object?>) throw FormatException('표 $name 이 배열이 아니다');
return value;
}
/// [path](flutter/ 에서 본 상대 경로)의 TS 파일에서 [name] 표를 읽는다 — `flutter test` 는 flutter/ 에서 돈다
List<Object?> 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<Object?> _readArray() {
_index++;
final List<Object?> items = <Object?>[];
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);
}
}
}