generated from ttipo/project-base
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 198ac8f4f2 | |||
| b644e8b104 | |||
| bd916ccbed | |||
| 94ec659b78 | |||
| 487760bd9b | |||
| 37ffcfca2c |
@@ -106,7 +106,7 @@ sudo docker compose --env-file .env.production logs -f backend
|
||||
`nginx-api-errors.conf`(이름 있는 오류 location — `@api_not_found` 404 `RES_001` 포함). 공개 화면 `/s/` 는 `no-referrer`(주소에 토큰) · `noindex` · `no-cache`.
|
||||
- 받기 `GET /api/public-links/{token}/files/…` · `GET /api/messages/{id}/large-files/…` 는 SSE 처럼 버퍼링 · 압축을 끄고 `X-Accel-Buffering` 을 넘긴다
|
||||
(기본값이면 받는 사람마다 1GB 까지 임시 파일에 받아 둔다). Range · If-Range 는 그대로 — 206 · 416 은 백엔드가 만든다.
|
||||
**휴대폰 앱(APK) 받기 `GET /api/app-releases/android/latest/file`**(2026-11-08 — 계약 1절 "휴대폰 앱 받기", 포털 · 로그인한 직원, 판마다 30MB 남짓)도 같은 줄들이다 —
|
||||
**휴대폰 앱(APK) 받기 `GET /api/app-releases/android/latest/file`**(2026-11-08 — 계약 1절 "휴대폰 앱 받기", 포털 · 로그인한 직원, 판마다 65MB 안팎 — 기기 종류 셋(ABI)이 한 파일에 든다)도 같은 줄들이다 —
|
||||
머리글을 대용량 받기와 같은 도우미(`backend/src/shared/utils/download-headers.ts` — `X-Accel-Buffering: no`)가 만든다. 정보 `GET /api/app-releases/android/latest`(JSON)는 받기 location 에 들지 않고,
|
||||
공개 내려받기 server 에서는 그 밖의 `/api` 처럼 404 `RES_001` 이다.
|
||||
머리글 버퍼는 `proxy_buffer_size 16k` + `proxy_busy_buffers_size 16k`(둘이 함께 — 하나만이면 `nginx -t` 가 실패) — 한글 255자 이름이면 머리글이 3.5KB 라 기본 4KB 에 아슬아슬하다.
|
||||
@@ -223,7 +223,7 @@ sudo docker compose --env-file .env.production logs -f backend
|
||||
- 업로드 중 임시 디스크: 백엔드는 첨부를 `/tmp/arasystem-mail-uploads`(컨테이너 쓰기 계층)에, nginx 는 요청 본문을 자기 임시 폴더에 받는다.
|
||||
필요한 공간은 동시 업로드 수 × 20MB 정도. backend 를 `read_only` 로 바꾸면 그 경로에 **tmpfs 가 아닌 볼륨**을 붙인다 (tmpfs 는 메모리 한도에 잡힌다).
|
||||
- **업로드 파일은 named volume `<PROJECT_NAME>_uploads` 에 쌓인다** (`/api/files`, 메일 서명 그림은 그 안의 `mail-signatures/` — 서명 표와 함께 백업한다,
|
||||
휴대폰 앱 APK 는 그 안의 `app-releases/` — 판마다 30MB 남짓 · 지우는 명령이 없어 올린 판이 모두 남는다 · 매일 백업에 든다). 호스트 디렉터리(`./data/uploads`)를 쓰지 않는 이유 —
|
||||
휴대폰 앱 APK 는 그 안의 `app-releases/` — 판마다 65MB 안팎 · 지우는 명령이 없어 올린 판이 모두 남는다 · 매일 백업에 든다). 호스트 디렉터리(`./data/uploads`)를 쓰지 않는 이유 —
|
||||
`sudo docker compose` 가 없는 디렉터리를 root 소유로 만들어 backend(node, UID 1000)가 쓰지 못한다. 새 named volume 은 이미지의
|
||||
`/app/uploads`(node 소유) 소유자를 물려받는다 (2026-10-27 운영 이미지로 확인). 개발만 `./data/uploads` 바인드 마운트다.
|
||||
백업은 매일 백업(`ops/` — 아래 "백업 · 감시")에 든다.
|
||||
|
||||
@@ -25,9 +25,9 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S
|
||||
| 앱 로그인 저장소 | `lib/features/auth/data/` | 계약 1절 "앱 로그인"(`/auth/app/login` · `refresh` · `logout`(리프레시 토큰 + Bearer) + Bearer `me`, 인터셉터 줄 밖의 세션 확인 `readSessionDirect`). 실패는 늘 `DioException`(`error` 에 `ApiException` — 응답 모양이 틀리면 `SYS_001`, `apiFailure()`). 모델은 손으로 쓴다(`AppLoginResult` · `AuthSessionInfo` · `SessionRestriction` — 모르는 제한은 `unknown`). 로그인 화면이 칸에 보이는 실패(`kLoginInlineCodes`)는 알리지 않는다 |
|
||||
| **로그인 화면 (네 단계 — `App1Login`)** | `lib/features/auth/presentation/pages/`(login · login_code · password_change · two_factor_setup · restricted_login) · `widgets/`(`login_frame` · `login_fields` · `login_parts`) · 글 `core/constants/login_texts.dart`(제목은 `screen_titles.dart`) · `data/`(`two_factor_repository` · `support_contact_repository` · `authenticator_launcher`) | 노티파이어의 제한 단계 API(`attempt` · `passedCodeStep` · `heldLoginListenable` · `changePassword` · `enableTwoFactor` · `finishRestrictedStep` → `RestrictedStepResult { done, needsPassword, stale }`). 로그인 비밀번호는 두 제한 단계에서만 10분 쥔다(모르는 제한 · 제한이 풀리면 지움). 칸 · 상자에 보이는 코드만 알림을 끈다(`kLoginInlineCodes` · `kLoginCodeInlineCodes` · `kPasswordChangeInlineCodes` · `kTwoFactorSetupInlineCodes` — 앱 디자인 README 의 표), `AUTH_002` 는 스낵바 없이 로그인 화면 상자만. 2단계 키 · otpauth 주소는 그 화면 State 에만. **url_launcher**: `canLaunchUrl` 은 쓰지 않는다(debug 판이 주소째 로그를 남긴다) — `launchUrl(externalNonBrowserApplication)` 만, 실패 예외에 주소가 들어 있어 삼킨다, `<queries>` 는 필요 없다. 한글 줄바꿈은 `shared/widgets/keep_all_text.dart`(시험은 `findCopy`). 보안 설정 요청 한도 `kSecuritySettingTimeout` 30초 |
|
||||
| **메일 목록 (`App1MailList`)** · 앱 틀 | `lib/features/mail/` — data(models · `mail_repository.dart`) · domain(`mailbox_tree` · `mail_list_merge` · `mail_bulk` — 순수 함수) · presentation(`pages/mail_list_page.dart` · providers · widgets), 틀 `core/router/app_shell.dart` · `shell_bar.dart`, `core/utils/`(`app_foreground` · `accessible_navigation` · `korean_format`), `shared/widgets/confirm_dialog.dart`, 시험 도구 `test/support/mail_fakes.dart` · `mail_harness.dart` | **틀은 Scaffold 가 아니다**(Column + 탭 막대 — 탭 안 화면마다 자기 Scaffold 를 둔다, 없으면 스낵바 · Material 조상이 없다). 탭 막대는 `MediaQuery.removePadding(removeTop)` 으로 감싸고, 화면이 숨기려면 `ShellBarHidden`(모듈마다 센다 — 보이는지는 TickerMode). Scaffold 는 넓은 단추 자리에 안전 영역을 넘기지 않으니 Scaffold 밖에서 읽어 넘긴다. 목록 상태는 메일함마다(`mailListProvider(String?)` autoDispose), 세션이 바뀌면 `mailAccountProvider` 로 처음부터. 알림은 두 갈래 — 단추 있는 `mailToastProvider`(한 번에 하나 · 화면 낭독기 켠 동안 닫지 않음) · 단추 없는 `mailNoticeSinkProvider`(전역 스낵바). 생명주기 안에서는 프로바이더를 바꾸지 않는다(그린 뒤 · 마이크로태스크), 다른 노티파이어 인스턴스를 든 노티파이어는 세션이 바뀐 뒤 다시 읽는다. 가짜 서버는 응답이 한 프레임 안에 오므로 "받는 중이 끝나는 순간"을 기다리는 위젯을 만들지 않는다 |
|
||||
| **메일 읽기 (`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` |
|
||||
| **메일 읽기 (`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 다). **보이는 것이 없는 본문이면 본문 칸(WebView)을 두지 않는다**(2026-11-12 — 첨부만 있는 메일에서 아래 절반이 빈 칸으로 남고 첨부가 위 절반에서 잘렸다): 판정은 정화 길이 함께 낸다(`SanitizedMailBody.hasVisibleContent` — 남긴 글 가운데 `core/utils/visible_text.dart` 의 `hasVisibleText`(공백 · 제어 문자 · Default_Ignorable(제로폭 · 방향 표시 · 한글 채움 등) · 점자 빈칸은 보이지 않음)인 글, 주소가 남은 그림(`_attributesOf` 가 src 를 맨 앞에 쓰는 모양에 기댄다 — 외부 그림은 받기 전에도), 글 없이 그려지는 `hr` · `li` · `details` · `q`. 꾸밈(style · bgcolor · border)만은 치지 않는다. 고르는 본문(`displayedHtmlBody`)은 그대로) → 상태 `bodyEmpty` → 머리의 최대 높이가 화면 전체(`kReaderHeadMaxFraction` 대신 — 그림 받는 진행 막대 자리만 뺀다 · 0 아래로 내려가지 않게), 아래 막대가 없는 메일(예약)은 머리 스크롤에 시스템 아래 여백을 더한다. 문서를 만들기 전(`document == null`)은 지금처럼 본문 자리의 진행 줄. 받은 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` |
|
||||
| **메일 쓰기 (`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`. **칸 줄**은 `ComposeFieldRow`(`widgets/compose_recipient_field.dart`) 하나 — 칸 이름 72 · 글자 15 · 간격 6 · 여백 4 4 4 16, 칸 이름 · 오른쪽 단추는 첫 줄(`ComposeFieldRow.lineHeight` 48)의 가운데(받는 사람은 `firstLineHeight` 로 칩 줄 40 을 넘겨 칩이 여러 줄이어도 첫 칩 줄에 맞춘다), 아래 선은 `foregroundDecoration` 이라 초점으로 굵어져도 칸이 움직이지 않는다. 입력칸은 `borderlessInputDecoration`(2026-11-12 — 아래 함정). 본문 칸 최소 높이는 `composeBodyMinHeightOf`(원본 메일이 있으면 150 · 없으면 240 — 서명과 상관없다, 보드 App1Compose) |
|
||||
| **메일 검색 (`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`)과 갱신 · 다시 보내기 · 로그인 없는 앱 설정용(언래핑만 — `publicDioProvider`. 인증 인터셉터는 요청을 한 줄로 세워 앞 요청의 미리 갱신을 기다리게 하므로, 앱을 띄우자마자 묻는 앱 설정은 줄 밖으로 보낸다). 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` 와 같다) |
|
||||
@@ -36,7 +36,7 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S
|
||||
| **인증 인터셉터** | `lib/core/api/auth_interceptor.dart` · `token_refresher.dart` | `QueuedInterceptor`. Bearer 붙이기 · 액세스 토큰이 없거나 30초 안에 끝나면 미리 갱신 · 401 이면 갱신 한 번 → 원래 요청 한 번 다시(로그인 · 갱신 · 로그아웃 제외 — `ApiPaths.noRefresh`). 갱신은 `TokenRefresher` 가 **한 번에 하나**(동시 401 은 토큰 세대를 보고 묶는다). 갱신 401 · 유예 응답(`tokens: null`) → 토큰 지움 + `AUTH_002` + 세션 끝, 5xx · 연결 끊김은 세션을 두고 그 코드로 알림, 갱신 중 로그아웃이면 받은 토큰을 버리고 서버 세션도 끝낸다. 갱신의 일시 장애는 같은 토큰 세대에서 5초 동안 다시 쓴다(`kRefreshFailureReuse` — 연결이 끊긴 동안 줄 선 요청마다 갱신하지 않게). `AUTH_010` 은 세션 노티파이어에 다시 맞춰 달라고 하고(`requestRecheck` — 안전 한도 65초) renewed 면 한 번 다시 보낸다 · restricted · stale 은 알리지 않는다 · unknown 은 알린다(웹 `recheckRestriction` 과 같은 결). 같은 토큰으로 줄 선 `AUTH_010` 은 한 번만 묻는다(그사이 새 토큰이면 묻지 않고 다시 보내고, restricted 는 그 세대 동안 · unknown 은 5초 다시 쓴다). **다시 보낸 요청의 실패도 첫 요청과 같은 규칙**(401 · `AUTH_010` 마다 한 번씩 — `kAuthRetriedExtraKey` · `kRestrictionRetriedExtraKey`). **요청을 보낸 세션이 끝났으면**(세션 세대 `kSessionGenerationExtraKey` 가 다르다 — 로그아웃 · 세션 끝 · 그 뒤 다른 직원 로그인) 어떤 실패든 조용히 끝낸다 — 401 은 사용자가 로그아웃했으면 `AUTH_001` 그대로, 세션이 끝났으면 `AUTH_002`. 실패를 내보내기 전에 Bearer · 비밀 본문을 지운다(`withoutSecrets`). 처리기는 뜻밖의 예외에도 꼭 부른다(`SYS_001`). 이 인터셉터의 오류 처리 안에서 보내는 요청(갱신 · 세션 확인 · 다시 보내기)은 모두 안쪽 Dio — 같은 줄로 보내면 서로 기다린다 |
|
||||
| 오류 알림 | `lib/core/api/error_notifier.dart` | 전역 `ScaffoldMessenger` 키 하나로 스낵바(웹 `showErrorUI` 자리). 같은 코드는 3초 안에 다시 띄우지 않는다. 요청마다 끄기: `Options(extra: {kSilentExtraKey: true})` 또는 코드 목록(`ApiOptions.silentFor`) — `request_flags.dart`. **비밀이 든 요청**(비밀번호 · 코드 · 리프레시 토큰이 본문에)은 `ApiOptions.secret(...)` 으로 보낸다 — 실패 예외의 본문을 비워 던져 화면이 `AsyncValue` 로 상태에 두어도 된다 |
|
||||
| 토큰 보관 | `lib/core/storage/` — `token_store.dart` · `secure_store.dart` · `first_run_marker.dart` | 리프레시 토큰만 `flutter_secure_storage`(키 상수 `kRefreshTokenKey`), 액세스 토큰은 메모리. 새 토큰은 받자마자 저장(로그인 저장이 실패하면 그 세션을 끝내고 `SYS_001`, 갱신 저장이 실패하면 이번 실행은 메모리의 새 토큰으로 이어 가고 다음 읽기 · 저장 때 다시 쓴다). 보안 저장소 일은 부른 차례대로 하나씩(로그인 저장과 로그아웃 지우기가 엇갈리지 않게). 토큰 세대 `generation` · 세션 세대 `sessionGeneration`(지울 때만 오른다), 로그아웃은 `signOut()`(메모리를 곧바로 비우고 서버 logout 에 쓸 토큰을 돌려준다). 만료는 기기 시계로 보지 않는다 — 응답의 `Date`(서버 시각, `core/api/server_time.dart`)로 남은 시간을 재고 받은 때부터 센다. 첫 실행 표시(`shared_preferences` — 참 · 거짓 하나)가 없으면 보안 저장소를 모두 지운다(iOS Keychain 은 앱을 지워도 남는다). Android 는 백업을 껐다(`AndroidManifest` · `res/xml/data_extraction_rules.xml`) |
|
||||
| 테마 | `lib/core/theme/` | 색은 웹 `tokens.css` 를 `ColorScheme` 에 손으로 대응(`app_colors.dart` · `app_theme.dart` — 시드 생성 쓰지 않음, Material 에 없는 색은 `AppExtraColors`). 간격 · 모서리 `AppSpacing` · `AppRadius`, 누르는 곳 최소 48dp(`AppSize.touchMin`). 글자는 `app_typography.dart` 한 곳 — 화면 글 15 · 입력 칸 16 · 위 막대 제목 18 · 메일 본문 15/1.75 는 확정(`docs/design/app/README.md`), 그 밖의 역할은 임시 값 |
|
||||
| 테마 | `lib/core/theme/` | 색은 웹 `tokens.css` 를 `ColorScheme` 에 손으로 대응(`app_colors.dart` · `app_theme.dart` — 시드 생성 쓰지 않음, Material 에 없는 색은 `AppExtraColors`). 간격 · 모서리 `AppSpacing` · `AppRadius`, 누르는 곳 최소 48dp(`AppSize.touchMin`). 글자는 `app_typography.dart` 한 곳 — 화면 글 15 · 입력 칸 16 · 위 막대 제목 18 · 메일 본문 15/1.75 는 확정(`docs/design/app/README.md`), 그 밖의 역할은 임시 값. 테두리 없는 입력칸(칸 줄이 아래 선 · 초점을 그리는 쓰기 화면 · 검색 막대)은 `borderlessInputDecoration` — 테마의 입력칸 테두리를 물려받지 않는다 |
|
||||
| 글꼴 | `assets/fonts/` · `assets/licenses/pretendard-ofl.txt` · `core/theme/app_fonts.dart` | Pretendard 1.3.9 정적 OTF 넷(400 · 500 · 600 · 700 — 웹 npm `pretendard` 와 같은 파일). OFL 1.1 글을 `LicenseRegistry` 에 올린다(설정 › 앱 정보 › 오픈소스 라이선스) |
|
||||
| **새 메일 알림 (`App1Push`)** | `lib/core/push/` — 포트 `push_messaging.dart`(FCM 은 `firebase_push_messaging.dart` — 띄우지 못하면 `UnavailablePushMessaging`) · `notification_system.dart`(채널 · 알림 지우기 · 휴대폰 설정 열기 — 안드로이드 `NewMailNotifications.kt`, 채널 `kr.it.arasystem.app/notifications`) · `push_preferences.dart`(이 휴대폰의 값 — `shared_preferences`, 참 · 거짓과 FID 하나뿐) · `push_state.dart`(상태 · 설정 줄 모습 `pushRowStatusOf` · 권하기 줄 `showsPushOffer`) · `push_controller.dart`(앱에 하나 — `PushRegistrar` 를 구현) · `push_registration.dart`(서버 저장소 · `pushRegistrarProvider`) · `push_events.dart`(앞에서 받음 · 누름 — 라우터 `core/router/push_navigation.dart` 가 누른 알림을 늘 메일 받은편지함으로) · `push_target.dart` · 글 `push_texts.dart`. 화면 `features/mail/presentation/widgets/push_offer_line.dart` · `features/settings/presentation/widgets/settings_notification_row.dart` | **보낼 조건** = 받을 수 있는 휴대폰 ∧ 서버가 보낼 수 있음(app-config `pushAvailable` — 모르면 보낼 수 있는 것으로) ∧ 메일을 쓸 수 있는 세션(세션 노티파이어의 `register` · 제한 단계는 `pause`) ∧ 이 휴대폰에서 켬 ∧ 권한 ∧ 새 메일 채널. 맞추기 `_reconcile` 하나가 PUT · DELETE 를 고른다. **PUT** 은 세션이 열릴 때 한 번 · FID 가 바뀔 때 · 켤 때만(앞으로 올 때마다가 아니다), **DELETE** 는 끌 때와 권한 · 채널이 꺼진 것을 알았을 때만 — 로그아웃 · 세션 끝은 FCM `unregister()` 만(서버 줄은 서버가 세션과 함께 지운다, 풀지 못하면 `pendingUnregister` 로 다음 실행 · 앞으로 옴에 다시). **봤음** `DELETE …/unseen` 은 앞으로 올 때 · 앞에서 받은 새 메일 알림 때, 30초 간격 + 끝 한 번 — 앱이 앞에 있을 때만 보내고, 기다리는 동안 뒤로 가면 그 자리에서 보낸다(끝까지 기다리면 그사이 온 메일까지 본 것으로 센다). 앞에서 받은 알림 뒤의 봤음은 받은 때 + 1.5초(`kPushSeenAfterMessageDelay`)보다 일찍 보내지 않는다 — 서버는 seen_at 의 초부터 같은 초를 세고 받은 시각은 초 단위라, 곧바로 보내면 열지 않은 그 메일이 다음 알림에 다시 세어진다(2026-11-08 실기기). 켜기 · 끄기는 서버가 답한 뒤에 바꾼다(실패는 그 코드의 사전 문구 — 등록 소식이 없으면 `SYS_001`). **켜기가 실패하면 이 휴대폰의 값을 끔으로 둔다**(처음 값 켬에서 허락하고 켰어도 — 스위치는 꺼진 채, 저절로 다시 하지 않는다). 서버 일은 멈춘(`pause`) 바로 그 차례에도 보내지 않는다(`_canReachServer` 가 컨트롤러의 세션 값도 본다). FCM `register` · `unregister` 는 우리가 한 줄로 세운다(플러그인이 스레드 풀에서 돈다), `onRegistered` 는 방송이라 `register` 전에 듣는다. **풀기는 기다리기 전에 등록 세대를 올리고 등록을 내려 둔다** — 풀기가 실패하거나 오래 걸려도 같은 실행의 다음 로그인이 그 뒤에 다시 등록하고(같은 FID 의 PUT 이 서버의 앞 직원 줄을 지운다), 풀기 앞의 등록은 끝나도 마친 것으로 치지 않으며 "풀지 못한 등록" 표시도 지우지 않는다. 풀기가 줄에 있는 동안 온 등록 소식은 쓰지 않는다. 앱이 앞에 있으면 FCM 이 알림을 그리지 않는다 — 앞에서 받은 새 메일 알림은 메일 목록이 바로 새 메일을 확인한다(`mail_state_watcher`). 알림 데이터는 **모듈 이름과 종류**(`{ module: 'mail', kind: 'new-mail' }` · `session-ended`). FID · 주소 · 토큰을 로그 · 디버그 출력에 남기지 않는다. 시험 도구 `test/support/push_fakes.dart`(가짜 FCM · 알림창 · 값 · 서버) · `push_screen_harness.dart`(앱 전체 + 진짜 컨트롤러 — `ProviderHarness.recordPush = false`). 다른 시험대는 기기 등록을 기록하는 가짜(`RecordingPushRegistrar`)이고 컨트롤러는 FCM 이 없어 조용하다 |
|
||||
| **앱 설정 · 업데이트 화면** | `lib/core/app_config/`(`app_config.dart` — 판 비교 `AppVersion` · 모양, `app_config_repository.dart`, `app_config_controller.dart` — `appUpdateRequiredProvider` · `pushAvailableProvider`) · `core/router/session_redirect.dart`(`updateRequired` 가 세션보다 먼저) · `features/app_update/`(모듈이 아니다 — 화면 · 글) · `core/app_download/`([새 버전 받기] — 받기 화면 주소 `app_download_link.dart` · 런처 `AppDownloadLauncher` · 글, 업데이트 화면과 설정 › 앱 정보가 함께 쓴다) | `GET /api/app-config` 는 띄울 때 한 번 · 앞으로 올 때 마지막으로 받은 지 1시간이 지났으면(실패는 1분 간격으로 다시) — 알리지 않고 막지 않는다. 404 `RES_001` 은 옛 서버(최소 판 없음 · 알림 쓸 수 없음). 판은 숫자 1~3칸 · 칸마다 4자리까지(서버의 판 칸 `APP_VERSION_PATTERN` 과 같다 — `-` · `+` 뒤는 버린다), 이 앱의 판을 모르면 막지 않는다. 업데이트 화면으로 옮길 때 쓰기 화면은 마저 저장한다(`ComposeController.detach(saveRemaining: true)` — 화면이 밖에서 닫힌 때도). 앱은 스토어에 올리지 않고 사내에 직접 나눠 준다(2026-11-08 — 빌드 · 서명 · 올리기는 `flutter/README.md` "사내 배포"). [새 버전 받기]는 포털의 휴대폰 앱 받기 화면 `<API_URL 의 출처>/app`(`appDownloadPageUrlOf` — `/api` 를 뗀 출처 + 경로, 짝: 웹 `mobile-app.ts` 의 `MOBILE_APP_PATH`)을 바깥 브라우저로(`LaunchMode.externalApplication` — `canLaunchUrl` 은 쓰지 않는다) 연다. 열지 못하면 스킴 없는 주소를 담아 알린다(`openAppDownloadPage` — 두 화면이 같은 알림). 앱 설정 계약은 받는 곳 주소를 싣지 않는다 |
|
||||
@@ -313,6 +313,10 @@ await tokenStore.clear(byUser: true); // 로그아웃 (세션 끝은 byUser
|
||||
InkWell 에 길게 누르기 처리가 없으면 길게 눌렀다 뗄 때 onTap 이 돈다 — 열지 않을 줄은 길게 누르기를 받아 두고 아무것도 하지 않는다(검색 결과 줄).
|
||||
두 줄 TextField 에도 Scrollable 이 있다 — 목록 스크롤은 ListView 아래에서 찾는다(`find.descendant`).
|
||||
go_router 17 의 `context.pushReplacement` · `replace` 는 void 다(`push` 만 Future) — 바꿔 끼울 화면은 `pushReplacement`(새 page key — 앞 화면의 PageStorage 스크롤 자리를 물려받지 않는다, 업데이트 내역의 다른 글 줄).
|
||||
짜임을 둘로 가르는 화면(읽기 화면의 본문 칸 있음 · 없음)은 두 짜임에 같은 자리의 같은 위젯을 두고 값(`ConstrainedBox` 의 maxHeight)만 바꾼다 — `Flexible` · `Expanded` 로 바꿔 끼우면 그 아래 State(펼친 받는 사람 등)가 새로 만들어진다. 시험은 위치로 본다(머리 스크롤 칸의 아래 끝 = 아래 막대의 위 끝).
|
||||
`InputDecoration.collapsed` 는 테두리 없는 칸이 아니다 — `border` 만 없애고 `enabledBorder` · `focusedBorder` 등을 비워 두면 TextField 가 그 칸을 테마의 `inputDecorationTheme`(둥근 테두리 · 초점은 청록 2)로 채우고, InputDecorator 는 `border` 보다 그 칸들을 먼저 쓴다(2026-11-12 — 쓰기 화면의 칸마다 글자를 감싼 테두리). 칸 줄이 선을 따로 그리는 칸은 `borderlessInputDecoration`(`core/theme/app_theme.dart` — 여섯 테두리 모두 없음 · 여백 0)을 쓴다. `lib/` 에 `InputDecoration.collapsed(` 가 들어오면 시험(`test/core/theme/borderless_input_decoration_test.dart`)이 실패한다. 칸의 테두리는 `test/support/input_borders.dart` 의 `expectBorderlessInput`(평소 · 초점 · 꺼짐마다 InputDecorator 가 받은 꾸밈)으로 본다.
|
||||
화면을 그림으로 견줄 때(임시 시험 — 저장소에 남기지 않는다): `setUpAll` 에서 `FontLoader('Pretendard')` 에 `assets/fonts/Pretendard-*.otf` 넷을, `FontLoader('MaterialIcons')` 에 SDK 의 `bin/cache/artifacts/material_fonts/materialicons-regular.otf` 를 올리고(그러지 않으면 글자가 네모), 맨 위 repaint boundary(RenderView)의 `OffsetLayer.toImage(paintBounds)` 를 `tester.runAsync` 안에서 PNG 로 쓴다. 화면 크기는 `MailAppHarness.pump(size:)`.
|
||||
화면 아래 끝까지 닿는 스크롤(읽기 화면의 빈 본문 + 아래 막대 없는 예약 메일 등)은 `MediaQuery.paddingOf(context).bottom` 을 스크롤 끝 여백으로 더한다 — Scaffold 는 bottomNavigationBar 가 있을 때만 몸통의 아래 padding 을 0 으로 만든다. 시험은 `tester.view.padding = FakeViewPadding(bottom: 48 * 3)`(물리 픽셀 — 시험대는 3배) + `addTearDown(tester.view.resetPadding)`.
|
||||
- [ ] **린트로 강제되는 항목**: `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` 가 경고**한다.
|
||||
|
||||
@@ -82,6 +82,8 @@
|
||||
- 짜임: 머리(제목 · 보낸 사람 · 받는 사람 · 시각 · 안내 줄 · 첨부)는 앱 화면으로 본문 위에 두고 남은 높이의 절반까지만 쓴다(넘으면 머리 안에서 스크롤).
|
||||
본문 WebView 는 나머지를 다 쓰고 그 안에서 스크롤한다 — 스크립트를 꺼서 내용 높이를 잴 수 없다.
|
||||
**첨부 목록은 본문 위**다(보드는 아래) — 본문이 WebView 안에서 스크롤하므로 아래에 두면 늘 화면 아래를 차지한다.
|
||||
**본문에 보이는 것이 없으면 본문 칸을 두지 않고** 머리 전체(안내 줄 · 첨부)를 화면 전체에서 한 스크롤로 보인다 — 빈 본문이라는 글은 따로 두지 않는다(2026-11-12 — 첨부만 있는 메일에서 아래 절반이 빈 칸으로 남고 첨부 목록이 위 절반 안에서 잘렸다).
|
||||
보이는 것은 공백 · 보이지 않는 글자(nbsp · 제로폭 · 방향 표시 · 한글 채움 문자 등)가 아닌 글자, 주소가 남은 그림(외부 그림은 받기 전에도), 가로줄 · 목록 표시 · 펼침 · 따옴표다 — 꾸밈(style · bgcolor · border)만으로 그린 상자 · 선은 치지 않는다. 보일 본문을 고르는 규칙은 그대로다(서식 본문이 비어 보여도 글자 본문으로 바꾸지 않는다 — 웹과 같은 본문).
|
||||
- 본문 글꼴은 기기 글꼴이다(CSP · 파일 접근 끄기로 WebView 가 Pretendard 를 쓰지 못한다). 크기 15 · 줄 간격 1.75 · 여백 16 / 20 / 24 는 보드대로, 기기 글자 크기 설정을 따르고, 앱이 어두운 테마여도 본문 바탕은 흰색이다.
|
||||
- 앱이 한 번 더 정화한다(서버 정화 뒤 — 허용 목록으로 다시 쓴다). 길이 2,000,000 · 중첩 비용 8,000,000 을 넘으면 정화하지 않고 글자 본문과 서버의 생략 안내와 같은 글을 보인다.
|
||||
서식 본문은 6,144자보다 짧고 중첩 비용이 5,000 이하일 때만 화면에서 바로, 그 밖은 다른 isolate 에서 정화한다(개발 PC 에서 바로 하는 것이 2ms 안쪽 — 휴대폰은 몇 배 느리다).
|
||||
@@ -113,6 +115,7 @@
|
||||
- 진행 막대의 화면 낭독 값은 숫자만 된다(Flutter 3.41) — 이름("… 받는 중")만 싣고 "받는 중 64%"는 화면 글로만 보인다.
|
||||
- iOS 를 붙일 때: 파일 열기 채널(지금은 "파일을 열지 못했습니다."), WKWebView 의 링크 길게 누르기 미리 보기 끄기(`setAllowsLinkPreview(false)` — 미리 보기는 판정을 거치지 않고 그 주소를 연다).
|
||||
- 실기기에서 볼 것: 넓은 표 뉴스레터(가로 스크롤 · 확대), 기기 글자 크기, 링크 · 길게 누르기(WebView 안에서 이동하지 않는지), 같은 그림을 여러 번 쓴 뉴스레터, 2MB 본문을 정화하는 시간,
|
||||
첨부만 있는 메일(본문 칸 없이 마지막 첨부까지 한 스크롤 · 그 메일에서 [이미지 보기]를 누르면 본문 칸이 생기는지),
|
||||
첨부(PDF · 오피스 · hwp 가 한컴 뷰어에서 열리는지 · 막는 형식의 알림 · eml 을 어느 앱이 여는지 — 크롬이 MHTML 로 열면 스크립트가 꺼져 있는지), 파일 · content 접근이 꺼졌는지, 폴더 밖 경로는 열지 않는지,
|
||||
TalkBack 읽는 차례(위 막대 → 머리 → 안내 → 첨부 → 본문 → 아래 막대), 뒤로 가기 몸짓, 로그아웃 뒤 받은 파일이 지워지는지.
|
||||
|
||||
@@ -175,11 +178,17 @@
|
||||
- 전달 원본 · 저장본의 첨부와 대용량 파일은 싣고 빼기만 한다. 문제가 있으면 줄 테두리가 빨갛고 줄 아래에 까닭을 적는다(함께 보낼 수 없음 · 찾지 못함 `MAIL_004` · 올리다 멈춤 · 지워짐 · 위험한 파일).
|
||||
- 복사본은 뺄 때 · 화면이 끝날 때 · 세션이 끝날 때 · 앱을 띄울 때 · 메일을 보는 직원이 바뀔 때 지운다.
|
||||
- 글자 크기는 테마 크기로 맞췄다(15.5 → 16 · 14와 14.5 → 15 · 13 → 13 · 12.5 → 12).
|
||||
- 입력칸(받는 사람 · 제목 · 본문)은 테두리 · 바탕 없이 글만 그리고, 선은 칸 줄의 아래 선(초점이면 청록 2)뿐이다 — 보드대로. 검색 막대의 칸도 테두리 없이 글만 그린다.
|
||||
앱 테마의 입력칸 테두리를 물려받지 않게 `borderlessInputDecoration`(`core/theme/app_theme.dart`)을 쓴다(2026-11-12 — `InputDecoration.collapsed` 가 테마의 둥근 테두리를 물려받아 칸마다 글자를 바짝 감싼 테두리가 그려지고, 초점이면 칸 줄의 아래 선과 청록 테두리가 겹쳤다).
|
||||
- 칸 줄(2026-11-12 — 보드에 맞춤): 최소 높이 56 · 여백 위 4 · 오른쪽 4 · 아래 4 · 왼쪽 16, 칸 이름 폭 72 · 글자 15(보드 14) · 칸 이름과 칸 사이 6. 아래 선은 자리를 차지하지 않아 초점으로 굵어져도 칸이 움직이지 않는다.
|
||||
**받는 사람 칩이 여러 줄이면 칸 이름과 [참조 펼치기]는 첫 칩 줄에 맞춘다**(보드는 한 줄만 그렸다 — 줄 전체의 가운데로 내려가지 않게). 칩 ✕ 는 #4A5363, 제목 · 본문의 안내 글은 보드의 placeholder 색 #8A94A3(검색 칸과 같다).
|
||||
본문 칸 최소 높이는 원본 메일이 아래에 있으면(답장 · 전달) 150, 없으면(새 메일 — 서명이 있어도) 240 이다(보드의 「새 메일 · 받는 사람 추천」 240 · 「답장 · 참조 펼침 · 첨부 · 원본 접힘」 150).
|
||||
- **알려진 한계**: 앱이 안 보이게 될 때 칸에 쓰다 만 주소(칩이 되지 않은 글)는 저장에 들지 않고, 나가기 확인 창이 떠 있는 동안은 저장하지 않는다. 앱이 앞에 있는 채로 정리되면 마지막 몇 초를 잃는다.
|
||||
고르는 동안 앱이 정리되면 고른 파일은 버린다(쓰던 내용은 고르는 창을 열 때 저장된다). 고르는 창에 10분 넘게 머물면 그 고르기는 시한으로 끝난다(알림이 창 뒤에서 뜰 수 있다 — 다시 고른다).
|
||||
**알림 · 업데이트 화면으로 내려갈 때**(2026-11-07): 새 메일 알림을 누르거나 업데이트 화면으로 옮겨(다른 화면으로 `go`) 쓰기 화면이 내려가면, 쓰다 만 주소까지 임시보관함에 마저 저장하고
|
||||
"임시보관함에 저장했습니다."(저장하지 못하면 그 코드의 사전 문구)로 알린다. 로그아웃 · 세션 끝 · 제한 단계로 옮겨 내려갈 때는 알리지 않는다(그 세션의 쓰기가 끝났다).
|
||||
- **실기기에서 볼 것**: 삼성 · Gboard 자판의 칩 만들기(쉼표 · [다음] · 한글 조합 중 확정 · 여러 줄 붙여 넣기), 추천 목록이 자판에 가리는지, 예측 뒤로 가기, 홈 · 다른 앱으로 갔을 때 저장, 긴 원문 펼침 스크롤,
|
||||
기기 글자 크기를 키웠을 때 칸 이름(숨은참조 — 폭 72 에서 낱말 가운데 꺾임),
|
||||
서명 그림 폭, TalkBack 차례(칩 · 추천 · 원본 펼침 · 띠), 제스처 내비게이션에서 읽기 화면 알림 자리, 웹 ↔ 앱으로 저장본을 서로 다시 열기, 앱 로그인 서버(feat/app-auth)와 함께 실제 보내기 · 저장 · 서명 그림.
|
||||
첨부(2차): 고르는 창의 출처마다(내 파일 · Google 드라이브 · 사진 · 다운로드) 이름 · 크기 · 확장자를 주는지, hwp · hwpx 가 창에 보이고 올라가는지 · HEIC 는 `FILE_001` 인지,
|
||||
20MB 넘는 파일 · 크기를 알려 주지 않는 클라우드 파일이 한도에서 멈추는지, 합계에 걸리는 큰 파일을 내려받지 않고 바로 알리는지, 받는 동안의 진행 막대, 받다 망이 끊길 때(비행기 모드) 가져오기가 끝나는지, 여러 개 · 남은 자리보다 많이 고르기,
|
||||
|
||||
+9
-9
@@ -3,13 +3,13 @@
|
||||
그룹웨어 전체의 작업 차례다. 단계 번호는 모듈마다 따로 센다 — 메일의 "N단계"는 API 계약([`docs/mail/api-contract.md`](./mail/api-contract.md))과
|
||||
화면 디자인([`docs/design/webmail/README.md`](./design/webmail/README.md))의 "N단계"와 같다. 단계마다 **조사 → 캔버스(화면 보드) → 확정 → 구현 → 배포** 차례로 한다.
|
||||
|
||||
마지막 정리: 2026-11-10
|
||||
마지막 정리: 2026-11-12
|
||||
|
||||
## 지금
|
||||
|
||||
- **운영**: 메일 1~13단계. 2026-11-05 옛 메일 서버(mailcow)에서 전 직원이 옮겨 왔다(메일만 옮겼다 — 주소록 · 달력 · 필터 · 부재중 · 서명은 직원이 새로).
|
||||
- **배포를 기다림**: 메일 14 · 15 · 16단계 · 앱 서버(앱 로그인 · 새 메일 알림) · 휴대폰 앱 받기 — main 에 합쳤고, 전환 뒤 안정화 1~2주를 지나 11월 셋째 주에 운영에 올린다.
|
||||
- **개발 중**: 업데이트 내역 — 웹메일 · 휴대폰 앱의 설정에서 무엇이 새로 생기거나 바뀌었는지 읽는 게시판(글은 저장소의 글 파일, 셋째 주 배포에 함께).
|
||||
- **운영**: 메일 1~16단계 · 휴대폰 앱 첫 판(안드로이드 1.0.0) · 업데이트 내역. 2026-11-05 옛 메일 서버(mailcow)에서 전 직원이 옮겨 왔고(메일만 옮겼다 — 주소록 · 달력 · 필터 · 부재중 · 서명은 직원이 새로),
|
||||
2026-11-12 0시에 14 · 15 · 16단계 · 앱 서버(앱 로그인 · 새 메일 알림 · 휴대폰 앱 받기) · 업데이트 내역을 운영에 올리고 휴대폰 앱을 나눠 주기 시작했다.
|
||||
- **고치는 중**: 배포 뒤 받은 의견 — 앱 읽기 화면(본문 없이 첨부만 있는 메일) · 앱 쓰기 화면 입력칸. 앱 다음 판(1.0.1)으로 나간다.
|
||||
- **다음**: 아래 "추후 후보" 가운데 직원 요청이 실제로 있는 것을 고른다. 그다음은 공통 기반(조직 · 권한).
|
||||
|
||||
## 1부 — 메일
|
||||
@@ -29,9 +29,9 @@
|
||||
| 11 | 대용량 첨부 — 링크로 보내기 · 조각 올리기 · 30일 보관 · 로그인 없는 내려받기 화면 · 악성 검사 | 운영 |
|
||||
| 12 | 계정 보안 — 2단계 인증 · 앱 비밀번호 · 로그인 기록 · 로그인된 기기 | 운영 |
|
||||
| 13 | 전환 준비 — 새 비밀번호 · 2단계 필수 · 메일 프로그램 연결 안내 · 운영 도구 · 백업 · 감시 · 휴지통 · 스팸 30일 | 운영 |
|
||||
| 14 | 자체 검색 색인 · 조건 검색 · 검색어 강조 · 같은 대화 · 대화로 묶어 보기 | main 에 합침 — 배포 기다림 |
|
||||
| 15 | 메일 · 메일함 다루기 — 읽기 화면 [이동] · 끌어다 놓기 · 메일함 옮기기 · 하위까지 지우기 · 차단할 때 받은편지함 정리 · 예약 메일 [지금 보내기] · 같은 대화 [모두 펼치기] | main 에 합침 — 배포 기다림 |
|
||||
| 16 | 개인 설정 — **주소록 가져오기 · 내보내기**(vCard · CSV · 엑셀) · 여러 서명(사내 · 대외 · 영문) · 부재중 응답의 서식 본문과 사내/외부 구분 · 2단계 인증 휴대폰 직접 바꾸기 | main 에 합침 — 배포 기다림 |
|
||||
| 14 | 자체 검색 색인 · 조건 검색 · 검색어 강조 · 같은 대화 · 대화로 묶어 보기 | 운영(2026-11-12) |
|
||||
| 15 | 메일 · 메일함 다루기 — 읽기 화면 [이동] · 끌어다 놓기 · 메일함 옮기기 · 하위까지 지우기 · 차단할 때 받은편지함 정리 · 예약 메일 [지금 보내기] · 같은 대화 [모두 펼치기] | 운영(2026-11-12) |
|
||||
| 16 | 개인 설정 — **주소록 가져오기 · 내보내기**(vCard · CSV · 엑셀) · 여러 서명(사내 · 대외 · 영문) · 부재중 응답의 서식 본문과 사내/외부 구분 · 2단계 인증 휴대폰 직접 바꾸기 | 운영(2026-11-12) |
|
||||
|
||||
- 15단계부터는 직원이 쓰며 바라는 것을 먼저 본다 — 아래 "추후 후보" 가운데 요청이 실제로 있는 것을 골라 다음 단계로 넣는다.
|
||||
- 2026-11-09 계획에서 뺀 것: 수신 확인 · 사내 발송 취소(사내 받는 사람에게만 되고, 흔한 실수는 보내기 취소가 막는다 — 아래 후보로).
|
||||
@@ -48,8 +48,8 @@
|
||||
|
||||
| 판 | 내용 | 상태 |
|
||||
| --- | --- | --- |
|
||||
| 앱 1단계 | 로그인(코드 · 새 비밀번호 · 2단계 켜기) · 메일(목록 · 읽기 · 쓰기 · 첨부 · 검색 · 이동 · 삭제) · 새 메일 알림(안드로이드) | main 에 합침 — 서버 배포 뒤 직원 배포 |
|
||||
| 사내 배포 | 스토어 없이 우리 키로 서명한 APK 를 포털의 휴대폰 앱 받기 화면에서 | main 에 합침 — 서명 키 준비됨 |
|
||||
| 앱 1단계 | 로그인(코드 · 새 비밀번호 · 2단계 켜기) · 메일(목록 · 읽기 · 쓰기 · 첨부 · 검색 · 이동 · 삭제) · 새 메일 알림(안드로이드) | 운영 — 1.0.0(2026-11-12) |
|
||||
| 사내 배포 | 스토어 없이 우리 키로 서명한 APK 를 포털의 휴대폰 앱 받기 화면에서 | 운영 — 1.0.0 을 올림(2026-11-12) |
|
||||
| 다음 판 후보 | 주소록 · 대용량 첨부 · 필터 · 서식 편집 · 예약 메일 [지금 보내기] | 첫 판 반응을 보고 고른다 |
|
||||
|
||||
## 추후 후보 (필요해지면)
|
||||
|
||||
+3
-2
@@ -137,8 +137,9 @@ flutter build apk --release
|
||||
Copy-Item build\app\outputs\flutter-apk\app-release.apk "$env:USERPROFILE\Desktop\ARA-SYSTEM-1.0.1.apk" # 이름은 ARA-SYSTEM-<버전>.apk
|
||||
```
|
||||
|
||||
- 나오는 파일은 `build/app/outputs/flutter-apk/app-release.apk` 하나다(모든 ABI — arm64-v8a · armeabi-v7a · x86_64). 저장소 밖으로 옮기며 이름을 `ARA-SYSTEM-<버전>.apk` 로 바꾼다(받기 화면이 주는 파일 이름과 같은 꼴).
|
||||
- 올리기 전에 본다:
|
||||
- 나오는 파일은 `build/app/outputs/flutter-apk/app-release.apk` 하나다(모든 ABI — arm64-v8a · armeabi-v7a · x86_64 — 그래서 1.0.0 이 64.4MB 다). 저장소 밖으로 옮기며 이름을 `ARA-SYSTEM-<버전>.apk` 로 바꾼다(받기 화면이 주는 파일 이름과 같은 꼴).
|
||||
- 올리기 전에 본다 — `apksigner` 는 Java 로 돈다. PATH 에 `java` 가 없으면 결과 없이 끝나니(2026-11-11 개발 PC) 먼저 Android Studio 의 Java 를 잡는다:
|
||||
`$env:JAVA_HOME = "C:\Program Files\Android\Android Studio\jbr"; $env:Path = "$env:JAVA_HOME\bin;$env:Path"`
|
||||
- 우리 키로 서명했는지 — `& "$env:LOCALAPPDATA\Android\Sdk\build-tools\36.1.0\apksigner.bat" verify --print-certs <파일>` 의 `certificate DN` 이 keytool 에 넣은 이름이다(`CN=Android Debug` 면 debug 키다).
|
||||
- 파일 안의 판이 파일 이름과 같은지 — `& "$env:LOCALAPPDATA\Android\Sdk\build-tools\36.1.0\aapt2.exe" dump badging <파일> | Select-String "^package:"` 의
|
||||
`versionName` 이 파일 이름의 버전, `versionCode` 가 `pubspec.yaml` 의 `+` 뒤 숫자다(예 `versionCode='2' versionName='1.0.1'`).
|
||||
|
||||
@@ -171,10 +171,10 @@ abstract final class AppSize {
|
||||
/// 쓰기 화면의 첨부 줄 파일 아이콘 칸
|
||||
static const double composeFileIconBox = 36;
|
||||
|
||||
/// 본문 칸의 최소 높이 — 새 메일(원본 메일이 아래에 없다)
|
||||
/// 본문 칸의 최소 높이 — 원본 메일이 아래에 없는 쓰기(새 메일 등 — 서명은 있어도)
|
||||
static const double composeBodyMinHeight = 240;
|
||||
|
||||
/// 본문 칸의 최소 높이 — 답장 · 전달(아래에 서명 · 원본 메일)
|
||||
/// 본문 칸의 최소 높이 — 답장 · 전달(아래에 원본 메일)
|
||||
static const double composeBodyMinHeightWithQuote = 150;
|
||||
|
||||
/// 원본 메일 접힌 줄의 최소 높이
|
||||
|
||||
@@ -231,6 +231,34 @@ abstract final class AppTheme {
|
||||
);
|
||||
}
|
||||
|
||||
/// 테두리 · 바탕 · 안쪽 여백이 없는 입력칸 꾸밈 — 칸을 담은 줄이 아래 선 · 초점 표시를 따로 그리는 칸
|
||||
/// (쓰기 화면의 받는 사람 · 제목 · 본문 — `ComposeFieldRow`, 메일 검색 막대).
|
||||
///
|
||||
/// `InputDecoration.collapsed` 는 쓰지 않는다 — collapsed 는 `border` 만 없애고 `enabledBorder` · `focusedBorder` 등을 비워 두는데,
|
||||
/// TextField 가 그 빈 칸을 테마의 입력칸 테두리(위 `inputDecorationTheme` — 둥근 테두리 · 초점은 청록 2)로 채우고, InputDecorator 는
|
||||
/// `border` 보다 이 칸들을 먼저 쓴다. 그래서 여섯 테두리를 모두 [InputBorder.none] 으로 적는다
|
||||
/// (2026-11-12 — 쓰기 화면의 칸마다 글자를 바짝 감싼 둥근 테두리가 그려지고, 초점이 오면 칸 줄의 아래 선과 청록 테두리가 겹쳤다).
|
||||
/// 여백 · 높이는 collapsed 와 같다(안쪽 여백 0 · `isCollapsed`). [hint] 와 [hintText] 는 하나만 준다
|
||||
InputDecoration borderlessInputDecoration({
|
||||
String? hintText,
|
||||
TextStyle? hintStyle,
|
||||
Widget? hint,
|
||||
}) => InputDecoration(
|
||||
hintText: hintText,
|
||||
hintStyle: hintStyle,
|
||||
hint: hint,
|
||||
isCollapsed: true,
|
||||
isDense: false,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
filled: false,
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
disabledBorder: InputBorder.none,
|
||||
errorBorder: InputBorder.none,
|
||||
focusedErrorBorder: InputBorder.none,
|
||||
);
|
||||
|
||||
/// Material 색 역할에 없는 웹 토큰 — `Theme.of(context).extension<AppExtraColors>()!` 로 쓴다
|
||||
@immutable
|
||||
class AppExtraColors extends ThemeExtension<AppExtraColors> {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/// 글에 보이는 글자가 있는가 — 순수 함수 (모듈 공통 — 메일 본문이 비었는지 등).
|
||||
///
|
||||
/// 아래 글자만 있는 글은 보이는 것이 없다(빈 글이다):
|
||||
/// - 공백: Dart `trim()` 이 지우는 공백 전부 — 빈칸 · 탭 · 줄바꿈 · nbsp · 전각 빈칸 · 줄 · 문단 구분 · BOM 등
|
||||
/// - 제어 문자: C0 · DEL · C1
|
||||
/// - 보이지 않는 글자: 유니코드의 Default_Ignorable_Code_Point — 제로폭 빈칸 · 이음(ZWNJ · ZWJ · WORD JOINER) · 방향 표시(LRM · RLM · 끼워 넣기 · 격리) ·
|
||||
/// soft hyphen · 이체 선택자 · 한글 채움 문자(U+115F · U+1160 · U+3164 · U+FFA0) · 태그 글자 등
|
||||
/// - 점자 빈칸(U+2800) — 글자이지만 빈칸으로 그려진다
|
||||
library;
|
||||
|
||||
/// [text] 에 보이는 글자가 하나라도 있는가 — 앞에서부터 보고 처음 보이는 글자에서 멈춘다(길이에 비례)
|
||||
bool hasVisibleText(String text) {
|
||||
for (final int rune in text.runes) {
|
||||
if (!_isInvisible(rune)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 보이지 않는 글자인가 — 위 머리말의 목록
|
||||
bool _isInvisible(int rune) {
|
||||
// C0 제어 문자 · 빈칸
|
||||
if (rune <= 0x20) return true;
|
||||
// 그 밖의 ASCII 는 모두 보인다
|
||||
if (rune < 0x7F) return false;
|
||||
// DEL · C1 제어 문자(NEL 포함) · nbsp
|
||||
if (rune <= 0xA0) return true;
|
||||
for (final (int first, int last) in _invisibleRanges) {
|
||||
if (rune < first) return false;
|
||||
if (rune <= last) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 보이지 않는 글자의 범위 — (처음, 끝) 끝 포함 · 작은 차례대로(U+00A0 까지는 위에서 따로 본다)
|
||||
const List<(int, int)> _invisibleRanges = <(int, int)>[
|
||||
(0x00AD, 0x00AD), // soft hyphen
|
||||
(0x034F, 0x034F), // 글자 묶음 이음(CGJ)
|
||||
(0x061C, 0x061C), // 아랍 글자 방향 표시
|
||||
(0x115F, 0x1160), // 한글 초성 · 중성 채움 문자
|
||||
(0x1680, 0x1680), // 오검 빈칸
|
||||
(0x17B4, 0x17B5), // 크메르의 소리 없는 모음
|
||||
(0x180B, 0x180F), // 몽골 이체 선택자 · 모음 구분
|
||||
(0x2000, 0x200F), // 너비가 다른 빈칸 · 제로폭 빈칸 · 이음 · 방향 표시
|
||||
(0x2028, 0x202F), // 줄 · 문단 구분 · 방향 끼워 넣기 · 좁은 nbsp
|
||||
(0x205F, 0x206F), // 수학 빈칸 · WORD JOINER · 보이지 않는 연산자 · 방향 격리
|
||||
(0x2800, 0x2800), // 점자 빈칸
|
||||
(0x3000, 0x3000), // 전각 빈칸
|
||||
(0x3164, 0x3164), // 한글 채움 문자
|
||||
(0xFE00, 0xFE0F), // 이체 선택자
|
||||
(0xFEFF, 0xFEFF), // BOM(제로폭 nbsp)
|
||||
(0xFFA0, 0xFFA0), // 반각 한글 채움 문자
|
||||
(0xFFF0, 0xFFF8), // 보이지 않게 정해 둔 빈 자리
|
||||
(0x1BCA0, 0x1BCA3), // 속기 서식 제어
|
||||
(0x1D173, 0x1D17A), // 악보 서식 제어
|
||||
(0xE0000, 0xE0FFF), // 태그 글자 · 이체 선택자 보충
|
||||
];
|
||||
@@ -11,8 +11,11 @@
|
||||
///
|
||||
/// 외부 이미지를 보일 때(서버가 중계 주소 `/api/mail-images/<표>` 로 바꿔 넣은 본문 — imagesShown)도 WebView 는 바깥에 나가지 않는다 —
|
||||
/// 앱이 Dio(Bearer)로 그림을 받아 `data:` 로 바꿔 넣는다([resolveProxiedImages]). 토큰이 WebView 에 가지 않는다.
|
||||
///
|
||||
/// 정화하며 본문에 보이는 것이 있는지도 함께 본다([SanitizedMailBody.hasVisibleContent]) — 없으면 읽기 화면이 본문 칸을 그리지 않는다.
|
||||
library;
|
||||
|
||||
import 'package:arasystem_app/core/utils/visible_text.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/mail_images.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/search_highlight.dart';
|
||||
import 'package:html/dom.dart' as dom;
|
||||
@@ -67,6 +70,11 @@ const Set<String> _droppedWithContent = <String>{
|
||||
/// 내용이 없는 태그 — 닫는 태그를 쓰지 않는다
|
||||
const Set<String> _voidTags = <String>{'br', 'col', 'hr', 'img', 'wbr'};
|
||||
|
||||
/// 남기는 태그 가운데 글 없이도 그려지는 것 — 가로줄 · 목록 표시(• · 1.) · 펼침 표시(▶ 와 기본 이름) · 따옴표.
|
||||
/// 그림(`img`)은 주소가 남았을 때만 보인다(주소를 지운 그림은 문서가 숨긴다 — [buildMailDocument]) — 따로 본다.
|
||||
/// 모양 속성(style · bgcolor · border)만으로 그린 상자 · 선 · 바탕과 빈 인용의 왼쪽 막대는 보이는 것으로 치지 않는다 — 글도 그림도 없는 꾸밈뿐이다
|
||||
const Set<String> _drawnWithoutText = <String>{'hr', 'li', 'details', 'q'};
|
||||
|
||||
/// 어느 태그에나 남기는 속성 — 모양(style) · 표 · 글꼴의 옛 속성. 이벤트 처리기 · data-* · id · target · 외부 자원 속성은 없다
|
||||
const Set<String> _globalAttributes = <String>{
|
||||
'style', 'class', 'title', 'dir', 'lang', 'align', 'valign', 'width', //
|
||||
@@ -109,6 +117,7 @@ class SanitizedMailBody {
|
||||
const SanitizedMailBody({
|
||||
required this.fragment,
|
||||
required this.proxiedTokens,
|
||||
required this.hasVisibleContent,
|
||||
this.htmlOmitted = false,
|
||||
});
|
||||
|
||||
@@ -118,6 +127,12 @@ class SanitizedMailBody {
|
||||
/// 받아 넣을 중계 그림의 표 — 문서 차례 · 겹치지 않게 · [kMaxProxiedImages] 까지(외부 이미지를 보일 때만)
|
||||
final List<String> proxiedTokens;
|
||||
|
||||
/// 본문에 보이는 것이 있다 — 공백 · 보이지 않는 글자가 아닌 글자([hasVisibleText]), 주소가 남은 그림, 글 없이도 그려지는 태그
|
||||
/// (가로줄 · 목록 표시 · 펼침 표시 · 따옴표). 거짓이면 읽기 화면이 본문 칸(WebView)을 그리지 않는다.
|
||||
///
|
||||
/// 중계 그림은 받기 전에도 보이는 것으로 친다 — 하나도 받지 못하면 빈 본문 칸이 남는다(본문을 다시 보지 않는다)
|
||||
final bool hasVisibleContent;
|
||||
|
||||
/// 서식 본문이 앱의 정화 한도(길이 · 중첩)를 넘어 글자 본문을 보인다 — 화면은 서버의 생략과 같은 안내를 보인다
|
||||
final bool htmlOmitted;
|
||||
}
|
||||
@@ -130,7 +145,10 @@ String? displayedHtmlBody(String? htmlBody) =>
|
||||
/// [imagesShown] 이면 서버의 중계 주소 그림을 남긴다(그 밖의 바깥 주소 · 상대 경로 그림은 어느 때나 지운다).
|
||||
/// 서식 본문이 길이 · 중첩 한도([kMaxSanitizedHtmlLength] · [kMaxHtmlNestingCost])를 넘으면 정화하지 않고 글자 본문을 보인다.
|
||||
/// [textHighlights] 는 글 본문의 검색에서 맞은 곳(검색 결과에서 연 메일 — 14단계) — 글 본문을 보일 때만 `<mark>` 로 넣는다
|
||||
/// (서식 본문은 서버가 정화한 뒤 맞은 글 조각에 `<mark>` 를 넣어 보냈고, 앱의 정화가 그대로 남긴다)
|
||||
/// (서식 본문은 서버가 정화한 뒤 맞은 글 조각에 `<mark>` 를 넣어 보냈고, 앱의 정화가 그대로 남긴다).
|
||||
///
|
||||
/// 고른 본문에 보이는 것이 있는지([SanitizedMailBody.hasVisibleContent])도 함께 낸다 — 고르는 규칙은 그대로다(서식 본문이 비어 보여도
|
||||
/// 글자 본문으로 바꾸지 않는다 — 웹 `displayedHtmlBody` 와 같은 본문을 보인다)
|
||||
SanitizedMailBody mailBodyFragment({
|
||||
required String? htmlBody,
|
||||
required String? textBody,
|
||||
@@ -162,6 +180,7 @@ SanitizedMailBody _textFragment(
|
||||
fragment:
|
||||
'<div class="mail-text">${highlightedTextHtml(textBody ?? '', highlights)}</div>',
|
||||
proxiedTokens: const <String>[],
|
||||
hasVisibleContent: hasVisibleText(textBody ?? ''),
|
||||
htmlOmitted: htmlOmitted,
|
||||
);
|
||||
|
||||
@@ -316,15 +335,22 @@ const Set<String> _htmlVoidTags = <String>{
|
||||
/// 허용 목록 밖의 태그는 태그만 지우고 글은 남긴다(`<o:p>` · `<form>` · `<button>` 등), [_droppedWithContent] 는 내용째 버린다.
|
||||
/// HTML 이 아닌 이름공간(SVG · MathML)은 내용째 버린다. 주석은 지운다. 깊은 중첩에도 스택이 넘치지 않게 되풀이로 돈다.
|
||||
/// 문서 전체로 읽어 `<body>` 아래만 쓴다 — 조각으로 읽으면(`parseFragment`) 맨 위 요소가 많을 때 파서가 옮기며 제곱으로 느려진다
|
||||
/// (2026-11-06 측정: 맨 위 span 4만 개 10초 → 문서로 0.3초). 머리에 들어가는 태그(meta · title · style 등)는 어차피 버린다
|
||||
/// (2026-11-06 측정: 맨 위 span 4만 개 10초 → 문서로 0.3초). 머리에 들어가는 태그(meta · title · style 등)는 어차피 버린다.
|
||||
/// 쓰는 동안 보이는 것이 있는지([SanitizedMailBody.hasVisibleContent])도 본다 — 남긴 글 · 주소가 남은 그림 · [_drawnWithoutText] 만
|
||||
/// (내용째 버린 것 · 주석은 보이지 않는다). 처음 찾으면 더 보지 않는다
|
||||
SanitizedMailBody sanitizeMailHtml(String html, {required bool imagesShown}) {
|
||||
final dom.Element? body = html_parser.parse(html).body;
|
||||
final StringBuffer out = StringBuffer();
|
||||
final List<String> tokens = <String>[];
|
||||
final Set<String> seen = <String>{};
|
||||
if (body == null) {
|
||||
return const SanitizedMailBody(fragment: '', proxiedTokens: <String>[]);
|
||||
return const SanitizedMailBody(
|
||||
fragment: '',
|
||||
proxiedTokens: <String>[],
|
||||
hasVisibleContent: false,
|
||||
);
|
||||
}
|
||||
bool visible = false;
|
||||
|
||||
// 되풀이 깊이 우선 — 닫는 태그는 글로 쌓는다
|
||||
final List<Object> stack = <Object>[..._reversed(body.nodes)];
|
||||
@@ -337,6 +363,7 @@ SanitizedMailBody sanitizeMailHtml(String html, {required bool imagesShown}) {
|
||||
final dom.Node node = item as dom.Node;
|
||||
if (node is dom.Text) {
|
||||
out.write(escapeHtmlText(node.data));
|
||||
if (!visible) visible = hasVisibleText(node.data);
|
||||
continue;
|
||||
}
|
||||
if (node is! dom.Element) continue;
|
||||
@@ -348,24 +375,29 @@ SanitizedMailBody sanitizeMailHtml(String html, {required bool imagesShown}) {
|
||||
stack.addAll(_reversed(node.nodes));
|
||||
continue;
|
||||
}
|
||||
final String attributes = _attributesOf(
|
||||
node,
|
||||
name,
|
||||
imagesShown: imagesShown,
|
||||
onProxied: (String token) {
|
||||
if (seen.contains(token)) return true;
|
||||
if (tokens.length >= kMaxProxiedImages) return false;
|
||||
seen.add(token);
|
||||
tokens.add(token);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
out
|
||||
..write('<')
|
||||
..write(name)
|
||||
..write(
|
||||
_attributesOf(
|
||||
node,
|
||||
name,
|
||||
imagesShown: imagesShown,
|
||||
onProxied: (String token) {
|
||||
if (seen.contains(token)) return true;
|
||||
if (tokens.length >= kMaxProxiedImages) return false;
|
||||
seen.add(token);
|
||||
tokens.add(token);
|
||||
return true;
|
||||
},
|
||||
),
|
||||
)
|
||||
..write(attributes)
|
||||
..write('>');
|
||||
// 그림은 주소가 남았을 때만 보인다 — 남긴 주소는 늘 맨 앞에 쓴다([_attributesOf])
|
||||
if (!visible &&
|
||||
(_drawnWithoutText.contains(name) ||
|
||||
(name == 'img' && attributes.startsWith(' src=')))) {
|
||||
visible = true;
|
||||
}
|
||||
if (_voidTags.contains(name)) continue;
|
||||
stack
|
||||
..add('</$name>')
|
||||
@@ -374,6 +406,7 @@ SanitizedMailBody sanitizeMailHtml(String html, {required bool imagesShown}) {
|
||||
return SanitizedMailBody(
|
||||
fragment: out.toString(),
|
||||
proxiedTokens: List<String>.unmodifiable(tokens),
|
||||
hasVisibleContent: visible,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -381,7 +414,7 @@ const String _htmlNamespace = 'http://www.w3.org/1999/xhtml';
|
||||
|
||||
Iterable<dom.Node> _reversed(List<dom.Node> nodes) => nodes.reversed;
|
||||
|
||||
/// 남길 속성을 ` 이름="값"` 꼴로 — 그림은 `src` 를, 링크는 `href` 를 맨 앞에 쓴다.
|
||||
/// 남길 속성을 ` 이름="값"` 꼴로 — 그림은 `src` 를, 링크는 `href` 를 맨 앞에 쓴다(보이는 그림인지 가르기 · [resolveProxiedImages] 가 이 모양에 기댄다).
|
||||
/// [onProxied] 는 남길 중계 그림이면 참(한 통의 한도를 넘으면 거짓 — 그 그림은 주소를 지운다)
|
||||
String _attributesOf(
|
||||
dom.Element element,
|
||||
|
||||
@@ -489,9 +489,7 @@ class _MessageComposePageState extends ConsumerState<MessageComposePage> {
|
||||
focusNode: _bodyFocus,
|
||||
enabled: enabled,
|
||||
error: lengthError == ComposeLengthError.body,
|
||||
minHeight: quote != null || signature != null
|
||||
? AppSize.composeBodyMinHeightWithQuote
|
||||
: AppSize.composeBodyMinHeight,
|
||||
minHeight: composeBodyMinHeightOf(hasQuote: quote != null),
|
||||
),
|
||||
if (lengthError == ComposeLengthError.body)
|
||||
ComposeAlert(
|
||||
@@ -556,6 +554,12 @@ class _MessageComposePageState extends ConsumerState<MessageComposePage> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 본문 칸의 최소 높이 — 원본 메일(인용)이 아래에 있으면(답장 · 전달) 낮게(150), 없으면(새 메일 등) 서명이 있어도 높게(240).
|
||||
/// 보드 `App1Compose` 의 「새 메일 · 받는 사람 추천」(서명 있음 — 240)과 「답장 · 참조 펼침 · 첨부 · 원본 접힘」(서명 · 원본 메일 — 150)
|
||||
double composeBodyMinHeightOf({required bool hasQuote}) => hasQuote
|
||||
? AppSize.composeBodyMinHeightWithQuote
|
||||
: AppSize.composeBodyMinHeight;
|
||||
|
||||
/// 올리지 못한 고른 파일의 수
|
||||
int failedUploadsOf(ComposeState state) => state.attachments
|
||||
.where(
|
||||
@@ -627,7 +631,6 @@ class _SubjectRow extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
|
||||
final TextStyle? style = theme.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: AppTypography.semiBold,
|
||||
);
|
||||
@@ -653,11 +656,13 @@ class _SubjectRow extends StatelessWidget {
|
||||
Utf16LengthLimitFormatter(kComposeMaxSubject),
|
||||
],
|
||||
style: style,
|
||||
decoration: InputDecoration.collapsed(
|
||||
// 테두리는 칸 줄(ComposeFieldRow)의 아래 선뿐이다 — 테마의 입력칸 테두리를 물려받지 않게
|
||||
// 안내 글은 보드의 placeholder 색(#8A94A3 — 테마의 outline, 검색 칸 안내 글과 같다)
|
||||
decoration: borderlessInputDecoration(
|
||||
hintText: MailComposeTexts.subjectHint,
|
||||
hintStyle: style?.copyWith(
|
||||
fontWeight: AppTypography.regular,
|
||||
color: extra.textMuted,
|
||||
color: theme.colorScheme.outline,
|
||||
),
|
||||
),
|
||||
onSubmitted: (String _) => onSubmitted(),
|
||||
@@ -669,7 +674,7 @@ class _SubjectRow extends StatelessWidget {
|
||||
}
|
||||
|
||||
/// 본문 칸 — 글만(서식 편집은 다음 판). 비어 있는 아래쪽을 눌러도 칸에 초점이 간다.
|
||||
/// 아래에 서명 · 원본 메일이 있으면 낮게(150), 없으면 높게(240) 둔다(보드)
|
||||
/// 아래에 원본 메일이 있으면 낮게(150), 없으면 서명이 있어도 높게(240) 둔다(보드 — [composeBodyMinHeightOf])
|
||||
class _BodyField extends StatelessWidget {
|
||||
const _BodyField({
|
||||
required this.controller,
|
||||
@@ -718,9 +723,9 @@ class _BodyField extends StatelessWidget {
|
||||
textInputAction: TextInputAction.newline,
|
||||
maxLines: null,
|
||||
style: style,
|
||||
decoration: InputDecoration.collapsed(
|
||||
decoration: borderlessInputDecoration(
|
||||
hintText: MailComposeTexts.bodyHint,
|
||||
hintStyle: style?.copyWith(color: extra.textMuted),
|
||||
hintStyle: style?.copyWith(color: theme.colorScheme.outline),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:arasystem_app/core/api/api_exception.dart';
|
||||
import 'package:arasystem_app/core/api/dio_provider.dart';
|
||||
@@ -33,7 +34,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
/// 머리(제목 · 보낸 사람 · 안내 줄 · 첨부)가 차지할 수 있는 최대 높이 — 남은 자리의 이만큼. 넘으면 머리 안에서 스크롤한다
|
||||
/// 머리(제목 · 보낸 사람 · 안내 줄 · 첨부)가 차지할 수 있는 최대 높이 — 남은 자리의 이만큼. 넘으면 머리 안에서 스크롤한다.
|
||||
/// 본문에 보이는 것이 없으면(본문 칸이 없다) 이 제한 없이 화면 전체를 쓴다
|
||||
const double kReaderHeadMaxFraction = 0.5;
|
||||
|
||||
/// 메일 읽기 — 앱 보드 `App1Reader` (앱 디자인 README 「읽기」). 탭 막대 없는 전체 화면이다.
|
||||
@@ -42,6 +44,8 @@ const double kReaderHeadMaxFraction = 0.5;
|
||||
/// 붙잡힌 메일(보내기 취소를 기다림 · 예약)에는 이동 · 스팸이 없고 [삭제]는 확인 뒤 발송을 취소하고 휴지통으로.
|
||||
/// - 머리는 앱 화면, 본문은 WebView(`MailBodyView`) — 머리 아래 남은 자리를 다 쓰고 본문은 그 안에서 스크롤한다.
|
||||
/// 머리가 길면(펼친 받는 사람 · 첨부 여럿) 남은 자리의 절반까지만 쓰고 머리 안에서 스크롤한다.
|
||||
/// **본문에 보이는 것이 없으면**(첨부만 있는 메일 등 — 상태의 `bodyEmpty`) 본문 칸을 두지 않고 머리가 화면 전체를 한 스크롤로 쓴다
|
||||
/// (2026-11-12 — 아래 절반이 빈 칸으로 남고 첨부 목록이 위 절반 안에서 잘렸다).
|
||||
/// - **첨부 목록은 머리 안(안내 줄 아래 · 본문 위)이다** — 보드는 본문 아래지만, 본문이 WebView 안에서 스크롤하므로 아래에 두면
|
||||
/// 늘 화면 아래 자리를 차지한다(쓰기 화면처럼 위에 둔다 — 앱에서 정함).
|
||||
/// - 아래 행동 막대: 답장 · 전체 답장 · 전달 — 쓰기 화면으로 그 메일 id 를 넘긴다(`MailRoutes.composeReply` 등). 예약한 메일에는 없다(웹과 같다).
|
||||
@@ -349,7 +353,8 @@ List<ReaderMenuItem> readerMenuOf(
|
||||
];
|
||||
}
|
||||
|
||||
/// 받은 메일 — 머리(남은 자리의 절반까지 · 넘으면 그 안에서 스크롤) + 본문 WebView(남은 자리 전부)
|
||||
/// 받은 메일 — 머리(남은 자리의 절반까지 · 넘으면 그 안에서 스크롤) + 본문 WebView(남은 자리 전부).
|
||||
/// 본문에 보이는 것이 없으면 본문 칸 없이 머리만(화면 전체까지 · 넘으면 그 안에서 스크롤)
|
||||
class _ReaderContent extends ConsumerWidget {
|
||||
const _ReaderContent({
|
||||
required this.messageId,
|
||||
@@ -383,16 +388,31 @@ class _ReaderContent extends ConsumerWidget {
|
||||
final String? myEmail = ref.watch(mailAccountProvider);
|
||||
final List<MessageAttachment> attachments = detail.listedAttachments;
|
||||
final String? document = reader.document;
|
||||
// 본문 칸 — 보이는 것이 없는 본문이면 두지 않는다. 문서를 만드는 동안(document 가 null)은 아직 모르므로 지금처럼 진행 줄을 둔다
|
||||
final bool bodyShown = !reader.bodyEmpty;
|
||||
return LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
// 본문 칸이 없으면 머리가 화면 전체를 쓴다 — 그림 받는 진행 막대가 머리 아래에 들 자리만 남긴다
|
||||
final double headMaxHeight = bodyShown
|
||||
? constraints.maxHeight * kReaderHeadMaxFraction
|
||||
: math.max(
|
||||
0,
|
||||
constraints.maxHeight -
|
||||
(reader.imagesLoading ? AppSize.progressBarHeight : 0),
|
||||
);
|
||||
// 머리가 화면 아래 끝까지 닿으면(본문 칸이 없다) 시스템 내비게이션 막대 몫을 머리 끝에 더한다 — 마지막 첨부가 그 밑에 남지 않게.
|
||||
// 아래 행동 막대가 있으면 Scaffold 가 이 값을 이미 0 으로 만든다(예약 메일처럼 막대가 없을 때만 남는다)
|
||||
final double headBottomInset = bodyShown
|
||||
? 0
|
||||
: MediaQuery.paddingOf(context).bottom;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
// 머리는 두 짜임에서 같은 자리의 같은 위젯이다 — 빈 본문에서 [이미지 보기]로 본문 칸이 생겨도 펼친 받는 사람이 그대로 남는다
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: constraints.maxHeight * kReaderHeadMaxFraction,
|
||||
),
|
||||
constraints: BoxConstraints(maxHeight: headMaxHeight),
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.only(bottom: headBottomInset),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
@@ -438,14 +458,15 @@ class _ReaderContent extends ConsumerWidget {
|
||||
minHeight: AppSize.progressBarHeight,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: document == null
|
||||
? const _BodyLoading()
|
||||
: MailBodyView(
|
||||
document: document,
|
||||
onNavigation: onNavigation,
|
||||
),
|
||||
),
|
||||
if (bodyShown)
|
||||
Expanded(
|
||||
child: document == null
|
||||
? const _BodyLoading()
|
||||
: MailBodyView(
|
||||
document: document,
|
||||
onNavigation: onNavigation,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -101,6 +101,7 @@ messageReaderProvider = NotifierProvider.autoDispose
|
||||
///
|
||||
/// - 열면 안 읽은 메일을 읽음으로 바꾼다(화면 · 메일함 개수를 먼저 — 실패하면 알리지 않고 되돌린다).
|
||||
/// - 본문은 두 번째 정화 뒤 CSP 를 건 문서로 만든다(`mail_html.dart` — 짧고 얕은 본문만 그 자리에서, 길거나 깊은 본문은 다른 isolate 에서).
|
||||
/// 정화가 함께 낸 "보이는 것이 없다"([MessageReaderState.bodyEmpty] — 첨부만 있는 메일 등)도 같은 차례에 싣는다 — 화면은 본문 칸을 두지 않는다.
|
||||
/// 외부 이미지를 보이는 본문이면(설정 · [이미지 보기]) 중계 그림을 Dio 로 받아(한 번에 [kMailImageConcurrency] 개 · 한도 `mail_images.dart`)
|
||||
/// `data:` 로 넣고 문서를 한 번 더 만든다. 그림 받기는 화면이 사라지거나 본문이 바뀌면 끊는다(끊긴 그림은 숨기고 알리지 않는다).
|
||||
/// - 옮기기 · 스팸 · 휴지통은 응답을 받은 뒤 목록으로 돌아가고(부르는 쪽이), 알림에 [되돌리기](원래 메일함 — 스팸은 표시도 반대로).
|
||||
@@ -279,6 +280,8 @@ class MessageReaderController extends Notifier<MessageReaderState> {
|
||||
state = state.copyWith(
|
||||
document: _document(),
|
||||
bodyOmitted: body.htmlOmitted,
|
||||
// 보이는 것이 없는 본문이면 화면이 본문 칸을 두지 않는다 — 판정은 정화가 함께 냈다
|
||||
bodyEmpty: !body.hasVisibleContent,
|
||||
imagesLoading: hasImages,
|
||||
);
|
||||
if (!hasImages) return;
|
||||
|
||||
@@ -38,6 +38,7 @@ class MessageReaderState {
|
||||
this.error,
|
||||
this.document,
|
||||
this.bodyOmitted = false,
|
||||
this.bodyEmpty = false,
|
||||
this.imagesLoading = false,
|
||||
this.busy,
|
||||
this.subjectHighlight,
|
||||
@@ -52,6 +53,10 @@ class MessageReaderState {
|
||||
/// WebView 에 넣을 문서(정화 · CSP 를 마쳤다) — 만들기 전이면 null
|
||||
final String? document;
|
||||
|
||||
/// 보여 줄 본문에 보이는 것이 없다(첨부만 있는 메일 등 — 정화가 함께 낸 판정 `SanitizedMailBody.hasVisibleContent`).
|
||||
/// 화면은 본문 칸(WebView)을 두지 않고 머리(제목 · 안내 줄 · 첨부)를 화면 전체에서 한 스크롤로 보인다. 문서를 만들기 전에는 거짓
|
||||
final bool bodyEmpty;
|
||||
|
||||
/// 서식 본문이 앱의 정화 한도를 넘어 글자 본문을 보인다 — 서버의 생략(`isHtmlBodyOmitted`)과 같은 안내를 보인다
|
||||
final bool bodyOmitted;
|
||||
|
||||
@@ -75,6 +80,7 @@ class MessageReaderState {
|
||||
MessageDetail? detail,
|
||||
String? document,
|
||||
bool? bodyOmitted,
|
||||
bool? bodyEmpty,
|
||||
bool? imagesLoading,
|
||||
MessageReaderBusy? busy,
|
||||
bool clearBusy = false,
|
||||
@@ -85,6 +91,7 @@ class MessageReaderState {
|
||||
error: error,
|
||||
document: document ?? this.document,
|
||||
bodyOmitted: bodyOmitted ?? this.bodyOmitted,
|
||||
bodyEmpty: bodyEmpty ?? this.bodyEmpty,
|
||||
imagesLoading: imagesLoading ?? this.imagesLoading,
|
||||
busy: clearBusy ? null : (busy ?? this.busy),
|
||||
subjectHighlight: identical(subjectHighlight, _unset)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:arasystem_app/core/people/address_input.dart';
|
||||
import 'package:arasystem_app/core/people/people_texts.dart';
|
||||
import 'package:arasystem_app/core/people/recipient_match.dart';
|
||||
@@ -192,6 +194,10 @@ class _ComposeRecipientFieldState extends State<ComposeRecipientField> {
|
||||
error: invalid,
|
||||
onTap: widget.enabled ? widget.focusNode.requestFocus : null,
|
||||
trailing: widget.trailing,
|
||||
// 칩이 있으면 첫 줄은 칩 줄 — 여러 줄이어도 칸 이름 · [참조 펼치기]가 첫 칩 줄에 맞는다
|
||||
firstLineHeight: widget.tokens.isEmpty
|
||||
? null
|
||||
: AppSize.recipientChipHeight,
|
||||
child: LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
return Wrap(
|
||||
@@ -263,7 +269,8 @@ class _ComposeRecipientFieldState extends State<ComposeRecipientField> {
|
||||
smartQuotesType: SmartQuotesType.disabled,
|
||||
inputFormatters: const <TextInputFormatter>[_LineBreaksToCommas()],
|
||||
style: theme.textTheme.bodyLarge,
|
||||
decoration: const InputDecoration.collapsed(hintText: ''),
|
||||
// 테두리는 칸 줄(ComposeFieldRow)의 아래 선뿐이다 — 테마의 입력칸 테두리를 물려받지 않게
|
||||
decoration: borderlessInputDecoration(hintText: ''),
|
||||
onChanged: widget.onChanged,
|
||||
onEditingComplete: _onNext,
|
||||
),
|
||||
@@ -342,7 +349,12 @@ class _LineBreaksToCommas extends TextInputFormatter {
|
||||
}
|
||||
}
|
||||
|
||||
/// 쓰기 화면의 칸 줄 — 왼쪽 칸 이름(72) · 가운데 칸 · 오른쪽 단추, 아래 선(초점이 있으면 청록 2) (보드 `App1Compose`)
|
||||
/// 쓰기 화면의 칸 줄 — 왼쪽 칸 이름(72 · 글자 15) · 간격 6 · 가운데 칸 · 오른쪽 단추, 아래 선(초점이 있으면 청록 2)
|
||||
/// (보드 `App1Compose` — 최소 높이 56 · 여백 위 4 · 오른쪽 4 · 아래 4 · 왼쪽 16).
|
||||
///
|
||||
/// 칸 이름과 오른쪽 단추는 **첫 줄**([lineHeight] — 최소 높이에서 위아래 여백을 뺀 48)의 세로 가운데에 둔다 —
|
||||
/// 받는 사람 칩이 여러 줄이어도 첫 줄에 남는다(줄 전체의 가운데로 내려가지 않게 — 2026-11-12). 칸이 한 줄이면 줄의 가운데 그대로다.
|
||||
/// 아래 선은 자리를 차지하지 않게 그린다 — 초점 · 오류로 굵어져도 칸이 움직이지 않는다(보드의 inset 그림자와 같다)
|
||||
class ComposeFieldRow extends StatelessWidget {
|
||||
const ComposeFieldRow({
|
||||
required this.label,
|
||||
@@ -351,9 +363,14 @@ class ComposeFieldRow extends StatelessWidget {
|
||||
this.error = false,
|
||||
this.trailing,
|
||||
this.onTap,
|
||||
this.firstLineHeight,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// 첫 줄의 높이 — 줄의 최소 높이(56)에서 위아래 여백(4)을 뺀 자리
|
||||
static const double lineHeight =
|
||||
AppSize.composeRowMinHeight - AppSpacing.s4 * 2;
|
||||
|
||||
final String label;
|
||||
final Widget child;
|
||||
final bool focused;
|
||||
@@ -363,12 +380,20 @@ class ComposeFieldRow extends StatelessWidget {
|
||||
final Widget? trailing;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// 칸 안 첫 줄의 높이(받는 사람 칩 40) — 주면 칸을 위아래로 ([lineHeight] − 이 값) / 2 씩 띄워,
|
||||
/// 칩이 여러 줄이어도 첫 칩 줄이 칸 이름 · 오른쪽 단추와 같은 높이에 온다(한 줄이면 줄을 꼭 채워 지금처럼 가운데)
|
||||
final double? firstLineHeight;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final ColorScheme scheme = theme.colorScheme;
|
||||
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
|
||||
final Widget? action = trailing;
|
||||
final double? inner = firstLineHeight;
|
||||
final double inset = inner == null
|
||||
? 0
|
||||
: math.max(0, (lineHeight - inner) / 2);
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: onTap,
|
||||
@@ -376,13 +401,13 @@ class ComposeFieldRow extends StatelessWidget {
|
||||
constraints: const BoxConstraints(
|
||||
minHeight: AppSize.composeRowMinHeight,
|
||||
),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.s16,
|
||||
AppSpacing.s4,
|
||||
action == null ? AppSpacing.s16 : AppSpacing.s4,
|
||||
AppSpacing.s4,
|
||||
AppSpacing.s4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
foregroundDecoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: error
|
||||
@@ -395,20 +420,32 @@ class ComposeFieldRow extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
SizedBox(
|
||||
width: AppSize.composeLabelWidth,
|
||||
child: ExcludeSemantics(
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: extra.textMuted,
|
||||
_FirstLine(
|
||||
child: SizedBox(
|
||||
width: AppSize.composeLabelWidth,
|
||||
child: ExcludeSemantics(
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: extra.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(child: child),
|
||||
?action,
|
||||
const SizedBox(width: AppSpacing.s6),
|
||||
Expanded(
|
||||
child: _FirstLine(
|
||||
stretch: true,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: inset),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (action != null) _FirstLine(child: action),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -416,6 +453,31 @@ class ComposeFieldRow extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 칸 줄의 첫 줄 자리 — [ComposeFieldRow.lineHeight] 안의 세로 가운데에 둔다. 더 높으면(칩이 여러 줄 · 큰 글자) 위에서부터
|
||||
class _FirstLine extends StatelessWidget {
|
||||
const _FirstLine({required this.child, this.stretch = false});
|
||||
|
||||
final Widget child;
|
||||
|
||||
/// 폭을 다 쓴다(가운데 칸)
|
||||
final bool stretch;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: ComposeFieldRow.lineHeight),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: stretch
|
||||
? CrossAxisAlignment.stretch
|
||||
: CrossAxisAlignment.start,
|
||||
children: <Widget>[child],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 받는 사람 칩 — 이름(없으면 주소) · ✕. 형식이 틀린 주소는 빨갛게
|
||||
class _RecipientChip extends StatelessWidget {
|
||||
const _RecipientChip({required this.token, required this.onRemove});
|
||||
@@ -427,7 +489,6 @@ class _RecipientChip extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
final ColorScheme scheme = theme.colorScheme;
|
||||
final AppExtraColors extra = theme.extension<AppExtraColors>()!;
|
||||
final String? name = token.address.name?.trim();
|
||||
final String shown = !token.valid
|
||||
? token.label
|
||||
@@ -470,7 +531,8 @@ class _RecipientChip extends StatelessWidget {
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
iconSize: AppSize.iconTiny,
|
||||
color: token.valid ? extra.textStrong : scheme.error,
|
||||
// 보드의 ✕ #4A5363 — 보조 글자색(onSurfaceVariant)
|
||||
color: token.valid ? scheme.onSurfaceVariant : scheme.error,
|
||||
tooltip: MailComposeTexts.remove(shown),
|
||||
onPressed: onRemove,
|
||||
icon: const Icon(Icons.close),
|
||||
|
||||
@@ -18,6 +18,7 @@ import 'package:webview_flutter_android/webview_flutter_android.dart';
|
||||
/// - 토큰은 WebView 에 가지 않는다(Bearer 는 Dio 만 — 그림 · 첨부는 앱이 받는다).
|
||||
///
|
||||
/// 높이: 머리(앱 화면) 아래 남은 자리를 다 쓰고 본문은 이 안에서 스크롤한다(앱 디자인 결정 — 스크립트가 꺼져 내용 높이를 잴 수 없다).
|
||||
/// 그래서 보이는 것이 없는 본문(첨부만 있는 메일 등)이면 읽기 화면이 이 칸을 아예 두지 않는다(`SanitizedMailBody.hasVisibleContent`).
|
||||
class MailBodyView extends StatefulWidget {
|
||||
const MailBodyView({
|
||||
required this.document,
|
||||
|
||||
@@ -94,8 +94,8 @@ class MailSearchBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
Utf16LengthLimitFormatter(kMailSearchMaxLength),
|
||||
],
|
||||
style: style,
|
||||
decoration: InputDecoration.collapsed(
|
||||
hintText: null,
|
||||
// 위 막대 안의 칸 — 테마의 입력칸 테두리를 물려받지 않게
|
||||
decoration: borderlessInputDecoration(
|
||||
hint: ExcludeSemantics(
|
||||
child: Text(
|
||||
MailSearchTexts.box,
|
||||
|
||||
@@ -6,7 +6,7 @@ publish_to: 'none'
|
||||
# 앱 판 — build-name(1.0.0)은 Android versionName · iOS CFBundleShortVersionString,
|
||||
# build-number(+1)는 Android versionCode · iOS CFBundleVersion 이 된다.
|
||||
# 서버에 보내는 User-Agent 의 판(ARASYSTEM-App/<판>)도 build-name 이다 (core/api/user_agent.dart).
|
||||
version: 1.0.0+1
|
||||
version: 1.0.1+2
|
||||
|
||||
environment:
|
||||
# Flutter 3.41.9 의 Dart — 판 고정은 .claude/skills/flutter/SKILL.md "Flutter 판"
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:arasystem_app/core/theme/app_theme.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../support/input_borders.dart';
|
||||
|
||||
/// 테두리 없는 입력칸 꾸밈 — 칸 줄이 아래 선 · 초점을 따로 그리는 칸(쓰기 화면 · 검색 막대)이 앱 테마의 둥근 테두리를 물려받지 않는다
|
||||
void main() {
|
||||
final ThemeData theme = AppTheme.light();
|
||||
|
||||
test(
|
||||
'앱 테마를 입혀도 여섯 테두리가 모두 없고 바탕 · 안쪽 여백도 없다 — collapsed 는 테마의 둥근 테두리를 물려받는다',
|
||||
() {
|
||||
final InputDecoration bare = borderlessInputDecoration(
|
||||
hintText: '제목을 입력해 주세요',
|
||||
).applyDefaults(theme.inputDecorationTheme);
|
||||
for (final InputBorder? border in <InputBorder?>[
|
||||
bare.border,
|
||||
bare.enabledBorder,
|
||||
bare.focusedBorder,
|
||||
bare.disabledBorder,
|
||||
bare.errorBorder,
|
||||
bare.focusedErrorBorder,
|
||||
]) {
|
||||
expect(border, InputBorder.none);
|
||||
}
|
||||
expect(bare.filled, isFalse);
|
||||
expect(bare.contentPadding, EdgeInsets.zero);
|
||||
expect(bare.isCollapsed, isTrue);
|
||||
expect(bare.hintText, '제목을 입력해 주세요');
|
||||
|
||||
// 까닭 — collapsed 는 border 만 없애고 비워 둔 칸을 TextField 가 테마의 둥근 테두리로 채운다
|
||||
final InputDecoration collapsed = const InputDecoration.collapsed(
|
||||
hintText: '제목을 입력해 주세요',
|
||||
).applyDefaults(theme.inputDecorationTheme);
|
||||
expect(collapsed.border, InputBorder.none);
|
||||
expect(collapsed.enabledBorder, isA<OutlineInputBorder>());
|
||||
expect(collapsed.focusedBorder, isA<OutlineInputBorder>());
|
||||
},
|
||||
);
|
||||
|
||||
/// 앱 테마로 칸 하나를 그린다 — [enabled] 가 거짓이면 꺼진 칸
|
||||
Future<FocusNode> pumpField(
|
||||
WidgetTester tester,
|
||||
InputDecoration decoration, {
|
||||
bool enabled = true,
|
||||
}) async {
|
||||
final FocusNode focusNode = FocusNode();
|
||||
addTearDown(focusNode.dispose);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: theme,
|
||||
home: Scaffold(
|
||||
body: TextField(
|
||||
key: const ValueKey<String>('field'),
|
||||
focusNode: focusNode,
|
||||
enabled: enabled,
|
||||
decoration: decoration,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return focusNode;
|
||||
}
|
||||
|
||||
final Finder field = find.byKey(const ValueKey<String>('field'));
|
||||
|
||||
testWidgets('앱 테마로 그린 칸 — 평소 · 초점 · 꺼짐 어느 때나 InputDecorator 가 받는 테두리가 없다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final FocusNode focusNode = await pumpField(
|
||||
tester,
|
||||
borderlessInputDecoration(hintText: '제목'),
|
||||
);
|
||||
InputDecorator decorator = expectBorderlessInput(
|
||||
tester,
|
||||
field,
|
||||
reason: '평소',
|
||||
);
|
||||
expect(decorator.isFocused, isFalse);
|
||||
expect(decorator.decoration.enabled, isTrue);
|
||||
|
||||
focusNode.requestFocus();
|
||||
await tester.pump();
|
||||
decorator = expectBorderlessInput(tester, field, reason: '초점');
|
||||
expect(decorator.isFocused, isTrue);
|
||||
|
||||
await pumpField(tester, borderlessInputDecoration(), enabled: false);
|
||||
decorator = expectBorderlessInput(tester, field, reason: '꺼짐');
|
||||
expect(decorator.decoration.enabled, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('견줌 — collapsed 로 그린 칸은 평소에 회색, 초점에 청록 둥근 테두리를 그린다(이 시험이 잡는 것)', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final FocusNode focusNode = await pumpField(
|
||||
tester,
|
||||
const InputDecoration.collapsed(hintText: '제목'),
|
||||
);
|
||||
InputDecorator decorator() => tester.widget<InputDecorator>(
|
||||
find.descendant(of: field, matching: find.byType(InputDecorator)),
|
||||
);
|
||||
expect(isDrawnInputBorder(shownInputBorder(decorator())), isTrue);
|
||||
focusNode.requestFocus();
|
||||
await tester.pump();
|
||||
final InputBorder? focused = shownInputBorder(decorator());
|
||||
expect(isDrawnInputBorder(focused), isTrue);
|
||||
expect(focused!.borderSide.color, theme.colorScheme.primary);
|
||||
});
|
||||
|
||||
test(
|
||||
'lib 의 입력칸은 InputDecoration.collapsed 를 쓰지 않는다 — 테두리 없는 칸은 borderlessInputDecoration',
|
||||
() {
|
||||
final List<String> found = <String>[];
|
||||
for (final FileSystemEntity entity in Directory(
|
||||
'lib',
|
||||
).listSync(recursive: true)) {
|
||||
if (entity is! File || !entity.path.endsWith('.dart')) continue;
|
||||
final List<String> lines = entity.readAsLinesSync();
|
||||
for (int index = 0; index < lines.length; index++) {
|
||||
if (lines[index].contains('InputDecoration.collapsed(')) {
|
||||
found.add('${entity.path}:${index + 1}');
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(found, isEmpty);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import 'package:arasystem_app/core/utils/visible_text.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// 보이는 글자가 있는가 — 공백 · 제어 문자 · 보이지 않는 글자만 있으면 빈 글이다
|
||||
void main() {
|
||||
/// 글자 코드로 만든 글 — 소스에 보이지 않는 글자를 두지 않는다
|
||||
String chars(List<int> codes) => String.fromCharCodes(codes);
|
||||
|
||||
test('Dart trim() 이 지우는 공백은 모두 보이지 않는다', () {
|
||||
int blanks = 0;
|
||||
for (int code = 0; code <= 0xFFFF; code++) {
|
||||
// 홀로 선 서로게이트는 글자가 아니다
|
||||
if (code >= 0xD800 && code <= 0xDFFF) continue;
|
||||
final String char = String.fromCharCode(code);
|
||||
if (char.trim().isNotEmpty) continue;
|
||||
blanks++;
|
||||
expect(
|
||||
hasVisibleText(char),
|
||||
isFalse,
|
||||
reason: 'U+${code.toRadixString(16).toUpperCase()}',
|
||||
);
|
||||
}
|
||||
// 유니코드 White_Space 25자 + BOM — 빈칸 · 탭 · 줄바꿈 · nbsp · 너비가 다른 빈칸 11자 · 줄 · 문단 구분 · 전각 빈칸 등
|
||||
expect(blanks, 26);
|
||||
});
|
||||
|
||||
test('빈 글 · 공백(nbsp · 전각 빈칸 · 줄 · 문단 구분 · BOM 등)뿐인 글은 보이는 것이 없다', () {
|
||||
expect(hasVisibleText(''), isFalse);
|
||||
expect(hasVisibleText(' \t\r\n'), isFalse);
|
||||
for (final int code in <int>[
|
||||
0x00A0, // nbsp
|
||||
0x0085, // NEL
|
||||
0x1680,
|
||||
0x2002,
|
||||
0x200A,
|
||||
0x2028,
|
||||
0x2029,
|
||||
0x202F,
|
||||
0x205F,
|
||||
0x3000, // 전각 빈칸
|
||||
0xFEFF, // BOM
|
||||
]) {
|
||||
expect(hasVisibleText(chars(<int>[0x20, code, 0x0A])), isFalse);
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
'제어 문자 · 보이지 않는 글자(제로폭 · 이음 · 방향 표시 · 이체 선택자 · 한글 채움 · 태그 · 점자 빈칸)도 보이지 않는다',
|
||||
() {
|
||||
for (final int code in <int>[
|
||||
0x0000,
|
||||
0x001F,
|
||||
0x007F,
|
||||
0x009F,
|
||||
0x00AD, // soft hyphen
|
||||
0x034F, // CGJ
|
||||
0x061C,
|
||||
0x115F, // 한글 초성 채움
|
||||
0x1160, // 한글 중성 채움
|
||||
0x180E,
|
||||
0x200B, // 제로폭 빈칸
|
||||
0x200C, // ZWNJ
|
||||
0x200D, // ZWJ
|
||||
0x200E, // LRM
|
||||
0x200F, // RLM
|
||||
0x202A,
|
||||
0x202E,
|
||||
0x2060, // WORD JOINER
|
||||
0x2066,
|
||||
0x2069,
|
||||
0x2800, // 점자 빈칸
|
||||
0x3164, // 한글 채움 문자
|
||||
0xFE0F, // 이체 선택자
|
||||
0xFFA0, // 반각 한글 채움 문자
|
||||
0x1BCA0,
|
||||
0x1D173,
|
||||
0xE0001, // 태그 글자
|
||||
0xE0041,
|
||||
0xE01EF, // 이체 선택자 보충
|
||||
]) {
|
||||
expect(
|
||||
hasVisibleText(chars(<int>[code, code])),
|
||||
isFalse,
|
||||
reason: 'U+${code.toRadixString(16).toUpperCase()}',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('글자 · 기호 · 숫자 · 이모지가 하나라도 있으면 보인다', () {
|
||||
expect(hasVisibleText('가'), isTrue);
|
||||
expect(hasVisibleText('a'), isTrue);
|
||||
expect(hasVisibleText('.'), isTrue);
|
||||
expect(hasVisibleText('~'), isTrue);
|
||||
expect(hasVisibleText('0'), isTrue);
|
||||
expect(hasVisibleText(chars(<int>[0x1F600])), isTrue);
|
||||
// 이음(ZWJ)으로 묶은 이모지 — 이음 말고 그림 글자가 보인다
|
||||
expect(hasVisibleText(chars(<int>[0x1F468, 0x200D, 0x1F469])), isTrue);
|
||||
// 보이지 않는 글자들 뒤에 글자 하나
|
||||
expect(
|
||||
hasVisibleText(chars(<int>[0x00A0, 0x200B, 0xFEFF, 0x3164, 0x41])),
|
||||
isTrue,
|
||||
);
|
||||
// 보이지 않는 글자의 범위 바로 옆 글자는 보인다
|
||||
expect(hasVisibleText(chars(<int>[0x00A1])), isTrue); // ¡
|
||||
expect(hasVisibleText(chars(<int>[0x2010])), isTrue); // 붙임표
|
||||
expect(hasVisibleText(chars(<int>[0x2801])), isTrue); // 점자 점 하나
|
||||
expect(hasVisibleText(chars(<int>[0x3163])), isTrue); // ㅣ
|
||||
expect(hasVisibleText(chars(<int>[0x20000])), isTrue); // 한자 확장 B
|
||||
});
|
||||
}
|
||||
@@ -372,6 +372,141 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('본문에 보이는 것이 있는가 (없으면 읽기 화면에 본문 칸이 없다)', () {
|
||||
bool visibleHtml(String html, {String? text, bool imagesShown = false}) =>
|
||||
mailBodyFragment(
|
||||
htmlBody: html,
|
||||
textBody: text,
|
||||
imagesShown: imagesShown,
|
||||
).hasVisibleContent;
|
||||
|
||||
bool visibleText(String? text) => mailBodyFragment(
|
||||
htmlBody: null,
|
||||
textBody: text,
|
||||
imagesShown: false,
|
||||
).hasVisibleContent;
|
||||
|
||||
test('빈 줄 · nbsp · 제로폭 글자뿐인 서식 본문은 비었다', () {
|
||||
expect(visibleHtml('<div><br></div>'), isFalse);
|
||||
expect(visibleHtml('<p> </p>'), isFalse);
|
||||
// Gmail · Outlook 이 본문 없이 보낸 꼴 — 모르는 태그(o:p)는 지우고 안의 nbsp 만 남는다
|
||||
expect(visibleHtml('<div dir="ltr"><br></div><div><br></div>'), isFalse);
|
||||
expect(
|
||||
visibleHtml('<p class="MsoNormal"><o:p> </o:p></p>'),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
visibleHtml(
|
||||
'<div style="font-family:Pretendard;color:#1F2733"><p></p></div>',
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
// 제로폭 빈칸 · BOM · WORD JOINER · 한글 채움 문자 · 점자 빈칸
|
||||
expect(
|
||||
visibleHtml('<p>​⁠ㅤ⠀</p>'),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('내용째 버리는 것 · 주석 · 주소를 지운 그림은 보이지 않는다', () {
|
||||
expect(
|
||||
visibleHtml(
|
||||
'<style>p{color:red}</style><!-- 주석 --><script>alert(1)</script>'
|
||||
'<title>제목</title>',
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
visibleHtml(
|
||||
'<p><svg><text>그림 글</text></svg><template><b>숨은 글</b></template></p>',
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
// 막은 바깥 그림 · 외부 이미지를 보이지 않을 때의 중계 주소 — 주소를 지워 문서가 숨긴다(alt 도 보이지 않는다)
|
||||
expect(
|
||||
visibleHtml('<img src="https://t.example/p.png" width="600">'),
|
||||
isFalse,
|
||||
);
|
||||
expect(visibleHtml('<img src="/api/mail-images/t1" alt="배너">'), isFalse);
|
||||
});
|
||||
|
||||
test(
|
||||
'꾸밈(style · bgcolor · border)만으로 그린 상자 · 선과 빈 인용의 막대는 보이는 것으로 치지 않는다',
|
||||
() {
|
||||
expect(
|
||||
visibleHtml(
|
||||
'<table border="1" bgcolor="#EEEEEE"><tr>'
|
||||
'<td style="height:20px;background:#0E7C86;border:1px solid #000000"></td>'
|
||||
'</tr></table>',
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
expect(visibleHtml('<blockquote><br></blockquote>'), isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test('글 · 그림 · 가로줄 · 목록 표시 · 펼침 · 따옴표가 하나라도 있으면 보인다', () {
|
||||
expect(visibleHtml('<p>안녕하세요</p>'), isTrue);
|
||||
expect(visibleHtml('<o:p>모르는 태그 안의 글</o:p>'), isTrue);
|
||||
expect(visibleHtml('<p> </p><p>.</p>'), isTrue);
|
||||
expect(
|
||||
visibleHtml(
|
||||
'<p><br></p><img src="data:image/png;base64,iVBORw0KGgo=">',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
// 외부 이미지를 보일 때의 중계 그림 — 받기 전에도 보이는 것으로 친다
|
||||
expect(
|
||||
visibleHtml('<img src="/api/mail-images/t1">', imagesShown: true),
|
||||
isTrue,
|
||||
);
|
||||
expect(visibleHtml('<hr>'), isTrue);
|
||||
expect(visibleHtml('<ul><li></li></ul>'), isTrue);
|
||||
expect(visibleHtml('<details></details>'), isTrue);
|
||||
expect(visibleHtml('<q></q>'), isTrue);
|
||||
});
|
||||
|
||||
test('글자 본문 — 없거나 공백 · 보이지 않는 글자뿐이면 비었다', () {
|
||||
expect(visibleText(null), isFalse);
|
||||
expect(visibleText(''), isFalse);
|
||||
expect(visibleText(' \n\t\n'), isFalse);
|
||||
expect(
|
||||
visibleText(String.fromCharCodes(<int>[0x00A0, 0x200B, 0x0A, 0xFEFF])),
|
||||
isFalse,
|
||||
);
|
||||
expect(visibleText('본문'), isTrue);
|
||||
});
|
||||
|
||||
test(
|
||||
'고른 본문으로 본다 — 공백뿐인 서식 본문 · 한도를 넘은 서식 본문은 글자 본문, 비어 보이는 서식 본문은 그대로',
|
||||
() {
|
||||
expect(visibleHtml(' \n ', text: ''), isFalse);
|
||||
expect(visibleHtml(' \n ', text: '글자 본문'), isTrue);
|
||||
// 서식 본문이 비어 보여도 글자 본문으로 바꾸지 않는다(웹 displayedHtmlBody 와 같은 본문을 보인다)
|
||||
expect(visibleHtml('<div><br></div>', text: '글자 본문'), isFalse);
|
||||
|
||||
const int depth = 20000;
|
||||
final String deep = '${'<div>' * depth}${'</div>' * depth}';
|
||||
final SanitizedMailBody omitted = mailBodyFragment(
|
||||
htmlBody: deep,
|
||||
textBody: '',
|
||||
imagesShown: false,
|
||||
);
|
||||
expect(omitted.htmlOmitted, isTrue);
|
||||
expect(omitted.hasVisibleContent, isFalse);
|
||||
expect(visibleHtml(deep, text: '글자 본문'), isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test('깊거나 큰 본문도 되풀이 없이 정화하며 판정한다', () {
|
||||
const int depth = 1500;
|
||||
expect(visibleHtml('${'<div>' * depth}<br>${'</div>' * depth}'), isFalse);
|
||||
expect(visibleHtml('${'<div>' * depth}끝${'</div>' * depth}'), isTrue);
|
||||
// 맨 끝에만 글이 있는 본문
|
||||
expect(visibleHtml('${'<p> </p>' * 20000}<p>끝</p>'), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('본문 조각 · 문서', () {
|
||||
test('HTML 이 없거나 공백뿐이면 글자 본문을 이스케이프해 보인다', () {
|
||||
final SanitizedMailBody text = mailBodyFragment(
|
||||
|
||||
@@ -28,6 +28,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../support/fakes.dart';
|
||||
import '../../support/input_borders.dart';
|
||||
import '../../support/login_fakes.dart';
|
||||
import '../../support/mail_fakes.dart';
|
||||
import '../../support/mail_harness.dart';
|
||||
@@ -187,6 +188,24 @@ void main() {
|
||||
expect(h.mail.searchQueries, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'검색 칸은 테두리를 그리지 않는다 — 초점이 있을 때 · 놓은 뒤(앱 테마의 둥근 테두리를 물려받지 않는다)',
|
||||
(WidgetTester tester) async {
|
||||
await openSearch(tester);
|
||||
expect(
|
||||
expectBorderlessInput(tester, field(), reason: '초점').isFocused,
|
||||
isTrue,
|
||||
);
|
||||
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
expectBorderlessInput(tester, field(), reason: '평소').isFocused,
|
||||
isFalse,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'[검색] → 뼈대("찾는 중") → 결과 — 개수 줄 · 제목 · 미리 보기의 맞은 말이 노랑, 자판을 내린다',
|
||||
(WidgetTester tester) async {
|
||||
|
||||
@@ -32,6 +32,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../support/compose_fakes.dart';
|
||||
import '../../support/fakes.dart';
|
||||
import '../../support/input_borders.dart';
|
||||
import '../../support/login_fakes.dart';
|
||||
import '../../support/mail_fakes.dart';
|
||||
import '../../support/mail_harness.dart';
|
||||
@@ -500,6 +501,241 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('입력칸 모양', () {
|
||||
testWidgets(
|
||||
'칸마다 테두리를 그리지 않는다 — 평소 · 초점 · 꺼짐(보내는 중) 모두 칸 줄의 아래 선뿐(앱 테마의 둥근 테두리를 물려받지 않는다)',
|
||||
(WidgetTester tester) async {
|
||||
await start(tester);
|
||||
await open(tester, MailRoutes.compose);
|
||||
await tapTooltip(tester, MailComposeTexts.expandCopies);
|
||||
await addRecipient(tester, 'doyun@x.kr');
|
||||
await tester.enterText(subjectField, '계약');
|
||||
await tester.enterText(bodyField, '안녕하세요');
|
||||
final List<(String, Finder)> fields = <(String, Finder)>[
|
||||
('받는 사람', field(RecipientFieldKind.to)),
|
||||
('참조', field(RecipientFieldKind.cc)),
|
||||
('숨은참조', field(RecipientFieldKind.bcc)),
|
||||
('제목', subjectField),
|
||||
('본문', bodyField),
|
||||
];
|
||||
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
await tester.pumpAndSettle();
|
||||
for (final (String name, Finder finder) in fields) {
|
||||
final InputDecorator decorator = expectBorderlessInput(
|
||||
tester,
|
||||
finder,
|
||||
reason: '$name 평소',
|
||||
);
|
||||
expect(decorator.isFocused, isFalse);
|
||||
expect(decorator.decoration.enabled, isTrue);
|
||||
}
|
||||
|
||||
for (final (String name, Finder finder) in fields) {
|
||||
tester.widget<TextField>(finder).focusNode!.requestFocus();
|
||||
await tester.pumpAndSettle();
|
||||
final InputDecorator decorator = expectBorderlessInput(
|
||||
tester,
|
||||
finder,
|
||||
reason: '$name 초점',
|
||||
);
|
||||
expect(decorator.isFocused, isTrue);
|
||||
}
|
||||
|
||||
// 보내는 동안은 칸을 막는다
|
||||
final Completer<void> gate = Completer<void>();
|
||||
c.sendGate = gate;
|
||||
await tester.tap(find.byTooltip(MailComposeTexts.send));
|
||||
await tester.pump();
|
||||
for (final (String name, Finder finder) in fields) {
|
||||
final InputDecorator decorator = expectBorderlessInput(
|
||||
tester,
|
||||
finder,
|
||||
reason: '$name 꺼짐',
|
||||
);
|
||||
expect(decorator.decoration.enabled, isFalse);
|
||||
}
|
||||
gate.complete();
|
||||
await tester.pumpAndSettle();
|
||||
expect(c.sends, hasLength(1));
|
||||
},
|
||||
);
|
||||
|
||||
/// 받는 사람 칸 줄
|
||||
Finder toRow() => find.descendant(
|
||||
of: find.byKey(const ValueKey<RecipientFieldKind>(RecipientFieldKind.to)),
|
||||
matching: find.byType(ComposeFieldRow),
|
||||
);
|
||||
Finder removeChip(String name) =>
|
||||
find.byTooltip(MailComposeTexts.remove(name));
|
||||
double centerY(WidgetTester tester, Finder finder) =>
|
||||
tester.getCenter(finder).dy;
|
||||
|
||||
testWidgets(
|
||||
'칸 줄 — 칸 이름 15 · 칸 이름과 칸 사이 6 · 오른쪽 여백 4 · 안내 글은 보드의 placeholder 색 · 칩 ✕ 는 #4A5363 (보드 App1Compose)',
|
||||
(WidgetTester tester) async {
|
||||
await start(tester);
|
||||
await open(
|
||||
tester,
|
||||
MailRoutes.compose,
|
||||
extra: const ComposePrefill(to: <String>['이도윤 <doyun@x.kr>']),
|
||||
);
|
||||
await tapTooltip(tester, MailComposeTexts.expandCopies);
|
||||
|
||||
for (final String label in <String>[
|
||||
fieldLabelOf(RecipientFieldKind.to),
|
||||
fieldLabelOf(RecipientFieldKind.cc),
|
||||
fieldLabelOf(RecipientFieldKind.bcc),
|
||||
MailComposeTexts.subject,
|
||||
]) {
|
||||
// 보드 14 → 앱의 크기 대응 15 (앱 디자인 README 「쓰기」)
|
||||
expect(
|
||||
tester.widget<Text>(find.text(label)).style?.fontSize,
|
||||
15,
|
||||
reason: label,
|
||||
);
|
||||
}
|
||||
final Rect label = tester.getRect(find.text(MailComposeTexts.subject));
|
||||
final Rect subject = tester.getRect(subjectField);
|
||||
final Rect row = tester.getRect(
|
||||
find.ancestor(
|
||||
of: subjectField,
|
||||
matching: find.byType(ComposeFieldRow),
|
||||
),
|
||||
);
|
||||
expect(subject.left, label.left + 72 + 6);
|
||||
expect(subject.right, row.right - 4);
|
||||
expect(row.height, 56);
|
||||
|
||||
// 안내 글 — 보드의 placeholder #8A94A3(검색 칸 안내 글과 같은 색)
|
||||
final InputDecoration? subjectHint = tester
|
||||
.widget<TextField>(subjectField)
|
||||
.decoration;
|
||||
final InputDecoration? bodyHint = tester
|
||||
.widget<TextField>(bodyField)
|
||||
.decoration;
|
||||
expect(subjectHint?.hintStyle?.color, AppColors.borderInput);
|
||||
expect(bodyHint?.hintStyle?.color, AppColors.borderInput);
|
||||
expect(AppColors.borderInput, const Color(0xFF8A94A3));
|
||||
|
||||
final IconButton remove = tester.widget<IconButton>(
|
||||
find.ancestor(
|
||||
of: removeChip('이도윤'),
|
||||
matching: find.byType(IconButton),
|
||||
),
|
||||
);
|
||||
expect(remove.color, AppColors.textSecondary);
|
||||
expect(AppColors.textSecondary, const Color(0xFF4A5363));
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'받는 사람 칩이 여러 줄이면 칸 이름 · [참조 펼치기]는 첫 칩 줄에 맞춘다 — 한 줄이면 줄의 가운데 그대로',
|
||||
(WidgetTester tester) async {
|
||||
await start(tester);
|
||||
await open(
|
||||
tester,
|
||||
MailRoutes.compose,
|
||||
extra: const ComposePrefill(
|
||||
to: <String>[
|
||||
'이도윤 <doyun@x.kr>',
|
||||
'박준호 <junho@x.kr>',
|
||||
'최서연 <seoyeon@x.kr>',
|
||||
],
|
||||
),
|
||||
);
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
await tester.pumpAndSettle();
|
||||
final Finder label = find.text(fieldLabelOf(RecipientFieldKind.to));
|
||||
final Finder toggle = find.byTooltip(MailComposeTexts.expandCopies);
|
||||
|
||||
// 두 줄 — 셋째 칩은 아래 줄. 칸 이름 · 단추는 첫 칩 줄의 가운데(줄 전체의 가운데가 아니다)
|
||||
final double firstLine = centerY(tester, removeChip('이도윤'));
|
||||
expect(centerY(tester, removeChip('최서연')), greaterThan(firstLine + 40));
|
||||
expect(
|
||||
centerY(tester, label),
|
||||
moreOrLessEquals(firstLine, epsilon: 0.5),
|
||||
);
|
||||
expect(
|
||||
centerY(tester, toggle),
|
||||
moreOrLessEquals(firstLine, epsilon: 0.5),
|
||||
);
|
||||
expect(
|
||||
tester.getRect(toRow()).top + 4 + 24,
|
||||
moreOrLessEquals(firstLine, epsilon: 0.5),
|
||||
);
|
||||
|
||||
// 한 줄 — 칩 · 칸 이름 · 단추가 줄(56)의 가운데
|
||||
await tester.tap(removeChip('최서연'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(removeChip('박준호'));
|
||||
await tester.pumpAndSettle();
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
await tester.pumpAndSettle();
|
||||
final Rect row = tester.getRect(toRow());
|
||||
expect(row.height, 56);
|
||||
for (final Finder finder in <Finder>[
|
||||
label,
|
||||
toggle,
|
||||
removeChip('이도윤'),
|
||||
]) {
|
||||
expect(
|
||||
centerY(tester, finder),
|
||||
moreOrLessEquals(row.center.dy, epsilon: 0.5),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/// 본문 칸의 최소 높이 — 본문 칸을 감싼 ConstrainedBox(가장 가까운 것)
|
||||
Finder bodyBox() => find
|
||||
.ancestor(of: bodyField, matching: find.byType(ConstrainedBox))
|
||||
.first;
|
||||
|
||||
testWidgets('본문 칸 최소 높이 — 새 메일은 서명이 있어도 240, 원본 메일이 있는 답장은 150 (보드)', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
expect(composeBodyMinHeightOf(hasQuote: false), 240);
|
||||
expect(composeBodyMinHeightOf(hasQuote: true), 150);
|
||||
c.signature = <String, Object?>{
|
||||
'bodyHtml': null,
|
||||
'body': '김아라\n경영지원팀',
|
||||
'isForNew': true,
|
||||
'isForReply': true,
|
||||
};
|
||||
h.mail.put(
|
||||
kClientsId,
|
||||
messageJson(
|
||||
'r1',
|
||||
mailboxId: kClientsId,
|
||||
subject: '유지보수 계약',
|
||||
isRead: true,
|
||||
),
|
||||
detail: <String, Object?>{'textBody': '원문 첫 줄'},
|
||||
);
|
||||
await start(tester);
|
||||
await open(tester, MailRoutes.compose);
|
||||
|
||||
expect(find.text('김아라'), findsOneWidget);
|
||||
expect(
|
||||
tester.widget<ConstrainedBox>(bodyBox()).constraints.minHeight,
|
||||
240,
|
||||
);
|
||||
expect(tester.getSize(bodyBox()).height, 240);
|
||||
|
||||
await tapTooltip(tester, MailComposeTexts.close);
|
||||
expect(find.byType(MessageComposePage), findsNothing);
|
||||
await open(tester, MailRoutes.composeReply('r1'));
|
||||
|
||||
expect(find.text('김아라'), findsOneWidget);
|
||||
expect(find.textContaining('이도윤 · '), findsOneWidget);
|
||||
expect(
|
||||
tester.widget<ConstrainedBox>(bodyBox()).constraints.minHeight,
|
||||
150,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('받는 사람', () {
|
||||
testWidgets('쉼표 · [다음]에 칩 — 붙여 넣은 여러 줄도 나누고, 틀린 주소는 빨간 칩, ✕ 로 뺀다', (
|
||||
WidgetTester tester,
|
||||
|
||||
@@ -21,6 +21,7 @@ import 'package:arasystem_app/features/mail/presentation/providers/message_reade
|
||||
import 'package:arasystem_app/features/mail/presentation/widgets/mail_body_view.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/widgets/mail_compose_area.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/widgets/message_row.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/widgets/reader_bars.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -246,6 +247,232 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('빈 본문 (첨부만 있는 메일)', () {
|
||||
const int fileCount = 12;
|
||||
|
||||
/// 첨부 [fileCount] 개 — 본문 칸은 [body] 로 덮는다(없으면 비었다)
|
||||
Map<String, Object?> manyFiles([
|
||||
Map<String, Object?> body = const <String, Object?>{},
|
||||
]) => <String, Object?>{
|
||||
'htmlBody': null,
|
||||
'textBody': '',
|
||||
...body,
|
||||
'attachments': <Object?>[
|
||||
for (int n = 1; n <= fileCount; n++)
|
||||
attachmentJson('a$n', name: 'file-$n.pdf', size: 2048),
|
||||
],
|
||||
};
|
||||
|
||||
/// 머리(제목 · 안내 줄 · 첨부)의 스크롤 칸
|
||||
Finder head() => find.descendant(
|
||||
of: find.byType(MessageReaderPage),
|
||||
matching: find.byType(SingleChildScrollView),
|
||||
);
|
||||
|
||||
Rect actionBar(WidgetTester tester) =>
|
||||
tester.getRect(find.byType(ReaderActionBar));
|
||||
|
||||
testWidgets('보이는 것이 없으면 본문 칸 없이 머리가 화면 전체를 한 스크롤로 — 마지막 첨부까지 닿는다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
// Gmail 이 본문 없이 보낸 서식 본문 — 빈 줄뿐이다
|
||||
put(
|
||||
'm1',
|
||||
isRead: true,
|
||||
detail: manyFiles(<String, Object?>{
|
||||
'htmlBody': '<div dir="ltr"><br></div>',
|
||||
}),
|
||||
);
|
||||
await open(tester, 'm1');
|
||||
|
||||
expect(find.byType(MailBodyView), findsNothing);
|
||||
expect(h.webView.controllers, isEmpty);
|
||||
expect(
|
||||
find.text(MailReaderTexts.attachmentsTitle(fileCount)),
|
||||
findsOneWidget,
|
||||
);
|
||||
// 머리가 아래 막대 위까지 다 쓴다(본문이 있으면 절반까지)
|
||||
final Rect scroll = tester.getRect(head());
|
||||
expect(scroll.bottom, moreOrLessEquals(actionBar(tester).top));
|
||||
|
||||
// 마지막 첨부는 처음에는 화면 밖 — 머리를 스크롤해 닿는다
|
||||
final Finder last = find.text('file-$fileCount.pdf');
|
||||
expect(last.hitTestable(), findsNothing);
|
||||
await tester.drag(head(), const Offset(0, -2000));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final Rect row = tester.getRect(last);
|
||||
expect(row.top, greaterThanOrEqualTo(scroll.top));
|
||||
expect(row.bottom, lessThanOrEqualTo(scroll.bottom));
|
||||
// 본문 칸이 있었다면 머리가 닿지 못하는 아래 절반이다
|
||||
expect(
|
||||
row.center.dy,
|
||||
greaterThan(scroll.top + scroll.height * kReaderHeadMaxFraction),
|
||||
);
|
||||
// 닿은 첨부를 받아 연다
|
||||
h.mail.attachmentFiles['a$fileCount'] = () =>
|
||||
FakeResponse.bytes(<int>[1, 2], contentType: 'application/pdf');
|
||||
await tapTooltip(tester, MailReaderTexts.download('file-$fileCount.pdf'));
|
||||
expect(h.opener.opened.single.path, endsWith('/file-$fileCount.pdf'));
|
||||
});
|
||||
|
||||
testWidgets('본문이 있으면 지금처럼 — 머리는 남은 자리의 절반까지(그 안에서 스크롤), 나머지는 본문 칸', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
put(
|
||||
'm1',
|
||||
isRead: true,
|
||||
detail: manyFiles(<String, Object?>{'textBody': '본문'}),
|
||||
);
|
||||
await open(tester, 'm1');
|
||||
|
||||
expect(find.byType(MailBodyView), findsOneWidget);
|
||||
final Rect scroll = tester.getRect(head());
|
||||
final Rect body = tester.getRect(find.byType(MailBodyView));
|
||||
final Rect bar = actionBar(tester);
|
||||
expect(body.top, moreOrLessEquals(scroll.bottom));
|
||||
expect(body.bottom, moreOrLessEquals(bar.top));
|
||||
expect(
|
||||
scroll.height,
|
||||
moreOrLessEquals((bar.top - scroll.top) * kReaderHeadMaxFraction),
|
||||
);
|
||||
|
||||
// 마지막 첨부도 머리 안에서 스크롤해 닿는다 — 위 절반 안이다
|
||||
await tester.drag(head(), const Offset(0, -2000));
|
||||
await tester.pumpAndSettle();
|
||||
final Rect row = tester.getRect(find.text('file-$fileCount.pdf'));
|
||||
expect(row.bottom, lessThanOrEqualTo(scroll.bottom));
|
||||
expect(find.byType(MailBodyView), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('본문을 만드는 동안은 지금처럼 본문 자리에 진행 줄 — 다 만들어 비었으면 본문 칸이 없어진다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
put('m1', isRead: true, detail: manyFiles());
|
||||
final Completer<SanitizedMailBody> building =
|
||||
Completer<SanitizedMailBody>();
|
||||
h.overrides.add(
|
||||
mailBodyBuilderProvider.overrideWithValue(
|
||||
({
|
||||
required String? htmlBody,
|
||||
required String? textBody,
|
||||
required bool imagesShown,
|
||||
required List<SearchRange> textHighlights,
|
||||
}) => building.future,
|
||||
),
|
||||
);
|
||||
await h.pump(tester);
|
||||
unawaited(h.router.push<void>(MailRoutes.message('m1')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
|
||||
expect(
|
||||
find.bySemanticsLabel(MailReaderTexts.bodyLoading),
|
||||
findsOneWidget,
|
||||
);
|
||||
final Rect loading = tester.getRect(head());
|
||||
expect(
|
||||
loading.height,
|
||||
moreOrLessEquals(
|
||||
(actionBar(tester).top - loading.top) * kReaderHeadMaxFraction,
|
||||
),
|
||||
);
|
||||
|
||||
building.complete(
|
||||
mailBodyFragment(htmlBody: null, textBody: '', imagesShown: false),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.bySemanticsLabel(MailReaderTexts.bodyLoading), findsNothing);
|
||||
expect(find.byType(MailBodyView), findsNothing);
|
||||
expect(
|
||||
tester.getRect(head()).bottom,
|
||||
moreOrLessEquals(actionBar(tester).top),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('빈 본문에서 [이미지 보기] — 그림이 생기면 본문 칸이 생기고 펼친 받는 사람은 그대로 남는다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
// 서버가 막은 바깥 그림을 지워 공백만 남은 서식 본문 — 글자 본문(빈 글)을 보인다
|
||||
put(
|
||||
'm1',
|
||||
isRead: true,
|
||||
to: <Map<String, Object?>>[addressJson('김아라', me)],
|
||||
detail: <String, Object?>{
|
||||
'htmlBody': '\n',
|
||||
'textBody': '',
|
||||
'hasBlockedImages': true,
|
||||
},
|
||||
);
|
||||
h.mail.shownImageDetails['m1'] = <String, Object?>{
|
||||
'htmlBody': '<img src="/api/mail-images/t1">',
|
||||
'hasBlockedImages': false,
|
||||
'imagesShown': true,
|
||||
};
|
||||
h.mail.mailImages['t1'] = FakeResponse.bytes(
|
||||
kTinyPng,
|
||||
contentType: 'image/png',
|
||||
);
|
||||
await open(tester, 'm1');
|
||||
|
||||
expect(find.byType(MailBodyView), findsNothing);
|
||||
expect(findCopy(MailReaderTexts.imagesBlocked), findsOneWidget);
|
||||
await tester.tap(
|
||||
find.text(MailReaderTexts.recipients(MailReaderTexts.me, 0)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text(MailReaderTexts.fromLabel), findsOneWidget);
|
||||
|
||||
await tester.ensureVisible(find.text(MailReaderTexts.showImages));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text(MailReaderTexts.showImages));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(MailBodyView), findsOneWidget);
|
||||
expect(lastDocument(), contains('data:image/png;base64,'));
|
||||
expect(findCopy(MailReaderTexts.imagesBlocked), findsNothing);
|
||||
// 머리는 같은 자리의 같은 위젯이라 펼친 칸이 남는다
|
||||
expect(find.text(MailReaderTexts.fromLabel), findsOneWidget);
|
||||
expect(
|
||||
tester.getRect(head()).bottom,
|
||||
moreOrLessEquals(tester.getRect(find.byType(MailBodyView)).top),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('아래 막대가 없는 예약 메일 — 머리 끝에 시스템 내비게이션 막대 몫을 더해 마지막 첨부가 그 위에 온다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
put(
|
||||
's1',
|
||||
mailboxId: kSentId,
|
||||
isRead: true,
|
||||
pendingSend: pendingJson(
|
||||
kTestNow.add(const Duration(days: 1)),
|
||||
kind: 'scheduled',
|
||||
),
|
||||
detail: manyFiles(),
|
||||
);
|
||||
// 3버튼 내비게이션 막대(48) — 물리 픽셀(시험대는 3배)
|
||||
tester.view.padding = const FakeViewPadding(bottom: 48 * 3);
|
||||
addTearDown(tester.view.resetPadding);
|
||||
await open(tester, 's1', location: MailRoutes.mailbox(kSentId));
|
||||
|
||||
expect(find.byType(ReaderActionBar), findsNothing);
|
||||
expect(find.byType(MailBodyView), findsNothing);
|
||||
// 머리가 화면 아래 끝까지 닿는다 — 그 밑의 48 은 내비게이션 막대 자리
|
||||
expect(tester.getRect(head()).bottom, kPhoneSize.height);
|
||||
await tester.drag(head(), const Offset(0, -3000));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 마지막 첨부 줄 전체가 내비게이션 막대 위에 온다
|
||||
final Rect row = tester.getRect(
|
||||
find.byKey(const ValueKey<String>('attachment-a$fileCount')),
|
||||
);
|
||||
expect(row.bottom, lessThanOrEqualTo(kPhoneSize.height - 48));
|
||||
});
|
||||
});
|
||||
|
||||
group('읽음 처리', () {
|
||||
testWidgets('열면 안 읽은 메일을 읽음으로 — 메일함 수를 먼저 줄이고 목록 줄은 바로 읽음', (
|
||||
WidgetTester tester,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// InputDecorator 가 지금 상태(꺼짐 · 초점 · 평소, 오류)에서 그릴 테두리 — SDK `input_decorator.dart` 가 고르는 차례와 같다
|
||||
/// (상태의 테두리가 없으면 `border`)
|
||||
InputBorder? shownInputBorder(InputDecorator decorator) {
|
||||
final InputDecoration decoration = decorator.decoration;
|
||||
final bool error = decoration.errorText != null || decoration.error != null;
|
||||
final InputBorder? border = !decoration.enabled
|
||||
? (error ? decoration.errorBorder : decoration.disabledBorder)
|
||||
: decorator.isFocused
|
||||
? (error ? decoration.focusedErrorBorder : decoration.focusedBorder)
|
||||
: (error ? decoration.errorBorder : decoration.enabledBorder);
|
||||
return border ?? decoration.border;
|
||||
}
|
||||
|
||||
/// 선이 보이는 테두리인가 — 없음([InputBorder.none]) · 두께 0 은 보이지 않는다
|
||||
bool isDrawnInputBorder(InputBorder? border) =>
|
||||
border != null &&
|
||||
border.borderSide.style != BorderStyle.none &&
|
||||
border.borderSide.width > 0;
|
||||
|
||||
/// [field](TextField 를 찾는 Finder) 의 InputDecorator 가 받은 꾸밈에 보이는 테두리 · 바탕이 없다 — 여섯 테두리와 지금 상태의 테두리를 본다
|
||||
/// (앱 테마의 입력칸 테두리를 물려받지 않았는지). 상태(초점 · 꺼짐)를 더 볼 수 있게 그 InputDecorator 를 돌려준다
|
||||
InputDecorator expectBorderlessInput(
|
||||
WidgetTester tester,
|
||||
Finder field, {
|
||||
String? reason,
|
||||
}) {
|
||||
final InputDecorator decorator = tester.widget<InputDecorator>(
|
||||
find.descendant(of: field, matching: find.byType(InputDecorator)),
|
||||
);
|
||||
final InputDecoration decoration = decorator.decoration;
|
||||
final Map<String, InputBorder?> borders = <String, InputBorder?>{
|
||||
'border': decoration.border,
|
||||
'enabledBorder': decoration.enabledBorder,
|
||||
'focusedBorder': decoration.focusedBorder,
|
||||
'disabledBorder': decoration.disabledBorder,
|
||||
'errorBorder': decoration.errorBorder,
|
||||
'focusedErrorBorder': decoration.focusedErrorBorder,
|
||||
'지금 상태': shownInputBorder(decorator),
|
||||
};
|
||||
borders.forEach((String name, InputBorder? border) {
|
||||
expect(
|
||||
isDrawnInputBorder(border),
|
||||
isFalse,
|
||||
reason: '${reason ?? ''} $name: $border',
|
||||
);
|
||||
});
|
||||
expect(decoration.filled, isFalse, reason: reason);
|
||||
return decorator;
|
||||
}
|
||||
+1
-1
@@ -1809,7 +1809,7 @@ mailcow 를 흉내 낸 Dovecot(`dovecot/dovecot:2.3.21.1@sha256:547d28f9a893c93a
|
||||
| --- | --- | --- |
|
||||
| 메일 엔진 `<PROJECT_NAME>_mail-config` · `_mail-data` | 볼륨 (읽기 전용으로 붙인다) | RocksDB — 메일 · 계정 · 설정 · DKIM 키 · 인증서 전체. `config.json` 이 없으면 bootstrap 모드로 뜬다 |
|
||||
| DB | `pg_dump -Fc` (멈추지 않는다) | 로그인 세션 · 서명 · 설정 · 대용량 첨부 기록 등. 덤프를 `pg_restore -l` 로 읽어 본 뒤 올린다 |
|
||||
| 업로드 `<PROJECT_NAME>_uploads` | 볼륨 | 서명 그림(`mail-signatures/`) · `/api/files` · 휴대폰 앱 APK(`app-releases/` — 판마다 30MB 남짓, 지우는 명령이 없어 올린 판이 모두 남는다) |
|
||||
| 업로드 `<PROJECT_NAME>_uploads` | 볼륨 | 서명 그림(`mail-signatures/`) · `/api/files` · 휴대폰 앱 APK(`app-releases/` — 판마다 65MB 안팎(1.0.0 이 64.4MB — 기기 종류 셋(ABI)이 한 파일에 든다), 지우는 명령이 없어 올린 판이 모두 남는다) |
|
||||
| env 파일 | `.env.production` · `backend/.env.production` | 비밀이 든 파일 — 저장소가 restic 비밀번호로 암호화된다. 새 서버를 되살릴 때 필요하다 |
|
||||
| 받지 않는 것 | — | 대용량 첨부 `_files`(30일짜리 전송물 — 기록은 DB 에 있어 복원하면 바이트가 없는 파일은 "찾을 수 없음"), 악성 검사 서명 `_clamav-db`(다시 받는다), 리졸버 · frontend(상태 없음), 로그 |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user