Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 117ec6c767 | |||
| 1448835aff | |||
| dacc100d3e | |||
| 5670017d2a | |||
| 3a25cc52f7 | |||
| 08b8144087 | |||
| e03eda1fd3 | |||
| bf725ae96d | |||
| 5b96ec2e85 | |||
| 9c5db05ada | |||
| facf9cd936 | |||
| bec78bce42 | |||
| 3278e1abc1 | |||
| ca6c68c34f | |||
| 23afa583c1 | |||
| 4ca2d41bad | |||
| 97a989be07 | |||
| 37f078e65c | |||
| 88ec6433b9 | |||
| 25280d8e90 | |||
| 94da1d2031 | |||
| 7546af0243 | |||
| 3a126bdf83 | |||
| f39f41ed4b | |||
| 80e48d318d | |||
| 695a343878 | |||
| 20cde01454 | |||
| f4b1d0a206 | |||
| a37045a553 | |||
| ef23afc94c | |||
| ad8b126abb | |||
| 4e779dedd2 | |||
| c14580e3a4 | |||
| dd02b9621a |
@@ -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 · 이름 · 아이콘 · 첫 화면 경로(`/<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)` 만, 실패 예외에 주소가 들어 있어 삼킨다, `<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`) · `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` 가 경고**한다.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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'] }],
|
||||
|
||||
@@ -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 와 같다).
|
||||
*/
|
||||
|
||||
|
||||
@@ -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 의 새 글자). 검색은 한 글자 판정에만 써서 결과에 영향이 없고, 받는 사람 추천은 그 글자들의 대소문자를 웹과 다르게 맞출 수 있다.
|
||||
- 보낸 사람이 본문에 쓴 `<mark>` 도 같은 노랑이다. 본문의 맞은 말 사이를 옮겨 다니지 못한다.
|
||||
- 조건 줄의 "나" 견주기는 한글만 모아 견준다(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. 검색은 결과에서 여러 통 고르기를 미뤘다(읽기 화면의 같은 대화 · 목록 대화로 묶기도 — 보드 먼저).
|
||||
|
||||
@@ -0,0 +1,835 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>앱 · 새 메일 알림 · 업데이트 · 앱 아이콘</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<style>
|
||||
@font-face{font-family:'Pretendard';src:url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2-variations'),url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2');font-weight:45 920;font-style:normal;font-display:swap}
|
||||
body{margin:0}
|
||||
a{color:#0E7C86}a:hover{color:#0A5F67}
|
||||
:focus-visible{outline:2px solid #0E7C86;outline-offset:2px}
|
||||
.ara-mask{-webkit-mask:url('/_blob/2b9ef4349333debc969432c859eaf1bf') center/contain no-repeat;mask:url('/_blob/2b9ef4349333debc969432c859eaf1bf') center/contain no-repeat}
|
||||
.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="width: 2222px; height: 3270px; box-sizing: border-box; padding: 40px; display: flex; flex-direction: column; gap: 56px; background: #EEF1F4; color: #171C26; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 15px; line-height: 1.5">
|
||||
|
||||
<section aria-labelledby="row1-h" style="display: flex; flex-direction: column; gap: 16px">
|
||||
<div style="display: flex; align-items: baseline; gap: 16px; min-height: 28px">
|
||||
<h2 id="row1-h" style="margin: 0; font-size: 19px; line-height: 28px; font-weight: 700; color: #0B3640; white-space: nowrap">허락 묻기와 설정</h2>
|
||||
<p style="margin: 0; font-size: 13.5px; line-height: 20px; color: #4A5363">앱 화면입니다. 받은편지함에서 한 번 권하고, 휴대폰이 묻는 창은 [알림 켜기]를 누를 때만 띄웁니다. 안드로이드 12 이하는 묻지 않고 처음부터 켜져 있습니다.</p>
|
||||
</div>
|
||||
<div style="display: flex; gap: 48px; height: 48px">
|
||||
<div style="flex: 0 0 390px"></div>
|
||||
<div style="flex: 0 0 390px"></div>
|
||||
<div role="group" aria-label="고른 뒤 상태 고르기" style="flex: 0 0 390px; display: flex; gap: 8px">
|
||||
<sc-for list="{{ afterModes }}" as="s" hint-placeholder-count="3">
|
||||
<button type="button" aria-pressed="{{ s.pressed }}" onClick="{{ s.pick }}" style="flex: 1 1 0; min-width: 0; min-height: 48px; box-sizing: border-box; padding: 0 10px; border: 1px solid {{ s.border }}; border-radius: 10px; background: {{ s.bg }}; color: {{ s.fg }}; font: inherit; font-size: 14px; font-weight: 600; cursor: pointer">{{ s.label }}</button>
|
||||
</sc-for>
|
||||
</div>
|
||||
<div style="flex: 0 0 828px"></div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 48px; align-items: flex-start">
|
||||
|
||||
<div style="flex: 0 0 390px; display: flex; flex-direction: column; gap: 12px">
|
||||
<div style="height: 62px; display: flex; flex-direction: column; gap: 2px">
|
||||
<h3 style="margin: 0; height: 20px; font-size: 13px; line-height: 20px; font-weight: 700; color: #344054">1. 받은편지함 · 권하기 줄</h3>
|
||||
<p style="margin: 0; font-size: 12.5px; line-height: 20px; color: #667085; word-break: keep-all">안드로이드 13 이상에서 아직 묻지 않았을 때만 보입니다. [✕]로 닫으면 다시 보이지 않습니다.</p>
|
||||
</div>
|
||||
<div style="position: relative; width: 390px; height: 844px; border-radius: 28px; overflow: hidden; background: #FFFFFF; box-shadow: 0 0 0 1px #D0D5DD, 0 12px 32px rgba(16, 24, 28, 0.12); display: flex; flex-direction: column">
|
||||
<header style="flex: 0 0 auto; display: flex; align-items: center; gap: 4px; height: 64px; box-sizing: border-box; padding: 0 4px; border-bottom: 1px solid #EEF0F3; background: #FFFFFF">
|
||||
<button type="button" aria-label="메일함 열기" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #344054; cursor: pointer"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6"></line><line x1="3" y1="12" x2="21" y2="12"></line><line x1="3" y1="18" x2="21" y2="18"></line></svg></button>
|
||||
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; padding-left: 4px; line-height: 1.3">
|
||||
<h1 style="margin: 0; font-size: 18px; font-weight: 700; letter-spacing: -0.01em">받은편지함</h1>
|
||||
<span style="font-size: 12.5px; color: #667085">안 읽은 메일 3통</span>
|
||||
</div>
|
||||
<button type="button" aria-label="메일 찾기" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #344054; cursor: pointer"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg></button>
|
||||
</header>
|
||||
<section aria-label="새 메일 알림 안내" style="flex: 0 0 auto; display: flex; align-items: flex-start; padding: 4px 4px 4px 16px; border-bottom: 1px solid #E3EEEF; background: #F3F8F8">
|
||||
<span aria-hidden="true" style="flex: 0 0 auto; display: inline-flex; margin-top: 13px; color: #0E7C86"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path><path d="M13.73 21a2 2 0 0 1-3.46 0"></path></svg></span>
|
||||
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; align-items: flex-start; padding: 12px 0 0 10px">
|
||||
<p style="margin: 0; font-size: 13px; line-height: 1.55; color: #344054; word-break: keep-all">새 메일 알림을 켜면 앱을 닫아 두어도 새 메일이 오는 대로 알려 드립니다.</p>
|
||||
<button type="button" style="min-height: 48px; margin: 0 0 0 -12px; padding: 0 12px; border: 0; border-radius: 8px; background: transparent; color: #0A5F67; font: inherit; font-size: 15px; font-weight: 600; cursor: pointer">알림 켜기</button>
|
||||
</div>
|
||||
<button type="button" aria-label="닫기" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #4A5363; cursor: pointer"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
|
||||
</section>
|
||||
<ul aria-label="받은편지함 메일" style="flex: 1 1 auto; min-height: 0; overflow: hidden; list-style: none; margin: 0; padding: 0">
|
||||
<sc-for list="{{ rows }}" as="r" hint-placeholder-count="7">
|
||||
<li style="border-bottom: 1px solid #F2F4F7; background: #FFFFFF">
|
||||
<div style="display: flex; align-items: flex-start; gap: 6px; padding: 10px 16px 10px 8px">
|
||||
<span aria-hidden="true" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center"><span style="display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; background: #E3F2F3; color: #0A5F67; font-size: 16px; font-weight: 600">{{ r.initial }}</span></span>
|
||||
<span style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 1px; padding-top: 1px">
|
||||
<span style="display: flex; align-items: baseline; gap: 8px">
|
||||
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; font-weight: {{ r.fw }}; color: #171C26">{{ r.who }}</span>
|
||||
<span style="flex: 0 0 auto; font-size: 12.5px; font-weight: {{ r.timeFw }}; color: {{ r.timeColor }}; font-variant-numeric: tabular-nums">{{ r.time }}</span>
|
||||
</span>
|
||||
<span style="display: flex; align-items: center; gap: 6px; min-width: 0">
|
||||
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14.5px; font-weight: {{ r.subFw }}; color: {{ r.subColor }}">{{ r.subject }}</span>
|
||||
<sc-if value="{{ r.att }}" hint-placeholder-val="{{ false }}"><svg role="img" aria-label="첨부 있음" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex: 0 0 auto"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path></svg></sc-if>
|
||||
<sc-if value="{{ r.flag }}" hint-placeholder-val="{{ false }}"><svg role="img" aria-label="중요" width="16" height="16" viewBox="0 0 24 24" fill="#B7791F" stroke="#B7791F" stroke-width="1.6" stroke-linejoin="round" style="flex: 0 0 auto"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg></sc-if>
|
||||
</span>
|
||||
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13.5px; color: #667085">{{ r.preview }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</sc-for>
|
||||
</ul>
|
||||
<a href="App1Compose.dc.html" style="position: absolute; right: 16px; bottom: 88px; display: inline-flex; align-items: center; gap: 10px; height: 56px; box-sizing: border-box; padding: 0 20px 0 16px; border-radius: 16px; background: #0E7C86; color: #FFFFFF; font-size: 15px; font-weight: 600; text-decoration: none; box-shadow: 0 6px 16px rgba(11, 54, 64, 0.28)"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg><span>메일 쓰기</span></a>
|
||||
<nav aria-label="앱 메뉴" style="flex: 0 0 auto; display: flex; height: 72px; box-sizing: border-box; padding: 8px 0 10px; border-top: 1px solid #EEF0F3; background: #FFFFFF">
|
||||
<a href="App1MailList.dc.html" aria-current="page" style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 0; border: 0; background: transparent; color: #0A5F67; font: inherit; font-size: 12.5px; font-weight: 700; text-decoration: none; cursor: pointer"><span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 56px; height: 32px; border-radius: 16px; background: #E3F2F3"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="20" height="16" rx="2"></rect><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"></path></svg></span><span>메일</span></a>
|
||||
<a href="App1Shell.dc.html" style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 0; border: 0; background: transparent; color: #4A5363; font: inherit; font-size: 12.5px; font-weight: 500; text-decoration: none; cursor: pointer"><span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 56px; height: 32px; border-radius: 16px; background: transparent"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg></span><span>설정</span></a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="flex: 0 0 390px; display: flex; flex-direction: column; gap: 12px">
|
||||
<div style="height: 62px; display: flex; flex-direction: column; gap: 2px">
|
||||
<h3 style="margin: 0; height: 20px; font-size: 13px; line-height: 20px; font-weight: 700; color: #344054">2. 휴대폰이 묻는 창</h3>
|
||||
<p style="margin: 0; font-size: 12.5px; line-height: 20px; color: #667085; word-break: keep-all">[알림 켜기]를 누르면 안드로이드가 띄우는 창입니다. 모양과 글은 휴대폰마다 다릅니다.</p>
|
||||
</div>
|
||||
<div style="position: relative; width: 390px; height: 844px; border-radius: 28px; overflow: hidden; background: #FFFFFF; box-shadow: 0 0 0 1px #D0D5DD, 0 12px 32px rgba(16, 24, 28, 0.12); display: flex; flex-direction: column">
|
||||
<header style="flex: 0 0 auto; display: flex; align-items: center; gap: 4px; height: 64px; box-sizing: border-box; padding: 0 4px; border-bottom: 1px solid #EEF0F3; background: #FFFFFF">
|
||||
<button type="button" aria-label="메일함 열기" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #344054; cursor: pointer"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6"></line><line x1="3" y1="12" x2="21" y2="12"></line><line x1="3" y1="18" x2="21" y2="18"></line></svg></button>
|
||||
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; padding-left: 4px; line-height: 1.3">
|
||||
<h1 style="margin: 0; font-size: 18px; font-weight: 700; letter-spacing: -0.01em">받은편지함</h1>
|
||||
<span style="font-size: 12.5px; color: #667085">안 읽은 메일 3통</span>
|
||||
</div>
|
||||
<button type="button" aria-label="메일 찾기" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #344054; cursor: pointer"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg></button>
|
||||
</header>
|
||||
<section aria-label="새 메일 알림 안내" style="flex: 0 0 auto; display: flex; align-items: flex-start; padding: 4px 4px 4px 16px; border-bottom: 1px solid #E3EEEF; background: #F3F8F8">
|
||||
<span aria-hidden="true" style="flex: 0 0 auto; display: inline-flex; margin-top: 13px; color: #0E7C86"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path><path d="M13.73 21a2 2 0 0 1-3.46 0"></path></svg></span>
|
||||
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; align-items: flex-start; padding: 12px 0 0 10px">
|
||||
<p style="margin: 0; font-size: 13px; line-height: 1.55; color: #344054; word-break: keep-all">새 메일 알림을 켜면 앱을 닫아 두어도 새 메일이 오는 대로 알려 드립니다.</p>
|
||||
<button type="button" style="min-height: 48px; margin: 0 0 0 -12px; padding: 0 12px; border: 0; border-radius: 8px; background: transparent; color: #0A5F67; font: inherit; font-size: 15px; font-weight: 600; cursor: pointer">알림 켜기</button>
|
||||
</div>
|
||||
<button type="button" aria-label="닫기" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #4A5363; cursor: pointer"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
|
||||
</section>
|
||||
<ul aria-label="받은편지함 메일" style="flex: 1 1 auto; min-height: 0; overflow: hidden; list-style: none; margin: 0; padding: 0">
|
||||
<sc-for list="{{ rows }}" as="r" hint-placeholder-count="7">
|
||||
<li style="border-bottom: 1px solid #F2F4F7; background: #FFFFFF">
|
||||
<div style="display: flex; align-items: flex-start; gap: 6px; padding: 10px 16px 10px 8px">
|
||||
<span aria-hidden="true" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center"><span style="display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; background: #E3F2F3; color: #0A5F67; font-size: 16px; font-weight: 600">{{ r.initial }}</span></span>
|
||||
<span style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 1px; padding-top: 1px">
|
||||
<span style="display: flex; align-items: baseline; gap: 8px">
|
||||
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; font-weight: {{ r.fw }}; color: #171C26">{{ r.who }}</span>
|
||||
<span style="flex: 0 0 auto; font-size: 12.5px; font-weight: {{ r.timeFw }}; color: {{ r.timeColor }}; font-variant-numeric: tabular-nums">{{ r.time }}</span>
|
||||
</span>
|
||||
<span style="display: flex; align-items: center; gap: 6px; min-width: 0">
|
||||
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14.5px; font-weight: {{ r.subFw }}; color: {{ r.subColor }}">{{ r.subject }}</span>
|
||||
<sc-if value="{{ r.att }}" hint-placeholder-val="{{ false }}"><svg role="img" aria-label="첨부 있음" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex: 0 0 auto"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path></svg></sc-if>
|
||||
<sc-if value="{{ r.flag }}" hint-placeholder-val="{{ false }}"><svg role="img" aria-label="중요" width="16" height="16" viewBox="0 0 24 24" fill="#B7791F" stroke="#B7791F" stroke-width="1.6" stroke-linejoin="round" style="flex: 0 0 auto"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg></sc-if>
|
||||
</span>
|
||||
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13.5px; color: #667085">{{ r.preview }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</sc-for>
|
||||
</ul>
|
||||
<a href="App1Compose.dc.html" style="position: absolute; right: 16px; bottom: 88px; display: inline-flex; align-items: center; gap: 10px; height: 56px; box-sizing: border-box; padding: 0 20px 0 16px; border-radius: 16px; background: #0E7C86; color: #FFFFFF; font-size: 15px; font-weight: 600; text-decoration: none; box-shadow: 0 6px 16px rgba(11, 54, 64, 0.28)"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg><span>메일 쓰기</span></a>
|
||||
<nav aria-label="앱 메뉴" style="flex: 0 0 auto; display: flex; height: 72px; box-sizing: border-box; padding: 8px 0 10px; border-top: 1px solid #EEF0F3; background: #FFFFFF">
|
||||
<a href="App1MailList.dc.html" aria-current="page" style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 0; border: 0; background: transparent; color: #0A5F67; font: inherit; font-size: 12.5px; font-weight: 700; text-decoration: none; cursor: pointer"><span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 56px; height: 32px; border-radius: 16px; background: #E3F2F3"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="20" height="16" rx="2"></rect><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"></path></svg></span><span>메일</span></a>
|
||||
<a href="App1Shell.dc.html" style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 0; border: 0; background: transparent; color: #4A5363; font: inherit; font-size: 12.5px; font-weight: 500; text-decoration: none; cursor: pointer"><span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 56px; height: 32px; border-radius: 16px; background: transparent"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg></span><span>설정</span></a>
|
||||
</nav>
|
||||
<div aria-hidden="true" style="position: absolute; inset: 0; background: rgba(0, 0, 0, 0.36)"></div>
|
||||
<div role="dialog" aria-modal="true" aria-labelledby="p2-title" style="position: absolute; left: 24px; right: 24px; top: 262px; box-sizing: border-box; display: flex; flex-direction: column; align-items: center; gap: 16px; padding: 28px 20px 20px; border-radius: 28px; background: #F2F3F5; box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28)">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="#4A5363" aria-hidden="true"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"></path></svg>
|
||||
<h2 id="p2-title" style="margin: 0; padding: 0 8px; font-size: 19px; line-height: 1.45; font-weight: 400; color: #1F1F1F; text-align: center; word-break: keep-all"><b style="font-weight: 700">ARA SYSTEM</b>에서 알림을 보내도록 허용하시겠습니까?</h2>
|
||||
<div style="align-self: stretch; display: flex; flex-direction: column; gap: 6px; margin-top: 8px">
|
||||
<button type="button" style="min-height: 52px; border: 0; border-radius: 26px; background: #DEE2E7; color: #1F1F1F; font: inherit; font-size: 15.5px; font-weight: 600; cursor: pointer">허용</button>
|
||||
<button type="button" style="min-height: 52px; border: 0; border-radius: 26px; background: #DEE2E7; color: #1F1F1F; font: inherit; font-size: 15.5px; font-weight: 600; cursor: pointer">허용 안함</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="flex: 0 0 390px; display: flex; flex-direction: column; gap: 12px">
|
||||
<div style="height: 62px; display: flex; flex-direction: column; gap: 2px">
|
||||
<h3 style="margin: 0; height: 20px; font-size: 13px; line-height: 20px; font-weight: 700; color: #344054">{{ afterTitle }}</h3>
|
||||
<p style="margin: 0; font-size: 12.5px; line-height: 20px; color: #667085; word-break: keep-all">{{ afterNote }}</p>
|
||||
</div>
|
||||
<div style="position: relative; width: 390px; height: 844px; border-radius: 28px; overflow: hidden; background: #FFFFFF; box-shadow: 0 0 0 1px #D0D5DD, 0 12px 32px rgba(16, 24, 28, 0.12); display: flex; flex-direction: column">
|
||||
<header style="flex: 0 0 auto; display: flex; align-items: center; gap: 4px; height: 64px; box-sizing: border-box; padding: 0 4px; border-bottom: 1px solid #EEF0F3; background: #FFFFFF">
|
||||
<button type="button" aria-label="메일함 열기" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #344054; cursor: pointer"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6"></line><line x1="3" y1="12" x2="21" y2="12"></line><line x1="3" y1="18" x2="21" y2="18"></line></svg></button>
|
||||
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; padding-left: 4px; line-height: 1.3">
|
||||
<h1 style="margin: 0; font-size: 18px; font-weight: 700; letter-spacing: -0.01em">받은편지함</h1>
|
||||
<span style="font-size: 12.5px; color: #667085">안 읽은 메일 3통</span>
|
||||
</div>
|
||||
<button type="button" aria-label="메일 찾기" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #344054; cursor: pointer"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg></button>
|
||||
</header>
|
||||
<ul aria-label="받은편지함 메일" style="flex: 1 1 auto; min-height: 0; overflow: hidden; list-style: none; margin: 0; padding: 0">
|
||||
<sc-for list="{{ rows }}" as="r" hint-placeholder-count="7">
|
||||
<li style="border-bottom: 1px solid #F2F4F7; background: #FFFFFF">
|
||||
<div style="display: flex; align-items: flex-start; gap: 6px; padding: 10px 16px 10px 8px">
|
||||
<span aria-hidden="true" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center"><span style="display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; background: #E3F2F3; color: #0A5F67; font-size: 16px; font-weight: 600">{{ r.initial }}</span></span>
|
||||
<span style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 1px; padding-top: 1px">
|
||||
<span style="display: flex; align-items: baseline; gap: 8px">
|
||||
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; font-weight: {{ r.fw }}; color: #171C26">{{ r.who }}</span>
|
||||
<span style="flex: 0 0 auto; font-size: 12.5px; font-weight: {{ r.timeFw }}; color: {{ r.timeColor }}; font-variant-numeric: tabular-nums">{{ r.time }}</span>
|
||||
</span>
|
||||
<span style="display: flex; align-items: center; gap: 6px; min-width: 0">
|
||||
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14.5px; font-weight: {{ r.subFw }}; color: {{ r.subColor }}">{{ r.subject }}</span>
|
||||
<sc-if value="{{ r.att }}" hint-placeholder-val="{{ false }}"><svg role="img" aria-label="첨부 있음" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex: 0 0 auto"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path></svg></sc-if>
|
||||
<sc-if value="{{ r.flag }}" hint-placeholder-val="{{ false }}"><svg role="img" aria-label="중요" width="16" height="16" viewBox="0 0 24 24" fill="#B7791F" stroke="#B7791F" stroke-width="1.6" stroke-linejoin="round" style="flex: 0 0 auto"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg></sc-if>
|
||||
</span>
|
||||
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13.5px; color: #667085">{{ r.preview }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</sc-for>
|
||||
</ul>
|
||||
<nav aria-label="앱 메뉴" style="flex: 0 0 auto; display: flex; height: 72px; box-sizing: border-box; padding: 8px 0 10px; border-top: 1px solid #EEF0F3; background: #FFFFFF">
|
||||
<a href="App1MailList.dc.html" aria-current="page" style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 0; border: 0; background: transparent; color: #0A5F67; font: inherit; font-size: 12.5px; font-weight: 700; text-decoration: none; cursor: pointer"><span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 56px; height: 32px; border-radius: 16px; background: #E3F2F3"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="20" height="16" rx="2"></rect><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"></path></svg></span><span>메일</span></a>
|
||||
<a href="App1Shell.dc.html" style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 0; border: 0; background: transparent; color: #4A5363; font: inherit; font-size: 12.5px; font-weight: 500; text-decoration: none; cursor: pointer"><span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 56px; height: 32px; border-radius: 16px; background: transparent"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg></span><span>설정</span></a>
|
||||
</nav>
|
||||
<div style="position: absolute; left: 12px; right: 12px; bottom: 84px; display: flex; flex-direction: column; align-items: flex-end; gap: 12px">
|
||||
<a href="App1Compose.dc.html" style="margin-right: 4px; display: inline-flex; align-items: center; gap: 10px; height: 56px; box-sizing: border-box; padding: 0 20px 0 16px; border-radius: 16px; background: #0E7C86; color: #FFFFFF; font-size: 15px; font-weight: 600; text-decoration: none; box-shadow: 0 6px 16px rgba(11, 54, 64, 0.28)"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg><span>메일 쓰기</span></a>
|
||||
<div role="status" style="align-self: stretch; display: flex; align-items: center; gap: 8px; min-height: 52px; box-sizing: border-box; padding: {{ snackPad }}; border-radius: 10px; background: #0B3640; color: #FFFFFF; font-size: 14.5px; line-height: 1.5; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.22)">
|
||||
<span style="flex: 1 1 auto; min-width: 0; padding: 12px 0; word-break: keep-all">{{ afterText }}</span>
|
||||
<sc-if value="{{ afterAction }}" hint-placeholder-val="{{ false }}"><button type="button" style="flex: 0 0 auto; min-height: 48px; padding: 0 12px; border: 0; border-radius: 8px; background: transparent; color: #9ED6DB; font: inherit; font-size: 14.5px; font-weight: 600; cursor: pointer">설정 열기</button></sc-if>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="flex: 0 0 828px; display: flex; flex-direction: column; gap: 12px">
|
||||
<div style="height: 62px; display: flex; flex-direction: column; gap: 2px">
|
||||
<h3 style="margin: 0; height: 20px; font-size: 13px; line-height: 20px; font-weight: 700; color: #344054">4. 설정 › 새 메일 알림 줄</h3>
|
||||
<p style="margin: 0; font-size: 12.5px; line-height: 20px; color: #667085; word-break: keep-all">설정 탭의 다른 칸과 로그아웃 확인 창은 App1Shell 그대로입니다. 알림 줄만 상태마다 그렸습니다. 왼쪽은 스위치를 누를 수 있는 상태, 오른쪽은 누를 수 없는 상태입니다.</p>
|
||||
</div>
|
||||
<div style="display: flex; gap: 48px; align-items: flex-start">
|
||||
<div style="flex: 0 0 390px; display: flex; flex-direction: column; gap: 18px">
|
||||
<sc-for list="{{ notifyLeft }}" as="c" hint-placeholder-count="4">
|
||||
<div style="display: flex; flex-direction: column; gap: 6px">
|
||||
<p style="margin: 0; font-size: 13px; line-height: 20px; color: #667085; word-break: keep-all"><b style="font-weight: 700; color: #344054">{{ c.name }}</b> · {{ c.when }}</p>
|
||||
<div style="border-radius: 16px; background: #FFFFFF; box-shadow: 0 0 0 1px #D0D5DD, 0 4px 12px rgba(16, 24, 28, 0.06)">
|
||||
<div style="display: flex; align-items: center; gap: 12px; min-height: 68px; box-sizing: border-box; padding: 6px 12px 6px 20px">
|
||||
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 2px; padding: {{ c.textPad }}">
|
||||
<span id="{{ c.id }}-label" style="font-size: 15.5px; font-weight: 500; color: #171C26">새 메일 알림</span>
|
||||
<span id="{{ c.id }}-desc" style="font-size: 13px; line-height: 1.5; color: {{ c.descColor }}; word-break: keep-all">{{ c.desc }}</span>
|
||||
<sc-if value="{{ c.action }}" hint-placeholder-val="{{ false }}">
|
||||
<span style="display: flex; margin-top: 2px">
|
||||
<button type="button" style="display: inline-flex; align-items: center; gap: 6px; min-height: 48px; margin-left: -12px; padding: 0 12px; border: 0; border-radius: 8px; background: transparent; color: #0A5F67; font: inherit; font-size: 15px; font-weight: 600; cursor: pointer"><span>설정 열기</span><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg></button>
|
||||
</span>
|
||||
</sc-if>
|
||||
</div>
|
||||
<sc-if value="{{ c.busy }}" hint-placeholder-val="{{ false }}">
|
||||
<span role="status" class="sr">{{ c.busyText }}</span>
|
||||
<svg aria-hidden="true" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke-width="2.6" stroke-linecap="round" style="flex: 0 0 auto"><circle cx="12" cy="12" r="9" stroke="#D5ECEE"></circle><path d="M12 3a9 9 0 0 1 9 9" stroke="#0E7C86"></path></svg>
|
||||
</sc-if>
|
||||
<button type="button" role="switch" aria-checked="{{ c.checked }}" aria-disabled="{{ c.disabledStr }}" aria-busy="{{ c.busyStr }}" aria-labelledby="{{ c.id }}-label" aria-describedby="{{ c.id }}-desc" style="flex: 0 0 auto; width: 64px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 24px; background: transparent; cursor: {{ c.cursor }}">
|
||||
<span aria-hidden="true" style="display: flex; align-items: center; justify-content: {{ c.thumbJustify }}; width: 52px; height: 32px; box-sizing: border-box; padding: 0 2px; border: 2px solid {{ c.trackBorder }}; border-radius: 16px; background: {{ c.trackBg }}">
|
||||
<span style="display: block; width: {{ c.thumbSize }}; height: {{ c.thumbSize }}; margin: 0 {{ c.thumbGap }}; border-radius: 50%; background: {{ c.thumbBg }}"></span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
<div style="display: flex; flex-direction: column; gap: 6px">
|
||||
<p style="margin: 0; font-size: 13px; line-height: 20px; color: #667085; word-break: keep-all"><b style="font-weight: 700; color: #344054">설정을 열지 못했을 때</b> · [설정 열기]를 누른 뒤 뜨는 알림</p>
|
||||
<div role="status" style="margin: 0 12px; display: flex; align-items: center; min-height: 52px; box-sizing: border-box; padding: 12px 16px; border-radius: 10px; background: #0B3640; color: #FFFFFF; font-size: 14.5px; line-height: 1.5; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.22); word-break: keep-all">휴대폰 설정을 열지 못했습니다. 휴대폰의 [설정] 앱에서 ARA SYSTEM을 찾아 알림을 켜 주세요.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex: 0 0 390px; display: flex; flex-direction: column; gap: 18px">
|
||||
<sc-for list="{{ notifyRight }}" as="c" hint-placeholder-count="4">
|
||||
<div style="display: flex; flex-direction: column; gap: 6px">
|
||||
<p style="margin: 0; font-size: 13px; line-height: 20px; color: #667085; word-break: keep-all"><b style="font-weight: 700; color: #344054">{{ c.name }}</b> · {{ c.when }}</p>
|
||||
<div style="border-radius: 16px; background: #FFFFFF; box-shadow: 0 0 0 1px #D0D5DD, 0 4px 12px rgba(16, 24, 28, 0.06)">
|
||||
<div style="display: flex; align-items: center; gap: 12px; min-height: 68px; box-sizing: border-box; padding: 6px 12px 6px 20px">
|
||||
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 2px; padding: {{ c.textPad }}">
|
||||
<span id="{{ c.id }}-label" style="font-size: 15.5px; font-weight: 500; color: #171C26">새 메일 알림</span>
|
||||
<span id="{{ c.id }}-desc" style="font-size: 13px; line-height: 1.5; color: {{ c.descColor }}; word-break: keep-all">{{ c.desc }}</span>
|
||||
<sc-if value="{{ c.action }}" hint-placeholder-val="{{ false }}">
|
||||
<span style="display: flex; margin-top: 2px">
|
||||
<button type="button" style="display: inline-flex; align-items: center; gap: 6px; min-height: 48px; margin-left: -12px; padding: 0 12px; border: 0; border-radius: 8px; background: transparent; color: #0A5F67; font: inherit; font-size: 15px; font-weight: 600; cursor: pointer"><span>설정 열기</span><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg></button>
|
||||
</span>
|
||||
</sc-if>
|
||||
</div>
|
||||
<sc-if value="{{ c.busy }}" hint-placeholder-val="{{ false }}">
|
||||
<span role="status" class="sr">{{ c.busyText }}</span>
|
||||
<svg aria-hidden="true" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke-width="2.6" stroke-linecap="round" style="flex: 0 0 auto"><circle cx="12" cy="12" r="9" stroke="#D5ECEE"></circle><path d="M12 3a9 9 0 0 1 9 9" stroke="#0E7C86"></path></svg>
|
||||
</sc-if>
|
||||
<button type="button" role="switch" aria-checked="{{ c.checked }}" aria-disabled="{{ c.disabledStr }}" aria-busy="{{ c.busyStr }}" aria-labelledby="{{ c.id }}-label" aria-describedby="{{ c.id }}-desc" style="flex: 0 0 auto; width: 64px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 24px; background: transparent; cursor: {{ c.cursor }}">
|
||||
<span aria-hidden="true" style="display: flex; align-items: center; justify-content: {{ c.thumbJustify }}; width: 52px; height: 32px; box-sizing: border-box; padding: 0 2px; border: 2px solid {{ c.trackBorder }}; border-radius: 16px; background: {{ c.trackBg }}">
|
||||
<span style="display: block; width: {{ c.thumbSize }}; height: {{ c.thumbSize }}; margin: 0 {{ c.thumbGap }}; border-radius: 50%; background: {{ c.thumbBg }}"></span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="row2-h" style="display: flex; flex-direction: column; gap: 16px">
|
||||
<div style="display: flex; align-items: baseline; gap: 16px; min-height: 28px">
|
||||
<h2 id="row2-h" style="margin: 0; font-size: 19px; line-height: 28px; font-weight: 700; color: #0B3640; white-space: nowrap">휴대폰에 오는 알림</h2>
|
||||
<p style="margin: 0; font-size: 13.5px; line-height: 20px; color: #4A5363">안드로이드가 그리는 모습입니다(Pixel 같은 휴대폰). 알림 글은 ‘새 메일 N통’뿐이고, 앱을 보고 있을 때는 띄우지 않습니다. 왼쪽 위 단추로 작은 아이콘을 바꿔 볼 수 있습니다.</p>
|
||||
</div>
|
||||
<div style="display: flex; gap: 48px; height: 48px">
|
||||
<div role="group" aria-label="작은 아이콘 고르기" style="flex: 0 0 390px; display: flex; gap: 8px">
|
||||
<sc-for list="{{ iconModes }}" as="s" hint-placeholder-count="2">
|
||||
<button type="button" aria-pressed="{{ s.pressed }}" onClick="{{ s.pick }}" style="flex: 1 1 0; min-width: 0; min-height: 48px; box-sizing: border-box; padding: 0 10px; border: 1px solid {{ s.border }}; border-radius: 10px; background: {{ s.bg }}; color: {{ s.fg }}; font: inherit; font-size: 14px; font-weight: 600; cursor: pointer">{{ s.label }}</button>
|
||||
</sc-for>
|
||||
</div>
|
||||
<div style="flex: 0 0 390px"></div>
|
||||
<div style="flex: 0 0 390px"></div>
|
||||
<div style="flex: 0 0 390px"></div>
|
||||
<div style="flex: 0 0 390px"></div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 48px; align-items: flex-start">
|
||||
|
||||
<div style="flex: 0 0 390px; display: flex; flex-direction: column; gap: 12px">
|
||||
<div style="height: 62px; display: flex; flex-direction: column; gap: 2px">
|
||||
<h3 style="margin: 0; height: 20px; font-size: 13px; line-height: 20px; font-weight: 700; color: #344054">5. 다른 앱을 쓰는 중</h3>
|
||||
<p style="margin: 0; font-size: 12.5px; line-height: 20px; color: #667085; word-break: keep-all">화면 위에 잠깐 떴다가 상태 표시줄에 작은 아이콘으로 남습니다. 소리와 진동은 휴대폰 설정을 따릅니다.</p>
|
||||
</div>
|
||||
<div role="img" aria-label="다른 앱을 쓰는 중에 화면 위에 뜬 ‘새 메일 3통’ 알림" style="position: relative; width: 390px; height: 844px; border-radius: 28px; overflow: hidden; background: #F4F5F7; box-shadow: 0 0 0 1px #D0D5DD, 0 12px 32px rgba(16, 24, 28, 0.12); display: flex; flex-direction: column">
|
||||
<div aria-hidden="true" style="flex: 0 0 auto; position: relative; display: flex; align-items: center; gap: 4px; height: 36px; box-sizing: border-box; padding: 0 20px 0 24px; background: #FFFFFF; color: #1F1F1F; font-size: 14px; font-weight: 600; font-variant-numeric: tabular-nums">
|
||||
<span>9:41</span>
|
||||
<span style="display: inline-flex; width: 24px; height: 24px; align-items: center; justify-content: center; margin-left: 6px"><sc-if value="{{ siAra }}" hint-placeholder-val="{{ true }}"><span class="ara-mask" style="display: block; width: 22px; height: 8px; background: currentColor"></span></sc-if><sc-if value="{{ siEnv }}" hint-placeholder-val="{{ false }}"><svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"></path></svg></sc-if></span>
|
||||
<span style="flex: 1 1 auto"></span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M21 3v18H3z"></path></svg>
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 21 23.5 6.6A18.6 18.6 0 0 0 12 3 18.6 18.6 0 0 0 .5 6.6z"></path></svg>
|
||||
<svg width="11" height="18" viewBox="0 0 11 18" fill="currentColor" aria-hidden="true"><rect x="3.5" y="0" width="4" height="2" rx="0.6"></rect><rect x="0.5" y="1.6" width="10" height="16" rx="2"></rect></svg>
|
||||
<span style="position: absolute; left: 50%; top: 11px; width: 14px; height: 14px; margin-left: -7px; border-radius: 50%; background: #111111"></span>
|
||||
</div>
|
||||
<div aria-hidden="true" style="flex: 1 1 auto; display: flex; flex-direction: column">
|
||||
<div style="display: flex; align-items: center; gap: 16px; height: 64px; box-sizing: border-box; padding: 0 20px; background: #FFFFFF; border-bottom: 1px solid #E4E7EC">
|
||||
<span style="width: 24px; height: 24px; border-radius: 6px; background: #D0D5DD"></span>
|
||||
<span style="width: 140px; height: 14px; border-radius: 7px; background: #C5CBD3"></span>
|
||||
<span style="flex: 1 1 auto"></span>
|
||||
<span style="width: 32px; height: 32px; border-radius: 50%; background: #D0D5DD"></span>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 14px; padding: 18px 16px">
|
||||
<span style="display: block; height: 180px; border-radius: 16px; background: #E4E7EC"></span>
|
||||
<span style="display: flex; align-items: center; gap: 12px"><span style="width: 44px; height: 44px; border-radius: 50%; background: #D0D5DD"></span><span style="flex: 1 1 auto; display: flex; flex-direction: column; gap: 8px"><span style="width: 70%; height: 10px; border-radius: 5px; background: #C5CBD3"></span><span style="width: 92%; height: 8px; border-radius: 4px; background: #E4E7EC"></span></span></span>
|
||||
<span style="display: flex; align-items: center; gap: 12px"><span style="width: 44px; height: 44px; border-radius: 50%; background: #D0D5DD"></span><span style="flex: 1 1 auto; display: flex; flex-direction: column; gap: 8px"><span style="width: 55%; height: 10px; border-radius: 5px; background: #C5CBD3"></span><span style="width: 84%; height: 8px; border-radius: 4px; background: #E4E7EC"></span></span></span>
|
||||
<span style="display: flex; align-items: center; gap: 12px"><span style="width: 44px; height: 44px; border-radius: 50%; background: #D0D5DD"></span><span style="flex: 1 1 auto; display: flex; flex-direction: column; gap: 8px"><span style="width: 64%; height: 10px; border-radius: 5px; background: #C5CBD3"></span><span style="width: 88%; height: 8px; border-radius: 4px; background: #E4E7EC"></span></span></span>
|
||||
<span style="display: block; height: 140px; border-radius: 16px; background: #E4E7EC"></span>
|
||||
<span style="display: flex; align-items: center; gap: 12px"><span style="width: 44px; height: 44px; border-radius: 50%; background: #D0D5DD"></span><span style="flex: 1 1 auto; display: flex; flex-direction: column; gap: 8px"><span style="width: 48%; height: 10px; border-radius: 5px; background: #C5CBD3"></span><span style="width: 80%; height: 8px; border-radius: 4px; background: #E4E7EC"></span></span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div aria-hidden="true" style="position: absolute; left: 10px; right: 10px; top: 44px; display: flex; align-items: center; gap: 12px; padding: 14px 12px 14px 14px; border-radius: 26px; background: #FFFFFF; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18)">
|
||||
<span style="flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; background: #0E7C86; color: #FFFFFF"><sc-if value="{{ siAra }}" hint-placeholder-val="{{ true }}"><img src="/_blob/2b9ef4349333debc969432c859eaf1bf" alt="" width="22" height="8" style="display: block; width: 22px; height: auto"></sc-if><sc-if value="{{ siEnv }}" hint-placeholder-val="{{ false }}"><svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"></path></svg></sc-if></span>
|
||||
<span style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 1px">
|
||||
<span style="font-size: 12.5px; line-height: 1.4; color: #5F6368">ARA SYSTEM · 지금</span>
|
||||
<span style="font-size: 15.5px; line-height: 1.4; font-weight: 600; color: #1F1F1F">새 메일 3통</span>
|
||||
</span>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#5F6368" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="6 9 12 15 18 9"></polyline></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="flex: 0 0 390px; display: flex; flex-direction: column; gap: 12px">
|
||||
<div style="height: 62px; display: flex; flex-direction: column; gap: 2px">
|
||||
<h3 style="margin: 0; height: 20px; font-size: 13px; line-height: 20px; font-weight: 700; color: #344054">6. 알림창</h3>
|
||||
<p style="margin: 0; font-size: 12.5px; line-height: 20px; color: #667085; word-break: keep-all">새 메일이 더 오면 이 알림의 수만 바뀌고 쌓이지 않습니다. 누르면 메일 탭의 받은편지함이 열립니다.</p>
|
||||
</div>
|
||||
<div role="img" aria-label="알림창에 온 ‘새 메일 3통’ 알림" style="position: relative; width: 390px; height: 844px; border-radius: 28px; overflow: hidden; background: #E8EBEE; box-shadow: 0 0 0 1px #D0D5DD, 0 12px 32px rgba(16, 24, 28, 0.12); display: flex; flex-direction: column">
|
||||
<div aria-hidden="true" style="display: flex; flex-direction: column">
|
||||
<div style="display: flex; align-items: flex-end; gap: 10px; padding: 22px 22px 10px">
|
||||
<span style="font-size: 30px; line-height: 1; font-weight: 400; color: #1F1F1F; font-variant-numeric: tabular-nums">9:41</span>
|
||||
<span style="padding-bottom: 2px; font-size: 13.5px; font-weight: 500; color: #44474E">11월 7일 (토)</span>
|
||||
<span style="flex: 1 1 auto"></span>
|
||||
<span style="display: inline-flex; align-items: center; gap: 4px; padding-bottom: 3px; color: #1F1F1F"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M21 3v18H3z"></path></svg><svg width="17" height="17" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 21 23.5 6.6A18.6 18.6 0 0 0 12 3 18.6 18.6 0 0 0 .5 6.6z"></path></svg><svg width="11" height="18" viewBox="0 0 11 18" fill="currentColor" aria-hidden="true"><rect x="3.5" y="0" width="4" height="2" rx="0.6"></rect><rect x="0.5" y="1.6" width="10" height="16" rx="2"></rect></svg></span>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding: 6px 12px 0">
|
||||
<span style="display: flex; align-items: center; gap: 10px; height: 60px; box-sizing: border-box; padding: 0 14px; border-radius: 22px; background: #C9D6E0; color: #1F1F1F; font-size: 13.5px; font-weight: 600; white-space: nowrap; overflow: hidden"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 21 23.5 6.6A18.6 18.6 0 0 0 12 3 18.6 18.6 0 0 0 .5 6.6z"></path></svg><span>인터넷</span></span>
|
||||
<span style="display: flex; align-items: center; gap: 10px; height: 60px; box-sizing: border-box; padding: 0 14px; border-radius: 22px; background: #FFFFFF; color: #1F1F1F; font-size: 13.5px; font-weight: 600; white-space: nowrap; overflow: hidden"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="7 7 17 17 12 22 12 2 17 7 7 17"></polyline></svg><span>블루투스</span></span>
|
||||
<span style="display: flex; align-items: center; gap: 10px; height: 60px; box-sizing: border-box; padding: 0 14px; border-radius: 22px; background: #FFFFFF; color: #1F1F1F; font-size: 13.5px; font-weight: 600; white-space: nowrap; overflow: hidden"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg><span>방해 금지 모드</span></span>
|
||||
<span style="display: flex; align-items: center; gap: 10px; height: 60px; box-sizing: border-box; padding: 0 14px; border-radius: 22px; background: #FFFFFF; color: #1F1F1F; font-size: 13.5px; font-weight: 600; white-space: nowrap; overflow: hidden"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6c0 2-2 2-2 4v10a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2V10c0-2-2-2-2-4V2h12z"></path><line x1="6" y1="6" x2="18" y2="6"></line></svg><span>손전등</span></span>
|
||||
</div>
|
||||
<div style="display: flex; margin: 10px 12px 0; height: 44px; border-radius: 22px; background: #FFFFFF; overflow: hidden"><span style="display: flex; align-items: center; justify-content: flex-end; width: 62%; height: 100%; box-sizing: border-box; padding-right: 13px; border-radius: 22px; background: #C9D6E0; color: #1F1F1F"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="4"></circle><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"></path></svg></span></div>
|
||||
<div style="display: flex; flex-direction: column; gap: 6px; padding: 18px 12px 0">
|
||||
<div style="display: flex; align-items: center; gap: 12px; padding: 14px 12px 14px 14px; border-radius: 22px; background: #FFFFFF">
|
||||
<span style="flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; background: #0E7C86; color: #FFFFFF"><sc-if value="{{ siAra }}" hint-placeholder-val="{{ true }}"><img src="/_blob/2b9ef4349333debc969432c859eaf1bf" alt="" width="22" height="8" style="display: block; width: 22px; height: auto"></sc-if><sc-if value="{{ siEnv }}" hint-placeholder-val="{{ false }}"><svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"></path></svg></sc-if></span>
|
||||
<span style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 1px">
|
||||
<span style="font-size: 12.5px; line-height: 1.4; color: #5F6368">ARA SYSTEM · 지금</span>
|
||||
<span style="font-size: 15.5px; line-height: 1.4; font-weight: 600; color: #1F1F1F">새 메일 3통</span>
|
||||
</span>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#5F6368" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="6 9 12 15 18 9"></polyline></svg>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 12px; padding: 14px 12px 14px 14px; border-radius: 22px; background: #FFFFFF">
|
||||
<span style="flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; background: #6B7785; color: #FFFFFF"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="4"></circle><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"></path></svg></span>
|
||||
<span style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 1px">
|
||||
<span style="font-size: 12.5px; line-height: 1.4; color: #5F6368">날씨 · 8분 전</span>
|
||||
<span style="font-size: 15.5px; line-height: 1.4; font-weight: 600; color: #1F1F1F">맑음 12°</span>
|
||||
<span style="font-size: 13.5px; line-height: 1.4; color: #44474E">오늘 최고 15°, 최저 6°</span>
|
||||
</span>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#5F6368" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="6 9 12 15 18 9"></polyline></svg>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; padding: 12px 16px 0">
|
||||
<span style="height: 36px; padding: 0 16px; border-radius: 18px; background: #FFFFFF; font-size: 13.5px; font-weight: 600; line-height: 36px; color: #1F1F1F">관리</span>
|
||||
<span style="height: 36px; padding: 0 16px; border-radius: 18px; background: #FFFFFF; font-size: 13.5px; font-weight: 600; line-height: 36px; color: #1F1F1F">모두 지우기</span>
|
||||
</div>
|
||||
</div>
|
||||
<span aria-hidden="true" style="position: absolute; left: 50%; bottom: 16px; width: 44px; height: 4px; margin-left: -22px; border-radius: 2px; background: #9AA0A6"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="flex: 0 0 390px; display: flex; flex-direction: column; gap: 12px">
|
||||
<div style="height: 62px; display: flex; flex-direction: column; gap: 2px">
|
||||
<h3 style="margin: 0; height: 20px; font-size: 13px; line-height: 20px; font-weight: 700; color: #344054">7. 잠금 화면</h3>
|
||||
<p style="margin: 0; font-size: 12.5px; line-height: 20px; color: #667085; word-break: keep-all">잠금 화면에도 수만 보입니다. 보낸 사람과 제목은 알림에 싣지 않습니다.</p>
|
||||
</div>
|
||||
<div role="img" aria-label="잠금 화면에 온 ‘새 메일 3통’ 알림" style="position: relative; width: 390px; height: 844px; border-radius: 28px; overflow: hidden; background: linear-gradient(165deg, #22303C 0%, #33475A 55%, #4C6478 100%); box-shadow: 0 0 0 1px #D0D5DD, 0 12px 32px rgba(16, 24, 28, 0.12); display: flex; flex-direction: column; color: #FFFFFF">
|
||||
<div aria-hidden="true" style="flex: 0 0 auto; position: relative; display: flex; align-items: center; justify-content: flex-end; gap: 4px; height: 36px; box-sizing: border-box; padding: 0 20px 0 24px">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M21 3v18H3z"></path></svg>
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 21 23.5 6.6A18.6 18.6 0 0 0 12 3 18.6 18.6 0 0 0 .5 6.6z"></path></svg>
|
||||
<svg width="11" height="18" viewBox="0 0 11 18" fill="currentColor" aria-hidden="true"><rect x="3.5" y="0" width="4" height="2" rx="0.6"></rect><rect x="0.5" y="1.6" width="10" height="16" rx="2"></rect></svg>
|
||||
<span style="position: absolute; left: 50%; top: 11px; width: 14px; height: 14px; margin-left: -7px; border-radius: 50%; background: #0B0B0B"></span>
|
||||
</div>
|
||||
<div aria-hidden="true" style="flex: 1 1 auto; display: flex; flex-direction: column; align-items: center; padding: 60px 12px 0">
|
||||
<span style="font-size: 92px; line-height: 1; font-weight: 300; letter-spacing: -0.03em; font-variant-numeric: tabular-nums">9:41</span>
|
||||
<span style="margin-top: 14px; font-size: 16px; font-weight: 500; color: rgba(255, 255, 255, 0.92)">11월 7일 토요일</span>
|
||||
<div style="align-self: stretch; margin-top: 52px; display: flex; align-items: center; gap: 12px; padding: 14px 12px 14px 14px; border-radius: 24px; background: rgba(255, 255, 255, 0.9); color: #1F1F1F">
|
||||
<span style="flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; background: #0E7C86; color: #FFFFFF"><sc-if value="{{ siAra }}" hint-placeholder-val="{{ true }}"><img src="/_blob/2b9ef4349333debc969432c859eaf1bf" alt="" width="22" height="8" style="display: block; width: 22px; height: auto"></sc-if><sc-if value="{{ siEnv }}" hint-placeholder-val="{{ false }}"><svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"></path></svg></sc-if></span>
|
||||
<span style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 1px">
|
||||
<span style="font-size: 12.5px; line-height: 1.4; color: #5F6368">ARA SYSTEM · 지금</span>
|
||||
<span style="font-size: 15.5px; line-height: 1.4; font-weight: 600; color: #1F1F1F">새 메일 3통</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span aria-hidden="true" style="position: absolute; left: 50%; bottom: 112px; margin-left: -12px; display: inline-flex; color: #FFFFFF"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4" y="11" width="16" height="10" rx="2"></rect><path d="M8 11V7a4 4 0 0 1 8 0v4"></path></svg></span>
|
||||
<span aria-hidden="true" style="position: absolute; left: 28px; bottom: 36px; display: inline-flex; align-items: center; justify-content: center; width: 52px; height: 52px; border-radius: 50%; background: rgba(255, 255, 255, 0.16); color: #FFFFFF"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6c0 2-2 2-2 4v10a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2V10c0-2-2-2-2-4V2h12z"></path><line x1="6" y1="6" x2="18" y2="6"></line></svg></span>
|
||||
<span aria-hidden="true" style="position: absolute; right: 28px; bottom: 36px; display: inline-flex; align-items: center; justify-content: center; width: 52px; height: 52px; border-radius: 50%; background: rgba(255, 255, 255, 0.16); color: #FFFFFF"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"></path><circle cx="12" cy="13" r="4"></circle></svg></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="flex: 0 0 390px; display: flex; flex-direction: column; gap: 12px">
|
||||
<div style="height: 62px; display: flex; flex-direction: column; gap: 2px">
|
||||
<h3 style="margin: 0; height: 20px; font-size: 13px; line-height: 20px; font-weight: 700; color: #344054">8. 로그인이 끝났을 때</h3>
|
||||
<p style="margin: 0; font-size: 12.5px; line-height: 20px; color: #667085; word-break: keep-all">저절로 끝났거나 다른 곳에서 비밀번호를 바꿔 끊겼을 때 한 번 옵니다. ‘새 메일 N통’ 자리를 바꿔 씁니다.</p>
|
||||
</div>
|
||||
<div role="img" aria-label="로그인이 끝나 새 메일 알림이 멈췄다는 알림" style="position: relative; width: 390px; height: 844px; border-radius: 28px; overflow: hidden; background: #E8EBEE; box-shadow: 0 0 0 1px #D0D5DD, 0 12px 32px rgba(16, 24, 28, 0.12); display: flex; flex-direction: column">
|
||||
<div aria-hidden="true" style="display: flex; flex-direction: column">
|
||||
<div style="display: flex; align-items: flex-end; gap: 10px; padding: 22px 22px 10px">
|
||||
<span style="font-size: 30px; line-height: 1; font-weight: 400; color: #1F1F1F; font-variant-numeric: tabular-nums">9:41</span>
|
||||
<span style="padding-bottom: 2px; font-size: 13.5px; font-weight: 500; color: #44474E">11월 7일 (토)</span>
|
||||
<span style="flex: 1 1 auto"></span>
|
||||
<span style="display: inline-flex; align-items: center; gap: 4px; padding-bottom: 3px; color: #1F1F1F"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M21 3v18H3z"></path></svg><svg width="17" height="17" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 21 23.5 6.6A18.6 18.6 0 0 0 12 3 18.6 18.6 0 0 0 .5 6.6z"></path></svg><svg width="11" height="18" viewBox="0 0 11 18" fill="currentColor" aria-hidden="true"><rect x="3.5" y="0" width="4" height="2" rx="0.6"></rect><rect x="0.5" y="1.6" width="10" height="16" rx="2"></rect></svg></span>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding: 6px 12px 0">
|
||||
<span style="display: flex; align-items: center; gap: 10px; height: 60px; box-sizing: border-box; padding: 0 14px; border-radius: 22px; background: #C9D6E0; color: #1F1F1F; font-size: 13.5px; font-weight: 600; white-space: nowrap; overflow: hidden"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 21 23.5 6.6A18.6 18.6 0 0 0 12 3 18.6 18.6 0 0 0 .5 6.6z"></path></svg><span>인터넷</span></span>
|
||||
<span style="display: flex; align-items: center; gap: 10px; height: 60px; box-sizing: border-box; padding: 0 14px; border-radius: 22px; background: #FFFFFF; color: #1F1F1F; font-size: 13.5px; font-weight: 600; white-space: nowrap; overflow: hidden"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="7 7 17 17 12 22 12 2 17 7 7 17"></polyline></svg><span>블루투스</span></span>
|
||||
<span style="display: flex; align-items: center; gap: 10px; height: 60px; box-sizing: border-box; padding: 0 14px; border-radius: 22px; background: #FFFFFF; color: #1F1F1F; font-size: 13.5px; font-weight: 600; white-space: nowrap; overflow: hidden"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg><span>방해 금지 모드</span></span>
|
||||
<span style="display: flex; align-items: center; gap: 10px; height: 60px; box-sizing: border-box; padding: 0 14px; border-radius: 22px; background: #FFFFFF; color: #1F1F1F; font-size: 13.5px; font-weight: 600; white-space: nowrap; overflow: hidden"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6c0 2-2 2-2 4v10a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2V10c0-2-2-2-2-4V2h12z"></path><line x1="6" y1="6" x2="18" y2="6"></line></svg><span>손전등</span></span>
|
||||
</div>
|
||||
<div style="display: flex; margin: 10px 12px 0; height: 44px; border-radius: 22px; background: #FFFFFF; overflow: hidden"><span style="display: flex; align-items: center; justify-content: flex-end; width: 62%; height: 100%; box-sizing: border-box; padding-right: 13px; border-radius: 22px; background: #C9D6E0; color: #1F1F1F"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="4"></circle><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"></path></svg></span></div>
|
||||
<div style="display: flex; flex-direction: column; gap: 6px; padding: 18px 12px 0">
|
||||
<div style="display: flex; align-items: flex-start; gap: 12px; padding: 14px 12px 16px 14px; border-radius: 22px; background: #FFFFFF">
|
||||
<span style="flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; background: #0E7C86; color: #FFFFFF"><sc-if value="{{ siAra }}" hint-placeholder-val="{{ true }}"><img src="/_blob/2b9ef4349333debc969432c859eaf1bf" alt="" width="22" height="8" style="display: block; width: 22px; height: auto"></sc-if><sc-if value="{{ siEnv }}" hint-placeholder-val="{{ false }}"><svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"></path></svg></sc-if></span>
|
||||
<span style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 2px">
|
||||
<span style="display: flex; align-items: center; gap: 6px; font-size: 12.5px; line-height: 1.4; color: #5F6368"><span style="flex: 1 1 auto">ARA SYSTEM · 지금</span><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#5F6368" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="18 15 12 9 6 15"></polyline></svg></span>
|
||||
<span style="font-size: 15.5px; line-height: 1.4; font-weight: 600; color: #1F1F1F; word-break: keep-all">새 메일 알림이 멈췄습니다</span>
|
||||
<span style="font-size: 14px; line-height: 1.5; color: #44474E; word-break: keep-all">안전을 위해 로그아웃했습니다. 앱을 열어 다시 로그인해 주세요.</span>
|
||||
</span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 12px; padding: 14px 12px 14px 14px; border-radius: 22px; background: #FFFFFF">
|
||||
<span style="flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; background: #6B7785; color: #FFFFFF"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="4"></circle><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"></path></svg></span>
|
||||
<span style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 1px">
|
||||
<span style="font-size: 12.5px; line-height: 1.4; color: #5F6368">날씨 · 8분 전</span>
|
||||
<span style="font-size: 15.5px; line-height: 1.4; font-weight: 600; color: #1F1F1F">맑음 12°</span>
|
||||
<span style="font-size: 13.5px; line-height: 1.4; color: #44474E">오늘 최고 15°, 최저 6°</span>
|
||||
</span>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#5F6368" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="6 9 12 15 18 9"></polyline></svg>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; padding: 12px 16px 0">
|
||||
<span style="height: 36px; padding: 0 16px; border-radius: 18px; background: #FFFFFF; font-size: 13.5px; font-weight: 600; line-height: 36px; color: #1F1F1F">관리</span>
|
||||
<span style="height: 36px; padding: 0 16px; border-radius: 18px; background: #FFFFFF; font-size: 13.5px; font-weight: 600; line-height: 36px; color: #1F1F1F">모두 지우기</span>
|
||||
</div>
|
||||
</div>
|
||||
<span aria-hidden="true" style="position: absolute; left: 50%; bottom: 16px; width: 44px; height: 4px; margin-left: -22px; border-radius: 2px; background: #9AA0A6"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="flex: 0 0 390px; display: flex; flex-direction: column; gap: 12px">
|
||||
<div style="height: 62px; display: flex; flex-direction: column; gap: 2px">
|
||||
<h3 style="margin: 0; height: 20px; font-size: 13px; line-height: 20px; font-weight: 700; color: #344054">9. 작은 아이콘 후보</h3>
|
||||
<p style="margin: 0; font-size: 12.5px; line-height: 20px; color: #667085; word-break: keep-all">상태 표시줄과 알림 앞의 동그라미에 쓰는 아이콘입니다. 휴대폰은 모양만 쓰고 색은 직접 칠합니다.</p>
|
||||
</div>
|
||||
<div style="width: 390px; height: 844px; box-sizing: border-box; border-radius: 28px; background: #FFFFFF; box-shadow: 0 0 0 1px #D0D5DD, 0 12px 32px rgba(16, 24, 28, 0.12); padding: 24px 20px; display: flex; flex-direction: column">
|
||||
<sc-for list="{{ smallIcons }}" as="k" hint-placeholder-count="2">
|
||||
<div style="display: flex; flex-direction: column; gap: 14px; padding: {{ k.pad }}; border-bottom: {{ k.sep }}">
|
||||
<h4 style="margin: 0; font-size: 15.5px; line-height: 22px; font-weight: 700; color: #171C26">{{ k.title }}</h4>
|
||||
<div style="display: flex; align-items: flex-start; gap: 16px">
|
||||
<figure style="flex: 0 0 auto; margin: 0; display: flex; flex-direction: column; align-items: center; gap: 6px">
|
||||
<span aria-hidden="true" style="display: flex; align-items: center; justify-content: center; width: 104px; height: 104px; border-radius: 12px; background: #1F2933">
|
||||
<span style="display: flex; align-items: center; justify-content: center; width: 96px; height: 96px; box-sizing: border-box; border: 1px dashed rgba(255, 255, 255, 0.34)"><sc-if value="{{ k.isAra }}" hint-placeholder-val="{{ true }}"><img src="/_blob/2b9ef4349333debc969432c859eaf1bf" alt="" width="88" height="31" style="display: block; width: 88px; height: auto"></sc-if><sc-if value="{{ k.isEnv }}" hint-placeholder-val="{{ false }}"><svg width="94" height="94" viewBox="0 0 24 24" fill="#FFFFFF" aria-hidden="true"><path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"></path></svg></sc-if></span>
|
||||
</span>
|
||||
<figcaption style="font-size: 12px; line-height: 1.4; color: #667085; text-align: center">24dp 칸을<br>4배로</figcaption>
|
||||
</figure>
|
||||
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 8px">
|
||||
<span aria-hidden="true" style="display: flex; align-items: center; gap: 4px; height: 32px; box-sizing: border-box; padding: 0 10px; border-radius: 8px; background: #202124; color: #FFFFFF; font-size: 13px; font-weight: 600; font-variant-numeric: tabular-nums">
|
||||
<span>9:41</span>
|
||||
<span style="display: inline-flex; width: 24px; height: 24px; align-items: center; justify-content: center; margin-left: 4px"><sc-if value="{{ k.isAra }}" hint-placeholder-val="{{ true }}"><span class="ara-mask" style="display: block; width: 22px; height: 8px; background: currentColor"></span></sc-if><sc-if value="{{ k.isEnv }}" hint-placeholder-val="{{ false }}"><svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"></path></svg></sc-if></span>
|
||||
<span style="display: inline-flex; width: 24px; height: 24px; align-items: center; justify-content: center"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z"></path></svg></span>
|
||||
<span style="flex: 1 1 auto"></span>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 21 23.5 6.6A18.6 18.6 0 0 0 12 3 18.6 18.6 0 0 0 .5 6.6z"></path></svg>
|
||||
<svg width="10" height="16" viewBox="0 0 11 18" fill="currentColor" aria-hidden="true"><rect x="3.5" y="0" width="4" height="2" rx="0.6"></rect><rect x="0.5" y="1.6" width="10" height="16" rx="2"></rect></svg>
|
||||
</span>
|
||||
<span aria-hidden="true" style="display: flex; align-items: center; gap: 4px; height: 32px; box-sizing: border-box; padding: 0 10px; border-radius: 8px; background: #FFFFFF; box-shadow: inset 0 0 0 1px #D0D5DD; color: #1F1F1F; font-size: 13px; font-weight: 600; font-variant-numeric: tabular-nums">
|
||||
<span>9:41</span>
|
||||
<span style="display: inline-flex; width: 24px; height: 24px; align-items: center; justify-content: center; margin-left: 4px"><sc-if value="{{ k.isAra }}" hint-placeholder-val="{{ true }}"><span class="ara-mask" style="display: block; width: 22px; height: 8px; background: currentColor"></span></sc-if><sc-if value="{{ k.isEnv }}" hint-placeholder-val="{{ false }}"><svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"></path></svg></sc-if></span>
|
||||
<span style="display: inline-flex; width: 24px; height: 24px; align-items: center; justify-content: center"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z"></path></svg></span>
|
||||
<span style="flex: 1 1 auto"></span>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 21 23.5 6.6A18.6 18.6 0 0 0 12 3 18.6 18.6 0 0 0 .5 6.6z"></path></svg>
|
||||
<svg width="10" height="16" viewBox="0 0 11 18" fill="currentColor" aria-hidden="true"><rect x="3.5" y="0" width="4" height="2" rx="0.6"></rect><rect x="0.5" y="1.6" width="10" height="16" rx="2"></rect></svg>
|
||||
</span>
|
||||
<span style="font-size: 12px; line-height: 1.4; color: #667085">상태 표시줄, 실제 크기 (옆은 다른 앱 아이콘)</span>
|
||||
</div>
|
||||
</div>
|
||||
<span aria-hidden="true" style="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 20px; background: #E8EBEE">
|
||||
<span style="flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 16px; background: #FFFFFF">
|
||||
<span style="flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; background: #0E7C86; color: #FFFFFF"><sc-if value="{{ k.isAra }}" hint-placeholder-val="{{ true }}"><img src="/_blob/2b9ef4349333debc969432c859eaf1bf" alt="" width="22" height="8" style="display: block; width: 22px; height: auto"></sc-if><sc-if value="{{ k.isEnv }}" hint-placeholder-val="{{ false }}"><svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"></path></svg></sc-if></span>
|
||||
<span style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 1px">
|
||||
<span style="font-size: 12.5px; line-height: 1.4; color: #5F6368">ARA SYSTEM · 지금</span>
|
||||
<span style="font-size: 15.5px; line-height: 1.4; font-weight: 600; color: #1F1F1F">새 메일 3통</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<p style="margin: 0; font-size: 13px; line-height: 1.6; color: #4A5363; word-break: keep-all">{{ k.note }}</p>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="row3-h" style="display: flex; flex-direction: column; gap: 16px">
|
||||
<div style="display: flex; align-items: baseline; gap: 16px; min-height: 28px">
|
||||
<h2 id="row3-h" style="margin: 0; font-size: 19px; line-height: 28px; font-weight: 700; color: #0B3640; white-space: nowrap">업데이트 화면과 앱 아이콘</h2>
|
||||
<p style="margin: 0; font-size: 13.5px; line-height: 20px; color: #4A5363">앱 아이콘은 아직 Flutter 기본이라 이번에 함께 정합니다. 삼성 휴대폰은 알림창에도 앱 아이콘을 씁니다.</p>
|
||||
</div>
|
||||
<div style="display: flex; gap: 48px; height: 48px">
|
||||
<div role="group" aria-label="업데이트 화면 상태 고르기" style="flex: 0 0 390px; display: flex; gap: 8px">
|
||||
<sc-for list="{{ updateModes }}" as="s" hint-placeholder-count="2">
|
||||
<button type="button" aria-pressed="{{ s.pressed }}" onClick="{{ s.pick }}" style="flex: 1 1 0; min-width: 0; min-height: 48px; box-sizing: border-box; padding: 0 10px; border: 1px solid {{ s.border }}; border-radius: 10px; background: {{ s.bg }}; color: {{ s.fg }}; font: inherit; font-size: 14px; font-weight: 600; cursor: pointer">{{ s.label }}</button>
|
||||
</sc-for>
|
||||
</div>
|
||||
<div style="flex: 0 0 1266px"></div>
|
||||
<div role="group" aria-label="휴대폰 설정 화면 상태 고르기" style="flex: 0 0 390px; display: flex; gap: 8px">
|
||||
<sc-for list="{{ sysModes }}" as="s" hint-placeholder-count="2">
|
||||
<button type="button" aria-pressed="{{ s.pressed }}" onClick="{{ s.pick }}" style="flex: 1 1 0; min-width: 0; min-height: 48px; box-sizing: border-box; padding: 0 10px; border: 1px solid {{ s.border }}; border-radius: 10px; background: {{ s.bg }}; color: {{ s.fg }}; font: inherit; font-size: 14px; font-weight: 600; cursor: pointer">{{ s.label }}</button>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 48px; align-items: flex-start">
|
||||
|
||||
<div style="flex: 0 0 390px; display: flex; flex-direction: column; gap: 12px">
|
||||
<div style="height: 62px; display: flex; flex-direction: column; gap: 2px">
|
||||
<h3 style="margin: 0; height: 20px; font-size: 13px; line-height: 20px; font-weight: 700; color: #344054">10. 업데이트 화면</h3>
|
||||
<p style="margin: 0; font-size: 12.5px; line-height: 20px; color: #667085; word-break: keep-all">최소 버전보다 낮으면 로그인 전이든 메일을 보던 중이든 이 화면으로 옵니다. 뒤로 가기는 앱을 닫습니다.</p>
|
||||
</div>
|
||||
<div style="position: relative; width: 390px; height: 844px; border-radius: 28px; overflow: hidden; background: #FFFFFF; box-shadow: 0 0 0 1px #D0D5DD, 0 12px 32px rgba(16, 24, 28, 0.12); display: flex; flex-direction: column">
|
||||
<div style="flex: 1 1 auto; min-height: 0; overflow-y: auto; display: flex; flex-direction: column">
|
||||
<section aria-label="ARA SYSTEM" style="flex: 0 0 auto; padding: 36px 24px 28px; background: #0B3640">
|
||||
<img src="/_blob/3a2f4115a8f90207faa7d3a51cd9aec2" alt="ARA SYSTEM" width="150" height="72" style="display: block; width: 150px; height: auto">
|
||||
</section>
|
||||
<main style="display: flex; flex-direction: column; gap: 20px; padding: 32px 20px 32px">
|
||||
<div style="display: flex; flex-direction: column; gap: 10px">
|
||||
<h1 style="margin: 0; font-size: 22px; line-height: 1.35; font-weight: 700; letter-spacing: -0.02em">앱을 업데이트해 주세요</h1>
|
||||
<p style="margin: 0; font-size: 15px; line-height: 1.6; color: #4A5363; word-break: keep-all">지금 쓰는 버전으로는 로그인하거나 메일을 볼 수 없습니다. Play 스토어에서 ARA SYSTEM을 업데이트한 뒤 다시 열어 주세요.</p>
|
||||
</div>
|
||||
<button type="button" style="display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 48px; border: 0; border-radius: 10px; background: #0E7C86; color: #FFFFFF; font: inherit; font-size: 15.5px; font-weight: 600; cursor: pointer"><span>Play 스토어 열기</span><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg></button>
|
||||
<p style="margin: 0; font-size: 13.5px; color: #667085; font-variant-numeric: tabular-nums">지금 쓰는 버전 1.0.0</p>
|
||||
<p style="margin: 0; padding-top: 16px; border-top: 1px solid #EEF0F3; font-size: 13.5px; line-height: 1.55; color: #667085; word-break: keep-all">Play 스토어에 업데이트가 보이지 않나요?<br><span style="color: #344054; font-weight: 500">IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it</span></p>
|
||||
</main>
|
||||
</div>
|
||||
<sc-if value="{{ updateFail }}" hint-placeholder-val="{{ false }}">
|
||||
<div role="status" style="position: absolute; left: 12px; right: 12px; bottom: 16px; display: flex; align-items: center; min-height: 52px; box-sizing: border-box; padding: 12px 16px; border-radius: 10px; background: #0B3640; color: #FFFFFF; font-size: 14.5px; line-height: 1.5; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.22); word-break: keep-all">Play 스토어를 열지 못했습니다. Play 스토어 앱을 직접 열어 ARA SYSTEM을 찾아 업데이트해 주세요.</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="flex: 0 0 1266px; display: flex; flex-direction: column; gap: 12px">
|
||||
<div style="height: 62px; display: flex; flex-direction: column; gap: 2px">
|
||||
<h3 style="margin: 0; height: 20px; font-size: 13px; line-height: 20px; font-weight: 700; color: #344054">11. 앱 아이콘 후보</h3>
|
||||
<p style="margin: 0; font-size: 12.5px; line-height: 20px; color: #667085; word-break: keep-all">안드로이드 적응형 아이콘입니다. 휴대폰마다 동그라미나 둥근 네모로 잘라 씁니다. 마크는 잘리지 않는 가운데 칸 안에 둡니다.</p>
|
||||
</div>
|
||||
<div style="width: 1266px; box-sizing: border-box; border-radius: 28px; background: #FFFFFF; box-shadow: 0 0 0 1px #D0D5DD, 0 12px 32px rgba(16, 24, 28, 0.12); padding: 28px 28px 24px; display: flex; flex-direction: column; gap: 24px">
|
||||
<div style="display: flex; gap: 32px">
|
||||
<sc-for list="{{ appIcons }}" as="a" hint-placeholder-count="3">
|
||||
<div style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; gap: 16px; padding: 18px; border-radius: 20px; box-shadow: {{ a.frame }}; background: {{ a.panelBg }}">
|
||||
<div style="display: flex; flex-direction: column; gap: 2px">
|
||||
<h4 style="margin: 0; font-size: 16px; line-height: 24px; font-weight: 700; color: #171C26">{{ a.title }}</h4>
|
||||
<p style="margin: 0; font-size: 13px; line-height: 20px; color: #667085">{{ a.sub }}</p>
|
||||
</div>
|
||||
<div style="display: flex; align-items: flex-start; gap: 28px">
|
||||
<figure style="margin: 0; display: flex; flex-direction: column; align-items: center; gap: 8px">
|
||||
<span aria-hidden="true" style="display: flex; align-items: center; justify-content: center; width: 96px; height: 96px; border-radius: 50%; background: {{ a.bg }}; box-shadow: {{ a.ring }}"><sc-if value="{{ a.white }}" hint-placeholder-val="{{ true }}"><img src="/_blob/2b9ef4349333debc969432c859eaf1bf" alt="" width="62" height="22" style="display: block; width: 62px; height: auto"></sc-if><sc-if value="{{ a.teal }}" hint-placeholder-val="{{ false }}"><span class="ara-mask" style="display: block; width: 62px; height: 22px; background: #0E7C86"></span></sc-if></span>
|
||||
<figcaption style="font-size: 12px; color: #667085">동그라미 (Pixel 등)</figcaption>
|
||||
</figure>
|
||||
<figure style="margin: 0; display: flex; flex-direction: column; align-items: center; gap: 8px">
|
||||
<span aria-hidden="true" style="display: flex; align-items: center; justify-content: center; width: 96px; height: 96px; border-radius: 30%; background: {{ a.bg }}; box-shadow: {{ a.ring }}"><sc-if value="{{ a.white }}" hint-placeholder-val="{{ true }}"><img src="/_blob/2b9ef4349333debc969432c859eaf1bf" alt="" width="62" height="22" style="display: block; width: 62px; height: auto"></sc-if><sc-if value="{{ a.teal }}" hint-placeholder-val="{{ false }}"><span class="ara-mask" style="display: block; width: 62px; height: 22px; background: #0E7C86"></span></sc-if></span>
|
||||
<figcaption style="font-size: 12px; color: #667085">둥근 네모 (삼성 등)</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 6px">
|
||||
<div aria-hidden="true" style="display: flex; height: 150px; border-radius: 16px; overflow: hidden">
|
||||
<div style="flex: 1 1 0; display: flex; justify-content: space-evenly; align-items: center; background: linear-gradient(160deg, #EEF3F7 0%, #D3DFE8 100%)">
|
||||
<span style="display: flex; flex-direction: column; align-items: center; gap: 6px; width: 76px">
|
||||
<span style="display: flex; align-items: center; justify-content: center; width: 56px; height: 56px; border-radius: 50%; background: {{ a.bg }}; box-shadow: {{ a.homeShadow }}"><sc-if value="{{ a.white }}" hint-placeholder-val="{{ true }}"><img src="/_blob/2b9ef4349333debc969432c859eaf1bf" alt="" width="36" height="13" style="display: block; width: 36px; height: auto"></sc-if><sc-if value="{{ a.teal }}" hint-placeholder-val="{{ false }}"><span class="ara-mask" style="display: block; width: 36px; height: 13px; background: #0E7C86"></span></sc-if></span>
|
||||
<span style="font-size: 12px; line-height: 1.3; color: #1F1F1F; white-space: nowrap">ARA SYSTEM</span>
|
||||
</span>
|
||||
<span style="display: flex; flex-direction: column; align-items: center; gap: 6px; width: 76px">
|
||||
<span style="display: flex; align-items: center; justify-content: center; width: 56px; height: 56px; border-radius: 50%; background: #3D7BE0; color: #FFFFFF; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.22)"><svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z"></path></svg></span>
|
||||
<span style="font-size: 12px; line-height: 1.3; color: #1F1F1F; white-space: nowrap">메시지</span>
|
||||
</span>
|
||||
</div>
|
||||
<div style="flex: 1 1 0; display: flex; justify-content: space-evenly; align-items: center; background: linear-gradient(160deg, #263241 0%, #141B24 100%)">
|
||||
<span style="display: flex; flex-direction: column; align-items: center; gap: 6px; width: 76px">
|
||||
<span style="display: flex; align-items: center; justify-content: center; width: 56px; height: 56px; border-radius: 30%; background: {{ a.bg }}; box-shadow: {{ a.homeShadow }}"><sc-if value="{{ a.white }}" hint-placeholder-val="{{ true }}"><img src="/_blob/2b9ef4349333debc969432c859eaf1bf" alt="" width="36" height="13" style="display: block; width: 36px; height: auto"></sc-if><sc-if value="{{ a.teal }}" hint-placeholder-val="{{ false }}"><span class="ara-mask" style="display: block; width: 36px; height: 13px; background: #0E7C86"></span></sc-if></span>
|
||||
<span style="font-size: 12px; line-height: 1.3; color: #FFFFFF; white-space: nowrap">ARA SYSTEM</span>
|
||||
</span>
|
||||
<span style="display: flex; flex-direction: column; align-items: center; gap: 6px; width: 76px">
|
||||
<span style="display: flex; align-items: center; justify-content: center; width: 56px; height: 56px; border-radius: 30%; background: #4B5563; color: #FFFFFF; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.22)"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"></path><circle cx="12" cy="13" r="4"></circle></svg></span>
|
||||
<span style="font-size: 12px; line-height: 1.3; color: #FFFFFF; white-space: nowrap">카메라</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span style="font-size: 12px; line-height: 1.4; color: #667085">홈 화면 · 밝은 바탕화면과 어두운 바탕화면</span>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 6px">
|
||||
<span aria-hidden="true" style="display: flex; align-items: center; gap: 12px; padding: 12px 14px; border-radius: 20px; background: #EEF0F2">
|
||||
<span style="flex: 0 0 auto; display: flex; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 30%; background: {{ a.bg }}; box-shadow: {{ a.ring }}"><sc-if value="{{ a.white }}" hint-placeholder-val="{{ true }}"><img src="/_blob/2b9ef4349333debc969432c859eaf1bf" alt="" width="24" height="9" style="display: block; width: 24px; height: auto"></sc-if><sc-if value="{{ a.teal }}" hint-placeholder-val="{{ false }}"><span class="ara-mask" style="display: block; width: 24px; height: 9px; background: #0E7C86"></span></sc-if></span>
|
||||
<span style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 1px">
|
||||
<span style="font-size: 12.5px; line-height: 1.4; color: #5F6368">ARA SYSTEM · 지금</span>
|
||||
<span style="font-size: 15px; line-height: 1.4; font-weight: 600; color: #1F1F1F">새 메일 3통</span>
|
||||
</span>
|
||||
</span>
|
||||
<span style="font-size: 12px; line-height: 1.4; color: #667085">삼성 휴대폰 알림창 (실기기에서 확인)</span>
|
||||
</div>
|
||||
<p style="margin: 0; font-size: 13px; line-height: 1.6; color: #4A5363; word-break: keep-all">{{ a.note }}</p>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 16px; padding-top: 20px; border-top: 1px solid #EEF0F3">
|
||||
<span aria-hidden="true" style="flex: 0 0 auto; display: flex; align-items: center; justify-content: center; width: 56px; height: 56px; border-radius: 50%; background: #D7E3EA"><span class="ara-mask" style="display: block; width: 36px; height: 13px; background: #2F4A5A"></span></span>
|
||||
<p style="margin: 0; font-size: 13.5px; line-height: 1.6; color: #4A5363; word-break: keep-all">안드로이드 13 이상에서 휴대폰의 테마 아이콘을 켜면 셋 모두 바탕을 버리고 마크만 휴대폰 색으로 칠합니다. 그래서 마크만 담은 그림을 한 장 더 넣습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="flex: 0 0 390px; display: flex; flex-direction: column; gap: 12px">
|
||||
<div style="height: 62px; display: flex; flex-direction: column; gap: 2px">
|
||||
<h3 style="margin: 0; height: 20px; font-size: 13px; line-height: 20px; font-weight: 700; color: #344054">12. [설정 열기]가 여는 휴대폰 설정</h3>
|
||||
<p style="margin: 0; font-size: 12.5px; line-height: 20px; color: #667085; word-break: keep-all">안드로이드가 그려 모양은 휴대폰마다 다릅니다. ‘새 메일’이라는 이름과 설명만 앱이 정합니다.</p>
|
||||
</div>
|
||||
<div role="img" aria-label="휴대폰 설정의 ARA SYSTEM 알림 화면" style="position: relative; width: 390px; height: 844px; border-radius: 28px; overflow: hidden; background: #F6F7F9; box-shadow: 0 0 0 1px #D0D5DD, 0 12px 32px rgba(16, 24, 28, 0.12); display: flex; flex-direction: column">
|
||||
<div aria-hidden="true" style="flex: 0 0 auto; position: relative; display: flex; align-items: center; gap: 4px; height: 36px; box-sizing: border-box; padding: 0 20px 0 24px; color: #1F1F1F; font-size: 14px; font-weight: 600; font-variant-numeric: tabular-nums">
|
||||
<span>9:41</span>
|
||||
<span style="flex: 1 1 auto"></span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M21 3v18H3z"></path></svg>
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 21 23.5 6.6A18.6 18.6 0 0 0 12 3 18.6 18.6 0 0 0 .5 6.6z"></path></svg>
|
||||
<svg width="11" height="18" viewBox="0 0 11 18" fill="currentColor" aria-hidden="true"><rect x="3.5" y="0" width="4" height="2" rx="0.6"></rect><rect x="0.5" y="1.6" width="10" height="16" rx="2"></rect></svg>
|
||||
<span style="position: absolute; left: 50%; top: 11px; width: 14px; height: 14px; margin-left: -7px; border-radius: 50%; background: #111111"></span>
|
||||
</div>
|
||||
<div aria-hidden="true" style="flex: 1 1 auto; display: flex; flex-direction: column">
|
||||
<div style="display: flex; align-items: center; height: 56px; padding: 0 16px; color: #1F1F1F"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg></div>
|
||||
<div style="padding: 20px 24px 18px; font-size: 32px; line-height: 1.2; font-weight: 400; color: #1F1F1F">알림</div>
|
||||
<div style="display: flex; align-items: center; gap: 14px; padding: 0 24px 22px">
|
||||
<span style="display: flex; align-items: center; justify-content: center; width: 44px; height: 44px; border-radius: 50%; background: #0B3640"><img src="/_blob/2b9ef4349333debc969432c859eaf1bf" alt="" width="28" height="10" style="display: block; width: 28px; height: auto"></span>
|
||||
<span style="font-size: 17px; font-weight: 500; color: #1F1F1F">ARA SYSTEM</span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 12px; margin: 0 16px; padding: 18px 14px 18px 22px; border-radius: 28px; background: #DCE5EC">
|
||||
<span style="flex: 1 1 auto; min-width: 0; font-size: 16.5px; font-weight: 500; color: #1F1F1F">모든 ‘ARA SYSTEM’ 알림</span>
|
||||
<sc-for list="{{ sysMainSw }}" as="w" hint-placeholder-count="1"><span style="flex: 0 0 auto; display: flex; align-items: center; justify-content: {{ w.thumbJustify }}; width: 52px; height: 32px; box-sizing: border-box; padding: 0 2px; border: 2px solid {{ w.trackBorder }}; border-radius: 16px; background: {{ w.trackBg }}"><span style="display: block; width: {{ w.thumbSize }}; height: {{ w.thumbSize }}; margin: 0 {{ w.thumbGap }}; border-radius: 50%; background: {{ w.thumbBg }}"></span></span></sc-for>
|
||||
</div>
|
||||
<div style="padding: 26px 24px 6px; font-size: 13.5px; font-weight: 600; color: #3E5F76">알림 카테고리</div>
|
||||
<div style="display: flex; align-items: center; gap: 16px; padding: 14px 30px 14px 24px">
|
||||
<span style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 2px">
|
||||
<span style="font-size: 16.5px; color: {{ sysChanColor }}">새 메일</span>
|
||||
<span style="font-size: 14px; line-height: 1.45; color: {{ sysChanSubColor }}">새 메일이 오면 알려 드립니다.</span>
|
||||
</span>
|
||||
<span style="flex: 0 0 auto; width: 1px; height: 32px; background: #C4C7CC"></span>
|
||||
<sc-for list="{{ sysChanSw }}" as="w" hint-placeholder-count="1"><span style="flex: 0 0 auto; display: flex; align-items: center; justify-content: {{ w.thumbJustify }}; width: 52px; height: 32px; box-sizing: border-box; padding: 0 2px; border: 2px solid {{ w.trackBorder }}; border-radius: 16px; background: {{ w.trackBg }}"><span style="display: block; width: {{ w.thumbSize }}; height: {{ w.thumbSize }}; margin: 0 {{ w.thumbGap }}; border-radius: 50%; background: {{ w.thumbBg }}"></span></span></sc-for>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":2222,"height":3270}}'>
|
||||
class Component extends DCLogic {
|
||||
state = { after: null, icon: null, update: null, sys: null };
|
||||
|
||||
// 받은편지함 예시 메일 — App1MailList 의 같은 메일
|
||||
mails() {
|
||||
return [
|
||||
{ who: '이도윤', initial: '이', time: '오전 10:42', subject: 'Re: 유지보수 계약 갱신 건', preview: '말씀하신 조건으로 계약서 초안을 고쳐 첨부했습니다. 기간은 1년이고, 단가는 작년과 같습니다.', unread: true, att: true },
|
||||
{ who: '인사팀', initial: '인', time: '오전 9:05', subject: '[공지] 계약직 채용 일정 안내', preview: '11월 채용 일정을 알려 드립니다. 서류 접수는 11월 14일(금)까지입니다.', unread: true },
|
||||
{ who: '박준호', initial: '박', time: '오전 8:31', subject: '11월 인프라 점검 일정 조율', preview: '점검 시간은 토요일 오전 2시부터 4시까지로 잡으려고 합니다. 괜찮은지 확인 부탁드립니다.', unread: true, flag: true },
|
||||
{ who: '한빛파트너스 고객지원', initial: '한', time: '어제', subject: '11월 정기 점검 일정 안내', preview: '안녕하세요, 한빛파트너스 고객지원팀입니다. 11월 정기 점검 일정을 안내해 드립니다.', att: true },
|
||||
{ who: '최서연', initial: '최', time: '11월 3일', subject: '11월 비용 정산 안내', preview: '10월 사용분 영수증을 11월 10일까지 올려 주세요.', att: true },
|
||||
{ who: '정하늘', initial: '정', time: '11월 2일', subject: '신규 입사자 교육 회의록', preview: '지난 회의에서 정한 교육 일정과 담당자를 정리했습니다.' },
|
||||
{ who: '김민수', initial: '김', time: '10월 30일', subject: '회의실 예약 변경', preview: '목요일 회의를 3층 큰 회의실로 옮겼습니다.' },
|
||||
];
|
||||
}
|
||||
|
||||
// 목록 줄 하나 — 목록 보드의 줄 그대로(뒤 화면이라 누르지 않는다)
|
||||
row(m) {
|
||||
const unread = !!m.unread;
|
||||
return {
|
||||
who: m.who,
|
||||
initial: m.initial,
|
||||
time: m.time,
|
||||
subject: m.subject,
|
||||
preview: m.preview,
|
||||
att: !!m.att,
|
||||
flag: !!m.flag,
|
||||
fw: unread ? '650' : '450',
|
||||
subFw: unread ? '600' : '400',
|
||||
subColor: unread ? '#171C26' : '#344054',
|
||||
timeFw: unread ? '600' : '400',
|
||||
timeColor: unread ? '#171C26' : '#667085',
|
||||
};
|
||||
}
|
||||
|
||||
// 상태 고르기 단추 — 다른 앱 보드와 같은 모양
|
||||
modes(list, current, choose) {
|
||||
return list.map((x) => {
|
||||
const on = x.id === current;
|
||||
return {
|
||||
label: x.label,
|
||||
pressed: on ? 'true' : 'false',
|
||||
pick: () => choose(x.id),
|
||||
bg: on ? '#E3F2F3' : '#FFFFFF',
|
||||
fg: on ? '#0A5F67' : '#344054',
|
||||
border: on ? '#0E7C86' : '#D0D5DD',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// 앱의 스위치 — App1Shell 과 같은 값. 누를 수 없으면 옅게
|
||||
appSwitch(on, disabled) {
|
||||
if (disabled) return { trackBg: '#F2F4F7', trackBorder: '#D0D5DD', thumbBg: '#C5CBD3', thumbSize: '16px', thumbGap: '4px', thumbJustify: 'flex-start' };
|
||||
if (on) return { trackBg: '#0E7C86', trackBorder: '#0E7C86', thumbBg: '#FFFFFF', thumbSize: '24px', thumbGap: '0', thumbJustify: 'flex-end' };
|
||||
return { trackBg: '#FFFFFF', trackBorder: '#8A94A3', thumbBg: '#8A94A3', thumbSize: '16px', thumbGap: '4px', thumbJustify: 'flex-start' };
|
||||
}
|
||||
|
||||
// 휴대폰 설정 화면의 스위치 — 안드로이드가 그리는 회청색(반복 하나짜리 목록으로 넘긴다)
|
||||
sysSwitch(on, disabled) {
|
||||
if (disabled) return [{ trackBg: '#E6E8EB', trackBorder: '#C4C7CC', thumbBg: '#C4C7CC', thumbSize: '16px', thumbGap: '4px', thumbJustify: 'flex-start' }];
|
||||
if (on) return [{ trackBg: '#3E5F76', trackBorder: '#3E5F76', thumbBg: '#FFFFFF', thumbSize: '24px', thumbGap: '0', thumbJustify: 'flex-end' }];
|
||||
return [{ trackBg: '#E1E5EA', trackBorder: '#74777F', thumbBg: '#74777F', thumbSize: '16px', thumbGap: '4px', thumbJustify: 'flex-start' }];
|
||||
}
|
||||
|
||||
// 설정 › 새 메일 알림 줄의 상태 여덟 — 왼쪽은 스위치를 누를 수 있는 상태, 오른쪽은 누를 수 없는 상태
|
||||
notifyRows() {
|
||||
const BASE = '새 메일이 오면 휴대폰 알림으로 알려 드립니다.';
|
||||
const list = [
|
||||
{ id: 'n1', side: 'left', name: '켜짐', when: '처음 값', desc: BASE, on: true },
|
||||
{ id: 'n2', side: 'left', name: '꺼짐', when: '스위치를 껐을 때', desc: BASE, on: false },
|
||||
{ id: 'n3', side: 'left', name: '허락 전', when: '안드로이드 13 이상에서 아직 허용하지 않았을 때', desc: '켜면 휴대폰이 알림을 허용할지 묻습니다. 그때 [허용]을 눌러 주세요.', on: false },
|
||||
{ id: 'n8', side: 'left', name: '켜는 중 · 끄는 중', when: '스위치는 바꿀 값으로 옮기고 끝날 때까지 기다립니다', desc: BASE, on: true, busy: true },
|
||||
{ id: 'n4', side: 'right', name: '휴대폰에서 막힘', when: '두 번 거절했거나 휴대폰 설정에서 앱 알림을 껐을 때', desc: '휴대폰 설정에서 이 앱의 알림이 꺼져 있습니다. [설정 열기]를 눌러 알림을 켜 주세요.', disabled: true, action: true },
|
||||
{ id: 'n5', side: 'right', name: '새 메일 알림만 꺼짐', when: '휴대폰 설정에서 ‘새 메일’ 카테고리만 껐을 때', desc: '휴대폰 설정에서 새 메일 알림이 꺼져 있습니다. [설정 열기]를 눌러 켜 주세요.', disabled: true, action: true },
|
||||
{ id: 'n6', side: 'right', name: '지금 쓸 수 없음', when: '회사 서버가 알림을 보낼 수 없을 때', desc: '지금은 새 메일 알림을 쓸 수 없습니다. 새 메일은 앱을 열어 확인해 주세요.', disabled: true },
|
||||
{ id: 'n7', side: 'right', name: '이 휴대폰에서 받을 수 없음', when: 'Google Play 서비스가 없는 휴대폰 등', desc: '이 휴대폰에서는 새 메일 알림을 받을 수 없습니다. 새 메일은 앱을 열어 확인해 주세요.', disabled: true },
|
||||
];
|
||||
return list.map((x) => {
|
||||
const disabled = !!x.disabled;
|
||||
return Object.assign({
|
||||
id: x.id,
|
||||
side: x.side,
|
||||
name: x.name,
|
||||
when: x.when,
|
||||
desc: x.desc,
|
||||
descColor: disabled ? '#4A5363' : '#667085',
|
||||
action: !!x.action,
|
||||
busy: !!x.busy,
|
||||
busyText: '켜는 중',
|
||||
checked: x.on ? 'true' : 'false',
|
||||
disabledStr: disabled ? 'true' : 'false',
|
||||
busyStr: x.busy ? 'true' : 'false',
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
textPad: x.action ? '8px 0 0' : '0',
|
||||
}, this.appSwitch(!!x.on, disabled));
|
||||
});
|
||||
}
|
||||
|
||||
// 작은 아이콘 후보 둘
|
||||
smallIcons() {
|
||||
return [
|
||||
{ title: 'ARA 글자 마크 (추천)', isAra: true, isEnv: false, pad: '0 0 22px', sep: '1px solid #EEF0F3', note: '웹의 바탕화면 알림, 탭 아이콘과 같은 마크라 어느 앱의 알림인지 바로 압니다. 가로로 길어 상태 표시줄에서는 가늘게 보이고, 해상도가 낮은 휴대폰에서는 획을 한 겹 굵게 만듭니다.' },
|
||||
{ title: '편지 봉투', isAra: false, isEnv: true, pad: '22px 0 0', sep: '0', note: '작게 보여도 또렷하지만 다른 메일 앱과 모양이 같아 어느 앱의 알림인지 알기 어렵습니다.' },
|
||||
];
|
||||
}
|
||||
|
||||
// 앱 아이콘 후보 셋 — 추천(나)은 청록 테두리로 묶는다
|
||||
appIcons() {
|
||||
const shadow = '0 1px 3px rgba(0, 0, 0, 0.22)';
|
||||
return [
|
||||
{ title: '가. 청록 바탕', sub: '#0E7C86 바탕에 흰 마크', bg: '#0E7C86', ring: 'none', homeShadow: shadow, white: true, teal: false, frame: 'inset 0 0 0 1px #EEF0F3', panelBg: '#FFFFFF', note: '브랜드 강조색이라 홈 화면에서 눈에 잘 띕니다. 웹의 탭 아이콘과 홈 화면 아이콘(남색)과는 색이 다릅니다.' },
|
||||
{ title: '나. 남색 바탕 (추천)', sub: '#0B3640 바탕에 흰 마크', bg: '#0B3640', ring: 'none', homeShadow: shadow, white: true, teal: false, frame: 'inset 0 0 0 2px #0E7C86', panelBg: '#F3F8F8', note: '웹의 탭 아이콘, 홈 화면 아이콘, 바탕화면 알림과 같은 모양이라 같은 서비스로 바로 알아봅니다. 어두운 바탕화면에서도 흰 마크가 또렷합니다.' },
|
||||
{ title: '다. 흰 바탕', sub: '흰 바탕에 #0E7C86 마크', bg: '#FFFFFF', ring: 'inset 0 0 0 1px #D0D5DD', homeShadow: 'inset 0 0 0 1px #D0D5DD, ' + shadow, white: false, teal: true, frame: 'inset 0 0 0 1px #EEF0F3', panelBg: '#FFFFFF', note: '깔끔하지만 밝은 바탕화면에서는 테두리가 흐려지고, 흰 아이콘이 많은 홈 화면에서 묻히기 쉽습니다.' },
|
||||
];
|
||||
}
|
||||
|
||||
renderVals() {
|
||||
const st = this.state;
|
||||
const after = st.after ?? 'allow';
|
||||
const icon = st.icon ?? 'ara';
|
||||
const update = st.update ?? 'first';
|
||||
const sys = st.sys ?? 'channel';
|
||||
// 3. 고른 뒤 — 허용 · 허용 안함 · 창이 뜨지 않을 만큼 이미 막혀 있을 때
|
||||
const AFTER = {
|
||||
allow: { title: '3. [허용]을 누른 뒤', note: '권하기 줄이 사라지고 새 메일 알림이 켜집니다.', text: '새 메일 알림을 켰습니다.', action: false },
|
||||
deny: { title: '3. [허용 안함]을 누른 뒤', note: '권하기 줄이 사라집니다. [설정] 탭의 스위치를 켜면 한 번 더 묻습니다.', text: '나중에 [설정] 탭에서 새 메일 알림을 켤 수 있습니다.', action: false },
|
||||
blocked: { title: '3. 이미 막혀 있을 때', note: '휴대폰이 묻는 창이 뜨지 않으면 이 알림만 뜨고 권하기 줄이 사라집니다.', text: '휴대폰 설정에서 이 앱의 알림이 꺼져 있습니다.', action: true },
|
||||
};
|
||||
const A = AFTER[after];
|
||||
const notify = this.notifyRows();
|
||||
const appOff = sys === 'app';
|
||||
return {
|
||||
rows: this.mails().map((m) => this.row(m)),
|
||||
|
||||
afterModes: this.modes([{ id: 'allow', label: '허용' }, { id: 'deny', label: '허용 안함' }, { id: 'blocked', label: '이미 막힘' }], after, (id) => this.setState({ after: id })),
|
||||
afterTitle: A.title,
|
||||
afterNote: A.note,
|
||||
afterText: A.text,
|
||||
afterAction: A.action,
|
||||
snackPad: A.action ? '0 4px 0 16px' : '0 16px',
|
||||
|
||||
notifyLeft: notify.filter((x) => x.side === 'left'),
|
||||
notifyRight: notify.filter((x) => x.side === 'right'),
|
||||
|
||||
iconModes: this.modes([{ id: 'ara', label: 'ARA 마크 (추천)' }, { id: 'env', label: '편지 봉투' }], icon, (id) => this.setState({ icon: id })),
|
||||
siAra: icon === 'ara',
|
||||
siEnv: icon === 'env',
|
||||
smallIcons: this.smallIcons(),
|
||||
|
||||
updateModes: this.modes([{ id: 'first', label: '처음' }, { id: 'fail', label: '열지 못했을 때' }], update, (id) => this.setState({ update: id })),
|
||||
updateFail: update === 'fail',
|
||||
|
||||
appIcons: this.appIcons(),
|
||||
|
||||
sysModes: this.modes([{ id: 'channel', label: '새 메일만 꺼짐' }, { id: 'app', label: '앱 알림 모두 꺼짐' }], sys, (id) => this.setState({ sys: id })),
|
||||
sysMainSw: this.sysSwitch(!appOff, false),
|
||||
sysChanSw: this.sysSwitch(false, appOff),
|
||||
sysChanColor: appOff ? '#9AA0A6' : '#1F1F1F',
|
||||
sysChanSubColor: appOff ? '#B0B4BA' : '#44474E',
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,484 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>앱 · 메일 검색</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<style>
|
||||
@font-face{font-family:'Pretendard';src:url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2-variations'),url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2');font-weight:45 920;font-style:normal;font-display:swap}
|
||||
body{margin:0}
|
||||
a{color:#0E7C86}a:hover{color:#0A5F67}
|
||||
:focus-visible{outline:2px solid #0E7C86;outline-offset:2px}
|
||||
input::placeholder,textarea::placeholder{color:#8A94A3}
|
||||
mark{background:#FCE7A4;color:inherit}
|
||||
.sfield:focus-within .fakecaret{display:none}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="width: 3098px; height: 1028px; box-sizing: border-box; padding: 40px; display: flex; flex-direction: column; gap: 24px; background: #EEF1F4; color: #171C26; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 15px; line-height: 1.5">
|
||||
<div style="display: flex; justify-content: flex-end; height: 48px">
|
||||
<div role="group" aria-label="결과 없음 상태 고르기" style="flex: 0 0 390px; display: flex; gap: 8px">
|
||||
<sc-for list="{{ b7Modes }}" as="s" hint-placeholder-count="2">
|
||||
<button type="button" aria-pressed="{{ s.pressed }}" onClick="{{ s.pick }}" style="flex: 1 1 0; min-width: 0; min-height: 48px; box-sizing: border-box; padding: 0 12px; border: 1px solid {{ s.border }}; border-radius: 10px; background: {{ s.bg }}; color: {{ s.fg }}; font: inherit; font-size: 14px; font-weight: 600; cursor: pointer">{{ s.label }}</button>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 48px; align-items: flex-start">
|
||||
|
||||
<div style="flex: 0 0 390px; display: flex; flex-direction: column; gap: 12px">
|
||||
<h2 style="margin: 0; height: 20px; font-size: 13px; line-height: 20px; font-weight: 700; color: #344054">찾기 전 (자판 올라옴 · 탭 막대 숨김)</h2>
|
||||
<div style="position: relative; width: 390px; height: 844px; border-radius: 28px; overflow: hidden; background: #FFFFFF; box-shadow: 0 0 0 1px #D0D5DD, 0 12px 32px rgba(16, 24, 28, 0.12); display: flex; flex-direction: column">
|
||||
<header style="flex: 0 0 auto; display: flex; align-items: center; gap: 4px; height: 64px; box-sizing: border-box; padding: 0 4px; border-bottom: 1px solid #EEF0F3; background: #FFFFFF">
|
||||
<a href="App1MailList.dc.html" aria-label="검색 닫기" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #344054; cursor: pointer"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg></a>
|
||||
<span class="sfield" style="position: relative; flex: 1 1 auto; min-width: 0; display: flex; align-items: center">
|
||||
<input type="text" enterkeyhint="search" autocomplete="off" maxlength="200" aria-label="메일 검색" placeholder="메일 검색" style="flex: 1 1 auto; min-width: 0; height: 48px; box-sizing: border-box; padding: 0 4px; border: 0; outline: none; background: transparent; font: inherit; font-size: 16px; color: #171C26; caret-color: #0E7C86">
|
||||
<span class="fakecaret" aria-hidden="true" style="position: absolute; left: 4px; top: 14px; width: 2px; height: 20px; border-radius: 1px; background: #0E7C86"></span>
|
||||
</span>
|
||||
</header>
|
||||
<div style="flex: 0 0 auto; border-bottom: 1px solid #EEF0F3; background: #FBFBFC">
|
||||
<div style="display: flex; align-items: center; gap: 12px; height: 56px; box-sizing: border-box; padding: 0 16px">
|
||||
<span id="s1-scope" style="flex: 0 0 auto; font-size: 13px; font-weight: 700; color: #475467">찾을 곳</span>
|
||||
<div role="group" aria-labelledby="s1-scope" style="flex: 1 1 auto; min-width: 0; display: flex">
|
||||
<button type="button" aria-pressed="true" style="flex: 1 1 0; min-width: 0; height: 48px; display: flex; align-items: center; padding: 0; border: 0; background: transparent; font: inherit; cursor: pointer"><span style="flex: 1 1 auto; min-width: 0; display: block; height: 40px; box-sizing: border-box; padding: 0 12px; border: 1px solid #D0D5DD; border-radius: 20px 0 0 20px; background: #E3F2F3; color: #0A5F67; font-size: 14px; font-weight: 700; line-height: 38px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">받은편지함</span></button>
|
||||
<button type="button" aria-pressed="false" style="flex: 1 1 0; min-width: 0; height: 48px; display: flex; align-items: center; padding: 0; border: 0; background: transparent; font: inherit; cursor: pointer"><span style="flex: 1 1 auto; min-width: 0; display: block; height: 40px; box-sizing: border-box; padding: 0 12px; border: 1px solid #D0D5DD; border-left: 0; border-radius: 0 20px 20px 0; background: #FFFFFF; color: #344054; font-size: 14px; font-weight: 500; line-height: 38px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">전체 메일함</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p style="flex: 0 0 auto; margin: 0; padding: 16px 16px 0; font-size: 13px; line-height: 1.6; color: #667085; word-break: keep-all">보낸 사람, 받는 사람, 제목, 본문, 첨부 파일 이름에서 찾습니다. 여러 낱말을 넣으면 모두 들어간 메일을 찾습니다.</p>
|
||||
<div style="flex: 1 1 auto"></div>
|
||||
<div aria-hidden="true" style="flex: 0 0 auto; display: flex; flex-direction: column; gap: 12px; box-sizing: border-box; padding: 6px 6px 28px; background: #E4E7EC">
|
||||
<div style="height: 40px"></div>
|
||||
<div style="display: flex; gap: 6px; height: 52px">
|
||||
<sc-for list="{{ kb10 }}" as="k" hint-placeholder-count="10"><span style="flex: 1 1 0; border-radius: 8px; background: #F8F9FB; box-shadow: 0 1px 0 #C5CAD2"></span></sc-for>
|
||||
</div>
|
||||
<div style="display: flex; gap: 6px; height: 52px; padding: 0 18px">
|
||||
<sc-for list="{{ kb9 }}" as="k" hint-placeholder-count="9"><span style="flex: 1 1 0; border-radius: 8px; background: #F8F9FB; box-shadow: 0 1px 0 #C5CAD2"></span></sc-for>
|
||||
</div>
|
||||
<div style="display: flex; gap: 6px; height: 52px">
|
||||
<span style="flex: 1.5 1 0; border-radius: 8px; background: #CDD2D9"></span>
|
||||
<sc-for list="{{ kb7 }}" as="k" hint-placeholder-count="7"><span style="flex: 1 1 0; border-radius: 8px; background: #F8F9FB; box-shadow: 0 1px 0 #C5CAD2"></span></sc-for>
|
||||
<span style="flex: 1.5 1 0; border-radius: 8px; background: #CDD2D9"></span>
|
||||
</div>
|
||||
<div style="display: flex; gap: 6px; height: 52px">
|
||||
<span style="flex: 1.5 1 0; border-radius: 8px; background: #CDD2D9"></span>
|
||||
<span style="flex: 1 1 0; border-radius: 8px; background: #F8F9FB; box-shadow: 0 1px 0 #C5CAD2"></span>
|
||||
<span style="flex: 5 1 0; border-radius: 8px; background: #F8F9FB; box-shadow: 0 1px 0 #C5CAD2"></span>
|
||||
<span style="flex: 1 1 0; border-radius: 8px; background: #F8F9FB; box-shadow: 0 1px 0 #C5CAD2"></span>
|
||||
<span style="flex: 1.5 1 0; display: flex; align-items: center; justify-content: center; border-radius: 26px; background: #0E7C86; color: #FFFFFF"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="7"></circle><line x1="20" y1="20" x2="16.2" y2="16.2"></line></svg></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="flex: 0 0 390px; display: flex; flex-direction: column; gap: 12px">
|
||||
<h2 style="margin: 0; height: 20px; font-size: 13px; line-height: 20px; font-weight: 700; color: #344054">찾는 중</h2>
|
||||
<div style="position: relative; width: 390px; height: 844px; border-radius: 28px; overflow: hidden; background: #FFFFFF; box-shadow: 0 0 0 1px #D0D5DD, 0 12px 32px rgba(16, 24, 28, 0.12); display: flex; flex-direction: column">
|
||||
<header style="flex: 0 0 auto; display: flex; align-items: center; gap: 4px; height: 64px; box-sizing: border-box; padding: 0 4px; border-bottom: 1px solid #EEF0F3; background: #FFFFFF">
|
||||
<a href="App1MailList.dc.html" aria-label="검색 닫기" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #344054; cursor: pointer"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg></a>
|
||||
<input type="text" enterkeyhint="search" autocomplete="off" maxlength="200" aria-label="메일 검색" placeholder="메일 검색" value="계약" style="flex: 1 1 auto; min-width: 0; height: 48px; box-sizing: border-box; padding: 0 4px; border: 0; outline: none; background: transparent; font: inherit; font-size: 16px; color: #171C26; caret-color: #0E7C86">
|
||||
<button type="button" aria-label="검색어 지우기" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #344054; cursor: pointer"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
|
||||
</header>
|
||||
<div style="flex: 0 0 auto; border-bottom: 1px solid #EEF0F3; background: #FBFBFC">
|
||||
<div style="display: flex; align-items: center; gap: 12px; height: 56px; box-sizing: border-box; padding: 0 16px">
|
||||
<span id="s2-scope" style="flex: 0 0 auto; font-size: 13px; font-weight: 700; color: #475467">찾을 곳</span>
|
||||
<div role="group" aria-labelledby="s2-scope" style="flex: 1 1 auto; min-width: 0; display: flex">
|
||||
<button type="button" aria-pressed="true" style="flex: 1 1 0; min-width: 0; height: 48px; display: flex; align-items: center; padding: 0; border: 0; background: transparent; font: inherit; cursor: pointer"><span style="flex: 1 1 auto; min-width: 0; display: block; height: 40px; box-sizing: border-box; padding: 0 12px; border: 1px solid #D0D5DD; border-radius: 20px 0 0 20px; background: #E3F2F3; color: #0A5F67; font-size: 14px; font-weight: 700; line-height: 38px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">받은편지함</span></button>
|
||||
<button type="button" aria-pressed="false" style="flex: 1 1 0; min-width: 0; height: 48px; display: flex; align-items: center; padding: 0; border: 0; background: transparent; font: inherit; cursor: pointer"><span style="flex: 1 1 auto; min-width: 0; display: block; height: 40px; box-sizing: border-box; padding: 0 12px; border: 1px solid #D0D5DD; border-left: 0; border-radius: 0 20px 20px 0; background: #FFFFFF; color: #344054; font-size: 14px; font-weight: 500; line-height: 38px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">전체 메일함</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p role="status" style="flex: 0 0 auto; margin: 0; height: 36px; box-sizing: border-box; padding: 0 16px; font-size: 13px; line-height: 36px; color: #667085; font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis">‘계약’ · 찾는 중</p>
|
||||
<div style="position: relative; flex: 1 1 auto; min-height: 0; overflow: hidden">
|
||||
<span style="position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap">찾는 중</span>
|
||||
<div aria-hidden="true">
|
||||
<sc-for list="{{ skel }}" as="k" hint-placeholder-count="8">
|
||||
<div style="display: flex; align-items: flex-start; gap: 10px; padding: 14px 16px 14px 12px">
|
||||
<span style="flex: 0 0 auto; width: 40px; height: 40px; border-radius: 50%; background: #E4E7EC"></span>
|
||||
<span style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 8px">
|
||||
<span style="display: block; width: 40%; height: 10px; border-radius: 4px; background: #E4E7EC"></span>
|
||||
<span style="display: block; width: 80%; height: 10px; border-radius: 4px; background: #E4E7EC"></span>
|
||||
<span style="display: block; width: 100%; height: 10px; border-radius: 4px; background: #E4E7EC"></span>
|
||||
</span>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
<nav aria-label="앱 메뉴" style="flex: 0 0 auto; display: flex; height: 72px; box-sizing: border-box; padding: 8px 0 10px; border-top: 1px solid #EEF0F3; background: #FFFFFF">
|
||||
<a href="#" aria-current="page" style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 0; border: 0; background: transparent; color: #0A5F67; font: inherit; font-size: 12.5px; font-weight: 700; text-decoration: none; cursor: pointer"><span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 56px; height: 32px; border-radius: 16px; background: #E3F2F3"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="20" height="16" rx="2"></rect><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"></path></svg></span><span>메일</span></a>
|
||||
<a href="App1Shell.dc.html" style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 0; border: 0; background: transparent; color: #4A5363; font: inherit; font-size: 12.5px; font-weight: 500; text-decoration: none; cursor: pointer"><span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 56px; height: 32px; border-radius: 16px; background: transparent"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg></span><span>설정</span></a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="flex: 0 0 390px; display: flex; flex-direction: column; gap: 12px">
|
||||
<h2 style="margin: 0; height: 20px; font-size: 13px; line-height: 20px; font-weight: 700; color: #344054">결과 · 받은편지함에서</h2>
|
||||
<div style="position: relative; width: 390px; height: 844px; border-radius: 28px; overflow: hidden; background: #FFFFFF; box-shadow: 0 0 0 1px #D0D5DD, 0 12px 32px rgba(16, 24, 28, 0.12); display: flex; flex-direction: column">
|
||||
<header style="flex: 0 0 auto; display: flex; align-items: center; gap: 4px; height: 64px; box-sizing: border-box; padding: 0 4px; border-bottom: 1px solid #EEF0F3; background: #FFFFFF">
|
||||
<a href="App1MailList.dc.html" aria-label="검색 닫기" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #344054; cursor: pointer"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg></a>
|
||||
<input type="text" enterkeyhint="search" autocomplete="off" maxlength="200" aria-label="메일 검색" placeholder="메일 검색" value="계약" style="flex: 1 1 auto; min-width: 0; height: 48px; box-sizing: border-box; padding: 0 4px; border: 0; outline: none; background: transparent; font: inherit; font-size: 16px; color: #171C26; caret-color: #0E7C86">
|
||||
<button type="button" aria-label="검색어 지우기" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #344054; cursor: pointer"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
|
||||
</header>
|
||||
<div style="flex: 0 0 auto; border-bottom: 1px solid #EEF0F3; background: #FBFBFC">
|
||||
<div style="display: flex; align-items: center; gap: 12px; height: 56px; box-sizing: border-box; padding: 0 16px">
|
||||
<span id="s3-scope" style="flex: 0 0 auto; font-size: 13px; font-weight: 700; color: #475467">찾을 곳</span>
|
||||
<div role="group" aria-labelledby="s3-scope" style="flex: 1 1 auto; min-width: 0; display: flex">
|
||||
<button type="button" aria-pressed="true" style="flex: 1 1 0; min-width: 0; height: 48px; display: flex; align-items: center; padding: 0; border: 0; background: transparent; font: inherit; cursor: pointer"><span style="flex: 1 1 auto; min-width: 0; display: block; height: 40px; box-sizing: border-box; padding: 0 12px; border: 1px solid #D0D5DD; border-radius: 20px 0 0 20px; background: #E3F2F3; color: #0A5F67; font-size: 14px; font-weight: 700; line-height: 38px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">받은편지함</span></button>
|
||||
<button type="button" aria-pressed="false" style="flex: 1 1 0; min-width: 0; height: 48px; display: flex; align-items: center; padding: 0; border: 0; background: transparent; font: inherit; cursor: pointer"><span style="flex: 1 1 auto; min-width: 0; display: block; height: 40px; box-sizing: border-box; padding: 0 12px; border: 1px solid #D0D5DD; border-left: 0; border-radius: 0 20px 20px 0; background: #FFFFFF; color: #344054; font-size: 14px; font-weight: 500; line-height: 38px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">전체 메일함</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p role="status" style="flex: 0 0 auto; margin: 0; height: 36px; box-sizing: border-box; padding: 0 16px; font-size: 13px; line-height: 36px; color: #667085; font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis">‘계약’ · 4통</p>
|
||||
<ul aria-label="검색 결과" style="flex: 1 1 auto; min-height: 0; overflow-y: auto; list-style: none; margin: 0; padding: 0 0 24px">
|
||||
<sc-for list="{{ rows3 }}" as="r" hint-placeholder-count="4">
|
||||
<li style="border-bottom: 1px solid #F2F4F7; background: #FFFFFF">
|
||||
<a href="App1SearchStates.dc.html" aria-label="{{ r.label }}" style="display: flex; align-items: flex-start; gap: 6px; padding: 10px 16px 10px 8px; color: inherit; text-decoration: none">
|
||||
<span aria-hidden="true" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center"><span style="display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; background: #E3F2F3; color: #0A5F67; font-size: 16px; font-weight: 600">{{ r.initial }}</span></span>
|
||||
<span style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 1px; padding-top: 1px">
|
||||
<span style="display: flex; align-items: baseline; gap: 8px">
|
||||
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; font-weight: {{ r.fw }}; color: #171C26">{{ r.who }}</span>
|
||||
<span style="flex: 0 0 auto; display: flex; align-items: baseline">
|
||||
<sc-if value="{{ r.hasBox }}" hint-placeholder-val="{{ false }}"><span style="flex: 0 1 auto; min-width: 0; max-width: 124px; box-sizing: border-box; padding-right: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; color: #667085">{{ r.boxLabel }}</span></sc-if>
|
||||
<span style="flex: 0 0 auto; font-size: 12.5px; font-weight: {{ r.timeFw }}; color: {{ r.timeColor }}; font-variant-numeric: tabular-nums; white-space: nowrap">{{ r.time }}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span style="display: flex; align-items: center; gap: 6px; min-width: 0">
|
||||
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14.5px; font-weight: {{ r.subFw }}; color: {{ r.subColor }}"><sc-for list="{{ r.subjectParts }}" as="p" hint-placeholder-count="1"><sc-if value="{{ p.hit }}" hint-placeholder-val="{{ false }}"><mark>{{ p.t }}</mark></sc-if><sc-if value="{{ p.plain }}" hint-placeholder-val="{{ true }}"><span>{{ p.t }}</span></sc-if></sc-for></span>
|
||||
<sc-if value="{{ r.att }}" hint-placeholder-val="{{ false }}"><svg aria-hidden="true" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex: 0 0 auto"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path></svg></sc-if>
|
||||
<sc-if value="{{ r.flag }}" hint-placeholder-val="{{ false }}"><svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="#B7791F" stroke="#B7791F" stroke-width="1.6" stroke-linejoin="round" style="flex: 0 0 auto"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg></sc-if>
|
||||
</span>
|
||||
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13.5px; color: #667085"><sc-for list="{{ r.previewParts }}" as="p" hint-placeholder-count="1"><sc-if value="{{ p.hit }}" hint-placeholder-val="{{ false }}"><mark style="color: {{ p.fg }}">{{ p.t }}</mark></sc-if><sc-if value="{{ p.plain }}" hint-placeholder-val="{{ true }}"><span>{{ p.t }}</span></sc-if></sc-for></span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</sc-for>
|
||||
</ul>
|
||||
<nav aria-label="앱 메뉴" style="flex: 0 0 auto; display: flex; height: 72px; box-sizing: border-box; padding: 8px 0 10px; border-top: 1px solid #EEF0F3; background: #FFFFFF">
|
||||
<a href="#" aria-current="page" style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 0; border: 0; background: transparent; color: #0A5F67; font: inherit; font-size: 12.5px; font-weight: 700; text-decoration: none; cursor: pointer"><span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 56px; height: 32px; border-radius: 16px; background: #E3F2F3"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="20" height="16" rx="2"></rect><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"></path></svg></span><span>메일</span></a>
|
||||
<a href="App1Shell.dc.html" style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 0; border: 0; background: transparent; color: #4A5363; font: inherit; font-size: 12.5px; font-weight: 500; text-decoration: none; cursor: pointer"><span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 56px; height: 32px; border-radius: 16px; background: transparent"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg></span><span>설정</span></a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="flex: 0 0 390px; display: flex; flex-direction: column; gap: 12px">
|
||||
<h2 style="margin: 0; height: 20px; font-size: 13px; line-height: 20px; font-weight: 700; color: #344054">결과 · 전체 메일함에서</h2>
|
||||
<div style="position: relative; width: 390px; height: 844px; border-radius: 28px; overflow: hidden; background: #FFFFFF; box-shadow: 0 0 0 1px #D0D5DD, 0 12px 32px rgba(16, 24, 28, 0.12); display: flex; flex-direction: column">
|
||||
<header style="flex: 0 0 auto; display: flex; align-items: center; gap: 4px; height: 64px; box-sizing: border-box; padding: 0 4px; border-bottom: 1px solid #EEF0F3; background: #FFFFFF">
|
||||
<a href="App1MailList.dc.html" aria-label="검색 닫기" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #344054; cursor: pointer"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg></a>
|
||||
<input type="text" enterkeyhint="search" autocomplete="off" maxlength="200" aria-label="메일 검색" placeholder="메일 검색" value="계약" style="flex: 1 1 auto; min-width: 0; height: 48px; box-sizing: border-box; padding: 0 4px; border: 0; outline: none; background: transparent; font: inherit; font-size: 16px; color: #171C26; caret-color: #0E7C86">
|
||||
<button type="button" aria-label="검색어 지우기" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #344054; cursor: pointer"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
|
||||
</header>
|
||||
<div style="flex: 0 0 auto; border-bottom: 1px solid #EEF0F3; background: #FBFBFC">
|
||||
<div style="display: flex; align-items: center; gap: 12px; height: 56px; box-sizing: border-box; padding: 0 16px">
|
||||
<span id="s4-scope" style="flex: 0 0 auto; font-size: 13px; font-weight: 700; color: #475467">찾을 곳</span>
|
||||
<div role="group" aria-labelledby="s4-scope" style="flex: 1 1 auto; min-width: 0; display: flex">
|
||||
<button type="button" aria-pressed="false" style="flex: 1 1 0; min-width: 0; height: 48px; display: flex; align-items: center; padding: 0; border: 0; background: transparent; font: inherit; cursor: pointer"><span style="flex: 1 1 auto; min-width: 0; display: block; height: 40px; box-sizing: border-box; padding: 0 12px; border: 1px solid #D0D5DD; border-radius: 20px 0 0 20px; background: #FFFFFF; color: #344054; font-size: 14px; font-weight: 500; line-height: 38px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">받은편지함</span></button>
|
||||
<button type="button" aria-pressed="true" style="flex: 1 1 0; min-width: 0; height: 48px; display: flex; align-items: center; padding: 0; border: 0; background: transparent; font: inherit; cursor: pointer"><span style="flex: 1 1 auto; min-width: 0; display: block; height: 40px; box-sizing: border-box; padding: 0 12px; border: 1px solid #D0D5DD; border-left: 0; border-radius: 0 20px 20px 0; background: #E3F2F3; color: #0A5F67; font-size: 14px; font-weight: 700; line-height: 38px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">전체 메일함</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<p style="margin: -4px 0 0; padding: 0 16px 12px; font-size: 13px; line-height: 1.5; color: #667085; word-break: keep-all">휴지통과 스팸메일함은 빼고 찾습니다</p>
|
||||
</div>
|
||||
<p role="status" style="flex: 0 0 auto; margin: 0; height: 36px; box-sizing: border-box; padding: 0 16px; font-size: 13px; line-height: 36px; color: #667085; font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis">‘계약’ · 6통</p>
|
||||
<ul aria-label="검색 결과" style="flex: 1 1 auto; min-height: 0; overflow-y: auto; list-style: none; margin: 0; padding: 0 0 24px">
|
||||
<sc-for list="{{ rows4 }}" as="r" hint-placeholder-count="6">
|
||||
<li style="border-bottom: 1px solid #F2F4F7; background: #FFFFFF">
|
||||
<a href="App1SearchStates.dc.html" aria-label="{{ r.label }}" style="display: flex; align-items: flex-start; gap: 6px; padding: 10px 16px 10px 8px; color: inherit; text-decoration: none">
|
||||
<span aria-hidden="true" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center"><span style="display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; background: #E3F2F3; color: #0A5F67; font-size: 16px; font-weight: 600">{{ r.initial }}</span></span>
|
||||
<span style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 1px; padding-top: 1px">
|
||||
<span style="display: flex; align-items: baseline; gap: 8px">
|
||||
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; font-weight: {{ r.fw }}; color: #171C26">{{ r.who }}</span>
|
||||
<span style="flex: 0 0 auto; display: flex; align-items: baseline">
|
||||
<sc-if value="{{ r.hasBox }}" hint-placeholder-val="{{ true }}"><span style="flex: 0 1 auto; min-width: 0; max-width: 124px; box-sizing: border-box; padding-right: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; color: #667085">{{ r.boxLabel }}</span></sc-if>
|
||||
<span style="flex: 0 0 auto; font-size: 12.5px; font-weight: {{ r.timeFw }}; color: {{ r.timeColor }}; font-variant-numeric: tabular-nums; white-space: nowrap">{{ r.time }}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span style="display: flex; align-items: center; gap: 6px; min-width: 0">
|
||||
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14.5px; font-weight: {{ r.subFw }}; color: {{ r.subColor }}"><sc-for list="{{ r.subjectParts }}" as="p" hint-placeholder-count="1"><sc-if value="{{ p.hit }}" hint-placeholder-val="{{ false }}"><mark>{{ p.t }}</mark></sc-if><sc-if value="{{ p.plain }}" hint-placeholder-val="{{ true }}"><span>{{ p.t }}</span></sc-if></sc-for></span>
|
||||
<sc-if value="{{ r.att }}" hint-placeholder-val="{{ false }}"><svg aria-hidden="true" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex: 0 0 auto"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path></svg></sc-if>
|
||||
<sc-if value="{{ r.flag }}" hint-placeholder-val="{{ false }}"><svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="#B7791F" stroke="#B7791F" stroke-width="1.6" stroke-linejoin="round" style="flex: 0 0 auto"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg></sc-if>
|
||||
</span>
|
||||
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13.5px; color: #667085"><sc-for list="{{ r.previewParts }}" as="p" hint-placeholder-count="1"><sc-if value="{{ p.hit }}" hint-placeholder-val="{{ false }}"><mark style="color: {{ p.fg }}">{{ p.t }}</mark></sc-if><sc-if value="{{ p.plain }}" hint-placeholder-val="{{ true }}"><span>{{ p.t }}</span></sc-if></sc-for></span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</sc-for>
|
||||
</ul>
|
||||
<nav aria-label="앱 메뉴" style="flex: 0 0 auto; display: flex; height: 72px; box-sizing: border-box; padding: 8px 0 10px; border-top: 1px solid #EEF0F3; background: #FFFFFF">
|
||||
<a href="#" aria-current="page" style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 0; border: 0; background: transparent; color: #0A5F67; font: inherit; font-size: 12.5px; font-weight: 700; text-decoration: none; cursor: pointer"><span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 56px; height: 32px; border-radius: 16px; background: #E3F2F3"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="20" height="16" rx="2"></rect><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"></path></svg></span><span>메일</span></a>
|
||||
<a href="App1Shell.dc.html" style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 0; border: 0; background: transparent; color: #4A5363; font: inherit; font-size: 12.5px; font-weight: 500; text-decoration: none; cursor: pointer"><span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 56px; height: 32px; border-radius: 16px; background: transparent"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg></span><span>설정</span></a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="flex: 0 0 390px; display: flex; flex-direction: column; gap: 12px">
|
||||
<h2 style="margin: 0; height: 20px; font-size: 13px; line-height: 20px; font-weight: 700; color: #344054">검색 준비 중</h2>
|
||||
<div style="position: relative; width: 390px; height: 844px; border-radius: 28px; overflow: hidden; background: #FFFFFF; box-shadow: 0 0 0 1px #D0D5DD, 0 12px 32px rgba(16, 24, 28, 0.12); display: flex; flex-direction: column">
|
||||
<header style="flex: 0 0 auto; display: flex; align-items: center; gap: 4px; height: 64px; box-sizing: border-box; padding: 0 4px; border-bottom: 1px solid #EEF0F3; background: #FFFFFF">
|
||||
<a href="App1MailList.dc.html" aria-label="검색 닫기" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #344054; cursor: pointer"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg></a>
|
||||
<input type="text" enterkeyhint="search" autocomplete="off" maxlength="200" aria-label="메일 검색" placeholder="메일 검색" value="계약" style="flex: 1 1 auto; min-width: 0; height: 48px; box-sizing: border-box; padding: 0 4px; border: 0; outline: none; background: transparent; font: inherit; font-size: 16px; color: #171C26; caret-color: #0E7C86">
|
||||
<button type="button" aria-label="검색어 지우기" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #344054; cursor: pointer"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
|
||||
</header>
|
||||
<div style="flex: 0 0 auto; border-bottom: 1px solid #EEF0F3; background: #FBFBFC">
|
||||
<div style="display: flex; align-items: center; gap: 12px; height: 56px; box-sizing: border-box; padding: 0 16px">
|
||||
<span id="s5-scope" style="flex: 0 0 auto; font-size: 13px; font-weight: 700; color: #475467">찾을 곳</span>
|
||||
<div role="group" aria-labelledby="s5-scope" style="flex: 1 1 auto; min-width: 0; display: flex">
|
||||
<button type="button" aria-pressed="true" style="flex: 1 1 0; min-width: 0; height: 48px; display: flex; align-items: center; padding: 0; border: 0; background: transparent; font: inherit; cursor: pointer"><span style="flex: 1 1 auto; min-width: 0; display: block; height: 40px; box-sizing: border-box; padding: 0 12px; border: 1px solid #D0D5DD; border-radius: 20px 0 0 20px; background: #E3F2F3; color: #0A5F67; font-size: 14px; font-weight: 700; line-height: 38px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">받은편지함</span></button>
|
||||
<button type="button" aria-pressed="false" style="flex: 1 1 0; min-width: 0; height: 48px; display: flex; align-items: center; padding: 0; border: 0; background: transparent; font: inherit; cursor: pointer"><span style="flex: 1 1 auto; min-width: 0; display: block; height: 40px; box-sizing: border-box; padding: 0 12px; border: 1px solid #D0D5DD; border-left: 0; border-radius: 0 20px 20px 0; background: #FFFFFF; color: #344054; font-size: 14px; font-weight: 500; line-height: 38px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">전체 메일함</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p role="status" style="flex: 0 0 auto; margin: 0; height: 36px; box-sizing: border-box; padding: 0 16px; font-size: 13px; line-height: 36px; color: #667085; font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis">‘계약’ · 3통</p>
|
||||
<div role="status" style="flex: 0 0 auto; display: flex; align-items: flex-start; gap: 8px; padding: 10px 16px; border-bottom: 1px solid #E3EEEF; background: #F3F8F8; color: #344054">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#0E7C86" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto; margin-top: 2px"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>
|
||||
<p style="flex: 1 1 auto; min-width: 0; margin: 0; font-size: 13px; line-height: 1.55; word-break: keep-all">지난 메일을 검색할 수 있게 밤사이에 준비하고 있습니다. 메일이 많으면 며칠 걸릴 수 있고, 그동안은 낱말이 통째로 같은 메일만 찾습니다.</p>
|
||||
</div>
|
||||
<ul aria-label="검색 결과" style="flex: 1 1 auto; min-height: 0; overflow-y: auto; list-style: none; margin: 0; padding: 0 0 24px">
|
||||
<sc-for list="{{ rows5 }}" as="r" hint-placeholder-count="3">
|
||||
<li style="border-bottom: 1px solid #F2F4F7; background: #FFFFFF">
|
||||
<a href="App1SearchStates.dc.html" aria-label="{{ r.label }}" style="display: flex; align-items: flex-start; gap: 6px; padding: 10px 16px 10px 8px; color: inherit; text-decoration: none">
|
||||
<span aria-hidden="true" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center"><span style="display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; background: #E3F2F3; color: #0A5F67; font-size: 16px; font-weight: 600">{{ r.initial }}</span></span>
|
||||
<span style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 1px; padding-top: 1px">
|
||||
<span style="display: flex; align-items: baseline; gap: 8px">
|
||||
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; font-weight: {{ r.fw }}; color: #171C26">{{ r.who }}</span>
|
||||
<span style="flex: 0 0 auto; display: flex; align-items: baseline">
|
||||
<sc-if value="{{ r.hasBox }}" hint-placeholder-val="{{ false }}"><span style="flex: 0 1 auto; min-width: 0; max-width: 124px; box-sizing: border-box; padding-right: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; color: #667085">{{ r.boxLabel }}</span></sc-if>
|
||||
<span style="flex: 0 0 auto; font-size: 12.5px; font-weight: {{ r.timeFw }}; color: {{ r.timeColor }}; font-variant-numeric: tabular-nums; white-space: nowrap">{{ r.time }}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span style="display: flex; align-items: center; gap: 6px; min-width: 0">
|
||||
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14.5px; font-weight: {{ r.subFw }}; color: {{ r.subColor }}"><sc-for list="{{ r.subjectParts }}" as="p" hint-placeholder-count="1"><sc-if value="{{ p.hit }}" hint-placeholder-val="{{ false }}"><mark>{{ p.t }}</mark></sc-if><sc-if value="{{ p.plain }}" hint-placeholder-val="{{ true }}"><span>{{ p.t }}</span></sc-if></sc-for></span>
|
||||
<sc-if value="{{ r.att }}" hint-placeholder-val="{{ false }}"><svg aria-hidden="true" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex: 0 0 auto"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path></svg></sc-if>
|
||||
<sc-if value="{{ r.flag }}" hint-placeholder-val="{{ false }}"><svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="#B7791F" stroke="#B7791F" stroke-width="1.6" stroke-linejoin="round" style="flex: 0 0 auto"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg></sc-if>
|
||||
</span>
|
||||
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13.5px; color: #667085"><sc-for list="{{ r.previewParts }}" as="p" hint-placeholder-count="1"><sc-if value="{{ p.hit }}" hint-placeholder-val="{{ false }}"><mark style="color: {{ p.fg }}">{{ p.t }}</mark></sc-if><sc-if value="{{ p.plain }}" hint-placeholder-val="{{ true }}"><span>{{ p.t }}</span></sc-if></sc-for></span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</sc-for>
|
||||
</ul>
|
||||
<nav aria-label="앱 메뉴" style="flex: 0 0 auto; display: flex; height: 72px; box-sizing: border-box; padding: 8px 0 10px; border-top: 1px solid #EEF0F3; background: #FFFFFF">
|
||||
<a href="#" aria-current="page" style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 0; border: 0; background: transparent; color: #0A5F67; font: inherit; font-size: 12.5px; font-weight: 700; text-decoration: none; cursor: pointer"><span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 56px; height: 32px; border-radius: 16px; background: #E3F2F3"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="20" height="16" rx="2"></rect><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"></path></svg></span><span>메일</span></a>
|
||||
<a href="App1Shell.dc.html" style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 0; border: 0; background: transparent; color: #4A5363; font: inherit; font-size: 12.5px; font-weight: 500; text-decoration: none; cursor: pointer"><span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 56px; height: 32px; border-radius: 16px; background: transparent"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg></span><span>설정</span></a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="flex: 0 0 390px; display: flex; flex-direction: column; gap: 12px">
|
||||
<h2 style="margin: 0; height: 20px; font-size: 13px; line-height: 20px; font-weight: 700; color: #344054">한 글자로 찾음</h2>
|
||||
<div style="position: relative; width: 390px; height: 844px; border-radius: 28px; overflow: hidden; background: #FFFFFF; box-shadow: 0 0 0 1px #D0D5DD, 0 12px 32px rgba(16, 24, 28, 0.12); display: flex; flex-direction: column">
|
||||
<header style="flex: 0 0 auto; display: flex; align-items: center; gap: 4px; height: 64px; box-sizing: border-box; padding: 0 4px; border-bottom: 1px solid #EEF0F3; background: #FFFFFF">
|
||||
<a href="App1MailList.dc.html" aria-label="검색 닫기" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #344054; cursor: pointer"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg></a>
|
||||
<input type="text" enterkeyhint="search" autocomplete="off" maxlength="200" aria-label="메일 검색" placeholder="메일 검색" value="김" style="flex: 1 1 auto; min-width: 0; height: 48px; box-sizing: border-box; padding: 0 4px; border: 0; outline: none; background: transparent; font: inherit; font-size: 16px; color: #171C26; caret-color: #0E7C86">
|
||||
<button type="button" aria-label="검색어 지우기" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #344054; cursor: pointer"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
|
||||
</header>
|
||||
<div style="flex: 0 0 auto; border-bottom: 1px solid #EEF0F3; background: #FBFBFC">
|
||||
<div style="display: flex; align-items: center; gap: 12px; height: 56px; box-sizing: border-box; padding: 0 16px">
|
||||
<span id="s6-scope" style="flex: 0 0 auto; font-size: 13px; font-weight: 700; color: #475467">찾을 곳</span>
|
||||
<div role="group" aria-labelledby="s6-scope" style="flex: 1 1 auto; min-width: 0; display: flex">
|
||||
<button type="button" aria-pressed="true" style="flex: 1 1 0; min-width: 0; height: 48px; display: flex; align-items: center; padding: 0; border: 0; background: transparent; font: inherit; cursor: pointer"><span style="flex: 1 1 auto; min-width: 0; display: block; height: 40px; box-sizing: border-box; padding: 0 12px; border: 1px solid #D0D5DD; border-radius: 20px 0 0 20px; background: #E3F2F3; color: #0A5F67; font-size: 14px; font-weight: 700; line-height: 38px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">받은편지함</span></button>
|
||||
<button type="button" aria-pressed="false" style="flex: 1 1 0; min-width: 0; height: 48px; display: flex; align-items: center; padding: 0; border: 0; background: transparent; font: inherit; cursor: pointer"><span style="flex: 1 1 auto; min-width: 0; display: block; height: 40px; box-sizing: border-box; padding: 0 12px; border: 1px solid #D0D5DD; border-left: 0; border-radius: 0 20px 20px 0; background: #FFFFFF; color: #344054; font-size: 14px; font-weight: 500; line-height: 38px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">전체 메일함</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p role="status" style="flex: 0 0 auto; margin: 0; height: 36px; box-sizing: border-box; padding: 0 16px; font-size: 13px; line-height: 36px; color: #667085; font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis">‘김’ · 2통</p>
|
||||
<div role="status" style="flex: 0 0 auto; display: flex; align-items: flex-start; gap: 8px; padding: 10px 16px; border-bottom: 1px solid #E3EEEF; background: #F3F8F8; color: #344054">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#0E7C86" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto; margin-top: 2px"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>
|
||||
<p style="flex: 1 1 auto; min-width: 0; margin: 0; font-size: 13px; line-height: 1.55; word-break: keep-all">한 글자로만 찾을 때는 본문은 빼고 제목, 보낸 사람과 받는 사람, 첨부 파일 이름에서 찾습니다. 본문까지 찾으려면 두 글자 이상으로 찾아 주세요.</p>
|
||||
</div>
|
||||
<ul aria-label="검색 결과" style="flex: 1 1 auto; min-height: 0; overflow-y: auto; list-style: none; margin: 0; padding: 0 0 24px">
|
||||
<sc-for list="{{ rows6 }}" as="r" hint-placeholder-count="2">
|
||||
<li style="border-bottom: 1px solid #F2F4F7; background: #FFFFFF">
|
||||
<a href="App1SearchStates.dc.html" aria-label="{{ r.label }}" style="display: flex; align-items: flex-start; gap: 6px; padding: 10px 16px 10px 8px; color: inherit; text-decoration: none">
|
||||
<span aria-hidden="true" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center"><span style="display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; background: #E3F2F3; color: #0A5F67; font-size: 16px; font-weight: 600">{{ r.initial }}</span></span>
|
||||
<span style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 1px; padding-top: 1px">
|
||||
<span style="display: flex; align-items: baseline; gap: 8px">
|
||||
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; font-weight: {{ r.fw }}; color: #171C26">{{ r.who }}</span>
|
||||
<span style="flex: 0 0 auto; display: flex; align-items: baseline">
|
||||
<sc-if value="{{ r.hasBox }}" hint-placeholder-val="{{ false }}"><span style="flex: 0 1 auto; min-width: 0; max-width: 124px; box-sizing: border-box; padding-right: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; color: #667085">{{ r.boxLabel }}</span></sc-if>
|
||||
<span style="flex: 0 0 auto; font-size: 12.5px; font-weight: {{ r.timeFw }}; color: {{ r.timeColor }}; font-variant-numeric: tabular-nums; white-space: nowrap">{{ r.time }}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span style="display: flex; align-items: center; gap: 6px; min-width: 0">
|
||||
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14.5px; font-weight: {{ r.subFw }}; color: {{ r.subColor }}"><sc-for list="{{ r.subjectParts }}" as="p" hint-placeholder-count="1"><sc-if value="{{ p.hit }}" hint-placeholder-val="{{ false }}"><mark>{{ p.t }}</mark></sc-if><sc-if value="{{ p.plain }}" hint-placeholder-val="{{ true }}"><span>{{ p.t }}</span></sc-if></sc-for></span>
|
||||
<sc-if value="{{ r.att }}" hint-placeholder-val="{{ false }}"><svg aria-hidden="true" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex: 0 0 auto"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path></svg></sc-if>
|
||||
<sc-if value="{{ r.flag }}" hint-placeholder-val="{{ false }}"><svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="#B7791F" stroke="#B7791F" stroke-width="1.6" stroke-linejoin="round" style="flex: 0 0 auto"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg></sc-if>
|
||||
</span>
|
||||
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13.5px; color: #667085"><sc-for list="{{ r.previewParts }}" as="p" hint-placeholder-count="1"><sc-if value="{{ p.hit }}" hint-placeholder-val="{{ false }}"><mark style="color: {{ p.fg }}">{{ p.t }}</mark></sc-if><sc-if value="{{ p.plain }}" hint-placeholder-val="{{ true }}"><span>{{ p.t }}</span></sc-if></sc-for></span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</sc-for>
|
||||
</ul>
|
||||
<nav aria-label="앱 메뉴" style="flex: 0 0 auto; display: flex; height: 72px; box-sizing: border-box; padding: 8px 0 10px; border-top: 1px solid #EEF0F3; background: #FFFFFF">
|
||||
<a href="#" aria-current="page" style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 0; border: 0; background: transparent; color: #0A5F67; font: inherit; font-size: 12.5px; font-weight: 700; text-decoration: none; cursor: pointer"><span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 56px; height: 32px; border-radius: 16px; background: #E3F2F3"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="20" height="16" rx="2"></rect><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"></path></svg></span><span>메일</span></a>
|
||||
<a href="App1Shell.dc.html" style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 0; border: 0; background: transparent; color: #4A5363; font: inherit; font-size: 12.5px; font-weight: 500; text-decoration: none; cursor: pointer"><span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 56px; height: 32px; border-radius: 16px; background: transparent"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg></span><span>설정</span></a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="flex: 0 0 390px; display: flex; flex-direction: column; gap: 12px">
|
||||
<h2 style="margin: 0; height: 20px; font-size: 13px; line-height: 20px; font-weight: 700; color: #344054">{{ b7Title }}</h2>
|
||||
<div style="position: relative; width: 390px; height: 844px; border-radius: 28px; overflow: hidden; background: #FFFFFF; box-shadow: 0 0 0 1px #D0D5DD, 0 12px 32px rgba(16, 24, 28, 0.12); display: flex; flex-direction: column">
|
||||
<header style="flex: 0 0 auto; display: flex; align-items: center; gap: 4px; height: 64px; box-sizing: border-box; padding: 0 4px; border-bottom: 1px solid #EEF0F3; background: #FFFFFF">
|
||||
<a href="App1MailList.dc.html" aria-label="검색 닫기" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #344054; cursor: pointer"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg></a>
|
||||
<input type="text" enterkeyhint="search" autocomplete="off" maxlength="200" aria-label="메일 검색" placeholder="메일 검색" value="해지 통보" style="flex: 1 1 auto; min-width: 0; height: 48px; box-sizing: border-box; padding: 0 4px; border: 0; outline: none; background: transparent; font: inherit; font-size: 16px; color: #171C26; caret-color: #0E7C86">
|
||||
<button type="button" aria-label="검색어 지우기" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #344054; cursor: pointer"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
|
||||
</header>
|
||||
<div style="flex: 0 0 auto; border-bottom: 1px solid #EEF0F3; background: #FBFBFC">
|
||||
<div style="display: flex; align-items: center; gap: 12px; height: 56px; box-sizing: border-box; padding: 0 16px">
|
||||
<span id="s7-scope" style="flex: 0 0 auto; font-size: 13px; font-weight: 700; color: #475467">찾을 곳</span>
|
||||
<div role="group" aria-labelledby="s7-scope" style="flex: 1 1 auto; min-width: 0; display: flex">
|
||||
<button type="button" aria-pressed="true" style="flex: 1 1 0; min-width: 0; height: 48px; display: flex; align-items: center; padding: 0; border: 0; background: transparent; font: inherit; cursor: pointer"><span style="flex: 1 1 auto; min-width: 0; display: block; height: 40px; box-sizing: border-box; padding: 0 12px; border: 1px solid #D0D5DD; border-radius: 20px 0 0 20px; background: #E3F2F3; color: #0A5F67; font-size: 14px; font-weight: 700; line-height: 38px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">받은편지함</span></button>
|
||||
<button type="button" aria-pressed="false" style="flex: 1 1 0; min-width: 0; height: 48px; display: flex; align-items: center; padding: 0; border: 0; background: transparent; font: inherit; cursor: pointer"><span style="flex: 1 1 auto; min-width: 0; display: block; height: 40px; box-sizing: border-box; padding: 0 12px; border: 1px solid #D0D5DD; border-left: 0; border-radius: 0 20px 20px 0; background: #FFFFFF; color: #344054; font-size: 14px; font-weight: 500; line-height: 38px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">전체 메일함</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p role="status" style="flex: 0 0 auto; margin: 0; height: 36px; box-sizing: border-box; padding: 0 16px; font-size: 13px; line-height: 36px; color: #667085; font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis">‘해지 통보’ · 0통</p>
|
||||
<sc-if value="{{ b7Indexing }}" hint-placeholder-val="{{ false }}">
|
||||
<div role="status" style="flex: 0 0 auto; display: flex; align-items: flex-start; gap: 8px; padding: 10px 16px; border-bottom: 1px solid #E3EEEF; background: #F3F8F8; color: #344054">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#0E7C86" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto; margin-top: 2px"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>
|
||||
<p style="flex: 1 1 auto; min-width: 0; margin: 0; font-size: 13px; line-height: 1.55; word-break: keep-all">지난 메일을 검색할 수 있게 밤사이에 준비하고 있습니다. 메일이 많으면 며칠 걸릴 수 있고, 그동안은 낱말이 통째로 같은 메일만 찾습니다.</p>
|
||||
</div>
|
||||
</sc-if>
|
||||
<div style="flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 0 40px 120px; text-align: center">
|
||||
<span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 72px; height: 72px; border-radius: 50%; background: #F2F4F7; color: #667085"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg></span>
|
||||
<h2 style="margin: 4px 0 0; font-size: 15px; line-height: 1.5; font-weight: 650; word-break: keep-all">‘해지 통보’에 맞는 메일이 없습니다</h2>
|
||||
<p style="margin: 0; font-size: 13px; line-height: 1.6; color: #667085; word-break: keep-all">다른 말로 찾아 보세요.</p>
|
||||
<sc-if value="{{ b7Indexing }}" hint-placeholder-val="{{ false }}">
|
||||
<p style="margin: 0; font-size: 13px; line-height: 1.6; color: #667085; word-break: keep-all">준비가 끝나기 전에는 ‘서버이전’의 ‘서버’처럼 붙여 쓴 말의 일부나, 제목 맨 앞의 [공지]나 Re: 같은 말머리로는 찾지 못합니다.</p>
|
||||
</sc-if>
|
||||
<button type="button" style="min-height: 48px; padding: 0 16px; border: 0; border-radius: 10px; background: transparent; color: #0E7C86; font: inherit; font-size: 15px; font-weight: 600; cursor: pointer">전체 메일함에서 찾기</button>
|
||||
</div>
|
||||
<nav aria-label="앱 메뉴" style="flex: 0 0 auto; display: flex; height: 72px; box-sizing: border-box; padding: 8px 0 10px; border-top: 1px solid #EEF0F3; background: #FFFFFF">
|
||||
<a href="#" aria-current="page" style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 0; border: 0; background: transparent; color: #0A5F67; font: inherit; font-size: 12.5px; font-weight: 700; text-decoration: none; cursor: pointer"><span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 56px; height: 32px; border-radius: 16px; background: #E3F2F3"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="20" height="16" rx="2"></rect><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"></path></svg></span><span>메일</span></a>
|
||||
<a href="App1Shell.dc.html" style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 0; border: 0; background: transparent; color: #4A5363; font: inherit; font-size: 12.5px; font-weight: 500; text-decoration: none; cursor: pointer"><span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 56px; height: 32px; border-radius: 16px; background: transparent"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg></span><span>설정</span></a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"previewMarkText":{"editor":"enum","options":["그대로 #667085","진하게 #4A5363"],"default":"진하게 #4A5363"},"$preview":{"width":3098,"height":1028}}'>
|
||||
class Component extends DCLogic {
|
||||
state = { b7: null };
|
||||
|
||||
// 계획서 8절의 예시 메일 — ⟦ ⟧ 안이 맞은 말(바탕만 노랑 #FCE7A4)
|
||||
mails() {
|
||||
return {
|
||||
doyun: { who: '이도윤', initial: '이', time: '오전 10:42', subject: 'Re: 유지보수 ⟦계약⟧ 갱신 건', preview: '말씀하신 조건으로 ⟦계약⟧서 초안을 고쳐 첨부했습니다. 기간은 1년이고, 단가는…', unread: true, att: true },
|
||||
hr: { who: '인사팀', initial: '인', time: '오전 9:05', subject: '[공지] ⟦계약⟧직 채용 일정 안내', preview: '11월 ⟦계약⟧직 채용 일정을 알려 드립니다. 서류 접수는 11월 14일(금)까지…' },
|
||||
jihoon: { who: '박지훈', initial: '박', time: '11월 4일', subject: '견적서 보내 드립니다', preview: '…지난달 ⟦계약⟧ 기간에 맞춰 단가를 다시 계산했습니다. 확인 부탁드…', att: true, flag: true },
|
||||
doyunOld: { who: '이도윤', initial: '이', time: '10월 29일', subject: '회신: 유지보수 ⟦계약⟧ 갱신 건', preview: '네, 확인했습니다. 단가와 기간을 정리해서 다음 주 초에…' },
|
||||
draft: { who: '받는 사람: 박지훈', initial: '박', time: '어제', subject: '⟦계약⟧ 조건 확인 요청', preview: '지난번 말씀하신 조건을 정리했습니다.', box: '임시보관함' },
|
||||
sent: { who: '받는 사람: 이도윤', initial: '이', time: '10월 28일', subject: '유지보수 ⟦계약⟧ 갱신 건', preview: '⟦계약⟧ 기간과 단가를 확인해 주세요.', box: '보낸편지함' },
|
||||
};
|
||||
}
|
||||
|
||||
// 검색 준비 중 — 낱말이 통째로 같은 메일만(박지훈 본문의 "지난달 계약 기간에"도 맞는다), 강조 없이 지금 미리 보기(본문 첫 부분)
|
||||
indexingMails() {
|
||||
return [
|
||||
{ who: '이도윤', initial: '이', time: '오전 10:42', subject: 'Re: 유지보수 계약 갱신 건', preview: '말씀하신 조건으로 계약서 초안을 고쳐 첨부했습니다. 기간은 1년이고, 단가는 작년과 같습니다.', unread: true, att: true },
|
||||
{ who: '박지훈', initial: '박', time: '11월 4일', subject: '견적서 보내 드립니다', preview: '안녕하세요, 박지훈입니다. 요청하신 견적서를 첨부했습니다. 지난달 계약 기간에 맞춰 단가를 다시 계산했습니다.', att: true, flag: true },
|
||||
{ who: '이도윤', initial: '이', time: '10월 29일', subject: '회신: 유지보수 계약 갱신 건', preview: '네, 확인했습니다. 단가와 기간을 정리해서 다음 주 초에 보내 드리겠습니다.' },
|
||||
];
|
||||
}
|
||||
|
||||
// 한 글자 '김' — 보낸 사람으로 맞아 제목 · 미리 보기에 강조가 없다
|
||||
singleCharMails() {
|
||||
return [
|
||||
{ who: '김민수', initial: '김', time: '10월 30일', subject: '회의실 예약 변경', preview: '목요일 회의를 3층 큰 회의실로 옮겼습니다.' },
|
||||
{ who: '김민수', initial: '김', time: '10월 21일', subject: '주간 업무 보고', preview: '이번 주 진행한 일을 정리했습니다.' },
|
||||
];
|
||||
}
|
||||
|
||||
// 글을 ⟦ ⟧ 기준으로 조각낸다 — hit 는 노랑 바탕, fg 는 강조 안의 글자색
|
||||
parts(text, fg) {
|
||||
const out = [];
|
||||
let rest = text;
|
||||
while (rest.length > 0) {
|
||||
const open = rest.indexOf('⟦');
|
||||
if (open < 0) {
|
||||
out.push({ t: rest, hit: false, plain: true, fg: fg });
|
||||
break;
|
||||
}
|
||||
if (open > 0) out.push({ t: rest.slice(0, open), hit: false, plain: true, fg: fg });
|
||||
const close = rest.indexOf('⟧', open + 1);
|
||||
const end = close < 0 ? rest.length : close;
|
||||
out.push({ t: rest.slice(open + 1, end), hit: true, plain: false, fg: fg });
|
||||
rest = close < 0 ? '' : rest.slice(close + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
plain(text) {
|
||||
return text.split('⟦').join('').split('⟧').join('');
|
||||
}
|
||||
|
||||
// 목록 줄 하나 — App1MailList 의 줄 그대로(사진은 단추가 아니다), 전체 메일함이면 시각 앞에 메일함
|
||||
row(m, markFg) {
|
||||
const unread = !!m.unread;
|
||||
const box = m.box || '';
|
||||
return {
|
||||
who: m.who,
|
||||
initial: m.initial,
|
||||
time: m.time,
|
||||
hasBox: box !== '',
|
||||
boxLabel: box !== '' ? box + ' ·' : '',
|
||||
att: !!m.att,
|
||||
flag: !!m.flag,
|
||||
fw: unread ? '650' : '450',
|
||||
subFw: unread ? '600' : '400',
|
||||
subColor: unread ? '#171C26' : '#344054',
|
||||
timeFw: unread ? '600' : '400',
|
||||
timeColor: unread ? '#171C26' : '#667085',
|
||||
subjectParts: this.parts(m.subject, 'inherit'),
|
||||
previewParts: this.parts(m.preview, markFg),
|
||||
// 낭독: 안 읽음, 보낸 사람, 메일함, 시각, 제목, 첨부 있음, 중요, 미리 보기 — 강조는 읽지 않는다
|
||||
label: [unread ? '안 읽음' : '', m.who, box, m.time, this.plain(m.subject), m.att ? '첨부 있음' : '', m.flag ? '중요' : '', this.plain(m.preview)].filter((s) => s !== '').join(', '),
|
||||
};
|
||||
}
|
||||
|
||||
renderVals() {
|
||||
// 미리 보기 강조 안 글자 — 기본은 진하게(#4A5363 = 웹 --color-text-secondary, #FCE7A4 위 6.3:1). '그대로'는 #667085(4.05:1)
|
||||
const strong = (this.props.previewMarkText ?? '진하게 #4A5363') === '진하게 #4A5363';
|
||||
const markFg = strong ? '#4A5363' : 'inherit';
|
||||
const row = (m) => this.row(m, markFg);
|
||||
const M = this.mails();
|
||||
const inbox = (m) => ({ ...m, box: '받은편지함' });
|
||||
const many = (n) => Array.from({ length: n }, (_, i) => ({ i: i }));
|
||||
const mode = this.state.b7 ?? 'normal';
|
||||
const MODES = [
|
||||
{ id: 'normal', label: '평소' },
|
||||
{ id: 'indexing', label: '준비 중일 때' },
|
||||
];
|
||||
return {
|
||||
skel: many(8),
|
||||
kb10: many(10),
|
||||
kb9: many(9),
|
||||
kb7: many(7),
|
||||
rows3: [M.doyun, M.hr, M.jihoon, M.doyunOld].map(row),
|
||||
rows4: [inbox(M.doyun), inbox(M.hr), M.draft, inbox(M.jihoon), inbox(M.doyunOld), M.sent].map(row),
|
||||
rows5: this.indexingMails().map(row),
|
||||
rows6: this.singleCharMails().map(row),
|
||||
b7Indexing: mode === 'indexing',
|
||||
b7Title: mode === 'indexing' ? '결과 없음 · 검색 준비 중일 때' : '결과 없음',
|
||||
b7Modes: MODES.map((x) => {
|
||||
const on = x.id === mode;
|
||||
return {
|
||||
label: x.label,
|
||||
pressed: on ? 'true' : 'false',
|
||||
pick: () => this.setState({ b7: x.id }),
|
||||
bg: on ? '#E3F2F3' : '#FFFFFF',
|
||||
fg: on ? '#0A5F67' : '#344054',
|
||||
border: on ? '#0E7C86' : '#D0D5DD',
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,599 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>앱 · 메일 검색 · 조건으로 찾기</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<style>
|
||||
@font-face{font-family:'Pretendard';src:url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2-variations'),url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2');font-weight:45 920;font-style:normal;font-display:swap}
|
||||
body{margin:0}
|
||||
a{color:#0E7C86}a:hover{color:#0A5F67}
|
||||
:focus-visible{outline:2px solid #0E7C86;outline-offset:2px}
|
||||
input::placeholder,textarea::placeholder{color:#8A94A3}
|
||||
mark{background:#FCE7A4;color:inherit}
|
||||
.cf{display:block;width:100%;height:52px;box-sizing:border-box;margin:0;padding:0 12px;border:1px solid #8A94A3;border-radius:8px;background:#FFFFFF;font:inherit;font-size:16px;color:#171C26;caret-color:#0E7C86}
|
||||
.cf:focus,.cf:focus-visible{outline:none;border-color:#0E7C86;box-shadow:inset 0 0 0 1px #0E7C86}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="width: 1784px; height: 1028px; box-sizing: border-box; padding: 40px; display: flex; flex-direction: column; gap: 24px; background: #EEF1F4; color: #171C26; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 15px; line-height: 1.5">
|
||||
<div style="display: flex; gap: 48px; height: 48px">
|
||||
<div role="group" aria-label="조건으로 찾기 상태 고르기" style="flex: 0 0 390px; display: flex; gap: 8px">
|
||||
<sc-for list="{{ b14Modes }}" as="s" hint-placeholder-count="2">
|
||||
<button type="button" aria-pressed="{{ s.pressed }}" onClick="{{ s.pick }}" style="flex: 1 1 0; min-width: 0; min-height: 48px; box-sizing: border-box; padding: 0 12px; border: 1px solid {{ s.border }}; border-radius: 10px; background: {{ s.bg }}; color: {{ s.fg }}; font: inherit; font-size: 14px; font-weight: 600; cursor: pointer">{{ s.label }}</button>
|
||||
</sc-for>
|
||||
</div>
|
||||
<div role="group" aria-label="날짜 고르기 상태 고르기" style="flex: 0 0 390px; display: flex; gap: 8px">
|
||||
<sc-for list="{{ b15Modes }}" as="s" hint-placeholder-count="2">
|
||||
<button type="button" aria-pressed="{{ s.pressed }}" onClick="{{ s.pick }}" style="flex: 1 1 0; min-width: 0; min-height: 48px; box-sizing: border-box; padding: 0 12px; border: 1px solid {{ s.border }}; border-radius: 10px; background: {{ s.bg }}; color: {{ s.fg }}; font: inherit; font-size: 14px; font-weight: 600; cursor: pointer">{{ s.label }}</button>
|
||||
</sc-for>
|
||||
</div>
|
||||
<div role="group" aria-label="조건이 걸린 결과 상태 고르기" style="flex: 0 0 390px; display: flex; gap: 8px">
|
||||
<sc-for list="{{ b16Modes }}" as="s" hint-placeholder-count="2">
|
||||
<button type="button" aria-pressed="{{ s.pressed }}" onClick="{{ s.pick }}" style="flex: 1 1 0; min-width: 0; min-height: 48px; box-sizing: border-box; padding: 0 12px; border: 1px solid {{ s.border }}; border-radius: 10px; background: {{ s.bg }}; color: {{ s.fg }}; font: inherit; font-size: 14px; font-weight: 600; cursor: pointer">{{ s.label }}</button>
|
||||
</sc-for>
|
||||
</div>
|
||||
<div role="group" aria-label="조건에 맞는 메일이 없음 상태 고르기" style="flex: 0 0 390px; display: flex; gap: 8px">
|
||||
<sc-for list="{{ b17Modes }}" as="s" hint-placeholder-count="2">
|
||||
<button type="button" aria-pressed="{{ s.pressed }}" onClick="{{ s.pick }}" style="flex: 1 1 0; min-width: 0; min-height: 48px; box-sizing: border-box; padding: 0 12px; border: 1px solid {{ s.border }}; border-radius: 10px; background: {{ s.bg }}; color: {{ s.fg }}; font: inherit; font-size: 14px; font-weight: 600; cursor: pointer">{{ s.label }}</button>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 48px; align-items: flex-start">
|
||||
|
||||
<div style="flex: 0 0 390px; display: flex; flex-direction: column; gap: 12px">
|
||||
<h2 style="margin: 0; height: 20px; font-size: 13px; line-height: 20px; font-weight: 700; color: #344054">{{ b14Title }}</h2>
|
||||
<div style="position: relative; width: 390px; height: 844px; border-radius: 28px; overflow: hidden; background: #FFFFFF; box-shadow: 0 0 0 1px #D0D5DD, 0 12px 32px rgba(16, 24, 28, 0.12); display: flex; flex-direction: column">
|
||||
<header style="flex: 0 0 auto; display: flex; align-items: center; gap: 4px; height: 64px; box-sizing: border-box; padding: 0 4px; border-bottom: 1px solid #EEF0F3; background: #FFFFFF">
|
||||
<a href="App1MailList.dc.html" aria-label="검색 닫기" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #344054; cursor: pointer"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg></a>
|
||||
<input type="text" enterkeyhint="search" autocomplete="off" maxlength="200" aria-label="메일 검색" placeholder="메일 검색" value="{{ q14 }}" style="flex: 1 1 auto; min-width: 0; height: 48px; box-sizing: border-box; padding: 0 4px; border: 0; outline: none; background: transparent; font: inherit; font-size: 16px; color: #171C26; caret-color: #0E7C86">
|
||||
<button type="button" aria-label="검색어 지우기" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #344054; cursor: pointer"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
|
||||
<button type="button" aria-label="조건으로 찾기" aria-haspopup="dialog" aria-expanded="true" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #0A5F67; cursor: pointer"><span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; background: #E3F2F3"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="21" y1="4" x2="14" y2="4"></line><line x1="10" y1="4" x2="3" y2="4"></line><line x1="21" y1="12" x2="12" y2="12"></line><line x1="8" y1="12" x2="3" y2="12"></line><line x1="21" y1="20" x2="16" y2="20"></line><line x1="12" y1="20" x2="3" y2="20"></line><line x1="14" y1="1" x2="14" y2="7"></line><line x1="8" y1="9" x2="8" y2="15"></line><line x1="16" y1="17" x2="16" y2="23"></line></svg></span></button>
|
||||
</header>
|
||||
<div style="flex: 0 0 auto; border-bottom: 1px solid #EEF0F3; background: #FBFBFC">
|
||||
<div style="display: flex; align-items: center; gap: 12px; height: 56px; box-sizing: border-box; padding: 0 16px">
|
||||
<span id="s14-scope" style="flex: 0 0 auto; font-size: 13px; font-weight: 700; color: #475467">찾을 곳</span>
|
||||
<div role="group" aria-labelledby="s14-scope" style="flex: 1 1 auto; min-width: 0; display: flex">
|
||||
<button type="button" aria-pressed="true" style="flex: 1 1 0; min-width: 0; height: 48px; display: flex; align-items: center; padding: 0; border: 0; background: transparent; font: inherit; cursor: pointer"><span style="flex: 1 1 auto; min-width: 0; display: block; height: 40px; box-sizing: border-box; padding: 0 12px; border: 1px solid #D0D5DD; border-radius: 20px 0 0 20px; background: #E3F2F3; color: #0A5F67; font-size: 14px; font-weight: 700; line-height: 38px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">받은편지함</span></button>
|
||||
<button type="button" aria-pressed="false" style="flex: 1 1 0; min-width: 0; height: 48px; display: flex; align-items: center; padding: 0; border: 0; background: transparent; font: inherit; cursor: pointer"><span style="flex: 1 1 auto; min-width: 0; display: block; height: 40px; box-sizing: border-box; padding: 0 12px; border: 1px solid #D0D5DD; border-left: 0; border-radius: 0 20px 20px 0; background: #FFFFFF; color: #344054; font-size: 14px; font-weight: 500; line-height: 38px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">전체 메일함</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p role="status" style="flex: 0 0 auto; margin: 0; height: 36px; box-sizing: border-box; padding: 0 16px; font-size: 13px; line-height: 36px; color: #667085; font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis">‘계약’ · 4통</p>
|
||||
<ul aria-label="검색 결과" style="flex: 1 1 auto; min-height: 0; overflow-y: auto; list-style: none; margin: 0; padding: 0 0 24px">
|
||||
<sc-for list="{{ rowsBg }}" as="r" hint-placeholder-count="4">
|
||||
<li style="border-bottom: 1px solid #F2F4F7; background: #FFFFFF">
|
||||
<a href="App1SearchStates.dc.html" aria-label="{{ r.label }}" style="display: flex; align-items: flex-start; gap: 6px; padding: 10px 16px 10px 8px; color: inherit; text-decoration: none">
|
||||
<span aria-hidden="true" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center"><span style="display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; background: #E3F2F3; color: #0A5F67; font-size: 16px; font-weight: 600">{{ r.initial }}</span></span>
|
||||
<span style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 1px; padding-top: 1px">
|
||||
<span style="display: flex; align-items: baseline; gap: 8px">
|
||||
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; font-weight: {{ r.fw }}; color: #171C26">{{ r.who }}</span>
|
||||
<span style="flex: 0 0 auto; display: flex; align-items: baseline">
|
||||
<sc-if value="{{ r.hasBox }}" hint-placeholder-val="{{ false }}"><span style="flex: 0 1 auto; min-width: 0; max-width: 124px; box-sizing: border-box; padding-right: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; color: #667085">{{ r.boxLabel }}</span></sc-if>
|
||||
<span style="flex: 0 0 auto; font-size: 12.5px; font-weight: {{ r.timeFw }}; color: {{ r.timeColor }}; font-variant-numeric: tabular-nums; white-space: nowrap">{{ r.time }}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span style="display: flex; align-items: center; gap: 6px; min-width: 0">
|
||||
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14.5px; font-weight: {{ r.subFw }}; color: {{ r.subColor }}"><sc-for list="{{ r.subjectParts }}" as="p" hint-placeholder-count="1"><sc-if value="{{ p.hit }}" hint-placeholder-val="{{ false }}"><mark>{{ p.t }}</mark></sc-if><sc-if value="{{ p.plain }}" hint-placeholder-val="{{ true }}"><span>{{ p.t }}</span></sc-if></sc-for></span>
|
||||
<sc-if value="{{ r.att }}" hint-placeholder-val="{{ false }}"><svg aria-hidden="true" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex: 0 0 auto"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path></svg></sc-if>
|
||||
<sc-if value="{{ r.flag }}" hint-placeholder-val="{{ false }}"><svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="#B7791F" stroke="#B7791F" stroke-width="1.6" stroke-linejoin="round" style="flex: 0 0 auto"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg></sc-if>
|
||||
</span>
|
||||
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13.5px; color: #667085"><sc-for list="{{ r.previewParts }}" as="p" hint-placeholder-count="1"><sc-if value="{{ p.hit }}" hint-placeholder-val="{{ false }}"><mark style="color: {{ p.fg }}">{{ p.t }}</mark></sc-if><sc-if value="{{ p.plain }}" hint-placeholder-val="{{ true }}"><span>{{ p.t }}</span></sc-if></sc-for></span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</sc-for>
|
||||
</ul>
|
||||
<nav aria-label="앱 메뉴" style="flex: 0 0 auto; display: flex; height: 72px; box-sizing: border-box; padding: 8px 0 10px; border-top: 1px solid #EEF0F3; background: #FFFFFF">
|
||||
<a href="#" aria-current="page" style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 0; border: 0; background: transparent; color: #0A5F67; font: inherit; font-size: 12.5px; font-weight: 700; text-decoration: none; cursor: pointer"><span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 56px; height: 32px; border-radius: 16px; background: #E3F2F3"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="20" height="16" rx="2"></rect><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"></path></svg></span><span>메일</span></a>
|
||||
<a href="App1Shell.dc.html" style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 0; border: 0; background: transparent; color: #4A5363; font: inherit; font-size: 12.5px; font-weight: 500; text-decoration: none; cursor: pointer"><span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 56px; height: 32px; border-radius: 16px; background: transparent"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg></span><span>설정</span></a>
|
||||
</nav>
|
||||
<div aria-hidden="true" style="position: absolute; inset: 0; background: rgba(11, 54, 64, 0.32)"></div>
|
||||
<section role="dialog" aria-modal="true" aria-labelledby="s14-sheet-title" style="position: absolute; left: 0; right: 0; top: 96px; bottom: 0; display: flex; flex-direction: column; border-radius: 16px 16px 0 0; background: #FFFFFF; overflow: hidden">
|
||||
<div aria-hidden="true" style="flex: 0 0 auto; display: flex; align-items: center; justify-content: center; height: 48px"><span style="display: block; width: 32px; height: 4px; border-radius: 2px; background: #4A5363"></span></div>
|
||||
<div style="flex: 0 0 auto; display: flex; align-items: center; gap: 8px; height: 48px; box-sizing: border-box; padding: 0 4px 0 20px">
|
||||
<h2 id="s14-sheet-title" style="flex: 1 1 auto; min-width: 0; margin: 0; font-size: 18px; line-height: 1.4; font-weight: 700; letter-spacing: -0.01em; color: #171C26">조건으로 찾기</h2>
|
||||
<button type="button" aria-label="닫기" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #344054; cursor: pointer"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
|
||||
</div>
|
||||
<div style="flex: 1 1 auto; min-height: 0; overflow-y: auto">
|
||||
<div style="display: flex; flex-direction: column; gap: 16px; padding: 8px 20px 32px">
|
||||
<div style="display: flex; flex-direction: column; gap: 8px">
|
||||
<label for="s14-from" style="font-size: 15px; line-height: 22px; font-weight: 700; color: #344054">보낸 사람</label>
|
||||
<input id="s14-from" class="cf" type="text" autocomplete="off" placeholder="이름이나 메일 주소" value="이도윤">
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 8px">
|
||||
<label for="s14-to" style="font-size: 15px; line-height: 22px; font-weight: 700; color: #344054">받는 사람</label>
|
||||
<input id="s14-to" class="cf" type="text" autocomplete="off" placeholder="이름이나 메일 주소">
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 8px">
|
||||
<label for="s14-subject" style="font-size: 15px; line-height: 22px; font-weight: 700; color: #344054">제목</label>
|
||||
<input id="s14-subject" class="cf" type="text" autocomplete="off" placeholder="제목에 든 말" value="{{ subj14 }}">
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 8px">
|
||||
<label for="s14-body" style="font-size: 15px; line-height: 22px; font-weight: 700; color: #344054">내용</label>
|
||||
<input id="s14-body" class="cf" type="text" autocomplete="off" placeholder="내용에 든 말">
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 8px">
|
||||
<label for="s14-period" style="font-size: 15px; line-height: 22px; font-weight: 700; color: #344054">기간</label>
|
||||
<span style="position: relative; display: block">
|
||||
<select id="s14-period" class="cf" value="{{ period14 }}" onChange="{{ setPeriod14 }}" style="padding-right: 44px; appearance: none; -webkit-appearance: none; cursor: pointer">
|
||||
<option value="all">전체 기간</option>
|
||||
<option value="week">최근 1주</option>
|
||||
<option value="month">최근 1달</option>
|
||||
<option value="quarter">최근 3달</option>
|
||||
<option value="year">최근 1년</option>
|
||||
<option value="custom">날짜 고르기</option>
|
||||
</select>
|
||||
<svg aria-hidden="true" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#4A5363" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="position: absolute; right: 14px; top: 16px; pointer-events: none"><polyline points="6 9 12 15 18 9"></polyline></svg>
|
||||
</span>
|
||||
<sc-if value="{{ custom14 }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="display: flex; gap: 12px">
|
||||
<div style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; gap: 6px">
|
||||
<span id="s14-start-label" style="font-size: 13px; line-height: 20px; font-weight: 700; color: #475467">시작 날짜</span>
|
||||
<button type="button" aria-labelledby="s14-start-label" style="display: flex; align-items: center; justify-content: flex-end; height: 52px; box-sizing: border-box; padding: 0 14px; border: 1px solid #8A94A3; border-radius: 8px; background: #FFFFFF; color: #4A5363; font: inherit; cursor: pointer"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg></button>
|
||||
</div>
|
||||
<div style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; gap: 6px">
|
||||
<span id="s14-end-label" style="font-size: 13px; line-height: 20px; font-weight: 700; color: #475467">끝 날짜</span>
|
||||
<button type="button" aria-labelledby="s14-end-label" style="display: flex; align-items: center; justify-content: flex-end; height: 52px; box-sizing: border-box; padding: 0 14px; border: 1px solid #8A94A3; border-radius: 8px; background: #FFFFFF; color: #4A5363; font: inherit; cursor: pointer"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
<div role="group" aria-labelledby="s14-att" style="display: flex; flex-direction: column">
|
||||
<span id="s14-att" style="font-size: 15px; line-height: 22px; font-weight: 700; color: #344054">첨부</span>
|
||||
<label style="display: flex; align-items: center; gap: 12px; min-height: 48px; margin: 0 -20px; padding: 0 20px; cursor: pointer">
|
||||
<input type="checkbox" checked="{{ att14 }}" onChange="{{ toggleAtt14 }}" style="flex: 0 0 auto; width: 18px; height: 18px; margin: 0; accent-color: #0E7C86; cursor: pointer">
|
||||
<span style="font-size: 15px; color: #171C26">첨부 파일이 있는 메일만</span>
|
||||
</label>
|
||||
</div>
|
||||
<p style="margin: 0; font-size: 13px; line-height: 1.6; color: #667085; word-break: keep-all">검색 칸에 쓴 말이 들어 있고 이 조건에도 맞는 메일을 찾습니다. 받는 사람으로 찾을 때는 참조로 받은 사람까지 찾습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex: 0 0 auto; border-top: 1px solid #EEF0F3; background: #FFFFFF">
|
||||
<sc-if value="{{ tooMany14 }}" hint-placeholder-val="{{ false }}">
|
||||
<p role="alert" style="display: flex; align-items: flex-start; gap: 8px; margin: 0; padding: 12px 20px 0; font-size: 13px; line-height: 1.55; color: #B42318; word-break: keep-all"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto; margin-top: 2px"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg><span style="flex: 1 1 auto; min-width: 0">검색 칸과 [조건]에 넣은 낱말은 모두 합쳐 8개까지입니다. 낱말을 몇 개 줄여 주세요.</span></p>
|
||||
</sc-if>
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 20px 12px 8px">
|
||||
<button type="button" style="flex: 0 0 auto; min-height: 48px; padding: 0 12px; border: 0; border-radius: 8px; background: transparent; color: #0A5F67; font: inherit; font-size: 15px; font-weight: 600; cursor: pointer">조건 지우기</button>
|
||||
<button type="button" style="flex: 0 0 auto; min-width: 120px; min-height: 48px; padding: 0 24px; border: 0; border-radius: 8px; background: #0E7C86; color: #FFFFFF; font: inherit; font-size: 15px; font-weight: 600; cursor: pointer">찾기</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="flex: 0 0 390px; display: flex; flex-direction: column; gap: 12px">
|
||||
<h2 style="margin: 0; height: 20px; font-size: 13px; line-height: 20px; font-weight: 700; color: #344054">{{ b15Title }}</h2>
|
||||
<div style="position: relative; width: 390px; height: 844px; border-radius: 28px; overflow: hidden; background: #FFFFFF; box-shadow: 0 0 0 1px #D0D5DD, 0 12px 32px rgba(16, 24, 28, 0.12); display: flex; flex-direction: column">
|
||||
<header style="flex: 0 0 auto; display: flex; align-items: center; gap: 4px; height: 64px; box-sizing: border-box; padding: 0 4px; border-bottom: 1px solid #EEF0F3; background: #FFFFFF">
|
||||
<a href="App1MailList.dc.html" aria-label="검색 닫기" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #344054; cursor: pointer"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg></a>
|
||||
<input type="text" enterkeyhint="search" autocomplete="off" maxlength="200" aria-label="메일 검색" placeholder="메일 검색" value="계약" style="flex: 1 1 auto; min-width: 0; height: 48px; box-sizing: border-box; padding: 0 4px; border: 0; outline: none; background: transparent; font: inherit; font-size: 16px; color: #171C26; caret-color: #0E7C86">
|
||||
<button type="button" aria-label="검색어 지우기" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #344054; cursor: pointer"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
|
||||
<button type="button" aria-label="조건으로 찾기" aria-haspopup="dialog" aria-expanded="true" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #0A5F67; cursor: pointer"><span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; background: #E3F2F3"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="21" y1="4" x2="14" y2="4"></line><line x1="10" y1="4" x2="3" y2="4"></line><line x1="21" y1="12" x2="12" y2="12"></line><line x1="8" y1="12" x2="3" y2="12"></line><line x1="21" y1="20" x2="16" y2="20"></line><line x1="12" y1="20" x2="3" y2="20"></line><line x1="14" y1="1" x2="14" y2="7"></line><line x1="8" y1="9" x2="8" y2="15"></line><line x1="16" y1="17" x2="16" y2="23"></line></svg></span></button>
|
||||
</header>
|
||||
<div style="flex: 0 0 auto; border-bottom: 1px solid #EEF0F3; background: #FBFBFC">
|
||||
<div style="display: flex; align-items: center; gap: 12px; height: 56px; box-sizing: border-box; padding: 0 16px">
|
||||
<span id="s15-scope" style="flex: 0 0 auto; font-size: 13px; font-weight: 700; color: #475467">찾을 곳</span>
|
||||
<div role="group" aria-labelledby="s15-scope" style="flex: 1 1 auto; min-width: 0; display: flex">
|
||||
<button type="button" aria-pressed="true" style="flex: 1 1 0; min-width: 0; height: 48px; display: flex; align-items: center; padding: 0; border: 0; background: transparent; font: inherit; cursor: pointer"><span style="flex: 1 1 auto; min-width: 0; display: block; height: 40px; box-sizing: border-box; padding: 0 12px; border: 1px solid #D0D5DD; border-radius: 20px 0 0 20px; background: #E3F2F3; color: #0A5F67; font-size: 14px; font-weight: 700; line-height: 38px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">받은편지함</span></button>
|
||||
<button type="button" aria-pressed="false" style="flex: 1 1 0; min-width: 0; height: 48px; display: flex; align-items: center; padding: 0; border: 0; background: transparent; font: inherit; cursor: pointer"><span style="flex: 1 1 auto; min-width: 0; display: block; height: 40px; box-sizing: border-box; padding: 0 12px; border: 1px solid #D0D5DD; border-left: 0; border-radius: 0 20px 20px 0; background: #FFFFFF; color: #344054; font-size: 14px; font-weight: 500; line-height: 38px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">전체 메일함</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p role="status" style="flex: 0 0 auto; margin: 0; height: 36px; box-sizing: border-box; padding: 0 16px; font-size: 13px; line-height: 36px; color: #667085; font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis">‘계약’ · 4통</p>
|
||||
<ul aria-label="검색 결과" style="flex: 1 1 auto; min-height: 0; overflow-y: auto; list-style: none; margin: 0; padding: 0 0 24px">
|
||||
<sc-for list="{{ rowsBg }}" as="r" hint-placeholder-count="4">
|
||||
<li style="border-bottom: 1px solid #F2F4F7; background: #FFFFFF">
|
||||
<a href="App1SearchStates.dc.html" aria-label="{{ r.label }}" style="display: flex; align-items: flex-start; gap: 6px; padding: 10px 16px 10px 8px; color: inherit; text-decoration: none">
|
||||
<span aria-hidden="true" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center"><span style="display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; background: #E3F2F3; color: #0A5F67; font-size: 16px; font-weight: 600">{{ r.initial }}</span></span>
|
||||
<span style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 1px; padding-top: 1px">
|
||||
<span style="display: flex; align-items: baseline; gap: 8px">
|
||||
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; font-weight: {{ r.fw }}; color: #171C26">{{ r.who }}</span>
|
||||
<span style="flex: 0 0 auto; display: flex; align-items: baseline">
|
||||
<sc-if value="{{ r.hasBox }}" hint-placeholder-val="{{ false }}"><span style="flex: 0 1 auto; min-width: 0; max-width: 124px; box-sizing: border-box; padding-right: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; color: #667085">{{ r.boxLabel }}</span></sc-if>
|
||||
<span style="flex: 0 0 auto; font-size: 12.5px; font-weight: {{ r.timeFw }}; color: {{ r.timeColor }}; font-variant-numeric: tabular-nums; white-space: nowrap">{{ r.time }}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span style="display: flex; align-items: center; gap: 6px; min-width: 0">
|
||||
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14.5px; font-weight: {{ r.subFw }}; color: {{ r.subColor }}"><sc-for list="{{ r.subjectParts }}" as="p" hint-placeholder-count="1"><sc-if value="{{ p.hit }}" hint-placeholder-val="{{ false }}"><mark>{{ p.t }}</mark></sc-if><sc-if value="{{ p.plain }}" hint-placeholder-val="{{ true }}"><span>{{ p.t }}</span></sc-if></sc-for></span>
|
||||
<sc-if value="{{ r.att }}" hint-placeholder-val="{{ false }}"><svg aria-hidden="true" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex: 0 0 auto"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path></svg></sc-if>
|
||||
<sc-if value="{{ r.flag }}" hint-placeholder-val="{{ false }}"><svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="#B7791F" stroke="#B7791F" stroke-width="1.6" stroke-linejoin="round" style="flex: 0 0 auto"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg></sc-if>
|
||||
</span>
|
||||
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13.5px; color: #667085"><sc-for list="{{ r.previewParts }}" as="p" hint-placeholder-count="1"><sc-if value="{{ p.hit }}" hint-placeholder-val="{{ false }}"><mark style="color: {{ p.fg }}">{{ p.t }}</mark></sc-if><sc-if value="{{ p.plain }}" hint-placeholder-val="{{ true }}"><span>{{ p.t }}</span></sc-if></sc-for></span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</sc-for>
|
||||
</ul>
|
||||
<nav aria-label="앱 메뉴" style="flex: 0 0 auto; display: flex; height: 72px; box-sizing: border-box; padding: 8px 0 10px; border-top: 1px solid #EEF0F3; background: #FFFFFF">
|
||||
<a href="#" aria-current="page" style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 0; border: 0; background: transparent; color: #0A5F67; font: inherit; font-size: 12.5px; font-weight: 700; text-decoration: none; cursor: pointer"><span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 56px; height: 32px; border-radius: 16px; background: #E3F2F3"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="20" height="16" rx="2"></rect><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"></path></svg></span><span>메일</span></a>
|
||||
<a href="App1Shell.dc.html" style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 0; border: 0; background: transparent; color: #4A5363; font: inherit; font-size: 12.5px; font-weight: 500; text-decoration: none; cursor: pointer"><span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 56px; height: 32px; border-radius: 16px; background: transparent"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg></span><span>설정</span></a>
|
||||
</nav>
|
||||
<div aria-hidden="true" style="position: absolute; inset: 0; background: rgba(11, 54, 64, 0.32)"></div>
|
||||
<section role="dialog" aria-modal="true" aria-labelledby="s15-sheet-title" style="position: absolute; left: 0; right: 0; top: 96px; bottom: 0; display: flex; flex-direction: column; border-radius: 16px 16px 0 0; background: #FFFFFF; overflow: hidden">
|
||||
<div aria-hidden="true" style="flex: 0 0 auto; display: flex; align-items: center; justify-content: center; height: 48px"><span style="display: block; width: 32px; height: 4px; border-radius: 2px; background: #4A5363"></span></div>
|
||||
<div style="flex: 0 0 auto; display: flex; align-items: center; gap: 8px; height: 49px; box-sizing: border-box; padding: 0 4px 0 20px; border-bottom: 1px solid #EEF0F3">
|
||||
<h2 id="s15-sheet-title" style="flex: 1 1 auto; min-width: 0; margin: 0; font-size: 18px; line-height: 1.4; font-weight: 700; letter-spacing: -0.01em; color: #171C26">조건으로 찾기</h2>
|
||||
<button type="button" aria-label="닫기" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #344054; cursor: pointer"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
|
||||
</div>
|
||||
<div style="flex: 1 1 auto; min-height: 0; overflow-y: auto; display: flex; flex-direction: column-reverse">
|
||||
<div style="flex: 0 0 auto; display: flex; flex-direction: column; gap: 16px; padding: 8px 20px 32px">
|
||||
<div style="display: flex; flex-direction: column; gap: 8px">
|
||||
<label for="s15-from" style="font-size: 15px; line-height: 22px; font-weight: 700; color: #344054">보낸 사람</label>
|
||||
<input id="s15-from" class="cf" type="text" autocomplete="off" placeholder="이름이나 메일 주소" value="이도윤">
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 8px">
|
||||
<label for="s15-to" style="font-size: 15px; line-height: 22px; font-weight: 700; color: #344054">받는 사람</label>
|
||||
<input id="s15-to" class="cf" type="text" autocomplete="off" placeholder="이름이나 메일 주소">
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 8px">
|
||||
<label for="s15-subject" style="font-size: 15px; line-height: 22px; font-weight: 700; color: #344054">제목</label>
|
||||
<input id="s15-subject" class="cf" type="text" autocomplete="off" placeholder="제목에 든 말">
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 8px">
|
||||
<label for="s15-body" style="font-size: 15px; line-height: 22px; font-weight: 700; color: #344054">내용</label>
|
||||
<input id="s15-body" class="cf" type="text" autocomplete="off" placeholder="내용에 든 말">
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 8px">
|
||||
<label for="s15-period" style="font-size: 15px; line-height: 22px; font-weight: 700; color: #344054">기간</label>
|
||||
<span style="position: relative; display: block">
|
||||
<select id="s15-period" class="cf" value="{{ period15 }}" onChange="{{ setPeriod15 }}" style="padding-right: 44px; appearance: none; -webkit-appearance: none; cursor: pointer">
|
||||
<option value="all">전체 기간</option>
|
||||
<option value="week">최근 1주</option>
|
||||
<option value="month">최근 1달</option>
|
||||
<option value="quarter">최근 3달</option>
|
||||
<option value="year">최근 1년</option>
|
||||
<option value="custom">날짜 고르기</option>
|
||||
</select>
|
||||
<svg aria-hidden="true" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#4A5363" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="position: absolute; right: 14px; top: 16px; pointer-events: none"><polyline points="6 9 12 15 18 9"></polyline></svg>
|
||||
</span>
|
||||
<sc-if value="{{ custom15 }}" hint-placeholder-val="{{ true }}">
|
||||
<div style="display: flex; gap: 12px">
|
||||
<div style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; gap: 6px">
|
||||
<span id="s15-start-label" style="font-size: 13px; line-height: 20px; font-weight: 700; color: #475467">시작 날짜</span>
|
||||
<div style="display: flex; align-items: center; height: 52px; box-sizing: border-box; border: 1px solid #8A94A3; border-radius: 8px; background: #FFFFFF">
|
||||
<button type="button" aria-labelledby="s15-start-label s15-start-value" onClick="{{ openPicker }}" style="flex: 1 1 auto; min-width: 0; align-self: stretch; display: flex; align-items: center; padding: 0 0 0 12px; border: 0; border-radius: 7px 0 0 7px; background: transparent; color: #171C26; font: inherit; font-size: 16px; font-variant-numeric: tabular-nums; text-align: left; cursor: pointer"><span id="s15-start-value" style="min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">2026. 11. 7.</span></button>
|
||||
<button type="button" aria-label="시작 날짜 지우기" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #4A5363; cursor: pointer"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; gap: 6px">
|
||||
<span id="s15-end-label" style="font-size: 13px; line-height: 20px; font-weight: 700; color: #475467">끝 날짜</span>
|
||||
<div style="display: flex; align-items: center; height: 52px; box-sizing: border-box; border: 1px solid #B42318; border-radius: 8px; background: #FFFFFF; box-shadow: inset 0 0 0 1px #B42318">
|
||||
<button type="button" aria-labelledby="s15-end-label s15-end-value" aria-describedby="s15-date-error" style="flex: 1 1 auto; min-width: 0; align-self: stretch; display: flex; align-items: center; padding: 0 0 0 12px; border: 0; border-radius: 7px 0 0 7px; background: transparent; color: #171C26; font: inherit; font-size: 16px; font-variant-numeric: tabular-nums; text-align: left; cursor: pointer"><span id="s15-end-value" style="min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">2026. 10. 1.</span></button>
|
||||
<button type="button" aria-label="끝 날짜 지우기" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #4A5363; cursor: pointer"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p id="s15-date-error" aria-live="polite" style="margin: 0; font-size: 13px; line-height: 20px; color: #B42318; word-break: keep-all">끝 날짜를 시작 날짜와 같거나 그 뒤로 골라 주세요.</p>
|
||||
</sc-if>
|
||||
</div>
|
||||
<div role="group" aria-labelledby="s15-att" style="display: flex; flex-direction: column">
|
||||
<span id="s15-att" style="font-size: 15px; line-height: 22px; font-weight: 700; color: #344054">첨부</span>
|
||||
<label style="display: flex; align-items: center; gap: 12px; min-height: 48px; margin: 0 -20px; padding: 0 20px; cursor: pointer">
|
||||
<input type="checkbox" checked="{{ att15 }}" onChange="{{ toggleAtt15 }}" style="flex: 0 0 auto; width: 18px; height: 18px; margin: 0; accent-color: #0E7C86; cursor: pointer">
|
||||
<span style="font-size: 15px; color: #171C26">첨부 파일이 있는 메일만</span>
|
||||
</label>
|
||||
</div>
|
||||
<p style="margin: 0; font-size: 13px; line-height: 1.6; color: #667085; word-break: keep-all">검색 칸에 쓴 말이 들어 있고 이 조건에도 맞는 메일을 찾습니다. 받는 사람으로 찾을 때는 참조로 받은 사람까지 찾습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex: 0 0 auto; border-top: 1px solid #EEF0F3; background: #FFFFFF">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 20px 12px 8px">
|
||||
<button type="button" style="flex: 0 0 auto; min-height: 48px; padding: 0 12px; border: 0; border-radius: 8px; background: transparent; color: #0A5F67; font: inherit; font-size: 15px; font-weight: 600; cursor: pointer">조건 지우기</button>
|
||||
<button type="button" style="flex: 0 0 auto; min-width: 120px; min-height: 48px; padding: 0 24px; border: 0; border-radius: 8px; background: #0E7C86; color: #FFFFFF; font: inherit; font-size: 15px; font-weight: 600; cursor: pointer">찾기</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<sc-if value="{{ pickerOpen }}" hint-placeholder-val="{{ false }}">
|
||||
<div aria-hidden="true" style="position: absolute; inset: 0; background: rgba(11, 54, 64, 0.32)"></div>
|
||||
<div role="dialog" aria-modal="true" aria-labelledby="s15-picker-help s15-picker-date" style="position: absolute; left: 16px; top: 138px; width: 358px; height: 568px; box-sizing: border-box; display: flex; flex-direction: column; border-radius: 28px; background: #F2F4F7; overflow: hidden">
|
||||
<div style="flex: 0 0 auto; display: flex; flex-direction: column; height: 120px; box-sizing: border-box; padding: 16px 12px 12px 24px; color: #4A5363">
|
||||
<span id="s15-picker-help" style="font-size: 15px; line-height: 1.3; font-weight: 600">시작 날짜</span>
|
||||
<span style="flex: 1 1 auto"></span>
|
||||
<div style="display: flex; align-items: center; gap: 8px">
|
||||
<span id="s15-picker-date" style="flex: 1 1 auto; min-width: 0; font-size: 22px; line-height: 1.4; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis">11월 7일 (토)</span>
|
||||
<span aria-hidden="true" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg></span>
|
||||
</div>
|
||||
</div>
|
||||
<div aria-hidden="true" style="flex: 0 0 auto; height: 1px; background: #E6E8EC"></div>
|
||||
<div aria-hidden="true" style="flex: 1 1 auto; min-height: 0">
|
||||
<div style="display: flex; align-items: center; height: 52px; box-sizing: border-box; padding: 0 4px 0 16px; color: #344054">
|
||||
<span style="display: inline-flex; align-items: center; height: 52px; padding: 0 8px; font-size: 15px; font-weight: 600">2026년 11월<svg width="24" height="24" viewBox="0 0 24 24" aria-hidden="true"><path d="M7 10l5 5 5-5z" fill="currentColor"></path></svg></span>
|
||||
<span style="flex: 1 1 auto"></span>
|
||||
<span style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 18 9 12 15 6"></polyline></svg></span>
|
||||
<span style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; color: rgba(23, 28, 38, 0.38)"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"></polyline></svg></span>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); grid-auto-rows: 48px; padding: 0 12px">
|
||||
<sc-for list="{{ calCells }}" as="c" hint-placeholder-count="37"><span style="display: flex; align-items: center; justify-content: center"><span style="display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; background: {{ c.bg }}; color: {{ c.fg }}; font-size: 16px; font-variant-numeric: tabular-nums">{{ c.t }}</span></span></sc-for>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex: 0 0 auto; display: flex; align-items: center; justify-content: flex-end; gap: 8px; min-height: 52px; box-sizing: border-box; padding: 0 8px">
|
||||
<button type="button" onClick="{{ closePicker }}" style="min-height: 48px; padding: 0 12px; border: 0; border-radius: 8px; background: transparent; color: #0E7C86; font: inherit; font-size: 15px; font-weight: 600; cursor: pointer">취소</button>
|
||||
<button type="button" onClick="{{ closePicker }}" style="min-height: 48px; padding: 0 12px; border: 0; border-radius: 8px; background: transparent; color: #0E7C86; font: inherit; font-size: 15px; font-weight: 600; cursor: pointer">확인</button>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="flex: 0 0 390px; display: flex; flex-direction: column; gap: 12px">
|
||||
<h2 style="margin: 0; height: 20px; font-size: 13px; line-height: 20px; font-weight: 700; color: #344054">{{ b16Title }}</h2>
|
||||
<div style="position: relative; width: 390px; height: 844px; border-radius: 28px; overflow: hidden; background: #FFFFFF; box-shadow: 0 0 0 1px #D0D5DD, 0 12px 32px rgba(16, 24, 28, 0.12); display: flex; flex-direction: column">
|
||||
<header style="flex: 0 0 auto; display: flex; align-items: center; gap: 4px; height: 64px; box-sizing: border-box; padding: 0 4px; border-bottom: 1px solid #EEF0F3; background: #FFFFFF">
|
||||
<a href="App1MailList.dc.html" aria-label="검색 닫기" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #344054; cursor: pointer"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg></a>
|
||||
<input type="text" enterkeyhint="search" autocomplete="off" maxlength="200" aria-label="메일 검색" placeholder="메일 검색" value="계약" style="flex: 1 1 auto; min-width: 0; height: 48px; box-sizing: border-box; padding: 0 4px; border: 0; outline: none; background: transparent; font: inherit; font-size: 16px; color: #171C26; caret-color: #0E7C86">
|
||||
<button type="button" aria-label="검색어 지우기" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #344054; cursor: pointer"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
|
||||
<button type="button" aria-label="{{ cond16Label }}" aria-haspopup="dialog" aria-expanded="false" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: {{ cond16Fg }}; cursor: pointer"><span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; background: {{ cond16Bg }}"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="21" y1="4" x2="14" y2="4"></line><line x1="10" y1="4" x2="3" y2="4"></line><line x1="21" y1="12" x2="12" y2="12"></line><line x1="8" y1="12" x2="3" y2="12"></line><line x1="21" y1="20" x2="16" y2="20"></line><line x1="12" y1="20" x2="3" y2="20"></line><line x1="14" y1="1" x2="14" y2="7"></line><line x1="8" y1="9" x2="8" y2="15"></line><line x1="16" y1="17" x2="16" y2="23"></line></svg></span></button>
|
||||
</header>
|
||||
<div style="flex: 0 0 auto; border-bottom: 1px solid #EEF0F3; background: #FBFBFC">
|
||||
<div style="display: flex; align-items: center; gap: 12px; height: 56px; box-sizing: border-box; padding: 0 16px">
|
||||
<span id="s16-scope" style="flex: 0 0 auto; font-size: 13px; font-weight: 700; color: #475467">찾을 곳</span>
|
||||
<div role="group" aria-labelledby="s16-scope" style="flex: 1 1 auto; min-width: 0; display: flex">
|
||||
<button type="button" aria-pressed="true" style="flex: 1 1 0; min-width: 0; height: 48px; display: flex; align-items: center; padding: 0; border: 0; background: transparent; font: inherit; cursor: pointer"><span style="flex: 1 1 auto; min-width: 0; display: block; height: 40px; box-sizing: border-box; padding: 0 12px; border: 1px solid #D0D5DD; border-radius: 20px 0 0 20px; background: #E3F2F3; color: #0A5F67; font-size: 14px; font-weight: 700; line-height: 38px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">받은편지함</span></button>
|
||||
<button type="button" aria-pressed="false" style="flex: 1 1 0; min-width: 0; height: 48px; display: flex; align-items: center; padding: 0; border: 0; background: transparent; font: inherit; cursor: pointer"><span style="flex: 1 1 auto; min-width: 0; display: block; height: 40px; box-sizing: border-box; padding: 0 12px; border: 1px solid #D0D5DD; border-left: 0; border-radius: 0 20px 20px 0; background: #FFFFFF; color: #344054; font-size: 14px; font-weight: 500; line-height: 38px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">전체 메일함</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<sc-if value="{{ applied16 }}" hint-placeholder-val="{{ true }}">
|
||||
<div style="flex: 0 0 auto; display: flex; align-items: center; gap: 8px; min-height: 48px; box-sizing: border-box; padding: 0 8px 0 16px; border-bottom: 1px solid #EEF0F3; background: #FFFFFF">
|
||||
<p style="flex: 1 1 auto; min-width: 0; margin: 0; padding: 6px 0; font-size: 13px; line-height: 1.55; color: #344054; word-break: keep-all">조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음</p>
|
||||
<button type="button" style="flex: 0 0 auto; min-height: 48px; padding: 0 12px; border: 0; border-radius: 8px; background: transparent; color: #0A5F67; font: inherit; font-size: 15px; font-weight: 600; cursor: pointer">조건 지우기</button>
|
||||
</div>
|
||||
</sc-if>
|
||||
<p role="status" style="flex: 0 0 auto; margin: 0; height: 36px; box-sizing: border-box; padding: 0 16px; font-size: 13px; line-height: 36px; color: #667085; font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis">{{ count16 }}</p>
|
||||
<ul aria-label="검색 결과" style="flex: 1 1 auto; min-height: 0; overflow-y: auto; list-style: none; margin: 0; padding: 0 0 24px">
|
||||
<sc-for list="{{ rows16 }}" as="r" hint-placeholder-count="2">
|
||||
<li style="border-bottom: 1px solid #F2F4F7; background: #FFFFFF">
|
||||
<a href="App1SearchStates.dc.html" aria-label="{{ r.label }}" style="display: flex; align-items: flex-start; gap: 6px; padding: 10px 16px 10px 8px; color: inherit; text-decoration: none">
|
||||
<span aria-hidden="true" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center"><span style="display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; background: #E3F2F3; color: #0A5F67; font-size: 16px; font-weight: 600">{{ r.initial }}</span></span>
|
||||
<span style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 1px; padding-top: 1px">
|
||||
<span style="display: flex; align-items: baseline; gap: 8px">
|
||||
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; font-weight: {{ r.fw }}; color: #171C26">{{ r.who }}</span>
|
||||
<span style="flex: 0 0 auto; display: flex; align-items: baseline">
|
||||
<sc-if value="{{ r.hasBox }}" hint-placeholder-val="{{ false }}"><span style="flex: 0 1 auto; min-width: 0; max-width: 124px; box-sizing: border-box; padding-right: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; color: #667085">{{ r.boxLabel }}</span></sc-if>
|
||||
<span style="flex: 0 0 auto; font-size: 12.5px; font-weight: {{ r.timeFw }}; color: {{ r.timeColor }}; font-variant-numeric: tabular-nums; white-space: nowrap">{{ r.time }}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span style="display: flex; align-items: center; gap: 6px; min-width: 0">
|
||||
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14.5px; font-weight: {{ r.subFw }}; color: {{ r.subColor }}"><sc-for list="{{ r.subjectParts }}" as="p" hint-placeholder-count="1"><sc-if value="{{ p.hit }}" hint-placeholder-val="{{ false }}"><mark>{{ p.t }}</mark></sc-if><sc-if value="{{ p.plain }}" hint-placeholder-val="{{ true }}"><span>{{ p.t }}</span></sc-if></sc-for></span>
|
||||
<sc-if value="{{ r.att }}" hint-placeholder-val="{{ false }}"><svg aria-hidden="true" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex: 0 0 auto"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path></svg></sc-if>
|
||||
<sc-if value="{{ r.flag }}" hint-placeholder-val="{{ false }}"><svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="#B7791F" stroke="#B7791F" stroke-width="1.6" stroke-linejoin="round" style="flex: 0 0 auto"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg></sc-if>
|
||||
</span>
|
||||
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13.5px; color: #667085"><sc-for list="{{ r.previewParts }}" as="p" hint-placeholder-count="1"><sc-if value="{{ p.hit }}" hint-placeholder-val="{{ false }}"><mark style="color: {{ p.fg }}">{{ p.t }}</mark></sc-if><sc-if value="{{ p.plain }}" hint-placeholder-val="{{ true }}"><span>{{ p.t }}</span></sc-if></sc-for></span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</sc-for>
|
||||
</ul>
|
||||
<nav aria-label="앱 메뉴" style="flex: 0 0 auto; display: flex; height: 72px; box-sizing: border-box; padding: 8px 0 10px; border-top: 1px solid #EEF0F3; background: #FFFFFF">
|
||||
<a href="#" aria-current="page" style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 0; border: 0; background: transparent; color: #0A5F67; font: inherit; font-size: 12.5px; font-weight: 700; text-decoration: none; cursor: pointer"><span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 56px; height: 32px; border-radius: 16px; background: #E3F2F3"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="20" height="16" rx="2"></rect><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"></path></svg></span><span>메일</span></a>
|
||||
<a href="App1Shell.dc.html" style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 0; border: 0; background: transparent; color: #4A5363; font: inherit; font-size: 12.5px; font-weight: 500; text-decoration: none; cursor: pointer"><span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 56px; height: 32px; border-radius: 16px; background: transparent"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg></span><span>설정</span></a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="flex: 0 0 390px; display: flex; flex-direction: column; gap: 12px">
|
||||
<h2 style="margin: 0; height: 20px; font-size: 13px; line-height: 20px; font-weight: 700; color: #344054">{{ b17Title }}</h2>
|
||||
<div style="position: relative; width: 390px; height: 844px; border-radius: 28px; overflow: hidden; background: #FFFFFF; box-shadow: 0 0 0 1px #D0D5DD, 0 12px 32px rgba(16, 24, 28, 0.12); display: flex; flex-direction: column">
|
||||
<header style="flex: 0 0 auto; display: flex; align-items: center; gap: 4px; height: 64px; box-sizing: border-box; padding: 0 4px; border-bottom: 1px solid #EEF0F3; background: #FFFFFF">
|
||||
<a href="App1MailList.dc.html" aria-label="검색 닫기" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #344054; cursor: pointer"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg></a>
|
||||
<sc-if value="{{ empty17 }}" hint-placeholder-val="{{ true }}">
|
||||
<input type="text" enterkeyhint="search" autocomplete="off" maxlength="200" aria-label="메일 검색" placeholder="메일 검색" style="flex: 1 1 auto; min-width: 0; height: 48px; box-sizing: border-box; padding: 0 4px; border: 0; outline: none; background: transparent; font: inherit; font-size: 16px; color: #171C26; caret-color: #0E7C86">
|
||||
</sc-if>
|
||||
<sc-if value="{{ many17 }}" hint-placeholder-val="{{ false }}">
|
||||
<input type="text" enterkeyhint="search" autocomplete="off" maxlength="200" aria-label="메일 검색" placeholder="메일 검색" value="견적 단가 납품 일정 변경 요청" style="flex: 1 1 auto; min-width: 0; height: 48px; box-sizing: border-box; padding: 0 4px; border: 0; outline: none; background: transparent; font: inherit; font-size: 16px; color: #171C26; caret-color: #0E7C86">
|
||||
<button type="button" aria-label="검색어 지우기" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #344054; cursor: pointer"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
|
||||
</sc-if>
|
||||
<button type="button" aria-label="조건으로 찾기, 조건이 걸려 있음" aria-haspopup="dialog" aria-expanded="false" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #0A5F67; cursor: pointer"><span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; background: #E3F2F3"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="21" y1="4" x2="14" y2="4"></line><line x1="10" y1="4" x2="3" y2="4"></line><line x1="21" y1="12" x2="12" y2="12"></line><line x1="8" y1="12" x2="3" y2="12"></line><line x1="21" y1="20" x2="16" y2="20"></line><line x1="12" y1="20" x2="3" y2="20"></line><line x1="14" y1="1" x2="14" y2="7"></line><line x1="8" y1="9" x2="8" y2="15"></line><line x1="16" y1="17" x2="16" y2="23"></line></svg></span></button>
|
||||
</header>
|
||||
<sc-if value="{{ empty17 }}" hint-placeholder-val="{{ true }}">
|
||||
<div style="flex: 0 0 auto; border-bottom: 1px solid #EEF0F3; background: #FBFBFC">
|
||||
<div style="display: flex; align-items: center; gap: 12px; height: 56px; box-sizing: border-box; padding: 0 16px">
|
||||
<span id="s17-scope" style="flex: 0 0 auto; font-size: 13px; font-weight: 700; color: #475467">찾을 곳</span>
|
||||
<div role="group" aria-labelledby="s17-scope" style="flex: 1 1 auto; min-width: 0; display: flex">
|
||||
<button type="button" aria-pressed="false" style="flex: 1 1 0; min-width: 0; height: 48px; display: flex; align-items: center; padding: 0; border: 0; background: transparent; font: inherit; cursor: pointer"><span style="flex: 1 1 auto; min-width: 0; display: block; height: 40px; box-sizing: border-box; padding: 0 12px; border: 1px solid #D0D5DD; border-radius: 20px 0 0 20px; background: #FFFFFF; color: #344054; font-size: 14px; font-weight: 500; line-height: 38px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">받은편지함</span></button>
|
||||
<button type="button" aria-pressed="true" style="flex: 1 1 0; min-width: 0; height: 48px; display: flex; align-items: center; padding: 0; border: 0; background: transparent; font: inherit; cursor: pointer"><span style="flex: 1 1 auto; min-width: 0; display: block; height: 40px; box-sizing: border-box; padding: 0 12px; border: 1px solid #D0D5DD; border-left: 0; border-radius: 0 20px 20px 0; background: #E3F2F3; color: #0A5F67; font-size: 14px; font-weight: 700; line-height: 38px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">전체 메일함</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<p style="margin: -4px 0 0; padding: 0 16px 12px; font-size: 13px; line-height: 1.5; color: #667085; word-break: keep-all">휴지통과 스팸메일함은 빼고 찾습니다</p>
|
||||
</div>
|
||||
</sc-if>
|
||||
<sc-if value="{{ many17 }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="flex: 0 0 auto; border-bottom: 1px solid #EEF0F3; background: #FBFBFC">
|
||||
<div style="display: flex; align-items: center; gap: 12px; height: 56px; box-sizing: border-box; padding: 0 16px">
|
||||
<span id="s17-scope" style="flex: 0 0 auto; font-size: 13px; font-weight: 700; color: #475467">찾을 곳</span>
|
||||
<div role="group" aria-labelledby="s17-scope" style="flex: 1 1 auto; min-width: 0; display: flex">
|
||||
<button type="button" aria-pressed="true" style="flex: 1 1 0; min-width: 0; height: 48px; display: flex; align-items: center; padding: 0; border: 0; background: transparent; font: inherit; cursor: pointer"><span style="flex: 1 1 auto; min-width: 0; display: block; height: 40px; box-sizing: border-box; padding: 0 12px; border: 1px solid #D0D5DD; border-radius: 20px 0 0 20px; background: #E3F2F3; color: #0A5F67; font-size: 14px; font-weight: 700; line-height: 38px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">받은편지함</span></button>
|
||||
<button type="button" aria-pressed="false" style="flex: 1 1 0; min-width: 0; height: 48px; display: flex; align-items: center; padding: 0; border: 0; background: transparent; font: inherit; cursor: pointer"><span style="flex: 1 1 auto; min-width: 0; display: block; height: 40px; box-sizing: border-box; padding: 0 12px; border: 1px solid #D0D5DD; border-left: 0; border-radius: 0 20px 20px 0; background: #FFFFFF; color: #344054; font-size: 14px; font-weight: 500; line-height: 38px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">전체 메일함</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
<div style="flex: 0 0 auto; display: flex; align-items: center; gap: 8px; min-height: 48px; box-sizing: border-box; padding: 0 8px 0 16px; border-bottom: 1px solid #EEF0F3; background: #FFFFFF">
|
||||
<p style="flex: 1 1 auto; min-width: 0; margin: 0; padding: 6px 0; font-size: 13px; line-height: 1.55; color: #344054; word-break: keep-all">{{ cond17 }}</p>
|
||||
<button type="button" style="flex: 0 0 auto; min-height: 48px; padding: 0 12px; border: 0; border-radius: 8px; background: transparent; color: #0A5F67; font: inherit; font-size: 15px; font-weight: 600; cursor: pointer">조건 지우기</button>
|
||||
</div>
|
||||
<sc-if value="{{ empty17 }}" hint-placeholder-val="{{ true }}">
|
||||
<p role="status" style="flex: 0 0 auto; margin: 0; height: 36px; box-sizing: border-box; padding: 0 16px; font-size: 13px; line-height: 36px; color: #667085; font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis">0통</p>
|
||||
<div style="flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 0 40px 120px; text-align: center">
|
||||
<span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 72px; height: 72px; border-radius: 50%; background: #F2F4F7; color: #667085"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg></span>
|
||||
<h2 style="margin: 4px 0 0; font-size: 15px; line-height: 1.5; font-weight: 650; word-break: keep-all">조건에 맞는 메일이 없습니다</h2>
|
||||
<p style="margin: 0; font-size: 13px; line-height: 1.6; color: #667085; word-break: keep-all">조건을 바꾸거나 줄여 보세요.</p>
|
||||
</div>
|
||||
</sc-if>
|
||||
<sc-if value="{{ many17 }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 0 40px 120px; text-align: center">
|
||||
<span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 72px; height: 72px; border-radius: 50%; background: #F2F4F7; color: #667085"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg></span>
|
||||
<h2 style="margin: 4px 0 0; font-size: 15px; line-height: 1.5; font-weight: 650; word-break: keep-all">찾을 말이 너무 많습니다</h2>
|
||||
<p style="margin: 0; font-size: 13px; line-height: 1.6; color: #667085; word-break: keep-all">검색 칸과 [조건]에 넣은 낱말은 모두 합쳐 8개까지입니다. 낱말을 몇 개 줄여 주세요.</p>
|
||||
</div>
|
||||
</sc-if>
|
||||
<nav aria-label="앱 메뉴" style="flex: 0 0 auto; display: flex; height: 72px; box-sizing: border-box; padding: 8px 0 10px; border-top: 1px solid #EEF0F3; background: #FFFFFF">
|
||||
<a href="#" aria-current="page" style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 0; border: 0; background: transparent; color: #0A5F67; font: inherit; font-size: 12.5px; font-weight: 700; text-decoration: none; cursor: pointer"><span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 56px; height: 32px; border-radius: 16px; background: #E3F2F3"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="20" height="16" rx="2"></rect><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"></path></svg></span><span>메일</span></a>
|
||||
<a href="App1Shell.dc.html" style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 0; border: 0; background: transparent; color: #4A5363; font: inherit; font-size: 12.5px; font-weight: 500; text-decoration: none; cursor: pointer"><span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 56px; height: 32px; border-radius: 16px; background: transparent"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg></span><span>설정</span></a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"previewMarkText":{"editor":"enum","options":["그대로 #667085","진하게 #4A5363"],"default":"진하게 #4A5363"},"$preview":{"width":1784,"height":1028}}'>
|
||||
class Component extends DCLogic {
|
||||
state = { b14: null, b15: null, b16: null, b17: null, period14: null, period15: null, att14: null, att15: null };
|
||||
|
||||
// 예시 메일 — 1차 보드(App1Search)의 같은 메일. ⟦ ⟧ 안이 맞은 말(바탕만 노랑 #FCE7A4)
|
||||
mails() {
|
||||
return {
|
||||
doyun: { who: '이도윤', initial: '이', time: '오전 10:42', subject: 'Re: 유지보수 ⟦계약⟧ 갱신 건', preview: '말씀하신 조건으로 ⟦계약⟧서 초안을 고쳐 첨부했습니다. 기간은 1년이고, 단가는…', unread: true, att: true },
|
||||
hr: { who: '인사팀', initial: '인', time: '오전 9:05', subject: '[공지] ⟦계약⟧직 채용 일정 안내', preview: '11월 ⟦계약⟧직 채용 일정을 알려 드립니다. 서류 접수는 11월 14일(금)까지…' },
|
||||
jihoon: { who: '박지훈', initial: '박', time: '11월 4일', subject: '견적서 보내 드립니다', preview: '…지난달 ⟦계약⟧ 기간에 맞춰 단가를 다시 계산했습니다. 확인 부탁드…', att: true, flag: true },
|
||||
doyunOld: { who: '이도윤', initial: '이', time: '10월 29일', subject: '회신: 유지보수 ⟦계약⟧ 갱신 건', preview: '네, 확인했습니다. 단가와 기간을 정리해서 다음 주 초에…' },
|
||||
// 16 의 둘째 줄 — 1차 보드에 없는 메일이라 미리 보기는 웹 보드(Search14)의 이도윤 메일 글을 썼다.
|
||||
// 1차 보드처럼 서버가 준 한 줄 폭으로 줄였다(줄 끝에서 잘린 강조가 노란 조각으로 남지 않게)
|
||||
doyunDraft: { who: '이도윤', initial: '이', time: '10월 28일', subject: '⟦계약⟧서 초안 보내 드립니다', preview: '지난번 말씀하신 ⟦계약⟧서 초안을 첨부했습니다. 단가는…', att: true },
|
||||
};
|
||||
}
|
||||
|
||||
// 글을 ⟦ ⟧ 기준으로 조각낸다 — hit 는 노랑 바탕, fg 는 강조 안의 글자색
|
||||
parts(text, fg) {
|
||||
const out = [];
|
||||
let rest = text;
|
||||
while (rest.length > 0) {
|
||||
const open = rest.indexOf('⟦');
|
||||
if (open < 0) {
|
||||
out.push({ t: rest, hit: false, plain: true, fg: fg });
|
||||
break;
|
||||
}
|
||||
if (open > 0) out.push({ t: rest.slice(0, open), hit: false, plain: true, fg: fg });
|
||||
const close = rest.indexOf('⟧', open + 1);
|
||||
const end = close < 0 ? rest.length : close;
|
||||
out.push({ t: rest.slice(open + 1, end), hit: true, plain: false, fg: fg });
|
||||
rest = close < 0 ? '' : rest.slice(close + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
plain(text) {
|
||||
return text.split('⟦').join('').split('⟧').join('');
|
||||
}
|
||||
|
||||
// 목록 줄 하나 — 1차 보드의 줄 그대로(사진은 단추가 아니다)
|
||||
row(m, markFg) {
|
||||
const unread = !!m.unread;
|
||||
const box = m.box || '';
|
||||
return {
|
||||
who: m.who,
|
||||
initial: m.initial,
|
||||
time: m.time,
|
||||
hasBox: box !== '',
|
||||
boxLabel: box !== '' ? box + ' ·' : '',
|
||||
att: !!m.att,
|
||||
flag: !!m.flag,
|
||||
fw: unread ? '650' : '450',
|
||||
subFw: unread ? '600' : '400',
|
||||
subColor: unread ? '#171C26' : '#344054',
|
||||
timeFw: unread ? '600' : '400',
|
||||
timeColor: unread ? '#171C26' : '#667085',
|
||||
subjectParts: this.parts(m.subject, 'inherit'),
|
||||
previewParts: this.parts(m.preview, markFg),
|
||||
// 낭독: 안 읽음, 보낸 사람, 메일함, 시각, 제목, 첨부 있음, 중요, 미리 보기 — 강조는 읽지 않는다
|
||||
label: [unread ? '안 읽음' : '', m.who, box, m.time, this.plain(m.subject), m.att ? '첨부 있음' : '', m.flag ? '중요' : '', this.plain(m.preview)].filter((s) => s !== '').join(', '),
|
||||
};
|
||||
}
|
||||
|
||||
// 상태 고르기 단추 — 1차 보드와 같은 모양
|
||||
modes(list, current, choose) {
|
||||
return list.map((x) => {
|
||||
const on = x.id === current;
|
||||
return {
|
||||
label: x.label,
|
||||
pressed: on ? 'true' : 'false',
|
||||
pick: () => choose(x.id),
|
||||
bg: on ? '#E3F2F3' : '#FFFFFF',
|
||||
fg: on ? '#0A5F67' : '#344054',
|
||||
border: on ? '#0E7C86' : '#D0D5DD',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// 2026년 11월 — 1일이 일요일이다. 오늘(7일)을 골랐고, 오늘 뒤(8~30일)는 고를 수 없다
|
||||
calendar() {
|
||||
const head = ['일', '월', '화', '수', '목', '금', '토'].map((d) => ({ t: d, bg: 'transparent', fg: '#171C26' }));
|
||||
const days = Array.from({ length: 30 }, (_, i) => {
|
||||
const d = i + 1;
|
||||
if (d === 7) return { t: '7', bg: '#0E7C86', fg: '#FFFFFF' };
|
||||
return { t: String(d), bg: 'transparent', fg: d > 7 ? 'rgba(23, 28, 38, 0.38)' : '#171C26' };
|
||||
});
|
||||
return head.concat(days);
|
||||
}
|
||||
|
||||
renderVals() {
|
||||
// 미리 보기 강조 안 글자 — 기본은 진하게(#4A5363), '그대로'는 #667085 (1차 보드와 같은 선택)
|
||||
const strong = (this.props.previewMarkText ?? '진하게 #4A5363') === '진하게 #4A5363';
|
||||
const markFg = strong ? '#4A5363' : 'inherit';
|
||||
const row = (m) => this.row(m, markFg);
|
||||
const M = this.mails();
|
||||
const st = this.state;
|
||||
const m14 = st.b14 ?? 'pick';
|
||||
const m15 = st.b15 ?? 'fields';
|
||||
const m16 = st.b16 ?? 'applied';
|
||||
const applied16 = m16 === 'applied';
|
||||
const m17 = st.b17 ?? 'empty';
|
||||
const period14 = st.period14 ?? 'month';
|
||||
const period15 = st.period15 ?? 'custom';
|
||||
const att14 = st.att14 ?? true;
|
||||
const att15 = st.att15 ?? true;
|
||||
return {
|
||||
// 14 · 15 의 뒤 화면 — '계약'으로 찾은 받은편지함 결과(시트에 가려 위 막대만 보인다)
|
||||
rowsBg: [M.doyun, M.hr, M.jihoon, M.doyunOld].map(row),
|
||||
// 16 — 조건 걸림: 이도윤이 보낸 첨부 있는 메일 둘(최근 1달) / 조건 없음: 1차 보드 결과 화면의 같은 넷
|
||||
rows16: (applied16 ? [M.doyun, M.doyunDraft] : [M.doyun, M.hr, M.jihoon, M.doyunOld]).map(row),
|
||||
b16Modes: this.modes([{ id: 'applied', label: '조건 걸림' }, { id: 'none', label: '조건 없음' }], m16, (id) => this.setState({ b16: id })),
|
||||
b16Title: applied16 ? '조건이 걸린 결과' : '조건 없이 찾은 결과',
|
||||
applied16: applied16,
|
||||
count16: applied16 ? '‘계약’ · 2통' : '‘계약’ · 4통',
|
||||
// [조건] — 걸림은 옅은 청록 원 위 #0A5F67, 평소는 바탕 없이 #344054
|
||||
cond16Label: applied16 ? '조건으로 찾기, 조건이 걸려 있음' : '조건으로 찾기',
|
||||
cond16Fg: applied16 ? '#0A5F67' : '#344054',
|
||||
cond16Bg: applied16 ? '#E3F2F3' : 'transparent',
|
||||
|
||||
b14Modes: this.modes([{ id: 'pick', label: '조건 고르기' }, { id: 'many', label: '낱말이 너무 많음' }], m14, (id) => this.setState({ b14: id, period14: null, att14: null })),
|
||||
b14Title: m14 === 'many' ? '조건으로 찾기 · 낱말이 너무 많음' : '조건으로 찾기 (시트)',
|
||||
// 낱말 수: 검색 칸 4 + 보낸 사람 1 + 제목 5 = 10 (8 넘음)
|
||||
q14: m14 === 'many' ? '견적 단가 납품 일정' : '계약',
|
||||
subj14: m14 === 'many' ? '계약 갱신 확인 요청 건' : '',
|
||||
tooMany14: m14 === 'many',
|
||||
period14: period14,
|
||||
setPeriod14: (e) => this.setState({ period14: e.target.value }),
|
||||
custom14: period14 === 'custom',
|
||||
att14: att14,
|
||||
toggleAtt14: () => this.setState({ att14: !att14 }),
|
||||
|
||||
b15Modes: this.modes([{ id: 'fields', label: '날짜 칸' }, { id: 'picker', label: '날짜 고르는 창' }], m15, (id) => this.setState({ b15: id, period15: null, att15: null })),
|
||||
b15Title: m15 === 'picker' ? '날짜 고르기 · 날짜 고르는 창' : '날짜 고르기',
|
||||
pickerOpen: m15 === 'picker',
|
||||
openPicker: () => this.setState({ b15: 'picker' }),
|
||||
closePicker: () => this.setState({ b15: 'fields' }),
|
||||
period15: period15,
|
||||
setPeriod15: (e) => this.setState({ period15: e.target.value }),
|
||||
custom15: period15 === 'custom',
|
||||
att15: att15,
|
||||
toggleAtt15: () => this.setState({ att15: !att15 }),
|
||||
calCells: this.calendar(),
|
||||
|
||||
b17Modes: this.modes([{ id: 'empty', label: '조건에 맞는 메일 없음' }, { id: 'many', label: '찾을 말이 너무 많음' }], m17, (id) => this.setState({ b17: id })),
|
||||
b17Title: m17 === 'many' ? '찾을 말이 너무 많음' : '조건에 맞는 메일이 없음',
|
||||
empty17: m17 !== 'many',
|
||||
many17: m17 === 'many',
|
||||
// 낱말 수: 검색 칸 6 + 제목 3 = 9 (8 넘음)
|
||||
cond17: m17 === 'many' ? '조건: 제목에 ‘계약 갱신 확인’이 든 메일' : '조건: 내가 보낸 메일, 2026. 10. 1.부터 11. 5.까지',
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,396 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>앱 · 메일 검색 · 상태</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<style>
|
||||
@font-face{font-family:'Pretendard';src:url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2-variations'),url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2');font-weight:45 920;font-style:normal;font-display:swap}
|
||||
body{margin:0}
|
||||
a{color:#0E7C86}a:hover{color:#0A5F67}
|
||||
:focus-visible{outline:2px solid #0E7C86;outline-offset:2px}
|
||||
input::placeholder,textarea::placeholder{color:#8A94A3}
|
||||
mark{background:#FCE7A4;color:inherit}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="width: 2660px; height: 1028px; box-sizing: border-box; padding: 40px; display: flex; flex-direction: column; gap: 24px; background: #EEF1F4; color: #171C26; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 15px; line-height: 1.5">
|
||||
<div style="display: flex; justify-content: flex-start; height: 48px">
|
||||
<div role="group" aria-label="아직 못 찾음 상태 고르기" style="flex: 0 0 390px; display: flex; gap: 8px">
|
||||
<sc-for list="{{ b8Modes }}" as="s" hint-placeholder-count="2">
|
||||
<button type="button" aria-pressed="{{ s.pressed }}" onClick="{{ s.pick }}" style="flex: 1 1 0; min-width: 0; min-height: 48px; box-sizing: border-box; padding: 0 12px; border: 1px solid {{ s.border }}; border-radius: 10px; background: {{ s.bg }}; color: {{ s.fg }}; font: inherit; font-size: 14px; font-weight: 600; cursor: pointer">{{ s.label }}</button>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 48px; align-items: flex-start">
|
||||
|
||||
<div style="flex: 0 0 390px; display: flex; flex-direction: column; gap: 12px">
|
||||
<h2 style="margin: 0; height: 20px; font-size: 13px; line-height: 20px; font-weight: 700; color: #344054">{{ b8Title }}</h2>
|
||||
<div style="position: relative; width: 390px; height: 844px; border-radius: 28px; overflow: hidden; background: #FFFFFF; box-shadow: 0 0 0 1px #D0D5DD, 0 12px 32px rgba(16, 24, 28, 0.12); display: flex; flex-direction: column">
|
||||
<header style="flex: 0 0 auto; display: flex; align-items: center; gap: 4px; height: 64px; box-sizing: border-box; padding: 0 4px; border-bottom: 1px solid #EEF0F3; background: #FFFFFF">
|
||||
<a href="App1MailList.dc.html" aria-label="검색 닫기" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #344054; cursor: pointer"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg></a>
|
||||
<input type="text" enterkeyhint="search" autocomplete="off" maxlength="200" aria-label="메일 검색" placeholder="메일 검색" value="정산서" style="flex: 1 1 auto; min-width: 0; height: 48px; box-sizing: border-box; padding: 0 4px; border: 0; outline: none; background: transparent; font: inherit; font-size: 16px; color: #171C26; caret-color: #0E7C86">
|
||||
<button type="button" aria-label="검색어 지우기" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #344054; cursor: pointer"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
|
||||
</header>
|
||||
<div style="flex: 0 0 auto; border-bottom: 1px solid #EEF0F3; background: #FBFBFC">
|
||||
<div style="display: flex; align-items: center; gap: 12px; height: 56px; box-sizing: border-box; padding: 0 16px">
|
||||
<span id="s8-scope" style="flex: 0 0 auto; font-size: 13px; font-weight: 700; color: #475467">찾을 곳</span>
|
||||
<div role="group" aria-labelledby="s8-scope" style="flex: 1 1 auto; min-width: 0; display: flex">
|
||||
<button type="button" aria-pressed="false" style="flex: 1 1 0; min-width: 0; height: 48px; display: flex; align-items: center; padding: 0; border: 0; background: transparent; font: inherit; cursor: pointer"><span style="flex: 1 1 auto; min-width: 0; display: block; height: 40px; box-sizing: border-box; padding: 0 12px; border: 1px solid #D0D5DD; border-radius: 20px 0 0 20px; background: #FFFFFF; color: #344054; font-size: 14px; font-weight: 500; line-height: 38px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">받은편지함</span></button>
|
||||
<button type="button" aria-pressed="true" style="flex: 1 1 0; min-width: 0; height: 48px; display: flex; align-items: center; padding: 0; border: 0; background: transparent; font: inherit; cursor: pointer"><span style="flex: 1 1 auto; min-width: 0; display: block; height: 40px; box-sizing: border-box; padding: 0 12px; border: 1px solid #D0D5DD; border-left: 0; border-radius: 0 20px 20px 0; background: #E3F2F3; color: #0A5F67; font-size: 14px; font-weight: 700; line-height: 38px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">전체 메일함</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<p style="margin: -4px 0 0; padding: 0 16px 12px; font-size: 13px; line-height: 1.5; color: #667085; word-break: keep-all">휴지통과 스팸메일함은 빼고 찾습니다</p>
|
||||
</div>
|
||||
<p role="status" style="flex: 0 0 auto; margin: 0; height: 36px; box-sizing: border-box; padding: 0 16px; font-size: 13px; line-height: 36px; color: #667085; font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis">‘정산서’</p>
|
||||
<div style="flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 0 40px 120px; text-align: center">
|
||||
<span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 72px; height: 72px; border-radius: 50%; background: #F2F4F7; color: #667085"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg></span>
|
||||
<h2 style="margin: 4px 0 0; font-size: 15px; line-height: 1.5; font-weight: 650; word-break: keep-all">아직 맞는 메일을 찾지 못했습니다</h2>
|
||||
<p style="margin: 0; font-size: 13px; line-height: 1.6; color: #667085; word-break: keep-all">메일이 많아 한 번에 다 찾지 못했습니다. [더 찾기]를 누르면 이어서 찾습니다.</p>
|
||||
<sc-if value="{{ b8Before }}" hint-placeholder-val="{{ true }}">
|
||||
<button type="button" onClick="{{ b8Press }}" style="min-height: 48px; padding: 0 16px; border: 0; border-radius: 10px; background: transparent; color: #0E7C86; font: inherit; font-size: 15px; font-weight: 600; cursor: pointer">더 찾기</button>
|
||||
</sc-if>
|
||||
<sc-if value="{{ b8After }}" hint-placeholder-val="{{ false }}">
|
||||
<button type="button" aria-disabled="true" style="min-height: 48px; padding: 0 16px; border: 0; border-radius: 10px; background: transparent; color: #667085; font: inherit; font-size: 15px; font-weight: 600; cursor: default">찾는 중…</button>
|
||||
</sc-if>
|
||||
</div>
|
||||
<nav aria-label="앱 메뉴" style="flex: 0 0 auto; display: flex; height: 72px; box-sizing: border-box; padding: 8px 0 10px; border-top: 1px solid #EEF0F3; background: #FFFFFF">
|
||||
<a href="#" aria-current="page" style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 0; border: 0; background: transparent; color: #0A5F67; font: inherit; font-size: 12.5px; font-weight: 700; text-decoration: none; cursor: pointer"><span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 56px; height: 32px; border-radius: 16px; background: #E3F2F3"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="20" height="16" rx="2"></rect><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"></path></svg></span><span>메일</span></a>
|
||||
<a href="App1Shell.dc.html" style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 0; border: 0; background: transparent; color: #4A5363; font: inherit; font-size: 12.5px; font-weight: 500; text-decoration: none; cursor: pointer"><span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 56px; height: 32px; border-radius: 16px; background: transparent"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg></span><span>설정</span></a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="flex: 0 0 390px; display: flex; flex-direction: column; gap: 12px">
|
||||
<h2 style="margin: 0; height: 20px; font-size: 13px; line-height: 20px; font-weight: 700; color: #344054">목록 끝 · 더 찾기</h2>
|
||||
<div style="position: relative; width: 390px; height: 844px; border-radius: 28px; overflow: hidden; background: #FFFFFF; box-shadow: 0 0 0 1px #D0D5DD, 0 12px 32px rgba(16, 24, 28, 0.12); display: flex; flex-direction: column">
|
||||
<header style="flex: 0 0 auto; display: flex; align-items: center; gap: 4px; height: 64px; box-sizing: border-box; padding: 0 4px; border-bottom: 1px solid #EEF0F3; background: #FFFFFF">
|
||||
<a href="App1MailList.dc.html" aria-label="검색 닫기" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #344054; cursor: pointer"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg></a>
|
||||
<input type="text" enterkeyhint="search" autocomplete="off" maxlength="200" aria-label="메일 검색" placeholder="메일 검색" value="견적" style="flex: 1 1 auto; min-width: 0; height: 48px; box-sizing: border-box; padding: 0 4px; border: 0; outline: none; background: transparent; font: inherit; font-size: 16px; color: #171C26; caret-color: #0E7C86">
|
||||
<button type="button" aria-label="검색어 지우기" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #344054; cursor: pointer"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
|
||||
</header>
|
||||
<div style="flex: 0 0 auto; border-bottom: 1px solid #EEF0F3; background: #FBFBFC">
|
||||
<div style="display: flex; align-items: center; gap: 12px; height: 56px; box-sizing: border-box; padding: 0 16px">
|
||||
<span id="s9-scope" style="flex: 0 0 auto; font-size: 13px; font-weight: 700; color: #475467">찾을 곳</span>
|
||||
<div role="group" aria-labelledby="s9-scope" style="flex: 1 1 auto; min-width: 0; display: flex">
|
||||
<button type="button" aria-pressed="true" style="flex: 1 1 0; min-width: 0; height: 48px; display: flex; align-items: center; padding: 0; border: 0; background: transparent; font: inherit; cursor: pointer"><span style="flex: 1 1 auto; min-width: 0; display: block; height: 40px; box-sizing: border-box; padding: 0 12px; border: 1px solid #D0D5DD; border-radius: 20px 0 0 20px; background: #E3F2F3; color: #0A5F67; font-size: 14px; font-weight: 700; line-height: 38px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">받은편지함</span></button>
|
||||
<button type="button" aria-pressed="false" style="flex: 1 1 0; min-width: 0; height: 48px; display: flex; align-items: center; padding: 0; border: 0; background: transparent; font: inherit; cursor: pointer"><span style="flex: 1 1 auto; min-width: 0; display: block; height: 40px; box-sizing: border-box; padding: 0 12px; border: 1px solid #D0D5DD; border-left: 0; border-radius: 0 20px 20px 0; background: #FFFFFF; color: #344054; font-size: 14px; font-weight: 500; line-height: 38px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">전체 메일함</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p role="status" style="flex: 0 0 auto; margin: 0; height: 36px; box-sizing: border-box; padding: 0 16px; font-size: 13px; line-height: 36px; color: #667085; font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis">‘견적’</p>
|
||||
<div style="flex: 1 1 auto; min-height: 0; overflow-y: auto">
|
||||
<ul aria-label="검색 결과" style="list-style: none; margin: 0; padding: 0">
|
||||
<sc-for list="{{ rows9 }}" as="r" hint-placeholder-count="3">
|
||||
<li style="border-bottom: 1px solid #F2F4F7; background: #FFFFFF">
|
||||
<a href="#s12" aria-label="{{ r.label }}" style="display: flex; align-items: flex-start; gap: 6px; padding: 10px 16px 10px 8px; color: inherit; text-decoration: none">
|
||||
<span aria-hidden="true" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center"><span style="display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; background: #E3F2F3; color: #0A5F67; font-size: 16px; font-weight: 600">{{ r.initial }}</span></span>
|
||||
<span style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 1px; padding-top: 1px">
|
||||
<span style="display: flex; align-items: baseline; gap: 8px">
|
||||
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; font-weight: {{ r.fw }}; color: #171C26">{{ r.who }}</span>
|
||||
<span style="flex: 0 0 auto; display: flex; align-items: baseline">
|
||||
<sc-if value="{{ r.hasBox }}" hint-placeholder-val="{{ false }}"><span style="flex: 0 1 auto; min-width: 0; max-width: 124px; box-sizing: border-box; padding-right: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; color: #667085">{{ r.boxLabel }}</span></sc-if>
|
||||
<span style="flex: 0 0 auto; font-size: 12.5px; font-weight: {{ r.timeFw }}; color: {{ r.timeColor }}; font-variant-numeric: tabular-nums; white-space: nowrap">{{ r.time }}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span style="display: flex; align-items: center; gap: 6px; min-width: 0">
|
||||
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14.5px; font-weight: {{ r.subFw }}; color: {{ r.subColor }}"><sc-for list="{{ r.subjectParts }}" as="p" hint-placeholder-count="1"><sc-if value="{{ p.hit }}" hint-placeholder-val="{{ false }}"><mark>{{ p.t }}</mark></sc-if><sc-if value="{{ p.plain }}" hint-placeholder-val="{{ true }}"><span>{{ p.t }}</span></sc-if></sc-for></span>
|
||||
<sc-if value="{{ r.att }}" hint-placeholder-val="{{ false }}"><svg aria-hidden="true" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex: 0 0 auto"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path></svg></sc-if>
|
||||
<sc-if value="{{ r.flag }}" hint-placeholder-val="{{ false }}"><svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="#B7791F" stroke="#B7791F" stroke-width="1.6" stroke-linejoin="round" style="flex: 0 0 auto"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg></sc-if>
|
||||
</span>
|
||||
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13.5px; color: #667085"><sc-for list="{{ r.previewParts }}" as="p" hint-placeholder-count="1"><sc-if value="{{ p.hit }}" hint-placeholder-val="{{ false }}"><mark style="color: {{ p.fg }}">{{ p.t }}</mark></sc-if><sc-if value="{{ p.plain }}" hint-placeholder-val="{{ true }}"><span>{{ p.t }}</span></sc-if></sc-for></span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</sc-for>
|
||||
</ul>
|
||||
<div style="display: flex; align-items: center; gap: 8px; min-height: 64px; box-sizing: border-box; padding: 8px 8px 8px 16px">
|
||||
<p style="flex: 1 1 auto; min-width: 0; margin: 0; font-size: 13px; line-height: 1.55; color: #667085; word-break: keep-all">메일이 많아 한 번에 다 찾지 못했습니다. [더 찾기]를 누르면 이어서 찾습니다.</p>
|
||||
<button type="button" style="flex: 0 0 auto; min-height: 48px; padding: 0 12px; border: 0; border-radius: 10px; background: transparent; color: #0E7C86; font: inherit; font-size: 15px; font-weight: 600; cursor: pointer">더 찾기</button>
|
||||
</div>
|
||||
</div>
|
||||
<nav aria-label="앱 메뉴" style="flex: 0 0 auto; display: flex; height: 72px; box-sizing: border-box; padding: 8px 0 10px; border-top: 1px solid #EEF0F3; background: #FFFFFF">
|
||||
<a href="#" aria-current="page" style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 0; border: 0; background: transparent; color: #0A5F67; font: inherit; font-size: 12.5px; font-weight: 700; text-decoration: none; cursor: pointer"><span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 56px; height: 32px; border-radius: 16px; background: #E3F2F3"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="20" height="16" rx="2"></rect><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"></path></svg></span><span>메일</span></a>
|
||||
<a href="App1Shell.dc.html" style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 0; border: 0; background: transparent; color: #4A5363; font: inherit; font-size: 12.5px; font-weight: 500; text-decoration: none; cursor: pointer"><span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 56px; height: 32px; border-radius: 16px; background: transparent"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg></span><span>설정</span></a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="flex: 0 0 390px; display: flex; flex-direction: column; gap: 12px">
|
||||
<h2 style="margin: 0; height: 20px; font-size: 13px; line-height: 20px; font-weight: 700; color: #344054">받지 못함</h2>
|
||||
<div style="position: relative; width: 390px; height: 844px; border-radius: 28px; overflow: hidden; background: #FFFFFF; box-shadow: 0 0 0 1px #D0D5DD, 0 12px 32px rgba(16, 24, 28, 0.12); display: flex; flex-direction: column">
|
||||
<header style="flex: 0 0 auto; display: flex; align-items: center; gap: 4px; height: 64px; box-sizing: border-box; padding: 0 4px; border-bottom: 1px solid #EEF0F3; background: #FFFFFF">
|
||||
<a href="App1MailList.dc.html" aria-label="검색 닫기" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #344054; cursor: pointer"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg></a>
|
||||
<input type="text" enterkeyhint="search" autocomplete="off" maxlength="200" aria-label="메일 검색" placeholder="메일 검색" value="계약" style="flex: 1 1 auto; min-width: 0; height: 48px; box-sizing: border-box; padding: 0 4px; border: 0; outline: none; background: transparent; font: inherit; font-size: 16px; color: #171C26; caret-color: #0E7C86">
|
||||
<button type="button" aria-label="검색어 지우기" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #344054; cursor: pointer"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
|
||||
</header>
|
||||
<div style="flex: 0 0 auto; border-bottom: 1px solid #EEF0F3; background: #FBFBFC">
|
||||
<div style="display: flex; align-items: center; gap: 12px; height: 56px; box-sizing: border-box; padding: 0 16px">
|
||||
<span id="s10-scope" style="flex: 0 0 auto; font-size: 13px; font-weight: 700; color: #475467">찾을 곳</span>
|
||||
<div role="group" aria-labelledby="s10-scope" style="flex: 1 1 auto; min-width: 0; display: flex">
|
||||
<button type="button" aria-pressed="true" style="flex: 1 1 0; min-width: 0; height: 48px; display: flex; align-items: center; padding: 0; border: 0; background: transparent; font: inherit; cursor: pointer"><span style="flex: 1 1 auto; min-width: 0; display: block; height: 40px; box-sizing: border-box; padding: 0 12px; border: 1px solid #D0D5DD; border-radius: 20px 0 0 20px; background: #E3F2F3; color: #0A5F67; font-size: 14px; font-weight: 700; line-height: 38px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">받은편지함</span></button>
|
||||
<button type="button" aria-pressed="false" style="flex: 1 1 0; min-width: 0; height: 48px; display: flex; align-items: center; padding: 0; border: 0; background: transparent; font: inherit; cursor: pointer"><span style="flex: 1 1 auto; min-width: 0; display: block; height: 40px; box-sizing: border-box; padding: 0 12px; border: 1px solid #D0D5DD; border-left: 0; border-radius: 0 20px 20px 0; background: #FFFFFF; color: #344054; font-size: 14px; font-weight: 500; line-height: 38px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">전체 메일함</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 0 40px 120px; text-align: center">
|
||||
<span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 72px; height: 72px; border-radius: 50%; background: #FEF3F2; color: #B42318"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg></span>
|
||||
<h2 style="margin: 4px 0 0; font-size: 15px; line-height: 1.5; font-weight: 650; word-break: keep-all">일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.</h2>
|
||||
<p style="margin: 0; font-size: 13px; line-height: 1.6; color: #667085">문의 코드 3f1c0a2e</p>
|
||||
<button type="button" style="min-height: 48px; padding: 0 16px; border: 0; border-radius: 10px; background: transparent; color: #0E7C86; font: inherit; font-size: 15px; font-weight: 600; cursor: pointer">다시 시도</button>
|
||||
</div>
|
||||
<nav aria-label="앱 메뉴" style="flex: 0 0 auto; display: flex; height: 72px; box-sizing: border-box; padding: 8px 0 10px; border-top: 1px solid #EEF0F3; background: #FFFFFF">
|
||||
<a href="#" aria-current="page" style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 0; border: 0; background: transparent; color: #0A5F67; font: inherit; font-size: 12.5px; font-weight: 700; text-decoration: none; cursor: pointer"><span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 56px; height: 32px; border-radius: 16px; background: #E3F2F3"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="20" height="16" rx="2"></rect><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"></path></svg></span><span>메일</span></a>
|
||||
<a href="App1Shell.dc.html" style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 0; border: 0; background: transparent; color: #4A5363; font: inherit; font-size: 12.5px; font-weight: 500; text-decoration: none; cursor: pointer"><span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 56px; height: 32px; border-radius: 16px; background: transparent"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg></span><span>설정</span></a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="flex: 0 0 390px; display: flex; flex-direction: column; gap: 12px">
|
||||
<h2 style="margin: 0; height: 20px; font-size: 13px; line-height: 20px; font-weight: 700; color: #344054">찾을 말이 너무 많음 (칸이 두 줄로)</h2>
|
||||
<div style="position: relative; width: 390px; height: 844px; border-radius: 28px; overflow: hidden; background: #FFFFFF; box-shadow: 0 0 0 1px #D0D5DD, 0 12px 32px rgba(16, 24, 28, 0.12); display: flex; flex-direction: column">
|
||||
<header style="flex: 0 0 auto; display: flex; align-items: center; gap: 4px; height: 64px; box-sizing: border-box; padding: 0 4px; border-bottom: 1px solid #EEF0F3; background: #FFFFFF">
|
||||
<a href="App1MailList.dc.html" aria-label="검색 닫기" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #344054; cursor: pointer"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg></a>
|
||||
<textarea enterkeyhint="search" autocomplete="off" maxlength="200" rows="2" aria-label="메일 검색" placeholder="메일 검색" style="flex: 1 1 auto; min-width: 0; display: block; height: 48px; box-sizing: border-box; padding: 1px 4px; border: 0; outline: none; resize: none; overflow: hidden; background: transparent; font: inherit; font-size: 16px; line-height: 23px; color: #171C26; caret-color: #0E7C86; word-break: keep-all">11월 정기 점검 일정 안내 메일 다시 보내 주세요</textarea>
|
||||
<button type="button" aria-label="검색어 지우기" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #344054; cursor: pointer"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
|
||||
</header>
|
||||
<div style="flex: 0 0 auto; border-bottom: 1px solid #EEF0F3; background: #FBFBFC">
|
||||
<div style="display: flex; align-items: center; gap: 12px; height: 56px; box-sizing: border-box; padding: 0 16px">
|
||||
<span id="s11-scope" style="flex: 0 0 auto; font-size: 13px; font-weight: 700; color: #475467">찾을 곳</span>
|
||||
<div role="group" aria-labelledby="s11-scope" style="flex: 1 1 auto; min-width: 0; display: flex">
|
||||
<button type="button" aria-pressed="true" style="flex: 1 1 0; min-width: 0; height: 48px; display: flex; align-items: center; padding: 0; border: 0; background: transparent; font: inherit; cursor: pointer"><span style="flex: 1 1 auto; min-width: 0; display: block; height: 40px; box-sizing: border-box; padding: 0 12px; border: 1px solid #D0D5DD; border-radius: 20px 0 0 20px; background: #E3F2F3; color: #0A5F67; font-size: 14px; font-weight: 700; line-height: 38px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">받은편지함</span></button>
|
||||
<button type="button" aria-pressed="false" style="flex: 1 1 0; min-width: 0; height: 48px; display: flex; align-items: center; padding: 0; border: 0; background: transparent; font: inherit; cursor: pointer"><span style="flex: 1 1 auto; min-width: 0; display: block; height: 40px; box-sizing: border-box; padding: 0 12px; border: 1px solid #D0D5DD; border-left: 0; border-radius: 0 20px 20px 0; background: #FFFFFF; color: #344054; font-size: 14px; font-weight: 500; line-height: 38px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">전체 메일함</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 0 40px 120px; text-align: center">
|
||||
<span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 72px; height: 72px; border-radius: 50%; background: #F2F4F7; color: #667085"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg></span>
|
||||
<h2 style="margin: 4px 0 0; font-size: 15px; line-height: 1.5; font-weight: 650; word-break: keep-all">찾을 말이 너무 많습니다</h2>
|
||||
<p style="margin: 0; font-size: 13px; line-height: 1.6; color: #667085; word-break: keep-all">검색어의 낱말은 8개까지 찾을 수 있습니다. 낱말을 줄여 주세요.</p>
|
||||
</div>
|
||||
<nav aria-label="앱 메뉴" style="flex: 0 0 auto; display: flex; height: 72px; box-sizing: border-box; padding: 8px 0 10px; border-top: 1px solid #EEF0F3; background: #FFFFFF">
|
||||
<a href="#" aria-current="page" style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 0; border: 0; background: transparent; color: #0A5F67; font: inherit; font-size: 12.5px; font-weight: 700; text-decoration: none; cursor: pointer"><span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 56px; height: 32px; border-radius: 16px; background: #E3F2F3"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="20" height="16" rx="2"></rect><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"></path></svg></span><span>메일</span></a>
|
||||
<a href="App1Shell.dc.html" style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 0; border: 0; background: transparent; color: #4A5363; font: inherit; font-size: 12.5px; font-weight: 500; text-decoration: none; cursor: pointer"><span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 56px; height: 32px; border-radius: 16px; background: transparent"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg></span><span>설정</span></a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="s12" style="flex: 0 0 390px; display: flex; flex-direction: column; gap: 12px">
|
||||
<h2 style="margin: 0; height: 20px; font-size: 13px; line-height: 20px; font-weight: 700; color: #344054">결과에서 연 메일 (첨부는 본문 위)</h2>
|
||||
<div style="position: relative; width: 390px; height: 844px; border-radius: 28px; overflow: hidden; background: #FFFFFF; box-shadow: 0 0 0 1px #D0D5DD, 0 12px 32px rgba(16, 24, 28, 0.12); display: flex; flex-direction: column">
|
||||
<header style="flex: 0 0 auto; display: flex; align-items: center; gap: 2px; height: 56px; box-sizing: border-box; padding: 0 4px; background: #FFFFFF">
|
||||
<a href="App1Search.dc.html" aria-label="목록으로" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #344054; cursor: pointer"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg></a>
|
||||
<span style="flex: 1 1 auto"></span>
|
||||
<button type="button" aria-label="삭제" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #344054; cursor: pointer"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path><path d="M10 11v6"></path><path d="M14 11v6"></path><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"></path></svg></button>
|
||||
<button type="button" aria-label="더 보기" aria-haspopup="menu" aria-expanded="false" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #344054; cursor: pointer"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="5" r="1.3" fill="currentColor"></circle><circle cx="12" cy="12" r="1.3" fill="currentColor"></circle><circle cx="12" cy="19" r="1.3" fill="currentColor"></circle></svg></button>
|
||||
</header>
|
||||
<div style="flex: 1 1 auto; min-height: 0; overflow-y: auto">
|
||||
<article aria-labelledby="s12-subject">
|
||||
<h1 id="s12-subject" style="margin: 0; padding: 6px 20px 0; font-size: 20px; line-height: 1.4; font-weight: 650; letter-spacing: -0.015em">Re: 유지보수 <mark>계약</mark> 갱신 건</h1>
|
||||
<div style="display: flex; gap: 12px; padding: 14px 20px 0">
|
||||
<span aria-hidden="true" style="flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; background: #E3F2F3; color: #0A5F67; font-size: 16px; font-weight: 600">이</span>
|
||||
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column">
|
||||
<div style="display: flex; align-items: baseline; gap: 8px; padding-top: 1px">
|
||||
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; font-weight: 600">이도윤</span>
|
||||
<span style="flex: 0 0 auto; font-size: 12.5px; color: #667085; font-variant-numeric: tabular-nums">오전 10:42</span>
|
||||
</div>
|
||||
<button type="button" aria-expanded="false" style="align-self: flex-start; display: inline-flex; align-items: center; gap: 4px; min-height: 48px; margin: -10px 0 -6px -8px; padding: 0 8px; border: 0; border-radius: 8px; background: transparent; color: #4A5363; font: inherit; font-size: 13.5px; cursor: pointer">
|
||||
<span>받는 사람 나</span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>
|
||||
</button>
|
||||
<p style="margin: 0; display: flex; gap: 8px; font-size: 13px; line-height: 1.5"><span style="color: #667085">메일함</span><span style="color: #344054">받은편지함</span></p>
|
||||
</div>
|
||||
</div>
|
||||
<section aria-labelledby="s12-att" style="margin: 16px 16px 12px; padding-top: 16px; border-top: 1px solid #EEF0F3; display: flex; flex-direction: column; gap: 8px">
|
||||
<h2 id="s12-att" style="margin: 0 4px; font-size: 13px; font-weight: 600; color: #4A5363">첨부 파일 1개</h2>
|
||||
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px">
|
||||
<li style="display: flex; align-items: center; gap: 12px; min-height: 64px; box-sizing: border-box; padding: 8px 4px 8px 12px; border: 1px solid #E6E8EC; border-radius: 12px">
|
||||
<span aria-hidden="true" style="flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 8px; background: #F2F4F7; color: #4A5363"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path><polyline points="13 2 13 9 20 9"></polyline></svg></span>
|
||||
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; line-height: 1.35">
|
||||
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14.5px; font-weight: 500"><mark>계약</mark>서_초안_v2.pdf</span>
|
||||
<span style="font-size: 12.5px; color: #667085">248 KB</span>
|
||||
</div>
|
||||
<button type="button" aria-label="계약서_초안_v2.pdf 받기" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #344054; cursor: pointer"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg></button>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<div style="padding: 16px 20px 20px; font-size: 15px; line-height: 1.75; color: #1F2733; white-space: pre-line">도윤입니다.
|
||||
|
||||
말씀하신 조건으로 <mark style="background: #FCE7A4; color: inherit; border-radius: 2px; padding: 0 1px">계약</mark>서 초안을 고쳐 첨부했습니다. 기간은 1년이고, 단가는 작년과 같습니다.
|
||||
|
||||
<mark style="background: #FCE7A4; color: inherit; border-radius: 2px; padding: 0 1px">계약</mark> 시작일만 확인 부탁드립니다.</div>
|
||||
</article>
|
||||
</div>
|
||||
<div role="toolbar" aria-label="답장과 전달" style="flex: 0 0 auto; display: flex; height: 64px; border-top: 1px solid #E6E8EC; background: #FFFFFF">
|
||||
<a href="App1Compose.dc.html" style="flex: 1 1 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; color: #344054; font-size: 12.5px; font-weight: 600; text-decoration: none"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 14 4 9 9 4"></polyline><path d="M20 20v-7a4 4 0 0 0-4-4H4"></path></svg><span>답장</span></a>
|
||||
<a href="App1Compose.dc.html" style="flex: 1 1 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; color: #344054; font-size: 12.5px; font-weight: 600; text-decoration: none"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="7 17 2 12 7 7"></polyline><polyline points="12 17 7 12 12 7"></polyline><path d="M22 18v-2a4 4 0 0 0-4-4H7"></path></svg><span>전체 답장</span></a>
|
||||
<a href="App1Compose.dc.html" style="flex: 1 1 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; color: #344054; font-size: 12.5px; font-weight: 600; text-decoration: none"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 14 20 9 15 4"></polyline><path d="M4 20v-7a4 4 0 0 1 4-4h12"></path></svg><span>전달</span></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="flex: 0 0 390px; display: flex; flex-direction: column; gap: 12px">
|
||||
<h2 style="margin: 0; height: 20px; font-size: 13px; line-height: 20px; font-weight: 700; color: #344054">읽기 화면에서 지운 뒤</h2>
|
||||
<div style="position: relative; width: 390px; height: 844px; border-radius: 28px; overflow: hidden; background: #FFFFFF; box-shadow: 0 0 0 1px #D0D5DD, 0 12px 32px rgba(16, 24, 28, 0.12); display: flex; flex-direction: column">
|
||||
<header style="flex: 0 0 auto; display: flex; align-items: center; gap: 4px; height: 64px; box-sizing: border-box; padding: 0 4px; border-bottom: 1px solid #EEF0F3; background: #FFFFFF">
|
||||
<a href="App1MailList.dc.html" aria-label="검색 닫기" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #344054; cursor: pointer"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg></a>
|
||||
<input type="text" enterkeyhint="search" autocomplete="off" maxlength="200" aria-label="메일 검색" placeholder="메일 검색" value="계약" style="flex: 1 1 auto; min-width: 0; height: 48px; box-sizing: border-box; padding: 0 4px; border: 0; outline: none; background: transparent; font: inherit; font-size: 16px; color: #171C26; caret-color: #0E7C86">
|
||||
<button type="button" aria-label="검색어 지우기" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #344054; cursor: pointer"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
|
||||
</header>
|
||||
<div style="flex: 0 0 auto; border-bottom: 1px solid #EEF0F3; background: #FBFBFC">
|
||||
<div style="display: flex; align-items: center; gap: 12px; height: 56px; box-sizing: border-box; padding: 0 16px">
|
||||
<span id="s13-scope" style="flex: 0 0 auto; font-size: 13px; font-weight: 700; color: #475467">찾을 곳</span>
|
||||
<div role="group" aria-labelledby="s13-scope" style="flex: 1 1 auto; min-width: 0; display: flex">
|
||||
<button type="button" aria-pressed="true" style="flex: 1 1 0; min-width: 0; height: 48px; display: flex; align-items: center; padding: 0; border: 0; background: transparent; font: inherit; cursor: pointer"><span style="flex: 1 1 auto; min-width: 0; display: block; height: 40px; box-sizing: border-box; padding: 0 12px; border: 1px solid #D0D5DD; border-radius: 20px 0 0 20px; background: #E3F2F3; color: #0A5F67; font-size: 14px; font-weight: 700; line-height: 38px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">받은편지함</span></button>
|
||||
<button type="button" aria-pressed="false" style="flex: 1 1 0; min-width: 0; height: 48px; display: flex; align-items: center; padding: 0; border: 0; background: transparent; font: inherit; cursor: pointer"><span style="flex: 1 1 auto; min-width: 0; display: block; height: 40px; box-sizing: border-box; padding: 0 12px; border: 1px solid #D0D5DD; border-left: 0; border-radius: 0 20px 20px 0; background: #FFFFFF; color: #344054; font-size: 14px; font-weight: 500; line-height: 38px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">전체 메일함</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p role="status" style="flex: 0 0 auto; margin: 0; height: 36px; box-sizing: border-box; padding: 0 16px; font-size: 13px; line-height: 36px; color: #667085; font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis">{{ count13 }}</p>
|
||||
<ul aria-label="검색 결과" style="flex: 1 1 auto; min-height: 0; overflow-y: auto; list-style: none; margin: 0; padding: 0 0 96px">
|
||||
<sc-for list="{{ rows13 }}" as="r" hint-placeholder-count="3">
|
||||
<li style="border-bottom: 1px solid #F2F4F7; background: #FFFFFF">
|
||||
<a href="#s12" aria-label="{{ r.label }}" style="display: flex; align-items: flex-start; gap: 6px; padding: 10px 16px 10px 8px; color: inherit; text-decoration: none">
|
||||
<span aria-hidden="true" style="flex: 0 0 auto; width: 48px; height: 48px; display: inline-flex; align-items: center; justify-content: center"><span style="display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; background: #E3F2F3; color: #0A5F67; font-size: 16px; font-weight: 600">{{ r.initial }}</span></span>
|
||||
<span style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 1px; padding-top: 1px">
|
||||
<span style="display: flex; align-items: baseline; gap: 8px">
|
||||
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; font-weight: {{ r.fw }}; color: #171C26">{{ r.who }}</span>
|
||||
<span style="flex: 0 0 auto; display: flex; align-items: baseline">
|
||||
<sc-if value="{{ r.hasBox }}" hint-placeholder-val="{{ false }}"><span style="flex: 0 1 auto; min-width: 0; max-width: 124px; box-sizing: border-box; padding-right: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; color: #667085">{{ r.boxLabel }}</span></sc-if>
|
||||
<span style="flex: 0 0 auto; font-size: 12.5px; font-weight: {{ r.timeFw }}; color: {{ r.timeColor }}; font-variant-numeric: tabular-nums; white-space: nowrap">{{ r.time }}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span style="display: flex; align-items: center; gap: 6px; min-width: 0">
|
||||
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14.5px; font-weight: {{ r.subFw }}; color: {{ r.subColor }}"><sc-for list="{{ r.subjectParts }}" as="p" hint-placeholder-count="1"><sc-if value="{{ p.hit }}" hint-placeholder-val="{{ false }}"><mark>{{ p.t }}</mark></sc-if><sc-if value="{{ p.plain }}" hint-placeholder-val="{{ true }}"><span>{{ p.t }}</span></sc-if></sc-for></span>
|
||||
<sc-if value="{{ r.att }}" hint-placeholder-val="{{ false }}"><svg aria-hidden="true" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex: 0 0 auto"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path></svg></sc-if>
|
||||
<sc-if value="{{ r.flag }}" hint-placeholder-val="{{ false }}"><svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="#B7791F" stroke="#B7791F" stroke-width="1.6" stroke-linejoin="round" style="flex: 0 0 auto"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg></sc-if>
|
||||
</span>
|
||||
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13.5px; color: #667085"><sc-for list="{{ r.previewParts }}" as="p" hint-placeholder-count="1"><sc-if value="{{ p.hit }}" hint-placeholder-val="{{ false }}"><mark style="color: {{ p.fg }}">{{ p.t }}</mark></sc-if><sc-if value="{{ p.plain }}" hint-placeholder-val="{{ true }}"><span>{{ p.t }}</span></sc-if></sc-for></span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</sc-for>
|
||||
</ul>
|
||||
<nav aria-label="앱 메뉴" style="flex: 0 0 auto; display: flex; height: 72px; box-sizing: border-box; padding: 8px 0 10px; border-top: 1px solid #EEF0F3; background: #FFFFFF">
|
||||
<a href="#" aria-current="page" style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 0; border: 0; background: transparent; color: #0A5F67; font: inherit; font-size: 12.5px; font-weight: 700; text-decoration: none; cursor: pointer"><span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 56px; height: 32px; border-radius: 16px; background: #E3F2F3"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="20" height="16" rx="2"></rect><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"></path></svg></span><span>메일</span></a>
|
||||
<a href="App1Shell.dc.html" style="flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 0; border: 0; background: transparent; color: #4A5363; font: inherit; font-size: 12.5px; font-weight: 500; text-decoration: none; cursor: pointer"><span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 56px; height: 32px; border-radius: 16px; background: transparent"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg></span><span>설정</span></a>
|
||||
</nav>
|
||||
<sc-if value="{{ hasToast }}" hint-placeholder-val="{{ true }}">
|
||||
<div role="status" style="position: absolute; left: 12px; right: 12px; bottom: 84px; display: flex; align-items: center; gap: 8px; min-height: 52px; box-sizing: border-box; padding: 2px 4px 2px 16px; border-radius: 10px; background: #0B3640; color: #FFFFFF; font-size: 14.5px; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.22)">
|
||||
<span style="flex: 1 1 auto; min-width: 0">메일 1통을 휴지통으로 옮겼습니다.</span>
|
||||
<button type="button" onClick="{{ undo }}" style="flex: 0 0 auto; min-height: 48px; padding: 0 12px; border: 0; border-radius: 8px; background: transparent; color: #9ED6DB; font: inherit; font-size: 14.5px; font-weight: 600; cursor: pointer">되돌리기</button>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"previewMarkText":{"editor":"enum","options":["그대로 #667085","진하게 #4A5363"],"default":"진하게 #4A5363"},"$preview":{"width":2660,"height":1028}}'>
|
||||
class Component extends DCLogic {
|
||||
state = { b8: null, restored: false };
|
||||
|
||||
// 예시 메일 — ⟦ ⟧ 안이 맞은 말(바탕만 노랑 #FCE7A4). '견적' 셋은 계획서에 줄이 없어 App1MailList 의 사람으로 지었다
|
||||
mails() {
|
||||
return {
|
||||
junho: { who: '박준호', initial: '박', time: '오전 8:31', subject: '11월 인프라 점검 일정 조율', preview: '…교체할 장비의 ⟦견적⟧은 금요일까지 받아 두겠습니다.', unread: true, flag: true },
|
||||
jihoonQuote: { who: '박지훈', initial: '박', time: '11월 4일', subject: '⟦견적⟧서 보내 드립니다', preview: '안녕하세요, 박지훈입니다. 요청하신 ⟦견적⟧서를 첨부했습니다. 지난달 계약 기간에…', att: true, flag: true },
|
||||
seoyeon: { who: '최서연', initial: '최', time: '11월 3일', subject: '11월 비용 정산 안내', preview: '…외부 업체에서 받은 ⟦견적⟧서도 영수증과 함께 올려 주세요.', att: true },
|
||||
// 결과에서 열어 읽은 뒤 지운 메일 — [되돌리기]로 돌아오면 읽음(굵지 않게)으로 맨 위에 다시 들어간다
|
||||
doyunRead: { who: '이도윤', initial: '이', time: '오전 10:42', subject: 'Re: 유지보수 ⟦계약⟧ 갱신 건', preview: '말씀하신 조건으로 ⟦계약⟧서 초안을 고쳐 첨부했습니다. 기간은 1년이고, 단가는…', att: true },
|
||||
hr: { who: '인사팀', initial: '인', time: '오전 9:05', subject: '[공지] ⟦계약⟧직 채용 일정 안내', preview: '11월 ⟦계약⟧직 채용 일정을 알려 드립니다. 서류 접수는 11월 14일(금)까지…' },
|
||||
jihoon: { who: '박지훈', initial: '박', time: '11월 4일', subject: '견적서 보내 드립니다', preview: '…지난달 ⟦계약⟧ 기간에 맞춰 단가를 다시 계산했습니다. 확인 부탁드…', att: true, flag: true },
|
||||
doyunOld: { who: '이도윤', initial: '이', time: '10월 29일', subject: '회신: 유지보수 ⟦계약⟧ 갱신 건', preview: '네, 확인했습니다. 단가와 기간을 정리해서 다음 주 초에…' },
|
||||
};
|
||||
}
|
||||
|
||||
// 글을 ⟦ ⟧ 기준으로 조각낸다 — hit 는 노랑 바탕, fg 는 강조 안의 글자색
|
||||
parts(text, fg) {
|
||||
const out = [];
|
||||
let rest = text;
|
||||
while (rest.length > 0) {
|
||||
const open = rest.indexOf('⟦');
|
||||
if (open < 0) {
|
||||
out.push({ t: rest, hit: false, plain: true, fg: fg });
|
||||
break;
|
||||
}
|
||||
if (open > 0) out.push({ t: rest.slice(0, open), hit: false, plain: true, fg: fg });
|
||||
const close = rest.indexOf('⟧', open + 1);
|
||||
const end = close < 0 ? rest.length : close;
|
||||
out.push({ t: rest.slice(open + 1, end), hit: true, plain: false, fg: fg });
|
||||
rest = close < 0 ? '' : rest.slice(close + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
plain(text) {
|
||||
return text.split('⟦').join('').split('⟧').join('');
|
||||
}
|
||||
|
||||
// 목록 줄 하나 — App1MailList 의 줄 그대로(사진은 단추가 아니다)
|
||||
row(m, markFg) {
|
||||
const unread = !!m.unread;
|
||||
const box = m.box || '';
|
||||
return {
|
||||
who: m.who,
|
||||
initial: m.initial,
|
||||
time: m.time,
|
||||
hasBox: box !== '',
|
||||
boxLabel: box !== '' ? box + ' ·' : '',
|
||||
att: !!m.att,
|
||||
flag: !!m.flag,
|
||||
fw: unread ? '650' : '450',
|
||||
subFw: unread ? '600' : '400',
|
||||
subColor: unread ? '#171C26' : '#344054',
|
||||
timeFw: unread ? '600' : '400',
|
||||
timeColor: unread ? '#171C26' : '#667085',
|
||||
subjectParts: this.parts(m.subject, 'inherit'),
|
||||
previewParts: this.parts(m.preview, markFg),
|
||||
// 낭독: 안 읽음, 보낸 사람, 메일함, 시각, 제목, 첨부 있음, 중요, 미리 보기 — 강조는 읽지 않는다
|
||||
label: [unread ? '안 읽음' : '', m.who, box, m.time, this.plain(m.subject), m.att ? '첨부 있음' : '', m.flag ? '중요' : '', this.plain(m.preview)].filter((s) => s !== '').join(', '),
|
||||
};
|
||||
}
|
||||
|
||||
renderVals() {
|
||||
// 미리 보기 강조 안 글자 — 기본은 진하게(#4A5363 = 웹 --color-text-secondary, #FCE7A4 위 6.3:1). '그대로'는 #667085(4.05:1)
|
||||
const strong = (this.props.previewMarkText ?? '진하게 #4A5363') === '진하게 #4A5363';
|
||||
const markFg = strong ? '#4A5363' : 'inherit';
|
||||
const row = (m) => this.row(m, markFg);
|
||||
const M = this.mails();
|
||||
const mode = this.state.b8 ?? 'before';
|
||||
const restored = !!this.state.restored;
|
||||
const MODES = [
|
||||
{ id: 'before', label: '누르기 전' },
|
||||
{ id: 'after', label: '누른 뒤' },
|
||||
];
|
||||
return {
|
||||
b8Before: mode !== 'after',
|
||||
b8After: mode === 'after',
|
||||
b8Title: mode === 'after' ? '아직 못 찾음 · [더 찾기]를 누른 뒤' : '아직 못 찾음',
|
||||
b8Press: () => this.setState({ b8: 'after' }),
|
||||
b8Modes: MODES.map((x) => {
|
||||
const on = x.id === mode;
|
||||
return {
|
||||
label: x.label,
|
||||
pressed: on ? 'true' : 'false',
|
||||
pick: () => this.setState({ b8: x.id }),
|
||||
bg: on ? '#E3F2F3' : '#FFFFFF',
|
||||
fg: on ? '#0A5F67' : '#344054',
|
||||
border: on ? '#0E7C86' : '#D0D5DD',
|
||||
};
|
||||
}),
|
||||
rows9: [M.junho, M.jihoonQuote, M.seoyeon].map(row),
|
||||
rows13: (restored ? [M.doyunRead, M.hr, M.jihoon, M.doyunOld] : [M.hr, M.jihoon, M.doyunOld]).map(row),
|
||||
count13: restored ? '‘계약’ · 4통' : '‘계약’ · 3통',
|
||||
hasToast: !restored,
|
||||
undo: () => this.setState({ restored: true }),
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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": []
|
||||
}
|
||||
|
||||
@@ -611,6 +611,16 @@ interface RejectedRecipient {
|
||||
원문은 `htmlBody`(없으면 `textBody`)를 편집기로 옮기며, 편집기가 모르는 요소(이미지 등)는 빠진다.
|
||||
- **본문 안 이미지는 이번 단계에서 넣지 않는다** — 붙여 넣거나 끌어다 놓은 이미지는 첨부 파일로 올린다 (첨부 한도 규칙 그대로).
|
||||
서명 그림만 예외다 (아래 "서명").
|
||||
- **앱이 만드는 모양**(2026-11-07 — 앱 쓰기 화면은 글만 쓴다. 웹이 다시 열 때 인용을 알아보는 모양이다):
|
||||
- 감싸개는 위 기본 글꼴 div 이고, 내 글은 줄마다 `<p style="margin: 0">`(빈 줄은 `<p style="margin: 0"><br></p>`, 여러 칸 공백 · 줄 앞 공백 · 탭은 위 nbsp 규칙)이다.
|
||||
- 서명이 있으면 빈 문단 하나 뒤의 **속성 없는 `<div>` 하나**가 서명 경계다 — 안은 서버가 준 서명 HTML 이고, margin 이 없는 `p` 에만 `margin: 0` 을 앞에 붙인다.
|
||||
- 원문 인용은 웹과 같은 "원본 메일" 머리 문단 + 머리 표(답장은 보낸 사람 · 날짜 · 제목, 전달은 받는 사람 · 참조도 — 날짜는 "2026. 11. 5. (목) 오전 10:42")에
|
||||
**글로 만든 원문 줄**(`<p style="margin: 0">` — 원문 속 인용은 안쪽 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단계 (같은 대화 · 대화로 묶어 보기)
|
||||
|
||||
@@ -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`).
|
||||
|
||||
## 라이선스
|
||||
|
||||
|
||||
@@ -12,3 +12,7 @@ GeneratedPluginRegistrant.java
|
||||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
|
||||
# 새 메일 알림(FCM)의 Firebase 설정 — 저장소에 넣지 않는다(이 템플릿을 클론한 곳마다 우리 Firebase 프로젝트를 가리키게 된다).
|
||||
# 저장소 밖에 둔 파일을 본 트리 · worktree 마다 복사해 둔다(flutter/README "새 메일 알림")
|
||||
app/google-services.json
|
||||
|
||||
@@ -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 \"새 메일 알림\").",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- API 서버에 닿으려면 release 판에도 있어야 한다 (debug · profile 매니페스트에만 있던 것을 옮겼다) -->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<!--
|
||||
새 메일 알림 — 안드로이드 13 이상은 알림을 보이려면 이 권한을 직원이 허락해야 한다(firebase_messaging 매니페스트에도 있다 — 뜻을 보이려고 적는다).
|
||||
묻는 창은 [알림 켜기]를 누를 때만 띄운다(core/push/push_controller.dart)
|
||||
-->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<!-- 광고 ID 를 쓰지 않는다 — 라이브러리 매니페스트가 넣어도 뺀다(Play 의 광고 ID 선언을 "아니요"로) -->
|
||||
<uses-permission android:name="com.google.android.gms.permission.AD_ID" tools:node="remove"/>
|
||||
<!--
|
||||
백업 끔: 리프레시 토큰은 flutter_secure_storage 가 이 기기의 Keystore 키로 암호화해 둔다.
|
||||
Google 드라이브 백업 · 기기 옮기기로 다른 기기에 복원되면 키가 없어 읽지 못하고(플러그인 README),
|
||||
@@ -10,6 +18,7 @@
|
||||
android:label="ARA SYSTEM"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:allowBackup="false"
|
||||
android:fullBackupContent="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules">
|
||||
@@ -35,6 +44,47 @@
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!--
|
||||
받은 첨부를 다른 앱으로 열 때 건네는 주소(content://) — 앱 임시 폴더의 downloads/ 안 파일만(res/xml/downloaded_file_paths.xml).
|
||||
밖에 열지 않고(exported=false), 연 앱에 그 파일 하나의 읽기 권한만 준다(MainActivity.kt 의 파일 채널)
|
||||
-->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.downloads"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/downloaded_file_paths" />
|
||||
</provider>
|
||||
<!--
|
||||
새 메일 알림(FCM) — 앱이 뒤에 있거나 꺼져 있으면 FCM 이 직접 띄운다. 서버 메시지에 없는 값을 여기서 채운다.
|
||||
채널: 짝 — NewMailNotifications.kt(채널을 만든다) · 서버 channel_id. 아이콘 · 색은 서버가 싣지 않아 늘 이 기본값이다
|
||||
(색: 짝 — Dart AppColors.brand #0E7C86. 작은 아이콘을 바꾸려면 drawable 의 ic_stat_ara 그림만 바꾼다 — flutter/README "앱 아이콘 · 알림 아이콘")
|
||||
-->
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_channel_id"
|
||||
android:value="@string/new_mail_channel_id" />
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_icon"
|
||||
android:resource="@drawable/ic_stat_ara" />
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_color"
|
||||
android:resource="@color/notification_accent" />
|
||||
<!-- 로그인 전에는 FCM 에 등록하지 않는다 — 앱이 register() 를 부를 때만(core/push/push_controller.dart) -->
|
||||
<meta-data
|
||||
android:name="firebase_messaging_auto_init_enabled"
|
||||
android:value="false" />
|
||||
<!--
|
||||
FID 등록을 켠다 — 이 표시가 없으면 FirebaseMessaging 의 register() · unregister() 가 맨 앞에서
|
||||
"API disabled" 로 곧바로 실패한다(firebase_core 5.1.0 의 BoM 35.1.0 → firebase-messaging 26.0.1 에서 확인 — 플러그인 README · CHANGELOG 에는 없고 예제 매니페스트에만 있다).
|
||||
켜면 거꾸로 옛 getToken() · deleteToken() 이 같은 "API disabled" 로 거절된다 — 둘을 함께 쓰지 않는다(앱은 register() · unregister() 만 쓴다).
|
||||
표시가 있어도 FID 길은 Google Play 서비스 26.12(versionCode 261200000) 이상에서만 탄다 — 그 아래는 SDK 가 옛 토큰 길로 돌아간다
|
||||
(docs/design/app/README.md "새 메일 알림" 의 실기기 줄 — 알려진 한계)
|
||||
-->
|
||||
<meta-data
|
||||
android:name="firebase_messaging_installation_id_enabled"
|
||||
android:value="true" />
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
|
||||
@@ -1,5 +1,521 @@
|
||||
package kr.it.arasystem.app
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.ContentResolver
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.core.content.FileProvider
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.security.SecureRandom
|
||||
import java.util.Locale
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
/**
|
||||
* 앱 화면 하나 — Flutter 가 그린다.
|
||||
*
|
||||
* 파일 채널(`kr.it.arasystem.app/files`) — 권한 · 새 플러그인 없이 앱 임시 폴더 두 곳만 쓴다:
|
||||
* - `open`: 앱이 받은 파일(메일 첨부)을 다른 앱으로 연다 — Dart `core/files/downloaded_files.dart` 의 `PlatformFileOpener` 가 부른다.
|
||||
* 앱 임시 폴더의 [DOWNLOADS_DIR] 안 파일만, FileProvider 주소와 읽기 권한 1회로 `ACTION_VIEW` 한다.
|
||||
* 결과는 글 하나 — opened · noApp · notFound · failed.
|
||||
* - `pick`: 고르는 창(`ACTION_GET_CONTENT` — 모든 형식 · 여러 개)으로 고른 파일 가운데 넣을 파일만 앱 임시 폴더의 [UPLOADS_DIR] 에 복사한다 —
|
||||
* Dart `core/files/picked_files.dart` 의 `PlatformPickedFileStore` 가 부른다. 고르는 창에서는 형식으로 거르지 않는다(hwp 가 숨는다).
|
||||
* 고른 뒤 Dart 사전 검사와 같은 차례(개수 → 형식 → 파일당 → 합계)로 미리 걸러 넣지 않을 파일은 읽지 않는다 — 판정은 Dart 가 다시 한다.
|
||||
* 결과는 `{outcome, files: [{name, size, status, path}]}` — 짝: Dart `PickResult.parse` · `PickedFile.fromChannel`.
|
||||
* 경로 · 주소 · 파일 이름은 로그에 남기지 않는다.
|
||||
*
|
||||
* 알림 채널(`kr.it.arasystem.app/notifications`)은 [NewMailNotifications] 가 맡는다 — 새 메일 알림 채널도 화면을 만들 때 그것이 만든다.
|
||||
*/
|
||||
class MainActivity : FlutterActivity() {
|
||||
/** 고르는 창이 열려 있는 동안 기다리는 답 — 한 번에 하나 */
|
||||
private var pendingPick: PendingPick? = null
|
||||
|
||||
/** 고르기 한 번의 한도 — Dart 가 넘긴다(짝: `PlatformPickedFileStore.pick` 의 인자) */
|
||||
private class PickLimits(
|
||||
/** 파일 하나의 한도 — 그 + 1바이트를 넘으면 복사를 멈춘다 */
|
||||
val maxBytes: Long,
|
||||
/** 넣을 수 있는 남은 개수 */
|
||||
val maxCount: Int,
|
||||
/** 넣을 수 있는 남은 합계 바이트 */
|
||||
val maxTotalBytes: Long,
|
||||
/** 허용 확장자(소문자, 점 없이) — Dart 의 형식 표 */
|
||||
val extensions: Set<String>,
|
||||
)
|
||||
|
||||
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<String>("path"), call.argument<String>("type")),
|
||||
)
|
||||
PICK_METHOD -> pickFiles(
|
||||
limitsOf(
|
||||
call.argument<Number>(ARG_MAX_BYTES),
|
||||
call.argument<Number>(ARG_MAX_COUNT),
|
||||
call.argument<Number>(ARG_MAX_TOTAL_BYTES),
|
||||
call.argument<List<*>>(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<String>().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<Map<String, Any?>>()
|
||||
val reply: Map<String, Any?> = 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<Uri> {
|
||||
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<Uri>, limits: PickLimits, files: MutableList<Map<String, Any?>>) {
|
||||
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<String, Any?>, name: String?, bytes: Long, limits: PickLimits): Map<String, Any?> {
|
||||
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<String?, Long?> {
|
||||
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<String, Any?> {
|
||||
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<String, Any?> =
|
||||
mapOf(KEY_NAME to name, KEY_SIZE to size, KEY_STATUS to status, KEY_PATH to path)
|
||||
|
||||
private fun outcomeOf(outcome: String): Map<String, Any?> = 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String, Any> = 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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 739 B |
|
After Width: | Height: | Size: 461 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
앱 아이콘(안드로이드 8 이상 — 적응형) — 앱 보드 App1Push ⑪ "나. 남색 바탕"(2026-11-07 확정).
|
||||
바탕은 #0B3640, 앞면은 흰 ARA 마크(108dp 칸의 보이는 72dp 가운데에 폭 46.5dp — 잘리지 않는 66dp 동그라미 안).
|
||||
테마 아이콘(안드로이드 13 이상)은 앞면의 모양만 휴대폰 색으로 칠한다(monochrome).
|
||||
그림은 docs/brand/logo_arasystem_white.png 에서 만들었다(만드는 스크립트는 저장소 밖 — flutter/README "앱 아이콘").
|
||||
안드로이드 7.x 는 mipmap-*/ic_launcher.png · ic_launcher_round.png 를 쓴다(같은 모양).
|
||||
-->
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
앱 아이콘(안드로이드 8 이상 — 적응형) — 앱 보드 App1Push ⑪ "나. 남색 바탕"(2026-11-07 확정).
|
||||
바탕은 #0B3640, 앞면은 흰 ARA 마크(108dp 칸의 보이는 72dp 가운데에 폭 46.5dp — 잘리지 않는 66dp 동그라미 안).
|
||||
테마 아이콘(안드로이드 13 이상)은 앞면의 모양만 휴대폰 색으로 칠한다(monochrome).
|
||||
그림은 docs/brand/logo_arasystem_white.png 에서 만들었다(만드는 스크립트는 저장소 밖 — flutter/README "앱 아이콘").
|
||||
안드로이드 7.x 는 mipmap-*/ic_launcher.png · ic_launcher_round.png 를 쓴다(같은 모양).
|
||||
-->
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- 새 메일 알림의 강조 색(알림창의 작은 아이콘 둘레 · 앱 이름) — 짝: Dart AppColors.brand(#0E7C86) -->
|
||||
<color name="notification_accent">#0E7C86</color>
|
||||
<!-- 앱 아이콘 바탕 — 남색(웹의 탭 · 홈 화면 아이콘과 같은 #0B3640 — 짝: Dart AppColors.brandSurface) -->
|
||||
<color name="ic_launcher_background">#0B3640</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- 새 메일 알림 채널 id — 짝: 서버 channel_id · 매니페스트의 기본 채널(default_notification_channel_id) · NewMailNotifications.kt -->
|
||||
<string name="new_mail_channel_id" translatable="false">new_mail</string>
|
||||
<!--
|
||||
휴대폰 설정의 알림 카테고리에 보이는 채널 이름 · 설명 — 앱 보드 App1Push ⑫ 의 글 그대로(화면 문구다 — 디자인 README 「화면 문구」).
|
||||
채널을 다시 만들 때 이름 · 설명은 바뀐다(중요도는 바뀌지 않는다)
|
||||
-->
|
||||
<string name="new_mail_channel_name">새 메일</string>
|
||||
<string name="new_mail_channel_description">새 메일이 오면 알려 드립니다.</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
앱이 다른 앱에 건네는 파일의 자리 — 앱 임시 폴더(cacheDir)의 downloads/ 한 곳뿐이다 (받은 메일 첨부).
|
||||
짝: Dart core/files/downloaded_files.dart 의 kDownloadedFilesFolder · MainActivity.kt 의 DOWNLOADS_DIR
|
||||
-->
|
||||
<paths>
|
||||
<cache-path
|
||||
name="downloads"
|
||||
path="downloads/" />
|
||||
</paths>
|
||||
@@ -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")
|
||||
|
||||
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 62 KiB |
@@ -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({
|
||||
|
||||
@@ -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<String> noRefresh = <String>{
|
||||
appLogin,
|
||||
appRefresh,
|
||||
appLogout,
|
||||
appConfig,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -68,6 +68,12 @@ final Provider<Dio> dioProvider = Provider<Dio>(
|
||||
(Ref ref) => ref.watch(dioClientProvider).dio,
|
||||
);
|
||||
|
||||
/// 로그인 없는 공개 요청의 Dio — 인증 인터셉터 줄 밖(Bearer · 갱신 · 알림 없음, 언래핑 · 코드 정규화만).
|
||||
/// 앱을 띄우자마자 세션 되살리기와 나란히 묻는 앱 설정(`GET /api/app-config`)이 쓴다
|
||||
final Provider<Dio> publicDioProvider = Provider<Dio>(
|
||||
(Ref ref) => ref.watch(dioClientProvider).publicDio,
|
||||
);
|
||||
|
||||
/// 인증 인터셉터 줄 밖의 인증 요청(갱신은 한 번에 하나) — 인증 저장소 · 세션 노티파이어도 이것을 지난다
|
||||
final Provider<TokenRefresher> tokenRefresherProvider =
|
||||
Provider<TokenRefresher>(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<Object?>? 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;
|
||||
}
|
||||
|
||||
@@ -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<void> 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<Object?>? response = err.response;
|
||||
if (response != null) {
|
||||
final Map<String, Object?>? 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<int>`) · 스트림(`ResponseBody`) 실패 본문 → JSON 객체. 이미 풀린 본문 · 너무 큼 · JSON 이 아님은 null.
|
||||
/// 스트림은 끝까지(한도 안) 읽어 연결을 놓아 준다
|
||||
Future<Map<String, Object?>?> decodeRawErrorBody(Object? data) async {
|
||||
final List<int>? bytes;
|
||||
if (data is ResponseBody) {
|
||||
bytes = await _readLimited(data.stream);
|
||||
} else if (data is List<int>) {
|
||||
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<String, Object?> ? json : null;
|
||||
} on FormatException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<int>?> _readLimited(Stream<List<int>> stream) async {
|
||||
final BytesBuilder builder = BytesBuilder(copy: false);
|
||||
try {
|
||||
await for (final List<int> chunk in stream) {
|
||||
builder.add(chunk);
|
||||
// 한도를 넘으면 그만 읽는다(구독을 끊어 연결을 놓는다) — 코드는 상태로 정한다
|
||||
if (builder.length > kMaxErrorBodyBytes) return null;
|
||||
}
|
||||
} on Object {
|
||||
return null;
|
||||
}
|
||||
return builder.takeBytes();
|
||||
}
|
||||
|
||||
@@ -52,7 +52,8 @@ final class RefreshDiscarded extends RefreshOutcome {
|
||||
|
||||
/// 앱 logout 요청 — 리프레시 토큰이 있으면 본문에, 액세스 토큰이 있으면 Bearer 로도 싣는다
|
||||
/// (계약 1절 "앱 로그인": 서버는 리프레시 토큰으로 세션을 찾고, 없거나 틀리면 액세스 토큰의 `sid` 로 끝낸다 — 둘 다 없으면 빈 본문 `{}`).
|
||||
/// 알림은 띄우지 않는다. 실패는 `DioException`(본문을 비운 것)으로 던진다.
|
||||
/// 알림은 띄우지 않는다. 실패는 `DioException` 으로 던진다 — 기본 Dio(인증 인터셉터가 있는 것)로 부르면 본문 · Bearer 를 지운 채 오고,
|
||||
/// 안쪽 Dio 로 부르는 [TokenRefresher.logoutQuietly] 는 예외를 밖으로 내지 않는다.
|
||||
Future<void> postAppLogout(
|
||||
Dio dio, {
|
||||
required String? refreshToken,
|
||||
|
||||
@@ -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<String> _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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 앱 판 — 숫자 셋(major.minor.patch). 판 이름(`PackageInfo.version` — pubspec 의 build-name, User-Agent 와 같은 값)을 견준다
|
||||
@immutable
|
||||
class AppVersion implements Comparable<AppVersion> {
|
||||
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<String> parts = value.split('.');
|
||||
if (parts.isEmpty || parts.length > 3) return null;
|
||||
final List<int> numbers = <int>[];
|
||||
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<String, Object?>) return const AppConfig();
|
||||
final Object? versions = data['minSupportedVersion'];
|
||||
final Object? minimum = versions is Map<String, Object?>
|
||||
? 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;
|
||||
}
|
||||
@@ -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<AppConfigRepository> appConfigRepositoryProvider =
|
||||
Provider<AppConfigRepository>(
|
||||
(Ref ref) => AppConfigRepository(ref.watch(publicDioProvider)),
|
||||
);
|
||||
|
||||
/// 앱 설정 — 앱에 하나. 라우터(최소 판)와 새 메일 알림(`pushAvailable`)이 본다.
|
||||
final NotifierProvider<AppConfigController, AppConfigState> appConfigProvider =
|
||||
NotifierProvider<AppConfigController, AppConfigState>(
|
||||
AppConfigController.new,
|
||||
retry: noAutomaticRetry,
|
||||
);
|
||||
|
||||
/// 앱 설정을 묻는다 — 앱을 띄울 때 한 번(세션 되살리기와 나란히 — 기다리지 않는다), 앞으로 올 때 마지막으로 받은 지 1시간이 지났으면.
|
||||
///
|
||||
/// 실패(끊긴 망 · 5xx)는 알리지 않고 막지 않는다 — 앞으로 올 때 1분 간격으로 다시. 404(그 경로가 없는 옛 서버)는 받은 것으로 본다
|
||||
/// (최소 판 없음 · 알림 쓸 수 없음). 모양이 틀리면 막지 않는다.
|
||||
class AppConfigController extends Notifier<AppConfigState> {
|
||||
Future<void>? _loading;
|
||||
|
||||
@override
|
||||
AppConfigState build() {
|
||||
ref.listen<bool>(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<void> refresh() {
|
||||
final Future<void>? running = _loading;
|
||||
if (running != null) return running;
|
||||
final Future<void> 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<void> _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<bool> appUpdateRequiredProvider = Provider<bool>(
|
||||
(Ref ref) => isAppUpdateRequired(
|
||||
appVersion: ref.watch(appVersionProvider),
|
||||
minimum: ref.watch(appConfigProvider).config?.minSupportedVersion,
|
||||
),
|
||||
);
|
||||
|
||||
/// 서버가 새 메일 알림을 보낼 수 있는가 — 모르면 null(쓸 수 있는 것으로 본다)
|
||||
final Provider<bool?> pushAvailableProvider = Provider<bool?>(
|
||||
(Ref ref) => ref.watch(appConfigProvider).config?.pushAvailable,
|
||||
);
|
||||
@@ -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<AppConfig> fetch() async {
|
||||
try {
|
||||
final Response<Object?> response = await _dio.get<Object?>(
|
||||
ApiPaths.appConfig,
|
||||
options: ApiOptions.silent(),
|
||||
);
|
||||
return AppConfig.fromJson(response.data);
|
||||
} on DioException catch (error) {
|
||||
if (apiErrorCodeOf(error) == _notFoundCode) return AppConfig.notProvided;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -5,3 +5,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
final Provider<String?> appVersionProvider = Provider<String?>(
|
||||
(Ref ref) => null,
|
||||
);
|
||||
|
||||
/// 앱 식별자(패키지 이름 — Android applicationId `kr.it.arasystem.app`). main 이 기기 정보를 읽어 덮어쓴다.
|
||||
/// 업데이트 화면의 [Play 스토어 열기]가 쓴다 — 읽지 못했으면 null(단추를 누르면 열지 못했다고 알린다)
|
||||
final Provider<String?> appPackageNameProvider = Provider<String?>(
|
||||
(Ref ref) => null,
|
||||
);
|
||||
|
||||
@@ -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단계 인증을 켰습니다. 메일 프로그램은 앱 비밀번호로 연결합니다.';
|
||||
}
|
||||
@@ -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 = '앱을 업데이트해 주세요';
|
||||
}
|
||||
|
||||
@@ -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<int> chunk);
|
||||
|
||||
/// 다 받았다 — 쓴 것을 마저 내보내고 닫는다
|
||||
Future<void> close();
|
||||
|
||||
/// 받다 말았다(실패 · 취소) — 닫고 지운다
|
||||
Future<void> discard();
|
||||
}
|
||||
|
||||
/// 받은 파일을 잠깐 두는 곳 — 앱이 다른 앱에 파일을 건네려고 둔다(모듈 공통).
|
||||
///
|
||||
/// 정리하는 때: 앱을 띄울 때(main — 지난 실행이 남긴 것) · 메일을 보는 직원이 바뀔 때(로그아웃 · 세션 끝 · 다른 직원) ·
|
||||
/// 기기가 저장 공간이 모자랄 때(안드로이드가 임시 폴더를 비운다). 다른 앱이 아직 읽는 중일 수 있어 연 직후에는 지우지 않는다.
|
||||
abstract class DownloadedFileStore {
|
||||
/// 새 파일 자리 — 받을 때마다 새 폴더 하나(같은 이름이어도 덮지 않는다). 이름은 [safeFileName] 으로 정리한다
|
||||
Future<DownloadTarget> create(String fileName);
|
||||
|
||||
/// 받아 둔 파일이 아직 있는가 (기기가 임시 폴더를 비웠을 수 있다)
|
||||
Future<bool> exists(String path);
|
||||
|
||||
/// 모두 지운다
|
||||
Future<void> purge();
|
||||
}
|
||||
|
||||
/// 받은 파일을 두는 곳 — 앱 임시 폴더의 [kDownloadedFilesFolder] 아래
|
||||
final Provider<DownloadedFileStore> downloadedFileStoreProvider =
|
||||
Provider<DownloadedFileStore>((Ref ref) => CacheDirFileStore());
|
||||
|
||||
/// 앱 임시 폴더(path_provider `getTemporaryDirectory` — Android `cacheDir`)를 쓰는 [DownloadedFileStore].
|
||||
///
|
||||
/// 지우기와 새 자리 만들기는 부른 차례대로 하나씩 한다 — 앱을 띄울 때의 정리가 막 만든 자리를 지우지 않게.
|
||||
class CacheDirFileStore implements DownloadedFileStore {
|
||||
CacheDirFileStore({
|
||||
Future<Directory> Function() baseDirectory = getTemporaryDirectory,
|
||||
Random? random,
|
||||
}) : _baseDirectory = baseDirectory,
|
||||
_random = random ?? Random.secure();
|
||||
|
||||
final Future<Directory> Function() _baseDirectory;
|
||||
final Random _random;
|
||||
Future<void> _queue = Future<void>.value();
|
||||
|
||||
/// 받을 때마다 만드는 폴더 이름의 길이(16진수)
|
||||
static const int _folderNameLength = 16;
|
||||
|
||||
Future<T> _serial<T>(Future<T> Function() task) {
|
||||
final Future<T> next = _queue.then((_) => task());
|
||||
_queue = next.then<void>((_) {}, onError: (Object _) {});
|
||||
return next;
|
||||
}
|
||||
|
||||
/// 받은 파일 폴더 — 임시 폴더를 알 수 없으면(플러그인 · 기기 사정) 저장하지 못한 것으로 다룬다([FileSystemException])
|
||||
Future<Directory> _root() async {
|
||||
final Directory base;
|
||||
try {
|
||||
base = await _baseDirectory();
|
||||
} on FileSystemException {
|
||||
rethrow;
|
||||
} on Exception {
|
||||
throw const FileSystemException('임시 폴더를 찾지 못했습니다');
|
||||
}
|
||||
return Directory('${base.path}/$kDownloadedFilesFolder');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<DownloadTarget> 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<bool> exists(String path) => File(path).exists();
|
||||
|
||||
@override
|
||||
Future<void> purge() => _serial(() async {
|
||||
try {
|
||||
final Directory root = await _root();
|
||||
if (await root.exists()) await root.delete(recursive: true);
|
||||
} on FileSystemException {
|
||||
// 임시 폴더를 모르거나 다른 앱이 열고 있는 등 — 다음 정리 · 기기가 지운다
|
||||
}
|
||||
});
|
||||
|
||||
String _folderName() => List<String>.generate(
|
||||
_folderNameLength,
|
||||
(_) => _random.nextInt(16).toRadixString(16),
|
||||
).join();
|
||||
}
|
||||
|
||||
class _FileTarget implements DownloadTarget {
|
||||
_FileTarget(this._file, this._sink) {
|
||||
// 쓰기가 실패하면(폴더가 지워짐 · 저장 공간) 그 실패는 close · discard 가 받는다 — 그 전에 잡히지 않은 오류로 알려지지 않게
|
||||
unawaited(_sink.done.then<void>((_) {}, onError: (Object _) {}));
|
||||
}
|
||||
|
||||
final File _file;
|
||||
final IOSink _sink;
|
||||
bool _closed = false;
|
||||
|
||||
@override
|
||||
String get path => _file.path;
|
||||
|
||||
@override
|
||||
void add(List<int> chunk) => _sink.add(chunk);
|
||||
|
||||
/// 닫기가 남은 쓰기를 마저 내보낸다 — `flush` 는 부르지 않는다(쓰기가 이미 실패한 IOSink 의 flush 는 끝나지 않는다.
|
||||
/// close 는 그 실패를 [FileSystemException] 으로 돌려준다)
|
||||
@override
|
||||
Future<void> close() async {
|
||||
if (_closed) return;
|
||||
_closed = true;
|
||||
await _sink.close();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> 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<FileOpenResult> open({required String path, required String mimeType});
|
||||
}
|
||||
|
||||
final Provider<FileOpener> fileOpenerProvider = Provider<FileOpener>(
|
||||
(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<FileOpenResult> open({
|
||||
required String path,
|
||||
required String mimeType,
|
||||
}) async {
|
||||
try {
|
||||
final String? result = await channel.invokeMethod<String>(
|
||||
openMethod,
|
||||
<String, Object?>{'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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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<String> kBlockedOpenExtensions = <String>{
|
||||
// 웹 문서 — 브라우저가 열면 스크립트가 돈다
|
||||
'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<String> kBlockedOpenTypes = <String>{
|
||||
// 웹 문서 · 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);
|
||||
}
|
||||
@@ -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<Object?, Object?>) {
|
||||
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 <PickedFile>[]]);
|
||||
|
||||
final PickOutcome outcome;
|
||||
|
||||
/// 고른 차례대로 — [PickOutcome.picked] 일 때만
|
||||
final List<PickedFile> files;
|
||||
|
||||
/// 채널의 답 — 모양이 틀리면 실패
|
||||
static PickResult parse(Object? raw) {
|
||||
if (raw is! Map<Object?, Object?>) {
|
||||
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<Object?>) return const PickResult(PickOutcome.failed);
|
||||
return PickResult(
|
||||
PickOutcome.picked,
|
||||
List<PickedFile>.unmodifiable(<PickedFile>[
|
||||
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<PickResult> pick({
|
||||
required int maxBytes,
|
||||
required int maxCount,
|
||||
required int maxTotalBytes,
|
||||
required List<String> extensions,
|
||||
});
|
||||
|
||||
/// 복사본이 아직 있는가 — 복사본 폴더 밖의 경로는 거짓(기기가 임시 폴더를 비웠을 수 있다)
|
||||
Future<bool> exists(String path);
|
||||
|
||||
/// 복사본을 읽는다(올리기 — 부를 때마다 처음부터)
|
||||
Stream<List<int>> openRead(String path);
|
||||
|
||||
/// 복사본 하나를 그 폴더째 지운다 — 복사본 폴더 밖은 지우지 않는다
|
||||
Future<void> delete(String path);
|
||||
|
||||
/// 모두 지운다
|
||||
Future<void> purge();
|
||||
}
|
||||
|
||||
/// 고른 파일을 두는 곳 — main 이 앱을 띄울 때 정리한 것으로 덮어쓴다
|
||||
final Provider<PickedFileStore> pickedFileStoreProvider =
|
||||
Provider<PickedFileStore>((Ref ref) => PlatformPickedFileStore());
|
||||
|
||||
/// 안드로이드 고르는 창(`MainActivity.kt` 의 파일 채널 `pick`) + 앱 임시 폴더의 [kPickedFilesFolder].
|
||||
///
|
||||
/// 고르기와 모두 지우기는 부른 차례대로 하나씩 한다 — 고르는 동안 직원이 바뀌어 지우기가 들어오면 고른 복사본까지 지운다
|
||||
/// (앱을 띄울 때의 정리가 막 고른 복사본을 지우지 않게도).
|
||||
class PlatformPickedFileStore implements PickedFileStore {
|
||||
PlatformPickedFileStore({
|
||||
Future<Directory> 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<Directory> Function() _baseDirectory;
|
||||
final MethodChannel _channel;
|
||||
|
||||
/// 고르기 한 번의 시한 — [kPickTimeout](시험은 짧게)
|
||||
final Duration _pickTimeout;
|
||||
Future<void> _queue = Future<void>.value();
|
||||
|
||||
Future<T> _serial<T>(Future<T> Function() task) {
|
||||
final Future<T> next = _queue.then((_) => task());
|
||||
_queue = next.then<void>((_) {}, onError: (Object _) {});
|
||||
return next;
|
||||
}
|
||||
|
||||
/// 복사본 폴더 — 임시 폴더를 알 수 없으면(플러그인 · 기기 사정) null
|
||||
Future<Directory?> _root() async {
|
||||
try {
|
||||
final Directory base = await _baseDirectory();
|
||||
return Directory('${base.path}/$kPickedFilesFolder');
|
||||
} on Exception {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PickResult> pick({
|
||||
required int maxBytes,
|
||||
required int maxCount,
|
||||
required int maxTotalBytes,
|
||||
required List<String> extensions,
|
||||
}) => _serial(() async {
|
||||
try {
|
||||
// 인자 이름 — 짝: `MainActivity.kt` 의 `ARG_*`. 시한은 차례 안에서 잰다(넘으면 다음 차례로)
|
||||
final Object? raw = await _channel
|
||||
.invokeMethod<Object?>(pickMethod, <String, Object?>{
|
||||
'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<Directory?> _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<bool> exists(String path) async {
|
||||
if (await _copyFolderOf(path) == null) return false;
|
||||
return File(path).exists();
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<List<int>> openRead(String path) => File(path).openRead();
|
||||
|
||||
@override
|
||||
Future<void> delete(String path) async {
|
||||
final Directory? folder = await _copyFolderOf(path);
|
||||
if (folder == null) return;
|
||||
try {
|
||||
await folder.delete(recursive: true);
|
||||
} on FileSystemException {
|
||||
// 이미 없다 — 다음 정리 · 기기가 지운다
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> purge() => _serial(() async {
|
||||
final Directory? root = await _root();
|
||||
if (root == null) return;
|
||||
try {
|
||||
if (await root.exists()) await root.delete(recursive: true);
|
||||
} on FileSystemException {
|
||||
// 다음 정리 · 기기가 지운다
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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<int> _separators = <int>{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<PersonAddress> uniqueAddresses(Iterable<PersonAddress> list) {
|
||||
final Set<String> seen = <String>{};
|
||||
return <PersonAddress>[
|
||||
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<PersonAddress> 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" <a@b.kr>`)
|
||||
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<String> splitRecipientInput(String text) {
|
||||
final List<String> parts = <String>[];
|
||||
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 <String>[
|
||||
for (final String part in parts)
|
||||
if (part.trim().isNotEmpty) part.trim(),
|
||||
];
|
||||
}
|
||||
|
||||
/// 칸의 글에서 구분자(따옴표 · 꺾쇠 밖의 `,` `;` 줄바꿈)까지를 떼어 낸다 — 다 쓴 주소들과 이어 쓰는 글.
|
||||
/// 구분자가 없으면 다 쓴 주소가 없다(글 그대로 이어 쓴다). 이어 쓰는 글의 앞 공백은 지운다("a@b.kr, " → 다 쓴 주소 하나 · 빈 글)
|
||||
({List<String> 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 <String>[], 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<RecipientToken> mergeRecipientTokens(
|
||||
List<RecipientToken> existing,
|
||||
Iterable<RecipientToken> incoming,
|
||||
) {
|
||||
final Set<String> seen = existing.map(recipientTokenKey).toSet();
|
||||
final List<RecipientToken> merged = <RecipientToken>[...existing];
|
||||
for (final RecipientToken token in incoming) {
|
||||
if (seen.add(recipientTokenKey(token))) merged.add(token);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
@@ -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<String> skipped = <String>[
|
||||
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);
|
||||
}
|
||||
@@ -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<String, Object?> toJson() => <String, Object?>{
|
||||
'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);
|
||||
}
|
||||
@@ -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<String> _initials = <String>[
|
||||
'ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅃ', 'ㅅ', //
|
||||
'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ',
|
||||
];
|
||||
|
||||
/// 받침 — 0 은 받침 없음
|
||||
const List<String> _finals = <String>[
|
||||
'', 'ㄱ', 'ㄲ', 'ㄳ', 'ㄴ', 'ㄵ', 'ㄶ', 'ㄷ', 'ㄹ', 'ㄺ', //
|
||||
'ㄻ', 'ㄼ', 'ㄽ', 'ㄾ', 'ㄿ', 'ㅀ', 'ㅁ', 'ㅂ', 'ㅄ', 'ㅅ', //
|
||||
'ㅆ', 'ㅇ', 'ㅈ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ',
|
||||
];
|
||||
|
||||
/// 겹받침을 나눈다 — 뒤 자음이 다음 글자의 초성으로 넘어가는 순간이 있다(닭 → 달 + ㄱ)
|
||||
const Map<String, (String, String)> _splitFinals = <String, (String, String)>{
|
||||
'ㄳ': ('ㄱ', 'ㅅ'),
|
||||
'ㄵ': ('ㄴ', 'ㅈ'),
|
||||
'ㄶ': ('ㄴ', 'ㅎ'),
|
||||
'ㄺ': ('ㄹ', 'ㄱ'),
|
||||
'ㄻ': ('ㄹ', 'ㅁ'),
|
||||
'ㄼ': ('ㄹ', 'ㅂ'),
|
||||
'ㄽ': ('ㄹ', 'ㅅ'),
|
||||
'ㄾ': ('ㄹ', 'ㅌ'),
|
||||
'ㄿ': ('ㄹ', 'ㅍ'),
|
||||
'ㅀ': ('ㄹ', 'ㅎ'),
|
||||
'ㅄ': ('ㅂ', 'ㅅ'),
|
||||
};
|
||||
|
||||
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<String> _codePoints(String value) => <String>[
|
||||
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<String> typed, List<String> 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<String> 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<String> typed = _codePoints(query.trim());
|
||||
final List<String> 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<RecipientMatch> filterRecipientSuggestions(
|
||||
String query,
|
||||
List<RecipientSuggestion> suggestions,
|
||||
Set<String> excluded, {
|
||||
int limit = kRecipientSuggestionLimit,
|
||||
}) {
|
||||
if (query.trim().isEmpty) return const <RecipientMatch>[];
|
||||
final Set<String> seen = <String>{...excluded};
|
||||
final List<RecipientMatch> matches = <RecipientMatch>[];
|
||||
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 <RecipientMatch>[
|
||||
for (final RecipientSource source in RecipientSource.values)
|
||||
...matches.where(
|
||||
(RecipientMatch match) => match.suggestion.source == source,
|
||||
),
|
||||
].take(limit).toList();
|
||||
}
|
||||
|
||||
/// 친 글자가 이름에 맞는 그룹 — 구성원이 없는 그룹은 뺀다(넣을 주소가 없다). 받은 차례(이름 차례) 그대로
|
||||
List<RecipientGroupMatch> matchContactGroups(
|
||||
String query,
|
||||
List<ContactGroupSummary> groups,
|
||||
) {
|
||||
if (query.trim().isEmpty) return const <RecipientGroupMatch>[];
|
||||
return <RecipientGroupMatch>[
|
||||
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<RecipientOption> arrangeRecipientOptions(
|
||||
List<RecipientMatch> addresses,
|
||||
List<RecipientGroupMatch> groups, {
|
||||
int limit = kRecipientSuggestionLimit,
|
||||
}) {
|
||||
return <RecipientOption>[
|
||||
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<HighlightPart> highlightParts(String text, MatchRange? range) {
|
||||
if (range == null ||
|
||||
range.start >= range.end ||
|
||||
range.start < 0 ||
|
||||
range.end > text.length) {
|
||||
return <HighlightPart>[(text: text, strong: false)];
|
||||
}
|
||||
return <HighlightPart>[
|
||||
(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();
|
||||
}
|
||||
@@ -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<String, Object?>) 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<String, Object?>) 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<String, Object?>) return null;
|
||||
final Object? name = json['name'];
|
||||
final Object? emails = json['emails'];
|
||||
if ((name != null && name is! String) || emails is! List<Object?>) {
|
||||
return null;
|
||||
}
|
||||
String? email;
|
||||
if (emails.isNotEmpty) {
|
||||
final Object? first = emails.first;
|
||||
final Object? address = first is Map<String, Object?>
|
||||
? 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<PersonAddress> addresses;
|
||||
final int withoutEmail;
|
||||
}
|
||||
|
||||
GroupRecipients groupRecipientsOf(Iterable<GroupMember> members) {
|
||||
final List<PersonAddress> addresses = <PersonAddress>[];
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -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<String, Object?>.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(<StreamSubscription<Object?>>[
|
||||
_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<PushMessaging> 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<StreamSubscription<Object?>> _subscriptions =
|
||||
<StreamSubscription<Object?>>[];
|
||||
final StreamController<String> _registered =
|
||||
StreamController<String>.broadcast();
|
||||
final StreamController<PushMessage> _foreground =
|
||||
StreamController<PushMessage>.broadcast();
|
||||
final StreamController<PushMessage> _opened =
|
||||
StreamController<PushMessage>.broadcast();
|
||||
|
||||
@override
|
||||
bool get isAvailable => true;
|
||||
|
||||
@override
|
||||
Future<PushPermission> permission() async {
|
||||
final NotificationSettings settings = await _messaging
|
||||
.getNotificationSettings();
|
||||
return pushPermissionOf(settings.authorizationStatus, sdkInt: _sdkInt);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PushPermission> requestPermission() async {
|
||||
final NotificationSettings settings = await _messaging.requestPermission();
|
||||
return pushPermissionOf(settings.authorizationStatus, sdkInt: _sdkInt);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> register() => _messaging.register();
|
||||
|
||||
@override
|
||||
Future<void> unregister() => _messaging.unregister();
|
||||
|
||||
@override
|
||||
Stream<String> get onRegistered => _registered.stream;
|
||||
|
||||
@override
|
||||
Stream<PushMessage> get onForegroundMessage => _foreground.stream;
|
||||
|
||||
@override
|
||||
Stream<PushMessage> get onOpened => _opened.stream;
|
||||
|
||||
@override
|
||||
Future<PushMessage?> initialOpen() async {
|
||||
final RemoteMessage? message = await _messaging.getInitialMessage();
|
||||
return message == null ? null : pushMessageOf(message);
|
||||
}
|
||||
}
|
||||
@@ -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<NotificationStatus> status();
|
||||
|
||||
/// 휴대폰의 알림 설정 화면을 연다 — 새 메일 채널만 꺼졌으면 그 채널, 아니면 이 앱의 알림 설정. 열었으면 참
|
||||
Future<bool> openSettings();
|
||||
|
||||
/// 알림창의 "새 메일 N통"을 지운다 — 다른 알림은 건드리지 않는다
|
||||
Future<void> 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<NotificationStatus> status() async {
|
||||
try {
|
||||
final Map<Object?, Object?>? reply = await channel
|
||||
.invokeMapMethod<Object?, Object?>(statusMethod);
|
||||
return notificationStatusOf(reply);
|
||||
} on PlatformException {
|
||||
return NotificationStatus.unknown;
|
||||
} on MissingPluginException {
|
||||
return NotificationStatus.unknown;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> openSettings() async {
|
||||
try {
|
||||
return await channel.invokeMethod<String>(openSettingsMethod) ==
|
||||
openedResult;
|
||||
} on PlatformException {
|
||||
return false;
|
||||
} on MissingPluginException {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clearNewMail() async {
|
||||
try {
|
||||
await channel.invokeMethod<void>(clearNewMailMethod);
|
||||
} on PlatformException {
|
||||
// 지우지 못했다 — 다음에 앞으로 올 때 다시 지운다
|
||||
} on MissingPluginException {
|
||||
// 채널이 없는 플랫폼
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 채널 답 → [NotificationStatus] — 모양이 틀린 칸은 막지 않는 쪽(켜짐 · 판 모름)으로 본다
|
||||
NotificationStatus notificationStatusOf(Map<Object?, Object?>? 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<NotificationSystem> notificationSystemProvider =
|
||||
Provider<NotificationSystem>(
|
||||
(Ref ref) => const PlatformNotificationSystem(),
|
||||
);
|
||||
@@ -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<PushController, PushState> pushControllerProvider =
|
||||
NotifierProvider<PushController, PushState>(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<PushState> implements PushRegistrar {
|
||||
late PushMessaging _messaging;
|
||||
late NotificationSystem _notifications;
|
||||
late PushPreferences _prefs;
|
||||
|
||||
final List<StreamSubscription<Object?>> _subscriptions =
|
||||
<StreamSubscription<Object?>>[];
|
||||
|
||||
/// 이 휴대폰의 값 · 권한을 다 읽었다 — 맞추기 · 켜기 · 끄기가 먼저 기다린다
|
||||
Future<void> _ready = Future<void>.value();
|
||||
|
||||
/// 서버 일 · 기기 읽기를 차례로
|
||||
Future<void> _queue = Future<void>.value();
|
||||
bool _reconcileQueued = false;
|
||||
|
||||
/// FCM 등록 · 풀기를 차례로(플러그인은 둘을 다른 스레드에서 돌린다)
|
||||
Future<void> _fcmQueue = Future<void>.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<void>? _fcmRegistering;
|
||||
bool _pendingUnregister = false;
|
||||
|
||||
/// FCM 등록 세대 — 풀기를 시작할 때마다 오른다. 등록은 시작한 세대가 그대로일 때만 마친 것으로 친다
|
||||
int _fcmGeneration = 0;
|
||||
|
||||
/// 줄에 섰거나 도는 풀기의 수 — 그동안 온 등록 소식은 풀리는 등록의 것이라 쓰지 않는다
|
||||
int _fcmUnregistering = 0;
|
||||
final List<Completer<String?>> _fidWaiters = <Completer<String?>>[];
|
||||
|
||||
// ── 봤음 ──
|
||||
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(<StreamSubscription<Object?>>[
|
||||
_messaging.onRegistered.listen(_onRegistered),
|
||||
_messaging.onForegroundMessage.listen(_onForegroundMessage),
|
||||
_messaging.onOpened.listen(_onOpened),
|
||||
]);
|
||||
ref
|
||||
..listen<bool>(appForegroundProvider, (bool? previous, bool next) {
|
||||
if (next && previous == false) _cameToForeground();
|
||||
if (!next && previous == true) _wentToBackground();
|
||||
})
|
||||
..listen<bool?>(pushAvailableProvider, (bool? previous, bool? next) {
|
||||
state = state.copyWith(server: _serverOf(next));
|
||||
_requestReconcile();
|
||||
});
|
||||
// 꺼져 있던 앱을 띄운 알림 — 라우터가 가져간다
|
||||
unawaited(
|
||||
_messaging.initialOpen().then<void>((PushMessage? opened) {
|
||||
if (opened != null && ref.mounted) {
|
||||
ref.read(pushEventsProvider.notifier).add(opened, opened: true);
|
||||
}
|
||||
}, onError: (Object _, StackTrace _) {}),
|
||||
);
|
||||
// build 안에서는 상태를 바꾸지 않는다 — 다음 차례에 읽기 시작한다
|
||||
_ready = Future<void>.microtask(_initialize);
|
||||
return PushState(
|
||||
deviceSupported: true,
|
||||
server: _serverOf(ref.read(pushAvailableProvider)),
|
||||
);
|
||||
}
|
||||
|
||||
// ── 화면이 부르는 일 ───────────────────────────────
|
||||
|
||||
/// [알림 켜기](권하기 줄) · 설정 스위치를 켰다 — 권한이 없으면 먼저 시스템 창(직원이 누른 뒤에만). 허락되면 FCM 등록 → 서버 등록(`PUT`)이
|
||||
/// 끝나야 켬을 둔다. 서버 등록이 실패하면 이 휴대폰의 값을 끔으로 두고(스위치가 꺼진 채로 돌아간다 — 처음 값 켬에서 켰어도, 저절로 다시 하지 않는다)
|
||||
/// 그 코드의 사전 문구를 알린다(인터셉터 · 등록 소식이 없으면 `SYS_001`)
|
||||
Future<PushTurnOnResult> 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<PushTurnOffResult> 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<bool> openSettings() => _notifications.openSettings();
|
||||
|
||||
// ── 세션 노티파이어가 부르는 일 (PushRegistrar) ──────
|
||||
|
||||
@override
|
||||
Future<void> register() async {
|
||||
// 세션 노티파이어가 상태를 바꾸는 중에 부를 수 있다 — 다음 차례에 바꾼다
|
||||
await Future<void>.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<void> 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<void> _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<void> _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<PushPermission> _readPermission() async {
|
||||
try {
|
||||
return await _messaging.permission();
|
||||
} on Object {
|
||||
return state.permission ?? PushPermission.denied;
|
||||
}
|
||||
}
|
||||
|
||||
Future<PushPermission> _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<void> _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<bool> _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<bool> _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<void> registering = _fcm(() async {
|
||||
await _messaging.register();
|
||||
// 그사이 풀기를 바랐으면(로그아웃) 이 등록은 마친 것으로 치지 않는다 — 뒤에 선 풀기가 푼다
|
||||
if (generation == _fcmGeneration) _fcmRegistered = true;
|
||||
});
|
||||
_fcmRegistering = registering;
|
||||
unawaited(
|
||||
registering
|
||||
.then<void>(
|
||||
(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<void> _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<String?> _waitForFid() async {
|
||||
final Completer<String?> waiter = Completer<String?>();
|
||||
_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<String?> waiter in List<Completer<String?>>.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>((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>(
|
||||
(void _) {},
|
||||
// 알리지 않는다 — 다음 앞으로 옴에 다시
|
||||
onError: (Object _, StackTrace _) {},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _cancelSeen() {
|
||||
_seenPending = false;
|
||||
_newMailAt = null;
|
||||
_seenTimer?.cancel();
|
||||
_seenTimer = null;
|
||||
}
|
||||
|
||||
// ── 차례 ──────────────────────────────────────
|
||||
|
||||
Future<T> _serial<T>(Future<T> Function() operation) {
|
||||
final Future<T> result = _queue.then((void _) => operation());
|
||||
_queue = result.then<void>((T _) {}, onError: (Object _, StackTrace _) {});
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<T> _fcm<T>(Future<T> Function() operation) {
|
||||
final Future<T> result = _fcmQueue.then((void _) => operation());
|
||||
_fcmQueue = result.then<void>(
|
||||
(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<Object?> subscription in _subscriptions) {
|
||||
unawaited(subscription.cancel());
|
||||
}
|
||||
_subscriptions.clear();
|
||||
_seenTimer?.cancel();
|
||||
_seenTimer = null;
|
||||
_completeFidWaiters(null);
|
||||
}
|
||||
}
|
||||
|
||||
/// 직원이 켠 등록의 결과 — 실패면 이 휴대폰의 값을 끔으로, 건너뜀(그사이 세션이 바뀜)이면 켬 · 끔을 건드리지 않는다
|
||||
enum _UserRegistration { registered, failed, skipped }
|
||||
@@ -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<PushEvents, PushEvent?> pushEventsProvider =
|
||||
NotifierProvider<PushEvents, PushEvent?>(PushEvents.new);
|
||||
|
||||
class PushEvents extends Notifier<PushEvent?> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<String, Object?> 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<PushPermission> permission();
|
||||
|
||||
/// 알림 권한을 묻는다 — 안드로이드 13 이상에서 묻지 않았거나 한 번 거절했으면 시스템 창을 띄운다. 직원이 [알림 켜기]를 누른 뒤에만 부른다
|
||||
Future<PushPermission> requestPermission();
|
||||
|
||||
/// FCM 에 이 앱을 등록한다 — 끝나면 [onRegistered] 로 FID 가 온다(값을 돌려주지 않는다)
|
||||
Future<void> register();
|
||||
|
||||
/// FCM 등록을 푼다 — 그 FID 로 보낸 알림은 오지 않는다
|
||||
Future<void> unregister();
|
||||
|
||||
/// 등록된 FID — 등록할 때마다 · 바뀔 때마다 온다. **듣기 전에 온 값은 사라진다** — [register] 전에 듣는다
|
||||
Stream<String> get onRegistered;
|
||||
|
||||
/// 앱이 앞에 있을 때 온 알림 — 알림창에 띄우지 않는다
|
||||
Stream<PushMessage> get onForegroundMessage;
|
||||
|
||||
/// 앱이 뒤에 있을 때 누른 알림
|
||||
Stream<PushMessage> get onOpened;
|
||||
|
||||
/// 꺼져 있던 앱을 띄운 알림 — 한 번만 온다(다시 부르면 null)
|
||||
Future<PushMessage?> initialOpen();
|
||||
}
|
||||
|
||||
/// 받을 수 없는 휴대폰 — 아무것도 하지 않는다(main 이 Firebase 를 띄우지 못했을 때 · 시험의 기본값)
|
||||
class UnavailablePushMessaging implements PushMessaging {
|
||||
const UnavailablePushMessaging();
|
||||
|
||||
@override
|
||||
bool get isAvailable => false;
|
||||
|
||||
@override
|
||||
Future<PushPermission> permission() async => PushPermission.blocked;
|
||||
|
||||
@override
|
||||
Future<PushPermission> requestPermission() async => PushPermission.blocked;
|
||||
|
||||
@override
|
||||
Future<void> register() async {}
|
||||
|
||||
@override
|
||||
Future<void> unregister() async {}
|
||||
|
||||
@override
|
||||
Stream<String> get onRegistered => const Stream<String>.empty();
|
||||
|
||||
@override
|
||||
Stream<PushMessage> get onForegroundMessage =>
|
||||
const Stream<PushMessage>.empty();
|
||||
|
||||
@override
|
||||
Stream<PushMessage> get onOpened => const Stream<PushMessage>.empty();
|
||||
|
||||
@override
|
||||
Future<PushMessage?> initialOpen() async => null;
|
||||
}
|
||||
|
||||
/// 알림 받기 — main 이 Firebase 를 띄웠으면 `FirebasePushMessaging` 으로 덮어쓴다. 시험은 가짜로
|
||||
final Provider<PushMessaging> pushMessagingProvider = Provider<PushMessaging>(
|
||||
(Ref ref) => const UnavailablePushMessaging(),
|
||||
);
|
||||
@@ -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<PushPreferenceValues> load();
|
||||
|
||||
Future<void> setEnabled(bool value);
|
||||
|
||||
Future<void> setOfferDismissed(bool value);
|
||||
|
||||
Future<void> setPendingUnregister(bool value);
|
||||
|
||||
Future<void> setLastFid(String? fid);
|
||||
|
||||
Future<void> 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<PushPreferenceValues> 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<void> setEnabled(bool value) => _write(
|
||||
(SharedPreferencesAsync prefs) => prefs.setBool(enabledKey, value),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> setOfferDismissed(bool value) => _write(
|
||||
(SharedPreferencesAsync prefs) => prefs.setBool(offerDismissedKey, value),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> setPendingUnregister(bool value) => _write(
|
||||
(SharedPreferencesAsync prefs) =>
|
||||
prefs.setBool(pendingUnregisterKey, value),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> setLastFid(String? fid) => _write(
|
||||
(SharedPreferencesAsync prefs) => fid == null
|
||||
? prefs.remove(lastFidKey)
|
||||
: prefs.setString(lastFidKey, fid),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> setServerRegistered(bool value) => _write(
|
||||
(SharedPreferencesAsync prefs) => prefs.setBool(serverRegisteredKey, value),
|
||||
);
|
||||
|
||||
Future<void> _write(
|
||||
Future<void> Function(SharedPreferencesAsync prefs) write,
|
||||
) async {
|
||||
try {
|
||||
await write(_store);
|
||||
} on Object {
|
||||
// 쓰지 못했다 — 이번 실행은 메모리 값으로 이어 간다
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 새 메일 알림 값을 두는 곳 — 시험은 메모리로 덮어쓴다
|
||||
final Provider<PushPreferences> pushPreferencesProvider =
|
||||
Provider<PushPreferences>((Ref ref) => PrefsPushPreferences());
|
||||
@@ -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<void> register();
|
||||
|
||||
/// 이 기기의 알림 토큰을 지운다 — 로그아웃 뒤라 서버를 부를 수 없다(인증 토큰이 없다)
|
||||
/// 세션이 끝났다 — 알림창의 "새 메일 N통"을 지우고 FCM 등록을 푼다.
|
||||
/// 서버는 부르지 않는다(인증 토큰이 이미 없다 — 서버 줄은 logout · 세션 끝이 함께 지운다). 짧은 한도 안에서만 기다리고 실패는 삼킨다
|
||||
Future<void> 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<void> register() async {}
|
||||
/// 판을 읽지 못한 기기(거의 없다)가 보내는 앱 판 — 서버 칸의 모양(`숫자.숫자.숫자`)에 맞춘 값
|
||||
const String kUnknownAppVersion = '0.0.0';
|
||||
|
||||
@override
|
||||
Future<void> 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<void> put({
|
||||
required String fid,
|
||||
required String appVersion,
|
||||
required bool silent,
|
||||
}) async {
|
||||
await _dio.put<Object?>(
|
||||
ApiPaths.pushRegistration,
|
||||
data: <String, Object?>{
|
||||
'fid': fid,
|
||||
'platform': kPushPlatform,
|
||||
'appVersion': appVersion,
|
||||
},
|
||||
options: silent ? ApiOptions.silent() : null,
|
||||
);
|
||||
}
|
||||
|
||||
/// 등록을 지운다 — 실패는 `DioException`
|
||||
Future<void> delete({required bool silent}) async {
|
||||
await _dio.delete<Object?>(
|
||||
ApiPaths.pushRegistration,
|
||||
options: silent ? ApiOptions.silent() : null,
|
||||
);
|
||||
}
|
||||
|
||||
/// "봤음" — 알리지 않는다. 실패는 `DioException`
|
||||
Future<void> markSeen() async {
|
||||
await _dio.delete<Object?>(
|
||||
ApiPaths.pushRegistrationUnseen,
|
||||
options: ApiOptions.silent(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 푸시 기기 등록
|
||||
/// 새 메일 알림 등록 API
|
||||
final Provider<PushRegistrationRepository> pushRegistrationRepositoryProvider =
|
||||
Provider<PushRegistrationRepository>(
|
||||
(Ref ref) => PushRegistrationRepository(ref.watch(dioProvider)),
|
||||
);
|
||||
|
||||
/// 세션 노티파이어가 부르는 기기 등록 — 새 메일 알림 컨트롤러다(시험은 기록하는 가짜로 덮어쓴다)
|
||||
final Provider<PushRegistrar> pushRegistrarProvider = Provider<PushRegistrar>(
|
||||
(Ref ref) => const NoopPushRegistrar(),
|
||||
(Ref ref) => ref.watch(pushControllerProvider.notifier),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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<String, Object?> data) {
|
||||
final Object? name = data[moduleKey];
|
||||
final AppModuleId? module = AppModuleId.fromName(
|
||||
|
||||
@@ -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을 찾아 알림을 켜 주세요.';
|
||||
}
|
||||
@@ -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<List<AppModule>> appModulesProvider = Provider<List<AppModule>>(
|
||||
(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<GoRouter> routerProvider = Provider<GoRouter>((Ref ref) {
|
||||
final List<AppModule> modules = ref.watch(appModulesProvider);
|
||||
final List<AppModule> enabled = enabledAppModules(modules);
|
||||
@@ -37,33 +47,41 @@ final Provider<GoRouter> routerProvider = Provider<GoRouter>((Ref ref) {
|
||||
settingsSections: moduleSettingsSections(moduleRoutes),
|
||||
);
|
||||
|
||||
final _SessionChanges sessionChanges = _SessionChanges();
|
||||
ref.listen<AsyncValue<SessionState>>(
|
||||
sessionProvider,
|
||||
(AsyncValue<SessionState>? previous, AsyncValue<SessionState> next) =>
|
||||
sessionChanges.notify(),
|
||||
);
|
||||
final _RouteChanges routeChanges = _RouteChanges();
|
||||
ref
|
||||
..listen<AsyncValue<SessionState>>(
|
||||
sessionProvider,
|
||||
(AsyncValue<SessionState>? previous, AsyncValue<SessionState> next) =>
|
||||
routeChanges.notify(),
|
||||
)
|
||||
..listen<bool>(
|
||||
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: <RouteBase>[
|
||||
_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<GoRouter> routerProvider = Provider<GoRouter>((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();
|
||||
}
|
||||
|
||||
@@ -20,4 +20,7 @@ abstract final class AppRoutes {
|
||||
|
||||
/// 제한된 세션 — 앱이 모르는 제한
|
||||
static const String restricted = '/login/restricted';
|
||||
|
||||
/// 앱을 업데이트해야 한다 — 이 앱의 판이 서버의 최소 판보다 낮다(어느 화면에서든 · 로그인 전에도)
|
||||
static const String update = '/update';
|
||||
}
|
||||
|
||||
@@ -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: <Widget>[
|
||||
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<AppModuleId, int> 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: <Widget>[
|
||||
// 늘 같은 모양으로 감싼다 — 감싸는 위젯이 바뀌면 탭 안 화면(이동 기록)이 처음부터 다시 만들어진다
|
||||
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<AppModule> modules;
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> onSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final AppExtraColors extra = Theme.of(context).extension<AppExtraColors>()!;
|
||||
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: <Widget>[
|
||||
for (final AppModule module in modules)
|
||||
NavigationDestination(
|
||||
icon: Icon(module.icon),
|
||||
selectedIcon: Icon(module.selectedIcon),
|
||||
label: module.label,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AppModule> 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<PushEvent?>(pushEventsProvider, (
|
||||
PushEvent? previous,
|
||||
PushEvent? next,
|
||||
) {
|
||||
if (next != null && next.opened) openPending();
|
||||
})
|
||||
..listen<PushState>(pushControllerProvider, (PushState? _, PushState _) {});
|
||||
// 라우터보다 먼저 온 누른 알림 — 라우터를 만든 뒤에 옮긴다(만드는 중에는 옮기지 않는다)
|
||||
scheduleMicrotask(openPending);
|
||||
}
|
||||
@@ -10,16 +10,21 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
///
|
||||
/// | 세션 | 화면 |
|
||||
/// | --- | --- |
|
||||
/// | (세션과 상관없이) 앱을 업데이트해야 함([updateRequired]) | 업데이트 — 로그인 전 화면에서도(옛 앱이 로그인 API 를 잘못 부르지 않게) |
|
||||
/// | 되살리는 중 · 되살리기 실패 | 시작 |
|
||||
/// | 로그아웃 | 로그인 |
|
||||
/// | 코드 단계 | 코드 |
|
||||
/// | 제한 passwordChange · twoFactorSetup · 모르는 제한 | 그 단계 |
|
||||
/// | 로그인(제한 없음) | 로그인 뒤 화면 — 로그인 전 화면 · 모르거나 꺼진 모듈의 주소면 메일 첫 화면으로 |
|
||||
///
|
||||
/// 업데이트가 풀리면(서버가 최소 판을 내림) 업데이트 화면은 세션이 고른 화면으로 돌아간다(그 경로는 로그인 전 화면도 모듈도 아니다).
|
||||
String? resolveSessionRedirect(
|
||||
AsyncValue<SessionState> session,
|
||||
String location, {
|
||||
List<AppModule> 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);
|
||||
|
||||
|
||||
@@ -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<ShellBarHiding, Map<AppModuleId, int>>
|
||||
shellBarHidingProvider =
|
||||
NotifierProvider<ShellBarHiding, Map<AppModuleId, int>>(ShellBarHiding.new);
|
||||
|
||||
/// 모듈 [module] 의 탭에서 막대를 숨겼는가
|
||||
bool isShellBarHidden(Map<AppModuleId, int> hiding, AppModuleId module) =>
|
||||
(hiding[module] ?? 0) > 0;
|
||||
|
||||
/// 탭 막대를 숨긴 화면 수
|
||||
class ShellBarHiding extends Notifier<Map<AppModuleId, int>> {
|
||||
@override
|
||||
Map<AppModuleId, int> build() => const <AppModuleId, int>{};
|
||||
|
||||
/// 한 화면이 숨긴다
|
||||
void hide(AppModuleId module) {
|
||||
if (!ref.mounted) return;
|
||||
state = <AppModuleId, int>{...state, module: (state[module] ?? 0) + 1};
|
||||
}
|
||||
|
||||
/// 숨겼던 화면 하나가 돌려준다
|
||||
void reveal(AppModuleId module) {
|
||||
if (!ref.mounted) return;
|
||||
final int left = (state[module] ?? 0) - 1;
|
||||
final Map<AppModuleId, int> next = <AppModuleId, int>{...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<ShellBarHidden> createState() => _ShellBarHiddenState();
|
||||
}
|
||||
|
||||
class _ShellBarHiddenState extends ConsumerState<ShellBarHidden> {
|
||||
/// 지금 숨겨 둔 모듈 — 숨기지 않았으면 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>[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<TextStyle?>((
|
||||
Set<WidgetState> 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<IconThemeData?>(
|
||||
(Set<WidgetState> 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<WidgetState> states) =>
|
||||
states.contains(WidgetState.disabled);
|
||||
bool on(Set<WidgetState> states) => states.contains(WidgetState.selected);
|
||||
return SwitchThemeData(
|
||||
thumbColor: WidgetStateProperty.resolveWith<Color>((
|
||||
Set<WidgetState> states,
|
||||
) {
|
||||
if (disabled(states)) return AppColors.switchThumbDisabled;
|
||||
return on(states) ? scheme.onPrimary : scheme.outline;
|
||||
}),
|
||||
trackColor: WidgetStateProperty.resolveWith<Color>((
|
||||
Set<WidgetState> states,
|
||||
) {
|
||||
if (disabled(states)) return AppColors.surfaceMuted;
|
||||
return on(states) ? scheme.primary : scheme.surface;
|
||||
}),
|
||||
trackOutlineColor: WidgetStateProperty.resolveWith<Color>((
|
||||
Set<WidgetState> states,
|
||||
) {
|
||||
if (disabled(states)) return AppColors.borderStrong;
|
||||
return on(states) ? scheme.primary : scheme.outline;
|
||||
}),
|
||||
trackOutlineWidth: const WidgetStatePropertyAll<double>(
|
||||
AppSize.switchOutlineWidth,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static OutlineInputBorder _inputBorder(
|
||||
Color color, {
|
||||
double width = AppSize.borderWidth,
|
||||
@@ -159,23 +237,45 @@ class AppExtraColors extends ThemeExtension<AppExtraColors> {
|
||||
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<AppExtraColors> {
|
||||
|
||||
/// 메일 본문
|
||||
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<AppExtraColors> {
|
||||
/// 중요 표시
|
||||
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<AppExtraColors> {
|
||||
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)!,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<AccessibleNavigation, bool>
|
||||
accessibleNavigationProvider = NotifierProvider<AccessibleNavigation, bool>(
|
||||
AccessibleNavigation.new,
|
||||
);
|
||||
|
||||
/// 기기의 접근성 설정을 듣는다
|
||||
class AccessibleNavigation extends Notifier<bool> 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;
|
||||
}
|
||||
@@ -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<AppForeground, bool> appForegroundProvider =
|
||||
NotifierProvider<AppForeground, bool>(AppForeground.new);
|
||||
|
||||
/// 앱 생명주기를 듣는다
|
||||
class AppForeground extends Notifier<bool> {
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -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<String> keyLines(String secret) {
|
||||
final List<String> groups = <String>[
|
||||
for (int start = 0; start < secret.length; start += kKeyGroupSize)
|
||||
secret.substring(start, math.min(start + kKeyGroupSize, secret.length)),
|
||||
];
|
||||
return <String>[
|
||||
for (int start = 0; start < groups.length; start += kKeyGroupsPerLine)
|
||||
groups
|
||||
.sublist(start, math.min(start + kKeyGroupsPerLine, groups.length))
|
||||
.join(' '),
|
||||
];
|
||||
}
|
||||
@@ -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<String> _units = <String>['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);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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 <String>['월', '화', '수', '목', '금', '토', '일'][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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||