diff --git a/CLAUDE.md b/CLAUDE.md index 3b8e65fb..4d447ec0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,7 +79,7 @@ AtchaV2(앱, 조합 루트: 어댑터·스플래시·AlarmSyncService) ─► Ho ### 미완 상태 (작업 시 참고) -- **익명 인증 발급 엔드포인트 미확정** — `UnconfiguredAnonymousSessionIssuer` 스텁이 주입돼 있어 실서버에서는 토큰 없이 동작한다(알람 서버 기능 전부 불가). Debug(DEV)는 `DevDemoFallbacks`가 데모 데이터로 가린다 — 실서버 스펙 확정 시 이 폴백과 `AppDIContainer`의 `#if DEV` 주입을 제거할 것. +- **인증은 소셜 로그인(카카오·애플), 가입까지 완결** — 강제 로그인(게스트 없음)으로 `AuthFeature`가 최초 토큰을 발급받는다(`GET /auth/check`·`/auth/login`·`POST /auth/sign-up`, 소셜 토큰을 Bearer로 — plain client 전용). 미가입 계정은 `SignInUseCase`가 온보딩 화면 없이 **최소 가입**(현재 위치+역지오코딩 주소, 실패 시 레거시 실측 폴백 `""`·(0,0), 알림 빈도 [1,10])까지 자동 수행한다. 로그아웃(`POST /auth/logout` — refresh 토큰을 Bearer로 싣는 특례, CoreAuth `AuthSessionManager.signOut()`)·회원탈퇴(`DELETE /members/me`)·유저 정보(`GET /members/me`, 홈주소/알림빈도 PATCH — `UserRepository`)는 UseCase까지 구현됐고 **설정 화면은 미구현**(로그아웃 진입점은 DEV 플로팅 메뉴뿐). 카카오 키는 `TUIST_KAKAO_APP_KEY` env로 주입(미주입 시 카카오 버튼만 런타임 비활성). 잔여: 카카오/Apple 콘솔에 V2 번들 등록, 서버 audience 허용 확인. `DevDemoFallbacks`(실패 은폐 데모 폴백)와 "DEV 건너뛰기"는 제거됨 — DEV에 남은 것은 막차 변경 주입용 `DevChangeSimulator`(refresh 가로채기 전용, 에러 은폐 없음)뿐이라 서버 실패가 DEV에서도 그대로 표면화된다. - `AppEnvironment`의 base URL은 dev/live 실주소 반영 완료. **Stage는 dev 호스트를 공유 중** — 전용 호스트만 미정. - `Projects/App/Resources/GoogleService-Info.plist`는 **레거시 번들 ID(`com.atcha.iOS`)용 파일**이라 존재 가드만 통과할 뿐 V2(`com.atcha.iOS.v2`)로의 사일런트 푸시가 성립하지 않는다 — V2용 재발급·교체 필요. FCM 토큰 서버 전달도 미구현(로깅만)이라 갱신 채널은 현재 폴링(앱 시작·포그라운드 복귀)과 홈 pull-to-refresh(수동)뿐. - AtchaV2는 iOS 26 전용. AlarmKit(CoreAlarm)·Live Activity(CoreLiveActivity + AtchaWidget 익스텐션)는 Phase 9~12에서 구축 완료. diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift index 4074bba7..a14f5c86 100644 --- a/Projects/App/Project.swift +++ b/Projects/App/Project.swift @@ -1,34 +1,51 @@ import ProjectDescription import ProjectDescriptionHelpers +// 카카오 네이티브 앱 키 — xcconfig 금지 규약(AtchaV2) 하에서 env(TUIST_KAKAO_APP_KEY)로 +// 주입한다(mise [env] 또는 CI secret). 미주입이면 빈 문자열: generate/빌드는 통과하고 +// 카카오 로그인만 런타임 비활성(providerUnavailable → 실패 토스트)이다. +let kakaoAppKey = Environment.kakaoAppKey.getString(default: "") + +var appInfoPlist: [String: Plist.Value] = [ + "CFBundleDisplayName": "앗차", + "UILaunchStoryboardName": "LaunchScreen", + "NSAlarmKitUsageDescription": "막차 시간에 맞춰 알람을 울리기 위해 권한이 필요합니다.", + "NSLocationWhenInUseUsageDescription": "현재 위치를 출발지로 사용하기 위해 위치 정보 접근 권한이 필요합니다.", + // 사일런트 푸시(content-available=1) 수신용 — 사용자 알림 권한과 무관. + "UIBackgroundModes": ["remote-notification"], + "NSSupportsLiveActivities": true, + "UIApplicationSceneManifest": [ + "UIApplicationSupportsMultipleScenes": false, + "UISceneConfigurations": [ + "UIWindowSceneSessionRoleApplication": [ + [ + "UISceneConfigurationName": "Default", + "UISceneDelegateClassName": "$(PRODUCT_MODULE_NAME).SceneDelegate", + ], + ], + ], + ], + "UISupportedInterfaceOrientations": ["UIInterfaceOrientationPortrait"], + "ITSAppUsesNonExemptEncryption": false, + // 카카오톡 로그인 앱 전환 조회용(레거시 Info.plist 실측). + "LSApplicationQueriesSchemes": ["kakaokompassauth", "kakaolink"], + // 런타임(KakaoConfig)이 읽는 키 — 빈 값이면 KakaoSDK.initSDK를 건너뛴다. + "KAKAO_APP_KEY": .string(kakaoAppKey), +] +// 키 미주입 시 무의미한 `kakao` 빈 스킴이 등록되지 않도록 조건 분기. +if !kakaoAppKey.isEmpty { + appInfoPlist["CFBundleURLTypes"] = [ + ["CFBundleURLSchemes": ["kakao\(kakaoAppKey)"]], + ] +} + let appTarget = Target.target( name: "AtchaV2", destinations: Atcha.destinations, product: .app, bundleId: Atcha.v2BundleID, deploymentTargets: Atcha.v2Deployment, - infoPlist: .extendingDefault(with: [ - "CFBundleDisplayName": "앗차", - "UILaunchStoryboardName": "LaunchScreen", - "NSAlarmKitUsageDescription": "막차 시간에 맞춰 알람을 울리기 위해 권한이 필요합니다.", - "NSLocationWhenInUseUsageDescription": "현재 위치를 출발지로 사용하기 위해 위치 정보 접근 권한이 필요합니다.", - // 사일런트 푸시(content-available=1) 수신용 — 사용자 알림 권한과 무관. - "UIBackgroundModes": ["remote-notification"], - "NSSupportsLiveActivities": true, - "UIApplicationSceneManifest": [ - "UIApplicationSupportsMultipleScenes": false, - "UISceneConfigurations": [ - "UIWindowSceneSessionRoleApplication": [ - [ - "UISceneConfigurationName": "Default", - "UISceneDelegateClassName": "$(PRODUCT_MODULE_NAME).SceneDelegate", - ], - ], - ], - ], - "UISupportedInterfaceOrientations": ["UIInterfaceOrientationPortrait"], - "ITSAppUsesNonExemptEncryption": false, - ]), + infoPlist: .extendingDefault(with: appInfoPlist), sources: ["Sources/**"], resources: ["Resources/**"], // APNs 등록에 필수 (없으면 didFailToRegister: "aps-environment 없음"). @@ -37,9 +54,13 @@ let appTarget = Target.target( "aps-environment": "development", // 폴백 노티의 .timeSensitive interruptionLevel용(Phase 15) — 집중 모드 관통. "com.apple.developer.usernotifications.time-sensitive": true, + // 소셜 로그인(Apple) — App ID capability 활성 + 프로비저닝 갱신 필요(콘솔 작업). + "com.apple.developer.applesignin": ["Default"], ]), dependencies: [ .target(name: "AtchaWidget"), + .project(target: "AuthFeature", path: "../Feature/Auth"), + .project(target: "AuthFeatureInterface", path: "../Feature/Auth"), .project(target: "HomeFeature", path: "../Feature/Home"), .project(target: "HomeFeatureInterface", path: "../Feature/Home"), .project(target: "SearchFeature", path: "../Feature/Search"), @@ -56,6 +77,11 @@ let appTarget = Target.target( .external(name: "FirebaseCore"), .external(name: "FirebaseCrashlytics"), .external(name: "FirebaseMessaging"), + // 소셜 SDK는 앱 타겟에서만 링크(내부 모듈 static framework 중복 심볼 방지 규약) + // — import 지점은 SocialLoginAdapter·AppDelegate·SceneDelegate뿐. + .external(name: "KakaoSDKCommon"), + .external(name: "KakaoSDKAuth"), + .external(name: "KakaoSDKUser"), ], settings: .atchaV2(base: [ // Firebase static libraries under XcodeProj-based integration need diff --git a/Projects/App/Sources/Adapters/AuthSessionEndingAdapter.swift b/Projects/App/Sources/Adapters/AuthSessionEndingAdapter.swift new file mode 100644 index 00000000..10a2d547 --- /dev/null +++ b/Projects/App/Sources/Adapters/AuthSessionEndingAdapter.swift @@ -0,0 +1,17 @@ +import CoreAuth +import Domain + +/// Domain의 세션 종료 포트를 CoreAuth의 AuthSessionManager로 배선한다. +/// 두 경로 모두 sessionExpired yield를 거치므로 로그인 복귀 라우팅은 +/// AppCoordinator의 기존 만료 관찰이 그대로 처리한다. +struct AuthSessionEndingAdapter: SessionEnding { + let sessionManager: AuthSessionManager + + func endSession() async { + await sessionManager.signOut() + } + + func invalidateLocalSession() async { + await sessionManager.invalidateSession() + } +} diff --git a/Projects/App/Sources/Adapters/AuthSessionStoreAdapter.swift b/Projects/App/Sources/Adapters/AuthSessionStoreAdapter.swift new file mode 100644 index 00000000..2a37ed3e --- /dev/null +++ b/Projects/App/Sources/Adapters/AuthSessionStoreAdapter.swift @@ -0,0 +1,14 @@ +import CoreAuth +import Domain + +/// `SessionStoring` → `AuthSessionManager.adopt` 배선 — Domain이 CoreAuth를 모른 채 +/// 로그인 결과 토큰을 세션에 채택시키는 다리. +struct AuthSessionStoreAdapter: SessionStoring { + let sessionManager: AuthSessionManager + + func store(accessToken: String, refreshToken: String) async throws { + try await sessionManager.adopt( + TokenPair(accessToken: accessToken, refreshToken: refreshToken) + ) + } +} diff --git a/Projects/App/Sources/Adapters/DevChangeSimulator.swift b/Projects/App/Sources/Adapters/DevChangeSimulator.swift new file mode 100644 index 00000000..003f1404 --- /dev/null +++ b/Projects/App/Sources/Adapters/DevChangeSimulator.swift @@ -0,0 +1,126 @@ +#if DEV +import Domain +import Foundation + +// Phase 11 변경 시뮬레이터 (DEV 한정 — 자동 검수 규약의 전제). +// +// 막차 "변경"(앞당김/늦춤/운행종료) → 판정 → 알람 재스케줄 → LA alert/홈 배너 경로는 +// 실서버가 임의로 재현해줄 수 없다. refresh 주입 가로채기만 남긴 슬림 데코레이터로, +// 과거 DevDemoFallbacks의 "실패 시 데모 데이터·에러 무시" 폴백은 전부 제거됐다 — +// 서버 실패는 이제 DEV에서도 그대로 표면화된다. + +/// AlarmRepository 데코레이터 — 보류 중 주입이 있을 때만 refresh를 가로챈다. +/// register/cancel은 base에 그대로 위임한다(에러 은폐 없음). +struct DevChangeSimulatingAlarmRepository: AlarmRepository { + let base: any AlarmRepository + + func register(lastRouteId: String) async throws { + try await base.register(lastRouteId: lastRouteId) + // 새 세션 시작 — 변경 시뮬레이터가 이 경로를 기준으로 변형한다. + await DevChangeSimulator.shared.noteKnownSession(routeId: lastRouteId, departureTime: nil) + } + + func cancel(lastRouteId: String) async throws { + try await base.cancel(lastRouteId: lastRouteId) + } + + func refresh() async throws -> AlarmInfo { + // 보류 중 변경 주입이 있으면 서버 대신 시뮬레이터가 응답한다(1회 소비). + if let injected = await DevChangeSimulator.shared.consumeInjectedInfo() { + print("⚠️ [DEV 변경 시뮬레이터] 변형 AlarmInfo 반환: departure=\(String(describing: injected.departureTime))") + return injected + } + let info = try await base.refresh() + await DevChangeSimulator.shared.noteKnownSession( + routeId: info.lastRouteId, departureTime: info.departureTime + ) + return info + } +} + +/// 막차 변경 피기백(판정 → 알람 재스케줄 → LA alert/홈 배너)을 검수할 유일한 주입 수단. +/// `DevChangeSimulatingAlarmRepository.refresh()`가 보류 중 주입을 소비해, +/// 마지막으로 알려진 출발 시각 기준으로 변형된 AlarmInfo를 반환한다. +@MainActor +final class DevChangeSimulator { + static let shared = DevChangeSimulator() + + enum Injection { + /// 출발 시각을 N초 앞당긴다 → advanced 판정 유도. + case advance(TimeInterval) + /// 출발 시각을 N초 늦춘다 → delayed 판정 유도. + case delay(TimeInterval) + /// 운행 종료 — departureTime 없는 응답으로 sessionEnded 판정 유도. + case end + } + + /// 알려진 세션의 UserDefaults 키 (Phase 14 검수) — 강제 종료·재실행 검수에서 + /// 기준 출발 시각을 잃으면 주입 diff·카드 복원 시각이 어긋나므로 DEV 한정 영속화한다. + private static let knownRouteIdKey = "dev.sim.knownRouteId" + private static let knownDepartureKey = "dev.sim.knownDeparture" + + private var pending: Injection? + /// 마지막으로 알려진 세션 — 등록·refresh 성공·주입 적용 시 갱신된다. + private var knownRouteId: String? + private var knownDepartureTime: Date? + + private init() { + knownRouteId = UserDefaults.standard.string(forKey: Self.knownRouteIdKey) + let epoch = UserDefaults.standard.double(forKey: Self.knownDepartureKey) + knownDepartureTime = epoch > 0 ? Date(timeIntervalSince1970: epoch) : nil + } + + /// 주입 예약 — 다음 refresh() 1회가 소비한다. + func inject(_ injection: Injection) { + pending = injection + print("⚠️ [DEV 변경 시뮬레이터] 주입 보류: \(injection)") + } + + /// departureTime이 nil이면 routeId만 갱신한다(등록 경로는 출발 시각을 모른다). + func noteKnownSession(routeId: String, departureTime: Date?) { + knownRouteId = routeId + UserDefaults.standard.set(routeId, forKey: Self.knownRouteIdKey) + if let departureTime { + knownDepartureTime = departureTime + UserDefaults.standard.set( + departureTime.timeIntervalSince1970, forKey: Self.knownDepartureKey + ) + } + } + + /// 알려진 세션의 출발 시각 — routeId가 일치할 때만 (상세 복원의 시각 정합용). + func knownSessionDeparture(routeId: String) -> Date? { + knownRouteId == routeId ? knownDepartureTime : nil + } + + /// 보류 중 주입을 소비해 변형된 AlarmInfo를 만든다. 주입이 없으면 nil. + /// 적용 결과를 기준 시각으로 다시 캐시하므로 주입을 연달아 합성할 수 있다 + /// (예: "10분 늦춤" 뒤 "5분 앞당김" → actionable alert 경로 검수). + func consumeInjectedInfo(now: Date = Date()) -> AlarmInfo? { + guard let injection = pending else { return nil } + pending = nil + let routeId = knownRouteId ?? "dev-demo-route" + // 기준 출발 시각: 캐시가 없으면 데모 경로 기본값(now+3분)과 같은 가정. + let base = knownDepartureTime ?? now.addingTimeInterval(3 * 60) + + let departure: Date? + switch injection { + case .advance(let seconds): departure = base.addingTimeInterval(-seconds) + case .delay(let seconds): departure = base.addingTimeInterval(seconds) + case .end: departure = nil + } + knownDepartureTime = departure + if let departure { + UserDefaults.standard.set( + departure.timeIntervalSince1970, forKey: Self.knownDepartureKey + ) + } + return AlarmInfo( + lastRouteId: routeId, + departureTime: departure, + updatedAt: now, + isReal: true + ) + } +} +#endif diff --git a/Projects/App/Sources/Adapters/DevDemoFallbacks.swift b/Projects/App/Sources/Adapters/DevDemoFallbacks.swift deleted file mode 100644 index cd5def65..00000000 --- a/Projects/App/Sources/Adapters/DevDemoFallbacks.swift +++ /dev/null @@ -1,303 +0,0 @@ -#if DEV -import Domain -import Foundation - -// DEV 검수용 임시 우회 (Phase 7 사람 검수 — 사용자 지시로 추가). -// -// 실서버가 미확정 입력 #1(실 base URL)·#2(익명 인증) 상태라 접속 불가인 동안에도 -// 전체 플로우(검색 → 경로 선택 → 알람 등록 → 발화 → 해제)를 시연할 수 있게, -// 서버 호출이 "실패했을 때만" 데모 데이터로 대체하거나 실패를 무시한다. -// 서버가 살아나면 실데이터가 그대로 우선한다. Stage/Release에는 컴파일되지 않는다. -// 실서버 스펙 확정 시 이 파일과 AppDIContainer의 #if DEV 주입만 제거하면 된다. - -struct DevDemoFallbackPlaceRepository: PlaceRepository { - let base: any PlaceRepository - - /// Phase 17 검수 훅 — 이 좌표의 장소를 도착지로 고르면 경로 검색이 빈 목록을 반환한다 - /// (빈 목록 정규화 = serviceEnded 표면의 유일한 DEV 재현 수단). - static let emptyRouteCoordinate = Coordinate(latitude: 0, longitude: 0) - - func searchPlaces(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { - // Phase 17 검수 훅 — 예약 키워드는 base보다 먼저 판정한다("실패 시에만" 원칙의 - // 명시적 예외, 검수 결정론 확보). 빈 상태·serviceEnded 화면의 재현 재료. - if keyword == "결과없음" { - print("⚠️ [DEV 검수 훅] 예약 키워드 → 장소 0건 반환") - return [] - } - if keyword == "경로없음" { - print("⚠️ [DEV 검수 훅] 예약 키워드 → 빈 경로 유도 데모 장소 반환") - return [ - Place( - name: "경로없음 (데모)", - address: "도착지로 선택하면 빈 경로 응답을 시연해요", - coordinate: Self.emptyRouteCoordinate - ), - ] - } - do { return try await base.searchPlaces(keyword: keyword, near: coordinate) } - catch { - print("⚠️ [DEV 우회] 장소 검색 실패 → 데모 장소 반환: \(error)") - return [ - Place( - name: "\(keyword) (데모)", - address: "서울 강남구 강남대로 396", - coordinate: Coordinate(latitude: 37.4979, longitude: 127.0276) - ), - Place( - name: "구로디지털단지역 (데모)", - address: "서울 구로구 도림천로 486", - coordinate: Coordinate(latitude: 37.4853, longitude: 126.9015) - ), - ] - } - } - - func reverseGeocode(_ coordinate: Coordinate) async throws -> Place { - do { return try await base.reverseGeocode(coordinate) } - catch { - print("⚠️ [DEV 우회] 역지오코딩 실패 → 데모 라벨 반환: \(error)") - return Place(name: "현재 위치 (데모)", address: "", coordinate: coordinate) - } - } -} - -struct DevDemoFallbackLastRouteRepository: LastRouteRepository { - let base: any LastRouteRepository - - func searchLastRoutes(start: Coordinate, end: Coordinate) async throws -> [LastRoute] { - // Phase 17 검수 훅 — 예약 도착지(경로없음 데모 장소)는 base보다 먼저 판정한다. - // 빈 목록은 정규화 가정(미확정 #3)에 따라 serviceEnded로 표면화된다. - if end == DevDemoFallbackPlaceRepository.emptyRouteCoordinate { - print("⚠️ [DEV 검수 훅] 예약 도착지 → 빈 경로 목록 반환 (serviceEnded 정규화 재료)") - return [] - } - do { return try await base.searchLastRoutes(start: start, end: end) } - catch { - print("⚠️ [DEV 우회] 막차 검색 실패 → 데모 경로 반환: \(error)") - let route = Self.demoRoute(start: start, end: end) - // 변경 시뮬레이터의 기준 출발 시각 — 데모 흐름에선 검색 직후 이 경로가 등록된다. - // 대안(내일 출발)은 기준 시각에 관여하지 않는다 — index 0 정합 불변. - await DevChangeSimulator.shared.noteKnownSession( - routeId: route.id, departureTime: route.departureTime - ) - return [route, Self.tomorrowDemoRoute(start: start, end: end)] - } - } - - func lastRoute(id: String) async throws -> LastRoute { - do { return try await base.lastRoute(id: id) } - catch { - print("⚠️ [DEV 우회] 경로 상세 실패 → 데모 경로 반환: \(error)") - // 카드 복원(Phase 14) 검수 정합: 알려진 세션(재실행 후에도 영속)의 출발 시각을 - // 재사용해야 배너·LA와 카드의 시각이 어긋나지 않는다. 모르면 새 데모 시각. - let knownDeparture = await DevChangeSimulator.shared.knownSessionDeparture(routeId: id) - let route = Self.demoRoute( - start: Coordinate(latitude: 37.4979, longitude: 127.0276), - end: Coordinate(latitude: 37.4853, longitude: 126.9015), - departure: knownDeparture - ) - await DevChangeSimulator.shared.noteKnownSession( - routeId: route.id, departureTime: route.departureTime - ) - return route - } - } - - /// 오늘/내일 라벨(Phase 17) 검수 재료 — 더보기의 대안 경로로 항상 "내일" 출발이 - /// 표시되게 다음 자정+10분을 출발 시각으로 잡는다(검수 시각 무관 결정론). - /// 알람 등록 검수 플로우는 featured(index 0)를 쓰므로 이 경로와 무관하다. - private static func tomorrowDemoRoute(start: Coordinate, end: Coordinate) -> LastRoute { - let startOfToday = Calendar.current.startOfDay(for: Date()) - let nextMidnight = Calendar.current.date(byAdding: .day, value: 1, to: startOfToday) - ?? startOfToday.addingTimeInterval(24 * 60 * 60) - return demoRoute( - start: start, - end: end, - departure: nextMidnight.addingTimeInterval(10 * 60), - id: "dev-demo-route-tomorrow" - ) - } - - /// 출발 시각은 8분 뒤 — 알람이 도보(첫 walk leg 120초)+버퍼(180초) 반영으로 +3분 - /// 시점에 걸린다(Phase 14 검수 ③: 배너·LA·발화가 전부 "출발 − 도보 − 3분" 기준). - /// 더 이르면 등록 탭 시점에 이미 과거가 되어 tooLate 가드·AlarmKit 거부에 걸린다. - private static func demoRoute( - start: Coordinate, end: Coordinate, departure: Date? = nil, id: String = "dev-demo-route" - ) -> LastRoute { - let departure = departure ?? Date().addingTimeInterval(8 * 60) - return LastRoute( - id: id, - departureTime: departure, - totalTime: 2940, - totalWalkTime: 480, - transferCount: 1, - totalDistance: 14200, - totalWalkDistance: 700, - legs: [ - TransportLeg( - mode: .walk, - sectionTime: 120, - distance: 150, - departureTime: nil, - routeName: nil, - lineType: nil, - start: nil, - end: RoutePoint(name: "강남역", coordinate: start), - subwayFinalStation: nil, - subwayDirection: nil, - isExpressSubway: false, - isLastSubway: false - ), - TransportLeg( - mode: .subway, - sectionTime: 1500, - distance: 9000, - departureTime: departure, - routeName: "2호선", - lineType: "2", - start: RoutePoint(name: "강남역", coordinate: start), - end: RoutePoint(name: "당산역", coordinate: Coordinate(latitude: 37.5343, longitude: 126.9024)), - subwayFinalStation: "홍대입구행", - subwayDirection: "외선", - isExpressSubway: false, - isLastSubway: true - ), - TransportLeg( - mode: .bus, - sectionTime: 960, - distance: 4500, - departureTime: nil, - routeName: "간선:6411", - lineType: "11", - start: RoutePoint(name: "당산역", coordinate: Coordinate(latitude: 37.5343, longitude: 126.9024)), - end: RoutePoint(name: "구로디지털단지", coordinate: end), - subwayFinalStation: nil, - subwayDirection: nil, - isExpressSubway: false, - isLastSubway: false - ), - ] - ) - } -} - -/// 서버 알람 등록/삭제 실패를 무시해 로컬 스케줄(AlarmKit)까지 진행시킨다. -/// refresh는 그대로 실패시킨다 — 홈이 상태를 유지하므로 시연에 지장이 없다. -/// 단, Phase 11 변경 시뮬레이터의 주입이 보류 중이면 서버 대신 변형 AlarmInfo를 반환한다. -struct DevDemoTolerantAlarmRepository: AlarmRepository { - let base: any AlarmRepository - - func register(lastRouteId: String) async throws { - do { try await base.register(lastRouteId: lastRouteId) } - catch { print("⚠️ [DEV 우회] 서버 알람 등록 실패 무시 → 로컬 스케줄 진행: \(error)") } - // 새 세션 시작 — 변경 시뮬레이터가 이 경로를 기준으로 변형한다. - await DevChangeSimulator.shared.noteKnownSession(routeId: lastRouteId, departureTime: nil) - } - - func cancel(lastRouteId: String) async throws { - do { try await base.cancel(lastRouteId: lastRouteId) } - catch { print("⚠️ [DEV 우회] 서버 알람 삭제 실패 무시 → 로컬 취소 진행: \(error)") } - } - - func refresh() async throws -> AlarmInfo { - // Phase 11 검수: 보류 중 변경 주입이 있으면 서버 대신 시뮬레이터가 응답한다(1회 소비). - if let injected = await DevChangeSimulator.shared.consumeInjectedInfo() { - print("⚠️ [DEV 변경 시뮬레이터] 변형 AlarmInfo 반환: departure=\(String(describing: injected.departureTime))") - return injected - } - let info = try await base.refresh() - await DevChangeSimulator.shared.noteKnownSession( - routeId: info.lastRouteId, departureTime: info.departureTime - ) - return info - } -} - -// MARK: - Phase 11 변경 시뮬레이터 (DEV 한정 — 사람 검수의 전제) - -/// 막차 변경 피기백(판정 → 알람 재스케줄 → LA alert/홈 배너)을 검수할 유일한 주입 수단. -/// 실서버가 변경을 내려줄 수 없는 동안 `DevDemoTolerantAlarmRepository.refresh()`가 -/// 보류 중 주입을 소비해, 마지막으로 알려진 출발 시각 기준으로 변형된 AlarmInfo를 반환한다. -@MainActor -final class DevChangeSimulator { - static let shared = DevChangeSimulator() - - enum Injection { - /// 출발 시각을 N초 앞당긴다 → advanced 판정 유도. - case advance(TimeInterval) - /// 출발 시각을 N초 늦춘다 → delayed 판정 유도. - case delay(TimeInterval) - /// 운행 종료 — departureTime 없는 응답으로 sessionEnded 판정 유도. - case end - } - - /// 알려진 세션의 UserDefaults 키 (Phase 14 검수) — 강제 종료·재실행 검수에서 - /// 기준 출발 시각을 잃으면 주입 diff·카드 복원 시각이 어긋나므로 DEV 한정 영속화한다. - private static let knownRouteIdKey = "dev.sim.knownRouteId" - private static let knownDepartureKey = "dev.sim.knownDeparture" - - private var pending: Injection? - /// 마지막으로 알려진 세션 — 데모 경로 생성·등록·refresh 성공·주입 적용 시 갱신된다. - private var knownRouteId: String? - private var knownDepartureTime: Date? - - private init() { - knownRouteId = UserDefaults.standard.string(forKey: Self.knownRouteIdKey) - let epoch = UserDefaults.standard.double(forKey: Self.knownDepartureKey) - knownDepartureTime = epoch > 0 ? Date(timeIntervalSince1970: epoch) : nil - } - - /// 주입 예약 — 다음 refresh() 1회가 소비한다. - func inject(_ injection: Injection) { - pending = injection - print("⚠️ [DEV 변경 시뮬레이터] 주입 보류: \(injection)") - } - - /// departureTime이 nil이면 routeId만 갱신한다(등록 경로는 출발 시각을 모른다). - func noteKnownSession(routeId: String, departureTime: Date?) { - knownRouteId = routeId - UserDefaults.standard.set(routeId, forKey: Self.knownRouteIdKey) - if let departureTime { - knownDepartureTime = departureTime - UserDefaults.standard.set( - departureTime.timeIntervalSince1970, forKey: Self.knownDepartureKey - ) - } - } - - /// 알려진 세션의 출발 시각 — routeId가 일치할 때만 (데모 상세의 시각 정합용). - func knownSessionDeparture(routeId: String) -> Date? { - knownRouteId == routeId ? knownDepartureTime : nil - } - - /// 보류 중 주입을 소비해 변형된 AlarmInfo를 만든다. 주입이 없으면 nil. - /// 적용 결과를 기준 시각으로 다시 캐시하므로 주입을 연달아 합성할 수 있다 - /// (예: "10분 늦춤" 뒤 "5분 앞당김" → actionable alert 경로 검수). - func consumeInjectedInfo(now: Date = Date()) -> AlarmInfo? { - guard let injection = pending else { return nil } - pending = nil - let routeId = knownRouteId ?? "dev-demo-route" - // 기준 출발 시각: 캐시가 없으면 데모 경로 기본값(now+3분)과 같은 가정. - let base = knownDepartureTime ?? now.addingTimeInterval(3 * 60) - - let departure: Date? - switch injection { - case .advance(let seconds): departure = base.addingTimeInterval(-seconds) - case .delay(let seconds): departure = base.addingTimeInterval(seconds) - case .end: departure = nil - } - knownDepartureTime = departure - if let departure { - UserDefaults.standard.set( - departure.timeIntervalSince1970, forKey: Self.knownDepartureKey - ) - } - return AlarmInfo( - lastRouteId: routeId, - departureTime: departure, - updatedAt: now, - isReal: true - ) - } -} -#endif diff --git a/Projects/App/Sources/Adapters/FCMPushTokenAdapter.swift b/Projects/App/Sources/Adapters/FCMPushTokenAdapter.swift new file mode 100644 index 00000000..f629ed42 --- /dev/null +++ b/Projects/App/Sources/Adapters/FCMPushTokenAdapter.swift @@ -0,0 +1,14 @@ +import Domain +import FirebaseCore +import FirebaseMessaging + +/// `/auth/login`의 fcmToken 소싱 — V2는 GoogleService-Info.plist가 레거시 번들용이라 +/// Firebase 미구성이 보통이고, 그때는 nil(→ 엔드포인트가 빈 문자열 전송, 레거시 관용). +/// FCM 토큰 서버 전달 정식화는 기존 미확정 #5 그대로. +struct FCMPushTokenAdapter: PushTokenProviding { + func currentPushToken() async -> String? { + // configure() 전에 Messaging.messaging()을 만지면 크래시 — 구성 여부로 가드. + guard FirebaseApp.app() != nil else { return nil } + return Messaging.messaging().fcmToken + } +} diff --git a/Projects/App/Sources/Adapters/SocialLoginAdapter.swift b/Projects/App/Sources/Adapters/SocialLoginAdapter.swift new file mode 100644 index 00000000..b1f833b4 --- /dev/null +++ b/Projects/App/Sources/Adapters/SocialLoginAdapter.swift @@ -0,0 +1,127 @@ +import AuthenticationServices +import Domain +import KakaoSDKAuth +import KakaoSDKCommon +import KakaoSDKUser +import UIKit + +/// 소셜 SDK를 아는 곳은 이 어댑터뿐(외부 라이브러리 앱 타겟 전용 링크 규약). +/// 레거시 `LoginUseCaseImpl`의 delegate 이중 보관·`self.delegate!`·`view.window!` +/// force unwrap을 제거한 async 래핑 — 진행 중 continuation은 take()(nil 교체 후 사용)로 +/// 이중 resume을 차단한다. +@MainActor +final class SocialLoginAdapter: NSObject, SocialLoginService { + // 진행 중 Apple 플로우 — 어댑터가 delegate로 살아 있는 동안 controller도 함께 보관. + private var appleContinuation: CheckedContinuation? + private var appleController: ASAuthorizationController? + + func authorize(provider: SocialLoginProvider) async throws -> SocialCredential { + switch provider { + case .kakao: try await authorizeKakao() + case .apple: try await authorizeApple() + } + } + + // MARK: - Kakao + + private func authorizeKakao() async throws -> SocialCredential { + // initSDK가 안 된 상태(키 미주입)에서 UserApi를 부르면 SDK가 fatal — 선차단. + guard KakaoConfig.appKey != nil else { throw SocialLoginError.providerUnavailable } + let accessToken: String = try await withCheckedThrowingContinuation { continuation in + let completion: (OAuthToken?, (any Error)?) -> Void = { oauthToken, error in + if let error { + continuation.resume(throwing: Self.mapKakaoError(error)) + } else if let accessToken = oauthToken?.accessToken { + continuation.resume(returning: accessToken) + } else { + continuation.resume(throwing: SocialLoginError.failed) + } + } + // 카카오톡 앱 설치 시 앱 전환, 아니면 계정 웹뷰 — 레거시 분기 그대로. + if UserApi.isKakaoTalkLoginAvailable() { + UserApi.shared.loginWithKakaoTalk(completion: completion) + } else { + UserApi.shared.loginWithKakaoAccount(completion: completion) + } + } + return SocialCredential(provider: .kakao, accessToken: accessToken) + } + + private static func mapKakaoError(_ error: any Error) -> any Error { + // 사용자 취소는 조용한 idle 복귀 대상 — 그 외는 실패 토스트로 표면화. + if let sdkError = error as? SdkError, + case let .ClientFailed(reason, _) = sdkError, + reason == .Cancelled { + return SocialLoginError.cancelled + } + return error + } + + // MARK: - Apple + + private func authorizeApple() async throws -> SocialCredential { + // ViewModel의 로딩 가드가 1차 방어지만, 플로우 단일성은 여기서도 보장한다. + guard appleContinuation == nil else { throw SocialLoginError.failed } + let request = ASAuthorizationAppleIDProvider().createRequest() + request.requestedScopes = [.fullName, .email] + let controller = ASAuthorizationController(authorizationRequests: [request]) + controller.delegate = self + controller.presentationContextProvider = self + appleController = controller + return try await withCheckedThrowingContinuation { continuation in + appleContinuation = continuation + controller.performRequests() + } + } + + private func takeAppleContinuation() -> CheckedContinuation? { + defer { + appleContinuation = nil + appleController = nil + } + return appleContinuation + } +} + +extension SocialLoginAdapter: ASAuthorizationControllerDelegate { + func authorizationController( + controller: ASAuthorizationController, + didCompleteWithAuthorization authorization: ASAuthorization + ) { + guard let continuation = takeAppleContinuation() else { return } + guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential, + let tokenData = credential.identityToken, + let identityToken = String(data: tokenData, encoding: .utf8) + else { + continuation.resume(throwing: SocialLoginError.failed) + return + } + continuation.resume( + returning: SocialCredential(provider: .apple, accessToken: identityToken) + ) + } + + func authorizationController( + controller: ASAuthorizationController, + didCompleteWithError error: any Error + ) { + guard let continuation = takeAppleContinuation() else { return } + if let authorizationError = error as? ASAuthorizationError, + authorizationError.code == .canceled { + continuation.resume(throwing: SocialLoginError.cancelled) + } else { + continuation.resume(throwing: error) + } + } +} + +extension SocialLoginAdapter: ASAuthorizationControllerPresentationContextProviding { + func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { + // 레거시의 `view.window!` 대체 — 호출 시점의 활성 씬 key window를 해석하고, + // 끝내 없으면 빈 앵커로 강제 크래시만 피한다(시트가 안 뜰 뿐). + let scene = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first { $0.activationState == .foregroundActive } + return scene?.keyWindow ?? scene?.windows.first ?? ASPresentationAnchor() + } +} diff --git a/Projects/App/Sources/AppCoordinator.swift b/Projects/App/Sources/AppCoordinator.swift index 7b6d331a..fcd4fad8 100644 --- a/Projects/App/Sources/AppCoordinator.swift +++ b/Projects/App/Sources/AppCoordinator.swift @@ -10,7 +10,12 @@ final class AppCoordinator: Coordinator, CoordinatorFinishDelegate { private let container: AppDIContainer private weak var splashViewController: SplashViewController? - private var bootstrapTask: Task? + private var sessionExpiryTask: Task? + /// 로그인 플로우 표시 중 가드 — 만료 스트림의 중복 yield에 로그인을 겹치지 않는다. + private var isShowingLogin = false + /// 세션 만료 경유 재로그인이면 성공 직후 수동 동기화 1회 — activate()의 시작 + /// 동기화는 최초 1회 가드라 재로그인 경로에선 돌지 않기 때문. + private var needsSyncAfterLogin = false init(navigationController: UINavigationController, container: AppDIContainer) { self.navigationController = navigationController @@ -18,7 +23,7 @@ final class AppCoordinator: Coordinator, CoordinatorFinishDelegate { } deinit { - bootstrapTask?.cancel() + sessionExpiryTask?.cancel() } func start() { @@ -26,30 +31,72 @@ final class AppCoordinator: Coordinator, CoordinatorFinishDelegate { splash.onRetryTapped = { [weak self] in self?.bootstrap() } splashViewController = splash navigationController.setViewControllers([splash], animated: false) + observeSessionExpiry() 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 } + // 세션 판정은 동기(키체인 존재 여부) — 토큰 유효성은 첫 인증 요청이 증명한다. + switch container.authSessionManager.bootstrapState() { + case .active: + startHome() + // 앱 시작 동기화 + 포그라운드 관찰 시작 — 세션이 준비된 뒤에만. + container.alarmSyncService.activate() + case .loginRequired: + showLogin() + } + } + + /// 강제 로그인 — 스플래시를 root로 유지한 채 로그인 시트를 present한다 + /// (스플래시 배경 위 바텀시트 = 레거시와 같은 시각 결과). + private func showLogin() { + guard !isShowingLogin else { return } + isShowingLogin = true + let loginCoordinator = container.makeAuthDIContainer().makeLoginCoordinator( + navigationController: navigationController, + onAuthenticated: { [weak self] in + guard let self else { return } + self.isShowingLogin = false self.startHome() - // 앱 시작 동기화 + 포그라운드 관찰 시작 — 세션이 준비된 뒤에만. self.container.alarmSyncService.activate() - } catch { - guard !Task.isCancelled else { return } - // 원인별 문구 분기(Phase 16) — 오프라인만 구분, 그 외는 일시 장애 안내. - self.splashViewController?.showRetry( - message: BootstrapFailureMessage.text(for: error) - ) + if self.needsSyncAfterLogin { + self.needsSyncAfterLogin = false + let syncService = self.container.alarmSyncService + Task { await syncService.syncNow() } + } + } + ) + loginCoordinator.finishDelegate = self + addChild(loginCoordinator) + loginCoordinator.start() + } + + /// refresh 확정 사망(AuthSessionManager.sessionExpired) 관찰 — 홈을 접고 스플래시 + /// 위 로그인으로 되돌린다. 스트림은 단일 소비자(이 코디네이터) 전제. + private func observeSessionExpiry() { + sessionExpiryTask = Task { [weak self] in + guard let stream = self?.container.authSessionManager.sessionExpired else { return } + for await _ in stream { + guard let self, !Task.isCancelled else { return } + self.handleSessionExpiry() } } } + private func handleSessionExpiry() { + guard !isShowingLogin else { return } + needsSyncAfterLogin = true + navigationController.presentedViewController?.dismiss(animated: false) + // setViewControllers가 didShow를 태워 SearchCoordinator류의 정리 경로도 돈다. + let splash = SplashViewController() + splash.onRetryTapped = { [weak self] in self?.bootstrap() } + splashViewController = splash + navigationController.setViewControllers([splash], animated: false) + childCoordinators.removeAll() + showLogin() + } + private func startHome() { let homeCoordinator = container.makeHomeDIContainer() .makeHomeCoordinator(navigationController: navigationController) diff --git a/Projects/App/Sources/AppDIContainer.swift b/Projects/App/Sources/AppDIContainer.swift index 7574ef8f..95707684 100644 --- a/Projects/App/Sources/AppDIContainer.swift +++ b/Projects/App/Sources/AppDIContainer.swift @@ -1,4 +1,6 @@ import AtchaData +import AuthFeature +import AuthFeatureInterface import CoreAlarm import CoreAuth import CoreNetwork @@ -14,6 +16,9 @@ import SearchFeatureInterface /// Presentation modules depend on Domain protocols only. final class AppDIContainer { private let networkClient: any NetworkClient + /// 데코레이터 미적용 클라이언트 — reissue(AuthSessionManager)와 소셜 Bearer를 실어야 + /// 하는 로그인 API(AuthRepositoryImpl)만 쓴다. + private let plainNetworkClient: any NetworkClient let authSessionManager: AuthSessionManager // 알람 스택은 1회 생성해 공유한다 — AlarmSyncService(갱신 일원화)와 @@ -45,17 +50,6 @@ final class AppDIContainer { #endif init() { - #if DEV - // 검수용 임시 우회: 실서버(미확정 #1·#2)가 죽어 있어도 기본 60초 타임아웃 대기로 - // 시연이 멈추지 않게 짧은 타임아웃을 쓴다. DevDemoFallbacks와 함께 제거한다. - let sessionConfiguration = URLSessionConfiguration.ephemeral - sessionConfiguration.timeoutIntervalForRequest = 3 - sessionConfiguration.timeoutIntervalForResource = 5 - let baseClient = URLSessionNetworkClient( - baseURL: AppEnvironment.current.apiBaseURL, - session: URLSession(configuration: sessionConfiguration) - ) - #else // Phase 16 — 기본 60초 타임아웃은 심야의 약한 연결에서 1분 침묵이다. 빨리 // 실패시키고(요청 10초) 멱등 GET 1회 재시도·신선도 스탬프가 정직성을 맡는다. let sessionConfiguration = URLSessionConfiguration.default @@ -65,38 +59,35 @@ final class AppDIContainer { baseURL: AppEnvironment.current.apiBaseURL, session: URLSession(configuration: sessionConfiguration) ) - #endif + self.plainNetworkClient = baseClient 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() + networkClient: baseClient ) self.authSessionManager = sessionManager let networkClient = AuthenticatedNetworkClient( base: baseClient, - sessionManager: sessionManager + sessionManager: sessionManager, + // /auth/* 는 전부 plain client 또는 CoreAuth 내부에서 특수 Bearer(소셜·refresh)로 + // 호출되지만, 실수로 이 데코레이터를 타도 그 Authorization이 서버 access 토큰으로 + // 조용히 덮이지 않도록 이중 방어로 전부 public 처리한다(레거시 allowlist 대응). + publicPathSuffixes: [ + "/auth/reissue", "/auth/check", "/auth/login", "/auth/sign-up", "/auth/logout", + ] ) self.networkClient = networkClient + self.placeRepository = PlaceRepositoryImpl(networkClient: networkClient) + self.lastRouteRepository = LastRouteRepositoryImpl(networkClient: networkClient) #if DEV - // 검수용 임시 우회 — 실서버(미확정 #1·#2) 부재 시에만 데모 데이터로 폴백. - // 실서버 확정 시 이 블록과 DevDemoFallbacks.swift를 제거한다. - self.placeRepository = DevDemoFallbackPlaceRepository( - base: PlaceRepositoryImpl(networkClient: networkClient) - ) - self.lastRouteRepository = DevDemoFallbackLastRouteRepository( - base: LastRouteRepositoryImpl(networkClient: networkClient) - ) - self.alarmRepository = DevDemoTolerantAlarmRepository( + // 막차 "변경"(앞당김/늦춤/운행종료)은 실서버가 임의로 재현해줄 수 없다 — + // refresh 주입 가로채기만 남긴 슬림 데코레이터(에러 은폐 없음, Phase 11 검수 수단). + self.alarmRepository = DevChangeSimulatingAlarmRepository( base: AlarmRepositoryImpl(networkClient: networkClient) ) #else - self.placeRepository = PlaceRepositoryImpl(networkClient: networkClient) - self.lastRouteRepository = LastRouteRepositoryImpl(networkClient: networkClient) self.alarmRepository = AlarmRepositoryImpl(networkClient: networkClient) #endif @@ -146,6 +137,41 @@ final class AppDIContainer { await liveActivityAdapter.reattachOrphans(snapshot: snapshot, now: Date()) } + func makeAuthDIContainer() -> any AuthCoordinatorBuildable { + AuthDIContainer( + signInUseCase: DefaultSignInUseCase( + socialLoginService: SocialLoginAdapter(), + // plain client — 소셜 Bearer 보존 + 401이 세션 복구를 촉발하지 않게. + authRepository: AuthRepositoryImpl(networkClient: plainNetworkClient), + sessionStore: AuthSessionStoreAdapter(sessionManager: authSessionManager), + pushTokenProvider: FCMPushTokenAdapter(), + // 최소 가입 폼 재료 — 신규 계정일 때만 쓰인다(현재 위치 + 역지오코딩 주소). + getCurrentLocationUseCase: DefaultGetCurrentLocationUseCase( + locationService: CoreLocationServiceAdapter() + ), + reverseGeocodeUseCase: DefaultReverseGeocodeUseCase(repository: placeRepository) + ) + ) + } + + // MARK: - 계정 스택 (설정 화면 前 단계 — 로그아웃은 DEV 디버그 메뉴가 유일한 진입점) + + private var userRepository: any UserRepository { + UserRepositoryImpl(networkClient: networkClient) + } + + private var sessionEnding: any SessionEnding { + AuthSessionEndingAdapter(sessionManager: authSessionManager) + } + + func makeLogoutUseCase() -> any LogoutUseCase { + DefaultLogoutUseCase(sessionEnding: sessionEnding) + } + + func makeWithdrawUseCase() -> any WithdrawUseCase { + DefaultWithdrawUseCase(userRepository: userRepository, sessionEnding: sessionEnding) + } + func makeHomeDIContainer() -> any HomeCoordinatorBuildable { let recentSearchRepository = RecentSearchRepositoryImpl(store: UserDefaultsKeyValueStore()) let locationService = CoreLocationServiceAdapter() diff --git a/Projects/App/Sources/AppDelegate.swift b/Projects/App/Sources/AppDelegate.swift index c9a391e4..60952d65 100644 --- a/Projects/App/Sources/AppDelegate.swift +++ b/Projects/App/Sources/AppDelegate.swift @@ -1,5 +1,6 @@ import FirebaseCore import FirebaseMessaging +import KakaoSDKCommon import UIKit import os @@ -27,6 +28,10 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { // 노티 탭 라우팅(Phase 15) — launch 완료 전에 등록해야 탭이 앱을 cold start시키는 // 경우의 didReceive까지 잡는다. 홈 랜딩 훅 배선은 SceneDelegate 몫. container.notificationTapDelegate.attachToNotificationCenter() + // 카카오 로그인 — 키 미주입(TUIST_KAKAO_APP_KEY 부재) 빌드에선 건너뛴다. + if let kakaoAppKey = KakaoConfig.appKey { + KakaoSDK.initSDK(appKey: kakaoAppKey) + } configureFirebaseIfAvailable() if isFirebaseEnabled { Messaging.messaging().delegate = self diff --git a/Projects/App/Sources/BootstrapFailureMessage.swift b/Projects/App/Sources/BootstrapFailureMessage.swift index ac3b0e6f..f1bcf071 100644 --- a/Projects/App/Sources/BootstrapFailureMessage.swift +++ b/Projects/App/Sources/BootstrapFailureMessage.swift @@ -2,8 +2,8 @@ import CoreNetwork import Foundation /// 스플래시 부트스트랩 실패 문구 매핑(Phase 16) — 원인 불문 고정 문구를 오프라인/기타로 -/// 나눈다. 순수 함수라 AtchaV2Tests가 직접 검증한다. 현 DEV는 bootstrap이 -/// issuerNotConfigured를 삼켜 재시도 화면에 도달하지 않는다 — 실표출은 S1(익명 인증) 이후. +/// 나눈다. 순수 함수라 AtchaV2Tests가 직접 검증한다. 현재 부트스트랩은 동기 판정 +/// (bootstrapState)이라 실패 경로가 없다 — 서버 검증형 부트스트랩 도입 대비로 유지. enum BootstrapFailureMessage { static let offline = "네트워크 연결을 확인해주세요" static let transient = "일시적인 문제가 생겼어요. 잠시 후 다시 시도해주세요" diff --git a/Projects/App/Sources/KakaoConfig.swift b/Projects/App/Sources/KakaoConfig.swift new file mode 100644 index 00000000..782040eb --- /dev/null +++ b/Projects/App/Sources/KakaoConfig.swift @@ -0,0 +1,12 @@ +import Foundation + +/// 카카오 네이티브 앱 키 — Tuist env(TUIST_KAKAO_APP_KEY)가 Info.plist로 주입한 값을 +/// 읽는다(xcconfig 금지 규약). 미주입(빈 값)이면 nil — 카카오 로그인만 비활성. +enum KakaoConfig { + static var appKey: String? { + guard let key = Bundle.main.object(forInfoDictionaryKey: "KAKAO_APP_KEY") as? String, + !key.isEmpty + else { return nil } + return key + } +} diff --git a/Projects/App/Sources/SceneDelegate.swift b/Projects/App/Sources/SceneDelegate.swift index de37ac44..b9b8a6b1 100644 --- a/Projects/App/Sources/SceneDelegate.swift +++ b/Projects/App/Sources/SceneDelegate.swift @@ -1,3 +1,4 @@ +import KakaoSDKAuth import UIKit final class SceneDelegate: UIResponder, UIWindowSceneDelegate { @@ -35,6 +36,12 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { #endif } + /// 카카오톡 앱 로그인 리디렉션(kakao{KEY}:// 스킴) 복귀 처리 — 레거시 동일. + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + guard let url = URLContexts.first?.url, AuthApi.isKakaoTalkLoginUrl(url) else { return } + _ = AuthController.handleOpenUrl(url: url) + } + #if DEV // MARK: - Phase 11 변경 시뮬레이터 (DEV 한정 플로팅 버튼) @@ -95,6 +102,14 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { add("10분 늦춤 (3초 후)", injection: .delay(10 * 60), delay: 3) add("운행 종료 (3초 후)", injection: .end, delay: 3, style: .destructive) add("즉시 refresh", injection: nil, delay: 0) + // 로그아웃 엔드투엔드 검수(설정 화면 前 유일한 진입점) — 서버 /auth/logout 베스트 + // 에포트 후 sessionExpired 경유로 로그인 시트에 복귀해야 정상이다. + sheet.addAction(UIAlertAction(title: "로그아웃 (DEV)", style: .destructive) { _ in + Task { @MainActor in + await container.makeLogoutUseCase().execute() + print("⚠️ [DEV] 로그아웃 실행 — sessionExpired 라우팅으로 로그인 시트 복귀 기대") + } + }) // Phase 12 dismiss 폴백 검수용 — 잠금화면 스와이프 자동화가 불안정할 때 기록을 강제한다. // 켠 뒤 앞당김/운행 종료를 주입하면 LA alert 대신 로컬 노티 경로를 탄다. // 표시용 현재값은 UserDefaults 동기 읽기 — 어댑터가 캐시와 함께 갱신하므로 어긋나지 않는다. diff --git a/Projects/App/Widget/Sources/AtchaWidgetBundle.swift b/Projects/App/Widget/Sources/AtchaWidgetBundle.swift index a83f4274..13cc7f2e 100644 --- a/Projects/App/Widget/Sources/AtchaWidgetBundle.swift +++ b/Projects/App/Widget/Sources/AtchaWidgetBundle.swift @@ -1,8 +1,17 @@ +import DesignSystem import SwiftUI import WidgetKit @main struct AtchaWidgetBundle: WidgetBundle { + // 익스텐션 프로세스 시작 시점에 Pretendard를 미리 등록한다 — WidgetKit이 + // 스냅샷을 요청하기 전에 등록 비용이 끝나도록. once-가드라 멱등이고, + // Font.pretendard 경로도 내부에서 같은 등록을 지연 수행하므로 이 훅은 + // 타이밍 보증일 뿐 정합성 조건은 아니다. + init() { + DSFont.registerFontsIfNeeded() + } + var body: some Widget { LastTrainLiveActivityWidget() } diff --git a/Projects/App/Widget/Sources/DSColor+SwiftUI.swift b/Projects/App/Widget/Sources/DSColor+SwiftUI.swift index 2a462260..b77df406 100644 --- a/Projects/App/Widget/Sources/DSColor+SwiftUI.swift +++ b/Projects/App/Widget/Sources/DSColor+SwiftUI.swift @@ -3,8 +3,9 @@ import SwiftUI import UIKit // DesignSystem is UIKit-based (UIColor role tokens). The widget extension is -// the repo's only SwiftUI surface, so the bridge lives here instead of adding -// a SwiftUI dependency to DesignSystem itself. +// the repo's only SwiftUI surface, so the bridges (this color bridge and the +// DSFont+SwiftUI font bridge) live here instead of adding a SwiftUI dependency +// to DesignSystem itself. extension Color { /// Bridges a DesignSystem `DSColor` token (UIColor) into SwiftUI, /// e.g. `Color(ds: DSColor.Accent.default)`. diff --git a/Projects/App/Widget/Sources/DSFont+SwiftUI.swift b/Projects/App/Widget/Sources/DSFont+SwiftUI.swift new file mode 100644 index 00000000..344eea50 --- /dev/null +++ b/Projects/App/Widget/Sources/DSFont+SwiftUI.swift @@ -0,0 +1,17 @@ +import DesignSystem +import SwiftUI +import UIKit + +// DSColor+SwiftUI와 같은 독트린 — 브리지는 위젯에 두고 DesignSystem에는 +// SwiftUI 의존을 더하지 않는다. +extension Font { + /// DesignSystem Pretendard를 SwiftUI로 브리지한다, + /// e.g. `Font.pretendard(.bold, size: 38)`. + /// DSFont.pretendard가 최초 호출 시 once-가드 등록을 수행하고, 번들·등록 + /// 실패 시 같은 가중치의 시스템 폰트로 폴백한다 — Font.custom을 쓰지 않는 + /// 이유(커스텀 이름 실패 시 weight를 잃은 시스템 regular로 떨어진다). + static func pretendard(_ weight: DSFont.Weight, size: CGFloat) -> Font { + // UIFont ↔ CTFont는 톨프리 브리지 — SwiftUI 공개 API Font(_: CTFont)로 감싼다. + Font(DSFont.pretendard(weight, size: size) as CTFont) + } +} diff --git a/Projects/App/Widget/Sources/LastTrainLiveActivityWidget.swift b/Projects/App/Widget/Sources/LastTrainLiveActivityWidget.swift index 5f4dce13..3d6da9d4 100644 --- a/Projects/App/Widget/Sources/LastTrainLiveActivityWidget.swift +++ b/Projects/App/Widget/Sources/LastTrainLiveActivityWidget.swift @@ -36,22 +36,26 @@ struct LastTrainLiveActivityWidget: Widget { DynamicIslandExpandedRegion(.leading) { HStack(spacing: DSSpacing.xs) { Image(systemName: context.attributes.transportSymbolName) - .font(.subheadline) + .font(.system(size: 13, weight: .semibold)) .foregroundStyle(state.glanceColor) Text(context.attributes.routeName) - .font(.headline) + .font(.pretendard(.semiBold, size: 15)) .foregroundStyle(Color(ds: DSColor.Text.primary)) .lineLimit(1) } } DynamicIslandExpandedRegion(.trailing) { Text("\(LastTrainTimeFormat.hhmm(state.departureTime)) 출발") - .font(.subheadline) + .font(.pretendard(.regular, size: 13)) .foregroundStyle(Color(ds: DSColor.Text.secondary)) .lineLimit(1) } + // expanded .center는 의도적 미사용 — 센서 하우징 좌우 클램프 탓에 + // 와이드 타이머가 잘리거나 축소되고, 전폭을 쓸 수 있는 영역은 + // bottom뿐이다. leading/trailing이 채워진 구성에선 center가 + // 세로 밴드만 더해 160pt 확장 캡을 압박한다. DynamicIslandExpandedRegion(.bottom) { - // 잠금화면 2행의 축약판. 도보 안내 축약도 이 영역 몫이지만 + // 잠금화면 히어로 슬롯의 축약판. 도보 안내 축약도 이 영역 몫이지만 // ContentState에 도보 필드가 없어 생략 — 아래 // LastTrainLockScreenView.departureRow 주석 참고. switch state.status { @@ -59,26 +63,26 @@ struct LastTrainLiveActivityWidget: Widget { // 갱신이 끊긴 채 staleDate(=출발 시각)가 지났다 — 얼어붙은 // 카운트다운 대신 최신성 경고 (Phase 13, 앱 깨움 없는 마지막 방어선). Text(LastTrainStaleCopy.message) - .font(.headline) + .font(.pretendard(.semiBold, size: 15)) .foregroundStyle(Color(ds: DSColor.Text.secondary)) + .lineLimit(1) + .minimumScaleFactor(0.8) case .active: - HStack(alignment: .firstTextBaseline, spacing: DSSpacing.sm) { + VStack(alignment: .leading, spacing: DSSpacing.xxs) { Text("출발까지") - .font(.subheadline) + .font(.pretendard(.medium, size: 12)) .foregroundStyle(Color(ds: DSColor.Text.secondary)) - HStack(spacing: DSSpacing.xs) { - Image(systemName: "timer") - .font(.body.weight(.semibold)) - Text(timerInterval: state.countdownRange, countsDown: true) - .font(.title2.weight(.bold)) - .monospacedDigit() - .multilineTextAlignment(.leading) - } - .foregroundStyle(state.urgencyColor) + Text(timerInterval: state.countdownRange, countsDown: true) + .font(.pretendard(.bold, size: 28)) + .monospacedDigit() + .foregroundStyle(state.urgencyColor) + .lineLimit(1) + .minimumScaleFactor(0.6) + .multilineTextAlignment(.leading) } case .departed, .missed, .serviceEnded: Text(state.finalStatusMessage ?? "") - .font(.headline) + .font(.pretendard(.semiBold, size: 17)) .foregroundStyle(state.glanceColor) } } @@ -90,11 +94,11 @@ struct LastTrainLiveActivityWidget: Widget { switch state.status { case .active where isStale: Text("지남") - .font(.caption2.weight(.semibold)) + .font(.pretendard(.semiBold, size: 12)) .foregroundStyle(Color(ds: DSColor.Text.secondary)) case .active: Text(timerInterval: state.countdownRange, countsDown: true) - .font(.caption2.weight(.semibold)) + .font(.pretendard(.semiBold, size: 12)) .monospacedDigit() .foregroundStyle(state.urgencyColor) .multilineTextAlignment(.trailing) @@ -105,15 +109,15 @@ struct LastTrainLiveActivityWidget: Widget { .frame(maxWidth: 56) case .departed: Text("출발") - .font(.caption2.weight(.semibold)) + .font(.pretendard(.semiBold, size: 12)) .foregroundStyle(state.glanceColor) case .missed: Text("놓침") - .font(.caption2.weight(.semibold)) + .font(.pretendard(.semiBold, size: 12)) .foregroundStyle(state.glanceColor) case .serviceEnded: Text("종료") - .font(.caption2.weight(.semibold)) + .font(.pretendard(.semiBold, size: 12)) .foregroundStyle(state.glanceColor) } } minimal: { @@ -127,16 +131,18 @@ struct LastTrainLiveActivityWidget: Widget { // MARK: - Lock screen -/// 잠금화면 뷰 — UX 정본 목업: +/// 잠금화면 뷰 — 타이머 히어로 목업(UX 정본): /// ``` /// ┌────────────────────────────┐ /// │ 🚌 5518 막차 [⚠ 당겨짐] │ -/// │ 출발까지 ⏱ 22분 │ ← 여유도에 따라 색 변경 +/// │ 출발까지 │ +/// │ 21:34 │ ← 히어로 카운트다운, 여유도에 따라 색 변경 /// │ 23:25 출발 · 정류장 도보 8분 │ /// └────────────────────────────┘ /// ``` -/// glance는 0.5초 — 숫자보다 색·상태가 먼저 읽히도록 카운트다운 -/// 숫자·아이콘에 긴급도 색을 상시 적용한다. +/// glance는 0.5초 — 숫자보다 색·상태가 먼저 읽히도록 카운트다운 숫자에 +/// 긴급도 색을 상시 적용한다. 타이머 아이콘은 히어로 스케일에서 숫자와 +/// 경합해 제거 — 아이콘 앵커는 헤더의 수단 심벌이 담당한다. private struct LastTrainLockScreenView: View { let attributes: LastTrainActivityAttributes let state: LastTrainActivityAttributes.ContentState @@ -147,7 +153,7 @@ private struct LastTrainLockScreenView: View { var body: some View { VStack(alignment: .leading, spacing: DSSpacing.sm) { headerRow - mainRow + heroSlot departureRow } .padding(DSSpacing.md) @@ -157,10 +163,10 @@ private struct LastTrainLockScreenView: View { private var headerRow: some View { HStack(spacing: DSSpacing.xs) { Image(systemName: attributes.transportSymbolName) - .font(.subheadline) + .font(.system(size: 14, weight: .semibold)) .foregroundStyle(Color(ds: DSColor.Icon.default)) Text("\(attributes.routeName) 막차") - .font(.subheadline.weight(.semibold)) + .font(.pretendard(.semiBold, size: 14)) .foregroundStyle(Color(ds: DSColor.Text.primary)) .lineLimit(1) Spacer(minLength: DSSpacing.sm) @@ -170,46 +176,62 @@ private struct LastTrainLockScreenView: View { } } - /// 2행: glance 핵심. active면 카운트다운(긴급도 색), stale이면 최신성 경고, - /// departed/final state면 카운트다운을 숨기고 상태 문구만 표시. + /// 2행(히어로 슬롯): active면 오버라인 + 대형 카운트다운(긴급도 색), stale이면 + /// 최신성 경고, final state면 상태 문구가 히어로 자리를 차지한다. @ViewBuilder - private var mainRow: some View { + private var heroSlot: some View { switch state.status { case .active where isStale: // 갱신이 끊긴 채 staleDate(=출발 시각) 경과 — 얼어붙은 카운트다운을 // 신선한 정보처럼 보여주지 않는다 (Phase 13 마지막 방어선). - Text(LastTrainStaleCopy.message) - .font(.title3.weight(.bold)) - .foregroundStyle(Color(ds: DSColor.Text.secondary)) + heroMessage( + LastTrainStaleCopy.message, + font: .pretendard(.bold, size: 20), + color: Color(ds: DSColor.Text.secondary), + lineLimit: 2 + ) case .active: - HStack(alignment: .firstTextBaseline, spacing: DSSpacing.sm) { + VStack(alignment: .leading, spacing: DSSpacing.xxs) { Text("출발까지") - .font(.body) + .font(.pretendard(.medium, size: 12)) .foregroundStyle(Color(ds: DSColor.Text.secondary)) - HStack(spacing: DSSpacing.xs) { - Image(systemName: "timer") - .font(.title3.weight(.semibold)) - // 알람 발화 시각(alarmTime = 출발 기준시각 − 버퍼) 기준 — - // 정책: "출발까지 N분"은 버퍼 포함 알람 시각 기준으로 통일. - Text(timerInterval: state.countdownRange, countsDown: true) - .font(.title.weight(.bold)) - .monospacedDigit() - .multilineTextAlignment(.leading) - } - .foregroundStyle(state.urgencyColor) + // 알람 발화 시각(alarmTime = 출발 기준시각 − 버퍼) 기준 — + // 정책: "출발까지 N분"은 버퍼 포함 알람 시각 기준으로 통일. + // h:mm:ss 최악 8글리프도 38pt에서 폭 여유가 있지만 + // lineLimit+minScale을 벨트로 유지한다. + Text(timerInterval: state.countdownRange, countsDown: true) + .font(.pretendard(.bold, size: 38)) + .monospacedDigit() + .foregroundStyle(state.urgencyColor) + .lineLimit(1) + .minimumScaleFactor(0.6) + .multilineTextAlignment(.leading) } case .departed, .missed, .serviceEnded: - Text(state.finalStatusMessage ?? "") - .font(.title3.weight(.bold)) - .foregroundStyle(state.glanceColor) + heroMessage( + state.finalStatusMessage ?? "", + font: .pretendard(.bold, size: 22), + color: state.glanceColor, + lineLimit: 1 + ) } } + /// stale·final 공용 — 히어로 자리를 차지하는 상태 문구. + private func heroMessage( + _ text: String, font: Font, color: Color, lineLimit: Int + ) -> some View { + Text(text) + .font(font) + .foregroundStyle(color) + .lineLimit(lineLimit) + } + /// 3행: 출발 시각 + 정류장 도보 소요 (Phase 14 — 미확정 #7 클라 임시안으로 목업 공석 해소). /// 도보 데이터가 없는 세션(구버전 LA·도보 없는 경로)은 출발 시각만 표시한다. private var departureRow: some View { Text(departureRowText) - .font(.footnote) + .font(.pretendard(.regular, size: 13)) .foregroundStyle(Color(ds: DSColor.Text.secondary)) .lineLimit(1) } @@ -239,14 +261,16 @@ private extension LastTrainActivityAttributes { /// "⚠ 당겨짐" 배지 — 변경 직후 10분 노출 정책의 표시 슬롯. /// 취소선 등 변경 흔적의 상시 표시는 하지 않는다(정책). +/// 스타일은 홈 배너 caution 티어(danger 글자 + dangerContainer 채움)와 같은 +/// 은은한 채움 — 강한 긴급색은 히어로 숫자가 담당한다. private struct ChangeBadge: View { var body: some View { Text("⚠ 당겨짐") - .font(.caption2.weight(.semibold)) - .foregroundStyle(Color(ds: DSColor.Text.primary)) + .font(.pretendard(.semiBold, size: 12)) + .foregroundStyle(Color(ds: DSColor.State.danger)) .padding(.horizontal, DSSpacing.sm) .padding(.vertical, DSSpacing.xxs) - .background(Color(ds: DSColor.State.danger), in: Capsule()) + .background(Color(ds: DSColor.State.dangerContainer), in: Capsule()) .lineLimit(1) } } @@ -323,3 +347,115 @@ private enum LastTrainTimeFormat { formatter.string(from: date) } } + +// MARK: - Previews (렌더 검증 픽스처 — 계약·정책과 무관한 표시 확인용) + +private extension LastTrainActivityAttributes { + static let previewSubway = LastTrainActivityAttributes( + routeId: "preview.subway", + routeName: "9호선 급행", + transportKind: .subway, + firstWalkSeconds: 300 + ) + + static let previewBusNoWalk = LastTrainActivityAttributes( + routeId: "preview.bus", + routeName: "5518", + transportKind: .bus, + firstWalkSeconds: nil + ) +} + +private extension LastTrainActivityAttributes.ContentState { + /// alarm이 N초 후, 출발은 alarm+3분 — 미래 시각 기반이라 프리뷰 타이머가 실제로 틱한다. + static func preview( + alarmIn seconds: TimeInterval, + urgency: LastTrainUrgency, + badge: Bool = false, + status: LastTrainSessionStatus = .active + ) -> Self { + let alarm = Date.now.addingTimeInterval(seconds) + return .init( + departureTime: alarm.addingTimeInterval(180), + alarmTime: alarm, + urgency: urgency, + changeBadgeExpiry: badge ? Date.now.addingTimeInterval(600) : nil, + status: status + ) + } +} + +#Preview("lock-active-relaxed", as: .content, using: LastTrainActivityAttributes.previewSubway) { + LastTrainLiveActivityWidget() +} contentStates: { + LastTrainActivityAttributes.ContentState.preview(alarmIn: 22 * 60, urgency: .relaxed) +} + +#Preview( + "lock-active-caution-badge", as: .content, using: LastTrainActivityAttributes.previewSubway +) { + LastTrainLiveActivityWidget() +} contentStates: { + LastTrainActivityAttributes.ContentState.preview(alarmIn: 7 * 60, urgency: .caution, badge: true) +} + +#Preview( + "lock-active-imminent", as: .content, using: LastTrainActivityAttributes.previewBusNoWalk +) { + LastTrainLiveActivityWidget() +} contentStates: { + LastTrainActivityAttributes.ContentState.preview(alarmIn: 45, urgency: .imminent) +} + +#Preview("lock-final-states", as: .content, using: LastTrainActivityAttributes.previewSubway) { + LastTrainLiveActivityWidget() +} contentStates: { + LastTrainActivityAttributes.ContentState + .preview(alarmIn: -60, urgency: .imminent, status: .departed) + LastTrainActivityAttributes.ContentState + .preview(alarmIn: -300, urgency: .imminent, status: .missed) + LastTrainActivityAttributes.ContentState + .preview(alarmIn: -300, urgency: .relaxed, status: .serviceEnded) +} + +#Preview( + "di-expanded", as: .dynamicIsland(.expanded), + using: LastTrainActivityAttributes.previewSubway +) { + LastTrainLiveActivityWidget() +} contentStates: { + LastTrainActivityAttributes.ContentState.preview(alarmIn: 22 * 60, urgency: .relaxed) + LastTrainActivityAttributes.ContentState.preview(alarmIn: 45, urgency: .imminent) + LastTrainActivityAttributes.ContentState + .preview(alarmIn: -300, urgency: .imminent, status: .missed) +} + +#Preview( + "di-compact", as: .dynamicIsland(.compact), + using: LastTrainActivityAttributes.previewBusNoWalk +) { + LastTrainLiveActivityWidget() +} contentStates: { + LastTrainActivityAttributes.ContentState.preview(alarmIn: 7 * 60, urgency: .caution) + LastTrainActivityAttributes.ContentState + .preview(alarmIn: -300, urgency: .imminent, status: .missed) +} + +#Preview( + "di-minimal", as: .dynamicIsland(.minimal), + using: LastTrainActivityAttributes.previewSubway +) { + LastTrainLiveActivityWidget() +} contentStates: { + LastTrainActivityAttributes.ContentState.preview(alarmIn: 22 * 60, urgency: .relaxed) +} + +// 프리뷰 매크로는 context.isStale을 주입할 수 없다 — stale 분기는 뷰 직접 렌더로 확인. +#Preview("lock-stale") { + LastTrainLockScreenView( + attributes: .previewSubway, + state: .preview(alarmIn: -300, urgency: .caution), + isStale: true + ) + .background(Color(ds: DSColor.Background.base)) +} diff --git a/Projects/Core/Auth/Sources/AnonymousSessionIssuing.swift b/Projects/Core/Auth/Sources/AnonymousSessionIssuing.swift deleted file mode 100644 index c2f1a8ec..00000000 --- a/Projects/Core/Auth/Sources/AnonymousSessionIssuing.swift +++ /dev/null @@ -1,27 +0,0 @@ -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 index 01b67404..7be42e8d 100644 --- a/Projects/Core/Auth/Sources/AuthError.swift +++ b/Projects/Core/Auth/Sources/AuthError.swift @@ -1,8 +1,8 @@ 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 session is definitively gone: no refresh token, or the server + /// rejected the refresh. The user must sign in again — surfaced through + /// `AuthSessionManager.sessionExpired` and thrown to the recovery caller. + case loginRequired /// 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 index 966232c9..d5e5d42c 100644 --- a/Projects/Core/Auth/Sources/AuthSessionManager.swift +++ b/Projects/Core/Auth/Sources/AuthSessionManager.swift @@ -1,45 +1,77 @@ 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. +/// Whether a stored session exists at launch. The splash decides between the +/// home flow (`active`) and the login flow (`loginRequired`) on this alone — +/// token validity is proven lazily by the first authenticated request. +public enum SessionState: Sendable, Equatable { + case active + case loginRequired +} + +/// Owns the session lifecycle: adopting server tokens after social login, +/// 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 + /// Yields once each time the session dies for good (no refresh token, or + /// the server rejected the refresh). Single-consumer stream — the app + /// coordinator subscribes and routes back to login; yields are buffered + /// until consumed. + public nonisolated let sessionExpired: AsyncStream + /// 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 let expiryContinuation: AsyncStream.Continuation private var recoveryTask: Task? - public init( - tokenStore: TokenStore, - networkClient: any NetworkClient, - issuer: any AnonymousSessionIssuing - ) { + public init(tokenStore: TokenStore, networkClient: any NetworkClient) { self.tokenStore = tokenStore self.networkClient = networkClient - self.issuer = issuer + (sessionExpired, expiryContinuation) = AsyncStream.makeStream(of: Void.self) } - /// 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 + /// Splash-time check: no network, no throw — a keychain read failure just + /// means there is no usable session. + public nonisolated func bootstrapState() -> SessionState { + ((try? tokenStore.accessToken()) != nil) ? .active : .loginRequired + } + + /// Adopts the server token pair obtained by social login. Actor-isolated + /// so adoption serializes with any in-flight recovery. + public func adopt(_ tokens: TokenPair) throws { + try tokenStore.save(tokens) + } + + /// Logout/withdrawal path (screens are a later phase — the clear path + /// ships now so callers never reach into TokenStore directly). + public func clearSession() throws { + try tokenStore.clear() + } + + /// Logout: best-effort server call (legacy special case — the *refresh* + /// token rides as Bearer), then local expiry either way. Offline or a + /// server failure must never trap the user in a session they asked to end. + public func signOut() async { + if let refreshToken = try? tokenStore.refreshToken() { + _ = try? await networkClient.data(for: LogoutEndpoint(refreshToken: refreshToken)) } + _ = expireSession() + } + + /// Post-withdrawal cleanup: the server already revoked the tokens, so no + /// network call — just local expiry and the login-routing yield. + public func invalidateSession() { + _ = expireSession() } - /// Single entry point for 401 recovery: refresh first, fall back to a - /// fresh anonymous session, throw when both are impossible. Concurrent + /// Single entry point for 401 recovery: refresh, or declare the session + /// dead (`AuthError.loginRequired` + a `sessionExpired` yield). Concurrent /// callers join the in-flight recovery instead of starting another. public func recoverSession() async throws { if let existing = recoveryTask { @@ -52,18 +84,32 @@ public actor AuthSessionManager { } 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. - } + guard let refreshToken = try? tokenStore.refreshToken() else { + throw expireSession() + } + do { + try await refreshTokens(with: refreshToken) + } catch { + // Only a definitive server rejection kills the session. Transient + // failures (offline, 5xx, decoding) propagate untouched so the + // caller treats them as network errors — never as a logout. + guard isDefinitiveRejection(error) else { throw error } + throw expireSession() } - // 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()) + } + + /// Best-effort clear (a keychain failure must not mask the expiry), + /// notify the observer, and hand back the error to throw. + private func expireSession() -> AuthError { + try? tokenStore.clear() + expiryContinuation.yield() + return .loginRequired + } + + private func isDefinitiveRejection(_ error: any Error) -> Bool { + if error is AuthError { return true } + if case NetworkError.unacceptableStatus(code: 401, data: _) = error { return true } + return false } private func refreshTokens(with refreshToken: String) async throws { diff --git a/Projects/Core/Auth/Sources/LogoutEndpoint.swift b/Projects/Core/Auth/Sources/LogoutEndpoint.swift new file mode 100644 index 00000000..5f221e55 --- /dev/null +++ b/Projects/Core/Auth/Sources/LogoutEndpoint.swift @@ -0,0 +1,12 @@ +import CoreNetwork + +/// Legacy-measured contract (TokenInterceptor special case): POST /auth/logout +/// with the *refresh* token as the Bearer credential — the reason this endpoint +/// lives in CoreAuth instead of AtchaData, which has no token access. +struct LogoutEndpoint: Endpoint { + let refreshToken: String + + var path: String { "/auth/logout" } + var method: HTTPMethod { .post } + var headers: [String: String] { ["Authorization": "Bearer \(refreshToken)"] } +} diff --git a/Projects/Core/Auth/Sources/TokenPair.swift b/Projects/Core/Auth/Sources/TokenPair.swift new file mode 100644 index 00000000..6b344c27 --- /dev/null +++ b/Projects/Core/Auth/Sources/TokenPair.swift @@ -0,0 +1,9 @@ +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 + } +} diff --git a/Projects/Core/Auth/Tests/AuthSessionManagerTests.swift b/Projects/Core/Auth/Tests/AuthSessionManagerTests.swift index 3ec242e1..d58397c7 100644 --- a/Projects/Core/Auth/Tests/AuthSessionManagerTests.swift +++ b/Projects/Core/Auth/Tests/AuthSessionManagerTests.swift @@ -8,59 +8,113 @@ struct AuthSessionManagerTests { 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) { + handler: @escaping @Sendable (any Endpoint, Int) async throws -> Data = { _, _ in Data() } + ) -> (manager: AuthSessionManager, network: ScriptedNetworkClient) { let network = ScriptedNetworkClient(handler: handler) - let manager = AuthSessionManager(tokenStore: tokenStore, networkClient: network, issuer: issuer) - return (manager, network, issuer) + let manager = AuthSessionManager(tokenStore: tokenStore, networkClient: network) + return (manager, network) } - // MARK: - bootstrap + // MARK: - bootstrapState @Test - func bootstrap_emptyStore_issuesAndSavesTokens() async throws { - let issued = TokenPair(accessToken: "A", refreshToken: "R") - let (manager, _, issuer) = makeManager(issuer: StubIssuer(result: .success(issued))) + func bootstrapState_emptyStore_returnsLoginRequired() { + let (manager, _) = makeManager() - try await manager.bootstrap() + #expect(manager.bootstrapState() == .loginRequired) + } + + @Test + func bootstrapState_existingAccessToken_returnsActive() throws { + try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) + let (manager, _) = makeManager() + + #expect(manager.bootstrapState() == .active) + } + + // MARK: - adopt / clearSession + + @Test + func adopt_savesBothTokens() async throws { + let (manager, _) = makeManager() + + try await manager.adopt(TokenPair(accessToken: "A", refreshToken: "R")) - #expect(issuer.issueCallCount == 1) #expect(try tokenStore.accessToken() == "A") #expect(try tokenStore.refreshToken() == "R") + #expect(manager.bootstrapState() == .active) } @Test - func bootstrap_existingAccessToken_skipsIssuer() async throws { + func clearSession_removesTokens() async throws { try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) - let (manager, _, issuer) = makeManager() + let (manager, _) = makeManager() - try await manager.bootstrap() + try await manager.clearSession() - #expect(issuer.issueCallCount == 0) - #expect(try tokenStore.accessToken() == "A") + #expect(try tokenStore.accessToken() == nil) + #expect(manager.bootstrapState() == .loginRequired) } - /// 미확정 입력 #2's interim behavior: an unconfigured issuer is non-fatal — - /// the app proceeds without tokens. + // MARK: - signOut / invalidateSession + + /// Pins the legacy-measured logout contract: POST /auth/logout with the + /// *refresh* token as the Bearer credential. @Test - func bootstrap_issuerNotConfigured_completesWithoutTokens() async throws { - let (manager, _, _) = makeManager() + func signOut_sendsPostLogoutWithBearerRefreshHeader_thenClearsAndYields() async throws { + try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) + let (manager, network) = makeManager() - try await manager.bootstrap() + await manager.signOut() + let logouts = network.recordedCalls(to: "/auth/logout") + #expect(logouts.count == 1) + #expect(logouts.first?.method == .post) + #expect(logouts.first?.headers["Authorization"] == "Bearer R") #expect(try tokenStore.accessToken() == nil) + #expect(try tokenStore.refreshToken() == nil) + + var iterator = manager.sessionExpired.makeAsyncIterator() + #expect(await iterator.next() != nil) } + /// Best-effort: a server failure must not trap the user in the session. @Test - func bootstrap_issuerTransportFailure_throws() async { - let (manager, _, _) = makeManager( - issuer: StubIssuer(result: .failure(URLError(.notConnectedToInternet))) - ) + func signOut_serverFailure_stillClearsTokensAndYields() async throws { + try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) + let (manager, _) = makeManager(handler: { _, _ in + throw NetworkError.offline(underlying: URLError(.notConnectedToInternet)) + }) - await #expect(throws: URLError.self) { - try await manager.bootstrap() - } + await manager.signOut() + + #expect(try tokenStore.accessToken() == nil) + var iterator = manager.sessionExpired.makeAsyncIterator() + #expect(await iterator.next() != nil) + } + + /// No refresh token → nothing to revoke server-side; local expiry only. + @Test + func signOut_withoutRefreshToken_skipsServerCall() async throws { + let (manager, network) = makeManager() + + await manager.signOut() + + #expect(network.recorded.isEmpty) + } + + /// Post-withdrawal path: no network call, local expiry + yield only. + @Test + func invalidateSession_clearsTokensAndYieldsWithoutNetwork() async throws { + try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) + let (manager, network) = makeManager() + + await manager.invalidateSession() + + #expect(network.recorded.isEmpty) + #expect(try tokenStore.accessToken() == nil) + var iterator = manager.sessionExpired.makeAsyncIterator() + #expect(await iterator.next() != nil) } // MARK: - recoverSession @@ -70,7 +124,7 @@ struct AuthSessionManagerTests { @Test func recoverSession_withRefreshToken_sendsGetReissueWithBearerRefreshHeader() async throws { try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) - let (manager, network, _) = makeManager(handler: { _, _ in + let (manager, network) = makeManager(handler: { _, _ in reissueSuccessBody(access: "A2", refresh: "R2") }) @@ -85,7 +139,7 @@ struct AuthSessionManagerTests { @Test func recoverSession_reissueSuccess_rotatesBothTokens() async throws { try tokenStore.save(TokenPair(accessToken: "A1", refreshToken: "R1")) - let (manager, _, _) = makeManager(handler: { _, _ in + let (manager, _) = makeManager(handler: { _, _ in reissueSuccessBody(access: "A2", refresh: "R2") }) @@ -95,84 +149,90 @@ struct AuthSessionManagerTests { #expect(try tokenStore.refreshToken() == "R2") } + /// A non-success envelope is a definitive rejection: the session is dead, + /// tokens are cleared, and the caller gets `loginRequired`. @Test - func recoverSession_reissueNonSuccessCode_fallsBackToIssuer() async throws { + func recoverSession_reissueNonSuccessCode_clearsTokensAndThrowsLoginRequired() 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)) - ) + let (manager, _) = makeManager(handler: { _, _ in + reissueRejectedBody(responseCode: "AUTH_401") + }) - try await manager.recoverSession() + await #expect(throws: AuthError.loginRequired) { + try await manager.recoverSession() + } - #expect(issuer.issueCallCount == 1) - #expect(try tokenStore.accessToken() == "A2") + #expect(try tokenStore.accessToken() == nil) + #expect(try tokenStore.refreshToken() == nil) } @Test - func recoverSession_reissueHTTP401_fallsBackToIssuer() async throws { + func recoverSession_reissueHTTP401_clearsTokensAndThrowsLoginRequired() 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)) - ) + let (manager, _) = makeManager(handler: { _, _ in throw unauthorizedError() }) - try await manager.recoverSession() + await #expect(throws: AuthError.loginRequired) { + try await manager.recoverSession() + } - #expect(issuer.issueCallCount == 1) - #expect(try tokenStore.accessToken() == "A2") + #expect(try tokenStore.accessToken() == nil) } @Test - func recoverSession_noRefreshToken_skipsReissueAndUsesIssuer() async throws { - let issued = TokenPair(accessToken: "A", refreshToken: "R") - let (manager, network, issuer) = makeManager(issuer: StubIssuer(result: .success(issued))) + func recoverSession_noRefreshToken_throwsLoginRequiredWithoutReissue() async throws { + let (manager, network) = makeManager() - try await manager.recoverSession() + await #expect(throws: AuthError.loginRequired) { + try await manager.recoverSession() + } #expect(network.recorded.isEmpty) - #expect(issuer.issueCallCount == 1) - #expect(try tokenStore.accessToken() == "A") } + /// A definitive rejection notifies the expiry stream (buffered — the + /// subscriber may attach later). @Test - func recoverSession_refreshAndIssuerBothFail_throws() async throws { + func recoverSession_definitiveRejection_yieldsSessionExpired() async throws { try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) - let (manager, _, _) = makeManager( - handler: { _, _ in throw unauthorizedError() }, - issuer: StubIssuer(result: .failure(URLError(.notConnectedToInternet))) - ) + let (manager, _) = makeManager(handler: { _, _ in throw unauthorizedError() }) - await #expect(throws: URLError.self) { + await #expect(throws: AuthError.loginRequired) { try await manager.recoverSession() } + + var iterator = manager.sessionExpired.makeAsyncIterator() + #expect(await iterator.next() != nil) } - /// Unlike bootstrap, recovery must not swallow issuerNotConfigured — the - /// decorator needs the failure to rethrow the original 401. + /// Transient failures (offline etc.) are not a logout: tokens survive and + /// the error propagates untouched so callers treat it as a network error. @Test - func recoverSession_issuerNotConfigured_propagatesError() async { - let (manager, _, _) = makeManager() + func recoverSession_transientFailure_keepsTokensAndPropagates() async throws { + try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) + let (manager, _) = makeManager(handler: { _, _ in + throw NetworkError.offline(underlying: URLError(.notConnectedToInternet)) + }) - await #expect(throws: AuthError.issuerNotConfigured) { + await #expect(throws: NetworkError.self) { try await manager.recoverSession() } + + #expect(try tokenStore.accessToken() == "A") + #expect(try tokenStore.refreshToken() == "R") } - /// No destructive clearing: a failed recovery keeps the stored identity. @Test - func recoverSession_reissueFailure_keepsExistingTokensWhenIssuerAlsoFails() async throws { + func recoverSession_reissueHTTP500_keepsTokensAndPropagates() async throws { try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) - let (manager, _, _) = makeManager(handler: { _, _ in throw unauthorizedError() }) + let (manager, _) = makeManager(handler: { _, _ in + throw NetworkError.unacceptableStatus(code: 500, data: Data()) + }) - await #expect(throws: AuthError.issuerNotConfigured) { + await #expect(throws: NetworkError.self) { try await manager.recoverSession() } #expect(try tokenStore.accessToken() == "A") - #expect(try tokenStore.refreshToken() == "R") } /// Single-flight: while one recovery is parked mid-reissue, concurrent @@ -182,7 +242,7 @@ struct AuthSessionManagerTests { try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) let reissueReached = AsyncGate() let reissueRelease = AsyncGate() - let (manager, network, _) = makeManager(handler: { _, _ in + let (manager, network) = makeManager(handler: { _, _ in reissueReached.open() await reissueRelease.wait() return reissueSuccessBody(access: "A2", refresh: "R2") @@ -206,7 +266,7 @@ struct AuthSessionManagerTests { @Test func recoverSession_sequentialCalls_performsReissuePerCall() async throws { try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) - let (manager, network, _) = makeManager(handler: { _, index in + let (manager, network) = makeManager(handler: { _, index in reissueSuccessBody(access: "A\(index + 2)", refresh: "R\(index + 2)") }) diff --git a/Projects/Core/Auth/Tests/AuthTestStubs.swift b/Projects/Core/Auth/Tests/AuthTestStubs.swift index 8d0ba221..24df87e6 100644 --- a/Projects/Core/Auth/Tests/AuthTestStubs.swift +++ b/Projects/Core/Auth/Tests/AuthTestStubs.swift @@ -59,22 +59,6 @@ final class ScriptedNetworkClient: NetworkClient { } } -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 { diff --git a/Projects/Core/Auth/Tests/AuthenticatedNetworkClientTests.swift b/Projects/Core/Auth/Tests/AuthenticatedNetworkClientTests.swift index 87449c99..7f413f09 100644 --- a/Projects/Core/Auth/Tests/AuthenticatedNetworkClientTests.swift +++ b/Projects/Core/Auth/Tests/AuthenticatedNetworkClientTests.swift @@ -13,23 +13,22 @@ struct AuthenticatedNetworkClientTests { /// 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) { + ) -> (sut: AuthenticatedNetworkClient, network: ScriptedNetworkClient) { let network = ScriptedNetworkClient(handler: handler) - let manager = AuthSessionManager(tokenStore: tokenStore, networkClient: network, issuer: issuer) + let manager = AuthSessionManager(tokenStore: tokenStore, networkClient: network) let sut = AuthenticatedNetworkClient( base: network, sessionManager: manager, publicPathSuffixes: publicPathSuffixes ) - return (sut, network, issuer) + return (sut, network) } @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 (sut, network) = makeSUT(handler: { _, _ in Data("ok".utf8) }) let result = try await sut.data(for: TestEndpoint()) @@ -39,7 +38,7 @@ struct AuthenticatedNetworkClientTests { @Test func dataFor_withoutToken_sendsWithoutAuthorizationHeader() async throws { - let (sut, network, _) = makeSUT(handler: { _, _ in Data() }) + let (sut, network) = makeSUT(handler: { _, _ in Data() }) _ = try await sut.data(for: TestEndpoint()) @@ -49,7 +48,7 @@ struct AuthenticatedNetworkClientTests { @Test func dataFor_publicPathSuffix_skipsAuthorizationHeader() async throws { try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) - let (sut, network, _) = makeSUT( + let (sut, network) = makeSUT( handler: { _, _ in Data() }, publicPathSuffixes: ["/public/thing"] ) @@ -62,7 +61,7 @@ struct AuthenticatedNetworkClientTests { @Test func dataFor_on401_refreshesAndRetriesWithNewToken() async throws { try tokenStore.save(TokenPair(accessToken: "OLD", refreshToken: "R")) - let (sut, network, _) = makeSUT(handler: { endpoint, index in + let (sut, network) = makeSUT(handler: { endpoint, index in switch (endpoint.path, index) { case ("/things", 0): throw unauthorizedError() case ("/things", _): return Data("ok".utf8) @@ -82,7 +81,7 @@ struct AuthenticatedNetworkClientTests { @Test func dataFor_on401RetryAlso401_throwsWithoutSecondRefresh() async throws { try tokenStore.save(TokenPair(accessToken: "OLD", refreshToken: "R")) - let (sut, network, _) = makeSUT(handler: { endpoint, _ in + let (sut, network) = makeSUT(handler: { endpoint, _ in if endpoint.path == "/things" { throw unauthorizedError() } return reissueSuccessBody(access: "NEW", refresh: "R2") }) @@ -100,7 +99,7 @@ struct AuthenticatedNetworkClientTests { @Test func dataFor_on401RecoveryFails_rethrowsOriginalUnauthorized() async throws { let marker = Data("original-401".utf8) - let (sut, network, _) = makeSUT(handler: { _, _ in + let (sut, network) = makeSUT(handler: { _, _ in throw NetworkError.unacceptableStatus(code: 401, data: marker) }) @@ -118,32 +117,30 @@ struct AuthenticatedNetworkClientTests { #expect(network.recordedCalls(to: "/things").count == 1) } + /// A definitively rejected refresh kills the session: the decorator + /// rethrows the original 401 without an unauthenticated retry, and the + /// dead tokens are cleared. @Test - func dataFor_401RefreshFailsIssuerSucceeds_retriesWithIssuedToken() async throws { + func dataFor_401RefreshRejected_rethrowsOriginal401AndClearsTokens() 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 (sut, network) = makeSUT(handler: { endpoint, _ in + if endpoint.path == "/things" { throw unauthorizedError() } + return reissueRejectedBody(responseCode: "AUTH_401") + }) - let result = try await sut.data(for: TestEndpoint()) + await #expect(throws: NetworkError.self) { + 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") + #expect(network.recordedCalls(to: "/things").count == 1) + #expect(network.recordedCalls(to: "/auth/reissue").count == 1) + #expect(try tokenStore.accessToken() == nil) } @Test func dataFor_non401Status_propagatesWithoutRecovery() async throws { try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) - let (sut, network, _) = makeSUT(handler: { _, _ in + let (sut, network) = makeSUT(handler: { _, _ in throw NetworkError.unacceptableStatus(code: 500, data: Data()) }) @@ -161,7 +158,7 @@ struct AuthenticatedNetworkClientTests { let value: String } try tokenStore.save(TokenPair(accessToken: "OLD", refreshToken: "R")) - let (sut, network, _) = makeSUT(handler: { endpoint, index in + let (sut, network) = makeSUT(handler: { endpoint, index in switch (endpoint.path, index) { case ("/things", 0): throw unauthorizedError() case ("/things", _): return Data(#"{"value": "hi"}"#.utf8) @@ -178,7 +175,7 @@ struct AuthenticatedNetworkClientTests { @Test func dataFor_endpointWithCustomHeaders_preservesThemAndAddsBearer() async throws { try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) - let (sut, network, _) = makeSUT(handler: { _, _ in Data() }) + let (sut, network) = makeSUT(handler: { _, _ in Data() }) _ = try await sut.data(for: TestEndpoint(headers: ["X-Custom": "1"])) @@ -194,7 +191,7 @@ struct AuthenticatedNetworkClientTests { try tokenStore.save(TokenPair(accessToken: "OLD", refreshToken: "R")) let reissueReached = AsyncGate() let reissueRelease = AsyncGate() - let (sut, network, _) = makeSUT(handler: { endpoint, _ in + let (sut, network) = makeSUT(handler: { endpoint, _ in if endpoint.path == "/auth/reissue" { reissueReached.open() await reissueRelease.wait() diff --git a/Projects/Data/Sources/DTO/AlertFrequencyPatchRequestDTO.swift b/Projects/Data/Sources/DTO/AlertFrequencyPatchRequestDTO.swift new file mode 100644 index 00000000..5a40a5e5 --- /dev/null +++ b/Projects/Data/Sources/DTO/AlertFrequencyPatchRequestDTO.swift @@ -0,0 +1,4 @@ +/// `PATCH /members/me/alert-frequency` body(레거시 PushAlarmPatchRequest 실측). +struct AlertFrequencyPatchRequestDTO: Encodable, Sendable { + let alertFrequencies: [Int]? +} diff --git a/Projects/Data/Sources/DTO/AuthResponseDTO.swift b/Projects/Data/Sources/DTO/AuthResponseDTO.swift new file mode 100644 index 00000000..0eaeed0b --- /dev/null +++ b/Projects/Data/Sources/DTO/AuthResponseDTO.swift @@ -0,0 +1,23 @@ +import CoreNetwork +import Domain + +struct AuthCheckResponseDTO: Decodable, Sendable { + let exists: Bool +} + +/// `/auth/login` 응답(레거시 LoginDTO 실측) — 전 필드 옵셔널, 좌표 키는 "lat"/"lon". +/// lat/lon(집 좌표)은 현 스코프 미사용이라 디코딩만 하고 엔티티로 올리지 않는다. +struct LoginResponseDTO: Decodable, Sendable { + let id: Int? + let accessToken: String? + let refreshToken: String? + let lat: Double? + let lon: Double? + + func toEntity() throws -> LoginSession { + guard let accessToken, let refreshToken else { + throw NetworkError.decoding(underlying: MissingResultError()) + } + return LoginSession(userID: id, accessToken: accessToken, refreshToken: refreshToken) + } +} diff --git a/Projects/Data/Sources/DTO/HomePatchRequestDTO.swift b/Projects/Data/Sources/DTO/HomePatchRequestDTO.swift new file mode 100644 index 00000000..77eb1a25 --- /dev/null +++ b/Projects/Data/Sources/DTO/HomePatchRequestDTO.swift @@ -0,0 +1,6 @@ +/// `PATCH /members/me/home-address` body(레거시 HomePatchRequest 실측). +struct HomePatchRequestDTO: Encodable, Sendable { + let address: String? + let lat: Double? + let lon: Double? +} diff --git a/Projects/Data/Sources/DTO/SignUpRequestDTO.swift b/Projects/Data/Sources/DTO/SignUpRequestDTO.swift new file mode 100644 index 00000000..8ba37845 --- /dev/null +++ b/Projects/Data/Sources/DTO/SignUpRequestDTO.swift @@ -0,0 +1,12 @@ +/// `POST /auth/sign-up` body(레거시 OnboardingDTO.SignUpRequest 실측). +/// 응답은 `/auth/login`과 계약이 같아 `LoginResponseDTO`를 재사용한다. +struct SignUpRequestDTO: Encodable, Sendable { + let provider: Int + let userName: String? + let address: String + let lat: Double + let lon: Double + let alertFrequencies: [Int] + /// 레거시 실측: FCM 토큰 부재 시 빈 문자열 전송. + let fcmToken: String +} diff --git a/Projects/Data/Sources/DTO/UserInfoPatchResponseDTO.swift b/Projects/Data/Sources/DTO/UserInfoPatchResponseDTO.swift new file mode 100644 index 00000000..6dc8426b --- /dev/null +++ b/Projects/Data/Sources/DTO/UserInfoPatchResponseDTO.swift @@ -0,0 +1,9 @@ +/// 두 PATCH(`home-address`·`alert-frequency`) 공용 응답(레거시 UserInfoPatchResponse +/// 실측) — 전 필드 옵셔널. V2에 소비처가 없어 디코딩 검증 후 폐기한다. +struct UserInfoPatchResponseDTO: Decodable, Sendable { + let id: Int? + let providerId: String? + let address: String? + let lat: Double? + let lon: Double? +} diff --git a/Projects/Data/Sources/DTO/UserInfoResponseDTO.swift b/Projects/Data/Sources/DTO/UserInfoResponseDTO.swift new file mode 100644 index 00000000..d768d6b6 --- /dev/null +++ b/Projects/Data/Sources/DTO/UserInfoResponseDTO.swift @@ -0,0 +1,30 @@ +import Domain + +/// `GET /members/me` 응답(레거시 UserInfoResponse 실측) — 좌표 키는 "lat"/"lon", +/// appVersion만 비옵셔널. profileUrl은 V2 미사용이라 디코딩만 하고 버린다. +struct UserInfoResponseDTO: Decodable, Sendable { + let id: Int? + let providerId: String? + let nickname: String? + let profileUrl: String? + let address: String? + let lat: Double? + let lon: Double? + let appVersion: String + + func toEntity() -> UserProfile { + let coordinate: Coordinate? = if let lat, let lon { + Coordinate(latitude: lat, longitude: lon) + } else { + nil + } + return UserProfile( + userID: id, + providerID: providerId, + nickname: nickname, + address: address, + coordinate: coordinate, + appVersion: appVersion + ) + } +} diff --git a/Projects/Data/Sources/DTO/WithdrawRequestDTO.swift b/Projects/Data/Sources/DTO/WithdrawRequestDTO.swift new file mode 100644 index 00000000..5e3a2577 --- /dev/null +++ b/Projects/Data/Sources/DTO/WithdrawRequestDTO.swift @@ -0,0 +1,4 @@ +/// `DELETE /members/me` body(레거시 WithdrawRequest 실측). +struct WithdrawRequestDTO: Encodable, Sendable { + let reason: String? +} diff --git a/Projects/Data/Sources/Network/AuthEndpoint.swift b/Projects/Data/Sources/Network/AuthEndpoint.swift new file mode 100644 index 00000000..b13d7e6c --- /dev/null +++ b/Projects/Data/Sources/Network/AuthEndpoint.swift @@ -0,0 +1,70 @@ +import CoreNetwork +import Domain +import Foundation + +/// `/auth/check`·`/auth/login` — 소셜 자격 증명을 Bearer로 보내는 특수 계약(레거시 실측). +/// AuthenticatedNetworkClient를 타면 Authorization이 서버 토큰으로 덮이므로, +/// 이 엔드포인트는 반드시 plain client로 호출한다(AuthRepositoryImpl 주입 규약). +enum AuthEndpoint: Endpoint { + case check(SocialCredential) + case login(SocialCredential, fcmToken: String?) + case signUp(SocialCredential, SignUpRequestDTO) + + var path: String { + switch self { + case .check: "/auth/check" + case .login: "/auth/login" + case .signUp: "/auth/sign-up" + } + } + + var method: HTTPMethod { + switch self { + case .check, .login: .get + case .signUp: .post + } + } + + var headers: [String: String] { + switch self { + case let .check(credential), let .login(credential, _): + ["Authorization": "Bearer \(credential.accessToken)"] + case let .signUp(credential, _): + [ + "Authorization": "Bearer \(credential.accessToken)", + "Content-Type": "application/json", + ] + } + } + + var queryItems: [URLQueryItem] { + switch self { + case let .check(credential): + [URLQueryItem(name: "provider", value: String(credential.provider.serverCode))] + case let .login(credential, fcmToken): + [ + URLQueryItem(name: "provider", value: String(credential.provider.serverCode)), + // 레거시 실측: FCM 토큰 부재 시 빈 문자열 전송. + URLQueryItem(name: "fcmToken", value: fcmToken ?? ""), + ] + case .signUp: [] + } + } + + var body: Data? { + switch self { + case .check, .login: nil + case let .signUp(_, request): try? JSONEncoder().encode(request) + } + } +} + +extension SocialLoginProvider { + /// 서버 provider 코드 — 레거시 `LoginType` raw value 실측(kakao=0, apple=1). + var serverCode: Int { + switch self { + case .kakao: 0 + case .apple: 1 + } + } +} diff --git a/Projects/Data/Sources/Network/UserEndpoint.swift b/Projects/Data/Sources/Network/UserEndpoint.swift new file mode 100644 index 00000000..39c1c7ac --- /dev/null +++ b/Projects/Data/Sources/Network/UserEndpoint.swift @@ -0,0 +1,44 @@ +import CoreNetwork +import Foundation + +enum UserEndpoint: Endpoint { + case me + case updateHomeAddress(HomePatchRequestDTO) + case updateAlertFrequencies(AlertFrequencyPatchRequestDTO) + // 탈퇴는 DELETE지만 body로 사유를 받는다 (레거시 실측). + case withdraw(WithdrawRequestDTO) + + var path: String { + switch self { + case .me, .withdraw: "/members/me" + case .updateHomeAddress: "/members/me/home-address" + case .updateAlertFrequencies: "/members/me/alert-frequency" + } + } + + var method: HTTPMethod { + switch self { + case .me: .get + case .updateHomeAddress, .updateAlertFrequencies: .patch + case .withdraw: .delete + } + } + + var headers: [String: String] { + switch self { + // 레거시 실측: 조회에만 플랫폼 식별 헤더가 붙는다. + case .me: ["X-Platform": "iOS"] + case .updateHomeAddress, .updateAlertFrequencies, .withdraw: + ["Content-Type": "application/json"] + } + } + + var body: Data? { + switch self { + case .me: nil + case let .updateHomeAddress(request): try? JSONEncoder().encode(request) + case let .updateAlertFrequencies(request): try? JSONEncoder().encode(request) + case let .withdraw(request): try? JSONEncoder().encode(request) + } + } +} diff --git a/Projects/Data/Sources/Repositories/AuthRepositoryImpl.swift b/Projects/Data/Sources/Repositories/AuthRepositoryImpl.swift new file mode 100644 index 00000000..486c7c7d --- /dev/null +++ b/Projects/Data/Sources/Repositories/AuthRepositoryImpl.swift @@ -0,0 +1,48 @@ +import CoreNetwork +import Domain + +public struct AuthRepositoryImpl: AuthRepository { + private let networkClient: any NetworkClient + + /// plain client(AuthenticatedNetworkClient 미적용)를 주입할 것 — Authorization에 + /// 소셜 자격 증명을 실어야 하고, 401이 세션 복구를 촉발해서도 안 된다. + public init(networkClient: any NetworkClient) { + self.networkClient = networkClient + } + + public func checkRegistration(credential: SocialCredential) async throws -> Bool { + let dto: AuthCheckResponseDTO = try await networkClient.requestEnveloped( + AuthEndpoint.check(credential) + ) + return dto.exists + } + + public func login(credential: SocialCredential, fcmToken: String?) async throws -> LoginSession { + let dto: LoginResponseDTO = try await networkClient.requestEnveloped( + AuthEndpoint.login(credential, fcmToken: fcmToken) + ) + return try dto.toEntity() + } + + public func signUp( + credential: SocialCredential, + form: SignUpForm, + fcmToken: String? + ) async throws -> LoginSession { + let request = SignUpRequestDTO( + provider: credential.provider.serverCode, + userName: form.userName, + address: form.address, + lat: form.coordinate.latitude, + lon: form.coordinate.longitude, + alertFrequencies: form.alertFrequencies, + // 레거시 실측: FCM 토큰 부재 시 빈 문자열 전송. + fcmToken: fcmToken ?? "" + ) + // 응답 계약은 /auth/login과 동일 — LoginResponseDTO 재사용. + let dto: LoginResponseDTO = try await networkClient.requestEnveloped( + AuthEndpoint.signUp(credential, request) + ) + return try dto.toEntity() + } +} diff --git a/Projects/Data/Sources/Repositories/UserRepositoryImpl.swift b/Projects/Data/Sources/Repositories/UserRepositoryImpl.swift new file mode 100644 index 00000000..15e0b400 --- /dev/null +++ b/Projects/Data/Sources/Repositories/UserRepositoryImpl.swift @@ -0,0 +1,41 @@ +import CoreNetwork +import Domain + +public struct UserRepositoryImpl: UserRepository { + private let networkClient: any NetworkClient + + /// 서버 토큰 데코레이터가 적용된 클라이언트를 주입할 것 — `/members/me` 계열은 + /// access 토큰 Bearer + 401 세션 복구의 일반 규약을 따른다. + public init(networkClient: any NetworkClient) { + self.networkClient = networkClient + } + + public func fetchMe() async throws -> UserProfile { + let dto: UserInfoResponseDTO = try await networkClient.requestEnveloped(UserEndpoint.me) + return dto.toEntity() + } + + public func updateHomeAddress(address: String?, coordinate: Coordinate?) async throws { + let _: UserInfoPatchResponseDTO = try await networkClient.requestEnveloped( + UserEndpoint.updateHomeAddress( + HomePatchRequestDTO( + address: address, + lat: coordinate?.latitude, + lon: coordinate?.longitude + ) + ) + ) + } + + public func updateAlertFrequencies(_ frequencies: [Int]) async throws { + let _: UserInfoPatchResponseDTO = try await networkClient.requestEnveloped( + UserEndpoint.updateAlertFrequencies(AlertFrequencyPatchRequestDTO(alertFrequencies: frequencies)) + ) + } + + public func withdraw(reason: String?) async throws { + let _: APIEmptyResult = try await networkClient.requestEnveloped( + UserEndpoint.withdraw(WithdrawRequestDTO(reason: reason)) + ) + } +} diff --git a/Projects/Data/Tests/AuthEndpointTests.swift b/Projects/Data/Tests/AuthEndpointTests.swift new file mode 100644 index 00000000..1d29c359 --- /dev/null +++ b/Projects/Data/Tests/AuthEndpointTests.swift @@ -0,0 +1,88 @@ +@testable import AtchaData +import CoreNetwork +import Domain +import Foundation +import Testing + +struct AuthEndpointTests { + private let kakaoCredential = SocialCredential(provider: .kakao, accessToken: "KAKAO_AT") + private let appleCredential = SocialCredential(provider: .apple, accessToken: "APPLE_IDT") + + /// 레거시 실측 계약 핀: 소셜 토큰이 Bearer로, provider는 kakao=0. + @Test + func check_kakao_sendsSocialBearerAndProviderZero() { + let endpoint = AuthEndpoint.check(kakaoCredential) + + #expect(endpoint.path == "/auth/check") + #expect(endpoint.method == .get) + #expect(endpoint.headers == ["Authorization": "Bearer KAKAO_AT"]) + #expect(endpoint.queryItems == [URLQueryItem(name: "provider", value: "0")]) + } + + @Test + func check_apple_sendsProviderOne() { + let endpoint = AuthEndpoint.check(appleCredential) + + #expect(endpoint.headers == ["Authorization": "Bearer APPLE_IDT"]) + #expect(endpoint.queryItems == [URLQueryItem(name: "provider", value: "1")]) + } + + @Test + func login_sendsProviderAndFcmToken() { + let endpoint = AuthEndpoint.login(kakaoCredential, fcmToken: "FCM") + + #expect(endpoint.path == "/auth/login") + #expect(endpoint.method == .get) + #expect(endpoint.headers == ["Authorization": "Bearer KAKAO_AT"]) + #expect(endpoint.queryItems == [ + URLQueryItem(name: "provider", value: "0"), + URLQueryItem(name: "fcmToken", value: "FCM"), + ]) + } + + /// 레거시 실측: FCM 토큰 부재 시 빈 문자열 전송. + @Test + func login_withoutFcmToken_sendsEmptyStringLikeLegacy() { + let endpoint = AuthEndpoint.login(appleCredential, fcmToken: nil) + + #expect(endpoint.queryItems == [ + URLQueryItem(name: "provider", value: "1"), + URLQueryItem(name: "fcmToken", value: ""), + ]) + } + + /// 레거시 실측 계약 핀: sign-up도 소셜 토큰이 Bearer로, 필드는 JSON body로. + @Test + func signUp_sendsSocialBearerAndJSONBody() throws { + let request = SignUpRequestDTO( + provider: 0, + userName: "", + address: "서울 어딘가 1-2", + lat: 37.5, + lon: 127.0, + alertFrequencies: [1, 10], + fcmToken: "" + ) + let endpoint = AuthEndpoint.signUp(kakaoCredential, request) + + #expect(endpoint.path == "/auth/sign-up") + #expect(endpoint.method == .post) + #expect(endpoint.headers == [ + "Authorization": "Bearer KAKAO_AT", + "Content-Type": "application/json", + ]) + #expect(endpoint.queryItems.isEmpty) + + let body = try #require(endpoint.body) + let json = try #require( + try JSONSerialization.jsonObject(with: body) as? [String: Any] + ) + #expect(json["provider"] as? Int == 0) + #expect(json["userName"] as? String == "") + #expect(json["address"] as? String == "서울 어딘가 1-2") + #expect(json["lat"] as? Double == 37.5) + #expect(json["lon"] as? Double == 127.0) + #expect(json["alertFrequencies"] as? [Int] == [1, 10]) + #expect(json["fcmToken"] as? String == "") + } +} diff --git a/Projects/Data/Tests/AuthResponseDTOTests.swift b/Projects/Data/Tests/AuthResponseDTOTests.swift new file mode 100644 index 00000000..99aea7f7 --- /dev/null +++ b/Projects/Data/Tests/AuthResponseDTOTests.swift @@ -0,0 +1,47 @@ +@testable import AtchaData +import CoreNetwork +import Domain +import Foundation +import Testing + +struct AuthResponseDTOTests { + @Test + func loginResponse_decodesLegacyKeysAndMapsToEntity() throws { + let json = Data(""" + {"id": 7, "accessToken": "SA", "refreshToken": "SR", "lat": 37.5, "lon": 127.0} + """.utf8) + + let dto = try JSONDecoder().decode(LoginResponseDTO.self, from: json) + let session = try dto.toEntity() + + #expect(session == LoginSession(userID: 7, accessToken: "SA", refreshToken: "SR")) + } + + @Test + func loginResponse_missingTokens_toEntityThrows() throws { + let json = Data(#"{"id": 7}"#.utf8) + let dto = try JSONDecoder().decode(LoginResponseDTO.self, from: json) + + #expect(throws: NetworkError.self) { + _ = try dto.toEntity() + } + } + + @Test + func loginResponse_missingUserID_stillMapsWithNilID() throws { + let json = Data(#"{"accessToken": "SA", "refreshToken": "SR"}"#.utf8) + let dto = try JSONDecoder().decode(LoginResponseDTO.self, from: json) + + #expect(try dto.toEntity().userID == nil) + } + + @Test + func checkResponse_decodesExists() throws { + let dto = try JSONDecoder().decode( + AuthCheckResponseDTO.self, + from: Data(#"{"exists": true}"#.utf8) + ) + + #expect(dto.exists) + } +} diff --git a/Projects/Data/Tests/UserEndpointTests.swift b/Projects/Data/Tests/UserEndpointTests.swift new file mode 100644 index 00000000..0d99ca9a --- /dev/null +++ b/Projects/Data/Tests/UserEndpointTests.swift @@ -0,0 +1,63 @@ +@testable import AtchaData +import CoreNetwork +import Foundation +import Testing + +struct UserEndpointTests { + /// 레거시 실측 계약 핀: 조회에만 플랫폼 식별 헤더가 붙는다. + @Test + func me_sendsPlatformHeader() { + let endpoint = UserEndpoint.me + + #expect(endpoint.path == "/members/me") + #expect(endpoint.method == .get) + #expect(endpoint.headers == ["X-Platform": "iOS"]) + #expect(endpoint.body == nil) + } + + @Test + func updateHomeAddress_patchesWithJSONBody() throws { + let endpoint = UserEndpoint.updateHomeAddress( + HomePatchRequestDTO(address: "서울 어딘가 1-2", lat: 37.5, lon: 127.0) + ) + + #expect(endpoint.path == "/members/me/home-address") + #expect(endpoint.method == .patch) + #expect(endpoint.headers == ["Content-Type": "application/json"]) + + let json = try decodeBody(endpoint) + #expect(json["address"] as? String == "서울 어딘가 1-2") + #expect(json["lat"] as? Double == 37.5) + #expect(json["lon"] as? Double == 127.0) + } + + @Test + func updateAlertFrequencies_patchesWithJSONBody() throws { + let endpoint = UserEndpoint.updateAlertFrequencies( + AlertFrequencyPatchRequestDTO(alertFrequencies: [1, 10]) + ) + + #expect(endpoint.path == "/members/me/alert-frequency") + #expect(endpoint.method == .patch) + + let json = try decodeBody(endpoint) + #expect(json["alertFrequencies"] as? [Int] == [1, 10]) + } + + /// 레거시 실측: 탈퇴는 DELETE지만 body로 사유를 싣는다. + @Test + func withdraw_deletesWithReasonBody() throws { + let endpoint = UserEndpoint.withdraw(WithdrawRequestDTO(reason: "서비스 미사용")) + + #expect(endpoint.path == "/members/me") + #expect(endpoint.method == .delete) + + let json = try decodeBody(endpoint) + #expect(json["reason"] as? String == "서비스 미사용") + } + + private func decodeBody(_ endpoint: any Endpoint) throws -> [String: Any] { + let body = try #require(endpoint.body) + return try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any]) + } +} diff --git a/Projects/Data/Tests/UserInfoResponseDTOTests.swift b/Projects/Data/Tests/UserInfoResponseDTOTests.swift new file mode 100644 index 00000000..af0ea786 --- /dev/null +++ b/Projects/Data/Tests/UserInfoResponseDTOTests.swift @@ -0,0 +1,56 @@ +@testable import AtchaData +import Foundation +import Testing + +struct UserInfoResponseDTOTests { + /// 레거시 실측 키 핀: 좌표는 "lat"/"lon", appVersion만 비옵셔널. + @Test + func decode_fullPayload_mapsToEntityWithCoordinate() throws { + let json = Data(""" + { + "id": 7, + "providerId": "kakao_123", + "nickname": "곤", + "profileUrl": "https://example.com/p.png", + "address": "서울 어딘가 1-2", + "lat": 37.5, + "lon": 127.0, + "appVersion": "2.0.0" + } + """.utf8) + + let dto = try JSONDecoder().decode(UserInfoResponseDTO.self, from: json) + let entity = dto.toEntity() + + #expect(entity.userID == 7) + #expect(entity.providerID == "kakao_123") + #expect(entity.nickname == "곤") + #expect(entity.address == "서울 어딘가 1-2") + #expect(entity.coordinate?.latitude == 37.5) + #expect(entity.coordinate?.longitude == 127.0) + #expect(entity.appVersion == "2.0.0") + } + + /// 좌표는 lat·lon이 모두 있을 때만 성립한다(레거시 toEntity 규칙). + @Test + func decode_missingLon_dropsCoordinate() throws { + let json = Data(""" + { "lat": 37.5, "appVersion": "2.0.0" } + """.utf8) + + let dto = try JSONDecoder().decode(UserInfoResponseDTO.self, from: json) + + #expect(dto.toEntity().coordinate == nil) + } + + /// 두 PATCH 공용 응답 — 전 필드 옵셔널이라 빈 객체도 디코딩된다. + @Test + func patchResponse_decodesEmptyObject() throws { + let dto = try JSONDecoder().decode( + UserInfoPatchResponseDTO.self, from: Data("{}".utf8) + ) + + #expect(dto.id == nil) + #expect(dto.address == nil) + } +} diff --git a/Projects/DesignSystem/Example/ComponentDemos.swift b/Projects/DesignSystem/Example/ComponentDemos.swift index ffc912a5..de3afb89 100644 --- a/Projects/DesignSystem/Example/ComponentDemos.swift +++ b/Projects/DesignSystem/Example/ComponentDemos.swift @@ -30,6 +30,15 @@ final class ButtonsDemoViewController: GalleryScreenViewController { disabledChip.setText("→ 구로디지털단지역") disabledChip.isEnabled = false [chip, disabledChip].forEach { contentStack.addArrangedSubview(leadingRow($0)) } + + addSectionTitle("DSPressEffect") + let pressButton = DSButton(title: "눌러 보세요 (스케일 다운)", style: .primary) + DSPressEffect.apply(to: pressButton) + contentStack.addArrangedSubview(pressButton) + let pressChip = DSChip() + pressChip.setText("→ 칩도 눌러 보세요") + DSPressEffect.apply(to: pressChip) + contentStack.addArrangedSubview(leadingRow(pressChip)) } /// 칩은 자기 크기 컴포넌트 — 스택 전폭으로 늘리지 않고 leading에 붙인다(홈과 같은 배치). @@ -55,6 +64,15 @@ final class FieldsDemoViewController: GalleryScreenViewController { contentStack.addArrangedSubview(titleBar) let searchBar = DSNavigationBar(style: .search(placeholder: "장소 검색")) contentStack.addArrangedSubview(searchBar) + + addSectionTitle("DSGroupedCard") + let grouped = DSGroupedCard( + rows: [ + DSTextField(placeholder: "출발지 입력"), + DSTextField(placeholder: "도착지 입력", showsAccentDot: true), + ] + ) + contentStack.addArrangedSubview(grouped) } } diff --git a/Projects/DesignSystem/Sources/Components/DSGroupedCard.swift b/Projects/DesignSystem/Sources/Components/DSGroupedCard.swift new file mode 100644 index 00000000..0668f196 --- /dev/null +++ b/Projects/DesignSystem/Sources/Components/DSGroupedCard.swift @@ -0,0 +1,58 @@ +import UIKit + +/// 여러 행을 한 표면으로 묶는 그룹 카드 — 홈·검색의 출발/도착 필드처럼 연관 행을 +/// 하나의 카드로 보여줄 때 쓴다. 행 사이엔 leading 인셋 헤어라인이 들어간다. +/// 행 구성은 init 고정 — 현재 호출부(홈·검색 모두 행 2개 정적)에 변경 API가 불필요하다. +public final class DSGroupedCard: UIView { + private static let separatorThickness: CGFloat = 0.5 + + public init(rows: [UIView]) { + super.init(frame: .zero) + backgroundColor = DSColor.Fill.surface + layer.cornerRadius = DSRadius.lg + // 행의 하이라이트 fill이 모서리 밖으로 새지 않게 자른다. + clipsToBounds = true + + let stack = UIStackView() + stack.axis = .vertical + stack.spacing = 0 + stack.translatesAutoresizingMaskIntoConstraints = false + addSubview(stack) + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: topAnchor), + stack.leadingAnchor.constraint(equalTo: leadingAnchor), + stack.trailingAnchor.constraint(equalTo: trailingAnchor), + stack.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + for (index, row) in rows.enumerated() { + if index > 0 { + stack.addArrangedSubview(Self.makeSeparatorRow()) + } + stack.addArrangedSubview(row) + } + } + + /// DSSeparator는 전폭 고정이라 재사용하지 않는다 — 카드 내부 구분선은 + /// leading만 콘텐츠 인셋을 따르는 별도 헤어라인이다. + private static func makeSeparatorRow() -> UIView { + let container = UIView() + let line = UIView() + line.backgroundColor = DSColor.Border.default + line.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(line) + NSLayoutConstraint.activate([ + line.topAnchor.constraint(equalTo: container.topAnchor), + line.bottomAnchor.constraint(equalTo: container.bottomAnchor), + line.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: DSSpacing.md), + line.trailingAnchor.constraint(equalTo: container.trailingAnchor), + line.heightAnchor.constraint(equalToConstant: Self.separatorThickness), + ]) + return container + } + + @available(*, unavailable) + public required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } +} diff --git a/Projects/DesignSystem/Sources/Components/DSPressEffect.swift b/Projects/DesignSystem/Sources/Components/DSPressEffect.swift new file mode 100644 index 00000000..586aa753 --- /dev/null +++ b/Projects/DesignSystem/Sources/Components/DSPressEffect.swift @@ -0,0 +1,34 @@ +import UIKit + +/// 프레스 스케일 다운 효과 — 누르는 동안 살짝 줄었다가 떼면 복원된다. +/// 호출부 옵트인(addAction 부착)이라 기존 컴포넌트 코드는 건드리지 않는다. +public enum DSPressEffect { + static let pressedScale: CGFloat = 0.97 + private static let duration: TimeInterval = 0.15 + + /// UIControl 계열(UIButton·DSChip·커스텀 행)에 터치 이벤트로 부착한다. + public static func apply(to control: UIControl) { + control.addAction( + UIAction { [weak control] _ in control.map { setPressed(true, on: $0) } }, + for: [.touchDown, .touchDragEnter] + ) + control.addAction( + UIAction { [weak control] _ in control.map { setPressed(false, on: $0) } }, + for: [.touchUpInside, .touchUpOutside, .touchCancel, .touchDragExit] + ) + } + + /// UIControl이 아닌 표면(셀의 setHighlighted 등)에서 직접 호출하는 심. + public static func setPressed(_ pressed: Bool, on view: UIView) { + guard !UIAccessibility.isReduceMotionEnabled else { return } + UIView.animate( + withDuration: duration, + delay: 0, + options: [.beginFromCurrentState, .allowUserInteraction, .curveEaseOut] + ) { + view.transform = pressed + ? CGAffineTransform(scaleX: pressedScale, y: pressedScale) + : .identity + } + } +} diff --git a/Projects/DesignSystem/Tests/DSGroupedCardTests.swift b/Projects/DesignSystem/Tests/DSGroupedCardTests.swift new file mode 100644 index 00000000..33362da8 --- /dev/null +++ b/Projects/DesignSystem/Tests/DSGroupedCardTests.swift @@ -0,0 +1,48 @@ +@testable import DesignSystem +import Testing +import UIKit + +@MainActor +struct DSGroupedCardTests { + @Test + func cardSurfaceAndRadius() { + let card = DSGroupedCard(rows: [UIView(), UIView()]) + #expect(card.backgroundColor.map { colorsMatch($0, DSColor.Fill.surface) } == true) + #expect(card.layer.cornerRadius == DSRadius.lg) + #expect(card.clipsToBounds) + } + + @Test + func rowsAreInterleavedWithSeparators() throws { + let rows = [UIView(), UIView(), UIView()] + let card = DSGroupedCard(rows: rows) + let stack = try #require(card.subviews.first as? UIStackView) + // 행 3개 + 사이 구분선 2개 = 5, 순서는 행-선-행-선-행. + #expect(stack.arrangedSubviews.count == 5) + #expect(stack.arrangedSubviews[0] === rows[0]) + #expect(stack.arrangedSubviews[2] === rows[1]) + #expect(stack.arrangedSubviews[4] === rows[2]) + } + + @Test + func separatorIsInsetHairline() throws { + let card = DSGroupedCard(rows: [UIView(), UIView()]) + let stack = try #require(card.subviews.first as? UIStackView) + let separatorContainer = stack.arrangedSubviews[1] + let line = try #require(separatorContainer.subviews.first) + #expect(line.backgroundColor.map { colorsMatch($0, DSColor.Border.default) } == true) + let heightConstraint = line.constraints.first { $0.firstAttribute == .height } + #expect(heightConstraint?.constant == 0.5) + let leadingConstraint = separatorContainer.constraints.first { + $0.firstAttribute == .leading || $0.secondAttribute == .leading + } + #expect(leadingConstraint?.constant == DSSpacing.md) + } + + @Test + func singleRowHasNoSeparator() throws { + let card = DSGroupedCard(rows: [UIView()]) + let stack = try #require(card.subviews.first as? UIStackView) + #expect(stack.arrangedSubviews.count == 1) + } +} diff --git a/Projects/DesignSystem/Tests/DSPressEffectTests.swift b/Projects/DesignSystem/Tests/DSPressEffectTests.swift new file mode 100644 index 00000000..d5bc8c02 --- /dev/null +++ b/Projects/DesignSystem/Tests/DSPressEffectTests.swift @@ -0,0 +1,34 @@ +@testable import DesignSystem +import Testing +import UIKit + +@MainActor +struct DSPressEffectTests { + // UIView.animate는 호스트리스에서도 모델 값을 즉시 반영한다 — 기존 테스트 규약과 동일. + @Test + func pressedAppliesScaleDown() { + let view = UIView() + DSPressEffect.setPressed(true, on: view) + #expect(abs(view.transform.a - DSPressEffect.pressedScale) < 0.0001) + #expect(abs(view.transform.d - DSPressEffect.pressedScale) < 0.0001) + } + + @Test + func releaseRestoresIdentity() { + let view = UIView() + DSPressEffect.setPressed(true, on: view) + DSPressEffect.setPressed(false, on: view) + #expect(view.transform == .identity) + } + + // 이벤트 발화는 UIApplication이 필요하다 — 부착이 깨지지 않는 것까지만 검증하고 + // 눌림 동작 자체는 setPressed 심으로 검증한다(위 두 테스트). + @Test + func applyAttachesWithoutSideEffects() { + let control = UIControl() + DSPressEffect.apply(to: control) + #expect(control.transform == .identity) + #expect(control.allControlEvents.contains(.touchDown)) + #expect(control.allControlEvents.contains(.touchUpInside)) + } +} diff --git a/Projects/Domain/Sources/Entities/LoginSession.swift b/Projects/Domain/Sources/Entities/LoginSession.swift new file mode 100644 index 00000000..795a1433 --- /dev/null +++ b/Projects/Domain/Sources/Entities/LoginSession.swift @@ -0,0 +1,13 @@ +/// `/auth/login` 성공 결과 — 서버가 교환해준 토큰 쌍. +/// 응답의 집 좌표(lat/lon)는 현 스코프에서 쓰지 않으므로 엔티티에서 생략한다(DTO만 디코딩). +public struct LoginSession: Sendable, Equatable { + public let userID: Int? + public let accessToken: String + public let refreshToken: String + + public init(userID: Int?, accessToken: String, refreshToken: String) { + self.userID = userID + self.accessToken = accessToken + self.refreshToken = refreshToken + } +} diff --git a/Projects/Domain/Sources/Entities/SignUpForm.swift b/Projects/Domain/Sources/Entities/SignUpForm.swift new file mode 100644 index 00000000..4d613c0a --- /dev/null +++ b/Projects/Domain/Sources/Entities/SignUpForm.swift @@ -0,0 +1,32 @@ +/// `POST /auth/sign-up` 입력 — V2는 온보딩 화면 없이 최소 가입으로 보낸다. +/// 집 주소·알림 빈도는 가입 후 `/members/me` PATCH로 언제든 바꿀 수 있다. +public struct SignUpForm: Sendable, Equatable { + public let userName: String? + public let address: String + public let coordinate: Coordinate + public let alertFrequencies: [Int] + + public init( + userName: String?, + address: String, + coordinate: Coordinate, + alertFrequencies: [Int] + ) { + self.userName = userName + self.address = address + self.coordinate = coordinate + self.alertFrequencies = alertFrequencies + } + + /// 레거시 실측 기본 알림 빈도(HomeFindViewModel): 1분·10분 전. + public static let defaultAlertFrequencies = [1, 10] + + /// 위치를 확보하지 못했을 때의 폼 — 레거시가 실제로 보내던 값 + /// (userName ""·address ""·좌표 (0,0))이라 서버 수용이 실측돼 있다. + public static let minimalFallback = SignUpForm( + userName: "", + address: "", + coordinate: Coordinate(latitude: 0, longitude: 0), + alertFrequencies: defaultAlertFrequencies + ) +} diff --git a/Projects/Domain/Sources/Entities/SocialCredential.swift b/Projects/Domain/Sources/Entities/SocialCredential.swift new file mode 100644 index 00000000..cf7ccd9c --- /dev/null +++ b/Projects/Domain/Sources/Entities/SocialCredential.swift @@ -0,0 +1,26 @@ +/// 소셜 로그인 제공자 — 서버 provider 파라미터 값(kakao=0, apple=1)은 Data 레이어가 매핑한다. +public enum SocialLoginProvider: Sendable, Equatable, CaseIterable { + case kakao + case apple +} + +/// 소셜 SDK가 발급한 자격 증명 — 카카오는 OAuth access token, 애플은 identityToken. +/// 서버 `/auth/*` 호출에서 Bearer로 실려 서버 토큰과 교환된다. +public struct SocialCredential: Sendable, Equatable { + public let provider: SocialLoginProvider + public let accessToken: String + + public init(provider: SocialLoginProvider, accessToken: String) { + self.provider = provider + self.accessToken = accessToken + } +} + +public enum SocialLoginError: Error, Equatable, Sendable { + /// 사용자가 소셜 로그인 UI를 스스로 닫음 — 에러 표출 없이 idle 복귀. + case cancelled + /// 제공자를 쓸 수 없는 상태(카카오 SDK 미초기화 등). + case providerUnavailable + /// 그 외 제공자 실패. + case failed +} diff --git a/Projects/Domain/Sources/Entities/UserProfile.swift b/Projects/Domain/Sources/Entities/UserProfile.swift new file mode 100644 index 00000000..4fb513d2 --- /dev/null +++ b/Projects/Domain/Sources/Entities/UserProfile.swift @@ -0,0 +1,26 @@ +/// `GET /members/me` 결과 — 서버에 저장된 내 계정 정보. +/// 좌표는 서버가 lat·lon을 모두 줄 때만 성립한다(레거시 toEntity 규칙 계승). +public struct UserProfile: Sendable, Equatable { + public let userID: Int? + public let providerID: String? + public let nickname: String? + public let address: String? + public let coordinate: Coordinate? + public let appVersion: String? + + public init( + userID: Int?, + providerID: String?, + nickname: String?, + address: String?, + coordinate: Coordinate?, + appVersion: String? + ) { + self.userID = userID + self.providerID = providerID + self.nickname = nickname + self.address = address + self.coordinate = coordinate + self.appVersion = appVersion + } +} diff --git a/Projects/Domain/Sources/Interfaces/AuthRepository.swift b/Projects/Domain/Sources/Interfaces/AuthRepository.swift new file mode 100644 index 00000000..8edf532d --- /dev/null +++ b/Projects/Domain/Sources/Interfaces/AuthRepository.swift @@ -0,0 +1,11 @@ +/// 로그인 도메인 API(`/auth/check`·`/auth/login`·`/auth/sign-up`) — 구현은 AtchaData. +/// 소셜 자격 증명을 Bearer로 보내는 특수 계약이라 서버 토큰 데코레이터를 타지 않는다 +/// (조합 루트가 plain client를 주입). +public protocol AuthRepository: Sendable { + /// GET /auth/check — 이 소셜 계정이 이미 가입돼 있는가. + func checkRegistration(credential: SocialCredential) async throws -> Bool + /// GET /auth/login — 소셜 자격 증명을 서버 토큰 쌍으로 교환. + func login(credential: SocialCredential, fcmToken: String?) async throws -> LoginSession + /// POST /auth/sign-up — 최소 가입 후 서버 토큰 쌍 수령(레거시 실측 계약). + func signUp(credential: SocialCredential, form: SignUpForm, fcmToken: String?) async throws -> LoginSession +} diff --git a/Projects/Domain/Sources/Interfaces/PushTokenProviding.swift b/Projects/Domain/Sources/Interfaces/PushTokenProviding.swift new file mode 100644 index 00000000..30c4bf77 --- /dev/null +++ b/Projects/Domain/Sources/Interfaces/PushTokenProviding.swift @@ -0,0 +1,5 @@ +/// 푸시 토큰 포트 — `/auth/login`의 fcmToken 파라미터 소싱용. +/// V2는 FCM 토큰 서버 전달이 미정(미확정 #5)이라 nil이 일반 상태다. +public protocol PushTokenProviding: Sendable { + func currentPushToken() async -> String? +} diff --git a/Projects/Domain/Sources/Interfaces/SessionEnding.swift b/Projects/Domain/Sources/Interfaces/SessionEnding.swift new file mode 100644 index 00000000..642d2ca0 --- /dev/null +++ b/Projects/Domain/Sources/Interfaces/SessionEnding.swift @@ -0,0 +1,8 @@ +/// 세션 종료 포트 — 어댑터는 App에서 `AuthSessionManager`로 배선. +public protocol SessionEnding: Sendable { + /// 로그아웃: 서버 `/auth/logout`은 베스트 에포트(실패 무시)로 호출하고, + /// 로컬 토큰 정리와 세션 만료 라우팅(로그인 복귀)을 항상 진행한다. + func endSession() async + /// 탈퇴 후 정리: 서버 토큰은 이미 무효이므로 로컬 정리 + 만료 라우팅만. + func invalidateLocalSession() async +} diff --git a/Projects/Domain/Sources/Interfaces/SessionStoring.swift b/Projects/Domain/Sources/Interfaces/SessionStoring.swift new file mode 100644 index 00000000..0fec2ca2 --- /dev/null +++ b/Projects/Domain/Sources/Interfaces/SessionStoring.swift @@ -0,0 +1,5 @@ +/// 서버 토큰 채택 포트 — Domain이 CoreAuth를 모른 채 로그인 결과를 세션에 반영한다. +/// 어댑터는 App에서 `AuthSessionManager.adopt(_:)`로 배선. +public protocol SessionStoring: Sendable { + func store(accessToken: String, refreshToken: String) async throws +} diff --git a/Projects/Domain/Sources/Interfaces/SocialLoginService.swift b/Projects/Domain/Sources/Interfaces/SocialLoginService.swift new file mode 100644 index 00000000..1d9bed18 --- /dev/null +++ b/Projects/Domain/Sources/Interfaces/SocialLoginService.swift @@ -0,0 +1,5 @@ +/// 소셜 SDK 포트 — 어댑터 구현은 App에 둔다(외부 SDK는 앱 타겟에서만 링크). +/// 제공자 UI(카카오톡 전환·Apple 시트)를 띄우므로 메인 액터 격리. +public protocol SocialLoginService: Sendable { + @MainActor func authorize(provider: SocialLoginProvider) async throws -> SocialCredential +} diff --git a/Projects/Domain/Sources/Interfaces/UserRepository.swift b/Projects/Domain/Sources/Interfaces/UserRepository.swift new file mode 100644 index 00000000..7c455812 --- /dev/null +++ b/Projects/Domain/Sources/Interfaces/UserRepository.swift @@ -0,0 +1,11 @@ +/// 계정 도메인 API(`/members/me` 계열) — 구현은 AtchaData, 서버 토큰 데코레이터 적용 대상. +public protocol UserRepository: Sendable { + /// GET /members/me — 내 계정 정보 조회. + func fetchMe() async throws -> UserProfile + /// PATCH /members/me/home-address — 집 주소 변경. + func updateHomeAddress(address: String?, coordinate: Coordinate?) async throws + /// PATCH /members/me/alert-frequency — 알림 빈도 변경. + func updateAlertFrequencies(_ frequencies: [Int]) async throws + /// DELETE /members/me — 회원탈퇴. 성공하면 서버 세션은 이미 무효다. + func withdraw(reason: String?) async throws +} diff --git a/Projects/Domain/Sources/UseCases/GetUserProfileUseCase.swift b/Projects/Domain/Sources/UseCases/GetUserProfileUseCase.swift new file mode 100644 index 00000000..5a50a617 --- /dev/null +++ b/Projects/Domain/Sources/UseCases/GetUserProfileUseCase.swift @@ -0,0 +1,15 @@ +public protocol GetUserProfileUseCase: Sendable { + func execute() async throws -> UserProfile +} + +public struct DefaultGetUserProfileUseCase: GetUserProfileUseCase { + private let userRepository: any UserRepository + + public init(userRepository: any UserRepository) { + self.userRepository = userRepository + } + + public func execute() async throws -> UserProfile { + try await userRepository.fetchMe() + } +} diff --git a/Projects/Domain/Sources/UseCases/LogoutUseCase.swift b/Projects/Domain/Sources/UseCases/LogoutUseCase.swift new file mode 100644 index 00000000..574bfeb6 --- /dev/null +++ b/Projects/Domain/Sources/UseCases/LogoutUseCase.swift @@ -0,0 +1,16 @@ +public protocol LogoutUseCase: Sendable { + /// 서버 로그아웃은 베스트 에포트 — 실패해도 로컬 세션은 끝난다(throws 없음). + func execute() async +} + +public struct DefaultLogoutUseCase: LogoutUseCase { + private let sessionEnding: any SessionEnding + + public init(sessionEnding: any SessionEnding) { + self.sessionEnding = sessionEnding + } + + public func execute() async { + await sessionEnding.endSession() + } +} diff --git a/Projects/Domain/Sources/UseCases/SignInUseCase.swift b/Projects/Domain/Sources/UseCases/SignInUseCase.swift new file mode 100644 index 00000000..d56af840 --- /dev/null +++ b/Projects/Domain/Sources/UseCases/SignInUseCase.swift @@ -0,0 +1,86 @@ +/// 소셜 로그인 전 과정: 소셜 인가 → 가입 여부 확인 → 토큰 교환(기존 회원) 또는 +/// 최소 가입(신규 — 온보딩 화면 없이 현재 위치 기반 폼) → 세션 채택. +/// 소셜 UI를 띄우는 authorize가 메인 액터 격리라 execute도 @MainActor. +public protocol SignInUseCase: Sendable { + @MainActor func execute(provider: SocialLoginProvider) async throws +} + +public final class DefaultSignInUseCase: SignInUseCase { + private let socialLoginService: any SocialLoginService + private let authRepository: any AuthRepository + private let sessionStore: any SessionStoring + private let pushTokenProvider: any PushTokenProviding + private let getCurrentLocationUseCase: any GetCurrentLocationUseCase + private let reverseGeocodeUseCase: any ReverseGeocodeUseCase + private let locationTimeout: Duration + + public init( + socialLoginService: any SocialLoginService, + authRepository: any AuthRepository, + sessionStore: any SessionStoring, + pushTokenProvider: any PushTokenProviding, + getCurrentLocationUseCase: any GetCurrentLocationUseCase, + reverseGeocodeUseCase: any ReverseGeocodeUseCase, + locationTimeout: Duration = .seconds(8) + ) { + self.socialLoginService = socialLoginService + self.authRepository = authRepository + self.sessionStore = sessionStore + self.pushTokenProvider = pushTokenProvider + self.getCurrentLocationUseCase = getCurrentLocationUseCase + self.reverseGeocodeUseCase = reverseGeocodeUseCase + self.locationTimeout = locationTimeout + } + + @MainActor + public func execute(provider: SocialLoginProvider) async throws { + let credential = try await socialLoginService.authorize(provider: provider) + let fcmToken = await pushTokenProvider.currentPushToken() + let session: LoginSession + if try await authRepository.checkRegistration(credential: credential) { + session = try await authRepository.login(credential: credential, fcmToken: fcmToken) + } else { + session = try await authRepository.signUp( + credential: credential, + form: await makeMinimalForm(), + fcmToken: fcmToken + ) + } + try await sessionStore.store( + accessToken: session.accessToken, + refreshToken: session.refreshToken + ) + } + + /// 최소 가입 폼 — 위치 실패(권한 거부·타임아웃)면 레거시 실측 폴백, + /// 역지오코딩만 실패하면 주소만 빈 문자열로 강등한다. + private func makeMinimalForm() async -> SignUpForm { + guard let coordinate = await currentCoordinate() else { + return .minimalFallback + } + let address = (try? await reverseGeocodeUseCase.execute(coordinate: coordinate))?.address ?? "" + return SignUpForm( + userName: "", + address: address, + coordinate: coordinate, + alertFrequencies: SignUpForm.defaultAlertFrequencies + ) + } + + /// 위치 조회는 권한 프롬프트 대기 등으로 길어질 수 있어 타임아웃과 레이스한다 — + /// 가입이 위치 때문에 무한정 막히면 안 된다. + private func currentCoordinate() async -> Coordinate? { + let locationUseCase = getCurrentLocationUseCase + let timeout = locationTimeout + return await withTaskGroup(of: Coordinate?.self) { group in + group.addTask { try? await locationUseCase.execute() } + group.addTask { + try? await Task.sleep(for: timeout) + return nil + } + let first = await group.next() ?? nil + group.cancelAll() + return first + } + } +} diff --git a/Projects/Domain/Sources/UseCases/UpdateAlertFrequencyUseCase.swift b/Projects/Domain/Sources/UseCases/UpdateAlertFrequencyUseCase.swift new file mode 100644 index 00000000..d7e8c713 --- /dev/null +++ b/Projects/Domain/Sources/UseCases/UpdateAlertFrequencyUseCase.swift @@ -0,0 +1,15 @@ +public protocol UpdateAlertFrequencyUseCase: Sendable { + func execute(frequencies: [Int]) async throws +} + +public struct DefaultUpdateAlertFrequencyUseCase: UpdateAlertFrequencyUseCase { + private let userRepository: any UserRepository + + public init(userRepository: any UserRepository) { + self.userRepository = userRepository + } + + public func execute(frequencies: [Int]) async throws { + try await userRepository.updateAlertFrequencies(frequencies) + } +} diff --git a/Projects/Domain/Sources/UseCases/UpdateHomeAddressUseCase.swift b/Projects/Domain/Sources/UseCases/UpdateHomeAddressUseCase.swift new file mode 100644 index 00000000..8773f2f9 --- /dev/null +++ b/Projects/Domain/Sources/UseCases/UpdateHomeAddressUseCase.swift @@ -0,0 +1,15 @@ +public protocol UpdateHomeAddressUseCase: Sendable { + func execute(address: String?, coordinate: Coordinate?) async throws +} + +public struct DefaultUpdateHomeAddressUseCase: UpdateHomeAddressUseCase { + private let userRepository: any UserRepository + + public init(userRepository: any UserRepository) { + self.userRepository = userRepository + } + + public func execute(address: String?, coordinate: Coordinate?) async throws { + try await userRepository.updateHomeAddress(address: address, coordinate: coordinate) + } +} diff --git a/Projects/Domain/Sources/UseCases/WithdrawUseCase.swift b/Projects/Domain/Sources/UseCases/WithdrawUseCase.swift new file mode 100644 index 00000000..62efa5d9 --- /dev/null +++ b/Projects/Domain/Sources/UseCases/WithdrawUseCase.swift @@ -0,0 +1,20 @@ +public protocol WithdrawUseCase: Sendable { + func execute(reason: String?) async throws +} + +public struct DefaultWithdrawUseCase: WithdrawUseCase { + private let userRepository: any UserRepository + private let sessionEnding: any SessionEnding + + public init(userRepository: any UserRepository, sessionEnding: any SessionEnding) { + self.userRepository = userRepository + self.sessionEnding = sessionEnding + } + + public func execute(reason: String?) async throws { + // 탈퇴가 서버에서 성공한 뒤에만 로컬 세션을 무효화한다 — + // 실패 시 세션을 보존해야 사용자가 재시도할 수 있다. + try await userRepository.withdraw(reason: reason) + await sessionEnding.invalidateLocalSession() + } +} diff --git a/Projects/Domain/Tests/DefaultLogoutUseCaseTests.swift b/Projects/Domain/Tests/DefaultLogoutUseCaseTests.swift new file mode 100644 index 00000000..4f03c7c3 --- /dev/null +++ b/Projects/Domain/Tests/DefaultLogoutUseCaseTests.swift @@ -0,0 +1,23 @@ +@testable import Domain +import Synchronization +import Testing + +private final class SessionEndingSpy: SessionEnding, Sendable { + private let events = Mutex<[String]>([]) + var log: [String] { events.withLock { $0 } } + + func endSession() async { events.withLock { $0.append("endSession") } } + func invalidateLocalSession() async { events.withLock { $0.append("invalidate") } } +} + +struct DefaultLogoutUseCaseTests { + @Test + func execute_endsSession() async { + let spy = SessionEndingSpy() + let sut = DefaultLogoutUseCase(sessionEnding: spy) + + await sut.execute() + + #expect(spy.log == ["endSession"]) + } +} diff --git a/Projects/Domain/Tests/DefaultSignInUseCaseTests.swift b/Projects/Domain/Tests/DefaultSignInUseCaseTests.swift new file mode 100644 index 00000000..b6fb998a --- /dev/null +++ b/Projects/Domain/Tests/DefaultSignInUseCaseTests.swift @@ -0,0 +1,223 @@ +@testable import Domain +import Foundation +import Synchronization +import Testing + +/// 스텁 공유 기록기 — 호출 순서·횟수·전달 인자를 한 곳에서 검증한다. +private final class SignInRecorder: Sendable { + private let events = Mutex<[String]>([]) + + func record(_ event: String) { events.withLock { $0.append(event) } } + var log: [String] { events.withLock { $0 } } + func count(of event: String) -> Int { log.count(where: { $0 == event }) } +} + +private struct StubSocialLoginService: SocialLoginService { + let recorder: SignInRecorder + var result: Result = + .success(SocialCredential(provider: .kakao, accessToken: "SOCIAL")) + + func authorize(provider: SocialLoginProvider) async throws -> SocialCredential { + recorder.record("authorize") + return try result.get() + } +} + +private struct StubAuthRepository: AuthRepository { + let recorder: SignInRecorder + var exists = true + var session = LoginSession(userID: 1, accessToken: "SA", refreshToken: "SR") + var signUpError: (any Error)? + + func checkRegistration(credential: SocialCredential) async throws -> Bool { + recorder.record("check(\(credential.accessToken))") + return exists + } + + func login(credential: SocialCredential, fcmToken: String?) async throws -> LoginSession { + recorder.record("login(fcm:\(fcmToken ?? "nil"))") + return session + } + + func signUp( + credential: SocialCredential, + form: SignUpForm, + fcmToken: String? + ) async throws -> LoginSession { + recorder.record( + "signUp(addr:\(form.address)/lat:\(form.coordinate.latitude)" + + "/freq:\(form.alertFrequencies)/name:\(form.userName ?? "nil")" + + "/fcm:\(fcmToken ?? "nil"))" + ) + if let signUpError { throw signUpError } + return session + } +} + +private struct StubSessionStore: SessionStoring { + let recorder: SignInRecorder + var error: (any Error)? + + func store(accessToken: String, refreshToken: String) async throws { + recorder.record("store(\(accessToken)/\(refreshToken))") + if let error { throw error } + } +} + +private struct StubPushTokenProvider: PushTokenProviding { + var token: String? + + func currentPushToken() async -> String? { token } +} + +private struct StubGetCurrentLocationUseCase: GetCurrentLocationUseCase { + var result: Result = + .success(Coordinate(latitude: 37.5, longitude: 127.0)) + + func execute() async throws -> Coordinate { + try result.get() + } +} + +private struct StubReverseGeocodeUseCase: ReverseGeocodeUseCase { + var result: Result = .success( + Place(name: "집앞", address: "서울 어딘가 1-2", coordinate: Coordinate(latitude: 37.5, longitude: 127.0)) + ) + + func execute(coordinate: Coordinate) async throws -> Place { + try result.get() + } +} + +private struct StoreError: Error {} +private struct SignUpError: Error {} +private struct GeocodeError: Error {} + +struct DefaultSignInUseCaseTests { + private let recorder = SignInRecorder() + + private func makeSUT( + social: StubSocialLoginService? = nil, + repository: StubAuthRepository? = nil, + store: StubSessionStore? = nil, + pushToken: String? = nil, + location: StubGetCurrentLocationUseCase = StubGetCurrentLocationUseCase(), + geocode: StubReverseGeocodeUseCase = StubReverseGeocodeUseCase() + ) -> DefaultSignInUseCase { + DefaultSignInUseCase( + socialLoginService: social ?? StubSocialLoginService(recorder: recorder), + authRepository: repository ?? StubAuthRepository(recorder: recorder), + sessionStore: store ?? StubSessionStore(recorder: recorder), + pushTokenProvider: StubPushTokenProvider(token: pushToken), + getCurrentLocationUseCase: location, + reverseGeocodeUseCase: geocode + ) + } + + @Test + func execute_registered_runsAuthorizeCheckLoginStoreInOrder() async throws { + let sut = makeSUT(pushToken: "FCM") + + try await sut.execute(provider: .kakao) + + #expect(recorder.log == [ + "authorize", + "check(SOCIAL)", + "login(fcm:FCM)", + "store(SA/SR)", + ]) + } + + @Test + func execute_noPushToken_passesNilToLogin() async throws { + let sut = makeSUT() + + try await sut.execute(provider: .apple) + + #expect(recorder.count(of: "login(fcm:nil)") == 1) + } + + /// 미가입이면 자동으로 최소 가입한다 — 현재 좌표 + 역지오코딩 주소 + 기본 알림 빈도. + @Test + func execute_notRegistered_signsUpWithLocationFormThenStores() async throws { + let sut = makeSUT( + repository: StubAuthRepository(recorder: recorder, exists: false), + pushToken: "FCM" + ) + + try await sut.execute(provider: .kakao) + + #expect(recorder.log == [ + "authorize", + "check(SOCIAL)", + "signUp(addr:서울 어딘가 1-2/lat:37.5/freq:[1, 10]/name:/fcm:FCM)", + "store(SA/SR)", + ]) + } + + /// 위치 실패(권한 거부 등) → 레거시 실측 폴백(address ""·좌표 (0,0))으로 가입한다. + @Test + func execute_notRegistered_locationFailure_fallsBackToMinimalForm() async throws { + let sut = makeSUT( + repository: StubAuthRepository(recorder: recorder, exists: false), + location: StubGetCurrentLocationUseCase(result: .failure(.permissionDenied)) + ) + + try await sut.execute(provider: .kakao) + + #expect(recorder.count(of: "signUp(addr:/lat:0.0/freq:[1, 10]/name:/fcm:nil)") == 1) + } + + /// 역지오코딩만 실패하면 좌표는 유지하고 주소만 빈 문자열로 강등한다. + @Test + func execute_notRegistered_geocodeFailure_keepsCoordinateWithEmptyAddress() async throws { + let sut = makeSUT( + repository: StubAuthRepository(recorder: recorder, exists: false), + geocode: StubReverseGeocodeUseCase(result: .failure(GeocodeError())) + ) + + try await sut.execute(provider: .kakao) + + #expect(recorder.count(of: "signUp(addr:/lat:37.5/freq:[1, 10]/name:/fcm:nil)") == 1) + } + + /// 가입 실패는 그대로 전파되고 세션은 채택되지 않는다. + @Test + func execute_signUpFailure_propagatesWithoutStore() async { + let sut = makeSUT( + repository: StubAuthRepository( + recorder: recorder, exists: false, signUpError: SignUpError() + ) + ) + + await #expect(throws: SignUpError.self) { + try await sut.execute(provider: .kakao) + } + + #expect(recorder.count(of: "store(SA/SR)") == 0) + } + + @Test + func execute_authorizeCancelled_propagatesWithoutServerCalls() async { + let sut = makeSUT( + social: StubSocialLoginService(recorder: recorder, result: .failure(.cancelled)) + ) + + await #expect(throws: SocialLoginError.cancelled) { + try await sut.execute(provider: .kakao) + } + + #expect(recorder.log == ["authorize"]) + } + + @Test + func execute_storeFailure_propagates() async { + let sut = makeSUT(store: StubSessionStore(recorder: recorder, error: StoreError())) + + await #expect(throws: StoreError.self) { + try await sut.execute(provider: .kakao) + } + + #expect(recorder.count(of: "store(SA/SR)") == 1) + } +} diff --git a/Projects/Domain/Tests/DefaultWithdrawUseCaseTests.swift b/Projects/Domain/Tests/DefaultWithdrawUseCaseTests.swift new file mode 100644 index 00000000..230b9ab0 --- /dev/null +++ b/Projects/Domain/Tests/DefaultWithdrawUseCaseTests.swift @@ -0,0 +1,74 @@ +@testable import Domain +import Synchronization +import Testing + +private final class WithdrawRecorder: Sendable { + private let events = Mutex<[String]>([]) + + func record(_ event: String) { events.withLock { $0.append(event) } } + var log: [String] { events.withLock { $0 } } +} + +private struct StubUserRepository: UserRepository { + let recorder: WithdrawRecorder + var withdrawError: (any Error)? + + func fetchMe() async throws -> UserProfile { + UserProfile( + userID: nil, providerID: nil, nickname: nil, + address: nil, coordinate: nil, appVersion: nil + ) + } + + func updateHomeAddress(address: String?, coordinate: Coordinate?) async throws {} + + func updateAlertFrequencies(_ frequencies: [Int]) async throws {} + + func withdraw(reason: String?) async throws { + recorder.record("withdraw(\(reason ?? "nil"))") + if let withdrawError { throw withdrawError } + } +} + +private struct StubSessionEnding: SessionEnding { + let recorder: WithdrawRecorder + + func endSession() async { recorder.record("endSession") } + func invalidateLocalSession() async { recorder.record("invalidate") } +} + +private struct WithdrawServerError: Error {} + +struct DefaultWithdrawUseCaseTests { + private let recorder = WithdrawRecorder() + + /// 탈퇴가 서버에서 성공한 뒤에만 로컬 세션을 무효화한다. + @Test + func execute_success_withdrawsThenInvalidatesLocalSession() async throws { + let sut = DefaultWithdrawUseCase( + userRepository: StubUserRepository(recorder: recorder), + sessionEnding: StubSessionEnding(recorder: recorder) + ) + + try await sut.execute(reason: "서비스 미사용") + + #expect(recorder.log == ["withdraw(서비스 미사용)", "invalidate"]) + } + + /// 서버 탈퇴 실패 시 세션을 보존한다 — 재시도할 수 있어야 한다. + @Test + func execute_serverFailure_propagatesAndKeepsSession() async { + let sut = DefaultWithdrawUseCase( + userRepository: StubUserRepository( + recorder: recorder, withdrawError: WithdrawServerError() + ), + sessionEnding: StubSessionEnding(recorder: recorder) + ) + + await #expect(throws: WithdrawServerError.self) { + try await sut.execute(reason: nil) + } + + #expect(recorder.log == ["withdraw(nil)"]) + } +} diff --git a/Projects/Feature/Auth/Example/ExampleApp.swift b/Projects/Feature/Auth/Example/ExampleApp.swift new file mode 100644 index 00000000..1f177240 --- /dev/null +++ b/Projects/Feature/Auth/Example/ExampleApp.swift @@ -0,0 +1,106 @@ +import AuthFeature +import AuthFeatureInterface +import CoreCoordinator +import Domain +import UIKit + +@main +final class AppDelegate: UIResponder, UIApplicationDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + 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? + private let navigationController = UINavigationController() + private var loginCoordinator: (any Coordinator)? + + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + guard let windowScene = scene as? UIWindowScene else { return } + + let launcher = LauncherViewController() + launcher.onStart = { [weak self] in self?.startLoginFlow() } + navigationController.viewControllers = [launcher] + + let window = UIWindow(windowScene: windowScene) + window.rootViewController = navigationController + window.makeKeyAndVisible() + self.window = window + } + + private func startLoginFlow() { + let container = AuthDIContainer(signInUseCase: PreviewSignInUseCase()) + let coordinator = container.makeLoginCoordinator( + navigationController: navigationController, + onAuthenticated: { [weak self] in self?.showAuthenticated() } + ) + coordinator.finishDelegate = self + loginCoordinator = coordinator + coordinator.start() + } + + private func showAuthenticated() { + let alert = UIAlertController(title: "로그인 완료", message: "세션 채택 성공 경로", preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "확인", style: .default)) + navigationController.present(alert, animated: true) + } +} + +extension SceneDelegate: CoordinatorFinishDelegate { + // finish 후 런처로 복귀 — 플로우를 반복 시연할 수 있다. + func coordinatorDidFinish(_ coordinator: any Coordinator) { + loginCoordinator = nil + } +} + +private final class LauncherViewController: UIViewController { + var onStart: (() -> Void)? + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + title = "AuthFeature Example" + + let startButton = UIButton(type: .system) + startButton.setTitle("로그인 시트 열기 (실패→성공 순환)", for: .normal) + startButton.addAction(UIAction { [weak self] _ in self?.onStart?() }, for: .touchUpInside) + startButton.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(startButton) + NSLayoutConstraint.activate([ + startButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), + startButton.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + } +} + +/// 탭할 때마다 실패 토스트 → 성공을 순환 — 두 UX를 모두 시연한다 +/// (미가입은 UseCase 내부에서 자동 가입으로 흡수돼 화면 분기가 없다). +/// (스텁이 Tests와 중복되는 것은 의도된 트레이드오프 — 규약 참고.) +@MainActor +private final class PreviewSignInUseCase: SignInUseCase { + private var attempt = 0 + + private struct PreviewError: Error {} + + func execute(provider: SocialLoginProvider) async throws { + try? await Task.sleep(for: .seconds(1)) + defer { attempt += 1 } + if attempt % 2 == 0 { throw PreviewError() } + } +} diff --git a/Projects/Feature/Auth/Interface/AuthCoordinatorBuildable.swift b/Projects/Feature/Auth/Interface/AuthCoordinatorBuildable.swift new file mode 100644 index 00000000..8f7482b5 --- /dev/null +++ b/Projects/Feature/Auth/Interface/AuthCoordinatorBuildable.swift @@ -0,0 +1,15 @@ +import CoreCoordinator +import UIKit + +/// Entry point other modules use to start the login flow. +/// The concrete builder is the feature's DIContainer; wiring happens at the +/// App composition root. +@MainActor +public protocol AuthCoordinatorBuildable { + /// 강제 로그인 플로우 — 성공(세션 채택 완료) 시에만 onAuthenticated가 불린다. + /// 취소 경로는 없다(시트 dismiss 제스처 미제공). + func makeLoginCoordinator( + navigationController: UINavigationController, + onAuthenticated: @escaping () -> Void + ) -> any Coordinator +} diff --git a/Projects/Feature/Auth/Project.swift b/Projects/Feature/Auth/Project.swift new file mode 100644 index 00000000..453a3675 --- /dev/null +++ b/Projects/Feature/Auth/Project.swift @@ -0,0 +1,23 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +let project = Project.feature( + name: "Auth", + dependencies: [ + .project(target: "Domain", path: "../../Domain"), + .project(target: "DesignSystem", path: "../../DesignSystem"), + .project(target: "CoreCoordinator", path: "../../Core/Coordinator"), + .external(name: "SnapKit"), + ], + interfaceDependencies: [ + .project(target: "CoreCoordinator", path: "../../Core/Coordinator"), + ], + testDependencies: [ + .project(target: "Domain", path: "../../Domain"), + ], + exampleDependencies: [ + .project(target: "Domain", path: "../../Domain"), + .project(target: "CoreCoordinator", path: "../../Core/Coordinator"), + ], + resources: ["Resources/**"] +) diff --git a/Projects/Feature/Auth/Resources/AuthFeature.xcassets/Contents.json b/Projects/Feature/Auth/Resources/AuthFeature.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Projects/Feature/Auth/Resources/AuthFeature.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Feature/Auth/Resources/AuthFeature.xcassets/apple.imageset/Button Icon.png b/Projects/Feature/Auth/Resources/AuthFeature.xcassets/apple.imageset/Button Icon.png new file mode 100644 index 00000000..1619631e Binary files /dev/null and b/Projects/Feature/Auth/Resources/AuthFeature.xcassets/apple.imageset/Button Icon.png differ diff --git a/Projects/Feature/Auth/Resources/AuthFeature.xcassets/apple.imageset/Button Icon@2x.png b/Projects/Feature/Auth/Resources/AuthFeature.xcassets/apple.imageset/Button Icon@2x.png new file mode 100644 index 00000000..230f9e87 Binary files /dev/null and b/Projects/Feature/Auth/Resources/AuthFeature.xcassets/apple.imageset/Button Icon@2x.png differ diff --git a/Projects/Feature/Auth/Resources/AuthFeature.xcassets/apple.imageset/Button Icon@3x.png b/Projects/Feature/Auth/Resources/AuthFeature.xcassets/apple.imageset/Button Icon@3x.png new file mode 100644 index 00000000..bcbb7f8b Binary files /dev/null and b/Projects/Feature/Auth/Resources/AuthFeature.xcassets/apple.imageset/Button Icon@3x.png differ diff --git a/Projects/Feature/Auth/Resources/AuthFeature.xcassets/apple.imageset/Contents.json b/Projects/Feature/Auth/Resources/AuthFeature.xcassets/apple.imageset/Contents.json new file mode 100644 index 00000000..e1feba99 --- /dev/null +++ b/Projects/Feature/Auth/Resources/AuthFeature.xcassets/apple.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { + "filename" : "Button Icon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Button Icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Button Icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Projects/Feature/Auth/Resources/AuthFeature.xcassets/kakao.imageset/Button Icon.png b/Projects/Feature/Auth/Resources/AuthFeature.xcassets/kakao.imageset/Button Icon.png new file mode 100644 index 00000000..e724295b Binary files /dev/null and b/Projects/Feature/Auth/Resources/AuthFeature.xcassets/kakao.imageset/Button Icon.png differ diff --git a/Projects/Feature/Auth/Resources/AuthFeature.xcassets/kakao.imageset/Button Icon@2x.png b/Projects/Feature/Auth/Resources/AuthFeature.xcassets/kakao.imageset/Button Icon@2x.png new file mode 100644 index 00000000..256f00de Binary files /dev/null and b/Projects/Feature/Auth/Resources/AuthFeature.xcassets/kakao.imageset/Button Icon@2x.png differ diff --git a/Projects/Feature/Auth/Resources/AuthFeature.xcassets/kakao.imageset/Button Icon@3x.png b/Projects/Feature/Auth/Resources/AuthFeature.xcassets/kakao.imageset/Button Icon@3x.png new file mode 100644 index 00000000..41405a0c Binary files /dev/null and b/Projects/Feature/Auth/Resources/AuthFeature.xcassets/kakao.imageset/Button Icon@3x.png differ diff --git a/Projects/Feature/Auth/Resources/AuthFeature.xcassets/kakao.imageset/Contents.json b/Projects/Feature/Auth/Resources/AuthFeature.xcassets/kakao.imageset/Contents.json new file mode 100644 index 00000000..e1feba99 --- /dev/null +++ b/Projects/Feature/Auth/Resources/AuthFeature.xcassets/kakao.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { + "filename" : "Button Icon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Button Icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Button Icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Projects/Feature/Auth/Sources/AuthDIContainer.swift b/Projects/Feature/Auth/Sources/AuthDIContainer.swift new file mode 100644 index 00000000..3d901da2 --- /dev/null +++ b/Projects/Feature/Auth/Sources/AuthDIContainer.swift @@ -0,0 +1,31 @@ +import AuthFeatureInterface +import CoreCoordinator +import Domain +import UIKit + +/// Assembles the Auth feature's screens. Coordinators own flow only; +/// screen/ViewModel assembly stays here. +public final class AuthDIContainer: AuthCoordinatorBuildable { + private let signInUseCase: any SignInUseCase + + public init(signInUseCase: any SignInUseCase) { + self.signInUseCase = signInUseCase + } + + public func makeLoginCoordinator( + navigationController: UINavigationController, + onAuthenticated: @escaping () -> Void + ) -> any Coordinator { + LoginCoordinator( + navigationController: navigationController, + container: self, + onAuthenticated: onAuthenticated + ) + } + + func makeLoginViewController(onAuthenticated: @escaping () -> Void) -> LoginViewController { + let viewModel = LoginViewModel(signInUseCase: signInUseCase) + viewModel.onAuthenticated = onAuthenticated + return LoginViewController(viewModel: viewModel) + } +} diff --git a/Projects/Feature/Auth/Sources/AuthPalette.swift b/Projects/Feature/Auth/Sources/AuthPalette.swift new file mode 100644 index 00000000..77388f37 --- /dev/null +++ b/Projects/Feature/Auth/Sources/AuthPalette.swift @@ -0,0 +1,10 @@ +import UIKit + +/// 소셜 브랜드 고정색 — 테마를 타는 시맨틱 토큰이 아니라 브랜드 상수라 DSPalette에 +/// 올리지 않는다(소비처가 로그인 화면뿐). 값은 레거시 colorset 실측. +enum AuthPalette { + /// 카카오 컨테이너 배경 (#FEE500). + static let kakaoYellow = UIColor(red: 0xFE / 255, green: 0xE5 / 255, blue: 0x00 / 255, alpha: 1) + /// 카카오 심볼 틴트 (#181600). + static let kakaoSymbol = UIColor(red: 0x18 / 255, green: 0x16 / 255, blue: 0x00 / 255, alpha: 1) +} diff --git a/Projects/Feature/Auth/Sources/LoginCoordinator.swift b/Projects/Feature/Auth/Sources/LoginCoordinator.swift new file mode 100644 index 00000000..a291a10d --- /dev/null +++ b/Projects/Feature/Auth/Sources/LoginCoordinator.swift @@ -0,0 +1,46 @@ +import CoreCoordinator +import UIKit + +final class LoginCoordinator: Coordinator { + var childCoordinators: [any Coordinator] = [] + weak var finishDelegate: (any CoordinatorFinishDelegate)? + + // The App (window root) owns the navigation controller. + private weak var navigationController: UINavigationController? + private let container: AuthDIContainer + private let onAuthenticated: () -> Void + private weak var loginViewController: LoginViewController? + + init( + navigationController: UINavigationController, + container: AuthDIContainer, + onAuthenticated: @escaping () -> Void + ) { + self.navigationController = navigationController + self.container = container + self.onAuthenticated = onAuthenticated + } + + func start() { + guard let navigationController else { return } + let viewController = container.makeLoginViewController(onAuthenticated: { [weak self] in + self?.completeFlow() + }) + loginViewController = viewController + navigationController.present(viewController, animated: false) + } + + /// 성공 유일 종료 경로 — 시트 하강 연출 후 onAuthenticated → finish. + private func completeFlow() { + guard let loginViewController else { + onAuthenticated() + finish() + return + } + loginViewController.animateDismiss { [weak self] in + guard let self else { return } + self.onAuthenticated() + self.finish() + } + } +} diff --git a/Projects/Feature/Auth/Sources/LoginViewController.swift b/Projects/Feature/Auth/Sources/LoginViewController.swift new file mode 100644 index 00000000..3ffd61f2 --- /dev/null +++ b/Projects/Feature/Auth/Sources/LoginViewController.swift @@ -0,0 +1,138 @@ +import DesignSystem +import Domain +import SnapKit +import UIKit + +/// 레거시 로그인 바텀시트 비주얼 이식(dim 0.9 + 컨테이너 198 + 핸들 + 슬라이드업). +/// 강제 로그인이라 dim 탭·팬 다운 dismiss 제스처는 계승하지 않는다 — 핸들은 장식. +final class LoginViewController: UIViewController { + private let viewModel: LoginViewModel + + private let dimView = UIView() + private let containerView = UIView() + private let sheetHandle = UIView() + private let kakaoButton = SocialLoginButton( + title: "카카오톡으로 3초만에 시작", + icon: AuthFeatureAsset.kakao.image, + textColor: .black, + background: AuthPalette.kakaoYellow, + iconTint: AuthPalette.kakaoSymbol + ) + private let appleButton = SocialLoginButton( + title: "Apple로 시작", + icon: AuthFeatureAsset.apple.image, + textColor: .white, + background: .black, + iconTint: .white + ) + private let sheetHeight: CGFloat = 198 + + init(viewModel: LoginViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .overFullScreen + modalTransitionStyle = .crossDissolve + } + + override func viewDidLoad() { + super.viewDidLoad() + setupLayout() + bindViewModel() + containerView.transform = CGAffineTransform(translationX: 0, y: sheetHeight) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseOut) { + self.dimView.alpha = 1 + self.containerView.transform = .identity + } + } + + /// 성공 종료 연출(레거시 dismissSheet) — 시트 하강 + dim 페이드 후 실제 dismiss. + /// 코디네이터가 부른다. + func animateDismiss(completion: @escaping () -> Void) { + UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseIn) { + self.dimView.alpha = 0 + self.containerView.transform = CGAffineTransform(translationX: 0, y: self.sheetHeight) + } completion: { _ in + self.dismiss(animated: false, completion: completion) + } + } + + private func setupLayout() { + view.backgroundColor = .clear + + dimView.backgroundColor = UIColor.black.withAlphaComponent(0.9) + dimView.alpha = 0 + view.addSubview(dimView) + dimView.snp.makeConstraints { $0.edges.equalToSuperview() } + + containerView.backgroundColor = DSColor.Background.elevated + containerView.layer.cornerRadius = 20 + containerView.clipsToBounds = true + view.addSubview(containerView) + containerView.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + make.height.equalTo(sheetHeight) + } + + sheetHandle.backgroundColor = DSColor.Fill.highlight + sheetHandle.layer.cornerRadius = 2 + containerView.addSubview(sheetHandle) + sheetHandle.snp.makeConstraints { make in + make.top.equalToSuperview().offset(DSSpacing.sm12) + make.centerX.equalToSuperview() + make.width.equalTo(40) + make.height.equalTo(4) + } + + let buttonStack = UIStackView(arrangedSubviews: [kakaoButton, appleButton]) + buttonStack.axis = .vertical + buttonStack.spacing = DSSpacing.sm12 + containerView.addSubview(buttonStack) + buttonStack.snp.makeConstraints { make in + make.horizontalEdges.equalToSuperview().inset(DSSpacing.lg) + make.bottom.equalToSuperview().inset(40) + } + + kakaoButton.addTarget(self, action: #selector(didTapKakao), for: .touchUpInside) + appleButton.addTarget(self, action: #selector(didTapApple), for: .touchUpInside) + } + + private func bindViewModel() { + viewModel.onStateChange = { [weak self] state in + self?.render(state) + } + viewModel.onEvent = { [weak self] event in + self?.handle(event) + } + render(viewModel.state) + } + + private func render(_ state: LoginViewModel.State) { + let isLoading = state != .idle + kakaoButton.isEnabled = !isLoading + appleButton.isEnabled = !isLoading + } + + private func handle(_ event: LoginViewModel.Event) { + switch event { + case let .showFailureToast(message): + DSToast.show(message, in: view) + } + } + + @objc private func didTapKakao() { + viewModel.kakaoTapped() + } + + @objc private func didTapApple() { + viewModel.appleTapped() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } +} diff --git a/Projects/Feature/Auth/Sources/LoginViewModel.swift b/Projects/Feature/Auth/Sources/LoginViewModel.swift new file mode 100644 index 00000000..2a3c637c --- /dev/null +++ b/Projects/Feature/Auth/Sources/LoginViewModel.swift @@ -0,0 +1,70 @@ +import Domain +import Foundation + +@MainActor +final class LoginViewModel { + enum State: Equatable { + case idle + /// 어느 제공자로 진행 중인지 — 화면은 양 버튼을 잠근다(중복 탭 방지). + case loading(SocialLoginProvider) + } + + /// 원샷 안내 — 상태와 분리한다(Home의 ToastEvent 패턴). + enum Event: Equatable { + case showFailureToast(message: String) + } + + static let failureMessage = "로그인에 실패했어요. 잠시 후 다시 시도해주세요." + + var onStateChange: ((State) -> Void)? + var onEvent: ((Event) -> Void)? + /// 세션 채택까지 완료된 뒤에만 불린다 — 코디네이터가 플로우를 닫는 신호. + var onAuthenticated: (() -> Void)? + + private(set) var state: State = .idle { + didSet { onStateChange?(state) } + } + + private let signInUseCase: any SignInUseCase + private var signInTask: Task? + + init(signInUseCase: any SignInUseCase) { + self.signInUseCase = signInUseCase + } + + deinit { + signInTask?.cancel() + } + + func kakaoTapped() { + signIn(provider: .kakao) + } + + func appleTapped() { + signIn(provider: .apple) + } + + private func signIn(provider: SocialLoginProvider) { + guard state == .idle else { return } + state = .loading(provider) + signInTask = Task { [weak self] in + guard let self else { return } + do { + // 미가입 계정은 UseCase가 최소 가입까지 수행한다 — 성공은 곧 세션 채택 완료. + try await self.signInUseCase.execute(provider: provider) + guard !Task.isCancelled else { return } + // 성공 시 idle 복귀 없이 바로 닫는다 — 버튼이 다시 살아나며 + // 깜빡이는 프레임을 만들지 않는다. + self.onAuthenticated?() + } catch SocialLoginError.cancelled { + // 사용자가 소셜 UI를 스스로 닫음 — 에러 표출 없이 재시도 대기. + guard !Task.isCancelled else { return } + self.state = .idle + } catch { + guard !Task.isCancelled else { return } + self.state = .idle + self.onEvent?(.showFailureToast(message: Self.failureMessage)) + } + } + } +} diff --git a/Projects/Feature/Auth/Sources/SocialLoginButton.swift b/Projects/Feature/Auth/Sources/SocialLoginButton.swift new file mode 100644 index 00000000..bc335fb5 --- /dev/null +++ b/Projects/Feature/Auth/Sources/SocialLoginButton.swift @@ -0,0 +1,53 @@ +import DesignSystem +import SnapKit +import UIKit + +/// 레거시 로그인 버튼 스펙 이식: 높이 52 · radius 8 · 아이콘 leading 16(24pt) · 라벨 중앙. +/// DSButton은 아이콘을 타이틀에 인접 배치하는 Configuration 기반이라 이 스펙(아이콘 +/// 고정 + 라벨 독립 중앙)에는 전용 컴포넌트가 맞다. +final class SocialLoginButton: UIButton { + static let height: CGFloat = 52 + + private let iconView = UIImageView() + private let label = UILabel() + + init(title: String, icon: UIImage?, textColor: UIColor, background: UIColor, iconTint: UIColor) { + super.init(frame: .zero) + + backgroundColor = background + layer.cornerRadius = DSRadius.sm + clipsToBounds = true + + iconView.image = icon + iconView.contentMode = .scaleAspectFit + iconView.tintColor = iconTint + iconView.isUserInteractionEnabled = false + + label.attributedText = DSTypography.label1.attributed(title, color: textColor, alignment: .center) + label.isUserInteractionEnabled = false + + addSubview(iconView) + addSubview(label) + + iconView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(DSSpacing.md) + make.centerY.equalToSuperview() + make.width.height.equalTo(DSIconSize.lg) + } + label.snp.makeConstraints { make in + make.center.equalToSuperview() + } + snp.makeConstraints { make in + make.height.equalTo(Self.height) + } + } + + override var isEnabled: Bool { + didSet { alpha = isEnabled ? 1 : 0.5 } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } +} diff --git a/Projects/Feature/Auth/Tests/LoginViewModelTests.swift b/Projects/Feature/Auth/Tests/LoginViewModelTests.swift new file mode 100644 index 00000000..aba3d00c --- /dev/null +++ b/Projects/Feature/Auth/Tests/LoginViewModelTests.swift @@ -0,0 +1,133 @@ +@testable import AuthFeature +import Domain +import Foundation +import Testing + +@MainActor +private final class StubSignInUseCase: SignInUseCase { + private(set) var executeCallCount = 0 + private(set) var providers: [SocialLoginProvider] = [] + private var result: Result + private let suspends: Bool + private var continuation: CheckedContinuation? + + init(result: Result = .success(()), suspends: Bool = false) { + self.result = result + self.suspends = suspends + } + + func execute(provider: SocialLoginProvider) async throws { + executeCallCount += 1 + providers.append(provider) + if suspends { + return try await withCheckedThrowingContinuation { continuation = $0 } + } + return try result.get() + } + + func resumeSuccessfully() { + continuation?.resume(returning: ()) + continuation = nil + } +} + +@MainActor +private final class Recorder { + private(set) var states: [LoginViewModel.State] = [] + private(set) var events: [LoginViewModel.Event] = [] + private(set) var authenticatedCount = 0 + + func attach(to viewModel: LoginViewModel) { + viewModel.onStateChange = { [weak self] state in self?.states.append(state) } + viewModel.onEvent = { [weak self] event in self?.events.append(event) } + viewModel.onAuthenticated = { [weak self] in self?.authenticatedCount += 1 } + } + + // Home 템플릿의 드레인 패턴: 스텁이 즉시 resolve하므로 yield로 충분하다. + func waitUntil(_ predicate: () -> Bool) async { + while !predicate() { + await Task.yield() + } + } +} + +private struct StubError: Error {} + +@MainActor +struct LoginViewModelTests { + @Test + func kakaoTapped_success_callsOnAuthenticatedWithoutIdleFlicker() async { + let useCase = StubSignInUseCase() + let sut = LoginViewModel(signInUseCase: useCase) + let recorder = Recorder() + recorder.attach(to: sut) + + sut.kakaoTapped() + await recorder.waitUntil { recorder.authenticatedCount == 1 } + + #expect(useCase.providers == [.kakao]) + // 성공 시 idle 복귀 없이 바로 닫힌다 — 버튼 재활성 프레임 없음. + #expect(recorder.states == [.loading(.kakao)]) + #expect(recorder.events.isEmpty) + } + + @Test + func appleTapped_passesAppleProvider() async { + let useCase = StubSignInUseCase() + let sut = LoginViewModel(signInUseCase: useCase) + let recorder = Recorder() + recorder.attach(to: sut) + + sut.appleTapped() + await recorder.waitUntil { recorder.authenticatedCount == 1 } + + #expect(useCase.providers == [.apple]) + } + + @Test + func tapped_socialCancelled_returnsIdleSilently() async { + let useCase = StubSignInUseCase(result: .failure(SocialLoginError.cancelled)) + let sut = LoginViewModel(signInUseCase: useCase) + let recorder = Recorder() + recorder.attach(to: sut) + + sut.kakaoTapped() + await recorder.waitUntil { recorder.states.last == .idle } + + #expect(recorder.events.isEmpty) + #expect(recorder.authenticatedCount == 0) + } + + @Test + func tapped_failure_returnsIdleAndEmitsFailureToast() async { + let useCase = StubSignInUseCase(result: .failure(StubError())) + let sut = LoginViewModel(signInUseCase: useCase) + let recorder = Recorder() + recorder.attach(to: sut) + + sut.appleTapped() + await recorder.waitUntil { recorder.states.last == .idle } + + #expect(recorder.events == [.showFailureToast(message: LoginViewModel.failureMessage)]) + #expect(recorder.authenticatedCount == 0) + } + + /// 로딩 중 중복 탭은 무시된다 — 소셜 UI 이중 기동 방지. + @Test + func tapped_whileLoading_ignoresSecondTap() async { + let useCase = StubSignInUseCase(suspends: true) + let sut = LoginViewModel(signInUseCase: useCase) + let recorder = Recorder() + recorder.attach(to: sut) + + sut.kakaoTapped() + await recorder.waitUntil { useCase.executeCallCount == 1 } + sut.appleTapped() + + #expect(useCase.executeCallCount == 1) + #expect(recorder.states == [.loading(.kakao)]) + + useCase.resumeSuccessfully() + await recorder.waitUntil { recorder.authenticatedCount == 1 } + } +} diff --git a/Projects/Feature/Home/Sources/HomeFieldRow.swift b/Projects/Feature/Home/Sources/HomeFieldRow.swift new file mode 100644 index 00000000..8750e1e1 --- /dev/null +++ b/Projects/Feature/Home/Sources/HomeFieldRow.swift @@ -0,0 +1,87 @@ +import DesignSystem +import SnapKit +import UIKit + +/// 홈의 출발/도착 행 — 편집이 아니라 검색 진입 트리거다. DSGroupedCard 안에 +/// DSTextField를 넣으면 surface 위 surface 중첩이 되므로 카드 행에 맞는 평면 +/// 행으로 대체한다. 텍스트 규약은 DSTextField와 동일: 빈 값이면 placeholder가 +/// 유도 문구 역할을 한다. 우측 셰브런이 "탭하면 검색으로 이동"을 시사한다. +final class HomeFieldRow: UIControl { + private static let height: CGFloat = 56 + + private let placeholder: String + private let textLabel = UILabel() + + init(icon: UIImage, placeholder: String, showsAccentDot: Bool = false) { + self.placeholder = placeholder + super.init(frame: .zero) + + let iconView = UIImageView(image: icon) + iconView.tintColor = DSColor.Icon.default + iconView.contentMode = .scaleAspectFit + + let chevronView = UIImageView(image: DSIcon.chevronRight16) + chevronView.tintColor = DSColor.Icon.muted + chevronView.contentMode = .scaleAspectFit + + textLabel.font = DSTypography.body1.font + + [iconView, textLabel, chevronView].forEach(addSubview) + iconView.snp.makeConstraints { make in + make.leading.equalToSuperview().inset(DSSpacing.md) + make.centerY.equalToSuperview() + make.size.equalTo(DSIconSize.lg) + } + + let textLeading: ConstraintItem + if showsAccentDot { + let dot = UIView() + dot.backgroundColor = DSColor.Accent.default + dot.layer.cornerRadius = 2 + addSubview(dot) + dot.snp.makeConstraints { make in + make.leading.equalTo(iconView.snp.trailing).offset(DSSpacing.sm) + make.centerY.equalToSuperview() + make.size.equalTo(4) + } + textLeading = dot.snp.trailing + } else { + textLeading = iconView.snp.trailing + } + textLabel.snp.makeConstraints { make in + make.leading.equalTo(textLeading).offset(DSSpacing.sm) + make.centerY.equalToSuperview() + make.trailing.lessThanOrEqualTo(chevronView.snp.leading).offset(-DSSpacing.sm) + } + chevronView.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(DSSpacing.md) + make.centerY.equalToSuperview() + make.size.equalTo(DSIconSize.sm) + } + snp.makeConstraints { make in + make.height.equalTo(Self.height) + } + + isAccessibilityElement = true + accessibilityTraits = .button + accessibilityLabel = placeholder + setText("") + } + + func setText(_ text: String) { + if text.isEmpty { + textLabel.text = placeholder + textLabel.textColor = DSColor.Text.secondary + accessibilityValue = nil + } else { + textLabel.text = text + textLabel.textColor = DSColor.Text.primary + accessibilityValue = text + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } +} diff --git a/Projects/Feature/Home/Sources/HomeViewController.swift b/Projects/Feature/Home/Sources/HomeViewController.swift index 4266d3c8..e6694807 100644 --- a/Projects/Feature/Home/Sources/HomeViewController.swift +++ b/Projects/Feature/Home/Sources/HomeViewController.swift @@ -8,23 +8,33 @@ final class HomeViewController: UIViewController { // VC strongly owns the VM; the VM's closures capture the VC weakly. private let viewModel: HomeViewModel - private let titleLabel: UILabel = { + // 토스식 위계: 브랜드는 작은 워드마크로 물러나고 시각적 무게는 컨텍스트 + // 타이틀("어디로 가세요?")이 가져간다. 타이틀은 정적 카피 — 상태 무관. + private let brandLabel: UILabel = { let label = UILabel() - label.font = DSTypography.title1.font + label.font = DSTypography.heading.font label.textColor = DSColor.Accent.default label.text = "앗차" return label }() + private let bigTitleLabel: UILabel = { + let label = UILabel() + label.numberOfLines = 0 + label.attributedText = DSTypography.title1.attributed( + "어디로 가세요?", color: DSColor.Text.primary + ) + return label + }() + private let banner = DSBanner() - private let departureField = DSTextField(placeholder: "출발지를 검색해 주세요", showsAccentDot: true) - private let arrivalField = DSTextField(placeholder: "도착지를 검색해 주세요") - private lazy var departureRow = makeFieldRow( - icon: DSIcon.myLocation24, field: departureField, entry: .departure + private let departureRow = HomeFieldRow( + icon: DSIcon.myLocation24, placeholder: "출발지를 검색해 주세요", showsAccentDot: true ) - private lazy var arrivalRow = makeFieldRow( - icon: DSIcon.place24, field: arrivalField, entry: .arrival + private let arrivalRow = HomeFieldRow( + icon: DSIcon.place24, placeholder: "도착지를 검색해 주세요" ) + private lazy var fieldCard = DSGroupedCard(rows: [departureRow, arrivalRow]) // 최근 경로 원탭 칩(Phase 18) — 자기 크기 컴포넌트라 스택 전폭으로 늘리지 않고 // 래퍼의 leading에 붙인다. 표시·활성은 render가 State로 반영한다. private let recentRouteChip = DSChip() @@ -37,10 +47,29 @@ final class HomeViewController: UIViewController { } return row }() + // 칩 위에 섹션 헤더를 얹는다 — 표시·숨김은 헤더와 칩이 한 몸으로 움직인다. + private lazy var recentSection: UIStackView = { + let header = UILabel() + header.font = DSTypography.label2.font + header.textColor = DSColor.Text.secondary + header.text = "최근 경로" + let stack = UIStackView(arrangedSubviews: [header, chipRow]) + stack.axis = .vertical + stack.spacing = DSSpacing.sm + return stack + }() private let routeCard = DSRouteCard() private let registerButton = DSButton(title: "알람 등록하기") // DSButton은 title이 init 고정이라 토글은 버튼 2개의 표시 전환으로 구현한다. private let cancelButton = DSButton(title: "알람 해제하기", style: .secondary) + // 하단 고정 CTA — 스택이라 두 버튼 모두 숨김이면 높이 0으로 접힌다. + private let ctaStack: UIStackView = { + let stack = UIStackView() + stack.axis = .vertical + return stack + }() + // 토스트가 하단 CTA를 덮지 않도록 CTA 위에 얹는 통과형 호스트. + private let toastHost = PassthroughView() private let contentStack: UIStackView = { let stack = UIStackView() @@ -57,6 +86,9 @@ final class HomeViewController: UIViewController { }() private let refreshControl = UIRefreshControl() + // 진입 페이드는 최초 1회만 — 검색 왕복마다 재생하면 홈이 깜빡인다. + private var hasPlayedEntry = false + init(viewModel: HomeViewModel) { self.viewModel = viewModel super.init(nibName: nil, bundle: nil) @@ -93,6 +125,49 @@ final class HomeViewController: UIViewController { navigationController?.setNavigationBarHidden(true, animated: animated) // 검색 화면을 다녀오며 바뀐 최근 검색(삭제 포함)을 칩에 반영한다(Phase 18). viewModel.viewWillAppear() + prepareEntryTransitionIfNeeded() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + playEntryTransitionIfNeeded() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + // 하단 고정 CTA에 콘텐츠 끝이 가리지 않게 바닥 인셋을 맞춘다(숨김이면 0). + // 동일 값 재대입 가드 — viewDidLayoutSubviews 재귀 레이아웃 루프 방지. + let ctaHeight = ctaStack.bounds.height + let bottomInset = ctaHeight > 0 ? ctaHeight + DSSpacing.md : 0 + if scrollView.contentInset.bottom != bottomInset { + scrollView.contentInset.bottom = bottomInset + scrollView.verticalScrollIndicatorInsets.bottom = bottomInset + } + } + + // MARK: - 진입 페이드 (최초 1회) + + private func prepareEntryTransitionIfNeeded() { + guard !hasPlayedEntry else { return } + let translation = UIAccessibility.isReduceMotionEnabled + ? CGAffineTransform.identity + : CGAffineTransform(translationX: 0, y: 8) + [contentStack, ctaStack].forEach { + $0.alpha = 0 + $0.transform = translation + } + } + + private func playEntryTransitionIfNeeded() { + guard !hasPlayedEntry else { return } + hasPlayedEntry = true + UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseOut) { [weak self] in + guard let self else { return } + [contentStack, ctaStack].forEach { + $0.alpha = 1 + $0.transform = .identity + } + } } // MARK: - UI @@ -120,17 +195,31 @@ final class HomeViewController: UIViewController { make.width.equalTo(scrollView.frameLayoutGuide).offset(-DSSpacing.md * 2) } - [titleLabel, banner, departureRow, arrivalRow, chipRow, routeCard, registerButton, cancelButton] + [brandLabel, banner, bigTitleLabel, fieldCard, recentSection, routeCard] .forEach(contentStack.addArrangedSubview) contentStack.addArrangedSubview(makeCaptionStack()) - contentStack.setCustomSpacing(DSSpacing.lg20, after: titleLabel) - contentStack.setCustomSpacing(DSSpacing.sm, after: departureRow) - // 칩이 숨겨져도 필드→카드 간격이 기존(lg)과 같도록 앞뒤 모두 lg를 쓴다. - contentStack.setCustomSpacing(DSSpacing.lg, after: arrivalRow) - contentStack.setCustomSpacing(DSSpacing.lg, after: chipRow) + contentStack.setCustomSpacing(DSSpacing.lg20, after: bigTitleLabel) + // 칩 섹션이 숨겨져도 카드→경로 카드 간격이 같도록 앞뒤 모두 lg를 쓴다. + contentStack.setCustomSpacing(DSSpacing.lg, after: fieldCard) + contentStack.setCustomSpacing(DSSpacing.lg, after: recentSection) + contentStack.setCustomSpacing(DSSpacing.lg, after: routeCard) + + // 알람 CTA는 스크롤 밖 하단 고정(토스식) — 표시 토글은 render가 그대로 수행한다. + [registerButton, cancelButton].forEach(ctaStack.addArrangedSubview) + view.addSubview(ctaStack) + ctaStack.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(DSSpacing.md) + make.bottom.equalTo(view.safeAreaLayoutGuide).inset(DSSpacing.sm) + } + + view.addSubview(toastHost) + toastHost.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.bottom.equalTo(ctaStack.snp.top) + } banner.isHidden = true - chipRow.isHidden = true + recentSection.isHidden = true routeCard.isHidden = true registerButton.isHidden = true cancelButton.isHidden = true @@ -147,35 +236,18 @@ final class HomeViewController: UIViewController { UIAction { [weak self] _ in self?.viewModel.cancelAlarmTapped() }, for: .touchUpInside ) - } - - /// 홈의 필드는 편집이 아니라 검색 진입 트리거다. DSTextField에는 편집 시작 훅이 - /// 없으므로 필드 터치를 통째로 죽이고 UIControl 래퍼가 탭을 가져간다. - /// 탭한 필드가 검색 진입 슬롯이 된다(Phase 17) — entry가 그대로 넘어간다. - private func makeFieldRow( - icon: UIImage, field: DSTextField, entry: SearchEntryField - ) -> UIControl { - let row = UIControl() - let iconView = UIImageView(image: icon) - iconView.tintColor = DSColor.Icon.default - iconView.contentMode = .scaleAspectFit - field.isUserInteractionEnabled = false - - [iconView, field].forEach(row.addSubview) - iconView.snp.makeConstraints { make in - make.leading.equalToSuperview() - make.centerY.equalToSuperview() - make.size.equalTo(DSIconSize.lg) - } - field.snp.makeConstraints { make in - make.leading.equalTo(iconView.snp.trailing).offset(DSSpacing.sm) - make.top.trailing.bottom.equalToSuperview() - } - row.addAction( - UIAction { [weak self] _ in self?.viewModel.searchFieldTapped(entry) }, + // 행 전체 탭 → 검색 진입. 탭한 필드가 검색 진입 슬롯이 된다(Phase 17). + departureRow.addAction( + UIAction { [weak self] _ in self?.viewModel.searchFieldTapped(.departure) }, for: .touchUpInside ) - return row + arrivalRow.addAction( + UIAction { [weak self] _ in self?.viewModel.searchFieldTapped(.arrival) }, + for: .touchUpInside + ) + + [registerButton, cancelButton, recentRouteChip, departureRow, arrivalRow] + .forEach { DSPressEffect.apply(to: $0) } } private func makeCaptionStack() -> UIStackView { @@ -231,23 +303,23 @@ final class HomeViewController: UIViewController { private func render(_ state: HomeViewModel.State) { switch state.departure { case .loading: - departureField.setText("현재 위치 확인 중...") + departureRow.setText("현재 위치 확인 중...") case let .current(name): - departureField.setText(name) + departureRow.setText(name) case .needsSearch: // 빈 값이면 placeholder("출발지를 검색해 주세요")가 유도 문구 역할을 한다. - departureField.setText("") + departureRow.setText("") } // 도착지 필드 = 선택 경로의 도착지명(Phase 17). nil이면 placeholder가 유도한다. - arrivalField.setText(state.arrivalText ?? "") + arrivalRow.setText(state.arrivalText ?? "") // 최근 경로 원탭 칩(Phase 18) — nil이면 숨김, 재검색 진행 중엔 비활성(더블 탭 방지). if let chipText = state.recentRouteChipText { recentRouteChip.setText(chipText) - chipRow.isHidden = false + recentSection.isHidden = false } else { - chipRow.isHidden = true + recentSection.isHidden = true } recentRouteChip.isEnabled = !state.isChipBusy @@ -289,7 +361,7 @@ final class HomeViewController: UIViewController { case .locationPermissionNeeded: DSToast.show( "위치 권한이 꺼져 있어요", - in: view, + in: toastHost, action: .init(title: "설정으로 이동") { guard let url = URL(string: UIApplication.openSettingsURLString) else { return } UIApplication.shared.open(url) @@ -298,7 +370,7 @@ final class HomeViewController: UIViewController { case .locationServicesDisabled: DSToast.show( "기기의 위치 서비스가 꺼져 있어요", - in: view, + in: toastHost, action: .init(title: "설정으로 이동") { guard let url = URL(string: UIApplication.openSettingsURLString) else { return } UIApplication.shared.open(url) @@ -306,50 +378,50 @@ final class HomeViewController: UIViewController { ) case .locationRestricted: // restricted는 설정으로 못 푸는 제약 — "설정으로 이동"을 안내하지 않는다(Phase 17). - DSToast.show("이 기기에선 위치를 사용할 수 없어요. 출발지를 검색해 주세요", in: view) + DSToast.show("이 기기에선 위치를 사용할 수 없어요. 출발지를 검색해 주세요", in: toastHost) case .chipLocationUnavailable: - DSToast.show("현재 위치를 확인하지 못했어요. 잠시 후 다시 시도해 주세요", in: view) + DSToast.show("현재 위치를 확인하지 못했어요. 잠시 후 다시 시도해 주세요", in: toastHost) case .chipSearchFailed: - DSToast.show("막차를 찾지 못했어요. 다시 시도해 주세요", in: view) + DSToast.show("막차를 찾지 못했어요. 다시 시도해 주세요", in: toastHost) case .chipServiceEnded: // 검색 화면 빈 상태 제목과 같은 어휘(Phase 18) — 화면 간 표기 통일. - DSToast.show("오늘 막차가 끊겼어요", in: view) + DSToast.show("오늘 막차가 끊겼어요", in: toastHost) case .chipNoRoute: - DSToast.show("대중교통 경로를 찾지 못했어요", in: view) + DSToast.show("대중교통 경로를 찾지 못했어요", in: toastHost) case .alarmPermissionNeeded: DSToast.show( "알람 권한이 꺼져 있어요", - in: view, + in: toastHost, action: .init(title: "설정으로 이동") { guard let url = URL(string: UIApplication.openSettingsURLString) else { return } UIApplication.shared.open(url) } ) case .alarmRegisterFailed: - DSToast.show("알람 등록에 실패했어요. 다시 시도해 주세요.", in: view) + DSToast.show("알람 등록에 실패했어요. 다시 시도해 주세요.", in: toastHost) case .alarmTooLate: - DSToast.show("이미 출발 시간이 지난 경로예요", in: view) + DSToast.show("이미 출발 시간이 지난 경로예요", in: toastHost) case .notificationPermissionDenied: DSToast.show( "막차 변경 알림을 받으려면 설정에서 알림을 허용해주세요", - in: view, + in: toastHost, action: .init(title: "설정으로 이동") { guard let url = URL(string: UIApplication.openSettingsURLString) else { return } UIApplication.shared.open(url) } ) case .alarmCancelFailed: - DSToast.show("알람 해제에 실패했어요. 다시 시도해 주세요.", in: view) + DSToast.show("알람 해제에 실패했어요. 다시 시도해 주세요.", in: toastHost) case let .lastTrainAdvanced(minutes): // 배너 강조는 이 원샷 이벤트에 부수하는 시각 효과 — 별도 채널을 만들지 않는다. // 배너의 시각·긴급도 값 자체는 info 스트림이 State로 이미 갱신한다. - DSToast.show("막차가 \(minutes)분 당겨졌어요", in: view) + DSToast.show("막차가 \(minutes)분 당겨졌어요", in: toastHost) banner.emphasize() case .lastTrainMissed: // TODO: [미확정 #9] 대안 제시(심야버스 등) 데이터 소스 확정 전까지 실패 문구만 표출한다. - DSToast.show("막차가 지나갔어요", in: view) + DSToast.show("막차가 지나갔어요", in: toastHost) case .lastTrainServiceEnded: - DSToast.show("오늘 운행이 끝나 알람을 정리했어요", in: view) + DSToast.show("오늘 운행이 끝나 알람을 정리했어요", in: toastHost) } } } diff --git a/Projects/Feature/Home/Sources/PassthroughView.swift b/Projects/Feature/Home/Sources/PassthroughView.swift new file mode 100644 index 00000000..71c0ad4c --- /dev/null +++ b/Projects/Feature/Home/Sources/PassthroughView.swift @@ -0,0 +1,10 @@ +import UIKit + +/// 토스트 전용 오버레이 호스트 — 서브뷰(토스트)에 명중할 때만 터치를 소비하고 +/// 그 외 영역은 아래 계층(스크롤·하단 CTA)으로 통과시킨다. +final class PassthroughView: UIView { + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let view = super.hitTest(point, with: event) + return view === self ? nil : view + } +} diff --git a/Projects/Feature/Home/Tests/HomeFieldRowTests.swift b/Projects/Feature/Home/Tests/HomeFieldRowTests.swift new file mode 100644 index 00000000..8a2b397d --- /dev/null +++ b/Projects/Feature/Home/Tests/HomeFieldRowTests.swift @@ -0,0 +1,60 @@ +import DesignSystem +@testable import HomeFeature +import Testing +import UIKit + +@MainActor +struct HomeFieldRowTests { + @Test + func emptyTextShowsPlaceholderInSecondary() { + let row = HomeFieldRow(icon: UIImage(), placeholder: "출발지를 검색해 주세요") + let label = row.subviews.compactMap { $0 as? UILabel }.first + #expect(label?.text == "출발지를 검색해 주세요") + #expect(label.map { colorsMatch($0.textColor, DSColor.Text.secondary) } == true) + #expect(row.accessibilityValue == nil) + } + + @Test + func valueShowsInPrimaryAndClearsBackToPlaceholder() { + let row = HomeFieldRow(icon: UIImage(), placeholder: "출발지를 검색해 주세요") + row.setText("강남역") + let label = row.subviews.compactMap { $0 as? UILabel }.first + #expect(label?.text == "강남역") + #expect(label.map { colorsMatch($0.textColor, DSColor.Text.primary) } == true) + #expect(row.accessibilityValue == "강남역") + + row.setText("") + #expect(label?.text == "출발지를 검색해 주세요") + #expect(label.map { colorsMatch($0.textColor, DSColor.Text.secondary) } == true) + } + + @Test + func rowHeightMatchesCardRowScale() { + let row = HomeFieldRow(icon: UIImage(), placeholder: "출발지") + let height = row.constraints.first { $0.firstAttribute == .height } + #expect(height?.constant == 56) + } + + @Test + func rowIsAccessibleButton() { + let row = HomeFieldRow(icon: UIImage(), placeholder: "도착지를 검색해 주세요") + #expect(row.isAccessibilityElement) + #expect(row.accessibilityTraits.contains(.button)) + #expect(row.accessibilityLabel == "도착지를 검색해 주세요") + } +} + +/// 토큰 색은 애셋 기반이라 인스턴스 비교가 불안정하다 — 다크 트레잇으로 해석해 +/// RGBA를 비교한다(DesignSystem 테스트의 colorsMatch와 같은 방식). +@MainActor +private func colorsMatch(_ lhs: UIColor, _ rhs: UIColor) -> Bool { + let trait = UITraitCollection(userInterfaceStyle: .dark) + var l: (red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat) = (0, 0, 0, 0) + var r: (red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat) = (0, 0, 0, 0) + lhs.resolvedColor(with: trait).getRed(&l.red, green: &l.green, blue: &l.blue, alpha: &l.alpha) + rhs.resolvedColor(with: trait).getRed(&r.red, green: &r.green, blue: &r.blue, alpha: &r.alpha) + return abs(l.red - r.red) < 0.001 + && abs(l.green - r.green) < 0.001 + && abs(l.blue - r.blue) < 0.001 + && abs(l.alpha - r.alpha) < 0.001 +} diff --git a/Projects/Feature/Search/Sources/SearchViewController.swift b/Projects/Feature/Search/Sources/SearchViewController.swift index 9e218ad1..8f7468a1 100644 --- a/Projects/Feature/Search/Sources/SearchViewController.swift +++ b/Projects/Feature/Search/Sources/SearchViewController.swift @@ -16,8 +16,9 @@ final class SearchViewController: UIViewController { private let navigationBar = DSNavigationBar(style: .backOnly) private let departureField = DSTextField(placeholder: "출발지 입력") private let arrivalField = DSTextField(placeholder: "도착지 입력", showsAccentDot: true) - private let fieldsStack = UIStackView() - private let separator = DSSeparator(axis: .horizontal) + // 홈의 필드 카드와 같은 그룹 카드 — DSTextField bg가 카드와 같은 surface라 + // 시각적으로 융합되고, 포커스 라임 보더가 카드 안에서 그대로 동작한다. + private lazy var fieldCard = DSGroupedCard(rows: [departureField, arrivalField]) private let tableView = UITableView(frame: .zero, style: .plain) private let emptyState = DSEmptyState(content: .init(title: "")) private let activityIndicator = UIActivityIndicatorView(style: .medium) @@ -26,6 +27,9 @@ final class SearchViewController: UIViewController { private var showsRecentHeader = false private var focusedField: SearchViewModel.Field? + // 진입 페이드는 최초 1회만 — 홈과 같은 규약. + private var hasPlayedEntry = false + init(viewModel: SearchViewModel) { self.viewModel = viewModel super.init(nibName: nil, bundle: nil) @@ -47,6 +51,7 @@ final class SearchViewController: UIViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) navigationController?.setNavigationBarHidden(true, animated: animated) + prepareEntryTransitionIfNeeded() } // 숨긴 내비바는 스와이프 백 제스처를 죽인다 — delegate를 잡아 되살린다(Phase 17). @@ -54,6 +59,32 @@ final class SearchViewController: UIViewController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) navigationController?.interactivePopGestureRecognizer?.delegate = self + playEntryTransitionIfNeeded() + } + + // MARK: - 진입 페이드 (최초 1회) + + private func prepareEntryTransitionIfNeeded() { + guard !hasPlayedEntry else { return } + let translation = UIAccessibility.isReduceMotionEnabled + ? CGAffineTransform.identity + : CGAffineTransform(translationX: 0, y: 8) + [fieldCard, tableView].forEach { + $0.alpha = 0 + $0.transform = translation + } + } + + private func playEntryTransitionIfNeeded() { + guard !hasPlayedEntry else { return } + hasPlayedEntry = true + UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseOut) { [weak self] in + guard let self else { return } + [fieldCard, tableView].forEach { + $0.alpha = 1 + $0.transform = .identity + } + } } override func viewWillDisappear(_ animated: Bool) { @@ -64,10 +95,6 @@ final class SearchViewController: UIViewController { private func configureUI() { view.backgroundColor = DSColor.Background.base - fieldsStack.axis = .vertical - fieldsStack.spacing = DSSpacing.sm - [departureField, arrivalField].forEach(fieldsStack.addArrangedSubview) - tableView.backgroundColor = .clear tableView.separatorStyle = .none tableView.keyboardDismissMode = .onDrag @@ -83,23 +110,19 @@ final class SearchViewController: UIViewController { emptyState.isHidden = true - [navigationBar, fieldsStack, separator, tableView, emptyState, activityIndicator] + [navigationBar, fieldCard, tableView, emptyState, activityIndicator] .forEach(view.addSubview) navigationBar.snp.makeConstraints { make in make.top.equalTo(view.safeAreaLayoutGuide) make.leading.trailing.equalToSuperview() } - fieldsStack.snp.makeConstraints { make in + fieldCard.snp.makeConstraints { make in make.top.equalTo(navigationBar.snp.bottom).offset(DSSpacing.xs) make.leading.trailing.equalToSuperview().inset(DSSpacing.md) } - separator.snp.makeConstraints { make in - make.top.equalTo(fieldsStack.snp.bottom).offset(DSSpacing.sm12) - make.leading.trailing.equalToSuperview() - } tableView.snp.makeConstraints { make in - make.top.equalTo(separator.snp.bottom) + make.top.equalTo(fieldCard.snp.bottom).offset(DSSpacing.sm12) make.leading.trailing.bottom.equalToSuperview() } // 빈 상태·스피너는 키보드 위 가시 영역의 중앙에 둔다(Phase 17) — 타이핑 중 뜨는 @@ -108,7 +131,9 @@ final class SearchViewController: UIViewController { let aboveKeyboardArea = UILayoutGuide() view.addLayoutGuide(aboveKeyboardArea) NSLayoutConstraint.activate([ - aboveKeyboardArea.topAnchor.constraint(equalTo: separator.bottomAnchor), + aboveKeyboardArea.topAnchor.constraint( + equalTo: fieldCard.bottomAnchor, constant: DSSpacing.sm12 + ), aboveKeyboardArea.leadingAnchor.constraint(equalTo: view.leadingAnchor), aboveKeyboardArea.trailingAnchor.constraint(equalTo: view.trailingAnchor), aboveKeyboardArea.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor), @@ -403,6 +428,12 @@ private final class SearchRouteCardCell: UITableViewCell { } } + // selectionStyle .none이라도 하이라이트 콜백은 온다 — 카드에 프레스 스케일만 준다. + override func setHighlighted(_ highlighted: Bool, animated: Bool) { + super.setHighlighted(highlighted, animated: animated) + DSPressEffect.setPressed(highlighted, on: card) + } + func configure(with content: DSRouteCard.Content) { card.configure(with: content) } diff --git a/Workspace.swift b/Workspace.swift index d2bc2dee..9ca45f99 100644 --- a/Workspace.swift +++ b/Workspace.swift @@ -4,6 +4,7 @@ let workspace = Workspace( name: "Atcha", projects: [ "Projects/App", + "Projects/Feature/Auth", "Projects/Feature/Home", "Projects/Feature/Search", "Projects/Domain", diff --git a/docs/planning/atcha-v2-post12-roadmap.md b/docs/planning/atcha-v2-post12-roadmap.md index 969170d6..94bc76a2 100644 --- a/docs/planning/atcha-v2-post12-roadmap.md +++ b/docs/planning/atcha-v2-post12-roadmap.md @@ -32,7 +32,7 @@ Phase 1~12로 "검색 → 알람 등록 → 변경 인지"의 전반부는 완 | # | 항목 | 클라 측 잔여 작업 | |---|---|---| -| **S1** | **익명 인증 발급 엔드포인트 (#2)** — 전 기능의 전제. 현재 `UnconfiguredAnonymousSessionIssuer` 스텁이 항상 throw하고 `bootstrap()`이 non-fatal로 삼켜 **토큰 없이 홈 진입 → 알람 서버 기능 전부 실서버에서 불가** | 구현체 1개 작성 + `AppDIContainer` 주입 교체 + `DevDemoFallbacks` 제거 (제거 조건이 파일에 명시돼 있음) | +| **S1** | ~~익명 인증 발급 엔드포인트 (#2)~~ — **소셜 로그인으로 대체 완료(2026-09)**: 익명 발급 개념을 접고 카카오·애플 로그인(`GET /auth/check`·`/auth/login`, 레거시 실측 계약)이 최초 토큰 소스가 됐다. 강제 로그인(게스트 없음), refresh 확정 거절 시 `loginRequired` 표면화 | 잔여: 카카오 콘솔에 `com.atcha.iOS.v2` 등록 + `TUIST_KAKAO_APP_KEY` 주입, Apple 콘솔 capability, 서버의 V2 번들 audience 허용 확인, `DevDemoFallbacks` 제거(실서버 검증 후) | | **S2** | **`com.atcha.iOS.v2`용 GoogleService-Info.plist 재발급 (#6·#10)** — 현재 커밋된 plist는 **레거시 번들 ID(`com.atcha.iOS`)**라 존재 가드만 무력화된 채 사일런트 푸시가 성립하지 않는다. **레거시 plist는 제거 대상.** + **FCM 토큰 전달 API (#5)** — 현재 로깅만 | plist 교체, 토큰 전달 호출 1개 (`AppDelegate` TODO 지점) | | **S3** | **responseCode 실측 (#3)** — `normalizedResult(code:)`가 빈 매핑이라 `.noRoute`가 도달 불가 상태 (빈 목록 = 무조건 `serviceEnded`). 레거시 단서: `URT_001, LRT_001, LRT_003, REQ_004` (의미 미확인). + **"등록된 알람 없음" 표현 (#11, 신규)** — 미정이면 서버에서 알람이 사라져도 홈의 배너·해제 버튼이 정리되지 않는 유령 알람 잔존 | 매핑 테이블 채우기, `refresh()`가 "없음"을 에러가 아닌 상태로 반환하게 수정 + 홈 정리 이벤트 | | **S4** | 알람 기준 시각 서버 필드 (#7 — Phase 14의 클라 임시안으로 급함 하락), 단일 알람 규약 (#4), Stage 전용 호스트 (#1 잔여), **"다음 운행 안내" 필드 (#12, 신규)** — 기획서가 "막차 종료 시 다음 운행 안내"를 약속했으나 어떤 DTO에도 필드가 없음, 대안(심야버스) 데이터 (#9) | 각 TODO 지점에 반영 | @@ -58,7 +58,7 @@ Phase 1~12로 "검색 → 알람 등록 → 변경 인지"의 전반부는 완 | # | 항목 | 상태 (2026-08-23) | |---|---|---| | 1 | 실서버 base URL | **부분 해결** — dev/live 실주소 반영(`AppEnvironment.swift`, 레거시 trust-evaluator에서 복원·사용자 승인). Stage는 dev 호스트 공유 — 전용 호스트만 미정 | -| 2 | 익명 인증 발급 엔드포인트 | **미해결 — 최치명 (S1)** | +| 2 | 익명 인증 발급 엔드포인트 | **대체 해결 (2026-09)** — 소셜 로그인(카카오·애플)이 최초 토큰 소스. 익명 발급 스펙 질문은 철회 | | 3 | responseCode 실측 | **미해결 (S3)** — `.noRoute` 도달 불가 상태 | | 4 | 단일 알람 규약 | 미해결 — 클라 refresh→cancel→register 우회 동작 중 | | 5 | FCM 토큰 전달 | **미해결 (S2)** — 로깅만 | diff --git a/docs/verification/la-redesign/la-01-lock-active.png b/docs/verification/la-redesign/la-01-lock-active.png new file mode 100644 index 00000000..93ba1e7b Binary files /dev/null and b/docs/verification/la-redesign/la-01-lock-active.png differ diff --git a/docs/verification/la-redesign/la-02-lock-badge.png b/docs/verification/la-redesign/la-02-lock-badge.png new file mode 100644 index 00000000..cc8e0e24 Binary files /dev/null and b/docs/verification/la-redesign/la-02-lock-badge.png differ diff --git a/docs/verification/la-redesign/la-03-lock-serviceend.png b/docs/verification/la-redesign/la-03-lock-serviceend.png new file mode 100644 index 00000000..80c12d06 Binary files /dev/null and b/docs/verification/la-redesign/la-03-lock-serviceend.png differ diff --git a/docs/verification/la-redesign/la-04-di-compact.png b/docs/verification/la-redesign/la-04-di-compact.png new file mode 100644 index 00000000..e37fc867 Binary files /dev/null and b/docs/verification/la-redesign/la-04-di-compact.png differ diff --git a/docs/verification/ui-redesign-toss/t01-home-initial.png b/docs/verification/ui-redesign-toss/t01-home-initial.png new file mode 100644 index 00000000..d3ccd7eb Binary files /dev/null and b/docs/verification/ui-redesign-toss/t01-home-initial.png differ diff --git a/docs/verification/ui-redesign-toss/t03-search-entry.png b/docs/verification/ui-redesign-toss/t03-search-entry.png new file mode 100644 index 00000000..743e6ceb Binary files /dev/null and b/docs/verification/ui-redesign-toss/t03-search-entry.png differ diff --git a/docs/verification/ui-redesign-toss/t04-search-empty-above-keyboard.png b/docs/verification/ui-redesign-toss/t04-search-empty-above-keyboard.png new file mode 100644 index 00000000..da5773bc Binary files /dev/null and b/docs/verification/ui-redesign-toss/t04-search-empty-above-keyboard.png differ diff --git a/docs/verification/ui-redesign-toss/t05a-search-routes.png b/docs/verification/ui-redesign-toss/t05a-search-routes.png new file mode 100644 index 00000000..a614b1d4 Binary files /dev/null and b/docs/verification/ui-redesign-toss/t05a-search-routes.png differ diff --git a/docs/verification/ui-redesign-toss/t05b-home-routecard-cta.png b/docs/verification/ui-redesign-toss/t05b-home-routecard-cta.png new file mode 100644 index 00000000..321c67ef Binary files /dev/null and b/docs/verification/ui-redesign-toss/t05b-home-routecard-cta.png differ diff --git a/docs/verification/ui-redesign-toss/t06a-home-banner-cancel-cta.png b/docs/verification/ui-redesign-toss/t06a-home-banner-cancel-cta.png new file mode 100644 index 00000000..8a27baf4 Binary files /dev/null and b/docs/verification/ui-redesign-toss/t06a-home-banner-cancel-cta.png differ diff --git a/docs/verification/ui-redesign-toss/t08b-toast-above-cta.png b/docs/verification/ui-redesign-toss/t08b-toast-above-cta.png new file mode 100644 index 00000000..1082616b Binary files /dev/null and b/docs/verification/ui-redesign-toss/t08b-toast-above-cta.png differ