generated from ttipo/project-base
feat(flutter): 메일 검색 화면 1차 — 찾기 · 결과 강조 · 이어 찾기 · 안내를 더한다
목록 위 막대의 [찾기]가 탭 안 검색 화면(/mail/search)을 연다. 웹 14단계 검색과 같은 한도(UTF-16 200자 · 낱말 8개)를 먼저 보고, 찾을 곳은 시작한 메일함 | 전체 메일함이다. 결과 줄은 목록 줄 그대로 맞은 말을 노랑으로 칠하고(회색 미리 보기 안은 글자를 진하게), 결과에서 연 읽기 화면도 제목 · 첨부 이름 · 본문을 칠한다. 이어 찾기는 한 차례 5번 · 20통이고, 모자라면 [더 찾기]로 멈춘다. 결과는 새 메일 확인으로 다시 찾지 않고, 읽기 화면에서 한 일과 붙잡힌 메일의 풀림만 반영한다. 낱말 나누기 · 한 글자 판정은 웹 · 서버와 짝이고, 시험이 백엔드 표본 표를 그대로 읽는다. 시험 750 → 870. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)!,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)]),
|
||||
),
|
||||
'<<mark>계약</mark>> & <mark>계약</mark>서',
|
||||
);
|
||||
expect(highlightedTextHtml('<b>', const <SearchRange>[]), '<b>');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user