feat(flutter): 메일 검색 2차 — 조건으로 찾기 시트와 결과의 조건 줄

2026-11-07 확정한 보드 App1SearchConditions 대로 웹 14단계의 [조건] 패널을
바닥 시트로 옮겼다. 보낸 사람 · 받는 사람 · 제목 · 내용 · 기간 · 첨부로 찾고,
결과에는 "조건: …" 줄과 [조건 지우기]를 둔다. 조건 다듬기 · 합친 낱말 수 ·
기간 · 날짜의 UTC 시각 · 강조 쿼리 · "나"는 웹 mail-search.ts 와 짝이다.

[조건]은 위 막대 맨 오른쪽에 고정해 글을 써도 옆으로 밀리지 않게 했고,
시트에서 낱말 8개를 넘기면 시트를 연 채 알린다. 날짜는 오늘까지만 고른다.
바닥 시트 가림막을 앱 대화상자 색으로 맞춰 메일함 옮기기 시트도 같은 색이 된다.

리뷰에서 가로 화면에 자판이 오르면 시트 모양이 바뀌며 칸이 새로 만들어져
초점을 잃는 것을 찾았다. 시트를 한 트리로 두고 같은 레이아웃 안에서 머리 ·
단추 줄의 실제 높이로 모양 셋(위를 남긴 고정 · 화면 위까지 고정 · 한 스크롤)을
고르게 고쳤고, 모양이 바뀌거나 알림이 뜨면 초점 칸 · 알림을 보이게 한다.
시험 872 → 932.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 19:00:15 +09:00
parent bec78bce42
commit facf9cd936
20 changed files with 4170 additions and 82 deletions
+14
View File
@@ -195,4 +195,18 @@ abstract final class AppSize {
/// 결과 머리(개수 줄)의 높이
static const double searchCountLineHeight = 36;
// ── 앱 보드에서 정한 크기 (`App1SearchConditions` — 2026-11-07 확정) ──
/// 위 막대 [조건]의 보이는 원(시트가 열려 있거나 조건이 걸려 있을 때 옅은 청록) — 누르는 곳은 [touchMin]
static const double searchConditionsToggle = 40;
/// 아래에서 올라오는 시트 위에 남기는 자리 — 시트를 끝까지 키워도 검색 칸이 보인다(보드의 시트 위 96)
static const double sheetTopGap = 96;
/// 시트 손잡이(보이기만 한다 — 끌기는 시트 전체가 받는다)
static const double sheetHandleWidth = 32;
static const double sheetHandleHeight = 4;
/// 시트의 [찾기]처럼 채운 주 단추의 최소 폭
static const double primaryActionMinWidth = 120;
}
+11
View File
@@ -98,6 +98,17 @@ abstract final class AppTheme {
),
),
navigationBarTheme: _navigationBarTheme(scheme, textTheme),
// 아래에서 올라오는 시트(옮기기 · 조건으로 찾기)의 가림막 — 대화상자 가림막 토큰(`--color-backdrop`). Flutter 기본은 검정 54%
bottomSheetTheme: const BottomSheetThemeData(
modalBarrierColor: AppColors.backdrop,
),
// 날짜 고르는 창 — 머리의 고른 날짜("11월 7일 (토)")는 22 · 600(보드 `App1SearchConditions`). Flutter 기본(headlineLarge 32)은 크다.
// 창의 가림막은 테마에 자리가 없어(대화상자 테마는 확인 창까지 바꾼다) 부르는 쪽이 넘긴다
datePickerTheme: DatePickerThemeData(
headerHeadlineStyle: textTheme.headlineSmall?.copyWith(
fontWeight: AppTypography.semiBold,
),
),
extensions: const <AppExtraColors>[AppExtraColors.light],
);
}
@@ -12,6 +12,7 @@ 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/search_conditions.dart';
import 'package:dio/dio.dart';
/// 메일 API 경로 — baseUrl(`…/api`) 뒤에 붙는다(계약 2절). 경로는 대소문자를 가린다
@@ -103,11 +104,13 @@ class MailRepository {
}
/// 메일 검색 — 받은 시각 내림차순 (계약 2절 "검색" · "검색 — 14단계"). [mailboxId] 가 없으면 휴지통 · 스팸메일함을 뺀 모든 메일함에서 찾는다.
/// [cursor] 는 해석하지 않고 그대로 보낸다(방식 · 범위 · 검색어가 그 안에 묶여 있다). 색인 방식은 `hasNext` 인데 항목이 [limit] 보다 적거나 0개일 수 있다.
/// [q] 가 비면 싣지 않는다 — 조건([conditions] — `from` · `to` · `subject` · `body` · `after` · `before` · `hasAttachment`, 빈 칸은 빼고)만으로 찾는다.
/// [cursor] 는 해석하지 않고 그대로 보낸다(방식 · 범위 · 검색어 · 조건이 그 안에 묶여 있다). 색인 방식은 `hasNext` 인데 항목이 [limit] 보다 적거나 0개일 수 있다.
/// [silent] 면 실패를 알리지 않는다(처음 찾는 쪽은 화면이 받지 못함을 직접 그린다). [cancelToken] 으로 끊은 요청은 알리지 않는다.
/// 응답의 서버 시각(`Date`)도 싣는다 — 결과의 붙잡힌 메일이 풀릴 때를 서버 시계로 잰다
Future<MailPage<MailSearchItem>> searchMessages({
required String q,
SearchConditions conditions = SearchConditions.none,
String? mailboxId,
String? cursor,
int limit = kMailPageSize,
@@ -117,7 +120,8 @@ class MailRepository {
final Response<Object?> response = await _dio.get<Object?>(
MailApiPaths.messages,
queryParameters: <String, Object?>{
'q': q,
if (q.isNotEmpty) 'q': q,
...searchConditionsParams(conditions),
'mailboxId': ?mailboxId,
'limit': limit,
'cursor': ?cursor,
@@ -0,0 +1,314 @@
/// 메일 검색의 조건(2차 — 보낸 사람 · 받는 사람 · 제목 · 내용 · 기간 · 첨부) — 순수 함수 (계약 2절 "검색 — 14단계").
///
/// **짝: 웹 `frontend/src/shared/utils/mail-search.ts`(조건 다듬기 · API 쿼리 · 읽기 화면의 강조 쿼리 · 합친 낱말 수 · 한 글자 판정 · 기간 고르기)
/// · `mail-vacation.ts` 의 `startOfDayIso` · `endOfDayIso`.** 같은 규칙을 그대로 옮겼다 — 한쪽을 고치면 둘 다.
/// 조건 줄의 글(“조건: 이도윤 님이 보낸 메일, …”)은 화면 글이라 `presentation/mail_search_view.dart` 에 있다.
///
/// 웹과 다른 점: 조건은 화면 상태에만 둔다(주소에 싣지 않는다 — 앱의 검색어와 같다). 날짜는 글(YYYY-MM-DD)이 아니라 [SearchDay] 다.
library;
import 'package:arasystem_app/core/utils/js_case.dart';
import 'package:arasystem_app/features/mail/data/models/mail_search.dart';
import 'package:arasystem_app/features/mail/domain/search_text.dart';
import 'package:flutter/foundation.dart';
/// 기기 시간대의 날짜 하나(시각 없음) — 조건의 시작 날짜 · 끝 날짜. 두 날짜 모두 든다(계약 — `since` · `until` 은 양 끝을 포함한다)
@immutable
class SearchDay implements Comparable<SearchDay> {
const SearchDay(this.year, this.month, this.day);
/// [date] 의 날짜 — 기기 시간대로 본다(UTC 시각이면 기기 시간대로 바꾼 날짜)
factory SearchDay.of(DateTime date) {
final DateTime local = date.toLocal();
return SearchDay(local.year, local.month, local.day);
}
final int year;
final int month;
final int day;
/// 있는 날짜인가 — 2월 30일처럼 넘어간 날짜는 아니다
bool get isValid {
final DateTime check = DateTime.utc(year, month, day);
return check.year == year && check.month == month && check.day == day;
}
/// 날짜 고르는 창에 넘기는 값 — 기기 시간대의 그날(시각 0시)
DateTime toLocalDate() => DateTime(year, month, day);
bool isAfter(SearchDay other) => compareTo(other) > 0;
@override
int compareTo(SearchDay other) {
if (year != other.year) return year.compareTo(other.year);
if (month != other.month) return month.compareTo(other.month);
return day.compareTo(other.day);
}
@override
bool operator ==(Object other) =>
other is SearchDay &&
other.year == year &&
other.month == month &&
other.day == day;
@override
int get hashCode => Object.hash(year, month, day);
/// YYYY-MM-DD (웹 `<input type="date">` 의 값과 같은 모양 — 시험 · 진단용)
@override
String toString() =>
'${year.toString().padLeft(4, '0')}-${month.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}';
}
/// 고를 수 있는 날짜의 처음 — 날짜 고르는 창의 `firstDate` (웹 `SEARCH_DAY_MIN`)
const SearchDay kSearchDayMin = SearchDay(1970, 1, 1);
/// 고를 수 있는 날짜의 끝 — 9999-12-31 은 UTC 의 다음 날 0시가 10000년이라 서버가 받지 않는다(웹 `SEARCH_DAY_MAX`).
/// 앱의 날짜 고르는 창은 오늘까지만 보인다
const SearchDay kSearchDayMax = SearchDay(9999, 12, 30);
/// 찾을 수 있는 날짜인가 — 있는 날짜이고 [kSearchDayMin] ~ [kSearchDayMax] 안 (웹 `isSearchDay`)
bool isSearchDay(SearchDay day) =>
day.isValid &&
day.compareTo(kSearchDayMin) >= 0 &&
day.compareTo(kSearchDayMax) <= 0;
/// 검색 칸 밖의 조건 — 글 칸은 비면 없는 것, 날짜는 기기 시간대의 날짜(두 날짜 모두 든다)
@immutable
class SearchConditions {
const SearchConditions({
this.from = '',
this.to = '',
this.subject = '',
this.body = '',
this.since,
this.until,
this.hasAttachment = false,
});
/// 조건 없음
static const SearchConditions none = SearchConditions();
/// 보낸 사람(이름 · 주소)
final String from;
/// 받는 사람과 참조
final String to;
final String subject;
/// 본문
final String body;
/// 시작 날짜 — 그날 0시부터. 없으면 null
final SearchDay? since;
/// 끝 날짜 — 그날이 끝날 때까지(다음 날 0시 앞). 없으면 null
final SearchDay? until;
/// 첨부 파일이 있는 메일만
final bool hasAttachment;
/// 조건이 하나도 없다 (웹 `hasSearchConditions` 의 반대)
bool get isEmpty =>
from.isEmpty &&
to.isEmpty &&
subject.isEmpty &&
body.isEmpty &&
since == null &&
until == null &&
!hasAttachment;
bool get isNotEmpty => !isEmpty;
@override
bool operator ==(Object other) =>
other is SearchConditions &&
other.from == from &&
other.to == to &&
other.subject == subject &&
other.body == body &&
other.since == since &&
other.until == until &&
other.hasAttachment == hasAttachment;
@override
int get hashCode =>
Object.hash(from, to, subject, body, since, until, hasAttachment);
}
/// 조건 다듬기 — 글 칸은 검색어처럼(앞뒤 공백 · UTF-16 200자 — [normalizeSearchText]), 날짜는 찾을 수 있는 날짜만([isSearchDay]).
/// 시작이 끝보다 늦은 기간은 통째로 버린다 — 아무것도 맞지 않는 검색이 되지 않게(시트는 넘기기 전에 칸 아래에 알린다).
/// 낱말 수는 자르지 않는다 — 넘으면 찾지 않고 까닭을 보인다([exceedsCombinedSearchWords]) (웹 `normalizeSearchConditions`)
SearchConditions normalizeSearchConditions(SearchConditions? input) {
if (input == null) return SearchConditions.none;
final SearchDay? since = _dayOrNull(input.since);
final SearchDay? until = _dayOrNull(input.until);
final bool reversed = isReversedPeriod(since, until);
return SearchConditions(
from: normalizeSearchText(input.from),
to: normalizeSearchText(input.to),
subject: normalizeSearchText(input.subject),
body: normalizeSearchText(input.body),
since: reversed ? null : since,
until: reversed ? null : until,
hasAttachment: input.hasAttachment,
);
}
SearchDay? _dayOrNull(SearchDay? day) =>
day != null && isSearchDay(day) ? day : null;
/// 날짜(연 · 월 · 일 — 넘친 일은 다음 달로 넘긴다) → 기기 시간대의 그날 0시. 시험은 시간대를 고정한 것을 넣는다
typedef SearchMidnight = DateTime Function(int year, int month, int day);
DateTime _deviceMidnight(int year, int month, int day) =>
DateTime(year, month, day);
/// API 쿼리의 조건(`GET /api/messages` — 빈 칸은 싣지 않는다).
/// 날짜는 기기 시간대의 시작 날짜 0시(`after`) · 끝 날짜 다음 날 0시(`before`)를 UTC ISO 로 — 웹 `searchConditionsParams` 와 같다
/// (부재중과 같은 방식). 첨부는 `hasAttachment=true` 만(계약 — 다른 값은 400 `VAL_001`)
Map<String, String> searchConditionsParams(
SearchConditions conditions, {
SearchMidnight midnight = _deviceMidnight,
}) {
final SearchDay? since = conditions.since;
final SearchDay? until = conditions.until;
return <String, String>{
if (conditions.from.isNotEmpty) 'from': conditions.from,
if (conditions.to.isNotEmpty) 'to': conditions.to,
if (conditions.subject.isNotEmpty) 'subject': conditions.subject,
if (conditions.body.isNotEmpty) 'body': conditions.body,
if (since != null)
'after': _utcIso(midnight(since.year, since.month, since.day)),
if (until != null)
'before': _utcIso(midnight(until.year, until.month, until.day + 1)),
if (conditions.hasAttachment) 'hasAttachment': kSearchHasAttachmentValue,
};
}
/// API 의 `hasAttachment` 값 — 이것만 받는다(웹 `SEARCH_HAS_ATTACHMENT_VALUE`)
const String kSearchHasAttachmentValue = 'true';
/// "2026-10-05T15:00:00.000Z" — JS `toISOString()` 과 같은 모양
String _utcIso(DateTime instant) => instant.toUtc().toIso8601String();
/// 읽기 화면에 넘기는 강조 쿼리 — 검색어와 글 칸 넷만(기간 · 첨부는 서버가 받지 않는다). 빈 칸은 빼고, 하나도 없으면 null (웹 `messageHighlightQueryOf`)
MessageHighlightQuery? messageHighlightQueryOf(
String q,
SearchConditions conditions,
) {
final MessageHighlightQuery query = MessageHighlightQuery(
q: q,
from: conditions.from,
to: conditions.to,
subject: conditions.subject,
body: conditions.body,
);
return query.isEmpty ? null : query;
}
/// 검색어와 조건 칸 넷을 합친 낱말 수 — 서버 한도(8개)와 같은 기준(띄어쓰기로 나눈 조각을 칸마다 세어 더한다)
int combinedSearchWords(String q, SearchConditions conditions) =>
countSearchWords(q) +
countSearchWords(conditions.from) +
countSearchWords(conditions.to) +
countSearchWords(conditions.subject) +
countSearchWords(conditions.body);
/// 합친 낱말이 한도([kMailSearchMaxWords])를 넘는가 — 넘으면 묻지 않는다
bool exceedsCombinedSearchWords(String q, SearchConditions conditions) =>
combinedSearchWords(q, conditions) > kMailSearchMaxWords;
/// 한 글자 낱말뿐이라 본문을 찾지 않은 검색인가 (색인 방식 — 서버와 같은 판정, [isSingleCharSearch]).
/// 본문을 찾을 말(검색어나 내용 조건)이 있을 때만 그렇다 — 보낸 사람 · 받는 사람 · 제목 조건만이면 본문은 원래 찾지 않는다 (웹 `skipsBodySearch`)
bool skipsBodySearch(String q, SearchConditions conditions) {
if (parseSearchTerms(q).isEmpty &&
parseSearchTerms(conditions.body).isEmpty) {
return false;
}
return isSingleCharSearch(<String>[
q,
conditions.from,
conditions.to,
conditions.subject,
conditions.body,
]);
}
// ── 기간 고르기 (웹 SEARCH_PERIOD_* · periodRange · periodOf) ─────────────
/// 기간 고르기 — 고르기 칸의 차례 그대로. 최근 N주 · 달 · 해는 그만큼 앞의 같은 날부터 오늘까지(두 날짜 모두 든다)
enum SearchPeriod {
all,
week(days: 7),
month(months: 1),
quarter(months: 3),
year(months: 12),
custom;
const SearchPeriod({this.days = 0, this.months = 0});
/// 오늘에서 뺄 날 · 달 수 (미리 정한 기간만)
final int days;
final int months;
/// 미리 정한 기간인가 — 전체 기간 · 날짜 고르기는 아니다
bool get isPreset => this != all && this != custom;
}
/// 오늘에서 그만큼 앞의 같은 날 — 달은 그 달의 마지막 날을 넘지 않는다(3월 31일의 1달 앞은 2월 28 · 29일).
/// 날짜 셈은 UTC 로 한다(일광 절약 시간의 0시가 없는 날에도 날짜가 바뀌지 않게)
SearchDay _shiftBack(SearchDay today, SearchPeriod preset) {
final int month = today.month - preset.months;
final int lastDay = DateTime.utc(today.year, month + 1, 0).day;
final int day = (today.day < lastDay ? today.day : lastDay) - preset.days;
final DateTime shifted = DateTime.utc(today.year, month, day);
return SearchDay(shifted.year, shifted.month, shifted.day);
}
/// 미리 정한 기간의 두 날짜 — 그만큼 앞의 같은 날부터 오늘까지. 전체 기간 · 날짜 고르기는 날짜가 없다 (웹 `periodRange`)
({SearchDay? since, SearchDay? until}) periodRange(
SearchPeriod period,
DateTime today,
) {
if (!period.isPreset) return (since: null, until: null);
final SearchDay day = SearchDay.of(today);
return (since: _shiftBack(day, period), until: day);
}
/// 두 날짜가 어떤 기간 고르기인가 — 둘 다 없으면 전체 기간, 오늘 기준의 미리 정한 기간과 같으면 그것, 아니면 날짜 고르기 (웹 `periodOf`)
SearchPeriod periodOf(SearchDay? since, SearchDay? until, DateTime today) {
if (since == null && until == null) return SearchPeriod.all;
for (final SearchPeriod period in SearchPeriod.values) {
if (!period.isPreset) continue;
final ({SearchDay? since, SearchDay? until}) range = periodRange(
period,
today,
);
if (range.since == since && range.until == until) return period;
}
return SearchPeriod.custom;
}
/// 시작 날짜가 끝 날짜보다 늦은가 — 그러면 아무것도 맞지 않는다(찾기 전에 막는다). 한쪽만 있으면 아니다
bool isReversedPeriod(SearchDay? since, SearchDay? until) =>
since != null && until != null && since.isAfter(until);
// ── 조건 줄의 "나" ─────────────────────────────────────
/// 조건 줄의 "나" — 보낸 사람 · 받는 사람 칸의 값이 이 이름이나 주소면 "내가 보낸 메일" · "내가 받은 메일" (웹 `SearchConditionsMe`)
typedef SearchConditionsMe = ({String name, String email});
/// 견줄 모양 — 앞뒤 공백을 빼고 NFC(한글 자모만 모은다 — [composeHangul]) · 소문자(JS 와 같게 — [jsLowerCase]).
/// 영문 이름 · 주소는 대소문자를 가리지 않는다
String _comparable(String value) =>
jsLowerCase(composeHangul(trimSearchSpaces(value)));
/// 칸의 값이 나인가 — 내 이름이나 내 주소와 통째로 같을 때만(일부만 같으면 아니다)
bool isSearchConditionMe(String value, SearchConditionsMe? me) {
if (me == null) return false;
final String text = _comparable(value);
if (text.isEmpty) return false;
return text == _comparable(me.email) || text == _comparable(me.name);
}
@@ -1,14 +1,17 @@
/// 검색 화면의 표시 — 순수 함수 (보드 `App1Search` · `App1SearchStates` · 웹 `MailSearchHeader` · `MailSearchNotices` · `useMailListRows`).
/// 검색 화면의 표시 — 순수 함수 (보드 `App1Search` · `App1SearchStates` · `App1SearchConditions` · 웹 `MailSearchHeader` · `MailSearchNotices` ·
/// `MailSearchConditionLine` · `useMailListRows`).
///
/// - 결과 머리: "‘계약’ · 4통" · 찾는 중 "‘계약’ · 찾는 중" · 전체 수를 모르면(`total` null) "‘계약’"(통 수를 보이지 않는다 — 계약).
/// - 안내 줄: 색인을 준비하는 중이면 그 줄, 준비됐고 한 글자 낱말뿐이면 한 글자 안내 — 둘이 함께 뜨지 않고, 준비 상태를 모르면 없다.
/// 조건만으로 찾았으면 "2통" · "찾는 중"(전체 수를 모르면 머리가 없다).
/// - 안내 줄: 색인을 준비하는 중이면 그 줄, 준비됐고 본문을 찾을 말이 한 글자 낱말뿐이면 한 글자 안내 — 둘이 함께 뜨지 않고, 준비 상태를 모르면 없다.
/// - 조건 줄: "조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음"(웹 `searchConditionsSummary` — 쉼표로 잇는다).
/// - 결과 줄: 한 메일함에서 찾았으면 그 메일함의 규칙(보낸편지함 · 임시보관함), 전체 메일함이면 줄마다 그 메일의 메일함(시각 앞에 이름).
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/domain/search_conditions.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';
@@ -23,7 +26,7 @@ enum MailSearchNotice {
singleChar,
}
/// 결과 머리(개수 줄)의 글 — 머리가 없는 단계(찾기 전 · 낱말이 너무 많음 · 받지 못함)면 null
/// 결과 머리(개수 줄)의 글 — 머리가 없는 단계(찾기 전 · 낱말이 너무 많음 · 받지 못함)거나 보일 글이 없으면 null
String? searchCountLineOf(MailSearchState state) {
final String? query = state.query;
if (query == null || !state.showsHead) return null;
@@ -31,19 +34,22 @@ String? searchCountLineOf(MailSearchState state) {
return MailSearchTexts.searching(query);
}
final int? total = state.total;
return total == null
? MailSearchTexts.quoted(query)
: MailSearchTexts.count(query, total);
if (total != null) return MailSearchTexts.count(query, total);
final String quoted = MailSearchTexts.quoted(query);
return quoted.isEmpty ? null : quoted;
}
/// 안내 줄 — 찾은 뒤에만. 한 글자 안내는 색인이 준비됐을 때만이라(그 전은 엔진 방식이 본문도 찾는다) 준비 중 줄과 함께 뜨지 않는다
/// 안내 줄 — 찾은 뒤에만. 한 글자 안내는 색인이 준비됐을 때만이라(그 전은 엔진 방식이 본문도 찾는다) 준비 중 줄과 함께 뜨지 않는다.
/// 본문을 찾을 말(검색어나 내용 조건)이 있을 때만이다([skipsBodySearch] — 웹과 같은 판정)
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,
skipsBodySearch(query, state.conditions)
? MailSearchNotice.singleChar
: null,
null => null,
};
}
@@ -85,13 +91,69 @@ MailRowView searchRowViewOf(
}
/// 결과에서 연 메일에 넘기는 값 — 강조 쿼리는 14단계 서버의 결과([MailSearchItem.highlightSupported])에서만 싣는다
/// (14단계 앞 서버는 읽기 화면의 모르는 쿼리를 400 `VAL_001` 로 막는다). 찾은 메일함은 한 메일함에서 찾았을 때만
/// (14단계 앞 서버는 읽기 화면의 모르는 쿼리를 400 `VAL_001` 로 막는다). 검색어와 글 조건 넷만 — 기간 · 첨부는 서버가 받지 않는다
/// ([messageHighlightQueryOf] — 실을 칸이 없으면 null). 찾은 메일함은 한 메일함에서 찾았을 때만
MailSearchOpen searchOpenOf(MailSearchItem item, MailSearchState state) {
final String? query = state.query;
return MailSearchOpen(
highlight: item.highlightSupported && query != null
? MessageHighlightQuery(q: query)
? messageHighlightQueryOf(query, state.conditions)
: null,
scopeMailboxId: state.searchedAll ? null : state.searchedMailboxId,
);
}
// ── 조건 줄 (웹 `searchConditionsSummary`) ─────────────────────────────
/// 날짜 칸 · 조건 줄의 날짜 — "2026. 10. 1.", [withYear] 가 아니면 "10. 1."
String formatSearchDay(SearchDay day, {bool withYear = true}) => withYear
? '${day.year}. ${day.month}. ${day.day}.'
: '${day.month}. ${day.day}.';
/// 조건 줄의 기간 — 오늘 기준의 미리 정한 기간과 같으면 그 이름, 아니면 날짜(같은 해면 끝 날짜의 해를 뺀다 · 같은 날이면 "… 하루"). 없으면 null
String? _periodText(SearchConditions conditions, DateTime today) {
final SearchDay? since = conditions.since;
final SearchDay? until = conditions.until;
final SearchPeriod period = periodOf(since, until, today);
if (period == SearchPeriod.all) return null;
if (period != SearchPeriod.custom) return MailSearchTexts.period(period);
if (since != null && until != null) {
if (since == until) {
return MailSearchTexts.lineOneDay(formatSearchDay(since));
}
return MailSearchTexts.lineRange(
formatSearchDay(since),
formatSearchDay(until, withYear: since.year != until.year),
);
}
if (since != null) return MailSearchTexts.lineSince(formatSearchDay(since));
return until == null
? null
: MailSearchTexts.lineUntil(formatSearchDay(until));
}
/// 결과 머리 위의 조건 줄 — "조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음" (쉼표로 잇는다 — 꼬리표 · 알약을 쓰지 않는다).
/// 보낸 사람 · 받는 사람이 나([me] — 내 이름이나 주소와 통째로 같음)면 "내가 보낸 메일" · "내가 받은 메일". 조건이 없으면 빈 글
String searchConditionsSummary(
SearchConditions conditions,
DateTime today, {
SearchConditionsMe? me,
}) {
if (conditions.isEmpty) return '';
final List<String> parts = <String>[
if (conditions.from.isNotEmpty)
isSearchConditionMe(conditions.from, me)
? MailSearchTexts.lineFromMe
: MailSearchTexts.lineFrom(conditions.from),
if (conditions.to.isNotEmpty)
isSearchConditionMe(conditions.to, me)
? MailSearchTexts.lineToMe
: MailSearchTexts.lineTo(conditions.to),
if (conditions.subject.isNotEmpty)
MailSearchTexts.lineSubject(conditions.subject),
if (conditions.body.isNotEmpty) MailSearchTexts.lineBody(conditions.body),
?_periodText(conditions, today),
if (conditions.hasAttachment) MailSearchTexts.lineAttachment,
];
return '${MailSearchTexts.linePrefix}${parts.join(MailSearchTexts.lineSeparator)}';
}
@@ -10,6 +10,7 @@ import 'package:arasystem_app/core/utils/korean_format.dart';
import 'package:arasystem_app/core/utils/korean_text.dart';
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
import 'package:arasystem_app/features/mail/domain/mail_body_notices.dart';
import 'package:arasystem_app/features/mail/domain/search_conditions.dart';
/// 쓰기 화면 (보드 `App1Compose`). 보드에 없는 상태(불러오기 · 받지 못함 · 칸 안내 · 첨부 상태 · 나가기 확인 · 보낸 뒤 알림)의 글은
/// 웹 상수(`frontend/src/shared/constants/mail.ts` · `mail-send-later.ts` · `large-file.ts`)를 그대로 옮겼다 — 앱에서 새로 쓴 글은 그 줄에 적었다
@@ -390,8 +391,10 @@ 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`)를 그대로 옮겼다 —
/// 메일 검색 (보드 `App1Search` · `App1SearchStates` — 1차: 검색어 · 찾을 곳 · 결과 · 강조 · 이어 찾기 · 안내,
/// `App1SearchConditions` — 2차: 조건으로 찾기 · 조건 줄 · 합친 낱말 수 · 기간).
/// 글은 웹 상수(`frontend/src/shared/constants/mail.ts` 의 `MAIL_SEARCH_TEXT` · `mail-search.ts` 의 `SEARCH_NOTICE_TEXT` ·
/// `SEARCH_CONDITIONS_TEXT` · `SEARCH_PERIOD_LABELS` · `SEARCH_CONDITION_LINE_TEXT`)를 그대로 옮겼다 —
/// 앱에서 새로 쓴 글은 그 줄에 적었다. 목록 줄 · 받지 못함의 글은 [MailListTexts] 를 함께 쓴다
abstract final class MailSearchTexts {
// ── 위 막대 ──
@@ -412,15 +415,17 @@ abstract final class MailSearchTexts {
static const String scopeAllHint = '휴지통과 스팸메일함은 빼고 찾습니다';
// ── 결과 머리 ──
/// 전체 수를 모를 때(`total` null) — "‘계약’"
static String quoted(String query) => '‘$query’';
/// 전체 수를 모를 때(`total` null) — "‘계약’" (조건만으로 찾았으면 빈 글 — 머리를 보이지 않는다)
static String quoted(String query) => query.isEmpty ? '' : '‘$query’';
/// "‘계약’ · 4통"
static String count(String query, int count) =>
'‘$query’ · ${formatCount(count)}통';
/// "‘계약’ · 4통" · 조건만으로 찾았으면 "2통"
static String count(String query, int count) => query.isEmpty
? '${formatCount(count)}통'
: '‘$query’ · ${formatCount(count)}통';
/// "‘계약’ · 찾는 중"
static String searching(String query) => '‘$query’ · 찾는 중';
/// "‘계약’ · 찾는 중" · 조건만으로 찾으면 "찾는 중"
static String searching(String query) =>
query.isEmpty ? '찾는 중' : '‘$query’ · 찾는 중';
/// 결과 목록 — 화면 낭독기 이름 (웹 `title`)
static const String results = '검색 결과';
@@ -438,12 +443,19 @@ abstract final class MailSearchTexts {
'한 글자로만 찾을 때는 본문은 빼고 제목, 보낸 사람과 받는 사람, 첨부 파일 이름에서 찾습니다. 본문까지 찾으려면 두 글자 이상으로 찾아 주세요.';
// ── 결과 없음 ──
/// "‘해지 통보’가 들어간 메일이 없습니다"
static String emptyTitle(String query) =>
'‘$query’${subjectParticle(query)} 들어간 메일이 없습니다';
/// "‘해지 통보’가 들어간 메일이 없습니다" · 조건만으로 찾았으면 "조건에 맞는 메일이 없습니다"
static String emptyTitle(String query) => query.isEmpty
? '조건에 맞는 메일이 없습니다'
: '‘$query’${subjectParticle(query)} 들어간 메일이 없습니다';
/// 검색어만으로 찾았을 때의 도움말 (웹 `emptyWordsHint` — 조건은 2차)
static const String emptyHint = '다른 말로 찾아 보세요.';
/// 검색어와 조건으로 찾았을 때의 도움말 (웹 `emptyHint`)
static const String emptyHint = '다른 말로 찾거나 조건을 줄여 보세요.';
/// 조건만으로 찾았을 때의 도움말 (웹 `emptyConditionsHint`)
static const String emptyConditionsHint = '조건을 바꾸거나 줄여 보세요.';
/// 검색어만으로 찾았을 때의 도움말 — 걸린 조건이 없으니 조건을 줄이라고 하지 않는다 (웹 `emptyWordsHint`)
static const String emptyWordsHint = '다른 말로 찾아 보세요.';
/// 색인을 준비하는 동안에만 더하는 한계 안내 (웹 `wordLimitHint`)
static const String wordLimitHint =
@@ -464,9 +476,9 @@ abstract final class MailSearchTexts {
// ── 찾을 말이 너무 많음 ──
static const String tooManyWordsTitle = '찾을 말이 너무 많습니다';
/// (앱 1차 — 검색어만) 2차(조건)부터는 웹 글 "검색 칸과 [조건]에 넣은 낱말은 모두 합쳐 …"
/// 검색어와 조건의 낱말을 합쳐 셌다 — 결과 자리 · 시트의 [찾기] 위 (웹 `tooManyWordsHint`)
static String tooManyWordsHint(int max) =>
'검색어에는 낱말을 ${formatCount(max)}개까지 넣을 수 있습니다. 낱말을 몇 개 줄여 주세요.';
'검색 칸과 [조건]에 넣은 낱말은 모두 합쳐 ${formatCount(max)}개까지입니다. 낱말을 몇 개 줄여 주세요.';
// ── 찾기 전 ──
/// 처음 연 화면 — 무엇을 찾는지 (앱에서 새로 씀)
@@ -479,6 +491,88 @@ abstract final class MailSearchTexts {
/// 검색 결과에서 연 메일의 머리 — "메일함 받은편지함"의 이름 칸 (웹 `mailboxLine`)
static const String mailboxLine = '메일함';
// ── 조건으로 찾기 — 위 막대의 [조건] · 시트 (2차 — 웹 `SEARCH_CONDITIONS_TEXT`) ──
/// [조건] — 툴팁 · 화면 낭독기 이름 · 시트 제목 (웹 `toggleTitle` · `title`)
static const String conditions = '조건으로 찾기';
/// 조건이 걸려 있을 때 [조건]의 낭독에 더한다 (웹 `toggleApplied`)
static const String conditionsApplied = '조건이 걸려 있음';
/// [조건]의 화면 낭독기 이름 — "조건으로 찾기, 조건이 걸려 있음"
static String conditionsLabel({required bool applied}) =>
applied ? '$conditions, $conditionsApplied' : conditions;
/// 시트의 [✕] — 화면 낭독기 (앱에서 새로 씀)
static const String closeConditions = '닫기';
static const String fromLabel = '보낸 사람';
static const String toLabel = '받는 사람';
static const String subjectLabel = '제목';
/// 칸 이름 "내용" — 조건 줄도 "내용에 ‘…’이 든 메일"이다
static const String bodyLabel = '내용';
static const String periodLabel = '기간';
static const String attachmentLabel = '첨부';
/// 보낸 사람 · 받는 사람 칸의 흐린 글
static const String personPlaceholder = '이름이나 메일 주소';
static const String subjectPlaceholder = '제목에 든 말';
static const String bodyPlaceholder = '내용에 든 말';
/// 기간 고르기의 이름 (웹 `SEARCH_PERIOD_LABELS`)
static String period(SearchPeriod period) => switch (period) {
SearchPeriod.all => '전체 기간',
SearchPeriod.week => '최근 1주',
SearchPeriod.month => '최근 1달',
SearchPeriod.quarter => '최근 3달',
SearchPeriod.year => '최근 1년',
SearchPeriod.custom => '날짜 고르기',
};
/// 날짜 칸의 이름 · 날짜 고르는 창의 머리
static const String since = '시작 날짜';
static const String until = '끝 날짜';
/// 날짜 칸의 [✕] — 화면 낭독기 (앱에서 새로 씀)
static const String clearSince = '시작 날짜 지우기';
static const String clearUntil = '끝 날짜 지우기';
static const String attachmentOnly = '첨부 파일이 있는 메일만';
static const String conditionsHint =
'검색 칸에 쓴 말이 들어 있고 이 조건에도 맞는 메일을 찾습니다. 받는 사람으로 찾을 때는 참조로 받은 사람까지 찾습니다.';
/// 날짜 고르기의 시작이 끝보다 늦을 때 — 두 날짜 칸 아래 (웹 `periodOrder`)
static const String periodOrder = '끝 날짜를 시작 날짜와 같거나 그 뒤로 골라 주세요.';
/// 시트의 [조건 지우기](칸만 비운다) · 조건 줄의 [조건 지우기](걸린 조건을 뺀다)
static const String clearConditions = '조건 지우기';
/// 시트의 [찾기]
static const String applyConditions = '찾기';
// ── 조건 줄 — "조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음" (웹 `SEARCH_CONDITION_LINE_TEXT` — 쉼표로 잇는다) ──
static const String linePrefix = '조건: ';
static const String lineSeparator = ', ';
static String lineFrom(String who) => '$who 님이 보낸 메일';
static String lineTo(String who) => '$who 님이 받은 메일';
/// 보낸 사람 · 받는 사람 칸의 값이 내 이름이나 내 주소일 때
static const String lineFromMe = '내가 보낸 메일';
static const String lineToMe = '내가 받은 메일';
/// "제목에 ‘견적’이 든 메일" — 조사(이 · 가)는 말의 받침으로
static String lineSubject(String text) =>
'제목에 ‘$text’${subjectParticle(text)} 든 메일';
static String lineBody(String text) =>
'내용에 ‘$text’${subjectParticle(text)} 든 메일';
static String lineSince(String day) => '$day부터';
static String lineUntil(String day) => '$day까지';
static String lineRange(String since, String until) => '$since부터 $until까지';
/// 시작 날짜와 끝 날짜가 같은 날 — "2026. 11. 6.부터 11. 6.까지" 대신
static String lineOneDay(String day) => '$day 하루';
static const String lineAttachment = '첨부 있음';
}
/// 메일함 서랍 (보드 `App1MailList` 「메일함 서랍」)
@@ -6,6 +6,7 @@ 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_conditions.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';
@@ -18,18 +19,22 @@ import 'package:arasystem_app/features/mail/presentation/providers/mailboxes_pro
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_conditions_sheet.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차: 검색어 · 찾을 곳 · 결과 · 강조 · 이어 찾기 · 안내).
/// 메일 검색 — 앱 보드 `App1Search` · `App1SearchStates` · `App1SearchConditions` (앱 디자인 README 「검색」 —
/// 1차: 검색어 · 찾을 곳 · 결과 · 강조 · 이어 찾기 · 안내, 2차: 조건으로 찾기 · 조건 줄 · 합친 낱말 수 · 기간 · "나").
///
/// - **탭 안 화면**이다 — 연 목록 위에 쌓이고 탭 막대가 보인다(자판이 열리면 틀이 숨긴다). 메일 탭을 다시 누르면 닫힌다.
/// 검색어는 주소에 싣지 않는다(화면 상태 — 시작한 메일함만 라우트의 `extra`).
/// - 열면 칸에 초점을 두고 자판을 올린다. 자판의 [검색]으로 찾고(빈 칸 · 공백뿐이면 아무 일 없이 자판을 둔다), 찾으면 자판을 내린다.
/// 결과를 끌면 자판을 내린다. [✕]는 칸과 결과를 비우고 칸에 초점을 둔다. [←] · 뒤로 가기가 검색을 닫는다.
/// 검색어 · 조건은 주소에 싣지 않는다(화면 상태 — 시작한 메일함만 라우트의 `extra`).
/// - 열면 칸에 초점을 두고 자판을 올린다. 자판의 [검색]으로 찾고(빈 칸 · 공백뿐이고 걸린 조건도 없으면 아무 일 없이 자판을 둔다), 찾으면 자판을 내린다.
/// 결과를 끌면 자판을 내린다. [✕]는 칸과 결과를 비우고 칸에 초점을 둔다(걸린 조건은 남는다). [←] · 뒤로 가기가 검색을 닫는다.
/// - [조건](맨 오른쪽)은 「조건으로 찾기」 시트를 연다 — 연 동안 · 조건이 걸려 있으면 옅은 청록 원. 시트의 [찾기]는 조건을 걸고 칸의 지금 글로 찾는다.
/// 조건이 걸리면 찾을 곳 줄 아래에 조건 줄("조건: …" + [조건 지우기])이 보인다.
/// - 찾을 곳은 찾기 전부터 보이고, 바꾸면 칸의 글(비었으면 보이는 결과의 검색어)로 바로 다시 찾는다 — 다시 찾으면 자판을 내린다.
/// - 결과 줄은 목록 줄 그대로(고르지 않는다 — 사진은 단추가 아니고 길게 눌러도 아무 일 없다). 누르면 읽기 화면(임시보관함은 쓰기 화면),
/// 14단계 서버의 결과면 강조 쿼리를 함께 넘긴다. 읽기 화면에서 지운 뒤 [되돌리기] 알림이 이 화면 아래에 뜬다([메일 쓰기]는 없다).
@@ -56,6 +61,12 @@ class _MailSearchPageState extends ConsumerState<MailSearchPage> {
final TextEditingController _field = TextEditingController();
final FocusNode _focus = FocusNode();
/// [조건]의 초점 — 시트를 열기 전에 받아 둔다(시트가 닫히면 이 단추로 돌아와 자판이 다시 올라오지 않는다)
final FocusNode _conditionsFocus = FocusNode();
/// 「조건으로 찾기」 시트가 열려 있다
bool _conditionsOpen = false;
MailSearchController get _controller =>
ref.read(mailSearchProvider(_args).notifier);
@@ -63,16 +74,40 @@ class _MailSearchPageState extends ConsumerState<MailSearchPage> {
void dispose() {
_field.dispose();
_focus.dispose();
_conditionsFocus.dispose();
super.dispose();
}
// ── 하는 일 ──
/// 자판의 [검색] — 빈 칸 · 공백뿐이면 자판을 그대로 둔다
/// 자판의 [검색] — 빈 칸 · 공백뿐이고 걸린 조건도 없으면 자판을 그대로 둔다
void _submit(String text) {
if (_controller.submit(text)) _focus.unfocus();
}
/// [조건] — 시트를 연다. [찾기]가 넘긴 조건을 걸고 칸의 지금 글로 찾는다(닫기만 했으면 아무 일 없다)
Future<void> _openConditions() async {
if (_conditionsOpen) return;
_conditionsFocus.requestFocus();
setState(() => _conditionsOpen = true);
final SearchConditions? picked = await showSearchConditionsSheet(
context,
applied: ref.read(mailSearchProvider(_args)).conditions,
searchText: _field.text,
clock: ref.read(clockProvider),
);
if (!mounted) return;
setState(() => _conditionsOpen = false);
if (picked != null && _controller.submit(_field.text, conditions: picked)) {
_focus.unfocus();
}
}
/// 조건 줄의 [조건 지우기] — 걸린 조건을 빼고 칸의 글(비었으면 보이는 결과의 검색어)로 다시 찾는다
void _clearConditions() {
if (_controller.clearConditions(fieldText: _field.text)) _focus.unfocus();
}
/// [✕] — 칸과 결과를 비우고 칸에 초점(자판이 다시 올라온다)
void _clear() {
_field.clear();
@@ -124,6 +159,7 @@ class _MailSearchPageState extends ConsumerState<MailSearchPage> {
});
final String? countLine = searchCountLineOf(search);
final MailSearchNotice? notice = searchNoticeOf(search);
final bool conditionsApplied = search.conditions.isNotEmpty;
return Scaffold(
appBar: MailSearchBar(
controller: _field,
@@ -131,6 +167,10 @@ class _MailSearchPageState extends ConsumerState<MailSearchPage> {
onSubmitted: _submit,
onClear: _clear,
onClose: _close,
onConditions: () => unawaited(_openConditions()),
conditionsFocusNode: _conditionsFocus,
conditionsApplied: conditionsApplied,
conditionsOpen: _conditionsOpen,
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -140,6 +180,15 @@ class _MailSearchPageState extends ConsumerState<MailSearchPage> {
allMailboxes: search.allMailboxes,
onChanged: _setScope,
),
if (conditionsApplied)
MailSearchConditionLine(
text: searchConditionsSummary(
search.conditions,
ref.read(clockProvider)(),
me: ref.watch(mailSelfProvider),
),
onClear: _clearConditions,
),
if (countLine != null) MailSearchCountLine(countLine),
if (notice != null) MailSearchNoticeLine(_noticeText(notice)),
Expanded(
@@ -248,6 +297,7 @@ class _SearchBody extends StatelessWidget {
content = _FillViewport(
child: MailSearchEmpty(
query: query,
hasConditions: search.conditions.isNotEmpty,
indexing: search.indexStatus == SearchIndexStatus.building,
onSearchAll: search.searchedAll ? null : onSearchAll,
),
@@ -2,6 +2,7 @@ import 'package:arasystem_app/core/api/dio_provider.dart';
import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart';
import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart';
import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart';
import 'package:arasystem_app/features/mail/domain/search_conditions.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 메일 저장소 — 인터셉터(언래핑 · Bearer · 갱신 · 알림)가 걸린 `dioProvider` 의 Dio 를 쓴다
@@ -20,3 +21,13 @@ final Provider<String?> mailAccountProvider = Provider<String?>((Ref ref) {
? session.info.email
: null;
});
/// 메일을 보는 직원의 이름 · 주소 — 검색 조건 줄이 "내가 보낸 메일"을 알아본다(웹은 `authStore.user`).
/// [mailAccountProvider] 와 같은 때만 있고 그 밖에는 null
final Provider<SearchConditionsMe?> mailSelfProvider =
Provider<SearchConditionsMe?>((Ref ref) {
final SessionState? session = ref.watch(sessionProvider).value;
return session is SignedIn && session.info.restriction == null
? (name: session.info.name, email: session.info.email)
: null;
});
@@ -11,6 +11,7 @@ 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_conditions.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';
@@ -36,9 +37,12 @@ mailSearchProvider = NotifierProvider.autoDispose
MailSearchController.new,
);
/// 메일 검색 — 계약 2절 "검색" · "검색 — 14단계"(이어 찾기 · 준비 상태), 웹 `stores/mail/list.ts` 의 검색 결과와 같은 규칙.
/// 메일 검색 — 계약 2절 "검색" · "검색 — 14단계"(조건 · 이어 찾기 · 준비 상태), 웹 `stores/mail/list.ts` · `useMailSearchNavigation` 의 검색과 같은 규칙.
///
/// - 자판의 [검색]으로만 찾는다(치는 동안 묻지 않는다). 낱말이 8개를 넘으면 묻지 않는다. 같은 검색어 · 같은 곳이면 보이는 채로 다시 찾는다.
/// - 자판의 [검색] · 시트의 [찾기]로만 찾는다(치는 동안 묻지 않는다). 검색어와 조건의 낱말이 합쳐 8개를 넘으면 묻지 않는다.
/// 같은 검색어 · 같은 조건 · 같은 곳이면 보이는 채로 다시 찾는다.
/// - **조건**(2차)은 화면 상태에만 둔다([MailSearchState.conditions] — 주소에 싣지 않는다). 걸린 조건은 [검색] · 찾을 곳 바꾸기 · 다시 찾기 ·
/// 이어 찾기가 모두 그대로 쓴다. 칸이 비어도 조건이 있으면 조건만으로 찾는다(웹과 같다). [✕]는 검색어와 결과만 지우고 조건은 남긴다.
/// - **이어 찾기**: 한 차례(처음 · 목록 끝에 닿음 · [더 찾기])에 한 쪽(20통)이 새로 차거나 더 없을 때까지 요청을 5번까지 보낸다.
/// 하나도 찾기 전에는 뼈대, 하나라도 찾으면 보이며 뒤에서 잇는다. 차례가 한 쪽을 못 채우고 끝났는데 더 있으면 저절로 잇지 않고 [더 찾기]를 둔다.
/// 다음 쪽의 `total` 이 null 이면 첫 쪽의 수를 남긴다. 같은 커서가 새 메일 없이 돌아오면 [다시 시도]로 멈춘다.
@@ -130,72 +134,94 @@ class MailSearchController extends Notifier<MailSearchState> {
// ── 하는 일 ─────────────────────────────────────────
/// 자판의 [검색] — 빈 칸 · 공백뿐이면 아무 일도 하지 않고 거짓(자판도 그대로 둔다).
/// 낱말이 한도를 넘으면 묻지 않는다. 같은 검색어 · 같은 곳의 결과가 보이면 보이는 채로 다시 찾는다
bool submit(String text) {
/// 자판의 [검색] · 시트의 [찾기]([conditions] — 그 조건을 걸고 찾는다. 주지 않으면 걸려 있는 조건 그대로 — 웹 `startSearch` 와 같다).
/// - 검색어도 조건도 없으면: [검색]은 아무 일도 하지 않고 거짓(자판도 그대로 둔다), 시트의 [찾기]는 조건을 빼고 찾기 전으로(요청 없음 —
/// 웹은 검색을 닫지만 앱은 검색 화면에 머문다)
/// - 칸이 비어도 조건이 있으면 조건만으로 찾는다
/// - 검색어와 조건의 낱말이 합쳐 한도를 넘으면 묻지 않는다(조건은 걸린 채 — 조건 줄과 [조건 지우기]가 남는다)
/// - 같은 검색어 · 같은 조건 · 같은 곳의 결과가 보이면 보이는 채로 다시 찾는다
bool submit(String text, {SearchConditions? conditions}) {
if (!ref.mounted) return false;
final String q = normalizeSearchText(text);
if (q.isEmpty) return false;
if (countSearchWords(q) > kMailSearchMaxWords) {
final SearchConditions next = conditions == null
? state.conditions
: normalizeSearchConditions(conditions);
if (q.isEmpty && next.isEmpty) {
if (conditions == null) return false;
_reset(SearchConditions.none);
return true;
}
if (exceedsCombinedSearchWords(q, next)) {
_beginGeneration();
_removed.clear();
state = MailSearchState(
phase: MailSearchPhase.tooManyWords,
allMailboxes: state.allMailboxes,
indexStatus: state.indexStatus,
conditions: next,
);
return true;
}
final MailSearchState current = state;
if (current.phase == MailSearchPhase.ready &&
final bool same =
current.phase == MailSearchPhase.ready &&
current.query == q &&
current.searchedAll == current.allMailboxes) {
unawaited(_search(q, keepVisible: true));
} else {
unawaited(_search(q));
}
current.searchedAll == current.allMailboxes &&
current.conditions == next;
unawaited(_search(q, conditions: next, keepVisible: same));
return true;
}
/// 찾을 곳을 바꾼다 — 칸의 글([fieldText] — 비었으면 보이는 결과의 검색어)로 바로 다시 찾는다. 둘 다 없으면 곳만 바꿔 둔다.
/// 다시 찾았으면 참([submit] 과 같다 — 화면이 자판을 내린다)
/// 찾을 곳을 바꾼다 — 칸의 글([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;
if (typed.isNotEmpty) return submit(typed);
final String? q = 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));
if (q != null) unawaited(_search(q, conditions: state.conditions));
}
/// [✕] — 결과를 비우고 찾기 전으로(찾을 곳은 그대로)
/// [✕] — 검색어의 결과를 비우고 찾기 전으로(찾을 곳 · 걸린 조건은 그대로 — 조건 줄이 남는다)
void clear() {
if (!ref.mounted) return;
_beginGeneration();
_removed.clear();
state = MailSearchState(
allMailboxes: state.allMailboxes,
indexStatus: state.indexStatus,
);
_reset(state.conditions);
}
/// 당겨서 새로고침 — 보이는 채로 다시 찾는다(받지 못한 화면이면 [retry])
/// 조건 줄의 [조건 지우기] — 걸린 조건을 모두 뺀다. 찾은 검색이면 칸의 글([fieldText] — 비었으면 보이는 결과의 검색어)로 다시 찾고,
/// 그 글도 없으면 요청 없이 찾기 전으로(웹은 검색을 닫는다). 찾기 전([✕] 뒤)이면 조건만 뺀다. 다시 찾았으면 참(화면이 자판을 내린다)
bool clearConditions({required String fieldText}) {
if (!ref.mounted) return false;
final MailSearchState current = state;
if (current.conditions.isEmpty) return false;
if (current.phase == MailSearchPhase.idle) {
state = current.copyWith(conditions: SearchConditions.none);
return false;
}
final String typed = normalizeSearchText(fieldText);
final String q = typed.isNotEmpty ? typed : current.query ?? '';
return submit(q, conditions: SearchConditions.none);
}
/// 당겨서 새로고침 — 보이는 채로 다시 찾는다(받지 못한 화면이면 [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);
await _search(q, conditions: state.conditions, keepVisible: true);
case MailSearchPhase.failed:
await _search(q);
await _search(q, conditions: state.conditions);
case MailSearchPhase.idle:
case MailSearchPhase.tooManyWords:
case MailSearchPhase.searching:
@@ -203,11 +229,22 @@ class MailSearchController extends Notifier<MailSearchState> {
}
}
/// 받지 못함의 [다시 시도] — 처음부터 다시 찾는다
/// 받지 못함의 [다시 시도] — 처음부터 다시 찾는다(걸린 조건 그대로)
Future<void> retry() async {
if (!ref.mounted) return;
final String? q = state.query;
if (q != null) await _search(q);
if (q != null) await _search(q, conditions: state.conditions);
}
/// 찾기 전으로 — 도는 요청을 끊고 결과를 비운다(찾을 곳 · 준비 상태는 그대로, 조건은 [conditions])
void _reset(SearchConditions conditions) {
_beginGeneration();
_removed.clear();
state = MailSearchState(
allMailboxes: state.allMailboxes,
indexStatus: state.indexStatus,
conditions: conditions,
);
}
/// 목록 끝에 닿았다 · 끝의 [다시 시도] — 한 쪽을 채운 차례 뒤에만 다음 차례를 묻는다([더 찾기]가 필요하면 그것으로만)
@@ -257,9 +294,13 @@ class MailSearchController extends Notifier<MailSearchState> {
// ── 찾기 ──────────────────────────────────────────
/// 첫 쪽을 묻고 이어 찾는다 — [keepVisible] 이면 보이는 결과를 둔 채(실패하면 그대로 두고 인터셉터가 알린다),
/// 첫 쪽을 묻고 이어 찾는다([q] 가 비면 [conditions] 만으로) — [keepVisible] 이면 보이는 결과를 둔 채(실패하면 그대로 두고 인터셉터가 알린다),
/// 아니면 뼈대부터(처음 찾는 쪽은 알리지 않는다 — 화면이 받지 못함을 그린다)
Future<void> _search(String q, {bool keepVisible = false}) async {
Future<void> _search(
String q, {
required SearchConditions conditions,
bool keepVisible = false,
}) async {
final Ref ref = this.ref;
final int seq = _beginGeneration();
final CancelToken cancel = _cancel!;
@@ -270,6 +311,7 @@ class MailSearchController extends Notifier<MailSearchState> {
refreshing: true,
fill: MailSearchFill.none,
loadMoreFailed: false,
conditions: conditions,
)
: MailSearchState(
phase: MailSearchPhase.searching,
@@ -277,6 +319,7 @@ class MailSearchController extends Notifier<MailSearchState> {
query: q,
searchedAll: all,
indexStatus: state.indexStatus,
conditions: conditions,
);
unawaited(_refreshIndexStatus());
String? mailboxId;
@@ -298,6 +341,7 @@ class MailSearchController extends Notifier<MailSearchState> {
try {
final MailPage<MailSearchItem> page = await _repository.searchMessages(
q: q,
conditions: conditions,
mailboxId: mailboxId,
silent: !keepVisible,
cancelToken: cancel,
@@ -316,6 +360,7 @@ class MailSearchController extends Notifier<MailSearchState> {
items: List<MailSearchItem>.unmodifiable(items),
pageInfo: page.pageInfo,
indexStatus: state.indexStatus,
conditions: conditions,
);
await _fillRound(
seq,
@@ -365,6 +410,7 @@ class MailSearchController extends Notifier<MailSearchState> {
try {
page = await _repository.searchMessages(
q: current.query!,
conditions: current.conditions,
mailboxId: current.searchedMailboxId,
cursor: cursor,
silent: beforeAny,
@@ -1,6 +1,7 @@
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:arasystem_app/features/mail/domain/search_conditions.dart';
import 'package:flutter/foundation.dart';
/// 검색 화면을 여는 값 — 화면(State)마다 하나. [instance] 로 가른다(같은 메일함에서 검색을 다시 열어도 앞 화면의 결과와 섞이지 않게 —
@@ -68,6 +69,7 @@ class MailSearchState {
this.partial = false,
this.loadMoreFailed = false,
this.indexStatus,
this.conditions = SearchConditions.none,
});
final MailSearchPhase phase;
@@ -75,9 +77,14 @@ class MailSearchState {
/// 찾을 곳 — 전체 메일함(참) · 시작한 메일함(거짓). 다음 검색이 쓴다
final bool allMailboxes;
/// 보이는(찾는) 결과의 검색어 — 찾기 전 · 낱말이 너무 많음이면 null. 칸의 글을 고친 채 아직 찾지 않았어도 이 값이다
/// 보이는(찾는) 결과의 검색어 — 찾기 전 · 낱말이 너무 많음이면 null, 조건만으로 찾았으면 빈 글.
/// 칸의 글을 고친 채 아직 찾지 않았어도 이 값이다
final String? query;
/// 걸린 조건(2차 — 시트의 [찾기]로 건다) — 보이는 결과를 찾은 조건이고, 다음 검색 · 다시 찾기 · 이어 찾기가 그대로 쓴다.
/// 찾기 전([✕] 뒤) · 낱말이 너무 많음에도 남는다(조건 줄이 보인다). 화면 상태에만 둔다(주소에 싣지 않는다)
final SearchConditions conditions;
/// 보이는 결과를 전체 메일함에서 찾았다 — 줄마다 메일함이 다르다
final bool searchedAll;
@@ -135,6 +142,7 @@ class MailSearchState {
bool? partial,
bool? loadMoreFailed,
Object? indexStatus = _unset,
SearchConditions? conditions,
}) => MailSearchState(
phase: phase ?? this.phase,
allMailboxes: allMailboxes ?? this.allMailboxes,
@@ -157,5 +165,6 @@ class MailSearchState {
indexStatus: identical(indexStatus, _unset)
? this.indexStatus
: indexStatus as SearchIndexStatus?,
conditions: conditions ?? this.conditions,
);
}
@@ -8,7 +8,8 @@ import 'package:arasystem_app/shared/widgets/keep_all_text.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// 검색 화면의 위 막대 — [←](검색 닫기) · 칸("메일 검색") · 글이 있으면 [✕](검색어 지우기) (보드 `App1Search`, 높이 64).
/// 검색 화면의 위 막대 — [←](검색 닫기) · 칸("메일 검색") · 글이 있으면 [✕](검색어 지우기) · 맨 오른쪽 [조건](조건으로 찾기)
/// (보드 `App1Search` · `App1SearchConditions`, 높이 64).
///
/// 칸은 자판의 [검색]으로만 찾는다(치는 동안 묻지 않는다). 글 자판(메일 주소 자판은 한글을 막는다) · 자동 고침 끔 · 추천 켬 ·
/// 둥근 따옴표 끔(큰따옴표가 둥근 따옴표가 되면 구절이 아니라 낱말로 찾는다). UTF-16 200자까지(서버와 같은 단위).
@@ -21,6 +22,10 @@ class MailSearchBar extends StatelessWidget implements PreferredSizeWidget {
required this.onSubmitted,
required this.onClear,
required this.onClose,
required this.onConditions,
required this.conditionsFocusNode,
this.conditionsApplied = false,
this.conditionsOpen = false,
super.key,
});
@@ -30,6 +35,18 @@ class MailSearchBar extends StatelessWidget implements PreferredSizeWidget {
final VoidCallback onClear;
final VoidCallback onClose;
/// [조건] — 조건으로 찾기 시트를 연다
final VoidCallback onConditions;
/// [조건]의 초점 — 시트를 열기 전에 받아 두어, 시트가 닫히면 초점이 칸이 아니라 이 단추로 돌아온다(자판이 다시 올라오지 않게)
final FocusNode conditionsFocusNode;
/// 조건이 걸려 있다 — [조건]이 옅은 청록 원 · 낭독에 "조건이 걸려 있음"
final bool conditionsApplied;
/// 시트가 열려 있다 — [조건]이 옅은 청록 원 · 펼침 상태
final bool conditionsOpen;
@override
Size get preferredSize => const Size.fromHeight(AppSize.appBarHeight);
@@ -73,7 +90,7 @@ class MailSearchBar extends StatelessWidget implements PreferredSizeWidget {
smartDashesType: SmartDashesType.disabled,
smartQuotesType: SmartQuotesType.disabled,
inputFormatters: const <TextInputFormatter>[
_LineBreaksToSpaces(),
LineBreaksToSpacesFormatter(),
Utf16LengthLimitFormatter(kMailSearchMaxLength),
],
style: style,
@@ -107,14 +124,134 @@ class MailSearchBar extends StatelessWidget implements PreferredSizeWidget {
onPressed: onClear,
),
),
_ConditionsButton(
applied: conditionsApplied,
open: conditionsOpen,
focusNode: conditionsFocusNode,
onPressed: onConditions,
),
],
);
}
}
/// 붙여 넣은 줄바꿈 → 띄어쓰기 — 낱말을 나누는 글자로 받는다(쓰기 화면의 받는 사람 칸과 같은 결)
class _LineBreaksToSpaces extends TextInputFormatter {
const _LineBreaksToSpaces();
/// [조건] — 누르는 곳 48 안의 보이는 원 40(시트가 열려 있거나 조건이 걸려 있으면 옅은 청록 원 · 진한 청록 아이콘, 평소는 바탕 없음).
/// 화면 낭독기는 "조건으로 찾기"(걸려 있으면 ", 조건이 걸려 있음")를 펼침 상태(시트가 열려 있음)와 함께 읽는다 — 툴팁은 늘 "조건으로 찾기"
class _ConditionsButton extends StatelessWidget {
const _ConditionsButton({
required this.applied,
required this.open,
required this.focusNode,
required this.onPressed,
});
final bool applied;
final bool open;
final FocusNode focusNode;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final ColorScheme scheme = theme.colorScheme;
final bool active = applied || open;
return Semantics(
container: true,
button: true,
expanded: open,
label: MailSearchTexts.conditionsLabel(applied: applied),
onTap: onPressed,
child: ExcludeSemantics(
child: IconButton(
key: const ValueKey<String>('mail-search-conditions'),
focusNode: focusNode,
icon: const Icon(Icons.tune, size: AppSize.iconLarge),
tooltip: MailSearchTexts.conditions,
onPressed: onPressed,
style: IconButton.styleFrom(
minimumSize: const Size.square(AppSize.searchConditionsToggle),
fixedSize: const Size.square(AppSize.searchConditionsToggle),
padding: EdgeInsets.zero,
backgroundColor: active ? scheme.primaryContainer : null,
foregroundColor: active
? scheme.onPrimaryContainer
: theme.extension<AppExtraColors>()!.textStrong,
),
),
),
);
}
}
/// 걸린 조건 줄 — "조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음" + 오른쪽 [조건 지우기] (보드 `App1SearchConditions` — 최소 48 · 아래 선).
/// 글이 길면 낱말 단위로 줄을 바꾼다(13). [조건 지우기]는 걸린 조건을 모두 뺀다(부르는 쪽이 다시 찾는다)
class MailSearchConditionLine extends StatelessWidget {
const MailSearchConditionLine({
required this.text,
required this.onClear,
super.key,
});
final String text;
final VoidCallback onClear;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
return DecoratedBox(
decoration: BoxDecoration(
color: theme.colorScheme.surface,
border: Border(
bottom: BorderSide(
color: extra.borderSubtle,
width: AppSize.borderWidth,
),
),
),
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: AppSize.touchMin),
child: Padding(
padding: const EdgeInsets.only(
left: AppSpacing.s16,
right: AppSpacing.s8,
),
child: Row(
children: <Widget>[
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.s6),
child: KeepAllText(
text,
style: theme.textTheme.bodySmall?.copyWith(
color: extra.textStrong,
),
),
),
),
const SizedBox(width: AppSpacing.s8),
TextButton(
onPressed: onClear,
style: TextButton.styleFrom(
foregroundColor: theme.colorScheme.onPrimaryContainer,
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.s12,
),
),
child: const Text(MailSearchTexts.clearConditions),
),
],
),
),
),
);
}
}
/// 붙여 넣은 줄바꿈 → 띄어쓰기 — 낱말을 나누는 글자로 받는다(쓰기 화면의 받는 사람 칸과 같은 결).
/// 검색 칸 · 조건 시트의 글 칸이 쓴다 — 그래서 칸은 한 줄(`maxLines: 1`)이 아니다(한 줄 칸은 Flutter 가 줄바꿈을 먼저 지운다)
class LineBreaksToSpacesFormatter extends TextInputFormatter {
const LineBreaksToSpacesFormatter();
static final RegExp _lineBreaks = RegExp(r'\r\n|\r|\n');
File diff suppressed because it is too large Load Diff
@@ -36,17 +36,25 @@ class MailSearchIntro extends StatelessWidget {
}
}
/// 결과 없음 — "‘해지 통보’가 들어간 메일이 없습니다" · 도움말 · (색인을 준비하는 중이면) 옛 한계 한 줄 · (한 메일함에서 찾았으면) [전체 메일함에서 찾기]
/// 결과 없음 — "‘해지 통보’가 들어간 메일이 없습니다"(조건만으로 찾았으면 "조건에 맞는 메일이 없습니다") · 도움말 ·
/// (색인을 준비하는 중이면) 옛 한계 한 줄 · (한 메일함에서 찾았으면) [전체 메일함에서 찾기].
/// 도움말은 검색어 + 조건이면 "다른 말로 찾거나 조건을 줄여 보세요.", 조건만이면 "조건을 바꾸거나 줄여 보세요.",
/// 검색어만이면 "다른 말로 찾아 보세요."(걸린 조건이 없으니 조건을 줄이라고 하지 않는다 — 웹과 같다)
class MailSearchEmpty extends StatelessWidget {
const MailSearchEmpty({
required this.query,
required this.indexing,
required this.onSearchAll,
this.hasConditions = false,
super.key,
});
/// 찾은 검색어 — 조건만으로 찾았으면 빈 글
final String query;
/// 조건이 걸려 있었다
final bool hasConditions;
/// 지난 메일을 검색할 수 있게 준비하는 중 — 그동안 찾지 못하는 말의 예를 더한다
final bool indexing;
@@ -58,6 +66,11 @@ class MailSearchEmpty extends StatelessWidget {
final ThemeData theme = Theme.of(context);
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
final VoidCallback? searchAll = onSearchAll;
final String hint = !hasConditions
? MailSearchTexts.emptyWordsHint
: query.isEmpty
? MailSearchTexts.emptyConditionsHint
: MailSearchTexts.emptyHint;
return MailStateLayout(
icon: Icons.search,
iconColor: extra.textMuted,
@@ -65,7 +78,7 @@ class MailSearchEmpty extends StatelessWidget {
title: MailSearchTexts.emptyTitle(query),
titleStyle: _stateTitleStyle(theme),
children: <Widget>[
KeepAllText(MailSearchTexts.emptyHint, style: _stateHintStyle(theme)),
KeepAllText(hint, style: _stateHintStyle(theme)),
if (indexing) ...<Widget>[
const SizedBox(height: AppSpacing.s8),
KeepAllText(
@@ -28,6 +28,21 @@ void main() {
expect(AppTypography.bodyMedium, 15);
});
test('아래 시트의 가림막은 대화상자 가림막 색 · 날짜 고르는 창의 머리 글은 22 · 600', () {
expect(theme.bottomSheetTheme.modalBarrierColor, AppColors.backdrop);
expect(theme.colorScheme.scrim, AppColors.backdrop);
expect(
theme.datePickerTheme.headerHeadlineStyle?.fontSize,
AppTypography.headline,
);
expect(AppTypography.headline, 22);
expect(
theme.datePickerTheme.headerHeadlineStyle?.fontWeight,
AppTypography.semiBold,
);
expect(AppTypography.semiBold, FontWeight.w600);
});
test('단추의 누르는 곳은 48dp 이상이다', () {
expect(AppSize.touchMin, 48);
expect(
File diff suppressed because it is too large Load Diff
@@ -7,6 +7,7 @@ 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/domain/search_conditions.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';
@@ -141,7 +142,7 @@ void main() {
List<RequestOptions> searchRequests() => <RequestOptions>[
for (final RequestOptions r in mail.listRequests)
if (r.queryParameters.containsKey('q')) r,
if (FakeMailBackend.isSearchQuery(r.queryParameters)) r,
];
MessageSummary summaryOf(Map<String, Object?> json) =>
@@ -726,4 +727,166 @@ void main() {
expect(stateOf(args).total, 21);
});
});
group('조건 (2차 — 화면 상태에만 둔다)', () {
const SearchConditions fromDoyun = SearchConditions(
from: '이도윤',
hasAttachment: true,
);
test(
'시트의 [찾기]는 조건을 걸고 찾는다 — 빈 검색어는 싣지 않고, 이어 찾기 · 다시 찾기 · [다시 시도]도 걸린 조건 그대로',
() async {
mail.searchResponder = (Map<String, Object?> query) =>
query['cursor'] == null
? _page(_mails(20), next: 'c1', total: 30)
: _page(_mails(10, from: 21));
final MailSearchArgs args = await open();
expect(controllerOf(args).submit('', conditions: fromDoyun), isTrue);
await settle(args);
expect(stateOf(args).query, '');
expect(stateOf(args).conditions, fromDoyun);
expect(searchRequests().single.queryParameters, <String, Object?>{
'from': '이도윤',
'hasAttachment': 'true',
'mailboxId': kInboxId,
'limit': kMailPageSize,
});
await controllerOf(args).loadMore();
expect(searchRequests().last.queryParameters, <String, Object?>{
'from': '이도윤',
'hasAttachment': 'true',
'mailboxId': kInboxId,
'limit': kMailPageSize,
'cursor': 'c1',
});
await controllerOf(args).refresh();
await settle(args);
expect(searchRequests().last.queryParameters['from'], '이도윤');
expect(searchRequests().last.queryParameters.containsKey('q'), isFalse);
mail.searchResponder = (_) => FakeResponse.fail(503, 'MAIL_001');
controllerOf(args).submit('계약');
await settle(args);
expect(stateOf(args).phase, MailSearchPhase.failed);
mail.searchResponder = null;
await controllerOf(args).retry();
await settle(args);
expect(searchRequests().last.queryParameters['q'], '계약');
expect(searchRequests().last.queryParameters['from'], '이도윤');
},
);
test(
'같은 검색어 · 같은 조건이면 보이는 채로 다시 찾고, 조건이 다르면 처음부터 찾는다 — 조건은 다듬어 건다',
() async {
mail.messages[kInboxId] = _mails(2);
final MailSearchArgs args = await open();
controllerOf(
args,
).submit('계약', conditions: const SearchConditions(from: ' 이도윤 '));
expect(stateOf(args).conditions, const SearchConditions(from: '이도윤'));
await settle(args);
controllerOf(args).submit('계약');
expect(stateOf(args).refreshing, isTrue);
await settle(args);
controllerOf(
args,
).submit('계약', conditions: const SearchConditions(from: '박지훈'));
expect(stateOf(args).refreshing, isFalse);
expect(stateOf(args).phase, MailSearchPhase.searching);
await settle(args);
expect(searchRequests().last.queryParameters['from'], '박지훈');
},
);
test(
'[✕]는 걸린 조건을 남기고 · 빈 칸의 [검색]은 조건만으로 · 검색어도 조건도 없는 [찾기]는 요청 없이 찾기 전으로',
() async {
mail.messages[kInboxId] = _mails(2);
final MailSearchArgs args = await open();
controllerOf(args).submit('계약', conditions: fromDoyun);
await settle(args);
controllerOf(args).clear();
expect(stateOf(args).phase, MailSearchPhase.idle);
expect(stateOf(args).conditions, fromDoyun);
// 찾기 전에 찾을 곳만 바꾸면 곳만 바꿔 둔다
expect(controllerOf(args).setScope(all: true, fieldText: ''), isFalse);
expect(controllerOf(args).submit(' '), isTrue);
await settle(args);
expect(searchRequests().last.queryParameters, <String, Object?>{
'from': '이도윤',
'hasAttachment': 'true',
'limit': kMailPageSize,
});
final int before = searchRequests().length;
expect(
controllerOf(args).submit('', conditions: SearchConditions.none),
isTrue,
);
await pumpEventsUntil(() => true);
expect(stateOf(args).phase, MailSearchPhase.idle);
expect(stateOf(args).conditions.isEmpty, isTrue);
expect(searchRequests(), hasLength(before));
// 그 뒤의 빈 칸 [검색]은 아무 일도 없다
expect(controllerOf(args).submit(''), isFalse);
},
);
test(
'조건 줄의 [조건 지우기] — 칸의 글(비었으면 보이는 검색어)로 조건 없이 다시, 검색어가 없으면 요청 없이 찾기 전, 찾기 전이면 조건만 뺀다',
() async {
mail.messages[kInboxId] = _mails(2);
final MailSearchArgs args = await open();
controllerOf(args).submit('계약', conditions: fromDoyun);
await settle(args);
expect(controllerOf(args).clearConditions(fieldText: ''), isTrue);
await settle(args);
expect(searchRequests().last.queryParameters, <String, Object?>{
'q': '계약',
'mailboxId': kInboxId,
'limit': kMailPageSize,
});
expect(stateOf(args).conditions.isEmpty, isTrue);
controllerOf(args).submit('', conditions: fromDoyun);
await settle(args);
final int before = searchRequests().length;
controllerOf(args).clearConditions(fieldText: '');
await pumpEventsUntil(() => true);
expect(stateOf(args).phase, MailSearchPhase.idle);
expect(stateOf(args).conditions.isEmpty, isTrue);
expect(searchRequests(), hasLength(before));
controllerOf(args).submit('계약', conditions: fromDoyun);
await settle(args);
controllerOf(args).clear();
expect(controllerOf(args).clearConditions(fieldText: '견적'), isFalse);
expect(stateOf(args).phase, MailSearchPhase.idle);
expect(stateOf(args).conditions.isEmpty, isTrue);
expect(searchRequests(), hasLength(before + 1));
},
);
test('검색어와 걸린 조건의 낱말이 합쳐 8개를 넘으면 묻지 않는다 — 조건은 걸린 채', () async {
final MailSearchArgs args = await open();
controllerOf(
args,
).submit('', conditions: const SearchConditions(subject: '계약 갱신 확인'));
await settle(args);
final int before = searchRequests().length;
expect(controllerOf(args).submit('견적 단가 납품 일정 변경 요청'), isTrue);
await pumpEventsUntil(() => true);
expect(stateOf(args).phase, MailSearchPhase.tooManyWords);
expect(stateOf(args).conditions.subject, '계약 갱신 확인');
expect(searchRequests(), hasLength(before));
});
});
}
@@ -472,7 +472,7 @@ void main() {
expect(find.text(MailSearchTexts.count('해지 통보', 0)), findsOneWidget);
expect(findCopy(MailSearchTexts.emptyTitle('해지 통보')), findsOneWidget);
expect(findCopy(MailSearchTexts.emptyHint), findsOneWidget);
expect(findCopy(MailSearchTexts.emptyWordsHint), findsOneWidget);
expect(findCopy(MailSearchTexts.wordLimitHint), findsNothing);
await tester.tap(find.text(MailSearchTexts.searchAll));
@@ -1,6 +1,7 @@
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_conditions.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';
@@ -303,4 +304,80 @@ void main() {
expect(old.scopeMailboxId, isNull);
});
});
group('조건 (2차)', () {
const SearchConditions fromDoyun = SearchConditions(
from: '이도윤',
subject: '갱신',
since: SearchDay(2026, 10, 6),
hasAttachment: true,
);
test('조건만으로 찾았으면 머리는 "2통" · "찾는 중", 전체 수를 모르면 머리가 없다', () {
MailSearchState conditionsOnly({int? total, bool refreshing = false}) =>
MailSearchState(
phase: MailSearchPhase.ready,
query: '',
pageInfo: MailPageInfo(hasNext: false, total: total),
conditions: fromDoyun,
refreshing: refreshing,
);
expect(searchCountLineOf(conditionsOnly(total: 2)), '2통');
expect(
searchCountLineOf(conditionsOnly(total: 2, refreshing: true)),
'찾는 중',
);
expect(searchCountLineOf(conditionsOnly()), isNull);
});
test('한 글자 안내는 본문을 찾을 말(검색어 · 내용 조건)이 한 글자뿐일 때만', () {
MailSearchState ready(String query, SearchConditions conditions) =>
MailSearchState(
phase: MailSearchPhase.ready,
query: query,
conditions: conditions,
indexStatus: SearchIndexStatus.ready,
);
expect(
searchNoticeOf(ready('', const SearchConditions(body: '가'))),
MailSearchNotice.singleChar,
);
expect(
searchNoticeOf(ready('', const SearchConditions(from: '김'))),
isNull,
);
expect(
searchNoticeOf(ready('김', const SearchConditions(subject: '견적'))),
isNull,
);
});
test('결과에서 연 메일에는 검색어와 글 조건만 — 실을 칸이 없으면 강조 쿼리 없이', () {
const MailSearchState state = MailSearchState(
phase: MailSearchPhase.ready,
query: '계약',
searchedMailboxId: kInboxId,
pageInfo: MailPageInfo(hasNext: false, total: 1),
conditions: fromDoyun,
);
expect(
searchOpenOf(_item('m1', mailboxId: kInboxId), state).highlight,
const MessageHighlightQuery(q: '계약', from: '이도윤', subject: '갱신'),
);
const MailSearchState datesOnly = MailSearchState(
phase: MailSearchPhase.ready,
query: '',
searchedMailboxId: kInboxId,
pageInfo: MailPageInfo(hasNext: false, total: 1),
conditions: SearchConditions(
since: SearchDay(2026, 10, 6),
hasAttachment: true,
),
);
expect(
searchOpenOf(_item('m1', mailboxId: kInboxId), datesOnly).highlight,
isNull,
);
});
});
}
@@ -0,0 +1,456 @@
import 'package:arasystem_app/features/mail/data/models/mail_search.dart';
import 'package:arasystem_app/features/mail/domain/search_conditions.dart';
import 'package:arasystem_app/features/mail/presentation/mail_search_view.dart';
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
import 'package:flutter_test/flutter_test.dart';
/// 시험 날짜 — 'YYYY-MM-DD' 를 [SearchDay] 로
SearchDay _day(String text) {
final List<int> parts = text.split('-').map(int.parse).toList();
return SearchDay(parts[0], parts[1], parts[2]);
}
/// 걸린 조건 — 웹 spec 의 `conditions(partial)` 과 같은 모양
SearchConditions _conditions({
String from = '',
String to = '',
String subject = '',
String body = '',
String? since,
String? until,
bool hasAttachment = false,
}) => SearchConditions(
from: from,
to: to,
subject: subject,
body: body,
since: since == null ? null : _day(since),
until: until == null ? null : _day(until),
hasAttachment: hasAttachment,
);
/// KST(UTC+9)로 고정한 그날 0시 — 기기 시간대에 기대지 않고 after · before 를 본다
DateTime _kstMidnight(int year, int month, int day) =>
DateTime.utc(year, month, day).subtract(const Duration(hours: 9));
/// 조건 검색 (2차 — 계약 2절 "검색 — 14단계"). **짝: 웹 `frontend/src/shared/utils/mail-search.spec.ts`** 의 같은 경우를 옮겼다.
/// 오늘은 지역 시각(`DateTime(년, 월, 일)`)으로 넘긴다 — 시간대(UTC · KST)와 상관없이 같은 날이다
void main() {
group('조건 다듬기 · 같은 조건', () {
test('글 칸은 검색어처럼(앞뒤 공백 · 200자), 날짜는 있는 날짜만', () {
expect(
normalizeSearchConditions(
_conditions(
from: ' 이도윤 ',
subject: '가' * 250,
since: '2026-02-30',
until: '2026-11-05',
hasAttachment: true,
),
),
_conditions(
from: '이도윤',
subject: '가' * 200,
until: '2026-11-05',
hasAttachment: true,
),
);
expect(normalizeSearchConditions(null), SearchConditions.none);
});
test('날짜는 1970-01-01 ~ 9999-12-30 안의 있는 날짜만 — 밖의 날짜는 그 날짜만 버린다', () {
expect(isSearchDay(_day('1970-01-01')), isTrue);
expect(isSearchDay(_day('9999-12-30')), isTrue);
expect(isSearchDay(_day('1969-12-31')), isFalse);
// 9999-12-31 의 다음 날 0시는 10000년 — 서버가 받지 않는다
expect(isSearchDay(_day('9999-12-31')), isFalse);
expect(isSearchDay(_day('10000-01-01')), isFalse);
expect(isSearchDay(_day('2024-02-29')), isTrue);
expect(isSearchDay(_day('2026-02-29')), isFalse);
expect(
normalizeSearchConditions(
_conditions(since: '1969-12-31', until: '2026-11-01'),
),
_conditions(until: '2026-11-01'),
);
expect(
normalizeSearchConditions(
_conditions(since: '2026-11-01', until: '9999-12-31'),
),
_conditions(since: '2026-11-01'),
);
});
test('시작이 끝보다 늦은 기간은 통째로 버린다 — 같은 날은 그 하루', () {
expect(
normalizeSearchConditions(
_conditions(
since: '2026-11-06',
until: '2026-10-01',
hasAttachment: true,
),
),
_conditions(hasAttachment: true),
);
expect(
normalizeSearchConditions(
_conditions(since: '2026-11-06', until: '2026-11-06'),
),
_conditions(since: '2026-11-06', until: '2026-11-06'),
);
});
test('조건이 하나라도 있는가 · 같은 조건인가 — 빈 조건은 조건 없음과 같다', () {
expect(SearchConditions.none.isEmpty, isTrue);
expect(_conditions().isEmpty, isTrue);
expect(_conditions(hasAttachment: true).isNotEmpty, isTrue);
expect(_conditions(until: '2026-11-05').isNotEmpty, isTrue);
expect(_conditions(), SearchConditions.none);
expect(_conditions(from: '이도윤'), _conditions(from: '이도윤'));
expect(_conditions(from: '이도윤'), isNot(_conditions(to: '이도윤')));
expect(_day('2026-11-05').toString(), '2026-11-05');
});
});
group('API 쿼리 · 읽기 화면의 강조 쿼리', () {
test(
'기간은 시작 날짜 0시 · 끝 날짜 다음 날 0시를 기기 시간대에서 UTC 로 — 첨부는 hasAttachment=true',
() {
final SearchConditions value = _conditions(
to: 'ara.kim',
subject: '견적',
since: '2026-10-06',
until: '2026-11-06',
hasAttachment: true,
);
expect(
searchConditionsParams(value, midnight: _kstMidnight),
<String, String>{
'to': 'ara.kim',
'subject': '견적',
'after': '2026-10-05T15:00:00.000Z',
'before': '2026-11-06T15:00:00.000Z',
'hasAttachment': 'true',
},
);
// 기본은 기기 시간대 — 그날 0시의 UTC (웹 spec 의 localMidnightIso 와 같은 셈)
expect(searchConditionsParams(value)['after'], _localIso(2026, 10, 6));
expect(searchConditionsParams(value)['before'], _localIso(2026, 11, 7));
},
);
test('시작만 · 끝만도 된다 — 끝 날짜가 그 달 · 그 해의 마지막 날이면 다음 달 · 해로 넘긴다', () {
expect(
searchConditionsParams(
_conditions(until: '2026-12-31'),
midnight: _kstMidnight,
),
<String, String>{'before': '2026-12-31T15:00:00.000Z'},
);
expect(
searchConditionsParams(_conditions(until: '2026-12-31')),
<String, String>{'before': _localIso(2027, 1, 1)},
);
expect(
searchConditionsParams(
_conditions(since: '2026-03-01'),
midnight: _kstMidnight,
),
<String, String>{'after': '2026-02-28T15:00:00.000Z'},
);
expect(searchConditionsParams(SearchConditions.none), isEmpty);
});
test('읽기 화면에는 검색어와 글 칸 넷만 넘긴다 — 기간 · 첨부는 서버가 받지 않는다', () {
final SearchConditions value = _conditions(
from: '이도윤',
body: '계약',
since: '2026-10-06',
hasAttachment: true,
);
expect(
messageHighlightQueryOf('갱신', value),
const MessageHighlightQuery(q: '갱신', from: '이도윤', body: '계약'),
);
expect(
messageHighlightQueryOf('', _conditions(subject: '견적')),
const MessageHighlightQuery(subject: '견적'),
);
expect(
messageHighlightQueryOf(
'',
_conditions(since: '2026-10-06', hasAttachment: true),
),
isNull,
);
expect(
messageHighlightQueryOf(
'계약',
SearchConditions.none,
)!.toQueryParameters(),
<String, String>{'q': '계약'},
);
});
});
group('합친 낱말 수 — 검색어와 조건 넷을 합쳐 8개까지 (서버와 같은 기준)', () {
test('띄어쓰기로 나눈 조각을 칸마다 세어 더한다 — 날짜 · 첨부는 낱말이 아니다', () {
final SearchConditions value = _conditions(
from: '이도윤',
to: '김아라 박지훈',
subject: '"계약 갱신"',
body: '단가',
);
expect(combinedSearchWords('견적 회의', value), 8);
expect(exceedsCombinedSearchWords('견적 회의', value), isFalse);
expect(exceedsCombinedSearchWords('견적 회의 일정', value), isTrue);
expect(
combinedSearchWords(
'',
_conditions(since: '2026-10-06', hasAttachment: true),
),
0,
);
});
});
group('기간 고르기', () {
final DateTime today = DateTime(2026, 11, 6, 15, 30);
test('최근 1주 · 1달 · 3달 · 1년은 그만큼 앞의 같은 날부터 오늘까지', () {
expect(periodRange(SearchPeriod.week, today), (
since: _day('2026-10-30'),
until: _day('2026-11-06'),
));
expect(periodRange(SearchPeriod.month, today), (
since: _day('2026-10-06'),
until: _day('2026-11-06'),
));
expect(periodRange(SearchPeriod.quarter, today), (
since: _day('2026-08-06'),
until: _day('2026-11-06'),
));
expect(periodRange(SearchPeriod.year, today), (
since: _day('2025-11-06'),
until: _day('2026-11-06'),
));
expect(periodRange(SearchPeriod.all, today), (since: null, until: null));
expect(periodRange(SearchPeriod.custom, today), (
since: null,
until: null,
));
});
test('달은 그 달의 마지막 날을 넘지 않는다 — 3월 31일의 1달 앞은 2월 28일(윤년이면 29일)', () {
expect(
periodRange(SearchPeriod.month, DateTime(2026, 3, 31)).since,
_day('2026-02-28'),
);
expect(
periodRange(SearchPeriod.month, DateTime(2028, 3, 31)).since,
_day('2028-02-29'),
);
expect(
periodRange(SearchPeriod.quarter, DateTime(2026, 5, 31)).since,
_day('2026-02-28'),
);
expect(
periodRange(SearchPeriod.week, DateTime(2026, 1, 3)).since,
_day('2025-12-27'),
);
expect(
periodRange(SearchPeriod.year, DateTime(2028, 2, 29)).since,
_day('2027-02-28'),
);
});
test('두 날짜가 오늘 기준의 미리 정한 기간과 같으면 그 고르기, 아니면 날짜 고르기', () {
expect(periodOf(null, null, today), SearchPeriod.all);
expect(
periodOf(_day('2026-10-06'), _day('2026-11-06'), today),
SearchPeriod.month,
);
expect(
periodOf(_day('2026-10-30'), _day('2026-11-06'), today),
SearchPeriod.week,
);
// 다음 날 보면 같은 날짜도 날짜 고르기다
expect(
periodOf(_day('2026-10-06'), _day('2026-11-06'), DateTime(2026, 11, 7)),
SearchPeriod.custom,
);
expect(periodOf(_day('2026-10-01'), null, today), SearchPeriod.custom);
});
test('시작이 끝보다 늦으면 거꾸로다 — 같은 날 · 한쪽만 있으면 아니다', () {
expect(isReversedPeriod(_day('2026-11-05'), _day('2026-10-01')), isTrue);
expect(isReversedPeriod(_day('2026-10-01'), _day('2026-10-01')), isFalse);
expect(isReversedPeriod(_day('2026-11-05'), null), isFalse);
expect(isReversedPeriod(null, _day('2026-10-01')), isFalse);
});
});
group('조건 줄 글', () {
final DateTime today = DateTime(2026, 11, 6);
test('"조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음" — 쉼표로 잇는다', () {
expect(
searchConditionsSummary(
_conditions(
from: '이도윤',
since: '2026-10-06',
until: '2026-11-06',
hasAttachment: true,
),
today,
),
'조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음',
);
});
test('받는 사람 · 제목 · 내용은 조사를 붙인다 — 날짜 고르기는 날짜로(같은 해면 끝 날짜의 해를 뺀다)', () {
expect(
searchConditionsSummary(
_conditions(
to: '김아라',
subject: '견적',
body: '계약서',
since: '2026-10-01',
until: '2026-11-05',
),
today,
),
'조건: 김아라 님이 받은 메일, 제목에 ‘견적’이 든 메일, 내용에 ‘계약서’가 든 메일, 2026. 10. 1.부터 11. 5.까지',
);
expect(
searchConditionsSummary(
_conditions(since: '2025-12-01', until: '2026-01-05'),
today,
),
'조건: 2025. 12. 1.부터 2026. 1. 5.까지',
);
expect(
searchConditionsSummary(_conditions(since: '2026-10-01'), today),
'조건: 2026. 10. 1.부터',
);
expect(
searchConditionsSummary(_conditions(until: '2026-10-31'), today),
'조건: 2026. 10. 31.까지',
);
// 소리를 알 수 없는 끝 글자(라틴)는 "이(가)"
expect(
searchConditionsSummary(_conditions(subject: 'ERP'), today),
'조건: 제목에 ‘ERP’이(가) 든 메일',
);
expect(searchConditionsSummary(SearchConditions.none, today), '');
});
test('하루만 고른 기간은 "2026. 11. 6. 하루" — 같은 날을 부터 · 까지로 두 번 쓰지 않는다', () {
expect(
searchConditionsSummary(
_conditions(since: '2026-11-06', until: '2026-11-06'),
today,
),
'조건: 2026. 11. 6. 하루',
);
expect(
searchConditionsSummary(
_conditions(since: '2025-12-31', until: '2025-12-31'),
today,
),
'조건: 2025. 12. 31. 하루',
);
});
test(
'보낸 사람 · 받는 사람이 내 이름이나 주소면 "내가 보낸 메일" · "내가 받은 메일" — 대소문자 · 앞뒤 공백은 가리지 않고, 일부만 같으면 아니다',
() {
const SearchConditionsMe me = (
name: '김아라',
email: 'ara.kim@arasystem.test',
);
expect(
searchConditionsSummary(
_conditions(from: '김아라', to: ' ARA.KIM@arasystem.test '),
today,
me: me,
),
'조건: 내가 보낸 메일, 내가 받은 메일',
);
expect(
searchConditionsSummary(
_conditions(from: 'ara.kim@arasystem.test'),
today,
me: me,
),
'조건: 내가 보낸 메일',
);
expect(
searchConditionsSummary(
_conditions(from: '아라', to: 'ara.kim'),
today,
me: me,
),
'조건: 아라 님이 보낸 메일, ara.kim 님이 받은 메일',
);
// 누구인지 모르면 지금 글 그대로
expect(
searchConditionsSummary(_conditions(from: '김아라'), today),
'조건: 김아라 님이 보낸 메일',
);
},
);
test('"나"는 풀어 쓴 한글(맥에서 온 글)도 모아서 견준다 — NFC', () {
// 김아라 를 첫소리 · 가운뎃소리 · 끝소리 자모로 풀어 쓴 글
final String decomposed = String.fromCharCodes(<int>[
0x1100, 0x1175, 0x11B7, // 김
0x110B, 0x1161, // 아
0x1105, 0x1161, // 라
]);
expect(decomposed, isNot('김아라'));
expect(
isSearchConditionMe(decomposed, (name: '김아라', email: 'ara@x.test')),
isTrue,
);
expect(
isSearchConditionMe('김아', (name: '김아라', email: 'ara@x.test')),
isFalse,
);
expect(isSearchConditionMe('김아라', null), isFalse);
expect(
isSearchConditionMe(' ', (name: '', email: 'ara@x.test')),
isFalse,
);
});
test('날짜 칸 · 조건 줄의 날짜는 "2026. 11. 7." — 해를 뺄 수 있다', () {
expect(formatSearchDay(_day('2026-11-07')), '2026. 11. 7.');
expect(formatSearchDay(_day('2026-01-05'), withYear: false), '1. 5.');
expect(MailSearchTexts.period(SearchPeriod.quarter), '최근 3달');
});
});
group('한 글자 안내 — 본문을 찾을 말이 한 글자뿐일 때만', () {
test('검색어나 내용 조건이 모두 한 글자면 그렇다', () {
expect(skipsBodySearch('김', SearchConditions.none), isTrue);
expect(skipsBodySearch('', _conditions(body: '가')), isTrue);
expect(skipsBodySearch('김', _conditions(from: '이')), isTrue);
});
test('두 글자 이상 낱말이 어느 칸에든 있거나, 본문을 찾을 말이 없으면 아니다', () {
expect(skipsBodySearch('김 계약', SearchConditions.none), isFalse);
expect(skipsBodySearch('김', _conditions(subject: '견적')), isFalse);
// 보낸 사람 · 제목 조건만이면 본문은 원래 찾지 않는다
expect(skipsBodySearch('', _conditions(from: '김')), isFalse);
expect(
skipsBodySearch('', _conditions(subject: '가', since: '2026-10-01')),
isFalse,
);
});
});
}
/// 기기 시간대의 그날 0시를 UTC ISO 로 — 시험이 도는 곳의 시간대와 상관없이 앱과 같은 셈
String _localIso(int year, int month, int day) =>
DateTime(year, month, day).toUtc().toIso8601String();
+58 -6
View File
@@ -196,10 +196,26 @@ class FakeMailBackend {
'total': 10,
});
/// 검색 요청의 쿼리 이름 — 이 가운데 하나라도 있으면 검색이다(검색어 또는 조건 — 계약 2절 "검색 — 14단계")
static const Set<String> searchKeys = <String>{
'q',
'from',
'to',
'subject',
'body',
'after',
'before',
'hasAttachment',
};
/// 검색 요청인가 — 메일함 목록 요청과 가른다
static bool isSearchQuery(Map<String, Object?> query) =>
query.keys.any(searchKeys.contains);
/// 받은 검색 요청의 쿼리 (차례대로)
List<Map<String, Object?>> get searchQueries => <Map<String, Object?>>[
for (final RequestOptions request in listRequests)
if (request.queryParameters.containsKey('q')) request.queryParameters,
if (isSearchQuery(request.queryParameters)) request.queryParameters,
];
/// 받은 `GET /search-index` 의 수
@@ -256,7 +272,7 @@ class FakeMailBackend {
});
});
server.on('GET', MailApiPaths.messages, (RequestOptions options) async {
if (options.queryParameters.containsKey('q')) {
if (isSearchQuery(options.queryParameters)) {
final Completer<void>? gate = searchGate;
if (gate != null) {
searchGate = null;
@@ -360,9 +376,10 @@ class FakeMailBackend {
/// 기본 검색 — 찾을 곳(메일함 · 없으면 휴지통 · 스팸메일함 밖 모두)의 메일 가운데 제목 · 미리 보기에 q 가 든 것을
/// 받은 시각 내림차순(같으면 id 내림차순 — 서버 색인과 같다)으로 한 쪽씩. 커서는 그 쪽 끝 줄의 자리([searchCursorAfter]) —
/// 서버처럼 그 줄 **다음**부터 준다(그사이 앞쪽 메일이 빠지거나 되돌아와도 다음 쪽은 밀리지도 겹치지도 않는다). 전체 수는 첫 쪽에만 싣는다.
/// 조건([_matchesConditions] — 보낸 사람 · 받는 사람 · 제목 · 내용(미리 보기로 대신) · 받은 시각 · 첨부)도 거른다. q 가 없으면 조건만으로.
/// [searchResponder] 가 쪽마다 이것을 불러 섞어 쓸 수 있다
FakeResponse defaultSearch(Map<String, Object?> query) {
final String q = query['q']! as String;
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
@@ -380,8 +397,10 @@ class FakeMailBackend {
pool
.where(
(Map<String, Object?> m) =>
(m['subject']! as String).contains(q) ||
(m['preview']! as String).contains(q),
(q == null ||
(m['subject']! as String).contains(q) ||
(m['preview']! as String).contains(q)) &&
_matchesConditions(m, query),
)
.toList()
..sort((Map<String, Object?> a, Map<String, Object?> b) {
@@ -416,6 +435,39 @@ class FakeMailBackend {
});
}
/// 기본 검색의 조건 — 글 조건은 그 칸에 그 글이 들었는지(보낸 사람 · 받는 사람은 이름이나 주소, 내용은 미리 보기로 대신),
/// 기간은 받은 시각이 `after` 이상 · `before` 미만(둘 다 UTC ISO 라 글로 견준다), 첨부는 `hasAttachment=true` 일 때 있는 메일만
static bool _matchesConditions(
Map<String, Object?> message,
Map<String, Object?> query,
) {
bool contains(Object? value, Object? text) =>
text is! String || (value is String && value.contains(text));
bool person(Object? address, Object? text) =>
address is Map<String, Object?> &&
(contains(address['name'], text) || contains(address['email'], text));
final Object? from = query['from'];
if (from != null && !person(message['from'], from)) return false;
final Object? to = query['to'];
if (to != null &&
!(message['to']! as List<Object?>).any(
(Object? address) => person(address, to),
)) {
return false;
}
if (!contains(message['subject'], query['subject'])) return false;
if (!contains(message['preview'], query['body'])) return false;
final String receivedAt = message['receivedAt']! as String;
final Object? after = query['after'];
if (after is String && receivedAt.compareTo(after) < 0) return false;
final Object? before = query['before'];
if (before is String && receivedAt.compareTo(before) >= 0) return false;
if (query['hasAttachment'] == 'true' && message['hasAttachment'] != true) {
return false;
}
return true;
}
/// 기본 검색의 커서 — [message] 줄 다음부터(`s:<받은 시각>|<id>` — 서버 색인 커서처럼 받은 시각 · id 가 더 작은 쪽)
static String searchCursorAfter(Map<String, Object?> message) =>
's:${message['receivedAt']}|${message['id']}';
@@ -463,7 +515,7 @@ class FakeMailBackend {
final Map<String, Object?>? detail = detailJsonOf(
id,
showImages: images == kMailImagesShow,
highlighted: options.queryParameters.containsKey('q'),
highlighted: isSearchQuery(options.queryParameters),
);
return detail == null
? FakeResponse.fail(404, 'RES_001')