Skip to content

✨ [Feat] Watch P0 에러·오프라인 폴백 화면 8종 — 실패 원인 4축 분류 · 무음 실패 차단 (#1209) - #1288

Open
JEONG-J wants to merge 6 commits into
developfrom
feat/1209
Open

✨ [Feat] Watch P0 에러·오프라인 폴백 화면 8종 — 실패 원인 4축 분류 · 무음 실패 차단 (#1209)#1288
JEONG-J wants to merge 6 commits into
developfrom
feat/1209

Conversation

@JEONG-J

@JEONG-J JEONG-J commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

🔗 관련 이슈

Closes #1209

⚠️ 스택 PRfeat/1205(#1275) → feat/1206(#1280) → feat/1209(이 PR) 순으로 쌓았습니다.
형제 PR 과 동일하게 base 는 develop 이지만, 실제 diff 는 #1280 머지 이후에 이 PR 분량만 남습니다.
먼저 #1275 · #1280 을 머지해 주세요.

✨ PR 유형

  • 새로운 기능 추가 (Feature)

📷 스크린샷 or 영상(UI 변경 시)

폴백 9종 전부 #if DEBUG 프리뷰로 확인할 수 있게 넣어 두었습니다. Xcode 캔버스에서 바로 보실 수 있습니다.

프리뷰 위치
폴백 9종 갤러리 · A11y(accessibility3) 크기 WatchFallbackScene.swift
전체화면 3종 (권한 거부 · 출석 요청 실패 · 마감) WatchFallbackView.swift
오프라인 큐 대기 · 만료 카드 WatchOfflineQueue.swift
필수 확인 공지 배너 WatchMandatoryNotice.swift
배너가 얹힌 루트 화면 WatchRootView.swift

시뮬레이터 캡처는 실패 상태를 실제로 만들어 내는 출석 플로우(#1207)가 붙은 뒤에 첨부하겠습니다.

🛠️ 작업내용

스펙 §4 P0 갭 화면 8종을 실패 원인별로 구분되게 구현했습니다.

1. 실패 원인 4축 분류 — WatchFallbackReason

권한 · 연결 · 세션 · 서버 4축(WatchFailureCategory)으로 나누고, 화면 8종을 9개 케이스로 표현했습니다.
(P0-7 오프라인 큐는 "대기"와 "유효시간 만료"가 문구·아이콘·CTA 가 전부 다른 두 상태라 케이스를 갈랐습니다.)

케이스 화면
locationPermissionDenied permission P0-1 위치 권한 거부
locationUnavailable connectivity P0-2 GPS 실패/타임아웃
phoneDisconnected connectivity P0-3 iPhone 연결 끊김
checkInRequestFailed server P0-4 출석 요청 실패
alreadyCheckedIn session P0-5 이미 출석 완료
checkInWindowClosed session P0-6 출석 창 마감
offlineQueued connectivity P0-7 전송 대기
offlineQueueExpired connectivity P0-7 유효시간 만료
mandatoryNoticeUnread session P0-8 필수 확인 공지

2. 무음 실패 금지 — 컴파일·테스트 3중 잠금

  • WatchFallbackReason 에 연관값을 두지 않아 CaseIterable 이 합성됩니다 → 케이스를 추가하면 자동으로 기존 테스트 커버리지에 들어옵니다(문구 비어있음·제목 중복·4축 커버 검사).
  • category · presentation 스위치에 default: 를 두지 않았습니다 → 케이스 추가 시 컴파일 에러로 드러납니다.
  • init(classifying error: Error) 는 Optional 을 반환하지 않습니다 → 어떤 에러도 화면 없이 사라지지 않습니다. 분류되지 않은 에러는 재시도와 iPhone 대체 경로를 모두 가진 checkInRequestFailed 로 귀착시킵니다.

3. 동작 없는 CTA 금지

WatchFallbackScene.onPrimaryAction / onSecondaryAction 을 옵셔널로 두고, 핸들러가 없으면 버튼을 아예 그리지 않습니다.
눌러도 아무 일이 없는 버튼이 곧 무음 실패라, 만들 수 없게 구조로 막았습니다.
(#1205 WatchActionButton(disabledReason:) 의 "사유 없는 비활성 버튼 금지" 규약과 같은 계열입니다.)

4. 표시 형태 3종

  • 전체화면WatchFallbackView(WatchRoute.fallback(reason) 목적지). "다시 시도" 는 router.pop() 으로 요청을 띄운 화면으로 되돌리고, "iPhone 에서 시도" 는 router.popToRoot() 로 워치 쪽 시도를 접습니다.
  • 인라인 카드WatchOfflineQueueCard. 만료 시 .danger 표면으로 바뀌고, 대기 중에는 남은 유효 시간을 힌트에 꽂습니다.
  • 상단 고정 배너WatchMandatoryNoticeBanner. NavigationStack 바깥 safeAreaInset(edge: .top) 에 붙여 dismiss 제스처 자체가 존재하지 않습니다. confirm() 외에 사라지는 경로가 없습니다.

5. 오프라인 큐 3시간 유효창

서버가 수신 시각 기준 과거 180분 이내만 출석 판정에 쓰므로(스펙 §3.3), 워치가 보내기 전에 스스로 버리고 공결 사유 제출로 안내합니다. WatchOfflineQueueWindow.state(measuredAt:now:)Date.now 를 읽지 않는 순수 함수라 경계값을 테스트로 고정했습니다(0분 · 179분 · 180분 · 181분 · 시계 역행).

6. 진입 경로 연결

7. 동적 문구 주입 — replacing(title:message:hint:)

presentation 은 순수 정적 값이라, 화면을 그리는 시점에만 알 수 있는 문구는 한 메서드로 꽂습니다. P0-5 출석 시각(· HH:MM) · P0-7 남은 유효 시간 · P0-8 공지 제목이 전부 이 경로를 씁니다. (초기 구현의 replacingHint(_:)/replacingMessage(_:) 두 개를 합쳐 P0-5 의 제목 주입 경로도 함께 열었습니다.)

검증

make test SCHEME=UMCWatchApp DESTINATION='platform=watchOS Simulator,name=Apple Watch Series 11 (46mm)'
  → ** TEST SUCCEEDED **  (38 tests in 5 suites)  ※ 리뷰 반영 커밋 이후 재실행
make test SCHEME=CoreWatchDesignSystem  → ** TEST SUCCEEDED **
make test                                → ** TEST SUCCEEDED **

📋 추후 진행 상황

📌 리뷰 포인트

  1. 문구 — 9종의 한국어 카피(WatchFallbackPresentation.swift)를 봐 주세요. 스펙 §5 원문을 따라 -습니다 체로 통일했고, 제목이 서로 겹치지 않는 것을 테스트로 강제하고 있습니다. 어색한 표현이 있으면 이 파일 한 곳만 고치면 전 화면에 반영됩니다.

  2. 아이콘 대체 — 스펙의 "bt-slash" 에 대응하는 Bluetooth 글리프가 SF Symbols 에 없어 iphone.slash 로 대체했습니다(문구가 "iPhone 과 연결이 끊겼습니다"라 오히려 더 정확하다고 판단). 대안이 있으면 알려 주세요.

  3. 배너를 NavigationStack 바깥에 붙인 선택 — 스택 안에 두면 좌측 엣지 스와이프로 pop 돼 "무시 불가" 계약이 깨집니다. WatchRootView.swiftsafeAreaInset 위치를 봐 주세요.

  4. WatchStatusEquatable 추가WatchFallbackPresentationEquatable 합성을 위해 CoreWatchDesignSystem 에 순수 추가만 했습니다(동작 변화 없음).

  5. 미분류 위치 에러 처리 — 권한 거부를 뺀 나머지 CLError 는 코드를 가리지 않고 전부 locationUnavailable 로 보냅니다. 원인이 뭐든 사용자가 할 일(자리 옮기고 재시도)이 같고, 위치 실패를 서버 실패 화면으로 흘리면 안내가 틀리기 때문입니다.

  6. 폴백 화면이 캐시 데이터를 다시 보여줘야 하는지 — P0-3 "캐시 데이터만 표시"는 홈 글랜스 수준에서 충족했습니다(연결이 끊겨도 마지막 값이 남고 빈 세션 문구에 "(마지막 동기화 기준)"이 붙음). 폴백 화면 자체는 설명만 하고 데이터를 다시 그리지는 않는데, 디자인 의도가 어느 쪽인지 확인 부탁드립니다.

  7. 앰버(.warning #FFB340)의 의미 범위 — 여기서는 P0-1·P0-2·P0-3·P0-8 에 썼습니다. 앰버를 출석 "지각" 전용으로 예약해 둔 것인지(2026-08-26 스펙 개정), 경고 일반으로 써도 되는지 ✨ Feature: Watch 출석 플로우 화면 7종 (목록·정시·지각·지오펜스·결과 3종) #1207 출석 목록과 나란히 놓이기 전에 확정이 필요합니다.

✅ Checklist

PR이 다음 요구 사항을 충족하는지 확인해주세요!!!

- Core/WatchDesignSystem 모듈 신설 — iOS 카탈로그는 watchOS 에서 항상 dark 를
  resolve 하므로(브랜드색이 #4869F0 대신 #4264F0 이 됨) 워치 전용 레이어로 분리
- 토큰 3종 추가: WatchColor(표면·브랜드·상태·텍스트 20종) ·
  WatchTypography(WatchTextRole 5종) · WatchLayout(코너·보더·패딩·스페이싱)
- 표면 API 추가: watchCard(_:leadingAccent:) · watchScreenBackground() ·
  watchListRowBackground(isSelected:)
- 컴포넌트 2종 추가: WatchActionButton(역할 3종) · WatchStatusBadge(상태 5종)
- Glass 절제 규칙을 API 로 강제 — WatchCardStyle 을 닫힌 enum 으로 두어 카드에
  Glass 를 넣을 경로 자체를 없애고, Glass API 는 WatchActionButton 한 파일로 한정
- 토큰 드리프트 가드 테스트 추가 — iOS Colors.xcassets 를 파싱해 브랜드색 불일치 시 실패
- docs/claude/watch-design-system.md 신규 · CLAUDE.md 레퍼런스 인덱스 갱신
- WatchRoute/WatchFallbackRoute — 출석·The Ping 두 축 진입점과 폴백 3종(권한 거부·연결 끊김·큐잉)을 경로로 확보
- WatchRouter — 앱 셸이 소유하는 @observable 단일 스택. NavigationPath 대신 [WatchRoute] 구체 배열로 두어 스택 내용을 테스트로 검증
- WatchRootView — navigationDestination 단일 등록. 목적지는 후속 이슈가 교체할 플레이스홀더
- HomeGlanceView/HomeGlanceViewModel — solid 카드 글랜스, 세션·공지 행을 tap-chip 으로 승격, 세션 없을 때 "오늘 세션 없음" 빈 상태
- 승인 대기는 WatchStatus.pending(중립+인디고 링). 앰버 #FFB340 은 개정 스펙상 지각 전용이라 쓰지 않음
- 카운트·식별자는 전 레이어 String, Int 변환은 비교 시점에만 (규칙 #2). 숫자 아닌 값은 0건 처리
- watchAppProject 헬퍼에 includesTests 추가 + WKApplication 키 보강 — 없으면 테스트 호스트 설치가 거부됨
- ContentView("Hello Watch") 제거
- "오늘 세션" 라벨 + "오늘 세션 없음" 값이 같은 말을 두 줄로 반복해 위계가 흐려지던 문제
- 빈 상태는 문구 한 줄만 카드에 남긴다. 세션이 있을 때의 라벨/값 위계는 그대로
- 실패 원인 4축(권한·연결·세션·서버)으로 나눈 WatchFallbackReason 9종 추가
  (P0-7 오프라인 큐는 대기/만료가 문구·아이콘·CTA 가 달라 케이스를 분리)
- 무음 실패 3중 잠금 — 연관값 없는 enum 의 CaseIterable 합성으로 신규 케이스
  자동 테스트 편입, category·presentation 스위치 default 제거로 컴파일 강제,
  init(classifying:) 비Optional 로 미분류 에러도 화면 없이 사라지지 않게 함
- 동작 없는 CTA 금지 — WatchFallbackScene 핸들러를 옵셔널로 두어 핸들러를
  넘기지 않으면 버튼 자체를 렌더하지 않음
- 표시 형태 3종 — 전체화면(WatchFallbackView) · 인라인 카드(WatchOfflineQueueCard)
  · 상단 고정 배너(WatchMandatoryNoticeBanner)
- P0-8 배너를 NavigationStack 바깥 safeAreaInset 에 붙여 스와이프 dismiss 경로 제거
- 오프라인 큐 3시간 유효창 — 서버 판정 창(과거 180분)과 같은 값으로 워치가
  보내기 전에 스스로 버리고 공결 사유 제출로 안내
- P0-3 진입 경로 연결 — WatchSessionCoordinator.isReachable 로 글랜스에 폴백 행
  노출, 빈 세션 문구를 캐시 표기로 전환
- WatchStatus 에 Equatable 추가(WatchFallbackPresentation 합성용, 동작 변화 없음)
- docs/claude/watch-design-system.md 에 폴백 계약 섹션(§9) 추가
@JEONG-J JEONG-J added the ✨ Feature 새로운 기능을 추가합니다. label Aug 30, 2026
@JEONG-J JEONG-J self-assigned this Aug 30, 2026
- replacingHint/replacingMessage 를 replacing(title:message:hint:) 하나로 합쳐
  P0-5 "이미 출석 처리됨 · HH:MM" 의 시각 주입 경로를 열어 둠 (#1210 이 값 공급)
- DEBUG 전용 WatchFallbackDebugMenu 추가 — 실제 실패 신호가 붙기 전(#1207·#1210)에도
  폴백 9종·오프라인 큐 카드·필수 확인 배너를 실기기에서 검수할 수 있게 함
- 홈 글랜스에 DEBUG 하네스 진입 행 추가 (릴리스 빌드 미포함)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✨ Feature 새로운 기능을 추가합니다.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

✨ Feature: Watch P0 에러·오프라인 폴백 화면 8종

1 participant