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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ cd UMCApp && make doctor # 환경 진단
| Network Router (Moya) | `docs/claude/network-router.md` | API 엔드포인트/DTO 추가 |
| Response DTO 디코딩 | `docs/claude/response-dto-decoding.md` | Response DTO 작성/수정 |
| 디자인 시스템 & 성능 | `docs/claude/design-system.md` | UI/토큰/Glass/렌더링 최적화 |
| watchOS 디자인 시스템 | `docs/claude/watch-design-system.md` | 워치 화면·컴포넌트 작업, Glass 절제 규칙 확인 |
| 코딩 스타일 & 네이밍 | `docs/claude/coding-style.md` | 네이밍 판단이 필요할 때 |
| Git Workflow | `docs/claude/git-workflow.md` | 브랜치/커밋/PR/이슈(템플릿·Type·Priority)/배포 |
| 프로젝트 구조(AppProduct) | `docs/claude/project-structure.md` | 레거시 디렉터리 탐색 |
Expand Down
28 changes: 28 additions & 0 deletions UMCApp/Core/WatchDesignSystem/Project.swift
Original file line number Diff line number Diff line change
@@ -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
)
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading