Compare commits

..

6 Commits

Author SHA1 Message Date
ttipo 198ac8f4f2 chore(flutter): 앱 판을 1.0.1 로 올린다
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 22m14s
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Successful in 13m2s
CI / Frontend (lint · type-check · test · build) (pull_request) Successful in 13m25s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (pull_request) Successful in 2m6s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m25s
CI (Flutter) / Flutter (analyze · test) (push) Successful in 22m26s
CI / Backend (lint · type-check · test · e2e · build) (push) Successful in 13m44s
CI / Frontend (lint · type-check · test · build) (push) Successful in 12m40s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (push) Successful in 2m11s
CI / 에러 코드 SSOT 동기화 검사 (push) Successful in 11m3s
읽기 화면의 빈 본문과 쓰기 · 검색 입력칸 고침을 직원 휴대폰에 내보낼 판이다.
최소 판(app-config 1.0.0)은 그대로라 1.0.0 을 쓰는 휴대폰도 막지 않는다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 08:11:02 +09:00
ttipo b644e8b104 docs: 앱 읽기 화면의 빈 본문과 쓰기 화면 입력칸 규칙을 앱 디자인 README · flutter 스킬에 적는다
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 08:10:34 +09:00
ttipo bd916ccbed fix(flutter): 쓰기 · 검색 입력칸에 테마의 둥근 테두리가 그려지던 것을 고치고 칸 줄을 보드에 맞춘다
InputDecoration.collapsed 는 enabledBorder · focusedBorder 를 비워 두어 TextField 가
앱 테마의 둥근 테두리로 채운다. 그래서 받는 사람 · 제목 · 본문 · 검색 칸에 글자를
감싼 테두리가 그려지고 초점이면 칸 줄의 아래 선과 청록 테두리가 겹쳤다
(11-12 배포 뒤 사용자 보고). 여섯 테두리를 모두 없앤 borderlessInputDecoration 으로
바꾸고, lib 에 InputDecoration.collapsed 가 다시 들어오면 시험이 실패하게 한다.

함께 칸 줄을 보드 App1Compose 에 맞췄다: 칸 이름 15 · 간격 6 · 오른쪽 여백 4,
칩이 여러 줄이면 칸 이름과 [참조 펼치기]를 첫 칩 줄에, 안내 글 색 · 칩 ✕ 색,
본문 칸 최소 높이(원본 메일이 있으면 150 · 없으면 서명이 있어도 240).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 08:10:34 +09:00
ttipo 94ec659b78 fix(flutter): 본문 없이 첨부만 있는 메일에서 읽기 화면 아래가 비고 첨부가 잘리던 것을 고친다
읽기 화면은 머리(제목 · 보낸 사람 · 안내 · 첨부)가 남은 높이의 절반까지만 쓰고
나머지는 본문 WebView 였다. 본문이 비면 아래 절반이 빈 칸으로 남고 첨부가 많으면
위 절반 안에서 잘렸다(11-12 배포 뒤 사용자 보고).

정화한 본문에 보이는 것(글자 · 주소가 남은 그림 · hr · li · details · q)이 없으면
상태 bodyEmpty 로 본문 칸을 두지 않고 머리가 화면 전체를 한 스크롤로 쓴다.
판정은 정화와 같은 길에서 내고(큰 본문은 isolate), 아래 막대가 없는 예약 메일은
머리 스크롤에 시스템 아래 여백을 더한다. 본문이 있는 메일은 그대로다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 08:10:34 +09:00
ttipo 487760bd9b docs: 휴대폰 앱 APK 크기와 apksigner 의 Java 를 바로 적는다
첫 release APK(1.0.0)는 기기 종류 셋(ABI)이 한 파일에 들어 64.4MB 였다 —
문서의 "판마다 30MB 남짓"을 고친다. 개발 PC 에 java 가 PATH 에 없으면
apksigner 가 결과 없이 끝나 서명 확인을 놓치므로 Android Studio 의 Java 를
먼저 잡는 줄을 flutter/README 사내 배포에 더한다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 00:28:55 +09:00
ttipo 37ffcfca2c docs: 로드맵에 11월 12일 운영 배포를 적는다
14 · 15 · 16단계 · 앱 서버 · 업데이트 내역을 11-12 0시에 운영에 올렸고
휴대폰 앱 1.0.0 을 나눠 주기 시작했다. 배포 뒤 받은 의견(앱 읽기 화면 ·
쓰기 화면 입력칸)은 앱 1.0.1 로 고치는 중이다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 00:28:54 +09:00
25 changed files with 1219 additions and 78 deletions
+2 -2
View File
@@ -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/` — 아래 "백업 · 감시")에 든다.
+7 -3
View File
@@ -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` 가 경고**한다.
+9
View File
@@ -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
View File
@@ -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
View File
@@ -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'`).
+2 -2
View File
@@ -171,10 +171,10 @@ abstract final class AppSize {
/// 쓰기 화면의 첨부 줄 파일 아이콘 칸
static const double composeFileIconBox = 36;
/// 본문 칸의 최소 높이 — 새 메일(원본 메일이 아래에 없다)
/// 본문 칸의 최소 높이 — 원본 메일이 아래에 없는 쓰기(새 메일 등 — 서명은 있어도)
static const double composeBodyMinHeight = 240;
/// 본문 칸의 최소 높이 — 답장 · 전달(아래에 서명 · 원본 메일)
/// 본문 칸의 최소 높이 — 답장 · 전달(아래에 원본 메일)
static const double composeBodyMinHeightWithQuote = 150;
/// 원본 메일 접힌 줄의 최소 높이
+28
View File
@@ -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> {
+56
View File
@@ -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), // 태그 글자 · 이체 선택자 보충
];
+51 -18
View File
@@ -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,
+1 -1
View File
@@ -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>&nbsp;</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>&nbsp;</o:p></p>'),
isFalse,
);
expect(
visibleHtml(
'<div style="font-family:Pretendard;color:#1F2733"><p></p></div>',
),
isFalse,
);
// 제로폭 빈칸 · BOM · WORD JOINER · 한글 채움 문자 · 점자 빈칸
expect(
visibleHtml('<p>&#x200B;&#xFEFF;&#x2060;&#x3164;&#x2800;</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>&nbsp;</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>&nbsp;</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,
+52
View File
@@ -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
View File
@@ -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(상태 없음), 로그 |