Compare commits

..

34 Commits

Author SHA1 Message Date
ttipo 117ec6c767 chore: 앱 서버 브랜치(14단계 · 문구 다듬기 · main 을 합친 판)를 앱 첫 판 브랜치에 합친다
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 22m26s
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Successful in 13m39s
CI / Frontend (lint · type-check · test · build) (pull_request) Successful in 13m20s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (pull_request) Successful in 2m2s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m12s
CI (Flutter) / Flutter (analyze · test) (push) Successful in 22m15s
CI / Backend (lint · type-check · test · e2e · build) (push) Successful in 13m53s
CI / Frontend (lint · type-check · test · build) (push) Successful in 13m13s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (push) Successful in 1m56s
CI / 에러 코드 SSOT 동기화 검사 (push) Successful in 11m48s
이 PR 이 앱 서버 PR 뒤에 빨리 감기로 합쳐지고 CI 가 합친 모습 그대로를 시험하게 한다.
계약 1절의 앱 절은 앱 서버 쪽이 들고 있고 이 브랜치는 2절 줄만 더했다 — 충돌 없음.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 12:39:17 +09:00
ttipo 1448835aff docs: 앱 README 의 실기기 확인에 바꿔 쓴 알림의 소리를 더한다
2026-11-08 갤럭시 S23 에서 사용자가 같은 tag 로 바꿔 쓴 알림도 다시 소리를 내는 것을 확인했다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 12:08:36 +09:00
ttipo dacc100d3e docs: 앱 README 의 실기기 확인에 홈 화면 배지를 더한다
2026-11-08 갤럭시 S23 에서 사용자가 notification_count 의 홈 화면 배지를 확인했다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 11:59:15 +09:00
ttipo 5670017d2a fix(flutter): 앱을 보는 중 받은 알림 뒤의 봤음을 1.5초 늦춰 N 이 한 통 많지 않게 한다
서버는 N 을 seen_at 의 초부터 같은 초를 넣어 센다(받은 시각이 초 단위). 앱을 보는 중 새 메일 알림을 받자마자
봤음을 보내면 seen_at 이 그 메일과 같은 초가 되어, 목록으로 보고 열지 않은 그 메일이 다음 알림에 다시 세어졌다
(2026-11-08 실기기 — 한 통이 더 왔는데 "새 메일 2통"). 그 봤음만 받은 때 + 1.5초와 30초 간격 가운데 늦은 때에 보낸다.
앱으로 돌아올 때 · 기다리는 동안 뒤로 갈 때는 그대로다. 고친 뒤 실기기에서 봤음이 2초 뒤에 갔고 다음 알림은 "새 메일 1통"이었다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 11:56:51 +09:00
ttipo 3a25cc52f7 fix(flutter): 새 메일 알림 켜기가 FID 등록 표시 없이 곧바로 실패하던 것을 고친다
firebase-messaging 26.0.1(firebase_core 5.1.0 의 BoM 35.1.0)의 register() · unregister() 는
매니페스트에 firebase_messaging_installation_id_enabled = true 가 없으면 곧바로 "API disabled" 로 거절한다
(2026-11-08 실기기에서 [알림 켜기]가 누르자마자 SYS_001 — SDK 바이트코드로 확인, 플러그인 문서에는 없다).
표시를 켜고, FID 길은 Google Play 서비스 26.12 이상에서만 탄다는 한계를 앱 README 에 적었다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 11:56:41 +09:00
ttipo 08b8144087 docs: 앱 새 메일 알림 · 업데이트 · 앱 아이콘을 앱 README · flutter README · 스킬 · 계약에 적는다
앱 디자인 README 에 "새 메일 알림 · 업데이트 · 앱 아이콘" 절(허락 묻기 · 설정 줄 상태 · 받기 · 누르기 · 로그아웃 차례 ·
"알림이 멈췄습니다"를 보내는 때)을, flutter README 에 Firebase 설정 파일 · 아이콘 만들기 · Play 데이터 보안 신고를,
flutter 스킬에 새 행을, 계약 2절 "새 메일 확인"에 앱이 알림을 받았을 때 한 번 묻는 줄을 더했다.
보드 App1Push 의 ⑧ 칸 설명은 "알림이 멈췄습니다"를 넓힌 결정대로 고쳤다(캔버스 v66).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 00:46:30 +09:00
ttipo e03eda1fd3 feat(flutter): 새 메일 알림(FCM) · 최소 판 확인 · 설정 탭
앱 첫 판의 마지막 기능이다. 알림 글은 "새 메일 N통"뿐이고 화면은 확정 보드 App1Push · App1Shell 대로다.

