From facf9cd9361ee932f688a119e66cb4687a0b0f36 Mon Sep 17 00:00:00 2001 From: ttipo Date: Sat, 7 Nov 2026 19:00:15 +0900 Subject: [PATCH] =?UTF-8?q?feat(flutter):=20=EB=A9=94=EC=9D=BC=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=202=EC=B0=A8=20=E2=80=94=20=EC=A1=B0=EA=B1=B4?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=B0=BE=EA=B8=B0=20=EC=8B=9C=ED=8A=B8?= =?UTF-8?q?=EC=99=80=20=EA=B2=B0=EA=B3=BC=EC=9D=98=20=EC=A1=B0=EA=B1=B4=20?= =?UTF-8?q?=EC=A4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-11-07 확정한 보드 App1SearchConditions 대로 웹 14단계의 [조건] 패널을 바닥 시트로 옮겼다. 보낸 사람 · 받는 사람 · 제목 · 내용 · 기간 · 첨부로 찾고, 결과에는 "조건: …" 줄과 [조건 지우기]를 둔다. 조건 다듬기 · 합친 낱말 수 · 기간 · 날짜의 UTC 시각 · 강조 쿼리 · "나"는 웹 mail-search.ts 와 짝이다. [조건]은 위 막대 맨 오른쪽에 고정해 글을 써도 옆으로 밀리지 않게 했고, 시트에서 낱말 8개를 넘기면 시트를 연 채 알린다. 날짜는 오늘까지만 고른다. 바닥 시트 가림막을 앱 대화상자 색으로 맞춰 메일함 옮기기 시트도 같은 색이 된다. 리뷰에서 가로 화면에 자판이 오르면 시트 모양이 바뀌며 칸이 새로 만들어져 초점을 잃는 것을 찾았다. 시트를 한 트리로 두고 같은 레이아웃 안에서 머리 · 단추 줄의 실제 높이로 모양 셋(위를 남긴 고정 · 화면 위까지 고정 · 한 스크롤)을 고르게 고쳤고, 모양이 바뀌거나 알림이 뜨면 초점 칸 · 알림을 보이게 한다. 시험 872 → 932. Co-Authored-By: Claude Opus 5.5 --- flutter/lib/core/theme/app_spacing.dart | 14 + flutter/lib/core/theme/app_theme.dart | 11 + .../data/repositories/mail_repository.dart | 8 +- .../mail/domain/search_conditions.dart | 314 ++++ .../mail/presentation/mail_search_view.dart | 84 +- .../mail/presentation/mail_texts.dart | 126 +- .../presentation/pages/mail_search_page.dart | 60 +- .../providers/mail_providers.dart | 11 + .../providers/mail_search_controller.dart | 110 +- .../providers/mail_search_state.dart | 11 +- .../presentation/widgets/mail_search_bar.dart | 147 +- .../widgets/mail_search_conditions_sheet.dart | 1229 +++++++++++++++ .../widgets/mail_search_states.dart | 17 +- flutter/test/core/theme/app_theme_test.dart | 15 + .../mail/mail_search_conditions_test.dart | 1331 +++++++++++++++++ .../mail/mail_search_controller_test.dart | 165 +- .../features/mail/mail_search_page_test.dart | 2 +- .../features/mail/mail_search_view_test.dart | 77 + .../features/mail/search_conditions_test.dart | 456 ++++++ flutter/test/support/mail_fakes.dart | 64 +- 20 files changed, 4170 insertions(+), 82 deletions(-) create mode 100644 flutter/lib/features/mail/domain/search_conditions.dart create mode 100644 flutter/lib/features/mail/presentation/widgets/mail_search_conditions_sheet.dart create mode 100644 flutter/test/features/mail/mail_search_conditions_test.dart create mode 100644 flutter/test/features/mail/search_conditions_test.dart diff --git a/flutter/lib/core/theme/app_spacing.dart b/flutter/lib/core/theme/app_spacing.dart index fe7c354..f093048 100644 --- a/flutter/lib/core/theme/app_spacing.dart +++ b/flutter/lib/core/theme/app_spacing.dart @@ -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; } diff --git a/flutter/lib/core/theme/app_theme.dart b/flutter/lib/core/theme/app_theme.dart index 2f4f40b..0f4e81f 100644 --- a/flutter/lib/core/theme/app_theme.dart +++ b/flutter/lib/core/theme/app_theme.dart @@ -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.light], ); } diff --git a/flutter/lib/features/mail/data/repositories/mail_repository.dart b/flutter/lib/features/mail/data/repositories/mail_repository.dart index 6c875c0..ceb7da7 100644 --- a/flutter/lib/features/mail/data/repositories/mail_repository.dart +++ b/flutter/lib/features/mail/data/repositories/mail_repository.dart @@ -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> searchMessages({ required String q, + SearchConditions conditions = SearchConditions.none, String? mailboxId, String? cursor, int limit = kMailPageSize, @@ -117,7 +120,8 @@ class MailRepository { final Response response = await _dio.get( MailApiPaths.messages, queryParameters: { - 'q': q, + if (q.isNotEmpty) 'q': q, + ...searchConditionsParams(conditions), 'mailboxId': ?mailboxId, 'limit': limit, 'cursor': ?cursor, diff --git a/flutter/lib/features/mail/domain/search_conditions.dart b/flutter/lib/features/mail/domain/search_conditions.dart new file mode 100644 index 0000000..ccd9af0 --- /dev/null +++ b/flutter/lib/features/mail/domain/search_conditions.dart @@ -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 { + 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 (웹 `` 의 값과 같은 모양 — 시험 · 진단용) + @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 searchConditionsParams( + SearchConditions conditions, { + SearchMidnight midnight = _deviceMidnight, +}) { + final SearchDay? since = conditions.since; + final SearchDay? until = conditions.until; + return { + 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([ + 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); +} diff --git a/flutter/lib/features/mail/presentation/mail_search_view.dart b/flutter/lib/features/mail/presentation/mail_search_view.dart index 1d95cbf..5515b75 100644 --- a/flutter/lib/features/mail/presentation/mail_search_view.dart +++ b/flutter/lib/features/mail/presentation/mail_search_view.dart @@ -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([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 parts = [ + 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)}'; +} diff --git a/flutter/lib/features/mail/presentation/mail_texts.dart b/flutter/lib/features/mail/presentation/mail_texts.dart index f0b798b..2ac2c16 100644 --- a/flutter/lib/features/mail/presentation/mail_texts.dart +++ b/flutter/lib/features/mail/presentation/mail_texts.dart @@ -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` 「메일함 서랍」) diff --git a/flutter/lib/features/mail/presentation/pages/mail_search_page.dart b/flutter/lib/features/mail/presentation/pages/mail_search_page.dart index 6ce8ff1..3b19957 100644 --- a/flutter/lib/features/mail/presentation/pages/mail_search_page.dart +++ b/flutter/lib/features/mail/presentation/pages/mail_search_page.dart @@ -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 { 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 { void dispose() { _field.dispose(); _focus.dispose(); + _conditionsFocus.dispose(); super.dispose(); } // ── 하는 일 ── - /// 자판의 [검색] — 빈 칸 · 공백뿐이면 자판을 그대로 둔다 + /// 자판의 [검색] — 빈 칸 · 공백뿐이고 걸린 조건도 없으면 자판을 그대로 둔다 void _submit(String text) { if (_controller.submit(text)) _focus.unfocus(); } + /// [조건] — 시트를 연다. [찾기]가 넘긴 조건을 걸고 칸의 지금 글로 찾는다(닫기만 했으면 아무 일 없다) + Future _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 { }); 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 { 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 { 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, ), diff --git a/flutter/lib/features/mail/presentation/providers/mail_providers.dart b/flutter/lib/features/mail/presentation/providers/mail_providers.dart index d37d490..bc49b2f 100644 --- a/flutter/lib/features/mail/presentation/providers/mail_providers.dart +++ b/flutter/lib/features/mail/presentation/providers/mail_providers.dart @@ -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 mailAccountProvider = Provider((Ref ref) { ? session.info.email : null; }); + +/// 메일을 보는 직원의 이름 · 주소 — 검색 조건 줄이 "내가 보낸 메일"을 알아본다(웹은 `authStore.user`). +/// [mailAccountProvider] 와 같은 때만 있고 그 밖에는 null +final Provider mailSelfProvider = + Provider((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; + }); diff --git a/flutter/lib/features/mail/presentation/providers/mail_search_controller.dart b/flutter/lib/features/mail/presentation/providers/mail_search_controller.dart index 3d48e89..a4ef563 100644 --- a/flutter/lib/features/mail/presentation/providers/mail_search_controller.dart +++ b/flutter/lib/features/mail/presentation/providers/mail_search_controller.dart @@ -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 { // ── 하는 일 ───────────────────────────────────────── - /// 자판의 [검색] — 빈 칸 · 공백뿐이면 아무 일도 하지 않고 거짓(자판도 그대로 둔다). - /// 낱말이 한도를 넘으면 묻지 않는다. 같은 검색어 · 같은 곳의 결과가 보이면 보이는 채로 다시 찾는다 - 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 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 { } } - /// 받지 못함의 [다시 시도] — 처음부터 다시 찾는다 + /// 받지 못함의 [다시 시도] — 처음부터 다시 찾는다(걸린 조건 그대로) Future 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 { // ── 찾기 ────────────────────────────────────────── - /// 첫 쪽을 묻고 이어 찾는다 — [keepVisible] 이면 보이는 결과를 둔 채(실패하면 그대로 두고 인터셉터가 알린다), + /// 첫 쪽을 묻고 이어 찾는다([q] 가 비면 [conditions] 만으로) — [keepVisible] 이면 보이는 결과를 둔 채(실패하면 그대로 두고 인터셉터가 알린다), /// 아니면 뼈대부터(처음 찾는 쪽은 알리지 않는다 — 화면이 받지 못함을 그린다) - Future _search(String q, {bool keepVisible = false}) async { + Future _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 { refreshing: true, fill: MailSearchFill.none, loadMoreFailed: false, + conditions: conditions, ) : MailSearchState( phase: MailSearchPhase.searching, @@ -277,6 +319,7 @@ class MailSearchController extends Notifier { query: q, searchedAll: all, indexStatus: state.indexStatus, + conditions: conditions, ); unawaited(_refreshIndexStatus()); String? mailboxId; @@ -298,6 +341,7 @@ class MailSearchController extends Notifier { try { final MailPage page = await _repository.searchMessages( q: q, + conditions: conditions, mailboxId: mailboxId, silent: !keepVisible, cancelToken: cancel, @@ -316,6 +360,7 @@ class MailSearchController extends Notifier { items: List.unmodifiable(items), pageInfo: page.pageInfo, indexStatus: state.indexStatus, + conditions: conditions, ); await _fillRound( seq, @@ -365,6 +410,7 @@ class MailSearchController extends Notifier { try { page = await _repository.searchMessages( q: current.query!, + conditions: current.conditions, mailboxId: current.searchedMailboxId, cursor: cursor, silent: beforeAny, diff --git a/flutter/lib/features/mail/presentation/providers/mail_search_state.dart b/flutter/lib/features/mail/presentation/providers/mail_search_state.dart index d9e249a..1e36745 100644 --- a/flutter/lib/features/mail/presentation/providers/mail_search_state.dart +++ b/flutter/lib/features/mail/presentation/providers/mail_search_state.dart @@ -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, ); } diff --git a/flutter/lib/features/mail/presentation/widgets/mail_search_bar.dart b/flutter/lib/features/mail/presentation/widgets/mail_search_bar.dart index 6d15ec7..3682ce8 100644 --- a/flutter/lib/features/mail/presentation/widgets/mail_search_bar.dart +++ b/flutter/lib/features/mail/presentation/widgets/mail_search_bar.dart @@ -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 [ - _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('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()!.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()!; + 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: [ + 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'); diff --git a/flutter/lib/features/mail/presentation/widgets/mail_search_conditions_sheet.dart b/flutter/lib/features/mail/presentation/widgets/mail_search_conditions_sheet.dart new file mode 100644 index 0000000..7949d00 --- /dev/null +++ b/flutter/lib/features/mail/presentation/widgets/mail_search_conditions_sheet.dart @@ -0,0 +1,1229 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/theme/app_theme.dart'; +import 'package:arasystem_app/core/theme/app_typography.dart'; +import 'package:arasystem_app/core/utils/clock.dart'; +import 'package:arasystem_app/core/utils/utf16_length.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/mail_search_view.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/mail_search_bar.dart'; +import 'package:arasystem_app/shared/widgets/keep_all_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; + +/// 「조건으로 찾기」 시트를 연다 — 아래에서 올라와 탭 막대까지 덮는다(보드 `App1SearchConditions` 14 · 15). +/// +/// [applied](걸린 조건)로 칸을 채운 사본을 고친다. [찾기]가 넘긴 조건(다듬은 것)을 돌려주고, +/// [찾기] 없이 닫으면([✕] · 뒤로 · 가림막 · 끌어 내림) null — 고친 값은 버린다(다시 열면 걸린 조건으로 다시 채운다). +/// [searchText] 는 검색 칸의 지금 글 — 낱말 수를 조건과 합쳐 센다. [clock] 으로 오늘을 센다(최근 N · 날짜 고르는 창) +Future showSearchConditionsSheet( + BuildContext context, { + required SearchConditions applied, + required String searchText, + required Clock clock, +}) { + final ThemeData theme = Theme.of(context); + return showModalBottomSheet( + context: context, + useRootNavigator: true, + useSafeArea: true, + isScrollControlled: true, + backgroundColor: theme.colorScheme.surface, + clipBehavior: Clip.antiAlias, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xxl)), + ), + builder: (BuildContext sheetContext) => MailSearchConditionsSheet( + applied: applied, + searchText: searchText, + clock: clock, + ), + ); +} + +/// 시트의 글 칸 넷 (보드의 차례) +enum _TextCondition { from, to, subject, body } + +/// 날짜 칸 둘 +enum _DayEdge { since, until } + +/// 「조건으로 찾기」 시트 (보드 `App1SearchConditions` · 웹 `MailSearchConditions` · `MailSearchConditionFields` · `MailSearchPeriodField` · +/// `useSearchConditionsDraft`). +/// +/// - 머리(제목 · [✕])와 아래 단추 줄([조건 지우기] · [찾기])은 고정이고 가운데만 스크롤한다. 가운데가 스크롤되면 머리 아래에 선. +/// 자판이 올라오면 시트도 함께 올라간다. 자리가 모자라면(가운데가 칸 하나보다 낮아짐 — 가로 화면에 자판 · 큰 글자 · 낱말 알림) +/// 위 96 을 남기지 않고 시트 전체를 한 스크롤로 둔다(머리 · 단추 줄도 함께 움직인다 — [_SheetBody]). +/// - 글 칸 넷의 자판은 검색 칸과 같은 결(글 자판 · 자동 고침 끔 · 둥근 따옴표 · 대시 끔 · 줄바꿈은 띄어쓰기 · UTF-16 200자), +/// 칸의 [검색] 키는 [찾기]와 같다. +/// - 기간: 미리 정한 기간은 넘길 때 그 날짜(그만큼 앞의 같은 날 ~ 오늘)로 바꾼다. 열 때는 날짜가 오늘 기준의 미리 정한 기간과 같으면 그 이름으로 보인다. +/// 미리 정한 기간에서 날짜 고르기로 바꾸면 빈 날짜 칸을 그 기간의 날짜로 채워 둔다. 두 날짜는 서로 막지 않고, 시작이 끝보다 늦으면 +/// 끝 날짜 칸을 빨갛게 하고 두 칸 아래에 알린다(고른 즉시). +/// - [조건 지우기]: 칸만 비운다(기간 · 날짜 · 첨부 · 칸 아래 알림까지) — 시트는 열린 채, 찾지 않고, 초점은 그 단추에(웹 2026-11-07). +/// - [찾기]: 날짜가 거꾸로면 넘기지 않고 끝 날짜 칸으로 스크롤 · 초점. 검색 칸의 글과 조건의 낱말이 합쳐 8개를 넘으면 시트를 연 채 +/// 단추 줄 위에 알린다(낱말을 고치면 지운다). 통과하면 다듬은 조건을 돌려주고 닫는다 +class MailSearchConditionsSheet extends StatefulWidget { + const MailSearchConditionsSheet({ + required this.applied, + required this.searchText, + required this.clock, + super.key, + }); + + /// 걸린 조건 — 열 때 칸을 채운다 + final SearchConditions applied; + + /// 검색 칸의 지금 글 — 낱말 수를 합쳐 센다 + final String searchText; + final Clock clock; + + @override + State createState() => + _MailSearchConditionsSheetState(); +} + +class _MailSearchConditionsSheetState extends State { + final Map<_TextCondition, TextEditingController> _texts = + <_TextCondition, TextEditingController>{ + for (final _TextCondition field in _TextCondition.values) + field: TextEditingController(), + }; + final ScrollController _scroll = ScrollController(); + final FocusNode _sinceFocus = FocusNode(); + final FocusNode _untilFocus = FocusNode(); + final FocusNode _clearFocus = FocusNode(); + final GlobalKey _untilKey = GlobalKey(); + + /// 단추 줄 — 낱말 알림을 띄운 뒤 보이게 한다 + final GlobalKey _footerKey = GlobalKey(); + + SearchPeriod _period = SearchPeriod.all; + + /// 바로 전에 고른 기간 — 날짜 고르기로 바꿀 때 그 날짜로 빈 칸을 채운다 + SearchPeriod _previousPeriod = SearchPeriod.all; + SearchDay? _since; + SearchDay? _until; + bool _hasAttachment = false; + + /// [찾기]가 낱말이 너무 많아 넘기지 못했다 — 단추 줄 위의 알림(낱말을 고치면 지운다) + bool _tooManyWords = false; + + /// 가운데가 스크롤됐다 — 머리 아래 선 + bool _scrolled = false; + + @override + void initState() { + super.initState(); + _fill(widget.applied); + _scroll.addListener(_onScroll); + } + + @override + void dispose() { + for (final TextEditingController controller in _texts.values) { + controller.dispose(); + } + _scroll.dispose(); + _sinceFocus.dispose(); + _untilFocus.dispose(); + _clearFocus.dispose(); + super.dispose(); + } + + DateTime _today() => widget.clock(); + + /// 걸린 조건으로 칸을 채운다 (열 때 · [조건 지우기]는 빈 조건으로) + void _fill(SearchConditions source) { + final SearchConditions value = normalizeSearchConditions(source); + _texts[_TextCondition.from]!.text = value.from; + _texts[_TextCondition.to]!.text = value.to; + _texts[_TextCondition.subject]!.text = value.subject; + _texts[_TextCondition.body]!.text = value.body; + _period = periodOf(value.since, value.until, _today()); + _previousPeriod = _period; + _since = value.since; + _until = value.until; + _hasAttachment = value.hasAttachment; + _tooManyWords = false; + } + + void _onScroll() { + final bool scrolled = _scroll.hasClients && _scroll.offset > 0; + if (scrolled != _scrolled) setState(() => _scrolled = scrolled); + } + + /// 날짜 고르기의 시작이 끝보다 늦다 — 끝 날짜 칸이 빨갛고 두 칸 아래에 알린다 + bool get _reversed => + _period == SearchPeriod.custom && isReversedPeriod(_since, _until); + + // ── 하는 일 ── + + /// 글 칸을 고쳤다 — 낱말 알림을 지운다 + void _onWordsEdited() { + if (_tooManyWords) setState(() => _tooManyWords = false); + } + + void _changePeriod(SearchPeriod period) { + setState(() { + _period = period; + if (period == SearchPeriod.custom && _since == null && _until == null) { + final ({SearchDay? since, SearchDay? until}) range = periodRange( + _previousPeriod, + _today(), + ); + _since = range.since; + _until = range.until; + } + _previousPeriod = period; + }); + } + + /// 날짜 고르는 창 — 1970-01-01 ~ 오늘, 처음은 칸의 날짜(없으면 오늘 · 범위 안으로). 가림막은 대화상자 가림막 색 + Future _pickDay(_DayEdge edge) async { + // 창이 닫히면 초점이 그 날짜 칸으로 돌아온다(전에 쓰던 글 칸으로 돌아가 자판이 다시 올라오지 않게) + (edge == _DayEdge.since ? _sinceFocus : _untilFocus).requestFocus(); + final DateTime now = _today().toLocal(); + final DateTime first = kSearchDayMin.toLocalDate(); + final DateTime today = DateTime(now.year, now.month, now.day); + final DateTime last = today.isBefore(first) ? first : today; + final SearchDay? current = edge == _DayEdge.since ? _since : _until; + DateTime initial = current?.toLocalDate() ?? today; + if (initial.isBefore(first)) initial = first; + if (initial.isAfter(last)) initial = last; + final DateTime? picked = await showDatePicker( + context: context, + initialDate: initial, + firstDate: first, + lastDate: last, + currentDate: today, + helpText: edge == _DayEdge.since + ? MailSearchTexts.since + : MailSearchTexts.until, + barrierColor: Theme.of(context).colorScheme.scrim, + ); + if (!mounted || picked == null) return; + setState(() { + if (edge == _DayEdge.since) { + _since = SearchDay.of(picked); + } else { + _until = SearchDay.of(picked); + } + }); + } + + /// 날짜 칸의 [✕] — 그 날짜를 지우고 초점은 그 칸으로(누른 단추가 사라진다) + void _clearDay(_DayEdge edge) { + setState(() { + if (edge == _DayEdge.since) { + _since = null; + } else { + _until = null; + } + }); + (edge == _DayEdge.since ? _sinceFocus : _untilFocus).requestFocus(); + } + + /// [조건 지우기] — 칸만 비운다(시트는 열린 채 · 찾지 않음 · 초점은 이 단추에) + void _clear() { + setState(() => _fill(SearchConditions.none)); + _clearFocus.requestFocus(); + } + + /// [찾기] · 글 칸의 [검색] 키 + void _apply() { + if (_reversed) { + // 끝 날짜 칸 아래의 알림은 이미 보인다 — 그 칸으로 + final BuildContext? target = _untilKey.currentContext; + if (target != null) unawaited(Scrollable.ensureVisible(target)); + _untilFocus.requestFocus(); + return; + } + final ({SearchDay? since, SearchDay? until}) range = + _period == SearchPeriod.custom + ? (since: _since, until: _until) + : periodRange(_period, _today()); + final SearchConditions conditions = normalizeSearchConditions( + SearchConditions( + from: _texts[_TextCondition.from]!.text, + to: _texts[_TextCondition.to]!.text, + subject: _texts[_TextCondition.subject]!.text, + body: _texts[_TextCondition.body]!.text, + since: range.since, + until: range.until, + hasAttachment: _hasAttachment, + ), + ); + if (exceedsCombinedSearchWords( + normalizeSearchText(widget.searchText), + conditions, + )) { + setState(() => _tooManyWords = true); + // 알림은 단추 줄 위에 붙는다 — 시트 전체가 한 스크롤이면 끝에 있어 보이지 않으니 다음 프레임에 단추 줄을 보이게 + WidgetsBinding.instance.addPostFrameCallback((Duration _) => _reveal()); + return; + } + Navigator.of(context).pop(conditions); + } + + // ── 그리기 ── + + /// 볼 곳을 보이게 한다(가장 적게 움직여 — 이미 보이면 그대로) — 낱말 알림이 떠 있으면 그 알림과 단추 줄을, 아니면 시트 안의 초점 칸을. + /// 알림을 띄운 뒤 · 모양이 바뀐 뒤(자판이 내려가 고정 모양으로 돌아오면 가운데가 맨 위로 가 초점 칸이 가려질 수 있다) 프레임이 끝나면 부른다 + void _reveal() { + if (!mounted) return; + if (_tooManyWords) { + _footerKey.currentContext?.findRenderObject()?.showOnScreen(); + return; + } + final BuildContext? focused = FocusManager.instance.primaryFocus?.context; + if (focused == null || + !focused.mounted || + focused.findAncestorStateOfType<_MailSearchConditionsSheetState>() != + this) { + return; + } + final RenderObject? target = focused.findRenderObject(); + if (target is RenderBox && target.hasSize) { + // 칸의 테두리 · 안쪽 여백까지 보이게 조금 넓혀서 + target.showOnScreen( + rect: (Offset.zero & target.size).inflate(AppSpacing.s16), + ); + } + } + + /// 가운데가 스크롤 자리가 바뀌었다(자리가 모자라 펼쳐지면 0 으로) — 머리 아래 선을 맞춘다 + bool _onMiddleMetrics(ScrollMetricsNotification notification) { + if (notification.depth == 0) { + final bool scrolled = + notification.metrics.pixels > notification.metrics.minScrollExtent; + if (scrolled != _scrolled) setState(() => _scrolled = scrolled); + } + return false; + } + + /// 트리 모양은 늘 같다 — 바깥 스크롤 > [_SheetBody](머리 · 가운데 스크롤 · 단추 줄). 자리가 모자라 시트 전체를 한 스크롤로 둘 때도 + /// 모양이 바뀌지 않아 칸(TextField)이 새로 만들어지지 않는다 — 자판이 올라오는 동안 초점 · 입력 연결이 끊기지 않는다(가로 화면 — 2026-11-07 리뷰). + /// 자리 판정은 [_SheetBody] 가 이번 레이아웃에서 잰 머리 · 단추 줄의 실제 높이(낱말 알림 · 아래 안전 영역 · 글자 크기 포함)로 한다 + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final double keyboard = MediaQuery.viewInsetsOf(context).bottom; + final double height = constraints.hasBoundedHeight + ? constraints.maxHeight + : MediaQuery.sizeOf(context).height; + final double available = math.max(0, height - keyboard); + return Padding( + padding: EdgeInsets.only(bottom: keyboard), + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: available), + // 자리가 넉넉하면 내용이 이 틀보다 낮아 스크롤할 것이 없다 — 그때 머리 · 단추 줄을 끄는 손짓은 시트가 받아 내려 닫는다 + child: SingleChildScrollView( + physics: const ClampingScrollPhysics(), + child: _SheetBody( + available: available, + // 넉넉할 때는 위 96 을 남긴다 — 모자라면 먼저 이것부터 없앤다(시트가 화면 위까지) + gap: AppSize.sheetTopGap, + // 가운데에 칸 하나도 남지 않으면 다음 모양으로 — 초점을 받은 칸이 다 보이게 + minMiddle: MediaQuery.textScalerOf( + context, + ).scale(AppSize.fieldHeight), + onLayoutChanged: _reveal, + top: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const _SheetHandle(), + _SheetHeader(divided: _scrolled), + ], + ), + middle: NotificationListener( + onNotification: _onMiddleMetrics, + child: SingleChildScrollView( + key: const ValueKey('search-conditions-middle'), + controller: _scroll, + child: _fields(), + ), + ), + bottom: _SheetFooter( + key: _footerKey, + tooManyWords: _tooManyWords, + clearFocus: _clearFocus, + onClear: _clear, + onApply: _apply, + ), + ), + ), + ), + ); + }, + ); + } + + /// 가운데 — 글 칸 넷 · 기간 · 첨부 · 안내 (위 8 · 좌우 20 · 아래 32, 칸 묶음 사이 16) + Widget _fields() { + final ThemeData theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.s20, + AppSpacing.s8, + AppSpacing.s20, + AppSpacing.s32, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: AppSpacing.s16, + children: [ + for (final _TextCondition field in _TextCondition.values) + _ConditionTextField( + key: ValueKey('search-condition-${field.name}'), + label: _labelOf(field), + placeholder: _placeholderOf(field), + controller: _texts[field]!, + onChanged: _onWordsEdited, + onSubmitted: _apply, + ), + _PeriodField( + period: _period, + onChanged: _changePeriod, + dates: _period == SearchPeriod.custom + ? _PeriodDates( + since: _since, + until: _until, + reversed: _reversed, + sinceFocus: _sinceFocus, + untilFocus: _untilFocus, + untilKey: _untilKey, + onPick: (_DayEdge edge) => unawaited(_pickDay(edge)), + onClear: _clearDay, + ) + : null, + ), + _AttachmentField( + value: _hasAttachment, + onChanged: (bool value) => setState(() => _hasAttachment = value), + ), + KeepAllText( + MailSearchTexts.conditionsHint, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.extension()!.textMuted, + height: AppTypography.lineHeightRelaxed, + ), + ), + ], + ), + ); + } + + static String _labelOf(_TextCondition field) => switch (field) { + _TextCondition.from => MailSearchTexts.fromLabel, + _TextCondition.to => MailSearchTexts.toLabel, + _TextCondition.subject => MailSearchTexts.subjectLabel, + _TextCondition.body => MailSearchTexts.bodyLabel, + }; + + static String _placeholderOf(_TextCondition field) => switch (field) { + _TextCondition.from || + _TextCondition.to => MailSearchTexts.personPlaceholder, + _TextCondition.subject => MailSearchTexts.subjectPlaceholder, + _TextCondition.body => MailSearchTexts.bodyPlaceholder, + }; +} + +/// 칸 묶음의 이름 — 칸 위 15 굵게 (보드) +TextStyle? _groupLabelStyle(ThemeData theme) => + theme.textTheme.titleSmall?.copyWith( + fontWeight: AppTypography.bold, + color: theme.extension()!.textStrong, + ); + +/// 손잡이 — 보이기만 한다(화면 낭독기는 [✕] "닫기"를 쓴다 — 보드의 손잡이도 낭독에서 뺐다). 끌기는 시트 전체가 받는다 +class _SheetHandle extends StatelessWidget { + const _SheetHandle(); + + @override + Widget build(BuildContext context) { + return ExcludeSemantics( + child: SizedBox( + height: AppSize.touchMin, + child: Center( + child: Container( + width: AppSize.sheetHandleWidth, + height: AppSize.sheetHandleHeight, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.onSurfaceVariant, + borderRadius: BorderRadius.circular( + AppSize.sheetHandleHeight / 2, + ), + ), + ), + ), + ), + ); + } +} + +/// 머리 — 제목 "조건으로 찾기"(머리) + [✕](닫기 — 고친 값을 버린다). [divided] 면 아래 선(가운데가 스크롤됨) +class _SheetHeader extends StatelessWidget { + const _SheetHeader({required this.divided}); + + final bool divided; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + return DecoratedBox( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: divided ? extra.borderSubtle : Colors.transparent, + width: AppSize.borderWidth, + ), + ), + ), + child: Padding( + padding: const EdgeInsets.only( + left: AppSpacing.s20, + right: AppSpacing.s4, + ), + child: Row( + children: [ + Expanded( + child: Semantics( + header: true, + child: Text( + MailSearchTexts.conditions, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: AppTypography.bold, + ), + ), + ), + ), + IconButton( + icon: const Icon(Icons.close, size: AppSize.iconToolbar), + tooltip: MailSearchTexts.closeConditions, + color: extra.textStrong, + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + ); + } +} + +/// 아래 단추 줄 — (낱말이 너무 많으면) 빨간 알림 · 왼쪽 [조건 지우기] · 오른쪽 [찾기](최소 폭 120). 위 선은 늘 있다 +class _SheetFooter extends StatelessWidget { + const _SheetFooter({ + required this.tooManyWords, + required this.clearFocus, + required this.onClear, + required this.onApply, + super.key, + }); + + final bool tooManyWords; + final FocusNode clearFocus; + final VoidCallback onClear; + final VoidCallback onApply; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme scheme = theme.colorScheme; + return DecoratedBox( + decoration: BoxDecoration( + color: scheme.surface, + border: Border( + top: BorderSide( + color: theme.extension()!.borderSubtle, + width: AppSize.borderWidth, + ), + ), + ), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (tooManyWords) + Semantics( + container: true, + liveRegion: true, + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.s20, + AppSpacing.s12, + AppSpacing.s20, + 0, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: AppSpacing.s2), + child: Icon( + Icons.error_outline, + size: AppSize.iconTiny, + color: scheme.error, + ), + ), + const SizedBox(width: AppSpacing.s8), + Expanded( + child: KeepAllText( + MailSearchTexts.tooManyWordsHint(kMailSearchMaxWords), + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.error, + ), + ), + ), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.s8, + AppSpacing.s12, + AppSpacing.s20, + AppSpacing.s12, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton( + focusNode: clearFocus, + onPressed: onClear, + style: TextButton.styleFrom( + foregroundColor: scheme.onPrimaryContainer, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.s12, + ), + ), + child: const Text(MailSearchTexts.clearConditions), + ), + FilledButton( + onPressed: onApply, + style: FilledButton.styleFrom( + minimumSize: const Size( + AppSize.primaryActionMinWidth, + AppSize.touchMin, + ), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.s24, + ), + ), + child: const Text(MailSearchTexts.applyConditions), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +/// 글 칸 하나 — 이름은 칸 위(낭독은 칸에 붙인다 — 이름을 두 번 읽지 않게), 칸 52 · 두 줄까지. +/// 검색 칸과 같은 자판(글 자판 · 자동 고침 끔 · 추천 켬 · 둥근 따옴표 · 대시 끔), 줄바꿈은 띄어쓰기, UTF-16 200자, [검색] 키는 [찾기] +class _ConditionTextField extends StatelessWidget { + const _ConditionTextField({ + required this.label, + required this.placeholder, + required this.controller, + required this.onChanged, + required this.onSubmitted, + super.key, + }); + + final String label; + final String placeholder; + final TextEditingController controller; + final VoidCallback onChanged; + final VoidCallback onSubmitted; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final TextStyle? style = theme.textTheme.bodyLarge; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: AppSpacing.s8, + children: [ + ExcludeSemantics(child: Text(label, style: _groupLabelStyle(theme))), + Semantics( + label: label, + child: TextField( + controller: controller, + minLines: 1, + maxLines: 2, + keyboardType: TextInputType.text, + textInputAction: TextInputAction.search, + autocorrect: false, + enableSuggestions: true, + smartDashesType: SmartDashesType.disabled, + smartQuotesType: SmartQuotesType.disabled, + inputFormatters: const [ + LineBreaksToSpacesFormatter(), + Utf16LengthLimitFormatter(kMailSearchMaxLength), + ], + style: style, + decoration: InputDecoration( + hintText: placeholder, + hintStyle: style?.copyWith(color: theme.colorScheme.outline), + ), + onChanged: (String _) => onChanged(), + onSubmitted: (String _) => onSubmitted(), + // 기본은 [검색] 뒤 초점을 놓는다 — 넘기지 못하면(낱말이 많음) 칸에 남아 고치게 한다 + onEditingComplete: () {}, + ), + ), + ], + ); + } +} + +/// 날짜 고르기의 두 칸 · 그 아래 알림에 넘기는 값 +class _PeriodDates { + const _PeriodDates({ + required this.since, + required this.until, + required this.reversed, + required this.sinceFocus, + required this.untilFocus, + required this.untilKey, + required this.onPick, + required this.onClear, + }); + + final SearchDay? since; + final SearchDay? until; + + /// 시작이 끝보다 늦다 + final bool reversed; + final FocusNode sinceFocus; + final FocusNode untilFocus; + + /// 끝 날짜 칸 — [찾기]가 그 칸으로 스크롤한다 + final GlobalKey untilKey; + final ValueChanged<_DayEdge> onPick; + final ValueChanged<_DayEdge> onClear; +} + +/// 기간 — 고르기 칸(전체 기간 · 최근 1주 · 1달 · 3달 · 1년 · 날짜 고르기), 날짜 고르기면 그 아래 날짜 칸 둘 나란히 · 거꾸로면 알림 +class _PeriodField extends StatelessWidget { + const _PeriodField({ + required this.period, + required this.onChanged, + required this.dates, + }); + + final SearchPeriod period; + final ValueChanged onChanged; + + /// 날짜 고르기일 때만 + final _PeriodDates? dates; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme scheme = theme.colorScheme; + final _PeriodDates? custom = dates; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: AppSpacing.s8, + children: [ + ExcludeSemantics( + child: Text( + MailSearchTexts.periodLabel, + style: _groupLabelStyle(theme), + ), + ), + // 낭독은 "기간" 과 고른 기간을 한 단추로 읽는다 + MergeSemantics( + child: Semantics( + label: MailSearchTexts.periodLabel, + child: DropdownButtonFormField( + key: const ValueKey('search-condition-period'), + initialValue: period, + isExpanded: true, + items: >[ + for (final SearchPeriod option in SearchPeriod.values) + DropdownMenuItem( + value: option, + child: Text( + MailSearchTexts.period(option), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + onChanged: (SearchPeriod? value) { + if (value != null) onChanged(value); + }, + style: theme.textTheme.bodyLarge?.copyWith( + color: scheme.onSurface, + ), + icon: Icon( + Icons.keyboard_arrow_down, + size: AppSize.iconMedium, + color: scheme.onSurfaceVariant, + ), + dropdownColor: scheme.surface, + borderRadius: BorderRadius.circular(AppRadius.md), + ), + ), + ), + if (custom != null) ...[ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: AppSpacing.s12, + children: [ + Expanded( + child: _DateField( + key: const ValueKey('search-condition-since'), + label: MailSearchTexts.since, + clearLabel: MailSearchTexts.clearSince, + value: custom.since, + invalid: false, + focusNode: custom.sinceFocus, + onPick: () => custom.onPick(_DayEdge.since), + onClear: () => custom.onClear(_DayEdge.since), + ), + ), + Expanded( + child: KeyedSubtree( + key: custom.untilKey, + child: _DateField( + key: const ValueKey('search-condition-until'), + label: MailSearchTexts.until, + clearLabel: MailSearchTexts.clearUntil, + value: custom.until, + invalid: custom.reversed, + error: custom.reversed ? MailSearchTexts.periodOrder : null, + focusNode: custom.untilFocus, + onPick: () => custom.onPick(_DayEdge.until), + onClear: () => custom.onClear(_DayEdge.until), + ), + ), + ), + ], + ), + if (custom.reversed) + Semantics( + container: true, + liveRegion: true, + child: KeepAllText( + MailSearchTexts.periodOrder, + style: theme.textTheme.bodySmall?.copyWith(color: scheme.error), + ), + ), + ], + ], + ); + } +} + +/// 날짜 칸 하나 — 작은 이름("시작 날짜") 아래 칸 52. 값이 있으면 "2026. 11. 7." + 오른쪽 [✕] 48(그 날짜 지우기), 비었으면 오른쪽 달력 아이콘. +/// 누르면 날짜 고르는 창. [invalid] 면 빨간 테두리(낭독은 "잘못됨" · 칸 아래 알림을 설명으로 붙인다) +class _DateField extends StatelessWidget { + const _DateField({ + required this.label, + required this.clearLabel, + required this.value, + required this.invalid, + required this.focusNode, + required this.onPick, + required this.onClear, + this.error, + super.key, + }); + + final String label; + final String clearLabel; + final SearchDay? value; + final bool invalid; + + /// 칸을 설명하는 알림 — 칸 아래의 "끝 날짜를 …" + final String? error; + final FocusNode focusNode; + final VoidCallback onPick; + final VoidCallback onClear; + + static const List _tabular = [ + FontFeature.tabularFigures(), + ]; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme scheme = theme.colorScheme; + final SearchDay? day = value; + final String? dayText = day == null ? null : formatSearchDay(day); + const Radius corner = Radius.circular(AppRadius.md); + final BorderSide side = invalid + ? BorderSide(color: scheme.error, width: AppSize.focusRingWidth) + : BorderSide(color: scheme.outline, width: AppSize.borderWidth); + final Widget pick = Semantics( + container: true, + button: true, + label: label, + value: dayText, + hint: error, + validationResult: invalid + ? SemanticsValidationResult.invalid + : SemanticsValidationResult.none, + onTap: onPick, + child: ExcludeSemantics( + child: InkWell( + focusNode: focusNode, + onTap: onPick, + borderRadius: dayText == null + ? const BorderRadius.all(corner) + : const BorderRadius.horizontal(left: corner), + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: AppSize.fieldHeight), + child: dayText == null + ? Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.s14, + ), + child: Align( + alignment: Alignment.centerRight, + child: Icon( + Icons.calendar_today_outlined, + size: AppSize.iconMedium, + color: scheme.onSurfaceVariant, + ), + ), + ) + : Padding( + padding: const EdgeInsets.only(left: AppSpacing.s12), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + dayText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyLarge?.copyWith( + color: scheme.onSurface, + fontFeatures: _tabular, + ), + ), + ), + ), + ), + ), + ), + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: AppSpacing.s6, + children: [ + ExcludeSemantics( + child: Text( + label, + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: AppTypography.bold, + color: scheme.onSurfaceVariant, + ), + ), + ), + DecoratedBox( + decoration: BoxDecoration( + border: Border.fromBorderSide(side), + borderRadius: const BorderRadius.all(corner), + ), + child: Row( + children: [ + Expanded(child: pick), + if (dayText != null) + IconButton( + icon: const Icon(Icons.close, size: AppSize.iconMedium), + tooltip: clearLabel, + color: scheme.onSurfaceVariant, + onPressed: onClear, + ), + ], + ), + ), + ], + ); + } +} + +/// 첨부 — 묶음 이름 "첨부" 아래 체크 줄 48 "첨부 파일이 있는 메일만"(줄 어디를 눌러도 바뀐다) +class _AttachmentField extends StatelessWidget { + const _AttachmentField({required this.value, required this.onChanged}); + + final bool value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(MailSearchTexts.attachmentLabel, style: _groupLabelStyle(theme)), + CheckboxListTile( + key: const ValueKey('search-condition-attachment'), + value: value, + onChanged: (bool? checked) => onChanged(checked ?? false), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + minTileHeight: AppSize.touchMin, + minLeadingWidth: 0, + horizontalTitleGap: AppSpacing.s10, + visualDensity: const VisualDensity( + horizontal: VisualDensity.minimumDensity, + vertical: VisualDensity.minimumDensity, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + ), + title: Text( + MailSearchTexts.attachmentOnly, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface, + ), + ), + ), + ], + ); + } +} + +/// 시트 내용을 놓는 모양 셋 — 자리가 줄어드는 차례 +enum _SheetLayout { + /// 머리 · 단추 줄 고정, 가운데만 스크롤 · 위 96 을 남긴다(보드 14 · 15) + fixedWithGap, + + /// 머리 · 단추 줄 고정, 가운데만 스크롤 · 위 96 을 남기지 않는다(시트가 화면 위까지) + fixedFull, + + /// 가운데를 제 높이대로 펼쳐 셋을 잇는다 — 바깥 스크롤이 시트 전체를 움직인다(머리 · 단추 줄도 함께) + scrolled, +} + +/// 시트의 세 칸 — 머리([top]) · 가운데 스크롤([middle]) · 단추 줄([bottom])을 위에서부터 잇는다(폭은 셋 다 꽉 채운다). +/// +/// 이번 레이아웃에서 잰 머리 · 단추 줄의 실제 높이(낱말 알림 · 아래 안전 영역 · 글자 크기가 다 든다)로 모양을 한 번에 고른다 — +/// [available](자판 위 자리)에서 위 [gap] 과 머리 · 단추 줄을 빼고도 가운데에 [minMiddle](칸 하나)가 남으면 [_SheetLayout.fixedWithGap], +/// [gap] 을 빼지 않아야 남으면 [_SheetLayout.fixedFull], 그래도 모자라면 [_SheetLayout.scrolled]. +/// 판정은 바깥 자리와 머리 · 단추 줄의 높이로만 하므로 되풀이되지 않고(자리가 줄면 차례대로 넘어갈 뿐), 위젯 트리가 바뀌지 않아 +/// 칸이 새로 만들어지지 않는다 — 초점 · 입력 연결이 그대로다. 모양이 바뀌면 그 프레임 뒤에 [onLayoutChanged] 를 부른다. +/// 바깥 스크롤 안에 둔다(높이 제한 없이 받는다) +class _SheetBody extends MultiChildRenderObjectWidget { + _SheetBody({ + required this.available, + required this.gap, + required this.minMiddle, + required this.onLayoutChanged, + required Widget top, + required Widget middle, + required Widget bottom, + }) : super(children: [top, middle, bottom]); + + /// 시트가 쓸 수 있는 높이 — 화면(안전 영역 위)에서 자판을 뺀 자리 + final double available; + + /// 자리가 넉넉할 때 시트 위에 남기는 자리 + final double gap; + + /// 가운데에 이만큼도 남지 않으면 다음 모양으로 + final double minMiddle; + + /// 모양이 바뀐 뒤(그 프레임이 끝난 뒤) + final VoidCallback onLayoutChanged; + + @override + RenderObject createRenderObject(BuildContext context) => _RenderSheetBody( + available: available, + gap: gap, + minMiddle: minMiddle, + onLayoutChanged: onLayoutChanged, + ); + + @override + void updateRenderObject( + BuildContext context, + covariant _RenderSheetBody renderObject, + ) { + renderObject + ..available = available + ..gap = gap + ..minMiddle = minMiddle + ..onLayoutChanged = onLayoutChanged; + } +} + +class _SheetBodyParentData extends ContainerBoxParentData {} + +class _RenderSheetBody extends RenderBox + with + ContainerRenderObjectMixin, + RenderBoxContainerDefaultsMixin { + _RenderSheetBody({ + required double available, + required double gap, + required double minMiddle, + required this.onLayoutChanged, + }) : _available = available, + _gap = gap, + _minMiddle = minMiddle; + + double _available; + set available(double value) { + if (value == _available) return; + _available = value; + markNeedsLayout(); + } + + double _gap; + set gap(double value) { + if (value == _gap) return; + _gap = value; + markNeedsLayout(); + } + + double _minMiddle; + set minMiddle(double value) { + if (value == _minMiddle) return; + _minMiddle = value; + markNeedsLayout(); + } + + VoidCallback onLayoutChanged; + + /// 지난 레이아웃의 모양 — 처음에는 없다(바뀜으로 치지 않는다) + _SheetLayout? _layout; + + @override + void setupParentData(RenderBox child) { + if (child.parentData is! _SheetBodyParentData) { + child.parentData = _SheetBodyParentData(); + } + } + + /// 셋을 재고(놓고) 이은 크기 — [place] 면 자리 · 모양도 정한다(마른 레이아웃은 재기만 한다) + Size _arrange( + BoxConstraints constraints, + ChildLayouter layoutChild, { + required bool place, + }) { + final RenderBox top = firstChild!; + final RenderBox middle = childAfter(top)!; + final RenderBox bottom = childAfter(middle)!; + final double width = constraints.maxWidth; + final BoxConstraints natural = BoxConstraints.tightFor(width: width); + final double topHeight = layoutChild(top, natural).height; + final double bottomHeight = layoutChild(bottom, natural).height; + final double room = _available - topHeight - bottomHeight; + final _SheetLayout layout = room - _gap >= _minMiddle + ? _SheetLayout.fixedWithGap + : room >= _minMiddle + ? _SheetLayout.fixedFull + : _SheetLayout.scrolled; + final double middleHeight = layoutChild(middle, switch (layout) { + _SheetLayout.fixedWithGap => BoxConstraints( + minWidth: width, + maxWidth: width, + maxHeight: room - _gap, + ), + _SheetLayout.fixedFull => BoxConstraints( + minWidth: width, + maxWidth: width, + maxHeight: room, + ), + _SheetLayout.scrolled => natural, + }).height; + if (place) { + (top.parentData! as _SheetBodyParentData).offset = Offset.zero; + (middle.parentData! as _SheetBodyParentData).offset = Offset( + 0, + topHeight, + ); + (bottom.parentData! as _SheetBodyParentData).offset = Offset( + 0, + topHeight + middleHeight, + ); + final _SheetLayout? previous = _layout; + _layout = layout; + if (previous != null && previous != layout) { + SchedulerBinding.instance.addPostFrameCallback( + (Duration _) => onLayoutChanged(), + debugLabel: 'SearchConditionsSheet.layoutChanged', + ); + } + } + return constraints.constrain( + Size(width, topHeight + middleHeight + bottomHeight), + ); + } + + @override + void performLayout() { + size = _arrange(constraints, ChildLayoutHelper.layoutChild, place: true); + } + + @override + Size computeDryLayout(covariant BoxConstraints constraints) => + _arrange(constraints, ChildLayoutHelper.dryLayoutChild, place: false); + + @override + double computeMinIntrinsicWidth(double height) => + _maxOfChildren((RenderBox child) => child.getMinIntrinsicWidth(height)); + + @override + double computeMaxIntrinsicWidth(double height) => + _maxOfChildren((RenderBox child) => child.getMaxIntrinsicWidth(height)); + + @override + double computeMinIntrinsicHeight(double width) => + _sumOfChildren((RenderBox child) => child.getMinIntrinsicHeight(width)); + + @override + double computeMaxIntrinsicHeight(double width) => + _sumOfChildren((RenderBox child) => child.getMaxIntrinsicHeight(width)); + + double _maxOfChildren(double Function(RenderBox child) measure) { + double result = 0; + for ( + RenderBox? child = firstChild; + child != null; + child = childAfter(child) + ) { + result = math.max(result, measure(child)); + } + return result; + } + + double _sumOfChildren(double Function(RenderBox child) measure) { + double result = 0; + for ( + RenderBox? child = firstChild; + child != null; + child = childAfter(child) + ) { + result += measure(child); + } + return result; + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) => + defaultHitTestChildren(result, position: position); + + @override + void paint(PaintingContext context, Offset offset) => + defaultPaint(context, offset); +} diff --git a/flutter/lib/features/mail/presentation/widgets/mail_search_states.dart b/flutter/lib/features/mail/presentation/widgets/mail_search_states.dart index e928c1f..ac811ce 100644 --- a/flutter/lib/features/mail/presentation/widgets/mail_search_states.dart +++ b/flutter/lib/features/mail/presentation/widgets/mail_search_states.dart @@ -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()!; 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: [ - KeepAllText(MailSearchTexts.emptyHint, style: _stateHintStyle(theme)), + KeepAllText(hint, style: _stateHintStyle(theme)), if (indexing) ...[ const SizedBox(height: AppSpacing.s8), KeepAllText( diff --git a/flutter/test/core/theme/app_theme_test.dart b/flutter/test/core/theme/app_theme_test.dart index ff02be3..490a252 100644 --- a/flutter/test/core/theme/app_theme_test.dart +++ b/flutter/test/core/theme/app_theme_test.dart @@ -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( diff --git a/flutter/test/features/mail/mail_search_conditions_test.dart b/flutter/test/features/mail/mail_search_conditions_test.dart new file mode 100644 index 0000000..49a8cb4 --- /dev/null +++ b/flutter/test/features/mail/mail_search_conditions_test.dart @@ -0,0 +1,1331 @@ +import 'package:arasystem_app/core/theme/app_colors.dart'; +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/theme/app_theme.dart'; +import 'package:arasystem_app/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/mail_search_view.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; +import 'package:arasystem_app/features/mail/presentation/pages/message_reader_page.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:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/login_fakes.dart'; +import '../../support/mail_fakes.dart'; +import '../../support/mail_harness.dart'; + +/// 기기 시간대의 그날 0시를 UTC ISO 로 — 앱이 API 에 싣는 after · before 와 같은 셈(시험이 도는 곳의 시간대와 상관없다) +String _localIso(SearchDay day, {int addDays = 0}) => + DateTime(day.year, day.month, day.day + addDays).toUtc().toIso8601String(); + +/// 조건으로 찾기 (앱 보드 `App1SearchConditions` — 2차: [조건] · 시트 · 날짜 · 조건 줄 · 결과 글) +void main() { + late MailAppHarness h; + + setUp(() => h = MailAppHarness()); + + Finder field() => find.byKey(const ValueKey('mail-search-field')); + Finder sheet() => find.byType(MailSearchConditionsSheet); + Finder inSheet(Finder finder) => + find.descendant(of: sheet(), matching: finder); + Finder toggle() => + find.byKey(const ValueKey('mail-search-conditions')); + Finder textField(String name) => find.descendant( + of: find.byKey(ValueKey('search-condition-$name')), + matching: find.byType(TextField), + ); + Finder period() => + find.byKey(const ValueKey('search-condition-period')); + Finder attachment() => + find.byKey(const ValueKey('search-condition-attachment')); + Finder dateField(String edge) => + find.byKey(ValueKey('search-condition-$edge')); + Finder applyButton() => inSheet( + find.widgetWithText(FilledButton, MailSearchTexts.applyConditions), + ); + Finder sheetClear() => + inSheet(find.widgetWithText(TextButton, MailSearchTexts.clearConditions)); + Finder line() => find.byType(MailSearchConditionLine); + + /// 시트의 가운데(스크롤) — 끌면 칸이 움직인다(머리를 끌면 시트가 내려간다) + Finder middle() => + find.byKey(const ValueKey('search-conditions-middle')); + Finder lineClear() => find.descendant( + of: line(), + matching: find.widgetWithText(TextButton, MailSearchTexts.clearConditions), + ); + + String textOf(WidgetTester tester, String name) => + tester.widget(textField(name)).controller!.text; + + /// 기간 고르기 칸에 보이는 글 — 고른 기간 하나만 보인다(나머지 이름은 칸 안에 숨어 있다) + String periodShown(WidgetTester tester) { + final List shown = tester + .widgetList( + find.descendant(of: period(), matching: find.byType(Text)), + ) + .toList(); + expect(shown, hasLength(1)); + return shown.single.data!; + } + + bool attachmentOf(WidgetTester tester) => + tester.widget(attachment()).value!; + + bool fieldFocused(WidgetTester tester) => + tester.widget(field()).focusNode!.hasFocus; + + /// 오늘 — 앱이 보는 기기 시간대의 날짜(시험 시계) + SearchDay today() => SearchDay.of(h.clock.now); + + /// 보드의 받은편지함 — 이도윤이 보낸 첨부 있는 계약 메일 둘 · 인사팀 · 박지훈 + void seedInbox() { + final DateTime now = h.clock.now; + h.mail + ..put( + kInboxId, + messageJson( + 'd1', + mailboxId: kInboxId, + subject: 'Re: 유지보수 계약 갱신 건', + receivedAt: now.subtract(const Duration(minutes: 1)), + hasAttachment: true, + ), + ) + ..put( + kInboxId, + messageJson( + 'h1', + mailboxId: kInboxId, + fromName: '인사팀', + fromEmail: 'hr@example.com', + subject: '[공지] 계약직 채용 일정 안내', + receivedAt: now.subtract(const Duration(hours: 2)), + isRead: true, + ), + ) + ..put( + kInboxId, + messageJson( + 'j1', + mailboxId: kInboxId, + fromName: '박지훈', + fromEmail: 'jihoon@example.com', + subject: '견적서 보내 드립니다', + receivedAt: now.subtract(const Duration(hours: 5)), + hasAttachment: true, + isRead: true, + ), + ) + ..put( + kInboxId, + messageJson( + 'd2', + mailboxId: kInboxId, + subject: '계약서 초안 보내 드립니다', + receivedAt: now.subtract(const Duration(hours: 8)), + hasAttachment: true, + isRead: true, + ), + ); + } + + Future openSearch(WidgetTester tester, {Size size = kPhoneSize}) async { + await h.pump(tester, size: size); + await tester.tap(find.byTooltip(MailListTexts.search)); + await tester.pumpAndSettle(); + } + + Future openSheet(WidgetTester tester) async { + await tester.tap(toggle()); + await tester.pumpAndSettle(); + } + + Future type(WidgetTester tester, String name, String text) async { + await tester.enterText(textField(name), text); + await tester.pump(); + } + + Future choosePeriod(WidgetTester tester, SearchPeriod value) async { + await tester.ensureVisible(period()); + await tester.pumpAndSettle(); + await tester.tap(period()); + await tester.pumpAndSettle(); + await tester.tap(find.text(MailSearchTexts.period(value)).last); + await tester.pumpAndSettle(); + } + + Future toggleAttachment(WidgetTester tester) async { + await tester.ensureVisible(attachment()); + await tester.pumpAndSettle(); + await tester.tap(attachment()); + await tester.pumpAndSettle(); + } + + /// 날짜 칸을 눌러 날짜 고르는 창을 열고 [day] 일(이달 — 없으면 처음 날짜 그대로)을 골라 [확인] + Future pickDay(WidgetTester tester, String edge, {int? day}) async { + await tester.ensureVisible(dateField(edge)); + await tester.pumpAndSettle(); + await tester.tap(dateField(edge)); + await tester.pumpAndSettle(); + if (day != null) { + await tester.tap( + find.descendant( + of: find.byType(DatePickerDialog), + matching: find.text('$day'), + ), + ); + await tester.pump(); + } + await tester.tap(find.text('확인')); + await tester.pumpAndSettle(); + } + + Future apply(WidgetTester tester) async { + await tester.tap(applyButton()); + await tester.pumpAndSettle(); + } + + /// 검색 칸에 [text] 를 쓰고 자판의 [검색] + Future search(WidgetTester tester, String text) async { + await tester.enterText(field(), text); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pumpAndSettle(); + } + + group('위 막대의 [조건]', () { + testWidgets( + '맨 오른쪽 · 평소는 바탕 없이 "조건으로 찾기", 열면 옅은 청록 원 · 펼침, 조건이 걸리면 "조건이 걸려 있음"', + (WidgetTester tester) async { + await openSearch(tester); + await tester.enterText(field(), '계약'); + await tester.pump(); + // [✕] 오른쪽 — 맨 끝 + expect( + tester.getTopLeft(toggle()).dx, + greaterThan( + tester.getTopLeft(find.byTooltip(MailSearchTexts.clear)).dx, + ), + ); + IconButton button() => tester.widget(toggle()); + Semantics semanticsOf() => tester.widget( + find.ancestor(of: toggle(), matching: find.byType(Semantics)).first, + ); + expect(button().style!.backgroundColor, isNull); + expect( + tester.getSemantics(toggle()), + isSemantics( + label: MailSearchTexts.conditions, + isButton: true, + hasExpandedState: true, + isExpanded: false, + ), + ); + expect(tester.getSize(toggle()), const Size.square(AppSize.touchMin)); + + await openSheet(tester); + expect(sheet(), findsOneWidget); + expect( + button().style!.backgroundColor!.resolve({}), + AppColors.brandSubtle, + ); + expect(semanticsOf().properties.expanded, isTrue); + // 시트를 열면 칸의 초점을 [조건]이 받는다 — 닫혀도 자판이 다시 올라오지 않게 + expect(fieldFocused(tester), isFalse); + + await type(tester, 'from', '이도윤'); + await apply(tester); + expect(sheet(), findsNothing); + expect(fieldFocused(tester), isFalse); + expect( + button().style!.backgroundColor!.resolve({}), + AppColors.brandSubtle, + ); + expect( + button().style!.foregroundColor!.resolve({}), + AppColors.brandStrong, + ); + expect( + tester.getSemantics(toggle()), + isSemantics( + label: MailSearchTexts.conditionsLabel(applied: true), + isButton: true, + hasExpandedState: true, + isExpanded: false, + ), + ); + expect( + MailSearchTexts.conditionsLabel(applied: true), + '조건으로 찾기, 조건이 걸려 있음', + ); + }, + ); + }); + + group('시트', () { + testWidgets( + '탭 막대까지 덮고 위 96 을 남긴다 — 머리(제목 · [✕] 닫기) · 손잡이는 낭독하지 않는다 · 가림막은 대화상자 가림막 색', + (WidgetTester tester) async { + await openSearch(tester); + await openSheet(tester); + expect( + tester.getTopLeft(sheet()).dy, + greaterThanOrEqualTo(AppSize.sheetTopGap), + ); + expect( + tester.getBottomLeft(sheet()).dy, + greaterThan(tester.getTopLeft(find.byType(NavigationBar)).dy), + ); + expect( + tester.getSemantics(inSheet(find.text(MailSearchTexts.conditions))), + isSemantics(label: MailSearchTexts.conditions, isHeader: true), + ); + expect( + tester.getSemantics( + inSheet(find.byTooltip(MailSearchTexts.closeConditions)), + ), + isSemantics(tooltip: MailSearchTexts.closeConditions, isButton: true), + ); + // 손잡이는 낭독하지 않는다 — Flutter 기본 손잡이의 "닫기" 단추가 없어 닫는 단추는 [✕] 하나다 + expect( + find.descendant( + of: sheet(), + matching: find.bySemanticsLabel(MailSearchTexts.closeConditions), + ), + findsNothing, + ); + expect( + tester.widgetList(find.byType(ModalBarrier)).last.color, + AppColors.backdrop, + ); + }, + ); + + testWidgets('가운데만 스크롤한다 — 스크롤되면 머리 아래 선, 단추 줄은 늘 아래', ( + WidgetTester tester, + ) async { + await openSearch(tester); + await openSheet(tester); + Color headerLine() { + final DecoratedBox box = tester.widget( + find + .ancestor( + of: inSheet(find.text(MailSearchTexts.conditions)), + matching: find.byType(DecoratedBox), + ) + .first, + ); + return ((box.decoration as BoxDecoration).border! as Border) + .bottom + .color; + } + + expect(headerLine(), Colors.transparent); + final double applyTop = tester.getTopLeft(applyButton()).dy; + await tester.drag(middle(), const Offset(0, -200)); + await tester.pumpAndSettle(); + expect(headerLine(), AppColors.borderSubtle); + expect(tester.getTopLeft(applyButton()).dy, applyTop); + }); + + // 자판 · 좁은 화면 — 시트만 띄워 화면 크기 · 안전 영역 · 글자 크기를 정한다(앱 틀 없이) + Future pumpSheet( + WidgetTester tester, { + required Size size, + double safeTop = 0, + double safeBottom = 0, + double textScale = 1, + SearchConditions applied = SearchConditions.none, + String searchText = '', + }) async { + tester.view.physicalSize = size * 3; + tester.view.devicePixelRatio = 3; + tester.view.padding = FakeViewPadding( + top: safeTop * 3, + bottom: safeBottom * 3, + ); + tester.view.viewPadding = FakeViewPadding( + top: safeTop * 3, + bottom: safeBottom * 3, + ); + tester.platformDispatcher.textScaleFactorTestValue = textScale; + addTearDown(tester.view.reset); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: Builder( + builder: (BuildContext context) => TextButton( + onPressed: () => showSearchConditionsSheet( + context, + applied: applied, + searchText: searchText, + clock: () => DateTime(2026, 11, 7, 10), + ), + child: const Text('open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + } + + /// 자판이 몇 프레임에 걸쳐 올라온다(그사이 시트의 자리가 줄어든다) + Future raiseKeyboard(WidgetTester tester, double height) async { + addTearDown(tester.view.resetViewInsets); + for (final double step in [height / 3, height * 2 / 3, height]) { + tester.view.viewInsets = FakeViewPadding( + bottom: step * tester.view.devicePixelRatio, + ); + await tester.pump(); + } + await tester.pumpAndSettle(); + } + + /// 「보낸 사람」에 초점을 두고 자판을 올린다 — 같은 칸에 초점 · 입력 연결이 남고, 칸이 자판 위에 보이고, 친 글이 그 칸에 든다 + Future typeWithKeyboard( + WidgetTester tester, { + required Size size, + required double keyboard, + }) async { + await pumpSheet(tester, size: size); + final Finder from = find.descendant( + of: find.byKey(const ValueKey('search-condition-from')), + matching: find.byType(EditableText), + ); + await tester.tap(from); + await tester.pump(); + final FocusNode node = tester.widget(from).focusNode; + expect(node.hasFocus, isTrue); + expect(tester.testTextInput.hasAnyClients, isTrue); + + await raiseKeyboard(tester, keyboard); + expect(tester.takeException(), isNull); + expect(tester.widget(from).focusNode, same(node)); + expect(node.hasFocus, isTrue); + expect(tester.testTextInput.hasAnyClients, isTrue); + final Rect rect = tester.getRect(from); + expect(rect.top, greaterThanOrEqualTo(0)); + expect(rect.bottom, lessThanOrEqualTo(size.height - keyboard)); + + tester.testTextInput.enterText('이도윤'); + await tester.pump(); + expect(textOf(tester, 'from'), '이도윤'); + } + + testWidgets( + '세로 화면 — 칸에 초점을 두고 자판이 올라와도 초점 · 입력이 그대로이고 시트가 함께 올라간다(단추 줄은 자판 위)', + (WidgetTester tester) async { + await typeWithKeyboard(tester, size: kPhoneSize, keyboard: 300); + expect( + tester.getBottomLeft(applyButton()).dy, + lessThanOrEqualTo(kPhoneSize.height - 300), + ); + // 자리가 넉넉하면 머리는 고정 — 가운데만 스크롤한다 + expect( + find.descendant( + of: middle(), + matching: find.text(MailSearchTexts.conditions), + ), + findsNothing, + ); + }, + ); + + testWidgets( + '가로 화면 — 자판이 올라와 자리가 모자라면 시트 전체가 한 스크롤이 되어도 칸의 초점 · 입력이 그대로다(되풀이해 끊기지 않는다)', + (WidgetTester tester) async { + await typeWithKeyboard( + tester, + size: const Size(844, 390), + keyboard: 200, + ); + // 자판을 내리고 다시 올려도 같다 + tester.view.resetViewInsets(); + await tester.pumpAndSettle(); + await raiseKeyboard(tester, 200); + expect(tester.takeException(), isNull); + expect(tester.testTextInput.hasAnyClients, isTrue); + // 아래로 내려 [찾기]까지 간다 — 보낸 사람을 넣었으니 넘기고 닫는다 + await tester.ensureVisible(applyButton()); + await tester.pumpAndSettle(); + expect( + tester.getBottomLeft(applyButton()).dy, + lessThanOrEqualTo(390 - 200), + ); + await tester.tap(applyButton()); + await tester.pumpAndSettle(); + expect(sheet(), findsNothing); + }, + ); + + testWidgets('한 스크롤로 펼치면 머리 아래 선을 지운다 — 머리도 함께 움직이고 가운데에는 스크롤할 것이 없다', ( + WidgetTester tester, + ) async { + await pumpSheet(tester, size: const Size(844, 390)); + Color headerLine() { + final DecoratedBox box = tester.widget( + find + .ancestor( + of: inSheet(find.text(MailSearchTexts.conditions)), + matching: find.byType(DecoratedBox), + ) + .first, + ); + return ((box.decoration as BoxDecoration).border! as Border) + .bottom + .color; + } + + await tester.drag(middle(), const Offset(0, -100)); + await tester.pumpAndSettle(); + expect(headerLine(), AppColors.borderSubtle); + await raiseKeyboard(tester, 200); + expect(tester.takeException(), isNull); + expect(headerLine(), Colors.transparent); + }); + + /// 낱말 넘김 알림 — 시트 안에 하나 + Finder wordsAlert() => inSheet( + findCopy(MailSearchTexts.tooManyWordsHint(kMailSearchMaxWords)), + ); + + /// [finder] 가 화면의 [top] ~ [bottom] 안에 다 보인다 + void expectShown( + WidgetTester tester, + Finder finder, { + required double top, + required double bottom, + }) { + final Rect rect = tester.getRect(finder); + expect(rect.top, greaterThanOrEqualTo(top)); + expect(rect.bottom, lessThanOrEqualTo(bottom)); + } + + for (final double scale in [1, 1.3, 2]) { + testWidgets( + '짧은 가로 화면(안전 영역 위아래 24 · 글자 $scale배)에서 낱말 알림이 떠도 넘치지 않고, 더 스크롤하지 않아도 알림 · [찾기]가 보인다', + (WidgetTester tester) async { + await pumpSheet( + tester, + size: const Size(780, 360), + safeTop: 24, + safeBottom: 24, + textScale: scale, + applied: const SearchConditions(from: '아홉'), + searchText: '하나 둘 셋 넷 다섯 여섯 일곱 여덟', + ); + await tester.ensureVisible(applyButton()); + await tester.pumpAndSettle(); + await tester.tap(applyButton()); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(sheet(), findsOneWidget); + expect(wordsAlert(), findsOneWidget); + expectShown(tester, wordsAlert(), top: 24, bottom: 360 - 24); + expectShown(tester, applyButton(), top: 24, bottom: 360 - 24); + }, + ); + } + + testWidgets( + '알림이 붙어 모양이 넘어가는 크기(800×340)에서도 [찾기]를 누르면 스크롤하지 않고 알림 · [찾기]가 화면 안에 보인다', + (WidgetTester tester) async { + await pumpSheet( + tester, + size: const Size(800, 340), + applied: const SearchConditions(from: '아홉'), + searchText: '하나 둘 셋 넷 다섯 여섯 일곱 여덟', + ); + // 열 때는 위 96 을 남긴 고정 모양 — [찾기]가 처음부터 보인다 + expect(tester.getTopLeft(sheet()).dy, AppSize.sheetTopGap); + expectShown(tester, applyButton(), top: 0, bottom: 340); + await tester.tap(applyButton()); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expectShown(tester, wordsAlert(), top: 0, bottom: 340); + expectShown(tester, applyButton(), top: 0, bottom: 340); + // 알림만큼 단추 줄이 높아져 위 96 을 없앤 고정 모양으로 — 머리도 그대로 보인다(한 스크롤로 펼쳐 끝으로 내려가지 않았다) + expect(tester.getTopLeft(sheet()).dy, 0); + expectShown( + tester, + inSheet(find.text(MailSearchTexts.conditions)), + top: 0, + bottom: 340, + ); + expect( + find.descendant( + of: middle(), + matching: find.text(MailSearchTexts.conditions), + ), + findsNothing, + ); + }, + ); + + testWidgets('한 스크롤로 펼친 모양(가로 + 자판)에서 글 칸의 [검색] 키로 띄운 알림도 자판 위에 보인다', ( + WidgetTester tester, + ) async { + await pumpSheet( + tester, + size: const Size(844, 390), + searchText: '하나 둘 셋 넷 다섯 여섯 일곱 여덟', + ); + await tester.tap( + find.descendant( + of: find.byKey(const ValueKey('search-condition-from')), + matching: find.byType(EditableText), + ), + ); + await tester.pump(); + await raiseKeyboard(tester, 200); + tester.testTextInput.enterText('아홉'); + await tester.pump(); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(sheet(), findsOneWidget); + expectShown(tester, wordsAlert(), top: 0, bottom: 390 - 200); + expectShown(tester, applyButton(), top: 0, bottom: 390 - 200); + }); + + testWidgets( + '펼치기 전에 위 96 부터 없앤다 — 780×360 · 안전 영역 24 는 머리 · 단추 줄 고정으로 열려 [찾기]가 처음부터 보이고 끌어 내려 닫힌다', + (WidgetTester tester) async { + await pumpSheet( + tester, + size: const Size(780, 360), + safeTop: 24, + safeBottom: 24, + ); + // 시트가 화면 위(상태 막대 아래)까지 — 가운데는 칸 둘이 넘게 들어간다 + expect(tester.getTopLeft(sheet()).dy, 24); + expect( + tester.getSize(middle()).height, + greaterThan(AppSize.fieldHeight * 2), + ); + expect( + find.descendant( + of: middle(), + matching: find.text(MailSearchTexts.conditions), + ), + findsNothing, + ); + expectShown(tester, applyButton(), top: 24, bottom: 360 - 24); + await tester.drag( + inSheet(find.text(MailSearchTexts.conditions)), + const Offset(0, 360), + ); + await tester.pumpAndSettle(); + expect(sheet(), findsNothing); + }, + ); + + testWidgets('자판을 내려 고정 모양으로 돌아와도 초점 칸이 보인다 — 가운데가 맨 위로 가 가려지지 않게', ( + WidgetTester tester, + ) async { + await pumpSheet(tester, size: const Size(844, 390)); + final Finder body = find.descendant( + of: find.byKey(const ValueKey('search-condition-body')), + matching: find.byType(EditableText), + ); + await tester.ensureVisible(body); + await tester.pumpAndSettle(); + await tester.tap(body); + await tester.pump(); + await raiseKeyboard(tester, 200); + expect(tester.takeException(), isNull); + expectShown(tester, body, top: 0, bottom: 390 - 200); + + tester.view.resetViewInsets(); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(tester.widget(body).focusNode.hasFocus, isTrue); + final Rect viewport = tester.getRect(middle()); + expectShown(tester, body, top: viewport.top, bottom: viewport.bottom); + }); + + testWidgets( + '자판이 조금씩 올라와도 모양은 차례로 한 번씩만 넘어간다 — 시트 위가 되돌아 내려오지 않고 초점 · 입력이 그대로', + (WidgetTester tester) async { + await pumpSheet(tester, size: const Size(844, 390)); + final Finder from = find.descendant( + of: find.byKey(const ValueKey('search-condition-from')), + matching: find.byType(EditableText), + ); + await tester.tap(from); + await tester.pump(); + final FocusNode node = tester.widget(from).focusNode; + addTearDown(tester.view.resetViewInsets); + double previous = tester.getTopLeft(sheet()).dy; + final Set tops = {previous}; + for (double keyboard = 4; keyboard <= 200; keyboard += 4) { + tester.view.viewInsets = FakeViewPadding( + bottom: keyboard * tester.view.devicePixelRatio, + ); + await tester.pump(); + final double top = tester.getTopLeft(sheet()).dy; + expect(top, lessThanOrEqualTo(previous), reason: '자판 $keyboard'); + previous = top; + tops.add(top); + } + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + // 위 96 을 남긴 고정 → 위 96 없는 고정 → 한 스크롤(시트 위는 화면 위) + expect(tops, containsAll([AppSize.sheetTopGap, 0])); + expect(tester.widget(from).focusNode, same(node)); + expect(node.hasFocus, isTrue); + expect(tester.testTextInput.hasAnyClients, isTrue); + }, + ); + + testWidgets('열면 걸린 조건으로 채운다 — [찾기] 없이 닫으면([✕] · 뒤로) 고친 값을 버린다', ( + WidgetTester tester, + ) async { + seedInbox(); + await openSearch(tester); + await openSheet(tester); + expect(textOf(tester, 'from'), isEmpty); + expect(periodShown(tester), MailSearchTexts.period(SearchPeriod.all)); + await type(tester, 'from', '이도윤'); + await choosePeriod(tester, SearchPeriod.month); + await toggleAttachment(tester); + await apply(tester); + + await openSheet(tester); + expect(textOf(tester, 'from'), '이도윤'); + expect(periodShown(tester), MailSearchTexts.period(SearchPeriod.month)); + expect(attachmentOf(tester), isTrue); + + // 고치고 [✕] + await type(tester, 'from', '박지훈'); + await toggleAttachment(tester); + await tester.tap( + inSheet(find.byTooltip(MailSearchTexts.closeConditions)), + ); + await tester.pumpAndSettle(); + expect(sheet(), findsNothing); + await openSheet(tester); + expect(textOf(tester, 'from'), '이도윤'); + expect(attachmentOf(tester), isTrue); + + // 고치고 뒤로 가기 + await type(tester, 'subject', '견적'); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(sheet(), findsNothing); + await openSheet(tester); + expect(textOf(tester, 'subject'), isEmpty); + // 닫기만 해서는 다시 찾지 않았다 + expect(h.mail.searchQueries, hasLength(1)); + }); + + testWidgets( + '[조건 지우기]는 칸만 비운다 — 기간 · 날짜 · 첨부까지, 시트는 열린 채 · 찾지 않음 · 초점은 그 단추', + (WidgetTester tester) async { + seedInbox(); + await openSearch(tester); + await openSheet(tester); + await type(tester, 'from', '이도윤'); + await type(tester, 'body', '단가'); + await choosePeriod(tester, SearchPeriod.custom); + await pickDay(tester, 'since'); + await toggleAttachment(tester); + await apply(tester); + expect(h.mail.searchQueries, hasLength(1)); + + await openSheet(tester); + await tester.ensureVisible(sheetClear()); + await tester.tap(sheetClear()); + await tester.pumpAndSettle(); + expect(sheet(), findsOneWidget); + expect(textOf(tester, 'from'), isEmpty); + expect(textOf(tester, 'body'), isEmpty); + expect(periodShown(tester), MailSearchTexts.period(SearchPeriod.all)); + expect(dateField('since'), findsNothing); + expect(attachmentOf(tester), isFalse); + expect( + tester.widget(sheetClear()).focusNode!.hasFocus, + isTrue, + ); + expect(h.mail.searchQueries, hasLength(1)); + // 걸린 조건 · 조건 줄은 [찾기]를 누를 때까지 그대로 + expect(line(), findsOneWidget); + }, + ); + + testWidgets('[찾기]는 다듬은 조건을 걸고 칸의 지금 글로 찾는다 — 시트를 닫고 자판을 내리고 조건 줄', ( + WidgetTester tester, + ) async { + seedInbox(); + await openSearch(tester); + await tester.enterText(field(), '계약'); + await tester.pump(); + await openSheet(tester); + await type(tester, 'from', ' 이도윤 '); + await type(tester, 'subject', '갱신'); + await toggleAttachment(tester); + await apply(tester); + + expect(sheet(), findsNothing); + expect(h.mail.searchQueries.single, { + 'q': '계약', + 'from': '이도윤', + 'subject': '갱신', + 'hasAttachment': 'true', + 'mailboxId': kInboxId, + 'limit': kMailPageSize, + }); + expect(find.text('Re: 유지보수 계약 갱신 건'), findsOneWidget); + expect(find.text('계약서 초안 보내 드립니다'), findsNothing); + expect( + findCopy('조건: 이도윤 님이 보낸 메일, 제목에 ‘갱신’이 든 메일, 첨부 있음'), + findsOneWidget, + ); + expect(find.text(MailSearchTexts.count('계약', 1)), findsOneWidget); + }); + + testWidgets('최근 N 은 넘길 때 그 날짜로 바꾼다 — after · before 는 기기 시간대의 0시(UTC)', ( + WidgetTester tester, + ) async { + seedInbox(); + await openSearch(tester); + await openSheet(tester); + await choosePeriod(tester, SearchPeriod.week); + await apply(tester); + final ({SearchDay? since, SearchDay? until}) week = periodRange( + SearchPeriod.week, + h.clock.now, + ); + expect(h.mail.searchQueries.single, { + 'after': _localIso(week.since!), + 'before': _localIso(week.until!, addDays: 1), + 'mailboxId': kInboxId, + 'limit': kMailPageSize, + }); + expect(findCopy('조건: 최근 1주'), findsOneWidget); + }); + + testWidgets('낱말이 합쳐 8개를 넘으면 시트를 연 채 단추 줄 위에 알린다(요청 없음) — 낱말을 고치면 지운다', ( + WidgetTester tester, + ) async { + await openSearch(tester); + await tester.enterText(field(), '견적 단가 납품 일정'); + await tester.pump(); + await openSheet(tester); + await type(tester, 'from', '이도윤'); + await type(tester, 'subject', '계약 갱신 확인 요청 건'); + await apply(tester); + + expect(sheet(), findsOneWidget); + final Finder alert = inSheet( + findCopy(MailSearchTexts.tooManyWordsHint(kMailSearchMaxWords)), + ); + expect(alert, findsOneWidget); + expect(tester.getSemantics(alert), isSemantics(isLiveRegion: true)); + expect( + tester.getBottomLeft(alert).dy, + lessThan(tester.getTopLeft(applyButton()).dy), + ); + expect(h.mail.searchQueries, isEmpty); + + await type(tester, 'subject', '계약 갱신'); + expect(alert, findsNothing); + await apply(tester); + expect(sheet(), findsNothing); + expect(h.mail.searchQueries.single['subject'], '계약 갱신'); + }); + + testWidgets('기간 "날짜 고르기"면 날짜 칸 둘 — 최근 N 에서 바꾸면 그 기간의 날짜로 채워 둔다', ( + WidgetTester tester, + ) async { + await openSearch(tester); + await openSheet(tester); + expect(dateField('since'), findsNothing); + // 고르기 칸은 "기간"과 고른 기간을 한 단추로 읽는다 · 높이 52 + expect( + tester.getSemantics(period()), + isSemantics( + label: + '${MailSearchTexts.periodLabel}\n${MailSearchTexts.period(SearchPeriod.all)}', + isButton: true, + ), + ); + expect(tester.getSize(period()).height, AppSize.fieldHeight); + expect(tester.getSize(textField('from')).height, AppSize.fieldHeight); + await choosePeriod(tester, SearchPeriod.custom); + // 전체 기간에서 바꾸면 빈 칸 — 오른쪽 달력 아이콘 + expect(dateField('since'), findsOneWidget); + expect( + find.descendant( + of: dateField('since'), + matching: find.byIcon(Icons.calendar_today_outlined), + ), + findsOneWidget, + ); + expect( + tester.getSemantics( + find.descendant( + of: dateField('until'), + matching: find.byType(InkWell), + ), + ), + isSemantics(label: MailSearchTexts.until, isButton: true), + ); + + await choosePeriod(tester, SearchPeriod.week); + await tester.tap( + inSheet(find.byTooltip(MailSearchTexts.closeConditions)), + ); + await tester.pumpAndSettle(); + await openSheet(tester); + await choosePeriod(tester, SearchPeriod.week); + await choosePeriod(tester, SearchPeriod.custom); + final ({SearchDay? since, SearchDay? until}) week = periodRange( + SearchPeriod.week, + h.clock.now, + ); + expect( + find.descendant( + of: dateField('since'), + matching: find.text(formatSearchDay(week.since!)), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: dateField('until'), + matching: find.text(formatSearchDay(week.until!)), + ), + findsOneWidget, + ); + // 값이 있으면 오른쪽 [✕] — 그 날짜 지우기 + await tester.tap( + find.descendant( + of: dateField('since'), + matching: find.byTooltip(MailSearchTexts.clearSince), + ), + ); + await tester.pumpAndSettle(); + expect( + find.descendant( + of: dateField('since'), + matching: find.byIcon(Icons.calendar_today_outlined), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: dateField('until'), + matching: find.byTooltip(MailSearchTexts.clearUntil), + ), + findsOneWidget, + ); + }); + + testWidgets( + '날짜 고르는 창 — 머리 "시작 날짜", 1970-01-01 ~ 오늘, 처음은 칸의 날짜 · 가림막은 대화상자 가림막 색', + (WidgetTester tester) async { + await openSearch(tester); + await openSheet(tester); + await choosePeriod(tester, SearchPeriod.custom); + await tester.ensureVisible(dateField('since')); + await tester.pumpAndSettle(); + await tester.tap(dateField('since')); + await tester.pumpAndSettle(); + expect(find.byType(DatePickerDialog), findsOneWidget); + final DatePickerDialog dialog = tester.widget( + find.byType(DatePickerDialog), + ); + expect(dialog.helpText, MailSearchTexts.since); + expect(dialog.firstDate, DateTime(1970)); + expect(dialog.lastDate, today().toLocalDate()); + expect(dialog.initialDate, today().toLocalDate()); + expect( + tester.widgetList(find.byType(ModalBarrier)).last.color, + AppColors.backdrop, + ); + await tester.tap( + find.descendant( + of: find.byType(DatePickerDialog), + matching: find.text('1'), + ), + ); + await tester.tap(find.text('확인')); + await tester.pumpAndSettle(); + final SearchDay first = SearchDay(today().year, today().month, 1); + expect( + find.descendant( + of: dateField('since'), + matching: find.text(formatSearchDay(first)), + ), + findsOneWidget, + ); + expect( + tester.getSemantics( + find + .descendant( + of: dateField('since'), + matching: find.byType(InkWell), + ) + .first, + ), + isSemantics( + label: MailSearchTexts.since, + value: formatSearchDay(first), + isButton: true, + ), + ); + + // 다시 열면 칸의 날짜에서 시작한다 + await tester.tap(dateField('since')); + await tester.pumpAndSettle(); + expect( + tester + .widget(find.byType(DatePickerDialog)) + .initialDate, + first.toLocalDate(), + ); + await tester.tap(find.text('취소')); + await tester.pumpAndSettle(); + }, + ); + + testWidgets('날짜가 거꾸로면 고른 즉시 끝 날짜 칸이 빨갛고 두 칸 아래에 알린다 — [찾기]는 넘기지 않고 그 칸으로', ( + WidgetTester tester, + ) async { + // 작은 휴대폰 — 가운데를 맨 위로 올리면 끝 날짜 칸이 단추 줄 아래로 가려진다 + await openSearch(tester, size: const Size(390, 640)); + await openSheet(tester); + await choosePeriod(tester, SearchPeriod.custom); + await pickDay(tester, 'since'); + expect(findCopy(MailSearchTexts.periodOrder), findsNothing); + await pickDay(tester, 'until', day: 1); + + final Finder error = inSheet(findCopy(MailSearchTexts.periodOrder)); + expect(error, findsOneWidget); + expect(tester.getSemantics(error), isSemantics(isLiveRegion: true)); + final Finder untilPick = find + .descendant(of: dateField('until'), matching: find.byType(InkWell)) + .first; + expect( + tester.getSemantics(untilPick), + isSemantics( + label: MailSearchTexts.until, + hint: MailSearchTexts.periodOrder, + validationResult: SemanticsValidationResult.invalid, + isButton: true, + ), + ); + expect( + tester.getSemantics( + find + .descendant( + of: dateField('since'), + matching: find.byType(InkWell), + ) + .first, + ), + isSemantics( + label: MailSearchTexts.since, + validationResult: SemanticsValidationResult.none, + isButton: true, + ), + ); + + // 가운데를 맨 위로 올려 끝 날짜 칸을 가려 두고 [찾기] — 그 칸이 보이게 스크롤하고 초점을 둔다 + await tester.drag(middle(), const Offset(0, 600)); + await tester.pumpAndSettle(); + expect( + tester.getRect(dateField('until')).bottom, + greaterThan(tester.getTopLeft(applyButton()).dy), + ); + await apply(tester); + expect(sheet(), findsOneWidget); + expect(h.mail.searchQueries, isEmpty); + expect(tester.widget(untilPick).focusNode!.hasFocus, isTrue); + expect( + tester.getRect(dateField('until')).bottom, + lessThanOrEqualTo(tester.getTopLeft(applyButton()).dy), + ); + + // 끝 날짜를 지우면 알림이 사라지고 넘긴다 — "…부터" + await tester.tap( + find.descendant( + of: dateField('until'), + matching: find.byTooltip(MailSearchTexts.clearUntil), + ), + ); + await tester.pumpAndSettle(); + expect(error, findsNothing); + await apply(tester); + expect(sheet(), findsNothing); + expect(h.mail.searchQueries.single['after'], _localIso(today())); + expect(h.mail.searchQueries.single.containsKey('before'), isFalse); + expect(findCopy('조건: ${formatSearchDay(today())}부터'), findsOneWidget); + }); + + testWidgets('글 칸의 자판은 검색 칸과 같은 결 — [검색] 키는 [찾기]', ( + WidgetTester tester, + ) async { + seedInbox(); + await openSearch(tester); + await openSheet(tester); + final TextField input = tester.widget(textField('to')); + expect(input.keyboardType, TextInputType.text); + expect(input.textInputAction, TextInputAction.search); + expect(input.autocorrect, isFalse); + expect(input.smartQuotesType, SmartQuotesType.disabled); + expect(input.smartDashesType, SmartDashesType.disabled); + expect( + tester.getSemantics(textField('to')).label, + contains(MailSearchTexts.toLabel), + ); + + await tester.enterText(textField('to'), '김아라'); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pumpAndSettle(); + expect(sheet(), findsNothing); + expect(h.mail.searchQueries.single['to'], '김아라'); + // 로그인한 직원의 이름 — "내가 받은 메일" + expect(findCopy('조건: 내가 받은 메일'), findsOneWidget); + }); + }); + + group('결과 화면', () { + testWidgets('조건만으로 찾으면 개수 "N통" · 조건 줄 — [조건 지우기]는 요청 없이 찾기 전으로', ( + WidgetTester tester, + ) async { + seedInbox(); + await openSearch(tester); + await openSheet(tester); + await type(tester, 'from', '이도윤'); + await toggleAttachment(tester); + await apply(tester); + + expect(h.mail.searchQueries.single.containsKey('q'), isFalse); + expect(find.text(MailSearchTexts.count('', 2)), findsOneWidget); + expect(MailSearchTexts.count('', 2), '2통'); + expect(findCopy('조건: 이도윤 님이 보낸 메일, 첨부 있음'), findsOneWidget); + + await tester.tap(lineClear()); + await tester.pumpAndSettle(); + expect(h.mail.searchQueries, hasLength(1)); + expect(line(), findsNothing); + expect(findCopy(MailSearchTexts.intro), findsOneWidget); + expect( + tester.widget(toggle()).style!.backgroundColor, + isNull, + ); + }); + + testWidgets('조건 줄의 [조건 지우기]는 걸린 조건을 모두 빼고 검색어로 다시 찾는다', ( + WidgetTester tester, + ) async { + seedInbox(); + await openSearch(tester); + await tester.enterText(field(), '계약'); + await tester.pump(); + await openSheet(tester); + await type(tester, 'from', '이도윤'); + await apply(tester); + expect(find.text(MailSearchTexts.count('계약', 2)), findsOneWidget); + + await tester.tap(lineClear()); + await tester.pumpAndSettle(); + expect(h.mail.searchQueries, hasLength(2)); + expect(h.mail.searchQueries.last, { + 'q': '계약', + 'mailboxId': kInboxId, + 'limit': kMailPageSize, + }); + expect(line(), findsNothing); + expect(find.text(MailSearchTexts.count('계약', 3)), findsOneWidget); + }); + + testWidgets('결과 없음 — 검색어 + 조건 · 조건만 · 검색어만이 각각 다른 글', ( + WidgetTester tester, + ) async { + seedInbox(); + await openSearch(tester); + await tester.enterText(field(), '해지'); + await tester.pump(); + await openSheet(tester); + await type(tester, 'from', '이도윤'); + await apply(tester); + expect(findCopy(MailSearchTexts.emptyTitle('해지')), findsOneWidget); + expect(findCopy(MailSearchTexts.emptyHint), findsOneWidget); + + await tester.enterText(field(), ''); + await tester.pump(); + await openSheet(tester); + await type(tester, 'from', '최유나'); + await apply(tester); + expect(findCopy('조건에 맞는 메일이 없습니다'), findsOneWidget); + expect(findCopy(MailSearchTexts.emptyConditionsHint), findsOneWidget); + expect(find.text(MailSearchTexts.count('', 0)), findsOneWidget); + + await tester.tap(lineClear()); + await tester.pumpAndSettle(); + await search(tester, '해지'); + expect(findCopy(MailSearchTexts.emptyTitle('해지')), findsOneWidget); + expect(findCopy(MailSearchTexts.emptyWordsHint), findsOneWidget); + }); + + testWidgets( + '[검색] 키로 낱말이 합쳐 8개를 넘으면 결과 자리에 알린다(요청 없음) — 조건 줄과 [조건 지우기]는 남는다', + (WidgetTester tester) async { + await openSearch(tester); + await openSheet(tester); + await type(tester, 'subject', '계약 갱신 확인'); + await apply(tester); + final int before = h.mail.searchQueries.length; + + await search(tester, '견적 단가 납품 일정 변경 요청'); + expect(h.mail.searchQueries, hasLength(before)); + expect(findCopy(MailSearchTexts.tooManyWordsTitle), findsOneWidget); + expect( + findCopy('검색 칸과 [조건]에 넣은 낱말은 모두 합쳐 8개까지입니다. 낱말을 몇 개 줄여 주세요.'), + findsOneWidget, + ); + expect(findCopy('조건: 제목에 ‘계약 갱신 확인’이 든 메일'), findsOneWidget); + + // 조건을 빼면 칸의 글(6낱말)로 찾는다 + await tester.tap(lineClear()); + await tester.pumpAndSettle(); + expect(h.mail.searchQueries, hasLength(before + 1)); + expect(h.mail.searchQueries.last['q'], '견적 단가 납품 일정 변경 요청'); + expect(findCopy(MailSearchTexts.tooManyWordsTitle), findsNothing); + }, + ); + + testWidgets('[✕]는 걸린 조건을 남기고 · 빈 칸의 [검색]은 조건만으로 · 찾을 곳을 바꿔도 조건 그대로', ( + WidgetTester tester, + ) async { + seedInbox(); + await openSearch(tester); + await tester.enterText(field(), '계약'); + await tester.pump(); + await openSheet(tester); + await type(tester, 'from', '이도윤'); + await apply(tester); + + await tester.tap(find.byTooltip(MailSearchTexts.clear)); + await tester.pumpAndSettle(); + expect(findCopy(MailSearchTexts.intro), findsOneWidget); + expect(line(), findsOneWidget); + + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pumpAndSettle(); + expect(h.mail.searchQueries.last, { + 'from': '이도윤', + 'mailboxId': kInboxId, + 'limit': kMailPageSize, + }); + expect(find.text(MailSearchTexts.count('', 2)), findsOneWidget); + + await tester.tap(find.text(MailSearchTexts.scopeAll)); + await tester.pumpAndSettle(); + expect(h.mail.searchQueries.last, { + 'from': '이도윤', + 'limit': kMailPageSize, + }); + + // 당겨서 새로고침도 걸린 조건 그대로 + await tester.fling( + find.text('Re: 유지보수 계약 갱신 건'), + const Offset(0, 400), + 1000, + ); + await tester.pumpAndSettle(); + expect(h.mail.searchQueries.last['from'], '이도윤'); + }); + + testWidgets('검색어도 조건도 비운 [찾기]는 요청 없이 찾기 전으로', (WidgetTester tester) async { + seedInbox(); + await openSearch(tester); + await openSheet(tester); + await type(tester, 'from', '이도윤'); + await apply(tester); + expect(h.mail.searchQueries, hasLength(1)); + + await openSheet(tester); + await tester.tap(sheetClear()); + await tester.pumpAndSettle(); + await apply(tester); + expect(sheet(), findsNothing); + expect(h.mail.searchQueries, hasLength(1)); + expect(line(), findsNothing); + expect(findCopy(MailSearchTexts.intro), findsOneWidget); + }); + + testWidgets('결과에서 연 메일에는 검색어와 글 조건만 강조 쿼리로 싣는다 — 기간 · 첨부는 싣지 않는다', ( + WidgetTester tester, + ) async { + seedInbox(); + h.mail.searchHighlightField = true; + await openSearch(tester); + await tester.enterText(field(), '계약'); + await tester.pump(); + await openSheet(tester); + await type(tester, 'from', '이도윤'); + await type(tester, 'subject', '갱신'); + await choosePeriod(tester, SearchPeriod.month); + await toggleAttachment(tester); + await apply(tester); + + await tester.tap(find.text('Re: 유지보수 계약 갱신 건')); + await tester.pumpAndSettle(); + expect(find.byType(MessageReaderPage), findsOneWidget); + expect(h.mail.detailQueries.last.query, { + 'q': '계약', + 'from': '이도윤', + 'subject': '갱신', + }); + }); + + testWidgets('조건 줄은 최소 48 — 글이 길면 낱말 단위로 줄을 바꿔 늘어난다(잘리지 않는다) · [조건 지우기]는 48', ( + WidgetTester tester, + ) async { + tester.view.physicalSize = kPhoneSize * 3; + tester.view.devicePixelRatio = 3; + addTearDown(tester.view.reset); + Future heightOf(String text) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + MailSearchConditionLine(text: text, onClear: () {}), + ], + ), + ), + ), + ); + expect(tester.takeException(), isNull); + return tester.getSize(find.byType(MailSearchConditionLine)).height; + } + + expect(await heightOf('조건: 최근 1달'), AppSize.touchMin); + expect( + await heightOf( + '조건: 이도윤 님이 보낸 메일, 제목에 ‘유지보수 계약 갱신’이 든 메일, 2026. 10. 1.부터 11. 5.까지, 첨부 있음', + ), + greaterThan(AppSize.touchMin), + ); + expect( + tester.getSize(find.byType(TextButton)).height, + greaterThanOrEqualTo(AppSize.touchMin), + ); + }); + }); +} diff --git a/flutter/test/features/mail/mail_search_controller_test.dart b/flutter/test/features/mail/mail_search_controller_test.dart index 3a27c7f..a6cf4db 100644 --- a/flutter/test/features/mail/mail_search_controller_test.dart +++ b/flutter/test/features/mail/mail_search_controller_test.dart @@ -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 searchRequests() => [ for (final RequestOptions r in mail.listRequests) - if (r.queryParameters.containsKey('q')) r, + if (FakeMailBackend.isSearchQuery(r.queryParameters)) r, ]; MessageSummary summaryOf(Map 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 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, { + 'from': '이도윤', + 'hasAttachment': 'true', + 'mailboxId': kInboxId, + 'limit': kMailPageSize, + }); + + await controllerOf(args).loadMore(); + expect(searchRequests().last.queryParameters, { + '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, { + '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, { + '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)); + }); + }); } diff --git a/flutter/test/features/mail/mail_search_page_test.dart b/flutter/test/features/mail/mail_search_page_test.dart index d180816..35bd524 100644 --- a/flutter/test/features/mail/mail_search_page_test.dart +++ b/flutter/test/features/mail/mail_search_page_test.dart @@ -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)); diff --git a/flutter/test/features/mail/mail_search_view_test.dart b/flutter/test/features/mail/mail_search_view_test.dart index 5a1c069..2db6708 100644 --- a/flutter/test/features/mail/mail_search_view_test.dart +++ b/flutter/test/features/mail/mail_search_view_test.dart @@ -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, + ); + }); + }); } diff --git a/flutter/test/features/mail/search_conditions_test.dart b/flutter/test/features/mail/search_conditions_test.dart new file mode 100644 index 0000000..4d02e35 --- /dev/null +++ b/flutter/test/features/mail/search_conditions_test.dart @@ -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 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), + { + '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, + ), + {'before': '2026-12-31T15:00:00.000Z'}, + ); + expect( + searchConditionsParams(_conditions(until: '2026-12-31')), + {'before': _localIso(2027, 1, 1)}, + ); + expect( + searchConditionsParams( + _conditions(since: '2026-03-01'), + midnight: _kstMidnight, + ), + {'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(), + {'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([ + 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(); diff --git a/flutter/test/support/mail_fakes.dart b/flutter/test/support/mail_fakes.dart index 9e91c08..b1f0cdb 100644 --- a/flutter/test/support/mail_fakes.dart +++ b/flutter/test/support/mail_fakes.dart @@ -196,10 +196,26 @@ class FakeMailBackend { 'total': 10, }); + /// 검색 요청의 쿼리 이름 — 이 가운데 하나라도 있으면 검색이다(검색어 또는 조건 — 계약 2절 "검색 — 14단계") + static const Set searchKeys = { + 'q', + 'from', + 'to', + 'subject', + 'body', + 'after', + 'before', + 'hasAttachment', + }; + + /// 검색 요청인가 — 메일함 목록 요청과 가른다 + static bool isSearchQuery(Map query) => + query.keys.any(searchKeys.contains); + /// 받은 검색 요청의 쿼리 (차례대로) List> get searchQueries => >[ 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? gate = searchGate; if (gate != null) { searchGate = null; @@ -360,9 +376,10 @@ class FakeMailBackend { /// 기본 검색 — 찾을 곳(메일함 · 없으면 휴지통 · 스팸메일함 밖 모두)의 메일 가운데 제목 · 미리 보기에 q 가 든 것을 /// 받은 시각 내림차순(같으면 id 내림차순 — 서버 색인과 같다)으로 한 쪽씩. 커서는 그 쪽 끝 줄의 자리([searchCursorAfter]) — /// 서버처럼 그 줄 **다음**부터 준다(그사이 앞쪽 메일이 빠지거나 되돌아와도 다음 쪽은 밀리지도 겹치지도 않는다). 전체 수는 첫 쪽에만 싣는다. + /// 조건([_matchesConditions] — 보낸 사람 · 받는 사람 · 제목 · 내용(미리 보기로 대신) · 받은 시각 · 첨부)도 거른다. q 가 없으면 조건만으로. /// [searchResponder] 가 쪽마다 이것을 불러 섞어 쓸 수 있다 FakeResponse defaultSearch(Map query) { - final String q = query['q']! as String; + final String? q = query['q'] as String?; final Object? mailboxId = query['mailboxId']; final List> pool = >[ for (final MapEntry>> entry @@ -380,8 +397,10 @@ class FakeMailBackend { pool .where( (Map 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 a, Map b) { @@ -416,6 +435,39 @@ class FakeMailBackend { }); } + /// 기본 검색의 조건 — 글 조건은 그 칸에 그 글이 들었는지(보낸 사람 · 받는 사람은 이름이나 주소, 내용은 미리 보기로 대신), + /// 기간은 받은 시각이 `after` 이상 · `before` 미만(둘 다 UTC ISO 라 글로 견준다), 첨부는 `hasAttachment=true` 일 때 있는 메일만 + static bool _matchesConditions( + Map message, + Map 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 && + (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).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 가 더 작은 쪽) static String searchCursorAfter(Map message) => 's:${message['receivedAt']}|${message['id']}'; @@ -463,7 +515,7 @@ class FakeMailBackend { final Map? detail = detailJsonOf( id, showImages: images == kMailImagesShow, - highlighted: options.queryParameters.containsKey('q'), + highlighted: isSearchQuery(options.queryParameters), ); return detail == null ? FakeResponse.fail(404, 'RES_001')