diff --git a/.claude/skills/flutter/SKILL.md b/.claude/skills/flutter/SKILL.md index a1b557e..f3b6e55 100644 --- a/.claude/skills/flutter/SKILL.md +++ b/.claude/skills/flutter/SKILL.md @@ -21,10 +21,16 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S | **모듈 목록** | `lib/core/modules/app_modules.dart` | **짝: 웹 `frontend/src/shared/constants/app-modules.ts`** — 같은 id · 이름 · 차례(한쪽을 바꾸면 둘 다). 항목마다 id · 이름 · 아이콘 · 첫 화면 경로(`/`) · 켜짐. 웹은 준비 중 모듈을 꺼진 채 보이지만 앱은 **켜진 모듈만** 탭이 된다(첫 판: 메일 · 설정) | | 모듈 라우트 묶음 | `lib/features/<모듈>/<모듈>_routes.dart` → `lib/core/router/module_registry.dart` | 모듈마다 `ModuleRoutes`(`core/modules/module_routes.dart` — 탭 안 라우트 · 알림 id → 경로 · 설정 절) 하나. registry 의 exhaustive switch 가 id → 묶음. 켰는데 묶음이 없으면 라우터를 만들 때 `StateError` | | 라우터 · 탭 막대 | `lib/core/router/` — `app_router.dart` · `app_shell.dart` · `session_redirect.dart` · `app_routes.dart` | go_router. 로그인 전 · 제한 단계(`/start` · `/login/...`)는 셸 밖, 로그인 뒤는 `StatefulShellRoute.indexedStack`(켜진 모듈마다 탭 하나 — 탭마다 이동 기록을 지킨다). 아래 탭 막대(`NavigationBar`)가 웹의 전역 앱 레일이다. 읽기 · 쓰기 같은 전체 화면은 `parentNavigatorKey: rootNavigatorKey`. `redirect` 하나(`resolveSessionRedirect` — 순수 함수)가 세션 상태로 화면을 고르고, 모르는 · 꺼진 모듈의 주소는 메일 첫 화면(`onException` 도) | -| **세션** | `lib/features/auth/presentation/providers/session_provider.dart` | `AsyncNotifier` 하나(`sessionProvider` — 저절로 다시 하지 않는다: Riverpod 3 의 `retry: null` 은 기본 다시 하기라 늘 null 을 돌려주는 `noAutomaticRetry` 를 준다). 상태 `SignedOut` · `CodeRequired` · `SignedIn(info.restriction)` + 되살리는 중(`AsyncLoading`) · 되살리기 장애(`AsyncError`). 로그인 때 넣은 비밀번호는 상태가 아니라 `LoginSecret`(메모리 · 10분 — 코드 단계 · 제한 단계가 쓴다). 인터셉터와는 `SessionEventBus`(`core/api/session_events.dart`)로만 잇는다 — 세션 끝 · `AUTH_010` 다시 맞추기(`SessionRecheckNeeded` 에 답 `RestrictionCheck` — renewed · restricted · stale · unknown, 웹과 같다) · 갱신 응답의 제한. **시도 세대**: 로그아웃 · [다른 계정으로 로그인] · 세션 끝 때 올라 그 전에 시작한 되살리기 · 로그인 · 코드 · 다시 맞추기의 늦은 결과를 버린다(버린 결과에 토큰이 있으면 조용히 앱 logout). 로그아웃은 토큰 · 상태를 먼저 지우고 서버 logout · 알림 토큰 지우기는 그 뒤. 제한이 풀리면 쥔 비밀번호를 지운다 | +| **세션** | `lib/features/auth/presentation/providers/session_provider.dart` | `AsyncNotifier` 하나(`sessionProvider` — 저절로 다시 하지 않는다: Riverpod 3 의 `retry: null` 은 기본 다시 하기라 늘 null 을 돌려주는 `noAutomaticRetry` 를 준다). 상태 `SignedOut` · `CodeRequired` · `SignedIn(info.restriction)` + 되살리는 중(`AsyncLoading`) · 되살리기 장애(`AsyncError`). 로그인 때 넣은 비밀번호는 상태가 아니라 `LoginSecret`(메모리 · 10분 — 코드 단계 · 제한 단계가 쓴다). 인터셉터와는 `SessionEventBus`(`core/api/session_events.dart`)로만 잇는다 — 세션 끝 · `AUTH_010` 다시 맞추기(`SessionRecheckNeeded` 에 답 `RestrictionCheck` — renewed · restricted · stale · unknown, 웹과 같다) · 갱신 응답의 제한. **시도 세대**: 로그아웃 · [다른 계정으로 로그인] · 세션 끝 때 올라 그 전에 시작한 되살리기 · 로그인 · 코드 · 다시 맞추기의 늦은 결과를 버린다(버린 결과에 토큰이 있으면 조용히 앱 logout). 로그아웃은 토큰 · 상태를 먼저 지우고, 그 뒤 서버 logout 과 이 기기의 알림 멈추기(알림창 지우기 · FCM 등록 풀기)를 함께 보낸다(서로 기다리지 않는다). 제한이 풀리면 쥔 비밀번호를 지운다 | | 앱 로그인 저장소 | `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`)는 알리지 않는다 | -| HTTP 클라이언트 | `lib/core/api/dio_client.dart` · `dio_provider.dart` | Dio 둘: 화면용(인터셉터 줄 `ResponseInterceptor` → `AuthInterceptor` → `ErrorNotifyInterceptor`)과 갱신 · 다시 보내기용(언래핑만). baseUrl=dotenv `API_URL`(`api_config.dart`), User-Agent `ARASYSTEM-App/<판> (Android <판>; <기종>)`(`user_agent.dart`). `LogInterceptor` 는 넣지 않는다(비밀번호 · 토큰). 시험은 `httpClientAdapterProvider` 로 가짜 서버를 끼운다 | -| 언래핑 · 코드 정규화 | `lib/core/api/response_interceptor.dart` · `api_error_mapper.dart` | `{success,data}→data`(스트림 · 바이트는 그대로), 200 + `success:false` 도 실패, 실패는 모두 `DioException.error` 에 `ApiException`. 상태 → 코드 폴백(웹 `resolveCodeByStatus` 와 같다) | +| **로그인 화면 (네 단계 — `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)` 만, 실패 예외에 주소가 들어 있어 삼킨다, `` 는 필요 없다. 한글 줄바꿈은 `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` | +| 받은 파일 · 고른 파일 · 형식 | `lib/core/files/` — `downloaded_files.dart`(`DownloadedFileStore` · `CacheDirFileStore` · `FileOpener`) · `picked_files.dart`(`PickedFileStore` · `PlatformPickedFileStore` — 채널의 `pick`) · `file_names.dart`(`safeFileName` · `displayFileName`) · `file_types.dart`(`fileOpenTypeOf` · `isBlockedFileOpen`), 안드로이드 `MainActivity.kt`(채널 `kr.it.arasystem.app/files`) · `res/xml/downloaded_file_paths.xml` · 매니페스트의 FileProvider `${applicationId}.downloads` | 모듈 공통(메일 첨부 · 나중의 파일 모듈). 자리는 `cacheDir/downloads/<무작위 16자>/<정리한 이름>` 뿐 — **짝 셋**: Dart `kDownloadedFilesFolder` · `downloaded_file_paths.xml` 의 cache-path · `MainActivity.kt` 의 `DOWNLOADS_DIR`(채널 이름도 짝). Kotlin 은 canonical 경로로 그 폴더 안인지 다시 보고 읽기 권한을 한 번만 준다. 파일 여는 플러그인(open_filex 등)은 쓰지 않는다 — 저장소 권한 · 기기 전체 경로를 더한다. 열 형식은 확장자 먼저(`package:mime`), 보낸 쪽 형식은 확장자를 모를 때만 — 형식 글 모양 검사는 짝: Kotlin `MIME_PATTERN` · `GENERIC_TYPE`. 막는 형식(웹 문서 · svg · 설치 파일 · 인증서)은 받지도 않는다. **고른 파일**(메일 쓰기 2차): 고르는 창(`ACTION_GET_CONTENT` — 권한 없음 · `*/*` · 여러 개)으로 고른 파일을 `cacheDir/uploads/<무작위 16자>/<정리한 이름>` 에 복사본으로 둔다 — **짝 둘**: Dart `kPickedFilesFolder` · `MainActivity.kt` 의 `UPLOADS_DIR`(다른 앱에 건네지 않아 FileProvider xml 에는 없다). 채널 인자 · 답의 칸 · 값도 짝이다 — Dart `PlatformPickedFileStore.pick` · `PickResult.parse` · `PickedFile.fromChannel` ↔ Kotlin `ARG_*` · `KEY_*` · `OUTCOME_*` · `STATUS_*`. Kotlin 은 `content://` 만 읽고, Dart 사전 검사와 같은 차례(개수 → 형식 → 파일당 → 합계 — `copyPicked` ↔ `precheckPickedFile`)로 넣을 파일만 복사한다 — 미리 거르기일 뿐 판정은 Dart 가 다시 한다. 확장자를 떼는 규칙도 짝(Kotlin `displayFileName` · `extensionOf` · `isDartWhitespace` ↔ Dart `displayFileName` · `extensionOf` · `trim()`). 복사본 이름은 Kotlin `safeFileName`(Dart 와 같은 규칙), 보이고 보내는 이름은 `displayFileName` 이다. 복사 스레드는 Throwable 까지 잡아 한 번만 답한다. 고르기와 모두 지우기는 차례로 돌고, 지우기는 기호 링크를 푼 경로로 복사본 폴더 바로 아래 한 칸만 지운다. `pick` 은 그 차례 안에서 `kPickTimeout`(10분) 시한을 둔다 — 넘으면 `timedOut`(Dart 에만 있는 결과)으로 끝내고 차례를 넘긴다(늦게 온 복사본은 다음 정리에). 파일 고르는 플러그인은 쓰지 않는다(권한 · 기기 전체 경로) | +| **메일 쓰기 (`App1Compose` — 1차 · 2차)** | `lib/core/people/`(사람 고르기 — 주소 칩 · 추천 맞추기 · 그룹, 모듈 공통. 표본 표 `test/core/people/recipient_match_samples.dart` 는 웹 · 백엔드와 **세 쪽 짝**) · `features/mail/domain/`(`compose_body` 직렬화 · `compose_fit`(`buildInitialQuote`) · `mail_quote` · `compose_draft_body`(`buildDraftBody` · `draftBodyPlaceOf`) · `compose_reply` · `compose_send` · `compose_attachments`(2차 — 형식 표 · 한도 · 사전 검사) · `compose_signature` · `html_nodes`) · `data/`(`compose_models` · `compose_payload` · `repositories/compose_repository.dart`) · presentation(`pages/message_compose_page.dart` · `providers/compose_controller.dart`(화면마다 하나 — `ComposeArgs.instance`, autoDispose) · `draft_autosave.dart` · `attachment_uploads.dart`(`AttachmentUploadQueue` — 둘씩 · 취소 · 다시 올리기) · `signature_image_provider.dart` · widgets `compose_*`) · `core/utils/utf16_length.dart` · 시험 도구 `test/support/compose_fakes.dart` | 본문 모양은 계약 2절 "앱이 만드는 모양". 웹 짝(`mail-address.ts` · `recipient-match.ts` · `MAIL_HTML_STYLES` · `docToText` · `fit-draft` · `DRAFT_*` · `shouldRenewSendKey`)은 각 파일 머리말에 적었다 — 한쪽을 고치면 둘 다. **package:html 의 `text` · `querySelectorAll` · `innerHtml` · `outerHtml` 은 깊이만큼 되풀이한다** — 쓰기 화면은 `html_nodes.dart`(되풀이 없는 글 모으기 · 찾기 · 직렬화)를 쓰고, 저장본 · 원문은 읽기 화면과 같은 한도 · 문턱(`mailBodyBuildPlaceOf`)으로 그 자리 · isolate · 읽지 않음을 고른다. **화면을 떠나도 끝까지 가야 하는 일(보내기)은 기다림을 시작하기 전에 `ref.keepAlive()` 를 잡고 try/finally 로 놓는다**(2026-11-07 리뷰 — 저장을 기다리는 사이 내려가 메일이 나가지 않았다). 임시 저장은 Timer 만 쓴다(시험은 `tester.pump` 로 시간을 넘긴다). 보내기 결과는 `mailToastProvider` 의 마감 알림(목록 · 읽기 화면이 그린다). 서버와 같은 한도를 거는 칸은 `Utf16LengthLimitFormatter`(UTF-16 단위). **첨부(2차)**: 형식 표 `kMailAttachmentTypes` 는 **짝: 백엔드 `attachment.policy.ts` 의 `ATTACHMENT_TYPES`** — 시험이 그 파일을 읽어 표와 `MAX_ATTACHMENTS_PER_MESSAGE` 를 맞춰 본다 (앱 CI 는 flutter/ 가 바뀐 커밋에서만 돈다 — 백엔드 표만 고친 커밋에서는 이 시험이 돌지 않으니 함께 고친다). 사전 검사는 웹 `precheckAttachment` 의 차례, 보내기 막기는 웹 `sendBlockOf` 의 차례(가져오는 중은 올리는 중 다음). 저장소 `uploadAttachment`(multipart `file` · 확장자의 정식 형식 · 보내기 5분 · 받기 60초 · 알림 끔 — 401 뒤에는 FormData 를 복제해 복사본을 처음부터 다시 읽는다) · `getAttachmentLimits`(`GET /api/mail-quota`). 다 올리면 서버의 이름 · 크기로 싣는다(이름 255자 — 웹과 같다, 2026-11-07 리뷰). `MAIL_004` 뒤 다시 올릴 수 없는 고른 파일은 `problem: missing` 으로 막는다(웹 `markMissing`). 시험 도구 `FakePickedFiles`(`test/support/file_fakes.dart`) · `FakeComposeBackend` 의 `uploads` · `uploadGates` · `quota` · `quotaGate` · `FakeServer.readRequestBodies` | +| **메일 검색 (`App1Search` — 1차 · `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` 와 같다) | | **ApiException** | `lib/core/api/api_exception.dart` | `{code, message, traceId, statusCode}`. 화면은 `apiErrorCodeOf(e)` 또는 `e.error as ApiException` 의 **`code`** 로 가른다 | | ErrorLexicon | `lib/core/api/error_lexicon.dart` | 사전 접근 API. 실제 문구는 `error_lexicon.g.dart`(**자동 생성, 수정 금지**) ([[error-codes]]) | | **인증 인터셉터** | `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 — 같은 줄로 보내면 서로 기다린다 | @@ -32,7 +38,9 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S | 토큰 보관 | `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`), 그 밖의 역할은 임시 값 | | 글꼴 | `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` 에 올린다(설정 › 앱 정보 › 오픈소스 라이선스) | -| 푸시 기기 등록 자리 | `lib/core/push/` — `push_registration.dart` · `push_target.dart` | 모듈 공통. 지금은 `NoopPushRegistrar`(서버 `/api/push-devices` 가 생기면 채운다 — 세션 노티파이어가 로그인 뒤 `register` · 로그아웃 전 `unregister` 를 부른다). 알림 데이터에는 **모듈 이름과 id 만**(`{module, id}`) — `locationForPush` 가 모듈의 `locationOfItem` 으로 경로를 고른다 | +| **새 메일 알림 (`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/`(모듈이 아니다 — 화면 · `AppStoreLauncher` · 글) | `GET /api/app-config` 는 띄울 때 한 번 · 앞으로 올 때 마지막으로 받은 지 1시간이 지났으면(실패는 1분 간격으로 다시) — 알리지 않고 막지 않는다. 404 `RES_001` 은 옛 서버(최소 판 없음 · 알림 쓸 수 없음). 판은 숫자 1~3칸 · 칸마다 4자리까지(서버의 판 칸 `APP_VERSION_PATTERN` 과 같다 — `-` · `+` 뒤는 버린다), 이 앱의 판을 모르면 막지 않는다. 업데이트 화면으로 옮길 때 쓰기 화면은 마저 저장한다(`ComposeController.detach(saveRemaining: true)` — 화면이 밖에서 닫힌 때도). [Play 스토어 열기]는 `market://details` → 웹 주소, 패키지 이름은 `appPackageNameProvider`(main 이 기기 정보로) | +| **설정 탭 (`App1Shell` · `App1Push` ④)** | `lib/features/settings/` — `settings_routes.dart`(`/settings` · `/settings/licenses` · 모듈이 내놓은 절) · `presentation/pages/settings_page.dart` · `widgets/settings_notification_row.dart` · `settings_texts.dart` | 한 화면(내 정보 · 알림 · 모듈 절 · 앱 정보 · 문의 · [로그아웃]). 문의는 로그인 화면과 같은 저장소(`supportContactProvider`), [로그아웃]은 확인 창(`shared/widgets/confirm_dialog.dart` — 처음 초점 [취소]). 스위치 색은 테마의 `switchTheme`(보드 — 켬 청록 · 끔 테두리 · 누를 수 없음 회색) | | 자리 화면 | `lib/shared/widgets/placeholder_page.dart` | 화면은 캔버스로 확인받은 뒤 만든다 — 그때까지 이름(과 갈 곳)만 보이는 자리 화면. 확정되면 라우트 묶음의 builder 를 바꾼다 | | 예제 feature(템플릿) | `lib/features/user/` | 목록 페이지네이션(`{items, pageInfo}` — 단건 + 커서 목록) 예제로만 남긴 템플릿. 앱 API 에 `/users` 는 없다 | | 시험 도구 | `test/support/` — `fakes.dart` · `provider_harness.dart` | 가짜 서버(`FakeServer` — HttpClientAdapter) · 메모리 보안 저장소 · 손 시계(`ManualClock`) · 알림 기록(`RecordingNotifier`). 고정 대기 없이 Completer 로 차례를 맞춘다 ([[testing]]) | @@ -71,12 +79,14 @@ lib/ │ ├── api/ ← Dio(dio_client) · 인터셉터 셋 · 토큰 갱신 · 에러 사전 · User-Agent │ ├── constants/ ← 앱 이름 · 로그인 단계 화면 이름 · 앱 판 │ ├── modules/ ← 모듈 목록(app_modules — 웹과 짝) · 라우트 묶음 꼴(module_routes) -│ ├── push/ ← 푸시 기기 등록 자리 · 알림 → 경로 +│ ├── app_config/ ← 앱 설정(최소 판 · 알림을 보낼 수 있는가) +│ ├── push/ ← 새 메일 알림(FCM · 채널 · 서버 등록 맞추기) · 알림 → 경로 │ ├── router/ ← 라우터 · 탭 막대 · redirect · 모듈 묶음 모으기(module_registry) │ ├── storage/ ← 토큰 보관 · 보안 저장소 · 첫 실행 표시 │ ├── theme/ ← 색 · 간격 · 글자(임시) · 글꼴 │ └── utils/ ← 순수 유틸 함수 (시계 등) ├── features/ +│ ├── app_update/ ← 업데이트 화면 (모듈이 아니다 — 서버의 최소 판보다 낮을 때) │ ├── auth/ ← 세션 · 앱 로그인 (모듈이 아니다 — 모든 모듈이 쓴다) │ ├── mail/ ← 모듈 — 주소 /mail/... │ │ ├── mail_routes.dart ← 이 모듈의 라우트 묶음 (core/router 가 모은다) @@ -283,9 +293,22 @@ await tokenStore.clear(byUser: true); // 로그아웃 (세션 끝은 byUser - [ ] **화면**: 캔버스로 확인받은 뒤 만든다 — 그때까지 자리 화면(`PlaceholderPage`). 글자 크기는 `textTheme` 역할 이름으로만, 누르는 곳은 48dp 이상. - [ ] **권장 패키지**: 새 패키지는 배포 2주가 지난 판만, `pubspec.yaml` 추가 후 `flutter pub get`. 모델 생성기를 넣을 때는 위 "모델 생성기" 주의(build.yaml `generate_for`). - [ ] **analyze/test**: 커밋 전 `flutter analyze` (Warning 0) + `flutter test` 통과. 배포 판은 `flutter build apk` 로 한 번 본다(release 매니페스트에 INTERNET 권한이 있어야 네트워크가 된다). + Kotlin(`MainActivity.kt`)을 고쳤으면 커밋 전에 `flutter build apk --debug` 로 컴파일을 본다 — CI 는 안드로이드를 빌드하지 않고, + Kotlin 쪽 짝(고른 파일의 미리 거르기 · 이름 규칙)에는 시험이 없다(Dart 가 판정을 다시 하고, 어긋남은 앱 README 의 실기기 목록으로 본다). Flutter 판은 위 "Flutter 판"(3.41.9) — CI 도 같은 판이다. - CI(`ci-flutter.yml`)는 **flutter/ 가 바뀐 커밋에서만** 자동 실행된다 (SDK 다운로드가 느려 분리). + CI(`ci-flutter.yml`)는 **flutter/ 와 앱 짝 시험이 읽는 백엔드 파일 둘(`attachment.policy.ts` · `test/search-text-samples.ts`)이 바뀐 커밋에서만** 자동 실행된다 (SDK 다운로드가 느려 분리). 다른 시점에 검사가 필요하면 Actions 탭의 "Run workflow" 로 수동 실행한다. +- [ ] **위젯 · 시험 함정**(쓰기 · 검색 화면에서 찾음 — 2026-11-07): + `TextEditingController` 는 초점 · 선택만 바뀌어도 알린다 — 바꿈은 글을 견줘 가른다. + go_router 의 `context.pop()` 은 `NavigatorState.pop` 이라 PopScope 의 `canPop` 을 보지 않는다 — 화면이 스스로 닫을 때는 자기 표시로 가린다. + `ref` 는 `State.dispose` 에서 쓰지 못한다 — 노티파이어를 필드에 쥔다(`initState` 에서 `listenManual` + `read`). + 되풀이 타이머(남은 시간 막대 250ms)는 `pumpAndSettle` 간격(100ms)보다 길게 둔다. isolate 를 쓰는 일(원문 인용)은 위젯 시험에서 프로바이더로 덮어쓴다(`composeQuoteBuilderProvider`). + 소스에 보이지 않는 글자를 두지 않는다 — nbsp 는 `'\u00A0'` 이스케이프로 적는다. + 기기 파일 입출력(고르기 · 복사본)은 시험 시계 안에서 끝나지 않는다 — `PickedFileStore` 를 덮어쓴다(`FakePickedFiles`). + 무한 진행 막대(고르는 동안)가 떠 있으면 `pumpAndSettle` 이 끝나지 않는다 — 그동안은 `pump()` 로 넘긴다. + 가짜 서버는 요청 본문을 읽지 않아 보내는 진행(onSendProgress)이 오지 않는다 — 진행을 보려면 `FakeServer.readRequestBodies` 를 켠다. + InkWell 에 길게 누르기 처리가 없으면 길게 눌렀다 뗄 때 onTap 이 돈다 — 열지 않을 줄은 길게 누르기를 받아 두고 아무것도 하지 않는다(검색 결과 줄). + 두 줄 TextField 에도 Scrollable 이 있다 — 목록 스크롤은 ListView 아래에서 찾는다(`find.descendant`). - [ ] **린트로 강제되는 항목**: `dynamic` 호출·`print`·`const` 누락·await 누락·작은따옴표·import 정렬 (`analysis_options.yaml`). - [ ] **env**: `flutter/.env.example` → `.env` 복사 후 `API_URL` 설정 (Android 에뮬레이터는 `10.0.2.2`). `.env` 는 `pubspec.yaml` 의 asset 이라 **없으면 `flutter analyze` 가 경고**한다. diff --git a/.github/workflows/ci-flutter.yml b/.github/workflows/ci-flutter.yml index 1ab1353..aa9196d 100644 --- a/.github/workflows/ci-flutter.yml +++ b/.github/workflows/ci-flutter.yml @@ -7,6 +7,8 @@ name: CI (Flutter) # # 언제 도는가: # - flutter/ 아래 파일이 바뀐 푸시·PR (= 실제로 Flutter 를 쓸 때 자동으로) +# - 앱 짝 시험이 그대로 읽는 백엔드 파일이 바뀐 푸시·PR — 첨부 형식 표(attachment.policy.ts) · 검색 표본 표(search-text-samples.ts). +# 그 파일만 고친 커밋에서도 앱 시험이 깨지는지 본다 (2026-11-07) # - Actions 탭의 "Run workflow" 버튼으로 수동 실행 # # 모바일 앱을 아예 쓰지 않는 프로젝트라면 이 파일을 지우면 된다. @@ -16,10 +18,14 @@ on: branches: [main] paths: - 'flutter/**' + - 'backend/src/modules/mail/attachment.policy.ts' + - 'backend/test/search-text-samples.ts' - '.github/workflows/ci-flutter.yml' pull_request: paths: - 'flutter/**' + - 'backend/src/modules/mail/attachment.policy.ts' + - 'backend/test/search-text-samples.ts' - '.github/workflows/ci-flutter.yml' workflow_dispatch: diff --git a/backend/src/modules/mail/attachment.policy.ts b/backend/src/modules/mail/attachment.policy.ts index bc8b821..4a05fc4 100644 --- a/backend/src/modules/mail/attachment.policy.ts +++ b/backend/src/modules/mail/attachment.policy.ts @@ -51,6 +51,9 @@ interface AttachmentTypeRule { * 브라우저는 OS 에 등록되지 않은 형식(한컴오피스가 없는 PC 의 .hwp 등)을 octet-stream 으로 보내기 때문이다. * 이미지·PDF·텍스트처럼 브라우저가 그릴 수 있는 형식은 정확한 MIME 만 받는다. * 어느 쪽이든 내려줄 때는 항상 octet-stream + attachment 이므로 내용이 실행되지 않는다. + * + * **짝: flutter `lib/features/mail/domain/compose_attachments.dart` 의 `kMailAttachmentTypes`**(확장자 · 정식 형식) — 앱 시험이 이 파일을 읽어 + * 이 표와 `MAX_ATTACHMENTS_PER_MESSAGE` 를 맞춰 본다. 앱 CI 는 flutter/ 가 바뀐 커밋에서만 도니 표를 고치면 앱 쪽도 함께 고친다. */ const ATTACHMENT_TYPES: ReadonlyMap = new Map([ ['.pdf', { type: 'application/pdf', accept: ['application/pdf'] }], diff --git a/backend/test/search-text-samples.ts b/backend/test/search-text-samples.ts index bbbbfc2..1a48fdd 100644 --- a/backend/test/search-text-samples.ts +++ b/backend/test/search-text-samples.ts @@ -4,6 +4,8 @@ * * ⚠️ frontend `src/test-utils/search-text-samples.ts` 와 **같은 표**다(행과 순서까지 그대로). * 한쪽을 고치면 둘 다 고친다 — 접기 · 위치 규칙을 바꾸면 두 쪽 함수와 두 표를 함께 (서버가 준 맞은 곳의 위치를 화면이 그대로 그린다). + * 앱 시험(`flutter/test/features/mail/search_text_test.dart`)도 이 파일의 `FOLD_SAMPLES` · `GRAM_SAMPLES` 를 그대로 읽는다(작은따옴표 글의 배열 · 이스케이프 · 주석만 읽는다) + * — 표의 모양을 바꾸면 앱 시험도 본다(이 파일이 바뀌면 Flutter CI 도 돈다). * 답은 손으로 적었고 두 쪽 테스트가 함수의 결과와 견준다. 단위 테스트는 `@/../test/search-text-samples` 로 부른다 (cpu-time.ts 와 같다). */ diff --git a/docs/design/app/README.md b/docs/design/app/README.md index f1729f2..d666e24 100644 --- a/docs/design/app/README.md +++ b/docs/design/app/README.md @@ -29,6 +29,27 @@ - 2단계 켜기는 같은 휴대폰이라 QR 을 찍을 수 없다 — [인증 앱에 추가](인증 앱을 바로 연다) · 키와 [복사](키를 직접 넣는 길) · 코드 칸. 인증 앱을 찾지 못하면 키를 직접 넣으라고 알린다. - 웹 첫 화면의 메일 프로그램(IMAP) 안내 줄은 넣지 않는다(앱이 메일 앱이다). 문의 연락처 줄은 웹과 같은 자리. +- **구현하며 정한 것**(2026-11-06 — 보드에는 아직 없다) + - 실패를 보이는 곳(웹 로그인 틀과 같은 결 — 화면이 칸 · 상자에 보이는 코드만 알림을 끈다): + + | 단계 | 칸 아래 | 위쪽 상자 | 그 밖 | + | --- | --- | --- | --- | + | 주소 · 비밀번호 | `VAL_001`(주소 칸) | `AUTH_004`(두 칸 빨강 · 비밀번호 비움) | 전역 알림(`REQ_001` 포함 — 웹과 같다) | + | 인증 앱 코드 | `AUTH_006` · `AUTH_007` · `VAL_001`(칸을 비움) | `REQ_001`(코드 그대로) | `AUTH_004` · 10분 지남은 첫 단계로(주소는 남긴다), 나머지는 전역 알림 | + | 새 비밀번호 | `AUTH_005`(임시 비밀번호) · `VAL_001`(새 비밀번호) · `AUTH_006` · `AUTH_007`(코드) | `REQ_001` · 넘기기 실패 + [다시 시도] | 전역 알림 | + | 2단계 켜기 | `AUTH_006` · `AUTH_007`(코드) · `AUTH_005`(지금 비밀번호) | `AUTH_008` + 새 키 · `REQ_001` | 서버의 `VAL_001` 과 나머지는 전역 알림 | + + - 칸 아래에는 안내와 오류를 함께 보인다(오류가 나도 "다음 코드가 뜨면 넣어 주세요" 같은 안내가 남는다). + - 세션이 끝나 로그인 화면으로 오면 위쪽 상자에 `AUTH_002` 문구만 보이고 알림(스낵바)은 띄우지 않는다(웹은 대화상자). + - [다른 계정으로 로그인]은 코드 단계에서는 늘 누를 수 있고(늦은 결과는 버린다), 새 비밀번호 · 2단계 켜기를 보내거나 넘기는 동안은 막는다(서버가 이미 바꿨을 수 있다 — 웹과 같다). + - 2단계 켜기는 처음 초점을 코드 칸에 두지 않는다 — 자판이 [인증 앱에 추가]를 가리지 않게(웹은 둔다). 켠 뒤 알림은 "2단계 인증을 켰습니다. 메일 프로그램은 앱 비밀번호로 연결합니다."이고 웹의 [앱 비밀번호 만들기] 단추는 뺐다(앱에 그 화면이 없다). + - 다른 곳에서 2단계를 켜면 이 세션은 서버가 끊는다(계약 6절) — 다음 요청에서 로그인 화면 + `AUTH_002`. 키를 받을 때 이미 켜져 있으면(`AUTH_008` 뒤 확인) 이 단계를 건너뛴다. + - 첫 단계 주소 칸만 자판 추천을 켠다(끄면 안드로이드 엔진이 비밀번호 보기 형식을 섞어 @ 자판이 나오지 않는다). 자동 고침은 모든 칸에서 끈다. + - 보드의 13.5 · 14.5 · 15.5 글자는 가까운 테마 역할(13 · 15 · 15)로 맞췄다. 한글은 낱말 단위로 줄을 바꾼다(`KeepAllText` — 보드의 `word-break: keep-all`). + - 새 글(보드에 없던 상태): 키를 복사하지 못하면 "키를 복사하지 못했습니다. 키를 보고 인증 앱에 직접 입력해 주세요.", ③ 안내 "지금 비밀번호를 한 번 더 넣어 주세요." — 그 밖은 웹 로그인 틀의 글을 그대로 옮겼다. + - **알려진 한계**: 인증 앱에 다녀오는 사이 안드로이드가 앱을 정리하면 돌아왔을 때 새 키가 나온다(인증 앱에 먼저 넣은 항목은 지우고 다시 넣어야 한다 — 옛 키의 코드는 `AUTH_006`, 웹의 새로고침과 같은 결). + 키 화면은 최근 앱 미리 보기에 찍히고(FLAG_SECURE 없음), 복사한 키는 클립보드에 남으며 안드로이드 13 이상은 복사 미리 보기에 보인다(Flutter 클립보드는 민감 표시를 못 한다 — 네이티브 채널이 든다). iOS 의 [인증 앱에 추가]는 iOS 를 붙일 때 여는 방식을 바꾼다. + ### 메일 목록 (`App1MailList`) - 위 막대: 서랍 열기 · 메일함 이름 · 찾기. 서랍은 메일함만(안 읽은 수는 회색 글 — 뱃지 없음), 탭 막대 위까지 화면 전체를 덮는다. - 줄: 동그란 사진(40) · 보낸 사람 · 시각 / 제목 · 첨부 표시 / **미리 보기 한 줄**. 안 읽음은 굵게, 중요는 별. @@ -37,19 +58,243 @@ - 당겨서 새로고침. 빈 메일함 · 빈 휴지통(30일 안내 글). - [메일 쓰기]는 넓은 단추로 탭 막대 위(아래에서 88). 알림이 뜨면 함께 올라간다. +- **구현하며 정한 것**(2026-11-06 — 보드에는 아직 없다) + - 고르기: 고르는 중에는 줄을 눌러도 고르기 · 풀기다(열지 않는다). 고른 것이 모두 읽음이면 [안 읽음 표시], 모두 중요면 [중요 해제]로 바뀐다. 휴지통에서는 빨간 [영구 삭제](확인 창 — 처음 초점 [취소]), 스팸메일함의 삭제는 휴지통으로 옮기기다. + 읽음 · 중요 표시가 끝나면 보드대로 고르기를 끝낸다(웹은 남긴다) — 통째로 실패하면 고른 것을 되살린다. 다른 메일함 · 다른 탭으로 가면 고른 것을 푼다(계약 2절). + - 붙잡힌 메일(보내기 취소 · 예약)은 고를 수 없다 — 누르면 "고를 수 없습니다" 알림, 화면 낭독기는 꺼진 칸의 까닭을 읽는다. 보낼 시각 + 2초에 다시 보고 남아 있으면 5초마다 세 번 더(서버 시각으로 잰다 · 실패해도 이어 본다). + - 알림: [되돌리기]가 있는 알림은 [메일 쓰기] 바로 아래 한 줄(10초 — 화면 낭독기를 켠 동안은 저절로 닫지 않는다), 단추 없는 알림은 전역 스낵바로 띄워 [되돌리기] 줄을 바꿔 끼우지 않는다. [메일 쓰기]는 알림이 뜨면 함께 올라간다. + - 서랍은 메일함을 바꿔 끼운다(방문 기록을 쌓지 않는다 — 다른 메일함은 받은편지함 위에 하나만). 단추로만 연다(가장자리 밀기 없음). 메일함 목록을 받기 전에는 뼈대, 받지 못했으면 [다시 시도]. + - 휴지통 · 스팸메일함에 메일이 있으면 맨 위에 30일 줄(웹 13단계 — 보드에는 빈 휴지통만). [찾기]는 검색 화면을 연다(아래 「검색」). + - 새 메일 확인: 화면이 보이고(다른 탭 · 전체 화면에 가리지 않음) 앱이 앞에 있을 때만 1분마다, 다시 보이면 10초가 지났을 때 바로(웹 3단계). 가려진 목록은 바뀜을 적어 두었다가 보일 때 맞춘다. + 다시 받은 쪽이 메일함 끝까지면 그 밖의 옛 메일은 다른 곳에서 지운 것으로 보고 남기지 않는다(웹은 남긴다 — 서버의 hasNext 는 끝까지 받았을 때만 거짓이다). 끝 표시는 보이는 동안 다음 쪽을 이어 부르고, 같은 커서가 되풀이되면 [다시 시도]로 멈춘다. + - 크기: 탭 막대의 고른 탭 알약은 Material 3 그대로 64×32(보드 56×32), 글자는 테마 역할로 맞췄다(12.5 → 12 · 13.5 → 13 · 14.5 → 15 · 17 → 18, 굵기 650 → 700 · 450 → 500). 탭 막대는 위 안전 영역을 빼고, 고르는 중 알림 줄은 시스템 내비게이션 위에 뜬다. + - 내 메일함 이름의 차례는 대소문자를 가리지 않는 비교다 — 영문 · 한글이 섞인 이름은 웹(`localeCompare('ko')`)과 다를 수 있다. + ### 읽기 (`App1Reader`) - 위 막대: 뒤로 · 삭제 · 더 보기(안 읽음 표시 · 중요 표시 · 이동 · 스팸 신고). 아래 행동 막대: 답장 · 전체 답장 · 전달. - 머리(제목 · 보낸 사람 · 받는 사람 펼치기 · 시각)는 앱 화면, 본문은 앱 안 WebView — 스크립트를 끄고 앱이 감싼 문서의 CSP 로 바깥에 나가지 않는다. 외부 이미지는 안내 줄의 [이미지 보기]를 누르면 앱이 받아서 넣는다. 링크는 바깥 브라우저 · mailto 는 앱의 쓰기 화면. - 첨부 목록 — 받는 중에는 진행(%)을 보인다. +- **구현하며 정한 것**(2026-11-06 — 보드에는 아직 없다) + - 짜임: 머리(제목 · 보낸 사람 · 받는 사람 · 시각 · 안내 줄 · 첨부)는 앱 화면으로 본문 위에 두고 남은 높이의 절반까지만 쓴다(넘으면 머리 안에서 스크롤). + 본문 WebView 는 나머지를 다 쓰고 그 안에서 스크롤한다 — 스크립트를 꺼서 내용 높이를 잴 수 없다. + **첨부 목록은 본문 위**다(보드는 아래) — 본문이 WebView 안에서 스크롤하므로 아래에 두면 늘 화면 아래를 차지한다. + - 본문 글꼴은 기기 글꼴이다(CSP · 파일 접근 끄기로 WebView 가 Pretendard 를 쓰지 못한다). 크기 15 · 줄 간격 1.75 · 여백 16 / 20 / 24 는 보드대로, 기기 글자 크기 설정을 따르고, 앱이 어두운 테마여도 본문 바탕은 흰색이다. + - 앱이 한 번 더 정화한다(서버 정화 뒤 — 허용 목록으로 다시 쓴다). 길이 2,000,000 · 중첩 비용 8,000,000 을 넘으면 정화하지 않고 글자 본문과 서버의 생략 안내와 같은 글을 보인다. + 서식 본문은 6,144자보다 짧고 중첩 비용이 5,000 이하일 때만 화면에서 바로, 그 밖은 다른 isolate 에서 정화한다(개발 PC 에서 바로 하는 것이 2ms 안쪽 — 휴대폰은 몇 배 느리다). + 정화하는 동안 본문 자리 맨 위에 가는 줄을 둔다(화면 낭독 "본문을 불러오고 있습니다."). + - 링크: http · https 는 바깥 앱, mailto 는 쓰기 화면(채울 값 100 · 255 · 10,000자까지), 그 밖은 막는다. 열 앱이 없으면 "이 링크를 열 수 있는 앱이 없습니다." + - 외부 이미지: [이미지 보기]는 본문만 다시 받는다(읽음 · 중요 · 메일함은 지금 값 그대로 — 웹과 같다). 그림은 앱이 받아 `data:` 로 넣는다 — + 한 번에 4개, 그림 하나 5MB, 받는 합계 10MB, 서로 다른 그림 100개, 앞 바이트로 형식(PNG · JPEG · GIF · WebP)을 보고, 받지 못한 그림은 숨기고 알리지 않는다. + **문서에 싣는 그림 글은 나온 횟수까지 세어 1,000만 자에서 멈춘다** — 같은 그림을 수백 번 쓴 메일 한 통이 앱을 메모리 부족으로 끝내지 않게(2026-11-06 리뷰). + 넘는 자리부터는 뒤의 그림을 모두 숨긴다 — 받은 그림이 다 보이지 않을 수 있다(5MB 그림 둘이면 하나만). + 문서는 그림 없이 먼저 보이고 다 받은 뒤 한 번 다시 넣는다(그동안 머리 아래 진행 막대). 화면을 떠나면 받던 그림을 끊는다. 설정으로 처음부터 보이는 본문은 안내 줄 없이 바로 받는다. + - 읽음 · 중요: 열면 화면과 메일함 개수를 먼저 읽음으로 바꾸고 알리지 않고 보낸다(실패하면 알리지 않고 되돌린다). [안 읽음 표시]는 바꾼 뒤 목록으로 돌아간다. + [중요 표시]는 화면에 머물며 "중요 표시했습니다." · "중요 표시를 해제했습니다."(읽기 화면에 별이 없다). 응답은 그 요청이 바꾼 칸만 지금 값에 덮는다 — 그사이 바꾼 표시 · [이미지 보기]의 본문을 되돌리지 않게. + - 더 보기 메뉴는 상태에 따라 바뀐다(읽음 표시 ↔ 안 읽음 표시 · 중요 표시 ↔ 중요 해제 · 스팸 신고 ↔ 스팸 아님). 휴지통에만 든 메일은 삭제 단추가 빨간 [영구 삭제](확인 창 — 처음 초점 [취소])다. + 삭제 · 이동 · 스팸은 목록의 여러 통 처리와 같은 규칙이고 성공하면 목록으로 돌아간다. **무엇을 하는 동안에는**(삭제 · 이동 · 스팸 · 읽음 표시 · 보내기 취소) 아래 막대와 본문 링크를 막고, 끝난 뒤에는 읽기 화면이 맨 위일 때만 닫는다. + - 붙잡힌 메일: 보내기 취소를 기다리는 메일은 "곧 보낼 메일입니다."(웹 읽기 화면에는 없는 줄), 예약 메일은 "{시각}에 보낼 메일입니다." + [보내기 취소] · [예약 취소] → 임시보관함으로 옮기고 목록으로. + 이미 보냈으면(`MAIL_014`) 다시 받아 머문다. [삭제]는 확인 → 보내기 취소 → 휴지통이고, 확인 창을 연 채 보내기가 풀리면 창을 닫는다. 이동 · 스팸은 메뉴에서 빠지고 예약 메일에는 아래 막대가 없다(웹과 같다). + 보낼 시각 + 2초에 다시 보고, 남아 있으면 5초마다 세 번 더 본다(서버 시각 — 목록과 같은 값). + - 첨부: [받기]는 앱의 임시 폴더에 받고(진행 %) 다 받으면 다른 앱으로 연다 — 같은 세션에서 다시 누르면 받지 않고 바로 연다. [받기 취소]는 쓰다 만 파일을 지우고, 화면을 떠나도 받기를 그만둔다. + - 열 형식은 이름의 확장자에서 먼저 정한다 — 보낸 쪽이 적은 형식은 확장자를 모를 때만 쓴다(웹은 늘 내려받기라 운영체제가 확장자로 고른다). + - **앱에서 열지 않는 파일**: 웹 문서(html · htm · xhtml · xht · shtml · mht · mhtml) · svg · 설치 파일(apk · apks · xapk) · 인증서 · 키 묶음(p12 · pfx · cer · crt · der · pem · p7b · p7c). + 확장자나 형식이 하나라도 걸리면 받지 않고 "이 형식의 파일은 앱에서 열 수 없습니다. PC 웹메일에서 받아 주세요." — 다른 앱(브라우저)이 그 파일의 스크립트를 돌리거나 설치 · 등록 창을 띄우지 않게(2026-11-06 리뷰). + S/MIME 의 `smime.p7m` 도 여기에 걸린다. eml · xml 은 연다. + - 받은 파일은 앱을 띄울 때와 메일을 보는 직원이 바뀔 때(로그아웃 · 세션 끝 · 다른 직원) 지운다. 연 직후에는 다른 앱이 읽고 있을 수 있어 두고, 기기가 임시 폴더를 비우기도 한다. + - 대용량 첨부는 이름 · 크기와 "대용량 첨부는 앱에서 아직 받을 수 없습니다. 본문의 링크를 누르거나 웹메일에서 받아 주세요."만 보인다. + - 실패 글: "이 파일을 열 수 있는 앱이 없습니다. 파일을 여는 앱을 설치하거나 웹메일에서 받아 주세요." · "파일을 열지 못했습니다. 다시 받아 주세요." · "파일을 저장하지 못했습니다. 휴대폰의 저장 공간을 확인해 주세요.", 받는 도중 끊김은 `SYS_001`. + - 보드에 없던 상태를 더했다: 불러오는 뼈대, 받지 못함([다시 시도] · [목록으로]), 그림 받는 진행 막대, 본문 생략 · 잘림 안내 줄, 붙잡힌 메일 안내 줄, 대용량 첨부 줄. + - **알려진 한계** + - WebView 의 렌더러가 죽으면(메모리 부족 등) 앱도 함께 끝난다 — 플러그인(webview_flutter_android 4.12.0)이 그 소식을 넘기지 않는다. 위 그림 한도가 그 위험을 줄인다. + - 진행 막대의 화면 낭독 값은 숫자만 된다(Flutter 3.41) — 이름("… 받는 중")만 싣고 "받는 중 64%"는 화면 글로만 보인다. + - iOS 를 붙일 때: 파일 열기 채널(지금은 "파일을 열지 못했습니다."), WKWebView 의 링크 길게 누르기 미리 보기 끄기(`setAllowsLinkPreview(false)` — 미리 보기는 판정을 거치지 않고 그 주소를 연다). + - 실기기에서 볼 것: 넓은 표 뉴스레터(가로 스크롤 · 확대), 기기 글자 크기, 링크 · 길게 누르기(WebView 안에서 이동하지 않는지), 같은 그림을 여러 번 쓴 뉴스레터, 2MB 본문을 정화하는 시간, + 첨부(PDF · 오피스 · hwp 가 한컴 뷰어에서 열리는지 · 막는 형식의 알림 · eml 을 어느 앱이 여는지 — 크롬이 MHTML 로 열면 스크립트가 꺼져 있는지), 파일 · content 접근이 꺼졌는지, 폴더 밖 경로는 열지 않는지, + TalkBack 읽는 차례(위 막대 → 머리 → 안내 → 첨부 → 본문 → 아래 막대), 뒤로 가기 몸짓, 로그아웃 뒤 받은 파일이 지워지는지. + ### 쓰기 (`App1Compose`) - 위 막대: 닫기 · 첨부 · 보내기. 받는 사람 · 제목은 웹 쓰기 화면처럼 줄 왼쪽에 이름(칸 안에서 떠오르는 꼴이 아니다). 받는 사람 추천은 맞은 글자를 굵게. - 참조 · 숨은참조는 펼쳐서. **첨부는 제목 아래**(휴대폰은 본문이 길어지면 아래 첨부가 묻힌다) — 크기 · 빼기 · 올리는 중. - 본문은 글만(서식 편집은 다음 판). **서명은 본문과 따로 된 칸에 [서명 빼기]만** — 서명 고치기는 웹에서. 답장의 원본 메일은 접혀 있고 펼쳐 볼 수 있다. - 보낸 뒤에는 쓰기를 시작한 화면(읽기 · 목록)으로 돌아가고, 그 화면 아래에 "메일을 보냈습니다. [보내기 취소]"와 남은 시간 막대. +- 만들 때 지킬 것(2026-11-06 읽기 화면 리뷰): 읽기 화면의 mailto 링크로 채운 숨은참조가 있으면 숨은참조 칸을 펼쳐 보인다 — 링크가 숨은참조를 몰래 넣을 수 있다(RFC 6068 보안 고려). -### 새 메일 알림 (푸시) -- 알림 내용은 "새 메일 N통"만 — 보낸 사람 · 제목이 Google · Apple 서버를 거치지 않게. 누르면 메일 모듈의 받은편지함으로(알림에는 모듈 이름과 id 만 싣는다). +- **구현하며 정한 것**(2026-11-07 — 보드에는 아직 없다. 1차 · 2차(첨부 고르기 · 올리기)) + - 본문: 글만 쓴다. 보낼 때 서식 본문 + 텍스트를 함께 만든다(계약 2절 "앱이 만드는 모양"). 원문 인용은 글로 만든 덩이라 원문의 표 · 색 · 링크 모양은 빠지고 글은 남는다(웹에서 쓴 답장은 원문 서식이 남는다 — 서식 편집을 넣는 다음 판에서 넓힌다). + 처음 95% · 저장 · 보내기 직전 100% 로 맞추고, 내 글만으로 넘으면 본문 아래 띠(`MAIL_007`). + - 서명: 따로 된 칸(점선 위 · [서명 빼기] → 그 자리에 [서명 넣기] 한 줄). 새 메일은 isForNew, 답장 · 전달은 isForReply 일 때만 넣는다. + 미리 보기는 글 줄과 서명 그림(Bearer · 300KB · HTML 폭을 화면 안으로)이고, 굵게 · 색 같은 꾸밈은 보낸 메일에만 남는다. + - 원본 메일: 처음 접혀 있다(요약 "보낸 사람 · 날짜" — 답장 · 전달은 "11월 5일 (목) …", 올해가 아니면 연도까지, 다시 연 저장본은 머리 표의 글 그대로). 펼치면 머리와 원문 줄, 원문 속 인용은 단마다 막대. + - 받는 사람 + - 칩은 쉼표 · 세미콜론 · 줄바꿈(따옴표 · 꺾쇠 밖) · [다음] · 칸 벗어남 · 추천 고름에서 만든다. [다음]은 첫 추천을 넣고, 완전한 주소면 친 그대로, 빈 칸이면 다음 칸으로 간다. + - 글 자판(추천 켬 · 자동 고침 끔)이다 — 메일 주소 자판은 한글을 막는다(로그인 화면에서 본 문제). + - 추천은 열 때 100개를 받아 두고 모자라면 250ms 뒤 q 로 묻는다. 묶음 이름 줄(주소록 · 그룹 · 사내 · 최근 주고받은 주소 — 계약 2절의 "사내와 최근을 나눠 보인다")을 두고 8줄까지다. + - 틀린 주소는 빨간 칩, 받는 사람 오류(사전 검사 · `MAIL_002` · `MAIL_010`)는 칸 아래 띠다. 참조 · 숨은참조는 값이 있어도 접을 수 있고(보드대로) 오류가 나면 저절로 펼친다. + 전체 답장 · 저장본 · mailto 에 값이 있으면 펼친 채 연다. 칩 ✕ 40 은 밀도 예외다. + - 제목: UTF-16 255자(서버 · 웹과 같은 단위 — 조합 중에는 막지 않고 끝나면 자른다). 저장본 · mailto 로 넘게 들어온 제목은 보낼 때 띠로 막고 제목에 초점을 옮긴다. + - 열 때 초점: 받는 사람 → 제목 → 본문 가운데 비어 있는 첫 칸(답장은 본문). + - 임시 저장: 웹 규칙(3초 멈춤 · 쉬지 않고 쓰면 30초 · 10초에 한 번 · 한 번에 하나 · `MAIL_008` · 404 면 새로 · 실패면 15 · 30 · 60초 뒤 다시)에 더해, 앱이 안 보이게 되면(홈 · 다른 앱 · 화면 끄기) 바로 저장한다. + 쓰는 동안 저장 상태는 보이지 않고, 저장한 채 닫으면 "임시보관함에 저장했습니다." 옛 저장본 id → 새 id 를 세션 동안 기억한다(목록의 옛 줄 → 마지막 저장본). + - 저장본 읽기: 읽기 화면과 같은 한도 · 문턱(그 자리 · 다른 isolate · 읽지 않음). 한도를 넘은 저장본은 텍스트 본문으로 열고 원래 HTML 을 그대로 다시 보낸다. 읽다 실패하면 [다시 시도]. + 서식이나 그림이 있는 남의 저장본(웹의 정렬 · 들여쓰기 포함)은 "서식이나 그림이 있는 임시 저장본입니다. 앱에서 본문을 고치면 글자만 남으니, 서식을 지키려면 웹메일에서 이어 써 주세요." + - 보내기: 칩 확정 → 사전 검사 → 본문 길이 재기 → 진행 중 저장 기다림 → 발송(60초 · 같은 키 — 성공 · `MAIL_005` 에만 새 키). + 보내는 동안은 칸 · 단추를 막는다(✕ · 뒤로는 그대로 — [보내기] 자리는 도는 표시, 낭독 "보내는 중입니다."). + [보내기]를 누른 뒤 떠나도 저장을 기다려 그대로 보내고 결과를 알린다(웹과 같다 — 2026-11-07 리뷰: 기다리는 사이 화면이 내려가면 나가지 않았다). + 떠난 뒤 보내기 전에 멈추면(기다린 저장이 첨부를 찾지 못함 · 대용량 파일을 쓸 수 없음) `MAIL_004` · `MAIL_016` 의 사전 문구를 알리고, 저장할 수 있으면 쓰던 내용을 저장본으로 남긴다. + - 보낸 뒤: 시작한 화면(읽기 · 목록)으로 돌아가 "메일을 보냈습니다." + [보내기 취소] + 3px 남은 시간 막대(읽기 화면에도 아래 막대 위 12 에 알림 자리를 만들었다). + 마감은 서버 시각이 있으면 sendAt − Date − 2초(Date 가 초 아래를 버리고 응답 시간을 빼지 않아 1초 더 남긴다), 없으면 min(설정 초 − 1초 — 모르면 30초, sendAt − 기기 시각), 상한 30초. + 화면 낭독기를 켠 동안에도 마감에 닫는다. 바로 보냈으면(설정 0) [보낸편지함 보기]. [보내기 취소]는 쓰기 화면을 다시 열고(새 키 · 서명 칸 · 인용까지) "보내기를 취소했습니다. 쓰던 메일로 돌아왔습니다." + 받는 사람 일부가 받지 못했으면 웹 문장 그대로("보냈지만 N명은 받지 못했습니다. …"). + - 나가기: 잃을 것이 없으면 바로 닫는다(예측 뒤로 가기). 있으면 쓰다 만 주소를 확정하고 저장(5초)한다 — 저장했으면 "임시보관함에 저장했습니다."로 닫고, + 못 했으면 "저장하지 못한 내용이 있습니다" / "이 화면을 나가면 마지막으로 바꾼 내용이 사라집니다." [계속 쓰기](주 단추 · 처음 초점) [나가기]. + 창을 연 뒤 저장이 끝나 잃을 것이 없어지면 저절로 닫고 떠난다. 떠나기 전 저장을 기다리는 동안은 [보내기]를 막는다. 보내는 중에는 묻지 않고, 세션이 끝나면 창만 닫고 알리지 않는다. + 올리는 중인 첨부가 있으면 먼저 "올리는 중인 첨부가 있습니다" / "이 화면을 나가면 올리던 파일은 빠집니다." [계속 쓰기](주 단추 · 처음 초점) [나가기]로 묻는다 — + [나가기]면 올리던 · 기다리던 파일을 빼고 위 차례(저장 → 닫기)로 간다. 묻는 사이 모두 다 올리면 저절로 이어 가고, 올리지 못한 파일이 새로 생기면 창을 그대로 두고 답을 기다린다. + - 첨부 + - [파일 첨부]는 안드로이드 고르는 창(모든 형식 · 여러 개 · 권한 없음 — 형식으로 거르면 hwp 가 숨는다)이다. 이미 20개면 창을 열지 않고 `MAIL_006` 사전 문구를 알린다. + - 고른 파일은 앱 임시 폴더에 복사해 두고 그 복사본을 올린다 — 올리기에 실패해도 다시 고르지 않는다. 기기가 아래 사전 검사와 같은 차례로 미리 걸러 넣을 파일만 복사한다 + (`content://` 만 · 받지 않을 형식과 합계를 넘는 파일은 읽지 않고 · 파일당 한도는 + 1바이트에서 멈춘다). 판정은 앱이 다시 한다. + 가져오는 동안은 위 막대 아래에 진행 막대가 보이고 [파일 첨부]가 막히며, [보내기]는 "첨부할 파일을 가져오는 중입니다. 다 가져온 뒤 보내 주세요." + 고르기 한 번은 10분(고르는 창에 머문 시간 포함)까지 기다리고, 넘으면 "파일을 가져오지 못했습니다. 다시 골라 주세요."로 끝내 [보내기] · [파일 첨부]를 푼다 + (늦게 온 복사본은 다음 정리에 지운다 — 2026-11-07 리뷰: 끝나지 않는 클라우드 파일이 [보내기]와 다음 고르기를 묶었다). + - 사전 검사는 웹과 같은 차례다: 개수 20 `MAIL_006` → 형식 `FILE_001`(HEIC 포함) → 파일당 → 합계. 개수 · 합계에는 넣을 파일만 센다. + 한도는 쓰기 화면을 열 때 `GET /api/mail-quota` 로 받는다(못 받으면 20 · 25MB). 원본 첨부가 있는 전달 · 첨부가 있는 저장본만 그 합계를 재려고 열 때 2초까지 기다린다(웹은 기다리지 않는다). + - 넣지 못한 파일은 줄로 남기지 않고 알림 하나로 까닭을 모은다(까닭마다 한 문장 — 합계를 넘긴 파일이 여럿이면 "‘파일’ 외 N개는"). + 파일당 한도를 넘으면 "{한도}보다 큰 파일은 앱에서 아직 보낼 수 없습니다. 웹메일에서 대용량 첨부로 보내 주세요."(대용량 첨부는 다음 판). + - 올리기는 고른 차례대로 둘씩(보내기 5분 · 받기 60초)이고, 줄은 진행 막대와 "올리는 중 N%"(기다리는 줄도 0%), ✕ 는 올리기 취소다. 다 올리면 서버가 정리한 이름 · 크기로 싣고 보인다(웹과 같다). + - 실패하면 빨간 테두리 · "올리지 못했습니다." · 사전 문구 · [다시 시도]. 형식 · 크기 거절(`FILE_001` · `FILE_002` · `VAL_001`)과 기기가 지운 복사본("올리려던 파일을 찾지 못했습니다. 빼고 다시 골라 주세요.")은 + [다시 시도] 없이 빼기만 한다. 처음 올리기에 실패한 줄은 보내기를 막지 않는다(웹과 같다). + - 올리는 동안 [보내기]는 "올리는 중인 첨부가 있습니다. 다 올린 뒤 보내 주세요."(웹 `sendBlockOf` 의 차례 — 가져오는 중은 그 뒤). 임시 저장은 다 올린 첨부만 싣고, 하나를 다 올리면 바꿈으로 보고 자동 저장한다. + - `MAIL_004` 면 고른 파일은 복사본으로 한 번 다시 올린다. 다시 올릴 수 없거나 다시 올리다 실패하면 원본 · 저장본의 첨부처럼 "찾지 못함"으로 두고, + 빼거나 [다시 시도]로 다시 올리기에 성공하기 전까지 저장 · 보내기를 막는다(2026-11-07 리뷰 — 떠난 뒤 보내기에서 그 파일 없이 나갔다). + - 머리 오른쪽에 "파일당 최대 20MB · 합계 25MB"(서버 값). 그림 첨부는 그림 아이콘이다. + - 전달 원본 · 저장본의 첨부와 대용량 파일은 싣고 빼기만 한다. 문제가 있으면 줄 테두리가 빨갛고 줄 아래에 까닭을 적는다(함께 보낼 수 없음 · 찾지 못함 `MAIL_004` · 올리다 멈춤 · 지워짐 · 위험한 파일). + - 복사본은 뺄 때 · 화면이 끝날 때 · 세션이 끝날 때 · 앱을 띄울 때 · 메일을 보는 직원이 바뀔 때 지운다. + - 글자 크기는 테마 크기로 맞췄다(15.5 → 16 · 14와 14.5 → 15 · 13 → 13 · 12.5 → 12). + - **알려진 한계**: 앱이 안 보이게 될 때 칸에 쓰다 만 주소(칩이 되지 않은 글)는 저장에 들지 않고, 나가기 확인 창이 떠 있는 동안은 저장하지 않는다. 앱이 앞에 있는 채로 정리되면 마지막 몇 초를 잃는다. + 고르는 동안 앱이 정리되면 고른 파일은 버린다(쓰던 내용은 고르는 창을 열 때 저장된다). 고르는 창에 10분 넘게 머물면 그 고르기는 시한으로 끝난다(알림이 창 뒤에서 뜰 수 있다 — 다시 고른다). + **알림 · 업데이트 화면으로 내려갈 때**(2026-11-07): 새 메일 알림을 누르거나 업데이트 화면으로 옮겨(다른 화면으로 `go`) 쓰기 화면이 내려가면, 쓰다 만 주소까지 임시보관함에 마저 저장하고 + "임시보관함에 저장했습니다."(저장하지 못하면 그 코드의 사전 문구)로 알린다. 로그아웃 · 세션 끝 · 제한 단계로 옮겨 내려갈 때는 알리지 않는다(그 세션의 쓰기가 끝났다). + - **실기기에서 볼 것**: 삼성 · Gboard 자판의 칩 만들기(쉼표 · [다음] · 한글 조합 중 확정 · 여러 줄 붙여 넣기), 추천 목록이 자판에 가리는지, 예측 뒤로 가기, 홈 · 다른 앱으로 갔을 때 저장, 긴 원문 펼침 스크롤, + 서명 그림 폭, TalkBack 차례(칩 · 추천 · 원본 펼침 · 띠), 제스처 내비게이션에서 읽기 화면 알림 자리, 웹 ↔ 앱으로 저장본을 서로 다시 열기, 앱 로그인 서버(feat/app-auth)와 함께 실제 보내기 · 저장 · 서명 그림. + 첨부(2차): 고르는 창의 출처마다(내 파일 · Google 드라이브 · 사진 · 다운로드) 이름 · 크기 · 확장자를 주는지, hwp · hwpx 가 창에 보이고 올라가는지 · HEIC 는 `FILE_001` 인지, + 20MB 넘는 파일 · 크기를 알려 주지 않는 클라우드 파일이 한도에서 멈추는지, 합계에 걸리는 큰 파일을 내려받지 않고 바로 알리는지, 받는 동안의 진행 막대, 받다 망이 끊길 때(비행기 모드) 가져오기가 끝나는지, 여러 개 · 남은 자리보다 많이 고르기, + 고르는 동안 앱이 정리될 때(개발자 옵션 "활동 유지 안 함"), file:// 를 건네는 옛 파일 관리자, 올리는 중 화면 끄기 · 다른 앱, 느린 망의 20MB(보내기 5분), + 실제 multipart(한글 · 괄호 · 콜론 이름이 웹메일에서 그대로 보이는지), `MAIL_004` 뒤 다시 올리기, TalkBack 차례(진행 막대 · ✕ · [다시 시도] · 알림), 큰 글자에서 첨부 머리의 한도 글. + +### 검색 (`App1Search` · `App1SearchStates` — 1차 · `App1SearchConditions` — 2차, 2026-11-07 확정) + +웹 14단계 검색(계약 2절 "검색 — 14단계" · 웹 디자인 README 「14단계」)을 앱에 옮겼다. 2차는 조건으로 찾기(바닥 시트 — 아래 「조건으로 찾기」)다. 계획의 정할 것 17개는 모두 첫째 안이다(사용자 2026-11-07 "추천대로"). + +- **자리**: 목록 위 막대의 [찾기]가 연다. 탭 안 화면 `/mail/search` 라 연 목록 위에 쌓이고 탭 막대가 보인다(자판이 열리면 틀이 숨긴다). 메일 탭을 다시 누르면 닫히고, 다른 탭에 다녀오면 결과가 남는다. 검색어는 주소에 싣지 않는다(시작한 메일함만 라우트 `extra`). +- **위 막대 64**: [←](검색 닫기) · 칸("메일 검색") · 글이 있으면 [✕](칸과 결과를 비우고 칸에 초점 — 걸린 조건은 남는다) · [조건](맨 오른쪽 — 2차). 열면 칸에 초점을 두고 자판을 올린다. 자판의 [검색]으로만 찾고, 빈 칸 · 공백뿐이면 아무 일 없이 자판을 둔다(조건이 걸려 있으면 조건만으로 찾는다 — 웹과 같다). 찾으면 자판을 내리고, 결과를 끌어도 내린다. + 칸은 글 자판 · 자동 고침 끔 · 추천 켬 · 둥근 따옴표와 대시 끔 · 두 줄까지 · 줄바꿈은 띄어쓰기 · UTF-16 200자다. 칸 이름은 낭독에서 한 번만 읽는다(보이는 안내 글은 낭독에서 뺀다). +- **찾을 곳 줄 56**: "찾을 곳" + [시작한 메일함 | 전체 메일함](고른 칸은 옅은 청록 바탕 · 굵은 글, ✓ 없음). 찾기 전부터 보이고, 바꾸면 칸의 글로 바로 다시 찾는다. 전체 메일함이면 줄 아래에 "휴지통과 스팸메일함은 빼고 찾습니다". 휴지통 · 스팸메일함에서 시작하면 그 안에서 찾는다. +- **결과 머리 36**(최소 — 큰 글자면 늘어난다): "‘계약’ · 4통" · 찾는 동안 "· 찾는 중" · 전체 수를 모르면 검색어만. 그 아래 안내 줄은 하나다(검색 준비 중 또는 한 글자 안내 — 함께 뜨지 않고, 준비 상태를 받지 못하면 없다). +- **결과 줄**은 목록 줄 그대로이고 고르지 않는다(사진은 단추가 아니고, 길게 눌러도 아무 일 없다). + - 맞은 말은 노랑 #FCE7A4(`AppColors.searchMark` — 짝: 웹 `--color-search-mark`) 바탕이다. 회색 미리 보기 줄 안의 강조 글자만 `textSecondary`(#4A5363 — 노랑 위 6.3:1)다. + - 강조가 있으면 미리 보기는 서버의 맞은 말 둘레 한 줄이다. + - 전체 메일함이면 시각 앞에 메일함 이름(시각과 같은 글자 · 글 줄 폭의 40%까지)을 두고, 보낸편지함 · 임시보관함은 "받는 사람: …"이다. +- **결과에서 읽기**: 누르면 읽기 화면이다(임시보관함은 쓰기 화면). 머리의 받는 사람 아래에 "메일함 받은편지함" 줄을 둔다. 제목 · 첨부 이름 · 본문의 맞은 말을 칠한다(본문 mark 는 웹과 같다 — 글자색 그대로 · 모서리 2 · 좌우 1px). 지우고 돌아오면 결과 아래에 [되돌리기] 알림이 뜬다. +- **이어 찾기**: 한 차례 5번 · 20통이다. 한 차례가 한 쪽을 못 채우면 저절로 잇지 않고 [더 찾기]로 멈춘다(0통이거나 불러온 결과를 모두 뺐으면 "아직 못 찾음" 화면, 있으면 목록 끝 줄). 한 쪽을 채운 차례만 목록 끝이 다음 차례를 저절로 묻는다. +- **결과 갱신**: 새 메일 확인으로 다시 찾지 않는다. 읽기 화면이 바꾼 표시 · 메일함 · 붙잡힘만 덮고, 제목 · 미리 보기 · 강조는 그 응답의 것 그대로다. 당겨서 새로고침은 다시 찾기다. +- **조건으로 찾기**(2차 — `App1SearchConditions`, 2026-11-07 확정 · 웹 「14단계 구현하며 정한 보완 — 검색」의 [조건] 패널을 바닥 시트로 옮겼다) + - **[조건]**: 위 막대 맨 오른쪽에 고정한다(보이는 원 40 · 누르는 곳 48 · 가로 슬라이더 아이콘). 계획은 [✕] 왼쪽이었지만 글을 쓰기 시작할 때 옆으로 밀리지 않게 바꿨다. + 평소 바탕 없음, 시트가 열려 있거나 조건이 걸려 있으면 옅은 청록 원. 낭독 "조건으로 찾기"(+ 펼침 상태), 걸려 있으면 ", 조건이 걸려 있음". + - **시트**: 탭 막대까지 덮는 바닥 시트(위 96 을 남김 · 위 모서리 16). 손잡이는 직접 그리고 낭독에서 뺀다(Flutter 기본 손잡이는 "닫기" 단추로 읽혀 [✕]와 겹친다). + 머리 "조건으로 찾기" + [✕](닫기)와 아래 단추 줄([조건 지우기] · [찾기])은 고정이고 가운데만 스크롤한다(스크롤되면 머리 아래 선). 자판이 올라오면 시트도 올라간다. + 모양은 셋이다 — 자판 위 자리에서 머리 · 단추 줄의 실제 높이(낱말 알림 · 아래 안전 영역 · 글자 크기 포함)와 위 96 을 빼도 가운데에 칸 하나(52 × 글자 배율)가 남으면 위 96 을 남긴 고정, 96 을 쓰면 남으면 시트를 화면 위까지 올린 고정, 그래도 모자라면(가로 화면 + 자판 약 170 이상 등) 머리 · 단추 줄까지 시트 전체가 한 스크롤이다. 판정은 같은 레이아웃 안에서 한 번에 하고(`_SheetBody`), 트리는 그대로라 칸의 초점 · 입력이 끊기지 않는다. 모양이 바뀌면 초점 칸을, 낱말 알림이 뜨면 알림과 단추 줄을 보이게 스크롤한다. 열 때 초점을 [조건]으로 옮겨 자판을 내린다(닫은 뒤 자판이 다시 올라오지 않게). + - **칸**: 보낸 사람 · 받는 사람 · 제목 · 내용(글 칸 52 — 검색 칸과 같은 자판이고 줄바꿈은 띄어쓰기라 두 줄까지 는다, 자판의 [검색] = [찾기]) · + 기간(전체 기간 · 최근 1주 · 최근 1달 · 최근 3달 · 최근 1년 · 날짜 고르기 — "기간, 고른 값"을 한 단추로 읽는다) · 날짜 고르기면 시작 날짜 · 끝 날짜 두 칸 · 첨부(체크 줄 "첨부 파일이 있는 메일만") · 안내 글(첫 화면에서는 접힘 아래). + - **날짜**: Flutter 날짜 창이다 — 1970년 1월 1일부터 오늘까지만, 처음 날짜는 칸의 값을 범위 안으로, 머리 큰 글 22 · 굵기 600 은 테마(`datePickerTheme`). + 값은 "2026. 11. 7." 꼴 + 칸마다 [✕](낭독 "시작 날짜 지우기" · "끝 날짜 지우기"), 비면 달력 아이콘. 두 날짜는 서로 막지 않고, 거꾸로면 고른 즉시 끝 날짜 칸만 빨갛게 하고 + 두 칸 아래에 "끝 날짜를 시작 날짜와 같거나 그 뒤로 골라 주세요."를 둔다(웹은 두 칸 모두). + - **열고 닫기**: 열면 걸린 조건으로 채운다. [찾기] 없이 닫으면(✕ · 뒤로 · 가림막 · 끌어 내림) 고친 값을 버린다. + 시트의 [조건 지우기]는 칸만 비우고(기간은 전체 기간 · 알림까지) 시트는 열린 채 · 찾지 않는다(웹 2026-11-07 규칙). "최근 N"은 [찾기]를 누를 때 날짜로 바꾼다(웹과 같다 — 자정 무렵에만 다르다). + - **[찾기]**: 날짜가 거꾸로면 넘기지 않고 그 칸으로 스크롤 · 초점. 검색 칸과 글 조건 넷의 낱말이 합쳐 8개를 넘으면 시트를 열어 둔 채 단추 줄 바로 위에 빨간 글 + "검색 칸과 [조건]에 넣은 낱말은 모두 합쳐 8개까지입니다. 낱말을 몇 개 줄여 주세요."(웹 패널은 `VAL_001` 알림). 통과하면 시트를 닫고 검색 칸의 글로 찾는다. + 검색어도 조건도 비면 요청 없이 찾기 전 화면이다(웹은 검색을 닫는다). + - **조건 줄**: 찾을 곳 줄 아래(최소 48) "조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음" + [조건 지우기]. 글은 웹 `searchConditionsSummary` 와 같다 + (쉼표로 잇기 · 기간 이름 또는 "2026. 10. 1.부터 11. 5.까지" · "… 하루" · 내 이름이나 주소면 "내가 보낸 메일" · "내가 받은 메일") — 줄 수를 자르지 않고 낱말 단위로 넘긴다. + 줄의 [조건 지우기]는 걸린 조건을 모두 빼고 칸의 글(비었으면 보이던 검색어)로 다시 찾는다. [✕] 뒤처럼 찾기 전이면 조건만 뺀다. + - **결과 글**: 검색어 없이 찾으면 머리는 "N통" · "찾는 중". 결과 없음의 제목은 검색어가 없으면 "조건에 맞는 메일이 없습니다", + 도움말은 검색어 + 조건 "다른 말로 찾거나 조건을 줄여 보세요." · 조건만 "조건을 바꾸거나 줄여 보세요." · 검색어만 "다른 말로 찾아 보세요.". + 자판의 [검색]으로 낱말 8개를 넘기면 1차처럼 결과 자리 안내이고 글은 시트의 글과 같다(1차의 앱 글을 바꿨다). 조건 줄은 남는다. + - **이어지는 곳**: 조건은 화면 상태(`MailSearchState.conditions` — 주소에 싣지 않는다)이고 이어 찾기 · [더 찾기] · 당겨서 새로고침 · [다시 시도] · 찾을 곳 바꾸기 · + [전체 메일함에서 찾기]에 그대로 이어진다. 결과에서 연 메일의 강조 쿼리에 q · from · to · subject · body 를 싣는다. + - **가림막**: 앱 대화상자 가림막 색(`AppColors.backdrop` — rgba(11,54,64,.32))이다. 바닥 시트 테마로 주므로 메일함 옮기기 시트도 같은 색이 됐다. + 날짜 창은 `showDatePicker(barrierColor:)`로 준다(테마의 대화상자 가림막을 바꾸면 확인 창까지 바뀐다). +- **구현하며 정한 것**(2026-11-07) + - 길게 누르기: InkWell 에 길게 누르기 처리가 없으면 뗄 때 누르기로 읽어 메일이 열린다 — 검색 줄은 GestureDetector 가 받아 두고 아무것도 하지 않는다(진동 없이). 줄의 낭독 동작은 `MessageRow` 바깥 Semantics 에 둔다(목록 줄도 같다). + - 같은 검색어로 다시 [검색] · 당겨서 새로고침은 결과를 둔 채 찾고, 그동안 머리를 "· 찾는 중"으로 둔다. 실패하면 결과를 그대로 두고 알린다. + - 찾을 곳 바꾸기 · [전체 메일함에서 찾기]는 칸이 비었어도 결과가 보이면 그 결과의 검색어로 다시 찾고 자판을 내린다(결과와 찾을 곳이 어긋나지 않게 — 계획은 "범위만 바꾼다"였다). 결과도 없을 때만 곳만 바꿔 둔다. + - 이어 찾기 실패: 하나도 찾기 전의 실패는 받지 못함 화면, 하나라도 찾은 뒤의 실패는 알림 + 끝 줄 [다시 시도], 0통 화면의 [더 찾기] 실패는 알림 + [더 찾기] 그대로. 같은 커서가 새 메일 없이 돌아오면 결과가 있을 때 [다시 시도], 없을 때 [더 찾기]. + - [되돌리기]로 돌아온 메일은 늘 받은 시각 차례의 그 자리에 넣고 수도 되돌린다(불러온 끝 줄이어도 — 서버 커서는 그 줄 다음부터 준다). 다음 쪽의 겹침은 지금 목록의 id 로만 막는다. + - 결과의 붙잡힌 메일(보내기 취소 · 예약)은 목록처럼 가장 이른 보낼 시각 + 2초(서버 시계 — 검색 응답의 `Date`)에 그 줄만 다시 받아 표시 · 메일함 · 붙잡힘을 덮는다. 시각이 지난 채 남으면 5초마다 3번 더 보고, 알리지 않으며, 없어졌으면(RES_001) 뺀다. + - 받은편지함 주소에서 연 검색이 메일함 목록을 받기 전이면 받아 본 뒤 기본 메일함에서 찾는다(받지 못하면 받지 못함 화면). + - 강조 위치는 그 위치를 준 응답의 글에만 쓴다(Dart 에 NFC 가 없다). 강조 쿼리는 `searchHighlight` 칸이 있던 결과에서만 싣는다. [이미지 보기]는 같은 쿼리로 본문 · 첨부 · 강조를 함께 바꾼다. 읽기 화면이 강조 응답의 요약(NFC 제목)을 아래 목록에 알리는 것은 받아들였다(글자는 같고 목록이 다시 맞추면 돌아온다). + - 크기: 전환 칸 글 15, "찾을 곳" 글자색 `textSecondary`, 상태 화면 제목 15(보드), 안내 줄 아이콘 16. + - 앱에서 새로 쓴 글: 찾기 전 안내, 낭독 글 "닫기" · "시작 날짜 지우기" · "끝 날짜 지우기"(2차). 1차의 "검색어에는 낱말을 8개까지 …"는 2차에서 웹 글로 바꿨다. 화면 글은 `MailSearchTexts` 한 곳에 있다. +- **알려진 한계** + - 결합 문자가 붙은 라틴 글자(e + U+0301)는 한 글자 판정에서 모으지 않는다(한글 자모만 모은다). + - Dart 의 유니코드 자료 판이 JS 와 달라 소문자 437자 · 글자 분류 4,433자가 다르다(대부분 Unicode 16 의 새 글자). 검색은 한 글자 판정에만 써서 결과에 영향이 없고, 받는 사람 추천은 그 글자들의 대소문자를 웹과 다르게 맞출 수 있다. + - 보낸 사람이 본문에 쓴 `` 도 같은 노랑이다. 본문의 맞은 말 사이를 옮겨 다니지 못한다. + - 조건 줄의 "나" 견주기는 한글만 모아 견준다(Dart 에 NFC 가 없다) — 내 이름을 풀어 쓴 라틴 글자(e + U+0301)로 넣으면 "나"로 읽지 못하고 "{이름} 님이 보낸 메일"이 된다(웹은 NFC 로 모은다). + - 조건 시트가 한 스크롤 모양일 때(가로 화면에 자판이 올라온 때)는 끌어 내려 닫을 수 없다 — 바깥 스크롤이 손짓을 가져간다. [✕] · 뒤로 가기로 닫는다. +- **실기기에서 볼 것**: 두 줄 칸 + 글 자판의 [검색] 키(삼성 · Gboard), 한글 조합 중 [검색], 하드웨어 자판 Enter, 여러 줄 붙여 넣기 · 삼성 둥근 따옴표, 결과를 끌어 자판이 내려가는지, 자판이 열릴 때 탭 막대 · [되돌리기] 알림 자리, + 큰 글자에서 전환 칸 · 개수 줄 · 결과 줄 메일함 이름, TalkBack 차례 · 개수 줄 읽기 · 칸 이름 한 번 · 찾을 곳 고름 상태 · [더 찾기] 꺼짐, 노랑 대비 · WebView mark, 큰 메일함의 [더 찾기] 흐름, 길게 누르기가 진동 없이 아무 일도 없는지, + 보내기 취소를 기다리는 메일의 "보내는 중"이 풀리는지, 앱 로그인 서버(feat/app-auth) + 14단계 서버로 실제 검색 · 강조 · 준비 상태(앱만 쓰는 직원의 색인이 시작되는지). + (2차) 자판과 함께 시트가 올라가고 초점 칸이 보이게 스크롤되는지 · 가로 화면 + 자판, 기간 메뉴가 뜨는 자리, 날짜 창의 한국어 머리와 연필(입력) 모드에서 오늘 뒤 날짜를 막는지, + 뒤로 가기 · 끌어 닫기 · 가림막에서 고친 값을 버리는지와 닫은 뒤 자판, TalkBack([조건] 펼침 · 기간 단추 · 날짜 칸 값과 오류 · 단추 줄 위 알림 · 닫은 뒤 초점), 큰 글자의 날짜 칸 · 여러 줄 조건 줄, + 바뀐 가림막 색(옮기기 시트 포함), 14단계 서버에서 조건만 · 검색어 + 조건(시간대 · 첨부) · from/to 를 실은 강조. +### 새 메일 알림 · 업데이트 · 앱 아이콘 (`App1Push` — 2026-11-07 확정) +- **알림 글은 "새 메일 N통"뿐**이다 — 보낸 사람 · 제목은 Google 서버를 거치지 않게 싣지 않는다(잠금 화면도 수만). 새 메일이 더 오면 같은 알림의 수만 바뀌고 쌓이지 않는다. + 앱을 보고 있을 때는 띄우지 않는다 — 받은편지함이 보이면 그 자리에서 바로 새 메일을 확인한다. 앱으로 돌아오면 알림창의 새 메일 알림을 지운다. + 누르면 늘 메일 탭의 받은편지함이다(읽기 · 쓰기 화면 위에 있었어도 — 쓰던 메일은 임시보관함에 마저 저장하고 알린다). 알림 데이터에는 모듈 이름과 종류만 싣는다. +- **로그인이 끝났다는 알림**: "새 메일 알림이 멈췄습니다" · "안전을 위해 로그아웃했습니다. 앱을 열어 다시 로그인해 주세요."(보드 ⑧ "로그인이 저절로 끝났을 때"의 글). + 보내는 때는 2026-11-08 사용자 결정으로 넓어졌다 — 표의 원본은 서버 `modules/push/push.constants.ts` 의 `SESSION_ENDED_NOTICE` 이고, 여기에는 뜻만 적는다. + - 보낸다: 저절로 끝남(14일 동안 쓰지 않음 · 로그인한 지 30일) · 다른 곳에서 비밀번호를 바꿈(바꾸다 결과를 알 수 없게 된 때도) · 앱 로그인 갱신 유예로 끊김 · 리프레시 토큰 재사용을 알아채 끊김. + - 보내지 않는다: 그 휴대폰에서 로그아웃 · 로그인된 기기에서 끊기 · 다른 곳 모두 로그아웃 · 운영 도구(재설정 · 잠금 · 이전 마치기 · 퇴사 마무리) · 계정을 쓸 수 없음 · 2단계 인증을 켜서 끊긴 세션(제한된 세션). + - 서버가 끝난 세션을 5분마다 정리할 때 한 번 보내고, 같은 tag 로 앞의 "새 메일 N통"을 바꿔 쓴다. 누르면 다른 알림처럼 메일 탭으로 가고, 세션이 없으니 첫 요청에서 로그인 화면이 된다(앱은 알림의 종류로 가르지 않는다). +- **허락은 받은편지함에서 한 번 권한다**(①): 안드로이드 13 이상 · 아직 묻지 않음 · 이 휴대폰에서 켬 · 서버가 보낼 수 있음 · 닫은 적 없음일 때만 보인다. 위 막대 아래에 고정이고(목록과 함께 흐르지 않는다) 메일을 고르는 동안은 숨긴다. + 휴대폰이 묻는 창(②)은 [알림 켜기]를 누를 때만 띄운다 — 두 번 거절하면 휴대폰이 다시 묻지 않으므로 직원이 고른 때에만 쓴다. 안드로이드 12 이하는 묻지 않고 처음부터 켜져 있다. + 고른 뒤(③): 허용 → "새 메일 알림을 켰습니다." · 허용 안 함 → "나중에 [설정] 탭에서 …" · 이미 막혀 창이 뜨지 않음 → 알림 줄 + [설정 열기]. 어느 쪽이든 줄은 사라지고, [✕]로 닫으면 이 휴대폰에서 다시 보이지 않는다. +- **설정 › 새 메일 알림 줄**(④ — 그 밖의 칸 · 로그아웃 확인 창은 `App1Shell`): + + | 상태 | 스위치 | 그 밖 | + | --- | --- | --- | + | 켜짐 · 꺼짐 | 누를 수 있음 | — | + | 허락 전(13 이상 — 묻지 않음 · 한 번 거절) | 꺼짐 · 누를 수 있음 | 켜면 휴대폰이 묻는다 | + | 켜는 중 · 끄는 중 | 바꿀 값 쪽 · 다시 받지 않음 | 둥근 진행 표시 — 화면 낭독기는 "켜는 중" · "끄는 중" | + | 휴대폰에서 이 앱 알림이 막힘 | 누를 수 없음 | [설정 열기] → 이 앱의 알림 설정 | + | 휴대폰에서 새 메일 카테고리만 꺼짐 | 누를 수 없음 | [설정 열기] → 그 카테고리 | + | 서버가 보낼 수 없음 · 받을 수 없는 휴대폰(Google Play 서비스 없음 등) | 누를 수 없음 | 앱을 열어 확인해 달라는 글 | + + 켜기는 서버 등록이 끝나야 켜지고(실패하면 꺼진 채로 돌아가고 그 코드의 사전 문구 — 처음 값이 켬인 휴대폰이 허락 전에서 켰어도 꺼짐으로 둔다), 끄기는 서버 등록을 지운 뒤 꺼진다. 휴대폰 설정에서 돌아오면 다시 읽어 모습을 바꾼다. + [설정 열기]가 휴대폰 설정을 열지 못하면 찾아가는 길을 알린다(④ — 보드의 "설정을 열지 못했을 때"). 화면 낭독기는 스위치를 "새 메일 알림"으로 읽고 줄 아래 글을 설명으로 읽는다. +- **휴대폰 설정의 카테고리**(⑫): 이름 "새 메일" · 설명 "새 메일이 오면 알려 드립니다."만 앱이 정한다. 중요도 높음 — 다른 앱을 쓰는 중에는 화면 위에 잠깐 떴다가 상태 표시줄에 남는다(소리 · 진동은 휴대폰 설정). +- **작은 아이콘**(⑨): ARA 글자 마크의 흰 실루엣(휴대폰은 모양만 쓰고 색은 직접 칠한다 — 알림 앞 동그라미는 강조 #0E7C86). 상태 표시줄에서 가늘다는 걱정이 있어 실기기에서 다시 본다 — + 봉투로 바꾸려면 같은 이름(`ic_stat_ara`)의 그림만 바꾼다(앱 매니페스트의 기본 아이콘이 이 이름 하나를 가리킨다 — 서버 메시지는 아이콘 · 색을 싣지 않는다). +- **앱 아이콘**(⑪ — 나안): 남색 #0B3640 바탕 + 흰 ARA 마크. 적응형(배경색 · 앞면 마크 · 안드로이드 13 테마 아이콘용 마크만 담은 그림 한 장 더)이고 마크는 잘리지 않는 가운데 칸 안에 둔다. + 안드로이드 7(적응형 전)의 아이콘도 같은 모양이다. 삼성 휴대폰은 알림창에도 앱 아이콘을 쓴다. +- **업데이트 화면**(⑩): 이 앱의 판이 서버가 정한 최소 판보다 낮으면 로그인 전이든 메일을 보던 중이든 이 화면으로 온다(쓰던 메일은 임시보관함에 저장한다). 뒤로 가기는 앱을 닫는다. + [Play 스토어 열기]는 Play 스토어 앱부터, 열리지 않으면 웹 주소. 문의 줄은 로그인 화면과 같은 연락처다. 서버가 최소 판을 내리면 다음 확인(앱으로 돌아올 때 — 1시간마다)에서 원래 화면으로 돌아간다. +- **로그아웃**: 확인 창이 이 휴대폰에 새 메일 알림도 오지 않는다고 알린다(`App1Shell`). 토큰을 지운 뒤 서버 logout 과 함께 이 휴대폰의 알림을 멈춘다(알림창 지우기 · FCM 등록 풀기 — 서로 기다리지 않는다). + 서버 줄은 서버가 세션과 함께 지운다. 끊긴 망이라 FCM 등록을 풀지 못했으면 다음 실행 · 다음에 앱으로 돌아올 때 다시 풀고, 그사이 같은 휴대폰에서 다른 직원이 로그인하면 그 등록이 이어받는다(같은 FID 로 올려 서버의 앞 직원 줄이 지워진다). +- 글자는 테마 크기로 맞췄다(15.5 → 16 · 14 → 15 · 13.5 → 13 · 13 → 13 — 위 「쓰기」와 같은 정리). +- **구현하며 정한 것**(2026-11-07 — 보드에 없는 글 · 경우) + - 권하기 줄의 [알림 켜기]를 눌렀는데 새 메일 카테고리만 꺼져 있으면: "휴대폰 설정에서 새 메일 알림이 꺼져 있습니다." + [설정 열기](막혔을 때와 같은 꼴). + - 끄는 중의 화면 낭독기 글 "끄는 중"(보드에는 켜는 중만 있다). + - 문의 연락처를 정하지 않았거나 받지 못했을 때: 설정 › 문의 "앱이나 계정 문제로 도움이 필요하면 관리자에게 문의해 주세요." · 업데이트 화면 "Play 스토어에 업데이트가 보이지 않나요? 관리자에게 문의해 주세요."(로그인 화면의 문의 줄과 같은 꼴). + - 서버가 보낼 수 있는지 아직 모르면(앱 설정을 받기 전 · 받지 못함) 쓸 수 있는 것으로 본다 — 막지 않는다. 앱 설정은 앱을 띄우자마자 세션 되살리기와 나란히 묻는다. + - 알림의 N 은 앱을 마지막으로 본 때부터 센다 — 앱으로 돌아올 때 · 앱을 보는 중 새 메일 알림을 받을 때 "봤음"을 보낸다(30초에 한 번 · 기다리는 동안 뒤로 가면 그 자리에서). 보는 중에 받은 알림 뒤의 봤음은 받은 때부터 1.5초가 지나기 전에는 보내지 않는다(30초 간격 끝이 더 늦으면 그때 — 그사이 뒤로 가면 그 자리에서 보내 같은 초가 될 수 있다, 받아들였다)(2026-11-08 실기기 — 서버는 봤음의 초와 같은 초에 받은 메일도 세는데 받은 시각이 초 단위라, 곧바로 보내면 열지 않은 그 메일이 다음 알림에 다시 세어져 한 통이 더 많았다). +- **실기기 · 실제 Firebase 에서 볼 것**: FID 등록 소식(`onRegistered`)이 자동 초기화를 끈 채 오는지 · 앱 데이터를 지운 뒤 새 FID, 같은 알림을 바꿔 쓰는지(태그 `new-mail`)와 앱으로 돌아올 때 지워지는지, + 앱을 보는 중에는 시스템 알림이 뜨지 않는지, 뒤에 있을 때 · 꺼져 있을 때 누른 알림이 받은편지함을 여는지, 두 번 거절한 뒤 스위치가 [설정 열기] 쪽으로 바뀌는지, + 카테고리만 끈 상태를 읽는지 · [설정 열기]가 그 카테고리를 여는지(삼성 · Pixel), 상태 표시줄의 작은 아이콘 굵기 · 삼성 알림창의 앱 아이콘 · 테마 아이콘, 절전(Doze) 중 도착 시간, + Play 스토어가 없는 휴대폰의 [Play 스토어 열기], TalkBack(권하기 줄 묶음 · 스위치 이름과 설명 · 켜는 중 알림). + - 2026-11-08 갤럭시 S23(Android 16)에서 본 것: 등록 소식(자동 초기화를 끈 채) · 같은 알림 바꿔 쓰기와 앱으로 돌아올 때 지우기 · 보는 중에는 그리지 않음 · 뒤에 있을 때와 꺼져 있을 때 누르면 받은편지함 · 작은 아이콘(잘 보인다) · 홈 화면 배지 · 바꿔 쓴 알림도 다시 소리를 냄 · 로그아웃 때 FCM 풀기(서버 줄은 서버가 지운다). 서버 쪽은 계약 1절 "실기기 · 실제 키로 확인할 것". + - 알려진 한계(2026-11-08): FID 로 등록하는 길은 Google Play 서비스 26.12(versionCode 261200000) 이상에서만 탄다 — 그보다 옛 휴대폰은 SDK(firebase-messaging 26.0.1)가 옛 토큰 길로 돌아가 등록 소식(`onRegistered`)의 값이 FID 가 아닐 수 있고, 서버는 FID 모양만 받으므로 켜기가 실패할 수 있다(아직 고치지 않았다 — 등록에 드는 매니페스트 표시는 flutter/README "새 메일 알림"). ### 처음 판에서 미룬 것 -서식 편집 · 대용량 첨부 · 필터 · 주소록 화면 · 서명 고치기 · 계정 설정(웹에서) · 오프라인 저장 · iOS. +서식 편집 · 대용량 첨부 · 필터 · 주소록 화면 · 서명 고치기 · 계정 설정(웹에서) · 오프라인 저장 · iOS. 검색은 결과에서 여러 통 고르기를 미뤘다(읽기 화면의 같은 대화 · 목록 대화로 묶기도 — 보드 먼저). diff --git a/docs/design/webmail/App1Push.dc.html b/docs/design/webmail/App1Push.dc.html new file mode 100644 index 0000000..be46dbf --- /dev/null +++ b/docs/design/webmail/App1Push.dc.html @@ -0,0 +1,835 @@ + + + + +앱 · 새 메일 알림 · 업데이트 · 앱 아이콘 + + + + + + + +
+ +
+
+

허락 묻기와 설정

+

앱 화면입니다. 받은편지함에서 한 번 권하고, 휴대폰이 묻는 창은 [알림 켜기]를 누를 때만 띄웁니다. 안드로이드 12 이하는 묻지 않고 처음부터 켜져 있습니다.

+
+
+
+
+
+ + + +
+
+
+
+ +
+
+

1. 받은편지함 · 권하기 줄

+

안드로이드 13 이상에서 아직 묻지 않았을 때만 보입니다. [✕]로 닫으면 다시 보이지 않습니다.

+
+
+
+ +
+

받은편지함

+ 안 읽은 메일 3통 +
+ +
+
+ +
+

새 메일 알림을 켜면 앱을 닫아 두어도 새 메일이 오는 대로 알려 드립니다.

+ +
+ +
+
    + +
  • +
    + + + + {{ r.who }} + {{ r.time }} + + + {{ r.subject }} + + + + {{ r.preview }} + +
    +
  • +
    +
+ 메일 쓰기 + +
+
+ +
+
+

2. 휴대폰이 묻는 창

+

[알림 켜기]를 누르면 안드로이드가 띄우는 창입니다. 모양과 글은 휴대폰마다 다릅니다.

+
+
+
+ +
+

받은편지함

+ 안 읽은 메일 3통 +
+ +
+
+ +
+

새 메일 알림을 켜면 앱을 닫아 두어도 새 메일이 오는 대로 알려 드립니다.

+ +
+ +
+
    + +
  • +
    + + + + {{ r.who }} + {{ r.time }} + + + {{ r.subject }} + + + + {{ r.preview }} + +
    +
  • +
    +
+ 메일 쓰기 + + +
+ +

ARA SYSTEM에서 알림을 보내도록 허용하시겠습니까?

+
+ + +
+
+
+
+ +
+
+

{{ afterTitle }}

+

{{ afterNote }}

+
+
+
+ +
+

받은편지함

+ 안 읽은 메일 3통 +
+ +
+
    + +
  • +
    + + + + {{ r.who }} + {{ r.time }} + + + {{ r.subject }} + + + + {{ r.preview }} + +
    +
  • +
    +
+ +
+ 메일 쓰기 +
+ {{ afterText }} + +
+
+
+
+ +
+
+

4. 설정 › 새 메일 알림 줄

+

설정 탭의 다른 칸과 로그아웃 확인 창은 App1Shell 그대로입니다. 알림 줄만 상태마다 그렸습니다. 왼쪽은 스위치를 누를 수 있는 상태, 오른쪽은 누를 수 없는 상태입니다.

+
+
+
+ +
+

{{ c.name }} · {{ c.when }}

+
+
+
+ 새 메일 알림 + {{ c.desc }} + + + + + +
+ + {{ c.busyText }} + + + +
+
+
+
+
+

설정을 열지 못했을 때 · [설정 열기]를 누른 뒤 뜨는 알림

+
휴대폰 설정을 열지 못했습니다. 휴대폰의 [설정] 앱에서 ARA SYSTEM을 찾아 알림을 켜 주세요.
+
+
+
+ +
+

{{ c.name }} · {{ c.when }}

+
+
+
+ 새 메일 알림 + {{ c.desc }} + + + + + +
+ + {{ c.busyText }} + + + +
+
+
+
+
+
+
+ +
+
+ +
+
+

휴대폰에 오는 알림

+

안드로이드가 그리는 모습입니다(Pixel 같은 휴대폰). 알림 글은 ‘새 메일 N통’뿐이고, 앱을 보고 있을 때는 띄우지 않습니다. 왼쪽 위 단추로 작은 아이콘을 바꿔 볼 수 있습니다.

+
+
+
+ + + +
+
+
+
+
+
+
+ +
+
+

5. 다른 앱을 쓰는 중

+

화면 위에 잠깐 떴다가 상태 표시줄에 작은 아이콘으로 남습니다. 소리와 진동은 휴대폰 설정을 따릅니다.

+
+
+ + + +
+
+ +
+
+

6. 알림창

+

새 메일이 더 오면 이 알림의 수만 바뀌고 쌓이지 않습니다. 누르면 메일 탭의 받은편지함이 열립니다.

+
+
+ + +
+
+ +
+
+

7. 잠금 화면

+

잠금 화면에도 수만 보입니다. 보낸 사람과 제목은 알림에 싣지 않습니다.

+
+
+ + + + + +
+
+ +
+
+

8. 로그인이 끝났을 때

+

저절로 끝났거나 다른 곳에서 비밀번호를 바꿔 끊겼을 때 한 번 옵니다. ‘새 메일 N통’ 자리를 바꿔 씁니다.

+
+
+ + +
+
+ +
+
+

9. 작은 아이콘 후보

+

상태 표시줄과 알림 앞의 동그라미에 쓰는 아이콘입니다. 휴대폰은 모양만 쓰고 색은 직접 칠합니다.

+
+
+ +
+

{{ k.title }}

+
+
+ +
24dp 칸을
4배로
+
+
+ + + 상태 표시줄, 실제 크기 (옆은 다른 앱 아이콘) +
+
+ +

{{ k.note }}

+
+
+
+
+ +
+
+ +
+
+

업데이트 화면과 앱 아이콘

+

앱 아이콘은 아직 Flutter 기본이라 이번에 함께 정합니다. 삼성 휴대폰은 알림창에도 앱 아이콘을 씁니다.

+
+
+
+ + + +
+
+
+ + + +
+
+
+ +
+
+

10. 업데이트 화면

+

최소 버전보다 낮으면 로그인 전이든 메일을 보던 중이든 이 화면으로 옵니다. 뒤로 가기는 앱을 닫습니다.

+
+
+
+
+ ARA SYSTEM +
+
+
+

앱을 업데이트해 주세요

+

지금 쓰는 버전으로는 로그인하거나 메일을 볼 수 없습니다. Play 스토어에서 ARA SYSTEM을 업데이트한 뒤 다시 열어 주세요.

+
+ +

지금 쓰는 버전 1.0.0

+

Play 스토어에 업데이트가 보이지 않나요?
IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it

+
+
+ +
Play 스토어를 열지 못했습니다. Play 스토어 앱을 직접 열어 ARA SYSTEM을 찾아 업데이트해 주세요.
+
+
+
+ +
+
+

11. 앱 아이콘 후보

+

안드로이드 적응형 아이콘입니다. 휴대폰마다 동그라미나 둥근 네모로 잘라 씁니다. 마크는 잘리지 않는 가운데 칸 안에 둡니다.

+
+
+
+ +
+
+

{{ a.title }}

+

{{ a.sub }}

+
+
+
+ +
동그라미 (Pixel 등)
+
+
+ +
둥근 네모 (삼성 등)
+
+
+
+ + 홈 화면 · 밝은 바탕화면과 어두운 바탕화면 +
+
+ + 삼성 휴대폰 알림창 (실기기에서 확인) +
+

{{ a.note }}

+
+
+
+
+ +

안드로이드 13 이상에서 휴대폰의 테마 아이콘을 켜면 셋 모두 바탕을 버리고 마크만 휴대폰 색으로 칠합니다. 그래서 마크만 담은 그림을 한 장 더 넣습니다.

+
+
+
+ +
+
+

12. [설정 열기]가 여는 휴대폰 설정

+

안드로이드가 그려 모양은 휴대폰마다 다릅니다. ‘새 메일’이라는 이름과 설명만 앱이 정합니다.

+
+
+ + +
+
+ +
+
+ +
+
+ + + diff --git a/docs/design/webmail/App1Search.dc.html b/docs/design/webmail/App1Search.dc.html new file mode 100644 index 0000000..7728f77 --- /dev/null +++ b/docs/design/webmail/App1Search.dc.html @@ -0,0 +1,484 @@ + + + + +앱 · 메일 검색 + + + + + + + +
+
+
+ + + +
+
+
+ +
+

찾기 전 (자판 올라옴 · 탭 막대 숨김)

+
+
+ + + + + +
+
+
+ 찾을 곳 +
+ + +
+
+
+

보낸 사람, 받는 사람, 제목, 본문, 첨부 파일 이름에서 찾습니다. 여러 낱말을 넣으면 모두 들어간 메일을 찾습니다.

+
+ +
+
+ +
+

찾는 중

+
+
+ + + +
+
+
+ 찾을 곳 +
+ + +
+
+
+

‘계약’ · 찾는 중

+
+ 찾는 중 + +
+ +
+
+ +
+

결과 · 받은편지함에서

+
+
+ + + +
+
+
+ 찾을 곳 +
+ + +
+
+
+

‘계약’ · 4통

+ + +
+
+ +
+

결과 · 전체 메일함에서

+
+
+ + + +
+
+
+ 찾을 곳 +
+ + +
+
+

휴지통과 스팸메일함은 빼고 찾습니다

+
+

‘계약’ · 6통

+ + +
+
+ +
+

검색 준비 중

+
+
+ + + +
+
+
+ 찾을 곳 +
+ + +
+
+
+

‘계약’ · 3통

+
+ +

지난 메일을 검색할 수 있게 밤사이에 준비하고 있습니다. 메일이 많으면 며칠 걸릴 수 있고, 그동안은 낱말이 통째로 같은 메일만 찾습니다.

+
+ + +
+
+ +
+

한 글자로 찾음

+
+
+ + + +
+
+
+ 찾을 곳 +
+ + +
+
+
+

‘김’ · 2통

+
+ +

한 글자로만 찾을 때는 본문은 빼고 제목, 보낸 사람과 받는 사람, 첨부 파일 이름에서 찾습니다. 본문까지 찾으려면 두 글자 이상으로 찾아 주세요.

+
+ + +
+
+ +
+

{{ b7Title }}

+
+
+ + + +
+
+
+ 찾을 곳 +
+ + +
+
+
+

‘해지 통보’ · 0통

+ +
+ +

지난 메일을 검색할 수 있게 밤사이에 준비하고 있습니다. 메일이 많으면 며칠 걸릴 수 있고, 그동안은 낱말이 통째로 같은 메일만 찾습니다.

+
+
+
+ +

‘해지 통보’에 맞는 메일이 없습니다

+

다른 말로 찾아 보세요.

+ +

준비가 끝나기 전에는 ‘서버이전’의 ‘서버’처럼 붙여 쓴 말의 일부나, 제목 맨 앞의 [공지]나 Re: 같은 말머리로는 찾지 못합니다.

+
+ +
+ +
+
+ +
+
+
+ + + diff --git a/docs/design/webmail/App1SearchConditions.dc.html b/docs/design/webmail/App1SearchConditions.dc.html new file mode 100644 index 0000000..c57617f --- /dev/null +++ b/docs/design/webmail/App1SearchConditions.dc.html @@ -0,0 +1,599 @@ + + + + +앱 · 메일 검색 · 조건으로 찾기 + + + + + + + +
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+
+ +
+

{{ b14Title }}

+
+
+ + + + +
+
+
+ 찾을 곳 +
+ + +
+
+
+

‘계약’ · 4통

+ + + +
+ +
+

조건으로 찾기

+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + + + +
+
+ 시작 날짜 + +
+
+ 끝 날짜 + +
+
+
+
+
+ 첨부 + +
+

검색 칸에 쓴 말이 들어 있고 이 조건에도 맞는 메일을 찾습니다. 받는 사람으로 찾을 때는 참조로 받은 사람까지 찾습니다.

+
+
+
+ +

검색 칸과 [조건]에 넣은 낱말은 모두 합쳐 8개까지입니다. 낱말을 몇 개 줄여 주세요.

+
+
+ + +
+
+
+
+
+ +
+

{{ b15Title }}

+
+
+ + + + +
+
+
+ 찾을 곳 +
+ + +
+
+
+

‘계약’ · 4통

+ + + +
+ +
+

조건으로 찾기

+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + + + +
+
+ 시작 날짜 +
+ + +
+
+
+ 끝 날짜 +
+ + +
+
+
+

끝 날짜를 시작 날짜와 같거나 그 뒤로 골라 주세요.

+
+
+
+ 첨부 + +
+

검색 칸에 쓴 말이 들어 있고 이 조건에도 맞는 메일을 찾습니다. 받는 사람으로 찾을 때는 참조로 받은 사람까지 찾습니다.

+
+
+
+
+ + +
+
+
+ + +
+
+ 시작 날짜 + +
+ 11월 7일 (토) + +
+
+ + +
+ + +
+
+
+
+
+ +
+

{{ b16Title }}

+
+
+ + + + +
+
+
+ 찾을 곳 +
+ + +
+
+
+ +
+

조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음

+ +
+
+

{{ count16 }}

+ + +
+
+ +
+

{{ b17Title }}

+
+
+ + + + + + + + + +
+ +
+
+ 찾을 곳 +
+ + +
+
+

휴지통과 스팸메일함은 빼고 찾습니다

+
+
+ +
+
+ 찾을 곳 +
+ + +
+
+
+
+
+

{{ cond17 }}

+ +
+ +

0통

+
+ +

조건에 맞는 메일이 없습니다

+

조건을 바꾸거나 줄여 보세요.

+
+
+ +
+ +

찾을 말이 너무 많습니다

+

검색 칸과 [조건]에 넣은 낱말은 모두 합쳐 8개까지입니다. 낱말을 몇 개 줄여 주세요.

+
+
+ +
+
+ +
+
+
+ + + diff --git a/docs/design/webmail/App1SearchStates.dc.html b/docs/design/webmail/App1SearchStates.dc.html new file mode 100644 index 0000000..a3cbe52 --- /dev/null +++ b/docs/design/webmail/App1SearchStates.dc.html @@ -0,0 +1,396 @@ + + + + +앱 · 메일 검색 · 상태 + + + + + + + +
+
+
+ + + +
+
+
+ +
+

{{ b8Title }}

+
+
+ + + +
+
+
+ 찾을 곳 +
+ + +
+
+

휴지통과 스팸메일함은 빼고 찾습니다

+
+

‘정산서’

+
+ +

아직 맞는 메일을 찾지 못했습니다

+

메일이 많아 한 번에 다 찾지 못했습니다. [더 찾기]를 누르면 이어서 찾습니다.

+ + + + + + +
+ +
+
+ +
+

목록 끝 · 더 찾기

+
+
+ + + +
+
+
+ 찾을 곳 +
+ + +
+
+
+

‘견적’

+
+ +
+

메일이 많아 한 번에 다 찾지 못했습니다. [더 찾기]를 누르면 이어서 찾습니다.

+ +
+
+ +
+
+ +
+

받지 못함

+
+
+ + + +
+
+
+ 찾을 곳 +
+ + +
+
+
+
+ +

일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.

+

문의 코드 3f1c0a2e

+ +
+ +
+
+ +
+

찾을 말이 너무 많음 (칸이 두 줄로)

+
+
+ + + +
+
+
+ 찾을 곳 +
+ + +
+
+
+
+ +

찾을 말이 너무 많습니다

+

검색어의 낱말은 8개까지 찾을 수 있습니다. 낱말을 줄여 주세요.

+
+ +
+
+ +
+

결과에서 연 메일 (첨부는 본문 위)

+
+
+ + + + +
+
+
+

Re: 유지보수 계약 갱신 건

+
+ +
+
+ 이도윤 + 오전 10:42 +
+ +

메일함받은편지함

+
+
+
+

첨부 파일 1개

+
    +
  • + +
    + 계약서_초안_v2.pdf + 248 KB +
    + +
  • +
+
+
도윤입니다. + +말씀하신 조건으로 계약서 초안을 고쳐 첨부했습니다. 기간은 1년이고, 단가는 작년과 같습니다. + +계약 시작일만 확인 부탁드립니다.
+
+
+ +
+
+ +
+

읽기 화면에서 지운 뒤

+
+
+ + + +
+
+
+ 찾을 곳 +
+ + +
+
+
+

{{ count13 }}

+ + + +
+ 메일 1통을 휴지통으로 옮겼습니다. + +
+
+
+
+ +
+
+
+ + + diff --git a/docs/design/webmail/canvas.json b/docs/design/webmail/canvas.json index e91d906..147100d 100644 --- a/docs/design/webmail/canvas.json +++ b/docs/design/webmail/canvas.json @@ -54,9 +54,13 @@ "App1MailList.dc.html": { "x": 988, "y": 32000, "w": 2222, "h": 956, "title": "앱 · 메일 목록", "is_interactive": true }, "App1Reader.dc.html": { "x": 3290, "y": 32000, "w": 1346, "h": 956, "title": "앱 · 읽기", "is_interactive": true }, "App1Compose.dc.html": { "x": 4716, "y": 32000, "w": 1784, "h": 956, "title": "앱 · 쓰기", "is_interactive": true }, - "App1Shell.dc.html": { "x": 6580, "y": 32000, "w": 2352, "h": 976, "title": "앱 · 틀 · 설정", "is_interactive": true } + "App1Shell.dc.html": { "x": 6580, "y": 32000, "w": 2352, "h": 976, "title": "앱 · 틀 · 설정", "is_interactive": true }, + "App1Search.dc.html": { "x": 0, "y": 33450, "w": 3098, "h": 1028, "title": "앱 · 검색 (찾기 · 결과)", "is_interactive": true }, + "App1SearchStates.dc.html": { "x": 3178, "y": 33450, "w": 2660, "h": 1028, "title": "앱 · 검색 (상태 · 결과에서 연 메일)", "is_interactive": true }, + "App1SearchConditions.dc.html": { "x": 5918, "y": 33450, "w": 1784, "h": 1028, "title": "앱 · 검색 2차 (조건으로 찾기)", "is_interactive": true }, + "App1Push.dc.html": { "x": 0, "y": 34900, "w": 2222, "h": 3270, "title": "앱 · 새 메일 알림 (푸시)", "is_interactive": true } }, - "order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html", "SettingsAccount.dc.html", "SettingsVacation.dc.html", "MailboxManage.dc.html", "MailActions4.dc.html", "SettingsNotify.dc.html", "MailRealtime.dc.html", "SettingsFilter.dc.html", "MailFilterStates.dc.html", "MailFilterApply.dc.html", "MailFilterApplyStates.dc.html", "Contacts.dc.html", "ContactsEdit.dc.html", "ContactsMail.dc.html", "MailReadTools.dc.html", "MailPrint.dc.html", "MailSendLater.dc.html", "SettingsSendRead.dc.html", "ComposeLargeFiles.dc.html", "LargeFileBlock.dc.html", "MailReadLargeFiles.dc.html", "SharePage.dc.html", "SettingsLargeFiles.dc.html", "LoginTwoFactor.dc.html", "SettingsTwoFactor.dc.html", "SettingsAppPasswords.dc.html", "SettingsLoginHistory.dc.html", "LoginPasswordChange.dc.html", "LoginTwoFactorSetup.dc.html", "SettingsMailClients.dc.html", "MailNoticesCutover.dc.html", "Search14.dc.html", "Thread14.dc.html", "ListThreads14.dc.html", "App1Login.dc.html", "App1MailList.dc.html", "App1Reader.dc.html", "App1Compose.dc.html", "App1Shell.dc.html"], + "order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html", "SettingsAccount.dc.html", "SettingsVacation.dc.html", "MailboxManage.dc.html", "MailActions4.dc.html", "SettingsNotify.dc.html", "MailRealtime.dc.html", "SettingsFilter.dc.html", "MailFilterStates.dc.html", "MailFilterApply.dc.html", "MailFilterApplyStates.dc.html", "Contacts.dc.html", "ContactsEdit.dc.html", "ContactsMail.dc.html", "MailReadTools.dc.html", "MailPrint.dc.html", "MailSendLater.dc.html", "SettingsSendRead.dc.html", "ComposeLargeFiles.dc.html", "LargeFileBlock.dc.html", "MailReadLargeFiles.dc.html", "SharePage.dc.html", "SettingsLargeFiles.dc.html", "LoginTwoFactor.dc.html", "SettingsTwoFactor.dc.html", "SettingsAppPasswords.dc.html", "SettingsLoginHistory.dc.html", "LoginPasswordChange.dc.html", "LoginTwoFactorSetup.dc.html", "SettingsMailClients.dc.html", "MailNoticesCutover.dc.html", "Search14.dc.html", "Thread14.dc.html", "ListThreads14.dc.html", "App1Login.dc.html", "App1MailList.dc.html", "App1Reader.dc.html", "App1Compose.dc.html", "App1Shell.dc.html", "App1Search.dc.html", "App1SearchStates.dc.html", "App1SearchConditions.dc.html", "App1Push.dc.html"], "notes": { "flow": { "x": 0, "y": -300, "text": "arasystem 웹메일 — 1단계 화면 (로그인 → 메일함 → 쓰기)", "kind": "title1", "maxW": 4480 }, "phase2": { "x": 0, "y": 2180, "text": "2단계 화면 — 임시 저장 · 받는 사람 자동 완성 · 서명 (2026-10-27 확정)", "kind": "title1", "maxW": 2960 }, @@ -71,7 +75,10 @@ "phase12": { "x": 0, "y": 21580, "text": "12단계 화면 — 계정 보안(2단계 인증 · 앱 비밀번호 · 로그인 기록) — 2026-10-31 확정", "kind": "title1", "maxW": 6000 }, "phase13": { "x": 0, "y": 25310, "text": "13단계 화면 — 전환 준비(새 비밀번호 · 2단계 필수 · 메일 프로그램 연결 · 알림) — 2026-10-31 확정", "kind": "title1", "maxW": 6000 }, "phase14": { "x": 0, "y": 30200, "text": "14단계 화면 — 검색 · 조건 검색 · 강조 · 같은 대화 · 대화로 묶어 보기 — 2026-11-05 확정", "kind": "title1", "maxW": 4480 }, - "app1": { "x": 0, "y": 31700, "text": "앱 1단계 화면 — 로그인 · 메일 목록 · 읽기 · 쓰기 · 앱 틀 — 2026-11-05 확정(안드로이드 먼저)", "kind": "title1", "maxW": 8932 } + "app1": { "x": 0, "y": 31700, "text": "앱 1단계 화면 — 로그인 · 메일 목록 · 읽기 · 쓰기 · 앱 틀 — 2026-11-05 확정(안드로이드 먼저)", "kind": "title1", "maxW": 8932 }, + "app1search": { "x": 0, "y": 33150, "text": "앱 1단계 검색 화면 — 찾기 · 결과 · 강조 · 이어 찾기 · 안내 — 2026-11-07 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 5838 }, + "app1search2": { "x": 5918, "y": 33150, "text": "앱 검색 2차 — 조건으로 찾기 · 날짜 · 조건 줄 — 2026-11-07 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 1784 }, + "app1push": { "x": 0, "y": 34600, "text": "앱 새 메일 알림 — 허락 묻기 · 설정 · 휴대폰 알림 · 업데이트 · 앱 아이콘 — 2026-11-07 확정(글은 화면 상수가 기준)", "kind": "title1", "maxW": 2222 } }, "designSystems": [] } diff --git a/docs/mail/api-contract.md b/docs/mail/api-contract.md index 9a7f2e7..39fffd6 100644 --- a/docs/mail/api-contract.md +++ b/docs/mail/api-contract.md @@ -611,6 +611,16 @@ interface RejectedRecipient { 원문은 `htmlBody`(없으면 `textBody`)를 편집기로 옮기며, 편집기가 모르는 요소(이미지 등)는 빠진다. - **본문 안 이미지는 이번 단계에서 넣지 않는다** — 붙여 넣거나 끌어다 놓은 이미지는 첨부 파일로 올린다 (첨부 한도 규칙 그대로). 서명 그림만 예외다 (아래 "서명"). +- **앱이 만드는 모양**(2026-11-07 — 앱 쓰기 화면은 글만 쓴다. 웹이 다시 열 때 인용을 알아보는 모양이다): + - 감싸개는 위 기본 글꼴 div 이고, 내 글은 줄마다 `

`(빈 줄은 `


`, 여러 칸 공백 · 줄 앞 공백 · 탭은 위 nbsp 규칙)이다. + - 서명이 있으면 빈 문단 하나 뒤의 **속성 없는 `
` 하나**가 서명 경계다 — 안은 서버가 준 서명 HTML 이고, margin 이 없는 `p` 에만 `margin: 0` 을 앞에 붙인다. + - 원문 인용은 웹과 같은 "원본 메일" 머리 문단 + 머리 표(답장은 보낸 사람 · 날짜 · 제목, 전달은 받는 사람 · 참조도 — 날짜는 "2026. 11. 5. (목) 오전 10:42")에 + **글로 만든 원문 줄**(`

` — 원문 속 인용은 안쪽 blockquote, 링크는 "글 (주소)")이다. 원문의 표 · 색 · 링크 모양은 빠지고 글은 남는다. + 원문이 길면 처음 95% · 저장 · 보내기 직전 100% 로 맞추고, 줄였으면 끝에 "(원문이 길어 이하 생략)" 문단을 둔다. 인용 경계 표시는 대용량 파일이 있을 때만이다(4절). + - 쓴 글 · 서명 · 인용이 모두 없으면 `bodyHtml` 을 싣지 않는다(텍스트 메일). `body` 는 내 글 줄 + (서명이 있으면 빈 줄 + 텍스트 서명) + 인용 줄(웹 `docToText` 와 같은 꼴 — "> 원본 메일" · "> 항목: 값" · ">" · "> 원문" · "> > 안쪽")이다. + - **다시 열기**: 감싸는 div(속성 없음 또는 앱 글꼴 style) 아래가 "글 · br 만 든 문단(속성 없음 또는 style 이 `margin:0` 하나 — 공백 · 끝 `;` 를 빼고 견줌) → (속성 없는 div 하나 = 서명) → (빈 div) → (원본 메일 blockquote)"이면 칸마다 되살린다. + 그 밖(웹의 정렬 · 들여쓰기 · 꾸밈 · 다른 프로그램의 저장본)은 인용 앞부분을 글로 바꿔 본문 칸에 넣고 안내 줄을 보이며, 본문을 고치지 않으면 원래 HTML · 텍스트를 그대로 다시 보낸다. + 읽기 화면과 같은 한도(서식 본문 2,000,000자 · 중첩 비용 8,000,000)를 넘는 저장본은 읽지 않고 텍스트 본문으로 연다. ### 첨부 업로드 한도 @@ -775,6 +785,8 @@ interface MailState { 평소 사용과 같은 칸을 쓴다 — `GET /api/messages` 는 대략 "쓰고 있는 탭 수 × 1분에 1번 + 평소 목록 · 더 보기"다(3단계). 5단계부터는 실시간 연결이 살아 있는 동안 1분 확인 대신 "바뀔 때마다(1초 모아서) 보이는 탭 수 × 1번"이다 — 사무실이 바쁠수록 늘 수 있다. 운영 전환 때 직원 수에 맞춰 정한다. - 5단계부터는 실시간 연결(아래 "실시간 알림")이 살아 있는 동안 1분 확인을 멈추고, 끊기면 1분 확인으로 돌아간다. 이 API 와 규칙은 그대로다. +- **클라이언트 — 앱**(Flutter — 앱 디자인 README "새 메일 알림 · 업데이트 · 앱 아이콘", 2026-11-07): 메일 목록이 보이고 앱이 앞에 있는 동안만 1분마다 묻는다(앞으로 오면 10초 규칙 — 웹과 같다). 실시간 연결(SSE)은 쓰지 않는다. + 앞에 있을 때 받은 새 메일 알림(1절 "앱 새 메일 알림 · 앱 설정" — 앱이 앞에 있으면 휴대폰이 알림을 그리지 않는다)이 오면 1분 차례를 기다리지 않고 바로 한 번 묻는다 — 묻는 중이면 그 물음이 끝난 뒤 한 번 더, 목록이 보이지 않으면(다른 탭) 묻지 않는다. ### 검색 (`GET /api/messages` 의 `q`) — 3단계 @@ -848,7 +860,7 @@ interface MailState { 함께 찾은 한 글자 낱말은 본문에서 맞았어도 강조하지 않는다(본문 곳곳에 흩어진다 — 2026-11-06 실사용 시험). 두 글자 이상 낱말이 보낸 사람 · 받는 사람 조건에만 있어도 섞인 검색이다. - 본문은 앞 2만 자까지 찾는다. 첨부 파일 안의 글 · 끝이 바뀐 말('보내' → '보냈습니다')은 찾지 않는다(첨부 이름은 찾는다). 받는 사람 칸은 주소들을 이어 보므로 구절이 이웃한 두 주소에 걸쳐 맞을 수 있다(작은 한계). - **응답 (목록)** - - 항목(MessageSummary)에 `searchHighlight: { subject: [start, end][], preview: { text, ranges: [start, end][] } } | null` — 색인 방식 검색에서만 차고, 메일함 목록 · 엔진 방식은 `null`. + - 항목(MessageSummary)에 `searchHighlight: { subject: [start, end][], preview: { text, ranges: [start, end][] } } | null` — 색인 방식 검색에서만 차고, 메일함 목록 · 엔진 방식은 `null`. 칸은 늘 싣는다(값이 없으면 `null`) — 앱은 이 칸이 있는지로 14단계 서버를 알아보고 읽기 화면에 강조 쿼리를 싣는다. 위치는 NFC 글의 UTF-16 [시작, 끝)이고, 강조가 있으면 `subject` 도 NFC 로 싣는다. `preview.text` 는 본문에서 맞았으면 맞은 말 둘레 한 줄(잘린 쪽에 "…"), 아니면 엔진 미리 보기다. 본문의 맞은 곳은 이 차례로 고른다(2026-11-06 실사용 시험 — 답장은 처음 맞은 곳이 거의 늘 원문 인용 안이었다). ① 인용 줄(앞 공백 뒤 `>`) 밖의 첫 맞은 곳 — 둘레는 인용 줄 전까지. @@ -880,6 +892,19 @@ interface MailState { 그래도 못 찾았으면 [더 찾기] 화면이다. 다음 쪽의 `total` 이 `null` 이면 첫 쪽의 수를 남기고, 첫 쪽부터 `null` 이면 통 수 · "n / 전체"를 보이지 않는다. 목록을 새로 고치는 동안은 이어 받지 않는다(옛 커서로 붙지 않게). - **클라이언트 — 준비 상태**: `GET /api/search-index` 를 받지 못하면(실패) "검색 준비 중" · 옛 한계 안내 · 한 글자 안내를 모두 보이지 않는다. 한 글자 안내는 `ready` 이고 본문을 찾을 말(검색어나 내용 조건)이 있을 때만이다. +- **클라이언트 — 앱**(Flutter 1차 · 2차 — 앱 디자인 README 「검색」, 2026-11-07) + - 검색어 · 찾을 곳 · 조건을 주소에 싣지 않는다(화면 상태 — 시작한 메일함만 라우트 `extra`). 웹의 "화면 주소" 줄은 앱에 없다. + - 자판의 [검색]으로만 찾고 서버와 같은 한도(앞뒤 공백을 뺀 UTF-16 200자 · 낱말 8개 — 2차부터 검색어와 조건 넷을 합쳐)를 먼저 본다. 넘으면 묻지 않는다 — 자판의 [검색]이면 결과 자리에 "찾을 말이 너무 많습니다", 조건 시트의 [찾기]면 시트 안 단추 줄 위 글이다(웹 검색 칸 · 패널은 `VAL_001` 사전 문구 알림). + [✕]는 검색어와 결과를 지운다(웹의 ✕ 는 검색을 닫는다 — 앱은 [←] · 뒤로 가기가 닫는다). + - (2차) 조건은 웹과 같은 이름으로 싣는다 — `from` · `to` · `subject` · `body` · `after` · `before`(기기 시간대의 시작 날짜 0시 · 끝 날짜 다음 날 0시를 UTC 로 — 웹과 같은 순간) · `hasAttachment=true`. + 검색어가 비면 `q` 를 싣지 않는다(조건만으로 찾기). 날짜는 1970-01-01 부터 오늘까지만 고른다. 조건이 걸려 있으면 빈 칸의 [검색]은 조건만으로 찾고, [✕]는 검색어만 지운다(조건은 남는다). + 검색어도 조건도 비면 묻지 않는다(웹은 검색을 닫는다). 조건 칸은 14단계 서버부터 받는다 — 그 전 서버는 400 `VAL_001` 이라 받지 못함 화면이 된다. + - 이어 찾기는 웹과 같이 한 차례 5번 · 20통이다. 다만 한 차례가 한 쪽을 못 채우면 저절로 잇지 않고 [더 찾기]로 멈춘다(0통이거나 불러온 결과를 모두 뺐으면 "아직 못 찾음" 화면). 한 쪽을 채운 차례만 목록 끝이 다음 차례를 묻는다. + - 결과는 새 메일 확인으로 다시 찾지 않는다. 앱의 읽기 화면이 바꾼 것과 붙잡힌 메일의 풀림(보낼 시각 + 2초에 그 줄만 `GET /api/messages/{id}` — 강조 쿼리 없이)만 반영하고, 제목 · 미리 보기 · 강조는 그 응답의 것 그대로다. + - 읽기 화면의 강조 쿼리(`q` — 2차부터 걸린 조건의 `from` · `to` · `subject` · `body` 도)는 결과 항목에 `searchHighlight` 칸이 있을 때만(14단계 서버) 싣는다. [이미지 보기]도 같은 쿼리다. + - 강조 위치는 NFC 로 다시 맞추지 않고 그 위치를 준 응답의 글(목록은 그 항목의 제목 · `preview.text`, 읽기 화면은 강조 응답의 제목 · 첨부 이름 · textBody)에만 쓴다(Dart 에 NFC 가 없다). + 읽기 화면이 강조 응답의 요약(NFC 제목)을 아래 목록에 알리는 것은 받아들였다 — 글자는 같고 목록이 다시 맞추면 돌아온다. + - `GET /api/search-index` 는 찾을 때마다 알림 없이 묻는다 — 이 요청으로 앱만 쓰는 직원의 색인도 시작된다. - 새 에러 코드는 없다(`VAL_001` · `RES_001` · `MAIL_001`). ### 대화 — 14단계 (같은 대화 · 대화로 묶어 보기) diff --git a/flutter/README.md b/flutter/README.md index c4e1389..7cc66fd 100644 --- a/flutter/README.md +++ b/flutter/README.md @@ -41,10 +41,38 @@ flutter build appbundle --release # Play 에 올리는 꼴 — 아래 서명 ``` - release 매니페스트(`android/app/src/main/AndroidManifest.xml`)에 INTERNET 권한이 있어야 네트워크가 된다(flutter create 기본은 debug · profile 에만 둔다). +- **release 빌드는 `android/app/google-services.json` 이 없으면 멈춘다**(아래 "새 메일 알림") — 배포 판이 새 메일 알림 없이 나가지 않게. debug 빌드는 없어도 된다. + Gradle 이 실행할 작업 그래프를 본다 — `flutter build apk` · `appbundle` 뿐 아니라 `./gradlew assemble` · `bundle` · `build`(모든 변형) · 줄여 쓴 이름(`aR`)도 멈춘다. + debug · release 를 다른 Firebase 프로젝트로 나누려면(`app/src/debug` · `app/src/release` 에 따로) `build.gradle.kts` 의 조건도 함께 바꾼다(지금은 `app/google-services.json` 하나만 본다). + +## 새 메일 알림 (Firebase Cloud Messaging) + +- 의존성은 `firebase_core` · `firebase_messaging` 둘뿐이다(Analytics · 로컬 알림 · 권한 플러그인은 넣지 않는다). 알림은 FCM 이 그리는 알림 메시지이고, 앱은 채널 · 지우기 · 휴대폰 설정 열기만 한다(`MainActivity.kt` 의 `NewMailNotifications.kt` — 채널 `kr.it.arasystem.app/notifications`). +- **`google-services.json`**: Firebase 콘솔에서 안드로이드 앱(패키지 `kr.it.arasystem.app`)을 만들고 받은 파일을 `flutter/android/app/google-services.json` 에 둔다. 커밋하지 않는다(`android/.gitignore`). + 키가 들어 있지만 비밀은 아니다(앱 안에 그대로 들어간다) — 그래도 저장소 밖에서 나눈다. 파일이 있을 때만 Gradle 이 `com.google.gms.google-services`(4.4.4)를 건다. + - 파일 없이 만든 debug 판은 Firebase 를 띄우지 못해 설정 › 새 메일 알림이 "이 휴대폰에서는 … 받을 수 없습니다"로 보인다(나머지는 그대로 된다). 서버 쪽 발송 키(서비스 계정)는 백엔드 설정이다. +- 매니페스트: 알림 권한(`POST_NOTIFICATIONS`), 기본 채널 `new_mail`(이름 · 설명은 `res/values/strings.xml`), 작은 아이콘 `@drawable/ic_stat_ara`, 강조색 `@color/notification_accent`, + FCM 자동 초기화 끔(`firebase_messaging_auto_init_enabled` — 로그인한 뒤에만 등록한다), 광고 ID 권한은 빼 둔다(`tools:node="remove"` — 어느 라이브러리도 넣지 않지만 들어오지 않게). + FID 등록 켬(`firebase_messaging_installation_id_enabled` = `true`) — 없으면 `register()` · `unregister()` 가 "API disabled" 로 곧바로 실패해 켜기가 `SYS_001` 이 된다(firebase-messaging 26.0.1 에서 확인 — 플러그인 README · CHANGELOG 에는 없고 예제 매니페스트에만 있다). + 켜면 거꾸로 옛 `getToken()` · `deleteToken()` 이 거절된다 — 앱은 `register()` · `unregister()` 만 쓴다. +- 등록은 FID 로 한다(`FirebaseMessaging.register()` · `onRegistered`) — 토큰 · FID 를 로그 · 디버그 출력에 남기지 않는다. 서버 경로 · 규칙은 계약 1절 "앱 새 메일 알림 · 앱 설정". + +## 앱 아이콘 · 알림 아이콘 + +보드 `App1Push` ⑨ · ⑪(2026-11-07 확정). 그림은 `docs/brand/logo_arasystem_white.png` 의 ARA 마크만 잘라 만들었다(만드는 스크립트는 저장소에 두지 않는다 — 의존성을 늘리지 않으려고). 다시 만들 때의 치수: + +- **앱 아이콘(적응형 — 안드로이드 8 이상)**: `res/mipmap-anydpi-v26/ic_launcher.xml` · `ic_launcher_round.xml` = 배경색 `@color/ic_launcher_background`(#0B3640) + 앞면 `@mipmap/ic_launcher_foreground` + monochrome(안드로이드 13 테마 아이콘 — 같은 앞면 그림). + 앞면은 108dp 칸(투명)에 흰 마크 폭 46.5dp(보이는 72dp 의 0.646 — 잘리지 않는 가운데 66dp 안)를 가운데에. mdpi 108px · hdpi 162 · xhdpi 216 · xxhdpi 324 · xxxhdpi 432. +- **옛 아이콘(안드로이드 7 — minSdk 24 · 25)**: `mipmap-*/ic_launcher.png`(48dp 칸에 44dp 둥근 네모 · 모서리 30%) · `ic_launcher_round.png`(동그라미) — 같은 남색 · 마크 비율. 48 · 72 · 96 · 144 · 192px. +- **알림 작은 아이콘 `ic_stat_ara`**: `res/drawable-*/ic_stat_ara.png` — 24dp 칸(투명)에 흰 마크 폭 22dp, 알파만 쓴다(휴대폰이 색을 칠한다). 24 · 36 · 48 · 72 · 96px, mdpi · hdpi 는 획을 반 픽셀 굵게. + 봉투로 바꾸려면 이 다섯 장만 같은 이름으로 바꾼다 — 매니페스트의 기본 아이콘(`default_notification_icon`)이 이 이름 하나를 가리킨다. + 서버 메시지는 아이콘 · 색을 싣지 않는다 — 휴대폰이 매니페스트의 기본값(아이콘 · `default_notification_color` · 채널 `new_mail`)으로 그린다. ## 배포 준비 - **release 서명 키는 저장소 밖의 비밀이다.** 지금 `build.gradle.kts` 의 release 는 debug 키로 서명한다 — 배포 전에 업로드 키(keystore)를 만들고 `key.properties` 로 읽게 한다. keystore · `key.properties` 는 커밋하지 않고(`android/.gitignore` 가 무시한다) 비밀번호 관리자에 둔다(잃으면 같은 앱으로 업데이트를 올릴 수 없다 — Play 앱 서명을 켜 두면 업로드 키는 다시 받을 수 있다). +- **Play 데이터 보안 신고**: 새 메일 알림 때문에 Firebase 설치 ID(FID)를 서버에 보낸다 — Play Console 의 데이터 보안 양식에 "기기 또는 기타 ID"(수집 · 앱 기능, 공유하지 않음 · 전송 중 암호화)로 적는다. + 메일 내용 · 주소는 알림에 싣지 않는다(글은 "새 메일 N통"뿐). 광고 ID 는 쓰지 않는다(매니페스트에서 뺐다 — `tools:node="remove"`). - **안드로이드: Play 내부 테스트** — Play Console 계정(1회 25달러, 법인 계정은 D-U-N-S 번호), 테스터 100명까지 메일 목록으로 · 심사 없이 · 업데이트가 저절로. 식별자(`kr.it.arasystem.app`)는 올린 뒤 바꿀 수 없다. APK 를 직접 나눠 주는 길도 있지만 Google 의 "인증된 개발자만 설치" 정책(2026-09 부터 나라별)이 한국에 언제 걸리는지 확인이 필요하다. - **iOS: 맥 · Apple 개발자 계정이 먼저다** — 빌드 · 서명 · 시뮬레이터가 모두 맥(Xcode)에서만 되고, 배포 · 푸시(APNs 키)는 Apple Developer Program(연 99달러, 법인은 D-U-N-S)이 든다. 내부 배포는 TestFlight 내부 테스터(빌드 90일 만료). @@ -56,13 +84,22 @@ flutter build appbundle --release # Play 에 올리는 꼴 — 아래 서명 - **Android 백업을 껐다**(`android:allowBackup="false"` · `res/xml/data_extraction_rules.xml`) — 보안 저장소의 값은 이 기기의 Keystore 키로 암호화되어 다른 기기로 복원되면 읽지 못하고, 첫 실행 표시까지 복원되면 남은 토큰 지우기가 돌지 않는다. - **iOS Keychain 은 앱을 지워도 남는다** — 일반 저장소(`shared_preferences`)의 첫 실행 표시가 없으면 앱을 띄우기 전에 보안 저장소를 모두 지운다(`core/storage/token_store.dart` 의 `prepare`). - 만료는 기기 시계로 보지 않는다 — 응답의 `Date`(서버 시각)로 남은 시간을 잰다. +- **받은 메일 본문(WebView)**: 앱이 한 번 더 정화한 문서(`features/mail/domain/mail_html.dart` — 허용 목록 · CSP `default-src 'none'; img-src data:`)만 넣고, + 스크립트 · 파일 · content 주소 접근을 끄고, 주 틀의 탐색은 모두 막는다(링크는 바깥 브라우저 · mailto 는 쓰기 화면). 아래 틀(iframe)의 탐색은 안드로이드가 판정에 넘기지 않는다 — + 아래 틀은 정화(iframe 을 내용째 버림)와 CSP(`default-src 'none'` — frame-src)가 막는다. 외부 이미지는 앱이 Dio(Bearer)로 받아 `data:` 로 넣는다 — 토큰이 WebView 에 가지 않는다. + 문서에 싣는 그림 글은 같은 그림이 여러 번 나오면 나온 만큼 세어 10MB 에서 멈춘다. +- **받은 첨부**: 앱 임시 폴더(`cacheDir`)의 `downloads/` 에 받고, 다른 앱으로 열 때는 FileProvider(`${applicationId}.downloads` — 그 폴더만)의 주소와 읽기 권한 1회로 `ACTION_VIEW` 한다(`MainActivity.kt` 의 파일 채널). + 앱을 띄울 때(지난 실행이 남긴 것) · 메일을 보는 직원이 바뀔 때 모두 지운다. 파일 이름은 경로 · 제어 · 방향 제어 글자를 지우고 줄인다(`core/files/file_names.dart`). + 열 형식은 정리한 이름의 확장자에서 먼저 정하고(보낸 쪽 형식은 확장자를 모를 때만), 웹 문서 · SVG · 설치 파일 · 인증서 · 키 묶음은 받지 않고 알린다(`core/files/file_types.dart`). + 고르는 창(`createChooser`)으로 열지 않는다 — 직원이 고른 브라우저가 스크립트를 돌린다. ## 나중에 넣을 때 주의 - **build_runner(freezed · json_serializable · riverpod_generator)**: 같은 폴더의 `lib/core/api/error_lexicon.g.dart`(백엔드 레지스트리에서 만든 사전 — 직접 고치지 않는다)를 build_runner 가 자기 출력으로 보고 지울 수 있다. 넣을 때는 `build.yaml` 의 `generate_for` 를 모델 폴더로 좁히고, 처음 한 번 돌린 뒤 사전이 그대로인지(`git diff`) 확인한다. -- **첨부 받기(스트림 · 바이트 응답)**: `responseType` 이 `bytes` · `stream` 이면 실패 응답의 본문도 JSON 으로 풀리지 않아 `toApiException` 이 HTTP 상태로만 코드를 정한다(`FILE_007` · `FILE_008` 같은 코드를 잃는다). - 받기를 만들 때 실패 본문(작다)을 끝까지 읽어 UTF-8 · JSON 으로 풀어 표준 실패의 `error.code` 를 꺼낸 뒤 코드를 정한다. 성공 본문은 지금처럼 풀지 않고 그대로 넘긴다(시험이 지킨다). +- **첨부 받기(스트림 · 바이트 응답)**: 실패 응답의 본문은 `ResponseInterceptor.onError` 가 64KB 까지 읽어 UTF-8 · JSON 으로 풀고 표준 실패의 `error.code` 를 꺼낸다 + (`decodeRawErrorBody` — 그 전에는 HTTP 상태로만 정해 `FILE_007` · `FILE_008` 같은 코드를 잃었다). 성공 본문은 풀지 않고 그대로 넘긴다(시험이 지킨다). + 받는 도중 끊기거나 조각 사이 받기 한도(`receiveTimeout` — Dio 가 스트림에 싣는다)를 넘기면 응답이 이미 인터셉터를 지나 아무도 알리지 않으므로 부르는 쪽이 `SYS_001` 로 알린다(`DownloadInterrupted`). ## 라이선스 diff --git a/flutter/android/.gitignore b/flutter/android/.gitignore index be3943c..69110b0 100644 --- a/flutter/android/.gitignore +++ b/flutter/android/.gitignore @@ -12,3 +12,7 @@ GeneratedPluginRegistrant.java key.properties **/*.keystore **/*.jks + +# 새 메일 알림(FCM)의 Firebase 설정 — 저장소에 넣지 않는다(이 템플릿을 클론한 곳마다 우리 Firebase 프로젝트를 가리키게 된다). +# 저장소 밖에 둔 파일을 본 트리 · worktree 마다 복사해 둔다(flutter/README "새 메일 알림") +app/google-services.json diff --git a/flutter/android/app/build.gradle.kts b/flutter/android/app/build.gradle.kts index 73e74a2..14eed18 100644 --- a/flutter/android/app/build.gradle.kts +++ b/flutter/android/app/build.gradle.kts @@ -42,3 +42,22 @@ android { flutter { source = "../.." } + +// 새 메일 알림(FCM)의 Firebase 설정 — android/app/google-services.json 은 저장소에 넣지 않는다(android/.gitignore · flutter/README "새 메일 알림"). +// 있으면 google-services 플러그인이 리소스로 바꾸고(Dart 의 Firebase.initializeApp 이 읽는다), 없으면 알림 없이 빌드한다 — 앱은 "받을 수 없는 휴대폰"으로 돈다. +// 배포 판(release 변형)을 만드는 작업이 하나라도 있으면 멈춘다 — 새 메일 알림 없이 나가지 않게. +// 부른 작업 이름이 아니라 실행할 작업 그래프를 본다: assembleRelease · bundleRelease 뿐 아니라 모든 변형을 만드는 assemble · bundle · build, +// 줄여 쓴 이름(aR)도 이 프로젝트의 release 작업(이름에 Release 가 든다)을 그래프에 넣는다. +// debug · release 를 다른 Firebase 프로젝트로 나누려면(app/src/debug · app/src/release 에 따로 둔다) 이 조건도 함께 바꾼다. +val firebaseConfig = file("google-services.json") +if (firebaseConfig.exists()) { + apply(plugin = "com.google.gms.google-services") +} else { + gradle.taskGraph.whenReady { + if (allTasks.any { it.project == project && it.name.contains("Release") }) { + throw GradleException( + "android/app/google-services.json 이 없습니다. 배포 판은 새 메일 알림 없이 나가지 않습니다 (flutter/README \"새 메일 알림\").", + ) + } + } +} diff --git a/flutter/android/app/src/main/AndroidManifest.xml b/flutter/android/app/src/main/AndroidManifest.xml index 0773065..046761b 100644 --- a/flutter/android/app/src/main/AndroidManifest.xml +++ b/flutter/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,14 @@ - + + + + + + + + + + + + + + + + , + ) + + private class PendingPick(val result: MethodChannel.Result, val limits: PickLimits) + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + // 새 메일 알림 — 채널은 첫 알림보다 먼저 있어야 한다(서버의 기기 줄은 앱을 연 뒤에야 생긴다) + NewMailNotifications(this).apply { + createChannel() + register(flutterEngine.dartExecutor.binaryMessenger) + } + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, FILES_CHANNEL) + .setMethodCallHandler { call, result -> + when (call.method) { + OPEN_METHOD -> result.success( + openFile(call.argument("path"), call.argument("type")), + ) + PICK_METHOD -> pickFiles( + limitsOf( + call.argument(ARG_MAX_BYTES), + call.argument(ARG_MAX_COUNT), + call.argument(ARG_MAX_TOTAL_BYTES), + call.argument>(ARG_EXTENSIONS), + ), + result, + ) + else -> result.notImplemented() + } + } + } + + private fun openFile(path: String?, type: String?): String { + if (path == null) return RESULT_FAILED + val file: File + val root: File + try { + root = File(cacheDir, DOWNLOADS_DIR).canonicalFile + file = File(path).canonicalFile + } catch (error: IOException) { + return RESULT_FAILED + } + // 받은 파일 폴더 밖은 열지 않는다 (../ · 심볼릭 링크로 앱의 다른 파일을 건네지 않게) + if (!file.path.startsWith(root.path + File.separator)) return RESULT_FAILED + if (!file.isFile) return RESULT_NOT_FOUND + val uri = try { + FileProvider.getUriForFile(this, packageName + AUTHORITY_SUFFIX, file) + } catch (error: IllegalArgumentException) { + return RESULT_FAILED + } + // 고르는 창(createChooser)은 쓰지 않는다 — 직원이 고른 브라우저가 스크립트를 돌린다 + val intent = Intent(Intent.ACTION_VIEW) + .setDataAndType(uri, mimeTypeOf(type)) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + return try { + startActivity(intent) + RESULT_OPENED + } catch (error: ActivityNotFoundException) { + RESULT_NO_APP + } catch (error: SecurityException) { + RESULT_FAILED + } + } + + /** + * 넘겨받은 형식 — Dart 가 정한다(`core/files/file_types.dart` 의 `fileOpenTypeOf` — 확장자에서 먼저, 열지 않는 형식은 받기 전에 막는다). + * 여기서는 모양만 다시 본다 — 틀리면 아무 형식(octet-stream) + */ + private fun mimeTypeOf(type: String?): String { + val given = type?.trim()?.lowercase() + return if (given != null && MIME_PATTERN.matches(given)) given else GENERIC_TYPE + } + + // ── 고르기 ───────────────────────────────────────── + + /** 넘겨받은 한도 — 모양이 틀리면 null */ + private fun limitsOf(maxBytes: Number?, maxCount: Number?, maxTotalBytes: Number?, extensions: List<*>?): PickLimits? { + if (maxBytes == null || maxCount == null || maxTotalBytes == null || extensions == null) return null + if (maxBytes.toLong() < 0 || maxCount.toInt() < 1 || maxTotalBytes.toLong() < 0) return null + return PickLimits( + maxBytes = maxBytes.toLong(), + maxCount = maxCount.toInt(), + maxTotalBytes = maxTotalBytes.toLong(), + extensions = extensions.filterIsInstance().map { it.lowercase(Locale.ROOT) }.toSet(), + ) + } + + /** + * 고르는 창을 연다 — 권한이 필요 없다(고르는 창이 고른 파일의 읽기 권한만 준다). 답은 고른 뒤 [onActivityResult] 에서 한다 + */ + private fun pickFiles(limits: PickLimits?, result: MethodChannel.Result) { + if (limits == null) { + result.success(outcomeOf(OUTCOME_FAILED)) + return + } + if (pendingPick != null) { + result.success(outcomeOf(OUTCOME_BUSY)) + return + } + // 형식으로 거르지 않는다 — hwp 는 기기 · 앱마다 형식이 달라 창에서 거르면 숨는다(고른 뒤 확장자로 거른다) + val intent = Intent(Intent.ACTION_GET_CONTENT) + .addCategory(Intent.CATEGORY_OPENABLE) + .setType(ANY_TYPE) + .putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) + try { + startActivityForResult(intent, PICK_REQUEST_CODE) + } catch (error: ActivityNotFoundException) { + result.success(outcomeOf(OUTCOME_NO_APP)) + return + } + pendingPick = PendingPick(result, limits) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode != PICK_REQUEST_CODE) { + super.onActivityResult(requestCode, resultCode, data) + return + } + // 고르는 동안 앱이 정리됐다가 다시 떴으면 기다리는 답이 없다 — 복사하지 않는다(Dart 의 쓰기 화면도 없다) + val pending = pendingPick ?: return + pendingPick = null + val uris = if (resultCode == Activity.RESULT_OK && data != null) pickedUris(data) else emptyList() + if (uris.isEmpty()) { + pending.result.success(outcomeOf(OUTCOME_CANCELED)) + return + } + // 복사는 화면 스레드 밖에서 — 클라우드 파일은 읽는 동안 받는다. 답은 화면 스레드에서 한 번만 + Thread { + val files = ArrayList>() + val reply: Map = try { + copyPicked(uris, pending.limits, files) + mapOf(KEY_OUTCOME to OUTCOME_PICKED, KEY_FILES to files) + } catch (error: Throwable) { + // 뜻밖의 실패(권한 · 메모리 등) — 만든 복사본을 지우고 실패로 답한다(Dart 가 기다리며 멈춰 있지 않게) + for (file in files) { + (file[KEY_PATH] as? String)?.let { deleteCopy(it) } + } + outcomeOf(OUTCOME_FAILED) + } + runOnUiThread { pending.result.success(reply) } + }.start() + } + + /** 고른 주소 — 여러 개면 clipData, 하나면 data */ + private fun pickedUris(data: Intent): List { + val clip = data.clipData + if (clip != null && clip.itemCount > 0) { + return (0 until clip.itemCount).mapNotNull { clip.getItemAt(it).uri } + } + return listOfNotNull(data.data) + } + + /** + * 고른 차례대로 넣을 파일만 복사한다 — **차례는 Dart 사전 검사(`compose_attachments.dart` 의 `precheckPickedFile`)와 같다**: + * 개수(넣은 수가 [PickLimits.maxCount]) → 형식(확장자 — 짝: Dart `displayFileName` · `extensionOf`) → 파일당(알려 준 크기) → + * 합계(알려 준 크기로 먼저, 복사한 실제 크기로 다시 — 넘으면 그 복사본을 지운다). 넣지 않을 파일은 읽지 않는다. + * 센 것은 넣을 파일뿐이다 — 형식 · 크기 · 합계 · 읽지 못함으로 빠진 파일은 개수 · 합계에 들지 않는다(웹과 같다) + */ + private fun copyPicked(uris: List, limits: PickLimits, files: MutableList>) { + val root = File(cacheDir, UPLOADS_DIR) + var count = 0 + var bytes = 0L + for (uri in uris) { + // content:// 만 읽는다 — 고른 앱이 file:// 로 우리 앱 안 파일을 건네는 길을 닫는다(묻지도 읽지도 않는다). + // 이름은 주소의 마지막 칸으로만 — Dart 가 "가져오지 못함"(형식이 틀리면 형식)으로 알린다 + if (uri.scheme != ContentResolver.SCHEME_CONTENT) { + files.add(fileOf(uri.lastPathSegment, null, STATUS_FAILED, null)) + continue + } + val info = queryInfo(uri) + val name = info.first + val reported = info.second + val file = when { + count >= limits.maxCount -> fileOf(name, reported, STATUS_SKIPPED, null) + extensionOf(displayFileName(name)) !in limits.extensions -> fileOf(name, reported, STATUS_NOT_ALLOWED, null) + reported != null && reported > limits.maxBytes -> fileOf(name, reported, STATUS_TOO_LARGE, null) + reported != null && bytes + reported > limits.maxTotalBytes -> fileOf(name, reported, STATUS_OVER_TOTAL, null) + else -> withinTotal(copyOne(uri, name, reported, limits.maxBytes, root), name, bytes, limits) + } + if (file[KEY_STATUS] == STATUS_OK) { + count++ + bytes += file[KEY_SIZE] as Long + } + files.add(file) + } + } + + /** 복사한 실제 크기로 합계를 다시 본다 — 고른 앱이 크기를 알려 주지 않았거나 틀렸을 수 있다. 넘으면 복사본을 지운다 */ + private fun withinTotal(copied: Map, name: String?, bytes: Long, limits: PickLimits): Map { + if (copied[KEY_STATUS] != STATUS_OK) return copied + val size = copied[KEY_SIZE] as Long + if (bytes + size <= limits.maxTotalBytes) return copied + (copied[KEY_PATH] as? String)?.let { deleteCopy(it) } + return fileOf(name, size, STATUS_OVER_TOTAL, null) + } + + /** 보이는 이름 · 크기 — 고른 앱이 알려 주지 않으면 null */ + private fun queryInfo(uri: Uri): Pair { + var name: String? = null + var size: Long? = null + try { + contentResolver.query( + uri, + arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), + null, + null, + null, + )?.use { cursor -> + if (cursor.moveToFirst()) { + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (nameIndex >= 0 && !cursor.isNull(nameIndex)) name = cursor.getString(nameIndex) + val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) + if (sizeIndex >= 0 && !cursor.isNull(sizeIndex)) size = cursor.getLong(sizeIndex) + } + } + } catch (error: Exception) { + // 고른 앱마다 던지는 것이 다르다(권한 · 주소 모양) — 이름 · 크기 없이 본다(이름이 없으면 형식으로 빠진다) + } + return Pair(name, size?.takeIf { it >= 0 }) + } + + /** + * 하나를 `cacheDir/uploads/<무작위 16자>/<정리한 이름>` 에 복사한다 — [maxBytes] + 1바이트를 넘으면 멈추고 쓰다 만 복사본을 지운다. + * 자리는 열기 채널처럼 canonical 경로로 그 폴더 안인지 다시 본다 + */ + private fun copyOne(uri: Uri, name: String?, reported: Long?, maxBytes: Long, root: File): Map { + val folder = File(root, randomFolderName()) + val target = File(folder, safeFileName(name)) + try { + if (!target.canonicalPath.startsWith(root.canonicalPath + File.separator)) { + return fileOf(name, reported, STATUS_FAILED, null) + } + } catch (error: IOException) { + return fileOf(name, reported, STATUS_FAILED, null) + } + if (!folder.mkdirs() && !folder.isDirectory) return fileOf(name, reported, STATUS_FAILED, null) + return try { + var total = 0L + var tooLarge = false + val input = contentResolver.openInputStream(uri) + ?: throw IOException("열 수 없음") + input.use { source -> + FileOutputStream(target).use { sink -> + val buffer = ByteArray(COPY_BUFFER_BYTES) + while (true) { + val read = source.read(buffer) + if (read < 0) break + total += read + if (total > maxBytes) { + tooLarge = true + break + } + sink.write(buffer, 0, read) + } + } + } + if (tooLarge) { + folder.deleteRecursively() + fileOf(name, reported, STATUS_TOO_LARGE, null) + } else { + fileOf(name, total, STATUS_OK, target.path) + } + } catch (error: Exception) { + // 읽기 · 쓰기 실패(클라우드 파일을 받지 못함 · 권한 · 저장 공간) — 쓰다 만 복사본을 지운다 + folder.deleteRecursively() + fileOf(name, reported, STATUS_FAILED, null) + } + } + + /** 복사본 하나를 그 폴더째 지운다 */ + private fun deleteCopy(path: String) { + File(path).parentFile?.deleteRecursively() + } + + private fun fileOf(name: String?, size: Long?, status: String, path: String?): Map = + mapOf(KEY_NAME to name, KEY_SIZE to size, KEY_STATUS to status, KEY_PATH to path) + + private fun outcomeOf(outcome: String): Map = mapOf(KEY_OUTCOME to outcome) + + /** 복사본마다 새 폴더 — 같은 이름을 여러 번 골라도 덮지 않는다(16진수 16자 — 지역 설정과 상관없이 0-9a-f) */ + private fun randomFolderName(): String { + val bytes = ByteArray(FOLDER_NAME_BYTES) + random.nextBytes(bytes) + val name = StringBuilder() + for (byte in bytes) { + val value = byte.toInt() and 0xff + name.append(HEX_DIGITS[value shr 4]).append(HEX_DIGITS[value and 0x0f]) + } + return name.toString() + } + + // ── 이름 · 확장자 (짝: Dart `core/files/file_names.dart` · `features/mail/domain/compose_attachments.dart`) ── + + /** + * 보이는 이름 — **짝: Dart `displayFileName`** 과 같은 규칙. 경로 앞부분(`/` · `\` 의 마지막 뒤)만 두고 제어 · 방향 제어 · 보이지 않는 글자를 지운 뒤 + * 앞뒤 공백(Dart `trim()` 의 공백 — [isDartWhitespace])을 지운다. 비면 [FALLBACK_FILE_NAME]. 형식을 가를 때만 쓴다(Dart 가 다시 정리한다) + */ + private fun displayFileName(raw: String?): String { + if (raw == null) return FALLBACK_FILE_NAME + val slash = maxOf(raw.lastIndexOf('/'), raw.lastIndexOf('\\')) + val segment = if (slash == -1) raw else raw.substring(slash + 1) + val cleaned = StringBuilder() + forEachCodePoint(segment) { codePoint -> + if (!isControl(codePoint) && !isInvisibleFormat(codePoint)) cleaned.appendCodePoint(codePoint) + } + val result = cleaned.toString().trim { isDartWhitespace(it.code) } + return result.ifEmpty { FALLBACK_FILE_NAME } + } + + /** 확장자 — **짝: Dart `extensionOf`**(마지막 점 뒤, 소문자 — 없으면 빈 글) */ + private fun extensionOf(name: String): String { + val dot = name.lastIndexOf('.') + return if (dot == -1) "" else name.substring(dot + 1).lowercase(Locale.ROOT) + } + + /** + * 기기 안 복사본의 이름 — 고른 앱이 정한 글이라 믿지 않는다. **짝: Dart `safeFileName`** 과 같은 규칙 + * (제어 · 방향 제어 · 보이지 않는 글자를 지우고, 경로 · 다른 시스템이 받지 않는 글자는 밑줄, 앞뒤 공백 · 점을 지우고, UTF-8 200바이트 — 확장자는 남긴다). + * 이 이름은 복사본에만 쓴다 — 보이는 이름 · 올릴 때 보내는 이름은 Dart 의 `displayFileName` + */ + private fun safeFileName(raw: String?): String { + val cleaned = StringBuilder() + forEachCodePoint(raw ?: "") { codePoint -> + if (isControl(codePoint) || isInvisibleFormat(codePoint)) return@forEachCodePoint + if (codePoint < 0x80 && REPLACED_CHARACTERS.indexOf(codePoint.toChar()) >= 0) { + cleaned.append('_') + } else { + cleaned.appendCodePoint(codePoint) + } + } + // Dart 의 trim() 뒤 `^[.\s]+|[.\s]+$` 지우기와 같다 — 앞뒤의 공백 · 점을 지운다 + val result = cleaned.toString().trim { it == '.' || isDartWhitespace(it.code) } + if (result.isEmpty()) return FALLBACK_FILE_NAME + return fitBytes(result, MAX_FILE_NAME_BYTES) + } + + private inline fun forEachCodePoint(text: String, action: (Int) -> Unit) { + var index = 0 + while (index < text.length) { + val codePoint = text.codePointAt(index) + index += Character.charCount(codePoint) + action(codePoint) + } + } + + /** 제어 문자(C0 · DEL · C1) — 짝: Dart `_isControl` */ + private fun isControl(codePoint: Int): Boolean = codePoint <= 0x1F || codePoint in 0x7F..0x9F + + /** 방향 제어 · 보이지 않는 글자 — 짝: Dart `_isInvisibleFormat` */ + private fun isInvisibleFormat(codePoint: Int): Boolean = + codePoint == 0x061C || + codePoint in 0x200B..0x200F || + codePoint in 0x202A..0x202E || + codePoint in 0x2060..0x2069 || + codePoint == 0xFEFF + + /** + * Dart `String.trim()` 이 지우는 공백(Unicode White_Space + BOM) — Kotlin 의 `trim()` 과 몇 글자가 달라 직접 적는다. + * Dart `RegExp` 의 `\s`(`safeFileName` 의 점 지우기)와도 제어 문자를 뺀 나머지가 같다 + */ + private fun isDartWhitespace(codePoint: Int): Boolean = + codePoint in 0x09..0x0D || + codePoint == 0x20 || + codePoint == 0x85 || + codePoint == 0xA0 || + codePoint == 0x1680 || + codePoint in 0x2000..0x200A || + codePoint == 0x2028 || + codePoint == 0x2029 || + codePoint == 0x202F || + codePoint == 0x205F || + codePoint == 0x3000 || + codePoint == 0xFEFF + + /** UTF-8 [maxBytes] 안으로 — 확장자(점 뒤 16자까지 영문 · 숫자)는 남기고 앞부분을 글자 단위로 자른다 */ + private fun fitBytes(name: String, maxBytes: Int): String { + if (name.toByteArray(Charsets.UTF_8).size <= maxBytes) return name + val dot = name.lastIndexOf('.') + val extension = if (dot > 0 && + name.length - dot - 1 <= MAX_EXTENSION_LENGTH && + EXTENSION_PATTERN.matches(name.substring(dot + 1)) + ) { + name.substring(dot) + } else { + "" + } + val base = if (extension.isEmpty()) name else name.substring(0, dot) + val budget = maxBytes - extension.toByteArray(Charsets.UTF_8).size + val kept = StringBuilder() + var used = 0 + var index = 0 + while (index < base.length) { + val codePoint = base.codePointAt(index) + val size = String(Character.toChars(codePoint)).toByteArray(Charsets.UTF_8).size + if (used + size > budget) break + kept.appendCodePoint(codePoint) + used += size + index += Character.charCount(codePoint) + } + val trimmed = kept.toString().trimEnd() + return if (trimmed.isEmpty()) FALLBACK_FILE_NAME + extension else trimmed + extension + } + + private companion object { + /** 짝: Dart `PlatformFileOpener.channel` · `openMethod` · `PlatformPickedFileStore.pickMethod` */ + const val FILES_CHANNEL = "kr.it.arasystem.app/files" + const val OPEN_METHOD = "open" + const val PICK_METHOD = "pick" + + /** `pick` 의 인자 — 짝: Dart `PlatformPickedFileStore.pick` */ + const val ARG_MAX_BYTES = "maxBytes" + const val ARG_MAX_COUNT = "maxCount" + const val ARG_MAX_TOTAL_BYTES = "maxTotalBytes" + const val ARG_EXTENSIONS = "extensions" + + /** 짝: Dart `kDownloadedFilesFolder` · `res/xml/downloaded_file_paths.xml` 의 cache-path */ + const val DOWNLOADS_DIR = "downloads" + + /** 짝: Dart `kPickedFilesFolder` — 다른 앱에 건네지 않아 FileProvider 경로(xml)에는 없다 */ + const val UPLOADS_DIR = "uploads" + + /** 짝: `AndroidManifest.xml` 의 provider authorities(`${applicationId}.downloads`) */ + const val AUTHORITY_SUFFIX = ".downloads" + + /** 짝: Dart `kGenericFileType` · `_typePattern`(`core/files/file_types.dart`) */ + const val GENERIC_TYPE = "application/octet-stream" + val MIME_PATTERN = Regex("^[a-z0-9][a-z0-9!#$&^_.+-]{0,126}/[a-z0-9][a-z0-9!#$&^_.+-]{0,126}$") + + const val RESULT_OPENED = "opened" + const val RESULT_NO_APP = "noApp" + const val RESULT_NOT_FOUND = "notFound" + const val RESULT_FAILED = "failed" + + /** 고르는 창의 요청 번호 — 다른 플러그인의 번호와 겹치지 않게 앱 이름(AR)에서 */ + const val PICK_REQUEST_CODE = 0x4152 + + /** 고르는 창에 보일 형식 — 모두 */ + const val ANY_TYPE = "*/*" + + // 짝: Dart `PickResult.parse` · `PickedFile.fromChannel` 의 칸과 글 + const val KEY_OUTCOME = "outcome" + const val KEY_FILES = "files" + const val KEY_NAME = "name" + const val KEY_SIZE = "size" + const val KEY_STATUS = "status" + const val KEY_PATH = "path" + const val OUTCOME_PICKED = "picked" + const val OUTCOME_CANCELED = "canceled" + const val OUTCOME_BUSY = "busy" + const val OUTCOME_NO_APP = "noApp" + const val OUTCOME_FAILED = "failed" + const val STATUS_OK = "ok" + const val STATUS_SKIPPED = "skipped" + const val STATUS_NOT_ALLOWED = "notAllowed" + const val STATUS_TOO_LARGE = "tooLarge" + const val STATUS_OVER_TOTAL = "overTotal" + const val STATUS_FAILED = "failed" + + /** 복사할 때 한 번에 읽는 크기 */ + const val COPY_BUFFER_BYTES = 64 * 1024 + + /** 복사본마다 만드는 폴더 이름의 바이트(16진수 16자 — Dart 받은 파일 폴더와 같은 길이) */ + const val FOLDER_NAME_BYTES = 8 + const val HEX_DIGITS = "0123456789abcdef" + + // 짝: Dart `kFallbackFileName` · `kMaxFileNameBytes` · `_maxExtensionLength` · `_replacedCharacters`(`file_names.dart`) + const val FALLBACK_FILE_NAME = "첨부파일" + const val MAX_FILE_NAME_BYTES = 200 + const val MAX_EXTENSION_LENGTH = 16 + const val REPLACED_CHARACTERS = "/\\:*?\"<>|" + val EXTENSION_PATTERN = Regex("^[A-Za-z0-9]+$") + + val random = SecureRandom() + } +} diff --git a/flutter/android/app/src/main/kotlin/kr/it/arasystem/app/NewMailNotifications.kt b/flutter/android/app/src/main/kotlin/kr/it/arasystem/app/NewMailNotifications.kt new file mode 100644 index 0000000..f0e191f --- /dev/null +++ b/flutter/android/app/src/main/kotlin/kr/it/arasystem/app/NewMailNotifications.kt @@ -0,0 +1,126 @@ +package kr.it.arasystem.app + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationManagerCompat +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MethodChannel + +/** + * 새 메일 알림의 채널과 알림창 — 채널 `kr.it.arasystem.app/notifications`. + * + * **짝: Dart `core/push/notification_system.dart` 의 `PlatformNotificationSystem`** — 채널 이름 · 부르는 이름 · 답의 칸과 값(한쪽을 고치면 둘 다). + * + * - 알림 채널 `new_mail`(짝: 서버 `channel_id` · 매니페스트의 기본 채널 — 값은 `R.string.new_mail_channel_id`)을 앱을 열 때마다 만든다. + * 중요도 HIGH(소리 · 머리 알림) — 한 번 만든 채널의 중요도는 앱이 바꾸지 못한다(직원이 바꾼다). 이름 · 설명은 다시 만들 때 바뀐다. + * 알림은 앱이 뒤에 있거나 꺼져 있을 때 FCM 이 이 채널로 띄운다(앱 코드는 돌지 않는다). + * - `status` → `{appEnabled, channelEnabled, sdkInt}` — 채널만 끈 것은 알림 권한으로 알 수 없어 따로 본다. + * - `openSettings` → 새 메일 채널만 꺼졌으면 그 채널 설정, 아니면 이 앱의 알림 설정(24 · 25 는 앱 정보). 답은 opened · failed. + * - `clearNewMail` → 알림창의 "새 메일 N통"(tag `new-mail` — 짝: 서버 tag. FCM 은 tag 를 주면 id 0 으로 띄운다)만 지운다. + * + * 주소 · 토큰 · FID 는 다루지도 로그에 남기지도 않는다. + */ +class NewMailNotifications(private val activity: Activity) { + private val manager = NotificationManagerCompat.from(activity) + + private val channelId: String + get() = activity.getString(R.string.new_mail_channel_id) + + /** 채널을 만든다 — 이미 있으면 이름 · 설명만 바뀐다(채널이 없는 판은 아무것도 하지 않는다) */ + fun createChannel() { + val channel = NotificationChannelCompat.Builder(channelId, NotificationManagerCompat.IMPORTANCE_HIGH) + .setName(activity.getString(R.string.new_mail_channel_name)) + .setDescription(activity.getString(R.string.new_mail_channel_description)) + .build() + manager.createNotificationChannel(channel) + } + + fun register(messenger: BinaryMessenger) { + MethodChannel(messenger, CHANNEL).setMethodCallHandler { call, result -> + when (call.method) { + STATUS_METHOD -> result.success(status()) + OPEN_SETTINGS_METHOD -> result.success(openSettings()) + CLEAR_NEW_MAIL_METHOD -> { + clearNewMail() + result.success(null) + } + else -> result.notImplemented() + } + } + } + + private fun status(): Map = mapOf( + KEY_APP_ENABLED to manager.areNotificationsEnabled(), + KEY_CHANNEL_ENABLED to isChannelEnabled(), + KEY_SDK_INT to Build.VERSION.SDK_INT, + ) + + /** 새 메일 채널이 켜져 있다 — 채널이 없는 판(24 · 25)이거나 채널을 아직 만들지 않았으면 참 */ + private fun isChannelEnabled(): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return true + val channel = manager.getNotificationChannelCompat(channelId) ?: return true + return channel.importance != NotificationManagerCompat.IMPORTANCE_NONE + } + + /** 휴대폰 설정을 연다 — 가장 알맞은 화면부터, 열리지 않으면 다음 화면 */ + private fun openSettings(): String { + val packageName = activity.packageName + val intents = buildList { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (manager.areNotificationsEnabled() && !isChannelEnabled()) { + add( + Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS) + .putExtra(Settings.EXTRA_APP_PACKAGE, packageName) + .putExtra(Settings.EXTRA_CHANNEL_ID, channelId), + ) + } + add( + Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) + .putExtra(Settings.EXTRA_APP_PACKAGE, packageName), + ) + } + add(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", packageName, null))) + } + for (intent in intents) { + try { + activity.startActivity(intent) + return RESULT_OPENED + } catch (error: ActivityNotFoundException) { + // 이 화면이 없는 기기 — 다음 화면을 연다 + } catch (error: SecurityException) { + // 열 수 없다 — 다음 화면을 연다 + } + } + return RESULT_FAILED + } + + private fun clearNewMail() { + manager.cancel(NEW_MAIL_TAG, FCM_NOTIFICATION_ID) + } + + private companion object { + /** 짝: Dart `PlatformNotificationSystem.channel` */ + const val CHANNEL = "kr.it.arasystem.app/notifications" + const val STATUS_METHOD = "status" + const val OPEN_SETTINGS_METHOD = "openSettings" + const val CLEAR_NEW_MAIL_METHOD = "clearNewMail" + + // 짝: Dart `PlatformNotificationSystem` 의 appEnabledKey · channelEnabledKey · sdkIntKey · openedResult + const val KEY_APP_ENABLED = "appEnabled" + const val KEY_CHANNEL_ENABLED = "channelEnabled" + const val KEY_SDK_INT = "sdkInt" + const val RESULT_OPENED = "opened" + const val RESULT_FAILED = "failed" + + /** 짝: 서버가 보내는 알림의 tag(`android.notification.tag`) — "새 메일 N통" · "알림이 멈췄습니다"가 함께 쓴다 */ + const val NEW_MAIL_TAG = "new-mail" + + /** FCM 은 tag 를 준 알림을 id 0 으로 띄운다 */ + const val FCM_NOTIFICATION_ID = 0 + } +} diff --git a/flutter/android/app/src/main/res/drawable-hdpi/ic_stat_ara.png b/flutter/android/app/src/main/res/drawable-hdpi/ic_stat_ara.png new file mode 100644 index 0000000..add14e9 Binary files /dev/null and b/flutter/android/app/src/main/res/drawable-hdpi/ic_stat_ara.png differ diff --git a/flutter/android/app/src/main/res/drawable-mdpi/ic_stat_ara.png b/flutter/android/app/src/main/res/drawable-mdpi/ic_stat_ara.png new file mode 100644 index 0000000..fc77e41 Binary files /dev/null and b/flutter/android/app/src/main/res/drawable-mdpi/ic_stat_ara.png differ diff --git a/flutter/android/app/src/main/res/drawable-xhdpi/ic_stat_ara.png b/flutter/android/app/src/main/res/drawable-xhdpi/ic_stat_ara.png new file mode 100644 index 0000000..232f70a Binary files /dev/null and b/flutter/android/app/src/main/res/drawable-xhdpi/ic_stat_ara.png differ diff --git a/flutter/android/app/src/main/res/drawable-xxhdpi/ic_stat_ara.png b/flutter/android/app/src/main/res/drawable-xxhdpi/ic_stat_ara.png new file mode 100644 index 0000000..3f3b290 Binary files /dev/null and b/flutter/android/app/src/main/res/drawable-xxhdpi/ic_stat_ara.png differ diff --git a/flutter/android/app/src/main/res/drawable-xxxhdpi/ic_stat_ara.png b/flutter/android/app/src/main/res/drawable-xxxhdpi/ic_stat_ara.png new file mode 100644 index 0000000..b25f9bf Binary files /dev/null and b/flutter/android/app/src/main/res/drawable-xxxhdpi/ic_stat_ara.png differ diff --git a/flutter/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/flutter/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..dd6e294 --- /dev/null +++ b/flutter/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/flutter/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/flutter/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..dd6e294 --- /dev/null +++ b/flutter/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index db77bb4..f03f8d4 100644 Binary files a/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..df75a08 Binary files /dev/null and b/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..6318343 Binary files /dev/null and b/flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 17987b7..9ea1cdc 100644 Binary files a/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..08edff9 Binary files /dev/null and b/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..a01074c Binary files /dev/null and b/flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 09d4391..7b39e97 100644 Binary files a/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..1dcac3c Binary files /dev/null and b/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..66d0bef Binary files /dev/null and b/flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index d5f1c8d..ef3c861 100644 Binary files a/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..de22366 Binary files /dev/null and b/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..48b85c7 Binary files /dev/null and b/flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 4d6372e..86c3114 100644 Binary files a/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..37de2e9 Binary files /dev/null and b/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..2e69894 Binary files /dev/null and b/flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/flutter/android/app/src/main/res/values/colors.xml b/flutter/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..7df9b15 --- /dev/null +++ b/flutter/android/app/src/main/res/values/colors.xml @@ -0,0 +1,7 @@ + + + + #0E7C86 + + #0B3640 + diff --git a/flutter/android/app/src/main/res/values/strings.xml b/flutter/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..e0cc52d --- /dev/null +++ b/flutter/android/app/src/main/res/values/strings.xml @@ -0,0 +1,11 @@ + + + + new_mail + + 새 메일 + 새 메일이 오면 알려 드립니다. + diff --git a/flutter/android/app/src/main/res/xml/downloaded_file_paths.xml b/flutter/android/app/src/main/res/xml/downloaded_file_paths.xml new file mode 100644 index 0000000..437824e --- /dev/null +++ b/flutter/android/app/src/main/res/xml/downloaded_file_paths.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/flutter/android/settings.gradle.kts b/flutter/android/settings.gradle.kts index ca7fe06..4738537 100644 --- a/flutter/android/settings.gradle.kts +++ b/flutter/android/settings.gradle.kts @@ -21,6 +21,9 @@ plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("com.android.application") version "8.11.1" apply false id("org.jetbrains.kotlin.android") version "2.2.20" apply false + // 새 메일 알림(FCM) — google-services.json 을 안드로이드 리소스로 바꾼다. 4.4.4 는 firebase_core 5.1.0 이 시험한 판 + // (그 pubspec 의 google_services_gradle_plugin_version). 거는 것은 app/build.gradle.kts — 파일이 있을 때만 + id("com.google.gms.google-services") version "4.4.4" apply false } include(":app") diff --git a/flutter/assets/brand/logo-ara-white.png b/flutter/assets/brand/logo-ara-white.png new file mode 100644 index 0000000..6766d3a Binary files /dev/null and b/flutter/assets/brand/logo-ara-white.png differ diff --git a/flutter/assets/brand/logo-arasystem-white.png b/flutter/assets/brand/logo-arasystem-white.png new file mode 100644 index 0000000..cab7539 Binary files /dev/null and b/flutter/assets/brand/logo-arasystem-white.png differ diff --git a/flutter/lib/core/api/api_config.dart b/flutter/lib/core/api/api_config.dart index 91b7cb6..cfca0d0 100644 --- a/flutter/lib/core/api/api_config.dart +++ b/flutter/lib/core/api/api_config.dart @@ -11,6 +11,10 @@ const String kDefaultApiUrl = 'http://localhost:3000/api'; const Duration kApiConnectTimeout = Duration(seconds: 15); const Duration kApiReceiveTimeout = Duration(seconds: 15); +/// 보안 설정(비밀번호 바꾸기 · 2단계 인증 켜기)의 받기 한도 — 서버가 엔진을 여러 번 부르고 세션을 끊는다. +/// 기본 한도로 끊으면 화면은 실패로 보는데 서버는 이미 바꿨을 수 있다(계약 1절 · 웹 `PASSWORD_CHANGE_TIMEOUT_MS` 와 같은 30초) +const Duration kSecuritySettingTimeout = Duration(seconds: 30); + /// API 연결 설정 class ApiConfig { const ApiConfig({ diff --git a/flutter/lib/core/api/api_paths.dart b/flutter/lib/core/api/api_paths.dart index f1ceb8d..f4ca6c2 100644 --- a/flutter/lib/core/api/api_paths.dart +++ b/flutter/lib/core/api/api_paths.dart @@ -8,10 +8,39 @@ abstract final class ApiPaths { /// 세션 확인 — 웹과 같은 경로를 Bearer 로 부른다 static const String me = '/auth/me'; - /// 401 에도 갱신하지 않는 경로 — 앱 login · refresh · logout (계약 1절 "앱 로그인 › 앱이 할 일") + // ── 로그인 틀의 제한 단계 (계약 5 · 6절 — 제한된 세션도 부를 수 있는 경로) ── + /// 비밀번호 바꾸기 — 새 비밀번호 단계 + static const String password = '/auth/password'; + + /// 2단계 인증 상태 — 새 비밀번호 단계가 코드 칸을 보일지 · 그사이 다른 곳에서 켰는지 + static const String twoFactor = '/two-factor'; + + /// 2단계 인증 등록 시작 — 새 키(10분) + static const String twoFactorEnrollments = '/two-factor/enrollments'; + + /// 2단계 인증 켜기 + static const String twoFactorActivation = '/two-factor/activation'; + + /// 문의 연락처 (로그인 없음) + static const String supportContact = '/support-contact'; + + // ── 앱 설정 · 새 메일 알림 (계약 1절 "앱 새 메일 알림 · 앱 설정") ── + /// 앱 설정 — 최소 판 · 새 메일 알림을 쓸 수 있는지 (로그인 없음) + static const String appConfig = '/app-config'; + + /// 이 세션의 새 메일 알림 등록 — `PUT`(FID · 플랫폼 · 앱 판) · `DELETE` (앱 토큰만) + static const String pushRegistration = '/push-registrations/current'; + + /// 이 세션의 "봤음" — `DELETE` 하면 서버가 "새 메일 N통"의 N 을 처음부터 센다 + static const String pushRegistrationUnseen = + '/push-registrations/current/unseen'; + + /// 401 에도 갱신하지 않고 Bearer 도 붙이지 않는 경로 — 앱 login · refresh · logout (계약 1절 "앱 로그인 › 앱이 할 일"), + /// 그리고 로그인 없는 앱 설정(인증 줄 밖의 Dio — `publicDioProvider` 로 보낸다. 줄 안으로 보내더라도 미리 갱신하지 않게) static const Set noRefresh = { appLogin, appRefresh, appLogout, + appConfig, }; } diff --git a/flutter/lib/core/api/auth_interceptor.dart b/flutter/lib/core/api/auth_interceptor.dart index 58864ab..a48c994 100644 --- a/flutter/lib/core/api/auth_interceptor.dart +++ b/flutter/lib/core/api/auth_interceptor.dart @@ -207,13 +207,16 @@ class AuthInterceptor extends QueuedInterceptor { return _SendAgain(_retryOptions(options, kRestrictionRetriedExtraKey)); } - final RestrictionCheck check = - _recentCheck(sentGeneration) ?? await _events.requestRecheck(); - _lastCheck = ( - generation: sentGeneration ?? _store.generation, - check: check, - at: _clock(), - ); + // 다시 쓴 결과는 시각을 새로 찍지 않는다 — 새로 물었을 때만 적는다(재사용 창이 끝없이 늘지 않게) + RestrictionCheck? check = _recentCheck(sentGeneration); + if (check == null) { + check = await _events.requestRecheck(); + _lastCheck = ( + generation: sentGeneration ?? _store.generation, + check: check, + at: _clock(), + ); + } return switch (check) { RestrictionCheck.renewed => _SendAgain( _retryOptions(options, kRestrictionRetriedExtraKey), diff --git a/flutter/lib/core/api/dio_client.dart b/flutter/lib/core/api/dio_client.dart index 39b1512..e60fa69 100644 --- a/flutter/lib/core/api/dio_client.dart +++ b/flutter/lib/core/api/dio_client.dart @@ -13,11 +13,11 @@ import 'package:dio/dio.dart'; /// - [dio]: 화면 · 저장소가 쓴다. 인터셉터 차례는 /// [ResponseInterceptor](언래핑 · 코드 정규화) → [AuthInterceptor](Bearer · 갱신 · 다시 보내기 · `AUTH_010`) /// → [ErrorNotifyInterceptor](알림 — 맨 끝). -/// - 안쪽 Dio(갱신 · 다시 보내기 전용): [ResponseInterceptor] 만 — 인증 인터셉터 줄을 다시 타지 않는다. +/// - 안쪽 Dio(갱신 · 다시 보내기 · 로그인 없는 앱 설정): [ResponseInterceptor] 만 — 인증 인터셉터 줄을 다시 타지 않는다. /// /// 로그 인터셉터(`LogInterceptor`)는 넣지 않는다 — 비밀번호 · 토큰이 본문 · 머리글에 실린다(로깅 규칙). class DioClient { - DioClient._(this.dio, this.refresher); + DioClient._(this.dio, this.refresher, this.publicDio); factory DioClient.create({ required ApiConfig config, @@ -62,7 +62,7 @@ class DioClient { ErrorNotifyInterceptor(notifier), ]); - return DioClient._(main, refresher); + return DioClient._(main, refresher, bare); } /// 화면 · 저장소가 쓰는 Dio @@ -70,4 +70,8 @@ class DioClient { /// 인증 인터셉터 줄 밖의 인증 요청(갱신 · 세션 확인 · 조용한 로그아웃) — 세션 노티파이어도 이것을 지난다(갱신은 한 번에 하나) final TokenRefresher refresher; + + /// 안쪽 Dio — 로그인 없이 앱을 띄우자마자 묻는 요청(앱 설정)이 쓴다. 인증 인터셉터는 요청을 한 줄로 세워 + /// 앞 요청의 미리 갱신을 기다리게 하므로, 줄 밖에서 세션 되살리기와 나란히 보낸다. 알림 인터셉터가 없다(알리지 않는 요청만) + final Dio publicDio; } diff --git a/flutter/lib/core/api/dio_provider.dart b/flutter/lib/core/api/dio_provider.dart index 18e68a0..64cdcc3 100644 --- a/flutter/lib/core/api/dio_provider.dart +++ b/flutter/lib/core/api/dio_provider.dart @@ -68,6 +68,12 @@ final Provider dioProvider = Provider( (Ref ref) => ref.watch(dioClientProvider).dio, ); +/// 로그인 없는 공개 요청의 Dio — 인증 인터셉터 줄 밖(Bearer · 갱신 · 알림 없음, 언래핑 · 코드 정규화만). +/// 앱을 띄우자마자 세션 되살리기와 나란히 묻는 앱 설정(`GET /api/app-config`)이 쓴다 +final Provider publicDioProvider = Provider( + (Ref ref) => ref.watch(dioClientProvider).publicDio, +); + /// 인증 인터셉터 줄 밖의 인증 요청(갱신은 한 번에 하나) — 인증 저장소 · 세션 노티파이어도 이것을 지난다 final Provider tokenRefresherProvider = Provider( diff --git a/flutter/lib/core/api/error_notifier.dart b/flutter/lib/core/api/error_notifier.dart index f24b587..943fab6 100644 --- a/flutter/lib/core/api/error_notifier.dart +++ b/flutter/lib/core/api/error_notifier.dart @@ -1,5 +1,6 @@ import 'package:arasystem_app/core/api/api_exception.dart'; import 'package:arasystem_app/core/api/request_flags.dart'; +import 'package:arasystem_app/core/api/token_refresher.dart'; import 'package:arasystem_app/core/utils/clock.dart'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; @@ -54,7 +55,8 @@ class ErrorNotifier { /// 실패를 알리는 인터셉터 — 인터셉터 줄의 맨 끝에 둔다(갱신 · 다시 보내기가 끝난 뒤의 결과만 알린다). /// /// 알리지 않는 것: 요청의 `silent`([kSilentExtraKey]) · 취소한 요청 · 인증 인터셉터가 조용히 끝낸 실패 -/// (제한을 확인한 `AUTH_010` · 사용자가 로그아웃한 뒤의 늦은 401 — 이 인터셉터까지 오지 않는다). +/// (제한을 확인한 `AUTH_010` · 사용자가 로그아웃한 뒤의 늦은 401 — 이 인터셉터까지 오지 않는다) · +/// 세션 끝(`AUTH_002`) — 세션이 끝나면 늘 로그인 화면으로 가고, 그 화면의 위쪽 상자가 같은 사전 문구로 알린다(두 번 뜨지 않게). class ErrorNotifyInterceptor extends Interceptor { ErrorNotifyInterceptor(this._notifier); @@ -65,6 +67,7 @@ class ErrorNotifyInterceptor extends Interceptor { final Object? error = err.error; if (err.type != DioExceptionType.cancel && error is ApiException && + error.code != kSessionExpiredCode && !isSilentFor(err.requestOptions, error.code)) { _notifier.show(error); } diff --git a/flutter/lib/core/api/request_flags.dart b/flutter/lib/core/api/request_flags.dart index 3f933b9..fcfa6bb 100644 --- a/flutter/lib/core/api/request_flags.dart +++ b/flutter/lib/core/api/request_flags.dart @@ -61,15 +61,20 @@ bool isSilentFor(RequestOptions options, String code) { return false; } -/// 실패 예외에서 비밀을 지운다 — 액세스 토큰(`Authorization` 머리)과 비밀 본문([kSecretBodyExtraKey]). +/// 실패 예외에서 비밀을 지운다 — 액세스 토큰(`Authorization` 머리)과 비밀 요청([kSecretBodyExtraKey])의 요청 본문 · 응답 본문. /// /// 인증 인터셉터가 실패를 내보내기 직전에 부른다(응답의 RequestOptions 도). 같은 예외를 돌려준다. DioException withoutSecrets(DioException error) { _scrub(error.requestOptions); - final RequestOptions? responseOptions = error.response?.requestOptions; - if (responseOptions != null && - !identical(responseOptions, error.requestOptions)) { - _scrub(responseOptions); + final Response? response = error.response; + if (response != null) { + if (!identical(response.requestOptions, error.requestOptions)) { + _scrub(response.requestOptions); + } + // 비밀 요청의 응답은 토큰을 실을 수 있다(로그인 · 갱신) — 코드는 이미 `ApiException` 에 있다 + if (error.requestOptions.extra[kSecretBodyExtraKey] == true) { + response.data = null; + } } return error; } diff --git a/flutter/lib/core/api/response_interceptor.dart b/flutter/lib/core/api/response_interceptor.dart index 114dcaf..2aafc45 100644 --- a/flutter/lib/core/api/response_interceptor.dart +++ b/flutter/lib/core/api/response_interceptor.dart @@ -1,3 +1,6 @@ +import 'dart:convert'; +import 'dart:typed_data'; + import 'package:arasystem_app/core/api/api_error_mapper.dart'; import 'package:arasystem_app/core/api/api_exception.dart'; import 'package:dio/dio.dart'; @@ -41,12 +44,75 @@ class ResponseInterceptor extends Interceptor { ); } + /// 실패 — 코드를 정한다. 바이트 · 스트림 응답(첨부 받기 · 그림)의 실패 본문은 JSON 으로 풀리지 않은 채 오므로 + /// 먼저 읽어(작다 — [kMaxErrorBodyBytes] 까지) 표준 실패의 `error.code` 를 꺼낸다. 성공 본문은 건드리지 않는다. + /// + /// 무슨 일이 있어도 `handler.next` 로 끝낸다(인증 인터셉터와 같은 규칙) — 비동기 처리기가 던지면 요청이 끝나지 않고 매달린다 @override - void onError(DioException err, ErrorInterceptorHandler handler) { + Future onError( + DioException err, + ErrorInterceptorHandler handler, + ) async { if (err.error is ApiException) { handler.next(err); return; } - handler.next(err.copyWith(error: toApiException(err))); + DioException failure; + try { + final Response? response = err.response; + if (response != null) { + final Map? decoded = await decodeRawErrorBody( + response.data, + ); + if (decoded != null) response.data = decoded; + } + failure = err.copyWith(error: toApiException(err)); + } on Object { + // 실패 본문을 읽다 뜻밖에 실패했다 — 코드는 최종 폴백으로 정하고 다음 인터셉터로 넘긴다 + failure = err.copyWith( + error: ApiException.fromCode( + kFallbackCode, + statusCode: err.response?.statusCode, + ), + ); + } + handler.next(failure); } } + +/// 바이트 · 스트림 응답의 실패 본문을 읽는 최대 크기 — 표준 실패 본문은 1KB 안팎이다 +const int kMaxErrorBodyBytes = 64 * 1024; + +/// 바이트(`List`) · 스트림(`ResponseBody`) 실패 본문 → JSON 객체. 이미 풀린 본문 · 너무 큼 · JSON 이 아님은 null. +/// 스트림은 끝까지(한도 안) 읽어 연결을 놓아 준다 +Future?> decodeRawErrorBody(Object? data) async { + final List? bytes; + if (data is ResponseBody) { + bytes = await _readLimited(data.stream); + } else if (data is List) { + bytes = data.length <= kMaxErrorBodyBytes ? data : null; + } else { + return null; + } + if (bytes == null || bytes.isEmpty) return null; + try { + final Object? json = jsonDecode(utf8.decode(bytes, allowMalformed: true)); + return json is Map ? json : null; + } on FormatException { + return null; + } +} + +Future?> _readLimited(Stream> stream) async { + final BytesBuilder builder = BytesBuilder(copy: false); + try { + await for (final List chunk in stream) { + builder.add(chunk); + // 한도를 넘으면 그만 읽는다(구독을 끊어 연결을 놓는다) — 코드는 상태로 정한다 + if (builder.length > kMaxErrorBodyBytes) return null; + } + } on Object { + return null; + } + return builder.takeBytes(); +} diff --git a/flutter/lib/core/api/token_refresher.dart b/flutter/lib/core/api/token_refresher.dart index 7d7e678..904d376 100644 --- a/flutter/lib/core/api/token_refresher.dart +++ b/flutter/lib/core/api/token_refresher.dart @@ -52,7 +52,8 @@ final class RefreshDiscarded extends RefreshOutcome { /// 앱 logout 요청 — 리프레시 토큰이 있으면 본문에, 액세스 토큰이 있으면 Bearer 로도 싣는다 /// (계약 1절 "앱 로그인": 서버는 리프레시 토큰으로 세션을 찾고, 없거나 틀리면 액세스 토큰의 `sid` 로 끝낸다 — 둘 다 없으면 빈 본문 `{}`). -/// 알림은 띄우지 않는다. 실패는 `DioException`(본문을 비운 것)으로 던진다. +/// 알림은 띄우지 않는다. 실패는 `DioException` 으로 던진다 — 기본 Dio(인증 인터셉터가 있는 것)로 부르면 본문 · Bearer 를 지운 채 오고, +/// 안쪽 Dio 로 부르는 [TokenRefresher.logoutQuietly] 는 예외를 밖으로 내지 않는다. Future postAppLogout( Dio dio, { required String? refreshToken, diff --git a/flutter/lib/core/api/user_agent.dart b/flutter/lib/core/api/user_agent.dart index daf7481..88f3579 100644 --- a/flutter/lib/core/api/user_agent.dart +++ b/flutter/lib/core/api/user_agent.dart @@ -44,46 +44,74 @@ String _clean(String? value) { : kept; } -/// 이 기기의 앱 판 · User-Agent — main 에서 한 번. 기기 정보를 읽지 못하면 아는 것만 싣는다. -Future<({String? appVersion, String userAgent})> loadAppDeviceInfo() async { +/// 이 기기의 앱 판 · 패키지 이름(업데이트 화면의 Play 스토어 주소) · User-Agent · 안드로이드 판(API — 새 메일 알림의 권한을 가른다) — main 에서 한 번. +/// 기기 정보를 읽지 못하면 아는 것만 싣는다. +Future< + ({ + String? appVersion, + String? packageName, + String userAgent, + int? androidSdkInt, + }) +> +loadAppDeviceInfo() async { String? appVersion; + String? packageName; try { - appVersion = (await PackageInfo.fromPlatform()).version; + final PackageInfo info = await PackageInfo.fromPlatform(); + appVersion = info.version; + packageName = info.packageName.isEmpty ? null : info.packageName; } on PlatformException { appVersion = null; } + final ({String userAgent, int? androidSdkInt}) device = await _deviceInfo( + appVersion, + ); return ( appVersion: appVersion, - userAgent: await _deviceUserAgent(appVersion), + packageName: packageName, + userAgent: device.userAgent, + androidSdkInt: device.androidSdkInt, ); } -Future _deviceUserAgent(String? appVersion) async { +Future<({String userAgent, int? androidSdkInt})> _deviceInfo( + String? appVersion, +) async { try { final DeviceInfoPlugin device = DeviceInfoPlugin(); if (Platform.isAndroid) { final AndroidDeviceInfo android = await device.androidInfo; - return buildUserAgent( - appVersion: appVersion, - osName: 'Android', - osVersion: android.version.release, - model: android.model, + return ( + userAgent: buildUserAgent( + appVersion: appVersion, + osName: 'Android', + osVersion: android.version.release, + model: android.model, + ), + androidSdkInt: android.version.sdkInt, ); } if (Platform.isIOS) { final IosDeviceInfo ios = await device.iosInfo; - return buildUserAgent( - appVersion: appVersion, - osName: 'iOS', - osVersion: ios.systemVersion, - model: ios.utsname.machine, + return ( + userAgent: buildUserAgent( + appVersion: appVersion, + osName: 'iOS', + osVersion: ios.systemVersion, + model: ios.utsname.machine, + ), + androidSdkInt: null, ); } } on PlatformException { // 아래의 아는 것만 싣는다 } - return buildUserAgent( - appVersion: appVersion, - osName: Platform.operatingSystem, + return ( + userAgent: buildUserAgent( + appVersion: appVersion, + osName: Platform.operatingSystem, + ), + androidSdkInt: null, ); } diff --git a/flutter/lib/core/app_config/app_config.dart b/flutter/lib/core/app_config/app_config.dart new file mode 100644 index 0000000..cc62d60 --- /dev/null +++ b/flutter/lib/core/app_config/app_config.dart @@ -0,0 +1,116 @@ +import 'package:flutter/foundation.dart'; + +/// 앱 판 — 숫자 셋(major.minor.patch). 판 이름(`PackageInfo.version` — pubspec 의 build-name, User-Agent 와 같은 값)을 견준다 +@immutable +class AppVersion implements Comparable { + const AppVersion(this.major, this.minor, this.patch); + + /// 판 이름을 읽는다 — `1.2` 는 `1.2.0`, `-` · `+` 뒤(미리 보기 꼬리 · 빌드 번호)는 버린다. + /// 칸이 넷 이상이거나 숫자가 아니면(칸마다 1~4자리) 읽지 못한 것(null)이다 — 칸마다 4자리는 서버의 판 칸(`APP_VERSION_PATTERN`)과 같다. + /// 5자리 칸을 받으면 서버 등록(`PUT`)이 늘 400 이 된다 + static AppVersion? tryParse(String? text) { + if (text == null) return null; + String value = text.trim(); + final int tail = value.indexOf(RegExp('[-+]')); + if (tail >= 0) value = value.substring(0, tail); + final List parts = value.split('.'); + if (parts.isEmpty || parts.length > 3) return null; + final List numbers = []; + for (final String part in parts) { + if (!_part.hasMatch(part)) return null; + numbers.add(int.parse(part)); + } + while (numbers.length < 3) { + numbers.add(0); + } + return AppVersion(numbers[0], numbers[1], numbers[2]); + } + + static final RegExp _part = RegExp(r'^\d{1,4}$'); + + final int major; + final int minor; + final int patch; + + @override + int compareTo(AppVersion other) { + if (major != other.major) return major.compareTo(other.major); + if (minor != other.minor) return minor.compareTo(other.minor); + return patch.compareTo(other.patch); + } + + bool operator <(AppVersion other) => compareTo(other) < 0; + + @override + bool operator ==(Object other) => + other is AppVersion && + other.major == major && + other.minor == minor && + other.patch == patch; + + @override + int get hashCode => Object.hash(major, minor, patch); + + @override + String toString() => '$major.$minor.$patch'; +} + +/// 서버에 알리는 앱 판 — `숫자.숫자.숫자`(서버 칸의 모양). 읽지 못하면 null +String? normalizedAppVersion(String? version) => + AppVersion.tryParse(version)?.toString(); + +/// 앱 설정 — `GET /api/app-config` (계약 1절 "앱 새 메일 알림 · 앱 설정", 로그인 없음) +/// +/// `{ minSupportedVersion: { android: "1.0.0", ios: null }, pushAvailable: true }` — 앱은 자기 플랫폼의 최소 판만 읽는다. +@immutable +class AppConfig { + const AppConfig({this.minSupportedVersion, this.pushAvailable}); + + /// 그 경로가 없는 옛 서버(404) — 막지 않고, 새 메일 알림은 쓸 수 없는 것으로 본다 + static const AppConfig notProvided = AppConfig(pushAvailable: false); + + /// 응답의 `data` 를 읽는다 — 모양이 틀린 칸은 없는 것으로 본다(최소 판이 틀리면 막지 않는다 · 알림 칸이 틀리면 모른다) + factory AppConfig.fromJson(Object? data, {String platform = kAppPlatform}) { + if (data is! Map) return const AppConfig(); + final Object? versions = data['minSupportedVersion']; + final Object? minimum = versions is Map + ? versions[platform] + : null; + final Object? push = data['pushAvailable']; + return AppConfig( + minSupportedVersion: minimum is String + ? AppVersion.tryParse(minimum) + : null, + pushAvailable: push is bool ? push : null, + ); + } + + /// 이 판보다 낮은 앱은 쓸 수 없다 — 없으면(정하지 않음 · 읽지 못함) 막지 않는다 + final AppVersion? minSupportedVersion; + + /// 서버가 새 메일 알림을 보낼 수 있다 — 모르면 null + final bool? pushAvailable; + + @override + bool operator ==(Object other) => + other is AppConfig && + other.minSupportedVersion == minSupportedVersion && + other.pushAvailable == pushAvailable; + + @override + int get hashCode => Object.hash(minSupportedVersion, pushAvailable); +} + +/// 앱이 읽는 플랫폼 칸 — iOS 를 붙이면 플랫폼마다 고른다 +const String kAppPlatform = 'android'; + +/// 업데이트해야 하는가 — 이 앱의 판이 서버의 최소 판보다 낮을 때만. 어느 쪽이든 읽지 못하면 막지 않는다 +bool isAppUpdateRequired({ + required String? appVersion, + required AppVersion? minimum, +}) { + if (minimum == null) return false; + final AppVersion? current = AppVersion.tryParse(appVersion); + if (current == null) return false; + return current < minimum; +} diff --git a/flutter/lib/core/app_config/app_config_controller.dart b/flutter/lib/core/app_config/app_config_controller.dart new file mode 100644 index 0000000..3c814d3 --- /dev/null +++ b/flutter/lib/core/app_config/app_config_controller.dart @@ -0,0 +1,138 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/dio_provider.dart'; +import 'package:arasystem_app/core/app_config/app_config.dart'; +import 'package:arasystem_app/core/app_config/app_config_repository.dart'; +import 'package:arasystem_app/core/constants/app_info_provider.dart'; +import 'package:arasystem_app/core/utils/app_foreground.dart'; +import 'package:arasystem_app/core/utils/provider_retry.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 앱 설정을 마지막으로 받은 뒤 이만큼 지나면 앞으로 올 때 다시 묻는다 +const Duration kAppConfigRefreshAfter = Duration(hours: 1); + +/// 받지 못했으면(끊긴 망 · 5xx) 앞으로 올 때 이만큼 간격으로 다시 묻는다 +const Duration kAppConfigRetryAfter = Duration(minutes: 1); + +/// 앱 설정 상태 — 받은 값과 받은 때 · 실패한 때(기기 시계 — 간격만 잰다) +@immutable +class AppConfigState { + const AppConfigState({this.config, this.fetchedAt, this.failedAt}); + + /// 마지막으로 받은 값 — 아직 받지 못했으면 null(막지 않는다 · 알림은 쓸 수 있는 것으로) + final AppConfig? config; + final DateTime? fetchedAt; + + /// 마지막 실패 — 그 뒤에 받았으면 의미가 없다 + final DateTime? failedAt; + + @override + bool operator ==(Object other) => + other is AppConfigState && + other.config == config && + other.fetchedAt == fetchedAt && + other.failedAt == failedAt; + + @override + int get hashCode => Object.hash(config, fetchedAt, failedAt); +} + +/// 앱 설정 API +final Provider appConfigRepositoryProvider = + Provider( + (Ref ref) => AppConfigRepository(ref.watch(publicDioProvider)), + ); + +/// 앱 설정 — 앱에 하나. 라우터(최소 판)와 새 메일 알림(`pushAvailable`)이 본다. +final NotifierProvider appConfigProvider = + NotifierProvider( + AppConfigController.new, + retry: noAutomaticRetry, + ); + +/// 앱 설정을 묻는다 — 앱을 띄울 때 한 번(세션 되살리기와 나란히 — 기다리지 않는다), 앞으로 올 때 마지막으로 받은 지 1시간이 지났으면. +/// +/// 실패(끊긴 망 · 5xx)는 알리지 않고 막지 않는다 — 앞으로 올 때 1분 간격으로 다시. 404(그 경로가 없는 옛 서버)는 받은 것으로 본다 +/// (최소 판 없음 · 알림 쓸 수 없음). 모양이 틀리면 막지 않는다. +class AppConfigController extends Notifier { + Future? _loading; + + @override + AppConfigState build() { + ref.listen(appForegroundProvider, (bool? previous, bool next) { + if (next && previous == false) refreshIfStale(); + }); + // 만들자마자 묻는다 — build 안에서는 상태를 바꾸지 않는다 + final Ref built = ref; + scheduleMicrotask(() { + if (built.mounted) unawaited(refresh()); + }); + return const AppConfigState(); + } + + /// 지금 묻는다 — 묻는 중이면 그 물음을 함께 기다린다 + Future refresh() { + final Future? running = _loading; + if (running != null) return running; + final Future started = _load(); + _loading = started; + return started.whenComplete(() { + if (identical(_loading, started)) _loading = null; + }); + } + + /// 앞으로 왔다 — 받은 지 1시간이 지났거나, 마지막이 실패였고 1분이 지났으면 묻는다 + void refreshIfStale() { + if (!ref.mounted || _loading != null) return; + final DateTime now = ref.read(clockProvider)(); + final DateTime? fetched = state.fetchedAt; + final DateTime? failed = state.failedAt; + final bool lastFailed = + failed != null && (fetched == null || failed.isAfter(fetched)); + final DateTime? since = lastFailed ? failed : fetched; + final Duration wait = lastFailed + ? kAppConfigRetryAfter + : kAppConfigRefreshAfter; + final Duration? elapsed = since == null ? null : now.difference(since); + // 시계를 뒤로 돌렸다(음수) — 지난 시간을 모르니 묻는다 + if (elapsed == null || elapsed.isNegative || elapsed >= wait) { + unawaited(refresh()); + } + } + + Future _load() async { + final Ref ref = this.ref; + try { + final AppConfig config = await ref + .read(appConfigRepositoryProvider) + .fetch(); + if (!ref.mounted) return; + state = AppConfigState( + config: config, + fetchedAt: ref.read(clockProvider)(), + ); + } on DioException { + if (!ref.mounted) return; + state = AppConfigState( + config: state.config, + fetchedAt: state.fetchedAt, + failedAt: ref.read(clockProvider)(), + ); + } + } +} + +/// 이 앱을 업데이트해야 하는가 — 라우터가 어느 화면에서든 업데이트 화면으로 옮긴다(`core/router/session_redirect.dart`) +final Provider appUpdateRequiredProvider = Provider( + (Ref ref) => isAppUpdateRequired( + appVersion: ref.watch(appVersionProvider), + minimum: ref.watch(appConfigProvider).config?.minSupportedVersion, + ), +); + +/// 서버가 새 메일 알림을 보낼 수 있는가 — 모르면 null(쓸 수 있는 것으로 본다) +final Provider pushAvailableProvider = Provider( + (Ref ref) => ref.watch(appConfigProvider).config?.pushAvailable, +); diff --git a/flutter/lib/core/app_config/app_config_repository.dart b/flutter/lib/core/app_config/app_config_repository.dart new file mode 100644 index 0000000..fe48816 --- /dev/null +++ b/flutter/lib/core/app_config/app_config_repository.dart @@ -0,0 +1,32 @@ +import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/api/api_paths.dart'; +import 'package:arasystem_app/core/api/request_flags.dart'; +import 'package:arasystem_app/core/app_config/app_config.dart'; +import 'package:dio/dio.dart'; + +/// 그 경로가 없을 때 서버 · 앞단 nginx 가 내는 코드 — 옛 서버로 본다 +const String _notFoundCode = 'RES_001'; + +/// 앱 설정 — `GET /api/app-config` (로그인 없음 · 알림 끔). +/// +/// 인증 인터셉터 줄 밖의 Dio(`publicDioProvider`)로 보낸다 — 앱을 띄우자마자 세션 되살리기(미리 갱신)를 기다리지 않고 나란히 묻는다. +/// 줄 안의 Dio 로 보내더라도 Bearer 를 붙이지 않고 미리 갱신하지 않는 경로다(`ApiPaths.noRefresh`). +class AppConfigRepository { + const AppConfigRepository(this._dio); + + final Dio _dio; + + /// 받는다 — 그 경로가 없으면(404 `RES_001` — 옛 서버) [AppConfig.notProvided]. 그 밖의 실패는 `DioException` + Future fetch() async { + try { + final Response response = await _dio.get( + ApiPaths.appConfig, + options: ApiOptions.silent(), + ); + return AppConfig.fromJson(response.data); + } on DioException catch (error) { + if (apiErrorCodeOf(error) == _notFoundCode) return AppConfig.notProvided; + rethrow; + } + } +} diff --git a/flutter/lib/core/constants/app_assets.dart b/flutter/lib/core/constants/app_assets.dart new file mode 100644 index 0000000..ef602dc --- /dev/null +++ b/flutter/lib/core/constants/app_assets.dart @@ -0,0 +1,10 @@ +/// 앱에 넣은 그림 파일 경로 (pubspec `assets`). +/// +/// 흰 로고 둘은 웹 `frontend/src/assets/brand/` 와 같은 파일이다(원본 `docs/brand/`) — 어두운 브랜드 바탕(#0B3640) 위에만 둔다. +abstract final class AppAssets { + /// 전체 로고(ARA SYSTEM) — 로그인 첫 화면의 브랜드 띠. 원본 960×459 + static const String logoFull = 'assets/brand/logo-arasystem-white.png'; + + /// ARA 마크 — 로그인 다음 단계의 짙은 띠. 원본 320×111 + static const String logoMark = 'assets/brand/logo-ara-white.png'; +} diff --git a/flutter/lib/core/constants/app_constants.dart b/flutter/lib/core/constants/app_constants.dart index fec044b..382d0fa 100644 --- a/flutter/lib/core/constants/app_constants.dart +++ b/flutter/lib/core/constants/app_constants.dart @@ -7,6 +7,13 @@ library; /// 홈 화면 · 작업 전환 화면에 보이는 앱 이름 const String kAppName = 'ARA SYSTEM'; +/// 아래 탭 막대(모듈 메뉴)의 화면 낭독기 이름 — 앱 디자인 README "앱 틀"(보드 `App1Shell` 의 `aria-label`) +const String kAppMenuLabel = '앱 메뉴'; + +/// 회사 메일 도메인 — 화면 안내 글에만 쓴다(로그인 화면의 "회사 메일 주소(@…)"). +/// 허용 도메인 검사는 서버가 한다. 웹 `frontend/src/shared/constants/brand.ts` 의 `COMPANY_MAIL_DOMAIN` 과 같은 값 +const String kCompanyMailDomain = 'arasystem.it.kr'; + /// 서버가 앱을 알아보는 User-Agent 제품 이름 — `ARASYSTEM-App/<판> (Android <판>; <기종>)` /// (계약 `docs/mail/api-contract.md` 1절 "앱 로그인 › 지금 세션" — 서버가 "ARA SYSTEM 앱 · Android" 꼴로 보인다) const String kUserAgentProduct = 'ARASYSTEM-App'; diff --git a/flutter/lib/core/constants/app_info_provider.dart b/flutter/lib/core/constants/app_info_provider.dart index 5900f14..31f9ec4 100644 --- a/flutter/lib/core/constants/app_info_provider.dart +++ b/flutter/lib/core/constants/app_info_provider.dart @@ -5,3 +5,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; final Provider appVersionProvider = Provider( (Ref ref) => null, ); + +/// 앱 식별자(패키지 이름 — Android applicationId `kr.it.arasystem.app`). main 이 기기 정보를 읽어 덮어쓴다. +/// 업데이트 화면의 [Play 스토어 열기]가 쓴다 — 읽지 못했으면 null(단추를 누르면 열지 못했다고 알린다) +final Provider appPackageNameProvider = Provider( + (Ref ref) => null, +); diff --git a/flutter/lib/core/constants/login_texts.dart b/flutter/lib/core/constants/login_texts.dart new file mode 100644 index 0000000..564b54a --- /dev/null +++ b/flutter/lib/core/constants/login_texts.dart @@ -0,0 +1,164 @@ +/// 로그인 틀 네 단계의 화면 글 — 원본은 앱 보드 `App1Login`(`docs/design/webmail/App1Login.dc.html`)이다. +/// +/// 보드에 없는 상태(보내는 중 · 임시 비밀번호 칸 · 코드 칸 · 마친 뒤 알림 등)의 글은 웹 로그인 틀의 상수 +/// (`frontend/src/shared/constants/account-security.ts` · `session-restriction.ts`)를 그대로 옮겼다 — 웹과 앱을 함께 쓰는 직원에게 같은 말. +/// 서버가 거절한 까닭의 문구는 여기 두지 않는다 — 에러 코드 사전(`ErrorLexicon`)에서만 온다. +/// 제목은 `screen_titles.dart`. 말투는 디자인 README 「화면 문구 — 사용자에게 말하듯」. +library; + +import 'package:arasystem_app/core/constants/app_constants.dart'; + +/// 첫 단계 — 주소와 비밀번호 +abstract final class LoginTexts { + /// 브랜드 띠의 큰 글 + static const String brandHeadline = '사내 업무를 한 계정으로'; + + /// 브랜드 띠의 소개 글 + static const String brandLead = '메일부터 시작해 캘린더와 파일 저장소까지 같은 계정으로 쓸 수 있게 됩니다.'; + + static const String subtitle = '회사 메일 주소(@$kCompanyMailDomain)로 로그인해 주세요.'; + + static const String emailLabel = '메일 주소'; + static const String emailPlaceholder = 'name@$kCompanyMailDomain'; + static const String passwordLabel = '비밀번호'; + static const String passwordPlaceholder = '비밀번호 입력'; + + /// [보기] 단추의 이름 (눌러 둔 상태는 화면 낭독기가 따로 읽는다) + static const String showPassword = '비밀번호 보기'; + + static const String submit = '로그인'; + + /// 보내는 동안 — 웹과 같다 + static const String submitting = '로그인하는 중…'; + + /// 코드 단계에 10분 넘게 머문 뒤 [확인]을 눌러 돌아왔을 때 위쪽 상자 (계약 5절 "로그인" 클라이언트 — 웹과 같다) + static const String codeTimedOut = '코드를 넣는 시간이 지났습니다. 다시 로그인해 주세요.'; +} + +/// 다음 단계들의 짙은 띠 +abstract final class LoginBandTexts { + static const String otherAccount = '다른 계정으로 로그인'; +} + +/// 문의 줄 — 연락처(`GET /api/support-contact`)가 있으면 물음 다음 줄에 연락처, 없거나 받지 못하면 지금 글 그대로(웹과 같다) +abstract final class SupportLineTexts { + /// 첫 단계 + static const String passwordQuestion = '비밀번호를 잊으셨나요?'; + static const String passwordFallback = '비밀번호를 잊으셨다면 관리자에게 문의해 주세요.'; + + /// 코드 단계 · 2단계 인증 켜기 + static const String authenticatorQuestion = '인증 앱을 쓸 수 없나요?'; + static const String authenticatorFallback = '인증 앱을 쓸 수 없나요? 관리자에게 문의해 주세요.'; +} + +/// 둘째 단계 — 2단계 인증 코드 (2단계를 켠 직원) +abstract final class LoginCodeTexts { + static const String subtitle = '비밀번호를 확인했습니다. 휴대폰 인증 앱에 표시된 코드를 넣어 주세요.'; + static const String codeLabel = '인증 앱에 표시된 6자리 코드'; + static const String submit = '확인'; + static const String submitting = '확인하는 중…'; + static const String note = + 'Outlook이나 휴대폰 기본 메일 앱 같은 다른 메일 프로그램은 이 코드를 묻지 않습니다. 웹메일의 [설정] → [앱 비밀번호]에서 만든 비밀번호로 연결해 주세요.'; +} + +/// 인증 앱 코드 칸 (코드 단계 · 새 비밀번호 단계 · 2단계 켜기) +abstract final class OtpTexts { + static const String placeholder = '000000'; +} + +/// 제한 단계 공용 — 단계는 마쳤지만 다음으로 넘기지 못했을 때(새 토큰 받기 · 세션 확인 모두 장애) 위쪽 상자의 단추 +abstract final class RestrictedStepTexts { + static const String retry = '다시 시도'; +} + +/// 셋째 단계 — 새 비밀번호 정하기 (제한 `passwordChange`) +abstract final class PasswordChangeTexts { + static const String subtitle = + '관리자가 정한 임시 비밀번호로 로그인했습니다. 메일을 쓰려면 먼저 새 비밀번호를 정해 주세요.'; + + /// 로그인 때 넣은 비밀번호를 쥐고 있지 않을 때만(앱을 다시 열었다 · 10분 지남) 맨 위에 — 웹과 같다 + static const String temporaryLabel = '임시 비밀번호'; + static const String showTemporary = '임시 비밀번호 보기'; + + /// 비우고 보내면 이 글이 빨간 오류가 된다 + static const String temporaryHint = '관리자에게 받은 임시 비밀번호를 다시 넣어 주세요.'; + + static const String nextLabel = '새 비밀번호'; + static const String showNext = '새 비밀번호 보기'; + + /// 규칙(8자 이상 · 임시 비밀번호와 다름)에 맞지 않으면 이 글이 빨간 오류가 된다 + static const String nextHint = + '8자 이상이고 임시 비밀번호와 달라야 합니다. 영문, 숫자, 기호를 섞으면 더 안전합니다.'; + + /// 보기 단추 없이 새 비밀번호의 [보기]를 따른다 + static const String confirmLabel = '새 비밀번호 확인'; + static const String mismatch = '새 비밀번호가 서로 다릅니다.'; + + /// 2단계 인증을 켠 직원만 — 맨 아래 (로그인 때 쓴 코드는 2분 동안 다시 받지 않는다 — 웹과 같다) + static const String codeLabel = '인증 앱 코드'; + static const String codeHint = + '로그인할 때 넣은 코드는 다시 쓸 수 없으니, 앱에 다음 코드가 뜨면 넣어 주세요.'; + + static const String submit = '비밀번호 바꾸고 계속'; + static const String submitting = '바꾸는 중…'; + static const String note = '아웃룩 같은 메일 프로그램을 연결해 두었다면 비밀번호를 바꾼 뒤 다시 연결해 주세요.'; + + /// 마친 뒤 알림 — 웹과 같다 + static const String done = '새 비밀번호를 정했습니다.'; +} + +/// 넷째 단계 — 2단계 인증 켜기 (제한 `twoFactorSetup`). 같은 휴대폰이라 QR 대신 [인증 앱에 추가] · 키 · [복사] +abstract final class TwoFactorSetupTexts { + static const String subtitle = + '회사 정책에 따라 로그인할 때 휴대폰 인증 앱의 코드도 확인합니다. 지금 켜면 바로 메일을 쓸 수 있습니다.'; + + /// ① 인증 앱에 추가 + static const String addTitle = '인증 앱에 추가해 주세요'; + static const String addHint = + '이 휴대폰의 인증 앱에 바로 추가합니다. 앱이 없으면 Google Authenticator나 Microsoft Authenticator를 먼저 설치해 주세요.'; + static const String addButton = '인증 앱에 추가'; + + /// [인증 앱에 추가]로 열 앱을 찾지 못했을 때 + static const String addFailed = + '이 휴대폰에서 인증 앱을 찾지 못했습니다. 인증 앱을 설치한 뒤 다시 눌러 주세요. 아래 키를 직접 입력해도 됩니다.'; + + static const String keyLabel = '추가되지 않으면 이 키를 인증 앱에 직접 입력해 주세요'; + static const String copy = '복사'; + + /// [복사]의 화면 낭독기 이름 + static const String copyLabel = '키 복사'; + + /// 복사한 뒤 알림 — 웹과 같다. 키는 클립보드에 남는다(앱이 지우지 않는다) + static const String copied = '키를 복사했습니다.'; + + /// 복사하지 못했을 때 — 앱의 키는 골라 복사할 수 없어 웹 글("직접 골라 복사")을 고쳤다 + static const String copyFailed = '키를 복사하지 못했습니다. 키를 보고 인증 앱에 직접 입력해 주세요.'; + + /// 인증 앱에 저장될 이름 — 서버의 otpauth 주소와 같은 이름(계약 5절) + static String savedAs(String email) => '앱에는 ‘ARA SYSTEM ($email)’로 저장됩니다.'; + + /// 키를 받는 동안 회색 자리의 화면 낭독기 글 + static const String keyLoading = '키를 만드는 중…'; + + /// 키를 받지 못했을 때 사전 문구 옆의 단추 — 웹과 같다 + static const String retry = '다시 시도'; + + /// ② 코드 + static const String codeLabel = '앱에 표시된 6자리 코드'; + static const String codeHint = '인증 앱에서 코드를 복사해 와서 붙여 넣어도 됩니다.'; + + /// ③ 지금 비밀번호 — 로그인 때 넣은 비밀번호를 쥐고 있지 않을 때만(앱을 다시 열었다 · 10분 지남) + static const String passwordLabel = '지금 비밀번호'; + static const String showPassword = '지금 비밀번호 보기'; + + /// 비우고 보내면 이 글이 빨간 오류가 된다 (웹의 "새로고침한 뒤에는…"을 앱에 맞게 고쳤다) + static const String passwordHint = '지금 비밀번호를 한 번 더 넣어 주세요.'; + + static const String submit = '켜고 계속'; + static const String submitting = '켜는 중…'; + static const String note = + 'Outlook이나 휴대폰 기본 메일 앱 같은 다른 메일 프로그램은 이 코드를 묻지 않습니다. 켠 뒤에는 웹메일의 [설정] → [앱 비밀번호]에서 만든 비밀번호로 연결해 주세요.'; + + /// 켠 뒤 알림 — 웹과 같다(웹의 [앱 비밀번호 만들기] 단추는 앱에 그 화면이 없어 뺐다) + static const String done = '2단계 인증을 켰습니다. 메일 프로그램은 앱 비밀번호로 연결합니다.'; +} diff --git a/flutter/lib/core/constants/screen_titles.dart b/flutter/lib/core/constants/screen_titles.dart index d65c4b5..b25abc4 100644 --- a/flutter/lib/core/constants/screen_titles.dart +++ b/flutter/lib/core/constants/screen_titles.dart @@ -1,24 +1,23 @@ -/// 로그인 전 · 제한 단계 화면 이름 — 지금은 자리 화면(이름만 보인다)이 쓴다. -/// 모듈 화면의 이름은 그 모듈 안에 둔다(`features/<모듈>/presentation/<모듈>_texts.dart`). +/// 로그인 전 · 제한 단계 화면의 제목 — 앱 보드 `App1Login` 의 제목 그대로다(화면 낭독기가 단계가 바뀔 때 이 글을 읽는다). +/// 그 밖의 화면 글은 `login_texts.dart`, 모듈 화면의 글은 그 모듈 안(`features/<모듈>/presentation/<모듈>_texts.dart`)에 둔다. /// -/// 화면은 캔버스로 확인받은 뒤 만든다. 그때 화면 글은 이 파일 같은 상수에 모으고 -/// 디자인 README 「화면 문구 — 사용자에게 말하듯」 말투를 따른다. +/// 화면 글은 디자인 README 「화면 문구 — 사용자에게 말하듯」 말투를 따른다. abstract final class ScreenTitles { - /// 세션을 되살리는 동안 + /// 세션을 되살리는 동안 — 아직 자리 화면(보드 없음) static const String start = '시작하는 중'; - /// 로그인 — 비밀번호 단계 + /// 로그인 — 주소와 비밀번호 static const String login = '로그인'; - /// 로그인 — 2단계 인증 코드 단계 - static const String loginCode = '인증 코드 입력'; + /// 로그인 — 2단계 인증 코드 + static const String loginCode = '2단계 인증'; /// 제한된 세션 — 새 비밀번호 정하기 (passwordChange) - static const String passwordChange = '새 비밀번호 정하기'; + static const String passwordChange = '새 비밀번호를 정해 주세요'; /// 제한된 세션 — 2단계 인증 켜기 (twoFactorSetup) - static const String twoFactorSetup = '2단계 인증 켜기'; + static const String twoFactorSetup = '2단계 인증을 켜 주세요'; - /// 제한된 세션 — 앱이 모르는 제한 (웹과 같이 사전 문구와 [다른 계정으로 로그인]만) - static const String restricted = '로그인 제한'; + /// 앱을 업데이트해야 한다 — 업데이트 화면의 제목(보드 `App1Push` ⑩) + static const String update = '앱을 업데이트해 주세요'; } diff --git a/flutter/lib/core/files/downloaded_files.dart b/flutter/lib/core/files/downloaded_files.dart new file mode 100644 index 0000000..1d7fa90 --- /dev/null +++ b/flutter/lib/core/files/downloaded_files.dart @@ -0,0 +1,215 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; + +import 'package:arasystem_app/core/files/file_names.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path_provider/path_provider.dart'; + +/// 받은 파일을 두는 폴더 이름 — 앱 임시 폴더(Android `cacheDir`) 아래. +/// **짝: `android/app/src/main/res/xml/downloaded_file_paths.xml` 의 `cache-path` 와 `MainActivity.kt` 의 `DOWNLOADS_DIR`** — 바꾸면 셋 다 +const String kDownloadedFilesFolder = 'downloads'; + +/// 받은 파일 하나의 자리 — 받는 동안 조각을 이어 쓰고, 다 받으면 닫는다 +abstract class DownloadTarget { + /// 기기 안의 경로 — 다른 앱으로 열 때 쓴다(로그 · 알림에 싣지 않는다) + String get path; + + /// 받은 조각을 이어 쓴다 + void add(List chunk); + + /// 다 받았다 — 쓴 것을 마저 내보내고 닫는다 + Future close(); + + /// 받다 말았다(실패 · 취소) — 닫고 지운다 + Future discard(); +} + +/// 받은 파일을 잠깐 두는 곳 — 앱이 다른 앱에 파일을 건네려고 둔다(모듈 공통). +/// +/// 정리하는 때: 앱을 띄울 때(main — 지난 실행이 남긴 것) · 메일을 보는 직원이 바뀔 때(로그아웃 · 세션 끝 · 다른 직원) · +/// 기기가 저장 공간이 모자랄 때(안드로이드가 임시 폴더를 비운다). 다른 앱이 아직 읽는 중일 수 있어 연 직후에는 지우지 않는다. +abstract class DownloadedFileStore { + /// 새 파일 자리 — 받을 때마다 새 폴더 하나(같은 이름이어도 덮지 않는다). 이름은 [safeFileName] 으로 정리한다 + Future create(String fileName); + + /// 받아 둔 파일이 아직 있는가 (기기가 임시 폴더를 비웠을 수 있다) + Future exists(String path); + + /// 모두 지운다 + Future purge(); +} + +/// 받은 파일을 두는 곳 — 앱 임시 폴더의 [kDownloadedFilesFolder] 아래 +final Provider downloadedFileStoreProvider = + Provider((Ref ref) => CacheDirFileStore()); + +/// 앱 임시 폴더(path_provider `getTemporaryDirectory` — Android `cacheDir`)를 쓰는 [DownloadedFileStore]. +/// +/// 지우기와 새 자리 만들기는 부른 차례대로 하나씩 한다 — 앱을 띄울 때의 정리가 막 만든 자리를 지우지 않게. +class CacheDirFileStore implements DownloadedFileStore { + CacheDirFileStore({ + Future Function() baseDirectory = getTemporaryDirectory, + Random? random, + }) : _baseDirectory = baseDirectory, + _random = random ?? Random.secure(); + + final Future Function() _baseDirectory; + final Random _random; + Future _queue = Future.value(); + + /// 받을 때마다 만드는 폴더 이름의 길이(16진수) + static const int _folderNameLength = 16; + + Future _serial(Future Function() task) { + final Future next = _queue.then((_) => task()); + _queue = next.then((_) {}, onError: (Object _) {}); + return next; + } + + /// 받은 파일 폴더 — 임시 폴더를 알 수 없으면(플러그인 · 기기 사정) 저장하지 못한 것으로 다룬다([FileSystemException]) + Future _root() async { + final Directory base; + try { + base = await _baseDirectory(); + } on FileSystemException { + rethrow; + } on Exception { + throw const FileSystemException('임시 폴더를 찾지 못했습니다'); + } + return Directory('${base.path}/$kDownloadedFilesFolder'); + } + + @override + Future create(String fileName) => _serial(() async { + final Directory root = await _root(); + final Directory folder = Directory('${root.path}/${_folderName()}'); + await folder.create(recursive: true); + final File file = File('${folder.path}/${safeFileName(fileName)}'); + return _FileTarget(file, file.openWrite()); + }); + + @override + Future exists(String path) => File(path).exists(); + + @override + Future purge() => _serial(() async { + try { + final Directory root = await _root(); + if (await root.exists()) await root.delete(recursive: true); + } on FileSystemException { + // 임시 폴더를 모르거나 다른 앱이 열고 있는 등 — 다음 정리 · 기기가 지운다 + } + }); + + String _folderName() => List.generate( + _folderNameLength, + (_) => _random.nextInt(16).toRadixString(16), + ).join(); +} + +class _FileTarget implements DownloadTarget { + _FileTarget(this._file, this._sink) { + // 쓰기가 실패하면(폴더가 지워짐 · 저장 공간) 그 실패는 close · discard 가 받는다 — 그 전에 잡히지 않은 오류로 알려지지 않게 + unawaited(_sink.done.then((_) {}, onError: (Object _) {})); + } + + final File _file; + final IOSink _sink; + bool _closed = false; + + @override + String get path => _file.path; + + @override + void add(List chunk) => _sink.add(chunk); + + /// 닫기가 남은 쓰기를 마저 내보낸다 — `flush` 는 부르지 않는다(쓰기가 이미 실패한 IOSink 의 flush 는 끝나지 않는다. + /// close 는 그 실패를 [FileSystemException] 으로 돌려준다) + @override + Future close() async { + if (_closed) return; + _closed = true; + await _sink.close(); + } + + @override + Future discard() async { + if (!_closed) { + _closed = true; + try { + await _sink.close(); + } on FileSystemException { + // 지우는 것이 목적이다 + } + } + try { + await _file.parent.delete(recursive: true); + } on FileSystemException { + // 이미 없거나 다른 앱이 열었다 — 다음 정리 · 기기가 지운다 + } + } +} + +/// 받은 파일을 다른 앱으로 연 결과 +enum FileOpenResult { + /// 다른 앱을 띄웠다(또는 고르는 창) + opened, + + /// 이 형식을 열 앱이 없다 + noApp, + + /// 파일이 없다(기기가 임시 폴더를 비웠다) + notFound, + + /// 그 밖의 실패(이 기기에서 열 수 없음 — iOS 는 아직 없다) + failed, +} + +/// 받은 파일을 다른 앱으로 연다 — 시험은 덮어쓴다 +abstract class FileOpener { + Future open({required String path, required String mimeType}); +} + +final Provider fileOpenerProvider = Provider( + (Ref ref) => const PlatformFileOpener(), +); + +/// 안드로이드 `ACTION_VIEW`(`MainActivity.kt` 의 파일 채널) — 앱의 FileProvider 가 [kDownloadedFilesFolder] 안의 파일만 건넨다. +/// +/// 패키지를 더하지 않고 앱 안에 둔 까닭: 파일 여는 플러그인은 앱에 저장소 · 사진 권한(Play 의 사진 · 동영상 권한 정책)과 +/// 기기 전체를 가리키는 FileProvider 경로를 더하고, 공유(share) 플러그인은 "열기"가 아니라 "보내기" 창이다. +/// 이 채널은 받은 파일 폴더 한 곳만 읽게 건넨다(읽기 권한 1회 — `FLAG_GRANT_READ_URI_PERMISSION`). iOS 는 붙일 때 만든다 +class PlatformFileOpener implements FileOpener { + const PlatformFileOpener(); + + /// 짝: `MainActivity.kt` 의 `FILES_CHANNEL` · `OPEN_METHOD` + static const MethodChannel channel = MethodChannel( + 'kr.it.arasystem.app/files', + ); + static const String openMethod = 'open'; + + @override + Future open({ + required String path, + required String mimeType, + }) async { + try { + final String? result = await channel.invokeMethod( + openMethod, + {'path': path, 'type': mimeType}, + ); + return switch (result) { + 'opened' => FileOpenResult.opened, + 'noApp' => FileOpenResult.noApp, + 'notFound' => FileOpenResult.notFound, + _ => FileOpenResult.failed, + }; + } on PlatformException { + return FileOpenResult.failed; + } on MissingPluginException { + return FileOpenResult.failed; + } + } +} diff --git a/flutter/lib/core/files/file_names.dart b/flutter/lib/core/files/file_names.dart new file mode 100644 index 0000000..eaa4954 --- /dev/null +++ b/flutter/lib/core/files/file_names.dart @@ -0,0 +1,90 @@ +/// 받은 파일을 기기에 둘 때의 이름 · 고른 파일의 보이는 이름 — 순수 함수 (모듈 공통: 메일 첨부 · 나중의 파일 모듈). +/// +/// 이름은 보낸 쪽이 정한 글이라 믿지 않는다 — 경로 문자 · 제어 문자 · 방향 제어 문자를 지우고 길이를 줄인다 +/// (서버의 첨부 이름 정리 `attachment.policy.ts` 와 같은 결 — 확장자는 남긴다). 저장 경로는 앱이 만든 폴더 안 한 칸뿐이다. +library; + +import 'dart:convert'; + +/// 이름이 비면 쓰는 이름 +const String kFallbackFileName = '첨부파일'; + +/// 저장 이름의 최대 UTF-8 바이트 — 안드로이드 파일 시스템(ext4 · f2fs)의 한 칸 한도(255)보다 넉넉히 작게 +const int kMaxFileNameBytes = 200; + +/// 남기는 확장자의 최대 길이 (점 뒤) +const int _maxExtensionLength = 16; + +/// 지우는 글자 — 제어 문자(C0 · DEL · C1) +bool _isControl(int rune) => rune <= 0x1F || (rune >= 0x7F && rune <= 0x9F); + +/// 지우는 글자 — 방향 제어 · 보이지 않는 글자(오른쪽에서 왼쪽 표시로 "…fdp.exe" 를 "…exe.pdf" 처럼 보이게 하는 속임 이름) +bool _isInvisibleFormat(int rune) => + rune == 0x061C || + (rune >= 0x200B && rune <= 0x200F) || + (rune >= 0x202A && rune <= 0x202E) || + (rune >= 0x2060 && rune <= 0x2069) || + rune == 0xFEFF; + +/// 밑줄로 바꾸는 글자 — 경로 구분 · 다른 시스템이 이름에 받지 않는 글자 +const String _replacedCharacters = r'/\:*?"<>|'; + +/// 기기에 둘 파일 이름 — 비거나 `.` · `..` 뿐이면 [fallback]. **짝: `MainActivity.kt` 의 `safeFileName`**(고른 파일의 복사본 이름) +String safeFileName(String name, {String fallback = kFallbackFileName}) { + final StringBuffer cleaned = StringBuffer(); + for (final int rune in name.runes) { + if (_isControl(rune) || _isInvisibleFormat(rune)) continue; + final String char = String.fromCharCode(rune); + cleaned.write(_replacedCharacters.contains(char) ? '_' : char); + } + // 앞뒤 공백 · 점을 지운다 — 앞의 점은 숨은 파일, 끝의 점 · 공백은 다른 시스템이 받지 않는다 + String result = cleaned.toString().trim(); + result = result.replaceAll(RegExp(r'^[.\s]+|[.\s]+$'), ''); + if (result.isEmpty) return fallback; + return _fitBytes(result, kMaxFileNameBytes); +} + +/// 고른 파일의 보이는 이름 — 고르는 창의 앱이 알려 준 이름(`OpenableColumns.DISPLAY_NAME`)에서 경로 앞부분 · 제어 문자 · +/// 방향 제어 · 보이지 않는 글자를 지우고 앞뒤 공백을 지운다. 비면 [kFallbackFileName]. +/// +/// 다른 글자(`:` 등)와 길이는 그대로 둔다 — 서버가 받은 이름을 같은 결로 다시 정리한다(`shared/utils/file-name.ts` 의 `sanitizeFileName` — +/// 웹이 보내는 파일 이름과 같게). 기기 안 복사본의 이름은 따로 정리한다([safeFileName] · `MainActivity.kt` 의 `safeFileName`). +/// **짝: `MainActivity.kt` 의 `displayFileName`** — 고를 때 기기가 이 이름의 확장자로 형식을 미리 거른다(공백은 Dart `trim()` 의 공백) +String displayFileName(String? name) { + if (name == null) return kFallbackFileName; + final int slash = name.lastIndexOf(RegExp(r'[/\\]')); + final String segment = slash == -1 ? name : name.substring(slash + 1); + final StringBuffer cleaned = StringBuffer(); + for (final int rune in segment.runes) { + if (_isControl(rune) || _isInvisibleFormat(rune)) continue; + cleaned.writeCharCode(rune); + } + final String result = cleaned.toString().trim(); + return result.isEmpty ? kFallbackFileName : result; +} + +/// UTF-8 [maxBytes] 안으로 줄인다 — 확장자(점 뒤 짧은 영문 · 숫자)는 남기고 앞부분을 글자 단위로 자른다 +String _fitBytes(String name, int maxBytes) { + if (utf8.encode(name).length <= maxBytes) return name; + final int dot = name.lastIndexOf('.'); + final String extension = + dot > 0 && + name.length - dot - 1 <= _maxExtensionLength && + RegExp(r'^[A-Za-z0-9]+$').hasMatch(name.substring(dot + 1)) + ? name.substring(dot) + : ''; + final String base = extension.isEmpty ? name : name.substring(0, dot); + final int budget = maxBytes - utf8.encode(extension).length; + final StringBuffer kept = StringBuffer(); + int used = 0; + for (final int rune in base.runes) { + final int size = utf8.encode(String.fromCharCode(rune)).length; + if (used + size > budget) break; + kept.writeCharCode(rune); + used += size; + } + final String trimmed = kept.toString().trimRight(); + return trimmed.isEmpty + ? '$kFallbackFileName$extension' + : '$trimmed$extension'; +} diff --git a/flutter/lib/core/files/file_types.dart b/flutter/lib/core/files/file_types.dart new file mode 100644 index 0000000..794a34e --- /dev/null +++ b/flutter/lib/core/files/file_types.dart @@ -0,0 +1,95 @@ +/// 받은 파일을 다른 앱으로 열 때의 형식(MIME)과 앱에서 열지 않는 형식 — 순수 함수 (모듈 공통: 메일 첨부 · 나중의 파일 모듈). +/// +/// 형식은 보낸 쪽이 정한 글이라 확장자보다 믿지 않는다 — 열 형식은 정리한 이름([safeFileName])의 확장자에서 먼저 정하고 +/// (`package:mime` 의 표), 확장자를 모를 때만 서버가 알려 준 형식(첨부의 `type`)을 쓴다. 안드로이드는 이 형식으로 열 앱을 고른다. +/// +/// 웹 문서 · SVG · 설치 파일 · 인증서 · 키 묶음은 앱에서 열지 않는다 — 다른 앱(브라우저)이 그 파일의 스크립트를 돌리거나 +/// 기기에 설치 · 등록하는 창을 띄우지 않게. 확장자 · 확장자에서 나온 형식 · 서버 형식 가운데 하나라도 걸리면 받지도 않는다. +/// 고르는 창(`Intent.createChooser`)으로 여는 길도 쓰지 않는다 — 직원이 고른 브라우저가 스크립트를 돌린다 +library; + +import 'package:arasystem_app/core/files/file_names.dart'; +import 'package:mime/mime.dart'; + +/// 형식을 모를 때 — 안드로이드는 이 형식을 받는 앱이 드물다(열 앱이 없다고 알린다) +const String kGenericFileType = 'application/octet-stream'; + +/// 앱에서 열지 않는 확장자 (소문자 — 정리한 이름의 마지막 점 뒤) +const Set kBlockedOpenExtensions = { + // 웹 문서 — 브라우저가 열면 스크립트가 돈다 + 'html', 'htm', 'xhtml', 'xht', 'shtml', 'mht', 'mhtml', // + // SVG — 그림이지만 스크립트를 담는다 + 'svg', 'svgz', // + // 설치 파일 + 'apk', 'apks', 'xapk', // + // 인증서 · 키 묶음 — 열면 기기에 설치하는 창이 뜬다 + 'p12', 'pfx', 'cer', 'crt', 'der', 'pem', 'p7b', 'p7c', +}; + +/// 앱에서 열지 않는 형식 — 위 확장자에서 나오는 형식(`package:mime` 의 표 — 시험이 빠짐없이 맞춰 본다)과 +/// 서버 · 보낸 쪽이 같은 갈래에 흔히 쓰는 형식 +const Set kBlockedOpenTypes = { + // 웹 문서 · MHTML + 'text/html', 'application/xhtml+xml', 'multipart/related', // + 'application/x-mimearchive', // + // SVG + 'image/svg+xml', // + // 설치 파일 + 'application/vnd.android.package-archive', // + // 인증서 · 키 묶음 + 'application/x-pkcs12', 'application/pkcs12', 'application/pkix-cert', // + 'application/x-x509-ca-cert', 'application/x-x509-user-cert', // + 'application/x-x509-server-cert', 'application/x-pem-file', // + 'application/x-pkcs7-certificates', 'application/pkcs7-mime', // + 'application/x-pkcs7-mime', 'application/x-pkcs7-certreqresp', +}; + +/// 형식 글의 모양 — `종류/이름` (짝: `MainActivity.kt` 의 `MIME_PATTERN` — 앱도 넘겨받은 형식을 이 모양으로 다시 본다) +final RegExp _typePattern = RegExp( + r'^[a-z0-9][a-z0-9!#$&^_.+-]{0,126}/[a-z0-9][a-z0-9!#$&^_.+-]{0,126}$', +); + +/// 정리한 이름의 확장자 — 소문자, 없으면 빈 글 +String fileExtensionOf(String fileName) { + final String name = safeFileName(fileName); + final int dot = name.lastIndexOf('.'); + if (dot <= 0 || dot == name.length - 1) return ''; + return name.substring(dot + 1).toLowerCase(); +} + +/// 서버가 알려 준 형식을 비교할 꼴로 — 소문자, `;` 뒤의 매개변수(`charset=` 등)를 뗀다. 모양이 틀리면 null +String? normalizedFileType(String? type) { + if (type == null) return null; + final int semicolon = type.indexOf(';'); + final String value = (semicolon == -1 ? type : type.substring(0, semicolon)) + .trim() + .toLowerCase(); + return _typePattern.hasMatch(value) ? value : null; +} + +/// 확장자에서 나오는 형식 — 모르거나 아무 형식(octet-stream)이면 null +String? fileTypeOfExtension(String extension) { + if (extension.isEmpty) return null; + final String? type = lookupMimeType('file.$extension'); + return type == null || type == kGenericFileType ? null : type; +} + +/// 다른 앱으로 열 때 넘길 형식 — 확장자에서 먼저, 모르면 서버 형식(모양이 맞고 아무 형식이 아닐 때), 그것도 아니면 [kGenericFileType] +String fileOpenTypeOf(String fileName, String? serverType) { + final String? byExtension = fileTypeOfExtension(fileExtensionOf(fileName)); + if (byExtension != null) return byExtension; + final String? given = normalizedFileType(serverType); + return given ?? kGenericFileType; +} + +/// 앱에서 열지 않는 파일인가 — 확장자 · 확장자에서 나온 형식 · 서버 형식 가운데 하나라도 [kBlockedOpenExtensions] · [kBlockedOpenTypes] 에 걸리면 +bool isBlockedFileOpen(String fileName, String? serverType) { + final String extension = fileExtensionOf(fileName); + if (kBlockedOpenExtensions.contains(extension)) return true; + final String? byExtension = fileTypeOfExtension(extension); + if (byExtension != null && kBlockedOpenTypes.contains(byExtension)) { + return true; + } + final String? given = normalizedFileType(serverType); + return given != null && kBlockedOpenTypes.contains(given); +} diff --git a/flutter/lib/core/files/picked_files.dart b/flutter/lib/core/files/picked_files.dart new file mode 100644 index 0000000..498650c --- /dev/null +++ b/flutter/lib/core/files/picked_files.dart @@ -0,0 +1,330 @@ +/// 고른 파일 — 안드로이드 고르는 창(`ACTION_GET_CONTENT`)으로 고른 파일을 앱 임시 폴더에 복사해 두고, 거기서 읽어 올린다(모듈 공통: 메일 첨부 · 나중의 파일 모듈). +/// +/// 패키지를 더하지 않고 앱 안에 둔 까닭(받은 파일 열기와 같다 — `downloaded_files.dart`): 파일 고르는 플러그인은 저장소 · 사진 권한이나 +/// 기기 전체를 가리키는 경로를 더한다. 이 길은 권한 없이 고른 파일만 읽는다(고르는 창이 그 파일의 읽기 권한을 준다 — `content://` 만 읽는다). +/// 고르는 창에는 모든 파일을 보인다(`*/*` — hwp 는 기기마다 형식이 달라 창에서 거르면 숨는다). +/// 고른 뒤 기기가 넘겨받은 한도(남은 개수 · 허용 확장자 · 파일당 · 남은 합계)로 쓰는 쪽의 사전 검사와 **같은 차례**로 미리 걸러 넣을 파일만 복사한다 — +/// 받지 않을 형식 · 합계를 넘는 파일을 내려받거나 쌓아 두지 않게. 판정은 쓰는 쪽(Dart)이 다시 한다. +/// +/// 복사본을 두는 까닭: 고른 주소(content://)의 읽기 권한은 그 화면(Activity)이 있는 동안뿐이고, 클라우드 파일은 읽을 때마다 받는다. +/// 복사해 두면 올리기에 실패해도 [다시 시도] · 서버가 찾지 못한 첨부(`MAIL_004`)를 다시 고르지 않고 올린다. +/// +/// 정리하는 때: 첨부를 뺄 때 · 쓰기 화면이 끝날 때(그 화면의 복사본) · 앱을 띄울 때(main — 지난 실행이 남긴 것) · +/// 메일을 보는 직원이 바뀔 때(모두) · 기기가 저장 공간이 모자랄 때(안드로이드가 임시 폴더를 비운다 — 그래서 올리기 전에 복사본이 있는지 본다). +library; + +import 'dart:async'; +import 'dart:io'; + +import 'package:arasystem_app/core/files/downloaded_files.dart'; +import 'package:arasystem_app/core/files/file_names.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path_provider/path_provider.dart'; + +/// 고르기 한 번을 기다리는 가장 긴 시간 — 고르는 창을 연 때부터 고른 파일을 다 가져올 때까지(창에 머문 시간도 든다). +/// 파일당 20MB · 합계 25MB 를 느린 망에서 클라우드 파일로 받아도 넉넉하게 10분. 넘으면 그 고르기를 [PickOutcome.timedOut] 으로 끝내고 +/// 차례를 넘긴다 — 고른 앱(provider)이 읽기에서 멈춰 기기가 답하지 않을 때 [보내기] · [파일 첨부] · 다음 고르기 · 직원이 바뀔 때의 정리가 +/// 그 뒤에 묶이지 않게. 늦게 온 기기의 답은 버린다 — 그 복사본은 다음 정리(앱을 띄울 때 · 직원이 바뀔 때)에 지워진다 +const Duration kPickTimeout = Duration(minutes: 10); + +/// 고른 파일을 복사해 두는 폴더 이름 — 앱 임시 폴더(Android `cacheDir`) 아래. +/// **짝: `MainActivity.kt` 의 `UPLOADS_DIR`** — 다른 앱에 건네지 않으므로 FileProvider 경로(`downloaded_file_paths.xml`)에는 넣지 않는다 +const String kPickedFilesFolder = 'uploads'; + +/// 고른 파일 하나를 가져온 결과 (짝: `MainActivity.kt` 의 `STATUS_*` — 기기가 거른 차례: 개수 → 형식 → 파일당 → 합계) +enum PickedFileStatus { + /// 다 복사했다 — [PickedFile.path] 가 복사본 + ok, + + /// 넣을 파일이 `maxCount` 개가 되어 가져오지 않았다 + skipped, + + /// 허용 확장자(`extensions`)가 아니다 — 읽지 않았다 + notAllowed, + + /// 한도(`maxBytes`)보다 크다 — 복사하지 않았다(고른 앱이 알려 준 크기로, 또는 한도 + 1바이트를 읽고 멈췄다) + tooLarge, + + /// 넣으면 남은 합계(`maxTotalBytes`)를 넘는다 — 알려 준 크기로 읽지 않았거나, 복사한 크기로 보고 복사본을 지웠다 + overTotal, + + /// 읽지 못했다(클라우드 파일을 받지 못함 · 권한 · 저장 공간 · `content://` 가 아닌 주소) + failed, +} + +/// 고른 파일 하나 +@immutable +class PickedFile { + const PickedFile({ + required this.name, + required this.status, + this.size, + this.path, + }); + + /// 보이는 이름 — [displayFileName] 으로 정리했다(올릴 때 이 이름을 보낸다) + final String name; + final PickedFileStatus status; + + /// [PickedFileStatus.ok] 면 복사한 바이트, 그 밖은 고른 앱이 알려 준 크기(모르면 null) + final int? size; + + /// 복사본의 경로 — [PickedFileStatus.ok] 일 때만(로그 · 알림에 싣지 않는다) + final String? path; + + /// 채널이 돌려준 항목 하나 — 모양이 틀리면 읽지 못한 파일로 본다(알리기는 한다) + static PickedFile fromChannel(Object? raw) { + if (raw is! Map) { + return const PickedFile( + name: kFallbackFileName, + status: PickedFileStatus.failed, + ); + } + final Object? name = raw['name']; + final Object? size = raw['size']; + final Object? path = raw['path']; + final PickedFileStatus? status = switch (raw['status']) { + 'ok' => PickedFileStatus.ok, + 'skipped' => PickedFileStatus.skipped, + 'notAllowed' => PickedFileStatus.notAllowed, + 'tooLarge' => PickedFileStatus.tooLarge, + 'overTotal' => PickedFileStatus.overTotal, + 'failed' => PickedFileStatus.failed, + _ => null, + }; + final String shown = displayFileName(name is String ? name : null); + final int? bytes = size is int && size >= 0 ? size : null; + if (status == PickedFileStatus.ok) { + // 다 복사했다는데 경로 · 크기가 없으면 쓸 수 없다 + if (path is! String || path.isEmpty || bytes == null) { + return PickedFile(name: shown, status: PickedFileStatus.failed); + } + return PickedFile( + name: shown, + status: PickedFileStatus.ok, + size: bytes, + path: path, + ); + } + return PickedFile( + name: shown, + status: status ?? PickedFileStatus.failed, + size: bytes, + ); + } + + @override + bool operator ==(Object other) => + other is PickedFile && + other.name == name && + other.status == status && + other.size == size && + other.path == path; + + @override + int get hashCode => Object.hash(name, status, size, path); +} + +/// 고르기 한 번의 결과 (짝: `MainActivity.kt` 의 `OUTCOME_*`) +enum PickOutcome { + /// 골랐다 — [PickResult.files] + picked, + + /// 고르지 않고 닫았다 + canceled, + + /// 고르는 창이 이미 열려 있다 + busy, + + /// 고르는 창을 열 앱이 없다 + noApp, + + /// 그 밖의 실패(채널이 없음 — iOS 는 아직 없다 · 모르는 답) + failed, + + /// [kPickTimeout] 안에 기기가 답하지 않았다 — 고른 파일을 가져오지 못한 것으로 본다(앱에서만 — 기기는 보내지 않는다) + timedOut, +} + +/// 고르기 한 번의 결과 +@immutable +class PickResult { + const PickResult(this.outcome, [this.files = const []]); + + final PickOutcome outcome; + + /// 고른 차례대로 — [PickOutcome.picked] 일 때만 + final List files; + + /// 채널의 답 — 모양이 틀리면 실패 + static PickResult parse(Object? raw) { + if (raw is! Map) { + return const PickResult(PickOutcome.failed); + } + final PickOutcome? outcome = switch (raw['outcome']) { + 'picked' => PickOutcome.picked, + 'canceled' => PickOutcome.canceled, + 'busy' => PickOutcome.busy, + 'noApp' => PickOutcome.noApp, + 'failed' => PickOutcome.failed, + _ => null, + }; + if (outcome == null) return const PickResult(PickOutcome.failed); + if (outcome != PickOutcome.picked) return PickResult(outcome); + final Object? files = raw['files']; + if (files is! List) return const PickResult(PickOutcome.failed); + return PickResult( + PickOutcome.picked, + List.unmodifiable([ + for (final Object? item in files) PickedFile.fromChannel(item), + ]), + ); + } +} + +/// 고른 파일을 가져오고 복사본을 다루는 곳 — 시험은 덮어쓴다(시험 시계 안에서는 기기 파일 입출력이 끝나지 않는다) +abstract class PickedFileStore { + /// 고르는 창을 열고 고른 파일 가운데 넣을 파일만 복사해 둔다 — 고른 차례대로, 쓰는 쪽 사전 검사와 같은 차례로 거른다: + /// 넣을 파일이 [maxCount] 개가 되면 나머지는 가져오지 않고([PickedFileStatus.skipped]), 확장자가 [extensions] 에 없으면 읽지 않고 + /// ([PickedFileStatus.notAllowed]), [maxBytes] + 1바이트를 넘으면 복사를 멈추고([PickedFileStatus.tooLarge]), + /// 넣은 파일의 합이 [maxTotalBytes] 를 넘게 되면 넣지 않는다([PickedFileStatus.overTotal]). 거른 파일은 개수 · 합계에 들지 않는다 + Future pick({ + required int maxBytes, + required int maxCount, + required int maxTotalBytes, + required List extensions, + }); + + /// 복사본이 아직 있는가 — 복사본 폴더 밖의 경로는 거짓(기기가 임시 폴더를 비웠을 수 있다) + Future exists(String path); + + /// 복사본을 읽는다(올리기 — 부를 때마다 처음부터) + Stream> openRead(String path); + + /// 복사본 하나를 그 폴더째 지운다 — 복사본 폴더 밖은 지우지 않는다 + Future delete(String path); + + /// 모두 지운다 + Future purge(); +} + +/// 고른 파일을 두는 곳 — main 이 앱을 띄울 때 정리한 것으로 덮어쓴다 +final Provider pickedFileStoreProvider = + Provider((Ref ref) => PlatformPickedFileStore()); + +/// 안드로이드 고르는 창(`MainActivity.kt` 의 파일 채널 `pick`) + 앱 임시 폴더의 [kPickedFilesFolder]. +/// +/// 고르기와 모두 지우기는 부른 차례대로 하나씩 한다 — 고르는 동안 직원이 바뀌어 지우기가 들어오면 고른 복사본까지 지운다 +/// (앱을 띄울 때의 정리가 막 고른 복사본을 지우지 않게도). +class PlatformPickedFileStore implements PickedFileStore { + PlatformPickedFileStore({ + Future Function() baseDirectory = getTemporaryDirectory, + MethodChannel channel = PlatformFileOpener.channel, + Duration pickTimeout = kPickTimeout, + }) : _baseDirectory = baseDirectory, + _channel = channel, + _pickTimeout = pickTimeout; + + /// 짝: `MainActivity.kt` 의 `PICK_METHOD` (채널은 받은 파일 열기와 같다 — `PlatformFileOpener.channel`) + static const String pickMethod = 'pick'; + + final Future Function() _baseDirectory; + final MethodChannel _channel; + + /// 고르기 한 번의 시한 — [kPickTimeout](시험은 짧게) + final Duration _pickTimeout; + Future _queue = Future.value(); + + Future _serial(Future Function() task) { + final Future next = _queue.then((_) => task()); + _queue = next.then((_) {}, onError: (Object _) {}); + return next; + } + + /// 복사본 폴더 — 임시 폴더를 알 수 없으면(플러그인 · 기기 사정) null + Future _root() async { + try { + final Directory base = await _baseDirectory(); + return Directory('${base.path}/$kPickedFilesFolder'); + } on Exception { + return null; + } + } + + @override + Future pick({ + required int maxBytes, + required int maxCount, + required int maxTotalBytes, + required List extensions, + }) => _serial(() async { + try { + // 인자 이름 — 짝: `MainActivity.kt` 의 `ARG_*`. 시한은 차례 안에서 잰다(넘으면 다음 차례로) + final Object? raw = await _channel + .invokeMethod(pickMethod, { + 'maxBytes': maxBytes, + 'maxCount': maxCount, + 'maxTotalBytes': maxTotalBytes, + 'extensions': extensions, + }) + .timeout(_pickTimeout); + return PickResult.parse(raw); + } on TimeoutException { + // 기기가 답하지 않는다(고른 앱이 읽기에서 멈춤 등) — 이 고르기를 끝내고 차례를 넘긴다. + // 늦게 온 답은 버린다 — 그 복사본은 다음 정리(앱을 띄울 때 · 직원이 바뀔 때)에 지워진다 + return const PickResult(PickOutcome.timedOut); + } on PlatformException { + return const PickResult(PickOutcome.failed); + } on MissingPluginException { + return const PickResult(PickOutcome.failed); + } + }); + + /// 복사본의 폴더 — 복사본 폴더 바로 아래 한 칸(무작위 폴더)일 때만. 기호 링크를 풀어 견준다(Kotlin 이 canonical 로 만든 자리). + /// 없거나 밖이면 null + Future _copyFolderOf(String path) async { + final Directory? root = await _root(); + if (root == null) return null; + try { + final String rootPath = await root.resolveSymbolicLinks(); + final File file = File(await File(path).resolveSymbolicLinks()); + final Directory folder = file.parent; + return folder.parent.path == rootPath ? folder : null; + } on FileSystemException { + return null; + } + } + + @override + Future exists(String path) async { + if (await _copyFolderOf(path) == null) return false; + return File(path).exists(); + } + + @override + Stream> openRead(String path) => File(path).openRead(); + + @override + Future delete(String path) async { + final Directory? folder = await _copyFolderOf(path); + if (folder == null) return; + try { + await folder.delete(recursive: true); + } on FileSystemException { + // 이미 없다 — 다음 정리 · 기기가 지운다 + } + } + + @override + Future purge() => _serial(() async { + final Directory? root = await _root(); + if (root == null) return; + try { + if (await root.exists()) await root.delete(recursive: true); + } on FileSystemException { + // 다음 정리 · 기기가 지운다 + } + }); +} diff --git a/flutter/lib/core/people/address_input.dart b/flutter/lib/core/people/address_input.dart new file mode 100644 index 0000000..054bd34 --- /dev/null +++ b/flutter/lib/core/people/address_input.dart @@ -0,0 +1,193 @@ +/// 받는 사람 칸의 주소 — 나누기 · 읽기 · 검사 · 겹침 (순수 함수). +/// +/// **짝: 웹 `frontend/src/shared/utils/mail-address.ts`** — 같은 정규식 · 같은 차례로 옮겼다(한쪽을 고치면 둘 다). +/// 옮긴 것: `isValidEmail` · `normalizeEmail` · `sameAddress` · `uniqueAddresses` · `formatAddress` · `formatAddressList` · +/// `parseRecipient` · `tokenFromAddress` · `hasOpenRecipientGroup` · `splitRecipientInput` · `recipientTokenKey` · `mergeRecipientTokens`. +/// 저장용 엄격 검사(`isStrictEmail` — 주소록 · 필터)는 쓰는 화면이 없어 옮기지 않았다. +/// 앱에만 있는 것: [splitPendingRecipients] — 웹은 키(쉼표 · Enter)를 받아 그 자리에서 나누지만, 휴대폰 자판은 키를 알려 주지 않아 +/// 칸의 글이 바뀔 때마다 구분자까지를 떼어 낸다. +library; + +import 'package:arasystem_app/core/people/person_address.dart'; +import 'package:flutter/foundation.dart'; + +/// 형식 검사 — 화면은 이 기준으로 바로 보이고 마지막 판단은 서버가 한다(`MAIL_002`) +final RegExp _emailPattern = RegExp(r'^[^@\s<>]+@[^@\s<>]+\.[^@\s<>]+$'); + +/// "이름 <주소>" 꼴 +final RegExp _namedAddressPattern = RegExp(r'^(.*?)\s*<([^<>]*)>$'); + +/// 여러 주소를 한 번에 넣을 때의 구분자 +const Set _separators = {0x2C, 0x3B, 0x0A, 0x0D}; // , ; \n \r + +const int _quote = 0x22; // " +const int _angleOpen = 0x3C; // < +const int _angleClose = 0x3E; // > + +bool isValidEmail(String email) => _emailPattern.hasMatch(email.trim()); + +String normalizeEmail(String email) => email.trim().toLowerCase(); + +bool sameAddress(PersonAddress a, PersonAddress b) => + normalizeEmail(a.email) == normalizeEmail(b.email); + +/// 같은 주소(대소문자 무시)는 처음 것만 남긴다 +List uniqueAddresses(Iterable list) { + final Set seen = {}; + return [ + for (final PersonAddress address in list) + if (seen.add(normalizeEmail(address.email))) address, + ]; +} + +/// "이름 <주소>" — 이름이 없으면 주소만 +String formatAddress(PersonAddress address) { + final String? name = address.name?.trim(); + return name != null && name.isNotEmpty + ? '$name <${address.email}>' + : address.email; +} + +String formatAddressList(Iterable list) => + list.map(formatAddress).join(', '); + +/// 받는 사람 칸의 칩 하나 +@immutable +class RecipientToken { + const RecipientToken({ + required this.label, + required this.address, + required this.valid, + }); + + /// 칩에 보이는 글 — 넣은 그대로 + final String label; + final PersonAddress address; + + /// 형식이 맞는가 — 틀리면 칩을 빨갛게 보인다 + final bool valid; + + @override + bool operator ==(Object other) => + other is RecipientToken && + other.label == label && + other.address == address && + other.valid == valid; + + @override + int get hashCode => Object.hash(label, address, valid); +} + +String _unquote(String value) { + final String trimmed = value.trim(); + final bool quoted = + trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"'); + return quoted ? trimmed.substring(1, trimmed.length - 1).trim() : trimmed; +} + +/// 넣은 글 한 덩어리 → 칩. 빈 글이면 null — "이름 <주소>" · "\"이름\" <주소>" · "<주소>" · "주소" 를 받는다 +RecipientToken? parseRecipient(String raw) { + final String label = raw.trim(); + if (label.isEmpty) return null; + final RegExpMatch? named = _namedAddressPattern.firstMatch(label); + final String email = (named?.group(2) ?? label).trim(); + final String name = named == null ? '' : _unquote(named.group(1) ?? ''); + return RecipientToken( + label: label, + address: PersonAddress(email: email, name: name.isEmpty ? null : name), + valid: isValidEmail(email), + ); +} + +/// 미리 채울 때(답장 · 저장본 · 추천) — 서버가 준 주소를 칩으로 +RecipientToken tokenFromAddress(PersonAddress address) => RecipientToken( + label: formatAddress(address), + address: address, + valid: isValidEmail(address.email), +); + +/// 따옴표나 꺾쇠가 열린 채인가 — 그 안의 쉼표는 구분자가 아니다(`"Kim, Ara" `) +bool hasOpenRecipientGroup(String text) { + bool inQuotes = false; + bool inAngle = false; + for (final int rune in text.runes) { + if (rune == _quote && !inAngle) { + inQuotes = !inQuotes; + } else if (rune == _angleOpen && !inQuotes) { + inAngle = true; + } else if (rune == _angleClose && !inQuotes) { + inAngle = false; + } + } + return inQuotes || inAngle; +} + +/// 붙여 넣은 글을 주소 단위로 나눈다(따옴표 · 꺾쇠 안의 구분자는 무시) — 앞뒤 공백을 빼고 빈 조각은 버린다 +List splitRecipientInput(String text) { + final List parts = []; + final StringBuffer current = StringBuffer(); + bool inQuotes = false; + bool inAngle = false; + for (final int rune in text.runes) { + if (rune == _quote && !inAngle) { + inQuotes = !inQuotes; + } else if (rune == _angleOpen && !inQuotes) { + inAngle = true; + } else if (rune == _angleClose && !inQuotes) { + inAngle = false; + } + if (_separators.contains(rune) && !inQuotes && !inAngle) { + parts.add(current.toString()); + current.clear(); + continue; + } + current.writeCharCode(rune); + } + parts.add(current.toString()); + return [ + for (final String part in parts) + if (part.trim().isNotEmpty) part.trim(), + ]; +} + +/// 칸의 글에서 구분자(따옴표 · 꺾쇠 밖의 `,` `;` 줄바꿈)까지를 떼어 낸다 — 다 쓴 주소들과 이어 쓰는 글. +/// 구분자가 없으면 다 쓴 주소가 없다(글 그대로 이어 쓴다). 이어 쓰는 글의 앞 공백은 지운다("a@b.kr, " → 다 쓴 주소 하나 · 빈 글) +({List complete, String rest}) splitPendingRecipients(String text) { + bool inQuotes = false; + bool inAngle = false; + int last = -1; + for (int index = 0; index < text.length; index++) { + final int unit = text.codeUnitAt(index); + if (unit == _quote && !inAngle) { + inQuotes = !inQuotes; + } else if (unit == _angleOpen && !inQuotes) { + inAngle = true; + } else if (unit == _angleClose && !inQuotes) { + inAngle = false; + } else if (_separators.contains(unit) && !inQuotes && !inAngle) { + last = index; + } + } + if (last < 0) return (complete: const [], rest: text); + return ( + complete: splitRecipientInput(text.substring(0, last)), + rest: text.substring(last + 1).trimLeft(), + ); +} + +/// 겹침을 가르는 키 — 형식이 맞으면 주소(대소문자 무시), 틀리면 넣은 글 +String recipientTokenKey(RecipientToken token) => + token.valid ? normalizeEmail(token.address.email) : token.label; + +/// 이미 있는 칩과 겹치는 것은 빼고 뒤에 붙인다 +List mergeRecipientTokens( + List existing, + Iterable incoming, +) { + final Set seen = existing.map(recipientTokenKey).toSet(); + final List merged = [...existing]; + for (final RecipientToken token in incoming) { + if (seen.add(recipientTokenKey(token))) merged.add(token); + } + return merged; +} diff --git a/flutter/lib/core/people/people_texts.dart b/flutter/lib/core/people/people_texts.dart new file mode 100644 index 0000000..0c65c1e --- /dev/null +++ b/flutter/lib/core/people/people_texts.dart @@ -0,0 +1,66 @@ +/// 사람 고르기(받는 사람 추천)의 화면 글 — 웹 상수(`frontend/src/shared/constants/contacts.ts` 의 `RECIPIENT_CONTACT_TEXT` · +/// `CONTACT_EMAIL_LABEL_TEXT`, `RecipientSuggestionList.vue` 의 묶음 이름)를 그대로 옮겼다 — 웹과 앱을 함께 쓰는 직원에게 같은 말. +/// 말투는 디자인 README 「화면 문구 — 사용자에게 말하듯」. +library; + +import 'package:arasystem_app/core/people/recipient_match.dart'; +import 'package:arasystem_app/core/people/recipient_suggestion.dart'; +import 'package:arasystem_app/core/utils/korean_format.dart'; + +abstract final class PeopleTexts { + /// 추천 목록의 묶음 이름 — 사내와 최근 주고받은 주소를 나눠 보인다(계약 2절 — 바깥 사람이 동료 이름을 쓰는 경우) + static String groupLabel(RecipientOptionGroup group) => switch (group) { + RecipientOptionGroup.contact => '주소록', + RecipientOptionGroup.group => '그룹', + RecipientOptionGroup.directory => '사내', + RecipientOptionGroup.recent => '최근 주고받은 주소', + }; + + /// 주소록 줄의 주소 종류 + static String emailLabel(ContactEmailLabel label) => switch (label) { + ContactEmailLabel.work => '회사', + ContactEmailLabel.personal => '개인', + ContactEmailLabel.other => '기타', + }; + + /// 그룹 줄의 설명 — "고르면 4명을 한꺼번에 넣습니다" + static String groupDetail(int count) => + '고르면 ${formatCount(count)}명을 한꺼번에 넣습니다'; + + /// 구성원을 받는 동안 칸 아래 글 + static String groupLoading(String group) => '‘$group’ 그룹의 구성원을 불러오는 중…'; + + /// 그룹을 넣은 뒤 칸 아래 글 — "‘거래처’ 그룹의 4명을 받는 사람에 넣었습니다. 주소가 없는 1명은 뺐습니다." + static String groupInserted( + String group, + String field, { + required int added, + required int withoutEmail, + required int already, + }) { + final String head = added > 0 + ? '‘$group’ 그룹의 ${formatCount(added)}명을 $field에 넣었습니다.' + : '‘$group’ 그룹에서 $field에 더 넣을 주소가 없습니다.'; + final List skipped = [ + if (withoutEmail > 0) '주소가 없는 ${formatCount(withoutEmail)}명', + if (already > 0) '이미 넣은 ${formatCount(already)}명', + ]; + return skipped.isEmpty ? head : '$head ${skipped.join(' · ')}은 뺐습니다.'; + } +} + +/// 주소록 줄의 주소 뒤 설명 — 회사 주소는 회사 이름, 그 밖은 종류("개인"). 주소록 줄이 아니면 null +/// (짝: 웹 `shared/utils/contacts.ts` 의 `contactSuggestionDetail`) +String? contactSuggestionDetail(RecipientSuggestion suggestion) { + if (suggestion.source != RecipientSource.contact) return null; + final String? company = suggestion.company?.trim(); + final String? shownCompany = company == null || company.isEmpty + ? null + : company; + final ContactEmailLabel? label = suggestion.emailLabel; + if (label == null) return shownCompany; + if (label == ContactEmailLabel.work) { + return shownCompany ?? PeopleTexts.emailLabel(label); + } + return PeopleTexts.emailLabel(label); +} diff --git a/flutter/lib/core/people/person_address.dart b/flutter/lib/core/people/person_address.dart new file mode 100644 index 0000000..194997e --- /dev/null +++ b/flutter/lib/core/people/person_address.dart @@ -0,0 +1,27 @@ +import 'package:flutter/foundation.dart'; + +/// 사람 한 명의 이름 · 메일 주소 — 받는 사람 칸 · 받는 사람 추천 · 그룹 구성원이 함께 쓴다(모듈 공통 — 메일 · 나중의 주소록). +/// +/// 메일 모듈의 `MailAddress`(계약 2절 `Address`)와 같은 모양이다. 모듈끼리 import 하지 않으려고 core 에 따로 둔다 +/// (flutter 스킬 "모듈끼리 서로 import 하지 않는다" — 사람 고르기는 core). +@immutable +class PersonAddress { + const PersonAddress({required this.email, this.name}); + + /// 보이는 이름 — 없으면 null + final String? name; + final String email; + + /// 계약의 `Address` 꼴 `{ name, email }` + Map toJson() => { + 'name': name, + 'email': email, + }; + + @override + bool operator ==(Object other) => + other is PersonAddress && other.email == email && other.name == name; + + @override + int get hashCode => Object.hash(email, name); +} diff --git a/flutter/lib/core/people/recipient_match.dart b/flutter/lib/core/people/recipient_match.dart new file mode 100644 index 0000000..d2c8e78 --- /dev/null +++ b/flutter/lib/core/people/recipient_match.dart @@ -0,0 +1,339 @@ +/// 받는 사람 추천의 거르기 — 순수 함수. +/// +/// **짝 셋: 웹 `frontend/src/shared/utils/recipient-match.ts` · 백엔드 `backend/src/modules/mail/recipient-match.ts`(서버의 `q` 맞추기).** +/// 같은 규칙을 그대로 옮겼다 — 표본 표도 같다(웹 `frontend/src/test-utils/recipient-match-samples.ts` · 백엔드 spec 의 표 · +/// 앱 `test/core/people/recipient_match_samples.dart` — 행과 차례까지 같다. 한쪽을 고치면 셋 다). +/// +/// - 이름 · 주소에 친 글자가 들어 있으면 맞는다(대소문자 무시). 맞은 부분은 굵게 보이도록 범위를 준다. +/// - 이름은 초성으로도 찾는다: ㄷㅇ → 이"도윤". 초성과 글자를 섞어도 된다: 도ㅇ → 이"도윤". +/// - 마지막 글자는 아직 조합 중일 수 있다 — "이도유"(윤을 치는 중) · "이동"(윤의 ㅇ 이 받침으로 붙은 순간)도 "이도윤"에 맞는다. +/// Flutter 는 칸의 글(`TextEditingController.text`)에 조합 중인 글자가 들어 있어 그대로 맞춘다. +/// - 친 글자가 완전한 주소면 같은 아이디(@ 앞)의 주소도 보인다 — 쓰기 화면은 그때 아무것도 미리 고르지 않는다. +/// - 이미 넣은 주소는 뺀다. 묶음은 주소록 → 그룹 → 사내 → 최근 주고받은 주소, 묶음 안은 받은 차례 그대로, 모두 합쳐 최대 8개. +/// - 대소문자는 JS 의 `toLowerCase` 에 가깝게 낮춘다([jsLowerCase] — Dart 는 'İ' 를 한 글자로 낮춘다). 그리스 문자의 낱말 끝 시그마(ς)는 가르지 않고, +/// 유니코드 자료의 판이 달라 JS 와 다르게 낮추는 글자(437자 — 대부분 Unicode 16 의 새 글자)가 있다(알려진 한계). +library; + +import 'package:arasystem_app/core/people/address_input.dart'; +import 'package:arasystem_app/core/people/recipient_suggestion.dart'; +import 'package:arasystem_app/core/utils/js_case.dart'; +import 'package:flutter/foundation.dart'; + +/// 추천 목록에 보이는 최대 개수 +const int kRecipientSuggestionLimit = 8; + +/// 글자 범위 — UTF-16 위치, [end] 는 들지 않는다 +@immutable +class MatchRange { + const MatchRange(this.start, this.end); + + final int start; + final int end; + + @override + bool operator ==(Object other) => + other is MatchRange && other.start == start && other.end == end; + + @override + int get hashCode => Object.hash(start, end); + + @override + String toString() => 'MatchRange($start, $end)'; +} + +/// 친 글자에 맞은 추천 하나 +@immutable +class RecipientMatch { + const RecipientMatch({required this.suggestion, this.name, this.email}); + + final RecipientSuggestion suggestion; + + /// 이름에서 맞은 범위 — 없으면 null + final MatchRange? name; + + /// 주소에서 맞은 범위 — 없으면 null + final MatchRange? email; +} + +/// 그룹 이름에 맞은 그룹 +@immutable +class RecipientGroupMatch { + const RecipientGroupMatch({required this.group, required this.name}); + + final ContactGroupSummary group; + final MatchRange name; +} + +/// 추천 목록의 한 줄 — 주소 하나 또는 그룹 하나 +sealed class RecipientOption { + const RecipientOption(); +} + +final class AddressOption extends RecipientOption { + const AddressOption(this.match); + + final RecipientMatch match; +} + +final class GroupOption extends RecipientOption { + const GroupOption(this.match); + + final RecipientGroupMatch match; +} + +/// 목록의 묶음 — 주소록 → 그룹 → 사내 → 최근 주고받은 주소 +enum RecipientOptionGroup { contact, group, directory, recent } + +RecipientOptionGroup recipientOptionGroupOf(RecipientOption option) => + switch (option) { + GroupOption() => RecipientOptionGroup.group, + AddressOption(:final RecipientMatch match) => + switch (match.suggestion.source) { + RecipientSource.contact => RecipientOptionGroup.contact, + RecipientSource.directory => RecipientOptionGroup.directory, + RecipientSource.recent => RecipientOptionGroup.recent, + }, + }; + +/// 굵게 보일 조각 — 맞은 부분만 [strong] +typedef HighlightPart = ({String text, bool strong}); + +// ── 한글 ───────────────────────────────────────────────── + +const int _syllableStart = 0xAC00; +const int _syllableEnd = 0xD7A3; +const int _medialCount = 21; +const int _finalCount = 28; + +/// 초성 — 호환 자모 +const List _initials = [ + 'ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅃ', 'ㅅ', // + 'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ', +]; + +/// 받침 — 0 은 받침 없음 +const List _finals = [ + '', 'ㄱ', 'ㄲ', 'ㄳ', 'ㄴ', 'ㄵ', 'ㄶ', 'ㄷ', 'ㄹ', 'ㄺ', // + 'ㄻ', 'ㄼ', 'ㄽ', 'ㄾ', 'ㄿ', 'ㅀ', 'ㅁ', 'ㅂ', 'ㅄ', 'ㅅ', // + 'ㅆ', 'ㅇ', 'ㅈ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ', +]; + +/// 겹받침을 나눈다 — 뒤 자음이 다음 글자의 초성으로 넘어가는 순간이 있다(닭 → 달 + ㄱ) +const Map _splitFinals = { + 'ㄳ': ('ㄱ', 'ㅅ'), + 'ㄵ': ('ㄴ', 'ㅈ'), + 'ㄶ': ('ㄴ', 'ㅎ'), + 'ㄺ': ('ㄹ', 'ㄱ'), + 'ㄻ': ('ㄹ', 'ㅁ'), + 'ㄼ': ('ㄹ', 'ㅂ'), + 'ㄽ': ('ㄹ', 'ㅅ'), + 'ㄾ': ('ㄹ', 'ㅌ'), + 'ㄿ': ('ㄹ', 'ㅍ'), + 'ㅀ': ('ㄹ', 'ㅎ'), + 'ㅄ': ('ㅂ', 'ㅅ'), +}; + +typedef _Syllable = ({int initial, int medial, int finalIndex}); + +_Syllable? _syllableOf(String char) { + if (char.length != 1) return null; + final int code = char.codeUnitAt(0); + if (code < _syllableStart || code > _syllableEnd) return null; + final int offset = code - _syllableStart; + return ( + initial: offset ~/ (_medialCount * _finalCount), + medial: (offset % (_medialCount * _finalCount)) ~/ _finalCount, + finalIndex: offset % _finalCount, + ); +} + +String _composeSyllable(int initial, int medial, int finalIndex) => + String.fromCharCode( + _syllableStart + + (initial * _medialCount + medial) * _finalCount + + finalIndex, + ); + +bool _isInitialJamo(String char) => _initials.contains(char); + +String? _initialOf(String char) { + final _Syllable? syllable = _syllableOf(char); + return syllable == null ? null : _initials[syllable.initial]; +} + +/// 글자(코드 포인트) 단위로 나눈다 — 이모지 같은 짝 글자를 가르지 않는다(JS `Array.from`) +List _codePoints(String value) => [ + for (final int rune in value.runes) String.fromCharCode(rune), +]; + +// ── 맞추기 ─────────────────────────────────────────────── + +/// 친 글자 하나가 이름 글자 하나에 맞는가 — 같은 글자(대소문자 무시) 또는 초성 +bool _matchesChar(String typed, String char) { + if (jsLowerCase(typed) == jsLowerCase(char)) return true; + return _isInitialJamo(typed) && _initialOf(char) == typed; +} + +/// 마지막으로 친 글자 — 조합 중일 수 있다. 맞으면 이름에서 쓴 글자 수(1 · 2), 아니면 0 +int _matchesLastChar(String typed, String char, String? next) { + if (_matchesChar(typed, char)) return 1; + final _Syllable? typedSyllable = _syllableOf(typed); + final _Syllable? syllable = _syllableOf(char); + if (typedSyllable == null || syllable == null) return 0; + if (typedSyllable.initial != syllable.initial || + typedSyllable.medial != syllable.medial) { + return 0; + } + if (typedSyllable.finalIndex == 0) return 1; + if (next == null) return 0; + final String finalJamo = _finals[typedSyllable.finalIndex]; + final (String rest, String moved) = + _splitFinals[finalJamo] ?? ('', finalJamo); + final String restSyllable = _composeSyllable( + typedSyllable.initial, + typedSyllable.medial, + _finals.indexOf(rest), + ); + return restSyllable == char && _initialOf(next) == moved ? 2 : 0; +} + +/// chars[start] 부터 친 글자가 맞으면 맞은 끝(들지 않는 위치), 아니면 null +int? _matchFrom(List typed, List chars, int start) { + int index = start; + for (int at = 0; at < typed.length; at++) { + if (index >= chars.length) return null; + final String char = chars[index]; + final String typedChar = typed[at]; + if (at == typed.length - 1) { + final String? next = index + 1 < chars.length ? chars[index + 1] : null; + final int used = _matchesLastChar(typedChar, char, next); + return used == 0 ? null : index + used; + } + if (!_matchesChar(typedChar, char)) return null; + index++; + } + return index; +} + +/// 글자 단위 위치 → UTF-16 위치 +int _utf16Offset(List chars, int index) { + int offset = 0; + for (int at = 0; at < index; at++) { + offset += chars[at].length; + } + return offset; +} + +/// 이름에서 친 글자(초성 · 조합 중 포함)가 처음 맞는 범위 +MatchRange? matchName(String query, String name) { + final List typed = _codePoints(query.trim()); + final List chars = _codePoints(name); + if (typed.isEmpty) return null; + for (int start = 0; start < chars.length; start++) { + final int? end = _matchFrom(typed, chars, start); + if (end != null) { + return MatchRange(_utf16Offset(chars, start), _utf16Offset(chars, end)); + } + } + return null; +} + +/// 주소에서 친 글자가 처음 들어 있는 범위(대소문자 무시). 완전한 주소를 쳤으면 아이디(@ 앞, @ 포함)로도 찾는다 +MatchRange? matchEmail(String query, String email) { + final String typed = jsLowerCase(query.trim()); + final String target = jsLowerCase(email); + // 대소문자를 바꾸며 길이가 달라지는 글자(드물다)면 범위를 믿을 수 없다 — 맞추지 않는다 + if (typed.isEmpty || target.length != email.length) return null; + final int at = target.indexOf(typed); + if (at >= 0) return MatchRange(at, at + typed.length); + final int localEnd = typed.indexOf('@'); + if (localEnd <= 0) return null; + final String local = typed.substring(0, localEnd + 1); + final int localAt = target.indexOf(local); + return localAt >= 0 ? MatchRange(localAt, localAt + local.length) : null; +} + +/// 추천 하나가 친 글자에 맞는가 — 이름 · 주소 가운데 하나라도 맞으면 +RecipientMatch? matchRecipient(String query, RecipientSuggestion suggestion) { + final String? suggestionName = suggestion.name; + final MatchRange? name = suggestionName == null || suggestionName.isEmpty + ? null + : matchName(query, suggestionName); + final MatchRange? email = matchEmail(query, suggestion.email); + if (name == null && email == null) return null; + return RecipientMatch(suggestion: suggestion, name: name, email: email); +} + +/// 친 글자에 맞는 추천 — 이미 넣은 주소([excluded] — 정규화한 주소)는 빼고, 같은 주소는 처음 것만, +/// 주소록 → 사내 → 최근 주고받은 주소 차례로(묶음 안은 받은 차례 그대로), 최대 [limit] 개. 빈 글자면 아무것도 보이지 않는다 +List filterRecipientSuggestions( + String query, + List suggestions, + Set excluded, { + int limit = kRecipientSuggestionLimit, +}) { + if (query.trim().isEmpty) return const []; + final Set seen = {...excluded}; + final List matches = []; + for (final RecipientSuggestion suggestion in suggestions) { + final String key = normalizeEmail(suggestion.email); + if (seen.contains(key)) continue; + final RecipientMatch? match = matchRecipient(query, suggestion); + if (match == null) continue; + seen.add(key); + matches.add(match); + } + // 묶음 차례로 — 같은 묶음 안은 받은 차례를 지킨다(Dart 의 sort 는 안정 정렬이 아니라 묶음마다 모은다) + return [ + for (final RecipientSource source in RecipientSource.values) + ...matches.where( + (RecipientMatch match) => match.suggestion.source == source, + ), + ].take(limit).toList(); +} + +/// 친 글자가 이름에 맞는 그룹 — 구성원이 없는 그룹은 뺀다(넣을 주소가 없다). 받은 차례(이름 차례) 그대로 +List matchContactGroups( + String query, + List groups, +) { + if (query.trim().isEmpty) return const []; + return [ + for (final ContactGroupSummary group in groups) + if (group.memberCount > 0) + if (matchName(query, group.name) case final MatchRange name) + RecipientGroupMatch(group: group, name: name), + ]; +} + +/// 추천 목록의 줄 — 주소록 → 그룹 → 사내 → 최근 주고받은 주소, 모두 합쳐 최대 [limit] 개 +List arrangeRecipientOptions( + List addresses, + List groups, { + int limit = kRecipientSuggestionLimit, +}) { + return [ + for (final RecipientMatch match in addresses) + if (match.suggestion.source == RecipientSource.contact) + AddressOption(match), + for (final RecipientGroupMatch match in groups) GroupOption(match), + for (final RecipientMatch match in addresses) + if (match.suggestion.source != RecipientSource.contact) + AddressOption(match), + ].take(limit).toList(); +} + +/// 굵게 보일 조각으로 나눈다 +List highlightParts(String text, MatchRange? range) { + if (range == null || + range.start >= range.end || + range.start < 0 || + range.end > text.length) { + return [(text: text, strong: false)]; + } + return [ + (text: text.substring(0, range.start), strong: false), + (text: text.substring(range.start, range.end), strong: true), + (text: text.substring(range.end), strong: false), + ].where((HighlightPart part) => part.text.isNotEmpty).toList(); +} diff --git a/flutter/lib/core/people/recipient_suggestion.dart b/flutter/lib/core/people/recipient_suggestion.dart new file mode 100644 index 0000000..0a12cd0 --- /dev/null +++ b/flutter/lib/core/people/recipient_suggestion.dart @@ -0,0 +1,181 @@ +/// 받는 사람 추천 · 주소록 그룹의 모양 — 계약 2절 `RecipientSuggestion` · 3절 `ContactGroup` · `Contact`(구성원의 대표 주소). +/// +/// 모델은 손으로 쓴다(flutter 스킬 "모델 생성기"). 모양이 틀린 항목은 [tryParse] 가 null — 부르는 쪽이 그 항목만 버린다 +/// (추천은 보조 정보라 한 줄이 틀려도 나머지는 보인다). +library; + +import 'package:arasystem_app/core/people/address_input.dart'; +import 'package:arasystem_app/core/people/person_address.dart'; +import 'package:flutter/foundation.dart'; + +/// 추천의 묶음 — 주소록 · 사내 · 최근 주고받은 주소 (계약 2절) +enum RecipientSource { + contact, + directory, + recent; + + static RecipientSource? parse(Object? value) { + for (final RecipientSource source in RecipientSource.values) { + if (source.name == value) return source; + } + return null; + } +} + +/// 주소록 연락처의 주소 종류 — 회사 · 개인 · 기타 (계약 3절 `ContactEmailLabel`) +enum ContactEmailLabel { + work, + personal, + other; + + static ContactEmailLabel? parse(Object? value) { + for (final ContactEmailLabel label in ContactEmailLabel.values) { + if (label.name == value) return label; + } + return null; + } +} + +/// 받는 사람 추천 한 줄 — `GET /api/recipient-suggestions` +@immutable +class RecipientSuggestion { + const RecipientSuggestion({ + required this.email, + required this.source, + this.name, + this.company, + this.emailLabel, + }); + + final String? name; + final String email; + final RecipientSource source; + + /// 주소록 줄만 — 그 연락처의 회사 + final String? company; + + /// 주소록 줄만 — 그 주소의 종류 + final ContactEmailLabel? emailLabel; + + PersonAddress get address => PersonAddress(email: email, name: name); + + /// 모양이 틀리면 null — 모르는 묶음은 최근 주고받은 주소로 본다(웹과 같다). 8단계 앞 서버의 빠진 칸은 null + static RecipientSuggestion? tryParse(Object? json) { + if (json is! Map) return null; + final Object? email = json['email']; + final Object? name = json['name']; + final Object? company = json['company']; + if (email is! String || email.isEmpty) return null; + if (name != null && name is! String) return null; + return RecipientSuggestion( + email: email, + name: name as String?, + source: RecipientSource.parse(json['source']) ?? RecipientSource.recent, + company: company is String ? company : null, + emailLabel: ContactEmailLabel.parse(json['emailLabel']), + ); + } + + @override + bool operator ==(Object other) => + other is RecipientSuggestion && + other.name == name && + other.email == email && + other.source == source && + other.company == company && + other.emailLabel == emailLabel; + + @override + int get hashCode => Object.hash(name, email, source, company, emailLabel); +} + +/// 주소록 그룹 — `GET /api/contact-groups` (계약 3절 `ContactGroup`) +@immutable +class ContactGroupSummary { + const ContactGroupSummary({ + required this.id, + required this.name, + required this.memberCount, + }); + + final String id; + final String name; + + /// 이 주소록에 있는 구성원 수 + final int memberCount; + + static ContactGroupSummary? tryParse(Object? json) { + if (json is! Map) return null; + final Object? id = json['id']; + final Object? name = json['name']; + final Object? count = json['memberCount']; + if (id is! String || id.isEmpty || name is! String || count is! num) { + return null; + } + return ContactGroupSummary(id: id, name: name, memberCount: count.toInt()); + } + + @override + bool operator ==(Object other) => + other is ContactGroupSummary && + other.id == id && + other.name == name && + other.memberCount == memberCount; + + @override + int get hashCode => Object.hash(id, name, memberCount); +} + +/// 그룹 구성원 한 사람 — 이름과 대표 주소(`Contact.emails` 의 첫 주소 — 없으면 null) +typedef GroupMember = ({String? name, String? email}); + +/// 구성원(`Contact`) 한 사람을 읽는다 — 모양이 틀리면 null +GroupMember? groupMemberOf(Object? json) { + if (json is! Map) return null; + final Object? name = json['name']; + final Object? emails = json['emails']; + if ((name != null && name is! String) || emails is! List) { + return null; + } + String? email; + if (emails.isNotEmpty) { + final Object? first = emails.first; + final Object? address = first is Map + ? first['address'] + : null; + if (address is String && address.trim().isNotEmpty) email = address.trim(); + } + return (name: name as String?, email: email); +} + +/// 그룹 → 넣을 대표 주소(겹치면 한 번)와 이메일이 없는 구성원 수 (짝: 웹 `shared/utils/contacts.ts` 의 `groupRecipientsOf`) +@immutable +class GroupRecipients { + const GroupRecipients({required this.addresses, required this.withoutEmail}); + + final List addresses; + final int withoutEmail; +} + +GroupRecipients groupRecipientsOf(Iterable members) { + final List addresses = []; + int withoutEmail = 0; + for (final GroupMember member in members) { + final String? email = member.email; + if (email == null) { + withoutEmail++; + continue; + } + final String? name = member.name?.trim(); + addresses.add( + PersonAddress( + email: email, + name: name == null || name.isEmpty ? null : name, + ), + ); + } + return GroupRecipients( + addresses: uniqueAddresses(addresses), + withoutEmail: withoutEmail, + ); +} diff --git a/flutter/lib/core/push/firebase_push_messaging.dart b/flutter/lib/core/push/firebase_push_messaging.dart new file mode 100644 index 0000000..2a6e685 --- /dev/null +++ b/flutter/lib/core/push/firebase_push_messaging.dart @@ -0,0 +1,123 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/push/push_messaging.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; + +/// Firebase 를 띄우는 데 기다리는 가장 긴 시간 — 넘으면 알림 없이 앱을 띄운다 +const Duration kFirebaseStartTimeout = Duration(seconds: 5); + +/// 알림 권한을 묻기 시작한 안드로이드 판 — 13(API 33). 그 아래는 묻는 창이 없고 휴대폰 설정에서만 켜고 끈다 +const int kNotificationPermissionSdk = 33; + +/// firebase_messaging 의 권한 → 앱의 [PushPermission] (firebase_messaging 17 — 안드로이드는 플러그인이 "물은 적 있음"을 기록해 넷으로 가른다). +/// +/// - 허락 · 임시 허락(iOS) → granted +/// - 묻지 않음 → notAsked +/// - 거절 → 안드로이드 13 이상은 다시 물을 수 있어 denied, 12 이하는 휴대폰 설정에서 끈 것이라 blocked. +/// 판을 모르면([sdkInt] null) 다시 물을 수 있는 것으로 본다(묻는 창이 뜨지 않으면 그 결과가 진짜 상태다) +/// - 다시 묻지 못함 → blocked +PushPermission pushPermissionOf(AuthorizationStatus status, {int? sdkInt}) { + final bool canAskAgain = + sdkInt == null || sdkInt >= kNotificationPermissionSdk; + return switch (status) { + AuthorizationStatus.authorized || + AuthorizationStatus.provisional => PushPermission.granted, + AuthorizationStatus.notDetermined => PushPermission.notAsked, + AuthorizationStatus.denied => + canAskAgain ? PushPermission.denied : PushPermission.blocked, + AuthorizationStatus.deniedPermanently => PushPermission.blocked, + }; +} + +/// FCM 메시지 → [PushMessage] — data 의 모듈 · 종류만 읽는다(알림 글 · 보낸 쪽 정보는 버린다) +PushMessage pushMessageOf(RemoteMessage message) => + PushMessage.fromData(Map.of(message.data)); + +/// firebase_messaging 으로 만든 [PushMessaging] — 앱에서 firebase_messaging 을 import 하는 곳은 여기뿐이다. +/// +/// 플러그인의 소식 줄(FID · 앞에서 받은 알림 · 누른 알림)은 만들자마자 듣는다 — 플러그인 줄은 broadcast 라 듣기 전에 온 값은 사라진다. +/// 기기 주소(FID) · 알림 내용은 로그에 남기지 않는다. +class FirebasePushMessaging implements PushMessaging { + FirebasePushMessaging._(this._messaging, {int? sdkInt}) : _sdkInt = sdkInt { + _subscriptions.addAll(>[ + _messaging.onRegistered.listen(_registered.add), + FirebaseMessaging.onMessage.listen( + (RemoteMessage message) => _foreground.add(pushMessageOf(message)), + ), + FirebaseMessaging.onMessageOpenedApp.listen( + (RemoteMessage message) => _opened.add(pushMessageOf(message)), + ), + ]); + } + + /// Firebase 를 띄운다 — 안드로이드는 google-services 플러그인이 만든 리소스(`google-services.json`)에서 설정을 읽는다. + /// 띄우지 못하면(설정 파일 없이 빌드 · [timeout] 넘음) 알림 없이 도는 [UnavailablePushMessaging] 을 돌려준다 — 앱은 그대로 돈다. + /// [sdkInt] 는 안드로이드 판(API) — 권한을 넷으로 가르는 데 쓴다(모르면 null). + static Future start({ + int? sdkInt, + Duration timeout = kFirebaseStartTimeout, + }) async { + try { + await Firebase.initializeApp().timeout(timeout); + // 플러그인의 채널 처리기를 지금 건다 — 누른 알림 · 앞에서 받은 알림을 놓치지 않게 + return FirebasePushMessaging._( + FirebaseMessaging.instance, + sdkInt: sdkInt, + ); + } on Object { + // "Failed to load FirebaseOptions from resource"(설정 파일 없음) · 시간 넘음 · 플러그인 없음 + return const UnavailablePushMessaging(); + } + } + + final FirebaseMessaging _messaging; + final int? _sdkInt; + + // 앱이 떠 있는 동안 내내 듣는다(닫지 않는다) + final List> _subscriptions = + >[]; + final StreamController _registered = + StreamController.broadcast(); + final StreamController _foreground = + StreamController.broadcast(); + final StreamController _opened = + StreamController.broadcast(); + + @override + bool get isAvailable => true; + + @override + Future permission() async { + final NotificationSettings settings = await _messaging + .getNotificationSettings(); + return pushPermissionOf(settings.authorizationStatus, sdkInt: _sdkInt); + } + + @override + Future requestPermission() async { + final NotificationSettings settings = await _messaging.requestPermission(); + return pushPermissionOf(settings.authorizationStatus, sdkInt: _sdkInt); + } + + @override + Future register() => _messaging.register(); + + @override + Future unregister() => _messaging.unregister(); + + @override + Stream get onRegistered => _registered.stream; + + @override + Stream get onForegroundMessage => _foreground.stream; + + @override + Stream get onOpened => _opened.stream; + + @override + Future initialOpen() async { + final RemoteMessage? message = await _messaging.getInitialMessage(); + return message == null ? null : pushMessageOf(message); + } +} diff --git a/flutter/lib/core/push/notification_system.dart b/flutter/lib/core/push/notification_system.dart new file mode 100644 index 0000000..5a643f9 --- /dev/null +++ b/flutter/lib/core/push/notification_system.dart @@ -0,0 +1,127 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 휴대폰의 알림 설정 — 앱 알림 전체 · 새 메일 채널 · 안드로이드 판 +@immutable +class NotificationStatus { + const NotificationStatus({ + required this.appEnabled, + required this.channelEnabled, + this.sdkInt, + }); + + /// 읽지 못했다 — 막지 않는 쪽(켜짐)으로 본다 + static const NotificationStatus unknown = NotificationStatus( + appEnabled: true, + channelEnabled: true, + ); + + /// 이 앱의 알림 전체가 켜져 있다(안드로이드 `areNotificationsEnabled`) + final bool appEnabled; + + /// 새 메일 채널이 켜져 있다 — 채널만 끈 것은 알림 권한으로 알 수 없어 따로 본다(채널이 없는 판은 참) + final bool channelEnabled; + + /// 안드로이드 판(API) — 모르면 null + final int? sdkInt; + + @override + bool operator ==(Object other) => + other is NotificationStatus && + other.appEnabled == appEnabled && + other.channelEnabled == channelEnabled && + other.sdkInt == sdkInt; + + @override + int get hashCode => Object.hash(appEnabled, channelEnabled, sdkInt); +} + +/// 휴대폰의 알림창 · 알림 설정 — 포트다(안드로이드는 [PlatformNotificationSystem], iOS 는 나중에 구현을 더한다) +abstract interface class NotificationSystem { + /// 지금 알림 설정 — 읽지 못하면 [NotificationStatus.unknown] + Future status(); + + /// 휴대폰의 알림 설정 화면을 연다 — 새 메일 채널만 꺼졌으면 그 채널, 아니면 이 앱의 알림 설정. 열었으면 참 + Future openSettings(); + + /// 알림창의 "새 메일 N통"을 지운다 — 다른 알림은 건드리지 않는다 + Future clearNewMail(); +} + +/// 안드로이드 알림 채널(`MainActivity.kt` → `NewMailNotifications.kt`) +/// +/// **짝: Kotlin `NewMailNotifications`** — 채널 이름 · 부르는 이름 · 답의 칸과 값([statusMethod] 의 [appEnabledKey] · [channelEnabledKey] · [sdkIntKey], +/// [openSettingsMethod] 의 [openedResult]). 한쪽을 고치면 둘 다. +class PlatformNotificationSystem implements NotificationSystem { + const PlatformNotificationSystem(); + + static const MethodChannel channel = MethodChannel( + 'kr.it.arasystem.app/notifications', + ); + + static const String statusMethod = 'status'; + static const String openSettingsMethod = 'openSettings'; + static const String clearNewMailMethod = 'clearNewMail'; + + static const String appEnabledKey = 'appEnabled'; + static const String channelEnabledKey = 'channelEnabled'; + static const String sdkIntKey = 'sdkInt'; + + static const String openedResult = 'opened'; + + @override + Future status() async { + try { + final Map? reply = await channel + .invokeMapMethod(statusMethod); + return notificationStatusOf(reply); + } on PlatformException { + return NotificationStatus.unknown; + } on MissingPluginException { + return NotificationStatus.unknown; + } + } + + @override + Future openSettings() async { + try { + return await channel.invokeMethod(openSettingsMethod) == + openedResult; + } on PlatformException { + return false; + } on MissingPluginException { + return false; + } + } + + @override + Future clearNewMail() async { + try { + await channel.invokeMethod(clearNewMailMethod); + } on PlatformException { + // 지우지 못했다 — 다음에 앞으로 올 때 다시 지운다 + } on MissingPluginException { + // 채널이 없는 플랫폼 + } + } +} + +/// 채널 답 → [NotificationStatus] — 모양이 틀린 칸은 막지 않는 쪽(켜짐 · 판 모름)으로 본다 +NotificationStatus notificationStatusOf(Map? reply) { + if (reply == null) return NotificationStatus.unknown; + final Object? app = reply[PlatformNotificationSystem.appEnabledKey]; + final Object? channel = reply[PlatformNotificationSystem.channelEnabledKey]; + final Object? sdkInt = reply[PlatformNotificationSystem.sdkIntKey]; + return NotificationStatus( + appEnabled: app is bool ? app : true, + channelEnabled: channel is bool ? channel : true, + sdkInt: sdkInt is int ? sdkInt : null, + ); +} + +/// 알림창 · 알림 설정 — 시험은 가짜로 덮어쓴다 +final Provider notificationSystemProvider = + Provider( + (Ref ref) => const PlatformNotificationSystem(), + ); diff --git a/flutter/lib/core/push/push_controller.dart b/flutter/lib/core/push/push_controller.dart new file mode 100644 index 0000000..5fc13ea --- /dev/null +++ b/flutter/lib/core/push/push_controller.dart @@ -0,0 +1,714 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/api/api_exception.dart'; +import 'package:arasystem_app/core/api/dio_provider.dart'; +import 'package:arasystem_app/core/api/error_notifier.dart'; +import 'package:arasystem_app/core/app_config/app_config.dart'; +import 'package:arasystem_app/core/app_config/app_config_controller.dart'; +import 'package:arasystem_app/core/constants/app_info_provider.dart'; +import 'package:arasystem_app/core/push/notification_system.dart'; +import 'package:arasystem_app/core/push/push_events.dart'; +import 'package:arasystem_app/core/push/push_messaging.dart'; +import 'package:arasystem_app/core/push/push_preferences.dart'; +import 'package:arasystem_app/core/push/push_registration.dart'; +import 'package:arasystem_app/core/push/push_state.dart'; +import 'package:arasystem_app/core/storage/token_store.dart'; +import 'package:arasystem_app/core/storage/token_store_provider.dart'; +import 'package:arasystem_app/core/utils/app_foreground.dart'; +import 'package:arasystem_app/core/utils/clock.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// "봤음"(`DELETE …/unseen`)을 보내는 가장 짧은 간격 — 그 안에 또 오면 건너뛰고 간격 끝에 한 번 보낸다 +const Duration kPushSeenInterval = Duration(seconds: 30); + +/// 앞에서 받은 새 메일 알림 뒤의 "봤음"은 받은 때부터 이만큼 지나야 보낸다. +/// 서버는 N 을 seen_at 의 초부터 같은 초도 넣어 세고 메일의 받은 시각은 초 단위라, 그 알림을 일으킨 메일과 같은 초에 봤음을 보내면 +/// 열지 않은 그 메일이 다음 알림에 다시 세어진다(2026-11-08 실기기 — 한 통이 더 왔는데 "새 메일 2통"). 1초 넘게 늦추면 늘 다음 초가 된다 +const Duration kPushSeenAfterMessageDelay = Duration(milliseconds: 1500); + +/// 직원이 켤 때 FCM 등록 소식(FID)을 기다리는 가장 긴 시간 — 넘으면 켜지 못한 것으로 알린다 +const Duration kPushRegisterWait = Duration(seconds: 15); + +/// 새 메일 알림 — 앱에 하나. 세션 노티파이어가 [PushRegistrar] 로 부르고, 설정 › 새 메일 알림 줄 · 받은편지함 위 권하기 줄이 상태를 본다. +final NotifierProvider pushControllerProvider = + NotifierProvider(PushController.new); + +/// 새 메일 알림 컨트롤러 — 계약 1절 "앱 새 메일 알림 · 앱 설정" · 앱 디자인 README "새 메일 알림 · 업데이트 · 앱 아이콘". +/// +/// - **맞추기**: "보내야 하나"(켬 ∧ 권한 ∧ 새 메일 채널 — [pushWanted])와 "맞출 수 있나"(받을 수 있는 휴대폰 ∧ 서버가 보낼 수 있음 ∧ +/// 메일을 쓸 수 있는 세션 — [pushReachable])를 보고 FCM 등록 · `PUT` · `DELETE` 를 고른다. 서버 일은 한 번에 하나씩(줄 세움). +/// `PUT` 은 FID 를 받았을 때 · FID 가 바뀌었을 때 · 세션이 열릴 때 한 번(앱을 띄울 때 — 판 · 줄 되살리기) · 직원이 켤 때만 — 앞으로 올 때마다 부르지 않는다. +/// `DELETE` 는 직원이 끌 때 · 권한이나 채널이 꺼진 것을 알았을 때. 로그아웃 · 세션 끝에는 부르지 않는다 — 세션 노티파이어가 토큰을 지운 뒤 +/// 서버 logout 과 함께 [unregister] 를 부르고(서로 기다리지 않는다), 여기서는 알림창을 지우고 FCM 등록만 푼다(서버 줄은 서버가 세션과 함께 지운다). +/// - **세션**: 세션 세대([_epoch] — register · unregister · pause 마다 오른다)가 바뀌면 늦은 결과를 버린다. 세션 노티파이어는 이것을 [pushRegistrarProvider] 로만 알고, +/// 이것은 세션을 watch 하지 않는다(서로 물리지 않게). +/// - **FCM 등록 세대**([_fcmGeneration] — 풀기를 시작할 때마다 오른다): 풀기 앞의 등록은 끝나도 마친 것으로 치지 않고 "풀지 못한 등록" 표시도 지우지 않는다. +/// 다음 세션의 등록은 풀기 뒤에 줄을 선다 — 풀기가 실패하거나 오래 걸려도 같은 실행의 다음 로그인이 다시 등록한다. +/// - **앞으로 옴**: 알림창의 "새 메일 N통"을 지우고, 권한 · 채널을 다시 읽어 맞추고, "봤음"을 보낸다(30초 간격 — 건너뛰면 끝에 한 번). +/// 앞에 있는 동안 온 새 메일 알림도 "봤음"을 보내고 메일 모듈에 알린다([pushEventsProvider] — 새 메일 확인을 바로). +/// 그 봤음만은 받은 때 + [kPushSeenAfterMessageDelay] 보다 일찍 보내지 않는다(보내는 때 = 마지막 봤음 + 30초 · 받은 때 + 1.5초 가운데 늦은 쪽). +/// 봤음은 앱이 앞에 있을 때만 보낸다 — 기다리는 동안(간격 끝 · 1.5초) 뒤로 가면 그 자리에서 보낸다(그때가 마지막으로 본 때다. 끝까지 기다리면 그사이 온 새 메일까지 본 것으로 센다). +/// - **누른 알림**: [pushEventsProvider] 에 값으로 올린다 — 라우터가 가져간다(`core/router/push_navigation.dart`). +/// - Firebase 를 띄우지 못한 휴대폰([PushMessaging.isAvailable] 이 거짓)에서는 아무것도 하지 않는다. +/// - FID · 주소 · 토큰은 로그에 남기지 않는다. +class PushController extends Notifier implements PushRegistrar { + late PushMessaging _messaging; + late NotificationSystem _notifications; + late PushPreferences _prefs; + + final List> _subscriptions = + >[]; + + /// 이 휴대폰의 값 · 권한을 다 읽었다 — 맞추기 · 켜기 · 끄기가 먼저 기다린다 + Future _ready = Future.value(); + + /// 서버 일 · 기기 읽기를 차례로 + Future _queue = Future.value(); + bool _reconcileQueued = false; + + /// FCM 등록 · 풀기를 차례로(플러그인은 둘을 다른 스레드에서 돌린다) + Future _fcmQueue = Future.value(); + + /// 세션 세대 — register · unregister · pause 마다 오른다 + int _epoch = 0; + bool _sessionReady = false; + + /// 이 세션의 서버 줄 — 있음(true) · 없음(false) · 모름(null) + bool? _serverRow = false; + + /// 이 세션에 이번 실행에서 올린 FID — 세션이 열릴 때마다 비운다(그 세션에서 한 번은 올린다) + String? _serverFid; + + /// FCM 이 알려 준 FID — 또는 지난 실행에 올린 것(FCM 등록이 그대로인 동안) + String? _fid; + + /// FCM 등록을 바란다(맞추기가 시작했다 — 풀 때까지) · 이번 실행에서 등록을 마쳤다 + bool _fcmWanted = false; + bool _fcmRegistered = false; + Future? _fcmRegistering; + bool _pendingUnregister = false; + + /// FCM 등록 세대 — 풀기를 시작할 때마다 오른다. 등록은 시작한 세대가 그대로일 때만 마친 것으로 친다 + int _fcmGeneration = 0; + + /// 줄에 섰거나 도는 풀기의 수 — 그동안 온 등록 소식은 풀리는 등록의 것이라 쓰지 않는다 + int _fcmUnregistering = 0; + final List> _fidWaiters = >[]; + + // ── 봤음 ── + bool _seenPending = false; + DateTime? _lastSeenAt; + + /// 빚진 봤음을 일으킨, 앞에서 받은 마지막 새 메일 알림의 때 — 그 뒤 [kPushSeenAfterMessageDelay] 가 지나야 보낸다(보내면 지운다) + DateTime? _newMailAt; + Timer? _seenTimer; + + PushRegistrationRepository get _repository => + ref.read(pushRegistrationRepositoryProvider); + + TokenStore get _store => ref.read(tokenStoreProvider); + + Clock get _clock => ref.read(clockProvider); + + ErrorNotifier get _errors => ref.read(errorNotifierProvider); + + String get _appVersion => + normalizedAppVersion(ref.read(appVersionProvider)) ?? kUnknownAppVersion; + + @override + PushState build() { + _messaging = ref.read(pushMessagingProvider); + _notifications = ref.read(notificationSystemProvider); + _prefs = ref.read(pushPreferencesProvider); + ref.onDispose(_dispose); + if (!_messaging.isAvailable) return const PushState(loaded: true); + + _subscriptions.addAll(>[ + _messaging.onRegistered.listen(_onRegistered), + _messaging.onForegroundMessage.listen(_onForegroundMessage), + _messaging.onOpened.listen(_onOpened), + ]); + ref + ..listen(appForegroundProvider, (bool? previous, bool next) { + if (next && previous == false) _cameToForeground(); + if (!next && previous == true) _wentToBackground(); + }) + ..listen(pushAvailableProvider, (bool? previous, bool? next) { + state = state.copyWith(server: _serverOf(next)); + _requestReconcile(); + }); + // 꺼져 있던 앱을 띄운 알림 — 라우터가 가져간다 + unawaited( + _messaging.initialOpen().then((PushMessage? opened) { + if (opened != null && ref.mounted) { + ref.read(pushEventsProvider.notifier).add(opened, opened: true); + } + }, onError: (Object _, StackTrace _) {}), + ); + // build 안에서는 상태를 바꾸지 않는다 — 다음 차례에 읽기 시작한다 + _ready = Future.microtask(_initialize); + return PushState( + deviceSupported: true, + server: _serverOf(ref.read(pushAvailableProvider)), + ); + } + + // ── 화면이 부르는 일 ─────────────────────────────── + + /// [알림 켜기](권하기 줄) · 설정 스위치를 켰다 — 권한이 없으면 먼저 시스템 창(직원이 누른 뒤에만). 허락되면 FCM 등록 → 서버 등록(`PUT`)이 + /// 끝나야 켬을 둔다. 서버 등록이 실패하면 이 휴대폰의 값을 끔으로 두고(스위치가 꺼진 채로 돌아간다 — 처음 값 켬에서 켰어도, 저절로 다시 하지 않는다) + /// 그 코드의 사전 문구를 알린다(인터셉터 · 등록 소식이 없으면 `SYS_001`) + Future turnOn() async { + await _ready; + if (!ref.mounted) return PushTurnOnResult.failed; + if (state.change != null) return PushTurnOnResult.busy; + if (!state.deviceSupported || + state.server == PushServer.unavailable || + !_sessionReady) { + return PushTurnOnResult.unavailable; + } + final int epoch = _epoch; + state = state.copyWith(change: PushChange.turningOn); + try { + PushPermission permission = await _readPermission(); + if (!_current(epoch)) return PushTurnOnResult.failed; + if (permission != PushPermission.granted) { + if (permission == PushPermission.blocked) { + state = state.copyWith(permission: permission); + return PushTurnOnResult.blocked; + } + permission = await _askPermission(); + if (!ref.mounted) return PushTurnOnResult.failed; + state = state.copyWith(permission: permission); + if (permission != PushPermission.granted) { + return permission == PushPermission.blocked + ? PushTurnOnResult.blocked + : PushTurnOnResult.denied; + } + } + final NotificationStatus status = await _notifications.status(); + if (!ref.mounted) return PushTurnOnResult.failed; + state = state.copyWith( + permission: permission, + channelEnabled: status.channelEnabled, + sdkInt: status.sdkInt, + ); + if (!status.channelEnabled) return PushTurnOnResult.channelOff; + // 켬 · 끔은 줄 안에서 둔다 — 뒤에 선 맞추기가 바뀐 값을 보게 + final _UserRegistration outcome = await _serial(() async { + final _UserRegistration result = await _registerByUser(epoch); + if (!ref.mounted) return _UserRegistration.skipped; + switch (result) { + case _UserRegistration.registered: + state = state.copyWith(enabled: true); + unawaited(_prefs.setEnabled(true)); + case _UserRegistration.failed: + // 직원이 켠 것이 실패했다 — 끔으로 둔다(스위치 · 줄이 꺼짐으로 돌아가고 저절로 다시 하지 않는다) + state = state.copyWith(enabled: false); + unawaited(_prefs.setEnabled(false)); + case _UserRegistration.skipped: + break; + } + return result; + }); + return outcome == _UserRegistration.registered + ? PushTurnOnResult.on + : PushTurnOnResult.failed; + } finally { + if (ref.mounted) state = state.copyWith(clearChange: true); + } + } + + /// 설정 스위치를 껐다 — 서버 등록을 지워야(`DELETE`) 끔을 둔다. 지우지 못하면 켬 그대로(스위치는 되돌린다 — 사전 문구는 인터셉터가 알렸다). + /// 끊긴 망에서는 끌 수 없다 — 휴대폰 설정에서 끌 수 있다 + Future turnOff() async { + await _ready; + if (!ref.mounted) return PushTurnOffResult.failed; + if (state.change != null) return PushTurnOffResult.busy; + final int epoch = _epoch; + state = state.copyWith(change: PushChange.turningOff); + try { + // 줄 안에서 본다 — 앞에 선 등록(PUT)이 끝난 뒤의 서버 줄로 정하고, 끔도 줄 안에서 둔다 + final bool done = await _serial(() async { + final bool talk = + _sessionReady && _store.hasSession && state.deviceSupported; + if (talk && _serverRow != false) { + if (!await _delete(epoch: epoch, silent: false)) return false; + } + if (!ref.mounted) return false; + state = state.copyWith(enabled: false); + unawaited(_prefs.setEnabled(false)); + return true; + }); + return done ? PushTurnOffResult.off : PushTurnOffResult.failed; + } finally { + if (ref.mounted) state = state.copyWith(clearChange: true); + } + } + + /// 받은편지함 위 권하기 줄의 [✕] — 이 휴대폰에서 다시 보이지 않는다 + void dismissOffer() { + if (!ref.mounted || state.offerDismissed) return; + state = state.copyWith(offerDismissed: true); + unawaited(_prefs.setOfferDismissed(true)); + } + + /// [설정 열기] — 새 메일 채널만 꺼졌으면 그 채널, 아니면 이 앱의 알림 설정. 열었으면 참(돌아오면 앞으로 옴이 다시 읽는다) + Future openSettings() => _notifications.openSettings(); + + // ── 세션 노티파이어가 부르는 일 (PushRegistrar) ────── + + @override + Future register() async { + // 세션 노티파이어가 상태를 바꾸는 중에 부를 수 있다 — 다음 차례에 바꾼다 + await Future.value(); + if (!ref.mounted || !state.deviceSupported) return; + _epoch++; + _sessionReady = true; + // 세션이 열릴 때마다 한 번은 올린다(앱을 띄울 때 — 판 · 줄 되살리기) + _serverFid = null; + if (ref.read(appForegroundProvider)) _seenPending = true; + state = state.copyWith(sessionReady: true); + _requestReconcile(); + } + + @override + Future unregister() async { + if (!ref.mounted) return; + _epoch++; + final int epoch = _epoch; + _sessionReady = false; + _serverFid = null; + _cancelSeen(); + _completeFidWaiters(null); + if (!state.deviceSupported) return; + // 지난 실행의 값(FID · 풀지 못한 등록)을 읽은 뒤에 정한다 + await _ready; + // 그사이 다음 세션이 열렸다(다른 직원의 로그인) — 그 세션이 등록한다 + if (!ref.mounted || epoch != _epoch) return; + final bool hadRegistration = + _fcmWanted || _fcmRegistered || _fid != null || _pendingUnregister; + _fcmWanted = false; + _fid = null; + _serverRow = false; + state = state.copyWith(sessionReady: false, registered: false); + unawaited(_prefs.setLastFid(null)); + unawaited(_prefs.setServerRegistered(false)); + await _notifications.clearNewMail(); + if (hadRegistration && ref.mounted && epoch == _epoch) { + await _unregisterFcm(); + } + } + + @override + void pause() { + if (!ref.mounted) return; + _epoch++; + _sessionReady = false; + _serverFid = null; + _cancelSeen(); + if (!state.deviceSupported) return; + scheduleMicrotask(() { + if (ref.mounted && !_sessionReady) { + state = state.copyWith(sessionReady: false); + } + }); + } + + // ── 읽기 · 맞추기 ───────────────────────────────── + + Future _initialize() async { + try { + final PushPreferenceValues values = await _prefs.load(); + if (!ref.mounted) return; + _pendingUnregister = values.pendingUnregister; + // 풀지 못한 등록이 남았으면 지난 FID 는 믿지 않는다(다시 등록하고 새 소식을 기다린다) + _fid = values.pendingUnregister ? null : values.lastFid; + _serverRow = values.serverRegistered ? null : false; + state = state.copyWith( + enabled: values.enabled, + offerDismissed: values.offerDismissed, + ); + await _readDevice(); + } on Object { + // 읽지 못한 값은 처음 값으로 — 켤 때 다시 묻는다 + } + if (!ref.mounted) return; + state = state.copyWith(loaded: true); + // 앱을 띄웠다 — 앞에 있으면 앞으로 온 것과 같다(알림창 지우기 · 봤음) + if (ref.read(appForegroundProvider)) { + unawaited(_notifications.clearNewMail()); + if (_sessionReady) _seenPending = true; + } + _retryUnregisterIfSignedOut(); + _requestReconcile(); + } + + /// 알림 권한 · 새 메일 채널을 읽는다 — 읽지 못한 권한은 한 번 거절한 것으로 본다(켤 때 다시 묻는다 — 창이 뜨지 않으면 그 결과가 진짜 상태다) + Future _readDevice() async { + final PushPermission permission = await _readPermission(); + final NotificationStatus status = await _notifications.status(); + if (!ref.mounted) return; + state = state.copyWith( + permission: permission, + channelEnabled: status.channelEnabled, + sdkInt: status.sdkInt, + ); + } + + Future _readPermission() async { + try { + return await _messaging.permission(); + } on Object { + return state.permission ?? PushPermission.denied; + } + } + + Future _askPermission() async { + try { + return await _messaging.requestPermission(); + } on Object { + return _readPermission(); + } + } + + /// 맞추기를 줄에 세운다 — 이미 기다리는 맞추기가 있으면 그것이 한다 + void _requestReconcile() { + if (_reconcileQueued || !ref.mounted || !state.deviceSupported) return; + _reconcileQueued = true; + unawaited( + _serial(() async { + _reconcileQueued = false; + try { + await _reconcile(); + } on Object { + // 다음 맞추기(앞으로 옴 · 등록 소식 · 다음 로그인)에 다시 + } + }), + ); + } + + Future _reconcile() async { + await _ready; + if (!ref.mounted || !_canReachServer) return; + final int epoch = _epoch; + if (pushWanted(state)) { + _startFcmRegistration(); + final String? fid = _fid; + // FID 가 아직 없다 — 등록 소식(onRegistered)이 다시 맞춘다 + if (fid == null) return; + if (_serverRow == true && _serverFid == fid) return; + await _put(fid, epoch: epoch, silent: true); + } else if (_serverRow != false) { + await _delete(epoch: epoch, silent: true); + } + } + + /// 직원이 켰다 — FID 를 기다려(15초) 알림을 켠 채 올린다. 그사이 세션이 바뀌었으면 건너뛴다(켬 · 끔을 건드리지 않는다) + Future<_UserRegistration> _registerByUser(int epoch) async { + if (!_current(epoch) || !_canReachServer) return _UserRegistration.skipped; + _startFcmRegistration(); + final String? fid = _fid ?? await _waitForFid(); + if (!_current(epoch)) return _UserRegistration.skipped; + if (fid == null) { + // 요청을 보내지 못했다 — 인터셉터가 알리지 않으니 여기서 사전 문구로 + _errors.show(ApiException.fromCode(kFallbackCode)); + return _UserRegistration.failed; + } + if (_serverRow == true && _serverFid == fid) { + return _UserRegistration.registered; + } + if (await _put(fid, epoch: epoch, silent: false)) { + return _UserRegistration.registered; + } + return _current(epoch) + ? _UserRegistration.failed + : _UserRegistration.skipped; + } + + Future _put( + String fid, { + required int epoch, + required bool silent, + }) async { + try { + await _repository.put(fid: fid, appVersion: _appVersion, silent: silent); + } on DioException { + // 알리지 않는다(저절로 맞추기) — 다음 앞으로 옴 · 다음 등록 소식 · 다음 로그인에 다시 + return false; + } + if (!_current(epoch)) return false; + _serverRow = true; + _serverFid = fid; + state = state.copyWith(registered: true); + unawaited(_prefs.setLastFid(fid)); + unawaited(_prefs.setServerRegistered(true)); + _flushSeen(); + return true; + } + + Future _delete({required int epoch, required bool silent}) async { + try { + await _repository.delete(silent: silent); + } on DioException { + return false; + } + if (!ref.mounted || epoch != _epoch) return false; + _serverRow = false; + _serverFid = null; + state = state.copyWith(registered: false); + unawaited(_prefs.setServerRegistered(false)); + return true; + } + + /// 서버와 맞출 수 있는가 — 세션 · 토큰 · 휴대폰 · 서버. 화면 상태(`sessionReady`)는 [pause] 의 다음 차례에야 내려가므로 + /// 이 컨트롤러가 곧바로 아는 [_sessionReady] 도 본다(멈춘 바로 그 차례에 줄 선 맞추기가 제한된 세션으로 보내지 않게) + bool get _canReachServer => + _sessionReady && pushReachable(state) && _store.hasSession; + + /// 그 세션 그대로인가 — 늦은 결과를 버린다 + bool _current(int epoch) => ref.mounted && epoch == _epoch && _sessionReady; + + // ── FCM ─────────────────────────────────────── + + /// FCM 등록을 시작한다 — 이번 실행에서 한 번(실패하면 다음 맞추기에 다시). FID 는 등록 소식으로 온다. + /// 풀기가 줄에 섰거나 도는 중이면 그 뒤에 선다([_fcm] 줄 — 풀기가 [_fcmRegistered] · [_fcmRegistering] 을 먼저 내려 둔다) + void _startFcmRegistration() { + _fcmWanted = true; + if (_fcmRegistered || _fcmRegistering != null) return; + final int generation = _fcmGeneration; + final Future registering = _fcm(() async { + await _messaging.register(); + // 그사이 풀기를 바랐으면(로그아웃) 이 등록은 마친 것으로 치지 않는다 — 뒤에 선 풀기가 푼다 + if (generation == _fcmGeneration) _fcmRegistered = true; + }); + _fcmRegistering = registering; + unawaited( + registering + .then( + (void _) { + // 풀기를 바라지 않은 채 끝났을 때만 — 풀지 못한 등록이 이 등록으로 이어졌다(같은 FID 의 PUT 이 서버의 옛 줄을 지운다) + if (generation == _fcmGeneration && + _pendingUnregister && + ref.mounted) { + _pendingUnregister = false; + unawaited(_prefs.setPendingUnregister(false)); + } + }, + onError: (Object _, StackTrace _) { + // 다음 맞추기(앞으로 옴 · 다음 로그인)에 다시 — 기다리던 켜기는 끝낸다. + // 그사이 풀기를 시작했으면 풀린 등록의 실패다 — 다음 세션이 기다리는 켜기는 그 세션의 등록을 기다린다 + if (generation == _fcmGeneration) _completeFidWaiters(null); + }, + ) + .whenComplete(() { + if (identical(_fcmRegistering, registering)) { + _fcmRegistering = null; + } + }), + ); + } + + /// FCM 등록을 푼다 — 못 풀면 표시를 남겨 로그아웃 상태로 다음 실행 · 다음 앞으로 옴에 다시. + /// 기다리기 전에 등록 세대를 올리고 등록을 내려 둔다 — 풀기가 실패하거나 오래 걸려도 다음 세션의 등록이 이 풀기 뒤에 줄을 선다 + Future _unregisterFcm() async { + _fcmGeneration++; + final int generation = _fcmGeneration; + _fcmRegistered = false; + _fcmRegistering = null; + _fcmUnregistering++; + _pendingUnregister = true; + unawaited(_prefs.setPendingUnregister(true)); + try { + await _fcm(_messaging.unregister); + } on Object { + return; + } finally { + _fcmUnregistering--; + } + // 그사이 다시 등록하기 시작했거나(다른 직원의 로그인) 또 풀기를 시작했으면 그쪽이 표시를 정한다 + if (!ref.mounted || _fcmWanted || generation != _fcmGeneration) return; + _pendingUnregister = false; + unawaited(_prefs.setPendingUnregister(false)); + } + + void _retryUnregisterIfSignedOut() { + if (!_pendingUnregister || _fcmWanted || _store.hasSession) return; + unawaited(_unregisterFcm()); + } + + void _onRegistered(String fid) { + // 풀었거나 아직 바라지 않는 등록 · 풀리는 중인 등록의 소식은 쓰지 않는다 + if (!ref.mounted || !_fcmWanted || _fcmUnregistering > 0) return; + _fid = fid; + _completeFidWaiters(fid); + _requestReconcile(); + } + + Future _waitForFid() async { + final Completer waiter = Completer(); + _fidWaiters.add(waiter); + try { + return await waiter.future.timeout(kPushRegisterWait); + } on TimeoutException { + return null; + } finally { + _fidWaiters.remove(waiter); + } + } + + void _completeFidWaiters(String? fid) { + for (final Completer waiter in List>.of( + _fidWaiters, + )) { + if (!waiter.isCompleted) waiter.complete(fid); + } + } + + // ── 앞으로 옴 · 받은 알림 · 봤음 ─────────────────── + + void _cameToForeground() { + if (!ref.mounted || !state.deviceSupported) return; + unawaited(_notifications.clearNewMail()); + if (_sessionReady) _seenPending = true; + unawaited( + _serial(() async { + await _ready; + await _readDevice(); + }).then((void _) { + if (!ref.mounted) return; + _requestReconcile(); + _flushSeen(); + }, onError: (Object _, StackTrace _) {}), + ); + _retryUnregisterIfSignedOut(); + } + + void _onForegroundMessage(PushMessage message) { + if (!ref.mounted) return; + ref.read(pushEventsProvider.notifier).add(message, opened: false); + if (message.isNewMail && _sessionReady) { + _seenPending = true; + // 그 메일과 같은 초에 봤음을 보내지 않는다 — 받은 때부터 1.5초 뒤에(kPushSeenAfterMessageDelay) + _newMailAt = _clock(); + _flushSeen(); + } + } + + void _onOpened(PushMessage message) { + if (!ref.mounted) return; + ref.read(pushEventsProvider.notifier).add(message, opened: true); + } + + /// 앱이 뒤로 갔다 — 기다리던 봤음(간격 끝 · 받은 알림 뒤 1.5초)은 그 자리에서 보내고 타이머는 버린다(마지막으로 본 때가 지금이다) + void _wentToBackground() { + final Timer? timer = _seenTimer; + if (!ref.mounted || timer == null) return; + timer.cancel(); + _seenTimer = null; + if (_seenPending && _canSendSeen) _sendSeen(_clock()); + } + + /// 봤음을 보낼 수 있는가 — 이 세션의 서버 줄이 있을 때만(없으면 등록 뒤에) + bool get _canSendSeen => + _sessionReady && _serverRow == true && _store.hasSession; + + /// 빚진 "봤음"을 보낸다 — 앱이 앞에 있고 이 세션의 서버 줄이 있을 때만. 30초 안이면 간격 끝에 한 번, + /// 앞에서 받은 새 메일 알림 뒤면 받은 때 + 1.5초보다 일찍 보내지 않는다([_seenWait]). + /// 타이머는 하나 — 기다리는 사이 더 늦은 때가 생기면(또 받은 알림) 타이머가 끝날 때 다시 재어 그만큼 더 기다린다 + void _flushSeen() { + if (!_seenPending || !ref.mounted || _seenTimer != null) return; + // 뒤에 있으면 보내지 않는다 — 앞으로 올 때 다시 빚진다 + if (!_canSendSeen || !ref.read(appForegroundProvider)) return; + final DateTime now = _clock(); + final Duration wait = _seenWait(now); + if (wait <= Duration.zero) { + _sendSeen(now); + return; + } + _seenTimer = Timer(wait, () { + _seenTimer = null; + _flushSeen(); + }); + } + + /// 지금부터 봤음을 보낼 때까지 — 마지막 봤음 + 30초와 앞에서 받은 새 메일 알림 + 1.5초 가운데 늦은 쪽. + /// 시계가 거꾸로 갔으면 그 기다림은 지난 것으로 본다(거꾸로 간 만큼 늦어지지 않게) + Duration _seenWait(DateTime now) { + Duration wait = Duration.zero; + final DateTime? last = _lastSeenAt; + if (last != null) { + final Duration since = now.difference(last); + if (!since.isNegative && since < kPushSeenInterval) { + wait = kPushSeenInterval - since; + } + } + final DateTime? received = _newMailAt; + if (received != null) { + final Duration since = now.difference(received); + if (!since.isNegative && since < kPushSeenAfterMessageDelay) { + final Duration rest = kPushSeenAfterMessageDelay - since; + if (rest > wait) wait = rest; + } + } + return wait; + } + + void _sendSeen(DateTime now) { + _seenPending = false; + _newMailAt = null; + _lastSeenAt = now; + unawaited( + _repository.markSeen().then( + (void _) {}, + // 알리지 않는다 — 다음 앞으로 옴에 다시 + onError: (Object _, StackTrace _) {}, + ), + ); + } + + void _cancelSeen() { + _seenPending = false; + _newMailAt = null; + _seenTimer?.cancel(); + _seenTimer = null; + } + + // ── 차례 ────────────────────────────────────── + + Future _serial(Future Function() operation) { + final Future result = _queue.then((void _) => operation()); + _queue = result.then((T _) {}, onError: (Object _, StackTrace _) {}); + return result; + } + + Future _fcm(Future Function() operation) { + final Future result = _fcmQueue.then((void _) => operation()); + _fcmQueue = result.then( + (T _) {}, + onError: (Object _, StackTrace _) {}, + ); + return result; + } + + static PushServer _serverOf(bool? available) => switch (available) { + true => PushServer.available, + false => PushServer.unavailable, + null => PushServer.unknown, + }; + + void _dispose() { + for (final StreamSubscription subscription in _subscriptions) { + unawaited(subscription.cancel()); + } + _subscriptions.clear(); + _seenTimer?.cancel(); + _seenTimer = null; + _completeFidWaiters(null); + } +} + +/// 직원이 켠 등록의 결과 — 실패면 이 휴대폰의 값을 끔으로, 건너뜀(그사이 세션이 바뀜)이면 켬 · 끔을 건드리지 않는다 +enum _UserRegistration { registered, failed, skipped } diff --git a/flutter/lib/core/push/push_events.dart b/flutter/lib/core/push/push_events.dart new file mode 100644 index 0000000..0683d9f --- /dev/null +++ b/flutter/lib/core/push/push_events.dart @@ -0,0 +1,60 @@ +import 'package:arasystem_app/core/push/push_messaging.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 앱이 받은 알림 소식 — 앱이 앞에 있을 때 온 알림(알림창에 띄우지 않는다)과 직원이 누른 알림 +@immutable +class PushEvent { + const PushEvent({ + required this.seq, + required this.message, + required this.opened, + }); + + /// 소식마다 하나씩 오른다 + final int seq; + + final PushMessage message; + + /// 직원이 누른 알림 — 거짓이면 앞에 있을 때 온 알림 + final bool opened; + + @override + bool operator ==(Object other) => + other is PushEvent && + other.seq == seq && + other.message == message && + other.opened == opened; + + @override + int get hashCode => Object.hash(seq, message, opened); +} + +/// 받은 알림 소식 — 새 메일 알림 컨트롤러가 올리고, 메일 모듈(새 메일 확인을 바로) · 라우터(누른 알림 — `core/router/push_navigation.dart`)가 듣는다. +/// core/push 는 라우터 · 모듈을 모른다 — 듣는 쪽이 고른다. +final NotifierProvider pushEventsProvider = + NotifierProvider(PushEvents.new); + +class PushEvents extends Notifier { + PushMessage? _pendingOpen; + + @override + PushEvent? build() => null; + + /// 소식을 올린다 — 누른 알림은 라우터가 가져갈 때까지 값으로 든다(앱이 막 뜰 때 라우터보다 먼저 와도 놓치지 않게) + void add(PushMessage message, {required bool opened}) { + if (opened) _pendingOpen = message; + state = PushEvent( + seq: (state?.seq ?? 0) + 1, + message: message, + opened: opened, + ); + } + + /// 아직 가져가지 않은 누른 알림을 가져간다 — 한 번만(상태는 바꾸지 않는다 — 다른 프로바이더를 만드는 중에도 부를 수 있게) + PushMessage? takeOpened() { + final PushMessage? opened = _pendingOpen; + _pendingOpen = null; + return opened; + } +} diff --git a/flutter/lib/core/push/push_messaging.dart b/flutter/lib/core/push/push_messaging.dart new file mode 100644 index 0000000..be4da24 --- /dev/null +++ b/flutter/lib/core/push/push_messaging.dart @@ -0,0 +1,142 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/modules/app_modules.dart'; +import 'package:arasystem_app/core/push/push_target.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 알림 권한 — 플랫폼마다 다른 값을 넷으로 묶는다(안드로이드는 `firebase_push_messaging.dart` 의 `pushPermissionOf`). +enum PushPermission { + /// 알림을 보여도 된다 + granted, + + /// 아직 묻지 않았다 — [알림 켜기]가 시스템 창을 띄운다(안드로이드 13 이상) + notAsked, + + /// 한 번 거절했다 — 다시 물을 수 있다(안드로이드 13 이상) + denied, + + /// 휴대폰 설정에서만 켤 수 있다 — 두 번 거절했거나, 안드로이드 12 이하에서 꺼져 있다 + blocked, +} + +/// 알림 종류 — 서버가 data 의 `kind` 칸에 싣는다(짝: 서버 `modules/push/push.constants.ts`) +abstract final class PushKinds { + /// "새 메일 N통" + static const String newMail = 'new-mail'; + + /// 세션이 끝나 알림이 멈췄다 — 저절로 끝남 · 다른 곳에서 비밀번호를 바꿈 등(보내는 때는 서버 `SESSION_ENDED_NOTICE` · + /// 앱 디자인 README "새 메일 알림 · 업데이트 · 앱 아이콘"). 같은 tag 로 "새 메일 N통"을 바꿔 쓴다. + /// 앱은 새 메일로 보지 않고(확인 · 봤음 없음), 누르면 다른 알림처럼 메일 탭으로 간다 — 세션이 없으니 첫 요청에서 로그인 화면이다 + static const String sessionEnded = 'session-ended'; +} + +/// 받은 알림 — 데이터의 모듈 · 종류만 읽는다(보낸 사람 · 제목 같은 내용은 싣지 않는다 — push_target.dart) +@immutable +class PushMessage { + const PushMessage({this.target, this.kind}); + + /// 알림 데이터(`{ module: 'mail', kind: 'new-mail' }`)를 읽는다 — 모르는 모듈이면 [target] 이 null + factory PushMessage.fromData(Map data) { + final Object? kind = data[kindKey]; + return PushMessage( + target: PushTarget.fromData(data), + kind: kind is String && kind.isNotEmpty ? kind : null, + ); + } + + /// 알림 데이터의 종류 칸 — 서버와 짝이다 + static const String kindKey = 'kind'; + + /// 갈 곳 — 모르는 모듈이면 null(누르면 홈) + final PushTarget? target; + + /// 알림 종류([PushKinds]) — 없으면 null + final String? kind; + + /// 메일 모듈의 새 메일 소식인가 — 종류가 없는 메일 알림도 새 메일로 본다 + bool get isNewMail => + target?.module == AppModuleId.mail && + (kind == null || kind == PushKinds.newMail); + + @override + bool operator ==(Object other) => + other is PushMessage && + other.kind == kind && + other.target?.module == target?.module && + other.target?.itemId == target?.itemId; + + @override + int get hashCode => Object.hash(kind, target?.module, target?.itemId); +} + +/// 휴대폰의 알림 받기(FCM) — 포트다. 앱 코드는 이것만 알고, firebase_messaging 은 `firebase_push_messaging.dart` 만 import 한다. +/// +/// 기기 주소는 FID(Firebase 설치 ID — [register] 뒤 [onRegistered] 로 온다)다 — FCM 토큰 API 는 deprecated 라 쓰지 않는다. +/// 알림은 앱이 뒤에 있거나 꺼져 있으면 FCM 이 직접 띄운다(앱 코드는 돌지 않는다). 앱이 앞에 있으면 띄우지 않고 [onForegroundMessage] 로만 온다. +abstract interface class PushMessaging { + /// 이 휴대폰에서 받을 수 있는가 — Firebase 를 띄웠다(설정 파일 없이 빌드했거나 Google Play 서비스가 없으면 거짓) + bool get isAvailable; + + /// 지금 알림 권한 + Future permission(); + + /// 알림 권한을 묻는다 — 안드로이드 13 이상에서 묻지 않았거나 한 번 거절했으면 시스템 창을 띄운다. 직원이 [알림 켜기]를 누른 뒤에만 부른다 + Future requestPermission(); + + /// FCM 에 이 앱을 등록한다 — 끝나면 [onRegistered] 로 FID 가 온다(값을 돌려주지 않는다) + Future register(); + + /// FCM 등록을 푼다 — 그 FID 로 보낸 알림은 오지 않는다 + Future unregister(); + + /// 등록된 FID — 등록할 때마다 · 바뀔 때마다 온다. **듣기 전에 온 값은 사라진다** — [register] 전에 듣는다 + Stream get onRegistered; + + /// 앱이 앞에 있을 때 온 알림 — 알림창에 띄우지 않는다 + Stream get onForegroundMessage; + + /// 앱이 뒤에 있을 때 누른 알림 + Stream get onOpened; + + /// 꺼져 있던 앱을 띄운 알림 — 한 번만 온다(다시 부르면 null) + Future initialOpen(); +} + +/// 받을 수 없는 휴대폰 — 아무것도 하지 않는다(main 이 Firebase 를 띄우지 못했을 때 · 시험의 기본값) +class UnavailablePushMessaging implements PushMessaging { + const UnavailablePushMessaging(); + + @override + bool get isAvailable => false; + + @override + Future permission() async => PushPermission.blocked; + + @override + Future requestPermission() async => PushPermission.blocked; + + @override + Future register() async {} + + @override + Future unregister() async {} + + @override + Stream get onRegistered => const Stream.empty(); + + @override + Stream get onForegroundMessage => + const Stream.empty(); + + @override + Stream get onOpened => const Stream.empty(); + + @override + Future initialOpen() async => null; +} + +/// 알림 받기 — main 이 Firebase 를 띄웠으면 `FirebasePushMessaging` 으로 덮어쓴다. 시험은 가짜로 +final Provider pushMessagingProvider = Provider( + (Ref ref) => const UnavailablePushMessaging(), +); diff --git a/flutter/lib/core/push/push_preferences.dart b/flutter/lib/core/push/push_preferences.dart new file mode 100644 index 0000000..3819026 --- /dev/null +++ b/flutter/lib/core/push/push_preferences.dart @@ -0,0 +1,144 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// 이 휴대폰의 새 메일 알림 값 — 참 · 거짓과 FID 하나만 둔다(토큰 · 주소는 두지 않는다 — 보안 규칙 "토큰 저장") +@immutable +class PushPreferenceValues { + const PushPreferenceValues({ + this.enabled = true, + this.offerDismissed = false, + this.pendingUnregister = false, + this.lastFid, + this.serverRegistered = false, + }); + + /// 처음 값 — 켜짐(이 휴대폰에서 켜고 끈다. 직원이 바뀌어도 그대로) + static const PushPreferenceValues initial = PushPreferenceValues(); + + /// 새 메일 알림을 켰다 — 처음은 참 + final bool enabled; + + /// 받은편지함 위 권하기 줄을 닫았다 — 다시 보이지 않는다 + final bool offerDismissed; + + /// 로그아웃 · 세션 끝에 FCM 등록을 풀지 못했다 — 로그아웃 상태로 다음 실행 · 다음 앞으로 옴에 다시 푼다 + final bool pendingUnregister; + + /// 마지막으로 서버에 올린 FID — FCM 등록이 그대로인 동안만(풀 때 지운다). 다음 실행에서 등록 소식을 기다리지 않고 바로 올린다 + final String? lastFid; + + /// 지금 세션의 서버 줄이 있을 수 있다 — 꺼진 채 앱을 띄울 때마다 지우기(DELETE)를 보내지 않게 + final bool serverRegistered; + + @override + bool operator ==(Object other) => + other is PushPreferenceValues && + other.enabled == enabled && + other.offerDismissed == offerDismissed && + other.pendingUnregister == pendingUnregister && + other.lastFid == lastFid && + other.serverRegistered == serverRegistered; + + @override + int get hashCode => Object.hash( + enabled, + offerDismissed, + pendingUnregister, + lastFid, + serverRegistered, + ); +} + +/// 새 메일 알림 값을 두는 곳 — 포트다(앱은 `shared_preferences`, 시험은 메모리). +/// 읽기 · 쓰기가 실패해도 던지지 않는다 — 읽지 못하면 처음 값, 쓰지 못하면 이번 실행의 메모리 값으로 이어 간다 +abstract interface class PushPreferences { + Future load(); + + Future setEnabled(bool value); + + Future setOfferDismissed(bool value); + + Future setPendingUnregister(bool value); + + Future setLastFid(String? fid); + + Future setServerRegistered(bool value); +} + +/// `shared_preferences` 구현 — 앱을 지우면 함께 지워지고, 백업을 껐으니(AndroidManifest) 다른 기기로 옮겨지지 않는다 +class PrefsPushPreferences implements PushPreferences { + PrefsPushPreferences([SharedPreferencesAsync? prefs]) : _given = prefs; + + // 저장 키 — 바꾸면 이미 깐 앱의 설정이 처음 값으로 돌아간다 + static const String enabledKey = 'push_enabled'; + static const String offerDismissedKey = 'push_offer_dismissed'; + static const String pendingUnregisterKey = 'push_pending_unregister'; + static const String lastFidKey = 'push_last_fid'; + static const String serverRegisteredKey = 'push_server_registered'; + + final SharedPreferencesAsync? _given; + SharedPreferencesAsync? _prefs; + + // 처음 쓸 때 만든다 — 플러그인이 없는 곳(시험)에서 만들면 던진다 + SharedPreferencesAsync get _store => + _prefs ??= _given ?? SharedPreferencesAsync(); + + @override + Future load() async { + try { + final SharedPreferencesAsync prefs = _store; + return PushPreferenceValues( + enabled: await prefs.getBool(enabledKey) ?? true, + offerDismissed: await prefs.getBool(offerDismissedKey) ?? false, + pendingUnregister: await prefs.getBool(pendingUnregisterKey) ?? false, + lastFid: await prefs.getString(lastFidKey), + serverRegistered: await prefs.getBool(serverRegisteredKey) ?? false, + ); + } on Object { + return PushPreferenceValues.initial; + } + } + + @override + Future setEnabled(bool value) => _write( + (SharedPreferencesAsync prefs) => prefs.setBool(enabledKey, value), + ); + + @override + Future setOfferDismissed(bool value) => _write( + (SharedPreferencesAsync prefs) => prefs.setBool(offerDismissedKey, value), + ); + + @override + Future setPendingUnregister(bool value) => _write( + (SharedPreferencesAsync prefs) => + prefs.setBool(pendingUnregisterKey, value), + ); + + @override + Future setLastFid(String? fid) => _write( + (SharedPreferencesAsync prefs) => fid == null + ? prefs.remove(lastFidKey) + : prefs.setString(lastFidKey, fid), + ); + + @override + Future setServerRegistered(bool value) => _write( + (SharedPreferencesAsync prefs) => prefs.setBool(serverRegisteredKey, value), + ); + + Future _write( + Future Function(SharedPreferencesAsync prefs) write, + ) async { + try { + await write(_store); + } on Object { + // 쓰지 못했다 — 이번 실행은 메모리 값으로 이어 간다 + } + } +} + +/// 새 메일 알림 값을 두는 곳 — 시험은 메모리로 덮어쓴다 +final Provider pushPreferencesProvider = + Provider((Ref ref) => PrefsPushPreferences()); diff --git a/flutter/lib/core/push/push_registration.dart b/flutter/lib/core/push/push_registration.dart index a838722..e026a45 100644 --- a/flutter/lib/core/push/push_registration.dart +++ b/flutter/lib/core/push/push_registration.dart @@ -1,35 +1,91 @@ +import 'package:arasystem_app/core/api/api_paths.dart'; +import 'package:arasystem_app/core/api/dio_provider.dart'; +import 'package:arasystem_app/core/api/request_flags.dart'; +import 'package:arasystem_app/core/push/push_controller.dart'; +import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -/// 푸시 기기 등록 — 모듈 공통이다(메일 · 나중의 캘린더 · 파일이 같은 기기 토큰을 쓴다). +/// 새 메일 알림의 기기 등록 — 모듈 공통이다(메일 · 나중의 캘린더 · 파일이 같은 기기 등록을 쓴다). /// -/// **자리만 둔다.** 서버 경로(`POST` · `DELETE /api/push-devices` — 기기 토큰 · 플랫폼 · 앱 판, 앱 세션에 묶음)와 -/// FCM 이 생기면 구현을 채운다. 세션 노티파이어가 부른다: -/// - 로그인을 마치면(제한 없음) [register] -/// - 로그아웃한 **뒤에**(토큰과 세션 상태를 먼저 지운다) [unregister] — 이 기기의 알림 토큰만 지운다. -/// 짧은 한도 안에서만 기다리고 실패는 삼킨다. 서버의 기기 줄은 앱 logout 이 그 세션과 함께 지운다 -/// (계약 `docs/mail/api-contract.md` 1절 "앱 로그인 › 이 절 다음" — 세션이 서버에서 끝날 때도 같다). +/// 세션 노티파이어가 부른다(구현은 `push_controller.dart` 의 [PushController]): +/// - 메일을 쓸 수 있는 세션이 되면(제한 없는 로그인 · 되살리기 · 제한이 풀림) [register] +/// - 로그아웃한 **뒤에**(토큰과 세션 상태를 먼저 지운다) · 세션이 끝나면(`AUTH_002`) [unregister] +/// - 세션에 제한이 새로 생기면(갱신 응답 — 비밀번호 만료 · 2단계 기한) [pause] abstract interface class PushRegistrar { + /// 메일을 쓸 수 있는 세션이 됐다 — 이 휴대폰이 켬이고 알림이 허락됐으면 서버에 등록한다(그 세션에서 처음 한 번) Future register(); - /// 이 기기의 알림 토큰을 지운다 — 로그아웃 뒤라 서버를 부를 수 없다(인증 토큰이 없다) + /// 세션이 끝났다 — 알림창의 "새 메일 N통"을 지우고 FCM 등록을 푼다. + /// 서버는 부르지 않는다(인증 토큰이 이미 없다 — 서버 줄은 logout · 세션 끝이 함께 지운다). 짧은 한도 안에서만 기다리고 실패는 삼킨다 Future unregister(); + + /// 세션에 제한이 생겼다 — 제한이 풀릴 때까지([register]) 서버를 부르지 않는다(서버도 제한된 세션에는 보내지 않는다) + void pause(); } -/// 로그아웃 뒤 알림 토큰 지우기를 기다리는 최대 시간 — 넘으면 그만 기다린다 +/// 로그아웃 뒤 FCM 등록 풀기를 기다리는 최대 시간 — 넘으면 그만 기다린다(풀기는 뒤에서 이어지고, 못 풀면 다음 실행에 다시) const Duration kPushUnregisterTimeout = Duration(seconds: 5); -/// 지금의 구현 — 아무것도 하지 않는다 -class NoopPushRegistrar implements PushRegistrar { - const NoopPushRegistrar(); +/// 서버에 알리는 플랫폼 — iOS 를 붙일 때 `ios` 를 더한다(계약 "앱 새 메일 알림") +const String kPushPlatform = 'android'; - @override - Future register() async {} +/// 판을 읽지 못한 기기(거의 없다)가 보내는 앱 판 — 서버 칸의 모양(`숫자.숫자.숫자`)에 맞춘 값 +const String kUnknownAppVersion = '0.0.0'; - @override - Future unregister() async {} +/// 이 세션의 새 메일 알림 등록 — 계약 1절 "앱 새 메일 알림 · 앱 설정" +/// +/// - `PUT /api/push-registrations/current` `{ fid, platform, appVersion }` → 200(응답은 쓰지 않는다). 서버는 세션(sid)마다 줄 하나를 두고 +/// 같은 FID 의 다른 줄(다른 세션 · 다른 직원)을 지운다. +/// - `DELETE /api/push-registrations/current` → 204(없어도 204) +/// - `DELETE /api/push-registrations/current/unseen` → 204 — "봤음"(서버가 N 을 처음부터 센다) +/// +/// 저절로 하는 맞추기는 알림을 끄고([silent]), 직원이 켜고 끌 때만 알린다(인터셉터의 사전 문구). FID 는 로그에 남기지 않는다. +class PushRegistrationRepository { + const PushRegistrationRepository(this._dio); + + final Dio _dio; + + /// 등록한다 — 실패는 `DioException` + Future put({ + required String fid, + required String appVersion, + required bool silent, + }) async { + await _dio.put( + ApiPaths.pushRegistration, + data: { + 'fid': fid, + 'platform': kPushPlatform, + 'appVersion': appVersion, + }, + options: silent ? ApiOptions.silent() : null, + ); + } + + /// 등록을 지운다 — 실패는 `DioException` + Future delete({required bool silent}) async { + await _dio.delete( + ApiPaths.pushRegistration, + options: silent ? ApiOptions.silent() : null, + ); + } + + /// "봤음" — 알리지 않는다. 실패는 `DioException` + Future markSeen() async { + await _dio.delete( + ApiPaths.pushRegistrationUnseen, + options: ApiOptions.silent(), + ); + } } -/// 푸시 기기 등록 +/// 새 메일 알림 등록 API +final Provider pushRegistrationRepositoryProvider = + Provider( + (Ref ref) => PushRegistrationRepository(ref.watch(dioProvider)), + ); + +/// 세션 노티파이어가 부르는 기기 등록 — 새 메일 알림 컨트롤러다(시험은 기록하는 가짜로 덮어쓴다) final Provider pushRegistrarProvider = Provider( - (Ref ref) => const NoopPushRegistrar(), + (Ref ref) => ref.watch(pushControllerProvider.notifier), ); diff --git a/flutter/lib/core/push/push_state.dart b/flutter/lib/core/push/push_state.dart new file mode 100644 index 0000000..2e2b3c4 --- /dev/null +++ b/flutter/lib/core/push/push_state.dart @@ -0,0 +1,250 @@ +import 'package:arasystem_app/core/push/push_messaging.dart'; +import 'package:flutter/foundation.dart'; + +/// 안드로이드 13(API 33)부터 알림 권한을 묻는다 — 권하기 줄은 그 판부터 보인다 +const int kPushOfferMinSdk = 33; + +/// 서버가 새 메일 알림을 보낼 수 있는가 — `GET /api/app-config` 의 `pushAvailable` +enum PushServer { + /// 아직 모른다 · 받지 못했다 — 쓸 수 있는 것으로 본다(막지 않는다) + unknown, + available, + + /// 보낼 수 없다(FCM 키가 없다 · 그 경로가 없는 옛 서버) + unavailable, +} + +/// 직원이 켜고 끄는 중 +enum PushChange { turningOn, turningOff } + +/// 새 메일 알림 상태 — 설정 › 새 메일 알림 줄과 받은편지함 위 권하기 줄이 본다([pushRowStatusOf] · [showsPushOffer]) +@immutable +class PushState { + const PushState({ + this.loaded = false, + this.deviceSupported = false, + this.server = PushServer.unknown, + this.sessionReady = false, + this.enabled = true, + this.permission, + this.channelEnabled = true, + this.sdkInt, + this.registered = false, + this.change, + this.offerDismissed = false, + }); + + /// 이 휴대폰의 값(켬 · 권하기 줄 닫음)과 알림 권한을 읽었다 + final bool loaded; + + /// 이 휴대폰에서 받을 수 있다 — Firebase 를 띄웠다 + final bool deviceSupported; + + final PushServer server; + + /// 메일을 쓸 수 있는 세션이다(제한 없는 로그인) — 세션 노티파이어의 register · unregister · pause + final bool sessionReady; + + /// 이 휴대폰에서 켰다 — 처음은 켜짐(직원이 바뀌어도 그대로) + final bool enabled; + + /// 알림 권한 — 아직 읽지 않았으면 null + final PushPermission? permission; + + /// 새 메일 채널이 켜져 있다(휴대폰 설정에서 채널만 끌 수 있다) + final bool channelEnabled; + + /// 안드로이드 판(API) — 모르면 null + final int? sdkInt; + + /// 이 세션의 서버 등록이 있다(이번 실행에서 확인했다) + final bool registered; + + /// 직원이 켜거나 끄는 중 + final PushChange? change; + + /// 받은편지함 위 권하기 줄을 닫았다 + final bool offerDismissed; + + PushState copyWith({ + bool? loaded, + bool? deviceSupported, + PushServer? server, + bool? sessionReady, + bool? enabled, + PushPermission? permission, + bool? channelEnabled, + int? sdkInt, + bool? registered, + PushChange? change, + bool clearChange = false, + bool? offerDismissed, + }) { + return PushState( + loaded: loaded ?? this.loaded, + deviceSupported: deviceSupported ?? this.deviceSupported, + server: server ?? this.server, + sessionReady: sessionReady ?? this.sessionReady, + enabled: enabled ?? this.enabled, + permission: permission ?? this.permission, + channelEnabled: channelEnabled ?? this.channelEnabled, + sdkInt: sdkInt ?? this.sdkInt, + registered: registered ?? this.registered, + change: clearChange ? null : (change ?? this.change), + offerDismissed: offerDismissed ?? this.offerDismissed, + ); + } + + @override + bool operator ==(Object other) => + other is PushState && + other.loaded == loaded && + other.deviceSupported == deviceSupported && + other.server == server && + other.sessionReady == sessionReady && + other.enabled == enabled && + other.permission == permission && + other.channelEnabled == channelEnabled && + other.sdkInt == sdkInt && + other.registered == registered && + other.change == change && + other.offerDismissed == offerDismissed; + + @override + int get hashCode => Object.hash( + loaded, + deviceSupported, + server, + sessionReady, + enabled, + permission, + channelEnabled, + sdkInt, + registered, + change, + offerDismissed, + ); +} + +/// 이 휴대폰이 알림을 받고 싶은가 — 켬 ∧ 권한 ∧ 새 메일 채널 +bool pushWanted(PushState state) => + state.enabled && + state.permission == PushPermission.granted && + state.channelEnabled; + +/// 서버와 맞출 수 있는가 — 값을 읽었고 ∧ 받을 수 있는 휴대폰 ∧ 서버가 보낼 수 있고(모르면 보낼 수 있는 것으로) ∧ 메일을 쓸 수 있는 세션 +bool pushReachable(PushState state) => + state.loaded && + state.deviceSupported && + state.server != PushServer.unavailable && + state.sessionReady; + +/// 설정 › 새 메일 알림 줄의 모습 — 보드 `App1Push` ④ · 앱 디자인 README "새 메일 알림 · 업데이트 · 앱 아이콘" 의 표 +enum PushRowStatus { + /// 아직 읽는 중 — 스위치를 누를 수 없다 + loading, + + /// 켜짐 + on, + + /// 꺼짐 — 직원이 껐다 + off, + + /// 꺼짐 · 허락 전 — 켜면 휴대폰이 허용할지 묻는다(안드로이드 13 이상, 묻지 않음 · 한 번 거절) + notAsked, + + /// 휴대폰 설정에서 이 앱의 알림이 막혔다 — 스위치는 누를 수 없고 [설정 열기] + blocked, + + /// 휴대폰 설정에서 새 메일 채널만 꺼졌다 — 스위치는 누를 수 없고 [설정 열기] + channelOff, + + /// 지금은 쓸 수 없다(서버가 보낼 수 없다) — 스위치는 누를 수 없다 + unavailable, + + /// 이 휴대폰에서는 받을 수 없다(Firebase 를 띄우지 못함 · Google Play 서비스 없음) — 스위치는 누를 수 없다 + unsupported, + + /// 켜는 중 — 스위치는 켬 · 바쁨 + turningOn, + + /// 끄는 중 — 스위치는 끔 · 바쁨 + turningOff, +} + +/// 설정 줄의 모습 — 받을 수 없음 > 쓸 수 없음 > 켜는 중 · 끄는 중 > 읽는 중 > 막힘 > 허락 전 > 채널 꺼짐 > 켬 · 끔 +PushRowStatus pushRowStatusOf(PushState state) { + if (!state.loaded) return PushRowStatus.loading; + if (!state.deviceSupported) return PushRowStatus.unsupported; + if (state.server == PushServer.unavailable) return PushRowStatus.unavailable; + switch (state.change) { + case PushChange.turningOn: + return PushRowStatus.turningOn; + case PushChange.turningOff: + return PushRowStatus.turningOff; + case null: + break; + } + final PushPermission? permission = state.permission; + if (permission == null) return PushRowStatus.loading; + switch (permission) { + case PushPermission.blocked: + return PushRowStatus.blocked; + case PushPermission.notAsked: + case PushPermission.denied: + return PushRowStatus.notAsked; + case PushPermission.granted: + break; + } + if (!state.channelEnabled) return PushRowStatus.channelOff; + return state.enabled ? PushRowStatus.on : PushRowStatus.off; +} + +/// 받은편지함 위 권하기 줄을 보이는가 — 안드로이드 13 이상 · 아직 묻지 않음 · 켬 · 서버가 보낼 수 있음 · 받을 수 있는 휴대폰 · +/// 메일을 쓸 수 있는 세션 · 닫은 적 없음 · 켜는 중이 아님 +bool showsPushOffer(PushState state) => + state.loaded && + state.deviceSupported && + state.server != PushServer.unavailable && + state.sessionReady && + state.enabled && + !state.offerDismissed && + state.change == null && + state.permission == PushPermission.notAsked && + (state.sdkInt ?? 0) >= kPushOfferMinSdk; + +/// [알림 켜기] · 설정 스위치를 켠 결과 — 화면이 글을 고른다(보드 `App1Push` ③ · ④) +enum PushTurnOnResult { + /// 켰다(서버 등록까지 끝났다) + on, + + /// 시스템 창에서 [허용 안 함] + denied, + + /// 휴대폰 설정에서만 켤 수 있다(창이 뜨지 않았다) + blocked, + + /// 새 메일 채널만 꺼져 있다 + channelOff, + + /// 지금 쓸 수 없다 · 받을 수 없는 휴대폰 · 세션이 없다 + unavailable, + + /// 서버 등록이 실패했다 — 그 코드의 사전 문구를 이미 알렸다(이 휴대폰의 값을 끔으로 두어 스위치가 꺼진 채로 돌아간다 — 저절로 다시 하지 않는다) + failed, + + /// 이미 켜거나 끄는 중 + busy, +} + +/// 설정 스위치를 끈 결과 +enum PushTurnOffResult { + /// 껐다 + off, + + /// 서버에서 지우지 못했다 — 그 코드의 사전 문구를 이미 알렸다(스위치는 되돌린다) + failed, + + /// 이미 켜거나 끄는 중 + busy, +} diff --git a/flutter/lib/core/push/push_target.dart b/flutter/lib/core/push/push_target.dart index 49d1385..7fb7ee1 100644 --- a/flutter/lib/core/push/push_target.dart +++ b/flutter/lib/core/push/push_target.dart @@ -1,14 +1,15 @@ import 'package:arasystem_app/core/modules/app_modules.dart'; import 'package:arasystem_app/core/modules/module_routes.dart'; -/// 알림 · 깊은 링크가 가리키는 곳 — **모듈 이름과 id 만** 싣는다. +/// 알림 · 깊은 링크가 가리키는 곳 — **모듈 이름과(있으면) 항목 id 만** 싣는다. /// /// 보낸 사람 · 제목 같은 내용은 알림에 싣지 않는다(Google · Apple 서버를 거치므로 "새 메일 N통"만 — `docs/design/app/README.md` "새 메일 알림"). /// 알림을 누르면 앱이 이것으로 화면을 고르고, 내용은 앱이 서버에서 받아 와 보인다. +/// 새 메일 알림의 데이터는 `{ module: 'mail', kind: 'new-mail' }` 이다(id 없음 — 늘 받은편지함. 종류는 `push_messaging.dart` 의 `PushMessage`). class PushTarget { const PushTarget({required this.module, this.itemId}); - /// 알림 데이터(`{ module: 'mail', id: '…' }`)를 읽는다. 모듈 이름을 모르면 null. + /// 알림 데이터(`{ module: 'mail', … }` — 항목이 있는 알림은 `id: '…'` 도)를 읽는다. 모듈 이름을 모르면 null. static PushTarget? fromData(Map data) { final Object? name = data[moduleKey]; final AppModuleId? module = AppModuleId.fromName( diff --git a/flutter/lib/core/push/push_texts.dart b/flutter/lib/core/push/push_texts.dart new file mode 100644 index 0000000..d989bd4 --- /dev/null +++ b/flutter/lib/core/push/push_texts.dart @@ -0,0 +1,47 @@ +/// 새 메일 알림의 화면 글 — 받은편지함 위 권하기 줄(메일 모듈)과 설정 › 새 메일 알림 줄(설정 모듈)이 함께 쓴다. +/// +/// 앱 보드 `App1Push`(2026-11-07 확정)의 글 그대로다 — 글자 하나까지(디자인 README 「화면 문구 — 사용자에게 말하듯」). +/// 휴대폰 설정에 보이는 채널 이름 · 설명은 안드로이드 `res/values/strings.xml` 에 있다(보드 ⑫). +abstract final class PushTexts { + // ── ① 받은편지함 · 권하기 줄 ── + /// 줄의 영역 이름(화면 낭독기) + static const String offerRegion = '새 메일 알림 안내'; + static const String offerMessage = + '새 메일 알림을 켜면 앱을 닫아 두어도 새 메일이 오는 대로 알려 드립니다.'; + static const String offerEnable = '알림 켜기'; + + /// [✕]의 이름(화면 낭독기) + static const String offerDismiss = '닫기'; + + // ── ③ 휴대폰이 묻는 창에서 고른 뒤 ── + static const String enabled = '새 메일 알림을 켰습니다.'; + static const String deniedLater = '나중에 [설정] 탭에서 새 메일 알림을 켤 수 있습니다.'; + + /// 창이 뜨지 않을 만큼 이미 막혀 있을 때 — 알림에 [설정 열기] + static const String blockedNotice = '휴대폰 설정에서 이 앱의 알림이 꺼져 있습니다.'; + + /// 새 메일 알림 카테고리만 꺼져 있을 때 — 알림에 [설정 열기](보드 ④ 의 같은 상태 글의 앞 문장) + static const String channelOffNotice = '휴대폰 설정에서 새 메일 알림이 꺼져 있습니다.'; + + // ── ④ 설정 › 새 메일 알림 줄 ── + static const String rowLabel = '새 메일 알림'; + static const String rowDescription = '새 메일이 오면 휴대폰 알림으로 알려 드립니다.'; + static const String rowNotAsked = '켜면 휴대폰이 알림을 허용할지 묻습니다. 그때 [허용]을 눌러 주세요.'; + static const String rowBlocked = + '휴대폰 설정에서 이 앱의 알림이 꺼져 있습니다. [설정 열기]를 눌러 알림을 켜 주세요.'; + static const String rowChannelOff = + '휴대폰 설정에서 새 메일 알림이 꺼져 있습니다. [설정 열기]를 눌러 켜 주세요.'; + static const String rowUnavailable = + '지금은 새 메일 알림을 쓸 수 없습니다. 새 메일은 앱을 열어 확인해 주세요.'; + static const String rowUnsupported = + '이 휴대폰에서는 새 메일 알림을 받을 수 없습니다. 새 메일은 앱을 열어 확인해 주세요.'; + + /// 켜는 중 · 끄는 중(화면 낭독기가 읽는다) + static const String turningOn = '켜는 중'; + static const String turningOff = '끄는 중'; + + // ── [설정 열기] ── + static const String openSettings = '설정 열기'; + static const String openSettingsFailed = + '휴대폰 설정을 열지 못했습니다. 휴대폰의 [설정] 앱에서 ARA SYSTEM을 찾아 알림을 켜 주세요.'; +} diff --git a/flutter/lib/core/router/app_router.dart b/flutter/lib/core/router/app_router.dart index 23d2d80..53c6d40 100644 --- a/flutter/lib/core/router/app_router.dart +++ b/flutter/lib/core/router/app_router.dart @@ -1,10 +1,18 @@ +import 'package:arasystem_app/core/app_config/app_config_controller.dart'; import 'package:arasystem_app/core/constants/screen_titles.dart'; import 'package:arasystem_app/core/modules/app_modules.dart'; import 'package:arasystem_app/core/modules/module_routes.dart'; import 'package:arasystem_app/core/router/app_routes.dart'; import 'package:arasystem_app/core/router/app_shell.dart'; import 'package:arasystem_app/core/router/module_registry.dart'; +import 'package:arasystem_app/core/router/push_navigation.dart'; import 'package:arasystem_app/core/router/session_redirect.dart'; +import 'package:arasystem_app/features/app_update/presentation/pages/app_update_page.dart'; +import 'package:arasystem_app/features/auth/presentation/pages/login_code_page.dart'; +import 'package:arasystem_app/features/auth/presentation/pages/login_page.dart'; +import 'package:arasystem_app/features/auth/presentation/pages/password_change_page.dart'; +import 'package:arasystem_app/features/auth/presentation/pages/restricted_login_page.dart'; +import 'package:arasystem_app/features/auth/presentation/pages/two_factor_setup_page.dart'; import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart'; import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart'; import 'package:arasystem_app/shared/widgets/placeholder_page.dart'; @@ -17,15 +25,17 @@ final Provider> appModulesProvider = Provider>( (Ref ref) => kAppModules, ); -/// 앱 라우터 — 세션이 바뀔 때마다 redirect 를 다시 돌린다. +/// 앱 라우터 — 세션 · 업데이트 여부(앱 설정의 최소 판)가 바뀔 때마다 redirect 를 다시 돌린다. /// -/// - 로그인 전 · 제한 단계 화면(`/start` · `/login/...`)은 탭 막대 밖(root navigator)이다. +/// - 로그인 전 · 제한 단계 화면(`/start` · `/login/...`)과 업데이트 화면(`/update`)은 탭 막대 밖(root navigator)이다. /// - 로그인 뒤 화면은 켜진 모듈마다 탭 하나(StatefulShellRoute.indexedStack — 탭마다 이동 기록을 따로 지킨다). /// 라우트는 모듈이 내놓은 묶음(`features/<모듈>/<모듈>_routes.dart`)을 module_registry 가 모은 것이다. /// - 모르는 주소 · 꺼진 모듈의 주소는 메일 첫 화면으로 간다(redirect · onException). +/// - 누른 알림은 [connectPushNavigation] 이 옮긴다(새 메일 알림 컨트롤러도 그때 띄운다). /// -/// 로그인 단계 화면은 이름만 보이는 자리 화면이다 — 캔버스(로그인 4단계)대로 만들면 -/// `features/auth/presentation/pages/` 의 화면으로 바꾼다(경로 · redirect 는 그대로 둔다). +/// 로그인 틀 네 단계(앱 보드 `App1Login`)와 모르는 제한은 `features/auth/presentation/pages/` 의 화면이다. +/// 시작 화면(되살리는 중 · 되살리기 실패)은 아직 자리 화면이다(보드 없음). 업데이트 화면은 보드 `App1Push` ⑩ 의 화면이다 +/// (`features/app_update/` — 모듈이 아니다). final Provider routerProvider = Provider((Ref ref) { final List modules = ref.watch(appModulesProvider); final List enabled = enabledAppModules(modules); @@ -37,33 +47,41 @@ final Provider routerProvider = Provider((Ref ref) { settingsSections: moduleSettingsSections(moduleRoutes), ); - final _SessionChanges sessionChanges = _SessionChanges(); - ref.listen>( - sessionProvider, - (AsyncValue? previous, AsyncValue next) => - sessionChanges.notify(), - ); + final _RouteChanges routeChanges = _RouteChanges(); + ref + ..listen>( + sessionProvider, + (AsyncValue? previous, AsyncValue next) => + routeChanges.notify(), + ) + ..listen( + appUpdateRequiredProvider, + (bool? previous, bool next) => routeChanges.notify(), + ); final GoRouter router = GoRouter( navigatorKey: rootNavigatorKey, initialLocation: AppRoutes.start, - refreshListenable: sessionChanges, + refreshListenable: routeChanges, redirect: (BuildContext context, GoRouterState state) => resolveSessionRedirect( ref.read(sessionProvider), state.uri.path, modules: modules, + updateRequired: ref.read(appUpdateRequiredProvider), ), // 켜진 모듈 안의 없는 주소(`/mail/없는곳`) — 메일 첫 화면으로 onException: (BuildContext context, GoRouterState state, GoRouter router) => router.go(homeLocationOf(modules)), routes: [ _placeholderRoute(AppRoutes.start, ScreenTitles.start), - _placeholderRoute(AppRoutes.login, ScreenTitles.login), - _placeholderRoute(AppRoutes.loginCode, ScreenTitles.loginCode), - _placeholderRoute(AppRoutes.passwordChange, ScreenTitles.passwordChange), - _placeholderRoute(AppRoutes.twoFactorSetup, ScreenTitles.twoFactorSetup), - _placeholderRoute(AppRoutes.restricted, ScreenTitles.restricted), + _loginRoute(AppRoutes.login, const LoginPage()), + _loginRoute(AppRoutes.loginCode, const LoginCodePage()), + _loginRoute(AppRoutes.passwordChange, const PasswordChangePage()), + _loginRoute(AppRoutes.twoFactorSetup, const TwoFactorSetupPage()), + _loginRoute(AppRoutes.restricted, const RestrictedLoginPage()), + // 업데이트 — 뒤로 가기는 앱을 닫는다(go 로만 오니 쌓인 화면이 없다) + _loginRoute(AppRoutes.update, const AppUpdatePage()), StatefulShellRoute.indexedStack( builder: ( @@ -79,9 +97,11 @@ final Provider routerProvider = Provider((Ref ref) { ], ); + connectPushNavigation(ref, router, modules: modules); + ref.onDispose(() { router.dispose(); - sessionChanges.dispose(); + routeChanges.dispose(); }); return router; }); @@ -94,7 +114,15 @@ GoRoute _placeholderRoute(String path, String title) { ); } -/// 세션이 바뀐 것을 라우터에 알린다 (refreshListenable) -class _SessionChanges extends ChangeNotifier { +/// 로그인 틀의 한 단계 — 세션이 바뀌면 redirect 가 단계를 바꾼다(화면끼리 서로 옮기지 않는다) +GoRoute _loginRoute(String path, Widget page) { + return GoRoute( + path: path, + builder: (BuildContext context, GoRouterState state) => page, + ); +} + +/// 세션 · 업데이트 여부가 바뀐 것을 라우터에 알린다 (refreshListenable) +class _RouteChanges extends ChangeNotifier { void notify() => notifyListeners(); } diff --git a/flutter/lib/core/router/app_routes.dart b/flutter/lib/core/router/app_routes.dart index 808e4c3..4355327 100644 --- a/flutter/lib/core/router/app_routes.dart +++ b/flutter/lib/core/router/app_routes.dart @@ -20,4 +20,7 @@ abstract final class AppRoutes { /// 제한된 세션 — 앱이 모르는 제한 static const String restricted = '/login/restricted'; + + /// 앱을 업데이트해야 한다 — 이 앱의 판이 서버의 최소 판보다 낮다(어느 화면에서든 · 로그인 전에도) + static const String update = '/update'; } diff --git a/flutter/lib/core/router/app_shell.dart b/flutter/lib/core/router/app_shell.dart index 69a5e6d..028d85b 100644 --- a/flutter/lib/core/router/app_shell.dart +++ b/flutter/lib/core/router/app_shell.dart @@ -1,13 +1,21 @@ +import 'package:arasystem_app/core/constants/app_constants.dart'; import 'package:arasystem_app/core/modules/app_modules.dart'; +import 'package:arasystem_app/core/router/shell_bar.dart'; +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/theme/app_theme.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -/// 로그인 뒤 화면의 틀 — 아래 탭 막대가 웹의 전역 앱 레일에 해당한다. +/// 로그인 뒤 화면의 틀 — 아래 탭 막대가 웹의 전역 앱 레일에 해당한다(앱 보드 `App1Shell`). /// -/// 탭은 켜진 모듈만, 모듈 목록 차례 그대로다. 탭마다 이동 기록을 따로 지킨다(StatefulShellRoute.indexedStack). -/// 지금 탭을 다시 누르면 그 모듈의 첫 화면으로 돌아간다. -/// 모양은 임시다 — 탭 막대 보드가 확정되면 이 위젯만 바꾼다. -class AppShell extends StatelessWidget { +/// - 탭은 켜진 모듈만, 모듈 목록 차례 그대로다. 탭마다 이동 기록을 따로 지킨다(StatefulShellRoute.indexedStack). +/// 지금 탭을 다시 누르면 그 모듈의 첫 화면으로 돌아간다. +/// - **틀은 Scaffold 가 아니다** — 탭 안 화면마다 자기 Scaffold 를 둔다. 그래서 그 화면의 Scaffold 가 전역 알림(스낵바)을 받고, +/// 알림은 화면의 넓은 단추([메일 쓰기]) 위에 뜬다(틀이 Scaffold 면 알림이 단추를 가린다). +/// - 지금 탭의 모듈이 막대를 숨겼거나([ShellBarHidden] — 여러 통 고르기) 자판이 열려 있으면 막대를 뺀다. +/// 막대가 있을 때는 화면에서 아래 안전 영역을 뺀다(막대가 그 자리를 채운다). +class AppShell extends ConsumerWidget { const AppShell({ required this.navigationShell, required this.modules, @@ -22,27 +30,99 @@ class AppShell extends StatelessWidget { /// NavigationBar 는 탭이 둘 이상이어야 한다 static const int _minTabs = 2; + AppModuleId? get _currentModule { + final int index = navigationShell.currentIndex; + return index >= 0 && index < modules.length ? modules[index].id : null; + } + @override - Widget build(BuildContext context) { - return Scaffold( - body: navigationShell, - bottomNavigationBar: modules.length < _minTabs - ? null - : NavigationBar( - selectedIndex: navigationShell.currentIndex, - onDestinationSelected: (int index) => navigationShell.goBranch( - index, - initialLocation: index == navigationShell.currentIndex, - ), - destinations: [ - for (final AppModule module in modules) - NavigationDestination( - icon: Icon(module.icon), - selectedIcon: Icon(module.selectedIcon), - label: module.label, - ), - ], + Widget build(BuildContext context, WidgetRef ref) { + final AppModuleId? current = _currentModule; + final bool hiddenByScreen = + current != null && + ref.watch( + shellBarHidingProvider.select( + (Map hiding) => isShellBarHidden(hiding, current), + ), + ); + final bool keyboardOpen = MediaQuery.viewInsetsOf(context).bottom > 0; + final bool showBar = + modules.length >= _minTabs && !hiddenByScreen && !keyboardOpen; + + return ColoredBox( + color: Theme.of(context).colorScheme.surface, + child: Column( + children: [ + // 늘 같은 모양으로 감싼다 — 감싸는 위젯이 바뀌면 탭 안 화면(이동 기록)이 처음부터 다시 만들어진다 + Expanded( + child: MediaQuery.removePadding( + context: context, + removeBottom: showBar, + child: navigationShell, ), + ), + if (showBar) + // NavigationBar 는 안에서 네 방향 SafeArea 를 쓴다 — 화면 아래에 붙으니 위쪽(상태 표시줄) 여백은 뺀다 + // (Scaffold 의 bottomNavigationBar 자리와 같다) + MediaQuery.removePadding( + context: context, + removeTop: true, + child: _ModuleTabBar( + modules: modules, + selectedIndex: navigationShell.currentIndex, + onSelected: (int index) => navigationShell.goBranch( + index, + initialLocation: index == navigationShell.currentIndex, + ), + ), + ), + ], + ), + ); + } +} + +/// 아래 탭 막대 — 위에 옅은 선, 화면 낭독기 이름 "앱 메뉴" +class _ModuleTabBar extends StatelessWidget { + const _ModuleTabBar({ + required this.modules, + required this.selectedIndex, + required this.onSelected, + }); + + final List modules; + final int selectedIndex; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + final AppExtraColors extra = Theme.of(context).extension()!; + return Semantics( + container: true, + label: kAppMenuLabel, + explicitChildNodes: true, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border( + top: BorderSide( + color: extra.borderSubtle, + width: AppSize.borderWidth, + ), + ), + ), + child: NavigationBar( + selectedIndex: selectedIndex, + onDestinationSelected: onSelected, + destinations: [ + for (final AppModule module in modules) + NavigationDestination( + icon: Icon(module.icon), + selectedIcon: Icon(module.selectedIcon), + label: module.label, + ), + ], + ), + ), ); } } diff --git a/flutter/lib/core/router/push_navigation.dart b/flutter/lib/core/router/push_navigation.dart new file mode 100644 index 0000000..ae30eca --- /dev/null +++ b/flutter/lib/core/router/push_navigation.dart @@ -0,0 +1,46 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/modules/app_modules.dart'; +import 'package:arasystem_app/core/push/push_controller.dart'; +import 'package:arasystem_app/core/push/push_events.dart'; +import 'package:arasystem_app/core/push/push_messaging.dart'; +import 'package:arasystem_app/core/push/push_state.dart'; +import 'package:arasystem_app/core/push/push_target.dart'; +import 'package:arasystem_app/core/router/module_registry.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +/// 누른 알림을 라우터로 옮긴다 — 라우터 프로바이더가 만들 때 한 번 건다(core/push 는 라우터를 모른다). +/// +/// - 누른 알림은 [PushEvents] 가 값으로 들고 있다가 여기서 가져간다 — 앱이 막 뜰 때 라우터보다 먼저 와도 놓치지 않는다. +/// - 갈 곳은 [locationForPush] — 새 메일 알림은 늘 메일 탭 받은편지함이다(메일 탭의 다른 메일함 · 검색 · 읽기 · 쓰기는 닫힌다. +/// 쓰던 메일은 쓰기 화면이 내려가며 임시보관함에 마저 저장한다). +/// - 로그아웃 상태 · 되살리는 중 · 업데이트해야 하면 redirect 가 그 화면으로 돌려보낸다 — 기다려 둔 갈 곳은 두지 않는다 +/// (로그인 뒤 첫 화면이 받은편지함이다. id 가 있는 알림이 생기면 그때 더한다). +/// - 다른 직원이 로그인해 있어도 그 직원의 받은편지함을 연다 — 알림에 메일 · 직원을 싣지 않아 새는 것이 없다. +/// - 새 메일 알림 컨트롤러도 여기서 띄운다 — 앱을 띄우자마자 앞에서 받은 알림 · 누른 알림을 듣게. +void connectPushNavigation( + Ref ref, + GoRouter router, { + List modules = kAppModules, +}) { + void openPending() { + if (!ref.mounted) return; + final PushMessage? opened = ref + .read(pushEventsProvider.notifier) + .takeOpened(); + if (opened == null) return; + router.go(locationForPush(opened.target, moduleRoutesOf, modules: modules)); + } + + ref + ..listen(pushEventsProvider, ( + PushEvent? previous, + PushEvent? next, + ) { + if (next != null && next.opened) openPending(); + }) + ..listen(pushControllerProvider, (PushState? _, PushState _) {}); + // 라우터보다 먼저 온 누른 알림 — 라우터를 만든 뒤에 옮긴다(만드는 중에는 옮기지 않는다) + scheduleMicrotask(openPending); +} diff --git a/flutter/lib/core/router/session_redirect.dart b/flutter/lib/core/router/session_redirect.dart index f10dfbe..5ca0cbf 100644 --- a/flutter/lib/core/router/session_redirect.dart +++ b/flutter/lib/core/router/session_redirect.dart @@ -10,16 +10,21 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; /// /// | 세션 | 화면 | /// | --- | --- | +/// | (세션과 상관없이) 앱을 업데이트해야 함([updateRequired]) | 업데이트 — 로그인 전 화면에서도(옛 앱이 로그인 API 를 잘못 부르지 않게) | /// | 되살리는 중 · 되살리기 실패 | 시작 | /// | 로그아웃 | 로그인 | /// | 코드 단계 | 코드 | /// | 제한 passwordChange · twoFactorSetup · 모르는 제한 | 그 단계 | /// | 로그인(제한 없음) | 로그인 뒤 화면 — 로그인 전 화면 · 모르거나 꺼진 모듈의 주소면 메일 첫 화면으로 | +/// +/// 업데이트가 풀리면(서버가 최소 판을 내림) 업데이트 화면은 세션이 고른 화면으로 돌아간다(그 경로는 로그인 전 화면도 모듈도 아니다). String? resolveSessionRedirect( AsyncValue session, String location, { List modules = kAppModules, + bool updateRequired = false, }) { + if (updateRequired) return _stayOr(location, AppRoutes.update); final SessionState? state = session.value; if (state == null) return _stayOr(location, AppRoutes.start); diff --git a/flutter/lib/core/router/shell_bar.dart b/flutter/lib/core/router/shell_bar.dart new file mode 100644 index 0000000..04bba5d --- /dev/null +++ b/flutter/lib/core/router/shell_bar.dart @@ -0,0 +1,129 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/modules/app_modules.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 아래 탭 막대를 잠깐 숨긴 화면 수 — 모듈마다 센다(앱 디자인 README "앱 틀": 여러 통 고르기 중에는 탭 막대를 숨긴다). +/// +/// 틀(`app_shell.dart`)은 **지금 탭의 모듈**이 숨겼을 때만 막대를 빼므로, 다른 탭에서는 그대로 보인다. +/// 화면은 직접 부르지 않고 [ShellBarHidden] 으로 감싼다(위젯 생명주기 안에서는 프로바이더를 바꿀 수 없다). +final NotifierProvider> +shellBarHidingProvider = + NotifierProvider>(ShellBarHiding.new); + +/// 모듈 [module] 의 탭에서 막대를 숨겼는가 +bool isShellBarHidden(Map hiding, AppModuleId module) => + (hiding[module] ?? 0) > 0; + +/// 탭 막대를 숨긴 화면 수 +class ShellBarHiding extends Notifier> { + @override + Map build() => const {}; + + /// 한 화면이 숨긴다 + void hide(AppModuleId module) { + if (!ref.mounted) return; + state = {...state, module: (state[module] ?? 0) + 1}; + } + + /// 숨겼던 화면 하나가 돌려준다 + void reveal(AppModuleId module) { + if (!ref.mounted) return; + final int left = (state[module] ?? 0) - 1; + final Map next = {...state}; + if (left > 0) { + next[module] = left; + } else { + next.remove(module); + } + state = next; + } +} + +/// [hidden] 인 동안 [module] 의 탭에서 아래 탭 막대를 숨긴다 — 여러 통 고르기. +/// +/// 이 화면이 보일 때만 숨긴다 — 위에 다른 화면이 쌓였거나(받은편지함 위의 다른 메일함 · 전체 화면) 다른 탭이면 +/// 돌려준다(TickerMode). 고르던 받은편지함 위에 다른 메일함을 열어도 그 화면에는 탭 막대가 있다. +/// 프로바이더는 그리는 동안 바꿀 수 없어 그린 뒤에 알린다(한 프레임 늦다). 이 위젯이 사라지면 숨긴 것을 돌려준다. +class ShellBarHidden extends ConsumerStatefulWidget { + const ShellBarHidden({ + required this.module, + required this.hidden, + required this.child, + super.key, + }); + + final AppModuleId module; + final bool hidden; + final Widget child; + + @override + ConsumerState createState() => _ShellBarHiddenState(); +} + +class _ShellBarHiddenState extends ConsumerState { + /// 지금 숨겨 둔 모듈 — 숨기지 않았으면 null + AppModuleId? _hiddenModule; + ShellBarHiding? _hiding; + + /// 이 화면이 보이는가 — 가려졌으면 숨기지 않는다 + bool _visible = true; + + @override + void initState() { + super.initState(); + _scheduleSync(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final bool visible = TickerMode.valuesOf(context).enabled; + if (visible == _visible) return; + _visible = visible; + _scheduleSync(); + } + + @override + void didUpdateWidget(ShellBarHidden oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.hidden != widget.hidden || + oldWidget.module != widget.module) { + _scheduleSync(); + } + } + + void _scheduleSync() { + WidgetsBinding.instance.addPostFrameCallback((Duration _) => _sync()); + } + + void _sync() { + if (!mounted) return; + final AppModuleId? wanted = widget.hidden && _visible + ? widget.module + : null; + if (wanted == _hiddenModule) return; + final ShellBarHiding hiding = + _hiding ?? ref.read(shellBarHidingProvider.notifier); + _hiding = hiding; + final AppModuleId? previous = _hiddenModule; + if (previous != null) hiding.reveal(previous); + if (wanted != null) hiding.hide(wanted); + _hiddenModule = wanted; + } + + @override + void dispose() { + final AppModuleId? module = _hiddenModule; + final ShellBarHiding? hiding = _hiding; + // 위젯을 지우는 동안에는 프로바이더를 바꿀 수 없다 — 이 프레임이 끝난 뒤에 돌려준다 + if (module != null && hiding != null) { + scheduleMicrotask(() => hiding.reveal(module)); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) => widget.child; +} diff --git a/flutter/lib/core/theme/app_colors.dart b/flutter/lib/core/theme/app_colors.dart index eab1dff..8c09a32 100644 --- a/flutter/lib/core/theme/app_colors.dart +++ b/flutter/lib/core/theme/app_colors.dart @@ -24,6 +24,12 @@ abstract final class AppColors { /// `--color-brand-tint` — 여러 통 처리 도구 줄 · 새 메일 알림 줄 바탕 static const Color brandTint = Color(0xFFF3F8F8); + /// `--color-brand-tint-border` — 도구 줄(바탕 [brandTint])의 아래 선 + static const Color brandTintBorder = Color(0xFFE3EEEF); + + /// `--color-row-checked` — 고른 행 (여러 통 처리) + static const Color rowChecked = Color(0xFFE8F4F5); + /// `--color-on-brand` static const Color onBrand = Color(0xFFFFFFFF); @@ -36,6 +42,12 @@ abstract final class AppColors { /// `--color-on-brand-link` — 브랜드 바탕 위 링크 · 알림 안 단추 static const Color onBrandLink = Color(0xFF9ED6DB); + /// `--color-on-brand-track` rgba(255, 255, 255, 0.18) — 알림 안 남은 시간 막대의 바탕 ([보내기 취소]) + static const Color onBrandTrack = Color(0x2EFFFFFF); + + /// `--color-on-brand-progress` — 알림 안 남은 시간 막대 ([보내기 취소]가 남은 동안) + static const Color onBrandProgress = Color(0xFF7FD3DA); + // ── 글자 ── /// `--color-text` static const Color text = Color(0xFF171C26); @@ -93,6 +105,9 @@ abstract final class AppColors { /// `--color-danger-surface` static const Color dangerSurface = Color(0xFFFEF3F2); + /// `--color-danger-border` — 칸 안내 띠의 아래 선 (쓰기 화면의 받는 사람 · 제목 · 본문 오류) + static const Color dangerBorder = Color(0xFFFBD7D3); + /// `--color-warning-surface` static const Color warningSurface = Color(0xFFFFF7E6); @@ -108,9 +123,20 @@ abstract final class AppColors { /// `--color-star` — 중요 표시 static const Color star = Color(0xFFB7791F); + /// `--color-search-mark` — 검색에서 맞은 말의 바탕(글자색은 그대로 — 회색 미리 보기 줄의 강조 안만 [textSecondary]). + /// 짝: 웹 토큰 · 읽기 화면 본문 문서의 `mark` 색(`features/mail/domain/mail_html.dart` — WebView 는 앱 색을 읽지 못해 같은 값을 적는다) + static const Color searchMark = Color(0xFFFCE7A4); + /// 그림자 · 가림막의 바탕색 — `--shadow-*` 의 rgba(16, 24, 28, …) static const Color shadow = Color(0xFF10181C); /// `--color-backdrop` rgba(11, 54, 64, 0.32) — 대화상자 뒤 가림막 static const Color backdrop = Color(0x520B3640); + + // ── 앱 보드에서 정한 색 (`App1Shell` · `App1Push` — 웹 토큰에 없다) ── + /// 누를 수 없는 스위치의 손잡이 + static const Color switchThumbDisabled = Color(0xFFC5CBD3); + + /// 둥근 진행 표시(켜는 중 · 끄는 중)의 바탕 고리 + static const Color progressRingTrack = Color(0xFFD5ECEE); } diff --git a/flutter/lib/core/theme/app_spacing.dart b/flutter/lib/core/theme/app_spacing.dart index 0d28bbf..cdf5dc3 100644 --- a/flutter/lib/core/theme/app_spacing.dart +++ b/flutter/lib/core/theme/app_spacing.dart @@ -14,9 +14,14 @@ abstract final class AppSpacing { static const double s12 = 12; static const double s14 = 14; static const double s16 = 16; + static const double s18 = 18; static const double s20 = 20; + static const double s22 = 22; static const double s24 = 24; static const double s28 = 28; + + /// 웹 토큰에는 없다 — 앱 보드(`App1Login`)의 화면 아래 여백 + static const double s32 = 32; static const double s36 = 36; static const double s40 = 40; static const double s48 = 48; @@ -39,6 +44,10 @@ abstract final class AppRadius { /// `--radius-xl` — 대화상자 static const double xl = 12; + + // ── 앱 보드에서 정한 모서리 (`App1MailList` · `App1Shell` — 2026-11-05 확정) ── + /// [메일 쓰기] 단추 · 메일함 서랍의 오른쪽 모서리 · 확인 창 · 아래에서 올라오는 시트 + static const double xxl = 16; } /// 크기 @@ -52,4 +61,172 @@ abstract final class AppSize { /// 초점 고리 두께 — `--focus-ring-width` 2px static const double focusRingWidth = 2; + + // ── 앱 보드에서 정한 크기 (`App1Login` — 2026-11-05 확정) ── + /// 글자 입력 칸 높이 + static const double fieldHeight = 52; + + /// 인증 앱 코드 칸 높이 (글자가 크다) + static const double codeFieldHeight = 56; + + /// 위 띠 높이 — 로그인 다음 단계의 짙은 띠 + static const double topBandHeight = 56; + + /// 알약 모양 줄(로그인한 주소) 높이 + static const double chipHeight = 36; + + /// 작은 아이콘(단추 안 · 안내 상자) + static const double iconSmall = 18; + + /// 보통 아이콘(칸 안 [보기]) + static const double iconMedium = 20; + + /// 넓은 화면(태블릿 · 가로)에서 양식 열의 최대 폭 — 휴대폰(390)에서는 화면 폭을 다 쓴다 + static const double formMaxWidth = 480; + + // ── 앱 보드에서 정한 크기 (`App1MailList` · `App1Shell` — 2026-11-05 확정) ── + /// 위 막대 높이 — 목록의 머리 · 여러 통 고르기 막대 + static const double appBarHeight = 64; + + /// 아래 탭 막대 높이 + static const double navigationBarHeight = 72; + + /// 큰 아이콘 — 위 막대의 서랍 열기 · 찾기 · 선택 해제 + static const double iconLarge = 24; + + /// 도구 아이콘 — 고르기 막대 · 아래 탭 막대 · [메일 쓰기] + static const double iconToolbar = 22; + + /// 작은 표시 아이콘 — 목록 줄의 첨부 · 중요 + static const double iconTiny = 16; + + /// 목록 줄의 동그란 사진(머리글자) + static const double avatar = 40; + + /// 메일함 서랍의 폭 — 좁은 휴대폰에서는 화면 폭에서 [drawerMinGap] 을 남긴다 + static const double drawerWidth = 304; + + /// 메일함 서랍 오른쪽에 남기는 가림막 폭 (Material 서랍 규칙) + static const double drawerMinGap = 56; + + /// 빈 메일함 · 받지 못함의 아이콘 원 + static const double stateIcon = 72; + + /// 빈 메일함 · 받지 못함 원 안의 아이콘 + static const double stateIconGlyph = 30; + + /// 알림 줄(되돌리기)의 최소 높이 + static const double toastMinHeight = 52; + + /// [메일 쓰기] 단추 높이 (Material 넓은 단추 그대로) — 목록 아래 여백이 이만큼 + 40 이다 + static const double fabHeight = 56; + + // ── 앱 보드에서 정한 크기 (`App1Reader` — 2026-11-05 확정) ── + /// 읽기 화면의 위 막대 높이 — 뒤로 · 삭제 · 더 보기 + static const double readerBarHeight = 56; + + /// 읽기 화면의 아래 행동 막대 높이 — 답장 · 전체 답장 · 전달 + static const double readerActionBarHeight = 64; + + /// 첨부 줄의 최소 높이 + static const double attachmentRowMinHeight = 64; + + /// 첨부 줄의 파일 아이콘 칸 + static const double fileIconBox = 40; + + /// 첨부를 받는 동안의 진행 막대 두께 + static const double progressBarHeight = 4; + + /// 머리 펼침의 칸 이름 열 폭 ("보낸 사람" · "받는 사람" · "숨은참조") + static const double detailsLabelWidth = 64; + + /// 더 보기 메뉴의 폭 + static const double readerMenuWidth = 224; + + // ── 앱 보드에서 정한 크기 (`App1Compose` — 2026-11-05 확정) ── + /// 쓰기 화면의 칸 줄(받는 사람 · 참조 · 숨은참조 · 제목) 최소 높이 + static const double composeRowMinHeight = 56; + + /// 칸 줄 왼쪽의 이름 폭("받는 사람" · "숨은참조") + static const double composeLabelWidth = 72; + + /// 받는 사람 칩 높이 + static const double recipientChipHeight = 40; + + /// 받는 사람 칩의 ✕ — 누르는 곳 40 (밀도 예외 — 앱 디자인 README "크기 · 글꼴", 사용자 확정) + static const double recipientChipRemove = 40; + + /// 받는 사람 추천 줄의 최소 높이 + static const double suggestionRowMinHeight = 60; + + /// 받는 사람 추천 줄의 동그란 사진 + static const double suggestionAvatar = 36; + + /// 받는 사람 추천 목록의 최대 높이 — 넘으면 그 안에서 스크롤한다(자판 위 자리가 모자라면 더 줄인다) + static const double suggestionListMaxHeight = 300; + + /// 쓰기 화면의 첨부 줄 최소 높이 + static const double composeAttachmentRowMinHeight = 56; + + /// 쓰기 화면의 첨부 줄 파일 아이콘 칸 + static const double composeFileIconBox = 36; + + /// 본문 칸의 최소 높이 — 새 메일(원본 메일이 아래에 없다) + static const double composeBodyMinHeight = 240; + + /// 본문 칸의 최소 높이 — 답장 · 전달(아래에 서명 · 원본 메일) + static const double composeBodyMinHeightWithQuote = 150; + + /// 원본 메일 접힌 줄의 최소 높이 + static const double quoteToggleMinHeight = 56; + + /// 인용 막대 폭 — 원본 메일 · 원문 속 인용 + static const double quoteBarWidth = 2; + + /// 알림 안 남은 시간 막대의 두께 ([보내기 취소]) + static const double toastCountdownHeight = 3; + + // ── 앱 보드에서 정한 크기 (`App1Search` — 2026-11-07 확정) ── + /// 찾을 곳 줄의 높이 + static const double searchScopeRowHeight = 56; + + /// 찾을 곳 전환 칸의 보이는 높이 — 누르는 곳은 [touchMin] + static const double searchToggleHeight = 40; + + /// 결과 머리(개수 줄)의 높이 + static const double searchCountLineHeight = 36; + + // ── 앱 보드에서 정한 크기 (`App1SearchConditions` — 2026-11-07 확정) ── + /// 위 막대 [조건]의 보이는 원(시트가 열려 있거나 조건이 걸려 있을 때 옅은 청록) — 누르는 곳은 [touchMin] + static const double searchConditionsToggle = 40; + + /// 아래에서 올라오는 시트 위에 남기는 자리 — 시트를 끝까지 키워도 검색 칸이 보인다(보드의 시트 위 96) + static const double sheetTopGap = 96; + + /// 시트 손잡이(보이기만 한다 — 끌기는 시트 전체가 받는다) + static const double sheetHandleWidth = 32; + static const double sheetHandleHeight = 4; + + /// 시트의 [찾기]처럼 채운 주 단추의 최소 폭 + static const double primaryActionMinWidth = 120; + + // ── 앱 보드에서 정한 크기 (`App1Shell` · `App1Push` — 2026-11-07 확정) ── + /// 설정 화면의 두 줄 칸(내 정보 · 새 메일 알림)의 최소 높이 + static const double settingsRowMinHeight = 68; + + /// 설정 화면의 한 줄 칸(앱 버전 · 오픈소스 라이선스)의 최소 높이 + static const double settingsLineMinHeight = 52; + + /// 설정 화면 내 정보의 동그란 사진 + static const double settingsAvatar = 48; + + /// 스위치의 누르는 곳 — 보이는 바는 52×32(Material 3) + static const double switchTouchWidth = 64; + + /// 스위치 바의 테두리 + static const double switchOutlineWidth = 2; + + /// 둥근 진행 표시(켜는 중 · 끄는 중)의 지름과 고리 두께 + static const double progressRing = 22; + static const double progressRingStroke = 2.6; } diff --git a/flutter/lib/core/theme/app_theme.dart b/flutter/lib/core/theme/app_theme.dart index 45038b9..5ff7e50 100644 --- a/flutter/lib/core/theme/app_theme.dart +++ b/flutter/lib/core/theme/app_theme.dart @@ -97,10 +97,88 @@ abstract final class AppTheme { borderRadius: BorderRadius.circular(AppRadius.lg), ), ), + navigationBarTheme: _navigationBarTheme(scheme, textTheme), + switchTheme: _switchTheme(scheme), + // 아래에서 올라오는 시트(옮기기 · 조건으로 찾기)의 가림막 — 대화상자 가림막 토큰(`--color-backdrop`). Flutter 기본은 검정 54% + bottomSheetTheme: const BottomSheetThemeData( + modalBarrierColor: AppColors.backdrop, + ), + // 날짜 고르는 창 — 머리의 고른 날짜("11월 7일 (토)")는 22 · 600(보드 `App1SearchConditions`). Flutter 기본(headlineLarge 32)은 크다. + // 창의 가림막은 테마에 자리가 없어(대화상자 테마는 확인 창까지 바꾼다) 부르는 쪽이 넘긴다 + datePickerTheme: DatePickerThemeData( + headerHeadlineStyle: textTheme.headlineSmall?.copyWith( + fontWeight: AppTypography.semiBold, + ), + ), extensions: const [AppExtraColors.light], ); } + /// 아래 탭 막대 — 앱 보드 `App1Shell` · `App1MailList`(높이 72 · 고른 탭은 청록 바탕 알약 안의 아이콘 · 이름 12.5 → 테마 12). + /// 알약 크기는 Material 3 그대로(64×32 — 보드는 56×32)다. 위 선은 틀(`app_shell.dart`)이 그린다 + static NavigationBarThemeData _navigationBarTheme( + ColorScheme scheme, + TextTheme textTheme, + ) { + return NavigationBarThemeData( + height: AppSize.navigationBarHeight, + backgroundColor: scheme.surface, + surfaceTintColor: Colors.transparent, + elevation: 0, + indicatorColor: scheme.primaryContainer, + indicatorShape: const StadiumBorder(), + labelBehavior: NavigationDestinationLabelBehavior.alwaysShow, + labelTextStyle: WidgetStateProperty.resolveWith(( + Set states, + ) { + final bool selected = states.contains(WidgetState.selected); + return textTheme.labelSmall?.copyWith( + fontWeight: selected ? AppTypography.bold : AppTypography.medium, + color: selected ? scheme.onPrimaryContainer : scheme.onSurfaceVariant, + ); + }), + iconTheme: WidgetStateProperty.resolveWith( + (Set states) => IconThemeData( + size: AppSize.iconToolbar, + color: states.contains(WidgetState.selected) + ? scheme.onPrimaryContainer + : scheme.onSurfaceVariant, + ), + ), + ); + } + + /// 스위치 — 앱 보드 `App1Shell` · `App1Push`(52×32 바 · 테두리 2 · 손잡이 켜짐 24 · 꺼짐 16 은 Material 3 그대로). + /// 켜짐은 청록 바에 흰 손잡이, 꺼짐은 흰 바에 회색 테두리 · 손잡이, 누를 수 없으면 옅은 회색(흐리게 하지 않는다) + static SwitchThemeData _switchTheme(ColorScheme scheme) { + bool disabled(Set states) => + states.contains(WidgetState.disabled); + bool on(Set states) => states.contains(WidgetState.selected); + return SwitchThemeData( + thumbColor: WidgetStateProperty.resolveWith(( + Set states, + ) { + if (disabled(states)) return AppColors.switchThumbDisabled; + return on(states) ? scheme.onPrimary : scheme.outline; + }), + trackColor: WidgetStateProperty.resolveWith(( + Set states, + ) { + if (disabled(states)) return AppColors.surfaceMuted; + return on(states) ? scheme.primary : scheme.surface; + }), + trackOutlineColor: WidgetStateProperty.resolveWith(( + Set states, + ) { + if (disabled(states)) return AppColors.borderStrong; + return on(states) ? scheme.primary : scheme.outline; + }), + trackOutlineWidth: const WidgetStatePropertyAll( + AppSize.switchOutlineWidth, + ), + ); + } + static OutlineInputBorder _inputBorder( Color color, { double width = AppSize.borderWidth, @@ -159,23 +237,45 @@ class AppExtraColors extends ThemeExtension { const AppExtraColors({ required this.textMuted, required this.textBody, + required this.textStrong, required this.iconMuted, + required this.borderSubtle, + required this.borderStrong, + required this.onBrandMuted, + required this.onBrandLink, + required this.onBrandTrack, + required this.onBrandProgress, + required this.dangerBorder, required this.warningSurface, required this.warningText, required this.warningBorder, required this.successText, required this.star, + required this.rowChecked, + required this.brandTintBorder, + required this.searchMark, }); static const AppExtraColors light = AppExtraColors( textMuted: AppColors.textMuted, textBody: AppColors.textBody, + textStrong: AppColors.textStrong, iconMuted: AppColors.iconMuted, + borderSubtle: AppColors.borderSubtle, + borderStrong: AppColors.borderStrong, + onBrandMuted: AppColors.onBrandMuted, + onBrandLink: AppColors.onBrandLink, + onBrandTrack: AppColors.onBrandTrack, + onBrandProgress: AppColors.onBrandProgress, + dangerBorder: AppColors.dangerBorder, warningSurface: AppColors.warningSurface, warningText: AppColors.warningText, warningBorder: AppColors.warningBorder, successText: AppColors.successText, star: AppColors.star, + rowChecked: AppColors.rowChecked, + brandTintBorder: AppColors.brandTintBorder, + searchMark: AppColors.searchMark, ); /// 시각 · 개수 · 보조 설명 @@ -183,8 +283,30 @@ class AppExtraColors extends ThemeExtension { /// 메일 본문 final Color textBody; + + /// 입력 이름 · 도구 단추 · 연락처 + final Color textStrong; final Color iconMuted; + /// 옅은 나눔선 — 화면 맨 아래 안내 글 위 + final Color borderSubtle; + + /// 짙은 나눔선 — 쓰기 화면의 인용 막대 · 서명 위 점선 (`--color-border-strong`) + final Color borderStrong; + + /// 브랜드 바탕 위 보조 글 (로그인 첫 화면의 소개 글) + final Color onBrandMuted; + + /// 브랜드 바탕 위 링크 · 단추 ([다른 계정으로 로그인]) + final Color onBrandLink; + + /// 브랜드 바탕 알림 안의 남은 시간 막대 — 바탕 · 채움 ([보내기 취소]) + final Color onBrandTrack; + final Color onBrandProgress; + + /// 칸 안내 띠(바탕 `errorContainer`)의 아래 선 + final Color dangerBorder; + /// 노란 안내 줄 final Color warningSurface; final Color warningText; @@ -194,26 +316,57 @@ class AppExtraColors extends ThemeExtension { /// 중요 표시 final Color star; + /// 고른 행 (여러 통 처리) + final Color rowChecked; + + /// 여러 통 고르기 막대(바탕 `secondaryContainer`)의 아래 선 + final Color brandTintBorder; + + /// 검색에서 맞은 말의 바탕 (`--color-search-mark`) + final Color searchMark; + @override AppExtraColors copyWith({ Color? textMuted, Color? textBody, + Color? textStrong, Color? iconMuted, + Color? borderSubtle, + Color? borderStrong, + Color? onBrandMuted, + Color? onBrandLink, + Color? onBrandTrack, + Color? onBrandProgress, + Color? dangerBorder, Color? warningSurface, Color? warningText, Color? warningBorder, Color? successText, Color? star, + Color? rowChecked, + Color? brandTintBorder, + Color? searchMark, }) { return AppExtraColors( textMuted: textMuted ?? this.textMuted, textBody: textBody ?? this.textBody, + textStrong: textStrong ?? this.textStrong, iconMuted: iconMuted ?? this.iconMuted, + borderSubtle: borderSubtle ?? this.borderSubtle, + borderStrong: borderStrong ?? this.borderStrong, + onBrandMuted: onBrandMuted ?? this.onBrandMuted, + onBrandLink: onBrandLink ?? this.onBrandLink, + onBrandTrack: onBrandTrack ?? this.onBrandTrack, + onBrandProgress: onBrandProgress ?? this.onBrandProgress, + dangerBorder: dangerBorder ?? this.dangerBorder, warningSurface: warningSurface ?? this.warningSurface, warningText: warningText ?? this.warningText, warningBorder: warningBorder ?? this.warningBorder, successText: successText ?? this.successText, star: star ?? this.star, + rowChecked: rowChecked ?? this.rowChecked, + brandTintBorder: brandTintBorder ?? this.brandTintBorder, + searchMark: searchMark ?? this.searchMark, ); } @@ -223,12 +376,23 @@ class AppExtraColors extends ThemeExtension { return AppExtraColors( textMuted: Color.lerp(textMuted, other.textMuted, t)!, textBody: Color.lerp(textBody, other.textBody, t)!, + textStrong: Color.lerp(textStrong, other.textStrong, t)!, iconMuted: Color.lerp(iconMuted, other.iconMuted, t)!, + borderSubtle: Color.lerp(borderSubtle, other.borderSubtle, t)!, + borderStrong: Color.lerp(borderStrong, other.borderStrong, t)!, + onBrandMuted: Color.lerp(onBrandMuted, other.onBrandMuted, t)!, + onBrandLink: Color.lerp(onBrandLink, other.onBrandLink, t)!, + onBrandTrack: Color.lerp(onBrandTrack, other.onBrandTrack, t)!, + onBrandProgress: Color.lerp(onBrandProgress, other.onBrandProgress, t)!, + dangerBorder: Color.lerp(dangerBorder, other.dangerBorder, t)!, warningSurface: Color.lerp(warningSurface, other.warningSurface, t)!, warningText: Color.lerp(warningText, other.warningText, t)!, warningBorder: Color.lerp(warningBorder, other.warningBorder, t)!, successText: Color.lerp(successText, other.successText, t)!, star: Color.lerp(star, other.star, t)!, + rowChecked: Color.lerp(rowChecked, other.rowChecked, t)!, + brandTintBorder: Color.lerp(brandTintBorder, other.brandTintBorder, t)!, + searchMark: Color.lerp(searchMark, other.searchMark, t)!, ); } } diff --git a/flutter/lib/core/theme/app_typography.dart b/flutter/lib/core/theme/app_typography.dart index 58f2c1d..4fb9b8b 100644 --- a/flutter/lib/core/theme/app_typography.dart +++ b/flutter/lib/core/theme/app_typography.dart @@ -35,10 +35,22 @@ abstract final class AppTypography { /// 메일 본문 줄 간격 (웹 `--line-height-body` · 확정) static const double lineHeightBody = 1.75; + /// 여러 줄 안내 글 · 2단계 인증 키 (앱 보드 `App1Login` 의 1.6) + static const double lineHeightRelaxed = 1.6; + // ── 자간 (웹 `--letter-spacing-*` 의 em 값 — 크기를 곱해 dp 로 바꾼다) ── static const double letterSpacingTightEm = -0.01; static const double letterSpacingHeadingEm = -0.02; + /// 인증 앱 코드 칸 — 숫자 사이를 벌린다 (앱 보드 `App1Login` 의 0.35em) + static const double letterSpacingCodeEm = 0.35; + + /// 2단계 인증 키 — 고정폭 글자 사이 (앱 보드 `App1Login` 의 0.03em) + static const double letterSpacingKeyEm = 0.03; + + /// 고정폭 글꼴 — 2단계 인증 키. 기기의 고정폭 글꼴을 쓴다(Android 는 'monospace' 가 Droid Sans Mono 계열) + static const String monospaceFamily = 'monospace'; + // ── 굵기 — pubspec 에 넣은 Pretendard 넷과 같다 ── static const FontWeight regular = FontWeight.w400; static const FontWeight medium = FontWeight.w500; diff --git a/flutter/lib/core/utils/accessible_navigation.dart b/flutter/lib/core/utils/accessible_navigation.dart new file mode 100644 index 0000000..2689a09 --- /dev/null +++ b/flutter/lib/core/utils/accessible_navigation.dart @@ -0,0 +1,31 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 화면 낭독기(TalkBack 등)로 화면을 다루는 중인가 — 기기의 접근성 설정 `accessibleNavigation`. +/// +/// 단추가 있는 알림([되돌리기])은 이 동안 저절로 닫지 않는다 — Flutter SnackBar · 웹 알림의 붙잡기(hold)와 같은 결이다. +/// 화면 밖(노티파이어)에서 읽으려고 둔다. 시험은 `tester.platformDispatcher.accessibilityFeaturesTestValue` 로 바꾸거나 이것을 덮어쓴다. +final NotifierProvider +accessibleNavigationProvider = NotifierProvider( + AccessibleNavigation.new, +); + +/// 기기의 접근성 설정을 듣는다 +class AccessibleNavigation extends Notifier with WidgetsBindingObserver { + @override + bool build() { + final WidgetsBinding binding = WidgetsBinding.instance; + binding.addObserver(this); + ref.onDispose(() => binding.removeObserver(this)); + return _read(binding); + } + + @override + void didChangeAccessibilityFeatures() { + if (!ref.mounted) return; + state = _read(WidgetsBinding.instance); + } + + static bool _read(WidgetsBinding binding) => + binding.platformDispatcher.accessibilityFeatures.accessibleNavigation; +} diff --git a/flutter/lib/core/utils/app_foreground.dart b/flutter/lib/core/utils/app_foreground.dart new file mode 100644 index 0000000..ab67473 --- /dev/null +++ b/flutter/lib/core/utils/app_foreground.dart @@ -0,0 +1,24 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 앱이 앞에 보이는가 — 안드로이드의 앱 생명주기가 `resumed` 일 때만 참이다. +/// +/// 화면이 보이는 동안만 하는 일(새 메일 1분 확인 — 웹의 `visibilityState === 'visible'` 자리)이 듣는다. +/// 시험은 `tester.binding.handleAppLifecycleStateChanged` 로 바꾸거나 이 프로바이더를 덮어쓴다. +final NotifierProvider appForegroundProvider = + NotifierProvider(AppForeground.new); + +/// 앱 생명주기를 듣는다 +class AppForeground extends Notifier { + @override + bool build() { + final AppLifecycleListener listener = AppLifecycleListener( + onStateChange: (AppLifecycleState lifecycle) => + state = lifecycle == AppLifecycleState.resumed, + ); + ref.onDispose(listener.dispose); + final AppLifecycleState? current = WidgetsBinding.instance.lifecycleState; + // 아직 생명주기를 받기 전(앱을 막 띄움 · 시험)은 앞에 보이는 것으로 본다 + return current == null || current == AppLifecycleState.resumed; + } +} diff --git a/flutter/lib/core/utils/display_text.dart b/flutter/lib/core/utils/display_text.dart new file mode 100644 index 0000000..ef39450 --- /dev/null +++ b/flutter/lib/core/utils/display_text.dart @@ -0,0 +1,40 @@ +/// 화면에 보이기 전에 글을 다듬는 순수 함수 — 문의 연락처 한 줄 · 2단계 인증 키 줄. +library; + +import 'dart:math' as math; + +/// 문의 연락처의 길이 상한 — 서버 설정값과 같은 200자 (계약 6절 "문의 연락처") +const int kSupportContactMaxLength = 200; + +final RegExp _whitespaceRun = RegExp(r'\s+'); + +/// 문의 연락처 — 서버 설정값을 화면에 보일 한 줄로 다듬는다(웹 `support-contact.ts` 와 같다). +/// +/// 줄바꿈 · 연속 공백은 띄어쓰기 하나, 앞뒤 공백은 지우고, 200자에서 자른다. 글자로만 보인다(링크 없음). +/// 빈 글 · 글이 아닌 값은 null — 정하지 않은 것으로 보고 화면은 지금 글 그대로다. +String? supportContactTextOf(Object? value) { + if (value is! String) return null; + final String line = value.replaceAll(_whitespaceRun, ' ').trim(); + if (line.isEmpty) return null; + return line.length > kSupportContactMaxLength + ? line.substring(0, kSupportContactMaxLength) + : line; +} + +/// 2단계 인증 키를 끊어 보이는 크기 — 4자씩, 한 줄에 4묶음 (32자 키는 두 줄 — 앱 보드 `App1Login`, 웹과 같다) +const int kKeyGroupSize = 4; +const int kKeyGroupsPerLine = 4; + +/// 2단계 인증 키의 줄 — "K7QF 2M4X HB6R TZ3N" 꼴. 보기 위한 띄어쓰기다 — [복사]는 키를 띄어쓰기 없이 복사한다 +List keyLines(String secret) { + final List groups = [ + for (int start = 0; start < secret.length; start += kKeyGroupSize) + secret.substring(start, math.min(start + kKeyGroupSize, secret.length)), + ]; + return [ + for (int start = 0; start < groups.length; start += kKeyGroupsPerLine) + groups + .sublist(start, math.min(start + kKeyGroupsPerLine, groups.length)) + .join(' '), + ]; +} diff --git a/flutter/lib/core/utils/file_size.dart b/flutter/lib/core/utils/file_size.dart new file mode 100644 index 0000000..2ba80d3 --- /dev/null +++ b/flutter/lib/core/utils/file_size.dart @@ -0,0 +1,44 @@ +/// 파일 크기 표기 — 웹 `shared/utils/format.ts` 의 `formatFileSize` · `formatMegabytes` 와 같은 글(순수 함수). +/// +/// "248 KB" · "1.2 MB" · "38 MB" — 1KB 미만은 바이트 그대로, 그 위는 KB · MB · GB · TB. KB 는 정수, MB 이상은 10 미만일 때 소수 한 자리. +/// 한도 글은 "20MB"(붙여 씀 — [formatMegabytes]). +library; + +const int _bytesPerUnit = 1024; +const List _units = ['KB', 'MB', 'GB', 'TB']; +const int _decimalThreshold = 10; + +/// 크기 표기 — 음수면 빈 글 +String formatFileSize(int bytes) { + if (bytes < 0) return ''; + if (bytes < _bytesPerUnit) return '$bytes B'; + double value = bytes / _bytesPerUnit; + int unit = 0; + // 반올림 결과가 1024 가 되면("1024 KB") 다음 단위로 올린다 + while (value.round() >= _bytesPerUnit && unit < _units.length - 1) { + value /= _bytesPerUnit; + unit++; + } + final String shown = unit > 0 && value < _decimalThreshold + ? _oneDecimal(value) + : value.round().toString(); + return '$shown ${_units[unit]}'; +} + +/// 한도 글의 크기 — "20MB" · "25MB" · "20.5MB" (웹 `format.ts` 의 `formatMegabytes` 와 같은 글 — 정수 MB 면 그대로, 아니면 소수 한 자리). +/// 첨부 한도 안내 · 한도를 넘은 알림에 쓴다(서버가 알려 준 바이트 — `GET /api/mail-quota`) +String formatMegabytes(int bytes) { + final double megabytes = bytes / (_bytesPerUnit * _bytesPerUnit); + final String shown = megabytes == megabytes.roundToDouble() + ? megabytes.round().toString() + : _oneDecimal(megabytes); + return '${shown}MB'; +} + +/// 소수 한 자리 — 끝의 ".0" 은 뺀다(웹 `Math.round(value * 10) / 10` 이 내는 글과 같다) +String _oneDecimal(double value) { + final double rounded = (value * 10).round() / 10; + return rounded == rounded.roundToDouble() + ? rounded.round().toString() + : rounded.toStringAsFixed(1); +} diff --git a/flutter/lib/core/utils/js_case.dart b/flutter/lib/core/utils/js_case.dart new file mode 100644 index 0000000..084a5cd --- /dev/null +++ b/flutter/lib/core/utils/js_case.dart @@ -0,0 +1,12 @@ +/// 대소문자를 웹 · 서버(JS)에 가깝게 낮춘다 — 받는 사람 추천(`core/people`) · 메일 검색의 접기(`features/mail/domain/search_text.dart`)가 함께 쓴다. +library; + +/// JS 의 `String.prototype.toLowerCase` 를 흉내 낸다 — 'İ'(U+0130) 는 두 글자(i + 윗점 U+0307)로 낮춘다. +/// Dart 의 `toLowerCase` 는 'i' 한 글자로 낮춰, 길이가 달라지는 글자를 웹 · 서버와 다르게 맞추게 된다 +/// (그 밖에 글자 수를 바꾸며 낮추는 글자는 없다 — 유니코드 SpecialCasing 의 조건 없는 소문자는 이 하나뿐이다). +/// +/// **JS 와 완전히 같지는 않다**(2026-11-07 잼): Dart 와 JS(V8 · ICU)는 유니코드 자료의 판이 달라 437자를 다르게 낮춘다 — +/// 대부분 Unicode 16 에 새로 든 글자다(한글 · 한자 · 라틴 기본 글자는 같다). 알려진 한계 — 받는 사람 추천에서는 그 글자들의 +/// 대소문자를 웹과 다르게 맞출 수 있고, 메일 검색은 한 글자 판정에만 써서 결과에는 영향이 없다(맞추기는 서버가 한다) +String jsLowerCase(String value) => + value.replaceAll('\u0130', 'i\u0307').toLowerCase(); diff --git a/flutter/lib/core/utils/korean_format.dart b/flutter/lib/core/utils/korean_format.dart new file mode 100644 index 0000000..76493e0 --- /dev/null +++ b/flutter/lib/core/utils/korean_format.dart @@ -0,0 +1,58 @@ +/// 화면에 보이는 숫자 · 시각 표기 — 웹 `shared/utils/format.ts` 의 ko-KR 표기와 같은 글을 손으로 만든다(순수 함수). +/// +/// `intl` 의 날짜 형식은 로캘 자료를 먼저 읽어야 해서(앱은 MaterialApp 이 읽는다) 순수 함수 시험에서 쓰기 번거롭다 — +/// 웹 `Intl.DateTimeFormat('ko-KR')` 이 내는 글 그대로를 여기서 만든다. 시각은 **기기 시간대**로 바꾼 값(`toLocal()`)을 받는다. +library; + +/// 개수 — 1234 → "1,234" (웹 `formatCount` 와 같다) +String formatCount(int value) { + final String digits = value.abs().toString(); + final StringBuffer out = StringBuffer(value < 0 ? '-' : ''); + for (int index = 0; index < digits.length; index++) { + if (index > 0 && (digits.length - index) % _digitGroup == 0) { + out.write(','); + } + out.write(digits[index]); + } + return out.toString(); +} + +const int _digitGroup = 3; +const int _hoursHalfDay = 12; + +/// 하루 안의 시각 — "오전 10:42" · "오후 3:05" · "오전 12:30" (웹 `{ hour: 'numeric', minute: '2-digit' }` 와 같다) +String koreanTimeOfDay(DateTime local) { + final String half = local.hour < _hoursHalfDay ? '오전' : '오후'; + final int hour = local.hour % _hoursHalfDay == 0 + ? _hoursHalfDay + : local.hour % _hoursHalfDay; + final String minute = local.minute.toString().padLeft(2, '0'); + return '$half $hour:$minute'; +} + +/// 달 · 날 — "11월 3일" (웹 `{ month: 'long', day: 'numeric' }`) +String koreanMonthDay(DateTime local) => '${local.month}월 ${local.day}일'; + +/// 연도까지 숫자로 — "2025. 10. 24." (웹 `{ year · month · day: 'numeric' }`) +String koreanNumericDate(DateTime local) => + '${local.year}. ${local.month}. ${local.day}.'; + +/// 요일 한 글자 — 월 · 화 · … · 일 (웹 `{ weekday: 'short' }`) +String koreanWeekday(DateTime local) => + const ['월', '화', '수', '목', '금', '토', '일'][local.weekday - 1]; + +/// 달 · 날 · 요일 · 시각 — "11월 7일 (토) 오전 9:00" (웹 `formatScheduleTime` — 예약 시각. 7일 안이라 연도가 없다) +String koreanScheduleTime(DateTime local) => + '${koreanMonthDay(local)} (${koreanWeekday(local)}) ${koreanTimeOfDay(local)}'; + +/// 연도까지 — "2026년 11월 4일 (수) 오후 4:20" (앱 보드 `App1Reader` 의 머리 펼침 "날짜") +String koreanFullDateTime(DateTime local) => + '${local.year}년 ${koreanScheduleTime(local)}'; + +/// 같은 달력 날인가 (둘 다 같은 시간대로 바꾼 값) +bool isSameCalendarDay(DateTime a, DateTime b) => + a.year == b.year && a.month == b.month && a.day == b.day; + +/// 하루 전 달력 날 — 달 · 해의 경계를 넘는다(1월 1일 → 12월 31일) +DateTime previousCalendarDay(DateTime local) => + DateTime(local.year, local.month, local.day - 1); diff --git a/flutter/lib/core/utils/korean_text.dart b/flutter/lib/core/utils/korean_text.dart new file mode 100644 index 0000000..78349e2 --- /dev/null +++ b/flutter/lib/core/utils/korean_text.dart @@ -0,0 +1,133 @@ +/// 한국어 글 다듬기 — 줄 나눔(웹 `word-break: keep-all` — 앱 보드 `App1Login` 의 안내 글)과 +/// 이름 뒤의 조사(받침에 맞춰 "이/가" · "으로/로" — 웹 `shared/utils/korean.ts` 와 같다. "은/는" 은 앱에서 더했다). +library; + +/// WORD JOINER — 보이지 않고, 그 앞뒤에서 줄을 나누지 못하게 한다(유니코드 줄 나눔 규칙 WJ) +const int _wordJoiner = 0x2060; + +/// 낱말 가운데서 줄이 바뀌지 않게 한다 — 줄은 띄어쓰기에서만 바뀐다. +/// +/// Flutter 의 줄 나눔(ICU)은 한글 글자 사이마다 줄을 바꿀 수 있어 "자동으 / 로" 처럼 낱말이 갈린다(Flutter 에 keep-all 이 아직 없다). +/// 한글 글자와 그 이웃(한글 · 영문 · 숫자 · ASCII 기호) 사이에 WORD JOINER 를 넣는다 — "Outlook이나" · "[설정]에서" 도 한 낱말로 남는다. +/// 그 밖의 글자(이모지 · 결합 문자 등) 옆에는 넣지 않는다(글자 묶음을 가르지 않게). 한 낱말이 한 줄보다 길면 Flutter 가 그 안에서 끊는다. +/// 화면 낭독기에는 원래 글을 준다(`KeepAllText` 의 `semanticsLabel`). +String keepAllWordBreak(String text) { + final StringBuffer out = StringBuffer(); + int? previous; + for (final int rune in text.runes) { + if (previous != null && + _joinable(previous) && + _joinable(rune) && + (_isHangulSyllable(previous) || _isHangulSyllable(rune))) { + out.writeCharCode(_wordJoiner); + } + out.writeCharCode(rune); + previous = rune; + } + return out.toString(); +} + +bool _isHangulSyllable(int rune) => rune >= _hangulFirst && rune <= _hangulLast; + +const int _hangulFirst = 0xAC00; +const int _hangulLast = 0xD7A3; + +/// 한글 음절 하나의 받침 경우의 수(받침 없음 포함) +const int _jongseongCount = 28; +const int _noJongseong = 0; + +/// ㄹ 받침 — 방향 조사가 "로"다(서울로 · 메일로) +const int _jongseongRieul = 8; + +/// 한글 호환 자모의 자음 ㄱ~ㅎ(겹자음 포함) · 모음 ㅏ~ㅣ (U+3131~U+3163) +const int _jamoConsonantFirst = 0x3131; +const int _jamoConsonantLast = 0x314E; +const int _jamoVowelFirst = 0x314F; +const int _jamoVowelLast = 0x3163; + +/// 자음 ㄹ(리을) — 자음 가운데 방향 조사가 "로"인 것 +const int _jamoRieul = 0x3139; + +/// 숫자 0~9 (ASCII) +const int _digitZero = 0x30; +const int _digitNine = 0x39; + +/// 끝소리 — 조사를 고르는 데 쓴다 +enum _Coda { + /// 받침 없음 (프로젝트 · ㅏ · 2) + none, + + /// ㄹ 받침 — 방향 조사가 "로"다 (메일 · ㄹ · 1) + rieul, + + /// 그 밖의 받침 (휴지통 · ㄱ · 0) + other, +} + +/// 숫자 0~9 의 끝소리 — 읽는 소리 영 · 일 · 이 · 삼 · 사 · 오 · 육 · 칠 · 팔 · 구 +const List<_Coda> _digitCodas = <_Coda>[ + _Coda.other, // 영 + _Coda.rieul, // 일 + _Coda.none, // 이 + _Coda.other, // 삼 + _Coda.none, // 사 + _Coda.none, // 오 + _Coda.other, // 육 + _Coda.rieul, // 칠 + _Coda.rieul, // 팔 + _Coda.none, // 구 +]; + +/// 마지막 글자의 끝소리 — 앞뒤 공백은 떼고 본다(따옴표 · 괄호는 떼지 않는다). 소리를 알 수 없으면(라틴 글자 · 기호 · 빈 글) null +/// - 한글 음절: 그 받침 +/// - 한글 호환 자모: 자음은 받침 있음(이름 기역 · 니은 · 리을 … 이 모두 받침으로 끝난다 — ㄹ 은 [_Coda.rieul]), 모음은 받침 없음 +/// - 숫자: 읽는 소리로 ([_digitCodas]) +/// (웹 `korean.ts` 와 같은 규칙 — 2026-11-07 자모 · 숫자를 함께 넓혔다) +_Coda? _lastCoda(String word) { + final String trimmed = word.trim(); + if (trimmed.isEmpty) return null; + final int last = trimmed.runes.last; + if (_isHangulSyllable(last)) { + final int jongseong = (last - _hangulFirst) % _jongseongCount; + if (jongseong == _noJongseong) return _Coda.none; + return jongseong == _jongseongRieul ? _Coda.rieul : _Coda.other; + } + if (last >= _jamoConsonantFirst && last <= _jamoConsonantLast) { + return last == _jamoRieul ? _Coda.rieul : _Coda.other; + } + if (last >= _jamoVowelFirst && last <= _jamoVowelLast) return _Coda.none; + if (last >= _digitZero && last <= _digitNine) { + return _digitCodas[last - _digitZero]; + } + return null; +} + +/// 주격 조사 "이/가" — 받침이 있으면 "이", 없으면 "가"(휴지통이 · 받은편지함이 · 프로젝트가 · ‘ㄱㅁ’이 · 2가). +/// 소리를 알 수 없는 끝 글자(라틴 글자 · 기호)면 "이(가)" 다 (웹 `korean.ts` 의 `subjectParticle` 과 같다). +String subjectParticle(String word) => switch (_lastCoda(word)) { + null => '이(가)', + _Coda.none => '가', + _Coda.rieul || _Coda.other => '이', +}; + +/// 방향 조사 "으로/로" — 받침이 있으면 "으로", 없거나 ㄹ 받침이면 "로"(휴지통으로 · 보낸편지함으로 · 프로젝트로 · 프로젝트1로). +/// 소리를 알 수 없는 끝 글자면 "(으)로" 다 (웹 `korean.ts` 의 `directionParticle` 과 같다). +String directionParticle(String word) => switch (_lastCoda(word)) { + null => '(으)로', + _Coda.none || _Coda.rieul => '로', + _Coda.other => '으로', +}; + +/// 보조사 "은/는" — 받침이 있으면 "은", 없으면 "는"(사진은 · 보고서는 · 외 2개는). +/// 소리를 알 수 없는 끝 글자(라틴 글자 · 기호 — 확장자로 끝나는 파일 이름)면 "은(는)" 이다 +/// (앱에서 더함 — 웹 `korean.ts` 에는 없다. [subjectParticle] 과 같은 규칙) +String topicParticle(String word) => switch (_lastCoda(word)) { + null => '은(는)', + _Coda.none => '는', + _Coda.rieul || _Coda.other => '은', +}; + +/// 띄어쓰기 · 제어 문자를 뺀 ASCII 글자 — 영문 · 숫자 · 기호 +bool _isAsciiGraphic(int rune) => rune >= 0x21 && rune <= 0x7E; + +bool _joinable(int rune) => _isHangulSyllable(rune) || _isAsciiGraphic(rune); diff --git a/flutter/lib/core/utils/otp_code.dart b/flutter/lib/core/utils/otp_code.dart new file mode 100644 index 0000000..1581d25 --- /dev/null +++ b/flutter/lib/core/utils/otp_code.dart @@ -0,0 +1,33 @@ +/// 인증 앱 코드 — 숫자 6자리 · 30초 (계약 5절 "한도 · 규칙"). 웹 `frontend/src/shared/utils/account-security.ts` 와 같은 규칙이다. +library; + +/// 코드의 자릿수 +const int kOtpCodeLength = 6; + +/// 전각 숫자 0 ~ 9 의 첫 글자 — 반각 숫자로 바꾼다(웹은 NFKC 로 같은 일을 한다) +const int _fullWidthZero = 0xFF10; +const int _fullWidthNine = 0xFF19; +const int _asciiZero = 0x30; +const int _asciiNine = 0x39; + +/// 코드 칸에 들어온 글을 다듬는다 — 숫자만 남기고 6자리에서 자른다. +/// +/// 붙여 넣은 "123 456" · "123-456" 과 전각 숫자도 받는다(계약 5절 "로그인" 클라이언트). +/// 칸의 글자 수 한도(maxLength)를 따로 걸지 않는 까닭: 한도가 먼저 잘라 "123 45" 만 남는다. +String normalizeOtpInput(String raw) { + final StringBuffer digits = StringBuffer(); + for (final int rune in raw.runes) { + if (digits.length == kOtpCodeLength) break; + if (rune >= _asciiZero && rune <= _asciiNine) { + digits.writeCharCode(rune); + } else if (rune >= _fullWidthZero && rune <= _fullWidthNine) { + digits.writeCharCode(_asciiZero + rune - _fullWidthZero); + } + } + return digits.toString(); +} + +final RegExp _completeOtp = RegExp('^[0-9]{$kOtpCodeLength}\$'); + +/// 숫자 6자리를 다 넣었는가 — 서버도 같은 모양만 받는다(아니면 400 `VAL_001`) +bool isCompleteOtp(String code) => _completeOtp.hasMatch(code); diff --git a/flutter/lib/core/utils/password_rules.dart b/flutter/lib/core/utils/password_rules.dart new file mode 100644 index 0000000..6078d52 --- /dev/null +++ b/flutter/lib/core/utils/password_rules.dart @@ -0,0 +1,61 @@ +/// 비밀번호 바꾸기 — 보내기 전에 서버와 같은 규칙을 먼저 본다 (계약 1절 "PUT /api/auth/password"). +/// +/// 웹 `frontend/src/shared/utils/password-change.ts` 와 같은 규칙이다: 8자 이상 · 지금 비밀번호와 다름 · 확인 칸 일치, +/// 128자를 넘으면 서버가 `VAL_001` 로 거절하므로 그것도 먼저 막는다. 공백도 비밀번호의 글자다 — 다듬지 않는다. +/// 길이는 서버와 같이 UTF-16 코드 단위로 센다(Dart `String.length` 가 JS 와 같다). +library; + +/// 새 비밀번호의 최소 길이 +const int kPasswordMinLength = 8; + +/// 새 비밀번호의 최대 길이 +const int kPasswordMaxLength = 128; + +/// 로그인 · 지금 비밀번호의 최대 길이 — 넘으면 서버가 `VAL_001` (계약 1절) +const int kLoginPasswordMaxLength = 256; + +/// 새 비밀번호 칸의 문제 +enum NewPasswordProblem { + /// 8자 미만이거나 지금 비밀번호와 같다 — 안내 글이 그대로 빨간 오류가 된다 + rule, + + /// 128자 초과 + tooLong, +} + +/// 칸마다 하나 — 없으면 null +class PasswordChangeProblems { + const PasswordChangeProblems({ + this.currentMissing = false, + this.next, + this.confirmMismatch = false, + }); + + /// 지금(임시) 비밀번호를 비웠다 + final bool currentMissing; + final NewPasswordProblem? next; + + /// 확인 칸이 새 비밀번호와 다르다 + final bool confirmMismatch; + + bool get hasAny => currentMissing || next != null || confirmMismatch; +} + +/// 세 칸을 본다 — [current] 는 쥔 비밀번호(로그인 때 넣은 것)이거나 칸에 넣은 임시 비밀번호 +PasswordChangeProblems passwordChangeProblems({ + required String current, + required String next, + required String confirm, +}) { + NewPasswordProblem? nextProblem; + if (next.length > kPasswordMaxLength) { + nextProblem = NewPasswordProblem.tooLong; + } else if (next.length < kPasswordMinLength || next == current) { + nextProblem = NewPasswordProblem.rule; + } + return PasswordChangeProblems( + currentMissing: current.isEmpty, + next: nextProblem, + confirmMismatch: confirm != next, + ); +} diff --git a/flutter/lib/core/utils/utf16_length.dart b/flutter/lib/core/utils/utf16_length.dart new file mode 100644 index 0000000..b4dc7a6 --- /dev/null +++ b/flutter/lib/core/utils/utf16_length.dart @@ -0,0 +1,53 @@ +/// 글 길이를 UTF-16 단위로 다룬다 — 서버(`MaxCodeUnits`) · 웹(`maxlength` · `.length`)과 같은 단위다. +/// +/// Flutter 의 `LengthLimitingTextInputFormatter` 는 글자(grapheme) 단위로 세어 이모지가 든 글을 서버보다 길게 받아 준다 — +/// 서버와 같은 한도를 거는 칸은 이 포매터를 쓴다. +library; + +import 'package:flutter/services.dart'; + +/// 앞에서 [maxLength](UTF-16)까지 — 높은 짝 글자에서 끝나면 그 앞에서 자른다(짝 글자를 가르지 않는다) +String truncateUtf16(String text, int maxLength) { + if (text.length <= maxLength) return text; + int end = maxLength < 0 ? 0 : maxLength; + if (end > 0) { + final int last = text.codeUnitAt(end - 1); + if (last >= 0xD800 && last <= 0xDBFF) end--; + } + return text.substring(0, end); +} + +/// 칸에 치는 글을 UTF-16 [maxLength] 까지 막는 입력 포매터. +/// +/// - 한글을 조합하는 동안은 막지 않고 조합이 끝나면 자른다(조합 중인 글자를 깨지 않게 — 안드로이드의 기본과 같은 결). +/// - 이미 한도만큼 차 있고 커서만 있으면 더 치는 글을 받지 않는다(가운데에 친 글자가 끝의 글자를 밀어내지 않게 — Flutter 와 같다). +/// - 그 밖에 넘으면 뒤를 자른다(붙여 넣기). 프로그램이 넣은 글(저장본 · mailto)은 포매터를 거치지 않으므로 보내기 전 검사가 막는다 +class Utf16LengthLimitFormatter extends TextInputFormatter { + const Utf16LengthLimitFormatter(this.maxLength); + + final int maxLength; + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + if (newValue.composing.isValid || newValue.text.length <= maxLength) { + return newValue; + } + if (oldValue.text.length == maxLength && oldValue.selection.isCollapsed) { + return oldValue; + } + final String text = truncateUtf16(newValue.text, maxLength); + final TextSelection selection = newValue.selection; + return TextEditingValue( + text: text, + selection: selection.isValid + ? selection.copyWith( + baseOffset: selection.baseOffset.clamp(0, text.length), + extentOffset: selection.extentOffset.clamp(0, text.length), + ) + : TextSelection.collapsed(offset: text.length), + ); + } +} diff --git a/flutter/lib/features/app_update/data/app_store_launcher.dart b/flutter/lib/features/app_update/data/app_store_launcher.dart new file mode 100644 index 0000000..6853542 --- /dev/null +++ b/flutter/lib/features/app_update/data/app_store_launcher.dart @@ -0,0 +1,52 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// 다른 앱으로 주소를 넘기는 함수 — 기본은 url_launcher 의 [launchUrl]. 시험은 가짜를 넣는다 +typedef StoreLaunch = Future Function(Uri url, LaunchMode mode); + +Future _launchExternal(Uri url, LaunchMode mode) => + launchUrl(url, mode: mode); + +/// Play 스토어의 이 앱 쪽 주소(앱이 없을 때 여는 웹 주소) +const String kPlayStoreWebDetails = + 'https://play.google.com/store/apps/details'; + +/// [Play 스토어 열기] — 이 앱의 Play 스토어 쪽을 연다(업데이트 화면). +/// +/// 먼저 Play 스토어 앱(`market://details?id=…` — 브라우저가 아닌 앱만), 열리지 않으면 웹 주소를 바깥 앱(브라우저)으로. +/// 패키지 이름은 앱 정보에서 읽은 값이다(하드코딩하지 않는다). `canLaunchUrl` 은 쓰지 않는다(debug 판이 주소를 로그에 남긴다 — +/// 인증 앱 열기와 같은 규칙). 못 열면 플러그인 예외를 버리고 거짓 — 화면이 알린다 +class AppStoreLauncher { + const AppStoreLauncher({StoreLaunch launch = _launchExternal}) + : _launch = launch; + + final StoreLaunch _launch; + + /// 열었으면 참 + Future open(String packageName) async { + final Uri market = Uri( + scheme: 'market', + host: 'details', + queryParameters: {'id': packageName}, + ); + if (await _tryLaunch(market, LaunchMode.externalNonBrowserApplication)) { + return true; + } + final Uri web = Uri.parse( + kPlayStoreWebDetails, + ).replace(queryParameters: {'id': packageName}); + return _tryLaunch(web, LaunchMode.externalApplication); + } + + Future _tryLaunch(Uri url, LaunchMode mode) async { + try { + return await _launch(url, mode); + } on Object { + return false; + } + } +} + +/// [Play 스토어 열기] — 시험은 가짜로 덮어쓴다 +final Provider appStoreLauncherProvider = + Provider((Ref ref) => const AppStoreLauncher()); diff --git a/flutter/lib/features/app_update/presentation/app_update_texts.dart b/flutter/lib/features/app_update/presentation/app_update_texts.dart new file mode 100644 index 0000000..bc27ef6 --- /dev/null +++ b/flutter/lib/features/app_update/presentation/app_update_texts.dart @@ -0,0 +1,22 @@ +/// 업데이트 화면의 글 — 앱 보드 `App1Push` ⑩(2026-11-07 확정)의 글 그대로다. 제목은 `core/constants/screen_titles.dart` 의 `ScreenTitles.update`. +/// +/// 화면 글은 디자인 README 「화면 문구 — 사용자에게 말하듯」 말투를 따른다. +abstract final class AppUpdateTexts { + static const String message = + '지금 쓰는 버전으로는 로그인하거나 메일을 볼 수 없습니다. Play 스토어에서 ARA SYSTEM을 업데이트한 뒤 다시 열어 주세요.'; + static const String openStore = 'Play 스토어 열기'; + + /// 지금 쓰는 판 — "지금 쓰는 버전 1.0.0" + static String currentVersion(String version) => '지금 쓰는 버전 $version'; + + /// 문의 줄 — 연락처(`GET /api/support-contact`)가 있으면 물음 다음 줄에 연락처 + static const String supportQuestion = 'Play 스토어에 업데이트가 보이지 않나요?'; + + /// 연락처를 정하지 않았거나 받지 못했을 때(보드에 없는 경우 — 로그인 화면의 문의 줄과 같은 꼴) + static const String supportFallback = + 'Play 스토어에 업데이트가 보이지 않나요? 관리자에게 문의해 주세요.'; + + /// Play 스토어를 열지 못했을 때(알림) + static const String openStoreFailed = + 'Play 스토어를 열지 못했습니다. Play 스토어 앱을 직접 열어 ARA SYSTEM을 찾아 업데이트해 주세요.'; +} diff --git a/flutter/lib/features/app_update/presentation/pages/app_update_page.dart b/flutter/lib/features/app_update/presentation/pages/app_update_page.dart new file mode 100644 index 0000000..d63e1ed --- /dev/null +++ b/flutter/lib/features/app_update/presentation/pages/app_update_page.dart @@ -0,0 +1,121 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/constants/app_info_provider.dart'; +import 'package:arasystem_app/core/constants/screen_titles.dart'; +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/theme/app_theme.dart'; +import 'package:arasystem_app/core/theme/app_typography.dart'; +import 'package:arasystem_app/features/app_update/data/app_store_launcher.dart'; +import 'package:arasystem_app/features/app_update/presentation/app_update_texts.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/login_providers.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_frame.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_parts.dart'; +import 'package:arasystem_app/shared/widgets/keep_all_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 업데이트 화면 — 앱 보드 `App1Push` ⑩. 이 앱의 판이 서버의 최소 판보다 낮으면 라우터가 어느 화면에서든 이리로 옮긴다 +/// (로그인 전 화면도 — 옛 앱이 로그인 API 를 잘못 부르지 않게. `core/router/session_redirect.dart`). +/// +/// - 로그인 첫 단계처럼 큰 브랜드 띠(로고만) 아래에 제목 · 글 · [Play 스토어 열기] · 지금 쓰는 판 · 문의 줄. +/// - [Play 스토어 열기]: Play 스토어 앱 → 안 되면 웹 주소. 열지 못하면 알린다. +/// - 뒤로 가기는 앱을 닫는다(이 화면으로는 `go` 로만 오니 쌓인 화면이 없다). 업데이트하면 Play 가 앱을 끝내고, +/// 서버가 최소 판을 내리면 다음 확인에서 세션이 고른 화면으로 돌아간다. +class AppUpdatePage extends ConsumerStatefulWidget { + const AppUpdatePage({super.key}); + + @override + ConsumerState createState() => _AppUpdatePageState(); +} + +class _AppUpdatePageState extends ConsumerState { + @override + void initState() { + super.initState(); + unawaited(ref.read(supportContactProvider.notifier).ensureLoaded()); + } + + Future _openStore() async { + final ScaffoldMessengerState messenger = ScaffoldMessenger.of(context); + final String? packageName = ref.read(appPackageNameProvider); + final bool opened = + packageName != null && + await ref.read(appStoreLauncherProvider).open(packageName); + if (opened) return; + messenger + ..hideCurrentSnackBar() + ..showSnackBar( + const SnackBar(content: KeepAllText(AppUpdateTexts.openStoreFailed)), + ); + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + final String? version = ref.watch(appVersionProvider); + return LoginBrandFrame( + introduction: false, + contentTopPadding: AppSpacing.s32, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: AppSpacing.s10, + children: [ + Semantics( + header: true, + child: Text( + ScreenTitles.update, + style: theme.textTheme.headlineSmall, + ), + ), + KeepAllText( + AppUpdateTexts.message, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + height: AppTypography.lineHeightRelaxed, + ), + ), + ], + ), + FilledButton( + onPressed: () => unawaited(_openStore()), + child: const Row( + mainAxisSize: MainAxisSize.min, + spacing: AppSpacing.s8, + children: [ + Text(AppUpdateTexts.openStore), + Icon(Icons.open_in_new, size: AppSize.iconSmall), + ], + ), + ), + if (version != null) + Text( + AppUpdateTexts.currentVersion(version), + style: theme.textTheme.bodySmall?.copyWith( + color: extra.textMuted, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + DecoratedBox( + decoration: BoxDecoration( + border: Border( + top: BorderSide( + color: extra.borderSubtle, + width: AppSize.borderWidth, + ), + ), + ), + child: Padding( + padding: const EdgeInsets.only(top: AppSpacing.s16), + child: LoginSupportLine( + question: AppUpdateTexts.supportQuestion, + fallback: AppUpdateTexts.supportFallback, + contact: ref.watch(supportContactProvider), + ), + ), + ), + ], + ); + } +} diff --git a/flutter/lib/features/auth/data/authenticator_launcher.dart b/flutter/lib/features/auth/data/authenticator_launcher.dart new file mode 100644 index 0000000..aec65b9 --- /dev/null +++ b/flutter/lib/features/auth/data/authenticator_launcher.dart @@ -0,0 +1,37 @@ +import 'package:arasystem_app/features/auth/data/models/two_factor_enrollment.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// 다른 앱으로 주소를 넘기는 함수 — 기본은 url_launcher 의 [launchUrl]. 시험은 가짜를 넣는다 +typedef UrlLaunch = Future Function(Uri url, LaunchMode mode); + +Future _launchExternal(Uri url, LaunchMode mode) => + launchUrl(url, mode: mode); + +/// [인증 앱에 추가] — otpauth 주소(키가 들어 있다)를 그 주소를 받는 인증 앱에 넘긴다(2단계 켜기 · 같은 휴대폰이라 QR 대신). +/// +/// - 브라우저가 아닌 앱만 연다(`externalNonBrowserApplication` — Android 11+ 의 FLAG_ACTIVITY_REQUIRE_NON_BROWSER). +/// 받을 앱이 없으면 false — 화면이 "인증 앱을 찾지 못했습니다"와 키를 직접 넣는 길을 보인다. +/// - **`canLaunchUrl` 을 쓰지 않는다** — url_launcher_android 의 debug 판이 묻는 주소를 로그에 남긴다(키가 들어 있다). +/// 그래서 매니페스트 `` 도 필요 없다: `launchUrl` 은 startActivity 를 바로 부르고 받을 앱이 없으면 +/// ActivityNotFoundException 을 false 로 바꾼다(앱 보기 제한은 resolveActivity 류에만 걸린다). +/// - 못 열면 플러그인이 던지는 `PlatformException` 의 문장에 주소째(키째) 들어 있다 — 잡아서 버린다(로그 · 상태에 남기지 않는다). +class AuthenticatorLauncher { + const AuthenticatorLauncher({UrlLaunch launch = _launchExternal}) + : _launch = launch; + + final UrlLaunch _launch; + + /// 인증 앱을 열었으면 true. otpauth 주소가 아니거나 받을 앱이 없거나 열지 못했으면 false + Future open(String otpauthUrl) async { + if (!otpauthUrl.startsWith(kOtpauthUrlPrefix)) return false; + final Uri? url = Uri.tryParse(otpauthUrl); + if (url == null) return false; + try { + return await _launch(url, LaunchMode.externalNonBrowserApplication); + } on Object { + // PlatformException(받을 앱 없음 · 화면 없음) · ArgumentError(지원하지 않는 방식) 모두 — + // 문장에 주소(키)가 들어 있어 남기지 않고 버린다. 화면은 키를 직접 넣는 길을 보인다 + return false; + } + } +} diff --git a/flutter/lib/features/auth/data/models/two_factor_enrollment.dart b/flutter/lib/features/auth/data/models/two_factor_enrollment.dart new file mode 100644 index 0000000..3be404a --- /dev/null +++ b/flutter/lib/features/auth/data/models/two_factor_enrollment.dart @@ -0,0 +1,48 @@ +/// 2단계 인증 등록 — `POST /api/two-factor/enrollments` 의 응답 (계약 5절 "2단계 인증"). +/// +/// **비밀이다.** 키([secret])와 otpauth 주소([otpauthUrl])는 2단계 켜기 화면의 메모리에만 둔다 — +/// Riverpod 상태 · 로그 · 예외 문장에 두지 않고, 켜거나 화면을 떠나면 버린다. [toString] 도 값을 가린다. +class TwoFactorEnrollment { + const TwoFactorEnrollment({ + required this.enrollmentId, + required this.secret, + required this.otpauthUrl, + this.expiresAt, + }); + + /// 켜기 요청에 함께 보낸다 + final String enrollmentId; + + /// base32 키(32자) — [복사] · 직접 입력용 + final String secret; + + /// `otpauth://totp/…` — [인증 앱에 추가]가 이 주소로 인증 앱을 연다 + final String otpauthUrl; + + /// 이때까지 켜야 한다(10분) — 지나면 서버가 `AUTH_008` + final DateTime? expiresAt; + + /// 응답을 읽는다 — 칸이 비었거나 모양이 틀리면 null + static TwoFactorEnrollment? tryParse(Object? json) { + if (json is! Map) return null; + final Object? id = json['enrollmentId']; + final Object? secret = json['secret']; + final Object? url = json['otpauthUrl']; + if (id is! String || id.isEmpty) return null; + if (secret is! String || secret.isEmpty) return null; + if (url is! String || !url.startsWith(kOtpauthUrlPrefix)) return null; + final Object? expires = json['expiresAt']; + return TwoFactorEnrollment( + enrollmentId: id, + secret: secret, + otpauthUrl: url, + expiresAt: expires is String ? DateTime.tryParse(expires)?.toUtc() : null, + ); + } + + @override + String toString() => 'TwoFactorEnrollment(***)'; +} + +/// otpauth 주소의 앞부분 — 다른 꼴의 주소로는 다른 앱을 열지 않는다 +const String kOtpauthUrlPrefix = 'otpauth://'; diff --git a/flutter/lib/features/auth/data/models/two_factor_status.dart b/flutter/lib/features/auth/data/models/two_factor_status.dart new file mode 100644 index 0000000..093fe39 --- /dev/null +++ b/flutter/lib/features/auth/data/models/two_factor_status.dart @@ -0,0 +1,17 @@ +/// 2단계 인증 상태 — `GET /api/two-factor` · 켜기 응답 (계약 5절 "2단계 인증" · 6절 `isRequired` · `deadline`). +/// +/// 앱은 로그인 틀에서만 쓴다: 새 비밀번호 단계가 코드 칸을 보일지(켠 직원은 비밀번호를 바꿀 때 코드가 든다), +/// 2단계 켜기 단계가 그사이 다른 곳에서 켰는지(`AUTH_008`) 본다. 계정 설정 화면은 웹에 있다. +class TwoFactorStatus { + const TwoFactorStatus({required this.isEnabled}); + + final bool isEnabled; + + /// 응답을 읽는다 — `isEnabled` 가 없으면 null + static TwoFactorStatus? tryParse(Object? json) { + if (json is! Map) return null; + final Object? enabled = json['isEnabled']; + if (enabled is! bool) return null; + return TwoFactorStatus(isEnabled: enabled); + } +} diff --git a/flutter/lib/features/auth/data/repositories/auth_repository.dart b/flutter/lib/features/auth/data/repositories/auth_repository.dart index 73f20f8..3b719ee 100644 --- a/flutter/lib/features/auth/data/repositories/auth_repository.dart +++ b/flutter/lib/features/auth/data/repositories/auth_repository.dart @@ -1,8 +1,12 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/api_config.dart'; import 'package:arasystem_app/core/api/api_error_mapper.dart'; import 'package:arasystem_app/core/api/api_paths.dart'; import 'package:arasystem_app/core/api/request_flags.dart'; import 'package:arasystem_app/core/api/server_time.dart'; import 'package:arasystem_app/core/api/token_refresher.dart'; +import 'package:arasystem_app/core/storage/auth_tokens.dart'; import 'package:arasystem_app/features/auth/data/models/app_login_result.dart'; import 'package:arasystem_app/features/auth/data/models/auth_session_info.dart'; import 'package:dio/dio.dart'; @@ -16,6 +20,23 @@ const Set kLoginInlineCodes = { 'VAL_001', }; +/// 코드 단계(코드를 실은 로그인)가 직접 보이는 실패 — 첫 단계 묶음에 시도 과다(`REQ_001` — 위쪽 상자)를 더한다. +/// 웹 코드 단계와 같다(웹 디자인 README 12단계 "로그인 — 코드 단계"). 첫 단계의 `REQ_001` 은 웹처럼 전역 알림이다 +const Set kLoginCodeInlineCodes = { + ...kLoginInlineCodes, + 'REQ_001', +}; + +/// 새 비밀번호 단계가 직접 보이는 실패 — 알림을 띄우지 않는다 +/// (`AUTH_005` 임시 비밀번호 칸 · `VAL_001` 새 비밀번호 칸 · `AUTH_006` · `AUTH_007` 코드 칸 · `REQ_001` 위쪽 상자 — 계약 1 · 5 · 6절, 웹과 같다) +const Set kPasswordChangeInlineCodes = { + 'AUTH_005', + 'AUTH_006', + 'AUTH_007', + 'VAL_001', + 'REQ_001', +}; + /// 응답이 계약과 다른 모양일 때의 코드 — 서버 쪽 문제로 본다 const String kMalformedResponseCode = 'SYS_001'; @@ -44,7 +65,9 @@ class AuthRepository { 'otpCode': ?otpCode, }, // 비밀번호 · 코드가 본문에 든다 — 실패 예외는 본문을 비워 던진다(인증 인터셉터가 지운다) - options: ApiOptions.secret(silent: kLoginInlineCodes), + options: ApiOptions.secret( + silent: otpCode == null ? kLoginInlineCodes : kLoginCodeInlineCodes, + ), ); final Object? body = response.data; if (body is Map) { @@ -54,10 +77,22 @@ class AuthRepository { serverTime: serverTimeOf(response), ); } on FormatException { - // 아래에서 같은 실패로 던진다 + // 모양이 틀린 응답 — 토큰은 실려 왔으면 그 서버 세션을 조용히 끝낸다(쓰지 않을 세션이 남지 않게) + final AuthTokens? tokens = AuthTokens.tryParse(body['tokens']); + if (tokens != null) { + unawaited( + _refresher.logoutQuietly( + refreshToken: tokens.refreshToken, + accessToken: tokens.accessToken, + ), + ); + } } } - throw _malformed(response); + // 응답(토큰이 들었을 수 있다)을 뺀 실패로 던진다 — 요청 본문 · Bearer 도 지운다 + throw withoutSecrets( + apiFailure(response.requestOptions, kMalformedResponseCode), + ); } /// 지금 세션 — 직원 · 제한 · 유예. [silent] 면 실패를 알리지 않는다(세션 되살리기) @@ -85,6 +120,28 @@ class AuthRepository { return info; } + /// 비밀번호를 바꾼다 — 새 비밀번호 단계(계약 1절 `PUT /api/auth/password` · 5절 `otpCode` · 6절 제한 풀기). + /// + /// [otpCode] 는 2단계 인증을 켠 직원만. 비밀번호 · 코드가 본문에 든다 — 실패 예외는 본문을 비운 채 온다. + /// 서버가 엔진을 두 번 부르고 세션을 끊어 받기 한도를 [kSecuritySettingTimeout] 으로 늘린다(웹과 같은 30초). + Future changePassword({ + required String currentPassword, + required String newPassword, + String? otpCode, + }) async { + await _dio.put( + ApiPaths.password, + data: { + 'currentPassword': currentPassword, + 'newPassword': newPassword, + 'otpCode': ?otpCode, + }, + options: ApiOptions.secret( + silent: kPasswordChangeInlineCodes, + ).copyWith(receiveTimeout: kSecuritySettingTimeout), + ); + } + /// 토큰을 새로 받는다 — 진행 중인 갱신이 있으면 함께 기다린다(한 번에 하나). /// 제한을 푼 직후(계약 6절 — 곧바로 갱신해 새 토큰) · 옛 토큰의 `AUTH_010` 에서 부른다. Future refresh() => _refresher.refresh(); diff --git a/flutter/lib/features/auth/data/repositories/support_contact_repository.dart b/flutter/lib/features/auth/data/repositories/support_contact_repository.dart new file mode 100644 index 0000000..fe0849f --- /dev/null +++ b/flutter/lib/features/auth/data/repositories/support_contact_repository.dart @@ -0,0 +1,26 @@ +import 'package:arasystem_app/core/api/api_paths.dart'; +import 'package:arasystem_app/core/api/request_flags.dart'; +import 'package:arasystem_app/core/utils/display_text.dart'; +import 'package:dio/dio.dart'; + +/// 문의 연락처 — `GET /api/support-contact` (로그인 없음 · 계약 6절 "문의 연락처"). +/// +/// 운영 도구로 정하는 회사 설정값 한 줄이다(200자). 정하지 않았으면 null. 알림은 띄우지 않는다 — +/// 받지 못해도 화면은 지금 글 그대로다(웹과 같다). +class SupportContactRepository { + const SupportContactRepository(this._dio); + + final Dio _dio; + + /// 연락처 한 줄 — 정하지 않았거나 모양이 틀리면 null. 실패는 `DioException` + Future fetch() async { + final Response response = await _dio.get( + ApiPaths.supportContact, + options: ApiOptions.silent(), + ); + final Object? data = response.data; + return data is Map + ? supportContactTextOf(data['text']) + : null; + } +} diff --git a/flutter/lib/features/auth/data/repositories/two_factor_repository.dart b/flutter/lib/features/auth/data/repositories/two_factor_repository.dart new file mode 100644 index 0000000..b8e9457 --- /dev/null +++ b/flutter/lib/features/auth/data/repositories/two_factor_repository.dart @@ -0,0 +1,84 @@ +import 'package:arasystem_app/core/api/api_config.dart'; +import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/api/api_paths.dart'; +import 'package:arasystem_app/core/api/request_flags.dart'; +import 'package:arasystem_app/features/auth/data/models/two_factor_enrollment.dart'; +import 'package:arasystem_app/features/auth/data/models/two_factor_status.dart'; +import 'package:arasystem_app/features/auth/data/repositories/auth_repository.dart'; +import 'package:dio/dio.dart'; + +/// 2단계 켜기 단계가 직접 보이는 실패 — 알림을 띄우지 않는다 +/// (`AUTH_005` 지금 비밀번호 칸 · `AUTH_006` · `AUTH_007` 코드 칸 · `AUTH_008` 위쪽 상자와 새 키 · `REQ_001` 위쪽 상자 — 계약 5절 "2단계 인증", 웹과 같다). +/// `VAL_001` 은 넣지 않는다 — 코드는 보내기 전에 6자리를 보므로 서버의 `VAL_001` 은 지금 비밀번호 쪽이고, 웹처럼 전역 알림이다 +const Set kTwoFactorSetupInlineCodes = { + 'AUTH_005', + 'AUTH_006', + 'AUTH_007', + 'AUTH_008', + 'REQ_001', +}; + +/// 2단계 인증 API — 로그인 틀(새 비밀번호 · 2단계 켜기)이 쓰는 것만. 계약 5절 "2단계 인증" · 6절 "제한된 세션"(제한된 세션도 부를 수 있다). +/// +/// 등록의 키 · otpauth 주소는 받은 값을 돌려주기만 한다 — 저장하지 않는다(화면 메모리에만, `TwoFactorEnrollment`). +/// 실패는 늘 `DioException`(`error` 에 `ApiException`). 응답 모양이 틀리면 [kMalformedResponseCode] — 응답은 싣지 않는다. +class TwoFactorRepository { + const TwoFactorRepository(this._dio); + + final Dio _dio; + + /// 지금 상태 — 알림은 띄우지 않는다(화면이 못 받아도 다른 길로 안다: 서버의 `AUTH_007` 등) + Future status() async { + final Response response = await _dio.get( + ApiPaths.twoFactor, + options: ApiOptions.silent(), + ); + final TwoFactorStatus? status = TwoFactorStatus.tryParse(response.data); + if (status == null) throw _malformed(response); + return status; + } + + /// 등록을 시작한다 — 새 키(10분 안에 켠다). 직원마다 기다리는 등록은 하나라 앞의 것은 서버가 버린다. + /// + /// 응답에 키가 들어 있어 비밀 요청으로 보낸다(실패 예외의 응답 본문을 비운다). 알림은 띄우지 않는다 — + /// 화면이 키 자리에 사전 문구와 [다시 시도]를 보인다. + Future startEnrollment() async { + final Response response = await _dio.post( + ApiPaths.twoFactorEnrollments, + options: ApiOptions.secret(silent: true), + ); + final TwoFactorEnrollment? enrollment = TwoFactorEnrollment.tryParse( + response.data, + ); + // 받은 응답 객체에 키를 남기지 않는다 + response.data = null; + if (enrollment == null) throw _malformed(response); + return enrollment; + } + + /// 켠다 — 등록의 키로 만든 코드와 지금 비밀번호를 함께 보낸다. 비밀이 본문에 든다. + /// 서버가 엔진을 여러 번 부르므로 받기 한도를 [kSecuritySettingTimeout] 으로 늘린다(웹과 같은 30초). + /// 성공 응답의 상태는 쓰지 않는다 — 다음 화면은 새 토큰의 세션 칸이 정한다. + Future activate({ + required String enrollmentId, + required String otpCode, + required String currentPassword, + }) async { + await _dio.post( + ApiPaths.twoFactorActivation, + data: { + 'enrollmentId': enrollmentId, + 'otpCode': otpCode, + 'currentPassword': currentPassword, + }, + options: ApiOptions.secret( + silent: kTwoFactorSetupInlineCodes, + ).copyWith(receiveTimeout: kSecuritySettingTimeout), + ); + } + + /// 모양이 틀린 응답 — 응답을 싣지 않고 Bearer 를 지운 실패 + DioException _malformed(Response response) => withoutSecrets( + apiFailure(response.requestOptions, kMalformedResponseCode), + ); +} diff --git a/flutter/lib/features/auth/presentation/login_failures.dart b/flutter/lib/features/auth/presentation/login_failures.dart new file mode 100644 index 0000000..182ff17 --- /dev/null +++ b/flutter/lib/features/auth/presentation/login_failures.dart @@ -0,0 +1,28 @@ +import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/api/api_exception.dart'; +import 'package:arasystem_app/core/api/auth_interceptor.dart'; +import 'package:arasystem_app/core/api/dio_provider.dart'; +import 'package:arasystem_app/core/api/token_refresher.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 로그인 틀이 칸 · 상자에 보이지 않는 실패를 전역 알림(스낵바)으로 보인다 — 웹의 "그 밖의 실패는 알림"과 같은 결. +/// +/// 서버가 답한 실패는 인터셉터가 이미 알렸다 — 같은 코드는 3초 안에 다시 띄우지 않아(`ErrorNotifier`) 겹치지 않는다. +/// 앱이 스스로 정한 실패(응답 모양이 틀림 · 받은 토큰을 보안 저장소에 두지 못함 — `SYS_001`)는 인터셉터를 지나지 않아 여기서만 알린다. +/// 문구는 사전에서만 온다(`ApiException.message`). 알리지 않는 것: `AUTH_010`(세션이 그 단계로 옮긴다) · +/// `AUTH_002`(세션 끝 — 로그인 화면의 위쪽 상자가 알린다, 인터셉터의 알림과 같은 규칙). +/// 부르기 전에 화면이 그 시도가 아직 지금 세션의 것인지(`SessionNotifier.attempt`) 본다 — 끝난 세션의 늦은 실패는 보이지 않는다. +void showLoginFailure(WidgetRef ref, Object error) { + final ApiException? apiError = switch (error) { + ApiException() => error, + DioException() => toApiException(error), + _ => null, + }; + if (apiError == null || + apiError.code == kRestrictedSessionCode || + apiError.code == kSessionExpiredCode) { + return; + } + ref.read(errorNotifierProvider).show(apiError); +} diff --git a/flutter/lib/features/auth/presentation/pages/login_code_page.dart b/flutter/lib/features/auth/presentation/pages/login_code_page.dart new file mode 100644 index 0000000..c66c545 --- /dev/null +++ b/flutter/lib/features/auth/presentation/pages/login_code_page.dart @@ -0,0 +1,162 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/api/error_lexicon.dart'; +import 'package:arasystem_app/core/constants/login_texts.dart'; +import 'package:arasystem_app/core/constants/screen_titles.dart'; +import 'package:arasystem_app/core/utils/otp_code.dart'; +import 'package:arasystem_app/features/auth/presentation/login_failures.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/login_providers.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_fields.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_frame.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_parts.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 로그인 둘째 단계 — 2단계 인증 코드(2단계를 켠 직원 — 앱 보드 `App1Login` 「2단계 인증 코드」 · 계약 5절 "로그인"). +/// +/// - 짙은 띠([다른 계정으로 로그인]) → 제목 · 설명 · 주소 → 코드 칸 → [확인] → 문의 줄 → 메일 프로그램 안내. +/// - 비밀번호는 세션 노티파이어 메모리에만 있다(10분) — 이 화면은 코드만 칸(화면 메모리)에 둔다. +/// - 6자리가 아니면 보내지 않고 칸 아래 `AUTH_007`. 서버의 `AUTH_006`(틀림 · 다시 쓴 코드)은 칸 아래 · 칸을 비운다. +/// 시도 과다(`REQ_001`)는 위쪽 상자(웹과 같다 — 다음에 보낼 때 내린다). `AUTH_004`(그사이 비밀번호가 바뀜) · 10분 지남은 +/// 세션이 첫 단계로 옮긴다(첫 단계가 까닭을 보인다). 그 밖은 전역 알림. +/// - [다른 계정으로 로그인]은 보내는 중에도 누를 수 있다 — 아직 로그인 전이라 서버에 바뀌는 것이 없다. +/// 늦게 온 결과는 세션 노티파이어가 버리고(시도 세대 — 토큰이 실려 오면 그 세션을 끝낸다), 이 화면도 보이지 않는다. +class LoginCodePage extends ConsumerStatefulWidget { + const LoginCodePage({super.key}); + + @override + ConsumerState createState() => _LoginCodePageState(); +} + +class _LoginCodePageState extends ConsumerState { + final TextEditingController _code = TextEditingController(); + final FocusNode _codeFocus = FocusNode(); + + /// 비밀번호를 확인한 주소 — 화면이 사라지는 동안(첫 단계로 옮겨 가는 중)에도 같은 글을 보이게 처음 값을 둔다 + late final String _email; + bool _submitting = false; + String? _codeError; + + /// 위쪽 상자 — 시도 과다(`REQ_001`) 사전 문구 + String? _alert; + + @override + void initState() { + super.initState(); + final SessionState? session = ref.read(sessionProvider).value; + _email = session is CodeRequired ? session.email : ''; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + unawaited(ref.read(supportContactProvider.notifier).ensureLoaded()); + _codeFocus.requestFocus(); + }); + } + + @override + void dispose() { + _code.clear(); + _code.dispose(); + _codeFocus.dispose(); + super.dispose(); + } + + Future _submit() async { + if (_submitting) return; + final String code = _code.text; + if (!isCompleteOtp(code)) { + setState(() { + _alert = null; + _codeError = ErrorLexicon.getMessage('AUTH_007'); + }); + _codeFocus.requestFocus(); + return; + } + final SessionNotifier session = ref.read(sessionProvider.notifier); + final int attempt = session.attempt; + setState(() { + _submitting = true; + _codeError = null; + _alert = null; + }); + try { + await session.submitCode(code); + // 세션이 다음 화면(제한 단계 · 메일 · 시간이 지났으면 첫 단계)으로 옮긴다 — 쓴 코드는 칸에 남기지 않는다 + if (mounted) _code.clear(); + } on DioException catch (error) { + // 그사이 [다른 계정으로 로그인] · 첫 단계로 돌아갔으면(`AUTH_004`) 이 화면은 보이지 않는다 + if (!mounted || + session.attempt != attempt || + ref.read(sessionProvider).value is! CodeRequired) { + return; + } + _onFailure(error); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + void _onFailure(DioException error) { + final String message = toApiException(error).message; + switch (apiErrorCodeOf(error)) { + case 'AUTH_006': + case 'AUTH_007': + case 'VAL_001': + setState(() => _codeError = message); + _code.clear(); + _codeFocus.requestFocus(); + case 'REQ_001': + // 칸 · 단추는 그대로 — 잠시 뒤 같은 코드로 다시 보낼 수 있다 + setState(() => _alert = message); + default: + showLoginFailure(ref, error); + } + } + + void _setCode(String _) { + // 고치면 칸 아래 오류를 내린다(다른 칸과 같다) + if (_codeError != null) setState(() => _codeError = null); + } + + @override + Widget build(BuildContext context) { + final String? alert = _alert; + return LoginStepFrame( + onOtherAccount: () => + unawaited(ref.read(sessionProvider.notifier).switchAccount()), + children: [ + LoginHeading( + title: ScreenTitles.loginCode, + subtitle: LoginCodeTexts.subtitle, + email: _email, + ), + if (alert != null) LoginAlertBox(message: alert), + LoginCodeField( + label: LoginCodeTexts.codeLabel, + controller: _code, + focusNode: _codeFocus, + error: _codeError, + textInputAction: TextInputAction.done, + onSubmitted: (_) => unawaited(_submit()), + onChanged: _setCode, + readOnly: _submitting, + ), + LoginSubmitButton( + label: LoginCodeTexts.submit, + busyLabel: LoginCodeTexts.submitting, + busy: _submitting, + onPressed: () => unawaited(_submit()), + ), + LoginSupportLine( + question: SupportLineTexts.authenticatorQuestion, + fallback: SupportLineTexts.authenticatorFallback, + contact: ref.watch(supportContactProvider), + ), + const LoginNote(text: LoginCodeTexts.note), + ], + ); + } +} diff --git a/flutter/lib/features/auth/presentation/pages/login_page.dart b/flutter/lib/features/auth/presentation/pages/login_page.dart new file mode 100644 index 0000000..4b76811 --- /dev/null +++ b/flutter/lib/features/auth/presentation/pages/login_page.dart @@ -0,0 +1,218 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/api/error_lexicon.dart'; +import 'package:arasystem_app/core/api/token_refresher.dart'; +import 'package:arasystem_app/core/constants/login_texts.dart'; +import 'package:arasystem_app/core/constants/screen_titles.dart'; +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/utils/password_rules.dart'; +import 'package:arasystem_app/features/auth/presentation/login_failures.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/login_providers.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_fields.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_frame.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_parts.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 로그인 첫 단계 — 주소 · 비밀번호(앱 보드 `App1Login` 「주소와 비밀번호」). +/// +/// - 큰 브랜드 띠 → 제목 · 설명 → (위쪽 빨간 상자) → 메일 주소 · 비밀번호([보기]) → [로그인] → 문의 줄. +/// 웹 첫 화면의 메일 프로그램(IMAP) 안내 줄은 넣지 않는다(앱이 메일 앱이다 — 디자인 README). +/// - 비밀번호가 맞으면 세션이 다음 단계(코드 · 제한 단계 · 메일)로 옮긴다(라우터 redirect). 이 화면은 결과의 실패만 보인다. +/// - `AUTH_004` 는 위쪽 상자(사전 문구) + 두 칸 빨갛게 + 비밀번호 비우기, `VAL_001` 은 주소 칸 아래, 그 밖은 전역 알림. +/// 비운 칸도 보내지 않고 그 칸 아래에 `VAL_001` 사전 문구. +/// - 코드 단계에서 돌아왔으면(시간 지남 · 비밀번호 틀림) 주소를 남기고 위쪽 상자에 까닭을 보인다(세션 상태 `SignedOut.reason`), +/// 세션이 끝나 왔으면 `AUTH_002` 사전 문구. +/// - 비밀번호는 이 칸(화면 메모리)에만 있다 — 실패하면 비우고, 화면을 떠날 때 지운다. +/// 성공한 뒤 곧바로 비우지 않는 까닭: 다음 화면으로 넘어가며 이 칸 묶음(AutofillGroup)이 사라질 때 기기의 비밀번호 저장(자동 채우기)이 그 값을 본다. +class LoginPage extends ConsumerStatefulWidget { + const LoginPage({super.key}); + + @override + ConsumerState createState() => _LoginPageState(); +} + +class _LoginPageState extends ConsumerState { + final TextEditingController _email = TextEditingController(); + final TextEditingController _password = TextEditingController(); + final FocusNode _emailFocus = FocusNode(); + final FocusNode _passwordFocus = FocusNode(); + + bool _passwordShown = false; + bool _submitting = false; + + /// 위쪽 상자 — 사전 문구(`AUTH_004` · `AUTH_002`) 또는 코드 단계 시간 지남 + String? _notice; + + /// 주소 · 비밀번호가 맞지 않았다 — 두 칸을 빨갛게(글은 위쪽 상자) + bool _credentialsRejected = false; + String? _emailError; + String? _passwordError; + + @override + void initState() { + super.initState(); + final SessionState? session = ref.read(sessionProvider).value; + if (session is SignedOut) _applyArrival(session); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + unawaited(ref.read(supportContactProvider.notifier).ensureLoaded()); + // 주소가 남아 있으면 비밀번호부터 넣는다 + if (_email.text.isNotEmpty) _passwordFocus.requestFocus(); + }); + } + + /// 이 화면으로 온 까닭 — 주소를 남기고 위쪽 상자에 보일 글 + void _applyArrival(SignedOut session) { + _email.text = session.email ?? ''; + switch (session.reason) { + case SignOutReason.codeTimedOut: + _notice = LoginTexts.codeTimedOut; + case SignOutReason.passwordRejected: + _notice = ErrorLexicon.getMessage('AUTH_004'); + _credentialsRejected = true; + case SignOutReason.expired: + _notice = ErrorLexicon.getMessage(kSessionExpiredCode); + case SignOutReason.none: + case SignOutReason.switchAccount: + break; + } + } + + @override + void dispose() { + _password.clear(); + _email.dispose(); + _password.dispose(); + _emailFocus.dispose(); + _passwordFocus.dispose(); + super.dispose(); + } + + Future _submit() async { + if (_submitting) return; + final String email = _email.text.trim(); + final String password = _password.text; + final String? emailProblem = email.isEmpty + ? ErrorLexicon.getMessage('VAL_001') + : null; + final String? passwordProblem = + password.isEmpty || password.length > kLoginPasswordMaxLength + ? ErrorLexicon.getMessage('VAL_001') + : null; + setState(() { + _notice = null; + _credentialsRejected = false; + _emailError = emailProblem; + _passwordError = passwordProblem; + }); + if (emailProblem != null) { + _emailFocus.requestFocus(); + return; + } + if (passwordProblem != null) { + _passwordFocus.requestFocus(); + return; + } + + setState(() => _submitting = true); + try { + await ref + .read(sessionProvider.notifier) + .signIn(email: email, password: password); + // 세션이 다음 화면으로 옮긴다 + } on DioException catch (error) { + if (mounted) _onFailure(error); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + void _onFailure(DioException error) { + final String message = toApiException(error).message; + switch (apiErrorCodeOf(error)) { + case 'AUTH_004': + setState(() { + _notice = message; + _credentialsRejected = true; + }); + _password.clear(); + _passwordFocus.requestFocus(); + case 'VAL_001': + setState(() => _emailError = message); + _emailFocus.requestFocus(); + default: + showLoginFailure(ref, error); + } + } + + @override + Widget build(BuildContext context) { + final String? notice = _notice; + return LoginBrandFrame( + children: [ + const LoginHeading( + title: ScreenTitles.login, + subtitle: LoginTexts.subtitle, + ), + if (notice != null) LoginAlertBox(message: notice), + AutofillGroup( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: AppSpacing.s16, + children: [ + LoginTextField( + label: LoginTexts.emailLabel, + controller: _email, + focusNode: _emailFocus, + placeholder: LoginTexts.emailPlaceholder, + error: _emailError, + invalid: _credentialsRejected, + // 위쪽 상자의 글을 칸에 이어 읽힌다(보드의 aria-describedby — 웹과 같이 두 칸 모두) + describedBy: notice, + keyboardType: TextInputType.emailAddress, + // 끄면 안드로이드 자판이 메일 주소 자판(@)이 아니게 된다 — 고쳐 쓰기(autocorrect)는 그대로 끈다 + enableSuggestions: true, + textInputAction: TextInputAction.next, + onSubmitted: (_) => _passwordFocus.requestFocus(), + autofillHints: const [AutofillHints.username], + ), + LoginPasswordField( + label: LoginTexts.passwordLabel, + controller: _password, + focusNode: _passwordFocus, + placeholder: LoginTexts.passwordPlaceholder, + shown: _passwordShown, + onToggle: () => + setState(() => _passwordShown = !_passwordShown), + toggleLabel: LoginTexts.showPassword, + error: _passwordError, + invalid: _credentialsRejected, + describedBy: notice, + textInputAction: TextInputAction.done, + onSubmitted: (_) => unawaited(_submit()), + autofillHints: const [AutofillHints.password], + ), + LoginSubmitButton( + label: LoginTexts.submit, + busyLabel: LoginTexts.submitting, + busy: _submitting, + onPressed: () => unawaited(_submit()), + inForm: true, + ), + ], + ), + ), + LoginSupportLine( + question: SupportLineTexts.passwordQuestion, + fallback: SupportLineTexts.passwordFallback, + contact: ref.watch(supportContactProvider), + ), + ], + ); + } +} diff --git a/flutter/lib/features/auth/presentation/pages/password_change_page.dart b/flutter/lib/features/auth/presentation/pages/password_change_page.dart new file mode 100644 index 0000000..a01cfe1 --- /dev/null +++ b/flutter/lib/features/auth/presentation/pages/password_change_page.dart @@ -0,0 +1,436 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/api/error_lexicon.dart'; +import 'package:arasystem_app/core/constants/login_texts.dart'; +import 'package:arasystem_app/core/constants/screen_titles.dart'; +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/utils/otp_code.dart'; +import 'package:arasystem_app/core/utils/password_rules.dart'; +import 'package:arasystem_app/features/auth/data/models/two_factor_status.dart'; +import 'package:arasystem_app/features/auth/presentation/login_failures.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/login_providers.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_fields.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_frame.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_parts.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 로그인 셋째 단계 — 새 비밀번호 정하기(제한 `passwordChange` — 앱 보드 `App1Login` 「새 비밀번호 정하기」 · 계약 6절 "비밀번호 바꾸기 강제"). +/// +/// - 짙은 띠 → 제목 · 설명 · 주소 → (위쪽 상자) → (임시 비밀번호) · 새 비밀번호([보기]) · 확인 · (인증 앱 코드) → [비밀번호 바꾸고 계속] → 안내. +/// - 임시 비밀번호는 로그인 때 넣은 것을 세션 노티파이어가 쥐고 있다(10분) — 없을 때만(앱을 다시 열었다 · 10분 지남) 칸을 보인다. +/// - 코드 칸은 2단계 인증을 켠 직원만 — 코드 단계를 거쳐 왔거나 `GET /two-factor` 가 켜짐이거나 서버가 `AUTH_007` 로 코드를 요구했을 때(웹과 같다). +/// - 보내기 전에 서버와 같은 규칙을 본다(8자 이상 · 임시 비밀번호와 다름 · 확인 일치 · 128자). 서버 거절은 칸 아래에 — +/// `AUTH_005` 임시 비밀번호 칸(쥔 것이었으면 지우고 칸을 보인다) · `VAL_001` 새 비밀번호 칸 · `AUTH_006` · `AUTH_007` 코드 칸. +/// 시도 과다(`REQ_001`)는 위쪽 상자(웹과 같다 — 다음에 보낼 때 내린다). 그 밖은 전역 알림. +/// - 바뀌면 칸을 비우고 세션 노티파이어가 새 토큰을 받아 다음 단계(2단계 켜기) · 메일로 옮긴다. 넘기지 못하면 위쪽 상자에 사전 문구 + [다시 시도] — +/// 그동안 칸 · 단추는 막는다(이미 바꿨다). +/// - 보내는 동안 · 넘기는 동안은 [다른 계정으로 로그인]을 막는다(웹과 같다) — 서버가 이미 바꿨을 수 있는데 그 결과를 버리면 +/// 직원이 임시 비밀번호로 다시 들어가다 막힌다(`AUTH_004`). +class PasswordChangePage extends ConsumerStatefulWidget { + const PasswordChangePage({super.key}); + + @override + ConsumerState createState() => _PasswordChangePageState(); +} + +class _PasswordChangePageState extends ConsumerState { + final TextEditingController _temporary = TextEditingController(); + final TextEditingController _next = TextEditingController(); + final TextEditingController _confirm = TextEditingController(); + final TextEditingController _code = TextEditingController(); + final FocusNode _temporaryFocus = FocusNode(); + final FocusNode _nextFocus = FocusNode(); + final FocusNode _confirmFocus = FocusNode(); + final FocusNode _codeFocus = FocusNode(); + + /// 로그인 때 넣은 비밀번호(임시 비밀번호)를 세션 노티파이어가 쥐고 있나 — 없으면 칸을 보인다 + late final ValueListenable _held; + late final String _email; + + /// 코드 단계를 거쳐 왔다 — 2단계 인증을 켠 직원 + late final bool _passedCodeStep; + + /// `GET /two-factor` 가 켜짐이다 + bool _twoFactorOn = false; + + /// 서버가 코드를 요구했다(`AUTH_007` — 그사이 다른 곳에서 켬) + bool _codeRequired = false; + + bool _temporaryShown = false; + bool _nextShown = false; + bool _submitting = false; + bool _finishing = false; + + /// 바꿨지만 다음으로 넘기지 못했다 — 사전 문구(위쪽 상자 + [다시 시도]) + String? _finishError; + + /// 위쪽 상자 — 시도 과다(`REQ_001`) 사전 문구 + String? _alert; + PasswordChangeProblems _problems = const PasswordChangeProblems(); + String? _temporaryError; + String? _nextError; + String? _codeError; + + bool get _showCode => _passedCodeStep || _twoFactorOn || _codeRequired; + + bool get _busy => _submitting || _finishing; + + /// 칸 · 단추를 막는다 — 바쁘거나, 이미 바꿨는데 넘기지 못했다(다시 보내면 안 된다) + bool get _blocked => _busy || _finishError != null; + + @override + void initState() { + super.initState(); + final SessionNotifier session = ref.read(sessionProvider.notifier); + _held = session.heldLoginListenable; + _held.addListener(_onHeldChanged); + _passedCodeStep = session.passedCodeStep; + final SessionState? state = ref.read(sessionProvider).value; + _email = state is SignedIn ? state.info.email : ''; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + (_held.value ? _nextFocus : _temporaryFocus).requestFocus(); + // 코드 단계를 거치지 않았으면(앱을 다시 열었다) 2단계 인증 상태를 묻는다 — 못 받아도 서버의 AUTH_007 이 칸을 부른다 + if (!_passedCodeStep) unawaited(_loadTwoFactorStatus()); + }); + } + + @override + void dispose() { + _held.removeListener(_onHeldChanged); + _clearFields(); + for (final TextEditingController controller in [ + _temporary, + _next, + _confirm, + _code, + ]) { + controller.dispose(); + } + for (final FocusNode node in [ + _temporaryFocus, + _nextFocus, + _confirmFocus, + _codeFocus, + ]) { + node.dispose(); + } + super.dispose(); + } + + /// 쥔 임시 비밀번호가 사라졌다(10분 · 서버 거절) — 칸을 보인다 + void _onHeldChanged() { + if (mounted) setState(() {}); + } + + Future _loadTwoFactorStatus() async { + final SessionNotifier session = ref.read(sessionProvider.notifier); + final int attempt = session.attempt; + try { + final TwoFactorStatus status = await ref + .read(twoFactorRepositoryProvider) + .status(); + if (!mounted || session.attempt != attempt) return; + if (status.isEnabled) setState(() => _twoFactorOn = true); + } on DioException { + // 못 받았다 — 서버가 AUTH_007 로 코드를 요구하면 그때 칸을 보인다 + } + } + + void _clearFields() { + _temporary.clear(); + _next.clear(); + _confirm.clear(); + _code.clear(); + } + + void _focusLater(FocusNode node) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) node.requestFocus(); + }); + } + + Future _submit() async { + if (_blocked) return; + final SessionNotifier session = ref.read(sessionProvider.notifier); + final ({String email, String password})? held = session.heldLogin; + final PasswordChangeProblems problems = passwordChangeProblems( + current: held?.password ?? _temporary.text, + next: _next.text, + confirm: _confirm.text, + ); + final bool codeMissing = _showCode && !isCompleteOtp(_code.text); + setState(() { + _alert = null; + _problems = problems; + _temporaryError = null; + _nextError = problems.next == NewPasswordProblem.tooLong + ? ErrorLexicon.getMessage('VAL_001') + : null; + _codeError = codeMissing ? ErrorLexicon.getMessage('AUTH_007') : null; + }); + if (problems.hasAny) { + // 10분이 막 지나 임시 비밀번호 칸이 이제 생겼을 수 있다 — 그린 뒤에 초점 + _focusLater( + problems.currentMissing + ? _temporaryFocus + : problems.next != null + ? _nextFocus + : _confirmFocus, + ); + return; + } + if (codeMissing) { + _codeFocus.requestFocus(); + return; + } + + final int attempt = session.attempt; + setState(() => _submitting = true); + final RestrictedStepResult result; + try { + result = await session.changePassword( + newPassword: _next.text, + currentPassword: held == null ? _temporary.text : null, + otpCode: _showCode ? _code.text : null, + ); + } on DioException catch (error) { + if (mounted && session.attempt == attempt) _onFailure(error); + return; + } finally { + if (mounted) setState(() => _submitting = false); + } + if (!mounted) return; + switch (result) { + case RestrictedStepResult.stale: + return; + case RestrictedStepResult.needsPassword: + // 쥔 비밀번호가 그사이 사라졌다 — 칸을 보이고 안내 글을 빨갛게 + setState( + () => _problems = const PasswordChangeProblems(currentMissing: true), + ); + _focusLater(_temporaryFocus); + return; + case RestrictedStepResult.done: + setState(() { + _clearFields(); + _problems = const PasswordChangeProblems(); + }); + } + await _finish(); + } + + void _onFailure(DioException error) { + final String message = toApiException(error).message; + switch (apiErrorCodeOf(error)) { + case 'AUTH_005': + // 쥔 비밀번호였으면 세션 노티파이어가 지웠다 — 임시 비밀번호 칸이 이 오류와 함께 보인다 + setState(() => _temporaryError = message); + _focusLater(_temporaryFocus); + case 'VAL_001': + setState(() => _nextError = message); + _nextFocus.requestFocus(); + case 'AUTH_006': + setState(() => _codeError = message); + _code.clear(); + _codeFocus.requestFocus(); + case 'AUTH_007': + // 그사이 다른 곳에서 2단계 인증을 켰다 — 칸을 보인다 + setState(() { + _codeRequired = true; + _codeError = message; + }); + _code.clear(); + _focusLater(_codeFocus); + case 'REQ_001': + // 칸 · 단추는 그대로 — 잠시 뒤 다시 보낼 수 있다 + setState(() => _alert = message); + default: + showLoginFailure(ref, error); + } + } + + /// 바꾼 뒤 — 새 토큰을 받아 다음 단계로. 넘기지 못하면 위쪽 상자 + [다시 시도] + Future _finish() async { + final SessionNotifier session = ref.read(sessionProvider.notifier); + final ScaffoldMessengerState? messenger = ScaffoldMessenger.maybeOf( + context, + ); + setState(() { + _finishing = true; + _finishError = null; + }); + try { + final RestrictedStepResult result = await session.finishRestrictedStep(); + if (result == RestrictedStepResult.done) { + messenger?.showSnackBar( + const SnackBar(content: Text(PasswordChangeTexts.done)), + ); + } + } on DioException catch (error) { + if (mounted) { + setState(() => _finishError = toApiException(error).message); + } + } finally { + if (mounted) setState(() => _finishing = false); + } + } + + // 칸을 고치면 그 칸에 걸린 오류를 내린다(임시 비밀번호를 고치면 "임시 비밀번호와 같음"도 다시 본다 — 웹과 같다) + void _onTemporaryChanged(String _) { + if (!_problems.currentMissing && + _temporaryError == null && + _problems.next != NewPasswordProblem.rule) { + return; + } + setState(() { + _problems = PasswordChangeProblems( + next: _problems.next == NewPasswordProblem.rule ? null : _problems.next, + confirmMismatch: _problems.confirmMismatch, + ); + _temporaryError = null; + }); + } + + void _onNextChanged(String _) { + if (_problems.next == null && + !_problems.confirmMismatch && + _nextError == null) { + return; + } + setState(() { + _problems = PasswordChangeProblems( + currentMissing: _problems.currentMissing, + ); + _nextError = null; + }); + } + + void _onConfirmChanged(String _) { + if (!_problems.confirmMismatch) return; + setState(() { + _problems = PasswordChangeProblems( + currentMissing: _problems.currentMissing, + next: _problems.next, + ); + }); + } + + void _onCodeChanged(String _) { + if (_codeError != null) setState(() => _codeError = null); + } + + @override + Widget build(BuildContext context) { + final bool askTemporary = !_held.value; + final String? finishError = _finishError; + final String? alert = _alert; + final bool showCode = _showCode; + return LoginStepFrame( + onOtherAccount: () => + unawaited(ref.read(sessionProvider.notifier).switchAccount()), + otherAccountEnabled: !_busy, + children: [ + LoginHeading( + title: ScreenTitles.passwordChange, + subtitle: PasswordChangeTexts.subtitle, + email: _email, + ), + if (finishError != null) + LoginAlertBox( + message: finishError, + actionLabel: RestrictedStepTexts.retry, + onAction: _busy ? null : () => unawaited(_finish()), + ) + else if (alert != null) + LoginAlertBox(message: alert), + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: AppSpacing.s16, + children: [ + if (askTemporary) + LoginPasswordField( + label: PasswordChangeTexts.temporaryLabel, + controller: _temporary, + focusNode: _temporaryFocus, + shown: _temporaryShown, + onToggle: () => + setState(() => _temporaryShown = !_temporaryShown), + toggleLabel: PasswordChangeTexts.showTemporary, + hint: PasswordChangeTexts.temporaryHint, + hintIsError: _problems.currentMissing, + error: _temporaryError, + textInputAction: TextInputAction.next, + onSubmitted: (_) => _nextFocus.requestFocus(), + onChanged: _onTemporaryChanged, + autofillHints: const [AutofillHints.password], + readOnly: _blocked, + ), + LoginPasswordField( + label: PasswordChangeTexts.nextLabel, + controller: _next, + focusNode: _nextFocus, + shown: _nextShown, + onToggle: () => setState(() => _nextShown = !_nextShown), + toggleLabel: PasswordChangeTexts.showNext, + hint: PasswordChangeTexts.nextHint, + hintIsError: _problems.next == NewPasswordProblem.rule, + error: _nextError, + textInputAction: TextInputAction.next, + onSubmitted: (_) => _confirmFocus.requestFocus(), + onChanged: _onNextChanged, + autofillHints: const [AutofillHints.newPassword], + readOnly: _blocked, + ), + LoginTextField( + label: PasswordChangeTexts.confirmLabel, + controller: _confirm, + focusNode: _confirmFocus, + // 보기 단추 없이 새 비밀번호의 [보기]를 따른다 + obscureText: !_nextShown, + keyboardType: TextInputType.visiblePassword, + error: _problems.confirmMismatch + ? PasswordChangeTexts.mismatch + : null, + textInputAction: showCode + ? TextInputAction.next + : TextInputAction.done, + onSubmitted: (_) => + showCode ? _codeFocus.requestFocus() : unawaited(_submit()), + onChanged: _onConfirmChanged, + autofillHints: const [AutofillHints.newPassword], + readOnly: _blocked, + ), + if (showCode) + LoginCodeField( + label: PasswordChangeTexts.codeLabel, + controller: _code, + focusNode: _codeFocus, + hint: PasswordChangeTexts.codeHint, + error: _codeError, + textInputAction: TextInputAction.done, + onSubmitted: (_) => unawaited(_submit()), + onChanged: _onCodeChanged, + readOnly: _blocked, + ), + LoginSubmitButton( + label: PasswordChangeTexts.submit, + busyLabel: PasswordChangeTexts.submitting, + busy: _busy, + enabled: finishError == null, + onPressed: () => unawaited(_submit()), + inForm: true, + ), + ], + ), + const LoginNote(text: PasswordChangeTexts.note), + ], + ); + } +} diff --git a/flutter/lib/features/auth/presentation/pages/restricted_login_page.dart b/flutter/lib/features/auth/presentation/pages/restricted_login_page.dart new file mode 100644 index 0000000..2083254 --- /dev/null +++ b/flutter/lib/features/auth/presentation/pages/restricted_login_page.dart @@ -0,0 +1,34 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/auth_interceptor.dart'; +import 'package:arasystem_app/core/api/error_lexicon.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_frame.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_parts.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 앱이 모르는 제한(서버가 새 제한을 더했을 때) — `AUTH_010` 사전 문구와 [다른 계정으로 로그인]만(웹과 같다 — 다시 받기 · 다시 보내기를 되풀이하지 않는다). +/// +/// 제목이 없는 화면이라 사전 문구 상자가 화면 이름이다(단계가 바뀌면 화면 낭독기가 읽는다). +class RestrictedLoginPage extends ConsumerWidget { + const RestrictedLoginPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final SessionState? state = ref.watch(sessionProvider).value; + final String? email = state is SignedIn ? state.info.email : null; + return LoginStepFrame( + onOtherAccount: () => + unawaited(ref.read(sessionProvider.notifier).switchAccount()), + children: [ + if (email != null) LoginAccountChip(email: email), + LoginAlertBox( + message: ErrorLexicon.getMessage(kRestrictedSessionCode), + namesRoute: true, + ), + ], + ); + } +} diff --git a/flutter/lib/features/auth/presentation/pages/two_factor_setup_page.dart b/flutter/lib/features/auth/presentation/pages/two_factor_setup_page.dart new file mode 100644 index 0000000..0685756 --- /dev/null +++ b/flutter/lib/features/auth/presentation/pages/two_factor_setup_page.dart @@ -0,0 +1,633 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/api/error_lexicon.dart'; +import 'package:arasystem_app/core/constants/login_texts.dart'; +import 'package:arasystem_app/core/constants/screen_titles.dart'; +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/theme/app_theme.dart'; +import 'package:arasystem_app/core/theme/app_typography.dart'; +import 'package:arasystem_app/core/utils/display_text.dart'; +import 'package:arasystem_app/core/utils/otp_code.dart'; +import 'package:arasystem_app/features/auth/data/models/two_factor_enrollment.dart'; +import 'package:arasystem_app/features/auth/data/models/two_factor_status.dart'; +import 'package:arasystem_app/features/auth/presentation/login_failures.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/login_providers.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_fields.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_frame.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_parts.dart'; +import 'package:arasystem_app/shared/widgets/keep_all_text.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 키를 받는 상태 +enum _EnrollmentLoad { loading, ready, failed } + +/// 로그인 넷째 단계 — 2단계 인증 켜기(제한 `twoFactorSetup` — 앱 보드 `App1Login` 「2단계 인증 켜기」 · 계약 6절 "2단계 인증 필수"). +/// +/// - 같은 휴대폰이라 QR 대신 ① [인증 앱에 추가](otpauth 주소로 인증 앱을 바로 연다) · 키와 [복사] ② 코드 칸 (③ 지금 비밀번호 — 쥐고 있지 않을 때만). +/// - 키는 이 화면을 열 때마다 새로 받는다(`POST /two-factor/enrollments` — 10분 안에 켠다). **키 · otpauth 주소는 이 화면의 메모리에만** 두고 +/// 켜거나 떠나면 버린다(Riverpod 상태 · 로그에 두지 않는다). [복사]한 키는 클립보드에 남는다 — 앱이 지우지 않는다. +/// - 인증 앱을 찾지 못하면(열 앱 없음) ① 아래에 안내 글 — 키를 직접 넣는 길이 바로 아래에 있다. +/// - 서버 거절: `AUTH_006` 코드 칸(비우고 초점 — 키는 그대로), `AUTH_005` 지금 비밀번호 칸(쥔 것이었으면 지우고 ③ 을 보인다), +/// `AUTH_008`(시간 지남 · 그사이 바뀜) 위쪽 상자 + 새 키(다른 곳에서 이미 켰으면 이 단계를 건너뛴다), 시도 과다(`REQ_001`) 위쪽 상자. +/// 그 밖은 전역 알림 — 서버의 `VAL_001` 도(코드는 보내기 전에 6자리를 보므로 지금 비밀번호 쪽이다 · 맞는 코드는 지우지 않는다, 웹과 같다). +/// - 켜면 세션 노티파이어가 새 토큰을 받아 메일로 옮긴다. 넘기지 못하면 위쪽 상자에 사전 문구 + [다시 시도] — 그동안 칸 · 단추는 막는다(이미 켰다). +/// - 보내는 동안 · 넘기는 동안은 [다른 계정으로 로그인]을 막는다(웹과 같다 — 서버가 이미 켰을 수 있다). +class TwoFactorSetupPage extends ConsumerStatefulWidget { + const TwoFactorSetupPage({super.key}); + + @override + ConsumerState createState() => _TwoFactorSetupPageState(); +} + +class _TwoFactorSetupPageState extends ConsumerState { + final TextEditingController _code = TextEditingController(); + final TextEditingController _password = TextEditingController(); + final FocusNode _codeFocus = FocusNode(); + final FocusNode _passwordFocus = FocusNode(); + + /// 로그인 때 넣은 비밀번호(또는 방금 정한 새 비밀번호)를 세션 노티파이어가 쥐고 있나 — 없으면 ③ 칸 + late final ValueListenable _held; + late final String _email; + + /// 등록 — 키 · otpauth 주소(비밀). 이 화면의 메모리에만 + TwoFactorEnrollment? _enrollment; + _EnrollmentLoad _load = _EnrollmentLoad.loading; + + /// 키를 받지 못한 까닭 — 사전 문구 + String? _loadError; + + /// 키를 받는 차례 — 다시 받으면 앞의 응답은 버린다 + int _loadSeq = 0; + + /// [인증 앱에 추가]로 열 앱을 찾지 못했다 + bool _openFailed = false; + bool _passwordShown = false; + bool _submitting = false; + bool _finishing = false; + + /// 위쪽 상자 — 처음부터 다시(`AUTH_008`) · 시도 과다(`REQ_001`) 사전 문구. 다음에 보낼 때 내린다 + String? _alert; + + /// 켰지만 다음으로 넘기지 못했다 — 사전 문구(위쪽 상자 + [다시 시도]) + String? _finishError; + String? _codeError; + String? _passwordError; + + /// ③ 을 비우고 보냈다 — 안내 글이 빨간 오류가 된다 + bool _passwordMissing = false; + + /// 켰다(또는 다른 곳에서 이미 켰다) — 키 · 칸은 쓸 일이 없다. 넘기지 못했으면 위쪽 상자와 [다시 시도]만 남긴다 + bool _enabled = false; + + bool get _busy => _submitting || _finishing; + + /// 칸 · 단추를 막는다 — 바쁘거나, 이미 켰는데 넘기지 못했다 + bool get _blocked => _busy || _finishError != null; + + @override + void initState() { + super.initState(); + final SessionNotifier session = ref.read(sessionProvider.notifier); + _held = session.heldLoginListenable; + _held.addListener(_onHeldChanged); + final SessionState? state = ref.read(sessionProvider).value; + _email = state is SignedIn ? state.info.email : ''; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + unawaited(ref.read(supportContactProvider.notifier).ensureLoaded()); + unawaited(_loadEnrollment()); + }); + } + + @override + void dispose() { + _held.removeListener(_onHeldChanged); + // 비밀을 버린다 — 키 · otpauth 주소 · 코드 · 비밀번호 + _loadSeq++; + _enrollment = null; + _code.clear(); + _password.clear(); + _code.dispose(); + _password.dispose(); + _codeFocus.dispose(); + _passwordFocus.dispose(); + super.dispose(); + } + + void _onHeldChanged() { + if (mounted) setState(() {}); + } + + /// 이 화면이 아직 있고 세션도 그대로인가 — 늦게 온 응답을 쓸지 + bool _stillHere(int attempt) => + mounted && ref.read(sessionProvider.notifier).attempt == attempt; + + Future _loadEnrollment() async { + final int seq = ++_loadSeq; + final int attempt = ref.read(sessionProvider.notifier).attempt; + setState(() { + _enrollment = null; + _load = _EnrollmentLoad.loading; + _loadError = null; + _openFailed = false; + }); + try { + final TwoFactorEnrollment enrollment = await ref + .read(twoFactorRepositoryProvider) + .startEnrollment(); + if (seq != _loadSeq || !_stillHere(attempt)) return; + setState(() { + _enrollment = enrollment; + _load = _EnrollmentLoad.ready; + }); + } on DioException catch (error) { + if (seq != _loadSeq || !_stillHere(attempt)) return; + // 이미 켜져 있다 — 다른 곳에서 켰다 + if (apiErrorCodeOf(error) == 'AUTH_008' && + await _skipIfEnabledElsewhere(attempt)) { + return; + } + if (seq != _loadSeq || !_stillHere(attempt)) return; + setState(() { + _load = _EnrollmentLoad.failed; + _loadError = toApiException(error).message; + }); + } + } + + /// 그사이 다른 곳에서 켰는지 — 켰으면 이 단계를 건너뛴다(true) + Future _skipIfEnabledElsewhere(int attempt) async { + final TwoFactorStatus status; + try { + status = await ref.read(twoFactorRepositoryProvider).status(); + } on DioException { + return false; + } + if (!status.isEnabled) return false; + if (_stillHere(attempt)) { + setState(() { + _enabled = true; + _loadSeq++; + _enrollment = null; + _code.clear(); + _password.clear(); + }); + await _finish(announce: false); + } + return true; + } + + Future _openAuthenticator() async { + final TwoFactorEnrollment? enrollment = _enrollment; + if (enrollment == null) return; + final bool opened = await ref + .read(authenticatorLauncherProvider) + .open(enrollment.otpauthUrl); + if (mounted) setState(() => _openFailed = !opened); + } + + Future _copyKey() async { + final TwoFactorEnrollment? enrollment = _enrollment; + if (enrollment == null) return; + final ScaffoldMessengerState messenger = ScaffoldMessenger.of(context); + bool copied = true; + try { + // 키를 띄어쓰기 없이 — 클립보드에 남는다(앱이 지우지 않는다) + await Clipboard.setData(ClipboardData(text: enrollment.secret)); + } on PlatformException { + copied = false; + } + messenger + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text( + copied + ? TwoFactorSetupTexts.copied + : TwoFactorSetupTexts.copyFailed, + ), + ), + ); + } + + Future _submit() async { + final TwoFactorEnrollment? enrollment = _enrollment; + if (_blocked || enrollment == null) return; + final SessionNotifier session = ref.read(sessionProvider.notifier); + final bool held = session.heldLogin != null; + final bool codeMissing = !isCompleteOtp(_code.text); + final bool passwordMissing = !held && _password.text.isEmpty; + setState(() { + _alert = null; + _codeError = codeMissing ? ErrorLexicon.getMessage('AUTH_007') : null; + _passwordError = null; + _passwordMissing = passwordMissing; + }); + if (codeMissing) { + _codeFocus.requestFocus(); + return; + } + if (passwordMissing) { + // 10분이 막 지나 ③ 칸이 이제 생겼을 수 있다 — 그린 뒤에 초점 + _focusLater(_passwordFocus); + return; + } + + final int attempt = session.attempt; + setState(() => _submitting = true); + final RestrictedStepResult result; + try { + result = await session.enableTwoFactor( + enrollmentId: enrollment.enrollmentId, + otpCode: _code.text, + currentPassword: held ? null : _password.text, + ); + } on DioException catch (error) { + if (_stillHere(attempt)) await _onFailure(error, attempt); + return; + } finally { + if (mounted) setState(() => _submitting = false); + } + if (!mounted) return; + switch (result) { + case RestrictedStepResult.stale: + return; + case RestrictedStepResult.needsPassword: + setState(() => _passwordMissing = true); + _focusLater(_passwordFocus); + return; + case RestrictedStepResult.done: + // 켰다 — 다시 쓸 일이 없는 비밀(키 · 코드 · 비밀번호)을 버린다 + setState(() { + _enabled = true; + _loadSeq++; + _enrollment = null; + _code.clear(); + _password.clear(); + }); + } + await _finish(announce: true); + } + + Future _onFailure(DioException error, int attempt) async { + final String message = toApiException(error).message; + switch (apiErrorCodeOf(error)) { + case 'AUTH_006': + case 'AUTH_007': + setState(() => _codeError = message); + _code.clear(); + _codeFocus.requestFocus(); + case 'REQ_001': + // 칸 · 단추 · 키는 그대로 — 잠시 뒤 다시 보낼 수 있다 + setState(() => _alert = message); + case 'AUTH_005': + // 쥔 비밀번호였으면 세션 노티파이어가 지웠다 — ③ 칸이 이 오류와 함께 보인다 + setState(() => _passwordError = message); + _focusLater(_passwordFocus); + case 'AUTH_008': + if (await _skipIfEnabledElsewhere(attempt)) return; + if (!_stillHere(attempt)) return; + setState(() { + _alert = message; + _codeError = null; + _passwordError = null; + }); + _code.clear(); + await _loadEnrollment(); + default: + showLoginFailure(ref, error); + } + } + + /// 켠 뒤 — 새 토큰을 받아 메일로. 넘기지 못하면 위쪽 상자 + [다시 시도] + Future _finish({required bool announce}) async { + final SessionNotifier session = ref.read(sessionProvider.notifier); + final ScaffoldMessengerState? messenger = ScaffoldMessenger.maybeOf( + context, + ); + setState(() { + _finishing = true; + _finishError = null; + }); + try { + final RestrictedStepResult result = await session.finishRestrictedStep(); + if (announce && result == RestrictedStepResult.done) { + messenger?.showSnackBar( + const SnackBar(content: Text(TwoFactorSetupTexts.done)), + ); + } + } on DioException catch (error) { + if (mounted) { + setState(() => _finishError = toApiException(error).message); + } + } finally { + if (mounted) setState(() => _finishing = false); + } + } + + void _focusLater(FocusNode node) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) node.requestFocus(); + }); + } + + void _onCodeChanged(String _) { + if (_codeError != null) setState(() => _codeError = null); + } + + void _onPasswordChanged(String _) { + if (_passwordError != null || _passwordMissing) { + setState(() { + _passwordError = null; + _passwordMissing = false; + }); + } + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final TextStyle? stepTitle = theme.textTheme.titleSmall; + // 켠 뒤에는 쥔 비밀번호를 지웠다 — ③ 을 다시 보이지 않는다 + final bool askPassword = !_held.value && !_enabled; + final String? finishError = _finishError; + final String? alert = _alert; + // 켰지만 넘기지 못했다 — 키 · 칸 · 단추는 쓸 일이 없어 위쪽 상자와 [다시 시도]만 남긴다 + final bool onlyRetry = _enabled && finishError != null; + return LoginStepFrame( + topPadding: AppSpacing.s24, + onOtherAccount: () => + unawaited(ref.read(sessionProvider.notifier).switchAccount()), + otherAccountEnabled: !_busy, + children: [ + LoginHeading( + title: ScreenTitles.twoFactorSetup, + subtitle: TwoFactorSetupTexts.subtitle, + email: _email, + ), + if (finishError != null) + LoginAlertBox( + message: finishError, + actionLabel: RestrictedStepTexts.retry, + onAction: _busy ? null : () => unawaited(_finish(announce: true)), + ) + else if (alert != null) + LoginAlertBox(message: alert), + if (!onlyRetry) ...[ + _steps(stepTitle, askPassword), + LoginSubmitButton( + label: TwoFactorSetupTexts.submit, + busyLabel: TwoFactorSetupTexts.submitting, + busy: _busy, + enabled: _load == _EnrollmentLoad.ready && finishError == null, + onPressed: () => unawaited(_submit()), + ), + ], + LoginSupportLine( + question: SupportLineTexts.authenticatorQuestion, + fallback: SupportLineTexts.authenticatorFallback, + contact: ref.watch(supportContactProvider), + ), + const LoginNote(text: TwoFactorSetupTexts.note), + ], + ); + } + + /// 차례 ① 인증 앱에 추가 · ② 코드 · (③ 지금 비밀번호) + Widget _steps(TextStyle? stepTitle, bool askPassword) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: AppSpacing.s22, + children: [ + LoginStepItem( + number: 1, + child: _AddToAuthenticator( + email: _email, + enrollment: _enrollment, + load: _load, + loadError: _loadError, + openFailed: _openFailed, + enabled: !_blocked, + onOpen: () => unawaited(_openAuthenticator()), + onCopy: () => unawaited(_copyKey()), + onRetry: () => unawaited(_loadEnrollment()), + ), + ), + LoginStepItem( + number: 2, + child: LoginCodeField( + label: TwoFactorSetupTexts.codeLabel, + labelStyle: stepTitle, + description: TwoFactorSetupTexts.codeHint, + controller: _code, + focusNode: _codeFocus, + error: _codeError, + textInputAction: askPassword + ? TextInputAction.next + : TextInputAction.done, + onSubmitted: (_) => askPassword + ? _passwordFocus.requestFocus() + : unawaited(_submit()), + onChanged: _onCodeChanged, + readOnly: _blocked, + ), + ), + if (askPassword) + LoginStepItem( + number: 3, + child: LoginPasswordField( + label: TwoFactorSetupTexts.passwordLabel, + controller: _password, + focusNode: _passwordFocus, + shown: _passwordShown, + onToggle: () => setState(() => _passwordShown = !_passwordShown), + toggleLabel: TwoFactorSetupTexts.showPassword, + hint: TwoFactorSetupTexts.passwordHint, + hintIsError: _passwordMissing, + error: _passwordError, + textInputAction: TextInputAction.done, + onSubmitted: (_) => unawaited(_submit()), + onChanged: _onPasswordChanged, + autofillHints: const [AutofillHints.password], + readOnly: _blocked, + ), + ), + ], + ); + } +} + +/// ① 인증 앱에 추가 — [인증 앱에 추가] · (찾지 못함 안내) · 키와 [복사] · 저장될 이름. 키를 받는 동안은 회색 자리, 못 받으면 사전 문구 + [다시 시도] +class _AddToAuthenticator extends StatelessWidget { + const _AddToAuthenticator({ + required this.email, + required this.enrollment, + required this.load, + required this.loadError, + required this.openFailed, + required this.enabled, + required this.onOpen, + required this.onCopy, + required this.onRetry, + }); + + final String email; + final TwoFactorEnrollment? enrollment; + final _EnrollmentLoad load; + final String? loadError; + final bool openFailed; + + /// 켜는 중 · 이미 켰으면 누를 수 없다 + final bool enabled; + final VoidCallback onOpen; + final VoidCallback onCopy; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + final String? error = loadError; + final bool ready = enrollment != null && enabled; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: AppSpacing.s10, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: AppSpacing.s4, + children: [ + Semantics( + header: true, + child: KeepAllText( + TwoFactorSetupTexts.addTitle, + style: theme.textTheme.titleSmall, + ), + ), + KeepAllText( + TwoFactorSetupTexts.addHint, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + if (load == _EnrollmentLoad.failed && error != null) + LoginAlertBox( + message: error, + actionLabel: TwoFactorSetupTexts.retry, + onAction: onRetry, + ) + else ...[ + OutlinedButton.icon( + onPressed: ready ? onOpen : null, + icon: const Icon(Icons.open_in_new, size: AppSize.iconSmall), + label: const Text(TwoFactorSetupTexts.addButton), + ), + if (openFailed) + const LoginFieldMessage( + text: TwoFactorSetupTexts.addFailed, + isError: true, + ), + Padding( + padding: const EdgeInsets.only(top: AppSpacing.s2), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: AppSpacing.s6, + children: [ + KeepAllText( + TwoFactorSetupTexts.keyLabel, + style: theme.textTheme.labelMedium?.copyWith( + fontWeight: AppTypography.semiBold, + color: extra.textStrong, + ), + ), + _KeyBox(enrollment: enrollment, onCopy: ready ? onCopy : null), + KeepAllText( + TwoFactorSetupTexts.savedAs(email), + style: theme.textTheme.labelSmall?.copyWith( + color: extra.textMuted, + ), + ), + ], + ), + ), + ], + ], + ); + } +} + +/// 키 상자 — 4자씩 · 두 줄 · 고정폭(보기 위한 띄어쓰기) + [복사](키를 띄어쓰기 없이). 받는 동안은 회색 자리 +class _KeyBox extends StatelessWidget { + const _KeyBox({required this.enrollment, required this.onCopy}); + + final TwoFactorEnrollment? enrollment; + final VoidCallback? onCopy; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme scheme = theme.colorScheme; + final TextStyle? base = theme.textTheme.bodyMedium; + final double fontSize = base?.fontSize ?? AppTypography.bodyMedium; + final TextStyle keyStyle = TextStyle( + fontFamily: AppTypography.monospaceFamily, + fontSize: fontSize, + height: AppTypography.lineHeightRelaxed, + letterSpacing: fontSize * AppTypography.letterSpacingKeyEm, + color: scheme.onSurface, + ); + final TwoFactorEnrollment? current = enrollment; + final Widget key = current == null + // 키를 받는 중 — 두 줄 높이의 회색 자리 + ? Semantics( + label: TwoFactorSetupTexts.keyLoading, + child: Container( + height: fontSize * AppTypography.lineHeightRelaxed * 2, + decoration: BoxDecoration( + color: scheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + ), + ) + : Text(keyLines(current.secret).join('\n'), style: keyStyle); + return Container( + padding: const EdgeInsetsDirectional.fromSTEB( + AppSpacing.s12, + AppSpacing.s4, + AppSpacing.s2, + AppSpacing.s4, + ), + decoration: BoxDecoration( + color: scheme.surfaceContainer, + border: Border.all(color: scheme.outlineVariant), + borderRadius: BorderRadius.circular(AppRadius.lg), + ), + child: Row( + spacing: AppSpacing.s4, + children: [ + Expanded(child: key), + TextButton.icon( + onPressed: onCopy, + icon: const Icon( + Icons.content_copy_outlined, + size: AppSize.iconSmall, + ), + label: const Text( + TwoFactorSetupTexts.copy, + semanticsLabel: TwoFactorSetupTexts.copyLabel, + ), + ), + ], + ), + ); + } +} diff --git a/flutter/lib/features/auth/presentation/providers/login_providers.dart b/flutter/lib/features/auth/presentation/providers/login_providers.dart new file mode 100644 index 0000000..2592a82 --- /dev/null +++ b/flutter/lib/features/auth/presentation/providers/login_providers.dart @@ -0,0 +1,62 @@ +import 'package:arasystem_app/core/api/dio_provider.dart'; +import 'package:arasystem_app/core/utils/provider_retry.dart'; +import 'package:arasystem_app/features/auth/data/authenticator_launcher.dart'; +import 'package:arasystem_app/features/auth/data/repositories/support_contact_repository.dart'; +import 'package:arasystem_app/features/auth/data/repositories/two_factor_repository.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 2단계 인증 API — 로그인 틀의 제한 단계가 쓴다 +final Provider twoFactorRepositoryProvider = + Provider( + (Ref ref) => TwoFactorRepository(ref.watch(dioProvider)), + ); + +/// 문의 연락처 API +final Provider supportContactRepositoryProvider = + Provider( + (Ref ref) => SupportContactRepository(ref.watch(dioProvider)), + ); + +/// [인증 앱에 추가] — 시험은 가짜로 덮어쓴다 +final Provider authenticatorLauncherProvider = + Provider((Ref ref) => const AuthenticatorLauncher()); + +/// 문의 연락처 한 줄 — 로그인 첫 화면 · 코드 단계 · 2단계 켜기의 문의 줄. 정하지 않았거나 받지 못했으면 null(화면은 지금 글). +/// +/// 화면이 [SupportContactNotifier.ensureLoaded] 를 부른다. 한 번 받으면 앱이 떠 있는 동안 다시 묻지 않고(운영 도구로 바꾸면 다음 실행에 반영), +/// 받지 못했으면 다음에 화면을 열 때 다시 묻는다(웹 `support-contact.store` 와 같다). 회사 설정값이라 로그아웃 때 지우지 않는다. +final NotifierProvider supportContactProvider = + NotifierProvider( + SupportContactNotifier.new, + retry: noAutomaticRetry, + ); + +class SupportContactNotifier extends Notifier { + Future? _loading; + bool _loaded = false; + + @override + String? build() => null; + + /// 아직 받지 않았으면 받는다 — 몇 번을 불러도 요청은 하나 + Future ensureLoaded() { + if (_loaded) return Future.value(); + return _loading ??= _load(); + } + + Future _load() async { + try { + final String? text = await ref + .read(supportContactRepositoryProvider) + .fetch(); + if (!ref.mounted) return; + state = text; + _loaded = true; + } on DioException { + // 받지 못했다 — 지금 글 그대로(다음에 화면을 열 때 다시 묻는다) + } finally { + _loading = null; + } + } +} diff --git a/flutter/lib/features/auth/presentation/providers/login_secret.dart b/flutter/lib/features/auth/presentation/providers/login_secret.dart index cbf5ea8..9de70bd 100644 --- a/flutter/lib/features/auth/presentation/providers/login_secret.dart +++ b/flutter/lib/features/auth/presentation/providers/login_secret.dart @@ -1,15 +1,17 @@ import 'dart:async'; import 'package:arasystem_app/core/utils/clock.dart'; +import 'package:flutter/foundation.dart'; /// 로그인 때 넣은 비밀번호를 쥐고 있는 시간 — 웹과 같다(계약 5절 코드 단계 · 6절 제한 단계) const Duration kLoginSecretLifetime = Duration(minutes: 10); /// 로그인 때 넣은 주소 · 비밀번호 — 화면 상태(Riverpod 상태)에 두지 않고 세션 노티파이어 메모리에만 둔다. /// -/// 쓰는 곳: 코드 단계(비밀번호를 한 번 더 보낸다) · 새 비밀번호 단계(지금 비밀번호로 쓴다). -/// 지우는 때: 로그인을 마침 · [다른 계정으로 로그인] · 로그아웃 · 세션 끝 · 서버가 마지막으로 확인하고 10분. +/// 쓰는 곳: 코드 단계(비밀번호를 한 번 더 보낸다) · 새 비밀번호 단계(지금 비밀번호로 쓴다) · 2단계 켜기(지금 비밀번호 — 새 비밀번호를 정했으면 그것). +/// 지우는 때: 로그인을 마침 · [다른 계정으로 로그인] · 로그아웃 · 세션 끝 · 서버가 거절함(`AUTH_005`) · 서버가 마지막으로 확인하고 10분. /// 10분은 타이머와 시계 둘 다로 본다 — 앱이 뒤에 있는 동안 타이머가 늦어도 읽을 때 시계로 다시 본다. +/// 쥐고 있는지([held] — 참 · 거짓 하나, 비밀이 아니다)는 화면이 듣는다: 없으면 임시 · 지금 비밀번호 칸을 보인다. class LoginSecret { LoginSecret({ Clock clock = systemClock, @@ -19,6 +21,7 @@ class LoginSecret { final Clock _clock; final Duration _lifetime; + final ValueNotifier _held = ValueNotifier(false); String? _email; String? _password; @@ -27,10 +30,11 @@ class LoginSecret { /// 서버가 비밀번호를 맞다고 본 때 쥔다 — 이미 쥐고 있으면 바꾼다 void hold(String email, String password) { - clear(); + _forget(); _email = email; _password = password; renew(); + _held.value = true; } /// 서버가 다시 확인했다 — 10분을 이때부터 다시 센다 @@ -57,7 +61,15 @@ class LoginSecret { /// 쥐고 있는가 (10분이 지났는지는 [read] 가 본다) bool get isHeld => _password != null; + /// 쥐고 있는지가 바뀔 때 알린다 — 화면이 칸을 보일지 정한다(값은 참 · 거짓뿐) + ValueListenable get held => _held; + void clear() { + _forget(); + _held.value = false; + } + + void _forget() { _timer?.cancel(); _timer = null; _email = null; diff --git a/flutter/lib/features/auth/presentation/providers/session_provider.dart b/flutter/lib/features/auth/presentation/providers/session_provider.dart index 9661534..1c096aa 100644 --- a/flutter/lib/features/auth/presentation/providers/session_provider.dart +++ b/flutter/lib/features/auth/presentation/providers/session_provider.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/api/api_exception.dart'; import 'package:arasystem_app/core/api/api_paths.dart'; import 'package:arasystem_app/core/api/dio_provider.dart'; import 'package:arasystem_app/core/api/session_events.dart'; @@ -13,9 +14,12 @@ import 'package:arasystem_app/core/utils/provider_retry.dart'; import 'package:arasystem_app/features/auth/data/models/app_login_result.dart'; import 'package:arasystem_app/features/auth/data/models/auth_session_info.dart'; import 'package:arasystem_app/features/auth/data/repositories/auth_repository.dart'; +import 'package:arasystem_app/features/auth/data/repositories/two_factor_repository.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/login_providers.dart'; import 'package:arasystem_app/features/auth/presentation/providers/login_secret.dart'; import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart'; import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -38,24 +42,42 @@ final AsyncNotifierProvider sessionProvider = retry: noAutomaticRetry, ); -/// 세션 노티파이어 — 되살리기 · 로그인 · 코드 단계 · 로그아웃 · 세션 끝 · 제한 다시 맞추기. +/// 제한 단계(새 비밀번호 · 2단계 켜기)의 일이 어떻게 끝났나 — 실패는 `DioException` 으로 던진다 +enum RestrictedStepResult { + /// 마쳤다 — 세션이 그대로다 + done, + + /// 지금 비밀번호가 없다(로그인 때 넣은 것을 쥐고 있지 않다 — 10분 지남 · 앱을 다시 열었다). 보내지 않았다 — 화면이 칸을 보인다 + needsPassword, + + /// 그사이 세션이 바뀌었다(로그아웃 · 다른 계정 · 세션 끝) — 결과를 쓰지 않았다 + stale, +} + +/// 세션 노티파이어 — 되살리기 · 로그인 · 코드 단계 · 제한 단계(새 비밀번호 · 2단계 켜기) · 로그아웃 · 세션 끝 · 제한 다시 맞추기. /// /// 계약 `docs/mail/api-contract.md` 1절 "앱 로그인 › 앱이 할 일"대로 한다. /// - 실패는 늘 `DioException`(`error` 에 `ApiException`)으로 던진다 — 화면이 `apiErrorCodeOf` 로 갈라 칸 아래 · 위쪽 상자에 보인다. /// 알림은 인터셉터가 이미 띄웠거나(알림을 끄지 않은 코드) 끈 것이다 — 여기서 문구를 만들지 않는다. -/// 비밀이 든 요청(로그인 · 코드)의 실패 예외는 본문을 비운 채 온다 — 화면이 `AsyncValue` 로 상태에 두어도 된다. -/// - **시도 세대**([_epoch]): 로그아웃 · 다른 계정으로 로그인 · 세션 끝 때 오른다. 그 전에 시작한 되살리기 · 로그인 · 코드 · 제한 다시 맞추기의 -/// 늦은 결과는 버리고, 버린 로그인 결과에 토큰이 있으면 그 토큰으로 조용히 앱 logout 한다(끝낸 세션을 되살리지 않게). +/// 비밀이 든 요청(로그인 · 코드 · 비밀번호 바꾸기 · 2단계 켜기)의 실패 예외는 본문을 비운 채 온다 — 화면이 `AsyncValue` 로 상태에 두어도 된다. +/// - **시도 세대**([_epoch] — 화면은 [attempt]): 로그아웃 · 다른 계정으로 로그인 · 세션 끝 때 오른다. 그 전에 시작한 되살리기 · 로그인 · 코드 · +/// 제한 단계 · 제한 다시 맞추기의 늦은 결과는 버리고, 버린 로그인 결과에 토큰이 있으면 그 토큰으로 조용히 앱 logout 한다(끝낸 세션을 되살리지 않게). class SessionNotifier extends AsyncNotifier { LoginSecret? _secretHolder; Future? _rechecking; int _epoch = 0; + bool _passedCodeStep = false; + + /// 답을 기다리는 제한 다시 맞추기 요청 — 세션이 바뀌면(로그아웃 · 세션 끝) stale 로 답해 인터셉터를 놓아준다 + final List _pendingRechecks = []; LoginSecret get _secret => _secretHolder ??= LoginSecret(clock: ref.read(clockProvider)); AuthRepository get _repository => ref.read(authRepositoryProvider); + TwoFactorRepository get _twoFactor => ref.read(twoFactorRepositoryProvider); + TokenStore get _store => ref.read(tokenStoreProvider); TokenRefresher get _refresher => ref.read(tokenRefresherProvider); @@ -71,6 +93,7 @@ class SessionNotifier extends AsyncNotifier { ref.onDispose(() { unawaited(subscription.cancel()); _secretHolder?.clear(); + _advanceEpoch(); }); final TokenStore store = ref.watch(tokenStoreProvider); @@ -78,9 +101,18 @@ class SessionNotifier extends AsyncNotifier { return _restore(); } - /// 로그인 때 넣은 주소 · 비밀번호 — 코드 단계 · 새 비밀번호 단계가 쓴다(10분이 지났거나 제한이 풀렸으면 null) + /// 로그인 때 넣은 주소 · 비밀번호 — 코드 단계 · 제한 단계가 쓴다(10분이 지났거나 제한이 풀렸으면 null) ({String email, String password})? get heldLogin => _secret.read(); + /// 로그인 때 넣은 비밀번호를 쥐고 있는지 — 제한 단계 화면이 듣고, 없으면 임시 · 지금 비밀번호 칸을 보인다(값은 참 · 거짓뿐) + ValueListenable get heldLoginListenable => _secret.held; + + /// 시도 세대 — 화면이 요청 전에 읽어 두고, 끝난 뒤 다르면(그사이 로그아웃 · 다른 계정 · 세션 끝) 결과를 보이지 않는다 + int get attempt => _epoch; + + /// 이번 로그인이 코드 단계를 거쳤다 — 2단계 인증을 켠 직원이라 새 비밀번호 단계가 처음부터 코드 칸을 보인다(계약 6절) + bool get passedCodeStep => _passedCodeStep; + /// 되살리기가 장애로 실패했을 때 다시 한다 — 방금의 갱신 실패를 다시 쓰지 않고 새로 묻는다 void retry() { _refresher.forgetFailure(); @@ -90,6 +122,7 @@ class SessionNotifier extends AsyncNotifier { /// 비밀번호 단계 Future signIn({required String email, required String password}) async { final int epoch = _epoch; + _passedCodeStep = false; final AppLoginResult result = await _repository.login( email: email, password: password, @@ -99,12 +132,18 @@ class SessionNotifier extends AsyncNotifier { /// 코드 단계 — 쥐고 있는 비밀번호와 함께 보낸다(계약 5절). /// - /// 10분이 지났으면 보내지 않고 첫 단계로 돌아간다. `AUTH_004`(그사이 비밀번호가 바뀜)도 첫 단계로 돌아간 뒤 던진다. + /// - 10분이 지났으면 보내지 않고 첫 단계로 돌아간다(주소는 남긴다). + /// - `AUTH_004`(그사이 비밀번호가 바뀜)도 첫 단계로 돌아간 뒤 던진다 — 첫 단계가 위쪽 상자에 보인다(주소는 남긴다). + /// - 코드를 실었는데도 서버가 코드를 묻으면(`twoFactorRequired`) 받아들이지 않은 코드로 보고 `AUTH_006` 으로 던진다(웹과 같다). Future submitCode(String otpCode) async { + final SessionState? current = state.value; final ({String email, String password})? held = _secret.read(); if (held == null) { - state = const AsyncData( - SignedOut(reason: SignOutReason.codeTimedOut), + state = AsyncData( + SignedOut( + reason: SignOutReason.codeTimedOut, + email: current is CodeRequired ? current.email : null, + ), ); return; } @@ -122,18 +161,150 @@ class SessionNotifier extends AsyncNotifier { ref.mounted && apiErrorCodeOf(error) == 'AUTH_004') { _secret.clear(); - state = const AsyncData(SignedOut()); + state = AsyncData( + SignedOut(reason: SignOutReason.passwordRejected, email: held.email), + ); } rethrow; } + if (result.status == LoginStatus.twoFactorRequired) { + if (!ref.mounted || epoch != _epoch) return; + throw apiFailure(RequestOptions(path: ApiPaths.appLogin), 'AUTH_006'); + } await _apply( result, epoch: epoch, email: held.email, password: held.password, + viaCode: true, ); } + /// 새 비밀번호 단계(제한 `passwordChange` — 계약 6절 "비밀번호 바꾸기 강제") — `PUT /api/auth/password`. + /// + /// [currentPassword] 가 null 이면 로그인 때 쥔 비밀번호(임시 비밀번호)를 쓴다 — 쥐고 있지 않으면 보내지 않고 [RestrictedStepResult.needsPassword]. + /// [otpCode] 는 2단계 인증을 켠 직원만(로그인 때 쓴 코드는 2분 동안 다시 받지 않는다 — 다음 코드). + /// - 쥔 비밀번호를 서버가 거절하면(`AUTH_005`) 지운다 — 화면이 임시 비밀번호 칸을 그 오류와 함께 보인다. + /// - 바뀌면 새 비밀번호를 쥔다(다음 단계 2단계 켜기의 지금 비밀번호 — 서버가 방금 확인했으니 10분을 새로 센다). + /// 다음 화면으로 옮기는 일은 [finishRestrictedStep] 이 한다. + Future changePassword({ + required String newPassword, + String? currentPassword, + String? otpCode, + }) async { + final int epoch = _epoch; + final SessionState? current = state.value; + if (current is! SignedIn) return RestrictedStepResult.stale; + final ({String email, String password})? held = currentPassword == null + ? _secret.read() + : null; + final String? password = currentPassword ?? held?.password; + if (password == null) return RestrictedStepResult.needsPassword; + try { + await _repository.changePassword( + currentPassword: password, + newPassword: newPassword, + otpCode: otpCode, + ); + } on DioException catch (error) { + if (epoch == _epoch && + ref.mounted && + held != null && + apiErrorCodeOf(error) == 'AUTH_005') { + _secret.clear(); + } + rethrow; + } + if (!ref.mounted || epoch != _epoch) return RestrictedStepResult.stale; + _secret.hold(current.info.email, newPassword); + return RestrictedStepResult.done; + } + + /// 2단계 켜기 단계(제한 `twoFactorSetup` — 계약 6절 "2단계 인증 필수") — `POST /api/two-factor/activation`. + /// + /// [currentPassword] 가 null 이면 쥔 비밀번호(로그인 때 넣은 것 — 방금 새 비밀번호를 정했으면 그것)를 쓴다 — 없으면 [RestrictedStepResult.needsPassword]. + /// 쥔 비밀번호를 서버가 거절하면(`AUTH_005`) 지운다. 켜면 쥔 비밀번호를 지운다(다시 쓸 일이 없다). 다음 화면은 [finishRestrictedStep]. + Future enableTwoFactor({ + required String enrollmentId, + required String otpCode, + String? currentPassword, + }) async { + final int epoch = _epoch; + if (state.value is! SignedIn) return RestrictedStepResult.stale; + final ({String email, String password})? held = currentPassword == null + ? _secret.read() + : null; + final String? password = currentPassword ?? held?.password; + if (password == null) return RestrictedStepResult.needsPassword; + try { + await _twoFactor.activate( + enrollmentId: enrollmentId, + otpCode: otpCode, + currentPassword: password, + ); + } on DioException catch (error) { + if (epoch == _epoch && + ref.mounted && + held != null && + apiErrorCodeOf(error) == 'AUTH_005') { + _secret.clear(); + } + rethrow; + } + if (!ref.mounted || epoch != _epoch) return RestrictedStepResult.stale; + _secret.clear(); + return RestrictedStepResult.done; + } + + /// 제한 단계를 마친 뒤 — 새 토큰을 받아 다음 제한(있으면 그 단계) · 제한 없음(메일)으로 옮긴다 + /// (계약 6절 "제한된 세션" — 푸는 요청이 성공하면 곧바로 갱신. 갱신도 한 번에 하나인 [TokenRefresher] 를 지난다). + /// + /// - 갱신이 일시 장애면 세션 확인(me — 세션 표의 지금 값)으로 정한다(웹과 같다). 둘 다 받지 못하면 갱신의 실패를 `DioException` 으로 던진다 — + /// 화면은 그 단계에 머물며 위쪽 상자에 사전 문구와 [다시 시도](이것을 다시 부른다). + /// - 갱신이 세션을 끝냈거나(401 · 유예 응답) 그사이 로그아웃했으면 [RestrictedStepResult.stale] — 세션 끝 흐름이 로그인 화면으로 옮긴다. + /// - 방금의 갱신 실패를 다시 쓰지 않는다 — [다시 시도]는 사용자가 누른 것이라 새로 묻는다(되살리기의 [retry] 와 같다). + Future finishRestrictedStep() async { + final int epoch = _epoch; + final SessionState? current = state.value; + if (current is! SignedIn) return RestrictedStepResult.stale; + bool isCurrent() => + epoch == _epoch && ref.mounted && state.value is SignedIn; + + _refresher.forgetFailure(); + final RefreshOutcome outcome = await _repository.refresh(); + if (!isCurrent()) return RestrictedStepResult.stale; + ApiException? refreshError; + switch (outcome) { + case RefreshSucceeded(:final Map? body): + final AuthSessionInfo? info = AuthSessionInfo.tryParse(body); + if (info != null && info.email == current.info.email) { + _setSignedIn(info); + return RestrictedStepResult.done; + } + // 세션 칸이 없다(그사이 다른 요청이 먼저 받아 두었다) — 아래 세션 확인으로 정한다 + case RefreshRejected(): + case RefreshDiscarded(): + return RestrictedStepResult.stale; + case RefreshFailed(:final ApiException error): + refreshError = error; + } + + final AuthSessionInfo info; + try { + info = await _repository.readSessionDirect(); + } on DioException { + if (!isCurrent()) return RestrictedStepResult.stale; + if (refreshError == null) rethrow; + throw DioException( + requestOptions: RequestOptions(path: ApiPaths.appRefresh), + error: refreshError, + ); + } + if (!isCurrent()) return RestrictedStepResult.stale; + _setSignedIn(info); + return RestrictedStepResult.done; + } + /// [다른 계정으로 로그인] — 리프레시 토큰이 있으면(로그인한 세션 · 되살리는 중 · 되살리기 실패) 로그아웃하고, /// 코드 단계면 쥔 비밀번호만 지운다. 진행 중인 로그인 · 코드의 늦은 결과는 버린다. Future switchAccount() async { @@ -141,17 +312,17 @@ class SessionNotifier extends AsyncNotifier { await signOut(reason: SignOutReason.switchAccount); return; } - _epoch++; + _advanceEpoch(); _secret.clear(); state = const AsyncData( SignedOut(reason: SignOutReason.switchAccount), ); } - /// 로그아웃 — 이 기기의 토큰과 세션 상태를 **먼저** 지우고, 그 뒤에 서버 세션을 끝내고 알림 토큰을 지운다. - /// 서버에 닿지 못해도 이 기기는 로그아웃 상태다. + /// 로그아웃 — 이 기기의 토큰과 세션 상태를 **먼저** 지운다. 그 뒤 이 기기의 알림 멈추기(알림창 지우기 · FCM 등록 풀기)와 서버 logout 을 + /// 함께 보낸다(서로 기다리지 않는다). 서버에 닿지 못해도 이 기기는 로그아웃 상태다. 알림 서버의 기기 줄은 부르지 않는다 — 서버 logout 이 세션과 함께 지운다. Future signOut({SignOutReason reason = SignOutReason.none}) async { - _epoch++; + _advanceEpoch(); _secret.clear(); // 메모리의 토큰은 이 부름 안에서 곧바로 지워진다(보안 저장소 읽기 · 지우기는 뒤에 — 실패해도 던지지 않는다) final Future clearing = _store.signOut(); @@ -177,7 +348,29 @@ class SessionNotifier extends AsyncNotifier { /// - me 가 401(액세스 토큰이 끝남)이면 갱신해 본다(같은 규칙) — 갱신이 거절되면 세션 끝이 로그인 화면으로 옮긴다 → [RestrictionCheck.stale] /// - 그사이 세션이 바뀌었으면 결과를 쓰지 않는다 → [RestrictionCheck.stale], 확인하지 못했으면 [RestrictionCheck.unknown] Future recheck() { - return _rechecking ??= _recheck().whenComplete(() => _rechecking = null); + final Future? running = _rechecking; + if (running != null) return running; + final Future started = _recheck(); + _rechecking = started; + started.whenComplete(() { + if (identical(_rechecking, started)) _rechecking = null; + }).ignore(); + return started; + } + + /// 시도 세대를 올린다 — 앞 세션의 진행 중인 다시 맞추기는 버리고(새 세션이 그것을 함께 기다리지 않게), + /// 답을 기다리는 요청에는 stale 로 답한다(다른 계정의 새 로그인 오류가 앞 세션의 me 뒤에서 기다리지 않게) + void _advanceEpoch() { + _epoch++; + _passedCodeStep = false; + _rechecking = null; + final List waiting = List.of( + _pendingRechecks, + ); + _pendingRechecks.clear(); + for (final SessionRecheckNeeded event in waiting) { + event.reply(RestrictionCheck.stale); + } } Future _recheck() async { @@ -231,6 +424,8 @@ class SessionNotifier extends AsyncNotifier { final String? code = apiErrorCodeOf(error); if (code == 'AUTH_001' || code == 'AUTH_002') { await _clearQuietly(); + // 앱이 꺼진 사이 세션이 끝났다 — 이 기기의 알림도 멈춘다(세션 끝과 같다) + unawaited(_unregisterPushQuietly()); return const SignedOut(); } // 연결 끊김 · 서버 장애 — 세션은 그대로 두고 시작 화면이 [다시 시도]를 보인다 @@ -252,6 +447,7 @@ class SessionNotifier extends AsyncNotifier { required int epoch, required String email, required String password, + bool viaCode = false, }) async { if (!ref.mounted || epoch != _epoch) { // 그사이 로그아웃 · [다른 계정으로 로그인] · 세션 끝 — 늦게 온 결과는 쓰지 않는다 @@ -286,15 +482,17 @@ class SessionNotifier extends AsyncNotifier { return; } final AuthSessionInfo info = result.session!; - if (info.restriction != null) { - // 제한 단계(새 비밀번호 · 2단계 켜기)가 지금 비밀번호로 쓴다 — 서버가 방금 확인했으니 10분을 새로 센다 + _passedCodeStep = viaCode; + if (_usesHeldPassword(info.restriction)) { + // 제한 단계(새 비밀번호 · 2단계 켜기)가 지금 비밀번호로 쓴다 — 서버가 방금 확인했으니 10분을 새로 센다. + // 모르는 제한은 쓸 단계가 없어 쥐지 않는다(웹 LoginPage 의 onSignedIn 과 같다) _secret.hold(email, password); } _setSignedIn(info); } } - /// 로그인한 상태로 둔다 — 제한이 풀렸으면 쥔 비밀번호를 지우고, 새로 쓸 수 있게 됐으면 푸시 기기를 등록한다 + /// 로그인한 상태로 둔다 — 제한이 풀렸거나 모르는 제한이 됐으면 쥔 비밀번호를 지우고, 새로 쓸 수 있게 됐으면 푸시 기기를 등록한다 void _setSignedIn(AuthSessionInfo info) { final SessionState? previous = state.value; final bool wasReady = @@ -304,12 +502,22 @@ class SessionNotifier extends AsyncNotifier { } void _afterSignedIn(AuthSessionInfo info, {required bool wasReady}) { - if (info.restriction != null) return; - // 제한이 풀렸다 — 다시 쓸 일이 없는 비밀번호를 쥐고 있지 않는다(계약 1절 "앱 로그인 › 앱이 할 일") - _secret.clear(); + // 제한이 풀렸다 · 모르는 제한이 됐다(갱신 · 다시 맞추기) — 다시 쓸 일이 없는 비밀번호를 쥐고 있지 않는다 + // (계약 1절 "앱 로그인 › 앱이 할 일" · 웹 LoginPage 의 onSignedIn) + if (!_usesHeldPassword(info.restriction)) _secret.clear(); + if (info.restriction != null) { + // 쓰던 세션에 제한이 새로 생겼다(갱신 응답 — 비밀번호 만료 · 2단계 기한) — 풀릴 때까지 알림 서버를 부르지 않는다 + if (wasReady) _push.pause(); + return; + } if (!wasReady) unawaited(_registerPush()); } + /// 로그인 때 넣은 비밀번호를 지금 비밀번호로 쓰는 제한 단계인가 — 새 비밀번호 · 2단계 켜기만 + static bool _usesHeldPassword(SessionRestriction? restriction) => + restriction == SessionRestriction.passwordChange || + restriction == SessionRestriction.twoFactorSetup; + Future _registerPush() async { try { await _push.register(); @@ -318,11 +526,13 @@ class SessionNotifier extends AsyncNotifier { } } + /// 이 기기의 새 메일 알림을 멈춘다 — 알림창의 "새 메일 N통"을 지우고 FCM 등록을 푼다(짧은 한도 · 실패는 삼킨다). + /// 서버의 기기 줄은 logout · 세션 끝이 세션과 함께 지운다 Future _unregisterPushQuietly() async { try { await _push.unregister().timeout(kPushUnregisterTimeout); } on Object { - // 이 기기의 알림 토큰만 남는다 — 서버의 기기 줄은 logout 이 세션과 함께 지운다 + // 못 푼 등록은 알림 컨트롤러가 기억해 다음 실행에 다시 푼다 } } @@ -334,20 +544,28 @@ class SessionNotifier extends AsyncNotifier { event.reply(RestrictionCheck.stale); return; } + _pendingRechecks.add(event); + void answer(RestrictionCheck check) { + _pendingRechecks.remove(event); + event.reply(check); + } + unawaited( recheck().then( - event.reply, + answer, onError: (Object _, StackTrace _) => - event.reply(RestrictionCheck.unknown), + answer(RestrictionCheck.unknown), ), ); case SessionEnded(): if (current is! SignedIn) return; - _epoch++; + _advanceEpoch(); _secret.clear(); state = const AsyncData( SignedOut(reason: SignOutReason.expired), ); + // 이 기기의 알림을 멈춘다 — 서버 줄은 세션을 끝낸 서버가 지운다 + unawaited(_unregisterPushQuietly()); case SessionRefreshed(:final Map body): // 갱신 응답의 제한 · 유예로 맞춘다(계약 6절 — 서버가 갱신 때마다 엔진 기준으로 다시 본다) if (current is! SignedIn) return; diff --git a/flutter/lib/features/auth/presentation/providers/session_state.dart b/flutter/lib/features/auth/presentation/providers/session_state.dart index 050244e..b9f07e1 100644 --- a/flutter/lib/features/auth/presentation/providers/session_state.dart +++ b/flutter/lib/features/auth/presentation/providers/session_state.dart @@ -9,17 +9,20 @@ sealed class SessionState { /// 로그아웃 상태 — 로그인 화면 final class SignedOut extends SessionState { - const SignedOut({this.reason = SignOutReason.none}); + const SignedOut({this.reason = SignOutReason.none, this.email}); - /// 로그인 화면이 위쪽 상자에 보일 까닭 (화면 작업 때 쓴다) + /// 로그인 화면이 위쪽 상자에 보일 까닭 final SignOutReason reason; + /// 로그인 화면의 주소 칸에 남길 주소 — 코드 단계에서 첫 단계로 돌아왔을 때(시간 지남 · 비밀번호 틀림). 비밀이 아니다 + final String? email; + @override bool operator ==(Object other) => - other is SignedOut && other.reason == reason; + other is SignedOut && other.reason == reason && other.email == email; @override - int get hashCode => reason.hashCode; + int get hashCode => Object.hash(reason, email); } /// 로그아웃한 까닭 @@ -33,6 +36,9 @@ enum SignOutReason { /// 코드 단계에 10분 넘게 머물렀다 (계약 5절 — "코드를 넣는 시간이 지났습니다. 다시 로그인해 주세요.") codeTimedOut, + /// 코드 단계에서 보낸 비밀번호를 서버가 거절했다(`AUTH_004` — 그사이 바뀜). 위쪽 상자에 사전 문구 · 칸은 빨갛게(계약 5절) + passwordRejected, + /// [다른 계정으로 로그인] switchAccount, } diff --git a/flutter/lib/features/auth/presentation/widgets/login_fields.dart b/flutter/lib/features/auth/presentation/widgets/login_fields.dart new file mode 100644 index 0000000..c525bd1 --- /dev/null +++ b/flutter/lib/features/auth/presentation/widgets/login_fields.dart @@ -0,0 +1,395 @@ +import 'package:arasystem_app/core/constants/login_texts.dart'; +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/theme/app_theme.dart'; +import 'package:arasystem_app/core/theme/app_typography.dart'; +import 'package:arasystem_app/core/utils/otp_code.dart'; +import 'package:arasystem_app/shared/widgets/keep_all_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter/services.dart'; + +/// 로그인 틀의 글자 칸 — 칸 이름은 칸 위, 안내 · 오류는 칸 아래(앱 보드 `App1Login` · 웹 PasswordField · OtpCodeField 와 같은 모양). +/// +/// - 안내와 오류는 따로 그린다 — 오류가 나도 안내는 남는다(예: 코드 칸의 "다음 코드가 뜨면 넣어 주세요"). [hintIsError] 면 안내 글이 빨갛다. +/// - 화면 낭독기: 보이는 이름은 빼고 같은 이름을 칸에 붙인다(이름을 두 번 읽지 않는다). 칸의 설명은 위쪽 상자([describedBy]) → 안내 → 오류 +/// 차례로 칸에 이어 둔다 — 칸에 다시 와도 함께 읽힌다(웹 `aria-describedby` 와 같은 결 · Android 는 칸의 hint 글). +/// 칸 아래 오류는 나타날 때도 읽어 준다(live region). 오류가 있으면 칸이 빨갛고 "잘못됨"(validationResult)이다. +/// - 비밀이 드는 칸(비밀번호 · 코드)의 글은 이 칸의 컨트롤러(화면 메모리)에만 있다 — 화면이 떠날 때 지운다. +class LoginTextField extends StatelessWidget { + const LoginTextField({ + required this.label, + required this.controller, + this.focusNode, + this.placeholder, + this.description, + this.hint, + this.hintIsError = false, + this.error, + this.invalid = false, + this.describedBy, + this.obscureText = false, + this.keyboardType, + this.enableSuggestions = false, + this.textInputAction, + this.onSubmitted, + this.onChanged, + this.autofillHints, + this.suffix, + this.readOnly = false, + this.inputFormatters, + this.style, + this.contentPadding, + this.labelStyle, + super.key, + }); + + final String label; + final TextEditingController controller; + final FocusNode? focusNode; + + /// 칸 안의 흐린 글 + final String? placeholder; + + /// 이름과 칸 사이의 설명 (2단계 켜기의 ② — 보드) + final String? description; + + /// 칸 아래 안내 + final String? hint; + final bool hintIsError; + + /// 칸 아래 오류 — 사전 문구 + final String? error; + + /// 칸 아래 글 없이 빨간 테두리만 — 오류 글은 위쪽 상자에 있다(첫 단계의 `AUTH_004` — 보드) + final bool invalid; + + /// 칸을 설명하는 위쪽 상자의 글 — 칸에 이어 함께 읽힌다(보드의 `aria-describedby="err-login-alert"`) + final String? describedBy; + final bool obscureText; + final TextInputType? keyboardType; + + /// 자판의 추천 · 학습 — 기본은 끈다. 메일 주소 칸만 켠다: 끄면 Flutter Android 가 VISIBLE_PASSWORD 변형을 더해 + /// EMAIL_ADDRESS 와 겹쳐(FILTER 변형이 된다) 자판에서 @ 가 빠진다(엔진 TextInputPlugin `inputTypeFromTextInputType`) + final bool enableSuggestions; + final TextInputAction? textInputAction; + final ValueChanged? onSubmitted; + final ValueChanged? onChanged; + final Iterable? autofillHints; + + /// 칸 끝의 단추([보기]) + final Widget? suffix; + + /// 보내는 동안 읽기만 + final bool readOnly; + final List? inputFormatters; + + /// 칸 글자 — 기본은 테마(bodyLarge 16) + final TextStyle? style; + final EdgeInsetsGeometry? contentPadding; + + /// 이름 글자 — 기본은 칸 이름(13 · 굵게) + final TextStyle? labelStyle; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + final InputDecorationThemeData decorations = InputDecorationTheme.of( + context, + ); + final String? hintText = hint; + final String? errorText = error; + final bool red = + invalid || errorText != null || (hintIsError && hintText != null); + final String? describe = description; + final String semanticsHint = [ + describedBy, + hintText, + errorText, + ].whereType().join('\n'); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: AppSpacing.s6, + children: [ + ExcludeSemantics( + child: KeepAllText( + label, + style: + labelStyle ?? + theme.textTheme.labelMedium?.copyWith( + fontWeight: AppTypography.semiBold, + color: extra.textStrong, + ), + ), + ), + if (describe != null) + KeepAllText( + describe, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + Semantics( + label: label, + hint: semanticsHint.isEmpty ? null : semanticsHint, + validationResult: red + ? SemanticsValidationResult.invalid + : SemanticsValidationResult.none, + child: TextField( + controller: controller, + focusNode: focusNode, + obscureText: obscureText, + keyboardType: keyboardType, + textInputAction: textInputAction, + onSubmitted: onSubmitted, + onChanged: onChanged, + autofillHints: autofillHints, + readOnly: readOnly, + inputFormatters: inputFormatters, + style: style, + autocorrect: false, + enableSuggestions: enableSuggestions, + // 안내 · 오류는 입력 장식에 두지 않고 아래에 직접 그린다 — 보드처럼 칸의 왼쪽 끝에 맞추고 낱말 가운데서 줄을 바꾸지 않게 + decoration: InputDecoration( + hintText: placeholder, + hintStyle: style?.copyWith(color: theme.colorScheme.outline), + enabledBorder: red ? decorations.errorBorder : null, + focusedBorder: red ? decorations.focusedErrorBorder : null, + suffixIcon: suffix, + contentPadding: contentPadding, + ), + ), + ), + if (hintText != null) + LoginFieldMessage( + text: hintText, + isError: hintIsError, + announce: false, + ), + if (errorText != null) + LoginFieldMessage(text: errorText, isError: true), + ], + ); + } +} + +/// 칸 아래 글 — 안내(회색 · 규칙에 어긋나면 빨강) 또는 오류(빨강). +/// [announce] 면 나타날 때 화면 낭독기가 읽는다(live region — 서버 거절 · 보내기 전 검사의 오류 글) +class LoginFieldMessage extends StatelessWidget { + const LoginFieldMessage({ + required this.text, + required this.isError, + bool? announce, + super.key, + }) : announce = announce ?? isError; + + final String text; + final bool isError; + final bool announce; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final Widget message = KeepAllText( + text, + style: theme.textTheme.bodySmall?.copyWith( + color: isError + ? theme.colorScheme.error + : theme.extension()!.textMuted, + ), + ); + if (!announce) return message; + return Semantics(container: true, liveRegion: true, child: message); + } +} + +/// 비밀번호 칸 — 끝에 [보기](48). 보드: 첫 단계 비밀번호 · 새 비밀번호 · 임시 비밀번호 · 지금 비밀번호 +class LoginPasswordField extends StatelessWidget { + const LoginPasswordField({ + required this.label, + required this.controller, + required this.shown, + required this.onToggle, + required this.toggleLabel, + this.focusNode, + this.placeholder, + this.hint, + this.hintIsError = false, + this.error, + this.invalid = false, + this.describedBy, + this.textInputAction, + this.onSubmitted, + this.onChanged, + this.autofillHints, + this.readOnly = false, + super.key, + }); + + final String label; + final TextEditingController controller; + + /// 글자를 보이는 중 + final bool shown; + final VoidCallback onToggle; + + /// [보기]의 화면 낭독기 이름 — "비밀번호 보기" + final String toggleLabel; + final FocusNode? focusNode; + final String? placeholder; + final String? hint; + final bool hintIsError; + final String? error; + final bool invalid; + final String? describedBy; + final TextInputAction? textInputAction; + final ValueChanged? onSubmitted; + final ValueChanged? onChanged; + final Iterable? autofillHints; + final bool readOnly; + + @override + Widget build(BuildContext context) { + return LoginTextField( + label: label, + controller: controller, + focusNode: focusNode, + placeholder: placeholder, + hint: hint, + hintIsError: hintIsError, + error: error, + invalid: invalid, + describedBy: describedBy, + obscureText: !shown, + // 보이는 동안에도 자판의 추천 · 학습을 끈다 + keyboardType: TextInputType.visiblePassword, + textInputAction: textInputAction, + onSubmitted: onSubmitted, + onChanged: onChanged, + autofillHints: autofillHints, + readOnly: readOnly, + suffix: PasswordVisibilityToggle( + shown: shown, + label: toggleLabel, + onToggle: onToggle, + ), + ); + } +} + +/// [보기] — 눌러 둔 상태(toggled)를 화면 낭독기가 읽는다(웹의 aria-pressed). 이름은 그대로 둔다 +class PasswordVisibilityToggle extends StatelessWidget { + const PasswordVisibilityToggle({ + required this.shown, + required this.label, + required this.onToggle, + super.key, + }); + + final bool shown; + final String label; + final VoidCallback onToggle; + + @override + Widget build(BuildContext context) { + return MergeSemantics( + child: Semantics( + label: label, + toggled: shown, + child: IconButton( + onPressed: onToggle, + color: Theme.of(context).colorScheme.onSurfaceVariant, + icon: Icon( + shown ? Icons.visibility_off_outlined : Icons.visibility_outlined, + size: AppSize.iconMedium, + ), + ), + ), + ); + } +} + +/// 인증 앱 코드 칸 — 숫자 6자리 · 높이 56 · 큰 글자 사이를 벌린다(보드). 붙여 넣은 글의 띄어쓰기 · 하이픈은 지운다. +/// +/// 숫자 자판 · 자동 채우기 이름 one-time-code(웹 `inputmode="numeric"` · `autocomplete="one-time-code"`). +class LoginCodeField extends StatelessWidget { + const LoginCodeField({ + required this.label, + required this.controller, + this.focusNode, + this.description, + this.hint, + this.error, + this.textInputAction, + this.onSubmitted, + this.onChanged, + this.readOnly = false, + this.labelStyle, + super.key, + }); + + final String label; + final TextEditingController controller; + final FocusNode? focusNode; + final String? description; + final String? hint; + final String? error; + final TextInputAction? textInputAction; + final ValueChanged? onSubmitted; + final ValueChanged? onChanged; + final bool readOnly; + final TextStyle? labelStyle; + + @override + Widget build(BuildContext context) { + final TextTheme text = Theme.of(context).textTheme; + final double fontSize = + text.headlineSmall?.fontSize ?? AppTypography.headline; + final TextStyle style = TextStyle( + fontSize: fontSize, + fontWeight: AppTypography.regular, + height: AppTypography.lineHeightBase, + letterSpacing: fontSize * AppTypography.letterSpacingCodeEm, + fontFeatures: const [FontFeature.tabularFigures()], + ); + final double lineHeight = fontSize * AppTypography.lineHeightBase; + return LoginTextField( + label: label, + controller: controller, + focusNode: focusNode, + placeholder: OtpTexts.placeholder, + description: description, + hint: hint, + error: error, + keyboardType: TextInputType.number, + textInputAction: textInputAction, + onSubmitted: onSubmitted, + onChanged: onChanged, + autofillHints: const [AutofillHints.oneTimeCode], + readOnly: readOnly, + inputFormatters: [OtpInputFormatter()], + style: style, + labelStyle: labelStyle, + contentPadding: EdgeInsets.symmetric( + horizontal: AppSpacing.s16, + vertical: (AppSize.codeFieldHeight - lineHeight) / 2, + ), + ); + } +} + +/// 코드 칸의 글을 숫자 6자리로 다듬는다 — 붙여 넣은 "123 456" · "123-456" · 전각 숫자도 받는다(`normalizeOtpInput`) +class OtpInputFormatter extends TextInputFormatter { + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + final String digits = normalizeOtpInput(newValue.text); + if (digits == newValue.text) return newValue; + return TextEditingValue( + text: digits, + selection: TextSelection.collapsed(offset: digits.length), + ); + } +} diff --git a/flutter/lib/features/auth/presentation/widgets/login_frame.dart b/flutter/lib/features/auth/presentation/widgets/login_frame.dart new file mode 100644 index 0000000..29ecf83 --- /dev/null +++ b/flutter/lib/features/auth/presentation/widgets/login_frame.dart @@ -0,0 +1,391 @@ +import 'package:arasystem_app/core/constants/app_assets.dart'; +import 'package:arasystem_app/core/constants/app_constants.dart'; +import 'package:arasystem_app/core/constants/login_texts.dart'; +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/theme/app_theme.dart'; +import 'package:arasystem_app/core/theme/app_typography.dart'; +import 'package:arasystem_app/shared/widgets/keep_all_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// 로그인 틀의 크기 — 앱 보드 `App1Login` (2026-11-05 확정). 여러 화면이 함께 쓰는 크기는 `AppSize` · `AppSpacing` 에 있다 +abstract final class LoginLayout { + /// 첫 단계 브랜드 띠의 전체 로고 폭 (높이는 그림 비율대로) + static const double logoWidth = 150; + + /// 다음 단계 짙은 띠의 ARA 마크 폭 + static const double markWidth = 48; + + /// 2단계 켜기의 차례 숫자(1 · 2 · 3) 칸 폭 + static const double stepNumberWidth = 18; + + /// 보내는 동안 단추를 흐리게 — 웹 로그인 단추와 같다 + static const double busyOpacity = 0.7; + + /// 보내는 동안 [다른 계정으로 로그인]을 흐리게 — 웹 LoginAccountRow 와 같다 + static const double busyLinkOpacity = 0.6; +} + +/// 로그인 틀의 상태 표시줄 — 위가 어두운 브랜드 띠라 밝은 아이콘. 탐색 막대는 건드리지 않는다(AppBar 와 같다) +const SystemUiOverlayStyle kLoginOverlayStyle = SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.light, + statusBarBrightness: Brightness.dark, + systemStatusBarContrastEnforced: false, +); + +/// 로그인 틀의 칸 · 단추 모양 — 보드는 모서리 10 · 글자 칸 52 · 단추 48 이다(앱 전체 테마의 8 과 다르다) +ThemeData loginThemeOf(ThemeData base) { + final ColorScheme scheme = base.colorScheme; + final TextTheme text = base.textTheme; + final BorderRadius radius = BorderRadius.circular(AppRadius.lg); + final RoundedRectangleBorder shape = RoundedRectangleBorder( + borderRadius: radius, + ); + OutlineInputBorder border(Color color, double width) => OutlineInputBorder( + borderRadius: radius, + borderSide: BorderSide(color: color, width: width), + ); + final TextStyle input = text.bodyLarge ?? const TextStyle(); + final double lineHeight = + (input.fontSize ?? AppTypography.bodyLarge) * + (input.height ?? AppTypography.lineHeightBase); + const Size buttonSize = Size.fromHeight(AppSize.touchMin); + + return base.copyWith( + inputDecorationTheme: InputDecorationThemeData( + filled: true, + fillColor: scheme.surface, + isDense: true, + contentPadding: EdgeInsets.symmetric( + horizontal: AppSpacing.s14, + vertical: (AppSize.fieldHeight - lineHeight) / 2, + ), + hintStyle: input.copyWith(color: scheme.outline), + // 칸 아래 안내 · 오류는 칸이 직접 그린다(LoginFieldMessage) — 오류 테두리만 여기서 정한다 + border: border(scheme.outline, AppSize.borderWidth), + enabledBorder: border(scheme.outline, AppSize.borderWidth), + focusedBorder: border(scheme.primary, AppSize.focusRingWidth), + errorBorder: border(scheme.error, AppSize.focusRingWidth), + focusedErrorBorder: border(scheme.error, AppSize.focusRingWidth), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + minimumSize: buttonSize, + shape: shape, + textStyle: text.labelLarge, + // 보내는 동안(누를 수 없음)에도 같은 색을 흐리게 — 회색 단추로 바뀌지 않게 + disabledBackgroundColor: scheme.primary.withValues( + alpha: LoginLayout.busyOpacity, + ), + disabledForegroundColor: scheme.onPrimary, + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + minimumSize: buttonSize, + shape: shape, + side: BorderSide(color: scheme.primary), + foregroundColor: scheme.onPrimaryContainer, + textStyle: text.labelLarge, + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + minimumSize: const Size(AppSize.touchMin, AppSize.touchMin), + shape: shape, + textStyle: text.labelLarge, + ), + ), + ); +} + +/// 첫 단계의 틀 — 큰 브랜드 띠(로고 · 소개)가 내용과 함께 흐른다. +/// 업데이트 화면(앱 보드 `App1Push` ⑩)도 이 틀을 쓴다 — 띠에 로고만([introduction] 거짓) · 내용 위 32 +class LoginBrandFrame extends StatelessWidget { + const LoginBrandFrame({ + required this.children, + this.introduction = true, + this.contentTopPadding = AppSpacing.s28, + super.key, + }); + + /// 브랜드 띠 아래의 내용 — 사이 20 + final List children; + + /// 띠에 큰 글 · 소개 글을 보이는가 — 거짓이면 로고만 + final bool introduction; + + /// 띠 아래 내용의 위 여백 + final double contentTopPadding; + + @override + Widget build(BuildContext context) { + return _LoginScaffold( + top: const _StatusBarStrip(), + body: _LoginScroll( + children: [ + _BrandBand(introduction: introduction), + _LoginColumn(topPadding: contentTopPadding, children: children), + ], + ), + ); + } +} + +/// 다음 단계들의 틀 — 짙은 띠(56 — ARA 마크 · [다른 계정으로 로그인])는 위에 남고 내용만 흐른다 +class LoginStepFrame extends StatelessWidget { + const LoginStepFrame({ + required this.onOtherAccount, + required this.children, + this.otherAccountEnabled = true, + this.topPadding = AppSpacing.s28, + super.key, + }); + + /// [다른 계정으로 로그인] + final VoidCallback onOtherAccount; + + /// [다른 계정으로 로그인]을 누를 수 있는가 — 제한 단계는 보내는 동안 · 넘기는 동안 막는다(웹 LoginAccountRow 의 busy 와 같다). + /// 서버가 이미 비밀번호를 바꿨거나 2단계를 켰을 수 있는데 그 결과를 버리면, 직원이 옛 비밀번호로 다시 들어가다 막힌다 + final bool otherAccountEnabled; + + /// 띠 아래의 내용 — 사이 20 + final List children; + + /// 띠와 첫 내용 사이 (보드: 코드 · 새 비밀번호 28, 2단계 켜기 24) + final double topPadding; + + @override + Widget build(BuildContext context) { + return _LoginScaffold( + top: _StepBand( + onOtherAccount: otherAccountEnabled ? onOtherAccount : null, + ), + body: _LoginScroll( + children: [ + _LoginColumn(topPadding: topPadding, children: children), + ], + ), + ); + } +} + +class _LoginScaffold extends StatelessWidget { + const _LoginScaffold({required this.top, required this.body}); + + final Widget top; + final Widget body; + + @override + Widget build(BuildContext context) { + final ThemeData theme = loginThemeOf(Theme.of(context)); + return AnnotatedRegion( + value: kLoginOverlayStyle, + child: Theme( + data: theme, + child: Scaffold( + backgroundColor: theme.colorScheme.surface, + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + top, + Expanded(child: body), + ], + ), + ), + ), + ); + } +} + +/// 상태 표시줄 뒤 — 브랜드 바탕(내용이 흘러 올라가도 밝은 아이콘이 보이게) +class _StatusBarStrip extends StatelessWidget { + const _StatusBarStrip(); + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: Theme.of(context).colorScheme.secondary, + child: SizedBox(height: MediaQuery.paddingOf(context).top), + ); + } +} + +class _LoginScroll extends StatelessWidget { + const _LoginScroll({required this.children}); + + final List children; + + @override + Widget build(BuildContext context) { + return SafeArea( + top: false, + child: SingleChildScrollView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children, + ), + ), + ); + } +} + +/// 내용 열 — 휴대폰은 화면 폭을 다 쓰고, 넓은 화면은 [AppSize.formMaxWidth] 로 가운데 +class _LoginColumn extends StatelessWidget { + const _LoginColumn({required this.topPadding, required this.children}); + + final double topPadding; + final List children; + + @override + Widget build(BuildContext context) { + return _FormWidth( + child: Padding( + padding: EdgeInsets.fromLTRB( + AppSpacing.s20, + topPadding, + AppSpacing.s20, + AppSpacing.s32, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: AppSpacing.s20, + children: children, + ), + ), + ); + } +} + +class _FormWidth extends StatelessWidget { + const _FormWidth({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: AppSize.formMaxWidth), + child: child, + ), + ); + } +} + +/// 첫 단계의 브랜드 띠 — 흰 로고 · 큰 글 · 소개 글([introduction] 이 거짓이면 로고만) +class _BrandBand extends StatelessWidget { + const _BrandBand({required this.introduction}); + + final bool introduction; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + return ColoredBox( + color: theme.colorScheme.secondary, + child: _FormWidth( + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.s24, + AppSpacing.s36, + AppSpacing.s24, + AppSpacing.s28, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: AppSpacing.s18, + children: [ + Image.asset( + AppAssets.logoFull, + width: LoginLayout.logoWidth, + semanticLabel: kAppName, + ), + if (introduction) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: AppSpacing.s6, + children: [ + KeepAllText( + LoginTexts.brandHeadline, + // 흰 글 — 브랜드 바탕 위 큰 글(웹 --color-on-brand) + style: theme.textTheme.headlineSmall?.copyWith( + color: theme.colorScheme.onPrimary, + ), + ), + KeepAllText( + LoginTexts.brandLead, + style: theme.textTheme.bodyMedium?.copyWith( + color: extra.onBrandMuted, + height: AppTypography.lineHeightRelaxed, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +/// 다음 단계의 짙은 띠 — ARA 마크와 [다른 계정으로 로그인] (웹처럼 주소 줄 안에 두면 390 폭에 들지 않는다) +class _StepBand extends StatelessWidget { + const _StepBand({required this.onOtherAccount}); + + /// null 이면 누를 수 없다(흐리게) + final VoidCallback? onOtherAccount; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + return ColoredBox( + color: theme.colorScheme.secondary, + child: Padding( + padding: EdgeInsets.only(top: MediaQuery.paddingOf(context).top), + child: SafeArea( + top: false, + bottom: false, + child: SizedBox( + height: AppSize.topBandHeight, + child: Padding( + padding: const EdgeInsets.only( + left: AppSpacing.s20, + right: AppSpacing.s6, + ), + child: Row( + children: [ + Image.asset( + AppAssets.logoMark, + width: LoginLayout.markWidth, + semanticLabel: kAppName, + ), + const Spacer(), + TextButton( + onPressed: onOtherAccount, + style: TextButton.styleFrom( + foregroundColor: extra.onBrandLink, + disabledForegroundColor: extra.onBrandLink.withValues( + alpha: LoginLayout.busyLinkOpacity, + ), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.s12, + ), + ), + child: const Text(LoginBandTexts.otherAccount), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/flutter/lib/features/auth/presentation/widgets/login_parts.dart b/flutter/lib/features/auth/presentation/widgets/login_parts.dart new file mode 100644 index 0000000..a5c9d26 --- /dev/null +++ b/flutter/lib/features/auth/presentation/widgets/login_parts.dart @@ -0,0 +1,317 @@ +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/theme/app_theme.dart'; +import 'package:arasystem_app/core/theme/app_typography.dart'; +import 'package:arasystem_app/core/utils/korean_text.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_frame.dart'; +import 'package:arasystem_app/shared/widgets/keep_all_text.dart'; +import 'package:flutter/material.dart'; + +/// 단계의 머리 — 제목 · 설명 (다음 단계는 로그인한 주소 줄도). +/// +/// 제목은 화면 낭독기의 머리글이자 **화면 이름**이다(`namesRoute`) — 단계가 바뀌어 화면이 바뀌면 낭독기가 이 글을 읽어 알린다. +class LoginHeading extends StatelessWidget { + const LoginHeading({ + required this.title, + required this.subtitle, + this.email, + super.key, + }); + + final String title; + final String subtitle; + + /// 로그인한(비밀번호를 확인한) 주소 — 다음 단계만 + final String? email; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final String? address = email; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: address == null ? AppSpacing.s4 : AppSpacing.s10, + children: [ + Semantics( + header: true, + namesRoute: true, + child: KeepAllText(title, style: theme.textTheme.headlineSmall), + ), + KeepAllText( + subtitle, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + if (address != null) LoginAccountChip(email: address), + ], + ); + } +} + +/// 로그인한 주소 — 사람 아이콘 + 주소(넘치면 줄임)의 알약(36) +class LoginAccountChip extends StatelessWidget { + const LoginAccountChip({required this.email, super.key}); + + final String email; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + return Align( + alignment: AlignmentDirectional.centerStart, + child: Container( + constraints: const BoxConstraints(minHeight: AppSize.chipHeight), + padding: const EdgeInsetsDirectional.only( + start: AppSpacing.s10, + end: AppSpacing.s14, + ), + decoration: ShapeDecoration( + color: theme.colorScheme.surfaceContainerHigh, + shape: const StadiumBorder(), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: AppSpacing.s8, + children: [ + Icon( + Icons.person_outline, + size: AppSize.iconSmall, + color: extra.textMuted, + ), + Flexible( + child: Text( + email, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: extra.textStrong, + ), + ), + ), + ], + ), + ), + ); + } +} + +/// 위쪽 빨간 상자 — 사전 문구(`AUTH_004` 등)나 단계 안내. 나타나면 화면 낭독기가 읽는다(live region — 웹의 role="alert"). +/// [actionLabel] 이 있으면 글 아래에 단추([다시 시도]) — [onAction] 이 null 이면(다시 하는 중) 자리는 두고 누를 수 없다. +class LoginAlertBox extends StatelessWidget { + const LoginAlertBox({ + required this.message, + this.actionLabel, + this.onAction, + this.namesRoute = false, + super.key, + }); + + final String message; + final String? actionLabel; + final VoidCallback? onAction; + + /// 이 상자가 화면 이름이다(제목 없는 화면 — 모르는 제한) + final bool namesRoute; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme scheme = theme.colorScheme; + final String? label = actionLabel; + return Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.s14, + vertical: AppSpacing.s12, + ), + decoration: BoxDecoration( + color: scheme.errorContainer, + borderRadius: BorderRadius.circular(AppRadius.lg), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: AppSpacing.s8, + children: [ + Semantics( + container: true, + liveRegion: true, + namesRoute: namesRoute, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: AppSpacing.s8, + children: [ + Icon( + Icons.error_outline, + size: AppSize.iconSmall, + color: scheme.error, + ), + Expanded( + child: KeepAllText( + message, + style: theme.textTheme.bodyMedium?.copyWith( + color: scheme.error, + ), + ), + ), + ], + ), + ), + if (label != null) + Align( + alignment: AlignmentDirectional.centerEnd, + child: OutlinedButton( + onPressed: onAction, + style: OutlinedButton.styleFrom( + minimumSize: const Size(AppSize.touchMin, AppSize.touchMin), + side: BorderSide(color: scheme.outlineVariant), + backgroundColor: scheme.surface, + foregroundColor: theme + .extension()! + .textStrong, + ), + child: Text(label), + ), + ), + ], + ), + ); + } +} + +/// 단계의 큰 단추 — 보내는 동안은 [busyLabel] 로 바꾸고 누를 수 없다(두 번 보내면 서버가 `REQ_001` — 계약 1절) +class LoginSubmitButton extends StatelessWidget { + const LoginSubmitButton({ + required this.label, + required this.busyLabel, + required this.busy, + required this.onPressed, + this.enabled = true, + this.inForm = false, + super.key, + }); + + final String label; + final String busyLabel; + final bool busy; + final VoidCallback onPressed; + + /// 보내는 중이 아니어도 누를 수 없음(키를 받는 중 · 이미 마쳤는데 넘기지 못함) + final bool enabled; + + /// 칸 묶음(사이 16) 안의 단추 — 위를 4 더 띄워 칸과 20 을 둔다(보드) + final bool inForm; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only(top: inForm ? AppSpacing.s4 : 0), + child: FilledButton( + onPressed: busy || !enabled ? null : onPressed, + child: Text(busy ? busyLabel : label), + ), + ); + } +} + +/// 문의 줄 — 연락처가 있으면 물음 다음 줄에 연락처(진하게), 없거나 받지 못했으면 지금 글(웹과 같다). 글자로만 보인다 +class LoginSupportLine extends StatelessWidget { + const LoginSupportLine({ + required this.question, + required this.fallback, + required this.contact, + super.key, + }); + + final String question; + final String fallback; + + /// 연락처 한 줄 — 없으면 null + final String? contact; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + final TextStyle? style = theme.textTheme.bodySmall?.copyWith( + color: extra.textMuted, + ); + final String? line = contact; + if (line == null) return KeepAllText(fallback, style: style); + return Text.rich( + TextSpan( + children: [ + TextSpan(text: '${keepAllWordBreak(question)}\n'), + TextSpan( + text: keepAllWordBreak(line), + style: TextStyle( + color: extra.textStrong, + fontWeight: AppTypography.medium, + ), + ), + ], + ), + style: style, + semanticsLabel: '$question\n$line', + ); + } +} + +/// 맨 아래 안내 — 위쪽 옅은 선 · 작은 회색 글(메일 프로그램 연결 안내) +class LoginNote extends StatelessWidget { + const LoginNote({required this.text, super.key}); + + final String text; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AppExtraColors extra = theme.extension()!; + return DecoratedBox( + decoration: BoxDecoration( + border: Border(top: BorderSide(color: extra.borderSubtle)), + ), + child: Padding( + padding: const EdgeInsets.only(top: AppSpacing.s16), + child: KeepAllText( + text, + style: theme.textTheme.bodySmall?.copyWith( + color: extra.textMuted, + height: AppTypography.lineHeightRelaxed, + ), + ), + ), + ); + } +} + +/// 2단계 켜기의 차례 하나 — 청록 굵은 숫자(동그라미 없음) + 내용 (숫자는 화면 낭독기가 읽지 않는다 — 보드의 aria-hidden) +class LoginStepItem extends StatelessWidget { + const LoginStepItem({required this.number, required this.child, super.key}); + + final int number; + final Widget child; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: AppSpacing.s12, + children: [ + SizedBox( + width: LoginLayout.stepNumberWidth, + child: ExcludeSemantics( + child: Text( + '$number', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: AppTypography.bold, + color: theme.colorScheme.primary, + ), + ), + ), + ), + Expanded(child: child), + ], + ); + } +} diff --git a/flutter/lib/features/mail/data/.gitkeep b/flutter/lib/features/mail/data/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/flutter/lib/features/mail/data/mail_link_launcher.dart b/flutter/lib/features/mail/data/mail_link_launcher.dart new file mode 100644 index 0000000..644b1f0 --- /dev/null +++ b/flutter/lib/features/mail/data/mail_link_launcher.dart @@ -0,0 +1,20 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// 본문의 http · https 링크를 바깥 앱(브라우저 · 그 주소를 받는 앱)으로 여는 함수 — 시험은 덮어쓴다 +typedef MailLinkLauncher = Future Function(Uri uri); + +final Provider mailLinkLauncherProvider = + Provider((Ref ref) => openMailLinkExternally); + +/// 바깥 앱으로 연다 — 열었으면 true. +/// +/// `canLaunchUrl` 은 쓰지 않는다(debug 판 안드로이드가 묻는 주소를 로그에 남긴다 — flutter 스킬). 못 열면 플러그인이 던지는 +/// 예외의 문장에 주소째 들어 있다 — 잡아서 버린다(메일 속 주소에는 추적 번호 · 개인 정보가 들어 있을 수 있다) +Future openMailLinkExternally(Uri uri) async { + try { + return await launchUrl(uri, mode: LaunchMode.externalApplication); + } on Object { + return false; + } +} diff --git a/flutter/lib/features/mail/data/models/compose_models.dart b/flutter/lib/features/mail/data/models/compose_models.dart new file mode 100644 index 0000000..aecf771 --- /dev/null +++ b/flutter/lib/features/mail/data/models/compose_models.dart @@ -0,0 +1,372 @@ +/// 쓰기 화면의 응답 모양 — 계약 2절 `Draft` · `DraftSaved` · `SentMessage` · `MailSignature` · `MailSettings` · `MailQuota`(첨부 한도), 4절 `LargeFile` · `MissingLargeFile`. +/// +/// 모델은 손으로 쓴다(flutter 스킬 "모델 생성기"). 모양이 틀리면 [tryParse] 가 null — 저장소가 `SYS_001` 로 던진다. +/// 나중 단계에 더해진 칸(`largeFiles` · `pendingSend` · `rejectedRecipients`)은 없으면 빈 목록 · null 로 읽는다(웹과 같다). +library; + +import 'package:arasystem_app/features/mail/data/models/mail_address.dart'; +import 'package:arasystem_app/features/mail/data/models/message_detail.dart'; +import 'package:arasystem_app/features/mail/data/models/message_summary.dart'; +import 'package:flutter/foundation.dart'; + +/// 저장본에 실린 대용량 파일의 상태 — 다 올림 · 올리는 중(웹에서 올리다 멈춤) · 없음(지웠거나 기간이 지남) +enum DraftLargeFileStatus { ready, uploading, missing } + +/// 저장본에 실린 대용량 파일 하나 — `Draft.largeFiles` 의 `LargeFile | MissingLargeFile` (계약 4절 "임시 저장"). +/// 앱은 첫 판에서 올리지 않는다 — 이름 · 크기와 상태를 보이고, 다시 저장 · 보낼 때 id 를 빠뜨리지 않고 싣거나 뺀다 +@immutable +class DraftLargeFile { + const DraftLargeFile({ + required this.id, + required this.status, + this.name, + this.size, + this.infected = false, + }); + + /// 올리기 id = 파일 id — 저장해도 바뀌지 않는다 + final String id; + final DraftLargeFileStatus status; + + /// 없는 파일은 기록이 남아 있을 때만(지운 뒤 30일) 이름 · 크기가 있다 + final String? name; + final int? size; + + /// 악성 검사에서 위험 — 보낼 수 없다(저장은 된다) + final bool infected; + + DraftLargeFile withStatus(DraftLargeFileStatus next, {bool? infected}) => + DraftLargeFile( + id: id, + status: next, + name: name, + size: size, + infected: infected ?? this.infected, + ); + + /// 모양이 틀리면 null — 그 항목만 버린다(11단계 칸이라 메일 전체를 열지 못하게 하지 않는다) + static DraftLargeFile? tryParse(Object? json) { + if (json is! Map) return null; + final Object? id = json['id']; + final Object? status = json['status']; + final Object? name = json['name']; + final Object? size = json['size']; + if (id is! String || id.isEmpty) return null; + final DraftLargeFileStatus? parsed = switch (status) { + 'ready' => DraftLargeFileStatus.ready, + 'uploading' => DraftLargeFileStatus.uploading, + 'missing' => DraftLargeFileStatus.missing, + _ => null, + }; + if (parsed == null) return null; + return DraftLargeFile( + id: id, + status: parsed, + name: name is String ? name : null, + size: size is num ? size.toInt() : null, + infected: json['scanStatus'] == 'infected', + ); + } + + @override + bool operator ==(Object other) => + other is DraftLargeFile && + other.id == id && + other.status == status && + other.name == name && + other.size == size && + other.infected == infected; + + @override + int get hashCode => Object.hash(id, status, name, size, infected); +} + +/// 임시 저장본 — `GET /api/drafts/{id}` +@immutable +class Draft { + const Draft({ + required this.id, + required this.to, + required this.cc, + required this.bcc, + required this.subject, + required this.body, + required this.updatedAt, + this.bodyHtml, + this.attachments = const [], + this.inReplyTo, + this.largeFiles = const [], + }); + + /// 저장할 때마다 바뀐다 + final String id; + final List to; + final List cc; + final List bcc; + final String subject; + + /// 텍스트 대체 본문 + final String body; + + /// 서식 본문 — 보내는 정화를 거친 값. 없거나 비면 null + final String? bodyHtml; + + /// 이 판의 첨부 — id 가 저장할 때마다 바뀐다 + final List attachments; + + /// 답장할 메일 id — 없거나 원 메일이 사라졌으면 null + final String? inReplyTo; + final DateTime updatedAt; + final List largeFiles; + + static Draft? tryParse(Object? json) { + if (json is! Map) return null; + final Object? id = json['id']; + final List? to = MailAddress.tryParseList(json['to']); + final List? cc = MailAddress.tryParseList( + json['cc'] ?? [], + ); + final List? bcc = MailAddress.tryParseList( + json['bcc'] ?? [], + ); + final Object? subject = json['subject']; + final Object? body = json['body']; + final Object? bodyHtml = json['bodyHtml']; + final Object? inReplyTo = json['inReplyTo']; + final Object? updatedAt = json['updatedAt']; + final DateTime? updated = updatedAt is String + ? DateTime.tryParse(updatedAt) + : null; + final List? attachments = _attachments( + json['attachments'], + ); + if (id is! String || + id.isEmpty || + to == null || + cc == null || + bcc == null || + subject is! String || + (body != null && body is! String) || + (bodyHtml != null && bodyHtml is! String) || + (inReplyTo != null && inReplyTo is! String) || + updated == null || + attachments == null) { + return null; + } + final Object? largeFiles = json['largeFiles']; + return Draft( + id: id, + to: to, + cc: cc, + bcc: bcc, + subject: subject, + body: body as String? ?? '', + bodyHtml: bodyHtml as String?, + attachments: attachments, + inReplyTo: inReplyTo as String?, + updatedAt: updated.toUtc(), + largeFiles: largeFiles is List + ? List.unmodifiable([ + for (final Object? item in largeFiles) + ?DraftLargeFile.tryParse(item), + ]) + : const [], + ); + } +} + +/// 저장한 결과 — `POST /api/drafts` · `PUT /api/drafts/{id}` (새 id · 이 판의 첨부) +@immutable +class DraftSaved { + const DraftSaved({ + required this.id, + required this.attachments, + required this.updatedAt, + }); + + final String id; + final List attachments; + final DateTime updatedAt; + + static DraftSaved? tryParse(Object? json) { + if (json is! Map) return null; + final Object? id = json['id']; + final Object? updatedAt = json['updatedAt']; + final DateTime? updated = updatedAt is String + ? DateTime.tryParse(updatedAt) + : null; + final List? attachments = _attachments( + json['attachments'], + ); + if (id is! String || id.isEmpty || updated == null || attachments == null) { + return null; + } + return DraftSaved( + id: id, + attachments: attachments, + updatedAt: updated.toUtc(), + ); + } +} + +List? _attachments(Object? value) { + if (value == null) return const []; + if (value is! List) return null; + final List items = []; + for (final Object? item in value) { + final MessageAttachment? attachment = MessageAttachment.tryParse(item); + if (attachment == null) return null; + items.add(attachment); + } + return List.unmodifiable(items); +} + +/// 받지 못한 까닭 — 없는 주소 · 받는 쪽이 거절 · 지금은 받을 수 없음 (모르는 값은 [unknown] — 주소만 적는다) +enum RejectedReason { notFound, refused, temporary, unknown } + +/// 엔진이 받지 않은 받는 사람 — 계약 2절 "받는 사람별 결과" +@immutable +class RejectedRecipient { + const RejectedRecipient({required this.email, required this.reason}); + + final String email; + final RejectedReason reason; + + static RejectedRecipient? tryParse(Object? json) { + if (json is! Map) return null; + final Object? email = json['email']; + if (email is! String || email.isEmpty) return null; + return RejectedRecipient( + email: email, + reason: switch (json['reason']) { + 'not-found' => RejectedReason.notFound, + 'refused' => RejectedReason.refused, + 'temporary' => RejectedReason.temporary, + _ => RejectedReason.unknown, + }, + ); + } +} + +/// 보낸 결과 — `POST /api/messages` 201 (보낸편지함의 메일 id · 받지 못한 사람 · 붙잡아 둔 발송) +@immutable +class SentMessage { + const SentMessage({ + required this.id, + this.rejectedRecipients = const [], + this.pendingSend, + }); + + final String id; + final List rejectedRecipients; + + /// 보내기 취소를 기다리는 중이면 그 정보 — 바로 보냈으면 null + final PendingSend? pendingSend; + + static SentMessage? tryParse(Object? json) { + if (json is! Map) return null; + final Object? id = json['id']; + if (id is! String || id.isEmpty) return null; + final Object? rejected = json['rejectedRecipients']; + return SentMessage( + id: id, + rejectedRecipients: rejected is List + ? List.unmodifiable([ + for (final Object? item in rejected) + ?RejectedRecipient.tryParse(item), + ]) + : const [], + pendingSend: PendingSend.tryParse(json['pendingSend']), + ); + } +} + +/// 내 서명 — `GET /api/mail-signature` (계약 2절 "서명") +@immutable +class MailSignature { + const MailSignature({ + this.bodyHtml, + this.body, + this.isForNew = true, + this.isForReply = true, + }); + + /// 서명 서식(서버가 정화한 HTML — 서명 그림 `` 포함) + final String? bodyHtml; + + /// 텍스트 서명 — 텍스트 대체 본문에 넣는다 + final String? body; + + /// 새 메일에 넣는다 + final bool isForNew; + + /// 답장 · 전달에 넣는다(인용 위) + final bool isForReply; + + static MailSignature? tryParse(Object? json) { + if (json is! Map) return null; + final Object? bodyHtml = json['bodyHtml']; + final Object? body = json['body']; + final Object? isForNew = json['isForNew']; + final Object? isForReply = json['isForReply']; + if ((bodyHtml != null && bodyHtml is! String) || + (body != null && body is! String) || + isForNew is! bool || + isForReply is! bool) { + return null; + } + return MailSignature( + bodyHtml: bodyHtml as String?, + body: body as String?, + isForNew: isForNew, + isForReply: isForReply, + ); + } +} + +/// 보내기 취소 시간 — `GET /api/mail-settings` 의 `undoSendSeconds`(0 · 5 · 10 · 20 · 30). 모양이 틀리면 null +int? undoSendSecondsOf(Object? json) { + if (json is! Map) return null; + final Object? seconds = json['undoSendSeconds']; + if (seconds is! num || seconds < 0) return null; + return seconds.toInt(); +} + +/// 첨부 한도 — `GET /api/mail-quota` 의 `attachmentMaxBytes` · `attachmentTotalMaxBytes` (계약 2절 `MailQuota` — 사용량 칸은 쓰지 않는다). +/// 받지 못하면 대체값(20 · 25MB — `kFallbackAttachmentLimits`)을 쓴다 +@immutable +class AttachmentLimits { + const AttachmentLimits({required this.maxBytes, required this.totalMaxBytes}); + + /// 파일 하나의 한도 — 새로 올리는 파일만 본다(전달하는 원본은 서버도 합계만 본다) + final int maxBytes; + + /// 메일 한 통의 첨부 합계 한도 — 전달하는 원본 · 저장본의 첨부까지 함께 센다 + final int totalMaxBytes; + + /// 모양이 틀리면(없음 · 0 이하) null + static AttachmentLimits? tryParse(Object? json) { + if (json is! Map) return null; + final Object? maxBytes = json['attachmentMaxBytes']; + final Object? totalMaxBytes = json['attachmentTotalMaxBytes']; + if (maxBytes is! num || + totalMaxBytes is! num || + maxBytes <= 0 || + totalMaxBytes <= 0) { + return null; + } + return AttachmentLimits( + maxBytes: maxBytes.toInt(), + totalMaxBytes: totalMaxBytes.toInt(), + ); + } + + @override + bool operator ==(Object other) => + other is AttachmentLimits && + other.maxBytes == maxBytes && + other.totalMaxBytes == totalMaxBytes; + + @override + int get hashCode => Object.hash(maxBytes, totalMaxBytes); +} diff --git a/flutter/lib/features/mail/data/models/compose_payload.dart b/flutter/lib/features/mail/data/models/compose_payload.dart new file mode 100644 index 0000000..04e72e2 --- /dev/null +++ b/flutter/lib/features/mail/data/models/compose_payload.dart @@ -0,0 +1,81 @@ +import 'package:arasystem_app/core/people/person_address.dart'; +import 'package:flutter/foundation.dart'; + +/// 보낼 첨부 하나 — 발송 · 임시 저장 본문의 `attachments[]` (`{ id, name }` — 전달 원본 · 저장본의 첨부는 그 id 를 그대로) +typedef ComposeAttachmentRef = ({String id, String name}); + +/// 발송 · 임시 저장 본문 — 계약 2절 "발송 본문" · "임시 저장"(같은 칸, 저장은 받는 사람 0명도 되고 `draftId` 가 없다) · +/// 4절 "발송 · 임시 저장과 함께"(`largeFileIds` · `bodyQuoteStart`). +/// +/// 비어 있는 선택 칸은 싣지 않는다(서버는 빠진 칸과 null 을 같게 보지만 계약의 예처럼 뺀다). +@immutable +class MailComposePayload { + const MailComposePayload({ + required this.to, + required this.cc, + required this.bcc, + required this.subject, + required this.body, + this.bodyHtml, + this.attachments = const [], + this.inReplyTo, + this.draftId, + this.largeFileIds = const [], + this.bodyQuoteStart, + }); + + final List to; + final List cc; + final List bcc; + final String subject; + + /// 텍스트 대체 본문 + final String body; + + /// 서식 본문 — null 이면 텍스트 메일 + final String? bodyHtml; + final List attachments; + + /// 답장할 메일 id — 답장만(전달에는 싣지 않는다) + final String? inReplyTo; + + /// 이어 쓴 저장본 — 보내기만(보낸 뒤 서버가 지운다) + final String? draftId; + + /// 이 메일에 싣는 내 대용량 파일 — 저장본에 이미 있던 것(앱은 첫 판에서 새로 올리지 않는다) + final List largeFileIds; + + /// 텍스트 본문에서 원문 인용이 시작하는 자리 — 대용량 파일이 있을 때만(서버가 덩이를 넣을 자리) + final int? bodyQuoteStart; + + /// 같은 내용의 저장 본문 — `draftId` 를 뺀다(보내지 못한 메일을 저장본으로 남길 때) + MailComposePayload withoutDraftId() => MailComposePayload( + to: to, + cc: cc, + bcc: bcc, + subject: subject, + body: body, + bodyHtml: bodyHtml, + attachments: attachments, + inReplyTo: inReplyTo, + largeFileIds: largeFileIds, + bodyQuoteStart: bodyQuoteStart, + ); + + Map toJson() => { + 'to': [for (final PersonAddress a in to) a.toJson()], + 'cc': [for (final PersonAddress a in cc) a.toJson()], + 'bcc': [for (final PersonAddress a in bcc) a.toJson()], + 'subject': subject, + 'body': body, + 'bodyHtml': ?bodyHtml, + 'attachments': [ + for (final ComposeAttachmentRef attachment in attachments) + {'id': attachment.id, 'name': attachment.name}, + ], + 'inReplyTo': ?inReplyTo, + 'draftId': ?draftId, + if (largeFileIds.isNotEmpty) 'largeFileIds': largeFileIds, + 'bodyQuoteStart': ?bodyQuoteStart, + }; +} diff --git a/flutter/lib/features/mail/data/models/mail_address.dart b/flutter/lib/features/mail/data/models/mail_address.dart new file mode 100644 index 0000000..3999fc7 --- /dev/null +++ b/flutter/lib/features/mail/data/models/mail_address.dart @@ -0,0 +1,46 @@ +import 'package:flutter/foundation.dart'; + +/// 메일 주소 하나 — 계약 2절 `Address { name: string | null; email: string }`. +/// +/// 모델은 손으로 쓴다(flutter 스킬 "모델 생성기"). 모양이 틀리면 [tryParse] 가 null 이다 — 저장소가 `SYS_001` 로 던진다. +@immutable +class MailAddress { + const MailAddress({required this.email, this.name}); + + /// 보이는 이름 — 없으면 null + final String? name; + final String email; + + /// 목록에 보이는 이름 — 이름이 없거나 비었으면 주소 (웹 `displayName`) + String get displayName { + final String? trimmed = name?.trim(); + return trimmed == null || trimmed.isEmpty ? email : trimmed; + } + + static MailAddress? tryParse(Object? json) { + if (json is! Map) return null; + final Object? email = json['email']; + final Object? name = json['name']; + if (email is! String || (name != null && name is! String)) return null; + return MailAddress(email: email, name: name as String?); + } + + /// 주소 배열 — 하나라도 모양이 틀리면 null + static List? tryParseList(Object? json) { + if (json is! List) return null; + final List addresses = []; + for (final Object? item in json) { + final MailAddress? address = tryParse(item); + if (address == null) return null; + addresses.add(address); + } + return List.unmodifiable(addresses); + } + + @override + bool operator ==(Object other) => + other is MailAddress && other.email == email && other.name == name; + + @override + int get hashCode => Object.hash(email, name); +} diff --git a/flutter/lib/features/mail/data/models/mail_bulk_results.dart b/flutter/lib/features/mail/data/models/mail_bulk_results.dart new file mode 100644 index 0000000..10eb26f --- /dev/null +++ b/flutter/lib/features/mail/data/models/mail_bulk_results.dart @@ -0,0 +1,74 @@ +import 'package:flutter/foundation.dart'; + +/// 메일 한 통 · 여러 통에 바꿀 것 — `PATCH /api/messages{/id}` 의 본문(계약 2절). 하나 이상 넣는다 +@immutable +class MessageChanges { + const MessageChanges({ + this.isRead, + this.isFlagged, + this.mailboxId, + this.isJunk, + }); + + final bool? isRead; + final bool? isFlagged; + + /// 이동 — 그 메일함 하나에만 속하게 + final String? mailboxId; + + /// 스팸 표시 (4단계 "스팸 · 원문") + final bool? isJunk; + + /// 보낼 본문 — 정한 칸만 싣는다 + Map toJson() => { + 'isRead': ?isRead, + 'isFlagged': ?isFlagged, + 'mailboxId': ?mailboxId, + 'isJunk': ?isJunk, + }; +} + +/// `PATCH /api/messages` 의 결과 — 계약 2절 `MessagesPatched` +@immutable +class MessagesPatched { + const MessagesPatched({required this.updatedIds, required this.failedIds}); + + final List updatedIds; + + /// 없음 · 그사이 지워짐 · 엔진이 거절함 · 붙잡힌 메일의 이동 — 나머지는 그대로 처리했다 + final List failedIds; + + static MessagesPatched? tryParse(Object? json) { + if (json is! Map) return null; + final List? updated = _idsOf(json['updatedIds']); + final List? failed = _idsOf(json['failedIds']); + if (updated == null || failed == null) return null; + return MessagesPatched(updatedIds: updated, failedIds: failed); + } +} + +/// `POST /api/message-deletions` 의 결과 — 계약 2절 `MessageDeletionResult` +@immutable +class MessageDeletions { + const MessageDeletions({required this.deletedIds, required this.skippedIds}); + + /// 영구 삭제했다 · 휴지통에서만 뺐다 + final List deletedIds; + + /// 휴지통에 없음 · 없음 · 엔진이 거절 · 붙잡힌 메일 — 지우지 않았다 + final List skippedIds; + + static MessageDeletions? tryParse(Object? json) { + if (json is! Map) return null; + final List? deleted = _idsOf(json['deletedIds']); + final List? skipped = _idsOf(json['skippedIds']); + if (deleted == null || skipped == null) return null; + return MessageDeletions(deletedIds: deleted, skippedIds: skipped); + } +} + +List? _idsOf(Object? value) { + if (value is! List) return null; + if (value.any((Object? id) => id is! String)) return null; + return List.unmodifiable(value.cast()); +} diff --git a/flutter/lib/features/mail/data/models/mail_page.dart b/flutter/lib/features/mail/data/models/mail_page.dart new file mode 100644 index 0000000..7709985 --- /dev/null +++ b/flutter/lib/features/mail/data/models/mail_page.dart @@ -0,0 +1,89 @@ +import 'package:flutter/foundation.dart'; + +/// 목록 응답의 `pageInfo` — 응답 포맷 규칙 "목록 응답 (페이지네이션)" +@immutable +class MailPageInfo { + const MailPageInfo({required this.hasNext, this.nextCursor, this.total}); + + final bool hasNext; + + /// 다음 쪽 커서 — 해석하지 않고 그대로 다시 보낸다(계약 2절). 마지막 쪽이면 null + final String? nextCursor; + + /// 전체 수 — 계산 비용이 크면 null + final int? total; + + /// 다음 쪽이 있는가 — `hasNext` 이고 커서가 있을 때만 + bool get hasMore => hasNext && (nextCursor?.isNotEmpty ?? false); + + MailPageInfo copyWith({int? total}) => MailPageInfo( + hasNext: hasNext, + nextCursor: nextCursor, + total: total ?? this.total, + ); + + static MailPageInfo? tryParse(Object? json) { + if (json is! Map) return null; + final Object? hasNext = json['hasNext']; + final Object? nextCursor = json['nextCursor']; + final Object? total = json['total']; + if (hasNext is! bool || + (nextCursor != null && nextCursor is! String) || + (total != null && total is! num)) { + return null; + } + return MailPageInfo( + hasNext: hasNext, + nextCursor: nextCursor as String?, + total: (total as num?)?.toInt(), + ); + } + + @override + bool operator ==(Object other) => + other is MailPageInfo && + other.hasNext == hasNext && + other.nextCursor == nextCursor && + other.total == total; + + @override + int get hashCode => Object.hash(hasNext, nextCursor, total); +} + +/// 목록 한 쪽 — `{ items, pageInfo }` +@immutable +class MailPage { + const MailPage({ + required this.items, + required this.pageInfo, + this.serverTime, + }); + + final List items; + final MailPageInfo pageInfo; + + /// 응답의 `Date` 머리(서버 시각) — 없으면 null. 붙잡아 둔 발송이 풀릴 때를 서버 시계로 재는 데 쓴다 + final DateTime? serverTime; + + /// 서버 시각을 붙인 쪽 + MailPage withServerTime(DateTime? serverTime) => + MailPage(items: items, pageInfo: pageInfo, serverTime: serverTime); + + /// 모양이 틀리면(항목 하나라도) null + static MailPage? tryParse( + Object? json, + T? Function(Object? item) parseItem, + ) { + if (json is! Map) return null; + final Object? rawItems = json['items']; + final MailPageInfo? pageInfo = MailPageInfo.tryParse(json['pageInfo']); + if (rawItems is! List || pageInfo == null) return null; + final List items = []; + for (final Object? raw in rawItems) { + final T? item = parseItem(raw); + if (item == null) return null; + items.add(item); + } + return MailPage(items: List.unmodifiable(items), pageInfo: pageInfo); + } +} diff --git a/flutter/lib/features/mail/data/models/mail_search.dart b/flutter/lib/features/mail/data/models/mail_search.dart new file mode 100644 index 0000000..7ab64d9 --- /dev/null +++ b/flutter/lib/features/mail/data/models/mail_search.dart @@ -0,0 +1,259 @@ +import 'package:arasystem_app/features/mail/data/models/message_summary.dart'; +import 'package:arasystem_app/features/mail/domain/search_highlight.dart'; +import 'package:flutter/foundation.dart'; + +/// 목록 항목의 검색 강조 — 계약 2절 "검색 — 14단계"의 `searchHighlight`(색인 방식으로 찾았을 때만 값이 있다) +@immutable +class MessageListHighlight { + const MessageListHighlight({ + required this.subject, + required this.previewText, + required this.previewRanges, + }); + + /// 그 항목의 제목(서버가 NFC 로 실었다) 안의 맞은 곳 + final List subject; + + /// 보일 미리 보기 한 줄 — 본문에서 맞았으면 맞은 말 둘레(잘린 쪽에 "…"), 아니면 엔진의 미리 보기 + final String previewText; + + /// [previewText] 안의 맞은 곳 — 비었을 수 있다(인용 밖의 첫 글을 보일 때) + final List previewRanges; + + /// 모양이 틀리면 null — 강조만 버리고 항목은 살린다 + static MessageListHighlight? tryParse(Object? json) { + if (json is! Map) return null; + final List? subject = parseSearchRanges(json['subject']); + final Object? preview = json['preview']; + if (subject == null || preview is! Map) return null; + final Object? text = preview['text']; + final List? ranges = parseSearchRanges(preview['ranges']); + if (text is! String || ranges == null) return null; + return MessageListHighlight( + subject: subject, + previewText: text, + previewRanges: ranges, + ); + } + + @override + bool operator ==(Object other) => + other is MessageListHighlight && + listEquals(other.subject, subject) && + other.previewText == previewText && + listEquals(other.previewRanges, previewRanges); + + @override + int get hashCode => Object.hash( + Object.hashAll(subject), + previewText, + Object.hashAll(previewRanges), + ); +} + +/// 검색 결과 한 통 — 목록 칸([summary])과 그 응답의 강조 +@immutable +class MailSearchItem { + const MailSearchItem({ + required this.summary, + this.highlight, + this.highlightSupported = false, + }); + + final MessageSummary summary; + + /// 맞은 곳 — 색인 방식으로 찾았을 때만. 엔진 방식 · 모양이 틀린 강조는 null + final MessageListHighlight? highlight; + + /// 응답 항목에 `searchHighlight` 칸이 있었다(14단계 서버 — 값이 null 이어도) — 그 결과에서 연 메일만 강조 쿼리를 싣는다 + /// (14단계 앞 서버는 모르는 쿼리를 400 `VAL_001` 로 막는다) + final bool highlightSupported; + + String get id => summary.id; + + /// 읽기 화면이 바꾼 메일의 표시 · 메일함 · 붙잡힘만 덮는다 — 제목 · 미리 보기 · 강조는 이 응답의 것 그대로(강조 위치가 그 글에 묶여 있다) + MailSearchItem withStateOf(MessageSummary next) => MailSearchItem( + summary: summary.withStateOf(next), + highlight: highlight, + highlightSupported: highlightSupported, + ); + + /// 모양이 틀리면 null(목록 항목과 같은 규칙) — `searchHighlight` 만은 틀려도 강조 없는 것으로 읽는다 + static MailSearchItem? tryParse(Object? json) { + final MessageSummary? summary = MessageSummary.tryParse(json); + if (summary == null || json is! Map) return null; + final bool supported = json.containsKey('searchHighlight'); + return MailSearchItem( + summary: summary, + highlight: supported + ? MessageListHighlight.tryParse(json['searchHighlight']) + : null, + highlightSupported: supported, + ); + } + + @override + bool operator ==(Object other) => + other is MailSearchItem && + other.summary == summary && + other.highlight == highlight && + other.highlightSupported == highlightSupported; + + @override + int get hashCode => Object.hash(summary, highlight, highlightSupported); +} + +/// 읽기 화면의 검색 강조 — 강조 쿼리로 받은 `GET /api/messages/{id}` 의 `searchHighlight`. 위치는 그 응답의 글(NFC) 기준이다 +@immutable +class MessageDetailHighlight { + const MessageDetailHighlight({ + this.subject = const [], + this.attachments = const >{}, + this.textBody = const [], + }); + + /// 제목의 맞은 곳 + final List subject; + + /// 첨부 이름의 맞은 곳 — 첨부 id 마다(맞은 첨부만) + final Map> attachments; + + /// 글 본문(textBody)의 맞은 곳 — 서식 본문은 서버가 맞은 글 조각에 `` 를 넣어 보낸다 + final List textBody; + + /// 모양이 틀리면 null — 강조만 버리고 메일은 연다 + static MessageDetailHighlight? tryParse(Object? json) { + if (json is! Map) return null; + final List? subject = parseSearchRanges(json['subject']); + final List? textBody = parseSearchRanges(json['textBody']); + final Object? rawAttachments = json['attachments']; + if (subject == null || + textBody == null || + rawAttachments is! Map) { + return null; + } + final Map> attachments = + >{}; + for (final MapEntry entry in rawAttachments.entries) { + final List? ranges = parseSearchRanges(entry.value); + if (ranges == null) return null; + attachments[entry.key] = ranges; + } + return MessageDetailHighlight( + subject: subject, + attachments: Map>.unmodifiable(attachments), + textBody: textBody, + ); + } + + @override + bool operator ==(Object other) => + other is MessageDetailHighlight && + listEquals(other.subject, subject) && + _sameAttachments(other.attachments, attachments) && + listEquals(other.textBody, textBody); + + static bool _sameAttachments( + Map> a, + Map> b, + ) => + a.length == b.length && + a.entries.every( + (MapEntry> entry) => + b.containsKey(entry.key) && listEquals(b[entry.key], entry.value), + ); + + @override + int get hashCode => Object.hash( + Object.hashAll(subject), + Object.hashAllUnordered(attachments.keys), + Object.hashAll(textBody), + ); +} + +/// 읽기 화면에 넘기는 강조 쿼리 — 검색어와 글 조건 넷(조건은 2차)만, 빈 칸은 싣지 않는다 +/// (계약 2절 "검색 — 14단계" › 읽기 화면 — `after` · `before` · `hasAttachment` · `cursor` · `limit` 은 받지 않는다) +@immutable +class MessageHighlightQuery { + const MessageHighlightQuery({ + this.q = '', + this.from = '', + this.to = '', + this.subject = '', + this.body = '', + }); + + final String q; + final String from; + final String to; + final String subject; + final String body; + + /// 실을 칸이 없다 — 강조 쿼리 없이 받는다 + bool get isEmpty => + q.isEmpty && + from.isEmpty && + to.isEmpty && + subject.isEmpty && + body.isEmpty; + + /// 쿼리 칸 — 빈 칸은 뺀다(서버의 이름 그대로) + Map toQueryParameters() => { + if (q.isNotEmpty) 'q': q, + if (from.isNotEmpty) 'from': from, + if (to.isNotEmpty) 'to': to, + if (subject.isNotEmpty) 'subject': subject, + if (body.isNotEmpty) 'body': body, + }; + + @override + bool operator ==(Object other) => + other is MessageHighlightQuery && + other.q == q && + other.from == from && + other.to == to && + other.subject == subject && + other.body == body; + + @override + int get hashCode => Object.hash(q, from, to, subject, body); +} + +/// 지난 메일 검색 색인의 상태 — `GET /api/search-index` 의 `status` +enum SearchIndexStatus { + /// 지난 메일을 채우는 중 — 그동안의 검색은 엔진 방식(낱말이 통째로 같을 때만)이다 + building, + + /// 준비됐다 — 색인 방식(부분 일치 · 강조) + ready; + + /// 응답 칸을 읽는다 — 모르는 값은 null(안내 줄을 보이지 않는다) + static SearchIndexStatus? parse(Object? value) { + for (final SearchIndexStatus status in SearchIndexStatus.values) { + if (status.name == value) return status; + } + return null; + } +} + +/// 검색 화면을 여는 값 — 라우트의 `extra`. 시작한 메일함(null 이면 받은편지함 — 메일함 목록의 기본 메일함). +/// 검색어는 주소에 싣지 않는다(앱에는 새로고침 · 즐겨찾기가 없다 — 검색어가 남는 곳을 줄인다) +@immutable +class MailSearchStart { + const MailSearchStart({this.mailboxId}); + + final String? mailboxId; +} + +/// 검색 결과에서 연 메일 — 읽기 화면 라우트의 `extra`. +/// 읽기 화면은 [highlight] 로 받아 맞은 말을 칠하고, 머리에 "메일함 받은편지함" 줄을 둔다([scopeMailboxId] 에 있으면 그 메일함) +@immutable +class MailSearchOpen { + const MailSearchOpen({this.highlight, this.scopeMailboxId}); + + /// 강조 쿼리 — 14단계 서버의 결과([MailSearchItem.highlightSupported])에서만, 아니면 null(쿼리 없이 받는다) + final MessageHighlightQuery? highlight; + + /// 한 메일함에서 찾았으면 그 메일함 — 전체 메일함이면 null + final String? scopeMailboxId; +} diff --git a/flutter/lib/features/mail/data/models/mailbox.dart b/flutter/lib/features/mail/data/models/mailbox.dart new file mode 100644 index 0000000..7cfa6a4 --- /dev/null +++ b/flutter/lib/features/mail/data/models/mailbox.dart @@ -0,0 +1,99 @@ +import 'package:flutter/foundation.dart'; + +/// 메일함 역할 — 계약 2절 `Mailbox.role` (역할이 있으면 서버가 한국어 표준 이름을 준다) +enum MailboxRole { + inbox, + drafts, + sent, + junk, + trash, + archive; + + /// 응답 칸을 읽는다 — null · 모르는 이름은 역할 없음(사용자가 만든 메일함)으로 본다 + static MailboxRole? parse(Object? value) { + for (final MailboxRole role in MailboxRole.values) { + if (role.name == value) return role; + } + return null; + } +} + +/// 메일함 하나 — 계약 2절 `Mailbox` +@immutable +class Mailbox { + const Mailbox({ + required this.id, + required this.name, + required this.role, + required this.parentId, + required this.sortOrder, + required this.totalCount, + required this.unreadCount, + }); + + final String id; + + /// 역할이 있으면 한국어 표준 이름(받은편지함 등), 없으면 서버의 이름 + final String name; + + /// 역할 — null 이면 사용자가 만든 메일함("내 메일함") + final MailboxRole? role; + final String? parentId; + final int sortOrder; + final int totalCount; + final int unreadCount; + + Mailbox copyWith({int? unreadCount}) => Mailbox( + id: id, + name: name, + role: role, + parentId: parentId, + sortOrder: sortOrder, + totalCount: totalCount, + unreadCount: unreadCount ?? this.unreadCount, + ); + + /// 모양이 틀리면 null + static Mailbox? tryParse(Object? json) { + if (json is! Map) return null; + final Object? id = json['id']; + final Object? name = json['name']; + final Object? parentId = json['parentId']; + final Object? sortOrder = json['sortOrder']; + final Object? totalCount = json['totalCount']; + final Object? unreadCount = json['unreadCount']; + if (id is! String || + id.isEmpty || + name is! String || + (parentId != null && parentId is! String) || + sortOrder is! num || + totalCount is! num || + unreadCount is! num) { + return null; + } + return Mailbox( + id: id, + name: name, + role: MailboxRole.parse(json['role']), + parentId: parentId as String?, + sortOrder: sortOrder.toInt(), + totalCount: totalCount.toInt(), + unreadCount: unreadCount.toInt(), + ); + } + + @override + bool operator ==(Object other) => + other is Mailbox && + other.id == id && + other.name == name && + other.role == role && + other.parentId == parentId && + other.sortOrder == sortOrder && + other.totalCount == totalCount && + other.unreadCount == unreadCount; + + @override + int get hashCode => + Object.hash(id, name, role, parentId, sortOrder, totalCount, unreadCount); +} diff --git a/flutter/lib/features/mail/data/models/message_detail.dart b/flutter/lib/features/mail/data/models/message_detail.dart new file mode 100644 index 0000000..ada1862 --- /dev/null +++ b/flutter/lib/features/mail/data/models/message_detail.dart @@ -0,0 +1,287 @@ +import 'package:arasystem_app/features/mail/data/models/mail_address.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_search.dart'; +import 'package:arasystem_app/features/mail/data/models/message_summary.dart'; +import 'package:flutter/foundation.dart'; + +/// 메일 한 통의 첨부 — 계약 2절 `MessageAttachment` +@immutable +class MessageAttachment { + const MessageAttachment({ + required this.id, + required this.name, + required this.type, + required this.size, + this.isInline = false, + }); + + final String id; + + /// 보낸 쪽이 붙인 이름 — 저장할 때는 다시 정리한다(`safeFileName`) + final String name; + + /// 서버가 알려 준 형식(`application/pdf` 등) — 다른 앱으로 열 때 쓴다 + final String type; + + /// 바이트 + final int size; + + /// 본문 안에 그림으로 들어갔다(cid → data:) — 첨부 목록에서 뺀다 + final bool isInline; + + /// 모양이 틀리면 null — `isInline` 이 없으면(옛 서버) 거짓으로 본다 + static MessageAttachment? tryParse(Object? json) { + if (json is! Map) return null; + final Object? id = json['id']; + final Object? name = json['name']; + final Object? type = json['type']; + final Object? size = json['size']; + final Object? isInline = json['isInline']; + if (id is! String || + id.isEmpty || + name is! String || + type is! String || + size is! num || + (isInline != null && isInline is! bool)) { + return null; + } + return MessageAttachment( + id: id, + name: name, + type: type, + size: size.toInt(), + isInline: isInline == true, + ); + } + + @override + bool operator ==(Object other) => + other is MessageAttachment && + other.id == id && + other.name == name && + other.type == type && + other.size == size && + other.isInline == isInline; + + @override + int get hashCode => Object.hash(id, name, type, size, isInline); +} + +/// 우리 서버가 넣은 대용량 첨부 하나 — 계약 4절 `MessageLargeFile` (첫 판은 받지 않고 이름 · 크기만 보인다) +@immutable +class MessageLargeFile { + const MessageLargeFile({ + required this.fileId, + required this.name, + required this.size, + }); + + final String fileId; + + /// 서버 기록의 이름 + final String name; + + /// 바이트 + final int size; + + static MessageLargeFile? tryParse(Object? json) { + if (json is! Map) return null; + final Object? fileId = json['fileId']; + final Object? name = json['name']; + final Object? size = json['size']; + if (fileId is! String || name is! String || size is! num) return null; + return MessageLargeFile(fileId: fileId, name: name, size: size.toInt()); + } + + @override + bool operator ==(Object other) => + other is MessageLargeFile && + other.fileId == fileId && + other.name == name && + other.size == size; + + @override + int get hashCode => Object.hash(fileId, name, size); +} + +/// 메일 한 통 — 계약 2절 `MessageDetail` (`GET /api/messages/{id}`) +/// +/// 목록 칸([summary])과 읽기 칸을 함께 둔다. 9단계 뒤에 더해진 칸(`imagesShown` · `isHtmlBodyOmitted` · `isBodyTruncated` · +/// `largeFiles` · 첨부의 `isInline`)은 없으면 거짓 · 빈 목록으로 읽는다(웹과 같다). 대용량 첨부는 하나가 틀려도 그것만 뺀다. +/// `replyTo` 도 없으면 빈 목록이다(쓰기 화면의 답장 받는 사람이 쓴다). +/// 14단계의 `searchHighlight`(강조 쿼리로 받았을 때만 값)는 없거나 모양이 틀리면 null 로 읽는다(메일은 연다). +@immutable +class MessageDetail { + const MessageDetail({ + required this.summary, + this.cc = const [], + this.bcc = const [], + this.replyTo = const [], + this.sentAt, + this.htmlBody, + this.textBody, + this.hasBlockedImages = false, + this.imagesShown = false, + this.isHtmlBodyOmitted = false, + this.isBodyTruncated = false, + this.attachments = const [], + this.largeFiles = const [], + this.searchHighlight, + }); + + /// 목록과 같은 칸 — 읽음 · 중요 · 메일함 · 붙잡힌 발송 + final MessageSummary summary; + final List cc; + + /// 숨은참조 — 내가 보낸 메일에만 온다 + final List bcc; + + /// 답장 받을 주소(`Reply-To`) — 없으면 빈 목록. 답장은 있으면 이 주소로 간다(쓰기 화면 — 웹 `buildReplyRecipients`) + final List replyTo; + final DateTime? sentAt; + + /// 서버가 정화한 HTML — 앱은 한 번 더 정화한다(`mail_html.dart`) + final String? htmlBody; + final String? textBody; + + /// 외부 이미지를 지웠다 — "외부 이미지 차단" 줄 + final bool hasBlockedImages; + + /// 외부 이미지를 중계 주소로 바꿔 넣었다(`images=show` 또는 설정) — 앱이 그 그림을 받아 넣는다 + final bool imagesShown; + + /// 서식 본문이 한도를 넘어 비웠다 — 글자 본문으로 보인다 + final bool isHtmlBodyOmitted; + + /// 보여 줄 본문이 읽기 한도에서 잘렸다 + final bool isBodyTruncated; + final List attachments; + final List largeFiles; + + /// 검색 강조 — 검색 결과에서 강조 쿼리로 받은 응답만(그 응답의 제목 · 첨부 이름 · 글 본문 기준), 그 밖에는 null + final MessageDetailHighlight? searchHighlight; + + String get id => summary.id; + + /// 첨부 목록에 보일 것 — 본문 안에 그림으로 들어간 것은 뺀다(웹 `listedAttachments`) + List get listedAttachments => [ + for (final MessageAttachment attachment in attachments) + if (!attachment.isInline) attachment, + ]; + + /// 목록 칸만 바꾼다 — 읽음 · 중요 · 메일함 · 붙잡힌 발송(본문 · 강조는 그대로) + MessageDetail withSummary(MessageSummary next) => MessageDetail( + summary: next, + cc: cc, + bcc: bcc, + replyTo: replyTo, + sentAt: sentAt, + htmlBody: htmlBody, + textBody: textBody, + hasBlockedImages: hasBlockedImages, + imagesShown: imagesShown, + isHtmlBodyOmitted: isHtmlBodyOmitted, + isBodyTruncated: isBodyTruncated, + attachments: attachments, + largeFiles: largeFiles, + searchHighlight: searchHighlight, + ); + + /// 본문 칸만 [other] 의 것으로 — [이미지 보기]로 다시 받은 본문(읽음 · 중요 · 메일함은 지금 값 그대로 — 웹 `showImages`). + /// 첨부와 검색 강조도 그 응답의 것으로 함께 바꾼다(강조 위치가 그 응답의 글에 묶여 있다) + MessageDetail withBodyOf(MessageDetail other) => MessageDetail( + summary: summary, + cc: cc, + bcc: bcc, + replyTo: replyTo, + sentAt: sentAt, + htmlBody: other.htmlBody, + textBody: other.textBody, + hasBlockedImages: other.hasBlockedImages, + imagesShown: other.imagesShown, + isHtmlBodyOmitted: other.isHtmlBodyOmitted, + isBodyTruncated: other.isBodyTruncated, + attachments: other.attachments, + largeFiles: other.largeFiles, + searchHighlight: other.searchHighlight, + ); + + /// 모양이 틀리면 null — 저장소가 `SYS_001` 로 던진다 + static MessageDetail? tryParse(Object? json) { + if (json is! Map) return null; + final MessageSummary? summary = MessageSummary.tryParse(json); + if (summary == null) return null; + final List? cc = _addresses(json['cc']); + final List? bcc = _addresses(json['bcc']); + final List? replyTo = _addresses(json['replyTo']); + final Object? sentAt = json['sentAt']; + final Object? htmlBody = json['htmlBody']; + final Object? textBody = json['textBody']; + final List? attachments = _attachments( + json['attachments'], + ); + final bool? hasBlockedImages = _flag(json['hasBlockedImages']); + final bool? imagesShown = _flag(json['imagesShown']); + final bool? omitted = _flag(json['isHtmlBodyOmitted']); + final bool? truncated = _flag(json['isBodyTruncated']); + final DateTime? sent = sentAt is String ? DateTime.tryParse(sentAt) : null; + if (cc == null || + bcc == null || + replyTo == null || + (sentAt != null && sent == null) || + (htmlBody != null && htmlBody is! String) || + (textBody != null && textBody is! String) || + attachments == null || + hasBlockedImages == null || + imagesShown == null || + omitted == null || + truncated == null) { + return null; + } + return MessageDetail( + summary: summary, + cc: cc, + bcc: bcc, + replyTo: replyTo, + sentAt: sent?.toUtc(), + htmlBody: htmlBody as String?, + textBody: textBody as String?, + hasBlockedImages: hasBlockedImages, + imagesShown: imagesShown, + isHtmlBodyOmitted: omitted, + isBodyTruncated: truncated, + attachments: attachments, + largeFiles: _largeFiles(json['largeFiles']), + searchHighlight: MessageDetailHighlight.tryParse(json['searchHighlight']), + ); + } + + /// 주소 칸 — 없으면 빈 목록, 모양이 틀리면 null + static List? _addresses(Object? value) => + value == null ? const [] : MailAddress.tryParseList(value); + + /// 참 · 거짓 칸 — 없으면 거짓, 모양이 틀리면 null + static bool? _flag(Object? value) { + if (value == null) return false; + return value is bool ? value : null; + } + + static List? _attachments(Object? value) { + if (value == null) return const []; + if (value is! List) return null; + final List items = []; + for (final Object? item in value) { + final MessageAttachment? attachment = MessageAttachment.tryParse(item); + if (attachment == null) return null; + items.add(attachment); + } + return List.unmodifiable(items); + } + + /// 대용량 첨부 — 11단계 칸이라 모양이 틀린 것은 빼고 읽는다(메일 전체를 열지 못하게 하지 않는다) + static List _largeFiles(Object? value) { + if (value is! List) return const []; + return List.unmodifiable([ + for (final Object? item in value) ?MessageLargeFile.tryParse(item), + ]); + } +} diff --git a/flutter/lib/features/mail/data/models/message_summary.dart b/flutter/lib/features/mail/data/models/message_summary.dart new file mode 100644 index 0000000..d7e069f --- /dev/null +++ b/flutter/lib/features/mail/data/models/message_summary.dart @@ -0,0 +1,216 @@ +import 'package:arasystem_app/features/mail/data/models/mail_address.dart'; +import 'package:flutter/foundation.dart'; + +/// 붙잡아 둔 내 발송의 종류 — 계약 2절 "보내기 취소 · 예약 발송"(9단계) +enum PendingSendKind { + /// [보내기]를 누른 뒤 보내기 취소를 기다리는 중 + undo, + + /// 예약 발송 + scheduled, +} + +/// 엔진이 붙잡아 둔 내 발송 — 아직 보내지 않았다(보내는 중 · 예약) +@immutable +class PendingSend { + const PendingSend({required this.sendAt, required this.kind}); + + /// 엔진이 보낼 시각 (UTC) + final DateTime sendAt; + final PendingSendKind kind; + + /// 응답의 값을 읽는다 — 칸이 없거나(9단계 앞 서버) 모양이 틀리면 없는 것(null)으로 본다(웹 `pendingSendOf` 와 같다) + static PendingSend? tryParse(Object? json) { + if (json is! Map) return null; + final Object? sendAt = json['sendAt']; + final Object? kind = json['kind']; + final DateTime? at = sendAt is String ? DateTime.tryParse(sendAt) : null; + final PendingSendKind? parsedKind = switch (kind) { + 'undo' => PendingSendKind.undo, + 'scheduled' => PendingSendKind.scheduled, + _ => null, + }; + if (at == null || parsedKind == null) return null; + return PendingSend(sendAt: at.toUtc(), kind: parsedKind); + } + + @override + bool operator ==(Object other) => + other is PendingSend && other.sendAt == sendAt && other.kind == kind; + + @override + int get hashCode => Object.hash(sendAt, kind); +} + +/// 목록의 메일 한 통 — 계약 2절 `MessageSummary` +@immutable +class MessageSummary { + const MessageSummary({ + required this.id, + required this.threadId, + required this.mailboxIds, + required this.from, + required this.to, + required this.subject, + required this.preview, + required this.receivedAt, + required this.isRead, + required this.isFlagged, + required this.hasAttachment, + required this.size, + this.pendingSend, + }); + + final String id; + final String threadId; + final List mailboxIds; + final MailAddress? from; + final List to; + + /// 제목이 없으면 빈 글 + final String subject; + final String preview; + + /// 받은 시각 (UTC) + final DateTime receivedAt; + final bool isRead; + final bool isFlagged; + final bool hasAttachment; + + /// 바이트 + final int size; + + /// 아직 보내지 않은 내 메일이면 그 정보 — 그 밖에는 null + final PendingSend? pendingSend; + + /// 아직 보내지 않은 메일(보내는 중 · 예약)인가 — 취소해야만 보낸편지함을 떠난다. + /// 옮기거나 지워도 엔진은 그 시각에 보내므로 목록에서 고를 수 없다(계약 2절 "붙잡힌 메일은 취소로만 보낸편지함을 떠난다") + bool get isHeld => pendingSend != null; + + MessageSummary copyWith({ + bool? isRead, + bool? isFlagged, + List? mailboxIds, + }) => MessageSummary( + id: id, + threadId: threadId, + mailboxIds: mailboxIds ?? this.mailboxIds, + from: from, + to: to, + subject: subject, + preview: preview, + receivedAt: receivedAt, + isRead: isRead ?? this.isRead, + isFlagged: isFlagged ?? this.isFlagged, + hasAttachment: hasAttachment, + size: size, + pendingSend: pendingSend, + ); + + /// [other] 의 표시(읽음 · 중요) · 메일함 · 붙잡힌 발송만 덮는다 — 제목 · 미리 보기 · 받은 시각은 이 요약 그대로 + /// (검색 결과는 강조 위치가 그 응답의 제목 · 미리 보기에 묶여 있다) + MessageSummary withStateOf(MessageSummary other) => MessageSummary( + id: id, + threadId: threadId, + mailboxIds: other.mailboxIds, + from: from, + to: to, + subject: subject, + preview: preview, + receivedAt: receivedAt, + isRead: other.isRead, + isFlagged: other.isFlagged, + hasAttachment: hasAttachment, + size: size, + pendingSend: other.pendingSend, + ); + + /// 모양이 틀리면 null — `pendingSend` 만은 틀려도 없는 것으로 읽는다 + static MessageSummary? tryParse(Object? json) { + if (json is! Map) return null; + final Object? id = json['id']; + final Object? threadId = json['threadId']; + final Object? mailboxIds = json['mailboxIds']; + final Object? fromJson = json['from']; + final Object? subject = json['subject']; + final Object? preview = json['preview']; + final Object? receivedAt = json['receivedAt']; + final Object? isRead = json['isRead']; + final Object? isFlagged = json['isFlagged']; + final Object? hasAttachment = json['hasAttachment']; + final Object? size = json['size']; + + final MailAddress? from = fromJson == null + ? null + : MailAddress.tryParse(fromJson); + final List? to = MailAddress.tryParseList(json['to']); + final DateTime? received = receivedAt is String + ? DateTime.tryParse(receivedAt) + : null; + if (id is! String || + id.isEmpty || + threadId is! String || + mailboxIds is! List || + mailboxIds.any((Object? value) => value is! String) || + (fromJson != null && from == null) || + to == null || + subject is! String || + preview is! String || + received == null || + isRead is! bool || + isFlagged is! bool || + hasAttachment is! bool || + size is! num) { + return null; + } + return MessageSummary( + id: id, + threadId: threadId, + mailboxIds: List.unmodifiable(mailboxIds.cast()), + from: from, + to: to, + subject: subject, + preview: preview, + receivedAt: received.toUtc(), + isRead: isRead, + isFlagged: isFlagged, + hasAttachment: hasAttachment, + size: size.toInt(), + pendingSend: PendingSend.tryParse(json['pendingSend']), + ); + } + + @override + bool operator ==(Object other) => + other is MessageSummary && + other.id == id && + other.threadId == threadId && + listEquals(other.mailboxIds, mailboxIds) && + other.from == from && + listEquals(other.to, to) && + other.subject == subject && + other.preview == preview && + other.receivedAt == receivedAt && + other.isRead == isRead && + other.isFlagged == isFlagged && + other.hasAttachment == hasAttachment && + other.size == size && + other.pendingSend == pendingSend; + + @override + int get hashCode => Object.hash( + id, + threadId, + Object.hashAll(mailboxIds), + from, + Object.hashAll(to), + subject, + preview, + receivedAt, + isRead, + isFlagged, + hasAttachment, + size, + pendingSend, + ); +} diff --git a/flutter/lib/features/mail/data/repositories/compose_repository.dart b/flutter/lib/features/mail/data/repositories/compose_repository.dart new file mode 100644 index 0000000..ce478c7 --- /dev/null +++ b/flutter/lib/features/mail/data/repositories/compose_repository.dart @@ -0,0 +1,333 @@ +import 'dart:typed_data'; + +import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/api/request_flags.dart'; +import 'package:arasystem_app/core/api/server_time.dart'; +import 'package:arasystem_app/core/people/recipient_suggestion.dart'; +import 'package:arasystem_app/features/mail/data/models/compose_models.dart'; +import 'package:arasystem_app/features/mail/data/models/compose_payload.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_page.dart'; +import 'package:arasystem_app/features/mail/data/models/message_detail.dart'; +import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart'; +import 'package:dio/dio.dart'; + +/// 쓰기 화면의 API 경로 — baseUrl(`…/api`) 뒤에 붙는다(계약 2 · 3 · 4절). 경로는 대소문자를 가린다 +abstract final class ComposeApiPaths { + static const String drafts = '/drafts'; + + /// 임시 저장본 하나 — id 는 경로 칸으로 인코딩한다 + static String draft(String id) => '$drafts/${Uri.encodeComponent(id)}'; + + static const String recipientSuggestions = '/recipient-suggestions'; + static const String contactGroups = '/contact-groups'; + + /// 그룹 구성원 — `GET /api/contacts?groupId=` + static const String contacts = '/contacts'; + static const String mailSignature = '/mail-signature'; + + /// 서명 그림 — 본인 그림만 내준다(표준 래퍼 없는 그림) + static String signatureImage(String id) => + '$mailSignature/images/${Uri.encodeComponent(id)}'; + + static const String mailSettings = '/mail-settings'; + + /// 대용량 파일 하나의 상태 — 발송이 `MAIL_016` 이면 파일마다 다시 묻는다(계약 4절) + static String fileUpload(String id) => + '/file-uploads/${Uri.encodeComponent(id)}'; + + /// 첨부 올리기 — multipart `file` 하나(계약 2절 `POST /api/mail-attachments`) + static const String mailAttachments = '/mail-attachments'; + + /// 메일 사용량 · 첨부 한도 + static const String mailQuota = '/mail-quota'; +} + +/// 발송 · 저장의 받기 한도 — 서버가 55~56초 안에 답한다(계약 2절). 기본 한도(15초)로 끊으면 화면은 실패로 보는데 서버는 이미 보냈을 수 있다 +/// (웹 `MAIL_SEND_TIMEOUT_MS` 와 같은 60초) +const Duration kMailSendTimeout = Duration(seconds: 60); + +/// 중복 발송 방지 키 머리 (계약 2절) +const String kIdempotencyHeader = 'Idempotency-Key'; + +/// 첨부 올리기의 보내기 한도 — 본문 전체에 건다(Dio 5.9 는 sendTimeout 을 본문을 다 보낼 때까지 잰다). 20MB 를 느린 망에서도 +const Duration kAttachmentUploadSendTimeout = Duration(minutes: 5); + +/// 첨부 올리기의 받기 한도 — 다 보낸 뒤 서버가 메일 엔진에 넘기고 답한다(발송과 같은 60초) +const Duration kAttachmentUploadReceiveTimeout = Duration(seconds: 60); + +/// 첨부 올리기의 multipart 칸 이름 (백엔드 `FileInterceptor('file')`) +const String kAttachmentFileField = 'file'; + +/// 처음 받아 두는 추천 수 — 계약의 최대 limit (웹 `RECIPIENT_SUGGESTION_PRELOAD`) +const int kRecipientSuggestionPreload = 100; + +/// 주소록 쪽 크기 — 그룹 목록 · 구성원 (웹 `CONTACTS_PAGE_SIZE`) +const int kContactsPageSize = 100; + +/// 구성원을 받는 최대 쪽 수 — 100명씩 10쪽(그룹 하나의 구성원은 500명까지 — 웹 `CONTACT_GROUP_MEMBER_PAGES_MAX`) +const int kGroupMemberPagesMax = 10; + +/// 받는 사람 추천 한 쪽 +typedef RecipientSuggestionPage = ({ + List items, + bool hasNext, +}); + +/// 쓰기 화면의 API — 계약 2절(임시 저장 · 발송 · 첨부 올리기 · 첨부 한도 · 서명 · 받는 사람 추천 · 보내기 설정) · 3절(그룹 · 구성원) · 4절(대용량 파일 상태). +/// +/// 응답 언래핑 · 에러 코드 · 알림은 인터셉터가 맡는다 — 여기서는 읽기만 하고 문구를 만들지 않는다. +/// 실패는 늘 `DioException`(`error` 에 `ApiException`)이다 — 응답 모양이 계약과 다르면 `SYS_001`. +class ComposeRepository { + const ComposeRepository(this._dio); + + final Dio _dio; + + /// 임시 저장본 — 알림은 띄우지 않는다(받지 못하면 화면이 [다시 시도]를 그린다) + Future getDraft(String id) async { + final Response response = await _dio.get( + ComposeApiPaths.draft(id), + options: ApiOptions.silent(), + ); + final Draft? draft = Draft.tryParse(response.data); + if (draft == null) throw _malformed(response); + return draft; + } + + /// 새 저장본 — 자동 저장은 알리지 않는다([silent]). 받기 한도는 발송과 같다 + Future createDraft( + MailComposePayload payload, { + bool silent = true, + }) async { + final Response response = await _dio.post( + ComposeApiPaths.drafts, + data: payload.toJson(), + options: _longOptions(silent: silent), + ); + return _saved(response); + } + + /// 저장본 바꾸기 — 응답의 새 id 로 바꿔 쓴다. 다른 곳에서 바뀌었으면 409 `MAIL_008`, 초안이 아니면 404 `RES_001` + Future updateDraft( + String id, + MailComposePayload payload, { + bool silent = true, + }) async { + final Response response = await _dio.put( + ComposeApiPaths.draft(id), + data: payload.toJson(), + options: _longOptions(silent: silent), + ); + return _saved(response); + } + + /// 보낸다 — 같은 메일을 다시 보낼 때도 같은 [idempotencyKey]. 화면이 칸에 보이는 코드([silentCodes])는 알리지 않는다. + /// 응답의 서버 시각(`Date`)도 함께 준다 — 보내기 취소 알림의 마감을 서버 시계로 잰다 + Future<({SentMessage sent, DateTime? serverTime})> sendMessage( + MailComposePayload payload, { + required String idempotencyKey, + Set silentCodes = const {}, + }) async { + final Response response = await _dio.post( + MailApiPaths.messages, + data: payload.toJson(), + options: Options( + receiveTimeout: kMailSendTimeout, + headers: {kIdempotencyHeader: idempotencyKey}, + extra: {kSilentExtraKey: silentCodes}, + ), + ); + final SentMessage? sent = SentMessage.tryParse(response.data); + if (sent == null) throw _malformed(response); + return (sent: sent, serverTime: serverTimeOf(response)); + } + + /// 내 서명 — 알리지 않는다(서명 없이 쓴다) + Future getSignature() async { + final Response response = await _dio.get( + ComposeApiPaths.mailSignature, + options: ApiOptions.silent(), + ); + final MailSignature? signature = MailSignature.tryParse(response.data); + if (signature == null) throw _malformed(response); + return signature; + } + + /// 서명 그림 하나(바이트) — 알리지 않는다(그 그림을 숨긴다). [maxBytes] 를 넘게 오면 끊는다 + Future fetchSignatureImage( + String id, { + required int maxBytes, + }) async { + final CancelToken cancel = CancelToken(); + final Response> response = await _dio.get>( + ComposeApiPaths.signatureImage(id), + options: Options( + responseType: ResponseType.bytes, + extra: {kSilentExtraKey: true}, + ), + cancelToken: cancel, + onReceiveProgress: (int received, int total) { + if (received > maxBytes || total > maxBytes) cancel.cancel(); + }, + ); + final List? data = response.data; + if (data == null) { + throw apiFailure(response.requestOptions, kFallbackCode); + } + return data is Uint8List ? data : Uint8List.fromList(data); + } + + /// 받는 사람 추천 — 보조 정보라 알리지 않는다. [query] 가 있으면 서버가 같은 맞추기로 거른다(계약 2절) + Future listRecipientSuggestions({ + String? query, + int limit = kRecipientSuggestionPreload, + CancelToken? cancelToken, + }) async { + final Response response = await _dio.get( + ComposeApiPaths.recipientSuggestions, + queryParameters: {'q': ?query, 'limit': limit}, + options: ApiOptions.silent(), + cancelToken: cancelToken, + ); + final Object? data = response.data; + final Object? items = data is Map ? data['items'] : null; + final MailPageInfo? pageInfo = data is Map + ? MailPageInfo.tryParse(data['pageInfo']) + : null; + if (items is! List || pageInfo == null) throw _malformed(response); + return ( + items: List.unmodifiable([ + for (final Object? item in items) ?RecipientSuggestion.tryParse(item), + ]), + hasNext: pageInfo.hasNext, + ); + } + + /// 내 주소록 그룹(한 쪽 — 100개까지) — 알리지 않는다(그룹 줄 없이 주소 추천만) + Future> listContactGroups() async { + final Response response = await _dio.get( + ComposeApiPaths.contactGroups, + queryParameters: {'limit': kContactsPageSize}, + options: ApiOptions.silent(), + ); + final MailPage? page = + MailPage.tryParse( + response.data, + ContactGroupSummary.tryParse, + ); + if (page == null) throw _malformed(response); + return page.items; + } + + /// 그룹 구성원 — 100명씩 다음 쪽까지(최대 [kGroupMemberPagesMax] 쪽). 고른 일이라 실패는 인터셉터가 알린다 + Future> listGroupMembers(String groupId) async { + final List members = []; + String? cursor; + for (int page = 0; page < kGroupMemberPagesMax; page++) { + final Response response = await _dio.get( + ComposeApiPaths.contacts, + queryParameters: { + 'groupId': groupId, + 'limit': kContactsPageSize, + 'cursor': ?cursor, + }, + ); + final MailPage? result = MailPage.tryParse( + response.data, + groupMemberOf, + ); + if (result == null) throw _malformed(response); + members.addAll(result.items); + if (!result.pageInfo.hasMore) break; + cursor = result.pageInfo.nextCursor; + } + return members; + } + + /// 보내기 취소 시간(초) — 알리지 않는다(알림의 마감을 다른 길로 센다) + Future undoSendSeconds() async { + final Response response = await _dio.get( + ComposeApiPaths.mailSettings, + options: ApiOptions.silent(), + ); + final int? seconds = undoSendSecondsOf(response.data); + if (seconds == null) throw _malformed(response); + return seconds; + } + + /// 대용량 파일 하나의 지금 상태 — 없으면 404 `RES_001`(지웠거나 기간이 지남). 알리지 않는다 + Future getLargeFile(String id) async { + final Response response = await _dio.get( + ComposeApiPaths.fileUpload(id), + options: ApiOptions.silent(), + ); + final DraftLargeFile? file = DraftLargeFile.tryParse(response.data); + if (file == null) throw _malformed(response); + return file; + } + + /// 첨부 하나를 올린다 — multipart `file`, 형식은 확장자의 정식 형식([type] — 서버가 확장자와 형식을 함께 본다), 이름은 고른 파일의 보이는 이름. + /// [read] 는 부를 때마다 처음부터 읽는다(401 뒤 갱신해 다시 보낼 때 — 인증 인터셉터가 FormData 를 복제한다). + /// 실패는 화면이 그 줄에 사전 문구로 보인다 — 알리지 않는다 + Future uploadAttachment({ + required String name, + required String type, + required int length, + required Stream> Function() read, + required CancelToken cancelToken, + required void Function(int sent, int total) onProgress, + }) async { + final FormData form = FormData() + ..files.add( + MapEntry( + kAttachmentFileField, + MultipartFile.fromStream( + read, + length, + filename: name, + contentType: DioMediaType.parse(type), + ), + ), + ); + final Response response = await _dio.post( + ComposeApiPaths.mailAttachments, + data: form, + options: Options( + sendTimeout: kAttachmentUploadSendTimeout, + receiveTimeout: kAttachmentUploadReceiveTimeout, + extra: {kSilentExtraKey: true}, + ), + cancelToken: cancelToken, + onSendProgress: onProgress, + ); + final MessageAttachment? attachment = MessageAttachment.tryParse( + response.data, + ); + if (attachment == null) throw _malformed(response); + return attachment; + } + + /// 첨부 한도(파일당 · 합계) — 알리지 않는다(받지 못하면 대체값으로 쓴다) + Future getAttachmentLimits() async { + final Response response = await _dio.get( + ComposeApiPaths.mailQuota, + options: ApiOptions.silent(), + ); + final AttachmentLimits? limits = AttachmentLimits.tryParse(response.data); + if (limits == null) throw _malformed(response); + return limits; + } + + Options _longOptions({required bool silent}) => Options( + receiveTimeout: kMailSendTimeout, + extra: {if (silent) kSilentExtraKey: true}, + ); + + DraftSaved _saved(Response response) { + final DraftSaved? saved = DraftSaved.tryParse(response.data); + if (saved == null) throw _malformed(response); + return saved; + } + + DioException _malformed(Response response) => + apiFailure(response.requestOptions, kFallbackCode); +} diff --git a/flutter/lib/features/mail/data/repositories/mail_repository.dart b/flutter/lib/features/mail/data/repositories/mail_repository.dart new file mode 100644 index 0000000..ceb7da7 --- /dev/null +++ b/flutter/lib/features/mail/data/repositories/mail_repository.dart @@ -0,0 +1,353 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/api/api_exception.dart'; +import 'package:arasystem_app/core/api/request_flags.dart'; +import 'package:arasystem_app/core/api/server_time.dart'; +import 'package:arasystem_app/core/files/downloaded_files.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_bulk_results.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_page.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_search.dart'; +import 'package:arasystem_app/features/mail/data/models/mailbox.dart'; +import 'package:arasystem_app/features/mail/data/models/message_detail.dart'; +import 'package:arasystem_app/features/mail/data/models/message_summary.dart'; +import 'package:arasystem_app/features/mail/domain/search_conditions.dart'; +import 'package:dio/dio.dart'; + +/// 메일 API 경로 — baseUrl(`…/api`) 뒤에 붙는다(계약 2절). 경로는 대소문자를 가린다 +abstract final class MailApiPaths { + static const String mailboxes = '/mailboxes'; + static const String messages = '/messages'; + + /// 메일 한 통 — id 는 경로 칸으로 인코딩한다 + static String message(String id) => '$messages/${Uri.encodeComponent(id)}'; + + /// 휴지통에서 영구 삭제 (여러 통) + static const String messageDeletions = '/message-deletions'; + + /// 새 메일 확인 — 불투명한 상태 글 + static const String mailState = '/mail-state'; + + /// 지난 메일 검색 색인의 상태 (14단계) — 조회지만 그 직원의 색인을 만들고 멈춤을 푼다(계약 2절 "검색 색인") + static const String searchIndex = '/search-index'; + + /// 첨부 받기 — 표준 래퍼 없는 파일 + static String attachment(String messageId, String attachmentId) => + '${message(messageId)}/attachments/${Uri.encodeComponent(attachmentId)}'; + + /// 붙잡아 둔 발송 취소(보내기 취소 · 예약 취소) — id 는 보낸 사본의 메일 id (9단계) + static String scheduledMessage(String id) => + '/scheduled-messages/${Uri.encodeComponent(id)}'; + + /// 외부 이미지 중계 — 서버가 본문에 넣은 주소(`/api/mail-images/<표>`)의 표 (9단계) + static String mailImage(String token) => + '/mail-images/${Uri.encodeComponent(token)}'; +} + +/// 메일 한 통을 외부 이미지가 보이는 본문으로 받는 쿼리 — `?images=show` (계약 2절 "외부 이미지 보기") +const String kMailImagesQuery = 'images'; +const String kMailImagesShow = 'show'; + +/// 목록 한 쪽의 기본 수 — 계약 기본값(20)과 같다 +const int kMailPageSize = 20; + +/// 여러 통 요청 하나의 id 한도 — 넘으면 이만큼씩 차례로 보낸다(계약 2절 "여러 통 한꺼번에") +const int kMailBulkMaxIds = 100; + +/// 메일 API — 계약 `docs/mail/api-contract.md` 2절. 웹과 같은 경로를 Bearer 로 부른다. +/// +/// 응답 언래핑 · 에러 코드 · 알림은 인터셉터가 맡는다 — 여기서는 읽기만 하고 문구를 만들지 않는다. +/// 실패는 늘 `DioException`(`error` 에 `ApiException`)이다 — 응답 모양이 계약과 다르면 `SYS_001`. +class MailRepository { + const MailRepository(this._dio); + + final Dio _dio; + + /// 메일함 목록 — 알림은 띄우지 않는다(목록 · 서랍이 받지 못함을 직접 그린다) + Future> listMailboxes() async { + final Response response = await _dio.get( + MailApiPaths.mailboxes, + options: ApiOptions.silent(), + ); + final MailPage? page = MailPage.tryParse( + response.data, + Mailbox.tryParse, + ); + if (page == null) throw _malformed(response); + return page.items; + } + + /// 메일함 하나의 목록 — 받은 시각 내림차순. [cursor] 는 해석하지 않고 그대로 보낸다. + /// [silent] 면 실패를 알리지 않는다(처음 여는 목록은 화면이 받지 못함을 직접 그린다 · 새 메일 맞추기) + Future> listMessages({ + required String mailboxId, + String? cursor, + int limit = kMailPageSize, + bool silent = false, + }) async { + final Response response = await _dio.get( + MailApiPaths.messages, + queryParameters: { + 'mailboxId': mailboxId, + 'limit': limit, + 'cursor': ?cursor, + }, + options: silent ? ApiOptions.silent() : null, + ); + final MailPage? page = MailPage.tryParse( + response.data, + MessageSummary.tryParse, + ); + if (page == null) throw _malformed(response); + return page.withServerTime(serverTimeOf(response)); + } + + /// 메일 검색 — 받은 시각 내림차순 (계약 2절 "검색" · "검색 — 14단계"). [mailboxId] 가 없으면 휴지통 · 스팸메일함을 뺀 모든 메일함에서 찾는다. + /// [q] 가 비면 싣지 않는다 — 조건([conditions] — `from` · `to` · `subject` · `body` · `after` · `before` · `hasAttachment`, 빈 칸은 빼고)만으로 찾는다. + /// [cursor] 는 해석하지 않고 그대로 보낸다(방식 · 범위 · 검색어 · 조건이 그 안에 묶여 있다). 색인 방식은 `hasNext` 인데 항목이 [limit] 보다 적거나 0개일 수 있다. + /// [silent] 면 실패를 알리지 않는다(처음 찾는 쪽은 화면이 받지 못함을 직접 그린다). [cancelToken] 으로 끊은 요청은 알리지 않는다. + /// 응답의 서버 시각(`Date`)도 싣는다 — 결과의 붙잡힌 메일이 풀릴 때를 서버 시계로 잰다 + Future> searchMessages({ + required String q, + SearchConditions conditions = SearchConditions.none, + String? mailboxId, + String? cursor, + int limit = kMailPageSize, + bool silent = false, + CancelToken? cancelToken, + }) async { + final Response response = await _dio.get( + MailApiPaths.messages, + queryParameters: { + if (q.isNotEmpty) 'q': q, + ...searchConditionsParams(conditions), + 'mailboxId': ?mailboxId, + 'limit': limit, + 'cursor': ?cursor, + }, + options: silent ? ApiOptions.silent() : null, + cancelToken: cancelToken, + ); + final MailPage? page = MailPage.tryParse( + response.data, + MailSearchItem.tryParse, + ); + if (page == null) throw _malformed(response); + return page.withServerTime(serverTimeOf(response)); + } + + /// 지난 메일 검색 색인의 상태 — 모르는 값이면 null. 알림은 띄우지 않는다(받지 못하면 화면은 안내 줄을 보이지 않는다 — 계약) + Future searchIndexStatus() async { + final Response response = await _dio.get( + MailApiPaths.searchIndex, + options: ApiOptions.silent(), + ); + final Object? data = response.data; + if (data is! Map) throw _malformed(response); + return SearchIndexStatus.parse(data['status']); + } + + /// 메일 한 통 — 조회만으로는 읽음 처리하지 않는다(계약 2절). [showImages] 면 외부 이미지를 보이는 본문(`images=show`). + /// [highlight] 가 있으면 검색 강조 쿼리(`q` 등 — 빈 칸은 빼고)를 함께 실어 맞은 말의 위치 · `` 를 받는다(14단계 서버만 받는다). + /// 알림은 [silent] 로 끈다 — 처음 여는 메일은 화면이 받지 못함을 직접 그린다. 응답의 서버 시각(`Date`)도 함께 준다 + Future<({MessageDetail detail, DateTime? serverTime})> getMessage( + String id, { + bool showImages = false, + bool silent = true, + MessageHighlightQuery? highlight, + }) async { + final Map query = { + if (showImages) kMailImagesQuery: kMailImagesShow, + ...?highlight?.toQueryParameters(), + }; + final Response response = await _dio.get( + MailApiPaths.message(id), + queryParameters: query.isEmpty ? null : query, + options: silent ? ApiOptions.silent() : null, + ); + final MessageDetail? detail = MessageDetail.tryParse(response.data); + if (detail == null) throw _malformed(response); + return (detail: detail, serverTime: serverTimeOf(response)); + } + + /// 한 통 바꾸기 — 읽음 · 중요 · 옮기기 · 스팸 (읽기 화면이 쓴다). [silent] 면 실패를 알리지 않는다(연 메일의 읽음 처리) + Future updateMessage( + String id, + MessageChanges changes, { + bool silent = false, + }) async { + final Response response = await _dio.patch( + MailApiPaths.message(id), + data: changes.toJson(), + options: silent ? ApiOptions.silent() : null, + ); + final MessageSummary? summary = MessageSummary.tryParse(response.data); + if (summary == null) throw _malformed(response); + return summary; + } + + /// 여러 통 바꾸기 — [ids] 는 100개까지(나눠 보내는 일은 부르는 쪽이 한다) + Future updateMessages( + List ids, + MessageChanges changes, + ) async { + final Response response = await _dio.patch( + MailApiPaths.messages, + data: {'ids': ids, ...changes.toJson()}, + ); + final MessagesPatched? result = MessagesPatched.tryParse(response.data); + if (result == null) throw _malformed(response); + return result; + } + + /// 휴지통에서 영구 삭제 — 확인을 받은 뒤에만 부른다. [ids] 는 100개까지 + Future deleteMessages(List ids) async { + final Response response = await _dio.post( + MailApiPaths.messageDeletions, + data: {'ids': ids}, + ); + final MessageDeletions? result = MessageDeletions.tryParse(response.data); + if (result == null) throw _malformed(response); + return result; + } + + /// 새 메일 확인 — 상태 글(해석하지 않고 지난 값과 같은지만 본다). 실패는 알리지 않는다(다음 차례에 다시 묻는다) + Future mailState() async { + final Response response = await _dio.get( + MailApiPaths.mailState, + options: ApiOptions.silent(), + ); + final Object? data = response.data; + final Object? state = data is Map ? data['state'] : null; + if (state is! String) throw _malformed(response); + return state; + } + + /// 붙잡아 둔 발송을 취소한다(보내기 취소 · 예약 취소) — 메일은 보내지 않고 임시보관함으로 간다. 이어 쓸 초안 id(같은 메일 id)를 준다. + /// 이미 보냈으면 409 `MAIL_014`, 없으면 404 `RES_001` — 알림은 인터셉터가 띄운다 (계약 2절 "보내기 취소 · 예약 발송") + Future cancelPendingSend(String id) async { + final Response response = await _dio.delete( + MailApiPaths.scheduledMessage(id), + ); + final Object? data = response.data; + final Object? draftId = data is Map + ? data['draftId'] + : null; + if (draftId is! String || draftId.isEmpty) throw _malformed(response); + return draftId; + } + + /// 외부 이미지 하나 — 서버가 바깥에서 대신 받아 준 그림(바이트). 실패는 알리지 않는다(그 자리를 비운다). + /// [maxBytes] 를 넘게 오면 받기를 끊는다(취소 — `DioExceptionType.cancel`). [cancelToken] 을 그만두면(화면을 떠남 · 본문이 바뀜) 이 받기도 끊는다 — + /// 한도로 끊는 토큰은 그림마다 따로 둔다(한 그림의 한도가 다른 그림을 끊지 않게) + Future fetchMailImage( + String token, { + required int maxBytes, + CancelToken? cancelToken, + }) async { + final CancelToken cancel = CancelToken(); + if (cancelToken != null) { + if (cancelToken.isCancelled) cancel.cancel(); + unawaited( + cancelToken.whenCancel.then((DioException _) { + if (!cancel.isCancelled) cancel.cancel(); + }), + ); + } + final Response> response = await _dio.get>( + MailApiPaths.mailImage(token), + options: Options( + responseType: ResponseType.bytes, + extra: {kSilentExtraKey: true}, + ), + cancelToken: cancel, + onReceiveProgress: (int received, int total) { + if (received > maxBytes || total > maxBytes) cancel.cancel(); + }, + ); + final List? data = response.data; + if (data == null) { + throw apiFailure(response.requestOptions, kFallbackCode); + } + return data is Uint8List ? data : Uint8List.fromList(data); + } + + /// 첨부 하나를 [target] 에 받는다 — 조각마다 [onProgress](받은 바이트 · 전체 바이트 — 모르면 null). + /// + /// 실패는 늘 `DioException` 이다 — 서버가 거절했으면 인터셉터가 코드로 알렸고(실패 본문도 코드로 읽는다), + /// 받는 도중 끊기면 `SYS_001`(이때는 응답이 이미 지나가 인터셉터가 알리지 않는다 — [DownloadInterrupted]). + /// [cancelToken] 으로 그만두면 `DioExceptionType.cancel` 이다. 쓰다 만 [target] 은 부르는 쪽이 지운다 + Future downloadAttachment({ + required String messageId, + required String attachmentId, + required DownloadTarget target, + void Function(int received, int? total)? onProgress, + CancelToken? cancelToken, + }) async { + final Response response = await _dio.get( + MailApiPaths.attachment(messageId, attachmentId), + options: Options(responseType: ResponseType.stream), + cancelToken: cancelToken, + ); + final ResponseBody? body = response.data; + if (body == null) { + throw apiFailure(response.requestOptions, kFallbackCode); + } + final int? length = int.tryParse( + response.headers.value(Headers.contentLengthHeader) ?? '', + ); + final int? total = length != null && length > 0 ? length : null; + final RequestOptions request = response.requestOptions; + final Completer done = Completer(); + int received = 0; + final StreamSubscription subscription = body.stream.listen( + (Uint8List chunk) { + target.add(chunk); + received += chunk.length; + onProgress?.call(received, total); + }, + onError: (Object error, StackTrace stackTrace) { + if (done.isCompleted) return; + // 받는 도중의 실패(끊김 · 조각 사이 받기 한도 `receiveTimeout`)는 인터셉터를 지나지 않는다 — 그만둔 것만 그대로 둔다 + final bool cancelled = + error is DioException && error.type == DioExceptionType.cancel; + done.completeError( + cancelled ? error : DownloadInterrupted(request), + stackTrace, + ); + }, + onDone: () { + if (!done.isCompleted) done.complete(); + }, + cancelOnError: true, + ); + // 그만두면 다음 조각을 기다리지 않고 바로 끊는다 + unawaited( + cancelToken?.whenCancel.then((DioException _) async { + if (done.isCompleted) return; + done.completeError( + DioException.requestCancelled( + requestOptions: request, + reason: cancelToken.cancelError?.error, + ), + ); + await subscription.cancel(); + }), + ); + await done.future; + } + + DioException _malformed(Response response) => + apiFailure(response.requestOptions, kFallbackCode); +} + +/// 첨부를 받는 도중 끊겼다 — 응답은 이미 인터셉터를 지나서 아무도 알리지 않았다(부르는 쪽이 `SYS_001` 로 알린다) +class DownloadInterrupted extends DioException { + DownloadInterrupted(RequestOptions requestOptions) + : super( + requestOptions: requestOptions, + type: DioExceptionType.connectionError, + error: ApiException.fromCode(kFallbackCode), + ); +} diff --git a/flutter/lib/features/mail/domain/.gitkeep b/flutter/lib/features/mail/domain/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/flutter/lib/features/mail/domain/compose_attachments.dart b/flutter/lib/features/mail/domain/compose_attachments.dart new file mode 100644 index 0000000..8e5d830 --- /dev/null +++ b/flutter/lib/features/mail/domain/compose_attachments.dart @@ -0,0 +1,136 @@ +/// 첨부의 규칙 — 형식 표 · 한도 · 사전 검사 (순수 함수, 계약 2절 "첨부 업로드 한도"). +/// +/// 짝: 백엔드 `modules/mail/attachment.policy.ts` 의 `ATTACHMENT_TYPES`(확장자 → 정식 형식 — 시험이 그 파일과 맞춰 본다) · +/// `MAX_ATTACHMENTS_PER_MESSAGE`, 웹 `shared/utils/mail-attachment.ts`(`precheckAttachment` — 개수 → 형식 → 파일당 → 합계 차례) · +/// `shared/constants/mail.ts`(대체 한도). 한쪽을 고치면 둘 다. +library; + +import 'package:arasystem_app/core/files/picked_files.dart'; +import 'package:arasystem_app/features/mail/data/models/compose_models.dart'; + +/// 메일 한 통의 첨부 개수 한도 — 올린 파일과 전달하는 원본을 함께 센다 (백엔드 `MAX_ATTACHMENTS_PER_MESSAGE` · 웹 `MAIL_MAX_ATTACHMENTS`) +const int kMailMaxAttachments = 20; + +/// 파일당 크기 대체값 — 서버 기본값과 같다(`GET /api/mail-quota` 를 받지 못했을 때 — 웹 `MAIL_ATTACHMENT_FALLBACK_MAX_BYTES`) +const int kMailAttachmentMaxBytes = 20 * 1024 * 1024; + +/// 첨부 합계 대체값 — 서버 기본값과 같다(웹 `MAIL_ATTACHMENT_TOTAL_FALLBACK_MAX_BYTES`) +const int kMailAttachmentTotalMaxBytes = 25 * 1024 * 1024; + +/// 한도를 받지 못했을 때 쓰는 값 +const AttachmentLimits kFallbackAttachmentLimits = AttachmentLimits( + maxBytes: kMailAttachmentMaxBytes, + totalMaxBytes: kMailAttachmentTotalMaxBytes, +); + +/// 허용 형식 — 확장자(소문자, 점 없이) → 올릴 때 보내는 정식 형식. +/// +/// 서버는 올린 파일의 확장자와 형식을 **둘 다** 본다(multer `isAllowedAttachment` — 그 확장자가 받는 형식이어야 한다). +/// 안드로이드가 알려 주는 형식은 기기 · 앱마다 달라(hwp 가 octet-stream 이거나 한컴 형식) 쓰지 않고, 확장자의 정식 형식을 보낸다. +/// **짝: 백엔드 `ATTACHMENT_TYPES` 의 `type`** (`compose_attachments_test.dart` 가 그 파일을 읽어 맞춰 본다) +const Map kMailAttachmentTypes = { + 'pdf': 'application/pdf', + 'doc': 'application/msword', + 'docx': + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'xls': 'application/vnd.ms-excel', + 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'ppt': 'application/vnd.ms-powerpoint', + 'pptx': + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'hwp': 'application/x-hwp', + 'hwpx': 'application/hwp+zip', + 'txt': 'text/plain', + 'csv': 'text/csv', + 'png': 'image/png', + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'gif': 'image/gif', + 'webp': 'image/webp', + 'zip': 'application/zip', +}; + +/// 확장자 — 소문자, 없으면 빈 글 (웹 `extensionOf` 와 같다 — 마지막 점 뒤). +/// **짝: `MainActivity.kt` 의 `extensionOf`** — 고를 때 기기가 같은 규칙으로 형식을 미리 거른다(보이는 이름 `displayFileName` 도 짝) +String extensionOf(String name) { + final int dot = name.lastIndexOf('.'); + return dot == -1 ? '' : name.substring(dot + 1).toLowerCase(); +} + +/// 올릴 때 보내는 형식 — 허용 목록에 없으면(HEIC 등) null +String? attachmentUploadTypeOf(String name) => + kMailAttachmentTypes[extensionOf(name)]; + +/// 그림 첨부인가 — 줄 아이콘(보드: 그림은 그림 아이콘, 그 밖은 문서 아이콘) +bool isImageAttachment(String name) => + attachmentUploadTypeOf(name)?.startsWith('image/') ?? false; + +/// 이미 붙은 첨부(전달하는 원본 · 저장본의 첨부)를 보낼 수 있는가 — 개수(`MAIL_006`) → 형식(`FILE_001`) → 합계(`FILE_002`) 차례(서버와 같다). +/// 전달하는 원본 첨부는 파일당 한도를 보지 않는다(서버도 확장자와 합계만 본다). 걸리면 그 코드, 아니면 null +String? precheckCarriedAttachment({ + required String name, + required int size, + required int attachedCount, + required int attachedBytes, + int totalMaxBytes = kMailAttachmentTotalMaxBytes, +}) { + if (attachedCount + 1 > kMailMaxAttachments) return 'MAIL_006'; + if (attachmentUploadTypeOf(name) == null) return 'FILE_001'; + if (attachedBytes + size > totalMaxBytes) return 'FILE_002'; + return null; +} + +/// 고른 파일을 넣지 못한 까닭 +enum AttachmentRejection { + /// 개수 한도(`MAIL_006`) — 복사본 수 한도로 가져오지 않은 파일도 + tooMany, + + /// 허용하지 않는 형식(`FILE_001`) — HEIC 도 + notAllowed, + + /// 파일당 한도보다 크다 — 앱은 아직 대용량 첨부(링크)로 보내지 못한다 + tooLarge, + + /// 고른 파일을 읽지 못했다 + unreadable, + + /// 합계 한도를 넘게 만든다 + overTotal, +} + +/// 고른 파일을 붙이기 전 사전 검사 — 걸리면 붙이지 않는다. 웹 `precheckAttachment` 와 같은 차례: +/// 1) 개수(서버도 형식보다 먼저 본다 — 허용 밖 형식이어도 한도를 넘으면 개수) 2) 형식 3) 파일당 크기 4) (앱에서 더함) 읽지 못함 5) 합계 크기. +/// [attachedCount] · [attachedBytes] 는 이미 붙은 것(올리는 중 · 올림 · 전달 원본) — 앞에서 넣은 파일이 늘린다. +/// +/// 기기(`MainActivity.kt` 의 `copyPicked`)가 같은 차례로 미리 걸러 넘긴 까닭(`skipped` · `notAllowed` · `tooLarge` · `overTotal`)은 +/// 그 차례의 까닭으로 옮기고, 다 복사한 파일(`ok`)은 여기서 다시 판정한다(기기는 미리 거르기만 한다) +AttachmentRejection? precheckPickedFile( + PickedFile file, { + required int attachedCount, + required int attachedBytes, + required AttachmentLimits limits, +}) { + if (file.status == PickedFileStatus.skipped || + attachedCount + 1 > kMailMaxAttachments) { + return AttachmentRejection.tooMany; + } + if (file.status == PickedFileStatus.notAllowed || + attachmentUploadTypeOf(file.name) == null) { + return AttachmentRejection.notAllowed; + } + final int? size = file.size; + if (file.status == PickedFileStatus.tooLarge || + (size != null && size > limits.maxBytes)) { + return AttachmentRejection.tooLarge; + } + if (file.status == PickedFileStatus.overTotal) { + return AttachmentRejection.overTotal; + } + if (file.status != PickedFileStatus.ok || size == null) { + return AttachmentRejection.unreadable; + } + if (attachedBytes + size > limits.totalMaxBytes) { + return AttachmentRejection.overTotal; + } + return null; +} diff --git a/flutter/lib/features/mail/domain/compose_body.dart b/flutter/lib/features/mail/domain/compose_body.dart new file mode 100644 index 0000000..e827590 --- /dev/null +++ b/flutter/lib/features/mail/domain/compose_body.dart @@ -0,0 +1,362 @@ +/// 앱이 만드는 보내는 본문 — 서식 본문(`bodyHtml`)과 같은 내용의 텍스트 대체 본문(`body`) (순수 함수 · 계약 2절 "서식 본문"). +/// +/// **짝: 웹 `frontend/src/components/mail/editor/mail-serialize.ts`** — [MailHtmlStyles] 는 `MAIL_HTML_STYLES` 와 글자까지 같고, +/// 감싸는 div 의 글꼴 · 크기 · 줄 간격은 `mail-editor.ts` 의 `MAIL_BODY_FONT_FAMILY` · `_SIZE_PX` · `_LINE_HEIGHT`, 공백은 `preserveSpaces` 와 같은 nbsp 바꾸기, +/// 텍스트는 `docToText` 와 같은 꼴(원문 인용 줄 "> " · 머리 줄 "항목: 값" · 머리 뒤 빈 줄 · 끝의 빈 줄 빼기)이다. 한쪽을 고치면 둘 다. +/// +/// 앱이 만드는 서식 본문은 고정 모양 셋뿐이다(서식 편집은 다음 판): +/// ```html +///

+///

내 글 한 줄

… ← 빈 줄은


+///


서명
← 서명 경계 div (속성 없음 — 서버 정화가 data-* 를 지운다) +///
← 원본 메일 — 웹이 다시 열 때 isMailQuoteBlock 으로 알아본다 +///

원본 메일

머리
+///

원문 한 줄

…(원문 속 인용은 안쪽 blockquote) +///
+///
+/// ``` +library; + +import 'package:arasystem_app/features/mail/domain/mail_html.dart'; +import 'package:arasystem_app/features/mail/domain/mail_quote.dart'; +import 'package:flutter/foundation.dart'; + +/// 본문 기본 글꼴 — 맑은 고딕 스택 (웹 `MAIL_BODY_FONT_FAMILY`) +const String kMailBodyFontFamily = + "'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif"; + +/// 감싸는 div 의 모양 — 글꼴 · 14px · 줄 간격 1.75 (웹 `toMailHtml` 과 같은 글) +const String kMailBodyWrapperStyle = + 'font-family: $kMailBodyFontFamily; font-size: 14px; line-height: 1.75'; + +/// 받는 쪽에서도 편집기와 같은 모양이 되도록 넣는 인라인 style — 웹 `MAIL_HTML_STYLES` 와 같은 글(앱이 쓰는 것만 옮겼다 — 목록 · 표 · 가로줄은 앱이 만들지 않는다) +abstract final class MailHtmlStyles { + static const String _quoteBar = + 'padding: 2px 0 2px 14px; border-left: 2px solid #D0D5DD; color: #4A5363'; + + static const String paragraph = 'margin: 0'; + + /// 원문 속 인용 + static const String quote = 'margin: 0; $_quoteBar'; + + /// 원문 인용 — 내 글과 24 띄운다 + static const String mailQuote = 'margin: 24px 0 0; $_quoteBar'; + + /// "원본 메일" — 문단 여백([paragraph])이 앞에 붙는다(웹 `applyMailStyles` 가 문단마다 앞에 넣는 차례와 같다) + static const String quoteHeading = 'font-size: 12.5px'; + static const String quoteHeaderTable = + 'margin: 0 0 12px; border-collapse: collapse; font-size: 12.5px; line-height: 1.6'; + static const String quoteHeaderLabel = + 'padding: 0 12px 0 0; color: #667085; vertical-align: top; white-space: nowrap'; + static const String quoteHeaderValue = 'padding: 0; vertical-align: top'; +} + +/// 대용량 첨부의 인용 경계 표시 — 서버가 첫 표시 자리에 덩이를 넣고 표시는 지운다(계약 4절 "발송 · 임시 저장과 함께") +const String kQuoteStartMarker = '
'; + +/// 텍스트 본문의 인용 줄 머리 (웹 `MAIL_QUOTE_LINE_PREFIX`) +const String kQuoteLinePrefix = '> '; + +/// 인용 머리 표의 "항목: 값" (웹 `HEADER_SEPARATOR`) +const String _headerSeparator = ': '; + +const String _nbsp = '\u00A0'; + +/// 탭이 닿는 칸 — 웹 편집 영역의 탭 간격(8)과 같다 +const int kComposeTabStop = 8; + +/// 여러 칸 띄어쓰기 · 줄 앞 공백(탭 포함)이 받는 쪽에서도 보이게 nbsp 로 바꾼다 — 웹 `preserveSpaces` 와 같다. +/// 줄 앞 공백과 공백 바로 뒤의 공백을 nbsp 로(보통 공백과 번갈아 — 긴 공백에서도 줄이 바뀐다), 탭은 다음 8칸 자리까지의 공백으로. +/// 단어 사이 공백 한 칸은 그대로다. 텍스트 대체 본문(`body`)에는 쓰지 않는다 +String preserveSpaces(String line) { + final StringBuffer out = StringBuffer(); + bool atLineStart = true; + bool afterSpace = false; + int column = 0; + void space() { + final bool keep = atLineStart || afterSpace; + out.write(keep ? _nbsp : ' '); + afterSpace = !keep; + atLineStart = false; + column++; + } + + for (final int rune in line.runes) { + if (rune == 0x20) { + space(); + } else if (rune == 0x09) { + final int width = kComposeTabStop - (column % kComposeTabStop); + for (int step = 0; step < width; step++) { + space(); + } + } else { + out.writeCharCode(rune); + atLineStart = false; + afterSpace = false; + column++; + } + } + return out.toString(); +} + +/// 쓰기 화면의 원문 인용 — 머리 · 덩이 · 생략 표시 +@immutable +class ComposeQuote { + const ComposeQuote({ + required this.header, + required this.blocks, + required this.summary, + this.omitted = false, + this.rawHtml, + }); + + final List header; + final List blocks; + + /// 접힌 줄의 둘째 줄 — "이도윤 · 11월 5일 (목) 오전 10:42" + final String summary; + + /// 끝에 생략 표시를 둔다 — 길어서 줄였거나 서버가 읽기 한도에서 잘라 보냈다 + final bool omitted; + + /// 그대로 실을 인용 HTML — 웹 · 다른 프로그램이 만든 저장본의 인용(서식을 지킨다). null 이면 [blocks] 로 다시 쓴다 + final String? rawHtml; + + /// 덩이를 바꾼 인용 — 원래 HTML 은 버린다(덩이로 다시 쓴다) + ComposeQuote withBlocks(List next, {required bool omitted}) => + ComposeQuote( + header: header, + blocks: next, + summary: summary, + omitted: omitted, + ); + + @override + bool operator ==(Object other) => + other is ComposeQuote && + listEquals(other.header, header) && + listEquals(other.blocks, blocks) && + other.summary == summary && + other.omitted == omitted && + other.rawHtml == rawHtml; + + @override + int get hashCode => Object.hash( + Object.hashAll(header), + Object.hashAll(blocks), + summary, + omitted, + rawHtml, + ); +} + +/// 보낼 본문의 재료 — 내 글 · 서명 · 원문 인용 +@immutable +class ComposeBodyInput { + const ComposeBodyInput({ + required this.text, + this.signatureHtml, + this.signatureText, + this.quote, + this.quoteStartMark = false, + }); + + /// 내 글 — 칸에 쓴 그대로(줄바꿈은 \n) + final String text; + + /// 서명 경계 div 안에 둘 서명 HTML — null 이면 서명 없음 + final String? signatureHtml; + + /// 서명의 텍스트 — 텍스트 대체 본문에 넣는다 + final String? signatureText; + final ComposeQuote? quote; + + /// 원문 인용 바로 앞에 인용 경계 표시를 둔다 — 대용량 파일이 있을 때(서버가 그 자리에 덩이를 넣는다) + final bool quoteStartMark; + + ComposeBodyInput withQuote(ComposeQuote? next) => ComposeBodyInput( + text: text, + signatureHtml: signatureHtml, + signatureText: signatureText, + quote: next, + quoteStartMark: quoteStartMark, + ); +} + +/// 보낼 본문 — [html] 이 null 이면 텍스트 메일(쓴 글이 없을 때) +@immutable +class MailBodyOutput { + const MailBodyOutput({ + required this.html, + required this.text, + this.quoteStart, + }); + + final String? html; + final String text; + + /// 텍스트 본문에서 원문 인용이 시작하는 자리(JS 문자열 위치 — 0 이거나 바로 앞이 줄바꿈). 인용이 없으면 null + final int? quoteStart; +} + +/// 쓴 글의 줄 — \r\n · \r 은 \n 으로 +List composeLinesOf(String text) => + text.replaceAll('\r\n', '\n').replaceAll('\r', '\n').split('\n'); + +/// 보낼 본문을 만든다 — 모양은 위 머리말. 인용이 [ComposeQuote.rawHtml] 을 가지면 그것을 그대로 싣는다 +MailBodyOutput buildMailBody(ComposeBodyInput input) { + final List lines = composeLinesOf(input.text); + final String? signatureHtml = input.signatureHtml; + final ComposeQuote? quote = input.quote; + final bool tail = signatureHtml != null || quote != null; + // 뒤에 아무것도 없으면 끝의 빈 줄은 보내지 않는다(웹 trimTrailingEmptyParagraphs) + final List htmlLines = tail ? lines : _withoutTrailingEmpty(lines); + final String? html = !tail && htmlLines.isEmpty + ? null + : _bodyHtml(htmlLines, signatureHtml, quote, input.quoteStartMark); + + final List textLines = [...lines]; + if (signatureHtml != null) { + textLines.add(''); + final String signatureText = input.signatureText ?? ''; + if (signatureText.isNotEmpty) { + textLines.addAll(composeLinesOf(signatureText)); + } + } + int? quoteLine; + if (quote != null) { + quoteLine = textLines.length; + textLines.addAll(quoteTextLines(quote)); + } + final String text = textLines.join('\n').trimRight(); + int? quoteStart; + if (quoteLine != null) { + int start = 0; + for (int index = 0; index < quoteLine; index++) { + start += textLines[index].length + 1; + } + quoteStart = start <= text.length ? start : null; + } + return MailBodyOutput(html: html, text: text, quoteStart: quoteStart); +} + +List _withoutTrailingEmpty(List lines) { + int end = lines.length; + while (end > 0 && lines[end - 1].trim().isEmpty) { + end--; + } + return lines.sublist(0, end); +} + +String _bodyHtml( + List lines, + String? signatureHtml, + ComposeQuote? quote, + bool quoteStartMark, +) { + final StringBuffer out = StringBuffer('
'); + for (final String line in lines) { + out.write(paragraphHtml(line)); + } + if (signatureHtml != null) { + out + ..write(paragraphHtml('')) + ..write(signatureBlockHtml(signatureHtml)); + } + if (quote != null) { + if (quoteStartMark) out.write(kQuoteStartMarker); + out.write(quote.rawHtml ?? quoteHtml(quote)); + } + out.write('
'); + return out.toString(); +} + +/// 문단 하나 — 빈 줄은 `
` 로 한 줄 높이를 지킨다 +String paragraphHtml(String line) => + '

${_lineHtml(line)}

'; + +String _lineHtml(String line) => + line.isEmpty ? '
' : escapeHtmlText(preserveSpaces(line)); + +/// 서명 경계 div — 속성 없는 `
` 하나(계약 2절 "앱이 만드는 모양") +String signatureBlockHtml(String signatureHtml) => '
$signatureHtml
'; + +/// 원문 인용 HTML — "원본 메일" 머리 문단 + 머리 표 + 덩이 + (생략 표시) +String quoteHtml(ComposeQuote quote) { + final StringBuffer out = StringBuffer() + ..write('
') + ..write(quoteShellHeadHtml(quote.header)); + for (final QuoteBlock block in quote.blocks) { + out.write(quoteBlockHtml(block)); + } + if (quote.omitted) out.write(paragraphHtml(kQuoteOmittedMark)); + out.write('
'); + return out.toString(); +} + +/// 인용 머리 — "원본 메일" 문단과 머리 표 +String quoteShellHeadHtml(List header) { + final StringBuffer out = StringBuffer() + ..write( + '

', + ) + ..write(escapeHtmlText(kQuoteHeading)) + ..write('

') + ..write(''); + for (final QuoteHeaderRow row in header) { + out + ..write(''); + } + out.write('
') + ..write(escapeHtmlText(preserveSpaces(row.label))) + ..write('') + ..write(escapeHtmlText(preserveSpaces(row.value))) + ..write('
'); + return out.toString(); +} + +/// 덩이 하나의 HTML — 글 줄은 문단, 원문 속 인용은 안쪽 blockquote +String quoteBlockHtml(QuoteBlock block) { + final StringBuffer out = StringBuffer(); + void write(QuoteBlock item) { + switch (item) { + case QuoteLine(:final String text): + out.write(paragraphHtml(text)); + case QuoteNest(:final List children): + out.write('
'); + children.forEach(write); + out.write('
'); + } + } + + write(block); + return out.toString(); +} + +/// 원문 인용의 텍스트 줄 — "> 원본 메일" · "> 항목: 값" · ">" · 원문 줄 · (생략 표시). 웹 `docToText` 의 인용과 같은 꼴 +List quoteTextLines(ComposeQuote quote) => [ + quotePrefixed(kQuoteHeading, 1), + for (final QuoteHeaderRow row in quote.header) + quotePrefixed('${row.label}$_headerSeparator${row.value}', 1), + quotePrefixed('', 1), + for (final QuoteBlock block in quote.blocks) ...quoteBlockTextLines(block, 1), + if (quote.omitted) quotePrefixed(kQuoteOmittedMark, 1), +]; + +/// 덩이 하나의 텍스트 줄 — [depth] 단의 인용 머리를 붙인다 +List quoteBlockTextLines(QuoteBlock block, int depth) { + final List lines = []; + void visit(QuoteBlock item, int level) { + switch (item) { + case QuoteLine(:final String text): + lines.add(quotePrefixed(text, level)); + case QuoteNest(:final List children): + for (final QuoteBlock child in children) { + visit(child, level + 1); + } + } + } + + visit(block, depth); + return lines; +} diff --git a/flutter/lib/features/mail/domain/compose_draft_body.dart b/flutter/lib/features/mail/domain/compose_draft_body.dart new file mode 100644 index 0000000..c32e8ad --- /dev/null +++ b/flutter/lib/features/mail/domain/compose_draft_body.dart @@ -0,0 +1,422 @@ +/// 임시 저장본 · 보내기 취소로 되돌린 메일을 다시 열 때의 본문 — 순수 함수 (계약 2절 "임시 저장"). +/// +/// - **앱이 만든 모양**(`compose_body.dart` 의 머리말): 감싸는 div(앱의 글꼴 style — 없어도 된다) 아래가 +/// "글 · `
` 만 든 문단들 → div 하나(서명, 속성 없음 · 선택) → 원본 메일 인용(선택)"이면 내 글 칸 · 서명 칸 · 인용으로 되살린다. +/// 문단은 속성이 없거나 `style` 이 `margin:0` 하나뿐이어야 한다(서버의 보내는 정화가 띄어쓰기를 바꾸므로 공백을 빼고 견준다 — +/// 웹 저장본의 정렬 · 들여쓰기가 말없이 사라지지 않게). 서명 앞의 빈 문단 하나는 앱이 넣은 띄움이라 뺀다. nbsp 는 공백으로 되돌린다. +/// - **그 밖**(웹 · 다른 메일 프로그램이 만든 저장본): 원문 인용 앞부분을 글로 바꿔 본문 칸에 넣는다 — 서명은 그 글의 일부가 되고 [서명 빼기]는 없다. +/// 원래 HTML · 텍스트는 쥐고 있다가 본문을 고치지 않으면 그대로 다시 보낸다. 서식(글자 꾸밈 · 링크 · 목록 · 표 · 그림 · 문단의 정렬 등)이 있었으면 +/// 열 때 안내 줄을 보인다. 원문 인용은 HTML 그대로 실어 보내고(서식을 지킨다), 화면에는 글로 보인다. +/// - 원문 인용은 웹과 같은 규칙으로 알아본다(`mail-extensions.ts` 의 `isMailQuoteBlock`): 맨 위 단계의 blockquote 이고 첫 자식이 "원본 메일" 문단, 바로 뒤가 표. +/// - **읽는 곳**([buildDraftBody]): 읽기 화면과 같은 한도 · 문턱이다 — 길이 · 중첩 비용 한도를 넘으면 읽지 않고 남의 저장본으로 +/// ([oversizedDraftBody]), 길거나 깊으면 다른 isolate 에서, 짧고 얕으면 그 자리에서. 되풀이하는 `package:html` 의 `text` · `innerHtml` 등은 +/// 쓰지 않는다(`html_nodes.dart`). +library; + +import 'dart:isolate'; + +import 'package:arasystem_app/features/mail/domain/compose_body.dart'; +import 'package:arasystem_app/features/mail/domain/compose_signature.dart'; +import 'package:arasystem_app/features/mail/domain/html_nodes.dart'; +import 'package:arasystem_app/features/mail/domain/mail_html.dart'; +import 'package:arasystem_app/features/mail/domain/mail_quote.dart'; +import 'package:flutter/foundation.dart'; +import 'package:html/dom.dart' as dom; +import 'package:html/parser.dart' as html_parser; + +/// 다시 연 본문 +sealed class DraftBody { + const DraftBody(); +} + +/// 앱이 만든 모양 — 칸마다 되살린다 +@immutable +final class AppDraftBody extends DraftBody { + const AppDraftBody({required this.text, this.signature, this.quote}); + + final String text; + + /// 서명 경계 div 안의 서명 — 없으면 서명 없음 + final ComposeSignature? signature; + final ComposeQuote? quote; +} + +/// 웹 · 다른 프로그램이 만든 모양 — 인용 앞부분을 글로 +@immutable +final class ForeignDraftBody extends DraftBody { + const ForeignDraftBody({ + required this.text, + required this.originalText, + required this.hasFormatting, + this.originalHtml, + this.markedHtml, + this.quote, + }); + + /// 본문 칸에 넣을 글 — 인용 앞부분(서명 포함) + final String text; + + /// 원래 서식 본문 — 본문을 고치지 않으면 그대로 보낸다 + final String? originalHtml; + + /// [originalHtml] 의 원문 인용 바로 앞에 인용 경계 표시를 넣은 것 — 대용량 파일이 있을 때 그대로 보낸다(읽을 때 한 번만 만든다). + /// 원문 인용이 없거나 읽지 않은 저장본이면 null + final String? markedHtml; + + /// 원래 텍스트 본문 + final String originalText; + + /// 서식(글자 꾸밈 · 링크 · 목록 · 표 · 그림 · 문단의 정렬 등)이 있었다 — 안내 줄을 보인다 + final bool hasFormatting; + + /// 원문 인용 — [ComposeQuote.rawHtml] 에 원래 HTML + final ComposeQuote? quote; +} + +/// 저장본 본문을 읽을 곳 — 읽기 화면과 같은 한도 · 문턱(`mail_html.dart`) +enum DraftBodyPlace { + /// 그 자리 — 짧고 얕다(서식 본문 6KB 미만 · 중첩 비용 5,000 이하, 또는 서식 본문이 없다) + inline, + + /// 다른 isolate 에서 — 길거나 깊다 + isolate, + + /// 읽지 않는다 — 길이(2,000,000자) · 중첩 비용(8,000,000) 한도를 넘었다([oversizedDraftBody]) + oversized, +} + +/// 읽을 곳 — 비용은 한도를 넘는 순간 멈춰 세므로 고르는 일 자체는 짧다 +DraftBodyPlace draftBodyPlaceOf({ + required String? bodyHtml, + required String body, +}) { + final String? html = displayedHtmlBody(bodyHtml)?.trim(); + if (html == null) return DraftBodyPlace.inline; + if (html.length > kMaxSanitizedHtmlLength || + htmlNestingCost(html, limit: kMaxHtmlNestingCost) > kMaxHtmlNestingCost) { + return DraftBodyPlace.oversized; + } + return mailBodyBuildPlaceOf(htmlBody: html, textBody: body) == + MailBodyBuildPlace.inline + ? DraftBodyPlace.inline + : DraftBodyPlace.isolate; +} + +/// 저장본 본문을 알맞은 곳에서 읽는다 — 위 [draftBodyPlaceOf] +Future buildDraftBody({ + required String? bodyHtml, + required String body, +}) async { + switch (draftBodyPlaceOf(bodyHtml: bodyHtml, body: body)) { + case DraftBodyPlace.inline: + return draftBodyOf(bodyHtml: bodyHtml, body: body); + case DraftBodyPlace.isolate: + return Isolate.run(() => draftBodyOf(bodyHtml: bodyHtml, body: body)); + case DraftBodyPlace.oversized: + return oversizedDraftBody(html: bodyHtml!.trim(), body: body); + } +} + +/// 한도를 넘는 저장본 — 읽지 않는다: 본문 칸은 텍스트 본문, 원래 HTML 은 쥐고 있다가 고치지 않으면 그대로 다시 보낸다(서식이 있다고 보고 안내한다) +ForeignDraftBody oversizedDraftBody({ + required String html, + required String body, +}) => ForeignDraftBody( + text: body.trimRight(), + originalHtml: html, + originalText: body, + hasFormatting: true, +); + +/// 저장본의 본문을 읽는다 — 서식 본문이 없으면 텍스트 그대로 본문 칸에(서식이 없어 잃을 것이 없다). 한도는 부르는 쪽이 본다([buildDraftBody]) +DraftBody draftBodyOf({required String? bodyHtml, required String body}) { + final String? html = displayedHtmlBody(bodyHtml)?.trim(); + if (html == null) return AppDraftBody(text: body.trimRight()); + final dom.Element? root = html_parser.parse(html).body; + if (root == null) return AppDraftBody(text: body.trimRight()); + final ({List nodes, dom.Element? wrapper}) content = _contentOf( + root, + ); + final bool appWrapper = + content.wrapper == null || _isAppWrapper(content.wrapper!); + if (appWrapper) { + final AppDraftBody? app = _appBodyOf(content.nodes); + if (app != null) return app; + } + return _foreignBodyOf( + root, + content.nodes, + originalHtml: html, + originalText: body, + formattedWrapper: !appWrapper, + ); +} + +/// 감싸는 div 하나면 그 안과 그 div, 아니면 body 의 자식 +({List nodes, dom.Element? wrapper}) _contentOf(dom.Element root) { + final List meaningful = _meaningful(root.nodes); + if (meaningful.length == 1) { + final dom.Node only = meaningful.single; + if (only is dom.Element && only.localName == 'div') { + return (nodes: _meaningful(only.nodes), wrapper: only); + } + } + return (nodes: meaningful, wrapper: null); +} + +/// 공백뿐인 글 · 주석은 뺀다 +List _meaningful(List nodes) => [ + for (final dom.Node node in nodes) + if (node is dom.Element || + (node is dom.Text && node.data.trim().isNotEmpty)) + node, +]; + +/// 공백을 뺀 style 글 — 끝의 `;` 도 뺀다(서버 정화는 `margin:0` · 앱은 `margin: 0`) +String _compactStyle(String style) { + final String compact = style.replaceAll(RegExp(r'\s+'), ''); + return compact.endsWith(';') + ? compact.substring(0, compact.length - 1) + : compact; +} + +final String _appWrapperStyle = _compactStyle(kMailBodyWrapperStyle); +final String _appParagraphStyle = _compactStyle(MailHtmlStyles.paragraph); + +/// 감싸는 div 가 앱의 것인가 — 속성이 없거나 style 이 앱의 글꼴 · 크기 · 줄 간격과 같다 +bool _isAppWrapper(dom.Element wrapper) { + final Map attributes = wrapper.attributes; + if (attributes.isEmpty) return true; + final String? style = attributes['style']; + return attributes.length == 1 && + style != null && + _compactStyle(style) == _appWrapperStyle; +} + +/// 꾸밈 없는 문단 · 감싸개인가 — 문단은 속성이 없거나 `style` 이 `margin:0` 하나, div 는 속성이 없을 때, `
` 은 늘 +bool _isPlainBlock(dom.Element element) { + final Map attributes = element.attributes; + switch (element.localName) { + case 'br': + return true; + case 'div': + return attributes.isEmpty; + case 'p': + if (attributes.isEmpty) return true; + final String? style = attributes['style']; + return attributes.length == 1 && + style != null && + _compactStyle(style) == _appParagraphStyle; + } + return false; +} + +/// 앱이 만든 모양이면 칸마다 — 아니면 null +AppDraftBody? _appBodyOf(List nodes) { + final List lines = []; + int index = 0; + while (index < nodes.length) { + final dom.Node node = nodes[index]; + if (node is! dom.Element || node.localName != 'p') break; + if (!_isPlainBlock(node)) return null; + final List? paragraph = _plainParagraphLines(node); + if (paragraph == null) return null; + lines.addAll(paragraph); + index++; + } + ComposeSignature? signature; + if (index < nodes.length) { + final dom.Node node = nodes[index]; + if (node is dom.Element && node.localName == 'div' && !_isEmptyDiv(node)) { + // 앱의 서명 경계는 속성 없는 div 다 — 꾸민 div 는 앱 모양이 아니다 + if (node.attributes.isNotEmpty) return null; + signature = composeSignatureFromHtml(innerHtmlOf(node)); + // 서명 앞의 빈 문단 하나는 앱이 넣은 띄움이다 + if (lines.isNotEmpty && lines.last.isEmpty) lines.removeLast(); + index++; + } + } + // 인용 경계 표시(서버가 지운다 — 남았으면 빈 div)는 건너뛴다 + while (index < nodes.length && _isEmptyDiv(nodes[index])) { + index++; + } + ComposeQuote? quote; + if (index < nodes.length) { + final dom.Node node = nodes[index]; + if (node is! dom.Element || !isMailQuoteBlock(node)) return null; + // 원래 HTML 을 쥔다 — 웹이 만든 저장본이 이 모양이어도 인용의 서식을 잃지 않게(줄여야 할 때만 덩이로 다시 쓴다) + quote = composeQuoteOf(node, keepHtml: true); + index++; + } + if (index < nodes.length) return null; + return AppDraftBody( + text: lines.join('\n'), + signature: signature, + quote: quote, + ); +} + +bool _isEmptyDiv(dom.Node node) => + node is dom.Element && + node.localName == 'div' && + _meaningful(node.nodes).isEmpty; + +/// 글 · `
` 만 든 문단의 줄 — 다른 태그가 있으면 null. 끝의 `
` 은 줄을 더하지 않는다(`


` 는 빈 줄 하나) +List? _plainParagraphLines(dom.Element paragraph) { + final StringBuffer current = StringBuffer(); + final List lines = []; + for (final dom.Node child in paragraph.nodes) { + if (child is dom.Text) { + current.write(child.data.replaceAll('\u00A0', ' ')); + } else if (child is dom.Element && child.localName == 'br') { + lines.add(current.toString()); + current.clear(); + } else if (child is dom.Element || child is! dom.Comment) { + return null; + } + } + final String rest = current.toString(); + if (rest.isNotEmpty || lines.isEmpty) lines.add(rest); + return lines; +} + +/// 웹 · 다른 프로그램의 모양 — 맨 위 단계의 첫 원문 인용을 찾고, 그 앞을 글로. 인용 뒤에 다른 것이 있으면 인용으로 보지 않는다(모두 글로). +/// 원문 인용이 있으면 그 앞에 인용 경계 표시를 넣은 HTML 도 이때 한 번 만든다([ForeignDraftBody.markedHtml]) +ForeignDraftBody _foreignBodyOf( + dom.Element root, + List nodes, { + required String originalHtml, + required String originalText, + required bool formattedWrapper, +}) { + int quoteIndex = -1; + for (int index = 0; index < nodes.length; index++) { + final dom.Node node = nodes[index]; + if (node is dom.Element && isMailQuoteBlock(node)) { + quoteIndex = index; + break; + } + } + if (quoteIndex >= 0 && quoteIndex != nodes.length - 1) quoteIndex = -1; + final List before = quoteIndex < 0 + ? nodes + : nodes.sublist(0, quoteIndex); + final List blocks = quoteBlocksFromNodes(before); + final ComposeQuote? quote = quoteIndex < 0 + ? null + : composeQuoteOf(nodes[quoteIndex] as dom.Element, keepHtml: true); + String? markedHtml; + if (quoteIndex >= 0) { + // 다른 것을 다 읽은 뒤에 표시를 넣는다(문서를 바꾸므로) + final dom.Node blockquote = nodes[quoteIndex]; + blockquote.parentNode?.insertBefore( + dom.Element.tag('div')..attributes['data-ara-quote-start'] = '', + blockquote, + ); + markedHtml = innerHtmlOf(root); + } + return ForeignDraftBody( + text: plainLinesOf(blocks).join('\n'), + originalHtml: originalHtml, + markedHtml: markedHtml, + originalText: originalText, + hasFormatting: formattedWrapper || _hasFormatting(before), + quote: quote, + ); +} + +/// 서식이 있는가 — 꾸밈 없는 문단 · 감싸개 · 줄바꿈([_isPlainBlock])이 아닌 요소(글자 꾸밈 · 링크 · 목록 · 인용 · 표 · 그림 · 정렬한 문단 등) +bool _hasFormatting(List nodes) { + final List stack = [...nodes]; + while (stack.isNotEmpty) { + final dom.Node node = stack.removeLast(); + if (node is! dom.Element) continue; + if (!_isPlainBlock(node)) return true; + stack.addAll(node.nodes); + } + return false; +} + +/// 웹과 같은 원문 인용 판정 — 첫 자식이 "원본 메일" 문단이고 바로 뒤가 표(맨 위 단계에서만 부른다) +bool isMailQuoteBlock(dom.Element element) { + if (element.localName != 'blockquote') return false; + final List children = element.children; + if (children.length < 2) return false; + final dom.Element first = children[0]; + return first.localName == 'p' && + nodeTextOf(first).trim() == kQuoteHeading && + children[1].localName == 'table'; +} + +/// 원문 인용 요소 → 쓰기 화면의 인용 — 머리 표의 칸 · 그 뒤의 덩이. [keepHtml] 이면 원래 HTML 을 쥔다(그대로 다시 싣는다) +ComposeQuote composeQuoteOf(dom.Element blockquote, {required bool keepHtml}) { + final List children = blockquote.children; + final dom.Element table = children[1]; + final List header = [ + for (final dom.Element row in descendantElements(table, 'tr')) + if (_cellsOf(row) case [final String label, final String value, ...]) + QuoteHeaderRow(label, value), + ]; + // 머리 표 뒤의 것 — 문단 · 안쪽 인용 + final int tableIndex = blockquote.nodes.indexOf(table); + final List blocks = quoteBlocksFromNodes( + blockquote.nodes.sublist(tableIndex + 1), + ); + bool omitted = false; + if (blocks.isNotEmpty && + blocks.last is QuoteLine && + (blocks.last as QuoteLine).text.trim() == kQuoteOmittedMark) { + blocks.removeLast(); + omitted = true; + } + return ComposeQuote( + header: List.unmodifiable(header), + blocks: List.unmodifiable(blocks), + omitted: omitted, + summary: quoteSummaryOfHeader(header), + rawHtml: keepHtml ? outerHtmlOf(blockquote) : null, + ); +} + +List _cellsOf(dom.Element row) => [ + for (final dom.Element cell in row.children) + if (cell.localName == 'td' || cell.localName == 'th') + nodeTextOf(cell).replaceAll('\u00A0', ' ').trim(), +]; + +/// 보낸 사람 머리 줄의 이름 — `이도윤 ` → "이도윤" (이름이 없으면 주소) +String senderNameOf(String value) { + final int open = value.lastIndexOf('<'); + if (open > 0 && value.endsWith('>')) { + final String name = value.substring(0, open).trim(); + if (name.isNotEmpty) return name; + } + return value.trim(); +} + +/// 접힌 인용 줄의 둘째 줄 — 다시 연 저장본은 머리 표의 글로("이도윤 · 2026. 11. 5. (목) 오전 10:42") +String quoteSummaryOfHeader(List header) { + String? from; + String? date; + for (final QuoteHeaderRow row in header) { + if (row.label == kQuoteFromLabel) from ??= senderNameOf(row.value); + if (row.label == kQuoteDateLabel) date ??= row.value; + } + return [?from, ?date].join(' · '); +} + +/// 인용 머리의 칸 이름 — 웹 `mail-quote.ts` 와 같다 +const String kQuoteFromLabel = '보낸 사람'; +const String kQuoteDateLabel = '날짜'; +const String kQuoteToLabel = '받는 사람'; +const String kQuoteCcLabel = '참조'; +const String kQuoteSubjectLabel = '제목'; + +/// 텍스트 본문에서 원문 인용이 시작하는 자리 — "> 원본 메일" 줄의 처음(웹 `docToText` 의 인용 머리 줄). 없으면 null +int? quoteStartInText(String text) { + final RegExpMatch? match = _quoteHeadingLine.firstMatch(text); + if (match == null) return null; + return match.start + (match.group(1)?.length ?? 0); +} + +final RegExp _quoteHeadingLine = RegExp('(^|\n)> $kQuoteHeading(?=\n|\$)'); diff --git a/flutter/lib/features/mail/domain/compose_fit.dart b/flutter/lib/features/mail/domain/compose_fit.dart new file mode 100644 index 0000000..0409338 --- /dev/null +++ b/flutter/lib/features/mail/domain/compose_fit.dart @@ -0,0 +1,229 @@ +/// 답장 · 전달 초안을 계약 한도에 맞춘다 — 순수 함수 (계약 2절 "답장·전달 초안의 길이"). +/// +/// **짝: 웹 `frontend/src/components/mail/editor/fit-draft.ts`** — 같은 한도 · 같은 내 글 몫(5%) · 같은 생략 표시. +/// 텍스트 100,000자 · 서식 본문 500,000자(JS 문자열 길이 — Dart 도 UTF-16 길이다). 넘으면 원문 인용을 뒤에서 덜어 내고 끝에 생략 표시를 둔다. +/// - 처음 초안: 한도에서 내 글 몫(5% — 텍스트 5,000 · 서식 25,000)을 남기고 맞춘다 — 꽉 채우면 몇 글자만 써도 보낼 수 없다. +/// - 보내기 · 저장 직전: 한도 그대로 다시 잰다. 넘으면 인용을 더 줄인다. 내 글 + 서명 + 인용 머리만으로 넘으면 보내지 않는다(`MAIL_007`). +/// - 덩이 단위로 앞에서부터 담고, 넘친 덩이가 글 한 줄이면 들어가는 만큼 글자 단위로 담는다(짝 글자를 가르지 않는다). +/// 두 한도 안이면 JSON 본문은 2MB 안쪽이라 4MB 한도(발송 · 저장)는 따로 재지 않는다. +/// - 답장 · 전달을 열 때는 원문 → 덩이 만들기와 처음 맞추기를 한 곳에서 한다([buildInitialQuote] — 큰 원문은 다른 isolate 에서). +library; + +import 'dart:isolate'; + +import 'package:arasystem_app/features/mail/domain/compose_body.dart'; +import 'package:arasystem_app/features/mail/domain/mail_html.dart'; +import 'package:arasystem_app/features/mail/domain/mail_quote.dart'; +import 'package:flutter/foundation.dart'; + +/// 텍스트 본문 한도 (웹 `MAIL_BODY_MAX_LENGTH`) +const int kMailBodyTextMax = 100000; + +/// 서식 본문 한도 (웹 `MAIL_BODY_HTML_MAX_LENGTH`) +const int kMailBodyHtmlMax = 500000; + +/// 처음 초안을 맞출 때 내 글 몫으로 남기는 비율 (웹 `DRAFT_WRITING_RESERVE` — 한도의 5%) +const double kDraftWritingReserveRatio = 0.05; + +/// 본문 한도 +@immutable +class BodyLimits { + const BodyLimits({required this.text, required this.html}); + + final int text; + final int html; + + /// 보내기 · 저장 — 한도 그대로 + static const BodyLimits full = BodyLimits( + text: kMailBodyTextMax, + html: kMailBodyHtmlMax, + ); + + /// 처음 초안 — 내 글 몫을 뺀 만큼 (95,000 · 475,000) + static final BodyLimits initial = BodyLimits( + text: + kMailBodyTextMax - + (kMailBodyTextMax * kDraftWritingReserveRatio).round(), + html: + kMailBodyHtmlMax - + (kMailBodyHtmlMax * kDraftWritingReserveRatio).round(), + ); + + /// 텍스트 한도에서 [reserve] 를 뺀다 — 서버가 대용량 첨부 덩이를 넣은 뒤 다시 잰다(계약 4절) + BodyLimits reserveText(int reserve) => + BodyLimits(text: text - reserve, html: html); + + bool fits(MailBodyOutput output) => + output.text.length <= text && (output.html?.length ?? 0) <= html; +} + +/// 맞춘 결과 — [output] 이 null 이면 인용을 다 덜어도 넘는다(내 글만으로 넘음 — `MAIL_007`) +typedef BodyFit = ({MailBodyOutput? output, ComposeQuote? quote}); + +/// 본문을 [limits] 에 맞춘다 — 맞으면 그대로, 넘으면 인용을 줄인 본문과 줄인 인용 +BodyFit fitMailBody(ComposeBodyInput input, BodyLimits limits) { + final MailBodyOutput whole = buildMailBody(input); + if (limits.fits(whole)) return (output: whole, quote: input.quote); + final ComposeQuote? quote = input.quote; + if (quote == null) return (output: null, quote: null); + + // 머리와 생략 표시만 둔 본문이 쓰는 길이 — 남는 만큼 원문 덩이를 담는다 + final ComposeBodyInput baseInput = input.withQuote( + quote.withBlocks(const [], omitted: true), + ); + final MailBodyOutput base = buildMailBody(baseInput); + if (!limits.fits(base)) return (output: null, quote: null); + int textBudget = limits.text - base.text.length; + int htmlBudget = limits.html - (base.html?.length ?? 0); + + final List kept = []; + for (final QuoteBlock block in quote.blocks) { + final int textLength = _textLength(block); + final int htmlLength = quoteBlockHtml(block).length; + if (textLength <= textBudget && htmlLength <= htmlBudget) { + kept.add(block); + textBudget -= textLength; + htmlBudget -= htmlLength; + continue; + } + // 넘친 덩이가 글 한 줄이면 들어가는 만큼 담는다(긴 한 줄짜리 원문도 비지 않게) + if (block is QuoteLine) { + final QuoteLine? part = _cutLine(block, textBudget, htmlBudget); + if (part != null) kept.add(part); + } + break; + } + + // 덩이마다 잰 값은 실제와 어긋나지 않지만(덩이는 서로 독립이다) 다시 재고, 넘치면 끝에서 하나씩 뺀다 + ComposeQuote fitted = quote.withBlocks( + List.unmodifiable(kept), + omitted: true, + ); + MailBodyOutput output = buildMailBody(input.withQuote(fitted)); + while (kept.isNotEmpty && !limits.fits(output)) { + kept.removeLast(); + fitted = quote.withBlocks( + List.unmodifiable(kept), + omitted: true, + ); + output = buildMailBody(input.withQuote(fitted)); + } + if (!limits.fits(output)) return (output: null, quote: null); + return (output: output, quote: fitted); +} + +/// 답장 · 전달의 처음 인용을 만드는 데 드는 것 — 받은 본문 · 인용 머리 · 서명(처음 맞출 때 그 몫을 함께 잰다) +@immutable +class ComposeQuoteRequest { + const ComposeQuoteRequest({ + required this.htmlBody, + required this.textBody, + required this.isBodyTruncated, + required this.header, + required this.summary, + this.signatureHtml, + this.signatureText, + }); + + final String? htmlBody; + final String? textBody; + + /// 서버가 읽기 한도에서 잘라 보냈다 — 끝에 생략 표시 + final bool isBodyTruncated; + final List header; + + /// 접힌 줄의 둘째 줄 + final String summary; + final String? signatureHtml; + final String? signatureText; +} + +/// 처음 맞춘 인용 — 원문 → 덩이([quoteSourceOf]) → 내 글 몫(5%)을 남기고 맞춘다. 인용 머리만으로도 넘으면 덩이 없이 생략 표시만 +ComposeQuote initialQuoteOf(ComposeQuoteRequest request) { + final QuoteSource source = quoteSourceOf( + htmlBody: request.htmlBody, + textBody: request.textBody, + isBodyTruncated: request.isBodyTruncated, + ); + final ComposeQuote whole = ComposeQuote( + header: request.header, + blocks: source.blocks, + omitted: source.truncated, + summary: request.summary, + ); + final BodyFit fit = fitMailBody( + ComposeBodyInput( + text: '', + signatureHtml: request.signatureHtml, + signatureText: request.signatureText, + quote: whole, + ), + BodyLimits.initial, + ); + return fit.quote ?? whole.withBlocks(const [], omitted: true); +} + +/// [initialQuoteOf] 를 알맞은 곳에서 — 길거나 깊은 원문은 다른 isolate 에서(읽기 화면의 정화와 같은 문턱 — `mailBodyBuildPlaceOf`) +Future buildInitialQuote(ComposeQuoteRequest request) async { + final MailBodyBuildPlace place = mailBodyBuildPlaceOf( + htmlBody: request.htmlBody, + textBody: request.textBody, + ); + if (place == MailBodyBuildPlace.inline) return initialQuoteOf(request); + return Isolate.run(() => initialQuoteOf(request)); +} + +/// 덩이 하나가 텍스트 본문에 더하는 길이 — 줄마다 인용 머리 · 줄바꿈 +int _textLength(QuoteBlock block) { + int length = 0; + for (final String line in quoteBlockTextLines(block, 1)) { + length += line.length + 1; + } + return length; +} + +/// 글 한 줄을 두 예산 안에 드는 만큼 앞에서 자른다 — 하나도 안 들면 null. 짝 글자(이모지 등) 가운데는 자르지 않는다. +/// 한 번 훑으며 글자마다 더해지는 길이를 센다(자를 때마다 전체를 다시 만들지 않는다): +/// 텍스트는 "> " + 글 + 줄바꿈, 서식 본문은 문단 태그 + 공백 바꾸기(탭은 다음 칸까지 — [preserveSpaces]) · 이스케이프(`&` 5 · `<` `>` 4) +QuoteLine? _cutLine(QuoteLine line, int textBudget, int htmlBudget) { + final String text = line.text; + // 문단 태그의 길이 — 글 한 글자짜리 문단에서 그 글자를 뺀다 + int html = paragraphHtml('x').length - 1; + int column = 0; + int best = 0; + int index = 0; + while (index < text.length) { + final int unit = text.codeUnitAt(index); + int units = 1; + int added; + if (unit == _tab) { + added = kComposeTabStop - column % kComposeTabStop; + column += added; + } else { + if (unit >= 0xD800 && unit <= 0xDBFF && index + 1 < text.length) { + final int next = text.codeUnitAt(index + 1); + if (next >= 0xDC00 && next <= 0xDFFF) units = 2; + } + added = switch (unit) { + _ampersand => 5, + _lessThan || _greaterThan => 4, + _ => units, + }; + column++; + } + html += added; + final int end = index + units; + if (_quoteLineTextLength(end) > textBudget || html > htmlBudget) break; + best = end; + index = end; + } + return best == 0 ? null : QuoteLine(text.substring(0, best)); +} + +/// 글 [length] 자 한 줄이 인용 1단의 텍스트 본문에 더하는 길이 — "> " + 글 + 줄바꿈 +int _quoteLineTextLength(int length) => kQuoteLinePrefix.length + length + 1; + +const int _tab = 0x09; +const int _ampersand = 0x26; +const int _lessThan = 0x3C; +const int _greaterThan = 0x3E; diff --git a/flutter/lib/features/mail/domain/compose_reply.dart b/flutter/lib/features/mail/domain/compose_reply.dart new file mode 100644 index 0000000..cd1a272 --- /dev/null +++ b/flutter/lib/features/mail/domain/compose_reply.dart @@ -0,0 +1,183 @@ +/// 답장 · 전달 초안 — 받는 사람 · 제목 · 인용 머리 (순수 함수). +/// +/// **짝: 웹 `frontend/src/shared/utils/mail-reply.ts` · `mail-forward.ts` · `mail-quote.ts` · `mail-draft.ts`** — 같은 규칙을 옮겼다(한쪽을 고치면 둘 다). +/// - 답장: Reply-To 가 있으면 그 주소, 없으면 보낸 사람. 전체 답장은 거기에 원래 받는 사람(받는 사람 칸) · 원래 참조(참조 칸). +/// 어느 쪽이든 나 자신은 뺀다(주소는 앞뒤 공백 · 대소문자를 가리지 않는다). 내가 보낸 메일에 답장하면 원래 받는 사람에게(나에게만 보낸 메일이면 나에게). +/// - 제목: "Re: " · "Fwd: " — 이미 접두어("Re:" · "RE :" · "회신:" / "Fwd:" · "FW:" · "전달:")가 있으면 그대로, 255자에 맞춘다(짝 글자를 가르지 않는다). +/// - 인용 머리: 답장은 보낸 사람 · 날짜 · 제목, 전달은 보낸 사람 · 날짜 · 받는 사람 · 참조 · 제목. 날짜는 웹과 같은 글("2026. 11. 5. (목) 오전 10:42"). +library; + +import 'package:arasystem_app/core/people/address_input.dart'; +import 'package:arasystem_app/core/people/person_address.dart'; +import 'package:arasystem_app/core/utils/korean_format.dart'; +import 'package:arasystem_app/core/utils/utf16_length.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_address.dart'; +import 'package:arasystem_app/features/mail/data/models/message_detail.dart'; +import 'package:arasystem_app/features/mail/domain/compose_draft_body.dart'; +import 'package:arasystem_app/features/mail/domain/compose_request.dart'; +import 'package:arasystem_app/features/mail/domain/mail_quote.dart'; + +/// 쓰기 종류 — 화면 이름이 갈린다 +enum ComposeKind { newMail, reply, replyAll, forward } + +/// 답장 접두어 (웹 `MAIL_REPLY_PREFIX`) +const String kReplyPrefix = 'Re: '; + +/// 전달 접두어 (웹 `MAIL_FORWARD_PREFIX`) +const String kForwardPrefix = 'Fwd: '; + +/// 제목이 없을 때 인용 머리의 제목 (웹 `NO_SUBJECT`) +const String kQuoteNoSubject = '(제목 없음)'; + +final RegExp _replyPrefix = RegExp(r'^\s*(?:re|회신)\s*:', caseSensitive: false); +final RegExp _forwardPrefix = RegExp( + r'^\s*(?:fwd?|전달)\s*:', + caseSensitive: false, +); + +extension MailAddressPerson on MailAddress { + PersonAddress get person => PersonAddress(email: email, name: name); +} + +String buildReplySubject(String subject) { + final String trimmed = subject.trim(); + if (_replyPrefix.hasMatch(trimmed)) return trimmed; + return '$kReplyPrefix$trimmed'.trimRight(); +} + +String buildForwardSubject(String subject) { + final String trimmed = subject.trim(); + if (_forwardPrefix.hasMatch(trimmed)) return trimmed; + return '$kForwardPrefix$trimmed'.trimRight(); +} + +/// 전달 제목인가 — 이어 쓰는 저장본의 쓰기 화면 이름을 정할 때 +bool isForwardSubject(String subject) => _forwardPrefix.hasMatch(subject); + +/// 제목 — 255자를 넘으면 자른다("Re: " · "Fwd: " 를 붙여 넘친 경우) +String fitDraftSubject(String subject) => + truncateUtf16(subject, kComposeMaxSubject); + +/// 답장 받는 사람 — 위 머리말의 규칙 (짝: 웹 `buildReplyRecipients`) +({List to, List cc}) buildReplyRecipients( + MessageDetail message, + String myEmail, { + required bool all, +}) { + final String me = normalizeEmail(myEmail); + bool notMe(PersonAddress address) => normalizeEmail(address.email) != me; + final PersonAddress? from = message.summary.from?.person; + final List originalTo = [ + for (final MailAddress address in message.summary.to) address.person, + ]; + final List originalCc = [ + for (final MailAddress address in message.cc) address.person, + ]; + + if (from != null && me.isNotEmpty && !notMe(from)) { + // 내가 보낸 메일 — 그 메일을 받은 사람에게(Reply-To 는 보지 않는다) + final List recipients = uniqueAddresses( + originalTo, + ).where(notMe).toList(); + final List to = recipients.isNotEmpty + ? recipients + : [from]; + if (!all) return (to: to, cc: const []); + return (to: to, cc: _ccOf(originalCc, to, notMe)); + } + + final List primary = message.replyTo.isNotEmpty + ? [for (final MailAddress a in message.replyTo) a.person] + : [?from]; + final List others = primary.where(notMe).toList(); + // 답장할 주소가 나뿐이면(Reply-To 가 나) 원래 받는 사람에게 + final List base = others.isNotEmpty + ? others + : originalTo.where(notMe).toList(); + if (!all) return (to: uniqueAddresses(base), cc: const []); + final List to = uniqueAddresses([ + ...base, + ...originalTo, + ]).where(notMe).toList(); + return (to: to, cc: _ccOf(originalCc, to, notMe)); +} + +List _ccOf( + List cc, + List to, + bool Function(PersonAddress address) notMe, +) => uniqueAddresses(cc) + .where( + (PersonAddress address) => + notMe(address) && + !to.any((PersonAddress target) => sameAddress(target, address)), + ) + .toList(); + +/// 인용 머리의 날짜 — "2026. 11. 5. (목) 오전 10:42" (웹 `formatMailDateTime` — ko-KR 의 연 · 월 · 일 · 요일 · 시각). 기기 시간대로 +String formatQuoteDate(DateTime at) { + final DateTime local = at.toLocal(); + return '${koreanNumericDate(local)} (${koreanWeekday(local)}) ${koreanTimeOfDay(local)}'; +} + +/// 답장 머리 — 보낸 사람 · 날짜 · 제목 +List replyQuoteRows(MessageDetail message) => [ + ..._fromRows(message), + _dateRow(message), + _subjectRow(message), +]; + +/// 전달 머리 — 보낸 사람 · 날짜 · 받는 사람 · 참조 · 제목 +List forwardQuoteRows( + MessageDetail message, +) => [ + ..._fromRows(message), + _dateRow(message), + if (message.summary.to.isNotEmpty) + QuoteHeaderRow( + kQuoteToLabel, + formatAddressList(message.summary.to.map((MailAddress a) => a.person)), + ), + if (message.cc.isNotEmpty) + QuoteHeaderRow( + kQuoteCcLabel, + formatAddressList(message.cc.map((MailAddress a) => a.person)), + ), + _subjectRow(message), +]; + +List _fromRows(MessageDetail message) { + final MailAddress? from = message.summary.from; + return from == null + ? const [] + : [ + QuoteHeaderRow(kQuoteFromLabel, formatAddress(from.person)), + ]; +} + +QuoteHeaderRow _dateRow(MessageDetail message) => QuoteHeaderRow( + kQuoteDateLabel, + formatQuoteDate(message.sentAt ?? message.summary.receivedAt), +); + +QuoteHeaderRow _subjectRow(MessageDetail message) => QuoteHeaderRow( + kQuoteSubjectLabel, + message.summary.subject.isEmpty ? kQuoteNoSubject : message.summary.subject, +); + +/// 접힌 인용 줄의 둘째 줄 — "이도윤 · 11월 5일 (목) 오전 10:42"(보드 `App1Compose` — 올해가 아니면 연도까지) +String quoteSummaryOf(MessageDetail message, {required DateTime now}) { + final MailAddress? from = message.summary.from; + final DateTime local = (message.sentAt ?? message.summary.receivedAt) + .toLocal(); + final String date = local.year == now.toLocal().year + ? koreanScheduleTime(local) + : koreanFullDateTime(local); + return [?from?.displayName, date].join(' · '); +} + +/// 이어 쓰는 저장본의 쓰기 종류 — 답장할 메일이 있으면 답장, 제목이 전달이면 전달, 아니면 새 메일 (웹 `draftKindOf`) +ComposeKind draftKindOf({required String? inReplyTo, required String subject}) { + if (inReplyTo != null && inReplyTo.isNotEmpty) return ComposeKind.reply; + return isForwardSubject(subject) ? ComposeKind.forward : ComposeKind.newMail; +} diff --git a/flutter/lib/features/mail/domain/compose_request.dart b/flutter/lib/features/mail/domain/compose_request.dart new file mode 100644 index 0000000..20987dd --- /dev/null +++ b/flutter/lib/features/mail/domain/compose_request.dart @@ -0,0 +1,200 @@ +/// 쓰기 화면에 넘길 값 — 읽기 화면의 [답장] · [전체 답장] · [전달] · 본문의 mailto 링크 (순수 함수). +/// +/// 쓰기 화면(`App1Compose`)은 다음 차례다. 지금은 가는 길(주소 · 쿼리)과 넘길 값만 정해 둔다 — +/// 주소는 웹과 같은 쿼리(`/mail/compose?replyTo=` · `&all=1` · `?forward=` · `?draft=`)이고, +/// mailto 의 받는 사람 · 제목 · 본문은 주소에 싣지 않고 라우트의 `extra`([ComposePrefill])로 넘긴다 +/// (웹 `compose-prefill.ts` 처럼 — 주소가 기록 · 로그에 남지 않게). +library; + +import 'package:flutter/foundation.dart'; + +/// 쓰기 화면 주소의 쿼리 이름 — 웹 `/mail/compose` 와 같다 +abstract final class ComposeQuery { + static const String draft = 'draft'; + static const String replyTo = 'replyTo'; + + /// [replyTo] 와 함께 `1` 이면 전체 답장 + static const String replyAll = 'all'; + static const String replyAllValue = '1'; + static const String forward = 'forward'; +} + +/// 쓰기 화면을 여는 까닭 +sealed class ComposeIntent { + const ComposeIntent(); +} + +/// 새 메일 (mailto 로 채운 것 포함 — 채울 값은 [ComposePrefill]) +final class NewComposeIntent extends ComposeIntent { + const NewComposeIntent(); +} + +/// 임시 저장본 이어 쓰기 +final class DraftComposeIntent extends ComposeIntent { + const DraftComposeIntent(this.draftId); + + final String draftId; +} + +/// 답장 · 전체 답장 +final class ReplyComposeIntent extends ComposeIntent { + const ReplyComposeIntent(this.messageId, {required this.all}); + + final String messageId; + final bool all; +} + +/// 전달 +final class ForwardComposeIntent extends ComposeIntent { + const ForwardComposeIntent(this.messageId); + + final String messageId; +} + +/// 쓰기 화면 주소의 쿼리 → 여는 까닭 (모르는 값은 새 메일 — 웹과 같은 차례: 이어 쓰기 → 답장 → 전달) +ComposeIntent composeIntentOf(Map query) { + final String? draft = query[ComposeQuery.draft]; + if (draft != null && draft.isNotEmpty) return DraftComposeIntent(draft); + final String? replyTo = query[ComposeQuery.replyTo]; + if (replyTo != null && replyTo.isNotEmpty) { + return ReplyComposeIntent( + replyTo, + all: query[ComposeQuery.replyAll] == ComposeQuery.replyAllValue, + ); + } + final String? forward = query[ComposeQuery.forward]; + if (forward != null && forward.isNotEmpty) { + return ForwardComposeIntent(forward); + } + return const NewComposeIntent(); +} + +/// mailto 링크로 채울 값 — 쓰기 화면이 받는 사람 · 제목 · 본문 칸을 채운다 +@immutable +class ComposePrefill { + const ComposePrefill({ + this.to = const [], + this.cc = const [], + this.bcc = const [], + this.subject = '', + this.body = '', + }); + + final List to; + final List cc; + final List bcc; + final String subject; + final String body; + + @override + bool operator ==(Object other) => + other is ComposePrefill && + listEquals(other.to, to) && + listEquals(other.cc, cc) && + listEquals(other.bcc, bcc) && + other.subject == subject && + other.body == body; + + @override + int get hashCode => Object.hash( + Object.hashAll(to), + Object.hashAll(cc), + Object.hashAll(bcc), + subject, + body, + ); +} + +/// 받는 사람 합계 한도 — 발송 한도와 같다(계약 2절 — 100명) +const int kComposeMaxRecipients = 100; + +/// 제목 한도 — 발송 한도와 같다(255자) +const int kComposeMaxSubject = 255; + +/// mailto 로 채우는 본문 한도 — 링크에 실린 긴 글로 화면이 무거워지지 않게(발송 한도 100,000자보다 짧게 둔다) +const int kComposeMaxPrefillBody = 10000; + +/// 주소 한 칸의 최대 길이 (RFC 5321) +const int _maxAddressLength = 254; + +/// mailto 링크 → 채울 값 — mailto 가 아니면 null. +/// +/// `mailto:a@x,b@x?cc=…&bcc=…&subject=…&body=…`(RFC 6068). 받는 사람은 `@` 가 있고 공백 · 제어 문자가 없는 것만, +/// 겹치면 하나, 합계 [kComposeMaxRecipients] 까지. 제목 · 본문은 한도에서 자른다. 받는 사람이 없어도 된다(빈 쓰기 화면). +ComposePrefill? composePrefillOfMailto(Uri uri) { + if (uri.scheme.toLowerCase() != 'mailto') return null; + final Map query = {}; + final String rawQuery = uri.hasQuery ? uri.query : ''; + if (rawQuery.isNotEmpty) { + for (final String part in rawQuery.split('&')) { + final int equals = part.indexOf('='); + final String key = _decode( + equals == -1 ? part : part.substring(0, equals), + ).toLowerCase(); + final String value = equals == -1 + ? '' + : _decode(part.substring(equals + 1)); + // 같은 이름이 여럿이면 앞의 것 — 받는 사람 칸은 이어 붙인다 + if (key == 'to' || key == 'cc' || key == 'bcc') { + query[key] = query[key] == null ? value : '${query[key]},$value'; + } else { + query.putIfAbsent(key, () => value); + } + } + } + final Set seen = {}; + List addresses(String raw) { + final List out = []; + for (final String piece in raw.split(',')) { + final String address = piece.trim(); + if (!_isPlausibleAddress(address)) continue; + if (seen.length >= kComposeMaxRecipients) break; + if (!seen.add(address.toLowerCase())) continue; + out.add(address); + } + return List.unmodifiable(out); + } + + return ComposePrefill( + to: addresses([_decode(uri.path), query['to'] ?? ''].join(',')), + cc: addresses(query['cc'] ?? ''), + bcc: addresses(query['bcc'] ?? ''), + subject: _limit(_singleLine(query['subject'] ?? ''), kComposeMaxSubject), + body: _limit(query['body'] ?? '', kComposeMaxPrefillBody), + ); +} + +/// 퍼센트 인코딩을 푼다 — 깨진 인코딩은 그대로 둔다 +String _decode(String value) { + try { + return Uri.decodeComponent(value); + } on ArgumentError { + return value; + } on FormatException { + return value; + } +} + +bool _isPlausibleAddress(String address) { + if (address.isEmpty || address.length > _maxAddressLength) return false; + final int at = address.indexOf('@'); + if (at <= 0 || at != address.lastIndexOf('@') || at == address.length - 1) { + return false; + } + return !address.runes.any( + (int rune) => rune <= 0x20 || (rune >= 0x7F && rune <= 0x9F), + ); +} + +/// 제목은 한 줄 — 줄바꿈 · 제어 문자는 띄어쓰기로 +String _singleLine(String value) => + value.replaceAll(RegExp(r'[\u0000-\u001F\u007F]+'), ' ').trim(); + +/// [max] 자(UTF-16)에서 자른다 — 이모지 같은 짝 글자(서로게이트)를 가르지 않는다 +String _limit(String value, int max) { + if (value.length <= max) return value; + final int end = _isHighSurrogate(value.codeUnitAt(max - 1)) ? max - 1 : max; + return value.substring(0, end); +} + +bool _isHighSurrogate(int unit) => unit >= 0xD800 && unit <= 0xDBFF; diff --git a/flutter/lib/features/mail/domain/compose_send.dart b/flutter/lib/features/mail/domain/compose_send.dart new file mode 100644 index 0000000..9209b01 --- /dev/null +++ b/flutter/lib/features/mail/domain/compose_send.dart @@ -0,0 +1,108 @@ +/// 보내기의 규칙 — 중복 발송 방지 키 · 받지 못한 사람 문장 · 보내기 취소 알림의 마감 · 대용량 파일 덩이 몫 (순수 함수). +/// 첨부의 형식 · 한도 · 사전 검사는 `compose_attachments.dart`. +/// +/// 짝: 웹 `shared/utils/idempotency-key.ts`(`shouldRenewSendKey`) · `uuid.ts` · `mail-send-result.ts`(`partialSendMessage`) · +/// `pending-send.ts`(`undoToastDeadline`) · `large-file.ts`(`largeFileBlockTextReserve`) — 같은 규칙을 옮겼다(한쪽을 고치면 둘 다). +library; + +import 'dart:math' as math; + +import 'package:arasystem_app/core/utils/korean_format.dart'; +import 'package:arasystem_app/features/mail/data/models/compose_models.dart'; + +/// 보내기 한 번의 결과 — 성공은 이 글, 실패는 그 에러 코드 (웹 `SendOutcome`) +const String kSendOutcomeSent = 'sent'; + +/// 새 발송 키 — UUID v4(무작위 128비트 중 122비트, `Random.secure`). 새 의존성 없이 만든다 +String createSendKey([math.Random? random]) { + final math.Random source = random ?? math.Random.secure(); + final List bytes = List.generate(16, (_) => source.nextInt(256)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + final String hex = bytes + .map((int byte) => byte.toRadixString(16).padLeft(2, '0')) + .join(); + return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-${hex.substring(12, 16)}-' + '${hex.substring(16, 20)}-${hex.substring(20)}'; +} + +/// 보내기 결과를 보고 다음 보내기에 새 키를 써야 하는가 — 계약 2절 "중복 발송 방지". +/// 키를 바꾸는 것은 **성공과 `MAIL_005` 뿐**이다. 그 밖의 실패는 키를 그대로 둔다(나갔을 수 있다 — 같은 키로 다시 보내야 서버가 두 번 내보내지 않는다) +bool shouldRenewSendKey(String outcome) => + outcome == kSendOutcomeSent || outcome == 'MAIL_005'; + +/// 알림에 주소 · 까닭을 적는 사람 수 — 더 있으면 " 외 N명" (웹 `MAIL_PARTIAL_SEND_LISTED`) +const int kPartialSendListed = 2; + +/// 받지 못한 까닭 (웹 `MAIL_REJECTED_REASON_TEXT`) +String? rejectedReasonText(RejectedReason reason) => switch (reason) { + RejectedReason.notFound => '없는 주소', + RejectedReason.refused => '받는 쪽이 거절', + RejectedReason.temporary => '지금은 받을 수 없음', + RejectedReason.unknown => null, +}; + +/// 받는 사람 일부가 받지 못한 채 보냈을 때의 알림 — 받지 못한 사람이 없으면 null. +/// "보냈지만 3명은 받지 못했습니다. a@… (없는 주소), b@… (받는 쪽이 거절) 외 1명" — 보내기 취소를 기다리는 발송이면([pending]) "받지 못합니다" +String? partialSendMessage( + List rejected, { + bool pending = false, +}) { + if (rejected.isEmpty) return null; + final String listed = rejected + .take(kPartialSendListed) + .map((RejectedRecipient recipient) { + final String? reason = rejectedReasonText(recipient.reason); + return reason == null + ? recipient.email + : '${recipient.email} ($reason)'; + }) + .join(', '); + final int others = rejected.length - kPartialSendListed; + final String tail = others > 0 ? ' 외 ${formatCount(others)}명' : ''; + final String count = formatCount(rejected.length); + return pending + ? '보냈지만 $count명은 받지 못합니다. $listed$tail' + : '보냈지만 $count명은 받지 못했습니다. $listed$tail'; +} + +/// [보내기 취소] 알림이 남을 수 있는 가장 긴 시간 — 설정의 가장 긴 값(30초 — 웹 `UNDO_SEND_MAX_MS`) +const Duration kUndoSendMax = Duration(seconds: 30); + +/// 설정한 초로 잴 때 남겨 두는 몫 — 닫히기 직전에 누른 취소가 서버에 닿기 전에 풀리지 않게(웹 `UNDO_SEND_DEADLINE_MARGIN_MS`) +const Duration kUndoSendMargin = Duration(seconds: 1); + +/// 서버 시각으로 잴 때 남겨 두는 몫 — [kUndoSendMargin] 에 1초를 더 둔다: 응답의 `Date` 는 초 아래를 버린 값이고, +/// 응답이 오는 동안 지난 시간도 빼지 않았다(앱에서 정함 — 웹은 서버 시각을 쓰지 않는다) +const Duration kUndoSendServerMargin = Duration(seconds: 2); + +/// [보내기 취소]를 누를 수 있는 남은 시간 — 서버 시계로 잰다(기기 시계가 틀려도 늘거나 줄지 않게). +/// - 응답의 서버 시각을 알면 `sendAt − 서버 시각 − 2초` +/// - 모르면 설정의 초(`GET /api/mail-settings`) − 1초, 그것도 모르면 30초 — 어느 쪽이든 `sendAt − 이 기기 시각`도 넘지 않게(웹 `undoToastDeadline` 처럼 sendAt 을 상한으로) +/// 어느 쪽이든 30초를 넘지 않는다. 남은 시간이 없으면 null — [보내기 취소] 없이 알린다 +Duration? undoSendRemaining({ + required DateTime sendAt, + required DateTime? serverNow, + required int? undoSendSeconds, + required DateTime deviceNow, +}) { + Duration remaining; + if (serverNow != null) { + remaining = sendAt.difference(serverNow) - kUndoSendServerMargin; + } else { + remaining = undoSendSeconds != null && undoSendSeconds > 0 + ? Duration(seconds: undoSendSeconds) - kUndoSendMargin + : kUndoSendMax; + final Duration untilSend = sendAt.difference(deviceNow); + if (untilSend < remaining) remaining = untilSend; + } + if (remaining <= Duration.zero) return null; + return remaining > kUndoSendMax ? kUndoSendMax : remaining; +} + +/// 서버가 덩이를 넣은 뒤 다시 재므로 보내기 직전에 텍스트 한도에서 남기는 몫 — 파일마다 이름 + 24, 머리 600 +/// (웹 `largeFileBlockTextReserve` · `LARGE_FILE_BLOCK_TEXT_PER_FILE` · `_BASE`) +int largeFileBlockTextReserve(Iterable names) { + if (names.isEmpty) return 0; + return names.fold(600, (int sum, String name) => sum + name.length + 24); +} diff --git a/flutter/lib/features/mail/domain/compose_signature.dart b/flutter/lib/features/mail/domain/compose_signature.dart new file mode 100644 index 0000000..d78c961 --- /dev/null +++ b/flutter/lib/features/mail/domain/compose_signature.dart @@ -0,0 +1,229 @@ +/// 쓰기 화면의 서명 — 넣을 서명 고르기 · 보낼 HTML · 미리 보기 (순수 함수 · 계약 2절 "서명"). +/// +/// - 새 메일은 `isForNew`, 답장 · 전달은 `isForReply` 일 때만 넣는다. 서식 서명(`bodyHtml`)이 있으면 그것, 없으면 텍스트 서명을 문단으로 +/// (짝: 웹 `shared/utils/mail-signature.ts` 의 `signatureHtmlFor`). 이어 쓰는 저장본에는 다시 넣지 않는다(저장본에 이미 있다). +/// - 보낼 때는 서명 경계 div(`compose_body.dart`) 안에 서버가 준 HTML 을 그대로 두고, 문단에 `margin: 0` 만 앞에 넣는다(받는 쪽의 문단 여백). +/// 텍스트 대체 본문에는 텍스트 서명(`body`)을 — 없으면 HTML 의 글을 넣는다. 서명 그림 주소를 `cid:` 로 바꾸는 일은 서버가 한다. +/// - 미리 보기는 서명 HTML 의 글 줄과 서명 그림이다. 굵게 · 색 같은 모양은 미리 보기에서 빼고, 보낸 메일에는 그대로 들어간다. +library; + +import 'package:arasystem_app/features/mail/data/models/compose_models.dart'; +import 'package:arasystem_app/features/mail/domain/compose_body.dart'; +import 'package:arasystem_app/features/mail/domain/html_nodes.dart'; +import 'package:arasystem_app/features/mail/domain/mail_quote.dart'; +import 'package:flutter/foundation.dart'; +import 'package:html/dom.dart' as dom; +import 'package:html/parser.dart' as html_parser; + +/// 서명 그림 주소 — `/api/mail-signature/images/{id}` (짝: 백엔드 `html/signature-images.ts` · 웹 `outgoing-html-rules.ts` 의 그림 규칙) +final RegExp _signatureImageSrc = RegExp( + r'^/api/mail-signature/images/([0-9a-fA-F-]{1,64})$', +); + +/// 서명 그림 한 장의 최대 크기 — 계약 2절 "서명"(300KB) +const int kSignatureImageMaxBytes = 300 * 1024; + +/// 서명 그림의 폭 상한 — 계약 2절(600) +const int kSignatureImageMaxWidth = 600; + +/// 미리 보기의 한 조각 +@immutable +sealed class SignaturePart { + const SignaturePart(); +} + +/// 글 한 줄 +final class SignatureTextPart extends SignaturePart { + const SignatureTextPart(this.text); + + final String text; + + @override + bool operator ==(Object other) => + other is SignatureTextPart && other.text == text; + + @override + int get hashCode => text.hashCode; +} + +/// 서명 그림 — [width] 는 HTML 의 폭(없으면 null), [alt] 는 화면 낭독기가 읽는다 +final class SignatureImagePart extends SignaturePart { + const SignatureImagePart({required this.id, this.width, this.alt = ''}); + + final String id; + final int? width; + final String alt; + + @override + bool operator ==(Object other) => + other is SignatureImagePart && + other.id == id && + other.width == width && + other.alt == alt; + + @override + int get hashCode => Object.hash(id, width, alt); +} + +/// 쓰기 화면에 넣을 서명 +@immutable +class ComposeSignature { + const ComposeSignature({ + required this.html, + required this.text, + required this.parts, + }); + + /// 서명 경계 div 안에 둘 HTML + final String html; + + /// 텍스트 대체 본문에 넣을 글 + final String text; + + /// 미리 보기 — 글 줄 · 그림 + final List parts; + + @override + bool operator ==(Object other) => + other is ComposeSignature && + other.html == html && + other.text == text && + listEquals(other.parts, parts); + + @override + int get hashCode => Object.hash(html, text, Object.hashAll(parts)); +} + +/// 이 쓰기 종류에 넣을 서명 — 넣기로 하지 않았거나 내용이 없으면 null +ComposeSignature? composeSignatureOf( + MailSignature? signature, { + required bool forReply, +}) { + if (signature == null) return null; + final bool enabled = forReply ? signature.isForReply : signature.isForNew; + if (!enabled) return null; + final String html = signature.bodyHtml?.trim() ?? ''; + final String text = signature.body?.trimRight() ?? ''; + if (html.isNotEmpty) { + return composeSignatureFromHtml( + _withParagraphMargins(html), + text: text.trim().isEmpty ? null : text, + ); + } + if (text.trim().isEmpty) return null; + return ComposeSignature( + html: [ + for (final String line in composeLinesOf(text)) paragraphHtml(line), + ].join(), + text: text, + parts: [ + for (final String line in composeLinesOf(text)) SignatureTextPart(line), + ], + ); +} + +/// 서명 HTML(경계 div 안의 것) → 쓰기 화면의 서명 — 다시 연 저장본의 서명도 이것으로. [text] 가 없으면 HTML 의 글 +ComposeSignature composeSignatureFromHtml(String html, {String? text}) { + final dom.Element? body = html_parser.parse(html).body; + final List parts = body == null + ? const [] + : _partsOf(body); + return ComposeSignature( + html: html, + text: text ?? plainLinesOf(quoteBlocksFromHtml(html)).join('\n'), + parts: List.unmodifiable(parts), + ); +} + +/// 문단마다 `margin: 0` 을 앞에 — 이미 margin 으로 시작하면 그대로(서버가 정화한 서명은 `margin:0` 을 가졌다) +String _withParagraphMargins(String html) { + final dom.Element? body = html_parser.parse(html).body; + if (body == null) return html; + bool changed = false; + for (final dom.Element paragraph in descendantElements(body, 'p')) { + final String style = paragraph.attributes['style']?.trim() ?? ''; + if (style.toLowerCase().startsWith('margin')) continue; + paragraph.attributes['style'] = style.isEmpty + ? MailHtmlStyles.paragraph + : '${MailHtmlStyles.paragraph}; $style'; + changed = true; + } + return changed ? innerHtmlOf(body) : html; +} + +/// 줄을 나누지 않는 태그 — 그 밖의 요소는 블록으로 본다 +const Set _inlineTags = { + 'span', 'strong', 'b', 'em', 'i', 'u', 's', 'a', 'font', 'small', 'big', // +}; + +/// 미리 보기 조각 — 블록 · `
` 은 줄, 서명 그림은 그 자리에. 빈 줄은 하나로 줄이고 앞뒤 빈 줄은 뺀다 +List _partsOf(dom.Element root) { + final List parts = []; + final StringBuffer line = StringBuffer(); + void flush({bool force = false}) { + final String text = line.toString().replaceAll(RegExp(r'\s+'), ' ').trim(); + line.clear(); + if (text.isEmpty && !force) return; + if (text.isEmpty && + (parts.isEmpty || parts.last == const SignatureTextPart(''))) { + return; + } + parts.add(SignatureTextPart(text)); + } + + // 되풀이 없이 — 닫는 표시(null)로 블록 끝을 안다 + final List stack = [...root.nodes.reversed]; + while (stack.isNotEmpty) { + final dom.Node? node = stack.removeLast(); + if (node == null) { + flush(); + continue; + } + if (node is dom.Text) { + line.write(node.data); + continue; + } + if (node is! dom.Element) continue; + final String name = (node.localName ?? '').toLowerCase(); + if (name == 'br') { + flush(force: true); + continue; + } + if (name == 'img') { + final SignatureImagePart? image = _imageOf(node); + if (image != null) { + flush(); + parts.add(image); + } + continue; + } + if (name == 'style' || name == 'script') continue; + if (_inlineTags.contains(name)) { + stack.addAll(node.nodes.reversed); + continue; + } + flush(); + stack + ..add(null) + ..addAll(node.nodes.reversed); + } + flush(); + while (parts.isNotEmpty && parts.last == const SignatureTextPart('')) { + parts.removeLast(); + } + return parts; +} + +SignatureImagePart? _imageOf(dom.Element image) { + final String src = image.attributes['src']?.trim() ?? ''; + final RegExpMatch? match = _signatureImageSrc.firstMatch(src); + if (match == null) return null; + final int? width = int.tryParse(image.attributes['width']?.trim() ?? ''); + return SignatureImagePart( + id: match.group(1)!, + width: width != null && width > 0 && width <= kSignatureImageMaxWidth + ? width + : null, + alt: image.attributes['alt'] ?? '', + ); +} diff --git a/flutter/lib/features/mail/domain/html_nodes.dart b/flutter/lib/features/mail/domain/html_nodes.dart new file mode 100644 index 0000000..6f4b932 --- /dev/null +++ b/flutter/lib/features/mail/domain/html_nodes.dart @@ -0,0 +1,97 @@ +/// HTML 노드를 되풀이 없이 다루는 도구 — 글 모으기 · 태그로 찾기 · 직렬화 (순수 함수). +/// +/// `package:html` 의 `text` · `querySelectorAll` · `innerHtml` · `outerHtml` 은 깊이만큼 되풀이한다 — 깊게 겹친 저장본 · 원문 하나로 +/// 스택이 넘칠 수 있어 쓰기 화면은 이 함수들을 쓴다. 직렬화는 `package:html` 과 같은 글을 낸다(같은 이스케이프 · 빈 요소 규칙). +library; + +import 'package:html/dom.dart' as dom; +import 'package:html/dom_parsing.dart' + show htmlSerializeEscape, isVoidElement, writeTextNodeAsHtml; + +/// 노드 안의 글을 문서 차례로 모은다 — `Node.text` 와 같은 글(모든 글 노드) +String nodeTextOf(dom.Node root) { + if (root is dom.Text) return root.data; + final StringBuffer out = StringBuffer(); + final List stack = [...root.nodes.reversed]; + while (stack.isNotEmpty) { + final dom.Node node = stack.removeLast(); + if (node is dom.Text) { + out.write(node.data); + } else if (node is dom.Element) { + stack.addAll(node.nodes.reversed); + } + } + return out.toString(); +} + +/// [root] 아래(자기 빼고)에서 이름이 [tag] 인 요소 — 문서 차례로 (`querySelectorAll(tag)` 와 같은 결과) +List descendantElements(dom.Element root, String tag) { + final List found = []; + final List stack = [...root.children.reversed]; + while (stack.isNotEmpty) { + final dom.Element element = stack.removeLast(); + if (element.localName == tag) found.add(element); + stack.addAll(element.children.reversed); + } + return found; +} + +/// 노드 하나의 HTML — `outerHtml` 과 같은 글 +String outerHtmlOf(dom.Node node) { + final StringBuffer out = StringBuffer(); + _write(out, [node]); + return out.toString(); +} + +/// 요소 안의 HTML — `innerHtml` 과 같은 글 +String innerHtmlOf(dom.Node node) { + final StringBuffer out = StringBuffer(); + _write(out, node.nodes); + return out.toString(); +} + +/// 노드들을 차례로 적는다 — 스택에는 노드와 닫는 태그(글)가 함께 든다 +void _write(StringBuffer out, List nodes) { + final List stack = [...nodes.reversed]; + while (stack.isNotEmpty) { + final Object item = stack.removeLast(); + if (item is String) { + out.write(item); + continue; + } + if (item is dom.Text) { + writeTextNodeAsHtml(out, item); + continue; + } + if (item is dom.Comment) { + out.write(''); + continue; + } + if (item is! dom.Element) { + // 문서 형식 선언 등 — 쓰기 화면이 다루는 조각에는 없다 + if (item is dom.Node) stack.addAll(item.nodes.reversed); + continue; + } + final String name = item.localName ?? ''; + out + ..write('<') + ..write(name); + item.attributes.forEach((Object key, String value) { + out + ..write(' ') + ..write(key) + ..write('="') + ..write(htmlSerializeEscape(value, attributeMode: true)) + ..write('"'); + }); + out.write('>'); + if (item.nodes.isNotEmpty && + (name == 'pre' || name == 'textarea' || name == 'listing')) { + // 파서가 맨 앞 줄바꿈 하나를 지우므로 남아 있으면 둘이었다 — 하나를 되돌린다(`package:html` 과 같다) + final dom.Node first = item.nodes.first; + if (first is dom.Text && first.data.startsWith('\n')) out.write('\n'); + } + if (!isVoidElement(name)) stack.add(''); + stack.addAll(item.nodes.reversed); + } +} diff --git a/flutter/lib/features/mail/domain/mail_body_notices.dart b/flutter/lib/features/mail/domain/mail_body_notices.dart new file mode 100644 index 0000000..6b9cd32 --- /dev/null +++ b/flutter/lib/features/mail/domain/mail_body_notices.dart @@ -0,0 +1,32 @@ +/// 읽기 화면의 본문 안내 — 서버가 본문을 다 주지 못했을 때 (계약 2절 "읽기 한도" · 웹 `shared/utils/mail-body-notices.ts` 와 같다). +/// +/// 차례는 생략(서식 없이 · 표시할 수 없음) → 잘림이다. 보여 줄 본문이 없으면("표시할 수 없음") 잘림 안내는 맞지 않아 뺀다. +/// 글자 본문은 공백뿐이면 없는 것으로 본다. +library; + +/// 본문 안내의 종류 +enum MailBodyNotice { + /// 서식 본문을 한도 때문에 비웠다 — 글자 본문을 보인다 + htmlOmitted, + + /// 서식 본문을 비웠고 글자 본문도 없다 + unavailable, + + /// 보여 주는 본문이 읽기 한도에서 잘렸다 + truncated, +} + +/// 본문 위에 둘 안내 — 없으면 빈 목록 +List mailBodyNoticesOf({ + required String? textBody, + required bool isHtmlBodyOmitted, + required bool isBodyTruncated, +}) { + final bool hasText = (textBody ?? '').trim().isNotEmpty; + return [ + if (isHtmlBodyOmitted) + hasText ? MailBodyNotice.htmlOmitted : MailBodyNotice.unavailable, + if (isBodyTruncated && !(isHtmlBodyOmitted && !hasText)) + MailBodyNotice.truncated, + ]; +} diff --git a/flutter/lib/features/mail/domain/mail_bulk.dart b/flutter/lib/features/mail/domain/mail_bulk.dart new file mode 100644 index 0000000..af824cd --- /dev/null +++ b/flutter/lib/features/mail/domain/mail_bulk.dart @@ -0,0 +1,93 @@ +/// 여러 통 처리 보조 — 나눠 보내기 · 되돌리기 묶음 (웹 `shared/utils/mail-bulk.ts` 와 같은 규칙, 순수 함수 · 문구 없음) +library; + +import 'dart:math' as math; + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; + +/// [size] 개씩 나눈다 — 요청 하나의 ids 는 100개까지다 +List> chunked(List items, int size) { + final int step = math.max(1, size); + return >[ + for (int start = 0; start < items.length; start += step) + items.sublist(start, math.min(start + step, items.length)), + ]; +} + +/// 여러 통 처리의 결과 +@immutable +class BulkResult { + const BulkResult({ + this.doneIds = const [], + this.skippedIds = const [], + this.unsentIds = const [], + }); + + /// 처리한 메일 + final List doneIds; + + /// 그사이 옮겨졌거나 지워져 그대로 둔 메일 — 서버의 failedIds · skippedIds + final List skippedIds; + + /// 요청이 실패해 보내지 못한 메일 — 알림은 인터셉터가 띄웠다 + final List unsentIds; + + /// 그대로 둔 메일 · 보내지 못한 메일이 있다 — 목록을 서버 기준으로 다시 맞춘다 + bool get hasLeftovers => skippedIds.isNotEmpty || unsentIds.isNotEmpty; +} + +/// 한 묶음을 보낸 결과 — 처리한 메일 · 그대로 둔 메일 +typedef BulkChunkSender = + Future<({List done, List skipped})> Function( + List chunkIds, + ); + +/// [ids] 를 [size] 통씩 차례로 보낸다 — 요청이 실패하면(알림은 인터셉터) 그 묶음과 남은 묶음은 보내지 않은 것으로 두고 멈춘다 +/// (같은 오류가 이어질 것이고, 503 은 일부만 바뀌었을 수 있어 부르는 쪽이 목록을 다시 맞춘다) +Future sendInChunks( + List ids, + int size, + BulkChunkSender send, +) async { + final List done = []; + final List skipped = []; + final List unsent = []; + final List> chunks = chunked(ids, size); + for (int index = 0; index < chunks.length; index++) { + try { + final ({List done, List skipped}) result = await send( + chunks[index], + ); + done.addAll(result.done); + skipped.addAll(result.skipped); + } on DioException { + unsent.addAll(chunks.sublist(index).expand((List c) => c)); + break; + } + } + return BulkResult(doneIds: done, skippedIds: skipped, unsentIds: unsent); +} + +/// 되돌리기 한 묶음 — 같은 메일함으로 돌아갈 메일 +@immutable +class BulkOrigin { + const BulkOrigin(this.mailboxId, this.ids); + + final String mailboxId; + final List ids; +} + +/// 옮긴 메일을 원래 메일함별로 묶는다(옮긴 차례대로). 원래 메일함을 모르는 메일은 뺀다 +List groupByOrigin(List ids, Map originOf) { + final Map> groups = >{}; + for (final String id in ids) { + final String? origin = originOf[id]; + if (origin == null) continue; + (groups[origin] ??= []).add(id); + } + return [ + for (final MapEntry> entry in groups.entries) + BulkOrigin(entry.key, List.unmodifiable(entry.value)), + ]; +} diff --git a/flutter/lib/features/mail/domain/mail_html.dart b/flutter/lib/features/mail/domain/mail_html.dart new file mode 100644 index 0000000..694f160 --- /dev/null +++ b/flutter/lib/features/mail/domain/mail_html.dart @@ -0,0 +1,531 @@ +/// 받은 메일 본문 → WebView 에 넣을 문서 — 보안 핵심 (순수 함수 · 웹 `shared/utils/mail-html.ts` 의 결을 따른다). +/// +/// 방어는 세 겹이다. 어느 한 겹이 뚫려도 나머지가 막도록 셋 다 유지한다. +/// 1) 서버가 정화한 htmlBody (계약 2절 "htmlBody 정화 규칙") +/// 2) 여기서 한 번 더 정화 — HTML5 파서(`package:html`)로 읽어 **허용 목록의 태그 · 속성만** 다시 쓴다. +/// 서버 정화에 빈틈이 있어도 스크립트 · 폼 · 외부 자원 · 이벤트 처리기를 걷어 낸다. 결과 HTML 은 파서의 직렬화가 아니라 +/// 여기서 직접 쓴다(속성 값의 `<` `>` `"` `&` 를 늘 이스케이프 — 아래 그림 주소 바꿔 끼우기가 그 모양에 기댄다). +/// 3) WebView — 스크립트를 끄고(`JavaScriptMode.disabled`), 이 문서 안의 CSP 로 모든 바깥 요청 · 아래 틀을 막고(그림은 `data:` 만), +/// 주 틀의 탐색은 모두 막는다(링크는 앱이 바깥 브라우저 · 쓰기 화면으로 — `mail_links.dart`). 아래 틀(iframe)의 탐색은 안드로이드가 +/// 판정에 넘기지 않으므로 정화(iframe 을 내용째 버림)와 CSP(frame-src)만 막는다. +/// +/// 외부 이미지를 보일 때(서버가 중계 주소 `/api/mail-images/<표>` 로 바꿔 넣은 본문 — imagesShown)도 WebView 는 바깥에 나가지 않는다 — +/// 앱이 Dio(Bearer)로 그림을 받아 `data:` 로 바꿔 넣는다([resolveProxiedImages]). 토큰이 WebView 에 가지 않는다. +library; + +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; +import 'package:html/parser.dart' as html_parser; + +/// WebView 문서 안의 CSP — 스크립트 · 글꼴 · 네트워크 요청을 모두 막고 `data:` 그림과 인라인 스타일만 연다. +/// 웹(`MAIL_FRAME_CSP`)과 같고, `` · 폼 보내기도 막는다(둘은 `default-src` 를 따르지 않는다). +/// 외부 이미지를 보일 때도 같다 — 그림은 앱이 받아 `data:` 로 넣는다(웹처럼 중계 경로를 열지 않는다) +const String kMailDocumentCsp = + "default-src 'none'; img-src data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'"; + +/// 서버의 중계 주소 — `/api/mail-images/<표>`. 표는 서버가 암호화한 불투명 글(base64url)이라 한 칸뿐이다 +/// (짝: backend `modules/mail/images/mail-image-token.ts` 의 `TOKEN_PATTERN` · 웹 `PROXIED_IMAGE_PATTERN`) +final RegExp kProxiedImagePattern = RegExp( + r'^/api/mail-images/([A-Za-z0-9_-]+)$', +); + +/// 한 통에서 받아 넣을 중계 그림의 최대 수 — 서버가 한 통에 바꿔 넣는 수와 같다(계약 2절 "외부 이미지 보기" — 100개) +const int kMaxProxiedImages = 100; + +/// 남길 링크 — 계약과 같다(http · https · mailto). 상대 경로 · javascript: 등은 지운다 +final RegExp _safeLink = RegExp(r'^(?:https?|mailto):', caseSensitive: false); + +/// 남길 `data:` 그림 — 서버가 본문에 넣어 주는 그림 형식(PNG · JPEG · GIF · WebP)만. SVG 는 받지 않는다 +final RegExp _dataImage = RegExp( + r'^data:image/(?:png|jpe?g|gif|webp);base64,[A-Za-z0-9+/=\s]*$', + caseSensitive: false, +); + +/// 남기는 태그 — 메일 본문에 흔한 글 · 표 · 목록 · 인용 태그(DOMPurify 의 HTML 목록에서 폼 · 미디어 · 틀을 뺐다) +const Set _allowedTags = { + 'a', 'abbr', 'acronym', 'address', 'article', 'aside', 'b', 'bdi', 'bdo', // + 'big', 'blockquote', 'br', 'caption', 'center', 'cite', 'code', 'col', // + 'colgroup', 'data', 'dd', 'del', 'details', 'dfn', 'dir', 'div', 'dl', // + 'dt', 'em', 'figcaption', 'figure', 'font', 'footer', 'h1', 'h2', 'h3', // + 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'i', 'img', 'ins', 'kbd', // + 'li', 'main', 'mark', 'menu', 'nav', 'nobr', 'ol', 'p', 'pre', 'q', 'rp', // + 'rt', 'ruby', 's', 'samp', 'section', 'small', 'span', 'strike', 'strong', // + 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', // + 'time', 'tr', 'tt', 'u', 'ul', 'var', 'wbr', +}; + +/// 내용째 버리는 태그 — 스크립트 · 스타일 · 틀 · 미디어 · SVG · 수식 · 폼의 숨은 값(웹 `FORBID_CONTENTS` 와 서버 제거 목록) +const Set _droppedWithContent = { + 'script', 'style', 'template', 'iframe', 'frame', 'frameset', 'object', // + 'embed', 'applet', 'param', 'noscript', 'noembed', 'noframes', 'svg', // + 'math', 'datalist', 'title', 'head', 'xmp', 'plaintext', 'audio', // + 'video', 'source', 'track', 'canvas', 'map', 'area', 'link', 'meta', // + 'base', 'input', 'select', 'dialog', 'slot', +}; + +/// 내용이 없는 태그 — 닫는 태그를 쓰지 않는다 +const Set _voidTags = {'br', 'col', 'hr', 'img', 'wbr'}; + +/// 어느 태그에나 남기는 속성 — 모양(style) · 표 · 글꼴의 옛 속성. 이벤트 처리기 · data-* · id · target · 외부 자원 속성은 없다 +const Set _globalAttributes = { + 'style', 'class', 'title', 'dir', 'lang', 'align', 'valign', 'width', // + 'height', 'bgcolor', 'color', 'border', 'cellpadding', 'cellspacing', // + 'colspan', 'rowspan', 'span', 'face', 'size', 'nowrap', 'frame', 'rules', // + 'summary', 'abbr', 'headers', 'scope', 'hspace', 'vspace', 'clear', // + 'noshade', 'datetime', +}; + +/// 태그마다 더 남기는 속성 +const Map> _tagAttributes = >{ + 'a': {'href'}, + 'img': {'src', 'alt'}, + 'ol': {'start', 'type', 'reversed'}, + 'ul': {'type'}, + 'li': {'type', 'value'}, + 'details': {'open'}, +}; + +/// 속성 값의 최대 길이 — 넘으면 그 속성을 뺀다(`style` 은 표 꾸밈이 길 수 있어 넉넉히 · 주소는 따로) +const int _maxAttributeLength = 1000; +const int _maxStyleLength = 4000; + +/// 링크 주소의 최대 길이 — 중계 주소 한도(2,048자)와 같다 +const int _maxHrefLength = 2048; + +/// 앱이 정화하는 서식 본문의 최대 길이(글자) — 서버의 읽기 한도와 같다(계약 2절 "읽기 한도" — 2,000,000) +const int kMaxSanitizedHtmlLength = 2000000; + +/// 앱이 정화하는 서식 본문의 최대 중첩 비용 — 태그마다 그때의 깊이를 더한 값([htmlNestingCost]). +/// +/// HTML5 파서(`package:html`)는 태그마다 열린 요소를 훑어(범위 검사) 깊이에 비례해 일하므로 깊은 본문은 제곱으로 느려진다 +/// (2026-11-06 이 PC 의 시험 판: 깊이 200 · 2MB 2.2초, 깊이 600 · 2MB 6.4초, div 5,000단 55KB 3.8초 — 서버는 2만 단까지 넘긴다). +/// 이 값이면 정화가 2MB 표 한 통(1.5초)보다 2초 남짓 더 걸리는 데서 멈춘다. 넘으면 서식 없이 글자 본문을 보인다 +/// (서버의 `isHtmlBodyOmitted` 와 같은 안내 — 50단 안팎의 보통 답장 스레드는 한참 아래다) +const int kMaxHtmlNestingCost = 8000000; + +/// 정화한 본문 조각 — `` 안에 넣는다 +class SanitizedMailBody { + const SanitizedMailBody({ + required this.fragment, + required this.proxiedTokens, + this.htmlOmitted = false, + }); + + /// 정화한 HTML 조각 — 중계 그림은 ` proxiedTokens; + + /// 서식 본문이 앱의 정화 한도(길이 · 중첩)를 넘어 글자 본문을 보인다 — 화면은 서버의 생략과 같은 안내를 보인다 + final bool htmlOmitted; +} + +/// 보여 줄 서식 본문 — null 이거나 공백뿐이면 없다(null). 그때는 textBody 를 보인다(웹 `displayedHtmlBody` · 서버와 같은 규칙) +String? displayedHtmlBody(String? htmlBody) => + htmlBody != null && htmlBody.trim().isNotEmpty ? htmlBody : null; + +/// 본문 조각을 만든다 — HTML 이 있으면 정화하고, 없으면(공백뿐 포함) 텍스트를 이스케이프한다. +/// [imagesShown] 이면 서버의 중계 주소 그림을 남긴다(그 밖의 바깥 주소 · 상대 경로 그림은 어느 때나 지운다). +/// 서식 본문이 길이 · 중첩 한도([kMaxSanitizedHtmlLength] · [kMaxHtmlNestingCost])를 넘으면 정화하지 않고 글자 본문을 보인다. +/// [textHighlights] 는 글 본문의 검색에서 맞은 곳(검색 결과에서 연 메일 — 14단계) — 글 본문을 보일 때만 `` 로 넣는다 +/// (서식 본문은 서버가 정화한 뒤 맞은 글 조각에 `` 를 넣어 보냈고, 앱의 정화가 그대로 남긴다) +SanitizedMailBody mailBodyFragment({ + required String? htmlBody, + required String? textBody, + required bool imagesShown, + List textHighlights = const [], +}) { + final String? html = displayedHtmlBody(htmlBody); + if (html == null) { + return _textFragment(textBody, highlights: textHighlights); + } + final String trimmed = html.trim(); + if (trimmed.length > kMaxSanitizedHtmlLength || + htmlNestingCost(trimmed, limit: kMaxHtmlNestingCost) > + kMaxHtmlNestingCost) { + return _textFragment( + textBody, + htmlOmitted: true, + highlights: textHighlights, + ); + } + return sanitizeMailHtml(trimmed, imagesShown: imagesShown); +} + +SanitizedMailBody _textFragment( + String? textBody, { + bool htmlOmitted = false, + List highlights = const [], +}) => SanitizedMailBody( + fragment: + '
${highlightedTextHtml(textBody ?? '', highlights)}
', + proxiedTokens: const [], + htmlOmitted: htmlOmitted, +); + +/// 글 → 이스케이프한 HTML — 맞은 곳은 속성 없는 `` 로(웹 `mail-html.ts` 의 `textBodyHtml` 과 같다). +/// 위치는 그 글(강조를 받은 응답의 textBody) 기준이다 — 글 밖 · 거꾸로 된 위치는 버린다([highlightSegments]) +String highlightedTextHtml(String text, List highlights) { + if (highlights.isEmpty) return escapeHtmlText(text); + final StringBuffer out = StringBuffer(); + for (final HighlightSegment segment in highlightSegments(text, highlights)) { + final String escaped = escapeHtmlText(segment.text); + out.write(segment.mark ? '$escaped' : escaped); + } + return out.toString(); +} + +/// 정화 전 비용 — 태그(여는 · 닫는)마다 그때의 깊이를 더한다. 글자를 한 번만 훑고 [limit] 을 넘으면 그 자리에서 멈춘다. +/// +/// 파서보다 **깊게** 센다(넉넉한 쪽 — 덜 세면 깊은 본문이 정화를 오래 붙잡는다): +/// - 암묵 닫힘(`

` 뒤의 `

` 등)을 보지 않고, 원시 글(`'; + + test('직렬화 · 글 · 태그로 찾기가 package:html 과 같다', () { + final dom.Element body = html_parser.parse(sample).body!; + expect(innerHtmlOf(body), body.innerHtml); + final dom.Element div = body.children.single; + expect(outerHtmlOf(div), div.outerHtml); + expect(nodeTextOf(div), div.text); + expect(descendantElements(body, 'p'), body.querySelectorAll('p')); + }); + + test('2만 단으로 겹친 요소도 스택이 넘치지 않는다', () { + const int depth = 20000; + // 파서는 깊이의 제곱으로 느리므로 요소를 손으로 잇는다 + final dom.Element body = dom.Element.tag('body'); + dom.Element parent = body; + for (int level = 0; level < depth; level++) { + final dom.Element child = dom.Element.tag('div'); + parent.append(child); + parent = child; + } + parent.append(dom.Text('깊은 글')); + final String html = innerHtmlOf(body); + expect(html, startsWith('

')); + expect(html.length, depth * '
'.length + '깊은 글'.length); + expect(nodeTextOf(body), '깊은 글'); + expect(descendantElements(body, 'div'), hasLength(depth)); + }); + }); + + group('저장본 읽기 — 읽기 화면과 같은 한도 · 문턱(M1)', () { + test('읽을 곳 — 짧고 얕으면 그 자리, 길거나 깊으면 isolate, 한도를 넘으면 읽지 않는다', () { + expect( + draftBodyPlaceOf(bodyHtml: '

짧은 글

', body: ''), + DraftBodyPlace.inline, + ); + expect( + draftBodyPlaceOf(bodyHtml: null, body: '글'), + DraftBodyPlace.inline, + ); + expect( + draftBodyPlaceOf( + bodyHtml: '

${'가' * kMailBodyInlineMaxLength}

', + body: '', + ), + DraftBodyPlace.isolate, + ); + // 짧아도 깊으면(중첩 비용이 그 자리 문턱을 넘으면) isolate + expect( + draftBodyPlaceOf(bodyHtml: _nestedDivs(120, 'x'), body: ''), + DraftBodyPlace.isolate, + ); + expect( + draftBodyPlaceOf(bodyHtml: _nestedDivs(20000, 'x'), body: ''), + DraftBodyPlace.oversized, + ); + expect( + draftBodyPlaceOf( + bodyHtml: '

${'가' * kMaxSanitizedHtmlLength}

', + body: '', + ), + DraftBodyPlace.oversized, + ); + }); + + test('2만 단 저장본 — 읽지 않고 남의 저장본으로: 본문 칸은 텍스트 본문, 원래 HTML 은 그대로 쥔다', () async { + final String html = _nestedDivs(20000, '깊은 글'); + final DraftBody parsed = await buildDraftBody( + bodyHtml: ' $html ', + body: '깊은 글\n', + ); + expect(parsed, isA()); + final ForeignDraftBody foreign = parsed as ForeignDraftBody; + expect(foreign.text, '깊은 글'); + expect(foreign.originalHtml, html); + expect(foreign.originalText, '깊은 글\n'); + expect(foreign.hasFormatting, isTrue); + expect(foreign.markedHtml, isNull); + expect(foreign.quote, isNull); + }); + + test('깊은 원문 인용이 든 저장본 — 다른 isolate 에서 읽고 원래 인용 HTML 을 쥔다', () async { + final String html = + '

앞 글

${_mailQuote(_nestedDivs(1500, '깊은 원문'))}
'; + expect( + draftBodyPlaceOf(bodyHtml: html, body: ''), + DraftBodyPlace.isolate, + ); + final DraftBody parsed = await buildDraftBody(bodyHtml: html, body: ''); + expect(parsed, isA()); + final AppDraftBody app = parsed as AppDraftBody; + expect(app.text, '앞 글'); + expect(app.quote!.header.single.value, '이도윤'); + expect(plainLinesOf(app.quote!.blocks), ['깊은 원문']); + expect(app.quote!.rawHtml, startsWith('

원본 메일

')); + }); + + test('바로 읽어도(한도 검사를 거치지 않아도) 깊은 인용 · 서명에서 스택이 넘치지 않는다', () { + final String html = + '

글

${_nestedDivs(2000, '서명')}
' + '${_mailQuote(_nestedDivs(2000, '원문'))}
'; + final DraftBody parsed = draftBodyOf(bodyHtml: html, body: ''); + expect(parsed, isA()); + final AppDraftBody app = parsed as AppDraftBody; + expect(app.signature?.parts, const [ + SignatureTextPart('서명'), + ]); + expect( + app.quote!.rawHtml!.length, + greaterThan(2000 * '
'.length), + ); + }); + }); + + group('처음 맞추기 · 줄 자르기(L2)', () { + test('처음 인용은 덩이 만들기와 함께 내 글 몫(5%)을 남기고 맞춘다', () { + final ComposeQuote quote = initialQuoteOf( + ComposeQuoteRequest( + htmlBody: null, + textBody: List.filled(3000, '가' * 50).join('\n'), + isBodyTruncated: false, + header: _header, + summary: '이도윤', + signatureText: '김아라', + signatureHtml: '

김아라

', + ), + ); + expect(quote.omitted, isTrue); + expect(quote.blocks.length, lessThan(3000)); + final MailBodyOutput output = buildMailBody( + ComposeBodyInput( + text: '', + signatureHtml: '

김아라

', + signatureText: '김아라', + quote: quote, + ), + ); + expect(BodyLimits.initial.fits(output), isTrue); + + final ComposeQuote small = initialQuoteOf( + const ComposeQuoteRequest( + htmlBody: '

짧은 원문

', + textBody: '짧은 원문', + isBodyTruncated: true, + header: _header, + summary: '이도윤', + ), + ); + expect(plainLinesOf(small.blocks), ['짧은 원문']); + expect(small.omitted, isTrue); + }); + + for (final (String name, String line) in <(String, String)>[ + ('이스케이프되는 글자(& 5칸)', '&' * 200000), + ('탭(8칸까지 공백)', '${'\t' * 70000}끝'), + ('섞인 글', List.filled(40000, 'a&\t<😀 ').join()), + ]) { + test('긴 한 줄은 들어가는 만큼만 — 한 글자만 더 담아도 넘친다($name)', () { + final ComposeQuote quote = ComposeQuote( + header: _header, + blocks: [QuoteLine(line)], + summary: '', + ); + final BodyFit fit = fitMailBody( + ComposeBodyInput(text: '', quote: quote), + BodyLimits.full, + ); + expect(BodyLimits.full.fits(fit.output!), isTrue); + final String kept = (fit.quote!.blocks.single as QuoteLine).text; + expect(kept.length, lessThan(line.length)); + final int last = kept.codeUnitAt(kept.length - 1); + expect(last >= 0xD800 && last <= 0xDBFF, isFalse); + // 다음 글자(짝 글자면 둘)까지 담으면 넘친다 + final int next = + kept.length + + (line.codeUnitAt(kept.length) >= 0xD800 && + line.codeUnitAt(kept.length) <= 0xDBFF + ? 2 + : 1); + final MailBodyOutput longer = buildMailBody( + ComposeBodyInput( + text: '', + quote: quote.withBlocks([ + QuoteLine(line.substring(0, next)), + ], omitted: true), + ), + ); + expect(BodyLimits.full.fits(longer), isFalse); + }); + } + }); + + group('제목 길이 — UTF-16 단위(L3 · 서버 · 웹과 같다)', () { + test('truncateUtf16 — 짝 글자를 가르지 않는다', () { + expect(truncateUtf16('a' * 300, 255), 'a' * 255); + expect(truncateUtf16('${'a' * 254}😀', 255), 'a' * 254); + expect(truncateUtf16('짧은 글', 255), '짧은 글'); + }); + + TextEditingValue typed( + String text, { + TextRange composing = TextRange.empty, + }) => TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), + composing: composing, + ); + + test('포매터 — 붙여 넣은 글은 뒤를 자르고, 이모지는 두 칸으로 센다', () { + const Utf16LengthLimitFormatter formatter = Utf16LengthLimitFormatter(10); + expect( + formatter.formatEditUpdate(typed(''), typed('a' * 30)).text, + 'a' * 10, + ); + expect( + formatter.formatEditUpdate(typed(''), typed('😀' * 8)).text, + '😀' * 5, + ); + expect( + formatter.formatEditUpdate(typed('a' * 9), typed('${'a' * 9}😀')).text, + 'a' * 9, + ); + }); + + test('포매터 — 한도만큼 찼으면 더 치는 글을 받지 않고, 한글을 조합하는 동안은 막지 않는다', () { + const Utf16LengthLimitFormatter formatter = Utf16LengthLimitFormatter(5); + final TextEditingValue full = typed('abcde'); + expect(formatter.formatEditUpdate(full, typed('abXcde')), full); + final TextEditingValue composing = typed( + 'abcde한', + composing: const TextRange(start: 5, end: 6), + ); + expect(formatter.formatEditUpdate(full, composing), composing); + // 조합이 끝나면 자른다 + expect( + formatter.formatEditUpdate(composing, typed('abcde한')).text, + 'abcde', + ); + }); + }); +} diff --git a/flutter/test/features/mail/compose_repository_test.dart b/flutter/test/features/mail/compose_repository_test.dart new file mode 100644 index 0000000..ac5eb97 --- /dev/null +++ b/flutter/test/features/mail/compose_repository_test.dart @@ -0,0 +1,591 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:arasystem_app/core/api/api_paths.dart'; +import 'package:arasystem_app/core/people/person_address.dart'; +import 'package:arasystem_app/core/people/recipient_suggestion.dart'; +import 'package:arasystem_app/features/mail/data/models/compose_models.dart'; +import 'package:arasystem_app/features/mail/data/models/compose_payload.dart'; +import 'package:arasystem_app/features/mail/data/models/message_detail.dart'; +import 'package:arasystem_app/features/mail/data/repositories/compose_repository.dart'; +import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/fakes.dart'; +import '../../support/mail_fakes.dart'; + +const MailComposePayload _payload = MailComposePayload( + to: [PersonAddress(email: 'doyun@x.kr', name: '이도윤')], + cc: [], + bcc: [], + subject: '계약', + body: '본문', + bodyHtml: '
본문
', + draftId: 'd1', +); + +Map _savedJson(String id) => { + 'id': id, + 'attachments': [attachmentJson('att-$id')], + 'updatedAt': '2026-11-07T00:00:00.000Z', +}; + +/// 쓰기 화면의 저장소 — 계약 2 · 3 · 4절의 경로 · 본문 · 응답 읽기 (인터셉터 줄을 그대로 탄다) +void main() { + late ApiHarness h; + late ComposeRepository repository; + + setUp(() async { + h = ApiHarness(); + await h.prepare(); + await h.signIn(); + repository = ComposeRepository(h.dio); + }); + + test( + '저장 — 처음은 POST /drafts, 그다음은 PUT /drafts/{id}. 받기 한도 60초 · 알리지 않는다', + () async { + h.server.on( + 'POST', + ComposeApiPaths.drafts, + (_) => FakeResponse.ok(_savedJson('d2')), + ); + h.server.on( + 'PUT', + '/drafts/d%2F1', + (_) => FakeResponse.ok(_savedJson('d3')), + ); + + final DraftSaved created = await repository.createDraft( + _payload.withoutDraftId(), + ); + expect(created.id, 'd2'); + expect(created.attachments.single.id, 'att-d2'); + final RequestOptions post = h.server.requests.single; + expect(post.data, _payload.withoutDraftId().toJson()); + expect(post.receiveTimeout, kMailSendTimeout); + expect(bearerTokenOf(post), 'access-1'); + + final DraftSaved updated = await repository.updateDraft( + 'd/1', + _payload.withoutDraftId(), + ); + expect(updated.id, 'd3'); + + h.server.on( + 'PUT', + '/drafts/gone', + (_) => FakeResponse.fail(409, 'MAIL_008'), + ); + expect( + apiErrorOf( + await errorOf(repository.updateDraft('gone', _payload)), + ).code, + 'MAIL_008', + ); + expect(h.notifier.shownCodes, isEmpty); + }, + ); + + test('저장본 받기 — 모양이 틀리면 SYS_001, 실패는 알리지 않는다(화면이 [다시 시도]를 그린다)', () async { + h.server.on( + 'GET', + '/drafts/d1', + (_) => FakeResponse.ok({'id': 'd1'}), + ); + expect( + apiErrorOf(await errorOf(repository.getDraft('d1'))).code, + 'SYS_001', + ); + h.server.on('GET', '/drafts/d2', (_) => FakeResponse.fail(503, 'MAIL_001')); + expect( + apiErrorOf(await errorOf(repository.getDraft('d2'))).code, + 'MAIL_001', + ); + expect(h.notifier.shownCodes, isEmpty); + }); + + test( + '보내기 — Idempotency-Key · 받기 한도 60초, 칸 안에 보일 코드만 알리지 않는다, 서버 시각을 준다', + () async { + final DateTime serverNow = DateTime.utc(2026, 11, 7, 9); + h.server.serverTime = serverNow; + h.server.on( + 'POST', + MailApiPaths.messages, + (_) => FakeResponse(201, { + 'success': true, + 'data': { + 'id': 'sent-1', + 'rejectedRecipients': [ + {'email': 'a@x.kr', 'reason': 'not-found'}, + ], + 'pendingSend': pendingJson( + serverNow.add(const Duration(seconds: 10)), + ), + }, + }), + ); + + final ({SentMessage sent, DateTime? serverTime}) result = await repository + .sendMessage( + _payload, + idempotencyKey: 'key-1', + silentCodes: const {'MAIL_002'}, + ); + + expect(result.sent.id, 'sent-1'); + expect( + result.sent.rejectedRecipients.single.reason, + RejectedReason.notFound, + ); + expect( + result.sent.pendingSend?.sendAt, + serverNow.add(const Duration(seconds: 10)), + ); + expect(result.serverTime, serverNow); + final RequestOptions sent = h.server.requests.single; + expect(sent.headers[kIdempotencyHeader], 'key-1'); + expect(sent.receiveTimeout, kMailSendTimeout); + expect(sent.data, _payload.toJson()); + + h.server.on( + 'POST', + MailApiPaths.messages, + (_) => FakeResponse.fail(400, 'MAIL_002'), + ); + expect( + apiErrorOf( + await errorOf( + repository.sendMessage( + _payload, + idempotencyKey: 'key-1', + silentCodes: const {'MAIL_002'}, + ), + ), + ).code, + 'MAIL_002', + ); + expect(h.notifier.shownCodes, isEmpty); + + h.server.on( + 'POST', + MailApiPaths.messages, + (_) => FakeResponse.fail(503, 'MAIL_001'), + ); + await errorOf( + repository.sendMessage( + _payload, + idempotencyKey: 'key-1', + silentCodes: const {'MAIL_002'}, + ), + ); + expect(h.notifier.shownCodes, ['MAIL_001']); + }, + ); + + test('토큰이 끝나 401 이면 갱신한 뒤 같은 키로 다시 보낸다(서버가 두 번 내보내지 않게)', () async { + int next = 2; + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.ok({ + ...sessionJson(), + 'tokens': tokensJson(next++, now: h.clock.now), + }), + ); + h.server.on( + 'POST', + MailApiPaths.messages, + (RequestOptions options) => bearerTokenOf(options) == 'access-2' + ? const FakeResponse(201, { + 'success': true, + 'data': {'id': 'sent-1'}, + }) + : FakeResponse.fail(401, 'AUTH_001'), + ); + + final ({SentMessage sent, DateTime? serverTime}) result = await repository + .sendMessage(_payload, idempotencyKey: 'key-9'); + + expect(result.sent.id, 'sent-1'); + expect(result.sent.pendingSend, isNull); + final List sends = h.server.received( + 'POST', + MailApiPaths.messages, + ); + expect(sends, hasLength(2)); + expect( + sends.map((RequestOptions r) => r.headers[kIdempotencyHeader]), + ['key-9', 'key-9'], + ); + }); + + test('서명 · 서명 그림 · 보내기 설정 — 알리지 않는다, 그림은 한도를 넘으면 끊는다', () async { + h.server.on( + 'GET', + ComposeApiPaths.mailSignature, + (_) => FakeResponse.ok({ + 'bodyHtml': '

김아라

', + 'body': '김아라', + 'isForNew': true, + 'isForReply': false, + 'updatedAt': '2026-11-01T00:00:00.000Z', + }), + ); + final MailSignature signature = await repository.getSignature(); + expect(signature.bodyHtml, '

김아라

'); + expect(signature.isForReply, isFalse); + + h.server.on( + 'GET', + ComposeApiPaths.signatureImage('img-1'), + (_) => FakeResponse.bytes(kTinyPng, contentType: 'image/png'), + ); + final Uint8List bytes = await repository.fetchSignatureImage( + 'img-1', + maxBytes: 1024, + ); + expect(bytes, kTinyPng); + expect(bearerTokenOf(h.server.requests.last), 'access-1'); + await errorOf(repository.fetchSignatureImage('img-1', maxBytes: 4)); + + h.server.on( + 'GET', + ComposeApiPaths.mailSettings, + (_) => FakeResponse.ok({'undoSendSeconds': 10}), + ); + expect(await repository.undoSendSeconds(), 10); + h.server.on( + 'GET', + ComposeApiPaths.mailSettings, + (_) => FakeResponse.ok({}), + ); + expect( + apiErrorOf(await errorOf(repository.undoSendSeconds())).code, + 'SYS_001', + ); + expect(h.notifier.shownCodes, isEmpty); + }); + + test( + '받는 사람 추천 · 그룹 · 구성원(다음 쪽까지) — 추천 · 그룹은 알리지 않는다, 고른 그룹의 실패는 알린다', + () async { + h.server.on( + 'GET', + ComposeApiPaths.recipientSuggestions, + (_) => FakeResponse.ok({ + 'items': [ + { + 'name': '이도윤', + 'email': 'doyun@x.kr', + 'source': 'directory', + }, + {'email': 'x'}, + {'name': 1, 'email': 'bad@x.kr'}, + {'email': 'new@x.kr', 'source': 'future'}, + ], + 'pageInfo': {'hasNext': true, 'nextCursor': 'c'}, + }), + ); + final RecipientSuggestionPage page = await repository + .listRecipientSuggestions(query: '도윤', limit: 16); + expect(page.items.map((RecipientSuggestion s) => s.email), [ + 'doyun@x.kr', + 'x', + 'new@x.kr', + ]); + expect(page.items.last.source, RecipientSource.recent); + expect(page.hasNext, isTrue); + expect(h.server.requests.last.queryParameters, { + 'q': '도윤', + 'limit': 16, + }); + + await repository.listRecipientSuggestions(); + expect(h.server.requests.last.queryParameters, { + 'limit': kRecipientSuggestionPreload, + }); + + h.server.on( + 'GET', + ComposeApiPaths.contactGroups, + (_) => FakeResponse.ok({ + 'items': [ + {'id': 'g1', 'name': '거래처', 'memberCount': 3}, + ], + 'pageInfo': {'hasNext': false, 'nextCursor': null}, + }), + ); + expect((await repository.listContactGroups()).single.name, '거래처'); + + h.server.on('GET', ComposeApiPaths.contacts, (RequestOptions options) { + final bool first = options.queryParameters['cursor'] == null; + return FakeResponse.ok({ + 'items': [ + { + 'name': first ? '박지훈' : '최서연', + 'emails': [ + { + 'address': first ? 'jihoon@x.kr' : 'seoyeon@x.kr', + 'label': 'work', + }, + ], + }, + if (!first) + {'name': '주소 없음', 'emails': []}, + ], + 'pageInfo': { + 'hasNext': first, + 'nextCursor': first ? 'p2' : null, + }, + }); + }); + final List members = await repository.listGroupMembers('g1'); + expect(members, [ + (name: '박지훈', email: 'jihoon@x.kr'), + (name: '최서연', email: 'seoyeon@x.kr'), + (name: '주소 없음', email: null), + ]); + final List pages = h.server.received( + 'GET', + ComposeApiPaths.contacts, + ); + expect(pages.first.queryParameters, { + 'groupId': 'g1', + 'limit': kContactsPageSize, + }); + expect(pages.last.queryParameters['cursor'], 'p2'); + + h.server.on( + 'GET', + ComposeApiPaths.contacts, + (_) => FakeResponse.fail(503, 'MAIL_001'), + ); + await errorOf(repository.listGroupMembers('g1')); + expect(h.notifier.shownCodes, ['MAIL_001']); + }, + ); + + test('대용량 파일 상태 — 없으면 RES_001(알리지 않는다)', () async { + h.server.on( + 'GET', + ComposeApiPaths.fileUpload('u1'), + (_) => FakeResponse.ok({ + 'id': 'u1', + 'status': 'ready', + 'name': 'a.zip', + 'size': 5, + 'scanStatus': 'clean', + }), + ); + final DraftLargeFile file = await repository.getLargeFile('u1'); + expect(file.status, DraftLargeFileStatus.ready); + expect(file.infected, isFalse); + expect( + apiErrorOf(await errorOf(repository.getLargeFile('u2'))).code, + 'RES_001', + ); + expect(h.notifier.shownCodes, isEmpty); + }); + + group('첨부 올리기 · 첨부 한도 (계약 2절)', () { + Map created( + String id, + String name, + String type, + int size, + ) => { + 'success': true, + 'data': attachmentJson(id, name: name, type: type, size: size), + }; + + test( + 'POST /mail-attachments — multipart file 하나(보이는 이름 · 확장자의 정식 형식 · 길이), 보내기 5분 · 받기 60초 · 알리지 않는다 · 보낸 진행', + () async { + h.server.readRequestBodies = true; + h.server.on( + 'POST', + ComposeApiPaths.mailAttachments, + (_) => FakeResponse( + 201, + created('att-1', '견적서 (최종).hwp', 'application/x-hwp', 5), + ), + ); + final List<(int, int)> progress = <(int, int)>[]; + + final MessageAttachment uploaded = await repository.uploadAttachment( + name: '견적서 (최종).hwp', + type: 'application/x-hwp', + length: 5, + read: () => Stream>.fromIterable(>[ + [0x41, 0x42], + [0x43, 0x44, 0x45], + ]), + cancelToken: CancelToken(), + onProgress: (int sent, int total) => progress.add((sent, total)), + ); + + expect(uploaded.id, 'att-1'); + expect(uploaded.size, 5); + final RequestOptions post = h.server.requests.single; + expect(post.sendTimeout, kAttachmentUploadSendTimeout); + expect(post.receiveTimeout, kAttachmentUploadReceiveTimeout); + expect(bearerTokenOf(post), 'access-1'); + final MapEntry file = + (post.data! as FormData).files.single; + expect(file.key, kAttachmentFileField); + expect(file.value.filename, '견적서 (최종).hwp'); + expect(file.value.contentType.toString(), 'application/x-hwp'); + expect(file.value.length, 5); + // 본문 — 한글 이름은 UTF-8 그대로(백엔드 multer `defParamCharset: utf8`), 내용은 다시 읽은 바이트 + final String body = utf8.decode(h.server.requestBodies.single.body); + expect( + body, + contains( + 'content-disposition: form-data; name="file"; filename="견적서 (최종).hwp"\r\n' + 'content-type: application/x-hwp\r\n\r\nABCDE\r\n', + ), + ); + expect(progress, isNotEmpty); + expect(progress.last.$1, progress.last.$2); + expect(h.notifier.shownCodes, isEmpty); + }, + ); + + test( + '실패는 알리지 않는다(화면이 그 줄에 보인다) · 취소하면 cancel · 모양이 틀린 응답은 SYS_001', + () async { + h.server.on( + 'POST', + ComposeApiPaths.mailAttachments, + (_) => FakeResponse.fail(400, 'FILE_001'), + ); + Future upload(CancelToken cancel) => + repository.uploadAttachment( + name: 'a.pdf', + type: 'application/pdf', + length: 1, + read: () => Stream>.value([1]), + cancelToken: cancel, + onProgress: (int sent, int total) {}, + ); + expect( + apiErrorOf(await errorOf(upload(CancelToken()))).code, + 'FILE_001', + ); + expect(h.notifier.shownCodes, isEmpty); + + final Completer gate = Completer(); + h.server.on('POST', ComposeApiPaths.mailAttachments, (_) async { + await gate.future; + return FakeResponse( + 201, + created('att-2', 'a.pdf', 'application/pdf', 1), + ); + }); + final CancelToken cancel = CancelToken(); + final Future canceled = errorOf(upload(cancel)); + cancel.cancel(); + final Object error = await canceled; + expect((error as DioException).type, DioExceptionType.cancel); + gate.complete(); + + h.server.on( + 'POST', + ComposeApiPaths.mailAttachments, + (_) => const FakeResponse(201, { + 'success': true, + 'data': {'id': 'x'}, + }), + ); + expect( + apiErrorOf(await errorOf(upload(CancelToken()))).code, + 'SYS_001', + ); + }, + ); + + test('토큰이 끝나 401 이면 갱신한 뒤 같은 파일을 처음부터 다시 보낸다(FormData 복제)', () async { + int next = 2; + h.server.readRequestBodies = true; + h.server.on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.ok({ + ...sessionJson(), + 'tokens': tokensJson(next++, now: h.clock.now), + }), + ); + h.server.on( + 'POST', + ComposeApiPaths.mailAttachments, + (RequestOptions options) => bearerTokenOf(options) == 'access-2' + ? FakeResponse(201, created('att-3', 'a.png', 'image/png', 3)) + : FakeResponse.fail(401, 'AUTH_001'), + ); + int reads = 0; + final MessageAttachment uploaded = await repository.uploadAttachment( + name: 'a.png', + type: 'image/png', + length: 3, + read: () { + reads++; + return Stream>.value([7, 8, 9]); + }, + cancelToken: CancelToken(), + onProgress: (int sent, int total) {}, + ); + expect(uploaded.id, 'att-3'); + final List<({String path, List body})> bodies = h + .server + .requestBodies + .where( + (({String path, List body}) b) => + b.path == ComposeApiPaths.mailAttachments, + ) + .toList(); + expect(bodies, hasLength(2)); + expect(bodies[1].body, bodies[0].body); + expect(reads, 2); + }); + + test( + '첨부 한도 — GET /mail-quota 의 파일당 · 합계, 알리지 않는다 · 모양이 틀리면 SYS_001', + () async { + h.server.on( + 'GET', + ComposeApiPaths.mailQuota, + (_) => FakeResponse.ok({ + 'usedBytes': 10, + 'limitBytes': null, + 'attachmentMaxBytes': 30, + 'attachmentTotalMaxBytes': 40, + }), + ); + expect( + await repository.getAttachmentLimits(), + const AttachmentLimits(maxBytes: 30, totalMaxBytes: 40), + ); + h.server.on( + 'GET', + ComposeApiPaths.mailQuota, + (_) => FakeResponse.ok({'usedBytes': 10}), + ); + expect( + apiErrorOf(await errorOf(repository.getAttachmentLimits())).code, + 'SYS_001', + ); + h.server.on( + 'GET', + ComposeApiPaths.mailQuota, + (_) => FakeResponse.fail(503, 'MAIL_001'), + ); + await errorOf(repository.getAttachmentLimits()); + expect(h.notifier.shownCodes, isEmpty); + }, + ); + }); +} diff --git a/flutter/test/features/mail/compose_rules_test.dart b/flutter/test/features/mail/compose_rules_test.dart new file mode 100644 index 0000000..9efeba3 --- /dev/null +++ b/flutter/test/features/mail/compose_rules_test.dart @@ -0,0 +1,921 @@ +import 'dart:math' as math; + +import 'package:arasystem_app/core/people/person_address.dart'; +import 'package:arasystem_app/features/mail/data/models/compose_models.dart'; +import 'package:arasystem_app/features/mail/data/models/compose_payload.dart'; +import 'package:arasystem_app/features/mail/data/models/message_detail.dart'; +import 'package:arasystem_app/features/mail/domain/compose_attachments.dart'; +import 'package:arasystem_app/features/mail/domain/compose_body.dart'; +import 'package:arasystem_app/features/mail/domain/compose_draft_body.dart'; +import 'package:arasystem_app/features/mail/domain/compose_fit.dart'; +import 'package:arasystem_app/features/mail/domain/compose_reply.dart'; +import 'package:arasystem_app/features/mail/domain/compose_send.dart'; +import 'package:arasystem_app/features/mail/domain/compose_signature.dart'; +import 'package:arasystem_app/features/mail/domain/mail_quote.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/mail_fakes.dart'; + +const String _nbsp = '\u00A0'; +const String _wrapper = + '
'; +const String _p = '

'; +const String _mailQuoteOpen = + '

'; +const String _nestOpen = + '
'; + +const List _header = [ + QuoteHeaderRow('보낸 사람', '이도윤 '), + QuoteHeaderRow('날짜', '2026. 11. 5. (목) 오전 10:42'), + QuoteHeaderRow('제목', '계약'), +]; + +const ComposeQuote _quote = ComposeQuote( + header: _header, + blocks: [ + QuoteLine('안녕하세요'), + QuoteLine(''), + QuoteNest([QuoteLine('이전 글')]), + ], + summary: '이도윤 · 11월 5일 (목) 오전 10:42', +); + +/// 시간대와 상관없이 같은 벽시계 — 지역 시각을 UTC 글로(읽으면 다시 그 지역 시각) +String _localIso(int year, int month, int day, int hour, int minute) => + DateTime(year, month, day, hour, minute).toUtc().toIso8601String(); + +MessageDetail _detail(Map overrides) => + MessageDetail.tryParse({ + ...messageJson('m1', mailboxId: kInboxId), + 'cc': [], + 'bcc': [], + 'replyTo': [], + 'htmlBody': null, + 'textBody': '본문', + 'hasBlockedImages': false, + 'attachments': [], + ...overrides, + })!; + +void main() { + group('보내는 본문 — 서식 본문 + 텍스트 (짝: 웹 mail-serialize.ts)', () { + test('내 글만 — 줄마다 문단, 빈 줄은
, 끝의 빈 줄은 보내지 않는다', () { + final MailBodyOutput output = buildMailBody( + const ComposeBodyInput(text: 'a\n\nb\n\n'), + ); + expect(output.html, '$_wrapper${_p}a

$_p

${_p}b

'); + expect(output.text, 'a\n\nb'); + expect(output.quoteStart, isNull); + }); + + test('쓴 글이 없으면 텍스트 메일(서식 본문 없음)', () { + final MailBodyOutput output = buildMailBody( + const ComposeBodyInput(text: '\n \n'), + ); + expect(output.html, isNull); + expect(output.text, ''); + }); + + test('글은 이스케이프한다 — 꺾쇠 · & 가 태그가 되지 않는다', () { + expect( + buildMailBody(const ComposeBodyInput(text: 'x < y & z')).html, + '$_wrapper${_p}x < y & <b>z</b>

', + ); + }); + + test('여러 칸 띄어쓰기 · 줄 앞 공백 · 탭은 nbsp 와 번갈아(웹 preserveSpaces 와 같은 글)', () { + expect( + preserveSpaces(' 1. 일시 : 10월 27일'), + '$_nbsp 1. 일시 $_nbsp $_nbsp: 10월 27일', + ); + expect(preserveSpaces('\tx'), '$_nbsp $_nbsp $_nbsp $_nbsp x'); + expect(preserveSpaces('ab\ty'), 'ab $_nbsp $_nbsp ${_nbsp}y'); + expect(preserveSpaces('한 칸 띄어쓰기'), '한 칸 띄어쓰기'); + // 텍스트 본문의 공백은 그대로다 + expect( + buildMailBody(const ComposeBodyInput(text: ' 들여 쓴 줄')).text, + ' 들여 쓴 줄', + ); + }); + + test('서명 — 빈 문단 하나 뒤 속성 없는 경계 div, 텍스트는 빈 줄 뒤 텍스트 서명', () { + final MailBodyOutput output = buildMailBody( + const ComposeBodyInput( + text: 'hi', + signatureHtml: '$_p김아라

', + signatureText: '김아라\n경영지원팀', + ), + ); + expect( + output.html, + '$_wrapper${_p}hi

$_p

$_p김아라

', + ); + expect(output.text, 'hi\n\n김아라\n경영지원팀'); + }); + + test('원문 인용 — "원본 메일" 문단 + 머리 표 + 문단 · 안쪽 인용, 텍스트는 "> " 줄', () { + final MailBodyOutput output = buildMailBody( + const ComposeBodyInput(text: 'ok', quote: _quote), + ); + const String label = + ''; + const String value = ''; + expect( + output.html, + '$_wrapper${_p}ok

' + '$_mailQuoteOpen' + '

원본 메일

' + '' + '$label보낸 사람$value이도윤 <doyun@x.kr>' + '$label날짜${value}2026. 11. 5. (목) 오전 10:42' + '$label제목$value계약' + '
' + '$_p안녕하세요

$_p

$_nestOpen$_p이전 글

' + '', + ); + expect( + output.text, + 'ok\n' + '> 원본 메일\n' + '> 보낸 사람: 이도윤 \n' + '> 날짜: 2026. 11. 5. (목) 오전 10:42\n' + '> 제목: 계약\n' + '>\n' + '> 안녕하세요\n' + '>\n' + '> > 이전 글', + ); + expect(output.quoteStart, 'ok\n'.length); + expect(output.text.substring(output.quoteStart!), startsWith('> 원본 메일')); + expect(quoteStartInText(output.text), output.quoteStart); + }); + + test('대용량 파일이 있으면 원문 인용 바로 앞에 인용 경계 표시', () { + final String html = buildMailBody( + const ComposeBodyInput( + text: 'ok', + signatureHtml: '$_p서명

', + quote: _quote, + quoteStartMark: true, + ), + ).html!; + expect(html, contains('$kQuoteStartMarker$_mailQuoteOpen')); + }); + + test('줄인 인용은 끝에 생략 표시 · 남의 저장본의 인용은 원래 HTML 그대로', () { + final MailBodyOutput omitted = buildMailBody( + ComposeBodyInput( + text: '', + quote: _quote.withBlocks(const [ + QuoteLine('앞부분'), + ], omitted: true), + ), + ); + expect( + omitted.html, + endsWith('$_p앞부분

$_p$kQuoteOmittedMark

'), + ); + expect(omitted.text, endsWith('> 앞부분\n> $kQuoteOmittedMark')); + + const String raw = '

원본 메일

'; + final MailBodyOutput kept = buildMailBody( + const ComposeBodyInput( + text: 'x', + quote: ComposeQuote( + header: _header, + blocks: [QuoteLine('글')], + summary: '', + rawHtml: raw, + ), + ), + ); + expect(kept.html, '$_wrapper${_p}x

$raw'); + }); + }); + + group('길이 맞추기 (짝: 웹 fit-draft.ts)', () { + ComposeQuote longQuote(int lines, {int width = 100}) => ComposeQuote( + header: _header, + blocks: [ + for (int index = 0; index < lines; index++) QuoteLine('가' * width), + ], + summary: '', + ); + + test('한도 안이면 그대로', () { + final BodyFit fit = fitMailBody( + const ComposeBodyInput(text: 'ok', quote: _quote), + BodyLimits.full, + ); + expect(fit.quote, _quote); + expect(fit.output?.text, startsWith('ok\n> 원본 메일')); + }); + + test('처음 초안은 내 글 몫(5%)을 남긴다 — 95,000 · 475,000', () { + expect(BodyLimits.initial.text, 95000); + expect(BodyLimits.initial.html, 475000); + final BodyFit fit = fitMailBody( + ComposeBodyInput(text: '', quote: longQuote(2000)), + BodyLimits.initial, + ); + expect(fit.output!.text.length, lessThanOrEqualTo(95000)); + expect(fit.output!.html!.length, lessThanOrEqualTo(475000)); + expect(fit.quote!.omitted, isTrue); + expect(fit.quote!.blocks.length, lessThan(2000)); + expect(fit.output!.text, endsWith('> $kQuoteOmittedMark')); + }); + + test('보낼 때는 한도 그대로 다시 잰다 — 넘으면 인용을 뒤에서 덜어 낸다', () { + final BodyFit fit = fitMailBody( + ComposeBodyInput(text: '내 글' * 10000, quote: longQuote(2000)), + BodyLimits.full, + ); + expect(fit.output!.text.length, lessThanOrEqualTo(kMailBodyTextMax)); + expect(fit.output!.text, startsWith('내 글' * 10000)); + expect(fit.quote!.omitted, isTrue); + }); + + test('내 글만으로 넘으면 보내지 않는다(MAIL_007 — 결과 없음)', () { + expect( + fitMailBody( + ComposeBodyInput(text: 'a' * (kMailBodyTextMax + 1), quote: _quote), + BodyLimits.full, + ).output, + isNull, + ); + expect( + fitMailBody( + ComposeBodyInput(text: 'a' * (kMailBodyTextMax + 1)), + BodyLimits.full, + ).output, + isNull, + ); + }); + + test('긴 한 줄짜리 원문은 들어가는 만큼 글자 단위로 — 짝 글자를 가르지 않는다', () { + final BodyFit fit = fitMailBody( + ComposeBodyInput(text: '', quote: longQuote(1, width: 200000)), + BodyLimits.full, + ); + final QuoteLine line = fit.quote!.blocks.single as QuoteLine; + expect(line.text.length, greaterThan(90000)); + expect(fit.output!.text.length, lessThanOrEqualTo(kMailBodyTextMax)); + + final BodyFit emoji = fitMailBody( + ComposeBodyInput( + text: '', + quote: ComposeQuote( + header: _header, + blocks: [QuoteLine('😀' * 60000)], + summary: '', + ), + ), + BodyLimits.full, + ); + final String cut = (emoji.quote!.blocks.single as QuoteLine).text; + final int last = cut.codeUnitAt(cut.length - 1); + expect(last >= 0xD800 && last <= 0xDBFF, isFalse); + }); + + test('대용량 첨부 덩이 몫 — 파일마다 이름 + 24, 머리 600', () { + expect(largeFileBlockTextReserve(const []), 0); + expect( + largeFileBlockTextReserve(const ['a.zip', '계약.pdf']), + 600 + 5 + 24 + 6 + 24, + ); + expect(BodyLimits.full.reserveText(1000).text, kMailBodyTextMax - 1000); + }); + }); + + group('원문 → 인용 덩이 (화면에 보이는 글과 같게)', () { + List lines(String html) => plainLinesOf(quoteBlocksFromHtml(html)); + + test('블록 ·
은 줄, 이어진 공백은 한 칸, 앞뒤 빈 줄은 뺀다', () { + expect(lines('

안녕 하세요

둘째
셋째

'), [ + '안녕 하세요', + '둘째', + '셋째', + ]); + }); + + test('목록은 "- " · "1. ", 칸이 여럿인 표 줄은 " | ", 링크는 "글 (주소)"', () { + expect( + lines( + '
  • 사과
  • 배
  1. 셋
' + '
이름값
' + '

문서 · 위험

', + ), + [ + '- 사과', + '- 배', + '3. 셋', + '이름 | 값', + '문서 (https://ara.example/x) · 위험', + ], + ); + }); + + test('원문 속 인용은 안쪽 인용, 스크립트 · 스타일 · 그림의 글은 뺀다', () { + final List blocks = quoteBlocksFromHtml( + '

위

안쪽

' + '그림', + ); + expect(blocks, const [ + QuoteLine('위'), + QuoteNest([QuoteLine('안쪽')]), + ]); + expect(quoteDisplayLines(blocks), [ + (text: '위', depth: 0), + (text: '안쪽', depth: 1), + ]); + }); + + test('읽기 화면과 같은 본문 고르기 — 그림뿐인 서식 본문이면 글 본문, 잘라 보낸 본문이면 생략 표시', () { + final QuoteSource onlyImage = quoteSourceOf( + htmlBody: '

', + textBody: '글 본문\n둘째 줄', + isBodyTruncated: false, + ); + expect(plainLinesOf(onlyImage.blocks), ['글 본문', '둘째 줄']); + + final QuoteSource truncated = quoteSourceOf( + htmlBody: '

서식 본문

', + textBody: '글 본문', + isBodyTruncated: true, + ); + expect(plainLinesOf(truncated.blocks), ['서식 본문']); + expect(truncated.truncated, isTrue); + }); + }); + + group('저장본 다시 열기 (계약 2절 "앱이 만드는 모양")', () { + const String signatureHtml = '$_p김아라

$_p경영지원팀

'; + + test('앱이 만든 모양 — 내 글 · 서명 · 인용으로 되살린다(nbsp 는 공백으로)', () { + final MailBodyOutput output = buildMailBody( + const ComposeBodyInput( + text: '첫 줄\n 들여 쓴 줄\n\n끝', + signatureHtml: signatureHtml, + signatureText: '김아라', + quote: _quote, + ), + ); + final DraftBody parsed = draftBodyOf( + bodyHtml: output.html, + body: output.text, + ); + expect(parsed, isA()); + final AppDraftBody app = parsed as AppDraftBody; + expect(app.text, '첫 줄\n 들여 쓴 줄\n\n끝'); + expect(app.signature?.html, signatureHtml); + expect(app.signature?.parts, const [ + SignatureTextPart('김아라'), + SignatureTextPart('경영지원팀'), + ]); + expect(app.quote!.header, _header); + expect(app.quote!.blocks, _quote.blocks); + expect(app.quote!.rawHtml, isNotNull); + expect(app.quote!.summary, '이도윤 · 2026. 11. 5. (목) 오전 10:42'); + }); + + test('서버 정화로 style 띄어쓰기 · 경계 표시가 바뀌어도 앱 모양으로 알아본다', () { + // 서버의 보내는 정화가 내는 꼴 — style 은 "속성:값" 을 ; 로 잇고 data-* 는 지운다 + final String html = + buildMailBody( + const ComposeBodyInput( + text: '글', + signatureHtml: signatureHtml, + quote: _quote, + quoteStartMark: true, + ), + ).html! + .replaceAll( + kMailBodyWrapperStyle, + "font-family:'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif;font-size:14px;line-height:1.75", + ) + .replaceAll('margin: 0', 'margin:0') + .replaceAll(' data-ara-quote-start=""', ''); + final DraftBody parsed = draftBodyOf(bodyHtml: html, body: ''); + expect(parsed, isA()); + expect((parsed as AppDraftBody).text, '글'); + expect(parsed.quote, isNotNull); + }); + + test('서식이 있는 남의 저장본 — 인용 앞은 글로, 안내 줄을 보인다(원래 HTML 은 쥔다)', () { + const String html = + '

안녕 굵게

서명

' + '

원본 메일

보낸 사람이도윤 <d@x.kr>

원문

'; + final DraftBody parsed = draftBodyOf(bodyHtml: html, body: '원래 텍스트'); + expect(parsed, isA()); + final ForeignDraftBody foreign = parsed as ForeignDraftBody; + expect(foreign.text, '안녕 굵게\n서명'); + expect(foreign.hasFormatting, isTrue); + expect(foreign.originalHtml, html); + expect(foreign.originalText, '원래 텍스트'); + expect(foreign.quote!.header.single.value, '이도윤 '); + expect(foreign.quote!.summary, '이도윤'); + expect(foreign.quote!.rawHtml, isNotNull); + }); + + test('서식이 없는 남의 저장본은 안내하지 않는다 · 서식 본문이 없으면 텍스트 그대로', () { + final DraftBody plain = draftBodyOf( + bodyHtml: '
a

b

', + body: '', + ); + expect(plain, isA()); + expect((plain as ForeignDraftBody).hasFormatting, isFalse); + expect(plain.text, 'a\nb'); + final DraftBody text = draftBodyOf(bodyHtml: null, body: '글 본문\n\n'); + expect(text, isA()); + expect((text as AppDraftBody).text, '글 본문'); + }); + + test('남의 저장본은 원문 인용 앞에 인용 경계 표시를 넣은 HTML 을 읽을 때 한 번 만들어 둔다', () { + const String html = + '

a b

원본 메일

'; + final ForeignDraftBody foreign = + draftBodyOf(bodyHtml: html, body: 'a b') as ForeignDraftBody; + expect( + foreign.markedHtml, + '

a b

원본 메일

', + ); + expect(foreign.originalHtml, html); + final ForeignDraftBody noQuote = + draftBodyOf(bodyHtml: '

인용 없음

', body: '') + as ForeignDraftBody; + expect(noQuote.markedHtml, isNull); + }); + + // 웹 저장본 표본 — 문단의 정렬 · 들여쓰기 · 글자 꾸밈 · 다른 감싸개는 앱 모양이 아니다(말없이 지우지 않게 원래 HTML 을 쥐고 안내한다) + for (final (String name, String html) in <(String, String)>[ + ('가운데 정렬', '

가운데

'), + ('들여쓰기', '

들여 쓴 줄

'), + ('굵게', '

글 굵게

'), + ('문단의 다른 속성', '

글

'), + ( + '꾸민 감싸개', + '

글

', + ), + ( + '꾸민 서명 div', + '

글

서명

', + ), + ]) { + test('웹 저장본 — $name 은 남의 저장본(원래 HTML 을 쥐고 안내 줄)', () { + final DraftBody parsed = draftBodyOf(bodyHtml: html, body: '글'); + expect(parsed, isA(), reason: name); + final ForeignDraftBody foreign = parsed as ForeignDraftBody; + expect(foreign.hasFormatting, isTrue, reason: name); + expect(foreign.originalHtml, html, reason: name); + }); + } + + test('꾸밈 없는 문단(속성 없음 · margin:0 하나)만 든 웹 저장본은 앱 모양으로 연다', () { + final DraftBody parsed = draftBodyOf( + bodyHtml: + '
' + '

첫 줄

둘째 줄

', + body: '', + ); + expect(parsed, isA()); + expect((parsed as AppDraftBody).text, '첫 줄\n둘째 줄'); + }); + }); + + group('서명 (계약 2절 "서명" — 짝: 웹 mail-signature.ts)', () { + test('새 메일은 isForNew, 답장 · 전달은 isForReply 일 때만', () { + const MailSignature signature = MailSignature( + body: '김아라', + isForNew: false, + isForReply: true, + ); + expect(composeSignatureOf(signature, forReply: false), isNull); + expect(composeSignatureOf(signature, forReply: true), isNotNull); + expect(composeSignatureOf(null, forReply: true), isNull); + expect( + composeSignatureOf(const MailSignature(body: ' '), forReply: true), + isNull, + ); + }); + + test('텍스트 서명은 줄마다 문단', () { + final ComposeSignature signature = composeSignatureOf( + const MailSignature(body: '김아라\n경영지원팀'), + forReply: false, + )!; + expect(signature.html, '$_p김아라

$_p경영지원팀

'); + expect(signature.text, '김아라\n경영지원팀'); + expect(signature.parts, const [ + SignatureTextPart('김아라'), + SignatureTextPart('경영지원팀'), + ]); + }); + + test('서식 서명 — 문단에 margin: 0 을 앞에, 미리 보기는 글 줄과 서명 그림', () { + const String id = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d'; + final ComposeSignature signature = composeSignatureOf( + const MailSignature( + bodyHtml: + '

김아라 경영지원팀

' + '

로고

' + '

', + body: '김아라 경영지원팀', + ), + forReply: false, + )!; + expect( + signature.html, + startsWith('

김아라'), + ); + expect(signature.html, contains('

[ + SignatureTextPart('김아라 경영지원팀'), + SignatureImagePart(id: id, width: 320, alt: '로고'), + ]); + }); + }); + + group('답장 · 전달 (짝: 웹 mail-reply.ts · mail-forward.ts · mail-draft.ts)', () { + test('제목 — 접두어가 있으면 그대로, 255자에 맞춘다(짝 글자를 가르지 않는다)', () { + expect(buildReplySubject('계약'), 'Re: 계약'); + expect(buildReplySubject(' RE : 계약'), 'RE : 계약'); + expect(buildReplySubject('회신: 계약'), '회신: 계약'); + expect(buildReplySubject(''), 'Re:'); + expect(buildForwardSubject('계약'), 'Fwd: 계약'); + expect(buildForwardSubject('FW: 계약'), 'FW: 계약'); + expect(buildForwardSubject('전달: 계약'), '전달: 계약'); + expect(fitDraftSubject('a' * 300), 'a' * 255); + expect(fitDraftSubject('${'a' * 254}😀'), 'a' * 254); + }); + + const String me = 'ara.kim@arasystem.it.kr'; + Map person(String name, String email) => { + 'name': name, + 'email': email, + }; + + List emails(List list) => [ + for (final PersonAddress address in list) address.email, + ]; + + test('답장은 보낸 사람(Reply-To 가 있으면 그 주소), 전체 답장은 받는 사람 · 참조까지 — 나는 뺀다', () { + final MessageDetail message = _detail({ + 'from': person('이도윤', 'doyun@x.kr'), + 'to': [ + person('김아라', 'ARA.KIM@arasystem.it.kr'), + person('박지훈', 'jihoon@x.kr'), + ], + 'cc': [ + person('최서연', 'seoyeon@x.kr'), + person('', 'jihoon@x.kr'), + ], + }); + final ({List to, List cc}) reply = + buildReplyRecipients(message, me, all: false); + expect(emails(reply.to), ['doyun@x.kr']); + expect(reply.cc, isEmpty); + + final ({List to, List cc}) all = + buildReplyRecipients(message, me, all: true); + expect(emails(all.to), ['doyun@x.kr', 'jihoon@x.kr']); + expect(emails(all.cc), ['seoyeon@x.kr']); + + final MessageDetail withReplyTo = _detail({ + 'from': person('이도윤', 'doyun@x.kr'), + 'replyTo': [person('지원팀', 'help@x.kr')], + }); + expect( + emails(buildReplyRecipients(withReplyTo, me, all: false).to), + ['help@x.kr'], + ); + }); + + test('내가 보낸 메일에 답장하면 원래 받는 사람에게, 나에게만 보냈으면 나에게', () { + final MessageDetail own = _detail({ + 'from': person('김아라', me), + 'to': [person('이도윤', 'doyun@x.kr')], + 'replyTo': [person('다른 곳', 'else@x.kr')], + }); + expect(emails(buildReplyRecipients(own, me, all: false).to), [ + 'doyun@x.kr', + ]); + final MessageDetail toSelf = _detail({ + 'from': person('김아라', me), + 'to': [person('김아라', me)], + }); + expect(emails(buildReplyRecipients(toSelf, me, all: false).to), [ + me, + ]); + }); + + test('Reply-To 가 나뿐이면 원래 받는 사람에게', () { + final MessageDetail message = _detail({ + 'from': person('이도윤', 'doyun@x.kr'), + 'replyTo': [person('김아라', me)], + 'to': [person('김아라', me), person('박지훈', 'jihoon@x.kr')], + }); + expect(emails(buildReplyRecipients(message, me, all: false).to), [ + 'jihoon@x.kr', + ]); + }); + + test('인용 머리 — 날짜는 웹과 같은 글, 전달은 받는 사람 · 참조도', () { + expect( + formatQuoteDate(DateTime(2026, 11, 5, 10, 42)), + '2026. 11. 5. (목) 오전 10:42', + ); + expect( + formatQuoteDate(DateTime(2026, 11, 5, 15, 7)), + '2026. 11. 5. (목) 오후 3:07', + ); + final MessageDetail message = _detail({ + 'from': person('이도윤', 'doyun@x.kr'), + 'to': [person('김아라', me)], + 'cc': [person('', 'cc@x.kr')], + 'subject': '', + 'sentAt': _localIso(2026, 11, 5, 10, 42), + }); + expect(replyQuoteRows(message), const [ + QuoteHeaderRow('보낸 사람', '이도윤 '), + QuoteHeaderRow('날짜', '2026. 11. 5. (목) 오전 10:42'), + QuoteHeaderRow('제목', '(제목 없음)'), + ]); + expect(forwardQuoteRows(message), const [ + QuoteHeaderRow('보낸 사람', '이도윤 '), + QuoteHeaderRow('날짜', '2026. 11. 5. (목) 오전 10:42'), + QuoteHeaderRow('받는 사람', '김아라 '), + QuoteHeaderRow('참조', 'cc@x.kr'), + QuoteHeaderRow('제목', '(제목 없음)'), + ]); + // 접힌 줄의 둘째 줄 — 올해면 연도 없이, 아니면 연도까지 + expect( + quoteSummaryOf(message, now: DateTime(2026, 12, 1)), + '이도윤 · 11월 5일 (목) 오전 10:42', + ); + expect( + quoteSummaryOf(message, now: DateTime(2027, 1, 2)), + '이도윤 · 2026년 11월 5일 (목) 오전 10:42', + ); + }); + + test('이어 쓰는 저장본의 쓰기 종류 — 답장할 메일 · 전달 제목 · 새 메일', () { + expect(draftKindOf(inReplyTo: 'm1', subject: '계약'), ComposeKind.reply); + expect( + draftKindOf(inReplyTo: null, subject: 'Fwd: 계약'), + ComposeKind.forward, + ); + expect(draftKindOf(inReplyTo: '', subject: '계약'), ComposeKind.newMail); + }); + }); + + group( + '보내기 규칙 (짝: 웹 idempotency-key.ts · mail-send-result.ts · pending-send.ts · mail-attachment.ts)', + () { + test('발송 키 — UUID v4 꼴, 매번 다르다', () { + final RegExp uuid = RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', + ); + expect(createSendKey(), matches(uuid)); + expect(createSendKey(math.Random(1)), matches(uuid)); + expect(createSendKey(), isNot(createSendKey())); + }); + + test('키를 바꾸는 것은 성공과 MAIL_005 뿐 — 그 밖의 실패는 같은 키로 다시 보낸다', () { + expect(shouldRenewSendKey(kSendOutcomeSent), isTrue); + expect(shouldRenewSendKey('MAIL_005'), isTrue); + for (final String code in [ + 'MAIL_001', + 'MAIL_002', + 'SYS_001', + 'REQ_001', + 'MAIL_007', + ]) { + expect(shouldRenewSendKey(code), isFalse, reason: code); + } + }); + + test('받지 못한 사람 — 두 명까지 주소 · 까닭, 나머지는 "외 N명"', () { + expect(partialSendMessage(const []), isNull); + expect( + partialSendMessage(const [ + RejectedRecipient(email: 'a@x.kr', reason: RejectedReason.notFound), + RejectedRecipient(email: 'b@x.kr', reason: RejectedReason.unknown), + RejectedRecipient(email: 'c@x.kr', reason: RejectedReason.refused), + ]), + '보냈지만 3명은 받지 못했습니다. a@x.kr (없는 주소), b@x.kr 외 1명', + ); + expect( + partialSendMessage(const [ + RejectedRecipient( + email: 'a@x.kr', + reason: RejectedReason.temporary, + ), + ], pending: true), + '보냈지만 1명은 받지 못합니다. a@x.kr (지금은 받을 수 없음)', + ); + }); + + test( + '보내기 취소 마감 — 서버 시각으로 sendAt − 2초, 모르면 설정의 초 − 1초(모르면 30초)와 sendAt − 이 기기 시각 가운데 짧은 것(30초를 넘지 않는다)', + () { + final DateTime sendAt = DateTime.utc(2026, 11, 7, 9, 0, 10); + final DateTime device = DateTime.utc(2026, 11, 7, 9, 0, 0); + expect( + undoSendRemaining( + sendAt: sendAt, + serverNow: DateTime.utc(2026, 11, 7, 9, 0, 0), + undoSendSeconds: 30, + deviceNow: device, + ), + const Duration(seconds: 8), + ); + expect( + undoSendRemaining( + sendAt: sendAt, + serverNow: null, + undoSendSeconds: 5, + deviceNow: device, + ), + const Duration(seconds: 4), + ); + // 설정을 모르면 sendAt 까지(이 기기 시각), 다만 30초를 넘지 않게 — 웹 undoToastDeadline 과 같다 + expect( + undoSendRemaining( + sendAt: sendAt, + serverNow: null, + undoSendSeconds: null, + deviceNow: device, + ), + const Duration(seconds: 10), + ); + expect( + undoSendRemaining( + sendAt: sendAt, + serverNow: null, + undoSendSeconds: null, + deviceNow: device.subtract(const Duration(hours: 1)), + ), + kUndoSendMax, + ); + // 설정을 알아도 sendAt 을 넘지 않는다(이 기기 시계가 빠르면 일찍 닫힌다 — 보낸 뒤에 취소를 보이지 않는 쪽) + expect( + undoSendRemaining( + sendAt: sendAt, + serverNow: null, + undoSendSeconds: 30, + deviceNow: device.add(const Duration(seconds: 7)), + ), + const Duration(seconds: 3), + ); + expect( + undoSendRemaining( + sendAt: sendAt, + serverNow: DateTime.utc(2026, 11, 7, 8, 0), + undoSendSeconds: null, + deviceNow: device, + ), + kUndoSendMax, + ); + expect( + undoSendRemaining( + sendAt: sendAt, + serverNow: sendAt.subtract(const Duration(seconds: 1)), + undoSendSeconds: 10, + deviceNow: device, + ), + isNull, + ); + }, + ); + + test('이미 붙은 첨부의 사전 검사 — 개수 → 형식 → 합계(서버와 같은 차례)', () { + expect( + precheckCarriedAttachment( + name: '계약.PDF', + size: 10, + attachedCount: 0, + attachedBytes: 0, + ), + isNull, + ); + expect( + precheckCarriedAttachment( + name: 'a.pdf', + size: 10, + attachedCount: 20, + attachedBytes: 0, + ), + 'MAIL_006', + ); + expect( + precheckCarriedAttachment( + name: 'a.exe', + size: 10, + attachedCount: 0, + attachedBytes: 0, + ), + 'FILE_001', + ); + expect( + precheckCarriedAttachment( + name: 'noext', + size: 10, + attachedCount: 0, + attachedBytes: 0, + ), + 'FILE_001', + ); + expect( + precheckCarriedAttachment( + name: 'a.zip', + size: 2, + attachedCount: 1, + attachedBytes: kMailAttachmentTotalMaxBytes - 1, + ), + 'FILE_002', + ); + }); + }, + ); + + group('발송 · 저장 본문 (계약 2절)', () { + test('빈 선택 칸은 싣지 않는다 · 저장본으로 남길 때는 draftId 를 뺀다', () { + const MailComposePayload payload = MailComposePayload( + to: [PersonAddress(email: 'a@x.kr', name: '가')], + cc: [], + bcc: [], + subject: '제목', + body: '본문', + draftId: 'd1', + ); + expect(payload.toJson(), { + 'to': [ + {'name': '가', 'email': 'a@x.kr'}, + ], + 'cc': [], + 'bcc': [], + 'subject': '제목', + 'body': '본문', + 'attachments': [], + 'draftId': 'd1', + }); + expect(payload.withoutDraftId().toJson().containsKey('draftId'), isFalse); + + const MailComposePayload full = MailComposePayload( + to: [], + cc: [], + bcc: [], + subject: '', + body: 'x', + bodyHtml: '

x
', + attachments: [(id: 'att1', name: '계약.pdf')], + inReplyTo: 'm1', + largeFileIds: ['u1'], + bodyQuoteStart: 3, + ); + expect(full.toJson(), containsPair('bodyHtml', '
x
')); + expect(full.toJson(), containsPair('inReplyTo', 'm1')); + expect(full.toJson(), containsPair('largeFileIds', ['u1'])); + expect(full.toJson(), containsPair('bodyQuoteStart', 3)); + expect(full.toJson()['attachments'], [ + {'id': 'att1', 'name': '계약.pdf'}, + ]); + }); + + test('저장본 응답 — 대용량 파일 상태 · 위험 판정을 읽는다(모양이 틀린 항목만 버린다)', () { + final Draft? draft = Draft.tryParse({ + 'id': 'd1', + 'to': [], + 'subject': '', + 'body': '', + 'updatedAt': '2026-11-05T01:00:00.000Z', + 'largeFiles': [ + { + 'id': 'u1', + 'status': 'ready', + 'name': 'a.zip', + 'size': 10, + 'scanStatus': 'infected', + }, + {'id': 'u2', 'status': 'missing'}, + {'id': 'u3', 'status': 'weird'}, + ], + }); + expect(draft, isNotNull); + expect(draft!.cc, isEmpty); + expect(draft.largeFiles, const [ + DraftLargeFile( + id: 'u1', + status: DraftLargeFileStatus.ready, + name: 'a.zip', + size: 10, + infected: true, + ), + DraftLargeFile(id: 'u2', status: DraftLargeFileStatus.missing), + ]); + expect(Draft.tryParse({'id': 'd1'}), isNull); + }); + }); +} diff --git a/flutter/test/features/mail/draft_autosave_test.dart b/flutter/test/features/mail/draft_autosave_test.dart new file mode 100644 index 0000000..357af43 --- /dev/null +++ b/flutter/test/features/mail/draft_autosave_test.dart @@ -0,0 +1,331 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/api_error_mapper.dart'; +import 'package:arasystem_app/core/people/person_address.dart'; +import 'package:arasystem_app/features/mail/data/models/compose_models.dart'; +import 'package:arasystem_app/features/mail/data/models/compose_payload.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/draft_autosave.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// 저장 한 번의 결과 — 문([gate])을 두면 열릴 때까지 기다린다 +class _Server { + final List calls = []; + + /// 다음 저장들이 이 코드로 실패한다(차례대로 하나씩 쓴다) + final List failures = []; + Completer? gate; + int _next = 0; + + Future save(String call) async { + calls.add(call); + final Completer? wait = gate; + if (wait != null) await wait.future; + if (failures.isNotEmpty) { + throw apiFailure(RequestOptions(path: '/drafts'), failures.removeAt(0)); + } + _next++; + return DraftSaved( + id: 'd$_next', + attachments: const [], + updatedAt: DateTime.utc(2026, 11, 7), + ); + } +} + +/// 자동 저장 시험대 — 바뀜 여부는 [changed], 저장이 끝나면 저장한 판으로 삼는다(바뀜 없음) +class _Harness { + _Harness() { + autosave = DraftAutosave( + canSave: () => allowed, + hasChanges: () => changed, + prepare: () { + final int version = this.version; + return rejected == null + ? DraftSaveRequest( + payload: _payload, + commit: (DraftSaved _) { + // 저장하는 동안 더 바꿨으면 그대로 바뀜 + if (this.version == version) changed = false; + }, + ) + : DraftSaveRejected(rejected!); + }, + create: (MailComposePayload _) => server.save('POST'), + update: (String id, MailComposePayload _) => server.save('PUT $id'), + canFallback: () => fallback, + onSaved: (String? previousId, DraftSaved saved, bool replaced) => + saved_.add((previousId, saved.id, replaced)), + onFailed: (String code, DraftSaveTrigger trigger) => + failed.add((code, trigger)), + ); + } + + final _Server server = _Server(); + late final DraftAutosave autosave; + bool changed = false; + bool allowed = true; + bool fallback = true; + String? rejected; + int version = 0; + final List<(String?, String, bool)> saved_ = <(String?, String, bool)>[]; + final List<(String, DraftSaveTrigger)> failed = + <(String, DraftSaveTrigger)>[]; + + static const MailComposePayload _payload = MailComposePayload( + to: [], + cc: [], + bcc: [], + subject: '제목', + body: '본문', + ); + + /// 사용자가 무언가 바꿨다 + void edit() { + changed = true; + version++; + autosave.touch(); + } +} + +void main() { + // 시간은 Timer 로만 잰다 — 시험 시간(testWidgets 의 가짜 시계)을 넘긴다 + testWidgets('입력이 3초 멈추면 저장한다 — 처음은 POST, 그다음은 PUT {직전 id}', ( + WidgetTester tester, + ) async { + final _Harness h = _Harness(); + h.edit(); + await tester.pump(const Duration(seconds: 2)); + expect(h.server.calls, isEmpty); + await tester.pump(const Duration(seconds: 1)); + expect(h.server.calls, ['POST']); + expect(h.autosave.draftId, 'd1'); + expect(h.autosave.status, DraftSaveStatus.saved); + + await tester.pump(const Duration(seconds: 10)); + h.edit(); + await tester.pump(const Duration(seconds: 3)); + expect(h.server.calls, ['POST', 'PUT d1']); + expect(h.saved_.last, ('d1', 'd2', false)); + h.autosave.dispose(); + }); + + testWidgets('저장은 10초에 한 번까지 — 그사이 바뀐 것은 문이 열릴 때 저장한다', ( + WidgetTester tester, + ) async { + final _Harness h = _Harness(); + h.edit(); + await tester.pump(const Duration(seconds: 3)); + expect(h.server.calls, ['POST']); + h.edit(); + await tester.pump(const Duration(seconds: 3)); + // 첫 저장을 시작한 지 3초 — 아직 문이 닫혀 있다(시작한 뒤 10초에 열린다) + expect(h.server.calls, ['POST']); + await tester.pump(const Duration(seconds: 6)); + expect(h.server.calls, ['POST']); + await tester.pump(const Duration(seconds: 1)); + expect(h.server.calls, ['POST', 'PUT d1']); + h.autosave.dispose(); + }); + + testWidgets('쉬지 않고 써도 처음 바꾼 뒤 30초가 지나면 저장한다', (WidgetTester tester) async { + final _Harness h = _Harness(); + for (int second = 0; second < 30; second++) { + h.edit(); + await tester.pump(const Duration(seconds: 1)); + } + expect(h.server.calls, ['POST']); + h.autosave.dispose(); + }); + + testWidgets('한 번에 하나 — 저장하는 동안 바뀐 것은 끝난 뒤 다시', (WidgetTester tester) async { + final _Harness h = _Harness(); + h.server.gate = Completer(); + h.edit(); + await tester.pump(const Duration(seconds: 3)); + expect(h.autosave.saving, isTrue); + h.edit(); + await tester.pump(const Duration(seconds: 15)); + expect(h.server.calls, ['POST']); + h.server.gate!.complete(); + h.server.gate = null; + await tester.pump(); + // 끝났지만 그사이 바뀐 것이 남았다 — 문(10초)이 이미 열렸으므로 바로 + expect(h.server.calls, ['POST', 'PUT d1']); + await tester.pump(); + expect(h.changed, isFalse); + h.autosave.dispose(); + }); + + testWidgets('옛 저장본이 다른 곳에서 바뀌었거나 없으면 새로 남긴다(MAIL_008 · RES_001)', ( + WidgetTester tester, + ) async { + final _Harness h = _Harness(); + h.autosave.reset(existingId: 'old'); + h.server.failures.add('MAIL_008'); + h.edit(); + await tester.pump(const Duration(seconds: 3)); + expect(h.server.calls, ['PUT old', 'POST']); + expect(h.autosave.status, DraftSaveStatus.replaced); + expect(h.saved_.single, ('old', 'd1', true)); + + // 세션이 바뀌었으면 새로 남기지 않는다 + final _Harness other = _Harness()..fallback = false; + other.autosave.reset(existingId: 'old'); + other.server.failures.add('RES_001'); + other.edit(); + await tester.pump(const Duration(seconds: 3)); + expect(other.server.calls, ['PUT old']); + expect(other.failed.single, ('RES_001', DraftSaveTrigger.auto)); + h.autosave.dispose(); + other.autosave.dispose(); + }); + + testWidgets('실을 수 없는 첨부가 든 남의 저장본은 첫 저장을 POST 로(원본을 지우지 않는다)', ( + WidgetTester tester, + ) async { + final _Harness h = _Harness(); + h.autosave.reset(existingId: 'foreign', keepOriginal: true); + expect(h.autosave.draftId, isNull); + h.edit(); + await tester.pump(const Duration(seconds: 3)); + expect(h.server.calls, ['POST']); + h.autosave.dispose(); + }); + + testWidgets('다시 해 보면 나아질 실패는 15 · 30 · 60초 뒤 저절로, 그 밖은 바뀔 때까지 기다린다', ( + WidgetTester tester, + ) async { + final _Harness h = _Harness(); + h.server.failures.addAll(['MAIL_001', 'SYS_001']); + h.edit(); + await tester.pump(const Duration(seconds: 3)); + expect(h.server.calls, ['POST']); + expect(h.autosave.status, DraftSaveStatus.failed); + await tester.pump(const Duration(seconds: 14)); + expect(h.server.calls, hasLength(1)); + await tester.pump(const Duration(seconds: 1)); + expect(h.server.calls, hasLength(2)); + await tester.pump(const Duration(seconds: 29)); + expect(h.server.calls, hasLength(2)); + await tester.pump(const Duration(seconds: 1)); + expect(h.server.calls, hasLength(3)); + expect(h.autosave.status, DraftSaveStatus.saved); + + final _Harness quota = _Harness(); + quota.server.failures.add('MAIL_004'); + quota.edit(); + await tester.pump(const Duration(seconds: 3)); + await tester.pump(const Duration(minutes: 5)); + expect(quota.server.calls, ['POST']); + expect(quota.failed.single.$1, 'MAIL_004'); + h.autosave.dispose(); + quota.autosave.dispose(); + }); + + testWidgets('보낼 수 없는 본문(MAIL_007)은 요청 없이 실패로 둔다', ( + WidgetTester tester, + ) async { + final _Harness h = _Harness()..rejected = 'MAIL_007'; + h.edit(); + await tester.pump(const Duration(seconds: 3)); + expect(h.server.calls, isEmpty); + expect(h.failed.single, ('MAIL_007', DraftSaveTrigger.auto)); + h.autosave.dispose(); + }); + + testWidgets('앱이 안 보이게 되면 10초 간격을 기다리지 않고 바로 저장한다', ( + WidgetTester tester, + ) async { + final _Harness h = _Harness(); + h.edit(); + await tester.pump(const Duration(seconds: 3)); + h.edit(); + h.autosave.saveOnHide(); + await tester.pump(); + expect(h.server.calls, ['POST', 'PUT d1']); + // 바꾼 것이 없으면 저장하지 않는다 + h.autosave.saveOnHide(); + await tester.pump(); + expect(h.server.calls, hasLength(2)); + h.autosave.dispose(); + }); + + testWidgets('떠나기 전 — 진행 중인 저장을 기다린 뒤 남은 변경을 저장한다', ( + WidgetTester tester, + ) async { + final _Harness h = _Harness(); + h.server.gate = Completer(); + h.edit(); + await tester.pump(const Duration(seconds: 3)); + h.edit(); + bool? result; + unawaited( + h.autosave.saveBeforeLeave().then((bool saved) => result = saved), + ); + await tester.pump(); + expect(h.server.calls, ['POST']); + h.server.gate!.complete(); + h.server.gate = null; + await tester.pump(); + await tester.pump(); + expect(h.server.calls, ['POST', 'PUT d1']); + expect(result, isTrue); + h.autosave.dispose(); + }); + + testWidgets('멈춘 동안(보내기 · 나가기 확인)은 저장하지 않고, 진행 중인 저장은 settle 로 기다린다', ( + WidgetTester tester, + ) async { + final _Harness h = _Harness(); + h.server.gate = Completer(); + h.edit(); + await tester.pump(const Duration(seconds: 3)); + h.autosave.suspend(); + h.edit(); + bool settled = false; + unawaited(h.autosave.settle().then((void _) => settled = true)); + await tester.pump(const Duration(seconds: 40)); + expect(settled, isFalse); + h.server.gate!.complete(); + h.server.gate = null; + await tester.pump(); + expect(settled, isTrue); + await tester.pump(const Duration(seconds: 40)); + expect(h.server.calls, ['POST']); + + h.autosave.resume(); + await tester.pump(const Duration(seconds: 3)); + expect(h.server.calls, ['POST', 'PUT d1']); + h.autosave.dispose(); + expect(h.autosave.hasPendingTimers, isFalse); + }); + + test('양식 없이 한 번 — PUT 이 막히면 같은 세션일 때만 POST 로 남긴다', () async { + final _Server server = _Server(); + server.failures.add('MAIL_008'); + const MailComposePayload payload = _Harness._payload; + final DraftSaved? saved = await saveDraftDetached( + payload: payload, + draftId: 'd0', + create: (MailComposePayload _) => server.save('POST'), + update: (String id, MailComposePayload _) => server.save('PUT $id'), + stillValid: () => true, + ); + expect(saved?.id, 'd1'); + expect(server.calls, ['PUT d0', 'POST']); + + server.failures.add('RES_001'); + expect( + await saveDraftDetached( + payload: payload, + draftId: 'd1', + create: (MailComposePayload _) => server.save('POST'), + update: (String id, MailComposePayload _) => server.save('PUT $id'), + stillValid: () => false, + ), + isNull, + ); + expect(server.calls, ['PUT d0', 'POST', 'PUT d1']); + }); +} diff --git a/flutter/test/features/mail/mail_html_test.dart b/flutter/test/features/mail/mail_html_test.dart new file mode 100644 index 0000000..d1b484f --- /dev/null +++ b/flutter/test/features/mail/mail_html_test.dart @@ -0,0 +1,411 @@ +import 'dart:typed_data'; + +import 'package:arasystem_app/features/mail/domain/mail_html.dart'; +import 'package:arasystem_app/features/mail/domain/mail_images.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// PNG 머리 — 그림 크기만 큰 시험 그림을 만든다 +const List kTestPng = [ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // +]; + +/// 받은 메일 본문의 두 번째 정화 · WebView 문서 (웹 `mail-html.spec.ts` 의 결) +void main() { + String clean(String html, {bool imagesShown = false}) => + sanitizeMailHtml(html, imagesShown: imagesShown).fragment; + + group('정화 — 태그', () { + test('스크립트 · 스타일 · 틀 · 개체 · SVG · 수식은 내용째 버린다', () { + final String out = clean( + '

안녕

' + '' + 'svg 글' + 'x' + '', + ); + expect(out, '

안녕

'); + }); + + test('폼 · 단추 · 모르는 태그는 태그만 지우고 글은 남긴다', () { + final String out = clean( + '
본문
' + '', + ); + expect(out, '보내기본문'); + }); + + test('글 · 표 · 목록 · 인용은 남긴다', () { + final String out = clean( + '
칸
' + '
  • 하나


', + ); + expect( + out, + '
칸
' + '
  • 하나


', + ); + }); + + test('주석은 지우고 글은 이스케이프한다', () { + expect( + clean('

a <b> & c

'), + '

a <b> & c

', + ); + }); + + test('깊은 중첩도 스택이 넘치지 않고 끝난다(되풀이로 돈다)', () { + const int depth = 1500; + final String html = '${'
' * depth}끝${'
' * depth}'; + final String out = clean(html); + expect(out, '${'
' * depth}끝${'
' * depth}'); + }); + + test('맨 위 요소가 아주 많아도 문서로 읽어 고르게 끝난다', () { + final String html = 'a' * 40000; + final String out = clean(html); + expect(out.length, html.length); + }); + }); + + group('정화 전 비용 (중첩)', () { + test('태그마다 그때의 깊이를 더한다 — 빈 요소만 깊이를 늘리지 않는다', () { + expect(htmlNestingCost('

a

', limit: 1000), 0 + 1); + expect( + htmlNestingCost('

a

', limit: 1000), + 0 + 1 + 2 + 1, + ); + expect( + htmlNestingCost('


', limit: 1000), + 0 + 1 + 1 + 1 + 1, + ); + expect(htmlNestingCost('글만 있다 <div>', limit: 1000), 0); + }); + + test('빈 요소가 아닌 는 여는 태그로 센다 — 파서가 / 를 무시한다', () { + const int depth = 100; + expect( + htmlNestingCost('
' * depth, limit: 1 << 30), + htmlNestingCost('
' * depth, limit: 1 << 30), + ); + expect( + htmlNestingCost('
', limit: 1000), + // span 이 열린 채라
는 깊이 2 에서 세고 닫지 않는다(맨 위가 span) + 0 + 1 + 2, + ); + }); + + test('주석을 건너뛰지 않는다 — 주석 안의 태그도 센다(서버가 주석을 지운다)', () { + expect( + htmlNestingCost('

a

', limit: 1000), + 0 + 1 + 2 + 3, + ); + expect(htmlNestingCost('
', limit: 1000), 0 + 1); + expect(htmlNestingCost('
', limit: 1000), 0 + 1); + const int depth = 300; + final String hidden = ''; + expect( + htmlNestingCost(hidden, limit: 1 << 30), + htmlNestingCost('
' * depth, limit: 1 << 30), + ); + // 문서 형식 선언 · 엉뚱한 주석은 파서처럼 다음 > 까지 건너뛴다 + expect( + htmlNestingCost('
', limit: 1000), + 0 + 1, + ); + }); + + test('맞지 않는 닫는 태그는 깊이를 줄이지 않는다 — 파서가 무시하는 으로 깊이를 덜 세지 않게', () { + const int n = 1000; + final String html = '${'
' * n}${'' * n}${'
' * n}'; + // 여는 div 2,000 개는 0 ~ 1,999 깊이에서, 1,000 개는 깊이 1,000 에서 센다 + const int expected = (2 * n - 1) * (2 * n) ~/ 2 + n * n; + expect(htmlNestingCost(html, limit: 1 << 30), expected); + // 맞는 닫는 태그는 줄인다 — 같은 깊이를 다시 연다 + expect( + htmlNestingCost('
', limit: 1000), + 0 + 1 + 0 + 1, + ); + expect( + htmlNestingCost('

a

', limit: 1000), + 0 + 1 + 2 + 1, + ); + }); + + test('한도를 넘으면 그 자리에서 멈춘다 — 깊은 본문은 정화하지 않고 글자 본문을 보인다', () { + const int depth = 20000; + final String deep = '${'
' * depth}끝${'
' * depth}'; + expect( + htmlNestingCost(deep, limit: kMaxHtmlNestingCost), + greaterThan(kMaxHtmlNestingCost), + ); + final SanitizedMailBody body = mailBodyFragment( + htmlBody: deep, + textBody: '글자 본문', + imagesShown: true, + ); + expect(body.htmlOmitted, isTrue); + expect(body.fragment, '
글자 본문
'); + expect(body.proxiedTokens, isEmpty); + }); + + test('서버 한도(2,000,000자)보다 긴 서식 본문도 글자 본문으로', () { + final SanitizedMailBody body = mailBodyFragment( + htmlBody: '

${'a' * kMaxSanitizedHtmlLength}

', + textBody: null, + imagesShown: false, + ); + expect(body.htmlOmitted, isTrue); + expect(body.fragment, '
'); + }); + }); + + group('정화 — 속성', () { + test('이벤트 처리기 · data-* · id · target · 외부 자원 속성은 지운다', () { + final String out = clean( + '', + ); + expect( + out, + '', + ); + }); + + test('링크는 http · https · mailto 만 남긴다', () { + expect(clean('a'), 'a'); + expect(clean('a'), 'a'); + expect(clean('a'), 'a'); + expect(clean('a'), 'a'); + expect(clean('a'), 'a'); + expect( + clean('a'), + 'a', + ); + expect( + clean('a'), + 'a', + ); + }); + + test('속성 값의 따옴표 · 꺾쇠는 늘 이스케이프한다', () { + expect( + clean('t'), + 't', + ); + }); + + test( + '그림은 data: 그림(PNG · JPEG · GIF · WebP)만 — SVG · 바깥 주소 · 상대 주소는 주소를 지운다', + () { + const String png = 'data:image/png;base64,iVBORw0KGgo='; + expect(clean('서명'), '서명'); + expect( + clean(''), + '', + ); + expect( + clean(''), + '', + ); + expect(clean(''), ''); + expect(clean(''), ''); + }, + ); + }); + + group('외부 이미지(중계 주소)', () { + test('외부 이미지를 보일 때만 중계 주소를 남기고 표를 문서 차례 · 겹치지 않게 모은다', () { + final SanitizedMailBody body = sanitizeMailHtml( + 'a' + '' + '', + imagesShown: true, + ); + expect(body.proxiedTokens, ['tok_A', 'tok-B']); + expect( + body.fragment, + 'a' + '', + ); + }); + + test('한 통에 100개까지만 받는다 — 넘는 그림은 주소를 지운다', () { + final String html = [ + for (int n = 0; n < kMaxProxiedImages + 5; n++) + '', + ].join(); + final SanitizedMailBody body = sanitizeMailHtml(html, imagesShown: true); + expect(body.proxiedTokens, hasLength(kMaxProxiedImages)); + expect(body.fragment, endsWith('')); + }); + + test('받은 그림으로 바꾸고, 받지 못한 그림은 주소를 지운다 — 글 속의 같은 모양에는 닿지 않는다', () { + final SanitizedMailBody body = sanitizeMailHtml( + '

<img src="/api/mail-images/t1"

' + 'x', + imagesShown: true, + ); + final String resolved = resolveProxiedImages( + body.fragment, + {'t1': 'data:image/png;base64,AAAA'}, + ); + expect( + resolved, + '

<img src="/api/mail-images/t1"

' + 'x', + ); + }); + + /// [token] 을 [count] 번 쓴 본문의 정화 조각 + String repeated(String token, int count) => sanitizeMailHtml( + '' * count, + imagesShown: true, + ).fragment; + + /// 실린 그림 수 · 지운 그림 수 + ({int shown, int hidden}) countImages(String resolved) => ( + shown: '')); + }, + ); + + test('1KB 아이콘이 같은 표로 300번 나오면 모두 보인다', () { + final String icon = 'data:image/png;base64,${'A' * 1024}'; + final String resolved = resolveProxiedImages( + repeated('icon', 300), + {'icon': icon}, + ); + expect(countImages(resolved), (shown: 300, hidden: 0)); + }); + + test( + '서로 다른 그림이 섞이면 문서 차례로 싣고, 넘는 자리부터는 뒤의 작은 그림도 지운다 — 받지 못한 그림은 세지 않는다', + () { + final SanitizedMailBody body = sanitizeMailHtml( + '' + '' + '' + '', + imagesShown: true, + ); + const String a = 'data:image/png;base64,AAAAAAAAAAAAAAAAAA'; // 40자 + const String b = 'data:image/gif;base64,BB'; // 24자 + // 셋째 a(40)가 넘친다 — 마지막 b(24)는 남은 자리(30)에 들지만 넘는 자리 뒤라 지운다 + final String resolved = resolveProxiedImages( + body.fragment, + {'a': a, 'b': b}, + maxTotalLength: 40 + 24 + 40 + 24 + 30, + ); + expect( + resolved, + '', + ); + }, + ); + }); + + group('정화할 곳 (그 자리 · 다른 isolate)', () { + MailBodyBuildPlace placeOf(String? html, [String? text]) => + mailBodyBuildPlaceOf(htmlBody: html, textBody: text); + + test('짧고 얕은 서식 본문 · 짧은 글자 본문만 그 자리에서', () { + expect(placeOf('

안녕하세요

'), MailBodyBuildPlace.inline); + expect(placeOf(null, '글자 본문'), MailBodyBuildPlace.inline); + expect(placeOf(' ', '공백뿐인 서식 본문은 글자 본문'), MailBodyBuildPlace.inline); + expect( + placeOf('

${'가' * (kMailBodyInlineMaxLength - 8)}

'), + MailBodyBuildPlace.inline, + ); + }); + + test('길면 다른 isolate 에서 — 서식 본문 · 글자 본문 모두', () { + expect( + placeOf('

${'가' * kMailBodyInlineMaxLength}

'), + MailBodyBuildPlace.isolate, + ); + expect( + placeOf(null, '가' * kMailBodyInlineMaxTextLength), + MailBodyBuildPlace.isolate, + ); + }); + + test('짧아도 깊으면 다른 isolate 에서 — 2,800단 · 30KB 본문, 200단 · 2KB 본문', () { + final String deep = '${'
' * 2800}끝${'
' * 2800}'; + expect(deep.length, lessThan(32 * 1024)); + expect(placeOf(deep), MailBodyBuildPlace.isolate); + + final String short = '${'
' * 200}끝${'
' * 200}'; + expect(short.length, lessThan(kMailBodyInlineMaxLength)); + expect( + htmlNestingCost(short, limit: 1 << 30), + greaterThan(kMailBodyInlineMaxCost), + ); + expect(placeOf(short), MailBodyBuildPlace.isolate); + + final String shallow = '${'
' * 40}끝${'
' * 40}'; + expect(placeOf(shallow), MailBodyBuildPlace.inline); + }); + }); + + group('본문 조각 · 문서', () { + test('HTML 이 없거나 공백뿐이면 글자 본문을 이스케이프해 보인다', () { + final SanitizedMailBody text = mailBodyFragment( + htmlBody: ' \n ', + textBody: '굵게\n둘째 줄', + imagesShown: false, + ); + expect( + text.fragment, + '
<b>굵게</b>\n둘째 줄
', + ); + expect(displayedHtmlBody(null), isNull); + expect(displayedHtmlBody('

a

'), '

a

'); + }); + + test('문서는 CSP 를 맨 앞에 두고 바깥 요청 · 스크립트를 막는다', () { + final String document = buildMailDocument('

본문

'); + expect( + kMailDocumentCsp, + "default-src 'none'; img-src data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'", + ); + expect( + document, + startsWith( + '' + '', + ), + ); + expect( + document, + contains(''), + ); + expect(document, contains('

본문

')); + expect(document, isNot(contains(' true; +} + +/// 화면 낭독기를 쓰지 않는 것으로 — 접근성 시험은 화면 시험이 한다 +class _NoScreenReader extends AccessibleNavigation { + @override + bool build() => false; +} + +/// 메일을 보는 직원 — 시험이 바꾼다(세션이 바뀜) +class _Account extends Notifier { + @override + String? build() => kMailTestAccount; + + void set(String? value) => state = value; +} + +final NotifierProvider<_Account, String?> _accountProvider = + NotifierProvider<_Account, String?>(_Account.new); + +/// 메일 목록 컨트롤러 — 계약 2절 목록 · 여러 통 한꺼번에 · 새 메일 확인 (웹 list · selection · bulk 와 같은 규칙) +void main() { + late ProviderHarness h; + late FakeMailBackend mail; + late ProviderContainer container; + + /// 단추 없는 알림(전역 알림 자리에 뜨는 것) + late List notices; + + setUp(() async { + h = ProviderHarness(); + mail = FakeMailBackend(h.server)..install(); + await h.prepare(); + await h.store.save(tokensFor(1, now: h.clock.now)); + notices = []; + container = ProviderContainer( + overrides: [ + ...h.overrides, + mailAccountProvider.overrideWith( + (Ref ref) => ref.watch(_accountProvider), + ), + appForegroundProvider.overrideWith(_Foreground.new), + accessibleNavigationProvider.overrideWith(_NoScreenReader.new), + mailNoticeSinkProvider.overrideWithValue(notices.add), + ], + retry: noAutomaticRetry, + ); + addTearDown(container.dispose); + }); + + MailListState stateOf([String? route]) => + container.read(mailListProvider(route)); + + MailListController controllerOf([String? route]) => + container.read(mailListProvider(route).notifier); + + /// 목록을 열고(화면이 보는 것처럼 구독한다) 첫 쪽이 끝날 때까지 기다린다 + Future open([String? route]) async { + final ProviderSubscription subscription = container.listen( + mailListProvider(route), + (MailListState? previous, MailListState next) {}, + ); + addTearDown(subscription.close); + // 알림 · 메일함 목록도 화면처럼 구독한다(autoDispose 가 사라지지 않게) + final ProviderSubscription toast = container.listen( + mailToastProvider, + (MailToast? previous, MailToast? next) {}, + ); + addTearDown(toast.close); + await pumpEventsUntil( + () => stateOf(route).status != MailListStatus.loading, + reason: '첫 쪽', + ); + return controllerOf(route); + } + + List idsOf([String? route]) => [ + for (final MessageSummary message in stateOf(route).items) message.id, + ]; + + MailToast? toast() => container.read(mailToastProvider); + + group('목록 · 이어 받기 · 새로고침', () { + test('받은편지함 주소는 메일함 목록을 받은 뒤 받은편지함의 첫 쪽(20통)을 받는다', () async { + mail.seed(kInboxId, 45); + + await open(); + + expect(stateOf().mailboxId, kInboxId); + expect(idsOf(), hasLength(20)); + expect(stateOf().hasMore, isTrue); + final RequestOptions request = mail.listRequests.single; + expect(request.queryParameters['mailboxId'], kInboxId); + expect(request.queryParameters['limit'], kMailPageSize); + expect(request.queryParameters.containsKey('cursor'), isFalse); + }); + + test('다음 쪽 — 커서를 그대로 보내고, 그사이 밀려 겹친 메일은 건너뛴다', () async { + mail.seed(kInboxId, 45); + final MailListController controller = await open(); + // 새 메일이 와서 경계가 한 통 밀렸다 — 다음 쪽의 첫 메일(m20)은 이미 있다 + mail.arrive(kInboxId, messageJson('new', mailboxId: kInboxId)); + + await controller.loadMore(); + + expect(mail.listRequests.last.queryParameters['cursor'], 'c:20'); + // 다음 쪽은 m20 ~ m39 — 이미 있는 m20 을 건너뛴다 + expect(idsOf(), hasLength(39)); + expect(idsOf().where((String id) => id == 'm20'), hasLength(1)); + expect(idsOf().last, 'm39'); + }); + + test('다음 쪽을 받지 못하면 끝에 [다시 시도] — 알림은 인터셉터가 띄운다', () async { + mail.seed(kInboxId, 45); + final MailListController controller = await open(); + mail.listFailure = FakeResponse.fail(503, 'MAIL_001'); + + await controller.loadMore(); + + expect(stateOf().loadMoreFailed, isTrue); + expect(stateOf().loadingMore, isFalse); + expect(h.notifier.shownCodes, ['MAIL_001']); + mail.listFailure = null; + await controller.loadMore(); + expect(idsOf(), hasLength(40)); + expect(stateOf().loadMoreFailed, isFalse); + }); + + test('처음 받지 못하면 알리지 않고 받지 못함 — [다시 시도]로 다시 받는다', () async { + mail.seed(kInboxId, 3); + mail.listFailure = FakeResponse.fail(503, 'MAIL_001'); + + final MailListController controller = await open(); + + expect(stateOf().status, MailListStatus.failed); + expect(stateOf().error?.code, 'MAIL_001'); + expect(stateOf().error?.traceId, 'trace-MAIL_001'); + expect(h.notifier.shownCodes, isEmpty); + mail.listFailure = null; + await controller.retry(); + expect(stateOf().status, MailListStatus.ready); + expect(idsOf(), ['m1', 'm2', 'm3']); + }); + + test('메일함 목록을 받지 못하면 받은편지함도 받지 못함 — [다시 시도]는 메일함 목록부터', () async { + mail.seed(kInboxId, 2); + mail.mailboxesFailure = FakeResponse.fail(503, 'MAIL_001'); + + final MailListController controller = await open(); + + expect(stateOf().status, MailListStatus.failed); + expect(stateOf().error?.code, 'MAIL_001'); + expect(mail.listRequests, isEmpty); + mail.mailboxesFailure = null; + await controller.retry(); + await pumpEventsUntil( + () => stateOf().status == MailListStatus.ready, + reason: '받은편지함', + ); + expect(idsOf(), ['m1', 'm2']); + }); + + test('응답 모양이 계약과 다르면 SYS_001 로 받지 못함이다', () async { + h.server.on( + 'GET', + MailApiPaths.messages, + (_) => FakeResponse.ok({'items': 'nope'}), + ); + + await open(); + + expect(stateOf().status, MailListStatus.failed); + expect(stateOf().error?.code, 'SYS_001'); + }); + + test('당겨서 새로고침 — 메일함 개수와 첫 쪽을 다시 받고, 고른 것은 남은 메일만 둔다', () async { + mail.seed(kInboxId, 3); + final MailListController controller = await open(); + controller + ..toggleSelection('m1') + ..toggleSelection('m3'); + mail.messages[kInboxId]!.removeWhere( + (Map m) => m['id'] == 'm3', + ); + final int boxesBefore = h.server + .received('GET', MailApiPaths.mailboxes) + .length; + + await controller.refresh(); + + expect(idsOf(), ['m1', 'm2']); + expect(stateOf().selectedIds, {'m1'}); + expect(stateOf().refreshing, isFalse); + expect( + h.server.received('GET', MailApiPaths.mailboxes).length, + boxesBefore + 1, + ); + }); + + test('늦은 응답 — 새로고침 뒤에 도착한 앞 쪽 응답은 버린다', () async { + mail.seed(kInboxId, 45); + final MailListController controller = await open(); + final Completer gate = Completer(); + mail.listGate = gate; + final Future more = controller.loadMore(); + await pumpEventsUntil( + () => mail.listRequests.length == 2, + reason: '다음 쪽 요청', + ); + + await controller.refresh(); + gate.complete(); + await more; + + expect(idsOf(), hasLength(20), reason: '새로고침 전의 다음 쪽을 붙이지 않는다'); + expect(stateOf().loadingMore, isFalse); + }); + + test('메일함을 바꾸면(앞 목록이 사라지면) 앞 목록의 응답은 버린다', () async { + mail.seed(kClientsId, 2); + final Completer gate = Completer(); + mail.listGate = gate; + final ProviderSubscription subscription = container.listen( + mailListProvider(kClientsId), + (_, _) {}, + ); + await pumpEventsUntil( + () => mail.listRequests.isNotEmpty, + reason: '목록 요청', + ); + + subscription.close(); + await pumpEventsUntil( + () => !container.exists(mailListProvider(kClientsId)), + reason: '앞 목록이 사라짐', + ); + gate.complete(); + await Future.delayed(Duration.zero); + + expect(container.exists(mailListProvider(kClientsId)), isFalse); + // 다시 열면 처음부터 받는다 + await open(kClientsId); + expect(idsOf(kClientsId), ['m1', 'm2']); + expect(mail.listRequests, hasLength(2)); + }); + + test('세션이 바뀌면 목록 · 고른 것 · 알림을 비우고, 세션이 없는 동안은 묻지 않는다', () async { + mail.seed(kInboxId, 3); + mail.seed(kClientsId, 1, prefix: 'c'); + final MailListController controller = await open(); + await open(kClientsId); + controller.toggleSelection('m1'); + container + .read(mailToastProvider.notifier) + .offer('알림', actionLabel: MailBulkTexts.undo, onAction: () async {}); + final int requests = h.server.requests.length; + + container.read(_accountProvider.notifier).set(null); + + expect(stateOf().items, isEmpty); + expect(stateOf().selectedIds, isEmpty); + expect(stateOf(kClientsId).items, isEmpty); + expect(toast(), isNull); + expect(container.read(mailboxesProvider).items, isEmpty); + await Future.delayed(Duration.zero); + expect(h.server.requests, hasLength(requests)); + + // 다시 로그인하면 처음부터 받는다 + container.read(_accountProvider.notifier).set(kMailTestAccount); + await pumpEventsUntil( + () => stateOf().status == MailListStatus.ready, + reason: '다시 받음', + ); + expect(idsOf(), ['m1', 'm2', 'm3']); + expect(stateOf().selectedIds, isEmpty); + }); + }); + + group('고르기', () { + test('하나씩 고르고 푼다 — 아직 보내지 않은 메일은 고를 수 없다', () async { + mail.messages[kSentId] = >[ + messageJson('s1', mailboxId: kSentId, isRead: true), + messageJson( + 's2', + mailboxId: kSentId, + isRead: true, + pendingSend: pendingJson(kTestNow.add(const Duration(days: 1))), + ), + ]; + final MailListController controller = await open(kSentId); + + expect(controller.toggleSelection('s1'), isTrue); + expect(controller.toggleSelection('s2'), isFalse); + expect(controller.toggleSelection('없음'), isFalse); + expect(stateOf(kSentId).selectedIds, {'s1'}); + expect(stateOf(kSentId).selectedCount, 1); + controller.toggleSelection('s1'); + expect(stateOf(kSentId).selecting, isFalse); + controller + ..toggleSelection('s1') + ..clearSelection(); + expect(stateOf(kSentId).selecting, isFalse); + }); + + test('고른 뒤 붙잡히면(목록을 맞추니 보내기를 기다림) 고른 것에서 뺀다', () async { + mail.messages[kSentId] = >[ + messageJson('s1', mailboxId: kSentId, isRead: true), + ]; + final MailListController controller = await open(kSentId); + controller + ..setVisible(true) + ..toggleSelection('s1'); + mail.messages[kSentId]!.first['pendingSend'] = pendingJson( + kTestNow.add(const Duration(minutes: 5)), + ); + + container.read(mailStateWatcherProvider.notifier).markChanged(); + await pumpEventsUntil( + () => stateOf(kSentId).items.first.isHeld, + reason: '맞추기', + ); + + expect(stateOf(kSentId).selecting, isFalse); + }); + }); + + group('여러 통 처리', () { + test('읽음 표시 — 먼저 바꾸고 고르기를 끝내며, failedIds 만 되돌린다', () async { + mail.seed(kInboxId, 3); + mail.failIds.add('m2'); + final MailListController controller = await open(); + controller + ..toggleSelection('m1') + ..toggleSelection('m2') + ..toggleSelection('m3'); + + final Future marking = controller.toggleSelectedRead(); + // 응답 전 — 화면이 먼저 바뀌고(안 읽은 수도) 고르기가 끝난다 + expect( + stateOf().items.every((MessageSummary message) => message.isRead), + isTrue, + ); + expect(stateOf().selecting, isFalse); + expect(container.read(mailboxesProvider).byId(kInboxId)!.unreadCount, 0); + await marking; + + expect(mail.patches.single, { + 'ids': ['m1', 'm2', 'm3'], + 'isRead': true, + }); + expect( + { + for (final MessageSummary message in stateOf().items) + message.id: message.isRead, + }, + {'m1': true, 'm2': false, 'm3': true}, + ); + expect(toast(), isNull, reason: '읽음 · 중요 표시는 알리지 않는다'); + expect(notices, isEmpty); + }); + + test('고른 것이 모두 읽음이면 안 읽음으로, 모두 중요면 해제로', () async { + mail.seed(kInboxId, 2, isRead: true); + for (final Map message in mail.messages[kInboxId]!) { + message['isFlagged'] = true; + } + final MailListController controller = await open(); + controller + ..toggleSelection('m1') + ..toggleSelection('m2'); + expect(stateOf().allSelectedRead, isTrue); + expect(stateOf().allSelectedFlagged, isTrue); + + await controller.toggleSelectedRead(); + controller + ..toggleSelection('m1') + ..toggleSelection('m2'); + await controller.toggleSelectedFlag(); + + expect(mail.patches.first['isRead'], isFalse); + expect(mail.patches.last['isFlagged'], isFalse); + }); + + test('요청이 통째로 실패하면 읽음 표시를 되돌린다 — 알림은 인터셉터가', () async { + mail.seed(kInboxId, 2); + mail.patchFailure = FakeResponse.fail(503, 'MAIL_001'); + final MailListController controller = await open(); + controller.toggleSelection('m1'); + + await controller.toggleSelectedRead(); + + expect(stateOf().items.first.isRead, isFalse); + expect(h.notifier.shownCodes, ['MAIL_001']); + // 다시 해 볼 수 있게 고른 것을 되살린다 + expect(stateOf().selectedIds, {'m1'}); + }); + + test('이동 — 응답의 updatedIds 만 빼고, 알림의 [되돌리기]는 원래 메일함으로 옮긴다', () async { + mail.seed(kInboxId, 3); + mail.failIds.add('m2'); + final MailListController controller = await open(); + controller + ..setVisible(true) + ..toggleSelection('m1') + ..toggleSelection('m2'); + final Mailbox clients = container + .read(mailboxesProvider) + .byId(kClientsId)!; + + await controller.moveSelected(clients); + + expect(mail.patches.single, { + 'ids': ['m1', 'm2'], + 'mailboxId': kClientsId, + }); + expect(idsOf(), ['m2', 'm3']); + expect(stateOf().selecting, isFalse); + expect(stateOf().busy, isFalse); + expect(toast()?.text, '메일 1통을 거래처로 옮겼습니다. 1통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.'); + expect(toast()?.actionLabel, MailBulkTexts.undo); + // 그대로 둔 메일이 있으면 서버 기준으로 다시 맞춘다 + await pumpEventsUntil(() => mail.listRequests.length >= 2, reason: '맞추기'); + + container.read(mailToastProvider.notifier).runAction(toast()!.id); + expect(toast(), isNull, reason: '단추를 누르면 알림 줄은 닫힌다'); + await pumpEventsUntil(() => mail.patches.length == 2, reason: '되돌리기'); + await pumpEventsUntil(() => notices.isNotEmpty, reason: '되돌린 알림'); + + expect(mail.patches.last, { + 'ids': ['m1'], + 'mailboxId': kInboxId, + }); + expect(notices, ['메일 1통을 되돌렸습니다.']); + await pumpEventsUntil(() => idsOf().contains('m1'), reason: '되돌린 목록'); + }); + + test('[되돌리기]는 한 번만 — 두 번 눌러도 한 번 보낸다', () async { + mail.seed(kInboxId, 1); + final MailListController controller = await open(); + controller.toggleSelection('m1'); + await controller.moveSelected( + container.read(mailboxesProvider).byId(kClientsId)!, + ); + final int id = toast()!.id; + + container.read(mailToastProvider.notifier) + ..runAction(id) + ..runAction(id); + await pumpEventsUntil(() => mail.patches.length == 2, reason: '되돌리기'); + await Future.delayed(Duration.zero); + + expect(mail.patches, hasLength(2)); + }); + + test('[되돌리기]는 만든 세션에서만 — 그사이 로그아웃했으면 보내지 않는다', () async { + mail.seed(kInboxId, 1); + final MailListController controller = await open(); + controller.toggleSelection('m1'); + await controller.moveSelected( + container.read(mailboxesProvider).byId(kClientsId)!, + ); + + await h.store.clear(); + container.read(mailToastProvider.notifier).runAction(toast()!.id); + await Future.delayed(Duration.zero); + + expect(mail.patches, hasLength(1)); + }); + + test('통째로 실패하면 목록 · 고른 것을 그대로 둔다(다시 해 볼 수 있게)', () async { + mail.seed(kInboxId, 2); + mail.patchFailure = FakeResponse.fail(503, 'MAIL_001'); + final MailListController controller = await open(); + controller.toggleSelection('m1'); + + await controller.moveSelected( + container.read(mailboxesProvider).byId(kClientsId)!, + ); + + expect(idsOf(), ['m1', 'm2']); + expect(stateOf().selectedIds, {'m1'}); + expect(toast(), isNull); + expect(h.notifier.shownCodes, ['MAIL_001']); + }); + + test('휴지통 밖의 [삭제]는 휴지통으로 옮긴다 — 영구 삭제 요청을 보내지 않는다', () async { + mail.seed(kInboxId, 2); + final MailListController controller = await open(); + controller.toggleSelection('m2'); + + await controller.moveSelectedToTrash(); + + expect(mail.patches.single['mailboxId'], kTrashId); + expect(mail.deletions, isEmpty); + expect(idsOf(), ['m1']); + expect(toast()?.text, '메일 1통을 휴지통으로 옮겼습니다.'); + expect(mail.idsIn(kTrashId), ['m2']); + }); + + test('휴지통에서는 영구 삭제 — deletedIds 만 빼고 되돌리기는 없다', () async { + mail.seed(kTrashId, 3, prefix: 't'); + mail.failIds.add('t2'); + final MailListController controller = await open(kTrashId); + controller + ..toggleSelection('t1') + ..toggleSelection('t2') + ..toggleSelection('t3'); + + await controller.purgeSelected(); + + expect(mail.deletions.single, ['t1', 't2', 't3']); + expect(idsOf(kTrashId), ['t2']); + expect(stateOf(kTrashId).pageInfo?.total, 1); + expect(toast(), isNull, reason: '영구 삭제는 되돌릴 수 없다 — 단추 알림이 아니다'); + expect(notices, [ + '메일 2통을 영구 삭제했습니다. 1통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.', + ]); + }); + + test('100통씩 차례로 보낸다', () async { + mail.seed(kInboxId, 150); + final MailListController controller = await open(); + while (stateOf().hasMore) { + await controller.loadMore(); + } + for (final String id in idsOf()) { + controller.toggleSelection(id); + } + + await controller.moveSelectedToTrash(); + + expect( + mail.patches.map( + (Map body) => (body['ids']! as List).length, + ), + [100, 50], + ); + expect(idsOf(), isEmpty); + expect(toast()?.text, '메일 150통을 휴지통으로 옮겼습니다.'); + }); + + test('휴지통을 모르면(메일함 목록에 없음) 옮기지 않고 알린다', () async { + mail.mailboxes = >[ + mailboxJson(kInboxId, '받은편지함', role: 'inbox'), + ]; + mail.seed(kInboxId, 1); + final MailListController controller = await open(); + controller.toggleSelection('m1'); + + await controller.moveSelectedToTrash(); + + expect(mail.patches, isEmpty); + expect(h.notifier.shownCodes, ['BIZ_001']); + expect(stateOf().selectedIds, {'m1'}); + }); + }); + + group('새 메일 확인으로 맞추기', () { + test('바뀜을 알리면 보이는 목록을 불러온 만큼 다시 받아 맞춘다 — 고른 것은 그대로', () async { + mail.seed(kInboxId, 3); + final MailListController controller = await open(); + controller + ..setVisible(true) + ..toggleSelection('m2'); + mail.arrive(kInboxId, messageJson('new', mailboxId: kInboxId)); + mail.messages[kInboxId]!.removeWhere( + (Map m) => m['id'] == 'm3', + ); + + container.read(mailStateWatcherProvider.notifier).markChanged(); + await pumpEventsUntil(() => idsOf().first == 'new', reason: '맞추기'); + + expect(idsOf(), ['new', 'm1', 'm2']); + expect(stateOf().selectedIds, {'m2'}); + expect(mail.listRequests.last.queryParameters['limit'], kMailPageSize); + }); + + test('가려진 동안의 바뀜은 다시 보일 때 맞춘다', () async { + mail.seed(kInboxId, 1); + final MailListController controller = await open(); + controller.setVisible(false); + mail.arrive(kInboxId, messageJson('new', mailboxId: kInboxId)); + + container.read(mailStateWatcherProvider.notifier).markChanged(); + await Future.delayed(Duration.zero); + expect(mail.listRequests, hasLength(1)); + + controller.setVisible(true); + await pumpEventsUntil(() => idsOf().first == 'new', reason: '맞추기'); + }); + + test('스스로 바꾼 목록은 그 소식으로 다시 받지 않는다 — 다른 목록은 맞춘다', () async { + mail.seed(kInboxId, 2); + mail.seed(kClientsId, 1, prefix: 'c'); + final MailListController inbox = await open(); + final MailListController clients = await open(kClientsId); + inbox.setVisible(true); + clients.setVisible(true); + final int before = mail.listRequests.length; + inbox.toggleSelection('m1'); + + await inbox.moveSelected( + container.read(mailboxesProvider).byId(kClientsId)!, + ); + await pumpEventsUntil( + () => idsOf(kClientsId).contains('m1'), + reason: '거래처 목록', + ); + + expect( + mail.listRequests + .skip(before) + .map((RequestOptions r) => r.queryParameters['mailboxId']), + [kClientsId], + ); + }); + + test('맞추는 사이 이 목록을 바꿨으면 그 응답은 버리고 한 번 더 맞춘다', () async { + mail.seed(kInboxId, 2); + final MailListController controller = await open(); + controller.setVisible(true); + final Completer gate = Completer(); + mail.listGate = gate; + container.read(mailStateWatcherProvider.notifier).markChanged(); + await pumpEventsUntil(() => mail.listRequests.length == 2, reason: '맞추기'); + + controller.toggleSelection('m1'); + final Future flagging = controller.toggleSelectedFlag(); + gate.complete(); + await flagging; + await pumpEventsUntil( + () => mail.listRequests.length == 3, + reason: '한 번 더', + ); + await Future.delayed(Duration.zero); + + expect(stateOf().items.first.isFlagged, isTrue); + }); + }); + + group('경합 · 세션 · 되돌리기 (2026-11-06 리뷰)', () { + test('이어 받는 중 새로고침이 실패해도 끝 표시가 멈추지 않는다', () async { + mail.seed(kInboxId, 45); + final MailListController controller = await open(); + final Completer gate = Completer(); + mail.listGate = gate; + final Future more = controller.loadMore(); + await pumpEventsUntil( + () => mail.listRequests.length == 2, + reason: '다음 쪽 요청', + ); + expect(stateOf().loadingMore, isTrue); + mail.listFailure = FakeResponse.fail(503, 'MAIL_001'); + + await controller.refresh(); + gate.complete(); + await more; + + expect(stateOf().loadingMore, isFalse); + expect(stateOf().refreshing, isFalse); + expect(idsOf(), hasLength(20)); + mail.listFailure = null; + await controller.loadMore(); + expect(idsOf(), hasLength(40)); + }); + + test('여러 쪽을 불러온 뒤 맞춘 쪽이 메일함 끝까지면 그 뒤의 옛 메일 · 커서를 남기지 않는다', () async { + mail.seed(kInboxId, 45); + final MailListController controller = await open(); + await controller.loadMore(); + expect(idsOf(), hasLength(40)); + expect(stateOf().hasMore, isTrue); + controller.setVisible(true); + // 다른 곳에서 앞의 10통을 지웠다 — 서버에는 35통이 남았다 + mail.messages[kInboxId]!.removeRange(0, 10); + + container.read(mailStateWatcherProvider.notifier).markChanged(); + await pumpEventsUntil(() => idsOf().length == 35, reason: '맞추기'); + + expect(mail.listRequests.last.queryParameters['limit'], 40); + expect(idsOf().first, 'm11'); + expect(idsOf().last, 'm45'); + expect(stateOf().hasMore, isFalse); + expect(stateOf().pageInfo?.nextCursor, isNull); + }); + + test('이어 받기가 맞추기보다 먼저 끝나면 그 맞추기 응답은 버리고 불러온 만큼 다시 맞춘다', () async { + mail.seed(kInboxId, 45); + final MailListController controller = await open(); + controller.setVisible(true); + final Completer gate = Completer(); + mail.listGate = gate; + container.read(mailStateWatcherProvider.notifier).markChanged(); + await pumpEventsUntil(() => mail.listRequests.length == 2, reason: '맞추기'); + + await controller.loadMore(); + expect(idsOf(), hasLength(40)); + gate.complete(); + await pumpEventsUntil( + () => mail.listRequests.length == 4, + reason: '한 번 더', + ); + await Future.delayed(Duration.zero); + + expect(mail.listRequests[1].queryParameters['limit'], kMailPageSize); + expect(mail.listRequests[2].queryParameters['cursor'], 'c:20'); + expect(mail.listRequests[3].queryParameters['limit'], 40); + expect(idsOf(), hasLength(40)); + }); + + test('맞추기는 한 번에 하나 — 도는 동안 바뀜이 여러 번 오면 끝난 뒤 한 번만 더', () async { + mail.seed(kInboxId, 2); + final MailListController controller = await open(); + controller.setVisible(true); + final Completer gate = Completer(); + mail.listGate = gate; + final MailStateWatcher watcher = container.read( + mailStateWatcherProvider.notifier, + ); + watcher.markChanged(); + await pumpEventsUntil(() => mail.listRequests.length == 2, reason: '맞추기'); + + watcher + ..markChanged() + ..markChanged(); + await Future.delayed(Duration.zero); + expect(mail.listRequests, hasLength(2), reason: '도는 동안은 새로 묻지 않는다'); + mail.arrive(kInboxId, messageJson('new', mailboxId: kInboxId)); + gate.complete(); + await pumpEventsUntil( + () => mail.listRequests.length == 3, + reason: '끝난 뒤 한 번 더', + ); + await pumpEventsUntil(() => idsOf().first == 'new', reason: '맞춤'); + await Future.delayed(Duration.zero); + + expect(mail.listRequests, hasLength(3)); + }); + + test('새로 붙은 메일 없이 같은 커서가 오면 끝에 [다시 시도] — 같은 커서로 저절로 다시 묻지 않는다', () async { + mail.seed(kInboxId, 25); + final MailListController controller = await open(); + h.server.on( + 'GET', + MailApiPaths.messages, + (_) => FakeResponse.ok({ + 'items': [messageJson('m1', mailboxId: kInboxId)], + 'pageInfo': { + 'hasNext': true, + 'nextCursor': 'c:20', + 'total': 25, + }, + }), + ); + + await controller.loadMore(); + + expect(mail.listRequests, hasLength(2)); + expect(idsOf(), hasLength(20)); + expect(stateOf().loadingMore, isFalse); + expect(stateOf().loadMoreFailed, isTrue); + expect(stateOf().hasMore, isTrue); + expect(h.notifier.shownCodes, isEmpty); + }); + + group('되돌리기는 실패한 칸만', () { + /// 요청마다 따로 답한다 — 시험이 차례를 정한다 + late List> replies; + final FakeResponse updated = FakeResponse.ok({ + 'updatedIds': ['m1'], + 'failedIds': [], + }); + final FakeResponse failed = FakeResponse.ok({ + 'updatedIds': [], + 'failedIds': ['m1'], + }); + + setUp(() { + replies = >[]; + mail.seed(kInboxId, 1); + h.server.on('PATCH', MailApiPaths.messages, (RequestOptions options) { + mail.patches.add(options.data! as Map); + final Completer reply = Completer(); + replies.add(reply); + return reply.future; + }); + }); + + test('중요 표시가 먼저 끝난 뒤 읽음 표시가 실패하면 읽음만 되돌린다', () async { + final MailListController controller = await open(); + controller.toggleSelection('m1'); + final Future reading = controller.toggleSelectedRead(); + controller.toggleSelection('m1'); + final Future flagging = controller.toggleSelectedFlag(); + expect(stateOf().items.single.isRead, isTrue); + expect(stateOf().items.single.isFlagged, isTrue); + await pumpEventsUntil(() => replies.length == 2, reason: '두 요청'); + + replies[1].complete(updated); + await flagging; + replies[0].complete(failed); + await reading; + + expect(stateOf().items.single.isRead, isFalse, reason: '읽음만 실패했다'); + expect( + stateOf().items.single.isFlagged, + isTrue, + reason: '먼저 끝난 중요 표시는 그대로', + ); + }); + + test('되돌린 칸에 아직 보내는 중인 표시가 있으면 그 값을 다시 입힌다', () async { + final MailListController controller = await open(); + // 읽음 → 안 읽음 → 읽음을 잇달아 누른다(셋 다 보내는 중) + final List> marks = >[]; + for (int n = 0; n < 3; n++) { + controller.toggleSelection('m1'); + marks.add(controller.toggleSelectedRead()); + } + expect(stateOf().items.single.isRead, isTrue); + await pumpEventsUntil(() => replies.length == 3, reason: '세 요청'); + expect( + mail.patches.map((Map b) => b['isRead']), + [true, false, true], + ); + + replies[0].complete(failed); + await marks[0]; + + expect( + stateOf().items.single.isRead, + isTrue, + reason: '마지막으로 보낸 읽음이 아직 오가는 중이다', + ); + replies[1].complete(updated); + replies[2].complete(updated); + await Future.wait(marks); + expect(stateOf().items.single.isRead, isTrue); + }); + }); + + test('읽음 표시 중에 목록이 사라져도 안 읽은 수를 되돌리고 다른 목록에 알린다', () async { + mail.seed(kInboxId, 2); + mail.failIds.add('m2'); + final ProviderSubscription boxes = container.listen( + mailboxesProvider, + (_, _) {}, + ); + addTearDown(boxes.close); + final ProviderSubscription changes = container.listen( + mailStateWatcherProvider, + (_, _) {}, + ); + addTearDown(changes.close); + final ProviderSubscription list = container.listen( + mailListProvider(null), + (_, _) {}, + ); + await pumpEventsUntil( + () => stateOf().status == MailListStatus.ready, + reason: '첫 쪽', + ); + int inboxUnread() => + container.read(mailboxesProvider).byId(kInboxId)!.unreadCount; + expect(inboxUnread(), 3); + final Completer gate = Completer(); + mail.patchGate = gate; + final MailListController controller = controllerOf() + ..toggleSelection('m1') + ..toggleSelection('m2'); + final Future marking = controller.toggleSelectedRead(); + expect(inboxUnread(), 1); + + list.close(); + await pumpEventsUntil( + () => !container.exists(mailListProvider(null)), + reason: '목록이 사라짐', + ); + // 바뀐 소식으로 메일함 개수를 다시 받는 것은 문에서 붙잡아 둔다 — 되돌린 값을 보려고 + final Completer boxesGate = Completer(); + mail.mailboxesGate = boxesGate; + addTearDown(boxesGate.complete); + gate.complete(); + await marking; + + expect(inboxUnread(), 2, reason: '바꾸지 못한 m2 만큼 되돌린다'); + expect(container.read(mailStateWatcherProvider).seq, 1); + }); + + test('세션이 바뀌는 사이 끝난 옮기기는 새 세션에 알리지 않는다 — [되돌리기]도 없다', () async { + mail.seed(kInboxId, 2); + final MailListController controller = await open(); + final Completer gate = Completer(); + mail.patchGate = gate; + controller.toggleSelection('m1'); + final Future moving = controller.moveSelectedToTrash(); + await pumpEventsUntil(() => mail.patches.isNotEmpty, reason: '옮기기 요청'); + + // 로그아웃한 뒤 다른 직원이 로그인했다 + await h.store.clear(); + container.read(_accountProvider.notifier).set(null); + await h.store.save(tokensFor(2, now: h.clock.now)); + container.read(_accountProvider.notifier).set('other@arasystem.it.kr'); + gate.complete(); + await moving; + await pumpEventsUntil( + () => stateOf().status == MailListStatus.ready, + reason: '새 세션의 목록', + ); + + expect(toast(), isNull); + expect(notices, isEmpty); + expect(container.read(mailStateWatcherProvider).seq, 0); + }); + + test('휴지통을 모르다가 메일함 목록을 다시 받아 찾으면 그리로 옮긴다', () async { + mail.mailboxes = >[ + mailboxJson(kInboxId, '받은편지함', role: 'inbox'), + ]; + mail.seed(kInboxId, 1); + final MailListController controller = await open(); + controller.toggleSelection('m1'); + mail.mailboxes = standardMailboxes(); + + await controller.moveSelectedToTrash(); + + expect(mail.patches.single['mailboxId'], kTrashId); + expect(h.notifier.shownCodes, isEmpty); + expect(idsOf(), isEmpty); + expect(toast()?.text, '메일 1통을 휴지통으로 옮겼습니다.'); + }); + + test('목록이 가려지면(다른 메일함 · 다른 탭 · 전체 화면) 고른 것을 푼다', () async { + mail.seed(kInboxId, 2); + final MailListController controller = await open(); + controller + ..setVisible(true) + ..toggleSelection('m1'); + + controller.setVisible(false); + + expect(stateOf().selecting, isFalse); + }); + + test('다른 직원으로 바뀌어 새 메일 확인이 처음부터 다시 만들어져도 보이는 목록이 있으면 다시 묻는다', () async { + mail.seed(kInboxId, 1); + final MailListController controller = await open(); + final ProviderSubscription watching = container.listen( + mailStateWatcherProvider, + (_, _) {}, + ); + addTearDown(watching.close); + controller.setVisible(true); + await pumpEventsUntil(() => mail.stateRequests == 1, reason: '기준'); + + container.read(_accountProvider.notifier).set('other@arasystem.it.kr'); + + await pumpEventsUntil(() => mail.stateRequests == 2, reason: '다시 물음'); + }); + + test('세션이 없는 동안 새 메일 확인이 사라졌다 다시 생겨도 보이는 목록은 새 것에 다시 알린다', () async { + mail.seed(kInboxId, 1); + final MailListController controller = await open(); + controller.setVisible(true); + await pumpEventsUntil(() => mail.stateRequests == 1, reason: '기준'); + + container.read(_accountProvider.notifier).set(null); + await pumpEventsUntil( + () => !container.exists(mailStateWatcherProvider), + reason: '아무도 듣지 않아 사라짐', + ); + container.read(_accountProvider.notifier).set(kMailTestAccount); + + await pumpEventsUntil(() => mail.stateRequests == 2, reason: '새 것이 묻는다'); + }); + }); + + test('목록이 없어지면 새 메일 확인에서도 빠진다', () async { + mail.seed(kInboxId, 1); + final ProviderSubscription subscription = container.listen( + mailListProvider(null), + (_, _) {}, + ); + await pumpEventsUntil( + () => stateOf().status == MailListStatus.ready, + reason: '첫 쪽', + ); + controllerOf().setVisible(true); + await pumpEventsUntil(() => mail.stateRequests == 1, reason: '기준'); + final ProviderSubscription watcher = container.listen( + mailStateWatcherProvider, + (_, _) {}, + ); + addTearDown(watcher.close); + + subscription.close(); + await pumpEventsUntil( + () => !container.exists(mailListProvider(null)), + reason: '목록이 사라짐', + ); + await Future.delayed(Duration.zero); + + // 보는 목록이 없으니 새로 묻지 않는다 — 기준만 남는다 + expect(mail.stateRequests, 1); + expect(container.read(mailStateWatcherProvider).seq, 0); + }); +} diff --git a/flutter/test/features/mail/mail_list_page_test.dart b/flutter/test/features/mail/mail_list_page_test.dart new file mode 100644 index 0000000..a3c7aa5 --- /dev/null +++ b/flutter/test/features/mail/mail_list_page_test.dart @@ -0,0 +1,1133 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/dio_provider.dart'; +import 'package:arasystem_app/core/api/error_lexicon.dart'; +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/theme/app_typography.dart'; +import 'package:arasystem_app/features/mail/data/models/mailbox.dart'; +import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart'; +import 'package:arasystem_app/features/mail/mail_routes.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; +import 'package:arasystem_app/features/mail/presentation/pages/mail_list_page.dart'; +import 'package:arasystem_app/features/mail/presentation/pages/mail_search_page.dart'; +import 'package:arasystem_app/features/mail/presentation/pages/message_compose_page.dart'; +import 'package:arasystem_app/features/mail/presentation/pages/message_reader_page.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_list_controller.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_state_watcher.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_toast_provider.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/mail_compose_area.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/mail_list_states.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/mailbox_drawer.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/message_row.dart'; +import 'package:arasystem_app/features/settings/settings_routes.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +import '../../support/fakes.dart'; +import '../../support/login_fakes.dart'; +import '../../support/mail_fakes.dart'; +import '../../support/mail_harness.dart'; + +/// 메일 목록 화면 (앱 보드 `App1MailList`) — 로그인한 앱 전체를 가짜 서버로 띄워 본다 +void main() { + late MailAppHarness h; + + setUp(() => h = MailAppHarness()); + + /// 시간을 넘긴다 — 손 시계와 시험 시간을 함께 + Future elapse(WidgetTester tester, Duration duration) async { + h.clock.advance(duration); + await tester.pump(duration); + await tester.pumpAndSettle(); + } + + Finder row(String subject) => + find.ancestor(of: find.text(subject), matching: find.byType(MessageRow)); + + Finder avatarOf(String subject) => find.descendant( + of: row(subject), + matching: find.byType(MailAvatarButton), + ); + + Finder toastText(String text) => find.descendant( + of: find.byType(MailToastCard), + matching: find.text(text), + ); + + /// 단추 없는 알림 — 전역 알림 자리(스낵바)에 뜬다 + Finder noticeText(String text) => + find.descendant(of: find.byType(SnackBar), matching: find.text(text)); + + Future scrollToFooter(WidgetTester tester) async { + await tester.scrollUntilVisible( + find.byType(MailListFooter), + 500, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + } + + Future longPress(WidgetTester tester, String subject) async { + await tester.longPress(find.text(subject)); + await tester.pumpAndSettle(); + } + + MailListPage currentPage(WidgetTester tester) => + tester.widget(find.byType(MailListPage)); + + /// 위에 쌓아 연 전체 화면(읽기 화면 · 쓰기 화면)의 주소 — `push` 는 라우터의 주소를 바꾸지 않는다 + String pushedLocation(WidgetTester tester) { + final Finder reader = find.byType(MessageReaderPage); + return GoRouterState.of( + tester.element( + reader.evaluate().isNotEmpty + ? reader.last + : find.byType(MessageComposePage).last, + ), + ).uri.toString(); + } + + group('목록 줄', () { + testWidgets('받은편지함 — 보낸 사람 · 시각 · 제목 · 미리 보기, 안 읽음은 굵게 · 첨부 · 중요', ( + WidgetTester tester, + ) async { + h.mail.messages[kInboxId] = >[ + messageJson( + 'm1', + mailboxId: kInboxId, + fromName: '이도윤', + subject: '유지보수 계약', + preview: '계약서 초안을 고쳐 첨부했습니다.', + hasAttachment: true, + isFlagged: true, + ), + messageJson( + 'm2', + mailboxId: kInboxId, + fromName: '인사팀', + subject: '채용 일정', + isRead: true, + ), + ]; + await h.pump(tester); + + expect(find.text('받은편지함'), findsOneWidget); + expect(find.text(MailListTexts.unread(3)), findsOneWidget); + expect(find.text('이도윤'), findsOneWidget); + expect(find.text('계약서 초안을 고쳐 첨부했습니다.'), findsOneWidget); + expect(find.byIcon(Icons.attach_file), findsOneWidget); + expect(find.byIcon(Icons.star), findsOneWidget); + final Text unread = tester.widget(find.text('이도윤')); + final Text read = tester.widget(find.text('인사팀')); + expect(unread.style?.fontWeight, AppTypography.bold); + expect(read.style?.fontWeight, AppTypography.medium); + expect( + tester.getSemantics(row('유지보수 계약')).label, + startsWith('안 읽음, 이도윤, '), + ); + expect(tester.getSemantics(avatarOf('채용 일정')).label, '고르기: 채용 일정'); + }); + + testWidgets('줄을 누르면 읽기 화면(탭 막대 없는 전체 화면) — 목록은 읽음 처리를 하지 않는다(읽기 화면이 한다)', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 2); + await h.pump(tester); + + await tester.tap(find.text('제목 m1')); + await tester.pumpAndSettle(); + + expect(pushedLocation(tester), MailRoutes.message('m1')); + expect(find.byType(MessageReaderPage), findsOneWidget); + expect(find.byType(NavigationBar), findsNothing); + // 여러 통 바꾸기는 없다 — 읽음은 읽기 화면의 한 통 바꾸기 + expect(h.mail.patches, isEmpty); + expect( + h.mail.singlePatches.map( + (({String id, Map body}) p) => p.id, + ), + ['m1'], + ); + }); + + testWidgets('임시보관함 — 받는 사람을 보이고, 누르면 쓰기 화면에서 이어 쓴다', ( + WidgetTester tester, + ) async { + h.mail.messages[kDraftsId] = >[ + messageJson( + 'd1', + mailboxId: kDraftsId, + to: >[addressJson('박준호', 'junho@example.com')], + subject: '쓰던 메일', + ), + ]; + await h.pump(tester, location: MailRoutes.mailbox(kDraftsId)); + + expect(find.text('박준호'), findsOneWidget); + await tester.tap(find.text('쓰던 메일')); + await tester.pumpAndSettle(); + + expect(pushedLocation(tester), '/mail/compose?draft=d1'); + }); + + testWidgets('보낸편지함 — 받는 사람과 보내는 중 · 예약 표시', (WidgetTester tester) async { + final DateTime sendAt = h.clock.now.add(const Duration(seconds: 8)); + h.mail.messages[kSentId] = >[ + messageJson( + 's1', + mailboxId: kSentId, + to: >[addressJson('박준호', 'junho@example.com')], + subject: '보내는 메일', + isRead: true, + pendingSend: pendingJson(sendAt), + ), + ]; + await h.pump(tester, location: MailRoutes.mailbox(kSentId)); + + expect(find.text('받는 사람: 박준호'), findsOneWidget); + expect(find.text(MailListTexts.sending), findsOneWidget); + expect(find.text(MailListTexts.unread(0)), findsNothing); + }); + }); + + group('메일함 서랍', () { + testWidgets('탭 막대 위까지 덮고, 메일함을 고르면 그 목록 — 뒤로 가기는 받은편지함', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 1); + h.mail.seed(kClientsId, 1, prefix: 'c'); + await h.pump(tester); + + await tester.tap(find.byTooltip(MailListTexts.openDrawer)); + await tester.pumpAndSettle(); + + expect(find.byType(MailboxDrawer), findsOneWidget); + expect(find.text(MailDrawerTexts.myMailboxes), findsOneWidget); + for (final String name in [ + '보낸편지함', + '임시보관함', + '스팸메일함', + '휴지통', + '거래처', + '프로젝트', + '설계', + ]) { + expect( + find.descendant( + of: find.byType(MailboxDrawer), + matching: find.text(name), + ), + findsOneWidget, + reason: name, + ); + } + // 서랍이 탭 막대를 덮는다 — 탭 막대 자리를 누르면 서랍 쪽이 받는다 + final Rect drawer = tester.getRect(find.byType(MailboxDrawer)); + final Rect bar = tester.getRect(find.byType(NavigationBar)); + expect(drawer.bottom, greaterThanOrEqualTo(bar.bottom)); + expect( + tester + .getSemantics( + find.descendant( + of: find.byType(MailboxDrawer), + matching: find.text('3'), + ), + ) + .label, + contains(MailDrawerTexts.unreadCount(3)), + ); + + await tester.tap( + find.descendant( + of: find.byType(MailboxDrawer), + matching: find.text('거래처'), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(MailboxDrawer), findsNothing); + expect(h.location(), MailRoutes.mailbox(kClientsId)); + expect(currentPage(tester).mailboxId, kClientsId); + expect(find.text('제목 c1'), findsOneWidget); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(h.location(), MailRoutes.home); + expect(find.text('제목 m1'), findsOneWidget); + }); + + testWidgets('메일함 목록을 받지 못하면 서랍에 사전 문구와 [다시 시도] — 다시 받으면 메일함', ( + WidgetTester tester, + ) async { + h.mail.mailboxesFailure = FakeResponse.fail(503, 'MAIL_001'); + await h.pump(tester); + + await tester.tap(find.byTooltip(MailListTexts.openDrawer)); + await tester.pumpAndSettle(); + final Finder drawer = find.byType(MailboxDrawer); + expect( + find.descendant( + of: drawer, + matching: findCopy(ErrorLexicon.getMessage('MAIL_001')), + ), + findsOneWidget, + ); + h.mail.mailboxesFailure = null; + await tester.tap( + find.descendant(of: drawer, matching: find.text(MailListTexts.retry)), + ); + await tester.pumpAndSettle(); + + expect( + find.descendant(of: drawer, matching: find.text('거래처')), + findsOneWidget, + ); + }); + + testWidgets('받은편지함을 고르면 첫 화면 주소로 돌아간다', (WidgetTester tester) async { + h.mail.seed(kInboxId, 1); + await h.pump(tester, location: MailRoutes.mailbox(kClientsId)); + + await tester.tap(find.byTooltip(MailListTexts.openDrawer)); + await tester.pumpAndSettle(); + await tester.tap( + find.descendant( + of: find.byType(MailboxDrawer), + matching: find.text('받은편지함'), + ), + ); + await tester.pumpAndSettle(); + + expect(h.location(), MailRoutes.home); + }); + }); + + group('고르기', () { + testWidgets('길게 누르면 고르기 막대 — 탭 막대 · [메일 쓰기]를 숨기고 ✕ · 뒤로 가기로 나온다', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 3); + await h.pump(tester); + + await longPress(tester, '제목 m1'); + + expect(find.text(MailBulkTexts.selected(1)), findsOneWidget); + expect(find.byType(NavigationBar), findsNothing); + expect(find.text(MailListTexts.compose), findsNothing); + // 고르는 중에는 줄을 눌러도 고르기 · 풀기 — 열지 않는다 + await tester.tap(find.text('제목 m2')); + await tester.pumpAndSettle(); + expect(find.text(MailBulkTexts.selected(2)), findsOneWidget); + expect(h.location(), MailRoutes.home); + await tester.tap(avatarOf('제목 m2')); + await tester.pumpAndSettle(); + expect(find.text(MailBulkTexts.selected(1)), findsOneWidget); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.text(MailBulkTexts.selected(1)), findsNothing); + expect(find.byType(NavigationBar), findsOneWidget); + expect(h.location(), MailRoutes.home); + + await tester.tap(avatarOf('제목 m3')); + await tester.pumpAndSettle(); + expect(find.text(MailBulkTexts.selected(1)), findsOneWidget); + await tester.tap(find.byTooltip(MailBulkTexts.clear)); + await tester.pumpAndSettle(); + expect(find.byType(NavigationBar), findsOneWidget); + expect(find.text(MailListTexts.compose), findsOneWidget); + }); + + testWidgets('다른 탭으로 가면 고르기를 풀고 탭 막대가 보인다 — 돌아와도 그대로', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 1); + await h.pump(tester); + await longPress(tester, '제목 m1'); + expect(find.byType(NavigationBar), findsNothing); + + h.router.go(SettingsRoutes.home); + await tester.pumpAndSettle(); + expect(find.byType(NavigationBar), findsOneWidget); + + h.router.go(MailRoutes.home); + await tester.pumpAndSettle(); + expect(find.text(MailBulkTexts.selected(1)), findsNothing); + expect(find.byType(NavigationBar), findsOneWidget); + }); + + testWidgets('고르던 받은편지함 위에 다른 메일함을 열면 고르기를 풀고 그 화면에는 탭 막대가 있다', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 1); + await h.pump(tester); + await longPress(tester, '제목 m1'); + expect(find.byType(NavigationBar), findsNothing); + + h.router.go(MailRoutes.mailbox(kClientsId)); + await tester.pumpAndSettle(); + expect(find.byType(NavigationBar), findsOneWidget); + expect(find.text(MailListTexts.compose), findsOneWidget); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(h.location(), MailRoutes.home); + expect(find.text(MailBulkTexts.selected(1)), findsNothing); + expect(find.byType(NavigationBar), findsOneWidget); + }); + + testWidgets('아직 보내지 않은 메일은 고를 수 없다 — 까닭을 알린다', (WidgetTester tester) async { + h.mail.messages[kSentId] = >[ + messageJson( + 's1', + mailboxId: kSentId, + subject: '예약한 메일', + isRead: true, + pendingSend: pendingJson( + h.clock.now.add(const Duration(days: 1)), + kind: 'scheduled', + ), + ), + ]; + await h.pump(tester, location: MailRoutes.mailbox(kSentId)); + + await tester.tap(avatarOf('예약한 메일')); + await tester.pumpAndSettle(); + expect(noticeText(MailListTexts.heldNotSelectable), findsOneWidget); + expect(find.text(MailBulkTexts.selected(1)), findsNothing); + + await longPress(tester, '예약한 메일'); + expect(find.text(MailBulkTexts.selected(1)), findsNothing); + final SemanticsNode avatar = tester.getSemantics(avatarOf('예약한 메일')); + expect(avatar.hint, MailListTexts.heldNotSelectable); + }); + + testWidgets('읽음 표시 — 먼저 굵은 글자가 풀리고 고르기를 끝낸다(알림 없음)', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 2); + await h.pump(tester); + await longPress(tester, '제목 m1'); + expect(find.byTooltip(MailBulkTexts.markRead), findsOneWidget); + + await tester.tap(find.byTooltip(MailBulkTexts.markRead)); + await tester.pumpAndSettle(); + + expect(h.mail.patches.single['isRead'], isTrue); + expect(find.text(MailBulkTexts.selected(1)), findsNothing); + expect(find.byType(MailToastCard), findsNothing); + final Text who = tester.widget( + find.descendant(of: row('제목 m1'), matching: find.text('이도윤')), + ); + expect(who.style?.fontWeight, AppTypography.medium); + }); + }); + + group('삭제 · 되돌리기 · 영구 삭제 · 이동', () { + testWidgets('삭제는 휴지통으로 옮기고 알림에 [되돌리기] — [메일 쓰기]가 알림 위로 함께 올라간다', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 3); + await h.pump(tester); + final double composeBottomBefore = tester + .getRect(find.text(MailListTexts.compose)) + .bottom; + await longPress(tester, '제목 m2'); + + await tester.tap(find.byTooltip(MailBulkTexts.delete)); + await tester.pumpAndSettle(); + + expect(h.mail.patches.single['mailboxId'], kTrashId); + expect(h.mail.deletions, isEmpty); + expect(find.text('제목 m2'), findsNothing); + expect(toastText('메일 1통을 휴지통으로 옮겼습니다.'), findsOneWidget); + final Rect toast = tester.getRect(find.byType(MailToastCard)); + final Rect compose = tester.getRect( + find.widgetWithText(FloatingActionButton, MailListTexts.compose), + ); + expect(compose.bottom, lessThan(toast.top)); + expect(compose.bottom, lessThan(composeBottomBefore)); + expect( + toast.bottom, + lessThan(tester.getRect(find.byType(NavigationBar)).top), + ); + + await tester.tap(find.text(MailBulkTexts.undo)); + await tester.pumpAndSettle(); + + expect(h.mail.patches.last, { + 'ids': ['m2'], + 'mailboxId': kInboxId, + }); + expect(noticeText('메일 1통을 되돌렸습니다.'), findsOneWidget); + expect(find.text('제목 m2'), findsOneWidget); + }); + + testWidgets('알림은 10초 뒤(단추가 없으면 5초 뒤) 저절로 닫힌다', (WidgetTester tester) async { + h.mail.seed(kInboxId, 2); + h.mail.messages[kInboxId]!.add(_heldMessage(h.clock.now)); + await h.pump(tester); + await longPress(tester, '제목 m1'); + await tester.tap(find.byTooltip(MailBulkTexts.delete)); + await tester.pumpAndSettle(); + expect(find.byType(MailToastCard), findsOneWidget); + + await elapse( + tester, + kMailToastActionDuration - const Duration(seconds: 1), + ); + expect(find.byType(MailToastCard), findsOneWidget); + await elapse(tester, const Duration(seconds: 1)); + expect(find.byType(MailToastCard), findsNothing); + + // 단추 없는 알림 — 고를 수 없는 메일의 까닭 + await tester.tap(avatarOf(_heldSubject)); + await tester.pumpAndSettle(); + expect(noticeText(MailListTexts.heldNotSelectable), findsOneWidget); + await elapse(tester, kMailToastDuration - const Duration(seconds: 1)); + expect(noticeText(MailListTexts.heldNotSelectable), findsOneWidget); + await elapse(tester, const Duration(seconds: 1)); + expect(find.byType(SnackBar), findsNothing); + }); + + testWidgets('단추 없는 알림은 [되돌리기] 알림을 바꾸지 않고 그 위(전역 알림 자리)에 뜬다', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 2); + h.mail.messages[kInboxId]!.add(_heldMessage(h.clock.now)); + await h.pump(tester); + await longPress(tester, '제목 m1'); + await tester.tap(find.byTooltip(MailBulkTexts.delete)); + await tester.pumpAndSettle(); + + await tester.tap(avatarOf(_heldSubject)); + await tester.pumpAndSettle(); + + expect(toastText('메일 1통을 휴지통으로 옮겼습니다.'), findsOneWidget); + expect(noticeText(MailListTexts.heldNotSelectable), findsOneWidget); + final Rect snack = tester.getRect(find.byType(SnackBar)); + final Rect compose = tester.getRect( + find.widgetWithText(FloatingActionButton, MailListTexts.compose), + ); + expect(snack.bottom, lessThanOrEqualTo(compose.top)); + + await tester.tap(find.text(MailBulkTexts.undo)); + await tester.pumpAndSettle(); + expect(h.mail.patches.last['mailboxId'], kInboxId); + }); + + testWidgets('화면 낭독기를 쓰는 동안은 [되돌리기] 알림을 저절로 닫지 않는다 — 끄면 그때부터 10초', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 2); + await h.pump(tester); + tester.platformDispatcher.accessibilityFeaturesTestValue = + const FakeAccessibilityFeatures(accessibleNavigation: true); + addTearDown( + tester.platformDispatcher.clearAccessibilityFeaturesTestValue, + ); + await longPress(tester, '제목 m1'); + await tester.tap(find.byTooltip(MailBulkTexts.delete)); + await tester.pumpAndSettle(); + + await elapse(tester, kMailToastActionDuration * 3); + expect(find.byType(MailToastCard), findsOneWidget); + + tester.platformDispatcher.accessibilityFeaturesTestValue = + const FakeAccessibilityFeatures(); + await tester.pump(); + await elapse( + tester, + kMailToastActionDuration - const Duration(seconds: 1), + ); + expect(find.byType(MailToastCard), findsOneWidget); + await elapse(tester, const Duration(seconds: 1)); + expect(find.byType(MailToastCard), findsNothing); + }); + + testWidgets('고르는 중(탭 막대가 숨음)의 알림 줄은 시스템 내비게이션 · 옆 안전 영역 안쪽에 뜬다', ( + WidgetTester tester, + ) async { + // 휴대폰의 안전 영역 — 위 24 · 아래 16 · 좌우 12 (물리 픽셀은 3배) + tester.view.padding = const FakeViewPadding( + left: 36, + top: 72, + right: 36, + bottom: 48, + ); + h.mail.seed(kInboxId, 3); + await h.pump(tester); + await longPress(tester, '제목 m1'); + await tester.tap(find.byTooltip(MailBulkTexts.delete)); + await tester.pumpAndSettle(); + expect(find.byType(MailToastCard), findsOneWidget); + + // 알림이 떠 있는 동안 다시 고른다 — 탭 막대가 숨어 화면 아래가 시스템 내비게이션 자리까지 내려온다 + await longPress(tester, '제목 m2'); + expect(find.byType(NavigationBar), findsNothing); + + final Rect toast = tester.getRect(find.byType(MailToastCard)); + expect( + toast.bottom, + moreOrLessEquals(kPhoneSize.height - 16 - AppSpacing.s12), + ); + expect(toast.left, moreOrLessEquals(12 + AppSpacing.s12)); + expect( + toast.right, + moreOrLessEquals(kPhoneSize.width - 12 - AppSpacing.s12), + ); + }); + + testWidgets('휴지통에서는 영구 삭제 — 확인 창(처음 초점 [취소]) 뒤에만 보낸다', ( + WidgetTester tester, + ) async { + h.mail.seed(kTrashId, 2, prefix: 't'); + await h.pump(tester, location: MailRoutes.mailbox(kTrashId)); + expect( + findCopy(MailListTexts.retentionNotice(MailboxRole.trash)!), + findsOneWidget, + ); + await longPress(tester, '제목 t1'); + expect(find.byTooltip(MailBulkTexts.purge), findsOneWidget); + + await tester.tap(find.byTooltip(MailBulkTexts.purge)); + await tester.pumpAndSettle(); + expect(find.text(MailBulkTexts.purgeTitle(1)), findsOneWidget); + expect(findCopy(MailBulkTexts.purgeMessage), findsOneWidget); + final BuildContext focused = FocusManager.instance.primaryFocus!.context!; + expect( + find.descendant( + of: find.byWidget(focused.widget), + matching: find.text(MailBulkTexts.purgeCancel), + ), + findsOneWidget, + ); + await tester.tap(find.text(MailBulkTexts.purgeCancel)); + await tester.pumpAndSettle(); + expect(h.mail.deletions, isEmpty); + expect(find.text(MailBulkTexts.selected(1)), findsOneWidget); + + await tester.tap(find.byTooltip(MailBulkTexts.purge)); + await tester.pumpAndSettle(); + await tester.tap( + find.widgetWithText(FilledButton, MailBulkTexts.purgeConfirm), + ); + await tester.pumpAndSettle(); + + expect(h.mail.deletions.single, ['t1']); + expect(h.mail.patches, isEmpty); + expect(find.text('제목 t1'), findsNothing); + expect(noticeText('메일 1통을 영구 삭제했습니다.'), findsOneWidget); + expect(find.text(MailBulkTexts.undo), findsNothing); + }); + + testWidgets('이동 — 시트에 옮길 수 있는 메일함만(지금 · 임시보관함 · 휴지통 빼고), 고르면 그리로', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 2); + await h.pump(tester); + await longPress(tester, '제목 m1'); + + await tester.tap(find.byTooltip(MailBulkTexts.move)); + await tester.pumpAndSettle(); + + final Finder sheet = find.byType(BottomSheet); + expect( + find.descendant( + of: sheet, + matching: find.text(MailBulkTexts.moveSheetTitle), + ), + findsOneWidget, + ); + for (final String name in [ + '보낸편지함', + '스팸메일함', + '거래처', + '프로젝트', + '설계', + ]) { + expect( + find.descendant(of: sheet, matching: find.text(name)), + findsOneWidget, + reason: name, + ); + } + for (final String name in ['받은편지함', '임시보관함', '휴지통']) { + expect( + find.descendant(of: sheet, matching: find.text(name)), + findsNothing, + reason: name, + ); + } + await tester.tap(find.descendant(of: sheet, matching: find.text('거래처'))); + await tester.pumpAndSettle(); + + expect(h.mail.patches.single['mailboxId'], kClientsId); + expect(toastText('메일 1통을 거래처로 옮겼습니다.'), findsOneWidget); + }); + }); + + group('상태 · 새로고침 · 이어 받기', () { + testWidgets('빈 휴지통 — 30일 안내 글', (WidgetTester tester) async { + await h.pump(tester, location: MailRoutes.mailbox(kTrashId)); + + expect(findCopy('휴지통이 비어 있습니다'), findsOneWidget); + expect( + findCopy(MailListTexts.emptyHint(MailboxRole.trash)), + findsOneWidget, + ); + expect(find.byType(MailRetentionNotice), findsNothing); + }); + + testWidgets('받지 못하면 사전 문구 · 문의 코드 · [다시 시도]', (WidgetTester tester) async { + h.mail.seed(kInboxId, 1); + h.mail.listFailure = FakeResponse.fail(503, 'MAIL_001'); + await h.pump(tester); + + expect(findCopy(ErrorLexicon.getMessage('MAIL_001')), findsOneWidget); + expect(find.text(MailListTexts.traceCode('trace-MA')), findsOneWidget); + expect(h.notifier.shownCodes, isEmpty); + + h.mail.listFailure = null; + await tester.tap(find.text(MailListTexts.retry)); + await tester.pumpAndSettle(); + expect(find.text('제목 m1'), findsOneWidget); + }); + + testWidgets('없는 메일함 주소면 [받은편지함으로]', (WidgetTester tester) async { + h.mail.seed(kInboxId, 1); + await h.pump(tester, location: MailRoutes.mailbox('gone')); + + expect(findCopy(ErrorLexicon.getMessage('RES_001')), findsOneWidget); + await tester.tap(find.text(MailListTexts.toInbox)); + await tester.pumpAndSettle(); + + expect(h.location(), MailRoutes.home); + expect(find.text('제목 m1'), findsOneWidget); + }); + + testWidgets('당겨서 새로고침 — 첫 쪽을 다시 받는다', (WidgetTester tester) async { + h.mail.seed(kInboxId, 2); + await h.pump(tester); + h.mail.arrive( + kInboxId, + messageJson('new', mailboxId: kInboxId, subject: '새로 온 메일'), + ); + + await tester.fling(find.text('제목 m1'), const Offset(0, 400), 1000); + await tester.pumpAndSettle(); + + expect(h.mail.listRequests, hasLength(2)); + expect(find.text('새로 온 메일'), findsOneWidget); + }); + + testWidgets('끝 근처에 닿으면 다음 쪽을 받는다', (WidgetTester tester) async { + h.mail.seed(kInboxId, 45); + await h.pump(tester); + expect(h.mail.listRequests, hasLength(1)); + + await tester.scrollUntilVisible( + find.text('제목 m20'), + 500, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + + expect(h.mail.listRequests, hasLength(2)); + expect(h.mail.listRequests.last.queryParameters['cursor'], 'c:20'); + await tester.scrollUntilVisible( + find.text('제목 m40'), + 500, + scrollable: find.byType(Scrollable).first, + ); + expect(find.text('제목 m40'), findsOneWidget); + }); + + testWidgets('받은 쪽이 모두 이미 있는 메일이어도 커서가 나아갔으면 끝 표시가 다음 쪽을 이어 받는다', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 45); + await h.pump(tester); + // 그사이 새 메일 20통이 와서 다음 쪽(c:20)이 모두 이미 보이는 메일이다 + for (int n = 20; n >= 1; n--) { + h.mail.arrive(kInboxId, messageJson('new$n', mailboxId: kInboxId)); + } + + await scrollToFooter(tester); + + expect( + h.mail.listRequests.map( + (RequestOptions r) => r.queryParameters['cursor'], + ), + [null, 'c:20', 'c:40'], + ); + expect(find.text('제목 m21'), findsOneWidget); + }); + + testWidgets('같은 커서가 새 메일 없이 다시 오면 끝에 [다시 시도] — 저절로 되풀이하지 않는다', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 25); + await h.pump(tester); + h.server.on( + 'GET', + MailApiPaths.messages, + (_) => FakeResponse.ok({ + 'items': [messageJson('m1', mailboxId: kInboxId)], + 'pageInfo': { + 'hasNext': true, + 'nextCursor': 'c:20', + 'total': 25, + }, + }), + ); + + await scrollToFooter(tester); + + expect(h.mail.listRequests, hasLength(2)); + final Finder retry = find.descendant( + of: find.byType(MailListFooter), + matching: find.text(MailListTexts.retry), + ); + expect(retry, findsOneWidget); + await tester.tap(retry); + await tester.pumpAndSettle(); + expect(h.mail.listRequests, hasLength(3)); + expect(retry, findsOneWidget); + }); + + testWidgets('메일함 목록을 받기 전에는 목록 대신 뼈대 — 받은 뒤 임시보관함 줄은 쓰기 화면에서 이어 쓴다', ( + WidgetTester tester, + ) async { + h.mail.messages[kDraftsId] = >[ + messageJson('d1', mailboxId: kDraftsId, subject: '쓰던 메일'), + ]; + final Completer boxes = Completer(); + h.mail.mailboxesGate = boxes; + await h.pump(tester, location: MailRoutes.mailbox(kDraftsId)); + + // 목록은 받아 두었지만 역할(임시보관함)을 알기 전이라 그리지 않는다 + expect( + h.mail.listRequests.map( + (RequestOptions r) => r.queryParameters['mailboxId'], + ), + contains(kDraftsId), + ); + expect(find.byType(MailListSkeleton), findsOneWidget); + expect(find.text('쓰던 메일'), findsNothing); + + boxes.complete(); + await tester.pumpAndSettle(); + await tester.tap(find.text('쓰던 메일')); + await tester.pumpAndSettle(); + + expect(pushedLocation(tester), MailRoutes.composeDraft('d1')); + }); + + testWidgets('다른 메일함 주소에서 메일함 목록을 받지 못하면 그 실패와 [다시 시도] — 다시 받으면 목록', ( + WidgetTester tester, + ) async { + h.mail.seed(kClientsId, 1, prefix: 'c'); + h.mail.mailboxesFailure = FakeResponse.fail(503, 'MAIL_001'); + await h.pump(tester, location: MailRoutes.mailbox(kClientsId)); + + expect(findCopy(ErrorLexicon.getMessage('MAIL_001')), findsOneWidget); + expect(find.text('제목 c1'), findsNothing); + + h.mail.mailboxesFailure = null; + await tester.tap(find.text(MailListTexts.retry)); + await tester.pumpAndSettle(); + + expect(find.text('제목 c1'), findsOneWidget); + expect(find.text('거래처'), findsOneWidget); + }); + + testWidgets('[찾기]는 검색 화면을 위에 쌓고(탭 막대는 그대로), [메일 쓰기]는 쓰기 화면을 연다', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 1); + await h.pump(tester); + + await tester.tap(find.byTooltip(MailListTexts.search)); + await tester.pumpAndSettle(); + expect(find.byType(MailSearchPage), findsOneWidget); + expect(find.byType(NavigationBar), findsOneWidget); + await tester.tap(find.byTooltip(MailSearchTexts.close)); + await tester.pumpAndSettle(); + expect(find.byType(MailSearchPage), findsNothing); + + await tester.tap(find.text(MailListTexts.compose)); + await tester.pumpAndSettle(); + expect(pushedLocation(tester), MailRoutes.compose); + expect(find.byType(NavigationBar), findsNothing); + }); + + testWidgets('전역 알림(API 실패)은 이 화면에 뜨고 [메일 쓰기] · 알림 줄을 가리지 않는다', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 2); + await h.pump(tester); + await longPress(tester, '제목 m1'); + await tester.tap(find.byTooltip(MailBulkTexts.delete)); + await tester.pumpAndSettle(); + + h.container + .read(scaffoldMessengerKeyProvider) + .currentState! + .showSnackBar( + SnackBar(content: Text(ErrorLexicon.getMessage('MAIL_001'))), + ); + await tester.pumpAndSettle(); + + final Rect snack = tester.getRect(find.byType(SnackBar)); + final Rect compose = tester.getRect( + find.widgetWithText(FloatingActionButton, MailListTexts.compose), + ); + expect(snack.bottom, lessThanOrEqualTo(compose.top)); + expect( + find.descendant( + of: find.byType(MailListPage), + matching: find.byType(SnackBar), + ), + findsOneWidget, + ); + }); + }); + + group('새 메일 확인 (1분 · 손 시계)', () { + testWidgets('화면이 보이는 동안 1분마다 묻는다 — 처음 값은 기준, 바뀌면 불러온 쪽을 맞춘다', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 2); + await h.pump(tester); + expect(h.mail.stateRequests, 1); + expect(h.mail.listRequests, hasLength(1)); + + await elapse(tester, kMailStatePollInterval - const Duration(seconds: 1)); + expect(h.mail.stateRequests, 1); + await elapse(tester, const Duration(seconds: 1)); + expect(h.mail.stateRequests, 2); + expect(h.mail.listRequests, hasLength(1), reason: '값이 같으면 맞추지 않는다'); + + h.mail + ..state = 'state-2' + ..arrive( + kInboxId, + messageJson('new', mailboxId: kInboxId, subject: '새 메일'), + ); + await elapse(tester, kMailStatePollInterval); + + expect(h.mail.stateRequests, 3); + expect(h.mail.listRequests, hasLength(2)); + expect(find.text('새 메일'), findsOneWidget); + expect(h.notifier.shownCodes, isEmpty); + }); + + testWidgets('다른 탭에 있는 동안은 묻지 않고, 돌아오면 10초가 지났으니 바로 묻는다', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 1); + await h.pump(tester); + expect(h.mail.stateRequests, 1); + + await tester.tap(find.text('설정')); + await tester.pumpAndSettle(); + await elapse(tester, kMailStatePollInterval * 2); + expect(h.mail.stateRequests, 1); + + await tester.tap(find.text('메일')); + await tester.pumpAndSettle(); + expect(h.mail.stateRequests, 2); + }); + + testWidgets('앱이 뒤로 가면 멈추고, 앞으로 오면 마지막으로 물은 지 10초가 지났을 때만 바로 묻는다', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 1); + await h.pump(tester); + expect(h.mail.stateRequests, 1); + + void lifecycle(List states) { + for (final AppLifecycleState state in states) { + tester.binding.handleAppLifecycleStateChanged(state); + } + } + + const List toBackground = [ + AppLifecycleState.inactive, + AppLifecycleState.hidden, + AppLifecycleState.paused, + ]; + const List toForeground = [ + AppLifecycleState.hidden, + AppLifecycleState.inactive, + AppLifecycleState.resumed, + ]; + + lifecycle(toBackground); + await elapse(tester, kMailStatePollInterval * 2); + expect(h.mail.stateRequests, 1); + + lifecycle(toForeground); + await tester.pumpAndSettle(); + expect(h.mail.stateRequests, 2); + + // 5초 만에 다시 왔다 — 바로 묻지 않고 원래 차례(마지막으로 물은 지 1분)에 + lifecycle(toBackground); + await elapse(tester, const Duration(seconds: 5)); + lifecycle(toForeground); + await tester.pumpAndSettle(); + expect(h.mail.stateRequests, 2); + await elapse(tester, kMailStatePollInterval - const Duration(seconds: 5)); + expect(h.mail.stateRequests, 3); + }); + + testWidgets('확인이 실패해도 알리지 않고 다음 차례에 다시 묻는다', (WidgetTester tester) async { + h.mail.seed(kInboxId, 1); + await h.pump(tester); + h.mail.stateFailure = FakeResponse.fail(503, 'MAIL_001'); + + await elapse(tester, kMailStatePollInterval); + expect(h.mail.stateRequests, 2); + expect(h.notifier.shownCodes, isEmpty); + + h.mail.stateFailure = null; + await elapse(tester, kMailStatePollInterval); + expect(h.mail.stateRequests, 3); + }); + + testWidgets('아직 보내지 않은 메일은 보낼 시각 + 2초에 목록을 다시 본다(엔진이 풀어도 상태가 바뀌지 않는다)', ( + WidgetTester tester, + ) async { + final DateTime sendAt = h.clock.now.add(const Duration(seconds: 8)); + h.mail.messages[kSentId] = >[ + messageJson( + 's1', + mailboxId: kSentId, + subject: '보내는 메일', + isRead: true, + pendingSend: pendingJson(sendAt), + ), + ]; + await h.pump(tester, location: MailRoutes.mailbox(kSentId)); + expect(find.text(MailListTexts.sending), findsOneWidget); + final int before = h.mail.listRequests.length; + h.mail.messages[kSentId]!.first['pendingSend'] = null; + + await elapse(tester, const Duration(seconds: 9)); + expect(h.mail.listRequests.length, before); + await elapse(tester, const Duration(seconds: 1)); + + expect(h.mail.listRequests.length, before + 1); + expect(find.text(MailListTexts.sending), findsNothing); + }); + }); + + group('아직 보내지 않은 메일 다시 보기 (손 시계)', () { + testWidgets('다시 보다 실패해도(연결 끊김) 5초 뒤 다시 본다', (WidgetTester tester) async { + final DateTime sendAt = h.clock.now.add(const Duration(seconds: 8)); + h.mail.messages[kSentId] = >[ + messageJson( + 's1', + mailboxId: kSentId, + subject: '보내는 메일', + isRead: true, + pendingSend: pendingJson(sendAt), + ), + ]; + await h.pump(tester, location: MailRoutes.mailbox(kSentId)); + final int before = h.mail.listRequests.length; + h.mail.listFailure = FakeResponse.fail(503, 'MAIL_001'); + + await elapse(tester, const Duration(seconds: 10)); + expect(h.mail.listRequests.length, before + 1); + expect(find.text(MailListTexts.sending), findsOneWidget); + expect(h.notifier.shownCodes, isEmpty, reason: '맞추기 실패는 알리지 않는다'); + + h.mail.listFailure = null; + h.mail.messages[kSentId]!.first['pendingSend'] = null; + await elapse(tester, kHeldReleaseRecheck); + + expect(h.mail.listRequests.length, before + 2); + expect(find.text(MailListTexts.sending), findsNothing); + }); + + testWidgets('보낼 시각은 서버 시계로 잰다 — 기기 시계가 5분 늦어도 제때 다시 본다', ( + WidgetTester tester, + ) async { + final DateTime serverNow = h.clock.now.add(const Duration(minutes: 5)); + h.server.serverTime = serverNow; + h.mail.messages[kSentId] = >[ + messageJson( + 's1', + mailboxId: kSentId, + subject: '보내는 메일', + isRead: true, + pendingSend: pendingJson(serverNow.add(const Duration(seconds: 8))), + ), + ]; + await h.pump(tester, location: MailRoutes.mailbox(kSentId)); + expect(find.text(MailListTexts.sending), findsOneWidget); + final int before = h.mail.listRequests.length; + h.mail.messages[kSentId]!.first['pendingSend'] = null; + + await elapse(tester, const Duration(seconds: 9)); + expect(h.mail.listRequests.length, before); + await elapse(tester, const Duration(seconds: 1)); + + expect(h.mail.listRequests.length, before + 1); + expect(find.text(MailListTexts.sending), findsNothing); + }); + }); + + testWidgets('세션이 끝나면(로그아웃) 메일 상태가 사라진다', (WidgetTester tester) async { + h.mail.seed(kInboxId, 1); + await h.pump(tester); + h.server.on( + 'POST', + '/auth/app/logout', + (RequestOptions _) => FakeResponse.ok(), + ); + h.container + .read(mailToastProvider.notifier) + .offer('알림', actionLabel: MailBulkTexts.undo, onAction: () async {}); + await tester.pumpAndSettle(); + + h.router.go(SettingsRoutes.home); + await tester.pumpAndSettle(); + final Finder signOut = find.widgetWithText(OutlinedButton, '로그아웃'); + await tester.ensureVisible(signOut); + await tester.pumpAndSettle(); + await tester.tap(signOut); + await tester.pumpAndSettle(); + // 확인 창의 [로그아웃] + await tester.tap( + find.descendant( + of: find.byType(AlertDialog), + matching: find.text('로그아웃'), + ), + ); + await tester.pumpAndSettle(); + // 확인 창이 닫히는 동안 로그아웃한다 — 마지막 그림에서 떼어진 프로바이더는 그다음 차례에 정리된다 + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.byType(MailListPage), findsNothing); + expect(h.container.exists(mailToastProvider), isFalse); + expect(h.container.exists(mailStateWatcherProvider), isFalse); + }); +} + +/// 고를 수 없는 메일(내일 보낼 예약) — 사진을 누르면 단추 없는 알림이 뜬다 +const String _heldSubject = '예약한 메일'; + +Map _heldMessage(DateTime now) => messageJson( + 'held', + mailboxId: kInboxId, + subject: _heldSubject, + isRead: true, + receivedAt: now.subtract(const Duration(days: 1)), + pendingSend: pendingJson(now.add(const Duration(days: 1)), kind: 'scheduled'), +); diff --git a/flutter/test/features/mail/mail_reader_rules_test.dart b/flutter/test/features/mail/mail_reader_rules_test.dart new file mode 100644 index 0000000..dc39a53 --- /dev/null +++ b/flutter/test/features/mail/mail_reader_rules_test.dart @@ -0,0 +1,506 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:arasystem_app/core/files/file_names.dart'; +import 'package:arasystem_app/core/utils/file_size.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_address.dart'; +import 'package:arasystem_app/features/mail/data/models/mailbox.dart'; +import 'package:arasystem_app/features/mail/data/models/message_detail.dart'; +import 'package:arasystem_app/features/mail/data/models/message_summary.dart'; +import 'package:arasystem_app/features/mail/domain/compose_request.dart'; +import 'package:arasystem_app/features/mail/domain/mail_body_notices.dart'; +import 'package:arasystem_app/features/mail/domain/mail_images.dart'; +import 'package:arasystem_app/features/mail/domain/mail_links.dart'; +import 'package:arasystem_app/features/mail/mail_routes.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; +import 'package:arasystem_app/features/mail/presentation/message_reader_view.dart'; +import 'package:arasystem_app/features/mail/presentation/pages/message_reader_page.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/attachment_downloads.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mailboxes_provider.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/reader_bars.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/mail_fakes.dart'; + +/// 읽기 화면의 규칙 — 순수 함수 (링크 · mailto · 쓰기 화면 주소 · 파일 이름 · 크기 · 그림 판정 · 안내 · 머리 표기 · 메뉴) +void main() { + MessageDetail detailOf( + Map overrides, { + String id = 'm1', + String mailboxId = kInboxId, + }) { + final MessageDetail? detail = MessageDetail.tryParse({ + ...messageJson(id, mailboxId: mailboxId), + 'cc': [], + 'bcc': [], + 'htmlBody': null, + 'textBody': '본문', + 'hasBlockedImages': false, + 'attachments': [], + ...overrides, + }); + return detail!; + } + + group('본문 링크 · 탐색 판정', () { + test('처음 넣은 문서(about:blank)만 연다 — 아래 틀은 열지 않는다', () { + expect( + mailNavigationOf('about:blank', isMainFrame: true), + isA(), + ); + expect( + mailNavigationOf('about:blank', isMainFrame: false), + isA(), + ); + expect( + mailNavigationOf('https://ok.example', isMainFrame: false), + isA(), + ); + }); + + test('http · https 는 바깥 앱으로, mailto 는 쓰기 화면으로, 그 밖은 무시', () { + final MailNavigation web = mailLinkOf('https://www.example.com/a?b=1'); + expect(web, isA()); + expect((web as OpenExternally).uri.host, 'www.example.com'); + expect(mailLinkOf('HTTP://Example.com'), isA()); + expect(mailLinkOf('mailto:kim@example.com'), isA()); + for (final String url in [ + 'javascript:alert(1)', + 'file:///data/data/kr.it.arasystem.app/x', + 'content://kr.it.arasystem.app.downloads/downloads/a', + 'intent://scan/#Intent;scheme=zxing;end', + 'tel:01012345678', + 'about:srcdoc', + '/api/messages', + 'https://', + 'https://ok.example/\u0000x', + '', + 'https://ok.example/${'a' * kMaxExternalLinkLength}', + ]) { + expect(mailLinkOf(url), isA(), reason: url); + } + }); + + test('mailto — 받는 사람 · 참조 · 숨은참조 · 제목 · 본문을 풀고, 틀린 주소 · 겹친 주소는 뺀다', () { + final MailNavigation link = mailLinkOf( + 'mailto:kim@example.com,%20lee@example.com,bad,KIM@example.com' + '?cc=park@example.com&bcc=choi@example.com&subject=%ED%9A%8C%EC%9D%98%0A%EC%9D%BC%EC%A0%95' + '&body=%EC%95%88%EB%85%95%0A%ED%95%98%EC%84%B8%EC%9A%94&to=yoon@example.com', + ); + final ComposePrefill prefill = (link as OpenCompose).prefill; + expect(prefill.to, [ + 'kim@example.com', + 'lee@example.com', + 'yoon@example.com', + ]); + expect(prefill.cc, ['park@example.com']); + expect(prefill.bcc, ['choi@example.com']); + expect(prefill.subject, '회의 일정'); + expect(prefill.body, '안녕\n하세요'); + }); + + test('mailto — 제목 · 본문은 한도에서 자른다', () { + final ComposePrefill prefill = composePrefillOfMailto( + Uri.parse( + 'mailto:?subject=${'가' * 300}&body=${'나' * (kComposeMaxPrefillBody + 10)}', + ), + )!; + expect(prefill.to, isEmpty); + expect(prefill.subject.length, kComposeMaxSubject); + expect(prefill.body.length, kComposeMaxPrefillBody); + expect(composePrefillOfMailto(Uri.parse('https://x.example')), isNull); + }); + }); + + group('쓰기 화면 주소', () { + test('답장 · 전체 답장 · 전달 · 이어 쓰기는 웹과 같은 쿼리다', () { + expect(MailRoutes.composeReply('m 1'), '/mail/compose?replyTo=m+1'); + expect( + MailRoutes.composeReply('m1', all: true), + '/mail/compose?replyTo=m1&all=1', + ); + expect(MailRoutes.composeForward('m1'), '/mail/compose?forward=m1'); + expect(MailRoutes.composeDraft('d1'), '/mail/compose?draft=d1'); + }); + + test('쿼리 → 여는 까닭 (이어 쓰기 → 답장 → 전달 차례)', () { + expect(composeIntentOf({}), isA()); + final ComposeIntent reply = composeIntentOf( + Uri.parse(MailRoutes.composeReply('m1', all: true)).queryParameters, + ); + expect(reply, isA()); + expect((reply as ReplyComposeIntent).messageId, 'm1'); + expect(reply.all, isTrue); + expect( + composeIntentOf({'replyTo': 'm1'}), + isA().having( + (ReplyComposeIntent r) => r.all, + 'all', + isFalse, + ), + ); + expect( + composeIntentOf({'forward': 'm2', 'draft': 'd1'}), + isA(), + ); + expect( + composeIntentOf({'forward': 'm2'}), + isA(), + ); + }); + }); + + group('받은 파일 이름', () { + test('경로 문자는 밑줄로, 제어 · 방향 제어 · 보이지 않는 글자는 지운다', () { + expect(safeFileName('../../etc/passwd'), '_.._etc_passwd'); + expect(safeFileName(r'a\b:c*d?e"fh|i.txt'), 'a_b_c_d_e_f_g_h_i.txt'); + expect(safeFileName('보고\u0000서\u001F.pdf'), '보고서.pdf'); + expect(safeFileName('invoice\u202Efdp.exe'), 'invoicefdp.exe'); + expect(safeFileName('\u200B\uFEFF견적.xlsx'), '견적.xlsx'); + }); + + test('앞뒤 공백 · 점을 지우고, 비면 기본 이름', () { + expect(safeFileName(' .hidden.txt. '), 'hidden.txt'); + expect(safeFileName('..'), kFallbackFileName); + expect(safeFileName(' \u0007 '), kFallbackFileName); + }); + + test('UTF-8 200바이트 안으로 줄이고 확장자는 남긴다', () { + final String name = safeFileName('${'가' * 120}.docx'); + expect(utf8.encode(name).length, lessThanOrEqualTo(kMaxFileNameBytes)); + expect(name, endsWith('.docx')); + expect(name, startsWith('가가가')); + final String noExtension = safeFileName('a' * 400); + expect(noExtension.length, kMaxFileNameBytes); + }); + }); + + group('파일 크기 표기 (웹 formatFileSize 와 같다)', () { + test('B · KB(정수) · MB(10 미만은 소수 한 자리)', () { + expect(formatFileSize(0), '0 B'); + expect(formatFileSize(1023), '1023 B'); + expect(formatFileSize(248 * 1024), '248 KB'); + expect(formatFileSize(1024 * 1024 - 1), '1 MB'); + expect(formatFileSize((1.2 * 1024 * 1024).round()), '1.2 MB'); + expect(formatFileSize(3 * 1024 * 1024), '3 MB'); + expect(formatFileSize(38 * 1024 * 1024), '38 MB'); + expect(formatFileSize(-1), ''); + }); + }); + + group('외부 이미지 판정', () { + test('앞 바이트로 PNG · JPEG · GIF · WebP 만 그림으로 본다', () { + expect(sniffImageType(Uint8List.fromList(kTinyPng)), 'image/png'); + expect( + sniffImageType(Uint8List.fromList([0xFF, 0xD8, 0xFF, 0xE0])), + 'image/jpeg', + ); + expect( + sniffImageType(Uint8List.fromList(ascii.encode('GIF89a...'))), + 'image/gif', + ); + expect( + sniffImageType( + Uint8List.fromList( + ascii.encode('RIFF\u0000\u0000\u0000\u0000WEBPVP8 '), + ), + ), + 'image/webp', + ); + expect( + sniffImageType( + Uint8List.fromList(utf8.encode('')), + ), + isNull, + ); + expect(sniffImageType(Uint8List(0)), isNull); + }); + + test('그림 하나 5MB · 합계 10MB 를 넘으면 넣지 않는다', () { + final Uint8List png = Uint8List.fromList(kTinyPng); + expect( + acceptMailImage(png, usedBytes: 0), + startsWith('data:image/png;base64,'), + ); + expect( + acceptMailImage(png, usedBytes: kMailImagesTotalMaxBytes - 1), + isNull, + ); + final Uint8List big = Uint8List(kMailImageMaxBytes + 1) + ..setAll(0, kTinyPng); + expect(acceptMailImage(big, usedBytes: 0), isNull); + }); + }); + + group('본문 안내 (웹 mailBodyNotices 와 같다)', () { + test('생략 → 잘림 차례, 보여 줄 본문이 없으면 잘림은 뺀다', () { + expect( + mailBodyNoticesOf( + textBody: '글', + isHtmlBodyOmitted: true, + isBodyTruncated: true, + ), + [MailBodyNotice.htmlOmitted, MailBodyNotice.truncated], + ); + expect( + mailBodyNoticesOf( + textBody: ' ', + isHtmlBodyOmitted: true, + isBodyTruncated: true, + ), + [MailBodyNotice.unavailable], + ); + expect( + mailBodyNoticesOf( + textBody: null, + isHtmlBodyOmitted: false, + isBodyTruncated: true, + ), + [MailBodyNotice.truncated], + ); + expect( + mailBodyNoticesOf( + textBody: null, + isHtmlBodyOmitted: false, + isBodyTruncated: false, + ), + isEmpty, + ); + }); + }); + + group('머리 표기', () { + final DateTime now = DateTime(2026, 11, 5, 15); + + test('시각 — 오늘 · 어제 · 올해 · 그 전 (기기 시간대)', () { + expect(formatReaderTime(DateTime(2026, 11, 5, 10, 42), now), '오전 10:42'); + expect( + formatReaderTime(DateTime(2026, 11, 4, 16, 20), now), + '어제 오후 4:20', + ); + expect( + formatReaderTime(DateTime(2026, 3, 1, 9, 5), now), + '3월 1일 오전 9:05', + ); + expect( + formatReaderTime(DateTime(2025, 10, 24, 12, 30), now), + '2025. 10. 24. 오후 12:30', + ); + expect( + formatReaderFullDate(DateTime(2026, 11, 4, 16, 20)), + '2026년 11월 4일 (수) 오후 4:20', + ); + }); + + test('받는 사람 줄 — 지금 직원이면 "나", 받는 사람 · 참조 · 숨은참조를 합쳐 센다', () { + final MessageDetail toMe = detailOf({ + 'to': [addressJson('김아라', 'Ara.Kim@arasystem.it.kr')], + }); + expect( + recipientSummaryOf(toMe, myEmail: 'ara.kim@arasystem.it.kr'), + '받는 사람 나', + ); + final MessageDetail withCc = detailOf({ + 'to': [addressJson('김아라', 'ara.kim@arasystem.it.kr')], + 'cc': [ + addressJson('박준호', 'junho@arasystem.it.kr'), + addressJson('김아라', 'ara.kim@arasystem.it.kr'), + ], + }); + expect( + recipientSummaryOf(withCc, myEmail: 'ara.kim@arasystem.it.kr'), + '받는 사람 나 외 1명', + ); + final MessageDetail others = detailOf({ + 'to': [ + addressJson('박준호', 'junho@arasystem.it.kr'), + {'name': null, 'email': 'x@example.com'}, + ], + 'bcc': [addressJson('최', 'choi@example.com')], + }); + expect( + recipientSummaryOf(others, myEmail: 'ara.kim@arasystem.it.kr'), + '받는 사람 박준호 외 2명', + ); + expect( + recipientSummaryOf(detailOf(const {}), myEmail: null), + MailReaderTexts.noRecipients, + ); + }); + + test('펼친 칸의 줄 — 이름이 없거나 주소와 같으면 주소만', () { + expect(readerAddressLine(const MailAddress(email: 'a@x.kr', name: '김')), ( + name: '김', + email: 'a@x.kr', + )); + expect(readerAddressLine(const MailAddress(email: 'a@x.kr')), ( + name: 'a@x.kr', + email: null, + )); + expect( + readerAddressLine(const MailAddress(email: 'a@x.kr', name: 'a@x.kr')), + (name: 'a@x.kr', email: null), + ); + }); + + test('붙잡힌 메일의 안내 — 예약은 시각, 보내기 취소를 기다리면 "곧 보낼 메일입니다."', () { + expect( + pendingNoticeOf( + PendingSend( + sendAt: DateTime(2026, 11, 7, 9), + kind: PendingSendKind.scheduled, + ), + ), + '11월 7일 (토) 오전 9:00에 보낼 메일입니다.', + ); + expect( + pendingNoticeOf( + PendingSend( + sendAt: DateTime(2026, 11, 7), + kind: PendingSendKind.undo, + ), + ), + MailReaderTexts.sendingNotice, + ); + }); + }); + + group('삭제 · 메뉴', () { + const Mailbox trash = Mailbox( + id: kTrashId, + name: '휴지통', + role: MailboxRole.trash, + parentId: null, + sortOrder: 0, + totalCount: 0, + unreadCount: 0, + ); + + test('휴지통에만 든 메일만 영구 삭제다 — 다른 메일함에도 있으면 휴지통으로 옮기기', () { + MessageSummary inBoxes(List ids) => + detailOf({'mailboxIds': ids}).summary; + expect(deletesPermanently(inBoxes([kTrashId]), trash), isTrue); + expect( + deletesPermanently(inBoxes([kTrashId, kInboxId]), trash), + isFalse, + ); + expect(deletesPermanently(inBoxes([kInboxId]), trash), isFalse); + expect(deletesPermanently(inBoxes([kTrashId]), null), isFalse); + }); + + MailboxesState ready() => MailboxesState( + status: MailboxesStatus.ready, + items: [ + for (final Map json in standardMailboxes()) + Mailbox.tryParse(json)!, + ], + ); + + test('메뉴 — 읽음 · 중요는 늘, 스팸메일함에서는 스팸 아님, 붙잡힌 메일은 이동 · 스팸이 없다', () { + final List normal = [ + for (final ReaderMenuItem item in readerMenuOf( + detailOf({'isRead': true}), + ready(), + )) + item.label, + ]; + expect(normal, [ + MailReaderTexts.markUnread, + MailReaderTexts.flag, + MailReaderTexts.move, + MailReaderTexts.reportSpam, + ]); + final List junk = [ + for (final ReaderMenuItem item in readerMenuOf( + detailOf({ + 'isFlagged': true, + 'mailboxIds': [kJunkId], + }), + ready(), + )) + item.label, + ]; + expect(junk, [ + MailReaderTexts.markRead, + MailReaderTexts.unflag, + MailReaderTexts.move, + MailReaderTexts.notSpam, + ]); + final List held = [ + for (final ReaderMenuItem item in readerMenuOf( + detailOf({ + 'mailboxIds': [kSentId], + 'pendingSend': pendingJson(DateTime.utc(2026, 11, 7)), + }), + ready(), + )) + item.label, + ]; + expect(held, [MailReaderTexts.markRead, MailReaderTexts.flag]); + // 메일함 목록을 받기 전에는 옮길 곳 · 스팸메일함을 몰라 이동 · 스팸이 없다 + expect( + readerMenuOf( + detailOf(const {}), + const MailboxesState(), + ), + hasLength(2), + ); + }); + }); + + group('메일 한 통 읽기 (MessageDetail)', () { + test('9단계 뒤 칸이 없으면 거짓 · 빈 목록으로 읽고, 본문 그림은 첨부 목록에서 뺀다', () { + final MessageDetail detail = detailOf({ + 'attachments': [ + attachmentJson('a1'), + attachmentJson( + 'a2', + name: 'sig.png', + type: 'image/png', + isInline: true, + ), + ], + 'largeFiles': [ + {'fileId': 'f1', 'name': '큰파일.zip', 'size': 3}, + {'fileId': 1}, + ], + }); + expect(detail.imagesShown, isFalse); + expect(detail.isBodyTruncated, isFalse); + expect( + detail.listedAttachments.map((MessageAttachment a) => a.id), + ['a1'], + ); + expect(detail.largeFiles.single.name, '큰파일.zip'); + }); + + test('모양이 틀리면 null — 첨부 · 본문 칸의 형식', () { + expect(detailOfOrNull({'htmlBody': 3}), isNull); + expect( + detailOfOrNull({ + 'attachments': [ + {'id': 'a'}, + ], + }), + isNull, + ); + expect(detailOfOrNull({'cc': 'x'}), isNull); + }); + }); + + group('첨부 받기 진행', () { + test('비율 — 전체를 모르면 null', () { + expect(const AttachmentDownload(received: 64, total: 100).percent, 64); + expect(const AttachmentDownload(received: 64, total: 100).fraction, 0.64); + expect(const AttachmentDownload(received: 5).percent, isNull); + expect(const AttachmentDownload(received: 500, total: 100).percent, 100); + }); + }); +} + +MessageDetail? detailOfOrNull(Map overrides) => + MessageDetail.tryParse({ + ...messageJson('m1', mailboxId: kInboxId), + 'textBody': '본문', + ...overrides, + }); diff --git a/flutter/test/features/mail/mail_repository_test.dart b/flutter/test/features/mail/mail_repository_test.dart new file mode 100644 index 0000000..2d2c9cf --- /dev/null +++ b/flutter/test/features/mail/mail_repository_test.dart @@ -0,0 +1,646 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:arasystem_app/core/api/request_flags.dart'; +import 'package:arasystem_app/core/files/downloaded_files.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_bulk_results.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_page.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_search.dart'; +import 'package:arasystem_app/features/mail/data/models/mailbox.dart'; +import 'package:arasystem_app/features/mail/data/models/message_detail.dart'; +import 'package:arasystem_app/features/mail/data/models/message_summary.dart'; +import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/fakes.dart'; +import '../../support/file_fakes.dart'; +import '../../support/mail_fakes.dart'; + +/// 메일 저장소 — 계약 2절의 경로 · 본문 · 응답 읽기 (인터셉터 줄을 그대로 탄다) +void main() { + late ApiHarness h; + late MailRepository repository; + + setUp(() async { + h = ApiHarness(); + await h.prepare(); + await h.signIn(); + repository = MailRepository(h.dio); + }); + + test('메일함 목록 — Bearer 로 부르고 읽는다, 실패는 알리지 않는다', () async { + h.server.on( + 'GET', + MailApiPaths.mailboxes, + (_) => FakeResponse.ok({ + 'items': standardMailboxes(), + 'pageInfo': {'hasNext': false, 'nextCursor': null}, + }), + ); + + final List mailboxes = await repository.listMailboxes(); + + expect(mailboxes, hasLength(8)); + expect(mailboxes.first.role, MailboxRole.inbox); + expect(bearerTokenOf(h.server.requests.single), 'access-1'); + + h.server.on( + 'GET', + MailApiPaths.mailboxes, + (_) => FakeResponse.fail(503, 'MAIL_001'), + ); + expect( + apiErrorOf(await errorOf(repository.listMailboxes())).code, + 'MAIL_001', + ); + expect(h.notifier.shownCodes, isEmpty); + }); + + test('목록 — 메일함 · 쪽 크기 · 커서를 그대로 보낸다', () async { + h.server.on( + 'GET', + MailApiPaths.messages, + (_) => FakeResponse.ok({ + 'items': [messageJson('m1', mailboxId: kInboxId)], + 'pageInfo': { + 'hasNext': true, + 'nextCursor': 'opaque==', + 'total': 21, + }, + }), + ); + + final MailPage page = await repository.listMessages( + mailboxId: kInboxId, + cursor: 'abc', + limit: 50, + ); + + expect(page.items.single.id, 'm1'); + expect(page.pageInfo.nextCursor, 'opaque=='); + expect(page.pageInfo.total, 21); + expect(h.server.requests.single.queryParameters, { + 'mailboxId': kInboxId, + 'limit': 50, + 'cursor': 'abc', + }); + }); + + test('목록 — 응답의 Date(서버 시각)를 쪽에 싣는다, 없으면 null', () async { + h.server.on( + 'GET', + MailApiPaths.messages, + (_) => FakeResponse.ok({ + 'items': [], + 'pageInfo': {'hasNext': false, 'nextCursor': null}, + }), + ); + final DateTime serverTime = DateTime.utc(2026, 11, 6, 1, 2, 3); + h.server.serverTime = serverTime; + + final MailPage page = await repository.listMessages( + mailboxId: kInboxId, + ); + expect(page.serverTime, serverTime); + + h.server.serverTime = null; + final MailPage withoutDate = await repository.listMessages( + mailboxId: kInboxId, + ); + expect(withoutDate.serverTime, isNull); + }); + + test('응답 모양이 계약과 다르면 SYS_001', () async { + h.server.on( + 'GET', + MailApiPaths.messages, + (_) => FakeResponse.ok({ + 'items': [ + {'id': 'm1'}, + ], + 'pageInfo': {'hasNext': false}, + }), + ); + h.server.on( + 'GET', + MailApiPaths.mailState, + (_) => FakeResponse.ok({'state': 3}), + ); + + expect( + apiErrorOf( + await errorOf(repository.listMessages(mailboxId: kInboxId)), + ).code, + 'SYS_001', + ); + expect(apiErrorOf(await errorOf(repository.mailState())).code, 'SYS_001'); + }); + + test('한 통 · 여러 통 바꾸기 · 영구 삭제의 경로와 본문', () async { + h.server.on( + 'PATCH', + MailApiPaths.message('a/b'), + (RequestOptions options) => FakeResponse.ok( + messageJson('a/b', mailboxId: kInboxId, isRead: true), + ), + ); + h.server.on( + 'PATCH', + MailApiPaths.messages, + (_) => FakeResponse.ok({ + 'updatedIds': ['m1'], + 'failedIds': ['m2'], + }), + ); + h.server.on( + 'POST', + MailApiPaths.messageDeletions, + (_) => FakeResponse.ok({ + 'deletedIds': ['t1'], + 'skippedIds': [], + }), + ); + + final MessageSummary one = await repository.updateMessage( + 'a/b', + const MessageChanges(isRead: true), + ); + final MessagesPatched many = await repository.updateMessages([ + 'm1', + 'm2', + ], const MessageChanges(mailboxId: kTrashId)); + final MessageDeletions deleted = await repository.deleteMessages([ + 't1', + ]); + + expect(MailApiPaths.message('a/b'), '/messages/a%2Fb'); + expect(one.isRead, isTrue); + expect(many.failedIds, ['m2']); + expect(deleted.deletedIds, ['t1']); + expect(h.server.requests[0].data, {'isRead': true}); + expect(h.server.requests[1].data, { + 'ids': ['m1', 'm2'], + 'mailboxId': kTrashId, + }); + expect(h.server.requests[2].data, { + 'ids': ['t1'], + }); + }); + + test('여러 통 바꾸기의 실패는 인터셉터가 알린다(새 메일 확인은 알리지 않는다)', () async { + h.server.on( + 'PATCH', + MailApiPaths.messages, + (_) => FakeResponse.fail(503, 'MAIL_001'), + ); + h.server.on( + 'GET', + MailApiPaths.mailState, + (_) => FakeResponse.fail(503, 'MAIL_001'), + ); + + await errorOf( + repository.updateMessages([ + 'm1', + ], const MessageChanges(isRead: true)), + ); + expect(h.notifier.shownCodes, ['MAIL_001']); + h.clock.advance(const Duration(seconds: 5)); + await errorOf(repository.mailState()); + expect(h.notifier.shownCodes, ['MAIL_001']); + }); + + group('읽기 화면', () { + Map detailJson({ + Map extra = const {}, + }) => { + ...messageJson('m1', mailboxId: kInboxId), + 'cc': [], + 'bcc': [], + 'htmlBody': '

본문

', + 'textBody': '본문', + 'hasBlockedImages': true, + 'imagesShown': false, + 'attachments': [attachmentJson('a1')], + ...extra, + }; + + test('메일 한 통 — images=show 는 [이미지 보기]에만 싣고, 처음 받기의 실패는 알리지 않는다', () async { + h.server.on( + 'GET', + MailApiPaths.message('m1'), + (_) => FakeResponse.ok(detailJson()), + ); + final DateTime serverTime = DateTime.utc(2026, 11, 6, 9); + h.server.serverTime = serverTime; + + final ({MessageDetail detail, DateTime? serverTime}) first = + await repository.getMessage('m1'); + await repository.getMessage('m1', showImages: true, silent: false); + + expect(first.detail.hasBlockedImages, isTrue); + expect(first.detail.attachments.single.id, 'a1'); + expect(first.serverTime, serverTime); + expect(h.server.requests[0].queryParameters, isEmpty); + expect(h.server.requests[1].queryParameters, { + kMailImagesQuery: kMailImagesShow, + }); + + h.server.on( + 'GET', + MailApiPaths.message('m1'), + (_) => FakeResponse.fail(404, 'RES_001'), + ); + expect( + apiErrorOf(await errorOf(repository.getMessage('m1'))).code, + 'RES_001', + ); + expect(h.notifier.shownCodes, isEmpty); + expect( + apiErrorOf( + await errorOf( + repository.getMessage('m1', showImages: true, silent: false), + ), + ).code, + 'RES_001', + ); + expect(h.notifier.shownCodes, ['RES_001']); + }); + + test( + '붙잡은 발송 취소 — DELETE /scheduled-messages/{id} 의 draftId, 이미 보냈으면 MAIL_014 를 알린다', + () async { + h.server.on( + 'DELETE', + MailApiPaths.scheduledMessage('s/1'), + (_) => FakeResponse.ok({'draftId': 's/1'}), + ); + expect( + MailApiPaths.scheduledMessage('s/1'), + '/scheduled-messages/s%2F1', + ); + expect(await repository.cancelPendingSend('s/1'), 's/1'); + + h.server.on( + 'DELETE', + MailApiPaths.scheduledMessage('s/1'), + (_) => FakeResponse.fail(409, 'MAIL_014'), + ); + expect( + apiErrorOf(await errorOf(repository.cancelPendingSend('s/1'))).code, + 'MAIL_014', + ); + expect(h.notifier.shownCodes, ['MAIL_014']); + }, + ); + + test('외부 이미지 — 바이트를 받고 실패는 알리지 않는다, 한도를 넘게 오면 끊는다', () async { + h.server.on( + 'GET', + MailApiPaths.mailImage('tok'), + (_) => FakeResponse.bytes(kTinyPng, contentType: 'image/png'), + ); + expect(await repository.fetchMailImage('tok', maxBytes: 1024), kTinyPng); + expect(bearerTokenOf(h.server.requests.last), 'access-1'); + + expect( + (await errorOf(repository.fetchMailImage('tok', maxBytes: 4)) + as DioException) + .type, + DioExceptionType.cancel, + ); + + h.server.on( + 'GET', + MailApiPaths.mailImage('tok'), + (_) => FakeResponse.fail(404, 'RES_001'), + ); + expect( + apiErrorOf( + await errorOf(repository.fetchMailImage('tok', maxBytes: 1024)), + ).code, + 'RES_001', + ); + expect(h.notifier.shownCodes, isEmpty); + }); + + test('첨부 받기 — 조각마다 이어 쓰고 진행을 알린다(길이 머리가 없으면 전체는 null)', () async { + final StreamController chunks = StreamController(); + h.server.on( + 'GET', + MailApiPaths.attachment('m1', 'a 1'), + (_) => FakeResponse.stream(chunks.stream, contentLength: 6), + ); + final MemoryFileStore files = MemoryFileStore(); + final DownloadTarget target = await files.create('견적서.pdf'); + final List<(int, int?)> progress = <(int, int?)>[]; + + final Future done = repository.downloadAttachment( + messageId: 'm1', + attachmentId: 'a 1', + target: target, + onProgress: (int received, int? total) => + progress.add((received, total)), + ); + await pumpEventsUntil( + () => h.server.requests.isNotEmpty, + reason: '요청이 서버에 닿기', + ); + chunks.add(Uint8List.fromList([1, 2, 3])); + await pumpEventsUntil(() => progress.isNotEmpty, reason: '첫 조각'); + chunks.add(Uint8List.fromList([4, 5, 6])); + await chunks.close(); + await done; + await target.close(); + + expect( + MailApiPaths.attachment('m1', 'a 1'), + '/messages/m1/attachments/a%201', + ); + expect(progress, <(int, int?)>[(3, 6), (6, 6)]); + expect(files.files[target.path], [1, 2, 3, 4, 5, 6]); + + h.server.on( + 'GET', + MailApiPaths.attachment('m1', 'a 1'), + (_) => FakeResponse.stream(Stream.value(Uint8List(2))), + ); + final List totals = []; + await repository.downloadAttachment( + messageId: 'm1', + attachmentId: 'a 1', + target: await files.create('b'), + onProgress: (int _, int? total) => totals.add(total), + ); + expect(totals, [null]); + }); + + test('첨부 받기의 실패 본문(스트림)도 코드로 읽는다 — 서버가 거절하면 인터셉터가 그 코드로 알린다', () async { + h.server.on( + 'GET', + MailApiPaths.attachment('m1', 'a1'), + (_) => FakeResponse.fail(409, 'FILE_008'), + ); + final MemoryFileStore files = MemoryFileStore(); + final Object error = await errorOf( + repository.downloadAttachment( + messageId: 'm1', + attachmentId: 'a1', + target: await files.create('a'), + ), + ); + // 상태(409)로만 정하면 SYS_001 이다 — 본문의 코드를 읽었다 + expect(apiErrorOf(error).code, 'FILE_008'); + expect(apiErrorOf(error).traceId, 'trace-FILE_008'); + expect(h.notifier.shownCodes, ['FILE_008']); + }); + + test( + '받는 도중 끊기거나 조각 사이 한도를 넘기면 SYS_001(DownloadInterrupted — 인터셉터는 알리지 않았다), 그만두면 취소', + () async { + final StreamController broken = + StreamController(); + h.server.on( + 'GET', + MailApiPaths.attachment('m1', 'a1'), + (_) => FakeResponse.stream(broken.stream, contentLength: 10), + ); + final MemoryFileStore files = MemoryFileStore(); + final Future failure = errorOf( + repository.downloadAttachment( + messageId: 'm1', + attachmentId: 'a1', + target: await files.create('a'), + ), + ); + await pumpEventsUntil( + () => h.server.requests.isNotEmpty, + reason: '요청이 서버에 닿기', + ); + broken.add(Uint8List(3)); + broken.addError(const SocketException('끊김(시험)')); + final Object error = await failure; + expect(error, isA()); + expect(apiErrorOf(error).code, 'SYS_001'); + expect(h.notifier.shownCodes, isEmpty); + + // 조각 사이 받기 한도(Dio 가 스트림에 싣는 receiveTimeout)도 끊긴 것이다 — 그만둔 것(cancel)만 그대로 둔다 + final StreamController stalled = + StreamController(); + h.server.on( + 'GET', + MailApiPaths.attachment('m1', 'a1'), + (_) => FakeResponse.stream(stalled.stream, contentLength: 10), + ); + final Future timedOut = errorOf( + repository.downloadAttachment( + messageId: 'm1', + attachmentId: 'a1', + target: await files.create('t'), + ), + ); + await pumpEventsUntil( + () => h.server.requests.length == 2, + reason: '두 번째 요청이 서버에 닿기', + ); + stalled.add(Uint8List(1)); + stalled.addError( + DioException.receiveTimeout( + timeout: const Duration(seconds: 15), + requestOptions: RequestOptions( + path: MailApiPaths.attachment('m1', 'a1'), + ), + ), + ); + final Object timeout = await timedOut; + expect(timeout, isA()); + expect(apiErrorOf(timeout).code, 'SYS_001'); + expect(h.notifier.shownCodes, isEmpty); + + final StreamController slow = StreamController(); + h.server.on( + 'GET', + MailApiPaths.attachment('m1', 'a1'), + (_) => FakeResponse.stream(slow.stream, contentLength: 10), + ); + final CancelToken cancel = CancelToken(); + final Future cancelled = errorOf( + repository.downloadAttachment( + messageId: 'm1', + attachmentId: 'a1', + target: await files.create('b'), + cancelToken: cancel, + ), + ); + await pumpEventsUntil( + () => h.server.requests.length == 3, + reason: '세 번째 요청이 서버에 닿기', + ); + slow.add(Uint8List(2)); + await Future.delayed(Duration.zero); + cancel.cancel(); + expect( + ((await cancelled) as DioException).type, + DioExceptionType.cancel, + ); + await slow.close(); + }, + ); + + test('바이트 응답의 실패 본문도 코드로 읽는다(그림 · 파일)', () async { + h.server.on('GET', '/files/x', (_) => FakeResponse.fail(410, 'FILE_007')); + final Object error = await errorOf( + h.dio.get>( + '/files/x', + options: Options(responseType: ResponseType.bytes), + ), + ); + expect(apiErrorOf(error).code, 'FILE_007'); + }); + }); + + group('검색 (계약 2절 "검색 — 14단계")', () { + test( + '검색어 · 메일함 · 쪽 크기 · 커서를 그대로 보낸다 — 전체 메일함이면 mailboxId 를 뺀다, silent 면 알리지 않는다', + () async { + h.server.on( + 'GET', + MailApiPaths.messages, + (_) => FakeResponse.ok({ + 'items': [ + { + ...messageJson('m1', mailboxId: kInboxId), + 'searchHighlight': null, + }, + ], + 'pageInfo': { + 'hasNext': true, + 'nextCursor': 'opaque==', + 'total': null, + }, + }), + ); + + final DateTime serverTime = DateTime.utc(2026, 11, 7, 2); + h.server.serverTime = serverTime; + + final MailPage page = await repository.searchMessages( + q: '계약', + mailboxId: kInboxId, + cursor: 'abc', + silent: true, + ); + expect(page.items.single.highlightSupported, isTrue); + expect(page.pageInfo.total, isNull); + // 결과의 붙잡힌 메일이 풀릴 때를 서버 시계로 잰다 + expect(page.serverTime, serverTime); + expect(h.server.requests.last.queryParameters, { + 'q': '계약', + 'mailboxId': kInboxId, + 'limit': kMailPageSize, + 'cursor': 'abc', + }); + expect(h.server.requests.last.extra[kSilentExtraKey], isTrue); + + await repository.searchMessages(q: '계약'); + expect(h.server.requests.last.queryParameters, { + 'q': '계약', + 'limit': kMailPageSize, + }); + expect(h.server.requests.last.extra[kSilentExtraKey], isNot(isTrue)); + }, + ); + + test('다음 쪽의 실패는 알린다 · 끊은 요청은 알리지 않는다', () async { + final Completer gate = Completer(); + h.server.on('GET', MailApiPaths.messages, (_) async { + await gate.future; + return FakeResponse.fail(503, 'MAIL_001'); + }); + final CancelToken cancel = CancelToken(); + final Future cancelled = errorOf( + repository.searchMessages(q: '계약', cursor: 'c1', cancelToken: cancel), + ); + await pumpEventsUntil(() => h.server.requests.isNotEmpty); + cancel.cancel(); + final Object error = await cancelled; + expect((error as DioException).type, DioExceptionType.cancel); + gate.complete(); + + expect( + apiErrorOf( + await errorOf(repository.searchMessages(q: '계약', cursor: 'c1')), + ).code, + 'MAIL_001', + ); + expect(h.notifier.shownCodes, ['MAIL_001']); + }); + + test( + '준비 상태 — building · ready, 모르는 값은 null, 받지 못하면(404) 알리지 않고 던진다', + () async { + Object? status = 'building'; + h.server.on( + 'GET', + MailApiPaths.searchIndex, + (_) => status == null + ? FakeResponse.fail(404, 'RES_001') + : FakeResponse.ok({ + 'status': status, + 'done': 3, + 'total': 10, + }), + ); + expect( + await repository.searchIndexStatus(), + SearchIndexStatus.building, + ); + status = 'ready'; + expect(await repository.searchIndexStatus(), SearchIndexStatus.ready); + status = 'paused'; + expect(await repository.searchIndexStatus(), isNull); + status = null; + expect( + apiErrorOf(await errorOf(repository.searchIndexStatus())).code, + 'RES_001', + ); + expect(h.notifier.shownCodes, isEmpty); + }, + ); + + test('메일 한 통 — 강조 쿼리는 빈 칸을 빼고 싣는다(외부 이미지와 함께도)', () async { + h.server.onPattern( + 'GET', + RegExp(r'^/messages/m1$'), + (_) => FakeResponse.ok({ + ...messageJson('m1', mailboxId: kInboxId), + 'searchHighlight': { + 'subject': [ + [0, 2], + ], + 'attachments': {}, + 'textBody': [], + }, + }), + ); + final ({MessageDetail detail, DateTime? serverTime}) result = + await repository.getMessage( + 'm1', + showImages: true, + highlight: const MessageHighlightQuery(q: '계약'), + ); + expect(h.server.requests.last.queryParameters, { + kMailImagesQuery: kMailImagesShow, + 'q': '계약', + }); + expect(result.detail.searchHighlight?.subject, <({int start, int end})>[ + (start: 0, end: 2), + ]); + + await repository.getMessage('m1'); + expect(h.server.requests.last.queryParameters, isEmpty); + }); + }); +} diff --git a/flutter/test/features/mail/mail_rules_test.dart b/flutter/test/features/mail/mail_rules_test.dart new file mode 100644 index 0000000..d5ca0be --- /dev/null +++ b/flutter/test/features/mail/mail_rules_test.dart @@ -0,0 +1,577 @@ +import 'package:arasystem_app/core/utils/korean_format.dart'; +import 'package:arasystem_app/core/utils/korean_text.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_bulk_results.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_page.dart'; +import 'package:arasystem_app/features/mail/data/models/mailbox.dart'; +import 'package:arasystem_app/features/mail/data/models/message_summary.dart'; +import 'package:arasystem_app/features/mail/domain/mail_bulk.dart'; +import 'package:arasystem_app/features/mail/domain/mail_list_merge.dart'; +import 'package:arasystem_app/features/mail/domain/mailbox_tree.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_row_view.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/mail_fakes.dart'; + +/// 메일 목록의 순수 규칙 — 표기 · 조사 · 줄 · 메일함 차례 · 병합 · 나눠 보내기 · 모델 읽기 (웹 규칙과 같은지) +void main() { + /// 기기 시간대의 시각을 UTC 로 — 시험이 CI(UTC) · 개발 PC(KST) 어디서나 같게 (테스트 규칙 "시간대도 입력이다") + DateTime local(int y, int m, int d, [int h = 0, int min = 0]) => + DateTime(y, m, d, h, min).toUtc(); + + MessageSummary summary(Map json) => + MessageSummary.tryParse(json)!; + + group('숫자 · 시각 표기 (웹 format.ts 와 같은 글)', () { + test('개수에 천 단위 쉼표', () { + expect(formatCount(0), '0'); + expect(formatCount(999), '999'); + expect(formatCount(1000), '1,000'); + expect(formatCount(1234567), '1,234,567'); + }); + + test('하루 안의 시각 — 오전 · 오후 · 열두 시', () { + expect(koreanTimeOfDay(DateTime(2026, 11, 6, 10, 42)), '오전 10:42'); + expect(koreanTimeOfDay(DateTime(2026, 11, 6, 15, 5)), '오후 3:05'); + expect(koreanTimeOfDay(DateTime(2026, 11, 6, 0, 30)), '오전 12:30'); + expect(koreanTimeOfDay(DateTime(2026, 11, 6, 12)), '오후 12:00'); + }); + + test('목록의 받은 시각 — 오늘 · 어제 · 올해 · 그 전', () { + final DateTime now = local(2026, 11, 6, 9, 12); + expect(formatMailListTime(local(2026, 11, 6, 8, 31), now), '오전 8:31'); + expect(formatMailListTime(local(2026, 11, 5, 23, 59), now), '어제'); + expect(formatMailListTime(local(2026, 11, 3, 10), now), '11월 3일'); + expect(formatMailListTime(local(2025, 10, 24, 10), now), '2025. 10. 24.'); + }); + + test('어제는 달 · 해의 경계를 넘는다', () { + expect( + formatMailListTime(local(2025, 12, 31, 18), local(2026, 1, 1, 8)), + '어제', + ); + expect( + formatMailListTime(local(2026, 2, 28, 18), local(2026, 3, 1, 8)), + '어제', + ); + }); + + test('붙잡아 둔 발송 — 보내는 중 · 예약 시각(요일 없이)', () { + expect( + pendingSendLabel( + PendingSend( + sendAt: local(2026, 11, 6, 9, 13), + kind: PendingSendKind.undo, + ), + ), + '보내는 중', + ); + expect( + pendingSendLabel( + PendingSend( + sendAt: local(2026, 11, 7, 9), + kind: PendingSendKind.scheduled, + ), + ), + '예약 · 11월 7일 오전 9:00', + ); + }); + }); + + group('조사 (웹 korean.ts 와 같다)', () { + test('이/가 — 받침 · 받침 없음 · 한글이 아님', () { + expect(subjectParticle('휴지통'), '이'); + expect(subjectParticle('프로젝트'), '가'); + expect(subjectParticle('Inbox'), '이(가)'); + expect(MailListTexts.emptyTitle('휴지통'), '휴지통이 비어 있습니다'); + expect(MailListTexts.emptyTitle('프로젝트'), '프로젝트가 비어 있습니다'); + // 검색 결과 없음 — 따옴표로 묶은 검색어 뒤에 붙인다 + expect( + MailSearchTexts.emptyTitle('계약'), + startsWith('${MailSearchTexts.quoted('계약')}이 '), + ); + expect( + MailSearchTexts.emptyTitle('해지 통보'), + startsWith('${MailSearchTexts.quoted('해지 통보')}가 '), + ); + expect( + MailSearchTexts.emptyTitle('ARA'), + startsWith('${MailSearchTexts.quoted('ARA')}이(가) '), + ); + }); + + test('으로/로 — 받침 · ㄹ 받침 · 받침 없음 · 한글이 아님', () { + expect(directionParticle('휴지통'), '으로'); + expect(directionParticle('보낸편지함'), '으로'); + expect(directionParticle('메일'), '로'); + expect(directionParticle('프로젝트'), '로'); + expect(directionParticle('Archive'), '(으)로'); + }); + + test('한글 자모 · 숫자는 읽는 소리로 — 자음은 받침 있음(ㄹ 은 "로"), 모음은 받침 없음', () { + // 끝 글자 → 이/가 · 으로/로 · 은/는 + for (final (String last, String subject, String direction, String topic) + in <(String, String, String, String)>[ + ('ㄱ', '이', '으로', '은'), // 기역 + ('ㄹ', '이', '로', '은'), // 리을 + ('ㅁ', '이', '으로', '은'), // 미음 + ('ㄳ', '이', '으로', '은'), // 기역시옷 — 겹자음도 받침 있음 + ('ㅏ', '가', '로', '는'), // 아 + ('0', '이', '으로', '은'), // 영 + ('1', '이', '로', '은'), // 일 + ('2', '가', '로', '는'), // 이 + ('3', '이', '으로', '은'), // 삼 + ('4', '가', '로', '는'), // 사 + ('5', '가', '로', '는'), // 오 + ('6', '이', '으로', '은'), // 육 + ('7', '이', '로', '은'), // 칠 + ('8', '이', '로', '은'), // 팔 + ('9', '가', '로', '는'), // 구 + ]) { + expect(subjectParticle(last), subject, reason: last); + expect(directionParticle(last), direction, reason: last); + expect(topicParticle(last), topic, reason: last); + } + // 낱말의 끝 글자로 본다 — 초성 검색어 · 번호로 끝나는 이름 + expect(directionParticle('ㄱㅁ'), '으로'); + expect(subjectParticle('ㄱㅁ'), '이'); + expect(directionParticle('프로젝트1'), '로'); + expect(subjectParticle('2026'), '이'); + // 라틴 글자 · 기호는 소리를 몰라 지금처럼 + expect(subjectParticle('ARA'), '이(가)'); + expect(directionParticle('100%'), '(으)로'); + expect(topicParticle('v'), '은(는)'); + }); + + test('끝의 공백은 떼고 보고, 따옴표 · 괄호는 떼지 않는다 — 그 글자로 끝나면 소리를 몰라 "이(가)" 꼴', () { + expect(subjectParticle('계약 '), '이'); + expect(directionParticle('ㄹ '), '로'); + expect(subjectParticle('계약)'), '이(가)'); + expect(subjectParticle('"계약"'), '이(가)'); + expect(directionParticle('(2)'), '(으)로'); + }); + }); + + group('여러 통 처리의 알림 글 (웹 mail-bulk.ts 와 같다)', () { + test('옮김 · 영구 삭제 · 되돌림, 그대로 둔 메일은 뒤에 덧붙인다', () { + expect(MailBulkTexts.moved(2, '휴지통'), '메일 2통을 휴지통으로 옮겼습니다.'); + expect( + MailBulkTexts.moved(1, '프로젝트', skipped: 1), + '메일 1통을 프로젝트로 옮겼습니다. 1통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.', + ); + expect( + MailBulkTexts.moved(0, '휴지통', skipped: 2), + '2통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.', + ); + expect(MailBulkTexts.purged(1234), '메일 1,234통을 영구 삭제했습니다.'); + expect(MailBulkTexts.restored(3), '메일 3통을 되돌렸습니다.'); + expect(MailBulkTexts.purgeTitle(2), '메일 2통을 영구 삭제할까요?'); + }); + + test('위 막대 아래 안 읽은 수', () { + expect(MailListTexts.unread(3), '안 읽은 메일 3통'); + expect(MailListTexts.unread(0), '안 읽은 메일이 없습니다'); + }); + }); + + group('목록 줄 (웹 mail-list-row.ts 의 분할 보기와 같다)', () { + final DateTime now = local(2026, 11, 6, 11); + + test('받은편지함 — 보낸 사람 · 머리글자 · 안 읽음 · 첨부 · 중요 · 미리 보기', () { + final MailRowView view = mailRowViewOf( + summary( + messageJson( + 'm1', + mailboxId: kInboxId, + fromName: '(주)한빛솔루션', + subject: '견적서', + preview: '첨부했습니다.', + receivedAt: local(2026, 11, 6, 10, 42), + hasAttachment: true, + isFlagged: true, + ), + ), + role: MailboxRole.inbox, + now: now, + ); + expect(view.who, '(주)한빛솔루션'); + expect(view.initial, '한'); + expect(view.time, '오전 10:42'); + expect(view.unread, isTrue); + expect(view.held, isFalse); + expect(view.selectLabel, '고르기: 견적서'); + expect( + view.semanticsLabel, + '안 읽음, (주)한빛솔루션, 오전 10:42, 견적서, 첨부 있음, 중요, 첨부했습니다.', + ); + }); + + test('이름이 없으면 주소, 보낸 사람이 없으면 "(보낸 사람 없음)" · 사람 아이콘', () { + final MailRowView noName = mailRowViewOf( + summary( + messageJson( + 'm1', + mailboxId: kInboxId, + fromName: '', + fromEmail: 'bot@example.com', + ), + ), + role: MailboxRole.inbox, + now: now, + ); + expect(noName.who, 'bot@example.com'); + expect(noName.initial, 'B'); + final MailRowView noSender = mailRowViewOf( + summary(messageJson('m2', mailboxId: kInboxId, noSender: true)), + role: MailboxRole.inbox, + now: now, + ); + expect(noSender.who, MailListTexts.noSender); + expect(noSender.initial, isNull); + }); + + test('보낸편지함 — "받는 사람: 박준호 외 2명", 받는 사람이 없으면 "받는 사람 없음"', () { + final MailRowView view = mailRowViewOf( + summary( + messageJson( + 's1', + mailboxId: kSentId, + to: >[ + addressJson('박준호', 'junho@example.com'), + addressJson('', 'b@example.com'), + addressJson('최서연', 'c@example.com'), + ], + isRead: true, + ), + ), + role: MailboxRole.sent, + now: now, + ); + expect(view.who, '받는 사람: 박준호 외 2명'); + expect(view.initial, '박'); + final MailRowView none = mailRowViewOf( + summary(messageJson('s2', mailboxId: kSentId)), + role: MailboxRole.sent, + now: now, + ); + expect(none.who, MailListTexts.noRecipients); + expect(none.whoPlaceholder, isFalse); + }); + + test('임시보관함 — 받는 사람을 쉼표로, 없으면 흐린 "(받는 사람 없음)", 읽음 여부 · 빈 제목', () { + final MailRowView view = mailRowViewOf( + summary( + messageJson( + 'd1', + mailboxId: kDraftsId, + to: >[ + addressJson('박준호', 'junho@example.com'), + addressJson('', 'infra@example.com'), + ], + ), + ), + role: MailboxRole.drafts, + now: now, + ); + expect(view.who, '박준호, infra@example.com'); + expect(view.unread, isFalse, reason: '임시 저장본은 읽음 여부를 보이지 않는다'); + final MailRowView empty = mailRowViewOf( + summary(messageJson('d2', mailboxId: kDraftsId, subject: '')), + role: MailboxRole.drafts, + now: now, + ); + expect(empty.who, MailListTexts.draftNoRecipients); + expect(empty.whoPlaceholder, isTrue); + expect(empty.subject, MailListTexts.noSubject); + expect(empty.subjectPlaceholder, isTrue); + }); + + test('아직 보내지 않은 메일 — 시각 자리에 표시(청록) · 고를 수 없다', () { + final MailRowView view = mailRowViewOf( + summary( + messageJson( + 's1', + mailboxId: kSentId, + isRead: true, + pendingSend: pendingJson(local(2026, 11, 7, 9), kind: 'scheduled'), + ), + ), + role: MailboxRole.sent, + now: now, + ); + expect(view.time, '예약 · 11월 7일 오전 9:00'); + expect(view.timePending, isTrue); + expect(view.held, isTrue); + }); + }); + + group('모델 읽기', () { + test('메일 — 칸이 틀리면 null, 붙잡아 둔 발송만은 틀리면 없는 것으로', () { + final Map json = messageJson('m1', mailboxId: kInboxId); + expect(summary(json).pendingSend, isNull); + expect( + MessageSummary.tryParse({...json, 'id': 1}), + isNull, + ); + expect( + MessageSummary.tryParse({ + ...json, + 'mailboxIds': [1], + }), + isNull, + ); + expect( + MessageSummary.tryParse({ + ...json, + 'receivedAt': 'nope', + }), + isNull, + ); + expect( + summary({ + ...json, + 'pendingSend': {'kind': 'later', 'sendAt': 'x'}, + }).pendingSend, + isNull, + ); + // 9단계 앞 서버 — 칸이 없다 + expect( + summary({...json}..remove('pendingSend')).isHeld, + isFalse, + ); + }); + + test('메일함 — 모르는 역할은 내 메일함, 목록 쪽 — 항목 하나라도 틀리면 null', () { + expect( + Mailbox.tryParse(mailboxJson('x', '보관', role: 'mystery'))!.role, + isNull, + ); + expect( + Mailbox.tryParse(mailboxJson('x', '받은편지함', role: 'inbox'))!.role, + MailboxRole.inbox, + ); + final MailPage? page = MailPage.tryParse( + { + 'items': [ + mailboxJson('a', 'A'), + {'id': 2}, + ], + 'pageInfo': {'hasNext': false, 'nextCursor': null}, + }, + Mailbox.tryParse, + ); + expect(page, isNull); + }); + + test('다음 쪽은 hasNext 이고 커서가 있을 때만', () { + expect( + MailPageInfo.tryParse({ + 'hasNext': true, + 'nextCursor': 'c:20', + 'total': 40, + })!.hasMore, + isTrue, + ); + expect( + MailPageInfo.tryParse({ + 'hasNext': true, + 'nextCursor': null, + })!.hasMore, + isFalse, + ); + }); + + test('바꿀 것 — 정한 칸만 싣는다', () { + expect(const MessageChanges(isRead: true).toJson(), { + 'isRead': true, + }); + expect( + const MessageChanges(mailboxId: 'mb-trash', isJunk: false).toJson(), + {'mailboxId': 'mb-trash', 'isJunk': false}, + ); + }); + }); + + group('메일함 차례 · 묶음 · 옮길 곳 (웹 mailbox.ts 와 같다)', () { + final List mailboxes = [ + for (final Map json in standardMailboxes()) + Mailbox.tryParse(json)!, + ]; + + test('역할 차례 → 내 메일함(sortOrder · 이름) — 하위는 부모 바로 아래 한 단 깊이', () { + final List nodes = orderMailboxes(mailboxes); + expect( + [for (final MailboxNode node in nodes) node.mailbox.name], + [ + '받은편지함', + '보낸편지함', + '임시보관함', + '스팸메일함', + '휴지통', + '거래처', + '프로젝트', + '설계', + ], + ); + expect(nodes.last.depth, 1); + final ({List system, List mine}) groups = + splitMailboxGroups(nodes); + expect(groups.system, hasLength(5)); + expect(groups.mine, hasLength(3)); + }); + + test('내 메일함 이름은 대소문자를 가리지 않고 글자 값으로 — 영문 · 한글이 섞이면 웹(ko 정렬)과 다를 수 있다', () { + final List mine = [ + for (final (String id, String name) in <(String, String)>[ + ('1', '가나다'), + ('2', 'erp'), + ('3', 'ARA 프로젝트'), + ('4', 'ara'), + ('5', 'Ara'), + ]) + Mailbox.tryParse(mailboxJson(id, name))!, + ]; + + expect( + [ + for (final MailboxNode node in orderMailboxes(mine)) + node.mailbox.name, + ], + // 같은 글(대소문자만 다름)은 원래 글자로 한 번 더 견준다 — 늘 같은 차례 + ['Ara', 'ara', 'ARA 프로젝트', 'erp', '가나다'], + ); + }); + + test('부모를 찾을 수 없거나 서로를 가리키는 메일함도 빠뜨리지 않는다', () { + final List broken = [ + Mailbox.tryParse(mailboxJson('a', 'A', parentId: 'b'))!, + Mailbox.tryParse(mailboxJson('b', 'B', parentId: 'a'))!, + Mailbox.tryParse(mailboxJson('c', 'C', parentId: 'gone'))!, + ]; + expect(orderMailboxes(broken), hasLength(3)); + }); + + test('옮길 곳은 지금 메일함 · 임시보관함 · 휴지통을 뺀다', () { + expect( + [ + for (final MailboxNode node in moveTargetsOf(mailboxes, kInboxId)) + node.mailbox.id, + ], + [kSentId, kJunkId, kClientsId, kProjectId, kProjectChildId], + ); + }); + + test('첫 화면은 받은편지함, 안 읽은 수는 보낸편지함 · 임시보관함 · 휴지통에 없다', () { + expect(defaultMailboxOf(mailboxes)!.id, kInboxId); + expect(showsUnreadCount(MailboxRole.inbox), isTrue); + expect(showsUnreadCount(null), isTrue); + expect(showsUnreadCount(MailboxRole.sent), isFalse); + expect(showsUnreadCount(MailboxRole.drafts), isFalse); + expect(showsUnreadCount(MailboxRole.trash), isFalse); + expect(MailListTexts.retentionNotice(MailboxRole.trash), isNotNull); + expect(MailListTexts.retentionNotice(MailboxRole.junk), isNotNull); + expect(MailListTexts.retentionNotice(MailboxRole.inbox), isNull); + }); + }); + + group('새 메일 확인의 병합 (웹 mail-list-merge.ts 와 같다)', () { + MessageSummary m(String id) => + summary(messageJson(id, mailboxId: kInboxId)); + const MailPageInfo oldInfo = MailPageInfo( + hasNext: true, + nextCursor: 'c:40', + total: 50, + ); + const MailPageInfo newInfo = MailPageInfo( + hasNext: true, + nextCursor: 'c:20', + total: 51, + ); + + test('다시 받은 범위는 서버 기준, 그 뒤의 옛 메일은 남기고 옛 커서를 쓴다', () { + final MailListMerge merged = mergeRefreshedList( + current: [m('a'), m('b'), m('c'), m('d')], + currentPageInfo: oldInfo, + fetched: [m('new'), m('a'), m('c')], + fetchedPageInfo: newInfo, + ); + expect( + [for (final MessageSummary x in merged.items) x.id], + ['new', 'a', 'c', 'd'], + reason: '다른 곳에서 지운 b 는 빠지고, 범위 밖의 d 는 남는다', + ); + expect(merged.pageInfo.nextCursor, 'c:40'); + expect(merged.pageInfo.total, 51); + }); + + test('다시 받은 쪽이 끝까지였으면 받지 못한 옛 메일은 지워진 것이라 남기지 않는다', () { + final MailListMerge merged = mergeRefreshedList( + current: [m('a'), m('b'), m('c')], + currentPageInfo: const MailPageInfo(hasNext: false), + fetched: [m('new'), m('a'), m('b')], + fetchedPageInfo: const MailPageInfo(hasNext: false, total: 3), + ); + expect( + [for (final MessageSummary x in merged.items) x.id], + ['new', 'a', 'b'], + ); + }); + + test('겹치는 메일이 없으면 다시 받은 것만 쓴다', () { + final MailListMerge merged = mergeRefreshedList( + current: [m('a')], + currentPageInfo: oldInfo, + fetched: [m('x'), m('y')], + fetchedPageInfo: newInfo, + ); + expect( + [for (final MessageSummary x in merged.items) x.id], + ['x', 'y'], + ); + expect(merged.pageInfo, newInfo); + }); + }); + + group('나눠 보내기 · 되돌리기 묶음', () { + test('100통씩 나눈다', () { + final List ids = [for (int i = 0; i < 250; i++) '$i']; + expect(chunked(ids, 100).map((List c) => c.length), [ + 100, + 100, + 50, + ]); + }); + + test('요청이 실패하면 그 묶음과 남은 묶음은 보내지 않은 것으로 두고 멈춘다', () async { + int calls = 0; + final BulkResult result = await sendInChunks( + [for (int i = 0; i < 250; i++) '$i'], + 100, + (List chunk) async { + calls++; + if (calls == 2) { + throw DioException(requestOptions: RequestOptions(path: '/x')); + } + return (done: chunk.sublist(1), skipped: chunk.sublist(0, 1)); + }, + ); + expect(calls, 2); + expect(result.doneIds, hasLength(99)); + expect(result.skippedIds, ['0']); + expect(result.unsentIds, hasLength(150)); + expect(result.hasLeftovers, isTrue); + }); + + test('원래 메일함별로 묶는다 — 모르는 메일은 뺀다', () { + final List groups = groupByOrigin( + ['a', 'b', 'c', 'd'], + {'a': kInboxId, 'b': kClientsId, 'c': kInboxId}, + ); + expect(groups, hasLength(2)); + expect(groups.first.mailboxId, kInboxId); + expect(groups.first.ids, ['a', 'c']); + expect(groups.last.ids, ['b']); + }); + }); +} diff --git a/flutter/test/features/mail/mail_search_conditions_test.dart b/flutter/test/features/mail/mail_search_conditions_test.dart new file mode 100644 index 0000000..49a8cb4 --- /dev/null +++ b/flutter/test/features/mail/mail_search_conditions_test.dart @@ -0,0 +1,1331 @@ +import 'package:arasystem_app/core/theme/app_colors.dart'; +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/theme/app_theme.dart'; +import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart'; +import 'package:arasystem_app/features/mail/domain/search_conditions.dart'; +import 'package:arasystem_app/features/mail/domain/search_text.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_search_view.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; +import 'package:arasystem_app/features/mail/presentation/pages/message_reader_page.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/mail_search_bar.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/mail_search_conditions_sheet.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/login_fakes.dart'; +import '../../support/mail_fakes.dart'; +import '../../support/mail_harness.dart'; + +/// 기기 시간대의 그날 0시를 UTC ISO 로 — 앱이 API 에 싣는 after · before 와 같은 셈(시험이 도는 곳의 시간대와 상관없다) +String _localIso(SearchDay day, {int addDays = 0}) => + DateTime(day.year, day.month, day.day + addDays).toUtc().toIso8601String(); + +/// 조건으로 찾기 (앱 보드 `App1SearchConditions` — 2차: [조건] · 시트 · 날짜 · 조건 줄 · 결과 글) +void main() { + late MailAppHarness h; + + setUp(() => h = MailAppHarness()); + + Finder field() => find.byKey(const ValueKey('mail-search-field')); + Finder sheet() => find.byType(MailSearchConditionsSheet); + Finder inSheet(Finder finder) => + find.descendant(of: sheet(), matching: finder); + Finder toggle() => + find.byKey(const ValueKey('mail-search-conditions')); + Finder textField(String name) => find.descendant( + of: find.byKey(ValueKey('search-condition-$name')), + matching: find.byType(TextField), + ); + Finder period() => + find.byKey(const ValueKey('search-condition-period')); + Finder attachment() => + find.byKey(const ValueKey('search-condition-attachment')); + Finder dateField(String edge) => + find.byKey(ValueKey('search-condition-$edge')); + Finder applyButton() => inSheet( + find.widgetWithText(FilledButton, MailSearchTexts.applyConditions), + ); + Finder sheetClear() => + inSheet(find.widgetWithText(TextButton, MailSearchTexts.clearConditions)); + Finder line() => find.byType(MailSearchConditionLine); + + /// 시트의 가운데(스크롤) — 끌면 칸이 움직인다(머리를 끌면 시트가 내려간다) + Finder middle() => + find.byKey(const ValueKey('search-conditions-middle')); + Finder lineClear() => find.descendant( + of: line(), + matching: find.widgetWithText(TextButton, MailSearchTexts.clearConditions), + ); + + String textOf(WidgetTester tester, String name) => + tester.widget(textField(name)).controller!.text; + + /// 기간 고르기 칸에 보이는 글 — 고른 기간 하나만 보인다(나머지 이름은 칸 안에 숨어 있다) + String periodShown(WidgetTester tester) { + final List shown = tester + .widgetList( + find.descendant(of: period(), matching: find.byType(Text)), + ) + .toList(); + expect(shown, hasLength(1)); + return shown.single.data!; + } + + bool attachmentOf(WidgetTester tester) => + tester.widget(attachment()).value!; + + bool fieldFocused(WidgetTester tester) => + tester.widget(field()).focusNode!.hasFocus; + + /// 오늘 — 앱이 보는 기기 시간대의 날짜(시험 시계) + SearchDay today() => SearchDay.of(h.clock.now); + + /// 보드의 받은편지함 — 이도윤이 보낸 첨부 있는 계약 메일 둘 · 인사팀 · 박지훈 + void seedInbox() { + final DateTime now = h.clock.now; + h.mail + ..put( + kInboxId, + messageJson( + 'd1', + mailboxId: kInboxId, + subject: 'Re: 유지보수 계약 갱신 건', + receivedAt: now.subtract(const Duration(minutes: 1)), + hasAttachment: true, + ), + ) + ..put( + kInboxId, + messageJson( + 'h1', + mailboxId: kInboxId, + fromName: '인사팀', + fromEmail: 'hr@example.com', + subject: '[공지] 계약직 채용 일정 안내', + receivedAt: now.subtract(const Duration(hours: 2)), + isRead: true, + ), + ) + ..put( + kInboxId, + messageJson( + 'j1', + mailboxId: kInboxId, + fromName: '박지훈', + fromEmail: 'jihoon@example.com', + subject: '견적서 보내 드립니다', + receivedAt: now.subtract(const Duration(hours: 5)), + hasAttachment: true, + isRead: true, + ), + ) + ..put( + kInboxId, + messageJson( + 'd2', + mailboxId: kInboxId, + subject: '계약서 초안 보내 드립니다', + receivedAt: now.subtract(const Duration(hours: 8)), + hasAttachment: true, + isRead: true, + ), + ); + } + + Future openSearch(WidgetTester tester, {Size size = kPhoneSize}) async { + await h.pump(tester, size: size); + await tester.tap(find.byTooltip(MailListTexts.search)); + await tester.pumpAndSettle(); + } + + Future openSheet(WidgetTester tester) async { + await tester.tap(toggle()); + await tester.pumpAndSettle(); + } + + Future type(WidgetTester tester, String name, String text) async { + await tester.enterText(textField(name), text); + await tester.pump(); + } + + Future choosePeriod(WidgetTester tester, SearchPeriod value) async { + await tester.ensureVisible(period()); + await tester.pumpAndSettle(); + await tester.tap(period()); + await tester.pumpAndSettle(); + await tester.tap(find.text(MailSearchTexts.period(value)).last); + await tester.pumpAndSettle(); + } + + Future toggleAttachment(WidgetTester tester) async { + await tester.ensureVisible(attachment()); + await tester.pumpAndSettle(); + await tester.tap(attachment()); + await tester.pumpAndSettle(); + } + + /// 날짜 칸을 눌러 날짜 고르는 창을 열고 [day] 일(이달 — 없으면 처음 날짜 그대로)을 골라 [확인] + Future pickDay(WidgetTester tester, String edge, {int? day}) async { + await tester.ensureVisible(dateField(edge)); + await tester.pumpAndSettle(); + await tester.tap(dateField(edge)); + await tester.pumpAndSettle(); + if (day != null) { + await tester.tap( + find.descendant( + of: find.byType(DatePickerDialog), + matching: find.text('$day'), + ), + ); + await tester.pump(); + } + await tester.tap(find.text('확인')); + await tester.pumpAndSettle(); + } + + Future apply(WidgetTester tester) async { + await tester.tap(applyButton()); + await tester.pumpAndSettle(); + } + + /// 검색 칸에 [text] 를 쓰고 자판의 [검색] + Future search(WidgetTester tester, String text) async { + await tester.enterText(field(), text); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pumpAndSettle(); + } + + group('위 막대의 [조건]', () { + testWidgets( + '맨 오른쪽 · 평소는 바탕 없이 "조건으로 찾기", 열면 옅은 청록 원 · 펼침, 조건이 걸리면 "조건이 걸려 있음"', + (WidgetTester tester) async { + await openSearch(tester); + await tester.enterText(field(), '계약'); + await tester.pump(); + // [✕] 오른쪽 — 맨 끝 + expect( + tester.getTopLeft(toggle()).dx, + greaterThan( + tester.getTopLeft(find.byTooltip(MailSearchTexts.clear)).dx, + ), + ); + IconButton button() => tester.widget(toggle()); + Semantics semanticsOf() => tester.widget( + find.ancestor(of: toggle(), matching: find.byType(Semantics)).first, + ); + expect(button().style!.backgroundColor, isNull); + expect( + tester.getSemantics(toggle()), + isSemantics( + label: MailSearchTexts.conditions, + isButton: true, + hasExpandedState: true, + isExpanded: false, + ), + ); + expect(tester.getSize(toggle()), const Size.square(AppSize.touchMin)); + + await openSheet(tester); + expect(sheet(), findsOneWidget); + expect( + button().style!.backgroundColor!.resolve({}), + AppColors.brandSubtle, + ); + expect(semanticsOf().properties.expanded, isTrue); + // 시트를 열면 칸의 초점을 [조건]이 받는다 — 닫혀도 자판이 다시 올라오지 않게 + expect(fieldFocused(tester), isFalse); + + await type(tester, 'from', '이도윤'); + await apply(tester); + expect(sheet(), findsNothing); + expect(fieldFocused(tester), isFalse); + expect( + button().style!.backgroundColor!.resolve({}), + AppColors.brandSubtle, + ); + expect( + button().style!.foregroundColor!.resolve({}), + AppColors.brandStrong, + ); + expect( + tester.getSemantics(toggle()), + isSemantics( + label: MailSearchTexts.conditionsLabel(applied: true), + isButton: true, + hasExpandedState: true, + isExpanded: false, + ), + ); + expect( + MailSearchTexts.conditionsLabel(applied: true), + '조건으로 찾기, 조건이 걸려 있음', + ); + }, + ); + }); + + group('시트', () { + testWidgets( + '탭 막대까지 덮고 위 96 을 남긴다 — 머리(제목 · [✕] 닫기) · 손잡이는 낭독하지 않는다 · 가림막은 대화상자 가림막 색', + (WidgetTester tester) async { + await openSearch(tester); + await openSheet(tester); + expect( + tester.getTopLeft(sheet()).dy, + greaterThanOrEqualTo(AppSize.sheetTopGap), + ); + expect( + tester.getBottomLeft(sheet()).dy, + greaterThan(tester.getTopLeft(find.byType(NavigationBar)).dy), + ); + expect( + tester.getSemantics(inSheet(find.text(MailSearchTexts.conditions))), + isSemantics(label: MailSearchTexts.conditions, isHeader: true), + ); + expect( + tester.getSemantics( + inSheet(find.byTooltip(MailSearchTexts.closeConditions)), + ), + isSemantics(tooltip: MailSearchTexts.closeConditions, isButton: true), + ); + // 손잡이는 낭독하지 않는다 — Flutter 기본 손잡이의 "닫기" 단추가 없어 닫는 단추는 [✕] 하나다 + expect( + find.descendant( + of: sheet(), + matching: find.bySemanticsLabel(MailSearchTexts.closeConditions), + ), + findsNothing, + ); + expect( + tester.widgetList(find.byType(ModalBarrier)).last.color, + AppColors.backdrop, + ); + }, + ); + + testWidgets('가운데만 스크롤한다 — 스크롤되면 머리 아래 선, 단추 줄은 늘 아래', ( + WidgetTester tester, + ) async { + await openSearch(tester); + await openSheet(tester); + Color headerLine() { + final DecoratedBox box = tester.widget( + find + .ancestor( + of: inSheet(find.text(MailSearchTexts.conditions)), + matching: find.byType(DecoratedBox), + ) + .first, + ); + return ((box.decoration as BoxDecoration).border! as Border) + .bottom + .color; + } + + expect(headerLine(), Colors.transparent); + final double applyTop = tester.getTopLeft(applyButton()).dy; + await tester.drag(middle(), const Offset(0, -200)); + await tester.pumpAndSettle(); + expect(headerLine(), AppColors.borderSubtle); + expect(tester.getTopLeft(applyButton()).dy, applyTop); + }); + + // 자판 · 좁은 화면 — 시트만 띄워 화면 크기 · 안전 영역 · 글자 크기를 정한다(앱 틀 없이) + Future pumpSheet( + WidgetTester tester, { + required Size size, + double safeTop = 0, + double safeBottom = 0, + double textScale = 1, + SearchConditions applied = SearchConditions.none, + String searchText = '', + }) async { + tester.view.physicalSize = size * 3; + tester.view.devicePixelRatio = 3; + tester.view.padding = FakeViewPadding( + top: safeTop * 3, + bottom: safeBottom * 3, + ); + tester.view.viewPadding = FakeViewPadding( + top: safeTop * 3, + bottom: safeBottom * 3, + ); + tester.platformDispatcher.textScaleFactorTestValue = textScale; + addTearDown(tester.view.reset); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: Builder( + builder: (BuildContext context) => TextButton( + onPressed: () => showSearchConditionsSheet( + context, + applied: applied, + searchText: searchText, + clock: () => DateTime(2026, 11, 7, 10), + ), + child: const Text('open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + } + + /// 자판이 몇 프레임에 걸쳐 올라온다(그사이 시트의 자리가 줄어든다) + Future raiseKeyboard(WidgetTester tester, double height) async { + addTearDown(tester.view.resetViewInsets); + for (final double step in [height / 3, height * 2 / 3, height]) { + tester.view.viewInsets = FakeViewPadding( + bottom: step * tester.view.devicePixelRatio, + ); + await tester.pump(); + } + await tester.pumpAndSettle(); + } + + /// 「보낸 사람」에 초점을 두고 자판을 올린다 — 같은 칸에 초점 · 입력 연결이 남고, 칸이 자판 위에 보이고, 친 글이 그 칸에 든다 + Future typeWithKeyboard( + WidgetTester tester, { + required Size size, + required double keyboard, + }) async { + await pumpSheet(tester, size: size); + final Finder from = find.descendant( + of: find.byKey(const ValueKey('search-condition-from')), + matching: find.byType(EditableText), + ); + await tester.tap(from); + await tester.pump(); + final FocusNode node = tester.widget(from).focusNode; + expect(node.hasFocus, isTrue); + expect(tester.testTextInput.hasAnyClients, isTrue); + + await raiseKeyboard(tester, keyboard); + expect(tester.takeException(), isNull); + expect(tester.widget(from).focusNode, same(node)); + expect(node.hasFocus, isTrue); + expect(tester.testTextInput.hasAnyClients, isTrue); + final Rect rect = tester.getRect(from); + expect(rect.top, greaterThanOrEqualTo(0)); + expect(rect.bottom, lessThanOrEqualTo(size.height - keyboard)); + + tester.testTextInput.enterText('이도윤'); + await tester.pump(); + expect(textOf(tester, 'from'), '이도윤'); + } + + testWidgets( + '세로 화면 — 칸에 초점을 두고 자판이 올라와도 초점 · 입력이 그대로이고 시트가 함께 올라간다(단추 줄은 자판 위)', + (WidgetTester tester) async { + await typeWithKeyboard(tester, size: kPhoneSize, keyboard: 300); + expect( + tester.getBottomLeft(applyButton()).dy, + lessThanOrEqualTo(kPhoneSize.height - 300), + ); + // 자리가 넉넉하면 머리는 고정 — 가운데만 스크롤한다 + expect( + find.descendant( + of: middle(), + matching: find.text(MailSearchTexts.conditions), + ), + findsNothing, + ); + }, + ); + + testWidgets( + '가로 화면 — 자판이 올라와 자리가 모자라면 시트 전체가 한 스크롤이 되어도 칸의 초점 · 입력이 그대로다(되풀이해 끊기지 않는다)', + (WidgetTester tester) async { + await typeWithKeyboard( + tester, + size: const Size(844, 390), + keyboard: 200, + ); + // 자판을 내리고 다시 올려도 같다 + tester.view.resetViewInsets(); + await tester.pumpAndSettle(); + await raiseKeyboard(tester, 200); + expect(tester.takeException(), isNull); + expect(tester.testTextInput.hasAnyClients, isTrue); + // 아래로 내려 [찾기]까지 간다 — 보낸 사람을 넣었으니 넘기고 닫는다 + await tester.ensureVisible(applyButton()); + await tester.pumpAndSettle(); + expect( + tester.getBottomLeft(applyButton()).dy, + lessThanOrEqualTo(390 - 200), + ); + await tester.tap(applyButton()); + await tester.pumpAndSettle(); + expect(sheet(), findsNothing); + }, + ); + + testWidgets('한 스크롤로 펼치면 머리 아래 선을 지운다 — 머리도 함께 움직이고 가운데에는 스크롤할 것이 없다', ( + WidgetTester tester, + ) async { + await pumpSheet(tester, size: const Size(844, 390)); + Color headerLine() { + final DecoratedBox box = tester.widget( + find + .ancestor( + of: inSheet(find.text(MailSearchTexts.conditions)), + matching: find.byType(DecoratedBox), + ) + .first, + ); + return ((box.decoration as BoxDecoration).border! as Border) + .bottom + .color; + } + + await tester.drag(middle(), const Offset(0, -100)); + await tester.pumpAndSettle(); + expect(headerLine(), AppColors.borderSubtle); + await raiseKeyboard(tester, 200); + expect(tester.takeException(), isNull); + expect(headerLine(), Colors.transparent); + }); + + /// 낱말 넘김 알림 — 시트 안에 하나 + Finder wordsAlert() => inSheet( + findCopy(MailSearchTexts.tooManyWordsHint(kMailSearchMaxWords)), + ); + + /// [finder] 가 화면의 [top] ~ [bottom] 안에 다 보인다 + void expectShown( + WidgetTester tester, + Finder finder, { + required double top, + required double bottom, + }) { + final Rect rect = tester.getRect(finder); + expect(rect.top, greaterThanOrEqualTo(top)); + expect(rect.bottom, lessThanOrEqualTo(bottom)); + } + + for (final double scale in [1, 1.3, 2]) { + testWidgets( + '짧은 가로 화면(안전 영역 위아래 24 · 글자 $scale배)에서 낱말 알림이 떠도 넘치지 않고, 더 스크롤하지 않아도 알림 · [찾기]가 보인다', + (WidgetTester tester) async { + await pumpSheet( + tester, + size: const Size(780, 360), + safeTop: 24, + safeBottom: 24, + textScale: scale, + applied: const SearchConditions(from: '아홉'), + searchText: '하나 둘 셋 넷 다섯 여섯 일곱 여덟', + ); + await tester.ensureVisible(applyButton()); + await tester.pumpAndSettle(); + await tester.tap(applyButton()); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(sheet(), findsOneWidget); + expect(wordsAlert(), findsOneWidget); + expectShown(tester, wordsAlert(), top: 24, bottom: 360 - 24); + expectShown(tester, applyButton(), top: 24, bottom: 360 - 24); + }, + ); + } + + testWidgets( + '알림이 붙어 모양이 넘어가는 크기(800×340)에서도 [찾기]를 누르면 스크롤하지 않고 알림 · [찾기]가 화면 안에 보인다', + (WidgetTester tester) async { + await pumpSheet( + tester, + size: const Size(800, 340), + applied: const SearchConditions(from: '아홉'), + searchText: '하나 둘 셋 넷 다섯 여섯 일곱 여덟', + ); + // 열 때는 위 96 을 남긴 고정 모양 — [찾기]가 처음부터 보인다 + expect(tester.getTopLeft(sheet()).dy, AppSize.sheetTopGap); + expectShown(tester, applyButton(), top: 0, bottom: 340); + await tester.tap(applyButton()); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expectShown(tester, wordsAlert(), top: 0, bottom: 340); + expectShown(tester, applyButton(), top: 0, bottom: 340); + // 알림만큼 단추 줄이 높아져 위 96 을 없앤 고정 모양으로 — 머리도 그대로 보인다(한 스크롤로 펼쳐 끝으로 내려가지 않았다) + expect(tester.getTopLeft(sheet()).dy, 0); + expectShown( + tester, + inSheet(find.text(MailSearchTexts.conditions)), + top: 0, + bottom: 340, + ); + expect( + find.descendant( + of: middle(), + matching: find.text(MailSearchTexts.conditions), + ), + findsNothing, + ); + }, + ); + + testWidgets('한 스크롤로 펼친 모양(가로 + 자판)에서 글 칸의 [검색] 키로 띄운 알림도 자판 위에 보인다', ( + WidgetTester tester, + ) async { + await pumpSheet( + tester, + size: const Size(844, 390), + searchText: '하나 둘 셋 넷 다섯 여섯 일곱 여덟', + ); + await tester.tap( + find.descendant( + of: find.byKey(const ValueKey('search-condition-from')), + matching: find.byType(EditableText), + ), + ); + await tester.pump(); + await raiseKeyboard(tester, 200); + tester.testTextInput.enterText('아홉'); + await tester.pump(); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(sheet(), findsOneWidget); + expectShown(tester, wordsAlert(), top: 0, bottom: 390 - 200); + expectShown(tester, applyButton(), top: 0, bottom: 390 - 200); + }); + + testWidgets( + '펼치기 전에 위 96 부터 없앤다 — 780×360 · 안전 영역 24 는 머리 · 단추 줄 고정으로 열려 [찾기]가 처음부터 보이고 끌어 내려 닫힌다', + (WidgetTester tester) async { + await pumpSheet( + tester, + size: const Size(780, 360), + safeTop: 24, + safeBottom: 24, + ); + // 시트가 화면 위(상태 막대 아래)까지 — 가운데는 칸 둘이 넘게 들어간다 + expect(tester.getTopLeft(sheet()).dy, 24); + expect( + tester.getSize(middle()).height, + greaterThan(AppSize.fieldHeight * 2), + ); + expect( + find.descendant( + of: middle(), + matching: find.text(MailSearchTexts.conditions), + ), + findsNothing, + ); + expectShown(tester, applyButton(), top: 24, bottom: 360 - 24); + await tester.drag( + inSheet(find.text(MailSearchTexts.conditions)), + const Offset(0, 360), + ); + await tester.pumpAndSettle(); + expect(sheet(), findsNothing); + }, + ); + + testWidgets('자판을 내려 고정 모양으로 돌아와도 초점 칸이 보인다 — 가운데가 맨 위로 가 가려지지 않게', ( + WidgetTester tester, + ) async { + await pumpSheet(tester, size: const Size(844, 390)); + final Finder body = find.descendant( + of: find.byKey(const ValueKey('search-condition-body')), + matching: find.byType(EditableText), + ); + await tester.ensureVisible(body); + await tester.pumpAndSettle(); + await tester.tap(body); + await tester.pump(); + await raiseKeyboard(tester, 200); + expect(tester.takeException(), isNull); + expectShown(tester, body, top: 0, bottom: 390 - 200); + + tester.view.resetViewInsets(); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(tester.widget(body).focusNode.hasFocus, isTrue); + final Rect viewport = tester.getRect(middle()); + expectShown(tester, body, top: viewport.top, bottom: viewport.bottom); + }); + + testWidgets( + '자판이 조금씩 올라와도 모양은 차례로 한 번씩만 넘어간다 — 시트 위가 되돌아 내려오지 않고 초점 · 입력이 그대로', + (WidgetTester tester) async { + await pumpSheet(tester, size: const Size(844, 390)); + final Finder from = find.descendant( + of: find.byKey(const ValueKey('search-condition-from')), + matching: find.byType(EditableText), + ); + await tester.tap(from); + await tester.pump(); + final FocusNode node = tester.widget(from).focusNode; + addTearDown(tester.view.resetViewInsets); + double previous = tester.getTopLeft(sheet()).dy; + final Set tops = {previous}; + for (double keyboard = 4; keyboard <= 200; keyboard += 4) { + tester.view.viewInsets = FakeViewPadding( + bottom: keyboard * tester.view.devicePixelRatio, + ); + await tester.pump(); + final double top = tester.getTopLeft(sheet()).dy; + expect(top, lessThanOrEqualTo(previous), reason: '자판 $keyboard'); + previous = top; + tops.add(top); + } + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + // 위 96 을 남긴 고정 → 위 96 없는 고정 → 한 스크롤(시트 위는 화면 위) + expect(tops, containsAll([AppSize.sheetTopGap, 0])); + expect(tester.widget(from).focusNode, same(node)); + expect(node.hasFocus, isTrue); + expect(tester.testTextInput.hasAnyClients, isTrue); + }, + ); + + testWidgets('열면 걸린 조건으로 채운다 — [찾기] 없이 닫으면([✕] · 뒤로) 고친 값을 버린다', ( + WidgetTester tester, + ) async { + seedInbox(); + await openSearch(tester); + await openSheet(tester); + expect(textOf(tester, 'from'), isEmpty); + expect(periodShown(tester), MailSearchTexts.period(SearchPeriod.all)); + await type(tester, 'from', '이도윤'); + await choosePeriod(tester, SearchPeriod.month); + await toggleAttachment(tester); + await apply(tester); + + await openSheet(tester); + expect(textOf(tester, 'from'), '이도윤'); + expect(periodShown(tester), MailSearchTexts.period(SearchPeriod.month)); + expect(attachmentOf(tester), isTrue); + + // 고치고 [✕] + await type(tester, 'from', '박지훈'); + await toggleAttachment(tester); + await tester.tap( + inSheet(find.byTooltip(MailSearchTexts.closeConditions)), + ); + await tester.pumpAndSettle(); + expect(sheet(), findsNothing); + await openSheet(tester); + expect(textOf(tester, 'from'), '이도윤'); + expect(attachmentOf(tester), isTrue); + + // 고치고 뒤로 가기 + await type(tester, 'subject', '견적'); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(sheet(), findsNothing); + await openSheet(tester); + expect(textOf(tester, 'subject'), isEmpty); + // 닫기만 해서는 다시 찾지 않았다 + expect(h.mail.searchQueries, hasLength(1)); + }); + + testWidgets( + '[조건 지우기]는 칸만 비운다 — 기간 · 날짜 · 첨부까지, 시트는 열린 채 · 찾지 않음 · 초점은 그 단추', + (WidgetTester tester) async { + seedInbox(); + await openSearch(tester); + await openSheet(tester); + await type(tester, 'from', '이도윤'); + await type(tester, 'body', '단가'); + await choosePeriod(tester, SearchPeriod.custom); + await pickDay(tester, 'since'); + await toggleAttachment(tester); + await apply(tester); + expect(h.mail.searchQueries, hasLength(1)); + + await openSheet(tester); + await tester.ensureVisible(sheetClear()); + await tester.tap(sheetClear()); + await tester.pumpAndSettle(); + expect(sheet(), findsOneWidget); + expect(textOf(tester, 'from'), isEmpty); + expect(textOf(tester, 'body'), isEmpty); + expect(periodShown(tester), MailSearchTexts.period(SearchPeriod.all)); + expect(dateField('since'), findsNothing); + expect(attachmentOf(tester), isFalse); + expect( + tester.widget(sheetClear()).focusNode!.hasFocus, + isTrue, + ); + expect(h.mail.searchQueries, hasLength(1)); + // 걸린 조건 · 조건 줄은 [찾기]를 누를 때까지 그대로 + expect(line(), findsOneWidget); + }, + ); + + testWidgets('[찾기]는 다듬은 조건을 걸고 칸의 지금 글로 찾는다 — 시트를 닫고 자판을 내리고 조건 줄', ( + WidgetTester tester, + ) async { + seedInbox(); + await openSearch(tester); + await tester.enterText(field(), '계약'); + await tester.pump(); + await openSheet(tester); + await type(tester, 'from', ' 이도윤 '); + await type(tester, 'subject', '갱신'); + await toggleAttachment(tester); + await apply(tester); + + expect(sheet(), findsNothing); + expect(h.mail.searchQueries.single, { + 'q': '계약', + 'from': '이도윤', + 'subject': '갱신', + 'hasAttachment': 'true', + 'mailboxId': kInboxId, + 'limit': kMailPageSize, + }); + expect(find.text('Re: 유지보수 계약 갱신 건'), findsOneWidget); + expect(find.text('계약서 초안 보내 드립니다'), findsNothing); + expect( + findCopy('조건: 이도윤 님이 보낸 메일, 제목에 ‘갱신’이 든 메일, 첨부 있음'), + findsOneWidget, + ); + expect(find.text(MailSearchTexts.count('계약', 1)), findsOneWidget); + }); + + testWidgets('최근 N 은 넘길 때 그 날짜로 바꾼다 — after · before 는 기기 시간대의 0시(UTC)', ( + WidgetTester tester, + ) async { + seedInbox(); + await openSearch(tester); + await openSheet(tester); + await choosePeriod(tester, SearchPeriod.week); + await apply(tester); + final ({SearchDay? since, SearchDay? until}) week = periodRange( + SearchPeriod.week, + h.clock.now, + ); + expect(h.mail.searchQueries.single, { + 'after': _localIso(week.since!), + 'before': _localIso(week.until!, addDays: 1), + 'mailboxId': kInboxId, + 'limit': kMailPageSize, + }); + expect(findCopy('조건: 최근 1주'), findsOneWidget); + }); + + testWidgets('낱말이 합쳐 8개를 넘으면 시트를 연 채 단추 줄 위에 알린다(요청 없음) — 낱말을 고치면 지운다', ( + WidgetTester tester, + ) async { + await openSearch(tester); + await tester.enterText(field(), '견적 단가 납품 일정'); + await tester.pump(); + await openSheet(tester); + await type(tester, 'from', '이도윤'); + await type(tester, 'subject', '계약 갱신 확인 요청 건'); + await apply(tester); + + expect(sheet(), findsOneWidget); + final Finder alert = inSheet( + findCopy(MailSearchTexts.tooManyWordsHint(kMailSearchMaxWords)), + ); + expect(alert, findsOneWidget); + expect(tester.getSemantics(alert), isSemantics(isLiveRegion: true)); + expect( + tester.getBottomLeft(alert).dy, + lessThan(tester.getTopLeft(applyButton()).dy), + ); + expect(h.mail.searchQueries, isEmpty); + + await type(tester, 'subject', '계약 갱신'); + expect(alert, findsNothing); + await apply(tester); + expect(sheet(), findsNothing); + expect(h.mail.searchQueries.single['subject'], '계약 갱신'); + }); + + testWidgets('기간 "날짜 고르기"면 날짜 칸 둘 — 최근 N 에서 바꾸면 그 기간의 날짜로 채워 둔다', ( + WidgetTester tester, + ) async { + await openSearch(tester); + await openSheet(tester); + expect(dateField('since'), findsNothing); + // 고르기 칸은 "기간"과 고른 기간을 한 단추로 읽는다 · 높이 52 + expect( + tester.getSemantics(period()), + isSemantics( + label: + '${MailSearchTexts.periodLabel}\n${MailSearchTexts.period(SearchPeriod.all)}', + isButton: true, + ), + ); + expect(tester.getSize(period()).height, AppSize.fieldHeight); + expect(tester.getSize(textField('from')).height, AppSize.fieldHeight); + await choosePeriod(tester, SearchPeriod.custom); + // 전체 기간에서 바꾸면 빈 칸 — 오른쪽 달력 아이콘 + expect(dateField('since'), findsOneWidget); + expect( + find.descendant( + of: dateField('since'), + matching: find.byIcon(Icons.calendar_today_outlined), + ), + findsOneWidget, + ); + expect( + tester.getSemantics( + find.descendant( + of: dateField('until'), + matching: find.byType(InkWell), + ), + ), + isSemantics(label: MailSearchTexts.until, isButton: true), + ); + + await choosePeriod(tester, SearchPeriod.week); + await tester.tap( + inSheet(find.byTooltip(MailSearchTexts.closeConditions)), + ); + await tester.pumpAndSettle(); + await openSheet(tester); + await choosePeriod(tester, SearchPeriod.week); + await choosePeriod(tester, SearchPeriod.custom); + final ({SearchDay? since, SearchDay? until}) week = periodRange( + SearchPeriod.week, + h.clock.now, + ); + expect( + find.descendant( + of: dateField('since'), + matching: find.text(formatSearchDay(week.since!)), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: dateField('until'), + matching: find.text(formatSearchDay(week.until!)), + ), + findsOneWidget, + ); + // 값이 있으면 오른쪽 [✕] — 그 날짜 지우기 + await tester.tap( + find.descendant( + of: dateField('since'), + matching: find.byTooltip(MailSearchTexts.clearSince), + ), + ); + await tester.pumpAndSettle(); + expect( + find.descendant( + of: dateField('since'), + matching: find.byIcon(Icons.calendar_today_outlined), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: dateField('until'), + matching: find.byTooltip(MailSearchTexts.clearUntil), + ), + findsOneWidget, + ); + }); + + testWidgets( + '날짜 고르는 창 — 머리 "시작 날짜", 1970-01-01 ~ 오늘, 처음은 칸의 날짜 · 가림막은 대화상자 가림막 색', + (WidgetTester tester) async { + await openSearch(tester); + await openSheet(tester); + await choosePeriod(tester, SearchPeriod.custom); + await tester.ensureVisible(dateField('since')); + await tester.pumpAndSettle(); + await tester.tap(dateField('since')); + await tester.pumpAndSettle(); + expect(find.byType(DatePickerDialog), findsOneWidget); + final DatePickerDialog dialog = tester.widget( + find.byType(DatePickerDialog), + ); + expect(dialog.helpText, MailSearchTexts.since); + expect(dialog.firstDate, DateTime(1970)); + expect(dialog.lastDate, today().toLocalDate()); + expect(dialog.initialDate, today().toLocalDate()); + expect( + tester.widgetList(find.byType(ModalBarrier)).last.color, + AppColors.backdrop, + ); + await tester.tap( + find.descendant( + of: find.byType(DatePickerDialog), + matching: find.text('1'), + ), + ); + await tester.tap(find.text('확인')); + await tester.pumpAndSettle(); + final SearchDay first = SearchDay(today().year, today().month, 1); + expect( + find.descendant( + of: dateField('since'), + matching: find.text(formatSearchDay(first)), + ), + findsOneWidget, + ); + expect( + tester.getSemantics( + find + .descendant( + of: dateField('since'), + matching: find.byType(InkWell), + ) + .first, + ), + isSemantics( + label: MailSearchTexts.since, + value: formatSearchDay(first), + isButton: true, + ), + ); + + // 다시 열면 칸의 날짜에서 시작한다 + await tester.tap(dateField('since')); + await tester.pumpAndSettle(); + expect( + tester + .widget(find.byType(DatePickerDialog)) + .initialDate, + first.toLocalDate(), + ); + await tester.tap(find.text('취소')); + await tester.pumpAndSettle(); + }, + ); + + testWidgets('날짜가 거꾸로면 고른 즉시 끝 날짜 칸이 빨갛고 두 칸 아래에 알린다 — [찾기]는 넘기지 않고 그 칸으로', ( + WidgetTester tester, + ) async { + // 작은 휴대폰 — 가운데를 맨 위로 올리면 끝 날짜 칸이 단추 줄 아래로 가려진다 + await openSearch(tester, size: const Size(390, 640)); + await openSheet(tester); + await choosePeriod(tester, SearchPeriod.custom); + await pickDay(tester, 'since'); + expect(findCopy(MailSearchTexts.periodOrder), findsNothing); + await pickDay(tester, 'until', day: 1); + + final Finder error = inSheet(findCopy(MailSearchTexts.periodOrder)); + expect(error, findsOneWidget); + expect(tester.getSemantics(error), isSemantics(isLiveRegion: true)); + final Finder untilPick = find + .descendant(of: dateField('until'), matching: find.byType(InkWell)) + .first; + expect( + tester.getSemantics(untilPick), + isSemantics( + label: MailSearchTexts.until, + hint: MailSearchTexts.periodOrder, + validationResult: SemanticsValidationResult.invalid, + isButton: true, + ), + ); + expect( + tester.getSemantics( + find + .descendant( + of: dateField('since'), + matching: find.byType(InkWell), + ) + .first, + ), + isSemantics( + label: MailSearchTexts.since, + validationResult: SemanticsValidationResult.none, + isButton: true, + ), + ); + + // 가운데를 맨 위로 올려 끝 날짜 칸을 가려 두고 [찾기] — 그 칸이 보이게 스크롤하고 초점을 둔다 + await tester.drag(middle(), const Offset(0, 600)); + await tester.pumpAndSettle(); + expect( + tester.getRect(dateField('until')).bottom, + greaterThan(tester.getTopLeft(applyButton()).dy), + ); + await apply(tester); + expect(sheet(), findsOneWidget); + expect(h.mail.searchQueries, isEmpty); + expect(tester.widget(untilPick).focusNode!.hasFocus, isTrue); + expect( + tester.getRect(dateField('until')).bottom, + lessThanOrEqualTo(tester.getTopLeft(applyButton()).dy), + ); + + // 끝 날짜를 지우면 알림이 사라지고 넘긴다 — "…부터" + await tester.tap( + find.descendant( + of: dateField('until'), + matching: find.byTooltip(MailSearchTexts.clearUntil), + ), + ); + await tester.pumpAndSettle(); + expect(error, findsNothing); + await apply(tester); + expect(sheet(), findsNothing); + expect(h.mail.searchQueries.single['after'], _localIso(today())); + expect(h.mail.searchQueries.single.containsKey('before'), isFalse); + expect(findCopy('조건: ${formatSearchDay(today())}부터'), findsOneWidget); + }); + + testWidgets('글 칸의 자판은 검색 칸과 같은 결 — [검색] 키는 [찾기]', ( + WidgetTester tester, + ) async { + seedInbox(); + await openSearch(tester); + await openSheet(tester); + final TextField input = tester.widget(textField('to')); + expect(input.keyboardType, TextInputType.text); + expect(input.textInputAction, TextInputAction.search); + expect(input.autocorrect, isFalse); + expect(input.smartQuotesType, SmartQuotesType.disabled); + expect(input.smartDashesType, SmartDashesType.disabled); + expect( + tester.getSemantics(textField('to')).label, + contains(MailSearchTexts.toLabel), + ); + + await tester.enterText(textField('to'), '김아라'); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pumpAndSettle(); + expect(sheet(), findsNothing); + expect(h.mail.searchQueries.single['to'], '김아라'); + // 로그인한 직원의 이름 — "내가 받은 메일" + expect(findCopy('조건: 내가 받은 메일'), findsOneWidget); + }); + }); + + group('결과 화면', () { + testWidgets('조건만으로 찾으면 개수 "N통" · 조건 줄 — [조건 지우기]는 요청 없이 찾기 전으로', ( + WidgetTester tester, + ) async { + seedInbox(); + await openSearch(tester); + await openSheet(tester); + await type(tester, 'from', '이도윤'); + await toggleAttachment(tester); + await apply(tester); + + expect(h.mail.searchQueries.single.containsKey('q'), isFalse); + expect(find.text(MailSearchTexts.count('', 2)), findsOneWidget); + expect(MailSearchTexts.count('', 2), '2통'); + expect(findCopy('조건: 이도윤 님이 보낸 메일, 첨부 있음'), findsOneWidget); + + await tester.tap(lineClear()); + await tester.pumpAndSettle(); + expect(h.mail.searchQueries, hasLength(1)); + expect(line(), findsNothing); + expect(findCopy(MailSearchTexts.intro), findsOneWidget); + expect( + tester.widget(toggle()).style!.backgroundColor, + isNull, + ); + }); + + testWidgets('조건 줄의 [조건 지우기]는 걸린 조건을 모두 빼고 검색어로 다시 찾는다', ( + WidgetTester tester, + ) async { + seedInbox(); + await openSearch(tester); + await tester.enterText(field(), '계약'); + await tester.pump(); + await openSheet(tester); + await type(tester, 'from', '이도윤'); + await apply(tester); + expect(find.text(MailSearchTexts.count('계약', 2)), findsOneWidget); + + await tester.tap(lineClear()); + await tester.pumpAndSettle(); + expect(h.mail.searchQueries, hasLength(2)); + expect(h.mail.searchQueries.last, { + 'q': '계약', + 'mailboxId': kInboxId, + 'limit': kMailPageSize, + }); + expect(line(), findsNothing); + expect(find.text(MailSearchTexts.count('계약', 3)), findsOneWidget); + }); + + testWidgets('결과 없음 — 검색어 + 조건 · 조건만 · 검색어만이 각각 다른 글', ( + WidgetTester tester, + ) async { + seedInbox(); + await openSearch(tester); + await tester.enterText(field(), '해지'); + await tester.pump(); + await openSheet(tester); + await type(tester, 'from', '이도윤'); + await apply(tester); + expect(findCopy(MailSearchTexts.emptyTitle('해지')), findsOneWidget); + expect(findCopy(MailSearchTexts.emptyHint), findsOneWidget); + + await tester.enterText(field(), ''); + await tester.pump(); + await openSheet(tester); + await type(tester, 'from', '최유나'); + await apply(tester); + expect(findCopy('조건에 맞는 메일이 없습니다'), findsOneWidget); + expect(findCopy(MailSearchTexts.emptyConditionsHint), findsOneWidget); + expect(find.text(MailSearchTexts.count('', 0)), findsOneWidget); + + await tester.tap(lineClear()); + await tester.pumpAndSettle(); + await search(tester, '해지'); + expect(findCopy(MailSearchTexts.emptyTitle('해지')), findsOneWidget); + expect(findCopy(MailSearchTexts.emptyWordsHint), findsOneWidget); + }); + + testWidgets( + '[검색] 키로 낱말이 합쳐 8개를 넘으면 결과 자리에 알린다(요청 없음) — 조건 줄과 [조건 지우기]는 남는다', + (WidgetTester tester) async { + await openSearch(tester); + await openSheet(tester); + await type(tester, 'subject', '계약 갱신 확인'); + await apply(tester); + final int before = h.mail.searchQueries.length; + + await search(tester, '견적 단가 납품 일정 변경 요청'); + expect(h.mail.searchQueries, hasLength(before)); + expect(findCopy(MailSearchTexts.tooManyWordsTitle), findsOneWidget); + expect( + findCopy('검색 칸과 [조건]에 넣은 낱말은 모두 합쳐 8개까지입니다. 낱말을 몇 개 줄여 주세요.'), + findsOneWidget, + ); + expect(findCopy('조건: 제목에 ‘계약 갱신 확인’이 든 메일'), findsOneWidget); + + // 조건을 빼면 칸의 글(6낱말)로 찾는다 + await tester.tap(lineClear()); + await tester.pumpAndSettle(); + expect(h.mail.searchQueries, hasLength(before + 1)); + expect(h.mail.searchQueries.last['q'], '견적 단가 납품 일정 변경 요청'); + expect(findCopy(MailSearchTexts.tooManyWordsTitle), findsNothing); + }, + ); + + testWidgets('[✕]는 걸린 조건을 남기고 · 빈 칸의 [검색]은 조건만으로 · 찾을 곳을 바꿔도 조건 그대로', ( + WidgetTester tester, + ) async { + seedInbox(); + await openSearch(tester); + await tester.enterText(field(), '계약'); + await tester.pump(); + await openSheet(tester); + await type(tester, 'from', '이도윤'); + await apply(tester); + + await tester.tap(find.byTooltip(MailSearchTexts.clear)); + await tester.pumpAndSettle(); + expect(findCopy(MailSearchTexts.intro), findsOneWidget); + expect(line(), findsOneWidget); + + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pumpAndSettle(); + expect(h.mail.searchQueries.last, { + 'from': '이도윤', + 'mailboxId': kInboxId, + 'limit': kMailPageSize, + }); + expect(find.text(MailSearchTexts.count('', 2)), findsOneWidget); + + await tester.tap(find.text(MailSearchTexts.scopeAll)); + await tester.pumpAndSettle(); + expect(h.mail.searchQueries.last, { + 'from': '이도윤', + 'limit': kMailPageSize, + }); + + // 당겨서 새로고침도 걸린 조건 그대로 + await tester.fling( + find.text('Re: 유지보수 계약 갱신 건'), + const Offset(0, 400), + 1000, + ); + await tester.pumpAndSettle(); + expect(h.mail.searchQueries.last['from'], '이도윤'); + }); + + testWidgets('검색어도 조건도 비운 [찾기]는 요청 없이 찾기 전으로', (WidgetTester tester) async { + seedInbox(); + await openSearch(tester); + await openSheet(tester); + await type(tester, 'from', '이도윤'); + await apply(tester); + expect(h.mail.searchQueries, hasLength(1)); + + await openSheet(tester); + await tester.tap(sheetClear()); + await tester.pumpAndSettle(); + await apply(tester); + expect(sheet(), findsNothing); + expect(h.mail.searchQueries, hasLength(1)); + expect(line(), findsNothing); + expect(findCopy(MailSearchTexts.intro), findsOneWidget); + }); + + testWidgets('결과에서 연 메일에는 검색어와 글 조건만 강조 쿼리로 싣는다 — 기간 · 첨부는 싣지 않는다', ( + WidgetTester tester, + ) async { + seedInbox(); + h.mail.searchHighlightField = true; + await openSearch(tester); + await tester.enterText(field(), '계약'); + await tester.pump(); + await openSheet(tester); + await type(tester, 'from', '이도윤'); + await type(tester, 'subject', '갱신'); + await choosePeriod(tester, SearchPeriod.month); + await toggleAttachment(tester); + await apply(tester); + + await tester.tap(find.text('Re: 유지보수 계약 갱신 건')); + await tester.pumpAndSettle(); + expect(find.byType(MessageReaderPage), findsOneWidget); + expect(h.mail.detailQueries.last.query, { + 'q': '계약', + 'from': '이도윤', + 'subject': '갱신', + }); + }); + + testWidgets('조건 줄은 최소 48 — 글이 길면 낱말 단위로 줄을 바꿔 늘어난다(잘리지 않는다) · [조건 지우기]는 48', ( + WidgetTester tester, + ) async { + tester.view.physicalSize = kPhoneSize * 3; + tester.view.devicePixelRatio = 3; + addTearDown(tester.view.reset); + Future heightOf(String text) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + MailSearchConditionLine(text: text, onClear: () {}), + ], + ), + ), + ), + ); + expect(tester.takeException(), isNull); + return tester.getSize(find.byType(MailSearchConditionLine)).height; + } + + expect(await heightOf('조건: 최근 1달'), AppSize.touchMin); + expect( + await heightOf( + '조건: 이도윤 님이 보낸 메일, 제목에 ‘유지보수 계약 갱신’이 든 메일, 2026. 10. 1.부터 11. 5.까지, 첨부 있음', + ), + greaterThan(AppSize.touchMin), + ); + expect( + tester.getSize(find.byType(TextButton)).height, + greaterThanOrEqualTo(AppSize.touchMin), + ); + }); + }); +} diff --git a/flutter/test/features/mail/mail_search_controller_test.dart b/flutter/test/features/mail/mail_search_controller_test.dart new file mode 100644 index 0000000..a6cf4db --- /dev/null +++ b/flutter/test/features/mail/mail_search_controller_test.dart @@ -0,0 +1,892 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/request_flags.dart'; +import 'package:arasystem_app/core/utils/accessible_navigation.dart'; +import 'package:arasystem_app/core/utils/app_foreground.dart'; +import 'package:arasystem_app/core/utils/provider_retry.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_search.dart'; +import 'package:arasystem_app/features/mail/data/models/message_summary.dart'; +import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart'; +import 'package:arasystem_app/features/mail/domain/search_conditions.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_search_controller.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_search_state.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_state_watcher.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_toast_provider.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mailboxes_provider.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/fakes.dart'; +import '../../support/mail_fakes.dart'; +import '../../support/mail_harness.dart'; +import '../../support/provider_harness.dart'; + +class _Foreground extends AppForeground { + @override + bool build() => true; +} + +class _NoScreenReader extends AccessibleNavigation { + @override + bool build() => false; +} + +/// 메일을 보는 직원 — 시험이 바꾼다(세션이 바뀜) +class _Account extends Notifier { + @override + String? build() => kMailTestAccount; + + void set(String? value) => state = value; +} + +final NotifierProvider<_Account, String?> _accountProvider = + NotifierProvider<_Account, String?>(_Account.new); + +/// 검색 응답 한 쪽 +FakeResponse _page( + List> items, { + String? next, + int? total, +}) => FakeResponse.ok({ + 'items': items, + 'pageInfo': { + 'hasNext': next != null, + 'nextCursor': next, + 'total': total, + }, +}); + +/// 받은편지함의 메일 [n] 통 — id 는 `1` 부터(앞이 최신) +List> _mails( + int n, { + String prefix = 'r', + int from = 1, + String mailboxId = kInboxId, +}) => >[ + for (int i = from; i < from + n; i++) + messageJson( + '$prefix$i', + mailboxId: mailboxId, + subject: '계약 $prefix$i', + receivedAt: kTestNow.subtract(Duration(minutes: i)), + isRead: true, + ), +]; + +/// 메일 검색 컨트롤러 — 계약 2절 "검색" · "검색 — 14단계"(이어 찾기 · 준비 상태 · 결과 반영) +void main() { + late ProviderHarness h; + late FakeMailBackend mail; + late ProviderContainer container; + int instance = 0; + + setUp(() async { + h = ProviderHarness(); + mail = FakeMailBackend(h.server)..install(); + await h.prepare(); + await h.store.save(tokensFor(1, now: h.clock.now)); + container = ProviderContainer( + overrides: [ + ...h.overrides, + mailAccountProvider.overrideWith( + (Ref ref) => ref.watch(_accountProvider), + ), + appForegroundProvider.overrideWith(_Foreground.new), + accessibleNavigationProvider.overrideWith(_NoScreenReader.new), + mailNoticeSinkProvider.overrideWithValue((String _) {}), + ], + retry: noAutomaticRetry, + ); + addTearDown(container.dispose); + }); + + /// 검색 화면 하나를 연다(화면이 보는 것처럼 구독한다) — 메일함 목록을 받을 때까지 기다린다 + Future open({String? origin}) async { + final MailSearchArgs args = MailSearchArgs( + instance: ++instance, + originMailboxId: origin, + ); + container + ..listen(mailboxesProvider, (MailboxesState? _, MailboxesState _) {}) + ..listen( + mailSearchProvider(args), + (MailSearchState? _, MailSearchState _) {}, + ); + await pumpEventsUntil( + () => container.read(mailboxesProvider).status == MailboxesStatus.ready, + reason: '메일함 목록', + ); + return args; + } + + MailSearchState stateOf(MailSearchArgs args) => + container.read(mailSearchProvider(args)); + + MailSearchController controllerOf(MailSearchArgs args) => + container.read(mailSearchProvider(args).notifier); + + /// 검색이 멈출 때까지(찾는 중 · 이어 찾는 중이 끝날 때까지) + Future settle(MailSearchArgs args) => pumpEventsUntil(() { + final MailSearchState s = stateOf(args); + return s.phase != MailSearchPhase.searching && + s.fill == MailSearchFill.none && + !s.refreshing; + }, reason: '검색'); + + List ids(MailSearchArgs args) => [ + for (final MailSearchItem item in stateOf(args).items) item.id, + ]; + + List searchRequests() => [ + for (final RequestOptions r in mail.listRequests) + if (FakeMailBackend.isSearchQuery(r.queryParameters)) r, + ]; + + MessageSummary summaryOf(Map json) => + MessageSummary.tryParse(json)!; + + group('찾기', () { + test('빈 칸 · 공백뿐이면 묻지 않고 거짓 — 찾기 전 그대로', () async { + final MailSearchArgs args = await open(); + expect(controllerOf(args).submit(''), isFalse); + expect(controllerOf(args).submit(' \u3000\n'), isFalse); + await pumpEventsUntil(() => true); + expect(searchRequests(), isEmpty); + expect(stateOf(args).phase, MailSearchPhase.idle); + }); + + test( + '받은편지함 주소에서 열면 기본 메일함에서 찾는다 — q · mailboxId · limit, 첫 쪽은 알리지 않는다', + () async { + mail.messages[kInboxId] = _mails(3); + final MailSearchArgs args = await open(); + expect(controllerOf(args).submit(' 계약 '), isTrue); + expect(stateOf(args).phase, MailSearchPhase.searching); + expect(stateOf(args).query, '계약'); + await settle(args); + + final RequestOptions request = searchRequests().single; + expect(request.queryParameters, { + 'q': '계약', + 'mailboxId': kInboxId, + 'limit': kMailPageSize, + }); + expect(request.extra[kSilentExtraKey], isTrue); + expect(stateOf(args).phase, MailSearchPhase.ready); + expect(ids(args), ['r1', 'r2', 'r3']); + expect(stateOf(args).total, 3); + expect(stateOf(args).searchedMailboxId, kInboxId); + }, + ); + + test('전체 메일함은 mailboxId 를 빼고 묻는다 — 시작한 메일함은 그 메일함', () async { + final MailSearchArgs args = await open(origin: kClientsId); + controllerOf(args).submit('계약'); + await settle(args); + expect(searchRequests().last.queryParameters['mailboxId'], kClientsId); + + controllerOf(args).setScope(all: true, fieldText: '계약'); + await settle(args); + expect( + searchRequests().last.queryParameters.containsKey('mailboxId'), + isFalse, + ); + expect(stateOf(args).searchedAll, isTrue); + }); + + test('낱말이 8개를 넘으면 묻지 않는다 — 찾을 말이 너무 많음', () async { + final MailSearchArgs args = await open(); + expect(controllerOf(args).submit('11월 정기 점검 일정 안내 메일 다시 보내 주세요'), isTrue); + await pumpEventsUntil(() => true); + expect(stateOf(args).phase, MailSearchPhase.tooManyWords); + expect(stateOf(args).query, isNull); + expect(searchRequests(), isEmpty); + }); + + test('첫 쪽을 받지 못하면 받지 못함(알리지 않는다 — 화면이 그린다) · [다시 시도]는 처음부터', () async { + mail.searchResponder = (_) => FakeResponse.fail(503, 'MAIL_001'); + final MailSearchArgs args = await open(); + controllerOf(args).submit('계약'); + await settle(args); + expect(stateOf(args).phase, MailSearchPhase.failed); + expect(stateOf(args).error?.code, 'MAIL_001'); + expect(h.notifier.shownCodes, isEmpty); + + mail.searchResponder = null; + mail.messages[kInboxId] = _mails(1); + await controllerOf(args).retry(); + await settle(args); + expect(stateOf(args).phase, MailSearchPhase.ready); + expect(ids(args), ['r1']); + }); + + test('찾던 메일함이 지워졌으면 RES_001 — [전체 메일함에서 찾기]는 보이는 검색어를 전체에서', () async { + final MailSearchArgs args = await open(origin: 'mb-gone'); + controllerOf(args).submit('계약'); + await settle(args); + expect(stateOf(args).error?.code, 'RES_001'); + + controllerOf(args).searchAll(); + await settle(args); + expect(stateOf(args).allMailboxes, isTrue); + expect(searchRequests().last.queryParameters['q'], '계약'); + expect( + searchRequests().last.queryParameters.containsKey('mailboxId'), + isFalse, + ); + }); + + test('같은 검색어 · 같은 곳이면 보이는 채로 다시 찾는다(결과를 두고) — 실패하면 그대로 두고 알린다', () async { + mail.messages[kInboxId] = _mails(2); + final MailSearchArgs args = await open(); + controllerOf(args).submit('계약'); + await settle(args); + + mail.searchResponder = (_) => FakeResponse.fail(503, 'MAIL_001'); + controllerOf(args).submit('계약'); + expect(stateOf(args).refreshing, isTrue); + expect(ids(args), ['r1', 'r2']); + await settle(args); + expect(stateOf(args).phase, MailSearchPhase.ready); + expect(ids(args), ['r1', 'r2']); + expect(h.notifier.shownCodes, ['MAIL_001']); + expect(searchRequests().last.extra[kSilentExtraKey], isNot(isTrue)); + }); + + test('[✕]는 결과를 비우고 찾기 전으로 — 찾을 곳은 그대로', () async { + mail.messages[kInboxId] = _mails(1); + final MailSearchArgs args = await open(); + controllerOf(args).setScope(all: true, fieldText: ''); + controllerOf(args).submit('계약'); + await settle(args); + controllerOf(args).clear(); + expect(stateOf(args).phase, MailSearchPhase.idle); + expect(stateOf(args).items, isEmpty); + expect(stateOf(args).allMailboxes, isTrue); + }); + + test('찾을 곳 바꾸기 — 칸의 글로, 칸이 비었으면 보이는 결과의 검색어로, 둘 다 없으면 곳만', () async { + final MailSearchArgs args = await open(); + controllerOf(args).setScope(all: true, fieldText: ''); + await pumpEventsUntil(() => true); + expect(searchRequests(), isEmpty); + expect(stateOf(args).allMailboxes, isTrue); + + controllerOf(args).setScope(all: false, fieldText: '견적'); + await settle(args); + expect(searchRequests().last.queryParameters['q'], '견적'); + + controllerOf(args).setScope(all: true, fieldText: ' '); + await settle(args); + expect(searchRequests().last.queryParameters['q'], '견적'); + expect(stateOf(args).searchedAll, isTrue); + }); + }); + + group('이어 찾기 (한 차례 5번 · 한 쪽 20통)', () { + test( + '하나도 못 찾고 hasNext 면 커서로 5번까지 — 그래도 없으면 [더 찾기](아직 못 찾음), 누르면 5번 더', + () async { + int calls = 0; + mail.searchResponder = (Map query) { + calls++; + if (calls <= 7) { + return _page(const >[], next: 'c$calls'); + } + return _page(_mails(2), total: null); + }; + final MailSearchArgs args = await open(); + controllerOf(args).submit('정산서'); + await settle(args); + expect(searchRequests(), hasLength(kMailSearchFillMaxRequests)); + expect(searchRequests()[1].queryParameters['cursor'], 'c1'); + expect( + [ + for (final RequestOptions r in searchRequests().skip(1)) + r.extra[kSilentExtraKey], + ], + everyElement(isTrue), + reason: '하나도 찾기 전의 이어 찾기는 알리지 않는다', + ); + expect(stateOf(args).phase, MailSearchPhase.ready); + expect(stateOf(args).items, isEmpty); + expect(stateOf(args).partial, isTrue); + + final Future more = controllerOf(args).searchMore(); + expect(stateOf(args).fill, MailSearchFill.more); + await more; + expect(searchRequests(), hasLength(8)); + expect(ids(args), ['r1', 'r2']); + expect(stateOf(args).partial, isFalse); + expect(stateOf(args).hasMore, isFalse); + }, + ); + + test( + '한 쪽이 차면 멈추고, 끝에 닿으면 다음 차례 — 다음 쪽의 total 이 null 이면 첫 쪽 수를 남긴다', + () async { + mail.searchResponder = (Map query) { + final Object? cursor = query['cursor']; + if (cursor == null) return _page(_mails(12), next: 'c1', total: 30); + if (cursor == 'c1') { + return _page(_mails(10, from: 13), next: 'c2'); + } + return _page(_mails(8, from: 23)); + }; + final MailSearchArgs args = await open(); + controllerOf(args).submit('계약'); + await settle(args); + expect(searchRequests(), hasLength(2)); + expect(stateOf(args).items, hasLength(22)); + expect(stateOf(args).total, 30); + expect(stateOf(args).partial, isFalse); + expect(stateOf(args).hasMore, isTrue); + expect(searchRequests()[1].extra[kSilentExtraKey], isNot(isTrue)); + + await controllerOf(args).loadMore(); + expect(searchRequests(), hasLength(3)); + expect(stateOf(args).items, hasLength(30)); + expect(stateOf(args).hasMore, isFalse); + expect(stateOf(args).total, 30); + }, + ); + + test('한 차례가 한 쪽을 못 채우고 끝났는데 더 있으면 저절로 잇지 않는다 — 목록 끝의 [더 찾기]', () async { + int calls = 0; + mail.searchResponder = (Map query) { + calls++; + return _page( + calls == 1 ? _mails(3) : const >[], + next: 'c$calls', + ); + }; + final MailSearchArgs args = await open(); + controllerOf(args).submit('견적'); + await settle(args); + expect(searchRequests(), hasLength(kMailSearchFillMaxRequests)); + expect(stateOf(args).items, hasLength(3)); + expect(stateOf(args).partial, isTrue); + + // 끝 표시가 불러도 잇지 않는다 — [더 찾기]로만 + await controllerOf(args).loadMore(); + expect(searchRequests(), hasLength(kMailSearchFillMaxRequests)); + }); + + test('같은 커서가 새 메일 없이 돌아오면 [다시 시도]로 멈춘다', () async { + mail.searchResponder = (Map query) => + query['cursor'] == null + ? _page(_mails(3), next: 'same', total: 10) + : _page(const >[], next: 'same'); + final MailSearchArgs args = await open(); + controllerOf(args).submit('계약'); + await settle(args); + expect(searchRequests(), hasLength(2)); + expect(stateOf(args).loadMoreFailed, isTrue); + expect(stateOf(args).partial, isFalse); + }); + + test('이어 찾다 실패하면 받은 데까지 보이고 끝에 [다시 시도] — 인터셉터가 알린다', () async { + int calls = 0; + mail.searchResponder = (Map query) { + calls++; + if (calls == 1) return _page(_mails(3), next: 'c1', total: 9); + if (calls == 2) return FakeResponse.fail(503, 'MAIL_001'); + return _page(_mails(6, from: 4)); + }; + final MailSearchArgs args = await open(); + controllerOf(args).submit('계약'); + await settle(args); + expect(ids(args), hasLength(3)); + expect(stateOf(args).loadMoreFailed, isTrue); + expect(h.notifier.shownCodes, ['MAIL_001']); + + await controllerOf(args).loadMore(); + expect(ids(args), hasLength(9)); + expect(stateOf(args).loadMoreFailed, isFalse); + expect(stateOf(args).hasMore, isFalse); + }); + + test('하나도 찾기 전의 이어 찾기가 실패하면 받지 못함(알리지 않는다)', () async { + int calls = 0; + mail.searchResponder = (_) { + calls++; + return calls == 1 + ? _page(const >[], next: 'c1') + : FakeResponse.fail(503, 'MAIL_001'); + }; + final MailSearchArgs args = await open(); + controllerOf(args).submit('계약'); + await settle(args); + expect(stateOf(args).phase, MailSearchPhase.failed); + expect(h.notifier.shownCodes, isEmpty); + }); + }); + + group('늦은 응답 · 세션', () { + test('새 검색을 하면 앞 검색의 요청을 끊고 그 응답은 버린다 — 끊은 요청은 알리지 않는다', () async { + mail.messages[kInboxId] = >[ + ..._mails(2), + ..._mails(1, prefix: 'q'), + ]; + final MailSearchArgs args = await open(); + final Completer slow = Completer(); + mail.searchGate = slow; + controllerOf(args).submit('r1'); + await pumpEventsUntil(() => searchRequests().length == 1); + controllerOf(args).submit('q1'); + await settle(args); + expect(stateOf(args).query, 'q1'); + expect(ids(args), ['q1']); + + slow.complete(); + await pumpEventsUntil(() => true); + expect(stateOf(args).query, 'q1'); + expect(ids(args), ['q1']); + expect(h.notifier.shownCodes, isEmpty); + }); + + test('화면이 닫히면(구독이 끊기면) 도는 요청을 끊는다 — 늦은 응답이 와도 아무 일 없다', () async { + final MailSearchArgs args = MailSearchArgs(instance: ++instance); + final ProviderSubscription subscription = container + .listen( + mailSearchProvider(args), + (MailSearchState? _, MailSearchState _) {}, + ); + container.listen( + mailboxesProvider, + (MailboxesState? _, MailboxesState _) {}, + ); + await pumpEventsUntil( + () => container.read(mailboxesProvider).status == MailboxesStatus.ready, + ); + final Completer slow = Completer(); + mail.searchGate = slow; + controllerOf(args).submit('계약'); + await pumpEventsUntil(() => searchRequests().length == 1); + subscription.close(); + await pumpEventsUntil(() => !container.exists(mailSearchProvider(args))); + slow.complete(); + await pumpEventsUntil(() => true); + expect(h.notifier.shownCodes, isEmpty); + }); + + test('세션이 바뀌면 처음(찾기 전)으로', () async { + mail.messages[kInboxId] = _mails(1); + final MailSearchArgs args = await open(); + controllerOf(args).submit('계약'); + await settle(args); + expect(stateOf(args).phase, MailSearchPhase.ready); + + container.read(_accountProvider.notifier).set('other@arasystem.it.kr'); + await pumpEventsUntil(() => stateOf(args).phase == MailSearchPhase.idle); + expect(stateOf(args).items, isEmpty); + }); + }); + + group('준비 상태 (GET /search-index)', () { + test( + '찾을 때마다 조용히 묻는다 — building · ready 를 담고, 받지 못하면(404 · 실패) 모름', + () async { + mail.messages[kInboxId] = _mails(1); + mail.searchIndex = FakeResponse.ok({ + 'status': 'building', + 'done': 3, + 'total': 10, + }); + final MailSearchArgs args = await open(); + controllerOf(args).submit('계약'); + await settle(args); + await pumpEventsUntil( + () => stateOf(args).indexStatus == SearchIndexStatus.building, + ); + expect(mail.searchIndexRequests, 1); + expect( + h.server + .received('GET', MailApiPaths.searchIndex) + .single + .extra[kSilentExtraKey], + isTrue, + ); + + mail.searchIndex = FakeResponse.fail(404, 'RES_001'); + controllerOf(args).submit('견적'); + await settle(args); + await pumpEventsUntil(() => stateOf(args).indexStatus == null); + expect(h.notifier.shownCodes, isEmpty); + + mail.searchIndex = FakeResponse.ok({ + 'status': 'paused', + }); + controllerOf(args).submit('보고'); + await settle(args); + await pumpEventsUntil(() => mail.searchIndexRequests == 3); + await pumpEventsUntil(() => true); + expect(stateOf(args).indexStatus, isNull); + }, + ); + }); + + group('읽기 화면이 바꾼 것 반영 (새 메일 확인으로는 다시 찾지 않는다)', () { + late MailSearchArgs args; + + Future searchInbox() async { + mail.messages[kInboxId] = _mails(3); + args = await open(); + controllerOf(args).submit('계약'); + await settle(args); + } + + MailStateWatcher watcher() => + container.read(mailStateWatcherProvider.notifier); + + test('읽음 · 중요는 표시만 덮는다 — 제목 · 미리 보기는 그 응답의 것 그대로', () async { + await searchInbox(); + final int before = searchRequests().length; + watcher().markChanged( + updated: [ + summaryOf({ + ...messageJson( + 'r2', + mailboxId: kInboxId, + subject: '바뀐 제목(NFC 아님)', + isFlagged: true, + ), + }), + ], + ); + final MailSearchItem item = stateOf(args).items[1]; + expect(item.summary.isFlagged, isTrue); + expect(item.summary.isRead, isFalse); + expect(item.summary.subject, '계약 r2'); + expect(searchRequests(), hasLength(before)); + }); + + test( + '한 메일함에서 찾았으면 그 메일함을 떠난 메일을 빼고 · [되돌리기]로 돌아오면 받은 시각 차례의 그 자리에', + () async { + await searchInbox(); + final Map moved = Map.of( + mail.messages[kInboxId]![1], + )..['mailboxIds'] = [kTrashId]; + watcher().markChanged(updated: [summaryOf(moved)]); + expect(ids(args), ['r1', 'r3']); + expect(stateOf(args).total, 2); + + final Map back = Map.of(moved) + ..['mailboxIds'] = [kInboxId]; + watcher().markChanged(updated: [summaryOf(back)]); + expect(ids(args), ['r1', 'r2', 'r3']); + expect(stateOf(args).total, 3); + }, + ); + + test( + '전체 메일함에서 찾았으면 휴지통 · 스팸메일함만 남은 메일을 빼고, 다른 메일함으로 옮긴 메일은 메일함만 바꾼다', + () async { + mail.messages[kInboxId] = _mails(2); + args = await open(); + controllerOf(args).setScope(all: true, fieldText: '계약'); + await settle(args); + + final Map toClients = Map.of( + mail.messages[kInboxId]![0], + )..['mailboxIds'] = [kClientsId]; + final Map toJunk = Map.of( + mail.messages[kInboxId]![1], + )..['mailboxIds'] = [kJunkId]; + watcher().markChanged( + updated: [summaryOf(toClients), summaryOf(toJunk)], + ); + expect(ids(args), ['r1']); + expect(stateOf(args).items.single.summary.mailboxIds, [ + kClientsId, + ]); + }, + ); + + test('영구 삭제 · 발송 취소(removedIds)는 빼고 기억하지 않는다 · 바뀐 것 없는 소식은 무시한다', () async { + await searchInbox(); + watcher().markChanged(removedIds: {'r1'}); + expect(ids(args), ['r2', 'r3']); + expect(stateOf(args).total, 2); + + watcher().markChanged(); + expect(ids(args), ['r2', 'r3']); + expect(searchRequests(), hasLength(1)); + }); + + test( + '불러온 끝 줄을 뺐다 [되돌리기]해도 그 자리(끝)에 다시 넣고 수도 되돌린다 — 다음 쪽은 그 줄 다음부터라 빠지지도 겹치지도 않는다', + () async { + mail.messages[kInboxId] = _mails(21); + args = await open(); + controllerOf(args).submit('계약'); + await settle(args); + expect(ids(args), hasLength(kMailPageSize)); + expect(stateOf(args).hasMore, isTrue); + expect(stateOf(args).total, 21); + + watcher().markChanged( + updated: [summaryOf(mail.move('r20', kTrashId))], + ); + expect(ids(args), hasLength(19)); + expect(stateOf(args).total, 20); + + watcher().markChanged( + updated: [summaryOf(mail.move('r20', kInboxId))], + ); + expect(ids(args), hasLength(kMailPageSize)); + expect(ids(args).last, 'r20'); + expect(stateOf(args).total, 21); + + await controllerOf(args).loadMore(); + expect(ids(args), [for (int i = 1; i <= 21; i++) 'r$i']); + expect(stateOf(args).hasMore, isFalse); + }, + ); + + test('1통만 찾고 [더 찾기]로 멈춘 결과에서 그 1통을 뺐다 되돌리면 다시 보인다 — [더 찾기]는 그대로', () async { + int calls = 0; + mail.searchResponder = (Map query) { + calls++; + return _page( + calls == 1 ? _mails(1) : const >[], + next: 'c$calls', + total: calls == 1 ? 3 : null, + ); + }; + args = await open(); + controllerOf(args).submit('계약'); + await settle(args); + expect(ids(args), ['r1']); + expect(stateOf(args).partial, isTrue); + + final Map moved = Map.of( + _mails(1).single, + )..['mailboxIds'] = [kTrashId]; + watcher().markChanged(updated: [summaryOf(moved)]); + expect(ids(args), isEmpty); + expect(stateOf(args).waitsForMore, isTrue); + expect(stateOf(args).total, 2); + + final Map back = Map.of(moved) + ..['mailboxIds'] = [kInboxId]; + watcher().markChanged(updated: [summaryOf(back)]); + expect(ids(args), ['r1']); + expect(stateOf(args).partial, isTrue); + expect(stateOf(args).total, 3); + expect(searchRequests(), hasLength(kMailSearchFillMaxRequests)); + }); + + test('불러온 결과를 모두 뺐는데 서버에 더 있으면 [더 찾기]로 잇는다 — 목록 끝이 없다', () async { + mail.messages[kInboxId] = _mails(21); + args = await open(); + controllerOf(args).submit('계약'); + await settle(args); + expect(stateOf(args).partial, isFalse); + + watcher().markChanged( + removedIds: {for (int i = 1; i <= kMailPageSize; i++) 'r$i'}, + ); + expect(ids(args), isEmpty); + expect(stateOf(args).waitsForMore, isTrue); + + final Future more = controllerOf(args).searchMore(); + expect(stateOf(args).fill, MailSearchFill.more); + await more; + expect(ids(args), ['r21']); + expect(stateOf(args).hasMore, isFalse); + expect(stateOf(args).waitsForMore, isFalse); + }); + + test('이 검색에서 뺀 메일이라도 서버가 다음 쪽에 주면 넣는다 — 겹침은 지금 목록의 id 로만 막는다', () async { + mail.searchResponder = (Map query) => + query['cursor'] == null + ? _page(_mails(20), next: 'c1', total: 21) + // 다른 곳에서 되돌린 r5 · 새 r21 — 이미 있는 r1 은 한 번만 + : _page(>[ + ..._mails(1), + ..._mails(1, from: 5), + ..._mails(1, from: 21), + ]); + args = await open(); + controllerOf(args).submit('계약'); + await settle(args); + final Map moved = Map.of(_mails(5).last) + ..['mailboxIds'] = [kTrashId]; + watcher().markChanged(updated: [summaryOf(moved)]); + expect(ids(args), hasLength(19)); + expect(stateOf(args).total, 20); + + await controllerOf(args).loadMore(); + expect(ids(args).sublist(19), ['r5', 'r21']); + expect(ids(args).where((String id) => id == 'r1'), hasLength(1)); + expect(stateOf(args).total, 21); + }); + }); + + group('조건 (2차 — 화면 상태에만 둔다)', () { + const SearchConditions fromDoyun = SearchConditions( + from: '이도윤', + hasAttachment: true, + ); + + test( + '시트의 [찾기]는 조건을 걸고 찾는다 — 빈 검색어는 싣지 않고, 이어 찾기 · 다시 찾기 · [다시 시도]도 걸린 조건 그대로', + () async { + mail.searchResponder = (Map query) => + query['cursor'] == null + ? _page(_mails(20), next: 'c1', total: 30) + : _page(_mails(10, from: 21)); + final MailSearchArgs args = await open(); + expect(controllerOf(args).submit('', conditions: fromDoyun), isTrue); + await settle(args); + expect(stateOf(args).query, ''); + expect(stateOf(args).conditions, fromDoyun); + expect(searchRequests().single.queryParameters, { + 'from': '이도윤', + 'hasAttachment': 'true', + 'mailboxId': kInboxId, + 'limit': kMailPageSize, + }); + + await controllerOf(args).loadMore(); + expect(searchRequests().last.queryParameters, { + 'from': '이도윤', + 'hasAttachment': 'true', + 'mailboxId': kInboxId, + 'limit': kMailPageSize, + 'cursor': 'c1', + }); + + await controllerOf(args).refresh(); + await settle(args); + expect(searchRequests().last.queryParameters['from'], '이도윤'); + expect(searchRequests().last.queryParameters.containsKey('q'), isFalse); + + mail.searchResponder = (_) => FakeResponse.fail(503, 'MAIL_001'); + controllerOf(args).submit('계약'); + await settle(args); + expect(stateOf(args).phase, MailSearchPhase.failed); + mail.searchResponder = null; + await controllerOf(args).retry(); + await settle(args); + expect(searchRequests().last.queryParameters['q'], '계약'); + expect(searchRequests().last.queryParameters['from'], '이도윤'); + }, + ); + + test( + '같은 검색어 · 같은 조건이면 보이는 채로 다시 찾고, 조건이 다르면 처음부터 찾는다 — 조건은 다듬어 건다', + () async { + mail.messages[kInboxId] = _mails(2); + final MailSearchArgs args = await open(); + controllerOf( + args, + ).submit('계약', conditions: const SearchConditions(from: ' 이도윤 ')); + expect(stateOf(args).conditions, const SearchConditions(from: '이도윤')); + await settle(args); + + controllerOf(args).submit('계약'); + expect(stateOf(args).refreshing, isTrue); + await settle(args); + + controllerOf( + args, + ).submit('계약', conditions: const SearchConditions(from: '박지훈')); + expect(stateOf(args).refreshing, isFalse); + expect(stateOf(args).phase, MailSearchPhase.searching); + await settle(args); + expect(searchRequests().last.queryParameters['from'], '박지훈'); + }, + ); + + test( + '[✕]는 걸린 조건을 남기고 · 빈 칸의 [검색]은 조건만으로 · 검색어도 조건도 없는 [찾기]는 요청 없이 찾기 전으로', + () async { + mail.messages[kInboxId] = _mails(2); + final MailSearchArgs args = await open(); + controllerOf(args).submit('계약', conditions: fromDoyun); + await settle(args); + + controllerOf(args).clear(); + expect(stateOf(args).phase, MailSearchPhase.idle); + expect(stateOf(args).conditions, fromDoyun); + // 찾기 전에 찾을 곳만 바꾸면 곳만 바꿔 둔다 + expect(controllerOf(args).setScope(all: true, fieldText: ''), isFalse); + + expect(controllerOf(args).submit(' '), isTrue); + await settle(args); + expect(searchRequests().last.queryParameters, { + 'from': '이도윤', + 'hasAttachment': 'true', + 'limit': kMailPageSize, + }); + + final int before = searchRequests().length; + expect( + controllerOf(args).submit('', conditions: SearchConditions.none), + isTrue, + ); + await pumpEventsUntil(() => true); + expect(stateOf(args).phase, MailSearchPhase.idle); + expect(stateOf(args).conditions.isEmpty, isTrue); + expect(searchRequests(), hasLength(before)); + // 그 뒤의 빈 칸 [검색]은 아무 일도 없다 + expect(controllerOf(args).submit(''), isFalse); + }, + ); + + test( + '조건 줄의 [조건 지우기] — 칸의 글(비었으면 보이는 검색어)로 조건 없이 다시, 검색어가 없으면 요청 없이 찾기 전, 찾기 전이면 조건만 뺀다', + () async { + mail.messages[kInboxId] = _mails(2); + final MailSearchArgs args = await open(); + controllerOf(args).submit('계약', conditions: fromDoyun); + await settle(args); + expect(controllerOf(args).clearConditions(fieldText: ''), isTrue); + await settle(args); + expect(searchRequests().last.queryParameters, { + 'q': '계약', + 'mailboxId': kInboxId, + 'limit': kMailPageSize, + }); + expect(stateOf(args).conditions.isEmpty, isTrue); + + controllerOf(args).submit('', conditions: fromDoyun); + await settle(args); + final int before = searchRequests().length; + controllerOf(args).clearConditions(fieldText: ''); + await pumpEventsUntil(() => true); + expect(stateOf(args).phase, MailSearchPhase.idle); + expect(stateOf(args).conditions.isEmpty, isTrue); + expect(searchRequests(), hasLength(before)); + + controllerOf(args).submit('계약', conditions: fromDoyun); + await settle(args); + controllerOf(args).clear(); + expect(controllerOf(args).clearConditions(fieldText: '견적'), isFalse); + expect(stateOf(args).phase, MailSearchPhase.idle); + expect(stateOf(args).conditions.isEmpty, isTrue); + expect(searchRequests(), hasLength(before + 1)); + }, + ); + + test('검색어와 걸린 조건의 낱말이 합쳐 8개를 넘으면 묻지 않는다 — 조건은 걸린 채', () async { + final MailSearchArgs args = await open(); + controllerOf( + args, + ).submit('', conditions: const SearchConditions(subject: '계약 갱신 확인')); + await settle(args); + final int before = searchRequests().length; + expect(controllerOf(args).submit('견적 단가 납품 일정 변경 요청'), isTrue); + await pumpEventsUntil(() => true); + expect(stateOf(args).phase, MailSearchPhase.tooManyWords); + expect(stateOf(args).conditions.subject, '계약 갱신 확인'); + expect(searchRequests(), hasLength(before)); + }); + }); +} diff --git a/flutter/test/features/mail/mail_search_page_test.dart b/flutter/test/features/mail/mail_search_page_test.dart new file mode 100644 index 0000000..35bd524 --- /dev/null +++ b/flutter/test/features/mail/mail_search_page_test.dart @@ -0,0 +1,1125 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/error_lexicon.dart'; +import 'package:arasystem_app/core/modules/app_modules.dart'; +import 'package:arasystem_app/core/theme/app_colors.dart'; +import 'package:arasystem_app/core/theme/app_spacing.dart'; +import 'package:arasystem_app/core/theme/app_theme.dart'; +import 'package:arasystem_app/core/theme/app_typography.dart'; +import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart'; +import 'package:arasystem_app/features/mail/domain/search_text.dart'; +import 'package:arasystem_app/features/mail/mail_routes.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; +import 'package:arasystem_app/features/mail/presentation/pages/mail_list_page.dart'; +import 'package:arasystem_app/features/mail/presentation/pages/mail_search_page.dart'; +import 'package:arasystem_app/features/mail/presentation/pages/message_compose_page.dart'; +import 'package:arasystem_app/features/mail/presentation/pages/message_reader_page.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_list_controller.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_search_controller.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_state_watcher.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/mail_compose_area.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/mail_search_bar.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/mail_search_states.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/message_row.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/reader_header.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +import '../../support/fakes.dart'; +import '../../support/login_fakes.dart'; +import '../../support/mail_fakes.dart'; +import '../../support/mail_harness.dart'; + +/// 보드 `App1Search` 3번의 메일 — ⟦계약⟧ 이 맞은 말 +const String _doyunSubject = 'Re: 유지보수 계약 갱신 건'; +const String _doyunPreview = '말씀하신 조건으로 계약서 초안을 고쳐 첨부했습니다.'; +const String _hrSubject = '[공지] 계약직 채용 일정 안내'; +const String _jihoonSubject = '견적서 보내 드립니다'; + +/// 메일 검색 화면 (앱 보드 `App1Search` · `App1SearchStates`) — 로그인한 앱 전체를 가짜 서버로 띄워 본다 +void main() { + late MailAppHarness h; + + setUp(() => h = MailAppHarness()); + + Finder field() => find.byKey(const ValueKey('mail-search-field')); + + bool fieldFocused(WidgetTester tester) => + tester.widget(field()).focusNode!.hasFocus; + + Finder row(String subject) => + find.ancestor(of: find.text(subject), matching: find.byType(MessageRow)); + + /// [finder] 안의 글 조각 가운데 노랑 바탕(검색 강조)인 것 + List marksIn(WidgetTester tester, Finder finder) { + final List marks = []; + for (final Text text in tester.widgetList( + find.descendant(of: finder, matching: find.byType(Text)), + )) { + text.textSpan?.visitChildren((InlineSpan span) { + if (span is TextSpan && + span.style?.backgroundColor == AppColors.searchMark) { + marks.add(span.text ?? ''); + } + return true; + }); + } + return marks; + } + + /// 결과 목록의 스크롤 — 칸(두 줄 TextField)에도 Scrollable 이 있다 + Finder results() => find + .descendant(of: find.byType(ListView), matching: find.byType(Scrollable)) + .first; + + Finder toastText(String text) => find.descendant( + of: find.byType(MailToastCard), + matching: find.text(text), + ); + + Future elapse(WidgetTester tester, Duration duration) async { + h.clock.advance(duration); + await tester.pump(duration); + await tester.pumpAndSettle(); + } + + /// 목록([location] — 없으면 받은편지함)을 띄우고 [찾기]로 검색 화면을 연다 + Future openSearch(WidgetTester tester, {String? location}) async { + await h.pump(tester, location: location); + await tester.tap(find.byTooltip(MailListTexts.search)); + await tester.pumpAndSettle(); + } + + /// 칸에 [text] 를 쓰고 자판의 [검색] + Future search(WidgetTester tester, String text) async { + await tester.enterText(field(), text); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pumpAndSettle(); + } + + /// 보드 3번의 받은편지함 — 14단계 서버(강조 칸이 있다) + void seedContracts() { + final DateTime now = h.clock.now; + h.mail + ..searchHighlightField = true + ..put( + kInboxId, + messageJson( + 'm1', + mailboxId: kInboxId, + subject: _doyunSubject, + preview: _doyunPreview, + receivedAt: now.subtract(const Duration(minutes: 1)), + hasAttachment: true, + ), + ) + ..put( + kInboxId, + messageJson( + 'm2', + mailboxId: kInboxId, + fromName: '인사팀', + subject: _hrSubject, + receivedAt: now.subtract(const Duration(hours: 2)), + isRead: true, + ), + ) + ..put( + kInboxId, + messageJson( + 'm3', + mailboxId: kInboxId, + fromName: '박지훈', + subject: _jihoonSubject, + preview: '안녕하세요. 지난달 계약 기간에 맞춰 단가를 다시 계산했습니다.', + receivedAt: now.subtract(const Duration(days: 1)), + isRead: true, + ), + ); + h.mail.searchHighlights + ..['m1'] = { + 'subject': [ + [9, 11], + ], + 'preview': { + 'text': _doyunPreview, + 'ranges': [ + [10, 12], + ], + }, + } + ..['m2'] = { + 'subject': [ + [5, 7], + ], + 'preview': {'text': '채용 일정입니다', 'ranges': []}, + } + ..['m3'] = { + 'subject': [], + 'preview': { + 'text': '…지난달 계약 기간에 맞춰', + 'ranges': [ + [5, 7], + ], + }, + }; + } + + group('들어가고 나가기', () { + testWidgets('목록의 [찾기]는 탭 안에 검색 화면을 쌓는다 — 칸에 초점 · 찾기 전 안내 · 찾을 곳은 시작한 메일함', ( + WidgetTester tester, + ) async { + await openSearch(tester); + + expect(find.byType(MailSearchPage), findsOneWidget); + expect(find.byType(NavigationBar), findsOneWidget); + expect(fieldFocused(tester), isTrue); + expect(findCopy(MailSearchTexts.intro), findsOneWidget); + expect(find.byTooltip(MailSearchTexts.clear), findsNothing); + expect( + tester.getSemantics(find.widgetWithText(InkWell, '받은편지함').first), + isSemantics(isButton: true, isSelected: true), + ); + expect(find.text(MailSearchTexts.scopeAll), findsOneWidget); + expect(findCopy(MailSearchTexts.scopeAllHint), findsNothing); + expect(h.mail.searchQueries, isEmpty); + }); + + testWidgets( + '[검색] → 뼈대("찾는 중") → 결과 — 개수 줄 · 제목 · 미리 보기의 맞은 말이 노랑, 자판을 내린다', + (WidgetTester tester) async { + seedContracts(); + await openSearch(tester); + final Completer gate = Completer(); + h.mail.searchGate = gate; + await tester.enterText(field(), '계약'); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pump(); + + expect(find.text(MailSearchTexts.searching('계약')), findsOneWidget); + expect(find.bySemanticsLabel(MailSearchTexts.loading), findsWidgets); + expect(fieldFocused(tester), isFalse); + + gate.complete(); + await tester.pumpAndSettle(); + expect(find.text(MailSearchTexts.count('계약', 3)), findsOneWidget); + expect(marksIn(tester, row(_doyunSubject)), ['계약', '계약']); + expect(marksIn(tester, row(_hrSubject)), ['계약']); + // 본문에서만 맞은 메일 — 미리 보기를 서버의 맞은 말 둘레 한 줄로 + expect(find.text('…지난달 계약 기간에 맞춰'), findsOneWidget); + expect(marksIn(tester, row(_jihoonSubject)), ['계약']); + expect(h.mail.searchQueries.single, { + 'q': '계약', + 'mailboxId': kInboxId, + 'limit': kMailPageSize, + }); + }, + ); + + testWidgets('미리 보기 줄의 강조 안 글자는 진하게(#4A5363), 제목 강조는 글자색 그대로', ( + WidgetTester tester, + ) async { + seedContracts(); + await openSearch(tester); + await search(tester, '계약'); + + Color? markColorIn(String text) { + Color? found; + for (final Text widget in tester.widgetList(find.byType(Text))) { + widget.textSpan?.visitChildren((InlineSpan span) { + if (span is TextSpan && + span.text == '계약' && + span.style?.backgroundColor == AppColors.searchMark && + widget.textSpan!.toPlainText() == text) { + found = span.style?.color; + } + return true; + }); + } + return found; + } + + expect(markColorIn(_doyunPreview), AppColors.textSecondary); + expect(markColorIn(_doyunSubject), isNull); + }); + + testWidgets('빈 칸 · 공백뿐인 [검색]은 아무 일도 없다 — 자판도 그대로', ( + WidgetTester tester, + ) async { + await openSearch(tester); + await search(tester, ' \u3000 '); + expect(h.mail.searchQueries, isEmpty); + expect(fieldFocused(tester), isTrue); + expect(findCopy(MailSearchTexts.intro), findsOneWidget); + }); + + testWidgets('붙여 넣은 줄바꿈은 띄어쓰기로 바꾸고(낱말이 붙지 않게) 200자(UTF-16)까지 받는다', ( + WidgetTester tester, + ) async { + await openSearch(tester); + await tester.enterText(field(), '회의\r\n일정\n공유'); + await tester.pump(); + expect(tester.widget(field()).controller!.text, '회의 일정 공유'); + + await tester.enterText(field(), '가' * 250); + await tester.pump(); + expect( + tester.widget(field()).controller!.text.length, + kMailSearchMaxLength, + ); + }); + + testWidgets('낱말이 9개면 묻지 않고 "찾을 말이 너무 많습니다"', (WidgetTester tester) async { + await openSearch(tester); + await search(tester, '11월 정기 점검 일정 안내 메일 다시 보내 주세요'); + + expect(h.mail.searchQueries, isEmpty); + expect(findCopy(MailSearchTexts.tooManyWordsTitle), findsOneWidget); + expect( + findCopy(MailSearchTexts.tooManyWordsHint(kMailSearchMaxWords)), + findsOneWidget, + ); + expect(find.text(MailSearchTexts.searching('11월')), findsNothing); + }); + + testWidgets('[✕]는 칸과 결과를 비우고 칸에 초점 · [←]는 검색을 닫는다', ( + WidgetTester tester, + ) async { + seedContracts(); + await openSearch(tester); + await search(tester, '계약'); + expect(find.text(_doyunSubject), findsOneWidget); + + await tester.tap(find.byTooltip(MailSearchTexts.clear)); + await tester.pumpAndSettle(); + expect(tester.widget(field()).controller!.text, isEmpty); + expect(find.text(_doyunSubject), findsNothing); + expect(findCopy(MailSearchTexts.intro), findsOneWidget); + expect(fieldFocused(tester), isTrue); + + await tester.tap(find.byTooltip(MailSearchTexts.close)); + await tester.pumpAndSettle(); + expect(find.byType(MailSearchPage), findsNothing); + expect(find.byType(MailListPage), findsOneWidget); + }); + + testWidgets('뒤로 가기 · 메일 탭 다시 누르기도 검색을 닫는다', (WidgetTester tester) async { + await openSearch(tester); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.byType(MailSearchPage), findsNothing); + + await tester.tap(find.byTooltip(MailListTexts.search)); + await tester.pumpAndSettle(); + await tester.tap( + find.descendant( + of: find.byType(NavigationBar), + matching: find.text(findAppModule(AppModuleId.mail).label), + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(MailSearchPage), findsNothing); + }); + + testWidgets('다른 메일함 목록에서 열면 그 메일함에서 찾는다', (WidgetTester tester) async { + h.mail.put( + kClientsId, + messageJson('c1', mailboxId: kClientsId, subject: '거래처 계약'), + ); + await openSearch(tester, location: MailRoutes.mailbox(kClientsId)); + expect( + find.descendant( + of: find.byType(MailSearchScopeBar), + matching: find.text('거래처'), + ), + findsOneWidget, + ); + await search(tester, '계약'); + expect(h.mail.searchQueries.single['mailboxId'], kClientsId); + expect(find.text('거래처 계약'), findsOneWidget); + }); + }); + + group('찾을 곳 · 다시 찾기', () { + testWidgets('전체 메일함으로 바꾸면 칸의 글로 다시 찾는다 — 휴지통 · 스팸 안내, 줄마다 메일함 · 받는 사람', ( + WidgetTester tester, + ) async { + seedContracts(); + h.mail.put( + kSentId, + messageJson( + 's1', + mailboxId: kSentId, + subject: '유지보수 계약 문의', + to: >[addressJson('이도윤', 'doyun@example.com')], + isRead: true, + receivedAt: h.clock.now.subtract(const Duration(days: 9)), + ), + ); + h.mail.put( + kTrashId, + messageJson('t1', mailboxId: kTrashId, subject: '휴지통 계약'), + ); + await openSearch(tester); + await search(tester, '계약'); + expect(find.text('유지보수 계약 문의'), findsNothing); + + await tester.tap(find.text(MailSearchTexts.scopeAll)); + await tester.pumpAndSettle(); + + expect(h.mail.searchQueries.last.containsKey('mailboxId'), isFalse); + expect(findCopy(MailSearchTexts.scopeAllHint), findsOneWidget); + expect(find.text('${MailListTexts.recipientPrefix}이도윤'), findsOneWidget); + expect( + find.descendant( + of: row('유지보수 계약 문의'), + matching: find.text(MailSearchTexts.rowMailbox('보낸편지함')), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: row(_doyunSubject), + matching: find.text(MailSearchTexts.rowMailbox('받은편지함')), + ), + findsOneWidget, + ); + expect(find.text('휴지통 계약'), findsNothing); + expect(find.text(MailSearchTexts.count('계약', 4)), findsOneWidget); + }); + + testWidgets('칸이 비었으면 찾을 곳만 바꿔 둔다(자판도 그대로) — 다음 검색이 그 곳에서', ( + WidgetTester tester, + ) async { + await openSearch(tester); + await tester.tap(find.text(MailSearchTexts.scopeAll)); + await tester.pumpAndSettle(); + expect(h.mail.searchQueries, isEmpty); + expect(findCopy(MailSearchTexts.scopeAllHint), findsOneWidget); + expect(fieldFocused(tester), isTrue); + + await search(tester, '계약'); + expect(h.mail.searchQueries.single.containsKey('mailboxId'), isFalse); + }); + + testWidgets('칸을 지운 채 찾을 곳을 바꾸면 보이는 결과의 검색어로 다시 찾고 자판을 내린다', ( + WidgetTester tester, + ) async { + seedContracts(); + await openSearch(tester); + await search(tester, '계약'); + await tester.enterText(field(), ''); + await tester.pumpAndSettle(); + expect(fieldFocused(tester), isTrue); + + await tester.tap(find.text(MailSearchTexts.scopeAll)); + await tester.pumpAndSettle(); + expect(h.mail.searchQueries, hasLength(2)); + expect(h.mail.searchQueries.last['q'], '계약'); + expect(h.mail.searchQueries.last.containsKey('mailboxId'), isFalse); + expect(fieldFocused(tester), isFalse); + }); + + testWidgets('같은 검색어로 다시 [검색] · 당겨서 새로고침은 결과를 보인 채로 다시 찾는다', ( + WidgetTester tester, + ) async { + seedContracts(); + await openSearch(tester); + await search(tester, '계약'); + final Completer gate = Completer(); + h.mail.searchGate = gate; + await tester.showKeyboard(field()); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pump(); + expect(find.text(_doyunSubject), findsOneWidget); + expect(find.text(MailSearchTexts.searching('계약')), findsOneWidget); + gate.complete(); + await tester.pumpAndSettle(); + expect(h.mail.searchQueries, hasLength(2)); + + await tester.fling(find.text(_doyunSubject), const Offset(0, 400), 1000); + await tester.pumpAndSettle(); + expect(h.mail.searchQueries, hasLength(3)); + expect(find.text(_doyunSubject), findsOneWidget); + }); + + testWidgets('새 메일 확인으로는 다시 찾지 않는다 — 보던 결과가 저절로 바뀌지 않는다', ( + WidgetTester tester, + ) async { + seedContracts(); + await openSearch(tester); + await search(tester, '계약'); + h.mail + ..state = 'state-2' + ..arrive( + kInboxId, + messageJson('new', mailboxId: kInboxId, subject: '새 계약'), + ); + await elapse(tester, kMailStatePollInterval + const Duration(seconds: 1)); + expect(h.mail.searchQueries, hasLength(1)); + expect(find.text('새 계약'), findsNothing); + }); + }); + + group('상태 화면', () { + testWidgets('결과 없음 — 한 메일함이면 [전체 메일함에서 찾기], 전체 메일함 결과 없음에는 단추가 없다', ( + WidgetTester tester, + ) async { + await openSearch(tester); + await search(tester, '해지 통보'); + + expect(find.text(MailSearchTexts.count('해지 통보', 0)), findsOneWidget); + expect(findCopy(MailSearchTexts.emptyTitle('해지 통보')), findsOneWidget); + expect(findCopy(MailSearchTexts.emptyWordsHint), findsOneWidget); + expect(findCopy(MailSearchTexts.wordLimitHint), findsNothing); + + await tester.tap(find.text(MailSearchTexts.searchAll)); + await tester.pumpAndSettle(); + expect(h.mail.searchQueries.last['q'], '해지 통보'); + expect(h.mail.searchQueries.last.containsKey('mailboxId'), isFalse); + expect(find.text(MailSearchTexts.searchAll), findsNothing); + }); + + testWidgets('검색 준비 중이면 안내 줄 · 결과 없음에 옛 한계 한 줄 — 준비 상태를 받지 못하면 아무 줄도 없다', ( + WidgetTester tester, + ) async { + h.mail.searchIndex = FakeResponse.ok({ + 'status': 'building', + 'done': 1, + 'total': 9, + }); + await openSearch(tester); + await search(tester, '서버'); + expect(findCopy(MailSearchTexts.indexing), findsOneWidget); + expect(findCopy(MailSearchTexts.wordLimitHint), findsOneWidget); + + h.mail.searchIndex = FakeResponse.fail(503, 'MAIL_001'); + await search(tester, '이전'); + expect(findCopy(MailSearchTexts.indexing), findsNothing); + expect(findCopy(MailSearchTexts.wordLimitHint), findsNothing); + expect(h.notifier.shownCodes, isEmpty); + }); + + testWidgets('한 글자로만 찾으면(색인이 준비됐을 때) 본문은 찾지 않았다고 알린다', ( + WidgetTester tester, + ) async { + h.mail.put( + kInboxId, + messageJson( + 'k1', + mailboxId: kInboxId, + fromName: '김민수', + subject: '김 회의', + ), + ); + await openSearch(tester); + await search(tester, '김'); + expect(findCopy(MailSearchTexts.singleChar), findsOneWidget); + + await search(tester, '김 회의'); + expect(findCopy(MailSearchTexts.singleChar), findsNothing); + }); + + testWidgets( + '받지 못함 — 사전 문구 · 문의 코드 · [다시 시도], 찾던 메일함이 지워졌으면 [전체 메일함에서 찾기]', + (WidgetTester tester) async { + h.mail.searchResponder = (_) => FakeResponse.fail(500, 'SYS_001'); + await openSearch(tester); + await search(tester, '계약'); + expect(findCopy(ErrorLexicon.getMessage('SYS_001')), findsOneWidget); + expect(find.text(MailListTexts.traceCode('trace-SY')), findsOneWidget); + expect(find.text(MailSearchTexts.searching('계약')), findsNothing); + expect(h.notifier.shownCodes, isEmpty); + + h.mail.searchResponder = (_) => FakeResponse.fail(404, 'RES_001'); + await tester.tap(find.text(MailListTexts.retry)); + await tester.pumpAndSettle(); + expect(find.text(MailSearchTexts.searchAll), findsOneWidget); + + h.mail.searchResponder = null; + await tester.tap(find.text(MailSearchTexts.searchAll)); + await tester.pumpAndSettle(); + expect(h.mail.searchQueries.last.containsKey('mailboxId'), isFalse); + expect(findCopy(MailSearchTexts.emptyTitle('계약')), findsOneWidget); + }, + ); + + testWidgets('아직 못 찾음 — [더 찾기]를 누른 동안 "찾는 중…"(다시 누르지 못한다) → 결과', ( + WidgetTester tester, + ) async { + int calls = 0; + h.mail.searchResponder = (Map query) { + calls++; + if (calls <= kMailSearchFillMaxRequests) { + return FakeResponse.ok({ + 'items': [], + 'pageInfo': { + 'hasNext': true, + 'nextCursor': 'c$calls', + 'total': null, + }, + }); + } + return FakeResponse.ok({ + 'items': [ + messageJson('p1', mailboxId: kInboxId, subject: '정산서 보냅니다'), + ], + 'pageInfo': { + 'hasNext': false, + 'nextCursor': null, + 'total': null, + }, + }); + }; + await openSearch(tester); + await tester.tap(find.text(MailSearchTexts.scopeAll)); + await tester.pumpAndSettle(); + await search(tester, '정산서'); + + expect(find.text(MailSearchTexts.quoted('정산서')), findsOneWidget); + expect(findCopy(MailSearchTexts.partialTitle), findsOneWidget); + expect(findCopy(MailSearchTexts.partialHint), findsOneWidget); + + final Completer gate = Completer(); + h.mail.searchGate = gate; + await tester.tap(find.text(MailSearchTexts.more)); + await tester.pump(); + expect(find.text(MailSearchTexts.moreBusy), findsOneWidget); + expect( + tester.getSemantics(find.text(MailSearchTexts.moreBusy)), + isSemantics(isButton: true, isEnabled: false), + ); + await tester.tap(find.text(MailSearchTexts.moreBusy)); + await tester.pump(); + + gate.complete(); + await tester.pumpAndSettle(); + expect(find.text('정산서 보냅니다'), findsOneWidget); + // 찾는 동안 다시 누른 [찾는 중…]은 요청을 더 보내지 않았다 + expect(h.mail.searchQueries, hasLength(kMailSearchFillMaxRequests + 1)); + expect(findCopy(MailSearchTexts.partialTitle), findsNothing); + }); + + testWidgets('목록 끝 — 한 차례가 한 쪽을 못 채우면 안내 줄 + [더 찾기], 누르면 이어 찾는다', ( + WidgetTester tester, + ) async { + int calls = 0; + h.mail.searchResponder = (_) { + calls++; + return FakeResponse.ok({ + 'items': [ + if (calls == 1) + messageJson('q1', mailboxId: kInboxId, subject: '견적 요청'), + if (calls == 6) + messageJson( + 'q2', + mailboxId: kInboxId, + subject: '견적 다시', + receivedAt: h.clock.now.subtract(const Duration(days: 30)), + ), + ], + 'pageInfo': { + 'hasNext': calls < 6, + 'nextCursor': calls < 6 ? 'c$calls' : null, + 'total': null, + }, + }); + }; + await openSearch(tester); + await search(tester, '견적'); + expect(find.text('견적 요청'), findsOneWidget); + expect(find.byType(MailSearchMoreLine), findsOneWidget); + expect(calls, kMailSearchFillMaxRequests); + + await tester.tap(find.text(MailSearchTexts.more)); + await tester.pumpAndSettle(); + expect(find.text('견적 다시'), findsOneWidget); + expect(find.byType(MailSearchMoreLine), findsNothing); + }); + + testWidgets('한 쪽을 채웠으면 끝 표시가 보일 때 다음 차례를 저절로 · 다음 쪽 실패는 끝의 [다시 시도]', ( + WidgetTester tester, + ) async { + final List> many = >[ + for (int i = 1; i <= 25; i++) + messageJson( + 'n$i', + mailboxId: kInboxId, + subject: '계약 $i', + receivedAt: h.clock.now.subtract(Duration(minutes: i)), + isRead: true, + ), + ]; + h.mail.messages[kInboxId] = many; + await openSearch(tester); + // 첫 쪽(20통 · 전체 25)은 기본 검색 그대로, 다음 쪽은 실패 + h.mail.searchResponder = (Map query) => + query['cursor'] == null + ? h.mail.defaultSearch(query) + : FakeResponse.fail(503, 'MAIL_001'); + await search(tester, '계약'); + expect(h.mail.searchQueries, hasLength(1)); + + await tester.scrollUntilVisible( + find.text(MailListTexts.retry), + 500, + scrollable: results(), + ); + await tester.pumpAndSettle(); + expect(h.mail.searchQueries, hasLength(2)); + expect(h.notifier.shownCodes, ['MAIL_001']); + + h.mail.searchResponder = null; + await tester.tap(find.text(MailListTexts.retry)); + await tester.pumpAndSettle(); + expect(h.mail.searchQueries, hasLength(3)); + // [다시 시도]는 받지 못한 그 커서로 다시 묻는다 + expect( + h.mail.searchQueries.last['cursor'], + FakeMailBackend.searchCursorAfter(many[19]), + ); + expect( + h.mail.searchQueries.last['cursor'], + h.mail.searchQueries[1]['cursor'], + ); + await tester.scrollUntilVisible( + find.text('계약 25'), + 500, + scrollable: results(), + ); + expect(find.text(MailSearchTexts.count('계약', 25)), findsOneWidget); + }); + }); + + group('결과에서 열기 · 결과 반영', () { + testWidgets('결과를 누르면 읽기 화면 — 강조 쿼리 · 메일함 줄, 제목 · 첨부 이름 · 글 본문을 칠한다', ( + WidgetTester tester, + ) async { + seedContracts(); + h.mail.highlightDetails['m1'] = { + 'textBody': '도윤입니다.\n말씀하신 조건으로 계약서 초안을 고쳐 첨부했습니다.', + 'attachments': [attachmentJson('a1', name: '계약서_초안_v2.pdf')], + 'searchHighlight': { + 'subject': [ + [9, 11], + ], + 'attachments': { + 'a1': [ + [0, 2], + ], + }, + 'textBody': [ + [17, 19], + ], + }, + }; + await openSearch(tester); + await search(tester, '계약'); + await tester.tap(find.text(_doyunSubject)); + await tester.pumpAndSettle(); + + expect(find.byType(MessageReaderPage), findsOneWidget); + expect(h.mail.detailQueries.last.query, {'q': '계약'}); + final Finder header = find.byType(ReaderHeader); + expect(marksIn(tester, header), ['계약']); + expect( + find.descendant( + of: header, + matching: find.text(MailSearchTexts.mailboxLine), + ), + findsOneWidget, + ); + expect( + find.descendant(of: header, matching: find.text('받은편지함')), + findsOneWidget, + ); + expect(marksIn(tester, find.byType(MessageReaderPage)), [ + '계약', + '계약', + ]); + final String document = h.webView.last.lastHtml!; + expect(document, contains('조건으로 계약서')); + expect(document, contains('mark{background:#FCE7A4;color:inherit')); + }); + + testWidgets('14단계 앞 서버(강조 칸 없음)의 결과는 강조 쿼리 없이 연다 — 메일함 줄은 있다', ( + WidgetTester tester, + ) async { + h.mail.put( + kInboxId, + messageJson('o1', mailboxId: kInboxId, subject: '옛 서버의 계약'), + ); + await openSearch(tester); + await search(tester, '계약'); + await tester.tap(find.text('옛 서버의 계약')); + await tester.pumpAndSettle(); + expect(h.mail.detailQueries.last.query, isEmpty); + expect(find.text(MailSearchTexts.mailboxLine), findsOneWidget); + }); + + testWidgets('[이미지 보기]는 같은 강조 쿼리로 다시 받는다', (WidgetTester tester) async { + seedContracts(); + h.mail + ..details['m1'] = { + 'htmlBody': '

계약 뉴스레터

', + 'hasBlockedImages': true, + } + ..shownImageDetails['m1'] = { + 'htmlBody': '

계약 뉴스레터

', + 'hasBlockedImages': false, + 'imagesShown': true, + }; + await openSearch(tester); + await search(tester, '계약'); + await tester.tap(find.text(_doyunSubject)); + await tester.pumpAndSettle(); + await tester.tap(find.text(MailReaderTexts.showImages)); + await tester.pumpAndSettle(); + expect(h.mail.detailQueries.last.query, { + kMailImagesQuery: kMailImagesShow, + 'q': '계약', + }); + expect(h.webView.last.lastHtml, contains('계약 뉴스레터')); + }); + + testWidgets('붙잡힌 메일을 다시 봐 요약을 바꿔도 제목 강조는 강조를 받은 응답의 글로 그린다', ( + WidgetTester tester, + ) async { + h.mail + ..searchHighlightField = true + ..put( + kInboxId, + messageJson( + 'h1', + mailboxId: kInboxId, + subject: '계약 안내', + pendingSend: pendingJson( + h.clock.now.add(const Duration(seconds: 5)), + ), + ), + ); + h.mail.highlightDetails['h1'] = { + 'searchHighlight': { + 'subject': [ + [0, 2], + ], + 'attachments': {}, + 'textBody': [], + }, + }; + await openSearch(tester); + await search(tester, '계약'); + await tester.tap(find.text('계약 안내')); + await tester.pumpAndSettle(); + expect(marksIn(tester, find.byType(ReaderHeader)), ['계약']); + + // 보낼 시각 + 2초 — 쿼리 없이 다시 받는다(그사이 제목 글이 바뀌었다) + h.mail.messageOf('h1')!['subject'] = '계약 안내 (고침)'; + await elapse(tester, const Duration(seconds: 8)); + expect(h.mail.detailQueries.last.query, isEmpty); + expect( + find.descendant( + of: find.byType(ReaderHeader), + matching: find.text('계약 안내'), + ), + findsOneWidget, + ); + expect(marksIn(tester, find.byType(ReaderHeader)), ['계약']); + }); + + testWidgets('임시보관함의 결과는 쓰기 화면에서 이어 쓴다', (WidgetTester tester) async { + h.mail.put( + kDraftsId, + messageJson('d1', mailboxId: kDraftsId, subject: '계약 초안 쓰는 중'), + ); + await openSearch(tester); + await tester.tap(find.text(MailSearchTexts.scopeAll)); + await tester.pumpAndSettle(); + await search(tester, '계약'); + await tester.tap(find.text('계약 초안 쓰는 중')); + await tester.pumpAndSettle(); + expect( + GoRouterState.of( + tester.element(find.byType(MessageComposePage).last), + ).uri.toString(), + MailRoutes.composeDraft('d1'), + ); + }); + + testWidgets('읽기 화면에서 읽으면 굵기가 풀리고, 휴지통으로 옮기면 빠진다 — [되돌리기]는 그 자리에 다시', ( + WidgetTester tester, + ) async { + seedContracts(); + await openSearch(tester); + await search(tester, '계약'); + Text whoOf(String subject) => tester.widget( + find.descendant(of: row(subject), matching: find.text('이도윤')), + ); + expect(whoOf(_doyunSubject).style?.fontWeight, AppTypography.bold); + + await tester.tap(find.text(_doyunSubject)); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip(MailReaderTexts.back)); + await tester.pumpAndSettle(); + expect(whoOf(_doyunSubject).style?.fontWeight, AppTypography.medium); + + await tester.tap(find.text(_doyunSubject)); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip(MailReaderTexts.delete)); + await tester.pumpAndSettle(); + expect(find.byType(MessageReaderPage), findsNothing); + expect(find.text(_doyunSubject), findsNothing); + expect(find.text(MailSearchTexts.count('계약', 2)), findsOneWidget); + expect(toastText(MailBulkTexts.moved(1, '휴지통')), findsOneWidget); + + await tester.tap(find.text(MailBulkTexts.undo)); + await tester.pumpAndSettle(); + expect(h.mail.mailboxOf('m1'), kInboxId); + expect(find.text(MailSearchTexts.count('계약', 3)), findsOneWidget); + final double doyun = tester.getTopLeft(row(_doyunSubject)).dy; + final double hr = tester.getTopLeft(row(_hrSubject)).dy; + expect(doyun, lessThan(hr)); + expect(marksIn(tester, row(_doyunSubject)), ['계약', '계약']); + expect(h.mail.searchQueries, hasLength(1)); + }); + + testWidgets('1통만 찾고 멈춘 결과를 지웠다 [되돌리기]하면 다시 보인다 — 아직 못 찾음 → 결과 + [더 찾기]', ( + WidgetTester tester, + ) async { + h.mail.put( + kInboxId, + messageJson('p1', mailboxId: kInboxId, subject: '정산서 보냅니다'), + ); + int calls = 0; + h.mail.searchResponder = (Map query) { + calls++; + return FakeResponse.ok({ + 'items': [ + if (calls == 1) Map.of(h.mail.messageOf('p1')!), + ], + 'pageInfo': { + 'hasNext': true, + 'nextCursor': 'c$calls', + 'total': null, + }, + }); + }; + await openSearch(tester); + await search(tester, '정산서'); + expect(find.byType(MailSearchMoreLine), findsOneWidget); + + await tester.tap(find.text('정산서 보냅니다')); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip(MailReaderTexts.delete)); + await tester.pumpAndSettle(); + expect(find.text('정산서 보냅니다'), findsNothing); + expect(findCopy(MailSearchTexts.partialTitle), findsOneWidget); + + await tester.tap(find.text(MailBulkTexts.undo)); + await tester.pumpAndSettle(); + expect(h.mail.mailboxOf('p1'), kInboxId); + expect(find.text('정산서 보냅니다'), findsOneWidget); + expect(find.byType(MailSearchMoreLine), findsOneWidget); + expect(findCopy(MailSearchTexts.partialTitle), findsNothing); + expect(calls, kMailSearchFillMaxRequests); + }); + + testWidgets('결과의 붙잡힌 메일은 보낼 시각 + 2초에 그 줄만 다시 받는다 — 표시만 덮고 제목 · 강조는 그대로', ( + WidgetTester tester, + ) async { + h.mail + ..searchHighlightField = true + ..put( + kSentId, + messageJson( + 's1', + mailboxId: kSentId, + subject: '계약 보내는 중', + isRead: true, + pendingSend: pendingJson( + h.clock.now.add(const Duration(seconds: 8)), + ), + ), + ); + h.mail.searchHighlights['s1'] = { + 'subject': [ + [0, 2], + ], + 'preview': {'text': '미리 보기 글', 'ranges': []}, + }; + await openSearch(tester, location: MailRoutes.mailbox(kSentId)); + await search(tester, '계약'); + Finder sending() => find.descendant( + of: row('계약 보내는 중'), + matching: find.text(MailListTexts.sending), + ); + expect(sending(), findsOneWidget); + final int before = h.mail.detailQueries.length; + h.mail.messageOf('s1')! + ..['pendingSend'] = null + ..['subject'] = '계약 보내는 중 (다른 글)'; + + await elapse(tester, const Duration(seconds: 9)); + expect(h.mail.detailQueries, hasLength(before)); + await elapse(tester, const Duration(seconds: 1)); + + expect(h.mail.detailQueries, hasLength(before + 1)); + expect(h.mail.detailQueries.last.id, 's1'); + expect(h.mail.detailQueries.last.query, isEmpty); + expect(sending(), findsNothing); + expect(marksIn(tester, row('계약 보내는 중')), ['계약']); + expect(h.mail.searchQueries, hasLength(1)); + expect(h.notifier.shownCodes, isEmpty); + }); + + testWidgets('붙잡힌 줄을 다시 받다 실패하면 5초 뒤 다시(알리지 않는다) · 메일이 없어졌으면(RES_001) 뺀다', ( + WidgetTester tester, + ) async { + h.mail + ..put( + kSentId, + messageJson( + 's1', + mailboxId: kSentId, + subject: '계약 보내는 중', + isRead: true, + pendingSend: pendingJson( + h.clock.now.add(const Duration(seconds: 8)), + ), + ), + ) + ..put( + kSentId, + messageJson( + 's2', + mailboxId: kSentId, + subject: '계약 보낸 메일', + isRead: true, + receivedAt: h.clock.now.subtract(const Duration(hours: 1)), + ), + ); + await openSearch(tester, location: MailRoutes.mailbox(kSentId)); + await search(tester, '계약'); + expect(find.text(MailSearchTexts.count('계약', 2)), findsOneWidget); + final int before = h.mail.detailQueries.length; + h.mail.detailFailure = FakeResponse.fail(503, 'MAIL_001'); + + await elapse(tester, const Duration(seconds: 10)); + expect(h.mail.detailQueries, hasLength(before + 1)); + expect(find.text(MailListTexts.sending), findsOneWidget); + expect(h.notifier.shownCodes, isEmpty); + + h.mail.detailFailure = FakeResponse.fail(404, 'RES_001'); + await elapse(tester, kHeldReleaseRecheck); + expect(h.mail.detailQueries, hasLength(before + 2)); + expect(find.text('계약 보내는 중'), findsNothing); + expect(find.text('계약 보낸 메일'), findsOneWidget); + expect(find.text(MailSearchTexts.count('계약', 1)), findsOneWidget); + expect(h.notifier.shownCodes, isEmpty); + }); + }); + + group('낭독', () { + testWidgets( + '개수 줄은 결과가 오면 읽고(live region) · 결과 줄은 메일함 이름까지 읽는다 · 사진은 단추가 아니다', + (WidgetTester tester) async { + seedContracts(); + await openSearch(tester); + await tester.tap(find.text(MailSearchTexts.scopeAll)); + await tester.pumpAndSettle(); + await search(tester, '계약'); + + expect( + tester.getSemantics(find.text(MailSearchTexts.count('계약', 3))), + isSemantics(isLiveRegion: true), + ); + expect( + tester.getSemantics(row(_hrSubject)).label, + contains('인사팀, 받은편지함, '), + ); + expect(find.byType(MailAvatarButton), findsNothing); + + // 길게 눌러도 아무 일 없다 — 고르지 않고 열지도 않는다 + await tester.longPress(find.text(_hrSubject)); + await tester.pumpAndSettle(); + expect(find.text(MailBulkTexts.selected(1)), findsNothing); + expect(find.byType(MessageReaderPage), findsNothing); + expect(find.byType(MailSearchScopeBar), findsOneWidget); + }, + ); + + testWidgets('찾을 곳은 "찾을 곳" 묶음 안의 두 단추 — 고른 칸이 고름 상태', ( + WidgetTester tester, + ) async { + await openSearch(tester); + expect( + tester.getSemantics( + find.widgetWithText(InkWell, MailSearchTexts.scopeAll), + ), + isSemantics(isButton: true, isSelected: false), + ); + await tester.tap(find.text(MailSearchTexts.scopeAll)); + await tester.pumpAndSettle(); + expect( + tester.getSemantics( + find.widgetWithText(InkWell, MailSearchTexts.scopeAll), + ), + isSemantics(isButton: true, isSelected: true), + ); + expect(find.bySemanticsLabel(MailSearchTexts.scopeLabel), findsWidgets); + }); + + testWidgets('검색 칸의 이름은 한 번만 읽는다 — 빈 칸 · 글을 쓴 뒤 모두(보이는 안내 글은 낭독에서 뺀다)', ( + WidgetTester tester, + ) async { + await openSearch(tester); + expect(find.text(MailSearchTexts.box), findsOneWidget); + expect(tester.getSemantics(field()).label, MailSearchTexts.box); + + await tester.enterText(field(), '계약'); + await tester.pump(); + final SemanticsNode node = tester.getSemantics(field()); + expect(node.label, MailSearchTexts.box); + expect(node.value, '계약'); + }); + }); + + group('크기', () { + Future countLineHeight(WidgetTester tester, double scale) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(scale)), + child: Scaffold( + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + MailSearchCountLine(MailSearchTexts.count('계약', 4)), + ], + ), + ), + ), + ), + ); + expect(tester.takeException(), isNull); + return tester.getSize(find.byType(MailSearchCountLine)).height; + } + + testWidgets('개수 줄은 높이 36 — 큰 글자에서는 글에 맞춰 늘어난다(잘리지 않는다)', ( + WidgetTester tester, + ) async { + expect(await countLineHeight(tester, 1), AppSize.searchCountLineHeight); + final double large = await countLineHeight(tester, 3); + expect(large, greaterThan(AppSize.searchCountLineHeight)); + expect( + large, + greaterThanOrEqualTo( + tester.getSize(find.text(MailSearchTexts.count('계약', 4))).height, + ), + ); + }); + }); +} diff --git a/flutter/test/features/mail/mail_search_view_test.dart b/flutter/test/features/mail/mail_search_view_test.dart new file mode 100644 index 0000000..2db6708 --- /dev/null +++ b/flutter/test/features/mail/mail_search_view_test.dart @@ -0,0 +1,383 @@ +import 'package:arasystem_app/features/mail/data/models/mail_page.dart'; +import 'package:arasystem_app/features/mail/data/models/mail_search.dart'; +import 'package:arasystem_app/features/mail/data/models/mailbox.dart'; +import 'package:arasystem_app/features/mail/domain/search_conditions.dart'; +import 'package:arasystem_app/features/mail/domain/search_highlight.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_row_view.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_search_view.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_search_state.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mailboxes_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/fakes.dart'; +import '../../support/mail_fakes.dart'; + +final MailboxesState _mailboxes = MailboxesState( + status: MailboxesStatus.ready, + items: [ + for (final Map json in standardMailboxes()) + Mailbox.tryParse(json)!, + ], +); + +MailSearchItem _item( + String id, { + required String mailboxId, + String subject = '유지보수 계약 갱신 건', + String preview = '본문 첫 부분', + List> to = const >[], + bool isRead = true, + bool hasAttachment = false, + Map? highlight, + bool supported = true, +}) => MailSearchItem.tryParse({ + ...messageJson( + id, + mailboxId: mailboxId, + subject: subject, + preview: preview, + to: to, + isRead: isRead, + hasAttachment: hasAttachment, + ), + if (supported) 'searchHighlight': highlight, +})!; + +MailSearchState _ready({ + String query = '계약', + bool all = false, + String? mailboxId = kInboxId, + int? total = 4, + SearchIndexStatus? index, + bool refreshing = false, +}) => MailSearchState( + phase: MailSearchPhase.ready, + allMailboxes: all, + query: query, + searchedAll: all, + searchedMailboxId: all ? null : mailboxId, + pageInfo: MailPageInfo(hasNext: false, total: total), + indexStatus: index, + refreshing: refreshing, +); + +/// 검색 화면의 표시 — 결과 머리 · 안내 줄 · 결과 줄 · 연 메일에 넘기는 값 (보드 `App1Search`) +void main() { + group('결과 머리(개수 줄)', () { + test('찾은 뒤 "‘계약’ · 4통", 전체 수를 모르면 검색어만, 찾는 동안 · 다시 찾는 동안은 "찾는 중"', () { + expect(searchCountLineOf(_ready()), MailSearchTexts.count('계약', 4)); + expect( + searchCountLineOf(_ready(total: null)), + MailSearchTexts.quoted('계약'), + ); + expect( + searchCountLineOf(_ready(refreshing: true)), + MailSearchTexts.searching('계약'), + ); + expect( + searchCountLineOf( + const MailSearchState(phase: MailSearchPhase.searching, query: '계약'), + ), + MailSearchTexts.searching('계약'), + ); + }); + + test('찾기 전 · 낱말이 너무 많음 · 받지 못함에는 머리가 없다', () { + expect(searchCountLineOf(const MailSearchState()), isNull); + expect( + searchCountLineOf( + const MailSearchState(phase: MailSearchPhase.tooManyWords), + ), + isNull, + ); + expect( + searchCountLineOf( + const MailSearchState(phase: MailSearchPhase.failed, query: '계약'), + ), + isNull, + ); + }); + }); + + group('안내 줄', () { + test('준비 중이면 준비 중 줄, 준비됐고 한 글자 낱말뿐이면 한 글자 안내 — 함께 뜨지 않는다', () { + expect( + searchNoticeOf(_ready(index: SearchIndexStatus.building)), + MailSearchNotice.indexing, + ); + expect( + searchNoticeOf(_ready(query: '김', index: SearchIndexStatus.building)), + MailSearchNotice.indexing, + ); + expect( + searchNoticeOf(_ready(query: '김', index: SearchIndexStatus.ready)), + MailSearchNotice.singleChar, + ); + expect(searchNoticeOf(_ready(index: SearchIndexStatus.ready)), isNull); + }); + + test('준비 상태를 모르면(받지 못함 · 14단계 앞 서버) 아무 줄도 없다 · 찾는 동안에도 없다', () { + expect(searchNoticeOf(_ready(query: '김')), isNull); + expect( + searchNoticeOf( + const MailSearchState( + phase: MailSearchPhase.searching, + query: '김', + indexStatus: SearchIndexStatus.ready, + ), + ), + isNull, + ); + }); + }); + + group('찾을 곳의 시작한 메일함 이름', () { + test('받은편지함 주소는 기본 메일함 이름(받기 전이면 "받은편지함"), 그 밖은 그 메일함(모르면 "메일함")', () { + expect(searchOriginNameOf(null, _mailboxes), '받은편지함'); + expect( + searchOriginNameOf(null, const MailboxesState()), + MailListTexts.inbox, + ); + expect(searchOriginNameOf(kClientsId, _mailboxes), '거래처'); + expect( + searchOriginNameOf('mb-gone', _mailboxes), + MailListTexts.mailboxFallback, + ); + }); + }); + + group('결과 줄', () { + final DateTime now = kTestNow; + + test('한 메일함에서 찾으면 목록 줄 그대로 — 메일함 이름이 없다', () { + final MailRowView view = searchRowViewOf( + _item('m1', mailboxId: kInboxId), + state: _ready(), + mailboxes: _mailboxes, + now: now, + ); + expect(view.mailboxName, isNull); + expect(view.who, '이도윤'); + }); + + test('전체 메일함이면 줄마다 그 메일의 메일함 — 보낸편지함 · 임시보관함은 "받는 사람: …"', () { + final MailSearchState all = _ready(all: true); + final MailRowView sent = searchRowViewOf( + _item( + 's1', + mailboxId: kSentId, + to: >[addressJson('이도윤', 'doyun@example.com')], + ), + state: all, + mailboxes: _mailboxes, + now: now, + ); + expect(sent.mailboxName, '보낸편지함'); + expect(sent.who, '${MailListTexts.recipientPrefix}이도윤'); + final MailRowView draft = searchRowViewOf( + _item( + 'd1', + mailboxId: kDraftsId, + to: >[addressJson('박지훈', 'jihoon@example.com')], + ), + state: all, + mailboxes: _mailboxes, + now: now, + ); + expect(draft.mailboxName, '임시보관함'); + expect(draft.who, '${MailListTexts.recipientPrefix}박지훈'); + // 한 메일함(임시보관함)에서 찾으면 이름만 + final MailRowView draftOnly = searchRowViewOf( + _item( + 'd1', + mailboxId: kDraftsId, + to: >[addressJson('박지훈', 'jihoon@example.com')], + ), + state: _ready(mailboxId: kDraftsId), + mailboxes: _mailboxes, + now: now, + ); + expect(draftOnly.who, '박지훈'); + }); + + test('강조가 있으면 미리 보기를 서버의 맞은 말 둘레 한 줄로 바꾸고 제목 · 미리 보기의 맞은 곳을 싣는다', () { + final MailRowView view = searchRowViewOf( + _item( + 'm1', + mailboxId: kInboxId, + preview: '엔진 미리 보기', + highlight: { + 'subject': [ + [5, 7], + ], + 'preview': { + 'text': '…지난달 계약 기간에', + 'ranges': [ + [5, 7], + ], + }, + }, + ), + state: _ready(), + mailboxes: _mailboxes, + now: now, + ); + expect(view.preview, '…지난달 계약 기간에'); + expect(view.subjectHighlights, [(start: 5, end: 7)]); + expect(view.previewHighlights, [(start: 5, end: 7)]); + // 강조가 없으면 지금 미리 보기 + final MailRowView plain = searchRowViewOf( + _item('m2', mailboxId: kInboxId, preview: '엔진 미리 보기'), + state: _ready(), + mailboxes: _mailboxes, + now: now, + ); + expect(plain.preview, '엔진 미리 보기'); + expect(plain.subjectHighlights, isEmpty); + }); + + test('제목이 없으면 "(제목 없음)"에 강조를 싣지 않는다', () { + final MailRowView view = searchRowViewOf( + _item( + 'm1', + mailboxId: kInboxId, + subject: '', + highlight: { + 'subject': [ + [0, 2], + ], + 'preview': {'text': '계약', 'ranges': []}, + }, + ), + state: _ready(), + mailboxes: _mailboxes, + now: now, + ); + expect(view.subject, MailListTexts.noSubject); + expect(view.subjectHighlights, isEmpty); + }); + + test('낭독 글은 안 읽음 · 보낸 사람 · 메일함 · 시각 · 제목 · 첨부 · 미리 보기 차례 — 강조는 읽지 않는다', () { + final MailRowView view = searchRowViewOf( + _item( + 'm1', + mailboxId: kInboxId, + subject: '유지보수 계약', + preview: '계약서 초안', + isRead: false, + hasAttachment: true, + ), + state: _ready(all: true), + mailboxes: _mailboxes, + now: now, + ); + expect( + view.semanticsLabel, + [ + MailListTexts.unreadPrefix, + '이도윤', + '받은편지함', + formatMailListTime(kTestNow, now), + '유지보수 계약', + MailListTexts.attachment, + '계약서 초안', + ].join(', '), + ); + }); + }); + + group('결과에서 연 메일에 넘기는 값', () { + test('14단계 서버의 결과만 강조 쿼리를 싣는다 — 한 메일함이면 그 메일함도', () { + final MailSearchOpen open = searchOpenOf( + _item('m1', mailboxId: kInboxId, highlight: null), + _ready(), + ); + expect(open.highlight, const MessageHighlightQuery(q: '계약')); + expect(open.scopeMailboxId, kInboxId); + + final MailSearchOpen old = searchOpenOf( + _item('m1', mailboxId: kInboxId, supported: false), + _ready(all: true), + ); + expect(old.highlight, isNull); + expect(old.scopeMailboxId, isNull); + }); + }); + + group('조건 (2차)', () { + const SearchConditions fromDoyun = SearchConditions( + from: '이도윤', + subject: '갱신', + since: SearchDay(2026, 10, 6), + hasAttachment: true, + ); + + test('조건만으로 찾았으면 머리는 "2통" · "찾는 중", 전체 수를 모르면 머리가 없다', () { + MailSearchState conditionsOnly({int? total, bool refreshing = false}) => + MailSearchState( + phase: MailSearchPhase.ready, + query: '', + pageInfo: MailPageInfo(hasNext: false, total: total), + conditions: fromDoyun, + refreshing: refreshing, + ); + expect(searchCountLineOf(conditionsOnly(total: 2)), '2통'); + expect( + searchCountLineOf(conditionsOnly(total: 2, refreshing: true)), + '찾는 중', + ); + expect(searchCountLineOf(conditionsOnly()), isNull); + }); + + test('한 글자 안내는 본문을 찾을 말(검색어 · 내용 조건)이 한 글자뿐일 때만', () { + MailSearchState ready(String query, SearchConditions conditions) => + MailSearchState( + phase: MailSearchPhase.ready, + query: query, + conditions: conditions, + indexStatus: SearchIndexStatus.ready, + ); + expect( + searchNoticeOf(ready('', const SearchConditions(body: '가'))), + MailSearchNotice.singleChar, + ); + expect( + searchNoticeOf(ready('', const SearchConditions(from: '김'))), + isNull, + ); + expect( + searchNoticeOf(ready('김', const SearchConditions(subject: '견적'))), + isNull, + ); + }); + + test('결과에서 연 메일에는 검색어와 글 조건만 — 실을 칸이 없으면 강조 쿼리 없이', () { + const MailSearchState state = MailSearchState( + phase: MailSearchPhase.ready, + query: '계약', + searchedMailboxId: kInboxId, + pageInfo: MailPageInfo(hasNext: false, total: 1), + conditions: fromDoyun, + ); + expect( + searchOpenOf(_item('m1', mailboxId: kInboxId), state).highlight, + const MessageHighlightQuery(q: '계약', from: '이도윤', subject: '갱신'), + ); + const MailSearchState datesOnly = MailSearchState( + phase: MailSearchPhase.ready, + query: '', + searchedMailboxId: kInboxId, + pageInfo: MailPageInfo(hasNext: false, total: 1), + conditions: SearchConditions( + since: SearchDay(2026, 10, 6), + hasAttachment: true, + ), + ); + expect( + searchOpenOf(_item('m1', mailboxId: kInboxId), datesOnly).highlight, + isNull, + ); + }); + }); +} diff --git a/flutter/test/features/mail/message_compose_page_test.dart b/flutter/test/features/mail/message_compose_page_test.dart new file mode 100644 index 0000000..46612e0 --- /dev/null +++ b/flutter/test/features/mail/message_compose_page_test.dart @@ -0,0 +1,1992 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:arasystem_app/core/api/api_paths.dart'; +import 'package:arasystem_app/core/api/error_lexicon.dart'; +import 'package:arasystem_app/core/files/downloaded_files.dart'; +import 'package:arasystem_app/core/files/picked_files.dart'; +import 'package:arasystem_app/core/people/people_texts.dart'; +import 'package:arasystem_app/core/theme/app_colors.dart'; +import 'package:arasystem_app/core/utils/file_size.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart'; +import 'package:arasystem_app/features/mail/domain/compose_attachments.dart'; +import 'package:arasystem_app/features/mail/domain/compose_body.dart'; +import 'package:arasystem_app/features/mail/domain/compose_draft_body.dart'; +import 'package:arasystem_app/features/mail/domain/compose_fit.dart'; +import 'package:arasystem_app/features/mail/domain/compose_request.dart'; +import 'package:arasystem_app/features/mail/mail_routes.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; +import 'package:arasystem_app/features/mail/presentation/pages/mail_list_page.dart'; +import 'package:arasystem_app/features/mail/presentation/pages/message_compose_page.dart'; +import 'package:arasystem_app/features/mail/presentation/pages/message_reader_page.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/compose_controller.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/compose_state.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/compose_recipient_field.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/compose_sections.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/mail_compose_area.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/reader_bars.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/misc.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/compose_fakes.dart'; +import '../../support/fakes.dart'; +import '../../support/login_fakes.dart'; +import '../../support/mail_fakes.dart'; +import '../../support/mail_harness.dart'; + +/// 쓰기 화면 — 앱 보드 `App1Compose` (가짜 서버 · 가짜 시계로 앱 전체를 띄운다) +void main() { + late MailAppHarness h; + late FakeComposeBackend c; + + // 원문 인용 · 저장본 본문은 시험에서 늘 이 isolate 에서 만든다(가짜 시계 안에서 다른 isolate 를 기다리지 않게) + Future inlineQuote(ComposeQuoteRequest request) async => + initialQuoteOf(request); + + /// 저장본 본문 읽기 — 시험이 바꿔 끼운다(실패 · 다시 시도) + late DraftBodyBuilder draftBuilder; + + setUp(() { + h = MailAppHarness(); + c = FakeComposeBackend(h.server, h.mail); + draftBuilder = ({required String? bodyHtml, required String body}) async => + draftBodyOf(bodyHtml: bodyHtml, body: body); + h.overrides.addAll([ + composeQuoteBuilderProvider.overrideWithValue(inlineQuote), + composeDraftBodyBuilderProvider.overrideWithValue( + ({required String? bodyHtml, required String body}) => + draftBuilder(bodyHtml: bodyHtml, body: body), + ), + ]); + h.server.serverTime = kTestNow; + }); + + final RegExp uuid = RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', + ); + + Future start(WidgetTester tester) async { + c.install(); + if (!h.mail.messages.containsKey(kInboxId)) h.mail.seed(kInboxId, 2); + await h.pump(tester); + } + + Future open( + WidgetTester tester, + String location, { + Object? extra, + }) async { + unawaited(h.router.push(location, extra: extra)); + await tester.pumpAndSettle(); + } + + Finder field(RecipientFieldKind kind) => find.descendant( + of: find.byKey(ValueKey(kind)), + matching: find.byType(TextField), + ); + final Finder subjectField = find.byKey( + const ValueKey('compose-subject'), + ); + final Finder bodyField = find.byKey(const ValueKey('compose-body')); + + /// 칩 · 칸 안 글 + Finder chip(String text) => find.descendant( + of: find.byType(ComposeRecipientField), + matching: find.text(text), + ); + String textOf(WidgetTester tester, Finder finder) => + tester.widget(finder).controller!.text; + bool focused(WidgetTester tester, Finder finder) => + tester.widget(finder).focusNode!.hasFocus; + Finder snack(String text) => + find.descendant(of: find.byType(SnackBar), matching: find.text(text)); + Finder toast(String text) => find.descendant( + of: find.byType(MailToastCard), + matching: find.text(text), + ); + + Future tapTooltip(WidgetTester tester, String tooltip) async { + await tester.tap(find.byTooltip(tooltip)); + await tester.pumpAndSettle(); + } + + Future addRecipient(WidgetTester tester, String address) async { + await tester.enterText(field(RecipientFieldKind.to), '$address,'); + await tester.pump(); + } + + /// 마지막 고르기에 넘긴 한도 — 남은 개수 · 파일당 · 남은 합계, 허용 확장자는 늘 형식 표 그대로 + void expectPick({ + required int maxBytes, + required int maxCount, + required int maxTotalBytes, + }) { + final ({ + int maxBytes, + int maxCount, + int maxTotalBytes, + List extensions, + }) + pick = h.picked.picks.last; + expect(pick.maxBytes, maxBytes); + expect(pick.maxCount, maxCount); + expect(pick.maxTotalBytes, maxTotalBytes); + expect(pick.extensions, unorderedEquals(kMailAttachmentTypes.keys)); + } + + group('열기', () { + testWidgets( + '새 메일 — "새 메일" · 받는 사람 칸에 초점 · 추천 · 그룹 · 첨부 한도를 미리 받는다 · [파일 첨부]는 고르는 창(닫으면 그대로)', + (WidgetTester tester) async { + await start(tester); + await open(tester, MailRoutes.compose); + + expect(find.text(MailComposeTexts.newMail), findsOneWidget); + expect(find.byType(NavigationBar), findsNothing); + expect(focused(tester, field(RecipientFieldKind.to)), isTrue); + expect(c.suggestionRequests.single.queryParameters, { + 'limit': 100, + }); + expect(h.server.received('GET', '/contact-groups'), hasLength(1)); + // 참조 · 숨은참조는 접혀 있다 + expect( + find.byKey(const ValueKey(RecipientFieldKind.cc)), + findsNothing, + ); + + expect(h.server.received('GET', '/mail-quota'), hasLength(1)); + + // 고르는 창 — 남은 자리 · 파일당 · 남은 합계 · 허용 확장자를 넘긴다. 고르지 않고 닫으면 아무것도 없다 + await tapTooltip(tester, MailComposeTexts.attach); + expect(h.picked.picks, hasLength(1)); + expectPick( + maxBytes: 20 * 1024 * 1024, + maxCount: 20, + maxTotalBytes: 25 * 1024 * 1024, + ); + expect(find.byType(SnackBar), findsNothing); + expect(find.text(MailComposeTexts.attachmentsTitle(0)), findsNothing); + + await tapTooltip(tester, MailComposeTexts.expandCopies); + expect(field(RecipientFieldKind.cc), findsOneWidget); + expect(field(RecipientFieldKind.bcc), findsOneWidget); + expect(find.byTooltip(MailComposeTexts.collapseCopies), findsOneWidget); + }, + ); + + testWidgets('mailto 로 연 새 메일 — 받는 사람 · 숨은참조(펼쳐 보인다) · 제목 · 본문을 채운다', ( + WidgetTester tester, + ) async { + await start(tester); + await open( + tester, + MailRoutes.compose, + extra: const ComposePrefill( + to: ['kim@example.com'], + bcc: ['hidden@example.com'], + subject: 'hi', + body: '본문', + ), + ); + + expect(chip('kim@example.com'), findsOneWidget); + expect(chip('hidden@example.com'), findsOneWidget); + expect(textOf(tester, subjectField), 'hi'); + expect(textOf(tester, bodyField), '본문'); + // 받는 사람 · 제목이 있으니 본문에 초점 + expect(focused(tester, bodyField), isTrue); + }); + + testWidgets( + '답장 — 원본과 서명을 받아 채운다: 받는 사람 · "Re:" 제목 · 서명 · 원본 메일(접힘 — 누르면 펼친다)', + (WidgetTester tester) async { + c.signature = { + 'bodyHtml': null, + 'body': '김아라\n경영지원팀', + 'isForNew': false, + 'isForReply': true, + }; + h.mail.put( + kClientsId, + messageJson( + 'r1', + mailboxId: kClientsId, + subject: '유지보수 계약', + isRead: true, + ), + detail: {'textBody': '원문 첫 줄\n\n> 앞선 글'}, + ); + await start(tester); + await open(tester, MailRoutes.composeReply('r1')); + + expect(find.text(MailComposeTexts.reply), findsOneWidget); + expect(chip('이도윤'), findsOneWidget); + expect(textOf(tester, subjectField), 'Re: 유지보수 계약'); + expect(focused(tester, bodyField), isTrue); + expect(find.text('김아라'), findsOneWidget); + expect(find.text('경영지원팀'), findsOneWidget); + expect(find.textContaining('이도윤 · '), findsOneWidget); + expect(find.text('원문 첫 줄'), findsNothing); + + await tester.tap(find.text(MailComposeTexts.quote).first); + await tester.pumpAndSettle(); + expect(find.text('원문 첫 줄'), findsOneWidget); + expect(find.text('> 앞선 글'), findsOneWidget); + expect(find.text('보낸 사람'), findsOneWidget); + expect(find.text('이도윤 '), findsOneWidget); + expect(find.text('유지보수 계약'), findsOneWidget); + }, + ); + + testWidgets('전달 — 원본 첨부를 싣는다(형식이 맞지 않는 첨부는 까닭을 적고 싣지 않는다)', ( + WidgetTester tester, + ) async { + h.mail.put( + kInboxId, + messageJson('f1', mailboxId: kInboxId, subject: '견적', isRead: true), + detail: { + 'attachments': [ + attachmentJson('a1', name: '견적서.pdf'), + attachmentJson( + 'a2', + name: 'setup.exe', + type: 'application/octet-stream', + ), + attachmentJson( + 'a3', + name: 'logo.png', + type: 'image/png', + isInline: true, + size: 1024, + ), + ], + }, + ); + await start(tester); + await open(tester, MailRoutes.composeForward('f1')); + + expect(find.text(MailComposeTexts.forward), findsOneWidget); + expect(textOf(tester, subjectField), 'Fwd: 견적'); + expect(focused(tester, field(RecipientFieldKind.to)), isTrue); + expect(find.text(MailComposeTexts.attachmentsTitle(3)), findsOneWidget); + expect(findCopy(MailComposeTexts.unsendable), findsOneWidget); + + await tapTooltip(tester, MailComposeTexts.remove('logo.png')); + expect(find.text('logo.png'), findsNothing); + expect(find.text(MailComposeTexts.attachmentsTitle(2)), findsOneWidget); + + await addRecipient(tester, 'doyun@x.kr'); + await tapTooltip(tester, MailComposeTexts.send); + + final Map body = c.sends.single.body; + expect(body['attachments'], [ + {'id': 'a1', 'name': '견적서.pdf'}, + ]); + expect(body.containsKey('inReplyTo'), isFalse); + expect(body['subject'], 'Fwd: 견적'); + }); + + testWidgets('원본을 받지 못하면 [다시 시도] · 없는 저장본이면 [닫기]', ( + WidgetTester tester, + ) async { + c.putDraft( + 'd3', + to: >[addressJson('박준호', 'junho@example.com')], + ); + int calls = 0; + h.server.on('GET', '/drafts/d3', (_) { + calls++; + return calls == 1 + ? FakeResponse.fail(503, 'MAIL_001') + : FakeResponse.ok(c.drafts['d3']); + }); + await start(tester); + await open(tester, MailRoutes.composeDraft('d3')); + + expect(find.text(MailComposeTexts.loadingTitle), findsOneWidget); + expect(findCopy(ErrorLexicon.getMessage('MAIL_001')), findsOneWidget); + // 저장본은 화면이 알리므로 전역 알림은 없다 + expect(h.notifier.shownCodes, isEmpty); + + await tester.tap(find.text(MailListTexts.retry)); + await tester.pumpAndSettle(); + expect(chip('박준호'), findsOneWidget); + expect(find.text(MailComposeTexts.newMail), findsOneWidget); + + h.router.pop(); + await tester.pumpAndSettle(); + await open(tester, MailRoutes.composeDraft('gone')); + expect(findCopy(ErrorLexicon.getMessage('RES_001')), findsOneWidget); + await tester.tap(find.widgetWithText(TextButton, MailComposeTexts.close)); + await tester.pumpAndSettle(); + expect(find.byType(MessageComposePage), findsNothing); + expect(find.byType(MailListPage), findsOneWidget); + }); + }); + + group('리뷰 고침 (쓰기 화면 1차 리뷰)', () { + testWidgets('저장본 본문을 읽다 실패하면(네트워크 밖의 실패) 뼈대에 머물지 않고 [다시 시도]', ( + WidgetTester tester, + ) async { + c.putDraft( + 'd8', + to: >[addressJson('박준호', 'junho@example.com')], + subject: '읽기 실패', + bodyHtml: '

글

', + body: '글', + ); + draftBuilder = + ({required String? bodyHtml, required String body}) async => + throw StateError('읽지 못함'); + await start(tester); + await open(tester, MailRoutes.composeDraft('d8')); + + expect(findCopy(ErrorLexicon.getMessage('SYS_001')), findsOneWidget); + expect(find.text(MailListTexts.retry), findsOneWidget); + + draftBuilder = + ({required String? bodyHtml, required String body}) async => + draftBodyOf(bodyHtml: bodyHtml, body: body); + await tester.tap(find.text(MailListTexts.retry)); + await tester.pumpAndSettle(); + expect(chip('박준호'), findsOneWidget); + expect(textOf(tester, subjectField), '읽기 실패'); + }); + + testWidgets('진행 중인 저장을 기다리는 사이 떠나도 그대로 보낸다 — 보내는 동안은 칸을 막는다', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + await addRecipient(tester, 'doyun@x.kr'); + await tester.enterText(bodyField, '느린 저장 뒤에 보낼 글'); + final Completer gate = Completer(); + c.saveGate = gate; + await tester.pump(const Duration(seconds: 3)); + expect(c.saves.single.call, 'POST'); + + await tester.tap(find.byTooltip(MailComposeTexts.send)); + await tester.pump(); + expect(find.bySemanticsLabel(MailComposeTexts.sending), findsOneWidget); + expect(tester.widget(bodyField).enabled, isFalse); + expect(tester.widget(subjectField).enabled, isFalse); + expect( + tester.widget(field(RecipientFieldKind.to)).enabled, + isFalse, + ); + + await tapTooltip(tester, MailComposeTexts.close); + expect(find.byType(MessageComposePage), findsNothing); + expect(c.sends, isEmpty); + + gate.complete(); + await tester.pumpAndSettle(); + expect(c.sends, hasLength(1)); + expect(c.sends.single.body['draftId'], 'saved-1'); + expect(c.sends.single.body['body'], '느린 저장 뒤에 보낼 글'); + expect(toast(MailComposeTexts.sent), findsOneWidget); + }); + + testWidgets('떠난 뒤 기다린 저장이 첨부를 찾지 못하면 보내지 않고 그 코드의 사전 문구로 알린다', ( + WidgetTester tester, + ) async { + h.mail.put( + kInboxId, + messageJson('f2', mailboxId: kInboxId, subject: '자료', isRead: true), + detail: { + 'attachments': [attachmentJson('a1', name: '견적서.pdf')], + }, + ); + await start(tester); + await open(tester, MailRoutes.composeForward('f2')); + await addRecipient(tester, 'doyun@x.kr'); + await tester.enterText(bodyField, '자료 보냅니다'); + final Completer gate = Completer(); + c.saveGate = gate; + await tester.pump(const Duration(seconds: 3)); + await tester.tap(find.byTooltip(MailComposeTexts.send)); + await tester.pump(); + await tapTooltip(tester, MailComposeTexts.close); + + c.draftFailures.add(FakeResponse.fail(400, 'MAIL_004')); + gate.complete(); + await tester.pumpAndSettle(); + + expect(c.sends, isEmpty); + expect(h.notifier.shownCodes, ['MAIL_004']); + // 찾지 못한 첨부가 있으면 저장본으로도 남기지 않는다(서버가 같은 까닭으로 거절한다) + expect(c.saves, hasLength(1)); + }); + + testWidgets('대용량 파일을 다시 묻는 사이 뺀 파일은 되살아나지 않는다(MAIL_016)', ( + WidgetTester tester, + ) async { + Map file(String id) => { + 'id': id, + 'status': 'ready', + 'name': '$id.zip', + 'size': 1048576, + }; + c.putDraft( + 'd9', + to: >[addressJson('박준호', 'junho@example.com')], + subject: '자료', + body: '본문', + largeFiles: >[file('u1'), file('u2')], + ); + c.largeFiles['u1'] = file('u1'); + c.largeFiles['u2'] = file('u2'); + c.sendFailures.add(FakeResponse.fail(400, 'MAIL_016')); + final Completer gate = Completer(); + c.largeFileGate = gate; + await start(tester); + await open(tester, MailRoutes.composeDraft('d9')); + + await tester.tap(find.byTooltip(MailComposeTexts.send)); + await tester.pumpAndSettle(); + expect(c.sends, hasLength(1)); + // 다시 묻는 동안 u2 를 뺀다 + await tapTooltip(tester, MailComposeTexts.remove('u2.zip')); + expect(find.text('u2.zip'), findsNothing); + + gate.complete(); + await tester.pumpAndSettle(); + expect(find.text('u1.zip'), findsOneWidget); + expect(find.text('u2.zip'), findsNothing); + expect(snack(ErrorLexicon.getMessage('MAIL_016')), findsOneWidget); + }); + + testWidgets('나가기 확인 창이 떠 있는 동안 세션이 끝나면 창만 닫고 "저장했습니다"를 알리지 않는다', ( + WidgetTester tester, + ) async { + h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok()); + await start(tester); + await open(tester, MailRoutes.compose); + await tester.enterText(bodyField, '저장하지 못한 글'); + c.draftFailures.add(FakeResponse.fail(503, 'MAIL_001')); + await tapTooltip(tester, MailComposeTexts.close); + expect(find.text(MailComposeTexts.leaveTitle), findsOneWidget); + + unawaited(h.container.read(sessionProvider.notifier).signOut()); + await tester.pumpAndSettle(); + + expect(find.text(MailComposeTexts.leaveTitle), findsNothing); + expect(snack(MailComposeTexts.savedOnLeave), findsNothing); + expect(c.saves, hasLength(1)); + }); + + testWidgets('떠나기 전 저장을 기다리는 동안은 [보내기]를 막는다', (WidgetTester tester) async { + await start(tester); + await open(tester, MailRoutes.compose); + await addRecipient(tester, 'doyun@x.kr'); + await tester.enterText(bodyField, '떠나며 저장할 글'); + final Completer gate = Completer(); + c.saveGate = gate; + + await tester.tap(find.byTooltip(MailComposeTexts.close)); + await tester.pump(); + await tester.tap(find.byTooltip(MailComposeTexts.send)); + await tester.pump(); + expect(c.sends, isEmpty); + expect(find.bySemanticsLabel(MailComposeTexts.sending), findsNothing); + + gate.complete(); + await tester.pumpAndSettle(); + expect(find.byType(MessageComposePage), findsNothing); + expect(snack(MailComposeTexts.savedOnLeave), findsOneWidget); + expect(c.sends, isEmpty); + }); + }); + + group('받는 사람', () { + testWidgets('쉼표 · [다음]에 칩 — 붙여 넣은 여러 줄도 나누고, 틀린 주소는 빨간 칩, ✕ 로 뺀다', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + + await addRecipient(tester, 'doyun@x.kr'); + expect(chip('doyun@x.kr'), findsOneWidget); + expect(textOf(tester, field(RecipientFieldKind.to)), isEmpty); + + await tester.enterText( + field(RecipientFieldKind.to), + '"Kim, Ara" \njihoon@x.kr\nbad@', + ); + await tester.pump(); + expect(chip('Kim, Ara'), findsOneWidget); + expect(chip('jihoon@x.kr'), findsOneWidget); + expect(textOf(tester, field(RecipientFieldKind.to)), 'bad@'); + + await tester.testTextInput.receiveAction(TextInputAction.next); + await tester.pump(); + expect(chip('bad@'), findsOneWidget); + expect(tester.widget(chip('bad@')).style?.color, AppColors.danger); + + // 틀린 주소가 있으면 보내지 않고 칸 아래 띠 + await tapTooltip(tester, MailComposeTexts.send); + expect(c.sends, isEmpty); + expect(findCopy(ErrorLexicon.getMessage('MAIL_002')), findsOneWidget); + + await tapTooltip(tester, MailComposeTexts.remove('bad@')); + expect(chip('bad@'), findsNothing); + expect(findCopy(ErrorLexicon.getMessage('MAIL_002')), findsNothing); + }); + + testWidgets('받는 사람이 없으면 보내지 않고 칸 아래 띠 · 받는 사람 칸으로 초점', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + await tester.tap(subjectField); + await tester.pump(); + + await tapTooltip(tester, MailComposeTexts.send); + + expect(c.sends, isEmpty); + expect(findCopy(ErrorLexicon.getMessage('MAIL_002')), findsOneWidget); + expect(focused(tester, field(RecipientFieldKind.to)), isTrue); + }); + + testWidgets( + '추천 — 묶음 이름 줄(그룹 · 사내 · 최근 주고받은 주소), 고르면 칩 · 그룹은 구성원을 넣고 칸 아래에 알린다', + (WidgetTester tester) async { + c.suggestions = >[ + { + 'name': '이도윤', + 'email': 'doyun.lee@arasystem.it.kr', + 'source': 'directory', + }, + { + 'name': '최도윤', + 'email': 'choi@partner.example', + 'source': 'recent', + }, + ]; + c.suggestionsHasNext = true; + c.groups = >[ + {'id': 'g1', 'name': '도윤 팀', 'memberCount': 2}, + ]; + c.members['g1'] = >[ + { + 'name': '박지훈', + 'emails': [ + {'address': 'jihoon@x.kr', 'label': 'work'}, + ], + }, + {'name': '주소 없음', 'emails': []}, + ]; + await start(tester); + await open(tester, MailRoutes.compose); + + await tester.enterText(field(RecipientFieldKind.to), '도윤'); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('그룹'), findsOneWidget); + expect(find.text('사내'), findsOneWidget); + expect(find.text('최근 주고받은 주소'), findsOneWidget); + expect(find.text(PeopleTexts.groupDetail(2)), findsOneWidget); + // 받아 둔 것이 모자라면 250ms 뒤 서버에도 묻는다 + expect(c.suggestionRequests.last.queryParameters['q'], '도윤'); + + await tester.tap(find.text('도윤 팀')); + await tester.pumpAndSettle(); + expect(chip('박지훈'), findsOneWidget); + expect( + findCopy('‘도윤 팀’ 그룹의 1명을 받는 사람에 넣었습니다. 주소가 없는 1명은 뺐습니다.'), + findsOneWidget, + ); + + await tester.enterText(field(RecipientFieldKind.to), '도윤'); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.text('이도윤')); + await tester.pumpAndSettle(); + expect(chip('이도윤'), findsOneWidget); + expect(textOf(tester, field(RecipientFieldKind.to)), isEmpty); + // 넣은 주소는 다시 보이지 않는다 + await tester.enterText(field(RecipientFieldKind.to), '도윤'); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('사내'), findsNothing); + expect(find.text('최근 주고받은 주소'), findsOneWidget); + }, + ); + + testWidgets('[다음]은 첫 추천을 넣는다 — 완전한 주소를 쳤으면 친 그대로', ( + WidgetTester tester, + ) async { + c.suggestions = >[ + { + 'name': '이도윤', + 'email': 'doyun.lee@arasystem.it.kr', + 'source': 'directory', + }, + ]; + await start(tester); + await open(tester, MailRoutes.compose); + + await tester.enterText(field(RecipientFieldKind.to), 'doyun'); + await tester.pump(const Duration(milliseconds: 300)); + await tester.testTextInput.receiveAction(TextInputAction.next); + await tester.pumpAndSettle(); + expect(chip('이도윤'), findsOneWidget); + + await tester.enterText( + field(RecipientFieldKind.to), + 'doyun.lee@other.example', + ); + await tester.pump(const Duration(milliseconds: 300)); + await tester.testTextInput.receiveAction(TextInputAction.next); + await tester.pumpAndSettle(); + expect(chip('doyun.lee@other.example'), findsOneWidget); + }); + }); + + group('보내기', () { + testWidgets( + '보내면 시작한 화면으로 — "메일을 보냈습니다." · [보내기 취소] · 남은 시간 막대, 본문은 서식 + 텍스트', + (WidgetTester tester) async { + c.signature = { + 'bodyHtml': null, + 'body': '김아라\n경영지원팀', + 'isForNew': true, + 'isForReply': true, + }; + await start(tester); + await open(tester, MailRoutes.compose); + + expect(find.text('김아라'), findsOneWidget); + await addRecipient(tester, 'doyun@x.kr'); + await tester.enterText(subjectField, '계약'); + await tester.enterText(bodyField, '안녕하세요'); + await tapTooltip(tester, MailComposeTexts.send); + + expect(find.byType(MessageComposePage), findsNothing); + expect(find.byType(MailListPage), findsOneWidget); + expect(toast(MailComposeTexts.sent), findsOneWidget); + expect(toast(MailComposeTexts.undoSend), findsOneWidget); + expect( + find.descendant( + of: find.byType(MailToastCard), + matching: find.byType(LinearProgressIndicator), + ), + findsOneWidget, + ); + + final SentRequest sent = c.sends.single; + expect(sent.key, matches(uuid)); + expect(sent.body['to'], [ + {'name': null, 'email': 'doyun@x.kr'}, + ]); + expect(sent.body['subject'], '계약'); + expect(sent.body['body'], '안녕하세요\n\n김아라\n경영지원팀'); + expect( + sent.body['bodyHtml'], + contains( + '

안녕하세요


' + '

김아라

경영지원팀

', + ), + ); + expect(sent.body.containsKey('draftId'), isFalse); + // 쓰는 동안 저장할 틈(3초)이 없었다 + expect(c.saves, isEmpty); + + // 마감(서버 시각으로 10초 − 2초)이 지나면 닫힌다 + await tester.pump(const Duration(seconds: 8)); + await tester.pumpAndSettle(); + expect(find.byType(MailToastCard), findsNothing); + }, + ); + + testWidgets('[보내기 취소] — 발송을 취소하고 그 메일로 쓰기 화면을 다시 연다(서명까지 돌아온다)', ( + WidgetTester tester, + ) async { + c.signature = { + 'bodyHtml': null, + 'body': '김아라', + 'isForNew': true, + 'isForReply': true, + }; + await start(tester); + await open(tester, MailRoutes.compose); + await addRecipient(tester, 'doyun@x.kr'); + await tester.enterText(subjectField, '계약'); + await tester.enterText(bodyField, '안녕하세요'); + await tapTooltip(tester, MailComposeTexts.send); + + await tester.tap(toast(MailComposeTexts.undoSend)); + await tester.pumpAndSettle(); + + expect(h.mail.cancellations, ['sent-1']); + expect(find.byType(MessageComposePage), findsOneWidget); + expect(snack(MailComposeTexts.undone), findsOneWidget); + expect(chip('doyun@x.kr'), findsOneWidget); + expect(textOf(tester, subjectField), '계약'); + expect(textOf(tester, bodyField), '안녕하세요'); + expect(find.byTooltip(MailComposeTexts.removeSignature), findsOneWidget); + + // 다시 보내면 새 키(새 양식)로, 이어 쓴 저장본 id 를 싣는다 + await tapTooltip(tester, MailComposeTexts.send); + expect(c.sends, hasLength(2)); + expect(c.sends.last.key, isNot(c.sends.first.key)); + expect(c.sends.last.body['draftId'], 'sent-1'); + }); + + testWidgets('바로 보냈으면(보내기 취소 0초) [보낸편지함 보기], 받지 못한 사람은 그 주소 · 까닭', ( + WidgetTester tester, + ) async { + c.undoSeconds = 0; + c.rejected = >[ + {'email': 'nobody@x.kr', 'reason': 'not-found'}, + ]; + await start(tester); + await open(tester, MailRoutes.compose); + await addRecipient(tester, 'doyun@x.kr'); + await addRecipient(tester, 'nobody@x.kr'); + await tapTooltip(tester, MailComposeTexts.send); + + expect(toast('보냈지만 1명은 받지 못했습니다. nobody@x.kr (없는 주소)'), findsOneWidget); + await tester.tap(toast(MailComposeTexts.viewSent)); + await tester.pumpAndSettle(); + expect(h.location(), MailRoutes.mailbox(kSentId)); + }); + + testWidgets('답장을 보내면 읽기 화면으로 — 알림은 아래 막대 위 · 본문에 원문 인용 · inReplyTo', ( + WidgetTester tester, + ) async { + h.mail.put( + kInboxId, + messageJson('r1', mailboxId: kInboxId, subject: '계약', isRead: true), + detail: {'textBody': '원문 줄'}, + ); + await start(tester); + await open(tester, MailRoutes.message('r1')); + await tester.tap(find.text(MailReaderTexts.reply)); + await tester.pumpAndSettle(); + await tester.enterText(bodyField, '확인했습니다'); + await tapTooltip(tester, MailComposeTexts.send); + + expect(find.byType(MessageReaderPage), findsOneWidget); + final Rect card = tester.getRect(find.byType(MailToastCard)); + final Rect bar = tester.getRect(find.byType(ReaderActionBar)); + expect(card.bottom, lessThanOrEqualTo(bar.top - 12)); + + final Map body = c.sends.single.body; + expect(body['inReplyTo'], 'r1'); + expect(body['subject'], 'Re: 계약'); + expect( + body['body'], + startsWith('확인했습니다\n> 원본 메일\n> 보낸 사람: 이도윤 \n'), + ); + expect(body['body'], endsWith('> 제목: 계약\n>\n> 원문 줄')); + expect( + body['bodyHtml'], + contains( + '

원본 메일

', + ), + ); + }); + + testWidgets( + '서버가 받는 사람을 거절하면(MAIL_002) 알림 없이 칸 아래 띠, 본문이 길면(MAIL_007) 본문 아래 띠', + (WidgetTester tester) async { + c.sendFailures.addAll([ + FakeResponse.fail(400, 'MAIL_002'), + FakeResponse.fail(400, 'MAIL_007'), + ]); + await start(tester); + await open(tester, MailRoutes.compose); + await addRecipient(tester, 'doyun@x.kr'); + await tester.enterText(bodyField, '본문'); + + await tapTooltip(tester, MailComposeTexts.send); + expect(find.byType(MessageComposePage), findsOneWidget); + expect(findCopy(ErrorLexicon.getMessage('MAIL_002')), findsOneWidget); + expect(focused(tester, field(RecipientFieldKind.to)), isTrue); + + await tapTooltip(tester, MailComposeTexts.send); + expect(findCopy(ErrorLexicon.getMessage('MAIL_002')), findsNothing); + expect(findCopy(ErrorLexicon.getMessage('MAIL_007')), findsOneWidget); + expect(focused(tester, bodyField), isTrue); + expect(h.notifier.shownCodes, isEmpty); + // 칸 안 실패는 같은 키로 다시 보낸다 + expect(c.sends[0].key, c.sends[1].key); + }, + ); + + testWidgets('그 밖의 실패는 알림 · 같은 키로 다시 보낸다, MAIL_005 뒤에는 새 키', ( + WidgetTester tester, + ) async { + c.sendFailures.addAll([ + FakeResponse.fail(503, 'MAIL_001'), + FakeResponse.fail(409, 'MAIL_005'), + ]); + await start(tester); + await open(tester, MailRoutes.compose); + await addRecipient(tester, 'doyun@x.kr'); + + await tapTooltip(tester, MailComposeTexts.send); + expect(h.notifier.shownCodes, ['MAIL_001']); + await tapTooltip(tester, MailComposeTexts.send); + await tapTooltip(tester, MailComposeTexts.send); + + expect(c.sends, hasLength(3)); + expect(c.sends[1].key, c.sends[0].key); + expect(c.sends[2].key, isNot(c.sends[1].key)); + expect(find.byType(MessageComposePage), findsNothing); + }); + + testWidgets('보내는 동안 떠나도 응답까지 기다려 알린다 — 떠난 뒤 실패하면 쓰던 내용을 저장본으로', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + await addRecipient(tester, 'doyun@x.kr'); + await tester.enterText(bodyField, '첫 메일'); + final Completer gate = Completer(); + c.sendGate = gate; + await tester.tap(find.byTooltip(MailComposeTexts.send)); + await tester.pump(); + expect(find.bySemanticsLabel(MailComposeTexts.sending), findsOneWidget); + + await tapTooltip(tester, MailComposeTexts.close); + expect(find.byType(MessageComposePage), findsNothing); + expect(find.text(MailComposeTexts.leaveTitle), findsNothing); + gate.complete(); + await tester.pumpAndSettle(); + expect(toast(MailComposeTexts.sent), findsOneWidget); + expect(c.saves, isEmpty); + + // 두 번째 — 떠난 뒤 받는 사람 거절(칸 안 실패) → 알림으로, 쓰던 내용은 저장본으로 + await open(tester, MailRoutes.compose); + await addRecipient(tester, 'jihoon@x.kr'); + await tester.enterText(bodyField, '둘째 메일'); + final Completer second = Completer(); + c.sendGate = second; + c.sendFailures.add(FakeResponse.fail(400, 'MAIL_002')); + await tester.tap(find.byTooltip(MailComposeTexts.send)); + await tester.pump(); + await tapTooltip(tester, MailComposeTexts.close); + second.complete(); + await tester.pumpAndSettle(); + + expect(h.notifier.shownCodes, ['MAIL_002']); + expect(c.saves.single.call, 'POST'); + expect(c.saves.single.body['body'], '둘째 메일'); + expect(c.saves.single.body.containsKey('draftId'), isFalse); + }); + + testWidgets('웹에서 올리다 멈춘 대용량 파일이 있으면 보내지 않고 까닭을 알린다 — 빼면 보낸다', ( + WidgetTester tester, + ) async { + c.putDraft( + 'd5', + to: >[addressJson('박준호', 'junho@example.com')], + subject: '자료', + body: '본문', + largeFiles: >[ + { + 'id': 'u1', + 'status': 'uploading', + 'name': 'big.zip', + 'size': 31457280, + }, + { + 'id': 'u2', + 'status': 'ready', + 'name': 'ok.zip', + 'size': 1048576, + }, + ], + ); + await start(tester); + await open(tester, MailRoutes.composeDraft('d5')); + + expect(find.text('big.zip'), findsOneWidget); + expect(find.text('30 MB · ${MailReaderTexts.largeFile}'), findsOneWidget); + expect(findCopy(MailComposeTexts.largeUploading), findsOneWidget); + + await tapTooltip(tester, MailComposeTexts.send); + expect(snack(MailComposeTexts.largeUploading), findsOneWidget); + expect(c.sends, isEmpty); + + await tapTooltip(tester, MailComposeTexts.remove('big.zip')); + await tapTooltip(tester, MailComposeTexts.send); + final Map body = c.sends.single.body; + expect(body['largeFileIds'], ['u2']); + expect(body['draftId'], isNotNull); + }); + + testWidgets('저장본의 제목이 255자를 넘으면 보내지 않고 제목 아래 띠(VAL_001) · 제목으로 초점', ( + WidgetTester tester, + ) async { + c.putDraft( + 'd6', + to: >[addressJson('박준호', 'junho@example.com')], + subject: 'a' * 300, + ); + await start(tester); + await open(tester, MailRoutes.composeDraft('d6')); + + await tapTooltip(tester, MailComposeTexts.send); + expect(c.sends, isEmpty); + expect(findCopy(ErrorLexicon.getMessage('VAL_001')), findsOneWidget); + expect(focused(tester, subjectField), isTrue); + }); + }); + + group('임시 저장 · 나가기', () { + testWidgets('이어 쓰기 — 칸 · 서명을 되살리고, 바꾸면 3초 뒤 PUT {id}. [서명 빼기] 뒤에는 [서명 넣기]', ( + WidgetTester tester, + ) async { + c.putDraft( + 'd1', + to: >[addressJson('박준호', 'junho@example.com')], + subject: '쓰던 메일', + body: '쓰던 본문\n\n김아라', + bodyHtml: + '

쓰던 본문

' + '


김아라

', + ); + await start(tester); + await open(tester, MailRoutes.composeDraft('d1')); + + expect(find.text(MailComposeTexts.newMail), findsOneWidget); + expect(chip('박준호'), findsOneWidget); + expect(textOf(tester, subjectField), '쓰던 메일'); + expect(textOf(tester, bodyField), '쓰던 본문'); + expect(find.text('김아라'), findsOneWidget); + expect(c.saves, isEmpty); + + await tapTooltip(tester, MailComposeTexts.removeSignature); + expect(find.text('김아라'), findsNothing); + expect(find.text(MailComposeTexts.insertSignature), findsOneWidget); + await tester.pump(const Duration(seconds: 3)); + expect(c.saves.single.call, 'PUT d1'); + expect(c.saves.single.body['body'], '쓰던 본문'); + + await tester.tap(find.text(MailComposeTexts.insertSignature)); + await tester.pumpAndSettle(); + expect(find.text('김아라'), findsOneWidget); + + // 바꾼 것이 남은 채 닫으면 저장하고 닫는다 — 간격(10초)을 기다리지 않는다 + await tapTooltip(tester, MailComposeTexts.close); + expect(c.saves.last.call, 'PUT saved-1'); + expect(find.byType(MessageComposePage), findsNothing); + expect(snack(MailComposeTexts.savedOnLeave), findsOneWidget); + }); + + testWidgets('서식이 있는 남의 저장본 — 안내 줄, 본문을 고치지 않으면 원래 HTML 그대로 저장한다', ( + WidgetTester tester, + ) async { + const String html = '

안녕 굵게

'; + c.putDraft( + 'd2', + to: >[addressJson('박준호', 'junho@example.com')], + subject: '서식', + body: '안녕 굵게', + bodyHtml: html, + ); + await start(tester); + await open(tester, MailRoutes.composeDraft('d2')); + + expect(findCopy(MailComposeTexts.foreignDraft), findsOneWidget); + expect(textOf(tester, bodyField), '안녕 굵게'); + + await tester.enterText(subjectField, '서식 고침'); + await tester.pump(const Duration(seconds: 3)); + expect(c.saves.single.body['bodyHtml'], html); + expect(c.saves.single.body['body'], '안녕 굵게'); + + await tester.enterText(bodyField, '안녕 굵게 더함'); + await tester.pump(const Duration(seconds: 10)); + expect(c.saves.last.body['bodyHtml'], isNot(html)); + expect(c.saves.last.body['body'], '안녕 굵게 더함'); + }); + + testWidgets('바꾼 것이 없으면 뒤로 가기에 바로 닫힌다(묻지 않고 알리지 않는다)', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.byType(MessageComposePage), findsNothing); + expect(find.text(MailComposeTexts.leaveTitle), findsNothing); + expect(find.byType(SnackBar), findsNothing); + expect(c.saves, isEmpty); + }); + + testWidgets('바꾼 채 뒤로 가기 — 쓰다 만 주소까지 칩으로 만들어 저장한 뒤 닫고 알린다', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + await tester.enterText(field(RecipientFieldKind.to), 'doyun@x.kr'); + await tester.pump(); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + expect(find.byType(MessageComposePage), findsNothing); + expect(c.saves.single.call, 'POST'); + expect(c.saves.single.body['to'], [ + {'name': null, 'email': 'doyun@x.kr'}, + ]); + expect(snack(MailComposeTexts.savedOnLeave), findsOneWidget); + }); + + testWidgets('저장하지 못하면 묻는다 — [계속 쓰기]가 주 단추, [나가기]는 알리지 않고 닫는다', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + await tester.enterText(bodyField, '잃을 수 있는 글'); + c.draftFailures.add(FakeResponse.fail(503, 'MAIL_001')); + + await tapTooltip(tester, MailComposeTexts.close); + expect(find.text(MailComposeTexts.leaveTitle), findsOneWidget); + expect( + find.widgetWithText(FilledButton, MailComposeTexts.stay), + findsOneWidget, + ); + expect( + find.widgetWithText(TextButton, MailComposeTexts.leave), + findsOneWidget, + ); + + await tester.tap(find.text(MailComposeTexts.stay)); + await tester.pumpAndSettle(); + expect(find.byType(MessageComposePage), findsOneWidget); + expect(textOf(tester, bodyField), '잃을 수 있는 글'); + + c.draftFailures.add(FakeResponse.fail(503, 'MAIL_001')); + await tapTooltip(tester, MailComposeTexts.close); + await tester.tap(find.text(MailComposeTexts.leave)); + await tester.pumpAndSettle(); + expect(find.byType(MessageComposePage), findsNothing); + expect(snack(MailComposeTexts.savedOnLeave), findsNothing); + expect(h.notifier.shownCodes, isEmpty); + }); + + testWidgets('5초 안에 저장이 끝나지 않아 물었는데 그사이 끝나면 창을 닫고 떠난다', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + await tester.enterText(bodyField, '느린 저장'); + final Completer gate = Completer(); + c.saveGate = gate; + + await tester.tap(find.byTooltip(MailComposeTexts.close)); + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + expect(find.text(MailComposeTexts.leaveTitle), findsOneWidget); + + gate.complete(); + await tester.pumpAndSettle(); + expect(find.text(MailComposeTexts.leaveTitle), findsNothing); + expect(find.byType(MessageComposePage), findsNothing); + expect(snack(MailComposeTexts.savedOnLeave), findsOneWidget); + }); + + testWidgets('메일을 보는 직원이 바뀌면(로그아웃) 묻지도 저장하지도 않는다', ( + WidgetTester tester, + ) async { + h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok()); + await start(tester); + await open(tester, MailRoutes.compose); + await addRecipient(tester, 'doyun@x.kr'); + await tester.enterText(bodyField, '로그아웃 전에 쓰던 글'); + + unawaited(h.container.read(sessionProvider.notifier).signOut()); + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 30)); + + expect(find.byType(MessageComposePage), findsNothing); + expect(find.text(MailComposeTexts.leaveTitle), findsNothing); + expect(c.saves, isEmpty); + expect(snack(MailComposeTexts.savedOnLeave), findsNothing); + }); + + testWidgets('앱이 안 보이게 되면 바꾼 것을 바로 저장한다', (WidgetTester tester) async { + await start(tester); + await open(tester, MailRoutes.compose); + await tester.enterText(bodyField, '홈으로 나가기 전'); + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden); + await tester.pump(const Duration(milliseconds: 100)); + expect(c.saves.single.call, 'POST'); + expect(c.saves.single.body['body'], '홈으로 나가기 전'); + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pumpAndSettle(); + }); + + testWidgets('저장할 때마다 바뀌는 id — 목록의 옛 줄을 눌러도 마지막 저장본이 열린다', ( + WidgetTester tester, + ) async { + c.putDraft( + 'd7', + to: >[addressJson('박준호', 'junho@example.com')], + subject: '처음', + ); + await start(tester); + await open(tester, MailRoutes.composeDraft('d7')); + await tester.enterText(subjectField, '고친 제목'); + await tapTooltip(tester, MailComposeTexts.close); + expect(c.saves.single.call, 'PUT d7'); + expect(c.drafts.containsKey('d7'), isFalse); + + await open(tester, MailRoutes.composeDraft('d7')); + expect(textOf(tester, subjectField), '고친 제목'); + expect(h.server.received('GET', '/drafts/saved-1'), hasLength(1)); + }); + }); + + group('첨부 고르기 · 올리기 (2차)', () { + const int mb = 1024 * 1024; + + /// 고른 파일 — 크기만 정하고 복사본은 작게 둔다(가짜 서버는 본문을 읽지 않는다) + PickedFile picked(String name, {int size = 4}) { + final PickedFile copy = h.picked.copy(name, [1, 2, 3, 4]); + return PickedFile( + name: name, + status: PickedFileStatus.ok, + size: size, + path: copy.path, + ); + } + + Future attach(WidgetTester tester, List files) async { + h.picked.willPick(files); + await tapTooltip(tester, MailComposeTexts.attach); + } + + /// 다음 올리기 하나의 응답을 붙잡는다 + Completer holdUpload() { + final Completer gate = Completer(); + c.uploadGates.add(gate); + return gate; + } + + List uploadedNames() => [ + for (final ({String name, String type, int length}) upload in c.uploads) + upload.name, + ]; + + Finder inAttachments(Finder finder) => find.descendant( + of: find.byType(ComposeAttachmentsSection), + matching: finder, + ); + + testWidgets( + '[파일 첨부] — 고른 파일을 둘씩 올린다: "올리는 중 N%" → 크기, 첨부 머리에 서버 한도 · 보이는 이름 · 확장자의 정식 형식', + (WidgetTester tester) async { + c.quota = quotaJson(maxBytes: 30 * mb, totalMaxBytes: 40 * mb); + await start(tester); + await open(tester, MailRoutes.compose); + final Completer first = holdUpload(); + final Completer second = holdUpload(); + final Completer third = holdUpload(); + await attach(tester, [ + picked('견적서.pdf'), + picked('현장사진.jpg'), + picked('보고서.hwp'), + ]); + + expect(h.picked.picks, hasLength(1)); + expectPick(maxBytes: 30 * mb, maxCount: 20, maxTotalBytes: 40 * mb); + expect(find.text(MailComposeTexts.attachmentsTitle(3)), findsOneWidget); + expect(find.text('파일당 최대 30MB · 합계 40MB'), findsOneWidget); + expect(find.text(MailComposeTexts.uploadProgress(0)), findsNWidgets(3)); + expect( + find.byTooltip(MailComposeTexts.cancelUpload('견적서.pdf')), + findsOneWidget, + ); + expect( + find.bySemanticsLabel(MailComposeTexts.uploading('현장사진.jpg')), + findsOneWidget, + ); + // 둘까지만 서버에 보낸다 — 셋째는 기다린다 + expect(uploadedNames(), ['견적서.pdf', '현장사진.jpg']); + expect( + c.uploads.map((({String name, String type, int length}) u) => u.type), + ['application/pdf', 'image/jpeg'], + ); + + first.complete(); + await tester.pumpAndSettle(); + expect(uploadedNames().last, '보고서.hwp'); + expect(c.uploads.last.type, 'application/x-hwp'); + expect( + find.byTooltip(MailComposeTexts.remove('견적서.pdf')), + findsOneWidget, + ); + expect(find.text(formatFileSize(4)), findsOneWidget); + + second.complete(); + third.complete(); + await tester.pumpAndSettle(); + expect(find.text(MailComposeTexts.uploadProgress(0)), findsNothing); + expect(find.text(formatFileSize(4)), findsNWidgets(3)); + expect(h.notifier.shownCodes, isEmpty); + }, + ); + + testWidgets( + '올리는 동안 [보내기]는 까닭을 알리고 보내지 않는다 — 임시 저장은 다 올린 첨부만 싣고, 다 올리면 보낸다(닫히면 복사본을 지운다)', + (WidgetTester tester) async { + await start(tester); + await open(tester, MailRoutes.compose); + await addRecipient(tester, 'doyun@x.kr'); + final Completer gate = holdUpload(); + await attach(tester, [picked('견적서.pdf')]); + + await tapTooltip(tester, MailComposeTexts.send); + expect(snack(MailComposeTexts.attachmentsUploading), findsOneWidget); + expect(c.sends, isEmpty); + + // 쓰는 사이의 저장 — 올리는 중인 첨부는 싣지 않는다 + await tester.pump(const Duration(seconds: 3)); + await tester.pumpAndSettle(); + expect(c.saves.single.body['attachments'], isEmpty); + + gate.complete(); + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 10)); + await tester.pumpAndSettle(); + expect(c.saves, hasLength(2)); + expect(c.saves.last.body['attachments'], [ + {'id': 'up-1', 'name': '견적서.pdf'}, + ]); + + await tapTooltip(tester, MailComposeTexts.send); + expect(c.sends.single.body['attachments'], [ + {'id': 'saved-2-up-1', 'name': '견적서.pdf'}, + ]); + expect(find.byType(MessageComposePage), findsNothing); + expect(h.picked.copies, isEmpty); + }, + ); + + testWidgets( + '사전 검사 — 형식(HEIC) · 크기 · 읽지 못함 · 개수는 넣지 않고 알림 하나로 까닭을 모은다(그 복사본은 지운다)', + (WidgetTester tester) async { + await start(tester); + await open(tester, MailRoutes.compose); + final PickedFile heic = picked('IMG_0001.HEIC'); + await attach(tester, [ + heic, + const PickedFile( + name: '큰자료.pdf', + status: PickedFileStatus.tooLarge, + size: 900 * mb, + ), + picked('견적서.pdf'), + const PickedFile(name: '드라이브.pdf', status: PickedFileStatus.failed), + const PickedFile(name: 'z.pdf', status: PickedFileStatus.skipped), + ]); + + expect(find.text(MailComposeTexts.attachmentsTitle(1)), findsOneWidget); + expect(find.text('IMG_0001.HEIC'), findsNothing); + expect(find.text('큰자료.pdf'), findsNothing); + expect( + snack( + [ + ErrorLexicon.getMessage('FILE_001'), + '20MB보다 큰 파일은 앱에서 아직 보낼 수 없습니다. 웹메일에서 대용량 첨부로 보내 주세요.', + '파일을 가져오지 못했습니다. 다시 골라 주세요.', + ErrorLexicon.getMessage('MAIL_006'), + ].join('\n'), + ), + findsOneWidget, + ); + expect(h.picked.deleted, [heic.path!]); + expect(uploadedNames(), ['견적서.pdf']); + }, + ); + + testWidgets('합계 한도 — 넘게 만드는 파일만 넣지 않는다(이름 + 은(는)) · 전달하는 원본의 합계도 서버 한도로 잰다', ( + WidgetTester tester, + ) async { + c.quota = quotaJson(totalMaxBytes: mb); + h.mail.put( + kInboxId, + messageJson('f3', mailboxId: kInboxId, subject: '자료', isRead: true), + detail: { + 'attachments': [ + attachmentJson('a1', name: '견적서.pdf', size: 600 * 1024), + attachmentJson('a2', name: '도면.pdf', size: 600 * 1024), + ], + }, + ); + await start(tester); + await open(tester, MailRoutes.composeForward('f3')); + // 둘째 원본은 합계(1MB)를 넘겨 싣지 않는다 + expect(findCopy(MailComposeTexts.unsendable), findsOneWidget); + + await attach(tester, [ + picked('a.pdf', size: 500 * 1024), + picked('b.pdf', size: 300 * 1024), + ]); + expect(find.text('a.pdf'), findsNothing); + expect(find.text('b.pdf'), findsOneWidget); + expect( + snack( + '첨부를 모두 합치면 1MB를 넘어 ‘a.pdf’은(는) 넣지 못했습니다. 큰 파일은 웹메일에서 대용량 첨부로 보내 주세요.', + ), + findsOneWidget, + ); + }); + + testWidgets('첨부 한도가 늦으면 2초까지만 기다리고 대체값으로 연다 — 받으면 한도 글이 바뀐다', ( + WidgetTester tester, + ) async { + c.quota = quotaJson(maxBytes: 5 * mb, totalMaxBytes: mb); + final Completer gate = Completer(); + c.quotaGate = gate; + h.mail.put( + kInboxId, + messageJson('f5', mailboxId: kInboxId, subject: '자료', isRead: true), + detail: { + 'attachments': [ + attachmentJson('a1', name: '견적서.pdf', size: 600 * 1024), + attachmentJson('a2', name: '도면.pdf', size: 600 * 1024), + ], + }, + ); + await start(tester); + unawaited(h.router.push(MailRoutes.composeForward('f5'))); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(find.text(MailComposeTexts.attachmentsTitle(2)), findsNothing); + await tester.pump(const Duration(seconds: 1)); + await tester.pumpAndSettle(); + + // 대체값(합계 25MB)으로 쟀다 — 둘 다 싣는다 + expect(find.text(MailComposeTexts.attachmentsTitle(2)), findsOneWidget); + expect(findCopy(MailComposeTexts.unsendable), findsNothing); + expect(find.text('파일당 최대 20MB · 합계 25MB'), findsOneWidget); + + gate.complete(); + await tester.pumpAndSettle(); + expect(find.text('파일당 최대 5MB · 합계 1MB'), findsOneWidget); + }); + + testWidgets('이미 20개면 고르는 창을 열지 않고 알린다 · 남은 자리만큼만 가져오게 한다', ( + WidgetTester tester, + ) async { + c.putDraft( + 'd1', + attachments: >[ + for (int i = 0; i < 18; i++) + attachmentJson('a$i', name: '자료$i.pdf', size: 10), + ], + ); + await start(tester); + await open(tester, MailRoutes.composeDraft('d1')); + + await attach(tester, [picked('a.pdf'), picked('b.pdf')]); + // 남은 자리 2 · 남은 합계 25MB − 18 × 10바이트 + expectPick(maxBytes: 20 * mb, maxCount: 2, maxTotalBytes: 25 * mb - 180); + expect(find.text(MailComposeTexts.attachmentsTitle(20)), findsOneWidget); + + await tapTooltip(tester, MailComposeTexts.attach); + expect(h.picked.picks, hasLength(1)); + expect(snack(ErrorLexicon.getMessage('MAIL_006')), findsOneWidget); + }); + + testWidgets( + '✕ 는 올리기 취소 — 줄을 빼고 복사본을 지우고 기다리던 파일이 이어 올라간다 · 한도를 받지 못하면 20 · 25MB', + (WidgetTester tester) async { + c.quota = null; + await start(tester); + await open(tester, MailRoutes.compose); + holdUpload(); + holdUpload(); + final Completer third = holdUpload(); + final PickedFile first = picked('a.pdf'); + await attach(tester, [ + first, + picked('b.pdf'), + picked('c.pdf'), + ]); + expect(h.picked.picks, hasLength(1)); + expectPick(maxBytes: 20 * mb, maxCount: 20, maxTotalBytes: 25 * mb); + expect(find.text('파일당 최대 20MB · 합계 25MB'), findsOneWidget); + expect(uploadedNames(), ['a.pdf', 'b.pdf']); + + await tapTooltip(tester, MailComposeTexts.cancelUpload('a.pdf')); + expect(find.text('a.pdf'), findsNothing); + expect(h.picked.deleted, [first.path!]); + expect(uploadedNames(), ['a.pdf', 'b.pdf', 'c.pdf']); + expect(find.text(MailComposeTexts.attachmentsTitle(2)), findsOneWidget); + + third.complete(); + await tester.pumpAndSettle(); + expect( + find.byTooltip(MailComposeTexts.remove('c.pdf')), + findsOneWidget, + ); + expect( + find.byTooltip(MailComposeTexts.cancelUpload('b.pdf')), + findsOneWidget, + ); + }, + ); + + testWidgets( + '올리지 못하면 "올리지 못했습니다." · 사전 문구 · [다시 시도](복사본으로 다시) — 형식 거절은 빼기만, 알림은 띄우지 않는다', + (WidgetTester tester) async { + await start(tester); + await open(tester, MailRoutes.compose); + c.uploadFailures.addAll([ + FakeResponse.fail(503, 'MAIL_001'), + FakeResponse.fail(400, 'FILE_001'), + ]); + await attach(tester, [picked('a.pdf'), picked('b.pdf')]); + + expect(find.text(MailComposeTexts.uploadFailed), findsNWidgets(2)); + expect( + inAttachments(findCopy(ErrorLexicon.getMessage('MAIL_001'))), + findsOneWidget, + ); + expect( + inAttachments(findCopy(ErrorLexicon.getMessage('FILE_001'))), + findsOneWidget, + ); + expect(find.text(MailComposeTexts.retryUpload), findsOneWidget); + expect( + findCopy(MailComposeTexts.retryUploadLabel('a.pdf')), + findsOneWidget, + ); + expect(h.notifier.shownCodes, isEmpty); + + await tester.tap(find.text(MailComposeTexts.retryUpload)); + await tester.pumpAndSettle(); + expect(uploadedNames(), ['a.pdf', 'b.pdf', 'a.pdf']); + expect(find.text(MailComposeTexts.uploadFailed), findsOneWidget); + expect( + find.byTooltip(MailComposeTexts.remove('a.pdf')), + findsOneWidget, + ); + expect(find.text(MailComposeTexts.retryUpload), findsNothing); + }, + ); + + testWidgets( + '보내기가 첨부를 찾지 못하면(MAIL_004) 고른 파일은 복사본으로 다시 올리고, 전달 원본은 "찾지 못함" — 빼면 보낸다', + (WidgetTester tester) async { + h.mail.put( + kInboxId, + messageJson('f4', mailboxId: kInboxId, subject: '자료', isRead: true), + detail: { + 'attachments': [attachmentJson('a1', name: '원본.pdf')], + }, + ); + await start(tester); + await open(tester, MailRoutes.composeForward('f4')); + await attach(tester, [picked('추가.pdf')]); + await addRecipient(tester, 'doyun@x.kr'); + c.sendFailures.add(FakeResponse.fail(400, 'MAIL_004')); + + await tapTooltip(tester, MailComposeTexts.send); + expect(snack(ErrorLexicon.getMessage('MAIL_004')), findsOneWidget); + expect( + inAttachments(findCopy(ErrorLexicon.getMessage('MAIL_004'))), + findsOneWidget, + ); + expect(uploadedNames(), ['추가.pdf', '추가.pdf']); + expect( + find.byTooltip(MailComposeTexts.remove('추가.pdf')), + findsOneWidget, + ); + + await tapTooltip(tester, MailComposeTexts.remove('원본.pdf')); + await tapTooltip(tester, MailComposeTexts.send); + expect(c.sends.last.body['attachments'], [ + {'id': 'up-2', 'name': '추가.pdf'}, + ]); + }, + ); + + testWidgets( + '올리는 중에 나가면 따로 묻는다 — [계속 쓰기]면 그대로, [나가기]면 올리던 파일을 빼고(복사본을 지운다) 닫는다', + (WidgetTester tester) async { + await start(tester); + await open(tester, MailRoutes.compose); + holdUpload(); + final PickedFile file = picked('a.pdf'); + await attach(tester, [file]); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.text(MailComposeTexts.uploadLeaveTitle), findsOneWidget); + expect(findCopy(MailComposeTexts.uploadLeaveMessage), findsOneWidget); + expect( + find.widgetWithText(FilledButton, MailComposeTexts.stay), + findsOneWidget, + ); + await tester.tap(find.text(MailComposeTexts.stay)); + await tester.pumpAndSettle(); + expect(find.byType(MessageComposePage), findsOneWidget); + expect(find.text(MailComposeTexts.uploadProgress(0)), findsOneWidget); + + await tapTooltip(tester, MailComposeTexts.close); + await tester.tap(find.text(MailComposeTexts.leave)); + await tester.pumpAndSettle(); + expect(find.byType(MessageComposePage), findsNothing); + expect(h.picked.deleted, contains(file.path)); + expect(c.saves, isEmpty); + expect(snack(MailComposeTexts.savedOnLeave), findsNothing); + expect(find.text(MailComposeTexts.leaveTitle), findsNothing); + }, + ); + + testWidgets('묻는 사이 다 올리면 창을 닫고 다 올린 첨부를 저장한 뒤 떠난다', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + final Completer gate = holdUpload(); + await attach(tester, [picked('a.pdf')]); + await tapTooltip(tester, MailComposeTexts.close); + expect(find.text(MailComposeTexts.uploadLeaveTitle), findsOneWidget); + + gate.complete(); + await tester.pumpAndSettle(); + expect(find.text(MailComposeTexts.uploadLeaveTitle), findsNothing); + expect(find.byType(MessageComposePage), findsNothing); + expect(c.saves.single.body['attachments'], [ + {'id': 'up-1', 'name': 'a.pdf'}, + ]); + expect(snack(MailComposeTexts.savedOnLeave), findsOneWidget); + }); + + testWidgets('세션이 끝나면 묻지 않고 올리던 것을 그만두고 복사본을 지운다 — 직원이 바뀌면 복사본 폴더도 비운다', ( + WidgetTester tester, + ) async { + h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok()); + await start(tester); + await open(tester, MailRoutes.compose); + holdUpload(); + final PickedFile file = picked('a.pdf'); + await attach(tester, [file]); + final int purges = h.picked.purgeCount; + + unawaited(h.container.read(sessionProvider.notifier).signOut()); + await tester.pumpAndSettle(); + + expect(find.byType(MessageComposePage), findsNothing); + expect(find.text(MailComposeTexts.uploadLeaveTitle), findsNothing); + expect(h.picked.deleted, contains(file.path)); + expect(h.picked.copies, isEmpty); + expect(h.picked.purgeCount, greaterThan(purges)); + }); + + testWidgets('고르는 · 가져오는 동안 [보내기]는 까닭을 알리고 보내지 않는다 — 다 가져오면 그 파일과 함께 보낸다', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + await addRecipient(tester, 'doyun@x.kr'); + final Completer gate = Completer(); + h.picked.pickGate = gate; + h.picked.willPick([picked('견적서.pdf')]); + await tester.tap(find.byTooltip(MailComposeTexts.attach)); + await tester.pump(); + + await tester.tap(find.byTooltip(MailComposeTexts.send)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + expect(snack(MailComposeTexts.attachmentsPicking), findsOneWidget); + expect(c.sends, isEmpty); + + gate.complete(); + await tester.pumpAndSettle(); + expect(uploadedNames(), ['견적서.pdf']); + await tapTooltip(tester, MailComposeTexts.send); + expect(c.sends.single.body['attachments'], [ + {'id': 'up-1', 'name': '견적서.pdf'}, + ]); + }); + + testWidgets('고른 결과가 화면을 떠난 뒤에 오면 붙이지 않고 복사본만 지운다', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + final Completer gate = Completer(); + h.picked.pickGate = gate; + final PickedFile file = picked('견적서.pdf'); + h.picked.willPick([file]); + await tester.tap(find.byTooltip(MailComposeTexts.attach)); + await tester.pump(); + + await tapTooltip(tester, MailComposeTexts.close); + expect(find.byType(MessageComposePage), findsNothing); + + gate.complete(); + await tester.pumpAndSettle(); + expect(h.picked.deleted, [file.path!]); + expect(c.uploads, isEmpty); + }); + + testWidgets('고르는 사이 세션이 끝나면 고른 결과를 붙이지 않고 복사본만 지운다', ( + WidgetTester tester, + ) async { + h.server.on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok()); + await start(tester); + await open(tester, MailRoutes.compose); + final Completer gate = Completer(); + h.picked.pickGate = gate; + final PickedFile file = picked('견적서.pdf'); + h.picked.willPick([file]); + await tester.tap(find.byTooltip(MailComposeTexts.attach)); + await tester.pump(); + + unawaited(h.container.read(sessionProvider.notifier).signOut()); + await tester.pumpAndSettle(); + expect(find.byType(MessageComposePage), findsNothing); + + gate.complete(); + await tester.pumpAndSettle(); + expect(h.picked.deleted, contains(file.path)); + expect(c.uploads, isEmpty); + }); + + testWidgets( + '기기가 미리 거른 파일(형식 · 합계)은 개수 · 합계에 들지 않는다 — 18개에 HEIC · 견적서 · 큰도면 · 도면이면 두 PDF 를 넣는다(웹과 같다)', + (WidgetTester tester) async { + c.quota = quotaJson(totalMaxBytes: mb); + c.putDraft( + 'd1', + attachments: >[ + for (int i = 0; i < 18; i++) + attachmentJson('a$i', name: '자료$i.pdf', size: 10), + ], + ); + await start(tester); + await open(tester, MailRoutes.composeDraft('d1')); + + // 기기(MainActivity.kt 의 copyPicked)가 같은 차례로 거른 결과 — HEIC 는 읽지 않았고, 큰도면은 합계를 넘겨 넣지 않았다 + await attach(tester, [ + const PickedFile( + name: 'IMG_0001.HEIC', + status: PickedFileStatus.notAllowed, + size: 3, + ), + picked('견적서.pdf', size: 600 * 1024), + const PickedFile( + name: '큰도면.pdf', + status: PickedFileStatus.overTotal, + size: 600 * 1024, + ), + picked('도면.pdf', size: 300 * 1024), + ]); + expectPick(maxBytes: 20 * mb, maxCount: 2, maxTotalBytes: mb - 180); + expect( + find.text(MailComposeTexts.attachmentsTitle(20)), + findsOneWidget, + ); + expect(uploadedNames(), ['견적서.pdf', '도면.pdf']); + expect( + snack( + [ + ErrorLexicon.getMessage('FILE_001'), + '첨부를 모두 합치면 1MB를 넘어 ‘큰도면.pdf’은(는) 넣지 못했습니다. 큰 파일은 웹메일에서 대용량 첨부로 보내 주세요.', + ].join('\n'), + ), + findsOneWidget, + ); + }, + ); + + testWidgets( + '다 올리면 서버가 정리한 이름 · 크기로 싣고 보인다 — 255자 넘는 이름도 저장 · 발송이 거절되지 않게', + (WidgetTester tester) async { + await start(tester); + await open(tester, MailRoutes.compose); + await addRecipient(tester, 'doyun@x.kr'); + final String longName = '${'가' * 300}.pdf'; + final String serverName = serverFileName(longName); + c.uploadedSize = 6; + await attach(tester, [picked(longName)]); + + // 올릴 때는 고른 이름 그대로 보내고(서버가 줄인다), 다 올리면 서버의 이름으로 + expect(c.uploads.single.name, longName); + expect(serverName.runes.length, 255); + expect(serverName, endsWith('.pdf')); + expect(find.text(serverName), findsOneWidget); + expect(find.text(longName), findsNothing); + expect(find.text(formatFileSize(6)), findsOneWidget); + + await tapTooltip(tester, MailComposeTexts.send); + expect(c.sends.single.body['attachments'], [ + {'id': 'up-1', 'name': serverName}, + ]); + }, + ); + + testWidgets('다시 올린 뒤 또 MAIL_004 면 "찾지 못함" — 빼기 전까지 보내기 · 저장을 막는다', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + await attach(tester, [picked('추가.pdf')]); + await addRecipient(tester, 'doyun@x.kr'); + c.sendFailures.addAll([ + FakeResponse.fail(400, 'MAIL_004'), + FakeResponse.fail(400, 'MAIL_004'), + ]); + + await tapTooltip(tester, MailComposeTexts.send); + expect(uploadedNames(), ['추가.pdf', '추가.pdf']); + await tapTooltip(tester, MailComposeTexts.send); + expect(c.sends, hasLength(2)); + // 이미 한 번 다시 올렸다 — 또 올리지 않고 빼기 전까지 막는다 + expect(uploadedNames(), hasLength(2)); + expect( + inAttachments(findCopy(ErrorLexicon.getMessage('MAIL_004'))), + findsOneWidget, + ); + expect(find.text(MailComposeTexts.retryUpload), findsNothing); + + await tapTooltip(tester, MailComposeTexts.send); + expect(c.sends, hasLength(2)); + expect(snack(ErrorLexicon.getMessage('MAIL_004')), findsOneWidget); + await tester.pump(const Duration(seconds: 30)); + await tester.pumpAndSettle(); + expect(c.saves, isEmpty); + + await tapTooltip(tester, MailComposeTexts.remove('추가.pdf')); + await tapTooltip(tester, MailComposeTexts.send); + expect(c.sends, hasLength(3)); + expect(c.sends.last.body['attachments'], isEmpty); + }); + + testWidgets('MAIL_004 뒤 복사본이 없어져 다시 올리지 못하면 빼기 전까지 보내지 않는다', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + await attach(tester, [picked('추가.pdf')]); + await addRecipient(tester, 'doyun@x.kr'); + // 기기가 임시 폴더를 비웠다 + h.picked.copies.clear(); + c.sendFailures.add(FakeResponse.fail(400, 'MAIL_004')); + + await tapTooltip(tester, MailComposeTexts.send); + expect(uploadedNames(), hasLength(1)); + expect( + inAttachments(findCopy(MailComposeTexts.uploadCopyGone)), + findsOneWidget, + ); + expect(find.text(MailComposeTexts.retryUpload), findsNothing); + + await tapTooltip(tester, MailComposeTexts.send); + expect(c.sends, hasLength(1)); + expect(snack(ErrorLexicon.getMessage('MAIL_004')), findsOneWidget); + }); + + testWidgets('MAIL_004 뒤 다시 올리기가 실패하면 [다시 시도]로 다 올릴 때까지 보내지 않는다', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + await attach(tester, [picked('추가.pdf')]); + await addRecipient(tester, 'doyun@x.kr'); + c.sendFailures.add(FakeResponse.fail(400, 'MAIL_004')); + c.uploadFailures.add(FakeResponse.fail(503, 'MAIL_001')); + + await tapTooltip(tester, MailComposeTexts.send); + expect(uploadedNames(), hasLength(2)); + expect(find.text(MailComposeTexts.uploadFailed), findsOneWidget); + + await tapTooltip(tester, MailComposeTexts.send); + expect(c.sends, hasLength(1)); + expect(snack(ErrorLexicon.getMessage('MAIL_004')), findsOneWidget); + + await tester.tap(find.text(MailComposeTexts.retryUpload)); + await tester.pumpAndSettle(); + expect(uploadedNames(), hasLength(3)); + await tapTooltip(tester, MailComposeTexts.send); + expect(c.sends, hasLength(2)); + expect(c.sends.last.body['attachments'], [ + {'id': 'up-2', 'name': '추가.pdf'}, + ]); + }); + + testWidgets('떠난 뒤 기다린 저장이 고른 파일을 찾지 못하면(MAIL_004) 그 파일 없이 보내지 않고 알린다', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + await attach(tester, [picked('추가.pdf')]); + await addRecipient(tester, 'doyun@x.kr'); + final Completer gate = Completer(); + c.saveGate = gate; + await tester.pump(const Duration(seconds: 3)); + expect(c.saves, hasLength(1)); + await tester.tap(find.byTooltip(MailComposeTexts.send)); + await tester.pump(); + // 보내는 중 — 묻지 않고 떠난다(올리기 차례 · 복사본이 닫혀 다시 올릴 수 없다) + await tapTooltip(tester, MailComposeTexts.close); + expect(find.byType(MessageComposePage), findsNothing); + + c.draftFailures.add(FakeResponse.fail(400, 'MAIL_004')); + gate.complete(); + await tester.pumpAndSettle(); + + expect(c.sends, isEmpty); + expect(h.notifier.shownCodes, ['MAIL_004']); + expect(c.saves, hasLength(1)); + expect(uploadedNames(), hasLength(1)); + }); + + testWidgets('묻는 사이 마지막 올리기가 실패하면 창을 닫지 않고 답을 기다린다', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + final Completer gate = holdUpload(); + await attach(tester, [picked('a.pdf')]); + await tapTooltip(tester, MailComposeTexts.close); + expect(find.text(MailComposeTexts.uploadLeaveTitle), findsOneWidget); + + c.uploadFailures.add(FakeResponse.fail(503, 'MAIL_001')); + gate.complete(); + await tester.pumpAndSettle(); + expect(find.text(MailComposeTexts.uploadLeaveTitle), findsOneWidget); + expect(find.byType(MessageComposePage), findsOneWidget); + + await tester.tap(find.text(MailComposeTexts.stay)); + await tester.pumpAndSettle(); + expect(find.text(MailComposeTexts.uploadFailed), findsOneWidget); + expect(find.text(MailComposeTexts.uploadLeaveTitle), findsNothing); + }); + + testWidgets('답장 · 첨부 없는 저장본은 첨부 한도를 기다리지 않고 연다', ( + WidgetTester tester, + ) async { + h.mail.put( + kInboxId, + messageJson('r1', mailboxId: kInboxId, subject: '안부', isRead: true), + ); + c.putDraft('d2', subject: '쓰던 메일'); + await start(tester); + + c.quotaGate = Completer(); + unawaited(h.router.push(MailRoutes.composeReply('r1'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + expect(textOf(tester, subjectField), 'Re: 안부'); + await tapTooltip(tester, MailComposeTexts.close); + expect(find.byType(MessageComposePage), findsNothing); + + c.quotaGate = Completer(); + unawaited(h.router.push(MailRoutes.composeDraft('d2'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + expect(textOf(tester, subjectField), '쓰던 메일'); + }); + + testWidgets( + '기기가 답하지 않는 고르기는 시한(10분) 뒤 풀린다 — 알리고 [보내기] · [파일 첨부]가 다시 되고 다음 고르기가 묶이지 않는다', + (WidgetTester tester) async { + // 기기 채널 쪽 차례 · 시한까지 보려고 실제 보관소를 쓴다 — 첫 고르기는 답이 오지 않는다(고른 앱이 멈춤) + final List channelCalls = []; + final Completer stuck = Completer(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(PlatformFileOpener.channel, ( + MethodCall call, + ) { + channelCalls.add(call); + return channelCalls.length == 1 + ? stuck.future + : Future.value({ + 'outcome': 'canceled', + }); + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding + .instance + .defaultBinaryMessenger + .setMockMethodCallHandler(PlatformFileOpener.channel, null), + ); + h.pickedStore = PlatformPickedFileStore( + baseDirectory: () async => Directory.systemTemp, + ); + final Finder pickingBar = find.byWidgetPredicate( + (Widget widget) => + widget is LinearProgressIndicator && + widget.semanticsLabel == MailComposeTexts.picking, + ); + final Finder attachButton = find.widgetWithIcon( + IconButton, + Icons.attach_file, + ); + await start(tester); + await open(tester, MailRoutes.compose); + await addRecipient(tester, 'doyun@x.kr'); + + await tester.tap(find.byTooltip(MailComposeTexts.attach)); + await tester.pump(); + expect(pickingBar, findsOneWidget); + expect(tester.widget(attachButton).onPressed, isNull); + + await tester.pump(kPickTimeout); + await tester.pumpAndSettle(); + expect(pickingBar, findsNothing); + expect(snack(MailComposeTexts.pickReadFailed), findsOneWidget); + expect(tester.widget(attachButton).onPressed, isNotNull); + + // 다음 고르기는 시한으로 끝난 고르기 뒤에 묶이지 않는다 + await tapTooltip(tester, MailComposeTexts.attach); + expect(channelCalls, hasLength(2)); + expect(pickingBar, findsNothing); + + // [보내기]도 다시 된다 + await tapTooltip(tester, MailComposeTexts.send); + expect(c.sends, hasLength(1)); + }, + ); + + testWidgets('고르는 동안 위에 진행 막대 · [파일 첨부]는 막힌다 — 고르지 못하면 알린다', ( + WidgetTester tester, + ) async { + await start(tester); + await open(tester, MailRoutes.compose); + final Completer gate = Completer(); + h.picked.pickGate = gate; + final Finder pickingBar = find.byWidgetPredicate( + (Widget widget) => + widget is LinearProgressIndicator && + widget.semanticsLabel == MailComposeTexts.picking, + ); + final Finder attachButton = find.widgetWithIcon( + IconButton, + Icons.attach_file, + ); + + await tester.tap(find.byTooltip(MailComposeTexts.attach)); + await tester.pump(); + expect(pickingBar, findsOneWidget); + expect(tester.widget(attachButton).onPressed, isNull); + + gate.complete(); + await tester.pumpAndSettle(); + expect(pickingBar, findsNothing); + expect(tester.widget(attachButton).onPressed, isNotNull); + expect(find.byType(SnackBar), findsNothing); + + h.picked.results.add(const PickResult(PickOutcome.failed)); + await tapTooltip(tester, MailComposeTexts.attach); + expect(snack(MailComposeTexts.pickFailed), findsOneWidget); + }); + }); +} diff --git a/flutter/test/features/mail/message_reader_page_test.dart b/flutter/test/features/mail/message_reader_page_test.dart new file mode 100644 index 0000000..9931a38 --- /dev/null +++ b/flutter/test/features/mail/message_reader_page_test.dart @@ -0,0 +1,1164 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:arasystem_app/core/api/api_paths.dart'; +import 'package:arasystem_app/core/files/downloaded_files.dart'; +import 'package:arasystem_app/core/theme/app_typography.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/session_provider.dart'; +import 'package:arasystem_app/features/mail/data/models/message_summary.dart'; +import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart'; +import 'package:arasystem_app/features/mail/domain/compose_request.dart'; +import 'package:arasystem_app/features/mail/domain/mail_body_notices.dart'; +import 'package:arasystem_app/features/mail/domain/mail_html.dart'; +import 'package:arasystem_app/features/mail/domain/search_highlight.dart'; +import 'package:arasystem_app/features/mail/mail_routes.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; +import 'package:arasystem_app/features/mail/presentation/message_reader_view.dart'; +import 'package:arasystem_app/features/mail/presentation/pages/message_compose_page.dart'; +import 'package:arasystem_app/features/mail/presentation/pages/message_reader_page.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mailboxes_provider.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/message_reader_controller.dart'; +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:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart'; + +import '../../support/fakes.dart'; +import '../../support/login_fakes.dart'; +import '../../support/mail_fakes.dart'; +import '../../support/mail_harness.dart'; + +/// 메일 읽기 화면 (앱 보드 `App1Reader`) — 로그인한 앱 전체를 가짜 서버 · 가짜 WebView 로 띄워 본다 +void main() { + late MailAppHarness h; + + setUp(() => h = MailAppHarness()); + + const String me = kMailTestAccount; + + /// 메일 하나를 [mailboxId] 에 둔다 — 읽기 칸은 [detail] 로 덮는다 + void put( + String id, { + String mailboxId = kInboxId, + String? subject, + bool isRead = false, + bool isFlagged = false, + Map? pendingSend, + List> to = const >[], + Map detail = const {}, + }) { + h.mail.put( + mailboxId, + messageJson( + id, + mailboxId: mailboxId, + subject: subject, + isRead: isRead, + isFlagged: isFlagged, + pendingSend: pendingSend, + to: to, + ), + detail: detail, + ); + } + + /// 목록([location] — 없으면 받은편지함)을 띄우고 그 위에 [id] 의 읽기 화면을 연다(목록의 줄을 누른 것과 같다) + Future open(WidgetTester tester, String id, {String? location}) async { + await h.pump(tester, location: location); + unawaited(h.router.push(MailRoutes.message(id))); + await tester.pumpAndSettle(); + } + + Finder snack(String text) => + find.descendant(of: find.byType(SnackBar), matching: find.text(text)); + + Finder toast(String text) => find.descendant( + of: find.byType(MailToastCard), + matching: find.text(text), + ); + + Future tapTooltip(WidgetTester tester, String tooltip) async { + await tester.tap(find.byTooltip(tooltip)); + await tester.pumpAndSettle(); + } + + Future openMenu(WidgetTester tester, String item) async { + await tapTooltip(tester, MailReaderTexts.more); + await tester.tap(find.text(item).last); + await tester.pumpAndSettle(); + } + + String? lastDocument() => h.webView.last.lastHtml; + + String pushedLocation(WidgetTester tester) => GoRouterState.of( + tester.element(find.byType(MessageComposePage).last), + ).uri.toString(); + + group('머리 · 본문', () { + testWidgets('제목 · 보낸 사람 · 시각 · "받는 사람 나 외 1명" — 누르면 펼친 칸', ( + WidgetTester tester, + ) async { + put( + 'm1', + subject: '11월 정기 점검 일정 안내', + isRead: true, + to: >[addressJson('김아라', me)], + detail: { + 'cc': [addressJson('박준호', 'junho@arasystem.it.kr')], + }, + ); + await open(tester, 'm1'); + + expect(find.byType(MessageReaderPage), findsOneWidget); + expect(find.byType(NavigationBar), findsNothing); + expect(find.text('11월 정기 점검 일정 안내'), findsOneWidget); + expect(find.text('이도윤'), findsOneWidget); + expect(find.text(formatReaderTime(kTestNow, kTestNow)), findsOneWidget); + expect(find.text('받는 사람 나 외 1명'), findsOneWidget); + expect(find.text(MailReaderTexts.ccLabel), findsNothing); + + await tester.tap(find.text('받는 사람 나 외 1명')); + await tester.pumpAndSettle(); + + expect(find.text(MailReaderTexts.fromLabel), findsOneWidget); + expect(find.text('doyun.lee@example.com'), findsOneWidget); + expect(find.text(MailReaderTexts.ccLabel), findsOneWidget); + expect(find.text('junho@arasystem.it.kr'), findsOneWidget); + expect(find.text(me), findsOneWidget); + expect(find.text(formatReaderFullDate(kTestNow)), findsOneWidget); + + await tester.tap(find.text('받는 사람 나 외 1명')); + await tester.pumpAndSettle(); + expect(find.text(MailReaderTexts.ccLabel), findsNothing); + }); + + testWidgets('본문은 스크립트를 끈 WebView 에 기준 주소 없이 CSP 문서로 — 두 번째 정화를 거친다', ( + WidgetTester tester, + ) async { + put( + 'm1', + isRead: true, + detail: { + 'htmlBody': + '

안녕하세요

' + '링크', + 'textBody': '안녕하세요', + }, + ); + await open(tester, 'm1'); + + expect(h.webView.last.javaScriptMode, JavaScriptMode.disabled); + expect(h.webView.last.baseUrls, [null]); + final String document = lastDocument()!; + expect(document, contains(kMailDocumentCsp)); + expect(document, contains('

안녕하세요

')); + expect(document, isNot(contains(' building = + Completer(); + h.overrides.add( + mailBodyBuilderProvider.overrideWithValue( + ({ + required String? htmlBody, + required String? textBody, + required bool imagesShown, + required List textHighlights, + }) => building.future, + ), + ); + await h.pump(tester); + unawaited(h.router.push(MailRoutes.message('m1'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect( + find.bySemanticsLabel(MailReaderTexts.bodyLoading), + findsOneWidget, + ); + expect(find.byType(MailBodyView), findsNothing); + + building.complete( + mailBodyFragment(htmlBody: null, textBody: '본문', imagesShown: false), + ); + await tester.pumpAndSettle(); + expect(find.bySemanticsLabel(MailReaderTexts.bodyLoading), findsNothing); + expect(find.byType(MailBodyView), findsOneWidget); + }); + + testWidgets('본문 안내 — 생략(서식 없이) · 잘림', (WidgetTester tester) async { + put( + 'm1', + isRead: true, + detail: { + 'htmlBody': null, + 'textBody': '글자 본문', + 'isHtmlBodyOmitted': true, + 'isBodyTruncated': true, + }, + ); + await open(tester, 'm1'); + + expect( + findCopy(MailReaderTexts.bodyNotice(MailBodyNotice.htmlOmitted)), + findsOneWidget, + ); + expect( + findCopy(MailReaderTexts.bodyNotice(MailBodyNotice.truncated)), + findsOneWidget, + ); + expect(lastDocument(), contains('
글자 본문
')); + }); + + testWidgets('받지 못함 — 없는 메일은 [목록으로], 장애는 [다시 시도]', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 1); + h.mail.detailFailure = FakeResponse.fail(503, 'MAIL_001'); + await open(tester, 'm1'); + + expect(find.text(MailListTexts.retry), findsOneWidget); + // 처음 받기의 실패는 화면이 그린다 — 알리지 않는다 + expect(h.notifier.shownCodes, isEmpty); + h.mail.detailFailure = null; + await tester.tap(find.text(MailListTexts.retry)); + await tester.pumpAndSettle(); + expect(find.text('제목 m1'), findsWidgets); + + h.router.pop(); + await tester.pumpAndSettle(); + unawaited(h.router.push(MailRoutes.message('gone'))); + await tester.pumpAndSettle(); + expect(find.text(MailReaderTexts.back), findsOneWidget); + await tester.tap(find.text(MailReaderTexts.back)); + await tester.pumpAndSettle(); + expect(find.byType(MessageReaderPage), findsNothing); + }); + }); + + group('읽음 처리', () { + testWidgets('열면 안 읽은 메일을 읽음으로 — 메일함 수를 먼저 줄이고 목록 줄은 바로 읽음', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 2); + h.mail.singlePatchGate = Completer(); + await h.pump(tester); + await tester.tap(find.text('제목 m1')); + await tester.pumpAndSettle(); + + expect(h.mail.singlePatches.single.body, { + 'isRead': true, + }); + expect( + h.container.read(mailboxesProvider).byId(kInboxId)!.unreadCount, + 2, + ); + + // 목록의 맞추기를 막아도 읽기 화면이 알린 응답으로 줄이 바로 바뀐다 + h.mail.listGate = Completer(); + h.mail.singlePatchGate!.complete(); + await tester.pumpAndSettle(); + h.router.pop(); + await tester.pumpAndSettle(); + + final Text row = tester.widget( + find.descendant( + of: find.ancestor( + of: find.text('제목 m1'), + matching: find.byType(MessageRow), + ), + matching: find.text('이도윤'), + ), + ); + expect(row.style?.fontWeight, AppTypography.medium); + h.mail.listGate?.complete(); + await tester.pumpAndSettle(); + }); + + testWidgets('읽음 처리가 실패하면 알리지 않고 되돌린다', (WidgetTester tester) async { + h.mail.seed(kInboxId, 1); + h.mail.singlePatchFailure = FakeResponse.fail(503, 'MAIL_001'); + await open(tester, 'm1'); + + expect(h.mail.singlePatches, hasLength(1)); + expect(h.notifier.shownCodes, isEmpty); + expect( + h.container.read(mailboxesProvider).byId(kInboxId)!.unreadCount, + 3, + ); + await tapTooltip(tester, MailReaderTexts.more); + expect(find.text(MailReaderTexts.markRead), findsOneWidget); + }); + + testWidgets('읽음 응답이 중요 응답보다 늦게 와도 중요 표시가 남는다 — 응답은 그 요청이 바꾼 칸만 덮는다', ( + WidgetTester tester, + ) async { + put('m1', subject: '계약서'); + final Completer readGate = Completer(); + // 서버는 읽음을 먼저 처리했지만(그때는 중요 아님) 그 응답이 중요 응답보다 늦게 온다 + h.server.on('PATCH', MailApiPaths.message('m1'), ( + RequestOptions options, + ) async { + final Map body = options.data! as Map; + final Map stored = h.mail.messageOf('m1')!; + h.mail.singlePatches.add((id: 'm1', body: body)); + stored.addAll(body); + final Map snapshot = Map.of(stored); + if (body.containsKey('isRead')) await readGate.future; + return FakeResponse.ok(snapshot); + }); + await open(tester, 'm1'); + + await openMenu(tester, MailReaderTexts.flag); + expect(snack(MailReaderTexts.flagged), findsOneWidget); + readGate.complete(); + await tester.pumpAndSettle(); + + await tapTooltip(tester, MailReaderTexts.more); + expect(find.text(MailReaderTexts.unflag), findsOneWidget); + expect(find.text(MailReaderTexts.markUnread), findsOneWidget); + }); + + testWidgets('[이미지 보기] 뒤 늦게 온 읽음 응답이 본문 칸(외부 이미지 안내)을 되돌리지 않는다', ( + WidgetTester tester, + ) async { + put( + 'm1', + detail: { + 'htmlBody': '

뉴스레터

', + 'hasBlockedImages': true, + }, + ); + h.mail.shownImageDetails['m1'] = { + 'htmlBody': '

뉴스레터

', + 'hasBlockedImages': false, + 'imagesShown': true, + }; + h.mail.mailImages['t1'] = FakeResponse.bytes( + kTinyPng, + contentType: 'image/png', + ); + // 열 때의 읽음 처리는 실패한다 — 메뉴에 [읽음 표시]가 남는다 + h.mail.singlePatchFailure = FakeResponse.fail(503, 'MAIL_001'); + await open(tester, 'm1'); + h.mail.singlePatchFailure = null; + + // [이미지 보기]를 붙잡아 둔 채 [읽음 표시]를 보낸다 — 요청 전의 메일 정보는 그림을 막은 본문이다 + final Completer imagesGate = Completer(); + final Completer readGate = Completer(); + h.mail.detailGate = imagesGate; + await tester.tap(find.text(MailReaderTexts.showImages)); + await tester.pump(); + h.mail.singlePatchGate = readGate; + await tester.tap(find.byTooltip(MailReaderTexts.more)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + await tester.tap(find.text(MailReaderTexts.markRead).last); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + // 열 때의 읽음 처리(실패)와 [읽음 표시] + expect(h.mail.singlePatches, hasLength(2)); + + imagesGate.complete(); + await tester.pumpAndSettle(); + expect(findCopy(MailReaderTexts.imagesBlocked), findsNothing); + expect(lastDocument(), contains('data:image/png;base64,')); + + readGate.complete(); + await tester.pumpAndSettle(); + expect(findCopy(MailReaderTexts.imagesBlocked), findsNothing); + await tapTooltip(tester, MailReaderTexts.more); + expect(find.text(MailReaderTexts.markUnread), findsOneWidget); + }); + + testWidgets('[안 읽음 표시]는 바꾼 뒤 목록으로, [중요 표시]는 머물며 알린다', ( + WidgetTester tester, + ) async { + put('m1', subject: '계약서', isRead: true); + await open(tester, 'm1'); + + await openMenu(tester, MailReaderTexts.flag); + expect(h.mail.singlePatches.last.body, { + 'isFlagged': true, + }); + expect(snack(MailReaderTexts.flagged), findsOneWidget); + await tapTooltip(tester, MailReaderTexts.more); + expect(find.text(MailReaderTexts.unflag), findsOneWidget); + await tester.tap(find.text(MailReaderTexts.markUnread)); + await tester.pumpAndSettle(); + + expect(h.mail.singlePatches.last.body, { + 'isRead': false, + }); + expect(find.byType(MessageReaderPage), findsNothing); + expect(h.mail.messageOf('m1')!['isRead'], isFalse); + }); + }); + + group('외부 이미지', () { + Map shown(List tokens) => { + 'htmlBody': + '

뉴스레터

${tokens.map((String t) => '').join()}', + 'hasBlockedImages': false, + 'imagesShown': true, + }; + + testWidgets( + '[이미지 보기] — images=show 로 다시 받아 중계 그림을 앱이 받고 data: 로 넣는다(그림이 아니거나 받지 못하면 비운다)', + (WidgetTester tester) async { + put( + 'm1', + isRead: true, + detail: { + 'htmlBody': '

뉴스레터

', + 'hasBlockedImages': true, + }, + ); + h.mail.shownImageDetails['m1'] = shown(['t1', 't2', 't3']); + h.mail.mailImages['t1'] = FakeResponse.bytes( + kTinyPng, + contentType: 'image/png', + ); + h.mail.mailImages['t2'] = FakeResponse.bytes( + ''.codeUnits, + contentType: 'image/png', + ); + await open(tester, 'm1'); + + expect(findCopy(MailReaderTexts.imagesBlocked), findsOneWidget); + expect(h.mail.imageRequests, isEmpty); + await tester.tap(find.text(MailReaderTexts.showImages)); + await tester.pumpAndSettle(); + + expect(h.mail.detailRequests.last.images, kMailImagesShow); + expect( + h.mail.imageRequests.map((RequestOptions r) => r.path).toSet(), + {'/mail-images/t1', '/mail-images/t2', '/mail-images/t3'}, + ); + // 그림 요청에도 Bearer — WebView 는 바깥에 나가지 않는다 + expect(bearerTokenOf(h.mail.imageRequests.first), isNotNull); + final String document = lastDocument()!; + expect('data:image/png;base64,'.allMatches(document), hasLength(1)); + expect(document, isNot(contains('/api/mail-images/'))); + expect(findCopy(MailReaderTexts.imagesBlocked), findsNothing); + // 받지 못한 그림은 알리지 않는다 + expect(h.notifier.shownCodes, isEmpty); + }, + ); + + testWidgets('화면을 떠나면 받던 중계 그림 요청을 끊는다 — 알리지 않는다', ( + WidgetTester tester, + ) async { + put('m1', isRead: true, detail: shown(['t1'])); + final StreamController image = StreamController(); + bool cancelled = false; + image.onCancel = () => cancelled = true; + addTearDown(() { + if (!image.isClosed) unawaited(image.close()); + }); + h.mail.mailImages['t1'] = FakeResponse.stream( + image.stream, + contentType: 'image/png', + ); + await h.pump(tester); + unawaited(h.router.push(MailRoutes.message('m1'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + expect(h.mail.imageRequests, hasLength(1)); + image.add(Uint8List.fromList(kTinyPng.sublist(0, 4))); + await tester.pump(); + + h.router.pop(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pumpAndSettle(); + + expect(find.byType(MessageReaderPage), findsNothing); + expect(cancelled, isTrue); + expect(h.notifier.shownCodes, isEmpty); + }); + + testWidgets('설정으로 처음부터 보이는 본문이면 안내 줄 없이 바로 받아 넣는다', ( + WidgetTester tester, + ) async { + put('m1', isRead: true, detail: shown(['t1'])); + h.mail.mailImages['t1'] = FakeResponse.bytes( + kTinyPng, + contentType: 'image/png', + ); + await open(tester, 'm1'); + + expect(findCopy(MailReaderTexts.imagesBlocked), findsNothing); + expect(h.mail.detailRequests.single.images, isNull); + expect(lastDocument(), contains('data:image/png;base64,')); + }); + }); + + group('붙잡힌 메일', () { + testWidgets('예약 — 안내 줄의 [예약 취소]는 임시보관함으로 옮기고 목록으로(답장 막대 · 이동 · 스팸 없음)', ( + WidgetTester tester, + ) async { + final DateTime sendAt = kTestNow.add(const Duration(days: 1)); + put( + 's1', + mailboxId: kSentId, + subject: '예약한 메일', + isRead: true, + pendingSend: pendingJson(sendAt, kind: 'scheduled'), + ); + await open(tester, 's1', location: MailRoutes.mailbox(kSentId)); + + expect( + findCopy( + pendingNoticeOf( + PendingSend(sendAt: sendAt, kind: PendingSendKind.scheduled), + ), + ), + findsOneWidget, + ); + expect(find.text(MailReaderTexts.reply), findsNothing); + await tapTooltip(tester, MailReaderTexts.more); + expect(find.text(MailReaderTexts.move), findsNothing); + expect(find.text(MailReaderTexts.reportSpam), findsNothing); + await tester.tapAt(Offset.zero); + await tester.pumpAndSettle(); + + await tester.tap(find.text(MailReaderTexts.cancelSchedule)); + await tester.pumpAndSettle(); + + expect(h.mail.cancellations, ['s1']); + expect(h.mail.mailboxOf('s1'), kDraftsId); + expect(find.byType(MessageReaderPage), findsNothing); + expect(snack(MailReaderTexts.scheduleCanceled), findsOneWidget); + expect(find.text('예약한 메일'), findsNothing); + }); + + testWidgets('보내기 취소를 기다리는 메일 — "곧 보낼 메일입니다." + [보내기 취소]', ( + WidgetTester tester, + ) async { + put( + 's1', + mailboxId: kSentId, + isRead: true, + pendingSend: pendingJson(kTestNow.add(const Duration(seconds: 10))), + ); + await open(tester, 's1', location: MailRoutes.mailbox(kSentId)); + + expect(findCopy(MailReaderTexts.sendingNotice), findsOneWidget); + expect(find.text(MailReaderTexts.reply), findsOneWidget); + await tester.tap(find.text(MailReaderTexts.undoSend)); + await tester.pumpAndSettle(); + + expect(h.mail.cancellations, ['s1']); + expect(snack(MailReaderTexts.undoSendDone), findsOneWidget); + }); + + testWidgets('[삭제] — 확인(처음 초점 [취소]) 뒤 발송을 취소하고 휴지통으로', ( + WidgetTester tester, + ) async { + put( + 's1', + mailboxId: kSentId, + isRead: true, + pendingSend: pendingJson( + kTestNow.add(const Duration(days: 1)), + kind: 'scheduled', + ), + ); + await open(tester, 's1', location: MailRoutes.mailbox(kSentId)); + + await tapTooltip(tester, MailReaderTexts.delete); + expect(find.text(MailReaderTexts.heldDeleteTitle), findsOneWidget); + final TextButton cancel = tester.widget( + find.widgetWithText(TextButton, MailReaderTexts.heldDeleteCancel), + ); + expect(cancel.autofocus, isTrue); + await tester.tap(find.text(MailReaderTexts.heldDeleteConfirm).last); + await tester.pumpAndSettle(); + + expect(h.mail.cancellations, ['s1']); + expect(h.mail.singlePatches.last.body, { + 'mailboxId': kTrashId, + }); + expect(h.mail.mailboxOf('s1'), kTrashId); + expect(find.byType(MessageReaderPage), findsNothing); + expect(snack(MailReaderTexts.heldDeleted), findsOneWidget); + }); + + testWidgets('이미 보냈으면(MAIL_014) 알리고 머문다', (WidgetTester tester) async { + put( + 's1', + mailboxId: kSentId, + isRead: true, + pendingSend: pendingJson( + kTestNow.add(const Duration(days: 1)), + kind: 'scheduled', + ), + ); + h.mail.cancelFailure = FakeResponse.fail(409, 'MAIL_014'); + await open(tester, 's1', location: MailRoutes.mailbox(kSentId)); + + await tester.tap(find.text(MailReaderTexts.cancelSchedule)); + await tester.pumpAndSettle(); + + expect(h.notifier.shownCodes, ['MAIL_014']); + expect(find.byType(MessageReaderPage), findsOneWidget); + }); + + testWidgets('확인 창을 연 채 발송이 풀리면 창을 닫는다(보낼 시각 + 2초에 다시 본다)', ( + WidgetTester tester, + ) async { + put( + 's1', + mailboxId: kSentId, + isRead: true, + pendingSend: pendingJson(kTestNow.add(const Duration(seconds: 5))), + ); + await open(tester, 's1', location: MailRoutes.mailbox(kSentId)); + await tapTooltip(tester, MailReaderTexts.delete); + expect(find.text(MailReaderTexts.heldDeleteTitle), findsOneWidget); + + // 엔진이 보냈다 + h.mail.messageOf('s1')!['pendingSend'] = null; + h.clock.advance(const Duration(seconds: 8)); + await tester.pump(const Duration(seconds: 8)); + await tester.pumpAndSettle(); + + expect(find.text(MailReaderTexts.heldDeleteTitle), findsNothing); + expect(findCopy(MailReaderTexts.sendingNotice), findsNothing); + expect(h.mail.cancellations, isEmpty); + }); + }); + + group('삭제 · 이동 · 스팸', () { + testWidgets('[삭제]는 휴지통으로 옮기고 목록으로 — 알림의 [되돌리기]로 받은편지함에 되돌린다', ( + WidgetTester tester, + ) async { + put('m1', subject: '지울 메일', isRead: true); + put('m2', subject: '남을 메일', isRead: true); + await open(tester, 'm1'); + + // 목록의 맞추기를 막아도 줄이 바로 빠진다 + h.mail.listGate = Completer(); + h.mail.listGateOnce = false; + await tapTooltip(tester, MailReaderTexts.delete); + + expect(h.mail.singlePatches.last.body, { + 'mailboxId': kTrashId, + }); + expect(find.byType(MessageReaderPage), findsNothing); + expect(find.text('지울 메일'), findsNothing); + expect(find.text('남을 메일'), findsOneWidget); + expect(toast(MailBulkTexts.moved(1, '휴지통')), findsOneWidget); + h.mail.listGate!.complete(); + h.mail.listGateOnce = true; + await tester.pumpAndSettle(); + + await tester.tap(find.text(MailBulkTexts.undo)); + await tester.pumpAndSettle(); + expect(h.mail.singlePatches.last.body, { + 'mailboxId': kInboxId, + }); + expect(h.mail.mailboxOf('m1'), kInboxId); + expect(snack(MailBulkTexts.restored(1)), findsOneWidget); + expect(find.text('지울 메일'), findsOneWidget); + }); + + testWidgets('옮기는 동안은 답장 · 본문 링크를 막고, 끝났을 때 위에 다른 화면이 열려 있으면 그 화면을 닫지 않는다', ( + WidgetTester tester, + ) async { + put('m1', isRead: true); + await open(tester, 'm1'); + final Completer moving = Completer(); + h.mail.singlePatchGate = moving; + await tapTooltip(tester, MailReaderTexts.delete); + + // 쓰기 화면 · 바깥 링크 · mailto 를 열지 않는다 + await tester.tap(find.text(MailReaderTexts.reply)); + await tester.pumpAndSettle(); + expect(find.byType(MessageComposePage), findsNothing); + expect( + await h.webView.last.navigate('mailto:kim@example.com'), + NavigationDecision.prevent, + ); + expect( + await h.webView.last.navigate('https://www.example.com/'), + NavigationDecision.prevent, + ); + await tester.pumpAndSettle(); + expect(find.byType(MessageComposePage), findsNothing); + expect(h.links.launched, isEmpty); + + // 그사이 위에 다른 화면이 열렸다 — 옮기기가 끝나도 그 화면을 닫지 않는다 + unawaited(h.router.push(MailRoutes.compose)); + await tester.pumpAndSettle(); + moving.complete(); + await tester.pumpAndSettle(); + + expect(h.mail.mailboxOf('m1'), kTrashId); + expect(find.byType(MessageComposePage), findsOneWidget); + expect(pushedLocation(tester), MailRoutes.compose); + h.router.pop(); + await tester.pumpAndSettle(); + expect(find.byType(MessageReaderPage), findsOneWidget); + }); + + testWidgets('휴지통에만 든 메일은 [영구 삭제] — 확인 뒤 지우고 목록으로', ( + WidgetTester tester, + ) async { + put('t1', mailboxId: kTrashId, subject: '버린 메일', isRead: true); + await open(tester, 't1', location: MailRoutes.mailbox(kTrashId)); + + await tapTooltip(tester, MailReaderTexts.purge); + expect(find.text(MailBulkTexts.purgeTitle(1)), findsOneWidget); + await tester.tap(find.text(MailBulkTexts.purgeConfirm).last); + await tester.pumpAndSettle(); + + expect(h.mail.deletions, >[ + ['t1'], + ]); + expect(find.byType(MessageReaderPage), findsNothing); + expect(snack(MailBulkTexts.purged(1)), findsOneWidget); + }); + + testWidgets('[이동] — 고른 메일함으로 옮기고 목록으로', (WidgetTester tester) async { + put('m1', isRead: true); + await open(tester, 'm1'); + + await openMenu(tester, MailReaderTexts.move); + await tester.tap(find.text('거래처')); + await tester.pumpAndSettle(); + + expect(h.mail.singlePatches.last.body, { + 'mailboxId': kClientsId, + }); + expect(find.byType(MessageReaderPage), findsNothing); + expect(toast(MailBulkTexts.moved(1, '거래처')), findsOneWidget); + }); + + testWidgets('[스팸 신고] — 스팸메일함으로 + 스팸 표시, [되돌리기]는 반대 표시로', ( + WidgetTester tester, + ) async { + put('m1', isRead: true); + await open(tester, 'm1'); + + await openMenu(tester, MailReaderTexts.reportSpam); + expect(h.mail.singlePatches.last.body, { + 'mailboxId': kJunkId, + 'isJunk': true, + }); + expect(toast(MailBulkTexts.moved(1, '스팸메일함')), findsOneWidget); + + await tester.tap(find.text(MailBulkTexts.undo)); + await tester.pumpAndSettle(); + expect(h.mail.singlePatches.last.body, { + 'mailboxId': kInboxId, + 'isJunk': false, + }); + }); + + testWidgets('스팸메일함에서는 [스팸 아님] — 받은편지함으로 + 스팸 아님 표시', ( + WidgetTester tester, + ) async { + put('j1', mailboxId: kJunkId, isRead: true); + await open(tester, 'j1', location: MailRoutes.mailbox(kJunkId)); + + await openMenu(tester, MailReaderTexts.notSpam); + expect(h.mail.singlePatches.last.body, { + 'mailboxId': kInboxId, + 'isJunk': false, + }); + expect(toast(MailBulkTexts.notSpamMoved(1, '받은편지함')), findsOneWidget); + }); + }); + + group('첨부', () { + Map withAttachment({int size = 6}) => { + 'attachments': [ + attachmentJson('a1', name: '견적서.pdf', size: size), + attachmentJson( + 'a2', + name: 'sig.png', + type: 'image/png', + isInline: true, + ), + ], + }; + + testWidgets('받는 중 진행(%) — 다 받으면 다른 앱으로 연다, 다시 누르면 받지 않고 연다', ( + WidgetTester tester, + ) async { + put('m1', isRead: true, detail: withAttachment()); + final StreamController chunks = StreamController(); + addTearDown(() { + if (!chunks.isClosed) unawaited(chunks.close()); + }); + h.mail.attachmentFiles['a1'] = () => + FakeResponse.stream(chunks.stream, contentLength: 6); + await open(tester, 'm1'); + + expect(find.text(MailReaderTexts.attachmentsTitle(1)), findsOneWidget); + expect(find.text('sig.png'), findsNothing); + expect(find.text('6 B'), findsOneWidget); + await tapTooltip(tester, MailReaderTexts.download('견적서.pdf')); + chunks.add(Uint8List.fromList([1, 2, 3])); + await tester.pump(); + await tester.pump(); + + expect(find.text(MailReaderTexts.progress(50)), findsOneWidget); + expect( + find.byTooltip(MailReaderTexts.cancelDownload('견적서.pdf')), + findsOneWidget, + ); + + chunks.add(Uint8List.fromList([4, 5, 6])); + unawaited(chunks.close()); + await tester.pumpAndSettle(); + + expect(h.opener.opened.single.mimeType, 'application/pdf'); + final String path = h.opener.opened.single.path; + expect(path, endsWith('/견적서.pdf')); + expect(h.files.files[path], [1, 2, 3, 4, 5, 6]); + expect(find.text('6 B'), findsOneWidget); + + final int requests = h.server.requests.length; + await tapTooltip(tester, MailReaderTexts.download('견적서.pdf')); + expect(h.server.requests.length, requests); + expect(h.opener.opened, hasLength(2)); + }); + + testWidgets('[받기 취소] — 받던 것을 그만두고 쓰다 만 파일을 지운다', ( + WidgetTester tester, + ) async { + put('m1', isRead: true, detail: withAttachment()); + final StreamController chunks = StreamController(); + addTearDown(() { + if (!chunks.isClosed) unawaited(chunks.close()); + }); + h.mail.attachmentFiles['a1'] = () => + FakeResponse.stream(chunks.stream, contentLength: 6); + await open(tester, 'm1'); + await tapTooltip(tester, MailReaderTexts.download('견적서.pdf')); + chunks.add(Uint8List.fromList([1])); + await tester.pump(); + + await tapTooltip(tester, MailReaderTexts.cancelDownload('견적서.pdf')); + + expect(h.files.discarded, hasLength(1)); + expect(h.opener.opened, isEmpty); + expect( + find.byTooltip(MailReaderTexts.download('견적서.pdf')), + findsOneWidget, + ); + expect(h.notifier.shownCodes, isEmpty); + unawaited(chunks.close()); + }); + + testWidgets('[받기 취소] 바로 뒤 다시 [받기] — 앞 받기의 끝이 새 받기의 진행을 지우지 않는다', ( + WidgetTester tester, + ) async { + put('m1', isRead: true, detail: withAttachment()); + final List> responses = + >[]; + addTearDown(() { + for (final StreamController chunks in responses) { + if (!chunks.isClosed) unawaited(chunks.close()); + } + }); + h.mail.attachmentFiles['a1'] = () { + final StreamController chunks = + StreamController(); + responses.add(chunks); + return FakeResponse.stream(chunks.stream, contentLength: 6); + }; + await open(tester, 'm1'); + await tapTooltip(tester, MailReaderTexts.download('견적서.pdf')); + responses.first.add(Uint8List.fromList([1])); + await tester.pump(); + + // 앞 받기는 쓰다 만 파일을 지우는 데서 늦게 끝난다 — 그 사이 다시 받는다 + final Completer discardGate = Completer(); + h.files.discardGate = discardGate; + await tapTooltip(tester, MailReaderTexts.cancelDownload('견적서.pdf')); + await tapTooltip(tester, MailReaderTexts.download('견적서.pdf')); + expect(responses, hasLength(2)); + discardGate.complete(); + await tester.pumpAndSettle(); + + expect( + find.byTooltip(MailReaderTexts.cancelDownload('견적서.pdf')), + findsOneWidget, + ); + expect(find.text(MailReaderTexts.progress(0)), findsOneWidget); + responses.last.add(Uint8List.fromList([1, 2, 3, 4, 5, 6])); + unawaited(responses.last.close()); + await tester.pumpAndSettle(); + expect(h.opener.opened, hasLength(1)); + expect(h.files.discarded, hasLength(1)); + }); + + testWidgets('앱에서 열지 않는 형식은 받기 전에 알리고, 여는 형식은 확장자에서 먼저 정한다', ( + WidgetTester tester, + ) async { + put( + 'm1', + isRead: true, + detail: { + 'attachments': [ + attachmentJson('a1', name: '안내.html', type: 'text/html', size: 2), + // 확장자는 무해해도 보낸 쪽 형식이 웹 문서면 받지 않는다 + attachmentJson('a2', name: '견적서.pdf', type: 'text/html', size: 2), + attachmentJson( + 'a3', + name: '사진.jpg', + type: 'application/octet-stream', + size: 2, + ), + ], + }, + ); + h.mail.attachmentFiles['a3'] = () => + FakeResponse.bytes([1, 2], contentType: 'image/jpeg'); + await open(tester, 'm1'); + final int requests = h.server.requests.length; + Future download(String name) async { + await tester.ensureVisible( + find.byTooltip(MailReaderTexts.download(name)), + ); + await tester.pumpAndSettle(); + await tapTooltip(tester, MailReaderTexts.download(name)); + } + + await download('안내.html'); + expect(snack(MailReaderTexts.blockedFileType), findsOneWidget); + await download('견적서.pdf'); + expect(h.server.requests.length, requests); + expect(h.opener.opened, isEmpty); + + await download('사진.jpg'); + expect(h.opener.opened.single.mimeType, 'image/jpeg'); + }); + + testWidgets('받지 못하면 인터셉터가 그 코드로 알리고, 열 앱이 없으면 알린다', ( + WidgetTester tester, + ) async { + put('m1', isRead: true, detail: withAttachment()); + await open(tester, 'm1'); + + await tapTooltip(tester, MailReaderTexts.download('견적서.pdf')); + expect(h.notifier.shownCodes, ['RES_001']); + expect(h.files.discarded, hasLength(1)); + expect(h.opener.opened, isEmpty); + + h.mail.attachmentFiles['a1'] = () => + FakeResponse.bytes([1, 2], contentType: 'application/pdf'); + h.opener.result = FileOpenResult.noApp; + await tapTooltip(tester, MailReaderTexts.download('견적서.pdf')); + expect(snack(MailReaderTexts.noAppToOpen), findsOneWidget); + }); + + testWidgets('저장 공간이 없으면 알린다', (WidgetTester tester) async { + put('m1', isRead: true, detail: withAttachment()); + h.mail.attachmentFiles['a1'] = () => + FakeResponse.bytes([1, 2], contentType: 'application/pdf'); + h.files.failCreate = true; + await open(tester, 'm1'); + + await tapTooltip(tester, MailReaderTexts.download('견적서.pdf')); + expect(snack(MailReaderTexts.saveFailed), findsOneWidget); + }); + + testWidgets('대용량 첨부는 이름 · 크기와 받는 길 안내만(받기 단추 없음)', ( + WidgetTester tester, + ) async { + put( + 'm1', + isRead: true, + detail: { + 'largeFiles': [ + { + 'linkId': 'l1', + 'fileId': 'f1', + 'name': '설계도.zip', + 'size': 3 * 1024 * 1024, + 'status': 'available', + }, + ], + }, + ); + await open(tester, 'm1'); + + expect(find.text(MailReaderTexts.attachmentsTitle(1)), findsOneWidget); + expect(find.text('설계도.zip'), findsOneWidget); + expect(find.text('3 MB · ${MailReaderTexts.largeFile}'), findsOneWidget); + expect(findCopy(MailReaderTexts.largeFilesNote), findsOneWidget); + expect(find.byTooltip(MailReaderTexts.download('설계도.zip')), findsNothing); + }); + }); + + group('링크 · 쓰기 화면', () { + testWidgets('본문 링크 — http(s) 는 바깥 앱으로, mailto 는 쓰기 화면으로, 그 밖은 막기만 한다', ( + WidgetTester tester, + ) async { + put('m1', isRead: true); + await open(tester, 'm1'); + + expect( + await h.webView.last.navigate('about:blank'), + NavigationDecision.navigate, + ); + expect( + await h.webView.last.navigate('https://www.example.com/a?b=1'), + NavigationDecision.prevent, + ); + expect(h.links.launched, [ + Uri.parse('https://www.example.com/a?b=1'), + ]); + for (final String url in [ + 'javascript:alert(1)', + 'file:///etc/hosts', + 'intent://x#Intent;end', + ]) { + expect(await h.webView.last.navigate(url), NavigationDecision.prevent); + } + expect(h.links.launched, hasLength(1)); + + h.links.result = false; + await h.webView.last.navigate('https://no-app.example'); + await tester.pumpAndSettle(); + expect(snack(MailReaderTexts.linkFailed), findsOneWidget); + + expect( + await h.webView.last.navigate('mailto:kim@example.com?subject=hi'), + NavigationDecision.prevent, + ); + await tester.pumpAndSettle(); + final GoRouterState compose = GoRouterState.of( + tester.element(find.byType(MessageComposePage).last), + ); + expect(compose.uri.path, MailRoutes.compose); + expect( + compose.extra, + const ComposePrefill(to: ['kim@example.com'], subject: 'hi'), + ); + }); + + testWidgets('아래 막대 — 답장 · 전체 답장 · 전달은 그 메일 id 를 쓰기 화면에 넘긴다', ( + WidgetTester tester, + ) async { + put('m1', isRead: true); + await open(tester, 'm1'); + + await tester.tap(find.text(MailReaderTexts.reply)); + await tester.pumpAndSettle(); + expect(pushedLocation(tester), MailRoutes.composeReply('m1')); + h.router.pop(); + await tester.pumpAndSettle(); + + await tester.tap(find.text(MailReaderTexts.replyAll)); + await tester.pumpAndSettle(); + expect(pushedLocation(tester), MailRoutes.composeReply('m1', all: true)); + h.router.pop(); + await tester.pumpAndSettle(); + + await tester.tap(find.text(MailReaderTexts.forward)); + await tester.pumpAndSettle(); + expect(pushedLocation(tester), MailRoutes.composeForward('m1')); + }); + }); + + group('늦은 응답 · 세션', () { + testWidgets('다른 메일로 바꾸면 앞 메일의 늦은 응답은 버린다', (WidgetTester tester) async { + put('m1', subject: '앞 메일', isRead: true); + put('m2', subject: '뒤 메일', isRead: true); + await h.pump(tester); + final Completer slow = Completer(); + h.mail.detailGate = slow; + unawaited(h.router.push(MailRoutes.message('m1'))); + await tester.pump(); + await tester.pump(); + + h.router.go(MailRoutes.message('m2')); + await tester.pumpAndSettle(); + slow.complete(); + await tester.pumpAndSettle(); + + expect(find.text('뒤 메일'), findsWidgets); + expect( + find.descendant( + of: find.byType(MessageReaderPage), + matching: find.text('앞 메일'), + ), + findsNothing, + ); + expect( + h.container.exists( + messageReaderProvider(const MessageReaderArgs('m1')), + ), + isFalse, + ); + }); + + testWidgets('로그아웃하면 읽기 화면이 사라지고 늦은 읽음 응답은 알리지 않는다', ( + WidgetTester tester, + ) async { + h.mail.seed(kInboxId, 1); + h.mail.singlePatchGate = Completer(); + h.server.on( + 'POST', + ApiPaths.appLogout, + (RequestOptions _) => FakeResponse.ok(), + ); + await open(tester, 'm1'); + expect(h.mail.singlePatches, hasLength(1)); + + unawaited(h.container.read(sessionProvider.notifier).signOut()); + await tester.pumpAndSettle(); + h.mail.singlePatchGate!.complete(); + await tester.pumpAndSettle(); + + expect(find.byType(MessageReaderPage), findsNothing); + expect(h.notifier.shownCodes, isEmpty); + }); + + testWidgets('메일을 보는 직원이 바뀌면(로그아웃) 받아 둔 파일을 모두 지운다', ( + WidgetTester tester, + ) async { + put( + 'm1', + isRead: true, + detail: { + 'attachments': [ + attachmentJson('a1', name: '견적서.pdf', size: 2), + ], + }, + ); + h.mail.attachmentFiles['a1'] = () => + FakeResponse.bytes([1, 2], contentType: 'application/pdf'); + h.server.on( + 'POST', + ApiPaths.appLogout, + (RequestOptions _) => FakeResponse.ok(), + ); + await open(tester, 'm1'); + await tapTooltip(tester, MailReaderTexts.download('견적서.pdf')); + expect(h.files.files, hasLength(1)); + + unawaited(h.container.read(sessionProvider.notifier).signOut()); + await tester.pumpAndSettle(); + + expect(h.files.purgeCount, 1); + expect(h.files.files, isEmpty); + }); + }); +} diff --git a/flutter/test/features/mail/push_offer_line_test.dart b/flutter/test/features/mail/push_offer_line_test.dart new file mode 100644 index 0000000..aafe734 --- /dev/null +++ b/flutter/test/features/mail/push_offer_line_test.dart @@ -0,0 +1,184 @@ +import 'package:arasystem_app/core/push/notification_system.dart'; +import 'package:arasystem_app/core/push/push_messaging.dart'; +import 'package:arasystem_app/core/push/push_preferences.dart'; +import 'package:arasystem_app/core/push/push_texts.dart'; +import 'package:arasystem_app/features/mail/mail_routes.dart'; +import 'package:arasystem_app/features/mail/presentation/widgets/push_offer_line.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/login_fakes.dart'; +import '../../support/mail_fakes.dart'; +import '../../support/push_screen_harness.dart'; + +/// 받은편지함 위 새 메일 알림 권하기 줄 — 앱 보드 `App1Push` ①(줄) · ③(고른 뒤 알림) +void main() { + late PushScreenHarness h; + + setUp(() { + h = PushScreenHarness(); + // 처음 모습: 안드로이드 14 · 아직 묻지 않음 — 줄이 보인다 + h.messaging.current = PushPermission.notAsked; + }); + + Finder line() => findCopy(PushTexts.offerMessage); + + Finder enableButton() => + find.widgetWithText(TextButton, PushTexts.offerEnable); + + Finder snack(String text) => + find.descendant(of: find.byType(SnackBar), matching: find.text(text)); + + testWidgets('보이는 때 — 받은편지함 위 막대 아래, 글 · [알림 켜기] · [닫기]', ( + WidgetTester tester, + ) async { + final SemanticsHandle semantics = tester.ensureSemantics(); + await h.pump(tester); + + expect(line(), findsOneWidget); + expect(enableButton(), findsOneWidget); + expect(find.byTooltip(PushTexts.offerDismiss), findsOneWidget); + expect(find.bySemanticsLabel(PushTexts.offerRegion), findsOneWidget); + // 목록 위에 고정이다(목록과 함께 흐르지 않는다) + expect( + tester.getBottomLeft(find.byType(PushOfferLine)).dy, + lessThanOrEqualTo(tester.getTopLeft(find.text('제목 m1')).dy), + ); + expect(h.messaging.permissionRequests, 0, reason: '누르기 전에는 묻지 않는다'); + semantics.dispose(); + }); + + group('보이지 않는 때', () { + testWidgets('다른 메일함', (WidgetTester tester) async { + await h.pump(tester, location: MailRoutes.mailbox(kClientsId)); + + expect(find.byType(PushOfferLine), findsNothing, reason: '받은편지함에만 둔다'); + }); + + testWidgets('안드로이드 12 이하(묻지 않고 처음부터 허용)', (WidgetTester tester) async { + h.notifications.current = const NotificationStatus( + appEnabled: true, + channelEnabled: true, + sdkInt: 32, + ); + await h.pump(tester); + + expect(line(), findsNothing); + }); + + testWidgets('이미 허용', (WidgetTester tester) async { + h.messaging.current = PushPermission.granted; + await h.pump(tester); + + expect(line(), findsNothing); + }); + + testWidgets('닫은 적 있음', (WidgetTester tester) async { + h.prefs.values = const PushPreferenceValues(offerDismissed: true); + await h.pump(tester); + + expect(line(), findsNothing); + }); + + testWidgets('이 휴대폰에서 끔', (WidgetTester tester) async { + h.prefs.values = const PushPreferenceValues(enabled: false); + await h.pump(tester); + + expect(line(), findsNothing); + }); + + testWidgets('서버가 보낼 수 없음', (WidgetTester tester) async { + h.push.config = { + 'minSupportedVersion': {'android': '1.0.0'}, + 'pushAvailable': false, + }; + await h.pump(tester); + + expect(line(), findsNothing); + }); + + testWidgets('메일을 고르는 동안', (WidgetTester tester) async { + await h.pump(tester); + expect(line(), findsOneWidget); + + await tester.longPress(find.text('제목 m1')); + await settle(tester); + + expect(line(), findsNothing); + }); + }); + + testWidgets('[알림 켜기] → [허용] — "새 메일 알림을 켰습니다." · 서버에 등록하고 줄은 사라진다', ( + WidgetTester tester, + ) async { + await h.pump(tester); + expect(h.push.puts, isEmpty); + + await tester.tap(enableButton()); + await settle(tester); + + expect(h.messaging.permissionRequests, 1); + expect(snack(PushTexts.enabled), findsOneWidget); + expect(line(), findsNothing); + expect(h.push.puts, hasLength(1)); + expect(h.prefs.values.enabled, isTrue); + }); + + testWidgets('[알림 켜기] → [허용 안 함] — 나중에 켜는 곳을 알리고 줄은 사라진다', ( + WidgetTester tester, + ) async { + h.messaging.answer = PushPermission.denied; + await h.pump(tester); + + await tester.tap(enableButton()); + await settle(tester); + + expect(snack(PushTexts.deniedLater), findsOneWidget); + expect(line(), findsNothing); + expect(h.push.puts, isEmpty); + }); + + testWidgets('[알림 켜기] — 휴대폰이 묻지 않고 막혀 있으면 알림 줄에 [설정 열기]', ( + WidgetTester tester, + ) async { + h.messaging.answer = PushPermission.blocked; + await h.pump(tester); + + await tester.tap(enableButton()); + await settle(tester); + + expect(find.text(PushTexts.blockedNotice), findsOneWidget); + expect(line(), findsNothing); + await tester.tap(find.widgetWithText(TextButton, PushTexts.openSettings)); + await settle(tester); + expect(h.notifications.settingsOpened, 1); + }); + + testWidgets('[설정 열기]가 휴대폰 설정을 열지 못하면 찾아가는 길을 알린다', ( + WidgetTester tester, + ) async { + h.messaging.answer = PushPermission.blocked; + h.notifications.opens = false; + await h.pump(tester); + + await tester.tap(enableButton()); + await settle(tester); + await tester.tap(find.widgetWithText(TextButton, PushTexts.openSettings)); + await settle(tester); + + expect(snack(PushTexts.openSettingsFailed), findsOneWidget); + }); + + testWidgets('[닫기] — 줄이 사라지고 이 휴대폰에서 다시 보이지 않는다(묻지도 않는다)', ( + WidgetTester tester, + ) async { + await h.pump(tester); + + await tester.tap(find.byTooltip(PushTexts.offerDismiss)); + await settle(tester); + + expect(line(), findsNothing); + expect(h.prefs.values.offerDismissed, isTrue); + expect(h.messaging.permissionRequests, 0); + }); +} diff --git a/flutter/test/features/mail/search_conditions_test.dart b/flutter/test/features/mail/search_conditions_test.dart new file mode 100644 index 0000000..4d02e35 --- /dev/null +++ b/flutter/test/features/mail/search_conditions_test.dart @@ -0,0 +1,456 @@ +import 'package:arasystem_app/features/mail/data/models/mail_search.dart'; +import 'package:arasystem_app/features/mail/domain/search_conditions.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_search_view.dart'; +import 'package:arasystem_app/features/mail/presentation/mail_texts.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// 시험 날짜 — 'YYYY-MM-DD' 를 [SearchDay] 로 +SearchDay _day(String text) { + final List parts = text.split('-').map(int.parse).toList(); + return SearchDay(parts[0], parts[1], parts[2]); +} + +/// 걸린 조건 — 웹 spec 의 `conditions(partial)` 과 같은 모양 +SearchConditions _conditions({ + String from = '', + String to = '', + String subject = '', + String body = '', + String? since, + String? until, + bool hasAttachment = false, +}) => SearchConditions( + from: from, + to: to, + subject: subject, + body: body, + since: since == null ? null : _day(since), + until: until == null ? null : _day(until), + hasAttachment: hasAttachment, +); + +/// KST(UTC+9)로 고정한 그날 0시 — 기기 시간대에 기대지 않고 after · before 를 본다 +DateTime _kstMidnight(int year, int month, int day) => + DateTime.utc(year, month, day).subtract(const Duration(hours: 9)); + +/// 조건 검색 (2차 — 계약 2절 "검색 — 14단계"). **짝: 웹 `frontend/src/shared/utils/mail-search.spec.ts`** 의 같은 경우를 옮겼다. +/// 오늘은 지역 시각(`DateTime(년, 월, 일)`)으로 넘긴다 — 시간대(UTC · KST)와 상관없이 같은 날이다 +void main() { + group('조건 다듬기 · 같은 조건', () { + test('글 칸은 검색어처럼(앞뒤 공백 · 200자), 날짜는 있는 날짜만', () { + expect( + normalizeSearchConditions( + _conditions( + from: ' 이도윤 ', + subject: '가' * 250, + since: '2026-02-30', + until: '2026-11-05', + hasAttachment: true, + ), + ), + _conditions( + from: '이도윤', + subject: '가' * 200, + until: '2026-11-05', + hasAttachment: true, + ), + ); + expect(normalizeSearchConditions(null), SearchConditions.none); + }); + + test('날짜는 1970-01-01 ~ 9999-12-30 안의 있는 날짜만 — 밖의 날짜는 그 날짜만 버린다', () { + expect(isSearchDay(_day('1970-01-01')), isTrue); + expect(isSearchDay(_day('9999-12-30')), isTrue); + expect(isSearchDay(_day('1969-12-31')), isFalse); + // 9999-12-31 의 다음 날 0시는 10000년 — 서버가 받지 않는다 + expect(isSearchDay(_day('9999-12-31')), isFalse); + expect(isSearchDay(_day('10000-01-01')), isFalse); + expect(isSearchDay(_day('2024-02-29')), isTrue); + expect(isSearchDay(_day('2026-02-29')), isFalse); + expect( + normalizeSearchConditions( + _conditions(since: '1969-12-31', until: '2026-11-01'), + ), + _conditions(until: '2026-11-01'), + ); + expect( + normalizeSearchConditions( + _conditions(since: '2026-11-01', until: '9999-12-31'), + ), + _conditions(since: '2026-11-01'), + ); + }); + + test('시작이 끝보다 늦은 기간은 통째로 버린다 — 같은 날은 그 하루', () { + expect( + normalizeSearchConditions( + _conditions( + since: '2026-11-06', + until: '2026-10-01', + hasAttachment: true, + ), + ), + _conditions(hasAttachment: true), + ); + expect( + normalizeSearchConditions( + _conditions(since: '2026-11-06', until: '2026-11-06'), + ), + _conditions(since: '2026-11-06', until: '2026-11-06'), + ); + }); + + test('조건이 하나라도 있는가 · 같은 조건인가 — 빈 조건은 조건 없음과 같다', () { + expect(SearchConditions.none.isEmpty, isTrue); + expect(_conditions().isEmpty, isTrue); + expect(_conditions(hasAttachment: true).isNotEmpty, isTrue); + expect(_conditions(until: '2026-11-05').isNotEmpty, isTrue); + expect(_conditions(), SearchConditions.none); + expect(_conditions(from: '이도윤'), _conditions(from: '이도윤')); + expect(_conditions(from: '이도윤'), isNot(_conditions(to: '이도윤'))); + expect(_day('2026-11-05').toString(), '2026-11-05'); + }); + }); + + group('API 쿼리 · 읽기 화면의 강조 쿼리', () { + test( + '기간은 시작 날짜 0시 · 끝 날짜 다음 날 0시를 기기 시간대에서 UTC 로 — 첨부는 hasAttachment=true', + () { + final SearchConditions value = _conditions( + to: 'ara.kim', + subject: '견적', + since: '2026-10-06', + until: '2026-11-06', + hasAttachment: true, + ); + expect( + searchConditionsParams(value, midnight: _kstMidnight), + { + 'to': 'ara.kim', + 'subject': '견적', + 'after': '2026-10-05T15:00:00.000Z', + 'before': '2026-11-06T15:00:00.000Z', + 'hasAttachment': 'true', + }, + ); + // 기본은 기기 시간대 — 그날 0시의 UTC (웹 spec 의 localMidnightIso 와 같은 셈) + expect(searchConditionsParams(value)['after'], _localIso(2026, 10, 6)); + expect(searchConditionsParams(value)['before'], _localIso(2026, 11, 7)); + }, + ); + + test('시작만 · 끝만도 된다 — 끝 날짜가 그 달 · 그 해의 마지막 날이면 다음 달 · 해로 넘긴다', () { + expect( + searchConditionsParams( + _conditions(until: '2026-12-31'), + midnight: _kstMidnight, + ), + {'before': '2026-12-31T15:00:00.000Z'}, + ); + expect( + searchConditionsParams(_conditions(until: '2026-12-31')), + {'before': _localIso(2027, 1, 1)}, + ); + expect( + searchConditionsParams( + _conditions(since: '2026-03-01'), + midnight: _kstMidnight, + ), + {'after': '2026-02-28T15:00:00.000Z'}, + ); + expect(searchConditionsParams(SearchConditions.none), isEmpty); + }); + + test('읽기 화면에는 검색어와 글 칸 넷만 넘긴다 — 기간 · 첨부는 서버가 받지 않는다', () { + final SearchConditions value = _conditions( + from: '이도윤', + body: '계약', + since: '2026-10-06', + hasAttachment: true, + ); + expect( + messageHighlightQueryOf('갱신', value), + const MessageHighlightQuery(q: '갱신', from: '이도윤', body: '계약'), + ); + expect( + messageHighlightQueryOf('', _conditions(subject: '견적')), + const MessageHighlightQuery(subject: '견적'), + ); + expect( + messageHighlightQueryOf( + '', + _conditions(since: '2026-10-06', hasAttachment: true), + ), + isNull, + ); + expect( + messageHighlightQueryOf( + '계약', + SearchConditions.none, + )!.toQueryParameters(), + {'q': '계약'}, + ); + }); + }); + + group('합친 낱말 수 — 검색어와 조건 넷을 합쳐 8개까지 (서버와 같은 기준)', () { + test('띄어쓰기로 나눈 조각을 칸마다 세어 더한다 — 날짜 · 첨부는 낱말이 아니다', () { + final SearchConditions value = _conditions( + from: '이도윤', + to: '김아라 박지훈', + subject: '"계약 갱신"', + body: '단가', + ); + expect(combinedSearchWords('견적 회의', value), 8); + expect(exceedsCombinedSearchWords('견적 회의', value), isFalse); + expect(exceedsCombinedSearchWords('견적 회의 일정', value), isTrue); + expect( + combinedSearchWords( + '', + _conditions(since: '2026-10-06', hasAttachment: true), + ), + 0, + ); + }); + }); + + group('기간 고르기', () { + final DateTime today = DateTime(2026, 11, 6, 15, 30); + + test('최근 1주 · 1달 · 3달 · 1년은 그만큼 앞의 같은 날부터 오늘까지', () { + expect(periodRange(SearchPeriod.week, today), ( + since: _day('2026-10-30'), + until: _day('2026-11-06'), + )); + expect(periodRange(SearchPeriod.month, today), ( + since: _day('2026-10-06'), + until: _day('2026-11-06'), + )); + expect(periodRange(SearchPeriod.quarter, today), ( + since: _day('2026-08-06'), + until: _day('2026-11-06'), + )); + expect(periodRange(SearchPeriod.year, today), ( + since: _day('2025-11-06'), + until: _day('2026-11-06'), + )); + expect(periodRange(SearchPeriod.all, today), (since: null, until: null)); + expect(periodRange(SearchPeriod.custom, today), ( + since: null, + until: null, + )); + }); + + test('달은 그 달의 마지막 날을 넘지 않는다 — 3월 31일의 1달 앞은 2월 28일(윤년이면 29일)', () { + expect( + periodRange(SearchPeriod.month, DateTime(2026, 3, 31)).since, + _day('2026-02-28'), + ); + expect( + periodRange(SearchPeriod.month, DateTime(2028, 3, 31)).since, + _day('2028-02-29'), + ); + expect( + periodRange(SearchPeriod.quarter, DateTime(2026, 5, 31)).since, + _day('2026-02-28'), + ); + expect( + periodRange(SearchPeriod.week, DateTime(2026, 1, 3)).since, + _day('2025-12-27'), + ); + expect( + periodRange(SearchPeriod.year, DateTime(2028, 2, 29)).since, + _day('2027-02-28'), + ); + }); + + test('두 날짜가 오늘 기준의 미리 정한 기간과 같으면 그 고르기, 아니면 날짜 고르기', () { + expect(periodOf(null, null, today), SearchPeriod.all); + expect( + periodOf(_day('2026-10-06'), _day('2026-11-06'), today), + SearchPeriod.month, + ); + expect( + periodOf(_day('2026-10-30'), _day('2026-11-06'), today), + SearchPeriod.week, + ); + // 다음 날 보면 같은 날짜도 날짜 고르기다 + expect( + periodOf(_day('2026-10-06'), _day('2026-11-06'), DateTime(2026, 11, 7)), + SearchPeriod.custom, + ); + expect(periodOf(_day('2026-10-01'), null, today), SearchPeriod.custom); + }); + + test('시작이 끝보다 늦으면 거꾸로다 — 같은 날 · 한쪽만 있으면 아니다', () { + expect(isReversedPeriod(_day('2026-11-05'), _day('2026-10-01')), isTrue); + expect(isReversedPeriod(_day('2026-10-01'), _day('2026-10-01')), isFalse); + expect(isReversedPeriod(_day('2026-11-05'), null), isFalse); + expect(isReversedPeriod(null, _day('2026-10-01')), isFalse); + }); + }); + + group('조건 줄 글', () { + final DateTime today = DateTime(2026, 11, 6); + + test('"조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음" — 쉼표로 잇는다', () { + expect( + searchConditionsSummary( + _conditions( + from: '이도윤', + since: '2026-10-06', + until: '2026-11-06', + hasAttachment: true, + ), + today, + ), + '조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음', + ); + }); + + test('받는 사람 · 제목 · 내용은 조사를 붙인다 — 날짜 고르기는 날짜로(같은 해면 끝 날짜의 해를 뺀다)', () { + expect( + searchConditionsSummary( + _conditions( + to: '김아라', + subject: '견적', + body: '계약서', + since: '2026-10-01', + until: '2026-11-05', + ), + today, + ), + '조건: 김아라 님이 받은 메일, 제목에 ‘견적’이 든 메일, 내용에 ‘계약서’가 든 메일, 2026. 10. 1.부터 11. 5.까지', + ); + expect( + searchConditionsSummary( + _conditions(since: '2025-12-01', until: '2026-01-05'), + today, + ), + '조건: 2025. 12. 1.부터 2026. 1. 5.까지', + ); + expect( + searchConditionsSummary(_conditions(since: '2026-10-01'), today), + '조건: 2026. 10. 1.부터', + ); + expect( + searchConditionsSummary(_conditions(until: '2026-10-31'), today), + '조건: 2026. 10. 31.까지', + ); + // 소리를 알 수 없는 끝 글자(라틴)는 "이(가)" + expect( + searchConditionsSummary(_conditions(subject: 'ERP'), today), + '조건: 제목에 ‘ERP’이(가) 든 메일', + ); + expect(searchConditionsSummary(SearchConditions.none, today), ''); + }); + + test('하루만 고른 기간은 "2026. 11. 6. 하루" — 같은 날을 부터 · 까지로 두 번 쓰지 않는다', () { + expect( + searchConditionsSummary( + _conditions(since: '2026-11-06', until: '2026-11-06'), + today, + ), + '조건: 2026. 11. 6. 하루', + ); + expect( + searchConditionsSummary( + _conditions(since: '2025-12-31', until: '2025-12-31'), + today, + ), + '조건: 2025. 12. 31. 하루', + ); + }); + + test( + '보낸 사람 · 받는 사람이 내 이름이나 주소면 "내가 보낸 메일" · "내가 받은 메일" — 대소문자 · 앞뒤 공백은 가리지 않고, 일부만 같으면 아니다', + () { + const SearchConditionsMe me = ( + name: '김아라', + email: 'ara.kim@arasystem.test', + ); + expect( + searchConditionsSummary( + _conditions(from: '김아라', to: ' ARA.KIM@arasystem.test '), + today, + me: me, + ), + '조건: 내가 보낸 메일, 내가 받은 메일', + ); + expect( + searchConditionsSummary( + _conditions(from: 'ara.kim@arasystem.test'), + today, + me: me, + ), + '조건: 내가 보낸 메일', + ); + expect( + searchConditionsSummary( + _conditions(from: '아라', to: 'ara.kim'), + today, + me: me, + ), + '조건: 아라 님이 보낸 메일, ara.kim 님이 받은 메일', + ); + // 누구인지 모르면 지금 글 그대로 + expect( + searchConditionsSummary(_conditions(from: '김아라'), today), + '조건: 김아라 님이 보낸 메일', + ); + }, + ); + + test('"나"는 풀어 쓴 한글(맥에서 온 글)도 모아서 견준다 — NFC', () { + // 김아라 를 첫소리 · 가운뎃소리 · 끝소리 자모로 풀어 쓴 글 + final String decomposed = String.fromCharCodes([ + 0x1100, 0x1175, 0x11B7, // 김 + 0x110B, 0x1161, // 아 + 0x1105, 0x1161, // 라 + ]); + expect(decomposed, isNot('김아라')); + expect( + isSearchConditionMe(decomposed, (name: '김아라', email: 'ara@x.test')), + isTrue, + ); + expect( + isSearchConditionMe('김아', (name: '김아라', email: 'ara@x.test')), + isFalse, + ); + expect(isSearchConditionMe('김아라', null), isFalse); + expect( + isSearchConditionMe(' ', (name: '', email: 'ara@x.test')), + isFalse, + ); + }); + + test('날짜 칸 · 조건 줄의 날짜는 "2026. 11. 7." — 해를 뺄 수 있다', () { + expect(formatSearchDay(_day('2026-11-07')), '2026. 11. 7.'); + expect(formatSearchDay(_day('2026-01-05'), withYear: false), '1. 5.'); + expect(MailSearchTexts.period(SearchPeriod.quarter), '최근 3달'); + }); + }); + + group('한 글자 안내 — 본문을 찾을 말이 한 글자뿐일 때만', () { + test('검색어나 내용 조건이 모두 한 글자면 그렇다', () { + expect(skipsBodySearch('김', SearchConditions.none), isTrue); + expect(skipsBodySearch('', _conditions(body: '가')), isTrue); + expect(skipsBodySearch('김', _conditions(from: '이')), isTrue); + }); + + test('두 글자 이상 낱말이 어느 칸에든 있거나, 본문을 찾을 말이 없으면 아니다', () { + expect(skipsBodySearch('김 계약', SearchConditions.none), isFalse); + expect(skipsBodySearch('김', _conditions(subject: '견적')), isFalse); + // 보낸 사람 · 제목 조건만이면 본문은 원래 찾지 않는다 + expect(skipsBodySearch('', _conditions(from: '김')), isFalse); + expect( + skipsBodySearch('', _conditions(subject: '가', since: '2026-10-01')), + isFalse, + ); + }); + }); +} + +/// 기기 시간대의 그날 0시를 UTC ISO 로 — 시험이 도는 곳의 시간대와 상관없이 앱과 같은 셈 +String _localIso(int year, int month, int day) => + DateTime(year, month, day).toUtc().toIso8601String(); diff --git a/flutter/test/features/mail/search_highlight_test.dart b/flutter/test/features/mail/search_highlight_test.dart new file mode 100644 index 0000000..23c91ce --- /dev/null +++ b/flutter/test/features/mail/search_highlight_test.dart @@ -0,0 +1,376 @@ +import 'package:arasystem_app/features/mail/data/models/mail_search.dart'; +import 'package:arasystem_app/features/mail/data/models/message_detail.dart'; +import 'package:arasystem_app/features/mail/domain/mail_html.dart'; +import 'package:arasystem_app/features/mail/domain/search_highlight.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/mail_fakes.dart'; + +/// 조각을 "[맞은 말]" 꼴의 글로 — 시험에서 보기 쉽게 +String _marked(String text, List? ranges) => [ + for (final HighlightSegment segment in highlightSegments(text, ranges)) + segment.mark ? '[${segment.text}]' : segment.text, +].join(); + +List _ranges(List<(int, int)> pairs) => [ + for (final (int start, int end) in pairs) (start: start, end: end), +]; + +/// 검색 강조 — 조각 나누기(짝: 웹 `search-highlight.ts`) · 응답 읽기 · 글 본문의 `` +void main() { + group('highlightSegments (강조 조각)', () { + test('맞은 곳이 없으면 글 한 조각 — 빈 글이면 조각이 없다', () { + expect(highlightSegments('계약서 초안', null), [ + (text: '계약서 초안', mark: false), + ]); + expect( + highlightSegments('계약서 초안', const []), + [(text: '계약서 초안', mark: false)], + ); + expect(highlightSegments('', null), isEmpty); + }); + + test('여러 범위를 차례로 자른다 — 앞 · 끝 · 맞닿은 곳도', () { + expect( + _marked('Re: 유지보수 계약 갱신 건', _ranges(<(int, int)>[(12, 14), (4, 8)])), + 'Re: [유지보수] 계약 [갱신] 건', + ); + expect(_marked('계약서', _ranges(<(int, int)>[(0, 2)])), '[계약]서'); + expect(_marked('견적서와 계약', _ranges(<(int, int)>[(5, 7)])), '견적서와 [계약]'); + expect(_marked('계약서', _ranges(<(int, int)>[(0, 3)])), '[계약서]'); + }); + + test('겹치거나 맞닿은 범위는 합친다', () { + expect(_marked('aaaa', _ranges(<(int, int)>[(0, 2), (1, 3)])), '[aaa]a'); + expect(_marked('계약서', _ranges(<(int, int)>[(0, 2), (2, 3)])), '[계약서]'); + }); + + test('글 밖 · 거꾸로 된 위치는 버리고, 끝이 글을 넘으면 글 끝에서 자른다', () { + expect(_marked('계약', _ranges(<(int, int)>[(5, 7)])), '계약'); + expect( + _marked('계약', _ranges(<(int, int)>[(1, 1), (2, 1), (-1, 1), (2, 5)])), + '계약', + ); + expect(_marked('계약', _ranges(<(int, int)>[(1, 10)])), '계[약]'); + }); + + test('BMP 밖 글자 뒤의 위치도 UTF-16 그대로 쓴다', () { + expect( + _marked('\u{1F600}계약', _ranges(<(int, int)>[(2, 4)])), + '\u{1F600}[계약]', + ); + }); + + test('위치가 짝 글자(이모지)의 가운데를 가르면 그 글자를 통째로 넣는다 — 반쪽 글자를 만들지 않는다', () { + expect( + _marked('\u{1F600}계약', _ranges(<(int, int)>[(1, 4)])), + '[\u{1F600}계약]', + ); + expect( + _marked('\u{1F600}계약', _ranges(<(int, int)>[(0, 1)])), + '[\u{1F600}]계약', + ); + expect( + _marked('계\u{1F600}약', _ranges(<(int, int)>[(2, 3)])), + '계[\u{1F600}]약', + ); + expect( + _marked('계\u{1F600}약', _ranges(<(int, int)>[(0, 2)])), + '[계\u{1F600}]약', + ); + // 가른 자리를 넓힌 뒤 맞닿으면 합친다 + expect( + _marked('\u{1F600}\u{1F600}', _ranges(<(int, int)>[(0, 1), (3, 4)])), + '[\u{1F600}\u{1F600}]', + ); + final List segments = highlightSegments( + 'a\u{1F600}b\u{1F600}c', + _ranges(<(int, int)>[(2, 5)]), + ); + expect( + [for (final HighlightSegment s in segments) s.text], + ['a', '\u{1F600}b\u{1F600}', 'c'], + ); + }); + + test( + 'NFC 로 다시 맞추지 않는다 — 위치는 그 위치를 준 응답의 글에만 쓴다(서버가 강조가 있으면 NFC 로 싣는다)', + () { + // 같은 응답의 글(모은 한글)이면 맞고, 풀어 쓴 다른 글에 쓰면 글 그대로 자를 뿐이다(어긋남 — 그래서 쓰지 않는다) + expect(_marked('계약서', _ranges(<(int, int)>[(0, 2)])), '[계약]서'); + const String nfd = '\u1100\u1168\u110B\u1163\u11A8'; + expect(highlightSegments(nfd, null).single.text, nfd); + }, + ); + }); + + group('parseSearchRanges (응답의 맞은 곳 읽기)', () { + test('[[시작, 끝], …] 을 읽는다 — 소수 부분 없는 수(2.0)는 정수다', () { + expect( + parseSearchRanges([ + [0, 2], + [4.0, 6], + ]), + _ranges(<(int, int)>[(0, 2), (4, 6)]), + ); + expect(parseSearchRanges([]), isEmpty); + }); + + test('정수가 아닌 위치는 그 칸만 버린다(웹 Number.isInteger) — 무한 · 아주 큰 수도', () { + expect( + parseSearchRanges([ + [0.5, 1], + [1, 2], + [double.infinity, 3], + [1e300, 1e300], + ]), + _ranges(<(int, int)>[(1, 2)]), + ); + }); + + test('모양이 틀리면 null — 목록이 아님 · 칸이 두 수가 아님', () { + expect(parseSearchRanges(null), isNull); + expect(parseSearchRanges('0,2'), isNull); + expect( + parseSearchRanges([ + [0], + ]), + isNull, + ); + expect( + parseSearchRanges([ + [0, '2'], + ]), + isNull, + ); + expect( + parseSearchRanges([ + [0, 1, 2], + ]), + isNull, + ); + expect(parseSearchRanges([0, 2]), isNull); + }); + }); + + group('검색 결과 항목 (MailSearchItem)', () { + Map item({Object? highlight = _absent}) => + { + ...messageJson('m1', mailboxId: kInboxId, subject: '유지보수 계약 갱신 건'), + if (!identical(highlight, _absent)) 'searchHighlight': highlight, + }; + + test('searchHighlight 칸이 없으면(14단계 앞 서버) 강조 없음 · 강조 쿼리도 싣지 않는다', () { + final MailSearchItem parsed = MailSearchItem.tryParse(item())!; + expect(parsed.highlight, isNull); + expect(parsed.highlightSupported, isFalse); + }); + + test('칸이 null 이면(엔진 방식) 강조 없음 — 그래도 14단계 서버다', () { + final MailSearchItem parsed = MailSearchItem.tryParse( + item(highlight: null), + )!; + expect(parsed.highlight, isNull); + expect(parsed.highlightSupported, isTrue); + }); + + test('값이 있으면 제목 · 미리 보기 한 줄 · 그 맞은 곳을 읽는다', () { + final MailSearchItem parsed = MailSearchItem.tryParse( + item( + highlight: { + 'subject': [ + [5, 7], + ], + 'preview': { + 'text': '…지난달 계약 기간에 맞춰', + 'ranges': [ + [5, 7], + ], + }, + }, + ), + )!; + expect( + parsed.highlight, + MessageListHighlight( + subject: _ranges(<(int, int)>[(5, 7)]), + previewText: '…지난달 계약 기간에 맞춰', + previewRanges: _ranges(<(int, int)>[(5, 7)]), + ), + ); + }); + + test('강조의 모양이 틀리면 강조만 버리고 항목은 살린다 — 목록 칸이 틀리면 항목을 버린다', () { + for (final Object? broken in [ + 'x', + {'subject': [], 'preview': 'x'}, + { + 'subject': ['x'], + 'preview': {'text': 'a', 'ranges': []}, + }, + { + 'subject': [], + 'preview': {'text': 1, 'ranges': []}, + }, + ]) { + final MailSearchItem? parsed = MailSearchItem.tryParse( + item(highlight: broken), + ); + expect(parsed, isNotNull); + expect(parsed!.highlight, isNull); + expect(parsed.highlightSupported, isTrue); + } + expect( + MailSearchItem.tryParse({...item(), 'isRead': 'x'}), + isNull, + ); + }); + }); + + group('읽기 화면의 강조 (MessageDetail.searchHighlight)', () { + Map detailJson(Object? highlight) => { + ...messageJson('m1', mailboxId: kInboxId), + 'textBody': '본문', + 'attachments': [attachmentJson('a1', name: '계약서.pdf')], + 'searchHighlight': highlight, + }; + + test('제목 · 첨부(맞은 첨부만) · 글 본문의 맞은 곳을 읽는다', () { + final MessageDetail detail = MessageDetail.tryParse( + detailJson({ + 'subject': [ + [0, 2], + ], + 'attachments': { + 'a1': [ + [0, 2], + ], + }, + 'textBody': [], + }), + )!; + expect( + detail.searchHighlight, + MessageDetailHighlight( + subject: _ranges(<(int, int)>[(0, 2)]), + attachments: >{ + 'a1': _ranges(<(int, int)>[(0, 2)]), + }, + ), + ); + }); + + test('없거나 모양이 틀리면 강조 없이 메일을 연다', () { + expect(MessageDetail.tryParse(detailJson(null))!.searchHighlight, isNull); + for (final Object? broken in [ + {'subject': [], 'textBody': []}, + { + 'subject': [], + 'attachments': {'a1': 'x'}, + 'textBody': [], + }, + ]) { + final MessageDetail? detail = MessageDetail.tryParse( + detailJson(broken), + ); + expect(detail, isNotNull); + expect(detail!.searchHighlight, isNull); + } + }); + + test('[이미지 보기]의 본문 바꾸기는 강조도 그 응답의 것으로, 요약 바꾸기는 강조를 그대로 둔다', () { + final MessageDetail first = MessageDetail.tryParse( + detailJson({ + 'subject': [ + [0, 2], + ], + 'attachments': {}, + 'textBody': [], + }), + )!; + final MessageDetail plain = MessageDetail.tryParse(detailJson(null))!; + expect(first.withBodyOf(plain).searchHighlight, isNull); + expect( + first.withSummary(plain.summary).searchHighlight, + first.searchHighlight, + ); + }); + }); + + group('강조 쿼리 · 준비 상태', () { + test('읽기 화면에 넘기는 쿼리는 빈 칸을 뺀 다섯 이름뿐이다', () { + expect( + const MessageHighlightQuery(q: '계약').toQueryParameters(), + {'q': '계약'}, + ); + expect( + const MessageHighlightQuery(q: '계약', subject: '견적').toQueryParameters(), + {'q': '계약', 'subject': '견적'}, + ); + expect(const MessageHighlightQuery().isEmpty, isTrue); + }); + + test('준비 상태는 building · ready 만 — 모르는 값은 null(안내 줄을 보이지 않는다)', () { + expect(SearchIndexStatus.parse('building'), SearchIndexStatus.building); + expect(SearchIndexStatus.parse('ready'), SearchIndexStatus.ready); + expect(SearchIndexStatus.parse('paused'), isNull); + expect(SearchIndexStatus.parse(null), isNull); + }); + }); + + group('글 본문의 · 본문 문서의 mark 모양', () { + test('맞은 곳은 속성 없는 — 글은 조각마다 이스케이프한다', () { + expect( + highlightedTextHtml( + '<계약> & 계약서', + _ranges(<(int, int)>[(1, 3), (7, 9)]), + ), + '<계약> & 계약서', + ); + expect(highlightedTextHtml('', const []), '<b>'); + }); + + test('글 본문이면 강조를 넣고, 서식 본문이면 넣지 않는다(서버가 이미 넣었다 — 앱의 정화가 mark 를 남긴다)', () { + final SanitizedMailBody text = mailBodyFragment( + htmlBody: null, + textBody: '계약서를 보냈습니다', + imagesShown: false, + textHighlights: _ranges(<(int, int)>[(0, 2)]), + ); + expect(text.fragment, contains('계약서를')); + final SanitizedMailBody html = mailBodyFragment( + htmlBody: + '

계약서를 보냈습니다

', + textBody: '계약서를 보냈습니다', + imagesShown: false, + textHighlights: _ranges(<(int, int)>[(0, 2)]), + ); + expect( + html.fragment, + '

계약서를 보냈습니다

', + ); + }); + + test('서식 본문이 한도를 넘어 글 본문으로 보일 때도 글 본문의 위치로 강조한다', () { + final SanitizedMailBody omitted = mailBodyFragment( + htmlBody: '
${'
' * 4000}x${'
' * 4000}
', + textBody: '계약 안내', + imagesShown: false, + textHighlights: _ranges(<(int, int)>[(0, 2)]), + ); + expect(omitted.htmlOmitted, isTrue); + expect(omitted.fragment, contains('계약 안내')); + }); + + test('문서의 mark 는 웹과 같은 노랑 · 글자색 그대로 · 모서리 2 · 좌우 1px', () { + expect( + buildMailDocument(''), + contains( + 'mark{background:#FCE7A4;color:inherit;border-radius:2px;padding:0 1px}', + ), + ); + }); + }); +} + +const Object _absent = Object(); diff --git a/flutter/test/features/mail/search_text_test.dart b/flutter/test/features/mail/search_text_test.dart new file mode 100644 index 0000000..91d0b2d --- /dev/null +++ b/flutter/test/features/mail/search_text_test.dart @@ -0,0 +1,256 @@ +import 'package:arasystem_app/features/mail/domain/search_text.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/ts_table.dart'; + +/// 서버 · 웹과 같은 표본 표 — 백엔드 파일을 그대로 읽는다(표는 하나 — 세 쪽 시험이 같은 표를 본다) +const String _samplesPath = '../backend/test/search-text-samples.ts'; + +/// 표의 한 행 — [넣은 글, 답] +List<(String, Object?)> _rows(String name) => <(String, Object?)>[ + for (final Object? row in readTsArrayFile(_samplesPath, name)) + if (row case [final String input, final Object? answer]) + (input, answer) + else + throw FormatException('표 $name 의 행 모양이 다르다: $row'), +]; + +/// 메일 검색의 낱말 나누기 · 접기 · 묶음 · 한 글자 판정 · 한도 (짝: 웹 `search-match.ts` · `mail-search.ts` · 백엔드 `search-text.ts`) +void main() { + group('표 읽기 (test/support/ts_table.dart)', () { + test('작은따옴표 · 큰따옴표 글 · 이스케이프 · 주석 · 끝 쉼표 · 겹친 배열을 읽는다', () { + const String source = r''' +export const SAMPLE: readonly (readonly [a: string, b: readonly string[]])[] = [ + // 주석은 건너뛴다 + ['가\u3000나', ['\n', '\t', '\r', '\\', '\'', "\"", '\u{1F600}']], + /* 막힌 주석 */ ["끝", []], +]; +'''; + expect(readTsArray(source, 'SAMPLE'), [ + [ + '가\u3000나', + ['\n', '\t', '\r', r'\', "'", '"', '\u{1F600}'], + ], + ['끝', []], + ]); + }); + + test('모르는 꼴(템플릿 글 · 수)이나 없는 표는 실패한다 — 표의 모양이 바뀐 것을 알아챈다', () { + expect( + () => readTsArray('export const X = [`a`];', 'X'), + throwsFormatException, + ); + expect( + () => readTsArray('export const X = [1];', 'X'), + throwsFormatException, + ); + expect(() => readTsArray('', 'X'), throwsFormatException); + }); + + test('백엔드 표를 읽는다 — 특별한 행(풀어 쓴 한글 · \u0130 · BMP 밖 글자)이 들어 있다', () { + final List<(String, Object?)> fold = _rows('FOLD_SAMPLES'); + final List<(String, Object?)> grams = _rows('GRAM_SAMPLES'); + expect(fold.length, greaterThanOrEqualTo(10)); + expect(grams.length, greaterThanOrEqualTo(8)); + final List inputs = [ + for (final (String input, Object? _) in fold) input, + ]; + expect(inputs, contains('\u1112\u1161\u11AB\u1100\u1173\u11AF')); + expect(inputs, contains('\u0130stanbul')); + expect(inputs, contains('\u{10400}\u{1F600}')); + }); + }); + + group('foldText (접기 — 표 FOLD_SAMPLES)', () { + test('표의 모든 행 — 넣은 글을 접으면 답과 같다', () { + for (final (String input, Object? folded) in _rows('FOLD_SAMPLES')) { + expect(foldText(input), folded, reason: '행 ${input.runes.toList()}'); + } + }); + + test('접은 글은 (한글을 모은) 원문과 UTF-16 길이가 늘 같다 — BMP 의 모든 글자', () { + for (final (String input, Object? _) in _rows('FOLD_SAMPLES')) { + expect(foldText(input).length, composeHangul(input).length); + } + final List changedLength = []; + for (int code = 0; code <= 0xFFFF; code++) { + if (code >= 0xD800 && code <= 0xDFFF) continue; + final String char = String.fromCharCode(code); + if (foldText(char).length != char.length) changedLength.add(code); + } + expect(changedLength, isEmpty); + }); + + test('두 번 접어도 같다 · 짝이 없는 서로게이트는 그대로 둔다', () { + for (final (String input, Object? folded) in _rows('FOLD_SAMPLES')) { + expect(foldText(folded! as String), folded); + expect(foldText(foldText(input)), folded); + } + expect(foldText('a\uD800B'), 'a\uD800b'); + expect(foldText('\uDC00A'), '\uDC00a'); + }); + }); + + group('gramsOf (글자 두 개 묶음 — 표 GRAM_SAMPLES)', () { + test('표의 모든 행 — 나온 차례대로 겹치지 않게', () { + for (final (String fold, Object? grams) in _rows('GRAM_SAMPLES')) { + expect(gramsOf(fold).toList(), grams, reason: '행 $fold'); + } + }); + + test( + '공백은 정규식 \\s 와 같은 글자다(낱말 나누기와 같다) — Dart 의 trim 이 지우는 U+0085 는 아니다', + () { + final RegExp space = RegExp(r'\s', unicode: true); + final List differ = []; + for (int code = 0; code <= 0xFFFF; code++) { + if (code >= 0xD800 && code <= 0xDFFF) continue; + if (isSearchSpaceCode(code) != + space.hasMatch(String.fromCharCode(code))) { + differ.add(code); + } + } + expect(differ, isEmpty); + expect(isSearchSpaceCode(0x85), isFalse); + }, + ); + }); + + group('parseSearchTerms (검색어 나누기 — 짝: 백엔드 search-query.ts)', () { + List texts(String query) => [ + for (final SearchTerm term in parseSearchTerms(query)) term.text, + ]; + + test('띄어쓰기로 낱말을 나눈다 — 여러 칸 · 전각 공백 · 줄바꿈도 한 번의 띄어쓰기', () { + expect(parseSearchTerms(' 서버 이전\u3000일정\n공유 '), [ + (kind: SearchTermKind.word, text: '서버'), + (kind: SearchTermKind.word, text: '이전'), + (kind: SearchTermKind.word, text: '일정'), + (kind: SearchTermKind.word, text: '공유'), + ]); + }); + + test('큰따옴표 안은 구절 하나다 — 짝이 없는 마지막 따옴표는 없는 것으로 본다', () { + expect(parseSearchTerms('회의 "일정 공유" 서버'), [ + (kind: SearchTermKind.word, text: '회의'), + (kind: SearchTermKind.phrase, text: '일정 공유'), + (kind: SearchTermKind.word, text: '서버'), + ]); + expect(texts('회의 "일정 공유'), ['회의', '일정', '공유']); + expect(parseSearchTerms('"a b" "c'), [ + (kind: SearchTermKind.phrase, text: 'a b'), + (kind: SearchTermKind.word, text: 'c'), + ]); + }); + + test('문자 · 숫자가 없는 낱말 · 구절은 버리고, 낱말 앞뒤의 문장부호는 뗀다 — 같은 낱말은 한 번', () { + for (final String query in [ + '-', + '…', + '- … !!', + '"- …"', + '\u{1F600}', + '" "', + '""', + ]) { + expect(parseSearchTerms(query), isEmpty, reason: query); + } + expect(texts('회의, (회의) 회의. “회의” [공지] 2026. 회의!'), [ + '회의', + '공지', + '2026', + ]); + expect(parseSearchTerms('"회의, 일정."'), [ + (kind: SearchTermKind.phrase, text: '회의, 일정.'), + ]); + expect(parseSearchTerms('회의 회의 "일정" "일정" 일정'), [ + (kind: SearchTermKind.word, text: '회의'), + (kind: SearchTermKind.phrase, text: '일정'), + (kind: SearchTermKind.word, text: '일정'), + ]); + }); + + test('글자에 붙은 결합 문자는 남기고, 그림 문자와 그 이체 선택자는 함께 뗀다', () { + expect(texts('cafe\u0301!'), ['cafe\u0301']); + expect(texts('회의\u{1F600}\uFE0F'), ['회의']); + }); + + test('풀어 쓴 한글은 음절로 모으고 NUL 은 U+FFFD 로 바꾼다(서버의 색인과 같다)', () { + expect( + parseSearchTerms('\u1112\u1161\u11AB\u1100\u1173\u11AF'), + [(kind: SearchTermKind.word, text: '한글')], + ); + expect(parseSearchTerms('계\u0000약'), [ + (kind: SearchTermKind.word, text: '계\uFFFD약'), + ]); + }); + }); + + group('isSingleCharSearch (한 글자 낱말뿐인 검색 — 서버가 본문을 찾지 않는다)', () { + test('낱말이 모두 한 글자면 그렇다 — 여러 칸을 합쳐 본다', () { + expect(isSingleCharSearch(['김']), isTrue); + expect(isSingleCharSearch(['김 a 3']), isTrue); + expect(isSingleCharSearch(['김', '', '', '', '박']), isTrue); + // 한 글자 낱말로만 된 구절도 묶음이 없다 + expect(isSingleCharSearch(['"가 나"']), isTrue); + }); + + test('두 글자 이상 낱말이 하나라도 있으면 아니다 — 다른 칸에 있어도', () { + expect(isSingleCharSearch(['계약']), isFalse); + expect(isSingleCharSearch(['김 계약']), isFalse); + expect(isSingleCharSearch(['김', '', '', '견적', '']), isFalse); + expect(isSingleCharSearch(['"가나"']), isFalse); + // 풀어 쓴 한 글자도 모은 뒤 센다 + expect(isSingleCharSearch(['\u1100\u1161']), isTrue); + }); + + test('찾을 낱말이 없으면(빈 글 · 문장부호뿐) 아니다 — 앞뒤 문장부호는 뗀 뒤 센다', () { + expect(isSingleCharSearch([]), isFalse); + expect(isSingleCharSearch(['', ' ']), isFalse); + expect(isSingleCharSearch(['- …']), isFalse); + expect(isSingleCharSearch(['(김)']), isTrue); + }); + }); + + group('검색어 다듬기 · 한도', () { + test( + '앞뒤 공백(전각 공백 · 줄바꿈 포함)을 빼고 200자(UTF-16)에서 자른다 — U+0085 는 지우지 않는다(JS trim 과 같다)', + () { + expect(normalizeSearchText(' 계약 \u3000\n'), '계약'); + expect(normalizeSearchText('\u0085계약'), '\u0085계약'); + expect(normalizeSearchText('가' * 250).length, kMailSearchMaxLength); + expect(normalizeSearchText(''), ''); + expect(normalizeSearchText(' \u00A0 '), ''); + }, + ); + + test('이모지를 가운데서 자르게 되면 그 글자를 뺀다 · 자른 뒤 끝 공백도 뺀다', () { + final String cut = normalizeSearchText('${'가' * 199}\u{1F600}'); + expect(cut, '가' * 199); + expect(normalizeSearchText('${'가' * 199} 나').length, 199); + }); + + test('낱말 수는 따옴표와 상관없이 공백으로 나눈 조각이다(서버와 같은 기준)', () { + expect(countSearchWords('계약'), 1); + expect(countSearchWords(' "유지보수 계약" 갱신\u3000건 '), 4); + expect(countSearchWords(''), 0); + expect(countSearchWords('11월 정기 점검 일정 안내 메일 다시 보내 주세요'), 9); + }); + }); + + group('composeHangul (한글 자모 모으기 — NFC 의 한글 부분)', () { + test('첫소리 + 가운뎃소리(+ 끝소리)를 음절로, 받침 없는 음절 + 끝소리도 모은다', () { + expect(composeHangul('\u1112\u1161\u11AB'), '한'); + expect(composeHangul('가\u11AB'), '간'); + expect(composeHangul('a\u1100\u1161b'), 'a가b'); + }); + + test('모을 것이 없으면 같은 글 · 짝이 없는 자모는 그대로 · 받침 있는 음절에는 끝소리를 더 붙이지 않는다', () { + const String plain = '계약서 \u{1F600}'; + expect(identical(composeHangul(plain), plain), isTrue); + expect(composeHangul('\u1100'), '\u1100'); + expect(composeHangul('\u1161'), '\u1161'); + expect(composeHangul('간\u11AB'), '간\u11AB'); + }); + }); +} diff --git a/flutter/test/features/settings/settings_page_test.dart b/flutter/test/features/settings/settings_page_test.dart new file mode 100644 index 0000000..1432494 --- /dev/null +++ b/flutter/test/features/settings/settings_page_test.dart @@ -0,0 +1,460 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/api_paths.dart'; +import 'package:arasystem_app/core/push/notification_system.dart'; +import 'package:arasystem_app/core/push/push_messaging.dart'; +import 'package:arasystem_app/core/push/push_preferences.dart'; +import 'package:arasystem_app/core/push/push_texts.dart'; +import 'package:arasystem_app/features/auth/presentation/pages/login_page.dart'; +import 'package:arasystem_app/features/settings/presentation/settings_texts.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/fakes.dart'; +import '../../support/login_fakes.dart'; +import '../../support/push_screen_harness.dart'; + +/// 설정 탭 — 앱 보드 `App1Shell`(설정 한 화면 · 로그아웃 확인 창)과 `App1Push` ④(새 메일 알림 줄) +void main() { + late PushScreenHarness h; + + setUp(() => h = PushScreenHarness()); + + /// 문의 연락처를 답한다 — 부르지 않으면 가짜 서버가 404 로 답한다(연락처 없음) + void supportContact(String? text) { + h.server.on( + 'GET', + ApiPaths.supportContact, + (_) => FakeResponse.ok({'text': text}), + ); + } + + Finder dialog() => find.byType(AlertDialog); + + Finder inDialog(String text) => + find.descendant(of: dialog(), matching: find.text(text)); + + Finder signOutButton() => + find.widgetWithText(OutlinedButton, SettingsTexts.signOut); + + Future tapVisible(WidgetTester tester, Finder finder) async { + await tester.ensureVisible(finder); + await tester.pumpAndSettle(); + await tester.tap(finder); + await settle(tester); + } + + group('설정 한 화면', () { + testWidgets('절 — 내 정보(이름 · 주소) · 알림 · 앱 정보(판) · 문의(연락처) · [로그아웃]', ( + WidgetTester tester, + ) async { + supportContact('정보보안팀 02-1234-5678'); + await h.pump(tester); + + await h.openSettings(tester); + + expect(h.app.location(), '/settings'); + for (final String title in [ + SettingsTexts.account, + SettingsTexts.notifications, + SettingsTexts.about, + SettingsTexts.support, + ]) { + expect(find.text(title), findsOneWidget, reason: title); + } + expect(find.text('김아라'), findsOneWidget); + expect(find.text(kTestEmail), findsOneWidget); + expect(find.text(PushTexts.rowLabel), findsOneWidget); + expect(find.text(SettingsTexts.appVersion), findsOneWidget); + expect(find.text(kPushScreenAppVersion), findsOneWidget); + expect(find.text(SettingsTexts.licenses), findsOneWidget); + expect( + findCopy('${SettingsTexts.supportLead}\n정보보안팀 02-1234-5678'), + findsOneWidget, + ); + expect(signOutButton(), findsOneWidget); + }); + + testWidgets('연락처가 없으면 관리자에게 문의해 달라고 한다', (WidgetTester tester) async { + supportContact(null); + await h.pump(tester); + + await h.openSettings(tester); + + expect(findCopy(SettingsTexts.supportFallback), findsOneWidget); + expect(h.notifier.shownCodes, isEmpty); + }); + + testWidgets('[오픈소스 라이선스]는 탭 막대 없이 전체 화면으로 연다', (WidgetTester tester) async { + await h.pump(tester); + await h.openSettings(tester); + + await tester.ensureVisible(find.text(SettingsTexts.licenses)); + await tester.pumpAndSettle(); + await tester.tap(find.text(SettingsTexts.licenses)); + // 라이선스 화면은 읽는 동안 둥근 표시가 돈다 — 화면 넘김만 기다린다 + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.byType(LicensePage), findsOneWidget); + expect(find.byType(NavigationBar), findsNothing); + }); + }); + + group('[로그아웃] — 확인 창부터', () { + testWidgets('처음 초점은 [취소] — [취소]면 머물고 로그아웃하지 않는다', ( + WidgetTester tester, + ) async { + await h.pump(tester); + await h.openSettings(tester); + + await tapVisible(tester, signOutButton()); + + expect(inDialog(SettingsTexts.signOutTitle), findsOneWidget); + expect( + find.descendant( + of: dialog(), + matching: findCopy(SettingsTexts.signOutMessage), + ), + findsOneWidget, + ); + final TextButton cancel = tester.widget( + find.widgetWithText(TextButton, SettingsTexts.cancel), + ); + expect(cancel.autofocus, isTrue); + + await tester.tap(inDialog(SettingsTexts.cancel)); + await settle(tester); + + expect(dialog(), findsNothing); + expect(h.app.location(), '/settings'); + expect(h.server.received('POST', ApiPaths.appLogout), isEmpty); + expect(h.messaging.unregisterCalls, 0); + }); + + testWidgets('[로그아웃]을 고르면 로그인 화면 — 이 휴대폰의 알림 등록도 푼다(서버 줄은 서버가 지운다)', ( + WidgetTester tester, + ) async { + await h.pump(tester); + expect(h.push.puts, hasLength(1)); + await h.openSettings(tester); + + await tapVisible(tester, signOutButton()); + await tester.tap(inDialog(SettingsTexts.signOut)); + await settle(tester); + + expect(find.byType(LoginPage), findsOneWidget); + expect(h.server.received('POST', ApiPaths.appLogout), hasLength(1)); + expect(h.messaging.unregisterCalls, 1); + expect(h.push.deletes, isEmpty, reason: '로그아웃은 DELETE 를 부르지 않는다'); + expect(h.notifications.clears, greaterThan(0)); + }); + }); + + group('설정 › 새 메일 알림 줄', () { + Finder switchFinder() => find.byType(Switch); + + Switch switchOf(WidgetTester tester) => + tester.widget(switchFinder()); + + Finder openSettingsButton() => + find.widgetWithText(TextButton, PushTexts.openSettings); + + Finder snack(String text) => + find.descendant(of: find.byType(SnackBar), matching: findCopy(text)); + + void lifecycle(WidgetTester tester, List states) { + for (final AppLifecycleState state in states) { + tester.binding.handleAppLifecycleStateChanged(state); + } + } + + /// 휴대폰 설정에 다녀온다(앱이 뒤로 갔다 앞으로 온다) + Future visitPhoneSettings(WidgetTester tester) async { + lifecycle(tester, const [ + AppLifecycleState.inactive, + AppLifecycleState.hidden, + AppLifecycleState.paused, + ]); + await tester.pump(); + lifecycle(tester, const [ + AppLifecycleState.hidden, + AppLifecycleState.inactive, + AppLifecycleState.resumed, + ]); + await settle(tester); + } + + testWidgets('켜짐 — 스위치를 "새 메일 알림"으로 읽고 줄 아래 글을 설명으로 읽는다', ( + WidgetTester tester, + ) async { + final SemanticsHandle semantics = tester.ensureSemantics(); + await h.pump(tester); + await h.openSettings(tester); + + expect(switchOf(tester).value, isTrue); + expect(switchOf(tester).onChanged, isNotNull); + expect(findCopy(PushTexts.rowDescription), findsOneWidget); + expect(openSettingsButton(), findsNothing); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect( + tester.getSemantics(switchFinder()), + isSemantics( + label: PushTexts.rowLabel, + hint: PushTexts.rowDescription, + hasToggledState: true, + isToggled: true, + hasEnabledState: true, + isEnabled: true, + ), + ); + semantics.dispose(); + }); + + testWidgets('끄기 — 끄는 중(둥근 표시)에는 다시 받지 않고, 서버 등록을 지운 뒤 꺼진다', ( + WidgetTester tester, + ) async { + final SemanticsHandle semantics = tester.ensureSemantics(); + await h.pump(tester); + await h.openSettings(tester); + h.push.deleteGate = Completer(); + + await tester.tap(switchFinder()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(h.push.deletes, hasLength(1)); + expect(switchOf(tester).value, isFalse, reason: '바꿀 값 쪽에 둔다'); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.bySemanticsLabel(PushTexts.turningOff), findsOneWidget); + + // 끄는 중에 다시 눌러도 겹쳐 보내지 않는다 + await tester.tap(switchFinder()); + await tester.pump(const Duration(milliseconds: 100)); + expect(h.push.deletes, hasLength(1)); + + h.push.deleteGate!.complete(); + await settle(tester); + + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(switchOf(tester).value, isFalse); + expect(h.prefs.values.enabled, isFalse); + expect(h.notifier.shownCodes, isEmpty); + semantics.dispose(); + }); + + testWidgets('켜기(이미 허용) — 켜는 중을 보이고, 서버에 등록한 뒤 켜진다', ( + WidgetTester tester, + ) async { + final SemanticsHandle semantics = tester.ensureSemantics(); + h.prefs.values = const PushPreferenceValues(enabled: false); + await h.pump(tester); + await h.openSettings(tester); + expect(switchOf(tester).value, isFalse); + expect(h.push.puts, isEmpty, reason: '끈 휴대폰은 등록하지 않는다'); + h.push.putGate = Completer(); + + await tester.tap(switchFinder()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(h.push.puts, hasLength(1)); + expect(switchOf(tester).value, isTrue, reason: '바꿀 값 쪽에 둔다'); + expect(find.bySemanticsLabel(PushTexts.turningOn), findsOneWidget); + + h.push.putGate!.complete(); + await settle(tester); + + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(switchOf(tester).value, isTrue); + expect(h.prefs.values.enabled, isTrue); + expect(h.push.puts.single.body['fid'], 'fid-1'); + expect(h.messaging.permissionRequests, 0, reason: '이미 허용이면 묻지 않는다'); + semantics.dispose(); + }); + + testWidgets('켜기가 서버에서 실패하면 꺼진 채로 돌아가고 그 코드의 사전 문구를 알린다', ( + WidgetTester tester, + ) async { + h.prefs.values = const PushPreferenceValues(enabled: false); + await h.pump(tester); + await h.openSettings(tester); + h.push.putFailure = FakeResponse.fail(500, 'SYS_001'); + + await tester.tap(switchFinder()); + await settle(tester); + + expect(switchOf(tester).value, isFalse); + expect(h.prefs.values.enabled, isFalse); + expect(h.notifier.shownCodes, ['SYS_001']); + }); + + testWidgets('허락 전(처음 값 켬)에서 켜고 [허용]했는데 서버에서 실패하면 꺼진 채로 돌아간다', ( + WidgetTester tester, + ) async { + h.messaging.current = PushPermission.notAsked; + await h.pump(tester); + await h.openSettings(tester); + expect(h.prefs.values.enabled, isTrue, reason: '처음 값'); + h.push.putFailure = FakeResponse.fail(503, 'MAIL_001'); + + await tester.tap(switchFinder()); + await settle(tester); + + expect(h.messaging.permissionRequests, 1); + expect(switchOf(tester).value, isFalse); + expect(switchOf(tester).onChanged, isNotNull); + expect( + findCopy(PushTexts.rowDescription), + findsOneWidget, + reason: '허락은 받았다', + ); + expect(h.prefs.values.enabled, isFalse); + expect(h.notifier.shownCodes, ['MAIL_001']); + }); + + testWidgets('허락 전 — 켜면 휴대폰이 묻고, [허용]이면 켜진다', (WidgetTester tester) async { + h.messaging.current = PushPermission.notAsked; + await h.pump(tester); + await h.openSettings(tester); + expect(switchOf(tester).value, isFalse); + expect(switchOf(tester).onChanged, isNotNull); + expect(findCopy(PushTexts.rowNotAsked), findsOneWidget); + expect(h.push.puts, isEmpty); + + await tester.tap(switchFinder()); + await settle(tester); + + expect(h.messaging.permissionRequests, 1); + expect(switchOf(tester).value, isTrue); + expect(findCopy(PushTexts.rowDescription), findsOneWidget); + expect(h.push.puts, hasLength(1)); + }); + + testWidgets('허락 전 — [허용 안 함]이면 꺼진 채 허락 전 글 그대로, 다시 켜면 또 묻는다', ( + WidgetTester tester, + ) async { + h.messaging + ..current = PushPermission.notAsked + ..answer = PushPermission.denied; + await h.pump(tester); + await h.openSettings(tester); + + await tester.tap(switchFinder()); + await settle(tester); + + expect(h.messaging.permissionRequests, 1); + expect(switchOf(tester).value, isFalse); + expect(findCopy(PushTexts.rowNotAsked), findsOneWidget); + expect(h.push.puts, isEmpty); + + await tester.tap(switchFinder()); + await settle(tester); + expect(h.messaging.permissionRequests, 2); + }); + + testWidgets('휴대폰에서 막힘 — 스위치는 누를 수 없고 [설정 열기], 다녀와서 허용됐으면 켜진다', ( + WidgetTester tester, + ) async { + h.messaging.current = PushPermission.blocked; + await h.pump(tester); + await h.openSettings(tester); + + expect(switchOf(tester).value, isFalse); + expect(switchOf(tester).onChanged, isNull); + expect(findCopy(PushTexts.rowBlocked), findsOneWidget); + expect(openSettingsButton(), findsOneWidget); + expect(h.push.puts, isEmpty); + + await tester.tap(openSettingsButton()); + await settle(tester); + expect(h.notifications.settingsOpened, 1); + expect(snack(PushTexts.openSettingsFailed), findsNothing); + + h.messaging.current = PushPermission.granted; + await visitPhoneSettings(tester); + + expect(switchOf(tester).value, isTrue); + expect(openSettingsButton(), findsNothing); + expect(h.push.puts, hasLength(1)); + }); + + testWidgets('[설정 열기]가 휴대폰 설정을 열지 못하면 찾아가는 길을 알린다', ( + WidgetTester tester, + ) async { + h.messaging.current = PushPermission.blocked; + h.notifications.opens = false; + await h.pump(tester); + await h.openSettings(tester); + + await tester.tap(openSettingsButton()); + await settle(tester); + + expect(snack(PushTexts.openSettingsFailed), findsOneWidget); + }); + + testWidgets('새 메일 알림만 꺼짐 — 스위치는 누를 수 없고 [설정 열기], 다녀와서 켜졌으면 켜진다', ( + WidgetTester tester, + ) async { + h.notifications.current = const NotificationStatus( + appEnabled: true, + channelEnabled: false, + sdkInt: 34, + ); + await h.pump(tester); + await h.openSettings(tester); + + expect(switchOf(tester).value, isFalse); + expect(switchOf(tester).onChanged, isNull); + expect(findCopy(PushTexts.rowChannelOff), findsOneWidget); + expect(openSettingsButton(), findsOneWidget); + expect(h.push.puts, isEmpty); + + h.notifications.current = const NotificationStatus( + appEnabled: true, + channelEnabled: true, + sdkInt: 34, + ); + await visitPhoneSettings(tester); + + expect(switchOf(tester).value, isTrue); + expect(findCopy(PushTexts.rowDescription), findsOneWidget); + expect(h.push.puts, hasLength(1)); + }); + + testWidgets('서버가 보낼 수 없으면 — 스위치는 누를 수 없고 쓸 수 없다는 글, 등록하지 않는다', ( + WidgetTester tester, + ) async { + h.push.config = { + 'minSupportedVersion': {'android': '1.0.0'}, + 'pushAvailable': false, + }; + await h.pump(tester); + await h.openSettings(tester); + + expect(switchOf(tester).value, isFalse); + expect(switchOf(tester).onChanged, isNull); + expect(findCopy(PushTexts.rowUnavailable), findsOneWidget); + expect(openSettingsButton(), findsNothing); + // 앱 설정은 세션 되살리기(미리 갱신)를 기다리지 않고 먼저 온다 — 등록하지 않는다. 앞으로 와도 그대로 + expect(h.push.puts, isEmpty); + await visitPhoneSettings(tester); + expect(h.push.puts, isEmpty); + expect(h.push.deletes, isEmpty); + }); + + testWidgets('받을 수 없는 휴대폰이면 — 스위치는 누를 수 없고 받을 수 없다는 글', ( + WidgetTester tester, + ) async { + h.messaging.available = false; + await h.pump(tester); + await h.openSettings(tester); + + expect(switchOf(tester).value, isFalse); + expect(switchOf(tester).onChanged, isNull); + expect(findCopy(PushTexts.rowUnsupported), findsOneWidget); + expect(h.messaging.registerCalls, 0); + expect(h.push.puts, isEmpty); + }); + }); +} diff --git a/flutter/test/support/compose_fakes.dart b/flutter/test/support/compose_fakes.dart new file mode 100644 index 0000000..caf083c --- /dev/null +++ b/flutter/test/support/compose_fakes.dart @@ -0,0 +1,377 @@ +import 'dart:async'; + +import 'package:arasystem_app/features/mail/data/repositories/compose_repository.dart'; +import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart'; +import 'package:dio/dio.dart'; + +import 'fakes.dart'; +import 'mail_fakes.dart'; + +/// 받은 발송 하나 — 본문과 중복 발송 방지 키 +typedef SentRequest = ({Map body, String? key}); + +/// 쓰기 화면의 API 를 흉내 내는 가짜 뒤쪽 — 임시 저장(POST · PUT · GET) · 발송 · 서명 · 서명 그림 · 보내기 설정 · +/// 받는 사람 추천 · 주소록 그룹 · 구성원 · 대용량 파일 상태 · 첨부 올리기 · 첨부 한도. +/// +/// 저장본은 메모리에 둔다 — PUT 은 새 id 로 바꾸고 옛것을 지운다(계약 2절). 실패([draftFailure] · [sendFailure])와 +/// 문([saveGate] · [sendGate])으로 응답을 늦추거나 실패시킨다. +class FakeComposeBackend { + FakeComposeBackend(this.server, this.mail); + + final FakeServer server; + final FakeMailBackend mail; + + /// 저장본 — id 마다 계약의 `Draft` 칸 + final Map> drafts = + >{}; + + /// 받은 저장 요청 차례대로 — `POST` · `PUT {id}` 와 본문 + final List<({String call, Map body})> saves = + <({String call, Map body})>[]; + + /// 받은 발송 차례대로 + final List sends = []; + + /// 다음 저장들이 이 응답으로 실패한다(차례대로 하나씩 쓴다) + final List draftFailures = []; + + /// 다음 발송들이 이 응답으로 실패한다(차례대로 하나씩 쓴다) + final List sendFailures = []; + + /// 있으면 저장 응답이 이것이 열릴 때까지 기다린다(한 번 쓰면 비운다) + Completer? saveGate; + + /// 있으면 발송 응답이 이것이 열릴 때까지 기다린다(한 번 쓰면 비운다) + Completer? sendGate; + + /// 발송의 붙잡기 — 보내기 취소를 기다리는 초(0 이면 바로 보냈다) + int undoSeconds = 10; + + /// 받지 못한 사람 — 발송 응답의 `rejectedRecipients` + List> rejected = >[]; + + /// 내 서명 — null 이면 없음(404) + Map? signature; + + /// 서명 그림 — id 마다 바이트 + final Map> signatureImages = >{}; + + /// 받는 사람 추천 — 받아 두기(q 없음)와 묻기(q)에 같은 것을 준다(q 는 이름 · 주소에 든 것만) + List> suggestions = >[]; + + /// 받아 두기(q 없음)의 다음 쪽이 있다 — 있으면 쓰기 화면이 모자랄 때 서버에 q 로 묻는다 + bool suggestionsHasNext = false; + + /// 주소록 그룹과 그 구성원(`Contact` 꼴) + List> groups = >[]; + final Map>> members = + >>{}; + + /// 대용량 파일의 지금 상태 — id 마다(없으면 404) + final Map> largeFiles = + >{}; + + /// 있으면 대용량 파일 상태 응답이 이것이 열릴 때까지 기다린다(한 번 쓰면 비운다) + Completer? largeFileGate; + + /// 받은 첨부 올리기 차례대로 — multipart 의 이름 · 형식 · 길이 + final List<({String name, String type, int length})> uploads = + <({String name, String type, int length})>[]; + + /// 다음 올리기들이 이 응답으로 실패한다(차례대로 하나씩 쓴다) + final List uploadFailures = []; + + /// 올리기 응답이 차례대로 이것들이 열릴 때까지 기다린다(온 차례대로 하나씩 쓴다 — 없으면 바로 답한다) + final List> uploadGates = >[]; + + /// 첨부 한도 응답(`MailQuota`) — null 이면 받지 못한다(503 `MAIL_001`) + Map? quota = quotaJson(); + + /// 있으면 올리기 응답의 크기를 이것으로 준다(서버가 센 크기 — 없으면 보낸 길이) + int? uploadedSize; + + /// 있으면 첨부 한도 응답이 이것이 열릴 때까지 기다린다(한 번 쓰면 비운다) + Completer? quotaGate; + + int _nextDraft = 0; + int _nextSent = 0; + int _nextUpload = 0; + + static final RegExp _draftPath = RegExp(r'^/drafts/([^/]+)$'); + static final RegExp _imagePath = RegExp(r'^/mail-signature/images/([^/]+)$'); + static final RegExp _filePath = RegExp(r'^/file-uploads/([^/]+)$'); + + String _idOf(RegExp pattern, String path) => + Uri.decodeComponent(pattern.firstMatch(path)!.group(1)!); + + List get suggestionRequests => + server.received('GET', ComposeApiPaths.recipientSuggestions); + + /// 저장본 하나를 둔다 — 칸은 계약 2절 `Draft` + void putDraft( + String id, { + List> to = const >[], + List> cc = const >[], + List> bcc = const >[], + String subject = '쓰던 메일', + String body = '', + String? bodyHtml, + List> attachments = const >[], + String? inReplyTo, + List> largeFiles = const >[], + }) { + drafts[id] = { + 'id': id, + 'to': to, + 'cc': cc, + 'bcc': bcc, + 'subject': subject, + 'body': body, + 'bodyHtml': bodyHtml, + 'attachments': attachments, + 'inReplyTo': inReplyTo, + 'updatedAt': kTestNow.toUtc().toIso8601String(), + 'largeFiles': largeFiles, + }; + } + + /// 경로마다 처리기를 건다 + void install() { + server.on('POST', ComposeApiPaths.drafts, (RequestOptions options) { + return _save('POST', options, null); + }); + server.onPattern('PUT', _draftPath, (RequestOptions options) { + final String id = _idOf(_draftPath, options.path); + return _save('PUT $id', options, id); + }); + server.onPattern('GET', _draftPath, (RequestOptions options) { + final Map? draft = + drafts[_idOf(_draftPath, options.path)]; + return draft == null + ? FakeResponse.fail(404, 'RES_001') + : FakeResponse.ok(draft); + }); + server.on('POST', MailApiPaths.messages, _send); + server.on('GET', ComposeApiPaths.mailSignature, (_) { + final Map? current = signature; + return current == null + ? FakeResponse.fail(404, 'RES_001') + : FakeResponse.ok(current); + }); + server.onPattern('GET', _imagePath, (RequestOptions options) { + final List? bytes = signatureImages[_idOf(_imagePath, options.path)]; + return bytes == null + ? FakeResponse.fail(404, 'RES_001') + : FakeResponse.bytes(bytes, contentType: 'image/png'); + }); + server.on( + 'GET', + ComposeApiPaths.mailSettings, + (_) => FakeResponse.ok({'undoSendSeconds': undoSeconds}), + ); + server.on('GET', ComposeApiPaths.recipientSuggestions, ( + RequestOptions options, + ) { + final Object? q = options.queryParameters['q']; + final List> items = q is String + ? >[ + for (final Map item in suggestions) + if ('${item['name']} ${item['email']}'.contains(q)) item, + ] + : suggestions; + final bool hasNext = q is! String && suggestionsHasNext; + return FakeResponse.ok({ + 'items': items, + 'pageInfo': { + 'hasNext': hasNext, + 'nextCursor': hasNext ? 'next' : null, + }, + }); + }); + server.on( + 'GET', + ComposeApiPaths.contactGroups, + (_) => FakeResponse.ok({ + 'items': groups, + 'pageInfo': {'hasNext': false, 'nextCursor': null}, + }), + ); + server.on('GET', ComposeApiPaths.contacts, (RequestOptions options) { + final Object? groupId = options.queryParameters['groupId']; + final List>? list = members[groupId]; + if (list == null) return FakeResponse.fail(404, 'RES_001'); + return FakeResponse.ok({ + 'items': list, + 'pageInfo': {'hasNext': false, 'nextCursor': null}, + }); + }); + server.on('POST', ComposeApiPaths.mailAttachments, _upload); + server.on('GET', ComposeApiPaths.mailQuota, (_) async { + final Completer? gate = quotaGate; + if (gate != null) { + quotaGate = null; + await gate.future; + } + final Map? current = quota; + return current == null + ? FakeResponse.fail(503, 'MAIL_001') + : FakeResponse.ok(current); + }); + server.onPattern('GET', _filePath, (RequestOptions options) async { + final Completer? gate = largeFileGate; + if (gate != null) { + largeFileGate = null; + await gate.future; + } + final Map? file = + largeFiles[_idOf(_filePath, options.path)]; + return file == null + ? FakeResponse.fail(404, 'RES_001') + : FakeResponse.ok(file); + }); + } + + Future _save( + String call, + RequestOptions options, + String? previousId, + ) async { + final Map body = options.data! as Map; + saves.add((call: call, body: body)); + final Completer? gate = saveGate; + if (gate != null) { + saveGate = null; + await gate.future; + } + if (draftFailures.isNotEmpty) return draftFailures.removeAt(0); + if (previousId != null && !drafts.containsKey(previousId)) { + return FakeResponse.fail(404, 'RES_001'); + } + if (previousId != null) drafts.remove(previousId); + final String id = 'saved-${++_nextDraft}'; + final List attachments = [ + for (final Map item + in (body['attachments']! as List) + .cast>()) + attachmentJson('$id-${item['id']}', name: item['name']! as String), + ]; + drafts[id] = { + ...body, + 'id': id, + 'attachments': attachments, + 'updatedAt': kTestNow.toUtc().toIso8601String(), + 'largeFiles': [ + for (final Object? fileId + in (body['largeFileIds'] as List?) ?? []) + { + 'id': fileId, + 'status': 'ready', + 'name': '$fileId.zip', + 'size': 1, + }, + ], + }; + return FakeResponse.ok({ + 'id': id, + 'attachments': attachments, + 'updatedAt': kTestNow.toUtc().toIso8601String(), + }); + } + + Future _upload(RequestOptions options) async { + final MultipartFile file = (options.data! as FormData).files.single.value; + final String name = file.filename ?? ''; + uploads.add(( + name: name, + type: file.contentType.toString(), + length: file.length, + )); + if (uploadGates.isNotEmpty) await uploadGates.removeAt(0).future; + if (uploadFailures.isNotEmpty) return uploadFailures.removeAt(0); + return FakeResponse(201, { + 'success': true, + 'data': attachmentJson( + 'up-${++_nextUpload}', + name: serverFileName(name), + type: file.contentType.toString(), + size: uploadedSize ?? file.length, + ), + }); + } + + Future _send(RequestOptions options) async { + final Map body = options.data! as Map; + sends.add(( + body: body, + key: options.headers[kIdempotencyHeader] as String?, + )); + final Completer? gate = sendGate; + if (gate != null) { + sendGate = null; + await gate.future; + } + if (sendFailures.isNotEmpty) return sendFailures.removeAt(0); + final Object? draftId = body['draftId']; + if (draftId is String) drafts.remove(draftId); + final String id = 'sent-${++_nextSent}'; + final DateTime now = server.serverTime ?? kTestNow; + // 보낸편지함에 넣어 둔다 — [보내기 취소]가 그 메일을 임시보관함으로 옮긴다 + mail.put( + kSentId, + messageJson( + id, + mailboxId: kSentId, + subject: body['subject']! as String, + pendingSend: undoSeconds > 0 + ? pendingJson(now.add(Duration(seconds: undoSeconds))) + : null, + ), + ); + if (undoSeconds > 0) { + // 취소하면 보낸 내용 그대로 임시 저장본이 된다 + drafts[id] = { + ...body, + 'id': id, + 'attachments': [], + 'updatedAt': now.toUtc().toIso8601String(), + 'largeFiles': [], + }..remove('draftId'); + } + return FakeResponse(201, { + 'success': true, + 'data': { + 'id': id, + 'rejectedRecipients': rejected, + 'pendingSend': undoSeconds > 0 + ? pendingJson(now.add(Duration(seconds: undoSeconds))) + : null, + }, + }); + } +} + +/// 첨부 한도 응답(`MailQuota`) — 기본은 서버 기본값(20 · 25MB) +Map quotaJson({ + int maxBytes = 20 * 1024 * 1024, + int totalMaxBytes = 25 * 1024 * 1024, +}) => { + 'usedBytes': 0, + 'limitBytes': null, + 'attachmentMaxBytes': maxBytes, + 'attachmentTotalMaxBytes': totalMaxBytes, +}; + +/// 서버가 받은 첨부 이름을 줄이는 모양 — 백엔드 `shared/utils/file-name.ts` 의 `sanitizeFileName` 처럼 255 코드 포인트까지, 확장자는 남긴다 +String serverFileName(String name) { + const int maxLength = 255; + final List runes = name.runes.toList(); + if (runes.length <= maxLength) return name; + final int dot = name.lastIndexOf('.'); + final List extension = dot > 0 + ? name.substring(dot).runes.toList() + : []; + return String.fromCharCodes([ + ...runes.take(maxLength - extension.length), + ...extension, + ]); +} diff --git a/flutter/test/support/fake_webview.dart b/flutter/test/support/fake_webview.dart new file mode 100644 index 0000000..f762ba8 --- /dev/null +++ b/flutter/test/support/fake_webview.dart @@ -0,0 +1,134 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart'; + +/// 가짜 WebView — 시험 기기(Windows · 머리 없는 시험)에는 WebView 가 없다. +/// +/// 읽기 화면이 실제로 하는 일(스크립트 끄기 · 탐색 판정 걸기 · 문서 넣기)을 적어 두고, 시험이 링크를 누른 것처럼 탐색을 물을 수 있다. +/// 안드로이드 전용 설정(`AndroidWebViewController` — 파일 · content 접근 끄기 등)은 이 가짜에서는 지나간다 — 실기기에서 본다. +class FakeWebViewPlatform extends WebViewPlatform { + /// 만든 컨트롤러 — 차례대로 + final List controllers = []; + + /// 마지막으로 만든 컨트롤러 + FakeWebViewController get last => controllers.last; + + @override + PlatformWebViewController createPlatformWebViewController( + PlatformWebViewControllerCreationParams params, + ) { + final FakeWebViewController controller = FakeWebViewController(params); + controllers.add(controller); + return controller; + } + + @override + PlatformNavigationDelegate createPlatformNavigationDelegate( + PlatformNavigationDelegateCreationParams params, + ) => FakeNavigationDelegate(params); + + @override + PlatformWebViewWidget createPlatformWebViewWidget( + PlatformWebViewWidgetCreationParams params, + ) => FakeWebViewWidget(params); +} + +/// 가짜 WebView 컨트롤러 — 받은 설정과 넣은 문서를 적는다 +class FakeWebViewController extends PlatformWebViewController { + FakeWebViewController(super.params) : super.implementation(); + + JavaScriptMode? javaScriptMode; + Color? backgroundColor; + FakeNavigationDelegate? navigationDelegate; + + /// 넣은 문서 — 차례대로 + final List loadedHtml = []; + + /// 넣은 문서의 기준 주소 — 차례대로(읽기 화면은 늘 null 이어야 한다) + final List baseUrls = []; + + String? get lastHtml => loadedHtml.isEmpty ? null : loadedHtml.last; + + @override + Future setJavaScriptMode(JavaScriptMode javaScriptMode) async { + this.javaScriptMode = javaScriptMode; + } + + @override + Future setBackgroundColor(Color color) async { + backgroundColor = color; + } + + @override + Future setPlatformNavigationDelegate( + PlatformNavigationDelegate handler, + ) async { + navigationDelegate = handler as FakeNavigationDelegate; + } + + @override + Future loadHtmlString(String html, {String? baseUrl}) async { + loadedHtml.add(html); + baseUrls.add(baseUrl); + } + + /// 링크를 누른 것처럼 탐색을 묻는다 — 판정(열기 · 막기)을 돌려준다 + Future navigate( + String url, { + bool isMainFrame = true, + }) async { + final NavigationRequestCallback? callback = + navigationDelegate?.onNavigationRequest; + if (callback == null) return NavigationDecision.navigate; + return callback(NavigationRequest(url: url, isMainFrame: isMainFrame)); + } +} + +/// 가짜 탐색 판정 — 건 처리기를 쥔다 +class FakeNavigationDelegate extends PlatformNavigationDelegate { + FakeNavigationDelegate(super.params) : super.implementation(); + + NavigationRequestCallback? onNavigationRequest; + + @override + Future setOnNavigationRequest( + NavigationRequestCallback onNavigationRequest, + ) async { + this.onNavigationRequest = onNavigationRequest; + } + + @override + Future setOnPageStarted(PageEventCallback onPageStarted) async {} + + @override + Future setOnPageFinished(PageEventCallback onPageFinished) async {} + + @override + Future setOnProgress(ProgressCallback onProgress) async {} + + @override + Future setOnWebResourceError( + WebResourceErrorCallback onWebResourceError, + ) async {} + + @override + Future setOnUrlChange(UrlChangeCallback onUrlChange) async {} + + @override + Future setOnHttpAuthRequest( + HttpAuthRequestCallback onHttpAuthRequest, + ) async {} + + @override + Future setOnHttpError(HttpResponseErrorCallback onHttpError) async {} +} + +/// 가짜 WebView 자리 — 아무것도 그리지 않는다 +class FakeWebViewWidget extends PlatformWebViewWidget { + FakeWebViewWidget(super.params) : super.implementation(); + + @override + Widget build(BuildContext context) => + const SizedBox.expand(key: ValueKey('fake-webview')); +} diff --git a/flutter/test/support/fakes.dart b/flutter/test/support/fakes.dart index 489c5c5..91b15cb 100644 --- a/flutter/test/support/fakes.dart +++ b/flutter/test/support/fakes.dart @@ -109,6 +109,7 @@ class RecordingPushRegistrar implements PushRegistrar { final TokenStore _store; int registered = 0; int unregistered = 0; + int paused = 0; bool? hadSessionAtUnregister; /// 두면 지우기가 이것이 끝날 때까지 기다린다 @@ -123,6 +124,9 @@ class RecordingPushRegistrar implements PushRegistrar { hadSessionAtUnregister = _store.hasSession; await unregisterGate?.future; } + + @override + void pause() => paused++; } /// 알림을 띄우지 않고 적어 두는 알림 — 겹침 거르기([ErrorNotifier.show])는 그대로 탄다 @@ -141,7 +145,45 @@ class RecordingNotifier extends ErrorNotifier { /// 가짜 응답 class FakeResponse { - const FakeResponse(this.status, this.body, {this.rawBody}); + const FakeResponse( + this.status, + this.body, { + this.rawBody, + this.bytes, + this.stream, + this.headers, + }); + + /// 표준 래퍼 없는 바이트(그림 · 파일) — [contentType] 과 길이 머리를 싣는다 + factory FakeResponse.bytes( + List data, { + String contentType = 'application/octet-stream', + int status = 200, + }) => FakeResponse( + status, + null, + bytes: data, + headers: >{ + Headers.contentTypeHeader: [contentType], + Headers.contentLengthHeader: ['${data.length}'], + }, + ); + + /// 조각으로 오는 파일 — 시험이 조각을 하나씩 흘린다. [contentLength] 가 없으면 길이 머리를 싣지 않는다 + factory FakeResponse.stream( + Stream chunks, { + int? contentLength, + String contentType = 'application/octet-stream', + }) => FakeResponse( + 200, + null, + stream: chunks, + headers: >{ + Headers.contentTypeHeader: [contentType], + if (contentLength != null) + Headers.contentLengthHeader: ['$contentLength'], + }, + ); /// 표준 성공 `{ success: true, data }` factory FakeResponse.ok([Object? data]) => @@ -166,6 +208,15 @@ class FakeResponse { /// JSON 이 아닌 그대로의 본문 (있으면 [body] 대신) final String? rawBody; + + /// 바이트 본문 (있으면 [body] · [rawBody] 대신) + final List? bytes; + + /// 조각으로 오는 본문 (있으면 다른 본문 대신) + final Stream? stream; + + /// 더 싣는 머리 — 같은 이름은 기본(JSON)을 덮는다 + final Map>? headers; } typedef FakeHandler = FutureOr Function(RequestOptions options); @@ -180,10 +231,40 @@ class FakeServer implements HttpClientAdapter { /// 응답의 `Date` 머리(서버 시각) — null 이면 싣지 않는다 DateTime? serverTime; + /// 켜면 처리기를 부르기 전에 요청 본문을 끝까지 읽어 [requestBodies] 에 둔다 — 보내는 진행(onSendProgress)이 오고, + /// multipart 본문을 바이트로 볼 수 있다(꺼 두면 본문은 `RequestOptions.data` 로만 본다) + bool readRequestBodies = false; + + /// 읽은 요청 본문 — [readRequestBodies] 를 켠 동안 받은 차례대로 + final List<({String path, List body})> requestBodies = + <({String path, List body})>[]; + + /// 경로 꼴마다 처리기 — 같은 경로의 [on] 이 없을 때 먼저 맞는 것 + final List<({String method, RegExp path, FakeHandler handler})> _patterns = + <({String method, RegExp path, FakeHandler handler})>[]; + void on(String method, String path, FakeHandler handler) { _routes['$method $path'] = handler; } + /// 경로 꼴(`^/messages/([^/]+)$` 등)에 처리기를 건다 — 처리기는 [RequestOptions.path] 로 칸을 꺼낸다 + void onPattern(String method, RegExp path, FakeHandler handler) { + _patterns.add((method: method, path: path, handler: handler)); + } + + FakeHandler? _handlerOf(RequestOptions options) { + final FakeHandler? exact = _routes['${options.method} ${options.path}']; + if (exact != null) return exact; + for (final ({String method, RegExp path, FakeHandler handler}) pattern + in _patterns) { + if (pattern.method == options.method && + pattern.path.hasMatch(options.path)) { + return pattern.handler; + } + } + return null; + } + /// [method] [path] 로 받은 요청들 List received(String method, String path) => requests .where((RequestOptions r) => r.method == method && r.path == path) @@ -197,7 +278,12 @@ class FakeServer implements HttpClientAdapter { ) async { // 받은 그때의 모습을 남긴다 — 인터셉터가 실패를 내보내며 토큰 · 비밀 본문을 지워도 시험이 볼 수 있게 requests.add(options.copyWith()); - final FakeHandler? handler = _routes['${options.method} ${options.path}']; + if (readRequestBodies && requestStream != null) { + final List body = []; + await requestStream.forEach(body.addAll); + requestBodies.add((path: options.path, body: body)); + } + final FakeHandler? handler = _handlerOf(options); final FakeResponse response = handler == null ? FakeResponse.fail(404, 'RES_001') : await handler(options); @@ -208,14 +294,23 @@ class FakeServer implements HttpClientAdapter { ); } final DateTime? date = serverTime; + final Map> headers = >{ + Headers.contentTypeHeader: [Headers.jsonContentType], + if (date != null) HttpHeaders.dateHeader: [HttpDate.format(date)], + ...?response.headers, + }; + final Stream? stream = response.stream; + if (stream != null) { + return ResponseBody(stream, response.status, headers: headers); + } + final List? bytes = response.bytes; + if (bytes != null) { + return ResponseBody.fromBytes(bytes, response.status, headers: headers); + } return ResponseBody.fromString( response.rawBody ?? jsonEncode(response.body), response.status, - headers: >{ - Headers.contentTypeHeader: [Headers.jsonContentType], - if (date != null) - HttpHeaders.dateHeader: [HttpDate.format(date)], - }, + headers: headers, ); } diff --git a/flutter/test/support/file_fakes.dart b/flutter/test/support/file_fakes.dart new file mode 100644 index 0000000..9eb9d96 --- /dev/null +++ b/flutter/test/support/file_fakes.dart @@ -0,0 +1,197 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:arasystem_app/core/files/downloaded_files.dart'; +import 'package:arasystem_app/core/files/file_names.dart'; +import 'package:arasystem_app/core/files/picked_files.dart'; + +/// 메모리 파일 보관 — 시험에서는 기기 파일 시스템을 쓰지 않는다(시험 시계 안에서 파일 입출력이 끝나지 않는다) +class MemoryFileStore implements DownloadedFileStore { + /// 다 받은 파일 — 경로마다 바이트 + final Map> files = >{}; + + /// 받다 말아 지운 자리 + final List discarded = []; + + /// 받는 중인 자리 + final Map> writing = >{}; + int purgeCount = 0; + int _created = 0; + + /// 켜면 자리 만들기가 실패한다(저장 공간 없음) + bool failCreate = false; + + /// 있으면 받다 만 자리 지우기가 이것이 끝날 때까지 기다린다 — 앞 받기의 끝을 늦춘다 + Completer? discardGate; + + @override + Future create(String fileName) async { + if (failCreate) throw const FileSystemException('저장 공간 없음(시험)'); + final String path = + '/cache/downloads/${++_created}/${safeFileName(fileName)}'; + writing[path] = []; + return _MemoryTarget(this, path); + } + + @override + Future exists(String path) async => files.containsKey(path); + + @override + Future purge() async { + purgeCount++; + files.clear(); + } +} + +class _MemoryTarget implements DownloadTarget { + _MemoryTarget(this._store, this.path); + + final MemoryFileStore _store; + + @override + final String path; + + @override + void add(List chunk) => _store.writing[path]?.addAll(chunk); + + @override + Future close() async { + final List? bytes = _store.writing.remove(path); + if (bytes != null) _store.files[path] = bytes; + } + + @override + Future discard() async { + await _store.discardGate?.future; + _store.writing.remove(path); + _store.files.remove(path); + _store.discarded.add(path); + } +} + +/// 다른 앱으로 연 것을 적는 가짜 — [result] 로 결과를 정한다 +class RecordingFileOpener implements FileOpener { + FileOpenResult result = FileOpenResult.opened; + final List<({String path, String mimeType})> opened = + <({String path, String mimeType})>[]; + + @override + Future open({ + required String path, + required String mimeType, + }) async { + opened.add((path: path, mimeType: mimeType)); + return result; + } +} + +/// 바깥 앱으로 연 링크를 적는 가짜 — [result] 로 열었는지 정한다 +class RecordingLinkLauncher { + bool result = true; + final List launched = []; + + Future call(Uri uri) async { + launched.add(uri); + return result; + } +} + +/// 고른 파일의 가짜 — 고르기의 결과를 차례대로 준다([willPick] · [results] — 비면 고르지 않고 닫은 것). +/// 복사본은 메모리(경로마다 바이트)다 — 시험 시계 안에서는 기기 파일 입출력이 끝나지 않는다 +class FakePickedFiles implements PickedFileStore { + /// 다음 고르기들의 결과 — 차례대로 하나씩 쓴다 + final List results = []; + + /// 받은 고르기 — 넘겨받은 한도들(남은 개수 · 파일당 · 남은 합계 · 허용 확장자) + final List< + ({int maxBytes, int maxCount, int maxTotalBytes, List extensions}) + > + picks = + < + ({ + int maxBytes, + int maxCount, + int maxTotalBytes, + List extensions, + }) + >[]; + + /// 있으면 고르기가 이것이 열릴 때까지 기다린다(한 번 쓰면 비운다) — 고르는 창이 열려 있는 동안 + Completer? pickGate; + + /// 복사본 — 경로마다 바이트 + final Map> copies = >{}; + + /// 지운 복사본 차례대로 + final List deleted = []; + int purgeCount = 0; + int _next = 0; + + /// 고른 파일 하나 — 복사본을 두고 그 [PickedFile] 을 준다 + PickedFile copy(String name, List bytes) { + final String path = '/cache/$kPickedFilesFolder/${++_next}/$name'; + copies[path] = bytes; + return PickedFile( + name: name, + status: PickedFileStatus.ok, + size: bytes.length, + path: path, + ); + } + + /// 다음 고르기에서 이 파일들을 고른다 — 기기가 미리 거른 결과(`MainActivity.kt` 의 `copyPicked`)를 그대로 적는다 + void willPick(List files) => + results.add(PickResult(PickOutcome.picked, files)); + + @override + Future pick({ + required int maxBytes, + required int maxCount, + required int maxTotalBytes, + required List extensions, + }) async { + picks.add(( + maxBytes: maxBytes, + maxCount: maxCount, + maxTotalBytes: maxTotalBytes, + extensions: extensions, + )); + final Completer? gate = pickGate; + if (gate != null) { + pickGate = null; + await gate.future; + } + return results.isEmpty + ? const PickResult(PickOutcome.canceled) + : results.removeAt(0); + } + + @override + Future exists(String path) async => copies.containsKey(path); + + /// 조각 넷으로 흘린다 — 보내는 진행이 여러 번 온다 + @override + Stream> openRead(String path) { + final List? bytes = copies[path]; + if (bytes == null) { + return Stream>.error(const FileSystemException('없음(시험)')); + } + final int step = (bytes.length / 4).ceil().clamp(1, bytes.length + 1); + return Stream>.fromIterable(>[ + for (int start = 0; start < bytes.length; start += step) + bytes.sublist(start, (start + step).clamp(0, bytes.length)), + ]); + } + + @override + Future delete(String path) async { + deleted.add(path); + copies.remove(path); + } + + @override + Future purge() async { + purgeCount++; + copies.clear(); + } +} diff --git a/flutter/test/support/login_fakes.dart b/flutter/test/support/login_fakes.dart new file mode 100644 index 0000000..6644024 --- /dev/null +++ b/flutter/test/support/login_fakes.dart @@ -0,0 +1,185 @@ +import 'package:arasystem_app/app.dart'; +import 'package:arasystem_app/core/api/api_paths.dart'; +import 'package:arasystem_app/core/utils/provider_retry.dart'; +import 'package:arasystem_app/features/auth/data/authenticator_launcher.dart'; +import 'package:arasystem_app/features/auth/presentation/providers/login_providers.dart'; +import 'package:arasystem_app/features/auth/presentation/widgets/login_fields.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import 'fakes.dart'; +import 'provider_harness.dart'; + +/// 시험 직원의 주소 +const String kTestEmail = 'ara.kim@arasystem.it.kr'; + +/// 시험 2단계 키(32자 base32)와 otpauth 주소 +const String kTestSecret = 'K7QF2M4XHB6RTZ3NPW5CYL7DJQ2VMGAS'; +const String kTestOtpauthUrl = + 'otpauth://totp/ARA%20SYSTEM:ara.kim%40arasystem.it.kr?secret=$kTestSecret&issuer=ARA%20SYSTEM&algorithm=SHA1&digits=6&period=30'; + +/// 코드 단계로 답하는 로그인 (계약 5절 — `email` · `name` 은 null, 토큰 없음) +FakeResponse codeStepResponse() => FakeResponse.ok({ + 'status': 'twoFactorRequired', + 'email': null, + 'name': null, + 'restriction': null, + 'twoFactorDeadline': null, + 'tokens': null, +}); + +/// 로그인을 마친 응답 ([n] 번째 토큰) +FakeResponse signedInResponse( + int n, { + required DateTime now, + String? restriction, +}) => FakeResponse.ok({ + 'status': 'signedIn', + ...sessionJson(restriction: restriction), + 'tokens': tokensJson(n, now: now), +}); + +/// 갱신 응답 ([n] 번째 토큰 · 세션 칸) +FakeResponse refreshResponse( + int n, { + required DateTime now, + String? restriction, +}) => FakeResponse.ok({ + ...sessionJson(restriction: restriction), + 'tokens': tokensJson(n, now: now), +}); + +/// 2단계 등록 응답 ([id] 번째 등록 — 키는 같다) +FakeResponse enrollmentResponse({String id = 'enroll-1'}) => + FakeResponse(201, { + 'success': true, + 'data': { + 'enrollmentId': id, + 'secret': kTestSecret, + 'otpauthUrl': kTestOtpauthUrl, + 'expiresAt': '2026-11-05T03:10:00.000Z', + }, + }); + +/// 2단계 상태 응답 +FakeResponse twoFactorStatusResponse({required bool enabled}) => + FakeResponse.ok({ + 'isEnabled': enabled, + 'enabledAt': null, + 'isRequired': true, + 'deadline': null, + }); + +/// 보이는 글로 찾는다 — 낱말 가운데서 줄을 바꾸지 않는 글(`KeepAllText`)은 원래 글(화면 낭독기 글)로 찾는다 +Finder findCopy(String text) => find.byWidgetPredicate( + (Widget widget) => + widget is Text && (widget.data == text || widget.semanticsLabel == text), +); + +/// 칸 이름으로 그 칸의 입력을 찾는다 +Finder fieldInput(String label) => find.descendant( + of: find.byWidgetPredicate( + (Widget widget) => widget is LoginTextField && widget.label == label, + ), + matching: find.byType(TextField), +); + +/// 칸 이름으로 그 칸의 컨트롤러 글을 읽는다 +String fieldText(WidgetTester tester, String label) => + tester.widget(fieldInput(label)).controller!.text; + +/// [인증 앱에 추가]를 적어 두는 가짜 — [opens] 가 false 면 받을 앱이 없는 것처럼 답한다 +class FakeAuthenticatorLauncher extends AuthenticatorLauncher { + FakeAuthenticatorLauncher() : super(launch: _never); + + bool opens = true; + final List opened = []; + + static Future _never(Uri url, LaunchMode mode) async => false; + + @override + Future open(String otpauthUrl) async { + opened.add(otpauthUrl); + return opens; + } +} + +/// 로그인 틀 시험대 — 가짜 서버 · 메모리 저장소 · 손 시계 · 가짜 인증 앱 열기로 앱 전체를 띄운다 +class LoginHarness { + LoginHarness(); + + final ProviderHarness base = ProviderHarness(); + final FakeAuthenticatorLauncher launcher = FakeAuthenticatorLauncher(); + + FakeServer get server => base.server; + ManualClock get clock => base.clock; + RecordingNotifier get notifier => base.notifier; + + /// 클립보드에 쓴 글 (가짜 플랫폼 통로) + final List clipboard = []; + + /// 켜면 클립보드 쓰기가 실패한다(플랫폼 예외) + bool clipboardFails = false; + + /// 문의 연락처를 답한다 — 부르지 않으면 가짜 서버가 404 로 답해 화면은 지금 글 그대로다 + void supportContact(String? text) { + server.on( + 'GET', + ApiPaths.supportContact, + (_) => FakeResponse.ok({'text': text}), + ); + } + + /// 앱을 띄운다 — [refreshToken] 이 있으면 앱을 다시 연 것처럼 세션을 되살린다. + /// 화면은 휴대폰 폭(390)에 세로로 길게 — 아래쪽 단추까지 스크롤 없이 누른다 + Future pump( + WidgetTester tester, { + String? refreshToken, + List observers = const [], + List overrides = const [], + }) async { + tester.view.physicalSize = const Size(390 * 3, 1500 * 3); + tester.view.devicePixelRatio = 3; + addTearDown(tester.view.reset); + await base.prepare(refreshToken: refreshToken); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (MethodCall call) async { + if (call.method == 'Clipboard.setData') { + if (clipboardFails) { + throw PlatformException(code: 'test', message: '클립보드 쓰기 실패(시험)'); + } + final Object? arguments = call.arguments; + if (arguments is Map) { + clipboard.add(arguments['text']! as String); + } + } + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); + await tester.pumpWidget( + ProviderScope( + retry: noAutomaticRetry, + observers: observers, + overrides: [ + ...base.overrides, + authenticatorLauncherProvider.overrideWithValue(launcher), + ...overrides, + ], + child: const App(), + ), + ); + await tester.pumpAndSettle(); + return ProviderScope.containerOf(tester.element(find.byType(App))); + } +} diff --git a/flutter/test/support/mail_fakes.dart b/flutter/test/support/mail_fakes.dart new file mode 100644 index 0000000..999828f --- /dev/null +++ b/flutter/test/support/mail_fakes.dart @@ -0,0 +1,692 @@ +import 'dart:async'; + +import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart'; +import 'package:dio/dio.dart'; + +import 'fakes.dart'; + +/// 시험 메일함 id +const String kInboxId = 'mb-inbox'; +const String kSentId = 'mb-sent'; +const String kDraftsId = 'mb-drafts'; +const String kJunkId = 'mb-junk'; +const String kTrashId = 'mb-trash'; +const String kClientsId = 'mb-clients'; +const String kProjectId = 'mb-project'; +const String kProjectChildId = 'mb-project-child'; + +/// 메일함 한 칸 (계약 2절 `Mailbox`) +Map mailboxJson( + String id, + String name, { + String? role, + String? parentId, + int sortOrder = 0, + int total = 0, + int unread = 0, +}) => { + 'id': id, + 'name': name, + 'role': role, + 'parentId': parentId, + 'sortOrder': sortOrder, + 'totalCount': total, + 'unreadCount': unread, +}; + +/// 메일 한 통 (계약 2절 `MessageSummary`) +Map messageJson( + String id, { + required String mailboxId, + String fromName = '이도윤', + String fromEmail = 'doyun.lee@example.com', + List> to = const >[], + String? subject, + String preview = '미리 보기 글', + DateTime? receivedAt, + bool isRead = false, + bool isFlagged = false, + bool hasAttachment = false, + Map? pendingSend, + bool noSender = false, +}) => { + 'id': id, + 'threadId': 'thread-$id', + 'mailboxIds': [mailboxId], + 'from': noSender + ? null + : {'name': fromName, 'email': fromEmail}, + 'to': to, + 'subject': subject ?? '제목 $id', + 'preview': preview, + 'receivedAt': (receivedAt ?? kTestNow).toUtc().toIso8601String(), + 'isRead': isRead, + 'isFlagged': isFlagged, + 'hasAttachment': hasAttachment, + 'size': 1024, + 'pendingSend': pendingSend, +}; + +/// 받는 사람 한 사람 +Map addressJson(String name, String email) => + {'name': name, 'email': email}; + +/// 붙잡아 둔 발송 (9단계) +Map pendingJson(DateTime sendAt, {String kind = 'undo'}) => + {'sendAt': sendAt.toUtc().toIso8601String(), 'kind': kind}; + +/// 메일의 첨부 하나 (계약 2절 `MessageAttachment`) +Map attachmentJson( + String id, { + String name = '견적서.pdf', + String type = 'application/pdf', + int size = 248 * 1024, + bool isInline = false, +}) => { + 'id': id, + 'name': name, + 'type': type, + 'size': size, + 'isInline': isInline, +}; + +/// 1×1 PNG (외부 이미지 중계의 그림) +final List kTinyPng = [ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, +]; + +/// 기본 메일함 다섯과 내 메일함 셋(프로젝트 아래에 하나) +List> standardMailboxes({ + int inboxUnread = 3, +}) => >[ + mailboxJson(kInboxId, '받은편지함', role: 'inbox', total: 30, unread: inboxUnread), + mailboxJson(kSentId, '보낸편지함', role: 'sent', total: 5), + mailboxJson(kDraftsId, '임시보관함', role: 'drafts', total: 1), + mailboxJson(kJunkId, '스팸메일함', role: 'junk', total: 2, unread: 2), + mailboxJson(kTrashId, '휴지통', role: 'trash'), + mailboxJson(kClientsId, '거래처', total: 1, unread: 1), + mailboxJson(kProjectId, '프로젝트', sortOrder: 1), + mailboxJson(kProjectChildId, '설계', parentId: kProjectId), +]; + +/// 메일 API 를 흉내 내는 가짜 뒤쪽 — 메일함 · 목록(커서) · 여러 통 바꾸기 · 영구 삭제 · 새 메일 확인. +/// +/// 상태는 메모리에 두고 요청대로 바꾼다. [failIds] 는 바꾸기 · 지우기가 그대로 둘 메일(`failedIds` · `skippedIds`), +/// [patchFailure] · [listFailure] 는 그 요청을 그 응답으로 실패시킨다. 문([listGate] 등)을 두면 그 응답이 문이 열릴 때까지 기다린다. +class FakeMailBackend { + FakeMailBackend(this.server, {List>? mailboxes}) + : mailboxes = mailboxes ?? standardMailboxes(); + + final FakeServer server; + List> mailboxes; + + /// 메일함마다 메일 — 받은 시각 내림차순(앞이 최신) + final Map>> messages = + >>{}; + + /// 새 메일 확인의 상태 글 + String state = 'state-1'; + + /// 바꾸기 · 지우기가 그대로 둘 메일 id + final Set failIds = {}; + + /// 있으면 PATCH 본문 · 메일 id 마다 그대로 둘지 묻는다(요청마다 다르게 — 읽음만 실패 등) + bool Function(Map body, String id)? failWhen; + + FakeResponse? mailboxesFailure; + FakeResponse? listFailure; + FakeResponse? patchFailure; + FakeResponse? deletionFailure; + FakeResponse? stateFailure; + + /// 있으면 다음 새 메일 확인 응답이 이것이 끝날 때까지 기다린다(한 번 쓰면 비운다) + Completer? stateGate; + + /// 있으면 다음 목록 응답들이 이것이 끝날 때까지 기다린다(한 번 쓰면 비운다 — [listGateOnce]) + Completer? listGate; + bool listGateOnce = true; + Completer? patchGate; + Completer? mailboxesGate; + + /// 받은 PATCH /messages 본문 + final List> patches = >[]; + + /// 받은 POST /message-deletions 의 ids + final List> deletions = >[]; + + // ── 읽기 화면 (메일 한 통 · 붙잡힌 발송 · 첨부 · 외부 이미지) ── + + /// 메일 한 통의 읽기 칸 — 메일 id 마다(없으면 텍스트 본문 하나). `messageJson` 위에 덮는다 + final Map> details = + >{}; + + /// `images=show` 로 물었을 때 덮는 칸 — 메일 id 마다(외부 이미지를 중계 주소로 바꾼 본문) + final Map> shownImageDetails = + >{}; + + /// 받은 GET /messages/{id} 의 쿼리 (메일 id · images) + final List<({String id, Object? images})> detailRequests = + <({String id, Object? images})>[]; + + /// 받은 GET /messages/{id} 의 쿼리 전체 (메일 id · 쿼리 — 검색 강조 쿼리를 본다) + final List<({String id, Map query})> detailQueries = + <({String id, Map query})>[]; + + /// 검색 강조 쿼리(`q`)로 물었을 때 덮는 칸 — 메일 id 마다(`searchHighlight` · NFC 로 실은 제목 등) + final Map> highlightDetails = + >{}; + + // ── 검색 (GET /messages?q=… · GET /search-index) ── + + /// 검색 응답 — 있으면 이것으로 답한다(쿼리를 받는다). 없으면 메일 제목 · 미리 보기에 q 가 든 메일을 한 쪽씩([defaultSearch]) + FakeResponse Function(Map query)? searchResponder; + + /// 기본 검색이 항목에 `searchHighlight` 칸을 싣는다(14단계 서버) — 값은 [searchHighlights] 에서(없으면 null) + bool searchHighlightField = false; + + /// 기본 검색 항목의 `searchHighlight` — 메일 id 마다 + final Map> searchHighlights = + >{}; + + /// 있으면 다음 검색 응답이 이것이 끝날 때까지 기다린다(한 번 쓰면 비운다) + Completer? searchGate; + + /// `GET /search-index` 의 응답 — 기본은 준비됨 + FakeResponse searchIndex = FakeResponse.ok({ + 'status': 'ready', + 'done': 10, + 'total': 10, + }); + + /// 검색 요청의 쿼리 이름 — 이 가운데 하나라도 있으면 검색이다(검색어 또는 조건 — 계약 2절 "검색 — 14단계") + static const Set searchKeys = { + 'q', + 'from', + 'to', + 'subject', + 'body', + 'after', + 'before', + 'hasAttachment', + }; + + /// 검색 요청인가 — 메일함 목록 요청과 가른다 + static bool isSearchQuery(Map query) => + query.keys.any(searchKeys.contains); + + /// 받은 검색 요청의 쿼리 (차례대로) + List> get searchQueries => >[ + for (final RequestOptions request in listRequests) + if (isSearchQuery(request.queryParameters)) request.queryParameters, + ]; + + /// 받은 `GET /search-index` 의 수 + int get searchIndexRequests => + server.received('GET', MailApiPaths.searchIndex).length; + + /// 받은 PATCH /messages/{id} (메일 id · 본문) + final List<({String id, Map body})> singlePatches = + <({String id, Map body})>[]; + + /// 받은 DELETE /scheduled-messages/{id} 의 메일 id + final List cancellations = []; + + FakeResponse? detailFailure; + FakeResponse? singlePatchFailure; + FakeResponse? cancelFailure; + + /// 있으면 읽기 칸 응답이 이것이 끝날 때까지 기다린다(한 번 쓰면 비운다) + Completer? detailGate; + + /// 있으면 한 통 바꾸기 응답이 이것이 끝날 때까지 기다린다 + Completer? singlePatchGate; + + /// 첨부 받기의 응답 — 첨부 id 마다(없으면 404) + final Map attachmentFiles = + {}; + + /// 외부 이미지 중계의 응답 — 표마다(없으면 404) + final Map mailImages = {}; + + List get imageRequests => server.requests + .where((RequestOptions r) => r.path.startsWith('/mail-images/')) + .toList(); + + int get stateRequests => + server.received('GET', MailApiPaths.mailState).length; + + List get listRequests => + server.received('GET', MailApiPaths.messages); + + /// 경로마다 처리기를 건다 + void install() { + server.on('GET', MailApiPaths.mailboxes, (_) async { + await mailboxesGate?.future; + final FakeResponse? failure = mailboxesFailure; + if (failure != null) return failure; + return FakeResponse.ok({ + 'items': mailboxes, + 'pageInfo': { + 'hasNext': false, + 'nextCursor': null, + 'total': mailboxes.length, + }, + }); + }); + server.on('GET', MailApiPaths.messages, (RequestOptions options) async { + if (isSearchQuery(options.queryParameters)) { + final Completer? gate = searchGate; + if (gate != null) { + searchGate = null; + await gate.future; + } + final FakeResponse Function(Map query)? responder = + searchResponder; + return responder == null + ? defaultSearch(options.queryParameters) + : responder(options.queryParameters); + } + final Completer? gate = listGate; + if (gate != null) { + if (listGateOnce) listGate = null; + await gate.future; + } + final FakeResponse? failure = listFailure; + if (failure != null) return failure; + final Map query = options.queryParameters; + final String mailboxId = query['mailboxId']! as String; + if (!mailboxes.any((Map m) => m['id'] == mailboxId)) { + return FakeResponse.fail(404, 'RES_001'); + } + final List> all = + messages[mailboxId] ?? >[]; + final int limit = (query['limit'] as int?) ?? kMailPageSize; + final Object? cursor = query['cursor']; + final int start = cursor is String ? int.parse(cursor.substring(2)) : 0; + final int end = (start + limit).clamp(0, all.length); + final bool hasNext = end < all.length; + return FakeResponse.ok({ + 'items': all.sublist(start.clamp(0, all.length), end), + 'pageInfo': { + 'hasNext': hasNext, + 'nextCursor': hasNext ? 'c:$end' : null, + 'total': all.length, + }, + }); + }); + server.on('PATCH', MailApiPaths.messages, (RequestOptions options) async { + final Map body = options.data! as Map; + patches.add(body); + await patchGate?.future; + final FakeResponse? failure = patchFailure; + if (failure != null) return failure; + final List ids = (body['ids']! as List).cast(); + final List updated = []; + final List failed = []; + for (final String id in ids) { + if (failIds.contains(id) || + (failWhen?.call(body, id) ?? false) || + _find(id) == null) { + failed.add(id); + continue; + } + _apply(id, body); + updated.add(id); + } + return FakeResponse.ok({ + 'updatedIds': updated, + 'failedIds': failed, + }); + }); + server.on('POST', MailApiPaths.messageDeletions, ( + RequestOptions options, + ) async { + final Map body = options.data! as Map; + final List ids = (body['ids']! as List).cast(); + deletions.add(ids); + final FakeResponse? failure = deletionFailure; + if (failure != null) return failure; + final List deleted = []; + final List skipped = []; + final List> trash = + messages[kTrashId] ?? >[]; + for (final String id in ids) { + final int index = trash.indexWhere( + (Map m) => m['id'] == id, + ); + if (failIds.contains(id) || index == -1) { + skipped.add(id); + continue; + } + trash.removeAt(index); + deleted.add(id); + } + return FakeResponse.ok({ + 'deletedIds': deleted, + 'skippedIds': skipped, + }); + }); + server.on('GET', MailApiPaths.mailState, (_) async { + final Completer? gate = stateGate; + stateGate = null; + await gate?.future; + final FakeResponse? failure = stateFailure; + if (failure != null) return failure; + return FakeResponse.ok({'state': state}); + }); + server.on('GET', MailApiPaths.searchIndex, (_) => searchIndex); + _installReader(); + } + + /// 기본 검색 — 찾을 곳(메일함 · 없으면 휴지통 · 스팸메일함 밖 모두)의 메일 가운데 제목 · 미리 보기에 q 가 든 것을 + /// 받은 시각 내림차순(같으면 id 내림차순 — 서버 색인과 같다)으로 한 쪽씩. 커서는 그 쪽 끝 줄의 자리([searchCursorAfter]) — + /// 서버처럼 그 줄 **다음**부터 준다(그사이 앞쪽 메일이 빠지거나 되돌아와도 다음 쪽은 밀리지도 겹치지도 않는다). 전체 수는 첫 쪽에만 싣는다. + /// 조건([_matchesConditions] — 보낸 사람 · 받는 사람 · 제목 · 내용(미리 보기로 대신) · 받은 시각 · 첨부)도 거른다. q 가 없으면 조건만으로. + /// [searchResponder] 가 쪽마다 이것을 불러 섞어 쓸 수 있다 + FakeResponse defaultSearch(Map query) { + final String? q = query['q'] as String?; + final Object? mailboxId = query['mailboxId']; + final List> pool = >[ + for (final MapEntry>> entry + in messages.entries) + if (mailboxId == null + ? entry.key != kTrashId && entry.key != kJunkId + : entry.key == mailboxId) + ...entry.value, + ]; + if (mailboxId != null && + !mailboxes.any((Map m) => m['id'] == mailboxId)) { + return FakeResponse.fail(404, 'RES_001'); + } + final List> matched = + pool + .where( + (Map m) => + (q == null || + (m['subject']! as String).contains(q) || + (m['preview']! as String).contains(q)) && + _matchesConditions(m, query), + ) + .toList() + ..sort((Map a, Map b) { + final int byTime = (b['receivedAt']! as String).compareTo( + a['receivedAt']! as String, + ); + return byTime != 0 + ? byTime + : (b['id']! as String).compareTo(a['id']! as String); + }); + final int limit = (query['limit'] as int?) ?? kMailPageSize; + final Object? cursor = query['cursor']; + final List> rest = cursor is String + ? matched.where(_afterSearchCursor(cursor)).toList() + : matched; + final List> page = rest.take(limit).toList(); + final bool hasNext = rest.length > page.length; + return FakeResponse.ok({ + 'items': >[ + for (final Map message in page) + { + ...message, + if (searchHighlightField) + 'searchHighlight': searchHighlights[message['id']], + }, + ], + 'pageInfo': { + 'hasNext': hasNext, + 'nextCursor': hasNext ? searchCursorAfter(page.last) : null, + 'total': cursor == null ? matched.length : null, + }, + }); + } + + /// 기본 검색의 조건 — 글 조건은 그 칸에 그 글이 들었는지(보낸 사람 · 받는 사람은 이름이나 주소, 내용은 미리 보기로 대신), + /// 기간은 받은 시각이 `after` 이상 · `before` 미만(둘 다 UTC ISO 라 글로 견준다), 첨부는 `hasAttachment=true` 일 때 있는 메일만 + static bool _matchesConditions( + Map message, + Map query, + ) { + bool contains(Object? value, Object? text) => + text is! String || (value is String && value.contains(text)); + bool person(Object? address, Object? text) => + address is Map && + (contains(address['name'], text) || contains(address['email'], text)); + final Object? from = query['from']; + if (from != null && !person(message['from'], from)) return false; + final Object? to = query['to']; + if (to != null && + !(message['to']! as List).any( + (Object? address) => person(address, to), + )) { + return false; + } + if (!contains(message['subject'], query['subject'])) return false; + if (!contains(message['preview'], query['body'])) return false; + final String receivedAt = message['receivedAt']! as String; + final Object? after = query['after']; + if (after is String && receivedAt.compareTo(after) < 0) return false; + final Object? before = query['before']; + if (before is String && receivedAt.compareTo(before) >= 0) return false; + if (query['hasAttachment'] == 'true' && message['hasAttachment'] != true) { + return false; + } + return true; + } + + /// 기본 검색의 커서 — [message] 줄 다음부터(`s:<받은 시각>|` — 서버 색인 커서처럼 받은 시각 · id 가 더 작은 쪽) + static String searchCursorAfter(Map message) => + 's:${message['receivedAt']}|${message['id']}'; + + /// 커서 자리 다음의 메일인가 + static bool Function(Map message) _afterSearchCursor( + String cursor, + ) { + final List parts = cursor.substring(2).split('|'); + final String at = parts[0]; + final String id = parts[1]; + return (Map message) { + final int byTime = (message['receivedAt']! as String).compareTo(at); + return byTime < 0 || + (byTime == 0 && (message['id']! as String).compareTo(id) < 0); + }; + } + + static final RegExp _messagePath = RegExp(r'^/messages/([^/]+)$'); + static final RegExp _attachmentPath = RegExp( + r'^/messages/([^/]+)/attachments/([^/]+)$', + ); + static final RegExp _scheduledPath = RegExp(r'^/scheduled-messages/([^/]+)$'); + static final RegExp _imagePath = RegExp(r'^/mail-images/([^/]+)$'); + + String _idOf(RegExp pattern, String path, [int group = 1]) => + Uri.decodeComponent(pattern.firstMatch(path)!.group(group)!); + + void _installReader() { + server.onPattern('GET', _messagePath, (RequestOptions options) async { + final String id = _idOf(_messagePath, options.path); + final Object? images = options.queryParameters[kMailImagesQuery]; + detailRequests.add((id: id, images: images)); + detailQueries.add(( + id: id, + query: Map.of(options.queryParameters), + )); + final Completer? gate = detailGate; + if (gate != null) { + detailGate = null; + await gate.future; + } + final FakeResponse? failure = detailFailure; + if (failure != null) return failure; + final Map? detail = detailJsonOf( + id, + showImages: images == kMailImagesShow, + highlighted: isSearchQuery(options.queryParameters), + ); + return detail == null + ? FakeResponse.fail(404, 'RES_001') + : FakeResponse.ok(detail); + }); + server.onPattern('PATCH', _messagePath, (RequestOptions options) async { + final String id = _idOf(_messagePath, options.path); + final Map body = options.data! as Map; + singlePatches.add((id: id, body: body)); + await singlePatchGate?.future; + final FakeResponse? failure = singlePatchFailure; + if (failure != null) return failure; + final ({String mailboxId, Map message})? found = _find( + id, + ); + if (found == null) return FakeResponse.fail(404, 'RES_001'); + final bool moves = + body.containsKey('mailboxId') || body.containsKey('isJunk'); + if (moves && found.message['pendingSend'] != null) { + return FakeResponse.fail(409, 'MAIL_015'); + } + _apply(id, body); + return FakeResponse.ok(Map.of(_find(id)!.message)); + }); + server.onPattern('DELETE', _scheduledPath, (RequestOptions options) async { + final String id = _idOf(_scheduledPath, options.path); + cancellations.add(id); + final FakeResponse? failure = cancelFailure; + if (failure != null) return failure; + final ({String mailboxId, Map message})? found = _find( + id, + ); + if (found == null) return FakeResponse.fail(404, 'RES_001'); + if (found.message['pendingSend'] == null) { + return FakeResponse.fail(409, 'MAIL_014'); + } + found.message['pendingSend'] = null; + _apply(id, {'mailboxId': kDraftsId}); + return FakeResponse.ok({'draftId': id}); + }); + server.onPattern('GET', _attachmentPath, (RequestOptions options) { + final String attachmentId = _idOf(_attachmentPath, options.path, 2); + final FakeResponse Function()? file = attachmentFiles[attachmentId]; + return file == null ? FakeResponse.fail(404, 'RES_001') : file(); + }); + server.onPattern('GET', _imagePath, (RequestOptions options) { + final String token = _idOf(_imagePath, options.path); + return mailImages[token] ?? FakeResponse.fail(404, 'RES_001'); + }); + } + + /// 메일 한 통의 읽기 칸 — 목록 칸 위에 [details] (와 외부 이미지를 보일 때 [shownImageDetails] · 강조 쿼리로 물었을 때 [highlightDetails])를 덮는다. + /// 없는 메일은 null + Map? detailJsonOf( + String id, { + bool showImages = false, + bool highlighted = false, + }) { + final ({String mailboxId, Map message})? found = _find(id); + if (found == null) return null; + return { + ...found.message, + 'cc': [], + 'bcc': [], + 'replyTo': [], + 'sentAt': found.message['receivedAt'], + 'messageId': '<$id@example.com>', + 'references': [], + 'htmlBody': null, + 'textBody': '본문 $id', + 'hasBlockedImages': false, + 'imagesShown': false, + 'isHtmlBodyOmitted': false, + 'isBodyTruncated': false, + 'attachments': [], + 'largeFiles': [], + ...?details[id], + if (showImages) ...?shownImageDetails[id], + if (highlighted) ...?highlightDetails[id], + }; + } + + /// 메일 하나를 [mailboxId] 에 두고 읽기 칸을 [detail] 로 덮는다 + void put( + String mailboxId, + Map message, { + Map detail = const {}, + }) { + (messages[mailboxId] ??= >[]).add(message); + details[message['id']! as String] = detail; + } + + /// 메일이 지금 든 메일함 (없으면 null) + String? mailboxOf(String id) => _find(id)?.mailboxId; + + /// 메일을 [mailboxId] 로 옮긴다(다른 화면 · 다른 기기에서 옮긴 것처럼) — 옮긴 뒤의 칸 + Map move(String id, String mailboxId) { + _apply(id, {'mailboxId': mailboxId}); + return _find(id)!.message; + } + + /// 메일의 지금 칸 (없으면 null) + Map? messageOf(String id) => _find(id)?.message; + + /// [mailboxId] 에 메일 [count] 통을 둔다 — id 는 `1` 부터(앞이 최신) + void seed( + String mailboxId, + int count, { + String prefix = 'm', + bool isRead = false, + }) { + messages[mailboxId] = >[ + for (int n = 1; n <= count; n++) + messageJson( + '$prefix$n', + mailboxId: mailboxId, + receivedAt: kTestNow.subtract(Duration(minutes: n)), + isRead: isRead, + ), + ]; + } + + /// 맨 앞에 새 메일을 넣는다(새 메일이 왔다) + void arrive(String mailboxId, Map message) { + (messages[mailboxId] ??= >[]).insert(0, message); + } + + ({String mailboxId, Map message})? _find(String id) { + for (final MapEntry>> entry + in messages.entries) { + for (final Map message in entry.value) { + if (message['id'] == id) { + return (mailboxId: entry.key, message: message); + } + } + } + return null; + } + + void _apply(String id, Map body) { + final ({String mailboxId, Map message})? found = _find(id); + if (found == null) return; + final Map message = found.message; + if (body['isRead'] case final bool isRead) message['isRead'] = isRead; + if (body['isFlagged'] case final bool isFlagged) { + message['isFlagged'] = isFlagged; + } + if (body['mailboxId'] case final String target + when target != found.mailboxId) { + messages[found.mailboxId]!.remove(message); + message['mailboxIds'] = [target]; + final List> into = messages[target] ??= + >[]; + into.add(message); + into.sort( + (Map a, Map b) => + (b['receivedAt']! as String).compareTo(a['receivedAt']! as String), + ); + } + } + + /// [mailboxId] 에 있는 메일 id (차례대로) + List idsIn(String mailboxId) => [ + for (final Map message + in messages[mailboxId] ?? >[]) + message['id']! as String, + ]; +} diff --git a/flutter/test/support/mail_harness.dart b/flutter/test/support/mail_harness.dart new file mode 100644 index 0000000..6306e51 --- /dev/null +++ b/flutter/test/support/mail_harness.dart @@ -0,0 +1,106 @@ +import 'package:arasystem_app/app.dart'; +import 'package:arasystem_app/core/api/api_paths.dart'; +import 'package:arasystem_app/core/files/downloaded_files.dart'; +import 'package:arasystem_app/core/files/picked_files.dart'; +import 'package:arasystem_app/core/router/app_router.dart'; +import 'package:arasystem_app/core/utils/provider_retry.dart'; +import 'package:arasystem_app/features/mail/data/mail_link_launcher.dart'; +import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart'; + +import 'fake_webview.dart'; +import 'fakes.dart'; +import 'file_fakes.dart'; +import 'mail_fakes.dart'; +import 'provider_harness.dart'; + +/// 시험 직원 (fakes.dart 의 `sessionJson` 과 같다) +const String kMailTestAccount = 'ara.kim@arasystem.it.kr'; + +/// 휴대폰 화면 크기 (보드의 390 × 844) +const Size kPhoneSize = Size(390, 844); + +/// 메일 화면 시험대 — 로그인한 앱 전체를 가짜 서버(인증 + 메일)로 띄운다. +/// +/// 기기에 기대는 것은 가짜로 바꾼다: WebView([webView]), 받은 파일 보관([files]) · 다른 앱으로 열기([opener]), +/// 바깥 브라우저([links]), 고른 파일([picked] — 고르는 창 · 복사본). +class MailAppHarness { + MailAppHarness(); + + final ProviderHarness base = ProviderHarness(); + late final FakeMailBackend mail = FakeMailBackend(base.server); + final FakeWebViewPlatform webView = FakeWebViewPlatform(); + final MemoryFileStore files = MemoryFileStore(); + final RecordingFileOpener opener = RecordingFileOpener(); + final RecordingLinkLauncher links = RecordingLinkLauncher(); + final FakePickedFiles picked = FakePickedFiles(); + + /// 고른 파일을 두는 곳 — 처음은 [picked](가짜). 기기 채널 쪽 규칙(시한 등)을 보는 시험은 [pump] 전에 바꿔 끼운다 + late PickedFileStore pickedStore = picked; + + /// 시험마다 더하는 덮어쓰기 — [pump] 전에 채운다 + final List overrides = []; + + FakeServer get server => base.server; + ManualClock get clock => base.clock; + RecordingNotifier get notifier => base.notifier; + + late ProviderContainer container; + + GoRouter get router => container.read(routerProvider); + + /// 지금 주소 + String location() => + router.routerDelegate.currentConfiguration.uri.toString(); + + /// 앱을 띄운다 — 리프레시 토큰으로 세션을 되살리고 메일 첫 화면(또는 [location])까지 그린다 + Future pump( + WidgetTester tester, { + String? location, + Size size = kPhoneSize, + }) async { + tester.view.physicalSize = size * 3; + tester.view.devicePixelRatio = 3; + addTearDown(tester.view.reset); + WebViewPlatform.instance = webView; + await base.prepare(refreshToken: 'refresh-1'); + server.on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.ok({ + ...sessionJson(), + 'tokens': tokensJson(2, now: clock.now), + }), + ); + server.on('GET', ApiPaths.me, (_) => FakeResponse.ok(sessionJson())); + mail.install(); + await tester.pumpWidget( + ProviderScope( + retry: noAutomaticRetry, + overrides: [ + ...base.overrides, + downloadedFileStoreProvider.overrideWithValue(files), + fileOpenerProvider.overrideWithValue(opener), + mailLinkLauncherProvider.overrideWithValue(links.call), + pickedFileStoreProvider.overrideWithValue(pickedStore), + ...overrides, + ], + child: const App(), + ), + ); + await tester.pumpAndSettle(); + container = ProviderScope.containerOf( + tester.element(find.byType(MaterialApp)), + ); + expect(container.read(mailAccountProvider), kMailTestAccount); + if (location != null) { + router.go(location); + await tester.pumpAndSettle(); + } + } +} diff --git a/flutter/test/support/provider_harness.dart b/flutter/test/support/provider_harness.dart index 83fc5b1..d2c8603 100644 --- a/flutter/test/support/provider_harness.dart +++ b/flutter/test/support/provider_harness.dart @@ -25,6 +25,9 @@ class ProviderHarness { late final RecordingNotifier notifier = RecordingNotifier(clock); late final RecordingPushRegistrar push = RecordingPushRegistrar(store); + /// 거짓이면 기기 등록을 기록하는 가짜로 바꾸지 않는다 — 세션이 진짜 새 메일 알림 컨트롤러를 부른다(알림 화면 시험) + bool recordPush = true; + /// ProviderScope · ProviderContainer 에 넣을 덮어쓰기 List get overrides => [ tokenStoreProvider.overrideWithValue(store), @@ -32,7 +35,7 @@ class ProviderHarness { clockProvider.overrideWithValue(clock.call), errorNotifierProvider.overrideWithValue(notifier), userAgentProvider.overrideWithValue(kTestUserAgent), - pushRegistrarProvider.overrideWithValue(push), + if (recordPush) pushRegistrarProvider.overrideWithValue(push), ]; /// 앱과 같게 프로바이더 다시 하기를 끈 컨테이너 diff --git a/flutter/test/support/push_fakes.dart b/flutter/test/support/push_fakes.dart new file mode 100644 index 0000000..3045c67 --- /dev/null +++ b/flutter/test/support/push_fakes.dart @@ -0,0 +1,339 @@ +import 'dart:async'; + +import 'package:arasystem_app/core/api/api_paths.dart'; +import 'package:arasystem_app/core/api/dio_provider.dart'; +import 'package:arasystem_app/core/constants/app_info_provider.dart'; +import 'package:arasystem_app/core/modules/app_modules.dart'; +import 'package:arasystem_app/core/push/notification_system.dart'; +import 'package:arasystem_app/core/push/push_messaging.dart'; +import 'package:arasystem_app/core/push/push_preferences.dart'; +import 'package:arasystem_app/core/push/push_target.dart'; +import 'package:arasystem_app/core/storage/token_store.dart'; +import 'package:arasystem_app/core/storage/token_store_provider.dart'; +import 'package:arasystem_app/core/utils/app_foreground.dart'; +import 'package:arasystem_app/core/utils/provider_retry.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart'; + +import 'fakes.dart'; + +/// 새 메일 알림 (서버가 싣는 데이터 `{ module: 'mail', kind: 'new-mail' }`) +const PushMessage kNewMailPush = PushMessage( + target: PushTarget(module: AppModuleId.mail), + kind: PushKinds.newMail, +); + +/// 로그인이 저절로 끝났다는 알림 +const PushMessage kSessionEndedPush = PushMessage( + target: PushTarget(module: AppModuleId.mail), + kind: PushKinds.sessionEnded, +); + +/// 가짜 FCM — 권한 · 등록 · 소식을 시험이 손으로 보낸다. 부른 차례를 [calls] 에 적는다 +class FakePushMessaging implements PushMessaging { + FakePushMessaging({this.available = true}); + + bool available; + + /// 지금 권한 + PushPermission current = PushPermission.notAsked; + + /// [requestPermission] 이 고를 값 — null 이면 창이 뜨지 않는다(지금 권한 그대로) + PushPermission? answer = PushPermission.granted; + int permissionRequests = 0; + + /// [register] 가 끝나면 보낼 FID — null 이면 소식을 보내지 않는다(시험이 [emitFid] 로) + String? fidOnRegister = 'fid-1'; + Object? registerError; + Object? unregisterError; + Completer? registerGate; + Completer? unregisterGate; + + /// 꺼져 있던 앱을 띄운 알림 + PushMessage? initial; + + /// 부른 차례 — 'register' · 'unregister' · 'requestPermission' + final List calls = []; + + final StreamController _registered = + StreamController.broadcast(); + final StreamController _foreground = + StreamController.broadcast(); + final StreamController _opened = + StreamController.broadcast(); + + int get registerCalls => + calls.where((String call) => call == 'register').length; + + int get unregisterCalls => + calls.where((String call) => call == 'unregister').length; + + /// FCM 이 FID 를 알린다(등록 · FID 바뀜) + void emitFid(String fid) => _registered.add(fid); + + /// 앱이 앞에 있을 때 알림이 왔다 + void receive(PushMessage message) => _foreground.add(message); + + /// 앱이 뒤에 있을 때 알림을 눌렀다 + void tap(PushMessage message) => _opened.add(message); + + @override + bool get isAvailable => available; + + @override + Future permission() async => current; + + @override + Future requestPermission() async { + permissionRequests++; + calls.add('requestPermission'); + final PushPermission? chosen = answer; + if (chosen != null) current = chosen; + return current; + } + + @override + Future register() async { + calls.add('register'); + await registerGate?.future; + final Object? error = registerError; + if (error != null) throw error; + final String? fid = fidOnRegister; + if (fid != null) scheduleMicrotask(() => _registered.add(fid)); + } + + /// [unregister] 를 부를 때 시험이 볼 것(그때 토큰이 남아 있었나 등) + void Function()? onUnregister; + + @override + Future unregister() async { + calls.add('unregister'); + onUnregister?.call(); + await unregisterGate?.future; + final Object? error = unregisterError; + if (error != null) throw error; + } + + @override + Stream get onRegistered => _registered.stream; + + @override + Stream get onForegroundMessage => _foreground.stream; + + @override + Stream get onOpened => _opened.stream; + + @override + Future initialOpen() async { + final PushMessage? message = initial; + initial = null; + return message; + } +} + +/// 가짜 알림창 · 알림 설정 +class FakeNotificationSystem implements NotificationSystem { + NotificationStatus current = const NotificationStatus( + appEnabled: true, + channelEnabled: true, + sdkInt: 34, + ); + int statusCalls = 0; + int clears = 0; + int settingsOpened = 0; + + /// [openSettings] 가 열었다고 답하는가 + bool opens = true; + + @override + Future status() async { + statusCalls++; + return current; + } + + @override + Future openSettings() async { + settingsOpened++; + return opens; + } + + @override + Future clearNewMail() async => clears++; +} + +/// 메모리 새 메일 알림 값 — 쓴 값을 [values] 로 본다 +class MemoryPushPreferences implements PushPreferences { + MemoryPushPreferences([this.values = PushPreferenceValues.initial]); + + PushPreferenceValues values; + + /// 두면 읽기가 이것이 끝날 때까지 기다린다 + Completer? loadGate; + + @override + Future load() async { + await loadGate?.future; + return values; + } + + PushPreferenceValues _with({ + bool? enabled, + bool? offerDismissed, + bool? pendingUnregister, + Object? lastFid = _keep, + bool? serverRegistered, + }) => PushPreferenceValues( + enabled: enabled ?? values.enabled, + offerDismissed: offerDismissed ?? values.offerDismissed, + pendingUnregister: pendingUnregister ?? values.pendingUnregister, + lastFid: identical(lastFid, _keep) ? values.lastFid : lastFid as String?, + serverRegistered: serverRegistered ?? values.serverRegistered, + ); + + static const Object _keep = Object(); + + @override + Future setEnabled(bool value) async => values = _with(enabled: value); + + @override + Future setOfferDismissed(bool value) async => + values = _with(offerDismissed: value); + + @override + Future setPendingUnregister(bool value) async => + values = _with(pendingUnregister: value); + + @override + Future setLastFid(String? fid) async => values = _with(lastFid: fid); + + @override + Future setServerRegistered(bool value) async => + values = _with(serverRegistered: value); +} + +/// 앞에 보이는지를 시험이 바꾼다 (앱 생명주기 대신) +class FakeForeground extends AppForeground { + @override + bool build() => true; + + void set(bool value) => state = value; +} + +/// 가짜 서버의 새 메일 알림 · 앱 설정 경로 +class FakePushBackend { + FakePushBackend(this.server); + + final FakeServer server; + + /// 받은 등록(PUT 본문 · Bearer) + final List<({Map body, String? bearer})> puts = + <({Map body, String? bearer})>[]; + final List deletes = []; + final List seen = []; + final List configRequests = []; + + /// 두면 그 응답으로 답한다(실패 시험) + FakeResponse? putFailure; + FakeResponse? deleteFailure; + Completer? putGate; + Completer? deleteGate; + + /// 앱 설정의 `data` — [configResponse] 가 있으면 그것으로 답한다 + Map config = { + 'minSupportedVersion': {'android': '1.0.0', 'ios': null}, + 'pushAvailable': true, + }; + FakeResponse? configResponse; + + void install() { + server + ..on('PUT', ApiPaths.pushRegistration, (RequestOptions options) async { + final Object? data = options.data; + puts.add(( + body: data is Map + ? Map.of(data) + : {}, + bearer: bearerTokenOf(options), + )); + await putGate?.future; + return putFailure ?? + FakeResponse.ok({ + 'updatedAt': '2026-11-07T05:00:00.000Z', + }); + }) + ..on('DELETE', ApiPaths.pushRegistration, (RequestOptions options) async { + deletes.add(options); + await deleteGate?.future; + return deleteFailure ?? const FakeResponse(204, null, rawBody: ''); + }) + ..on('DELETE', ApiPaths.pushRegistrationUnseen, (RequestOptions options) { + seen.add(options); + return const FakeResponse(204, null, rawBody: ''); + }) + ..on('GET', ApiPaths.appConfig, (RequestOptions options) { + configRequests.add(options); + return configResponse ?? FakeResponse.ok(config); + }); + } +} + +/// 새 메일 알림 시험대 — 진짜 세션 노티파이어 · 알림 컨트롤러를 가짜 FCM · 알림창 · 서버로 띄운다 +/// ([ProviderHarness] 와 달리 기기 등록을 가짜로 바꾸지 않는다) +class PushHarness { + PushHarness(); + + final FakeServer server = FakeServer(); + final MemorySecureStore secure = MemorySecureStore(); + final MemoryFirstRunMarker firstRun = MemoryFirstRunMarker(); + final ManualClock clock = ManualClock(); + final FakePushMessaging messaging = FakePushMessaging(); + final FakeNotificationSystem notifications = FakeNotificationSystem(); + final MemoryPushPreferences prefs = MemoryPushPreferences(); + late final FakePushBackend push = FakePushBackend(server); + + late final TokenStore store = TokenStore( + secure: secure, + firstRun: firstRun, + clock: clock.call, + ); + late final RecordingNotifier notifier = RecordingNotifier(clock); + + /// 시험마다 더하는 덮어쓰기 + final List extra = []; + + List get overrides => [ + tokenStoreProvider.overrideWithValue(store), + httpClientAdapterProvider.overrideWithValue(server), + clockProvider.overrideWithValue(clock.call), + errorNotifierProvider.overrideWithValue(notifier), + userAgentProvider.overrideWithValue(kTestUserAgent), + appVersionProvider.overrideWithValue('1.0.0'), + pushMessagingProvider.overrideWithValue(messaging), + notificationSystemProvider.overrideWithValue(notifications), + pushPreferencesProvider.overrideWithValue(prefs), + appForegroundProvider.overrideWith(FakeForeground.new), + ...extra, + ]; + + ProviderContainer container() => + ProviderContainer(overrides: overrides, retry: noAutomaticRetry); + + /// 보안 저장소에 리프레시 토큰을 두고(앱을 새로 띄운 상태) 준비한다 — 갱신 · me 는 [kTestEmail] 의 세션으로 답한다 + Future prepare({String? refreshToken}) async { + if (refreshToken != null) secure.values[kRefreshTokenKey] = refreshToken; + push.install(); + server + ..on( + 'POST', + ApiPaths.appRefresh, + (_) => FakeResponse.ok({ + ...sessionJson(), + 'tokens': tokensJson(2, now: clock.now), + }), + ) + ..on('GET', ApiPaths.me, (_) => FakeResponse.ok(sessionJson())) + ..on('POST', ApiPaths.appLogout, (_) => FakeResponse.ok()); + await store.prepare(); + } +} diff --git a/flutter/test/support/push_screen_harness.dart b/flutter/test/support/push_screen_harness.dart new file mode 100644 index 0000000..3e3d95e --- /dev/null +++ b/flutter/test/support/push_screen_harness.dart @@ -0,0 +1,73 @@ +import 'package:arasystem_app/core/constants/app_info_provider.dart'; +import 'package:arasystem_app/core/push/notification_system.dart'; +import 'package:arasystem_app/core/push/push_controller.dart'; +import 'package:arasystem_app/core/push/push_messaging.dart'; +import 'package:arasystem_app/core/push/push_preferences.dart'; +import 'package:arasystem_app/core/push/push_state.dart'; +import 'package:arasystem_app/features/settings/presentation/settings_texts.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/misc.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'fakes.dart'; +import 'mail_fakes.dart'; +import 'mail_harness.dart'; +import 'push_fakes.dart'; + +/// 새 메일 알림 화면 시험대 — 로그인한 앱 전체(메일 시험대)를 띄우고, 세션이 진짜 새 메일 알림 컨트롤러를 부르게 한다. +/// 기기 쪽(FCM · 알림창 · 이 휴대폰의 값)과 서버(`/push-registrations/current` · `/app-config`)는 가짜다. +/// +/// 처음 모습: 안드로이드 14 · 알림 권한 허용 · 이 휴대폰에서 켬 · 서버가 보낼 수 있음 — 시험마다 [pump] 전에 바꾼다. +class PushScreenHarness { + PushScreenHarness() { + app.base.recordPush = false; + messaging.current = PushPermission.granted; + app.overrides.addAll([ + appVersionProvider.overrideWithValue(kPushScreenAppVersion), + pushMessagingProvider.overrideWithValue(messaging), + notificationSystemProvider.overrideWithValue(notifications), + pushPreferencesProvider.overrideWithValue(prefs), + ]); + } + + final MailAppHarness app = MailAppHarness(); + final FakePushMessaging messaging = FakePushMessaging(); + final FakeNotificationSystem notifications = FakeNotificationSystem(); + final MemoryPushPreferences prefs = MemoryPushPreferences(); + late final FakePushBackend push = FakePushBackend(app.server); + + FakeServer get server => app.server; + RecordingNotifier get notifier => app.notifier; + + /// 지금 알림 상태 + PushState get state => app.container.read(pushControllerProvider); + + /// 앱을 띄운다 — 세션을 되살리고 알림 등록(FCM · 서버)까지 마친 뒤 [location](없으면 받은편지함)을 그린다 + Future pump(WidgetTester tester, {String? location}) async { + push.install(); + app.mail.seed(kInboxId, 2); + await app.pump(tester, location: location); + await settle(tester); + } + + /// 설정 탭을 연다 + Future openSettings(WidgetTester tester) async { + await tester.tap( + find.descendant( + of: find.byType(NavigationBar), + matching: find.text(SettingsTexts.home), + ), + ); + await settle(tester); + } +} + +/// 시험 앱의 판 +const String kPushScreenAppVersion = '1.2.0'; + +/// 그리기 · 가짜 서버 응답이 모두 끝날 때까지 — 서버 응답은 시계가 가야 온다(화면이 가만히 있어도) +Future settle(WidgetTester tester) async { + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); +} diff --git a/flutter/test/support/ts_table.dart b/flutter/test/support/ts_table.dart new file mode 100644 index 0000000..04de897 --- /dev/null +++ b/flutter/test/support/ts_table.dart @@ -0,0 +1,146 @@ +import 'dart:io'; + +/// 모노레포의 TypeScript 표본 표를 읽는다 — 시험 전용(앱 코드에서 쓰지 않는다). +/// +/// `export const NAME … = [` 부터 짝이 맞는 `]` 까지를 풀어 글(String) · 목록(List)의 나무로 돌려준다. +/// 읽는 것: 작은따옴표 · 큰따옴표 글, `\u` · `\u{…}` · `\n` · `\t` · `\r` · `\0` · `\\` · `\'` · `\"` 이스케이프, 대괄호 · 쉼표(끝 쉼표 포함) · `//` 주석 · `/* */` 주석. +/// 줄바꿈 모양(prettier)이 바뀌어도 읽는다. 그 밖의 꼴(템플릿 글 · 수 · 변수)이 나오면 [FormatException] — 표의 모양이 바뀐 것을 시험이 알아챈다. +List readTsArray(String source, String name) { + final int at = source.indexOf('export const $name'); + if (at < 0) throw FormatException('표 $name 이 없다'); + final int open = source.indexOf('= [', at); + if (open < 0) throw FormatException('표 $name 의 배열이 없다'); + final _TsReader reader = _TsReader(source, open + 2); + final Object? value = reader.readValue(); + if (value is! List) throw FormatException('표 $name 이 배열이 아니다'); + return value; +} + +/// [path](flutter/ 에서 본 상대 경로)의 TS 파일에서 [name] 표를 읽는다 — `flutter test` 는 flutter/ 에서 돈다 +List readTsArrayFile(String path, String name) => + readTsArray(File(path).readAsStringSync(), name); + +class _TsReader { + _TsReader(this._source, this._index); + + final String _source; + int _index; + + Object? readValue() { + _skip(); + _expectMore(); + final String char = _source[_index]; + if (char == '[') return _readArray(); + if (char == "'" || char == '"') return _readString(); + throw FormatException('모르는 값', _source, _index); + } + + List _readArray() { + _index++; + final List items = []; + while (true) { + _skip(); + _expectMore(); + if (_source[_index] == ']') { + _index++; + return items; + } + items.add(readValue()); + _skip(); + _expectMore(); + final String next = _source[_index]; + if (next == ',') { + _index++; + } else if (next != ']') { + throw FormatException('쉼표나 ] 가 와야 한다', _source, _index); + } + } + } + + String _readString() { + final String quote = _source[_index]; + _index++; + final StringBuffer out = StringBuffer(); + while (true) { + _expectMore(); + final String char = _source[_index]; + if (char == quote) { + _index++; + return out.toString(); + } + if (char == '\n' || char == '\r') { + throw FormatException('글 안에 줄바꿈', _source, _index); + } + if (char != r'\') { + out.write(char); + _index++; + continue; + } + _index++; + _expectMore(); + final String escape = _source[_index]; + _index++; + switch (escape) { + case 'n': + out.write('\n'); + case 't': + out.write('\t'); + case 'r': + out.write('\r'); + case '0': + out.write('\u0000'); + case r'\': + case "'": + case '"': + out.write(escape); + case 'u': + out.writeCharCode(_readUnicodeEscape()); + default: + throw FormatException('모르는 이스케이프 \\$escape', _source, _index); + } + } + } + + /// `\u` 뒤 — `{1F600}` 또는 네 자리 16진수 + int _readUnicodeEscape() { + _expectMore(); + if (_source[_index] == '{') { + final int close = _source.indexOf('}', _index); + if (close < 0) throw FormatException('닫히지 않은 \\u{', _source, _index); + final String hex = _source.substring(_index + 1, close); + _index = close + 1; + return int.parse(hex, radix: 16); + } + if (_index + 4 > _source.length) { + throw FormatException('짧은 \\u', _source, _index); + } + final String hex = _source.substring(_index, _index + 4); + _index += 4; + return int.parse(hex, radix: 16); + } + + /// 공백 · 주석을 건너뛴다 + void _skip() { + while (_index < _source.length) { + final String char = _source[_index]; + if (char == ' ' || char == '\n' || char == '\r' || char == '\t') { + _index++; + } else if (_source.startsWith('//', _index)) { + final int end = _source.indexOf('\n', _index); + _index = end < 0 ? _source.length : end + 1; + } else if (_source.startsWith('/*', _index)) { + final int end = _source.indexOf('*/', _index + 2); + if (end < 0) throw FormatException('닫히지 않은 주석', _source, _index); + _index = end + 2; + } else { + return; + } + } + } + + void _expectMore() { + if (_index >= _source.length) { + throw FormatException('표가 중간에 끝났다', _source, _index); + } + } +} diff --git a/flutter/windows/flutter/generated_plugin_registrant.cc b/flutter/windows/flutter/generated_plugin_registrant.cc index 0c50753..0e0afee 100644 --- a/flutter/windows/flutter/generated_plugin_registrant.cc +++ b/flutter/windows/flutter/generated_plugin_registrant.cc @@ -6,9 +6,15 @@ #include "generated_plugin_registrant.h" +#include #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + FirebaseCorePluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/flutter/windows/flutter/generated_plugins.cmake b/flutter/windows/flutter/generated_plugins.cmake index d0b33f8..9e4353b 100644 --- a/flutter/windows/flutter/generated_plugins.cmake +++ b/flutter/windows/flutter/generated_plugins.cmake @@ -3,7 +3,9 @@ # list(APPEND FLUTTER_PLUGIN_LIST + firebase_core flutter_secure_storage_windows + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/frontend/src/test-utils/search-text-samples.ts b/frontend/src/test-utils/search-text-samples.ts index 10054f9..6315e9c 100644 --- a/frontend/src/test-utils/search-text-samples.ts +++ b/frontend/src/test-utils/search-text-samples.ts @@ -4,6 +4,7 @@ /** * ⚠️ backend `test/search-text-samples.ts` 와 **같은 표**다(행과 순서까지 그대로). * 한쪽을 고치면 둘 다 고친다 — 접기 · 위치 규칙을 바꾸면 두 쪽 함수와 두 표를 함께 (서버가 준 맞은 곳의 위치를 화면이 그대로 그린다). + * 앱 시험(`flutter/test/features/mail/search_text_test.dart`)은 backend 표의 `FOLD_SAMPLES` · `GRAM_SAMPLES` 를 그대로 읽는다 — 이 표와 행이 같으니 고칠 때 세 쪽이 함께 본다. * 답은 손으로 적었고 두 쪽 테스트가 함수의 결과와 견준다. */