From ef23afc94c10bb30b08f25fb410bbca0915c40f8 Mon Sep 17 00:00:00 2001 From: ttipo Date: Fri, 6 Nov 2026 19:36:31 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20=EC=95=B1=20=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=EC=9D=98=20=EA=B5=AC=ED=98=84=ED=95=98?= =?UTF-8?q?=EB=A9=B0=20=EC=A0=95=ED=95=9C=20=EA=B2=83=EC=9D=84=20=EC=95=B1?= =?UTF-8?q?=20=EB=94=94=EC=9E=90=EC=9D=B8=20README=20=C2=B7=20flutter=20?= =?UTF-8?q?=EC=8A=A4=ED=82=AC=EC=97=90=20=EC=A0=81=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 앱 디자인 README: 고르기(줄 누르기 · 바뀌는 단추 · 영구 삭제 · 읽음 뒤 끝내기 · 선택 풀기), 붙잡힌 메일, 알림 두 갈래, 서랍, 30일 줄 · [찾기] 준비 중, 새 메일 확인과 병합 규칙(웹과 다른 점), 크기 맞춤, 메일함 이름 차례 - flutter 스킬 골격 표: 메일 목록 · 앱 틀 줄(틀은 Scaffold 가 아니다 · ShellBarHidden · 알림 두 갈래 · 생명주기 안 프로바이더 · 가짜 서버의 한 프레임 응답) Co-Authored-By: Claude Opus 5.5 --- .claude/skills/flutter/SKILL.md | 1 + docs/design/app/README.md | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/.claude/skills/flutter/SKILL.md b/.claude/skills/flutter/SKILL.md index 441203f..b23c408 100644 --- a/.claude/skills/flutter/SKILL.md +++ b/.claude/skills/flutter/SKILL.md @@ -24,6 +24,7 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S | **세션** | `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/data/` | 계약 1절 "앱 로그인"(`/auth/app/login` · `refresh` · `logout`(리프레시 토큰 + Bearer) + Bearer `me`, 인터셉터 줄 밖의 세션 확인 `readSessionDirect`). 실패는 늘 `DioException`(`error` 에 `ApiException` — 응답 모양이 틀리면 `SYS_001`, `apiFailure()`). 모델은 손으로 쓴다(`AppLoginResult` · `AuthSessionInfo` · `SessionRestriction` — 모르는 제한은 `unknown`). 로그인 화면이 칸에 보이는 실패(`kLoginInlineCodes`)는 알리지 않는다 | | **로그인 화면 (네 단계 — `App1Login`)** | `lib/features/auth/presentation/pages/`(login · login_code · password_change · two_factor_setup · restricted_login) · `widgets/`(`login_frame` · `login_fields` · `login_parts`) · 글 `core/constants/login_texts.dart`(제목은 `screen_titles.dart`) · `data/`(`two_factor_repository` · `support_contact_repository` · `authenticator_launcher`) | 노티파이어의 제한 단계 API(`attempt` · `passedCodeStep` · `heldLoginListenable` · `changePassword` · `enableTwoFactor` · `finishRestrictedStep` → `RestrictedStepResult { done, needsPassword, stale }`). 로그인 비밀번호는 두 제한 단계에서만 10분 쥔다(모르는 제한 · 제한이 풀리면 지움). 칸 · 상자에 보이는 코드만 알림을 끈다(`kLoginInlineCodes` · `kLoginCodeInlineCodes` · `kPasswordChangeInlineCodes` · `kTwoFactorSetupInlineCodes` — 앱 디자인 README 의 표), `AUTH_002` 는 스낵바 없이 로그인 화면 상자만. 2단계 키 · otpauth 주소는 그 화면 State 에만. **url_launcher**: `canLaunchUrl` 은 쓰지 않는다(debug 판이 주소째 로그를 남긴다) — `launchUrl(externalNonBrowserApplication)` 만, 실패 예외에 주소가 들어 있어 삼킨다, `` 는 필요 없다. 한글 줄바꿈은 `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`(전역 스낵바). 생명주기 안에서는 프로바이더를 바꾸지 않는다(그린 뒤 · 마이크로태스크), 다른 노티파이어 인스턴스를 든 노티파이어는 세션이 바뀐 뒤 다시 읽는다. 가짜 서버는 응답이 한 프레임 안에 오므로 "받는 중이 끝나는 순간"을 기다리는 위젯을 만들지 않는다 | | 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` 와 같다) | | **ApiException** | `lib/core/api/api_exception.dart` | `{code, message, traceId, statusCode}`. 화면은 `apiErrorCodeOf(e)` 또는 `e.error as ApiException` 의 **`code`** 로 가른다 | diff --git a/docs/design/app/README.md b/docs/design/app/README.md index 515e24b..31b2066 100644 --- a/docs/design/app/README.md +++ b/docs/design/app/README.md @@ -57,6 +57,17 @@ - 당겨서 새로고침. 빈 메일함 · 빈 휴지통(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 는 앱의 쓰기 화면.