From 40d58e3ee30ac0e99e94f631d7d67762b3e60441 Mon Sep 17 00:00:00 2001 From: JEONG Date: Sun, 30 Aug 2026 09:05:56 +0900 Subject: [PATCH 1/3] =?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 9d937a1947c440cc104e830fe54c69fb9b3f55ce Mon Sep 17 00:00:00 2001 From: JEONG Date: Sun, 30 Aug 2026 14:04:05 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EC=9B=8C=EC=B9=98=20=ED=99=88=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20=EA=B8=80=EB=9E=9C=EC=8A=A4=C2=B7=EB=84=A4?= =?UTF-8?q?=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98=20IA=20=EA=B3=A8=EA=B2=A9?= =?UTF-8?q?=20=EA=B5=AC=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WatchRoute/WatchFallbackRoute — 출석·The Ping 두 축 진입점과 폴백 3종(권한 거부·연결 끊김·큐잉)을 경로로 확보 - WatchRouter — 앱 셸이 소유하는 @Observable 단일 스택. NavigationPath 대신 [WatchRoute] 구체 배열로 두어 스택 내용을 테스트로 검증 - WatchRootView — navigationDestination 단일 등록. 목적지는 후속 이슈가 교체할 플레이스홀더 - HomeGlanceView/HomeGlanceViewModel — solid 카드 글랜스, 세션·공지 행을 tap-chip 으로 승격, 세션 없을 때 "오늘 세션 없음" 빈 상태 - 승인 대기는 WatchStatus.pending(중립+인디고 링). 앰버 #FFB340 은 개정 스펙상 지각 전용이라 쓰지 않음 - 카운트·식별자는 전 레이어 String, Int 변환은 비교 시점에만 (규칙 #2). 숫자 아닌 값은 0건 처리 - watchAppProject 헬퍼에 includesTests 추가 + WKApplication 키 보강 — 없으면 테스트 호스트 설치가 거부됨 - ContentView("Hello Watch") 제거 --- .../Project+WatchApp.swift | 70 ++++++--- UMCApp/UMCWatchApp/Project.swift | 3 +- UMCApp/UMCWatchApp/Sources/ContentView.swift | 17 --- .../Sources/Home/HomeGlanceView.swift | 142 ++++++++++++++++++ .../Sources/Home/HomeGlanceViewModel.swift | 49 ++++++ .../Sources/Home/WatchGlance.swift | 65 ++++++++ .../Sources/Routing/WatchRootView.swift | 24 +++ .../Sources/Routing/WatchRoute.swift | 58 +++++++ .../Routing/WatchRoutePlaceholderView.swift | 38 +++++ .../Sources/Routing/WatchRouter.swift | 34 +++++ UMCApp/UMCWatchApp/Sources/UMCWatchApp.swift | 17 ++- .../Tests/HomeGlanceViewModelTests.swift | 85 +++++++++++ .../UMCWatchApp/Tests/WatchRouterTests.swift | 74 +++++++++ 13 files changed, 626 insertions(+), 50 deletions(-) delete mode 100644 UMCApp/UMCWatchApp/Sources/ContentView.swift create mode 100644 UMCApp/UMCWatchApp/Sources/Home/HomeGlanceView.swift create mode 100644 UMCApp/UMCWatchApp/Sources/Home/HomeGlanceViewModel.swift create mode 100644 UMCApp/UMCWatchApp/Sources/Home/WatchGlance.swift create mode 100644 UMCApp/UMCWatchApp/Sources/Routing/WatchRootView.swift create mode 100644 UMCApp/UMCWatchApp/Sources/Routing/WatchRoute.swift create mode 100644 UMCApp/UMCWatchApp/Sources/Routing/WatchRoutePlaceholderView.swift create mode 100644 UMCApp/UMCWatchApp/Sources/Routing/WatchRouter.swift create mode 100644 UMCApp/UMCWatchApp/Tests/HomeGlanceViewModelTests.swift create mode 100644 UMCApp/UMCWatchApp/Tests/WatchRouterTests.swift diff --git a/UMCApp/Tuist/ProjectDescriptionHelpers/Project+WatchApp.swift b/UMCApp/Tuist/ProjectDescriptionHelpers/Project+WatchApp.swift index f98ea91f6..6d0269777 100644 --- a/UMCApp/Tuist/ProjectDescriptionHelpers/Project+WatchApp.swift +++ b/UMCApp/Tuist/ProjectDescriptionHelpers/Project+WatchApp.swift @@ -6,6 +6,7 @@ private let companionBundleId = "com.umc.product" /// Watch 앱 타겟용 Project 생성 헬퍼 /// /// watchOS App 타겟 1개를 구성합니다. +/// `includesTests: true`인 경우 `{name}Tests` unitTests 타겟이 함께 생성됩니다. /// WKCompanionAppBundleIdentifier는 호스트 iOS 앱 번들 ID로 자동 설정됩니다. /// /// - Parameters: @@ -13,38 +14,59 @@ private let companionBundleId = "com.umc.product" /// - bundleId: Watch 앱 번들 ID /// - entitlements: entitlements 파일 경로 (기본값 nil) /// - dependencies: 의존성 목록 +/// - includesTests: `true`이면 `Tests/**` 소스를 사용하는 unitTests 타겟을 함께 생성 public func watchAppProject( name: String, bundleId: String, entitlements: Entitlements? = nil, - dependencies: [TargetDependency] = [] + dependencies: [TargetDependency] = [], + includesTests: Bool = false ) -> Project { - Project( - name: name, - settings: recommendedProjectSettings, - targets: [ + var targets: [Target] = [ + .target( + name: name, + destinations: [.appleWatch], + product: .app, + bundleId: bundleId, + deploymentTargets: .watchOS("26.4"), + infoPlist: .extendingDefault( + with: [ + "CFBundleShortVersionString": .string("$(MARKETING_VERSION)"), + // watchOS 앱의 CFBundleVersion도 호스트 앱과 일치해야 업로드가 통과한다. + "CFBundleVersion": .string("$(CURRENT_PROJECT_VERSION)"), + "WKCompanionAppBundleIdentifier": .string(companionBundleId), + // 이 키가 없으면 빌드는 통과해도 시뮬레이터·기기 설치가 거부된다 + // ("WatchKit app ... missing either the WKWatchKitApp or WKApplication"). + "WKApplication": .boolean(true), + "UISupportedInterfaceOrientations": .array([ + .string("UIInterfaceOrientationPortrait"), + ]), + ] + ), + sources: ["Sources/**"], + resources: ["Resources/**"], + entitlements: entitlements, + dependencies: dependencies + ) + ] + + if includesTests { + targets.append( .target( - name: name, + name: "\(name)Tests", destinations: [.appleWatch], - product: .app, - bundleId: bundleId, + product: .unitTests, + bundleId: "\(bundleId).tests", deploymentTargets: .watchOS("26.4"), - infoPlist: .extendingDefault( - with: [ - "CFBundleShortVersionString": .string("$(MARKETING_VERSION)"), - // watchOS 앱의 CFBundleVersion도 호스트 앱과 일치해야 업로드가 통과한다. - "CFBundleVersion": .string("$(CURRENT_PROJECT_VERSION)"), - "WKCompanionAppBundleIdentifier": .string(companionBundleId), - "UISupportedInterfaceOrientations": .array([ - .string("UIInterfaceOrientationPortrait"), - ]), - ] - ), - sources: ["Sources/**"], - resources: ["Resources/**"], - entitlements: entitlements, - dependencies: dependencies + sources: ["Tests/**"], + dependencies: [.target(name: name)] ) - ] + ) + } + + return Project( + name: name, + settings: recommendedProjectSettings, + targets: targets ) } diff --git a/UMCApp/UMCWatchApp/Project.swift b/UMCApp/UMCWatchApp/Project.swift index 5d0d08fc1..91efccb1e 100644 --- a/UMCApp/UMCWatchApp/Project.swift +++ b/UMCApp/UMCWatchApp/Project.swift @@ -7,5 +7,6 @@ let project = watchAppProject( dependencies: [ .project(target: "CoreWatchConnectivity", path: .relativeToRoot("Core/WatchConnectivity")), .project(target: "CoreWatchDesignSystem", path: .relativeToRoot("Core/WatchDesignSystem")), - ] + ], + includesTests: true ) diff --git a/UMCApp/UMCWatchApp/Sources/ContentView.swift b/UMCApp/UMCWatchApp/Sources/ContentView.swift deleted file mode 100644 index 8deba7e09..000000000 --- a/UMCApp/UMCWatchApp/Sources/ContentView.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// ContentView.swift -// UMCWatchApp -// -// Created by euijjang97 on 4/24/26. -// - -import SwiftUI - -struct ContentView: View { - - // MARK: - Body - - var body: some View { - Text("Hello Watch") - } -} diff --git a/UMCApp/UMCWatchApp/Sources/Home/HomeGlanceView.swift b/UMCApp/UMCWatchApp/Sources/Home/HomeGlanceView.swift new file mode 100644 index 000000000..c44e0e5e3 --- /dev/null +++ b/UMCApp/UMCWatchApp/Sources/Home/HomeGlanceView.swift @@ -0,0 +1,142 @@ +import SwiftUI +import CoreWatchDesignSystem + +// MARK: - HomeGlanceView + +/// 홈 통합 글랜스. 오늘 세션·The Ping·승인 대기를 한 화면에서 훑고 각 플로우로 진입한다. +struct HomeGlanceView: View { + + // MARK: - Property + + @Environment(WatchRouter.self) private var router + @State private var viewModel: HomeGlanceViewModel + + // MARK: - Init + + init(glance: WatchGlance = .empty) { + _viewModel = State(initialValue: HomeGlanceViewModel(glance: glance)) + } + + // MARK: - Body + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: WatchLayout.stackSpacing) { + attendanceChip + pingChip + pendingApprovalBadge + } + .padding(.horizontal, WatchLayout.screenHorizontalPadding) + } + .navigationTitle("홈") + .watchScreenBackground() + } + + // MARK: - Function + + /// 세션 카드 전체가 출석 플로우 진입점이다. `.plain` 이어야 카드 표면이 solid 로 남는다 — + /// Glass 버튼 스타일을 쓰면 카드가 Glass 금지 구역을 침범한다. + private var attendanceChip: some View { + Button { + router.push(viewModel.attendanceRoute) + } label: { + sessionCard + } + .buttonStyle(.plain) + .accessibilityElement(children: .combine) + } + + @ViewBuilder + private var sessionCard: some View { + if let session = viewModel.glance.session { + VStack(alignment: .leading, spacing: WatchLayout.tightSpacing) { + cardLabel("오늘 세션") + Text(session.startsAt, format: .dateTime.hour().minute()) + .font(.watch(.metric)) + .foregroundStyle(WatchColor.textPrimary) + Text(session.title) + .font(.watch(.cardValue)) + .foregroundStyle(WatchColor.textPrimary) + WatchStatusBadge(session.status) + } + .watchCard(.hero) + } else { + VStack(alignment: .leading, spacing: WatchLayout.tightSpacing) { + cardLabel("오늘 세션") + Text(viewModel.emptySessionMessage) + .font(.watch(.cardValue)) + .foregroundStyle(WatchColor.textSecondary) + } + .watchCard() + } + } + + private var pingChip: some View { + Button { + router.push(.pingList) + } label: { + HStack(spacing: WatchLayout.tightSpacing) { + VStack(alignment: .leading, spacing: WatchLayout.tightSpacing) { + Text("The Ping") + .font(.watch(.cardLabel)) + .foregroundStyle(pingAccentColor) + Text(viewModel.unreadPingLabel) + .font(.watch(.cardValue)) + .foregroundStyle(WatchColor.textPrimary) + } + Spacer(minLength: 0) + Image(systemName: "chevron.forward") + .font(.watch(.cardLabel)) + .foregroundStyle(WatchColor.textSecondary) + .accessibilityHidden(true) + } + .watchCard() + } + .buttonStyle(.plain) + .accessibilityElement(children: .combine) + } + + @ViewBuilder + private var pendingApprovalBadge: some View { + if let pendingApprovalLabel = viewModel.pendingApprovalLabel { + // 승인 대기는 앰버(#FFB340)가 아니라 중립 회색 점 + 인디고 링(`.pending`)이다. + // 앰버는 2026-08-26 개정 이후 지각(`.warning`) 전용이라 여기서 쓰지 않는다. + WatchStatusBadge(.pending, label: pendingApprovalLabel) + } + } + + /// The Ping 강조는 브랜드 액센트다 — 상태 색이 아니라 미확인 여부만 신호한다. + private var pingAccentColor: Color { + viewModel.hasUnreadPing ? WatchColor.brandAccent : WatchColor.textSecondary + } + + private func cardLabel(_ text: String) -> some View { + Text(text) + .font(.watch(.cardLabel)) + .foregroundStyle(WatchColor.textSecondary) + } +} + +#if DEBUG +#Preview("HomeGlanceView — 세션 있음") { + NavigationStack { + HomeGlanceView(glance: .sample) + } + .environment(WatchRouter()) +} + +#Preview("HomeGlanceView — 세션 없음") { + NavigationStack { + HomeGlanceView(glance: .noSession) + } + .environment(WatchRouter()) +} + +#Preview("HomeGlanceView — A11y 크기") { + NavigationStack { + HomeGlanceView(glance: .pending) + } + .environment(WatchRouter()) + .dynamicTypeSize(.accessibility3) +} +#endif diff --git a/UMCApp/UMCWatchApp/Sources/Home/HomeGlanceViewModel.swift b/UMCApp/UMCWatchApp/Sources/Home/HomeGlanceViewModel.swift new file mode 100644 index 000000000..b5358abd6 --- /dev/null +++ b/UMCApp/UMCWatchApp/Sources/Home/HomeGlanceViewModel.swift @@ -0,0 +1,49 @@ +import Observation + +// MARK: - HomeGlanceViewModel + +/// 홈 통합 글랜스의 표시 로직. 카운트 문구·출석 진입 경로를 여기서 결정한다. +@Observable +final class HomeGlanceViewModel { + + // MARK: - Property + + private(set) var glance: WatchGlance + + var hasSession: Bool { glance.session != nil } + + /// 출석 tap-chip 목적지. 오늘 세션이 있으면 그 세션으로, 없으면 목록으로 보낸다. + var attendanceRoute: WatchRoute { + guard let session = glance.session else { return .attendanceList } + return .attendanceSession(scheduleID: session.id) + } + + var pendingApprovalLabel: String? { + let count = Self.count(glance.pendingApprovalCount) + return count > 0 ? "승인 대기 \(count)건" : nil + } + + var hasUnreadPing: Bool { Self.count(glance.unreadPingCount) > 0 } + + var unreadPingLabel: String { + let count = Self.count(glance.unreadPingCount) + return count > 0 ? "미확인 \(count)건" : "새 공지 없음" + } + + var emptySessionMessage: String { "오늘 세션 없음" } + + // MARK: - Init + + // 데이터 로딩 경로는 #1210 이 붙인다 — 지금은 주입된 값을 그대로 표시한다. + init(glance: WatchGlance = .empty) { + self.glance = glance + } + + // MARK: - Function + + /// 카운트는 전 레이어 String 이고(절대 규칙 #2), Int 변환은 이 비교 시점에서만 한다. + /// 숫자가 아니거나 음수인 값은 0건으로 취급한다 — 글랜스가 빈 문자열 하나로 깨지면 안 된다. + private static func count(_ rawValue: String) -> Int { + max(Int(rawValue) ?? 0, 0) + } +} diff --git a/UMCApp/UMCWatchApp/Sources/Home/WatchGlance.swift b/UMCApp/UMCWatchApp/Sources/Home/WatchGlance.swift new file mode 100644 index 000000000..deda1837b --- /dev/null +++ b/UMCApp/UMCWatchApp/Sources/Home/WatchGlance.swift @@ -0,0 +1,65 @@ +import Foundation +import CoreWatchDesignSystem + +// MARK: - WatchGlance + +/// 홈 통합 글랜스 표시 모델. 데이터 공급은 #1210(WatchConnectivity 페이로드 계약)이 붙인다. +struct WatchGlance: Equatable, Sendable { + + /// `nil` 이면 오늘 세션이 없다. + let session: GlanceSession? + /// 승인 대기 건수. 서버가 정수를 String 으로 내려주므로 그대로 String 이다 (절대 규칙 #2). + let pendingApprovalCount: String + /// 미확인 The Ping 건수. + let unreadPingCount: String + + static let empty = WatchGlance( + session: nil, + pendingApprovalCount: "0", + unreadPingCount: "0" + ) +} + +// MARK: - GlanceSession + +struct GlanceSession: Equatable, Sendable, Identifiable { + + /// 스케줄 식별자 (절대 규칙 #2 — 서버 정수는 전 레이어 String). + let id: String + let title: String + let startsAt: Date + let status: WatchStatus +} + +#if DEBUG +extension WatchGlance { + + static let sample = WatchGlance( + session: .sample, + pendingApprovalCount: "3", + unreadPingCount: "2" + ) + + static let noSession = WatchGlance( + session: nil, + pendingApprovalCount: "0", + unreadPingCount: "1" + ) + + static let pending = WatchGlance( + session: .sample, + pendingApprovalCount: "5", + unreadPingCount: "0" + ) +} + +extension GlanceSession { + + static let sample = GlanceSession( + id: "1024", + title: "5주차 정기 세션", + startsAt: .now.addingTimeInterval(30 * 60), + status: .active + ) +} +#endif diff --git a/UMCApp/UMCWatchApp/Sources/Routing/WatchRootView.swift b/UMCApp/UMCWatchApp/Sources/Routing/WatchRootView.swift new file mode 100644 index 000000000..85438b690 --- /dev/null +++ b/UMCApp/UMCWatchApp/Sources/Routing/WatchRootView.swift @@ -0,0 +1,24 @@ +import SwiftUI + +// MARK: - WatchRootView + +/// 워치 앱의 단일 `NavigationStack` 셸. 모든 목적지를 여기 한 곳에서 등록한다. +struct WatchRootView: View { + + // MARK: - Property + + @Environment(WatchRouter.self) private var router + + // MARK: - Body + + var body: some View { + @Bindable var router = router + + NavigationStack(path: $router.path) { + HomeGlanceView() + .navigationDestination(for: WatchRoute.self) { route in + WatchRoutePlaceholderView(route: route) + } + } + } +} diff --git a/UMCApp/UMCWatchApp/Sources/Routing/WatchRoute.swift b/UMCApp/UMCWatchApp/Sources/Routing/WatchRoute.swift new file mode 100644 index 000000000..281ef1d8b --- /dev/null +++ b/UMCApp/UMCWatchApp/Sources/Routing/WatchRoute.swift @@ -0,0 +1,58 @@ +import Foundation + +// MARK: - WatchRoute + +/// 워치 네비게이션 스택의 목적지. +/// +/// 각 케이스는 **플로우 진입점 + 식별자**만 담는다. 정시/지각/지오펜스 이탈, 읽음 여부 같은 +/// 화면 내부 상태는 목적지 화면이 데이터에서 파생하므로 케이스로 쪼개지 않는다. +/// 그래서 후속 이슈(#1207·#1208·#1209)는 케이스를 추가하지 않고 `WatchRootView` 의 +/// destination 만 실제 화면으로 바꿔 끼우면 된다. +public enum WatchRoute: Hashable, Sendable { + /// #1207 출석 목록. + case attendanceList + /// #1207 출석 진행(정시·지각·지오펜스 이탈). + /// 서버가 정수 식별자를 String 으로 내려주므로 전 레이어 String 이다 (절대 규칙 #2). + case attendanceSession(scheduleID: String) + /// #1207 출석 결과(출석·지각·공결·결석). + case attendanceResult(scheduleID: String) + /// #1208 The Ping 목록. + case pingList + /// #1208 The Ping 읽기·수신 확인. + case pingDetail(noticeID: String) + /// #1209 폴백 화면. + case fallback(WatchFallbackRoute) + + /// 화면 제목. 플레이스홀더 화면과 접근성 낭독에 함께 쓴다. + public var title: String { + switch self { + case .attendanceList: "출석" + case .attendanceSession: "출석 체크" + case .attendanceResult: "출석 결과" + case .pingList: "공지" + case .pingDetail: "공지 상세" + case .fallback(let route): route.title + } + } +} + +// MARK: - WatchFallbackRoute + +/// #1209 폴백 경로. 워치 단독으로는 진행할 수 없는 상태를 사용자에게 설명하는 화면들이다. +public enum WatchFallbackRoute: Hashable, Sendable, CaseIterable { + /// P0-1 위치 권한 거부. + case locationPermissionDenied + /// P0-3 iPhone 연결 끊김. + case phoneDisconnected + /// P0-7 오프라인 큐잉. + case offlineQueue + + /// 화면 제목. + public var title: String { + switch self { + case .locationPermissionDenied: "위치 권한 필요" + case .phoneDisconnected: "iPhone 연결 끊김" + case .offlineQueue: "전송 대기 중" + } + } +} diff --git a/UMCApp/UMCWatchApp/Sources/Routing/WatchRoutePlaceholderView.swift b/UMCApp/UMCWatchApp/Sources/Routing/WatchRoutePlaceholderView.swift new file mode 100644 index 000000000..d4391f8b9 --- /dev/null +++ b/UMCApp/UMCWatchApp/Sources/Routing/WatchRoutePlaceholderView.swift @@ -0,0 +1,38 @@ +// 경로가 실제로 도달 가능한지만 증명하는 최소 화면이다. +// #1207(출석)·#1208(The Ping)·#1209(폴백) 에서 실제 화면으로 교체된다. + +import SwiftUI +import CoreWatchDesignSystem + +// MARK: - WatchRoutePlaceholderView + +struct WatchRoutePlaceholderView: View { + + // MARK: - Property + + let route: WatchRoute + + // MARK: - Body + + var body: some View { + VStack(spacing: WatchLayout.tightSpacing) { + Text(route.title) + .font(.watch(.screenTitle)) + .foregroundStyle(WatchColor.textPrimary) + Text("준비 중") + .font(.watch(.caption)) + .foregroundStyle(WatchColor.textSecondary) + } + .multilineTextAlignment(.center) + .padding(.horizontal, WatchLayout.screenHorizontalPadding) + .watchScreenBackground() + } +} + +#if DEBUG +#Preview("WatchRoutePlaceholderView — 출석 세션") { + NavigationStack { + WatchRoutePlaceholderView(route: .attendanceSession(scheduleID: "1024")) + } +} +#endif diff --git a/UMCApp/UMCWatchApp/Sources/Routing/WatchRouter.swift b/UMCApp/UMCWatchApp/Sources/Routing/WatchRouter.swift new file mode 100644 index 000000000..035e54f50 --- /dev/null +++ b/UMCApp/UMCWatchApp/Sources/Routing/WatchRouter.swift @@ -0,0 +1,34 @@ +import Observation + +// MARK: - WatchRouter + +/// 워치 앱의 단일 네비게이션 스택 소유자. +/// +/// 앱 셸(`UMCWatchApp`)이 소유하고 `.environment` 로 주입하는 **앱 생명주기 전역 관리자**라 +/// 절대 규칙 #1 의 예외에 해당한다 — 그래서 `@Observable` 을 쓴다. +/// +/// 경로를 타입 소거된 `NavigationPath` 가 아니라 `[WatchRoute]` 로 두는 이유: 워치는 단일 +/// 타겟·단일 스택이라 iOS `PathStore` 의 역방향 의존 회피 논거가 없고, 구체 배열이어야 +/// 스택 내용을 테스트로 검증할 수 있다. +@Observable +final class WatchRouter { + + // MARK: - Property + + var path: [WatchRoute] = [] + + // MARK: - Function + + func push(_ route: WatchRoute) { + path.append(route) + } + + func pop() { + guard !path.isEmpty else { return } + path.removeLast() + } + + func popToRoot() { + path.removeAll() + } +} diff --git a/UMCApp/UMCWatchApp/Sources/UMCWatchApp.swift b/UMCApp/UMCWatchApp/Sources/UMCWatchApp.swift index 76fd2a4fc..08087220c 100644 --- a/UMCApp/UMCWatchApp/Sources/UMCWatchApp.swift +++ b/UMCApp/UMCWatchApp/Sources/UMCWatchApp.swift @@ -1,17 +1,18 @@ -// -// UMCWatchApp.swift -// UMCWatchApp -// -// Created by euijjang97 on 4/24/26. -// - import SwiftUI @main struct UMCWatchApp: App { + + // MARK: - Property + + @State private var router = WatchRouter() + + // MARK: - Body + var body: some Scene { WindowGroup { - ContentView() + WatchRootView() + .environment(router) } } } diff --git a/UMCApp/UMCWatchApp/Tests/HomeGlanceViewModelTests.swift b/UMCApp/UMCWatchApp/Tests/HomeGlanceViewModelTests.swift new file mode 100644 index 000000000..4bd1ea9d6 --- /dev/null +++ b/UMCApp/UMCWatchApp/Tests/HomeGlanceViewModelTests.swift @@ -0,0 +1,85 @@ +import Foundation +import Testing +@testable import UMCWatchApp + +@Suite("HomeGlanceViewModel — 글랜스 파생 표시값") +struct HomeGlanceViewModelTests { + + // MARK: - Test + + @Test("세션이 없으면 출석 목록으로 보낸다") + func routesToListWithoutSession() { + let viewModel = HomeGlanceViewModel(glance: .empty) + + #expect(viewModel.hasSession == false) + #expect(viewModel.attendanceRoute == .attendanceList) + #expect(viewModel.emptySessionMessage == "오늘 세션 없음") + } + + @Test("세션이 있으면 해당 스케줄의 출석 화면으로 보낸다") + func routesToSessionWhenPresent() { + let viewModel = HomeGlanceViewModel(glance: Self.glance(sessionID: "1024")) + + #expect(viewModel.hasSession) + #expect(viewModel.attendanceRoute == .attendanceSession(scheduleID: "1024")) + } + + @Test("승인 대기 0건이면 배지를 감춘다") + func hidesPendingApprovalLabelWhenZero() { + let viewModel = HomeGlanceViewModel(glance: Self.glance(pendingApprovalCount: "0")) + + #expect(viewModel.pendingApprovalLabel == nil) + } + + @Test("승인 대기 N건이면 건수를 문구로 낸다") + func showsPendingApprovalLabelWhenPositive() { + let viewModel = HomeGlanceViewModel(glance: Self.glance(pendingApprovalCount: "3")) + + #expect(viewModel.pendingApprovalLabel == "승인 대기 3건") + } + + @Test("미확인 공지 0건이면 빈 상태 문구를 낸다") + func showsEmptyPingLabelWhenZero() { + let viewModel = HomeGlanceViewModel(glance: Self.glance(unreadPingCount: "0")) + + #expect(viewModel.hasUnreadPing == false) + #expect(viewModel.unreadPingLabel == "새 공지 없음") + } + + @Test("미확인 공지 N건이면 건수를 문구로 낸다") + func showsUnreadPingCount() { + let viewModel = HomeGlanceViewModel(glance: Self.glance(unreadPingCount: "5")) + + #expect(viewModel.hasUnreadPing) + #expect(viewModel.unreadPingLabel == "미확인 5건") + } + + @Test( + "숫자가 아닌 카운트는 0건으로 취급한다", + arguments: ["", "abc", "-1", "3건"] + ) + func treatsNonNumericCountAsZero(_ rawValue: String) { + let viewModel = HomeGlanceViewModel( + glance: Self.glance(pendingApprovalCount: rawValue, unreadPingCount: rawValue) + ) + + #expect(viewModel.pendingApprovalLabel == nil) + #expect(viewModel.unreadPingLabel == "새 공지 없음") + } + + // MARK: - Function + + private static func glance( + sessionID: String? = nil, + pendingApprovalCount: String = "0", + unreadPingCount: String = "0" + ) -> WatchGlance { + WatchGlance( + session: sessionID.map { + GlanceSession(id: $0, title: "5주차 정기 세션", startsAt: .now, status: .active) + }, + pendingApprovalCount: pendingApprovalCount, + unreadPingCount: unreadPingCount + ) + } +} diff --git a/UMCApp/UMCWatchApp/Tests/WatchRouterTests.swift b/UMCApp/UMCWatchApp/Tests/WatchRouterTests.swift new file mode 100644 index 000000000..fdb84b1a9 --- /dev/null +++ b/UMCApp/UMCWatchApp/Tests/WatchRouterTests.swift @@ -0,0 +1,74 @@ +import Testing +@testable import UMCWatchApp + +@Suite("WatchRouter — 단일 네비게이션 스택") +struct WatchRouterTests { + + // MARK: - Test + + @Test("push 하면 스택이 쌓이고 순서가 유지된다") + func pushAppendsRoutesInOrder() { + let router = WatchRouter() + + router.push(.attendanceList) + router.push(.attendanceSession(scheduleID: "1024")) + + #expect(router.path == [.attendanceList, .attendanceSession(scheduleID: "1024")]) + } + + @Test("pop 은 마지막 라우트만 제거한다") + func popRemovesLastRoute() { + let router = WatchRouter() + router.push(.pingList) + router.push(.pingDetail(noticeID: "42")) + + router.pop() + + #expect(router.path == [.pingList]) + } + + @Test("빈 스택에서 pop 해도 크래시하지 않는다") + func popOnEmptyStackIsSafe() { + let router = WatchRouter() + + router.pop() + + #expect(router.path.isEmpty) + } + + @Test("popToRoot 는 스택을 비운다") + func popToRootClearsStack() { + let router = WatchRouter() + router.push(.attendanceList) + router.push(.attendanceResult(scheduleID: "1024")) + + router.popToRoot() + + #expect(router.path.isEmpty) + } + + @Test("같은 식별자의 라우트는 같고, 다른 식별자면 다르다") + func routeEqualityFollowsIdentifier() { + #expect( + WatchRoute.attendanceSession(scheduleID: "1024") + == .attendanceSession(scheduleID: "1024") + ) + #expect( + WatchRoute.attendanceSession(scheduleID: "1024") + != .attendanceSession(scheduleID: "2048") + ) + #expect(WatchRoute.pingDetail(noticeID: "42") != .attendanceResult(scheduleID: "42")) + } + + @Test("폴백 3종이 모두 라우트로 감싸져 스택에 쌓인다") + func fallbackRoutesArePushable() { + let router = WatchRouter() + + for fallback in WatchFallbackRoute.allCases { + router.push(.fallback(fallback)) + } + + #expect(router.path == WatchFallbackRoute.allCases.map(WatchRoute.fallback)) + #expect(router.path.count == 3) + } +} From c2f91cafecdd25b64045fa4ab3a46153e8b1ce7b Mon Sep 17 00:00:00 2001 From: JEONG Date: Sun, 30 Aug 2026 14:06:25 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=EA=B8=80=EB=9E=9C=EC=8A=A4=20?= =?UTF-8?q?=EB=B9=88=20=EC=83=81=ED=83=9C=EC=97=90=EC=84=9C=20=EB=9D=BC?= =?UTF-8?q?=EB=B2=A8=C2=B7=EA=B0=92=20=EB=AC=B8=EA=B5=AC=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "오늘 세션" 라벨 + "오늘 세션 없음" 값이 같은 말을 두 줄로 반복해 위계가 흐려지던 문제 - 빈 상태는 문구 한 줄만 카드에 남긴다. 세션이 있을 때의 라벨/값 위계는 그대로 --- .../UMCWatchApp/Sources/Home/HomeGlanceView.swift | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/UMCApp/UMCWatchApp/Sources/Home/HomeGlanceView.swift b/UMCApp/UMCWatchApp/Sources/Home/HomeGlanceView.swift index c44e0e5e3..50417bfe5 100644 --- a/UMCApp/UMCWatchApp/Sources/Home/HomeGlanceView.swift +++ b/UMCApp/UMCWatchApp/Sources/Home/HomeGlanceView.swift @@ -61,13 +61,12 @@ struct HomeGlanceView: View { } .watchCard(.hero) } else { - VStack(alignment: .leading, spacing: WatchLayout.tightSpacing) { - cardLabel("오늘 세션") - Text(viewModel.emptySessionMessage) - .font(.watch(.cardValue)) - .foregroundStyle(WatchColor.textSecondary) - } - .watchCard() + // 빈 상태 문구가 "오늘 세션"을 이미 포함하므로 라벨을 얹지 않는다 — 같은 말이 + // 두 줄로 반복되면 라벨/값 위계가 오히려 흐려진다. + Text(viewModel.emptySessionMessage) + .font(.watch(.cardValue)) + .foregroundStyle(WatchColor.textSecondary) + .watchCard() } }