- firebase_core 5.1.0 · firebase_messaging 17.0.1 — FCM 토큰 API 가 deprecated 라 FID 로 등록한다.
  google-services.json 은 저장소에 두지 않고, 파일이 있을 때만 Gradle 플러그인을 건다(없으면 배포 판 빌드를 멈춘다).
- 받은편지함 위 권하기 줄과 설정 › 새 메일 알림 — 휴대폰의 허락 창은 [알림 켜기]를 누를 때만, 켬 · 끔은 이 휴대폰에 둔다.
- 등록 PUT · 끄기 DELETE · 봤음 unseen(앞에 있을 때만 · 30초 간격), 로그아웃과 세션 끝에는 FCM 등록을 푼다
  (같은 휴대폰에 다른 직원이 로그인해도 앞 직원 알림이 남지 않게 — 풀기가 실패해도 다음 로그인은 다시 등록한다).
- 앱이 앞에 있으면 알림 대신 목록을 바로 맞추고, 앞으로 오면 알림창의 알림을 지운다. 알림을 누르면 받은편지함으로 가고 쓰던 메일은 저장한다.
- 앱 설정 GET /api/app-config 를 인증 줄 밖에서 먼저 묻고, 최소 판보다 낮으면 어느 화면에서든 업데이트 화면으로 간다(묻지 못하면 막지 않는다).
- 설정 탭을 한 화면으로(내 정보 · 새 메일 알림 · 앱 정보 · 문의 · 로그아웃 확인 창), 알림 채널 "새 메일"을 Kotlin 에서 만든다.
- 시험 932 → 1,056.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 00:46:11 +09:00
ttipo bf725ae96d feat(flutter): 앱 아이콘과 알림 작은 아이콘을 ARA 마크로 바꾼다
확정 보드 App1Push(캔버스 v65)의 ⑪ "나. 남색 바탕"과 ⑨ 작은 아이콘이다.
앱 아이콘은 #0B3640 바탕에 흰 ARA 마크(적응형 · 안드로이드 13 테마 아이콘 monochrome · 7.x 용 PNG),
알림 작은 아이콘 ic_stat_ara 는 흰 실루엣이다. 그림은 브랜드 원본에서 저장소 밖 스크립트로 만들었다.
Flutter 기본 아이콘을 이것으로 바꾼다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 00:45:36 +09:00
ttipo 5b96ec2e85 docs: 앱 새 메일 알림 보드(App1Push)를 디자인 원본에 더한다
2026-11-07 사용자 확정(캔버스 v65). 허락 묻기 · 설정의 새 메일 알림 줄 상태 ·
휴대폰 알림 모습 · 로그인이 끝났을 때 알림 · 업데이트 화면 · 앱 아이콘 후보를 그렸다.
앱 아이콘은 남색 바탕에 흰 ARA 마크, 작은 아이콘은 ARA 마크(실기기에서 다시 본다).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 22:40:17 +09:00
ttipo 9c5db05ada docs: 앱 검색 2차(조건으로 찾기)를 앱 README · 계약 · flutter 스킬에 적는다
조건 시트 · 날짜 · 조건 줄 · 낱말 넘김 · 시트의 세 모양 · 가림막처럼 구현하며
정한 것과 웹과 다른 점(검색어도 조건도 비면 찾기 전 화면 · 시트 안 낱말 알림 ·
날짜는 오늘까지)을 적고, 1차 문서의 "조건으로 찾기는 2차"와 미룬 것을 고친다.
조건 칸은 14단계 서버부터 받는다는 것과 알려진 한계 둘(라틴 글자의 "나" 견주기 ·
한 스크롤 모양에서 끌어 내려 닫기)도 남긴다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 19:00:33 +09:00
ttipo facf9cd936 feat(flutter): 메일 검색 2차 — 조건으로 찾기 시트와 결과의 조건 줄
2026-11-07 확정한 보드 App1SearchConditions 대로 웹 14단계의 [조건] 패널을
바닥 시트로 옮겼다. 보낸 사람 · 받는 사람 · 제목 · 내용 · 기간 · 첨부로 찾고,
결과에는 "조건: …" 줄과 [조건 지우기]를 둔다. 조건 다듬기 · 합친 낱말 수 ·
기간 · 날짜의 UTC 시각 · 강조 쿼리 · "나"는 웹 mail-search.ts 와 짝이다.

