From 862ae49de3d6b20e4312edf088d8df948d3dfa5f Mon Sep 17 00:00:00 2001 From: AppGoni Date: Thu, 24 Sep 2026 12:01:04 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20Domain=20=EA=B3=84=EC=A0=95=C2=B7?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=ED=8F=AC=ED=8A=B8=20=E2=80=94=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EC=A2=85=EB=A3=8C=20=EC=95=8C=EB=9E=8C=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=C2=B7=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=A7=80=EC=97=AD?= =?UTF-8?q?=C2=B7=EC=95=B1=20=EB=B2=84=EC=A0=84=C2=B7FCM=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EB=8F=99=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AlarmSessionTeardown 포트 + LogoutUseCase가 세션 종료 전 서버 알람 취소 선행 - UpdateHomeAddressUseCase: 서비스 지역 확인 통과 후에만 PATCH (outOfServiceRegion) - CheckAppUpdateUseCase: 점 구분 숫자 비교, 권장 업데이트만 판정 - SyncPushTokenUseCase: 프로세스 내 중복 전송 방지 + 로그아웃 시 reset Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Interfaces/AlarmSessionTeardown.swift | 8 +++ .../Interfaces/AppVersionRepository.swift | 5 ++ .../Sources/Interfaces/PlaceRepository.swift | 2 + .../Interfaces/PushTokenRepository.swift | 5 ++ .../UseCases/CheckAppUpdateUseCase.swift | 48 +++++++++++++ .../Sources/UseCases/LogoutUseCase.swift | 7 +- .../UseCases/SyncPushTokenUseCase.swift | 33 +++++++++ .../UseCases/UpdateHomeAddressUseCase.swift | 17 ++++- .../DefaultCheckAppUpdateUseCaseTests.swift | 33 +++++++++ .../Tests/DefaultLogoutUseCaseTests.swift | 17 +++++ .../DefaultReverseGeocodeUseCaseTests.swift | 2 + .../DefaultSyncPushTokenUseCaseTests.swift | 58 ++++++++++++++++ ...DefaultUpdateHomeAddressUseCaseTests.swift | 67 +++++++++++++++++++ 13 files changed, 298 insertions(+), 4 deletions(-) create mode 100644 Projects/Domain/Sources/Interfaces/AlarmSessionTeardown.swift create mode 100644 Projects/Domain/Sources/Interfaces/AppVersionRepository.swift create mode 100644 Projects/Domain/Sources/Interfaces/PushTokenRepository.swift create mode 100644 Projects/Domain/Sources/UseCases/CheckAppUpdateUseCase.swift create mode 100644 Projects/Domain/Sources/UseCases/SyncPushTokenUseCase.swift create mode 100644 Projects/Domain/Tests/DefaultCheckAppUpdateUseCaseTests.swift create mode 100644 Projects/Domain/Tests/DefaultSyncPushTokenUseCaseTests.swift create mode 100644 Projects/Domain/Tests/DefaultUpdateHomeAddressUseCaseTests.swift diff --git a/Projects/Domain/Sources/Interfaces/AlarmSessionTeardown.swift b/Projects/Domain/Sources/Interfaces/AlarmSessionTeardown.swift new file mode 100644 index 00000000..19b9ea8c --- /dev/null +++ b/Projects/Domain/Sources/Interfaces/AlarmSessionTeardown.swift @@ -0,0 +1,8 @@ +/// 계정 세션 종료(로그아웃·탈퇴·강제 만료) 시 알람 세션 정리 포트 — 구현은 App 어댑터. +/// 레거시는 로그아웃 후에도 알람·노티가 살아 있어 로그인 화면에서 알람이 울렸다(이식 금지 버그). +/// 멱등이어야 한다 — 로그아웃 경로와 만료 라우팅 경로가 연달아 부를 수 있다. +public protocol AlarmSessionTeardown: Sendable { + /// 로컬 알람·LA·스냅샷·동기화 상태를 비운다. cancelOnServer=true면 서버 알람 취소를 + /// 베스트 에포트로 먼저 시도한다 — 토큰이 아직 살아 있는 로그아웃 경로에서만 의미가 있다. + func tearDown(cancelOnServer: Bool) async +} diff --git a/Projects/Domain/Sources/Interfaces/AppVersionRepository.swift b/Projects/Domain/Sources/Interfaces/AppVersionRepository.swift new file mode 100644 index 00000000..4393485b --- /dev/null +++ b/Projects/Domain/Sources/Interfaces/AppVersionRepository.swift @@ -0,0 +1,5 @@ +/// 서버가 아는 최신 앱 버전 — 구현은 AtchaData(`GET /app/version`). +public protocol AppVersionRepository: Sendable { + /// 최신 배포 버전 문자열(예: "2.0.1"). + func latestVersion() async throws -> String +} diff --git a/Projects/Domain/Sources/Interfaces/PlaceRepository.swift b/Projects/Domain/Sources/Interfaces/PlaceRepository.swift index 45989a67..4ccac63a 100644 --- a/Projects/Domain/Sources/Interfaces/PlaceRepository.swift +++ b/Projects/Domain/Sources/Interfaces/PlaceRepository.swift @@ -1,4 +1,6 @@ public protocol PlaceRepository: Sendable { func searchPlaces(keyword: String, near coordinate: Coordinate?) async throws -> [Place] func reverseGeocode(_ coordinate: Coordinate) async throws -> Place + /// GET /locations/is-service-region — 서비스 지역(서울·경기·인천) 여부. + func isServiceRegion(_ coordinate: Coordinate) async throws -> Bool } diff --git a/Projects/Domain/Sources/Interfaces/PushTokenRepository.swift b/Projects/Domain/Sources/Interfaces/PushTokenRepository.swift new file mode 100644 index 00000000..e96e11e7 --- /dev/null +++ b/Projects/Domain/Sources/Interfaces/PushTokenRepository.swift @@ -0,0 +1,5 @@ +/// FCM 토큰 갱신의 서버 전달 — 로그인/가입 파라미터 외의 채널(미확정 #5). +/// 서버 경로가 확정되기 전까지 App은 no-op 구현을 주입한다. +public protocol PushTokenRepository: Sendable { + func register(token: String) async throws +} diff --git a/Projects/Domain/Sources/UseCases/CheckAppUpdateUseCase.swift b/Projects/Domain/Sources/UseCases/CheckAppUpdateUseCase.swift new file mode 100644 index 00000000..29cbd852 --- /dev/null +++ b/Projects/Domain/Sources/UseCases/CheckAppUpdateUseCase.swift @@ -0,0 +1,48 @@ +import Foundation + +public enum AppUpdateStatus: Sendable, Equatable { + case upToDate + /// 스토어에 더 새 버전이 있다 — 권장 팝업("업데이트 / 나중에"). + case recommended(latest: String) +} + +public protocol CheckAppUpdateUseCase: Sendable { + func execute(currentVersion: String) async throws -> AppUpdateStatus +} + +public struct DefaultCheckAppUpdateUseCase: CheckAppUpdateUseCase { + private let repository: any AppVersionRepository + + public init(repository: any AppVersionRepository) { + self.repository = repository + } + + // 강제 업데이트는 서버가 최소 지원 버전을 주기 전까지 판정할 근거가 없다 — 권장만 낸다. + public func execute(currentVersion: String) async throws -> AppUpdateStatus { + let latest = try await repository.latestVersion() + return AppVersion.isNewer(latest, than: currentVersion) + ? .recommended(latest: latest) + : .upToDate + } +} + +enum AppVersion { + /// 점 구분 숫자 비교 — "2.10" > "2.9", 자릿수 차이는 0으로 채운다. + /// 숫자가 아닌 조각(베타 접미사 등)은 0으로 본다: 잘못된 서버 값이 팝업을 띄우지 않는 쪽으로 기운다. + static func isNewer(_ candidate: String, than current: String) -> Bool { + let lhs = components(candidate) + let rhs = components(current) + for index in 0.. right } + } + return false + } + + private static func components(_ version: String) -> [Int] { + version.trimmingCharacters(in: .whitespaces) + .split(separator: ".") + .map { Int($0) ?? 0 } + } +} diff --git a/Projects/Domain/Sources/UseCases/LogoutUseCase.swift b/Projects/Domain/Sources/UseCases/LogoutUseCase.swift index 574bfeb6..f3427c17 100644 --- a/Projects/Domain/Sources/UseCases/LogoutUseCase.swift +++ b/Projects/Domain/Sources/UseCases/LogoutUseCase.swift @@ -5,12 +5,17 @@ public protocol LogoutUseCase: Sendable { public struct DefaultLogoutUseCase: LogoutUseCase { private let sessionEnding: any SessionEnding + /// nil이면 알람 정리 없이 동작한다(Example·테스트 호환). + private let alarmTeardown: (any AlarmSessionTeardown)? - public init(sessionEnding: any SessionEnding) { + public init(sessionEnding: any SessionEnding, alarmTeardown: (any AlarmSessionTeardown)? = nil) { self.sessionEnding = sessionEnding + self.alarmTeardown = alarmTeardown } public func execute() async { + // 서버 알람 취소는 토큰이 살아 있을 때만 가능하다 — 세션 종료보다 반드시 먼저. + await alarmTeardown?.tearDown(cancelOnServer: true) await sessionEnding.endSession() } } diff --git a/Projects/Domain/Sources/UseCases/SyncPushTokenUseCase.swift b/Projects/Domain/Sources/UseCases/SyncPushTokenUseCase.swift new file mode 100644 index 00000000..a4b7e5b3 --- /dev/null +++ b/Projects/Domain/Sources/UseCases/SyncPushTokenUseCase.swift @@ -0,0 +1,33 @@ +import Synchronization + +public protocol SyncPushTokenUseCase: Sendable { + /// 로그인 상태에서만 호출할 것 — 토큰 갱신 콜백·로그인 직후 두 지점. + func execute(token: String) async + /// 로그아웃 시 호출 — 다음 계정에는 같은 토큰이라도 다시 보내야 한다. + func reset() +} + +public final class DefaultSyncPushTokenUseCase: SyncPushTokenUseCase { + private let repository: any PushTokenRepository + /// 프로세스 내 중복 전송 방지 — Firebase는 실행마다 같은 토큰을 다시 알려 준다. + /// 영속 dedup은 두지 않는다: 실행당 1요청이라 저장소를 늘릴 이유가 없다. + private let lastSent = Mutex(nil) + + public init(repository: any PushTokenRepository) { + self.repository = repository + } + + public func execute(token: String) async { + guard lastSent.withLock({ $0 }) != token else { return } + do { + try await repository.register(token: token) + lastSent.withLock { $0 = token } + } catch { + // 실패는 삼킨다 — 다음 트리거(재실행·재로그인)가 자연 재시도다. + } + } + + public func reset() { + lastSent.withLock { $0 = nil } + } +} diff --git a/Projects/Domain/Sources/UseCases/UpdateHomeAddressUseCase.swift b/Projects/Domain/Sources/UseCases/UpdateHomeAddressUseCase.swift index 8773f2f9..35617009 100644 --- a/Projects/Domain/Sources/UseCases/UpdateHomeAddressUseCase.swift +++ b/Projects/Domain/Sources/UseCases/UpdateHomeAddressUseCase.swift @@ -1,15 +1,26 @@ +public enum UpdateHomeAddressError: Error, Equatable { + /// 서비스 지역 밖 — 레거시 토스트 "앗차는 현재 서울, 경기, 인천에서만 이용 가능해요". + case outOfServiceRegion +} + public protocol UpdateHomeAddressUseCase: Sendable { - func execute(address: String?, coordinate: Coordinate?) async throws + func execute(address: String?, coordinate: Coordinate) async throws } public struct DefaultUpdateHomeAddressUseCase: UpdateHomeAddressUseCase { private let userRepository: any UserRepository + private let placeRepository: any PlaceRepository - public init(userRepository: any UserRepository) { + public init(userRepository: any UserRepository, placeRepository: any PlaceRepository) { self.userRepository = userRepository + self.placeRepository = placeRepository } - public func execute(address: String?, coordinate: Coordinate?) async throws { + public func execute(address: String?, coordinate: Coordinate) async throws { + // 서비스 지역 밖의 집은 막차 검색이 성립하지 않는다 — 저장 전에 거른다(레거시 규칙). + guard try await placeRepository.isServiceRegion(coordinate) else { + throw UpdateHomeAddressError.outOfServiceRegion + } try await userRepository.updateHomeAddress(address: address, coordinate: coordinate) } } diff --git a/Projects/Domain/Tests/DefaultCheckAppUpdateUseCaseTests.swift b/Projects/Domain/Tests/DefaultCheckAppUpdateUseCaseTests.swift new file mode 100644 index 00000000..a383c3fa --- /dev/null +++ b/Projects/Domain/Tests/DefaultCheckAppUpdateUseCaseTests.swift @@ -0,0 +1,33 @@ +@testable import Domain +import Testing + +private struct StubAppVersionRepository: AppVersionRepository { + let latest: String + func latestVersion() async throws -> String { latest } +} + +struct DefaultCheckAppUpdateUseCaseTests { + @Test(arguments: [ + ("2.0.1", "2.0.0", true), + ("2.10.0", "2.9.9", true), + ("2.1", "2.0.9", true), + ("2.0", "2.0.0", false), + ("2.0.0", "2.0.1", false), + ("garbage", "2.0.0", false), + ]) + func isNewer_comparesNumerically(candidate: String, current: String, expected: Bool) { + #expect(AppVersion.isNewer(candidate, than: current) == expected) + } + + @Test + func execute_newerOnServer_recommends() async throws { + let sut = DefaultCheckAppUpdateUseCase(repository: StubAppVersionRepository(latest: "2.1.0")) + #expect(try await sut.execute(currentVersion: "2.0.0") == .recommended(latest: "2.1.0")) + } + + @Test + func execute_sameVersion_isUpToDate() async throws { + let sut = DefaultCheckAppUpdateUseCase(repository: StubAppVersionRepository(latest: "2.0.0")) + #expect(try await sut.execute(currentVersion: "2.0.0") == .upToDate) + } +} diff --git a/Projects/Domain/Tests/DefaultLogoutUseCaseTests.swift b/Projects/Domain/Tests/DefaultLogoutUseCaseTests.swift index 4f03c7c3..092fc869 100644 --- a/Projects/Domain/Tests/DefaultLogoutUseCaseTests.swift +++ b/Projects/Domain/Tests/DefaultLogoutUseCaseTests.swift @@ -8,6 +8,12 @@ private final class SessionEndingSpy: SessionEnding, Sendable { func endSession() async { events.withLock { $0.append("endSession") } } func invalidateLocalSession() async { events.withLock { $0.append("invalidate") } } + func append(_ event: String) { events.withLock { $0.append(event) } } +} + +private struct TeardownSpy: AlarmSessionTeardown { + let spy: SessionEndingSpy + func tearDown(cancelOnServer: Bool) async { spy.append("tearDown(\(cancelOnServer))") } } struct DefaultLogoutUseCaseTests { @@ -20,4 +26,15 @@ struct DefaultLogoutUseCaseTests { #expect(spy.log == ["endSession"]) } + + /// 서버 알람 취소는 토큰이 살아 있을 때만 가능 — 정리가 세션 종료보다 먼저여야 한다. + @Test + func execute_tearsDownAlarmsBeforeEndingSession() async { + let spy = SessionEndingSpy() + let sut = DefaultLogoutUseCase(sessionEnding: spy, alarmTeardown: TeardownSpy(spy: spy)) + + await sut.execute() + + #expect(spy.log == ["tearDown(true)", "endSession"]) + } } diff --git a/Projects/Domain/Tests/DefaultReverseGeocodeUseCaseTests.swift b/Projects/Domain/Tests/DefaultReverseGeocodeUseCaseTests.swift index 9af6c39d..81f50d13 100644 --- a/Projects/Domain/Tests/DefaultReverseGeocodeUseCaseTests.swift +++ b/Projects/Domain/Tests/DefaultReverseGeocodeUseCaseTests.swift @@ -22,6 +22,8 @@ private struct SpyPlaceRepository: PlaceRepository { guard let place else { throw StubError() } return place } + + func isServiceRegion(_ coordinate: Coordinate) async throws -> Bool { true } } struct DefaultReverseGeocodeUseCaseTests { diff --git a/Projects/Domain/Tests/DefaultSyncPushTokenUseCaseTests.swift b/Projects/Domain/Tests/DefaultSyncPushTokenUseCaseTests.swift new file mode 100644 index 00000000..33b2807b --- /dev/null +++ b/Projects/Domain/Tests/DefaultSyncPushTokenUseCaseTests.swift @@ -0,0 +1,58 @@ +@testable import Domain +import Synchronization +import Testing + +private final class SpyPushTokenRepository: PushTokenRepository { + private let sent = Mutex<[String]>([]) + private let failing = Mutex(false) + var log: [String] { sent.withLock { $0 } } + func setFailing(_ value: Bool) { failing.withLock { $0 = value } } + + func register(token: String) async throws { + sent.withLock { $0.append(token) } + if failing.withLock({ $0 }) { throw SpyFailure() } + } +} + +private struct SpyFailure: Error {} + +struct DefaultSyncPushTokenUseCaseTests { + @Test + func execute_sameTokenTwice_sendsOnce() async { + let repository = SpyPushTokenRepository() + let sut = DefaultSyncPushTokenUseCase(repository: repository) + + await sut.execute(token: "a") + await sut.execute(token: "a") + await sut.execute(token: "b") + + #expect(repository.log == ["a", "b"]) + } + + /// 실패한 토큰은 기억하지 않는다 — 다음 트리거가 재시도가 된다. + @Test + func execute_failure_retriesNextTime() async { + let repository = SpyPushTokenRepository() + repository.setFailing(true) + let sut = DefaultSyncPushTokenUseCase(repository: repository) + + await sut.execute(token: "a") + repository.setFailing(false) + await sut.execute(token: "a") + + #expect(repository.log == ["a", "a"]) + } + + /// 계정이 바뀌면 같은 토큰도 다시 보내야 한다. + @Test + func reset_allowsResend() async { + let repository = SpyPushTokenRepository() + let sut = DefaultSyncPushTokenUseCase(repository: repository) + + await sut.execute(token: "a") + sut.reset() + await sut.execute(token: "a") + + #expect(repository.log == ["a", "a"]) + } +} diff --git a/Projects/Domain/Tests/DefaultUpdateHomeAddressUseCaseTests.swift b/Projects/Domain/Tests/DefaultUpdateHomeAddressUseCaseTests.swift new file mode 100644 index 00000000..cadbdb83 --- /dev/null +++ b/Projects/Domain/Tests/DefaultUpdateHomeAddressUseCaseTests.swift @@ -0,0 +1,67 @@ +@testable import Domain +import Synchronization +import Testing + +private final class AddressRecorder: Sendable { + private let events = Mutex<[String]>([]) + func record(_ event: String) { events.withLock { $0.append(event) } } + var log: [String] { events.withLock { $0 } } +} + +private struct StubPlaceRepository: PlaceRepository { + let recorder: AddressRecorder + let inRegion: Bool + + func searchPlaces(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { [] } + func reverseGeocode(_ coordinate: Coordinate) async throws -> Place { throw StubFailure() } + func isServiceRegion(_ coordinate: Coordinate) async throws -> Bool { + recorder.record("region") + return inRegion + } +} + +private struct StubUserRepository: UserRepository { + let recorder: AddressRecorder + + 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 { + recorder.record("patch(\(address ?? "nil"))") + } + func updateAlertFrequencies(_ frequencies: [Int]) async throws {} + func withdraw(reason: String?) async throws {} +} + +private struct StubFailure: Error {} + +struct DefaultUpdateHomeAddressUseCaseTests { + private let recorder = AddressRecorder() + private let seoul = Coordinate(latitude: 37.5665, longitude: 126.9780) + + @Test + func execute_inServiceRegion_checksThenPatches() async throws { + let sut = DefaultUpdateHomeAddressUseCase( + userRepository: StubUserRepository(recorder: recorder), + placeRepository: StubPlaceRepository(recorder: recorder, inRegion: true) + ) + + try await sut.execute(address: "서울 중구", coordinate: seoul) + + #expect(recorder.log == ["region", "patch(서울 중구)"]) + } + + /// 지역 밖이면 PATCH를 보내지 않는다 — 서버에 쓸 수 없는 집이 저장되면 안 된다. + @Test + func execute_outOfServiceRegion_throwsWithoutPatch() async { + let sut = DefaultUpdateHomeAddressUseCase( + userRepository: StubUserRepository(recorder: recorder), + placeRepository: StubPlaceRepository(recorder: recorder, inRegion: false) + ) + + await #expect(throws: UpdateHomeAddressError.outOfServiceRegion) { + try await sut.execute(address: "부산 해운대구", coordinate: seoul) + } + #expect(recorder.log == ["region"]) + } +} From e6acd0cc88e6d0f4fe2cfbdc5028d700d926091c Mon Sep 17 00:00:00 2001 From: AppGoni Date: Thu, 24 Sep 2026 12:01:04 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20AtchaData=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EC=A7=80=EC=97=AD=C2=B7=EC=95=B1=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /locations/is-service-region (PlaceEndpoint.serviceRegion → Bool) - GET /app/version (X-Platform: iOS, 레거시는 버전 문자열 result) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Data/Sources/Network/AppVersionEndpoint.swift | 9 +++++++++ Projects/Data/Sources/Network/PlaceEndpoint.swift | 6 ++++-- .../Repositories/AppVersionRepositoryImpl.swift | 15 +++++++++++++++ .../Repositories/PlaceRepositoryImpl.swift | 4 ++++ Projects/Data/Tests/AppVersionEndpointTests.swift | 13 +++++++++++++ Projects/Data/Tests/PlaceEndpointTests.swift | 11 +++++++++++ 6 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 Projects/Data/Sources/Network/AppVersionEndpoint.swift create mode 100644 Projects/Data/Sources/Repositories/AppVersionRepositoryImpl.swift create mode 100644 Projects/Data/Tests/AppVersionEndpointTests.swift diff --git a/Projects/Data/Sources/Network/AppVersionEndpoint.swift b/Projects/Data/Sources/Network/AppVersionEndpoint.swift new file mode 100644 index 00000000..5543371d --- /dev/null +++ b/Projects/Data/Sources/Network/AppVersionEndpoint.swift @@ -0,0 +1,9 @@ +import CoreNetwork +import Foundation + +/// GET /app/version — 레거시 실측: 플랫폼 헤더 필수, 무토큰(로그인 전 스플래시에서 호출). +struct AppVersionEndpoint: Endpoint { + var path: String { "/app/version" } + var method: HTTPMethod { .get } + var headers: [String: String] { ["X-Platform": "iOS"] } +} diff --git a/Projects/Data/Sources/Network/PlaceEndpoint.swift b/Projects/Data/Sources/Network/PlaceEndpoint.swift index 443fa780..c74d2705 100644 --- a/Projects/Data/Sources/Network/PlaceEndpoint.swift +++ b/Projects/Data/Sources/Network/PlaceEndpoint.swift @@ -5,17 +5,19 @@ import Foundation enum PlaceEndpoint: Endpoint { case search(keyword: String, near: Coordinate?) case reverseGeocode(Coordinate) + case serviceRegion(Coordinate) var path: String { switch self { case .search: "/locations" case .reverseGeocode: "/locations/rgeo" + case .serviceRegion: "/locations/is-service-region" } } var method: HTTPMethod { switch self { - case .search, .reverseGeocode: .get + case .search, .reverseGeocode, .serviceRegion: .get } } @@ -28,7 +30,7 @@ enum PlaceEndpoint: Endpoint { URLQueryItem(name: "lat", value: String(near?.latitude ?? 0.0)), URLQueryItem(name: "lon", value: String(near?.longitude ?? 0.0)), ] - case let .reverseGeocode(coordinate): + case let .reverseGeocode(coordinate), let .serviceRegion(coordinate): [ URLQueryItem(name: "lat", value: String(coordinate.latitude)), URLQueryItem(name: "lon", value: String(coordinate.longitude)), diff --git a/Projects/Data/Sources/Repositories/AppVersionRepositoryImpl.swift b/Projects/Data/Sources/Repositories/AppVersionRepositoryImpl.swift new file mode 100644 index 00000000..a674f842 --- /dev/null +++ b/Projects/Data/Sources/Repositories/AppVersionRepositoryImpl.swift @@ -0,0 +1,15 @@ +import CoreNetwork +import Domain + +public struct AppVersionRepositoryImpl: AppVersionRepository { + private let networkClient: any NetworkClient + + public init(networkClient: any NetworkClient) { + self.networkClient = networkClient + } + + // 레거시는 result를 버전 문자열 하나로 디코딩했다 — 최소 지원 버전 필드가 오면 DTO로 승격한다. + public func latestVersion() async throws -> String { + try await networkClient.requestEnveloped(AppVersionEndpoint(), as: String.self) + } +} diff --git a/Projects/Data/Sources/Repositories/PlaceRepositoryImpl.swift b/Projects/Data/Sources/Repositories/PlaceRepositoryImpl.swift index 62d8d845..edad99aa 100644 --- a/Projects/Data/Sources/Repositories/PlaceRepositoryImpl.swift +++ b/Projects/Data/Sources/Repositories/PlaceRepositoryImpl.swift @@ -24,4 +24,8 @@ public struct PlaceRepositoryImpl: PlaceRepository { } return place } + + public func isServiceRegion(_ coordinate: Coordinate) async throws -> Bool { + try await networkClient.requestEnveloped(PlaceEndpoint.serviceRegion(coordinate), as: Bool.self) + } } diff --git a/Projects/Data/Tests/AppVersionEndpointTests.swift b/Projects/Data/Tests/AppVersionEndpointTests.swift new file mode 100644 index 00000000..9dfda8de --- /dev/null +++ b/Projects/Data/Tests/AppVersionEndpointTests.swift @@ -0,0 +1,13 @@ +@testable import AtchaData +import CoreNetwork +import Testing + +struct AppVersionEndpointTests { + @Test + func appVersion_sendsPlatformHeader() { + let endpoint = AppVersionEndpoint() + #expect(endpoint.path == "/app/version") + #expect(endpoint.method == .get) + #expect(endpoint.headers == ["X-Platform": "iOS"]) + } +} diff --git a/Projects/Data/Tests/PlaceEndpointTests.swift b/Projects/Data/Tests/PlaceEndpointTests.swift index 0d5a1895..cf894105 100644 --- a/Projects/Data/Tests/PlaceEndpointTests.swift +++ b/Projects/Data/Tests/PlaceEndpointTests.swift @@ -40,4 +40,15 @@ struct PlaceEndpointTests { URLQueryItem(name: "lon", value: "126.921537"), ]) } + + @Test + func serviceRegion_composesQuery() { + let endpoint = PlaceEndpoint.serviceRegion(Coordinate(latitude: 37.560908, longitude: 126.921537)) + #expect(endpoint.path == "/locations/is-service-region") + #expect(endpoint.method == .get) + #expect(endpoint.queryItems == [ + URLQueryItem(name: "lat", value: "37.560908"), + URLQueryItem(name: "lon", value: "126.921537"), + ]) + } } From 9cde92b787af88aa7bb332b2ca207ec70adaca97 Mon Sep 17 00:00:00 2001 From: AppGoni Date: Thu, 24 Sep 2026 12:01:04 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20SettingsFeature=20=EC=8B=A0?= =?UTF-8?q?=EC=84=A4=20=E2=80=94=20=EC=84=A4=EC=A0=95=C2=B7=EA=B3=84?= =?UTF-8?q?=EC=A0=95=20=ED=83=88=ED=87=B4=C2=B7=EC=9A=B0=EB=A6=AC=EC=A7=91?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20+=20=ED=99=88=20=ED=86=B1=EB=8B=88?= =?UTF-8?q?=EB=B0=94=ED=80=B4=20=EC=A7=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 설정: 우리집 주소(GET /members/me)·약관·피드백·버전(업데이트 여부)·로그아웃·계정 탈퇴 - 계정 탈퇴: 레거시 사유 7종(기타 자유입력, 공백 금지)·확인 팝업·실패 시 재시도 - 우리집 설정: 키워드 검색 + 현재 위치 역지오코딩, 지역 밖 레거시 토스트 - Home은 SettingsFeatureInterface만 의존, DSIcon.settings24 추가 - Swift Testing 12건 + Example 앱(스텁 UseCase) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Sources/Foundation/DSIcon.swift | 1 + Projects/Feature/Home/Project.swift | 2 + .../Home/Sources/HomeCoordinator.swift | 16 +- .../Home/Sources/HomeDIContainer.swift | 15 +- .../Home/Sources/HomeViewController.swift | 19 ++- .../Feature/Home/Sources/HomeViewModel.swift | 6 + .../Feature/Settings/Example/ExampleApp.swift | 148 ++++++++++++++++++ .../SettingsCoordinatorBuildable.swift | 9 ++ Projects/Feature/Settings/Project.swift | 22 +++ .../Sources/HomeAddressViewController.swift | 138 ++++++++++++++++ .../Sources/HomeAddressViewModel.swift | 142 +++++++++++++++++ .../Sources/SettingsCoordinator.swift | 75 +++++++++ .../Sources/SettingsDIContainer.swift | 74 +++++++++ .../Settings/Sources/SettingsLinks.swift | 7 + .../Settings/Sources/SettingsScreen.swift | 62 ++++++++ .../Sources/SettingsViewController.swift | 110 +++++++++++++ .../Settings/Sources/SettingsViewModel.swift | 148 ++++++++++++++++++ .../Sources/WithdrawViewController.swift | 125 +++++++++++++++ .../Settings/Sources/WithdrawViewModel.swift | 96 ++++++++++++ .../Tests/HomeAddressViewModelTests.swift | 69 ++++++++ .../Settings/Tests/SettingsFeatureStubs.swift | 88 +++++++++++ .../Tests/SettingsViewModelTests.swift | 81 ++++++++++ .../Tests/WithdrawViewModelTests.swift | 58 +++++++ 23 files changed, 1507 insertions(+), 4 deletions(-) create mode 100644 Projects/Feature/Settings/Example/ExampleApp.swift create mode 100644 Projects/Feature/Settings/Interface/SettingsCoordinatorBuildable.swift create mode 100644 Projects/Feature/Settings/Project.swift create mode 100644 Projects/Feature/Settings/Sources/HomeAddressViewController.swift create mode 100644 Projects/Feature/Settings/Sources/HomeAddressViewModel.swift create mode 100644 Projects/Feature/Settings/Sources/SettingsCoordinator.swift create mode 100644 Projects/Feature/Settings/Sources/SettingsDIContainer.swift create mode 100644 Projects/Feature/Settings/Sources/SettingsLinks.swift create mode 100644 Projects/Feature/Settings/Sources/SettingsScreen.swift create mode 100644 Projects/Feature/Settings/Sources/SettingsViewController.swift create mode 100644 Projects/Feature/Settings/Sources/SettingsViewModel.swift create mode 100644 Projects/Feature/Settings/Sources/WithdrawViewController.swift create mode 100644 Projects/Feature/Settings/Sources/WithdrawViewModel.swift create mode 100644 Projects/Feature/Settings/Tests/HomeAddressViewModelTests.swift create mode 100644 Projects/Feature/Settings/Tests/SettingsFeatureStubs.swift create mode 100644 Projects/Feature/Settings/Tests/SettingsViewModelTests.swift create mode 100644 Projects/Feature/Settings/Tests/WithdrawViewModelTests.swift diff --git a/Projects/DesignSystem/Sources/Foundation/DSIcon.swift b/Projects/DesignSystem/Sources/Foundation/DSIcon.swift index 0d60899e..558d554c 100644 --- a/Projects/DesignSystem/Sources/Foundation/DSIcon.swift +++ b/Projects/DesignSystem/Sources/Foundation/DSIcon.swift @@ -12,6 +12,7 @@ public enum DSIcon { public static var myLocation24: UIImage { asset("icMyLocation24", fallback: "location.fill") } public static var place24: UIImage { asset("icPlace24", fallback: "mappin.and.ellipse") } public static var bell24: UIImage { asset("icBell24", fallback: "bell.fill") } + public static var settings24: UIImage { asset("icSettings24", fallback: "gearshape") } public static var info16: UIImage { asset("icInfo16", fallback: "info.circle") } public static var check20: UIImage { asset("icCheck20", fallback: "checkmark") } public static var illustCharacterGray: UIImage { asset("illustCharacterGray", fallback: "tram.fill") } diff --git a/Projects/Feature/Home/Project.swift b/Projects/Feature/Home/Project.swift index c6f92df1..a17ff489 100644 --- a/Projects/Feature/Home/Project.swift +++ b/Projects/Feature/Home/Project.swift @@ -9,6 +9,8 @@ let project = Project.feature( .project(target: "CoreCoordinator", path: "../../Core/Coordinator"), // 검색 플로우 진입점 — 본체(SearchFeature)가 아니라 Interface만 본다. .project(target: "SearchFeatureInterface", path: "../Search"), + // 설정 플로우 진입점 — 역시 Interface만 본다. + .project(target: "SettingsFeatureInterface", path: "../Settings"), .external(name: "SnapKit"), ], interfaceDependencies: [ diff --git a/Projects/Feature/Home/Sources/HomeCoordinator.swift b/Projects/Feature/Home/Sources/HomeCoordinator.swift index 9d358ab1..e8e997d9 100644 --- a/Projects/Feature/Home/Sources/HomeCoordinator.swift +++ b/Projects/Feature/Home/Sources/HomeCoordinator.swift @@ -20,6 +20,9 @@ final class HomeCoordinator: Coordinator, CoordinatorFinishDelegate { let viewController = container.makeHomeViewController( onSearchRequested: { [weak self] initialField, onRouteSelected in self?.startSearchFlow(initialField: initialField, onRouteSelected: onRouteSelected) + }, + onSettingsRequested: { [weak self] in + self?.startSettingsFlow() } ) navigationController?.pushViewController(viewController, animated: false) @@ -43,7 +46,18 @@ final class HomeCoordinator: Coordinator, CoordinatorFinishDelegate { child.start() } - // SearchCoordinator가 스스로 pop 후 finish()하므로 여기서는 제거만 한다. + // MARK: - 설정 플로우 + + private func startSettingsFlow() { + guard let navigationController, + let child = container.makeSettingsCoordinator(navigationController: navigationController) + else { return } + child.finishDelegate = self + addChild(child) + child.start() + } + + // 자식(Search·Settings) 코디네이터가 스스로 pop 후 finish()하므로 여기서는 제거만 한다. func coordinatorDidFinish(_ coordinator: any Coordinator) { removeChild(coordinator) } diff --git a/Projects/Feature/Home/Sources/HomeDIContainer.swift b/Projects/Feature/Home/Sources/HomeDIContainer.swift index a465a63f..0686b17e 100644 --- a/Projects/Feature/Home/Sources/HomeDIContainer.swift +++ b/Projects/Feature/Home/Sources/HomeDIContainer.swift @@ -2,6 +2,7 @@ import CoreCoordinator import Domain import HomeFeatureInterface import SearchFeatureInterface +import SettingsFeatureInterface import UIKit /// Assembles the Home feature's screens. Coordinators own flow only; @@ -19,6 +20,8 @@ public final class HomeDIContainer: HomeCoordinatorBuildable { private let searchLastRoutesUseCase: any SearchLastRoutesUseCase private let recentSearchesUseCase: any RecentSearchesUseCase private let searchCoordinatorBuildable: any SearchCoordinatorBuildable + /// nil이면 톱니바퀴가 아무 일도 하지 않는다(Example 구성 호환). + private let settingsCoordinatorBuildable: (any SettingsCoordinatorBuildable)? public init( getCurrentLocationUseCase: any GetCurrentLocationUseCase, @@ -31,7 +34,8 @@ public final class HomeDIContainer: HomeCoordinatorBuildable { getLastRouteDetailUseCase: any GetLastRouteDetailUseCase, searchLastRoutesUseCase: any SearchLastRoutesUseCase, recentSearchesUseCase: any RecentSearchesUseCase, - searchCoordinatorBuildable: any SearchCoordinatorBuildable + searchCoordinatorBuildable: any SearchCoordinatorBuildable, + settingsCoordinatorBuildable: (any SettingsCoordinatorBuildable)? = nil ) { self.getCurrentLocationUseCase = getCurrentLocationUseCase self.reverseGeocodeUseCase = reverseGeocodeUseCase @@ -44,6 +48,7 @@ public final class HomeDIContainer: HomeCoordinatorBuildable { self.searchLastRoutesUseCase = searchLastRoutesUseCase self.recentSearchesUseCase = recentSearchesUseCase self.searchCoordinatorBuildable = searchCoordinatorBuildable + self.settingsCoordinatorBuildable = settingsCoordinatorBuildable } public func makeHomeCoordinator(navigationController: UINavigationController) -> any Coordinator { @@ -54,7 +59,8 @@ public final class HomeDIContainer: HomeCoordinatorBuildable { onSearchRequested: @escaping ( _ initialField: SearchEntryField, _ onRouteSelected: @escaping (LastRoute, Place) -> Void - ) -> Void + ) -> Void, + onSettingsRequested: @escaping () -> Void ) -> UIViewController { let viewModel = HomeViewModel( getCurrentLocationUseCase: getCurrentLocationUseCase, @@ -69,6 +75,7 @@ public final class HomeDIContainer: HomeCoordinatorBuildable { recentSearchesUseCase: recentSearchesUseCase ) viewModel.onSearchRequested = onSearchRequested + viewModel.onSettingsRequested = onSettingsRequested return HomeViewController(viewModel: viewModel) } @@ -83,4 +90,8 @@ public final class HomeDIContainer: HomeCoordinatorBuildable { onRouteSelected: onRouteSelected ) } + + func makeSettingsCoordinator(navigationController: UINavigationController) -> (any Coordinator)? { + settingsCoordinatorBuildable?.makeSettingsCoordinator(navigationController: navigationController) + } } diff --git a/Projects/Feature/Home/Sources/HomeViewController.swift b/Projects/Feature/Home/Sources/HomeViewController.swift index e6694807..7d461613 100644 --- a/Projects/Feature/Home/Sources/HomeViewController.swift +++ b/Projects/Feature/Home/Sources/HomeViewController.swift @@ -27,6 +27,14 @@ final class HomeViewController: UIViewController { return label }() + private let settingsButton: UIButton = { + let button = UIButton(type: .system) + button.setImage(DSIcon.settings24.withRenderingMode(.alwaysTemplate), for: .normal) + button.tintColor = DSColor.Icon.default + button.accessibilityLabel = "설정" + return button + }() + private let banner = DSBanner() private let departureRow = HomeFieldRow( icon: DSIcon.myLocation24, placeholder: "출발지를 검색해 주세요", showsAccentDot: true @@ -195,7 +203,16 @@ final class HomeViewController: UIViewController { make.width.equalTo(scrollView.frameLayoutGuide).offset(-DSSpacing.md * 2) } - [brandLabel, banner, bigTitleLabel, fieldCard, recentSection, routeCard] + let brandRow = UIStackView(arrangedSubviews: [brandLabel, UIView(), settingsButton]) + brandRow.alignment = .center + settingsButton.snp.makeConstraints { make in + make.size.equalTo(DSIconSize.lg) + } + settingsButton.addAction( + UIAction { [weak self] _ in self?.viewModel.settingsTapped() }, + for: .touchUpInside + ) + [brandRow, banner, bigTitleLabel, fieldCard, recentSection, routeCard] .forEach(contentStack.addArrangedSubview) contentStack.addArrangedSubview(makeCaptionStack()) contentStack.setCustomSpacing(DSSpacing.lg20, after: bigTitleLabel) diff --git a/Projects/Feature/Home/Sources/HomeViewModel.swift b/Projects/Feature/Home/Sources/HomeViewModel.swift index 756c18b2..70623fd9 100644 --- a/Projects/Feature/Home/Sources/HomeViewModel.swift +++ b/Projects/Feature/Home/Sources/HomeViewModel.swift @@ -91,6 +91,8 @@ final class HomeViewModel { _ initialField: SearchEntryField, _ onRouteSelected: @escaping (LastRoute, Place) -> Void ) -> Void)? + /// Set by the Coordinator: 설정 플로우 진입(홈 상단 톱니바퀴). + var onSettingsRequested: (() -> Void)? private(set) var state = State() { didSet { if state != oldValue { onStateChange?(state) } } @@ -217,6 +219,10 @@ final class HomeViewModel { } } + func settingsTapped() { + onSettingsRequested?() + } + /// 탭한 필드가 그대로 검색 진입 슬롯이 된다(Phase 17) — 도착지 탭이면 도착지부터. func searchFieldTapped(_ field: SearchEntryField) { onSearchRequested?(field) { [weak self] route, arrival in diff --git a/Projects/Feature/Settings/Example/ExampleApp.swift b/Projects/Feature/Settings/Example/ExampleApp.swift new file mode 100644 index 00000000..d9ca767a --- /dev/null +++ b/Projects/Feature/Settings/Example/ExampleApp.swift @@ -0,0 +1,148 @@ +import CoreCoordinator +import Domain +import SettingsFeature +import SettingsFeatureInterface +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 settingsCoordinator: (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?.startSettingsFlow() } + navigationController.viewControllers = [launcher] + + let window = UIWindow(windowScene: windowScene) + window.rootViewController = navigationController + window.makeKeyAndVisible() + self.window = window + } + + private func startSettingsFlow() { + let container = SettingsDIContainer( + getUserProfileUseCase: PreviewGetUserProfileUseCase(), + logoutUseCase: PreviewLogoutUseCase(), + withdrawUseCase: PreviewWithdrawUseCase(), + updateHomeAddressUseCase: PreviewUpdateHomeAddressUseCase(), + searchPlacesUseCase: PreviewSearchPlacesUseCase(), + getCurrentLocationUseCase: PreviewGetCurrentLocationUseCase(), + reverseGeocodeUseCase: PreviewReverseGeocodeUseCase(), + checkAppUpdateUseCase: PreviewCheckAppUpdateUseCase(), + currentVersion: "2.0.0", + appStoreURL: URL(string: "https://apps.apple.com") + ) + let coordinator = container.makeSettingsCoordinator(navigationController: navigationController) + coordinator.finishDelegate = self + settingsCoordinator = coordinator + coordinator.start() + } +} + +extension SceneDelegate: CoordinatorFinishDelegate { + func coordinatorDidFinish(_ coordinator: any Coordinator) { + settingsCoordinator = nil + } +} + +private final class LauncherViewController: UIViewController { + var onStart: (() -> Void)? + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + title = "SettingsFeature 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), + ]) + } +} + +// Example 스텁 — Data 무의존으로 피처 단독 실행(Tests 스텁과의 중복은 의도된 트레이드오프). +// "부산" 검색 결과를 고르면 서비스 지역 밖 토스트, 그 외는 저장 성공 경로를 시연한다. + +private struct PreviewError: Error {} + +private struct PreviewGetUserProfileUseCase: GetUserProfileUseCase { + func execute() async throws -> UserProfile { + try? await Task.sleep(for: .milliseconds(500)) + return UserProfile(userID: 1, providerID: nil, nickname: nil, address: "서울 중구 세종대로 110", coordinate: nil, appVersion: nil) + } +} + +private struct PreviewLogoutUseCase: LogoutUseCase { + func execute() async {} +} + +private struct PreviewWithdrawUseCase: WithdrawUseCase { + func execute(reason: String?) async throws { + try? await Task.sleep(for: .seconds(1)) + throw PreviewError() + } +} + +private struct PreviewUpdateHomeAddressUseCase: UpdateHomeAddressUseCase { + func execute(address: String?, coordinate: Coordinate) async throws { + try? await Task.sleep(for: .milliseconds(500)) + if address?.contains("부산") == true { throw UpdateHomeAddressError.outOfServiceRegion } + } +} + +private struct PreviewSearchPlacesUseCase: SearchPlacesUseCase { + func execute(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { + [ + Place(name: "\(keyword) 서울점", address: "서울 중구 세종대로 110", coordinate: Coordinate(latitude: 37.5665, longitude: 126.9780)), + Place(name: "\(keyword) 부산점", address: "부산 해운대구 해운대해변로 264", coordinate: Coordinate(latitude: 35.1587, longitude: 129.1604)), + ] + } +} + +private struct PreviewGetCurrentLocationUseCase: GetCurrentLocationUseCase { + func execute() async throws -> Coordinate { + Coordinate(latitude: 37.5665, longitude: 126.9780) + } +} + +private struct PreviewReverseGeocodeUseCase: ReverseGeocodeUseCase { + func execute(coordinate: Coordinate) async throws -> Place { + Place(name: "서울시청", address: "서울 중구 세종대로 110", coordinate: coordinate) + } +} + +private struct PreviewCheckAppUpdateUseCase: CheckAppUpdateUseCase { + func execute(currentVersion: String) async throws -> AppUpdateStatus { + .recommended(latest: "2.1.0") + } +} diff --git a/Projects/Feature/Settings/Interface/SettingsCoordinatorBuildable.swift b/Projects/Feature/Settings/Interface/SettingsCoordinatorBuildable.swift new file mode 100644 index 00000000..f3fd8fcf --- /dev/null +++ b/Projects/Feature/Settings/Interface/SettingsCoordinatorBuildable.swift @@ -0,0 +1,9 @@ +import CoreCoordinator +import UIKit + +/// Entry point other modules use to start the Settings flow. +/// 로그아웃·탈퇴 후의 로그인 복귀는 앱의 세션 만료 관찰이 맡는다 — 여기엔 완료 콜백이 없다. +@MainActor +public protocol SettingsCoordinatorBuildable { + func makeSettingsCoordinator(navigationController: UINavigationController) -> any Coordinator +} diff --git a/Projects/Feature/Settings/Project.swift b/Projects/Feature/Settings/Project.swift new file mode 100644 index 00000000..e0982d7b --- /dev/null +++ b/Projects/Feature/Settings/Project.swift @@ -0,0 +1,22 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +let project = Project.feature( + name: "Settings", + 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"), + ] +) diff --git a/Projects/Feature/Settings/Sources/HomeAddressViewController.swift b/Projects/Feature/Settings/Sources/HomeAddressViewController.swift new file mode 100644 index 00000000..c8530c2a --- /dev/null +++ b/Projects/Feature/Settings/Sources/HomeAddressViewController.swift @@ -0,0 +1,138 @@ +import DesignSystem +import SnapKit +import UIKit + +final class HomeAddressViewController: SettingsScreen { + // VC strongly owns the VM; the VM's closures capture the VC weakly. + private let viewModel: HomeAddressViewModel + private let searchField = DSTextField(placeholder: "지번, 도로명, 건물명으로 검색") + private let currentLocationButton = DSButton( + title: "현재 위치로 설정", style: .secondary, size: .medium, icon: DSIcon.myLocation24 + ) + private let tableView = UITableView(frame: .zero, style: .plain) + private let emptyState = DSEmptyState(content: .init(title: "")) + private let activityIndicator = UIActivityIndicatorView(style: .medium) + private var places: [HomeAddressViewModel.PlaceViewData] = [] + + init(viewModel: HomeAddressViewModel) { + self.viewModel = viewModel + super.init(title: "우리집 설정") + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func viewDidLoad() { + super.viewDidLoad() + configureUI() + bind() + } + + private func configureUI() { + tableView.backgroundColor = .clear + tableView.separatorStyle = .none + tableView.keyboardDismissMode = .onDrag + tableView.dataSource = self + tableView.delegate = self + tableView.register(DSListCell.self, forCellReuseIdentifier: DSListCell.reuseIdentifier) + emptyState.isHidden = true + activityIndicator.hidesWhenStopped = true + activityIndicator.color = DSColor.Text.primary + + [searchField, currentLocationButton, tableView, emptyState, activityIndicator] + .forEach(contentView.addSubview) + searchField.snp.makeConstraints { make in + make.top.equalToSuperview().offset(DSSpacing.sm) + make.leading.trailing.equalToSuperview().inset(DSSpacing.md) + } + currentLocationButton.snp.makeConstraints { make in + make.top.equalTo(searchField.snp.bottom).offset(DSSpacing.sm12) + make.leading.trailing.equalToSuperview().inset(DSSpacing.md) + } + tableView.snp.makeConstraints { make in + make.top.equalTo(currentLocationButton.snp.bottom).offset(DSSpacing.sm) + make.leading.trailing.bottom.equalToSuperview() + } + emptyState.snp.makeConstraints { make in + make.top.equalTo(tableView).offset(DSSpacing.xl) + make.leading.trailing.equalToSuperview().inset(DSSpacing.md) + } + activityIndicator.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.top.equalTo(tableView).offset(DSSpacing.xl) + } + + searchField.onTextChange = { [weak self] text in + self?.viewModel.keywordDidChange(text) + } + currentLocationButton.addAction( + UIAction { [weak self] _ in self?.viewModel.useCurrentLocationTapped() }, + for: .touchUpInside + ) + } + + private func bind() { + viewModel.onStateChange = { [weak self] state in + self?.render(state) + } + viewModel.onSavingChange = { [weak self] isSaving in + self?.currentLocationButton.isEnabled = !isSaving + self?.tableView.isUserInteractionEnabled = !isSaving + if isSaving { + self?.activityIndicator.startAnimating() + } else { + self?.activityIndicator.stopAnimating() + } + } + viewModel.onToast = { [weak self] message in + self?.showToast(message) + } + render(viewModel.state) + } + + private func render(_ state: HomeAddressViewModel.State) { + activityIndicator.stopAnimating() + emptyState.isHidden = true + switch state { + case .idle: + places = [] + case .loading: + places = [] + activityIndicator.startAnimating() + case let .places(items): + places = items + case .empty: + places = [] + emptyState.configure(with: .init(title: "검색 결과가 없어요")) + emptyState.isHidden = false + case let .failed(message): + places = [] + emptyState.configure(with: .init(title: message)) + emptyState.isHidden = false + } + tableView.reloadData() + } +} + +extension HomeAddressViewController: UITableViewDataSource, UITableViewDelegate { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + places.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: DSListCell.reuseIdentifier, for: indexPath) + let place = places[indexPath.row] + (cell as? DSListCell)?.configure(with: .init( + leadingIcon: DSIcon.place24, title: place.name, subtitle: place.address + )) + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + view.endEditing(true) + viewModel.didSelectPlace(at: indexPath.row) + } +} diff --git a/Projects/Feature/Settings/Sources/HomeAddressViewModel.swift b/Projects/Feature/Settings/Sources/HomeAddressViewModel.swift new file mode 100644 index 00000000..98509d2c --- /dev/null +++ b/Projects/Feature/Settings/Sources/HomeAddressViewModel.swift @@ -0,0 +1,142 @@ +import Domain +import Foundation + +// Convention: every ViewModel in the codebase is @MainActor. +@MainActor +final class HomeAddressViewModel { + struct PlaceViewData: Equatable { + let name: String + let address: String + } + + enum State: Equatable { + case idle + case loading + case places([PlaceViewData]) + case empty + case failed(message: String) + } + + /// Set by the ViewController; always invoked on the main actor. + var onStateChange: ((State) -> Void)? + var onSavingChange: ((Bool) -> Void)? + var onToast: ((String) -> Void)? + /// Set by the Coordinator — 저장 성공 시 1회. + var onSaved: (() -> Void)? + + private(set) var state: State = .idle { + didSet { if state != oldValue { onStateChange?(state) } } + } + + private(set) var isSaving = false { + didSet { if isSaving != oldValue { onSavingChange?(isSaving) } } + } + + private var listedPlaces: [Place] = [] + private let searchPlacesUseCase: any SearchPlacesUseCase + private let getCurrentLocationUseCase: any GetCurrentLocationUseCase + private let reverseGeocodeUseCase: any ReverseGeocodeUseCase + private let updateHomeAddressUseCase: any UpdateHomeAddressUseCase + private let debounceInterval: Duration + + private var searchTask: Task? + private var saveTask: Task? + + init( + searchPlacesUseCase: any SearchPlacesUseCase, + getCurrentLocationUseCase: any GetCurrentLocationUseCase, + reverseGeocodeUseCase: any ReverseGeocodeUseCase, + updateHomeAddressUseCase: any UpdateHomeAddressUseCase, + debounceInterval: Duration = .milliseconds(300) + ) { + self.searchPlacesUseCase = searchPlacesUseCase + self.getCurrentLocationUseCase = getCurrentLocationUseCase + self.reverseGeocodeUseCase = reverseGeocodeUseCase + self.updateHomeAddressUseCase = updateHomeAddressUseCase + self.debounceInterval = debounceInterval + } + + deinit { + searchTask?.cancel() + saveTask?.cancel() + } + + func keywordDidChange(_ keyword: String) { + searchTask?.cancel() + let trimmed = keyword.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + listedPlaces = [] + state = .idle + return + } + // [weak self]: the in-flight task must not keep the ViewModel alive. + searchTask = Task { [weak self] in + guard let interval = self?.debounceInterval, + let useCase = self?.searchPlacesUseCase else { return } + try? await Task.sleep(for: interval) + guard !Task.isCancelled else { return } + self?.state = .loading + do { + let places = try await useCase.execute(keyword: trimmed, near: nil) + guard !Task.isCancelled else { return } + self?.listedPlaces = places + self?.state = places.isEmpty + ? .empty + : .places(places.map { PlaceViewData(name: $0.name, address: $0.address) }) + } catch { + guard !Task.isCancelled else { return } + self?.state = .failed(message: "검색에 실패했어요") + } + } + } + + func didSelectPlace(at index: Int) { + guard listedPlaces.indices.contains(index) else { return } + save(listedPlaces[index]) + } + + /// 현재 위치 → 역지오코딩 주소로 저장(레거시 "현위치 찾기"). + func useCurrentLocationTapped() { + guard !isSaving else { return } + isSaving = true + saveTask = Task { [weak self] in + guard let locate = self?.getCurrentLocationUseCase, + let geocode = self?.reverseGeocodeUseCase else { return } + do { + let coordinate = try await locate.execute() + let place = try await geocode.execute(coordinate: coordinate) + guard !Task.isCancelled else { return } + self?.isSaving = false + self?.save(place) + } catch { + guard !Task.isCancelled else { return } + self?.isSaving = false + self?.onToast?("현재 위치를 확인하지 못했어요. 잠시 후 다시 시도해 주세요") + } + } + } + + private func save(_ place: Place) { + guard !isSaving else { return } + isSaving = true + // 검색 결과는 도로명 주소가 비어 있을 수 있다 — 이름으로 대신한다. + let address = place.address.isEmpty ? place.name : place.address + saveTask = Task { [weak self] in + guard let useCase = self?.updateHomeAddressUseCase else { return } + do { + try await useCase.execute(address: address, coordinate: place.coordinate) + guard !Task.isCancelled else { return } + self?.isSaving = false + self?.onSaved?() + } catch UpdateHomeAddressError.outOfServiceRegion { + guard !Task.isCancelled else { return } + self?.isSaving = false + self?.onToast?("앗차는 현재 서울, 경기, 인천에서만 이용 가능해요") + } catch { + guard !Task.isCancelled else { return } + self?.isSaving = false + self?.onToast?("집 주소를 변경하지 못했어요. 다시 시도해 주세요") + } + } + } +} diff --git a/Projects/Feature/Settings/Sources/SettingsCoordinator.swift b/Projects/Feature/Settings/Sources/SettingsCoordinator.swift new file mode 100644 index 00000000..14db49ec --- /dev/null +++ b/Projects/Feature/Settings/Sources/SettingsCoordinator.swift @@ -0,0 +1,75 @@ +import CoreCoordinator +import SafariServices +import UIKit + +// NSObject 상속은 UINavigationControllerDelegate(NSObjectProtocol) 요구 — 이탈 경로 +// (백 버튼·스와이프 백·popToRoot·세션 만료의 setViewControllers)를 didShow 한 지점에서 +// 정리한다(SearchCoordinator와 같은 규약). +final class SettingsCoordinator: NSObject, 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: SettingsDIContainer + private weak var rootViewController: UIViewController? + // 주소 저장 후 복귀 시 토스트·재조회를 알리기 위한 참조 — VC가 소유한다. + private weak var settingsViewModel: SettingsViewModel? + private weak var previousNavigationDelegate: (any UINavigationControllerDelegate)? + private var isFinished = false + + init(navigationController: UINavigationController, container: SettingsDIContainer) { + self.navigationController = navigationController + self.container = container + } + + func start() { + guard let navigationController else { return } + let (viewController, viewModel) = container.makeSettingsScreen() + viewModel.onRoute = { [weak self] route in self?.handle(route) } + rootViewController = viewController + settingsViewModel = viewModel + previousNavigationDelegate = navigationController.delegate + navigationController.delegate = self + navigationController.pushViewController(viewController, animated: true) + } + + private func handle(_ route: SettingsViewModel.Route) { + guard let navigationController else { return } + switch route { + case .homeAddress: + let viewController = container.makeHomeAddressViewController { [weak self] in + guard let self, let root = self.rootViewController else { return } + self.navigationController?.popToViewController(root, animated: true) + self.settingsViewModel?.homeAddressDidChange() + } + navigationController.pushViewController(viewController, animated: true) + case .withdraw: + navigationController.pushViewController(container.makeWithdrawViewController(), animated: true) + case let .externalLink(url): + if url.scheme?.hasPrefix("http") == true { + navigationController.present(SFSafariViewController(url: url), animated: true) + } else { + UIApplication.shared.open(url) + } + } + } +} + +extension SettingsCoordinator: UINavigationControllerDelegate { + func navigationController( + _ navigationController: UINavigationController, + didShow viewController: UIViewController, + animated: Bool + ) { + guard !isFinished else { return } + // 설정 루트가 스택에 남아 있으면(하위 화면 push·pop 포함) 플로우 진행 중이다. + if let rootViewController, + navigationController.viewControllers.contains(rootViewController) { + return + } + isFinished = true + navigationController.delegate = previousNavigationDelegate + finish() + } +} diff --git a/Projects/Feature/Settings/Sources/SettingsDIContainer.swift b/Projects/Feature/Settings/Sources/SettingsDIContainer.swift new file mode 100644 index 00000000..e44e71fc --- /dev/null +++ b/Projects/Feature/Settings/Sources/SettingsDIContainer.swift @@ -0,0 +1,74 @@ +import CoreCoordinator +import Domain +import SettingsFeatureInterface +import UIKit + +/// Assembles the Settings feature's screens. Coordinators own flow only; +/// screen/ViewModel assembly stays here so adding screens never bloats +/// coordinator initializers. +public final class SettingsDIContainer: SettingsCoordinatorBuildable { + private let getUserProfileUseCase: any GetUserProfileUseCase + private let logoutUseCase: any LogoutUseCase + private let withdrawUseCase: any WithdrawUseCase + private let updateHomeAddressUseCase: any UpdateHomeAddressUseCase + private let searchPlacesUseCase: any SearchPlacesUseCase + private let getCurrentLocationUseCase: any GetCurrentLocationUseCase + private let reverseGeocodeUseCase: any ReverseGeocodeUseCase + private let checkAppUpdateUseCase: (any CheckAppUpdateUseCase)? + private let currentVersion: String + private let appStoreURL: URL? + + public init( + getUserProfileUseCase: any GetUserProfileUseCase, + logoutUseCase: any LogoutUseCase, + withdrawUseCase: any WithdrawUseCase, + updateHomeAddressUseCase: any UpdateHomeAddressUseCase, + searchPlacesUseCase: any SearchPlacesUseCase, + getCurrentLocationUseCase: any GetCurrentLocationUseCase, + reverseGeocodeUseCase: any ReverseGeocodeUseCase, + checkAppUpdateUseCase: (any CheckAppUpdateUseCase)? = nil, + currentVersion: String, + appStoreURL: URL? = nil + ) { + self.getUserProfileUseCase = getUserProfileUseCase + self.logoutUseCase = logoutUseCase + self.withdrawUseCase = withdrawUseCase + self.updateHomeAddressUseCase = updateHomeAddressUseCase + self.searchPlacesUseCase = searchPlacesUseCase + self.getCurrentLocationUseCase = getCurrentLocationUseCase + self.reverseGeocodeUseCase = reverseGeocodeUseCase + self.checkAppUpdateUseCase = checkAppUpdateUseCase + self.currentVersion = currentVersion + self.appStoreURL = appStoreURL + } + + public func makeSettingsCoordinator(navigationController: UINavigationController) -> any Coordinator { + SettingsCoordinator(navigationController: navigationController, container: self) + } + + func makeSettingsScreen() -> (UIViewController, SettingsViewModel) { + let viewModel = SettingsViewModel( + getUserProfileUseCase: getUserProfileUseCase, + logoutUseCase: logoutUseCase, + checkAppUpdateUseCase: checkAppUpdateUseCase, + currentVersion: currentVersion, + appStoreURL: appStoreURL + ) + return (SettingsViewController(viewModel: viewModel), viewModel) + } + + func makeWithdrawViewController() -> UIViewController { + WithdrawViewController(viewModel: WithdrawViewModel(withdrawUseCase: withdrawUseCase)) + } + + func makeHomeAddressViewController(onSaved: @escaping () -> Void) -> UIViewController { + let viewModel = HomeAddressViewModel( + searchPlacesUseCase: searchPlacesUseCase, + getCurrentLocationUseCase: getCurrentLocationUseCase, + reverseGeocodeUseCase: reverseGeocodeUseCase, + updateHomeAddressUseCase: updateHomeAddressUseCase + ) + viewModel.onSaved = onSaved + return HomeAddressViewController(viewModel: viewModel) + } +} diff --git a/Projects/Feature/Settings/Sources/SettingsLinks.swift b/Projects/Feature/Settings/Sources/SettingsLinks.swift new file mode 100644 index 00000000..2cf1c5f3 --- /dev/null +++ b/Projects/Feature/Settings/Sources/SettingsLinks.swift @@ -0,0 +1,7 @@ +import Foundation + +/// 레거시 마이페이지 실측 링크 — 서버가 아니라 외부 문서라 피처 상수로 둔다. +enum SettingsLinks { + static let privacyPolicy = URL(string: "https://mammoth-cheese-88e.notion.site/1008a99e3bbe80e88468c11f09c5a2dc?pvs=4")! + static let feedbackForm = URL(string: "https://forms.gle/56cbZmtZR3fUywgi9")! +} diff --git a/Projects/Feature/Settings/Sources/SettingsScreen.swift b/Projects/Feature/Settings/Sources/SettingsScreen.swift new file mode 100644 index 00000000..52b9fec4 --- /dev/null +++ b/Projects/Feature/Settings/Sources/SettingsScreen.swift @@ -0,0 +1,62 @@ +import DesignSystem +import SnapKit +import UIKit + +/// 설정 플로우 화면 공통 골격 — DSNavigationBar(시스템 내비바 대체) + 토스트 호스트 + +/// 숨긴 내비바가 죽이는 스와이프 백 복구(Search와 같은 규약). +class SettingsScreen: UIViewController, UIGestureRecognizerDelegate { + let navigationBar: DSNavigationBar + let contentView = UIView() + + init(title: String) { + navigationBar = DSNavigationBar(style: .title(title)) + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = DSColor.Background.base + navigationBar.onBack = { [weak self] in + self?.navigationController?.popViewController(animated: true) + } + [navigationBar, contentView].forEach(view.addSubview) + navigationBar.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide) + make.leading.trailing.equalToSuperview() + } + contentView.snp.makeConstraints { make in + make.top.equalTo(navigationBar.snp.bottom) + make.leading.trailing.bottom.equalToSuperview() + } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationController?.setNavigationBarHidden(true, animated: animated) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + navigationController?.interactivePopGestureRecognizer?.delegate = self + } + + func showToast(_ message: String) { + DSToast.show(message, in: view) + } + + func confirm(title: String, confirmTitle: String, onConfirm: @escaping () -> Void) { + let alert = UIAlertController(title: title, message: nil, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "취소", style: .cancel)) + alert.addAction(UIAlertAction(title: confirmTitle, style: .destructive) { _ in onConfirm() }) + present(alert, animated: true) + } + + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + (navigationController?.viewControllers.count ?? 0) > 1 + } +} diff --git a/Projects/Feature/Settings/Sources/SettingsViewController.swift b/Projects/Feature/Settings/Sources/SettingsViewController.swift new file mode 100644 index 00000000..a7dc6877 --- /dev/null +++ b/Projects/Feature/Settings/Sources/SettingsViewController.swift @@ -0,0 +1,110 @@ +import DesignSystem +import SnapKit +import UIKit + +final class SettingsViewController: SettingsScreen { + // VC strongly owns the VM; the VM's closures capture the VC weakly. + private let viewModel: SettingsViewModel + private let tableView = UITableView(frame: .zero, style: .grouped) + private var sections: [SettingsViewModel.Section] = [] + + init(viewModel: SettingsViewModel) { + self.viewModel = viewModel + super.init(title: "설정") + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func viewDidLoad() { + super.viewDidLoad() + configureUI() + bind() + viewModel.viewDidLoad() + } + + private func configureUI() { + tableView.backgroundColor = .clear + tableView.separatorStyle = .none + tableView.sectionHeaderTopPadding = 0 + tableView.dataSource = self + tableView.delegate = self + tableView.register(DSListCell.self, forCellReuseIdentifier: DSListCell.reuseIdentifier) + contentView.addSubview(tableView) + tableView.snp.makeConstraints { make in make.edges.equalToSuperview() } + } + + private func bind() { + viewModel.onSectionsChange = { [weak self] sections in + self?.sections = sections + self?.tableView.reloadData() + } + viewModel.onToast = { [weak self] message in + self?.showToast(message) + } + sections = viewModel.sections + } + + private func content(for row: SettingsViewModel.Row) -> DSListCell.Content { + switch row { + case let .homeAddress(subtitle): + DSListCell.Content(leadingIcon: DSIcon.place24, title: "우리집 설정", subtitle: subtitle, accessory: .chevron) + case .privacyPolicy: + DSListCell.Content(title: "개인정보 처리방침", accessory: .chevron) + case .feedback: + DSListCell.Content(title: "피드백 보내기", accessory: .chevron) + case let .version(text, hasUpdate): + DSListCell.Content(title: "현재 버전 \(text)", accessory: hasUpdate ? .value("업데이트") : .none) + case .logout: + DSListCell.Content(title: "로그아웃") + case .withdraw: + DSListCell.Content(title: "계정 탈퇴") + } + } +} + +extension SettingsViewController: UITableViewDataSource, UITableViewDelegate { + func numberOfSections(in tableView: UITableView) -> Int { + sections.count + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + sections[section].rows.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: DSListCell.reuseIdentifier, for: indexPath) + (cell as? DSListCell)?.configure(with: content(for: sections[indexPath.section].rows[indexPath.row])) + return cell + } + + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + sections[section].title.map { DSSectionHeader(title: $0) } + } + + func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + sections[section].title == nil ? DSSpacing.lg : UITableView.automaticDimension + } + + func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { + UIView() + } + + func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { + DSSpacing.sm + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + let row = sections[indexPath.section].rows[indexPath.row] + if row == .logout { + confirm(title: "로그아웃하시겠어요?", confirmTitle: "로그아웃") { [weak self] in + self?.viewModel.logoutConfirmed() + } + return + } + viewModel.didSelect(row) + } +} diff --git a/Projects/Feature/Settings/Sources/SettingsViewModel.swift b/Projects/Feature/Settings/Sources/SettingsViewModel.swift new file mode 100644 index 00000000..2e9553fd --- /dev/null +++ b/Projects/Feature/Settings/Sources/SettingsViewModel.swift @@ -0,0 +1,148 @@ +import Domain +import Foundation + +// Convention: every ViewModel in the codebase is @MainActor. +@MainActor +final class SettingsViewModel { + enum Route: Equatable { + case homeAddress + case withdraw + case externalLink(URL) + } + + enum Row: Equatable { + case homeAddress(subtitle: String) + case privacyPolicy + case feedback + case version(text: String, hasUpdate: Bool) + case logout + case withdraw + } + + struct Section: Equatable { + let title: String? + let rows: [Row] + } + + /// Set by the ViewController; always invoked on the main actor. + var onSectionsChange: (([Section]) -> Void)? + var onToast: ((String) -> Void)? + /// Set by the Coordinator. + var onRoute: ((Route) -> Void)? + + private(set) var sections: [Section] = [] { + didSet { if sections != oldValue { onSectionsChange?(sections) } } + } + + private var addressText = "불러오는 중…" + private var hasUpdate = false + private var isLoggingOut = false + + private let getUserProfileUseCase: any GetUserProfileUseCase + private let logoutUseCase: any LogoutUseCase + private let checkAppUpdateUseCase: (any CheckAppUpdateUseCase)? + private let currentVersion: String + private let appStoreURL: URL? + + private var profileTask: Task? + private var updateTask: Task? + private var logoutTask: Task? + + init( + getUserProfileUseCase: any GetUserProfileUseCase, + logoutUseCase: any LogoutUseCase, + checkAppUpdateUseCase: (any CheckAppUpdateUseCase)? = nil, + currentVersion: String, + appStoreURL: URL? = nil + ) { + self.getUserProfileUseCase = getUserProfileUseCase + self.logoutUseCase = logoutUseCase + self.checkAppUpdateUseCase = checkAppUpdateUseCase + self.currentVersion = currentVersion + self.appStoreURL = appStoreURL + rebuildSections() + } + + deinit { + profileTask?.cancel() + updateTask?.cancel() + logoutTask?.cancel() + } + + func viewDidLoad() { + loadProfile() + checkForUpdate() + } + + /// 주소 변경 화면이 저장에 성공하고 돌아왔을 때 — 서버 값으로 다시 읽는다(캐시 없음). + func homeAddressDidChange() { + onToast?("집 주소가 변경되었어요") + loadProfile() + } + + func didSelect(_ row: Row) { + switch row { + case .homeAddress: onRoute?(.homeAddress) + case .privacyPolicy: onRoute?(.externalLink(SettingsLinks.privacyPolicy)) + case .feedback: onRoute?(.externalLink(SettingsLinks.feedbackForm)) + case .version(_, hasUpdate: true): + if let appStoreURL { onRoute?(.externalLink(appStoreURL)) } + case .version: break + case .withdraw: onRoute?(.withdraw) + // 확인 팝업은 VC 몫 — 확정되면 logoutConfirmed()로 들어온다. + case .logout: break + } + } + + /// 로그인 화면 복귀는 세션 만료 관찰(App)이 한다 — 여기선 중복 탭만 막는다. + func logoutConfirmed() { + guard !isLoggingOut else { return } + isLoggingOut = true + logoutTask = Task { [weak self] in + guard let useCase = self?.logoutUseCase else { return } + await useCase.execute() + } + } + + private func loadProfile() { + profileTask?.cancel() + profileTask = Task { [weak self] in + guard let useCase = self?.getUserProfileUseCase else { return } + let text: String + do { + let profile = try await useCase.execute() + let address = profile.address?.trimmingCharacters(in: .whitespaces) ?? "" + text = address.isEmpty ? "집 주소를 등록해 주세요" : address + } catch { + text = "주소를 불러오지 못했어요" + } + guard !Task.isCancelled else { return } + self?.addressText = text + self?.rebuildSections() + } + } + + private func checkForUpdate() { + guard let checkAppUpdateUseCase else { return } + let currentVersion = currentVersion + updateTask = Task { [weak self] in + // 실패는 무음 — 버전 행은 현재 버전만 보여 준다. + guard case .recommended = try? await checkAppUpdateUseCase.execute(currentVersion: currentVersion), + !Task.isCancelled else { return } + self?.hasUpdate = true + self?.rebuildSections() + } + } + + private func rebuildSections() { + sections = [ + Section(title: "내 정보", rows: [.homeAddress(subtitle: addressText)]), + Section(title: "앱 정보", rows: [ + .privacyPolicy, + .feedback, + .version(text: currentVersion, hasUpdate: hasUpdate), + ]), + Section(title: nil, rows: [.logout, .withdraw]), + ] + } +} diff --git a/Projects/Feature/Settings/Sources/WithdrawViewController.swift b/Projects/Feature/Settings/Sources/WithdrawViewController.swift new file mode 100644 index 00000000..e1ecf9e9 --- /dev/null +++ b/Projects/Feature/Settings/Sources/WithdrawViewController.swift @@ -0,0 +1,125 @@ +import DesignSystem +import SnapKit +import UIKit + +final class WithdrawViewController: SettingsScreen { + // VC strongly owns the VM; the VM's closures capture the VC weakly. + private let viewModel: WithdrawViewModel + private let scrollView = UIScrollView() + private let stack: UIStackView = { + let stack = UIStackView() + stack.axis = .vertical + stack.spacing = DSSpacing.sm + return stack + }() + private let titleLabel: UILabel = { + let label = UILabel() + label.numberOfLines = 0 + label.attributedText = DSTypography.title2.attributed( + "떠나시는 이유를 알려주세요", color: DSColor.Text.primary + ) + return label + }() + private var reasonButtons: [WithdrawViewModel.Reason: UIButton] = [:] + private let otherField = DSTextField(placeholder: "이유를 입력해 주세요") + private let submitButton = DSButton(title: "탈퇴하기") + + init(viewModel: WithdrawViewModel) { + self.viewModel = viewModel + super.init(title: "계정 탈퇴") + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func viewDidLoad() { + super.viewDidLoad() + configureUI() + bind() + } + + private func configureUI() { + scrollView.keyboardDismissMode = .onDrag + [scrollView, submitButton].forEach(contentView.addSubview) + scrollView.addSubview(stack) + submitButton.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(DSSpacing.md) + make.bottom.equalTo(view.keyboardLayoutGuide.snp.top).offset(-DSSpacing.sm) + } + scrollView.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.bottom.equalTo(submitButton.snp.top).offset(-DSSpacing.sm) + } + stack.snp.makeConstraints { make in + make.edges.equalTo(scrollView.contentLayoutGuide).inset(DSSpacing.md) + make.width.equalTo(scrollView.frameLayoutGuide).offset(-DSSpacing.md * 2) + } + + stack.addArrangedSubview(titleLabel) + stack.setCustomSpacing(DSSpacing.lg, after: titleLabel) + for reason in WithdrawViewModel.Reason.allCases { + let button = makeReasonButton(reason) + reasonButtons[reason] = button + stack.addArrangedSubview(button) + } + stack.addArrangedSubview(otherField) + otherField.isHidden = true + otherField.onTextChange = { [weak self] text in + self?.viewModel.otherTextDidChange(text) + } + + submitButton.addAction( + UIAction { [weak self] _ in + self?.confirm(title: "탈퇴하시겠어요?", confirmTitle: "탈퇴하기") { + self?.viewModel.withdrawConfirmed() + } + }, + for: .touchUpInside + ) + } + + private func makeReasonButton(_ reason: WithdrawViewModel.Reason) -> UIButton { + var configuration = UIButton.Configuration.plain() + configuration.title = reason.title + configuration.imagePadding = DSSpacing.sm12 + configuration.contentInsets = NSDirectionalEdgeInsets( + top: DSSpacing.sm12, leading: 0, bottom: DSSpacing.sm12, trailing: 0 + ) + let button = UIButton(configuration: configuration) + button.contentHorizontalAlignment = .leading + button.tintColor = DSColor.Text.primary + button.accessibilityTraits.insert(.button) + button.addAction( + UIAction { [weak self] _ in self?.viewModel.select(reason) }, + for: .touchUpInside + ) + return button + } + + private func bind() { + viewModel.onStateChange = { [weak self] state in + self?.render(state) + } + viewModel.onToast = { [weak self] message in + self?.showToast(message) + } + render(viewModel.state) + } + + private func render(_ state: WithdrawViewModel.State) { + for (reason, button) in reasonButtons { + let isSelected = reason == state.selected + let tint = isSelected ? DSColor.Accent.default : DSColor.Icon.muted + button.configuration?.image = UIImage(systemName: isSelected ? "largecircle.fill.circle" : "circle")? + .withTintColor(tint, renderingMode: .alwaysOriginal) + button.configuration?.baseForegroundColor = DSColor.Text.primary + button.accessibilityTraits = isSelected ? [.button, .selected] : .button + } + if otherField.isHidden == state.isOtherInputVisible { + otherField.isHidden = !state.isOtherInputVisible + } + submitButton.isEnabled = state.canSubmit + } +} diff --git a/Projects/Feature/Settings/Sources/WithdrawViewModel.swift b/Projects/Feature/Settings/Sources/WithdrawViewModel.swift new file mode 100644 index 00000000..20545933 --- /dev/null +++ b/Projects/Feature/Settings/Sources/WithdrawViewModel.swift @@ -0,0 +1,96 @@ +import Domain +import Foundation + +// Convention: every ViewModel in the codebase is @MainActor. +@MainActor +final class WithdrawViewModel { + /// 레거시 WithdrawOption 실측 순서·문구 그대로 — 서버에는 문구 자체가 사유로 저장된다. + enum Reason: CaseIterable, Equatable { + case timeMismatch + case rarelyUse + case frequentErrors + case hardToFind + case hardToUse + case mapAppEnough + case other + + var title: String { + switch self { + case .timeMismatch: "막차 시간이 안맞아요" + case .rarelyUse: "막차를 자주 안 타요" + case .frequentErrors: "잦은 에러를 겪었어요" + case .hardToFind: "막차를 찾기가 번거로워요" + case .hardToUse: "앱 사용법을 모르겠어요" + case .mapAppEnough: "기존에 쓰던 지도 앱으로 충분해요" + case .other: "기타" + } + } + } + + struct State: Equatable { + var selected: Reason? + var isOtherInputVisible: Bool { selected == .other } + var canSubmit = false + var isSubmitting = false + } + + /// Set by the ViewController; always invoked on the main actor. + var onStateChange: ((State) -> Void)? + var onToast: ((String) -> Void)? + + private(set) var state = State() { + didSet { if state != oldValue { onStateChange?(state) } } + } + + private var otherText = "" + private let withdrawUseCase: any WithdrawUseCase + private var submitTask: Task? + + init(withdrawUseCase: any WithdrawUseCase) { + self.withdrawUseCase = withdrawUseCase + } + + deinit { + submitTask?.cancel() + } + + func select(_ reason: Reason) { + state.selected = reason + refreshCanSubmit() + } + + func otherTextDidChange(_ text: String) { + otherText = text + refreshCanSubmit() + } + + /// 확인 팝업을 통과한 뒤에만 호출된다. 성공하면 앱의 세션 만료 관찰이 로그인으로 보낸다. + func withdrawConfirmed() { + guard state.canSubmit, !state.isSubmitting, let reason = reasonText() else { return } + state.isSubmitting = true + refreshCanSubmit() + submitTask = Task { [weak self] in + guard let useCase = self?.withdrawUseCase else { return } + do { + try await useCase.execute(reason: reason) + } catch { + // 실패 시 세션이 보존된다(UseCase 계약) — 같은 화면에서 재시도할 수 있다. + guard !Task.isCancelled else { return } + self?.state.isSubmitting = false + self?.refreshCanSubmit() + self?.onToast?("탈퇴에 실패했어요. 다시 시도해 주세요") + } + } + } + + private func reasonText() -> String? { + guard let selected = state.selected else { return nil } + guard selected == .other else { return selected.title } + let trimmed = otherText.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private func refreshCanSubmit() { + state.canSubmit = !state.isSubmitting && reasonText() != nil + } +} diff --git a/Projects/Feature/Settings/Tests/HomeAddressViewModelTests.swift b/Projects/Feature/Settings/Tests/HomeAddressViewModelTests.swift new file mode 100644 index 00000000..0cde68ee --- /dev/null +++ b/Projects/Feature/Settings/Tests/HomeAddressViewModelTests.swift @@ -0,0 +1,69 @@ +@testable import SettingsFeature +import Domain +import Testing + +@MainActor +struct HomeAddressViewModelTests { + private let place = Place( + name: "서울시청", address: "서울 중구 세종대로 110", + coordinate: Coordinate(latitude: 37.5665, longitude: 126.9780) + ) + + private func makeSUT( + update: SpyUpdateHomeAddressUseCase, + locationFails: Bool = false + ) -> HomeAddressViewModel { + HomeAddressViewModel( + searchPlacesUseCase: StubSearchPlacesUseCase(places: [place]), + getCurrentLocationUseCase: StubGetCurrentLocationUseCase(fails: locationFails), + reverseGeocodeUseCase: StubReverseGeocodeUseCase(), + updateHomeAddressUseCase: update, + debounceInterval: .zero + ) + } + + @Test + func search_thenSelect_savesAddress() async { + let update = SpyUpdateHomeAddressUseCase() + let sut = makeSUT(update: update) + var savedCount = 0 + sut.onSaved = { savedCount += 1 } + + sut.keywordDidChange("시청") + await waitUntil { if case .places = sut.state { true } else { false } } + sut.didSelectPlace(at: 0) + await waitUntil { savedCount == 1 } + + #expect(update.addresses == ["서울 중구 세종대로 110"]) + #expect(savedCount == 1) + } + + @Test + func outOfServiceRegion_showsLegacyToast() async { + let update = SpyUpdateHomeAddressUseCase() + update.error = UpdateHomeAddressError.outOfServiceRegion + let sut = makeSUT(update: update) + var toasts: [String] = [] + sut.onToast = { toasts.append($0) } + + sut.useCurrentLocationTapped() + await waitUntil { !toasts.isEmpty } + + #expect(toasts == ["앗차는 현재 서울, 경기, 인천에서만 이용 가능해요"]) + #expect(sut.isSaving == false) + } + + @Test + func currentLocationFailure_toastsWithoutSaving() async { + let update = SpyUpdateHomeAddressUseCase() + let sut = makeSUT(update: update, locationFails: true) + var toasts: [String] = [] + sut.onToast = { toasts.append($0) } + + sut.useCurrentLocationTapped() + await waitUntil { !toasts.isEmpty } + + #expect(update.addresses.isEmpty) + #expect(toasts == ["현재 위치를 확인하지 못했어요. 잠시 후 다시 시도해 주세요"]) + } +} diff --git a/Projects/Feature/Settings/Tests/SettingsFeatureStubs.swift b/Projects/Feature/Settings/Tests/SettingsFeatureStubs.swift new file mode 100644 index 00000000..7afb7026 --- /dev/null +++ b/Projects/Feature/Settings/Tests/SettingsFeatureStubs.swift @@ -0,0 +1,88 @@ +import Domain +import Foundation +import Synchronization + +struct StubFailure: Error {} + +/// 테스트 드레인 — 스텁이 즉시 resolve하므로 yield로 충분하다(Home 템플릿 패턴). +@MainActor +func waitUntil(_ predicate: () -> Bool) async { + for _ in 0..<1000 where !predicate() { + await Task.yield() + } +} + +final class StubGetUserProfileUseCase: GetUserProfileUseCase { + let address: String? + let fails: Bool + + init(address: String? = nil, fails: Bool = false) { + self.address = address + self.fails = fails + } + + func execute() async throws -> UserProfile { + if fails { throw StubFailure() } + return UserProfile(userID: 1, providerID: nil, nickname: nil, address: address, coordinate: nil, appVersion: nil) + } +} + +final class SpyLogoutUseCase: LogoutUseCase { + private let count = Mutex(0) + var callCount: Int { count.withLock { $0 } } + func execute() async { count.withLock { $0 += 1 } } +} + +final class SpyWithdrawUseCase: WithdrawUseCase { + private let log = Mutex<[String?]>([]) + private let failing = Mutex(false) + var reasons: [String?] { log.withLock { $0 } } + var fails: Bool { + get { failing.withLock { $0 } } + set { failing.withLock { $0 = newValue } } + } + + func execute(reason: String?) async throws { + log.withLock { $0.append(reason) } + if fails { throw StubFailure() } + } +} + +struct StubCheckAppUpdateUseCase: CheckAppUpdateUseCase { + let status: AppUpdateStatus + func execute(currentVersion: String) async throws -> AppUpdateStatus { status } +} + +struct StubSearchPlacesUseCase: SearchPlacesUseCase { + var places: [Place] = [] + func execute(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { places } +} + +struct StubGetCurrentLocationUseCase: GetCurrentLocationUseCase { + var fails = false + func execute() async throws -> Coordinate { + if fails { throw StubFailure() } + return Coordinate(latitude: 37.5665, longitude: 126.9780) + } +} + +struct StubReverseGeocodeUseCase: ReverseGeocodeUseCase { + func execute(coordinate: Coordinate) async throws -> Place { + Place(name: "서울시청", address: "서울 중구 세종대로 110", coordinate: coordinate) + } +} + +final class SpyUpdateHomeAddressUseCase: UpdateHomeAddressUseCase { + private let log = Mutex<[String?]>([]) + private let stored = Mutex<(any Error)?>(nil) + var addresses: [String?] { log.withLock { $0 } } + var error: (any Error)? { + get { stored.withLock { $0 } } + set { stored.withLock { $0 = newValue } } + } + + func execute(address: String?, coordinate: Coordinate) async throws { + log.withLock { $0.append(address) } + if let error { throw error } + } +} diff --git a/Projects/Feature/Settings/Tests/SettingsViewModelTests.swift b/Projects/Feature/Settings/Tests/SettingsViewModelTests.swift new file mode 100644 index 00000000..929b9583 --- /dev/null +++ b/Projects/Feature/Settings/Tests/SettingsViewModelTests.swift @@ -0,0 +1,81 @@ +@testable import SettingsFeature +import Domain +import Foundation +import Testing + +@MainActor +struct SettingsViewModelTests { + private func makeSUT( + address: String? = "서울 중구 세종대로 110", + profileFails: Bool = false, + update: AppUpdateStatus = .upToDate, + logout: SpyLogoutUseCase = SpyLogoutUseCase() + ) -> SettingsViewModel { + SettingsViewModel( + getUserProfileUseCase: StubGetUserProfileUseCase(address: address, fails: profileFails), + logoutUseCase: logout, + checkAppUpdateUseCase: StubCheckAppUpdateUseCase(status: update), + currentVersion: "2.0.0", + appStoreURL: URL(string: "itms-apps://example") + ) + } + + private func homeSubtitle(_ sut: SettingsViewModel) -> String? { + guard case let .homeAddress(subtitle) = sut.sections.first?.rows.first else { return nil } + return subtitle + } + + @Test + func viewDidLoad_showsServerAddress() async { + let sut = makeSUT() + sut.viewDidLoad() + await waitUntil { homeSubtitle(sut) == "서울 중구 세종대로 110" } + #expect(homeSubtitle(sut) == "서울 중구 세종대로 110") + } + + @Test + func viewDidLoad_profileFailure_showsFailureCopy() async { + let sut = makeSUT(profileFails: true) + sut.viewDidLoad() + await waitUntil { homeSubtitle(sut) == "주소를 불러오지 못했어요" } + #expect(homeSubtitle(sut) == "주소를 불러오지 못했어요") + } + + @Test + func updateAvailable_versionRowRoutesToStore() async { + let sut = makeSUT(update: .recommended(latest: "2.1.0")) + var routes: [SettingsViewModel.Route] = [] + sut.onRoute = { routes.append($0) } + sut.viewDidLoad() + let updatedRow = SettingsViewModel.Row.version(text: "2.0.0", hasUpdate: true) + await waitUntil { sut.sections.contains { $0.rows.contains(updatedRow) } } + + sut.didSelect(updatedRow) + + #expect(routes == [.externalLink(URL(string: "itms-apps://example")!)]) + } + + /// 중복 탭이 로그아웃을 두 번 보내지 않는다. + @Test + func logoutConfirmed_twice_executesOnce() async { + let logout = SpyLogoutUseCase() + let sut = makeSUT(logout: logout) + + sut.logoutConfirmed() + sut.logoutConfirmed() + await waitUntil { logout.callCount == 1 } + + #expect(logout.callCount == 1) + } + + @Test + func homeAddressDidChange_toastsAndReloads() { + let sut = makeSUT() + var toasts: [String] = [] + sut.onToast = { toasts.append($0) } + + sut.homeAddressDidChange() + + #expect(toasts == ["집 주소가 변경되었어요"]) + } +} diff --git a/Projects/Feature/Settings/Tests/WithdrawViewModelTests.swift b/Projects/Feature/Settings/Tests/WithdrawViewModelTests.swift new file mode 100644 index 00000000..ca9a6a10 --- /dev/null +++ b/Projects/Feature/Settings/Tests/WithdrawViewModelTests.swift @@ -0,0 +1,58 @@ +@testable import SettingsFeature +import Testing + +@MainActor +struct WithdrawViewModelTests { + @Test + func noSelection_cannotSubmit() { + let sut = WithdrawViewModel(withdrawUseCase: SpyWithdrawUseCase()) + #expect(sut.state.canSubmit == false) + } + + @Test + func presetReason_sendsTitleAsReason() async { + let useCase = SpyWithdrawUseCase() + let sut = WithdrawViewModel(withdrawUseCase: useCase) + + sut.select(.rarelyUse) + sut.withdrawConfirmed() + await waitUntil { useCase.reasons.count == 1 } + + #expect(useCase.reasons == ["막차를 자주 안 타요"]) + } + + /// 기타는 공백만 입력하면 제출할 수 없다(레거시 규칙). + @Test + func otherReason_requiresNonBlankText() async { + let useCase = SpyWithdrawUseCase() + let sut = WithdrawViewModel(withdrawUseCase: useCase) + + sut.select(.other) + sut.otherTextDidChange(" ") + #expect(sut.state.canSubmit == false) + + sut.otherTextDidChange(" 알림이 늦어요 ") + #expect(sut.state.canSubmit) + sut.withdrawConfirmed() + await waitUntil { useCase.reasons.count == 1 } + + #expect(useCase.reasons == ["알림이 늦어요"]) + } + + @Test + func failure_toastsAndAllowsRetry() async { + let useCase = SpyWithdrawUseCase() + useCase.fails = true + let sut = WithdrawViewModel(withdrawUseCase: useCase) + var toasts: [String] = [] + sut.onToast = { toasts.append($0) } + + sut.select(.timeMismatch) + sut.withdrawConfirmed() + await waitUntil { !toasts.isEmpty } + + #expect(toasts == ["탈퇴에 실패했어요. 다시 시도해 주세요"]) + #expect(sut.state.canSubmit) + #expect(sut.state.isSubmitting == false) + } +} From 3c10b896484ac592e9d616f2eaeb43db408c7df8 Mon Sep 17 00:00:00 2001 From: AppGoni Date: Thu, 24 Sep 2026 12:01:04 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20App=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=E2=80=94=20=EC=84=B8=EC=85=98=20=EC=A2=85=EB=A3=8C=20=EC=95=8C?= =?UTF-8?q?=EB=9E=8C=20=EC=A0=95=EB=A6=AC=C2=B7=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EB=B0=B0=EC=84=A0=C2=B7=EC=95=B1=20=EB=B2=84=EC=A0=84=20?= =?UTF-8?q?=EC=B2=B4=ED=81=AC=C2=B7FCM=20=ED=86=A0=ED=81=B0=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0=20=EC=A0=84=EB=8B=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 로그아웃·탈퇴·강제 만료 공통 합류점(handleSessionExpiry)에서 AlarmKit·LA·스냅샷·동기화 상태 정리 (레거시의 로그아웃 후 알람 방치 버그 미이식) - 스플래시와 병렬 버전 체크: 1.5초 예산·실패 시 통과, /app/version public path - FCM 토큰 갱신·로그인 직후 동기화 배선 — 서버 API 미확정이라 no-op 저장소 주입 - CLAUDE.md·로드맵 서버 질문(#10~13) 갱신 Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 7 +- Projects/App/Project.swift | 2 + .../AlarmSessionTeardownAdapter.swift | 46 ++++++++++++ .../UnconfirmedPushTokenRepository.swift | 12 +++ Projects/App/Sources/AlarmSyncService.swift | 18 +++++ Projects/App/Sources/AppCoordinator.swift | 42 +++++++++++ Projects/App/Sources/AppDIContainer.swift | 73 ++++++++++++++++++- Projects/App/Sources/AppDelegate.swift | 8 +- Projects/App/Sources/AppEnvironment.swift | 5 ++ Workspace.swift | 1 + docs/planning/atcha-v2-post12-roadmap.md | 6 +- 11 files changed, 211 insertions(+), 9 deletions(-) create mode 100644 Projects/App/Sources/Adapters/AlarmSessionTeardownAdapter.swift create mode 100644 Projects/App/Sources/Adapters/UnconfirmedPushTokenRepository.swift diff --git a/CLAUDE.md b/CLAUDE.md index 4d447ec0..8b36e088 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,8 +47,9 @@ xcodebuild -workspace Atcha.xcworkspace -scheme Atcha-Dev -configuration Debug \ ## 아키텍처 (AtchaV2 — uFeatures + 클린아키텍처) ``` -AtchaV2(앱, 조합 루트: 어댑터·스플래시·AlarmSyncService) ─► HomeFeature ─► {HomeFeatureInterface, SearchFeatureInterface, Domain, DesignSystem, CoreCoordinator, SnapKit} +AtchaV2(앱, 조합 루트: 어댑터·스플래시·AlarmSyncService) ─► HomeFeature ─► {HomeFeatureInterface, SearchFeatureInterface, SettingsFeatureInterface, Domain, DesignSystem, CoreCoordinator, SnapKit} ├─► SearchFeature ─► {SearchFeatureInterface, Domain, DesignSystem, CoreCoordinator, SnapKit} + ├─► SettingsFeature ─► {SettingsFeatureInterface, Domain, DesignSystem, CoreCoordinator, SnapKit} ├─► AtchaData ─► {Domain, CoreNetwork, CoreStorage} ├─► CoreAuth ─► {CoreNetwork, CoreStorage} ├─► CoreAlarm (무의존 — AlarmKit 유일 import 지점, App만 import) @@ -79,9 +80,9 @@ AtchaV2(앱, 조합 루트: 어댑터·스플래시·AlarmSyncService) ─► Ho ### 미완 상태 (작업 시 참고) -- **인증은 소셜 로그인(카카오·애플), 가입까지 완결** — 강제 로그인(게스트 없음)으로 `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에서도 그대로 표면화된다. +- **인증은 소셜 로그인(카카오·애플), 가입까지 완결** — 강제 로그인(게스트 없음)으로 `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`)은 `SettingsFeature`(홈 상단 톱니바퀴 진입: 우리집 설정·약관·피드백·버전·로그아웃·계정 탈퇴)로 연결됐다. 알림 빈도는 레거시처럼 미노출. 로그아웃·탈퇴·강제 만료는 전부 `AppCoordinator.handleSessionExpiry`에 합류하고 거기서 `AlarmSessionTeardown`이 로컬 알람·LA·스냅샷·동기화 상태를 비운다(로그아웃은 토큰이 살아 있을 때 서버 알람 취소를 먼저 시도). 홈주소 변경은 `GET /locations/is-service-region` 통과 후 PATCH. 앱 버전은 `GET /app/version`(무토큰, 1.5초 예산·실패 시 통과)으로 권장 업데이트 팝업만. 카카오 키는 `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(수동)뿐. +- `Projects/App/Resources/GoogleService-Info.plist`는 **레거시 번들 ID(`com.atcha.iOS`)용 파일**이라 존재 가드만 통과할 뿐 V2(`com.atcha.iOS.v2`)로의 사일런트 푸시가 성립하지 않는다 — V2용 재발급·교체 필요. FCM 토큰은 로그인/가입 파라미터로만 서버에 가고, 갱신 전달은 `SyncPushTokenUseCase`까지 배선됐지만 서버 API 미확정이라 `UnconfirmedPushTokenRepository`(no-op)가 주입돼 있다. 그래서 갱신 채널은 현재 폴링(앱 시작·포그라운드 복귀)과 홈 pull-to-refresh(수동)뿐. - AtchaV2는 iOS 26 전용. AlarmKit(CoreAlarm)·Live Activity(CoreLiveActivity + AtchaWidget 익스텐션)는 Phase 9~12에서 구축 완료. - Phase 12 이후의 갭 분석·후속 로드맵: `docs/planning/atcha-v2-post12-roadmap.md` / Phase 13·14(알람 이후 + 재실행 정합성) 구현 프롬프트: `docs/prompts/atcha-v2-session-lifecycle-prompt.md`. - Phase 검수는 사람 검수 대신 **자동 검수 규약**(`docs/prompts/atcha-v2-auto-verification.md`)을 따른다 — 에이전트가 computer use로 시뮬레이터 검수를 직접 수행·증적 보고하고, 실기기 잔여 항목만 사용자에게 이관. diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift index a14f5c86..382a0bf6 100644 --- a/Projects/App/Project.swift +++ b/Projects/App/Project.swift @@ -65,6 +65,8 @@ let appTarget = Target.target( .project(target: "HomeFeatureInterface", path: "../Feature/Home"), .project(target: "SearchFeature", path: "../Feature/Search"), .project(target: "SearchFeatureInterface", path: "../Feature/Search"), + .project(target: "SettingsFeature", path: "../Feature/Settings"), + .project(target: "SettingsFeatureInterface", path: "../Feature/Settings"), .project(target: "Domain", path: "../Domain"), .project(target: "AtchaData", path: "../Data"), .project(target: "CoreNetwork", path: "../Core/Network"), diff --git a/Projects/App/Sources/Adapters/AlarmSessionTeardownAdapter.swift b/Projects/App/Sources/Adapters/AlarmSessionTeardownAdapter.swift new file mode 100644 index 00000000..85eb2e58 --- /dev/null +++ b/Projects/App/Sources/Adapters/AlarmSessionTeardownAdapter.swift @@ -0,0 +1,46 @@ +import Domain +import Foundation +import os + +/// 계정 세션 종료 시 알람 세션 정리 — 조합 루트가 공유 인스턴스(스케줄러·LA·스냅샷·동기화)를 +/// 그대로 넘긴다. 등록/취소 UseCase와 다른 인스턴스를 보면 정리가 헛돈다. +struct AlarmSessionTeardownAdapter: AlarmSessionTeardown { + let alarmRepository: any AlarmRepository + let scheduler: any AlarmScheduler + let activityPort: any LastTrainActivityPort + let snapshotStore: any AlarmSessionSnapshotStore + let syncService: AlarmSyncService + private static let logger = Logger(subsystem: "com.atcha.iOS.v2", category: "AlarmTeardown") + + func tearDown(cancelOnServer: Bool) async { + if cancelOnServer, let routeId = await activeRouteId() { + // 베스트 에포트 — 오프라인이어도 로그아웃을 막지 않는다. 서버에 남은 세션은 + // 다음 로그인의 refresh가 정본으로 되돌려 준다(같은 계정일 때만 의미가 있다). + do { + try await alarmRepository.cancel(lastRouteId: routeId) + } catch { + Self.logger.error("로그아웃 서버 알람 취소 실패(무시): \(error.localizedDescription, privacy: .public)") + } + } + // 더는 울리면 안 되는 것이 핵심 — 표출 정리(LA)보다 알람 취소를 먼저 한다. + await scheduler.cancelAlarm() + await snapshotStore.clear() + let now = Date() + await activityPort.end(final: LastTrainActivityState( + departureTime: now, + alarmTime: now, + urgency: .relaxed, + changeBadgeExpiry: nil, + phase: .active + )) + await syncService.resetForSignOut() + } + + /// 만료 톰스톤은 이미 죽은 세션이라 서버 취소 대상이 아니다. + private func activeRouteId() async -> String? { + if let snapshot = await snapshotStore.load(), !snapshot.expired { + return snapshot.info.lastRouteId + } + return await syncService.currentSession?.lastRouteId + } +} diff --git a/Projects/App/Sources/Adapters/UnconfirmedPushTokenRepository.swift b/Projects/App/Sources/Adapters/UnconfirmedPushTokenRepository.swift new file mode 100644 index 00000000..cc7ca185 --- /dev/null +++ b/Projects/App/Sources/Adapters/UnconfirmedPushTokenRepository.swift @@ -0,0 +1,12 @@ +import Domain +import os + +/// FCM 토큰 갱신 API가 서버에서 확정되기 전(미확정 #5)의 자리표시 — 호출 지점과 중복 방지는 +/// 지금 배선해 두고, 경로가 정해지면 AtchaData의 실구현으로 이 타입만 교체한다. +struct UnconfirmedPushTokenRepository: PushTokenRepository { + private static let logger = Logger(subsystem: "com.atcha.iOS.v2", category: "FCM") + + func register(token: String) async throws { + Self.logger.info("FCM 토큰 서버 전달 보류(API 미확정): \(token, privacy: .private)") + } +} diff --git a/Projects/App/Sources/AlarmSyncService.swift b/Projects/App/Sources/AlarmSyncService.swift index 4a422e14..bb29f05e 100644 --- a/Projects/App/Sources/AlarmSyncService.swift +++ b/Projects/App/Sources/AlarmSyncService.swift @@ -136,6 +136,24 @@ final class AlarmSyncService: AlarmSyncEvents, AlarmChangeEvents, AlarmSyncReque await sync() != nil ? .newData : .failed } + // MARK: - 계정 세션 종료 (로그아웃·탈퇴·만료) + + /// 현재 보유 세션 — 로그아웃 시 서버 취소 대상 routeId의 출처(스냅샷 부재 대비). + var currentSession: AlarmInfo? { lastInfo } + + /// 계정이 바뀌면 이전 계정의 세션 기억이 새 계정의 diff·만료 판정을 오염시킨다 — + /// 메모리 상태를 전부 비우고, 다음 로그인의 첫 sync가 (이미 비워진) 스냅샷부터 다시 시딩한다. + func resetForSignOut() { + inFlight?.cancel() + inFlight = nil + lastInfo = nil + lastCheckedAt = nil + locallyExpiredSession = nil + sessionWalkSeconds = nil + changeBadgeExpiry = nil + isSeededFromSnapshot = false + } + // MARK: - AlarmSyncRequesting (Phase 16 — 홈 pull-to-refresh) /// 수동 갱신 트리거(4번째) — 기존 sync()에 그대로 합류한다. 실패는 던지지 않고 diff --git a/Projects/App/Sources/AppCoordinator.swift b/Projects/App/Sources/AppCoordinator.swift index fcd4fad8..9769b497 100644 --- a/Projects/App/Sources/AppCoordinator.swift +++ b/Projects/App/Sources/AppCoordinator.swift @@ -33,6 +33,32 @@ final class AppCoordinator: Coordinator, CoordinatorFinishDelegate { navigationController.setViewControllers([splash], animated: false) observeSessionExpiry() bootstrap() + checkForAppUpdate() + } + + /// 부트스트랩과 병렬 — 결과가 늦거나 실패하면 아무 일도 없다(레거시 스플래시 정지 버그 방지). + private func checkForAppUpdate() { + let container = container + Task { [weak self] in + guard case .recommended = await container.checkForAppUpdate() else { return } + self?.presentUpdateRecommendation() + } + } + + private func presentUpdateRecommendation() { + let alert = UIAlertController( + title: nil, + message: "더 좋아진 앗차를 사용하기 위해\n업데이트를 권장해요", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "나중에", style: .cancel)) + alert.addAction(UIAlertAction(title: "업데이트", style: .default) { _ in + UIApplication.shared.open(AppEnvironment.current.appStoreURL) + }) + // 로그인 시트가 떠 있으면 그 위에 — 가장 위의 presented에 얹는다. + var top: UIViewController = navigationController + while let presented = top.presentedViewController { top = presented } + top.present(alert, animated: true) } private func bootstrap() { @@ -60,6 +86,7 @@ final class AppCoordinator: Coordinator, CoordinatorFinishDelegate { self.isShowingLogin = false self.startHome() self.container.alarmSyncService.activate() + self.syncPushTokenAfterLogin() if self.needsSyncAfterLogin { self.needsSyncAfterLogin = false let syncService = self.container.alarmSyncService @@ -87,6 +114,12 @@ final class AppCoordinator: Coordinator, CoordinatorFinishDelegate { private func handleSessionExpiry() { guard !isShowingLogin else { return } needsSyncAfterLogin = true + // 로그아웃·탈퇴·강제 만료 공통 합류점 — 이전 계정의 알람이 로그인 화면에서 울리지 않게 + // 로컬 정리를 여기서 한 번 더 보장한다(로그아웃 경로의 선행 정리와 겹쳐도 멱등). + let teardown = container.alarmSessionTeardown + Task { await teardown.tearDown(cancelOnServer: false) } + // 다음 계정에는 같은 FCM 토큰이라도 다시 전달해야 한다. + container.syncPushTokenUseCase.reset() navigationController.presentedViewController?.dismiss(animated: false) // setViewControllers가 didShow를 태워 SearchCoordinator류의 정리 경로도 돈다. let splash = SplashViewController() @@ -97,6 +130,15 @@ final class AppCoordinator: Coordinator, CoordinatorFinishDelegate { showLogin() } + /// 로그인 중에 토큰이 갱신됐을 수 있다 — 세션이 생긴 직후 현재 토큰을 한 번 맞춘다. + private func syncPushTokenAfterLogin() { + let syncPushToken = container.syncPushTokenUseCase + Task { + guard let token = await FCMPushTokenAdapter().currentPushToken() else { return } + await syncPushToken.execute(token: token) + } + } + 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 95707684..6c5a2b41 100644 --- a/Projects/App/Sources/AppDIContainer.swift +++ b/Projects/App/Sources/AppDIContainer.swift @@ -11,6 +11,8 @@ import HomeFeature import HomeFeatureInterface import SearchFeature import SearchFeatureInterface +import SettingsFeature +import SettingsFeatureInterface /// Composition root — the only place that sees concrete Data/Network types. /// Presentation modules depend on Domain protocols only. @@ -44,6 +46,9 @@ final class AppDIContainer { /// 노티 탭 라우팅 델리게이트(Phase 15) — AppDelegate가 launch 시 등록하고, /// SceneDelegate가 홈 랜딩 훅(onTap)을 배선한다. let notificationTapDelegate = NotificationTapRoutingDelegate() + /// FCM 토큰 갱신 전달 — 프로세스 내 중복 방지 상태를 갖으므로 1회 생성 공유한다. + let syncPushTokenUseCase: any SyncPushTokenUseCase = + DefaultSyncPushTokenUseCase(repository: UnconfirmedPushTokenRepository()) #if DEV /// DEV 플로팅 디버그 메뉴가 dismiss 기록 강제 토글에 접근하는 유일한 통로 (Phase 12 검수). var devLiveActivityAdapter: LastTrainLiveActivityAdapter { liveActivityAdapter } @@ -75,6 +80,8 @@ final class AppDIContainer { // 조용히 덮이지 않도록 이중 방어로 전부 public 처리한다(레거시 allowlist 대응). publicPathSuffixes: [ "/auth/reissue", "/auth/check", "/auth/login", "/auth/sign-up", "/auth/logout", + // 로그인 전 스플래시에서 호출된다 — 토큰이 없어 401 복구가 로그인 라우팅을 촉발하면 안 된다. + "/app/version", ] ) self.networkClient = networkClient @@ -154,7 +161,7 @@ final class AppDIContainer { ) } - // MARK: - 계정 스택 (설정 화면 前 단계 — 로그아웃은 DEV 디버그 메뉴가 유일한 진입점) + // MARK: - 계정 스택 (설정 화면 + DEV 디버그 메뉴의 로그아웃) private var userRepository: any UserRepository { UserRepositoryImpl(networkClient: networkClient) @@ -164,14 +171,71 @@ final class AppDIContainer { AuthSessionEndingAdapter(sessionManager: authSessionManager) } + /// 세션 종료 시 알람 정리 — 등록/취소 UseCase와 같은 공유 인스턴스를 봐야 한다. + var alarmSessionTeardown: any AlarmSessionTeardown { + AlarmSessionTeardownAdapter( + alarmRepository: alarmRepository, + scheduler: alarmScheduler, + activityPort: liveActivityPort, + snapshotStore: alarmSessionSnapshotStore, + syncService: alarmSyncService + ) + } + func makeLogoutUseCase() -> any LogoutUseCase { - DefaultLogoutUseCase(sessionEnding: sessionEnding) + DefaultLogoutUseCase(sessionEnding: sessionEnding, alarmTeardown: alarmSessionTeardown) } func makeWithdrawUseCase() -> any WithdrawUseCase { DefaultWithdrawUseCase(userRepository: userRepository, sessionEnding: sessionEnding) } + func makeSettingsDIContainer( + getCurrentLocationUseCase: any GetCurrentLocationUseCase + ) -> any SettingsCoordinatorBuildable { + SettingsDIContainer( + getUserProfileUseCase: DefaultGetUserProfileUseCase(userRepository: userRepository), + logoutUseCase: makeLogoutUseCase(), + withdrawUseCase: makeWithdrawUseCase(), + updateHomeAddressUseCase: DefaultUpdateHomeAddressUseCase( + userRepository: userRepository, + placeRepository: placeRepository + ), + searchPlacesUseCase: DefaultSearchPlacesUseCase(repository: placeRepository), + getCurrentLocationUseCase: getCurrentLocationUseCase, + reverseGeocodeUseCase: DefaultReverseGeocodeUseCase(repository: placeRepository), + checkAppUpdateUseCase: DefaultCheckAppUpdateUseCase( + repository: AppVersionRepositoryImpl(networkClient: networkClient) + ), + currentVersion: Self.currentAppVersion ?? "-", + appStoreURL: AppEnvironment.current.appStoreURL + ) + } + + // MARK: - 앱 버전 + + private static var currentAppVersion: String? { + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String + } + + /// 스플래시를 절대 막지 않는다 — 1.5초 안에 답이 없거나 실패하면 nil(체크 생략). + func checkForAppUpdate() async -> AppUpdateStatus? { + let useCase = DefaultCheckAppUpdateUseCase( + repository: AppVersionRepositoryImpl(networkClient: networkClient) + ) + guard let current = Self.currentAppVersion else { return nil } + return await withTaskGroup(of: AppUpdateStatus?.self) { group in + group.addTask { try? await useCase.execute(currentVersion: current) } + group.addTask { + try? await Task.sleep(for: .milliseconds(1500)) + return nil + } + let first = await group.next() ?? nil + group.cancelAll() + return first + } + } + func makeHomeDIContainer() -> any HomeCoordinatorBuildable { let recentSearchRepository = RecentSearchRepositoryImpl(store: UserDefaultsKeyValueStore()) let locationService = CoreLocationServiceAdapter() @@ -220,7 +284,10 @@ final class AppDIContainer { // 원탭 칩(Phase 18) — 검색 화면과 같은 인스턴스 공유(위 주석 참조). searchLastRoutesUseCase: searchLastRoutes, recentSearchesUseCase: recentSearches, - searchCoordinatorBuildable: searchContainer + searchCoordinatorBuildable: searchContainer, + settingsCoordinatorBuildable: makeSettingsDIContainer( + getCurrentLocationUseCase: getCurrentLocation + ) ) } } diff --git a/Projects/App/Sources/AppDelegate.swift b/Projects/App/Sources/AppDelegate.swift index 60952d65..f98eceaa 100644 --- a/Projects/App/Sources/AppDelegate.swift +++ b/Projects/App/Sources/AppDelegate.swift @@ -92,7 +92,11 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { extension AppDelegate: MessagingDelegate { nonisolated func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { - // TODO: [미확정 #5] 익명 체계에서의 FCM 토큰 서버 전달 방식 확정 전까지 로깅만 한다. - Self.fcmLogger.info("FCM 토큰 수신(서버 전달 보류): \(fcmToken ?? "nil", privacy: .private)") + guard let fcmToken else { return } + Task { @MainActor in + // 로그인 전 토큰은 로그인/가입 파라미터가 싣는다 — 여기선 세션이 있을 때의 갱신만. + guard container.authSessionManager.bootstrapState() == .active else { return } + await container.syncPushTokenUseCase.execute(token: fcmToken) + } } } diff --git a/Projects/App/Sources/AppEnvironment.swift b/Projects/App/Sources/AppEnvironment.swift index 2b6a9d66..c1d40d98 100644 --- a/Projects/App/Sources/AppEnvironment.swift +++ b/Projects/App/Sources/AppEnvironment.swift @@ -28,4 +28,9 @@ enum AppEnvironment { case .live: URL(string: "https://atcha.online")! } } + + // TODO: [서버 확인] V2가 레거시 스토어 앱(id6747877903)을 잇는지 확정 — 별도 앱이면 교체. + var appStoreURL: URL { + URL(string: "itms-apps://itunes.apple.com/app/id6747877903")! + } } diff --git a/Workspace.swift b/Workspace.swift index 9ca45f99..a8cfd843 100644 --- a/Workspace.swift +++ b/Workspace.swift @@ -7,6 +7,7 @@ let workspace = Workspace( "Projects/Feature/Auth", "Projects/Feature/Home", "Projects/Feature/Search", + "Projects/Feature/Settings", "Projects/Domain", "Projects/Data", "Projects/Core/Network", diff --git a/docs/planning/atcha-v2-post12-roadmap.md b/docs/planning/atcha-v2-post12-roadmap.md index 94bc76a2..9a42ad06 100644 --- a/docs/planning/atcha-v2-post12-roadmap.md +++ b/docs/planning/atcha-v2-post12-roadmap.md @@ -48,6 +48,10 @@ Phase 1~12로 "검색 → 알람 등록 → 변경 인지"의 전반부는 완 7. "다음 운행 안내"(내일 첫차 등) 데이터를 줄 수 있나 / 심야버스 대안 경로는? 8. Stage 전용 호스트 계획 (현재 dev 호스트 공유) 9. (확인용) 익명 세션은 기기 단위인가 — 다중 기기 동시 알람 케이스가 실존하나 +10. (2026-09 설정 연동) `GET /app/version` 응답 형식 — 최신 버전 문자열만인가, `minSupportedVersion`/`forceUpdate`를 줄 수 있나 (없으면 강제 업데이트 불가, 권장만) +11. (2026-09 설정 연동) `GET /locations/is-service-region` 응답이 envelope `result: Bool`인가 +12. (2026-09 설정 연동) V2가 레거시 스토어 앱(id6747877903)을 잇는가 — 별도 앱이면 App Store ID +13. (2026-09 설정 연동) 탈퇴 응답 유실 후 재호출 시 서버 응답(401/404?) — 로컬 무효화로 수렴시킬지 판단용 --- @@ -61,7 +65,7 @@ Phase 1~12로 "검색 → 알람 등록 → 변경 인지"의 전반부는 완 | 2 | 익명 인증 발급 엔드포인트 | **대체 해결 (2026-09)** — 소셜 로그인(카카오·애플)이 최초 토큰 소스. 익명 발급 스펙 질문은 철회 | | 3 | responseCode 실측 | **미해결 (S3)** — `.noRoute` 도달 불가 상태 | | 4 | 단일 알람 규약 | 미해결 — 클라 refresh→cancel→register 우회 동작 중 | -| 5 | FCM 토큰 전달 | **미해결 (S2)** — 로깅만 | +| 5 | FCM 토큰 전달 | **부분 (2026-09)** — 로그인/가입 파라미터 전달 + 갱신 전달 UseCase·호출 지점 배선 완료, 서버 API 미확정이라 no-op 저장소(`UnconfirmedPushTokenRepository`) 주입 중. 질문 #2 | | 6·10 | GoogleService-Info.plist | **잘못 해결 (S2)** — 레거시 번들 ID plist가 커밋돼 가드 무력화. 재발급·교체 필요 | | 7 | 알람 기준 시각 서버 필드 | 미해결·타협(균일 −3분, 도보 미반영) → **Phase 14 클라 임시안이 해소** (등록 시점 walk leg 저장). 서버 필드 확정 시 대체 | | 8 | 알림 권한 요청 시점 | 임시 동작 유지 (알람 등록 성공 직후) — 확정만 남음 |