generated from ttipo/project-base
feat(flutter): 앱 첫 판 화면 — 로그인 · 메일 목록 · 읽기 · 쓰기 · 검색 · 새 메일 알림 #21
@@ -26,8 +26,8 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S
|
||||
| **로그인 화면 (네 단계 — `App1Login`)** | `lib/features/auth/presentation/pages/`(login · login_code · password_change · two_factor_setup · restricted_login) · `widgets/`(`login_frame` · `login_fields` · `login_parts`) · 글 `core/constants/login_texts.dart`(제목은 `screen_titles.dart`) · `data/`(`two_factor_repository` · `support_contact_repository` · `authenticator_launcher`) | 노티파이어의 제한 단계 API(`attempt` · `passedCodeStep` · `heldLoginListenable` · `changePassword` · `enableTwoFactor` · `finishRestrictedStep` → `RestrictedStepResult { done, needsPassword, stale }`). 로그인 비밀번호는 두 제한 단계에서만 10분 쥔다(모르는 제한 · 제한이 풀리면 지움). 칸 · 상자에 보이는 코드만 알림을 끈다(`kLoginInlineCodes` · `kLoginCodeInlineCodes` · `kPasswordChangeInlineCodes` · `kTwoFactorSetupInlineCodes` — 앱 디자인 README 의 표), `AUTH_002` 는 스낵바 없이 로그인 화면 상자만. 2단계 키 · otpauth 주소는 그 화면 State 에만. **url_launcher**: `canLaunchUrl` 은 쓰지 않는다(debug 판이 주소째 로그를 남긴다) — `launchUrl(externalNonBrowserApplication)` 만, 실패 예외에 주소가 들어 있어 삼킨다, `<queries>` 는 필요 없다. 한글 줄바꿈은 `shared/widgets/keep_all_text.dart`(시험은 `findCopy`). 보안 설정 요청 한도 `kSecuritySettingTimeout` 30초 |
|
||||
| **메일 목록 (`App1MailList`)** · 앱 틀 | `lib/features/mail/` — data(models · `mail_repository.dart`) · domain(`mailbox_tree` · `mail_list_merge` · `mail_bulk` — 순수 함수) · presentation(`pages/mail_list_page.dart` · providers · widgets), 틀 `core/router/app_shell.dart` · `shell_bar.dart`, `core/utils/`(`app_foreground` · `accessible_navigation` · `korean_format`), `shared/widgets/confirm_dialog.dart`, 시험 도구 `test/support/mail_fakes.dart` · `mail_harness.dart` | **틀은 Scaffold 가 아니다**(Column + 탭 막대 — 탭 안 화면마다 자기 Scaffold 를 둔다, 없으면 스낵바 · Material 조상이 없다). 탭 막대는 `MediaQuery.removePadding(removeTop)` 으로 감싸고, 화면이 숨기려면 `ShellBarHidden`(모듈마다 센다 — 보이는지는 TickerMode). Scaffold 는 넓은 단추 자리에 안전 영역을 넘기지 않으니 Scaffold 밖에서 읽어 넘긴다. 목록 상태는 메일함마다(`mailListProvider(String?)` autoDispose), 세션이 바뀌면 `mailAccountProvider` 로 처음부터. 알림은 두 갈래 — 단추 있는 `mailToastProvider`(한 번에 하나 · 화면 낭독기 켠 동안 닫지 않음) · 단추 없는 `mailNoticeSinkProvider`(전역 스낵바). 생명주기 안에서는 프로바이더를 바꾸지 않는다(그린 뒤 · 마이크로태스크), 다른 노티파이어 인스턴스를 든 노티파이어는 세션이 바뀐 뒤 다시 읽는다. 가짜 서버는 응답이 한 프레임 안에 오므로 "받는 중이 끝나는 순간"을 기다리는 위젯을 만들지 않는다 |
|
||||
| **메일 읽기 (`App1Reader`)** | `lib/features/mail/` — domain(`mail_html`(두 번째 정화 · 중첩 비용 · CSP 문서 · 중계 그림 바꿔 넣기) · `mail_images` · `mail_links` · `compose_request` · `mail_body_notices`) · `data/`(`models/message_detail.dart` · `mail_link_launcher.dart`) · presentation(`pages/message_reader_page.dart` · `providers/message_reader_controller.dart` · `attachment_downloads.dart` · widgets `mail_body_view` · `reader_*`), 시험 도구 `test/support/fake_webview.dart`(`WebViewPlatform.instance`) · `file_fakes.dart` | **WebView 에는 `buildMailDocument` 의 문서만 넣는다**(CSP 를 맨 앞 · JS 끔 · 처음 문서 말고는 탐색을 막음 · 안드로이드는 파일 · content 접근 · 위치 · 섞인 내용 끔, 아래 틀(iframe)의 탐색은 판정을 거치지 않으니 막는 것은 정화(내용째 버림)와 CSP 다). 받은 HTML 은 서버가 정화했어도 `sanitizeMailHtml` 을 한 번 더 지난다 — package:html 은 `parse().body`(`parseFragment` 는 맨 위 형제가 많으면 제곱), 그 전에 `htmlNestingCost`(선형 · 파서보다 깊게 센다)로 한도를 보고, 바로 할지 isolate 로 보낼지는 `mailBodyBuildPlaceOf` 하나로 고른다. 중계 그림은 Dio(Bearer)로 받아 `data:` 로 넣고 토큰은 WebView 에 가지 않는다 — 문서에 싣는 양은 **나온 횟수까지** 센다(`resolveProxiedImages`). 응답은 그 요청이 바꾼 칸만 지금 요약에 덮는다(`_applyResponse`). 메일 id 마다 autoDispose 프로바이더(라우트 `ValueKey(id)`) · 받기 세대 · 본문 세대 · 세션 세대로 늦은 응답을 버린다. 목록에는 `MailChange(updated, removedIds)` 로 알린다. 쓰기 화면을 여는 값은 웹과 같은 쿼리(`replyTo` · `all` · `forward` · `draft` — `composeIntentOf`), mailto 로 채울 값은 go_router `extra` 의 `ComposePrefill` |
|
||||
| 받은 파일 · 형식 | `lib/core/files/` — `downloaded_files.dart`(`DownloadedFileStore` · `CacheDirFileStore` · `FileOpener`) · `file_names.dart`(`safeFileName`) · `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 · 설치 파일 · 인증서)은 받지도 않는다 |
|
||||
| **메일 쓰기 (`App1Compose` — 1차)** | `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_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` · `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 단위) |
|
||||
| 받은 파일 · 고른 파일 · 형식 | `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` |
|
||||
| HTTP 클라이언트 | `lib/core/api/dio_client.dart` · `dio_provider.dart` | Dio 둘: 화면용(인터셉터 줄 `ResponseInterceptor` → `AuthInterceptor` → `ErrorNotifyInterceptor`)과 갱신 · 다시 보내기용(언래핑만). baseUrl=dotenv `API_URL`(`api_config.dart`), User-Agent `ARASYSTEM-App/<판> (Android <판>; <기종>)`(`user_agent.dart`). `LogInterceptor` 는 넣지 않는다(비밀번호 · 토큰). 시험은 `httpClientAdapterProvider` 로 가짜 서버를 끼운다 |
|
||||
| 언래핑 · 코드 정규화 | `lib/core/api/response_interceptor.dart` · `api_error_mapper.dart` | `{success,data}→data`(스트림 · 바이트는 그대로 — 그 **실패** 본문은 64KB 까지 읽어 `error.code` 를 꺼낸다(`decodeRawErrorBody`). 스트림을 받는 도중의 실패는 인터셉터를 거치지 않아 부르는 쪽이 `SYS_001` 로 알린다(`DownloadInterrupted`). 비동기 처리기는 무슨 일이 있어도 `handler.next` 로 끝낸다 — 던지면 요청이 매달린다), 200 + `success:false` 도 실패, 실패는 모두 `DioException.error` 에 `ApiException`. 상태 → 코드 폴백(웹 `resolveCodeByStatus` 와 같다) |
|
||||
| **ApiException** | `lib/core/api/api_exception.dart` | `{code, message, traceId, statusCode}`. 화면은 `apiErrorCodeOf(e)` 또는 `e.error as ApiException` 의 **`code`** 로 가른다 |
|
||||
@@ -288,6 +288,8 @@ 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 다운로드가 느려 분리).
|
||||
다른 시점에 검사가 필요하면 Actions 탭의 "Run workflow" 로 수동 실행한다.
|
||||
@@ -297,6 +299,9 @@ await tokenStore.clear(byUser: true); // 로그아웃 (세션 끝은 byUser
|
||||
`ref` 는 `State.dispose` 에서 쓰지 못한다 — 노티파이어를 필드에 쥔다(`initState` 에서 `listenManual` + `read`).
|
||||
되풀이 타이머(남은 시간 막대 250ms)는 `pumpAndSettle` 간격(100ms)보다 길게 둔다. isolate 를 쓰는 일(원문 인용)은 위젯 시험에서 프로바이더로 덮어쓴다(`composeQuoteBuilderProvider`).
|
||||
소스에 보이지 않는 글자를 두지 않는다 — nbsp 는 `'\u00A0'` 이스케이프로 적는다.
|
||||
기기 파일 입출력(고르기 · 복사본)은 시험 시계 안에서 끝나지 않는다 — `PickedFileStore` 를 덮어쓴다(`FakePickedFiles`).
|
||||
무한 진행 막대(고르는 동안)가 떠 있으면 `pumpAndSettle` 이 끝나지 않는다 — 그동안은 `pump()` 로 넘긴다.
|
||||
가짜 서버는 요청 본문을 읽지 않아 보내는 진행(onSendProgress)이 오지 않는다 — 진행을 보려면 `FakeServer.readRequestBodies` 를 켠다.
|
||||
- [ ] **린트로 강제되는 항목**: `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` 가 경고**한다.
|
||||
|
||||
@@ -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<string, AttachmentTypeRule> = new Map([
|
||||
['.pdf', { type: 'application/pdf', accept: ['application/pdf'] }],
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
- 보낸 뒤에는 쓰기를 시작한 화면(읽기 · 목록)으로 돌아가고, 그 화면 아래에 "메일을 보냈습니다. [보내기 취소]"와 남은 시간 막대.
|
||||
- 만들 때 지킬 것(2026-11-06 읽기 화면 리뷰): 읽기 화면의 mailto 링크로 채운 숨은참조가 있으면 숨은참조 칸을 펼쳐 보인다 — 링크가 숨은참조를 몰래 넣을 수 있다(RFC 6068 보안 고려).
|
||||
|
||||
- **구현하며 정한 것**(2026-11-07 — 보드에는 아직 없다. 1차 — 첨부 고르기 · 올리기는 2차)
|
||||
- **구현하며 정한 것**(2026-11-07 — 보드에는 아직 없다. 1차 · 2차(첨부 고르기 · 올리기))
|
||||
- 본문: 글만 쓴다. 보낼 때 서식 본문 + 텍스트를 함께 만든다(계약 2절 "앱이 만드는 모양"). 원문 인용은 글로 만든 덩이라 원문의 표 · 색 · 링크 모양은 빠지고 글은 남는다(웹에서 쓴 답장은 원문 서식이 남는다 — 서식 편집을 넣는 다음 판에서 넓힌다).
|
||||
처음 95% · 저장 · 보내기 직전 100% 로 맞추고, 내 글만으로 넘으면 본문 아래 띠(`MAIL_007`).
|
||||
- 서명: 따로 된 칸(점선 위 · [서명 빼기] → 그 자리에 [서명 넣기] 한 줄). 새 메일은 isForNew, 답장 · 전달은 isForReply 일 때만 넣는다.
|
||||
@@ -149,13 +149,38 @@
|
||||
- 나가기: 잃을 것이 없으면 바로 닫는다(예측 뒤로 가기). 있으면 쓰다 만 주소를 확정하고 저장(5초)한다 — 저장했으면 "임시보관함에 저장했습니다."로 닫고,
|
||||
못 했으면 "저장하지 못한 내용이 있습니다" / "이 화면을 나가면 마지막으로 바꾼 내용이 사라집니다." [계속 쓰기](주 단추 · 처음 초점) [나가기].
|
||||
창을 연 뒤 저장이 끝나 잃을 것이 없어지면 저절로 닫고 떠난다. 떠나기 전 저장을 기다리는 동안은 [보내기]를 막는다. 보내는 중에는 묻지 않고, 세션이 끝나면 창만 닫고 알리지 않는다.
|
||||
- 첨부(1차): 전달하는 원본의 첨부와 저장본에 있던 첨부 · 대용량 파일을 싣고 빼기만 한다. 문제가 있으면 줄 테두리가 빨갛고 줄 아래에 까닭을 적는다
|
||||
(함께 보낼 수 없음 · 찾지 못함 `MAIL_004` · 올리다 멈춤 · 지워짐 · 위험한 파일). [파일 첨부]는 "앱에서 파일 첨부는 아직 준비 중입니다. 지금은 웹메일에서 첨부해 주세요." 첨부 머리의 한도 글은 2차다.
|
||||
올리는 중인 첨부가 있으면 먼저 "올리는 중인 첨부가 있습니다" / "이 화면을 나가면 올리던 파일은 빠집니다." [계속 쓰기](주 단추 · 처음 초점) [나가기]로 묻는다 —
|
||||
[나가기]면 올리던 · 기다리던 파일을 빼고 위 차례(저장 → 닫기)로 간다. 묻는 사이 모두 다 올리면 저절로 이어 가고, 올리지 못한 파일이 새로 생기면 창을 그대로 두고 답을 기다린다.
|
||||
- 첨부
|
||||
- [파일 첨부]는 안드로이드 고르는 창(모든 형식 · 여러 개 · 권한 없음 — 형식으로 거르면 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).
|
||||
- **알려진 한계**: 앱이 안 보이게 될 때 칸에 쓰다 만 주소(칩이 되지 않은 글)는 저장에 들지 않고, 나가기 확인 창이 떠 있는 동안은 저장하지 않는다. 앱이 앞에 있는 채로 정리되면 마지막 몇 초를 잃는다.
|
||||
첨부 합계 한도는 1차에서 25MB 대체값이다(`mail-quota` 는 2차). **푸시를 붙일 때**: 다른 화면으로 `go` 해서 쓰기 화면이 내려가면 detach 가 자동 저장을 멈춘다 — 내려가기 전에 마저 저장하게 바꾼다.
|
||||
고르는 동안 앱이 정리되면 고른 파일은 버린다(쓰던 내용은 고르는 창을 열 때 저장된다). 고르는 창에 10분 넘게 머물면 그 고르기는 시한으로 끝난다(알림이 창 뒤에서 뜰 수 있다 — 다시 고른다).
|
||||
**푸시를 붙일 때**: 다른 화면으로 `go` 해서 쓰기 화면이 내려가면 detach 가 자동 저장을 멈춘다 — 내려가기 전에 마저 저장하게 바꾼다.
|
||||
- **실기기에서 볼 것**: 삼성 · Gboard 자판의 칩 만들기(쉼표 · [다음] · 한글 조합 중 확정 · 여러 줄 붙여 넣기), 추천 목록이 자판에 가리는지, 예측 뒤로 가기, 홈 · 다른 앱으로 갔을 때 저장, 긴 원문 펼침 스크롤,
|
||||
서명 그림 폭, TalkBack 차례(칩 · 추천 · 원본 펼침 · 띠), 제스처 내비게이션에서 읽기 화면 알림 자리, 웹 ↔ 앱으로 저장본을 서로 다시 열기, 앱 로그인 서버(feat/app-auth)와 함께 실제 보내기 · 저장 · 서명 그림.
|
||||
첨부(2차): 고르는 창의 출처마다(내 파일 · Google 드라이브 · 사진 · 다운로드) 이름 · 크기 · 확장자를 주는지, hwp · hwpx 가 창에 보이고 올라가는지 · HEIC 는 `FILE_001` 인지,
|
||||
20MB 넘는 파일 · 크기를 알려 주지 않는 클라우드 파일이 한도에서 멈추는지, 합계에 걸리는 큰 파일을 내려받지 않고 바로 알리는지, 받는 동안의 진행 막대, 받다 망이 끊길 때(비행기 모드) 가져오기가 끝나는지, 여러 개 · 남은 자리보다 많이 고르기,
|
||||
고르는 동안 앱이 정리될 때(개발자 옵션 "활동 유지 안 함"), file:// 를 건네는 옛 파일 관리자, 올리는 중 화면 끄기 · 다른 앱, 느린 망의 20MB(보내기 5분),
|
||||
실제 multipart(한글 · 괄호 · 콜론 이름이 웹메일에서 그대로 보이는지), `MAIL_004` 뒤 다시 올리기, TalkBack 차례(진행 막대 · ✕ · [다시 시도] · 알림), 큰 글자에서 첨부 머리의 한도 글.
|
||||
|
||||
### 새 메일 알림 (푸시)
|
||||
- 알림 내용은 "새 메일 N통"만 — 보낸 사람 · 제목이 Google · Apple 서버를 거치지 않게. 누르면 메일 모듈의 받은편지함으로(알림에는 모듈 이름과 id 만 싣는다).
|
||||
|
||||
Reference in New Issue
Block a user