diff --git a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/Project+InfoPlist.swift b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/Project+InfoPlist.swift index 7951818..ce46c21 100644 --- a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/Project+InfoPlist.swift +++ b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/Project+InfoPlist.swift @@ -40,6 +40,7 @@ public extension InfoPlist { ]) .setUIRequiredDeviceCapabilities(["armv7"]) .setCFBundleDevelopmentRegion() + .setUISupportedInterfaceOrientations(["UIInterfaceOrientationPortrait"]) .setBaseURL("$(BASE_URL)") .setNMFGovClientId("$(NMFGovClientId)") .setNMFGovClientSecret("$(NMFGovClientSecret)") @@ -47,7 +48,6 @@ public extension InfoPlist { .setGoogleClientID("${GOOGLE_CLIENT_ID}") .setGoogleClientiOSID("${GOOGLE_IOS_CLIENT_ID}") .setGIDClientID("${GOOGLE_CLIENT_ID}") - .setUILaunchScreens() .setLocationPermissions() diff --git a/Projects/App/Sources/Di/DiRegister.swift b/Projects/App/Sources/Di/DiRegister.swift index 0737217..dd2904f 100644 --- a/Projects/App/Sources/Di/DiRegister.swift +++ b/Projects/App/Sources/Di/DiRegister.swift @@ -41,7 +41,9 @@ public final class AppDIManager { .register { AppleOAuthRepositoryImpl() as AppleOAuthInterface } .register { AppleOAuthProvider() as AppleOAuthProviderInterface } // MARK: - 회원가입 - .register { SignUpRepositoryImpl() as SignUpInterface } + .register { SignUpRepositoryImpl() as SignUpInterface } + // MARK: - 프로필 + .register(ProfileInterface.self) { ProfileRepositoryImpl() } diff --git a/Projects/App/Sources/Reducer/AppReducer.swift b/Projects/App/Sources/Reducer/AppReducer.swift index 166a8d6..f20d6dd 100644 --- a/Projects/App/Sources/Reducer/AppReducer.swift +++ b/Projects/App/Sources/Reducer/AppReducer.swift @@ -5,7 +5,9 @@ // Created by Wonji Suh on 3/1/26. // +import Foundation import Presentation +import Home import ComposableArchitecture import Entity import LogMacro @@ -17,12 +19,12 @@ public struct AppReducer: Sendable { @ObservableState public enum State { case splash(SplashReducer.State) - case home(HomeReducer.State) + case home(HomeCoordinator.State) case auth(AuthCoordinator.State) public init() { - self = .splash(SplashReducer.State()) + self = .splash(.init()) } // Animation identifier for SwiftUI transitions @@ -53,7 +55,8 @@ public struct AppReducer: Sendable { //MARK: - 앱내에서 사용하는 액션 public enum InnerAction: Equatable { - + case updateToHome + case updateToAuth } //MARK: - 비동기 처리 액션 @@ -71,7 +74,7 @@ public struct AppReducer: Sendable { @CasePathable public enum ScopeAction { case splash(SplashReducer.Action) - case home(HomeReducer.Action) + case home(HomeCoordinator.Action) case auth(AuthCoordinator.Action) } @@ -89,6 +92,16 @@ public struct AppReducer: Sendable { } public var body: some ReducerOf { + EmptyReducer() + .ifCaseLet(\.splash, action: \.scope.splash) { + SplashReducer() + } + .ifCaseLet(\.home, action: \.scope.home) { + HomeCoordinator() + } + .ifCaseLet(\.auth, action: \.scope.auth) { + AuthCoordinator() + } Reduce { state, action in switch action { case .view(let viewAction): @@ -107,15 +120,6 @@ public struct AppReducer: Sendable { return handleScopeAction(state: &state, action: scopeAction) } } - .ifCaseLet(\.splash, action: \.scope.splash) { - SplashReducer() - } - .ifCaseLet(\.home, action: \.scope.home) { - HomeReducer() - } - .ifCaseLet(\.auth, action: \.scope.auth) { - AuthCoordinator() - } } } @@ -185,7 +189,7 @@ extension AppReducer { // 토큰이 있어서 메인 화면으로 이동 return .run { send in try await clock.sleep(for: Constants.splashTransitionDelay) - await send(.view(.presentAuth)) + await send(.view(.presentRoot)) } case .splash(.navigation(.presentAuth)): @@ -196,9 +200,11 @@ extension AppReducer { } case .auth(.navigation(.presentMain)): - // 로그인 완료 후 메인 화면으로 return .send(.view(.presentRoot)) + case .home(.router(.routeAction(id: _, action: .profile(.navigation(.presentAuth))))): + return .send(.view(.presentAuth)) + default: return .none } diff --git a/Projects/App/Sources/View/AppView.swift b/Projects/App/Sources/View/AppView.swift index 4e08f89..0c1d963 100644 --- a/Projects/App/Sources/View/AppView.swift +++ b/Projects/App/Sources/View/AppView.swift @@ -6,11 +6,9 @@ // import SwiftUI - import Presentation - - import ComposableArchitecture +import DesignSystem struct AppView: View { @Bindable var store: StoreOf @@ -32,20 +30,28 @@ struct AppView: View { case .auth: if let store = store.scope(state: \.auth, action: \.scope.auth) { AuthCoordinatorView(store: store) - .transition(.opacity.combined(with: .scale(scale: 0.98))) + .transition(.asymmetric( + insertion: .move(edge: .trailing), + removal: .move(edge: .leading) + )) } case .home: if let store = store.scope(state: \.home, action: \.scope.home) { - HomeView(store: store) - .transition(.opacity.combined(with: .scale(scale: 0.98))) + HomeCoordinatorView(store: store) + .transition(.asymmetric( + insertion: .move(edge: .trailing), + removal: .move(edge: .leading) + )) } } } } - + .animation( + .appDefault, + value: store.state.animationID + ) } } - diff --git a/Projects/Data/API/Sources/API/Auth/AuthAPI.swift b/Projects/Data/API/Sources/API/Auth/AuthAPI.swift index 1e74551..5f628f7 100644 --- a/Projects/Data/API/Sources/API/Auth/AuthAPI.swift +++ b/Projects/Data/API/Sources/API/Auth/AuthAPI.swift @@ -11,6 +11,7 @@ public enum AuthAPI: String, CaseIterable { case login case logout case refresh + case withDraw public var description: String { switch self { @@ -20,6 +21,8 @@ public enum AuthAPI: String, CaseIterable { return "/logout" case .refresh: return "/refresh" + case .withDraw: + return "" } } } diff --git a/Projects/Data/API/Sources/API/Domain/TimeSpotDomain.swift b/Projects/Data/API/Sources/API/Domain/TimeSpotDomain.swift index 6daac6b..dd7a1b6 100644 --- a/Projects/Data/API/Sources/API/Domain/TimeSpotDomain.swift +++ b/Projects/Data/API/Sources/API/Domain/TimeSpotDomain.swift @@ -11,6 +11,7 @@ import AsyncMoya public enum TimeSpotDomain { case auth + case place case profile } @@ -23,6 +24,8 @@ extension TimeSpotDomain: DomainType { switch self { case .auth: return "api/v1/auth" + case .place: + return "api/v1/place" case .profile: return "api/v1/users" } diff --git a/Projects/Data/API/Sources/API/Profile/ProfileAPI.swift b/Projects/Data/API/Sources/API/Profile/ProfileAPI.swift new file mode 100644 index 0000000..c3b05f3 --- /dev/null +++ b/Projects/Data/API/Sources/API/Profile/ProfileAPI.swift @@ -0,0 +1,23 @@ +// +// ProfileAPI.swift +// API +// +// Created by Wonji Suh on 3/25/26. +// + +import Foundation + +public enum ProfileAPI: String, CaseIterable { + case user + case editUser + + public var description : String { + switch self { + case .user: + return "" + + case .editUser: + return "" + } + } +} diff --git a/Projects/Data/Model/Sources/Auth/DTO/LogoutDTOModel.swift b/Projects/Data/Model/Sources/Auth/DTO/LogoutDTOModel.swift new file mode 100644 index 0000000..2646f22 --- /dev/null +++ b/Projects/Data/Model/Sources/Auth/DTO/LogoutDTOModel.swift @@ -0,0 +1,41 @@ +// +// LogoutDTOModel.swift +// Model +// +// Created by Wonji Suh on 3/25/26. +// + +import Foundation + +public struct LogoutDTOModel: Decodable, Equatable { + public let code: Int + public let message: String + public let data: EmptyResponseDTO? + + public init( + code: Int, + message: String, + data: EmptyResponseDTO? = nil + ) { + self.code = code + self.message = message + self.data = data + } + + enum CodingKeys: String, CodingKey { + case code + case message + case data + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.code = try container.decode(Int.self, forKey: .code) + self.message = try container.decode(String.self, forKey: .message) + self.data = try container.decodeIfPresent(EmptyResponseDTO.self, forKey: .data) + } +} + +public struct EmptyResponseDTO: Decodable, Equatable { + public init() {} +} diff --git a/Projects/Data/Model/Sources/Auth/Mapper/ Extension+LoginModel.swift b/Projects/Data/Model/Sources/Auth/Mapper/ Extension+LoginModel.swift index f934bc3..bca31e8 100644 --- a/Projects/Data/Model/Sources/Auth/Mapper/ Extension+LoginModel.swift +++ b/Projects/Data/Model/Sources/Auth/Mapper/ Extension+LoginModel.swift @@ -21,12 +21,29 @@ public extension LoginResponseDTO { default: .apple } + let mapType: ExternalMapType? = if let mapName = self.map?.mapName { + switch mapName.uppercased() { + case "GOOGLE", "구글": + .googleMap + case "NAVER", "네이버", "네이버지도": + .naverMap + case "APPLE", "애플", "지도": + .appleMap + default: + nil + } + } else { + nil + } + return LoginEntity( name: self.userInfo?.nickname ?? "", isNewUser: self.newUser ?? false, provider: provider, token: token, - email: self.userInfo?.email ?? "" + email: self.userInfo?.email ?? "", + mapType: mapType, + mapURLScheme: self.map?.mapURLScheme ) } } diff --git a/Projects/Data/Model/Sources/Auth/Mapper/LogoutDTOModel+.swift b/Projects/Data/Model/Sources/Auth/Mapper/LogoutDTOModel+.swift new file mode 100644 index 0000000..5fff1b9 --- /dev/null +++ b/Projects/Data/Model/Sources/Auth/Mapper/LogoutDTOModel+.swift @@ -0,0 +1,17 @@ +// +// LogoutDTOModel+.swift +// Model +// +// Created by Wonji Suh on 3/25/26. +// + +import Entity + +public extension LogoutDTOModel { + func toDomain() -> LogoutEntity { + LogoutEntity( + code: code, + message: message + ) + } +} diff --git a/Projects/Data/Model/Sources/Profile/DTO/ProfileDTO.swift b/Projects/Data/Model/Sources/Profile/DTO/ProfileDTO.swift new file mode 100644 index 0000000..92d8d29 --- /dev/null +++ b/Projects/Data/Model/Sources/Profile/DTO/ProfileDTO.swift @@ -0,0 +1,41 @@ +// +// ProfileDTO.swift +// Model +// +// Created by Wonji Suh on 3/25/26. +// + +import Foundation + +public typealias ProfileDTOModel = BaseResponseDTO + +// MARK: - DataClass +public struct ProfileResponseDTO: Decodable, Equatable { + let userID, email, nickname, mapAPI: String + let role, providerType, createdAt: String + + enum CodingKeys: String, CodingKey { + case userID = "userId" + case email, nickname + case mapAPI = "mapApi" + case role, providerType, createdAt + } + + public init( + userID: String, + email: String, + nickname: String, + mapAPI: String, + role: String, + providerType: String, + createdAt: String + ) { + self.userID = userID + self.email = email + self.nickname = nickname + self.mapAPI = mapAPI + self.role = role + self.providerType = providerType + self.createdAt = createdAt + } +} diff --git a/Projects/Data/Model/Sources/Profile/Mapper/ProfileDTOModel+.swift b/Projects/Data/Model/Sources/Profile/Mapper/ProfileDTOModel+.swift new file mode 100644 index 0000000..de84420 --- /dev/null +++ b/Projects/Data/Model/Sources/Profile/Mapper/ProfileDTOModel+.swift @@ -0,0 +1,40 @@ +// +// ProfileDTOModel+.swift +// Model +// +// Created by Wonji Suh on 3/25/26. +// + + +import Entity + +public extension ProfileResponseDTO { + func toDomain() -> ProfileEntity { + let mapType: ExternalMapType = switch self.mapAPI.uppercased() { + case "GOOGLE": + .googleMap + case "NAVER": + .naverMap + case "APPLE": + .appleMap + default: + .appleMap + } + + let provider: SocialType = switch self.providerType.uppercased() { + case "GOOGLE": + .google + case "APPLE": + .apple + default: + .apple + } + + return ProfileEntity( + email: self.email, + nickname: self.nickname, + mapType: mapType, + provider: provider + ) + } +} diff --git a/Projects/Data/Repository/Sources/OAuth/Auth/Repository/AuthRepositoryImpl.swift b/Projects/Data/Repository/Sources/OAuth/Auth/Repository/AuthRepositoryImpl.swift index ceefa85..dac6680 100644 --- a/Projects/Data/Repository/Sources/OAuth/Auth/Repository/AuthRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/OAuth/Auth/Repository/AuthRepositoryImpl.swift @@ -41,17 +41,7 @@ final public class AuthRepositoryImpl: AuthInterface, @unchecked Sendable { return dto.data.toDomain() } -// public func login( -// provider socialProvider: SocialType, -// token: String -// ) async throws -> LoginEntity { -// let dto: LoginResponseDTO = try await provider.request( -// .login(body: OAuthLoginRequest(provider: socialProvider.description, token: token)) -// ) -// return dto.toDomain() -// } -// -// + // // MARK: - 토큰 재발급 public func refresh() async throws -> AuthTokens { let refreshToken = await keychainManager.refreshToken() ?? "" @@ -100,52 +90,17 @@ final public class AuthRepositoryImpl: AuthInterface, @unchecked Sendable { } // MARK: - 로그아웃 -// public func logout() async throws -> AuthExitEntity { -// let response = try await authProvider.requestResponse(.logout) -// let decoder = JSONDecoder() -// -// if (200...299).contains(response.statusCode) { -// keychainManager.clear() -// if response.data.isEmpty { -// return AuthExitEntity() -// } -// if let successDTO = try? decoder.decode(LogOutDTO.self, from: response.data) { -// return successDTO.toDomain() -// } -// return AuthExitEntity() -// } -// -// if let errorDTO = try? decoder.decode(LogOutDTO.self, from: response.data) { -// return errorDTO.toDomain() -// } -// -// let errorMessage = String(data: response.data, encoding: .utf8) -// return AuthExitEntity(message: errorMessage) -// } -// -// // MARK: - 계정 삭제 -// public func withDraw(token: String) async throws -> WithdrawEntity { -// let response = try await provider.requestResponse(.withdraw(token: token)) -// let decoder = JSONDecoder() -// -// if (200...299).contains(response.statusCode) { -// if response.data.isEmpty { -// return WithdrawEntity(isSuccess: true) -// } -// if let successDTO = try? decoder.decode(WithdrawDTO.self, from: response.data) { -// return successDTO.toDomain(isSuccess: true) -// } -// return WithdrawEntity(isSuccess: true) -// } -// -// if let errorDTO = try? decoder.decode(WithdrawDTO.self, from: response.data) { -// return errorDTO.toDomain(isSuccess: false) -// } -// return WithdrawEntity( -// isSuccess: false, -// message: String(data: response.data, encoding: .utf8) -// ) -// } + public func logout() async throws -> LogoutEntity { + let dto: LogoutDTOModel = try await authProvider.request(.logout) + try await keychainManager.clear() + return dto.toDomain() + } + // MARK: - 계정 삭제 + public func withDraw() async throws -> LogoutEntity { + let dto: LogoutDTOModel = try await authProvider.request(.withDraw) + try await keychainManager.clear() + return dto.toDomain() + } // MARK: - 세션 Credential 업데이트 public func updateSessionCredential(with tokens: AuthTokens) { diff --git a/Projects/Data/Repository/Sources/Profile/ProfileRepositoryImpl.swift b/Projects/Data/Repository/Sources/Profile/ProfileRepositoryImpl.swift new file mode 100644 index 0000000..0075ac1 --- /dev/null +++ b/Projects/Data/Repository/Sources/Profile/ProfileRepositoryImpl.swift @@ -0,0 +1,91 @@ +// +// ProfileRepositoryImpl.swift +// Repository +// +// Created by Wonji Suh on 3/25/26. +// + +import DomainInterface +import Model +import Entity + +import Service + + +import Moya +import AsyncMoya + +public class ProfileRepositoryImpl: ProfileInterface, @unchecked Sendable { + private let provider: MoyaProvider + + public init( + provider: MoyaProvider = MoyaProvider.authorized, + ) { + self.provider = provider + } + + public func fetchUser() async throws -> ProfileEntity { + do { + let response = try await provider.requestResponse(.fetchProfile) + + if (200...299).contains(response.statusCode) { + let dto = try JSONDecoder().decode(ProfileDTOModel.self, from: response.data) + return dto.data.toDomain() + } + + if let errorResponse = try? JSONDecoder().decode(ProfileErrorResponseDTO.self, from: response.data) { + if errorResponse.message.contains("잘못된 AccessToken") + || errorResponse.message.contains("유효하지 않은 토큰") { + throw ProfileError.profileAccessDenied + } + throw ProfileError.unknownError(errorResponse.message) + } + + throw ProfileError.unknownError("statusCodeError(\(response.statusCode))") + } catch let error as ProfileError { + throw error + } catch let error as MoyaError { + switch error { + case .statusCode(let response): + if let errorResponse = try? JSONDecoder().decode(ProfileErrorResponseDTO.self, from: response.data) { + if errorResponse.message.contains("잘못된 AccessToken") + || errorResponse.message.contains("유효하지 않은 토큰") { + throw ProfileError.profileAccessDenied + } + throw ProfileError.unknownError(errorResponse.message) + } + throw ProfileError.unknownError("statusCodeError(\(response.statusCode))") + + case .underlying(_, let response): + if let response, + let errorResponse = try? JSONDecoder().decode(ProfileErrorResponseDTO.self, from: response.data) { + if errorResponse.message.contains("잘못된 AccessToken") + || errorResponse.message.contains("유효하지 않은 토큰") { + throw ProfileError.profileAccessDenied + } + throw ProfileError.unknownError(errorResponse.message) + } + throw ProfileError.unknownError(error.localizedDescription) + + default: + throw ProfileError.unknownError(error.localizedDescription) + } + } catch { + throw ProfileError.unknownError(error.localizedDescription) + } + } + + public func editUser( + name: String, + mapType: ExternalMapType + ) async throws -> LoginEntity { + let body: ProfileRequest = ProfileRequest(nickname: name, mapApi: mapType.type) + let dto: LoginDTOModel = try await provider.request(.editProfile(body: body)) + return dto.data.toDomain() + } +} + +private struct ProfileErrorResponseDTO: Decodable { + let code: Int + let message: String +} diff --git a/Projects/Data/Service/Sources/Auth/AuthService.swift b/Projects/Data/Service/Sources/Auth/AuthService.swift index 2b400a5..3e5f2d3 100644 --- a/Projects/Data/Service/Sources/Auth/AuthService.swift +++ b/Projects/Data/Service/Sources/Auth/AuthService.swift @@ -17,6 +17,7 @@ public enum AuthService { case login(body: OAuthLoginRequest) case refresh(refreshToken: String) case logout + case withDraw } @@ -28,6 +29,9 @@ extension AuthService: BaseTargetType { switch self { case .login, .refresh, .logout: return .auth + + case .withDraw: + return .profile } } @@ -39,6 +43,8 @@ extension AuthService: BaseTargetType { return AuthAPI.refresh.description case .logout: return AuthAPI.logout.description + case .withDraw: + return AuthAPI.withDraw.description } } @@ -50,6 +56,8 @@ extension AuthService: BaseTargetType { switch self { case .login, .refresh, .logout: return .post + case .withDraw: + return .delete } } @@ -59,14 +67,14 @@ extension AuthService: BaseTargetType { return body.toDictionary case .refresh(let refreshToken): return refreshToken.toDictionary(key: "refreshToken") - case .logout: + case .logout, .withDraw: return nil } } public var headers: [String : String]? { switch self { - case .logout: + case .logout, .withDraw: return APIHeader.baseHeader default: return APIHeader.notAccessTokenHeader diff --git a/Projects/Data/Service/Sources/Profile/ProfileRequest.swift b/Projects/Data/Service/Sources/Profile/ProfileRequest.swift new file mode 100644 index 0000000..1551597 --- /dev/null +++ b/Projects/Data/Service/Sources/Profile/ProfileRequest.swift @@ -0,0 +1,19 @@ +// +// ProfileRequest.swift +// Service +// +// Created by Wonji Suh on 3/26/26. +// + +public struct ProfileRequest: Encodable { + public let nickname: String + public let mapApi: String + + public init( + nickname: String, + mapApi: String + ) { + self.nickname = nickname + self.mapApi = mapApi + } +} diff --git a/Projects/Data/Service/Sources/Profile/ProfileService.swift b/Projects/Data/Service/Sources/Profile/ProfileService.swift new file mode 100644 index 0000000..bfde890 --- /dev/null +++ b/Projects/Data/Service/Sources/Profile/ProfileService.swift @@ -0,0 +1,70 @@ +// +// ProfileService.swift +// Service +// +// Created by Wonji Suh on 3/25/26. +// + + +import Foundation + +import API +import Foundations + +import AsyncMoya + + +public enum ProfileService { + case fetchProfile + case editProfile(body: ProfileRequest) +} + + +extension ProfileService: BaseTargetType { + public typealias Domain = TimeSpotDomain + + public var domain: TimeSpotDomain { + return .profile + } + + public var urlPath: String { + switch self { + case .fetchProfile: + return ProfileAPI.user.description + + case .editProfile: + return ProfileAPI.editUser.description + } + } + + public var error: [Int : NetworkError]? { + return nil + } + + public var method: Moya.Method { + switch self { + case .fetchProfile: + return .get + + case .editProfile: + return .post + } + } + + public var parameters: [String : Any]? { + switch self { + case .fetchProfile: + return nil + + case .editProfile(let body): + return body.toDictionary + } + } + + public var headers: [String : String]? { + switch self { + default: + return APIHeader.baseHeader + } + } +} diff --git a/Projects/Domain/DomainInterface/Project.swift b/Projects/Domain/DomainInterface/Project.swift index 9d70abb..a3c3501 100644 --- a/Projects/Domain/DomainInterface/Project.swift +++ b/Projects/Domain/DomainInterface/Project.swift @@ -11,6 +11,7 @@ let project = Project.makeModule( settings: .settings(), dependencies: [ .Data(implements: .Model), + .Domain(implements: .Entity), .SPM.composableArchitecture, .SPM.weaveDI ], diff --git a/Projects/Domain/DomainInterface/Sources/Auth/AuthInterface.swift b/Projects/Domain/DomainInterface/Sources/Auth/AuthInterface.swift index 84c0f42..0c788b2 100644 --- a/Projects/Domain/DomainInterface/Sources/Auth/AuthInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Auth/AuthInterface.swift @@ -15,8 +15,8 @@ import Entity public protocol AuthInterface: Sendable { func login(provider: SocialType, token: String) async throws -> LoginEntity func refresh() async throws -> AuthTokens -// func withDraw(token: String) async throws -> WithdrawEntity -// func logout() async throws -> AuthExitEntity + func logout() async throws -> LogoutEntity + func withDraw() async throws -> LogoutEntity func updateSessionCredential(with tokens: AuthTokens) } diff --git a/Projects/Domain/DomainInterface/Sources/Auth/DefaultAuthRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Auth/DefaultAuthRepositoryImpl.swift index 13b78cc..83acca6 100644 --- a/Projects/Domain/DomainInterface/Sources/Auth/DefaultAuthRepositoryImpl.swift +++ b/Projects/Domain/DomainInterface/Sources/Auth/DefaultAuthRepositoryImpl.swift @@ -22,7 +22,10 @@ final public class DefaultAuthRepositoryImpl: AuthInterface { token: AuthTokens( accessToken: "mock_access_token_\(UUID().uuidString)", refreshToken: "mock_refresh_token_\(UUID().uuidString)" - ), email: "test@test.com", + ), + email: "test@test.com", + mapType: nil, + mapURLScheme: nil ) } @@ -33,18 +36,19 @@ final public class DefaultAuthRepositoryImpl: AuthInterface { ) } -// public func withDraw(token: String) async throws -> WithdrawEntity { -// return WithdrawEntity(isSuccess: true) -// } -// -// public func logout() async throws -> AuthExitEntity { -// // Mock 로그아웃 성공 응답 -// return AuthExitEntity( -// code: "200", -// message: "로그아웃이 성공적으로 완료되었습니다.", -// detail: "사용자 세션이 종료되었습니다." -// ) -// } + public func logout() async throws -> LogoutEntity { + LogoutEntity( + code: 200, + message: "로그아웃 되었습니다." + ) + } + + public func withDraw() async throws -> LogoutEntity { + LogoutEntity( + code: 200, + message: "회원 탈퇴 되었습니다." + ) + } public func updateSessionCredential(with tokens: AuthTokens) { // Mock 구현체에서는 아무것도 하지 않음 (테스트/프리뷰용) diff --git a/Projects/Domain/DomainInterface/Sources/Profile/DefaultProfileRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Profile/DefaultProfileRepositoryImpl.swift new file mode 100644 index 0000000..984eabe --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Profile/DefaultProfileRepositoryImpl.swift @@ -0,0 +1,41 @@ +// +// DefaultProfileRepositoryImpl.swift +// DomainInterface +// +// Created by Wonji Suh on 3/25/26. +// + +import Foundation +import Entity + +/// Profile Repository의 기본 구현체 (테스트/프리뷰용) +final public class DefaultProfileRepositoryImpl: ProfileInterface { + public init() {} + + public func fetchUser() async throws -> Entity.ProfileEntity { + return ProfileEntity( + email: "test@example.com", + nickname: "Mock User", + mapType: .appleMap, + provider: .apple + ) + } + + public func editUser( + name: String, + mapType: ExternalMapType + ) async throws -> LoginEntity { + return LoginEntity( + name: name, + isNewUser: false, + provider: .apple, + token: AuthTokens( + accessToken: "mock_access_token_\(UUID().uuidString)", + refreshToken: "mock_refresh_token_\(UUID().uuidString)" + ), + email: "test@example.com", + mapType: mapType, + mapURLScheme: nil + ) + } +} diff --git a/Projects/Domain/DomainInterface/Sources/Profile/ProfileInterface.swift b/Projects/Domain/DomainInterface/Sources/Profile/ProfileInterface.swift new file mode 100644 index 0000000..288da23 --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Profile/ProfileInterface.swift @@ -0,0 +1,40 @@ +// +// ProfileInterface.swift +// DomainInterface +// +// Created by Wonji Suh on 3/25/26. +// + +import Entity +import ComposableArchitecture +import WeaveDI + +public protocol ProfileInterface: Sendable { + func fetchUser() async throws -> ProfileEntity + func editUser( + name: String, + mapType: ExternalMapType + ) async throws -> LoginEntity +} + + +/// Profile Repository의 DependencyKey 구조체 +public struct ProfileRepositoryDependency: DependencyKey { + public static var liveValue: ProfileInterface { + UnifiedDI.resolve(ProfileInterface.self) ?? DefaultProfileRepositoryImpl() + } + + public static var testValue: ProfileInterface { + UnifiedDI.resolve(ProfileInterface.self) ?? DefaultProfileRepositoryImpl() + } + + public static var previewValue: ProfileInterface = liveValue +} + +/// DependencyValues extension으로 간편한 접근 제공 +public extension DependencyValues { + var profileRepository: ProfileInterface { + get { self[ProfileRepositoryDependency.self] } + set { self[ProfileRepositoryDependency.self] = newValue } + } +} diff --git a/Projects/Domain/DomainInterface/Sources/SignUp/DefaultSignUpRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/SignUp/DefaultSignUpRepositoryImpl.swift index aa31f7e..7080bcf 100644 --- a/Projects/Domain/DomainInterface/Sources/SignUp/DefaultSignUpRepositoryImpl.swift +++ b/Projects/Domain/DomainInterface/Sources/SignUp/DefaultSignUpRepositoryImpl.swift @@ -133,7 +133,9 @@ final public class DefaultSignUpRepositoryImpl: SignUpInterface, @unchecked Send isNewUser: true, // 회원가입이므로 신규 사용자 provider: input.provider, token: authTokens, - email: input.email + email: input.email, + mapType: input.mapType, + mapURLScheme: nil ) } } diff --git a/Projects/Domain/Entity/Sources/Error/ProfileError.swift b/Projects/Domain/Entity/Sources/Error/ProfileError.swift new file mode 100644 index 0000000..e712e6f --- /dev/null +++ b/Projects/Domain/Entity/Sources/Error/ProfileError.swift @@ -0,0 +1,120 @@ +// +// ProfileError.swift +// Entity +// +// Created by Claude on 1/4/26. +// + +import Foundation + +public enum ProfileError: Error, LocalizedError, Equatable { + // MARK: - Profile Fetch Related Errors + case profileNotFound + case profileAccessDenied + case profileDataCorrupted + + + // MARK: - General Errors + case unknownError(String) + case userCancelled + case missingRequiredField(String) + + public var errorDescription: String? { + switch self { + // Profile Fetch Related Errors + case .profileNotFound: + return "프로필을 찾을 수 없습니다" + case .profileAccessDenied: + return "프로필 접근이 거부되었습니다" + case .profileDataCorrupted: + return "프로필 데이터가 손상되었습니다" + + + // General Errors + case .unknownError(let message): + return "알 수 없는 오류가 발생했습니다: \(message)" + case .userCancelled: + return "사용자가 취소했습니다" + case .missingRequiredField(let field): + return "\(field)은(는) 필수 입력 항목입니다" + } + } + + public var failureReason: String? { + switch self { + case .profileNotFound: + return "프로필 조회 실패" + case .profileAccessDenied: + return "프로필 접근 권한 부족" + default: + return nil + } + } + + public var recoverySuggestion: String? { + switch self { + case .profileNotFound: + return "프로필 정보를 다시 설정하거나 관리자에게 문의해주세요" + case .profileAccessDenied: + return "관리자에게 권한 요청을 문의해주세요" + default: + return "문제가 지속되면 고객센터에 문의해주세요" + } + } +} + +// MARK: - Convenience Methods + +public extension ProfileError { + static func from(_ error: Error) -> ProfileError { + if let profileError = error as? ProfileError { + return profileError + } + return .unknownError(error.localizedDescription) + } + + /// 프로필 조회 관련 에러인지 확인 + var isFetchError: Bool { + switch self { + case .profileNotFound, .profileAccessDenied, .profileDataCorrupted: + return true + default: + return false + } + } + + /// 재시도 가능한 에러인지 확인 + var isRetryable: Bool { + switch self { + default: + return false + } + } + + /// 사용자 액션이 필요한 에러인지 확인 + var requiresUserAction: Bool { + switch self { + case .profileAccessDenied: + return true + default: + return false + } + } + + var shouldPresentAuth: Bool { + switch self { + case .profileAccessDenied: + return true + + case .unknownError(let message): + return message.contains("잘못된 AccessToken") + || message.contains("유효하지 않은 토큰") + || message.contains("해당 회원을 찾을 수 없습니다") + || message.contains("statusCodeError(401)") + || message.contains("401") + + default: + return false + } + } +} diff --git a/Projects/Domain/Entity/Sources/OAuth/LoginEntity.swift b/Projects/Domain/Entity/Sources/OAuth/LoginEntity.swift index a50c628..49167bb 100644 --- a/Projects/Domain/Entity/Sources/OAuth/LoginEntity.swift +++ b/Projects/Domain/Entity/Sources/OAuth/LoginEntity.swift @@ -13,18 +13,24 @@ public struct LoginEntity: Equatable { public let token: AuthTokens public let isNewUser: Bool public let email: String + public let mapType: ExternalMapType? + public let mapURLScheme: String? public init( name: String, isNewUser: Bool, provider: SocialType, token: AuthTokens, - email: String + email: String, + mapType: ExternalMapType? = nil, + mapURLScheme: String? = nil ) { self.name = name self.isNewUser = isNewUser self.provider = provider self.token = token self.email = email + self.mapType = mapType + self.mapURLScheme = mapURLScheme } } diff --git a/Projects/Domain/Entity/Sources/OAuth/LogoutEntity.swift b/Projects/Domain/Entity/Sources/OAuth/LogoutEntity.swift new file mode 100644 index 0000000..088b5c1 --- /dev/null +++ b/Projects/Domain/Entity/Sources/OAuth/LogoutEntity.swift @@ -0,0 +1,21 @@ +// +// LogoutEntity.swift +// Entity +// +// Created by Wonji Suh on 3/25/26. +// + +import Foundation + +public struct LogoutEntity: Equatable, Hashable { + public let code: Int + public let message: String + + public init( + code: Int, + message: String + ) { + self.code = code + self.message = message + } +} diff --git a/Projects/Domain/Entity/Sources/OAuth/UserSession.swift b/Projects/Domain/Entity/Sources/OAuth/UserSession.swift index fff7cc0..28ace17 100644 --- a/Projects/Domain/Entity/Sources/OAuth/UserSession.swift +++ b/Projects/Domain/Entity/Sources/OAuth/UserSession.swift @@ -7,7 +7,7 @@ import Foundation -public struct UserSession: Equatable { +public struct UserSession: Equatable, Hashable { public var name: String public var email: String public var provider: SocialType @@ -32,4 +32,3 @@ public struct UserSession: Equatable { public extension UserSession { static let empty = UserSession() } - diff --git a/Projects/Domain/Entity/Sources/OnBoarding/ExternalMapType.swift b/Projects/Domain/Entity/Sources/OnBoarding/ExternalMapType.swift index d81d295..99877f2 100644 --- a/Projects/Domain/Entity/Sources/OnBoarding/ExternalMapType.swift +++ b/Projects/Domain/Entity/Sources/OnBoarding/ExternalMapType.swift @@ -17,7 +17,7 @@ public enum ExternalMapType: String, CaseIterable, Identifiable, Hashable, Equat public var description: String { switch self { case .appleMap: - return "애플지도" + return "지도" case .googleMap: return "Google Maps" diff --git a/Projects/Domain/Entity/Sources/Profile/NotificationOption.swift b/Projects/Domain/Entity/Sources/Profile/NotificationOption.swift new file mode 100644 index 0000000..3c2bd7e --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/NotificationOption.swift @@ -0,0 +1,33 @@ +// +// NotificationOption.swift +// Entity +// +// Created by Wonji Suh on 3/26/26. +// + +import Foundation + +public enum NotificationOption: String, CaseIterable, Equatable, Hashable, Identifiable { + case none + case departureTime + case fiveMinutesBefore + case tenMinutesBefore + case fifteenMinutesBefore + + public var id: String { rawValue } + + public var title: String { + switch self { + case .none: + return "설정 하지 않음" + case .departureTime: + return "출발 시간" + case .fiveMinutesBefore: + return "출발 5분 전" + case .tenMinutesBefore: + return "출발 10분 전" + case .fifteenMinutesBefore: + return "출발 15분 전" + } + } +} diff --git a/Projects/Domain/Entity/Sources/Profile/ProfileEntity.swift b/Projects/Domain/Entity/Sources/Profile/ProfileEntity.swift new file mode 100644 index 0000000..9a183be --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/ProfileEntity.swift @@ -0,0 +1,27 @@ +// +// ProfileEntity.swift +// Entity +// +// Created by Wonji Suh on 3/25/26. +// + +import Foundation + +// MARK: - DataClass +public struct ProfileEntity: Equatable, Hashable { + public let email, nickname: String + public let mapType: ExternalMapType + public let provider: SocialType + + public init( + email: String, + nickname: String, + mapType: ExternalMapType, + provider: SocialType + ) { + self.email = email + self.nickname = nickname + self.mapType = mapType + self.provider = provider + } +} diff --git a/Projects/Domain/Entity/Sources/Profile/TravelHistorySort.swift b/Projects/Domain/Entity/Sources/Profile/TravelHistorySort.swift new file mode 100644 index 0000000..7f418fb --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/TravelHistorySort.swift @@ -0,0 +1,22 @@ +// +// TravelHistorySort.swift +// Entity +// +// Created by Wonji Suh on 3/25/26. +// + +import Foundation + +public enum TravelHistorySort: String, CaseIterable, Equatable, Hashable { + case oldest + case recent + + public var title: String { + switch self { + case .recent: + return "최근 방문한 순" + case .oldest: + return "오래된 순" + } + } +} diff --git a/Projects/Domain/UseCase/Sources/Auth/AuthUseCaseImpl.swift b/Projects/Domain/UseCase/Sources/Auth/AuthUseCaseImpl.swift new file mode 100644 index 0000000..65bc6a6 --- /dev/null +++ b/Projects/Domain/UseCase/Sources/Auth/AuthUseCaseImpl.swift @@ -0,0 +1,57 @@ +// +// AuthUseCaseImpl.swift +// UseCase +// +// Created by Wonji Suh on 3/25/26. +// + +import DomainInterface +import Entity + +import WeaveDI +import ComposableArchitecture + + +public struct AuthUseCaseImpl: AuthInterface { + @Dependency(\.authRepository) var repository + + public init() {} + + public func login( + provider: Entity.SocialType, + token: String + ) async throws -> Entity.LoginEntity { + return try await repository.login(provider: provider, token: token) + } + + public func refresh() async throws -> Entity.AuthTokens { + return try await repository.refresh() + } + + public func logout() async throws -> Entity.LogoutEntity { + return try await repository.logout() + } + + public func withDraw() async throws -> LogoutEntity { + return try await repository.withDraw() + } + + public func updateSessionCredential(with tokens: Entity.AuthTokens) { + return repository.updateSessionCredential(with: tokens) + } + +} + + +extension AuthUseCaseImpl: DependencyKey { + static public var liveValue: AuthInterface = AuthUseCaseImpl() + static public var testValue: AuthInterface = AuthUseCaseImpl() + static public var previewValue: AuthInterface = AuthUseCaseImpl() +} + +public extension DependencyValues { + var authUseCase: AuthInterface { + get { self[AuthUseCaseImpl.self] } + set { self[AuthUseCaseImpl.self] = newValue } + } +} diff --git a/Projects/Domain/UseCase/Sources/OAuth/UnifiedOAuthUseCase.swift b/Projects/Domain/UseCase/Sources/OAuth/UnifiedOAuthUseCase.swift index d2ec4bb..1a9058a 100644 --- a/Projects/Domain/UseCase/Sources/OAuth/UnifiedOAuthUseCase.swift +++ b/Projects/Domain/UseCase/Sources/OAuth/UnifiedOAuthUseCase.swift @@ -21,6 +21,7 @@ public struct UnifiedOAuthUseCase { @Dependency(\.keychainManager) private var keychainManager: KeychainManaging @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty @Shared(.appStorage("appleUserName")) var savedAppleUserName: String? + @Shared(.appStorage("mapUrlScheme")) var mapURLScheme: String? public init() {} } @@ -82,7 +83,7 @@ public extension UnifiedOAuthUseCase { let loginEntity = try await authRepository.login( provider: .apple, - token: payload.idToken ?? "" + token: payload.idToken ) print("애플 코드 \(payload.authorizationCode ?? "")") @@ -93,6 +94,9 @@ public extension UnifiedOAuthUseCase { $0.email = loginEntity.email $0.authCode = payload.authorizationCode ?? "" } + self.$mapURLScheme.withLock { + $0 = loginEntity.mapURLScheme + } try await keychainManager.save( accessToken: loginEntity.token.accessToken, @@ -124,6 +128,9 @@ public extension UnifiedOAuthUseCase { self.$userSession.withLock { $0.email = loginEntity.email } + self.$mapURLScheme.withLock { + $0 = loginEntity.mapURLScheme + } try await keychainManager.save( @@ -131,12 +138,13 @@ public extension UnifiedOAuthUseCase { refreshToken: loginEntity.token.refreshToken ) - // AuthSessionManager의 credential도 업데이트 authRepository.updateSessionCredential(with: loginEntity.token) return loginEntity } + + /// OAuth 플로우 처리 (TCA용) func processOAuthFlow( with socialType: SocialType, diff --git a/Projects/Domain/UseCase/Sources/Profile/ProfileUseCaseImpl.swift b/Projects/Domain/UseCase/Sources/Profile/ProfileUseCaseImpl.swift new file mode 100644 index 0000000..89e5a53 --- /dev/null +++ b/Projects/Domain/UseCase/Sources/Profile/ProfileUseCaseImpl.swift @@ -0,0 +1,67 @@ +// +// ProfileUseCaseImpl.swift +// UseCase +// +// Created by Wonji Suh on 3/25/26. +// + +import Foundation +import DomainInterface + +import WeaveDI +import Entity +import ComposableArchitecture + + +public struct ProfileUseCaseImpl: ProfileInterface { + @Dependency(\.profileRepository) var repository + @Dependency(\.keychainManager) private var keychainManager: KeychainManaging + @Dependency(\.authRepository) private var authRepository: AuthInterface + @Shared(.appStorage("mapUrlScheme")) var mapURLScheme: String? + + public init() {} + + public func fetchUser() async throws -> ProfileEntity { + return try await repository.fetchUser() + } + + public func editUser( + name: String, + mapType: ExternalMapType + ) async throws -> LoginEntity { + let editUserEntity = try await repository.editUser(name: name, mapType: mapType) + + do { + try await keychainManager.save( + accessToken: editUserEntity.token.accessToken, + refreshToken: editUserEntity.token.refreshToken + ) + + authRepository.updateSessionCredential(with: editUserEntity.token) + + self.$mapURLScheme.withLock { + $0 = editUserEntity.mapURLScheme + } + + return editUserEntity + } catch { + // 토큰 저장 실패 시 일관성 유지를 위해 에러 전파 + throw error + } + } +} + + +extension ProfileUseCaseImpl: DependencyKey { + static public var liveValue: ProfileInterface = ProfileUseCaseImpl() + static public var testValue: ProfileInterface = ProfileUseCaseImpl() + static public var previewValue: ProfileInterface = liveValue +} + +public extension DependencyValues { + var profileUseCase: ProfileInterface { + get { self[ProfileUseCaseImpl.self] } + set { self[ProfileUseCaseImpl.self] = newValue } + } +} + diff --git a/Projects/Domain/UseCase/Sources/SignUp/SignUpUseCaseImpl.swift b/Projects/Domain/UseCase/Sources/SignUp/SignUpUseCaseImpl.swift index 06eb941..db5f231 100644 --- a/Projects/Domain/UseCase/Sources/SignUp/SignUpUseCaseImpl.swift +++ b/Projects/Domain/UseCase/Sources/SignUp/SignUpUseCaseImpl.swift @@ -19,6 +19,7 @@ public protocol SignUpUseCaseInterface: Sendable { public struct SignUpUseCaseImpl: SignUpUseCaseInterface { @Dependency(\.signUpRepository) var repository @Dependency(\.keychainManager) private var keychainManager + @Dependency(\.authRepository) private var authRepository: AuthInterface public init() { @@ -42,6 +43,9 @@ public struct SignUpUseCaseImpl: SignUpUseCaseInterface { refreshToken: signUpUser.token.refreshToken ) + // AuthSessionManager의 credential도 업데이트 + authRepository.updateSessionCredential(with: signUpUser.token) + return signUpUser } diff --git a/Projects/Presentation/Auth/Sources/Coordinator/Reducer/AuthCoordinator.swift b/Projects/Presentation/Auth/Sources/Coordinator/Reducer/AuthCoordinator.swift index 5b98e5e..4fd7663 100644 --- a/Projects/Presentation/Auth/Sources/Coordinator/Reducer/AuthCoordinator.swift +++ b/Projects/Presentation/Auth/Sources/Coordinator/Reducer/AuthCoordinator.swift @@ -19,7 +19,7 @@ public struct AuthCoordinator { var routes: [Route] public init() { - self.routes = [.root(.login(.init()), withNavigation: true)] + self.routes = [.root(.login(.init()), embedInNavigationView: true)] } } @@ -46,7 +46,8 @@ public struct AuthCoordinator { // MARK: - 앱내에서 사용하는 액션 public enum InnerAction: Equatable { - + case pushOnBoarding + case performPushOnBoarding } // MARK: - NavigationAction @@ -86,19 +87,14 @@ extension AuthCoordinator { switch action { case .routeAction(id: _, action: .login(.delegate(.presentOnBoarding))): - return .run { send in - await MainActor.run { - state.routes.push(.onBoarding(.init())) - } - } + return .send(.inner(.pushOnBoarding)) case .routeAction(id: _, action: .login(.delegate(.presentMain))): return .send(.navigation(.presentMain)) - case .routeAction(id: _, action: .onBoarding(.navigation(.presentMain))): + case .routeAction(id: _, action: .onBoarding(.navigation(.onBoardingCompleted))): return .send(.navigation(.presentMain)) - default: return .none } @@ -134,25 +130,41 @@ extension AuthCoordinator { state: inout State, action: AsyncAction ) -> Effect { - + switch action { + default: + return .none + } } private func handleInnerAction( state: inout State, action: InnerAction ) -> Effect { - + switch action { + case .pushOnBoarding: + return .run { send in + await Task.yield() + await send(.inner(.performPushOnBoarding)) + } + + case .performPushOnBoarding: + state.routes.push(.onBoarding(.init())) + return .none + } } + } extension AuthCoordinator { @Reducer public enum AuthScreen { case login(LoginFeature) - case onBoarding(OnBoardingCoordinator) + case onBoarding(OnBoardingFeature) } } // MARK: - AuthScreen State Equatable & Hashable extension AuthCoordinator.AuthScreen.State: Equatable {} extension AuthCoordinator.AuthScreen.State: Hashable {} + + diff --git a/Projects/Presentation/Auth/Sources/Coordinator/View/AuthCoordinatorView.swift b/Projects/Presentation/Auth/Sources/Coordinator/View/AuthCoordinatorView.swift index 2d8ec79..c53b0ab 100644 --- a/Projects/Presentation/Auth/Sources/Coordinator/View/AuthCoordinatorView.swift +++ b/Projects/Presentation/Auth/Sources/Coordinator/View/AuthCoordinatorView.swift @@ -26,7 +26,7 @@ public struct AuthCoordinatorView: View { .navigationBarBackButtonHidden() case .onBoarding(let onBoardingStore): - OnBoardingCoordinatorView(store: onBoardingStore) + OnBoardingView(store: onBoardingStore) .navigationBarBackButtonHidden() .transition(.opacity.combined(with: .scale(scale: 0.98))) } diff --git a/Projects/Presentation/Auth/Sources/View/LoginView.swift b/Projects/Presentation/Auth/Sources/View/LoginView.swift index 04c517d..840fd66 100644 --- a/Projects/Presentation/Auth/Sources/View/LoginView.swift +++ b/Projects/Presentation/Auth/Sources/View/LoginView.swift @@ -39,6 +39,7 @@ public struct LoginView: View { ) { termServiceStore in TermsAgreementView(store: termServiceStore) } + .toastOverlay() } } } @@ -50,10 +51,22 @@ extension LoginView { private func loginLogo() -> some View { VStack{ Spacer() + .frame(height: 180) + + Image(asset: .logo) + .resizable() + .scaledToFit() + .frame(width: 212, height: 38) + + Spacer() + .frame(height: 30) + + Image(asset: .loginlogo) + .resizable() + .scaledToFit() + .frame(height: 200) + - Text("Time Spot") - .pretendardFont(family: .SemiBold, size: 48) - .foregroundStyle(.black) Spacer() } diff --git a/Projects/Presentation/Home/Project.swift b/Projects/Presentation/Home/Project.swift index 2b4cc12..cfb4df4 100644 --- a/Projects/Presentation/Home/Project.swift +++ b/Projects/Presentation/Home/Project.swift @@ -12,8 +12,10 @@ let project = Project.makeModule( settings: .settings(), dependencies: [ .Domain(implements: .UseCase), - .Shared(implements: .DesignSystem), + .Shared(implements: .Shared), .SPM.composableArchitecture, + .SPM.tcaCoordinator, + .Presentation(implements: .Profile), .xcframework(path: "./Resources/framework/NMapsMap.xcframework"), .xcframework(path: "./Resources/framework/NMapsGeometry.xcframework") ], diff --git a/Projects/Presentation/OnBoarding/Sources/Coordintaor/Reducer/OnBoardingCoordinator.swift b/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift similarity index 59% rename from Projects/Presentation/OnBoarding/Sources/Coordintaor/Reducer/OnBoardingCoordinator.swift rename to Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift index 59aa5c2..40ba28a 100644 --- a/Projects/Presentation/OnBoarding/Sources/Coordintaor/Reducer/OnBoardingCoordinator.swift +++ b/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift @@ -1,29 +1,30 @@ // -// OnBoardingCoordinator.swift -// OnBoarding +// HomeCoordinator.swift +// Home // -// Created by Wonji Suh on 3/21/26. +// Created by Wonji Suh on 3/24/26. // import ComposableArchitecture import TCACoordinators +import Profile @Reducer -public struct OnBoardingCoordinator { +public struct HomeCoordinator { public init(){} @ObservableState - public struct State: Equatable, Hashable { - var routes: [Route] + public struct State: Equatable { + var routes: [Route] public init() { - self.routes = [.root(.onBoarding(.init()), withNavigation: true)] + self.routes = [.root(.home(.init()), embedInNavigationView: true)] } } public enum Action { - case router(IndexedRouterActionOf) + case router(IndexedRouterActionOf) case view(View) case async(AsyncAction) case inner(InnerAction) @@ -45,12 +46,14 @@ public struct OnBoardingCoordinator { // MARK: - 앱내에서 사용하는 액션 public enum InnerAction: Equatable { - + case presentProfile + case presentProfileWithAnimation } // MARK: - NavigationAction public enum NavigationAction: Equatable { - case presentMain + case presentAuth + } public var body: some Reducer { @@ -77,15 +80,23 @@ public struct OnBoardingCoordinator { } -extension OnBoardingCoordinator { +extension HomeCoordinator { private func routerAction( state: inout State, - action: IndexedRouterActionOf + action: IndexedRouterActionOf ) -> Effect { switch action { + case .routeAction(id: _, action: .home(.delegate(.presentProfile))): + return .run { send in + await send(.inner(.presentProfileWithAnimation)) + } + + case .routeAction(id: _, action: .profile(.navigation(.presentRoot))): + return .send(.view(.backAction)) + + case .routeAction(id: _, action: .profile(.navigation(.presentAuth))): + return .send(.navigation(.presentAuth)) - case .routeAction(id: _, action: .onBoarding(.navigation(.onBoardingCompleted))): - return .send(.navigation(.presentMain)) default: return .none @@ -112,7 +123,7 @@ extension OnBoardingCoordinator { action: NavigationAction ) -> Effect { switch action { - case .presentMain: + case .presentAuth: return .none } } @@ -121,24 +132,40 @@ extension OnBoardingCoordinator { state: inout State, action: AsyncAction ) -> Effect { - + switch action { + default: + return .none + } } private func handleInnerAction( state: inout State, action: InnerAction ) -> Effect { + switch action { + case .presentProfile: + state.routes.push(.profile(.init())) + return .none + case .presentProfileWithAnimation: + state.routes.push(.profile(.init())) + return .none + } } + } -extension OnBoardingCoordinator { +extension HomeCoordinator { @Reducer - public enum OnBoardingScreen { - case onBoarding(OnBoardingFeature) + public enum HomeScreen { + case home(HomeFeature) + case explore(ExploreReducer) + case profile(ProfileCoordinator) } } -// MARK: - OnBoardingScreen State Equatable & Hashable -extension OnBoardingCoordinator.OnBoardingScreen.State: Equatable {} -extension OnBoardingCoordinator.OnBoardingScreen.State: Hashable {} +// MARK: - HomeScreen State Equatable & Hashable +extension HomeCoordinator.HomeScreen.State: Equatable {} +extension HomeCoordinator.HomeScreen.State: Hashable {} + + diff --git a/Projects/Presentation/Home/Sources/Coordinator/View/HomeCoordinatorView.swift b/Projects/Presentation/Home/Sources/Coordinator/View/HomeCoordinatorView.swift new file mode 100644 index 0000000..ca20df5 --- /dev/null +++ b/Projects/Presentation/Home/Sources/Coordinator/View/HomeCoordinatorView.swift @@ -0,0 +1,55 @@ +// +// HomeCoordinatorView.swift +// Home +// +// Created by Wonji Suh on 3/24/26. +// + +import SwiftUI +import ComposableArchitecture +import TCACoordinators +import Profile + +public struct HomeCoordinatorView: View { + @Bindable var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + TCARouter(store.scope(state: \.routes, action: \.router)) { screen in + switch screen.case { + case .home(let homeStore): + HomeView(store: homeStore) + .navigationBarBackButtonHidden() + .transition(.asymmetric( + insertion: .move(edge: .leading).combined(with: .opacity), + removal: .move(edge: .trailing).combined(with: .opacity) + )) + + case .explore(let exploreStore): + ExploreView(store: exploreStore) + .navigationBarBackButtonHidden() + .transition(.asymmetric( + insertion: .move(edge: .bottom).combined(with: .opacity), + removal: .move(edge: .top).combined(with: .opacity) + )) + + case .profile(let profileStore): + ProfileCoordinatorView(store: profileStore) + .navigationBarBackButtonHidden() + .transition(.asymmetric( + insertion: .move(edge: .trailing).combined(with: .opacity), + removal: .move(edge: .leading).combined(with: .opacity) + )) + } + } + .animation(.easeInOut(duration: 0.35), value: store.routes.count) + .transaction { transaction in + if store.routes.count > 1 { + transaction.animation = .easeInOut(duration: 0.35) + } + } + } +} diff --git a/Projects/Presentation/Home/Sources/Reducer/HomeReducer.swift b/Projects/Presentation/Home/Sources/Explore/Reducer/ExploreReducer.swift similarity index 94% rename from Projects/Presentation/Home/Sources/Reducer/HomeReducer.swift rename to Projects/Presentation/Home/Sources/Explore/Reducer/ExploreReducer.swift index 239eece..cd3a2f1 100644 --- a/Projects/Presentation/Home/Sources/Reducer/HomeReducer.swift +++ b/Projects/Presentation/Home/Sources/Explore/Reducer/ExploreReducer.swift @@ -1,5 +1,5 @@ // -// HomeReducer.swift +// ExploreReducer.swift // Home // // Created by wonji suh on 2026-03-12 @@ -14,7 +14,7 @@ import UseCase import Entity @Reducer -public struct HomeReducer: Sendable { +public struct ExploreReducer: Sendable { public init() {} @ObservableState @@ -128,7 +128,7 @@ public struct HomeReducer: Sendable { } } -extension HomeReducer { +extension ExploreReducer { private func handleViewAction( state: inout State, action: View @@ -418,3 +418,18 @@ extension HomeReducer { } } } + +// MARK: - ExploreReducer.State + Hashable +extension ExploreReducer.State: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(locationPermissionStatus) + hasher.combine(currentLocation?.coordinate.latitude) + hasher.combine(currentLocation?.coordinate.longitude) + hasher.combine(isLocationPermissionDenied) + hasher.combine(locationError) + hasher.combine(isLoadingRoute) + hasher.combine(routeError) + hasher.combine(shouldReturnToCurrentLocation) + // Note: alert, selectedDestination, routeInfo are not hashed as they contain complex types + } +} diff --git a/Projects/Presentation/Home/Sources/View/HomeView.swift b/Projects/Presentation/Home/Sources/Explore/View/ExploreView.swift similarity index 96% rename from Projects/Presentation/Home/Sources/View/HomeView.swift rename to Projects/Presentation/Home/Sources/Explore/View/ExploreView.swift index 371367f..d0f9043 100644 --- a/Projects/Presentation/Home/Sources/View/HomeView.swift +++ b/Projects/Presentation/Home/Sources/Explore/View/ExploreView.swift @@ -1,5 +1,5 @@ // -// HomeView.swift +// ExploreView.swift // Home // // Created by wonji suh on 2026-03-12 @@ -11,10 +11,10 @@ import ComposableArchitecture import CoreLocation import Entity -public struct HomeView: View { - @Bindable var store: StoreOf +public struct ExploreView: View { + @Bindable var store: StoreOf - public init(store: StoreOf) { + public init(store: StoreOf) { self.store = store } @@ -238,10 +238,3 @@ public struct HomeView: View { } -#Preview { - HomeView( - store: Store(initialState: HomeReducer.State()) { - HomeReducer() - } - ) -} diff --git a/Projects/Presentation/Home/Sources/LocationPermissionManager.swift b/Projects/Presentation/Home/Sources/LocationPermissionManager.swift index d6cdb69..449a315 100644 --- a/Projects/Presentation/Home/Sources/LocationPermissionManager.swift +++ b/Projects/Presentation/Home/Sources/LocationPermissionManager.swift @@ -13,23 +13,43 @@ import CoreLocation import UIKit #endif +// MARK: - Location Errors +public enum LocationError: Error, LocalizedError { + case permissionDenied + case locationUnavailable + case timeout + + public var errorDescription: String? { + switch self { + case .permissionDenied: + return "위치 권한이 거부되었습니다." + case .locationUnavailable: + return "위치 정보를 가져올 수 없습니다." + case .timeout: + return "위치 요청 시간이 초과되었습니다." + } + } +} + // Swift Concurrency를 사용한 위치 권한 전용 관리자 @MainActor -public class LocationPermissionManager: NSObject, Sendable { +public final class LocationPermissionManager: NSObject, ObservableObject { // 싱글톤 인스턴스 public static let shared = LocationPermissionManager() - public var authorizationStatus: CLAuthorizationStatus = .notDetermined - public var currentLocation: CLLocation? - public var locationError: String? + @Published public private(set) var authorizationStatus: CLAuthorizationStatus = .notDetermined + @Published public private(set) var currentLocation: CLLocation? + @Published public private(set) var locationError: String? private let locationManager = CLLocationManager() private var authorizationContinuation: CheckedContinuation? private var locationContinuation: CheckedContinuation? - // 지속적인 위치 업데이트 콜백 - public var onLocationUpdate: ((CLLocation) -> Void)? - public var onLocationError: ((Error) -> Void)? + // 지속적인 위치 업데이트 콜백 (MainActor 격리) + @MainActor + public var onLocationUpdate: (@MainActor (CLLocation) -> Void)? + @MainActor + public var onLocationError: (@MainActor (Error) -> Void)? public override init() { super.init() @@ -113,18 +133,14 @@ public class LocationPermissionManager: NSObject, Sendable { } } - public enum LocationError: Error { - case permissionDenied - case locationUnavailable - case timeout - } - // 설정 앱으로 이동 public func openLocationSettings() { #if canImport(UIKit) - if let settingsUrl = URL(string: UIApplication.openSettingsURLString), - UIApplication.shared.canOpenURL(settingsUrl) { - UIApplication.shared.open(settingsUrl) + Task { @MainActor in + if let settingsUrl = URL(string: UIApplication.openSettingsURLString), + UIApplication.shared.canOpenURL(settingsUrl) { + await UIApplication.shared.open(settingsUrl) + } } #endif } @@ -164,7 +180,7 @@ extension LocationPermissionManager: CLLocationManagerDelegate { self.locationError = nil // 지속적인 위치 업데이트 콜백 호출 - self.onLocationUpdate?(location) + await self.onLocationUpdate?(location) // continuation이 있으면 결과 반환 (일회성 요청용) if let continuation = self.locationContinuation { @@ -179,7 +195,7 @@ extension LocationPermissionManager: CLLocationManagerDelegate { self.locationError = "위치 업데이트 실패: \(error.localizedDescription)" // 지속적인 위치 업데이트 에러 콜백 호출 - self.onLocationError?(error) + await self.onLocationError?(error) // continuation이 있으면 에러 반환 (일회성 요청용) if let continuation = self.locationContinuation { diff --git a/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift b/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift new file mode 100644 index 0000000..72b0fa3 --- /dev/null +++ b/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift @@ -0,0 +1,171 @@ +// +// HomeReducer.swift +// Home +// +// Created by Wonji Suh on 3/24/26. +// + + +import Foundation +import ComposableArchitecture +import Utill + + +@Reducer +public struct HomeFeature { + @Dependency(\.date.now) var now + + public init() {} + + @ObservableState + public struct State: Equatable { + public init() { + let currentDate = Date() + departureTime = currentDate + currentTime = currentDate + todayDate = currentDate + } + + var departureTimePickerVisible: Bool = false + var departureTime: Date + var currentTime: Date + var todayDate: Date + var isSelected: Bool = false + var isDepartureTimeSet: Bool = false + } + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case delegate(DelegateAction) + + } + + //MARK: - ViewAction + @CasePathable + public enum View { + case departureTimeButtonTapped + case departureTimeChanged(Date) + } + + + + //MARK: - AsyncAction 비동기 처리 액션 + public enum AsyncAction: Equatable { + + } + + //MARK: - 앱내에서 사용하는 액션 + public enum InnerAction: Equatable { + } + + //MARK: - NavigationAction + public enum DelegateAction: Equatable { + case presentProfile + + } + + + public var body: some ReducerOf { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding: + return .none + + case .view(let viewAction): + return handleViewAction(state: &state, action: viewAction) + + case .async(let asyncAction): + return handleAsyncAction(state: &state, action: asyncAction) + + case .inner(let innerAction): + return handleInnerAction(state: &state, action: innerAction) + + case .delegate(let delegateAction): + return handleDelegateAction(state: &state, action: delegateAction) + } + } + } +} + +extension HomeFeature { + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .departureTimeButtonTapped: + state.departureTimePickerVisible.toggle() + return .none + + case .departureTimeChanged(let date): + state.currentTime = now + state.departureTime = date + state.departureTimePickerVisible = false + state.isDepartureTimeSet = true + return .none + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + return .none + } + + private func handleDelegateAction( + state: inout State, + action: DelegateAction + ) -> Effect { + switch action { + case .presentProfile: + return .none + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + return .none + } +} + +extension HomeFeature.State { + var hasRemainingTimeResult: Bool { + isDepartureTimeSet && !departureTimePickerVisible + } + + var remainingTime: DateComponents { + guard isDepartureTimeSet else { + return DateComponents(hour: 0, minute: 0) + } + + return Calendar.current.remainingTimeComponents(from: currentTime, to: departureTime) + } + + var remainingHoursText: String { + String(format: "%02d", remainingTime.hour ?? 0) + } + + var remainingMinutesText: String { + String(format: "%02d", remainingTime.minute ?? 0) + } +} + +// MARK: - HomeReducer.State + Hashable +extension HomeFeature.State: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(departureTimePickerVisible) + hasher.combine(todayDate) + hasher.combine(isSelected) + hasher.combine(currentTime) + hasher.combine(departureTime) + hasher.combine(isDepartureTimeSet) + } +} diff --git a/Projects/Presentation/Home/Sources/Main/View/HomeView.swift b/Projects/Presentation/Home/Sources/Main/View/HomeView.swift new file mode 100644 index 0000000..c0804a9 --- /dev/null +++ b/Projects/Presentation/Home/Sources/Main/View/HomeView.swift @@ -0,0 +1,268 @@ +// +// HomeView.swift +// Home +// +// Created by Wonji Suh on 3/24/26. +// + +import SwiftUI + +import DesignSystem +import Utill + +import ComposableArchitecture + +public struct HomeView: View { + @Bindable var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + ZStack { + Color.gray300.opacity(0.5) + .edgesIgnoringSafeArea(.all) + + VStack(spacing: 0) { + logoContentView() + .zIndex(1) + + timeLeftView() + .padding(.top, 14) + .zIndex(0) + + exploreNearbyButton() + .padding(.top, 28) + + Spacer() + } + } + } +} + + + +extension HomeView { + + @ViewBuilder + fileprivate func logoContentView() -> some View { + let heroHeight: CGFloat = 524 + + GeometryReader { geometry in + ZStack(alignment: .topLeading) { + ZStack(alignment: .top) { + Image(asset: .homeLogo) + .resizable() + .scaledToFill() + .frame(width: geometry.size.width, height: heroHeight, alignment: .top) + .scaleEffect(1.06, anchor: .top) + .ignoresSafeArea(edges: .top) + + VStack { + navigationBar() + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + + VStack(spacing: 8) { + selectStationView() + + selectTrainTimeView() + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + .padding(.bottom, 28) + } + .frame(width: geometry.size.width, height: heroHeight) + .background(.clear) + .clipShape( + UnevenRoundedRectangle( + cornerRadii: .init( + bottomLeading: 40, + bottomTrailing: 40 + ) + ) + ) + + if store.departureTimePickerVisible { + departureTimePickerView() + .offset(x: geometry.size.width - 8 - 176 - 8, y: 492) + .zIndex(2) + } + } + } + .frame(height: heroHeight) + } + + @ViewBuilder + fileprivate func navigationBar() -> some View { + HStack { + Spacer() + + Button { + store.send(.delegate(.presentProfile)) + } label: { + Image(asset: .profile) + .resizable() + .scaledToFit() + .frame(width: 56, height: 56) + } + .buttonStyle(.plain) + } + .padding(.top, 10) + .padding(.horizontal, 14) + } + + + + @ViewBuilder + fileprivate func selectStationView() -> some View { + VStack(alignment: .center, spacing: 0) { + Text(store.todayDate.formattedKoreanDateWithWeekday()) + .pretendardCustomFont(textStyle: .body2Medium) + .foregroundStyle(.gray900.opacity(0.9)) + .padding(.bottom, 4) + + Text("SEOUL") + .pretendardFont(family: .Bold, size: 64) + .foregroundStyle(store.isSelected ? .gray900 : .slateGray) + .tracking(-2.2) + } + .frame(maxWidth: .infinity) + } + + @ViewBuilder + fileprivate func selectTrainTimeView() -> some View { + HStack(alignment: .top, spacing: 10) { + timeCapsuleView( + title: "현재 시간", + time: store.currentTime.formattedKoreanTime(), + timeColor: .gray900, + backgroundColor: .gray100 + ) + + Button { + store.send(.view(.departureTimeButtonTapped)) + } label: { + timeCapsuleView( + title: "출발 시간", + time: store.isDepartureTimeSet + ? store.departureTime.formattedKoreanTime() + : store.currentTime.formattedKoreanTime(), + timeColor: store.isDepartureTimeSet ? .gray900 : .enableColor, + backgroundColor: .gray100 + ) + } + .buttonStyle(.plain) + .frame(maxWidth: .infinity) + } + .padding(.horizontal, 8) + .padding(.top, 4) + } + + @ViewBuilder + fileprivate func departureTimePickerView() -> some View { + DatePicker( + "출발 시간 선택", + selection: $store.departureTime, + displayedComponents: [.hourAndMinute] + ) + .datePickerStyle(.wheel) + .labelsHidden() + .environment(\.locale, Locale(identifier: "ko_KR")) + .onChange(of: store.departureTime) { _, newValue in + store.send(.view(.departureTimeChanged(newValue))) + } + .frame(height: 180) + .clipped() + .frame(width: 176) + .background(.gray300) + .clipShape(RoundedRectangle(cornerRadius: 24)) + } + + @ViewBuilder + fileprivate func timeCapsuleView( + title: String, + time: String, + timeColor: Color, + backgroundColor: Color + ) -> some View { + VStack(spacing: 4) { + Text(title) + .pretendardCustomFont(textStyle: .caption) + .foregroundStyle(.gray700) + + Text(time) + .pretendardCustomFont(textStyle: .bodyMedium) + .foregroundStyle(timeColor) + } + .frame(maxWidth: .infinity) + .frame(height: 77) + .background(backgroundColor) + .cornerRadius(32) + } + + + @ViewBuilder + fileprivate func timeLeftView() -> some View { + HStack { + Spacer() + + VStack { + Text(store.remainingHoursText) + .pretendardFont(family: .SemiBold, size: 52) + .foregroundStyle(store.hasRemainingTimeResult ? .gray900 : .enableColor) + .frame(height: 69) + + Text("HOURS") + .pretendardCustomFont(textStyle: .caption) + .foregroundStyle(store.hasRemainingTimeResult ? .gray900 : .enableColor) + + } + + Spacer() + .frame(width: 24) + + Image(asset: .time) + .resizable() + .scaledToFit() + .frame(width: 6, height: 31) + + Spacer() + .frame(width: 24) + + VStack { + Text(store.remainingMinutesText) + .pretendardFont(family: .SemiBold, size: 52) + .foregroundStyle(store.hasRemainingTimeResult ? .gray900 : .enableColor) + .frame(height: 69) + + Text("MINUTES") + .pretendardCustomFont(textStyle: .caption) + .foregroundStyle(store.hasRemainingTimeResult ? .gray900 : .enableColor) + + } + + Spacer() + + } + .padding(.vertical, 21) + .background( + RoundedRectangle(cornerRadius: 36) + .fill(.white) + ) + .padding(.horizontal, 24) + } + + @ViewBuilder + fileprivate func exploreNearbyButton() -> some View { + CustomButton( + action: {}, + title: "주변 탐색 시작하기", + config: CustomButtonConfig.create(), + isEnable: false + ) + .padding(.horizontal, 24) + } +} diff --git a/Projects/Presentation/OnBoarding/Sources/Coordintaor/View/OnBoardingCoordinatorView.swift b/Projects/Presentation/OnBoarding/Sources/Coordintaor/View/OnBoardingCoordinatorView.swift deleted file mode 100644 index 51866c2..0000000 --- a/Projects/Presentation/OnBoarding/Sources/Coordintaor/View/OnBoardingCoordinatorView.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// OnBoardingCoordinatorView.swift -// OnBoarding -// -// Created by Wonji Suh on 3/21/26. -// - -import SwiftUI - -import ComposableArchitecture -import TCACoordinators - -public struct OnBoardingCoordinatorView: View { - @Bindable var store: StoreOf - - public init(store: StoreOf) { - self.store = store - } - - public var body: some View { - TCARouter(store.scope(state: \.routes, action: \.router)) { screens in - switch screens.case { - case .onBoarding(let onBoardingStore): - OnBoardingView(store: onBoardingStore) - .navigationBarBackButtonHidden() - } - } - } -} diff --git a/Projects/Presentation/OnBoarding/Sources/Reducer/OnBoardingFeature.swift b/Projects/Presentation/OnBoarding/Sources/Reducer/OnBoardingFeature.swift index 94834e5..390d48e 100644 --- a/Projects/Presentation/OnBoarding/Sources/Reducer/OnBoardingFeature.swift +++ b/Projects/Presentation/OnBoarding/Sources/Reducer/OnBoardingFeature.swift @@ -8,6 +8,7 @@ import Foundation import ComposableArchitecture +import DesignSystem import UseCase import Entity import LogMacro @@ -22,8 +23,8 @@ public struct OnBoardingFeature { @ObservableState public struct State: Hashable { - public init() {} + @Presents public var customAlert: CustomAlertState? var stepRange: ClosedRange = 1...4 var activeStep: Int = 1 var selectedMap: ExternalMapType? = nil @@ -37,7 +38,13 @@ public struct OnBoardingFeature { case async(AsyncAction) case inner(InnerAction) case navigation(NavigationAction) + case scope(ScopeAction) + + } + @CasePathable + public enum ScopeAction { + case customAlert(PresentationAction) } //MARK: - ViewAction @@ -88,12 +95,34 @@ public struct OnBoardingFeature { case .navigation(let navigationAction): return handleNavigationAction(state: &state, action: navigationAction) + + case .scope(let scopeAction): + return handleScopeAction(state: &state, action: scopeAction) } } + .ifLet(\.$customAlert, action: \.scope.customAlert) { + CustomConfirmAlert() + } } } extension OnBoardingFeature { + private func handleScopeAction( + state: inout State, + action: ScopeAction + ) -> Effect { + switch action { + case .customAlert(.presented(.confirmTapped)), + .customAlert(.presented(.cancelTapped)), + .customAlert(.dismiss): + state.customAlert = nil + return .none + + case .customAlert(.presented(.policyTapped)): + return .none + } + } + private func handleViewAction( state: inout State, action: View @@ -159,6 +188,13 @@ extension OnBoardingFeature { case .failure(let error): #logDebug("회원가입 실패", error.localizedDescription) + state.customAlert = .alert( + title: "회원가입 실패", + message: error.errorDescription ?? "회원가입 중 문제가 발생했어요.", + confirmTitle: "확인", + cancelTitle: "닫기", + isDestructive: false + ) return .none } } @@ -176,9 +212,9 @@ extension OnBoardingFeature.State: Equatable { } extension OnBoardingFeature.State { public func hash(into hasher: inout Hasher) { + hasher.combine(customAlert != nil) hasher.combine(activeStep) hasher.combine(selectedMap) } } - diff --git a/Projects/Presentation/OnBoarding/Sources/View/OnBoardingView.swift b/Projects/Presentation/OnBoarding/Sources/View/OnBoardingView.swift index c5a964b..e435f32 100644 --- a/Projects/Presentation/OnBoarding/Sources/View/OnBoardingView.swift +++ b/Projects/Presentation/OnBoarding/Sources/View/OnBoardingView.swift @@ -35,6 +35,7 @@ public struct OnBoardingView: View { Spacer() } } + .customAlert($store.scope(state: \.customAlert, action: \.scope.customAlert)) } } diff --git a/Projects/Presentation/Profile/Sources/Base.swift b/Projects/Presentation/Profile/Sources/Base.swift deleted file mode 100644 index ded44bb..0000000 --- a/Projects/Presentation/Profile/Sources/Base.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// base.swift -// DDDAttendance. -// -// Created by Roy on 2026-03-23 -// Copyright © 2026 DDD , Ltd., All rights reserved. -// - -import SwiftUI - -struct BaseView: View { - var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundColor(.accentColor) - Text("Hello, world!") - } - .padding() - } -} - diff --git a/Projects/Presentation/Profile/Sources/Components/ProfileSkeletonView.swift b/Projects/Presentation/Profile/Sources/Components/ProfileSkeletonView.swift new file mode 100644 index 0000000..bd7d89f --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Components/ProfileSkeletonView.swift @@ -0,0 +1,188 @@ +// +// ProfileSkeletonView.swift +// Profile +// +// Created by Wonji Suh on 3/25/26. +// + +import SwiftUI +import DesignSystem + +public struct ProfileSkeletonView: View { + + public init() {} + + public var body: some View { + ZStack { + Color.gray100 + .edgesIgnoringSafeArea(.all) + + VStack { + Spacer() + .frame(height: 8) + + // Navigation Bar Skeleton + CustomNavigationBar( + title: "마이페이지", + leftImage: .leftArrow, + rightImage: .setting, + leftAction: {}, + rightAction: {} + ) + + profileInfoCardSkeletonView() + + travelHistorySkeleton() + + Spacer() + } + .padding(.horizontal, 16) + } + } +} + +extension ProfileSkeletonView { + @ViewBuilder + private func profileInfoCardSkeletonView() -> some View { + VStack(alignment: .leading) { + VStack { + Spacer() + .frame(height: 12) + + HStack { + // Name skeleton + RoundedRectangle(cornerRadius: 6) + .fill(.blueGray600) + .frame(width: 100, height: 24) + .shimmerEffect() + + Spacer() + } + .padding(.horizontal, 8) + + Spacer() + .frame(height: 3) + + HStack { + // Icon skeleton + Circle() + .fill(.blueGray600) + .frame(width: 16, height: 16) + .shimmerEffect() + + Spacer() + .frame(width: 4) + + // Email skeleton + RoundedRectangle(cornerRadius: 4) + .fill(.blueGray600) + .frame(width: 150, height: 14) + .shimmerEffect() + + Spacer() + } + .padding(.horizontal, 8) + + Spacer() + .frame(height: 24) + + HStack { + Spacer() + + VStack(alignment: .center) { + // "방문한 장소" skeleton + RoundedRectangle(cornerRadius: 4) + .fill(.blueGray600) + .frame(width: 60, height: 12) + .shimmerEffect() + + Spacer() + .frame(height: 4) + + // Count skeleton + RoundedRectangle(cornerRadius: 4) + .fill(.blueGray600) + .frame(width: 40, height: 16) + .shimmerEffect() + } + + Spacer() + + Image(asset: .lineHeight) + .resizable() + .scaledToFit() + .frame(height: 48) + + Spacer() + + VStack(alignment: .center) { + // "탐험 시간" skeleton + RoundedRectangle(cornerRadius: 4) + .fill(.blueGray600) + .frame(width: 50, height: 12) + .shimmerEffect() + + Spacer() + .frame(height: 4) + + // Time skeleton + RoundedRectangle(cornerRadius: 4) + .fill(.blueGray600) + .frame(width: 70, height: 16) + .shimmerEffect() + } + + Spacer() + } + .padding(.horizontal, 21) + .padding(.vertical, 11) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(.blueGray800) + ) + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 24) + .fill(.navy900) + ) + } + } + + @ViewBuilder + private func travelHistorySkeleton() -> some View { + VStack(alignment: .leading, spacing: 0) { + Spacer() + .frame(height: 40) + + HStack { + // "나의 히스토리" skeleton + RoundedRectangle(cornerRadius: 6) + .fill(.gray300) + .frame(width: 120, height: 20) + .shimmerEffect() + + Spacer() + + // Sort button skeleton + RoundedRectangle(cornerRadius: 4) + .fill(.gray300) + .frame(width: 80, height: 16) + .shimmerEffect() + } + + Spacer() + .frame(height: 20) + + ScrollView(.vertical) { + VStack(spacing: 12) { + ForEach(0..<3, id: \.self) { _ in + TravelHistoryCardSkeletonView() + } + } + .padding(.bottom, 24) + } + .scrollIndicators(.hidden) + } + } +} diff --git a/Projects/Presentation/Profile/Sources/Components/SettingMenuRowView.swift b/Projects/Presentation/Profile/Sources/Components/SettingMenuRowView.swift new file mode 100644 index 0000000..4878088 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Components/SettingMenuRowView.swift @@ -0,0 +1,90 @@ +// +// SettingMenuRowView.swift +// Profile +// +// Created by Wonji Suh on 3/25/26. +// + +import SwiftUI + +import DesignSystem + +public struct SettingMenuRowView: View { + enum Accessory: Equatable { + case chevron + case dropdown + case none + } + + private let title: String + private let trailingText: String? + private let accessory: Accessory + private let showsDivider: Bool + private let action: () -> Void + + init( + title: String, + trailingText: String? = nil, + accessory: Accessory = .chevron, + showsDivider: Bool = true, + action: @escaping () -> Void = {} + ) { + self.title = title + self.trailingText = trailingText + self.accessory = accessory + self.showsDivider = showsDivider + self.action = action + } + + public var body: some View { + Button(action: action) { + VStack(spacing: 0) { + HStack(spacing: 12) { + Text(title) + .pretendardCustomFont(textStyle: .bodyMedium) + .foregroundStyle(.gray900) + + Spacer(minLength: 12) + + if let trailingText { + Text(trailingText) + .pretendardCustomFont(textStyle: .bodyMedium) + .foregroundStyle(.gray550) + .lineLimit(1) + } + + accessoryView + } + .frame(height: 52) + + if showsDivider { + Rectangle() + .fill(.gray300) + .frame(height: 1) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + @ViewBuilder + private var accessoryView: some View { + switch accessory { + case .chevron: + Image(systemName: "chevron.right") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(.gray550) + .frame(width: 8, height: 12) + + case .dropdown: + Image(systemName: "chevron.down") + .font(.system(size: 15, weight: .medium)) + .frame(width: 8, height: 12) + .foregroundStyle(.gray550) + + case .none: + EmptyView() + } + } +} diff --git a/Projects/Presentation/Profile/Sources/Components/TravelHistoryCardSkeletonView.swift b/Projects/Presentation/Profile/Sources/Components/TravelHistoryCardSkeletonView.swift new file mode 100644 index 0000000..204f460 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Components/TravelHistoryCardSkeletonView.swift @@ -0,0 +1,174 @@ +// +// TravelHistoryCardSkeletonView.swift +// Profile +// +// Created by Wonji Suh on 3/25/26. +// + +import SwiftUI +import DesignSystem + +public struct TravelHistoryCardSkeletonView: View { + + public init() {} + + public var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Date skeleton + RoundedRectangle(cornerRadius: 4) + .fill(.gray300) + .frame(width: 80, height: 12) + .shimmerEffect() + + Spacer() + .frame(height: 10) + + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + // Place name skeleton + RoundedRectangle(cornerRadius: 4) + .fill(.gray300) + .frame(width: 120, height: 20) + .shimmerEffect() + + // "방문 장소" skeleton + RoundedRectangle(cornerRadius: 4) + .fill(.gray300) + .frame(width: 60, height: 14) + .shimmerEffect() + } + + Spacer() + + // Travel line skeleton + RoundedRectangle(cornerRadius: 3) + .fill(.gray300) + .frame(width: 123, height: 6) + .padding(.vertical, 9) + .shimmerEffect() + + Spacer() + .frame(width: 24) + + VStack(alignment: .trailing, spacing: 4) { + // Departure name skeleton + RoundedRectangle(cornerRadius: 4) + .fill(.gray300) + .frame(width: 52, height: 20) + .shimmerEffect() + + // "출발역" skeleton + RoundedRectangle(cornerRadius: 4) + .fill(.gray300) + .frame(width: 40, height: 14) + .shimmerEffect() + } + .frame(width: 52, alignment: .trailing) + } + + Spacer() + .frame(height: 20) + + // Dotted line skeleton + Path { path in + path.move(to: CGPoint(x: 0, y: 0.5)) + path.addLine(to: CGPoint(x: 330, y: 0.5)) + } + .stroke( + .gray400, + style: StrokeStyle( + lineWidth: 1, + lineCap: .round, + dash: [1, 4] + ) + ) + .frame(height: 1) + + Spacer() + .frame(height: 20) + + HStack(alignment: .bottom) { + VStack(alignment: .leading, spacing: 2) { + // "총 여정 시간" skeleton + RoundedRectangle(cornerRadius: 4) + .fill(.gray300) + .frame(width: 70, height: 12) + .shimmerEffect() + + // Duration skeleton + RoundedRectangle(cornerRadius: 4) + .fill(.gray300) + .frame(width: 60, height: 16) + .shimmerEffect() + } + + Spacer() + + VStack(alignment: .trailing, spacing: 6) { + // "열차 출발" skeleton + RoundedRectangle(cornerRadius: 4) + .fill(.gray300) + .frame(width: 50, height: 12) + .shimmerEffect() + + // Departure time skeleton + RoundedRectangle(cornerRadius: 4) + .fill(.gray300) + .frame(width: 70, height: 16) + .shimmerEffect() + } + } + } + .padding(20) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 24) + .fill(.gray200) + .overlay { + RoundedRectangle(cornerRadius: 24) + .stroke(.neutral200, style: .init(lineWidth: 1)) + } + ) + } +} + +// MARK: - Shimmer Effect Extension +extension View { + func shimmerEffect() -> some View { + modifier(ShimmerEffectModifier()) + } +} + +private struct ShimmerEffectModifier: ViewModifier { + @State private var isAnimating = false + + func body(content: Content) -> some View { + content + .overlay { + GeometryReader { geometry in + LinearGradient( + colors: [ + .white.opacity(0), + .white.opacity(0.28), + .white.opacity(0) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .frame(width: geometry.size.width * 0.55) + .offset(x: isAnimating ? geometry.size.width * 1.25 : -geometry.size.width * 0.8) + } + .clipped() + } + .mask(content) + .onAppear { + guard !isAnimating else { return } + withAnimation( + .easeInOut(duration: 1.0) + .repeatForever(autoreverses: false) + ) { + isAnimating = true + } + } + } +} diff --git a/Projects/Presentation/Profile/Sources/Components/TravelHistoryCardView.swift b/Projects/Presentation/Profile/Sources/Components/TravelHistoryCardView.swift new file mode 100644 index 0000000..ad44357 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Components/TravelHistoryCardView.swift @@ -0,0 +1,141 @@ +// +// TravelHistoryCardView.swift +// Profile +// +// Created by Wonji Suh on 3/25/26. +// + +import SwiftUI + +import DesignSystem +import Utill + +public struct TravelHistoryCardItem: Identifiable, Hashable { + public let id = UUID() + public let visitedAt: Date + public let placeName: String + public let departureName: String + public let durationText: String + public let departureTimeText: String + + public init( + visitedAt: Date, + placeName: String, + departureName: String, + durationText: String, + departureTimeText: String + ) { + self.visitedAt = visitedAt + self.placeName = placeName + self.departureName = departureName + self.durationText = durationText + self.departureTimeText = departureTimeText + } +} + +public struct TravelHistoryCardView: View { + let item: TravelHistoryCardItem + + public init(item: TravelHistoryCardItem) { + self.item = item + } + + public var body: some View { + VStack(alignment: .leading, spacing: 0) { + Text(item.visitedAt.formattedDateToString()) + .pretendardCustomFont(textStyle: .caption) + .foregroundStyle(.gray800) + + Spacer() + .frame(height: 10) + + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text(item.placeName) + .pretendardCustomFont(textStyle: .titleRegular) + .foregroundStyle(.staticBlack) + + Text("방문 장소") + .pretendardCustomFont(textStyle: .body2Medium) + .foregroundStyle(.gray800) + } + + Spacer() + + Image(asset: .travelLine) + .resizable() + .scaledToFit() + .frame(width: 123, height: 6) + .padding(.vertical, 9) + + Spacer() + .frame(width: 24) + + VStack(alignment: .trailing, spacing: 4) { + Text(item.departureName) + .pretendardCustomFont(textStyle: .titleRegular) + .foregroundStyle(.staticBlack) + + Text("출발역") + .pretendardCustomFont(textStyle: .body2Medium) + .foregroundStyle(.gray800) + } + .frame(width: 52, alignment: .trailing) + } + + Spacer() + .frame(height: 20) + + Path { path in + path.move(to: CGPoint(x: 0, y: 0.5)) + path.addLine(to: CGPoint(x: 330, y: 0.5)) + } + .stroke( + .gray400, + style: StrokeStyle( + lineWidth: 1, + lineCap: .round, + dash: [1, 4] + ) + ) + .frame(height: 1) + + Spacer() + .frame(height: 20) + + HStack(alignment: .bottom) { + VStack(alignment: .leading, spacing: 2) { + Text("총 여정 시간") + .pretendardCustomFont(textStyle: .caption) + .foregroundStyle(.gray800) + + Text(item.durationText) + .pretendardCustomFont(textStyle: .bodyMedium) + .foregroundStyle(.staticBlack) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 6) { + Text("열차 출발") + .pretendardCustomFont(textStyle: .caption) + .foregroundStyle(.gray800) + + Text(item.departureTimeText) + .pretendardCustomFont(textStyle: .bodyBold) + .foregroundStyle(.staticBlack) + } + } + } + .padding(20) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 24) + .fill(.gray200) + .overlay { + RoundedRectangle(cornerRadius: 24) + .stroke(.neutral200, style: .init(lineWidth: 1)) + } + ) + } +} diff --git a/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift b/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift new file mode 100644 index 0000000..5a27cbc --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift @@ -0,0 +1,192 @@ +// +// ProfileCoordinator.swift +// Profile +// +// Created by Wonji Suh on 3/25/26. +// + +import ComposableArchitecture +import TCACoordinators + +@Reducer +public struct ProfileCoordinator { + + public init(){} + + @ObservableState + public struct State: Equatable { + var routes: [Route] + + public init() { + self.routes = [.root(.profile(.init()), embedInNavigationView: true)] + } + } + + public enum Action { + case router(IndexedRouterActionOf) + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case navigation(NavigationAction) + } + + // MARK: - ViewAction + @CasePathable + public enum View { + case backAction + case backToRootAction + } + + // MARK: - AsyncAction 비동기 처리 액션 + + public enum AsyncAction: Equatable { + + } + + // MARK: - 앱내에서 사용하는 액션 + public enum InnerAction: Equatable { + + } + + // MARK: - NavigationAction + public enum NavigationAction: Equatable { + case presentRoot + case presentAuth + + } + + public var body: some Reducer { + Reduce { state, action in + switch action { + case .router(let routeAction): + return routerAction(state: &state, action: routeAction) + + case .view(let viewAction): + return handleViewAction(state: &state, action: viewAction) + + case .async(let asyncAction): + return handleAsyncAction(state: &state, action: asyncAction) + + case .inner(let innerAction): + return handleInnerAction(state: &state, action: innerAction) + + case .navigation(let navigationAction): + return handleNavigationAction(state: &state, action: navigationAction) + } + } + .forEachRoute(\.routes, action: \.router) + } + +} + +extension ProfileCoordinator { + private func routerAction( + state: inout State, + action: IndexedRouterActionOf + ) -> Effect { + switch action { + case .routeAction(id: _, action: .profile(.delegate(.presentBack))): + return .send(.navigation(.presentRoot)) + + case .routeAction(id: _, action: .profile(.delegate(.presentSetting))): + state.routes.push(.setting(.init())) + return .none + + case .routeAction(id: _, action: .profile(.delegate(.presentAuth))): + return .send(.navigation(.presentAuth)) + + case .routeAction(id: _, action: .setting(.delegate(.presentBack))): + return .send(.view(.backAction)) + + case .routeAction(id: _, action: .setting(.delegate(.presentAuth))): + return .send(.navigation(.presentAuth)) + + + case .routeAction(id: _, action: .setting(.delegate(.presentWithDraw))): + state.routes.push(.withDraw(.init())) + return .none + + case .routeAction(id: _, action: .setting(.delegate(.presentNotificationSetting))): + state.routes.push(.notification(.init())) + return .none + + case .routeAction(id: _, action: .withDraw(.delegate(.presentBack))): + return .send(.view(.backAction)) + + case .routeAction(id: _, action: .withDraw(.delegate(.presentAuth))): + return .send(.navigation(.presentAuth)) + + case .routeAction(id: _, action: .notification(.delegate(.presentBack))): + return .send(.view(.backAction)) + + default: + return .none + } + } + + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .backAction: + state.routes.goBack() + return .none + + case .backToRootAction: + state.routes.goBackToRoot() + return .none + } + } + + private func handleNavigationAction( + state: inout State, + action: NavigationAction + ) -> Effect { + switch action { + case .presentRoot: + return .none + + case .presentAuth: + return .none + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + default: + return .none + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + return .none + } + +} + +extension ProfileCoordinator { + @Reducer + public enum ProfileScreen { + case profile(ProfileFeature) + case setting(SettingFeature) + case withDraw(WithDrawFeature) + case notification(NotificationSettingFeature) + } +} + +// MARK: - AuthScreen State Equatable & Hashable +extension ProfileCoordinator.ProfileScreen.State: Equatable {} +extension ProfileCoordinator.ProfileScreen.State: Hashable {} + +extension ProfileCoordinator.State: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(routes) + } +} diff --git a/Projects/Presentation/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift b/Projects/Presentation/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift new file mode 100644 index 0000000..a06eac9 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift @@ -0,0 +1,54 @@ +// +// ProfileCoordinatorView.swift +// Profile +// +// Created by Wonji Suh on 3/25/26. +// + +import SwiftUI + +import ComposableArchitecture +import TCACoordinators + +public struct ProfileCoordinatorView: View { + @Bindable var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + TCARouter(store.scope(state: \.routes, action: \.router)) { (screen: StoreOf) in + switch screen.case { + case .profile(let profileStore): + ProfileView(store: profileStore) + .navigationBarBackButtonHidden() + + + case .setting(let settingStore): + SettingView(store: settingStore) + .navigationBarBackButtonHidden() + .transition(.asymmetric( + insertion: .move(edge: .trailing), + removal: .move(edge: .leading) + )) + + case .withDraw(let withDrawStore): + WithDrawView(store: withDrawStore) + .navigationBarBackButtonHidden() + .transition(.asymmetric( + insertion: .move(edge: .trailing), + removal: .move(edge: .leading) + )) + + case .notification(let notificationStore): + NotificationSettingView(store: notificationStore) + .navigationBarBackButtonHidden() + .transition(.asymmetric( + insertion: .move(edge: .trailing), + removal: .move(edge: .leading) + )) + } + } + } +} diff --git a/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileFeature.swift b/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileFeature.swift new file mode 100644 index 0000000..ef4e905 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileFeature.swift @@ -0,0 +1,193 @@ +// +// ProfileFeature.swift +// Profile +// +// Created by Wonji Suh on 3/25/26. +// + + +import Foundation +import ComposableArchitecture +import Entity + +import UseCase + +@Reducer +public struct ProfileFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + var travelHistorySort: TravelHistorySort = .recent + var profileEntity: ProfileEntity? = nil + var errorMessage: String? = nil + var isLoading: Bool = false + @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty + + public init() {} + } + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case delegate(DelegateAction) + + } + + //MARK: - ViewAction + @CasePathable + public enum View { + case onAppear + case travelHistorySortSelected(TravelHistorySort) + } + + + + //MARK: - AsyncAction 비동기 처리 액션 + public enum AsyncAction: Equatable { + case fetchUser + } + + //MARK: - 앱내에서 사용하는 액션 + public enum InnerAction: Equatable { + case fetchUserResponse(Result) + } + + //MARK: - NavigationAction + public enum DelegateAction: Equatable { + case presentBack + case presentSetting + case presentAuth + + } + + nonisolated enum CancelID: Hashable { + case fetchUser + } + + @Dependency(\.profileUseCase) var profileUseCase + @Dependency(\.keychainManager) var keychainManager + + public var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding(_): + return .none + + case .view(let viewAction): + return handleViewAction(state: &state, action: viewAction) + + case .async(let asyncAction): + return handleAsyncAction(state: &state, action: asyncAction) + + case .inner(let innerAction): + return handleInnerAction(state: &state, action: innerAction) + + case .delegate(let delegateAction): + return handleDelegateAction(state: &state, action: delegateAction) + } + } + } +} + +extension ProfileFeature { + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .travelHistorySortSelected(let sort): + state.travelHistorySort = sort + return .none + + case .onAppear: + return .run { send in + await send(.async(.fetchUser)) + } + + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + + case .fetchUser: + state.isLoading = true + return .run { send in + let result = await Result { + try await profileUseCase.fetchUser() + + } + .mapError(ProfileError.from) + return await send(.inner(.fetchUserResponse(result))) + + } + .cancellable(id: CancelID.fetchUser, cancelInFlight: true) + } + } + + private func handleDelegateAction( + state: inout State, + action: DelegateAction + ) -> Effect { + switch action { + case .presentBack: + return .none + + case .presentSetting: + return .none + + case .presentAuth: + return .none + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case .fetchUserResponse(let result): + state.isLoading = false + switch result { + case .success(let data): + state.profileEntity = data + state.errorMessage = nil + state.$userSession.withLock { + $0.name = state.profileEntity?.nickname ?? "" + $0.mapType = state.profileEntity?.mapType ?? .appleMap + } + return .none + + case .failure(let error): + if error.shouldPresentAuth { + state.profileEntity = nil + state.errorMessage = nil + return .run { send in + try? await keychainManager.clear() + await send(.delegate(.presentAuth)) + } + } + state.errorMessage = error.errorDescription + return .none + } + } + } +} + + +extension ProfileFeature.State: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(travelHistorySort) + hasher.combine(profileEntity) + hasher.combine(errorMessage) + hasher.combine(isLoading) + hasher.combine(userSession) + } +} diff --git a/Projects/Presentation/Profile/Sources/Main/View/ProfileView.swift b/Projects/Presentation/Profile/Sources/Main/View/ProfileView.swift new file mode 100644 index 0000000..e69ec50 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Main/View/ProfileView.swift @@ -0,0 +1,550 @@ +// +// ProfileView.swift +// Profile +// +// Created by Wonji Suh on 3/25/26. +// + +import SwiftUI + +import DesignSystem +import Entity + +import ComposableArchitecture + +public struct ProfileView: View { + @Bindable var store: StoreOf + + //TODO: - 제거 할거 + private let travelHistoryItems: [TravelHistoryCardItem] = [ + .init( + visitedAt: Calendar.current.date(from: DateComponents(year: 2026, month: 3, day: 10, hour: 13, minute: 5)) ?? .now, + placeName: "스테이션 카페", + departureName: "강릉역", + durationText: "25분 소요", + departureTimeText: "오후 1:05" + ), + .init( + visitedAt: Calendar.current.date(from: DateComponents(year: 2026, month: 3, day: 7, hour: 9, minute: 20)) ?? .now, + placeName: "오죽헌", + departureName: "강릉역", + durationText: "18분 소요", + departureTimeText: "오전 9:20" + ), + .init( + visitedAt: Calendar.current.date(from: DateComponents(year: 2026, month: 3, day: 1, hour: 18, minute: 40)) ?? .now, + placeName: "안목해변", + departureName: "강릉역", + durationText: "32분 소요", + departureTimeText: "오후 6:40" + ) + ] + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + ZStack { + Color.gray100 + .edgesIgnoringSafeArea(.all) + + if store.isLoading || store.profileEntity == nil { + profileSkeletonView() + } else { + VStack { + Spacer() + .frame(height: 8) + + CustomNavigationBar( + title: "마이페이지", + leftImage: .leftArrow, + rightImage: .setting, + leftAction: { + store.send(.delegate(.presentBack)) + }, + rightAction: { + store.send(.delegate(.presentSetting)) + } + ) + + profileInfoCardView() + + travelHistory() + + Spacer() + + } + .padding(.horizontal, 16) + } + } + .onAppear { + store.send(.view(.onAppear)) + } + } +} + +extension ProfileView { + private var sortedTravelHistoryItems: [TravelHistoryCardItem] { + switch store.travelHistorySort { + case .recent: + return travelHistoryItems.sorted { $0.visitedAt > $1.visitedAt } + case .oldest: + return travelHistoryItems.sorted { $0.visitedAt < $1.visitedAt } + } + } + + @ViewBuilder + private func profileInfoCardView() -> some View { + VStack(alignment: .leading) { + VStack { + Spacer() + .frame(height: 12) + + HStack { + Text("\(store.profileEntity?.nickname ?? "사용자")님") + .pretendardCustomFont(textStyle: .heading1) + .foregroundStyle(.staticWhite) + + Spacer() + } + .padding(.horizontal, 8) + + Spacer() + .frame(height: 3) + + HStack { + switch store.profileEntity?.provider { + case .apple: + Image(systemName: store.profileEntity?.provider.image ?? "") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + .foregroundStyle(.blueGray300) + + case .google: + Image(assetName: store.profileEntity?.provider.image ?? "") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + + case nil: + Image(systemName: store.profileEntity?.provider.image ?? "") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + .foregroundStyle(.blueGray300) + } + + + + Spacer() + .frame(width: 4) + + Text(verbatim: store.profileEntity?.email ?? "user@example.com") + .pretendardCustomFont(textStyle: .body2Medium) + .foregroundStyle(.blueGray300) + + Spacer() + + } + .padding(.horizontal, 8) + + + Spacer() + .frame(height: 24) + + HStack { + Spacer() + + VStack(alignment: .center) { + Text("방문한 장소") + .pretendardCustomFont(textStyle: .caption) + .foregroundStyle(.staticWhite) + + Spacer() + .frame(height: 4) + + Text("24곳") + .pretendardFont(family: .SemiBold, size: 16) + .foregroundStyle(.staticWhite) + + + } + + Spacer() + + Image(asset: .lineHeight) + .resizable() + .scaledToFit() + .frame(height: 48) + + Spacer() + + VStack(alignment: .center) { + Text("탐험 시간") + .pretendardCustomFont(textStyle: .caption) + .foregroundStyle(.staticWhite) + + Spacer() + .frame(height: 4) + + Text("5시간 20분") + .pretendardFont(family: .SemiBold, size: 16) + .foregroundStyle(.staticWhite) + + + } + + Spacer() + + } + .padding(.horizontal, 21) + .padding(.vertical, 11) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(.blueGray800) + ) + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 24) + .fill(.navy900) + ) + } + } + + @ViewBuilder + fileprivate func travelHistory() -> some View { + VStack(alignment: .leading, spacing: 0) { + Spacer() + .frame(height: 40) + + HStack { + Text("나의 히스토리") + .pretendardCustomFont(textStyle: .titleRegular) + .foregroundStyle(.staticBlack) + + Spacer() + + Menu { + ForEach(TravelHistorySort.allCases, id: \.self) { sort in + Button { + store.send(.view(.travelHistorySortSelected(sort))) + } label: { + HStack { + Text(sort.title) + .pretendardCustomFont(textStyle: .bodyMedium) + .foregroundStyle(.gray800) + + Spacer() + + if store.travelHistorySort == sort { + Image(systemName: "checkmark") + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + .foregroundStyle(.gray800) + } + } + .environment(\.layoutDirection, .leftToRight) + } + } + } label: { + HStack(spacing: 8) { + Text(store.travelHistorySort.title) + .pretendardCustomFont(textStyle: .bodyMedium) + .foregroundStyle(.staticBlack) + .fixedSize(horizontal: true, vertical: false) + + Image(asset: .arrowtriangleDown) + .resizable() + .scaledToFit() + .frame(width: 12, height: 12) + } + .frame(minWidth: 128, alignment: .trailing) + } + } + + Spacer() + .frame(height: 20) + + ScrollView(.vertical) { + VStack(spacing: 12) { + ForEach(sortedTravelHistoryItems) { item in + TravelHistoryCardView(item: item) + } + } + } + .scrollIndicators(.hidden) + } + } + + @ViewBuilder + private func profileSkeletonView() -> some View { + VStack { + Spacer() + .frame(height: 8) + + CustomNavigationBar( + title: "마이페이지", + leftImage: .leftArrow, + rightImage: .setting, + leftAction: { + store.send(.delegate(.presentBack)) + }, + rightAction: {} + ) + + profileInfoCardSkeletonView() + + travelHistorySkeletonView() + + Spacer() + } + .padding(.horizontal, 16) + } + + @ViewBuilder + private func profileInfoCardSkeletonView() -> some View { + VStack(alignment: .leading) { + VStack { + Spacer() + .frame(height: 12) + + HStack { + RoundedRectangle(cornerRadius: 6) + .fill(.blueGray800) + .frame(width: 100, height: 24) + .opacity(0.7) + + Spacer() + } + .padding(.horizontal, 8) + + Spacer() + .frame(height: 3) + + HStack { + Circle() + .fill(.blueGray800) + .frame(width: 16, height: 16) + .opacity(0.7) + + Spacer() + .frame(width: 4) + + RoundedRectangle(cornerRadius: 4) + .fill(.blueGray800) + .frame(width: 150, height: 14) + .opacity(0.7) + + Spacer() + } + .padding(.horizontal, 8) + + Spacer() + .frame(height: 24) + + HStack { + Spacer() + + VStack(alignment: .center) { + RoundedRectangle(cornerRadius: 4) + .fill(.blueGray800) + .frame(width: 60, height: 12) + .opacity(0.7) + + Spacer() + .frame(height: 4) + + RoundedRectangle(cornerRadius: 4) + .fill(.blueGray800) + .frame(width: 40, height: 16) + .opacity(0.7) + } + + Spacer() + + Image(asset: .lineHeight) + .resizable() + .scaledToFit() + .frame(height: 48) + + Spacer() + + VStack(alignment: .center) { + RoundedRectangle(cornerRadius: 4) + .fill(.blueGray800) + .frame(width: 50, height: 12) + .opacity(0.7) + + Spacer() + .frame(height: 4) + + RoundedRectangle(cornerRadius: 4) + .fill(.blueGray800) + .frame(width: 70, height: 16) + .opacity(0.7) + } + + Spacer() + } + .padding(.horizontal, 21) + .padding(.vertical, 11) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(.blueGray800) + ) + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 24) + .fill(.navy900) + ) + } + } + + @ViewBuilder + private func travelHistorySkeletonView() -> some View { + VStack(alignment: .leading, spacing: 0) { + Spacer() + .frame(height: 40) + + HStack { + RoundedRectangle(cornerRadius: 6) + .fill(.gray300) + .frame(width: 120, height: 20) + .opacity(0.7) + + Spacer() + + RoundedRectangle(cornerRadius: 4) + .fill(.gray300) + .frame(width: 80, height: 16) + .opacity(0.7) + } + + Spacer() + .frame(height: 20) + + ScrollView(.vertical) { + VStack(spacing: 12) { + ForEach(0..<3, id: \.self) { _ in + travelHistoryCardSkeletonView() + } + } + } + .scrollIndicators(.hidden) + } + } + + @ViewBuilder + private func travelHistoryCardSkeletonView() -> some View { + VStack(alignment: .leading, spacing: 0) { + RoundedRectangle(cornerRadius: 4) + .fill(.gray300) + .frame(width: 80, height: 12) + .opacity(0.7) + + Spacer() + .frame(height: 10) + + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + RoundedRectangle(cornerRadius: 4) + .fill(.gray300) + .frame(width: 120, height: 20) + .opacity(0.7) + + RoundedRectangle(cornerRadius: 4) + .fill(.gray300) + .frame(width: 60, height: 14) + .opacity(0.7) + } + + Spacer() + + RoundedRectangle(cornerRadius: 3) + .fill(.gray300) + .frame(width: 123, height: 6) + .padding(.vertical, 9) + .opacity(0.7) + + Spacer() + .frame(width: 24) + + VStack(alignment: .trailing, spacing: 4) { + RoundedRectangle(cornerRadius: 4) + .fill(.gray300) + .frame(width: 52, height: 20) + .opacity(0.7) + + RoundedRectangle(cornerRadius: 4) + .fill(.gray300) + .frame(width: 40, height: 14) + .opacity(0.7) + } + .frame(width: 52, alignment: .trailing) + } + + Spacer() + .frame(height: 20) + + Path { path in + path.move(to: CGPoint(x: 0, y: 0.5)) + path.addLine(to: CGPoint(x: 330, y: 0.5)) + } + .stroke( + .gray400, + style: StrokeStyle( + lineWidth: 1, + lineCap: .round, + dash: [1, 4] + ) + ) + .frame(height: 1) + + Spacer() + .frame(height: 20) + + HStack(alignment: .bottom) { + VStack(alignment: .leading, spacing: 2) { + RoundedRectangle(cornerRadius: 4) + .fill(.gray300) + .frame(width: 70, height: 12) + .opacity(0.7) + + RoundedRectangle(cornerRadius: 4) + .fill(.gray300) + .frame(width: 60, height: 16) + .opacity(0.7) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 6) { + RoundedRectangle(cornerRadius: 4) + .fill(.gray300) + .frame(width: 50, height: 12) + .opacity(0.7) + + RoundedRectangle(cornerRadius: 4) + .fill(.gray300) + .frame(width: 70, height: 16) + .opacity(0.7) + } + } + } + .padding(20) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 24) + .fill(.gray200) + .overlay { + RoundedRectangle(cornerRadius: 24) + .stroke(.neutral200, style: .init(lineWidth: 1)) + } + ) + } +} diff --git a/Projects/Presentation/Profile/Sources/NotificationSetting/Reducer/NotificationSettingFeature.swift b/Projects/Presentation/Profile/Sources/NotificationSetting/Reducer/NotificationSettingFeature.swift new file mode 100644 index 0000000..157acba --- /dev/null +++ b/Projects/Presentation/Profile/Sources/NotificationSetting/Reducer/NotificationSettingFeature.swift @@ -0,0 +1,145 @@ +// +// NotificationSettingFeature.swift +// Profile +// +// Created by Wonji Suh on 3/26/26. +// + + +import Foundation +import ComposableArchitecture + +import Utill +import Entity + +@Reducer +public struct NotificationSettingFeature { + public init() {} + + + @ObservableState + public struct State: Equatable { + var selectedOptions: [NotificationOption] = [.fiveMinutesBefore, .tenMinutesBefore] + + public init() {} + } + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case delegate(DelegateAction) + + } + + //MARK: - ViewAction + @CasePathable + public enum View { + case notificationOptionTapped(NotificationOption) + } + + + + //MARK: - AsyncAction 비동기 처리 액션 + public enum AsyncAction: Equatable { + + } + + //MARK: - 앱내에서 사용하는 액션 + public enum InnerAction: Equatable { + } + + //MARK: - DelegateAction + public enum DelegateAction: Equatable { + case presentBack + + } + + + public var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding(_): + return .none + + case .view(let viewAction): + return handleViewAction(state: &state, action: viewAction) + + case .async(let asyncAction): + return handleAsyncAction(state: &state, action: asyncAction) + + case .inner(let innerAction): + return handleInnerAction(state: &state, action: innerAction) + + case .delegate(let delegateAction): + return handleDelegateAction(state: &state, action: delegateAction) + } + } + } +} + +extension NotificationSettingFeature { + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .notificationOptionTapped(let option): + if option == .none { + state.selectedOptions = [.none] + return .none + } + + state.selectedOptions.removeAll { $0 == .none } + + if state.selectedOptions.contains(option) { + state.selectedOptions.removeAll { $0 == option } + return .none + } + + guard state.selectedOptions.count < 2 else { + return .none + } + + state.selectedOptions.append(option) + return .none + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + + } + } + + private func handleDelegateAction( + state: inout State, + action: DelegateAction + ) -> Effect { + switch action { + case .presentBack: + return .none + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + + } + } +} + + +extension NotificationSettingFeature.State: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(selectedOptions) + } +} diff --git a/Projects/Presentation/Profile/Sources/NotificationSetting/View/NotificationSettingView.swift b/Projects/Presentation/Profile/Sources/NotificationSetting/View/NotificationSettingView.swift new file mode 100644 index 0000000..4dccf39 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/NotificationSetting/View/NotificationSettingView.swift @@ -0,0 +1,101 @@ +// +// NotificationSettingView.swift +// Profile +// +// Created by Wonji Suh on 3/26/26. +// + +import SwiftUI + +import DesignSystem +import Entity + +import ComposableArchitecture + + +public struct NotificationSettingView: View { + @Bindable var store: StoreOf + + public init( + store: StoreOf + ) { + self.store = store + } + + public var body: some View { + ZStack { + Color.staticWhite + .edgesIgnoringSafeArea(.all) + + VStack { + Spacer() + .frame(height: 8) + + CustomNavigationBackBar( + buttonAction: { + store.send(.delegate(.presentBack)) + }, + title: "시간 설정" + ) + + notificationOptionMenuView() + + Spacer() + } + .padding(.horizontal, 16) + } + } +} + + +extension NotificationSettingView { + @ViewBuilder + fileprivate func notificationOptionMenuView() -> some View { + VStack(spacing: 0) { + Spacer() + .frame(height: 48) + + VStack(spacing: 0) { + ForEach(NotificationOption.allCases) { option in + Button { + store.send(.view(.notificationOptionTapped(option))) + } label: { + HStack(spacing: 12) { + Text(option.title) + .pretendardCustomFont(textStyle: .bodyMedium) + .foregroundStyle(.gray900) + + Spacer() + + if store.selectedOptions.contains(option) { + Image(systemName: "checkmark") + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(.gray550) + } + } + .frame(height: 58) + .padding(.horizontal, 20) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if option != .fifteenMinutesBefore { + Rectangle() + .fill(.enableColor) + .frame(height: 1) + .padding(.horizontal, 14) + } + } + } + .background( + RoundedRectangle(cornerRadius: 24) + .fill(.gray200) + ) + .overlay { + RoundedRectangle(cornerRadius: 24) + .stroke(.enableColor, lineWidth: 1) + } + .clipShape(RoundedRectangle(cornerRadius: 24)) + } + } +} diff --git a/Projects/Presentation/Profile/Sources/Setting/Reducer/SettingReducer.swift b/Projects/Presentation/Profile/Sources/Setting/Reducer/SettingReducer.swift new file mode 100644 index 0000000..6164529 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Setting/Reducer/SettingReducer.swift @@ -0,0 +1,297 @@ +// +// SettingFeature.swift +// Profile +// +// Created by Wonji Suh on 3/25/26. +// + + +import Foundation +import ComposableArchitecture + +import DesignSystem +import Utill + +import UseCase +import Entity + +@Reducer +public struct SettingFeature { + public init() {} + + + @ObservableState + public struct State: Equatable { + @Presents public var customAlert: CustomAlertState? + var customAlertMode: CustomAlertMode? = nil + var logoutEntity: LogoutEntity? = nil + var errorMessage: String? = nil + var editProfileEntity: LoginEntity? = nil + @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty + @Shared(.appStorage("mapUrlScheme")) var mapURLScheme: String? + + public init() {} + } + + enum CustomAlertMode: Equatable, Hashable { + case logoutConfirmation + case logoutError + } + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case delegate(DelegateAction) + case scope(ScopeAction) + + } + + @CasePathable + public enum ScopeAction { + case customAlert(PresentationAction) + } + + //MARK: - ViewAction + @CasePathable + public enum View { + case timeNotificationRowTapped + case logoutRowTapped + case mapTypeSelected(ExternalMapType) + } + + + + //MARK: - AsyncAction 비동기 처리 액션 + public enum AsyncAction: Equatable { + case logout + case editProfile(previousMapType: ExternalMapType) + } + + //MARK: - 앱내에서 사용하는 액션 + public enum InnerAction: Equatable { + case presentLogoutConfirmationAlert + case logoutResponse(Result) + case editProfileResponse(Result, previousMapType: ExternalMapType) + } + + //MARK: - DelegateAction + public enum DelegateAction: Equatable { + case presentBack + case presentAuth + case presentWithDraw + case presentNotificationSetting + + } + + nonisolated enum CancelID: Hashable { + case logout + case editProfile + } + + @Dependency(\.authUseCase) var authUseCase + @Dependency(\.profileUseCase) var profileUseCase + + + public var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding(_): + return .none + + case .view(let viewAction): + return handleViewAction(state: &state, action: viewAction) + + case .async(let asyncAction): + return handleAsyncAction(state: &state, action: asyncAction) + + case .inner(let innerAction): + return handleInnerAction(state: &state, action: innerAction) + + case .delegate(let delegateAction): + return handleDelegateAction(state: &state, action: delegateAction) + + case .scope(let scopeAction): + return handleScopeAction(state: &state, action: scopeAction) + } + } + .ifLet(\.$customAlert, action: \.scope.customAlert) { + CustomConfirmAlert() + } + } +} + +extension SettingFeature { + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .timeNotificationRowTapped: + return .none + + case .mapTypeSelected(let mapType): + let previousMapType = state.userSession.mapType + state.$userSession.withLock { + $0.mapType = mapType + } + return .send(.async(.editProfile(previousMapType: previousMapType))) + + case .logoutRowTapped: + return .send(.inner(.presentLogoutConfirmationAlert)) + } + } + + private func handleScopeAction( + state: inout State, + action: ScopeAction + ) -> Effect { + switch action { + case .customAlert(.presented(.confirmTapped)): + switch state.customAlertMode { + case .logoutConfirmation: + state.customAlert = nil + state.customAlertMode = nil + return .send(.async(.logout)) + + case .logoutError: + state.customAlert = nil + state.customAlertMode = nil + return .none + + case .none: + state.customAlert = nil + return .none + } + + case .customAlert(.presented(.cancelTapped)), + .customAlert(.dismiss): + state.customAlert = nil + state.customAlertMode = nil + return .none + + case .customAlert(.presented(.policyTapped)): + return .none + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case .logout: + return .run { send in + let result = await Result { + try await authUseCase.logout() + } + .mapError(AuthError.from) + await send(.inner(.logoutResponse(result))) + } + .cancellable(id: CancelID.logout, cancelInFlight: true) + + case let .editProfile(previousMapType): + return .run { [ + userSession = state.userSession + ] send in + let result = await Result { + try await profileUseCase.editUser( + name: userSession.name, + mapType: userSession.mapType + ) + } + .mapError(ProfileError.from) + await send(.inner(.editProfileResponse(result, previousMapType: previousMapType))) + } + .cancellable(id: CancelID.editProfile, cancelInFlight: true) + + } + } + + private func handleDelegateAction( + state: inout State, + action: DelegateAction + ) -> Effect { + switch action { + case .presentBack: + return .none + + case .presentAuth: + return .none + + case .presentWithDraw: + return .none + + case .presentNotificationSetting: + return .none + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case .presentLogoutConfirmationAlert: + state.customAlertMode = .logoutConfirmation + state.customAlert = .logout() + return .none + + case .logoutResponse(let result): + switch result { + case .success(let data): + state.logoutEntity = data + state.errorMessage = nil + state.customAlert = nil + state.customAlertMode = nil + return .send(.delegate(.presentAuth)) + + case .failure(let error): + state.errorMessage = error.errorDescription + state.customAlertMode = .logoutError + state.customAlert = .alert( + title: "로그아웃 실패", + message: error.errorDescription ?? "로그아웃 중 문제가 발생했어요.", + confirmTitle: "다시 시도", + cancelTitle: "닫기", + isDestructive: false + ) + return .none + + + } + + case let .editProfileResponse(result, previousMapType): + switch result { + case .success(let data): + state.editProfileEntity = data + state.$userSession.withLock { + $0.mapType = data.mapType ?? $0.mapType + } + state.$mapURLScheme.withLock { + $0 = data.mapURLScheme + } + return .none + + case .failure(let error): + state.errorMessage = error.errorDescription + state.$userSession.withLock { + $0.mapType = previousMapType + } + return .none + } + + } + } +} + +extension SettingFeature.State: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(customAlertMode) + hasher.combine(logoutEntity) + hasher.combine(errorMessage) + hasher.combine(userSession) + } +} diff --git a/Projects/Presentation/Profile/Sources/Setting/View/SettingView.swift b/Projects/Presentation/Profile/Sources/Setting/View/SettingView.swift new file mode 100644 index 0000000..c6e4a94 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Setting/View/SettingView.swift @@ -0,0 +1,154 @@ +// +// SettingView.swift +// Profile +// +// Created by Wonji Suh on 3/25/26. +// + +import SwiftUI +import UIKit + +import DesignSystem +import Entity + +import ComposableArchitecture + +public struct SettingView: View { + @Bindable var store: StoreOf + @Environment(\.openURL) private var openURL + + public init( + store: StoreOf + ) { + self.store = store + } + + public var body: some View { + ZStack(alignment: .top) { + Color.staticWhite + .ignoresSafeArea() + + VStack(spacing: 0) { + Spacer() + .frame(height: 8) + + CustomNavigationBackBar( + buttonAction: { + store.send(.delegate(.presentBack)) + }, + title: "설정" + ) + + Spacer() + .frame(height: 18) + + notificationSettingsSection + + Spacer() + .frame(height: 24) + + accountSettingsSection + + Spacer() + } + .padding(.horizontal, 16) + } + .customAlert($store.scope(state: \.customAlert, action: \.scope.customAlert)) + } +} + +extension SettingView { + @ViewBuilder + private var notificationSettingsSection: some View { + settingsSection { + SettingMenuRowView( + title: "시간 알림", + action: { + store.send(.delegate(.presentNotificationSetting)) + } + ) + + SettingMenuRowView( + title: "위치 접근 권한", + trailingText: "설정", + action: { + openAppSettings() + } + ) + + Menu { + ForEach(ExternalMapType.allCases) { mapType in + Button { + store.send(.view(.mapTypeSelected(mapType))) + } label: { + if store.userSession.mapType == mapType { + Label(mapType.description, systemImage: "checkmark") + .pretendardCustomFont(textStyle: .bodyMedium) + .foregroundStyle(.gray800) + } else { + Text(mapType.description) + .pretendardCustomFont(textStyle: .bodyMedium) + .foregroundStyle(.gray800) + } + } + } + } label: { + SettingMenuRowView( + title: "연동된 지도", + trailingText: store.userSession.mapType.description, + accessory: .dropdown, + showsDivider: false + ) + } + } + } + + @ViewBuilder + private var accountSettingsSection: some View { + settingsSection { + SettingMenuRowView( + title: "서비스 이용 약관" + ) + + SettingMenuRowView( + title: "개인정보 처리방침" + ) + + SettingMenuRowView( + title: "로그아웃", + action: { + store.send(.view(.logoutRowTapped)) + } + ) + + SettingMenuRowView( + title: "회원 탈퇴", + showsDivider: false, + action: { + store.send(.delegate(.presentWithDraw)) + } + ) + } + } + + @ViewBuilder + private func settingsSection( + @ViewBuilder content: () -> Content + ) -> some View { + VStack(spacing: 0) { + content() + } + .padding(.horizontal, 16) + .background( + RoundedRectangle(cornerRadius: 24) + .fill(.gray200) + ) + } + + private func openAppSettings() { + guard let settingsURL = URL(string: UIApplication.openSettingsURLString) else { + return + } + openURL(settingsURL) + } +} diff --git a/Projects/Presentation/Profile/Sources/WithDraw/Reducer/WithDrawFeature.swift b/Projects/Presentation/Profile/Sources/WithDraw/Reducer/WithDrawFeature.swift new file mode 100644 index 0000000..1c1324a --- /dev/null +++ b/Projects/Presentation/Profile/Sources/WithDraw/Reducer/WithDrawFeature.swift @@ -0,0 +1,210 @@ +// +// WithDrawFeature.swift +// Profile +// +// Created by Wonji Suh on 3/25/26. +// + + +import Foundation +import ComposableArchitecture + +import DesignSystem +import Utill +import UseCase +import Entity + +@Reducer +public struct WithDrawFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + @Presents public var customAlert: CustomAlertState? + var customAlertMode: CustomAlertMode? = nil + var withdrawButtonTapped: Bool = false + var withDrawEntity: LogoutEntity? = nil + var errorMessage: String? = nil + public init() {} + } + + enum CustomAlertMode: Equatable, Hashable { + case withDrawError + } + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case delegate(DelegateAction) + case scope(ScopeAction) + + } + + @CasePathable + public enum ScopeAction { + case customAlert(PresentationAction) + } + + //MARK: - ViewAction + @CasePathable + public enum View { + case tapWithDrawAgree + } + + + + //MARK: - AsyncAction 비동기 처리 액션 + public enum AsyncAction: Equatable { + case withDraw + } + + //MARK: - 앱내에서 사용하는 액션 + public enum InnerAction: Equatable { + case withDrawResponse(Result) + } + + //MARK: - DelegateAction + public enum DelegateAction: Equatable { + case presentBack + case presentAuth + + } + + nonisolated enum CancelID: Hashable { + case withDraw + } + + @Dependency(\.authUseCase) var authUseCase + + public var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding(_): + return .none + + case .view(let viewAction): + return handleViewAction(state: &state, action: viewAction) + + case .async(let asyncAction): + return handleAsyncAction(state: &state, action: asyncAction) + + case .inner(let innerAction): + return handleInnerAction(state: &state, action: innerAction) + + case .delegate(let delegateAction): + return handleDelegateAction(state: &state, action: delegateAction) + + case .scope(let scopeAction): + return handleScopeAction(state: &state, action: scopeAction) + } + } + .ifLet(\.$customAlert, action: \.scope.customAlert) { + CustomConfirmAlert() + } + } +} + +extension WithDrawFeature { + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .tapWithDrawAgree: + state.withdrawButtonTapped.toggle() + return .none + } + } + + private func handleScopeAction( + state: inout State, + action: ScopeAction + ) -> Effect { + switch action { + case .customAlert(.presented(.confirmTapped)), + .customAlert(.presented(.cancelTapped)), + .customAlert(.dismiss): + state.customAlert = nil + state.customAlertMode = nil + return .none + + case .customAlert(.presented(.policyTapped)): + return .none + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case .withDraw: + return .run { send in + let result = await Result { + try await authUseCase.withDraw() + } + .mapError(AuthError.from) + await send(.inner(.withDrawResponse(result))) + } + .cancellable(id: CancelID.withDraw, cancelInFlight: true) + + } + } + + private func handleDelegateAction( + state: inout State, + action: DelegateAction + ) -> Effect { + switch action { + case .presentBack: + return .none + + case .presentAuth: + return .none + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + + case .withDrawResponse(let result): + switch result { + case .success(let data): + state.withDrawEntity = data + state.errorMessage = nil + state.customAlert = nil + state.customAlertMode = nil + return .send(.delegate(.presentAuth)) + + case .failure(let error): + state.errorMessage = error.localizedDescription + state.customAlertMode = .withDrawError + state.customAlert = .alert( + title: "회원 탈퇴 실패", + message: error.errorDescription ?? "회원 탈퇴 중 문제가 발생했어요.", + confirmTitle: "확인", + cancelTitle: "닫기", + isDestructive: false + ) + return .none + } + } + } +} + + + +extension WithDrawFeature.State: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(customAlertMode) + hasher.combine(withdrawButtonTapped ) + hasher.combine(withDrawEntity) + hasher.combine(errorMessage) + } +} diff --git a/Projects/Presentation/Profile/Sources/WithDraw/View/WithDrawView.swift b/Projects/Presentation/Profile/Sources/WithDraw/View/WithDrawView.swift new file mode 100644 index 0000000..93ddfa1 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/WithDraw/View/WithDrawView.swift @@ -0,0 +1,183 @@ +// +// WithDrawView.swift +// Profile +// +// Created by Wonji Suh on 3/25/26. +// + +import SwiftUI + +import DesignSystem + +import ComposableArchitecture + +public struct WithDrawView: View { + @Bindable var store: StoreOf + + public init( + store: StoreOf + ) { + self.store = store + } + + public var body: some View { + ZStack { + Color.staticWhite + .edgesIgnoringSafeArea(.all) + + VStack { + Spacer() + .frame(height: 8) + + CustomNavigationBackBar( + buttonAction: { + store.send(.delegate(.presentBack)) + }, + title: "회원탈퇴" + ) + + titleHeaderView() + + warningContentView() + + withDrawAgreeButton() + + withDrawButton() + + Spacer() + } + .padding(.horizontal, 16) + } + .customAlert($store.scope(state: \.customAlert, action: \.scope.customAlert)) + } +} + + +extension WithDrawView { + + @ViewBuilder + fileprivate func titleHeaderView() -> some View { + VStack(alignment: .center) { + Spacer() + .frame(height: 66) + + + Text("Time Spot을 탈퇴하시나요?") + .pretendardCustomFont(textStyle: .heading1) + .foregroundStyle(.staticBlack) + + Spacer() + .frame(height: 12) + + Text("탈퇴 시 주의사항을 확인해주세요") + .pretendardCustomFont(textStyle: .heading1) + .foregroundStyle(.gray800) + + } + } + + @ViewBuilder + fileprivate func warningContentView() -> some View { + VStack(spacing: 0) { + Spacer() + .frame(height: 46) + + VStack(spacing: 0) { + Image(asset: .warningTriangle) + .resizable() + .scaledToFit() + .frame(width: 28, height: 28) + + Spacer() + .frame(height: 8) + + Text("주의 사항") + .pretendardCustomFont(textStyle: .bodyBold) + .foregroundStyle(.gray800) + + Spacer() + .frame(height: 18) + + warningBulletRow("Time Spot 탈퇴 시 계정에 저장된 정보 및 최근 내역이 삭제됩니다.") + + Spacer() + .frame(height: 16) + + warningBulletRow("탈퇴 후 삭제된 정보는 다시 복구되지 않습니다.") + } + .padding(.horizontal, 24) + .padding(.top, 24) + .padding(.bottom, 28) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 24) + .fill(.gray200) + ) + } + } + + @ViewBuilder + fileprivate func warningBulletRow(_ text: String) -> some View { + HStack(alignment: .top, spacing: 10) { + Text("•") + .pretendardCustomFont(textStyle: .body2Medium) + .foregroundStyle(.gray800) + + Text(text) + .pretendardCustomFont(textStyle: .body2Medium) + .foregroundStyle(.gray800) + .frame(maxWidth: .infinity, alignment: .leading) + .multilineTextAlignment(.leading) + + Spacer(minLength: 0) + } + } + + + @ViewBuilder + fileprivate func withDrawAgreeButton() -> some View { + VStack { + Spacer() + .frame(height: UIScreen.screenHeight * 0.22) + + HStack { + Image(asset: store.withdrawButtonTapped ? .check : .noCheck) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + + Spacer() + .frame(width: 12) + + Text("주의 사항을 모두 확인했고 이에 동의합니다.") + .pretendardCustomFont(textStyle: .bodyMedium) + .foregroundStyle(.staticBlack) + + Spacer() + + } + .onTapGesture { + store.send(.view(.tapWithDrawAgree)) + } + } + } + + @ViewBuilder + fileprivate func withDrawButton() -> some View { + VStack { + Spacer() + .frame(height: 25) + + CustomButton( + action: { + store.send(.async(.withDraw)) + }, + title: "탈퇴하기", + config: CustomButtonConfig.create(), + isEnable: store.withdrawButtonTapped + ) + } + } + + +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/homeLogo.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/homeLogo.imageset/Contents.json new file mode 100644 index 0000000..bf1e245 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/homeLogo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "homeImage.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/homeLogo.imageset/homeImage.png b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/homeLogo.imageset/homeImage.png new file mode 100644 index 0000000..1ad0028 Binary files /dev/null and b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/homeLogo.imageset/homeImage.png differ diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/loginlogo.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/loginlogo.imageset/Contents.json new file mode 100644 index 0000000..efe98e2 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/loginlogo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "loginImage.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/loginlogo.imageset/loginImage.png b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/loginlogo.imageset/loginImage.png new file mode 100644 index 0000000..3913596 Binary files /dev/null and b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/loginlogo.imageset/loginImage.png differ diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/logo.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/logo.imageset/Contents.json new file mode 100644 index 0000000..94779cb --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/logo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "loginLogo.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/logo.imageset/loginLogo.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/logo.imageset/loginLogo.svg new file mode 100644 index 0000000..31ef7be --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/logo.imageset/loginLogo.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo1.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo1.imageset/Contents.json index b88864d..b299af2 100644 --- a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo1.imageset/Contents.json +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo1.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "onBoardingLogo1.png", + "filename" : "image 25563.png", "idiom" : "universal" } ], diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo1.imageset/image 25563.png b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo1.imageset/image 25563.png new file mode 100644 index 0000000..4e7b605 Binary files /dev/null and b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo1.imageset/image 25563.png differ diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo1.imageset/onBoardingLogo1.png b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo1.imageset/onBoardingLogo1.png deleted file mode 100644 index 7dc92fd..0000000 Binary files a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo1.imageset/onBoardingLogo1.png and /dev/null differ diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo2.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo2.imageset/Contents.json index f80034b..3ae489f 100644 --- a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo2.imageset/Contents.json +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo2.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "onBoardingLogo2.png", + "filename" : "image 25561.png", "idiom" : "universal" } ], diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo2.imageset/image 25561.png b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo2.imageset/image 25561.png new file mode 100644 index 0000000..db10fda Binary files /dev/null and b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo2.imageset/image 25561.png differ diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo2.imageset/onBoardingLogo2.png b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo2.imageset/onBoardingLogo2.png deleted file mode 100644 index 68ed389..0000000 Binary files a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo2.imageset/onBoardingLogo2.png and /dev/null differ diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo3.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo3.imageset/Contents.json index 5e7cba6..5181dfe 100644 --- a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo3.imageset/Contents.json +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo3.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "onBoardingLogo3.png", + "filename" : "image 25562.png", "idiom" : "universal" } ], diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo3.imageset/image 25562.png b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo3.imageset/image 25562.png new file mode 100644 index 0000000..809b100 Binary files /dev/null and b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo3.imageset/image 25562.png differ diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo3.imageset/onBoardingLogo3.png b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo3.imageset/onBoardingLogo3.png deleted file mode 100644 index 0025bd5..0000000 Binary files a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo3.imageset/onBoardingLogo3.png and /dev/null differ diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/goolgeMap.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/goolgeMap.imageset/Contents.json index 037ecfc..78b0900 100644 --- a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/goolgeMap.imageset/Contents.json +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/goolgeMap.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "googleMap.png", + "filename" : "temp-3-36-43-image_upscayl_4x_high-fidelity-4x 1.png", "idiom" : "universal" } ], diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/goolgeMap.imageset/googleMap.png b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/goolgeMap.imageset/googleMap.png deleted file mode 100644 index 83da01f..0000000 Binary files a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/goolgeMap.imageset/googleMap.png and /dev/null differ diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/goolgeMap.imageset/temp-3-36-43-image_upscayl_4x_high-fidelity-4x 1.png b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/goolgeMap.imageset/temp-3-36-43-image_upscayl_4x_high-fidelity-4x 1.png new file mode 100644 index 0000000..1d20e28 Binary files /dev/null and b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/goolgeMap.imageset/temp-3-36-43-image_upscayl_4x_high-fidelity-4x 1.png differ diff --git a/Projects/Shared/DesignSystem/Sources/Animation/AppAnimations.swift b/Projects/Shared/DesignSystem/Sources/Animation/AppAnimations.swift new file mode 100644 index 0000000..1b90e49 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Animation/AppAnimations.swift @@ -0,0 +1,63 @@ +// +// AppAnimations.swift +// DesignSystem +// +// Created by Wonji Suh on 3/25/26. +// + +import SwiftUI + +public struct AppAnimations { + // MARK: - Screen Transitions + public static let screenTransition = Animation.spring( + response: 0.52, + dampingFraction: 0.94, + blendDuration: 0.14 + ) + + public static let modalTransition = Animation.spring( + response: 0.42, + dampingFraction: 0.88 + ) + + public static let quickTransition = Animation.spring( + response: 0.32, + dampingFraction: 0.92 + ) + + // MARK: - Button Animations + public static let buttonPress = Animation.spring( + response: 0.25, + dampingFraction: 0.8 + ) + + public static let buttonHover = Animation.spring( + response: 0.18, + dampingFraction: 0.75 + ) + + // MARK: - Loading Animations + public static let loadingRotation = Animation.linear(duration: 1.0).repeatForever(autoreverses: false) + + public static let pulseAnimation = Animation.easeInOut(duration: 1.2).repeatForever(autoreverses: true) + + // MARK: - Custom Easing + public static let smoothEaseOut = Animation.timingCurve(0.25, 0.46, 0.45, 0.94) + + public static let bounceEaseOut = Animation.timingCurve(0.175, 0.885, 0.32, 1.275) +} + +// MARK: - Convenience Extensions +public extension Animation { + static var appDefault: Animation { + AppAnimations.screenTransition + } + + static var appQuick: Animation { + AppAnimations.quickTransition + } + + static var appButton: Animation { + AppAnimations.buttonPress + } +} \ No newline at end of file diff --git a/Projects/Shared/DesignSystem/Sources/Color/ShapeStyle+.swift b/Projects/Shared/DesignSystem/Sources/Color/ShapeStyle+.swift index f4b653e..2c118ef 100644 --- a/Projects/Shared/DesignSystem/Sources/Color/ShapeStyle+.swift +++ b/Projects/Shared/DesignSystem/Sources/Color/ShapeStyle+.swift @@ -15,15 +15,22 @@ public extension ShapeStyle where Self == Color { static var gray300: Color { .init(hex: "EDEDED") } static var gray400: Color { .init(hex: "D4D4D4") } static var gray500: Color { .init(hex: "BABABA") } + static var gray550: Color { .init(hex: "B0B0B0") } static var gray600: Color { .init(hex: "A1A1A1") } static var gray700: Color { .init(hex: "878787") } static var gray800: Color { .init(hex: "545454") } static var gray900: Color { .init(hex: "181818") } static var lightGray: Color { .init(hex: "CCCCCC") } static var mediumGray: Color { .init(hex: "6C6C6C")} + static var slateGray : Color { .init(hex: "949FB1") } + static var blueGray300: Color { .init(hex: "9EA3AE") } + static var blueGray600: Color { .init(hex: "5E6880") } + static var blueGray800: Color { .init(hex: "2E3951") } + static var neutral200: Color { .init(hex: "D9D9D9") } static var enableColor: Color { .init(hex: "E2E2E2")} + static var mauve: Color { .init(hex: "B78A82") } // ORANGE static var orange100: Color { .init(hex: "FFF6F5") } @@ -47,5 +54,7 @@ public extension ShapeStyle where Self == Color { static var navy800: Color { .init(hex: "12234D") } static var navy900: Color { .init(hex: "0C1834") } -} + static var staticBlack: Color { .init(hex: "000000") } + static var staticWhite: Color { .init(hex: "FFFFFF") } +} diff --git a/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift b/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift index 3e1901f..73c811a 100644 --- a/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift +++ b/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift @@ -17,6 +17,9 @@ public enum ImageAsset: String { case noCheck case check case arrowRight + case leftArrow + case lineHeight + // MARK: - 지도 case naverMap @@ -26,6 +29,17 @@ public enum ImageAsset: String { case onBoardingLogo1 case onBoardingLogo2 case onBoardingLogo3 + case homeLogo + case logo + case loginlogo + + case warning + case setting + case time + case profile + case arrowtriangleDown + case travelLine + case warningTriangle case none } diff --git a/Projects/Shared/DesignSystem/Sources/Ui/Alert/CustomPopup/CustomAlertState.swift b/Projects/Shared/DesignSystem/Sources/Ui/Alert/CustomPopup/CustomAlertState.swift index 2847d23..99f84ba 100644 --- a/Projects/Shared/DesignSystem/Sources/Ui/Alert/CustomPopup/CustomAlertState.swift +++ b/Projects/Shared/DesignSystem/Sources/Ui/Alert/CustomPopup/CustomAlertState.swift @@ -88,8 +88,8 @@ public extension CustomAlertState where Action == CustomAlertAction { static func logout() -> CustomAlertState { .alert( - title: "로그아웃 하시겠습니까?", - message: "다시 로그인해야 앱을 사용할 수 있습니다.", + title: "로그아웃", + message: "정말로 로그아웃 하시겠어요?", confirmTitle: "로그아웃", cancelTitle: "취소", isDestructive: false diff --git a/Projects/Shared/DesignSystem/Sources/Ui/Alert/CustomPopup/CustomConfirmationPopupView.swift b/Projects/Shared/DesignSystem/Sources/Ui/Alert/CustomPopup/CustomConfirmationPopupView.swift index 5885aa8..0652987 100644 --- a/Projects/Shared/DesignSystem/Sources/Ui/Alert/CustomPopup/CustomConfirmationPopupView.swift +++ b/Projects/Shared/DesignSystem/Sources/Ui/Alert/CustomPopup/CustomConfirmationPopupView.swift @@ -19,6 +19,7 @@ struct CustomConfirmationPopup: View { private let onCancel: () -> Void private let onPolicyTap: () -> Void @State private var isChecked = false + @State private var isContentVisible = false init( title: String, @@ -47,7 +48,7 @@ struct CustomConfirmationPopup: View { var body: some View { ZStack { Color.black - .opacity(0.6) + .opacity(isContentVisible ? 0.6 : 0) .edgesIgnoringSafeArea(.all) .onTapGesture { if style != .consent { @@ -55,63 +56,75 @@ struct CustomConfirmationPopup: View { } } - if style == .consent { - consentContent - } else { - confirmationContent + Group { + if style == .consent { + consentContent + } else { + confirmationContent + } + } + .padding(.horizontal, 10) + .offset(y: isContentVisible ? 0 : 120) + .opacity(isContentVisible ? 1 : 0) + } + .onAppear { + withAnimation(.easeInOut(duration: 0.3)) { + isContentVisible = true } } } private var confirmationContent: some View { - VStack(alignment: .center, spacing: 24) { - VStack(alignment: .center, spacing: 8) { + VStack(alignment: .center, spacing: 28) { + VStack(alignment: .center, spacing: 14) { Text(title) - .pretendardCustomFont(textStyle: .bodyBold) - .foregroundStyle(.white) + .pretendardCustomFont(textStyle: .heading2) + .foregroundStyle(.staticBlack) .multilineTextAlignment(.center) if !message.isEmpty { Text(message) - .pretendardCustomFont(textStyle: .bodyBold) - .foregroundStyle(.gray100) + .pretendardCustomFont(textStyle: .bodyMedium) + .foregroundStyle(.gray700) .multilineTextAlignment(.center) } } HStack(spacing: 12) { Button { - onConfirm() + onCancel() } label: { - Text(confirmTitle) + Text(cancelTitle) .pretendardFont(family: .Medium, size: 16) - .foregroundStyle(.white) + .foregroundStyle(.gray800) .frame(maxWidth: .infinity) - .frame(height: 48) + .frame(height: 62) } - .background(.gray800) - .clipShape(.rect(cornerRadius: 20)) - .contentShape(.rect(cornerRadius: 20)) + .background(.gray300) + .clipShape(.rect(cornerRadius: 31)) + .contentShape(.rect(cornerRadius: 31)) Button { - onCancel() + onConfirm() } label: { - Text(cancelTitle) + Text(confirmTitle) .pretendardFont(family: .Medium, size: 16) .foregroundStyle(.white) .frame(maxWidth: .infinity) - .frame(height: 48) + .frame(height: 62) } - .background(.gray800) - .clipShape(.rect(cornerRadius: 20)) - .contentShape(.rect(cornerRadius: 20)) + .background(isDestructive ? .orange800 : .navy900) + .clipShape(.rect(cornerRadius: 31)) + .contentShape(.rect(cornerRadius: 31)) } + .padding(.top, 2) } - .padding(.vertical, 32) - .padding(.horizontal, 24) - .frame(width: 320) - .background(.gray800) - .clipShape(.rect(cornerRadius: 20)) + .padding(.top, 32) + .padding(.horizontal, 18) + .padding(.bottom, 20) + .frame(maxWidth: 355) + .background(.staticWhite) + .clipShape(.rect(cornerRadius: 28)) .onTapGesture {} } diff --git a/Projects/Shared/DesignSystem/Sources/Ui/Navigation/CustomNavigationBar.swift b/Projects/Shared/DesignSystem/Sources/Ui/Navigation/CustomNavigationBar.swift new file mode 100644 index 0000000..0704ec7 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Ui/Navigation/CustomNavigationBar.swift @@ -0,0 +1,60 @@ +// +// CustomNavigationBar.swift +// DesignSystem +// +// Created by Wonji Suh on 3/25/26. +// + +import SwiftUI + +public struct CustomNavigationBar: View { + private var title: String + private var leftImage: ImageAsset + private var rightImage: ImageAsset + private var leftAction: () -> Void + private var rightAction: () -> Void + + public init( + title: String, + leftImage: ImageAsset, + rightImage: ImageAsset, + leftAction: @escaping () -> Void, + rightAction: @escaping () -> Void + ) { + self.title = title + self.leftImage = leftImage + self.rightImage = rightImage + self.leftAction = leftAction + self.rightAction = rightAction + } + + + public var body: some View { + HStack { + Image(asset: leftImage) + .resizable() + .scaledToFit() + .frame(width: 60, height: 60) + .onTapGesture { leftAction() } + + + Spacer() + + + Text(title) + .pretendardCustomFont(textStyle: .titleBold) + .foregroundStyle(.staticBlack) + + + Spacer() + + Image(asset: rightImage) + .resizable() + .scaledToFit() + .frame(width: 60, height: 60) + .onTapGesture { rightAction() } + + + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Ui/Navigation/NavigationBar.swift b/Projects/Shared/DesignSystem/Sources/Ui/Navigation/NavigationBar.swift new file mode 100644 index 0000000..5ab5a28 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Ui/Navigation/NavigationBar.swift @@ -0,0 +1,46 @@ +// +// NavigationBar.swift +// DesignSystem +// +// Created by Wonji Suh on 3/25/26. +// + +import SwiftUI + +public struct CustomNavigationBackBar: View { + private var buttonAction: () -> Void = { } + private var title: String + + public init( + buttonAction: @escaping () -> Void, + title: String + ) { + self.buttonAction = buttonAction + self.title = title + } + + public var body: some View { + HStack { + Image(asset: .leftArrow) + .resizable() + .scaledToFit() + .frame(width: 60, height: 60) + .onTapGesture { + buttonAction() + } + + Spacer() + + if !title.isEmpty { + Text(title) + .pretendardCustomFont(textStyle: .titleBold) + .foregroundStyle(.staticBlack) + + Spacer() + } else { + Spacer() + } + + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Ui/Toast/ToastManager.swift b/Projects/Shared/DesignSystem/Sources/Ui/Toast/ToastManager.swift index 28398af..3e5c712 100644 --- a/Projects/Shared/DesignSystem/Sources/Ui/Toast/ToastManager.swift +++ b/Projects/Shared/DesignSystem/Sources/Ui/Toast/ToastManager.swift @@ -15,7 +15,8 @@ public class ToastManager: ObservableObject { @Published public var currentToast: ToastType? @Published public var isVisible = false - private var dismissTimer: Timer? + private var dismissTask: Task? + private var hideAnimationTask: Task? private init() {} @@ -23,8 +24,9 @@ public class ToastManager: ObservableObject { _ toast: ToastType, duration: TimeInterval? = 3.0 ) { - // 기존 타이머 취소 - dismissTimer?.invalidate() + // 기존 작업 취소 + dismissTask?.cancel() + hideAnimationTask?.cancel() // 새 토스트 표시 currentToast = toast @@ -32,33 +34,38 @@ public class ToastManager: ObservableObject { isVisible = true } - // 자동 dismiss 타이머 설정 - if let duration { - dismissTimer = Timer.scheduledTimer(withTimeInterval: duration, repeats: false) { _ in - Task { @MainActor in - self.hideToast() - } - } - } else { - dismissTimer = nil + // 자동 dismiss 설정 + guard let duration else { + dismissTask = nil + return + } + + dismissTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(duration)) + guard !Task.isCancelled else { return } + self?.hideToast() } } public func hideToast() { + dismissTask?.cancel() + dismissTask = nil + withAnimation(.easeIn(duration: 0.3)) { isVisible = false } // 애니메이션 완료 후 토스트 제거 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - self.currentToast = nil + hideAnimationTask?.cancel() + hideAnimationTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(0.3)) + guard !Task.isCancelled else { return } + self?.currentToast = nil } - - dismissTimer?.invalidate() - dismissTimer = nil } - // 편의 메소드들 + // MARK: - 편의 메소드 + public func showSuccess(_ message: String) { showToast(.success(message)) } @@ -70,7 +77,7 @@ public class ToastManager: ObservableObject { public func showWarning(_ message: String) { showToast(.warning(message)) } - + public func showInfo(_ message: String) { showToast(.info(message)) } diff --git a/Projects/Shared/DesignSystem/Sources/Ui/Toast/ToastType.swift b/Projects/Shared/DesignSystem/Sources/Ui/Toast/ToastType.swift index 3efd425..6caa93b 100644 --- a/Projects/Shared/DesignSystem/Sources/Ui/Toast/ToastType.swift +++ b/Projects/Shared/DesignSystem/Sources/Ui/Toast/ToastType.swift @@ -28,28 +28,28 @@ public enum ToastType: Equatable { public var backgroundColor: Color { switch self { case .success: - return .gray700 + return .gray800 case .error: - return .gray700 + return .gray800 case .warning: - return .gray700 + return .gray800 case .info: - return .gray700 + return .gray800 case .loading: - return .gray700 + return .gray800 } } public var iconName: String? { switch self { case .success: - return "checkBlue" + return "warning" case .error: - return "errorXmark" + return "warning" case .warning: - return "errorXmark" + return "warning" case .info: - return "info.circle.fill" + return "warning" case .loading: return nil } diff --git a/Projects/Shared/DesignSystem/Sources/Ui/Toast/ToastView.swift b/Projects/Shared/DesignSystem/Sources/Ui/Toast/ToastView.swift index 0826564..295e5ec 100644 --- a/Projects/Shared/DesignSystem/Sources/Ui/Toast/ToastView.swift +++ b/Projects/Shared/DesignSystem/Sources/Ui/Toast/ToastView.swift @@ -9,34 +9,38 @@ import SwiftUI public struct ToastView: View { let toast: ToastType - @StateObject private var toastManager = ToastManager.shared public init(toast: ToastType) { self.toast = toast } public var body: some View { - HStack(alignment: .center, spacing: 8) { - leadingView + HStack(spacing: 12) { + Spacer() + .frame(width: 8) + leadingView // 메시지 Text(toast.message) .pretendardCustomFont(textStyle: .bodyBold) .foregroundColor(.white) .multilineTextAlignment(.leading) .fixedSize(horizontal: false, vertical: true) + + Spacer() } .padding(.horizontal, 20) .padding(.vertical, 11) + .frame(width: 361, height: 56) .background(toast.backgroundColor) - .cornerRadius(12) + .cornerRadius(30) .shadow(color: .black.opacity(0.1), radius: 4, x: 0, y: 2) } } // MARK: - Toast Overlay Modifier public struct ToastOverlay: ViewModifier { - @StateObject private var toastManager = ToastManager.shared + @ObservedObject private var toastManager = ToastManager.shared public func body(content: Content) -> some View { content @@ -79,7 +83,7 @@ private extension ToastView { Image(assetName: iconName) .resizable() .scaledToFit() - .frame(width: 12, height: 12) + .frame(width: 24, height: 24) } } } diff --git a/Projects/Shared/Utill/Sources/Date/Date+.swift b/Projects/Shared/Utill/Sources/Date/Date+.swift index fbfee4a..4e118fd 100644 --- a/Projects/Shared/Utill/Sources/Date/Date+.swift +++ b/Projects/Shared/Utill/Sources/Date/Date+.swift @@ -107,4 +107,43 @@ public extension Date { dateFormatter.dateFormat = "yyyy년 MM월" return dateFormatter.string(from: self) } + + /// 한국어 날짜 + 요일 포맷 (예: 2026년 3월 17일 화요일) + func formattedKoreanDateWithWeekday() -> String { + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "ko_KR") + dateFormatter.dateFormat = "yyyy년 M월 d일 EEEE" + return dateFormatter.string(from: self) + } + + /// 특정 날짜를 한국어 날짜 + 요일 포맷으로 변환 (예: 2026년 3월 17일 화요일) + static func formattedKoreanDateWithWeekday(from date: Date) -> String { + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "ko_KR") + dateFormatter.dateFormat = "yyyy년 M월 d일 EEEE" + return dateFormatter.string(from: date) + } + + /// 한국어 시간 포맷 (예: 오후 3시 32분) + func formattedKoreanTime() -> String { + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "ko_KR") + dateFormatter.dateFormat = "a h시 m분" + return dateFormatter.string(from: self) + } + + /// 특정 시간을 한국어 시간 포맷으로 변환 (예: 오후 3시 32분) + static func formattedKoreanTime(from date: Date) -> String { + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "ko_KR") + dateFormatter.dateFormat = "a h시 m분" + return dateFormatter.string(from: date) + } +} + +public extension Calendar { + func remainingTimeComponents(from currentTime: Date, to targetTime: Date) -> DateComponents { + let components = dateComponents([.hour, .minute], from: currentTime, to: targetTime) + return DateComponents(hour: max(components.hour ?? 0, 0), minute: max(components.minute ?? 0, 0)) + } } diff --git a/Tuist/Package.swift b/Tuist/Package.swift index 0578bca..50cdce0 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -20,7 +20,8 @@ let packageSettings = PackageSettings( "XCTestDynamicOverlay": .staticFramework, "Clocks": .staticFramework, "ConcurrencyExtras": .staticFramework, - "WeaveDI": .staticFramework + "WeaveDI": .staticFramework, + "ReactiveSwift": .staticFramework ] ) #endif @@ -29,7 +30,7 @@ let package = Package( name: "TimeSpot", dependencies: [ .package(url: "https://github.com/pointfreeco/swift-composable-architecture", exact: "1.23.0"), - .package(url: "https://github.com/johnpatrickmorgan/TCACoordinators.git", exact: "0.14.0"), + .package(url: "https://github.com/johnpatrickmorgan/TCACoordinators.git", exact: "0.13.0"), .package(url: "https://github.com/Roy-wonji/WeaveDI.git", from: "3.4.0"), .package(url: "https://github.com/google/GoogleSignIn-iOS", from: "9.0.0"), .package(url: "https://github.com/Roy-wonji/AsyncMoya", from: "1.1.8"),