[조건]은 위 막대 맨 오른쪽에 고정해 글을 써도 옆으로 밀리지 않게 했고,
시트에서 낱말 8개를 넘기면 시트를 연 채 알린다. 날짜는 오늘까지만 고른다.
바닥 시트 가림막을 앱 대화상자 색으로 맞춰 메일함 옮기기 시트도 같은 색이 된다.

리뷰에서 가로 화면에 자판이 오르면 시트 모양이 바뀌며 칸이 새로 만들어져
초점을 잃는 것을 찾았다. 시트를 한 트리로 두고 같은 레이아웃 안에서 머리 ·
단추 줄의 실제 높이로 모양 셋(위를 남긴 고정 · 화면 위까지 고정 · 한 스크롤)을
고르게 고쳤고, 모양이 바뀌거나 알림이 뜨면 초점 칸 · 알림을 보이게 한다.
시험 872 → 932.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 19:00:15 +09:00
ttipo bec78bce42 docs: 앱 검색 2차 보드(App1SearchConditions)를 디자인 원본에 더한다
2026-11-07 사용자가 확정한 조건으로 찾기 화면 넷이다(캔버스 v63) — 조건 시트,
날짜 고르기, 조건이 걸린 결과, 조건에 맞는 메일이 없을 때.
계획과 다르게 정한 것: [조건]은 위 막대 맨 오른쪽에 고정, 시트에서 낱말 8개를
넘기면 시트 안에 알림, 날짜는 오늘까지만 고르고 칸마다 지우기 단추, 가림막은
앱 대화상자 색. 화면의 글은 화면 상수가 기준이다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:00:36 +09:00
ttipo 3278e1abc1 feat(flutter): 조사 도우미가 호환 자모 · 숫자의 받침도 고른다
웹 조사 도우미(frontend shared/utils/korean.ts)와 짝으로 맞춘다. 초성 검색어(‘ㄱㅁ’)나
숫자로 끝나는 말이 "(으)로" · "이(가)" 꼴로 나오지 않게, 호환 자모 자음은 받침 있음
(ㄹ 은 "로"), 모음은 받침 없음, 숫자는 읽는 소리(영 · 일 · 이 …)로 고른다. 라틴 글자 ·
기호로 끝나면 지금처럼 괄호 꼴이다. 알려진 한계: 라틴 글자 뒤의 숫자도 한국어 소리로
센다(‘녹음.mp3’은).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 14:42:43 +09:00
ttipo ca6c68c34f docs: 앱 README 에 옮겨 적은 검색 글을 새 문구로 맞춘다
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 14:04:59 +09:00
ttipo 23afa583c1 fix(flutter): 앱 화면 문구 18곳을 사람이 말하듯 다듬는다
2026-11-07 사용자가 "자연스러운 문구가 아니라 시스템, 설계 문구 형식"이라고
짚은 글을 웹과 같은 기준으로 고친다. 화면이 하는 일을 설명하는 말("여기에
표시됩니다")과 번역투("맞는 메일")를 빼고, 빈 메일함 안내 · 읽기 화면 본문 안내 ·
검색 글 · 로그인 글을 내 메일이 어떻게 되는지와 할 일로 쓴다. 본문을 다 보이지
못할 때는 PC 웹메일에서 원문을 받는 길까지 적고, 사용자가 만든 메일함의 빈 안내는
앱의 [이동]이 아이콘이라 길게 눌러 고르는 말로 쓴다. 오류 사전은 이 브랜치를 main
위로 옮길 때 새 글로 다시 만든다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 14:04:59 +09:00
ttipo 4ca2d41bad docs: 앱 검색 1차의 구현하며 정한 것과 짝을 적는다
앱 디자인 README 「검색」 절, flutter 스킬 표 · 함정 두 줄 · CI 경로, 계약 2절
"검색 — 14단계"의 앱 클라이언트 줄과 목록 항목의 searchHighlight 칸이 늘 있다는
줄, 두 표본 표 머리말(앱 시험이 백엔드 표를 읽는다)을 적는다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 12:19:45 +09:00
ttipo 97a989be07 chore: 앱 짝 시험이 읽는 백엔드 표 두 파일이 바뀌어도 Flutter CI 를 돌린다
첨부 형식 표(attachment.policy.ts)와 검색 표본 표(search-text-samples.ts)만 고친
커밋에서는 앱 시험이 깨진 채로 남고 다음 flutter 커밋에서야 드러났다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 12:19:45 +09:00
ttipo 37f078e65c feat(flutter): 메일 검색 화면 1차 — 찾기 · 결과 강조 · 이어 찾기 · 안내를 더한다
목록 위 막대의 [찾기]가 탭 안 검색 화면(/mail/search)을 연다. 웹 14단계 검색과
같은 한도(UTF-16 200자 · 낱말 8개)를 먼저 보고, 찾을 곳은 시작한 메일함 | 전체
메일함이다. 결과 줄은 목록 줄 그대로 맞은 말을 노랑으로 칠하고(회색 미리 보기
안은 글자를 진하게), 결과에서 연 읽기 화면도 제목 · 첨부 이름 · 본문을 칠한다.
이어 찾기는 한 차례 5번 · 20통이고, 모자라면 [더 찾기]로 멈춘다. 결과는 새 메일
확인으로 다시 찾지 않고, 읽기 화면에서 한 일과 붙잡힌 메일의 풀림만 반영한다.
낱말 나누기 · 한 글자 판정은 웹 · 서버와 짝이고, 시험이 백엔드 표본 표를 그대로
읽는다. 시험 750 → 870.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 12:19:45 +09:00
ttipo 88ec6433b9 docs: 앱 검색 1차 보드(App1Search · App1SearchStates)를 디자인 원본에 더한다
2026-11-07 사용자가 추천대로 확정한 앱 메일 검색 화면 13장이다(캔버스 v60).
강조된 미리 보기 글은 진하게(#4A5363 — 노랑 위 6.3:1) 그렸다. 화면의 글은
이어서 다듬는 화면 문구(상수)가 기준이다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 10:18:30 +09:00
ttipo 25280d8e90 chore: 14단계(메일 검색 색인 · 검색 · 대화)를 앱 검색 브랜치에 합친다
앱 검색 화면(App1Search)은 계약 2절 "검색 — 14단계", 웹 글 원본과
백엔드 표본 표(backend/test/search-text-samples.ts)를 짝 시험으로 읽는다.
feat/mail-search-index 는 아직 main 에 없어서, 갈래점이 ba80956 인 앱 브랜치에
먼저 합친다. 14단계가 main 에 들어가면 이 브랜치를 main 위로 옮긴다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 09:30:37 +09:00
ttipo 94da1d2031 style(flutter): 소스의 날것 보이지 않는 글자를 이스케이프로 적는다
nbsp(본문 코드 둘 · 시험 하나)와 폭 없는 공백 · BOM(시험 하나)이 소스에 그대로
있었다 — 눈에 보이지 않아 고치다 지우거나 잘못 옮기기 쉽다. flutter 스킬 규칙대로
\u 이스케이프로 바꾼다. 뜻은 같다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 04:22:19 +09:00
ttipo 7546af0243 docs: 앱 쓰기 화면 2차(첨부 고르기 · 올리기)의 구현하며 정한 것과 짝을 적는다
앱 디자인 README 「쓰기」(첨부 · 나가기 · 알려진 한계 · 실기기에서 볼 것),
flutter 스킬(받은 파일 · 고른 파일 · 형식 줄, 메일 쓰기 줄, Kotlin 을 고쳤을 때의
apk 빌드, 시험 함정), 백엔드 형식 표(ATTACHMENT_TYPES)에 앱 형식 표와의 짝 주석.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 04:20:20 +09:00
ttipo 3a126bdf83 feat(flutter): 메일 쓰기 화면 2차 — 첨부 고르기 · 올리기를 더한다
[파일 첨부]가 안드로이드 고르는 창(권한 없음)을 연다. 고른 파일은 앱 임시 폴더에
복사해 두고 그 복사본을 multipart 로 둘씩 올린다 — 올리기에 실패하거나 서버가 첨부를
찾지 못해도(MAIL_004) 다시 고르지 않고 다시 올리려고.

- 사전 검사 · 보내기 막기는 웹과 같은 차례, 한도는 GET /api/mail-quota
- 기기가 같은 차례로 미리 걸러 넣을 파일만 복사한다(content:// 만, 판정은 Dart 가 다시)
- 다 올리면 서버가 정리한 이름 · 크기로 싣는다(이름 255자 — 웹과 같다)
- MAIL_004 뒤 다시 올릴 수 없는 파일은 빼기 전까지 저장 · 보내기를 막는다
- 가져오는 · 올리는 동안 [보내기]를 막고(가져오기는 10분 시한), 올리는 중 나가기는 따로 묻는다
- 화면이 끝나거나 직원이 바뀌거나 앱을 띄울 때 복사본을 지운다

리뷰(M1~M4 · L 다섯 · 다시 본 리뷰의 L 하나)를 고쳤다. 시험 690 → 750.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 04:19:57 +09:00
ttipo f39f41ed4b docs: 앱 메일 쓰기 화면 1차의 본문 모양과 구현하며 정한 것을 적는다
계약 2절 "서식 본문"에 앱이 만드는 모양(서명 경계 div · 글로 만든 원문
인용 · 다시 열기 조건), 앱 디자인 README 「쓰기」에 구현하며 정한 것 ·
알려진 한계 · 실기기 점검, flutter 스킬에 쓰기 화면 줄과 위젯 · 시험
함정을 적는다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 02:11:01 +09:00
ttipo 80e48d318d feat(flutter): 메일 쓰기 화면 1차를 만든다 (App1Compose)
새 메일 · 답장 · 전체 답장 · 전달 · 이어 쓰기 · mailto 를 연다. 글만 쓰고,
보낼 때 웹과 같은 서식 본문(기본 글꼴 div · 서명 경계 div · "원본 메일"
인용)과 텍스트 본문을 함께 만든다 — 원문 인용은 글로 만든 덩이다.

- 임시 저장은 웹 규칙(3초 · 30초 · 10초 · 한 번에 하나)에 더해 앱이 안
  보이게 되면 바로 저장한다
- 보내기는 같은 Idempotency-Key 규칙(성공 · MAIL_005 에만 새 키), 보낸 뒤
  시작한 화면에 [보내기 취소] 마감 알림 · 남은 시간 막대
- 서명은 따로 된 칸([서명 빼기] · [서명 넣기]), 원본 메일은 접힌 채
- 리뷰 H1: 진행 중인 저장을 기다리는 사이 화면을 떠나면 메일이 나가지
  않던 것 — keepAlive 를 기다림 앞에서 잡고 떠나도 그대로 보낸다
- 리뷰 M1 · M2 · M3: 저장본 읽기는 읽기 화면과 같은 한도 · isolate,
  웹 저장본의 정렬 · 들여쓰기는 남의 저장본 길로, 확인 창의 거짓 "저장했습니다"
- 첨부 고르기 · 올리기는 2차(지금은 전달 원본 · 저장본의 첨부만 싣고 뺀다)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 02:11:00 +09:00
ttipo 695a343878 feat(flutter): 사람 고르기(core/people) — 주소 칩 · 받는 사람 추천 맞추기를 더한다
쓰기 화면의 받는 사람 칸이 쓰고, 나중의 캘린더 · 파일 모듈도 함께 쓰게
core 에 둔다. 주소 검사는 웹 mail-address.ts, 추천 맞추기(초성 · 겹자음 ·
조합 중 글자)는 웹 recipient-match.ts 와 짝이고, 표본 표는 웹 · 백엔드와
같은 70행을 같은 차례로 옮겼다(세 쪽 짝 — 한쪽을 고치면 모두).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 02:11:00 +09:00
ttipo 20cde01454 fix(flutter): 읽기 화면 예약 안내의 조사를 붙여 쓴다
"오전 9:00 에 보낼 메일입니다." → "오전 9:00에 보낼 메일입니다." 웹 9단계 글을
그대로 옮긴 것이었는데, 웹 실사용 시험(2026-11-06)에서 띄어쓰기가 틀렸다고
나와 웹과 함께 고친다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 02:10:59 +09:00
ttipo f4b1d0a206 docs: 앱 메일 읽기 화면의 구현하며 정한 것을 앱 디자인 README · flutter 스킬에 적는다
앱 디자인 README 「읽기」에 짜임 · 두 번째 정화 · 그림 한도 · 첨부 형식 막기 ·
붙잡힌 메일 · 알려진 한계 · 실기기 점검을, 「쓰기」에 mailto 숨은참조를
펼쳐 보일 것을 적는다. flutter 스킬 골격 표에 읽기 화면 · 받은 파일 줄과
인터셉터의 실패 본문 규칙을 더한다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 22:26:09 +09:00
ttipo a37045a553 feat(flutter): 메일 읽기 화면을 만든다 (App1Reader)
머리는 앱 화면, 본문은 스크립트를 끈 WebView 에 앱이 감싼 CSP 문서로
보인다. 받은 HTML 은 서버 정화 뒤 앱이 한 번 더 허용 목록으로 다시 쓰고,
외부 이미지는 [이미지 보기] 뒤 앱이 Dio 로 받아 data: 로 넣는다(토큰이
WebView 에 가지 않는다).

- 읽음 · 중요 · 이동 · 스팸 · 삭제 · 영구 삭제, 붙잡힌 메일의 보내기 취소 ·
  예약 취소는 목록과 같은 규칙. 응답은 그 요청이 바꾼 칸만 지금 값에 덮는다
- 첨부는 앱 임시 폴더(cacheDir/downloads)에 받아 FileProvider + ACTION_VIEW
  로 연다(파일 여는 플러그인 없이). 열 형식은 확장자 먼저, 웹 문서 · svg ·
  설치 파일 · 인증서는 받지 않는다
- 응답 인터셉터가 바이트 · 스트림 실패 본문(64KB 까지)에서 코드를 읽고,
  뜻밖의 실패에도 handler.next 로 끝낸다
- 리뷰 H1: 같은 그림을 수백 번 쓴 메일이 문서를 GB 단위로 키우던 것 —
  문서에 싣는 그림 글을 나온 횟수까지 세어 10M 자에서 멈춘다
- 리뷰 M1: 짧고 깊은 본문을 화면 isolate 에서 정화하던 것 — 길이와 중첩
  비용으로 고르고(6,144자 · 비용 5,000), 그 밖은 Isolate.run
- 새 의존성: webview_flutter 4.14.1 · webview_flutter_android 4.12.0 ·
  html 0.15.7(+ csslib 1.0.2) — 배포 2주 넘음. path_provider · mime 은
  lock 에 있던 판을 직접 의존으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 22:26:09 +09:00
ttipo ef23afc94c docs: 앱 메일 목록의 구현하며 정한 것을 앱 디자인 README · flutter 스킬에 적는다
- 앱 디자인 README: 고르기(줄 누르기 · 바뀌는 단추 · 영구 삭제 · 읽음 뒤 끝내기 · 선택 풀기),
  붙잡힌 메일, 알림 두 갈래, 서랍, 30일 줄 · [찾기] 준비 중, 새 메일 확인과 병합 규칙(웹과 다른 점),
  크기 맞춤, 메일함 이름 차례
- flutter 스킬 골격 표: 메일 목록 · 앱 틀 줄(틀은 Scaffold 가 아니다 · ShellBarHidden · 알림 두 갈래 ·
  생명주기 안 프로바이더 · 가짜 서버의 한 프레임 응답)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 19:36:31 +09:00
ttipo ad8b126abb feat(flutter): 메일 목록 화면을 만들고 앱 틀이 화면마다 탭 막대를 숨길 수 있게 한다 (App1MailList)
앱 틀(core/router):
- 틀을 Scaffold 가 아니라 Column + 탭 막대로 — 탭 안 화면마다 자기 Scaffold 를 두어 전역 알림이
  [메일 쓰기] 위에 뜬다. 탭 막대는 위 안전 영역을 빼고(실기기에서 상태 표시줄만큼 두꺼워지지 않게),
  화면이 ShellBarHidden 으로 숨길 수 있다(모듈마다 셈 · 보이는지는 TickerMode, 자판이 열리면 뺌)

메일 목록(features/mail):
- 메일함 서랍(바꿔 끼움 · 안 읽은 수는 회색 글), 줄(사진 · 보낸 사람 · 시각 / 제목 · 첨부 /
  미리 보기 한 줄 · 안 읽음 굵게 · 중요 별), 커서로 이어 받기 · 당겨서 새로고침 · 빈 메일함 · 30일 줄
- 고르기 막대(읽음 · 중요 · 이동 · 삭제) — 먼저 바꾸고 failedIds 만 되돌리기, 이동 · 삭제는 응답
  id 만, 100통씩, 휴지통으로 옮기고 [되돌리기], 휴지통에서는 확인 뒤 영구 삭제, 붙잡힌 메일은 고를 수 없음
- 새 메일 확인: 보이고 앱이 앞에 있을 때만 1분마다, 다시 받은 쪽이 끝까지면 지운 옛 메일을 뺀다
- [되돌리기] 알림은 [메일 쓰기]와 한 자리(화면 낭독기를 켠 동안은 닫지 않음), 단추 없는 알림은 전역 스낵바
- 시험 346건(새로 119)

리뷰 반영: 탭 막대 높이, 고르는 중 알림 줄 위치, 이어 받기 중 새로고침 실패, 붙잡힌 메일 다시 보기,
메일함 역할을 알기 전 그리지 않음, 선택 풀기 · 되살리기를 계약에 맞춤, 맞추기 한 번에 하나.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 19:36:31 +09:00
ttipo 4e779dedd2 docs: 앱 로그인 네 단계의 구현하며 정한 것을 앱 디자인 README · flutter 스킬에 적는다
- 앱 디자인 README: 단계마다 실패를 보이는 곳(표), 안내와 오류 함께, 세션 끝은 상자만,
  [다른 계정으로 로그인]을 막는 때, 2단계 켜기의 처음 초점 · 켠 뒤 알림, 다른 곳에서 켠 경우,
  주소 칸 자판, 글자 크기 맞춤, 새 글, 알려진 한계(인증 앱에 다녀오는 사이 앱이 정리되면 새 키 ·
  최근 앱 미리 보기 · 클립보드 · iOS)
- flutter 스킬 골격 표에 로그인 화면 줄(노티파이어 제한 단계 API · 알림 끄는 묶음 · url_launcher
  쓰는 규칙 · KeepAllText)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 15:57:39 +09:00
ttipo c14580e3a4 feat(flutter): 로그인 네 단계 화면을 만든다 (앱 1단계 — App1Login)
주소 · 비밀번호 → 인증 앱 코드 → 새 비밀번호 정하기(임시 비밀번호) → 2단계 켜기(필수일 때)를
세션 노티파이어의 상태로 고른다. 첫 단계만 큰 브랜드 띠, 그다음은 짙은 띠에 [다른 계정으로 로그인].

- 2단계 켜기는 같은 휴대폰이라 QR 대신 [인증 앱에 추가](otpauth — url_launcher 6.3.3,
  canLaunchUrl 은 주소를 로그에 남겨 쓰지 않는다) · 키 [복사] · 코드 칸
- 실패는 칸 아래 · 위쪽 상자 · 전역 알림으로 웹 로그인 틀과 같게(REQ_001 은 코드 · 제한 단계에서
  상자), 칸의 안내와 오류를 함께, 세션 끝은 로그인 화면 상자만
- 비밀번호 · 코드 · 2단계 키는 화면 메모리와 노티파이어의 10분 기억에만(두 제한 단계에서만 쥔다),
  비밀 요청의 실패 예외는 본문을 비운다 — 시험이 모든 프로바이더 값에 비밀이 없음을 본다
- 제한 단계를 보내는 동안 [다른 계정으로 로그인]을 막는다, 주소 칸만 자판 추천(@ 자판)
- 한글 낱말 단위 줄바꿈(KeepAllText), 시험 227건

리뷰 반영: 모르는 제한에서 비밀번호를 쥐지 않음, REQ_001 자리, 안내 · 오류 함께, 주소 칸 자판,
계약 테스트 목록(AUTH_005 · AUTH_008), AUTH_002 를 두 번 알리지 않음.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 15:57:39 +09:00
ttipo dd02b9621a fix(flutter): 모양이 틀린 로그인 응답의 토큰 · 다시 확인 재사용 창 · 기다리던 다시 맞추기를 고친다
앱 기반 3차 확인에서 나온 낮음 넷이다.

- 토큰은 실렸는데 모양이 틀린 로그인 응답은 응답을 뺀 실패로 던지고, 읽을 수 있는 토큰으로
  조용히 logout 한다(비밀 요청의 실패 예외는 응답 본문도 비운다)
- 확인하지 못한(unknown) 다시 확인 결과는 처음 물은 때부터 5초만 다시 쓴다
- 로그아웃 · 다른 계정 · 세션 끝 때 기다리던 다시 맞추기에 stale 로 답한다 — 새 로그인의 오류가
  앞 세션의 me 를 기다리지 않게
- postAppLogout 주석을 사실대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 13:05:46 +09:00
278 changed files with 61232 additions and 364 deletions
+29 -6
View File
@@ -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` 가 경고**한다.
+6
View File
@@ -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'] }],
+2
View File
@@ -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 와 같다).
*/
+248 -3
View File
@@ -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. 검색은 결과에서 여러 통 고르기를 미뤘다(읽기 화면의 같은 대화 · 목록 대화로 묶기도 — 보드 먼저).
+835
View File
@@ -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>
+484
View File
@@ -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>
+10 -3
View File
@@ -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": []
}
+26 -1
View File
@@ -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단계 (같은 대화 · 대화로 묶어 보기)
+39 -2
View File
@@ -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`).
## 라이선스
+4
View File
@@ -12,3 +12,7 @@ GeneratedPluginRegistrant.java
key.properties
**/*.keystore
**/*.jks
# 새 메일 알림(FCM)의 Firebase 설정 — 저장소에 넣지 않는다(이 템플릿을 클론한 곳마다 우리 Firebase 프로젝트를 가리키게 된다).
# 저장소 밖에 둔 파일을 본 트리 · worktree 마다 복사해 둔다(flutter/README "새 메일 알림")
app/google-services.json
+19
View File
@@ -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
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 739 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 461 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

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>
+3
View File
@@ -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")
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

+4
View File
@@ -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({
+30 -1
View File
@@ -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,
};
}
+10 -7
View File
@@ -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),
+7 -3
View File
@@ -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;
}
+6
View File
@@ -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>(
+4 -1
View File
@@ -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);
}
+10 -5
View File
@@ -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;
}
+68 -2
View File
@@ -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();
}
+2 -1
View File
@@ -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,
+46 -18
View File
@@ -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,
);
}
+116
View File
@@ -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,
);
+164
View File
@@ -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단계 인증을 켰습니다. 메일 프로그램은 앱 비밀번호로 연결합니다.';
}
+11 -12
View File
@@ -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;
}
}
}
+90
View File
@@ -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';
}
+95
View File
@@ -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);
}
+330
View File
@@ -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 {
// 다음 정리 · 기기가 지운다
}
});
}
+193
View File
@@ -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;
}
+66
View File
@@ -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(),
);
+714
View File
@@ -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 }
+60
View File
@@ -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;
}
}
+142
View File
@@ -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(),
);
+144
View File
@@ -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());
+74 -18
View File
@@ -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),
);
+250
View File
@@ -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,
}
+3 -2
View File
@@ -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(
+47
View File
@@ -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을 찾아 알림을 켜 주세요.';
}
+47 -19
View File
@@ -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();
}
+3
View File
@@ -20,4 +20,7 @@ abstract final class AppRoutes {
/// 제한된 세션 — 앱이 모르는 제한
static const String restricted = '/login/restricted';
/// 앱을 업데이트해야 한다 — 이 앱의 판이 서버의 최소 판보다 낮다(어느 화면에서든 · 로그인 전에도)
static const String update = '/update';
}
+104 -24
View File
@@ -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);
+129
View File
@@ -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;
}
+26
View File
@@ -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);
}
+177
View File
@@ -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;
}
+164
View File
@@ -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;
}
}
+40
View File
@@ -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(' '),
];
}
+44
View File
@@ -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);
}
+12
View File
@@ -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();
+58
View File
@@ -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);
+133
View File
@@ -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);
+33
View File
@@ -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);

Some files were not shown because too many files have changed in this diff Show More