docs: 앱 검색 1차의 구현하며 정한 것과 짝을 적는다

앱 디자인 README 「검색」 절, flutter 스킬 표 · 함정 두 줄 · CI 경로, 계약 2절
"검색 — 14단계"의 앱 클라이언트 줄과 목록 항목의 searchHighlight 칸이 늘 있다는
줄, 두 표본 표 머리말(앱 시험이 백엔드 표를 읽는다)을 적는다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 12:19:45 +09:00
parent 97a989be07
commit 4ca2d41bad
5 changed files with 55 additions and 5 deletions
+5 -2
View File
@@ -28,6 +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` |
| 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`** 로 가른다 |
@@ -291,9 +292,9 @@ await tokenStore.clear(byUser: true); // 로그아웃 (세션 끝은 byUser
Kotlin(`MainActivity.kt`)을 고쳤으면 커밋 전에 `flutter build apk --debug` 로 컴파일을 본다 — CI 는 안드로이드를 빌드하지 않고,
Kotlin 쪽 짝(고른 파일의 미리 거르기 · 이름 규칙)에는 시험이 없다(Dart 가 판정을 다시 하고, 어긋남은 앱 README 의 실기기 목록으로 본다).
Flutter 판은 위 "Flutter 판"(3.41.9) — CI 도 같은 판이다.
CI(`ci-flutter.yml`)는 **flutter/ 가 바뀐 커밋에서만** 자동 실행된다 (SDK 다운로드가 느려 분리).
CI(`ci-flutter.yml`)는 **flutter/ 와 앱 짝 시험이 읽는 백엔드 파일 둘(`attachment.policy.ts` · `test/search-text-samples.ts`)이 바뀐 커밋에서만** 자동 실행된다 (SDK 다운로드가 느려 분리).
다른 시점에 검사가 필요하면 Actions 탭의 "Run workflow" 로 수동 실행한다.
- [ ] **위젯 · 시험 함정**(쓰기 화면에서 찾음 — 2026-11-07):
- [ ] **위젯 · 시험 함정**(쓰기 · 검색 화면에서 찾음 — 2026-11-07):
`TextEditingController` 는 초점 · 선택만 바뀌어도 알린다 — 바꿈은 글을 견줘 가른다.
go_router 의 `context.pop()` 은 `NavigatorState.pop` 이라 PopScope 의 `canPop` 을 보지 않는다 — 화면이 스스로 닫을 때는 자기 표시로 가린다.
`ref` 는 `State.dispose` 에서 쓰지 못한다 — 노티파이어를 필드에 쥔다(`initState` 에서 `listenManual` + `read`).
@@ -302,6 +303,8 @@ await tokenStore.clear(byUser: true); // 로그아웃 (세션 끝은 byUser
기기 파일 입출력(고르기 · 복사본)은 시험 시계 안에서 끝나지 않는다 — `PickedFileStore` 를 덮어쓴다(`FakePickedFiles`).
무한 진행 막대(고르는 동안)가 떠 있으면 `pumpAndSettle` 이 끝나지 않는다 — 그동안은 `pump()` 로 넘긴다.
가짜 서버는 요청 본문을 읽지 않아 보내는 진행(onSendProgress)이 오지 않는다 — 진행을 보려면 `FakeServer.readRequestBodies` 를 켠다.
InkWell 에 길게 누르기 처리가 없으면 길게 눌렀다 뗄 때 onTap 이 돈다 — 열지 않을 줄은 길게 누르기를 받아 두고 아무것도 하지 않는다(검색 결과 줄).
두 줄 TextField 에도 Scrollable 이 있다 — 목록 스크롤은 ListView 아래에서 찾는다(`find.descendant`).
- [ ] **린트로 강제되는 항목**: `dynamic` 호출·`print`·`const` 누락·await 누락·작은따옴표·import 정렬 (`analysis_options.yaml`).
- [ ] **env**: `flutter/.env.example` → `.env` 복사 후 `API_URL` 설정 (Android 에뮬레이터는 `10.0.2.2`).
`.env` 는 `pubspec.yaml` 의 asset 이라 **없으면 `flutter analyze` 가 경고**한다.
+2
View File
@@ -4,6 +4,8 @@
*
* ⚠️ frontend `src/test-utils/search-text-samples.ts` 와 **같은 표**다(행과 순서까지 그대로).
* 한쪽을 고치면 둘 다 고친다 — 접기 · 위치 규칙을 바꾸면 두 쪽 함수와 두 표를 함께 (서버가 준 맞은 곳의 위치를 화면이 그대로 그린다).
* 앱 시험(`flutter/test/features/mail/search_text_test.dart`)도 이 파일의 `FOLD_SAMPLES` · `GRAM_SAMPLES` 를 그대로 읽는다(작은따옴표 글의 배열 · 이스케이프 · 주석만 읽는다)
* — 표의 모양을 바꾸면 앱 시험도 본다(이 파일이 바뀌면 Flutter CI 도 돈다).
* 답은 손으로 적었고 두 쪽 테스트가 함수의 결과와 견준다. 단위 테스트는 `@/../test/search-text-samples` 로 부른다 (cpu-time.ts 와 같다).
*/
+36 -2
View File
@@ -64,7 +64,7 @@
- 붙잡힌 메일(보내기 취소 · 예약)은 고를 수 없다 — 누르면 "고를 수 없습니다" 알림, 화면 낭독기는 꺼진 칸의 까닭을 읽는다. 보낼 시각 + 2초에 다시 보고 남아 있으면 5초마다 세 번 더(서버 시각으로 잰다 · 실패해도 이어 본다).
- 알림: [되돌리기]가 있는 알림은 [메일 쓰기] 바로 아래 한 줄(10초 — 화면 낭독기를 켠 동안은 저절로 닫지 않는다), 단추 없는 알림은 전역 스낵바로 띄워 [되돌리기] 줄을 바꿔 끼우지 않는다. [메일 쓰기]는 알림이 뜨면 함께 올라간다.
- 서랍은 메일함을 바꿔 끼운다(방문 기록을 쌓지 않는다 — 다른 메일함은 받은편지함 위에 하나만). 단추로만 연다(가장자리 밀기 없음). 메일함 목록을 받기 전에는 뼈대, 받지 못했으면 [다시 시도].
- 휴지통 · 스팸메일함에 메일이 있으면 맨 위에 30일 줄(웹 13단계 — 보드에는 빈 휴지통만). [찾기]는 아직 "메일 찾기는 아직 준비 중입니다. 지금은 웹메일에서 찾아 주세요."
- 휴지통 · 스팸메일함에 메일이 있으면 맨 위에 30일 줄(웹 13단계 — 보드에는 빈 휴지통만). [찾기]는 검색 화면을 연다(아래 「검색」).
- 새 메일 확인: 화면이 보이고(다른 탭 · 전체 화면에 가리지 않음) 앱이 앞에 있을 때만 1분마다, 다시 보이면 10초가 지났을 때 바로(웹 3단계). 가려진 목록은 바뀜을 적어 두었다가 보일 때 맞춘다.
다시 받은 쪽이 메일함 끝까지면 그 밖의 옛 메일은 다른 곳에서 지운 것으로 보고 남기지 않는다(웹은 남긴다 — 서버의 hasNext 는 끝까지 받았을 때만 거짓이다). 끝 표시는 보이는 동안 다음 쪽을 이어 부르고, 같은 커서가 되풀이되면 [다시 시도]로 멈춘다.
- 크기: 탭 막대의 고른 탭 알약은 Material 3 그대로 64×32(보드 56×32), 글자는 테마 역할로 맞췄다(12.5 → 12 · 13.5 → 13 · 14.5 → 15 · 17 → 18, 굵기 650 → 700 · 450 → 500). 탭 막대는 위 안전 영역을 빼고, 고르는 중 알림 줄은 시스템 내비게이션 위에 뜬다.
@@ -182,8 +182,42 @@
고르는 동안 앱이 정리될 때(개발자 옵션 "활동 유지 안 함"), file:// 를 건네는 옛 파일 관리자, 올리는 중 화면 끄기 · 다른 앱, 느린 망의 20MB(보내기 5분),
실제 multipart(한글 · 괄호 · 콜론 이름이 웹메일에서 그대로 보이는지), `MAIL_004` 뒤 다시 올리기, TalkBack 차례(진행 막대 · ✕ · [다시 시도] · 알림), 큰 글자에서 첨부 머리의 한도 글.
### 검색 (`App1Search` · `App1SearchStates` — 1차, 2026-11-07 확정)
웹 14단계 검색(계약 2절 "검색 — 14단계" · 웹 디자인 README 「14단계」)을 앱에 옮겼다. 조건으로 찾기(바닥 시트)는 2차다. 계획의 정할 것 17개는 모두 첫째 안이다(사용자 2026-11-07 "추천대로").
- **자리**: 목록 위 막대의 [찾기]가 연다. 탭 안 화면 `/mail/search` 라 연 목록 위에 쌓이고 탭 막대가 보인다(자판이 열리면 틀이 숨긴다). 메일 탭을 다시 누르면 닫히고, 다른 탭에 다녀오면 결과가 남는다. 검색어는 주소에 싣지 않는다(시작한 메일함만 라우트 `extra`).
- **위 막대 64**: [←](검색 닫기) · 칸("메일 검색") · 글이 있으면 [✕](칸과 결과를 비우고 칸에 초점). 열면 칸에 초점을 두고 자판을 올린다. 자판의 [검색]으로만 찾고, 빈 칸 · 공백뿐이면 아무 일 없이 자판을 둔다. 찾으면 자판을 내리고, 결과를 끌어도 내린다.
칸은 글 자판 · 자동 고침 끔 · 추천 켬 · 둥근 따옴표와 대시 끔 · 두 줄까지 · 줄바꿈은 띄어쓰기 · UTF-16 200자다. 칸 이름은 낭독에서 한 번만 읽는다(보이는 안내 글은 낭독에서 뺀다).
- **찾을 곳 줄 56**: "찾을 곳" + [시작한 메일함 | 전체 메일함](고른 칸은 옅은 청록 바탕 · 굵은 글, ✓ 없음). 찾기 전부터 보이고, 바꾸면 칸의 글로 바로 다시 찾는다. 전체 메일함이면 줄 아래에 "휴지통과 스팸메일함은 빼고 찾습니다". 휴지통 · 스팸메일함에서 시작하면 그 안에서 찾는다.
- **결과 머리 36**(최소 — 큰 글자면 늘어난다): "‘계약’ · 4통" · 찾는 동안 "· 찾는 중" · 전체 수를 모르면 검색어만. 그 아래 안내 줄은 하나다(검색 준비 중 또는 한 글자 안내 — 함께 뜨지 않고, 준비 상태를 받지 못하면 없다).
- **결과 줄**은 목록 줄 그대로이고 고르지 않는다(사진은 단추가 아니고, 길게 눌러도 아무 일 없다).
- 맞은 말은 노랑 #FCE7A4(`AppColors.searchMark` — 짝: 웹 `--color-search-mark`) 바탕이다. 회색 미리 보기 줄 안의 강조 글자만 `textSecondary`(#4A5363 — 노랑 위 6.3:1)다.
- 강조가 있으면 미리 보기는 서버의 맞은 말 둘레 한 줄이다.
- 전체 메일함이면 시각 앞에 메일함 이름(시각과 같은 글자 · 글 줄 폭의 40%까지)을 두고, 보낸편지함 · 임시보관함은 "받는 사람: …"이다.
- **결과에서 읽기**: 누르면 읽기 화면이다(임시보관함은 쓰기 화면). 머리의 받는 사람 아래에 "메일함 받은편지함" 줄을 둔다. 제목 · 첨부 이름 · 본문의 맞은 말을 칠한다(본문 mark 는 웹과 같다 — 글자색 그대로 · 모서리 2 · 좌우 1px). 지우고 돌아오면 결과 아래에 [되돌리기] 알림이 뜬다.
- **이어 찾기**: 한 차례 5번 · 20통이다. 한 차례가 한 쪽을 못 채우면 저절로 잇지 않고 [더 찾기]로 멈춘다(0통이거나 불러온 결과를 모두 뺐으면 "아직 못 찾음" 화면, 있으면 목록 끝 줄). 한 쪽을 채운 차례만 목록 끝이 다음 차례를 저절로 묻는다.
- **결과 갱신**: 새 메일 확인으로 다시 찾지 않는다. 읽기 화면이 바꾼 표시 · 메일함 · 붙잡힘만 덮고, 제목 · 미리 보기 · 강조는 그 응답의 것 그대로다. 당겨서 새로고침은 다시 찾기다.
- **구현하며 정한 것**(2026-11-07)
- 길게 누르기: InkWell 에 길게 누르기 처리가 없으면 뗄 때 누르기로 읽어 메일이 열린다 — 검색 줄은 GestureDetector 가 받아 두고 아무것도 하지 않는다(진동 없이). 줄의 낭독 동작은 `MessageRow` 바깥 Semantics 에 둔다(목록 줄도 같다).
- 같은 검색어로 다시 [검색] · 당겨서 새로고침은 결과를 둔 채 찾고, 그동안 머리를 "· 찾는 중"으로 둔다. 실패하면 결과를 그대로 두고 알린다.
- 찾을 곳 바꾸기 · [전체 메일함에서 찾기]는 칸이 비었어도 결과가 보이면 그 결과의 검색어로 다시 찾고 자판을 내린다(결과와 찾을 곳이 어긋나지 않게 — 계획은 "범위만 바꾼다"였다). 결과도 없을 때만 곳만 바꿔 둔다.
- 이어 찾기 실패: 하나도 찾기 전의 실패는 받지 못함 화면, 하나라도 찾은 뒤의 실패는 알림 + 끝 줄 [다시 시도], 0통 화면의 [더 찾기] 실패는 알림 + [더 찾기] 그대로. 같은 커서가 새 메일 없이 돌아오면 결과가 있을 때 [다시 시도], 없을 때 [더 찾기].
- [되돌리기]로 돌아온 메일은 늘 받은 시각 차례의 그 자리에 넣고 수도 되돌린다(불러온 끝 줄이어도 — 서버 커서는 그 줄 다음부터 준다). 다음 쪽의 겹침은 지금 목록의 id 로만 막는다.
- 결과의 붙잡힌 메일(보내기 취소 · 예약)은 목록처럼 가장 이른 보낼 시각 + 2초(서버 시계 — 검색 응답의 `Date`)에 그 줄만 다시 받아 표시 · 메일함 · 붙잡힘을 덮는다. 시각이 지난 채 남으면 5초마다 3번 더 보고, 알리지 않으며, 없어졌으면(RES_001) 뺀다.
- 받은편지함 주소에서 연 검색이 메일함 목록을 받기 전이면 받아 본 뒤 기본 메일함에서 찾는다(받지 못하면 받지 못함 화면).
- 강조 위치는 그 위치를 준 응답의 글에만 쓴다(Dart 에 NFC 가 없다). 강조 쿼리는 `searchHighlight` 칸이 있던 결과에서만 싣는다. [이미지 보기]는 같은 쿼리로 본문 · 첨부 · 강조를 함께 바꾼다. 읽기 화면이 강조 응답의 요약(NFC 제목)을 아래 목록에 알리는 것은 받아들였다(글자는 같고 목록이 다시 맞추면 돌아온다).
- 크기: 전환 칸 글 15, "찾을 곳" 글자색 `textSecondary`, 상태 화면 제목 15(보드), 안내 줄 아이콘 16.
- 앱에서 새로 쓴 글: 찾기 전 안내, (1차) "검색어의 낱말은 8개까지 …". 화면 글은 `MailSearchTexts` 한 곳에 있다.
- **알려진 한계**
- 결합 문자가 붙은 라틴 글자(e + U+0301)는 한 글자 판정에서 모으지 않는다(한글 자모만 모은다).
- Dart 의 유니코드 자료 판이 JS 와 달라 소문자 437자 · 글자 분류 4,433자가 다르다(대부분 Unicode 16 의 새 글자). 검색은 한 글자 판정에만 써서 결과에 영향이 없고, 받는 사람 추천은 그 글자들의 대소문자를 웹과 다르게 맞출 수 있다.
- 보낸 사람이 본문에 쓴 `<mark>` 도 같은 노랑이다. 본문의 맞은 말 사이를 옮겨 다니지 못한다.
- **실기기에서 볼 것**: 두 줄 칸 + 글 자판의 [검색] 키(삼성 · Gboard), 한글 조합 중 [검색], 하드웨어 자판 Enter, 여러 줄 붙여 넣기 · 삼성 둥근 따옴표, 결과를 끌어 자판이 내려가는지, 자판이 열릴 때 탭 막대 · [되돌리기] 알림 자리,
큰 글자에서 전환 칸 · 개수 줄 · 결과 줄 메일함 이름, TalkBack 차례 · 개수 줄 읽기 · 칸 이름 한 번 · 찾을 곳 고름 상태 · [더 찾기] 꺼짐, 노랑 대비 · WebView mark, 큰 메일함의 [더 찾기] 흐름, 길게 누르기가 진동 없이 아무 일도 없는지,
보내기 취소를 기다리는 메일의 "보내는 중"이 풀리는지, 앱 로그인 서버(feat/app-auth) + 14단계 서버로 실제 검색 · 강조 · 준비 상태(앱만 쓰는 직원의 색인이 시작되는지).
### 새 메일 알림 (푸시)
- 알림 내용은 "새 메일 N통"만 — 보낸 사람 · 제목이 Google · Apple 서버를 거치지 않게. 누르면 메일 모듈의 받은편지함으로(알림에는 모듈 이름과 id 만 싣는다).
### 처음 판에서 미룬 것
서식 편집 · 대용량 첨부 · 필터 · 주소록 화면 · 서명 고치기 · 계정 설정(웹에서) · 오프라인 저장 · iOS.
서식 편집 · 대용량 첨부 · 필터 · 주소록 화면 · 서명 고치기 · 계정 설정(웹에서) · 오프라인 저장 · iOS. 검색은 조건으로 찾기(2차)와 결과에서 여러 통 고르기를 미뤘다.
+11 -1
View File
@@ -730,7 +730,7 @@ interface MailState {
함께 찾은 한 글자 낱말은 본문에서 맞았어도 강조하지 않는다(본문 곳곳에 흩어진다 — 2026-11-06 실사용 시험). 두 글자 이상 낱말이 보낸 사람 · 받는 사람 조건에만 있어도 섞인 검색이다.
- 본문은 앞 2만 자까지 찾는다. 첨부 파일 안의 글 · 끝이 바뀐 말('보내' → '보냈습니다')은 찾지 않는다(첨부 이름은 찾는다). 받는 사람 칸은 주소들을 이어 보므로 구절이 이웃한 두 주소에 걸쳐 맞을 수 있다(작은 한계).
- **응답 (목록)**
- 항목(MessageSummary)에 `searchHighlight: { subject: [start, end][], preview: { text, ranges: [start, end][] } } | null` — 색인 방식 검색에서만 차고, 메일함 목록 · 엔진 방식은 `null`.
- 항목(MessageSummary)에 `searchHighlight: { subject: [start, end][], preview: { text, ranges: [start, end][] } } | null` — 색인 방식 검색에서만 차고, 메일함 목록 · 엔진 방식은 `null`. 칸은 늘 싣는다(값이 없으면 `null`) — 앱은 이 칸이 있는지로 14단계 서버를 알아보고 읽기 화면에 강조 쿼리를 싣는다.
위치는 NFC 글의 UTF-16 [시작, 끝)이고, 강조가 있으면 `subject` 도 NFC 로 싣는다. `preview.text` 는 본문에서 맞았으면 맞은 말 둘레 한 줄(잘린 쪽에 "…"), 아니면 엔진 미리 보기다.
본문의 맞은 곳은 이 차례로 고른다(2026-11-06 실사용 시험 — 답장은 처음 맞은 곳이 거의 늘 원문 인용 안이었다).
① 인용 줄(앞 공백 뒤 `>`) 밖의 첫 맞은 곳 — 둘레는 인용 줄 전까지.
@@ -762,6 +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` 사전 문구 알림).
[✕]는 검색어와 결과를 지운다(웹의 ✕ 는 검색을 닫는다 — 앱은 [←] · 뒤로 가기가 닫는다).
- 이어 찾기는 웹과 같이 한 차례 5번 · 20통이다. 다만 한 차례가 한 쪽을 못 채우면 저절로 잇지 않고 [더 찾기]로 멈춘다(0통이거나 불러온 결과를 모두 뺐으면 "아직 못 찾음" 화면). 한 쪽을 채운 차례만 목록 끝이 다음 차례를 묻는다.
- 결과는 새 메일 확인으로 다시 찾지 않는다. 앱의 읽기 화면이 바꾼 것과 붙잡힌 메일의 풀림(보낼 시각 + 2초에 그 줄만 `GET /api/messages/{id}` — 강조 쿼리 없이)만 반영하고, 제목 · 미리 보기 · 강조는 그 응답의 것 그대로다.
- 읽기 화면의 강조 쿼리(`q`)는 결과 항목에 `searchHighlight` 칸이 있을 때만(14단계 서버) 싣는다. [이미지 보기]도 같은 쿼리다.
- 강조 위치는 NFC 로 다시 맞추지 않고 그 위치를 준 응답의 글(목록은 그 항목의 제목 · `preview.text`, 읽기 화면은 강조 응답의 제목 · 첨부 이름 · textBody)에만 쓴다(Dart 에 NFC 가 없다).
읽기 화면이 강조 응답의 요약(NFC 제목)을 아래 목록에 알리는 것은 받아들였다 — 글자는 같고 목록이 다시 맞추면 돌아온다.
- `GET /api/search-index` 는 찾을 때마다 알림 없이 묻는다 — 이 요청으로 앱만 쓰는 직원의 색인도 시작된다.
- 새 에러 코드는 없다(`VAL_001` · `RES_001` · `MAIL_001`).
### 대화 — 14단계 (같은 대화 · 대화로 묶어 보기)
@@ -4,6 +4,7 @@
/**
* ⚠️ backend `test/search-text-samples.ts` 와 **같은 표**다(행과 순서까지 그대로).
* 한쪽을 고치면 둘 다 고친다 — 접기 · 위치 규칙을 바꾸면 두 쪽 함수와 두 표를 함께 (서버가 준 맞은 곳의 위치를 화면이 그대로 그린다).
* 앱 시험(`flutter/test/features/mail/search_text_test.dart`)은 backend 표의 `FOLD_SAMPLES` · `GRAM_SAMPLES` 를 그대로 읽는다 — 이 표와 행이 같으니 고칠 때 세 쪽이 함께 본다.
* 답은 손으로 적었고 두 쪽 테스트가 함수의 결과와 견준다.
*/