Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
13 commits
Select commit Hold shift + click to select a range
0a5501c
feat: AtchaV2 UI 고도화 — 토스 스타일 홈·검색 (화면단 전용)
YuGeonHui Aug 24, 2026
e565a86
feat: AtchaV2 라이브 액티비티 고도화 — 타이머 히어로 잠금화면 + DI 전면 Pretendard (화면단 전용)
YuGeonHui Aug 24, 2026
a7a9361
refactor: CoreAuth 익명 세션 제거 — 소셜 로그인 전제의 세션 수명 재설계
YuGeonHui Sep 13, 2026
7cd1afd
feat: Domain 소셜 로그인 수직 슬라이스 — 엔티티·포트·SignInUseCase
YuGeonHui Sep 13, 2026
106df6c
feat: AtchaData 로그인 API — AuthEndpoint·DTO·AuthRepositoryImpl
YuGeonHui Sep 13, 2026
15b3efb
feat: AuthFeature 모듈 신설 — 레거시 로그인 바텀시트 V2 이식
YuGeonHui Sep 13, 2026
693f9b8
feat: App 통합 — 강제 로그인 플로우·소셜 SDK 어댑터·카카오 설정 주입
YuGeonHui Sep 13, 2026
ebf4f87
docs: 인증 항목 정합 — 익명 세션(S1/#2)을 소셜 로그인 대체 완료로 갱신
YuGeonHui Sep 13, 2026
9352272
feat: Domain 계정 수직 슬라이스 — 자동 최소 가입·로그아웃/탈퇴/유저정보 포트
YuGeonHui Sep 13, 2026
87202de
feat: CoreAuth 로그아웃 — refresh Bearer 특례의 LogoutEndpoint·signOut/inval…
YuGeonHui Sep 13, 2026
1d9c59c
feat: AtchaData 계정 API — sign-up·UserEndpoint·DTO·UserRepositoryImpl
YuGeonHui Sep 13, 2026
ebe4851
feat: AuthFeature 로그인 흐름 정리 — needsSignUp 분기·DEV 건너뛰기 제거
YuGeonHui Sep 13, 2026
902c1de
feat: App 통합 — 계정 스택 배선·DevDemoFallbacks 제거·변경 시뮬레이터 축소 존치
YuGeonHui Sep 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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에서 구축 완료.
Expand Down
70 changes: 48 additions & 22 deletions Projects/App/Project.swift
Original file line number Diff line number Diff line change
@@ -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 없음").
Expand All @@ -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"),
Expand All @@ -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
Expand Down
17 changes: 17 additions & 0 deletions Projects/App/Sources/Adapters/AuthSessionEndingAdapter.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
14 changes: 14 additions & 0 deletions Projects/App/Sources/Adapters/AuthSessionStoreAdapter.swift
Original file line number Diff line number Diff line change
@@ -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)
)
}
}
126 changes: 126 additions & 0 deletions Projects/App/Sources/Adapters/DevChangeSimulator.swift
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading