diff --git a/UMCApp/Core/Domain/Project.swift b/UMCApp/Core/Domain/Project.swift index 38e38a4f7..d42604f33 100644 --- a/UMCApp/Core/Domain/Project.swift +++ b/UMCApp/Core/Domain/Project.swift @@ -4,6 +4,10 @@ import ProjectDescriptionHelpers let project = coreProject( name: "CoreDomain", bundleIdSuffix: "domain", + // #1212: watch 출석 도메인이 ActivityDomain → CoreDomain 경로로 canonical 모델을 재사용한다. + // 소스는 Foundation/UMCFoundation 만 쓰므로 watchOS 제약이 없다. + destinations: [.iPhone, .appleWatch], + deploymentTargets: .multiplatform(iOS: "26.4", watchOS: "26.4"), dependencies: [ .project(target: "UMCFoundation", path: .relativeToRoot("Core/Foundation")), ], diff --git a/UMCApp/Features/Activity/Domain/Sources/Enums/Attendance/AttendanceTimeWindow+Schedule.swift b/UMCApp/Features/Activity/Domain/Sources/Enums/Attendance/AttendanceTimeWindow+Schedule.swift new file mode 100644 index 000000000..d1bb00ca2 --- /dev/null +++ b/UMCApp/Features/Activity/Domain/Sources/Enums/Attendance/AttendanceTimeWindow+Schedule.swift @@ -0,0 +1,83 @@ +// +// AttendanceTimeWindow+Schedule.swift +// ActivityDomain +// +// Created by euijjang97 on 8/30/26. +// + +import Foundation +import HomeDomain + +// MARK: - 출석 시간대 판정 (순수 함수) + +extension AttendanceTimeWindow { + + /// 클라이언트 상수(`AttendancePolicy`) 기반 판정. + /// + /// 서버 출석 정책이 붙지 않은 일정/세션에 쓰는 폴백 규칙이다. + /// 종일 일정은 지각 구간을 두지 않고 종료 시각까지 전부 정시로 본다. + public init(startsAt: Date, endsAt: Date, isAllDay: Bool, now: Date) { + let onTimeThreshold = TimeInterval( + AttendancePolicy.onTimeThresholdMinutes * 60 + ) + let lateThreshold = TimeInterval( + AttendancePolicy.lateThresholdMinutes * 60 + ) + + if isAllDay { + if now < startsAt.addingTimeInterval(-onTimeThreshold) { + self = .tooEarly + } else if now <= endsAt { + self = .onTime + } else { + self = .expired + } + return + } + + if now < startsAt.addingTimeInterval(-onTimeThreshold) { + self = .tooEarly + } else if now <= startsAt.addingTimeInterval(onTimeThreshold) { + self = .onTime + } else if now <= startsAt.addingTimeInterval(lateThreshold) { + self = .lateWindow + } else { + self = .expired + } + } + + /// 서버 출석 정책 우선 판정 — `policy` 가 `nil` 이면 클라이언트 상수로 폴백한다. + public init( + policy: ScheduleAttendancePolicy?, + startsAt: Date, + endsAt: Date, + isAllDay: Bool, + now: Date + ) { + guard let policy else { + self.init(startsAt: startsAt, endsAt: endsAt, isAllDay: isAllDay, now: now) + return + } + + if now < policy.checkInStartAt { + self = .tooEarly + } else if now <= policy.onTimeEndAt { + self = .onTime + } else if now <= policy.lateEndAt { + self = .lateWindow + } else { + self = .expired + } + } + + /// 일정(canonical `HomeDomain` 모델) 기반 편의 진입점. + public init(schedule: ScheduleDetailData, now: Date = Date()) { + self.init( + policy: schedule.attendancePolicy, + startsAt: schedule.startsAt, + endsAt: schedule.endsAt, + isAllDay: schedule.isAllDay, + now: now + ) + } +} diff --git a/UMCApp/Features/Activity/Domain/Sources/UseCases/Implementations/ChallengerAttendanceUseCase.swift b/UMCApp/Features/Activity/Domain/Sources/UseCases/Implementations/ChallengerAttendanceUseCase.swift index 12cc893d0..42a64ba51 100644 --- a/UMCApp/Features/Activity/Domain/Sources/UseCases/Implementations/ChallengerAttendanceUseCase.swift +++ b/UMCApp/Features/Activity/Domain/Sources/UseCases/Implementations/ChallengerAttendanceUseCase.swift @@ -164,38 +164,19 @@ public final class ChallengerAttendanceUseCase: ChallengerAttendanceUseCaseProto // MARK: - 시간 윈도우 /// 기준 시각(`now`)이 어느 출석 시간대에 속하는지 판정 + /// + /// 판정 규칙 자체는 `AttendanceTimeWindow` 의 순수 이니셜라이저가 소유한다. + /// 전송 계층 없이 시간대만 필요한 호출부(워치 등)가 UseCase 조립 없이 같은 규칙을 쓴다. public func isWithinAttendanceTime( info: SessionInfo, now: Date ) -> AttendanceTimeWindow { - let onTimeThreshold = TimeInterval( - AttendancePolicy.onTimeThresholdMinutes * 60 - ) - let lateThreshold = TimeInterval( - AttendancePolicy.lateThresholdMinutes * 60 + AttendanceTimeWindow( + startsAt: info.startTime, + endsAt: info.endTime, + isAllDay: info.isAllDay, + now: now ) - let startTime = info.startTime - - if info.isAllDay { - if now < startTime.addingTimeInterval(-onTimeThreshold) { - return .tooEarly - } - if now <= info.endTime { - return .onTime - } - return .expired - } - - if now < startTime.addingTimeInterval(-onTimeThreshold) { - return .tooEarly - } - if now <= startTime.addingTimeInterval(onTimeThreshold) { - return .onTime - } - if now <= startTime.addingTimeInterval(lateThreshold) { - return .lateWindow - } - return .expired } // MARK: - 위치/지오펜스 diff --git a/UMCApp/Features/Activity/Domain/Tests/Enums/AttendanceTimeWindowScheduleTests.swift b/UMCApp/Features/Activity/Domain/Tests/Enums/AttendanceTimeWindowScheduleTests.swift new file mode 100644 index 000000000..c1c872e73 --- /dev/null +++ b/UMCApp/Features/Activity/Domain/Tests/Enums/AttendanceTimeWindowScheduleTests.swift @@ -0,0 +1,227 @@ +// +// AttendanceTimeWindowScheduleTests.swift +// ActivityDomainTests +// +// Created by euijjang97 on 8/30/26. +// + +import Foundation +import HomeDomain +import Testing +@testable import ActivityDomain + +// MARK: - Helpers + +/// 결정론적 기준 시각: epoch 10_000. 모든 케이스가 이 값을 기준으로 상대 정의된다. +private let fixedNow = Date(timeIntervalSince1970: 10_000) + +private let onTimeSec = TimeInterval(AttendancePolicy.onTimeThresholdMinutes * 60) +private let lateSec = TimeInterval(AttendancePolicy.lateThresholdMinutes * 60) + +private func makeSchedule( + startsAt: Date, + endsAt: Date, + attendancePolicy: ScheduleAttendancePolicy? = nil +) -> ScheduleDetailData { + ScheduleDetailData( + scheduleId: "1", + name: "정기 세션", + description: "", + tags: [], + startsAt: startsAt, + endsAt: endsAt, + isParticipant: true, + attendancePolicy: attendancePolicy + ) +} + +// MARK: - 상수 기반 판정 + +@Suite("AttendanceTimeWindow — 클라이언트 상수 기반 판정") +struct AttendanceTimeWindowConstantTests { + + @Test("일반 — 시작 onTime 임계 이전 → tooEarly") + func nonAllDayTooEarly() { + let window = AttendanceTimeWindow( + startsAt: fixedNow.addingTimeInterval(onTimeSec + 60), + endsAt: fixedNow.addingTimeInterval(onTimeSec + 3_600), + isAllDay: false, + now: fixedNow + ) + #expect(window == .tooEarly) + } + + @Test("일반 — onTime 경계값(now == start + onTime) → onTime (boundary 포함)") + func nonAllDayOnTimeUpperBoundary() { + let window = AttendanceTimeWindow( + startsAt: fixedNow.addingTimeInterval(-onTimeSec), + endsAt: fixedNow.addingTimeInterval(3_600), + isAllDay: false, + now: fixedNow + ) + #expect(window == .onTime) + } + + @Test("일반 — onTime 초과 ~ late 임계 → lateWindow") + func nonAllDayLateWindow() { + let window = AttendanceTimeWindow( + startsAt: fixedNow.addingTimeInterval(-(onTimeSec + 60)), + endsAt: fixedNow.addingTimeInterval(3_600), + isAllDay: false, + now: fixedNow + ) + #expect(window == .lateWindow) + } + + @Test("일반 — late 임계 초과 → expired") + func nonAllDayExpired() { + let window = AttendanceTimeWindow( + startsAt: fixedNow.addingTimeInterval(-(lateSec + 60)), + endsAt: fixedNow.addingTimeInterval(3_600), + isAllDay: false, + now: fixedNow + ) + #expect(window == .expired) + } + + @Test("종일 — 시작 onTime 임계 이전 → tooEarly") + func allDayTooEarly() { + let window = AttendanceTimeWindow( + startsAt: fixedNow.addingTimeInterval(onTimeSec + 60), + endsAt: fixedNow.addingTimeInterval(onTimeSec + 86_400), + isAllDay: true, + now: fixedNow + ) + #expect(window == .tooEarly) + } + + @Test("종일 — late 임계를 지나도 종료 전이면 onTime (lateWindow 미분기)") + func allDayStaysOnTimeUntilEnd() { + let window = AttendanceTimeWindow( + startsAt: fixedNow.addingTimeInterval(-(lateSec + 3_600)), + endsAt: fixedNow.addingTimeInterval(3_600), + isAllDay: true, + now: fixedNow + ) + #expect(window == .onTime) + } + + @Test("종일 — 종료 시각 지남 → expired") + func allDayExpired() { + let window = AttendanceTimeWindow( + startsAt: fixedNow.addingTimeInterval(-86_400), + endsAt: fixedNow.addingTimeInterval(-60), + isAllDay: true, + now: fixedNow + ) + #expect(window == .expired) + } +} + +// MARK: - 서버 정책 기반 판정 + +@Suite("AttendanceTimeWindow — 서버 출석 정책 기반 판정") +struct AttendanceTimeWindowPolicyTests { + + private static let policy = ScheduleAttendancePolicy( + checkInStartAt: fixedNow.addingTimeInterval(-600), + onTimeEndAt: fixedNow.addingTimeInterval(600), + lateEndAt: fixedNow.addingTimeInterval(1_800) + ) + + private func window(now: Date) -> AttendanceTimeWindow { + AttendanceTimeWindow( + policy: Self.policy, + // 정책이 있으면 무시되는 값들 — 폴백 경로와 다른 결과가 나오도록 일부러 어긋나게 둔다. + startsAt: fixedNow.addingTimeInterval(-86_400), + endsAt: fixedNow.addingTimeInterval(-86_000), + isAllDay: false, + now: now + ) + } + + @Test("체크인 시작 전 → tooEarly") + func beforeCheckInStart() { + #expect(window(now: fixedNow.addingTimeInterval(-601)) == .tooEarly) + } + + @Test("체크인 시작 ~ 정시 마감 → onTime (양 경계 포함)") + func withinOnTime() { + #expect(window(now: fixedNow.addingTimeInterval(-600)) == .onTime) + #expect(window(now: fixedNow.addingTimeInterval(600)) == .onTime) + } + + @Test("정시 마감 초과 ~ 지각 마감 → lateWindow (경계 포함)") + func withinLateWindow() { + #expect(window(now: fixedNow.addingTimeInterval(601)) == .lateWindow) + #expect(window(now: fixedNow.addingTimeInterval(1_800)) == .lateWindow) + } + + @Test("지각 마감 초과 → expired") + func afterLateEnd() { + #expect(window(now: fixedNow.addingTimeInterval(1_801)) == .expired) + } + + @Test("정책 nil → 클라이언트 상수 경로와 동일한 결과") + func nilPolicyFallsBackToConstants() { + let startsAt = fixedNow.addingTimeInterval(-(onTimeSec + 60)) + let endsAt = fixedNow.addingTimeInterval(3_600) + + let fallback = AttendanceTimeWindow( + policy: nil, + startsAt: startsAt, + endsAt: endsAt, + isAllDay: false, + now: fixedNow + ) + let constant = AttendanceTimeWindow( + startsAt: startsAt, + endsAt: endsAt, + isAllDay: false, + now: fixedNow + ) + + #expect(fallback == constant) + #expect(fallback == .lateWindow) + } +} + +// MARK: - 일정 기반 편의 진입점 + +@Suite("AttendanceTimeWindow — ScheduleDetailData 위임") +struct AttendanceTimeWindowScheduleDelegationTests { + + @Test("정책이 붙은 일정은 정책 분기를 따른다") + func scheduleUsesAttachedPolicy() { + let schedule = makeSchedule( + // 상수 경로였다면 expired 가 될 과거 일정. + startsAt: fixedNow.addingTimeInterval(-86_400), + endsAt: fixedNow.addingTimeInterval(-86_000), + attendancePolicy: ScheduleAttendancePolicy( + checkInStartAt: fixedNow.addingTimeInterval(-600), + onTimeEndAt: fixedNow.addingTimeInterval(600), + lateEndAt: fixedNow.addingTimeInterval(1_800) + ) + ) + #expect(AttendanceTimeWindow(schedule: schedule, now: fixedNow) == .onTime) + } + + @Test("정책 없는 일정은 상수 분기로 폴백한다") + func scheduleWithoutPolicyFallsBack() { + let schedule = makeSchedule( + startsAt: fixedNow.addingTimeInterval(onTimeSec + 60), + endsAt: fixedNow.addingTimeInterval(onTimeSec + 3_600) + ) + #expect(AttendanceTimeWindow(schedule: schedule, now: fixedNow) == .tooEarly) + } + + @Test("now 기본값 — 먼 미래 일정은 tooEarly") + func defaultNowUsesCurrentDate() { + let now = Date() + let schedule = makeSchedule( + startsAt: now.addingTimeInterval(3_600), + endsAt: now.addingTimeInterval(7_200) + ) + #expect(AttendanceTimeWindow(schedule: schedule) == .tooEarly) + } +} diff --git a/UMCApp/Features/Activity/Project.swift b/UMCApp/Features/Activity/Project.swift index 81a0ab0fa..b9f333256 100644 --- a/UMCApp/Features/Activity/Project.swift +++ b/UMCApp/Features/Activity/Project.swift @@ -12,22 +12,13 @@ let project = featureProject( // #981 에서 최종 확정했다: 전용 Schedule 모듈은 신설하지 않고 Home* 이 단일 소유자다. // (docs/claude/build-and-modules.md "경계 정책 — 일정(Schedule)") // - // HomeDomain 은 iOS 전용이라 watchOS 까지 확장된 ActivityDomain 의 destination 을 - // 그대로 두려면 의존을 iOS 로 한정해야 한다. 현재 UMCWatchApp 은 CoreWatchConnectivity - // 만 링크하므로 watch 빌드에 영향이 없다. watch 가 출석 도메인을 쓰게 되는 시점에 - // 일정 모델의 공용 위치(Core 승격 등)를 다시 판단한다. - .project( - target: "HomeDomain", - path: .relativeToRoot("Features/Home"), - condition: .when([.ios]) - ), + // #1212 에서 확정: 일정 모델은 옮기지 않는다. HomeDomain(+NoticeDomain)·CoreDomain 의 + // Domain 타겟을 [.iPhone, .appleWatch] 로 열어 watch 가 canonical 자산을 그대로 재사용한다. + // #981 의 "Home* 단일 소유자" 경계는 그대로 유지되며 지원 플랫폼만 넓어졌다. + // 세 Domain 타겟 모두 Foundation/UMCFoundation/SwiftData 만 사용해 watchOS 제약이 없다. + .project(target: "HomeDomain", path: .relativeToRoot("Features/Home")), // 챌린저 검색 결과(ChallengerSearchPage)가 Core canonical ChallengerInfo 를 담는다. - // CoreDomain 은 iOS 전용이므로 HomeDomain 과 같은 이유로 조건부 의존이다. - .project( - target: "CoreDomain", - path: .relativeToRoot("Core/Domain"), - condition: .when([.ios]) - ), + .project(target: "CoreDomain", path: .relativeToRoot("Core/Domain")), ], dataExtraDependencies: [ // 출석 응답 DTO 가 HomeDomain 의 일정 장소/출석 정책 모델로 매핑한다. @@ -51,18 +42,9 @@ let project = featureProject( includesDomainTests: true, domainTestDependencies: [ // UseCase 테스트가 ScheduleDetailData 픽스처를 직접 만든다. - // 테스트 타겟도 domainDestinations 를 물려받으므로 메인 타겟과 같은 iOS 한정 조건이 필요하다. - .project( - target: "HomeDomain", - path: .relativeToRoot("Features/Home"), - condition: .when([.ios]) - ), + .project(target: "HomeDomain", path: .relativeToRoot("Features/Home")), // UseCase 테스트가 ChallengerSearchPage 픽스처의 ChallengerInfo 를 직접 만든다. - .project( - target: "CoreDomain", - path: .relativeToRoot("Core/Domain"), - condition: .when([.ios]) - ), + .project(target: "CoreDomain", path: .relativeToRoot("Core/Domain")), .project(target: "UMCFoundation", path: .relativeToRoot("Core/Foundation")), ], includesDataTests: true, diff --git a/UMCApp/Features/Home/Project.swift b/UMCApp/Features/Home/Project.swift index 11f0eb4f8..14fa44d91 100644 --- a/UMCApp/Features/Home/Project.swift +++ b/UMCApp/Features/Home/Project.swift @@ -3,6 +3,11 @@ import ProjectDescriptionHelpers let project = featureProject( name: "Home", + // #1212: watch 출석 도메인이 ActivityDomain → HomeDomain 경로로 일정 모델 + // (ScheduleDetailData·ScheduleAttendancePolicy)을 그대로 재사용한다. 모델을 옮기지 않고 + // 지원 플랫폼만 넓혔다 — Domain 소스는 Foundation/UMCFoundation/NoticeDomain/SwiftData 만 쓴다. + domainDestinations: [.iPhone, .appleWatch], + domainDeploymentTargets: .multiplatform(iOS: "26.4", watchOS: "26.4"), domainExtraDependencies: [ // 최근 공지(#915)가 NoticeDomain의 조회 파이프라인(NoticeItemModel/NoticeListRequest 등)을 재사용한다. .project(target: "NoticeDomain", path: .relativeToRoot("Features/Notice")), diff --git a/UMCApp/Features/Notice/Project.swift b/UMCApp/Features/Notice/Project.swift index 51674c3d8..d4ea9f2f6 100644 --- a/UMCApp/Features/Notice/Project.swift +++ b/UMCApp/Features/Notice/Project.swift @@ -3,6 +3,11 @@ import ProjectDescriptionHelpers let project = featureProject( name: "Notice", + // #1212: HomeDomain 이 NoticeDomain 에 의존하므로, watch 가 쓰는 일정 모델 경로 + // (ActivityDomain → HomeDomain → NoticeDomain)를 잇기 위해 함께 연다. + // 소스는 Foundation/UMCFoundation/SwiftData 만 쓰므로 watchOS 제약이 없다. + domainDestinations: [.iPhone, .appleWatch], + domainDeploymentTargets: .multiplatform(iOS: "26.4", watchOS: "26.4"), presentationExtraDependencies: [ .project(target: "CoreDI", path: .relativeToRoot("Core/DI")), // `UserSessionManager`(#957 후속으로 CoreDomain에 승격)를 사용한다. diff --git a/UMCApp/UMCWatchApp/Project.swift b/UMCApp/UMCWatchApp/Project.swift index f4022f385..d2e48c533 100644 --- a/UMCApp/UMCWatchApp/Project.swift +++ b/UMCApp/UMCWatchApp/Project.swift @@ -8,6 +8,10 @@ let project = watchAppProject( dependencies: [ .project(target: "CoreWatchConnectivity", path: .relativeToRoot("Core/WatchConnectivity")), .project(target: "CoreWatchDesignSystem", path: .relativeToRoot("Core/WatchDesignSystem")), + // 워치 출석 화면이 시간대 판정(AttendanceTimeWindow)을 ActivityDomain 에서 가져온다. + .project(target: "ActivityDomain", path: .relativeToRoot("Features/Activity")), + // 같은 화면이 일정 모델(ScheduleDetailData)을 직접 다루므로 HomeDomain 도 명시 링크한다. + .project(target: "HomeDomain", path: .relativeToRoot("Features/Home")), ], includesTests: true ) diff --git a/UMCApp/UMCWatchApp/Sources/Attendance/WatchAttendanceListView.swift b/UMCApp/UMCWatchApp/Sources/Attendance/WatchAttendanceListView.swift new file mode 100644 index 000000000..28e5fec10 --- /dev/null +++ b/UMCApp/UMCWatchApp/Sources/Attendance/WatchAttendanceListView.swift @@ -0,0 +1,108 @@ +// +// WatchAttendanceListView.swift +// UMCWatchApp +// +// Created by euijjang97 on 8/30/26. +// + +import HomeDomain +import SwiftUI + +/// 워치 출석 대상 일정 목록 +/// +/// 디자인 토큰(`CoreDesignSystem`)은 iOS 전용이라 링크할 수 없어 SwiftUI 기본 스타일만 쓴다. +/// 워치 전용 토큰은 #1205 에서 정리한다. +struct WatchAttendanceListView: View { + + // MARK: - Property + + @State private var viewModel: WatchAttendanceViewModel + + // MARK: - Init + + /// - Parameter viewModel: 프리뷰/테스트용 주입 지점 (기본값: 빈 목록으로 시작) + init(viewModel: WatchAttendanceViewModel? = nil) { + _viewModel = State(initialValue: viewModel ?? WatchAttendanceViewModel()) + } + + // MARK: - Body + + var body: some View { + Group { + if viewModel.schedules.isEmpty { + ContentUnavailableView( + "iPhone 연결 대기", + systemImage: "iphone.gen3.radiowaves.left.and.right", + description: Text("iPhone 에서 출석 일정을 받아오는 중입니다.") + ) + } else { + List(viewModel.schedules) { schedule in + row(for: schedule) + } + } + } + .navigationTitle("출석") + } + + // MARK: - Function + + private func row(for schedule: ScheduleDetailData) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(schedule.name) + .font(.headline) + .lineLimit(2) + + Text(schedule.startsAt, format: .dateTime.month().day().hour().minute()) + .font(.caption2) + .foregroundStyle(.secondary) + + Text(viewModel.statusText(for: schedule)) + .font(.caption) + .foregroundStyle(.tint) + } + .padding(.vertical, 2) + } +} + +// MARK: - Preview + +#if DEBUG +#Preview { + let now = Date() + let viewModel = WatchAttendanceViewModel() + viewModel.apply(schedules: [ + ScheduleDetailData( + scheduleId: "1", + name: "1주차 정기 세션", + description: "", + tags: [], + startsAt: now, + endsAt: now.addingTimeInterval(7_200), + isParticipant: true, + attendancePolicy: ScheduleAttendancePolicy( + checkInStartAt: now.addingTimeInterval(-600), + onTimeEndAt: now.addingTimeInterval(600), + lateEndAt: now.addingTimeInterval(1_800) + ) + ), + ScheduleDetailData( + scheduleId: "2", + name: "지난주 스터디", + description: "", + tags: [], + startsAt: now.addingTimeInterval(-86_400), + endsAt: now.addingTimeInterval(-79_200), + isParticipant: true, + attendancePolicy: ScheduleAttendancePolicy( + checkInStartAt: now.addingTimeInterval(-87_000), + onTimeEndAt: now.addingTimeInterval(-85_800), + lateEndAt: now.addingTimeInterval(-84_600) + ) + ), + ]) + + return NavigationStack { + WatchAttendanceListView(viewModel: viewModel) + } +} +#endif diff --git a/UMCApp/UMCWatchApp/Sources/Attendance/WatchAttendanceViewModel.swift b/UMCApp/UMCWatchApp/Sources/Attendance/WatchAttendanceViewModel.swift new file mode 100644 index 000000000..392074d5b --- /dev/null +++ b/UMCApp/UMCWatchApp/Sources/Attendance/WatchAttendanceViewModel.swift @@ -0,0 +1,51 @@ +// +// WatchAttendanceViewModel.swift +// UMCWatchApp +// +// Created by euijjang97 on 8/30/26. +// + +import ActivityDomain +import Foundation +import HomeDomain + +/// 워치 출석 목록의 상태를 관리하는 ViewModel +/// +/// 시간대 판정은 직접 계산하지 않고 `ActivityDomain` 의 `AttendanceTimeWindow` 규칙에 위임한다. +/// 일정 데이터 주입 경로(WatchConnectivity)는 #1210·#1207 에서 `apply(schedules:)` 로 연결된다. +@MainActor +@Observable +final class WatchAttendanceViewModel { + + // MARK: - Property + + private(set) var schedules: [ScheduleDetailData] = [] + + // MARK: - Function + + /// 출석 필수이면서 본인이 참여자인 일정만 시작 시각 오름차순으로 보관한다. + /// + /// iPhone 의 `fetchAvailableSchedules` 와 달리 마감(`.expired`) 일정도 남긴다 — + /// 워치 목록은 마감 여부를 상태 라벨로 보여준다. + func apply(schedules: [ScheduleDetailData]) { + self.schedules = schedules + .filter { $0.requiresAttendanceApproval && $0.isParticipant } + .sorted { $0.startsAt < $1.startsAt } + } + + func timeWindow( + for schedule: ScheduleDetailData, + now: Date = Date() + ) -> AttendanceTimeWindow { + AttendanceTimeWindow(schedule: schedule, now: now) + } + + func statusText(for schedule: ScheduleDetailData, now: Date = Date()) -> String { + switch timeWindow(for: schedule, now: now) { + case .tooEarly: "출석 전" + case .onTime: "출석 가능" + case .lateWindow: "지각 사유" + case .expired: "마감" + } + } +} diff --git a/docs/claude/build-and-modules.md b/docs/claude/build-and-modules.md index f64d2e43b..2e4d72d20 100644 --- a/docs/claude/build-and-modules.md +++ b/docs/claude/build-and-modules.md @@ -115,10 +115,12 @@ External Packages (Moya 15.0.3 / Kingfisher 8.6.1) > > 렌더링 이력: 초기 구상이던 RealityKit(3D)은 한 차례 폐기됐다(#1196에서 ARKit·RealityKit 링크 해제). 이후로는 2D SwiftUI가 유일한 렌더링 경로였다. 그런데 #1245 Phase 0 스파이크가 3D 명함을 "조건부 Go"로 되살렸다 — 온디바이스 합성·한글 텍스트 메시·2D 스냅샷은 전부 기준을 넘겼고, 유일한 미해결 항목인 첫 진입 지연(시뮬레이터 실측 9.42~11.62s)은 #1249 착수 전 실기기 재측정을 조건으로 건다(`docs/claude/business-card-3d-spike.md`). #1246이 베이스 USDZ 템플릿과 앵커 바인딩 규약을 정했고(`docs/claude/business-card-3d-anchor-contract.md`), #1247(회전)·#1248(온디바이스 합성)이 뒤따른다. 스파이크 하네스는 `#if DEBUG` 가드 아래에 있고(`Presentation/Sources/Spike/BusinessCard3DSpike.swift`), 템플릿 규약과 USDZ 에셋은 프로덕션 코드지만(`Presentation/Sources/Card3D/BusinessCardTemplate.swift` · `Presentation/Resources/BusinessCardTemplate.usdz`) 아직 어떤 화면에도 연결되지 않는다 — #1247·#1248 이 붙인다. **따라서 "UMCApp에 RealityKit 참조가 없다"는 더 이상 사실이 아니다** — `import RealityKit`이 `BusinessCardPresentation`에 이미 있다. -> **경계 정책 — 일정(Schedule) (#981 확정)**: 전용 Schedule Feature 모듈은 **신설하지 않는다.** +> **경계 정책 — 일정(Schedule) (#981 확정 · #1212 갱신)**: 전용 Schedule Feature 모듈은 **신설하지 않는다.** > 일정 도메인의 단일 소유자는 `HomeDomain`(모델·Repository/UseCase Protocol) + `HomeData`(`ScheduleV2Router`·`ScheduleRepository`·일정 DTO) + `HomePresentation`(일정 화면)이다. > Activity 등 다른 Feature 는 `ScheduleDetailData`·`ScheduleLocation`·`ScheduleAttendancePolicy`·`ScheduleRepositoryProtocol` 을 **HomeDomain 에서 재사용**하며 자체 일정 모델을 다시 만들지 않는다. > 단, 엔드포인트별 wire DTO 는 각 Feature Data 에 두는 것이 원칙이다 — 출석 응답의 `ScheduleLocationDTO`/`ScheduleAttendancePolicyDTO`(ActivityData)와 V2 일정 응답의 동명 DTO(HomeData)는 서로 다른 엔드포인트 계약이므로 의도적으로 분리돼 있고, 둘 다 같은 HomeDomain 모델로 매핑한다. (ActivityData → HomeData 링크는 HomeData 의 CoreML 리소스 번들까지 끌고 오므로 통합하지 않는다.) +> +> **플랫폼 축 (#1212 확정)**: Watch 앱이 ActivityDomain 출석 UseCase 를 쓰기 위해 일정 모델을 옮기지 않는다 — 소유자는 그대로 두고 `HomeDomain`(+의존 사슬의 `NoticeDomain`)과 `CoreDomain` 의 **Domain 타겟만** iOS+watchOS 멀티플랫폼으로 개방한다. 세 타겟의 소스 import 는 `Foundation`·`UMCFoundation`·`NoticeDomain`·`SwiftData` 뿐이라 watchOS 제약이 없고, 모델을 옮기지 않으므로 iOS 측 `import HomeDomain` 은 전부 무변경이다. 워치가 재사용하는 타입은 `ScheduleDetailData`·`ScheduleLocation`·`ScheduleAttendancePolicy`·`ScheduleAttendanceStatus`·`ScheduleRepositoryProtocol`(HomeDomain)과 `ChallengerInfo`(CoreDomain). `Data`/`Presentation` 타겟은 iOS 전용 그대로다 — `ActivityData` 는 Moya/CoreNetwork 의존이라 워치가 링크할 수 없고, 워치의 데이터 수급은 WatchConnectivity 경로(#1210)로 해결한다. (대안이던 "일정 모델 CoreDomain 승격"은 단일 소유자 경계를 깨면서 기존 import 도 대량으로 깨뜨리므로 기각.) ### Feature 모듈 구조 @@ -130,6 +132,21 @@ External Packages (Moya 15.0.3 / Kingfisher 8.6.1) | `{Name}Data` | `.staticFramework` | `dev.umc.feature.{name}.data` | `Data/Sources/**` | | `{Name}Presentation` | `.staticFramework` | `dev.umc.feature.{name}.presentation` | `Presentation/Sources/**` | +### 플랫폼(destination) 정책 + +기본값은 iOS 전용이다. watchOS 재사용이 필요한 타겟만 `[.iPhone, .appleWatch]` / `.multiplatform(iOS: "26.4", watchOS: "26.4")` 로 개방한다 — Core 모듈은 `coreProject` 의 `destinations`/`deploymentTargets` 인자, Feature Domain 은 `featureProject` 의 `domainDestinations`/`domainDeploymentTargets` 인자로 지정한다. + +| watchOS 개방 타겟 | 매니페스트 | +|-------------------|-----------| +| `UMCFoundation` | `Core/Foundation/Project.swift` | +| `CoreWatchConnectivity` | `Core/WatchConnectivity/Project.swift` | +| `CoreDomain` | `Core/Domain/Project.swift` | +| `NoticeDomain` | `Features/Notice/Project.swift` | +| `HomeDomain` | `Features/Home/Project.swift` | +| `ActivityDomain` | `Features/Activity/Project.swift` | + +이 목록은 #1212 에서 확정됐다. 그 전에는 `ActivityDomain` 만 destination 이 watchOS 로 열려 있고 `HomeDomain`·`CoreDomain` 의존은 `condition: .when([.ios])` 로 iOS 한정이라, watchOS 로 빌드하면 `unable to resolve module dependency: 'HomeDomain'` 으로 실패했다(어떤 watch 타겟도 링크하지 않아 드러나지 않았을 뿐). #1212 에서 의존 사슬 전체를 개방하며 조건부 의존을 제거했고, `UMCWatchApp` 이 `ActivityDomain`·`HomeDomain` 을 링크한다. + ### ProjectDescriptionHelpers 보일러플레이트 제거를 위해 두 개의 헬퍼 함수를 사용합니다. @@ -168,7 +185,7 @@ featureProject( ### 주요 설정 - **Tuist 버전**: `UMCApp/mise.toml` 고정 (`4.155.0`) -- **Deployment Target**: iOS 26.4 (`Project.swift` 기준, 전체 타겟 공통) +- **Deployment Target**: iOS 26.4 (`Project.swift` 기준). watchOS 개방 타겟은 watchOS 26.4 병기 ("플랫폼(destination) 정책" 참고) - **Product Type**: 모든 모듈 `.staticFramework` - **Bundle ID**: Core → `dev.umc.core.*` / Feature → `dev.umc.feature.*.*` - **Workspace**: glob(`Core/*`, `Features/*`) + `UMCAppWidget`, `UMCWatchApp` 명시 포함