generated from ttipo/project-base
feat(flutter): 메일 검색 2차 — 조건으로 찾기 시트와 결과의 조건 줄
2026-11-07 확정한 보드 App1SearchConditions 대로 웹 14단계의 [조건] 패널을 바닥 시트로 옮겼다. 보낸 사람 · 받는 사람 · 제목 · 내용 · 기간 · 첨부로 찾고, 결과에는 "조건: …" 줄과 [조건 지우기]를 둔다. 조건 다듬기 · 합친 낱말 수 · 기간 · 날짜의 UTC 시각 · 강조 쿼리 · "나"는 웹 mail-search.ts 와 짝이다. [조건]은 위 막대 맨 오른쪽에 고정해 글을 써도 옆으로 밀리지 않게 했고, 시트에서 낱말 8개를 넘기면 시트를 연 채 알린다. 날짜는 오늘까지만 고른다. 바닥 시트 가림막을 앱 대화상자 색으로 맞춰 메일함 옮기기 시트도 같은 색이 된다. 리뷰에서 가로 화면에 자판이 오르면 시트 모양이 바뀌며 칸이 새로 만들어져 초점을 잃는 것을 찾았다. 시트를 한 트리로 두고 같은 레이아웃 안에서 머리 · 단추 줄의 실제 높이로 모양 셋(위를 남긴 고정 · 화면 위까지 고정 · 한 스크롤)을 고르게 고쳤고, 모양이 바뀌거나 알림이 뜨면 초점 칸 · 알림을 보이게 한다. 시험 872 → 932. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ import 'package:arasystem_app/features/mail/data/models/mail_search.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/message_detail.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/message_summary.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/search_conditions.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
/// 메일 API 경로 — baseUrl(`…/api`) 뒤에 붙는다(계약 2절). 경로는 대소문자를 가린다
|
||||
@@ -103,11 +104,13 @@ class MailRepository {
|
||||
}
|
||||
|
||||
/// 메일 검색 — 받은 시각 내림차순 (계약 2절 "검색" · "검색 — 14단계"). [mailboxId] 가 없으면 휴지통 · 스팸메일함을 뺀 모든 메일함에서 찾는다.
|
||||
/// [cursor] 는 해석하지 않고 그대로 보낸다(방식 · 범위 · 검색어가 그 안에 묶여 있다). 색인 방식은 `hasNext` 인데 항목이 [limit] 보다 적거나 0개일 수 있다.
|
||||
/// [q] 가 비면 싣지 않는다 — 조건([conditions] — `from` · `to` · `subject` · `body` · `after` · `before` · `hasAttachment`, 빈 칸은 빼고)만으로 찾는다.
|
||||
/// [cursor] 는 해석하지 않고 그대로 보낸다(방식 · 범위 · 검색어 · 조건이 그 안에 묶여 있다). 색인 방식은 `hasNext` 인데 항목이 [limit] 보다 적거나 0개일 수 있다.
|
||||
/// [silent] 면 실패를 알리지 않는다(처음 찾는 쪽은 화면이 받지 못함을 직접 그린다). [cancelToken] 으로 끊은 요청은 알리지 않는다.
|
||||
/// 응답의 서버 시각(`Date`)도 싣는다 — 결과의 붙잡힌 메일이 풀릴 때를 서버 시계로 잰다
|
||||
Future<MailPage<MailSearchItem>> searchMessages({
|
||||
required String q,
|
||||
SearchConditions conditions = SearchConditions.none,
|
||||
String? mailboxId,
|
||||
String? cursor,
|
||||
int limit = kMailPageSize,
|
||||
@@ -117,7 +120,8 @@ class MailRepository {
|
||||
final Response<Object?> response = await _dio.get<Object?>(
|
||||
MailApiPaths.messages,
|
||||
queryParameters: <String, Object?>{
|
||||
'q': q,
|
||||
if (q.isNotEmpty) 'q': q,
|
||||
...searchConditionsParams(conditions),
|
||||
'mailboxId': ?mailboxId,
|
||||
'limit': limit,
|
||||
'cursor': ?cursor,
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
/// 메일 검색의 조건(2차 — 보낸 사람 · 받는 사람 · 제목 · 내용 · 기간 · 첨부) — 순수 함수 (계약 2절 "검색 — 14단계").
|
||||
///
|
||||
/// **짝: 웹 `frontend/src/shared/utils/mail-search.ts`(조건 다듬기 · API 쿼리 · 읽기 화면의 강조 쿼리 · 합친 낱말 수 · 한 글자 판정 · 기간 고르기)
|
||||
/// · `mail-vacation.ts` 의 `startOfDayIso` · `endOfDayIso`.** 같은 규칙을 그대로 옮겼다 — 한쪽을 고치면 둘 다.
|
||||
/// 조건 줄의 글(“조건: 이도윤 님이 보낸 메일, …”)은 화면 글이라 `presentation/mail_search_view.dart` 에 있다.
|
||||
///
|
||||
/// 웹과 다른 점: 조건은 화면 상태에만 둔다(주소에 싣지 않는다 — 앱의 검색어와 같다). 날짜는 글(YYYY-MM-DD)이 아니라 [SearchDay] 다.
|
||||
library;
|
||||
|
||||
import 'package:arasystem_app/core/utils/js_case.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mail_search.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/search_text.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 기기 시간대의 날짜 하나(시각 없음) — 조건의 시작 날짜 · 끝 날짜. 두 날짜 모두 든다(계약 — `since` · `until` 은 양 끝을 포함한다)
|
||||
@immutable
|
||||
class SearchDay implements Comparable<SearchDay> {
|
||||
const SearchDay(this.year, this.month, this.day);
|
||||
|
||||
/// [date] 의 날짜 — 기기 시간대로 본다(UTC 시각이면 기기 시간대로 바꾼 날짜)
|
||||
factory SearchDay.of(DateTime date) {
|
||||
final DateTime local = date.toLocal();
|
||||
return SearchDay(local.year, local.month, local.day);
|
||||
}
|
||||
|
||||
final int year;
|
||||
final int month;
|
||||
final int day;
|
||||
|
||||
/// 있는 날짜인가 — 2월 30일처럼 넘어간 날짜는 아니다
|
||||
bool get isValid {
|
||||
final DateTime check = DateTime.utc(year, month, day);
|
||||
return check.year == year && check.month == month && check.day == day;
|
||||
}
|
||||
|
||||
/// 날짜 고르는 창에 넘기는 값 — 기기 시간대의 그날(시각 0시)
|
||||
DateTime toLocalDate() => DateTime(year, month, day);
|
||||
|
||||
bool isAfter(SearchDay other) => compareTo(other) > 0;
|
||||
|
||||
@override
|
||||
int compareTo(SearchDay other) {
|
||||
if (year != other.year) return year.compareTo(other.year);
|
||||
if (month != other.month) return month.compareTo(other.month);
|
||||
return day.compareTo(other.day);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is SearchDay &&
|
||||
other.year == year &&
|
||||
other.month == month &&
|
||||
other.day == day;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(year, month, day);
|
||||
|
||||
/// YYYY-MM-DD (웹 `<input type="date">` 의 값과 같은 모양 — 시험 · 진단용)
|
||||
@override
|
||||
String toString() =>
|
||||
'${year.toString().padLeft(4, '0')}-${month.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
/// 고를 수 있는 날짜의 처음 — 날짜 고르는 창의 `firstDate` (웹 `SEARCH_DAY_MIN`)
|
||||
const SearchDay kSearchDayMin = SearchDay(1970, 1, 1);
|
||||
|
||||
/// 고를 수 있는 날짜의 끝 — 9999-12-31 은 UTC 의 다음 날 0시가 10000년이라 서버가 받지 않는다(웹 `SEARCH_DAY_MAX`).
|
||||
/// 앱의 날짜 고르는 창은 오늘까지만 보인다
|
||||
const SearchDay kSearchDayMax = SearchDay(9999, 12, 30);
|
||||
|
||||
/// 찾을 수 있는 날짜인가 — 있는 날짜이고 [kSearchDayMin] ~ [kSearchDayMax] 안 (웹 `isSearchDay`)
|
||||
bool isSearchDay(SearchDay day) =>
|
||||
day.isValid &&
|
||||
day.compareTo(kSearchDayMin) >= 0 &&
|
||||
day.compareTo(kSearchDayMax) <= 0;
|
||||
|
||||
/// 검색 칸 밖의 조건 — 글 칸은 비면 없는 것, 날짜는 기기 시간대의 날짜(두 날짜 모두 든다)
|
||||
@immutable
|
||||
class SearchConditions {
|
||||
const SearchConditions({
|
||||
this.from = '',
|
||||
this.to = '',
|
||||
this.subject = '',
|
||||
this.body = '',
|
||||
this.since,
|
||||
this.until,
|
||||
this.hasAttachment = false,
|
||||
});
|
||||
|
||||
/// 조건 없음
|
||||
static const SearchConditions none = SearchConditions();
|
||||
|
||||
/// 보낸 사람(이름 · 주소)
|
||||
final String from;
|
||||
|
||||
/// 받는 사람과 참조
|
||||
final String to;
|
||||
final String subject;
|
||||
|
||||
/// 본문
|
||||
final String body;
|
||||
|
||||
/// 시작 날짜 — 그날 0시부터. 없으면 null
|
||||
final SearchDay? since;
|
||||
|
||||
/// 끝 날짜 — 그날이 끝날 때까지(다음 날 0시 앞). 없으면 null
|
||||
final SearchDay? until;
|
||||
|
||||
/// 첨부 파일이 있는 메일만
|
||||
final bool hasAttachment;
|
||||
|
||||
/// 조건이 하나도 없다 (웹 `hasSearchConditions` 의 반대)
|
||||
bool get isEmpty =>
|
||||
from.isEmpty &&
|
||||
to.isEmpty &&
|
||||
subject.isEmpty &&
|
||||
body.isEmpty &&
|
||||
since == null &&
|
||||
until == null &&
|
||||
!hasAttachment;
|
||||
|
||||
bool get isNotEmpty => !isEmpty;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is SearchConditions &&
|
||||
other.from == from &&
|
||||
other.to == to &&
|
||||
other.subject == subject &&
|
||||
other.body == body &&
|
||||
other.since == since &&
|
||||
other.until == until &&
|
||||
other.hasAttachment == hasAttachment;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
Object.hash(from, to, subject, body, since, until, hasAttachment);
|
||||
}
|
||||
|
||||
/// 조건 다듬기 — 글 칸은 검색어처럼(앞뒤 공백 · UTF-16 200자 — [normalizeSearchText]), 날짜는 찾을 수 있는 날짜만([isSearchDay]).
|
||||
/// 시작이 끝보다 늦은 기간은 통째로 버린다 — 아무것도 맞지 않는 검색이 되지 않게(시트는 넘기기 전에 칸 아래에 알린다).
|
||||
/// 낱말 수는 자르지 않는다 — 넘으면 찾지 않고 까닭을 보인다([exceedsCombinedSearchWords]) (웹 `normalizeSearchConditions`)
|
||||
SearchConditions normalizeSearchConditions(SearchConditions? input) {
|
||||
if (input == null) return SearchConditions.none;
|
||||
final SearchDay? since = _dayOrNull(input.since);
|
||||
final SearchDay? until = _dayOrNull(input.until);
|
||||
final bool reversed = isReversedPeriod(since, until);
|
||||
return SearchConditions(
|
||||
from: normalizeSearchText(input.from),
|
||||
to: normalizeSearchText(input.to),
|
||||
subject: normalizeSearchText(input.subject),
|
||||
body: normalizeSearchText(input.body),
|
||||
since: reversed ? null : since,
|
||||
until: reversed ? null : until,
|
||||
hasAttachment: input.hasAttachment,
|
||||
);
|
||||
}
|
||||
|
||||
SearchDay? _dayOrNull(SearchDay? day) =>
|
||||
day != null && isSearchDay(day) ? day : null;
|
||||
|
||||
/// 날짜(연 · 월 · 일 — 넘친 일은 다음 달로 넘긴다) → 기기 시간대의 그날 0시. 시험은 시간대를 고정한 것을 넣는다
|
||||
typedef SearchMidnight = DateTime Function(int year, int month, int day);
|
||||
|
||||
DateTime _deviceMidnight(int year, int month, int day) =>
|
||||
DateTime(year, month, day);
|
||||
|
||||
/// API 쿼리의 조건(`GET /api/messages` — 빈 칸은 싣지 않는다).
|
||||
/// 날짜는 기기 시간대의 시작 날짜 0시(`after`) · 끝 날짜 다음 날 0시(`before`)를 UTC ISO 로 — 웹 `searchConditionsParams` 와 같다
|
||||
/// (부재중과 같은 방식). 첨부는 `hasAttachment=true` 만(계약 — 다른 값은 400 `VAL_001`)
|
||||
Map<String, String> searchConditionsParams(
|
||||
SearchConditions conditions, {
|
||||
SearchMidnight midnight = _deviceMidnight,
|
||||
}) {
|
||||
final SearchDay? since = conditions.since;
|
||||
final SearchDay? until = conditions.until;
|
||||
return <String, String>{
|
||||
if (conditions.from.isNotEmpty) 'from': conditions.from,
|
||||
if (conditions.to.isNotEmpty) 'to': conditions.to,
|
||||
if (conditions.subject.isNotEmpty) 'subject': conditions.subject,
|
||||
if (conditions.body.isNotEmpty) 'body': conditions.body,
|
||||
if (since != null)
|
||||
'after': _utcIso(midnight(since.year, since.month, since.day)),
|
||||
if (until != null)
|
||||
'before': _utcIso(midnight(until.year, until.month, until.day + 1)),
|
||||
if (conditions.hasAttachment) 'hasAttachment': kSearchHasAttachmentValue,
|
||||
};
|
||||
}
|
||||
|
||||
/// API 의 `hasAttachment` 값 — 이것만 받는다(웹 `SEARCH_HAS_ATTACHMENT_VALUE`)
|
||||
const String kSearchHasAttachmentValue = 'true';
|
||||
|
||||
/// "2026-10-05T15:00:00.000Z" — JS `toISOString()` 과 같은 모양
|
||||
String _utcIso(DateTime instant) => instant.toUtc().toIso8601String();
|
||||
|
||||
/// 읽기 화면에 넘기는 강조 쿼리 — 검색어와 글 칸 넷만(기간 · 첨부는 서버가 받지 않는다). 빈 칸은 빼고, 하나도 없으면 null (웹 `messageHighlightQueryOf`)
|
||||
MessageHighlightQuery? messageHighlightQueryOf(
|
||||
String q,
|
||||
SearchConditions conditions,
|
||||
) {
|
||||
final MessageHighlightQuery query = MessageHighlightQuery(
|
||||
q: q,
|
||||
from: conditions.from,
|
||||
to: conditions.to,
|
||||
subject: conditions.subject,
|
||||
body: conditions.body,
|
||||
);
|
||||
return query.isEmpty ? null : query;
|
||||
}
|
||||
|
||||
/// 검색어와 조건 칸 넷을 합친 낱말 수 — 서버 한도(8개)와 같은 기준(띄어쓰기로 나눈 조각을 칸마다 세어 더한다)
|
||||
int combinedSearchWords(String q, SearchConditions conditions) =>
|
||||
countSearchWords(q) +
|
||||
countSearchWords(conditions.from) +
|
||||
countSearchWords(conditions.to) +
|
||||
countSearchWords(conditions.subject) +
|
||||
countSearchWords(conditions.body);
|
||||
|
||||
/// 합친 낱말이 한도([kMailSearchMaxWords])를 넘는가 — 넘으면 묻지 않는다
|
||||
bool exceedsCombinedSearchWords(String q, SearchConditions conditions) =>
|
||||
combinedSearchWords(q, conditions) > kMailSearchMaxWords;
|
||||
|
||||
/// 한 글자 낱말뿐이라 본문을 찾지 않은 검색인가 (색인 방식 — 서버와 같은 판정, [isSingleCharSearch]).
|
||||
/// 본문을 찾을 말(검색어나 내용 조건)이 있을 때만 그렇다 — 보낸 사람 · 받는 사람 · 제목 조건만이면 본문은 원래 찾지 않는다 (웹 `skipsBodySearch`)
|
||||
bool skipsBodySearch(String q, SearchConditions conditions) {
|
||||
if (parseSearchTerms(q).isEmpty &&
|
||||
parseSearchTerms(conditions.body).isEmpty) {
|
||||
return false;
|
||||
}
|
||||
return isSingleCharSearch(<String>[
|
||||
q,
|
||||
conditions.from,
|
||||
conditions.to,
|
||||
conditions.subject,
|
||||
conditions.body,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── 기간 고르기 (웹 SEARCH_PERIOD_* · periodRange · periodOf) ─────────────
|
||||
|
||||
/// 기간 고르기 — 고르기 칸의 차례 그대로. 최근 N주 · 달 · 해는 그만큼 앞의 같은 날부터 오늘까지(두 날짜 모두 든다)
|
||||
enum SearchPeriod {
|
||||
all,
|
||||
week(days: 7),
|
||||
month(months: 1),
|
||||
quarter(months: 3),
|
||||
year(months: 12),
|
||||
custom;
|
||||
|
||||
const SearchPeriod({this.days = 0, this.months = 0});
|
||||
|
||||
/// 오늘에서 뺄 날 · 달 수 (미리 정한 기간만)
|
||||
final int days;
|
||||
final int months;
|
||||
|
||||
/// 미리 정한 기간인가 — 전체 기간 · 날짜 고르기는 아니다
|
||||
bool get isPreset => this != all && this != custom;
|
||||
}
|
||||
|
||||
/// 오늘에서 그만큼 앞의 같은 날 — 달은 그 달의 마지막 날을 넘지 않는다(3월 31일의 1달 앞은 2월 28 · 29일).
|
||||
/// 날짜 셈은 UTC 로 한다(일광 절약 시간의 0시가 없는 날에도 날짜가 바뀌지 않게)
|
||||
SearchDay _shiftBack(SearchDay today, SearchPeriod preset) {
|
||||
final int month = today.month - preset.months;
|
||||
final int lastDay = DateTime.utc(today.year, month + 1, 0).day;
|
||||
final int day = (today.day < lastDay ? today.day : lastDay) - preset.days;
|
||||
final DateTime shifted = DateTime.utc(today.year, month, day);
|
||||
return SearchDay(shifted.year, shifted.month, shifted.day);
|
||||
}
|
||||
|
||||
/// 미리 정한 기간의 두 날짜 — 그만큼 앞의 같은 날부터 오늘까지. 전체 기간 · 날짜 고르기는 날짜가 없다 (웹 `periodRange`)
|
||||
({SearchDay? since, SearchDay? until}) periodRange(
|
||||
SearchPeriod period,
|
||||
DateTime today,
|
||||
) {
|
||||
if (!period.isPreset) return (since: null, until: null);
|
||||
final SearchDay day = SearchDay.of(today);
|
||||
return (since: _shiftBack(day, period), until: day);
|
||||
}
|
||||
|
||||
/// 두 날짜가 어떤 기간 고르기인가 — 둘 다 없으면 전체 기간, 오늘 기준의 미리 정한 기간과 같으면 그것, 아니면 날짜 고르기 (웹 `periodOf`)
|
||||
SearchPeriod periodOf(SearchDay? since, SearchDay? until, DateTime today) {
|
||||
if (since == null && until == null) return SearchPeriod.all;
|
||||
for (final SearchPeriod period in SearchPeriod.values) {
|
||||
if (!period.isPreset) continue;
|
||||
final ({SearchDay? since, SearchDay? until}) range = periodRange(
|
||||
period,
|
||||
today,
|
||||
);
|
||||
if (range.since == since && range.until == until) return period;
|
||||
}
|
||||
return SearchPeriod.custom;
|
||||
}
|
||||
|
||||
/// 시작 날짜가 끝 날짜보다 늦은가 — 그러면 아무것도 맞지 않는다(찾기 전에 막는다). 한쪽만 있으면 아니다
|
||||
bool isReversedPeriod(SearchDay? since, SearchDay? until) =>
|
||||
since != null && until != null && since.isAfter(until);
|
||||
|
||||
// ── 조건 줄의 "나" ─────────────────────────────────────
|
||||
|
||||
/// 조건 줄의 "나" — 보낸 사람 · 받는 사람 칸의 값이 이 이름이나 주소면 "내가 보낸 메일" · "내가 받은 메일" (웹 `SearchConditionsMe`)
|
||||
typedef SearchConditionsMe = ({String name, String email});
|
||||
|
||||
/// 견줄 모양 — 앞뒤 공백을 빼고 NFC(한글 자모만 모은다 — [composeHangul]) · 소문자(JS 와 같게 — [jsLowerCase]).
|
||||
/// 영문 이름 · 주소는 대소문자를 가리지 않는다
|
||||
String _comparable(String value) =>
|
||||
jsLowerCase(composeHangul(trimSearchSpaces(value)));
|
||||
|
||||
/// 칸의 값이 나인가 — 내 이름이나 내 주소와 통째로 같을 때만(일부만 같으면 아니다)
|
||||
bool isSearchConditionMe(String value, SearchConditionsMe? me) {
|
||||
if (me == null) return false;
|
||||
final String text = _comparable(value);
|
||||
if (text.isEmpty) return false;
|
||||
return text == _comparable(me.email) || text == _comparable(me.name);
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
/// 검색 화면의 표시 — 순수 함수 (보드 `App1Search` · `App1SearchStates` · 웹 `MailSearchHeader` · `MailSearchNotices` · `useMailListRows`).
|
||||
/// 검색 화면의 표시 — 순수 함수 (보드 `App1Search` · `App1SearchStates` · `App1SearchConditions` · 웹 `MailSearchHeader` · `MailSearchNotices` ·
|
||||
/// `MailSearchConditionLine` · `useMailListRows`).
|
||||
///
|
||||
/// - 결과 머리: "‘계약’ · 4통" · 찾는 중 "‘계약’ · 찾는 중" · 전체 수를 모르면(`total` null) "‘계약’"(통 수를 보이지 않는다 — 계약).
|
||||
/// - 안내 줄: 색인을 준비하는 중이면 그 줄, 준비됐고 한 글자 낱말뿐이면 한 글자 안내 — 둘이 함께 뜨지 않고, 준비 상태를 모르면 없다.
|
||||
/// 조건만으로 찾았으면 "2통" · "찾는 중"(전체 수를 모르면 머리가 없다).
|
||||
/// - 안내 줄: 색인을 준비하는 중이면 그 줄, 준비됐고 본문을 찾을 말이 한 글자 낱말뿐이면 한 글자 안내 — 둘이 함께 뜨지 않고, 준비 상태를 모르면 없다.
|
||||
/// - 조건 줄: "조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음"(웹 `searchConditionsSummary` — 쉼표로 잇는다).
|
||||
/// - 결과 줄: 한 메일함에서 찾았으면 그 메일함의 규칙(보낸편지함 · 임시보관함), 전체 메일함이면 줄마다 그 메일의 메일함(시각 앞에 이름).
|
||||
library;
|
||||
|
||||
import 'package:arasystem_app/features/mail/data/models/mail_search.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/mailbox_tree.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/search_text.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/search_conditions.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/mail_row_view.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_search_state.dart';
|
||||
@@ -23,7 +26,7 @@ enum MailSearchNotice {
|
||||
singleChar,
|
||||
}
|
||||
|
||||
/// 결과 머리(개수 줄)의 글 — 머리가 없는 단계(찾기 전 · 낱말이 너무 많음 · 받지 못함)면 null
|
||||
/// 결과 머리(개수 줄)의 글 — 머리가 없는 단계(찾기 전 · 낱말이 너무 많음 · 받지 못함)거나 보일 글이 없으면 null
|
||||
String? searchCountLineOf(MailSearchState state) {
|
||||
final String? query = state.query;
|
||||
if (query == null || !state.showsHead) return null;
|
||||
@@ -31,19 +34,22 @@ String? searchCountLineOf(MailSearchState state) {
|
||||
return MailSearchTexts.searching(query);
|
||||
}
|
||||
final int? total = state.total;
|
||||
return total == null
|
||||
? MailSearchTexts.quoted(query)
|
||||
: MailSearchTexts.count(query, total);
|
||||
if (total != null) return MailSearchTexts.count(query, total);
|
||||
final String quoted = MailSearchTexts.quoted(query);
|
||||
return quoted.isEmpty ? null : quoted;
|
||||
}
|
||||
|
||||
/// 안내 줄 — 찾은 뒤에만. 한 글자 안내는 색인이 준비됐을 때만이라(그 전은 엔진 방식이 본문도 찾는다) 준비 중 줄과 함께 뜨지 않는다
|
||||
/// 안내 줄 — 찾은 뒤에만. 한 글자 안내는 색인이 준비됐을 때만이라(그 전은 엔진 방식이 본문도 찾는다) 준비 중 줄과 함께 뜨지 않는다.
|
||||
/// 본문을 찾을 말(검색어나 내용 조건)이 있을 때만이다([skipsBodySearch] — 웹과 같은 판정)
|
||||
MailSearchNotice? searchNoticeOf(MailSearchState state) {
|
||||
final String? query = state.query;
|
||||
if (query == null || state.phase != MailSearchPhase.ready) return null;
|
||||
return switch (state.indexStatus) {
|
||||
SearchIndexStatus.building => MailSearchNotice.indexing,
|
||||
SearchIndexStatus.ready =>
|
||||
isSingleCharSearch(<String>[query]) ? MailSearchNotice.singleChar : null,
|
||||
skipsBodySearch(query, state.conditions)
|
||||
? MailSearchNotice.singleChar
|
||||
: null,
|
||||
null => null,
|
||||
};
|
||||
}
|
||||
@@ -85,13 +91,69 @@ MailRowView searchRowViewOf(
|
||||
}
|
||||
|
||||
/// 결과에서 연 메일에 넘기는 값 — 강조 쿼리는 14단계 서버의 결과([MailSearchItem.highlightSupported])에서만 싣는다
|
||||
/// (14단계 앞 서버는 읽기 화면의 모르는 쿼리를 400 `VAL_001` 로 막는다). 찾은 메일함은 한 메일함에서 찾았을 때만
|
||||
/// (14단계 앞 서버는 읽기 화면의 모르는 쿼리를 400 `VAL_001` 로 막는다). 검색어와 글 조건 넷만 — 기간 · 첨부는 서버가 받지 않는다
|
||||
/// ([messageHighlightQueryOf] — 실을 칸이 없으면 null). 찾은 메일함은 한 메일함에서 찾았을 때만
|
||||
MailSearchOpen searchOpenOf(MailSearchItem item, MailSearchState state) {
|
||||
final String? query = state.query;
|
||||
return MailSearchOpen(
|
||||
highlight: item.highlightSupported && query != null
|
||||
? MessageHighlightQuery(q: query)
|
||||
? messageHighlightQueryOf(query, state.conditions)
|
||||
: null,
|
||||
scopeMailboxId: state.searchedAll ? null : state.searchedMailboxId,
|
||||
);
|
||||
}
|
||||
|
||||
// ── 조건 줄 (웹 `searchConditionsSummary`) ─────────────────────────────
|
||||
|
||||
/// 날짜 칸 · 조건 줄의 날짜 — "2026. 10. 1.", [withYear] 가 아니면 "10. 1."
|
||||
String formatSearchDay(SearchDay day, {bool withYear = true}) => withYear
|
||||
? '${day.year}. ${day.month}. ${day.day}.'
|
||||
: '${day.month}. ${day.day}.';
|
||||
|
||||
/// 조건 줄의 기간 — 오늘 기준의 미리 정한 기간과 같으면 그 이름, 아니면 날짜(같은 해면 끝 날짜의 해를 뺀다 · 같은 날이면 "… 하루"). 없으면 null
|
||||
String? _periodText(SearchConditions conditions, DateTime today) {
|
||||
final SearchDay? since = conditions.since;
|
||||
final SearchDay? until = conditions.until;
|
||||
final SearchPeriod period = periodOf(since, until, today);
|
||||
if (period == SearchPeriod.all) return null;
|
||||
if (period != SearchPeriod.custom) return MailSearchTexts.period(period);
|
||||
if (since != null && until != null) {
|
||||
if (since == until) {
|
||||
return MailSearchTexts.lineOneDay(formatSearchDay(since));
|
||||
}
|
||||
return MailSearchTexts.lineRange(
|
||||
formatSearchDay(since),
|
||||
formatSearchDay(until, withYear: since.year != until.year),
|
||||
);
|
||||
}
|
||||
if (since != null) return MailSearchTexts.lineSince(formatSearchDay(since));
|
||||
return until == null
|
||||
? null
|
||||
: MailSearchTexts.lineUntil(formatSearchDay(until));
|
||||
}
|
||||
|
||||
/// 결과 머리 위의 조건 줄 — "조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음" (쉼표로 잇는다 — 꼬리표 · 알약을 쓰지 않는다).
|
||||
/// 보낸 사람 · 받는 사람이 나([me] — 내 이름이나 주소와 통째로 같음)면 "내가 보낸 메일" · "내가 받은 메일". 조건이 없으면 빈 글
|
||||
String searchConditionsSummary(
|
||||
SearchConditions conditions,
|
||||
DateTime today, {
|
||||
SearchConditionsMe? me,
|
||||
}) {
|
||||
if (conditions.isEmpty) return '';
|
||||
final List<String> parts = <String>[
|
||||
if (conditions.from.isNotEmpty)
|
||||
isSearchConditionMe(conditions.from, me)
|
||||
? MailSearchTexts.lineFromMe
|
||||
: MailSearchTexts.lineFrom(conditions.from),
|
||||
if (conditions.to.isNotEmpty)
|
||||
isSearchConditionMe(conditions.to, me)
|
||||
? MailSearchTexts.lineToMe
|
||||
: MailSearchTexts.lineTo(conditions.to),
|
||||
if (conditions.subject.isNotEmpty)
|
||||
MailSearchTexts.lineSubject(conditions.subject),
|
||||
if (conditions.body.isNotEmpty) MailSearchTexts.lineBody(conditions.body),
|
||||
?_periodText(conditions, today),
|
||||
if (conditions.hasAttachment) MailSearchTexts.lineAttachment,
|
||||
];
|
||||
return '${MailSearchTexts.linePrefix}${parts.join(MailSearchTexts.lineSeparator)}';
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:arasystem_app/core/utils/korean_format.dart';
|
||||
import 'package:arasystem_app/core/utils/korean_text.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/mail_body_notices.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/search_conditions.dart';
|
||||
|
||||
/// 쓰기 화면 (보드 `App1Compose`). 보드에 없는 상태(불러오기 · 받지 못함 · 칸 안내 · 첨부 상태 · 나가기 확인 · 보낸 뒤 알림)의 글은
|
||||
/// 웹 상수(`frontend/src/shared/constants/mail.ts` · `mail-send-later.ts` · `large-file.ts`)를 그대로 옮겼다 — 앱에서 새로 쓴 글은 그 줄에 적었다
|
||||
@@ -390,8 +391,10 @@ abstract final class MailListTexts {
|
||||
static const String compose = '메일 쓰기';
|
||||
}
|
||||
|
||||
/// 메일 검색 (보드 `App1Search` · `App1SearchStates` — 1차: 검색어 · 찾을 곳 · 결과 · 강조 · 이어 찾기 · 안내).
|
||||
/// 글은 웹 상수(`frontend/src/shared/constants/mail.ts` 의 `MAIL_SEARCH_TEXT` · `mail-search.ts` 의 `SEARCH_NOTICE_TEXT`)를 그대로 옮겼다 —
|
||||
/// 메일 검색 (보드 `App1Search` · `App1SearchStates` — 1차: 검색어 · 찾을 곳 · 결과 · 강조 · 이어 찾기 · 안내,
|
||||
/// `App1SearchConditions` — 2차: 조건으로 찾기 · 조건 줄 · 합친 낱말 수 · 기간).
|
||||
/// 글은 웹 상수(`frontend/src/shared/constants/mail.ts` 의 `MAIL_SEARCH_TEXT` · `mail-search.ts` 의 `SEARCH_NOTICE_TEXT` ·
|
||||
/// `SEARCH_CONDITIONS_TEXT` · `SEARCH_PERIOD_LABELS` · `SEARCH_CONDITION_LINE_TEXT`)를 그대로 옮겼다 —
|
||||
/// 앱에서 새로 쓴 글은 그 줄에 적었다. 목록 줄 · 받지 못함의 글은 [MailListTexts] 를 함께 쓴다
|
||||
abstract final class MailSearchTexts {
|
||||
// ── 위 막대 ──
|
||||
@@ -412,15 +415,17 @@ abstract final class MailSearchTexts {
|
||||
static const String scopeAllHint = '휴지통과 스팸메일함은 빼고 찾습니다';
|
||||
|
||||
// ── 결과 머리 ──
|
||||
/// 전체 수를 모를 때(`total` null) — "‘계약’"
|
||||
static String quoted(String query) => '‘$query’';
|
||||
/// 전체 수를 모를 때(`total` null) — "‘계약’" (조건만으로 찾았으면 빈 글 — 머리를 보이지 않는다)
|
||||
static String quoted(String query) => query.isEmpty ? '' : '‘$query’';
|
||||
|
||||
/// "‘계약’ · 4통"
|
||||
static String count(String query, int count) =>
|
||||
'‘$query’ · ${formatCount(count)}통';
|
||||
/// "‘계약’ · 4통" · 조건만으로 찾았으면 "2통"
|
||||
static String count(String query, int count) => query.isEmpty
|
||||
? '${formatCount(count)}통'
|
||||
: '‘$query’ · ${formatCount(count)}통';
|
||||
|
||||
/// "‘계약’ · 찾는 중"
|
||||
static String searching(String query) => '‘$query’ · 찾는 중';
|
||||
/// "‘계약’ · 찾는 중" · 조건만으로 찾으면 "찾는 중"
|
||||
static String searching(String query) =>
|
||||
query.isEmpty ? '찾는 중' : '‘$query’ · 찾는 중';
|
||||
|
||||
/// 결과 목록 — 화면 낭독기 이름 (웹 `title`)
|
||||
static const String results = '검색 결과';
|
||||
@@ -438,12 +443,19 @@ abstract final class MailSearchTexts {
|
||||
'한 글자로만 찾을 때는 본문은 빼고 제목, 보낸 사람과 받는 사람, 첨부 파일 이름에서 찾습니다. 본문까지 찾으려면 두 글자 이상으로 찾아 주세요.';
|
||||
|
||||
// ── 결과 없음 ──
|
||||
/// "‘해지 통보’가 들어간 메일이 없습니다"
|
||||
static String emptyTitle(String query) =>
|
||||
'‘$query’${subjectParticle(query)} 들어간 메일이 없습니다';
|
||||
/// "‘해지 통보’가 들어간 메일이 없습니다" · 조건만으로 찾았으면 "조건에 맞는 메일이 없습니다"
|
||||
static String emptyTitle(String query) => query.isEmpty
|
||||
? '조건에 맞는 메일이 없습니다'
|
||||
: '‘$query’${subjectParticle(query)} 들어간 메일이 없습니다';
|
||||
|
||||
/// 검색어만으로 찾았을 때의 도움말 (웹 `emptyWordsHint` — 조건은 2차)
|
||||
static const String emptyHint = '다른 말로 찾아 보세요.';
|
||||
/// 검색어와 조건으로 찾았을 때의 도움말 (웹 `emptyHint`)
|
||||
static const String emptyHint = '다른 말로 찾거나 조건을 줄여 보세요.';
|
||||
|
||||
/// 조건만으로 찾았을 때의 도움말 (웹 `emptyConditionsHint`)
|
||||
static const String emptyConditionsHint = '조건을 바꾸거나 줄여 보세요.';
|
||||
|
||||
/// 검색어만으로 찾았을 때의 도움말 — 걸린 조건이 없으니 조건을 줄이라고 하지 않는다 (웹 `emptyWordsHint`)
|
||||
static const String emptyWordsHint = '다른 말로 찾아 보세요.';
|
||||
|
||||
/// 색인을 준비하는 동안에만 더하는 한계 안내 (웹 `wordLimitHint`)
|
||||
static const String wordLimitHint =
|
||||
@@ -464,9 +476,9 @@ abstract final class MailSearchTexts {
|
||||
// ── 찾을 말이 너무 많음 ──
|
||||
static const String tooManyWordsTitle = '찾을 말이 너무 많습니다';
|
||||
|
||||
/// (앱 1차 — 검색어만) 2차(조건)부터는 웹 글 "검색 칸과 [조건]에 넣은 낱말은 모두 합쳐 …"
|
||||
/// 검색어와 조건의 낱말을 합쳐 셌다 — 결과 자리 · 시트의 [찾기] 위 (웹 `tooManyWordsHint`)
|
||||
static String tooManyWordsHint(int max) =>
|
||||
'검색어에는 낱말을 ${formatCount(max)}개까지 넣을 수 있습니다. 낱말을 몇 개 줄여 주세요.';
|
||||
'검색 칸과 [조건]에 넣은 낱말은 모두 합쳐 ${formatCount(max)}개까지입니다. 낱말을 몇 개 줄여 주세요.';
|
||||
|
||||
// ── 찾기 전 ──
|
||||
/// 처음 연 화면 — 무엇을 찾는지 (앱에서 새로 씀)
|
||||
@@ -479,6 +491,88 @@ abstract final class MailSearchTexts {
|
||||
|
||||
/// 검색 결과에서 연 메일의 머리 — "메일함 받은편지함"의 이름 칸 (웹 `mailboxLine`)
|
||||
static const String mailboxLine = '메일함';
|
||||
|
||||
// ── 조건으로 찾기 — 위 막대의 [조건] · 시트 (2차 — 웹 `SEARCH_CONDITIONS_TEXT`) ──
|
||||
/// [조건] — 툴팁 · 화면 낭독기 이름 · 시트 제목 (웹 `toggleTitle` · `title`)
|
||||
static const String conditions = '조건으로 찾기';
|
||||
|
||||
/// 조건이 걸려 있을 때 [조건]의 낭독에 더한다 (웹 `toggleApplied`)
|
||||
static const String conditionsApplied = '조건이 걸려 있음';
|
||||
|
||||
/// [조건]의 화면 낭독기 이름 — "조건으로 찾기, 조건이 걸려 있음"
|
||||
static String conditionsLabel({required bool applied}) =>
|
||||
applied ? '$conditions, $conditionsApplied' : conditions;
|
||||
|
||||
/// 시트의 [✕] — 화면 낭독기 (앱에서 새로 씀)
|
||||
static const String closeConditions = '닫기';
|
||||
|
||||
static const String fromLabel = '보낸 사람';
|
||||
static const String toLabel = '받는 사람';
|
||||
static const String subjectLabel = '제목';
|
||||
|
||||
/// 칸 이름 "내용" — 조건 줄도 "내용에 ‘…’이 든 메일"이다
|
||||
static const String bodyLabel = '내용';
|
||||
static const String periodLabel = '기간';
|
||||
static const String attachmentLabel = '첨부';
|
||||
|
||||
/// 보낸 사람 · 받는 사람 칸의 흐린 글
|
||||
static const String personPlaceholder = '이름이나 메일 주소';
|
||||
static const String subjectPlaceholder = '제목에 든 말';
|
||||
static const String bodyPlaceholder = '내용에 든 말';
|
||||
|
||||
/// 기간 고르기의 이름 (웹 `SEARCH_PERIOD_LABELS`)
|
||||
static String period(SearchPeriod period) => switch (period) {
|
||||
SearchPeriod.all => '전체 기간',
|
||||
SearchPeriod.week => '최근 1주',
|
||||
SearchPeriod.month => '최근 1달',
|
||||
SearchPeriod.quarter => '최근 3달',
|
||||
SearchPeriod.year => '최근 1년',
|
||||
SearchPeriod.custom => '날짜 고르기',
|
||||
};
|
||||
|
||||
/// 날짜 칸의 이름 · 날짜 고르는 창의 머리
|
||||
static const String since = '시작 날짜';
|
||||
static const String until = '끝 날짜';
|
||||
|
||||
/// 날짜 칸의 [✕] — 화면 낭독기 (앱에서 새로 씀)
|
||||
static const String clearSince = '시작 날짜 지우기';
|
||||
static const String clearUntil = '끝 날짜 지우기';
|
||||
|
||||
static const String attachmentOnly = '첨부 파일이 있는 메일만';
|
||||
static const String conditionsHint =
|
||||
'검색 칸에 쓴 말이 들어 있고 이 조건에도 맞는 메일을 찾습니다. 받는 사람으로 찾을 때는 참조로 받은 사람까지 찾습니다.';
|
||||
|
||||
/// 날짜 고르기의 시작이 끝보다 늦을 때 — 두 날짜 칸 아래 (웹 `periodOrder`)
|
||||
static const String periodOrder = '끝 날짜를 시작 날짜와 같거나 그 뒤로 골라 주세요.';
|
||||
|
||||
/// 시트의 [조건 지우기](칸만 비운다) · 조건 줄의 [조건 지우기](걸린 조건을 뺀다)
|
||||
static const String clearConditions = '조건 지우기';
|
||||
|
||||
/// 시트의 [찾기]
|
||||
static const String applyConditions = '찾기';
|
||||
|
||||
// ── 조건 줄 — "조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음" (웹 `SEARCH_CONDITION_LINE_TEXT` — 쉼표로 잇는다) ──
|
||||
static const String linePrefix = '조건: ';
|
||||
static const String lineSeparator = ', ';
|
||||
static String lineFrom(String who) => '$who 님이 보낸 메일';
|
||||
static String lineTo(String who) => '$who 님이 받은 메일';
|
||||
|
||||
/// 보낸 사람 · 받는 사람 칸의 값이 내 이름이나 내 주소일 때
|
||||
static const String lineFromMe = '내가 보낸 메일';
|
||||
static const String lineToMe = '내가 받은 메일';
|
||||
|
||||
/// "제목에 ‘견적’이 든 메일" — 조사(이 · 가)는 말의 받침으로
|
||||
static String lineSubject(String text) =>
|
||||
'제목에 ‘$text’${subjectParticle(text)} 든 메일';
|
||||
static String lineBody(String text) =>
|
||||
'내용에 ‘$text’${subjectParticle(text)} 든 메일';
|
||||
static String lineSince(String day) => '$day부터';
|
||||
static String lineUntil(String day) => '$day까지';
|
||||
static String lineRange(String since, String until) => '$since부터 $until까지';
|
||||
|
||||
/// 시작 날짜와 끝 날짜가 같은 날 — "2026. 11. 6.부터 11. 6.까지" 대신
|
||||
static String lineOneDay(String day) => '$day 하루';
|
||||
static const String lineAttachment = '첨부 있음';
|
||||
}
|
||||
|
||||
/// 메일함 서랍 (보드 `App1MailList` 「메일함 서랍」)
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:arasystem_app/core/theme/app_spacing.dart';
|
||||
import 'package:arasystem_app/core/theme/app_typography.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mail_search.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/search_conditions.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/search_text.dart';
|
||||
import 'package:arasystem_app/features/mail/mail_routes.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/mail_search_view.dart';
|
||||
@@ -18,18 +19,22 @@ import 'package:arasystem_app/features/mail/presentation/providers/mailboxes_pro
|
||||
import 'package:arasystem_app/features/mail/presentation/widgets/mail_compose_area.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/widgets/mail_list_states.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/widgets/mail_search_bar.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/widgets/mail_search_conditions_sheet.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/widgets/mail_search_states.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/widgets/message_row.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
/// 메일 검색 — 앱 보드 `App1Search` · `App1SearchStates` (앱 디자인 README 「검색」 — 1차: 검색어 · 찾을 곳 · 결과 · 강조 · 이어 찾기 · 안내).
|
||||
/// 메일 검색 — 앱 보드 `App1Search` · `App1SearchStates` · `App1SearchConditions` (앱 디자인 README 「검색」 —
|
||||
/// 1차: 검색어 · 찾을 곳 · 결과 · 강조 · 이어 찾기 · 안내, 2차: 조건으로 찾기 · 조건 줄 · 합친 낱말 수 · 기간 · "나").
|
||||
///
|
||||
/// - **탭 안 화면**이다 — 연 목록 위에 쌓이고 탭 막대가 보인다(자판이 열리면 틀이 숨긴다). 메일 탭을 다시 누르면 닫힌다.
|
||||
/// 검색어는 주소에 싣지 않는다(화면 상태 — 시작한 메일함만 라우트의 `extra`).
|
||||
/// - 열면 칸에 초점을 두고 자판을 올린다. 자판의 [검색]으로 찾고(빈 칸 · 공백뿐이면 아무 일 없이 자판을 둔다), 찾으면 자판을 내린다.
|
||||
/// 결과를 끌면 자판을 내린다. [✕]는 칸과 결과를 비우고 칸에 초점을 둔다. [←] · 뒤로 가기가 검색을 닫는다.
|
||||
/// 검색어 · 조건은 주소에 싣지 않는다(화면 상태 — 시작한 메일함만 라우트의 `extra`).
|
||||
/// - 열면 칸에 초점을 두고 자판을 올린다. 자판의 [검색]으로 찾고(빈 칸 · 공백뿐이고 걸린 조건도 없으면 아무 일 없이 자판을 둔다), 찾으면 자판을 내린다.
|
||||
/// 결과를 끌면 자판을 내린다. [✕]는 칸과 결과를 비우고 칸에 초점을 둔다(걸린 조건은 남는다). [←] · 뒤로 가기가 검색을 닫는다.
|
||||
/// - [조건](맨 오른쪽)은 「조건으로 찾기」 시트를 연다 — 연 동안 · 조건이 걸려 있으면 옅은 청록 원. 시트의 [찾기]는 조건을 걸고 칸의 지금 글로 찾는다.
|
||||
/// 조건이 걸리면 찾을 곳 줄 아래에 조건 줄("조건: …" + [조건 지우기])이 보인다.
|
||||
/// - 찾을 곳은 찾기 전부터 보이고, 바꾸면 칸의 글(비었으면 보이는 결과의 검색어)로 바로 다시 찾는다 — 다시 찾으면 자판을 내린다.
|
||||
/// - 결과 줄은 목록 줄 그대로(고르지 않는다 — 사진은 단추가 아니고 길게 눌러도 아무 일 없다). 누르면 읽기 화면(임시보관함은 쓰기 화면),
|
||||
/// 14단계 서버의 결과면 강조 쿼리를 함께 넘긴다. 읽기 화면에서 지운 뒤 [되돌리기] 알림이 이 화면 아래에 뜬다([메일 쓰기]는 없다).
|
||||
@@ -56,6 +61,12 @@ class _MailSearchPageState extends ConsumerState<MailSearchPage> {
|
||||
final TextEditingController _field = TextEditingController();
|
||||
final FocusNode _focus = FocusNode();
|
||||
|
||||
/// [조건]의 초점 — 시트를 열기 전에 받아 둔다(시트가 닫히면 이 단추로 돌아와 자판이 다시 올라오지 않는다)
|
||||
final FocusNode _conditionsFocus = FocusNode();
|
||||
|
||||
/// 「조건으로 찾기」 시트가 열려 있다
|
||||
bool _conditionsOpen = false;
|
||||
|
||||
MailSearchController get _controller =>
|
||||
ref.read(mailSearchProvider(_args).notifier);
|
||||
|
||||
@@ -63,16 +74,40 @@ class _MailSearchPageState extends ConsumerState<MailSearchPage> {
|
||||
void dispose() {
|
||||
_field.dispose();
|
||||
_focus.dispose();
|
||||
_conditionsFocus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// ── 하는 일 ──
|
||||
|
||||
/// 자판의 [검색] — 빈 칸 · 공백뿐이면 자판을 그대로 둔다
|
||||
/// 자판의 [검색] — 빈 칸 · 공백뿐이고 걸린 조건도 없으면 자판을 그대로 둔다
|
||||
void _submit(String text) {
|
||||
if (_controller.submit(text)) _focus.unfocus();
|
||||
}
|
||||
|
||||
/// [조건] — 시트를 연다. [찾기]가 넘긴 조건을 걸고 칸의 지금 글로 찾는다(닫기만 했으면 아무 일 없다)
|
||||
Future<void> _openConditions() async {
|
||||
if (_conditionsOpen) return;
|
||||
_conditionsFocus.requestFocus();
|
||||
setState(() => _conditionsOpen = true);
|
||||
final SearchConditions? picked = await showSearchConditionsSheet(
|
||||
context,
|
||||
applied: ref.read(mailSearchProvider(_args)).conditions,
|
||||
searchText: _field.text,
|
||||
clock: ref.read(clockProvider),
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _conditionsOpen = false);
|
||||
if (picked != null && _controller.submit(_field.text, conditions: picked)) {
|
||||
_focus.unfocus();
|
||||
}
|
||||
}
|
||||
|
||||
/// 조건 줄의 [조건 지우기] — 걸린 조건을 빼고 칸의 글(비었으면 보이는 결과의 검색어)로 다시 찾는다
|
||||
void _clearConditions() {
|
||||
if (_controller.clearConditions(fieldText: _field.text)) _focus.unfocus();
|
||||
}
|
||||
|
||||
/// [✕] — 칸과 결과를 비우고 칸에 초점(자판이 다시 올라온다)
|
||||
void _clear() {
|
||||
_field.clear();
|
||||
@@ -124,6 +159,7 @@ class _MailSearchPageState extends ConsumerState<MailSearchPage> {
|
||||
});
|
||||
final String? countLine = searchCountLineOf(search);
|
||||
final MailSearchNotice? notice = searchNoticeOf(search);
|
||||
final bool conditionsApplied = search.conditions.isNotEmpty;
|
||||
return Scaffold(
|
||||
appBar: MailSearchBar(
|
||||
controller: _field,
|
||||
@@ -131,6 +167,10 @@ class _MailSearchPageState extends ConsumerState<MailSearchPage> {
|
||||
onSubmitted: _submit,
|
||||
onClear: _clear,
|
||||
onClose: _close,
|
||||
onConditions: () => unawaited(_openConditions()),
|
||||
conditionsFocusNode: _conditionsFocus,
|
||||
conditionsApplied: conditionsApplied,
|
||||
conditionsOpen: _conditionsOpen,
|
||||
),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
@@ -140,6 +180,15 @@ class _MailSearchPageState extends ConsumerState<MailSearchPage> {
|
||||
allMailboxes: search.allMailboxes,
|
||||
onChanged: _setScope,
|
||||
),
|
||||
if (conditionsApplied)
|
||||
MailSearchConditionLine(
|
||||
text: searchConditionsSummary(
|
||||
search.conditions,
|
||||
ref.read(clockProvider)(),
|
||||
me: ref.watch(mailSelfProvider),
|
||||
),
|
||||
onClear: _clearConditions,
|
||||
),
|
||||
if (countLine != null) MailSearchCountLine(countLine),
|
||||
if (notice != null) MailSearchNoticeLine(_noticeText(notice)),
|
||||
Expanded(
|
||||
@@ -248,6 +297,7 @@ class _SearchBody extends StatelessWidget {
|
||||
content = _FillViewport(
|
||||
child: MailSearchEmpty(
|
||||
query: query,
|
||||
hasConditions: search.conditions.isNotEmpty,
|
||||
indexing: search.indexStatus == SearchIndexStatus.building,
|
||||
onSearchAll: search.searchedAll ? null : onSearchAll,
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:arasystem_app/core/api/dio_provider.dart';
|
||||
import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart';
|
||||
import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart';
|
||||
import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/search_conditions.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
/// 메일 저장소 — 인터셉터(언래핑 · Bearer · 갱신 · 알림)가 걸린 `dioProvider` 의 Dio 를 쓴다
|
||||
@@ -20,3 +21,13 @@ final Provider<String?> mailAccountProvider = Provider<String?>((Ref ref) {
|
||||
? session.info.email
|
||||
: null;
|
||||
});
|
||||
|
||||
/// 메일을 보는 직원의 이름 · 주소 — 검색 조건 줄이 "내가 보낸 메일"을 알아본다(웹은 `authStore.user`).
|
||||
/// [mailAccountProvider] 와 같은 때만 있고 그 밖에는 null
|
||||
final Provider<SearchConditionsMe?> mailSelfProvider =
|
||||
Provider<SearchConditionsMe?>((Ref ref) {
|
||||
final SessionState? session = ref.watch(sessionProvider).value;
|
||||
return session is SignedIn && session.info.restriction == null
|
||||
? (name: session.info.name, email: session.info.email)
|
||||
: null;
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/message_detail.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/message_summary.dart';
|
||||
import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/search_conditions.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/search_text.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_list_controller.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart';
|
||||
@@ -36,9 +37,12 @@ mailSearchProvider = NotifierProvider.autoDispose
|
||||
MailSearchController.new,
|
||||
);
|
||||
|
||||
/// 메일 검색 — 계약 2절 "검색" · "검색 — 14단계"(이어 찾기 · 준비 상태), 웹 `stores/mail/list.ts` 의 검색 결과와 같은 규칙.
|
||||
/// 메일 검색 — 계약 2절 "검색" · "검색 — 14단계"(조건 · 이어 찾기 · 준비 상태), 웹 `stores/mail/list.ts` · `useMailSearchNavigation` 의 검색과 같은 규칙.
|
||||
///
|
||||
/// - 자판의 [검색]으로만 찾는다(치는 동안 묻지 않는다). 낱말이 8개를 넘으면 묻지 않는다. 같은 검색어 · 같은 곳이면 보이는 채로 다시 찾는다.
|
||||
/// - 자판의 [검색] · 시트의 [찾기]로만 찾는다(치는 동안 묻지 않는다). 검색어와 조건의 낱말이 합쳐 8개를 넘으면 묻지 않는다.
|
||||
/// 같은 검색어 · 같은 조건 · 같은 곳이면 보이는 채로 다시 찾는다.
|
||||
/// - **조건**(2차)은 화면 상태에만 둔다([MailSearchState.conditions] — 주소에 싣지 않는다). 걸린 조건은 [검색] · 찾을 곳 바꾸기 · 다시 찾기 ·
|
||||
/// 이어 찾기가 모두 그대로 쓴다. 칸이 비어도 조건이 있으면 조건만으로 찾는다(웹과 같다). [✕]는 검색어와 결과만 지우고 조건은 남긴다.
|
||||
/// - **이어 찾기**: 한 차례(처음 · 목록 끝에 닿음 · [더 찾기])에 한 쪽(20통)이 새로 차거나 더 없을 때까지 요청을 5번까지 보낸다.
|
||||
/// 하나도 찾기 전에는 뼈대, 하나라도 찾으면 보이며 뒤에서 잇는다. 차례가 한 쪽을 못 채우고 끝났는데 더 있으면 저절로 잇지 않고 [더 찾기]를 둔다.
|
||||
/// 다음 쪽의 `total` 이 null 이면 첫 쪽의 수를 남긴다. 같은 커서가 새 메일 없이 돌아오면 [다시 시도]로 멈춘다.
|
||||
@@ -130,72 +134,94 @@ class MailSearchController extends Notifier<MailSearchState> {
|
||||
|
||||
// ── 하는 일 ─────────────────────────────────────────
|
||||
|
||||
/// 자판의 [검색] — 빈 칸 · 공백뿐이면 아무 일도 하지 않고 거짓(자판도 그대로 둔다).
|
||||
/// 낱말이 한도를 넘으면 묻지 않는다. 같은 검색어 · 같은 곳의 결과가 보이면 보이는 채로 다시 찾는다
|
||||
bool submit(String text) {
|
||||
/// 자판의 [검색] · 시트의 [찾기]([conditions] — 그 조건을 걸고 찾는다. 주지 않으면 걸려 있는 조건 그대로 — 웹 `startSearch` 와 같다).
|
||||
/// - 검색어도 조건도 없으면: [검색]은 아무 일도 하지 않고 거짓(자판도 그대로 둔다), 시트의 [찾기]는 조건을 빼고 찾기 전으로(요청 없음 —
|
||||
/// 웹은 검색을 닫지만 앱은 검색 화면에 머문다)
|
||||
/// - 칸이 비어도 조건이 있으면 조건만으로 찾는다
|
||||
/// - 검색어와 조건의 낱말이 합쳐 한도를 넘으면 묻지 않는다(조건은 걸린 채 — 조건 줄과 [조건 지우기]가 남는다)
|
||||
/// - 같은 검색어 · 같은 조건 · 같은 곳의 결과가 보이면 보이는 채로 다시 찾는다
|
||||
bool submit(String text, {SearchConditions? conditions}) {
|
||||
if (!ref.mounted) return false;
|
||||
final String q = normalizeSearchText(text);
|
||||
if (q.isEmpty) return false;
|
||||
if (countSearchWords(q) > kMailSearchMaxWords) {
|
||||
final SearchConditions next = conditions == null
|
||||
? state.conditions
|
||||
: normalizeSearchConditions(conditions);
|
||||
if (q.isEmpty && next.isEmpty) {
|
||||
if (conditions == null) return false;
|
||||
_reset(SearchConditions.none);
|
||||
return true;
|
||||
}
|
||||
if (exceedsCombinedSearchWords(q, next)) {
|
||||
_beginGeneration();
|
||||
_removed.clear();
|
||||
state = MailSearchState(
|
||||
phase: MailSearchPhase.tooManyWords,
|
||||
allMailboxes: state.allMailboxes,
|
||||
indexStatus: state.indexStatus,
|
||||
conditions: next,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
final MailSearchState current = state;
|
||||
if (current.phase == MailSearchPhase.ready &&
|
||||
final bool same =
|
||||
current.phase == MailSearchPhase.ready &&
|
||||
current.query == q &&
|
||||
current.searchedAll == current.allMailboxes) {
|
||||
unawaited(_search(q, keepVisible: true));
|
||||
} else {
|
||||
unawaited(_search(q));
|
||||
}
|
||||
current.searchedAll == current.allMailboxes &&
|
||||
current.conditions == next;
|
||||
unawaited(_search(q, conditions: next, keepVisible: same));
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 찾을 곳을 바꾼다 — 칸의 글([fieldText] — 비었으면 보이는 결과의 검색어)로 바로 다시 찾는다. 둘 다 없으면 곳만 바꿔 둔다.
|
||||
/// 다시 찾았으면 참([submit] 과 같다 — 화면이 자판을 내린다)
|
||||
/// 찾을 곳을 바꾼다 — 칸의 글([fieldText] — 비었으면 보이는 결과의 검색어, 조건만으로 찾았으면 그 조건)로 바로 다시 찾는다.
|
||||
/// 보이는 결과도 없으면 곳만 바꿔 둔다. 다시 찾았으면 참([submit] 과 같다 — 화면이 자판을 내린다)
|
||||
bool setScope({required bool all, required String fieldText}) {
|
||||
if (!ref.mounted || state.allMailboxes == all) return false;
|
||||
state = state.copyWith(allMailboxes: all);
|
||||
final String typed = normalizeSearchText(fieldText);
|
||||
final String? q = typed.isNotEmpty ? typed : state.query;
|
||||
if (typed.isNotEmpty) return submit(typed);
|
||||
final String? q = state.query;
|
||||
return q != null && submit(q);
|
||||
}
|
||||
|
||||
/// [전체 메일함에서 찾기] — 보이는 결과의 검색어를 전체 메일함에서(결과 없음 · 찾던 메일함이 지워짐)
|
||||
/// [전체 메일함에서 찾기] — 보이는 결과의 검색어 · 조건을 전체 메일함에서(결과 없음 · 찾던 메일함이 지워짐)
|
||||
void searchAll() {
|
||||
if (!ref.mounted) return;
|
||||
final String? q = state.query;
|
||||
state = state.copyWith(allMailboxes: true);
|
||||
if (q != null) unawaited(_search(q));
|
||||
if (q != null) unawaited(_search(q, conditions: state.conditions));
|
||||
}
|
||||
|
||||
/// [✕] — 결과를 비우고 찾기 전으로(찾을 곳은 그대로)
|
||||
/// [✕] — 검색어의 결과를 비우고 찾기 전으로(찾을 곳 · 걸린 조건은 그대로 — 조건 줄이 남는다)
|
||||
void clear() {
|
||||
if (!ref.mounted) return;
|
||||
_beginGeneration();
|
||||
_removed.clear();
|
||||
state = MailSearchState(
|
||||
allMailboxes: state.allMailboxes,
|
||||
indexStatus: state.indexStatus,
|
||||
);
|
||||
_reset(state.conditions);
|
||||
}
|
||||
|
||||
/// 당겨서 새로고침 — 보이는 채로 다시 찾는다(받지 못한 화면이면 [retry])
|
||||
/// 조건 줄의 [조건 지우기] — 걸린 조건을 모두 뺀다. 찾은 검색이면 칸의 글([fieldText] — 비었으면 보이는 결과의 검색어)로 다시 찾고,
|
||||
/// 그 글도 없으면 요청 없이 찾기 전으로(웹은 검색을 닫는다). 찾기 전([✕] 뒤)이면 조건만 뺀다. 다시 찾았으면 참(화면이 자판을 내린다)
|
||||
bool clearConditions({required String fieldText}) {
|
||||
if (!ref.mounted) return false;
|
||||
final MailSearchState current = state;
|
||||
if (current.conditions.isEmpty) return false;
|
||||
if (current.phase == MailSearchPhase.idle) {
|
||||
state = current.copyWith(conditions: SearchConditions.none);
|
||||
return false;
|
||||
}
|
||||
final String typed = normalizeSearchText(fieldText);
|
||||
final String q = typed.isNotEmpty ? typed : current.query ?? '';
|
||||
return submit(q, conditions: SearchConditions.none);
|
||||
}
|
||||
|
||||
/// 당겨서 새로고침 — 보이는 채로 다시 찾는다(받지 못한 화면이면 [retry]). 걸린 조건 그대로
|
||||
Future<void> refresh() async {
|
||||
if (!ref.mounted) return;
|
||||
final String? q = state.query;
|
||||
if (q == null) return;
|
||||
switch (state.phase) {
|
||||
case MailSearchPhase.ready:
|
||||
await _search(q, keepVisible: true);
|
||||
await _search(q, conditions: state.conditions, keepVisible: true);
|
||||
case MailSearchPhase.failed:
|
||||
await _search(q);
|
||||
await _search(q, conditions: state.conditions);
|
||||
case MailSearchPhase.idle:
|
||||
case MailSearchPhase.tooManyWords:
|
||||
case MailSearchPhase.searching:
|
||||
@@ -203,11 +229,22 @@ class MailSearchController extends Notifier<MailSearchState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 받지 못함의 [다시 시도] — 처음부터 다시 찾는다
|
||||
/// 받지 못함의 [다시 시도] — 처음부터 다시 찾는다(걸린 조건 그대로)
|
||||
Future<void> retry() async {
|
||||
if (!ref.mounted) return;
|
||||
final String? q = state.query;
|
||||
if (q != null) await _search(q);
|
||||
if (q != null) await _search(q, conditions: state.conditions);
|
||||
}
|
||||
|
||||
/// 찾기 전으로 — 도는 요청을 끊고 결과를 비운다(찾을 곳 · 준비 상태는 그대로, 조건은 [conditions])
|
||||
void _reset(SearchConditions conditions) {
|
||||
_beginGeneration();
|
||||
_removed.clear();
|
||||
state = MailSearchState(
|
||||
allMailboxes: state.allMailboxes,
|
||||
indexStatus: state.indexStatus,
|
||||
conditions: conditions,
|
||||
);
|
||||
}
|
||||
|
||||
/// 목록 끝에 닿았다 · 끝의 [다시 시도] — 한 쪽을 채운 차례 뒤에만 다음 차례를 묻는다([더 찾기]가 필요하면 그것으로만)
|
||||
@@ -257,9 +294,13 @@ class MailSearchController extends Notifier<MailSearchState> {
|
||||
|
||||
// ── 찾기 ──────────────────────────────────────────
|
||||
|
||||
/// 첫 쪽을 묻고 이어 찾는다 — [keepVisible] 이면 보이는 결과를 둔 채(실패하면 그대로 두고 인터셉터가 알린다),
|
||||
/// 첫 쪽을 묻고 이어 찾는다([q] 가 비면 [conditions] 만으로) — [keepVisible] 이면 보이는 결과를 둔 채(실패하면 그대로 두고 인터셉터가 알린다),
|
||||
/// 아니면 뼈대부터(처음 찾는 쪽은 알리지 않는다 — 화면이 받지 못함을 그린다)
|
||||
Future<void> _search(String q, {bool keepVisible = false}) async {
|
||||
Future<void> _search(
|
||||
String q, {
|
||||
required SearchConditions conditions,
|
||||
bool keepVisible = false,
|
||||
}) async {
|
||||
final Ref ref = this.ref;
|
||||
final int seq = _beginGeneration();
|
||||
final CancelToken cancel = _cancel!;
|
||||
@@ -270,6 +311,7 @@ class MailSearchController extends Notifier<MailSearchState> {
|
||||
refreshing: true,
|
||||
fill: MailSearchFill.none,
|
||||
loadMoreFailed: false,
|
||||
conditions: conditions,
|
||||
)
|
||||
: MailSearchState(
|
||||
phase: MailSearchPhase.searching,
|
||||
@@ -277,6 +319,7 @@ class MailSearchController extends Notifier<MailSearchState> {
|
||||
query: q,
|
||||
searchedAll: all,
|
||||
indexStatus: state.indexStatus,
|
||||
conditions: conditions,
|
||||
);
|
||||
unawaited(_refreshIndexStatus());
|
||||
String? mailboxId;
|
||||
@@ -298,6 +341,7 @@ class MailSearchController extends Notifier<MailSearchState> {
|
||||
try {
|
||||
final MailPage<MailSearchItem> page = await _repository.searchMessages(
|
||||
q: q,
|
||||
conditions: conditions,
|
||||
mailboxId: mailboxId,
|
||||
silent: !keepVisible,
|
||||
cancelToken: cancel,
|
||||
@@ -316,6 +360,7 @@ class MailSearchController extends Notifier<MailSearchState> {
|
||||
items: List<MailSearchItem>.unmodifiable(items),
|
||||
pageInfo: page.pageInfo,
|
||||
indexStatus: state.indexStatus,
|
||||
conditions: conditions,
|
||||
);
|
||||
await _fillRound(
|
||||
seq,
|
||||
@@ -365,6 +410,7 @@ class MailSearchController extends Notifier<MailSearchState> {
|
||||
try {
|
||||
page = await _repository.searchMessages(
|
||||
q: current.query!,
|
||||
conditions: current.conditions,
|
||||
mailboxId: current.searchedMailboxId,
|
||||
cursor: cursor,
|
||||
silent: beforeAny,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:arasystem_app/core/api/api_exception.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mail_page.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mail_search.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/search_conditions.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 검색 화면을 여는 값 — 화면(State)마다 하나. [instance] 로 가른다(같은 메일함에서 검색을 다시 열어도 앞 화면의 결과와 섞이지 않게 —
|
||||
@@ -68,6 +69,7 @@ class MailSearchState {
|
||||
this.partial = false,
|
||||
this.loadMoreFailed = false,
|
||||
this.indexStatus,
|
||||
this.conditions = SearchConditions.none,
|
||||
});
|
||||
|
||||
final MailSearchPhase phase;
|
||||
@@ -75,9 +77,14 @@ class MailSearchState {
|
||||
/// 찾을 곳 — 전체 메일함(참) · 시작한 메일함(거짓). 다음 검색이 쓴다
|
||||
final bool allMailboxes;
|
||||
|
||||
/// 보이는(찾는) 결과의 검색어 — 찾기 전 · 낱말이 너무 많음이면 null. 칸의 글을 고친 채 아직 찾지 않았어도 이 값이다
|
||||
/// 보이는(찾는) 결과의 검색어 — 찾기 전 · 낱말이 너무 많음이면 null, 조건만으로 찾았으면 빈 글.
|
||||
/// 칸의 글을 고친 채 아직 찾지 않았어도 이 값이다
|
||||
final String? query;
|
||||
|
||||
/// 걸린 조건(2차 — 시트의 [찾기]로 건다) — 보이는 결과를 찾은 조건이고, 다음 검색 · 다시 찾기 · 이어 찾기가 그대로 쓴다.
|
||||
/// 찾기 전([✕] 뒤) · 낱말이 너무 많음에도 남는다(조건 줄이 보인다). 화면 상태에만 둔다(주소에 싣지 않는다)
|
||||
final SearchConditions conditions;
|
||||
|
||||
/// 보이는 결과를 전체 메일함에서 찾았다 — 줄마다 메일함이 다르다
|
||||
final bool searchedAll;
|
||||
|
||||
@@ -135,6 +142,7 @@ class MailSearchState {
|
||||
bool? partial,
|
||||
bool? loadMoreFailed,
|
||||
Object? indexStatus = _unset,
|
||||
SearchConditions? conditions,
|
||||
}) => MailSearchState(
|
||||
phase: phase ?? this.phase,
|
||||
allMailboxes: allMailboxes ?? this.allMailboxes,
|
||||
@@ -157,5 +165,6 @@ class MailSearchState {
|
||||
indexStatus: identical(indexStatus, _unset)
|
||||
? this.indexStatus
|
||||
: indexStatus as SearchIndexStatus?,
|
||||
conditions: conditions ?? this.conditions,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ import 'package:arasystem_app/shared/widgets/keep_all_text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// 검색 화면의 위 막대 — [←](검색 닫기) · 칸("메일 검색") · 글이 있으면 [✕](검색어 지우기) (보드 `App1Search`, 높이 64).
|
||||
/// 검색 화면의 위 막대 — [←](검색 닫기) · 칸("메일 검색") · 글이 있으면 [✕](검색어 지우기) · 맨 오른쪽 [조건](조건으로 찾기)
|
||||
/// (보드 `App1Search` · `App1SearchConditions`, 높이 64).
|
||||
///
|
||||
/// 칸은 자판의 [검색]으로만 찾는다(치는 동안 묻지 않는다). 글 자판(메일 주소 자판은 한글을 막는다) · 자동 고침 끔 · 추천 켬 ·
|
||||
/// 둥근 따옴표 끔(큰따옴표가 둥근 따옴표가 되면 구절이 아니라 낱말로 찾는다). UTF-16 200자까지(서버와 같은 단위).
|
||||
@@ -21,6 +22,10 @@ class MailSearchBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
required this.onSubmitted,
|
||||
required this.onClear,
|
||||
required this.onClose,
|
||||
required this.onConditions,
|
||||
required this.conditionsFocusNode,
|
||||
this.conditionsApplied = false,
|
||||
this.conditionsOpen = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@@ -30,6 +35,18 @@ class MailSearchBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
final VoidCallback onClear;
|
||||
final VoidCallback onClose;
|
||||
|
||||
/// [조건] — 조건으로 찾기 시트를 연다
|
||||
final VoidCallback onConditions;
|
||||
|
||||
/// [조건]의 초점 — 시트를 열기 전에 받아 두어, 시트가 닫히면 초점이 칸이 아니라 이 단추로 돌아온다(자판이 다시 올라오지 않게)
|
||||
final FocusNode conditionsFocusNode;
|
||||
|
||||
/// 조건이 걸려 있다 — [조건]이 옅은 청록 원 · 낭독에 "조건이 걸려 있음"
|
||||
final bool conditionsApplied;
|
||||
|
||||
/// 시트가 열려 있다 — [조건]이 옅은 청록 원 · 펼침 상태
|
||||
final bool conditionsOpen;
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(AppSize.appBarHeight);
|
||||
|
||||
@@ -73,7 +90,7 @@ class MailSearchBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
smartDashesType: SmartDashesType.disabled,
|
||||
smartQuotesType: SmartQuotesType.disabled,
|
||||
inputFormatters: const <TextInputFormatter>[
|
||||
_LineBreaksToSpaces(),
|
||||
LineBreaksToSpacesFormatter(),
|
||||
Utf16LengthLimitFormatter(kMailSearchMaxLength),
|
||||
],
|
||||
style: style,
|
||||
@@ -107,14 +124,134 @@ class MailSearchBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
onPressed: onClear,
|
||||
),
|
||||
),
|
||||
_ConditionsButton(
|
||||
applied: conditionsApplied,
|
||||
open: conditionsOpen,
|
||||
focusNode: conditionsFocusNode,
|
||||
onPressed: onConditions,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 붙여 넣은 줄바꿈 → 띄어쓰기 — 낱말을 나누는 글자로 받는다(쓰기 화면의 받는 사람 칸과 같은 결)
|
||||
class _LineBreaksToSpaces extends TextInputFormatter {
|
||||
const _LineBreaksToSpaces();
|
||||
/// [조건] — 누르는 곳 48 안의 보이는 원 40(시트가 열려 있거나 조건이 걸려 있으면 옅은 청록 원 · 진한 청록 아이콘, 평소는 바탕 없음).
|
||||
/// 화면 낭독기는 "조건으로 찾기"(걸려 있으면 ", 조건이 걸려 있음")를 펼침 상태(시트가 열려 있음)와 함께 읽는다 — 툴팁은 늘 "조건으로 찾기"
|
||||
class _ConditionsButton extends StatelessWidget {
|
||||
const _ConditionsButton({
|
||||
required this.applied,
|
||||
required this.open,
|
||||
required this.focusNode,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final bool applied;
|
||||
final bool open;
|
||||
final FocusNode focusNode;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final ColorScheme scheme = theme.colorScheme;
|
||||
final bool active = applied || open;
|
||||
return Semantics(
|
||||
container: true,
|
||||
button: true,
|
||||
expanded: open,
|
||||
label: MailSearchTexts.conditionsLabel(applied: applied),
|
||||
onTap: onPressed,
|
||||
child: ExcludeSemantics(
|
||||
child: IconButton(
|
||||
key: const ValueKey<String>('mail-search-conditions'),
|
||||
focusNode: focusNode,
|
||||
icon: const Icon(Icons.tune, size: AppSize.iconLarge),
|
||||
tooltip: MailSearchTexts.conditions,
|
||||
onPressed: onPressed,
|
||||
style: IconButton.styleFrom(
|
||||
minimumSize: const Size.square(AppSize.searchConditionsToggle),
|
||||
fixedSize: const Size.square(AppSize.searchConditionsToggle),
|
||||
padding: EdgeInsets.zero,
|
||||
backgroundColor: active ? scheme.primaryContainer : null,
|
||||
foregroundColor: active
|
||||
? scheme.onPrimaryContainer
|
||||
: theme.extension<AppExtraColors>()!.textStrong,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 걸린 조건 줄 — "조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음" + 오른쪽 [조건 지우기] (보드 `App1SearchConditions` — 최소 48 · 아래 선).
|
||||
/// 글이 길면 낱말 단위로 줄을 바꾼다(13). [조건 지우기]는 걸린 조건을 모두 뺀다(부르는 쪽이 다시 찾는다)
|
||||
class MailSearchConditionLine extends StatelessWidget {
|
||||
const MailSearchConditionLine({
|
||||
required this.text,
|
||||
required this.onClear,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String text;
|
||||
final VoidCallback onClear;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: extra.borderSubtle,
|
||||
width: AppSize.borderWidth,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: AppSize.touchMin),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: AppSpacing.s16,
|
||||
right: AppSpacing.s8,
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: AppSpacing.s6),
|
||||
child: KeepAllText(
|
||||
text,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: extra.textStrong,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.s8),
|
||||
TextButton(
|
||||
onPressed: onClear,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: theme.colorScheme.onPrimaryContainer,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.s12,
|
||||
),
|
||||
),
|
||||
child: const Text(MailSearchTexts.clearConditions),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 붙여 넣은 줄바꿈 → 띄어쓰기 — 낱말을 나누는 글자로 받는다(쓰기 화면의 받는 사람 칸과 같은 결).
|
||||
/// 검색 칸 · 조건 시트의 글 칸이 쓴다 — 그래서 칸은 한 줄(`maxLines: 1`)이 아니다(한 줄 칸은 Flutter 가 줄바꿈을 먼저 지운다)
|
||||
class LineBreaksToSpacesFormatter extends TextInputFormatter {
|
||||
const LineBreaksToSpacesFormatter();
|
||||
|
||||
static final RegExp _lineBreaks = RegExp(r'\r\n|\r|\n');
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,17 +36,25 @@ class MailSearchIntro extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 결과 없음 — "‘해지 통보’가 들어간 메일이 없습니다" · 도움말 · (색인을 준비하는 중이면) 옛 한계 한 줄 · (한 메일함에서 찾았으면) [전체 메일함에서 찾기]
|
||||
/// 결과 없음 — "‘해지 통보’가 들어간 메일이 없습니다"(조건만으로 찾았으면 "조건에 맞는 메일이 없습니다") · 도움말 ·
|
||||
/// (색인을 준비하는 중이면) 옛 한계 한 줄 · (한 메일함에서 찾았으면) [전체 메일함에서 찾기].
|
||||
/// 도움말은 검색어 + 조건이면 "다른 말로 찾거나 조건을 줄여 보세요.", 조건만이면 "조건을 바꾸거나 줄여 보세요.",
|
||||
/// 검색어만이면 "다른 말로 찾아 보세요."(걸린 조건이 없으니 조건을 줄이라고 하지 않는다 — 웹과 같다)
|
||||
class MailSearchEmpty extends StatelessWidget {
|
||||
const MailSearchEmpty({
|
||||
required this.query,
|
||||
required this.indexing,
|
||||
required this.onSearchAll,
|
||||
this.hasConditions = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// 찾은 검색어 — 조건만으로 찾았으면 빈 글
|
||||
final String query;
|
||||
|
||||
/// 조건이 걸려 있었다
|
||||
final bool hasConditions;
|
||||
|
||||
/// 지난 메일을 검색할 수 있게 준비하는 중 — 그동안 찾지 못하는 말의 예를 더한다
|
||||
final bool indexing;
|
||||
|
||||
@@ -58,6 +66,11 @@ class MailSearchEmpty extends StatelessWidget {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
|
||||
final VoidCallback? searchAll = onSearchAll;
|
||||
final String hint = !hasConditions
|
||||
? MailSearchTexts.emptyWordsHint
|
||||
: query.isEmpty
|
||||
? MailSearchTexts.emptyConditionsHint
|
||||
: MailSearchTexts.emptyHint;
|
||||
return MailStateLayout(
|
||||
icon: Icons.search,
|
||||
iconColor: extra.textMuted,
|
||||
@@ -65,7 +78,7 @@ class MailSearchEmpty extends StatelessWidget {
|
||||
title: MailSearchTexts.emptyTitle(query),
|
||||
titleStyle: _stateTitleStyle(theme),
|
||||
children: <Widget>[
|
||||
KeepAllText(MailSearchTexts.emptyHint, style: _stateHintStyle(theme)),
|
||||
KeepAllText(hint, style: _stateHintStyle(theme)),
|
||||
if (indexing) ...<Widget>[
|
||||
const SizedBox(height: AppSpacing.s8),
|
||||
KeepAllText(
|
||||
|
||||
Reference in New Issue
Block a user