generated from ttipo/project-base
feat(flutter): 앱 첫 판 화면 — 로그인 · 메일 목록 · 읽기 · 쓰기 · 검색 · 새 메일 알림 #21
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user