docs: 앱 검색 2차(조건으로 찾기)를 앱 README · 계약 · flutter 스킬에 적는다

조건 시트 · 날짜 · 조건 줄 · 낱말 넘김 · 시트의 세 모양 · 가림막처럼 구현하며
정한 것과 웹과 다른 점(검색어도 조건도 비면 찾기 전 화면 · 시트 안 낱말 알림 ·
날짜는 오늘까지)을 적고, 1차 문서의 "조건으로 찾기는 2차"와 미룬 것을 고친다.
조건 칸은 14단계 서버부터 받는다는 것과 알려진 한계 둘(라틴 글자의 "나" 견주기 ·
한 스크롤 모양에서 끌어 내려 닫기)도 남긴다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 19:00:33 +09:00
parent facf9cd936
commit 9c5db05ada
3 changed files with 44 additions and 10 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S
| **메일 읽기 (`App1Reader`)** | `lib/features/mail/` — domain(`mail_html`(두 번째 정화 · 중첩 비용 · CSP 문서 · 중계 그림 바꿔 넣기) · `mail_images` · `mail_links` · `compose_request` · `mail_body_notices`) · `data/`(`models/message_detail.dart` · `mail_link_launcher.dart`) · presentation(`pages/message_reader_page.dart` · `providers/message_reader_controller.dart` · `attachment_downloads.dart` · widgets `mail_body_view` · `reader_*`), 시험 도구 `test/support/fake_webview.dart`(`WebViewPlatform.instance`) · `file_fakes.dart` | **WebView 에는 `buildMailDocument` 의 문서만 넣는다**(CSP 를 맨 앞 · JS 끔 · 처음 문서 말고는 탐색을 막음 · 안드로이드는 파일 · content 접근 · 위치 · 섞인 내용 끔, 아래 틀(iframe)의 탐색은 판정을 거치지 않으니 막는 것은 정화(내용째 버림)와 CSP 다). 받은 HTML 은 서버가 정화했어도 `sanitizeMailHtml` 을 한 번 더 지난다 — package:html 은 `parse().body`(`parseFragment` 는 맨 위 형제가 많으면 제곱), 그 전에 `htmlNestingCost`(선형 · 파서보다 깊게 센다)로 한도를 보고, 바로 할지 isolate 로 보낼지는 `mailBodyBuildPlaceOf` 하나로 고른다. 중계 그림은 Dio(Bearer)로 받아 `data:` 로 넣고 토큰은 WebView 에 가지 않는다 — 문서에 싣는 양은 **나온 횟수까지** 센다(`resolveProxiedImages`). 응답은 그 요청이 바꾼 칸만 지금 요약에 덮는다(`_applyResponse`). 메일 id 마다 autoDispose 프로바이더(라우트 `ValueKey(id)`) · 받기 세대 · 본문 세대 · 세션 세대로 늦은 응답을 버린다. 목록에는 `MailChange(updated, removedIds)` 로 알린다. 쓰기 화면을 여는 값은 웹과 같은 쿼리(`replyTo` · `all` · `forward` · `draft` — `composeIntentOf`), mailto 로 채울 값은 go_router `extra` 의 `ComposePrefill` |
| 받은 파일 · 고른 파일 · 형식 | `lib/core/files/` — `downloaded_files.dart`(`DownloadedFileStore` · `CacheDirFileStore` · `FileOpener`) · `picked_files.dart`(`PickedFileStore` · `PlatformPickedFileStore` — 채널의 `pick`) · `file_names.dart`(`safeFileName` · `displayFileName`) · `file_types.dart`(`fileOpenTypeOf` · `isBlockedFileOpen`), 안드로이드 `MainActivity.kt`(채널 `kr.it.arasystem.app/files`) · `res/xml/downloaded_file_paths.xml` · 매니페스트의 FileProvider `${applicationId}.downloads` | 모듈 공통(메일 첨부 · 나중의 파일 모듈). 자리는 `cacheDir/downloads/<무작위 16자>/<정리한 이름>` 뿐 — **짝 셋**: Dart `kDownloadedFilesFolder` · `downloaded_file_paths.xml` 의 cache-path · `MainActivity.kt` 의 `DOWNLOADS_DIR`(채널 이름도 짝). Kotlin 은 canonical 경로로 그 폴더 안인지 다시 보고 읽기 권한을 한 번만 준다. 파일 여는 플러그인(open_filex 등)은 쓰지 않는다 — 저장소 권한 · 기기 전체 경로를 더한다. 열 형식은 확장자 먼저(`package:mime`), 보낸 쪽 형식은 확장자를 모를 때만 — 형식 글 모양 검사는 짝: Kotlin `MIME_PATTERN` · `GENERIC_TYPE`. 막는 형식(웹 문서 · svg · 설치 파일 · 인증서)은 받지도 않는다. **고른 파일**(메일 쓰기 2차): 고르는 창(`ACTION_GET_CONTENT` — 권한 없음 · `*/*` · 여러 개)으로 고른 파일을 `cacheDir/uploads/<무작위 16자>/<정리한 이름>` 에 복사본으로 둔다 — **짝 둘**: Dart `kPickedFilesFolder` · `MainActivity.kt` 의 `UPLOADS_DIR`(다른 앱에 건네지 않아 FileProvider xml 에는 없다). 채널 인자 · 답의 칸 · 값도 짝이다 — Dart `PlatformPickedFileStore.pick` · `PickResult.parse` · `PickedFile.fromChannel` ↔ Kotlin `ARG_*` · `KEY_*` · `OUTCOME_*` · `STATUS_*`. Kotlin 은 `content://` 만 읽고, Dart 사전 검사와 같은 차례(개수 → 형식 → 파일당 → 합계 — `copyPicked` ↔ `precheckPickedFile`)로 넣을 파일만 복사한다 — 미리 거르기일 뿐 판정은 Dart 가 다시 한다. 확장자를 떼는 규칙도 짝(Kotlin `displayFileName` · `extensionOf` · `isDartWhitespace` ↔ Dart `displayFileName` · `extensionOf` · `trim()`). 복사본 이름은 Kotlin `safeFileName`(Dart 와 같은 규칙), 보이고 보내는 이름은 `displayFileName` 이다. 복사 스레드는 Throwable 까지 잡아 한 번만 답한다. 고르기와 모두 지우기는 차례로 돌고, 지우기는 기호 링크를 푼 경로로 복사본 폴더 바로 아래 한 칸만 지운다. `pick` 은 그 차례 안에서 `kPickTimeout`(10분) 시한을 둔다 — 넘으면 `timedOut`(Dart 에만 있는 결과)으로 끝내고 차례를 넘긴다(늦게 온 복사본은 다음 정리에). 파일 고르는 플러그인은 쓰지 않는다(권한 · 기기 전체 경로) |
| **메일 쓰기 (`App1Compose` — 1차 · 2차)** | `lib/core/people/`(사람 고르기 — 주소 칩 · 추천 맞추기 · 그룹, 모듈 공통. 표본 표 `test/core/people/recipient_match_samples.dart` 는 웹 · 백엔드와 **세 쪽 짝**) · `features/mail/domain/`(`compose_body` 직렬화 · `compose_fit`(`buildInitialQuote`) · `mail_quote` · `compose_draft_body`(`buildDraftBody` · `draftBodyPlaceOf`) · `compose_reply` · `compose_send` · `compose_attachments`(2차 — 형식 표 · 한도 · 사전 검사) · `compose_signature` · `html_nodes`) · `data/`(`compose_models` · `compose_payload` · `repositories/compose_repository.dart`) · presentation(`pages/message_compose_page.dart` · `providers/compose_controller.dart`(화면마다 하나 — `ComposeArgs.instance`, autoDispose) · `draft_autosave.dart` · `attachment_uploads.dart`(`AttachmentUploadQueue` — 둘씩 · 취소 · 다시 올리기) · `signature_image_provider.dart` · widgets `compose_*`) · `core/utils/utf16_length.dart` · 시험 도구 `test/support/compose_fakes.dart` | 본문 모양은 계약 2절 "앱이 만드는 모양". 웹 짝(`mail-address.ts` · `recipient-match.ts` · `MAIL_HTML_STYLES` · `docToText` · `fit-draft` · `DRAFT_*` · `shouldRenewSendKey`)은 각 파일 머리말에 적었다 — 한쪽을 고치면 둘 다. **package:html 의 `text` · `querySelectorAll` · `innerHtml` · `outerHtml` 은 깊이만큼 되풀이한다** — 쓰기 화면은 `html_nodes.dart`(되풀이 없는 글 모으기 · 찾기 · 직렬화)를 쓰고, 저장본 · 원문은 읽기 화면과 같은 한도 · 문턱(`mailBodyBuildPlaceOf`)으로 그 자리 · isolate · 읽지 않음을 고른다. **화면을 떠나도 끝까지 가야 하는 일(보내기)은 기다림을 시작하기 전에 `ref.keepAlive()` 를 잡고 try/finally 로 놓는다**(2026-11-07 리뷰 — 저장을 기다리는 사이 내려가 메일이 나가지 않았다). 임시 저장은 Timer 만 쓴다(시험은 `tester.pump` 로 시간을 넘긴다). 보내기 결과는 `mailToastProvider` 의 마감 알림(목록 · 읽기 화면이 그린다). 서버와 같은 한도를 거는 칸은 `Utf16LengthLimitFormatter`(UTF-16 단위). **첨부(2차)**: 형식 표 `kMailAttachmentTypes` 는 **짝: 백엔드 `attachment.policy.ts` 의 `ATTACHMENT_TYPES`** — 시험이 그 파일을 읽어 표와 `MAX_ATTACHMENTS_PER_MESSAGE` 를 맞춰 본다 (앱 CI 는 flutter/ 가 바뀐 커밋에서만 돈다 — 백엔드 표만 고친 커밋에서는 이 시험이 돌지 않으니 함께 고친다). 사전 검사는 웹 `precheckAttachment` 의 차례, 보내기 막기는 웹 `sendBlockOf` 의 차례(가져오는 중은 올리는 중 다음). 저장소 `uploadAttachment`(multipart `file` · 확장자의 정식 형식 · 보내기 5분 · 받기 60초 · 알림 끔 — 401 뒤에는 FormData 를 복제해 복사본을 처음부터 다시 읽는다) · `getAttachmentLimits`(`GET /api/mail-quota`). 다 올리면 서버의 이름 · 크기로 싣는다(이름 255자 — 웹과 같다, 2026-11-07 리뷰). `MAIL_004` 뒤 다시 올릴 수 없는 고른 파일은 `problem: missing` 으로 막는다(웹 `markMissing`). 시험 도구 `FakePickedFiles`(`test/support/file_fakes.dart`) · `FakeComposeBackend` 의 `uploads` · `uploadGates` · `quota` · `quotaGate` · `FakeServer.readRequestBodies` |
| **메일 검색 (`App1Search` — 1차)** | domain `search_text`(**짝: 웹 `search-match.ts` · `mail-search.ts` · 백엔드 `search-text.ts`** — 표본 표는 시험이 `backend/test/search-text-samples.ts` 의 FOLD · GRAM 을 직접 읽는다 · `test/support/ts_table.dart`) · `search_highlight`(짝: 웹 `search-highlight.ts`) · `data/models/mail_search.dart` · `mail_repository`(`searchMessages` — 응답의 `Date` 로 서버 시각 · `searchIndexStatus` · `getMessage(highlight)`) · presentation(`pages/mail_search_page` · `providers/mail_search_controller`(화면마다 — `MailSearchArgs.instance`, autoDispose) · `mail_search_state`(`waitsForMore`) · `mail_search_view` · widgets `mail_search_bar` · `mail_search_states` · `highlighted_text`) · `core/utils/js_case.dart` | 탭 안 `/mail/search`(시작한 메일함은 `extra` 의 `MailSearchStart`, 검색어는 주소에 싣지 않는다). 이어 찾기는 한 차례 `kMailSearchFillMaxRequests`(5) · 20통, 모자라면 [더 찾기]로 멈춘다. 검색 세대 + `CancelToken`(새 검색 · 화면 닫기에 끊는다), 준비 상태는 따로 세대. 결과는 `MailChange` 와 붙잡힌 줄 다시 받기(보낼 시각 + 2초)만 반영한다. **강조 위치는 그 응답의 글에만**. 읽기 키는 `MessageReaderArgs(id, highlight)`, 결과에서 연 메일은 `MailSearchOpen`. 노랑 `AppColors.searchMark` 는 **짝: 웹 `--color-search-mark` · `mail_html.dart` 의 mark**. 시험 도구는 `FakeMailBackend` 의 `defaultSearch`(받은 시각 내림차순 · `searchCursorAfter` 는 그 줄 다음부터) · `move` · `searchResponder` · `searchGate` · `searchHighlights` · `highlightDetails` · `searchIndex` |
| **메일 검색 (`App1Search` — 1차 · `App1SearchConditions` — 2차)** | domain `search_text`(**짝: 웹 `search-match.ts` · `mail-search.ts` · 백엔드 `search-text.ts`** — 표본 표는 시험이 `backend/test/search-text-samples.ts` 의 FOLD · GRAM 을 직접 읽는다 · `test/support/ts_table.dart`) · `search_highlight`(짝: 웹 `search-highlight.ts`) · `search_conditions`(2차 — **짝: 웹 `mail-search.ts` · `mail-vacation.ts`** — 조건 다듬기 · 합친 낱말 수 · 기간 · after/before(기기 시간대 0시 → UTC, 끝 날짜는 다음 날 0시) · 강조 쿼리 · "나") · `data/models/mail_search.dart` · `mail_repository`(`searchMessages` — 응답의 `Date` 로 서버 시각 · `searchIndexStatus` · `getMessage(highlight)`) · presentation(`pages/mail_search_page` · `providers/mail_search_controller`(화면마다 — `MailSearchArgs.instance`, autoDispose) · `mail_search_state`(`waitsForMore`) · `mail_search_view` · widgets `mail_search_bar`(+ 공용 `LineBreaksToSpacesFormatter`) · `mail_search_states` · `highlighted_text` · `mail_search_conditions_sheet`(2차)) · `core/utils/js_case.dart` | 탭 안 `/mail/search`(시작한 메일함은 `extra` 의 `MailSearchStart`, 검색어는 주소에 싣지 않는다). 이어 찾기는 한 차례 `kMailSearchFillMaxRequests`(5) · 20통, 모자라면 [더 찾기]로 멈춘다. 검색 세대 + `CancelToken`(새 검색 · 화면 닫기에 끊는다), 준비 상태는 따로 세대. 결과는 `MailChange` 와 붙잡힌 줄 다시 받기(보낼 시각 + 2초)만 반영한다. **강조 위치는 그 응답의 글에만**. 읽기 키는 `MessageReaderArgs(id, highlight)`, 결과에서 연 메일은 `MailSearchOpen`. 조건(2차)은 화면 상태 `MailSearchState.conditions` 로 이어 찾기 · 새로고침 · 다시 시도 · 찾을 곳 바꾸기에 이어지고, "나"는 `mailSelfProvider`. 시트 가림막은 테마의 바닥 시트 가림막(`AppColors.backdrop` — 옮기기 시트도), 날짜 창은 `showDatePicker(barrierColor:)` · 머리 22(`datePickerTheme`). 노랑 `AppColors.searchMark` 는 **짝: 웹 `--color-search-mark` · `mail_html.dart` 의 mark**. 시험 도구는 `FakeMailBackend` 의 `defaultSearch`(받은 시각 내림차순 · `searchCursorAfter` 는 그 줄 다음부터) · `move` · `searchResponder` · `searchGate` · `searchHighlights` · `highlightDetails` · `searchIndex` |
| HTTP 클라이언트 | `lib/core/api/dio_client.dart` · `dio_provider.dart` | Dio 둘: 화면용(인터셉터 줄 `ResponseInterceptor` → `AuthInterceptor` → `ErrorNotifyInterceptor`)과 갱신 · 다시 보내기용(언래핑만). baseUrl=dotenv `API_URL`(`api_config.dart`), User-Agent `ARASYSTEM-App/<판> (Android <판>; <기종>)`(`user_agent.dart`). `LogInterceptor` 는 넣지 않는다(비밀번호 · 토큰). 시험은 `httpClientAdapterProvider` 로 가짜 서버를 끼운다 |
| 언래핑 · 코드 정규화 | `lib/core/api/response_interceptor.dart` · `api_error_mapper.dart` | `{success,data}→data`(스트림 · 바이트는 그대로 — 그 **실패** 본문은 64KB 까지 읽어 `error.code` 를 꺼낸다(`decodeRawErrorBody`). 스트림을 받는 도중의 실패는 인터셉터를 거치지 않아 부르는 쪽이 `SYS_001` 로 알린다(`DownloadInterrupted`). 비동기 처리기는 무슨 일이 있어도 `handler.next` 로 끝낸다 — 던지면 요청이 매달린다), 200 + `success:false` 도 실패, 실패는 모두 `DioException.error` 에 `ApiException`. 상태 → 코드 폴백(웹 `resolveCodeByStatus` 와 같다) |
| **ApiException** | `lib/core/api/api_exception.dart` | `{code, message, traceId, statusCode}`. 화면은 `apiErrorCodeOf(e)` 또는 `e.error as ApiException` 의 **`code`** 로 가른다 |
+36 -5
View File
@@ -182,12 +182,12 @@
고르는 동안 앱이 정리될 때(개발자 옵션 "활동 유지 안 함"), file:// 를 건네는 옛 파일 관리자, 올리는 중 화면 끄기 · 다른 앱, 느린 망의 20MB(보내기 5분),
실제 multipart(한글 · 괄호 · 콜론 이름이 웹메일에서 그대로 보이는지), `MAIL_004` 뒤 다시 올리기, TalkBack 차례(진행 막대 · ✕ · [다시 시도] · 알림), 큰 글자에서 첨부 머리의 한도 글.
### 검색 (`App1Search` · `App1SearchStates` — 1차, 2026-11-07 확정)
### 검색 (`App1Search` · `App1SearchStates` — 1차 · `App1SearchConditions` — 2차, 2026-11-07 확정)
웹 14단계 검색(계약 2절 "검색 — 14단계" · 웹 디자인 README 「14단계」)을 앱에 옮겼다. 조건으로 찾기(바닥 시트)는 2차다. 계획의 정할 것 17개는 모두 첫째 안이다(사용자 2026-11-07 "추천대로").
웹 14단계 검색(계약 2절 "검색 — 14단계" · 웹 디자인 README 「14단계」)을 앱에 옮겼다. 2차는 조건으로 찾기(바닥 시트 — 아래 「조건으로 찾기」)다. 계획의 정할 것 17개는 모두 첫째 안이다(사용자 2026-11-07 "추천대로").
- **자리**: 목록 위 막대의 [찾기]가 연다. 탭 안 화면 `/mail/search` 라 연 목록 위에 쌓이고 탭 막대가 보인다(자판이 열리면 틀이 숨긴다). 메일 탭을 다시 누르면 닫히고, 다른 탭에 다녀오면 결과가 남는다. 검색어는 주소에 싣지 않는다(시작한 메일함만 라우트 `extra`).
- **위 막대 64**: [←](검색 닫기) · 칸("메일 검색") · 글이 있으면 [✕](칸과 결과를 비우고 칸에 초점). 열면 칸에 초점을 두고 자판을 올린다. 자판의 [검색]으로만 찾고, 빈 칸 · 공백뿐이면 아무 일 없이 자판을 둔다. 찾으면 자판을 내리고, 결과를 끌어도 내린다.
- **위 막대 64**: [←](검색 닫기) · 칸("메일 검색") · 글이 있으면 [✕](칸과 결과를 비우고 칸에 초점 — 걸린 조건은 남는다) · [조건](맨 오른쪽 — 2차). 열면 칸에 초점을 두고 자판을 올린다. 자판의 [검색]으로만 찾고, 빈 칸 · 공백뿐이면 아무 일 없이 자판을 둔다(조건이 걸려 있으면 조건만으로 찾는다 — 웹과 같다). 찾으면 자판을 내리고, 결과를 끌어도 내린다.
칸은 글 자판 · 자동 고침 끔 · 추천 켬 · 둥근 따옴표와 대시 끔 · 두 줄까지 · 줄바꿈은 띄어쓰기 · UTF-16 200자다. 칸 이름은 낭독에서 한 번만 읽는다(보이는 안내 글은 낭독에서 뺀다).
- **찾을 곳 줄 56**: "찾을 곳" + [시작한 메일함 | 전체 메일함](고른 칸은 옅은 청록 바탕 · 굵은 글, ✓ 없음). 찾기 전부터 보이고, 바꾸면 칸의 글로 바로 다시 찾는다. 전체 메일함이면 줄 아래에 "휴지통과 스팸메일함은 빼고 찾습니다". 휴지통 · 스팸메일함에서 시작하면 그 안에서 찾는다.
- **결과 머리 36**(최소 — 큰 글자면 늘어난다): "‘계약’ · 4통" · 찾는 동안 "· 찾는 중" · 전체 수를 모르면 검색어만. 그 아래 안내 줄은 하나다(검색 준비 중 또는 한 글자 안내 — 함께 뜨지 않고, 준비 상태를 받지 못하면 없다).
@@ -198,6 +198,32 @@
- **결과에서 읽기**: 누르면 읽기 화면이다(임시보관함은 쓰기 화면). 머리의 받는 사람 아래에 "메일함 받은편지함" 줄을 둔다. 제목 · 첨부 이름 · 본문의 맞은 말을 칠한다(본문 mark 는 웹과 같다 — 글자색 그대로 · 모서리 2 · 좌우 1px). 지우고 돌아오면 결과 아래에 [되돌리기] 알림이 뜬다.
- **이어 찾기**: 한 차례 5번 · 20통이다. 한 차례가 한 쪽을 못 채우면 저절로 잇지 않고 [더 찾기]로 멈춘다(0통이거나 불러온 결과를 모두 뺐으면 "아직 못 찾음" 화면, 있으면 목록 끝 줄). 한 쪽을 채운 차례만 목록 끝이 다음 차례를 저절로 묻는다.
- **결과 갱신**: 새 메일 확인으로 다시 찾지 않는다. 읽기 화면이 바꾼 표시 · 메일함 · 붙잡힘만 덮고, 제목 · 미리 보기 · 강조는 그 응답의 것 그대로다. 당겨서 새로고침은 다시 찾기다.
- **조건으로 찾기**(2차 — `App1SearchConditions`, 2026-11-07 확정 · 웹 「14단계 구현하며 정한 보완 — 검색」의 [조건] 패널을 바닥 시트로 옮겼다)
- **[조건]**: 위 막대 맨 오른쪽에 고정한다(보이는 원 40 · 누르는 곳 48 · 가로 슬라이더 아이콘). 계획은 [✕] 왼쪽이었지만 글을 쓰기 시작할 때 옆으로 밀리지 않게 바꿨다.
평소 바탕 없음, 시트가 열려 있거나 조건이 걸려 있으면 옅은 청록 원. 낭독 "조건으로 찾기"(+ 펼침 상태), 걸려 있으면 ", 조건이 걸려 있음".
- **시트**: 탭 막대까지 덮는 바닥 시트(위 96 을 남김 · 위 모서리 16). 손잡이는 직접 그리고 낭독에서 뺀다(Flutter 기본 손잡이는 "닫기" 단추로 읽혀 [✕]와 겹친다).
머리 "조건으로 찾기" + [✕](닫기)와 아래 단추 줄([조건 지우기] · [찾기])은 고정이고 가운데만 스크롤한다(스크롤되면 머리 아래 선). 자판이 올라오면 시트도 올라간다.
모양은 셋이다 — 자판 위 자리에서 머리 · 단추 줄의 실제 높이(낱말 알림 · 아래 안전 영역 · 글자 크기 포함)와 위 96 을 빼도 가운데에 칸 하나(52 × 글자 배율)가 남으면 위 96 을 남긴 고정, 96 을 쓰면 남으면 시트를 화면 위까지 올린 고정, 그래도 모자라면(가로 화면 + 자판 약 170 이상 등) 머리 · 단추 줄까지 시트 전체가 한 스크롤이다. 판정은 같은 레이아웃 안에서 한 번에 하고(`_SheetBody`), 트리는 그대로라 칸의 초점 · 입력이 끊기지 않는다. 모양이 바뀌면 초점 칸을, 낱말 알림이 뜨면 알림과 단추 줄을 보이게 스크롤한다. 열 때 초점을 [조건]으로 옮겨 자판을 내린다(닫은 뒤 자판이 다시 올라오지 않게).
- **칸**: 보낸 사람 · 받는 사람 · 제목 · 내용(글 칸 52 — 검색 칸과 같은 자판이고 줄바꿈은 띄어쓰기라 두 줄까지 는다, 자판의 [검색] = [찾기]) ·
기간(전체 기간 · 최근 1주 · 최근 1달 · 최근 3달 · 최근 1년 · 날짜 고르기 — "기간, 고른 값"을 한 단추로 읽는다) · 날짜 고르기면 시작 날짜 · 끝 날짜 두 칸 · 첨부(체크 줄 "첨부 파일이 있는 메일만") · 안내 글(첫 화면에서는 접힘 아래).
- **날짜**: Flutter 날짜 창이다 — 1970년 1월 1일부터 오늘까지만, 처음 날짜는 칸의 값을 범위 안으로, 머리 큰 글 22 · 굵기 600 은 테마(`datePickerTheme`).
값은 "2026. 11. 7." 꼴 + 칸마다 [✕](낭독 "시작 날짜 지우기" · "끝 날짜 지우기"), 비면 달력 아이콘. 두 날짜는 서로 막지 않고, 거꾸로면 고른 즉시 끝 날짜 칸만 빨갛게 하고
두 칸 아래에 "끝 날짜를 시작 날짜와 같거나 그 뒤로 골라 주세요."를 둔다(웹은 두 칸 모두).
- **열고 닫기**: 열면 걸린 조건으로 채운다. [찾기] 없이 닫으면(✕ · 뒤로 · 가림막 · 끌어 내림) 고친 값을 버린다.
시트의 [조건 지우기]는 칸만 비우고(기간은 전체 기간 · 알림까지) 시트는 열린 채 · 찾지 않는다(웹 2026-11-07 규칙). "최근 N"은 [찾기]를 누를 때 날짜로 바꾼다(웹과 같다 — 자정 무렵에만 다르다).
- **[찾기]**: 날짜가 거꾸로면 넘기지 않고 그 칸으로 스크롤 · 초점. 검색 칸과 글 조건 넷의 낱말이 합쳐 8개를 넘으면 시트를 열어 둔 채 단추 줄 바로 위에 빨간 글
"검색 칸과 [조건]에 넣은 낱말은 모두 합쳐 8개까지입니다. 낱말을 몇 개 줄여 주세요."(웹 패널은 `VAL_001` 알림). 통과하면 시트를 닫고 검색 칸의 글로 찾는다.
검색어도 조건도 비면 요청 없이 찾기 전 화면이다(웹은 검색을 닫는다).
- **조건 줄**: 찾을 곳 줄 아래(최소 48) "조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음" + [조건 지우기]. 글은 웹 `searchConditionsSummary` 와 같다
(쉼표로 잇기 · 기간 이름 또는 "2026. 10. 1.부터 11. 5.까지" · "… 하루" · 내 이름이나 주소면 "내가 보낸 메일" · "내가 받은 메일") — 줄 수를 자르지 않고 낱말 단위로 넘긴다.
줄의 [조건 지우기]는 걸린 조건을 모두 빼고 칸의 글(비었으면 보이던 검색어)로 다시 찾는다. [✕] 뒤처럼 찾기 전이면 조건만 뺀다.
- **결과 글**: 검색어 없이 찾으면 머리는 "N통" · "찾는 중". 결과 없음의 제목은 검색어가 없으면 "조건에 맞는 메일이 없습니다",
도움말은 검색어 + 조건 "다른 말로 찾거나 조건을 줄여 보세요." · 조건만 "조건을 바꾸거나 줄여 보세요." · 검색어만 "다른 말로 찾아 보세요.".
자판의 [검색]으로 낱말 8개를 넘기면 1차처럼 결과 자리 안내이고 글은 시트의 글과 같다(1차의 앱 글을 바꿨다). 조건 줄은 남는다.
- **이어지는 곳**: 조건은 화면 상태(`MailSearchState.conditions` — 주소에 싣지 않는다)이고 이어 찾기 · [더 찾기] · 당겨서 새로고침 · [다시 시도] · 찾을 곳 바꾸기 ·
[전체 메일함에서 찾기]에 그대로 이어진다. 결과에서 연 메일의 강조 쿼리에 q · from · to · subject · body 를 싣는다.
- **가림막**: 앱 대화상자 가림막 색(`AppColors.backdrop` — rgba(11,54,64,.32))이다. 바닥 시트 테마로 주므로 메일함 옮기기 시트도 같은 색이 됐다.
날짜 창은 `showDatePicker(barrierColor:)`로 준다(테마의 대화상자 가림막을 바꾸면 확인 창까지 바뀐다).
- **구현하며 정한 것**(2026-11-07)
- 길게 누르기: InkWell 에 길게 누르기 처리가 없으면 뗄 때 누르기로 읽어 메일이 열린다 — 검색 줄은 GestureDetector 가 받아 두고 아무것도 하지 않는다(진동 없이). 줄의 낭독 동작은 `MessageRow` 바깥 Semantics 에 둔다(목록 줄도 같다).
- 같은 검색어로 다시 [검색] · 당겨서 새로고침은 결과를 둔 채 찾고, 그동안 머리를 "· 찾는 중"으로 둔다. 실패하면 결과를 그대로 두고 알린다.
@@ -208,16 +234,21 @@
- 받은편지함 주소에서 연 검색이 메일함 목록을 받기 전이면 받아 본 뒤 기본 메일함에서 찾는다(받지 못하면 받지 못함 화면).
- 강조 위치는 그 위치를 준 응답의 글에만 쓴다(Dart 에 NFC 가 없다). 강조 쿼리는 `searchHighlight` 칸이 있던 결과에서만 싣는다. [이미지 보기]는 같은 쿼리로 본문 · 첨부 · 강조를 함께 바꾼다. 읽기 화면이 강조 응답의 요약(NFC 제목)을 아래 목록에 알리는 것은 받아들였다(글자는 같고 목록이 다시 맞추면 돌아온다).
- 크기: 전환 칸 글 15, "찾을 곳" 글자색 `textSecondary`, 상태 화면 제목 15(보드), 안내 줄 아이콘 16.
- 앱에서 새로 쓴 글: 찾기 전 안내, (1차) "검색어에는 낱말을 8개까지 …". 화면 글은 `MailSearchTexts` 한 곳에 있다.
- 앱에서 새로 쓴 글: 찾기 전 안내, 낭독 글 "닫기" · "시작 날짜 지우기" · "끝 날짜 지우기"(2차). 1차의 "검색어에는 낱말을 8개까지 …"는 2차에서 웹 글로 바꿨다. 화면 글은 `MailSearchTexts` 한 곳에 있다.
- **알려진 한계**
- 결합 문자가 붙은 라틴 글자(e + U+0301)는 한 글자 판정에서 모으지 않는다(한글 자모만 모은다).
- Dart 의 유니코드 자료 판이 JS 와 달라 소문자 437자 · 글자 분류 4,433자가 다르다(대부분 Unicode 16 의 새 글자). 검색은 한 글자 판정에만 써서 결과에 영향이 없고, 받는 사람 추천은 그 글자들의 대소문자를 웹과 다르게 맞출 수 있다.
- 보낸 사람이 본문에 쓴 `<mark>` 도 같은 노랑이다. 본문의 맞은 말 사이를 옮겨 다니지 못한다.
- 조건 줄의 "나" 견주기는 한글만 모아 견준다(Dart 에 NFC 가 없다) — 내 이름을 풀어 쓴 라틴 글자(e + U+0301)로 넣으면 "나"로 읽지 못하고 "{이름} 님이 보낸 메일"이 된다(웹은 NFC 로 모은다).
- 조건 시트가 한 스크롤 모양일 때(가로 화면에 자판이 올라온 때)는 끌어 내려 닫을 수 없다 — 바깥 스크롤이 손짓을 가져간다. [✕] · 뒤로 가기로 닫는다.
- **실기기에서 볼 것**: 두 줄 칸 + 글 자판의 [검색] 키(삼성 · Gboard), 한글 조합 중 [검색], 하드웨어 자판 Enter, 여러 줄 붙여 넣기 · 삼성 둥근 따옴표, 결과를 끌어 자판이 내려가는지, 자판이 열릴 때 탭 막대 · [되돌리기] 알림 자리,
큰 글자에서 전환 칸 · 개수 줄 · 결과 줄 메일함 이름, TalkBack 차례 · 개수 줄 읽기 · 칸 이름 한 번 · 찾을 곳 고름 상태 · [더 찾기] 꺼짐, 노랑 대비 · WebView mark, 큰 메일함의 [더 찾기] 흐름, 길게 누르기가 진동 없이 아무 일도 없는지,
보내기 취소를 기다리는 메일의 "보내는 중"이 풀리는지, 앱 로그인 서버(feat/app-auth) + 14단계 서버로 실제 검색 · 강조 · 준비 상태(앱만 쓰는 직원의 색인이 시작되는지).
(2차) 자판과 함께 시트가 올라가고 초점 칸이 보이게 스크롤되는지 · 가로 화면 + 자판, 기간 메뉴가 뜨는 자리, 날짜 창의 한국어 머리와 연필(입력) 모드에서 오늘 뒤 날짜를 막는지,
뒤로 가기 · 끌어 닫기 · 가림막에서 고친 값을 버리는지와 닫은 뒤 자판, TalkBack([조건] 펼침 · 기간 단추 · 날짜 칸 값과 오류 · 단추 줄 위 알림 · 닫은 뒤 초점), 큰 글자의 날짜 칸 · 여러 줄 조건 줄,
바뀐 가림막 색(옮기기 시트 포함), 14단계 서버에서 조건만 · 검색어 + 조건(시간대 · 첨부) · from/to 를 실은 강조.
### 새 메일 알림 (푸시)
- 알림 내용은 "새 메일 N통"만 — 보낸 사람 · 제목이 Google · Apple 서버를 거치지 않게. 누르면 메일 모듈의 받은편지함으로(알림에는 모듈 이름과 id 만 싣는다).
### 처음 판에서 미룬 것
서식 편집 · 대용량 첨부 · 필터 · 주소록 화면 · 서명 고치기 · 계정 설정(웹에서) · 오프라인 저장 · iOS. 검색은 조건으로 찾기(2차)와 결과에서 여러 통 고르기를 미뤘다.
서식 편집 · 대용량 첨부 · 필터 · 주소록 화면 · 서명 고치기 · 계정 설정(웹에서) · 오프라인 저장 · iOS. 검색은 결과에서 여러 통 고르기를 미뤘다(읽기 화면의 같은 대화 · 목록 대화로 묶기도 — 보드 먼저).
+7 -4
View File
@@ -762,13 +762,16 @@ interface MailState {
그래도 못 찾았으면 [더 찾기] 화면이다. 다음 쪽의 `total` 이 `null` 이면 첫 쪽의 수를 남기고, 첫 쪽부터 `null` 이면 통 수 · "n / 전체"를 보이지 않는다.
목록을 새로 고치는 동안은 이어 받지 않는다(옛 커서로 붙지 않게).
- **클라이언트 — 준비 상태**: `GET /api/search-index` 를 받지 못하면(실패) "검색 준비 중" · 옛 한계 안내 · 한 글자 안내를 모두 보이지 않는다. 한 글자 안내는 `ready` 이고 본문을 찾을 말(검색어나 내용 조건)이 있을 때만이다.
- **클라이언트 — 앱**(Flutter 1차 — 앱 디자인 README 「검색」, 2026-11-07)
- 검색어 · 찾을 곳을 주소에 싣지 않는다(화면 상태 — 시작한 메일함만 라우트 `extra`). 웹의 "화면 주소" 줄은 앱에 없다.
- 자판의 [검색]으로만 찾고 서버와 같은 한도(앞뒤 공백을 뺀 UTF-16 200자 · 낱말 8개)를 먼저 본다. 낱말이 8개를 넘으면 묻지 않고 결과 자리에 "찾을 말이 너무 많습니다"를 보인다(웹 검색 칸은 `VAL_001` 사전 문구 알림).
- **클라이언트 — 앱**(Flutter 1차 · 2차 — 앱 디자인 README 「검색」, 2026-11-07)
- 검색어 · 찾을 곳 · 조건을 주소에 싣지 않는다(화면 상태 — 시작한 메일함만 라우트 `extra`). 웹의 "화면 주소" 줄은 앱에 없다.
- 자판의 [검색]으로만 찾고 서버와 같은 한도(앞뒤 공백을 뺀 UTF-16 200자 · 낱말 8개 — 2차부터 검색어와 조건 넷을 합쳐)를 먼저 본다. 넘으면 묻지 않는다 — 자판의 [검색]이면 결과 자리에 "찾을 말이 너무 많습니다", 조건 시트의 [찾기]면 시트 안 단추 줄 위 글이다(웹 검색 칸 · 패널은 `VAL_001` 사전 문구 알림).
[✕]는 검색어와 결과를 지운다(웹의 ✕ 는 검색을 닫는다 — 앱은 [←] · 뒤로 가기가 닫는다).
- (2차) 조건은 웹과 같은 이름으로 싣는다 — `from` · `to` · `subject` · `body` · `after` · `before`(기기 시간대의 시작 날짜 0시 · 끝 날짜 다음 날 0시를 UTC 로 — 웹과 같은 순간) · `hasAttachment=true`.
검색어가 비면 `q` 를 싣지 않는다(조건만으로 찾기). 날짜는 1970-01-01 부터 오늘까지만 고른다. 조건이 걸려 있으면 빈 칸의 [검색]은 조건만으로 찾고, [✕]는 검색어만 지운다(조건은 남는다).
검색어도 조건도 비면 묻지 않는다(웹은 검색을 닫는다). 조건 칸은 14단계 서버부터 받는다 — 그 전 서버는 400 `VAL_001` 이라 받지 못함 화면이 된다.
- 이어 찾기는 웹과 같이 한 차례 5번 · 20통이다. 다만 한 차례가 한 쪽을 못 채우면 저절로 잇지 않고 [더 찾기]로 멈춘다(0통이거나 불러온 결과를 모두 뺐으면 "아직 못 찾음" 화면). 한 쪽을 채운 차례만 목록 끝이 다음 차례를 묻는다.
- 결과는 새 메일 확인으로 다시 찾지 않는다. 앱의 읽기 화면이 바꾼 것과 붙잡힌 메일의 풀림(보낼 시각 + 2초에 그 줄만 `GET /api/messages/{id}` — 강조 쿼리 없이)만 반영하고, 제목 · 미리 보기 · 강조는 그 응답의 것 그대로다.
- 읽기 화면의 강조 쿼리(`q`)는 결과 항목에 `searchHighlight` 칸이 있을 때만(14단계 서버) 싣는다. [이미지 보기]도 같은 쿼리다.
- 읽기 화면의 강조 쿼리(`q` — 2차부터 걸린 조건의 `from` · `to` · `subject` · `body` 도)는 결과 항목에 `searchHighlight` 칸이 있을 때만(14단계 서버) 싣는다. [이미지 보기]도 같은 쿼리다.
- 강조 위치는 NFC 로 다시 맞추지 않고 그 위치를 준 응답의 글(목록은 그 항목의 제목 · `preview.text`, 읽기 화면은 강조 응답의 제목 · 첨부 이름 · textBody)에만 쓴다(Dart 에 NFC 가 없다).
읽기 화면이 강조 응답의 요약(NFC 제목)을 아래 목록에 알리는 것은 받아들였다 — 글자는 같고 목록이 다시 맞추면 돌아온다.
- `GET /api/search-index` 는 찾을 때마다 알림 없이 묻는다 — 이 요청으로 앱만 쓰는 직원의 색인도 시작된다.