Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | 레거시 디렉터리 탐색 |
Expand Down
3 changes: 2 additions & 1 deletion UMCApp/Core/WatchConnectivity/Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
89 changes: 89 additions & 0 deletions UMCApp/Core/WatchConnectivity/Sources/Models/WatchAttendance.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
103 changes: 103 additions & 0 deletions UMCApp/Core/WatchConnectivity/Sources/Models/WatchMessage.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
79 changes: 79 additions & 0 deletions UMCApp/Core/WatchConnectivity/Sources/Models/WatchNotice.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading