diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..8c168ec5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,79 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## 프로젝트 개요 + +**앗차(Atcha)** — 위치 기반으로 막차(버스/지하철) 시간을 확인하고 출발 알림을 주는 iOS 앱. 이 레포에는 두 세계가 공존한다: + +- **Tuist 워크스페이스** (`Atcha.xcworkspace`, 생성물) — "메인 2.0" **AtchaV2** 개발이 이뤄지는 곳. 모든 신규 작업은 여기서. +- **레거시** (`Atcha-iOS.xcodeproj` + `Atcha-iOS/` 소스) — 기존 1.x 앱. Tuist에서 `Projects/Legacy`의 단일 타겟으로도 래핑돼 있지만, **CI/fastlane은 아직 기존 xcodeproj를 직접 빌드**하므로 기존 xcodeproj를 삭제·수정하지 말 것. 참고용. + +## 필수 명령어 + +```bash +# 최초 1회 (클론 직후·xcconfig 없을 때): 스탠드인 xcconfig 생성 + tuist install + generate +sh Scripts/bootstrap.sh + +# 매니페스트(Project.swift 등) 수정 후 재생성 +tuist generate --no-open + +# AtchaV2 빌드 (구성: Debug/Stage/Release 3개 — Stage 빼먹으면 CI가 깨짐) +xcodebuild -workspace Atcha.xcworkspace -scheme AtchaV2 -configuration Debug \ + -destination 'generic/platform=iOS Simulator' build + +# 모듈 테스트 (Swift Testing 기반) +xcodebuild -workspace Atcha.xcworkspace -scheme HomeFeature \ + -destination 'platform=iOS Simulator,name=iPhone 17' test +# 단일 테스트: -only-testing:HomeFeatureTests/HomeViewModelTests/viewDidLoad_success_transitionsLoadingToLoaded + +# 의존 그래프 확인 (graph.dot 생성, gitignore됨) +tuist graph --format dot --no-open + +# 레거시 빌드 — 디바이스 전용 (TMapSDK.framework가 arm64 디바이스 전용이라 시뮬레이터 빌드는 원래 불가) +xcodebuild -workspace Atcha.xcworkspace -scheme Atcha-Dev -configuration Debug \ + -destination 'generic/platform=iOS' CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO build +``` + +도구는 mise로 고정(`mise.toml`, Tuist 4.202). `bundle exec`은 로컬에서 동작하지 않음(시스템 ruby 2.6 ↔ Gemfile.lock의 bundler 4.0 비호환) — fastlane은 CI 전용으로 취급. + +### 알아야 할 함정 + +- **xcconfig 4개(Base/Dev/Stage/Live)는 gitignore돼 있고 없으면 `tuist generate`가 에러로 실패한다.** `Scripts/bootstrap.sh`가 빈 스탠드인을 만들어 해결한다(CI는 시크릿에서 실제 파일을 복원). AtchaV2는 xcconfig에 의존하지 않도록 설계돼 있으므로 새 모듈에 xcconfig 참조를 추가하지 말 것. +- 빌드 구성은 프로젝트 전체가 **Debug/Stage/Release 3개**. 새 타겟·외부 의존성 설정에 Stage가 누락되면 `-configuration Stage` 빌드가 조용히 깨진다(외부 SPM은 `Tuist/Package.swift`의 `PackageSettings.baseSettings`가 3구성을 선언). +- SPM 의존성 추가는 `Tuist/Package.swift`에서. `Tuist/Package.resolved`는 커밋 대상(Amplitude가 branch 추적이라 리비전 고정 역할). +- 레거시 소스 글롭에서 `Atcha-iOS/App/DIContainer/DIContainer.swift`는 의도적으로 제외(기존 pbxproj도 컴파일하지 않던 죽은 파일, AppDIContainer 중복 선언). + +## 아키텍처 (AtchaV2 — uFeatures + 클린아키텍처) + +``` +AtchaV2(앱, 조합 루트) ─► HomeFeature ─► {HomeFeatureInterface, Domain, DesignSystem, CoreCoordinator, SnapKit} + └─► AtchaData ─► {Domain, CoreNetwork} +``` + +의존 규칙(위반 금지, `tuist graph`로 검증 가능): +- **Presentation → Domain ← Data**: Feature 모듈은 `AtchaData`를 절대 import하지 않는다. Domain은 무의존. +- 구체 Data/Network 타입을 보는 곳은 앱의 `AppDIContainer`(조합 루트)뿐. 여기서 NetworkClient → RepositoryImpl → UseCase → 피처 DIContainer 순으로 주입한다. +- Firebase 등 외부 라이브러리는 **앱 타겟에서만** 링크(내부 모듈 전부 static framework, 중복 심볼 방지). 앱 타겟에 `-ObjC` 필요. + +모듈 정의는 `Tuist/ProjectDescriptionHelpers/`의 DSL로만 한다: +- `Project.feature(name:)` — 피처당 `{N}Feature`/`{N}FeatureInterface`/`{N}FeatureTests`/`{N}FeatureExample` 4타겟. 새 피처는 `Projects/Feature/Home`을 그대로 본뜬다. +- `Project.layer(name:)` — 수평 모듈(framework+tests). isolation 파라미터: UI 모듈은 `.mainActor`, Domain/Data/Network은 `.nonisolated`. +- `Settings.atchaV2()` — Swift 6 + 3구성 + 구성별 컴파일 플래그(Debug=DEV, Stage=STAGE, Release=LIVE). 환경 분기는 앱의 `AppEnvironment` enum이 이 플래그로 수행(런타임 xcconfig 의존 없음). + +### 피처 내부 컨벤션 (Home이 표준 템플릿) + +- 클린아키텍처 수직 슬라이스 필수 구성: Domain에 Entity + **추상화 UseCase(프로토콜)** + Repository 인터페이스 / Data에 Request·Response DTO + `toEntity()` + RepositoryImpl / Presentation에 ViewData(Entity를 뷰에 직접 노출 금지) + ViewModel + VC. +- **모든 ViewModel은 `@MainActor`.** 비동기 작업은 `Task` 보관 + `deinit`에서 cancel + `[weak self]` + `Task.isCancelled` 가드. +- **조립은 피처 DIContainer, 화면 흐름은 Coordinator.** Coordinator는 `CoreCoordinator.Coordinator`를 채택하고 `finishDelegate`(weak)로 부모가 자식을 제거한다(누수 방지). navigationController는 앱 루트(AppCoordinator)만 강한 소유, 나머지는 weak. +- 다른 모듈에 노출하는 진입점은 Interface 타겟의 프로토콜(`HomeCoordinatorBuildable` 패턴)로만. +- 테스트는 **Swift Testing**(`@Test`/`#expect`). Example 앱은 스텁 UseCase로 피처 단독 실행(Data 무의존). 스텁이 Tests/Example에 중복되는 것은 의도된 트레이드오프. +- catch-all `Shared`/`Common` 모듈을 만들지 않는다. 로깅·캐싱 등이 필요해지면 목적별 단일 모듈(`Logger`, `Storage`)을 새로 판다. +- 모듈명 `Data`는 금지(Foundation.Data 섀도잉) — Data 레이어 모듈명은 `AtchaData`(디렉터리는 `Projects/Data`). +- UI는 UIKit 코드 기반(스토리보드 없음) + SnapKit + DesignSystem 토큰(`DSColor`/`DSFont`/`DSSpacing`). + +### 미완 상태 (작업 시 참고) + +- `AppEnvironment`의 API base URL은 플레이스홀더 — 실서버 주소 미정. +- `com.atcha.iOS.v2`용 GoogleService-Info.plist 미발급 — `AppDelegate`가 파일 존재를 가드한 뒤에만 `FirebaseApp.configure()` 호출. plist를 `Projects/App/Resources/`에 넣으면 자동 활성화. +- AtchaV2는 iOS 26 전용(AlarmKit 사용 예정). AlarmKit의 커스텀 알람 UI(Live Activity)는 추후 위젯 익스텐션 타겟이 별도로 필요. diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift index 5062c469..5e4857ff 100644 --- a/Projects/App/Project.swift +++ b/Projects/App/Project.swift @@ -33,6 +33,8 @@ let appTarget = Target.target( .project(target: "Domain", path: "../Domain"), .project(target: "AtchaData", path: "../Data"), .project(target: "CoreNetwork", path: "../Core/Network"), + .project(target: "CoreStorage", path: "../Core/Storage"), + .project(target: "CoreAuth", path: "../Core/Auth"), .project(target: "CoreCoordinator", path: "../Core/Coordinator"), .project(target: "DesignSystem", path: "../DesignSystem"), .external(name: "FirebaseCore"), diff --git a/Projects/App/Sources/AppCoordinator.swift b/Projects/App/Sources/AppCoordinator.swift index a2ede259..3284cac1 100644 --- a/Projects/App/Sources/AppCoordinator.swift +++ b/Projects/App/Sources/AppCoordinator.swift @@ -9,17 +9,52 @@ final class AppCoordinator: Coordinator, CoordinatorFinishDelegate { private let navigationController: UINavigationController private let container: AppDIContainer + private weak var splashViewController: SplashViewController? + private var bootstrapTask: Task? + init(navigationController: UINavigationController, container: AppDIContainer) { self.navigationController = navigationController self.container = container } + deinit { + bootstrapTask?.cancel() + } + func start() { + let splash = SplashViewController() + splash.onRetryTapped = { [weak self] in self?.bootstrap() } + splashViewController = splash + navigationController.setViewControllers([splash], animated: false) + bootstrap() + } + + private func bootstrap() { + bootstrapTask?.cancel() + splashViewController?.showLoading() + bootstrapTask = Task { [weak self] in + guard let self else { return } + do { + try await self.container.authSessionManager.bootstrap() + guard !Task.isCancelled else { return } + self.startHome() + } catch { + guard !Task.isCancelled else { return } + self.splashViewController?.showRetry() + } + } + } + + private func startHome() { let homeCoordinator = container.makeHomeDIContainer() .makeHomeCoordinator(navigationController: navigationController) homeCoordinator.finishDelegate = self addChild(homeCoordinator) homeCoordinator.start() + // Home pushed its own root; drop the splash out from under it. + if let splash = splashViewController { + navigationController.viewControllers.removeAll { $0 === splash } + } } func coordinatorDidFinish(_ coordinator: any Coordinator) { diff --git a/Projects/App/Sources/AppDIContainer.swift b/Projects/App/Sources/AppDIContainer.swift index ed118a22..44e27a4e 100644 --- a/Projects/App/Sources/AppDIContainer.swift +++ b/Projects/App/Sources/AppDIContainer.swift @@ -1,5 +1,7 @@ import AtchaData +import CoreAuth import CoreNetwork +import CoreStorage import Domain import HomeFeature import HomeFeatureInterface @@ -8,11 +10,26 @@ import HomeFeatureInterface /// Presentation modules depend on Domain protocols only. final class AppDIContainer { private let networkClient: any NetworkClient + let authSessionManager: AuthSessionManager init() { - self.networkClient = URLSessionNetworkClient( + let baseClient = URLSessionNetworkClient( baseURL: AppEnvironment.current.apiBaseURL ) + let sessionManager = AuthSessionManager( + tokenStore: TokenStore(store: KeychainStore()), + // The plain client, not the decorator — reissue must never recurse + // into the 401-recovery path. + networkClient: baseClient, + // 미확정 입력 #2: swap in the real issuer here once the anonymous + // issuance endpoint spec is confirmed. + issuer: UnconfiguredAnonymousSessionIssuer() + ) + self.authSessionManager = sessionManager + self.networkClient = AuthenticatedNetworkClient( + base: baseClient, + sessionManager: sessionManager + ) } func makeHomeDIContainer() -> any HomeCoordinatorBuildable { diff --git a/Projects/App/Sources/AppEnvironment.swift b/Projects/App/Sources/AppEnvironment.swift index 334bf511..2b6a9d66 100644 --- a/Projects/App/Sources/AppEnvironment.swift +++ b/Projects/App/Sources/AppEnvironment.swift @@ -18,12 +18,14 @@ enum AppEnvironment { #endif } - // Placeholder URLs — replace with the real per-environment hosts. + // Hosts recovered from the legacy trust-evaluator registrations + // (user-approved 2026-08-22). Stage shares the dev host until a dedicated + // one exists. var apiBaseURL: URL { switch self { - case .dev: URL(string: "https://dev-api.atcha.example")! - case .stage: URL(string: "https://stage-api.atcha.example")! - case .live: URL(string: "https://api.atcha.example")! + case .dev: URL(string: "https://atcha.p-e.kr")! + case .stage: URL(string: "https://atcha.p-e.kr")! + case .live: URL(string: "https://atcha.online")! } } } diff --git a/Projects/App/Sources/SplashViewController.swift b/Projects/App/Sources/SplashViewController.swift new file mode 100644 index 00000000..56339f85 --- /dev/null +++ b/Projects/App/Sources/SplashViewController.swift @@ -0,0 +1,72 @@ +import DesignSystem +import UIKit + +/// Dumb splash screen: logo while the auth bootstrap runs, a retry affordance +/// when it fails. All flow decisions live in AppCoordinator. +final class SplashViewController: UIViewController { + var onRetryTapped: (() -> Void)? + + private let logoLabel = UILabel() + private let activityIndicator = UIActivityIndicatorView(style: .medium) + private let retryStack = UIStackView() + private let messageLabel = UILabel() + private let retryButton = DSButton(title: "다시 시도") + + override func viewDidLoad() { + super.viewDidLoad() + configureUI() + } + + func showLoading() { + retryStack.isHidden = true + activityIndicator.startAnimating() + } + + func showRetry() { + activityIndicator.stopAnimating() + retryStack.isHidden = false + } + + private func configureUI() { + view.backgroundColor = DSColor.background + + logoLabel.text = "앗차" + logoLabel.font = DSFont.title(34) + logoLabel.textColor = DSColor.accent + + activityIndicator.hidesWhenStopped = true + + messageLabel.text = "네트워크 연결을 확인해주세요" + messageLabel.font = DSFont.body() + messageLabel.textColor = DSColor.textPrimary + messageLabel.textAlignment = .center + + retryButton.addAction( + UIAction { [weak self] _ in self?.onRetryTapped?() }, + for: .touchUpInside + ) + + retryStack.axis = .vertical + retryStack.alignment = .center + retryStack.spacing = DSSpacing.md + retryStack.isHidden = true + retryStack.addArrangedSubview(messageLabel) + retryStack.addArrangedSubview(retryButton) + + for subview in [logoLabel, activityIndicator, retryStack] { + subview.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(subview) + } + + NSLayoutConstraint.activate([ + logoLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + logoLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -DSSpacing.xl), + activityIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor), + activityIndicator.topAnchor.constraint(equalTo: logoLabel.bottomAnchor, constant: DSSpacing.lg), + retryStack.centerXAnchor.constraint(equalTo: view.centerXAnchor), + retryStack.topAnchor.constraint(equalTo: logoLabel.bottomAnchor, constant: DSSpacing.lg), + retryStack.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: DSSpacing.md), + retryStack.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -DSSpacing.md), + ]) + } +} diff --git a/Projects/Core/Auth/Project.swift b/Projects/Core/Auth/Project.swift new file mode 100644 index 00000000..c9ba841c --- /dev/null +++ b/Projects/Core/Auth/Project.swift @@ -0,0 +1,12 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +let project = Project.layer( + name: "CoreAuth", + bundleSuffix: "core.auth", + isolation: .nonisolated, + dependencies: [ + .project(target: "CoreNetwork", path: "../Network"), + .project(target: "CoreStorage", path: "../Storage"), + ] +) diff --git a/Projects/Core/Auth/Sources/AnonymousSessionIssuing.swift b/Projects/Core/Auth/Sources/AnonymousSessionIssuing.swift new file mode 100644 index 00000000..c2f1a8ec --- /dev/null +++ b/Projects/Core/Auth/Sources/AnonymousSessionIssuing.swift @@ -0,0 +1,27 @@ +public struct TokenPair: Equatable, Sendable { + public let accessToken: String + public let refreshToken: String + + public init(accessToken: String, refreshToken: String) { + self.accessToken = accessToken + self.refreshToken = refreshToken + } +} + +/// Issues a brand-new anonymous session (first launch, or when refresh is no +/// longer possible). The real endpoint spec is pending (미확정 입력 #2); once it +/// lands, add a concrete implementation and swap it in AppDIContainer — no +/// other CoreAuth code changes. +public protocol AnonymousSessionIssuing: Sendable { + func issueSession() async throws -> TokenPair +} + +/// Stand-in until the issuance endpoint spec is confirmed: always throws +/// `AuthError.issuerNotConfigured`. +public struct UnconfiguredAnonymousSessionIssuer: AnonymousSessionIssuing { + public init() {} + + public func issueSession() async throws -> TokenPair { + throw AuthError.issuerNotConfigured + } +} diff --git a/Projects/Core/Auth/Sources/AuthError.swift b/Projects/Core/Auth/Sources/AuthError.swift new file mode 100644 index 00000000..01b67404 --- /dev/null +++ b/Projects/Core/Auth/Sources/AuthError.swift @@ -0,0 +1,9 @@ +public enum AuthError: Error, Equatable, Sendable { + /// The anonymous-session issuance endpoint spec is not confirmed yet + /// (master prompt, 미확정 입력 #2). `bootstrap()` treats this as non-fatal; + /// `recoverSession()` propagates it. + case issuerNotConfigured + /// The reissue envelope came back with a non-success responseCode or an + /// empty result. + case refreshRejected(responseCode: String) +} diff --git a/Projects/Core/Auth/Sources/AuthSessionManager.swift b/Projects/Core/Auth/Sources/AuthSessionManager.swift new file mode 100644 index 00000000..966232c9 --- /dev/null +++ b/Projects/Core/Auth/Sources/AuthSessionManager.swift @@ -0,0 +1,82 @@ +import CoreNetwork + +/// Owns the anonymous session lifecycle: first-launch issuance, token refresh +/// via GET /auth/reissue, and the 401-recovery chain. Single-flight is +/// guaranteed by keeping the in-progress recovery task on the actor — the +/// legacy interceptor's waiting-queue semantics without the queue. +public actor AuthSessionManager { + /// Read synchronously by the decorator on every request (no actor hop); + /// TokenStore is a Sendable value and the keychain store locks internally. + public nonisolated let tokenStore: TokenStore + + /// Must be the plain (undecorated) client — the legacy setup used a + /// separate interceptor-free session for reissue to break recursion. + private let networkClient: any NetworkClient + private let issuer: any AnonymousSessionIssuing + private var recoveryTask: Task? + + public init( + tokenStore: TokenStore, + networkClient: any NetworkClient, + issuer: any AnonymousSessionIssuing + ) { + self.tokenStore = tokenStore + self.networkClient = networkClient + self.issuer = issuer + } + + /// Splash-time bootstrap. Issues an anonymous session only when no access + /// token is stored. `issuerNotConfigured` is non-fatal (미확정 입력 #2's + /// interim behavior: proceed without tokens); other failures propagate so + /// the splash can offer retry. + public func bootstrap() async throws { + if (try? tokenStore.accessToken()) != nil { return } + do { + try tokenStore.save(try await issuer.issueSession()) + } catch AuthError.issuerNotConfigured { + return + } + } + + /// Single entry point for 401 recovery: refresh first, fall back to a + /// fresh anonymous session, throw when both are impossible. Concurrent + /// callers join the in-flight recovery instead of starting another. + public func recoverSession() async throws { + if let existing = recoveryTask { + return try await existing.value + } + let task = Task { try await self.performRecovery() } + recoveryTask = task + defer { recoveryTask = nil } + try await task.value + } + + private func performRecovery() async throws { + if let refreshToken = try? tokenStore.refreshToken() { + do { + return try await refreshTokens(with: refreshToken) + } catch { + // Refresh is dead (rejected, expired, transport) — fall back + // to re-issuing an anonymous session. + } + } + // Existing tokens are never cleared here: save() overwrites on + // success, and a transient failure must not destroy the anonymous + // identity that owns server-side state. + try tokenStore.save(try await issuer.issueSession()) + } + + private func refreshTokens(with refreshToken: String) async throws { + let envelope = try await networkClient.request( + ReissueEndpoint(refreshToken: refreshToken), + as: ReissueEnvelope.self + ) + guard envelope.responseCode == "SUCCESS", let tokens = envelope.result else { + throw AuthError.refreshRejected(responseCode: envelope.responseCode) + } + // The server rotates both tokens on reissue. + try tokenStore.save( + TokenPair(accessToken: tokens.accessToken, refreshToken: tokens.refreshToken) + ) + } +} diff --git a/Projects/Core/Auth/Sources/AuthenticatedNetworkClient.swift b/Projects/Core/Auth/Sources/AuthenticatedNetworkClient.swift new file mode 100644 index 00000000..83ebbf5f --- /dev/null +++ b/Projects/Core/Auth/Sources/AuthenticatedNetworkClient.swift @@ -0,0 +1,86 @@ +import CoreNetwork +import Foundation + +/// NetworkClient decorator that attaches the Bearer access token, and on a 401 +/// runs the session-recovery chain once before a single retry. Wrap the plain +/// URLSessionNetworkClient with this at the composition root — callers keep +/// seeing the NetworkClient contract, so no Data/Feature code changes. +public struct AuthenticatedNetworkClient: NetworkClient { + private let base: any NetworkClient + private let sessionManager: AuthSessionManager + /// Legacy allowlist semantics: suffix match on the request path skips the + /// Authorization header entirely. + private let publicPathSuffixes: [String] + + public init( + base: any NetworkClient, + sessionManager: AuthSessionManager, + publicPathSuffixes: [String] = ["/auth/reissue"] + ) { + self.base = base + self.sessionManager = sessionManager + self.publicPathSuffixes = publicPathSuffixes + } + + public func data(for endpoint: any Endpoint) async throws -> Data { + try await perform(endpoint) { try await base.data(for: $0) } + } + + public func request( + _ endpoint: any Endpoint, + as type: Response.Type + ) async throws -> Response { + try await perform(endpoint) { try await base.request($0, as: type) } + } + + private func perform( + _ endpoint: any Endpoint, + send: @Sendable (any Endpoint) async throws -> Value + ) async throws -> Value { + if publicPathSuffixes.contains(where: { endpoint.path.hasSuffix($0) }) { + return try await send(endpoint) + } + do { + return try await send(authorized(endpoint)) + } catch let networkError as NetworkError { + guard case .unacceptableStatus(code: 401, data: _) = networkError else { + throw networkError + } + do { + try await sessionManager.recoverSession() + } catch { + // Recovery is impossible — surface the original 401 so callers + // keep receiving the NetworkError contract, and skip the + // pointless unauthenticated retry (legacy doNotRetry). + throw networkError + } + // Single retry; a second 401 propagates as-is. + return try await send(authorized(endpoint)) + } + } + + /// No token (or an unreadable keychain) degrades to an unauthenticated + /// request — legacy semantics: never block the request itself. + private func authorized(_ endpoint: any Endpoint) -> any Endpoint { + guard let token = try? sessionManager.tokenStore.accessToken() else { + return endpoint + } + return AuthorizedEndpoint(base: endpoint, accessToken: token) + } +} + +/// Endpoint has no auth flag, so authorization is layered on by wrapping: all +/// members delegate to the base and only headers gain the Bearer entry +/// (overwriting on conflict, like the legacy interceptor's setValue). +struct AuthorizedEndpoint: Endpoint { + let base: any Endpoint + let accessToken: String + + var path: String { base.path } + var method: HTTPMethod { base.method } + var queryItems: [URLQueryItem] { base.queryItems } + var body: Data? { base.body } + var headers: [String: String] { + base.headers.merging(["Authorization": "Bearer \(accessToken)"]) { _, bearer in bearer } + } +} diff --git a/Projects/Core/Auth/Sources/ReissueEndpoint.swift b/Projects/Core/Auth/Sources/ReissueEndpoint.swift new file mode 100644 index 00000000..0eda11f3 --- /dev/null +++ b/Projects/Core/Auth/Sources/ReissueEndpoint.swift @@ -0,0 +1,24 @@ +import CoreNetwork + +/// Legacy-measured contract: GET /auth/reissue with the *refresh* token as the +/// Bearer credential. +struct ReissueEndpoint: Endpoint { + let refreshToken: String + + var path: String { "/auth/reissue" } + var method: HTTPMethod { .get } + var headers: [String: String] { ["Authorization": "Bearer \(refreshToken)"] } +} + +/// Deliberate duplicate of AtchaData's APIResponse envelope — CoreAuth must +/// not import AtchaData, so it carries its own minimal decoding type. +struct ReissueEnvelope: Decodable, Sendable { + let responseCode: String + let result: Tokens? + + struct Tokens: Decodable, Sendable { + let id: Int? + let accessToken: String + let refreshToken: String + } +} diff --git a/Projects/Core/Auth/Sources/TokenStore.swift b/Projects/Core/Auth/Sources/TokenStore.swift new file mode 100644 index 00000000..99dea1f1 --- /dev/null +++ b/Projects/Core/Auth/Sources/TokenStore.swift @@ -0,0 +1,44 @@ +import CoreStorage +import Foundation + +/// Keeps the session tokens under the legacy key names ("accessToken" / +/// "refreshToken") as raw UTF-8. The backing store is the v2 keychain service, +/// so there is no legacy migration concern — the names are kept only so the +/// stored contract stays recognizable. +public struct TokenStore: Sendable { + private enum Key { + static let accessToken = "accessToken" + static let refreshToken = "refreshToken" + } + + private let store: any KeyValueStore + + public init(store: any KeyValueStore) { + self.store = store + } + + public func accessToken() throws -> String? { + try string(forKey: Key.accessToken) + } + + public func refreshToken() throws -> String? { + try string(forKey: Key.refreshToken) + } + + /// Saves both tokens — the server rotates the refresh token on reissue, so + /// a pair is always written together. + public func save(_ pair: TokenPair) throws { + try store.set(Data(pair.accessToken.utf8), forKey: Key.accessToken) + try store.set(Data(pair.refreshToken.utf8), forKey: Key.refreshToken) + } + + public func clear() throws { + try store.removeValue(forKey: Key.accessToken) + try store.removeValue(forKey: Key.refreshToken) + } + + private func string(forKey key: String) throws -> String? { + guard let data = try store.data(forKey: key) else { return nil } + return String(decoding: data, as: UTF8.self) + } +} diff --git a/Projects/Core/Auth/Tests/AuthSessionManagerTests.swift b/Projects/Core/Auth/Tests/AuthSessionManagerTests.swift new file mode 100644 index 00000000..3ec242e1 --- /dev/null +++ b/Projects/Core/Auth/Tests/AuthSessionManagerTests.swift @@ -0,0 +1,219 @@ +import CoreAuth +import CoreNetwork +import Foundation +import Testing + +struct AuthSessionManagerTests { + private let backing = InMemoryKeyValueStore() + private var tokenStore: TokenStore { TokenStore(store: backing) } + + private func makeManager( + handler: @escaping @Sendable (any Endpoint, Int) async throws -> Data = { _, _ in Data() }, + issuer: StubIssuer = StubIssuer(result: .failure(AuthError.issuerNotConfigured)) + ) -> (manager: AuthSessionManager, network: ScriptedNetworkClient, issuer: StubIssuer) { + let network = ScriptedNetworkClient(handler: handler) + let manager = AuthSessionManager(tokenStore: tokenStore, networkClient: network, issuer: issuer) + return (manager, network, issuer) + } + + // MARK: - bootstrap + + @Test + func bootstrap_emptyStore_issuesAndSavesTokens() async throws { + let issued = TokenPair(accessToken: "A", refreshToken: "R") + let (manager, _, issuer) = makeManager(issuer: StubIssuer(result: .success(issued))) + + try await manager.bootstrap() + + #expect(issuer.issueCallCount == 1) + #expect(try tokenStore.accessToken() == "A") + #expect(try tokenStore.refreshToken() == "R") + } + + @Test + func bootstrap_existingAccessToken_skipsIssuer() async throws { + try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) + let (manager, _, issuer) = makeManager() + + try await manager.bootstrap() + + #expect(issuer.issueCallCount == 0) + #expect(try tokenStore.accessToken() == "A") + } + + /// 미확정 입력 #2's interim behavior: an unconfigured issuer is non-fatal — + /// the app proceeds without tokens. + @Test + func bootstrap_issuerNotConfigured_completesWithoutTokens() async throws { + let (manager, _, _) = makeManager() + + try await manager.bootstrap() + + #expect(try tokenStore.accessToken() == nil) + } + + @Test + func bootstrap_issuerTransportFailure_throws() async { + let (manager, _, _) = makeManager( + issuer: StubIssuer(result: .failure(URLError(.notConnectedToInternet))) + ) + + await #expect(throws: URLError.self) { + try await manager.bootstrap() + } + } + + // MARK: - recoverSession + + /// Pins the legacy-measured reissue contract: GET /auth/reissue with the + /// refresh token as the Bearer credential. + @Test + func recoverSession_withRefreshToken_sendsGetReissueWithBearerRefreshHeader() async throws { + try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) + let (manager, network, _) = makeManager(handler: { _, _ in + reissueSuccessBody(access: "A2", refresh: "R2") + }) + + try await manager.recoverSession() + + let reissues = network.recordedCalls(to: "/auth/reissue") + #expect(reissues.count == 1) + #expect(reissues.first?.method == .get) + #expect(reissues.first?.headers["Authorization"] == "Bearer R") + } + + @Test + func recoverSession_reissueSuccess_rotatesBothTokens() async throws { + try tokenStore.save(TokenPair(accessToken: "A1", refreshToken: "R1")) + let (manager, _, _) = makeManager(handler: { _, _ in + reissueSuccessBody(access: "A2", refresh: "R2") + }) + + try await manager.recoverSession() + + #expect(try tokenStore.accessToken() == "A2") + #expect(try tokenStore.refreshToken() == "R2") + } + + @Test + func recoverSession_reissueNonSuccessCode_fallsBackToIssuer() async throws { + try tokenStore.save(TokenPair(accessToken: "A1", refreshToken: "R1")) + let issued = TokenPair(accessToken: "A2", refreshToken: "R2") + let (manager, _, issuer) = makeManager( + handler: { _, _ in reissueRejectedBody(responseCode: "AUTH_401") }, + issuer: StubIssuer(result: .success(issued)) + ) + + try await manager.recoverSession() + + #expect(issuer.issueCallCount == 1) + #expect(try tokenStore.accessToken() == "A2") + } + + @Test + func recoverSession_reissueHTTP401_fallsBackToIssuer() async throws { + try tokenStore.save(TokenPair(accessToken: "A1", refreshToken: "R1")) + let issued = TokenPair(accessToken: "A2", refreshToken: "R2") + let (manager, _, issuer) = makeManager( + handler: { _, _ in throw unauthorizedError() }, + issuer: StubIssuer(result: .success(issued)) + ) + + try await manager.recoverSession() + + #expect(issuer.issueCallCount == 1) + #expect(try tokenStore.accessToken() == "A2") + } + + @Test + func recoverSession_noRefreshToken_skipsReissueAndUsesIssuer() async throws { + let issued = TokenPair(accessToken: "A", refreshToken: "R") + let (manager, network, issuer) = makeManager(issuer: StubIssuer(result: .success(issued))) + + try await manager.recoverSession() + + #expect(network.recorded.isEmpty) + #expect(issuer.issueCallCount == 1) + #expect(try tokenStore.accessToken() == "A") + } + + @Test + func recoverSession_refreshAndIssuerBothFail_throws() async throws { + try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) + let (manager, _, _) = makeManager( + handler: { _, _ in throw unauthorizedError() }, + issuer: StubIssuer(result: .failure(URLError(.notConnectedToInternet))) + ) + + await #expect(throws: URLError.self) { + try await manager.recoverSession() + } + } + + /// Unlike bootstrap, recovery must not swallow issuerNotConfigured — the + /// decorator needs the failure to rethrow the original 401. + @Test + func recoverSession_issuerNotConfigured_propagatesError() async { + let (manager, _, _) = makeManager() + + await #expect(throws: AuthError.issuerNotConfigured) { + try await manager.recoverSession() + } + } + + /// No destructive clearing: a failed recovery keeps the stored identity. + @Test + func recoverSession_reissueFailure_keepsExistingTokensWhenIssuerAlsoFails() async throws { + try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) + let (manager, _, _) = makeManager(handler: { _, _ in throw unauthorizedError() }) + + await #expect(throws: AuthError.issuerNotConfigured) { + try await manager.recoverSession() + } + + #expect(try tokenStore.accessToken() == "A") + #expect(try tokenStore.refreshToken() == "R") + } + + /// Single-flight: while one recovery is parked mid-reissue, concurrent + /// callers join it instead of starting their own. + @Test + func recoverSession_concurrentCalls_performsSingleReissue() async throws { + try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) + let reissueReached = AsyncGate() + let reissueRelease = AsyncGate() + let (manager, network, _) = makeManager(handler: { _, _ in + reissueReached.open() + await reissueRelease.wait() + return reissueSuccessBody(access: "A2", refresh: "R2") + }) + + async let first: Void = manager.recoverSession() + await reissueReached.wait() + async let second: Void = manager.recoverSession() + async let third: Void = manager.recoverSession() + // The recovery task stays registered until the gate opens; give the + // joiners ample time to enter the actor before releasing. + try await Task.sleep(for: .milliseconds(50)) + reissueRelease.open() + + _ = try await (first, second, third) + + #expect(network.recordedCalls(to: "/auth/reissue").count == 1) + #expect(try tokenStore.accessToken() == "A2") + } + + @Test + func recoverSession_sequentialCalls_performsReissuePerCall() async throws { + try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) + let (manager, network, _) = makeManager(handler: { _, index in + reissueSuccessBody(access: "A\(index + 2)", refresh: "R\(index + 2)") + }) + + try await manager.recoverSession() + try await manager.recoverSession() + + #expect(network.recordedCalls(to: "/auth/reissue").count == 2) + #expect(try tokenStore.accessToken() == "A3") + } +} diff --git a/Projects/Core/Auth/Tests/AuthTestStubs.swift b/Projects/Core/Auth/Tests/AuthTestStubs.swift new file mode 100644 index 00000000..8d0ba221 --- /dev/null +++ b/Projects/Core/Auth/Tests/AuthTestStubs.swift @@ -0,0 +1,130 @@ +import CoreAuth +import CoreNetwork +import CoreStorage +import Foundation +import Synchronization + +// Shared within this test target only (single-target sharing — distinct from +// the intended Tests/Example duplication trade-off). + +final class InMemoryKeyValueStore: KeyValueStore { + private let storage = Mutex<[String: Data]>([:]) + + func data(forKey key: String) throws -> Data? { storage.withLock { $0[key] } } + func set(_ data: Data, forKey key: String) throws { storage.withLock { $0[key] = data } } + func removeValue(forKey key: String) throws { storage.withLock { $0[key] = nil } } +} + +/// Records every request and delegates the response to a scripted handler. +/// The handler receives the call index *among calls to the same path*, so +/// "first /things call fails, second succeeds" scripts stay readable. +final class ScriptedNetworkClient: NetworkClient { + struct RecordedRequest: Sendable { + let path: String + let method: HTTPMethod + let headers: [String: String] + } + + private let handler: @Sendable (any Endpoint, Int) async throws -> Data + private let recordedStorage = Mutex<[RecordedRequest]>([]) + + init(handler: @escaping @Sendable (any Endpoint, Int) async throws -> Data) { + self.handler = handler + } + + var recorded: [RecordedRequest] { recordedStorage.withLock { $0 } } + + func recordedCalls(to path: String) -> [RecordedRequest] { + recorded.filter { $0.path == path } + } + + func data(for endpoint: any Endpoint) async throws -> Data { + let pathCallIndex = recordedStorage.withLock { requests -> Int in + let index = requests.count(where: { $0.path == endpoint.path }) + requests.append(RecordedRequest( + path: endpoint.path, + method: endpoint.method, + headers: endpoint.headers + )) + return index + } + return try await handler(endpoint, pathCallIndex) + } + + func request( + _ endpoint: any Endpoint, + as _: Response.Type + ) async throws -> Response { + try JSONDecoder().decode(Response.self, from: try await data(for: endpoint)) + } +} + +final class StubIssuer: AnonymousSessionIssuing { + private let result: Result + private let callCount = Mutex(0) + + init(result: Result) { + self.result = result + } + + var issueCallCount: Int { callCount.withLock { $0 } } + + func issueSession() async throws -> TokenPair { + callCount.withLock { $0 += 1 } + return try result.get() + } +} + +/// Deterministic concurrency gate: `wait()` suspends until `open()`; once +/// opened, all current and future waiters pass immediately. +final class AsyncGate: Sendable { + private struct State { + var opened = false + var waiters: [CheckedContinuation] = [] + } + + private let state = Mutex(State()) + + func wait() async { + await withCheckedContinuation { continuation in + let passImmediately = state.withLock { state -> Bool in + if state.opened { return true } + state.waiters.append(continuation) + return false + } + if passImmediately { continuation.resume() } + } + } + + func open() { + let waiters = state.withLock { state -> [CheckedContinuation] in + state.opened = true + let waiters = state.waiters + state.waiters = [] + return waiters + } + for waiter in waiters { waiter.resume() } + } +} + +struct TestEndpoint: Endpoint { + var path = "/things" + var method: HTTPMethod = .get + var headers: [String: String] = [:] +} + +func reissueSuccessBody(access: String, refresh: String) -> Data { + Data(""" + {"responseCode": "SUCCESS", "result": {"id": 1, "accessToken": "\(access)", "refreshToken": "\(refresh)"}} + """.utf8) +} + +func reissueRejectedBody(responseCode: String) -> Data { + Data(""" + {"responseCode": "\(responseCode)", "result": null} + """.utf8) +} + +func unauthorizedError() -> NetworkError { + .unacceptableStatus(code: 401, data: Data()) +} diff --git a/Projects/Core/Auth/Tests/AuthenticatedNetworkClientTests.swift b/Projects/Core/Auth/Tests/AuthenticatedNetworkClientTests.swift new file mode 100644 index 00000000..87449c99 --- /dev/null +++ b/Projects/Core/Auth/Tests/AuthenticatedNetworkClientTests.swift @@ -0,0 +1,222 @@ +import CoreAuth +import CoreNetwork +import Foundation +import Testing + +struct AuthenticatedNetworkClientTests { + private let backing = InMemoryKeyValueStore() + private var tokenStore: TokenStore { TokenStore(store: backing) } + + /// Real AuthSessionManager + scripted network — the master-prompt test + /// scenario ("stub NetworkClient, 401→refresh→retry chain") end to end. + /// The scripted client serves both roles: the decorator's base transport + /// and the manager's reissue transport. + private func makeSUT( + handler: @escaping @Sendable (any Endpoint, Int) async throws -> Data, + issuer: StubIssuer = StubIssuer(result: .failure(AuthError.issuerNotConfigured)), + publicPathSuffixes: [String] = ["/auth/reissue"] + ) -> (sut: AuthenticatedNetworkClient, network: ScriptedNetworkClient, issuer: StubIssuer) { + let network = ScriptedNetworkClient(handler: handler) + let manager = AuthSessionManager(tokenStore: tokenStore, networkClient: network, issuer: issuer) + let sut = AuthenticatedNetworkClient( + base: network, + sessionManager: manager, + publicPathSuffixes: publicPathSuffixes + ) + return (sut, network, issuer) + } + + @Test + func dataFor_withStoredAccessToken_attachesBearerHeader() async throws { + try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) + let (sut, network, _) = makeSUT(handler: { _, _ in Data("ok".utf8) }) + + let result = try await sut.data(for: TestEndpoint()) + + #expect(result == Data("ok".utf8)) + #expect(network.recorded.first?.headers["Authorization"] == "Bearer A") + } + + @Test + func dataFor_withoutToken_sendsWithoutAuthorizationHeader() async throws { + let (sut, network, _) = makeSUT(handler: { _, _ in Data() }) + + _ = try await sut.data(for: TestEndpoint()) + + #expect(network.recorded.first?.headers["Authorization"] == nil) + } + + @Test + func dataFor_publicPathSuffix_skipsAuthorizationHeader() async throws { + try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) + let (sut, network, _) = makeSUT( + handler: { _, _ in Data() }, + publicPathSuffixes: ["/public/thing"] + ) + + _ = try await sut.data(for: TestEndpoint(path: "/api/public/thing")) + + #expect(network.recorded.first?.headers["Authorization"] == nil) + } + + @Test + func dataFor_on401_refreshesAndRetriesWithNewToken() async throws { + try tokenStore.save(TokenPair(accessToken: "OLD", refreshToken: "R")) + let (sut, network, _) = makeSUT(handler: { endpoint, index in + switch (endpoint.path, index) { + case ("/things", 0): throw unauthorizedError() + case ("/things", _): return Data("ok".utf8) + default: return reissueSuccessBody(access: "NEW", refresh: "R2") + } + }) + + let result = try await sut.data(for: TestEndpoint()) + + #expect(result == Data("ok".utf8)) + #expect(network.recordedCalls(to: "/auth/reissue").count == 1) + let sends = network.recordedCalls(to: "/things") + #expect(sends.count == 2) + #expect(sends.last?.headers["Authorization"] == "Bearer NEW") + } + + @Test + func dataFor_on401RetryAlso401_throwsWithoutSecondRefresh() async throws { + try tokenStore.save(TokenPair(accessToken: "OLD", refreshToken: "R")) + let (sut, network, _) = makeSUT(handler: { endpoint, _ in + if endpoint.path == "/things" { throw unauthorizedError() } + return reissueSuccessBody(access: "NEW", refresh: "R2") + }) + + await #expect(throws: NetworkError.self) { + try await sut.data(for: TestEndpoint()) + } + + #expect(network.recordedCalls(to: "/things").count == 2) + #expect(network.recordedCalls(to: "/auth/reissue").count == 1) + } + + /// Recovery failure surfaces the *original* 401 (NetworkClient contract), + /// not the internal auth error, and skips the unauthenticated retry. + @Test + func dataFor_on401RecoveryFails_rethrowsOriginalUnauthorized() async throws { + let marker = Data("original-401".utf8) + let (sut, network, _) = makeSUT(handler: { _, _ in + throw NetworkError.unacceptableStatus(code: 401, data: marker) + }) + + do { + _ = try await sut.data(for: TestEndpoint()) + Issue.record("Expected the original 401 to be rethrown") + } catch let error as NetworkError { + guard case .unacceptableStatus(code: 401, data: let data) = error else { + Issue.record("Expected unacceptableStatus(401), got \(error)") + return + } + #expect(data == marker) + } + + #expect(network.recordedCalls(to: "/things").count == 1) + } + + @Test + func dataFor_401RefreshFailsIssuerSucceeds_retriesWithIssuedToken() async throws { + try tokenStore.save(TokenPair(accessToken: "OLD", refreshToken: "DEAD")) + let issued = TokenPair(accessToken: "ISSUED", refreshToken: "R2") + let (sut, network, issuer) = makeSUT( + handler: { endpoint, index in + switch (endpoint.path, index) { + case ("/things", 0): throw unauthorizedError() + case ("/things", _): return Data("ok".utf8) + default: throw unauthorizedError() // reissue rejected — refresh is dead + } + }, + issuer: StubIssuer(result: .success(issued)) + ) + + let result = try await sut.data(for: TestEndpoint()) + + #expect(result == Data("ok".utf8)) + #expect(issuer.issueCallCount == 1) + #expect(network.recordedCalls(to: "/things").last?.headers["Authorization"] == "Bearer ISSUED") + } + + @Test + func dataFor_non401Status_propagatesWithoutRecovery() async throws { + try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) + let (sut, network, _) = makeSUT(handler: { _, _ in + throw NetworkError.unacceptableStatus(code: 500, data: Data()) + }) + + await #expect(throws: NetworkError.self) { + try await sut.data(for: TestEndpoint()) + } + + #expect(network.recordedCalls(to: "/things").count == 1) + #expect(network.recordedCalls(to: "/auth/reissue").isEmpty) + } + + @Test + func request_on401_refreshesRetriesAndDecodes() async throws { + struct Payload: Decodable, Equatable, Sendable { + let value: String + } + try tokenStore.save(TokenPair(accessToken: "OLD", refreshToken: "R")) + let (sut, network, _) = makeSUT(handler: { endpoint, index in + switch (endpoint.path, index) { + case ("/things", 0): throw unauthorizedError() + case ("/things", _): return Data(#"{"value": "hi"}"#.utf8) + default: return reissueSuccessBody(access: "NEW", refresh: "R2") + } + }) + + let payload = try await sut.request(TestEndpoint(), as: Payload.self) + + #expect(payload == Payload(value: "hi")) + #expect(network.recordedCalls(to: "/things").count == 2) + } + + @Test + func dataFor_endpointWithCustomHeaders_preservesThemAndAddsBearer() async throws { + try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) + let (sut, network, _) = makeSUT(handler: { _, _ in Data() }) + + _ = try await sut.data(for: TestEndpoint(headers: ["X-Custom": "1"])) + + let headers = network.recorded.first?.headers + #expect(headers?["X-Custom"] == "1") + #expect(headers?["Authorization"] == "Bearer A") + } + + /// Master prompt: "동시 다발 401에도 리프레시는 1회" — concurrent 401s share + /// one recovery. + @Test + func dataFor_concurrent401s_triggersSingleRefresh() async throws { + try tokenStore.save(TokenPair(accessToken: "OLD", refreshToken: "R")) + let reissueReached = AsyncGate() + let reissueRelease = AsyncGate() + let (sut, network, _) = makeSUT(handler: { endpoint, _ in + if endpoint.path == "/auth/reissue" { + reissueReached.open() + await reissueRelease.wait() + return reissueSuccessBody(access: "NEW", refresh: "R2") + } + // Old token → 401; refreshed token → success. + if endpoint.headers["Authorization"] == "Bearer OLD" { throw unauthorizedError() } + return Data("ok".utf8) + }) + + async let first = sut.data(for: TestEndpoint()) + async let second = sut.data(for: TestEndpoint()) + async let third = sut.data(for: TestEndpoint()) + await reissueReached.wait() + // The recovery is parked at the gate; give the remaining 401 callers + // time to join it before releasing. + try await Task.sleep(for: .milliseconds(50)) + reissueRelease.open() + + let results = try await [first, second, third] + + #expect(results.allSatisfy { $0 == Data("ok".utf8) }) + #expect(network.recordedCalls(to: "/auth/reissue").count == 1) + } +} diff --git a/Projects/Core/Auth/Tests/TokenStoreTests.swift b/Projects/Core/Auth/Tests/TokenStoreTests.swift new file mode 100644 index 00000000..fcad8c5e --- /dev/null +++ b/Projects/Core/Auth/Tests/TokenStoreTests.swift @@ -0,0 +1,49 @@ +import CoreAuth +import Foundation +import Testing + +struct TokenStoreTests { + private let backing = InMemoryKeyValueStore() + private var store: TokenStore { TokenStore(store: backing) } + + @Test + func save_thenRead_returnsBothTokens() throws { + try store.save(TokenPair(accessToken: "A", refreshToken: "R")) + + #expect(try store.accessToken() == "A") + #expect(try store.refreshToken() == "R") + } + + @Test + func accessToken_emptyStore_returnsNil() throws { + #expect(try store.accessToken() == nil) + #expect(try store.refreshToken() == nil) + } + + @Test + func save_overwrite_rotatesBothTokens() throws { + try store.save(TokenPair(accessToken: "A1", refreshToken: "R1")) + try store.save(TokenPair(accessToken: "A2", refreshToken: "R2")) + + #expect(try store.accessToken() == "A2") + #expect(try store.refreshToken() == "R2") + } + + @Test + func clear_thenRead_returnsNil() throws { + try store.save(TokenPair(accessToken: "A", refreshToken: "R")) + try store.clear() + + #expect(try store.accessToken() == nil) + #expect(try store.refreshToken() == nil) + } + + /// Pins the stored contract: legacy key names, raw UTF-8 values. + @Test + func save_usesLegacyKeychainKeys() throws { + try store.save(TokenPair(accessToken: "A", refreshToken: "R")) + + #expect(try backing.data(forKey: "accessToken") == Data("A".utf8)) + #expect(try backing.data(forKey: "refreshToken") == Data("R".utf8)) + } +} diff --git a/Projects/Core/Storage/Project.swift b/Projects/Core/Storage/Project.swift new file mode 100644 index 00000000..fb939fbb --- /dev/null +++ b/Projects/Core/Storage/Project.swift @@ -0,0 +1,4 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +let project = Project.layer(name: "CoreStorage", bundleSuffix: "core.storage", isolation: .nonisolated) diff --git a/Projects/Core/Storage/Sources/KeyValueStore+Codable.swift b/Projects/Core/Storage/Sources/KeyValueStore+Codable.swift new file mode 100644 index 00000000..9247481d --- /dev/null +++ b/Projects/Core/Storage/Sources/KeyValueStore+Codable.swift @@ -0,0 +1,14 @@ +import Foundation + +public extension KeyValueStore { + /// 부재 → nil. 저장된 데이터가 디코딩 불가면 throw (숨기지 않는다 — 무시 정책은 호출자 몫). + // JSONDecoder/JSONEncoder는 non-Sendable — 공유하지 않고 호출마다 새로 만든다. + func value(_ type: T.Type = T.self, forKey key: String) throws -> T? { + guard let data = try data(forKey: key) else { return nil } + return try JSONDecoder().decode(T.self, from: data) + } + + func setValue(_ value: T, forKey key: String) throws { + try set(JSONEncoder().encode(value), forKey: key) + } +} diff --git a/Projects/Core/Storage/Sources/KeyValueStore.swift b/Projects/Core/Storage/Sources/KeyValueStore.swift new file mode 100644 index 00000000..dbf9912f --- /dev/null +++ b/Projects/Core/Storage/Sources/KeyValueStore.swift @@ -0,0 +1,8 @@ +import Foundation + +/// Data 단위 key-value 저장 추상화. `nil` = 값 부재, `throw` = 실제 저장소 실패. +public protocol KeyValueStore: Sendable { + func data(forKey key: String) throws -> Data? + func set(_ data: Data, forKey key: String) throws + func removeValue(forKey key: String) throws +} diff --git a/Projects/Core/Storage/Sources/KeychainStore.swift b/Projects/Core/Storage/Sources/KeychainStore.swift new file mode 100644 index 00000000..e5a251bc --- /dev/null +++ b/Projects/Core/Storage/Sources/KeychainStore.swift @@ -0,0 +1,76 @@ +import Foundation +import Security +import Synchronization + +public enum KeychainError: Error, Equatable, Sendable { + case unexpectedStatus(OSStatus) +} + +/// 레거시 TokenStorage의 키체인+메모리 캐시 의미를 프로토콜 기반으로 재작성. +/// 캐시는 존재값만 보관하며 Mutex로 동기화한다 (레거시는 unsynchronized라 Swift 6 불가). +public final class KeychainStore: KeyValueStore { + private let service: String + private let cache = Mutex<[String: Data]>([:]) + + public init(service: String = "com.atcha.iOS.v2") { + self.service = service + } + + public func data(forKey key: String) throws -> Data? { + if let cached = cache.withLock({ $0[key] }) { + return cached + } + var query = baseQuery(forKey: key) + query[kSecReturnData as String] = kCFBooleanTrue + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + switch status { + case errSecSuccess: + guard let data = result as? Data else { return nil } + cache.withLock { $0[key] = data } + return data + case errSecItemNotFound: + return nil + default: + throw KeychainError.unexpectedStatus(status) + } + } + + public func set(_ data: Data, forKey key: String) throws { + var addQuery = baseQuery(forKey: key) + addQuery[kSecValueData as String] = data + addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock + + // 레거시의 delete-then-add는 비원자적 — add 후 duplicate면 update로 대체한다. + var status = SecItemAdd(addQuery as CFDictionary, nil) + if status == errSecDuplicateItem { + let attributes: [String: Any] = [ + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, + ] + status = SecItemUpdate(baseQuery(forKey: key) as CFDictionary, attributes as CFDictionary) + } + guard status == errSecSuccess else { + throw KeychainError.unexpectedStatus(status) + } + cache.withLock { $0[key] = data } + } + + public func removeValue(forKey key: String) throws { + let status = SecItemDelete(baseQuery(forKey: key) as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeychainError.unexpectedStatus(status) + } + cache.withLock { $0[key] = nil } + } + + private func baseQuery(forKey key: String) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + ] + } +} diff --git a/Projects/Core/Storage/Sources/UserDefaultsKeyValueStore.swift b/Projects/Core/Storage/Sources/UserDefaultsKeyValueStore.swift new file mode 100644 index 00000000..2b2dd77e --- /dev/null +++ b/Projects/Core/Storage/Sources/UserDefaultsKeyValueStore.swift @@ -0,0 +1,22 @@ +import Foundation + +// UserDefaults는 문서화된 thread-safe지만 SDK가 Sendable로 표기하지 않아 @unchecked가 필요하다. +public final class UserDefaultsKeyValueStore: KeyValueStore, @unchecked Sendable { + private let defaults: UserDefaults + + public init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + public func data(forKey key: String) throws -> Data? { + defaults.data(forKey: key) + } + + public func set(_ data: Data, forKey key: String) throws { + defaults.set(data, forKey: key) + } + + public func removeValue(forKey key: String) throws { + defaults.removeObject(forKey: key) + } +} diff --git a/Projects/Core/Storage/Tests/KeyValueStoreCodableTests.swift b/Projects/Core/Storage/Tests/KeyValueStoreCodableTests.swift new file mode 100644 index 00000000..6f76c613 --- /dev/null +++ b/Projects/Core/Storage/Tests/KeyValueStoreCodableTests.swift @@ -0,0 +1,63 @@ +@testable import CoreStorage +import Foundation +import Synchronization +import Testing + +private final class InMemoryKeyValueStore: KeyValueStore { + private let storage = Mutex<[String: Data]>([:]) + + func data(forKey key: String) throws -> Data? { + storage.withLock { $0[key] } + } + + func set(_ data: Data, forKey key: String) throws { + storage.withLock { $0[key] = data } + } + + func removeValue(forKey key: String) throws { + storage.withLock { $0[key] = nil } + } +} + +private struct Token: Codable, Equatable { + let value: String + let expiresAt: Int +} + +struct KeyValueStoreCodableTests { + private let store = InMemoryKeyValueStore() + + @Test + func setValue_thenValue_roundTripsCodable() throws { + let token = Token(value: "abc", expiresAt: 123) + try store.setValue(token, forKey: "token") + #expect(try store.value(Token.self, forKey: "token") == token) + } + + @Test + func value_missingKey_returnsNil() throws { + #expect(try store.value(Token.self, forKey: "missing") == nil) + } + + @Test + func value_corruptData_throwsDecodingError() throws { + try store.set(Data("not json".utf8), forKey: "token") + #expect(throws: DecodingError.self) { + try store.value(Token.self, forKey: "token") + } + } + + @Test + func setValue_overwrite_replacesPreviousValue() throws { + try store.setValue(Token(value: "old", expiresAt: 1), forKey: "token") + try store.setValue(Token(value: "new", expiresAt: 2), forKey: "token") + #expect(try store.value(Token.self, forKey: "token") == Token(value: "new", expiresAt: 2)) + } + + @Test + func removeValue_thenValue_returnsNil() throws { + try store.setValue(Token(value: "abc", expiresAt: 1), forKey: "token") + try store.removeValue(forKey: "token") + #expect(try store.value(Token.self, forKey: "token") == nil) + } +} diff --git a/Projects/Data/Project.swift b/Projects/Data/Project.swift index c1f0483d..b0eb24e6 100644 --- a/Projects/Data/Project.swift +++ b/Projects/Data/Project.swift @@ -9,5 +9,6 @@ let project = Project.layer( dependencies: [ .project(target: "Domain", path: "../Domain"), .project(target: "CoreNetwork", path: "../Core/Network"), + .project(target: "CoreStorage", path: "../Core/Storage"), ] ) diff --git a/Projects/Data/Sources/DTO/AlarmRefreshResponseDTO.swift b/Projects/Data/Sources/DTO/AlarmRefreshResponseDTO.swift new file mode 100644 index 00000000..626862e4 --- /dev/null +++ b/Projects/Data/Sources/DTO/AlarmRefreshResponseDTO.swift @@ -0,0 +1,20 @@ +import Domain +import Foundation + +public struct AlarmRefreshResponseDTO: Decodable, Sendable { + public let departureTime: String? + public let updatedAt: String? + public let lastRouteId: String? + // 서버가 Bool이 아니라 "true"/"false" 문자열로 준다 (레거시 실측). + public let isReal: String? + + public func toEntity() -> AlarmInfo? { + guard let lastRouteId else { return nil } + return AlarmInfo( + lastRouteId: lastRouteId, + departureTime: departureTime.flatMap { ServerDateParser.date(from: $0) }, + updatedAt: updatedAt.flatMap { ServerDateParser.date(from: $0) }, + isReal: isReal == "true" + ) + } +} diff --git a/Projects/Data/Sources/DTO/AlarmRegisterRequestDTO.swift b/Projects/Data/Sources/DTO/AlarmRegisterRequestDTO.swift new file mode 100644 index 00000000..ac599556 --- /dev/null +++ b/Projects/Data/Sources/DTO/AlarmRegisterRequestDTO.swift @@ -0,0 +1,7 @@ +public struct AlarmRegisterRequestDTO: Encodable, Sendable { + public let lastRouteId: String + + public init(lastRouteId: String) { + self.lastRouteId = lastRouteId + } +} diff --git a/Projects/Data/Sources/DTO/LastRouteResponseDTO.swift b/Projects/Data/Sources/DTO/LastRouteResponseDTO.swift new file mode 100644 index 00000000..95608316 --- /dev/null +++ b/Projects/Data/Sources/DTO/LastRouteResponseDTO.swift @@ -0,0 +1,87 @@ +import Domain +import Foundation + +public struct LastRouteResponseDTO: Decodable, Sendable { + public let routeId: String? + public let departureDateTime: String? + public let totalTime: Int? + public let totalWalkTime: Int? + public let transferCount: Int? + public let totalDistance: Int? + public let totalWalkDistance: Int? + public let legs: [LegResponseDTO]? + + /// routeId·막차 출발 시각이 없는 항목은 세울 수 없어 nil을 돌려준다 (호출부 compactMap). + public func toEntity() -> LastRoute? { + guard let routeId, + let departureDateTime, + let departureTime = ServerDateParser.date(from: departureDateTime) + else { return nil } + return LastRoute( + id: routeId, + departureTime: departureTime, + totalTime: totalTime ?? 0, + totalWalkTime: totalWalkTime ?? 0, + transferCount: transferCount ?? 0, + totalDistance: totalDistance ?? 0, + totalWalkDistance: totalWalkDistance ?? 0, + legs: legs?.map { $0.toEntity() } ?? [] + ) + } +} + +// 지도 표시 전용 필드(passStopList/step/passShape 등)는 2.0 스코프에 없어 디코딩하지 않는다. +public struct LegResponseDTO: Decodable, Sendable { + public let distance: Int? + public let sectionTime: Int? + // 레거시는 enum으로 받아 미지의 mode 문자열에서 디코딩이 통째로 실패했다 — String으로 받고 매핑한다. + public let mode: String? + public let departureDateTime: String? + public let route: String? + public let type: String? + public let start: RoutePointResponseDTO? + public let end: RoutePointResponseDTO? + public let subwayFinalStation: String? + public let subwayDirection: String? + public let isExpressSubway: Bool? + public let isLastSubway: Bool? + + public func toEntity() -> TransportLeg { + TransportLeg( + mode: TransportMode(serverValue: mode), + sectionTime: sectionTime ?? 0, + distance: distance ?? 0, + departureTime: departureDateTime.flatMap { ServerDateParser.date(from: $0) }, + routeName: route, + lineType: type, + start: start?.toEntity(), + end: end?.toEntity(), + subwayFinalStation: subwayFinalStation, + subwayDirection: subwayDirection, + isExpressSubway: isExpressSubway ?? false, + isLastSubway: isLastSubway ?? false + ) + } +} + +public struct RoutePointResponseDTO: Decodable, Sendable { + public let name: String? + public let lon: Double? + public let lat: Double? + + public func toEntity() -> RoutePoint? { + guard let name, let lat, let lon else { return nil } + return RoutePoint(name: name, coordinate: Coordinate(latitude: lat, longitude: lon)) + } +} + +private extension TransportMode { + init(serverValue: String?) { + switch serverValue { + case "WALK": self = .walk + case "BUS": self = .bus + case "SUBWAY": self = .subway + default: self = .unknown + } + } +} diff --git a/Projects/Data/Sources/DTO/PlaceResponseDTO.swift b/Projects/Data/Sources/DTO/PlaceResponseDTO.swift new file mode 100644 index 00000000..e213b6cd --- /dev/null +++ b/Projects/Data/Sources/DTO/PlaceResponseDTO.swift @@ -0,0 +1,16 @@ +import Domain + +public struct PlaceResponseDTO: Decodable, Sendable { + public let name: String? + public let lat: Double? + public let lon: Double? + public let businessCategory: String? + public let address: String? + public let radius: String? + + /// 레거시는 6필드 전부 non-nil이어야 항목을 살렸지만, 이름·좌표만 있으면 표시엔 충분하다. + public func toEntity() -> Place? { + guard let name, let lat, let lon else { return nil } + return Place(name: name, address: address ?? "", coordinate: Coordinate(latitude: lat, longitude: lon)) + } +} diff --git a/Projects/Data/Sources/DTO/RecentSearchRecordDTO.swift b/Projects/Data/Sources/DTO/RecentSearchRecordDTO.swift new file mode 100644 index 00000000..bc6b2317 --- /dev/null +++ b/Projects/Data/Sources/DTO/RecentSearchRecordDTO.swift @@ -0,0 +1,20 @@ +import Domain + +/// 최근 검색 로컬 저장용 레코드 — Domain 엔티티는 Codable을 채택하지 않으므로 여기서 변환한다. +public struct RecentSearchRecordDTO: Codable, Equatable, Sendable { + public let name: String + public let address: String + public let latitude: Double + public let longitude: Double + + public init(_ place: Place) { + name = place.name + address = place.address + latitude = place.coordinate.latitude + longitude = place.coordinate.longitude + } + + public func toEntity() -> Place { + Place(name: name, address: address, coordinate: Coordinate(latitude: latitude, longitude: longitude)) + } +} diff --git a/Projects/Data/Sources/DTO/ReverseGeocodeResponseDTO.swift b/Projects/Data/Sources/DTO/ReverseGeocodeResponseDTO.swift new file mode 100644 index 00000000..a1aa2a6c --- /dev/null +++ b/Projects/Data/Sources/DTO/ReverseGeocodeResponseDTO.swift @@ -0,0 +1,13 @@ +import Domain + +public struct ReverseGeocodeResponseDTO: Decodable, Sendable { + public let name: String? + public let address: String? + public let lat: Double? + public let lon: Double? + + public func toEntity() -> Place? { + guard let name, let lat, let lon else { return nil } + return Place(name: name, address: address ?? "", coordinate: Coordinate(latitude: lat, longitude: lon)) + } +} diff --git a/Projects/Data/Sources/Network/APIResponse.swift b/Projects/Data/Sources/Network/APIResponse.swift new file mode 100644 index 00000000..d2a747f0 --- /dev/null +++ b/Projects/Data/Sources/Network/APIResponse.swift @@ -0,0 +1,17 @@ +// 레거시 서버 envelope 실측: { "responseCode": "SUCCESS", "result": ... } +struct APIResponse: Decodable, Sendable { + let responseCode: String + let result: T? +} + +/// result가 없는(무시되는) 응답을 기대할 때 쓰는 자리표시 타입. +struct APIEmptyResult: Decodable, Sendable {} + +/// 실패 응답 본문 실측: { "responseCode": ..., "message": ..., "path": ... } +struct APIFailureResponse: Decodable, Sendable { + let responseCode: String? + let message: String? +} + +/// envelope는 성공인데 기대한 result가 없거나 엔티티로 세울 수 없을 때. +struct MissingResultError: Error, Sendable {} diff --git a/Projects/Data/Sources/Network/AlarmEndpoint.swift b/Projects/Data/Sources/Network/AlarmEndpoint.swift new file mode 100644 index 00000000..9f2ce97e --- /dev/null +++ b/Projects/Data/Sources/Network/AlarmEndpoint.swift @@ -0,0 +1,46 @@ +import CoreNetwork +import Foundation + +enum AlarmEndpoint: Endpoint { + case register(AlarmRegisterRequestDTO) + case cancel(lastRouteId: String) + case refresh + + var path: String { + switch self { + case .register, .cancel: "/routes/user-routes" + case .refresh: "/routes/user-routes/refresh" + } + } + + var method: HTTPMethod { + switch self { + case .register: .post + case .cancel: .delete + case .refresh: .get + } + } + + var headers: [String: String] { + switch self { + case .register: ["Content-Type": "application/json"] + case .cancel, .refresh: [:] + } + } + + var queryItems: [URLQueryItem] { + switch self { + // 삭제는 body가 아니라 쿼리로 lastRouteId를 받는다 (레거시 실측). + case let .cancel(lastRouteId): + [URLQueryItem(name: "lastRouteId", value: lastRouteId)] + case .register, .refresh: [] + } + } + + var body: Data? { + switch self { + case let .register(request): try? JSONEncoder().encode(request) + case .cancel, .refresh: nil + } + } +} diff --git a/Projects/Data/Sources/Network/NetworkClient+Envelope.swift b/Projects/Data/Sources/Network/NetworkClient+Envelope.swift new file mode 100644 index 00000000..991d0e8e --- /dev/null +++ b/Projects/Data/Sources/Network/NetworkClient+Envelope.swift @@ -0,0 +1,46 @@ +import CoreNetwork +import Domain +import Foundation + +private let successResponseCode = "SUCCESS" + +extension NetworkClient { + /// envelope를 해체해 result만 돌려준다. responseCode ≠ SUCCESS면 `ServerError`. + func requestEnveloped( + _ endpoint: any Endpoint, + as _: T.Type = T.self + ) async throws -> T { + let data: Data + do { + data = try await self.data(for: endpoint) + } catch let error as NetworkError { + // 비즈니스 에러 코드는 non-2xx HTTP의 body envelope로 온다 (레거시 실측). + throw serverError(from: error) ?? error + } + + let envelope: APIResponse + do { + envelope = try JSONDecoder().decode(APIResponse.self, from: data) + } catch { + // 레거시 규약: 2xx + 빈 응답 기대(T == APIEmptyResult)면 본문 형태와 무관하게 성공. + if let empty = APIEmptyResult() as? T { return empty } + throw NetworkError.decoding(underlying: error) + } + + guard envelope.responseCode == successResponseCode else { + let message = (try? JSONDecoder().decode(APIFailureResponse.self, from: data))?.message + throw ServerError(code: envelope.responseCode, message: message) + } + if let result = envelope.result { return result } + if let empty = APIEmptyResult() as? T { return empty } + throw NetworkError.decoding(underlying: MissingResultError()) + } +} + +private func serverError(from error: NetworkError) -> ServerError? { + guard case let .unacceptableStatus(_, data) = error, + let failure = try? JSONDecoder().decode(APIFailureResponse.self, from: data), + let code = failure.responseCode + else { return nil } + return ServerError(code: code, message: failure.message) +} diff --git a/Projects/Data/Sources/Network/PlaceEndpoint.swift b/Projects/Data/Sources/Network/PlaceEndpoint.swift new file mode 100644 index 00000000..443fa780 --- /dev/null +++ b/Projects/Data/Sources/Network/PlaceEndpoint.swift @@ -0,0 +1,38 @@ +import CoreNetwork +import Domain +import Foundation + +enum PlaceEndpoint: Endpoint { + case search(keyword: String, near: Coordinate?) + case reverseGeocode(Coordinate) + + var path: String { + switch self { + case .search: "/locations" + case .reverseGeocode: "/locations/rgeo" + } + } + + var method: HTTPMethod { + switch self { + case .search, .reverseGeocode: .get + } + } + + var queryItems: [URLQueryItem] { + switch self { + case let .search(keyword, near): + // 좌표 미지정 시 0.0 전송은 레거시 실측 규약. + [ + URLQueryItem(name: "keyword", value: keyword), + URLQueryItem(name: "lat", value: String(near?.latitude ?? 0.0)), + URLQueryItem(name: "lon", value: String(near?.longitude ?? 0.0)), + ] + case let .reverseGeocode(coordinate): + [ + URLQueryItem(name: "lat", value: String(coordinate.latitude)), + URLQueryItem(name: "lon", value: String(coordinate.longitude)), + ] + } + } +} diff --git a/Projects/Data/Sources/Network/RouteEndpoint.swift b/Projects/Data/Sources/Network/RouteEndpoint.swift new file mode 100644 index 00000000..16b3d087 --- /dev/null +++ b/Projects/Data/Sources/Network/RouteEndpoint.swift @@ -0,0 +1,34 @@ +import CoreNetwork +import Domain +import Foundation + +enum RouteEndpoint: Endpoint { + case search(start: Coordinate, end: Coordinate) + case detail(routeId: String) + + var path: String { + switch self { + case .search: "/routes/last-routes" + case let .detail(routeId): "/routes/last-routes/\(routeId)" + } + } + + var method: HTTPMethod { + switch self { + case .search, .detail: .get + } + } + + var queryItems: [URLQueryItem] { + switch self { + case let .search(start, end): + [ + URLQueryItem(name: "startLat", value: String(start.latitude)), + URLQueryItem(name: "startLon", value: String(start.longitude)), + URLQueryItem(name: "endLat", value: String(end.latitude)), + URLQueryItem(name: "endLon", value: String(end.longitude)), + ] + case .detail: [] + } + } +} diff --git a/Projects/Data/Sources/Network/ServerDateParser.swift b/Projects/Data/Sources/Network/ServerDateParser.swift new file mode 100644 index 00000000..01a5700d --- /dev/null +++ b/Projects/Data/Sources/Network/ServerDateParser.swift @@ -0,0 +1,18 @@ +import Foundation + +/// 서버 시각은 타임존 표기 없는 KST 문자열이다 (실측: "yyyy-MM-dd'T'HH:mm:ss"). +enum ServerDateParser { + static func date(from string: String) -> Date? { + // DateFormatter는 Sendable이 아니므로 호출마다 새로 만든다. + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "Asia/Seoul") + for format in ["yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm"] { + formatter.dateFormat = format + if let date = formatter.date(from: string) { + return date + } + } + return nil + } +} diff --git a/Projects/Data/Sources/Repositories/AlarmRepositoryImpl.swift b/Projects/Data/Sources/Repositories/AlarmRepositoryImpl.swift new file mode 100644 index 00000000..b6032100 --- /dev/null +++ b/Projects/Data/Sources/Repositories/AlarmRepositoryImpl.swift @@ -0,0 +1,30 @@ +import CoreNetwork +import Domain + +public struct AlarmRepositoryImpl: AlarmRepository { + private let networkClient: any NetworkClient + + public init(networkClient: any NetworkClient) { + self.networkClient = networkClient + } + + public func register(lastRouteId: String) async throws { + let _: APIEmptyResult = try await networkClient.requestEnveloped( + AlarmEndpoint.register(AlarmRegisterRequestDTO(lastRouteId: lastRouteId)) + ) + } + + public func cancel(lastRouteId: String) async throws { + let _: APIEmptyResult = try await networkClient.requestEnveloped( + AlarmEndpoint.cancel(lastRouteId: lastRouteId) + ) + } + + public func refresh() async throws -> AlarmInfo { + let dto: AlarmRefreshResponseDTO = try await networkClient.requestEnveloped(AlarmEndpoint.refresh) + guard let info = dto.toEntity() else { + throw NetworkError.decoding(underlying: MissingResultError()) + } + return info + } +} diff --git a/Projects/Data/Sources/Repositories/LastRouteRepositoryImpl.swift b/Projects/Data/Sources/Repositories/LastRouteRepositoryImpl.swift new file mode 100644 index 00000000..21d605bb --- /dev/null +++ b/Projects/Data/Sources/Repositories/LastRouteRepositoryImpl.swift @@ -0,0 +1,27 @@ +import CoreNetwork +import Domain + +public struct LastRouteRepositoryImpl: LastRouteRepository { + private let networkClient: any NetworkClient + + public init(networkClient: any NetworkClient) { + self.networkClient = networkClient + } + + public func searchLastRoutes(start: Coordinate, end: Coordinate) async throws -> [LastRoute] { + let dtos: [LastRouteResponseDTO] = try await networkClient.requestEnveloped( + RouteEndpoint.search(start: start, end: end) + ) + return dtos.compactMap { $0.toEntity() } + } + + public func lastRoute(id: String) async throws -> LastRoute { + let dto: LastRouteResponseDTO = try await networkClient.requestEnveloped( + RouteEndpoint.detail(routeId: id) + ) + guard let route = dto.toEntity() else { + throw NetworkError.decoding(underlying: MissingResultError()) + } + return route + } +} diff --git a/Projects/Data/Sources/Repositories/PlaceRepositoryImpl.swift b/Projects/Data/Sources/Repositories/PlaceRepositoryImpl.swift new file mode 100644 index 00000000..62d8d845 --- /dev/null +++ b/Projects/Data/Sources/Repositories/PlaceRepositoryImpl.swift @@ -0,0 +1,27 @@ +import CoreNetwork +import Domain + +public struct PlaceRepositoryImpl: PlaceRepository { + private let networkClient: any NetworkClient + + public init(networkClient: any NetworkClient) { + self.networkClient = networkClient + } + + public func searchPlaces(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { + let dtos: [PlaceResponseDTO] = try await networkClient.requestEnveloped( + PlaceEndpoint.search(keyword: keyword, near: coordinate) + ) + return dtos.compactMap { $0.toEntity() } + } + + public func reverseGeocode(_ coordinate: Coordinate) async throws -> Place { + let dto: ReverseGeocodeResponseDTO = try await networkClient.requestEnveloped( + PlaceEndpoint.reverseGeocode(coordinate) + ) + guard let place = dto.toEntity() else { + throw NetworkError.decoding(underlying: MissingResultError()) + } + return place + } +} diff --git a/Projects/Data/Sources/Repositories/RecentSearchRepositoryImpl.swift b/Projects/Data/Sources/Repositories/RecentSearchRepositoryImpl.swift new file mode 100644 index 00000000..02c8379e --- /dev/null +++ b/Projects/Data/Sources/Repositories/RecentSearchRepositoryImpl.swift @@ -0,0 +1,35 @@ +import CoreStorage +import Domain + +public struct RecentSearchRepositoryImpl: RecentSearchRepository { + private let store: any KeyValueStore + private let maxCount: Int + private let storageKey = "recentSearches" + + public init(store: any KeyValueStore, maxCount: Int = 10) { + self.store = store + self.maxCount = maxCount + } + + public func recentSearches() async throws -> [Place] { + loadRecords().map { $0.toEntity() } + } + + public func save(_ place: Place) async throws { + var records = loadRecords() + records.removeAll { $0.toEntity() == place } + records.insert(RecentSearchRecordDTO(place), at: 0) + try store.setValue(Array(records.prefix(maxCount)), forKey: storageKey) + } + + public func remove(_ place: Place) async throws { + var records = loadRecords() + records.removeAll { $0.toEntity() == place } + try store.setValue(records, forKey: storageKey) + } + + /// 부재·손상 데이터는 빈 목록으로 — 일회성 캐시라 다음 save가 덮어써 자가 치유한다. + private func loadRecords() -> [RecentSearchRecordDTO] { + (try? store.value([RecentSearchRecordDTO].self, forKey: storageKey)) ?? [] + } +} diff --git a/Projects/Data/Tests/AlarmEndpointTests.swift b/Projects/Data/Tests/AlarmEndpointTests.swift new file mode 100644 index 00000000..f65db4fd --- /dev/null +++ b/Projects/Data/Tests/AlarmEndpointTests.swift @@ -0,0 +1,36 @@ +@testable import AtchaData +import CoreNetwork +import Foundation +import Testing + +struct AlarmEndpointTests { + @Test + func register_postsJSONBodyWithLastRouteId() throws { + let endpoint = AlarmEndpoint.register(AlarmRegisterRequestDTO(lastRouteId: "route-1")) + #expect(endpoint.path == "/routes/user-routes") + #expect(endpoint.method == .post) + #expect(endpoint.headers == ["Content-Type": "application/json"]) + #expect(endpoint.queryItems.isEmpty) + let body = try #require(endpoint.body) + let json = try JSONSerialization.jsonObject(with: body) as? [String: String] + #expect(json == ["lastRouteId": "route-1"]) + } + + @Test + func cancel_usesQueryNotBodyLikeLegacy() { + let endpoint = AlarmEndpoint.cancel(lastRouteId: "route-1") + #expect(endpoint.path == "/routes/user-routes") + #expect(endpoint.method == .delete) + #expect(endpoint.queryItems == [URLQueryItem(name: "lastRouteId", value: "route-1")]) + #expect(endpoint.body == nil) + } + + @Test + func refresh_getsRefreshPath() { + let endpoint = AlarmEndpoint.refresh + #expect(endpoint.path == "/routes/user-routes/refresh") + #expect(endpoint.method == .get) + #expect(endpoint.queryItems.isEmpty) + #expect(endpoint.body == nil) + } +} diff --git a/Projects/Data/Tests/AlarmRefreshResponseDTOTests.swift b/Projects/Data/Tests/AlarmRefreshResponseDTOTests.swift new file mode 100644 index 00000000..9a20ed4e --- /dev/null +++ b/Projects/Data/Tests/AlarmRefreshResponseDTOTests.swift @@ -0,0 +1,33 @@ +@testable import AtchaData +import Domain +import Foundation +import Testing + +struct AlarmRefreshResponseDTOTests { + @Test + func toEntity_mapsFieldsAndParsesIsRealString() throws { + let json = Data( + #"{"departureTime":"2026-08-22T23:40:00","updatedAt":"2026-08-22T22:00:00","lastRouteId":"route-1","isReal":"true"}"#.utf8 + ) + let dto = try JSONDecoder().decode(AlarmRefreshResponseDTO.self, from: json) + let info = try #require(dto.toEntity()) + #expect(info.lastRouteId == "route-1") + #expect(info.isReal) + #expect(info.departureTime != nil) + #expect(info.updatedAt != nil) + } + + @Test + func toEntity_isRealFalseOrMissing_mapsToFalse() throws { + for fixture in [#"{"lastRouteId":"r","isReal":"false"}"#, #"{"lastRouteId":"r"}"#] { + let dto = try JSONDecoder().decode(AlarmRefreshResponseDTO.self, from: Data(fixture.utf8)) + #expect(dto.toEntity()?.isReal == false) + } + } + + @Test + func toEntity_missingLastRouteId_returnsNil() throws { + let dto = try JSONDecoder().decode(AlarmRefreshResponseDTO.self, from: Data(#"{"isReal":"true"}"#.utf8)) + #expect(dto.toEntity() == nil) + } +} diff --git a/Projects/Data/Tests/EnvelopeTests.swift b/Projects/Data/Tests/EnvelopeTests.swift new file mode 100644 index 00000000..f86106dc --- /dev/null +++ b/Projects/Data/Tests/EnvelopeTests.swift @@ -0,0 +1,84 @@ +@testable import AtchaData +import CoreNetwork +import Domain +import Foundation +import Testing + +private struct PingEndpoint: Endpoint { + var path: String { "/ping" } + var method: HTTPMethod { .get } +} + +private struct StubNetworkClient: NetworkClient { + let result: Result + + func data(for endpoint: any Endpoint) async throws -> Data { + try result.get() + } + + func request( + _ endpoint: any Endpoint, + as _: Response.Type + ) async throws -> Response { + try JSONDecoder().decode(Response.self, from: result.get()) + } +} + +struct EnvelopeTests { + @Test + func requestEnveloped_success_unwrapsResult() async throws { + let body = Data(#"{"responseCode":"SUCCESS","result":{"lastRouteId":"route-1","isReal":"true"}}"#.utf8) + let client = StubNetworkClient(result: .success(body)) + let dto: AlarmRefreshResponseDTO = try await client.requestEnveloped(PingEndpoint()) + #expect(dto.lastRouteId == "route-1") + #expect(dto.isReal == "true") + } + + @Test + func requestEnveloped_nonSuccessCode_throwsServerErrorWithMessage() async { + let body = Data(#"{"responseCode":"LRT_001","message":"오늘 막차가 종료되었습니다","result":null}"#.utf8) + let client = StubNetworkClient(result: .success(body)) + await #expect(throws: ServerError(code: "LRT_001", message: "오늘 막차가 종료되었습니다")) { + let _: AlarmRefreshResponseDTO = try await client.requestEnveloped(PingEndpoint()) + } + } + + @Test + func requestEnveloped_nullResultForNonEmptyType_throws() async { + let body = Data(#"{"responseCode":"SUCCESS","result":null}"#.utf8) + let client = StubNetworkClient(result: .success(body)) + await #expect(throws: NetworkError.self) { + let _: AlarmRefreshResponseDTO = try await client.requestEnveloped(PingEndpoint()) + } + } + + @Test + func requestEnveloped_nullResultForEmptyType_succeeds() async throws { + let body = Data(#"{"responseCode":"SUCCESS"}"#.utf8) + let client = StubNetworkClient(result: .success(body)) + let _: APIEmptyResult = try await client.requestEnveloped(PingEndpoint()) + } + + @Test + func requestEnveloped_nonEnvelopeBodyForEmptyType_succeeds() async throws { + let client = StubNetworkClient(result: .success(Data())) + let _: APIEmptyResult = try await client.requestEnveloped(PingEndpoint()) + } + + @Test + func requestEnveloped_unacceptableStatusWithEnvelopeBody_throwsServerError() async { + let body = Data(#"{"responseCode":"URT_001","message":"등록된 경로가 없습니다","path":"/routes/user-routes/refresh"}"#.utf8) + let client = StubNetworkClient(result: .failure(.unacceptableStatus(code: 404, data: body))) + await #expect(throws: ServerError(code: "URT_001", message: "등록된 경로가 없습니다")) { + let _: AlarmRefreshResponseDTO = try await client.requestEnveloped(PingEndpoint()) + } + } + + @Test + func requestEnveloped_unacceptableStatusWithoutEnvelopeBody_rethrowsNetworkError() async { + let client = StubNetworkClient(result: .failure(.unacceptableStatus(code: 500, data: Data()))) + await #expect(throws: NetworkError.self) { + let _: AlarmRefreshResponseDTO = try await client.requestEnveloped(PingEndpoint()) + } + } +} diff --git a/Projects/Data/Tests/LastRouteResponseDTOTests.swift b/Projects/Data/Tests/LastRouteResponseDTOTests.swift new file mode 100644 index 00000000..98e0ff05 --- /dev/null +++ b/Projects/Data/Tests/LastRouteResponseDTOTests.swift @@ -0,0 +1,90 @@ +@testable import AtchaData +import Domain +import Foundation +import Testing + +struct LastRouteResponseDTOTests { + @Test + func toEntity_mapsLegacyShapedResponse() throws { + let json = Data(#""" + { + "routeId": "route-1", + "departureDateTime": "2026-08-22T23:40:00", + "totalTime": 2820, + "totalWalkTime": 600, + "transferCount": 1, + "totalDistance": 12000, + "totalWalkDistance": 800, + "pathType": 1, + "legs": [ + { + "distance": 300, + "sectionTime": 240, + "mode": "WALK", + "start": {"name": "강남역", "lon": 127.02761, "lat": 37.49794}, + "end": {"name": "서울역", "lon": 126.970833, "lat": 37.554722} + }, + { + "sectionTime": 1800, + "mode": "SUBWAY", + "departureDateTime": "2026-08-22T23:45:00", + "route": "수도권2호선", + "type": "2", + "subwayFinalStation": "성수", + "subwayDirection": "내선", + "isExpressSubway": false, + "isLastSubway": true + } + ] + } + """#.utf8) + + let dto = try JSONDecoder().decode(LastRouteResponseDTO.self, from: json) + let route = try #require(dto.toEntity()) + + #expect(route.id == "route-1") + #expect(route.departureTime == Self.kstDate(2026, 8, 22, 23, 40)) + #expect(route.totalTime == 2820) + #expect(route.totalWalkTime == 600) + #expect(route.transferCount == 1) + #expect(route.legs.count == 2) + #expect(route.legs[0].mode == .walk) + #expect(route.legs[0].start == RoutePoint( + name: "강남역", + coordinate: Coordinate(latitude: 37.49794, longitude: 127.02761) + )) + #expect(route.legs[1].mode == .subway) + #expect(route.legs[1].departureTime == Self.kstDate(2026, 8, 22, 23, 45)) + #expect(route.legs[1].routeName == "수도권2호선") + #expect(route.legs[1].subwayFinalStation == "성수") + #expect(route.legs[1].isLastSubway) + #expect(!route.legs[1].isExpressSubway) + } + + @Test + func toEntity_unknownMode_fallsBackToUnknown() throws { + let json = Data(#"{"routeId":"r","departureDateTime":"2026-08-22T23:40:00","legs":[{"mode":"TRAM"}]}"#.utf8) + let dto = try JSONDecoder().decode(LastRouteResponseDTO.self, from: json) + #expect(dto.toEntity()?.legs.first?.mode == .unknown) + } + + @Test + func toEntity_missingRouteId_returnsNil() throws { + let json = Data(#"{"departureDateTime":"2026-08-22T23:40:00"}"#.utf8) + let dto = try JSONDecoder().decode(LastRouteResponseDTO.self, from: json) + #expect(dto.toEntity() == nil) + } + + @Test + func toEntity_unparsableDepartureDateTime_returnsNil() throws { + let json = Data(#"{"routeId":"r","departureDateTime":"not-a-date"}"#.utf8) + let dto = try JSONDecoder().decode(LastRouteResponseDTO.self, from: json) + #expect(dto.toEntity() == nil) + } + + private static func kstDate(_ year: Int, _ month: Int, _ day: Int, _ hour: Int, _ minute: Int) -> Date { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "Asia/Seoul")! + return calendar.date(from: DateComponents(year: year, month: month, day: day, hour: hour, minute: minute))! + } +} diff --git a/Projects/Data/Tests/PlaceEndpointTests.swift b/Projects/Data/Tests/PlaceEndpointTests.swift new file mode 100644 index 00000000..0d5a1895 --- /dev/null +++ b/Projects/Data/Tests/PlaceEndpointTests.swift @@ -0,0 +1,43 @@ +@testable import AtchaData +import CoreNetwork +import Domain +import Foundation +import Testing + +struct PlaceEndpointTests { + @Test + func search_sendsKeywordAndCoordinate() { + let endpoint = PlaceEndpoint.search( + keyword: "홍대입구", + near: Coordinate(latitude: 37.556748, longitude: 126.923643) + ) + #expect(endpoint.path == "/locations") + #expect(endpoint.method == .get) + #expect(endpoint.queryItems == [ + URLQueryItem(name: "keyword", value: "홍대입구"), + URLQueryItem(name: "lat", value: "37.556748"), + URLQueryItem(name: "lon", value: "126.923643"), + ]) + } + + @Test + func search_withoutCoordinate_sendsZeroesLikeLegacy() { + let endpoint = PlaceEndpoint.search(keyword: "홍대입구", near: nil) + #expect(endpoint.queryItems == [ + URLQueryItem(name: "keyword", value: "홍대입구"), + URLQueryItem(name: "lat", value: "0.0"), + URLQueryItem(name: "lon", value: "0.0"), + ]) + } + + @Test + func reverseGeocode_composesQuery() { + let endpoint = PlaceEndpoint.reverseGeocode(Coordinate(latitude: 37.560908, longitude: 126.921537)) + #expect(endpoint.path == "/locations/rgeo") + #expect(endpoint.method == .get) + #expect(endpoint.queryItems == [ + URLQueryItem(name: "lat", value: "37.560908"), + URLQueryItem(name: "lon", value: "126.921537"), + ]) + } +} diff --git a/Projects/Data/Tests/PlaceResponseDTOTests.swift b/Projects/Data/Tests/PlaceResponseDTOTests.swift new file mode 100644 index 00000000..e088d143 --- /dev/null +++ b/Projects/Data/Tests/PlaceResponseDTOTests.swift @@ -0,0 +1,37 @@ +@testable import AtchaData +import Domain +import Foundation +import Testing + +struct PlaceResponseDTOTests { + @Test + func toEntity_mapsNameAddressAndCoordinate() throws { + let json = Data( + #"{"name":"홍대입구역","lat":37.556748,"lon":126.923643,"businessCategory":"지하철역","address":"서울 마포구","radius":"500"}"#.utf8 + ) + let dto = try JSONDecoder().decode(PlaceResponseDTO.self, from: json) + #expect(dto.toEntity() == Place( + name: "홍대입구역", + address: "서울 마포구", + coordinate: Coordinate(latitude: 37.556748, longitude: 126.923643) + )) + } + + @Test + func toEntity_missingOptionalMetadata_stillReturnsPlace() throws { + let json = Data(#"{"name":"홍대입구역","lat":37.556748,"lon":126.923643}"#.utf8) + let dto = try JSONDecoder().decode(PlaceResponseDTO.self, from: json) + #expect(dto.toEntity() == Place( + name: "홍대입구역", + address: "", + coordinate: Coordinate(latitude: 37.556748, longitude: 126.923643) + )) + } + + @Test + func toEntity_missingCoordinate_returnsNil() throws { + let json = Data(#"{"name":"홍대입구역"}"#.utf8) + let dto = try JSONDecoder().decode(PlaceResponseDTO.self, from: json) + #expect(dto.toEntity() == nil) + } +} diff --git a/Projects/Data/Tests/RecentSearchRecordDTOTests.swift b/Projects/Data/Tests/RecentSearchRecordDTOTests.swift new file mode 100644 index 00000000..f6d8e925 --- /dev/null +++ b/Projects/Data/Tests/RecentSearchRecordDTOTests.swift @@ -0,0 +1,31 @@ +@testable import AtchaData +import Domain +import Foundation +import Testing + +struct RecentSearchRecordDTOTests { + @Test + func decode_inlineJSON_mapsFields() throws { + let json = Data( + #"{"name":"홍대입구역","address":"서울 마포구","latitude":37.556748,"longitude":126.923643}"#.utf8 + ) + let dto = try JSONDecoder().decode(RecentSearchRecordDTO.self, from: json) + #expect(dto.toEntity() == Place( + name: "홍대입구역", + address: "서울 마포구", + coordinate: Coordinate(latitude: 37.556748, longitude: 126.923643) + )) + } + + @Test + func roundTrip_placeToRecordToEntity_preservesFields() throws { + let place = Place( + name: "서울역", + address: "서울 용산구", + coordinate: Coordinate(latitude: 37.554722, longitude: 126.970833) + ) + let encoded = try JSONEncoder().encode(RecentSearchRecordDTO(place)) + let decoded = try JSONDecoder().decode(RecentSearchRecordDTO.self, from: encoded) + #expect(decoded.toEntity() == place) + } +} diff --git a/Projects/Data/Tests/RecentSearchRepositoryImplTests.swift b/Projects/Data/Tests/RecentSearchRepositoryImplTests.swift new file mode 100644 index 00000000..cd89dd53 --- /dev/null +++ b/Projects/Data/Tests/RecentSearchRepositoryImplTests.swift @@ -0,0 +1,104 @@ +@testable import AtchaData +import CoreStorage +import Domain +import Foundation +import Synchronization +import Testing + +private final class InMemoryKeyValueStore: KeyValueStore { + private let storage = Mutex<[String: Data]>([:]) + + func data(forKey key: String) throws -> Data? { + storage.withLock { $0[key] } + } + + func set(_ data: Data, forKey key: String) throws { + storage.withLock { $0[key] = data } + } + + func removeValue(forKey key: String) throws { + storage.withLock { $0[key] = nil } + } +} + +private extension Place { + static func fixture( + name: String = "홍대입구역", + address: String = "서울 마포구", + latitude: Double = 37.556748, + longitude: Double = 126.923643 + ) -> Place { + Place(name: name, address: address, coordinate: Coordinate(latitude: latitude, longitude: longitude)) + } +} + +struct RecentSearchRepositoryImplTests { + private let store = InMemoryKeyValueStore() + private var sut: RecentSearchRepositoryImpl { RecentSearchRepositoryImpl(store: store) } + + @Test + func recentSearches_emptyStore_returnsEmpty() async throws { + #expect(try await sut.recentSearches() == []) + } + + @Test + func save_thenFetch_returnsPlace() async throws { + try await sut.save(.fixture()) + #expect(try await sut.recentSearches() == [.fixture()]) + } + + @Test + func save_ordersNewestFirst() async throws { + try await sut.save(.fixture(name: "첫번째")) + try await sut.save(.fixture(name: "두번째")) + try await sut.save(.fixture(name: "세번째")) + #expect(try await sut.recentSearches().map(\.name) == ["세번째", "두번째", "첫번째"]) + } + + @Test + func save_duplicate_movesToFrontWithoutDuplicate() async throws { + try await sut.save(.fixture(name: "홍대입구역")) + try await sut.save(.fixture(name: "서울역")) + try await sut.save(.fixture(name: "홍대입구역")) + #expect(try await sut.recentSearches().map(\.name) == ["홍대입구역", "서울역"]) + } + + @Test + func save_eleventhItem_dropsOldest_capsAtTen() async throws { + for index in 1 ... 11 { + try await sut.save(.fixture(name: "역\(index)")) + } + let names = try await sut.recentSearches().map(\.name) + #expect(names.count == 10) + #expect(names.first == "역11") + #expect(!names.contains("역1")) + } + + @Test + func remove_deletesOnlyMatchingPlace() async throws { + try await sut.save(.fixture(name: "홍대입구역")) + try await sut.save(.fixture(name: "서울역")) + try await sut.remove(.fixture(name: "홍대입구역")) + #expect(try await sut.recentSearches().map(\.name) == ["서울역"]) + } + + @Test + func remove_absentPlace_isNoop() async throws { + try await sut.save(.fixture(name: "서울역")) + try await sut.remove(.fixture(name: "없는역")) + #expect(try await sut.recentSearches().map(\.name) == ["서울역"]) + } + + @Test + func recentSearches_corruptStoredData_returnsEmpty() async throws { + try store.set(Data("not json".utf8), forKey: "recentSearches") + #expect(try await sut.recentSearches() == []) + } + + @Test + func save_persistsAcrossRepositoryInstances() async throws { + try await RecentSearchRepositoryImpl(store: store).save(.fixture()) + let other = RecentSearchRepositoryImpl(store: store) + #expect(try await other.recentSearches() == [.fixture()]) + } +} diff --git a/Projects/Data/Tests/ReverseGeocodeResponseDTOTests.swift b/Projects/Data/Tests/ReverseGeocodeResponseDTOTests.swift new file mode 100644 index 00000000..9448d1c0 --- /dev/null +++ b/Projects/Data/Tests/ReverseGeocodeResponseDTOTests.swift @@ -0,0 +1,24 @@ +@testable import AtchaData +import Domain +import Foundation +import Testing + +struct ReverseGeocodeResponseDTOTests { + @Test + func toEntity_mapsCurrentLocationLabel() throws { + let json = Data(#"{"name":"연남동","address":"서울 마포구 연남동","lat":37.560908,"lon":126.921537}"#.utf8) + let dto = try JSONDecoder().decode(ReverseGeocodeResponseDTO.self, from: json) + #expect(dto.toEntity() == Place( + name: "연남동", + address: "서울 마포구 연남동", + coordinate: Coordinate(latitude: 37.560908, longitude: 126.921537) + )) + } + + @Test + func toEntity_missingName_returnsNil() throws { + let json = Data(#"{"address":"서울 마포구 연남동","lat":37.560908,"lon":126.921537}"#.utf8) + let dto = try JSONDecoder().decode(ReverseGeocodeResponseDTO.self, from: json) + #expect(dto.toEntity() == nil) + } +} diff --git a/Projects/Data/Tests/RouteEndpointTests.swift b/Projects/Data/Tests/RouteEndpointTests.swift new file mode 100644 index 00000000..7e8e30d1 --- /dev/null +++ b/Projects/Data/Tests/RouteEndpointTests.swift @@ -0,0 +1,32 @@ +@testable import AtchaData +import CoreNetwork +import Domain +import Foundation +import Testing + +struct RouteEndpointTests { + @Test + func search_composesPathMethodAndLegacyQueryNames() { + let endpoint = RouteEndpoint.search( + start: Coordinate(latitude: 37.49794, longitude: 127.02761), + end: Coordinate(latitude: 37.554722, longitude: 126.970833) + ) + #expect(endpoint.path == "/routes/last-routes") + #expect(endpoint.method == .get) + #expect(endpoint.queryItems == [ + URLQueryItem(name: "startLat", value: "37.49794"), + URLQueryItem(name: "startLon", value: "127.02761"), + URLQueryItem(name: "endLat", value: "37.554722"), + URLQueryItem(name: "endLon", value: "126.970833"), + ]) + #expect(endpoint.body == nil) + } + + @Test + func detail_interpolatesRouteIdIntoPath() { + let endpoint = RouteEndpoint.detail(routeId: "route-1") + #expect(endpoint.path == "/routes/last-routes/route-1") + #expect(endpoint.method == .get) + #expect(endpoint.queryItems.isEmpty) + } +} diff --git a/Projects/DesignSystem/Example/ComponentDemos.swift b/Projects/DesignSystem/Example/ComponentDemos.swift new file mode 100644 index 00000000..622769a4 --- /dev/null +++ b/Projects/DesignSystem/Example/ComponentDemos.swift @@ -0,0 +1,214 @@ +import DesignSystem +import UIKit + +final class ButtonsDemoViewController: GalleryScreenViewController { + override func viewDidLoad() { + super.viewDidLoad() + + addSectionTitle("Styles · large") + contentStack.addArrangedSubview(DSButton(title: "알람 등록하기", style: .primary)) + contentStack.addArrangedSubview(DSButton(title: "다시 검색", style: .secondary)) + contentStack.addArrangedSubview(DSButton(title: "더보기", style: .line)) + contentStack.addArrangedSubview(DSButton(title: "설정으로 이동", style: .text)) + + addSectionTitle("Sizes") + contentStack.addArrangedSubview(DSButton(title: "medium", style: .primary, size: .medium)) + contentStack.addArrangedSubview(DSButton(title: "small", style: .primary, size: .small)) + + addSectionTitle("Icon · disabled") + contentStack.addArrangedSubview( + DSButton(title: "알람 등록", style: .primary, icon: DSIcon.bell24) + ) + let disabled = DSButton(title: "비활성", style: .primary) + disabled.isEnabled = false + contentStack.addArrangedSubview(disabled) + } +} + +final class FieldsDemoViewController: GalleryScreenViewController { + override func viewDidLoad() { + super.viewDidLoad() + + addSectionTitle("DSTextField") + contentStack.addArrangedSubview(DSTextField(placeholder: "도착지를 검색해 주세요")) + let dotted = DSTextField(placeholder: "출발지", showsAccentDot: true) + dotted.setText("강남역") + contentStack.addArrangedSubview(dotted) + + addSectionTitle("DSNavigationBar") + let titleBar = DSNavigationBar(style: .title("경로 상세")) + contentStack.addArrangedSubview(titleBar) + let searchBar = DSNavigationBar(style: .search(placeholder: "장소 검색")) + contentStack.addArrangedSubview(searchBar) + } +} + +final class ListDemoViewController: UIViewController, UITableViewDataSource, UITableViewDelegate { + private var recents = ["강남역", "홍대입구역", "판교역", "성수역"] + private let tableView = UITableView() + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = DSColor.Background.base + + let header = DSSectionHeader(title: "최근 검색", actionTitle: "전체 삭제") + header.onAction = { [weak self] in + self?.recents.removeAll() + self?.tableView.reloadData() + } + + tableView.backgroundColor = .clear + tableView.separatorStyle = .none + tableView.dataSource = self + tableView.delegate = self + tableView.register(DSListCell.self, forCellReuseIdentifier: DSListCell.reuseIdentifier) + + let separator = DSSeparator() + let stack = UIStackView(arrangedSubviews: [header, separator, tableView]) + stack.axis = .vertical + stack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stack) + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), + stack.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + recents.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell( + withIdentifier: DSListCell.reuseIdentifier, for: indexPath + ) + if let cell = cell as? DSListCell { + cell.configure( + with: .init( + leadingIcon: DSIcon.place24, + title: recents[indexPath.row], + subtitle: "서울특별시 어딘가 \(indexPath.row + 1)번길", + accessory: .delete + ) + ) + cell.onDeleteTap = { [weak self] in + guard let self, let row = self.recents.firstIndex( + of: self.recents[indexPath.row] + ) else { return } + self.recents.remove(at: row) + self.tableView.reloadData() + } + } + return cell + } + + func tableView( + _ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath + ) -> UISwipeActionsConfiguration? { + let delete = UIContextualAction(style: .destructive, title: "삭제") { + [weak self] _, _, done in + self?.recents.remove(at: indexPath.row) + self?.tableView.deleteRows(at: [indexPath], with: .automatic) + done(true) + } + return UISwipeActionsConfiguration(actions: [delete]) + } +} + +final class CardsDemoViewController: GalleryScreenViewController { + override func viewDidLoad() { + super.viewDidLoad() + + addSectionTitle("DSBanner") + contentStack.addArrangedSubview(DSBanner(text: "막차 출발까지 42분", style: .normal)) + contentStack.addArrangedSubview(DSBanner(text: "막차 출발까지 5분!", style: .urgent)) + + addSectionTitle("DSRouteCard") + let card = DSRouteCard() + card.configure( + with: .init( + badgeText: "가장 늦은 차", + departureTimeText: "23:52 출발", + legs: [ + .subway(.line2, text: "2"), + .subway(.line9, text: "9"), + .bus(.mainline, text: "6411"), + ], + summaryText: "강남역 → 당산역 → 구로디지털단지", + destinationText: "도착 00:41 · 환승 2회" + ) + ) + contentStack.addArrangedSubview(card) + + addSectionTitle("DSTransportBadge") + let badges = UIStackView( + arrangedSubviews: [ + DSTransportBadge(kind: .subway(.line1, text: "1")), + DSTransportBadge(kind: .subway(.line4, text: "4")), + DSTransportBadge(kind: .subway(.shinbundang, text: "신분당")), + DSTransportBadge(kind: .bus(.town, text: "마을07")), + DSTransportBadge(kind: .bus(.widearea, text: "9401")), + DSTransportBadge(kind: .walk), + ] + ) + badges.axis = .horizontal + badges.spacing = DSSpacing.xs + badges.alignment = .center + badges.addArrangedSubview(UIView()) + contentStack.addArrangedSubview(badges) + } +} + +final class FeedbackDemoViewController: GalleryScreenViewController { + override func viewDidLoad() { + super.viewDidLoad() + + addSectionTitle("DSToast") + let plainToast = DSButton(title: "토스트 띄우기", style: .secondary, size: .medium) + plainToast.addAction( + UIAction { [weak self] _ in + guard let view = self?.view else { return } + DSToast.show("최근 검색이 삭제되었어요", in: view) + }, + for: .touchUpInside + ) + contentStack.addArrangedSubview(plainToast) + + let actionToast = DSButton(title: "액션 토스트 띄우기", style: .secondary, size: .medium) + actionToast.addAction( + UIAction { [weak self] _ in + guard let view = self?.view else { return } + DSToast.show( + "알람 권한이 꺼져 있어요", + in: view, + action: .init(title: "설정 이동") { print("settings tapped") }, + duration: 5 + ) + }, + for: .touchUpInside + ) + contentStack.addArrangedSubview(actionToast) + + addSectionTitle("DSEmptyState") + let ended = DSEmptyState( + content: .init( + icon: DSIcon.illustCharacterGray, + title: "오늘 막차가 끊겼어요", + message: "다음 첫차는 05:31에 출발해요.\n내일 다시 검색해 주세요.", + actionTitle: "다시 검색" + ) + ) + ended.onAction = { print("retry tapped") } + contentStack.addArrangedSubview(ended) + + let noRoute = DSEmptyState( + content: .init( + title: "대중교통 경로를 찾지 못했어요", + message: "도착지를 바꿔서 다시 검색해 보세요." + ) + ) + contentStack.addArrangedSubview(noRoute) + } +} diff --git a/Projects/DesignSystem/Example/FoundationDemos.swift b/Projects/DesignSystem/Example/FoundationDemos.swift new file mode 100644 index 00000000..693ac992 --- /dev/null +++ b/Projects/DesignSystem/Example/FoundationDemos.swift @@ -0,0 +1,103 @@ +import DesignSystem +import UIKit + +final class ColorsDemoViewController: GalleryScreenViewController { + override func viewDidLoad() { + super.viewDidLoad() + + addSectionTitle("Semantic") + let semantic: [(String, UIColor)] = [ + ("Background.base", DSColor.Background.base), + ("Background.elevated", DSColor.Background.elevated), + ("Fill.surface", DSColor.Fill.surface), + ("Fill.elevated", DSColor.Fill.elevated), + ("Fill.highlight", DSColor.Fill.highlight), + ("Text.primary", DSColor.Text.primary), + ("Text.secondary", DSColor.Text.secondary), + ("Text.tertiary", DSColor.Text.tertiary), + ("Text.disabled", DSColor.Text.disabled), + ("Icon.default", DSColor.Icon.default), + ("Icon.muted", DSColor.Icon.muted), + ("Border.default", DSColor.Border.default), + ("Border.focused", DSColor.Border.focused), + ("Accent.default", DSColor.Accent.default), + ("Accent.pressed", DSColor.Accent.pressed), + ("Accent.container", DSColor.Accent.container), + ("Accent.tint", DSColor.Accent.tint), + ("State.danger", DSColor.State.danger), + ("State.urgent", DSColor.State.urgent), + ] + semantic.forEach { contentStack.addArrangedSubview(swatch(name: $0.0, color: $0.1)) } + + addSectionTitle("Palette · grey") + let greys: [(String, UIColor)] = [ + ("grey50", DSPalette.grey50), ("grey100", DSPalette.grey100), + ("grey200", DSPalette.grey200), ("grey300", DSPalette.grey300), + ("grey400", DSPalette.grey400), ("grey500", DSPalette.grey500), + ("grey600", DSPalette.grey600), ("grey700", DSPalette.grey700), + ("grey800", DSPalette.grey800), ("grey850", DSPalette.grey850), + ("grey900", DSPalette.grey900), + ] + greys.forEach { contentStack.addArrangedSubview(swatch(name: $0.0, color: $0.1)) } + + addSectionTitle("Palette · lime / red") + let brand: [(String, UIColor)] = [ + ("lime200", DSPalette.lime200), ("lime400", DSPalette.lime400), + ("lime600", DSPalette.lime600), ("lime900", DSPalette.lime900), + ("red400", DSPalette.red400), ("red600", DSPalette.red600), + ] + brand.forEach { contentStack.addArrangedSubview(swatch(name: $0.0, color: $0.1)) } + } + + private func swatch(name: String, color: UIColor) -> UIView { + let chip = UIView() + chip.backgroundColor = color + chip.layer.cornerRadius = DSRadius.sm + chip.layer.borderWidth = 0.5 + chip.layer.borderColor = DSColor.Border.default.cgColor + chip.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + chip.widthAnchor.constraint(equalToConstant: 44), + chip.heightAnchor.constraint(equalToConstant: 28), + ]) + + let label = UILabel() + label.font = DSTypography.caption1.font + label.textColor = DSColor.Text.primary + label.text = name + + let row = UIStackView(arrangedSubviews: [chip, label]) + row.axis = .horizontal + row.alignment = .center + row.spacing = DSSpacing.sm12 + return row + } +} + +final class TypographyDemoViewController: GalleryScreenViewController { + override func viewDidLoad() { + super.viewDidLoad() + + let presets: [(String, DSTypography)] = [ + ("display · EB40/48", .display), + ("title1 · B26/34", .title1), + ("title2 · B22/28", .title2), + ("title3 · B20/25", .title3), + ("heading · SB17/24", .heading), + ("body1 · R17/24", .body1), + ("body2 · R15/22", .body2), + ("label1 · SB15/20", .label1), + ("label2 · SB14/18", .label2), + ("caption1 · R13/16", .caption1), + ("caption2 · M12/14", .caption2), + ] + for (name, style) in presets { + let label = UILabel() + label.numberOfLines = 0 + label.attributedText = style.attributed( + "막차 놓치지 마세요 · \(name)", color: DSColor.Text.primary + ) + contentStack.addArrangedSubview(label) + } + } +} diff --git a/Projects/DesignSystem/Example/GalleryApp.swift b/Projects/DesignSystem/Example/GalleryApp.swift new file mode 100644 index 00000000..c3109c6c --- /dev/null +++ b/Projects/DesignSystem/Example/GalleryApp.swift @@ -0,0 +1,42 @@ +import DesignSystem +import UIKit + +@main +final class AppDelegate: UIResponder, UIApplicationDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + DSFont.registerFontsIfNeeded() + return true + } + + func application( + _ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions + ) -> UISceneConfiguration { + UISceneConfiguration(name: "Default", sessionRole: connectingSceneSession.role) + } +} + +final class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + guard let windowScene = scene as? UIWindowScene else { return } + let window = UIWindow(windowScene: windowScene) + // The design system is dark-only; the gallery pins the style so + // system chrome (alerts, keyboard) matches. + window.overrideUserInterfaceStyle = .dark + window.rootViewController = UINavigationController( + rootViewController: GalleryRootViewController() + ) + window.makeKeyAndVisible() + self.window = window + } +} diff --git a/Projects/DesignSystem/Example/GalleryRootViewController.swift b/Projects/DesignSystem/Example/GalleryRootViewController.swift new file mode 100644 index 00000000..e5cee18c --- /dev/null +++ b/Projects/DesignSystem/Example/GalleryRootViewController.swift @@ -0,0 +1,104 @@ +import DesignSystem +import UIKit + +final class GalleryRootViewController: UITableViewController { + private let demos: [(title: String, make: () -> UIViewController)] = [ + ("Colors", { ColorsDemoViewController() }), + ("Typography", { TypographyDemoViewController() }), + ("Buttons", { ButtonsDemoViewController() }), + ("TextField · NavigationBar", { FieldsDemoViewController() }), + ("ListCell · SectionHeader · Separator", { ListDemoViewController() }), + ("RouteCard · TransportBadge · Banner", { CardsDemoViewController() }), + ("Toast · EmptyState", { FeedbackDemoViewController() }), + ] + + init() { + super.init(style: .insetGrouped) + title = "DesignSystem" + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = DSColor.Background.base + tableView.register(DSListCell.self, forCellReuseIdentifier: DSListCell.reuseIdentifier) + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + demos.count + } + + override func tableView( + _ tableView: UITableView, cellForRowAt indexPath: IndexPath + ) -> UITableViewCell { + let cell = tableView.dequeueReusableCell( + withIdentifier: DSListCell.reuseIdentifier, for: indexPath + ) + (cell as? DSListCell)?.configure( + with: .init(title: demos[indexPath.row].title, accessory: .chevron) + ) + return cell + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + let demo = demos[indexPath.row] + let viewController = demo.make() + viewController.title = demo.title + navigationController?.pushViewController(viewController, animated: true) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } +} + +// Scrollable vertical stack shared by the demo screens. +class GalleryScreenViewController: UIViewController { + let contentStack = UIStackView() + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = DSColor.Background.base + + let scrollView = UIScrollView() + scrollView.alwaysBounceVertical = true + scrollView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(scrollView) + + contentStack.axis = .vertical + contentStack.spacing = DSSpacing.md + contentStack.translatesAutoresizingMaskIntoConstraints = false + scrollView.addSubview(contentStack) + + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + contentStack.topAnchor.constraint( + equalTo: scrollView.contentLayoutGuide.topAnchor, constant: DSSpacing.md + ), + contentStack.leadingAnchor.constraint( + equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: DSSpacing.md + ), + contentStack.trailingAnchor.constraint( + equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -DSSpacing.md + ), + contentStack.bottomAnchor.constraint( + equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -DSSpacing.xl + ), + contentStack.widthAnchor.constraint( + equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: -DSSpacing.md * 2 + ), + ]) + } + + func addSectionTitle(_ text: String) { + let label = UILabel() + label.font = DSTypography.label2.font + label.textColor = DSColor.Text.secondary + label.text = text + contentStack.addArrangedSubview(label) + } +} diff --git a/Projects/DesignSystem/Project.swift b/Projects/DesignSystem/Project.swift index 3ebb1903..d4bd2a7b 100644 --- a/Projects/DesignSystem/Project.swift +++ b/Projects/DesignSystem/Project.swift @@ -5,5 +5,6 @@ let project = Project.layer( name: "DesignSystem", bundleSuffix: "designsystem", isolation: .mainActor, - resources: ["Resources/**"] + resources: ["Resources/**"], + example: true ) diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/dsAccent.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/dsAccent.colorset/Contents.json deleted file mode 100644 index bcd18ece..00000000 --- a/Projects/DesignSystem/Resources/DesignSystem.xcassets/dsAccent.colorset/Contents.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.910", - "green" : "0.310", - "red" : "0.290" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "1.000", - "green" : "0.450", - "red" : "0.420" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/dsBackground.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/dsBackground.colorset/Contents.json deleted file mode 100644 index 311224ae..00000000 --- a/Projects/DesignSystem/Resources/DesignSystem.xcassets/dsBackground.colorset/Contents.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "1.000", - "green" : "1.000", - "red" : "1.000" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.075", - "green" : "0.067", - "red" : "0.067" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/dsTextPrimary.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/dsTextPrimary.colorset/Contents.json deleted file mode 100644 index 8bd1c76f..00000000 --- a/Projects/DesignSystem/Resources/DesignSystem.xcassets/dsTextPrimary.colorset/Contents.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.050", - "green" : "0.050", - "red" : "0.050" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.950", - "green" : "0.950", - "red" : "0.950" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/grey100.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/grey100.colorset/Contents.json new file mode 100644 index 00000000..60f1c00a --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/grey100.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0xC2", + "green": "0xB9", + "red": "0xB9" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/grey200.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/grey200.colorset/Contents.json new file mode 100644 index 00000000..daba54ba --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/grey200.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0xA4", + "green": "0x9C", + "red": "0x99" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/grey300.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/grey300.colorset/Contents.json new file mode 100644 index 00000000..b417505e --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/grey300.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x8A", + "green": "0x7E", + "red": "0x7E" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/grey400.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/grey400.colorset/Contents.json new file mode 100644 index 00000000..c9c8a83a --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/grey400.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x70", + "green": "0x69", + "red": "0x66" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/grey50.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/grey50.colorset/Contents.json new file mode 100644 index 00000000..e32859df --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/grey50.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0xFF", + "green": "0xFF", + "red": "0xFE" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/grey500.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/grey500.colorset/Contents.json new file mode 100644 index 00000000..3ec95d46 --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/grey500.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x63", + "green": "0x5B", + "red": "0x5B" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/grey600.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/grey600.colorset/Contents.json new file mode 100644 index 00000000..610d0fa4 --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/grey600.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x49", + "green": "0x42", + "red": "0x42" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/grey700.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/grey700.colorset/Contents.json new file mode 100644 index 00000000..05f96d09 --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/grey700.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x3A", + "green": "0x36", + "red": "0x36" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/grey800.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/grey800.colorset/Contents.json new file mode 100644 index 00000000..75c0c092 --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/grey800.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x2E", + "green": "0x2C", + "red": "0x2C" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/grey850.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/grey850.colorset/Contents.json new file mode 100644 index 00000000..0f559d03 --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/grey850.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x23", + "green": "0x1F", + "red": "0x1F" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/grey900.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/grey900.colorset/Contents.json new file mode 100644 index 00000000..d7a39fe8 --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/grey900.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x15", + "green": "0x13", + "red": "0x13" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/icBack24.imageset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icBack24.imageset/Contents.json new file mode 100644 index 00000000..3b29ae82 --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icBack24.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images": [ + { + "filename": "name=chevron-left-filled.png", + "idiom": "universal", + "scale": "1x" + }, + { + "filename": "name=chevron-left-filled@2x.png", + "idiom": "universal", + "scale": "2x" + }, + { + "filename": "name=chevron-left-filled@3x.png", + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "template-rendering-intent": "template" + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/icBack24.imageset/name=chevron-left-filled.png b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icBack24.imageset/name=chevron-left-filled.png new file mode 100644 index 00000000..9b4975ee Binary files /dev/null and b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icBack24.imageset/name=chevron-left-filled.png differ diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/icBack24.imageset/name=chevron-left-filled@2x.png b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icBack24.imageset/name=chevron-left-filled@2x.png new file mode 100644 index 00000000..e1f06671 Binary files /dev/null and b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icBack24.imageset/name=chevron-left-filled@2x.png differ diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/icBack24.imageset/name=chevron-left-filled@3x.png b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icBack24.imageset/name=chevron-left-filled@3x.png new file mode 100644 index 00000000..184a55fb Binary files /dev/null and b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icBack24.imageset/name=chevron-left-filled@3x.png differ diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/icBell24.imageset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icBell24.imageset/Contents.json new file mode 100644 index 00000000..6c47ae9a --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icBell24.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images": [ + { + "filename": "bell-outlined.png", + "idiom": "universal", + "scale": "1x" + }, + { + "filename": "bell-outlined@2x.png", + "idiom": "universal", + "scale": "2x" + }, + { + "filename": "bell-outlined@3x.png", + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "template-rendering-intent": "template" + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/icBell24.imageset/bell-outlined.png b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icBell24.imageset/bell-outlined.png new file mode 100644 index 00000000..f6be1062 Binary files /dev/null and b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icBell24.imageset/bell-outlined.png differ diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/icBell24.imageset/bell-outlined@2x.png b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icBell24.imageset/bell-outlined@2x.png new file mode 100644 index 00000000..3f172ec7 Binary files /dev/null and b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icBell24.imageset/bell-outlined@2x.png differ diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/icBell24.imageset/bell-outlined@3x.png b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icBell24.imageset/bell-outlined@3x.png new file mode 100644 index 00000000..22ac1f7a Binary files /dev/null and b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icBell24.imageset/bell-outlined@3x.png differ diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/icCheck20.imageset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icCheck20.imageset/Contents.json new file mode 100644 index 00000000..ddf4f1a2 --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icCheck20.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images": [ + { + "filename": "name=ckeck.png", + "idiom": "universal", + "scale": "1x" + }, + { + "filename": "name=ckeck@2x.png", + "idiom": "universal", + "scale": "2x" + }, + { + "filename": "name=ckeck@3x.png", + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "template-rendering-intent": "template" + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/icCheck20.imageset/name=ckeck.png b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icCheck20.imageset/name=ckeck.png new file mode 100644 index 00000000..23b878d4 Binary files /dev/null and b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icCheck20.imageset/name=ckeck.png differ diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/icCheck20.imageset/name=ckeck@2x.png b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icCheck20.imageset/name=ckeck@2x.png new file mode 100644 index 00000000..70dc59bb Binary files /dev/null and b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icCheck20.imageset/name=ckeck@2x.png differ diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/icCheck20.imageset/name=ckeck@3x.png b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icCheck20.imageset/name=ckeck@3x.png new file mode 100644 index 00000000..18209726 Binary files /dev/null and b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icCheck20.imageset/name=ckeck@3x.png differ diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/icChevronRight16.imageset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icChevronRight16.imageset/Contents.json new file mode 100644 index 00000000..d526a31a --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icChevronRight16.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images": [ + { + "filename": "name=chevron-right.png", + "idiom": "universal", + "scale": "1x" + }, + { + "filename": "name=chevron-right@2x.png", + "idiom": "universal", + "scale": "2x" + }, + { + "filename": "name=chevron-right@3x.png", + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "template-rendering-intent": "template" + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/icChevronRight16.imageset/name=chevron-right.png b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icChevronRight16.imageset/name=chevron-right.png new file mode 100644 index 00000000..c068522d Binary files /dev/null and b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icChevronRight16.imageset/name=chevron-right.png differ diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/icChevronRight16.imageset/name=chevron-right@2x.png b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icChevronRight16.imageset/name=chevron-right@2x.png new file mode 100644 index 00000000..768e0a62 Binary files /dev/null and b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icChevronRight16.imageset/name=chevron-right@2x.png differ diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/icChevronRight16.imageset/name=chevron-right@3x.png b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icChevronRight16.imageset/name=chevron-right@3x.png new file mode 100644 index 00000000..9399e3a8 Binary files /dev/null and b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icChevronRight16.imageset/name=chevron-right@3x.png differ diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/icClear16.imageset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icClear16.imageset/Contents.json new file mode 100644 index 00000000..e614e291 --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icClear16.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images": [ + { + "filename": "x-circle.png", + "idiom": "universal", + "scale": "1x" + }, + { + "filename": "x-circle@2x.png", + "idiom": "universal", + "scale": "2x" + }, + { + "filename": "x-circle@3x.png", + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "template-rendering-intent": "template" + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/icClear16.imageset/x-circle.png b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icClear16.imageset/x-circle.png new file mode 100644 index 00000000..34ae0b2b Binary files /dev/null and b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icClear16.imageset/x-circle.png differ diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/icClear16.imageset/x-circle@2x.png b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icClear16.imageset/x-circle@2x.png new file mode 100644 index 00000000..f95ef00e Binary files /dev/null and b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icClear16.imageset/x-circle@2x.png differ diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/icClear16.imageset/x-circle@3x.png b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icClear16.imageset/x-circle@3x.png new file mode 100644 index 00000000..f4faffb3 Binary files /dev/null and b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icClear16.imageset/x-circle@3x.png differ diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/icClose24.imageset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icClose24.imageset/Contents.json new file mode 100644 index 00000000..feca7bca --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icClose24.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images": [ + { + "filename": "name=x.png", + "idiom": "universal", + "scale": "1x" + }, + { + "filename": "name=x@2x.png", + "idiom": "universal", + "scale": "2x" + }, + { + "filename": "name=x@3x.png", + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "template-rendering-intent": "template" + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/icClose24.imageset/name=x.png b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icClose24.imageset/name=x.png new file mode 100644 index 00000000..05b9f2ba Binary files /dev/null and b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icClose24.imageset/name=x.png differ diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/icClose24.imageset/name=x@2x.png b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icClose24.imageset/name=x@2x.png new file mode 100644 index 00000000..e8e1f5d1 Binary files /dev/null and b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icClose24.imageset/name=x@2x.png differ diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/icClose24.imageset/name=x@3x.png b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icClose24.imageset/name=x@3x.png new file mode 100644 index 00000000..4be9518b Binary files /dev/null and b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icClose24.imageset/name=x@3x.png differ diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/icInfo16.imageset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icInfo16.imageset/Contents.json new file mode 100644 index 00000000..587dcd39 --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icInfo16.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images": [ + { + "filename": "name=info, Fill=False.png", + "idiom": "universal", + "scale": "1x" + }, + { + "filename": "name=info, Fill=False@2x.png", + "idiom": "universal", + "scale": "2x" + }, + { + "filename": "name=info, Fill=False@3x.png", + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "template-rendering-intent": "template" + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/icInfo16.imageset/name=info, Fill=False.png b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icInfo16.imageset/name=info, Fill=False.png new file mode 100644 index 00000000..d08eac9c Binary files /dev/null and b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icInfo16.imageset/name=info, Fill=False.png differ diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/icInfo16.imageset/name=info, Fill=False@2x.png b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icInfo16.imageset/name=info, Fill=False@2x.png new file mode 100644 index 00000000..ea28825c Binary files /dev/null and b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icInfo16.imageset/name=info, Fill=False@2x.png differ diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/icInfo16.imageset/name=info, Fill=False@3x.png b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icInfo16.imageset/name=info, Fill=False@3x.png new file mode 100644 index 00000000..1b571c42 Binary files /dev/null and b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icInfo16.imageset/name=info, Fill=False@3x.png differ diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/icMyLocation24.imageset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icMyLocation24.imageset/Contents.json new file mode 100644 index 00000000..ef0be7b5 --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icMyLocation24.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images": [ + { + "filename": "name=My location.png", + "idiom": "universal", + "scale": "1x" + }, + { + "filename": "name=My location@2x.png", + "idiom": "universal", + "scale": "2x" + }, + { + "filename": "name=My location@3x.png", + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "template-rendering-intent": "template" + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/icMyLocation24.imageset/name=My location.png b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icMyLocation24.imageset/name=My location.png new file mode 100644 index 00000000..615ccd62 Binary files /dev/null and b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icMyLocation24.imageset/name=My location.png differ diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/icMyLocation24.imageset/name=My location@2x.png b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icMyLocation24.imageset/name=My location@2x.png new file mode 100644 index 00000000..b4cc2b9d Binary files /dev/null and b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icMyLocation24.imageset/name=My location@2x.png differ diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/icMyLocation24.imageset/name=My location@3x.png b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icMyLocation24.imageset/name=My location@3x.png new file mode 100644 index 00000000..b703e532 Binary files /dev/null and b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icMyLocation24.imageset/name=My location@3x.png differ diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/icPlace24.imageset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icPlace24.imageset/Contents.json new file mode 100644 index 00000000..f96f0549 --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icPlace24.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images": [ + { + "filename": "name=place, Fill=False.png", + "idiom": "universal", + "scale": "1x" + }, + { + "filename": "name=place, Fill=False@2x.png", + "idiom": "universal", + "scale": "2x" + }, + { + "filename": "name=place, Fill=False@3x.png", + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "template-rendering-intent": "template" + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/icPlace24.imageset/name=place, Fill=False.png b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icPlace24.imageset/name=place, Fill=False.png new file mode 100644 index 00000000..389aff26 Binary files /dev/null and b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icPlace24.imageset/name=place, Fill=False.png differ diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/icPlace24.imageset/name=place, Fill=False@2x.png b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icPlace24.imageset/name=place, Fill=False@2x.png new file mode 100644 index 00000000..302d99d9 Binary files /dev/null and b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icPlace24.imageset/name=place, Fill=False@2x.png differ diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/icPlace24.imageset/name=place, Fill=False@3x.png b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icPlace24.imageset/name=place, Fill=False@3x.png new file mode 100644 index 00000000..cf279215 Binary files /dev/null and b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icPlace24.imageset/name=place, Fill=False@3x.png differ diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/icSearch24.imageset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icSearch24.imageset/Contents.json new file mode 100644 index 00000000..4a91722d --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icSearch24.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images": [ + { + "filename": "name=search.png", + "idiom": "universal", + "scale": "1x" + }, + { + "filename": "name=search@2x.png", + "idiom": "universal", + "scale": "2x" + }, + { + "filename": "name=search@3x.png", + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "template-rendering-intent": "template" + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/icSearch24.imageset/name=search.png b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icSearch24.imageset/name=search.png new file mode 100644 index 00000000..6ef2f74c Binary files /dev/null and b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icSearch24.imageset/name=search.png differ diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/icSearch24.imageset/name=search@2x.png b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icSearch24.imageset/name=search@2x.png new file mode 100644 index 00000000..7a1a59e3 Binary files /dev/null and b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icSearch24.imageset/name=search@2x.png differ diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/icSearch24.imageset/name=search@3x.png b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icSearch24.imageset/name=search@3x.png new file mode 100644 index 00000000..9bee818b Binary files /dev/null and b/Projects/DesignSystem/Resources/DesignSystem.xcassets/icSearch24.imageset/name=search@3x.png differ diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/illustCharacterGray.imageset/Atcha.png b/Projects/DesignSystem/Resources/DesignSystem.xcassets/illustCharacterGray.imageset/Atcha.png new file mode 100644 index 00000000..875b9d85 Binary files /dev/null and b/Projects/DesignSystem/Resources/DesignSystem.xcassets/illustCharacterGray.imageset/Atcha.png differ diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/illustCharacterGray.imageset/Atcha@2x.png b/Projects/DesignSystem/Resources/DesignSystem.xcassets/illustCharacterGray.imageset/Atcha@2x.png new file mode 100644 index 00000000..69ff13e1 Binary files /dev/null and b/Projects/DesignSystem/Resources/DesignSystem.xcassets/illustCharacterGray.imageset/Atcha@2x.png differ diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/illustCharacterGray.imageset/Atcha@3x.png b/Projects/DesignSystem/Resources/DesignSystem.xcassets/illustCharacterGray.imageset/Atcha@3x.png new file mode 100644 index 00000000..02b0af26 Binary files /dev/null and b/Projects/DesignSystem/Resources/DesignSystem.xcassets/illustCharacterGray.imageset/Atcha@3x.png differ diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/illustCharacterGray.imageset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/illustCharacterGray.imageset/Contents.json new file mode 100644 index 00000000..a478a343 --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/illustCharacterGray.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images": [ + { + "filename": "Atcha.png", + "idiom": "universal", + "scale": "1x" + }, + { + "filename": "Atcha@2x.png", + "idiom": "universal", + "scale": "2x" + }, + { + "filename": "Atcha@3x.png", + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/lime200.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/lime200.colorset/Contents.json new file mode 100644 index 00000000..3975539a --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/lime200.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0xAD", + "green": "0xFB", + "red": "0xC2" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/lime400.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/lime400.colorset/Contents.json new file mode 100644 index 00000000..01e4f552 --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/lime400.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x77", + "green": "0xF9", + "red": "0x99" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/lime600.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/lime600.colorset/Contents.json new file mode 100644 index 00000000..7e5d31dc --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/lime600.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x50", + "green": "0xCC", + "red": "0x6F" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/lime900.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/lime900.colorset/Contents.json new file mode 100644 index 00000000..12fa481a --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/lime900.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x1B", + "green": "0x3C", + "red": "0x24" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/red400.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/red400.colorset/Contents.json new file mode 100644 index 00000000..11559376 --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/red400.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x47", + "green": "0x47", + "red": "0xF2" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/red600.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/red600.colorset/Contents.json new file mode 100644 index 00000000..de5855e3 --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/red600.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x31", + "green": "0x31", + "red": "0xAA" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportAirport.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportAirport.colorset/Contents.json new file mode 100644 index 00000000..78e9bd0a --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportAirport.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0xDB", + "green": "0xA9", + "red": "0x5C" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportBusGeneral.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportBusGeneral.colorset/Contents.json new file mode 100644 index 00000000..6382488d --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportBusGeneral.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0xA9", + "green": "0x9B", + "red": "0x00" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportBusMainline.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportBusMainline.colorset/Contents.json new file mode 100644 index 00000000..cc6bd9cb --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportBusMainline.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0xFF", + "green": "0x77", + "red": "0x17" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportBusRegular.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportBusRegular.colorset/Contents.json new file mode 100644 index 00000000..30a64898 --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportBusRegular.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x47", + "green": "0xB8", + "red": "0x24" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportBusTown.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportBusTown.colorset/Contents.json new file mode 100644 index 00000000..78db1916 --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportBusTown.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x3F", + "green": "0xC5", + "red": "0x6F" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportBusWidearea.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportBusWidearea.colorset/Contents.json new file mode 100644 index 00000000..11559376 --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportBusWidearea.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x47", + "green": "0x47", + "red": "0xF2" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportEverline.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportEverline.colorset/Contents.json new file mode 100644 index 00000000..6945ec6a --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportEverline.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x60", + "green": "0xBA", + "red": "0x66" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportGimpo.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportGimpo.colorset/Contents.json new file mode 100644 index 00000000..6aa8d7d5 --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportGimpo.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x10", + "green": "0x7A", + "red": "0x9F" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportGtxA.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportGtxA.colorset/Contents.json new file mode 100644 index 00000000..8caaeca4 --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportGtxA.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x87", + "green": "0x57", + "red": "0x8F" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportGyeongchun.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportGyeongchun.colorset/Contents.json new file mode 100644 index 00000000..36de2bd3 --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportGyeongchun.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x8B", + "green": "0xBA", + "red": "0x2B" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportGyeonggang.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportGyeonggang.colorset/Contents.json new file mode 100644 index 00000000..c7b25216 --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportGyeonggang.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0xC3", + "green": "0x6C", + "red": "0x39" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportGyeonguiJungang.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportGyeonguiJungang.colorset/Contents.json new file mode 100644 index 00000000..75a67a3c --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportGyeonguiJungang.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0xAD", + "green": "0xAD", + "red": "0x3E" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportIncheon1.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportIncheon1.colorset/Contents.json new file mode 100644 index 00000000..92ef4fcb --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportIncheon1.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0xE6", + "green": "0xA4", + "red": "0x71" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportIncheon2.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportIncheon2.colorset/Contents.json new file mode 100644 index 00000000..300b7281 --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportIncheon2.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x5E", + "green": "0x9F", + "red": "0xD5" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportNeutral.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportNeutral.colorset/Contents.json new file mode 100644 index 00000000..337f039f --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportNeutral.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x42", + "green": "0x3C", + "red": "0x39" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportSeohae.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportSeohae.colorset/Contents.json new file mode 100644 index 00000000..46ae4aeb --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportSeohae.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x39", + "green": "0xC9", + "red": "0x90" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportShinbundang.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportShinbundang.colorset/Contents.json new file mode 100644 index 00000000..430a3acb --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportShinbundang.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x49", + "green": "0x36", + "red": "0xBF" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportSillim.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportSillim.colorset/Contents.json new file mode 100644 index 00000000..125f7a03 --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportSillim.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0xC4", + "green": "0x8C", + "red": "0x60" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportSubwayLine1.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportSubwayLine1.colorset/Contents.json new file mode 100644 index 00000000..cc6bd9cb --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportSubwayLine1.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0xFF", + "green": "0x77", + "red": "0x17" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportSubwayLine2.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportSubwayLine2.colorset/Contents.json new file mode 100644 index 00000000..30a64898 --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportSubwayLine2.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x47", + "green": "0xB8", + "red": "0x24" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportSubwayLine3.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportSubwayLine3.colorset/Contents.json new file mode 100644 index 00000000..204bbe21 --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportSubwayLine3.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x2A", + "green": "0x7B", + "red": "0xED" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportSubwayLine4.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportSubwayLine4.colorset/Contents.json new file mode 100644 index 00000000..b77898fb --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportSubwayLine4.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0xFF", + "green": "0xB1", + "red": "0x3E" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportSubwayLine5.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportSubwayLine5.colorset/Contents.json new file mode 100644 index 00000000..3f61032c --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportSubwayLine5.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0xF6", + "green": "0x4F", + "red": "0x92" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportSubwayLine6.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportSubwayLine6.colorset/Contents.json new file mode 100644 index 00000000..07ccc5a8 --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportSubwayLine6.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x31", + "green": "0x6E", + "red": "0xC8" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportSubwayLine7.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportSubwayLine7.colorset/Contents.json new file mode 100644 index 00000000..baf22b0a --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportSubwayLine7.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x1D", + "green": "0xA8", + "red": "0x9B" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportSubwayLine8.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportSubwayLine8.colorset/Contents.json new file mode 100644 index 00000000..362a8be4 --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportSubwayLine8.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x90", + "green": "0x4B", + "red": "0xF5" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportSubwayLine9.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportSubwayLine9.colorset/Contents.json new file mode 100644 index 00000000..a4e7cb6d --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportSubwayLine9.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x16", + "green": "0xA5", + "red": "0xD8" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportSuinBundang.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportSuinBundang.colorset/Contents.json new file mode 100644 index 00000000..c8f4b614 --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportSuinBundang.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x21", + "green": "0xB4", + "red": "0xDD" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportUiSinseol.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportUiSinseol.colorset/Contents.json new file mode 100644 index 00000000..f7337545 --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportUiSinseol.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x1C", + "green": "0xB5", + "red": "0xBB" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportUijeongbu.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportUijeongbu.colorset/Contents.json new file mode 100644 index 00000000..2fc50834 --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/transportUijeongbu.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0x24", + "green": "0x8E", + "red": "0xE6" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/DesignSystem.xcassets/whiteAlpha4.colorset/Contents.json b/Projects/DesignSystem/Resources/DesignSystem.xcassets/whiteAlpha4.colorset/Contents.json new file mode 100644 index 00000000..a4290c30 --- /dev/null +++ b/Projects/DesignSystem/Resources/DesignSystem.xcassets/whiteAlpha4.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "0.040", + "blue": "0xFF", + "green": "0xFF", + "red": "0xFF" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Projects/DesignSystem/Resources/Fonts/Pretendard-Bold.otf b/Projects/DesignSystem/Resources/Fonts/Pretendard-Bold.otf new file mode 100644 index 00000000..8e5e30a2 Binary files /dev/null and b/Projects/DesignSystem/Resources/Fonts/Pretendard-Bold.otf differ diff --git a/Projects/DesignSystem/Resources/Fonts/Pretendard-ExtraBold.otf b/Projects/DesignSystem/Resources/Fonts/Pretendard-ExtraBold.otf new file mode 100644 index 00000000..388f3ca4 Binary files /dev/null and b/Projects/DesignSystem/Resources/Fonts/Pretendard-ExtraBold.otf differ diff --git a/Projects/DesignSystem/Resources/Fonts/Pretendard-Medium.otf b/Projects/DesignSystem/Resources/Fonts/Pretendard-Medium.otf new file mode 100644 index 00000000..05750698 Binary files /dev/null and b/Projects/DesignSystem/Resources/Fonts/Pretendard-Medium.otf differ diff --git a/Projects/DesignSystem/Resources/Fonts/Pretendard-Regular.otf b/Projects/DesignSystem/Resources/Fonts/Pretendard-Regular.otf new file mode 100644 index 00000000..08bf4cfc Binary files /dev/null and b/Projects/DesignSystem/Resources/Fonts/Pretendard-Regular.otf differ diff --git a/Projects/DesignSystem/Resources/Fonts/Pretendard-SemiBold.otf b/Projects/DesignSystem/Resources/Fonts/Pretendard-SemiBold.otf new file mode 100644 index 00000000..e7e36abc Binary files /dev/null and b/Projects/DesignSystem/Resources/Fonts/Pretendard-SemiBold.otf differ diff --git a/Projects/DesignSystem/Sources/Components/DSBanner.swift b/Projects/DesignSystem/Sources/Components/DSBanner.swift new file mode 100644 index 00000000..8c68a5a8 --- /dev/null +++ b/Projects/DesignSystem/Sources/Components/DSBanner.swift @@ -0,0 +1,48 @@ +import UIKit + +// Countdown banner ("막차 출발까지 N분"). Rendering only — the 1-minute tick +// lives in the owning ViewModel, which calls configure on each update. +public final class DSBanner: UIView { + public enum Style { + case normal + case urgent + } + + private let label = UILabel() + + public init(text: String = "", style: Style = .normal) { + super.init(frame: .zero) + + layer.cornerRadius = DSRadius.lg + + label.font = DSTypography.label1.font + label.textAlignment = .center + label.translatesAutoresizingMaskIntoConstraints = false + addSubview(label) + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: DSSpacing.md), + label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -DSSpacing.md), + label.topAnchor.constraint(equalTo: topAnchor, constant: DSSpacing.sm12), + label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -DSSpacing.sm12), + ]) + + configure(text: text, style: style) + } + + public func configure(text: String, style: Style = .normal) { + label.text = text + switch style { + case .normal: + backgroundColor = DSColor.Accent.container + label.textColor = DSColor.Accent.default + case .urgent: + backgroundColor = DSColor.State.urgent + label.textColor = DSColor.Text.primary + } + } + + @available(*, unavailable) + public required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } +} diff --git a/Projects/DesignSystem/Sources/Components/DSButton.swift b/Projects/DesignSystem/Sources/Components/DSButton.swift index fc396c21..b0b77028 100644 --- a/Projects/DesignSystem/Sources/Components/DSButton.swift +++ b/Projects/DesignSystem/Sources/Components/DSButton.swift @@ -4,21 +4,114 @@ public final class DSButton: UIButton { public enum Style { case primary case secondary + case line + case text } - public init(title: String, style: Style = .primary) { + public enum Size { + case large + case medium + case small + + var height: CGFloat { + switch self { + case .large: 52 + case .medium: 44 + case .small: 32 + } + } + + var cornerRadius: CGFloat { + switch self { + case .large: DSRadius.lg + case .medium: DSRadius.md + case .small: DSRadius.sm + } + } + + var font: UIFont { + switch self { + case .large: DSTypography.heading.font + case .medium: DSTypography.label1.font + case .small: DSTypography.label2.font + } + } + } + + private let size: Size + + public init(title: String, style: Style = .primary, size: Size = .large, icon: UIImage? = nil) { + self.size = size super.init(frame: .zero) - var configuration: UIButton.Configuration = (style == .primary) ? .filled() : .gray() - configuration.title = title - configuration.cornerStyle = .large - if style == .primary { - configuration.baseBackgroundColor = DSColor.accent + + var configuration: UIButton.Configuration = switch style { + case .primary, .secondary: .filled() + case .line, .text: .plain() } + configuration.attributedTitle = AttributedString( + title, attributes: AttributeContainer([.font: size.font]) + ) + configuration.background.cornerRadius = size.cornerRadius + configuration.cornerStyle = .fixed configuration.contentInsets = .init( - top: DSSpacing.sm, leading: DSSpacing.md, - bottom: DSSpacing.sm, trailing: DSSpacing.md + top: 0, leading: DSSpacing.md, bottom: 0, trailing: DSSpacing.md ) + if let icon { + configuration.image = icon.withRenderingMode(.alwaysTemplate) + configuration.imagePadding = 6 + } + // Colors are applied eagerly so the initial configuration is complete + // without waiting for an update pass (which never runs in hostless + // tests); the update handler keeps them in sync with state changes. + Self.applyColors(&configuration, style: style, isEnabled: true, isHighlighted: false) self.configuration = configuration + + configurationUpdateHandler = { [style] button in + guard var configuration = button.configuration else { return } + Self.applyColors( + &configuration, + style: style, + isEnabled: button.isEnabled, + isHighlighted: button.isHighlighted + ) + button.configuration = configuration + } + } + + static func applyColors( + _ configuration: inout UIButton.Configuration, + style: Style, + isEnabled: Bool, + isHighlighted: Bool + ) { + guard isEnabled else { + configuration.baseBackgroundColor = + (style == .primary || style == .secondary) ? DSColor.Fill.surface : .clear + configuration.baseForegroundColor = DSColor.Text.disabled + configuration.background.strokeWidth = 0 + return + } + switch style { + case .primary: + configuration.baseBackgroundColor = + isHighlighted ? DSColor.Accent.pressed : DSColor.Accent.default + configuration.baseForegroundColor = DSColor.Text.onAccent + case .secondary: + configuration.baseBackgroundColor = DSColor.Fill.elevated + configuration.baseForegroundColor = DSColor.Text.primary + case .line: + configuration.baseBackgroundColor = .clear + configuration.baseForegroundColor = DSColor.Text.primary + configuration.background.strokeColor = DSColor.Border.default + configuration.background.strokeWidth = 1 + case .text: + configuration.baseBackgroundColor = .clear + configuration.baseForegroundColor = DSColor.Accent.default + } + } + + public override var intrinsicContentSize: CGSize { + CGSize(width: super.intrinsicContentSize.width, height: size.height) } @available(*, unavailable) diff --git a/Projects/DesignSystem/Sources/Components/DSEmptyState.swift b/Projects/DesignSystem/Sources/Components/DSEmptyState.swift new file mode 100644 index 00000000..cad9b8e6 --- /dev/null +++ b/Projects/DesignSystem/Sources/Components/DSEmptyState.swift @@ -0,0 +1,108 @@ +import UIKit + +// Shared empty/error surface: last-train-ended, no-route, permission-denied. +public final class DSEmptyState: UIView { + public struct Content { + public let icon: UIImage? + public let title: String + public let message: String? + public let actionTitle: String? + + public init( + icon: UIImage? = nil, + title: String, + message: String? = nil, + actionTitle: String? = nil + ) { + self.icon = icon + self.title = title + self.message = message + self.actionTitle = actionTitle + } + } + + public var onAction: (() -> Void)? + + private let iconView = UIImageView() + private let titleLabel = UILabel() + private let messageLabel = UILabel() + private let contentStack = UIStackView() + private var actionButton: DSButton? + + public init(content: Content) { + super.init(frame: .zero) + + iconView.contentMode = .scaleAspectFit + iconView.tintColor = DSColor.Text.secondary + iconView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + iconView.widthAnchor.constraint(lessThanOrEqualToConstant: 120), + iconView.heightAnchor.constraint(lessThanOrEqualToConstant: 120), + ]) + + titleLabel.font = DSTypography.heading.font + titleLabel.textColor = DSColor.Text.primary + titleLabel.textAlignment = .center + titleLabel.numberOfLines = 0 + + messageLabel.textAlignment = .center + messageLabel.numberOfLines = 0 + + contentStack.axis = .vertical + contentStack.alignment = .center + contentStack.spacing = DSSpacing.md + [iconView, titleLabel, messageLabel].forEach(contentStack.addArrangedSubview) + + contentStack.translatesAutoresizingMaskIntoConstraints = false + addSubview(contentStack) + NSLayoutConstraint.activate([ + contentStack.leadingAnchor.constraint(equalTo: leadingAnchor), + contentStack.trailingAnchor.constraint(equalTo: trailingAnchor), + contentStack.topAnchor.constraint(equalTo: topAnchor), + contentStack.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + configure(with: content) + } + + public func configure(with content: Content) { + iconView.image = content.icon + iconView.isHidden = content.icon == nil + + titleLabel.text = content.title + + if let message = content.message { + messageLabel.attributedText = DSTypography.body2.attributed( + message, color: DSColor.Text.secondary, alignment: .center + ) + messageLabel.isHidden = false + } else { + messageLabel.isHidden = true + } + + // DSButton's title is init-only, so the action button is recreated on + // every configure — cheap for an empty state that rarely re-renders. + actionButton?.removeFromSuperview() + actionButton = nil + if let actionTitle = content.actionTitle { + let button = DSButton(title: actionTitle, style: .secondary, size: .medium) + button.addAction( + UIAction { [weak self] _ in self?.handleActionTap() }, + for: .touchUpInside + ) + contentStack.addArrangedSubview(button) + actionButton = button + } + } + + // Internal so hostless tests can trigger the tap (sendActions needs a + // running UIApplication). + func handleActionTap() { + onAction?() + } + + @available(*, unavailable) + public required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } +} diff --git a/Projects/DesignSystem/Sources/Components/DSListCell.swift b/Projects/DesignSystem/Sources/Components/DSListCell.swift new file mode 100644 index 00000000..b434f2cb --- /dev/null +++ b/Projects/DesignSystem/Sources/Components/DSListCell.swift @@ -0,0 +1,156 @@ +import UIKit + +public final class DSListCell: UITableViewCell { + public enum Accessory: Equatable { + case none + case chevron + case value(String) + case delete + } + + public struct Content { + public let leadingIcon: UIImage? + public let title: String + public let subtitle: String? + public let accessory: Accessory + + public init( + leadingIcon: UIImage? = nil, + title: String, + subtitle: String? = nil, + accessory: Accessory = .none + ) { + self.leadingIcon = leadingIcon + self.title = title + self.subtitle = subtitle + self.accessory = accessory + } + } + + public static let reuseIdentifier = "DSListCell" + public static let rowHeight: CGFloat = 56 + + public var onDeleteTap: (() -> Void)? + + private let iconView = UIImageView() + private let titleLabel = UILabel() + private let subtitleLabel = UILabel() + private let textStack = UIStackView() + private let accessoryStack = UIStackView() + private let rowStack = UIStackView() + + // UITableView dequeue requires this initializer — the one sanctioned + // deviation from the designated-init convention. + public override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + backgroundColor = .clear + let selected = UIView() + selected.backgroundColor = DSColor.Fill.highlight + selectedBackgroundView = selected + + iconView.contentMode = .scaleAspectFit + iconView.tintColor = DSColor.Icon.muted + iconView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + iconView.widthAnchor.constraint(equalToConstant: DSIconSize.md), + iconView.heightAnchor.constraint(equalToConstant: DSIconSize.md), + ]) + + titleLabel.font = DSTypography.body2.font + titleLabel.textColor = DSColor.Text.primary + titleLabel.lineBreakMode = .byTruncatingTail + + subtitleLabel.font = DSTypography.caption1.font + subtitleLabel.textColor = DSColor.Text.secondary + subtitleLabel.lineBreakMode = .byTruncatingTail + + textStack.axis = .vertical + textStack.spacing = DSSpacing.xxs + [titleLabel, subtitleLabel].forEach(textStack.addArrangedSubview) + + accessoryStack.axis = .horizontal + accessoryStack.alignment = .center + + rowStack.axis = .horizontal + rowStack.alignment = .center + rowStack.spacing = DSSpacing.sm12 + [iconView, textStack, accessoryStack].forEach(rowStack.addArrangedSubview) + + rowStack.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(rowStack) + NSLayoutConstraint.activate([ + rowStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: DSSpacing.md), + rowStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -DSSpacing.md), + rowStack.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + contentView.heightAnchor.constraint(greaterThanOrEqualToConstant: Self.rowHeight), + ]) + } + + public func configure(with content: Content) { + iconView.image = content.leadingIcon?.withRenderingMode(.alwaysTemplate) + iconView.isHidden = content.leadingIcon == nil + titleLabel.text = content.title + subtitleLabel.text = content.subtitle + subtitleLabel.isHidden = content.subtitle == nil + applyAccessory(content.accessory) + } + + public override func prepareForReuse() { + super.prepareForReuse() + onDeleteTap = nil + applyAccessory(.none) + } + + private func applyAccessory(_ accessory: Accessory) { + accessoryStack.arrangedSubviews.forEach { $0.removeFromSuperview() } + switch accessory { + case .none: + accessoryStack.isHidden = true + case .chevron: + accessoryStack.isHidden = false + let chevron = UIImageView(image: DSIcon.chevronRight16.withRenderingMode(.alwaysTemplate)) + chevron.tintColor = DSColor.Text.secondary + chevron.contentMode = .scaleAspectFit + chevron.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + chevron.widthAnchor.constraint(equalToConstant: DSIconSize.sm), + chevron.heightAnchor.constraint(equalToConstant: DSIconSize.sm), + ]) + accessoryStack.addArrangedSubview(chevron) + case .value(let value): + accessoryStack.isHidden = false + let label = UILabel() + label.font = DSTypography.caption1.font + label.textColor = DSColor.Text.secondary + label.text = value + accessoryStack.addArrangedSubview(label) + case .delete: + accessoryStack.isHidden = false + let button = UIButton(type: .system) + button.setImage(DSIcon.close24.withRenderingMode(.alwaysTemplate), for: .normal) + button.tintColor = DSColor.Icon.default + button.addAction( + UIAction { [weak self] _ in self?.handleDeleteTap() }, + for: .touchUpInside + ) + button.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + button.widthAnchor.constraint(equalToConstant: DSIconSize.lg), + button.heightAnchor.constraint(equalToConstant: DSIconSize.lg), + ]) + accessoryStack.addArrangedSubview(button) + } + } + + // Internal so hostless tests can trigger the tap (sendActions needs a + // running UIApplication). + func handleDeleteTap() { + onDeleteTap?() + } + + @available(*, unavailable) + public required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } +} diff --git a/Projects/DesignSystem/Sources/Components/DSNavigationBar.swift b/Projects/DesignSystem/Sources/Components/DSNavigationBar.swift new file mode 100644 index 00000000..0eea90c9 --- /dev/null +++ b/Projects/DesignSystem/Sources/Components/DSNavigationBar.swift @@ -0,0 +1,82 @@ +import UIKit + +public final class DSNavigationBar: UIView { + public enum Style { + case title(String) + case backOnly + case search(placeholder: String) + } + + public var onBack: (() -> Void)? + + // Exposed for the .search style so callers can wire text callbacks. + public private(set) var searchField: DSTextField? + + private static let barHeight: CGFloat = 56 + + private let backButton = UIButton(type: .system) + private let titleLabel = UILabel() + + public init(style: Style) { + super.init(frame: .zero) + + backgroundColor = DSColor.Background.base + + backButton.setImage(DSIcon.back24.withRenderingMode(.alwaysTemplate), for: .normal) + backButton.tintColor = DSColor.Icon.default + backButton.addAction( + UIAction { [weak self] _ in self?.handleBackTap() }, + for: .touchUpInside + ) + backButton.translatesAutoresizingMaskIntoConstraints = false + addSubview(backButton) + NSLayoutConstraint.activate([ + backButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: DSSpacing.md), + backButton.centerYAnchor.constraint(equalTo: centerYAnchor), + backButton.widthAnchor.constraint(equalToConstant: DSIconSize.lg), + backButton.heightAnchor.constraint(equalToConstant: DSIconSize.lg), + ]) + + switch style { + case .title(let title): + titleLabel.text = title + titleLabel.font = DSTypography.heading.font + titleLabel.textColor = DSColor.Text.primary + titleLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(titleLabel) + NSLayoutConstraint.activate([ + titleLabel.centerXAnchor.constraint(equalTo: centerXAnchor), + titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + case .backOnly: + break + case .search(let placeholder): + let field = DSTextField(placeholder: placeholder) + field.translatesAutoresizingMaskIntoConstraints = false + addSubview(field) + NSLayoutConstraint.activate([ + field.leadingAnchor.constraint( + equalTo: backButton.trailingAnchor, constant: DSSpacing.sm12 + ), + field.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -DSSpacing.md), + field.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + searchField = field + } + } + + public override var intrinsicContentSize: CGSize { + CGSize(width: UIView.noIntrinsicMetric, height: Self.barHeight) + } + + // Internal so hostless tests can trigger the tap (sendActions needs a + // running UIApplication). + func handleBackTap() { + onBack?() + } + + @available(*, unavailable) + public required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } +} diff --git a/Projects/DesignSystem/Sources/Components/DSRouteCard.swift b/Projects/DesignSystem/Sources/Components/DSRouteCard.swift new file mode 100644 index 00000000..0c95d079 --- /dev/null +++ b/Projects/DesignSystem/Sources/Components/DSRouteCard.swift @@ -0,0 +1,140 @@ +import UIKit + +public final class DSRouteCard: UIView { + public struct Content { + public let badgeText: String? + public let departureTimeText: String + public let legs: [DSTransportBadge.Kind] + public let summaryText: String? + public let destinationText: String? + + public init( + badgeText: String? = nil, + departureTimeText: String, + legs: [DSTransportBadge.Kind] = [], + summaryText: String? = nil, + destinationText: String? = nil + ) { + self.badgeText = badgeText + self.departureTimeText = departureTimeText + self.legs = legs + self.summaryText = summaryText + self.destinationText = destinationText + } + } + + private let badgeLabel = DSPaddedLabel( + insets: .init(top: DSSpacing.xxs, left: DSSpacing.sm, bottom: DSSpacing.xxs, right: DSSpacing.sm) + ) + private let departureTimeLabel = UILabel() + private let legsStack = UIStackView() + private let summaryLabel = UILabel() + private let destinationLabel = UILabel() + private let contentStack = UIStackView() + + public init() { + super.init(frame: .zero) + + backgroundColor = DSColor.Fill.surface + layer.cornerRadius = DSRadius.lg + + badgeLabel.font = DSTypography.caption2.font + badgeLabel.textColor = DSColor.Accent.default + badgeLabel.backgroundColor = DSColor.Accent.container + badgeLabel.layer.cornerRadius = DSRadius.sm + badgeLabel.clipsToBounds = true + + departureTimeLabel.font = DSTypography.title2.font + departureTimeLabel.textColor = DSColor.Text.primary + + legsStack.axis = .horizontal + legsStack.alignment = .center + legsStack.spacing = DSSpacing.xs + + summaryLabel.font = DSTypography.body2.font + summaryLabel.textColor = DSColor.Text.primary + summaryLabel.numberOfLines = 0 + + destinationLabel.font = DSTypography.caption1.font + destinationLabel.textColor = DSColor.Text.secondary + + contentStack.axis = .vertical + contentStack.alignment = .leading + contentStack.spacing = DSSpacing.sm + [badgeLabel, departureTimeLabel, legsStack, summaryLabel, destinationLabel] + .forEach(contentStack.addArrangedSubview) + + contentStack.translatesAutoresizingMaskIntoConstraints = false + addSubview(contentStack) + NSLayoutConstraint.activate([ + contentStack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: DSSpacing.md), + contentStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -DSSpacing.md), + contentStack.topAnchor.constraint(equalTo: topAnchor, constant: DSSpacing.md), + contentStack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -DSSpacing.md), + ]) + } + + public func configure(with content: Content) { + badgeLabel.text = content.badgeText + badgeLabel.isHidden = content.badgeText == nil + + departureTimeLabel.text = content.departureTimeText + + legsStack.arrangedSubviews.forEach { $0.removeFromSuperview() } + legsStack.isHidden = content.legs.isEmpty + for (index, kind) in content.legs.enumerated() { + if index > 0 { + let chevron = UIImageView( + image: DSIcon.chevronRight16.withRenderingMode(.alwaysTemplate) + ) + chevron.tintColor = DSColor.Icon.muted + chevron.contentMode = .scaleAspectFit + chevron.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + chevron.widthAnchor.constraint(equalToConstant: DSIconSize.sm), + chevron.heightAnchor.constraint(equalToConstant: DSIconSize.sm), + ]) + legsStack.addArrangedSubview(chevron) + } + legsStack.addArrangedSubview(DSTransportBadge(kind: kind)) + } + + summaryLabel.text = content.summaryText + summaryLabel.isHidden = content.summaryText == nil + + destinationLabel.text = content.destinationText + destinationLabel.isHidden = content.destinationText == nil + } + + @available(*, unavailable) + public required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } +} + +// UILabel with content insets — used for padded badge chips. +final class DSPaddedLabel: UILabel { + private let insets: UIEdgeInsets + + init(insets: UIEdgeInsets) { + self.insets = insets + super.init(frame: .zero) + } + + override func drawText(in rect: CGRect) { + super.drawText(in: rect.inset(by: insets)) + } + + override var intrinsicContentSize: CGSize { + let size = super.intrinsicContentSize + return CGSize( + width: size.width + insets.left + insets.right, + height: size.height + insets.top + insets.bottom + ) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } +} diff --git a/Projects/DesignSystem/Sources/Components/DSSectionHeader.swift b/Projects/DesignSystem/Sources/Components/DSSectionHeader.swift new file mode 100644 index 00000000..15cd0d00 --- /dev/null +++ b/Projects/DesignSystem/Sources/Components/DSSectionHeader.swift @@ -0,0 +1,61 @@ +import UIKit + +public final class DSSectionHeader: UIView { + public var onAction: (() -> Void)? + + private static let headerHeight: CGFloat = 40 + + private let titleLabel = UILabel() + private let actionButton = UIButton(type: .system) + + public init(title: String, actionTitle: String? = nil) { + super.init(frame: .zero) + + titleLabel.text = title + titleLabel.font = DSTypography.label2.font + titleLabel.textColor = DSColor.Text.secondary + titleLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(titleLabel) + NSLayoutConstraint.activate([ + titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: DSSpacing.md), + titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + + actionButton.setAttributedTitle( + NSAttributedString( + string: actionTitle ?? "", + attributes: [ + .font: DSTypography.caption1.font, + .foregroundColor: DSColor.Text.secondary, + ] + ), + for: .normal + ) + actionButton.isHidden = actionTitle == nil + actionButton.addAction( + UIAction { [weak self] _ in self?.handleActionTap() }, + for: .touchUpInside + ) + actionButton.translatesAutoresizingMaskIntoConstraints = false + addSubview(actionButton) + NSLayoutConstraint.activate([ + actionButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -DSSpacing.md), + actionButton.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + } + + public override var intrinsicContentSize: CGSize { + CGSize(width: UIView.noIntrinsicMetric, height: Self.headerHeight) + } + + // Internal so hostless tests can trigger the tap (sendActions needs a + // running UIApplication). + func handleActionTap() { + onAction?() + } + + @available(*, unavailable) + public required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } +} diff --git a/Projects/DesignSystem/Sources/Components/DSSeparator.swift b/Projects/DesignSystem/Sources/Components/DSSeparator.swift new file mode 100644 index 00000000..abd34ac7 --- /dev/null +++ b/Projects/DesignSystem/Sources/Components/DSSeparator.swift @@ -0,0 +1,32 @@ +import UIKit + +public final class DSSeparator: UIView { + public enum Axis { + case horizontal + case vertical + } + + private static let thickness: CGFloat = 0.5 + + private let axis: Axis + + public init(axis: Axis = .horizontal) { + self.axis = axis + super.init(frame: .zero) + backgroundColor = DSColor.Border.default + } + + public override var intrinsicContentSize: CGSize { + switch axis { + case .horizontal: + CGSize(width: UIView.noIntrinsicMetric, height: Self.thickness) + case .vertical: + CGSize(width: Self.thickness, height: UIView.noIntrinsicMetric) + } + } + + @available(*, unavailable) + public required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } +} diff --git a/Projects/DesignSystem/Sources/Components/DSTextField.swift b/Projects/DesignSystem/Sources/Components/DSTextField.swift new file mode 100644 index 00000000..799c3916 --- /dev/null +++ b/Projects/DesignSystem/Sources/Components/DSTextField.swift @@ -0,0 +1,135 @@ +import UIKit + +public final class DSTextField: UIView { + public var onTextChange: ((String) -> Void)? + public var onSubmit: (() -> Void)? + public var onClear: (() -> Void)? + + public var text: String { textField.text ?? "" } + + private static let fieldHeight: CGFloat = 52 + + private let textField = UITextField() + private let clearButton = UIButton(type: .system) + private let dotView = UIView() + private let contentStack = UIStackView() + private let placeholder: String + + public init(placeholder: String, showsAccentDot: Bool = false) { + self.placeholder = placeholder + super.init(frame: .zero) + + backgroundColor = DSColor.Fill.surface + layer.cornerRadius = DSRadius.md + layer.borderColor = DSColor.Border.focused.cgColor + + dotView.backgroundColor = DSColor.Accent.default + dotView.layer.cornerRadius = 2 + dotView.isHidden = !showsAccentDot + dotView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + dotView.widthAnchor.constraint(equalToConstant: 4), + dotView.heightAnchor.constraint(equalToConstant: 4), + ]) + + textField.font = DSTypography.body1.font + textField.textColor = DSColor.Text.primary + textField.tintColor = DSColor.Accent.default + textField.attributedPlaceholder = NSAttributedString( + string: placeholder, + attributes: [ + .font: DSTypography.body1.font, + .foregroundColor: DSColor.Text.secondary, + ] + ) + textField.returnKeyType = .search + textField.autocorrectionType = .no + textField.spellCheckingType = .no + textField.delegate = self + textField.addAction( + UIAction { [weak self] _ in self?.textDidChange() }, + for: .editingChanged + ) + + clearButton.setImage(DSIcon.clear16.withRenderingMode(.alwaysTemplate), for: .normal) + clearButton.tintColor = DSColor.Icon.default + clearButton.isHidden = true + clearButton.addAction( + UIAction { [weak self] _ in self?.handleClearTap() }, + for: .touchUpInside + ) + clearButton.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + clearButton.widthAnchor.constraint(equalToConstant: DSIconSize.sm), + clearButton.heightAnchor.constraint(equalToConstant: DSIconSize.sm), + ]) + + contentStack.axis = .horizontal + contentStack.alignment = .center + contentStack.spacing = DSSpacing.sm12 + contentStack.setCustomSpacing(DSSpacing.sm, after: dotView) + [dotView, textField, clearButton].forEach(contentStack.addArrangedSubview) + + contentStack.translatesAutoresizingMaskIntoConstraints = false + addSubview(contentStack) + NSLayoutConstraint.activate([ + contentStack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: DSSpacing.md), + contentStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -DSSpacing.md), + contentStack.topAnchor.constraint(equalTo: topAnchor, constant: DSSpacing.sm12), + contentStack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -DSSpacing.sm12), + ]) + } + + public func setText(_ text: String) { + textField.text = text + clearButton.isHidden = text.isEmpty + } + + public override var intrinsicContentSize: CGSize { + CGSize(width: UIView.noIntrinsicMetric, height: Self.fieldHeight) + } + + @discardableResult + public override func becomeFirstResponder() -> Bool { + textField.becomeFirstResponder() + } + + @discardableResult + public override func resignFirstResponder() -> Bool { + textField.resignFirstResponder() + } + + private func textDidChange() { + clearButton.isHidden = text.isEmpty + onTextChange?(text) + } + + // Internal so hostless tests can trigger the tap (sendActions needs a + // running UIApplication). + func handleClearTap() { + textField.text = "" + clearButton.isHidden = true + onClear?() + onTextChange?("") + } + + @available(*, unavailable) + public required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } +} + +extension DSTextField: UITextFieldDelegate { + public func textFieldDidBeginEditing(_ textField: UITextField) { + layer.borderWidth = 1.5 + } + + public func textFieldDidEndEditing(_ textField: UITextField) { + layer.borderWidth = 0 + } + + public func textFieldShouldReturn(_ textField: UITextField) -> Bool { + onSubmit?() + return true + } +} diff --git a/Projects/DesignSystem/Sources/Components/DSToast.swift b/Projects/DesignSystem/Sources/Components/DSToast.swift new file mode 100644 index 00000000..8781338c --- /dev/null +++ b/Projects/DesignSystem/Sources/Components/DSToast.swift @@ -0,0 +1,135 @@ +import UIKit + +public final class DSToast: UIView { + public struct Action { + public let title: String + public let handler: () -> Void + + public init(title: String, handler: @escaping () -> Void) { + self.title = title + self.handler = handler + } + } + + private static weak var current: DSToast? + + private let messageLabel = UILabel() + private let actionLabel = UILabel() + private let action: Action? + private var autoHideTask: Task? + + public init(message: String, action: Action? = nil) { + self.action = action + super.init(frame: .zero) + + backgroundColor = DSColor.Fill.elevated + layer.cornerRadius = DSRadius.lg + + messageLabel.attributedText = DSTypography.body2.attributed( + message, color: DSColor.Text.primary + ) + messageLabel.numberOfLines = 0 + + let contentStack = UIStackView(arrangedSubviews: [messageLabel]) + contentStack.axis = .horizontal + contentStack.alignment = .center + contentStack.spacing = DSSpacing.sm + + if let action { + actionLabel.attributedText = DSTypography.body2.attributed( + action.title, color: DSColor.Accent.default + ) + actionLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + contentStack.addArrangedSubview(actionLabel) + addGestureRecognizer( + UITapGestureRecognizer(target: self, action: #selector(didTapAction)) + ) + } + + contentStack.translatesAutoresizingMaskIntoConstraints = false + addSubview(contentStack) + NSLayoutConstraint.activate([ + contentStack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: DSSpacing.md), + contentStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -DSSpacing.md), + contentStack.topAnchor.constraint(equalTo: topAnchor, constant: DSSpacing.md), + contentStack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -DSSpacing.md), + heightAnchor.constraint(greaterThanOrEqualToConstant: 52), + ]) + } + + @discardableResult + public static func show( + _ message: String, + in view: UIView, + action: Action? = nil, + duration: TimeInterval = 2.0 + ) -> DSToast { + current?.dismiss(animated: false) + let toast = DSToast(message: message, action: action) + toast.show(in: view, duration: duration) + current = toast + return toast + } + + public func show(in view: UIView, duration: TimeInterval = 2.0) { + translatesAutoresizingMaskIntoConstraints = false + view.addSubview(self) + NSLayoutConstraint.activate([ + leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: DSSpacing.md), + trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -DSSpacing.md), + bottomAnchor.constraint( + equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -DSSpacing.md + ), + ]) + + alpha = 0 + transform = CGAffineTransform(translationX: 0, y: 10) + UIView.animate( + withDuration: 0.4, delay: 0, + usingSpringWithDamping: 0.8, initialSpringVelocity: 0.5, + options: [.beginFromCurrentState, .allowUserInteraction] + ) { + self.alpha = 1 + self.transform = .identity + } + + // [weak self] keeps a removed toast from pinning itself alive until + // the timer fires; no deinit cancellation needed. + autoHideTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(duration)) + guard !Task.isCancelled else { return } + self?.dismiss() + } + } + + public func dismiss(animated: Bool = true) { + autoHideTask?.cancel() + autoHideTask = nil + guard animated else { + removeFromSuperview() + return + } + UIView.animate(withDuration: 0.25, delay: 0, options: [.curveEaseIn]) { + self.alpha = 0 + self.transform = CGAffineTransform(translationX: 0, y: 10) + } completion: { _ in + self.removeFromSuperview() + } + } + + @objc private func didTapAction() { + handleActionTap() + } + + // Internal so hostless tests can trigger the tap (gesture recognizers + // don't fire without a running UIApplication). + func handleActionTap() { + action?.handler() + dismiss() + } + + @available(*, unavailable) + public required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } +} diff --git a/Projects/DesignSystem/Sources/Components/DSTransportBadge.swift b/Projects/DesignSystem/Sources/Components/DSTransportBadge.swift new file mode 100644 index 00000000..a6a4c217 --- /dev/null +++ b/Projects/DesignSystem/Sources/Components/DSTransportBadge.swift @@ -0,0 +1,123 @@ +import UIKit + +// UI vocabulary for transit lines — not a Domain type. Features map their +// entities onto these cases; the palette mapping stays inside DesignSystem. +public enum DSSubwayLine: CaseIterable, Sendable { + case line1, line2, line3, line4, line5, line6, line7, line8, line9 + case airport, gtxA, shinbundang, suinBundang + case gyeongchun, gyeonguiJungang, gyeonggang + case incheon1, incheon2 + case seohae, sillim, uiSinseol, uijeongbu, everline, gimpo + + public var color: UIColor { + switch self { + case .line1: DSPalette.Transport.subwayLine1 + case .line2: DSPalette.Transport.subwayLine2 + case .line3: DSPalette.Transport.subwayLine3 + case .line4: DSPalette.Transport.subwayLine4 + case .line5: DSPalette.Transport.subwayLine5 + case .line6: DSPalette.Transport.subwayLine6 + case .line7: DSPalette.Transport.subwayLine7 + case .line8: DSPalette.Transport.subwayLine8 + case .line9: DSPalette.Transport.subwayLine9 + case .airport: DSPalette.Transport.airport + case .gtxA: DSPalette.Transport.gtxA + case .shinbundang: DSPalette.Transport.shinbundang + case .suinBundang: DSPalette.Transport.suinBundang + case .gyeongchun: DSPalette.Transport.gyeongchun + case .gyeonguiJungang: DSPalette.Transport.gyeonguiJungang + case .gyeonggang: DSPalette.Transport.gyeonggang + case .incheon1: DSPalette.Transport.incheon1 + case .incheon2: DSPalette.Transport.incheon2 + case .seohae: DSPalette.Transport.seohae + case .sillim: DSPalette.Transport.sillim + case .uiSinseol: DSPalette.Transport.uiSinseol + case .uijeongbu: DSPalette.Transport.uijeongbu + case .everline: DSPalette.Transport.everline + case .gimpo: DSPalette.Transport.gimpo + } + } +} + +public enum DSBusType: CaseIterable, Sendable { + case general, mainline, regular, town, widearea + + public var color: UIColor { + switch self { + case .general: DSPalette.Transport.busGeneral + case .mainline: DSPalette.Transport.busMainline + case .regular: DSPalette.Transport.busRegular + case .town: DSPalette.Transport.busTown + case .widearea: DSPalette.Transport.busWidearea + } + } +} + +public final class DSTransportBadge: UIView { + public enum Kind: Equatable, Sendable { + case subway(DSSubwayLine, text: String) + case bus(DSBusType, text: String) + case walk + } + + private static let badgeHeight: CGFloat = 20 + + private let label = UILabel() + private var isCircular = false + + public init(kind: Kind) { + super.init(frame: .zero) + + // Badges must keep their intrinsic width even inside fill-distribution + // stacks (a stretched line badge reads as a different line). + setContentHuggingPriority(.required, for: .horizontal) + setContentCompressionResistancePriority(.required, for: .horizontal) + + label.font = DSTypography.caption2.font + label.textAlignment = .center + label.translatesAutoresizingMaskIntoConstraints = false + addSubview(label) + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: DSSpacing.xs), + label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -DSSpacing.xs), + label.centerYAnchor.constraint(equalTo: centerYAnchor), + heightAnchor.constraint(equalToConstant: Self.badgeHeight), + widthAnchor.constraint(greaterThanOrEqualTo: heightAnchor), + ]) + + configure(kind: kind) + } + + public func configure(kind: Kind) { + switch kind { + case .subway(let line, let text): + backgroundColor = line.color + label.text = text + label.textColor = DSPalette.grey50 + isCircular = true + case .bus(let type, let text): + backgroundColor = type.color + label.text = text + label.textColor = DSPalette.grey50 + isCircular = false + case .walk: + backgroundColor = DSColor.Fill.elevated + label.text = "도보" + label.textColor = DSColor.Text.secondary + isCircular = false + } + setNeedsLayout() + } + + public override func layoutSubviews() { + super.layoutSubviews() + // Single-digit subway badges render as a circle; anything wider + // (bus route numbers, named lines) becomes a pill. + layer.cornerRadius = isCircular ? bounds.height / 2 : 6 + } + + @available(*, unavailable) + public required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } +} diff --git a/Projects/DesignSystem/Sources/Foundation/DSColor.swift b/Projects/DesignSystem/Sources/Foundation/DSColor.swift new file mode 100644 index 00000000..dce16b8c --- /dev/null +++ b/Projects/DesignSystem/Sources/Foundation/DSColor.swift @@ -0,0 +1,55 @@ +import UIKit + +// Semantic color layer over DSPalette. Components use these role tokens only; +// raw palette slots stay an implementation detail behind them. +public enum DSColor { + public enum Background { + public static var base: UIColor { DSPalette.grey900 } + public static var elevated: UIColor { DSPalette.grey850 } + } + + public enum Fill { + public static var surface: UIColor { DSPalette.grey850 } + public static var elevated: UIColor { DSPalette.grey800 } + public static var highlight: UIColor { DSPalette.whiteAlpha4 } + } + + public enum Text { + public static var primary: UIColor { DSPalette.grey50 } + public static var secondary: UIColor { DSPalette.grey400 } + public static var tertiary: UIColor { DSPalette.grey500 } + public static var disabled: UIColor { DSPalette.grey600 } + public static var onAccent: UIColor { UIColor(hex: 0x000000) } + } + + public enum Icon { + public static var `default`: UIColor { DSPalette.grey200 } + public static var muted: UIColor { DSPalette.grey400 } + } + + public enum Border { + public static var `default`: UIColor { DSPalette.grey700 } + public static var focused: UIColor { DSPalette.lime400 } + } + + public enum Accent { + public static var `default`: UIColor { DSPalette.lime400 } + public static var pressed: UIColor { DSPalette.lime600 } + public static var container: UIColor { DSPalette.lime900 } + public static var tint: UIColor { DSPalette.lime200 } + } + + public enum State { + public static var danger: UIColor { DSPalette.red400 } + public static var urgent: UIColor { DSPalette.red600 } + } + + // Flat aliases kept so HomeFeature compiles untouched until the Phase 6 + // home overhaul migrates it to the semantic tokens. + @available(*, deprecated, renamed: "Accent.default") + public static var accent: UIColor { Accent.default } + @available(*, deprecated, renamed: "Background.base") + public static var background: UIColor { Background.base } + @available(*, deprecated, renamed: "Text.primary") + public static var textPrimary: UIColor { Text.primary } +} diff --git a/Projects/DesignSystem/Sources/Foundation/DSFont.swift b/Projects/DesignSystem/Sources/Foundation/DSFont.swift new file mode 100644 index 00000000..f4bae206 --- /dev/null +++ b/Projects/DesignSystem/Sources/Foundation/DSFont.swift @@ -0,0 +1,70 @@ +import CoreText +import UIKit + +public enum DSFont { + public enum Weight: String, CaseIterable { + case regular = "Pretendard-Regular" + case medium = "Pretendard-Medium" + case semiBold = "Pretendard-SemiBold" + case bold = "Pretendard-Bold" + case extraBold = "Pretendard-ExtraBold" + + var systemWeight: UIFont.Weight { + switch self { + case .regular: .regular + case .medium: .medium + case .semiBold: .semibold + case .bold: .bold + case .extraBold: .heavy + } + } + } + + public static func pretendard(_ weight: Weight, size: CGFloat) -> UIFont { + registerFontsIfNeeded() + return UIFont(name: weight.rawValue, size: size) + ?? .systemFont(ofSize: size, weight: weight.systemWeight) + } + + // One-shot CTFontManager registration. The module's resources live in a + // nested bundle, so the app's UIAppFonts plist can't pick them up — the + // fonts must be registered at runtime. Missing bundle or failed + // registration silently falls back to the system font. + @discardableResult + public static func registerFontsIfNeeded() -> Bool { + if didAttemptRegistration { return registrationSucceeded } + didAttemptRegistration = true + + guard let bundle = DSResourceBundle.current else { return false } + var urls = bundle.urls(forResourcesWithExtension: "otf", subdirectory: nil) ?? [] + urls += bundle.urls(forResourcesWithExtension: "otf", subdirectory: "Fonts") ?? [] + for url in urls { + var error: Unmanaged? + if !CTFontManagerRegisterFontsForURL(url as CFURL, .process, &error) { + // Already-registered (e.g. a host app also bundles Pretendard) + // is not a failure; the font resolves either way. + _ = error?.takeRetainedValue() + } + } + registrationSucceeded = UIFont(name: Weight.regular.rawValue, size: 17) != nil + return registrationSucceeded + } + + private static var didAttemptRegistration = false + private static var registrationSucceeded = false + + @available(*, deprecated, message: "Use DSTypography presets") + public static func title(_ size: CGFloat = 22) -> UIFont { + pretendard(.bold, size: size) + } + + @available(*, deprecated, message: "Use DSTypography presets") + public static func body(_ size: CGFloat = 16) -> UIFont { + pretendard(.regular, size: size) + } + + @available(*, deprecated, message: "Use DSTypography presets") + public static func caption(_ size: CGFloat = 12) -> UIFont { + pretendard(.medium, size: size) + } +} diff --git a/Projects/DesignSystem/Sources/Foundation/DSIcon.swift b/Projects/DesignSystem/Sources/Foundation/DSIcon.swift new file mode 100644 index 00000000..0d60899e --- /dev/null +++ b/Projects/DesignSystem/Sources/Foundation/DSIcon.swift @@ -0,0 +1,26 @@ +import UIKit + +// Brand glyphs from the asset catalog, with SF Symbol stand-ins when the +// bundle (or a specific asset) is unreachable — same resilience contract as +// the color tokens. +public enum DSIcon { + public static var back24: UIImage { asset("icBack24", fallback: "chevron.left") } + public static var close24: UIImage { asset("icClose24", fallback: "xmark") } + public static var search24: UIImage { asset("icSearch24", fallback: "magnifyingglass") } + public static var clear16: UIImage { asset("icClear16", fallback: "xmark.circle.fill") } + public static var chevronRight16: UIImage { asset("icChevronRight16", fallback: "chevron.right") } + public static var myLocation24: UIImage { asset("icMyLocation24", fallback: "location.fill") } + public static var place24: UIImage { asset("icPlace24", fallback: "mappin.and.ellipse") } + public static var bell24: UIImage { asset("icBell24", fallback: "bell.fill") } + public static var info16: UIImage { asset("icInfo16", fallback: "info.circle") } + public static var check20: UIImage { asset("icCheck20", fallback: "checkmark") } + public static var illustCharacterGray: UIImage { asset("illustCharacterGray", fallback: "tram.fill") } + + private static func asset(_ name: String, fallback systemName: String) -> UIImage { + if let bundle = DSResourceBundle.current, + let image = UIImage(named: name, in: bundle, compatibleWith: nil) { + return image + } + return UIImage(systemName: systemName) ?? UIImage() + } +} diff --git a/Projects/DesignSystem/Sources/Foundation/DSIconSize.swift b/Projects/DesignSystem/Sources/Foundation/DSIconSize.swift new file mode 100644 index 00000000..daf2c198 --- /dev/null +++ b/Projects/DesignSystem/Sources/Foundation/DSIconSize.swift @@ -0,0 +1,7 @@ +import Foundation + +public enum DSIconSize { + public static let sm: CGFloat = 16 + public static let md: CGFloat = 20 + public static let lg: CGFloat = 24 +} diff --git a/Projects/DesignSystem/Sources/Foundation/DSPalette.swift b/Projects/DesignSystem/Sources/Foundation/DSPalette.swift new file mode 100644 index 00000000..c518abea --- /dev/null +++ b/Projects/DesignSystem/Sources/Foundation/DSPalette.swift @@ -0,0 +1,81 @@ +import UIKit + +// Primitive color layer. Components never reference DSPalette directly — +// they go through the semantic DSColor tokens. Fallback hex values are kept +// identical to the asset catalog so every token resolves to the same color +// whether or not the resource bundle is reachable (e.g. hostless tests). +public enum DSPalette { + public static var grey50: UIColor { asset("grey50", fallback: 0xFEFFFF) } + public static var grey100: UIColor { asset("grey100", fallback: 0xB9B9C2) } + public static var grey200: UIColor { asset("grey200", fallback: 0x999CA4) } + public static var grey300: UIColor { asset("grey300", fallback: 0x7E7E8A) } + public static var grey400: UIColor { asset("grey400", fallback: 0x666970) } + public static var grey500: UIColor { asset("grey500", fallback: 0x5B5B63) } + public static var grey600: UIColor { asset("grey600", fallback: 0x424249) } + public static var grey700: UIColor { asset("grey700", fallback: 0x36363A) } + public static var grey800: UIColor { asset("grey800", fallback: 0x2C2C2E) } + public static var grey850: UIColor { asset("grey850", fallback: 0x1F1F23) } + public static var grey900: UIColor { asset("grey900", fallback: 0x131315) } + + public static var lime200: UIColor { asset("lime200", fallback: 0xC2FBAD) } + public static var lime400: UIColor { asset("lime400", fallback: 0x99F977) } + public static var lime600: UIColor { asset("lime600", fallback: 0x6FCC50) } + public static var lime900: UIColor { asset("lime900", fallback: 0x243C1B) } + + public static var red400: UIColor { asset("red400", fallback: 0xF24747) } + public static var red600: UIColor { asset("red600", fallback: 0xAA3131) } + + public static var whiteAlpha4: UIColor { asset("whiteAlpha4", fallback: 0xFFFFFF, alpha: 0.04) } + + public enum Transport { + public static var subwayLine1: UIColor { asset("transportSubwayLine1", fallback: 0x1777FF) } + public static var subwayLine2: UIColor { asset("transportSubwayLine2", fallback: 0x24B847) } + public static var subwayLine3: UIColor { asset("transportSubwayLine3", fallback: 0xED7B2A) } + public static var subwayLine4: UIColor { asset("transportSubwayLine4", fallback: 0x3EB1FF) } + public static var subwayLine5: UIColor { asset("transportSubwayLine5", fallback: 0x924FF6) } + public static var subwayLine6: UIColor { asset("transportSubwayLine6", fallback: 0xC86E31) } + public static var subwayLine7: UIColor { asset("transportSubwayLine7", fallback: 0x9BA81D) } + public static var subwayLine8: UIColor { asset("transportSubwayLine8", fallback: 0xF54B90) } + public static var subwayLine9: UIColor { asset("transportSubwayLine9", fallback: 0xD8A516) } + public static var airport: UIColor { asset("transportAirport", fallback: 0x5CA9DB) } + public static var gtxA: UIColor { asset("transportGtxA", fallback: 0x8F5787) } + public static var shinbundang: UIColor { asset("transportShinbundang", fallback: 0xBF3649) } + public static var suinBundang: UIColor { asset("transportSuinBundang", fallback: 0xDDB421) } + public static var gyeongchun: UIColor { asset("transportGyeongchun", fallback: 0x2BBA8B) } + public static var gyeonguiJungang: UIColor { asset("transportGyeonguiJungang", fallback: 0x3EADAD) } + public static var gyeonggang: UIColor { asset("transportGyeonggang", fallback: 0x396CC3) } + public static var incheon1: UIColor { asset("transportIncheon1", fallback: 0x71A4E6) } + public static var incheon2: UIColor { asset("transportIncheon2", fallback: 0xD59F5E) } + public static var seohae: UIColor { asset("transportSeohae", fallback: 0x90C939) } + public static var sillim: UIColor { asset("transportSillim", fallback: 0x608CC4) } + public static var uiSinseol: UIColor { asset("transportUiSinseol", fallback: 0xBBB51C) } + public static var uijeongbu: UIColor { asset("transportUijeongbu", fallback: 0xE68E24) } + public static var everline: UIColor { asset("transportEverline", fallback: 0x66BA60) } + public static var gimpo: UIColor { asset("transportGimpo", fallback: 0x9F7A10) } + public static var busGeneral: UIColor { asset("transportBusGeneral", fallback: 0x009BA9) } + public static var busMainline: UIColor { asset("transportBusMainline", fallback: 0x1777FF) } + public static var busRegular: UIColor { asset("transportBusRegular", fallback: 0x24B847) } + public static var busTown: UIColor { asset("transportBusTown", fallback: 0x6FC53F) } + public static var busWidearea: UIColor { asset("transportBusWidearea", fallback: 0xF24747) } + public static var neutral: UIColor { asset("transportNeutral", fallback: 0x393C42) } + } + + static func asset(_ name: String, fallback hex: UInt32, alpha: CGFloat = 1.0) -> UIColor { + guard let bundle = DSResourceBundle.current, + let color = UIColor(named: name, in: bundle, compatibleWith: nil) else { + return UIColor(hex: hex, alpha: alpha) + } + return color + } +} + +extension UIColor { + convenience init(hex: UInt32, alpha: CGFloat = 1.0) { + self.init( + red: CGFloat((hex >> 16) & 0xFF) / 255.0, + green: CGFloat((hex >> 8) & 0xFF) / 255.0, + blue: CGFloat(hex & 0xFF) / 255.0, + alpha: alpha + ) + } +} diff --git a/Projects/DesignSystem/Sources/Foundation/DSRadius.swift b/Projects/DesignSystem/Sources/Foundation/DSRadius.swift new file mode 100644 index 00000000..a08fa5a7 --- /dev/null +++ b/Projects/DesignSystem/Sources/Foundation/DSRadius.swift @@ -0,0 +1,7 @@ +import Foundation + +public enum DSRadius { + public static let sm: CGFloat = 8 + public static let md: CGFloat = 12 + public static let lg: CGFloat = 16 +} diff --git a/Projects/DesignSystem/Sources/Foundation/DSResourceBundle.swift b/Projects/DesignSystem/Sources/Foundation/DSResourceBundle.swift new file mode 100644 index 00000000..3d9e2b2a --- /dev/null +++ b/Projects/DesignSystem/Sources/Foundation/DSResourceBundle.swift @@ -0,0 +1,26 @@ +import UIKit + +// Non-crashing counterpart to the Tuist-generated Bundle.module accessor: +// static-framework resources land in DesignSystem_DesignSystem.bundle inside +// the host product, but hostless unit tests have no host app and the generated +// accessor fatalErrors. Returning nil lets every token fall back gracefully. +enum DSResourceBundle { + private final class Token {} + + static let current: Bundle? = { + let bundleName = "DesignSystem_DesignSystem.bundle" + let candidates: [URL?] = [ + Bundle.main.resourceURL, + Bundle(for: Token.self).resourceURL, + Bundle.main.bundleURL, + // Hostless test runners put the bundle next to the xctest bundle + // (one directory up in BUILT_PRODUCTS_DIR). + Bundle(for: Token.self).resourceURL?.appendingPathComponent(".."), + ] + for candidate in candidates { + guard let url = candidate?.appendingPathComponent(bundleName) else { continue } + if let bundle = Bundle(url: url) { return bundle } + } + return nil + }() +} diff --git a/Projects/DesignSystem/Sources/Tokens/DSSpacing.swift b/Projects/DesignSystem/Sources/Foundation/DSSpacing.swift similarity index 66% rename from Projects/DesignSystem/Sources/Tokens/DSSpacing.swift rename to Projects/DesignSystem/Sources/Foundation/DSSpacing.swift index 448e17f9..885ae97f 100644 --- a/Projects/DesignSystem/Sources/Tokens/DSSpacing.swift +++ b/Projects/DesignSystem/Sources/Foundation/DSSpacing.swift @@ -1,9 +1,12 @@ import Foundation public enum DSSpacing { + public static let xxs: CGFloat = 2 public static let xs: CGFloat = 4 public static let sm: CGFloat = 8 + public static let sm12: CGFloat = 12 public static let md: CGFloat = 16 + public static let lg20: CGFloat = 20 public static let lg: CGFloat = 24 public static let xl: CGFloat = 32 } diff --git a/Projects/DesignSystem/Sources/Foundation/DSTypography.swift b/Projects/DesignSystem/Sources/Foundation/DSTypography.swift new file mode 100644 index 00000000..83f602fa --- /dev/null +++ b/Projects/DesignSystem/Sources/Foundation/DSTypography.swift @@ -0,0 +1,40 @@ +import UIKit + +// Type scale: font + pinned line height as one token. Single-line labels can +// use `.font` directly; multiline text should go through `attributed(_:)` so +// the line height actually applies. +public struct DSTypography { + public let font: UIFont + public let lineHeight: CGFloat + + public func attributed( + _ text: String, + color: UIColor, + alignment: NSTextAlignment = .natural + ) -> NSAttributedString { + let paragraph = NSMutableParagraphStyle() + paragraph.minimumLineHeight = lineHeight + paragraph.maximumLineHeight = lineHeight + paragraph.alignment = alignment + return NSAttributedString( + string: text, + attributes: [ + .font: font, + .foregroundColor: color, + .paragraphStyle: paragraph, + ] + ) + } + + public static var display: DSTypography { .init(font: DSFont.pretendard(.extraBold, size: 40), lineHeight: 48) } + public static var title1: DSTypography { .init(font: DSFont.pretendard(.bold, size: 26), lineHeight: 34) } + public static var title2: DSTypography { .init(font: DSFont.pretendard(.bold, size: 22), lineHeight: 28) } + public static var title3: DSTypography { .init(font: DSFont.pretendard(.bold, size: 20), lineHeight: 25) } + public static var heading: DSTypography { .init(font: DSFont.pretendard(.semiBold, size: 17), lineHeight: 24) } + public static var body1: DSTypography { .init(font: DSFont.pretendard(.regular, size: 17), lineHeight: 24) } + public static var body2: DSTypography { .init(font: DSFont.pretendard(.regular, size: 15), lineHeight: 22) } + public static var label1: DSTypography { .init(font: DSFont.pretendard(.semiBold, size: 15), lineHeight: 20) } + public static var label2: DSTypography { .init(font: DSFont.pretendard(.semiBold, size: 14), lineHeight: 18) } + public static var caption1: DSTypography { .init(font: DSFont.pretendard(.regular, size: 13), lineHeight: 16) } + public static var caption2: DSTypography { .init(font: DSFont.pretendard(.medium, size: 12), lineHeight: 14) } +} diff --git a/Projects/DesignSystem/Sources/Tokens/DSColor.swift b/Projects/DesignSystem/Sources/Tokens/DSColor.swift deleted file mode 100644 index 1eea6830..00000000 --- a/Projects/DesignSystem/Sources/Tokens/DSColor.swift +++ /dev/null @@ -1,11 +0,0 @@ -import UIKit - -public enum DSColor { - public static var accent: UIColor { asset("dsAccent", fallback: .systemIndigo) } - public static var background: UIColor { asset("dsBackground", fallback: .systemBackground) } - public static var textPrimary: UIColor { asset("dsTextPrimary", fallback: .label) } - - private static func asset(_ name: String, fallback: UIColor) -> UIColor { - UIColor(named: name, in: .module, compatibleWith: nil) ?? fallback - } -} diff --git a/Projects/DesignSystem/Sources/Tokens/DSFont.swift b/Projects/DesignSystem/Sources/Tokens/DSFont.swift deleted file mode 100644 index 0ea04801..00000000 --- a/Projects/DesignSystem/Sources/Tokens/DSFont.swift +++ /dev/null @@ -1,15 +0,0 @@ -import UIKit - -public enum DSFont { - public static func title(_ size: CGFloat = 22) -> UIFont { - .systemFont(ofSize: size, weight: .bold) - } - - public static func body(_ size: CGFloat = 16) -> UIFont { - .systemFont(ofSize: size, weight: .regular) - } - - public static func caption(_ size: CGFloat = 12) -> UIFont { - .systemFont(ofSize: size, weight: .medium) - } -} diff --git a/Projects/DesignSystem/Tests/DSBannerTests.swift b/Projects/DesignSystem/Tests/DSBannerTests.swift new file mode 100644 index 00000000..dabb6e15 --- /dev/null +++ b/Projects/DesignSystem/Tests/DSBannerTests.swift @@ -0,0 +1,29 @@ +@testable import DesignSystem +import Testing +import UIKit + +@MainActor +struct DSBannerTests { + @Test + func configureSetsText() { + let banner = DSBanner() + banner.configure(text: "막차 출발까지 42분") + #expect(renderedTexts(in: banner).contains("막차 출발까지 42분")) + } + + @Test + func normalStyleUsesAccentContainer() { + let banner = DSBanner(text: "막차 출발까지 42분", style: .normal) + #expect(banner.backgroundColor.map { + colorsMatch($0, DSColor.Accent.container) + } == true) + } + + @Test + func urgentStyleUsesUrgentState() { + let banner = DSBanner(text: "막차 출발까지 5분", style: .urgent) + #expect(banner.backgroundColor.map { + colorsMatch($0, DSColor.State.urgent) + } == true) + } +} diff --git a/Projects/DesignSystem/Tests/DSButtonTests.swift b/Projects/DesignSystem/Tests/DSButtonTests.swift new file mode 100644 index 00000000..d9e44d11 --- /dev/null +++ b/Projects/DesignSystem/Tests/DSButtonTests.swift @@ -0,0 +1,60 @@ +@testable import DesignSystem +import Testing +import UIKit + +@MainActor +struct DSButtonTests { + @Test + func legacyTwoArgumentInitStillCompiles() { + let button = DSButton(title: "새로고침", style: .secondary) + #expect(button.configuration?.title == "새로고침") + } + + @Test + func primaryUsesAccentColors() { + let button = DSButton(title: "등록", style: .primary) + let configuration = button.configuration + #expect(configuration?.baseBackgroundColor.map { + colorsMatch($0, DSColor.Accent.default) + } == true) + #expect(configuration?.baseForegroundColor.map { + colorsMatch($0, DSColor.Text.onAccent) + } == true) + } + + @Test + func secondaryUsesElevatedFill() { + let button = DSButton(title: "취소", style: .secondary) + #expect(button.configuration?.baseBackgroundColor.map { + colorsMatch($0, DSColor.Fill.elevated) + } == true) + } + + @Test + func lineStyleStrokesBorder() { + let button = DSButton(title: "더보기", style: .line) + #expect(button.configuration?.background.strokeWidth == 1) + #expect(button.configuration?.background.strokeColor.map { + colorsMatch($0, DSColor.Border.default) + } == true) + } + + @Test + func sizesDriveIntrinsicHeight() { + #expect(DSButton(title: "a", size: .large).intrinsicContentSize.height == 52) + #expect(DSButton(title: "a", size: .medium).intrinsicContentSize.height == 44) + #expect(DSButton(title: "a", size: .small).intrinsicContentSize.height == 32) + } + + @Test + func disabledAppearanceMutesColors() { + var configuration = UIButton.Configuration.filled() + DSButton.applyColors(&configuration, style: .primary, isEnabled: false, isHighlighted: false) + #expect(configuration.baseBackgroundColor.map { + colorsMatch($0, DSColor.Fill.surface) + } == true) + #expect(configuration.baseForegroundColor.map { + colorsMatch($0, DSColor.Text.disabled) + } == true) + } +} diff --git a/Projects/DesignSystem/Tests/DSEmptyStateTests.swift b/Projects/DesignSystem/Tests/DSEmptyStateTests.swift new file mode 100644 index 00000000..dc511a1c --- /dev/null +++ b/Projects/DesignSystem/Tests/DSEmptyStateTests.swift @@ -0,0 +1,56 @@ +@testable import DesignSystem +import Testing +import UIKit + +@MainActor +struct DSEmptyStateTests { + @Test + func configureRendersTitleAndMessage() { + let empty = DSEmptyState( + content: .init( + icon: DSIcon.illustCharacterGray, + title: "오늘 막차가 끊겼어요", + message: "내일 다시 검색해 주세요" + ) + ) + let texts = renderedTexts(in: empty) + #expect(texts.contains("오늘 막차가 끊겼어요")) + #expect(texts.contains("내일 다시 검색해 주세요")) + } + + @Test + func actionButtonHiddenWithoutActionTitle() { + let empty = DSEmptyState(content: .init(title: "경로 없음")) + let buttons = allSubviews(of: empty).compactMap { $0 as? DSButton } + #expect(buttons.isEmpty) + } + + @Test + func actionButtonAppearsAndFiresCallback() { + let empty = DSEmptyState( + content: .init(title: "오늘 막차가 끊겼어요", actionTitle: "다시 검색") + ) + var fired = false + empty.onAction = { fired = true } + + let buttons = allSubviews(of: empty).compactMap { $0 as? DSButton } + #expect(buttons.count == 1) + + empty.handleActionTap() + #expect(fired) + } + + @Test + func reconfigureSwapsActionTitle() { + let empty = DSEmptyState(content: .init(title: "a", actionTitle: "다시 검색")) + empty.configure(with: .init(title: "b", actionTitle: "설정 이동")) + + let buttons = allSubviews(of: empty).compactMap { $0 as? DSButton } + #expect(buttons.count == 1) + #expect(buttons.first?.configuration?.title == "설정 이동") + } + + private func allSubviews(of view: UIView) -> [UIView] { + view.subviews + view.subviews.flatMap(allSubviews(of:)) + } +} diff --git a/Projects/DesignSystem/Tests/DSFontTests.swift b/Projects/DesignSystem/Tests/DSFontTests.swift new file mode 100644 index 00000000..ce679d19 --- /dev/null +++ b/Projects/DesignSystem/Tests/DSFontTests.swift @@ -0,0 +1,29 @@ +@testable import DesignSystem +import Testing +import UIKit + +@MainActor +struct DSFontTests { + @Test + func pretendardReturnsRequestedPointSize() { + for weight in DSFont.Weight.allCases { + #expect(DSFont.pretendard(weight, size: 17).pointSize == 17) + } + } + + @Test + func registrationResultMatchesFontAvailability() { + // Coherence, not absolute availability: robust whether or not the + // resource bundle resolves in this environment. + let registered = DSFont.registerFontsIfNeeded() + let resolvable = UIFont(name: "Pretendard-Regular", size: 17) != nil + #expect(registered == resolvable) + } + + @Test + func deprecatedHelpersKeepDefaultSizes() { + #expect(DSFont.title().pointSize == 22) + #expect(DSFont.body().pointSize == 16) + #expect(DSFont.caption().pointSize == 12) + } +} diff --git a/Projects/DesignSystem/Tests/DSListCellTests.swift b/Projects/DesignSystem/Tests/DSListCellTests.swift new file mode 100644 index 00000000..982537e8 --- /dev/null +++ b/Projects/DesignSystem/Tests/DSListCellTests.swift @@ -0,0 +1,54 @@ +@testable import DesignSystem +import Testing +import UIKit + +@MainActor +struct DSListCellTests { + @Test + func rowHeightIs56() { + #expect(DSListCell.rowHeight == 56) + } + + @Test + func configureRendersTitleSubtitleAndIcon() { + let cell = DSListCell(style: .default, reuseIdentifier: nil) + cell.configure( + with: .init( + leadingIcon: DSIcon.place24, + title: "강남역", + subtitle: "서울 강남구", + accessory: .none + ) + ) + let texts = renderedTexts(in: cell.contentView) + #expect(texts.contains("강남역")) + #expect(texts.contains("서울 강남구")) + } + + @Test + func valueAccessoryRendersText() { + let cell = DSListCell(style: .default, reuseIdentifier: nil) + cell.configure(with: .init(title: "버전", accessory: .value("2.0.0"))) + #expect(renderedTexts(in: cell.contentView).contains("2.0.0")) + } + + @Test + func deleteAccessoryFiresCallback() { + let cell = DSListCell(style: .default, reuseIdentifier: nil) + cell.configure(with: .init(title: "홍대입구역", accessory: .delete)) + + var deleted = false + cell.onDeleteTap = { deleted = true } + cell.handleDeleteTap() + #expect(deleted) + } + + @Test + func prepareForReuseClearsDeleteCallback() { + let cell = DSListCell(style: .default, reuseIdentifier: nil) + cell.configure(with: .init(title: "판교역", accessory: .delete)) + cell.onDeleteTap = {} + cell.prepareForReuse() + #expect(cell.onDeleteTap == nil) + } +} diff --git a/Projects/DesignSystem/Tests/DSNavigationBarTests.swift b/Projects/DesignSystem/Tests/DSNavigationBarTests.swift new file mode 100644 index 00000000..ed57d253 --- /dev/null +++ b/Projects/DesignSystem/Tests/DSNavigationBarTests.swift @@ -0,0 +1,34 @@ +@testable import DesignSystem +import Testing +import UIKit + +@MainActor +struct DSNavigationBarTests { + @Test + func intrinsicHeightIs56() { + let bar = DSNavigationBar(style: .backOnly) + #expect(bar.intrinsicContentSize.height == 56) + } + + @Test + func titleStyleRendersCenteredTitle() { + let bar = DSNavigationBar(style: .title("경로 상세")) + #expect(renderedTexts(in: bar).contains("경로 상세")) + #expect(bar.searchField == nil) + } + + @Test + func searchStyleExposesEmbeddedField() { + let bar = DSNavigationBar(style: .search(placeholder: "장소 검색")) + #expect(bar.searchField != nil) + } + + @Test + func backTapFiresCallback() { + let bar = DSNavigationBar(style: .backOnly) + var fired = false + bar.onBack = { fired = true } + bar.handleBackTap() + #expect(fired) + } +} diff --git a/Projects/DesignSystem/Tests/DSRouteCardTests.swift b/Projects/DesignSystem/Tests/DSRouteCardTests.swift new file mode 100644 index 00000000..717a62e4 --- /dev/null +++ b/Projects/DesignSystem/Tests/DSRouteCardTests.swift @@ -0,0 +1,56 @@ +@testable import DesignSystem +import Testing +import UIKit + +@MainActor +struct DSRouteCardTests { + @Test + func configureRendersAllContentFields() { + let card = DSRouteCard() + card.configure( + with: .init( + badgeText: "가장 늦은 차", + departureTimeText: "23:52 출발", + legs: [.subway(.line2, text: "2"), .bus(.mainline, text: "6411")], + summaryText: "강남역 → 구로디지털단지", + destinationText: "도착 00:41" + ) + ) + let texts = renderedTexts(in: card) + #expect(texts.contains("가장 늦은 차")) + #expect(texts.contains("23:52 출발")) + #expect(texts.contains("2")) + #expect(texts.contains("6411")) + #expect(texts.contains("강남역 → 구로디지털단지")) + #expect(texts.contains("도착 00:41")) + } + + @Test + func legsStackContainsBadgesJoinedByChevrons() { + let card = DSRouteCard() + card.configure( + with: .init( + departureTimeText: "23:52", + legs: [.subway(.line2, text: "2"), .subway(.line9, text: "9"), .walk] + ) + ) + let badges = allSubviews(of: card).filter { $0 is DSTransportBadge } + #expect(badges.count == 3) + } + + @Test + func nilFieldsHideTheirViews() { + let card = DSRouteCard() + card.configure(with: .init(departureTimeText: "23:52 출발")) + let texts = renderedTexts(in: card) + #expect(texts.contains("23:52 출발")) + #expect(!texts.contains("가장 늦은 차")) + + let hidden = labels(in: card).filter(\.isHidden) + #expect(!hidden.isEmpty) + } + + private func allSubviews(of view: UIView) -> [UIView] { + view.subviews + view.subviews.flatMap(allSubviews(of:)) + } +} diff --git a/Projects/DesignSystem/Tests/DSSectionHeaderTests.swift b/Projects/DesignSystem/Tests/DSSectionHeaderTests.swift new file mode 100644 index 00000000..32c145b5 --- /dev/null +++ b/Projects/DesignSystem/Tests/DSSectionHeaderTests.swift @@ -0,0 +1,37 @@ +@testable import DesignSystem +import Testing +import UIKit + +@MainActor +struct DSSectionHeaderTests { + @Test + func intrinsicHeightIs40() { + let header = DSSectionHeader(title: "최근 검색") + #expect(header.intrinsicContentSize.height == 40) + } + + @Test + func rendersTitle() { + let header = DSSectionHeader(title: "최근 검색") + #expect(renderedTexts(in: header).contains("최근 검색")) + } + + @Test + func actionButtonHiddenWithoutActionTitle() { + let header = DSSectionHeader(title: "최근 검색") + let button = header.subviews.compactMap { $0 as? UIButton }.first + #expect(button?.isHidden == true) + } + + @Test + func actionTapFiresCallback() { + let header = DSSectionHeader(title: "최근 검색", actionTitle: "전체 삭제") + var fired = false + header.onAction = { fired = true } + header.handleActionTap() + #expect(fired) + + let button = header.subviews.compactMap { $0 as? UIButton }.first + #expect(button?.isHidden == false) + } +} diff --git a/Projects/DesignSystem/Tests/DSTextFieldTests.swift b/Projects/DesignSystem/Tests/DSTextFieldTests.swift new file mode 100644 index 00000000..f03e6858 --- /dev/null +++ b/Projects/DesignSystem/Tests/DSTextFieldTests.swift @@ -0,0 +1,57 @@ +@testable import DesignSystem +import Testing +import UIKit + +@MainActor +struct DSTextFieldTests { + @Test + func intrinsicHeightIs52() { + let field = DSTextField(placeholder: "도착지") + #expect(field.intrinsicContentSize.height == 52) + } + + @Test + func setTextTogglesClearButtonVisibility() { + let field = DSTextField(placeholder: "도착지") + let clearButton = field.subviews + .flatMap(\.subviews) + .compactMap { $0 as? UIButton } + .first + + field.setText("강남역") + #expect(field.text == "강남역") + #expect(clearButton?.isHidden == false) + + field.setText("") + #expect(clearButton?.isHidden == true) + } + + @Test + func clearTapEmptiesTextAndFiresCallbacks() { + let field = DSTextField(placeholder: "도착지") + field.setText("강남역") + + var cleared = false + var changedTo: String? + field.onClear = { cleared = true } + field.onTextChange = { changedTo = $0 } + + field.handleClearTap() + + #expect(field.text.isEmpty) + #expect(cleared) + #expect(changedTo == "") + } + + @Test + func focusTogglesAccentBorder() { + let field = DSTextField(placeholder: "도착지") + let proxy = UITextField() + + field.textFieldDidBeginEditing(proxy) + #expect(field.layer.borderWidth == 1.5) + + field.textFieldDidEndEditing(proxy) + #expect(field.layer.borderWidth == 0) + } +} diff --git a/Projects/DesignSystem/Tests/DSToastTests.swift b/Projects/DesignSystem/Tests/DSToastTests.swift new file mode 100644 index 00000000..470155a4 --- /dev/null +++ b/Projects/DesignSystem/Tests/DSToastTests.swift @@ -0,0 +1,48 @@ +@testable import DesignSystem +import Testing +import UIKit + +@MainActor +struct DSToastTests { + @Test + func showAttachesToastToHostView() { + let host = UIView() + let toast = DSToast(message: "저장되었어요") + toast.show(in: host) + #expect(toast.superview === host) + toast.dismiss(animated: false) + } + + @Test + func actionToastRendersActionTitleAndFiresHandler() { + var fired = false + let toast = DSToast( + message: "알람 권한이 꺼져 있어요", + action: .init(title: "설정 이동") { fired = true } + ) + #expect(renderedTexts(in: toast).contains("설정 이동")) + + toast.handleActionTap() + #expect(fired) + } + + @Test + func staticShowReplacesPreviousToast() { + let host = UIView() + let first = DSToast.show("첫 번째", in: host) + let second = DSToast.show("두 번째", in: host) + + #expect(first.superview == nil) + #expect(second.superview === host) + second.dismiss(animated: false) + } + + @Test + func unanimatedDismissRemovesFromHierarchy() { + let host = UIView() + let toast = DSToast(message: "완료") + toast.show(in: host) + toast.dismiss(animated: false) + #expect(toast.superview == nil) + } +} diff --git a/Projects/DesignSystem/Tests/DSTransportBadgeTests.swift b/Projects/DesignSystem/Tests/DSTransportBadgeTests.swift new file mode 100644 index 00000000..41652b21 --- /dev/null +++ b/Projects/DesignSystem/Tests/DSTransportBadgeTests.swift @@ -0,0 +1,41 @@ +@testable import DesignSystem +import Testing +import UIKit + +@MainActor +struct DSTransportBadgeTests { + @Test + func subwayKindUsesLineColor() { + let badge = DSTransportBadge(kind: .subway(.line2, text: "2")) + #expect(badge.backgroundColor.map { + colorsMatch($0, DSPalette.Transport.subwayLine2) + } == true) + #expect(renderedTexts(in: badge).contains("2")) + } + + @Test + func busKindUsesTypeColor() { + let badge = DSTransportBadge(kind: .bus(.widearea, text: "9401")) + #expect(badge.backgroundColor.map { + colorsMatch($0, DSPalette.Transport.busWidearea) + } == true) + #expect(renderedTexts(in: badge).contains("9401")) + } + + @Test + func walkKindUsesNeutralFill() { + let badge = DSTransportBadge(kind: .walk) + #expect(badge.backgroundColor.map { + colorsMatch($0, DSColor.Fill.elevated) + } == true) + #expect(renderedTexts(in: badge).contains("도보")) + } + + @Test + func everySubwayLineMapsToADistinctColorToken() { + for line in DSSubwayLine.allCases { + let (_, _, _, alpha) = rgba(line.color) + #expect(alpha == 1.0) + } + } +} diff --git a/Projects/DesignSystem/Tests/DSTypographyTests.swift b/Projects/DesignSystem/Tests/DSTypographyTests.swift new file mode 100644 index 00000000..0b8eddfe --- /dev/null +++ b/Projects/DesignSystem/Tests/DSTypographyTests.swift @@ -0,0 +1,42 @@ +@testable import DesignSystem +import Testing +import UIKit + +@MainActor +struct DSTypographyTests { + @Test + func presetsMatchScaleSpec() { + let expected: [(DSTypography, CGFloat, CGFloat)] = [ + (.display, 40, 48), + (.title1, 26, 34), + (.title2, 22, 28), + (.title3, 20, 25), + (.heading, 17, 24), + (.body1, 17, 24), + (.body2, 15, 22), + (.label1, 15, 20), + (.label2, 14, 18), + (.caption1, 13, 16), + (.caption2, 12, 14), + ] + for (style, pointSize, lineHeight) in expected { + #expect(style.font.pointSize == pointSize) + #expect(style.lineHeight == lineHeight) + } + } + + @Test + func attributedPinsLineHeightAndColor() { + let style = DSTypography.body2 + let attributed = style.attributed("막차", color: DSColor.Text.secondary, alignment: .center) + let attributes = attributed.attributes(at: 0, effectiveRange: nil) + + let paragraph = attributes[.paragraphStyle] as? NSParagraphStyle + #expect(paragraph?.minimumLineHeight == style.lineHeight) + #expect(paragraph?.maximumLineHeight == style.lineHeight) + #expect(paragraph?.alignment == .center) + + let color = attributes[.foregroundColor] as? UIColor + #expect(color.map { colorsMatch($0, DSColor.Text.secondary) } == true) + } +} diff --git a/Projects/DesignSystem/Tests/DesignTokenTests.swift b/Projects/DesignSystem/Tests/DesignTokenTests.swift index 00e91c15..11039e88 100644 --- a/Projects/DesignSystem/Tests/DesignTokenTests.swift +++ b/Projects/DesignSystem/Tests/DesignTokenTests.swift @@ -1,15 +1,96 @@ @testable import DesignSystem import Testing +import UIKit -// Token-only assertions — resource-bundle lookup in hostless unit tests is -// unreliable for static frameworks, so no color asset access here. @MainActor struct DesignTokenTests { @Test func spacingScaleIsAscending() { + #expect(DSSpacing.xxs < DSSpacing.xs) #expect(DSSpacing.xs < DSSpacing.sm) - #expect(DSSpacing.sm < DSSpacing.md) - #expect(DSSpacing.md < DSSpacing.lg) + #expect(DSSpacing.sm < DSSpacing.sm12) + #expect(DSSpacing.sm12 < DSSpacing.md) + #expect(DSSpacing.md < DSSpacing.lg20) + #expect(DSSpacing.lg20 < DSSpacing.lg) #expect(DSSpacing.lg < DSSpacing.xl) } + + @Test + func radiusAndIconScales() { + #expect(DSRadius.sm == 8) + #expect(DSRadius.md == 12) + #expect(DSRadius.lg == 16) + #expect(DSIconSize.sm == 16) + #expect(DSIconSize.md == 20) + #expect(DSIconSize.lg == 24) + } + + @Test + func greyPaletteMatchesSpec() { + #expect(colorMatchesHex(DSPalette.grey50, 0xFEFFFF)) + #expect(colorMatchesHex(DSPalette.grey100, 0xB9B9C2)) + #expect(colorMatchesHex(DSPalette.grey200, 0x999CA4)) + #expect(colorMatchesHex(DSPalette.grey300, 0x7E7E8A)) + #expect(colorMatchesHex(DSPalette.grey400, 0x666970)) + #expect(colorMatchesHex(DSPalette.grey500, 0x5B5B63)) + #expect(colorMatchesHex(DSPalette.grey600, 0x424249)) + #expect(colorMatchesHex(DSPalette.grey700, 0x36363A)) + #expect(colorMatchesHex(DSPalette.grey800, 0x2C2C2E)) + #expect(colorMatchesHex(DSPalette.grey850, 0x1F1F23)) + #expect(colorMatchesHex(DSPalette.grey900, 0x131315)) + } + + @Test + func brandPaletteMatchesSpec() { + #expect(colorMatchesHex(DSPalette.lime200, 0xC2FBAD)) + #expect(colorMatchesHex(DSPalette.lime400, 0x99F977)) + #expect(colorMatchesHex(DSPalette.lime600, 0x6FCC50)) + #expect(colorMatchesHex(DSPalette.lime900, 0x243C1B)) + #expect(colorMatchesHex(DSPalette.red400, 0xF24747)) + #expect(colorMatchesHex(DSPalette.red600, 0xAA3131)) + #expect(colorMatchesHex(DSPalette.whiteAlpha4, 0xFFFFFF, alpha: 0.04)) + } + + @Test + func transportPaletteMatchesSpec() { + #expect(colorMatchesHex(DSPalette.Transport.subwayLine1, 0x1777FF)) + #expect(colorMatchesHex(DSPalette.Transport.subwayLine2, 0x24B847)) + #expect(colorMatchesHex(DSPalette.Transport.subwayLine9, 0xD8A516)) + #expect(colorMatchesHex(DSPalette.Transport.shinbundang, 0xBF3649)) + #expect(colorMatchesHex(DSPalette.Transport.gtxA, 0x8F5787)) + #expect(colorMatchesHex(DSPalette.Transport.busGeneral, 0x009BA9)) + #expect(colorMatchesHex(DSPalette.Transport.busWidearea, 0xF24747)) + #expect(colorMatchesHex(DSPalette.Transport.neutral, 0x393C42)) + } + + @Test + func semanticTokensResolveToPaletteSlots() { + #expect(colorsMatch(DSColor.Background.base, DSPalette.grey900)) + #expect(colorsMatch(DSColor.Background.elevated, DSPalette.grey850)) + #expect(colorsMatch(DSColor.Fill.surface, DSPalette.grey850)) + #expect(colorsMatch(DSColor.Fill.elevated, DSPalette.grey800)) + #expect(colorsMatch(DSColor.Fill.highlight, DSPalette.whiteAlpha4)) + #expect(colorsMatch(DSColor.Text.primary, DSPalette.grey50)) + #expect(colorsMatch(DSColor.Text.secondary, DSPalette.grey400)) + #expect(colorsMatch(DSColor.Text.tertiary, DSPalette.grey500)) + #expect(colorsMatch(DSColor.Text.disabled, DSPalette.grey600)) + #expect(colorMatchesHex(DSColor.Text.onAccent, 0x000000)) + #expect(colorsMatch(DSColor.Icon.default, DSPalette.grey200)) + #expect(colorsMatch(DSColor.Icon.muted, DSPalette.grey400)) + #expect(colorsMatch(DSColor.Border.default, DSPalette.grey700)) + #expect(colorsMatch(DSColor.Border.focused, DSPalette.lime400)) + #expect(colorsMatch(DSColor.Accent.default, DSPalette.lime400)) + #expect(colorsMatch(DSColor.Accent.pressed, DSPalette.lime600)) + #expect(colorsMatch(DSColor.Accent.container, DSPalette.lime900)) + #expect(colorsMatch(DSColor.Accent.tint, DSPalette.lime200)) + #expect(colorsMatch(DSColor.State.danger, DSPalette.red400)) + #expect(colorsMatch(DSColor.State.urgent, DSPalette.red600)) + } + + @Test + func flatAliasesForwardToSemanticTokens() { + #expect(colorsMatch(DSColor.accent, DSColor.Accent.default)) + #expect(colorsMatch(DSColor.background, DSColor.Background.base)) + #expect(colorsMatch(DSColor.textPrimary, DSColor.Text.primary)) + } } diff --git a/Projects/DesignSystem/Tests/TestSupport.swift b/Projects/DesignSystem/Tests/TestSupport.swift new file mode 100644 index 00000000..daa0a548 --- /dev/null +++ b/Projects/DesignSystem/Tests/TestSupport.swift @@ -0,0 +1,43 @@ +@testable import DesignSystem +import UIKit + +// Every assertion in this target must also hold on the fallback path +// (no resource bundle): DSPalette fallback hex values are identical to the +// asset catalog, so color checks are deterministic either way. + +func rgba(_ color: UIColor) -> (red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat) { + var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0 + color.resolvedColor(with: UITraitCollection(userInterfaceStyle: .dark)) + .getRed(&red, green: &green, blue: &blue, alpha: &alpha) + return (red, green, blue, alpha) +} + +func colorsMatch(_ lhs: UIColor, _ rhs: UIColor, tolerance: CGFloat = 0.001) -> Bool { + let l = rgba(lhs) + let r = rgba(rhs) + return abs(l.red - r.red) <= tolerance + && abs(l.green - r.green) <= tolerance + && abs(l.blue - r.blue) <= tolerance + && abs(l.alpha - r.alpha) <= tolerance +} + +func colorMatchesHex( + _ color: UIColor, _ hex: UInt32, alpha: CGFloat = 1.0, tolerance: CGFloat = 0.001 +) -> Bool { + colorsMatch(color, UIColor(hex: hex, alpha: alpha), tolerance: tolerance) +} + +@MainActor +func labels(in view: UIView) -> [UILabel] { + var result: [UILabel] = [] + if let label = view as? UILabel { result.append(label) } + for subview in view.subviews { + result.append(contentsOf: labels(in: subview)) + } + return result +} + +@MainActor +func renderedTexts(in view: UIView) -> [String] { + labels(in: view).compactMap { $0.text ?? $0.attributedText?.string } +} diff --git a/Projects/Domain/Sources/Entities/AlarmInfo.swift b/Projects/Domain/Sources/Entities/AlarmInfo.swift new file mode 100644 index 00000000..41acf7c9 --- /dev/null +++ b/Projects/Domain/Sources/Entities/AlarmInfo.swift @@ -0,0 +1,18 @@ +import Foundation + +// TODO: [미확정] 서버 계산 "알람 시각" 필드는 refresh 응답에 없다(실측: departureTime뿐) — +// 알람 시각 스펙이 확정되면 여기에 추가한다. +public struct AlarmInfo: Equatable, Sendable { + public let lastRouteId: String + /// 막차 출발 시각 (서버 재계산 값) + public let departureTime: Date? + public let updatedAt: Date? + public let isReal: Bool + + public init(lastRouteId: String, departureTime: Date?, updatedAt: Date?, isReal: Bool) { + self.lastRouteId = lastRouteId + self.departureTime = departureTime + self.updatedAt = updatedAt + self.isReal = isReal + } +} diff --git a/Projects/Domain/Sources/Entities/Coordinate.swift b/Projects/Domain/Sources/Entities/Coordinate.swift new file mode 100644 index 00000000..352728b2 --- /dev/null +++ b/Projects/Domain/Sources/Entities/Coordinate.swift @@ -0,0 +1,9 @@ +public struct Coordinate: Equatable, Sendable { + public let latitude: Double + public let longitude: Double + + public init(latitude: Double, longitude: Double) { + self.latitude = latitude + self.longitude = longitude + } +} diff --git a/Projects/Domain/Sources/Entities/LastRoute.swift b/Projects/Domain/Sources/Entities/LastRoute.swift new file mode 100644 index 00000000..cbefa7ae --- /dev/null +++ b/Projects/Domain/Sources/Entities/LastRoute.swift @@ -0,0 +1,102 @@ +import Foundation + +public enum TransportMode: Equatable, Sendable { + case walk + case bus + case subway + case unknown +} + +public struct RoutePoint: Equatable, Sendable { + public let name: String + public let coordinate: Coordinate + + public init(name: String, coordinate: Coordinate) { + self.name = name + self.coordinate = coordinate + } +} + +public struct TransportLeg: Equatable, Sendable { + public let mode: TransportMode + /// 초 단위 + public let sectionTime: Int + /// 미터 단위 + public let distance: Int + public let departureTime: Date? + /// 버스는 "타입:번호"(예: "간선:472"), 지하철은 노선명 + public let routeName: String? + /// 노선 타입 코드 — 아이콘·색상 키 + public let lineType: String? + public let start: RoutePoint? + public let end: RoutePoint? + public let subwayFinalStation: String? + public let subwayDirection: String? + public let isExpressSubway: Bool + public let isLastSubway: Bool + + public init( + mode: TransportMode, + sectionTime: Int, + distance: Int, + departureTime: Date?, + routeName: String?, + lineType: String?, + start: RoutePoint?, + end: RoutePoint?, + subwayFinalStation: String?, + subwayDirection: String?, + isExpressSubway: Bool, + isLastSubway: Bool + ) { + self.mode = mode + self.sectionTime = sectionTime + self.distance = distance + self.departureTime = departureTime + self.routeName = routeName + self.lineType = lineType + self.start = start + self.end = end + self.subwayFinalStation = subwayFinalStation + self.subwayDirection = subwayDirection + self.isExpressSubway = isExpressSubway + self.isLastSubway = isLastSubway + } +} + +// 지도 표시 전용 필드(passShape/passStopList/step)는 2.0 스코프에 없어 이식하지 않았다. +public struct LastRoute: Equatable, Sendable { + public let id: String + /// 막차 출발 시각 + public let departureTime: Date + /// 초 단위 + public let totalTime: Int + /// 초 단위 + public let totalWalkTime: Int + public let transferCount: Int + /// 미터 단위 + public let totalDistance: Int + /// 미터 단위 + public let totalWalkDistance: Int + public let legs: [TransportLeg] + + public init( + id: String, + departureTime: Date, + totalTime: Int, + totalWalkTime: Int, + transferCount: Int, + totalDistance: Int, + totalWalkDistance: Int, + legs: [TransportLeg] + ) { + self.id = id + self.departureTime = departureTime + self.totalTime = totalTime + self.totalWalkTime = totalWalkTime + self.transferCount = transferCount + self.totalDistance = totalDistance + self.totalWalkDistance = totalWalkDistance + self.legs = legs + } +} diff --git a/Projects/Domain/Sources/Entities/LastRouteSearchResult.swift b/Projects/Domain/Sources/Entities/LastRouteSearchResult.swift new file mode 100644 index 00000000..e1a67df6 --- /dev/null +++ b/Projects/Domain/Sources/Entities/LastRouteSearchResult.swift @@ -0,0 +1,8 @@ +public enum LastRouteSearchResult: Sendable, Equatable { + /// 첫 항목 = 가장 늦은 차 + case available([LastRoute]) + /// 오늘 막차 종료 + case serviceEnded + /// 경로 없음 (도보권 등) + case noRoute +} diff --git a/Projects/Domain/Sources/Entities/Place.swift b/Projects/Domain/Sources/Entities/Place.swift new file mode 100644 index 00000000..bd6baa50 --- /dev/null +++ b/Projects/Domain/Sources/Entities/Place.swift @@ -0,0 +1,11 @@ +public struct Place: Equatable, Sendable { + public let name: String + public let address: String + public let coordinate: Coordinate + + public init(name: String, address: String, coordinate: Coordinate) { + self.name = name + self.address = address + self.coordinate = coordinate + } +} diff --git a/Projects/Domain/Sources/Entities/ServerError.swift b/Projects/Domain/Sources/Entities/ServerError.swift new file mode 100644 index 00000000..0950e400 --- /dev/null +++ b/Projects/Domain/Sources/Entities/ServerError.swift @@ -0,0 +1,10 @@ +/// 서버가 envelope의 responseCode로 알려온 비즈니스 에러. +public struct ServerError: Error, Equatable, Sendable { + public let code: String + public let message: String? + + public init(code: String, message: String? = nil) { + self.code = code + self.message = message + } +} diff --git a/Projects/Domain/Sources/Interfaces/AlarmRepository.swift b/Projects/Domain/Sources/Interfaces/AlarmRepository.swift new file mode 100644 index 00000000..2bd099b2 --- /dev/null +++ b/Projects/Domain/Sources/Interfaces/AlarmRepository.swift @@ -0,0 +1,7 @@ +// 조회 전용 GET /routes/user-routes는 서버에 없다(레거시 실측) — refresh가 조회를 겸한다. +// TODO: [미확정] 서버에 조회 API가 생기면 별도 메서드로 분리한다. +public protocol AlarmRepository: Sendable { + func register(lastRouteId: String) async throws + func cancel(lastRouteId: String) async throws + func refresh() async throws -> AlarmInfo +} diff --git a/Projects/Domain/Sources/Interfaces/AlarmScheduler.swift b/Projects/Domain/Sources/Interfaces/AlarmScheduler.swift new file mode 100644 index 00000000..5e48ec1f --- /dev/null +++ b/Projects/Domain/Sources/Interfaces/AlarmScheduler.swift @@ -0,0 +1,8 @@ +import Foundation + +/// 디바이스 알람 포트 — 어댑터 구현은 Phase 7(CoreAlarm)에서 App에 둔다. +public protocol AlarmScheduler: Sendable { + /// 기존 알람을 전부 취소하고 새로 등록한다 (단일 알람 정책). + func replaceAlarm(id: String, fireDate: Date, title: String) async throws + func cancelAlarm() async +} diff --git a/Projects/Domain/Sources/Interfaces/LastRouteRepository.swift b/Projects/Domain/Sources/Interfaces/LastRouteRepository.swift new file mode 100644 index 00000000..2d1daa6a --- /dev/null +++ b/Projects/Domain/Sources/Interfaces/LastRouteRepository.swift @@ -0,0 +1,4 @@ +public protocol LastRouteRepository: Sendable { + func searchLastRoutes(start: Coordinate, end: Coordinate) async throws -> [LastRoute] + func lastRoute(id: String) async throws -> LastRoute +} diff --git a/Projects/Domain/Sources/Interfaces/LocationService.swift b/Projects/Domain/Sources/Interfaces/LocationService.swift new file mode 100644 index 00000000..56c3c152 --- /dev/null +++ b/Projects/Domain/Sources/Interfaces/LocationService.swift @@ -0,0 +1,4 @@ +/// 디바이스 위치 포트 — 어댑터 구현은 Phase 6에서 App에 둔다. +public protocol LocationService: Sendable { + func currentLocation() async throws -> Coordinate +} diff --git a/Projects/Domain/Sources/Interfaces/PlaceRepository.swift b/Projects/Domain/Sources/Interfaces/PlaceRepository.swift new file mode 100644 index 00000000..45989a67 --- /dev/null +++ b/Projects/Domain/Sources/Interfaces/PlaceRepository.swift @@ -0,0 +1,4 @@ +public protocol PlaceRepository: Sendable { + func searchPlaces(keyword: String, near coordinate: Coordinate?) async throws -> [Place] + func reverseGeocode(_ coordinate: Coordinate) async throws -> Place +} diff --git a/Projects/Domain/Sources/Interfaces/RecentSearchRepository.swift b/Projects/Domain/Sources/Interfaces/RecentSearchRepository.swift new file mode 100644 index 00000000..ef54da69 --- /dev/null +++ b/Projects/Domain/Sources/Interfaces/RecentSearchRepository.swift @@ -0,0 +1,6 @@ +/// 로컬 저장 전용 — 구현은 Phase 2(CoreStorage)에서. +public protocol RecentSearchRepository: Sendable { + func recentSearches() async throws -> [Place] + func save(_ place: Place) async throws + func remove(_ place: Place) async throws +} diff --git a/Projects/Domain/Sources/UseCases/CancelAlarmUseCase.swift b/Projects/Domain/Sources/UseCases/CancelAlarmUseCase.swift new file mode 100644 index 00000000..aec245ec --- /dev/null +++ b/Projects/Domain/Sources/UseCases/CancelAlarmUseCase.swift @@ -0,0 +1,18 @@ +public protocol CancelAlarmUseCase: Sendable { + func execute(lastRouteId: String) async throws +} + +public struct DefaultCancelAlarmUseCase: CancelAlarmUseCase { + private let repository: any AlarmRepository + private let scheduler: any AlarmScheduler + + public init(repository: any AlarmRepository, scheduler: any AlarmScheduler) { + self.repository = repository + self.scheduler = scheduler + } + + public func execute(lastRouteId: String) async throws { + try await repository.cancel(lastRouteId: lastRouteId) + await scheduler.cancelAlarm() + } +} diff --git a/Projects/Domain/Sources/UseCases/GetCurrentLocationUseCase.swift b/Projects/Domain/Sources/UseCases/GetCurrentLocationUseCase.swift new file mode 100644 index 00000000..80cac688 --- /dev/null +++ b/Projects/Domain/Sources/UseCases/GetCurrentLocationUseCase.swift @@ -0,0 +1,15 @@ +public protocol GetCurrentLocationUseCase: Sendable { + func execute() async throws -> Coordinate +} + +public struct DefaultGetCurrentLocationUseCase: GetCurrentLocationUseCase { + private let locationService: any LocationService + + public init(locationService: any LocationService) { + self.locationService = locationService + } + + public func execute() async throws -> Coordinate { + try await locationService.currentLocation() + } +} diff --git a/Projects/Domain/Sources/UseCases/RecentSearchesUseCase.swift b/Projects/Domain/Sources/UseCases/RecentSearchesUseCase.swift new file mode 100644 index 00000000..59497f38 --- /dev/null +++ b/Projects/Domain/Sources/UseCases/RecentSearchesUseCase.swift @@ -0,0 +1,25 @@ +public protocol RecentSearchesUseCase: Sendable { + func fetch() async throws -> [Place] + func save(_ place: Place) async throws + func remove(_ place: Place) async throws +} + +public struct DefaultRecentSearchesUseCase: RecentSearchesUseCase { + private let repository: any RecentSearchRepository + + public init(repository: any RecentSearchRepository) { + self.repository = repository + } + + public func fetch() async throws -> [Place] { + try await repository.recentSearches() + } + + public func save(_ place: Place) async throws { + try await repository.save(place) + } + + public func remove(_ place: Place) async throws { + try await repository.remove(place) + } +} diff --git a/Projects/Domain/Sources/UseCases/RefreshAlarmUseCase.swift b/Projects/Domain/Sources/UseCases/RefreshAlarmUseCase.swift new file mode 100644 index 00000000..73afeed3 --- /dev/null +++ b/Projects/Domain/Sources/UseCases/RefreshAlarmUseCase.swift @@ -0,0 +1,15 @@ +public protocol RefreshAlarmUseCase: Sendable { + func execute() async throws -> AlarmInfo +} + +public struct DefaultRefreshAlarmUseCase: RefreshAlarmUseCase { + private let repository: any AlarmRepository + + public init(repository: any AlarmRepository) { + self.repository = repository + } + + public func execute() async throws -> AlarmInfo { + try await repository.refresh() + } +} diff --git a/Projects/Domain/Sources/UseCases/RegisterAlarmUseCase.swift b/Projects/Domain/Sources/UseCases/RegisterAlarmUseCase.swift new file mode 100644 index 00000000..a0c136a8 --- /dev/null +++ b/Projects/Domain/Sources/UseCases/RegisterAlarmUseCase.swift @@ -0,0 +1,25 @@ +public protocol RegisterAlarmUseCase: Sendable { + func execute(route: LastRoute) async throws +} + +public struct DefaultRegisterAlarmUseCase: RegisterAlarmUseCase { + private let repository: any AlarmRepository + private let scheduler: any AlarmScheduler + + public init(repository: any AlarmRepository, scheduler: any AlarmScheduler) { + self.repository = repository + self.scheduler = scheduler + } + + public func execute(route: LastRoute) async throws { + // TODO: [미확정 #4] 단일 알람 규약(서버 교체 여부) 확정 전까지 클라이언트가 삭제 후 등록한다. + // 기존 알람 확인 실패(= 등록된 알람 없음)와 삭제 실패는 등록을 막지 않는다. + if let existing = try? await repository.refresh() { + try? await repository.cancel(lastRouteId: existing.lastRouteId) + } + try await repository.register(lastRouteId: route.id) + // 단일 알람 정책: 서버 등록이 성공한 뒤에만 로컬 알람을 교체한다. + // TODO: [미확정] 서버 계산 알람 시각 스펙 확정 전까지 막차 출발 시각으로 스케줄한다. + try await scheduler.replaceAlarm(id: route.id, fireDate: route.departureTime, title: "막차 출발 알림") + } +} diff --git a/Projects/Domain/Sources/UseCases/SearchLastRoutesUseCase.swift b/Projects/Domain/Sources/UseCases/SearchLastRoutesUseCase.swift new file mode 100644 index 00000000..f872c359 --- /dev/null +++ b/Projects/Domain/Sources/UseCases/SearchLastRoutesUseCase.swift @@ -0,0 +1,34 @@ +public protocol SearchLastRoutesUseCase: Sendable { + func execute(start: Coordinate, end: Coordinate) async throws -> LastRouteSearchResult +} + +public struct DefaultSearchLastRoutesUseCase: SearchLastRoutesUseCase { + private let repository: any LastRouteRepository + + public init(repository: any LastRouteRepository) { + self.repository = repository + } + + public func execute(start: Coordinate, end: Coordinate) async throws -> LastRouteSearchResult { + let routes: [LastRoute] + do { + routes = try await repository.searchLastRoutes(start: start, end: end) + } catch let error as ServerError { + if let normalized = Self.normalizedResult(code: error.code) { + return normalized + } + throw error + } + // TODO: [미확정 #3] 서버의 "막차 종료" 표현 실측 전까지 빈 목록을 종료로 간주한다. + guard !routes.isEmpty else { return .serviceEnded } + return .available(routes) + } + + // TODO: [미확정 #3] "막차 종료"/"경로 없음"의 responseCode 실측값이 확정되면 이 매핑에만 추가한다. + // 레거시 단서(의미 미확인): URT_001, LRT_001, LRT_003, REQ_004 + private static func normalizedResult(code: String) -> LastRouteSearchResult? { + switch code { + default: nil + } + } +} diff --git a/Projects/Domain/Sources/UseCases/SearchPlacesUseCase.swift b/Projects/Domain/Sources/UseCases/SearchPlacesUseCase.swift new file mode 100644 index 00000000..bef88198 --- /dev/null +++ b/Projects/Domain/Sources/UseCases/SearchPlacesUseCase.swift @@ -0,0 +1,15 @@ +public protocol SearchPlacesUseCase: Sendable { + func execute(keyword: String, near coordinate: Coordinate?) async throws -> [Place] +} + +public struct DefaultSearchPlacesUseCase: SearchPlacesUseCase { + private let repository: any PlaceRepository + + public init(repository: any PlaceRepository) { + self.repository = repository + } + + public func execute(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { + try await repository.searchPlaces(keyword: keyword, near: coordinate) + } +} diff --git a/Projects/Domain/Tests/DefaultCancelAlarmUseCaseTests.swift b/Projects/Domain/Tests/DefaultCancelAlarmUseCaseTests.swift new file mode 100644 index 00000000..12eb8d08 --- /dev/null +++ b/Projects/Domain/Tests/DefaultCancelAlarmUseCaseTests.swift @@ -0,0 +1,67 @@ +@testable import Domain +import Foundation +import Testing + +private actor CallLog { + private(set) var events: [String] = [] + func append(_ event: String) { events.append(event) } +} + +private struct StubError: Error {} + +private struct SpyAlarmRepository: AlarmRepository { + let log: CallLog + var cancelError: Error? = nil + + func register(lastRouteId: String) async throws { + await log.append("register:\(lastRouteId)") + } + + func cancel(lastRouteId: String) async throws { + await log.append("cancel:\(lastRouteId)") + if let cancelError { throw cancelError } + } + + func refresh() async throws -> AlarmInfo { + await log.append("refresh") + throw StubError() + } +} + +private struct SpyAlarmScheduler: AlarmScheduler { + let log: CallLog + + func replaceAlarm(id: String, fireDate: Date, title: String) async throws { + await log.append("replaceAlarm:\(id)") + } + + func cancelAlarm() async { + await log.append("cancelAlarm") + } +} + +struct DefaultCancelAlarmUseCaseTests { + @Test + func execute_serverCancelSucceeds_thenCancelsLocalAlarm() async throws { + let log = CallLog() + let sut = DefaultCancelAlarmUseCase( + repository: SpyAlarmRepository(log: log), + scheduler: SpyAlarmScheduler(log: log) + ) + try await sut.execute(lastRouteId: "route-1") + #expect(await log.events == ["cancel:route-1", "cancelAlarm"]) + } + + @Test + func execute_serverCancelFails_keepsLocalAlarm() async { + let log = CallLog() + let sut = DefaultCancelAlarmUseCase( + repository: SpyAlarmRepository(log: log, cancelError: StubError()), + scheduler: SpyAlarmScheduler(log: log) + ) + await #expect(throws: StubError.self) { + try await sut.execute(lastRouteId: "route-1") + } + #expect(await log.events == ["cancel:route-1"]) + } +} diff --git a/Projects/Domain/Tests/DefaultRegisterAlarmUseCaseTests.swift b/Projects/Domain/Tests/DefaultRegisterAlarmUseCaseTests.swift new file mode 100644 index 00000000..c71078f8 --- /dev/null +++ b/Projects/Domain/Tests/DefaultRegisterAlarmUseCaseTests.swift @@ -0,0 +1,96 @@ +@testable import Domain +import Foundation +import Testing + +private actor CallLog { + private(set) var events: [String] = [] + func append(_ event: String) { events.append(event) } +} + +private struct StubError: Error {} + +private struct SpyAlarmRepository: AlarmRepository { + let log: CallLog + var existing: AlarmInfo? = nil + var registerError: Error? = nil + + func register(lastRouteId: String) async throws { + await log.append("register:\(lastRouteId)") + if let registerError { throw registerError } + } + + func cancel(lastRouteId: String) async throws { + await log.append("cancel:\(lastRouteId)") + } + + func refresh() async throws -> AlarmInfo { + await log.append("refresh") + guard let existing else { throw StubError() } + return existing + } +} + +private struct SpyAlarmScheduler: AlarmScheduler { + let log: CallLog + + func replaceAlarm(id: String, fireDate: Date, title: String) async throws { + await log.append("replaceAlarm:\(id)") + } + + func cancelAlarm() async { + await log.append("cancelAlarm") + } +} + +private extension LastRoute { + static func fixture(id: String) -> LastRoute { + LastRoute( + id: id, + departureTime: Date(timeIntervalSince1970: 1_000), + totalTime: 0, + totalWalkTime: 0, + transferCount: 0, + totalDistance: 0, + totalWalkDistance: 0, + legs: [] + ) + } +} + +struct DefaultRegisterAlarmUseCaseTests { + @Test + func execute_existingAlarm_cancelsThenRegistersThenSchedules() async throws { + let log = CallLog() + let existing = AlarmInfo(lastRouteId: "old", departureTime: nil, updatedAt: nil, isReal: false) + let sut = DefaultRegisterAlarmUseCase( + repository: SpyAlarmRepository(log: log, existing: existing), + scheduler: SpyAlarmScheduler(log: log) + ) + try await sut.execute(route: .fixture(id: "new")) + #expect(await log.events == ["refresh", "cancel:old", "register:new", "replaceAlarm:new"]) + } + + @Test + func execute_noExistingAlarm_skipsCancel() async throws { + let log = CallLog() + let sut = DefaultRegisterAlarmUseCase( + repository: SpyAlarmRepository(log: log), + scheduler: SpyAlarmScheduler(log: log) + ) + try await sut.execute(route: .fixture(id: "new")) + #expect(await log.events == ["refresh", "register:new", "replaceAlarm:new"]) + } + + @Test + func execute_serverRegisterFails_doesNotSchedule() async { + let log = CallLog() + let sut = DefaultRegisterAlarmUseCase( + repository: SpyAlarmRepository(log: log, registerError: StubError()), + scheduler: SpyAlarmScheduler(log: log) + ) + await #expect(throws: StubError.self) { + try await sut.execute(route: .fixture(id: "new")) + } + #expect(await log.events == ["refresh", "register:new"]) + } +} diff --git a/Projects/Domain/Tests/DefaultSearchLastRoutesUseCaseTests.swift b/Projects/Domain/Tests/DefaultSearchLastRoutesUseCaseTests.swift new file mode 100644 index 00000000..1768fe53 --- /dev/null +++ b/Projects/Domain/Tests/DefaultSearchLastRoutesUseCaseTests.swift @@ -0,0 +1,66 @@ +@testable import Domain +import Foundation +import Testing + +private struct StubError: Error {} + +private struct StubLastRouteRepository: LastRouteRepository { + let routes: [LastRoute] + var error: Error? = nil + + func searchLastRoutes(start: Coordinate, end: Coordinate) async throws -> [LastRoute] { + if let error { throw error } + return routes + } + + func lastRoute(id: String) async throws -> LastRoute { + if let error { throw error } + guard let route = routes.first else { throw StubError() } + return route + } +} + +private extension LastRoute { + static func fixture(id: String) -> LastRoute { + LastRoute( + id: id, + departureTime: Date(timeIntervalSince1970: 1_000), + totalTime: 0, + totalWalkTime: 0, + transferCount: 0, + totalDistance: 0, + totalWalkDistance: 0, + legs: [] + ) + } +} + +struct DefaultSearchLastRoutesUseCaseTests { + private let start = Coordinate(latitude: 37.49794, longitude: 127.02761) + private let end = Coordinate(latitude: 37.554722, longitude: 126.970833) + + @Test + func execute_nonEmptyRoutes_returnsAvailablePreservingOrder() async throws { + let routes = [LastRoute.fixture(id: "latest"), LastRoute.fixture(id: "alternative")] + let sut = DefaultSearchLastRoutesUseCase(repository: StubLastRouteRepository(routes: routes)) + let result = try await sut.execute(start: start, end: end) + #expect(result == .available(routes)) + } + + @Test + func execute_emptyRoutes_returnsServiceEnded() async throws { + let sut = DefaultSearchLastRoutesUseCase(repository: StubLastRouteRepository(routes: [])) + let result = try await sut.execute(start: start, end: end) + #expect(result == .serviceEnded) + } + + @Test + func execute_unknownServerError_rethrows() async { + let sut = DefaultSearchLastRoutesUseCase( + repository: StubLastRouteRepository(routes: [], error: ServerError(code: "LRT_999")) + ) + await #expect(throws: ServerError(code: "LRT_999")) { + try await sut.execute(start: start, end: end) + } + } +} diff --git a/Tuist/ProjectDescriptionHelpers/Project+Layer.swift b/Tuist/ProjectDescriptionHelpers/Project+Layer.swift index 49762d98..2026b8aa 100644 --- a/Tuist/ProjectDescriptionHelpers/Project+Layer.swift +++ b/Tuist/ProjectDescriptionHelpers/Project+Layer.swift @@ -2,13 +2,17 @@ import ProjectDescription public extension Project { /// Horizontal / Clean Architecture layer module: framework + unit tests. + /// `example: true` adds a {name}Example app target (gallery/demo) — + /// mirrors the feature Example pattern for layers that benefit from + /// standalone visual review (e.g. DesignSystem). static func layer( name: String, bundleSuffix: String? = nil, isolation: AtchaIsolation = .nonisolated, dependencies: [TargetDependency] = [], testDependencies: [TargetDependency] = [], - resources: ResourceFileElements? = nil + resources: ResourceFileElements? = nil, + example: Bool = false ) -> Project { let suffix = bundleSuffix ?? name.lowercased() @@ -37,6 +41,39 @@ public extension Project { settings: .atchaV2(isolation: isolation) ) + var targets = [framework, tests] + + if example { + let sceneManifest: Plist.Value = [ + "UIApplicationSupportsMultipleScenes": false, + "UISceneConfigurations": [ + "UIWindowSceneSessionRoleApplication": [ + [ + "UISceneConfigurationName": "Default", + "UISceneDelegateClassName": "$(PRODUCT_MODULE_NAME).SceneDelegate", + ], + ], + ], + ] + + targets.append( + Target.target( + name: "\(name)Example", + destinations: Atcha.destinations, + product: .app, + bundleId: "\(Atcha.v2BundleID).\(suffix).example", + deploymentTargets: Atcha.v2Deployment, + infoPlist: .extendingDefault(with: [ + "UILaunchScreen": [:], + "UIApplicationSceneManifest": sceneManifest, + ]), + sources: ["Example/**"], + dependencies: [.target(name: name)], + settings: .atchaV2(isolation: .mainActor) + ) + ) + } + return Project( name: name, options: .options( @@ -44,7 +81,7 @@ public extension Project { developmentRegion: Atcha.developmentRegion ), settings: .atchaV2(isolation: isolation), - targets: [framework, tests] + targets: targets ) } } diff --git a/Workspace.swift b/Workspace.swift index b65b0e5c..415fb518 100644 --- a/Workspace.swift +++ b/Workspace.swift @@ -8,6 +8,8 @@ let workspace = Workspace( "Projects/Domain", "Projects/Data", "Projects/Core/Network", + "Projects/Core/Storage", + "Projects/Core/Auth", "Projects/Core/Coordinator", "Projects/DesignSystem", "Projects/Legacy", diff --git a/docs/prompts/atcha-v2-master-prompt.md b/docs/prompts/atcha-v2-master-prompt.md new file mode 100644 index 00000000..4f2b6e5d --- /dev/null +++ b/docs/prompts/atcha-v2-master-prompt.md @@ -0,0 +1,389 @@ +# AtchaV2 마스터 구현 프롬프트 — 막차 검색 + 알람 + +> **사용법**: 이 문서 전체를 Claude Code에 컨텍스트로 전달하고 `"Phase N을 진행해"`라고 지시한다. +> 실행 에이전트는 반드시 [진행 프로토콜](#진행-프로토콜)을 따르며, 한 번에 한 Phase만 수행한다. +> 작성일: 2026-08-22. 이 문서와 레포 `CLAUDE.md`가 충돌하면 **CLAUDE.md가 우선**한다. + +--- + +## Goal (최상위) + +**앗차 2.0의 핵심 가치를 완성한다: 오차를 감안하더라도, 가장 간편하고 쉽게 막차 시간을 알려주고 놓치지 않게 깨워주는 앱.** + +사용자 플로우 전체: + +``` +스플래시(자동 익명 인증, 로그인 UI 없음) + → 홈 (출발지=현재 위치 기본값 / 도착지 입력) + → 검색 화면 (장소 검색 + 최근 검색 → 서버 기준 "가장 늦은 차" 1개 + 더보기로 대안 경로) + → 경로 선택 → 홈 복귀 (선택 경로 카드 표출) + → 알람 등록 (AlarmKit, 단일 알람 — 새 경로 등록 시 교체) + → 홈 상단 배너 "막차 출발까지 N분" (1분 타이머 + 포그라운드 복귀 시 재조회) + → 서버가 알람 시각 재계산 시 FCM 사일런트 푸시(또는 폴링 폴백)로 알람 자동 갱신 +``` + +확정된 제품 결정사항 (변경하려면 사용자에게 먼저 물을 것): + +| 항목 | 결정 | +|---|---| +| 서버 | 레거시 1.x와 **같은 서버** — 스펙 확정·구현됨. 장소 검색도 자체 서버 | +| 인증 | 자동 **익명 인증** (로그인 화면 없음, 스플래시에서 토큰 부트스트랩) | +| 출발지 | **현재 위치 기본값** + 위치 권한 플로우 (denied 시 검색 유도) | +| 알람 | AlarmKit **기본 UI**, **단일 알람만** (새 경로 등록 시 기존 알람 교체). Live Activity 위젯 익스텐션은 스코프 제외 | +| 알람 시각 | **서버가 계산·갱신**. 통지: FCM 사일런트 푸시 + 폴링 폴백 | +| 홈 배너 | "막차 출발까지 N분" — 1분 단위 타이머 갱신 + 포그라운드 복귀 시 서버 재조회 | +| 결과 표출 | 기본 "가장 늦은 차" 1개 강조 → "더보기"로 대안 경로 목록 확장 | +| 검색 편의 | **최근 검색만** 로컬 저장 (즐겨찾기 없음, 서버 저장 아님) | +| 막차 없음 UX | 서버 상태를 3가지로 정규화: 막차 있음 / 오늘 막차 종료(다음 운행 안내) / 경로 없음(안내 문구) | +| 설명글 2종 | "막차 시간과 가까워질수록 정확해져요" / "알람 시간은 막차 환경에 따라 변경될 수 있어요" — 홈에 상시 노출 | +| 디자인 | 기존 V2 DesignSystem을 고도화해서 사용 (레거시 `DesignSource/`는 시각 스펙 참고만) | + +--- + +## 공통 규칙 (모든 Phase에 상속) + +### 시작 절차 + +모든 Phase 시작 전에 반드시: + +1. 레포 루트 `CLAUDE.md`를 읽는다 (아키텍처 규약·함정 목록의 원본). +2. 표준 템플릿을 읽는다: `Projects/Feature/Home/`(피처 구조), `Tuist/ProjectDescriptionHelpers/`(모듈 DSL). +3. 해당 Phase의 "레거시 참고 파일"을 읽는다 (아래 표). + +### 공통 acceptance (모든 Phase 마지막에 전부 실행) + +```bash +# 매니페스트(Project.swift/Workspace.swift) 수정한 경우에만 +tuist generate --no-open + +# 항상: Debug와 Stage 모두 빌드 (Stage 누락이 이 레포 1순위 함정) +xcodebuild -workspace Atcha.xcworkspace -scheme AtchaV2 -configuration Debug \ + -destination 'generic/platform=iOS Simulator' build +xcodebuild -workspace Atcha.xcworkspace -scheme AtchaV2 -configuration Stage \ + -destination 'generic/platform=iOS Simulator' build + +# 해당 Phase에서 만들거나 수정한 모듈의 테스트 +xcodebuild -workspace Atcha.xcworkspace -scheme <모듈명> \ + -destination 'platform=iOS Simulator,name=iPhone 17' test +``` + +### 공통 constraints + +- **레거시 보호**: `Atcha-iOS.xcodeproj`와 `Atcha-iOS/` 소스는 읽기 전용. 절대 수정·삭제 금지 (CI/fastlane이 직접 빌드 중). +- **xcconfig 참조 금지**: 새 모듈·타겟에 xcconfig 의존을 추가하지 않는다. 환경 분기는 `AppEnvironment` 컴파일 플래그(DEV/STAGE/LIVE)로만. +- **의존 규칙**: Feature는 `AtchaData`를 절대 import하지 않는다. Domain은 무의존. 구체 Data/Network 타입은 `AppDIContainer`(조합 루트)만 본다. `tuist graph --format dot --no-open`으로 검증 가능. +- **외부 라이브러리는 앱 타겟에서만 링크** (내부 모듈 전부 static framework — 중복 심볼 방지). 내부 모듈에서 `import Firebase*` 금지. +- **`Tuist/Package.swift`·`Tuist/Package.resolved` 무변경**: 필요한 SPM(Firebase 3종, SnapKit)은 이미 선언·링크돼 있다. 새 의존성이 필요해 보이면 멈추고 사용자에게 물을 것. +- **Swift 6 + isolation**: 모든 ViewModel `@MainActor`, 비동기는 `Task` 보관 + `deinit`에서 cancel + `[weak self]` + `Task.isCancelled` 가드. Domain/AtchaData/CoreNetwork 및 신규 Core 모듈은 `.nonisolated` — `@MainActor` 어노테이션 유입 금지. +- **테스트는 Swift Testing** (`@Test`/`#expect`). XCTest 금지. +- **모듈 규율**: catch-all `Shared`/`Common` 금지 (목적별 단일 모듈). 모듈명 `Data` 금지 (Foundation.Data 섀도잉 — Data 레이어는 `AtchaData`). +- **UI**: UIKit 코드 기반(스토리보드 없음) + SnapKit + DS 토큰(`DSColor`/`DSFont`/`DSSpacing`) 경유. 색·폰트·간격 하드코딩 금지. +- **신규 프로젝트는 `Workspace.swift` 등록 필수** (누락 시 스킴이 생성되지 않는다). +- 레거시 코드는 **의미만 이식**한다: Alamofire·RxSwift 등 레거시 의존이 섞인 코드 복붙 금지 (V2는 URLSession + Swift Concurrency). + +### 목표 모듈 지도 (전 Phase 완료 시점) + +``` +AtchaV2 (앱, 조합 루트: 어댑터/FCM/스플래시) + ├─► HomeFeature ──► {HomeFeatureInterface, SearchFeatureInterface, Domain, DesignSystem, CoreCoordinator, SnapKit} + ├─► SearchFeature ─► {SearchFeatureInterface, Domain, DesignSystem, CoreCoordinator, SnapKit} + ├─► AtchaData ────► {Domain, CoreNetwork, CoreStorage} + ├─► CoreAuth ─────► {CoreNetwork, CoreStorage} ← import하는 곳은 App뿐 + ├─► CoreAlarm (무의존, AlarmKit 유일 import 지점) ← import하는 곳은 App뿐 + └─► CoreStorage / CoreNetwork / CoreCoordinator / DesignSystem / Domain(무의존) +``` + +신규 모듈 4개: `SearchFeature`(`Project.feature()`), `CoreStorage`·`CoreAuth`·`CoreAlarm`(`Project.layer()`, 전부 `.nonisolated`). + +디바이스 능력(위치·알람)은 **Domain 포트 + App 어댑터** 패턴: Domain에 순수 프로토콜(`LocationService`, `AlarmScheduler`)만 두고, CoreLocation/AlarmKit을 아는 어댑터는 `Projects/App/Sources/Adapters/`에 둔다. Feature는 UseCase 프로토콜만 본다. + +### 서버 계약 뼈대 (직접 수록 — 필드 상세는 레거시 파일 참조) + +모든 응답은 envelope로 감싸져 있다 (`Atcha-iOS/Core/Network/API/APIResponse.swift` 참고): + +```swift +struct APIResponse: Decodable { + let responseCode: ... // 정확한 타입·성공값은 레거시 파일에서 확인 + let result: T? +} +``` + +| 용도 | 엔드포인트 | 비고 | +|---|---|---| +| 막차 경로 검색 | `GET /routes/last-routes?startLat&startLon&endLat&endLon` (레거시 실측) | 결과 목록의 첫 항목이 "가장 늦은 차", 나머지가 더보기 대안 | +| 경로 상세 | `GET /routes/last-routes/{routeId}` | | +| 알람(사용자 경로) 등록/삭제/조회 | `POST` / `DELETE` / `GET /routes/user-routes` | 단일 알람 정책: 등록 전 기존 것 삭제 또는 서버 교체 규약 확인 | +| 알람 시각 갱신 조회 | `GET /routes/user-routes/refresh` | 폴링 폴백과 푸시 수신 후 재조회 공용 | +| 장소 키워드 검색 | `GET /locations` (keyword·좌표 쿼리) | | +| 역지오코딩 | `GET /locations/rgeo` (좌표 쿼리) | 현재 위치 → 출발지 라벨 | +| 토큰 리프레시 | `GET /auth/reissue` | 리프레시 토큰을 `Authorization: Bearer`로 전달 (레거시 실측) | + +쿼리 파라미터 이름·DTO 필드는 레거시 Repository/DTO 파일에서 확인해 이식한다. **이식 금지 목록**: SSE 스트림(`/routes/**/stream`), 서버 최근검색(`/locations/histories`, `/locations/history`), 실시간 도착(`/routes/user-routes/subway-arrival`, `bus-arrival`, `/transits/*`), 소셜 로그인(`/auth/login`, `/auth/sign-up`) — 전부 이번 스코프 밖. + +### 레거시 참고 파일 표 (읽기 전용) + +| 용도 | 경로 | +|---|---| +| 인증 의미 원본 (Bearer·공개경로·401 처리) | `Atcha-iOS/Core/Network/Token/TokenInterceptor.swift`, `TokenStorage.swift` | +| envelope·에러 규약 | `Atcha-iOS/Core/Network/API/APIResponse.swift`, `APIError.swift` | +| 막차 검색 API·DTO | `Atcha-iOS/Data/Repository/CourseRepositoryImpl.swift`, `Atcha-iOS/Data/Model/CourseSearchDTO/CourseSearchResponse.swift` | +| 알람 API·DTO | `Atcha-iOS/Data/Repository/AlarmRepositoryImpl.swift` | +| 장소 검색 API·DTO | `Atcha-iOS/Data/Repository/Location/` | +| 시각 스펙 참고 (컴포넌트) | `Atcha-iOS/DesignSource/` (AtchaTextField/AtchaList/AtchaToast 등) | +| V2 표준 템플릿 | `Projects/Feature/Home/`, `Tuist/ProjectDescriptionHelpers/` | + +--- + +## Phase 1 — 서버 계약 이식: Domain 확장 + AtchaData 실 엔드포인트 + +### Goal +막차·장소·알람의 Domain 계약(엔티티/포트/UseCase)과 실서버 Data 구현을 완성한다 — UI 없이, 기존 더미 흐름과 병행. + +### Requirements +- **Domain 엔티티**: `Place`(이름·주소·좌표), `LastRoute`(경로 요약 + `TransportLeg` 목록 + 막차 출발 시각), `AlarmInfo`(lastRouteId, 알람 시각, 막차 출발 시각, updatedAt), 정규화 열거형: + ```swift + public enum LastRouteSearchResult: Sendable, Equatable { + case available([LastRoute]) // 첫 항목 = 가장 늦은 차 + case serviceEnded // 오늘 막차 종료 + case noRoute // 경로 없음 (도보권 등) + } + ``` +- **Domain 포트**: `PlaceRepository`(키워드 검색·역지오코딩), `LastRouteRepository`, `AlarmRepository`(서버 등록/삭제/refresh 조회), `RecentSearchRepository`(로컬 — 구현은 Phase 2), `AlarmScheduler`·`LocationService`(디바이스 포트 — 구현은 Phase 6~7). +- **UseCase** (프로토콜 + Default 구현): `SearchPlacesUseCase`, `SearchLastRoutesUseCase`(**정규화 책임** — 응답 코드/빈 목록 → `serviceEnded`/`noRoute` 매핑), `RegisterAlarmUseCase`(서버 등록 성공 → `AlarmScheduler.replaceAlarm` 순서, 단일 알람 정책), `CancelAlarmUseCase`, `RefreshAlarmUseCase`, `GetCurrentLocationUseCase`, `RecentSearchesUseCase`. +- **AtchaData**: `RouteEndpoint`/`PlaceEndpoint`/`AlarmEndpoint`(기존 `HomeEndpoint.swift` 패턴), `APIResponse` envelope 디코딩 헬퍼, DTO는 위 레거시 파일에서 이식(optional 남발 정리, V2 네이밍, `toEntity()` 패턴), RepositoryImpl 구현. +- **테스트**: 레거시 응답 형태의 **인라인 JSON 문자열 픽스처**로 DTO 디코딩·`toEntity()`·정규화 테스트. UseCase는 스텁 리포지토리로 정책(등록 순서·정규화) 테스트. + +### Constraints +- 기존 더미(`HomeSummary`/`FetchHomeUseCase`/`HomeRepositoryImpl`/`HomeEndpoint`) **삭제·수정 금지** — Phase 6에서 일괄 제거한다. 지금 지우면 HomeFeature·App 빌드가 붕괴한다. +- 이식 금지 목록(공통 규칙) 준수. Alamofire 타입 유입 금지. +- 픽스처는 리소스 파일 대신 인라인 문자열 (테스트 타겟 리소스 설정 회피). + +### Acceptance +공통 acceptance + `-scheme Domain test` + `-scheme AtchaData test`. + +### 사람 검수 +엔드포인트·DTO 매핑 표를 보고용으로 출력하고 확인받을 것. 특히 **"막차 종료/경로 없음"을 서버가 어떻게 표현하는지(responseCode 값)는 [미확정 입력](#미확정-입력-사용자-제공-대기)** — 실측값을 받으면 정규화 로직에 반영. + +--- + +## Phase 2 — CoreStorage 모듈 + 최근 검색 로컬 저장 + +### Goal +목적별 저장 모듈(CoreStorage)을 신설하고 최근 검색 로컬 저장을 구현한다. + +### Requirements +- `Projects/Core/Storage`에 `Project.layer(name: "CoreStorage", bundleSuffix: "core.storage", isolation: .nonisolated)` + **Workspace.swift 등록**. +- `KeyValueStore` 프로토콜 + `UserDefaultsKeyValueStore` + `KeychainStore`(레거시 `TokenStorage.swift`의 키체인+메모리 캐시 패턴 참고, 프로토콜 기반 재작성 — Phase 3의 토큰 저장에 재사용) + Codable 저장 헬퍼. +- AtchaData에 `RecentSearchRepositoryImpl`(CoreStorage 사용): 최대 개수 제한(예: 10개), 중복 검색 시 최신으로 갱신, 최신순 정렬, 삭제 지원. `Projects/Data/Project.swift`에 CoreStorage 의존 추가. + +### Constraints +- 테스트는 **인메모리 `KeyValueStore` 스텁**으로 (실 UserDefaults 사용 금지 — 테스트 오염). +- 앱 타겟 의존성에 CoreStorage를 추가하지 않는다 (AtchaData 경유; App이 직접 필요해지는 건 Phase 3의 CoreAuth부터). + +### Acceptance +공통 acceptance + `-scheme CoreStorage test` + `-scheme AtchaData test`. + +--- + +## Phase 3 — CoreAuth: 익명 세션 + 인증 데코레이터 + 스플래시 + +### Goal +로그인 UI 없는 익명 인증 체계를 구축하고, 모든 API 호출에 토큰을 투명하게 부착한다. + +### Requirements +- `Projects/Core/Auth`에 `Project.layer(name: "CoreAuth", bundleSuffix: "core.auth", isolation: .nonisolated, dependencies: [CoreNetwork, CoreStorage])` + Workspace 등록. +- `TokenStore`(KeychainStore 주입, 액세스/리프레시 토큰 보관). +- `AuthSessionManager` **actor**: 익명 세션 부트스트랩(최초 실행 시 발급), `GET /auth/reissue` 리프레시(리프레시 토큰을 Bearer 헤더로 — 레거시 실측), **single-flight**(동시 다발 401에도 리프레시는 1회 — 레거시 `TokenInterceptor.swift`의 대기열 의미를 actor로 재구현). +- `AuthenticatedNetworkClient: NetworkClient` **데코레이터**: 기존 `URLSessionNetworkClient`를 감싸 Bearer 부착 → 401 시 리프레시 1회 → 재시도 → 리프레시도 실패하면 **익명 세션 재발급 후 재시도**. 공개 경로(인증 헤더 제외) 목록은 주입 가능하게. +- `AppDIContainer`에서 NetworkClient를 데코레이터로 교체 (교체 지점은 이 한 곳). +- `AppCoordinator`에 스플래시 단계: 로고 화면 → 인증 부트스트랩 완료 후 홈 진입, 실패 시 재시도 UI. +- `AppEnvironment`의 `apiBaseURL` 플레이스홀더를 실제 값으로 교체 ([미확정 입력](#미확정-입력-사용자-제공-대기)). + +### Constraints +- 레거시 `SessionController.expireAndRouteToLogin` 패턴 **이식 금지** — V2에는 로그인 화면이 없다. +- CoreAuth를 import하는 곳은 **App뿐** (`tuist graph`로 확인). +- **AtchaData·Feature 코드가 한 줄도 안 바뀌어야 정상** — 바뀐다면 데코레이터 설계가 틀린 것. +- 테스트: 스텁 NetworkClient로 401→리프레시→재시도, single-flight 동시성, 공개 경로 예외 검증. + +### Acceptance +공통 acceptance + `-scheme CoreAuth test`. + +### 사람 검수 (블로킹) +**실 base URL과 익명 인증 발급 엔드포인트 스펙은 코드 어디에도 없다** (레거시는 소셜 로그인뿐, xcconfig는 gitignore). 코드·테스트는 완성하되, **실서버 스모크 전에 반드시 사용자에게 두 값을 확인**받을 것. + +--- + +## Phase 4 — DesignSystem 고도화 *(Phase 1~3과 병렬 가능)* + +### Goal +검색·결과·알람 UI에 필요한 재사용 컴포넌트를 DesignSystem에 추가한다. + +### Requirements +- 컴포넌트 신설: `DSTextField`(검색 입력), `DSListCell`(장소/경로 리스트 셀 — 최근검색·검색결과·대안경로 공용), `DSRouteCard`(선택 경로 요약 카드), `DSBanner`(상단 카운트다운 배너), `DSToast`, `DSEmptyState`(막차 종료/경로 없음/권한 거부 공용 — 아이콘+제목+본문+선택적 액션 버튼). +- 설명글용 캡션 스타일(작은 안내 텍스트) 추가. +- 필요한 색·간격이 토큰에 없으면 **토큰부터 추가**하고 컴포넌트가 토큰을 쓰게 한다. +- 레거시 `Atcha-iOS/DesignSource/`는 **시각 스펙 참고용으로만** (코드 복붙 금지 — 레거시 컨벤션·의존이 다름). + +### Constraints +- `.mainActor` isolation 유지 (DesignSystem 기존 설정). +- 에셋은 `DesignSystem.xcassets` + 기존 `asset(_:fallback:)` 폴백 패턴 준수 (static framework의 번들 처리). +- 기존 `DSButton` API 호환 유지 (파괴적 변경 금지). + +### Acceptance +공통 acceptance + `-scheme DesignSystem test`. + +### 사람 검수 +레이어 모듈엔 Example 타겟이 없으므로 시각 검수는 Phase 5·6의 Example 앱에서 수행한다. + +--- + +## Phase 5 — SearchFeature 신규 + +### Goal +장소 검색 → 막차 결과("가장 늦은 차" + 더보기) → 경로 선택 반환까지의 검색 플로우를 독립 실행 가능한 피처로 만든다. + +### Requirements +- `Projects/Feature/Search`에 `Project.feature(name: "Search", ...)` 신설 (의존 구성은 `Projects/Feature/Home/Project.swift`를 그대로 본뜸) + Workspace 등록. +- Interface 타겟에 노출: + ```swift + @MainActor + public protocol SearchCoordinatorBuildable { + func makeSearchCoordinator( + navigationController: UINavigationController, + onRouteSelected: @escaping (LastRoute) -> Void + ) -> any Coordinator + } + ``` +- 화면 구성: + - 출발지·도착지 입력 슬롯 (`DSTextField`), 키워드 검색은 **디바운스 + 이전 Task cancel**. + - 최근 검색 리스트 (선택 시 즉시 적용, 스와이프/버튼 삭제). + - 두 지점 확정 시 막차 결과: 최상단 "가장 늦은 차" 강조(`DSRouteCard`) + "더보기" 탭 시 대안 경로 목록 확장(`DSListCell`). + - `serviceEnded`/`noRoute` 상태는 `DSEmptyState`로: 막차 종료 → "오늘 막차가 끊겼어요" + 다음 운행 안내(서버 제공 시)/재검색 유도, 경로 없음 → "대중교통 경로를 찾지 못했어요" + 재검색 유도. +- 경로 선택 → `onRouteSelected(entity)` 호출 + `finish()` (finishDelegate로 부모가 제거). +- ViewModel 테스트(스텁 UseCase: 성공/막차 종료/경로 없음/검색 실패), Example 앱은 스텁으로 전체 플로우 시연. + +### Constraints +- **AtchaData import 금지** — UseCase 프로토콜만. +- Interface 타겟에는 프로토콜 + 최소 타입만 (구현 유출 금지). +- Entity를 뷰에 직접 노출 금지 — ViewData로 감쌀 것. +- navigationController는 weak (앱 루트만 강한 소유). +- 스텁이 Tests/Example에 중복되는 것은 의도된 트레이드오프 (기존 규약). + +### Acceptance +공통 acceptance + `-scheme SearchFeature test` + `SearchFeatureExample` Debug 빌드. + +### 사람 검수 +`SearchFeatureExample`을 시뮬레이터에서 실행해 검색 UX(디바운스, 더보기 확장, 3가지 빈 상태) 시연. + +--- + +## Phase 6 — HomeFeature 개편 (더미 제거 + 위치 권한 + 검색 연결) + +### Goal +플레이스홀더 홈을 실제 홈으로 교체한다 — 현재 위치 출발지, 검색 진입, 선택 경로 표출, 알람 등록 버튼(로컬 스케줄은 아직 no-op). + +### Requirements +- **홈 화면**: 출발지(현재 위치 기본값 — `GetCurrentLocationUseCase` + 역지오코딩 라벨)/도착지 필드 → 탭 시 `SearchCoordinatorBuildable`로 검색 플로우 시작 → `onRouteSelected` 수신 시 경로 카드(`DSRouteCard`) 표출. +- **위치 권한 플로우**: 홈 최초 진입 시 WhenInUse 요청. denied → 출발지 빈 상태 + "출발지를 검색해 주세요" 유도 + 설정 이동 안내. `Projects/App/Project.swift` infoPlist에 `NSLocationWhenInUseUsageDescription` 추가. +- App에 `CoreLocationServiceAdapter`(CLLocationManager → Domain `LocationService`) 구현·주입 (`Projects/App/Sources/Adapters/`). +- **알람 등록 버튼**: `RegisterAlarmUseCase` 호출 — 서버 등록은 실동작, `AlarmScheduler`는 App의 `NoopAlarmScheduler` 임시 어댑터 (Phase 7에서 교체). +- **설명글 2종** 상시 노출 (캡션 스타일): "막차 시간과 가까워질수록 정확해져요" / "알람 시간은 막차 환경에 따라 변경될 수 있어요". +- **상단 배너**: 알람 등록 상태면 `DSBanner`에 "막차 출발까지 N분" — 1분 단위 타이머 갱신 (실서버 갱신 연동은 Phase 7~8). +- **더미 일괄 청소**: `HomeSummary`·`FetchHomeUseCase`·`HomeRepository(+Impl)`·`HomeEndpoint`·`HomeSummaryRequestDTO/ResponseDTO`·관련 테스트 제거. `HomeDIContainer`·`AppDIContainer` 재구성. `Projects/Feature/Home/Project.swift`에 `SearchFeatureInterface` 의존 추가 + `tuist generate`. + +### Constraints +- 더미 제거는 **이 Phase에서 일괄** (부분 제거 시 App 빌드 붕괴). +- HomeFeature가 SearchFeature **본체를 import하면 안 됨** — Interface만 (`tuist graph`로 확인). +- 타이머는 ViewModel이 Task로 보관 + `deinit` cancel. +- Example 앱은 스텁 LocationService·UseCase로 실행 가능해야 함. + +### Acceptance +공통 acceptance + `-scheme HomeFeature test` + `HomeFeatureExample` Debug 빌드 + `tuist graph`로 의존 규칙 확인. + +### 사람 검수 (중요) +시뮬레이터에서 **스플래시 → 홈 → 검색 → 경로 선택 → 홈 복귀 → (Noop) 알람 등록 → 배너 표시** 전체 플로우 시연 후 확인받을 것. + +--- + +## Phase 7 — CoreAlarm: AlarmKit 스케줄링 E2E + +### Goal +AlarmKit 래퍼 모듈을 만들고 알람 등록을 실제 디바이스 알람까지 연결한다 (단일 알람 교체 정책). + +### Requirements +- `Projects/Core/Alarm`에 `Project.layer(name: "CoreAlarm", bundleSuffix: "core.alarm", isolation: .nonisolated)` (무의존 — Domain을 import하지 않는다) + Workspace 등록. **AlarmKit import는 이 모듈이 유일.** +- 중립 타입 API: + ```swift + public struct AlarmSpec: Sendable { public let id: String; public let fireDate: Date; public let title: String } + public protocol AlarmKitScheduling: Sendable { + func requestAuthorization() async -> Bool + func replaceAlarm(_ spec: AlarmSpec) async throws // 전부 취소 후 등록 (단일 알람 정책) + func cancelAll() async + func scheduledAlarm() async -> AlarmSpec? + } + ``` +- App의 `NoopAlarmScheduler`를 CoreAlarm 기반 어댑터(CoreAlarm ↔ Domain `AlarmScheduler` 매핑)로 교체. +- 등록 플로우 완성: 버튼 탭 → **AlarmKit 권한 요청(이 시점이 최초)** → 서버 등록 → 로컬 스케줄 → 배너 표시. 권한 denied → 토스트 + 설정 이동 안내, 서버 등록 보류. +- 알람 해제 플로우: `CancelAlarmUseCase` → 서버 삭제 + 로컬 취소 + 배너 숨김. +- 포그라운드 복귀 시 `RefreshAlarmUseCase` 재조회 → 시각 변경 시 재스케줄 + 배너 갱신 (SceneDelegate → 알림 경유). + +### Constraints +- AlarmKit 심볼이 Domain·Feature로 새어나가면 안 됨 (어댑터는 App에만). +- 단위 테스트는 AlarmKit 직접 호출 없이 — 교체·순서 정책은 Domain UseCase를 스텁 스케줄러로 테스트. +- **entitlements**: 현재 `NSAlarmKitUsageDescription`만 있고 entitlements 파일이 없다. AlarmKit 권한 요청이 실패하면 `Projects/App/Project.swift`에 Tuist `entitlements:` DSL로 추가 (수동 파일 생성 대신 매니페스트로). +- iOS 26 전용 API — 가용성 어노테이션 불필요 (배포 타겟이 이미 26.0). + +### Acceptance +공통 acceptance + `-scheme CoreAlarm test` + `-scheme Domain test`(정책 테스트). + +### 사람 검수 (블로킹) +**권한 다이얼로그와 실제 알람 발화는 자동 검증 불가** — iOS 26 시뮬레이터/실기기에서 사용자가 직접 확인해야 다음 Phase 진행. + +--- + +## Phase 8 — 갱신 채널: FCM 사일런트 푸시(가드) + 폴링 폴백 + 하드닝 + +### Goal +서버발 알람 시각 갱신을 FCM(가능할 때)·폴링(항상)으로 반영하고, 전체 시나리오를 마감한다. + +### Requirements +- **AppDelegate**: 기존 `GoogleService-Info.plist` 존재 가드 패턴 유지. 구성 성공 시에만 `MessagingDelegate` 설정 + `registerForRemoteNotifications()` 호출 (**알림 권한 프롬프트 없음** — 사일런트 푸시는 사용자 알림 권한 불필요). +- `didReceiveRemoteNotification`(content-available=1) 수신 → refresh 조회 → 재스케줄·배너 갱신. +- `Projects/App/Project.swift` infoPlist에 `UIBackgroundModes: ["remote-notification"]` 추가. +- **`AlarmSyncService`(App)로 갱신 일원화**: 앱 시작·포그라운드 복귀·푸시 수신 3경로가 전부 같은 `RefreshAlarmUseCase`를 경유하게 리팩터링 (Phase 7의 복귀 로직 흡수). +- FCM 토큰 서버 전달: 방식이 [미확정 입력](#미확정-입력-사용자-제공-대기) — 스펙 확인 후 구현, 그전까지는 토큰 로깅만. +- **하드닝 체크리스트** (전부 점검·수정): 막차 종료/경로 없음 UX, 위치·AlarmKit 권한 거부, 오프라인(네트워크 에러 시 토스트+재시도), 알람 교체(기존 알람 있는 상태에서 새 경로 등록), 배너 시각과 실제 알람 시각 일치, plist 부재 시 FCM 경로 완전 비활성. + +### Constraints +- **plist 부재 상태에서 전 acceptance 통과가 기본선** — FCM 코드는 전부 dead-path여야 하고, 폴링만으로 전 기능이 성립해야 한다. +- 사일런트 푸시에 `UNUserNotificationCenter.requestAuthorization` 호출 금지. +- 내부 모듈 Firebase import 금지, `Tuist/Package.swift`·`Package.resolved` 무변경 확인 (git diff로). + +### Acceptance +공통 acceptance + **Release 구성 빌드 1회 추가** + 전 모듈 테스트 스킴 일괄(`Domain`/`AtchaData`/`CoreStorage`/`CoreAuth`/`CoreAlarm`/`DesignSystem`/`SearchFeature`/`HomeFeature`) + `tuist graph`로 최종 의존 규칙 검증(모듈 지도와 일치). + +### 사람 검수 +`GoogleService-Info.plist` 발급 시 `Projects/App/Resources/`에 투입 → Firebase 자동 활성·푸시 수신 확인 (발급 전엔 폴링만으로 시연). + +--- + +## 진행 프로토콜 + +1. **한 번에 한 Phase만.** 사용자가 지정한 Phase의 시작 절차(공통 규칙)부터 수행한다. +2. Phase 진행 중 **뒤 Phase의 산출물을 선취하지 않는다** (예: Phase 1에서 더미 제거, Phase 5에서 홈 연결). +3. Phase 완료 시 **acceptance 명령을 전부 실행하고 결과를 그대로 보고**한다 (실패를 숨기지 않는다). +4. **사람 검수 포인트가 있으면 정지**하고 확인을 요청한다. "블로킹" 표시가 있으면 확인 전 다음 Phase 진행 금지. +5. 이 문서의 결정사항과 다른 방향이 필요해 보이면 **임의로 바꾸지 말고 근거와 함께 사용자에게 물을 것.** +6. [미확정 입력](#미확정-입력-사용자-제공-대기)이 필요한 시점이 오면 사용자에게 요청하고, 받기 전까지는 명시된 임시 동작(플레이스홀더·로깅)으로 진행한다. +7. Phase 4(DesignSystem)는 Phase 1~3과 독립이므로 병렬(먼저) 실행 가능. 그 외에는 번호 순서가 기본값. + +## 미확정 입력 (사용자 제공 대기) + +| # | 항목 | 필요한 Phase | 받기 전 임시 동작 | +|---|---|---|---| +| 1 | 실서버 base URL (Dev/Stage/Live) | 3 | `AppEnvironment` 플레이스홀더 유지, 실서버 스모크 보류 | +| 2 | 익명 인증 발급 엔드포인트 스펙 (레거시엔 소셜 로그인뿐) | 3 | 프로토콜·스텁으로 구현, 실호출 보류 | +| 3 | "막차 종료"/"경로 없음"의 서버 표현 (responseCode 실측값) | 1 | 빈 목록 = `serviceEnded` 가정, TODO 주석 | +| 4 | 알람 등록 API의 단일 알람 규약 (서버가 교체? 클라가 삭제 후 등록?) | 1, 7 | 클라가 삭제 후 등록으로 가정, TODO 주석 | +| 5 | FCM 토큰 서버 전달 방식 (익명 체계에서) | 8 | 토큰 로깅만, 전달 보류 | +| 6 | `com.atcha.iOS.v2`용 GoogleService-Info.plist | 8 | plist 가드로 FCM 비활성, 폴링만 동작 |