From cdb651d03e945a96bd5a3b213d50149716dfe865 Mon Sep 17 00:00:00 2001 From: JEONG Date: Sun, 30 Aug 2026 08:57:13 +0900 Subject: [PATCH 1/4] =?UTF-8?q?refactor:=20WatchConnectivity=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=ED=8E=98=EC=9D=B4=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EA=B3=84=EC=95=BD=20=EC=A0=95=EC=9D=98=20+=20=EC=96=91?= =?UTF-8?q?=EB=B0=A9=ED=96=A5=20=EC=88=98=EC=8B=A0=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WatchEnvelope 봉투 코덱 추가 — JSON 을 단일 키에 Data 로 실어 sendMessage · updateApplicationContext · transferUserInfo 세 채널이 하나의 코덱을 공유 - WatchMessage(요청 5종) · WatchReply(응답 4종) · WatchConnectivityError(실패 11종) 계약 정의, 수동 Codable 로 스키마 버전 상한 검증 - 페이로드 값 타입 추가 — WatchSessionState · WatchSchedule · WatchNotice · WatchAttendanceRequest / Result. 서버 정수 식별자는 전 레이어 String (절대 규칙 #2) - WatchSessionCoordinator 를 @MainActor @Observable 로 재작성, WCSessionDelegate 양방향 수신 경로 구현 (요청 핸들러 주입 · userInfo AsyncStream · 앱 컨텍스트 상태) - replyHandler 없이 sendMessage 를 호출해 성공 시 continuation 이 매달리던 버그를 구조적으로 제거 — 모든 전송이 replyHandler 를 넘긴다 - WatchMessenger 삭제 — 구현체 1개짜리 pass-through 프로토콜이었고 Sendable 준수가 non-Sendable 코디네이터와 충돌 - CoreWatchConnectivity 에 테스트 타깃 활성화 (includesTests: true) - 계약 테스트 22개 추가 (2 suites) --- UMCApp/Core/WatchConnectivity/Project.swift | 3 +- .../Sources/Models/WatchAttendance.swift | 89 +++++ .../Models/WatchConnectivityError.swift | 37 ++ .../Sources/Models/WatchMessage.swift | 103 ++++++ .../Sources/Models/WatchNotice.swift | 79 ++++ .../Sources/Models/WatchReply.swift | 119 ++++++ .../Sources/Models/WatchSessionState.swift | 136 +++++++ .../Sources/WatchEnvelope.swift | 84 +++++ .../Sources/WatchMessenger.swift | 47 --- .../Sources/WatchSessionCoordinator.swift | 345 ++++++++++++++++-- .../Tests/WatchEnvelopeTests.swift | 177 +++++++++ .../Tests/WatchMessageTests.swift | 303 +++++++++++++++ 12 files changed, 1447 insertions(+), 75 deletions(-) create mode 100644 UMCApp/Core/WatchConnectivity/Sources/Models/WatchAttendance.swift create mode 100644 UMCApp/Core/WatchConnectivity/Sources/Models/WatchConnectivityError.swift create mode 100644 UMCApp/Core/WatchConnectivity/Sources/Models/WatchMessage.swift create mode 100644 UMCApp/Core/WatchConnectivity/Sources/Models/WatchNotice.swift create mode 100644 UMCApp/Core/WatchConnectivity/Sources/Models/WatchReply.swift create mode 100644 UMCApp/Core/WatchConnectivity/Sources/Models/WatchSessionState.swift create mode 100644 UMCApp/Core/WatchConnectivity/Sources/WatchEnvelope.swift delete mode 100644 UMCApp/Core/WatchConnectivity/Sources/WatchMessenger.swift create mode 100644 UMCApp/Core/WatchConnectivity/Tests/WatchEnvelopeTests.swift create mode 100644 UMCApp/Core/WatchConnectivity/Tests/WatchMessageTests.swift diff --git a/UMCApp/Core/WatchConnectivity/Project.swift b/UMCApp/Core/WatchConnectivity/Project.swift index dbe433a0f..3955fd0d1 100644 --- a/UMCApp/Core/WatchConnectivity/Project.swift +++ b/UMCApp/Core/WatchConnectivity/Project.swift @@ -8,5 +8,6 @@ let project = coreProject( deploymentTargets: .multiplatform(iOS: "26.4", watchOS: "26.4"), dependencies: [ .sdk(name: "WatchConnectivity", type: .framework, status: .required), - ] + ], + includesTests: true ) diff --git a/UMCApp/Core/WatchConnectivity/Sources/Models/WatchAttendance.swift b/UMCApp/Core/WatchConnectivity/Sources/Models/WatchAttendance.swift new file mode 100644 index 000000000..3f4d16096 --- /dev/null +++ b/UMCApp/Core/WatchConnectivity/Sources/Models/WatchAttendance.swift @@ -0,0 +1,89 @@ +// +// WatchAttendance.swift +// CoreWatchConnectivity +// +// Created by euijjang97 on 8/29/26. +// + +import Foundation + +// MARK: - WatchAttendanceRequest + +/// 워치가 iPhone 에 위임하는 GPS 출석 요청. +/// +/// 워치는 서버를 직접 치지 않는다. 좌표와 **측정 시각**을 iPhone 에 넘기면 iPhone 이 +/// `POST /api/v2/schedules/{scheduleId}/attendances/request` 를 대신 호출한다. +public struct WatchAttendanceRequest: Codable, Sendable, Equatable { + + // MARK: - Property + + /// 서버 정수 식별자를 String 으로 보존한다 (절대 규칙 #2 · `ScheduleDetailData.scheduleId` 와 동형). + public let scheduleId: String + public let latitude: Double + public let longitude: Double + /// 클라이언트 측 지오펜스 검증 결과. 서버 바디의 `locationVerified` 와 같은 값이다. + public let locationVerified: Bool + /// **기기에서 위치를 측정한 시각.** 오프라인 큐가 늦게 도착해도 이 시각으로 판정되므로, + /// 전송 시각이 아니라 측정 시각이라는 점이 이 필드의 존재 이유다. + public let measuredAt: Date + + // MARK: - Constant + + /// 서버가 `measuredAt` 을 판정에 쓰는 최대 지연(180분). 이 값을 넘긴 큐 항목은 보내 봐야 + /// 수신 시각으로 판정되어 결석이 되므로 워치가 스스로 버린다. + public static let maxQueueAge: TimeInterval = 180 * 60 + + // MARK: - Init + + public init( + scheduleId: String, + latitude: Double, + longitude: Double, + locationVerified: Bool, + measuredAt: Date + ) { + self.scheduleId = scheduleId + self.latitude = latitude + self.longitude = longitude + self.locationVerified = locationVerified + self.measuredAt = measuredAt + } + + // MARK: - Function + + /// 큐에서 버려야 하는 항목인지. 경계값(정확히 180분)은 **아직 유효**로 본다. + public func isExpired(now: Date = Date()) -> Bool { + now.timeIntervalSince(measuredAt) > Self.maxQueueAge + } +} + +// MARK: - WatchAttendanceResult + +/// 출석 결정 결과. iPhone → 워치 단방향(푸시 반영)과 왕복 응답 양쪽에서 쓴다. +public struct WatchAttendanceResult: Codable, Sendable, Equatable { + + // MARK: - Property + + public let scheduleId: String + /// **서버 `AttendanceStatus` 원본 문자열** — `PRESENT` / `LATE` / `EXCUSED` / `ABSENT` + /// / `PENDING` / `PRESENT_PENDING` / `LATE_PENDING` / `EXCUSED_PENDING`. + /// + /// 앱의 축약 enum(`AttendanceStatus`·`ScheduleAttendanceStatus`)으로 좁히지 **않는다.** + /// 두 enum 모두 `EXCUSED` 를 `.present` 로 합치는데, 워치는 공결 전용 결과 화면을 따로 + /// 그려야 하므로 합치는 순간 그 사용자가 볼 화면이 없어진다. + public let status: String + /// 운영진이 결정한 시각. 아직 대기 중이면 `nil`. + public let decidedAt: Date? + /// 사용자가 적은 사유를 운영진 결정 사유보다 우선한다 + /// (`AttendanceDecisionResult.toAttendance` 의 `excuseReason ?? decisionReason` 규칙과 동일). + public let reason: String? + + // MARK: - Init + + public init(scheduleId: String, status: String, decidedAt: Date?, reason: String?) { + self.scheduleId = scheduleId + self.status = status + self.decidedAt = decidedAt + self.reason = reason + } +} diff --git a/UMCApp/Core/WatchConnectivity/Sources/Models/WatchConnectivityError.swift b/UMCApp/Core/WatchConnectivity/Sources/Models/WatchConnectivityError.swift new file mode 100644 index 000000000..c41a90a7c --- /dev/null +++ b/UMCApp/Core/WatchConnectivity/Sources/Models/WatchConnectivityError.swift @@ -0,0 +1,37 @@ +// +// WatchConnectivityError.swift +// CoreWatchConnectivity +// +// Created by euijjang97 on 4/24/26. +// + +import Foundation + +/// 기기 간 통신에서 나올 수 있는 실패. +/// +/// `WCError` 코드 분류는 ``WatchSessionCoordinator`` 파일의 `from(_:)` 이 맡는다 — +/// 이 파일이 `WatchConnectivity` 를 import 하면 계약·코덱 계층 전체가 프레임워크에 묶여 +/// `WCSession` 을 활성화할 수 없는 유닛 테스트에서 검증 불가능해진다. +public enum WatchConnectivityError: Error { + + /// `WCSession.isSupported() == false` (iPad 등). + case notSupported + case sessionNotActivated + /// `sendMessage` 전용 — 즉시 채널만 reachable 을 요구한다. + case notReachable + /// 페이로드가 너무 크다 (7009). 호출자는 스냅샷 건수를 줄여야 한다. + case payloadTooLarge + /// 상대가 제때 응답하지 않았다 (7012). 호출자는 재시도하거나 큐로 넘긴다. + case replyTimedOut + /// 봉투를 읽을 수 없다. + case malformedPayload(String) + /// 지원하지 않는 스키마 버전. + case unsupportedSchemaVersion(Int) + /// 요청한 종류와 다른 응답이 왔다. + case unexpectedReply(WatchReply) + /// 이 채널로는 보낼 수 없는 종류다 — **호출자 오류**이며 상대의 거절(``remote(_:)``)과 다르다. + case unsupportedChannel(WatchMessage) + /// 상대가 처리에 실패했다고 응답했다. + case remote(WatchRemoteFailure) + case transportFailure(underlying: Error) +} diff --git a/UMCApp/Core/WatchConnectivity/Sources/Models/WatchMessage.swift b/UMCApp/Core/WatchConnectivity/Sources/Models/WatchMessage.swift new file mode 100644 index 000000000..58bfc11b6 --- /dev/null +++ b/UMCApp/Core/WatchConnectivity/Sources/Models/WatchMessage.swift @@ -0,0 +1,103 @@ +// +// WatchMessage.swift +// CoreWatchConnectivity +// +// Created by euijjang97 on 8/29/26. +// + +import Foundation + +/// WCSession 위로 오가는 메시지 봉투. +/// +/// 봉투를 두는 이유는 **같은 채널에 여러 종류가 섞여 흐르기 때문**이다. 수신 측이 `kind` 로 +/// 갈라내지 못하면 읽음 확인을 출석 요청으로 오인한다. +public enum WatchMessage: Codable, Sendable, Equatable { + + // MARK: - Watch → iPhone + + /// GPS 출석 요청. 온라인이면 왕복(``WatchReply/attendance(_:)``), 오프라인이면 큐잉된다. + case attendanceRequest(WatchAttendanceRequest) + /// 공지 읽음 확인. 단방향 — `transferUserInfo` 로만 보낸다. + case noticeRead(WatchNoticeRead) + /// 최신 스냅샷 요청. 응답은 ``WatchReply/state(_:)``. + case syncRequest + + // MARK: - iPhone → Watch + + /// 최신 스냅샷 밀어넣기. `updateApplicationContext` 로만 보낸다. + case sessionState(WatchSessionState) + /// 출석 결과가 **방금 바뀌었다**는 이벤트 (`ATTENDANCE_STATUS_CHANGED` 푸시 반영). + /// + /// ``sessionState(_:)`` 안의 `attendanceStatus` 와 값이 겹치지만 의미가 다르다 — + /// 상태는 배지를, 이벤트는 결과 화면 전환을 만든다. + case attendanceChanged(WatchAttendanceResult) + + // MARK: - Codable + + private enum CodingKeys: String, CodingKey { + case kind + case version + case attendanceRequest + case noticeRead + case sessionState + case attendanceChanged + } + + private enum Kind: String, Codable { + case attendanceRequest + case noticeRead + case syncRequest + case sessionState + case attendanceChanged + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 1 + guard (1...WatchSchema.currentVersion).contains(version) else { + throw WatchConnectivityError.unsupportedSchemaVersion(version) + } + + // 모르는 kind 는 `decode(Kind.self)` 가 그대로 던진다. 워치와 폰은 서로 다른 시점에 + // 업데이트되므로, 모르는 종류를 조용히 삼키면 어디서도 걸리지 않는다. + switch try container.decode(Kind.self, forKey: .kind) { + case .attendanceRequest: + self = .attendanceRequest( + try container.decode(WatchAttendanceRequest.self, forKey: .attendanceRequest) + ) + case .noticeRead: + self = .noticeRead(try container.decode(WatchNoticeRead.self, forKey: .noticeRead)) + case .syncRequest: + self = .syncRequest + case .sessionState: + self = .sessionState( + try container.decode(WatchSessionState.self, forKey: .sessionState) + ) + case .attendanceChanged: + self = .attendanceChanged( + try container.decode(WatchAttendanceResult.self, forKey: .attendanceChanged) + ) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(WatchSchema.currentVersion, forKey: .version) + switch self { + case .attendanceRequest(let request): + try container.encode(Kind.attendanceRequest, forKey: .kind) + try container.encode(request, forKey: .attendanceRequest) + case .noticeRead(let read): + try container.encode(Kind.noticeRead, forKey: .kind) + try container.encode(read, forKey: .noticeRead) + case .syncRequest: + try container.encode(Kind.syncRequest, forKey: .kind) + case .sessionState(let state): + try container.encode(Kind.sessionState, forKey: .kind) + try container.encode(state, forKey: .sessionState) + case .attendanceChanged(let result): + try container.encode(Kind.attendanceChanged, forKey: .kind) + try container.encode(result, forKey: .attendanceChanged) + } + } +} diff --git a/UMCApp/Core/WatchConnectivity/Sources/Models/WatchNotice.swift b/UMCApp/Core/WatchConnectivity/Sources/Models/WatchNotice.swift new file mode 100644 index 000000000..8bb5bcc30 --- /dev/null +++ b/UMCApp/Core/WatchConnectivity/Sources/Models/WatchNotice.swift @@ -0,0 +1,79 @@ +// +// WatchNotice.swift +// CoreWatchConnectivity +// +// Created by euijjang97 on 8/29/26. +// + +import Foundation + +// MARK: - WatchNotice + +/// 워치 공지 목록·본문이 쓰는 최소 필드. +/// +/// 원본은 `NoticeItemModel` 이지만 Core 는 Feature 에 의존할 수 없으므로 자체 값 타입으로 둔다. +/// 워치가 그리지 않는 필드(조회수·파트·링크·이미지·투표)는 싣지 않는다. +public struct WatchNotice: Codable, Sendable, Equatable, Identifiable { + + // MARK: - Property + + public var id: String { noticeId } + public let noticeId: String + public let title: String + /// 본문 전문. 워치가 본문을 스크롤해 읽고 확인 CTA 를 누른다. + public let content: String + /// `NoticeItemModel.displayWriter` 결과를 그대로 싣는다 — 이름/닉네임 조합 규칙은 iPhone 소유다. + public let writer: String + /// `NoticeItemModel.date`. + public let postedAt: Date + /// `NoticeItemModel.mustRead` — 필수확인 공지. 워치 상단 고정 배너의 근거다. + public let isMustRead: Bool + /// `NoticeItemModel.isAlert` — 긴급 표시. 좌측 색바 신호의 근거다. + public let isAlert: Bool + public let isRead: Bool + + // MARK: - Init + + public init( + noticeId: String, + title: String, + content: String, + writer: String, + postedAt: Date, + isMustRead: Bool, + isAlert: Bool, + isRead: Bool + ) { + self.noticeId = noticeId + self.title = title + self.content = content + self.writer = writer + self.postedAt = postedAt + self.isMustRead = isMustRead + self.isAlert = isAlert + self.isRead = isRead + } +} + +// MARK: - WatchNoticeRead + +/// 워치에서 공지를 읽었다는 확인. 워치 → iPhone 단방향. +/// +/// `memberId` 를 싣지 않는다 — 신원은 iPhone 이 안다 +/// (`NoticeReadRepositoryProtocol.markAsRead(noticeId:memberId:)` 의 `memberId` 는 iPhone 이 채운다). +/// 워치에 신원 정보를 두지 않는다는 원칙이기도 하다. +public struct WatchNoticeRead: Codable, Sendable, Equatable { + + // MARK: - Property + + public let noticeId: String + /// 읽은 시각. 오프라인 큐로 늦게 도착할 수 있어 도착 시각으로 대체할 수 없다. + public let readAt: Date + + // MARK: - Init + + public init(noticeId: String, readAt: Date) { + self.noticeId = noticeId + self.readAt = readAt + } +} diff --git a/UMCApp/Core/WatchConnectivity/Sources/Models/WatchReply.swift b/UMCApp/Core/WatchConnectivity/Sources/Models/WatchReply.swift new file mode 100644 index 000000000..fd3f3b79d --- /dev/null +++ b/UMCApp/Core/WatchConnectivity/Sources/Models/WatchReply.swift @@ -0,0 +1,119 @@ +// +// WatchReply.swift +// CoreWatchConnectivity +// +// Created by euijjang97 on 8/29/26. +// + +import Foundation + +// MARK: - WatchReply + +/// `replyHandler` 로 돌아가는 응답 봉투. +/// +/// ``WatchMessage`` 와 **분리한 이유**: 응답은 요청받은 쪽만 만든다. 한 enum 으로 합치면 +/// 출석 요청의 응답으로 `.noticeRead` 가 돌아오는 상태가 타입상 표현 가능해진다. +/// 두 방향을 나누면 그 상태 자체가 사라진다. +public enum WatchReply: Codable, Sendable, Equatable { + + /// 단방향 메시지의 수신 확인. 모든 `sendMessage` 가 `replyHandler` 를 넘기므로 + /// 응답이 필요 없는 메시지도 ack 를 돌려준다. + case ack + /// ``WatchMessage/syncRequest`` 의 응답. + case state(WatchSessionState) + /// ``WatchMessage/attendanceRequest(_:)`` 의 응답. + case attendance(WatchAttendanceResult) + /// 상대가 요청을 처리하지 못했다. **에러를 던지는 대신 응답으로 실어 보낸다** — + /// 그러지 않으면 송신자는 WCSession 타임아웃(7012)만 보고 원인을 알 수 없다. + case failure(WatchRemoteFailure) + + // MARK: - Codable + + private enum CodingKeys: String, CodingKey { + case kind + case version + case state + case attendance + case failure + } + + private enum Kind: String, Codable { + case ack + case state + case attendance + case failure + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 1 + guard (1...WatchSchema.currentVersion).contains(version) else { + throw WatchConnectivityError.unsupportedSchemaVersion(version) + } + + switch try container.decode(Kind.self, forKey: .kind) { + case .ack: + self = .ack + case .state: + self = .state(try container.decode(WatchSessionState.self, forKey: .state)) + case .attendance: + self = .attendance( + try container.decode(WatchAttendanceResult.self, forKey: .attendance) + ) + case .failure: + self = .failure(try container.decode(WatchRemoteFailure.self, forKey: .failure)) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(WatchSchema.currentVersion, forKey: .version) + switch self { + case .ack: + try container.encode(Kind.ack, forKey: .kind) + case .state(let state): + try container.encode(Kind.state, forKey: .kind) + try container.encode(state, forKey: .state) + case .attendance(let result): + try container.encode(Kind.attendance, forKey: .kind) + try container.encode(result, forKey: .attendance) + case .failure(let failure): + try container.encode(Kind.failure, forKey: .kind) + try container.encode(failure, forKey: .failure) + } + } +} + +// MARK: - WatchRemoteFailure + +/// 상대 기기가 요청을 처리하지 못한 사유. +public struct WatchRemoteFailure: Codable, Sendable, Equatable { + + // MARK: - Reason + + public enum Reason: String, Codable, Sendable { + /// 봉투를 디코딩하지 못했다 (손상). + case malformedPayload + /// 상대가 우리보다 새로운 스키마를 쓴다. 손상과 달리 **업데이트가 필요하다**는 신호다. + case unsupportedSchemaVersion + /// 핸들러 미등록이거나 이 방향에서 받을 수 없는 종류다. + case unsupportedRequest + /// iPhone 이 로그아웃 상태다. 워치는 로그인 안내를 그린다. + case notSignedIn + /// iPhone 이 서버 호출에 실패했다. + case upstreamFailed + } + + // MARK: - Property + + public let reason: Reason + /// 디버깅·표시용 보조 메시지. 사용자 문구는 워치가 `reason` 으로 고른다. + public let message: String? + + // MARK: - Init + + public init(reason: Reason, message: String? = nil) { + self.reason = reason + self.message = message + } +} diff --git a/UMCApp/Core/WatchConnectivity/Sources/Models/WatchSessionState.swift b/UMCApp/Core/WatchConnectivity/Sources/Models/WatchSessionState.swift new file mode 100644 index 000000000..ae00de5e6 --- /dev/null +++ b/UMCApp/Core/WatchConnectivity/Sources/Models/WatchSessionState.swift @@ -0,0 +1,136 @@ +// +// WatchSessionState.swift +// CoreWatchConnectivity +// +// Created by euijjang97 on 8/29/26. +// + +import Foundation + +// MARK: - WatchSessionState + +/// iPhone 이 워치에 밀어 넣는 **화면 한 장을 그리는 데 필요한 전부**. +/// +/// 워치는 서버를 직접 폴링하지 않는다. 이 스냅샷 하나가 `updateApplicationContext` 로 건너가며, +/// 시스템이 마지막 값을 보존하므로 워치가 콜드런치해도 즉시 그릴 수 있다 +/// (「iPhone 과 연결이 끊겼습니다 · 캐시 데이터만 표시」의 그 캐시가 이것이다). +/// +/// - Important: 페이로드가 크면 `updateApplicationContext` 가 `payloadTooLarge`(7009)로 던진다. +/// 목록 건수를 제한할 책임은 생산자(iPhone)에 있다 — 화면이 무엇을 필요로 하는지 아는 쪽이 +/// iPhone 이라 상한을 계약에 상수로 박지 않는다. +public struct WatchSessionState: Codable, Sendable, Equatable { + + // MARK: - Property + + /// 로그인 여부. `false` 면 워치는 목록 대신 「iPhone 에서 로그인해 주세요」를 그린다. + public let isSignedIn: Bool + public let schedules: [WatchSchedule] + public let notices: [WatchNotice] + /// 스냅샷 생성 시각. 워치가 「N분 전 정보」를 표시하고 신선도를 판단한다. + public let generatedAt: Date + + // MARK: - Init + + public init( + isSignedIn: Bool, + schedules: [WatchSchedule], + notices: [WatchNotice], + generatedAt: Date + ) { + self.isSignedIn = isSignedIn + self.schedules = schedules + self.notices = notices + self.generatedAt = generatedAt + } +} + +// MARK: - WatchSchedule + +/// 원본은 `ScheduleDetailData`. 워치 출석 화면이 쓰는 필드만 옮긴다. +public struct WatchSchedule: Codable, Sendable, Equatable, Identifiable { + + // MARK: - Property + + public var id: String { scheduleId } + public let scheduleId: String + /// `ScheduleDetailData.name`. + public let name: String + public let startsAt: Date + public let endsAt: Date + /// `nil` = 비대면 (`ScheduleDetailData.location` 이 `nil` 인 경우와 같은 의미). + public let location: WatchScheduleLocation? + /// `nil` = 출석 비필수 (`ScheduleDetailData.attendancePolicy` 와 같은 의미). + public let attendanceWindow: WatchAttendanceWindow? + /// 현재 사용자의 출석 상태. **서버 원본 문자열** (`WatchAttendanceResult.status` 와 동일 규약). + /// 서버가 내려주지 않았으면 `nil`. + public let attendanceStatus: String? + + // MARK: - Init + + public init( + scheduleId: String, + name: String, + startsAt: Date, + endsAt: Date, + location: WatchScheduleLocation?, + attendanceWindow: WatchAttendanceWindow?, + attendanceStatus: String? + ) { + self.scheduleId = scheduleId + self.name = name + self.startsAt = startsAt + self.endsAt = endsAt + self.location = location + self.attendanceWindow = attendanceWindow + self.attendanceStatus = attendanceStatus + } +} + +// MARK: - WatchScheduleLocation + +/// `ScheduleLocation` 과 1:1. +public struct WatchScheduleLocation: Codable, Sendable, Equatable { + + // MARK: - Property + + public let name: String + public let latitude: Double + public let longitude: Double + + // MARK: - Init + + public init(name: String, latitude: Double, longitude: Double) { + self.name = name + self.latitude = latitude + self.longitude = longitude + } +} + +// MARK: - WatchAttendanceWindow + +/// `ScheduleAttendancePolicy` 와 1:1. +/// +/// 세 시각을 낱개 옵셔널로 펼치지 않는 이유: 정책은 있거나 없거나지 반쪽일 수 없다. +/// 중첩 옵셔널 하나로 두면 `checkInStartAt` 만 있고 `lateEndAt` 은 없는 상태가 표현 불가능해진다. +/// +/// 지오펜스 반경은 싣지 않는다 — `AttendancePolicy.geofenceRadius`(50m)는 일정별 값이 아니라 +/// 앱 전역 상수라 워치가 자기 쪽에서 안다. +public struct WatchAttendanceWindow: Codable, Sendable, Equatable { + + // MARK: - Property + + /// 이전엔 출석 불가. + public let checkInStartAt: Date + /// 이후엔 지각. + public let onTimeEndAt: Date + /// 이후엔 결석. + public let lateEndAt: Date + + // MARK: - Init + + public init(checkInStartAt: Date, onTimeEndAt: Date, lateEndAt: Date) { + self.checkInStartAt = checkInStartAt + self.onTimeEndAt = onTimeEndAt + self.lateEndAt = lateEndAt + } +} diff --git a/UMCApp/Core/WatchConnectivity/Sources/WatchEnvelope.swift b/UMCApp/Core/WatchConnectivity/Sources/WatchEnvelope.swift new file mode 100644 index 000000000..4acbbaf10 --- /dev/null +++ b/UMCApp/Core/WatchConnectivity/Sources/WatchEnvelope.swift @@ -0,0 +1,84 @@ +// +// WatchEnvelope.swift +// CoreWatchConnectivity +// +// Created by euijjang97 on 8/29/26. +// + +import Foundation + +// MARK: - WatchSchema + +/// 봉투 스키마 버전. ``WatchMessage`` 와 ``WatchReply`` 가 공유한다. +/// +/// 상한을 검증하는 이유는 워치와 iPhone 이 서로 다른 시점에 업데이트되기 때문이다. +/// 상한이 없으면 미래의 v2 를 v1 로 읽고 **틀린 값을 조용히 받아들인다.** +public enum WatchSchema { + public static let currentVersion = 1 +} + +// MARK: - WatchEnvelope + +/// 봉투 ↔ WCSession 딕셔너리 코덱. +/// +/// `sendMessage` · `updateApplicationContext` · `transferUserInfo` 세 채널이 **하나의 코덱을 +/// 공유**하도록 JSON 을 단일 키에 통째로 싣는다. 필드를 딕셔너리에 펼치면 plist 타입 제약 +/// (옵셔널·중첩·enum 불가)을 페이로드마다 손으로 우회해야 한다. +/// +/// `WatchConnectivity` 를 import 하지 않는다 — `WCSession` 을 활성화할 수 없는 유닛 테스트 +/// 환경에서도 계약 전체를 검증할 수 있어야 한다. +public enum WatchEnvelope { + + // MARK: - Property + + /// JSON 을 싣는 단일 키. `Data` 는 plist 원시 타입이라 세 채널 모두 그대로 통과한다. + static let payloadKey = "p" + + static let jsonEncoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + return encoder + }() + + static let jsonDecoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + }() + + // MARK: - Function + + public static func encode(_ value: T) throws -> [String: Any] { + do { + return [payloadKey: try jsonEncoder.encode(value)] + } catch { + throw WatchConnectivityError.malformedPayload("봉투 인코딩 실패: \(error)") + } + } + + public static func decode( + _ type: T.Type, + from dictionary: [String: Any] + ) throws -> T { + guard let data = dictionary[payloadKey] as? Data else { + throw WatchConnectivityError.malformedPayload("봉투 키 '\(payloadKey)' 없음") + } + do { + return try jsonDecoder.decode(type, from: data) + } catch let error as WatchConnectivityError { + // 버전 상한 위반은 손상과 다른 신호다 — 뭉개면 호출자가 업데이트 안내를 못 한다. + throw error + } catch { + throw WatchConnectivityError.malformedPayload("봉투 디코딩 실패: \(error)") + } + } + + /// **절대 throw 하지 않는** 응답 인코더. + /// + /// `replyHandler` 는 어떤 경우에도 정확히 한 번 호출돼야 한다. 인코딩이 실패했다고 호출을 + /// 건너뛰면 송신자는 원인 없이 타임아웃(7012)만 본다. 그래서 실패 시 빈 딕셔너리를 보내고 + /// 송신자가 봉투 오류로 처리하게 둔다. + public static func encodeFallback(_ reply: WatchReply) -> [String: Any] { + (try? encode(reply)) ?? [:] + } +} diff --git a/UMCApp/Core/WatchConnectivity/Sources/WatchMessenger.swift b/UMCApp/Core/WatchConnectivity/Sources/WatchMessenger.swift deleted file mode 100644 index 4894a4e0b..000000000 --- a/UMCApp/Core/WatchConnectivity/Sources/WatchMessenger.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// WatchMessenger.swift -// CoreWatchConnectivity -// -// Created by euijjang97 on 4/24/26. -// - -#if canImport(WatchConnectivity) -import WatchConnectivity - -/// iOS ↔ watchOS 간 메시지 송수신 인터페이스 -/// -/// 실제 도메인 페이로드 정의는 후속 이슈에서 추가됩니다. -/// 현재는 WCSession 기반 통신 인프라만 제공합니다. -public protocol WatchMessengerProtocol: Sendable { - /// 상대방이 reachable 상태일 때 메시지를 즉시 전송합니다. - func sendMessage(_ message: [String: Any]) async throws - - /// 애플리케이션 컨텍스트를 업데이트합니다. - /// 상대방이 활성화되지 않은 경우에도 최신 상태를 전달할 수 있습니다. - func updateApplicationContext(_ context: [String: Any]) throws -} - -/// `WatchMessengerProtocol`의 `WCSession` 기반 구현체 -public final class WatchMessenger: WatchMessengerProtocol { - - // MARK: - Property - - private let coordinator: WatchSessionCoordinator - - // MARK: - Init - - public init(coordinator: WatchSessionCoordinator) { - self.coordinator = coordinator - } - - // MARK: - Function - - public func sendMessage(_ message: [String: Any]) async throws { - try await coordinator.sendMessage(message) - } - - public func updateApplicationContext(_ context: [String: Any]) throws { - try coordinator.updateApplicationContext(context) - } -} -#endif diff --git a/UMCApp/Core/WatchConnectivity/Sources/WatchSessionCoordinator.swift b/UMCApp/Core/WatchConnectivity/Sources/WatchSessionCoordinator.swift index 751c53cad..fe18ad2df 100644 --- a/UMCApp/Core/WatchConnectivity/Sources/WatchSessionCoordinator.swift +++ b/UMCApp/Core/WatchConnectivity/Sources/WatchSessionCoordinator.swift @@ -5,81 +5,372 @@ // Created by euijjang97 on 4/24/26. // -#if canImport(WatchConnectivity) +import Foundation +import Observation import WatchConnectivity -/// WCSession 활성화 및 상태 관리 코디네이터 +// MARK: - WatchRequestHandler + +/// 왕복 요청 처리기. +/// +/// `async` 인 이유는 iPhone 쪽 처리가 서버 왕복이라 본질적으로 비동기이기 때문이다. +/// **핸들러 자신이 타임아웃을 걸 책임이 있다** — 여기서 오래 붙들면 송신자는 +/// `messageReplyTimedOut`(7012)만 받고 원인을 모른다. +public typealias WatchRequestHandler = @Sendable (WatchMessage) async -> WatchReply + +// MARK: - WatchSessionCoordinator + +/// WCSession 활성화 · 상태 · 타입 안전 송수신을 모두 소유하는 어댑터. +/// +/// **`@MainActor` 인 이유**: 관측 상태를 SwiftUI 가 읽는데 `WCSessionDelegate` 콜백은 임의 +/// 스레드에서 온다. MainActor 격리 클래스는 암묵적으로 `Sendable` 이라 격리 경계도 함께 정리된다. /// -/// - `activate()` 호출 후 세션이 준비됩니다. -/// - reachability / activation 상태는 내부에서 WCSessionDelegate를 통해 관리합니다. +/// 델리게이트 메서드는 전부 `nonisolated` 이며 **동기적으로 디코딩한 뒤** MainActor 로 hop 한다. +/// 디코딩을 hop 뒤로 미루면 `[String: Any]`(non-Sendable)가 격리 경계를 넘어야 한다. +@MainActor @Observable public final class WatchSessionCoordinator: NSObject, WCSessionDelegate { // MARK: - Property - public private(set) var isReachable: Bool = false public private(set) var isActivated: Bool = false + public private(set) var isReachable: Bool = false + + /// 상대가 마지막으로 퍼블리시한 스냅샷. + /// + /// 활성화가 끝나는 시점(`activationDidCompleteWith`)에 `receivedApplicationContext` 로 + /// **시딩된다.** 이미 도착해 있던 컨텍스트에는 델리게이트 콜백이 다시 오지 않아, 시딩이 + /// 없으면 워치 콜드런치 화면이 빈다. + public private(set) var receivedState: WatchSessionState? + + @ObservationIgnored + private var requestHandler: WatchRequestHandler? + + /// `transferUserInfo` 수신 스트림. `init` 에서 미리 만들어 델리게이트가 **hop 없이** 바로 + /// yield 한다 — MainActor 잡 큐는 우선순위 큐라, hop 을 거치면 백그라운드 wake 로 들어온 + /// 항목이 포그라운드 항목에 추월당해 도착 순서가 뒤바뀐다. + /// + /// 버퍼는 `.unbounded` 다 — 읽음 확인은 건별로 전부 도달해야 해서 메모리보다 유실이 나쁘다. + /// 대신 아무도 구독하지 않으면 무한히 쌓인다. + @ObservationIgnored + private let userInfoStream: AsyncStream + + @ObservationIgnored + private let userInfoContinuation: AsyncStream.Continuation private var session: WCSession { .default } // MARK: - Init public override init() { + let (stream, continuation) = AsyncStream.makeStream() + userInfoStream = stream + userInfoContinuation = continuation super.init() } // MARK: - Function - /// WCSession을 활성화합니다. 앱 시작 시 한 번 호출합니다. + /// WCSession 을 활성화한다. 앱 시작 시 한 번 호출한다. public func activate() { guard WCSession.isSupported() else { return } session.delegate = self session.activate() } - /// 상대방이 reachable 상태일 때 메시지를 즉시 전송합니다. - public func sendMessage(_ message: [String: Any]) async throws { + /// 최신 스냅샷을 요청한다. + public func requestSync() async throws -> WatchSessionState { + let reply = try await sendMessage(.syncRequest) + switch reply { + case .state(let state): + return state + case .failure(let failure): + throw WatchConnectivityError.remote(failure) + case .ack, .attendance: + throw WatchConnectivityError.unexpectedReply(reply) + } + } + + /// GPS 출석을 iPhone 에 위임한다. 오프라인이면 `.notReachable` 이므로 호출자가 + /// ``enqueue(_:)`` 로 넘긴다. + public func requestAttendance( + _ request: WatchAttendanceRequest + ) async throws -> WatchAttendanceResult { + let reply = try await sendMessage(.attendanceRequest(request)) + switch reply { + case .attendance(let result): + return result + case .failure(let failure): + throw WatchConnectivityError.remote(failure) + case .ack, .state: + throw WatchConnectivityError.unexpectedReply(reply) + } + } + + /// 출석 결과 변경을 상대에게 즉시 알린다. 응답 `.ack` 는 도달 확인 용도로만 쓴다. + public func notifyAttendanceChanged(_ result: WatchAttendanceResult) async throws { + let reply = try await sendMessage(.attendanceChanged(result)) + switch reply { + case .ack: + return + case .failure(let failure): + throw WatchConnectivityError.remote(failure) + case .state, .attendance: + throw WatchConnectivityError.unexpectedReply(reply) + } + } + + /// 세션 스냅샷을 퍼블리시한다 (덮어쓰기). + public func publishSessionState(_ state: WatchSessionState) throws { + try requireActivated() + do { + let payload = try WatchEnvelope.encode(WatchMessage.sessionState(state)) + try session.updateApplicationContext(payload) + } catch let error as WatchConnectivityError { + throw error + } catch { + throw WatchConnectivityError.from(error) + } + } + + /// FIFO 큐에 넣는다. 앱이 종료돼도 시스템이 전송을 계속하므로 자체 큐 저장소를 두지 않는다. + public func enqueue(_ message: WatchMessage) throws { + switch message { + case .attendanceRequest, .noticeRead: + break + case .syncRequest, .sessionState, .attendanceChanged: + // 왕복 응답이 필요하거나 최신 1건만 의미 있는 종류다. 큐에 넣으면 응답이 유실되거나 + // 오래된 스냅샷이 뒤늦게 도착한다. + throw WatchConnectivityError.unsupportedChannel(message) + } + try requireActivated() + session.transferUserInfo(try WatchEnvelope.encode(message)) + } + + /// 아직 전송되지 않은 큐 항목. + /// + /// - Important: **관측 대상이 아니다.** `outstandingUserInfoTransfers` 는 `@Observable` 이 + /// 추적하지 못해, SwiftUI 가 바인딩해도 한 번 그린 뒤 영원히 갱신되지 않는다. 호출 시점의 + /// 스냅샷이므로 화면 캡션은 ``purgeExpiredQueue(now:)`` 의 반환값이나 타이머로 갱신한다. + public var pendingMessages: [WatchMessage] { + session.outstandingUserInfoTransfers.compactMap { + try? WatchEnvelope.decode(WatchMessage.self, from: $0.userInfo) + } + } + + /// `measuredAt` 기준 180분이 지난 출석 요청을 큐에서 취소한다. + /// + /// 보내도 수신 시각으로 판정되어 결석이 확정되므로 왕복이 무의미하다. + /// - Returns: 취소를 **시도한** 요청들. 이미 전송이 시작된 항목은 취소가 보장되지 않는다. + /// 호출자는 이 개수만큼 「사유 제출」 안내를 띄운다. + @discardableResult + public func purgeExpiredQueue(now: Date = Date()) -> [WatchAttendanceRequest] { + var purged: [WatchAttendanceRequest] = [] + for transfer in session.outstandingUserInfoTransfers { + guard + let message = try? WatchEnvelope.decode( + WatchMessage.self, from: transfer.userInfo + ), + case .attendanceRequest(let request) = message, + request.isExpired(now: now) + else { continue } + transfer.cancel() + purged.append(request) + } + return purged + } + + /// 왕복 요청 처리기를 등록한다. 등록 전에 도착한 요청에는 `.unsupportedRequest` 로 즉시 응답한다. + public func setRequestHandler(_ handler: @escaping WatchRequestHandler) { + requestHandler = handler + } + + /// `transferUserInfo` 로 도착한 메시지 스트림 (FIFO). + /// + /// 구독 전에 도착한 항목도 스트림 버퍼에 남아 있다가 구독 즉시 흘러나온다 — 시스템은 앱을 + /// 백그라운드로 깨워 배달하므로 화면이 스트림을 열기 전에 읽음 확인이 도착할 수 있다. + /// + /// - Note: 앱당 한 번만 구독한다. `AsyncStream` 은 이터레이터가 하나뿐이라, 두 번째 + /// 구독자는 첫 구독자가 소비하고 남은 것만 본다. + public func receivedUserInfo() -> AsyncStream { + userInfoStream + } + + // MARK: - Private + + private func requireActivated() throws { + guard WCSession.isSupported() else { + throw WatchConnectivityError.notSupported + } + guard session.activationState == .activated else { + throw WatchConnectivityError.sessionNotActivated + } + } + + /// 응답은 클로저 안에서 디코딩한다 — `[String: Any]` 를 continuation 밖으로 내보내면 + /// non-Sendable 값이 격리 경계를 넘는다. + private func sendMessage(_ message: WatchMessage) async throws -> WatchReply { + try requireActivated() guard session.isReachable else { throw WatchConnectivityError.notReachable } - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - session.sendMessage(message, replyHandler: nil) { error in - continuation.resume(throwing: error) + let payload = try WatchEnvelope.encode(message) + + return try await withCheckedThrowingContinuation { continuation in + session.sendMessage(payload) { raw in + do { + let reply = try WatchEnvelope.decode(WatchReply.self, from: raw) + continuation.resume(returning: reply) + } catch { + continuation.resume(throwing: error) + } + } errorHandler: { error in + continuation.resume(throwing: WatchConnectivityError.from(error)) } } } - /// 애플리케이션 컨텍스트를 업데이트합니다. - public func updateApplicationContext(_ context: [String: Any]) throws { - try session.updateApplicationContext(context) - } - // MARK: - WCSessionDelegate - public func session( + public nonisolated func session( _ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error? ) { - isActivated = activationState == .activated + // WCSession 은 Sendable 이 아니다. hop 하기 전에 값만 읽어 둔다. + let activated = activationState == .activated + let reachable = session.isReachable + // `receivedApplicationContext` 는 활성화가 끝난 뒤에야 채워진다. 활성화는 비동기라 + // `activate()` 직후에 읽으면 빈 딕셔너리를 받아 시딩이 조용히 무산된다. + let context: [String: Any] = activated ? session.receivedApplicationContext : [:] + let seeded = try? WatchEnvelope.decode(WatchMessage.self, from: context) + Task { @MainActor in + self.isActivated = activated + self.isReachable = reachable + // 델리게이트 콜백이 이미 더 최신 컨텍스트를 넣었다면 덮어쓰지 않는다. + if case .sessionState(let state)? = seeded, self.receivedState == nil { + self.receivedState = state + } + } + } + + public nonisolated func sessionReachabilityDidChange(_ session: WCSession) { + let reachable = session.isReachable + Task { @MainActor in + self.isReachable = reachable + } + } + + /// `replyHandler` 는 송신자의 타임아웃(7012) 안에 **정확히 한 번** 호출돼야 한다. + /// early return 과 `Task` 가 상호 배타적인 형태라 「한 번만 호출」 장치가 따로 필요 없다. + public nonisolated func session( + _ session: WCSession, + didReceiveMessage message: [String: Any], + replyHandler: @escaping ([String: Any]) -> Void + ) { + nonisolated(unsafe) let reply = replyHandler + + // 실패는 hop 없이 즉시 응답한다. + let decoded: WatchMessage + do { + decoded = try WatchEnvelope.decode(WatchMessage.self, from: message) + } catch WatchConnectivityError.unsupportedSchemaVersion(let version) { + // 손상이 아니라 상대가 더 새로운 스키마를 쓴다는 신호다. 「손상」으로 뭉개면 + // 상대는 업데이트가 필요하다는 사실을 알 수 없다. + reply( + WatchEnvelope.encodeFallback( + .failure( + .init(reason: .unsupportedSchemaVersion, message: "v\(version)") + ) + ) + ) + return + } catch { + reply(WatchEnvelope.encodeFallback(.failure(.init(reason: .malformedPayload)))) + return + } + + Task { @MainActor in + guard let handler = self.requestHandler else { + reply(WatchEnvelope.encodeFallback(.failure(.init(reason: .unsupportedRequest)))) + return + } + reply(WatchEnvelope.encodeFallback(await handler(decoded))) + } + } + + public nonisolated func session( + _ session: WCSession, + didReceiveApplicationContext applicationContext: [String: Any] + ) { + guard + let message = try? WatchEnvelope.decode( + WatchMessage.self, from: applicationContext + ), + case .sessionState(let state) = message + else { return } + Task { @MainActor in + self.receivedState = state + } } - public func sessionReachabilityDidChange(_ session: WCSession) { - isReachable = session.isReachable + public nonisolated func session( + _ session: WCSession, + didReceiveUserInfo userInfo: [String: Any] + ) { + guard let message = try? WatchEnvelope.decode(WatchMessage.self, from: userInfo) else { + return + } + // hop 하지 않는다 — continuation 은 Sendable 이고 yield 순서를 그대로 보존한다. + userInfoContinuation.yield(message) } #if os(iOS) - public func sessionDidBecomeInactive(_ session: WCSession) {} - public func sessionDidDeactivate(_ session: WCSession) { + /// 사용자가 다른 워치로 갈아타는 중이다. 이 세션으로는 더 보낼 수 없으므로 상태를 내린다 — + /// 남겨 두면 화면은 「연결됨」인데 전송만 조용히 실패한다. + public nonisolated func sessionDidBecomeInactive(_ session: WCSession) { + Task { @MainActor in + self.isActivated = false + self.isReachable = false + } + } + + public nonisolated func sessionDidDeactivate(_ session: WCSession) { session.activate() } #endif } -// MARK: - Error +// MARK: - WatchConnectivityError + WCError + +extension WatchConnectivityError { -public enum WatchConnectivityError: Error { - case notReachable - case sessionNotActivated + /// `WCSession` 콜백의 `NSError` 를 도메인 에러로 분류한다. + /// + /// 이 분류가 무너지면 화면은 원인 불문 「전송 오류」가 되고, 큐로 넘겨야 할 상황(도달 불가)과 + /// 스냅샷을 줄여야 할 상황(페이로드 초과)을 구분하지 못한다. + public static func from(_ error: Error) -> WatchConnectivityError { + if let error = error as? WatchConnectivityError { return error } + + let nsError = error as NSError + guard + nsError.domain == WCError.errorDomain, + let code = WCError.Code(rawValue: nsError.code) + else { + return .transportFailure(underlying: error) + } + + switch code { + case .notReachable: + return .notReachable + case .payloadTooLarge: + return .payloadTooLarge + case .messageReplyTimedOut: + return .replyTimedOut + case .sessionNotActivated: + return .sessionNotActivated + default: + return .transportFailure(underlying: error) + } + } } -#endif diff --git a/UMCApp/Core/WatchConnectivity/Tests/WatchEnvelopeTests.swift b/UMCApp/Core/WatchConnectivity/Tests/WatchEnvelopeTests.swift new file mode 100644 index 000000000..694da9557 --- /dev/null +++ b/UMCApp/Core/WatchConnectivity/Tests/WatchEnvelopeTests.swift @@ -0,0 +1,177 @@ +// +// WatchEnvelopeTests.swift +// CoreWatchConnectivityTests +// +// Created by euijjang97 on 8/29/26. +// + +import Foundation +import Testing +import WatchConnectivity +@testable import CoreWatchConnectivity + +/// 봉투를 WCSession 딕셔너리에 싣는 계약. +/// +/// 세 채널(`sendMessage`·`updateApplicationContext`·`transferUserInfo`)이 같은 코덱을 쓰는데, +/// 뒤의 둘은 plist 로 직렬화되는 딕셔너리만 받는다. 그 제약을 여기서 고정한다. +@Suite("WatchEnvelope — 딕셔너리 래핑") +struct WatchEnvelopeTests { + + // MARK: - Fixture + + private let fixedDate = Date(timeIntervalSince1970: 1_700_000_000) + + private func makeMessage() -> WatchMessage { + .attendanceRequest( + WatchAttendanceRequest( + scheduleId: "42", + latitude: 37.557_192, + longitude: 127.045_5, + locationVerified: true, + measuredAt: fixedDate + ) + ) + } + + // MARK: - Roundtrip + + @Test("딕셔너리 래핑·언래핑 왕복") + func dictionaryRoundtrip() throws { + let message = makeMessage() + + let decoded = try WatchEnvelope.decode( + WatchMessage.self, from: try WatchEnvelope.encode(message) + ) + + #expect(decoded == message) + } + + @Test("래핑 결과는 plist 호환이다 — 값이 Data 하나뿐이다") + func wrappedDictionaryIsPropertyList() throws { + let dictionary = try WatchEnvelope.encode(makeMessage()) + + #expect(dictionary.count == 1) + #expect(dictionary[WatchEnvelope.payloadKey] is Data) + #expect(PropertyListSerialization.propertyList(dictionary, isValidFor: .binary)) + } + + // MARK: - Malformed + + @Test("키가 없는 딕셔너리는 malformedPayload") + func missingKeyIsMalformed() { + do { + _ = try WatchEnvelope.decode(WatchMessage.self, from: [:]) + Issue.record("키 없는 딕셔너리가 통과함") + } catch WatchConnectivityError.malformedPayload(let description) { + #expect(!description.isEmpty) + } catch { + Issue.record("예상과 다른 에러: \(error)") + } + } + + @Test("값이 Data 가 아니면 malformedPayload") + func nonDataValueIsMalformed() { + do { + _ = try WatchEnvelope.decode( + WatchMessage.self, from: [WatchEnvelope.payloadKey: "not data"] + ) + Issue.record("Data 가 아닌 값이 통과함") + } catch WatchConnectivityError.malformedPayload(let description) { + #expect(!description.isEmpty) + } catch { + Issue.record("예상과 다른 에러: \(error)") + } + } + + @Test("손상된 JSON 은 malformedPayload") + func corruptedJSONIsMalformed() { + let payload: [String: Any] = [WatchEnvelope.payloadKey: Data("{not json".utf8)] + + do { + _ = try WatchEnvelope.decode(WatchMessage.self, from: payload) + Issue.record("손상된 JSON 이 통과함") + } catch WatchConnectivityError.malformedPayload(let description) { + #expect(!description.isEmpty) + } catch { + Issue.record("예상과 다른 에러: \(error)") + } + } + + @Test("딕셔너리 경로에서도 버전 상한 신호가 손상으로 뭉개지지 않는다") + func futureVersionSurvivesDictionaryPath() { + // 실제 수신 경로는 딕셔너리다. 여기서 malformedPayload 로 덮이면 상대는 「업데이트 + // 필요」 대신 「손상」이라는 답을 받고 원인을 찾지 못한다. + let payload: [String: Any] = [ + WatchEnvelope.payloadKey: Data(#"{"kind":"syncRequest","version":2}"#.utf8) + ] + + do { + _ = try WatchEnvelope.decode(WatchMessage.self, from: payload) + Issue.record("상한을 넘긴 버전이 통과함") + } catch WatchConnectivityError.unsupportedSchemaVersion(let version) { + #expect(version == 2) + } catch { + Issue.record("예상과 다른 에러: \(error)") + } + } + + // MARK: - Fallback + + @Test("encodeFallback 은 어떤 응답에도 빈 결과를 내지 않는다") + func encodeFallbackAlwaysProducesPayload() { + let replies: [WatchReply] = [ + .ack, + .state( + WatchSessionState( + isSignedIn: false, schedules: [], notices: [], generatedAt: fixedDate + ) + ), + .attendance( + WatchAttendanceResult( + scheduleId: "42", status: "PRESENT", decidedAt: nil, reason: nil + ) + ), + .failure(WatchRemoteFailure(reason: .malformedPayload)) + ] + + for reply in replies { + let encoded = WatchEnvelope.encodeFallback(reply) + #expect(encoded[WatchEnvelope.payloadKey] is Data) + } + } + + // MARK: - Error Classification + + @Test("WCError 코드는 처리 방법이 다른 도메인 에러로 갈린다") + func wcErrorCodesAreClassified() { + func classify(_ code: WCError.Code) -> WatchConnectivityError { + WatchConnectivityError.from( + NSError(domain: WCError.errorDomain, code: code.rawValue) + ) + } + + guard case .notReachable = classify(.notReachable) else { + Issue.record("7007 이 notReachable 로 분류되지 않음"); return + } + guard case .payloadTooLarge = classify(.payloadTooLarge) else { + Issue.record("7009 가 payloadTooLarge 로 분류되지 않음"); return + } + guard case .replyTimedOut = classify(.messageReplyTimedOut) else { + Issue.record("7012 가 replyTimedOut 으로 분류되지 않음"); return + } + guard case .transportFailure = classify(.genericError) else { + Issue.record("분류 대상이 아닌 코드가 transportFailure 로 떨어지지 않음"); return + } + } + + @Test("WCError 가 아닌 에러는 transportFailure 로 감싼다") + func foreignErrorFallsBackToTransportFailure() { + let error = WatchConnectivityError.from( + NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut) + ) + + guard case .transportFailure = error else { + Issue.record("다른 도메인의 에러가 transportFailure 로 감싸이지 않음"); return + } + } +} diff --git a/UMCApp/Core/WatchConnectivity/Tests/WatchMessageTests.swift b/UMCApp/Core/WatchConnectivity/Tests/WatchMessageTests.swift new file mode 100644 index 000000000..3538dc3d4 --- /dev/null +++ b/UMCApp/Core/WatchConnectivity/Tests/WatchMessageTests.swift @@ -0,0 +1,303 @@ +// +// WatchMessageTests.swift +// CoreWatchConnectivityTests +// +// Created by euijjang97 on 8/29/26. +// + +import Foundation +import Testing +@testable import CoreWatchConnectivity + +/// iOS ↔ watchOS 사이를 오가는 봉투의 계약. +/// +/// 다섯 종류가 **같은 채널**에 섞여 흐르므로, 디코딩이 종류를 갈라내지 못하면 읽음 확인이 +/// 출석 요청으로 오인된다. 두 기기가 서로 다른 시점에 업데이트되는 것도 여기서 고정한다. +@Suite("WatchMessage — 봉투 왕복") +struct WatchMessageTests { + + // MARK: - Fixture + + /// 날짜를 정수 초로 고정한다. + /// + /// 전송 포맷이 ISO8601 이라 소수점 이하 초가 인코딩에서 잘린다. `Date()` 기본값을 쓰면 + /// 왕복 후 마이크로초가 어긋나 동등 비교가 실패한다 — 봉투의 결함이 아니라 날짜 표현의 + /// 성질이므로, 그 성질을 피해서 봉투만 검증한다. + private let fixedDate = Date(timeIntervalSince1970: 1_700_000_000) + + private func makeRequest() -> WatchAttendanceRequest { + WatchAttendanceRequest( + scheduleId: "42", + latitude: 37.557_192, + longitude: 127.045_5, + locationVerified: true, + measuredAt: fixedDate + ) + } + + private func makeNotice() -> WatchNotice { + WatchNotice( + noticeId: "7", + title: "5주차 세미나 공지", + content: "이번 주 세미나는 온라인으로 진행합니다.", + writer: "정의찬", + postedAt: fixedDate, + isMustRead: true, + isAlert: false, + isRead: false + ) + } + + private func makeSchedule(location: WatchScheduleLocation?) -> WatchSchedule { + WatchSchedule( + scheduleId: "42", + name: "5주차 세미나", + startsAt: fixedDate, + endsAt: fixedDate.addingTimeInterval(7_200), + location: location, + attendanceWindow: location.map { _ in + WatchAttendanceWindow( + checkInStartAt: fixedDate.addingTimeInterval(-600), + onTimeEndAt: fixedDate.addingTimeInterval(600), + lateEndAt: fixedDate.addingTimeInterval(1_800) + ) + }, + attendanceStatus: location == nil ? nil : "PENDING" + ) + } + + private func makeState() -> WatchSessionState { + WatchSessionState( + isSignedIn: true, + schedules: [ + makeSchedule( + location: WatchScheduleLocation( + name: "한양대학교 IT/BT관", + latitude: 37.557_192, + longitude: 127.045_5 + ) + ), + makeSchedule(location: nil) + ], + notices: [makeNotice()], + generatedAt: fixedDate + ) + } + + private func makeResult(status: String) -> WatchAttendanceResult { + WatchAttendanceResult( + scheduleId: "42", + status: status, + decidedAt: fixedDate, + reason: "병원 진료" + ) + } + + private func roundtrip(_ message: WatchMessage) throws -> WatchMessage { + try WatchEnvelope.jsonDecoder.decode( + WatchMessage.self, from: WatchEnvelope.jsonEncoder.encode(message) + ) + } + + private func kindTag(_ message: WatchMessage) -> String { + switch message { + case .attendanceRequest: "attendanceRequest" + case .noticeRead: "noticeRead" + case .syncRequest: "syncRequest" + case .sessionState: "sessionState" + case .attendanceChanged: "attendanceChanged" + } + } + + // MARK: - Roundtrip + + @Test("attendanceRequest 왕복 — 좌표·검증 결과·측정 시각이 보존된다") + func attendanceRequestRoundtrip() throws { + let request = makeRequest() + + guard case .attendanceRequest(let restored) = try roundtrip(.attendanceRequest(request)) + else { + Issue.record("attendanceRequest 로 디코딩되지 않음"); return + } + #expect(restored == request) + #expect(restored.measuredAt == request.measuredAt) + } + + @Test("noticeRead 왕복 — 공지 식별자와 읽은 시각이 보존된다") + func noticeReadRoundtrip() throws { + let read = WatchNoticeRead(noticeId: "7", readAt: fixedDate) + + guard case .noticeRead(let restored) = try roundtrip(.noticeRead(read)) else { + Issue.record("noticeRead 로 디코딩되지 않음"); return + } + #expect(restored == read) + } + + @Test("syncRequest 왕복 — 연관값 없는 케이스가 kind 만으로 복원된다") + func syncRequestRoundtrip() throws { + guard case .syncRequest = try roundtrip(.syncRequest) else { + Issue.record("syncRequest 로 디코딩되지 않음"); return + } + } + + @Test("sessionState 왕복 — 중첩 목록과 옵셔널 위치·출석 정책이 보존된다") + func sessionStateRoundtrip() throws { + let state = makeState() + + guard case .sessionState(let restored) = try roundtrip(.sessionState(state)) else { + Issue.record("sessionState 로 디코딩되지 않음"); return + } + #expect(restored == state) + #expect(restored.schedules[0].location != nil) + #expect(restored.schedules[0].attendanceWindow != nil) + // 비대면 일정은 위치·정책이 함께 비어 있어야 한다. + #expect(restored.schedules[1].location == nil) + #expect(restored.schedules[1].attendanceWindow == nil) + #expect(restored.schedules[1].attendanceStatus == nil) + } + + @Test("attendanceChanged 왕복 — 서버 원본 상태 문자열이 축약되지 않는다") + func attendanceChangedPreservesServerStatus() throws { + let result = makeResult(status: "EXCUSED") + + guard case .attendanceChanged(let restored) = try roundtrip(.attendanceChanged(result)) + else { + Issue.record("attendanceChanged 로 디코딩되지 않음"); return + } + // 앱의 축약 enum 은 EXCUSED 를 present 로 합친다. 워치는 공결 전용 화면을 그려야 하므로 + // 이 문자열이 그대로 남아야 한다. + #expect(restored.status == "EXCUSED") + } + + @Test("다섯 종류가 섞여도 서로 오인되지 않는다") + func kindsAreDistinguished() throws { + let messages: [WatchMessage] = [ + .attendanceRequest(makeRequest()), + .noticeRead(WatchNoticeRead(noticeId: "7", readAt: fixedDate)), + .syncRequest, + .sessionState(makeState()), + .attendanceChanged(makeResult(status: "PRESENT")) + ] + + for message in messages { + #expect(kindTag(try roundtrip(message)) == kindTag(message)) + } + } + + // MARK: - Reply + + @Test("WatchReply 네 종류가 왕복하고 서로 오인되지 않는다") + func replyRoundtrip() throws { + let replies: [WatchReply] = [ + .ack, + .state(makeState()), + .attendance(makeResult(status: "LATE")), + .failure(WatchRemoteFailure(reason: .upstreamFailed, message: "500")) + ] + + for reply in replies { + let decoded = try WatchEnvelope.jsonDecoder.decode( + WatchReply.self, from: WatchEnvelope.jsonEncoder.encode(reply) + ) + #expect(decoded == reply) + } + + let attendanceData = try WatchEnvelope.jsonEncoder.encode( + WatchReply.attendance(makeResult(status: "PRESENT")) + ) + if case .state = try WatchEnvelope.jsonDecoder.decode( + WatchReply.self, from: attendanceData + ) { + Issue.record("attendance 응답이 state 로 오인됨") + } + } + + // MARK: - Schema + + @Test("모르는 버전은 조용히 오독하지 않고 던진다") + func futureVersionIsRejected() throws { + let data = Data(#"{"kind":"syncRequest","version":2}"#.utf8) + + do { + _ = try WatchEnvelope.jsonDecoder.decode(WatchMessage.self, from: data) + Issue.record("상한을 넘긴 버전이 통과함") + } catch WatchConnectivityError.unsupportedSchemaVersion(let version) { + #expect(version == 2) + } + } + + @Test("version 키가 없으면 v1 로 읽는다") + func missingVersionFallsBackToV1() throws { + let data = Data(#"{"kind":"syncRequest"}"#.utf8) + + guard case .syncRequest = try WatchEnvelope.jsonDecoder.decode( + WatchMessage.self, from: data + ) else { + Issue.record("version 없는 봉투를 읽지 못함"); return + } + } + + @Test("모르는 kind 는 조용히 삼키지 않고 던진다") + func unknownKindIsRejected() { + let data = Data(#"{"kind":"futureThing","version":1}"#.utf8) + + #expect(throws: DecodingError.self) { + try WatchEnvelope.jsonDecoder.decode(WatchMessage.self, from: data) + } + } + + @Test("응답 봉투도 모르는 kind 와 상한 넘긴 버전을 던진다") + func replyRejectsUnknownKindAndFutureVersion() { + // 응답은 요청과 반대 방향이라 계약이 따로 깨질 수 있다. 같은 규칙임을 여기서 고정한다. + #expect(throws: DecodingError.self) { + try WatchEnvelope.jsonDecoder.decode( + WatchReply.self, from: Data(#"{"kind":"futureThing","version":1}"#.utf8) + ) + } + + do { + _ = try WatchEnvelope.jsonDecoder.decode( + WatchReply.self, from: Data(#"{"kind":"ack","version":2}"#.utf8) + ) + Issue.record("상한을 넘긴 응답 버전이 통과함") + } catch WatchConnectivityError.unsupportedSchemaVersion(let version) { + #expect(version == 2) + } catch { + Issue.record("예상과 다른 에러: \(error)") + } + } + + @Test("서버 정수 식별자는 JSON 에서도 문자열이다") + func serverIdentifiersStayStrings() throws { + let requestJSON = try #require( + String( + data: try WatchEnvelope.jsonEncoder.encode( + WatchMessage.attendanceRequest(makeRequest()) + ), + encoding: .utf8 + ) + ) + let readJSON = try #require( + String( + data: try WatchEnvelope.jsonEncoder.encode( + WatchMessage.noticeRead(WatchNoticeRead(noticeId: "7", readAt: fixedDate)) + ), + encoding: .utf8 + ) + ) + + #expect(requestJSON.contains(#""scheduleId":"42""#)) + #expect(readJSON.contains(#""noticeId":"7""#)) + } + + // MARK: - Queue Age + + @Test("측정 후 180분까지는 큐에 남고 그 뒤엔 버린다") + func expiryBoundary() { + let request = makeRequest() + + #expect(!request.isExpired(now: fixedDate.addingTimeInterval(179 * 60))) + #expect(!request.isExpired(now: fixedDate.addingTimeInterval(180 * 60))) + #expect(request.isExpired(now: fixedDate.addingTimeInterval(181 * 60))) + } +} From 40d58e3ee30ac0e99e94f631d7d67762b3e60441 Mon Sep 17 00:00:00 2001 From: JEONG Date: Sun, 30 Aug 2026 09:05:56 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20watchOS=20=EB=94=94=EC=9E=90?= =?UTF-8?q?=EC=9D=B8=20=ED=86=A0=ED=81=B0=C2=B7=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=20=EA=B5=AC=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 레퍼런스 인덱스 갱신 --- CLAUDE.md | 1 + UMCApp/Core/WatchDesignSystem/Project.swift | 28 ++ .../Components/WatchActionButton.swift | 142 ++++++++ .../Sources/Components/WatchStatusBadge.swift | 137 ++++++++ .../Sources/Surfaces/WatchSurface.swift | 172 +++++++++ .../Sources/Tokens/WatchColor.swift | 108 ++++++ .../Sources/Tokens/WatchLayout.swift | 23 ++ .../Sources/Tokens/WatchTypography.swift | 44 +++ .../Tests/WatchColorTokenTests.swift | 146 ++++++++ UMCApp/UMCWatchApp/Project.swift | 1 + docs/claude/watch-design-system.md | 326 ++++++++++++++++++ 11 files changed, 1128 insertions(+) create mode 100644 UMCApp/Core/WatchDesignSystem/Project.swift create mode 100644 UMCApp/Core/WatchDesignSystem/Sources/Components/WatchActionButton.swift create mode 100644 UMCApp/Core/WatchDesignSystem/Sources/Components/WatchStatusBadge.swift create mode 100644 UMCApp/Core/WatchDesignSystem/Sources/Surfaces/WatchSurface.swift create mode 100644 UMCApp/Core/WatchDesignSystem/Sources/Tokens/WatchColor.swift create mode 100644 UMCApp/Core/WatchDesignSystem/Sources/Tokens/WatchLayout.swift create mode 100644 UMCApp/Core/WatchDesignSystem/Sources/Tokens/WatchTypography.swift create mode 100644 UMCApp/Core/WatchDesignSystem/Tests/WatchColorTokenTests.swift create mode 100644 docs/claude/watch-design-system.md diff --git a/CLAUDE.md b/CLAUDE.md index 4af21db08..df5878db4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,6 +113,7 @@ cd UMCApp && make doctor # 환경 진단 | Network Router (Moya) | `docs/claude/network-router.md` | API 엔드포인트/DTO 추가 | | Response DTO 디코딩 | `docs/claude/response-dto-decoding.md` | Response DTO 작성/수정 | | 디자인 시스템 & 성능 | `docs/claude/design-system.md` | UI/토큰/Glass/렌더링 최적화 | +| watchOS 디자인 시스템 | `docs/claude/watch-design-system.md` | 워치 화면·컴포넌트 작업, Glass 절제 규칙 확인 | | 코딩 스타일 & 네이밍 | `docs/claude/coding-style.md` | 네이밍 판단이 필요할 때 | | Git Workflow | `docs/claude/git-workflow.md` | 브랜치/커밋/PR/이슈(템플릿·Type·Priority)/배포 | | 프로젝트 구조(AppProduct) | `docs/claude/project-structure.md` | 레거시 디렉터리 탐색 | diff --git a/UMCApp/Core/WatchDesignSystem/Project.swift b/UMCApp/Core/WatchDesignSystem/Project.swift new file mode 100644 index 000000000..9633eb555 --- /dev/null +++ b/UMCApp/Core/WatchDesignSystem/Project.swift @@ -0,0 +1,28 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +// watchOS 디자인 시스템을 CoreDesignSystem 에 합치지 않고 별도 모듈로 둔 이유. +// +// 1) 토큰 값이 다르다. CoreDesignSystem 의 Colors.xcassets 는 colorset 마다 light/dark 를 +// 따로 갖는데 watchOS 는 항상 dark 로 해석한다. 같은 카탈로그를 워치에 링크하면 +// indigo500 이 스펙값 #4869F0 이 아니라 dark 값 #4264F0 으로 나온다(orange500 → #FF6C0F, +// grey400 → #7C8792 도 마찬가지). 그래서 이 모듈은 asset catalog 없이 sRGB 리터럴을 쓴다. +// 2) 타이포가 다르다. 워치 스펙은 시스템 폰트(SF) 기반이라 Pretendard(.otf 3종)가 필요 없다. +// CoreDesignSystem 을 링크하면 쓰지도 않는 폰트 리소스가 워치 앱 번들에 실린다. +// 3) 컴포넌트 규칙이 반대다. iOS 는 Glass 를 카드 배경까지 쓰지만 워치는 컨트롤/오버레이에만 +// 쓰고 콘텐츠 배경은 OLED 순수 블랙 + 불투명 solid 로 간다(가독성·배터리·번인). +// 한 모듈에 두면 44pt 고정 높이 버튼처럼 워치에서 쓰면 안 되는 API 가 그대로 자동완성에 뜬다. +// +// destinations 에 .iPhone 을 함께 넣은 것은 순전히 테스트 실행 경로 때문이다. Makefile 기본 +// DESTINATION 이 iOS 시뮬레이터라, 워치 전용으로 잡으면 `make test SCHEME=CoreWatchDesignSystem` +// 이 기본값으로 돌지 않는다. 이 모듈은 watchOS 전용 API 를 쓰지 않아 iOS 에서도 그대로 컴파일된다. +// 멀티플랫폼 Core 모듈 선례는 Core/WatchConnectivity 에 이미 있다. +// iOS 화면은 이 모듈을 쓰지 않는다 — CoreDesignSystem 을 쓴다. 의존하는 건 UMCWatchApp 뿐이다. +let project = coreProject( + name: "CoreWatchDesignSystem", + bundleIdSuffix: "watchdesignsystem", + destinations: [.iPhone, .appleWatch], + deploymentTargets: .multiplatform(iOS: "26.4", watchOS: "26.4"), + dependencies: [], + includesTests: true +) diff --git a/UMCApp/Core/WatchDesignSystem/Sources/Components/WatchActionButton.swift b/UMCApp/Core/WatchDesignSystem/Sources/Components/WatchActionButton.swift new file mode 100644 index 000000000..01e749173 --- /dev/null +++ b/UMCApp/Core/WatchDesignSystem/Sources/Components/WatchActionButton.swift @@ -0,0 +1,142 @@ +import SwiftUI + +// MARK: - WatchButtonRole + +/// 워치 액션 버튼 역할. Glass 는 **컨트롤에만** 허용되므로 세 역할 모두 Glass 배리언트를 쓴다. +public enum WatchButtonRole: Sendable, CaseIterable { + /// 화면당 1개. `.glassProminent` + 인디고 tint. + case primary + /// 보조 액션. `.glass` 중립(tint 없음). + case secondary + /// 파괴적이지만 **채우지 않는** 안전형. `.glass` + 에러 레드 tint. + /// 빨간 채움 버튼은 워치 좁은 화면에서 오탭을 유도하므로 쓰지 않는다. + case destructive +} + +// MARK: - WatchActionButton + +/// 워치 공통 CTA. 캡슐 형태는 watchOS Glass 버튼 스타일의 기본값이라 별도 지정하지 않는다. +/// +/// 비활성은 `disabledReason` 으로만 만든다 — **사유 없는 비활성 버튼을 만들 수 없다**. +/// 비활성일 때 버튼은 회색조 `.glass` 로 바뀌고, 사유가 버튼 아래 캡션으로 노출되며 +/// VoiceOver 에는 `accessibilityValue` 로 전달된다(캡션 자체는 중복 낭독 방지로 숨김). +/// hint 가 아니라 value 인 이유: hint 는 VoiceOver 설정에서 꺼질 수 있는 보조 정보인데 +/// "왜 못 누르는가"는 필수 정보다. +/// +/// 높이를 고정하지 않는다 — iOS `PrimaryButtonStyle`(height 44) 을 워치에 가져오면 +/// 큰 Dynamic Type 에서 라벨이 잘린다. +public struct WatchActionButton: View { + + // MARK: - Property + + private let title: String + private let role: WatchButtonRole + private let systemImage: String? + private let disabledReason: String? + private let action: () -> Void + + // MARK: - Init + + /// - Note: `.primary` 는 화면당 1개 제약이 있어 기본값으로 두지 않는다. + /// 화면의 대표 CTA 에만 명시적으로 지정한다. + public init( + _ title: String, + role: WatchButtonRole = .secondary, + systemImage: String? = nil, + disabledReason: String? = nil, + action: @escaping () -> Void + ) { + self.title = title + self.role = role + self.systemImage = systemImage + self.disabledReason = disabledReason + self.action = action + } + + // MARK: - Body + + public var body: some View { + VStack(spacing: WatchLayout.tightSpacing) { + styledButton + + if let disabledReason { + Text(disabledReason) + .font(.watch(.caption)) + .foregroundStyle(WatchColor.textSecondary) + .multilineTextAlignment(.center) + .accessibilityHidden(true) + } + } + } + + // MARK: - Function + + @ViewBuilder + private var styledButton: some View { + if let disabledReason { + baseButton + .buttonStyle(.glass) + .foregroundStyle(WatchColor.textDisabled) + .disabled(true) + .accessibilityValue(disabledReason) + } else { + switch role { + case .primary: + baseButton.buttonStyle(.glassProminent).tint(WatchColor.brandPrimary) + case .secondary: + baseButton.buttonStyle(.glass) + case .destructive: + baseButton.buttonStyle(.glass).tint(WatchColor.statusError) + } + } + } + + private var baseButton: some View { + Button(action: action) { + label.frame(maxWidth: .infinity) + } + } + + @ViewBuilder + private var label: some View { + if let systemImage { + Label(title, systemImage: systemImage) + } else { + Text(title) + } + } +} + +#if DEBUG +private struct WatchActionButtonGallery: View { + + var body: some View { + ScrollView { + VStack(spacing: WatchLayout.stackSpacing) { + WatchActionButton("출석 체크", role: .primary, systemImage: "checkmark") {} + WatchActionButton("나중에", role: .secondary) {} + WatchActionButton("출석 취소", role: .destructive) {} + WatchActionButton( + "출석 체크", + disabledReason: "출석 장소에서 200m 밖입니다" + ) {} + } + .padding(.horizontal, WatchLayout.screenHorizontalPadding) + } + .watchScreenBackground() + } +} + +#Preview("WatchActionButton — 4 상태") { + NavigationStack { + WatchActionButtonGallery() + } +} + +#Preview("WatchActionButton — A11y 크기") { + NavigationStack { + WatchActionButtonGallery() + } + .dynamicTypeSize(.accessibility3) +} +#endif diff --git a/UMCApp/Core/WatchDesignSystem/Sources/Components/WatchStatusBadge.swift b/UMCApp/Core/WatchDesignSystem/Sources/Components/WatchStatusBadge.swift new file mode 100644 index 000000000..1101020a9 --- /dev/null +++ b/UMCApp/Core/WatchDesignSystem/Sources/Components/WatchStatusBadge.swift @@ -0,0 +1,137 @@ +import SwiftUI + +// MARK: - WatchStatus + +/// 시맨틱 상태 축. 브랜드 색(`WatchColor.brandAccent` 등)과 **분리**되어 있다. +/// 각 케이스는 색뿐 아니라 **실루엣이 다른** SF Symbol 을 가진다 — +/// 색각 이상 사용자가 색 없이도 구분할 수 있어야 한다. +public enum WatchStatus: Sendable, CaseIterable { + /// 진행 중 — 인디고 원판. + case active + /// 승인 대기 — 회색 점 + 인디고 링(팔레트 렌더링). + case pending + /// 완료 — 체크. + case success + /// 주의 — 삼각형. + case warning + /// 실패 — 팔각형. + case error + + /// 상태 색. `pending` 은 점 색이고, 링 색은 `ringTint` 다. + public var tint: Color { + switch self { + case .active: WatchColor.statusActive + case .pending: WatchColor.statusPending + case .success: WatchColor.statusSuccess + case .warning: WatchColor.statusWarning + case .error: WatchColor.statusError + } + } + + /// 2차 색 — `pending` 의 인디고 링에만 쓰인다. 그 외에는 `tint` 와 같다. + /// `active` 원판(`brandPrimary`)과 같은 인디고를 쓰면 라벨 없는 경로에서 둘이 섞이므로 + /// 한 단계 밝은 `brandPrimarySoft` 를 쓴다. + public var ringTint: Color { + self == .pending ? WatchColor.brandPrimarySoft : tint + } + + /// 실루엣이 서로 다른 심볼. 색을 못 봐도 구분되도록 고른 값이다. + /// 원판 / 점+링 / 원안체크 / 삼각형 / 팔각형. + public var symbolName: String { + switch self { + case .active: "circle.fill" + case .pending: "smallcircle.filled.circle" + case .success: "checkmark.circle.fill" + case .warning: "exclamationmark.triangle.fill" + case .error: "xmark.octagon.fill" + } + } + + /// 화면이 문구를 주지 않을 때 쓰는 기본 라벨. + public var defaultLabel: String { + switch self { + case .active: "진행 중" + case .pending: "승인 대기" + case .success: "완료" + case .warning: "주의" + case .error: "실패" + } + } +} + +// MARK: - WatchStatusBadge + +/// 상태 표시. **색 단독으로 상태를 표현하지 않는다** — 심볼 실루엣이 다르고, +/// 기본적으로 텍스트를 병기한다. +/// +/// - `showsLabel: true` — 심볼 + 텍스트. 심볼은 `accessibilityHidden`, 전체를 하나의 +/// 접근성 요소로 합쳐 텍스트만 낭독한다. +/// - `showsLabel: false` — 리스트 행처럼 폭이 없는 자리용. 심볼만 그리되 +/// `accessibilityLabel` 로 같은 문구를 반드시 노출한다. +public struct WatchStatusBadge: View { + + // MARK: - Property + + private let status: WatchStatus + private let label: String? + private let showsLabel: Bool + + private var resolvedLabel: String { label ?? status.defaultLabel } + + // MARK: - Init + + public init( + _ status: WatchStatus, + label: String? = nil, + showsLabel: Bool = true + ) { + self.status = status + self.label = label + self.showsLabel = showsLabel + } + + // MARK: - Body + + public var body: some View { + if showsLabel { + HStack(spacing: WatchLayout.tightSpacing) { + symbol.accessibilityHidden(true) + Text(resolvedLabel) + .font(.watch(.cardLabel)) + .foregroundStyle(WatchColor.textPrimary) + } + .accessibilityElement(children: .combine) + } else { + symbol.accessibilityLabel(resolvedLabel) + } + } + + // MARK: - Function + + private var symbol: some View { + Image(systemName: status.symbolName) + .symbolRenderingMode(.palette) + .foregroundStyle(status.tint, status.ringTint) + .font(.watch(.cardLabel)) + } +} + +#if DEBUG +#Preview("WatchStatusBadge — 5 상태 × 라벨 유무") { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: WatchLayout.stackSpacing) { + ForEach(Array(WatchStatus.allCases.enumerated()), id: \.offset) { _, status in + HStack(spacing: WatchLayout.stackSpacing) { + WatchStatusBadge(status) + Spacer(minLength: 0) + WatchStatusBadge(status, showsLabel: false) + } + } + } + .padding(.horizontal, WatchLayout.screenHorizontalPadding) + } + .watchScreenBackground() + } +} +#endif diff --git a/UMCApp/Core/WatchDesignSystem/Sources/Surfaces/WatchSurface.swift b/UMCApp/Core/WatchDesignSystem/Sources/Surfaces/WatchSurface.swift new file mode 100644 index 000000000..7b8f4ed7d --- /dev/null +++ b/UMCApp/Core/WatchDesignSystem/Sources/Surfaces/WatchSurface.swift @@ -0,0 +1,172 @@ +import SwiftUI + +// MARK: - WatchCardStyle + +/// 카드 표면 종류. **전부 불투명 solid** 이며 Glass 배리언트는 존재하지 않는다. +/// 콘텐츠 배경 Glass 금지 규칙을 타입 수준에서 강제하기 위한 닫힌 집합이다. +public enum WatchCardStyle: Sendable, CaseIterable { + /// 일반 카드 — #16181C / border #2A2D34 + case standard + /// 대표 지표·다음 일정 등 Hero — #1B2140 / border 인디고 45% + case hero + /// 위험·실패·파괴적 맥락 — #241416 / border 에러 레드 40% + case danger +} + +// MARK: - WatchCardStyle + Palette + +extension WatchCardStyle { + + var fill: Color { + switch self { + case .standard: WatchColor.cardBackground + case .hero: WatchColor.heroBackground + case .danger: WatchColor.dangerBackground + } + } + + var border: Color { + switch self { + case .standard: WatchColor.cardBorder + case .hero: WatchColor.heroBorder + case .danger: WatchColor.dangerBorder + } + } +} + +// MARK: - WatchSurfaceShape + +enum WatchSurfaceShape { + + static var shape: ConcentricRectangle { + ConcentricRectangle( + corners: .concentric(minimum: WatchLayout.cardCornerRadius), + isUniform: true + ) + } + + static func border(_ color: Color) -> some View { + shape.stroke(color, lineWidth: WatchLayout.cardBorderWidth) + } +} + +// MARK: - WatchLayout + Shape + +public extension WatchLayout { + + /// 카드와 동일한 곡률. 중첩 콘텐츠 clip·히트영역(`contentShape`) 정합용. + static var cardShape: ConcentricRectangle { WatchSurfaceShape.shape } +} + +// MARK: - WatchSurface + +/// `watchListRowBackground` 전용 표면 뷰 — `List` 행 배경은 뷰 하나로 넘겨야 한다. +/// `watchCard` 는 콘텐츠를 감싸는 모디파이어 체인이라 이 뷰를 쓰지 않고, +/// 보더 렌더링만 `WatchSurfaceShape.border(_:)` 로 공유한다. +struct WatchSurface: View { + + // MARK: - Property + + let style: WatchCardStyle + + // MARK: - Body + + var body: some View { + WatchSurfaceShape.shape + .fill(style.fill) + .overlay { WatchSurfaceShape.border(style.border) } + } +} + +// MARK: - View + Watch Surface + +public extension View { + + /// 워치 공통 카드. 패딩·불투명 배경·1pt 보더·동심 모서리를 한 번에 적용한다. + /// + /// - Parameters: + /// - style: 표면 종류 (기본 `.standard`). + /// - leadingAccent: 좌측 색바 색. `nil`이면 그리지 않는다. + /// 긴급 공지처럼 **색 이외의 위치 신호**가 필요할 때만 쓴다 (#1208). + func watchCard( + _ style: WatchCardStyle = .standard, + leadingAccent: Color? = nil + ) -> some View { + // accent bar 를 background 와 clipShape 사이에 끼워야 모서리 안쪽으로 잘린다. + // 보더는 clipShape 뒤에 얹어야 1pt 가 온전히 남는다 — ConcentricRectangle 은 + // InsettableShape 가 아니라 strokeBorder 를 쓸 수 없어 stroke + overlay 조합이다. + // + // containerShape 는 붙이지 않는다: SDK 상 concentric 곡률을 containerShape 로 전달할 + // 방법이 없고(ConcentricRectangle 이 RoundedRectangularShape 비채택), 리터럴 22 을 + // 선언하면 실제 해석값(디스플레이 곡률 기반, 22 초과)과 달라 자식이 틀린 값을 상속한다. + // 중첩 콘텐츠는 `WatchLayout.cardShape` 로 직접 맞춘다. + self + .padding(WatchLayout.cardContentPadding) + .frame(maxWidth: .infinity, alignment: .leading) + .background(style.fill) + .overlay(alignment: .leading) { + if let leadingAccent { + leadingAccent.frame(width: WatchLayout.accentBarWidth) + } + } + .clipShape(WatchSurfaceShape.shape) + .contentShape(WatchSurfaceShape.shape) + .overlay { WatchSurfaceShape.border(style.border) } + } + + /// 화면 전체 배경을 순수 블랙(#000000)으로 고정한다. + /// watchOS 기본 네비게이션 그라디언트 배경을 덮는다. + /// + /// - Important: `NavigationStack` **destination 의 최상위 콘텐츠**에 적용한다. + func watchScreenBackground() -> some View { + containerBackground(WatchColor.screen, for: .navigation) + } + + /// `List` 행 배경. 기본 시스템 행 배경(반투명)을 불투명 solid 로 교체한다 — + /// 리스트 행은 Glass 금지 구역이다. + /// + /// - Parameter isSelected: `true`면 Hero 표면(인디고 tint) + 좌측 색바로 선택을 표현한다. + /// Hero fill 단독은 standard 대비 명암비가 1.13:1 에 그쳐 저시력·야외에서 식별되지 + /// 않으므로, 색과 분리된 위치 신호를 함께 준다 (#1207 선택행). + func watchListRowBackground(isSelected: Bool = false) -> some View { + listRowBackground( + WatchSurface(style: isSelected ? .hero : .standard) + .overlay(alignment: .leading) { + if isSelected { + WatchColor.brandPrimary + .frame(width: WatchLayout.accentBarWidth) + } + } + .clipShape(WatchSurfaceShape.shape) + ) + } +} + +#if DEBUG +#Preview("WatchSurface — 카드 3종 + accent bar") { + NavigationStack { + ScrollView { + VStack(spacing: WatchLayout.stackSpacing) { + ForEach(Array(WatchCardStyle.allCases.enumerated()), id: \.offset) { _, style in + VStack(alignment: .leading, spacing: WatchLayout.tightSpacing) { + Text(String(describing: style)) + .font(.watch(.cardLabel)) + .foregroundStyle(WatchColor.textSecondary) + Text("12:30") + .font(.watch(.cardValue)) + .foregroundStyle(WatchColor.textPrimary) + } + .watchCard(style) + } + + Text("긴급 공지 — 좌측 색바") + .font(.watch(.cardValue)) + .foregroundStyle(WatchColor.textPrimary) + .watchCard(leadingAccent: WatchColor.brandAccent) + } + .padding(.horizontal, WatchLayout.screenHorizontalPadding) + } + .watchScreenBackground() + } +} +#endif diff --git a/UMCApp/Core/WatchDesignSystem/Sources/Tokens/WatchColor.swift b/UMCApp/Core/WatchDesignSystem/Sources/Tokens/WatchColor.swift new file mode 100644 index 000000000..6cbfe7d83 --- /dev/null +++ b/UMCApp/Core/WatchDesignSystem/Sources/Tokens/WatchColor.swift @@ -0,0 +1,108 @@ +import SwiftUI + +// MARK: - WatchColor + +/// watchOS 전용 색 토큰. +/// +/// OLED 순수 블랙 배경을 전제로 한 값이라 appearance 분기가 없다(워치는 항상 dark). +/// iOS `CoreDesignSystem/Resources/Colors.xcassets` 와 값을 공유하는 것은 브랜드 4종 + +/// 중립 회색 1종뿐이며, 그 정합은 `WatchColorTokenTests` 가 잠근다. +/// 시맨틱 상태색 3종은 iOS 와 **의도적으로 다르다** — 검정 배경 대비를 위해 Apple 다크 +/// 시스템 팔레트를 쓴다(iOS: #33A881/#FFA500/#DD4646). +public enum WatchColor { + + // MARK: - Background + + /// 화면 전체 배경. OLED 픽셀 소등(배터리·번인) 목적의 순수 블랙. (#000000) + public static let screen = Color(sRGBHex: WatchColorHex.screen) + /// 일반 카드 배경 — 불투명 solid. Glass 금지 구역. (#16181C) + public static let cardBackground = Color(sRGBHex: WatchColorHex.cardBackground) + /// 일반 카드 보더. (#2A2D34) + public static let cardBorder = Color(sRGBHex: WatchColorHex.cardBorder) + /// Hero(대표 지표) 카드 배경. (#1B2140) + public static let heroBackground = Color(sRGBHex: WatchColorHex.heroBackground) + /// Hero 카드 보더 = 브랜드 인디고 45% (rgba(72,105,240,.45)). + public static let heroBorder = brandPrimary.opacity(0.45) + /// 위험/파괴적 맥락 카드 배경. (#241416) + public static let dangerBackground = Color(sRGBHex: WatchColorHex.dangerBackground) + /// 위험 카드 보더 = 에러 레드 40% (rgba(255,69,58,.4)). + public static let dangerBorder = statusError.opacity(0.40) + + // MARK: - Brand + + /// Primary Indigo. CTA tint·active 상태·Hero 보더. (iOS indigo500 · #4869F0) + public static let brandPrimary = Color(sRGBHex: WatchColorHex.brandPrimary) + /// 검정/다크 카드 위에 얹는 브랜드색 텍스트·아이콘용 밝은 단계. (iOS indigo400 · #6683FF) + public static let brandPrimaryHighlight = Color(sRGBHex: WatchColorHex.brandPrimaryHighlight) + /// 가장 밝은 단계 — pending 링, 저강조 보조 강조. (iOS indigo300 · #99ABFF) + public static let brandPrimarySoft = Color(sRGBHex: WatchColorHex.brandPrimarySoft) + /// Accent Orange — **The Ping 배지·브랜드 강조 전용**. (iOS orange500 · #FF731A) + /// 상태(성공/경고/실패)를 표현하는 데 절대 쓰지 않는다. 상태는 `status*` 를 쓴다. + public static let brandAccent = Color(sRGBHex: WatchColorHex.brandAccent) + + // MARK: - Status (브랜드색과 분리된 시맨틱 축) + + /// 진행 중/활성. 브랜드 인디고와 같은 값이지만 **의미가 다르므로 별도 이름**으로 참조한다. + public static let statusActive = brandPrimary + /// 승인 대기 — 회색 점 + 인디고 링(`WatchStatusBadge` 가 palette 렌더링으로 합성). (#B2B8BF) + public static let statusPending = neutralGrey + /// 완료. (#30D158) + public static let statusSuccess = Color(sRGBHex: WatchColorHex.statusSuccess) + /// 주의. (#FFB340) + public static let statusWarning = Color(sRGBHex: WatchColorHex.statusWarning) + /// 실패. (#FF453A) + public static let statusError = Color(sRGBHex: WatchColorHex.statusError) + + // MARK: - Text + + /// 본문·강조 텍스트. (#FFFFFF) + public static let textPrimary = Color(sRGBHex: WatchColorHex.textPrimary) + /// 보조 텍스트·캡션. (#B2B8BF) + public static let textSecondary = neutralGrey + /// 비활성 컨트롤 라벨. 새 리터럴을 만들지 않고 보조 텍스트를 감쇠시킨다. + public static let textDisabled = neutralGrey.opacity(0.5) + + // MARK: - Private + + /// `textSecondary` 와 `statusPending` 이 공유하는 단일 회색 리터럴. (iOS grey400 · #B2B8BF) + private static let neutralGrey = Color(sRGBHex: WatchColorHex.neutralGrey) +} + +// MARK: - WatchColorHex + +/// 팔레트 원본값(sRGB 24bit). `WatchColor` 가 유일한 소비자이고, +/// 테스트가 `@testable import` 로 읽어 iOS asset catalog 와 대조한다. +enum WatchColorHex { + static let screen: UInt32 = 0x000000 + static let cardBackground: UInt32 = 0x16181C + static let cardBorder: UInt32 = 0x2A2D34 + static let heroBackground: UInt32 = 0x1B2140 + static let dangerBackground: UInt32 = 0x241416 + + static let brandPrimary: UInt32 = 0x4869F0 + static let brandPrimaryHighlight: UInt32 = 0x6683FF + static let brandPrimarySoft: UInt32 = 0x99ABFF + static let brandAccent: UInt32 = 0xFF731A + + static let statusSuccess: UInt32 = 0x30D158 + static let statusWarning: UInt32 = 0xFFB340 + static let statusError: UInt32 = 0xFF453A + + static let textPrimary: UInt32 = 0xFFFFFF + static let neutralGrey: UInt32 = 0xB2B8BF +} + +// MARK: - Color + sRGBHex + +private extension Color { + /// 0xRRGGBB sRGB 리터럴로 색을 만든다. 워치는 appearance 분기가 없어 이 한 벌로 충분하다. + init(sRGBHex hex: UInt32) { + self.init( + .sRGB, + red: Double((hex >> 16) & 0xFF) / 255, + green: Double((hex >> 8) & 0xFF) / 255, + blue: Double(hex & 0xFF) / 255, + opacity: 1 + ) + } +} diff --git a/UMCApp/Core/WatchDesignSystem/Sources/Tokens/WatchLayout.swift b/UMCApp/Core/WatchDesignSystem/Sources/Tokens/WatchLayout.swift new file mode 100644 index 000000000..5d181f19a --- /dev/null +++ b/UMCApp/Core/WatchDesignSystem/Sources/Tokens/WatchLayout.swift @@ -0,0 +1,23 @@ +import SwiftUI + +/// 워치 전용 레이아웃 상수. iOS `DefaultConstant` 는 탭바·44pt 터치 타깃 등 +/// 폰 전제 값이라 워치에서 재사용하지 않는다. +public enum WatchLayout { + + /// 카드/리스트 행 모서리. `ConcentricRectangle(corners: .concentric(minimum:))` 에 넣어 + /// 워치 디스플레이 곡률과 동심으로 맞춘다. + public static let cardCornerRadius: Edge.Corner.Style = 22 + + /// 카드 보더 두께. + public static let cardBorderWidth: CGFloat = 1 + /// 카드 내부 패딩. 화면이 좁아 iOS(16~24)보다 타이트하게 잡는다. + public static let cardContentPadding: CGFloat = 12 + /// 화면 좌우 인셋. 카드가 디스플레이 곡률에 물리지 않을 최소값. + public static let screenHorizontalPadding: CGFloat = 4 + /// 카드/섹션 사이 세로 간격. + public static let stackSpacing: CGFloat = 8 + /// 라벨-값, 버튼-사유 캡션처럼 붙는 요소 사이 간격. + public static let tightSpacing: CGFloat = 4 + /// 긴급 표시용 좌측 색바 두께 (#1208). + public static let accentBarWidth: CGFloat = 3 +} diff --git a/UMCApp/Core/WatchDesignSystem/Sources/Tokens/WatchTypography.swift b/UMCApp/Core/WatchDesignSystem/Sources/Tokens/WatchTypography.swift new file mode 100644 index 000000000..2fb9f8595 --- /dev/null +++ b/UMCApp/Core/WatchDesignSystem/Sources/Tokens/WatchTypography.swift @@ -0,0 +1,44 @@ +import SwiftUI + +// MARK: - WatchTextRole + +/// 워치 타이포 스케일 5단계. 전부 **시스템 폰트(SF)** 이고 `Font.TextStyle` 에 매핑돼 +/// 워치 설정의 텍스트 크기(Dynamic Type)를 그대로 따라간다. +/// 괄호 안 pt 는 기본 크기에서의 실현값이며, 워치 크기·사용자 설정에 따라 스케일된다. +/// 버튼 라벨은 watchOS 버튼 스타일의 기본 폰트를 존중해 이 스케일에 포함하지 않는다. +public enum WatchTextRole: Sendable, CaseIterable { + /// 화면 타이틀 — SF Semibold (≈22pt). + case screenTitle + /// 대형 지표(출석 카운트다운·인원수) — SF Rounded Semibold + 등폭 숫자 (≈34pt). + case metric + /// 카드 라벨 — SF Medium (≈13pt). + case cardLabel + /// 카드 값 — SF Regular (≈16pt). + case cardValue + /// 캡션·보조 설명 — SF Regular (≈12pt). + case caption +} + +// MARK: - Font + watch + +public extension Font { + + /// 워치 타이포 토큰. `.font(.watch(.screenTitle))` 형태로 쓴다. + /// + /// 고정 pt(`Font.system(size:)`)를 쓰지 않는 이유: Dynamic Type 에 반응하지 않는다. + /// `.extraLargeTitle` 은 watchOS 에서 unavailable 이라 `metric` 은 `.largeTitle` 을 쓴다. + static func watch(_ role: WatchTextRole) -> Font { + switch role { + case .screenTitle: + return .system(.title2, design: .default, weight: .semibold) + case .metric: + return .system(.largeTitle, design: .rounded, weight: .semibold).monospacedDigit() + case .cardLabel: + return .system(.caption, design: .default, weight: .medium) + case .cardValue: + return .system(.body, design: .default, weight: .regular) + case .caption: + return .system(.caption2, design: .default, weight: .regular) + } + } +} diff --git a/UMCApp/Core/WatchDesignSystem/Tests/WatchColorTokenTests.swift b/UMCApp/Core/WatchDesignSystem/Tests/WatchColorTokenTests.swift new file mode 100644 index 000000000..c6e75557d --- /dev/null +++ b/UMCApp/Core/WatchDesignSystem/Tests/WatchColorTokenTests.swift @@ -0,0 +1,146 @@ +import Foundation +import Testing +@testable import CoreWatchDesignSystem + +/// 시맨틱 상태색 3종(success/warning/error)은 iOS 와 값이 다르며 이는 의도된 결정이다 — +/// 워치는 항상 검정 배경이라 Apple 다크 시스템 팔레트를 쓴다. 그래서 이 테스트는 브랜드 4종 + +/// 중립 회색 1종만 잠근다. +@Suite("Watch 색 토큰 — iOS 브랜드 팔레트와의 정합") +struct WatchColorTokenTests { + + // MARK: - Test + + @Test("iOS colorset 의 universal 값과 워치 리터럴이 같다", arguments: BrandToken.all) + func matchesIOSUniversalValue(_ token: BrandToken) { + guard let assetHex = Self.universalHex(of: token) else { return } + + #expect( + assetHex == token.watchHex, + """ + 브랜드 토큰 드리프트 — WatchColorHex.\(token.watchName) = \(Self.format(token.watchHex)), \ + \(token.colorsetPath).colorset universal = \(Self.format(assetHex)). \ + iOS 팔레트를 바꿨다면 워치 리터럴도 같이 갱신한다. + """ + ) + } + + // MARK: - Function + + /// colorset 의 `appearances` 없는(universal) 엔트리 헥스를 읽는다. + /// 파싱에 실패하면 조용히 통과하지 않도록 `Issue.record` 로 명시적 실패를 남긴다. + private static func universalHex(of token: BrandToken) -> UInt32? { + let url = colorsAssetRoot.appending(path: "\(token.colorsetPath).colorset/Contents.json") + + guard let data = try? Data(contentsOf: url) else { + Issue.record("colorset 을 읽지 못했다: \(url.path)") + return nil + } + guard + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let colors = root["colors"] as? [[String: Any]] + else { + Issue.record("colorset JSON 구조가 예상과 다르다: \(url.path)") + return nil + } + guard + let universal = colors.first(where: { $0["appearances"] == nil }), + let color = universal["color"] as? [String: Any], + let components = color["components"] as? [String: String] + else { + Issue.record("universal(appearances 없는) 엔트리를 찾지 못했다: \(url.path)") + return nil + } + + guard + let red = channel(components["red"], key: "red", url: url), + let green = channel(components["green"], key: "green", url: url), + let blue = channel(components["blue"], key: "blue", url: url) + else { + return nil + } + return red << 16 | green << 8 | blue + } + + /// `"0x48"` 형태의 채널 문자열을 파싱한다. 포맷이 바뀌면 명시적으로 실패시킨다. + private static func channel(_ raw: String?, key: String, url: URL) -> UInt32? { + guard let raw else { + Issue.record("components.\(key) 가 없다: \(url.path)") + return nil + } + guard raw.hasPrefix("0x"), let value = UInt32(raw.dropFirst(2), radix: 16) else { + Issue.record("components.\(key) 가 0xRR 형식이 아니다(\"\(raw)\"): \(url.path)") + return nil + } + return value + } + + private static func format(_ hex: UInt32) -> String { + String(format: "#%06X", hex) + } + + /// `#filePath` 로 소스 트리를 역산한다 — 이 모듈은 asset 을 링크하지 않으므로 + /// 번들이 아니라 파일시스템에서 iOS 카탈로그를 직접 읽는다. + /// `Tests/` → `WatchDesignSystem/` → `Core/` + private static let colorsAssetRoot: URL = URL(filePath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appending(path: "DesignSystem/Resources/Colors.xcassets") +} + +// MARK: - BrandToken + +/// iOS 와 값을 공유하는 토큰 한 쌍. +struct BrandToken: Sendable, CustomStringConvertible { + + let colorsetPath: String + let watchName: String + let watchHex: UInt32 + + var description: String { watchName } + + static let all: [BrandToken] = [ + BrandToken( + colorsetPath: "Primary/indigo500", + watchName: "brandPrimary", + watchHex: WatchColorHex.brandPrimary + ), + BrandToken( + colorsetPath: "Primary/indigo400", + watchName: "brandPrimaryHighlight", + watchHex: WatchColorHex.brandPrimaryHighlight + ), + BrandToken( + colorsetPath: "Primary/indigo300", + watchName: "brandPrimarySoft", + watchHex: WatchColorHex.brandPrimarySoft + ), + BrandToken( + colorsetPath: "Accent/orange500", + watchName: "brandAccent", + watchHex: WatchColorHex.brandAccent + ), + BrandToken( + colorsetPath: "Grey/grey400", + watchName: "neutralGrey", + watchHex: WatchColorHex.neutralGrey + ), + ] +} + +// MARK: - WatchStatusSymbolTests + +#if canImport(UIKit) +import UIKit + +/// `WatchStatus.symbolName` 은 후속 화면들이 의존하는 public 계약이라 오타·SF Symbols 버전 +/// 변경을 여기서 잡는다. watchOS 심볼 카탈로그는 iOS 와 공유되므로 iOS destination 으로 검증한다. +@Suite("Watch 상태 심볼 — SF Symbols 존재") +struct WatchStatusSymbolTests { + + @Test("상태 심볼이 전부 존재한다", arguments: WatchStatus.allCases) + func symbolExists(_ status: WatchStatus) { + #expect(UIImage(systemName: status.symbolName) != nil, "\(status.symbolName) 없음") + } +} +#endif diff --git a/UMCApp/UMCWatchApp/Project.swift b/UMCApp/UMCWatchApp/Project.swift index cea0b0d50..5d0d08fc1 100644 --- a/UMCApp/UMCWatchApp/Project.swift +++ b/UMCApp/UMCWatchApp/Project.swift @@ -6,5 +6,6 @@ let project = watchAppProject( bundleId: "com.umc.product.watchkitapp", dependencies: [ .project(target: "CoreWatchConnectivity", path: .relativeToRoot("Core/WatchConnectivity")), + .project(target: "CoreWatchDesignSystem", path: .relativeToRoot("Core/WatchDesignSystem")), ] ) diff --git a/docs/claude/watch-design-system.md b/docs/claude/watch-design-system.md new file mode 100644 index 000000000..9a6a85fbd --- /dev/null +++ b/docs/claude/watch-design-system.md @@ -0,0 +1,326 @@ +# watchOS 디자인 시스템 (CoreWatchDesignSystem) + +> 워치 화면(#1206~#1209·#1215)이 사용하는 토큰·표면·컴포넌트 레퍼런스와 **Glass 허용/금지 매트릭스**. +> 핵심 요약은 `CLAUDE.md` 참고. iOS 디자인 시스템은 `docs/claude/design-system.md` 참고 — 워치는 그 규칙을 따르지 않는다. + +- 작성자: euijjang97 +- 기준 코드: + - `UMCApp/Core/WatchDesignSystem/Project.swift` + - `UMCApp/Core/WatchDesignSystem/Sources/Tokens/WatchColor.swift` + - `UMCApp/Core/WatchDesignSystem/Sources/Tokens/WatchTypography.swift` + - `UMCApp/Core/WatchDesignSystem/Sources/Tokens/WatchLayout.swift` + - `UMCApp/Core/WatchDesignSystem/Sources/Surfaces/WatchSurface.swift` + - `UMCApp/Core/WatchDesignSystem/Sources/Components/WatchActionButton.swift` + - `UMCApp/Core/WatchDesignSystem/Sources/Components/WatchStatusBadge.swift` + - `UMCApp/Core/WatchDesignSystem/Tests/WatchColorTokenTests.swift` + - `UMCApp/UMCWatchApp/Project.swift` + +## 1) 모듈 분리 근거 — 왜 `CoreDesignSystem` 확장이 아닌가 + +`CoreDesignSystem` 을 watchOS 로 넓히지 않고 `Core/WatchDesignSystem` 을 신설했다. 근거는 매니페스트 주석에 원문이 있다 (`UMCApp/Core/WatchDesignSystem/Project.swift:4-20`). + +| 이유 | 내용 | +|------|------| +| **토큰 값이 다르다 (핵심 함정)** | iOS `Colors.xcassets` 의 colorset 은 light/dark 를 함께 갖는데 **watchOS 는 항상 dark 로 해석**한다. 같은 카탈로그를 워치 타겟에 링크하면 브랜드 인디고가 스펙값 `#4869F0` 이 아니라 dark 값 `#4264F0` 으로 나온다 (`orange500` → `#FF6C0F`, `grey400` → `#7C8792` 도 동일). 자산 카탈로그를 공유할 수 없는 실제 이유다. | +| **타이포가 다르다** | 워치 스펙은 시스템 폰트(SF) 기반 — Pretendard `.otf` 3종이 필요 없다. `CoreDesignSystem` 을 링크하면 안 쓰는 폰트 리소스가 워치 번들에 실린다. | +| **컴포넌트 규칙이 반대다** | iOS 는 Glass 를 카드 배경까지 쓰지만 워치는 컨트롤에만 쓴다 (§4). iOS `PrimaryButtonStyle` 의 `height: 44` 고정 같은 폰 전제 API 가 워치 자동완성에 뜨는 것도 막는다. | + +### 1-1) destinations 가 `[.iPhone, .appleWatch]` 인 이유 + +순전히 **테스트 실행 경로** 때문이다 (`Project.swift:16-19`). Makefile 기본 `DESTINATION` 이 iOS 시뮬레이터라, 워치 전용으로 잡으면 `make test SCHEME=CoreWatchDesignSystem` 이 기본값으로 돌지 않는다. 이 모듈은 watchOS 전용 API 를 쓰지 않아 iOS 에서도 그대로 컴파일된다. 멀티플랫폼 Core 모듈 선례는 `Core/WatchConnectivity`. + +- **iOS 화면은 이 모듈을 쓰지 않는다** — iOS 는 `CoreDesignSystem`. 소비자는 `UMCWatchApp` 뿐이다 (`UMCApp/UMCWatchApp/Project.swift:9`). +- 리소스·의존성 0 (`dependencies: []`, asset catalog 없음). #1215 워치 위젯 익스텐션이 생기면 같은 한 줄로 링크한다. + +## 2) 색 토큰 (`WatchColor`) — 단일 출처와 드리프트 가드 + +### 2-1) Swift sRGB 리터럴이 워치 쪽 원본이다 + +asset catalog 없이 `WatchColorHex`(internal, `WatchColor.swift:75-93`)의 `0xRRGGBB` 리터럴이 유일한 값 출처다. 워치는 appearance 분기가 없어(항상 dark) 한 벌로 충분하고, 헥스값이 코드에 그대로 보여 grep·리뷰가 된다. + +| 그룹 | 토큰 | 값 | 용도 | +|------|------|-----|------| +| 배경 | `screen` | `#000000` | 화면 전체 배경. OLED 픽셀 소등(배터리·번인) | +| | `cardBackground` / `cardBorder` | `#16181C` / `#2A2D34` | 일반 카드 — 불투명 solid | +| | `heroBackground` / `heroBorder` | `#1B2140` / 인디고 45% | Hero(대표 지표) 카드 | +| | `dangerBackground` / `dangerBorder` | `#241416` / 에러 레드 40% | 위험·파괴적 맥락 카드 | +| 브랜드 | `brandPrimary` | `#4869F0` | CTA tint·active·Hero 보더 (iOS `indigo500`) | +| | `brandPrimaryHighlight` | `#6683FF` | 다크 카드 위 브랜드색 텍스트·아이콘 (iOS `indigo400`) | +| | `brandPrimarySoft` | `#99ABFF` | pending 링·저강조 (iOS `indigo300`) | +| | `brandAccent` | `#FF731A` | **The Ping 배지·브랜드 강조 전용** (iOS `orange500`) | +| 상태 | `statusActive` / `statusPending` | `#4869F0` / `#B2B8BF` | 진행 중 / 승인 대기 | +| | `statusSuccess` / `statusWarning` / `statusError` | `#30D158` / `#FFB340` / `#FF453A` | 완료 / 주의 / 실패 | +| 텍스트 | `textPrimary` / `textSecondary` / `textDisabled` | `#FFFFFF` / `#B2B8BF` / grey 50% | 본문 / 보조 / 비활성 라벨 | + +- `brandAccent` 는 상태(성공/경고/실패) 표현에 **절대 쓰지 않는다** — 상태는 `status*` 축 (`WatchColor.swift:39-41`). +- `statusActive` 는 `brandPrimary` 와 같은 값이지만 의미가 다르므로 별도 이름으로 참조한다 (`WatchColor.swift:45-46`). +- 색 이름은 역할 기반이다 — iOS 팔레트 번호(`indigo500`)를 워치 API 에 노출하지 않는다 (절대 규칙 #7). + +### 2-2) iOS 와의 정합은 테스트가 잠근다 + +iOS 와 값을 공유하는 것은 **브랜드 4종 + 중립 회색 1종**뿐이고, 그 정합은 `WatchColorTokenTests` 가 강제한다. 이 모듈은 asset 을 링크하지 않으므로 테스트가 `#filePath` 로 소스 트리를 역산해 (`Tests/WatchColorTokenTests.swift:84-88`) iOS `Core/DesignSystem/Resources/Colors.xcassets` 의 colorset `Contents.json` 을 직접 파싱하고, `appearances` 키가 없는 universal 엔트리의 RGB 를 워치 리터럴과 비교한다 (`Tests/WatchColorTokenTests.swift:31-62`). + +| 워치 토큰 | iOS colorset (universal) | +|-----------|--------------------------| +| `brandPrimary` | `Primary/indigo500` | +| `brandPrimaryHighlight` | `Primary/indigo400` | +| `brandPrimarySoft` | `Primary/indigo300` | +| `brandAccent` | `Accent/orange500` | +| `neutralGrey` (→ `textSecondary`·`statusPending`) | `Grey/grey400` | + +iOS 쪽에서 브랜드 색을 바꾸면 이 테스트가 즉시 깨진다 → 워치 리터럴도 같이 갱신하게 강제된다. + +### 2-3) 시맨틱 상태색 3종은 iOS 와 **의도적으로 다르다** + +드리프트가 아니라 결정이다 — 워치는 항상 검정 배경이라 Apple 다크 시스템 팔레트가 대비를 낸다 (`WatchColor.swift:10-11`, `Tests/WatchColorTokenTests.swift:5-7`). 그래서 테스트로 잠그지 않는다. + +| 상태 | 워치 (Apple 다크 팔레트) | iOS DS | +|------|--------------------------|--------| +| success | `#30D158` | `#33A881` | +| warning | `#FFB340` | `#FFA500` | +| error | `#FF453A` | `#DD4646` | + +## 3) 타이포 (`Font.watch`) · 레이아웃 (`WatchLayout`) + +### 3-1) 타이포 — 5단계, 전부 `Font.TextStyle` 기반 + +`.font(.watch(.screenTitle))` 형태로 쓴다 (`WatchTypography.swift:30-43`). 고정 pt(`Font.system(size:)`)를 쓰지 않는 이유: Dynamic Type 에 반응하지 않는다. 괄호 안 pt 는 기본 크기에서의 실현값이다. + +| Role | 매핑 | 용도 | +|------|------|------| +| `.screenTitle` | `.title2` semibold (≈22pt) | 화면 타이틀 | +| `.metric` | `.largeTitle` rounded semibold + `monospacedDigit()` (≈34pt) | 대형 지표 (카운트다운·인원수) | +| `.cardLabel` | `.caption` medium (≈13pt) | 카드 라벨 | +| `.cardValue` | `.body` regular (≈16pt) | 카드 값 | +| `.caption` | `.caption2` regular (≈12pt) | 캡션·보조 설명 | + +- 버튼 라벨은 이 스케일에 없다 — watchOS 버튼 스타일의 기본 폰트를 존중한다 (`WatchTypography.swift:8`). +- `.extraLargeTitle` 은 watchOS unavailable → `metric` 이 `.largeTitle` 을 쓴다 (§8-2). + +### 3-2) 레이아웃 — iOS `DefaultConstant` 를 재사용하지 않는다 + +iOS 상수는 탭바·44pt 터치 타깃 등 폰 전제 값이다 (`WatchLayout.swift:3-4`). + +| 상수 | 값 | 용도 | +|------|----|------| +| `cardCornerRadius` | `Edge.Corner.Style` 22 | 카드/행 모서리 — `ConcentricRectangle` 의 `minimum` | +| `cardBorderWidth` | 1 | 카드 보더 | +| `cardContentPadding` | 12 | 카드 내부 패딩 (iOS 16~24 보다 타이트) | +| `screenHorizontalPadding` | 4 | 화면 좌우 인셋 — 디스플레이 곡률에 안 물리는 최소값 | +| `stackSpacing` | 8 | 카드/섹션 사이 세로 간격 | +| `tightSpacing` | 4 | 라벨-값처럼 붙는 요소 간격 | +| `accentBarWidth` | 3 | 긴급 표시 좌측 색바 (#1208) | +| `cardShape` (computed) | `ConcentricRectangle` | 카드와 동일 곡률. 중첩 콘텐츠 clip·히트영역 정합용 (`WatchSurface.swift:55-59`) | + +## 4) Glass 허용/금지 매트릭스 + +이 문서의 핵심. 워치에서 Glass 는 **컨트롤(버튼)에만** 허용된다. 근거는 가독성(콘텐츠 배경 대비 붕괴)·배터리(OLED 순수 블랙 유지)·번인이다. + +| 대상 | Glass | 실제 처리 | 근거 | +|------|:-----:|-----------|------| +| 화면 전체 배경 | ❌ | `watchScreenBackground()` → `containerBackground(WatchColor.screen, for: .navigation)` | OLED 순수 블랙 (배터리·번인) | +| 일반/Hero/위험 카드 배경 | ❌ | `watchCard(_:)` — 불투명 solid + 1pt 보더 | 가독성. 콘텐츠 배경 Glass 금지 | +| `List`·`Table` 행 배경 | ❌ | `watchListRowBackground(isSelected:)` — solid 표면 | 금지 구역 (#1208) | +| 선택된 `List` 행 | ❌ | Hero 표면 + 좌측 색바 (§5-3) | solid tint (#1207) | +| 결과 풀스크린 배경 (#1207) | ❌ | `watchScreenBackground()` + `watchCard(.hero/.danger)` | 금지 구역 | +| Complication (#1215) | ❌ | 토큰 색만 사용 — accessory family 는 시스템이 렌더 | 금지 구역 | +| Primary CTA | ✅ | `.buttonStyle(.glassProminent)` + 인디고 tint | 컨트롤 = 허용 구역 | +| Secondary 버튼 | ✅ | `.buttonStyle(.glass)` (tint 없음) | 컨트롤 | +| Destructive-safe 버튼 | ✅ | `.buttonStyle(.glass)` + 에러 레드 tint (채우지 않음) | 컨트롤 | +| Disabled 버튼 | ✅(무tint) | `.buttonStyle(.glass)` + `textDisabled` + `.disabled(true)` | 컨트롤 | +| 상태 배지 | ❌ | 심볼 + 텍스트, 배경 없음 | — | + +### 4-1) 금지 규칙이 API 수준에서 강제되는 방식 + +1. **모듈이 `glassEffect` 래퍼를 하나도 노출하지 않는다.** `import CoreWatchDesignSystem` 만으로는 배경에 Glass 를 얹을 방법이 없다. SwiftUI 를 직접 불러 쓰는 건 명시적 이탈이라 PR 리뷰에서 잡는다. +2. **`WatchCardStyle` 은 solid 3케이스 닫힌 enum** (`WatchSurface.swift:5-14`) — Glass 배리언트가 존재하지 않는다. +3. **배경 토큰은 `Color` 타입이지 `Material` 이 아니다** — `WatchColor.cardBackground` 로는 반투명을 만들 수 없다. +4. **Glass API 는 `WatchActionButton` 내부에서만 등장한다.** grep 으로 검증한다: + +```bash +grep -rn "glassEffect\|buttonStyle(.glass" UMCApp/Core/WatchDesignSystem/Sources +# → WatchActionButton.swift 한 파일만 나와야 한다 (현재 76·83·85·87행). +# WatchSurface.swift / WatchColor.swift 에서 매치가 나오면 금지 구역 위반. +``` + +## 5) 표면 API — 카드·화면 배경·리스트 행 + +### 5-1) `watchCard(_:leadingAccent:)` + +패딩·불투명 배경·1pt 보더·동심 모서리를 한 번에 적용한다 (`WatchSurface.swift:91-115`). + +```swift +VStack(alignment: .leading, spacing: WatchLayout.tightSpacing) { + Text("다음 출석") + .font(.watch(.cardLabel)) + .foregroundStyle(WatchColor.textSecondary) + Text("12:30") + .font(.watch(.metric)) + .foregroundStyle(WatchColor.textPrimary) +} +.watchCard(.hero) + +// 긴급 공지 (#1208) — 색과 분리된 위치 신호 +NoticeRow(notice: notice) + .watchCard(leadingAccent: WatchColor.brandAccent) +``` + +| `WatchCardStyle` | fill / border | 용도 | +|------------------|---------------|------| +| `.standard` (기본) | `#16181C` / `#2A2D34` | 일반 카드 | +| `.hero` | `#1B2140` / 인디고 45% | 대표 지표·다음 일정 | +| `.danger` | `#241416` / 에러 레드 40% | 위험·실패·파괴적 맥락 | + +구현 결정 두 가지 (`WatchSurface.swift:95-102`): + +- `ConcentricRectangle` 은 `InsettableShape` 가 아니라 `strokeBorder` 를 못 쓴다 → 보더는 `clipShape` **뒤에** `stroke` + `overlay` 로 얹어 1pt 를 온전히 남긴다. +- `containerShape` 는 붙이지 않는다 — §8-1 의 컴파일 제약 때문. 중첩 콘텐츠는 `WatchLayout.cardShape` 로 직접 맞춘다. + +### 5-2) `watchScreenBackground()` + +화면 전체를 순수 블랙으로 고정하고 watchOS 기본 네비게이션 그라디언트를 덮는다 (`WatchSurface.swift:117-123`). **`NavigationStack` destination 의 최상위 콘텐츠**에 적용한다. + +```swift +ScrollView { /* … */ } + .watchScreenBackground() +``` + +### 5-3) `watchListRowBackground(isSelected:)` + +기본 시스템 행 배경(반투명)을 불투명 solid 로 교체한다 (`WatchSurface.swift:125-142`). `isSelected: true` 면 Hero 표면 + **좌측 인디고 색바**로 선택을 표현한다 — Hero fill 단독은 standard 대비 명암비 1.13:1 에 그쳐 저시력·야외에서 식별되지 않으므로, 색과 분리된 위치 신호를 함께 준다 (#1207 선택행). + +```swift +List(schedules) { schedule in + ScheduleRow(schedule: schedule) + .watchListRowBackground(isSelected: schedule.id == selectedID) +} +``` + +## 6) 컴포넌트 + +### 6-1) `WatchActionButton` + +워치 공통 CTA (`WatchActionButton.swift:40-52`). 캡슐 형태·높이는 시스템 기본값을 존중한다 — iOS `PrimaryButtonStyle` 의 `height: 44` 를 가져오면 큰 Dynamic Type 에서 라벨이 잘린다. + +```swift +public init( + _ title: String, + role: WatchButtonRole = .secondary, // .primary | .secondary | .destructive + systemImage: String? = nil, + disabledReason: String? = nil, + action: @escaping () -> Void +) +``` + +| `WatchButtonRole` | 스타일 | 규칙 | +|-------------------|--------|------| +| `.primary` | `.glassProminent` + 인디고 tint | 화면당 1개 — 기본값이 아니라 대표 CTA 에만 명시 | +| `.secondary` | `.glass` 중립 | 보조 액션 | +| `.destructive` | `.glass` + 에러 레드 tint | **채우지 않는** 안전형 — 빨간 채움 버튼은 좁은 화면에서 오탭 유도 | + +```swift +// #1207 하단 고정 CTA +List { /* … */ } + .watchScreenBackground() + .safeAreaInset(edge: .bottom) { + WatchActionButton("출석 체크", systemImage: "checkmark") { + viewModel.checkIn() + } + } + +// 비활성 + 사유 — disabledReason 이 유일한 비활성 경로다 +WatchActionButton( + "출석 체크", + disabledReason: viewModel.isOutOfRange ? "출석 장소에서 200m 밖입니다" : nil, + action: viewModel.checkIn +) +``` + +`disabledReason` 이 있으면 역할과 무관하게 회색조 `.glass` + `.disabled(true)` 로 바뀌고, 사유가 버튼 아래 캡션으로 노출되며 VoiceOver 에는 `accessibilityValue` 로 전달된다 (`WatchActionButton.swift:74-79`). **사유 없는 비활성 버튼은 만들 수 없다.** + +### 6-2) `WatchStatusBadge` / `WatchStatus` + +상태 표시. 색 단독으로 상태를 표현하지 않는다 — 5종 심볼의 **실루엣이 서로 다르고**(`WatchStatus.symbolName`, `WatchStatusBadge.swift:40-48`), 기본적으로 텍스트를 병기한다. + +| `WatchStatus` | 심볼 (실루엣) | tint | `defaultLabel` | +|---------------|----------------|------|-----------------| +| `.active` | `circle.fill` (원판) | `statusActive` | 진행 중 | +| `.pending` | `smallcircle.filled.circle` (점+링) | `statusPending` / 링 `brandPrimarySoft` | 승인 대기 | +| `.success` | `checkmark.circle.fill` (원안 체크) | `statusSuccess` | 완료 | +| `.warning` | `exclamationmark.triangle.fill` (삼각형) | `statusWarning` | 주의 | +| `.error` | `xmark.octagon.fill` (팔각형) | `statusError` | 실패 | + +- `pending` 링이 `brandPrimary` 가 아니라 한 단계 밝은 `brandPrimarySoft` 인 이유: `active` 원판과 같은 인디고를 쓰면 라벨 없는 경로에서 둘이 섞인다 (`WatchStatusBadge.swift:31-35`). +- 심볼 오타·SF Symbols 버전 변경은 `WatchStatusSymbolTests` 가 잡는다 (`Tests/WatchColorTokenTests.swift:139-145`). + +```swift +// 홈 글랜스 (#1206) — 텍스트 병기 (기본) +WatchStatusBadge(.pending, label: "승인 대기 3건") + +// 목록 행 (#1208) — 폭이 없어 심볼만, 접근성 라벨로 의미 유지 +WatchStatusBadge(.warning, label: "마감 임박", showsLabel: false) +``` + +## 7) 접근성 계약 + +| 요구 | 처리 | +|------|------| +| 상태를 색 단독으로 표현 금지 | `WatchStatus` 5종의 심볼 실루엣이 전부 다르다 (원판/점+링/원안 체크/삼각형/팔각형) + `defaultLabel` 이 항상 존재해 문구 누락 불가 | +| 배지 낭독 1회 | `showsLabel: true` — 심볼 `accessibilityHidden(true)` + 전체 `.accessibilityElement(children: .combine)` → 텍스트만 한 번 낭독 (`WatchStatusBadge.swift:96-103`) | +| 심볼 단독 사용 | `showsLabel: false` 경로는 심볼을 숨기지 않고 `.accessibilityLabel(resolvedLabel)` 을 붙인다 — 숨기면 정보가 사라진다 (`WatchStatusBadge.swift:105`) | +| 비활성 사유 | `disabledReason` 이 유일한 비활성 경로. VoiceOver 에는 `accessibilityValue` — hint 는 VoiceOver 설정에서 꺼질 수 있는 보조 정보인데 "왜 못 누르는가"는 필수 정보다 (`WatchActionButton.swift:22-24`). 캡션은 `accessibilityHidden(true)` 로 중복 낭독 차단 | +| Dynamic Type | 타이포 전부 `Font.TextStyle` 기반, **고정 pt·고정 높이가 하나도 없다.** 심볼도 `.font(.watch(.cardLabel))` 로 텍스트와 함께 스케일 (`WatchStatusBadge.swift:111-116`) | + +## 8) 알려진 제약 2건 + +후속 이슈(#1206~#1209·#1215)가 반드시 알아야 하는 제약이다. + +### 8-1) `containerShape` 로 동심 곡률을 전달할 수 없다 + +`ConcentricRectangle` 이 `RoundedRectangularShape` 를 채택하지 않아 `.containerShape(.rect(corners: .concentric(minimum:)))` 가 **컴파일되지 않는다**. 리터럴 반경(22)을 `containerShape` 로 선언하는 우회도 틀렸다 — 실제 해석값(디스플레이 곡률 기반, 22 초과)과 달라 자식이 틀린 값을 상속한다 (`WatchSurface.swift:99-102`). + +→ 카드 안 중첩 콘텐츠의 clip·히트영역은 `WatchLayout.cardShape` 로 직접 맞춘다: + +```swift +Image(uiImage: thumbnail) + .clipShape(WatchLayout.cardShape) +``` + +> ⚠️ `docs/claude/design-system.md:56` 이 바로 이 컴파일 불가 패턴(`containerShape(.rect(corners: .concentric(...)))`)을 iOS 프로젝트 규약으로 문서화해 두고 있다. 레포 내 실제 사용처가 0건이라 아무도 밟지 않았을 뿐이다. **그 문서는 이번 작업(#1205)에서 고치지 않았다** — iOS 쪽 수정 시 별도 확인이 필요하다. + +### 8-2) `Font.TextStyle.extraLargeTitle` 은 watchOS unavailable + +visionOS 전용이다. 대형 지표는 `.largeTitle` 을 쓴다 — `WatchTextRole.metric` 이 이미 그렇게 매핑돼 있으므로 (`WatchTypography.swift:29-35`) 화면 코드에서 텍스트 스타일을 직접 고르지 말고 `.font(.watch(.metric))` 을 쓰면 된다. + +## 9) 체크리스트 — 워치 화면을 올릴 때 + +- [ ] `NavigationStack` destination 최상위 콘텐츠에 `watchScreenBackground()` 적용 +- [ ] 좌우 인셋은 `WatchLayout.screenHorizontalPadding`, 세로 간격은 `stackSpacing`/`tightSpacing` +- [ ] 카드·행·풀스크린 배경에 Glass·`Material`·`glassEffect` 미사용 — §4-1 의 grep 으로 확인 +- [ ] 텍스트는 전부 `.font(.watch(...))` — `Font.system(size:)` 고정 pt 금지 +- [ ] 상태 표현은 `WatchStatus`/`WatchStatusBadge` — `brandAccent` 로 상태를 그리지 않기 +- [ ] 비활성 버튼은 `disabledReason` 으로만 — `.disabled(true)` 직접 호출 금지 +- [ ] 카드 내 중첩 clip 은 `WatchLayout.cardShape` (§8-1) +- [ ] iOS 브랜드 팔레트를 건드렸다면 `make test SCHEME=CoreWatchDesignSystem` 으로 드리프트 확인 +- [ ] 프리뷰를 46mm·40mm 와 `.dynamicTypeSize(.accessibility3)` 에서 확인 (갤러리: 각 컴포넌트 파일의 `#Preview`) + +## 10) 트러블슈팅 + +- 증상: `WatchColorTokenTests` 실패 — "브랜드 토큰 드리프트 — WatchColorHex.… ≠ ….colorset universal" + - 원인: iOS `Colors.xcassets` 의 브랜드/회색 팔레트가 바뀌었는데 워치 리터럴이 안 따라갔다 (`Tests/WatchColorTokenTests.swift:13-25`). + - 해결: `WatchColorHex` 의 해당 리터럴을 iOS universal 값과 같게 갱신한다. success/warning/error 는 대상이 아니다 — 의도적으로 다르다 (§2-3). +- 증상: 테스트가 "colorset 을 읽지 못했다" / "universal 엔트리를 찾지 못했다" / "0xRR 형식이 아니다" 로 실패 + - 원인: `Colors.xcassets` 경로 이동 또는 `Contents.json` 포맷 변경. 파싱 실패는 조용히 통과하지 않도록 `Issue.record` 로 명시적으로 실패한다 (`Tests/WatchColorTokenTests.swift:31-75`). + - 해결: `colorsAssetRoot` 경로 역산(`Tests/WatchColorTokenTests.swift:84-88`) 또는 채널 파서를 새 구조에 맞춘다. +- 증상: `.containerShape(.rect(corners: .concentric(...)))` 가 컴파일 에러 + - 원인: §8-1 — `ConcentricRectangle` 이 `RoundedRectangularShape` 비채택. + - 해결: 중첩 콘텐츠에 `WatchLayout.cardShape` 를 직접 쓴다. +- 증상: `.extraLargeTitle` 사용 시 watchOS 타겟에서 unavailable 컴파일 에러 + - 원인: §8-2 — visionOS 전용 케이스. + - 해결: `.font(.watch(.metric))` 사용. +- 증상: 워치에서 브랜드 인디고가 `#4264F0` 등 어두운 값으로 보인다 + - 원인: iOS `Colors.xcassets` 를 워치 타겟에 링크했다 — watchOS 는 dark 엔트리를 집는다 (§1). + - 해결: 워치 코드는 `WatchColor` 만 쓴다. iOS 카탈로그를 워치 타겟에 링크하지 않는다. +- 증상: pending 배지가 회색 점 + 인디고 링이 아니라 뒤집혀/단색으로 보인다 + - 원인: 팔레트 렌더링 레이어 순서 — `foregroundStyle(status.tint, status.ringTint)` 의 인자 순서가 레이어 0(점)·레이어 1(링)에 대응한다 (`WatchStatusBadge.swift:111-116`). + - 해결: 인자 순서를 유지하고, SF Symbols 버전 변경이 의심되면 `WatchStatusSymbolTests` 를 돌려 심볼 존재부터 확인한다. From 0dc4578c95c85234d9f964003259a035d23f71ad Mon Sep 17 00:00:00 2001 From: JEONG Date: Sun, 30 Aug 2026 14:11:22 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20watchOS=20Complication=203=EC=A2=85?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20(WidgetKit=20accessory=20family)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CoreWatchConnectivity 에 ComplicationSnapshot·ComplicationStore 추가 — WatchSessionState 를 워치페이스가 필요로 하는 최소 형태로 축약하고 App Group UserDefaults 로 익스텐션에 전달 - ComplicationTimeline 이 세션 시작·종료·체크인/정시/지각 마감을 경계 시각으로 삼아 최대 6개 엔트리 생성 — 1분 폴링 대신 값이 바뀌는 순간에만 엔트리를 둔다 - UMCWatchComplication 익스텐션 신설: 다음 세션·출석 상태·미확인 공지 3종이 accessoryCircular·Rectangular·Inline 을 모두 지원 - 출석 8상태를 심볼·라벨·링 3채널로 구분 — accented/vibrant 에서 색이 치환돼도, 색각 이상 사용자에게도 구분이 살아남는다 - WatchSessionCoordinator 수신 시 스냅샷을 저장하고 타임라인을 리로드 — 워치는 서버를 직접 폴링하지 않으므로 이 경로가 유일한 갱신 통로 - Project+WidgetExtension 헬퍼를 destinations·deploymentTargets·displayName 로 일반화 (기본값이 기존 iOS 설정이라 UMCAppWidget 은 무변경) - CoreWatchConnectivity 테스트 21개 추가 (스냅샷 도출 15 · 스토어 6) --- UMCApp/Core/WatchConnectivity/Project.swift | 3 + .../Complication/ComplicationSnapshot.swift | 328 ++++++++++++++++++ .../Complication/ComplicationStore.swift | 64 ++++ .../Tests/ComplicationSnapshotTests.swift | 276 +++++++++++++++ .../Tests/ComplicationStoreTests.swift | 137 ++++++++ .../Project+WidgetExtension.swift | 11 +- UMCApp/UMCWatchApp/Project.swift | 3 + .../Sources/ComplicationSyncModifier.swift | 42 +++ UMCApp/UMCWatchApp/Sources/UMCWatchApp.swift | 10 + UMCApp/UMCWatchApp/UMCWatchApp.entitlements | 10 + UMCApp/UMCWatchComplication/Project.swift | 16 + .../AttendanceStatusComplication.swift | 117 +++++++ .../Sources/ComplicationProvider.swift | 76 ++++ .../Sources/ComplicationStyle.swift | 82 +++++ .../Sources/NextSessionComplication.swift | 133 +++++++ .../Sources/PingCountComplication.swift | 116 +++++++ .../Sources/UMCWatchComplicationBundle.swift | 18 + .../UMCWatchComplication.entitlements | 10 + UMCApp/Workspace.swift | 1 + 19 files changed, 1449 insertions(+), 4 deletions(-) create mode 100644 UMCApp/Core/WatchConnectivity/Sources/Complication/ComplicationSnapshot.swift create mode 100644 UMCApp/Core/WatchConnectivity/Sources/Complication/ComplicationStore.swift create mode 100644 UMCApp/Core/WatchConnectivity/Tests/ComplicationSnapshotTests.swift create mode 100644 UMCApp/Core/WatchConnectivity/Tests/ComplicationStoreTests.swift create mode 100644 UMCApp/UMCWatchApp/Sources/ComplicationSyncModifier.swift create mode 100644 UMCApp/UMCWatchApp/UMCWatchApp.entitlements create mode 100644 UMCApp/UMCWatchComplication/Project.swift create mode 100644 UMCApp/UMCWatchComplication/Sources/AttendanceStatusComplication.swift create mode 100644 UMCApp/UMCWatchComplication/Sources/ComplicationProvider.swift create mode 100644 UMCApp/UMCWatchComplication/Sources/ComplicationStyle.swift create mode 100644 UMCApp/UMCWatchComplication/Sources/NextSessionComplication.swift create mode 100644 UMCApp/UMCWatchComplication/Sources/PingCountComplication.swift create mode 100644 UMCApp/UMCWatchComplication/Sources/UMCWatchComplicationBundle.swift create mode 100644 UMCApp/UMCWatchComplication/UMCWatchComplication.entitlements diff --git a/UMCApp/Core/WatchConnectivity/Project.swift b/UMCApp/Core/WatchConnectivity/Project.swift index 3955fd0d1..6234e942d 100644 --- a/UMCApp/Core/WatchConnectivity/Project.swift +++ b/UMCApp/Core/WatchConnectivity/Project.swift @@ -1,6 +1,8 @@ import ProjectDescription import ProjectDescriptionHelpers +// WidgetKit 을 무는 이유는 하나다 — `ComplicationStore.save` 가 저장 직후 워치페이스 타임라인을 +// 리로드한다. 저장과 리로드를 갈라 두면 호출자가 리로드를 빠뜨려 값이 조용히 낡는다. let project = coreProject( name: "CoreWatchConnectivity", bundleIdSuffix: "watchconnectivity", @@ -8,6 +10,7 @@ let project = coreProject( deploymentTargets: .multiplatform(iOS: "26.4", watchOS: "26.4"), dependencies: [ .sdk(name: "WatchConnectivity", type: .framework, status: .required), + .sdk(name: "WidgetKit", type: .framework, status: .required), ], includesTests: true ) diff --git a/UMCApp/Core/WatchConnectivity/Sources/Complication/ComplicationSnapshot.swift b/UMCApp/Core/WatchConnectivity/Sources/Complication/ComplicationSnapshot.swift new file mode 100644 index 000000000..03c4aeb0f --- /dev/null +++ b/UMCApp/Core/WatchConnectivity/Sources/Complication/ComplicationSnapshot.swift @@ -0,0 +1,328 @@ +// +// ComplicationSnapshot.swift +// CoreWatchConnectivity +// +// Created by euijjang97 on 8/30/26. +// + +import Foundation +import WidgetKit + +// MARK: - ComplicationSnapshot + +/// 워치페이스가 그리는 데 필요한 최소값만 담은 파생 스냅샷. +/// +/// ``WatchSessionState`` 를 그대로 저장하지 않는 이유: 타임라인 리프레시마다 디코딩하는 값이라 +/// 목록 전체를 실으면 매 갱신에 낭비가 붙는다. 워치페이스는 「다음 하나」와 「개수」만 그린다. +public struct ComplicationSnapshot: Codable, Sendable, Equatable { + + // MARK: - Property + + /// `false` 면 3종 모두 「iPhone 로그인 필요」를 그린다. + public let isSignedIn: Bool + /// 아직 끝나지 않은 일정 중 가장 이른 것. 없으면 `nil`. + public let nextSession: ComplicationSession? + public let attendance: ComplicationAttendanceState + /// 미확인 The Ping 개수. 상한을 두지 않는다 — 절단(`99+`)은 뷰의 책임이다. + public let unreadPingCount: Int + /// 원본 스냅샷의 생성 시각. 워치페이스가 신선도를 표시한다. + public let generatedAt: Date + + // MARK: - Init + + public init( + isSignedIn: Bool, + nextSession: ComplicationSession?, + attendance: ComplicationAttendanceState, + unreadPingCount: Int, + generatedAt: Date + ) { + self.isSignedIn = isSignedIn + self.nextSession = nextSession + self.attendance = attendance + self.unreadPingCount = unreadPingCount + self.generatedAt = generatedAt + } + + /// WC 스냅샷 → Complication 스냅샷. 순수 함수라 테스트가 규칙을 잠근다. + public init(state: WatchSessionState, now: Date = Date()) { + let schedule = Self.nextSchedule(in: state.schedules, now: now) + self.init( + isSignedIn: state.isSignedIn, + nextSession: schedule.map(ComplicationSession.init(schedule:)), + attendance: Self.attendanceState(for: schedule, now: now), + unreadPingCount: state.notices.count { !$0.isRead }, + generatedAt: state.generatedAt + ) + } + + // MARK: - Function + + /// 경계 시각 기준으로 **출석 상태만** 다시 계산한 사본. + /// + /// 세션·개수까지 다시 파생하지 않는 이유: 미래 시점의 일정 목록·읽음 여부는 워치가 알 수 없다. + /// 시간이 지나기만 해도 확정적으로 바뀌는 값은 출석 창 판정뿐이다. + func projected(at date: Date) -> ComplicationSnapshot { + guard !attendance.isServerConfirmed else { return self } + return ComplicationSnapshot( + isSignedIn: isSignedIn, + nextSession: nextSession, + attendance: Self.windowState(nextSession?.attendanceWindow, now: date), + unreadPingCount: unreadPingCount, + generatedAt: generatedAt + ) + } + + /// 끝난 세션은 워치페이스에서 의미가 없으므로 후보에서 뺀다. + /// 진행 중인 세션은 `startsAt` 이 가장 이르므로 정렬만으로 자연히 우선한다. + private static func nextSchedule( + in schedules: [WatchSchedule], + now: Date + ) -> WatchSchedule? { + schedules + .filter { $0.endsAt > now } + .min { + $0.startsAt == $1.startsAt + ? $0.scheduleId < $1.scheduleId + : $0.startsAt < $1.startsAt + } + } + + private static func attendanceState( + for schedule: WatchSchedule?, + now: Date + ) -> ComplicationAttendanceState { + guard let schedule else { return .none } + if + let rawStatus = schedule.attendanceStatus, + let mapped = ComplicationAttendanceState.from(rawStatus: rawStatus) + { + return mapped + } + return windowState(schedule.attendanceWindow, now: now) + } + + /// 서버 상태가 없거나 워치가 모르는 문자열일 때의 폴백. + /// 창이 닫혔는데 상태가 없다면 결석이다 — 「알 수 없음」으로 두면 사용자가 조치할 시점을 놓친다. + static func windowState( + _ window: WatchAttendanceWindow?, + now: Date + ) -> ComplicationAttendanceState { + guard let window else { return .none } + if now < window.checkInStartAt { return .upcoming } + if now < window.lateEndAt { return .awaiting } + return .absent + } +} + +// MARK: - ComplicationSession + +public struct ComplicationSession: Codable, Sendable, Equatable { + + // MARK: - Property + + /// 서버 정수 식별자를 String 으로 보존한다 (절대 규칙 #2). + public let scheduleId: String + public let name: String + public let startsAt: Date + public let endsAt: Date + /// `nil` = 출석 비필수. 타임라인 경계 시각의 유일한 출처이기도 하다. + public let attendanceWindow: WatchAttendanceWindow? + + // MARK: - Init + + public init( + scheduleId: String, + name: String, + startsAt: Date, + endsAt: Date, + attendanceWindow: WatchAttendanceWindow? + ) { + self.scheduleId = scheduleId + self.name = name + self.startsAt = startsAt + self.endsAt = endsAt + self.attendanceWindow = attendanceWindow + } + + init(schedule: WatchSchedule) { + self.init( + scheduleId: schedule.scheduleId, + name: schedule.name, + startsAt: schedule.startsAt, + endsAt: schedule.endsAt, + attendanceWindow: schedule.attendanceWindow + ) + } + + // MARK: - Function + + public func isInProgress(now: Date = Date()) -> Bool { + startsAt <= now && now < endsAt + } +} + +// MARK: - ComplicationAttendanceState + +/// 워치페이스가 그리는 출석 상태. +/// +/// 각 케이스는 색 말고도 **심볼·라벨·링 유무**를 함께 낸다. accented(tinted) 워치페이스에서 +/// 시스템이 색을 단색으로 치환하면 색으로만 구분하던 상태가 통째로 구별 불가가 되기 때문이다. +/// 색은 보조 채널이라는 원칙은 색각 이상 사용자에게도 그대로 유효하다. +public enum ComplicationAttendanceState: String, Codable, Sendable, CaseIterable { + /// 다음 세션이 없거나 출석 비필수. + case none + /// 체크인 창이 아직 열리지 않음. + case upcoming + /// 체크인 창이 열렸고 아직 요청하지 않음. + case awaiting + case pending + case present + case late + case excused + case absent + + // MARK: - Property + + /// 실루엣이 서로 다른 심볼. `ComplicationSnapshotTests` 가 중복을 잠근다. + public var symbolName: String { + switch self { + case .none: "calendar" + case .upcoming: "clock" + case .awaiting: "location.circle" + case .pending: "hourglass" + case .present: "checkmark.circle.fill" + case .late: "exclamationmark.circle.fill" + case .excused: "checkmark.shield.fill" + case .absent: "xmark.circle.fill" + } + } + + /// inline·circular 처럼 폭이 없는 자리에서도 잘리지 않는 짧은 라벨. + public var shortLabel: String { + switch self { + case .none: "예정 없음" + case .upcoming: "출석 예정" + case .awaiting: "출석 가능" + case .pending: "승인 대기" + case .present: "출석" + case .late: "지각" + case .excused: "공결" + case .absent: "결석" + } + } + + /// 승인 대기와 공결은 스펙상 같은 중립색이라 색으로는 갈리지 않는다. + /// 링은 색이 아니라 **형태**라 accented 모드에서도 살아남는다. + public var hasPendingRing: Bool { self == .pending } + + /// 시간이 흘러도 뒤집히지 않는 확정 상태. 타임라인이 이 값을 창 판정으로 덮어쓰지 않는다. + var isServerConfirmed: Bool { + switch self { + case .pending, .present, .late, .excused, .absent: true + case .none, .upcoming, .awaiting: false + } + } + + // MARK: - Function + + /// 서버 `AttendanceStatus` 원본 문자열 → 표시 상태. 모르는 값은 `nil` 이라 창 폴백으로 떨어진다. + /// + /// `EXCUSED` 를 `.present` 로 합치지 않는다 — 합치는 순간 공결 사용자가 볼 화면이 사라진다. + static func from(rawStatus: String) -> ComplicationAttendanceState? { + switch rawStatus { + case "PRESENT": .present + case "LATE": .late + case "EXCUSED": .excused + case "ABSENT": .absent + case "PENDING", "PRESENT_PENDING", "LATE_PENDING", "EXCUSED_PENDING": .pending + default: nil + } + } +} + +// MARK: - ComplicationEntry + +public struct ComplicationEntry: TimelineEntry { + + // MARK: - Property + + public let date: Date + public let snapshot: ComplicationSnapshot + + // MARK: - Init + + public init(date: Date, snapshot: ComplicationSnapshot) { + self.date = date + self.snapshot = snapshot + } +} + +// MARK: - ComplicationTimeline + +/// 스냅샷 하나에서 타임라인 엔트리를 뽑는 순수 함수 모음. +/// +/// 익스텐션이 아니라 여기 두는 이유: 엔트리 규칙은 스냅샷 파생 규칙의 연장이라 같은 테스트가 +/// 함께 잠가야 한다. 익스텐션 타겟은 유닛 테스트에서 import 할 수 없다. +public enum ComplicationTimeline { + + // MARK: - Property + + /// 경계 엔트리 상한. 워치 리프레시 예산이 유한해서, 한 세션의 상태 전이를 덮는 최소치로 둔다. + private static let maxBoundaryCount = 6 + + // MARK: - Function + + /// `now` 엔트리 1개 + 상태가 실제로 바뀌는 시각의 엔트리들. + /// + /// 카운트다운 숫자로는 엔트리를 늘리지 않는다 — `Text(_:style:)` 이 시스템 쪽에서 갱신한다. + public static func entries( + from snapshot: ComplicationSnapshot, + now: Date = Date() + ) -> [ComplicationEntry] { + [ComplicationEntry(date: now, snapshot: snapshot)] + + boundaries(of: snapshot, after: now).map { + ComplicationEntry(date: $0, snapshot: snapshot.projected(at: $0)) + } + } + + private static func boundaries(of snapshot: ComplicationSnapshot, after now: Date) -> [Date] { + guard let session = snapshot.nextSession else { return [] } + var candidates: Set = [session.startsAt, session.endsAt] + if let window = session.attendanceWindow { + candidates.formUnion([window.checkInStartAt, window.onTimeEndAt, window.lateEndAt]) + } + return Array(candidates.filter { $0 > now }.sorted().prefix(maxBoundaryCount)) + } +} + +#if DEBUG +public extension ComplicationSnapshot { + + static let preview = ComplicationSnapshot( + isSignedIn: true, + nextSession: ComplicationSession( + scheduleId: "1", + name: "9주차 정기 세션", + startsAt: Date(timeIntervalSinceNow: 45 * 60), + endsAt: Date(timeIntervalSinceNow: 165 * 60), + attendanceWindow: WatchAttendanceWindow( + checkInStartAt: Date(timeIntervalSinceNow: 30 * 60), + onTimeEndAt: Date(timeIntervalSinceNow: 55 * 60), + lateEndAt: Date(timeIntervalSinceNow: 75 * 60) + ) + ), + attendance: .upcoming, + unreadPingCount: 3, + generatedAt: Date() + ) + + static let signedOut = ComplicationSnapshot( + isSignedIn: false, + nextSession: nil, + attendance: .none, + unreadPingCount: 0, + generatedAt: Date() + ) +} +#endif diff --git a/UMCApp/Core/WatchConnectivity/Sources/Complication/ComplicationStore.swift b/UMCApp/Core/WatchConnectivity/Sources/Complication/ComplicationStore.swift new file mode 100644 index 000000000..f3294338c --- /dev/null +++ b/UMCApp/Core/WatchConnectivity/Sources/Complication/ComplicationStore.swift @@ -0,0 +1,64 @@ +// +// ComplicationStore.swift +// CoreWatchConnectivity +// +// Created by euijjang97 on 8/30/26. +// + +import Foundation +import WidgetKit + +// MARK: - ComplicationStore + +/// 워치 앱 ↔ Complication 익스텐션 스냅샷 공유 스토어. +/// +/// iOS 위젯의 `WidgetStorage`(`group.com.umc.product.widget`)를 재사용하지 않는다 — +/// App Group 컨테이너는 iPhone 과 워치가 공유하지 않아서, 워치 전용 그룹이 따로 필요하다. +public final class ComplicationStore: Sendable { + + // MARK: - Property + + public static let shared = ComplicationStore() + + /// 워치 앱과 익스텐션 entitlements 양쪽에 같은 값이 들어가야 한다. 어긋나면 저장은 성공한 것처럼 + /// 보이는데 익스텐션이 읽는 컨테이너가 달라 워치페이스가 영원히 비어 있다. + public static let appGroupIdentifier = "group.com.umc.product.watch" + + private static let snapshotKey = "complication.snapshot" + + nonisolated(unsafe) private let defaults: UserDefaults? + + // MARK: - Init + + public init(suiteName: String = ComplicationStore.appGroupIdentifier) { + defaults = UserDefaults(suiteName: suiteName) + } + + // MARK: - Function + + public func load() -> ComplicationSnapshot? { + guard + let defaults, + let data = defaults.data(forKey: Self.snapshotKey) + else { return nil } + return try? WatchEnvelope.jsonDecoder.decode(ComplicationSnapshot.self, from: data) + } + + /// 저장한 뒤 워치페이스 타임라인을 즉시 다시 로드한다. + /// + /// 저장과 리로드를 갈라 두면 새 호출자가 리로드를 빠뜨려 「값은 바뀌었는데 워치페이스는 옛날 것」이 + /// 된다. 워치는 서버를 직접 폴링하지 않아 이 경로가 갱신의 유일한 동력이다. + public func save(_ snapshot: ComplicationSnapshot) { + guard + let defaults, + let data = try? WatchEnvelope.jsonEncoder.encode(snapshot) + else { return } + defaults.set(data, forKey: Self.snapshotKey) + WidgetCenter.shared.reloadAllTimelines() + } + + public func clear() { + defaults?.removeObject(forKey: Self.snapshotKey) + WidgetCenter.shared.reloadAllTimelines() + } +} diff --git a/UMCApp/Core/WatchConnectivity/Tests/ComplicationSnapshotTests.swift b/UMCApp/Core/WatchConnectivity/Tests/ComplicationSnapshotTests.swift new file mode 100644 index 000000000..93deadc6c --- /dev/null +++ b/UMCApp/Core/WatchConnectivity/Tests/ComplicationSnapshotTests.swift @@ -0,0 +1,276 @@ +// +// ComplicationSnapshotTests.swift +// CoreWatchConnectivityTests +// +// Created by euijjang97 on 8/30/26. +// + +import Foundation +import Testing +@testable import CoreWatchConnectivity + +/// 워치페이스가 그리는 값을 뽑는 파생 규칙. +/// +/// 이 파생이 무너지면 워치페이스는 「끝난 세션」이나 「색만 다른 구별 불가 상태」를 그리는데, +/// 워치페이스는 사용자가 앱을 열지 않고 보는 화면이라 잘못된 값을 정정할 기회가 없다. +@Suite("ComplicationSnapshot — WC 스냅샷 파생") +struct ComplicationSnapshotTests { + + // MARK: - Fixture + + private let now = Date(timeIntervalSince1970: 1_700_000_000) + + private func minutes(_ value: Double) -> Date { + now.addingTimeInterval(value * 60) + } + + private func makeSchedule( + scheduleId: String, + startsAt: Date, + endsAt: Date, + window: WatchAttendanceWindow? = nil, + status: String? = nil + ) -> WatchSchedule { + WatchSchedule( + scheduleId: scheduleId, + name: "세션 \(scheduleId)", + startsAt: startsAt, + endsAt: endsAt, + location: nil, + attendanceWindow: window, + attendanceStatus: status + ) + } + + private func makeNotice(noticeId: String, isRead: Bool) -> WatchNotice { + WatchNotice( + noticeId: noticeId, + title: "공지 \(noticeId)", + content: "본문", + writer: "운영진", + postedAt: now, + isMustRead: false, + isAlert: false, + isRead: isRead + ) + } + + private func makeState( + isSignedIn: Bool = true, + schedules: [WatchSchedule] = [], + notices: [WatchNotice] = [] + ) -> WatchSessionState { + WatchSessionState( + isSignedIn: isSignedIn, + schedules: schedules, + notices: notices, + generatedAt: now + ) + } + + private func makeSnapshot( + window: WatchAttendanceWindow?, + status: String?, + now referenceDate: Date? = nil + ) -> ComplicationSnapshot { + let state = makeState( + schedules: [ + makeSchedule( + scheduleId: "1", + startsAt: minutes(30), + endsAt: minutes(90), + window: window, + status: status + ) + ] + ) + return ComplicationSnapshot(state: state, now: referenceDate ?? now) + } + + private var standardWindow: WatchAttendanceWindow { + WatchAttendanceWindow( + checkInStartAt: minutes(15), + onTimeEndAt: minutes(35), + lateEndAt: minutes(45) + ) + } + + // MARK: - Next Session + + @Test("이미 끝난 세션은 후보에서 빠진다") + func finishedSessionIsExcluded() { + let state = makeState( + schedules: [ + makeSchedule(scheduleId: "1", startsAt: minutes(-120), endsAt: minutes(-60)), + makeSchedule(scheduleId: "2", startsAt: minutes(60), endsAt: minutes(120)), + ] + ) + + let snapshot = ComplicationSnapshot(state: state, now: now) + + #expect(snapshot.nextSession?.scheduleId == "2") + } + + @Test("진행 중인 세션이 미래 세션보다 우선한다") + func inProgressSessionWins() { + let state = makeState( + schedules: [ + makeSchedule(scheduleId: "1", startsAt: minutes(10), endsAt: minutes(70)), + makeSchedule(scheduleId: "2", startsAt: minutes(-10), endsAt: minutes(50)), + ] + ) + + let snapshot = ComplicationSnapshot(state: state, now: now) + + #expect(snapshot.nextSession?.scheduleId == "2") + #expect(snapshot.nextSession?.isInProgress(now: now) == true) + } + + @Test("후보가 여럿이면 startsAt 이 가장 이른 것을 고른다") + func earliestUpcomingSessionWins() { + let state = makeState( + schedules: [ + makeSchedule(scheduleId: "1", startsAt: minutes(180), endsAt: minutes(240)), + makeSchedule(scheduleId: "2", startsAt: minutes(30), endsAt: minutes(90)), + makeSchedule(scheduleId: "3", startsAt: minutes(60), endsAt: minutes(120)), + ] + ) + + let snapshot = ComplicationSnapshot(state: state, now: now) + + #expect(snapshot.nextSession?.scheduleId == "2") + } + + @Test("후보가 없으면 세션은 nil 이고 출석 상태는 none") + func noCandidateYieldsNoneState() { + let state = makeState( + schedules: [ + makeSchedule(scheduleId: "1", startsAt: minutes(-120), endsAt: minutes(-60)) + ] + ) + + let snapshot = ComplicationSnapshot(state: state, now: now) + + #expect(snapshot.nextSession == nil) + #expect(snapshot.attendance == .none) + } + + // MARK: - Attendance Mapping + + @Test( + "서버 확정 상태 매핑", + arguments: [ + ("PRESENT", ComplicationAttendanceState.present), + ("LATE", .late), + ("EXCUSED", .excused), + ("ABSENT", .absent), + ] + ) + func decidedStatusMapping(rawStatus: String, expected: ComplicationAttendanceState) { + let snapshot = makeSnapshot(window: standardWindow, status: rawStatus) + + #expect(snapshot.attendance == expected) + } + + @Test( + "대기 계열은 전부 pending 으로 모인다", + arguments: ["PENDING", "PRESENT_PENDING", "LATE_PENDING", "EXCUSED_PENDING"] + ) + func pendingStatusMapping(rawStatus: String) { + let snapshot = makeSnapshot(window: standardWindow, status: rawStatus) + + #expect(snapshot.attendance == .pending) + } + + @Test("EXCUSED 는 present 로 합쳐지지 않는다") + func excusedStaysDistinct() { + let snapshot = makeSnapshot(window: standardWindow, status: "EXCUSED") + + #expect(snapshot.attendance == .excused) + #expect(snapshot.attendance != .present) + } + + @Test("모르는 상태 문자열은 창 기반 폴백으로 떨어진다") + func unknownStatusFallsBackToWindow() { + let snapshot = makeSnapshot( + window: standardWindow, + status: "SOME_FUTURE_STATUS", + now: minutes(20) + ) + + #expect(ComplicationAttendanceState.from(rawStatus: "SOME_FUTURE_STATUS") == nil) + #expect(snapshot.attendance == .awaiting) + } + + // MARK: - Window Fallback + + @Test( + "창 폴백 — 열리기 전 upcoming · 열린 뒤 awaiting · 닫힌 뒤 absent", + arguments: [ + (0.0, ComplicationAttendanceState.upcoming), + (20.0, .awaiting), + (50.0, .absent), + ] + ) + func windowFallback(offset: Double, expected: ComplicationAttendanceState) { + let snapshot = makeSnapshot(window: standardWindow, status: nil, now: minutes(offset)) + + #expect(snapshot.attendance == expected) + } + + @Test("출석 창이 없으면(비필수) none") + func missingWindowYieldsNone() { + let snapshot = makeSnapshot(window: nil, status: nil) + + #expect(snapshot.attendance == .none) + } + + // MARK: - Ping Count + + @Test("미확인 개수는 읽지 않은 공지만 센다") + func unreadPingCount() { + let state = makeState( + notices: [ + makeNotice(noticeId: "1", isRead: false), + makeNotice(noticeId: "2", isRead: true), + makeNotice(noticeId: "3", isRead: false), + ] + ) + + #expect(ComplicationSnapshot(state: state, now: now).unreadPingCount == 2) + } + + @Test("전부 읽었으면 미확인 개수는 0") + func allReadYieldsZero() { + let state = makeState(notices: [makeNotice(noticeId: "1", isRead: true)]) + + #expect(ComplicationSnapshot(state: state, now: now).unreadPingCount == 0) + } + + @Test("로그아웃 상태는 그대로 보존된다") + func signedOutIsPreserved() { + let state = makeState(isSignedIn: false) + + #expect(ComplicationSnapshot(state: state, now: now).isSignedIn == false) + } + + // MARK: - Color-Free Channels + + @Test("심볼·라벨은 8개 상태에서 서로 겹치지 않는다") + func stateChannelsAreDistinct() { + let states = ComplicationAttendanceState.allCases + let symbols = Set(states.map(\.symbolName)) + let labels = Set(states.map(\.shortLabel)) + + #expect(states.allSatisfy { !$0.symbolName.isEmpty && !$0.shortLabel.isEmpty }) + #expect(symbols.count == states.count) + #expect(labels.count == states.count) + } + + @Test("승인 대기 링은 pending 에서만 켜진다") + func pendingRingIsExclusive() { + for state in ComplicationAttendanceState.allCases { + #expect(state.hasPendingRing == (state == .pending)) + } + } +} diff --git a/UMCApp/Core/WatchConnectivity/Tests/ComplicationStoreTests.swift b/UMCApp/Core/WatchConnectivity/Tests/ComplicationStoreTests.swift new file mode 100644 index 000000000..6a58962a6 --- /dev/null +++ b/UMCApp/Core/WatchConnectivity/Tests/ComplicationStoreTests.swift @@ -0,0 +1,137 @@ +// +// ComplicationStoreTests.swift +// CoreWatchConnectivityTests +// +// Created by euijjang97 on 8/30/26. +// + +import Foundation +import Testing +@testable import CoreWatchConnectivity + +/// App Group 공유 스토어와 타임라인 엔트리 생성 규칙. +/// +/// 실제 App Group(`group.com.umc.product.watch`)은 서명된 앱에서만 열리므로 테스트는 임의 +/// suite 이름을 주입한다. 검증 대상은 컨테이너가 아니라 **직렬화 왕복과 엔트리 규칙**이다. +@Suite("ComplicationStore — 공유 저장 · 타임라인") +final class ComplicationStoreTests { + + // MARK: - Fixture + + private let suiteName = "complication.tests.\(UUID().uuidString)" + private let store: ComplicationStore + private let now = Date(timeIntervalSince1970: 1_700_000_000) + + init() { + store = ComplicationStore(suiteName: suiteName) + } + + deinit { + UserDefaults.standard.removePersistentDomain(forName: suiteName) + } + + private func minutes(_ value: Double) -> Date { + now.addingTimeInterval(value * 60) + } + + /// `generatedAt` 을 초 단위로 딱 떨어지게 잡는다 — 봉투 코덱이 ISO8601 이라 + /// 소수점 이하가 왕복에서 잘린다. + private func makeSnapshot( + nextSession: ComplicationSession? = nil, + attendance: ComplicationAttendanceState = .upcoming + ) -> ComplicationSnapshot { + ComplicationSnapshot( + isSignedIn: true, + nextSession: nextSession, + attendance: attendance, + unreadPingCount: 3, + generatedAt: now + ) + } + + private func makeSession(window: WatchAttendanceWindow?) -> ComplicationSession { + ComplicationSession( + scheduleId: "1", + name: "정기 세션", + startsAt: minutes(-5), + endsAt: minutes(60), + attendanceWindow: window + ) + } + + // MARK: - Storage + + @Test("저장한 스냅샷이 그대로 돌아온다") + func saveLoadRoundtrip() { + let snapshot = makeSnapshot(nextSession: makeSession(window: nil)) + + store.save(snapshot) + + #expect(store.load() == snapshot) + } + + @Test("빈 스토어는 nil 을 낸다") + func emptyStoreLoadsNil() { + #expect(store.load() == nil) + } + + @Test("clear 이후에는 nil 을 낸다") + func clearRemovesSnapshot() { + store.save(makeSnapshot()) + + store.clear() + + #expect(store.load() == nil) + } + + // MARK: - Timeline + + @Test("엔트리는 now 로 시작해 미래 경계만 오름차순으로 잇는다") + func timelineEntriesCoverFutureBoundaries() { + let window = WatchAttendanceWindow( + checkInStartAt: minutes(-10), + onTimeEndAt: minutes(10), + lateEndAt: minutes(20) + ) + let snapshot = makeSnapshot( + nextSession: makeSession(window: window), + attendance: .awaiting + ) + + let entries = ComplicationTimeline.entries(from: snapshot, now: now) + let dates = entries.map(\.date) + + #expect(dates.first == now) + #expect(dates == dates.sorted()) + #expect(dates.allSatisfy { $0 >= now }) + #expect(entries.count <= 7) + #expect(dates == [now, minutes(10), minutes(20), minutes(60)]) + // 창이 닫히는 시각 이후의 엔트리는 창 폴백으로 결석이 되어야 한다. + #expect(entries.last?.snapshot.attendance == .absent) + } + + @Test("서버 확정 상태는 경계 엔트리에서도 유지된다") + func decidedStateSurvivesProjection() { + let window = WatchAttendanceWindow( + checkInStartAt: minutes(-10), + onTimeEndAt: minutes(10), + lateEndAt: minutes(20) + ) + let snapshot = makeSnapshot( + nextSession: makeSession(window: window), + attendance: .present + ) + + let entries = ComplicationTimeline.entries(from: snapshot, now: now) + + #expect(entries.allSatisfy { $0.snapshot.attendance == .present }) + } + + @Test("경계가 없으면 엔트리는 하나뿐이다") + func timelineWithoutBoundaries() { + let entries = ComplicationTimeline.entries(from: makeSnapshot(), now: now) + + #expect(entries.count == 1) + #expect(entries.first?.date == now) + } +} diff --git a/UMCApp/Tuist/ProjectDescriptionHelpers/Project+WidgetExtension.swift b/UMCApp/Tuist/ProjectDescriptionHelpers/Project+WidgetExtension.swift index a39937baf..2ab40d85b 100644 --- a/UMCApp/Tuist/ProjectDescriptionHelpers/Project+WidgetExtension.swift +++ b/UMCApp/Tuist/ProjectDescriptionHelpers/Project+WidgetExtension.swift @@ -1,8 +1,5 @@ import ProjectDescription -private let deploymentTargets: DeploymentTargets = .iOS("26.4") -private let destinations: Destinations = .iOS - /// Widget Extension 타겟용 Project 생성 헬퍼 /// /// Widget Extension은 단일 appExtension 타겟으로 구성됩니다. @@ -11,11 +8,17 @@ private let destinations: Destinations = .iOS /// - Parameters: /// - name: 타겟 이름 (예: "UMCAppWidget") /// - bundleId: 완전한 번들 ID — 반드시 호스트 앱 번들 ID를 prefix로 포함해야 합니다 +/// - destinations: 지원 플랫폼 (기본값 iOS). watchOS Complication은 `[.appleWatch]` +/// - deploymentTargets: 배포 대상 (기본값 iOS 26.4) +/// - displayName: 위젯 갤러리 표시 이름 /// - entitlements: entitlements 파일 경로 (기본값 nil) /// - dependencies: 의존성 목록 public func widgetExtensionProject( name: String, bundleId: String, + destinations: Destinations = .iOS, + deploymentTargets: DeploymentTargets = .iOS("26.4"), + displayName: String = "UMC", entitlements: Entitlements? = nil, dependencies: [TargetDependency] = [] ) -> Project { @@ -34,7 +37,7 @@ public func widgetExtensionProject( "CFBundleShortVersionString": "$(MARKETING_VERSION)", // 위젯 갤러리에 표시되는 이름. 앱 익스텐션은 이 키가 없으면 // App Store Connect 업로드가 거부된다(ITMS-90360). - "CFBundleDisplayName": "UMC", + "CFBundleDisplayName": .string(displayName), // 앱 익스텐션의 CFBundleVersion은 호스트 앱과 반드시 일치해야 한다. // 어긋나면 App Store Connect 업로드가 거부된다. "CFBundleVersion": "$(CURRENT_PROJECT_VERSION)", diff --git a/UMCApp/UMCWatchApp/Project.swift b/UMCApp/UMCWatchApp/Project.swift index 5d0d08fc1..323578e8e 100644 --- a/UMCApp/UMCWatchApp/Project.swift +++ b/UMCApp/UMCWatchApp/Project.swift @@ -1,11 +1,14 @@ import ProjectDescription import ProjectDescriptionHelpers +// `.project(target:)` 로 appExtension 을 걸면 Tuist 가 워치 앱 PlugIns 에 자동 임베드한다. let project = watchAppProject( name: "UMCWatchApp", bundleId: "com.umc.product.watchkitapp", + entitlements: .file(path: "UMCWatchApp.entitlements"), dependencies: [ .project(target: "CoreWatchConnectivity", path: .relativeToRoot("Core/WatchConnectivity")), .project(target: "CoreWatchDesignSystem", path: .relativeToRoot("Core/WatchDesignSystem")), + .project(target: "UMCWatchComplication", path: .relativeToRoot("UMCWatchComplication")), ] ) diff --git a/UMCApp/UMCWatchApp/Sources/ComplicationSyncModifier.swift b/UMCApp/UMCWatchApp/Sources/ComplicationSyncModifier.swift new file mode 100644 index 000000000..6edd6d6f8 --- /dev/null +++ b/UMCApp/UMCWatchApp/Sources/ComplicationSyncModifier.swift @@ -0,0 +1,42 @@ +// +// ComplicationSyncModifier.swift +// UMCWatchApp +// +// Created by euijjang97 on 8/30/26. +// + +import CoreWatchConnectivity +import SwiftUI + +// MARK: - ComplicationSyncModifier + +/// WC 로 새 스냅샷이 도착할 때마다 워치페이스 스냅샷을 갱신한다. +/// +/// 워치는 서버를 직접 폴링하지 않으므로 이 경로가 Complication 이 최신값을 얻는 유일한 통로다. +/// `initial: true` 인 이유는 콜드런치 시딩 때문이다 — 활성화 시점에 이미 도착해 있던 +/// 컨텍스트에는 델리게이트 콜백이 다시 오지 않아, 첫 값이 그대로 누락된다. +private struct ComplicationSyncModifier: ViewModifier { + + // MARK: - Property + + let coordinator: WatchSessionCoordinator + + // MARK: - Body + + func body(content: Content) -> some View { + content + .onChange(of: coordinator.receivedState, initial: true) { _, state in + guard let state else { return } + ComplicationStore.shared.save(ComplicationSnapshot(state: state)) + } + } +} + +// MARK: - View + syncsComplication + +extension View { + + func syncsComplication(with coordinator: WatchSessionCoordinator) -> some View { + modifier(ComplicationSyncModifier(coordinator: coordinator)) + } +} diff --git a/UMCApp/UMCWatchApp/Sources/UMCWatchApp.swift b/UMCApp/UMCWatchApp/Sources/UMCWatchApp.swift index 76fd2a4fc..fc11869c0 100644 --- a/UMCApp/UMCWatchApp/Sources/UMCWatchApp.swift +++ b/UMCApp/UMCWatchApp/Sources/UMCWatchApp.swift @@ -5,13 +5,23 @@ // Created by euijjang97 on 4/24/26. // +import CoreWatchConnectivity import SwiftUI @main struct UMCWatchApp: App { + + // MARK: - Property + + @State private var coordinator = WatchSessionCoordinator() + + // MARK: - Body + var body: some Scene { WindowGroup { ContentView() + .task { coordinator.activate() } + .syncsComplication(with: coordinator) } } } diff --git a/UMCApp/UMCWatchApp/UMCWatchApp.entitlements b/UMCApp/UMCWatchApp/UMCWatchApp.entitlements new file mode 100644 index 000000000..c173f1647 --- /dev/null +++ b/UMCApp/UMCWatchApp/UMCWatchApp.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.umc.product.watch + + + diff --git a/UMCApp/UMCWatchComplication/Project.swift b/UMCApp/UMCWatchComplication/Project.swift new file mode 100644 index 000000000..a082f96b1 --- /dev/null +++ b/UMCApp/UMCWatchComplication/Project.swift @@ -0,0 +1,16 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +// 번들 ID 는 반드시 워치 앱 번들 ID 를 prefix 로 가져야 한다. 어긋나면 워치 앱이 익스텐션을 +// 임베드하지 못하고 업로드가 거부된다. +let project = widgetExtensionProject( + name: "UMCWatchComplication", + bundleId: "com.umc.product.watchkitapp.complication", + destinations: [.appleWatch], + deploymentTargets: .watchOS("26.4"), + entitlements: .file(path: "UMCWatchComplication.entitlements"), + dependencies: [ + .project(target: "CoreWatchConnectivity", path: .relativeToRoot("Core/WatchConnectivity")), + .project(target: "CoreWatchDesignSystem", path: .relativeToRoot("Core/WatchDesignSystem")), + ] +) diff --git a/UMCApp/UMCWatchComplication/Sources/AttendanceStatusComplication.swift b/UMCApp/UMCWatchComplication/Sources/AttendanceStatusComplication.swift new file mode 100644 index 000000000..0ce46b737 --- /dev/null +++ b/UMCApp/UMCWatchComplication/Sources/AttendanceStatusComplication.swift @@ -0,0 +1,117 @@ +// +// AttendanceStatusComplication.swift +// UMCWatchComplication +// +// Created by euijjang97 on 8/30/26. +// + +import CoreWatchConnectivity +import CoreWatchDesignSystem +import SwiftUI +import WidgetKit + +// MARK: - AttendanceStatusComplication + +struct AttendanceStatusComplication: Widget { + + // MARK: - Body + + var body: some WidgetConfiguration { + StaticConfiguration( + kind: "UMCAttendanceStatus", + provider: ComplicationProvider() + ) { entry in + AttendanceStatusComplicationView(entry: entry) + } + .configurationDisplayName("출석 상태") + .description("다음 세션의 출석 상태를 확인합니다.") + .supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline]) + } +} + +// MARK: - AttendanceStatusComplicationView + +struct AttendanceStatusComplicationView: View { + + // MARK: - Property + + @Environment(\.widgetFamily) private var family + @Environment(\.widgetRenderingMode) private var renderingMode + + let entry: ComplicationEntry + + private var state: ComplicationAttendanceState { entry.snapshot.attendance } + + /// 승인 대기 링 두께. 링은 색이 아니라 형태라, accented 모드에서 색이 치환돼도 살아남는다. + private let pendingRingWidth: CGFloat = 2 + + // MARK: - Body + + var body: some View { + content + .privacySensitive() + .containerBackground(.clear, for: .widget) + } + + // MARK: - Function + + @ViewBuilder + private var content: some View { + if entry.snapshot.isSignedIn { + switch family { + case .accessoryInline: inline + case .accessoryRectangular: rectangular + default: circular + } + } else { + ComplicationSignedOutView() + } + } + + private var circular: some View { + ZStack { + AccessoryWidgetBackground() + statusSymbol + .font(.watch(.cardValue)) + .widgetAccentable() + } + .overlay { + if state.hasPendingRing { + Circle() + .strokeBorder(lineWidth: pendingRingWidth) + .complicationTint(state.fullColorTint, mode: renderingMode) + } + } + .accessibilityLabel(state.shortLabel) + } + + private var rectangular: some View { + VStack(alignment: .leading, spacing: WatchLayout.tightSpacing) { + HStack(spacing: WatchLayout.tightSpacing) { + statusSymbol + .widgetAccentable() + Text(state.shortLabel) + .font(.watch(.cardValue)) + } + if let session = entry.snapshot.nextSession { + Text(session.name) + .font(.watch(.caption)) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var inline: some View { + Label { + Text(state.shortLabel) + } icon: { + Image(systemName: state.symbolName) + } + } + + private var statusSymbol: some View { + Image(systemName: state.symbolName) + .complicationTint(state.fullColorTint, mode: renderingMode) + } +} diff --git a/UMCApp/UMCWatchComplication/Sources/ComplicationProvider.swift b/UMCApp/UMCWatchComplication/Sources/ComplicationProvider.swift new file mode 100644 index 000000000..23a69ebc9 --- /dev/null +++ b/UMCApp/UMCWatchComplication/Sources/ComplicationProvider.swift @@ -0,0 +1,76 @@ +// +// ComplicationProvider.swift +// UMCWatchComplication +// +// Created by euijjang97 on 8/30/26. +// + +import CoreWatchConnectivity +import WidgetKit + +// MARK: - ComplicationProvider + +/// 3종 위젯이 **공유**하는 단일 프로바이더. +/// +/// 셋은 같은 스냅샷을 읽고 뷰만 다르다. 프로바이더를 복제하면 같은 App Group 읽기를 세 벌 +/// 유지해야 하고, 리로드 타이밍이 위젯마다 어긋난다. +struct ComplicationProvider: TimelineProvider { + + // MARK: - Property + + /// 경계 시각이 없을 때의 리로드 폴백 간격. 실제 갱신의 주 동력은 WC 수신 시의 + /// `reloadAllTimelines()` 이고, 이 값은 그 경로가 끊겼을 때 워치페이스가 멈추지 않게 하는 안전망이다. + private static let fallbackReloadInterval: TimeInterval = 60 * 60 + + /// 한 번도 동기화되지 않은 상태. `generatedAt` 이 `.distantPast` 라 신선도 표시가 스스로 드러난다. + private static let neverSyncedSnapshot = ComplicationSnapshot( + isSignedIn: false, + nextSession: nil, + attendance: .none, + unreadPingCount: 0, + generatedAt: .distantPast + ) + + /// 워치페이스 갤러리용 표본. `#if DEBUG` 로 가리지 않는다 — 릴리스 빌드의 갤러리도 이 값을 그린다. + private static var gallerySnapshot: ComplicationSnapshot { + ComplicationSnapshot( + isSignedIn: true, + nextSession: ComplicationSession( + scheduleId: "0", + name: "정기 세션", + startsAt: Date(timeIntervalSinceNow: 45 * 60), + endsAt: Date(timeIntervalSinceNow: 165 * 60), + attendanceWindow: nil + ), + attendance: .upcoming, + unreadPingCount: 2, + generatedAt: Date() + ) + } + + // MARK: - Function + + func placeholder(in context: Context) -> ComplicationEntry { + ComplicationEntry(date: Date(), snapshot: Self.gallerySnapshot) + } + + func getSnapshot(in context: Context, completion: @escaping (ComplicationEntry) -> Void) { + let snapshot = context.isPreview + ? Self.gallerySnapshot + : ComplicationStore.shared.load() ?? Self.neverSyncedSnapshot + completion(ComplicationEntry(date: Date(), snapshot: snapshot)) + } + + func getTimeline( + in context: Context, + completion: @escaping (Timeline) -> Void + ) { + let now = Date() + let snapshot = ComplicationStore.shared.load() ?? Self.neverSyncedSnapshot + let entries = ComplicationTimeline.entries(from: snapshot, now: now) + let policy: TimelineReloadPolicy = entries.count > 1 + ? .atEnd + : .after(now.addingTimeInterval(Self.fallbackReloadInterval)) + completion(Timeline(entries: entries, policy: policy)) + } +} diff --git a/UMCApp/UMCWatchComplication/Sources/ComplicationStyle.swift b/UMCApp/UMCWatchComplication/Sources/ComplicationStyle.swift new file mode 100644 index 000000000..359aed5ec --- /dev/null +++ b/UMCApp/UMCWatchComplication/Sources/ComplicationStyle.swift @@ -0,0 +1,82 @@ +// +// ComplicationStyle.swift +// UMCWatchComplication +// +// Created by euijjang97 on 8/30/26. +// + +import CoreWatchConnectivity +import CoreWatchDesignSystem +import SwiftUI +import WidgetKit + +// MARK: - ComplicationAttendanceState + Tint + +extension ComplicationAttendanceState { + + /// `.fullColor` 워치페이스에서만 쓰는 색. + /// + /// `.pending`(승인 대기)과 `.excused`(공결)가 같은 중립색인 것은 의도다 — 스펙상 둘 다 + /// 「확정되지 않았거나 예외」 축이라 색으로 갈리지 않는다. 구분은 심볼과 링이 맡는다. + var fullColorTint: Color { + switch self { + case .none, .upcoming: WatchColor.textSecondary + case .awaiting: WatchColor.brandPrimaryHighlight + case .pending, .excused: WatchColor.statusPending + case .present: WatchColor.statusSuccess + case .late: WatchColor.statusWarning + case .absent: WatchColor.statusError + } + } +} + +// MARK: - View + complicationTint + +extension View { + + /// `.accented`·`.vibrant` 에서는 시스템이 색을 다시 칠한다. 그때 커스텀 색을 넘기면 + /// 치환 대상이 하나로 뭉개져 강조 계층만 사라지므로 `.primary` 를 준다. + func complicationTint(_ color: Color, mode: WidgetRenderingMode) -> some View { + foregroundStyle(mode == .fullColor ? color : Color.primary) + } +} + +// MARK: - ComplicationSignedOutView + +/// 로그아웃(또는 아직 한 번도 동기화되지 않음) 상태의 공통 표시. +/// +/// 3종이 같은 문구를 그려야 사용자가 「워치가 고장난 게 아니라 iPhone 에서 로그인해야 한다」를 +/// 한 번에 안다. 워치는 스스로 로그인할 수 없다. +struct ComplicationSignedOutView: View { + + // MARK: - Property + + @Environment(\.widgetFamily) private var family + + private let message = "iPhone 로그인 필요" + private let symbolName = "person.slash" + + // MARK: - Body + + var body: some View { + switch family { + case .accessoryInline: + Label(message, systemImage: symbolName) + case .accessoryRectangular: + HStack(spacing: WatchLayout.tightSpacing) { + Image(systemName: symbolName) + .widgetAccentable() + Text(message) + .font(.watch(.cardLabel)) + .lineLimit(2) + } + default: + ZStack { + AccessoryWidgetBackground() + Image(systemName: symbolName) + .font(.watch(.cardValue)) + .widgetAccentable() + } + } + } +} diff --git a/UMCApp/UMCWatchComplication/Sources/NextSessionComplication.swift b/UMCApp/UMCWatchComplication/Sources/NextSessionComplication.swift new file mode 100644 index 000000000..a51633c40 --- /dev/null +++ b/UMCApp/UMCWatchComplication/Sources/NextSessionComplication.swift @@ -0,0 +1,133 @@ +// +// NextSessionComplication.swift +// UMCWatchComplication +// +// Created by euijjang97 on 8/30/26. +// + +import CoreWatchConnectivity +import CoreWatchDesignSystem +import SwiftUI +import WidgetKit + +// MARK: - NextSessionComplication + +struct NextSessionComplication: Widget { + + // MARK: - Body + + var body: some WidgetConfiguration { + StaticConfiguration(kind: "UMCNextSession", provider: ComplicationProvider()) { entry in + NextSessionComplicationView(entry: entry) + } + .configurationDisplayName("다음 세션") + .description("가장 가까운 세션까지 남은 시간을 확인합니다.") + .supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline]) + } +} + +// MARK: - NextSessionComplicationView + +struct NextSessionComplicationView: View { + + // MARK: - Property + + @Environment(\.widgetFamily) private var family + + let entry: ComplicationEntry + + private var session: ComplicationSession? { entry.snapshot.nextSession } + + /// 진행 중이면 종료까지, 아니면 시작까지를 링으로 그린다. + /// 역전된 구간(`start >= end`)은 `ClosedRange` 생성 자체가 트랩이라 `nil` 로 떨군다. + private var countdownRange: ClosedRange? { + guard let session else { return nil } + let isInProgress = session.isInProgress(now: entry.date) + let start = isInProgress ? session.startsAt : entry.date + let end = isInProgress ? session.endsAt : session.startsAt + guard start < end else { return nil } + return start...end + } + + // MARK: - Body + + var body: some View { + content + .privacySensitive() + .containerBackground(.clear, for: .widget) + } + + // MARK: - Function + + @ViewBuilder + private var content: some View { + if entry.snapshot.isSignedIn { + switch family { + case .accessoryInline: inline + case .accessoryRectangular: rectangular + default: circular + } + } else { + ComplicationSignedOutView() + } + } + + @ViewBuilder + private var circular: some View { + ZStack { + AccessoryWidgetBackground() + if let countdownRange { + // 카운트다운 숫자로 타임라인 엔트리를 늘리지 않는다 — 링과 숫자 모두 + // 시스템이 스스로 갱신한다. + ProgressView(timerInterval: countdownRange, countsDown: true) { + EmptyView() + } currentValueLabel: { + Text(countdownRange.upperBound, style: .timer) + .font(.watch(.caption)) + .minimumScaleFactor(0.5) + .widgetAccentable() + } + .progressViewStyle(.circular) + } else { + Image(systemName: "calendar") + .font(.watch(.cardValue)) + .widgetAccentable() + .accessibilityLabel("예정된 세션 없음") + } + } + } + + @ViewBuilder + private var rectangular: some View { + if let session { + VStack(alignment: .leading, spacing: WatchLayout.tightSpacing) { + Text(session.name) + .font(.watch(.cardLabel)) + .lineLimit(1) + Text(session.startsAt, style: .relative) + .font(.watch(.cardValue)) + .widgetAccentable() + Text(session.startsAt, format: .dateTime.hour().minute()) + .font(.watch(.caption)) + } + .frame(maxWidth: .infinity, alignment: .leading) + } else { + Label("예정된 세션 없음", systemImage: "calendar") + .font(.watch(.cardLabel)) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + @ViewBuilder + private var inline: some View { + if let session { + Label { + Text("\(session.name) · \(session.startsAt.formatted(.dateTime.hour().minute()))") + } icon: { + Image(systemName: "calendar") + } + } else { + Label("예정된 세션 없음", systemImage: "calendar") + } + } +} diff --git a/UMCApp/UMCWatchComplication/Sources/PingCountComplication.swift b/UMCApp/UMCWatchComplication/Sources/PingCountComplication.swift new file mode 100644 index 000000000..874a8083f --- /dev/null +++ b/UMCApp/UMCWatchComplication/Sources/PingCountComplication.swift @@ -0,0 +1,116 @@ +// +// PingCountComplication.swift +// UMCWatchComplication +// +// Created by euijjang97 on 8/30/26. +// + +import CoreWatchConnectivity +import CoreWatchDesignSystem +import SwiftUI +import WidgetKit + +// MARK: - PingCountComplication + +struct PingCountComplication: Widget { + + // MARK: - Body + + var body: some WidgetConfiguration { + StaticConfiguration(kind: "UMCPingCount", provider: ComplicationProvider()) { entry in + PingCountComplicationView(entry: entry) + } + .configurationDisplayName("미확인 공지") + .description("아직 확인하지 않은 공지 개수를 확인합니다.") + .supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline]) + } +} + +// MARK: - PingCountComplicationView + +struct PingCountComplicationView: View { + + // MARK: - Property + + @Environment(\.widgetFamily) private var family + @Environment(\.widgetRenderingMode) private var renderingMode + + let entry: ComplicationEntry + + /// 워치페이스 지름 안에서 잘리지 않는 상한. 넘으면 정확한 수보다 「많다」가 더 쓸모 있다. + private let displayLimit = 99 + + private var count: Int { entry.snapshot.unreadPingCount } + + private var countText: String { count > displayLimit ? "\(displayLimit)+" : "\(count)" } + + // MARK: - Body + + var body: some View { + content + .privacySensitive() + .containerBackground(.clear, for: .widget) + } + + // MARK: - Function + + @ViewBuilder + private var content: some View { + if entry.snapshot.isSignedIn { + switch family { + case .accessoryInline: inline + case .accessoryRectangular: rectangular + default: circular + } + } else { + ComplicationSignedOutView() + } + } + + @ViewBuilder + private var circular: some View { + ZStack { + AccessoryWidgetBackground() + if count > 0 { + Text(countText) + .font(.watch(.metric)) + .minimumScaleFactor(0.4) + .lineLimit(1) + .complicationTint(WatchColor.brandAccent, mode: renderingMode) + .widgetAccentable() + .accessibilityLabel("미확인 공지 \(countText)") + } else { + Image(systemName: "bell") + .font(.watch(.cardValue)) + .widgetAccentable() + .accessibilityLabel("미확인 공지 없음") + } + } + } + + private var rectangular: some View { + VStack(alignment: .leading, spacing: WatchLayout.tightSpacing) { + Label { + Text(count > 0 ? "미확인 공지 \(countText)" : "미확인 공지 없음") + .font(.watch(.cardValue)) + } icon: { + Image(systemName: count > 0 ? "bell.badge" : "bell") + .complicationTint(WatchColor.brandAccent, mode: renderingMode) + .widgetAccentable() + } + // 워치는 서버를 직접 폴링하지 않는다. 값이 언제 기준인지 보여 줘야 사용자가 + // 「0인데 실제로는 새 공지가 있다」를 오해하지 않는다. + Text("\(entry.snapshot.generatedAt, style: .relative) 기준") + .font(.watch(.caption)) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var inline: some View { + Label { + Text(count > 0 ? "미확인 \(countText)" : "미확인 없음") + } icon: { + Image(systemName: count > 0 ? "bell.badge" : "bell") + } + } +} diff --git a/UMCApp/UMCWatchComplication/Sources/UMCWatchComplicationBundle.swift b/UMCApp/UMCWatchComplication/Sources/UMCWatchComplicationBundle.swift new file mode 100644 index 000000000..b2988ebdc --- /dev/null +++ b/UMCApp/UMCWatchComplication/Sources/UMCWatchComplicationBundle.swift @@ -0,0 +1,18 @@ +// +// UMCWatchComplicationBundle.swift +// UMCWatchComplication +// +// Created by euijjang97 on 8/30/26. +// + +import SwiftUI +import WidgetKit + +@main +struct UMCWatchComplicationBundle: WidgetBundle { + var body: some Widget { + NextSessionComplication() + AttendanceStatusComplication() + PingCountComplication() + } +} diff --git a/UMCApp/UMCWatchComplication/UMCWatchComplication.entitlements b/UMCApp/UMCWatchComplication/UMCWatchComplication.entitlements new file mode 100644 index 000000000..c173f1647 --- /dev/null +++ b/UMCApp/UMCWatchComplication/UMCWatchComplication.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.umc.product.watch + + + diff --git a/UMCApp/Workspace.swift b/UMCApp/Workspace.swift index 2a20d1d08..c4b623968 100644 --- a/UMCApp/Workspace.swift +++ b/UMCApp/Workspace.swift @@ -8,5 +8,6 @@ let workspace = Workspace( "Features/*", "UMCAppWidget", "UMCWatchApp", + "UMCWatchComplication", ] ) From d07248c83bac58c060ee52ac57112b5bade613f9 Mon Sep 17 00:00:00 2001 From: JEONG Date: Sun, 30 Aug 2026 14:12:19 +0900 Subject: [PATCH 4/4] =?UTF-8?q?docs:=20watchOS=20Complication=20=EB=A0=88?= =?UTF-8?q?=ED=8D=BC=EB=9F=B0=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/claude/watch-complications.md 신규 — 데이터 흐름(워치는 서버를 폴링하지 않는다)·모듈 배치 근거·App Group 함정·출석 8상태 매핑 표·tinted 규칙· 타임라인 정책·확장 체크리스트·테스트·트러블슈팅 6건 - CLAUDE.md 상세 레퍼런스 표에 인덱스 행 추가 --- CLAUDE.md | 1 + docs/claude/watch-complications.md | 182 +++++++++++++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 docs/claude/watch-complications.md diff --git a/CLAUDE.md b/CLAUDE.md index df5878db4..dc97a0665 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,6 +114,7 @@ cd UMCApp && make doctor # 환경 진단 | Response DTO 디코딩 | `docs/claude/response-dto-decoding.md` | Response DTO 작성/수정 | | 디자인 시스템 & 성능 | `docs/claude/design-system.md` | UI/토큰/Glass/렌더링 최적화 | | watchOS 디자인 시스템 | `docs/claude/watch-design-system.md` | 워치 화면·컴포넌트 작업, Glass 절제 규칙 확인 | +| watchOS Complication | `docs/claude/watch-complications.md` | 워치페이스 Complication 추가·수정, App Group 스냅샷 경로 확인 | | 코딩 스타일 & 네이밍 | `docs/claude/coding-style.md` | 네이밍 판단이 필요할 때 | | Git Workflow | `docs/claude/git-workflow.md` | 브랜치/커밋/PR/이슈(템플릿·Type·Priority)/배포 | | 프로젝트 구조(AppProduct) | `docs/claude/project-structure.md` | 레거시 디렉터리 탐색 | diff --git a/docs/claude/watch-complications.md b/docs/claude/watch-complications.md new file mode 100644 index 000000000..449803f51 --- /dev/null +++ b/docs/claude/watch-complications.md @@ -0,0 +1,182 @@ +# watchOS Complication (UMCWatchComplication) + +> 워치페이스 accessory 위젯 3종(#1215)의 데이터 흐름·모듈 배치·App Group·갱신 정책 레퍼런스. +> 워치 토큰·Glass 절제 규칙은 `docs/claude/watch-design-system.md`, WC 통신 계약은 `UMCApp/Core/WatchConnectivity` 소스 주석 참고. + +- 작성자: euijjang97 +- 기준 코드: + - `UMCApp/Core/WatchConnectivity/Sources/Complication/ComplicationSnapshot.swift` + - `UMCApp/Core/WatchConnectivity/Sources/Complication/ComplicationStore.swift` + - `UMCApp/Core/WatchConnectivity/Sources/WatchSessionCoordinator.swift` + - `UMCApp/UMCWatchComplication/Project.swift` · `Sources/` 6개 파일 + - `UMCApp/UMCWatchApp/Sources/ComplicationSyncModifier.swift` + - `UMCApp/Tuist/ProjectDescriptionHelpers/Project+WidgetExtension.swift` + - `UMCApp/Core/WatchConnectivity/Tests/ComplicationSnapshotTests.swift` · `ComplicationStoreTests.swift` + +## 1) 한눈에 — Complication 3종 + +전부 `StaticConfiguration` + 단일 공유 프로바이더(§7)이고, 지원 family 는 셋 다 동일하다: +`.accessoryCircular` · `.accessoryRectangular` · `.accessoryInline`. + +| kind | 표시 이름 | 그리는 것 | 파일 | +|------|-----------|-----------|------| +| `UMCNextSession` | 다음 세션 | 가장 가까운 세션까지 남은 시간 — circular 는 카운트다운 링, rectangular 는 세션명+상대시각 | `Sources/NextSessionComplication.swift` | +| `UMCAttendanceStatus` | 출석 상태 | 다음 세션의 출석 상태 (심볼+라벨+링, §5) | `Sources/AttendanceStatusComplication.swift` | +| `UMCPingCount` | 미확인 공지 | 미확인 The Ping 개수 (`99+` 절단) + `generatedAt` 신선도 캡션 | `Sources/PingCountComplication.swift` | + +- 로그아웃(또는 최초 동기화 전) 상태는 3종 모두 공통 뷰 `ComplicationSignedOutView` 로 「iPhone 로그인 필요」를 그린다 — 워치는 스스로 로그인할 수 없으므로 같은 문구여야 사용자가 조치처를 한 번에 안다 (`Sources/ComplicationStyle.swift:46-49`). +- 3종 모두 `.privacySensitive()` — 손목 프라이버시 모드에서 값이 가려진다 (예: `NextSessionComplication.swift:55-57`). + +## 2) 데이터 흐름 — 워치는 서버를 폴링하지 않는다 + +``` +운영진 승인/공지 → 서버 → 푸시 → iPhone 앱 + → WatchSessionCoordinator.publishSessionState(_:) // WC updateApplicationContext + → 워치 앱 didReceiveApplicationContext → receivedState 갱신 + → ComplicationSyncModifier (onChange of receivedState) + → ComplicationSnapshot(state:) 파생 (순수 함수) + → ComplicationStore.save → App Group UserDefaults + → WidgetCenter.reloadAllTimelines() → 워치페이스 +``` + +- **워치·익스텐션 어디에도 네트워크 코드가 없다.** 최신값의 유일한 통로는 iPhone 이 `updateApplicationContext` 로 밀어 넣는 `WatchSessionState` 다 (`WatchSessionCoordinator.swift:124-134`, `Models/WatchSessionState.swift:12-16`). +- 워치 앱은 `didReceiveApplicationContext` 에서 `receivedState` 를 갱신하고 (`WatchSessionCoordinator.swift:302-315`), `ComplicationSyncModifier` 가 이를 관찰해 스냅샷을 저장한다 (`UMCWatchApp/Sources/ComplicationSyncModifier.swift:26-32`). 진입점은 앱 루트의 `.syncsComplication(with:)` 한 줄이다 (`UMCWatchApp/Sources/UMCWatchApp.swift:20-26`). +- `onChange(…, initial: true)` 인 이유는 **콜드런치 시딩**이다 — WC 활성화 시점에 이미 도착해 있던 컨텍스트에는 델리게이트 콜백이 다시 오지 않아(활성화 완료 시 `receivedApplicationContext` 로 시딩됨, `WatchSessionCoordinator.swift:243-253`), `initial` 이 없으면 첫 값이 그대로 누락된다 (`ComplicationSyncModifier.swift:16-17`). +- `WatchSessionState` 를 그대로 저장하지 않고 `ComplicationSnapshot` 으로 줄이는 이유: 워치페이스는 「다음 하나」와 「개수」만 그리는데, 타임라인 리프레시마다 목록 전체를 디코딩하면 매 갱신에 낭비가 붙는다 (`ComplicationSnapshot.swift:13-16`). + +### 2-1) `ComplicationSnapshot` 파생 규칙 + +`init(state:now:)` 는 순수 함수라 테스트가 규칙을 잠근다 (`ComplicationSnapshot.swift:48-57`, §9): + +| 필드 | 규칙 | 근거 위치 | +|------|------|-----------| +| `nextSession` | `endsAt > now` 인 세션 중 `startsAt` 최소 (동률이면 `scheduleId` 사전순). 진행 중 세션은 정렬만으로 자연히 우선 | `ComplicationSnapshot.swift:76-89` | +| `attendance` | 서버 원본 문자열 매핑 우선 → 없거나 모르는 값이면 출석 창 폴백 (§5) | `ComplicationSnapshot.swift:91-103` | +| `unreadPingCount` | `notices` 중 `!isRead` 개수. 상한 없음 — `99+` 절단은 뷰의 책임 | `ComplicationSnapshot.swift:26-27, 54` | +| `generatedAt` | 원본 스냅샷 생성 시각 그대로 — 「N분 전 기준」 신선도 표시용 | `ComplicationSnapshot.swift:28-29`, `PingCountComplication.swift:101-104` | + +`scheduleId` 는 서버 정수를 `String` 으로 보존한다 (절대 규칙 #2, `ComplicationSnapshot.swift:124-125`). + +## 3) 모듈·타겟 배치 + +### 3-1) 왜 새 Core 모듈이 아니라 `CoreWatchConnectivity` 안인가 + +`ComplicationSnapshot`·`ComplicationStore` 는 `Core/WatchConnectivity/Sources/Complication/` 에 있다. + +- 파생 로직의 입력이 `WatchSessionState` 라 **WC 계약의 연장**이다. 모듈을 가르면 워치 계약이 바뀔 때마다 두 모듈이 lockstep 으로 움직여야 한다. +- 익스텐션 타겟은 유닛 테스트에서 import 할 수 없다 — 파생·타임라인 규칙을 익스텐션에 두면 테스트 불가가 된다 (`ComplicationSnapshot.swift:263-266`). Core 에 두면 기존 `CoreWatchConnectivityTests` 스킴이 그대로 잠근다 (§9). +- 대가로 `CoreWatchConnectivity` 가 `WidgetKit` SDK 를 문다 — `ComplicationStore.save` 가 저장 직후 타임라인을 리로드하기 위해서다. 저장과 리로드를 갈라 두면 호출자가 리로드를 빠뜨려 값이 조용히 낡는다 (`Core/WatchConnectivity/Project.swift:4-5, 13`). + +### 3-2) 익스텐션 타겟 `UMCWatchComplication` + +- **번들 ID 는 반드시 워치 앱 번들 ID(`com.umc.product.watchkitapp`)를 prefix 로 가진다** → `com.umc.product.watchkitapp.complication`. 어긋나면 워치 앱이 익스텐션을 임베드하지 못하고 업로드가 거부된다 (`UMCWatchComplication/Project.swift:4-8`). +- 임베드는 Tuist 가 처리한다 — `UMCWatchApp` 이 `.project(target: "UMCWatchComplication", …)` 을 걸면 워치 앱 PlugIns 에 자동 임베드된다 (`UMCWatchApp/Project.swift:4, 12`). 워크스페이스에도 명시 포함 (`UMCApp/Workspace.swift:11`). +- 매니페스트는 iOS 위젯(`UMCAppWidget`)과 같은 `widgetExtensionProject` 헬퍼를 쓴다. 헬퍼를 복제하지 않고 `destinations`/`deploymentTargets` 파라미터를 추가한 이유: 익스텐션 Info.plist 의 함정 — `CFBundleDisplayName` 누락 시 ITMS-90360 거부, `CFBundleVersion` 호스트 불일치 시 업로드 거부, `NSExtensionPointIdentifier` — 이 **플랫폼과 무관하게 동일**해서, 두 벌로 가르면 이 지식이 한쪽만 고쳐지는 드리프트가 생긴다 (`Tuist/ProjectDescriptionHelpers/Project+WidgetExtension.swift:16-47`). watchOS 는 `destinations: [.appleWatch]` + `deploymentTargets: .watchOS("26.4")` 만 넘긴다 (`UMCWatchComplication/Project.swift:9-10`). + +## 4) App Group — `group.com.umc.product.watch` + +워치 앱(쓰기)과 익스텐션(읽기)은 프로세스가 달라 App Group `UserDefaults` 로 스냅샷을 공유한다 (`ComplicationStore.swift:25, 33-35`). + +- **iOS 위젯의 `group.com.umc.product.widget` 을 재사용할 수 없다** — App Group 컨테이너는 iPhone 과 워치가 공유하지 않는다. 같은 식별자를 양쪽에 등록해도 물리적으로 다른 컨테이너라, 워치 전용 그룹을 따로 두는 편이 「공유되는 것처럼 보이는」 오해를 막는다 (`ComplicationStore.swift:14-16`). +- 식별자는 **세 곳이 정확히 일치**해야 한다: `ComplicationStore.appGroupIdentifier`, `UMCWatchApp/UMCWatchApp.entitlements`, `UMCWatchComplication/UMCWatchComplication.entitlements`. 어긋나면 저장은 성공한 것처럼 보이는데 익스텐션이 읽는 컨테이너가 달라 워치페이스가 영원히 비어 있다 (`ComplicationStore.swift:23-25`). + +> ⚠️ **App Group 은 Apple Developer 포털 등록이 필요한 사람 작업이다.** `group.com.umc.product.watch` 를 포털에 등록하고 워치 앱·익스텐션 프로비저닝에 포함해야 한다. 미등록 상태에서는 `UserDefaults(suiteName:)` 이 nil 을 내고, `load()`/`save()` 는 guard 로 조용히 빠져나간다 (`ComplicationStore.swift:39-58`) — **크래시도 에러도 없이** 워치페이스만 「iPhone 로그인 필요」에 고정된다. + +## 5) 출석 상태 매핑 + +서버 `AttendanceStatus` 원본 문자열(`WatchSchedule.attendanceStatus`, 절대 규칙 #2 로 String 보존)을 `ComplicationAttendanceState.from(rawStatus:)` 가 표시 상태로 바꾼다 (`ComplicationSnapshot.swift:232-241`). 색 토큰은 익스텐션 쪽 `fullColorTint` 다 (`UMCWatchComplication/Sources/ComplicationStyle.swift:21-30`). + +| 서버 원본 | 상태 | SF Symbol | 라벨 | 링 | `.fullColor` 색 | +|-----------|------|-----------|------|:--:|-----------------| +| — (다음 세션 없음/출석 비필수) | `.none` | `calendar` | 예정 없음 | | `WatchColor.textSecondary` | +| — (창 열리기 전, 폴백) | `.upcoming` | `clock` | 출석 예정 | | `WatchColor.textSecondary` | +| — (창 열림, 폴백) | `.awaiting` | `location.circle` | 출석 가능 | | `WatchColor.brandPrimaryHighlight` | +| `PENDING` `PRESENT_PENDING` `LATE_PENDING` `EXCUSED_PENDING` | `.pending` | `hourglass` | 승인 대기 | ✅ | `WatchColor.statusPending` | +| `PRESENT` | `.present` | `checkmark.circle.fill` | 출석 | | `WatchColor.statusSuccess` | +| `LATE` | `.late` | `exclamationmark.circle.fill` | 지각 | | `WatchColor.statusWarning` | +| `EXCUSED` | `.excused` | `checkmark.shield.fill` | 공결 | | `WatchColor.statusPending` | +| `ABSENT` | `.absent` | `xmark.circle.fill` | 결석 | | `WatchColor.statusError` | + +- **`EXCUSED` 를 `.present` 로 합치지 않는다** — 합치는 순간 공결 사용자가 볼 화면이 사라진다 (`ComplicationSnapshot.swift:229-241`, 테스트 `excusedStaysDistinct`). +- **모르는 문자열은 nil → 창 폴백**: `checkInStartAt` 이전 `.upcoming` → `lateEndAt` 이전 `.awaiting` → 이후 `.absent` (`ComplicationSnapshot.swift:105-115`). 창이 닫혔는데 상태가 없으면 결석이다 — 「알 수 없음」으로 두면 사용자가 조치할 시점을 놓친다. 서버가 미래에 상태를 추가해도 워치는 크래시 없이 폴백으로 동작한다 (테스트 `unknownStatusFallsBackToWindow`). +- `.pending/.present/.late/.excused/.absent` 는 `isServerConfirmed` — 시간이 흘러도 뒤집히지 않으므로 타임라인 경계 엔트리가 창 판정으로 덮어쓰지 않는다 (`ComplicationSnapshot.swift:219-225`, §7). + +## 6) tinted(accented) 모드 — 색은 보조 채널이다 + +accented·vibrant 워치페이스에서는 **시스템이 색을 단색으로 치환**한다. 색으로만 구분하던 상태는 통째로 구별 불가가 된다. 그래서: + +1. **모든 상태가 심볼(실루엣 상이)·라벨·링을 색과 병행한다** (`ComplicationSnapshot.swift:167-171`). 이 규칙은 `ComplicationSnapshotTests` 의 `stateChannelsAreDistinct`(8개 상태의 심볼·라벨 중복 금지, `Tests/ComplicationSnapshotTests.swift:259-268`)와 `pendingRingIsExclusive`(링은 `.pending` 전용, `:270-275`)가 잠근다. +2. 커스텀 색은 `.fullColor` 렌더링 모드에서만 적용한다 — `complicationTint(_:mode:)` 가 accented 에서 `.primary` 로 떨군다. 치환 대상 색을 커스텀으로 넘기면 강조 계층이 하나로 뭉개진다 (`ComplicationStyle.swift:35-42`). +3. `.pending`(승인 대기)과 `.excused`(공결)가 같은 중립색인 것은 의도다 — 스펙상 둘 다 「확정되지 않았거나 예외」 축이라 색으로 갈리지 않는다. 구분은 심볼과 **링(색이 아니라 형태라 accented 에서도 살아남는다)**이 맡는다 (`ComplicationStyle.swift:17-20`, `AttendanceStatusComplication.swift:45-46, 78-84`). +4. **Complication 에 Liquid Glass 를 쓸 수 없다** — accessory family 는 시스템이 렌더를 전담한다. 배경은 `AccessoryWidgetBackground()` 만 쓴다. `docs/claude/watch-design-system.md` §4 Glass 매트릭스의 금지 구역이다. + +## 7) TimelineProvider 갱신 정책 + +3종은 `ComplicationProvider` **하나를 공유**한다 — 같은 스냅샷을 읽고 뷰만 다른데, 프로바이더를 복제하면 App Group 읽기를 세 벌 유지해야 하고 리로드 타이밍이 위젯마다 어긋난다 (`UMCWatchComplication/Sources/ComplicationProvider.swift:13-16`). + +### 7-1) 엔트리 생성 — 상태가 실제로 바뀌는 시각만 + +`ComplicationTimeline.entries(from:now:)` (`ComplicationSnapshot.swift:279-287`): + +- `now` 엔트리 1개 + **상태 전이 경계 시각**의 엔트리들. 경계 후보는 세션 `startsAt`/`endsAt` + 출석 창 3시각(`checkInStartAt`/`onTimeEndAt`/`lateEndAt`) 중 미래분, 오름차순 최대 6개 (`ComplicationSnapshot.swift:271-272, 289-296`). 워치 리프레시 예산이 유한해서 한 세션의 상태 전이를 덮는 최소치로 상한을 둔다. +- 경계 엔트리의 스냅샷은 `projected(at:)` 로 **출석 상태만** 재계산한다 — 미래 시점의 일정 목록·읽음 여부는 워치가 알 수 없고, 시간 경과만으로 확정적으로 바뀌는 값은 출석 창 판정뿐이다. 서버 확정 상태(`isServerConfirmed`)는 덮어쓰지 않는다 (`ComplicationSnapshot.swift:61-74`). +- **카운트다운 숫자로는 엔트리를 늘리지 않는다.** 분 단위 엔트리는 리프레시 예산을 태우므로, 링과 숫자는 `ProgressView(timerInterval:countsDown:)` + `Text(_, style: .timer)` / `Text(_, style: .relative)` 로 시스템이 스스로 갱신하게 맡긴다 (`NextSessionComplication.swift:79-90, 107`). + +### 7-2) 리로드 정책 + +| 상황 | 정책 | 위치 | +|------|------|------| +| 경계 엔트리가 있음 | `.atEnd` — 마지막 경계를 지나면 재계산 | `ComplicationProvider.swift:71-73` | +| 경계 없음 (세션 없음 등) | `.after(now + 60분)` 폴백 | `ComplicationProvider.swift:21-23` | +| WC 로 새 스냅샷 도착 | `ComplicationStore.save` 가 즉시 `reloadAllTimelines()` — **이것이 갱신의 주 동력**이고 위 정책은 이 경로가 끊겼을 때의 안전망 | `ComplicationStore.swift:47-58` | + +- 스토어가 비어 있으면(최초 동기화 전) `generatedAt = .distantPast` 인 `neverSyncedSnapshot` 을 그려 신선도 표시가 스스로 문제를 드러낸다 (`ComplicationProvider.swift:25-32`). +- 갤러리 표본(`gallerySnapshot`)은 `#if DEBUG` 로 가리지 않는다 — 릴리스 빌드의 워치페이스 갤러리도 이 값을 그린다 (`ComplicationProvider.swift:34-49`). 실사용자 데이터는 갤러리에 노출되지 않는다 (`context.isPreview` 분기, `:57-62`). + +## 8) 체크리스트 — accessory 위젯을 하나 더 붙일 때 + +- [ ] `UMCWatchComplication/Sources/` 에 `Widget` + View 파일 추가 — kind 는 `UMC` prefix, `ComplicationProvider` 를 그대로 쓴다 (복제 금지, §7) +- [ ] `UMCWatchComplicationBundle.body` 에 한 줄 추가 (`Sources/UMCWatchComplicationBundle.swift:12-18`) +- [ ] 로그아웃 분기는 `ComplicationSignedOutView`, 콘텐츠에 `.privacySensitive()` + `.containerBackground(.clear, for: .widget)` +- [ ] 색은 `complicationTint(_:mode:)` 경유 — 상태를 색 단독으로 구분하지 않는다 (§6) +- [ ] 새 파생값이 필요하면 `ComplicationSnapshot` 에 필드를 추가하고 테스트를 함께 — 익스텐션 안에서 파생하지 않는다 (§3-1) + +## 9) 테스트 — `make test SCHEME=CoreWatchConnectivity` + +파생·저장 로직을 익스텐션이 아니라 Core 의 **순수 함수/주입 가능한 스토어**로 뽑아 놓은 이유가 이것이다. 워치페이스는 사용자가 앱을 열지 않고 보는 화면이라 잘못된 값을 정정할 기회가 없다 (`Tests/ComplicationSnapshotTests.swift:12-15`). + +| 스위트 | 잠그는 것 | +|--------|-----------| +| `ComplicationSnapshotTests` | 다음 세션 선정(끝난 세션 제외·진행 중 우선·동률 규칙), 서버 상태 매핑(§5 표 전체), 창 폴백 3구간, 미확인 개수, 심볼·라벨 유일성, pending 링 배타성 | +| `ComplicationStoreTests` | 직렬화 왕복(ISO8601 — 소수점 이하 초는 왕복에서 잘린다, `:37-38`), 타임라인 경계 엔트리 순서·상한, 서버 확정 상태의 projection 생존 | + +실제 App Group 은 서명된 앱에서만 열리므로 스토어 테스트는 임의 suite 이름을 주입한다 — 검증 대상은 컨테이너가 아니라 직렬화와 엔트리 규칙이다 (`Tests/ComplicationStoreTests.swift:12-15, 25-27`). + +## 10) 남은 사람 작업 + +- [ ] **App Group 포털 등록**: `group.com.umc.product.watch` 를 Apple Developer 포털에 등록하고 워치 앱·익스텐션 App ID/프로비저닝에 포함 (§4 경고 참조 — 미등록 시 조용히 실패) +- [ ] **디자이너 tinted 목업 확정**: accented 모드 실기기 렌더 기준의 목업이 설계 스펙 §9(기획 레포)에 미해결로 남아 있다 — 확정되면 `fullColorTint`/링 두께 조정 가능성 있음 +- [ ] **워치페이스 실배치 확인**: 실기기에서 3종을 워치페이스에 올려 accented/fullColor 양쪽, 갤러리 표본, 프라이버시 모드 가림을 확인 +- [ ] **iPhone 쪽 퍼블리시 배선 (#1211)**: `publishSessionState(_:)` 는 API 만 존재하고 iOS 앱 쪽 호출부(푸시 수신·데이터 갱신 시점)는 아직 배선되지 않았다 — 배선 전까지 실기기 워치페이스는 「iPhone 로그인 필요」에 머무른다 + +## 11) 트러블슈팅 + +- 증상: 워치페이스가 「iPhone 로그인 필요」에 고정 / 영원히 비어 있음 + - 원인 1: App Group 미등록 또는 3곳 식별자 불일치 → `UserDefaults(suiteName:)` nil → `load()`/`save()` 가 guard 로 조용히 실패 (`ComplicationStore.swift:39-58`) + - 원인 2: iPhone 이 `publishSessionState` 를 아직 호출하지 않음 (#1211, §10) — 스토어가 비어 `neverSyncedSnapshot`(`isSignedIn: false`)을 그린다 (`ComplicationProvider.swift:26-32`) + - 해결: 포털 등록·entitlements 3곳 대조(§4) → iPhone 퍼블리시 경로 확인 +- 증상: 워치 앱 값은 최신인데 워치페이스만 옛날 값 + - 원인: `ComplicationStore.save` 를 거치지 않고 스냅샷을 저장했거나(리로드 누락), 익스텐션이 다른 suite 를 읽는다 — 저장과 `reloadAllTimelines()` 를 묶어 둔 이유가 이것이다 (`ComplicationStore.swift:47-58`) + - 해결: 쓰기는 항상 `ComplicationStore.shared.save(_:)` 경유 +- 증상: 워치 콜드런치 직후 첫 스냅샷이 워치페이스에 반영 안 됨 + - 원인: `ComplicationSyncModifier` 의 `onChange(…, initial: true)` 가 빠졌다 — 활성화 전에 도착한 컨텍스트는 델리게이트 콜백이 다시 오지 않는다 (`ComplicationSyncModifier.swift:16-17, 28`) + - 해결: `initial: true` 유지. 시딩 자체는 `activationDidCompleteWith` 가 담당한다 (`WatchSessionCoordinator.swift:243-253`) +- 증상: 서버가 새 출석 상태 문자열을 내려보낸 뒤 워치가 창 기반 상태(출석 가능/결석)를 그린다 + - 원인: `from(rawStatus:)` 가 모르는 값은 nil → 창 폴백 (`ComplicationSnapshot.swift:229-241`) — 크래시 대신 의도된 강등이다 + - 해결: 새 문자열을 `from(rawStatus:)` 와 §5 표·테스트에 추가 +- 증상: accented 워치페이스에서 출석/지각/결석이 똑같아 보인다 + - 원인: 시스템 단색 치환은 정상이다. 상태 구분은 심볼·라벨·링이 담당한다 (§6) — 같아 보인다면 색 단독 표현이 섞였다는 뜻 + - 해결: `stateChannelsAreDistinct`/`pendingRingIsExclusive` 테스트가 통과하는지, 커스텀 색이 `complicationTint(_:mode:)` 를 우회하지 않는지 확인 +- 증상: `saveLoadRoundtrip` 류 테스트가 `generatedAt` 불일치로 실패 + - 원인: 봉투 코덱이 ISO8601 이라 소수점 이하 초가 왕복에서 잘린다 (`Tests/ComplicationStoreTests.swift:37-38`) + - 해결: 픽스처 시각을 초 단위로 딱 떨어지게 잡는다