diff --git a/Plugins/DependencyPackagePlugin/ProjectDescriptionHelpers/DependencyPackage/Extension+TargetDependencySPM.swift b/Plugins/DependencyPackagePlugin/ProjectDescriptionHelpers/DependencyPackage/Extension+TargetDependencySPM.swift index 8132d0b..cbb5b15 100644 --- a/Plugins/DependencyPackagePlugin/ProjectDescriptionHelpers/DependencyPackage/Extension+TargetDependencySPM.swift +++ b/Plugins/DependencyPackagePlugin/ProjectDescriptionHelpers/DependencyPackage/Extension+TargetDependencySPM.swift @@ -10,9 +10,12 @@ import ProjectDescription public extension TargetDependency.SPM { static let asyncMoya = TargetDependency.external(name: "AsyncMoya", condition: .none) static let composableArchitecture = TargetDependency.external(name: "ComposableArchitecture", condition: .none) + static let kingfisher = TargetDependency.external(name: "Kingfisher", condition: .none) static let tcaCoordinator = TargetDependency.external(name: "TCACoordinators", condition: .none) static let weaveDI = TargetDependency.external(name: "WeaveDI", condition: .none) static let googleSignIn = TargetDependency.external(name: "GoogleSignIn", condition: .none) static let appAuth: TargetDependency = .external(name: "AppAuth") + static let FirebaseCrashlytics = TargetDependency.external(name: "FirebaseCrashlytics", condition: .none) + static let sdWebImage = TargetDependency.external(name: "SDWebImageSwiftUI", condition: .none) } diff --git a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift index 27daddd..f7ac803 100644 --- a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift +++ b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift @@ -25,6 +25,7 @@ public extension ModulePath { case Auth case OnBoarding case Profile + case Web public static let name: String = "Presentation" diff --git a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift index b8175a0..45b58a2 100644 --- a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift +++ b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift @@ -17,7 +17,7 @@ extension String { return Project.Environment.bundlePrefix } - public static func appBuildVersion(buildVersion: String = "10") -> String { + public static func appBuildVersion(buildVersion: String = "18") -> String { return buildVersion } diff --git a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Setting/Project+Settings.swift b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Setting/Project+Settings.swift index 73049ee..7350dd7 100644 --- a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Setting/Project+Settings.swift +++ b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Setting/Project+Settings.swift @@ -48,6 +48,8 @@ extension Settings { .setCurrentProjectVersion(.appBuildVersion()) .setCodeSignIdentity() .setCodeSignStyle() + .setCodeSignAllowEntitlementsModification(true) + .setUserScriptSandboxing(false) .setSwiftVersion("6.0") .setVersioningSystem() .setProvisioningProfileSpecifier("match Development \(Project.Environment.bundlePrefix)") diff --git a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Setting/SettingDictionary.swift b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Setting/SettingDictionary.swift index 11e16ce..3375add 100644 --- a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Setting/SettingDictionary.swift +++ b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Setting/SettingDictionary.swift @@ -100,6 +100,21 @@ public extension SettingsDictionary { "CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES": SettingValue(stringLiteral: stringValue) ]) { _, new in new } } + + func setCodeSignAllowEntitlementsModification(_ value: Bool = true) -> SettingsDictionary { + let stringValue = value ? "YES" : "NO" + return merging([ + "CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION": SettingValue(stringLiteral: stringValue) + ]) { _, new in new } + } + + /// User Script Sandboxing을 비활성화 (entitlements 에러 해결) + func setUserScriptSandboxing(_ value: Bool = false) -> SettingsDictionary { + let stringValue = value ? "YES" : "NO" + return merging([ + "ENABLE_USER_SCRIPT_SANDBOXING": SettingValue(stringLiteral: stringValue) + ]) { _, new in new } + } } diff --git a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/InfoPlistDictionary.swift b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/InfoPlistDictionary.swift index 3c22171..a100265 100644 --- a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/InfoPlistDictionary.swift +++ b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/InfoPlistDictionary.swift @@ -118,22 +118,22 @@ extension InfoPlistDictionary { .string("${REVERSED_CLIENT_ID}") ]) ]), - // 구글 지도 - .dictionary([ - "CFBundleURLName": .string("google-maps"), - "CFBundleURLSchemes": .array([ - .string("googlemaps"), - .string("comgooglemaps") - ]) - ]), - // 네이버 지도 - .dictionary([ - "CFBundleURLName": .string("naver-maps"), - "CFBundleURLSchemes": .array([ - .string("nmap"), - .string("nmapmobile") - ]) - ]) + // 구글 지도 (제거 - 외부 앱 호출용이므로 불필요) + // .dictionary([ + // "CFBundleURLName": .string("google-maps"), + // "CFBundleURLSchemes": .array([ + // .string("googlemaps"), + // .string("comgooglemaps") + // ]) + // ]), + // 네이버 지도 (제거 - 외부 앱 호출용이므로 불필요) + // .dictionary([ + // "CFBundleURLName": .string("naver-maps"), + // "CFBundleURLSchemes": .array([ + // .string("nmap"), + // .string("nmapmobile") + // ]) + // ]) ]) ] return self.merging(dict) { (_, new) in new } @@ -240,4 +240,23 @@ extension InfoPlistDictionary { func setNMFGovClientSecret(_ value: String) -> InfoPlistDictionary { return self.merging(["NMFGovClientSecret": .string(value)]) { (_, new) in new } } + + // LSApplicationQueriesSchemes 설정 (다른 앱의 URL 스킴 호출을 위해 필요) + func setLSApplicationQueriesSchemes() -> InfoPlistDictionary { + let schemes = [ + // 네이버 지도 + "nmap", + "nmapmobile", + "navermap", + // 구글 지도 + "googlemaps", + "comgooglemaps", + // 카카오맵 + "kakaomap", + // 기본 지도들 + "maps" + ] + + return self.merging(["LSApplicationQueriesSchemes": .array(schemes.map { .string($0) })]) { (_, new) in new } + } } diff --git a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/Project+InfoPlist.swift b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/Project+InfoPlist.swift index ce46c21..74c0842 100644 --- a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/Project+InfoPlist.swift +++ b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/Project+InfoPlist.swift @@ -50,6 +50,7 @@ public extension InfoPlist { .setGIDClientID("${GOOGLE_CLIENT_ID}") .setUILaunchScreens() .setLocationPermissions() + .setLSApplicationQueriesSchemes() ) diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift index d414820..d2ec6aa 100644 --- a/Projects/App/Project.swift +++ b/Projects/App/Project.swift @@ -11,7 +11,8 @@ let project = Project.makeAppModule( scripts: [], dependencies: [ .Presentation(implements: .Presentation), - .Data(implements: .Repository) + .Data(implements: .Repository), + .SPM.FirebaseCrashlytics ], sources: ["Sources/**"], resources: ["Resources/**", "FontAsset/**"], diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json index 2a7279b..cfdb703 100644 --- a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "darklogo 1.png", + "filename" : "Group 1000003826.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" @@ -13,7 +13,7 @@ "value" : "dark" } ], - "filename" : "darklogo.png", + "filename" : "Group 1000003827.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Group 1000003826.png b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Group 1000003826.png new file mode 100644 index 0000000..2bb9e42 Binary files /dev/null and b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Group 1000003826.png differ diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Group 1000003827.png b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Group 1000003827.png new file mode 100644 index 0000000..2bb9e42 Binary files /dev/null and b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Group 1000003827.png differ diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/darklogo 1.png b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/darklogo 1.png deleted file mode 100644 index c0b0d7d..0000000 Binary files a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/darklogo 1.png and /dev/null differ diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/darklogo.png b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/darklogo.png deleted file mode 100644 index c0b0d7d..0000000 Binary files a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/darklogo.png and /dev/null differ diff --git a/Projects/App/Resources/GoogleService-Info2.plist b/Projects/App/Resources/GoogleService-Info2.plist new file mode 100644 index 0000000..03a3750 --- /dev/null +++ b/Projects/App/Resources/GoogleService-Info2.plist @@ -0,0 +1,30 @@ + + + + + API_KEY + AIzaSyCuEwad40yLs4BzISNiIGFemZTLBfPcMdU + GCM_SENDER_ID + 42173256062 + PLIST_VERSION + 1 + BUNDLE_ID + io.TimeSpot.co + PROJECT_ID + timespot-app + STORAGE_BUCKET + timespot-app.firebasestorage.app + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + 1:42173256062:ios:078c44ec597bd69892a32f + + \ No newline at end of file diff --git a/Projects/App/Resources/GoogleService-Info.plist b/Projects/App/Resources/GoogleService-info.plist similarity index 100% rename from Projects/App/Resources/GoogleService-Info.plist rename to Projects/App/Resources/GoogleService-info.plist diff --git a/Projects/App/Resources/splash.gif b/Projects/App/Resources/splash.gif new file mode 100644 index 0000000..1d52b6f Binary files /dev/null and b/Projects/App/Resources/splash.gif differ diff --git a/Projects/App/Sources/Application/AppDelegate.swift b/Projects/App/Sources/Application/AppDelegate.swift index 6e28cf9..2221a18 100644 --- a/Projects/App/Sources/Application/AppDelegate.swift +++ b/Projects/App/Sources/Application/AppDelegate.swift @@ -6,11 +6,17 @@ // import UIKit +import UserNotifications import WeaveDI import Home +import Kingfisher +import LogMacro -class AppDelegate: UIResponder, UIApplicationDelegate { +final class AppDelegate: UIResponder, UIApplicationDelegate, @MainActor UNUserNotificationCenterDelegate { + @Dependency(\.deeplinkRouter) var deeplinkRouter + @Dependency(\.authUseCase) var authUseCase + func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? @@ -20,9 +26,42 @@ class AppDelegate: UIResponder, UIApplicationDelegate { await AppDIManager.shared.registerDefaultDependencies() } + // Kingfisher 캐시 최적화 설정 + configureImageCaching() + // 네이버맵 초기화 (Home 모듈의 NaverMapInitializer 사용) NaverMapInitializer.initialize() + let center = UNUserNotificationCenter.current() + center.delegate = self + + // iOS 17+ 호환 배지 초기화 +// if #available(iOS 17.0, *) { +// center.setBadgeCount(0) { error in +// if let error = error { +// #logDebug("🔔 Failed to set badge count: \(error)") +// } +// } +// } else { +// application.applicationIconBadgeNumber = 0 +// } + + center.requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in + if let error = error { + #logDebug(" Notification auth error:", error) + return + } + + guard granted else { + #logDebug(" Notification permission not granted") + return + } + + Task { @MainActor in + UIApplication.shared.registerForRemoteNotifications() + } + } + return true } @@ -39,4 +78,184 @@ class AppDelegate: UIResponder, UIApplicationDelegate { didDiscardSceneSessions sceneSessions: Set ) { } + + // MARK: - Image Caching Configuration + private func configureImageCaching() { + let cache = ImageCache.default + + // 메모리 캐시 설정 - 50MB로 제한 + cache.memoryStorage.config.totalCostLimit = 50 * 1024 * 1024 + + // 디스크 캐시 설정 - 200MB로 제한, 1주일 보관 + cache.diskStorage.config.sizeLimit = 200 * 1024 * 1024 + cache.diskStorage.config.expiration = .days(7) + + // 이미지 다운로드 설정 (Google Places API 최적화) + let modifier = AnyModifier { request in + var r = request + r.setValue("image/webp,image/*,*/*;q=0.8", forHTTPHeaderField: "Accept") + r.setValue("TimeSpot-iOS/1.0", forHTTPHeaderField: "User-Agent") + r.cachePolicy = .useProtocolCachePolicy + // Google Places API 이미지는 응답이 느릴 수 있으므로 타임아웃 증가 + r.timeoutInterval = 30.0 + return r + } + + KingfisherManager.shared.defaultOptions = [ + .requestModifier(modifier), + .backgroundDecode, + .diskCacheExpiration(.days(1)), // Google Places 이미지는 1일만 캐시 + .memoryCacheExpiration(.seconds(300)) + ] + + // Google Places API를 위한 네트워크 최적화 + let config = KingfisherManager.shared.downloader.sessionConfiguration + config.httpMaximumConnectionsPerHost = 6 + config.timeoutIntervalForRequest = 30.0 + config.timeoutIntervalForResource = 60.0 + config.waitsForConnectivity = true + } +} + +extension AppDelegate { + func application( + _ app: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey : Any] = [:] + ) -> Bool { + return false + } + + // APNs 토큰 성공 + func application( + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { + let tokenString = deviceToken.map { String(format: "%02x", $0) }.joined() + UserDefaults.standard.set(tokenString, forKey: "Token") + + + Task.detached(priority: .utility) { + do { + try await Task.sleep(for: .seconds(0.3)) + _ = try await self.authUseCase.registerNotification(with: tokenString) + } catch { + #logDebug(" Failed to register device token: \(error.localizedDescription)") + } + } + } + + // APNs 토큰 실패 + func application( + _ application: UIApplication, + didFailToRegisterForRemoteNotificationsWithError error: Error + ) { + + } + + // 백그라운드 Push 알림 수신 + func application( + _ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void + ) { + // customPayload에서 historyId 추출하여 appStorage에 저장 (백그라운드에서도 처리) + if let customPayload = userInfo["customPayload"] as? [String: Any], + let historyId = customPayload["historyId"] as? Int { + #logDebug("🔔 백그라운드 Push 알림에서 historyId 받음: \(historyId)") + UserDefaults.standard.set(historyId, forKey: "visitingHistoryId") + } else if let customPayload = userInfo["customPayload"] as? [String: Any], + let historyIdString = customPayload["historyId"] as? String, + let historyId = Int(historyIdString) { + #logDebug("🔔 백그라운드 Push 알림에서 historyId 받음 (문자열): \(historyId)") + UserDefaults.standard.set(historyId, forKey: "visitingHistoryId") + } + + completionHandler(.newData) + } + + // 포그라운드 알림 표시 + @MainActor + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + let userInfo = notification.request.content.userInfo + + // customPayload에서 historyId 추출하여 appStorage에 저장 (포그라운드에서도 처리) + if let customPayload = userInfo["customPayload"] as? [String: Any], + let historyId = customPayload["historyId"] as? Int { + #logDebug("🔔 포그라운드 Push 알림에서 historyId 받음: \(historyId)") + UserDefaults.standard.set(historyId, forKey: "visitingHistoryId") + } else if let customPayload = userInfo["customPayload"] as? [String: Any], + let historyIdString = customPayload["historyId"] as? String, + let historyId = Int(historyIdString) { + #logDebug("🔔 포그라운드 Push 알림에서 historyId 받음 (문자열): \(historyId)") + UserDefaults.standard.set(historyId, forKey: "visitingHistoryId") + } + + completionHandler([.banner, .badge, .sound]) + } + + // 알림 터치 처리 + @MainActor + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + let userInfo = response.notification.request.content.userInfo + + // 푸시 알림 payload 분석 (디버그) + #logDebug("🔔 푸시 알림 payload 분석 시작") + #logDebug("🔔 Available keys: \(Array(userInfo.keys))") + for (key, value) in userInfo { + #logDebug("🔔 Key: \(key), Value: \(value), Type: \(type(of: value))") + } + if let customPayload = userInfo["customPayload"] as? [String: Any] { + #logDebug("🔔 customPayload 컨테이너 내용: \(customPayload)") + } + + // customPayload에서 historyId 추출하여 appStorage에 저장 + if let customPayload = userInfo["customPayload"] as? [String: Any], + let historyId = customPayload["historyId"] as? Int { + #logDebug("🔔 Push 알림에서 historyId 받음: \(historyId)") + UserDefaults.standard.set(historyId, forKey: "visitingHistoryId") + } else if let customPayload = userInfo["customPayload"] as? [String: Any], + let historyIdString = customPayload["historyId"] as? String, + let historyId = Int(historyIdString) { + #logDebug("🔔 Push 알림에서 historyId 받음 (문자열): \(historyId)") + UserDefaults.standard.set(historyId, forKey: "visitingHistoryId") + } + + if let urlString = self.deeplinkRouter.extractDeepLink(from: userInfo) { + #logDebug("🔗 Processing push notification deep link: \(urlString)") + + // UserDefaults에도 저장 (앱이 종료된 상태에서 푸시 알림을 탭한 경우 대비) + UserDefaults.standard.set(urlString, forKey: UserDefaultsKey.pendingPushDeepLink.rawValue) + + NotificationCenter.default.post( + name: .pushNotificationDeepLink, + object: nil, + userInfo: [ + "url": urlString, + "deeplink_type": "push" + ] + ) + + // 푸시 알림 터치 시 현재 표시중인 알림 뷰 닫기 + NotificationCenter.default.post( + name: .dismissRouteNotification, + object: nil + ) + } + + completionHandler() + } +} + + +enum UserDefaultsKey: String { + case pendingPushDeepLink } diff --git a/Projects/App/Sources/Di/DiRegister.swift b/Projects/App/Sources/Di/DiRegister.swift index fd55ac7..5f63c11 100644 --- a/Projects/App/Sources/Di/DiRegister.swift +++ b/Projects/App/Sources/Di/DiRegister.swift @@ -48,8 +48,8 @@ public final class AppDIManager { .register(HistoryInterface.self) { HistoryRepositoryImpl() } // MARK: - 역 .register(StationInterface.self) { StationRepositoryImpl() } - - + // MARK: - 장소 + .register(PlaceInterface.self) { PlaceRepositoryImpl() } .configure() } diff --git a/Projects/App/Sources/Di/KeychainTokenProvider.swift b/Projects/App/Sources/Di/KeychainTokenProvider.swift index 75ef6bf..bd8f41e 100644 --- a/Projects/App/Sources/Di/KeychainTokenProvider.swift +++ b/Projects/App/Sources/Di/KeychainTokenProvider.swift @@ -6,11 +6,17 @@ // import Foundation +import Security import DomainInterface import Foundations +import LogMacro final class KeychainTokenProvider: TokenProviding, @unchecked Sendable { + private enum Constants { + static let cachedAccessTokenKey = "cached_access_token" + } + private let keychainManager: KeychainManagingInterface init(keychainManager: KeychainManagingInterface) { @@ -23,10 +29,25 @@ final class KeychainTokenProvider: TokenProviding, @unchecked Sendable { return cached } + if let persistedToken = UserDefaults.standard.string(forKey: Constants.cachedAccessTokenKey), + !persistedToken.isEmpty { + TokenCache.shared.token = persistedToken + return persistedToken + } + + if let keychainToken = readAccessTokenFromKeychain(), !keychainToken.isEmpty { + TokenCache.shared.token = keychainToken + UserDefaults.standard.set(keychainToken, forKey: Constants.cachedAccessTokenKey) + return keychainToken + } + // 캐시가 없으면 비동기적으로 로드 Task { let token = await keychainManager.accessToken() TokenCache.shared.token = token + if let token, !token.isEmpty { + UserDefaults.standard.set(token, forKey: Constants.cachedAccessTokenKey) + } } // 현재는 캐시된 값 또는 nil 반환 @@ -36,18 +57,55 @@ final class KeychainTokenProvider: TokenProviding, @unchecked Sendable { func saveAccessToken(_ token: String) { // 캐시 업데이트 TokenCache.shared.token = token + UserDefaults.standard.set(token, forKey: Constants.cachedAccessTokenKey) // 백그라운드에서 비동기적으로 저장 Task { do { try await keychainManager.saveAccessToken(token) } catch { - print("Failed to save access token: \(error)") + #logError("Failed to save access token", "\(error)") // 저장 실패 시 캐시도 초기화 TokenCache.shared.token = nil + UserDefaults.standard.removeObject(forKey: Constants.cachedAccessTokenKey) + } + } + } + + func clearToken() { + // 메모리 캐시 클리어 + TokenCache.shared.token = nil + + // UserDefaults에서 제거 + UserDefaults.standard.removeObject(forKey: Constants.cachedAccessTokenKey) + + // 백그라운드에서 키체인에서도 제거 + Task { + do { + try await keychainManager.clear() + } catch { + #logError("Failed to clear tokens from keychain", "\(error)") } } } + + private func readAccessTokenFromKeychain() -> String? { + let service = Bundle.main.bundleIdentifier ?? "com.nomadspot.app" + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: "ACCESS_TOKEN", + kSecReturnData: true, + kSecMatchLimit: kSecMatchLimitOne + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let data = result as? Data else { + return nil + } + return String(data: data, encoding: .utf8) + } } // Thread-safe 토큰 캐시 diff --git a/Projects/App/Sources/Reducer/AppReducer.swift b/Projects/App/Sources/Reducer/AppReducer.swift index 52ce9ba..31c81ff 100644 --- a/Projects/App/Sources/Reducer/AppReducer.swift +++ b/Projects/App/Sources/Reducer/AppReducer.swift @@ -11,6 +11,7 @@ import Home import ComposableArchitecture import Entity import LogMacro +import UseCase @Reducer public struct AppReducer: Sendable { @@ -22,7 +23,6 @@ public struct AppReducer: Sendable { case home(HomeCoordinator.State) case auth(AuthCoordinator.State) - public init() { self = .splash(.init()) } @@ -51,12 +51,16 @@ public struct AppReducer: Sendable { case presentView case presentRoot case presentAuth + case handlePushNotificationDeepLink(String) } //MARK: - 앱내에서 사용하는 액션 public enum InnerAction: Equatable { case updateToHome case updateToAuth + case setupPushNotificationObserver + case handlePushDeepLink(String) + case checkPendingPushDeepLink } //MARK: - 비동기 처리 액션 @@ -135,8 +139,32 @@ extension AppReducer { } case .presentRoot: + #logDebug("🏠 AppReducer: Home 상태로 전환, 대기 중인 딥링크 확인") + + // 대기 중인 딥링크가 있는지 먼저 확인 + if let pendingDeepLink = UserDefaults.standard.string(forKey: "pendingPushDeepLink") { + #logDebug("📋 AppReducer: 대기 중인 딥링크 발견, 즉시 처리 = \(pendingDeepLink)") + + // visitingHistoryId 확인 - 유효하지 않으면 딥링크 무시 + let visitingHistoryId = UserDefaults.standard.integer(forKey: "visitingHistoryId") + #logDebug("🔍 AppReducer: 현재 visitingHistoryId = \(visitingHistoryId)") + + // 시간 알림 딥링크이면서 유효한 visitingHistoryId가 있을 때만 RouteNotificationView 표시 + if (pendingDeepLink.contains("min_before") || pendingDeepLink.contains("min_after") || pendingDeepLink.contains("departure_time") || pendingDeepLink.contains("end_journey")) && visitingHistoryId > 0 { + #logDebug("✅ AppReducer: 유효한 여정이 있음, RouteNotificationView 포함한 Home 상태 생성") + UserDefaults.standard.removeObject(forKey: "pendingPushDeepLink") + state = .home(.init(withRouteNotification: true, deepLink: pendingDeepLink)) + } else { + #logDebug("🔍 AppReducer: 여정이 없거나 일반 딥링크, 기본 Home 상태로 전환하고 딥링크 제거") + UserDefaults.standard.removeObject(forKey: "pendingPushDeepLink") + state = .home(.init()) + } + return .none + } else { + #logDebug("🔍 AppReducer: 대기 중인 딥링크 없음, 일반 Home 상태로 전환") state = .home(.init()) - return .none + return .none + } case .presentAuth: state = .auth(.init()) @@ -144,6 +172,10 @@ extension AppReducer { .cancel(id: CancelID.mainEffects), ) + case .handlePushNotificationDeepLink(let urlString): + #logDebug("🔗 AppReducer: 푸쉬 딥링크 처리 = \(urlString)") + return .send(.inner(.handlePushDeepLink(urlString))) + } } @@ -170,7 +202,55 @@ extension AppReducer { state: inout State, action: InnerAction ) -> Effect { - return .none + switch action { + case .updateToHome: + return .none + + case .updateToAuth: + return .none + + case .setupPushNotificationObserver: + #logDebug("📱 AppReducer: 푸쉬 알림 옵저버 설정") + return .run { send in + for await notification in NotificationCenter.default.notifications(named: .pushNotificationDeepLink) { + if let urlString = notification.userInfo?["url"] as? String { + #logDebug("📱 AppReducer: 푸쉬 딥링크 수신 = \(urlString)") + await send(.view(.handlePushNotificationDeepLink(urlString))) + } + } + } + + case .handlePushDeepLink(let urlString): + #logDebug("🔗 AppReducer: 딥링크 처리 = \(urlString)") + + // Home 상태일 때만 HomeCoordinator로 전달 + switch state { + case .home: + #logDebug("✅ AppReducer: Home 상태, HomeCoordinator로 딥링크 전달") + // 시간 알림 딥링크면 RouteView로 이동 + if urlString.contains("min_before") || urlString.contains("min_after") || urlString.contains("departure_time") || urlString.contains("end_journey") { + #logDebug("🚀 AppReducer: 시간 알림 딥링크 감지, HomeCoordinator로 전달") + return .send(.scope(.home(.inner(.presentRouteFromPushNotification(urlString))))) + } + #logDebug("❌ AppReducer: 시간 알림 딥링크가 아님") + return .none + case .auth, .splash: + #logDebug("⏳ AppReducer: 아직 Home 상태가 아님, 나중에 처리 필요") + return .none + } + + case .checkPendingPushDeepLink: + #logDebug("🔍 AppReducer: 대기 중인 푸쉬 딥링크 확인") + return .run { send in + if let pendingDeepLink = UserDefaults.standard.string(forKey: "pendingPushDeepLink") { + #logDebug("📋 AppReducer: 대기 중인 딥링크 발견 = \(pendingDeepLink)") + UserDefaults.standard.removeObject(forKey: "pendingPushDeepLink") + await send(.inner(.handlePushDeepLink(pendingDeepLink))) + } else { + #logDebug("🔍 AppReducer: 대기 중인 딥링크 없음") + } + } + } } private func handleNavigationAction( @@ -215,15 +295,21 @@ extension AppReducer { /// Refresh token 만료 감지 리스너 설정 private func setupRefreshTokenExpiredListener() -> Effect { - #logDebug("🔔 [AppReducer] 🚨 SETTING UP REFRESH TOKEN EXPIRED LISTENER...") + #logDebug(" [AppReducer] 🚨 SETTING UP REFRESH TOKEN EXPIRED LISTENER...") return .publisher { NotificationCenter.default .publisher(for: NSNotification.Name("RefreshTokenExpired")) .map { notification in - #logDebug("🔔 [AppReducer] 🔥 🎯 REFRESH TOKEN EXPIRED NOTIFICATION RECEIVED!") - #logDebug("🔔 [AppReducer] Notification details: \(notification)") + #logDebug(" [AppReducer] 🔥 🎯 REFRESH TOKEN EXPIRED NOTIFICATION RECEIVED!") + #logDebug(" [AppReducer] Notification details: \(notification)") return Action.async(.refreshTokenExpired) } } } } + +// MARK: - Notification Extensions +extension Notification.Name { + static let pushNotificationDeepLink = Notification.Name("pushNotificationDeepLink") + static let dismissRouteNotification = Notification.Name("dismissRouteNotification") +} diff --git a/Projects/App/Sources/View/AppView.swift b/Projects/App/Sources/View/AppView.swift index 0c1d963..0849527 100644 --- a/Projects/App/Sources/View/AppView.swift +++ b/Projects/App/Sources/View/AppView.swift @@ -53,5 +53,8 @@ struct AppView: View { .appDefault, value: store.state.animationID ) + .onAppear { + store.send(.inner(.setupPushNotificationObserver)) + } } } diff --git a/Projects/Data/API/Sources/API/Auth/AuthAPI.swift b/Projects/Data/API/Sources/API/Auth/AuthAPI.swift index 5f628f7..68f818d 100644 --- a/Projects/Data/API/Sources/API/Auth/AuthAPI.swift +++ b/Projects/Data/API/Sources/API/Auth/AuthAPI.swift @@ -12,6 +12,7 @@ public enum AuthAPI: String, CaseIterable { case logout case refresh case withDraw + case registerNotification public var description: String { switch self { @@ -23,6 +24,8 @@ public enum AuthAPI: String, CaseIterable { return "/refresh" case .withDraw: return "" + case .registerNotification: + return "/devices" } } } diff --git a/Projects/Data/API/Sources/API/Domain/TimeSpotDomain.swift b/Projects/Data/API/Sources/API/Domain/TimeSpotDomain.swift index c1ee8c3..9bf304d 100644 --- a/Projects/Data/API/Sources/API/Domain/TimeSpotDomain.swift +++ b/Projects/Data/API/Sources/API/Domain/TimeSpotDomain.swift @@ -27,7 +27,7 @@ extension TimeSpotDomain: DomainType { case .auth: return "api/v1/auth" case .place: - return "api/v1/place" + return "api/v2/places" case .profile: return "api/v1/users" case .history: diff --git a/Projects/Data/API/Sources/API/History/HistoryAPI.swift b/Projects/Data/API/Sources/API/History/HistoryAPI.swift index cd99ee9..7f1b3c9 100644 --- a/Projects/Data/API/Sources/API/History/HistoryAPI.swift +++ b/Projects/Data/API/Sources/API/History/HistoryAPI.swift @@ -6,13 +6,21 @@ // import Foundation -public enum HistoryAPI: String, CaseIterable { +public enum HistoryAPI { case myHistory + case startHistory + case endHistory(historyId: Int) public var description: String { switch self { case .myHistory: return "" + + case .startHistory: + return "" + + case .endHistory(let historyId): + return "/\(historyId)" } } } diff --git a/Projects/Data/API/Sources/API/Place/PlaceAPI.swift b/Projects/Data/API/Sources/API/Place/PlaceAPI.swift new file mode 100644 index 0000000..a7880fc --- /dev/null +++ b/Projects/Data/API/Sources/API/Place/PlaceAPI.swift @@ -0,0 +1,22 @@ +// +// PlaceAPI.swift +// API +// +// Created by Wonji Suh on 3/27/26. +// + +import Foundation + +public enum PlaceAPI { + case fetchPlace + case detailPlace(placeId: Int) + + public var description: String { + switch self { + case .fetchPlace: + return "" + case .detailPlace(let placeId): + return "/\(placeId)" + } + } +} diff --git a/Projects/Data/API/Sources/API/Profile/ProfileAPI.swift b/Projects/Data/API/Sources/API/Profile/ProfileAPI.swift index c3b05f3..ab59f8b 100644 --- a/Projects/Data/API/Sources/API/Profile/ProfileAPI.swift +++ b/Projects/Data/API/Sources/API/Profile/ProfileAPI.swift @@ -10,14 +10,20 @@ import Foundation public enum ProfileAPI: String, CaseIterable { case user case editUser + case fetchNotification + case editNotification public var description : String { switch self { case .user: return "" - case .editUser: return "" + case .fetchNotification: + return "/notification-settings" + + case .editNotification: + return "/notification-settings" } } } diff --git a/Projects/Data/API/Sources/API/Station/StationAPI.swift b/Projects/Data/API/Sources/API/Station/StationAPI.swift index b59a212..1b4c2a1 100644 --- a/Projects/Data/API/Sources/API/Station/StationAPI.swift +++ b/Projects/Data/API/Sources/API/Station/StationAPI.swift @@ -9,17 +9,17 @@ import Foundation public enum StationAPI { case allStation - case addFavoriteStation - case deleteFavoriteStation(deleteStationId: Int) + case addFavoriteStation(stationID: Int) + case deleteFavoriteStation(stationID: Int) public var description: String { switch self { case .allStation: return "" - case .addFavoriteStation: - return "/favorites" - case .deleteFavoriteStation(let deleteStationId): - return "/favorites/\(deleteStationId)" + case .addFavoriteStation(let stationID): + return "/favorites/\(stationID)" + case .deleteFavoriteStation(let stationID): + return "/favorites/\(stationID)" } } } diff --git a/Projects/Data/Model/Sources/Auth/DTO/RegisterNotificationDTO.swift b/Projects/Data/Model/Sources/Auth/DTO/RegisterNotificationDTO.swift new file mode 100644 index 0000000..97a1e76 --- /dev/null +++ b/Projects/Data/Model/Sources/Auth/DTO/RegisterNotificationDTO.swift @@ -0,0 +1,23 @@ +// +// RegisterNotificationDTO.swift +// Model +// +// Created by Wonji Suh on 3/30/26. +// + + +import Foundation + + +public typealias RegisterNotificationDTO = BaseResponseDTO + +public struct RegisterNotificationResponseDTOModel: Decodable, Equatable { + let userID: String? + let deviceToken: String + let isActive: Bool + + enum CodingKeys: String, CodingKey { + case userID = "userId" + case deviceToken, isActive + } +} diff --git a/Projects/Data/Model/Sources/Auth/Mapper/ Extension+LoginModel.swift b/Projects/Data/Model/Sources/Auth/Mapper/Extension+LoginModel.swift similarity index 100% rename from Projects/Data/Model/Sources/Auth/Mapper/ Extension+LoginModel.swift rename to Projects/Data/Model/Sources/Auth/Mapper/Extension+LoginModel.swift diff --git a/Projects/Data/Model/Sources/Auth/Mapper/RegisterNotificationDTO+.swift b/Projects/Data/Model/Sources/Auth/Mapper/RegisterNotificationDTO+.swift new file mode 100644 index 0000000..29e12cd --- /dev/null +++ b/Projects/Data/Model/Sources/Auth/Mapper/RegisterNotificationDTO+.swift @@ -0,0 +1,19 @@ +// +// RegisterNotificationDTO+.swift +// Model +// +// Created by Wonji Suh on 3/30/26. +// + +import Foundation +import Entity + +public extension RegisterNotificationResponseDTOModel { + func toDomain() -> RegisterNotificationEntity { + return RegisterNotificationEntity( + userId: self.userID, + deviceToken: self.deviceToken, + isActive: self.isActive + ) + } +} diff --git a/Projects/Data/Model/Sources/History/DTO/HistoryDTOModel.swift b/Projects/Data/Model/Sources/History/DTO/HistoryDTOModel.swift index 676baa9..9a2c2ea 100644 --- a/Projects/Data/Model/Sources/History/DTO/HistoryDTOModel.swift +++ b/Projects/Data/Model/Sources/History/DTO/HistoryDTOModel.swift @@ -75,7 +75,7 @@ public struct HistoryItemResponseDTO: Decodable, Equatable { public let visitingHistoryID: Int public let stationID: Int public let stationName: String - public let placeID: Int + public let placeID: String public let placeName: String public let placeCategory: String public let startTime: String @@ -106,7 +106,7 @@ public struct HistoryItemResponseDTO: Decodable, Equatable { visitingHistoryID: Int, stationID: Int, stationName: String, - placeID: Int, + placeID: String, placeName: String, placeCategory: String, startTime: String, diff --git a/Projects/Data/Model/Sources/History/DTO/JourneyDTOModel.swift b/Projects/Data/Model/Sources/History/DTO/JourneyDTOModel.swift new file mode 100644 index 0000000..852bd2e --- /dev/null +++ b/Projects/Data/Model/Sources/History/DTO/JourneyDTOModel.swift @@ -0,0 +1,98 @@ +// +// JourneyDTOModel.swift +// Model +// +// Created by Wonji Suh on 4/1/26. +// + +import Foundation + +public typealias StartJourneyDTOModel = BaseResponseDTO +public typealias EndJourneyDTOModel = BaseResponseDTO + +public struct JourneyResponseDTO: Decodable, Equatable { + public let visitingHistoryId: Int + public let stationId: Int + public let stationName: String + public let stationAddress: String + public let placeId: String + public let placeName: String + public let placeCategory: String + public let placeAddress: String + public let placeLat: Double + public let placeLng: Double + public let startTime: String + public let endTime: String? + public let trainDepartureTime: String + public let totalDurationMinutes: Int + public let isInProgress: Bool + public let isSuccess: Bool + public let createdAt: String + public let startLat: Double? + public let startLng: Double? + + enum CodingKeys: String, CodingKey { + case visitingHistoryId + case stationId + case stationName + case stationAddress + case placeId + case placeName + case placeCategory + case placeAddress + case placeLat + case placeLng + case startTime + case endTime + case trainDepartureTime + case totalDurationMinutes + case isInProgress + case isSuccess + case createdAt + case startLat + case startLng + } + + public init( + visitingHistoryId: Int, + stationId: Int, + stationName: String, + stationAddress: String, + placeId: String, + placeName: String, + placeCategory: String, + placeAddress: String, + placeLat: Double, + placeLng: Double, + startTime: String, + endTime: String? = nil, + trainDepartureTime: String, + totalDurationMinutes: Int, + isInProgress: Bool, + isSuccess: Bool, + createdAt: String, + startLat: Double? = nil, + startLng: Double? = nil + ) { + self.visitingHistoryId = visitingHistoryId + self.stationId = stationId + self.stationName = stationName + self.stationAddress = stationAddress + self.placeId = placeId + self.placeName = placeName + self.placeCategory = placeCategory + self.placeAddress = placeAddress + self.placeLat = placeLat + self.placeLng = placeLng + self.startTime = startTime + self.endTime = endTime + self.trainDepartureTime = trainDepartureTime + self.totalDurationMinutes = totalDurationMinutes + self.isInProgress = isInProgress + self.isSuccess = isSuccess + self.createdAt = createdAt + self.startLat = startLat + self.startLng = startLng + } +} + diff --git a/Projects/Data/Model/Sources/History/Mapper/JourneyDTOModel+.swift b/Projects/Data/Model/Sources/History/Mapper/JourneyDTOModel+.swift new file mode 100644 index 0000000..4284cc3 --- /dev/null +++ b/Projects/Data/Model/Sources/History/Mapper/JourneyDTOModel+.swift @@ -0,0 +1,55 @@ +// +// JourneyDTOModel+.swift +// Model +// +// Created by Wonji Suh on 4/1/26. +// + +import Entity +import Foundation + +public extension JourneyResponseDTO { + func toDomain() -> JourneyEntity { + let dateFormatter = ISO8601DateFormatter() + dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + // Fallback formatter without fractional seconds + let fallbackFormatter = ISO8601DateFormatter() + fallbackFormatter.formatOptions = [.withInternetDateTime] + + // Custom formatter for server response format (without Z suffix) + let customFormatter = DateFormatter() + customFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + customFormatter.timeZone = TimeZone.current + + func parseDate(_ dateString: String) -> Date { + return dateFormatter.date(from: dateString) + ?? fallbackFormatter.date(from: dateString) + ?? customFormatter.date(from: dateString) + ?? Date() + } + + return JourneyEntity( + id: visitingHistoryId, + stationId: stationId, + stationName: stationName, + stationAddress: stationAddress, + placeId: placeId, + placeName: placeName, + placeCategory: placeCategory, + placeAddress: placeAddress, + placeLat: placeLat, + placeLng: placeLng, + startTime: parseDate(startTime), + endTime: endTime.map(parseDate), + trainDepartureTime: parseDate(trainDepartureTime), + totalDurationMinutes: totalDurationMinutes, + isInProgress: isInProgress, + isSuccess: isSuccess, + createdAt: parseDate(createdAt), + startLat: startLat, + startLng: startLng + ) + } +} + diff --git a/Projects/Data/Model/Sources/Place/DTO/PlaceDTOModel.swift b/Projects/Data/Model/Sources/Place/DTO/PlaceDTOModel.swift new file mode 100644 index 0000000..bb56e4e --- /dev/null +++ b/Projects/Data/Model/Sources/Place/DTO/PlaceDTOModel.swift @@ -0,0 +1,119 @@ +// +// PlaceDTOModel.swift +// Model +// +// Created by Wonji Suh on 3/27/26. +// + +import Foundation + + +public typealias PlaceDTOModel = BaseResponseDTO<[PlaceResponseDTOModel]> +public typealias PlaceSearchDTOModel = BaseResponseDTO + +// MARK: - Datum +public struct PlaceResponseDTOModel: Decodable, Equatable { + public let placeId: String + public let name: String? + public let category: String + public let address: String? + public let latitude: Double + public let longitude: Double + public let distanceFromUser: Double? + public let distanceFromStation: Double? + public let walkTimeFromStation: Int? + public let stayableMinutes: Int? + public let visitable: Bool + public let imageUrl: String? + public let isOpen: Bool? + public let closingTime: String? + + enum CodingKeys: String, CodingKey { + case placeId, name, category, address + case latitude, longitude, lat, lon + case distanceFromUser, distanceFromStation, walkTimeFromStation + case stayableMinutes, visitable, imageUrl, isOpen, closingTime + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.placeId = try container.decode(String.self, forKey: .placeId) + self.name = try container.decodeIfPresent(String.self, forKey: .name) + self.category = try container.decodeIfPresent(String.self, forKey: .category) ?? "" + self.address = try container.decodeIfPresent(String.self, forKey: .address) + if let latitudeValue = try container.decodeIfPresent(Double.self, forKey: .latitude) { + self.latitude = latitudeValue + } else if let latValue = try container.decodeIfPresent(Double.self, forKey: .lat) { + self.latitude = latValue + } else { + self.latitude = 0 + } + if let longitudeValue = try container.decodeIfPresent(Double.self, forKey: .longitude) { + self.longitude = longitudeValue + } else if let lonValue = try container.decodeIfPresent(Double.self, forKey: .lon) { + self.longitude = lonValue + } else { + self.longitude = 0 + } + self.distanceFromUser = try container.decodeIfPresent(Double.self, forKey: .distanceFromUser) + self.distanceFromStation = try container.decodeIfPresent(Double.self, forKey: .distanceFromStation) + self.walkTimeFromStation = try container.decodeIfPresent(Int.self, forKey: .walkTimeFromStation) + self.stayableMinutes = try container.decodeIfPresent(Int.self, forKey: .stayableMinutes) + self.visitable = try container.decodeIfPresent(Bool.self, forKey: .visitable) ?? false + self.imageUrl = try container.decodeIfPresent(String.self, forKey: .imageUrl) + self.isOpen = try container.decodeIfPresent(Bool.self, forKey: .isOpen) + self.closingTime = try container.decodeIfPresent(String.self, forKey: .closingTime) + } +} + +public struct PlaceSearchPageResponseDTO: Decodable, Equatable { + public let content: [PlaceResponseDTOModel] + public let number: Int + public let size: Int + public let hasNext: Bool + public let totalPages: Int + public let totalElements: Int + public let sort: PlaceSortResponseDTO + + enum CodingKeys: String, CodingKey { + case content, number, size, hasNext, totalPages, totalElements, sort + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.content = try container.decodeIfPresent([PlaceResponseDTOModel].self, forKey: .content) ?? [] + self.number = try container.decodeIfPresent(Int.self, forKey: .number) ?? 0 + self.size = try container.decodeIfPresent(Int.self, forKey: .size) ?? 10 + self.hasNext = try container.decodeIfPresent(Bool.self, forKey: .hasNext) ?? false + self.totalPages = try container.decodeIfPresent(Int.self, forKey: .totalPages) ?? 0 + self.totalElements = try container.decodeIfPresent(Int.self, forKey: .totalElements) ?? 0 + self.sort = try container.decodeIfPresent(PlaceSortResponseDTO.self, forKey: .sort) ?? .init() + } +} + +public struct PlaceSortResponseDTO: Decodable, Equatable { + public let empty: Bool + public let sorted: Bool + public let unsorted: Bool + + public init( + empty: Bool = true, + sorted: Bool = false, + unsorted: Bool = true + ) { + self.empty = empty + self.sorted = sorted + self.unsorted = unsorted + } + + enum CodingKeys: String, CodingKey { + case empty, sorted, unsorted + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.empty = try container.decodeIfPresent(Bool.self, forKey: .empty) ?? true + self.sorted = try container.decodeIfPresent(Bool.self, forKey: .sorted) ?? false + self.unsorted = try container.decodeIfPresent(Bool.self, forKey: .unsorted) ?? true + } +} diff --git a/Projects/Data/Model/Sources/Place/DTO/PlaceDetailDTOModel.swift b/Projects/Data/Model/Sources/Place/DTO/PlaceDetailDTOModel.swift new file mode 100644 index 0000000..4465ff6 --- /dev/null +++ b/Projects/Data/Model/Sources/Place/DTO/PlaceDetailDTOModel.swift @@ -0,0 +1,37 @@ +// +// PlaceDetailDTOModel.swift +// Model +// +// Created by Wonji Suh on 3/28/26. +// + +import Foundation + +public typealias PlaceDetailDTOModel = BaseResponseDTO + +// MARK: - DataClass +public struct PlaceDetailDTOResponseModel: Decodable, Equatable { + let placeId: String + let name, category, address: String + let latitude, longitude: Double + let distanceFromStation, walkTimeFromStation, stayableMinutes: Int + let visitable: Bool + let stationLatitude, stationLongitude: Double + let leaveTime: String + let images: [String] + let useTime: String? + let spendTime: String? + let useFee: String? + let discountInfo: String? + let accomCountCulture: String? + let parkingCulture: String? + let placeType: String + + enum CodingKeys: String, CodingKey { + case placeId, name, category, address, latitude, longitude + case distanceFromStation, walkTimeFromStation, stayableMinutes, visitable + case stationLatitude, stationLongitude, leaveTime, images + case useTime, spendTime, useFee, discountInfo, accomCountCulture, parkingCulture, placeType + } +} + diff --git a/Projects/Data/Model/Sources/Place/Mapper/PlaceDTOModel+.swift b/Projects/Data/Model/Sources/Place/Mapper/PlaceDTOModel+.swift new file mode 100644 index 0000000..d61e8d8 --- /dev/null +++ b/Projects/Data/Model/Sources/Place/Mapper/PlaceDTOModel+.swift @@ -0,0 +1,113 @@ +// +// PlaceDTOModel+.swift +// Model +// +// Created by Wonji Suh on 3/27/26. +// + + +import Foundation +import Entity + +public extension PlaceResponseDTOModel { + func toDomain() -> PlaceEntity { + PlaceEntity( + placeId: Int(placeId) ?? 0, + name: name ?? "", + category: mapCategory(category), + lat: latitude, + lon: longitude, + address: address ?? "", + imageURL: imageUrl, + stayableMinutes: stayableMinutes ?? 0, + isOpen: isOpen ?? visitable, + closingTime: closingTime, + distanceFromUser: distanceFromUser, + distanceFromStation: distanceFromStation, + walkTimeFromStation: walkTimeFromStation, + visitable: visitable + ) + } + + func toDomainForFetchPlaces() -> PlaceEntity { + PlaceEntity( + placeId: Int(placeId) ?? 0, + name: name ?? "", + category: mapCategory(category), + lat: latitude, + lon: longitude, + address: address ?? "", + imageURL: imageUrl, + stayableMinutes: stayableMinutes ?? 0, + isOpen: isOpen ?? visitable, + closingTime: closingTime, + distanceFromUser: distanceFromUser, + distanceFromStation: distanceFromStation, + walkTimeFromStation: walkTimeFromStation, + visitable: visitable + ) + } + + private func mapCategory(_ value: String) -> ExploreCategory { + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + + let category: ExploreCategory + switch normalized { + case "카페", "cafe": + category = .cafe + case "음식점", "restaurant": + category = .restaurant + case "액티비티", "activity": + category = .activity + case "관광지", "tour", "tourism": + category = .etc // 관광지를 etc로 매핑 + case "문화시설", "culture", "cultural": + category = .etc // 문화시설도 etc로 매핑 + case "쇼핑", "shopping": + category = .shopping + case "기타": + category = .etc + default: + category = .etc + } + + return category + } +} + +public extension PlaceSearchPageResponseDTO { + func toDomain() -> PlaceSearchPageEntity { + let sortEntity = sort.toDomain() + let pageable = PlacePageableEntity( + isUnpaged: false, + isPaged: true, + pageNumber: number, + pageSize: size, + offset: number * size, + sort: sortEntity + ) + + + return PlaceSearchPageEntity( + pageable: pageable, + isLastPage: !hasNext, + numberOfElements: content.count, + isFirstPage: number == 1, + size: size, + content: content.map { $0.toDomainForFetchPlaces() }, + page: number, // 서버와 클라이언트 모두 1-based 페이지 사용 + sort: sortEntity, + isEmpty: content.isEmpty + ) + } +} + +public extension PlaceSortResponseDTO { + func toDomain() -> PlaceSortEntity { + PlaceSortEntity( + isUnsorted: unsorted, + isSorted: sorted, + isEmpty: empty + ) + } +} diff --git a/Projects/Data/Model/Sources/Place/Mapper/PlaceDetailDTOModel+.swift b/Projects/Data/Model/Sources/Place/Mapper/PlaceDetailDTOModel+.swift new file mode 100644 index 0000000..30c2302 --- /dev/null +++ b/Projects/Data/Model/Sources/Place/Mapper/PlaceDetailDTOModel+.swift @@ -0,0 +1,38 @@ +// +// PlaceDetailDTOModel+.swift +// Model +// +// Created by Wonji Suh on 3/29/26. +// + +import Foundation + +import Entity + +public extension PlaceDetailDTOResponseModel { + func toDomain() -> PlaceDetailEntity { + PlaceDetailEntity( + placeId: placeId, + name: name, + category: category, + address: address, + latitude: latitude, + longitude: longitude, + distanceFromStation: distanceFromStation, + walkTimeFromStation: walkTimeFromStation, + stayableMinutes: stayableMinutes, + visitable: visitable, + stationLatitude: stationLatitude, + stationLongitude: stationLongitude, + leaveTime: leaveTime, + images: images, + useTime: useTime, + spendTime: spendTime, + useFee: useFee, + discountInfo: discountInfo, + accomCountCulture: accomCountCulture, + parkingCulture: parkingCulture, + placeType: placeType + ) + } +} diff --git a/Projects/Data/Model/Sources/Profile/DTO/ProfileNotificationDTO.swift b/Projects/Data/Model/Sources/Profile/DTO/ProfileNotificationDTO.swift new file mode 100644 index 0000000..902d1b1 --- /dev/null +++ b/Projects/Data/Model/Sources/Profile/DTO/ProfileNotificationDTO.swift @@ -0,0 +1,41 @@ +// +// ProfileNotificationDTO.swift +// Model +// +// Created by Wonji Suh on 3/27/26. +// + +import Foundation + +public typealias ProfileNotificationDTO = BaseResponseDTO + +// MARK: - DataClass +public struct ProfileNotificationResponseDTO: Decodable, Equatable { + public let settings: [ProfileNotificationSettingDTO] + public let updatedAt: String + + public init( + settings: [ProfileNotificationSettingDTO], + updatedAt: String + ) { + self.settings = settings + self.updatedAt = updatedAt + } +} + +// MARK: - Setting +public struct ProfileNotificationSettingDTO: Decodable, Equatable { + public let type: String + public let isEnabled: Bool + public let isEditable: Bool + + public init( + type: String, + isEnabled: Bool, + isEditable: Bool + ) { + self.type = type + self.isEnabled = isEnabled + self.isEditable = isEditable + } +} diff --git a/Projects/Data/Model/Sources/Profile/Mapper/ProfileNotificationDTO+.swift b/Projects/Data/Model/Sources/Profile/Mapper/ProfileNotificationDTO+.swift new file mode 100644 index 0000000..c695d3e --- /dev/null +++ b/Projects/Data/Model/Sources/Profile/Mapper/ProfileNotificationDTO+.swift @@ -0,0 +1,39 @@ +// +// ProfileNotificationDTO+.swift +// Model +// +// Created by Wonji Suh on 3/27/26. +// + +import Entity + +public extension ProfileNotificationDTO { + func toDomain() -> NotificationEntity { + data.toDomain() + } +} + +public extension ProfileNotificationResponseDTO { + func toDomain() -> NotificationEntity { + NotificationEntity( + settings: settings.compactMap { setting in + setting.toDomain() + }, + updatedAt: updatedAt + ) + } +} + +public extension ProfileNotificationSettingDTO { + func toDomain() -> NotificationSettingEntity? { + guard let option = NotificationOption(apiType: type) else { + return nil + } + + return NotificationSettingEntity( + option: option, + isEnabled: isEnabled, + isEditable: isEditable + ) + } +} diff --git a/Projects/Data/Model/Sources/Station/DTO/StationDTOModel.swift b/Projects/Data/Model/Sources/Station/DTO/StationDTOModel.swift index 355a65a..5f19cdb 100644 --- a/Projects/Data/Model/Sources/Station/DTO/StationDTOModel.swift +++ b/Projects/Data/Model/Sources/Station/DTO/StationDTOModel.swift @@ -39,24 +39,46 @@ public struct StationListResponseDTO: Decodable, Equatable { } public struct StationSummaryResponseDTO: Decodable, Equatable { + public let favoriteID: Int? public let stationID: Int public let name: String public let lines: [String] + public let lat: Double? + public let lng: Double? enum CodingKeys: String, CodingKey { + case favoriteID = "favoriteId" case stationID = "stationId" case name case lines + case lat + case lng } public init( + favoriteID: Int? = nil, stationID: Int, name: String, - lines: [String] + lines: [String], + lat: Double? = nil, + lng: Double? = nil ) { + self.favoriteID = favoriteID self.stationID = stationID self.name = name self.lines = lines + self.lat = lat + self.lng = lng + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.favoriteID = try container.decodeIfPresent(Int.self, forKey: .favoriteID) + self.stationID = try container.decode(Int.self, forKey: .stationID) + self.name = try container.decode(String.self, forKey: .name) + self.lines = try container.decode([String].self, forKey: .lines) + self.lat = try container.decodeIfPresent(Double.self, forKey: .lat) + self.lng = try container.decodeIfPresent(Double.self, forKey: .lng) } } diff --git a/Projects/Data/Model/Sources/Station/Mapper/StationDTOModel+.swift b/Projects/Data/Model/Sources/Station/Mapper/StationDTOModel+.swift index ac8adc6..0ab34d0 100644 --- a/Projects/Data/Model/Sources/Station/Mapper/StationDTOModel+.swift +++ b/Projects/Data/Model/Sources/Station/Mapper/StationDTOModel+.swift @@ -20,9 +20,12 @@ public extension StationListResponseDTO { public extension StationSummaryResponseDTO { func toDomain() -> StationSummaryEntity { StationSummaryEntity( + favoriteID: favoriteID, stationID: stationID, name: name, - lines: lines + lines: lines, + lat: lat, + lng: lng ) } } diff --git a/Projects/Data/Repository/Sources/Direction/DirectionRepositoryImpl.swift b/Projects/Data/Repository/Sources/Direction/DirectionRepositoryImpl.swift index 06a068e..3215af2 100644 --- a/Projects/Data/Repository/Sources/Direction/DirectionRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/Direction/DirectionRepositoryImpl.swift @@ -45,15 +45,12 @@ public final class DirectionRepositoryImpl: DirectionInterface, @unchecked Senda let goalCoord = "\(destination.longitude),\(destination.latitude)" do { - #logDebug(" [DirectionRepositoryImpl] 하이브리드 경로 검색 (경로: 네이버 Directions 15 + 시간: Apple MapKit)") + // 1-2. 병렬로 네이버 API와 Apple MapKit 호출 (성능 개선) - #logDebug(" [DirectionRepositoryImpl] 병렬 API 호출 시작") async let pathResponse: NaverWalkingResponse = provider.request(.walking(start: startCoord, goal: goalCoord, option: Constants.multiOptions)) async let appleResult = calculateWalkingTime(from: start, to: destination) - #logDebug(" [DirectionRepositoryImpl] 네이버 Directions 15로 실시간 경로 조회") - #logDebug(" [DirectionRepositoryImpl] Apple MapKit으로 도보 시간/거리 계산") let (naverResponse, appleData) = try await (pathResponse, appleResult) // 3. 하이브리드 결과 생성: 네이버 경로 + Apple 도보 시간/거리 @@ -91,10 +88,8 @@ public final class DirectionRepositoryImpl: DirectionInterface, @unchecked Senda return (duration: durationInMinutes, distance: distanceInMeters) } catch let mkError as MKError { - #logDebug(" [MKError] Apple MapKit 경로 계산 실패: \(mkError.localizedDescription)") throw DirectionError.invalidResponse } catch { - #logDebug(" [UnknownError] Apple MapKit 계산 실패: \(error)") throw DirectionError.invalidResponse } } diff --git a/Projects/Data/Repository/Sources/History/HistoryRepositoryImpl.swift b/Projects/Data/Repository/Sources/History/HistoryRepositoryImpl.swift index 916a494..b36cdb2 100644 --- a/Projects/Data/Repository/Sources/History/HistoryRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/History/HistoryRepositoryImpl.swift @@ -37,4 +37,30 @@ public class HistoryRepositoryImpl: HistoryInterface , @unchecked Sendable { return dto.data.toDomain() } + public func startJourney( + input: StartJourneyInput + ) async throws -> JourneyEntity { + let body = StartJourneyRequest( + stationId: input.stationId, + placeId: input.placeId, + trainDepartureTime: input.trainDepartureTime, + lat: input.lat, + lng: input.lng + ) + let dto: StartJourneyDTOModel = try await provider.request(.startHistory(body: body)) + return dto.data.toDomain() + } + + public func endJourney( + journeyId: Int, + isCompleted: Bool + ) async throws -> JourneyEntity { + let body = EndJourneyRequest( + journeyId: journeyId, + isCompleted: isCompleted + ) + let dto: EndJourneyDTOModel = try await provider.request(.endHistory(body: body)) + return dto.data.toDomain() + } + } diff --git a/Projects/Data/Repository/Sources/OAuth/Auth/Interceptor/AuthInterceptor.swift b/Projects/Data/Repository/Sources/OAuth/Auth/Interceptor/AuthInterceptor.swift index 91092ac..bbb1f3e 100644 --- a/Projects/Data/Repository/Sources/OAuth/Auth/Interceptor/AuthInterceptor.swift +++ b/Projects/Data/Repository/Sources/OAuth/Auth/Interceptor/AuthInterceptor.swift @@ -15,6 +15,7 @@ import Combine import LogMacro import ComposableArchitecture import UseCase +import Foundations // MARK: - Token Refresh Manager actor TokenRefreshManager { @@ -34,15 +35,11 @@ actor TokenRefreshManager { isRefreshing = true defer { isRefreshing = false } - #logDebug("🔄 Starting token refresh...") + #logDebug(" Starting token refresh...") do { let tokens = try await authRepository.refresh() - #if DEBUG - #logDebug("✅ Token refresh completed successfully: \(tokens)") - #else - #logDebug("✅ Token refresh completed successfully") - #endif + #logDebug(" Token refresh completed successfully: \(tokens)") // 키체인에 새 토큰 저장 try await keychainManager.save(accessToken: tokens.accessToken, refreshToken: tokens.refreshToken) @@ -60,16 +57,16 @@ actor TokenRefreshManager { return newCredential } catch { - #logDebug("❌ Token refresh failed: \(error)") + #logDebug(" Token refresh failed: \(error)") // Refresh token이 만료된 경우 자동 로그아웃 수행 if isRefreshTokenExpiredError(error) { - #logDebug("🚪 [TokenRefreshManager] 401 ERROR DETECTED! Starting automatic logout...") + #logDebug(" [TokenRefreshManager] 401 ERROR DETECTED! Starting automatic logout...") try await performAutomaticLogout() - #logDebug("✅ [TokenRefreshManager] Automatic logout completed, throwing refresh token expired error") + #logDebug(" [TokenRefreshManager] Automatic logout completed, throwing refresh token expired error") throw AuthError.refreshTokenExpired } else { - #logDebug("⚠️ [TokenRefreshManager] Non-401 error, rethrowing: \(error)") + #logDebug(" [TokenRefreshManager] Non-401 error, rethrowing: \(error)") throw error } } @@ -77,12 +74,12 @@ actor TokenRefreshManager { /// Refresh token이 만료된 에러인지 확인 private func isRefreshTokenExpiredError(_ error: Error) -> Bool { - #logDebug("🔍 [TokenRefreshManager] 🚨 CHECKING IF 401 ERROR: \(error)") + #logDebug(" [TokenRefreshManager] 🚨 CHECKING IF 401 ERROR: \(error)") // 1. statusCodeError(401) 직접 감지 (최우선) let errorString = String(describing: error) if errorString.contains("statusCodeError(401)") { - #logDebug("🎯 [TokenRefreshManager] ✅ statusCodeError(401) DETECTED!") + #logDebug(" [TokenRefreshManager] ✅ statusCodeError(401) DETECTED!") return true } @@ -90,27 +87,27 @@ actor TokenRefreshManager { if let moyaError = error as? MoyaError { switch moyaError { case .statusCode(let response): - #logDebug("📋 [TokenRefreshManager] MoyaError statusCode: \(response.statusCode)") + #logDebug(" [TokenRefreshManager] MoyaError statusCode: \(response.statusCode)") if response.statusCode == 401 { - #logDebug("🎯 [TokenRefreshManager] ✅ MoyaError 401 DETECTED!") + #logDebug(" [TokenRefreshManager] ✅ MoyaError 401 DETECTED!") return true } case .underlying(_, let response): - #logDebug("📋 [TokenRefreshManager] MoyaError underlying statusCode: \(String(describing: response?.statusCode))") + #logDebug(" [TokenRefreshManager] MoyaError underlying statusCode: \(String(describing: response?.statusCode))") if response?.statusCode == 401 { - #logDebug("🎯 [TokenRefreshManager] ✅ MoyaError underlying 401 DETECTED!") + #logDebug(" [TokenRefreshManager] ✅ MoyaError underlying 401 DETECTED!") return true } default: - #logDebug("📋 [TokenRefreshManager] Other MoyaError: \(moyaError)") + #logDebug(" [TokenRefreshManager] Other MoyaError: \(moyaError)") } } // 3. AuthError인 경우 if let authError = error as? AuthError { - #logDebug("📋 [TokenRefreshManager] AuthError: \(authError)") + #logDebug(" [TokenRefreshManager] AuthError: \(authError)") if authError.isTokenExpiredError { - #logDebug("🎯 [TokenRefreshManager] ✅ AuthError TOKEN EXPIRED DETECTED!") + #logDebug(" [TokenRefreshManager] ✅ AuthError TOKEN EXPIRED DETECTED!") return true } } @@ -123,45 +120,47 @@ actor TokenRefreshManager { errorDesc.contains("invalid token") || errorDesc.contains("token expired") || errorDesc.contains("authentication failed") { - #logDebug("🎯 [TokenRefreshManager] ✅ ERROR MESSAGE 401 DETECTED: \(errorDesc)") + #logDebug(" [TokenRefreshManager] ✅ ERROR MESSAGE 401 DETECTED: \(errorDesc)") return true } - #logDebug("❌ [TokenRefreshManager] Error is NOT 401 - continuing normally") + #logDebug(" [TokenRefreshManager] Error is NOT 401 - continuing normally") return false } /// 자동 로그아웃 수행 (로컬 상태 정리만) private func performAutomaticLogout() async throws { - #logDebug("🚪 [TokenRefreshManager] 🔥 PERFORMING AUTOMATIC LOGOUT - 401 ERROR DETECTED!") + #logDebug(" [TokenRefreshManager] 🔥 PERFORMING AUTOMATIC LOGOUT - 401 ERROR DETECTED!") // Refresh token이 만료된 상황이므로 서버 API 호출은 불가능 // 로컬 상태만 정리함 // 1. Keychain에서 모든 토큰 제거 - #logDebug("🔑 [TokenRefreshManager] Clearing keychain tokens...") + #logDebug(" [TokenRefreshManager] Clearing keychain tokens...") try await keychainManager.clear() - #logDebug("✅ [TokenRefreshManager] Keychain cleared") + #logDebug(" [TokenRefreshManager] Keychain cleared") // 2. AuthSessionManager credential 정리 - #logDebug("🗂️ [TokenRefreshManager] Clearing session manager...") + #logDebug(" [TokenRefreshManager] Clearing session manager...") await MainActor.run { AuthSessionManager.shared.credential = nil + // APIHeader TokenProvider도 함께 클리어 + APIHeader.clearAccessToken() } - #logDebug("✅ [TokenRefreshManager] Session manager cleared") + #logDebug(" [TokenRefreshManager] Session manager cleared") // 3. 전역 로그인 만료 알림 전송 - 확실하게 발송 - #logDebug("📢 [TokenRefreshManager] 🚨 SENDING LOGOUT NOTIFICATION...") + #logDebug(" [TokenRefreshManager] 🚨 SENDING LOGOUT NOTIFICATION...") await MainActor.run { NotificationCenter.default.post( name: NSNotification.Name("RefreshTokenExpired"), object: nil, userInfo: ["reason": "401_refresh_failed"] // 추가 정보 ) - #logDebug("✅ [TokenRefreshManager] 🎯 RefreshTokenExpired NOTIFICATION SENT!") + #logDebug(" [TokenRefreshManager] 🎯 RefreshTokenExpired NOTIFICATION SENT!") } - #logDebug("✅ [TokenRefreshManager] 🔥 AUTOMATIC LOGOUT COMPLETED!") + #logDebug(" [TokenRefreshManager] 🔥 AUTOMATIC LOGOUT COMPLETED!") } } @@ -188,7 +187,7 @@ final class AuthInterceptor: RequestInterceptor, @unchecked Sendable { adaptedRequest.headers.update(.authorization(bearerToken: newCredential.accessToken)) completion(.success(adaptedRequest)) } catch { - #logDebug("❌ Token refresh failed in adapt: \(error)") + #logDebug(" Token refresh failed in adapt: \(error)") completion(.failure(error)) } } @@ -206,7 +205,7 @@ final class AuthInterceptor: RequestInterceptor, @unchecked Sendable { return } - #logDebug("🚨 401 Unauthorized detected, attempting token refresh for retry") + #logDebug(" 401 Unauthorized detected, attempting token refresh for retry") _Concurrency.Task { do { @@ -215,11 +214,11 @@ final class AuthInterceptor: RequestInterceptor, @unchecked Sendable { // 갱신 성공 시 원래 요청 재시도 completion(.retry) } catch { - #logDebug("❌ Token refresh failed in retry: \(error)") + #logDebug(" Token refresh failed in retry: \(error)") // Refresh token이 만료된 경우 특별 처리 if let authError = error as? AuthError, authError.isTokenExpiredError { - #logDebug("🚪 Refresh token expired in retry - user will be automatically logged out") + #logDebug(" Refresh token expired in retry - user will be automatically logged out") // 자동 로그아웃이 이미 TokenRefreshManager에서 수행되었으므로 // 단순히 에러를 전달하여 UI가 적절히 대응할 수 있도록 함 completion(.doNotRetryWithError(authError)) diff --git a/Projects/Data/Repository/Sources/OAuth/Auth/RefreshToken/AccessTokenCredential.swift b/Projects/Data/Repository/Sources/OAuth/Auth/RefreshToken/AccessTokenCredential.swift index 208988b..e98f5c4 100644 --- a/Projects/Data/Repository/Sources/OAuth/Auth/RefreshToken/AccessTokenCredential.swift +++ b/Projects/Data/Repository/Sources/OAuth/Auth/RefreshToken/AccessTokenCredential.swift @@ -27,7 +27,7 @@ struct AccessTokenCredential: Sendable { // JWT 디코딩을 시도하되, 실패하면 기본 만료시간 사용 (24시간 후) let fallbackExpiration = Date().addingTimeInterval(24 * 60 * 60) // 24시간 let expiration = decodeExpiration(from: accessToken) ?? { - #logDebug("⚠️ JWT decoding failed, using fallback expiration: 24 hours from now") + #logDebug(" JWT decoding failed, using fallback expiration: 24 hours from now") return fallbackExpiration }() @@ -43,7 +43,7 @@ private extension AccessTokenCredential { static func decodeExpiration(from token: String) -> Date? { let components = token.components(separatedBy: ".") guard components.count == 3 else { - #logDebug("🚫 JWT decoding failed: Invalid JWT format (expected 3 parts, got \(components.count))") + #logDebug(" JWT decoding failed: Invalid JWT format (expected 3 parts, got \(components.count))") return nil } @@ -58,24 +58,24 @@ private extension AccessTokenCredential { } guard let data = Data(base64Encoded: base64) else { - #logDebug("🚫 JWT decoding failed: Base64 decoding failed") + #logDebug(" JWT decoding failed: Base64 decoding failed") return nil } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - #logDebug("🚫 JWT decoding failed: JSON parsing failed") + #logDebug(" JWT decoding failed: JSON parsing failed") return nil } guard let exp = json["exp"] as? TimeInterval else { - #logDebug("🚫 JWT decoding failed: 'exp' claim not found or invalid type") - #logDebug("🔍 Available keys in JWT payload: \(json.keys.joined(separator: ", "))") + #logDebug(" JWT decoding failed: 'exp' claim not found or invalid type") + #logDebug(" Available keys in JWT payload: \(json.keys.joined(separator: ", "))") return nil } let expirationDate = Date(timeIntervalSince1970: exp) - #logDebug("✅ JWT expiration decoded successfully: \(expirationDate)") - #logDebug("🕐 Time until expiration: \(expirationDate.timeIntervalSinceNow / 3600) hours") + #logDebug(" JWT expiration decoded successfully: \(expirationDate)") + #logDebug(" Time until expiration: \(expirationDate.timeIntervalSinceNow / 3600) hours") return expirationDate } diff --git a/Projects/Data/Repository/Sources/OAuth/Auth/Repository/AuthRepositoryImpl.swift b/Projects/Data/Repository/Sources/OAuth/Auth/Repository/AuthRepositoryImpl.swift index dac6680..efe255a 100644 --- a/Projects/Data/Repository/Sources/OAuth/Auth/Repository/AuthRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/OAuth/Auth/Repository/AuthRepositoryImpl.swift @@ -10,6 +10,7 @@ import Model import Entity import Service +import Foundations import WeaveDI import Dependencies import Moya @@ -38,7 +39,9 @@ final public class AuthRepositoryImpl: AuthInterface, @unchecked Sendable { ) async throws -> LoginEntity { let reqeust = OAuthLoginRequest(provider: socialProvider.rawValue, idToken: token) let dto: LoginDTOModel = try await provider.request(.login(body: reqeust)) - return dto.data.toDomain() + let entity = dto.data.toDomain() + APIHeader.updateAccessToken(entity.token.accessToken) + return entity } @@ -54,13 +57,13 @@ final public class AuthRepositoryImpl: AuthInterface, @unchecked Sendable { // ✅ TokenRefresher에서 keychain 저장과 credential 업데이트를 담당하므로 중복 제거 return refreshData } catch { - #logDebug("🔍 [AuthRepositoryImpl] Refresh failed: \(error)") + #logDebug(" [AuthRepositoryImpl] Refresh failed: \(error)") // 401 에러 감지 및 처리는 AuthInterceptor에서 처리하므로 여기서는 단순히 에러 전달 // AuthInterceptor가 더 정확하고 포괄적인 401 에러 감지를 수행 let errorString = String(describing: error) if errorString.contains("statusCodeError(401)") { - #logDebug("🚪 [AuthRepositoryImpl] statusCodeError(401) detected - AuthInterceptor will handle logout") + #logDebug(" [AuthRepositoryImpl] statusCodeError(401) detected - AuthInterceptor will handle logout") throw AuthError.refreshTokenExpired } @@ -68,10 +71,10 @@ final public class AuthRepositoryImpl: AuthInterface, @unchecked Sendable { if let moyaError = error as? MoyaError { switch moyaError { case .statusCode(let response) where response.statusCode == 401: - #logDebug("🚪 [AuthRepositoryImpl] MoyaError statusCode 401 detected - AuthInterceptor will handle logout") + #logDebug(" [AuthRepositoryImpl] MoyaError statusCode 401 detected - AuthInterceptor will handle logout") throw AuthError.refreshTokenExpired case .underlying(_, let response) where response?.statusCode == 401: - #logDebug("🚪 [AuthRepositoryImpl] MoyaError underlying 401 detected - AuthInterceptor will handle logout") + #logDebug(" [AuthRepositoryImpl] MoyaError underlying 401 detected - AuthInterceptor will handle logout") throw AuthError.refreshTokenExpired default: break @@ -81,7 +84,7 @@ final public class AuthRepositoryImpl: AuthInterface, @unchecked Sendable { // 에러 메시지에서 401 키워드 체크 let errorDesc = error.localizedDescription.lowercased() if errorDesc.contains("401") || errorDesc.contains("유효하지 않은 토큰") { - #logDebug("🚪 [AuthRepositoryImpl] Error description contains 401/invalid token - AuthInterceptor will handle logout") + #logDebug(" [AuthRepositoryImpl] Error description contains 401/invalid token - AuthInterceptor will handle logout") throw AuthError.refreshTokenExpired } @@ -93,12 +96,20 @@ final public class AuthRepositoryImpl: AuthInterface, @unchecked Sendable { public func logout() async throws -> LogoutEntity { let dto: LogoutDTOModel = try await authProvider.request(.logout) try await keychainManager.clear() + + // APIHeader tokenProvider도 함께 클리어 + APIHeader.clearAccessToken() + return dto.toDomain() } // MARK: - 계정 삭제 public func withDraw() async throws -> LogoutEntity { let dto: LogoutDTOModel = try await authProvider.request(.withDraw) try await keychainManager.clear() + + // APIHeader tokenProvider도 함께 클리어 + APIHeader.clearAccessToken() + return dto.toDomain() } @@ -107,4 +118,12 @@ final public class AuthRepositoryImpl: AuthInterface, @unchecked Sendable { AuthSessionManager.shared.updateCredential(with: tokens) } + // MARK: - 알림을 위한 token 등록 + public func registerNotification( + with deviceToken: String + ) async throws -> RegisterNotificationEntity { + let dto: RegisterNotificationDTO = try await provider.request(.registerNotification(deviceToken: deviceToken)) + return dto.data.toDomain() + } + } diff --git a/Projects/Data/Repository/Sources/Place/PlaceRepositoryImpl.swift b/Projects/Data/Repository/Sources/Place/PlaceRepositoryImpl.swift new file mode 100644 index 0000000..053af45 --- /dev/null +++ b/Projects/Data/Repository/Sources/Place/PlaceRepositoryImpl.swift @@ -0,0 +1,61 @@ +// +// PlaceRepositoryImpl.swift +// Repository +// +// Created by Wonji Suh on 3/27/26. +// + +import DomainInterface +import Model +import Entity + +import Service +import Dependencies +import LogMacro + +import AsyncMoya + +public final class PlaceRepositoryImpl: PlaceInterface, @unchecked Sendable { + private let provider: MoyaProvider + + public init( + provider: MoyaProvider = MoyaProvider.default, + ) { + self.provider = provider + } + + // MARK: - 장소 관련 api + public func fetchPlaces( + _ input: PlaceSearchInput + ) async throws -> PlaceSearchPageEntity { + let body = FetchPlaceRequest( + stationId: input.stationId, + userLat: input.userLat, + userLon: input.userLon, + remainingMinutes: input.remainingMinutes, + mapLat: input.mapLat, + mapLon: input.mapLon, + keyword: input.keyword, + category: input.category, + page: input.page, + size: input.size, + sort: input.sort + ) + let dto: PlaceSearchDTOModel = try await provider.request(.fetchPlace(body: body)) + return dto.data.toDomain() + } + + public func detailPlaces( + _ input: PlaceDetailInput + ) async throws -> PlaceDetailEntity { + let body: PlaceDetailRequest = .init( + stationId: input.stationId, + userLat: input.userLat, + userLon: input.userLon, + remainingMinutes: input.remainingMinutes + ) + + let dto: PlaceDetailDTOModel = try await provider.request(.detailPlaces(placeId: input.placeId, body: body)) + return dto.data.toDomain() + } +} diff --git a/Projects/Data/Repository/Sources/Profile/ProfileRepositoryImpl.swift b/Projects/Data/Repository/Sources/Profile/ProfileRepositoryImpl.swift index 179f0b7..8f740eb 100644 --- a/Projects/Data/Repository/Sources/Profile/ProfileRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/Profile/ProfileRepositoryImpl.swift @@ -82,6 +82,23 @@ public class ProfileRepositoryImpl: ProfileInterface, @unchecked Sendable { let dto: LoginDTOModel = try await provider.request(.editProfile(body: body)) return dto.data.toDomain() } + + public func fetchNotificationSettings() async throws -> NotificationEntity { + let dto: ProfileNotificationDTO = try await provider.request(.fetchNotification) + return dto.data.toDomain() + } + + public func editNotificationSettings( + notificationSettings: [NotificationOption] + ) async throws -> NotificationEntity { + let options: [NotificationOption] = [.fiveMinutesBefore, .tenMinutesBefore, .fifteenMinutesBefore] + let body = EditNotificationRequest( + options: options, + enabledOptions: Set(notificationSettings) + ) + let dto: ProfileNotificationDTO = try await provider.request(.editNotification(body: body)) + return dto.data.toDomain() + } } private struct ProfileErrorResponseDTO: Decodable { diff --git a/Projects/Data/Repository/Sources/SignUp/SignUpRepositoryImpl.swift b/Projects/Data/Repository/Sources/SignUp/SignUpRepositoryImpl.swift index 33a71ee..9584fa2 100644 --- a/Projects/Data/Repository/Sources/SignUp/SignUpRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/SignUp/SignUpRepositoryImpl.swift @@ -10,6 +10,7 @@ import DomainInterface import Model import Entity import Service +import Foundations @preconcurrency import AsyncMoya @@ -33,6 +34,8 @@ final public class SignUpRepositoryImpl: SignUpInterface { mapApi: input.mapType.type ) let dto: LoginDTOModel = try await provider.request(.signUp(body: body)) - return dto.data.toDomain() + let entity = dto.data.toDomain() + APIHeader.updateAccessToken(entity.token.accessToken) + return entity } } diff --git a/Projects/Data/Repository/Sources/Station/StationRepositoryImpl.swift b/Projects/Data/Repository/Sources/Station/StationRepositoryImpl.swift index 238f850..f031aa1 100644 --- a/Projects/Data/Repository/Sources/Station/StationRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/Station/StationRepositoryImpl.swift @@ -10,8 +10,11 @@ import Model import Entity import Service +import UseCase import AsyncMoya +import Foundation +import ComposableArchitecture public final class StationRepositoryImpl: StationInterface, @unchecked Sendable { private let authorizedProvider: MoyaProvider @@ -19,21 +22,21 @@ public final class StationRepositoryImpl: StationInterface, @unchecked Sendable public init( authorizedProvider: MoyaProvider = MoyaProvider.authorized, - publicProvider: MoyaProvider = MoyaProvider() + publicProvider: MoyaProvider = MoyaProvider.default ) { self.authorizedProvider = authorizedProvider self.publicProvider = publicProvider } public func fetchStations( - lat: Double, - lng: Double, + userLat: Double, + userLon: Double, page: Int, size: Int ) async throws -> StationListEntity { let body: StationRequest = .init( - lat: lat, - lng: lng, + userLat: userLat, + userLon: userLon, page: page, size: size, sort: "stationName,ASC" @@ -46,16 +49,36 @@ public final class StationRepositoryImpl: StationInterface, @unchecked Sendable stationID: Int ) async throws -> FavoriteStationMutationEntity { let body: AddFavoriteStationRequest = .init(stationID: stationID) - let dto: FavoriteStationMutationDTOModel = try await authorizedProvider.request(.addFavoriteStation(body: body)) + let response = try await authorizedProvider.requestResponse(.addFavoriteStation(body: body)) + let dto = try JSONDecoder().decode(FavoriteStationMutationDTOModel.self, from: response.data) + + guard 200..<300 ~= response.statusCode else { + throw NSError( + domain: "StationFavoriteError", + code: dto.code, + userInfo: [NSLocalizedDescriptionKey: dto.message] + ) + } + return dto.toDomain() } public func deleteFavoriteStation( - stationID: Int + favoriteID: Int ) async throws -> FavoriteStationMutationEntity { - let dto: FavoriteStationMutationDTOModel = try await authorizedProvider.request( - .deleteFavoriteStation(deleteStationId: stationID) + let response = try await authorizedProvider.requestResponse( + .deleteFavoriteStation(favoriteID: favoriteID) ) + let dto = try JSONDecoder().decode(FavoriteStationMutationDTOModel.self, from: response.data) + + guard 200..<300 ~= response.statusCode else { + throw NSError( + domain: "StationFavoriteError", + code: dto.code, + userInfo: [NSLocalizedDescriptionKey: dto.message] + ) + } + return dto.toDomain() } } diff --git a/Projects/Data/Service/Sources/Auth/AuthService.swift b/Projects/Data/Service/Sources/Auth/AuthService.swift index 3e5f2d3..a53411b 100644 --- a/Projects/Data/Service/Sources/Auth/AuthService.swift +++ b/Projects/Data/Service/Sources/Auth/AuthService.swift @@ -18,6 +18,7 @@ public enum AuthService { case refresh(refreshToken: String) case logout case withDraw + case registerNotification(deviceToken: String) } @@ -27,7 +28,7 @@ extension AuthService: BaseTargetType { public var domain: TimeSpotDomain { switch self { - case .login, .refresh, .logout: + case .login, .refresh, .logout, .registerNotification: return .auth case .withDraw: @@ -45,6 +46,8 @@ extension AuthService: BaseTargetType { return AuthAPI.logout.description case .withDraw: return AuthAPI.withDraw.description + case .registerNotification: + return AuthAPI.registerNotification.description } } @@ -54,7 +57,7 @@ extension AuthService: BaseTargetType { public var method: Moya.Method { switch self { - case .login, .refresh, .logout: + case .login, .refresh, .logout, .registerNotification: return .post case .withDraw: return .delete @@ -69,12 +72,14 @@ extension AuthService: BaseTargetType { return refreshToken.toDictionary(key: "refreshToken") case .logout, .withDraw: return nil + case .registerNotification(let deviceToken): + return deviceToken.toDictionary(key: "deviceToken") } } public var headers: [String : String]? { switch self { - case .logout, .withDraw: + case .logout, .withDraw, .registerNotification: return APIHeader.baseHeader default: return APIHeader.notAccessTokenHeader diff --git a/Projects/Data/Service/Sources/History/HistoryRequest.swift b/Projects/Data/Service/Sources/History/HistoryRequest.swift index d3ccb06..ed3fba2 100644 --- a/Projects/Data/Service/Sources/History/HistoryRequest.swift +++ b/Projects/Data/Service/Sources/History/HistoryRequest.swift @@ -23,3 +23,133 @@ public struct MyHistoryRequest: Encodable { self.sort = sort } } + +public struct StartJourneyRequest: Encodable { + public let stationId: Int + public let placeId: String + public let trainDepartureTime: String + public let lat: Double + public let lng: Double + + public init( + stationId: Int, + placeId: String, + trainDepartureTime: String, + lat: Double, + lng: Double + ) { + self.stationId = stationId + self.placeId = placeId + self.trainDepartureTime = trainDepartureTime + self.lat = lat + self.lng = lng + } + + // Date를 받아서 ISO8601 형식으로 변환하는 편의 이니셜라이저 + public init( + stationId: Int, + placeId: String, + trainDepartureTime: Date, + lat: Double, + lng: Double + ) { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + formatter.timeZone = TimeZone.current + + self.stationId = stationId + self.placeId = placeId + self.trainDepartureTime = formatter.string(from: trainDepartureTime) + self.lat = lat + self.lng = lng + } +} + +public struct EndJourneyRequest: Encodable { + public let journeyId: Int + public let isCompleted: Bool + + public init(journeyId: Int, isCompleted: Bool = true) { + self.journeyId = journeyId + self.isCompleted = isCompleted + } +} + +// MARK: - Response DTOs + +public struct ApiResponse: Codable { + public let code: Int + public let message: String + public let data: T + + public init(code: Int, message: String, data: T) { + self.code = code + self.message = message + self.data = data + } +} + +public struct StartJourneyResponseDTO: Codable { + public let visitingHistoryId: Int + public let stationId: Int + public let stationName: String + public let stationAddress: String + public let placeId: String + public let placeName: String + public let placeCategory: String + public let placeAddress: String + public let placeLat: Double + public let placeLng: Double + public let startTime: String + public let endTime: String? + public let trainDepartureTime: String + public let totalDurationMinutes: Int + public let isInProgress: Bool + public let isSuccess: Bool + public let createdAt: String + public let startLat: Double + public let startLng: Double + + public init( + visitingHistoryId: Int, + stationId: Int, + stationName: String, + stationAddress: String, + placeId: String, + placeName: String, + placeCategory: String, + placeAddress: String, + placeLat: Double, + placeLng: Double, + startTime: String, + endTime: String? = nil, + trainDepartureTime: String, + totalDurationMinutes: Int, + isInProgress: Bool, + isSuccess: Bool, + createdAt: String, + startLat: Double, + startLng: Double + ) { + self.visitingHistoryId = visitingHistoryId + self.stationId = stationId + self.stationName = stationName + self.stationAddress = stationAddress + self.placeId = placeId + self.placeName = placeName + self.placeCategory = placeCategory + self.placeAddress = placeAddress + self.placeLat = placeLat + self.placeLng = placeLng + self.startTime = startTime + self.endTime = endTime + self.trainDepartureTime = trainDepartureTime + self.totalDurationMinutes = totalDurationMinutes + self.isInProgress = isInProgress + self.isSuccess = isSuccess + self.createdAt = createdAt + self.startLat = startLat + self.startLng = startLng + } +} + diff --git a/Projects/Data/Service/Sources/History/HistoryService.swift b/Projects/Data/Service/Sources/History/HistoryService.swift index 1f08882..9fafd5c 100644 --- a/Projects/Data/Service/Sources/History/HistoryService.swift +++ b/Projects/Data/Service/Sources/History/HistoryService.swift @@ -14,6 +14,8 @@ import AsyncMoya public enum HistoryService { case myHistory(body: MyHistoryRequest) + case startHistory(body: StartJourneyRequest) + case endHistory(body: EndJourneyRequest) } @@ -28,6 +30,10 @@ extension HistoryService: BaseTargetType { switch self { case .myHistory: return HistoryAPI.myHistory.description + case .startHistory: + return HistoryAPI.startHistory.description + case .endHistory(let body): + return HistoryAPI.endHistory(historyId: body.journeyId).description } } @@ -39,6 +45,10 @@ extension HistoryService: BaseTargetType { switch self { case .myHistory: return .get + case .startHistory: + return .post + case .endHistory: + return .put } } @@ -46,6 +56,12 @@ extension HistoryService: BaseTargetType { switch self { case .myHistory(let body): return body.toDictionary + + case .startHistory(let body): + return body.toDictionary + + case .endHistory(let body): + return body.toDictionary } } diff --git a/Projects/Data/Service/Sources/Place/PlaceRequest.swift b/Projects/Data/Service/Sources/Place/PlaceRequest.swift new file mode 100644 index 0000000..d5f625f --- /dev/null +++ b/Projects/Data/Service/Sources/Place/PlaceRequest.swift @@ -0,0 +1,70 @@ +// +// PlaceRequest.swift +// Service +// +// Created by Wonji Suh on 3/27/26. +// + + +import Foundation + +// 기존 PlaceRequest 제거 - FetchPlaceRequest 사용 + +public struct FetchPlaceRequest: Encodable { + public let stationId: Int + public let userLat: Double + public let userLon: Double + public let remainingMinutes: Int + public let mapLat: Double? + public let mapLon: Double? + public let keyword: String? + public let category: String? + public let page: Int + public let size: Int + public let sort: String + + public init( + stationId: Int, + userLat: Double, + userLon: Double, + remainingMinutes: Int, + mapLat: Double? = nil, + mapLon: Double? = nil, + keyword: String? = nil, + category: String? = nil, + page: Int = 1, + size: Int = 50, + sort: String = "distanceFromStation,ASC" + ) { + self.stationId = stationId + self.userLat = userLat + self.userLon = userLon + self.remainingMinutes = remainingMinutes + self.mapLat = mapLat + self.mapLon = mapLon + self.keyword = keyword + self.category = category + self.page = page + self.size = size + self.sort = sort + } +} + +public struct PlaceDetailRequest: Encodable { + let stationId: Int + let userLat: Double + let userLon: Double + let remainingMinutes: Int + + public init( + stationId: Int, + userLat: Double, + userLon: Double, + remainingMinutes: Int + ) { + self.stationId = stationId + self.userLat = userLat + self.userLon = userLon + self.remainingMinutes = remainingMinutes + } +} diff --git a/Projects/Data/Service/Sources/Place/PlaceService.swift b/Projects/Data/Service/Sources/Place/PlaceService.swift new file mode 100644 index 0000000..4f9b668 --- /dev/null +++ b/Projects/Data/Service/Sources/Place/PlaceService.swift @@ -0,0 +1,60 @@ +// +// PlaceService.swift +// Service +// +// Created by Wonji Suh on 3/27/26. +// + +import Foundation + +import API +import Foundations + +import AsyncMoya + +public enum PlaceService { + case fetchPlace(body: FetchPlaceRequest) + case detailPlaces(placeId: Int, body: PlaceDetailRequest) +} + + +extension PlaceService: BaseTargetType { + public typealias Domain = TimeSpotDomain + + public var domain: TimeSpotDomain { + return .place + } + + public var urlPath: String { + switch self { + case .fetchPlace: + return PlaceAPI.fetchPlace.description + case .detailPlaces(let placeId, _): + return PlaceAPI.detailPlace(placeId: placeId).description + } + } + + public var error: [Int : NetworkError]? { + return nil + } + + public var method: Moya.Method { + switch self { + case .fetchPlace, .detailPlaces: + return .get + } + } + + public var parameters: [String : Any]? { + switch self { + case .fetchPlace(let body): + return body.toDictionary + case .detailPlaces(_, let body): + return body.toDictionary + } + } + + public var headers: [String : String]? { + return APIHeader.notAccessTokenHeader + } +} diff --git a/Projects/Data/Service/Sources/Profile/ProfileRequest.swift b/Projects/Data/Service/Sources/Profile/ProfileRequest.swift index 1758a4e..6216851 100644 --- a/Projects/Data/Service/Sources/Profile/ProfileRequest.swift +++ b/Projects/Data/Service/Sources/Profile/ProfileRequest.swift @@ -5,6 +5,8 @@ // Created by Wonji Suh on 3/26/26. // +import Entity + public struct ProfileRequest: Encodable { public let mapApi: String @@ -14,3 +16,46 @@ public struct ProfileRequest: Encodable { self.mapApi = mapApi } } + +public struct EditNotificationRequest: Encodable, Equatable { + public let notificationSettings: [NotificationSettingRequest] + + public init( + notificationSettings: [NotificationSettingRequest] + ) { + self.notificationSettings = notificationSettings + } + + public init( + options: [NotificationOption], + enabledOptions: Set + ) { + self.notificationSettings = options.map { + NotificationSettingRequest( + option: $0, + isEnabled: enabledOptions.contains($0) + ) + } + } +} + +public struct NotificationSettingRequest: Encodable, Equatable { + public let type: String + public let isEnabled: Bool + + public init( + type: String, + isEnabled: Bool + ) { + self.type = type + self.isEnabled = isEnabled + } + + public init( + option: NotificationOption, + isEnabled: Bool + ) { + self.type = option.apiType + self.isEnabled = isEnabled + } +} diff --git a/Projects/Data/Service/Sources/Profile/ProfileService.swift b/Projects/Data/Service/Sources/Profile/ProfileService.swift index bfde890..f1fee0b 100644 --- a/Projects/Data/Service/Sources/Profile/ProfileService.swift +++ b/Projects/Data/Service/Sources/Profile/ProfileService.swift @@ -17,6 +17,8 @@ import AsyncMoya public enum ProfileService { case fetchProfile case editProfile(body: ProfileRequest) + case fetchNotification + case editNotification(body: EditNotificationRequest) } @@ -34,6 +36,12 @@ extension ProfileService: BaseTargetType { case .editProfile: return ProfileAPI.editUser.description + + case .fetchNotification: + return ProfileAPI.fetchNotification.description + + case .editNotification: + return ProfileAPI.editNotification.description } } @@ -43,21 +51,27 @@ extension ProfileService: BaseTargetType { public var method: Moya.Method { switch self { - case .fetchProfile: + case .fetchProfile, .fetchNotification: return .get case .editProfile: return .post + + case .editNotification: + return .put } } public var parameters: [String : Any]? { switch self { - case .fetchProfile: + case .fetchProfile, .fetchNotification: return nil case .editProfile(let body): return body.toDictionary + + case .editNotification(let body): + return body.toDictionary } } diff --git a/Projects/Data/Service/Sources/Station/StationRequest.swift b/Projects/Data/Service/Sources/Station/StationRequest.swift index 4aa62e6..fdbf09a 100644 --- a/Projects/Data/Service/Sources/Station/StationRequest.swift +++ b/Projects/Data/Service/Sources/Station/StationRequest.swift @@ -8,24 +8,27 @@ import Foundation public struct StationRequest: Encodable, Equatable { - public let lat: Double - public let lng: Double + public let userLat: Double + public let userLon: Double public let page: Int public let size: Int public let sort: String + public let radius: Int public init( - lat: Double, - lng: Double, + userLat: Double, + userLon: Double, page: Int = 1, size: Int = 10, - sort: String = "stationName,ASC" + sort: String = "stationName,ASC", + radius: Int = 20000 ) { - self.lat = lat - self.lng = lng + self.userLat = userLat + self.userLon = userLon self.page = max(page, 1) self.size = max(size, 10) self.sort = sort + self.radius = radius } } diff --git a/Projects/Data/Service/Sources/Station/StationService.swift b/Projects/Data/Service/Sources/Station/StationService.swift index 2ff5bb2..340eed4 100644 --- a/Projects/Data/Service/Sources/Station/StationService.swift +++ b/Projects/Data/Service/Sources/Station/StationService.swift @@ -13,9 +13,10 @@ import Foundations import AsyncMoya public enum StationService { - case allStation(body: StationRequest) + case allStation(body: StationRequest) // For guest users + case memberStations(body: StationRequest) // For authenticated members case addFavoriteStation(body: AddFavoriteStationRequest) - case deleteFavoriteStation(deleteStationId: Int) + case deleteFavoriteStation(favoriteID: Int) } @@ -28,12 +29,12 @@ extension StationService: BaseTargetType { public var urlPath: String { switch self { - case .allStation: + case .allStation, .memberStations: return StationAPI.allStation.description - case .addFavoriteStation: - return StationAPI.addFavoriteStation.description - case .deleteFavoriteStation(let deleteStationId): - return StationAPI.deleteFavoriteStation(deleteStationId: deleteStationId).description + case .addFavoriteStation(let body): + return StationAPI.addFavoriteStation(stationID: body.stationID).description + case .deleteFavoriteStation(let stationID): + return StationAPI.deleteFavoriteStation(stationID: stationID).description } } @@ -43,7 +44,7 @@ extension StationService: BaseTargetType { public var method: Moya.Method { switch self { - case .allStation: + case .allStation, .memberStations: return .get case .addFavoriteStation: return .post @@ -54,7 +55,7 @@ extension StationService: BaseTargetType { public var parameters: [String : Any]? { switch self { - case .allStation(let body): + case .allStation(let body), .memberStations(let body): return body.toDictionary case .addFavoriteStation(let body): return body.toDictionary @@ -66,9 +67,11 @@ extension StationService: BaseTargetType { public var headers: [String : String]? { switch self { case .allStation: - return APIHeader.notAccessTokenHeader + return APIHeader.accessTokenKeyChain.isEmpty ? APIHeader.notAccessTokenHeader : APIHeader.baseHeader // Guest users + case .memberStations: + return APIHeader.baseHeader // Authenticated members case .addFavoriteStation, .deleteFavoriteStation: - return APIHeader.baseHeader + return APIHeader.baseHeader // 인증 필요한 API } } } diff --git a/Projects/Domain/DomainInterface/Sources/Auth/AuthInterface.swift b/Projects/Domain/DomainInterface/Sources/Auth/AuthInterface.swift index 0c788b2..f6f37df 100644 --- a/Projects/Domain/DomainInterface/Sources/Auth/AuthInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Auth/AuthInterface.swift @@ -18,6 +18,7 @@ public protocol AuthInterface: Sendable { func logout() async throws -> LogoutEntity func withDraw() async throws -> LogoutEntity func updateSessionCredential(with tokens: AuthTokens) + func registerNotification(with deviceToken: String) async throws -> RegisterNotificationEntity } /// Auth Repository의 DependencyKey 구조체 diff --git a/Projects/Domain/DomainInterface/Sources/Auth/DefaultAuthRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Auth/DefaultAuthRepositoryImpl.swift index 83acca6..d4668bb 100644 --- a/Projects/Domain/DomainInterface/Sources/Auth/DefaultAuthRepositoryImpl.swift +++ b/Projects/Domain/DomainInterface/Sources/Auth/DefaultAuthRepositoryImpl.swift @@ -53,4 +53,14 @@ final public class DefaultAuthRepositoryImpl: AuthInterface { public func updateSessionCredential(with tokens: AuthTokens) { // Mock 구현체에서는 아무것도 하지 않음 (테스트/프리뷰용) } + + public func registerNotification( + with deviceToken: String + ) async throws -> RegisterNotificationEntity { + return RegisterNotificationEntity( + userId: nil, + deviceToken: deviceToken, + isActive: true + ) + } } diff --git a/Projects/Domain/DomainInterface/Sources/History/DefaultHistoryRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/History/DefaultHistoryRepositoryImpl.swift index a98639d..2cf55ce 100644 --- a/Projects/Domain/DomainInterface/Sources/History/DefaultHistoryRepositoryImpl.swift +++ b/Projects/Domain/DomainInterface/Sources/History/DefaultHistoryRepositoryImpl.swift @@ -21,7 +21,7 @@ final public class DefaultHistoryRepositoryImpl: HistoryInterface { id: 1, stationID: 10, stationName: "서울역", - placeID: 100, + placeID: "100", placeName: "스타벅스 서울역점", placeCategory: "카페", startTime: "2024-03-25T13:00:00", @@ -36,7 +36,7 @@ final public class DefaultHistoryRepositoryImpl: HistoryInterface { id: 2, stationID: 20, stationName: "강남역", - placeID: 200, + placeID: "200", placeName: "강남역 맛집", placeCategory: "레스토랑", startTime: "2024-03-24T10:00:00", @@ -59,4 +59,59 @@ final public class DefaultHistoryRepositoryImpl: HistoryInterface { isLastPage: true ) } + + public func startJourney( + input: StartJourneyInput + ) async throws -> JourneyEntity { + // Mock response for testing + return JourneyEntity( + id: 1, + stationId: input.stationId, + stationName: "테스트역", + stationAddress: "테스트 주소", + placeId: input.placeId, + placeName: "테스트 장소", + placeCategory: "카페", + placeAddress: "테스트 장소 주소", + placeLat: 37.5407599328, + placeLng: 126.973658303, + startTime: Date(), + endTime: nil, + trainDepartureTime: input.trainDepartureTime, + totalDurationMinutes: 0, + isInProgress: true, + isSuccess: false, + createdAt: Date(), + startLat: input.lat, + startLng: input.lng + ) + } + + public func endJourney( + journeyId: Int, + isCompleted: Bool + ) async throws -> JourneyEntity { + // Mock response for testing + return JourneyEntity( + id: journeyId, + stationId: 1, + stationName: "테스트역", + stationAddress: "테스트 주소", + placeId: "1", + placeName: "테스트 장소", + placeCategory: "카페", + placeAddress: "테스트 장소 주소", + placeLat: 37.5407599328, + placeLng: 126.973658303, + startTime: Date().addingTimeInterval(-3600), + endTime: Date(), + trainDepartureTime: Date(), + totalDurationMinutes: 60, + isInProgress: false, + isSuccess: isCompleted, + createdAt: Date().addingTimeInterval(-3600), + startLat: nil, + startLng: nil + ) + } } diff --git a/Projects/Domain/DomainInterface/Sources/History/HistoryInterface.swift b/Projects/Domain/DomainInterface/Sources/History/HistoryInterface.swift index 5c10082..b2c9016 100644 --- a/Projects/Domain/DomainInterface/Sources/History/HistoryInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/History/HistoryInterface.swift @@ -15,6 +15,15 @@ public protocol HistoryInterface: Sendable { size: Int, sort: TravelHistorySort ) async throws -> HistoryEntity + + func startJourney( + input: StartJourneyInput + ) async throws -> JourneyEntity + + func endJourney( + journeyId: Int, + isCompleted: Bool + ) async throws -> JourneyEntity } public struct HistoryRepositoryDependency: DependencyKey { diff --git a/Projects/Domain/DomainInterface/Sources/Location/LocationPermissionManager.swift b/Projects/Domain/DomainInterface/Sources/Location/LocationPermissionManager.swift new file mode 100644 index 0000000..27ba28d --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Location/LocationPermissionManager.swift @@ -0,0 +1,341 @@ +// +// LocationPermissionManager.swift +// DomainInterface +// +// Created by Wonji Suh on 3/28/26. +// + +import Foundation +import CoreLocation +import UIKit + +// MARK: - Location Errors +public enum LocationError: Error, LocalizedError, Equatable { + case permissionDenied + case locationUnavailable + case timeout + case networkError + case unknown(String) + + public var errorDescription: String? { + switch self { + case .permissionDenied: + return "위치 권한이 거부되었습니다." + case .locationUnavailable: + return "위치 정보를 가져올 수 없습니다." + case .timeout: + return "위치 요청 시간이 초과되었습니다." + case .networkError: + return "네트워크 오류가 발생했습니다." + case .unknown(let message): + return message + } + } +} + +// MARK: - LocationPermissionManager Protocol +@MainActor +public protocol LocationPermissionManagerProtocol: Sendable { + var authorizationStatus: CLAuthorizationStatus { get } + var currentLocation: CLLocation? { get } + var locationError: String? { get } + + func requestLocationPermission() async -> CLAuthorizationStatus + func requestFullAccuracy() async + func startLocationUpdates() async + func stopLocationUpdates() async + func requestCurrentLocation() async throws -> CLLocation? + func isLocationServicesEnabled() async -> Bool + func openLocationSettings() + + // 콜백 설정 + func setLocationUpdateCallback(_ callback: @escaping @Sendable (CLLocation) -> Void) async + func setLocationErrorCallback(_ callback: @escaping @Sendable (Error) -> Void) async +} + +// MARK: - LocationPermissionManager Implementation +@MainActor +public final class LocationPermissionManager: NSObject, ObservableObject, LocationPermissionManagerProtocol { + + // 싱글톤 인스턴스 + public static let shared = LocationPermissionManager() + @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? + private var locationTimeoutTask: Task? + + // 지속적인 위치 업데이트 콜백 (MainActor 격리) + @MainActor + public var onLocationUpdate: ((CLLocation) -> Void)? + @MainActor + public var onLocationError: ((Error) -> Void)? + + public override init() { + super.init() + setupLocationManager() + } + + private func setupLocationManager() { + locationManager.delegate = self + locationManager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters // 배터리 효율적인 정확도 + locationManager.distanceFilter = 5 // 5미터 이상 이동시 업데이트 (더 민감하게) + authorizationStatus = locationManager.authorizationStatus + + // 앱 시작 시 즉시 위치 요청 + Task { + await startInitialLocationUpdate() + } + } + + private func startInitialLocationUpdate() async { + guard authorizationStatus == .authorizedWhenInUse || authorizationStatus == .authorizedAlways else { + return + } + + // 즉시 위치 업데이트 시작 + locationManager.startUpdatingLocation() + + // 3초 후 중지 (초기 위치만 가져오기 위함) + Task { + try? await Task.sleep(for: .seconds(3)) + if currentLocation != nil { + locationManager.stopUpdatingLocation() + } + } + } + + // MARK: - LocationPermissionManagerProtocol Implementation + + public func setLocationUpdateCallback(_ callback: @escaping @Sendable (CLLocation) -> Void) async { + self.onLocationUpdate = callback + } + + public func setLocationErrorCallback(_ callback: @escaping @Sendable (Error) -> Void) async { + self.onLocationError = callback + } + + // async/await을 사용한 위치 권한 요청 + public func requestLocationPermission() async -> CLAuthorizationStatus { + let isLocationServicesEnabled = await Task.detached { + CLLocationManager.locationServicesEnabled() + }.value + + guard isLocationServicesEnabled else { + locationError = "위치 서비스가 비활성화되어 있습니다. 설정에서 활성화해 주세요." + return .denied + } + + // authorizationStatus는 델리게이트에서 업데이트된 값 사용 + switch authorizationStatus { + case .notDetermined: + return await withCheckedContinuation { continuation in + self.authorizationContinuation = continuation + locationManager.requestWhenInUseAuthorization() + } + case .denied, .restricted: + locationError = "위치 권한이 거부되었습니다. 설정에서 허용해 주세요." + return authorizationStatus + case .authorizedWhenInUse, .authorizedAlways: + // 권한이 있으면 즉시 위치 업데이트 시작 + await startInitialLocationUpdate() + return authorizationStatus + @unknown default: + locationError = "알 수 없는 위치 권한 상태입니다." + return authorizationStatus + } + } + + // iOS 14+ 정확한 위치 권한 요청 + public func requestFullAccuracy() async { + if #available(iOS 14.0, *) { + do { + try await locationManager.requestTemporaryFullAccuracyAuthorization( + withPurposeKey: "TimeSpotLocationAccuracy" + ) + } catch { + locationError = "정확한 위치 권한 요청에 실패했습니다." + } + } + } + + // 위치 업데이트 시작 + public func startLocationUpdates() async { + #if os(iOS) + guard authorizationStatus == .authorizedWhenInUse || authorizationStatus == .authorizedAlways else { + locationError = "위치 권한이 없습니다." + return + } + #else + guard authorizationStatus == .authorizedAlways else { + locationError = "위치 권한이 없습니다." + return + } + #endif + + locationManager.startUpdatingLocation() + } + + // 위치 업데이트 중지 + public func stopLocationUpdates() async { + locationManager.stopUpdatingLocation() + } + + // async/await을 사용한 현재 위치 가져오기 + public func requestCurrentLocation() async throws -> CLLocation? { + #if os(iOS) + guard authorizationStatus == .authorizedWhenInUse || authorizationStatus == .authorizedAlways else { + locationError = "위치 권한이 없습니다." + throw LocationError.permissionDenied + } + #else + guard authorizationStatus == .authorizedAlways else { + locationError = "위치 권한이 없습니다." + throw LocationError.permissionDenied + } + #endif + + if let currentLocation { + return currentLocation + } + + if let cachedLocation = locationManager.location { + self.currentLocation = cachedLocation + return cachedLocation + } + + if locationContinuation != nil { + resumeLocationContinuation(with: .failure(LocationError.locationUnavailable)) + } + + return try await withCheckedThrowingContinuation { continuation in + self.locationContinuation = continuation + self.locationTimeoutTask?.cancel() + self.locationTimeoutTask = Task { [weak self] in + guard let self else { return } + try? await Task.sleep(for: .seconds(10)) // 타임아웃을 10초로 증가 + guard !Task.isCancelled else { return } + self.resumeLocationContinuation(with: .failure(LocationError.timeout)) + } + + // 더 적극적인 위치 요청을 위해 startUpdatingLocation 사용 + locationManager.startUpdatingLocation() + + // 위치를 받으면 자동으로 중지하는 태스크 + Task { + try? await Task.sleep(for: .seconds(8)) + if self.locationContinuation == nil { + // 이미 위치를 받아서 continuation이 nil이면 중지 + await self.stopLocationUpdates() + } + } + } + } + + // 위치 서비스 사용 가능 여부 + public func isLocationServicesEnabled() async -> Bool { + await Task.detached { + CLLocationManager.locationServicesEnabled() + }.value + } + + public func openLocationSettings() { + guard let settingsURL = URL(string: UIApplication.openSettingsURLString), + UIApplication.shared.canOpenURL(settingsURL) else { + return + } + + UIApplication.shared.open(settingsURL) + } + + // 권한 상태 문자열 + public var authorizationStatusString: String { + switch authorizationStatus { + case .notDetermined: + return "권한 미결정" + case .restricted: + return "권한 제한됨" + case .denied: + return "권한 거부됨" + case .authorizedAlways: + return "항상 허용" + case .authorizedWhenInUse: + return "사용 중 허용" + @unknown default: + return "알 수 없음" + } + } + + private func resumeLocationContinuation(with result: Result) { + locationTimeoutTask?.cancel() + locationTimeoutTask = nil + + guard let continuation = locationContinuation else { return } + locationContinuation = nil + + switch result { + case .success(let location): + continuation.resume(returning: location) + case .failure(let error): + continuation.resume(throwing: error) + } + } +} + +// MARK: - CLLocationManagerDelegate +extension LocationPermissionManager: CLLocationManagerDelegate { + + nonisolated public func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + guard let location = locations.last else { return } + + Task { @MainActor in + self.currentLocation = location + self.locationError = nil + + // 지속적인 위치 업데이트 콜백 호출 + self.onLocationUpdate?(location) + + // continuation이 있으면 결과 반환 (일회성 요청용) + let hadContinuation = self.locationContinuation != nil + self.resumeLocationContinuation(with: .success(location)) + + // 일회성 요청이었다면 업데이트 중지 + if hadContinuation { + manager.stopUpdatingLocation() + } + } + } + + nonisolated public func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + Task { @MainActor in + self.locationError = "위치 업데이트 실패: \(error.localizedDescription)" + + // 지속적인 위치 업데이트 에러 콜백 호출 + self.onLocationError?(error) + + // continuation이 있으면 에러 반환 (일회성 요청용) + self.resumeLocationContinuation(with: .failure(error)) + } + } + + nonisolated public func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { + Task { @MainActor in + self.authorizationStatus = status + self.locationError = nil + + // 권한이 허용된 경우 즉시 초기 위치 업데이트 시작 + if status == .authorizedWhenInUse || status == .authorizedAlways { + await self.startInitialLocationUpdate() + } + + // continuation이 있으면 권한 상태 반환 + if let continuation = self.authorizationContinuation { + self.authorizationContinuation = nil + continuation.resume(returning: status) + } + } + } +} diff --git a/Projects/Domain/DomainInterface/Sources/Place/DefaultPlaceRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Place/DefaultPlaceRepositoryImpl.swift new file mode 100644 index 0000000..8193a2d --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Place/DefaultPlaceRepositoryImpl.swift @@ -0,0 +1,29 @@ +// +// DefaultPlaceRepositoryImpl.swift +// DomainInterface +// +// Created by Wonji Suh on 3/27/26. +// + +import Foundation +import Entity + +public final class DefaultPlaceRepositoryImpl: PlaceInterface { + public init() {} + + public func fetchPlaces(_ input: PlaceSearchInput) async throws -> PlaceSearchPageEntity { + throw NSError( + domain: "PlaceRepository", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "PlaceRepository is not configured."] + ) + } + + public func detailPlaces(_ input: PlaceDetailInput) async throws -> PlaceDetailEntity { + throw NSError( + domain: "PlaceRepository", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "PlaceRepository is not configured."] + ) + } +} diff --git a/Projects/Domain/DomainInterface/Sources/Place/PlaceInterface.swift b/Projects/Domain/DomainInterface/Sources/Place/PlaceInterface.swift new file mode 100644 index 0000000..90c0bb8 --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Place/PlaceInterface.swift @@ -0,0 +1,38 @@ +// +// PlaceInterface.swift +// DomainInterface +// +// Created by Wonji Suh on 3/27/26. +// + +import Entity + +import WeaveDI +import ComposableArchitecture + +public protocol PlaceInterface: Sendable { + func fetchPlaces(_ input: PlaceSearchInput) async throws -> PlaceSearchPageEntity + func detailPlaces(_ input: PlaceDetailInput) async throws -> PlaceDetailEntity +} + + +/// Profile Repository의 DependencyKey 구조체 +public struct PlaceRepositoryDependency: DependencyKey { + public static var liveValue: PlaceInterface { + UnifiedDI.resolve(PlaceInterface.self) ?? DefaultPlaceRepositoryImpl() + } + + public static var testValue: PlaceInterface { + UnifiedDI.resolve(PlaceInterface.self) ?? DefaultPlaceRepositoryImpl() + } + + public static var previewValue: PlaceInterface = liveValue +} + +/// DependencyValues extension으로 간편한 접근 제공 +public extension DependencyValues { + var placeRepository: PlaceInterface { + get { self[PlaceRepositoryDependency.self] } + set { self[PlaceRepositoryDependency.self] = newValue } + } +} diff --git a/Projects/Domain/DomainInterface/Sources/Profile/DefaultProfileRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Profile/DefaultProfileRepositoryImpl.swift index ad79a8e..5271fae 100644 --- a/Projects/Domain/DomainInterface/Sources/Profile/DefaultProfileRepositoryImpl.swift +++ b/Projects/Domain/DomainInterface/Sources/Profile/DefaultProfileRepositoryImpl.swift @@ -39,4 +39,30 @@ final public class DefaultProfileRepositoryImpl: ProfileInterface { mapURLScheme: nil ) } + + public func fetchNotificationSettings() async throws -> NotificationEntity { + NotificationEntity( + settings: [ + .init(option: .departureTime, isEnabled: true, isEditable: false), + .init(option: .fiveMinutesBefore, isEnabled: false, isEditable: true), + .init(option: .tenMinutesBefore, isEnabled: false, isEditable: true), + .init(option: .fifteenMinutesBefore, isEnabled: false, isEditable: true) + ], + updatedAt: "2024-01-15T10:30:00" + ) + } + + public func editNotificationSettings( + notificationSettings: [NotificationOption] + ) async throws -> NotificationEntity { + NotificationEntity( + settings: [ + .init(option: .departureTime, isEnabled: true, isEditable: false), + .init(option: .fiveMinutesBefore, isEnabled: notificationSettings.contains(.fiveMinutesBefore), isEditable: true), + .init(option: .tenMinutesBefore, isEnabled: notificationSettings.contains(.tenMinutesBefore), isEditable: true), + .init(option: .fifteenMinutesBefore, isEnabled: notificationSettings.contains(.fifteenMinutesBefore), isEditable: true) + ], + updatedAt: "2024-01-15T10:30:00" + ) + } } diff --git a/Projects/Domain/DomainInterface/Sources/Profile/ProfileInterface.swift b/Projects/Domain/DomainInterface/Sources/Profile/ProfileInterface.swift index 277c622..db1f2a6 100644 --- a/Projects/Domain/DomainInterface/Sources/Profile/ProfileInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Profile/ProfileInterface.swift @@ -14,6 +14,10 @@ public protocol ProfileInterface: Sendable { func editUser( mapType: ExternalMapType ) async throws -> LoginEntity + func fetchNotificationSettings() async throws -> NotificationEntity + func editNotificationSettings( + notificationSettings: [NotificationOption] + ) async throws -> NotificationEntity } diff --git a/Projects/Domain/DomainInterface/Sources/Station/DefaultStationRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Station/DefaultStationRepositoryImpl.swift index da52692..af32bb6 100644 --- a/Projects/Domain/DomainInterface/Sources/Station/DefaultStationRepositoryImpl.swift +++ b/Projects/Domain/DomainInterface/Sources/Station/DefaultStationRepositoryImpl.swift @@ -12,14 +12,14 @@ final public class DefaultStationRepositoryImpl: StationInterface { public init() {} public func fetchStations( - lat: Double, - lng: Double, + userLat: Double, + userLon: Double, page: Int, size: Int ) async throws -> StationListEntity { let favoriteStations: [StationSummaryEntity] = [ - .init(stationID: 1, name: "서울", lines: ["경부선"]), - .init(stationID: 4, name: "동대구", lines: ["경부선"]) + .init(favoriteID: 101, stationID: 1, name: "서울", lines: ["경부선"]), + .init(favoriteID: 102, stationID: 4, name: "동대구", lines: ["경부선"]) ] let nearbyStations: [StationSummaryEntity] = [ @@ -59,7 +59,7 @@ final public class DefaultStationRepositoryImpl: StationInterface { } public func deleteFavoriteStation( - stationID: Int + favoriteID: Int ) async throws -> FavoriteStationMutationEntity { FavoriteStationMutationEntity( code: 200, diff --git a/Projects/Domain/DomainInterface/Sources/Station/StationInterface.swift b/Projects/Domain/DomainInterface/Sources/Station/StationInterface.swift index 0fb1e57..f4feadb 100644 --- a/Projects/Domain/DomainInterface/Sources/Station/StationInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Station/StationInterface.swift @@ -11,8 +11,8 @@ import WeaveDI public protocol StationInterface: Sendable { func fetchStations( - lat: Double, - lng: Double, + userLat: Double, + userLon: Double, page: Int, size: Int ) async throws -> StationListEntity @@ -22,7 +22,7 @@ public protocol StationInterface: Sendable { ) async throws -> FavoriteStationMutationEntity func deleteFavoriteStation( - stationID: Int + favoriteID: Int ) async throws -> FavoriteStationMutationEntity } diff --git a/Projects/Domain/Entity/Sources/Camera/CameraControlResult.swift b/Projects/Domain/Entity/Sources/Camera/CameraControlResult.swift new file mode 100644 index 0000000..ace2cd7 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Camera/CameraControlResult.swift @@ -0,0 +1,30 @@ +// +// CameraControlResult.swift +// Entity +// +// Created by Wonji Suh on 3/28/26. +// + +import Foundation + +public struct CameraControlResult: Equatable { + public let shouldUpdateTrigger: Bool + public let newTrigger: Int + public let shouldClearSpot: Bool + public let shouldResetFlag: Bool + public let shouldDismissCard: Bool + + public init( + shouldUpdateTrigger: Bool = false, + newTrigger: Int = 0, + shouldClearSpot: Bool = false, + shouldResetFlag: Bool = false, + shouldDismissCard: Bool = false + ) { + self.shouldUpdateTrigger = shouldUpdateTrigger + self.newTrigger = newTrigger + self.shouldClearSpot = shouldClearSpot + self.shouldResetFlag = shouldResetFlag + self.shouldDismissCard = shouldDismissCard + } +} \ No newline at end of file diff --git a/Projects/Domain/Entity/Sources/Error/PlaceError.swift b/Projects/Domain/Entity/Sources/Error/PlaceError.swift new file mode 100644 index 0000000..242be89 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Error/PlaceError.swift @@ -0,0 +1,82 @@ +// +// PlaceError.swift +// Entity +// +// Created by Codex on 3/29/26. +// + +import Foundation + +public enum PlaceError: Error, LocalizedError, Equatable, Hashable { + case placeNotFound + case placeAccessDenied + case placeDataCorrupted + case unknownError(String) + case userCancelled + case missingRequiredField(String) + + public var errorDescription: String? { + switch self { + case .placeNotFound: + return "장소를 찾을 수 없습니다" + case .placeAccessDenied: + return "장소 접근이 거부되었습니다" + case .placeDataCorrupted: + return "장소 데이터가 손상되었습니다" + case .unknownError(let message): + return "알 수 없는 오류가 발생했습니다: \(message)" + case .userCancelled: + return "사용자가 취소했습니다" + case .missingRequiredField(let field): + return "\(field)은(는) 필수 입력 항목입니다" + } + } + + public var failureReason: String? { + switch self { + case .placeNotFound: + return "장소 조회 실패" + case .placeAccessDenied: + return "장소 접근 권한 부족" + default: + return nil + } + } + + public var recoverySuggestion: String? { + switch self { + case .placeNotFound: + return "장소를 다시 선택하거나 잠시 후 다시 시도해주세요" + case .placeAccessDenied: + return "권한 상태를 확인하거나 다시 로그인해주세요" + default: + return "문제가 지속되면 고객센터에 문의해주세요" + } + } +} + +public extension PlaceError { + static func from(_ error: Error) -> PlaceError { + if let placeError = error as? PlaceError { + return placeError + } + return .unknownError(error.localizedDescription) + } + + var shouldPresentAuth: Bool { + switch self { + case .placeAccessDenied: + 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/Error/ProfileError.swift b/Projects/Domain/Entity/Sources/Error/ProfileError.swift index e712e6f..c25ced3 100644 --- a/Projects/Domain/Entity/Sources/Error/ProfileError.swift +++ b/Projects/Domain/Entity/Sources/Error/ProfileError.swift @@ -2,7 +2,7 @@ // ProfileError.swift // Entity // -// Created by Claude on 1/4/26. +// Created by Wonji Suh on 1/4/26. // import Foundation diff --git a/Projects/Domain/Entity/Sources/Explore/ExploreCategory.swift b/Projects/Domain/Entity/Sources/Explore/ExploreCategory.swift index b683cb9..f9e937c 100644 --- a/Projects/Domain/Entity/Sources/Explore/ExploreCategory.swift +++ b/Projects/Domain/Entity/Sources/Explore/ExploreCategory.swift @@ -12,6 +12,7 @@ public enum ExploreCategory: String, CaseIterable, Equatable, Sendable { case cafe case restaurant case activity + case shopping case etc public var title: String { @@ -24,8 +25,10 @@ public enum ExploreCategory: String, CaseIterable, Equatable, Sendable { return "음식점" case .activity: return "액티비티" - case .etc: - return "기타" + case .shopping: + return "쇼핑" + case .etc: + return "기타" } } } diff --git a/Projects/Domain/Entity/Sources/Explore/ExploreListSort.swift b/Projects/Domain/Entity/Sources/Explore/ExploreListSort.swift new file mode 100644 index 0000000..2b8872b --- /dev/null +++ b/Projects/Domain/Entity/Sources/Explore/ExploreListSort.swift @@ -0,0 +1,22 @@ +// +// ExploreListSort.swift +// Entity +// +// Created by Wonji Suh on 3/30/26. +// + +import Foundation + +public enum ExploreListSort: String, CaseIterable, Equatable { + case stationNearest = "distanceFromStation,ASC" + case userNearest = "distanceFromUser,ASC" + + public var title: String { + switch self { + case .stationNearest: + return "역에서 가까운 순" + case .userNearest: + return "현재 위치로부터 가까운 순" + } + } +} diff --git a/Projects/Domain/Entity/Sources/Explore/ExploreMapSpot.swift b/Projects/Domain/Entity/Sources/Explore/ExploreMapSpot.swift new file mode 100644 index 0000000..41ef98d --- /dev/null +++ b/Projects/Domain/Entity/Sources/Explore/ExploreMapSpot.swift @@ -0,0 +1,114 @@ +// +// ExploreMapSpot.swift +// Entity +// +// Created by wonji suh on 2026-03-27. +// + +import Foundation +import CoreLocation + +public struct ExploreMapSpot: Identifiable { + public let id: String + public let name: String + public let category: ExploreCategory + public let coordinate: CLLocationCoordinate2D + public let hasDetail: Bool + public let imageURL: String? + public let badgeText: String + public let subtitle: String + public let statusText: String + public let closingText: String + public let distanceText: String + public let walkTimeText: String + public let address: String + public let visitable: Bool + + public init( + id: String, + name: String, + category: ExploreCategory, + coordinate: CLLocationCoordinate2D, + hasDetail: Bool = false, + imageURL: String? = nil, + badgeText: String, + subtitle: String, + statusText: String, + closingText: String, + distanceText: String, + walkTimeText: String, + address: String, + visitable: Bool = true + ) { + self.id = id + self.name = name + self.category = category + self.coordinate = coordinate + self.hasDetail = hasDetail + self.imageURL = imageURL + self.badgeText = badgeText + self.subtitle = subtitle + self.statusText = statusText + self.closingText = closingText + self.distanceText = distanceText + self.walkTimeText = walkTimeText + self.address = address + self.visitable = visitable + } +} + +extension ExploreMapSpot: Equatable { + public static func == (lhs: ExploreMapSpot, rhs: ExploreMapSpot) -> Bool { + lhs.id == rhs.id + && lhs.name == rhs.name + && lhs.category == rhs.category + && lhs.coordinate.latitude == rhs.coordinate.latitude + && lhs.coordinate.longitude == rhs.coordinate.longitude + && lhs.hasDetail == rhs.hasDetail + && lhs.imageURL == rhs.imageURL + && lhs.badgeText == rhs.badgeText + && lhs.subtitle == rhs.subtitle + && lhs.statusText == rhs.statusText + && lhs.closingText == rhs.closingText + && lhs.distanceText == rhs.distanceText + && lhs.walkTimeText == rhs.walkTimeText + && lhs.address == rhs.address + && lhs.visitable == rhs.visitable + } +} + +extension ExploreMapSpot: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + hasher.combine(name) + hasher.combine(category) + hasher.combine(coordinate.latitude) + hasher.combine(coordinate.longitude) + hasher.combine(hasDetail) + hasher.combine(imageURL) + hasher.combine(badgeText) + hasher.combine(subtitle) + hasher.combine(statusText) + hasher.combine(closingText) + hasher.combine(distanceText) + hasher.combine(walkTimeText) + hasher.combine(address) + hasher.combine(visitable) + } +} + +public struct ExploreSpotPageEntity: Equatable { + public let spots: [ExploreMapSpot] + public let currentPage: Int + public let hasNextPage: Bool + + public init( + spots: [ExploreMapSpot], + currentPage: Int, + hasNextPage: Bool + ) { + self.spots = spots + self.currentPage = currentPage + self.hasNextPage = hasNextPage + } +} diff --git a/Projects/Domain/Entity/Sources/Explore/FetchPlacesRequest.swift b/Projects/Domain/Entity/Sources/Explore/FetchPlacesRequest.swift new file mode 100644 index 0000000..29cdfcf --- /dev/null +++ b/Projects/Domain/Entity/Sources/Explore/FetchPlacesRequest.swift @@ -0,0 +1,36 @@ +// +// FetchPlacesRequest.swift +// Entity +// +// Created by Wonji Suh on 3/30/26. +// + +import Foundation + +public struct FetchPlacesRequest: Equatable, Sendable { + public let page: Int + public let keyword: String + public let category: ExploreCategory? + public let markerLat: Double? + public let markerLon: Double? + public let append: Bool + public let usedCurrentLocation: Bool + + public init( + page: Int, + keyword: String, + category: ExploreCategory?, + markerLat: Double?, + markerLon: Double?, + append: Bool, + usedCurrentLocation: Bool + ) { + self.page = page + self.keyword = keyword + self.category = category + self.markerLat = markerLat + self.markerLon = markerLon + self.append = append + self.usedCurrentLocation = usedCurrentLocation + } +} diff --git a/Projects/Domain/Entity/Sources/History/HistoryEntity.swift b/Projects/Domain/Entity/Sources/History/HistoryEntity.swift index 7d814e0..43a4426 100644 --- a/Projects/Domain/Entity/Sources/History/HistoryEntity.swift +++ b/Projects/Domain/Entity/Sources/History/HistoryEntity.swift @@ -49,7 +49,7 @@ public struct HistoryItemEntity: Equatable, Hashable, Identifiable { public let id: Int public let stationID: Int public let stationName: String - public let placeID: Int + public let placeID: String public let placeName: String public let placeCategory: String public let startTime: String @@ -64,7 +64,7 @@ public struct HistoryItemEntity: Equatable, Hashable, Identifiable { id: Int, stationID: Int, stationName: String, - placeID: Int, + placeID: String, placeName: String, placeCategory: String, startTime: String, @@ -90,3 +90,5 @@ public struct HistoryItemEntity: Equatable, Hashable, Identifiable { self.createdAt = createdAt } } + + diff --git a/Projects/Domain/Entity/Sources/History/JourneyEntity.swift b/Projects/Domain/Entity/Sources/History/JourneyEntity.swift new file mode 100644 index 0000000..5a720c6 --- /dev/null +++ b/Projects/Domain/Entity/Sources/History/JourneyEntity.swift @@ -0,0 +1,73 @@ +// +// JourneyEntity.swift +// Entity +// +// Created by Wonji Suh on 4/1/26. +// + +import Foundation + +public struct JourneyEntity: Equatable, Hashable, Identifiable { + public let id: Int // visitingHistoryId + public let stationId: Int + public let stationName: String + public let stationAddress: String + public let placeId: String + public let placeName: String + public let placeCategory: String + public let placeAddress: String + public let placeLat: Double + public let placeLng: Double + public let startTime: Date + public let endTime: Date? + public let trainDepartureTime: Date + public let totalDurationMinutes: Int + public let isInProgress: Bool + public let isSuccess: Bool + public let createdAt: Date + public let startLat: Double? + public let startLng: Double? + + public init( + id: Int, + stationId: Int, + stationName: String, + stationAddress: String, + placeId: String, + placeName: String, + placeCategory: String, + placeAddress: String, + placeLat: Double, + placeLng: Double, + startTime: Date, + endTime: Date? = nil, + trainDepartureTime: Date, + totalDurationMinutes: Int, + isInProgress: Bool, + isSuccess: Bool, + createdAt: Date, + startLat: Double? = nil, + startLng: Double? = nil + ) { + self.id = id + self.stationId = stationId + self.stationName = stationName + self.stationAddress = stationAddress + self.placeId = placeId + self.placeName = placeName + self.placeCategory = placeCategory + self.placeAddress = placeAddress + self.placeLat = placeLat + self.placeLng = placeLng + self.startTime = startTime + self.endTime = endTime + self.trainDepartureTime = trainDepartureTime + self.totalDurationMinutes = totalDurationMinutes + self.isInProgress = isInProgress + self.isSuccess = isSuccess + self.createdAt = createdAt + self.startLat = startLat + self.startLng = startLng + } +} + diff --git a/Projects/Domain/Entity/Sources/History/JourneyInput.swift b/Projects/Domain/Entity/Sources/History/JourneyInput.swift new file mode 100644 index 0000000..e9a6508 --- /dev/null +++ b/Projects/Domain/Entity/Sources/History/JourneyInput.swift @@ -0,0 +1,32 @@ +// +// JourneyInput.swift +// Entity +// +// Created by Wonji Suh on 4/1/26. +// + +import Foundation + +// MARK: - Journey Input + +public struct StartJourneyInput: Equatable, Hashable { + public let stationId: Int + public let placeId: String + public let trainDepartureTime: Date + public let lat: Double + public let lng: Double + + public init( + stationId: Int, + placeId: String, + trainDepartureTime: Date, + lat: Double, + lng: Double + ) { + self.stationId = stationId + self.placeId = placeId + self.trainDepartureTime = trainDepartureTime + self.lat = lat + self.lng = lng + } +} diff --git a/Projects/Domain/Entity/Sources/Notification/RegisterNotificationEntity.swift b/Projects/Domain/Entity/Sources/Notification/RegisterNotificationEntity.swift new file mode 100644 index 0000000..a02744b --- /dev/null +++ b/Projects/Domain/Entity/Sources/Notification/RegisterNotificationEntity.swift @@ -0,0 +1,24 @@ +// +// RegisterNotificationEntity.swift +// Entity +// +// Created by Wonji Suh on 3/30/26. +// + +import Foundation + +public struct RegisterNotificationEntity: Equatable { + public let userId: String? + public let deviceToken: String + public let isActive: Bool + + public init( + userId: String?, + deviceToken: String, + isActive: Bool + ) { + self.userId = userId + self.deviceToken = deviceToken + self.isActive = isActive + } +} diff --git a/Projects/Domain/Entity/Sources/OAuth/UserSession.swift b/Projects/Domain/Entity/Sources/OAuth/UserSession.swift index 7bf4351..93961af 100644 --- a/Projects/Domain/Entity/Sources/OAuth/UserSession.swift +++ b/Projects/Domain/Entity/Sources/OAuth/UserSession.swift @@ -13,8 +13,30 @@ public struct UserSession: Equatable, Hashable { public var provider: SocialType public var authCode: String public var mapType: ExternalMapType + public var isGuest: Bool public var travelID: String public var travelStationName: String + public var travelStationLat: Double? + public var travelStationLng: Double? + public var remainingMinutes: Int + public var departureTime: Date? + public var selectedExploreSpotID: String + public var selectedExplorePlaceID: String + public var explorePlacesFetchedAt: Date? + + // MARK: - Route 관련 위치 정보 + public var routeStartLat: Double? // 현재 위치 (출발지) 위도 + public var routeStartLng: Double? // 현재 위치 (출발지) 경도 + public var routeDestinationLat: Double? // 목적지 위도 + public var routeDestinationLng: Double? // 목적지 경도 + public var routeDestinationName: String // 목적지 이름 + + // MARK: - 경로 정보 + public var routeDistance: Int // 경로 거리 (미터) + public var routeDuration: Int // 경로 소요시간 (분) + public var nearestStationName: String // 가장 가까운 역 이름 + public var nearestStationLat: Double? // 가장 가까운 역 위도 + public var nearestStationLng: Double? // 가장 가까운 역 경도 public init( name: String = "", @@ -22,16 +44,52 @@ public struct UserSession: Equatable, Hashable { provider: SocialType = .apple, authCode: String = "", mapType: ExternalMapType = .appleMap, + isGuest: Bool = false, travelID: String = "", - travelStationName: String = "" + travelStationName: String = "", + travelStationLat: Double? = nil, + travelStationLng: Double? = nil, + remainingMinutes: Int = 0, + departureTime: Date? = nil, + selectedExploreSpotID: String = "", + selectedExplorePlaceID: String = "", + explorePlacesFetchedAt: Date? = nil, + routeStartLat: Double? = nil, + routeStartLng: Double? = nil, + routeDestinationLat: Double? = nil, + routeDestinationLng: Double? = nil, + routeDestinationName: String = "", + routeDistance: Int = 0, + routeDuration: Int = 0, + nearestStationName: String = "", + nearestStationLat: Double? = nil, + nearestStationLng: Double? = nil ) { self.name = name self.email = email self.provider = provider self.authCode = authCode self.mapType = mapType + self.isGuest = isGuest self.travelID = travelID self.travelStationName = travelStationName + self.travelStationLat = travelStationLat + self.travelStationLng = travelStationLng + self.remainingMinutes = remainingMinutes + self.departureTime = departureTime + self.selectedExploreSpotID = selectedExploreSpotID + self.selectedExplorePlaceID = selectedExplorePlaceID + self.explorePlacesFetchedAt = explorePlacesFetchedAt + self.routeStartLat = routeStartLat + self.routeStartLng = routeStartLng + self.routeDestinationLat = routeDestinationLat + self.routeDestinationLng = routeDestinationLng + self.routeDestinationName = routeDestinationName + self.routeDistance = routeDistance + self.routeDuration = routeDuration + self.nearestStationName = nearestStationName + self.nearestStationLat = nearestStationLat + self.nearestStationLng = nearestStationLng } } diff --git a/Projects/Domain/Entity/Sources/Place/PlaceDetailEntity.swift b/Projects/Domain/Entity/Sources/Place/PlaceDetailEntity.swift new file mode 100644 index 0000000..7986bb3 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Place/PlaceDetailEntity.swift @@ -0,0 +1,78 @@ +// +// PlaceDetailEntity.swift +// Entity +// +// Created by Wonji Suh on 3/28/26. +// + +import Foundation + +public struct PlaceDetailEntity: Equatable, Hashable { + public let placeId: String + public let name: String + public let category: String + public let address: String + public let latitude: Double + public let longitude: Double + public let distanceFromStation: Int + public let walkTimeFromStation: Int + public let stayableMinutes: Int + public let visitable: Bool + public let stationLatitude: Double + public let stationLongitude: Double + public let leaveTime: String + public let images: [String] + public let useTime: String? + public let spendTime: String? + public let useFee: String? + public let discountInfo: String? + public let accomCountCulture: String? + public let parkingCulture: String? + public let placeType: String + + public init( + placeId: String, + name: String, + category: String, + address: String, + latitude: Double, + longitude: Double, + distanceFromStation: Int, + walkTimeFromStation: Int, + stayableMinutes: Int, + visitable: Bool, + stationLatitude: Double, + stationLongitude: Double, + leaveTime: String, + images: [String], + useTime: String? = nil, + spendTime: String? = nil, + useFee: String? = nil, + discountInfo: String? = nil, + accomCountCulture: String? = nil, + parkingCulture: String? = nil, + placeType: String + ) { + self.placeId = placeId + self.name = name + self.category = category + self.address = address + self.latitude = latitude + self.longitude = longitude + self.distanceFromStation = distanceFromStation + self.walkTimeFromStation = walkTimeFromStation + self.stayableMinutes = stayableMinutes + self.visitable = visitable + self.stationLatitude = stationLatitude + self.stationLongitude = stationLongitude + self.leaveTime = leaveTime + self.images = images + self.useTime = useTime + self.spendTime = spendTime + self.useFee = useFee + self.discountInfo = discountInfo + self.accomCountCulture = accomCountCulture + self.parkingCulture = parkingCulture + self.placeType = placeType + } +} diff --git a/Projects/Domain/Entity/Sources/Place/PlaceDetailInput.swift b/Projects/Domain/Entity/Sources/Place/PlaceDetailInput.swift new file mode 100644 index 0000000..15c693d --- /dev/null +++ b/Projects/Domain/Entity/Sources/Place/PlaceDetailInput.swift @@ -0,0 +1,30 @@ +// +// PlaceDetailInput.swift +// Entity +// +// Created by Wonji Suh on 3/29/26. +// + +import Foundation + +public struct PlaceDetailInput: Equatable { + public let placeId: Int + public let stationId: Int + public let userLat: Double + public let userLon: Double + public let remainingMinutes: Int + + public init( + placeId: Int, + stationId: Int, + userLat: Double, + userLon: Double, + remainingMinutes: Int + ) { + self.placeId = placeId + self.stationId = stationId + self.userLat = userLat + self.userLon = userLon + self.remainingMinutes = remainingMinutes + } +} diff --git a/Projects/Domain/Entity/Sources/Place/PlaceEntity.swift b/Projects/Domain/Entity/Sources/Place/PlaceEntity.swift new file mode 100644 index 0000000..7fad9bd --- /dev/null +++ b/Projects/Domain/Entity/Sources/Place/PlaceEntity.swift @@ -0,0 +1,131 @@ +// +// PlaceEntity.swift +// Entity +// +// Created by Wonji Suh on 3/27/26. +// + +import Foundation + +public struct PlaceEntity: Equatable { + public let placeId: Int + public let name: String + public let category: ExploreCategory + public let address: String + public let lat, lon: Double + public let imageURL: String? + public let stayableMinutes: Int + public let isOpen: Bool + public let closingTime: String? + public let distanceFromUser: Double? + public let distanceFromStation: Double? + public let walkTimeFromStation: Int? + public let visitable: Bool + + public init( + placeId: Int, + name: String = "", + category: ExploreCategory, + lat: Double, + lon: Double, + address: String = "", + imageURL: String? = nil, + stayableMinutes: Int = 0, + isOpen: Bool = false, + closingTime: String? = nil, + distanceFromUser: Double? = nil, + distanceFromStation: Double? = nil, + walkTimeFromStation: Int? = nil, + visitable: Bool = true + ) { + self.placeId = placeId + self.name = name + self.category = category + self.lat = lat + self.lon = lon + self.address = address + self.imageURL = imageURL + self.stayableMinutes = stayableMinutes + self.isOpen = isOpen + self.closingTime = closingTime + self.distanceFromUser = distanceFromUser + self.distanceFromStation = distanceFromStation + self.walkTimeFromStation = walkTimeFromStation + self.visitable = visitable + } +} + +public struct PlaceSearchPageEntity: Equatable { + public let pageable: PlacePageableEntity + public let isLastPage: Bool + public let numberOfElements: Int + public let isFirstPage: Bool + public let size: Int + public let content: [PlaceEntity] + public let page: Int + public let sort: PlaceSortEntity + public let isEmpty: Bool + + public init( + pageable: PlacePageableEntity, + isLastPage: Bool, + numberOfElements: Int, + isFirstPage: Bool, + size: Int, + content: [PlaceEntity], + page: Int, + sort: PlaceSortEntity, + isEmpty: Bool + ) { + self.pageable = pageable + self.isLastPage = isLastPage + self.numberOfElements = numberOfElements + self.isFirstPage = isFirstPage + self.size = size + self.content = content + self.page = page + self.sort = sort + self.isEmpty = isEmpty + } +} + +public struct PlacePageableEntity: Equatable { + public let isUnpaged: Bool + public let isPaged: Bool + public let pageNumber: Int + public let pageSize: Int + public let offset: Int + public let sort: PlaceSortEntity + + public init( + isUnpaged: Bool, + isPaged: Bool, + pageNumber: Int, + pageSize: Int, + offset: Int, + sort: PlaceSortEntity + ) { + self.isUnpaged = isUnpaged + self.isPaged = isPaged + self.pageNumber = pageNumber + self.pageSize = pageSize + self.offset = offset + self.sort = sort + } +} + +public struct PlaceSortEntity: Equatable { + public let isUnsorted: Bool + public let isSorted: Bool + public let isEmpty: Bool + + public init( + isUnsorted: Bool, + isSorted: Bool, + isEmpty: Bool + ) { + self.isUnsorted = isUnsorted + self.isSorted = isSorted + self.isEmpty = isEmpty + } +} diff --git a/Projects/Domain/Entity/Sources/Place/PlaceInput.swift b/Projects/Domain/Entity/Sources/Place/PlaceInput.swift new file mode 100644 index 0000000..d3b39bd --- /dev/null +++ b/Projects/Domain/Entity/Sources/Place/PlaceInput.swift @@ -0,0 +1,34 @@ +// +// PlaceInput.swift +// Service +// +// Created by Wonji Suh on 3/27/26. +// + +import Foundation + +public struct PlaceInput { + public let userLat: Double + public let userLon: Double + public let mapLat: Double + public let mapLon: Double + public let stationId: Int + public let remainingMinutes: Int + + public init( + userLat: Double, + userLon: Double, + mapLat: Double, + mapLon: Double, + stationId: Int, + remainingMinutes: Int + ) { + self.userLat = userLat + self.userLon = userLon + self.mapLat = mapLat + self.mapLon = mapLon + self.stationId = stationId + self.remainingMinutes = remainingMinutes + } +} + diff --git a/Projects/Domain/Entity/Sources/Place/PlaceSearchInput.swift b/Projects/Domain/Entity/Sources/Place/PlaceSearchInput.swift new file mode 100644 index 0000000..7c42830 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Place/PlaceSearchInput.swift @@ -0,0 +1,49 @@ +// +// PlaceSearchInput.swift +// Entity +// +// Created by Wonji Suh on 3/29/26. +// + +import Foundation + + +public struct PlaceSearchInput: Equatable { + public let userLat: Double + public let userLon: Double + public let stationId: Int + public let remainingMinutes: Int + public let keyword: String? + public let category: String? + public let mapLat: Double? + public let mapLon: Double? + public let page: Int + public let size: Int + public let sort: String + + public init( + userLat: Double, + userLon: Double, + stationId: Int, + remainingMinutes: Int, + keyword: String? = nil, + category: String? = nil, + mapLat: Double? = nil, + mapLon: Double? = nil, + page: Int = 1, + size: Int = 50, + sort: String = "distanceFromStation,ASC" + ) { + self.userLat = userLat + self.userLon = userLon + self.stationId = stationId + self.remainingMinutes = remainingMinutes + self.keyword = keyword + self.category = category + self.mapLat = mapLat + self.mapLon = mapLon + self.page = page + self.size = size + self.sort = sort + } +} diff --git a/Projects/Domain/Entity/Sources/Profile/NotificationEntity.swift b/Projects/Domain/Entity/Sources/Profile/NotificationEntity.swift new file mode 100644 index 0000000..ed58f89 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/NotificationEntity.swift @@ -0,0 +1,39 @@ +// +// NotificationEntity.swift +// Entity +// +// Created by Wonji Suh on 3/27/26. +// + +import Foundation + +public struct NotificationEntity: Equatable, Hashable { + public let settings: [NotificationSettingEntity] + public let updatedAt: String + + public init( + settings: [NotificationSettingEntity], + updatedAt: String + ) { + self.settings = settings + self.updatedAt = updatedAt + } +} + +public struct NotificationSettingEntity: Equatable, Hashable, Identifiable { + public let option: NotificationOption + public let isEnabled: Bool + public let isEditable: Bool + + public var id: NotificationOption { option } + + public init( + option: NotificationOption, + isEnabled: Bool, + isEditable: Bool + ) { + self.option = option + self.isEnabled = isEnabled + self.isEditable = isEditable + } +} diff --git a/Projects/Domain/Entity/Sources/Profile/NotificationOption.swift b/Projects/Domain/Entity/Sources/Profile/NotificationOption.swift index 3c2bd7e..bf4ec88 100644 --- a/Projects/Domain/Entity/Sources/Profile/NotificationOption.swift +++ b/Projects/Domain/Entity/Sources/Profile/NotificationOption.swift @@ -30,4 +30,36 @@ public enum NotificationOption: String, CaseIterable, Equatable, Hashable, Ident return "출발 15분 전" } } + + public var apiType: String { + switch self { + case .none: + return "NONE" + case .departureTime: + return "DEPARTURE_TIME" + case .fiveMinutesBefore: + return "DEPARTURE_5_MIN_BEFORE" + case .tenMinutesBefore: + return "DEPARTURE_10_MIN_BEFORE" + case .fifteenMinutesBefore: + return "DEPARTURE_15_MIN_BEFORE" + } + } + + public init?(apiType: String) { + switch apiType.uppercased() { + case "DEPARTURE_TIME": + self = .departureTime + case "DEPARTURE_5_MIN_BEFORE": + self = .fiveMinutesBefore + case "DEPARTURE_10_MIN_BEFORE": + self = .tenMinutesBefore + case "DEPARTURE_15_MIN_BEFORE": + self = .fifteenMinutesBefore + case "NONE": + self = .none + default: + return nil + } + } } diff --git a/Projects/Domain/Entity/Sources/Station/FavoriteStationMutationEntity.swift b/Projects/Domain/Entity/Sources/Station/FavoriteStationMutationEntity.swift index f74d826..9589979 100644 --- a/Projects/Domain/Entity/Sources/Station/FavoriteStationMutationEntity.swift +++ b/Projects/Domain/Entity/Sources/Station/FavoriteStationMutationEntity.swift @@ -19,3 +19,17 @@ public struct FavoriteStationMutationEntity: Equatable, Hashable { self.message = message } } + +public struct StationFavoriteError: Error, Equatable, LocalizedError { + public let code: Int + public let message: String + + public init(code: Int, message: String) { + self.code = code + self.message = message + } + + public var errorDescription: String? { + message + } +} diff --git a/Projects/Domain/Entity/Sources/Station/StationListEntity.swift b/Projects/Domain/Entity/Sources/Station/StationListEntity.swift index d551a4f..686ec44 100644 --- a/Projects/Domain/Entity/Sources/Station/StationListEntity.swift +++ b/Projects/Domain/Entity/Sources/Station/StationListEntity.swift @@ -24,20 +24,29 @@ public struct StationListEntity: Equatable, Hashable { } public struct StationSummaryEntity: Equatable, Hashable, Identifiable { + public let favoriteID: Int? public let stationID: Int public let name: String public let lines: [String] + public let lat: Double? + public let lng: Double? public var id: Int { stationID } public init( + favoriteID: Int? = nil, stationID: Int, name: String, - lines: [String] + lines: [String], + lat: Double? = nil, + lng: Double? = nil ) { + self.favoriteID = favoriteID self.stationID = stationID self.name = name self.lines = lines + self.lat = lat + self.lng = lng } } diff --git a/Projects/Domain/Entity/Sources/TrainStation/Station.swift b/Projects/Domain/Entity/Sources/TrainStation/Station.swift index ca6122c..2d467cd 100644 --- a/Projects/Domain/Entity/Sources/TrainStation/Station.swift +++ b/Projects/Domain/Entity/Sources/TrainStation/Station.swift @@ -7,7 +7,7 @@ import Foundation -public enum Station: String, CaseIterable, Equatable, Hashable, Identifiable { +public enum Station: String, CaseIterable, Equatable, Hashable, Identifiable, Sendable { case seoul case yongsan case gwangmyeong diff --git a/Projects/Domain/Entity/Sources/TrainStation/StationEntity.swift b/Projects/Domain/Entity/Sources/TrainStation/StationEntity.swift new file mode 100644 index 0000000..e3ff142 --- /dev/null +++ b/Projects/Domain/Entity/Sources/TrainStation/StationEntity.swift @@ -0,0 +1,39 @@ +// +// StationEntity.swift +// Entity +// +// Created by Wonji Suh on 3/30/26. +// + +import Foundation + +public struct StationEntity: Identifiable, Equatable, Hashable, Sendable { + public let id: Int + public let favoriteID: Int? + public let station: Station? + public let name: String + public let badges: [String] + public let latitude: Double? + public let longitude: Double? + public let isFavorite: Bool + + public init( + id: Int, + favoriteID: Int? = nil, + station: Station?, + name: String, + badges: [String], + latitude: Double? = nil, + longitude: Double? = nil, + isFavorite: Bool = false + ) { + self.id = id + self.favoriteID = favoriteID + self.station = station + self.name = name + self.badges = badges + self.latitude = latitude + self.longitude = longitude + self.isFavorite = isFavorite + } +} \ No newline at end of file diff --git a/Projects/Domain/UseCase/Project.swift b/Projects/Domain/UseCase/Project.swift index 2e6cc37..c850a02 100644 --- a/Projects/Domain/UseCase/Project.swift +++ b/Projects/Domain/UseCase/Project.swift @@ -11,6 +11,7 @@ let project = Project.makeModule( settings: .settings(), dependencies: [ .Domain(implements: .DomainInterface), + .Shared(implements: .Utill), .SPM.composableArchitecture, .SPM.weaveDI ], diff --git a/Projects/Domain/UseCase/Sources/Auth/AuthUseCaseImpl.swift b/Projects/Domain/UseCase/Sources/Auth/AuthUseCaseImpl.swift index 2ee7e69..187f841 100644 --- a/Projects/Domain/UseCase/Sources/Auth/AuthUseCaseImpl.swift +++ b/Projects/Domain/UseCase/Sources/Auth/AuthUseCaseImpl.swift @@ -40,6 +40,11 @@ public struct AuthUseCaseImpl: AuthInterface { return repository.updateSessionCredential(with: tokens) } + public func registerNotification( + with deviceToken: String + ) async throws -> RegisterNotificationEntity { + return try await repository.registerNotification(with: deviceToken) + } } diff --git a/Projects/Domain/UseCase/Sources/Camera/CameraUseCase.swift b/Projects/Domain/UseCase/Sources/Camera/CameraUseCase.swift new file mode 100644 index 0000000..7294244 --- /dev/null +++ b/Projects/Domain/UseCase/Sources/Camera/CameraUseCase.swift @@ -0,0 +1,99 @@ +// +// CameraUseCase.swift +// UseCase +// +// Created by Wonji Suh on 3/28/26. +// + +import Foundation +import CoreLocation +import Entity +import ComposableArchitecture + +// MARK: - CameraUseCaseInterface Protocol + +public protocol CameraUseCaseInterface: Sendable { + /// 현재 위치로 돌아가기 트리거 생성 + func createReturnToCurrentLocationTrigger( + currentTrigger: Int, + hasCurrentLocation: Bool + ) -> CameraControlResult + + /// 선택된 스팟 클리어 및 관련 상태 업데이트 + func clearSelectedSpotForLocationReturn( + selectedSpotID: String, + isCardVisible: Bool + ) -> CameraControlResult + + /// 카메라 플래그 리셋 + func resetCameraFlag() -> CameraControlResult + + /// 위치 업데이트 시 카메라 트리거 처리 + func handleLocationUpdateForCamera( + shouldReturnToLocation: Bool, + currentTrigger: Int + ) -> CameraControlResult +} + +// MARK: - CameraUseCaseImpl + +public struct CameraUseCaseImpl: CameraUseCaseInterface { + public init() {} + + public func createReturnToCurrentLocationTrigger( + currentTrigger: Int, + hasCurrentLocation: Bool + ) -> CameraControlResult { + guard hasCurrentLocation else { + return CameraControlResult(shouldResetFlag: false) + } + + return CameraControlResult( + shouldUpdateTrigger: true, + newTrigger: currentTrigger + 1, + shouldClearSpot: true + ) + } + + public func clearSelectedSpotForLocationReturn( + selectedSpotID: String, + isCardVisible: Bool + ) -> CameraControlResult { + return CameraControlResult( + shouldClearSpot: !selectedSpotID.isEmpty, + shouldDismissCard: isCardVisible + ) + } + + public func resetCameraFlag() -> CameraControlResult { + return CameraControlResult(shouldResetFlag: true) + } + + public func handleLocationUpdateForCamera( + shouldReturnToLocation: Bool, + currentTrigger: Int + ) -> CameraControlResult { + guard shouldReturnToLocation else { + return CameraControlResult() + } + + return CameraControlResult( + shouldUpdateTrigger: true, + newTrigger: currentTrigger + 1, + shouldResetFlag: true + ) + } +} + +// MARK: - Dependency Extension + +extension DependencyValues { + public var cameraUseCase: CameraUseCaseInterface { + get { self[CameraUseCaseKey.self] } + set { self[CameraUseCaseKey.self] = newValue } + } +} + +private enum CameraUseCaseKey: DependencyKey { + static let liveValue: CameraUseCaseInterface = CameraUseCaseImpl() +} diff --git a/Projects/Domain/UseCase/Sources/DeepLink/Deeplinkrrouter.swift b/Projects/Domain/UseCase/Sources/DeepLink/Deeplinkrrouter.swift new file mode 100644 index 0000000..2a9643a --- /dev/null +++ b/Projects/Domain/UseCase/Sources/DeepLink/Deeplinkrrouter.swift @@ -0,0 +1,180 @@ +// +// DeeplinkRouter.swift +// UseCase +// +// Created by Wonji Suh on 3/30/26. +// + +import Foundation +import Dependencies +import LogMacro + +public struct DeeplinkRouter: Sendable { + + public init() {} + + // MARK: - Public Interface + + public func parse( + _ urlString: String + ) -> DeeplinkResult { + guard let url = URL(string: urlString), + url.scheme == "timespot" else { + return .invalid(url: urlString, reason: "Invalid scheme") + } + + let pathComponents = url.pathComponents.filter { $0 != "/" } + + let hostOrPath = url.host ?? pathComponents.first ?? "" + + switch hostOrPath { + case "route": + return parseRouteDeeplink(url: url) + case "departure_time": + // 출발 시간 알림 딥링크 + return parseRouteDeeplink(url: url) + case "end_journey": + // 여정 종료 알림 딥링크 + return parseRouteDeeplink(url: url) + case let host where host.contains("min_before") || host.contains("min_after"): + // 시간 알림 관련 딥링크는 route로 처리 + return parseRouteDeeplink(url: url) + default: + return .success(.unknown(url: urlString)) + } + } + + // MARK: - Private Parsing + + + + private func parseRouteDeeplink( + url: URL + ) -> DeeplinkResult { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return .invalid(url: url.absoluteString, reason: "Invalid URL format") + } + + var departureTime: String? + var notificationMinutes: [Int] = [] + + // Parse query parameters + components.queryItems?.forEach { queryItem in + switch queryItem.name { + case "departure-time": + departureTime = queryItem.value + case "notification": + // Parse notification time values like "5-min-before", "15-min-before", "10-min-before" + if let value = queryItem.value { + if let minutes = parseNotificationMinutes(from: value) { + notificationMinutes.append(minutes) + } + } + default: + break + } + } + + let routeDeeplink = RouteDeeplink( + departureTime: departureTime, + notificationMinutes: notificationMinutes.isEmpty ? nil : notificationMinutes + ) + + return .success(.route(routeDeeplink)) + } + + private func parseNotificationMinutes(from value: String) -> Int? { + // Parse values like "5-min-before", "15-min-before", "10-min-before" + let cleanValue = value.lowercased().replacingOccurrences(of: "-min-before", with: "") + return Int(cleanValue) + } + + + public func extractDeepLink(from userInfo: [AnyHashable: Any]) -> String? { + #logDebug(" 푸시 알림 payload 분석 시작") + #logDebug(" Available keys: \(userInfo.keys)") + + // 전체 payload 내용 출력 (디버깅용) + for (key, value) in userInfo { + #logDebug(" Key: \(key), Value: \(value), Type: \(type(of: value))") + } + + // 1) 단일 문자열 필드 우선 + let stringKeys = ["deeplink", "url"] + for key in stringKeys { + if let url = userInfo[key] as? String { + #logDebug(" 딥링크 발견 (단일): \(url)") + return url + } + } + + // 2) 중첩 객체에서 url 필드 찾기 (customPayload 추가) + let containerKeys = ["deeplink", "data", "custom", "customPayload"] + for key in containerKeys { + if let container = userInfo[key] as? [String: Any] { + #logDebug(" \(key) 컨테이너 내용: \(container)") + + // url 또는 deeplink 필드 확인 + for urlKey in ["url", "deeplink", "link", "notificationSchema"] { + if let url = container[urlKey] as? String { + #logDebug(" 딥링크 발견 (\(key).\(urlKey)): \(url)") + + // notificationType도 함께 추출해서 URL에 추가 + if let notificationType = container["notificationType"] as? String { + #logDebug(" 알림 타입 발견: \(notificationType)") + return "\(url)?notificationType=\(notificationType)" + } + + return url + } + } + } + } + + // 3) APS 내부도 확인해보기 + if let aps = userInfo["aps"] as? [String: Any] { + #logDebug(" aps 내용: \(aps)") + } + + #logDebug(" No deep link found in push notification") + #logDebug("Available keys: \(userInfo.keys)") + return nil + } +} + +// MARK: - Dependencies + +extension DeeplinkRouter: DependencyKey { + public static let liveValue = DeeplinkRouter() + public static let testValue = DeeplinkRouter() +} + +extension DependencyValues { + public var deeplinkRouter: DeeplinkRouter { + get { self[DeeplinkRouter.self] } + set { self[DeeplinkRouter.self] = newValue } + } +} + + + +public enum DeeplinkDestination: Equatable, Sendable { + case route(RouteDeeplink) + case unknown(url: String) +} + + +public struct RouteDeeplink: Equatable, Sendable { + public let departureTime: String? + public let notificationMinutes: [Int]? + + public init(departureTime: String?, notificationMinutes: [Int]?) { + self.departureTime = departureTime + self.notificationMinutes = notificationMinutes + } +} + +public enum DeeplinkResult: Equatable, Sendable { + case success(DeeplinkDestination) + case invalid(url: String, reason: String) +} diff --git a/Projects/Domain/UseCase/Sources/DeepLink/NotificationType.swift b/Projects/Domain/UseCase/Sources/DeepLink/NotificationType.swift new file mode 100644 index 0000000..9583405 --- /dev/null +++ b/Projects/Domain/UseCase/Sources/DeepLink/NotificationType.swift @@ -0,0 +1,103 @@ +// +// NotificationType.swift +// UseCase +// +// Created by Wonji Suh on 4/1/26. +// + +import Foundation + +public enum NotificationType: Equatable, CaseIterable { + case now // 지금 바로 출발 + case fiveMin // 5분 전 + case tenMin // 10분 전 + case fifteenMin // 15분 전 + case endJourney // 여정 종료 + + public var title: String { + switch self { + case .now: + return "지금 바로 출발해야 해요!" + case .fiveMin: + return "5분 뒤면 역으로 출발 일어날 채비를 할 시간이에요." + case .tenMin: + return "10분 뒤면 역으로 출발해야 해요!" + case .fifteenMin: + return "역으로 출발하기까지 15분 남았어요!" + case .endJourney: + return "무사히 탑승하셨나요?" + } + } + + public var subtitle: String { + switch self { + case .now: + return "지금 바로 역으로 향해야 15분 전에 플랫폼에 도착할 수 있어요." + case .fiveMin: + return "잠시 후 출발할 수 있도록 미리 준비해주세요." + case .tenMin: + return "이제 슬슬 일어날 준비를 해볼까요?" + case .fifteenMin: + return "지금 하는 활동을 차분히 마무리해 주세요." + case .endJourney: + return "오늘 대기 시간이 맞진 여행이 되었어요.\n이제 편안한 여정 되세요!" + } + } + + public static func from(deepLink: String) -> NotificationType { + guard let url = URL(string: deepLink) else { + return .now + } + + // URL host/path 체크 (timespot://departure_time 등) + let pathComponents = url.pathComponents.filter { $0 != "/" } + let hostOrPath = url.host ?? pathComponents.first ?? "" + + switch hostOrPath { + case "departure_time": + return .now + case "end_journey": + return .endJourney + case let path where path.contains("15_min_before"): + return .fifteenMin + case let path where path.contains("10_min_before"): + return .tenMin + case let path where path.contains("5_min_before"): + return .fiveMin + default: + break + } + + // URL에서 notificationType 파라미터 추출 + if let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let notificationTypeParam = components.queryItems?.first(where: { $0.name == "notificationType" })?.value { + + switch notificationTypeParam { + case "BEFORE_15_MINUTES": + return .fifteenMin + case "BEFORE_10_MINUTES": + return .tenMin + case "BEFORE_5_MINUTES": + return .fiveMin + case "DEPARTURE_TIME": + return .now + case "END_JOURNEY": + return .endJourney + default: + return .now + } + } + + // 기존 방식도 유지 (fallback) + if deepLink.contains("15_min_before") { + return .fifteenMin + } else if deepLink.contains("10_min_before") { + return .tenMin + } else if deepLink.contains("5_min_before") { + return .fiveMin + } else { + return .now + } + } +} + diff --git a/Projects/Domain/UseCase/Sources/Direction/RouteUseCaseImpl.swift b/Projects/Domain/UseCase/Sources/Direction/RouteUseCaseImpl.swift index 85fe2d4..2979e7d 100644 --- a/Projects/Domain/UseCase/Sources/Direction/RouteUseCaseImpl.swift +++ b/Projects/Domain/UseCase/Sources/Direction/RouteUseCaseImpl.swift @@ -8,6 +8,8 @@ import Foundation import CoreLocation +import MapKit +import UIKit import DomainInterface import Entity @@ -27,7 +29,7 @@ public struct RouteUseCaseImpl: DirectionInterface { to destination: CLLocationCoordinate2D, option: RouteOption = .walking ) async throws -> RouteInfo { - #logDebug("🎯 [GetRouteUseCase] 경로 검색 시작: \(option.displayName)") + #logDebug(" [GetRouteUseCase] 경로 검색 시작: \(option.displayName)") do { let routeInfo = try await getRoute( @@ -36,10 +38,10 @@ public struct RouteUseCaseImpl: DirectionInterface { option: option ) - #logDebug("✅ [GetRouteUseCase] 경로 검색 완료: \(routeInfo.distance)m, \(routeInfo.duration)분") + #logDebug(" [GetRouteUseCase] 경로 검색 완료: \(routeInfo.distance)m, \(routeInfo.duration)분") return routeInfo } catch { - #logDebug("❌ [GetRouteUseCase] 경로 검색 실패: \(error)") + #logDebug(" [GetRouteUseCase] 경로 검색 실패: \(error)") throw error } } @@ -56,6 +58,184 @@ public struct RouteUseCaseImpl: DirectionInterface { option: option ) } + + /// 외부 지도 앱으로 길찾기 시작 + public func startNavigation( + mapType: ExternalMapType, + destination: CLLocationCoordinate2D, + destinationName: String + ) async { + #logDebug(" [RouteUseCase] 길찾기 시작: \(destinationName) (\(mapType.description))") + + // 네이버 지도의 경우 앱 설치 여부를 먼저 확인 + if mapType == .naverMap { + let isInstalled = await checkNaverMapInstallation() + #logDebug(" [RouteUseCase] 네이버지도 앱 설치 상태: \(isInstalled)") + } + + switch mapType { + case .appleMap: + await openAppleMap(destination: destination, destinationName: destinationName) + + case .googleMap: + await openGoogleMap(lat: destination.latitude, lng: destination.longitude, destinationName: destinationName) + + case .naverMap: + await openNaverMap(lat: destination.latitude, lng: destination.longitude, destinationName: destinationName) + } + } + + /// 네이버 지도 앱 설치 여부 확인 (강화된 디버깅) + @MainActor + private func checkNaverMapInstallation() -> Bool { + let naverMapSchemes = ["nmap://", "nmapmobile://", "navermap://"] + + for scheme in naverMapSchemes { + if let url = URL(string: scheme) { + let canOpen = UIApplication.shared.canOpenURL(url) + + if canOpen { + // 추가 테스트: 실제 간단한 URL로 테스트 + let testURL = scheme + "place?lat=37.5665&lng=126.9780" + if let testUrl = URL(string: testURL) { + let testCanOpen = UIApplication.shared.canOpenURL(testUrl) + #logDebug(" [RouteUseCase] 테스트 URL \(testURL) canOpenURL: \(testCanOpen)") + } + + return true + } + } else { + #logDebug(" [RouteUseCase] URL 생성 실패: \(scheme)") + } + } + + #logDebug(" [RouteUseCase] 네이버지도 앱 미설치 (모든 스킴 실패)") + + // 설치되지 않은 경우 App Store로 이동 + let appStoreURL = "itms-apps://itunes.apple.com/app/311867728" + + + if let url = URL(string: appStoreURL), UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } + + return false + } + + /// Apple 지도 앱으로 길찾기 + @MainActor + private func openAppleMap(destination: CLLocationCoordinate2D, destinationName: String) { + #logDebug("🍎 [RouteUseCase] Apple Maps 실행") + + let placemark = MKPlacemark(coordinate: destination) + let mapItem = MKMapItem(placemark: placemark) + mapItem.name = destinationName + + let launchOptions = [ + MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeWalking + ] + + mapItem.openInMaps(launchOptions: launchOptions) + } + + /// Google Maps 앱으로 길찾기 + @MainActor + private func openGoogleMap(lat: Double, lng: Double, destinationName: String) { + let encodedName = destinationName.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? destinationName + let primaryURL = "comgooglemaps://?daddr=\(lat),\(lng)(\(encodedName))&directionsmode=walking" + + if let url = URL(string: primaryURL), UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url, options: [:]) { success in + Task { @MainActor in + if !success { + self.openGoogleMapWeb(lat: lat, lng: lng, destinationName: destinationName) + } + } + } + } else { + openGoogleMapWeb(lat: lat, lng: lng, destinationName: destinationName) + } + } + + /// Google Maps 웹으로 길찾기 + @MainActor + private func openGoogleMapWeb(lat: Double, lng: Double, destinationName: String) { + let webURL = "https://www.google.com/maps/dir/?api=1&destination=\(lat),\(lng)&travelmode=walking" + if let url = URL(string: webURL) { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } + } + + /// 네이버 지도 앱으로 길찾기 + @MainActor + private func openNaverMap(lat: Double, lng: Double, destinationName: String) { + #if targetEnvironment(simulator) + #logDebug("⚠️ [RouteUseCase] 시뮬레이터에서는 외부 앱 연동 불가") + openNaverMapWeb(lat: lat, lng: lng, destinationName: destinationName) + return + #endif + + let encodedName = destinationName.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? destinationName + + // 가장 효과적인 URL 순서로 정렬 + let primaryURL = "nmap://route/walk?dlat=\(lat)&dlng=\(lng)&dname=\(encodedName)" + + if let url = URL(string: primaryURL), UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url, options: [:]) { success in + Task { @MainActor in + if !success { + self.openNaverMapWeb(lat: lat, lng: lng, destinationName: destinationName) + } + } + } + } else { + // 앱이 설치되지 않은 경우 App Store로 이동 + let appStoreURL = "itms-apps://itunes.apple.com/app/311867728" + if let storeUrl = URL(string: appStoreURL) { + UIApplication.shared.open(storeUrl, options: [:], completionHandler: nil) + } else { + openNaverMapWeb(lat: lat, lng: lng, destinationName: destinationName) + } + } + } + + + /// 네이버 지도 웹으로 길찾기 + @MainActor + private func openNaverMapWeb(lat: Double, lng: Double, destinationName: String) { + // 인코딩된 목적지 이름 + let encodedName = destinationName.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? destinationName + + // 여러 웹 URL 형식 시도 + let webURLs = [ + // 1. 도보 길찾기 (정확한 형식) + "https://map.naver.com/v5/directions/-/-,\(encodedName),\(lat),\(lng)/-/walk", + + // 2. 기본 지도에서 해당 위치 표시 + "https://map.naver.com/v5/search/\(encodedName)?c=\(lng),\(lat),15,0,0,0,dh", + + // 3. 간단한 좌표 중심 지도 + "https://map.naver.com/v5/?c=\(lng),\(lat),15,0,0,0,dh" + ] + + // 첫 번째 유효한 URL로 실행 + for (index, urlString) in webURLs.enumerated() { + #logDebug(" [RouteUseCase] 웹 URL 시도 \(index + 1): \(urlString)") + + if let url = URL(string: urlString) { + UIApplication.shared.open(url, options: [:]) { success in + Task { @MainActor in + if success { + #logDebug(" [RouteUseCase] 웹 열기 성공!") + } else { + #logDebug("❌ [RouteUseCase] 웹 열기 실패") + } + } + } + return // 첫 번째 성공한 URL로 종료 + } + } + } } diff --git a/Projects/Domain/UseCase/Sources/History/HistoryUseCaseImpl.swift b/Projects/Domain/UseCase/Sources/History/HistoryUseCaseImpl.swift index e022091..fa91dc9 100644 --- a/Projects/Domain/UseCase/Sources/History/HistoryUseCaseImpl.swift +++ b/Projects/Domain/UseCase/Sources/History/HistoryUseCaseImpl.swift @@ -24,6 +24,20 @@ public struct HistoryUseCaseImpl: HistoryInterface { ) async throws -> HistoryEntity { return try await repository.myHistory(page: page, size: size, sort: sort) } + + // MARK: - 여정 관리 + public func startJourney( + input: StartJourneyInput + ) async throws -> JourneyEntity { + return try await repository.startJourney(input: input) + } + + public func endJourney( + journeyId: Int, + isCompleted: Bool + ) async throws -> JourneyEntity { + return try await repository.endJourney(journeyId: journeyId, isCompleted: isCompleted) + } } diff --git a/Projects/Domain/UseCase/Sources/Location/LocationUseCase.swift b/Projects/Domain/UseCase/Sources/Location/LocationUseCase.swift new file mode 100644 index 0000000..a8993d9 --- /dev/null +++ b/Projects/Domain/UseCase/Sources/Location/LocationUseCase.swift @@ -0,0 +1,93 @@ +// +// LocationUseCase.swift +// UseCase +// +// Created by Wonji Suh on 3/28/26. +// + +import Foundation +import CoreLocation +import ComposableArchitecture +import DomainInterface + +// MARK: - LocationUseCaseInterface Protocol + +public protocol LocationUseCaseInterface: Sendable { + /// 위치 권한 상태 확인 + func getAuthorizationStatus() async -> CLAuthorizationStatus + + /// 위치 권한 요청 + func requestLocationPermission() async -> CLAuthorizationStatus + + /// 정확도 개선 요청 + func requestFullAccuracy() async + + /// 위치 업데이트 시작 및 콜백 설정 + func startLocationUpdates( + onUpdate: @escaping @Sendable (CLLocation) -> Void, + onError: @escaping @Sendable (Error) -> Void + ) async + + /// 위치 업데이트 중지 + func stopLocationUpdates() async + + /// 현재 위치 한번만 요청 + func requestCurrentLocation() async throws -> CLLocation? + + /// 위치 서비스 사용 가능 여부 + func isLocationServicesEnabled() async -> Bool +} + +// MARK: - LocationUseCaseImpl + +public struct LocationUseCaseImpl: LocationUseCaseInterface { + public init() {} + + public func getAuthorizationStatus() async -> CLAuthorizationStatus { + await MainActor.run { + LocationPermissionManager.shared.authorizationStatus + } + } + + public func requestLocationPermission() async -> CLAuthorizationStatus { + await LocationPermissionManager.shared.requestLocationPermission() + } + + public func requestFullAccuracy() async { + await LocationPermissionManager.shared.requestFullAccuracy() + } + + public func startLocationUpdates( + onUpdate: @escaping @Sendable (CLLocation) -> Void, + onError: @escaping @Sendable (Error) -> Void + ) async { + await LocationPermissionManager.shared.setLocationUpdateCallback(onUpdate) + await LocationPermissionManager.shared.setLocationErrorCallback(onError) + await LocationPermissionManager.shared.startLocationUpdates() + } + + public func stopLocationUpdates() async { + await LocationPermissionManager.shared.stopLocationUpdates() + } + + public func requestCurrentLocation() async throws -> CLLocation? { + try await LocationPermissionManager.shared.requestCurrentLocation() + } + + public func isLocationServicesEnabled() async -> Bool { + await LocationPermissionManager.shared.isLocationServicesEnabled() + } +} + +// MARK: - Dependency Extension + +extension DependencyValues { + public var locationUseCase: LocationUseCaseInterface { + get { self[LocationUseCaseKey.self] } + set { self[LocationUseCaseKey.self] = newValue } + } +} + +private enum LocationUseCaseKey: DependencyKey { + static let liveValue: LocationUseCaseInterface = LocationUseCaseImpl() +} diff --git a/Projects/Domain/UseCase/Sources/OAuth/UnifiedOAuthUseCase.swift b/Projects/Domain/UseCase/Sources/OAuth/UnifiedOAuthUseCase.swift index 1a9058a..b768a29 100644 --- a/Projects/Domain/UseCase/Sources/OAuth/UnifiedOAuthUseCase.swift +++ b/Projects/Domain/UseCase/Sources/OAuth/UnifiedOAuthUseCase.swift @@ -86,7 +86,7 @@ public extension UnifiedOAuthUseCase { token: payload.idToken ) - print("애플 코드 \(payload.authorizationCode ?? "")") + #logDebug("애플 코드", "\(payload.authorizationCode ?? "")") self.$userSession.withLock { $0.name = savedAppleUserName ?? "" diff --git a/Projects/Domain/UseCase/Sources/Place/PlaceUseCaseImpl.swift b/Projects/Domain/UseCase/Sources/Place/PlaceUseCaseImpl.swift new file mode 100644 index 0000000..19e245f --- /dev/null +++ b/Projects/Domain/UseCase/Sources/Place/PlaceUseCaseImpl.swift @@ -0,0 +1,356 @@ +// +// PlaceUseCaseImpl.swift +// UseCase +// +// Created by Wonji Suh on 3/27/26. +// + +import DomainInterface +import Entity + +import ComposableArchitecture +import CoreLocation +import Utill +import LogMacro + +public protocol PlaceUseCaseInterface: Sendable { + func detailPlace( + userSession: UserSession, + placeId: Int + ) async throws -> PlaceDetailEntity + + func fetchPlaces( + userSession: UserSession, + userLat: Double, + userLon: Double, + keyword: String?, + category: ExploreCategory?, + sort: String, + mapLat: Double?, + mapLon: Double?, + page: Int + ) async throws -> PlaceSearchPageEntity + + func fetchInitialExploreSpots( + userSession: UserSession, + userLat: Double, + userLon: Double + ) async throws -> ExploreSpotPageEntity + + func searchExploreSpots( + baseSpots: [ExploreMapSpot], + userSession: UserSession, + userLat: Double, + userLon: Double, + keyword: String?, + category: ExploreCategory?, + sort: String, + mapLat: Double?, + mapLon: Double?, + page: Int + ) async throws -> ExploreSpotPageEntity +} + +public struct PlaceUseCaseImpl: PlaceUseCaseInterface { + @Dependency(\.placeRepository) var repository + @Dependency(\.locationUseCase) var locationUseCase + + public init() {} + + public func detailPlace( + userSession: UserSession, + placeId: Int + ) async throws -> PlaceDetailEntity { + let resolvedLocation: CLLocation? + do { + resolvedLocation = try await locationUseCase.requestCurrentLocation() + } catch { + resolvedLocation = nil + } + + let input = PlaceDetailInput( + placeId: placeId, + stationId: Int(userSession.travelID) ?? 0, + userLat: resolvedLocation?.coordinate.latitude ?? userSession.travelStationLat ?? 0, + userLon: resolvedLocation?.coordinate.longitude ?? userSession.travelStationLng ?? 0, + remainingMinutes: 250 + ) + + return try await repository.detailPlaces(input) + } + + public func fetchPlaces( + userSession: UserSession, + userLat: Double, + userLon: Double, + keyword: String?, + category: ExploreCategory?, + sort: String, + mapLat: Double?, + mapLon: Double?, + page: Int + ) async throws -> PlaceSearchPageEntity { + let input = PlaceSearchInput( + userLat: userLat, + userLon: userLon, + stationId: Int(userSession.travelID) ?? 0, + remainingMinutes: userSession.remainingMinutes, + keyword: keyword, + category: mapCategory(category), + mapLat: mapLat, + mapLon: mapLon, + page: page, // 서버도 1-based 페이지 사용 + size: 50, + sort: sort + ) + + #logDebug("🌐 [API요청] fetchPlaces - page=\(page), size=50") + + return try await repository.fetchPlaces(input) + } + + public func fetchInitialExploreSpots( + userSession: UserSession, + userLat: Double, + userLon: Double + ) async throws -> ExploreSpotPageEntity { + // 🚀 단순화: fetchPlaces API 하나만 사용 + let searchInput = PlaceSearchInput( + userLat: userLat, + userLon: userLon, + stationId: Int(userSession.travelID) ?? 0, + remainingMinutes: userSession.remainingMinutes, + keyword: nil, + category: nil, + mapLat: userSession.travelStationLat, + mapLon: userSession.travelStationLng, + page: 1, // 서버도 1-based 페이지 사용 + size: 200, + sort: "distanceFromStation,ASC" + ) + + #logDebug("🚀 [초기로딩] fetchPlaces - page=1, size=200") + + let pageEntity = try await repository.fetchPlaces(searchInput) + + #logDebug("🚀 [초기로딩] fetchPlaces 응답: \(pageEntity.content.count)개") + + // 직접 마커 생성 (병합 없이) + let spots = pageEntity.content.map { entity in + makeDetailSpot( + from: entity, + coordinate: CLLocationCoordinate2D(latitude: entity.lat, longitude: entity.lon), + stationName: userSession.travelStationName, + stationLat: userSession.travelStationLat, + stationLon: userSession.travelStationLng + ) + } + + #logDebug("🚀 [초기로딩] 최종 spots: \(spots.count)개") + + return ExploreSpotPageEntity( + spots: spots, + currentPage: pageEntity.page, + hasNextPage: !pageEntity.isLastPage + ) + } + + + public func searchExploreSpots( + baseSpots: [ExploreMapSpot], + userSession: UserSession, + userLat: Double, + userLon: Double, + keyword: String?, + category: ExploreCategory?, + sort: String, + mapLat: Double?, + mapLon: Double?, + page: Int + ) async throws -> ExploreSpotPageEntity { + // 🔍 단순화: fetchPlaces API 하나만 사용 + let searchInput = PlaceSearchInput( + userLat: userLat, + userLon: userLon, + stationId: Int(userSession.travelID) ?? 0, + remainingMinutes: userSession.remainingMinutes, + keyword: keyword, + category: mapCategory(category), + mapLat: mapLat, + mapLon: mapLon, + page: page, // 서버도 1-based 페이지 사용 + size: 50, + sort: sort + ) + + #logDebug("🔍 [API요청] searchExploreSpots - page=\(page), size=50") + + let pageEntity = try await repository.fetchPlaces(searchInput) + + #logDebug("🔍 [API응답] searchExploreSpots - 응답 size: \(pageEntity.content.count), hasNext: \(!pageEntity.isLastPage)") + + #logDebug("🔍 [필터링] fetchPlaces 응답: \(pageEntity.content.count)개") + + // 직접 마커 생성 (병합 없이) + let spots = pageEntity.content.map { entity in + makeDetailSpot( + from: entity, + coordinate: CLLocationCoordinate2D(latitude: entity.lat, longitude: entity.lon), + stationName: userSession.travelStationName, + stationLat: userSession.travelStationLat, + stationLon: userSession.travelStationLng + ) + } + + #logDebug("🔍 [필터링] 최종 spots: \(spots.count)개") + + return ExploreSpotPageEntity( + spots: spots, + currentPage: pageEntity.page, + hasNextPage: !pageEntity.isLastPage + ) + } + + private func mapCategory(_ category: ExploreCategory?) -> String? { + switch category { + case .none, .some(.all): + return nil + case .some(.cafe): + return "카페" + case .some(.restaurant): + return "음식점" + case .some(.activity): + return "액티비티" + case .some(.shopping): + return "쇼핑" + case .some(.etc): + return "기타" + } + } + + private func makeBaseSpot(from entity: PlaceEntity) -> ExploreMapSpot { + ExploreMapSpot( + id: String(entity.placeId), + name: "", + category: entity.category, + coordinate: CLLocationCoordinate2D(latitude: entity.lat, longitude: entity.lon), + hasDetail: false, + imageURL: entity.imageURL, + badgeText: entity.stayableMinutes > 0 ? "\(entity.stayableMinutes)분 체류 가능" : "", + subtitle: entity.category.title, + statusText: "", + closingText: "", + distanceText: "", + walkTimeText: "", + address: entity.address, + visitable: entity.visitable + ) + } + + private func makeDetailSpot( + from entity: PlaceEntity, + coordinate: CLLocationCoordinate2D, + stationName: String, + stationLat: Double?, + stationLon: Double? + ) -> ExploreMapSpot { + let closingText: String + if let closingTime = entity.closingTime, !closingTime.isEmpty { + closingText = closingTime.formattedClosingTimeText() + } else { + closingText = entity.address + } + + let distanceText: String + let walkTimeText: String + + if let distanceFromStation = entity.distanceFromStation { + let roundedDistance = Int(distanceFromStation.rounded()) + distanceText = "\(roundedDistance)m" + } else if let stationLat, let stationLon { + let stationLocation = CLLocation(latitude: stationLat, longitude: stationLon) + let placeLocation = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude) + let distanceInMeters = stationLocation.distance(from: placeLocation) + let roundedDistance = Int((distanceInMeters / 10).rounded() * 10) + distanceText = "\(roundedDistance)m" + } else { + distanceText = "" + } + + if let walkTime = entity.walkTimeFromStation { + walkTimeText = "\(stationName)역에서 약 \(walkTime)분" + } else if let stationLat, let stationLon, distanceText.isEmpty == false { + let stationLocation = CLLocation(latitude: stationLat, longitude: stationLon) + let placeLocation = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude) + let distanceInMeters = stationLocation.distance(from: placeLocation) + let walkingMinutes = max(Int(ceil(distanceInMeters / 67)), 1) + walkTimeText = "\(stationName)역에서 약 \(walkingMinutes)분" + } else { + walkTimeText = "" + } + + return ExploreMapSpot( + id: String(entity.placeId), + name: entity.name, + category: entity.category, + coordinate: coordinate, + hasDetail: true, + imageURL: entity.imageURL, + badgeText: entity.stayableMinutes > 0 ? "\(entity.stayableMinutes)분 체류 가능" : "", + subtitle: entity.category.title, + statusText: entity.visitable ? "영업 중" : "영업 종료", + closingText: closingText, + distanceText: distanceText, + walkTimeText: walkTimeText, + address: entity.address, + visitable: entity.visitable + ) + } + + private func mergeSpots( + baseSpots: [ExploreMapSpot], + detailPage: PlaceSearchPageEntity, + stationName: String, + stationLat: Double?, + stationLon: Double? + ) -> [ExploreMapSpot] { + let baseSpotsByID = Dictionary(uniqueKeysWithValues: baseSpots.map { ($0.id, $0) }) + var mergedSpots: [ExploreMapSpot] = [] + var resolvedIDs = Set() + + for entity in detailPage.content { + let id = String(entity.placeId) + let coordinate = baseSpotsByID[id]?.coordinate + ?? CLLocationCoordinate2D(latitude: entity.lat, longitude: entity.lon) + let detailSpot = makeDetailSpot( + from: entity, + coordinate: coordinate, + stationName: stationName, + stationLat: stationLat, + stationLon: stationLon + ) + + mergedSpots.append(detailSpot) + resolvedIDs.insert(id) + } + + for baseSpot in baseSpots where !resolvedIDs.contains(baseSpot.id) { + mergedSpots.append(baseSpot) + } + + return mergedSpots + } +} + +extension PlaceUseCaseImpl: DependencyKey { + public static var liveValue: PlaceUseCaseInterface = PlaceUseCaseImpl() + public static var testValue: PlaceUseCaseInterface = PlaceUseCaseImpl() + public static var previewValue: PlaceUseCaseInterface = PlaceUseCaseImpl() +} + +public extension DependencyValues { + var placeUseCase: PlaceUseCaseInterface { + get { self[PlaceUseCaseImpl.self] } + set { self[PlaceUseCaseImpl.self] = newValue } + } +} diff --git a/Projects/Domain/UseCase/Sources/Profile/ProfileUseCaseImpl.swift b/Projects/Domain/UseCase/Sources/Profile/ProfileUseCaseImpl.swift index 0217d44..9ab2089 100644 --- a/Projects/Domain/UseCase/Sources/Profile/ProfileUseCaseImpl.swift +++ b/Projects/Domain/UseCase/Sources/Profile/ProfileUseCaseImpl.swift @@ -48,6 +48,16 @@ public struct ProfileUseCaseImpl: ProfileInterface { throw error } } + + public func fetchNotificationSettings() async throws -> NotificationEntity { + try await repository.fetchNotificationSettings() + } + + public func editNotificationSettings( + notificationSettings: [NotificationOption] + ) async throws -> NotificationEntity { + try await repository.editNotificationSettings(notificationSettings: notificationSettings) + } } @@ -63,4 +73,3 @@ public extension DependencyValues { set { self[ProfileUseCaseImpl.self] = newValue } } } - diff --git a/Projects/Domain/UseCase/Sources/Station/StationUseCaseImpl.swift b/Projects/Domain/UseCase/Sources/Station/StationUseCaseImpl.swift index 69661e6..23a072b 100644 --- a/Projects/Domain/UseCase/Sources/Station/StationUseCaseImpl.swift +++ b/Projects/Domain/UseCase/Sources/Station/StationUseCaseImpl.swift @@ -16,14 +16,14 @@ public struct StationUseCaseImpl: StationInterface { public init() {} public func fetchStations( - lat: Double, - lng: Double, + userLat: Double, + userLon: Double, page: Int, size: Int ) async throws -> StationListEntity { try await repository.fetchStations( - lat: lat, - lng: lng, + userLat: userLat, + userLon: userLon, page: page, size: size ) @@ -36,9 +36,9 @@ public struct StationUseCaseImpl: StationInterface { } public func deleteFavoriteStation( - stationID: Int + favoriteID: Int ) async throws -> FavoriteStationMutationEntity { - try await repository.deleteFavoriteStation(stationID: stationID) + try await repository.deleteFavoriteStation(favoriteID: favoriteID) } } diff --git a/Projects/Network/Foundations/Sources/APIHeader/APIHeader.swift b/Projects/Network/Foundations/Sources/APIHeader/APIHeader.swift index e545ebe..1d6a8c6 100644 --- a/Projects/Network/Foundations/Sources/APIHeader/APIHeader.swift +++ b/Projects/Network/Foundations/Sources/APIHeader/APIHeader.swift @@ -31,6 +31,10 @@ public struct APIHeader { tokenProvider.saveAccessToken(newToken) } + public static func clearAccessToken() { + tokenProvider.clearToken() + } + public init() {} } diff --git a/Projects/Network/Foundations/Sources/APIHeader/TokenProviding.swift b/Projects/Network/Foundations/Sources/APIHeader/TokenProviding.swift index 75c7be9..6bf052a 100644 --- a/Projects/Network/Foundations/Sources/APIHeader/TokenProviding.swift +++ b/Projects/Network/Foundations/Sources/APIHeader/TokenProviding.swift @@ -13,6 +13,7 @@ import WeaveDI public protocol TokenProviding: Sendable { func accessToken() -> String? func saveAccessToken(_ token: String) + func clearToken() } private enum TokenProviderKey: DependencyKey { @@ -45,4 +46,10 @@ public final class InMemoryTokenProvider: TokenProviding, @unchecked Sendable { storage = token lock.unlock() } + + public func clearToken() { + lock.lock() + storage = nil + lock.unlock() + } } diff --git a/Projects/Presentation/Auth/Project.swift b/Projects/Presentation/Auth/Project.swift index f3ddf7d..ad8af72 100644 --- a/Projects/Presentation/Auth/Project.swift +++ b/Projects/Presentation/Auth/Project.swift @@ -12,11 +12,12 @@ let project = Project.makeModule( product: .staticFramework, settings: .settings(), dependencies: [ - .Domain(implements: .UseCase), - .Shared(implements: .Shared), .SPM.composableArchitecture, .SPM.tcaCoordinator, - .Presentation(implements: .OnBoarding) + .Domain(implements: .UseCase), + .Shared(implements: .Shared), + .Presentation(implements: .OnBoarding), + .Presentation(implements: .Web) ], sources: ["Sources/**"] ) diff --git a/Projects/Presentation/Auth/Sources/Coordinator/Reducer/AuthCoordinator.swift b/Projects/Presentation/Auth/Sources/Coordinator/Reducer/AuthCoordinator.swift index 4fd7663..c77005e 100644 --- a/Projects/Presentation/Auth/Sources/Coordinator/Reducer/AuthCoordinator.swift +++ b/Projects/Presentation/Auth/Sources/Coordinator/Reducer/AuthCoordinator.swift @@ -8,6 +8,7 @@ import ComposableArchitecture import TCACoordinators import OnBoarding +import Web @Reducer public struct AuthCoordinator { @@ -85,6 +86,10 @@ extension AuthCoordinator { action: IndexedRouterActionOf ) -> Effect { switch action { + case .routeAction(id: _, action: .login(.delegate(.presentGuestLookAround))): + return .routeWithDelaysIfUnsupported(state.routes, action: \.router) { + $0.push(.onBoarding(.init())) + } case .routeAction(id: _, action: .login(.delegate(.presentOnBoarding))): return .send(.inner(.pushOnBoarding)) @@ -95,6 +100,13 @@ extension AuthCoordinator { case .routeAction(id: _, action: .onBoarding(.navigation(.onBoardingCompleted))): return .send(.navigation(.presentMain)) + case .routeAction(id: _, action: .login(.delegate(.presentPrivacyWeb))): + state.routes.push(.web(.init(url: "https://www.notion.so/329f94ae438b807d95dcd0f5f8abf66a?source=copy_link"))) + return .none + + case .routeAction(id: _, action: .web(.backToRoot)): + return .send(.view(.backAction)) + default: return .none } @@ -160,6 +172,7 @@ extension AuthCoordinator { public enum AuthScreen { case login(LoginFeature) case onBoarding(OnBoardingFeature) + case web(WebFeature) } } diff --git a/Projects/Presentation/Auth/Sources/Coordinator/View/AuthCoordinatorView.swift b/Projects/Presentation/Auth/Sources/Coordinator/View/AuthCoordinatorView.swift index c53b0ab..2b0dff9 100644 --- a/Projects/Presentation/Auth/Sources/Coordinator/View/AuthCoordinatorView.swift +++ b/Projects/Presentation/Auth/Sources/Coordinator/View/AuthCoordinatorView.swift @@ -10,6 +10,7 @@ import SwiftUI import ComposableArchitecture import TCACoordinators import OnBoarding +import Web public struct AuthCoordinatorView: View { @Bindable private var store: StoreOf @@ -29,6 +30,10 @@ public struct AuthCoordinatorView: View { OnBoardingView(store: onBoardingStore) .navigationBarBackButtonHidden() .transition(.opacity.combined(with: .scale(scale: 0.98))) + + case .web(let webStore): + WebView(store: webStore) + .navigationBarBackButtonHidden() } } } diff --git a/Projects/Presentation/Auth/Sources/Reducer/LoginFeature.swift b/Projects/Presentation/Auth/Sources/Main/Reducer/LoginFeature.swift similarity index 92% rename from Projects/Presentation/Auth/Sources/Reducer/LoginFeature.swift rename to Projects/Presentation/Auth/Sources/Main/Reducer/LoginFeature.swift index 0b77221..1258556 100644 --- a/Projects/Presentation/Auth/Sources/Reducer/LoginFeature.swift +++ b/Projects/Presentation/Auth/Sources/Main/Reducer/LoginFeature.swift @@ -15,8 +15,6 @@ import Utill import ComposableArchitecture import LogMacro - - @Reducer public struct LoginFeature { public init() {} @@ -30,6 +28,7 @@ public struct LoginFeature { var loginEntity: LoginEntity? var currentSocialType: SocialType? @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty + @Shared(.appStorage("selectedMapType")) var selectedMapTypeStorage: ExternalMapType = .naverMap public init() {} } @@ -74,6 +73,7 @@ public struct LoginFeature { case presentPrivacyWeb case presentOnBoarding case presentMain + case presentGuestLookAround } @@ -221,6 +221,13 @@ extension LoginFeature { case .presentMain: return .none + case .presentGuestLookAround: + // 비회원으로 시작하기 + state.$userSession.withLock { userSession in + userSession.isGuest = true + } + return .send(.delegate(.presentOnBoarding)) + } } @@ -237,6 +244,13 @@ extension LoginFeature { switch result { case .success(let loginEntity): state.loginEntity = loginEntity + state.$selectedMapTypeStorage.withLock { + $0 = loginEntity.mapType ?? .appleMap + } + + state.$userSession.withLock { userSession in + userSession.isGuest = false + } if loginEntity.isNewUser { return .send(.delegate(.presentTermsAgreement)) @@ -283,6 +297,7 @@ extension LoginFeature.State { hasher.combine(nonce) hasher.combine(appleAccessToken) hasher.combine(currentSocialType) + hasher.combine(selectedMapTypeStorage) } } diff --git a/Projects/Presentation/Auth/Sources/View/LoginView.swift b/Projects/Presentation/Auth/Sources/Main/View/LoginView.swift similarity index 83% rename from Projects/Presentation/Auth/Sources/View/LoginView.swift rename to Projects/Presentation/Auth/Sources/Main/View/LoginView.swift index 840fd66..eafb720 100644 --- a/Projects/Presentation/Auth/Sources/View/LoginView.swift +++ b/Projects/Presentation/Auth/Sources/Main/View/LoginView.swift @@ -51,20 +51,12 @@ 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) @@ -77,8 +69,6 @@ extension LoginView { VStack(alignment: .center, spacing: 8) { ForEach(SocialType.allCases) { type in SocialLoginButton(store: store, type: type) { - // 애플 로그인은 SignInWithAppleButton 자체 처리 사용 - // 구글 로그인만 여기서 처리 store.send(.view(.signInWithSocial(social: type))) } } @@ -97,7 +87,7 @@ extension LoginView { .foregroundStyle(.gray800) .underline(true, color: .gray800.opacity(0.5)) .onTapGesture { - store.send(.delegate(.presentPrivacyWeb)) + store.send(.delegate(.presentGuestLookAround)) } diff --git a/Projects/Presentation/Home/Project.swift b/Projects/Presentation/Home/Project.swift index cfb4df4..5935922 100644 --- a/Projects/Presentation/Home/Project.swift +++ b/Projects/Presentation/Home/Project.swift @@ -14,6 +14,7 @@ let project = Project.makeModule( .Domain(implements: .UseCase), .Shared(implements: .Shared), .SPM.composableArchitecture, + .SPM.kingfisher, .SPM.tcaCoordinator, .Presentation(implements: .Profile), .xcframework(path: "./Resources/framework/NMapsMap.xcframework"), diff --git a/Projects/Presentation/Home/Sources/Components/LocationPermissionOverlay.swift b/Projects/Presentation/Home/Sources/Components/LocationPermissionOverlay.swift index 2ee38aa..a3db5e9 100644 --- a/Projects/Presentation/Home/Sources/Components/LocationPermissionOverlay.swift +++ b/Projects/Presentation/Home/Sources/Components/LocationPermissionOverlay.swift @@ -2,8 +2,7 @@ // LocationPermissionOverlay.swift // Home // -// Created by Roy on 2026-03-11 -// Copyright © 2026 TimeSpot, Ltd., All rights reserved. +// Created by Wonji Suh 2026-03-11 // import SwiftUI @@ -78,7 +77,6 @@ extension LocationPermissionOverlay { LocationPermissionOverlay.openSettings() }, onRetryButtonTapped: { - print("Retry tapped") } ) -} \ No newline at end of file +} diff --git a/Projects/Presentation/Home/Sources/Components/NaverMapComponent.swift b/Projects/Presentation/Home/Sources/Components/NaverMapComponent.swift index 16b5f87..e8a4f48 100644 --- a/Projects/Presentation/Home/Sources/Components/NaverMapComponent.swift +++ b/Projects/Presentation/Home/Sources/Components/NaverMapComponent.swift @@ -10,7 +10,9 @@ import SwiftUI import UIKit import CoreLocation import NMapsMap +import DesignSystem import Entity +import LogMacro // 네이버 맵을 SwiftUI에서 사용하기 위한 컴포넌트 public struct NaverMapComponent: UIViewRepresentable { @@ -18,11 +20,26 @@ public struct NaverMapComponent: UIViewRepresentable { let currentLocation: CLLocation? let routeInfo: RouteInfo? let destination: Destination? - let returnToLocation: Bool // 현재 위치로 돌아가기 트리거 + let travelStation: Destination? // 🚉 출발역 정보 + let spots: [ExploreMapSpot] + let selectedSpotID: String? + let returnToLocationTrigger: Int + let autoFitTrigger: Int // 카메라 자동 조정 트리거 + let onSpotTapped: ((String) -> Void)? + let onMapTapped: (() -> Void)? + let onCameraIdle: ((CLLocationCoordinate2D) -> Void)? // 마커와 경로를 저장할 변수들 private static var currentMarker: NMFMarker? private static var destinationMarker: NMFMarker? + private static var spotMarkers: [String: NMFMarker] = [:] + private static let markerImageCache = NSCache() + private static var selectedSpotID: String? + private static var lastSyncedSpotID: String? + private static var lastDestinationKey: String? + private static var lastReturnToLocationTrigger: Int? + private static var lastAutoFitKey: String? + private static var lastAutoFitTrigger: Int? private static var routePath: NMFPath? public init( @@ -30,17 +47,36 @@ public struct NaverMapComponent: UIViewRepresentable { currentLocation: CLLocation?, routeInfo: RouteInfo? = nil, destination: Destination? = nil, - returnToLocation: Bool = false + travelStation: Destination? = nil, + spots: [ExploreMapSpot] = [], + selectedSpotID: String? = nil, + returnToLocationTrigger: Int = 0, + autoFitTrigger: Int = 0, + onSpotTapped: ((String) -> Void)? = nil, + onMapTapped: (() -> Void)? = nil, + onCameraIdle: ((CLLocationCoordinate2D) -> Void)? = nil ) { self.locationPermissionStatus = locationPermissionStatus self.currentLocation = currentLocation self.routeInfo = routeInfo self.destination = destination - self.returnToLocation = returnToLocation + self.travelStation = travelStation + self.spots = spots + self.selectedSpotID = selectedSpotID + self.returnToLocationTrigger = returnToLocationTrigger + self.autoFitTrigger = autoFitTrigger + self.onSpotTapped = onSpotTapped + self.onMapTapped = onMapTapped + self.onCameraIdle = onCameraIdle + } + + public func makeCoordinator() -> Coordinator { + Coordinator(parent: self) } public func makeUIView(context: Context) -> NMFMapView { let mapView = NMFMapView() + context.coordinator.parent = self // 지도 기본 설정 mapView.positionMode = .normal @@ -49,20 +85,55 @@ public struct NaverMapComponent: UIViewRepresentable { mapView.isRotateGestureEnabled = true mapView.isTiltGestureEnabled = true - // 🌙 다크 모드 설정 - mapView.mapType = .navi - mapView.isNightModeEnabled = true + // ☀️ 라이트 모드 설정 + mapView.mapType = .basic + mapView.isNightModeEnabled = false // 🎯 네이버 지도 위치 오버레이 설정 (항상 기본 오버레이 사용) mapView.locationOverlay.hidden = false + mapView.touchDelegate = context.coordinator + mapView.addCameraDelegate(delegate: context.coordinator) + + let (initialLatitude, initialLongitude): (Double, Double) = { + if let _ = routeInfo, + let currentLoc = currentLocation, + let dest = destination { + + var locations: [(lat: Double, lng: Double)] = [] + + // 1. 출발역이 있으면 추가 + if let station = travelStation { + locations.append((lat: station.coordinate.latitude, lng: station.coordinate.longitude)) + } + + // 2. 현재 위치 (출발지) 추가 + locations.append((lat: currentLoc.coordinate.latitude, lng: currentLoc.coordinate.longitude)) + + // 3. 목적지 추가 + locations.append((lat: dest.coordinate.latitude, lng: dest.coordinate.longitude)) + + // 모든 위치의 중심점 계산 + let centerLat = locations.reduce(0.0) { $0 + $1.lat } / Double(locations.count) + let centerLng = locations.reduce(0.0) { $0 + $1.lng } / Double(locations.count) + + return (centerLat, centerLng) + } else { + let lat = destination?.coordinate.latitude + ?? currentLocation?.coordinate.latitude + ?? 37.5666805 + let lng = destination?.coordinate.longitude + ?? currentLocation?.coordinate.longitude + ?? 126.9784147 + return (lat, lng) + } + }() - // 현재 위치가 있으면 그 위치로, 없으면 서울로 초기 설정 - let initialLatitude = currentLocation?.coordinate.latitude ?? 37.5666805 - let initialLongitude = currentLocation?.coordinate.longitude ?? 126.9784147 + // 🗺️ 경로 모드일 때는 더 넓은 범위를 보여주기 위해 줌 레벨 조정 + let zoomLevel: Double = routeInfo != nil ? 7.0 : 15.0 let cameraPosition = NMFCameraPosition( NMGLatLng(lat: initialLatitude, lng: initialLongitude), - zoom: 15 + zoom: zoomLevel ) let cameraUpdate = NMFCameraUpdate(position: cameraPosition) mapView.moveCamera(cameraUpdate) @@ -70,72 +141,280 @@ public struct NaverMapComponent: UIViewRepresentable { return mapView } - public func updateUIView(_ uiView: NMFMapView, context: Context) { - // 기존 마커들과 경로 제거 + public static func dismantleUIView(_ uiView: NMFMapView, coordinator: Coordinator) { + uiView.removeCameraDelegate(delegate: coordinator) + + // 경로와 destination 마커만 정리 Self.currentMarker?.mapView = nil Self.destinationMarker?.mapView = nil Self.routePath?.mapView = nil + Self.currentMarker = nil + Self.destinationMarker = nil + Self.routePath = nil + + Self.lastSyncedSpotID = nil + Self.lastDestinationKey = nil + Self.lastReturnToLocationTrigger = nil + Self.lastAutoFitKey = nil + } + + public func updateUIView(_ uiView: NMFMapView, context: Context) { + context.coordinator.parent = self + + let shouldReturnToLocation = + currentLocation != nil + && Self.lastReturnToLocationTrigger != returnToLocationTrigger + let shouldAutoFit = Self.lastAutoFitTrigger != autoFitTrigger && autoFitTrigger > 0 + let shouldPrioritizeCurrentLocation = shouldReturnToLocation + let autoFitKey = makeAutoFitKey(destination: destination, spots: spots) + Self.routePath?.mapView = nil + Self.routePath = nil // 위치 권한이 허용되었고 현재 위치가 있을 때 - 항상 현재 위치 마커 표시 if (locationPermissionStatus == .authorizedWhenInUse || locationPermissionStatus == .authorizedAlways), let location = currentLocation { // 현재 위치로 돌아가기 버튼이 눌렸을 때만 카메라 이동 - if returnToLocation { - let cameraPosition = NMFCameraPosition( - NMGLatLng(lat: location.coordinate.latitude, lng: location.coordinate.longitude), - zoom: 16 + if shouldReturnToLocation { + Self.lastReturnToLocationTrigger = returnToLocationTrigger + Self.lastAutoFitKey = autoFitKey + + let target = NMGLatLng( + lat: location.coordinate.latitude, + lng: location.coordinate.longitude + ) + + let zoomLevel: Double = routeInfo != nil ? 7.0 : 16.0 + + moveCamera( + on: uiView, + to: target, + zoom: zoomLevel ) - let cameraUpdate = NMFCameraUpdate(position: cameraPosition) - cameraUpdate.animationDuration = 0.8 - uiView.moveCamera(cameraUpdate) } - // 경로가 있을 때는 출발점에 빨간색 마커도 추가로 표시 + // 경로가 있을 때는 출발점에 startLocation 이미지 마커 표시 if routeInfo != nil { - let currentMarker = NMFMarker() - currentMarker.position = NMGLatLng(lat: location.coordinate.latitude, lng: location.coordinate.longitude) - - // 네이버 기본 마커 (빨간색) - currentMarker.iconTintColor = UIColor.red - currentMarker.mapView = uiView - Self.currentMarker = currentMarker + if Self.currentMarker == nil { + let currentMarker = NMFMarker() + currentMarker.zIndex = 900 // 🎯 출발점 마커도 높은 z-index 설정 (destination보다 낮음) + currentMarker.touchHandler = { _ in + Self.setSelectedSpotID(nil) + onMapTapped?() + return true + } + currentMarker.mapView = uiView + Self.currentMarker = currentMarker + } + + if let currentMarker = Self.currentMarker { + // 여러 방법으로 이미지 로딩 시도 + var startLocationImage: UIImage? + + // 방법 1: UIImage(assetName:) + startLocationImage = UIImage(assetName: "startLocation") + if startLocationImage == nil { + // 방법 2: UIImage(.startLocation) + startLocationImage = UIImage(.startLocation) + } + + if let image = startLocationImage { + + // SVG를 PNG로 렌더링해서 사용 + if let pngImage = image.pngData().flatMap({ UIImage(data: $0) }) { + currentMarker.iconImage = NMFOverlayImage(image: pngImage) + currentMarker.width = 32 + currentMarker.height = 38 + // 커스텀 이미지 사용 시 틴트 색상 제거 + currentMarker.iconTintColor = UIColor.clear + } else { + // PNG 변환 실패시 원본 이미지 사용 + currentMarker.iconImage = NMFOverlayImage(image: image) + currentMarker.width = 32 + currentMarker.height = 38 + currentMarker.iconTintColor = UIColor.clear + } + } else { + currentMarker.iconTintColor = UIColor.systemBlue // 파란색으로 구분 + currentMarker.width = CGFloat(NMF_MARKER_SIZE_AUTO) + currentMarker.height = CGFloat(NMF_MARKER_SIZE_AUTO) + } + } + + Self.currentMarker?.position = NMGLatLng( + lat: location.coordinate.latitude, + lng: location.coordinate.longitude + ) + Self.currentMarker?.mapView = uiView - print("🔴 [NaverMap] 출발점 빨간색 마커 추가 (텍스트 없이)") } else { - print("🎯 [NaverMap] 네이버 기본 위치 오버레이만 사용") + Self.currentMarker?.mapView = nil + Self.currentMarker = nil } + } else { + Self.currentMarker?.mapView = nil + Self.currentMarker = nil } - // 목적지 마커 추가 (네이버 3D 기본 마커 - 초록색) + // 목적지 마커 추가 (경로 찾기일 때는 endLocation 이미지, 아니면 기본 네이버 마커) if let destination = destination { - let destinationMarker = NMFMarker() - destinationMarker.position = NMGLatLng( - lat: destination.coordinate.latitude, - lng: destination.coordinate.longitude - ) + let destinationKey = "\(destination.coordinate.latitude),\(destination.coordinate.longitude),\(destination.name)" + let isRouteMode = routeInfo != nil + + if Self.destinationMarker == nil { + let destinationMarker = NMFMarker() + destinationMarker.touchHandler = { _ in + Self.setSelectedSpotID(nil) + onMapTapped?() + return true + } + destinationMarker.mapView = uiView + Self.destinationMarker = destinationMarker + } + + // 마커 스타일을 routeInfo 상태에 따라 업데이트 + if let destinationMarker = Self.destinationMarker { + // 먼저 기존 설정 초기화 + destinationMarker.width = CGFloat(NMF_MARKER_SIZE_AUTO) + destinationMarker.height = CGFloat(NMF_MARKER_SIZE_AUTO) + + if isRouteMode { + // 경로 찾기 모드일 때는 endLocation 이미지 사용 + var endLocationImage: UIImage? + + // 방법 1: UIImage(assetName:) + endLocationImage = UIImage(assetName: "endLocation") + if endLocationImage == nil { + // 방법 2: UIImage(.endLocation) + endLocationImage = UIImage(.endLocation) + } + + if let image = endLocationImage { + + // 강제로 새 마커 생성해서 확실히 적용 + let newDestinationMarker = NMFMarker() + newDestinationMarker.iconImage = NMFOverlayImage(image: image) + newDestinationMarker.width = 32 + newDestinationMarker.height = 38 + newDestinationMarker.zIndex = 1000 // 🎯 최우선 표시를 위해 높은 z-index 설정 + newDestinationMarker.position = NMGLatLng( + lat: destination.coordinate.latitude, + lng: destination.coordinate.longitude + ) + newDestinationMarker.touchHandler = { _ in + Self.setSelectedSpotID(nil) + onMapTapped?() + return true + } + + // 기존 마커 제거 후 새 마커 추가 + Self.destinationMarker?.mapView = nil + Self.destinationMarker = newDestinationMarker + newDestinationMarker.mapView = uiView + } else { + destinationMarker.iconTintColor = UIColor.systemOrange // 오렌지색으로 구분 + destinationMarker.width = CGFloat(NMF_MARKER_SIZE_AUTO) + destinationMarker.height = CGFloat(NMF_MARKER_SIZE_AUTO) + } + } else { + // 일반 모드일 때는 stationLocation 이미지 사용 + if let stationLocationImage = UIImage(assetName: "stationLocation") ?? UIImage(.stationLocation) { + destinationMarker.iconImage = NMFOverlayImage(image: stationLocationImage) + destinationMarker.width = 36 + destinationMarker.height = 44 + destinationMarker.iconTintColor = UIColor.clear + } else { + // 이미지 로드 실패시 기본 초록색 마커 사용 + destinationMarker.iconTintColor = UIColor.systemGreen + destinationMarker.width = CGFloat(NMF_MARKER_SIZE_AUTO) + destinationMarker.height = CGFloat(NMF_MARKER_SIZE_AUTO) + } + } + } + + // 경로 모드가 아닐 때만 position 업데이트 (경로 모드에서는 새 마커에서 이미 설정됨) + if !isRouteMode { + Self.destinationMarker?.position = NMGLatLng( + lat: destination.coordinate.latitude, + lng: destination.coordinate.longitude + ) + Self.destinationMarker?.mapView = uiView + } + + // destination이 변경되면 항상 해당 역 중심으로 카메라 이동 + if Self.lastDestinationKey != destinationKey { + Self.lastDestinationKey = destinationKey + moveCamera( + on: uiView, + to: NMGLatLng( + lat: destination.coordinate.latitude, + lng: destination.coordinate.longitude + ), + zoom: 15 + ) + } + } else { + Self.destinationMarker?.mapView = nil + Self.destinationMarker = nil + Self.lastDestinationKey = nil + } + + let previousSpotID = Self.lastSyncedSpotID + + // 먼저 마커들을 동기화한 후에 선택 상태를 설정 + syncSpotMarkers( + on: uiView, + coordinator: context.coordinator, + onSpotTapped: onSpotTapped + ) - // 네이버 기본 마커 (초록색) - destinationMarker.iconTintColor = UIColor.systemGreen - destinationMarker.mapView = uiView - Self.destinationMarker = destinationMarker + Self.setSelectedSpotID(selectedSpotID) + Self.lastSyncedSpotID = Self.selectedSpotID - print("🗺️ [NaverMap] 목적지 3D 마커 추가 (32x32): \(destination.name)") + if !spots.contains(where: { $0.id == Self.selectedSpotID }) { + Self.setSelectedSpotID(nil) + } + + if !shouldPrioritizeCurrentLocation, + let selectedSpotID = Self.selectedSpotID, + let selectedSpot = spots.first(where: { $0.id == selectedSpotID }) { + let currentCameraTarget = uiView.cameraPosition.target + let shouldMoveToSelectedSpot = + selectedSpotID != previousSpotID + || abs(currentCameraTarget.lat - selectedSpot.coordinate.latitude) > 0.000001 + || abs(currentCameraTarget.lng - selectedSpot.coordinate.longitude) > 0.000001 + + if shouldMoveToSelectedSpot { + moveCamera( + on: uiView, + to: NMGLatLng( + lat: selectedSpot.coordinate.latitude, + lng: selectedSpot.coordinate.longitude + ), + zoom: 17 + ) + } + } else if !shouldPrioritizeCurrentLocation, + routeInfo == nil, + !spots.isEmpty, + Self.lastAutoFitKey != autoFitKey { + Self.lastAutoFitKey = autoFitKey + adjustCameraToFitSpots( + mapView: uiView, + spots: spots, + destination: destination + ) } // 도보 경로 그리기 if let routeInfo = routeInfo, !routeInfo.paths.isEmpty { - print("🗺️ [NaverMap] 경로 정보: \(routeInfo.paths.count)개 좌표, 거리: \(routeInfo.distance)m") - // 경로 좌표들을 NMGLatLng 배열로 변환 let pathCoords = routeInfo.paths.map { coordinate in - print("📍 좌표: \(coordinate.latitude), \(coordinate.longitude)") return NMGLatLng(lat: coordinate.latitude, lng: coordinate.longitude) } // 좌표가 부족한 경우 체크 guard pathCoords.count >= 2 else { - print("🚨 [NaverMap] 경로 좌표가 부족합니다: \(pathCoords.count)개") return } @@ -145,9 +424,9 @@ public struct NaverMapComponent: UIViewRepresentable { pathOverlay.path = lineString as! NMGLineString // 경로 스타일 (실선으로 표시) - pathOverlay.color = UIColor(red: 0.0, green: 0.48, blue: 1.0, alpha: 0.8) // 파란색 + pathOverlay.color = UIColor(hex: "#223B73", alpha: 1.0) // 메인 경로 색상 pathOverlay.width = 8 - pathOverlay.outlineColor = UIColor.white + pathOverlay.outlineColor = UIColor(hex: "#4D6399", alpha: 1.0) // 외곽선 색상 pathOverlay.outlineWidth = 2 pathOverlay.mapView = uiView Self.routePath = pathOverlay @@ -155,12 +434,262 @@ public struct NaverMapComponent: UIViewRepresentable { // 🎯 경로 전체가 보이도록 카메라 조정 (중앙으로) adjustCameraToFitRoute(mapView: uiView, routeCoords: pathCoords, currentLocation: currentLocation) - print("🔵 [NaverMap] 경로 표시 및 카메라 조정 완료") + } + + // autoFitTrigger가 변경되었을 때 카메라 자동 조정 + if shouldAutoFit { + Self.lastAutoFitTrigger = autoFitTrigger + + // 경로가 있으면 경로에 맞게 카메라 조정 + if let routeInfo = routeInfo, !routeInfo.paths.isEmpty { + let pathCoords = routeInfo.paths.map { + NMGLatLng(lat: $0.latitude, lng: $0.longitude) + } + adjustCameraToFitRoute(mapView: uiView, routeCoords: pathCoords, currentLocation: currentLocation) + } + // 경로가 없고 목적지가 있으면 목적지와 현재 위치에 맞게 조정 + else if let destination = destination { + var coords = [NMGLatLng(lat: destination.coordinate.latitude, lng: destination.coordinate.longitude)] + if let currentLocation = currentLocation { + coords.append(NMGLatLng(lat: currentLocation.coordinate.latitude, lng: currentLocation.coordinate.longitude)) + } + if let travelStation = travelStation { + coords.append(NMGLatLng(lat: travelStation.coordinate.latitude, lng: travelStation.coordinate.longitude)) + } + adjustCameraToFitCoordinates(mapView: uiView, coordinates: coords) + } + } + } + + public final class Coordinator: NSObject, NMFMapViewTouchDelegate, NMFMapViewCameraDelegate { + var parent: NaverMapComponent + private var shouldIgnoreNextMapTap = false + + init(parent: NaverMapComponent) { + self.parent = parent + } + + func markMarkerTap() { + shouldIgnoreNextMapTap = true + } + + public func mapView(_ mapView: NMFMapView, didTapMap latlng: NMGLatLng, point: CGPoint) { + if shouldIgnoreNextMapTap { + shouldIgnoreNextMapTap = false + return + } + parent.onMapTapped?() + } + + public func mapViewCameraIdle(_ mapView: NMFMapView) { + let target = mapView.cameraPosition.target + parent.onCameraIdle?( + CLLocationCoordinate2D(latitude: target.lat, longitude: target.lng) + ) } } // MARK: - Helper Functions + private func markerImage(for category: ExploreCategory) -> UIImage { + let asset: ImageAsset + switch category { + case .all: + asset = .etcPin + case .cafe: + asset = .cafePin + case .restaurant: + asset = .foodPin + case .shopping: + asset = .shoppingPin + case .activity: + asset = .gamePin + case .etc: + asset = .etcPin + @unknown default: + asset = .etcPin + } + + // 이미지가 없을 때 기본 마커 이미지 생성 + return UIImage(asset) ?? createDefaultMarkerImage(for: category) + } + + private func createDefaultMarkerImage(for category: ExploreCategory) -> UIImage { + let color: UIColor + switch category { + case .all: + color = .systemGray + case .cafe: + color = .systemBrown + case .restaurant: + color = .systemRed + case .shopping: + color = .systemBlue + case .activity: + color = .systemGreen + case .etc: + color = .systemGray + @unknown default: + color = .systemGray + } + + return create3DMarkerImage(color: color, size: CGSize(width: 20, height: 24)) + } + + private static func applySpotMarkerStyle( + _ marker: NMFMarker, + isSelected: Bool + ) { + marker.width = isSelected ? 36 : 20 + marker.height = isSelected ? 43 : 24 + // 업데이트된 zIndex: 선택된 마커가 최상위에 표시 + marker.zIndex = isSelected ? 600 : 500 + } + + private static func updateSpotMarkerSelection() { + for (spotID, marker) in spotMarkers { + applySpotMarkerStyle(marker, isSelected: spotID == selectedSpotID) + } + } + + private static func setSelectedSpotID(_ newValue: String?) { + guard selectedSpotID != newValue else { return } + + let previousSpotID = selectedSpotID + selectedSpotID = newValue + + if let previousSpotID, let previousMarker = spotMarkers[previousSpotID] { + applySpotMarkerStyle(previousMarker, isSelected: false) + } + + if let newValue, let selectedMarker = spotMarkers[newValue] { + applySpotMarkerStyle(selectedMarker, isSelected: true) + } + } + + private func syncSpotMarkers( + on mapView: NMFMapView, + coordinator: Coordinator, + onSpotTapped: ((String) -> Void)? + ) { + let currentSpotIDs = Set(spots.map(\.id)) + let isRouteMode = routeInfo != nil + + // 경로 찾기 모드일 때는 모든 spot 마커들 숨김 (데이터는 보존) + if isRouteMode { + for (_, marker) in Self.spotMarkers { + marker.mapView = nil // 지도에서만 숨김, 마커 객체는 유지 + } + return // spot 마커 처리 종료 + } + + // 현재 spots에 없는 마커들만 숨김 (기존 마커 전체 제거 방식 개선) + for (markerID, marker) in Self.spotMarkers { + if !currentSpotIDs.contains(markerID) { + marker.mapView = nil // 필터링으로 제외된 마커만 숨김 + } + } + + for spot in spots { + let marker: NMFMarker + + if let existingMarker = Self.spotMarkers[spot.id] { + marker = existingMarker + } else { + let newMarker = NMFMarker() + newMarker.anchor = CGPoint(x: 0.5, y: 1.0) + Self.spotMarkers[spot.id] = newMarker + marker = newMarker + } + + // 마커 위치 설정 + marker.position = NMGLatLng( + lat: spot.coordinate.latitude, + lng: spot.coordinate.longitude + ) + + // 마커 이미지 설정 (실패 시 기본 이미지 사용) + let spotImage = markerImage(for: spot.category) + marker.iconImage = NMFOverlayImage(image: spotImage) + + // 마커 크기와 스타일 적용 + let isSelected = selectedSpotID == spot.id + marker.width = isSelected ? 36 : 20 + marker.height = isSelected ? 43 : 24 + + // spot 마커는 높은 우선순위로 설정 (선택된 마커가 가장 위에) + marker.zIndex = isSelected ? 600 : 500 + + // 마커를 지도에 연결 + marker.mapView = mapView + + // 마커 터치 핸들러 설정 (for loop 안에서) + marker.touchHandler = { [weak coordinator] _ in + coordinator?.markMarkerTap() + Self.setSelectedSpotID(spot.id) + onSpotTapped?(spot.id) + self.moveCamera( + on: mapView, + to: NMGLatLng( + lat: spot.coordinate.latitude, + lng: spot.coordinate.longitude + ), + zoom: 17 + ) + return true + } + } + + // 디버깅: 현재 표시된 마커 개수 로그 + let _ = Self.spotMarkers.values.filter { $0.mapView != nil }.count + + // 선택된 스팟이 현재 spots 배열에 없더라도 마커 스타일 유지하고 표시 + if let selectedSpotID = Self.selectedSpotID, + let selectedMarker = Self.spotMarkers[selectedSpotID], + !currentSpotIDs.contains(selectedSpotID) { + + // 마커가 지도에서 제거되었을 수 있으므로 다시 추가 + selectedMarker.mapView = mapView + + // 선택된 스타일 적용 + Self.applySpotMarkerStyle(selectedMarker, isSelected: true) + + // 마커 터치 핸들러 재설정 + selectedMarker.touchHandler = { [weak coordinator] _ in + coordinator?.markMarkerTap() + Self.setSelectedSpotID(selectedSpotID) + onSpotTapped?(selectedSpotID) + return true + } + } + } + + private func moveCamera( + on mapView: NMFMapView, + to target: NMGLatLng, + zoom: Double + ) { + let cameraPosition = NMFCameraPosition(target, zoom: zoom) + let cameraUpdate = NMFCameraUpdate(position: cameraPosition) + cameraUpdate.animation = .easeOut + cameraUpdate.animationDuration = 0.45 + mapView.moveCamera(cameraUpdate) + } + + private func makeAutoFitKey( + destination: Destination?, + spots: [ExploreMapSpot] + ) -> String { + let destinationKey = destination.map { + "\($0.name)-\($0.coordinate.latitude)-\($0.coordinate.longitude)" + } ?? "nil" + let spotKey = spots + .map { "\($0.id)-\($0.coordinate.latitude)-\($0.coordinate.longitude)" } + .sorted() + .joined(separator: "|") + return "\(destinationKey)::\(spotKey)" + } + // 3D 효과가 있는 핀 마커 이미지 생성 private func create3DMarkerImage(color: UIColor, size: CGSize) -> UIImage { let renderer = UIGraphicsImageRenderer(size: size) @@ -214,6 +743,37 @@ public struct NaverMapComponent: UIViewRepresentable { } } + // 여러 좌표에 맞게 카메라 조정 + private func adjustCameraToFitCoordinates(mapView: NMFMapView, coordinates: [NMGLatLng]) { + guard let first = coordinates.first else { return } + + var minLat = first.lat + var maxLat = first.lat + var minLng = first.lng + var maxLng = first.lng + + for coord in coordinates { + minLat = min(minLat, coord.lat) + maxLat = max(maxLat, coord.lat) + minLng = min(minLng, coord.lng) + maxLng = max(maxLng, coord.lng) + } + + // 약간의 여백 추가 (15%) + let latPadding = (maxLat - minLat) * 0.15 + let lngPadding = (maxLng - minLng) * 0.15 + + let bounds = NMGLatLngBounds( + southWest: NMGLatLng(lat: minLat - latPadding, lng: minLng - lngPadding), + northEast: NMGLatLng(lat: maxLat + latPadding, lng: maxLng + lngPadding) + ) + + // 카메라를 bounds에 맞게 조정 + let cameraUpdate = NMFCameraUpdate(fit: bounds, paddingInsets: UIEdgeInsets(top: 100, left: 50, bottom: 100, right: 50)) + cameraUpdate.animationDuration = 1.0 + mapView.moveCamera(cameraUpdate) + } + // 경로 전체가 보이도록 카메라 조정 private func adjustCameraToFitRoute(mapView: NMFMapView, routeCoords: [NMGLatLng], currentLocation: CLLocation?) { var allCoords = routeCoords @@ -252,7 +812,55 @@ public struct NaverMapComponent: UIViewRepresentable { cameraUpdate.animationDuration = 1.0 mapView.moveCamera(cameraUpdate) - print("📹 [NaverMap] 경로 전체가 보이도록 카메라 조정 완료") + } + + private func adjustCameraToFitSpots( + mapView: NMFMapView, + spots: [ExploreMapSpot], + destination: Destination? + ) { + var allCoords = spots.map { + NMGLatLng(lat: $0.coordinate.latitude, lng: $0.coordinate.longitude) + } + + if let destination { + allCoords.append( + NMGLatLng( + lat: destination.coordinate.latitude, + lng: destination.coordinate.longitude + ) + ) + } + + guard let first = allCoords.first else { return } + + var minLat = first.lat + var maxLat = first.lat + var minLng = first.lng + var maxLng = first.lng + + for coord in allCoords { + minLat = min(minLat, coord.lat) + maxLat = max(maxLat, coord.lat) + minLng = min(minLng, coord.lng) + maxLng = max(maxLng, coord.lng) + } + + let latPadding = max((maxLat - minLat) * 0.25, 0.0015) + let lngPadding = max((maxLng - minLng) * 0.25, 0.0015) + + let bounds = NMGLatLngBounds( + southWest: NMGLatLng(lat: minLat - latPadding, lng: minLng - lngPadding), + northEast: NMGLatLng(lat: maxLat + latPadding, lng: maxLng + lngPadding) + ) + + let cameraUpdate = NMFCameraUpdate( + fit: bounds, + paddingInsets: UIEdgeInsets(top: 180, left: 48, bottom: 220, right: 48) + ) + cameraUpdate.animation = .easeOut + cameraUpdate.animationDuration = 0.45 + mapView.moveCamera(cameraUpdate) } private func createCircleMarkerImage(color: UIColor, size: CGSize) -> UIImage { @@ -281,6 +889,13 @@ public struct NaverMapComponent: UIViewRepresentable { #Preview { NaverMapComponent( locationPermissionStatus: .authorizedWhenInUse, - currentLocation: CLLocation(latitude: 37.5666805, longitude: 126.9784147) + currentLocation: CLLocation(latitude: 37.5666805, longitude: 126.9784147), + routeInfo: nil as RouteInfo?, + destination: nil as Destination?, + travelStation: nil as Destination?, + spots: [], + selectedSpotID: nil as String?, + returnToLocationTrigger: 0, + autoFitTrigger: 0 ) } diff --git a/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift b/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift index 1e84c41..f660ebe 100644 --- a/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift +++ b/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift @@ -8,6 +8,10 @@ import ComposableArchitecture import TCACoordinators import Profile +import CoreLocation +import Entity +import UseCase +import LogMacro @Reducer public struct HomeCoordinator { @@ -21,6 +25,22 @@ public struct HomeCoordinator { public init() { self.routes = [.root(.home(.init()), embedInNavigationView: true)] } + + // 푸쉬 알림용 이니셜라이저 + public init(withRouteNotification: Bool, deepLink: String? = nil) { + if withRouteNotification, let deepLink = deepLink { + // 딥링크에 따른 알림 타입 결정 + let notificationType = NotificationType.from(deepLink: deepLink) + + // 홈을 root로 하고 RouteNotificationView를 즉시 push (스와이프 뒤로가기 지원) + self.routes = [ + .root(.home(.init()), embedInNavigationView: true), + .push(.routeNotification(.init(notificationType: notificationType))) + ] + } else { + self.routes = [.root(.home(.init()), embedInNavigationView: true)] + } + } } public enum Action { @@ -49,6 +69,9 @@ public struct HomeCoordinator { case presentProfile case presentProfileWithAnimation case presentExplore + case presentExploreList(ExploreFeature.State) + case presentExploreDetail + case presentRouteFromPushNotification(String) } // MARK: - NavigationAction @@ -98,12 +121,83 @@ extension HomeCoordinator { case .routeAction(id: _, action: .home(.delegate(.presentAuth))): return .send(.navigation(.presentAuth)) + case let .routeAction(id: id, action: .explore(.delegate(.presentExploreList))): + guard state.routes.indices.contains(id) else { + return .none + } + + switch state.routes[id] { + case let .push(.explore(exploreState)): + return .send(.inner(.presentExploreList(exploreState))) + default: + return .none + } + + case let .routeAction(id: id, action: .explore(.delegate(.presentExplorerDetail))): + guard state.routes.indices.contains(id) else { + return .none + } + + switch state.routes[id] { + case .push(.explore): + return .send(.inner(.presentExploreDetail)) + default: + return .none + } + + case let .routeAction(id: id, action: .exploreList(.delegate(.presentExploreMapAtCurrentLocation))): + guard state.routes.indices.contains(id) else { + return .none + } + + let exploreIndex = id - 1 + guard exploreIndex >= 0, + state.routes.indices.contains(exploreIndex) else { + return .send(.view(.backAction)) + } + + switch state.routes[exploreIndex] { + case .push(.explore): + state.routes.goBack() + return .send( + .router( + .routeAction( + id: exploreIndex, + action: .explore(.view(.returnToCurrentLocation)) + ) + ) + ) + + default: + return .send(.view(.backAction)) + } + + case .routeAction(id: _, action: .exploreList(.delegate(.presentExploreDetail))): + return .send(.inner(.presentExploreDetail)) + 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: .explore(.delegate(.presentRoute))): + state.routes.push(.route(.init())) + return .none + + case .routeAction(id: _, action: .exploreDetail(.delegate(.presentRoute))): + state.routes.push(.route(.init())) + return .none + + case .routeAction(id: _, action: .routeNotification(.delegate(.presentRoute))): + state.routes.push(.route(.init())) + return .none + + case .routeAction(id: _, action: .routeNotification(.delegate(.closeNotification))): + return .send(.view(.backAction)) + + case .routeAction(id: _, action: .routeNotification(.delegate(.presentHome))): + return .send(.view(.backAction)) default: return .none @@ -159,7 +253,87 @@ extension HomeCoordinator { return .none case .presentExplore: - state.routes.push(.explore(.init())) + var exploreState = ExploreFeature.State() + if let lat = exploreState.userSession.travelStationLat, + let lng = exploreState.userSession.travelStationLng { + exploreState.selectedDestination = Destination( + name: exploreState.userSession.travelStationName, + coordinate: CLLocationCoordinate2D(latitude: lat, longitude: lng) + ) + } + state.routes.push(.explore(exploreState)) + return .none + + case let .presentExploreList(exploreState): + var exploreListState = ExploreListFeature.State() + exploreListState.searchText = exploreState.searchText + exploreListState.selectedCategory = exploreState.selectedCategory + exploreListState.currentLocation = exploreState.currentLocation?.coordinate + exploreListState.markerLat = exploreState.mapCenterLat ?? exploreState.searchMarkerLat + exploreListState.markerLon = exploreState.mapCenterLon ?? exploreState.searchMarkerLon + + let hasFullyLoadedMarkerData = + !exploreState.spots.isEmpty + && exploreState.spots.allSatisfy(\.hasDetail) + + if hasFullyLoadedMarkerData { + exploreListState.bufferedSpots = exploreState.spots + exploreListState.spots = Array( + exploreState.spots.prefix(ExploreListFeature.State.pageChunkSize) + ) + exploreListState.currentPage = exploreState.currentPage + exploreListState.hasNextPage = exploreState.hasNextPage + exploreListState.hasLoadedInitialPage = true + } + + state.routes.push(.exploreList(exploreListState)) + return .none + + case .presentExploreDetail: + state.routes.push(.exploreDetail(.init())) + return .none + + case let .presentRouteFromPushNotification(deepLink): + #logDebug("🚀 HomeCoordinator: presentRouteFromPushNotification 액션 처리 시작") + + let notificationType = NotificationType.from(deepLink: deepLink) + #logDebug("📋 HomeCoordinator: 딥링크 = \(deepLink), 알림 타입 = \(notificationType)") + + // 현재 routes 상태 로그 + #logDebug("📍 현재 routes 개수: \(state.routes.count)") + for (index, route) in state.routes.enumerated() { + switch route.screen { + case .home: + #logDebug("Route[\(index)]: home") + case .explore: + #logDebug("Route[\(index)]: explore") + case .exploreList: + #logDebug("Route[\(index)]: exploreList") + case .exploreDetail: + #logDebug("Route[\(index)]: exploreDetail") + case .profile: + #logDebug("Route[\(index)]: profile") + case .route: + #logDebug("Route[\(index)]: route (기존)") + case .routeNotification: + #logDebug("Route[\(index)]: routeNotification (기존)") + } + } + + // 기존 route 또는 routeNotification 화면이 있으면 제거하고 새로 추가 + let removedCount = state.routes.count + state.routes.removeAll { route in + switch route.screen { + case .route, .routeNotification: + return true + default: + return false + } + } + #logDebug("🗑️ 기존 route/routeNotification 화면 제거됨. 제거 전: \(removedCount), 제거 후: \(state.routes.count)") + + state.routes.push(.routeNotification(.init(notificationType: notificationType))) + #logDebug("✅ RouteNotificationView 추가 완료. 현재 routes 개수: \(state.routes.count)") return .none } } @@ -170,8 +344,12 @@ extension HomeCoordinator { @Reducer public enum HomeScreen { case home(HomeFeature) - case explore(ExploreReducer) + case explore(ExploreFeature) + case exploreList(ExploreListFeature) + case exploreDetail(ExploreDetailFeature) case profile(ProfileCoordinator) + case route(RouteFeature) + case routeNotification(RouteNotificationFeature) } } diff --git a/Projects/Presentation/Home/Sources/Coordinator/View/HomeCoordinatorView.swift b/Projects/Presentation/Home/Sources/Coordinator/View/HomeCoordinatorView.swift index ca20df5..cc8f761 100644 --- a/Projects/Presentation/Home/Sources/Coordinator/View/HomeCoordinatorView.swift +++ b/Projects/Presentation/Home/Sources/Coordinator/View/HomeCoordinatorView.swift @@ -9,6 +9,8 @@ import SwiftUI import ComposableArchitecture import TCACoordinators import Profile +import UseCase +import LogMacro public struct HomeCoordinatorView: View { @Bindable var store: StoreOf @@ -36,6 +38,22 @@ public struct HomeCoordinatorView: View { removal: .move(edge: .top).combined(with: .opacity) )) + case .exploreList(let exploreListStore): + ExploreListView(store: exploreListStore) + .navigationBarBackButtonHidden() + .transition(.asymmetric( + insertion: .move(edge: .trailing).combined(with: .opacity), + removal: .move(edge: .leading).combined(with: .opacity) + )) + + case .exploreDetail(let exploreDetailStore): + ExploreDetailView(store: exploreDetailStore) + .navigationBarBackButtonHidden() + .transition(.asymmetric( + insertion: .move(edge: .trailing).combined(with: .opacity), + removal: .move(edge: .leading).combined(with: .opacity) + )) + case .profile(let profileStore): ProfileCoordinatorView(store: profileStore) .navigationBarBackButtonHidden() @@ -43,13 +61,28 @@ public struct HomeCoordinatorView: View { insertion: .move(edge: .trailing).combined(with: .opacity), removal: .move(edge: .leading).combined(with: .opacity) )) + + case .route(let routeStore): + RouteView(store: routeStore) + .navigationBarBackButtonHidden() + .transition(.asymmetric( + insertion: .move(edge: .trailing).combined(with: .opacity), + removal: .move(edge: .leading).combined(with: .opacity) + )) + + case .routeNotification(let routeNotificationStore): + RouteNotificationView(store: routeNotificationStore) + .navigationBarBackButtonHidden() + .transition(.opacity) + } } - .animation(.easeInOut(duration: 0.35), value: store.routes.count) + .animation(.easeInOut(duration: 0.1), value: store.routes.count) .transaction { transaction in if store.routes.count > 1 { - transaction.animation = .easeInOut(duration: 0.35) + transaction.animation = .easeInOut(duration: 0.1) } } } } + diff --git a/Projects/Presentation/Home/Sources/Explore/Components/ExploreCategoryChipView.swift b/Projects/Presentation/Home/Sources/Explore/Components/ExploreCategoryChipView.swift new file mode 100644 index 0000000..744e9ca --- /dev/null +++ b/Projects/Presentation/Home/Sources/Explore/Components/ExploreCategoryChipView.swift @@ -0,0 +1,96 @@ +// +// ExploreCategoryChipView.swift +// Home +// + +import SwiftUI + +import DesignSystem +import Entity + +struct ExploreCategoryChipView: View { + let category: ExploreCategory + let isSelected: Bool + let action: () -> Void + + /// 10자 이상인 텍스트에 중간 스페이스 추가 + private func formatLongText(_ text: String) -> String { + guard text.count > 10 else { return text } + + let characters = Array(text) + let midPoint = characters.count / 2 + + // 중간점 근처에서 적절한 위치 찾기 (±2 범위 내) + let searchRange = max(0, midPoint - 2)...min(characters.count - 1, midPoint + 2) + + // 이미 스페이스가 있는 위치 찾기 + if searchRange.first(where: { characters[$0] == " " }) != nil { + return text + } + + // 스페이스가 없으면 중간에 스페이스 추가 + let insertIndex = midPoint + var result = characters + result.insert(" ", at: insertIndex) + + return String(result) + } + + var body: some View { + Button(action: action) { + HStack(spacing: 4) { + categoryIcon + + Text(formatLongText(category.title)) + .pretendardCustomFont(textStyle: .body2Medium) + .foregroundStyle(isSelected ? .staticBlack : .gray700) + } + .padding(.vertical, 10) + .padding(.horizontal, 16) + .background(isSelected ? .orange200 : .staticWhite) + .overlay { + Capsule() + .stroke(isSelected ? .orange800 : .gray300, lineWidth: 1) + } + .clipShape(Capsule()) + .shadow(color: .black.opacity(isSelected ? 0.04 : 0.08), radius: 8, y: 2) + } + .buttonStyle(.plain) + } + + @ViewBuilder + private var categoryIcon: some View { + switch category { + case .all: + Image(asset: isSelected ? .tapAll : .all) + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + case .cafe: + Image(asset: isSelected ? .tapCaffe : .cafe) + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + case .shopping: + Image(asset: isSelected ? .tapShopping : .shopping) + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + case .restaurant: + Image(asset: isSelected ? .tapFood : .food) + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + case .activity: + Image(asset: isSelected ? .tapGame : .game) + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + case .etc: + Image(asset: isSelected ? .tapEtc : .etc) + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + } + } +} diff --git a/Projects/Presentation/Home/Sources/Explore/Components/ExploreFloatingControlsView.swift b/Projects/Presentation/Home/Sources/Explore/Components/ExploreFloatingControlsView.swift new file mode 100644 index 0000000..c183cb2 --- /dev/null +++ b/Projects/Presentation/Home/Sources/Explore/Components/ExploreFloatingControlsView.swift @@ -0,0 +1,77 @@ +// +// ExploreFloatingControlsView.swift +// Home +// + +import SwiftUI + +import DesignSystem + +struct ExploreFloatingControlsView: View { + private let listButtonWidth: CGFloat = 100 + private let currentLocationButtonWidth: CGFloat = 48 + private let buttonsSpacing: CGFloat = 80 + + let showsListButton: Bool + let controlsBottomPadding: CGFloat + let onListTap: () -> Void + let onCurrentLocationTap: () -> Void + + var body: some View { + Group { + if showsListButton { + ZStack { + listButton + .frame(maxWidth: .infinity, alignment: .center) + + currentLocationButton + .offset(x: currentLocationOffset) + } + .frame(maxWidth: .infinity) + } else { + HStack { + Spacer() + currentLocationButton + } + } + } + .padding(.horizontal, 16) + .padding(.bottom, controlsBottomPadding) + } + + private var currentLocationOffset: CGFloat { + (listButtonWidth / 2) + buttonsSpacing + (currentLocationButtonWidth / 2) + } + + private var listButton: some View { + Button(action: onListTap) { + HStack(spacing: 6) { + Image(systemName: "list.bullet") + .font(.system(size: 14, weight: .semibold)) + Text("목록보기") + .pretendardCustomFont(textStyle: .body2Medium) + } + .foregroundStyle(.gray830) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .frame(width: listButtonWidth, height: 38) + .background(.staticWhite) + .clipShape(Capsule()) + .shadow(color: .black.opacity(0.12), radius: 8, y: 2) + } + .buttonStyle(.plain) + } + + private var currentLocationButton: some View { + Button(action: onCurrentLocationTap) { + Image(asset: .location) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + .frame(width: currentLocationButtonWidth, height: currentLocationButtonWidth) + .background(.staticWhite, in: Circle()) + .shadow(color: .black.opacity(0.12), radius: 8, y: 2) + } + .buttonStyle(.plain) + } +} diff --git a/Projects/Presentation/Home/Sources/Explore/Components/ExploreSearchHeaderView.swift b/Projects/Presentation/Home/Sources/Explore/Components/ExploreSearchHeaderView.swift new file mode 100644 index 0000000..5791918 --- /dev/null +++ b/Projects/Presentation/Home/Sources/Explore/Components/ExploreSearchHeaderView.swift @@ -0,0 +1,205 @@ +// +// ExploreSearchHeaderView.swift +// Home +// + +import SwiftUI + +import DesignSystem +import Entity + +struct ExploreSearchHeaderView: View { + let stationName: String + let searchText: String + let selectedCategory: ExploreCategory + let showCategories: Bool + let isSearchable: Bool // 검색 가능 여부 + let onBackTap: () -> Void + let onSearchTextChanged: ((String) -> Void)? + let onCategoryTap: ((ExploreCategory) -> Void)? + let onSearchBarTap: (() -> Void)? + + init( + stationName: String, + searchText: String = "", + selectedCategory: ExploreCategory = .all, + showCategories: Bool = false, + isSearchable: Bool = false, + onBackTap: @escaping () -> Void, + onSearchTextChanged: ((String) -> Void)? = nil, + onCategoryTap: ((ExploreCategory) -> Void)? = nil, + onSearchBarTap: (() -> Void)? = nil + ) { + self.stationName = stationName + self.searchText = searchText + self.selectedCategory = selectedCategory + self.showCategories = showCategories + self.isSearchable = isSearchable + self.onBackTap = onBackTap + self.onSearchTextChanged = onSearchTextChanged + self.onCategoryTap = onCategoryTap + self.onSearchBarTap = onSearchBarTap + } + + /// 10자 이상인 텍스트에 중간 스페이스 추가 + private func formatLongText(_ text: String) -> String { + guard text.count > 10 else { return text } + + let characters = Array(text) + let midPoint = characters.count / 2 + + // 중간점 근처에서 적절한 위치 찾기 (±2 범위 내) + let searchRange = max(0, midPoint - 2)...min(characters.count - 1, midPoint + 2) + + // 이미 스페이스가 있는 위치 찾기 + if searchRange.first(where: { characters[$0] == " " }) != nil { + return text + } + + // 스페이스가 없으면 중간에 스페이스 추가 + let insertIndex = midPoint + var result = characters + result.insert(" ", at: insertIndex) + + return String(result) + } + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 12) { + backButton() + searchBar() + } + + if showCategories { + categoryScrollView() + .padding(.top, 12) + } + } + } + + @ViewBuilder + private func backButton() -> some View { + Button(action: onBackTap) { + Image(asset: .leftArrow) + .resizable() + .scaledToFit() + .frame(width: 48, height: 48) + .background(.staticWhite) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.05), radius: 10, y: 2) + } + .buttonStyle(.plain) + } + + @ViewBuilder + private func searchBar() -> some View { + if isSearchable { + // 검색 가능한 TextField 형태 + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(.gray600) + + ZStack(alignment: .leading) { + if searchText.isEmpty { + Text("\(formatLongText(stationName))") + .pretendardCustomFont(textStyle: .titleRegular) + .foregroundStyle(.gray600) + } + + TextField( + "", + text: Binding( + get: { searchText }, + set: { newValue in + onSearchTextChanged?(newValue) + } + ) + ) + .pretendardCustomFont(textStyle: .titleRegular) + .foregroundStyle(.staticBlack) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + } + .padding(.horizontal, 24) + .frame(height: 48) + .background(.staticWhite) + .clipShape(RoundedRectangle(cornerRadius: 28)) + .shadow(color: .black.opacity(0.04), radius: 8, y: 2) + } else { + HStack { + Text("\(formatLongText(stationName))") + .pretendardFont(family: .Medium, size: 18) + .foregroundStyle(.staticBlack) + + Spacer() + } + .padding(.horizontal, 24) + .frame(height: 48) + .background(.staticWhite) + .clipShape(RoundedRectangle(cornerRadius: 28)) + .shadow(color: .black.opacity(0.04), radius: 8, y: 2) + } + } + + @ViewBuilder + private func categoryScrollView() -> some View { + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(ExploreCategory.allCases, id: \.self) { category in + ExploreCategoryChipView( + category: category, + isSelected: selectedCategory == category, + action: { onCategoryTap?(category) } + ) + .id(category) + } + } + .padding(.horizontal, 2) + } + .onAppear { + scrollToCategory(selectedCategory, with: proxy, animated: false) + } + .onChange(of: selectedCategory) { _, category in + DispatchQueue.main.async { + scrollToCategory(category, with: proxy) + } + } + } + } + + private func scrollToCategory( + _ category: ExploreCategory, + with proxy: ScrollViewProxy, + animated: Bool = true + ) { + let targetCategory: ExploreCategory + switch category { + case .all, .cafe: + targetCategory = .all + case .restaurant: + targetCategory = .cafe + case .shopping: + targetCategory = .restaurant + case .activity: + targetCategory = .shopping + case .etc: + targetCategory = .activity + } + + let action = { + proxy.scrollTo(targetCategory, anchor: .leading) + } + + if animated { + withAnimation(.easeInOut(duration: 0.2)) { + action() + } + } else { + action() + } + } +} diff --git a/Projects/Presentation/Home/Sources/Explore/Components/ExploreSelectedSpotCardView.swift b/Projects/Presentation/Home/Sources/Explore/Components/ExploreSelectedSpotCardView.swift new file mode 100644 index 0000000..9def694 --- /dev/null +++ b/Projects/Presentation/Home/Sources/Explore/Components/ExploreSelectedSpotCardView.swift @@ -0,0 +1,239 @@ +// +// ExploreSelectedSpotCardView.swift +// Home +// + +import SwiftUI + +import DesignSystem +import Entity +import Kingfisher +import Utill +import ComposableArchitecture +import LogMacro + +struct ExploreSelectedSpotCardView: View { + let currentSpot: ExploreMapSpot + let adjacentSpot: ExploreMapSpot? + let store: StoreOf + let currentOffset: CGFloat + let adjacentOffset: CGFloat? + let cardOpacity: Double + let onCardTap: () -> Void + let onRouteTap: () -> Void + let onDragChanged: (DragGesture.Value) -> Void + let onDragEnded: (DragGesture.Value) -> Void + + var body: some View { + ZStack { + if let adjacentSpot, let adjacentOffset { + cardContent(for: adjacentSpot) + .offset(x: adjacentOffset) + .allowsHitTesting(false) + } + + cardContent(for: currentSpot) + .offset(x: currentOffset) + .opacity(cardOpacity) + } + .gesture( + DragGesture(minimumDistance: 20) + .onChanged(onDragChanged) + .onEnded(onDragEnded) + ) + } + + private func cardContent(for spot: ExploreMapSpot) -> some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 0) { + // 뱃지는 visitable 상태와 무관하게 항상 표시 + if !spot.badgeText.isEmpty { + Text(formatLongText(spot.badgeText)) + .pretendardCustomFont(textStyle: .caption) + .foregroundStyle(.orange800) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(.orange200) + .clipShape(Capsule()) + .padding(.bottom, 12) + } + + HStack(alignment: .top, spacing: 6) { + Text(formattedDisplayName(for: spot)) + .pretendardFont(family: .SemiBold, size: 18) + .foregroundStyle(.staticBlack) + .lineLimit(titleLineLimit(for: spot)) + .minimumScaleFactor(0.7) + .truncationMode(.tail) + .fixedSize(horizontal: false, vertical: true) + .layoutPriority(1) + + if !spot.subtitle.isEmpty { + Text(formatLongText(spot.subtitle)) + .pretendardCustomFont(textStyle: .caption) + .foregroundStyle(.gray650) + .lineLimit(1) + .fixedSize() + .padding(.top, 2) + } + + Spacer(minLength: 0) + } + .frame(minHeight: titleMinHeight(for: spot), alignment: .topLeading) + .padding(.bottom, 4) + + if !spot.statusText.isEmpty || !spot.closingText.isEmpty { + HStack(spacing: 12) { + if !spot.statusText.isEmpty { + Text(formatLongText(spot.statusText)) + .pretendardCustomFont(textStyle: .body2Medium) + .foregroundStyle(.gray850) + } + + if !spot.closingText.isEmpty { + Text(formatLongText(spot.closingText)) + .pretendardCustomFont(textStyle: .body2Regular) + .foregroundStyle(.gray750) + .lineLimit(1) + } + } + .padding(.bottom, 10) + } + + if !spot.distanceText.isEmpty || !spot.walkTimeText.isEmpty { + HStack(spacing: 8) { + if !spot.distanceText.isEmpty { + Text(formatLongText(spot.distanceText)) + .pretendardCustomFont(textStyle: .bodyBold) + .foregroundStyle(.gray830) + } + + if !spot.walkTimeText.isEmpty { + Text(formatLongText(spot.walkTimeText)) + .pretendardCustomFont(textStyle: .bodyRegular) + .foregroundStyle(.gray830) + .lineLimit(1) + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + spotImage(for: spot) + } + .contentShape(Rectangle()) + .onTapGesture(perform: onCardTap) + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 20) + + Button(action: spot.visitable ? onRouteTap : {}) { + Text(spot.visitable ? "경로 확인하기" : "방문 불가") + .pretendardCustomFont(textStyle: .bodyBold) + .foregroundStyle(.staticWhite) + .frame(maxWidth: .infinity) + .frame(height: 55) + .background(spot.visitable ? .navy900 : .gray500) + .clipShape(RoundedRectangle(cornerRadius: 25)) + } + .buttonStyle(.plain) + .disabled(!spot.visitable) + .padding(.horizontal, 16) + .padding(.bottom, 12) + } + .background(.staticWhite) + .clipShape(RoundedRectangle(cornerRadius: 24)) + .shadow(color: .black.opacity(0.12), radius: 14, y: 4) + .transaction { transaction in + transaction.animation = nil + } + } + + @ViewBuilder + private func spotImage(for spot: ExploreMapSpot) -> some View { + Group { + if let url = imageURL(for: spot) { + KFImage(url) + .placeholder { + imagePlaceholder() + } + .cacheMemoryOnly(false) + .diskCacheExpiration(.days(7)) + .memoryCacheExpiration(.seconds(300)) + .loadDiskFileSynchronously() + .cancelOnDisappear(true) + .fade(duration: 0.2) + .resizable() + .scaledToFill() + .frame(width: 92, height: 112) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } else { + imagePlaceholder() + } + } + } + + private func titleLineLimit(for spot: ExploreMapSpot) -> Int { + return 1 // 항상 한 줄로 표시 + } + + private func titleMinHeight(for spot: ExploreMapSpot) -> CGFloat { + return 24 // 고정 높이 + } + + /// 10자 이상인 텍스트에 중간 스페이스 추가 + private func formatLongText(_ text: String) -> String { + guard text.count > 10 else { return text } + + let characters = Array(text) + let midPoint = characters.count / 2 + + // 중간점 근처에서 적절한 위치 찾기 (±2 범위 내) + let searchRange = max(0, midPoint - 2)...min(characters.count - 1, midPoint + 2) + + // 이미 스페이스가 있는 위치 찾기 + if searchRange.first(where: { characters[$0] == " " }) != nil { + return text + } + + // 스페이스가 없으면 중간에 스페이스 추가 + let insertIndex = midPoint + var result = characters + result.insert(" ", at: insertIndex) + + return String(result) + } + + private func formattedDisplayName(for spot: ExploreMapSpot) -> String { + return formatLongText(spot.name.formattedPlaceNameForDisplay) + } + + private func imageURL(for spot: ExploreMapSpot) -> URL? { + // 1. 기존 spot.imageURL이 있으면 우선 사용 + if let imageURL = spot.imageURL?.trimmingCharacters(in: .whitespacesAndNewlines), + !imageURL.isEmpty { + if let url = URL(string: imageURL) { + return url + } + let encoded = imageURL.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) + return encoded.flatMap(URL.init(string:)) + } + + + return nil + } + + private func imagePlaceholder() -> some View { + ZStack { + RoundedRectangle(cornerRadius: 16) + .fill(.gray200) + + Image(systemName: "photo") + .font(.system(size: 24, weight: .medium)) + .foregroundStyle(.gray500) + } + .frame(width: 92, height: 112) + } + +} diff --git a/Projects/Presentation/Home/Sources/Explore/Components/ExploreSpotListCardView.swift b/Projects/Presentation/Home/Sources/Explore/Components/ExploreSpotListCardView.swift new file mode 100644 index 0000000..2fed2c5 --- /dev/null +++ b/Projects/Presentation/Home/Sources/Explore/Components/ExploreSpotListCardView.swift @@ -0,0 +1,207 @@ +// +// ExploreSpotListCardView.swift +// Home +// + +import SwiftUI + +import DesignSystem +import Entity +import Kingfisher +import Utill +import ComposableArchitecture +import LogMacro + +struct ExploreSpotListCardView: View { + let spot: ExploreMapSpot + let store: StoreOf + + var body: some View { + Button { + store.send(.view(.spotCardTapped(spot))) + } label: { + HStack(alignment: .top, spacing: 14) { + VStack(alignment: .leading, spacing: 0) { + if !spot.badgeText.isEmpty { + Text(formatLongText(spot.badgeText)) + .pretendardCustomFont(textStyle: .caption) + .foregroundStyle(.orange800) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(.orange200) + .clipShape(Capsule()) + .padding(.bottom, 12) + } + + VStack(alignment: .leading, spacing: 6) { + titleRow + .frame(maxWidth: .infinity, alignment: .leading) + .frame(minHeight: titleMinHeight, alignment: .topLeading) + } + .padding(.bottom, 8) + + HStack(spacing: 10) { + if !spot.statusText.isEmpty { + Text(formatLongText(spot.statusText)) + .pretendardCustomFont(textStyle: .body2Medium) + .foregroundStyle(.gray700) + .fixedSize() + } + + if !spot.closingText.isEmpty { + Text(formatLongText(spot.closingText)) + .pretendardCustomFont(textStyle: .body2Regular) + .foregroundStyle(.gray750) + .lineLimit(1) + .minimumScaleFactor(0.8) + .frame(maxWidth: .infinity, alignment: .leading) + .truncationMode(.tail) + } + } + .padding(.bottom, 10) + + HStack(spacing: 8) { + if !spot.distanceText.isEmpty { + Text(formatLongText(spot.distanceText)) + .pretendardFont(family: .SemiBold, size: 16) + .foregroundStyle(.staticBlack) + .fixedSize() + } + + if !spot.walkTimeText.isEmpty { + Text(formatLongText(spot.walkTimeText)) + .pretendardCustomFont(textStyle: .body2Regular) + .foregroundStyle(.gray830) + .lineLimit(1) + .minimumScaleFactor(0.8) + .frame(maxWidth: .infinity, alignment: .leading) + .truncationMode(.tail) + } + } + + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .leading) + + spotImage(for: spot) + } + .padding(.horizontal, 16) + .padding(.vertical, 16) + .background(.staticWhite) + .clipShape(RoundedRectangle(cornerRadius: 22)) + .overlay { + RoundedRectangle(cornerRadius: 22) + .stroke(.enableColor, lineWidth: 1) + } + } + .buttonStyle(.plain) + } + + @ViewBuilder + private var titleRow: some View { + HStack(alignment: .top, spacing: 6) { + Text(formattedDisplayName) + .font(.pretendardFontFamily(family: .SemiBold, size: 18)) + .foregroundStyle(.staticBlack) + .lineLimit(titleLineLimit) + .minimumScaleFactor(0.7) + .layoutPriority(1) + + if !spot.subtitle.isEmpty { + Text(formatLongText(spot.subtitle)) + .font(.pretendardFontFamily(family: .Medium, size: 14)) + .foregroundStyle(.gray700) + .lineLimit(1) + .fixedSize() + .padding(.top, 2) + } + + Spacer(minLength: 0) + } + } + + private var titleLineLimit: Int { + return 1 // 항상 한 줄로 표시 + } + + private var titleMinHeight: CGFloat { + return 24 // 고정 높이 + } + + /// 10자 이상인 텍스트에 중간 스페이스 추가 + private func formatLongText(_ text: String) -> String { + guard text.count > 10 else { return text } + + let characters = Array(text) + let midPoint = characters.count / 2 + + // 중간점 근처에서 적절한 위치 찾기 (±2 범위 내) + let searchRange = max(0, midPoint - 2)...min(characters.count - 1, midPoint + 2) + + // 이미 스페이스가 있는 위치 찾기 + if searchRange.first(where: { characters[$0] == " " }) != nil { + return text + } + + // 스페이스가 없으면 중간에 스페이스 추가 + let insertIndex = midPoint + var result = characters + result.insert(" ", at: insertIndex) + + return String(result) + } + + private var formattedDisplayName: String { + return formatLongText(spot.name.formattedPlaceNameForDisplay) + } + + @ViewBuilder + private func spotImage(for spot: ExploreMapSpot) -> some View { + if let url = imageURL(for: spot) { + KFImage(url) + .placeholder { + imagePlaceholder() + } + .cacheMemoryOnly(false) + .diskCacheExpiration(.days(7)) + .memoryCacheExpiration(.seconds(300)) + .loadDiskFileSynchronously() + .cancelOnDisappear(true) + .fade(duration: 0.2) + .resizable() + .scaledToFill() + .frame(width: 92, height: 112) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } else { + imagePlaceholder() + } + } + + private func imageURL(for spot: ExploreMapSpot) -> URL? { + // 1. 기존 spot.imageURL이 있으면 우선 사용 + if let imageURL = spot.imageURL?.trimmingCharacters(in: .whitespacesAndNewlines), + !imageURL.isEmpty { + if let url = URL(string: imageURL) { + return url + } + let encoded = imageURL.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) + return encoded.flatMap(URL.init(string:)) + } + + + return nil + } + + private func imagePlaceholder() -> some View { + ZStack { + RoundedRectangle(cornerRadius: 16) + .fill(.gray200) + + Image(systemName: "photo") + .font(.system(size: 22, weight: .medium)) + .foregroundStyle(.gray500) + } + .frame(width: 92, height: 112) + } + +} diff --git a/Projects/Presentation/Home/Sources/Explore/Helpers/CardHelpers.swift b/Projects/Presentation/Home/Sources/Explore/Helpers/CardHelpers.swift new file mode 100644 index 0000000..ec70967 --- /dev/null +++ b/Projects/Presentation/Home/Sources/Explore/Helpers/CardHelpers.swift @@ -0,0 +1,11 @@ +// +// CardHelpers.swift +// Home +// +// Created by Wonji Suh on 3/28/26. +// DEPRECATED: This file is deprecated. Use UIScreen extensions instead. +// + +// This file is kept temporarily to resolve build cache issues. +// All functionality has been moved to UIScreen extensions in Utill module. +// TODO: Remove this file after cleaning derived data and regenerating project. \ No newline at end of file diff --git a/Projects/Presentation/Home/Sources/Explore/Helpers/ExploreHelpers.swift b/Projects/Presentation/Home/Sources/Explore/Helpers/ExploreHelpers.swift new file mode 100644 index 0000000..fd770e1 --- /dev/null +++ b/Projects/Presentation/Home/Sources/Explore/Helpers/ExploreHelpers.swift @@ -0,0 +1,174 @@ +// +// ExploreHelpers.swift +// Home +// +// Created by Wonji Suh on 3/28/26. +// + +import Foundation +import CoreLocation +import Entity + +// MARK: - ExploreHelpers + +public struct ExploreHelpers { + + // MARK: - State Management + + public static func resetPagination(state: inout ExploreFeature.State) { + state.currentPage = 0 + state.hasNextPage = true + state.pendingSelectFirstSpotFromNextPage = false + } + + public static func resetSearchContext( + state: inout ExploreFeature.State, + clearMarker: Bool = true, + preserveSearchText: Bool = false, + preserveSelectedCategory: Bool = false + ) { + if !preserveSearchText { + state.searchText = "" + } + if !preserveSelectedCategory { + state.selectedCategory = .all + } + state.isLoadingPlaces = false + state.hasRequestedPlaces = false + resetPagination(state: &state) + if clearMarker { + state.searchMarkerLat = nil + state.searchMarkerLon = nil + } + } + + public static func clearSelectedSpot(state: inout ExploreFeature.State) { + state.isSpotCardVisible = false + state.cardDragOffset = 0 + state.cardBaseOffset = 0 + state.isCardTransitioning = false + + state.$userSession.withLock { + $0.selectedExploreSpotID = "" + $0.selectedExplorePlaceID = "" + } + } + + // MARK: - Data Calculations + + public static func currentKeyword(state: ExploreFeature.State) -> String { + return state.searchText.trimmingCharacters(in: .whitespacesAndNewlines) + } + + public static func currentCategory(state: ExploreFeature.State) -> ExploreCategory? { + return state.selectedCategory == .all ? nil : state.selectedCategory + } + + + public static func isResolvingSelectedMarkerDetail(state: ExploreFeature.State) -> Bool { + let selectedSpotID = state.userSession.selectedExploreSpotID + guard !selectedSpotID.isEmpty else { return false } + + let spot = state.spots.first { $0.id == selectedSpotID } + return spot?.hasDetail == false + } + + + // MARK: - Filtered Data + + public static func filteredSpots(state: ExploreFeature.State) -> [ExploreMapSpot] { + let query = currentKeyword(state: state) + let filtered = state.spots.filter { spot in + let hasDetail = spot.hasDetail + let matchesCategory = state.selectedCategory == .all || spot.category == state.selectedCategory + let matchesQuery = query.isEmpty || spot.name.localizedCaseInsensitiveContains(query) + return hasDetail && matchesCategory && matchesQuery + } + + guard let currentLocation = state.currentLocation else { + return filtered + } + + return filtered.sorted { lhs, rhs in + let lhsDistance = currentLocation.distance( + from: CLLocation( + latitude: lhs.coordinate.latitude, + longitude: lhs.coordinate.longitude + ) + ) + let rhsDistance = currentLocation.distance( + from: CLLocation( + latitude: rhs.coordinate.latitude, + longitude: rhs.coordinate.longitude + ) + ) + + return lhsDistance < rhsDistance + } + } + + public static func syncSelectedSpot(state: inout ExploreFeature.State) { + guard let selectedSpotID = state.userSession.selectedExploreSpotID.nilIfEmpty else { + return + } + + guard state.spots.contains(where: { $0.id == selectedSpotID }) else { + clearSelectedSpot(state: &state) + return + } + } + + public static func syncSelectionWithFilters(state: inout ExploreFeature.State) { + guard let selectedSpotID = state.userSession.selectedExploreSpotID.nilIfEmpty else { + return + } + + guard let selectedSpot = state.spots.first(where: { $0.id == selectedSpotID && $0.hasDetail }) else { + clearSelectedSpot(state: &state) + return + } + + let matchesCategory = state.selectedCategory == .all || selectedSpot.category == state.selectedCategory + let query = currentKeyword(state: state) + let matchesQuery = query.isEmpty || selectedSpot.name.localizedCaseInsensitiveContains(query) + + if !matchesCategory || !matchesQuery { + clearSelectedSpot(state: &state) + } + } + + public static func filteredCardSpots(state: ExploreFeature.State) -> [ExploreMapSpot] { + let query = currentKeyword(state: state) + let filtered = state.spots.filter { spot in + let hasDetail = spot.hasDetail + let matchesCategory = state.selectedCategory == .all || spot.category == state.selectedCategory + let matchesQuery = query.isEmpty || spot.name.localizedCaseInsensitiveContains(query) + return hasDetail && matchesCategory && matchesQuery + } + + guard let currentLocation = state.currentLocation else { + return filtered + } + + return filtered.sorted { lhs, rhs in + let lhsDistance = currentLocation.distance( + from: CLLocation( + latitude: lhs.coordinate.latitude, + longitude: lhs.coordinate.longitude + ) + ) + let rhsDistance = currentLocation.distance( + from: CLLocation( + latitude: rhs.coordinate.latitude, + longitude: rhs.coordinate.longitude + ) + ) + + return lhsDistance < rhsDistance + } + } + + public static func currentCardSpots(state: ExploreFeature.State) -> [ExploreMapSpot] { + return filteredCardSpots(state: state) + } +} diff --git a/Projects/Presentation/Home/Sources/Explore/Reducer/ExploreFeature.swift b/Projects/Presentation/Home/Sources/Explore/Reducer/ExploreFeature.swift new file mode 100644 index 0000000..2ded208 --- /dev/null +++ b/Projects/Presentation/Home/Sources/Explore/Reducer/ExploreFeature.swift @@ -0,0 +1,916 @@ +// +// ExploreFeature.swift +// Home +// +// Created by wonji suh on 2026-03-12 +// Copyright © 2026 TimeSpot, Ltd., All rights reserved. +// + +import Foundation +import UIKit +import ComposableArchitecture +import CoreLocation +import UseCase +import Entity +import LogMacro +import Utill +import IdentifiedCollections + +@Reducer +public struct ExploreFeature: Sendable { + public init() {} + + + enum CancelID: Hashable { + case startLocationUpdates + case fetchPlaces + case searchRoute + } + + @ObservableState + public struct State: Equatable { + public var locationPermissionStatus: CLAuthorizationStatus = .notDetermined + public var currentLocation: CLLocation? + public var isLocationPermissionDenied: Bool = false + public var locationError: String? + public var placeError: PlaceError? + public var searchText: String = "" + public var isLoadingPlaces: Bool = false + public var hasRequestedPlaces: Bool = false + public var hasFetchedPlacesWithCurrentLocation: Bool = false + public var currentPage: Int = 1 + public var hasNextPage: Bool = true + public var pendingSelectFirstSpotFromNextPage: Bool = false + public var searchMarkerLat: Double? + public var searchMarkerLon: Double? + public var mapCenterLat: Double? + public var mapCenterLon: Double? + @Presents public var alert: AlertState? + @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty + public var spots: [ExploreMapSpot] = [] + + // 길찾기 관련 상태 + public var selectedDestination: Destination? + public var routeInfo: RouteInfo? + public var isLoadingRoute: Bool = false + public var routeError: String? + + // 지도 카메라 제어 + public var shouldReturnToCurrentLocation: Bool = false + public var returnToCurrentLocationTrigger: Int = 0 + public var selectedCategory: ExploreCategory = .all + public var isSpotCardVisible: Bool = false + public var cardDragOffset: CGFloat = 0 + public var cardBaseOffset: CGFloat = 0 + public var isCardTransitioning: Bool = false + + + public init() {} + } + + public enum Action: ViewAction { + case view(View) + case inner(InnerAction) + case async(AsyncAction) + case scope(ScopeAction) + case delegate(DelegateAction) + } + + @CasePathable + public enum ScopeAction { + case alert(PresentationAction) + } + + public enum Alert: Equatable { + case confirmLocationPermission + case cancelLocationPermission + case openSettings + case dismissAlert + } + + @CasePathable + public enum View { + case onAppear + case onDisappear + case requestLocationPermission + case retryLocationPermission + case requestFullAccuracy + case openSettings + case searchTextChanged(String) + case categoryTapped(ExploreCategory) + case spotTapped(String) + case detailTapped + case spotCardChanged(String?) + case cardDragChanged(CGFloat) + case cardDragEnded(CGFloat) + case loadNextSpotPage + case mapCenterChanged(CLLocationCoordinate2D) + case returnToCurrentLocation + } + + public enum InnerAction: Equatable { + case locationPermissionStatusChanged(CLAuthorizationStatus) + case locationUpdated(CLLocation) + case locationUpdateFailed(String) + case fetchPlacesInitialResponse(ExploreSpotPageEntity, usedCurrentLocation: Bool) + case fetchPlacesPageResponse(ExploreSpotPageEntity, request: FetchPlacesRequest) + case fetchPlacesFailed(PlaceError, usedCurrentLocation: Bool) + // 지도 카메라 제어 + case resetCameraFlag + case completeCardSwipe(next: Bool) + case finishCardTransition + } + + public enum AsyncAction: Equatable { + case requestLocationPermission + case requestFullAccuracy + case startLocationUpdates + case stopLocationUpdates + case requestCurrentLocation + case fetchPlaces(page: Int, append: Bool) + } + + + public enum DelegateAction: Equatable { + case presentExploreList + case presentExplorerDetail + case presentRoute + } + + @Dependency(\.getRouteUseCase) var getRouteUseCase + @Dependency(\.placeUseCase) var placeUseCase + @Dependency(\.locationUseCase) var locationUseCase + @Dependency(\.cameraUseCase) var cameraUseCase + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case .view(let viewAction): + return handleViewAction(state: &state, action: viewAction) + + case .inner(let innerAction): + return handleInnerAction(state: &state, action: innerAction) + + case .async(let asyncAction): + return handleAsyncAction(state: &state, action: asyncAction) + + case .scope(let scopeAction): + return handleScopeAction(state: &state, action: scopeAction) + + case .delegate(let delegateAction): + return handleDelegateAction(state: &state, action: delegateAction) + } + } + .ifLet(\.$alert, action: \.scope.alert) + } +} + +extension ExploreFeature { + + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .onAppear: + let shouldBootstrap = state.spots.isEmpty && !state.hasRequestedPlaces + + + if let lat = state.userSession.travelStationLat, + let lng = state.userSession.travelStationLng { + state.selectedDestination = Destination( + name: state.userSession.travelStationName, + coordinate: CLLocationCoordinate2D(latitude: lat, longitude: lng) + ) + } + + guard shouldBootstrap else { + + ExploreHelpers.syncSelectedSpot(state: &state) + return .run { send in + let currentStatus = await locationUseCase.getAuthorizationStatus() + await send(.inner(.locationPermissionStatusChanged(currentStatus))) + } + } + + state.isSpotCardVisible = false + state.hasFetchedPlacesWithCurrentLocation = false + ExploreHelpers.resetSearchContext(state: &state) + ExploreHelpers.syncSelectedSpot(state: &state) + return .merge( + .run { send in + let currentStatus = await locationUseCase.getAuthorizationStatus() + await send(.inner(.locationPermissionStatusChanged(currentStatus))) + }, + .send(.async(.fetchPlaces(page: 1, append: false))) + ) + + case .onDisappear: + return .none + + case .requestLocationPermission: + return .none + + case .retryLocationPermission: + state.isLocationPermissionDenied = false + return .none + + case .requestFullAccuracy: + return .send(.async(.requestFullAccuracy)) + + case .openSettings: + return .run { send in + await MainActor.run { + guard let settingsUrl = URL(string: UIApplication.openSettingsURLString), + UIApplication.shared.canOpenURL(settingsUrl) else { + return + } + UIApplication.shared.open(settingsUrl) + } + } + + case .searchTextChanged(let text): + state.searchText = text + ExploreHelpers.syncSelectionWithFilters(state: &state) + return .none + + case .categoryTapped(let category): + state.selectedCategory = category + ExploreHelpers.syncSelectionWithFilters(state: &state) + return .none + + case .spotTapped(let spotID): + if state.userSession.selectedExploreSpotID == spotID, state.isSpotCardVisible { + ExploreHelpers.clearSelectedSpot(state: &state) + state.searchMarkerLat = nil + state.searchMarkerLon = nil + state.pendingSelectFirstSpotFromNextPage = false + return .cancel(id: CancelID.fetchPlaces) + } + + state.$userSession.withLock { + $0.selectedExploreSpotID = spotID + $0.selectedExplorePlaceID = spotID + } + state.isSpotCardVisible = state.spots.contains(where: { $0.id == spotID && $0.hasDetail }) + + guard !state.spots.contains(where: { $0.id == spotID && $0.hasDetail }), + let markerSpot = state.spots.first(where: { $0.id == spotID }) else { + return .none + } + + state.searchMarkerLat = markerSpot.coordinate.latitude + state.searchMarkerLon = markerSpot.coordinate.longitude + ExploreHelpers.resetSearchContext(state: &state, clearMarker: false) + + return .merge( + .cancel(id: CancelID.fetchPlaces), + .send(.async(.fetchPlaces(page: 1, append: false))) + ) + + case .detailTapped: + guard state.remainingSelectedSpotMinutes > 0 else { + state.alert = AlertState { + TextState("방문 불가능해요") + } actions: { + ButtonState(action: .dismissAlert) { + TextState("확인") + } + } message: { + TextState("남은 체류 시간이 없어서 상세 보기를 열 수 없어요.") + } + return .none + } + return .send(.delegate(.presentExplorerDetail)) + + case .spotCardChanged(let spotID): + if let spotID { + state.$userSession.withLock { + $0.selectedExploreSpotID = spotID + $0.selectedExplorePlaceID = spotID + } + state.isSpotCardVisible = true + } else { + ExploreHelpers.clearSelectedSpot(state: &state) + state.searchMarkerLat = nil + state.searchMarkerLon = nil + state.pendingSelectFirstSpotFromNextPage = false + return .cancel(id: CancelID.fetchPlaces) + } + return .none + + case .cardDragChanged(let offset): + guard !state.isCardTransitioning else { + return .none + } + let limitedOffset = max(min(offset, UIScreen.cardTravelDistance), -UIScreen.cardTravelDistance) + state.cardDragOffset = limitedOffset + return .none + + case .cardDragEnded(let translationWidth): + guard !state.isCardTransitioning else { + return .none + } + + if translationWidth > UIScreen.cardSwipeThreshold { + return .send(.inner(.completeCardSwipe(next: false))) + } + + if translationWidth < -UIScreen.cardSwipeThreshold { + return .send(.inner(.completeCardSwipe(next: true))) + } + + state.cardDragOffset = 0 + return .none + + case .loadNextSpotPage: + guard state.hasNextPage, !state.isLoadingPlaces else { + return .none + } + state.pendingSelectFirstSpotFromNextPage = true + return .send(.async(.fetchPlaces(page: state.currentPage, append: true))) + + case .mapCenterChanged(let coordinate): + state.mapCenterLat = coordinate.latitude + state.mapCenterLon = coordinate.longitude + return .none + + + case .returnToCurrentLocation: + + + // CameraUseCase를 통한 스팟 클리어 처리 + let clearResult = cameraUseCase.clearSelectedSpotForLocationReturn( + selectedSpotID: state.userSession.selectedExploreSpotID, + isCardVisible: state.isSpotCardVisible + ) + + if clearResult.shouldClearSpot { + state.$userSession.withLock { + $0.selectedExploreSpotID = "" + $0.selectedExplorePlaceID = "" + } + } + + if clearResult.shouldDismissCard { + ExploreHelpers.clearSelectedSpot(state: &state) + } + + // 경로만 제거하고 역 목적지 마커는 유지 + state.routeInfo = nil + + // CameraUseCase를 통한 카메라 트리거 처리 + let cameraResult = cameraUseCase.createReturnToCurrentLocationTrigger( + currentTrigger: state.returnToCurrentLocationTrigger, + hasCurrentLocation: state.currentLocation != nil + ) + + if !cameraResult.shouldUpdateTrigger { + state.shouldReturnToCurrentLocation = true + return .send(.async(.requestCurrentLocation)) + } + + state.returnToCurrentLocationTrigger = cameraResult.newTrigger + + return .none + + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case .locationPermissionStatusChanged(let status): + state.locationPermissionStatus = status + + switch status { + case .authorizedWhenInUse, .authorizedAlways: + state.isLocationPermissionDenied = false + state.alert = nil + return .send(.async(.startLocationUpdates)) + case .denied, .restricted: + state.isLocationPermissionDenied = true + state.alert = nil + return .send(.async(.stopLocationUpdates)) + case .notDetermined: + state.isLocationPermissionDenied = false + state.alert = nil + return .none + @unknown default: + return .none + } + + case .locationUpdated(let location): + state.currentLocation = location + + // CameraUseCase를 통한 위치 업데이트 카메라 처리 + let cameraResult = cameraUseCase.handleLocationUpdateForCamera( + shouldReturnToLocation: state.shouldReturnToCurrentLocation, + currentTrigger: state.returnToCurrentLocationTrigger + ) + + if cameraResult.shouldUpdateTrigger { + state.returnToCurrentLocationTrigger = cameraResult.newTrigger + } + + if cameraResult.shouldResetFlag { + state.shouldReturnToCurrentLocation = false + return .none + } + if state.spots.isEmpty, + !state.hasFetchedPlacesWithCurrentLocation, + !state.isLoadingPlaces { + ExploreHelpers.resetSearchContext(state: &state, clearMarker: false) + return .merge( + .cancel(id: CancelID.fetchPlaces), + .cancel(id: CancelID.fetchPlaces), + .send(.async(.fetchPlaces(page: 1, append: false))) + ) + } + return .none + + case .locationUpdateFailed(let error): + #logDebug(" [ExploreReducer] 위치 업데이트 실패: \(error)") + return .none + + case .fetchPlacesInitialResponse(let pageEntity, let usedCurrentLocation): + + state.isLoadingPlaces = false + state.hasRequestedPlaces = false + state.spots = pageEntity.spots + + state.currentPage = pageEntity.currentPage + state.hasNextPage = pageEntity.hasNextPage || pageEntity.spots.contains { !$0.hasDetail } + state.hasFetchedPlacesWithCurrentLocation = usedCurrentLocation + state.$userSession.withLock { + $0.explorePlacesFetchedAt = Date() + } + return .none + + case .fetchPlacesPageResponse(let pageEntity, let request): + state.isLoadingPlaces = false + state.hasRequestedPlaces = false + + let previousDetailedCount = state.spots.filter(\.hasDetail).count + let currentKeyword = ExploreHelpers.currentKeyword(state: state) + let currentCategory = ExploreHelpers.currentCategory(state: state) + let currentMarkerLat: Double? + let currentMarkerLon: Double? + + if ExploreHelpers.isResolvingSelectedMarkerDetail(state: state) { + currentMarkerLat = state.searchMarkerLat + currentMarkerLon = state.searchMarkerLon + } else { + currentMarkerLat = state.mapCenterLat ?? state.userSession.travelStationLat + currentMarkerLon = state.mapCenterLon ?? state.userSession.travelStationLng + } + + guard request.page == 0 || request.append else { + return .none + } + + if request.markerLat != nil || request.markerLon != nil { + guard CLLocationCoordinate2D.isSameCoordinate(request.markerLat, currentMarkerLat), + CLLocationCoordinate2D.isSameCoordinate(request.markerLon, currentMarkerLon) else { + return .none + } + } else { + guard request.keyword == currentKeyword, + request.category == currentCategory, + request.markerLat == currentMarkerLat, + request.markerLon == currentMarkerLon else { + return .none + } + } + + state.hasFetchedPlacesWithCurrentLocation = request.usedCurrentLocation + let newSpots = pageEntity.spots + let mergedSpots: [ExploreMapSpot] + if request.append { + let existingSpotIDs = Set(state.spots.map(\.id)) + let uniqueNewSpots = newSpots.filter { !existingSpotIDs.contains($0.id) } + mergedSpots = state.spots + uniqueNewSpots + } else { + mergedSpots = newSpots + } + state.currentPage = request.page + 1 + state.$userSession.withLock { + $0.explorePlacesFetchedAt = Date() + } + let newDetailedCount = newSpots.filter(\.hasDetail).count + let gainedMoreDetail = newDetailedCount > previousDetailedCount + let shouldKeepBootstrappingDetails = + request.markerLat == nil + && request.markerLon == nil + && request.keyword.isEmpty + && request.category == nil + && newSpots.contains { !$0.hasDetail } + && (request.page == 0 || gainedMoreDetail) + + state.hasNextPage = pageEntity.hasNextPage || shouldKeepBootstrappingDetails + let firstNewSpotID = newSpots.first(where: \.hasDetail)?.id + let selectedSpotID = state.userSession.selectedExploreSpotID.nilIfEmpty + + state.spots = mergedSpots + if let selectedSpotID, + mergedSpots.contains(where: { $0.id == selectedSpotID && $0.hasDetail }) { + state.isSpotCardVisible = true + } else if state.searchMarkerLat != nil { + state.isSpotCardVisible = false + } else { + ExploreHelpers.clearSelectedSpot(state: &state) + } + + if state.pendingSelectFirstSpotFromNextPage, let firstNewSpotID { + state.$userSession.withLock { + $0.selectedExploreSpotID = firstNewSpotID + $0.selectedExplorePlaceID = firstNewSpotID + } + state.isSpotCardVisible = true + state.pendingSelectFirstSpotFromNextPage = false + state.cardBaseOffset = 0 + state.cardDragOffset = 0 + state.isCardTransitioning = false + ExploreHelpers.syncSelectedSpot(state: &state) + return .none + } + + let wasPendingNextPage = state.pendingSelectFirstSpotFromNextPage + state.pendingSelectFirstSpotFromNextPage = false + ExploreHelpers.syncSelectedSpot(state: &state) + + if let selectedSpotID, + state.searchMarkerLat != nil, + !state.spots.contains(where: { $0.id == selectedSpotID && $0.hasDetail }), + state.hasNextPage { + return .send(.async(.fetchPlaces(page: state.currentPage, append: true))) + } + + if let selectedSpotID, + state.searchMarkerLat != nil, + !state.spots.contains(where: { $0.id == selectedSpotID && $0.hasDetail }), + !state.hasNextPage { + ExploreHelpers.clearSelectedSpot(state: &state) + } + + if wasPendingNextPage { + return .send(.inner(.finishCardTransition)) + } + return .none + + case .fetchPlacesFailed(let error, let usedCurrentLocation): + state.placeError = error + state.hasRequestedPlaces = false + ExploreHelpers.resetSearchContext(state: &state, clearMarker: false) + if usedCurrentLocation { + state.hasFetchedPlacesWithCurrentLocation = false + } + state.spots = [] + ExploreHelpers.clearSelectedSpot(state: &state) + return .none + + + case .resetCameraFlag: + let cameraResult = cameraUseCase.resetCameraFlag() + if cameraResult.shouldResetFlag { + state.shouldReturnToCurrentLocation = false + } + return .none + + case .completeCardSwipe(let next): + let cardSpots = state.cardSpots + guard !cardSpots.isEmpty else { + return .none + } + + let currentSelectedID = state.selectedSpot?.id ?? state.userSession.selectedExploreSpotID + let currentIndex = cardSpots.firstIndex(where: { $0.id == currentSelectedID }) ?? 0 + let entryOffset: CGFloat = next ? UIScreen.cardTravelDistance : -UIScreen.cardTravelDistance + let isAtEnd = next && currentIndex == cardSpots.count - 1 + let isAtStart = !next && currentIndex == 0 + + state.isCardTransitioning = true + state.cardDragOffset = next ? -UIScreen.cardTravelDistance : UIScreen.cardTravelDistance + + if isAtEnd { + if state.hasNextPage { + state.isSpotCardVisible = true + state.cardDragOffset = 0 + state.cardBaseOffset = 0 + state.isCardTransitioning = true + return .send(.view(.loadNextSpotPage)) + } + + state.$userSession.withLock { + $0.selectedExploreSpotID = cardSpots[0].id + $0.selectedExplorePlaceID = cardSpots[0].id + } + } else if isAtStart { + state.$userSession.withLock { + $0.selectedExploreSpotID = cardSpots[cardSpots.count - 1].id + $0.selectedExplorePlaceID = cardSpots[cardSpots.count - 1].id + } + } else { + let newIndex = next ? currentIndex + 1 : currentIndex - 1 + state.$userSession.withLock { + $0.selectedExploreSpotID = cardSpots[newIndex].id + $0.selectedExplorePlaceID = cardSpots[newIndex].id + } + } + + state.isSpotCardVisible = true + state.cardBaseOffset = entryOffset + state.cardDragOffset = 0 + + return .run { send in + try await Task.sleep(for: .milliseconds(240)) + await send(.inner(.finishCardTransition)) + } + + case .finishCardTransition: + state.cardBaseOffset = 0 + state.cardDragOffset = 0 + state.isCardTransitioning = false + return .none + + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case .requestLocationPermission: + return .none + + case .requestFullAccuracy: + return .run { send in + await locationUseCase.requestFullAccuracy() + + try await Task.sleep(for: .seconds(1)) + await send(.async(.startLocationUpdates)) + } + + case .startLocationUpdates: + return .run { send in + // UseCase를 통한 위치 업데이트 시작 + await locationUseCase.startLocationUpdates( + onUpdate: { location in + Task { @MainActor in + send(.inner(.locationUpdated(location))) + } + }, + onError: { error in + Task { @MainActor in + send(.inner(.locationUpdateFailed(error.localizedDescription))) + } + } + ) + + // 초기 위치도 가져오기 + do { + if let location = try await locationUseCase.requestCurrentLocation() { + await send(.inner(.locationUpdated(location))) + } + } catch { + await send(.inner(.locationUpdateFailed(error.localizedDescription))) + } + } + .cancellable(id: CancelID.startLocationUpdates, cancelInFlight: true) + + case .stopLocationUpdates: + return .run { send in + await locationUseCase.stopLocationUpdates() + } + + case .requestCurrentLocation: + return .run { send in + do { + if let location = try await locationUseCase.requestCurrentLocation() { + await send(.inner(.locationUpdated(location))) + } else { + await send(.inner(.locationUpdateFailed("위치 정보를 가져올 수 없습니다"))) + } + } catch { + await send(.inner(.locationUpdateFailed(error.localizedDescription))) + } + } + + case .fetchPlaces(let page, let append): + // 초기 로딩인 경우와 페이지네이션인 경우를 구분 + let isInitialLoad = page == 1 && !append + #logDebug("🔍 [fetchPlaces] page=\(page), append=\(append), isInitialLoad=\(isInitialLoad)") + + if isInitialLoad { + // 초기 로딩 조건 + let travelIDExists = Int(state.userSession.travelID) != nil + let stationLatExists = state.userSession.travelStationLat != nil + let stationLngExists = state.userSession.travelStationLng != nil + let notLoading = !state.isLoadingPlaces + let notRequested = !state.hasRequestedPlaces + + + guard travelIDExists, stationLatExists, stationLngExists, notLoading, notRequested else { + return .none + } + + } else { + // 페이지네이션 조건 + let travelIDExists = Int(state.userSession.travelID) != nil + let notLoading = !state.isLoadingPlaces + let notRequested = !state.hasRequestedPlaces + + + guard travelIDExists, notLoading, notRequested else { + return .none + } + } + + state.isLoadingPlaces = true + state.hasRequestedPlaces = true + let userSession = state.userSession + let fallbackLat = state.userSession.travelStationLat ?? 0 + let fallbackLng = state.userSession.travelStationLng ?? 0 + let usedCurrentLocation = state.currentLocation != nil + let userLat = state.currentLocation?.coordinate.latitude ?? fallbackLat + let userLon = state.currentLocation?.coordinate.longitude ?? fallbackLng + + if isInitialLoad { + // 초기 로딩: fetchInitialExploreSpots 사용 + return .run { send in + let result = await Result { + try await placeUseCase.fetchInitialExploreSpots( + userSession: userSession, + userLat: userLat, + userLon: userLon + ) + } + + switch result { + case .success(let entities): + await send(.inner(.fetchPlacesInitialResponse(entities, usedCurrentLocation: usedCurrentLocation))) + case .failure(let error): + await send(.inner(.fetchPlacesFailed(PlaceError.from(error), usedCurrentLocation: usedCurrentLocation))) + } + } + .cancellable(id: CancelID.fetchPlaces, cancelInFlight: true) + } else { + // 페이지네이션: searchExploreSpots 사용 + let rawKeyword = state.searchText.trimmingCharacters(in: .whitespacesAndNewlines) + let isResolvingSelectedMarkerDetail = ExploreHelpers.isResolvingSelectedMarkerDetail(state: state) + let mapLat = isResolvingSelectedMarkerDetail + ? (state.searchMarkerLat ?? state.userSession.travelStationLat ?? fallbackLat) + : (state.mapCenterLat ?? state.userSession.travelStationLat ?? fallbackLat) + let mapLon = isResolvingSelectedMarkerDetail + ? (state.searchMarkerLon ?? state.userSession.travelStationLng ?? fallbackLng) + : (state.mapCenterLon ?? state.userSession.travelStationLng ?? fallbackLng) + let requestedMarkerLat = mapLat + let requestedMarkerLon = mapLon + let keyword = isResolvingSelectedMarkerDetail ? nil : rawKeyword.nilIfEmpty + let category: ExploreCategory? = isResolvingSelectedMarkerDetail + ? nil + : (state.selectedCategory == .all ? nil : state.selectedCategory) + let sortBy = "distanceFromStation,ASC" + let baseSpots = state.spots + + return .run { send in + let result = await Result { + try await placeUseCase.searchExploreSpots( + baseSpots: baseSpots, + userSession: userSession, + userLat: userLat, + userLon: userLon, + keyword: keyword, + category: category, + sort: sortBy, + mapLat: mapLat, + mapLon: mapLon, + page: page + ) + } + + switch result { + case .success(let entity): + let request = FetchPlacesRequest( + page: page, + keyword: rawKeyword, + category: category, + markerLat: requestedMarkerLat, + markerLon: requestedMarkerLon, + append: append, + usedCurrentLocation: usedCurrentLocation + ) + await send(.inner(.fetchPlacesPageResponse(entity, request: request))) + case .failure(let error): + await send(.inner(.fetchPlacesFailed(PlaceError.from(error), usedCurrentLocation: false))) + } + } + .cancellable(id: CancelID.fetchPlaces, cancelInFlight: true) + } + + } + } + + private func handleScopeAction( + state: inout State, + action: ScopeAction + ) -> Effect { + switch action { + case .alert(let alertAction): + return handleAlertAction(state: &state, action: alertAction) + } + } + + private func handleDelegateAction( + state: inout State, + action: DelegateAction + ) -> Effect { + switch action { + case .presentExploreList: + return .none + + case .presentExplorerDetail: + return .none + + case .presentRoute: + // 현재 선택된 스팟의 위도와 경도를 UserSession에 저장 + if let selectedSpot = state.selectedSpot { + state.$userSession.withLock { + // 목적지 정보 저장 + $0.routeDestinationLat = selectedSpot.coordinate.latitude + $0.routeDestinationLng = selectedSpot.coordinate.longitude + $0.routeDestinationName = selectedSpot.name + + // 현재 위치도 함께 저장 (출발지) + if let currentLocation = state.currentLocation { + $0.routeStartLat = currentLocation.coordinate.latitude + $0.routeStartLng = currentLocation.coordinate.longitude + } + } + } + return .none + } + } + + private func handleAlertAction( + state: inout State, + action: PresentationAction + ) -> Effect { + switch action { + case .presented(let alertAction): + switch alertAction { + case .confirmLocationPermission: + state.alert = nil + return .none + + case .cancelLocationPermission: + state.alert = nil + return .none + + case .openSettings: + state.alert = nil + return .send(.view(.openSettings)) + + case .dismissAlert: + state.alert = nil + return .none + } + + case .dismiss: + state.alert = nil + return .none + } + } +} + +private extension ExploreFeature.State { + var remainingSelectedSpotMinutes: Int { + guard let selectedSpot else { + return 0 + } + + let originalMinutes = selectedSpot.originalStayableMinutes + let elapsedMinutes = elapsedMinutesSincePlacesFetched + return max(originalMinutes - elapsedMinutes, 0) + } + + var elapsedMinutesSincePlacesFetched: Int { + guard let fetchedAt = userSession.explorePlacesFetchedAt else { + return 0 + } + + return max(Int(Date().timeIntervalSince(fetchedAt) / 60), 0) + } + +} + +private extension ExploreMapSpot { + var originalStayableMinutes: Int { + let digits = badgeText.compactMap(\.wholeNumberValue) + guard !digits.isEmpty else { return 0 } + return digits.reduce(0) { ($0 * 10) + $1 } + } +} diff --git a/Projects/Presentation/Home/Sources/Explore/Reducer/ExploreReducer.swift b/Projects/Presentation/Home/Sources/Explore/Reducer/ExploreReducer.swift deleted file mode 100644 index 1ceafef..0000000 --- a/Projects/Presentation/Home/Sources/Explore/Reducer/ExploreReducer.swift +++ /dev/null @@ -1,405 +0,0 @@ -// -// ExploreReducer.swift -// Home -// -// Created by wonji suh on 2026-03-12 -// Copyright © 2026 TimeSpot, Ltd., All rights reserved. -// - -import Foundation -import UIKit -import ComposableArchitecture -import CoreLocation -import UseCase -import Entity - -@Reducer -public struct ExploreReducer: Sendable { - public init() {} - - @ObservableState - public struct State: Equatable { - public var locationPermissionStatus: CLAuthorizationStatus = .notDetermined - public var currentLocation: CLLocation? - public var isLocationPermissionDenied: Bool = false - public var locationError: String? - public var searchText: String = "" - @Presents public var alert: AlertState? - @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty - - // 길찾기 관련 상태 - public var selectedDestination: Destination? - public var routeInfo: RouteInfo? - public var isLoadingRoute: Bool = false - public var routeError: String? - - // 지도 카메라 제어 - public var shouldReturnToCurrentLocation: Bool = false - public var selectedCategory: ExploreCategory = .all - - public init() {} - } - - public enum Action: ViewAction { - case view(View) - case inner(InnerAction) - case async(AsyncAction) - case scope(ScopeAction) - } - - @CasePathable - public enum ScopeAction { - case alert(PresentationAction) - } - - public enum Alert: Equatable { - case confirmLocationPermission - case cancelLocationPermission - case openSettings - case dismissAlert - } - - @CasePathable - public enum View { - case onAppear - case onDisappear - case requestLocationPermission - case retryLocationPermission - case requestFullAccuracy - case openSettings - case searchTextChanged(String) - case categoryTapped(ExploreCategory) - // 길찾기 관련 액션 - case searchRouteToGangnam - case clearRoute - case returnToCurrentLocation - } - - public enum InnerAction: Equatable { - case locationPermissionStatusChanged(CLAuthorizationStatus) - case locationUpdated(CLLocation) - case locationUpdateFailed(String) - // 길찾기 관련 액션 - case routeSearchStarted(Destination) - case routeSearchResponse(Result) - // 지도 카메라 제어 - case resetCameraFlag - } - - public enum AsyncAction: Equatable { - case requestLocationPermission - case requestFullAccuracy - case startLocationUpdates - case stopLocationUpdates - // 길찾기 관련 액션 - case searchRoute(from: CLLocationCoordinate2D, to: Destination) - - public static func == (lhs: AsyncAction, rhs: AsyncAction) -> Bool { - switch (lhs, rhs) { - case (.requestLocationPermission, .requestLocationPermission), - (.requestFullAccuracy, .requestFullAccuracy), - (.startLocationUpdates, .startLocationUpdates), - (.stopLocationUpdates, .stopLocationUpdates): - return true - case (.searchRoute(let lhsFrom, let lhsTo), .searchRoute(let rhsFrom, let rhsTo)): - return lhsFrom.latitude == rhsFrom.latitude && - lhsFrom.longitude == rhsFrom.longitude && - lhsTo == rhsTo - default: - return false - } - } - } - - @Dependency(\.getRouteUseCase) var getRouteUseCase - - public var body: some ReducerOf { - Reduce { state, action in - switch action { - case .view(let viewAction): - return handleViewAction(state: &state, action: viewAction) - - case .inner(let innerAction): - return handleInnerAction(state: &state, action: innerAction) - - case .async(let asyncAction): - return handleAsyncAction(state: &state, action: asyncAction) - - case .scope(let scopeAction): - return handleScopeAction(state: &state, action: scopeAction) - } - } - .ifLet(\.$alert, action: \.scope.alert) - } -} - -extension ExploreReducer { - private func handleViewAction( - state: inout State, - action: View - ) -> Effect { - switch action { - case .onAppear: - return .run { send in - let locationManager = await LocationPermissionManager.shared - let currentStatus = await locationManager.authorizationStatus - await send(.inner(.locationPermissionStatusChanged(currentStatus))) - } - - case .onDisappear: - return .send(.async(.stopLocationUpdates)) - - case .requestLocationPermission: - return .none - - case .retryLocationPermission: - state.isLocationPermissionDenied = false - return .none - - case .requestFullAccuracy: - return .send(.async(.requestFullAccuracy)) - - case .openSettings: - return .run { send in - await MainActor.run { - guard let settingsUrl = URL(string: UIApplication.openSettingsURLString), - UIApplication.shared.canOpenURL(settingsUrl) else { - return - } - UIApplication.shared.open(settingsUrl) - } - } - - case .searchTextChanged(let text): - state.searchText = text - return .none - - case .categoryTapped(let category): - state.selectedCategory = category - return .none - - // 길찾기 관련 액션 - case .searchRouteToGangnam: - guard let currentLocation = state.currentLocation else { - state.routeError = "현재 위치를 확인할 수 없습니다" - return .none - } - - let destination = PredefinedDestinations.gangnamStation - return .send(.async(.searchRoute( - from: currentLocation.coordinate, - to: destination - ))) - - case .clearRoute: - state.selectedDestination = nil - state.routeInfo = nil - state.routeError = nil - return .none - - case .returnToCurrentLocation: - // 현재 위치로 지도 중심 이동 - state.shouldReturnToCurrentLocation = true - return .run { send in - // 0.1초 후에 플래그를 리셋 (지도 업데이트 후) - try await Task.sleep(for: .milliseconds(100)) - await send(.inner(.resetCameraFlag)) - } - } - } - - private func handleInnerAction( - state: inout State, - action: InnerAction - ) -> Effect { - switch action { - case .locationPermissionStatusChanged(let status): - state.locationPermissionStatus = status - - switch status { - case .authorizedWhenInUse, .authorizedAlways: - state.isLocationPermissionDenied = false - state.alert = nil - return .send(.async(.startLocationUpdates)) - case .denied, .restricted: - state.isLocationPermissionDenied = true - state.alert = nil - return .send(.async(.stopLocationUpdates)) - case .notDetermined: - state.isLocationPermissionDenied = false - state.alert = nil - return .none - @unknown default: - return .none - } - - case .locationUpdated(let location): - state.currentLocation = location - return .none - - case .locationUpdateFailed(let error): - print("위치 업데이트 실패: \(error)") - return .none - - // 길찾기 관련 액션 - case .routeSearchStarted(let destination): - state.selectedDestination = destination - state.isLoadingRoute = true - state.routeError = nil - return .none - - case .routeSearchResponse(let result): - state.isLoadingRoute = false - switch result { - case .success(let routeInfo): - state.routeInfo = routeInfo - state.routeError = nil - print("✅ 경로 검색 완료: \(routeInfo.distance)m, \(routeInfo.duration)분") - case .failure(let error): - state.routeError = error.localizedDescription - print("🚨 경로 검색 실패: \(error.localizedDescription)") - } - return .none - - case .resetCameraFlag: - state.shouldReturnToCurrentLocation = false - return .none - } - } - - private func handleAsyncAction( - state: inout State, - action: AsyncAction - ) -> Effect { - switch action { - case .requestLocationPermission: - return .none - - case .requestFullAccuracy: - return .run { send in - await MainActor.run { - let locationManager = LocationPermissionManager.shared - locationManager.requestFullAccuracy() - - Task { - try await Task.sleep(for: .seconds(1)) - await send(.async(.startLocationUpdates)) - } - } - } - - case .startLocationUpdates: - return .run { send in - let locationManager = await LocationPermissionManager.shared - - // 지속적인 위치 업데이트 콜백 설정 (MainActor에서 실행) - await MainActor.run { - locationManager.onLocationUpdate = { location in - Task { @MainActor in - await send(.inner(.locationUpdated(location))) - } - } - - locationManager.onLocationError = { error in - Task { @MainActor in - await send(.inner(.locationUpdateFailed(error.localizedDescription))) - } - } - } - - await locationManager.startLocationUpdates() - - // 초기 위치도 가져오기 - do { - if let location = try await locationManager.requestCurrentLocation() { - await send(.inner(.locationUpdated(location))) - } - } catch { - await send(.inner(.locationUpdateFailed(error.localizedDescription))) - } - } - - case .stopLocationUpdates: - return .run { send in - await MainActor.run { - let locationManager = LocationPermissionManager.shared - locationManager.stopLocationUpdates() - } - } - - // 길찾기 관련 액션 - case .searchRoute(let from, let destination): - return .run { send in - // 경로 검색 시작 알림 - await send(.inner(.routeSearchStarted(destination))) - - let routeResult = await Result { - try await getRouteUseCase.execute( - from: from, - to: destination.coordinate, - option: .traoptimal // 최적 경로로 변경 - ) - } - .mapError(DirectionError.from) - - await send(.inner(.routeSearchResponse(routeResult))) - } - } - } - - private func handleScopeAction( - state: inout State, - action: ScopeAction - ) -> Effect { - switch action { - case .alert(let alertAction): - return handleAlertAction(state: &state, action: alertAction) - } - } - - private func handleAlertAction( - state: inout State, - action: PresentationAction - ) -> Effect { - switch action { - case .presented(let alertAction): - switch alertAction { - case .confirmLocationPermission: - state.alert = nil - return .none - - case .cancelLocationPermission: - state.alert = nil - return .none - - case .openSettings: - state.alert = nil - return .send(.view(.openSettings)) - - case .dismissAlert: - state.alert = nil - return .none - } - - case .dismiss: - state.alert = nil - return .none - } - } -} - -// 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) - hasher.combine(userSession) - // Note: alert, selectedDestination, routeInfo are not hashed as they contain complex types - } -} diff --git a/Projects/Presentation/Home/Sources/Explore/Reducer/ExploreState+Extensions.swift b/Projects/Presentation/Home/Sources/Explore/Reducer/ExploreState+Extensions.swift new file mode 100644 index 0000000..7f6bfbe --- /dev/null +++ b/Projects/Presentation/Home/Sources/Explore/Reducer/ExploreState+Extensions.swift @@ -0,0 +1,147 @@ +// +// ExploreState+Extensions.swift +// Home +// +// Created by Wonji Suh on 3/28/26. +// + +import Foundation +import CoreLocation +import ComposableArchitecture +import Entity + +extension ExploreFeature.State { + var trimmedSearchText: String { + searchText.trimmingCharacters(in: .whitespacesAndNewlines) + } + + func hasVisibleMarkerContent(_ spot: ExploreMapSpot) -> Bool { + spot.hasDetail + || !spot.name.isEmpty + || !spot.badgeText.isEmpty + || !spot.statusText.isEmpty + || !spot.closingText.isEmpty + || !spot.distanceText.isEmpty + || !spot.walkTimeText.isEmpty + } + + func matchesCurrentFilters(_ spot: ExploreMapSpot) -> Bool { + let matchesCategory = selectedCategory == .all || spot.category == selectedCategory + let matchesQuery = trimmedSearchText.isEmpty || spot.name.localizedCaseInsensitiveContains(trimmedSearchText) + return matchesCategory && matchesQuery + } + + var filteredMapSpots: [ExploreMapSpot] { + spots.filter { spot in + spot.hasDetail && matchesCurrentFilters(spot) + } + } + + var filteredSpots: [ExploreMapSpot] { + spots.filter { spot in + spot.hasDetail && matchesCurrentFilters(spot) + } + } + + func mergedSpot(for spotID: String) -> ExploreMapSpot? { + spots.first(where: { $0.id == spotID && $0.hasDetail }) + } + + var cardSpots: [ExploreMapSpot] { + let selectedSpotID = userSession.selectedExploreSpotID + + guard !selectedSpotID.isEmpty else { + return filteredSpots + } + + if filteredSpots.contains(where: { $0.id == selectedSpotID }) { + return filteredSpots + } + + if let selectedSearchSpot = mergedSpot(for: selectedSpotID) { + return [selectedSearchSpot] + filteredSpots + } + + return filteredSpots + } + + var selectedSpot: ExploreMapSpot? { + guard isSpotCardVisible else { return nil } + + let selectedSpotID = userSession.selectedExploreSpotID + + if !selectedSpotID.isEmpty, + let selectedSpot = mergedSpot(for: selectedSpotID) { + return selectedSpot + } + + return nil + } + + func adjacentSpot(cardTravelDistance: CGFloat) -> ExploreMapSpot? { + let currentSelectedID = selectedSpot?.id ?? userSession.selectedExploreSpotID + guard let currentIndex = cardSpots.firstIndex(where: { $0.id == currentSelectedID }) else { + return nil + } + guard abs(cardDragOffset) > 0 else { + return nil + } + + let adjacentIndex: Int + if cardDragOffset < 0 { + adjacentIndex = (currentIndex + 1) % cardSpots.count + } else { + adjacentIndex = (currentIndex - 1 + cardSpots.count) % cardSpots.count + } + return cardSpots[adjacentIndex] + } + + func adjacentCardOffset(cardTravelDistance: CGFloat) -> CGFloat? { + guard adjacentSpot(cardTravelDistance: cardTravelDistance) != nil else { return nil } + let baseOffset = cardDragOffset >= 0 ? -cardTravelDistance : cardTravelDistance + return baseOffset + cardDragOffset + } + + func cardOpacity(cardTravelDistance: CGFloat) -> Double { + let progress = min(abs(cardBaseOffset + cardDragOffset) / cardTravelDistance, 1) + return 1 - (progress * 0.02) + } +} + +// MARK: - ExploreReducer.State + Hashable + +extension ExploreFeature.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(mapCenterLat) + hasher.combine(mapCenterLon) + hasher.combine(spots) + hasher.combine(isLoadingRoute) + hasher.combine(routeError) + hasher.combine(shouldReturnToCurrentLocation) + hasher.combine(userSession) + } +} + +// MARK: - ExploreReducer.AsyncAction + Equatable + +extension ExploreFeature.AsyncAction { + public static func == (lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case (.requestLocationPermission, .requestLocationPermission), + (.requestFullAccuracy, .requestFullAccuracy), + (.startLocationUpdates, .startLocationUpdates), + (.stopLocationUpdates, .stopLocationUpdates), + (.requestCurrentLocation, .requestCurrentLocation): + return true + case (.fetchPlaces(let lhsPage, let lhsAppend), .fetchPlaces(let rhsPage, let rhsAppend)): + return lhsPage == rhsPage && lhsAppend == rhsAppend + default: + return false + } + } +} diff --git a/Projects/Presentation/Home/Sources/Explore/View/ExploreSkeletonView.swift b/Projects/Presentation/Home/Sources/Explore/View/ExploreSkeletonView.swift new file mode 100644 index 0000000..80098ae --- /dev/null +++ b/Projects/Presentation/Home/Sources/Explore/View/ExploreSkeletonView.swift @@ -0,0 +1,135 @@ +// +// ExploreSkeletonView.swift +// Home +// + +import SwiftUI + +import DesignSystem + +struct ExploreSkeletonView: View { + var body: some View { + ZStack { + LinearGradient( + colors: [.gray200, .gray100], + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea() + + VStack(spacing: 0) { + headerSection() + .padding(.top, 8) + .padding(.horizontal, 20) + + Spacer() + + bottomSection() + } + } + } +} + +private extension ExploreSkeletonView { + @ViewBuilder + func headerSection() -> some View { + VStack(spacing: 0) { + HStack(spacing: 10) { + Circle() + .fill(.staticWhite.opacity(0.9)) + .frame(width: 48, height: 48) + + RoundedRectangle(cornerRadius: 18) + .fill(.staticWhite.opacity(0.9)) + .frame(height: 48) + } + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(0..<5, id: \.self) { index in + Capsule() + .fill(.staticWhite.opacity(0.9)) + .frame(width: CGFloat([72, 64, 80, 68, 76][index]), height: 36) + } + } + .padding(.top, 10) + .padding(.horizontal, 2) + } + } + } + + @ViewBuilder + func bottomSection() -> some View { + VStack(spacing: 16) { + HStack { + Spacer() + + Circle() + .fill(.staticWhite) + .frame(width: 48, height: 48) + .shadow(color: .black.opacity(0.08), radius: 8, y: 2) + } + .padding(.horizontal, 16) + + selectedSpotCardSkeleton() + .padding(.horizontal, 16) + } + .padding(.bottom, 36) + } + + @ViewBuilder + func selectedSpotCardSkeleton() -> some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 0) { + Capsule() + .fill(.gray200) + .frame(width: 88, height: 24) + .padding(.bottom, 12) + + RoundedRectangle(cornerRadius: 6) + .fill(.gray200) + .frame(height: 22) + .padding(.bottom, 6) + + RoundedRectangle(cornerRadius: 6) + .fill(.gray200) + .frame(width: 120, height: 16) + .padding(.bottom, 12) + + RoundedRectangle(cornerRadius: 4) + .fill(.gray200) + .frame(width: 180, height: 14) + .padding(.bottom, 8) + + HStack(spacing: 8) { + RoundedRectangle(cornerRadius: 4) + .fill(.gray200) + .frame(width: 52, height: 14) + + RoundedRectangle(cornerRadius: 4) + .fill(.gray200) + .frame(width: 120, height: 14) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + RoundedRectangle(cornerRadius: 16) + .fill(.gray200) + .frame(width: 92, height: 112) + } + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 20) + + RoundedRectangle(cornerRadius: 25) + .fill(.gray850) + .frame(height: 55) + .padding(.horizontal, 16) + .padding(.bottom, 12) + } + .background(.staticWhite) + .clipShape(RoundedRectangle(cornerRadius: 24)) + .shadow(color: .black.opacity(0.12), radius: 14, y: 4) + } +} diff --git a/Projects/Presentation/Home/Sources/Explore/View/ExploreView.swift b/Projects/Presentation/Home/Sources/Explore/View/ExploreView.swift index 8d37d3d..e65ab2d 100644 --- a/Projects/Presentation/Home/Sources/Explore/View/ExploreView.swift +++ b/Projects/Presentation/Home/Sources/Explore/View/ExploreView.swift @@ -9,15 +9,25 @@ import SwiftUI import ComposableArchitecture import CoreLocation +import UIKit import DesignSystem import Entity +import LogMacro public struct ExploreView: View { - @Bindable var store: StoreOf + @Bindable var store: StoreOf @Environment(\.dismiss) private var dismiss - public init(store: StoreOf) { + private var cardTravelDistance: CGFloat { + UIScreen.main.bounds.width - 8 + } + + private var cardSwipeThreshold: CGFloat { + (UIScreen.main.bounds.width - 32) / 2 + } + + public init(store: StoreOf) { self.store = store } @@ -25,14 +35,24 @@ public struct ExploreView: View { ZStack { mapView() - VStack(spacing: 0) { - headerSection() - .padding(.top, 8) - .padding(.horizontal, 20) - - Spacer() - - currentLocationButton() + // 🦴 마커 로딩 중일 때는 스켈레톤 전체 화면으로 표시 + if store.isLoadingPlaces && store.spots.isEmpty { + ExploreSkeletonView() + .transition(.opacity) + .animation(.easeInOut(duration: 0.3), value: store.isLoadingPlaces && store.spots.isEmpty) + } else { + // ✅ 마커 로딩 완료 후 실제 UI 표시 + VStack(spacing: 0) { + headerSection() + .padding(.top, 8) + .padding(.horizontal, 16) + + Spacer() + + bottomSection() + } + .transition(.scale.combined(with: .opacity)) + .animation(.easeInOut(duration: 0.3), value: !(store.isLoadingPlaces && store.spots.isEmpty)) } } .onAppear { @@ -51,214 +71,84 @@ private extension ExploreView { NaverMapComponent( locationPermissionStatus: store.locationPermissionStatus, currentLocation: store.currentLocation, - routeInfo: store.routeInfo, + routeInfo: nil, destination: store.selectedDestination, - returnToLocation: store.shouldReturnToCurrentLocation + travelStation: nil, + spots: store.spots, + selectedSpotID: store.userSession.selectedExploreSpotID.isEmpty + ? nil + : store.userSession.selectedExploreSpotID, + returnToLocationTrigger: store.returnToCurrentLocationTrigger, + autoFitTrigger: 0, // ExploreView에서는 자동 피팅 사용하지 않음 + onSpotTapped: { spotID in + store.send(.view(.spotTapped(spotID))) + }, + onMapTapped: { + store.send(.view(.spotCardChanged(nil))) + }, + onCameraIdle: { coordinate in + store.send(.view(.mapCenterChanged(coordinate))) + } ) .ignoresSafeArea(.all) } @ViewBuilder func headerSection() -> some View { - VStack(spacing: 0) { - HStack(spacing: 12) { - backButton() - searchBar() - } - - categoryScrollView() - .padding(.top, 12) - } - } - - @ViewBuilder - func backButton() -> some View { - Button { - dismiss() - } label: { - Image(asset: .leftArrow) - .resizable() - .scaledToFit() - .frame(width: 56, height: 56) - .background(.staticWhite) - .clipShape(Circle()) - .shadow(color: .black.opacity(0.08), radius: 12, y: 2) - } - .buttonStyle(.plain) + ExploreSearchHeaderView( + stationName: "\(store.userSession.travelStationName)역", + searchText: store.searchText, + selectedCategory: store.selectedCategory, + showCategories: true, // 카테고리 표시 + isSearchable: false, // 검색창 아닌 텍스트로 표시 + onBackTap: { dismiss() }, + onSearchTextChanged: { store.send(.view(.searchTextChanged($0))) }, + onCategoryTap: { store.send(.view(.categoryTapped($0))) }, + onSearchBarTap: nil + ) } @ViewBuilder - func searchBar() -> some View { - HStack(spacing: 8) { - Image(systemName: "magnifyingglass") - .font(.system(size: 16, weight: .medium)) - .foregroundStyle(.gray600) - - ZStack(alignment: .leading) { - if store.searchText.isEmpty { - Text("\(store.userSession.travelStationName)역") - .pretendardFont(family: .Regular, size: 18) - .foregroundStyle(.gray600) + func bottomSection() -> some View { + let selectedSpot = store.state.selectedSpot + let hasSelectedSpotCard = selectedSpot != nil + + VStack(spacing: 16) { + ExploreFloatingControlsView( + showsListButton: hasSelectedSpotCard, + controlsBottomPadding: 0, + onListTap: { + store.send(.delegate(.presentExploreList)) + }, + onCurrentLocationTap: { + store.send(.view(.returnToCurrentLocation)) } - - TextField( - "", - text: Binding( - get: { store.searchText }, - set: { store.send(.view(.searchTextChanged($0))) } - ) - ) - .pretendardFont(family: .Regular, size: 18) - .foregroundStyle(.staticBlack) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - } - } - .padding(.horizontal, 24) - .frame(height: 56) - .background(.staticWhite) - .clipShape(RoundedRectangle(cornerRadius: 28)) - .shadow(color: .black.opacity(0.08), radius: 12, y: 2) - } - - @ViewBuilder - func categoryScrollView() -> some View { - ScrollViewReader { proxy in - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - ForEach(ExploreCategory.allCases, id: \.self) { category in - categoryChip(category) - .id(category) + ) + + if let selectedSpot { + ExploreSelectedSpotCardView( + currentSpot: selectedSpot, + adjacentSpot: store.state.adjacentSpot(cardTravelDistance: cardTravelDistance), + store: store, + currentOffset: store.cardBaseOffset + store.cardDragOffset, + adjacentOffset: store.state.adjacentCardOffset(cardTravelDistance: cardTravelDistance), + cardOpacity: store.state.cardOpacity(cardTravelDistance: cardTravelDistance), + onCardTap: { + store.send(.view(.detailTapped)) + }, + onRouteTap: { + store.send(.delegate(.presentRoute)) + }, + onDragChanged: { value in + store.send(.view(.cardDragChanged(value.translation.width))) + }, + onDragEnded: { value in + store.send(.view(.cardDragEnded(value.translation.width))) } - } - .padding(.horizontal, 2) - } - .onAppear { - scrollToCategory(store.selectedCategory, with: proxy, animated: false) - } - .onChange(of: store.selectedCategory) { _, category in - DispatchQueue.main.async { - scrollToCategory(category, with: proxy) - } - } - } - } - - @ViewBuilder - func categoryChip(_ category: ExploreCategory) -> some View { - let isSelected = store.selectedCategory == category - - Button { - store.send(.view(.categoryTapped(category))) - } label: { - HStack(spacing: 4) { - categoryIcon(for: category, isSelected: isSelected) - - Text(category.title) - .pretendardFont(family: .Medium, size: 14) - .foregroundStyle(isSelected ? .staticBlack : .gray700) - } - .padding(.vertical, 10) - .padding(.horizontal, 16) - .background(isSelected ? .orange200 : .staticWhite) - .overlay { - Capsule() - .stroke(isSelected ? .orange800 : .gray300, lineWidth: 1) - } - .clipShape(Capsule()) - .shadow(color: .black.opacity(isSelected ? 0.04 : 0.08), radius: 8, y: 2) - } - .buttonStyle(.plain) - } - - @ViewBuilder - func currentLocationButton() -> some View { - HStack { - Spacer() - - Button { - store.send(.view(.returnToCurrentLocation)) - } label: { - Image(asset: .location) - .resizable() - .scaledToFit() - .frame(width: 24, height: 24) - .frame(width: 48, height: 48) - .background(.staticWhite, in: Circle()) - .shadow(color: .black.opacity(0.12), radius: 8, y: 2) - } - .padding(.trailing, 16) - .padding(.bottom, 36) - } - } - - func scrollToCategory( - _ category: ExploreCategory, - with proxy: ScrollViewProxy, - animated: Bool = true - ) { - let targetCategory: ExploreCategory - switch category { - case .all, .cafe: - targetCategory = .all - case .restaurant: - targetCategory = .cafe - case .activity: - targetCategory = .restaurant - case .etc: - targetCategory = .activity - @unknown default: - targetCategory = .all - } - - let action = { - proxy.scrollTo(targetCategory, anchor: .leading) - } - - if animated { - withAnimation(.easeInOut(duration: 0.2)) { - action() + ) + .padding(.horizontal, 16) } - } else { - action() - } - } - - @ViewBuilder - func categoryIcon( - for category: ExploreCategory, - isSelected: Bool - ) -> some View { - switch category { - case .all: - Image(asset: isSelected ? .tapAll : .all) - .resizable() - .scaledToFit() - .frame(width: 16, height: 16) - case .cafe: - Image(asset: isSelected ? .tapCaffe : .cafe) - .resizable() - .scaledToFit() - .frame(width: 16, height: 16) - case .restaurant: - Image(asset: isSelected ? .tapFood : .food) - .resizable() - .scaledToFit() - .frame(width: 16, height: 16) - case .activity: - Image(asset: isSelected ? .tapGame : .game) - .resizable() - .scaledToFit() - .frame(width: 16, height: 16) - .foregroundStyle(isSelected ? .orange800 : .gray700) - - case .etc: - Image(asset: isSelected ? .tapEtc : .etc) - .resizable() - .scaledToFit() - .frame(width: 16, height: 16) - .foregroundStyle(isSelected ? .orange800 : .gray700) - } + .padding(.bottom, 36) } } diff --git a/Projects/Presentation/Home/Sources/ExploreDetail/Components/ExploreDetailNavigationBar.swift b/Projects/Presentation/Home/Sources/ExploreDetail/Components/ExploreDetailNavigationBar.swift new file mode 100644 index 0000000..8d0ce73 --- /dev/null +++ b/Projects/Presentation/Home/Sources/ExploreDetail/Components/ExploreDetailNavigationBar.swift @@ -0,0 +1,67 @@ +// +// ExploreDetailNavigationBar.swift +// Home +// +// Created by Wonji Suh on 4/1/26. +// + +import SwiftUI +import DesignSystem + +public struct ExploreDetailNavigationBar: View { + private let placeName: String + private let category: String + private let showTitle: Bool + private let onBackTap: () -> Void + + public init( + placeName: String, + category: String, + showTitle: Bool, + onBackTap: @escaping () -> Void + ) { + self.placeName = placeName + self.category = category + self.showTitle = showTitle + self.onBackTap = onBackTap + } + + public var body: some View { + HStack(alignment: .top, spacing: 12) { + // 뒤로가기 버튼 + Button(action: onBackTap) { + Image(asset: .leftArrow) + .frame(width: 48, height: 48) + .background(.staticWhite) + } + + // 제목 영역 + if showTitle { + HStack(spacing: 12) { + // 장소명 - 7자 이상이면 ... 처리 + Text(placeName.count > 7 ? String(placeName.prefix(7)) + "..." : placeName) + .pretendardCustomFont(textStyle: .heading1) + .foregroundStyle(.staticBlack) + .lineLimit(1) + + // 카테고리 - 그대로 표시 + Text(category) + .pretendardCustomFont(textStyle: .body2Regular) + .foregroundStyle(.gray700) + .lineLimit(1) + + Spacer() + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 12) + .padding(.horizontal, 8) + .padding(.bottom, 4) + .transition(.move(edge: .top).combined(with: .opacity)) + } else { + Spacer() + } + } + .frame(minHeight: 48) + .animation(.easeInOut(duration: 0.3), value: showTitle) + } +} diff --git a/Projects/Presentation/Home/Sources/ExploreDetail/Components/ExploreDetailSkeletonView.swift b/Projects/Presentation/Home/Sources/ExploreDetail/Components/ExploreDetailSkeletonView.swift new file mode 100644 index 0000000..52ce715 --- /dev/null +++ b/Projects/Presentation/Home/Sources/ExploreDetail/Components/ExploreDetailSkeletonView.swift @@ -0,0 +1,170 @@ +// +// ExploreDetailSkeletonView.swift +// Home +// +// Created by Wonji Suh on 3/29/26. +// + +import SwiftUI +import DesignSystem + +public struct ExploreDetailSkeletonView: View { + public init() {} + + public var body: some View { + VStack(alignment: .leading, spacing: 0) { + RoundedRectangle(cornerRadius: 4) + .fill(.gray200) + .frame(width: 84, height: 14) + .skeletonShimmer() + .padding(.top, 12) + + HStack(spacing: 12) { + RoundedRectangle(cornerRadius: 20) + .fill(.gray200) + .frame(height: 180) + .frame(maxWidth: .infinity) + .skeletonShimmer() + + RoundedRectangle(cornerRadius: 20) + .fill(.gray200) + .frame(width: 84, height: 180) + .skeletonShimmer() + } + .padding(.top, 24) + + HStack(spacing: 0) { + skeletonMetricColumn() + divider + skeletonMetricColumn() + divider + skeletonMetricColumn() + } + .padding(.horizontal, 8) + .padding(.vertical, 16) + .background(.staticWhite) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .overlay { + RoundedRectangle(cornerRadius: 16) + .stroke(.gray300, lineWidth: 1) + } + .padding(.top, 24) + + RoundedRectangle(cornerRadius: 20) + .fill(.gray200) + .frame(height: 84) + .skeletonShimmer() + .padding(.top, 24) + + VStack(alignment: .leading, spacing: 16) { + RoundedRectangle(cornerRadius: 4) + .fill(.gray200) + .frame(width: 64, height: 14) + .skeletonShimmer() + + skeletonInfoRow(lineWidth: 180) + skeletonInfoRow(lineWidth: 120) + skeletonInfoRow(lineWidth: 200) + } + .padding(.top, 24) + + RoundedRectangle(cornerRadius: 20) + .fill(.gray200) + .frame(height: 180) + .skeletonShimmer() + .padding(.top, 24) + + Capsule() + .fill(.gray200) + .frame(height: 56) + .skeletonShimmer() + .padding(.top, 24) + } + } + + @ViewBuilder + private func skeletonMetricColumn() -> some View { + VStack(spacing: 8) { + RoundedRectangle(cornerRadius: 4) + .fill(.gray200) + .frame(width: 46, height: 16) + .skeletonShimmer() + + RoundedRectangle(cornerRadius: 4) + .fill(.gray200) + .frame(width: 34, height: 12) + .skeletonShimmer() + } + .frame(maxWidth: .infinity) + } + + @ViewBuilder + private func skeletonInfoRow(lineWidth: CGFloat) -> some View { + HStack(alignment: .top, spacing: 10) { + Circle() + .fill(.gray200) + .frame(width: 20, height: 20) + .skeletonShimmer() + + VStack(alignment: .leading, spacing: 6) { + RoundedRectangle(cornerRadius: 4) + .fill(.gray200) + .frame(width: 56, height: 12) + .skeletonShimmer() + + RoundedRectangle(cornerRadius: 4) + .fill(.gray200) + .frame(width: lineWidth, height: 14) + .skeletonShimmer() + } + + Spacer(minLength: 0) + } + } + + private var divider: some View { + Rectangle() + .fill(.gray300) + .frame(width: 1, height: 34) + } +} + +private extension View { + func skeletonShimmer() -> some View { + modifier(ExploreDetailSkeletonShimmerModifier()) + } +} + +private struct ExploreDetailSkeletonShimmerModifier: 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/Home/Sources/ExploreDetail/Reducer/ExploreDetailFeature.swift b/Projects/Presentation/Home/Sources/ExploreDetail/Reducer/ExploreDetailFeature.swift new file mode 100644 index 0000000..77224fc --- /dev/null +++ b/Projects/Presentation/Home/Sources/ExploreDetail/Reducer/ExploreDetailFeature.swift @@ -0,0 +1,461 @@ +// +// ExploreDetailFeature.swift +// Home +// +// Created by Wonji Suh on 3/28/26. +// + +import Foundation +import ComposableArchitecture +import DesignSystem +import Entity +import UseCase +import Utill +import MapKit +import LogMacro + +@Reducer +public struct ExploreDetailFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + public var placeDetail: PlaceDetailEntity? + public var isLoading: Bool = false + public var errorMessage: String? + public var shouldDismiss: Bool = false + @Presents public var customAlert: CustomAlertState? + public var customAlertMode: CustomAlertMode? + public var showLowStayTimeToast: Bool = false + @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty + + // 스크롤 관련 상태 + public var scrollOffset: CGFloat = 0 + public var showNavigationTitle: Bool = false + + public init() {} + } + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case scope(ScopeAction) + case delegate(DelegateAction) + } + + @CasePathable + public enum ScopeAction { + case customAlert(PresentationAction) + } + + public enum CustomAlertMode: Equatable { + case visitUnavailable + case networkError + } + + @CasePathable + public enum View { + case onAppear + case hideLowStayTimeToast + case routeButtonTapped + case titlePositionChanged(CGFloat) + } + + public enum AsyncAction: Equatable { + case fetchPlaceDetail + } + + public enum InnerAction: Equatable { + case fetchPlaceDetailResponse(Result) + } + + public enum DelegateAction: Equatable { + case presentRoute + } + + @Dependency(\.placeUseCase) var placeUseCase + + 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 .scope(let scopeAction): + return handleScopeAction(state: &state, action: scopeAction) + case .delegate(let delegateAction): + return handleDelegateAction(state: &state, action: delegateAction) + } + } + .ifLet(\.$customAlert, action: \.scope.customAlert) { + CustomConfirmAlert() + } + } +} + +extension ExploreDetailFeature { + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .onAppear: + return .send(.async(.fetchPlaceDetail)) + + case .hideLowStayTimeToast: + state.showLowStayTimeToast = false + return .none + + case .routeButtonTapped: + // UserSession에 목적지 정보 저장 + if let placeDetail = state.placeDetail { + state.$userSession.withLock { userSession in + userSession.routeDestinationLat = placeDetail.latitude + userSession.routeDestinationLng = placeDetail.longitude + userSession.routeDestinationName = placeDetail.name + } + } + + return .send(.delegate(.presentRoute)) + + case .titlePositionChanged(let imageY): + // 이미지가 조금만 스크롤되어도 네비게이션 바에 제목 표시 + // 더 빠른 트리거로 사용자 경험 개선 + state.showNavigationTitle = imageY < 120 + return .none + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case .fetchPlaceDetail: + guard let placeID = Int(state.userSession.selectedExplorePlaceID) else { + state.errorMessage = PlaceError.placeNotFound.errorDescription + return .none + } + + state.isLoading = true + state.errorMessage = nil + let userSession = state.userSession + + return .run { send in + let result = await Result { + try await placeUseCase.detailPlace( + userSession: userSession, + placeId: placeID + ) + } + .mapError(PlaceError.from) + + await send(.inner(.fetchPlaceDetailResponse(result))) + } + } + } + + private func handleDelegateAction( + state: inout State, + action: DelegateAction + ) -> Effect { + switch action { + case .presentRoute: + // 장소 상세 정보의 위도와 경도를 UserSession에 저장 + if let placeDetail = state.placeDetail { + state.$userSession.withLock { + // 목적지 정보 저장 + $0.routeDestinationLat = placeDetail.latitude + $0.routeDestinationLng = placeDetail.longitude + $0.routeDestinationName = placeDetail.name + } + } + return .none + } + } + + private func handleScopeAction( + state: inout State, + action: ScopeAction + ) -> Effect { + switch action { + case .customAlert(.presented(.confirmTapped)): + switch state.customAlertMode { + case .visitUnavailable, .networkError: + state.customAlert = nil + state.customAlertMode = nil + state.shouldDismiss = true + 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 handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case .fetchPlaceDetailResponse(let result): + state.isLoading = false + + switch result { + case .success(let detail): + state.placeDetail = detail + state.errorMessage = nil + if isVisitUnavailable(detail: detail, fetchedAt: state.userSession.explorePlacesFetchedAt) { + state.customAlertMode = .visitUnavailable + state.customAlert = .alert( + title: "방문 불가능해요", + message: "남은 체류 시간이 없어서 이전 화면으로 돌아갈게요.", + confirmTitle: "확인", + cancelTitle: "취소" + ) + } else { + // 체류시간이 10분 미만이면 토스트 표시 + let remainingMinutes = calculateRemainingStayableMinutes(detail: detail, fetchedAt: state.userSession.explorePlacesFetchedAt) + if remainingMinutes > 0 && remainingMinutes < 10 { + state.showLowStayTimeToast = true + } + } + case .failure(let error): + state.errorMessage = error.errorDescription + state.customAlertMode = .networkError + state.customAlert = .alert( + title: "오류가 발생했어요", + message: error.errorDescription ?? "장소 정보를 불러오지 못했어요.", + confirmTitle: "확인", + cancelTitle: "취소" + ) + } + return .none + } + } + + private func isVisitUnavailable( + detail: PlaceDetailEntity, + fetchedAt: Date? + ) -> Bool { + let elapsedMinutes: Int + if let fetchedAt { + elapsedMinutes = max(Int(Date().timeIntervalSince(fetchedAt) / 60), 0) + } else { + elapsedMinutes = 0 + } + return max(detail.stayableMinutes - elapsedMinutes, 0) <= 0 + } + + private func calculateRemainingStayableMinutes( + detail: PlaceDetailEntity, + fetchedAt: Date? + ) -> Int { + let elapsedMinutes: Int + if let fetchedAt { + elapsedMinutes = max(Int(Date().timeIntervalSince(fetchedAt) / 60), 0) + } else { + elapsedMinutes = 0 + } + return max(detail.stayableMinutes - elapsedMinutes, 0) + } +} + +extension ExploreDetailFeature.State: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(placeDetail) + hasher.combine(isLoading) + hasher.combine(errorMessage) + hasher.combine(userSession) + } +} + +// MARK: - State Computed Properties +extension ExploreDetailFeature.State { + + var imageCards: [URL?] { + let urls = placeDetail?.images.compactMap { urlString -> URL? in + // Google Places API URL에 대한 특별한 처리 + if urlString.contains("places.googleapis.com") { + return URL(string: urlString.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)) + } + return urlString.normalizedURL + } ?? [] + + if !urls.isEmpty { + return urls + } + return [nil, nil] + } + + var placeNameText: String { + placeDetail?.name ?? "" + } + + var categoryText: String { + placeDetail?.category ?? "" + } + + var distanceText: String { + if let placeDetail = placeDetail { + return "\(placeDetail.distanceFromStation)m" + } + return "" + } + + var returnDeadlineText: String { + if isVisitUnavailable { + return "방문 불가능해요" + } + + if let leaveTime = placeDetail?.leaveTime, + let formatted = formattedDeadlineTime(from: leaveTime) { + return formatted + } + + return Date().formattedReturnDeadlineText(addingMinutes: stayableMinutesValue) + "분" + } + + var stayableMinutesValue: Int { + remainingStayableMinutes + } + + var remainingStayableMinutes: Int { + let originalMinutes = placeDetail?.stayableMinutes ?? 0 + let elapsedMinutes = elapsedMinutesSincePlacesFetched + return max(originalMinutes - elapsedMinutes, 0) + } + + var elapsedMinutesSincePlacesFetched: Int { + guard let fetchedAt = userSession.explorePlacesFetchedAt else { + return 0 + } + + return max(Int(Date().timeIntervalSince(fetchedAt) / 60), 0) + } + + var isVisitUnavailable: Bool { + guard let detail = placeDetail else { return true } + return !detail.visitable + } + + var returnDeadlineSuffixText: String { + isVisitUnavailable ? "" : " 에는 역으로 출발해야 합니다." + } + + var openingHoursText: String { + if let useTime = placeDetail?.useTime, !useTime.isEmpty { + // HTML 태그 제거 및 개행 문자 처리 + return useTime + .replacingOccurrences(of: "
", with: "\n") + .replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + return "영업 시간 정보 준비 중" + } + + var phoneNumberText: String { + "전화번호 정보 준비 중" + } + + var addressText: String { + placeDetail?.address.nilIfEmpty ?? "주소 정보 준비 중" + } + + var stayableMinutesText: String { + "약 \(remainingStayableMinutes)분" + } + + var walkMinutesText: String { + if let placeDetail = placeDetail { + return "\(placeDetail.walkTimeFromStation)분" + } + return "0분" + } + + var mapCoordinate: CLLocationCoordinate2D { + if let placeDetail = placeDetail { + let coordinate = CLLocationCoordinate2D( + latitude: placeDetail.latitude, // 장소의 실제 위도 + longitude: placeDetail.longitude // 장소의 실제 경도 + ) + + // 디버깅: 좌표가 유효한지 확인 + if coordinate.latitude != 0 && coordinate.longitude != 0 { + return coordinate + } + } + + // placeDetail이 없거나 좌표가 유효하지 않을 때는 기본 서울 중심 좌표 사용 + return CLLocationCoordinate2D( + latitude: 37.5666805, // 서울 중심 + longitude: 126.9784147 + ) + } + + var mapRegion: MKCoordinateRegion { + MKCoordinateRegion( + center: mapCoordinate, + span: MKCoordinateSpan(latitudeDelta: 0.0035, longitudeDelta: 0.0035) + ) + } + + // MARK: - Private Helper Methods + + private func summarizedOpeningHours(from values: [String]) -> String { + let normalized = values + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + guard !normalized.isEmpty else { + return "" + } + + let extractedTimes = normalized.map { value in + guard let separatorIndex = value.firstIndex(of: ":") else { + return value + } + return value[value.index(after: separatorIndex)...] + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + let uniqueTimes = Array(Set(extractedTimes)) + + if uniqueTimes.count == 1, let first = extractedTimes.first { + return first + } + + return normalized.joined(separator: ", ") + } + + private func formattedDeadlineTime(from leaveTime: String) -> String? { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + + guard let date = formatter.date(from: leaveTime) else { + return nil + } + + let outputFormatter = DateFormatter() + outputFormatter.locale = Locale(identifier: "ko_KR") + outputFormatter.dateFormat = "a h:mm분" + return outputFormatter.string(from: date) + } +} diff --git a/Projects/Presentation/Home/Sources/ExploreDetail/View/ExploreDetailView.swift b/Projects/Presentation/Home/Sources/ExploreDetail/View/ExploreDetailView.swift new file mode 100644 index 0000000..5f3e2fd --- /dev/null +++ b/Projects/Presentation/Home/Sources/ExploreDetail/View/ExploreDetailView.swift @@ -0,0 +1,536 @@ +// +// ExploreDetailView.swift +// Home +// +// Created by Wonji Suh on 3/28/26. +// + + +import SwiftUI +import MapKit +import DesignSystem +import Kingfisher +import Entity +import Utill + +import ComposableArchitecture + +public struct ExploreDetailView: View { + @Bindable var store: StoreOf + @Environment(\.dismiss) private var dismiss + + public init( + store: StoreOf + ) { + self.store = store + } + + public var body: some View { + GeometryReader { proxy in + ZStack(alignment: .topLeading) { + Color.gray100 + .edgesIgnoringSafeArea(.all) + + VStack(spacing: 0) { + // 상단 네비게이션 바 + if !(store.isLoading && store.placeDetail == nil) { + ExploreDetailNavigationBar( + placeName: store.placeNameText.formattedPlaceNameForDisplay, + category: store.categoryText, + showTitle: store.showNavigationTitle, + onBackTap: { + dismiss() + } + ) + .padding(.horizontal, 16) + .offset(y: -30) + } + + // 스크롤 가능한 컨텐츠 + ScrollViewReader { scrollProxy in + ScrollView(.vertical) { + LazyVStack(alignment: .leading, spacing: 0) { + Group { + if store.isLoading && store.placeDetail == nil { + ExploreDetailSkeletonView() + } else { + VStack(alignment: .leading, spacing: 0) { + exploreSpotNameTitle() + .padding(.top, 18) // 6 + 18 = 24 (네비게이션에서 총 24만큼 떨어짐) + .id("title") + + imageSection() + .padding(.top, 24) + .id("images") + .background( + GeometryReader { imageGeo in + Color.clear + .onAppear { + // 이미지 섹션 위치 감지 + let imageFrame = imageGeo.frame(in: .global) + store.send(.view(.titlePositionChanged(imageFrame.minY))) + } + .onChange(of: imageGeo.frame(in: .global).minY) { _, newY in + store.send(.view(.titlePositionChanged(newY))) + } + } + ) + + stayInfoSection() + .padding(.top, 24) + + returnDeadlineSection() + .padding(.top, 24) + + placeInfoSection() + .padding(.top, 29) + + locationMapSection() + .padding(.top, 24) + .id("map") + + // 고정 버튼 영역만큼 하단 공간 확보 + Color.clear + .frame(height: 131) // 간격 41 + 버튼 높이 56 + 하단 패딩 34 + .id("bottom") + + } + } + } + .padding(.horizontal, 16) + } + } + .scrollIndicators(.hidden) + .padding(.top, 6) // 상단 네비게이션에서 6만큼 떨어진 지점부터 스크롤 시작 + } + .offset(y: -10) + } + + // 하단 고정 버튼 + VStack { + Spacer() + + if !(store.isLoading && store.placeDetail == nil) { + VStack(spacing: 0) { + // 투명한 상단 간격 (콘텐츠가 보이도록) + Spacer() + .frame(height: 41) + + // 버튼 영역만 배경 적용 + routeButtonSection() + .padding(.horizontal, 16) + .padding(.bottom, 24) + .background(.gray100) + } + } + } + } + } + .onAppear { + store.send(.view(.onAppear)) + } + .refreshable { + // 캐시된 이미지 다시 확인 + } + .onChange(of: store.shouldDismiss) { _, shouldDismiss in + guard shouldDismiss else { return } + dismiss() + } + .onChange(of: store.showLowStayTimeToast) { _, showToast in + if showToast { + ToastManager.shared.showWarning("체류시간이 10분 미만입니다") + store.send(.view(.hideLowStayTimeToast)) + } + } + .customAlert($store.scope(state: \.customAlert, action: \.scope.customAlert)) + .toastOverlay( + position: .bottom, + horizontalPadding: 20, + bottomPadding: 170 // 고정 버튼(상단 41 + 버튼 + 하단 34) 위에 토스트 표시 + ) + } +} + + +private extension ExploreDetailView { + + /// 10자 이상인 텍스트에 중간 스페이스 추가 + private func formatLongText(_ text: String) -> String { + guard text.count > 10 else { return text } + + let characters = Array(text) + let midPoint = characters.count / 2 + + // 중간점 근처에서 적절한 위치 찾기 (±2 범위 내) + let searchRange = max(0, midPoint - 2)...min(characters.count - 1, midPoint + 2) + + // 이미 스페이스가 있는 위치 찾기 + if searchRange.first(where: { characters[$0] == " " }) != nil { + return text + } + + // 스페이스가 없으면 중간에 스페이스 추가 + let insertIndex = midPoint + var result = characters + result.insert(" ", at: insertIndex) + + return String(result) + } + + @ViewBuilder + func exploreSpotNameTitle() -> some View { + HStack(spacing: 8) { + Text(formatLongText(store.placeNameText.formattedPlaceNameForDisplay)) + .pretendardCustomFont(textStyle: .heading1) + .foregroundStyle(.staticBlack) + .lineLimit(2) + + Text(formatLongText(store.categoryText)) + .pretendardCustomFont(textStyle: .body2Regular) + .foregroundStyle(.gray700) + + Spacer() + } + } + + @ViewBuilder + func imageSection() -> some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(store.imageCards.indices, id: \.self) { index in + spotImageCard(for: store.imageCards[index], index: index) + } + } + } + } + + @ViewBuilder + func stayInfoSection() -> some View { + HStack(spacing: 0) { + metricColumn( + value: formatLongText(store.stayableMinutesText), + title: "체류시간", + valueColor: .orange800 + ) + + divider + + metricColumn( + value: formatLongText(store.walkMinutesText), + title: "도보", + valueColor: .gray830 + ) + + divider + + metricColumn( + value: formatLongText(store.distanceText), + title: "거리", + valueColor: .gray830 + ) + } + .padding(.horizontal, 8) + .padding(.vertical, 16) + .background(.staticWhite) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .overlay { + RoundedRectangle(cornerRadius: 16) + .stroke(.gray300, lineWidth: 1) + } + } + + @ViewBuilder + func returnDeadlineSection() -> some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 12) { + Image(asset: .warning) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + .padding(.vertical,11) + + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("최종 복귀 시간") + .pretendardCustomFont(textStyle: .body2Bold) + .foregroundStyle(.staticBlack) + + Spacer() + } + + ( + Text(formatLongText(store.returnDeadlineText)) + .foregroundStyle(store.isVisitUnavailable ? .gray700 : .orange800) + + + Text(formatLongText(store.returnDeadlineSuffixText)).foregroundStyle(.gray800) + ) + .pretendardCustomFont(textStyle: .body2Medium) + .lineSpacing(2) + .lineLimit(3) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } + } + + + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 21) + .padding(.vertical, 18) + .background(.orange200) + .clipShape(RoundedRectangle(cornerRadius: 20)) + .overlay { + RoundedRectangle(cornerRadius: 20) + .stroke(.orange500, lineWidth: 1) + } + } + + @ViewBuilder + func placeInfoSection() -> some View { + VStack(alignment: .leading, spacing: 24) { + Text("장소 정보") + .pretendardCustomFont(textStyle: .bodyBold) + .foregroundStyle(.gray850) + + VStack(alignment: .leading, spacing: 16) { + infoRow( + icon: "clock.fill", + title: "영업 시간", + content: formatLongText(store.openingHoursText) + ) + + infoRow( + icon: "phone.fill", + title: "전화번호", + content: formatLongText(store.phoneNumberText) + ) + + infoRow( + icon: "location.fill", + title: "주소", + content: formatLongText(store.addressText) + ) + } + } + } + + @ViewBuilder + func locationMapSection() -> some View { + GeometryReader { proxy in + if let placeDetail = store.placeDetail { + // placeDetail이 로딩된 후 유효한 좌표로만 지도 표시 + let coordinate = CLLocationCoordinate2D( + latitude: placeDetail.latitude, + longitude: placeDetail.longitude + ) + + if coordinate.latitude != 0 && coordinate.longitude != 0 { + Map(initialPosition: .region(store.mapRegion), interactionModes: .all) { + Annotation(formatLongText(store.placeNameText), coordinate: coordinate) { + Image(asset: .spotPin) + .resizable() + .scaledToFit() + .frame(width: 24, height: 28) + } + } + .frame(width: proxy.size.width, height: 180) + .clipShape(RoundedRectangle(cornerRadius: 20)) + .clipped() + } else { + // 좌표가 유효하지 않을 때 + RoundedRectangle(cornerRadius: 20) + .fill(.gray200) + .frame(width: proxy.size.width, height: 180) + .overlay { + VStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 24, weight: .medium)) + .foregroundStyle(.orange600) + + Text("위치 정보를 찾을 수 없습니다") + .pretendardCustomFont(textStyle: .body2Regular) + .foregroundStyle(.gray600) + } + } + } + } else { + // placeDetail 로딩 중에는 플레이스홀더 표시 + RoundedRectangle(cornerRadius: 20) + .fill(.gray200) + .frame(width: proxy.size.width, height: 180) + .overlay { + VStack(spacing: 8) { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .gray600)) + .scaleEffect(0.8) + + Text("지도 로딩 중...") + .pretendardCustomFont(textStyle: .body2Regular) + .foregroundStyle(.gray600) + } + } + } + } + .frame(height: 180) + } + + @ViewBuilder + func routeButtonSection() -> some View { + CustomButton( + action: { + store.send(.view(.routeButtonTapped)) + }, + title: store.isVisitUnavailable ? "방문 불가" : "경로 확인하기", + config: CustomButtonConfig.create(), + isEnable: !store.isVisitUnavailable + ) + } + + @ViewBuilder + func infoRow( + icon: String, + title: String, + content: String + ) -> some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: icon) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.gray700) + .frame(width: 20, height: 20) + .padding(.top, 1) + + VStack(alignment: .leading, spacing: 4) { + Text(title) + .pretendardCustomFont(textStyle: .body2Medium) + .foregroundStyle(.gray700) + + Text(content) + .pretendardCustomFont(textStyle: .bodyRegular) + .foregroundStyle(.gray850) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 0) + } + } + + @ViewBuilder + func metricColumn( + value: String, + title: String, + valueColor: Color + ) -> some View { + VStack(spacing: 4) { + Text(value) + .pretendardCustomFont(textStyle: .bodyMedium) + .foregroundStyle(valueColor) + + Text(title) + .pretendardCustomFont(textStyle: .body2Regular) + .foregroundStyle(.gray800) + } + .frame(maxWidth: .infinity) + } + + var divider: some View { + Rectangle() + .fill(.gray300) + .frame(width: 1, height: 34) + } + + // All computed properties moved to ExploreDetailFeature.State extension + + // Helper function moved to ExploreDetailFeature.State extension + + @ViewBuilder + func spotImageCard(for url: URL?, index: Int) -> some View { + Group { + if let url { + // Google Places API 이미지 로드 (제한적 네트워크 허용) + KFImage(url) + .placeholder { + imagePlaceholder() + } + .setProcessor(DownsamplingImageProcessor(size: CGSize(width: 280, height: 180))) + .loadDiskFileSynchronously() + .memoryCacheExpiration(.seconds(1800)) + .diskCacheExpiration(.days(7)) // 더 긴 캐시 (할당량 절약) + .requestModifier(rateLimitedImageModifier) + .fade(duration: 0.2) + .resizable() + .scaledToFill() + } else { + imagePlaceholder() + } + } + .frame(width: 280, height: 180) + .background(.gray200) + .clipShape(RoundedRectangle(cornerRadius: 20)) + .overlay(alignment: .bottomLeading) { + if index == 0 { + LinearGradient( + colors: [.black.opacity(0.0), .black.opacity(0.18)], + startPoint: .top, + endPoint: .bottom + ) + .clipShape(RoundedRectangle(cornerRadius: 20)) + } + } + } + + // Rate Limiting을 고려한 Google Places API 이미지용 요청 modifier + private var rateLimitedImageModifier: AnyModifier { + AnyModifier { request in + var modifiedRequest = request + modifiedRequest.timeoutInterval = 45.0 // 더 긴 타임아웃 + modifiedRequest.setValue("TimeSpot-iOS/1.0", forHTTPHeaderField: "User-Agent") + modifiedRequest.setValue("image/*", forHTTPHeaderField: "Accept") + // Rate limiting 방지를 위해 캐시 우선 사용 + modifiedRequest.cachePolicy = .returnCacheDataElseLoad + + return modifiedRequest + } + } + + // 캐시된 이미지만 클리어 (네트워크 요청 없음) + private func forceReloadImages() { + let urls = store.imageCards.compactMap { $0 } + for url in urls { + KingfisherManager.shared.cache.removeImage(forKey: url.absoluteString) + } + } + + // Rate Limit을 고려한 제한적 프리페칭 (첫 번째 이미지만) + private func prefetchImagesWithRateLimit() { + let urls = store.imageCards.compactMap { $0 } + guard !urls.isEmpty else { return } + + // 첫 번째 이미지만 프리페치 (Rate Limit 방지) + let limitedUrls = Array(urls.prefix(1)) + + let prefetcher = ImagePrefetcher( + urls: limitedUrls, + options: [ + .processor(DownsamplingImageProcessor(size: CGSize(width: 280, height: 180))), + .requestModifier(rateLimitedImageModifier), + .backgroundDecode, + .diskCacheExpiration(.days(1)), + .memoryCacheExpiration(.seconds(1800)) + ] + ) + + prefetcher.start() + } + + func imagePlaceholder() -> some View { + ZStack { + RoundedRectangle(cornerRadius: 20) + .fill(.gray200) + + Image(systemName: "photo") + .font(.system(size: 28, weight: .medium)) + .foregroundStyle(.gray500) + } + } +} + diff --git a/Projects/Presentation/Home/Sources/ExploreList/Reducer/ExploreListFeature.swift b/Projects/Presentation/Home/Sources/ExploreList/Reducer/ExploreListFeature.swift new file mode 100644 index 0000000..f4aedac --- /dev/null +++ b/Projects/Presentation/Home/Sources/ExploreList/Reducer/ExploreListFeature.swift @@ -0,0 +1,506 @@ +// +// ExploreListFeature.swift +// Home +// +// Created by Wonji Suh on 3/28/26. +// + + +import Foundation +import CoreLocation +import ComposableArchitecture +import Entity +import UseCase +import Utill +import IdentifiedCollections +import LogMacro + + +@Reducer +public struct ExploreListFeature { + public init() {} + + enum CancelID: Hashable { + case fetchPlaces + } + + @ObservableState + public struct State: Equatable { + public static let pageChunkSize = 50 + + public var searchText: String = "" + public var selectedCategory: ExploreCategory = .all + public var selectedSort: ExploreListSort = .stationNearest + public var requestSortBy: String = "distanceFromStation,ASC" + public var spots: [ExploreMapSpot] = [] + public var bufferedSpots: [ExploreMapSpot] = [] + public var currentPage: Int = 1 + public var hasNextPage: Bool = true + public var isLoading: Bool = false + public var placeError: PlaceError? = nil + public var currentLocation: CLLocationCoordinate2D? + public var markerLat: Double? + public var markerLon: Double? + public var lastTriggeredLoadSpotID: String? + public var hasLoadedInitialPage: Bool = false + @Shared(.inMemory("UserSession")) public 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 searchTextChanged(String) + case categoryTapped(ExploreCategory) + case sortTapped(ExploreListSort) + case loadNextPage + case spotCardTapped(ExploreMapSpot) + // Explore에서 데이터 동기화 + case syncSpotsFromExplore([ExploreMapSpot], currentPage: Int, hasNextPage: Bool) + } + + + + //MARK: - AsyncAction 비동기 처리 액션 + public enum AsyncAction: Equatable { + case fetchPlaces(page: Int, append: Bool, ignoreCategory: Bool = false) + } + + //MARK: - 앱내에서 사용하는 액션 + public enum InnerAction: Equatable { + case fetchPlacesResponse(PlaceSearchPageEntity, append: Bool, requestedPage: Int) + case fetchPlacesFailed(PlaceError) + case forceResetLoading + } + + //MARK: - NavigationAction + public enum NavigationAction: Equatable { + } + + public enum DelegateAction: Equatable { + case presentExploreMapAtCurrentLocation + case presentExploreDetail + } + + @Dependency(\.placeUseCase) var placeUseCase + + 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 ExploreListFeature { + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .onAppear: + guard !state.hasLoadedInitialPage else { + return .none + } + state.hasLoadedInitialPage = true + return .send(.async(.fetchPlaces(page: 1, append: false, ignoreCategory: true))) + + case .searchTextChanged(let text): + state.searchText = text + state.currentPage = 1 + state.hasNextPage = true + state.lastTriggeredLoadSpotID = nil + return .merge( + .cancel(id: CancelID.fetchPlaces), + .send(.async(.fetchPlaces(page: 1, append: false, ignoreCategory: false))) + ) + + case .categoryTapped(let category): + state.selectedCategory = category + state.lastTriggeredLoadSpotID = nil + + // 기존 데이터가 있으면 로컬 필터링만, 없으면 API 호출 + if !state.bufferedSpots.isEmpty { + return .none + } else { + state.currentPage = 1 + state.hasNextPage = true + return .merge( + .cancel(id: CancelID.fetchPlaces), + .send(.async(.fetchPlaces(page: 1, append: false, ignoreCategory: false))) + ) + } + + case .sortTapped(let sort): + state.selectedSort = sort + state.requestSortBy = sort.rawValue + state.currentPage = 1 + state.hasNextPage = true + state.lastTriggeredLoadSpotID = nil + return .merge( + .cancel(id: CancelID.fetchPlaces), + .send(.async(.fetchPlaces(page: 1, append: false, ignoreCategory: false))) + ) + + case .loadNextPage: + #logDebug("[ExploreList loadNextPage 호출] isLoading=\(state.isLoading), hasNextPage=\(state.hasNextPage)") + + guard !state.isLoading else { + #logDebug(" [ExploreList loadNextPage 차단] 로딩 중이므로 요청 차단") + // 5초 후 강제 리셋 (무한 로딩 방지) + return .run { send in + try await Task.sleep(nanoseconds: 5_000_000_000) // 5초 + await send(.inner(.forceResetLoading)) + } + } + + let visibleSpots = filteredSpots(from: state.spots, state: state) + let bufferedVisibleSpots = filteredSpots(from: state.bufferedSpots, state: state) + let currentLastSpotID = visibleSpots.last?.id + + // 버퍼가 완전히 소진되지 않았을 때만 중복 체크 + let isBufferExhausted = visibleSpots.count >= bufferedVisibleSpots.count + + if !isBufferExhausted { + guard state.lastTriggeredLoadSpotID != currentLastSpotID else { + return .none + } + } + + if visibleSpots.count < bufferedVisibleSpots.count { + state.lastTriggeredLoadSpotID = currentLastSpotID + revealNextVisibleChunk(state: &state) + return .none + } + + guard state.hasNextPage else { + return .none + } + + state.lastTriggeredLoadSpotID = currentLastSpotID + + // 현재 버퍼 크기를 기준으로 다음 페이지 계산 (size=50 기준) + let nextPage = (state.bufferedSpots.count / 50) + 1 + return .send(.async(.fetchPlaces(page: nextPage, append: true, ignoreCategory: false))) + + case .spotCardTapped(let spot): + state.$userSession.withLock { + $0.selectedExplorePlaceID = spot.id + } + return .send(.delegate(.presentExploreDetail)) + + case .syncSpotsFromExplore(let spots, let currentPage, let hasNextPage): + // Explore에서 전달받은 데이터로 동기화 + state.bufferedSpots = spots + state.spots = [] + revealNextChunk(state: &state) + state.currentPage = currentPage + state.hasNextPage = hasNextPage + state.hasLoadedInitialPage = true + return .none + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case let .fetchPlaces(page, append, ignoreCategory): + guard !state.isLoading else { + return .none + } + + state.isLoading = true + let userSession = state.userSession + let fallbackLat = userSession.travelStationLat ?? 0 + let fallbackLon = userSession.travelStationLng ?? 0 + let userLat = state.currentLocation?.latitude ?? fallbackLat + let userLon = state.currentLocation?.longitude ?? fallbackLon + let trimmedKeyword = state.searchText.trimmingCharacters(in: .whitespacesAndNewlines) + let keyword = trimmedKeyword.isEmpty ? nil : trimmedKeyword + + // ignoreCategory가 true면 카테고리 조건 무시 (초기 로딩용) + let category: ExploreCategory? = ignoreCategory ? nil : (state.selectedCategory == .all ? nil : state.selectedCategory) + let sortBy = state.requestSortBy + let mapLat = state.markerLat ?? userSession.travelStationLat + let mapLon = state.markerLon ?? userSession.travelStationLng + + return .run { send in + let result = await Result { + try await placeUseCase.fetchPlaces( + userSession: userSession, + userLat: userLat, + userLon: userLon, + keyword: keyword, + category: category, + sort: sortBy, + mapLat: mapLat, + mapLon: mapLon, + page: page + ) + } + + switch result { + case .success(let pageEntity): + await send( + .inner( + .fetchPlacesResponse( + pageEntity, + append: append, + requestedPage: page + ) + ) + ) + case .failure(let error): + await send(.inner(.fetchPlacesFailed(PlaceError.from(error)))) + } + } + .cancellable(id: CancelID.fetchPlaces, cancelInFlight: true) + + } + } + + private func handleDelegateAction( + state: inout State, + action: DelegateAction + ) -> Effect { + switch action { + case .presentExploreMapAtCurrentLocation: + return .none + case .presentExploreDetail: + return .none + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case let .fetchPlacesResponse(pageEntity, append, _): + // 무조건 로딩 해제 (중복 데이터여도) + state.isLoading = false + + #logDebug("📥 [ExploreList 응답] content.count=\(pageEntity.content.count), pageNumber=\(pageEntity.page), isLastPage=\(pageEntity.isLastPage)") + + // PlaceEntity를 ExploreMapSpot으로 변환 + let spots = makeSpots(from: pageEntity.content, userSession: state.userSession) + + #logDebug("📥 [ExploreList 변환] spots.count=\(spots.count), spots.first?.name=\(spots.first?.name ?? "nil")") + + if append { + let existingSpotIDs = Set(state.bufferedSpots.map(\.id)) + let uniqueNewSpots = spots.filter { !existingSpotIDs.contains($0.id) } + + // 페이지 업데이트 + state.currentPage = max(pageEntity.page, 1) + state.hasNextPage = !pageEntity.isLastPage + + #logDebug("📥 [ExploreList append] uniqueNewSpots.count=\(uniqueNewSpots.count), totalBuffered=\(state.bufferedSpots.count), currentPage=\(state.currentPage)") + + if !uniqueNewSpots.isEmpty { + state.bufferedSpots.append(contentsOf: uniqueNewSpots) + revealNextChunk(state: &state) + } else { + #logDebug("🚨 [ExploreList] 중복 데이터만 있음 - 무한 스크롤 계속 진행") + // 중복이어도 페이지는 증가시켜서 다음 페이지를 시도 + } + } else { + state.bufferedSpots = spots + state.spots = spots // 카테고리 변경 시 모든 데이터 바로 표시 + state.currentPage = max(pageEntity.page, 1) + state.hasNextPage = !pageEntity.isLastPage + + } + + return .none + + case .fetchPlacesFailed(let error): + state.placeError = error + state.isLoading = false + return .none + + + case .forceResetLoading: + state.isLoading = false + return .none + } + } +} + +private extension ExploreListFeature { + func revealNextChunk(state: inout State) { + let nextCount = min( + state.spots.count + State.pageChunkSize, + state.bufferedSpots.count + ) + state.spots = Array(state.bufferedSpots.prefix(nextCount)) + } + + func revealNextVisibleChunk(state: inout State) { + let currentVisibleCount = filteredSpots(from: state.spots, state: state).count + var nextCount = state.spots.count + + while nextCount < state.bufferedSpots.count { + nextCount = min(nextCount + State.pageChunkSize, state.bufferedSpots.count) + let nextSpots = Array(state.bufferedSpots.prefix(nextCount)) + let nextVisibleCount = filteredSpots(from: nextSpots, state: state).count + + state.spots = nextSpots + + if nextVisibleCount > currentVisibleCount || nextCount == state.bufferedSpots.count { + return + } + } + } + + func filteredSpots(from spots: [ExploreMapSpot], state: State) -> [ExploreMapSpot] { + let query = state.searchText.trimmingCharacters(in: .whitespacesAndNewlines) + + #logDebug("🔍 [필터링 시작] totalSpots=\(spots.count), query='\(query)', selectedCategory=\(state.selectedCategory)") + + let filtered = spots.filter { spot in + let hasDetail = spot.hasDetail + let matchesQuery = query.isEmpty || spot.name.localizedCaseInsensitiveContains(query) + let matchesCategory = state.selectedCategory == .all || spot.category == state.selectedCategory + + return hasDetail && matchesQuery && matchesCategory + } + + return filtered + } + + func makeSpots( + from places: [PlaceEntity], + userSession: UserSession + ) -> [ExploreMapSpot] { + places.map { place in + let coordinate = CLLocationCoordinate2D(latitude: place.lat, longitude: place.lon) + let closingText: String + + if let closingTime = place.closingTime, !closingTime.isEmpty { + closingText = closingTime.formattedClosingTimeText() + } else { + closingText = place.address + } + + let distanceText: String + let walkTimeText: String + + if let stationLat = userSession.travelStationLat, + let stationLon = userSession.travelStationLng { + let stationLocation = CLLocation(latitude: stationLat, longitude: stationLon) + let placeLocation = CLLocation(latitude: place.lat, longitude: place.lon) + let distanceInMeters = stationLocation.distance(from: placeLocation) + let roundedDistance = Int((distanceInMeters / 10).rounded() * 10) + let walkingMinutes = max(Int(ceil(distanceInMeters / 67)), 1) + + distanceText = "\(roundedDistance)m" + walkTimeText = "\(userSession.travelStationName)역에서 약 \(walkingMinutes)분" + } else { + distanceText = "" + walkTimeText = "" + } + + return ExploreMapSpot( + id: String(place.placeId), + name: place.name, + category: place.category, + coordinate: coordinate, + hasDetail: true, + imageURL: place.imageURL, + badgeText: place.stayableMinutes > 0 ? "\(place.stayableMinutes)분 체류 가능" : "", + subtitle: place.category.title, + statusText: place.visitable ? "영업 중" : "영업 종료", + closingText: closingText, + distanceText: distanceText, + walkTimeText: walkTimeText, + address: place.address, + visitable: place.visitable + ) + } + } +} + +extension ExploreListFeature.State { + var shouldShowInitialSkeleton: Bool { + spots.isEmpty && (!hasLoadedInitialPage || isLoading) + } + + var isFilteringLocally: Bool { + !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var shouldShowLoadMore: Bool { + !isFilteringLocally && (spots.count < bufferedSpots.count || hasNextPage) + } + + var filteredMapSpots: [ExploreMapSpot] { + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + let sourceSpots = isFilteringLocally ? bufferedSpots : spots + + return sourceSpots.filter { spot in + let hasDetail = spot.hasDetail + let matchesQuery = query.isEmpty || spot.name.localizedCaseInsensitiveContains(query) + let matchesCategory = selectedCategory == .all || spot.category == selectedCategory + + return hasDetail && matchesQuery && matchesCategory + } + } + + var shouldShowEmptyState: Bool { + !isLoading && !spots.isEmpty && filteredMapSpots.isEmpty + } +} + +extension ExploreListFeature.State: Hashable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.searchText == rhs.searchText + && lhs.selectedCategory == rhs.selectedCategory + && lhs.selectedSort == rhs.selectedSort + && lhs.spots == rhs.spots + && lhs.placeError == rhs.placeError + && lhs.currentLocation?.latitude == rhs.currentLocation?.latitude + && lhs.currentLocation?.longitude == rhs.currentLocation?.longitude + && lhs.userSession == rhs.userSession + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(searchText) + hasher.combine(selectedCategory) + hasher.combine(selectedSort) + hasher.combine(spots) + hasher.combine(placeError) + hasher.combine(currentLocation?.latitude) + hasher.combine(currentLocation?.longitude) + hasher.combine(userSession) + } +} diff --git a/Projects/Presentation/Home/Sources/ExploreList/View/ExploreListSkeletonView.swift b/Projects/Presentation/Home/Sources/ExploreList/View/ExploreListSkeletonView.swift new file mode 100644 index 0000000..c9301bb --- /dev/null +++ b/Projects/Presentation/Home/Sources/ExploreList/View/ExploreListSkeletonView.swift @@ -0,0 +1,195 @@ +// +// ExploreListSkeletonView.swift +// Home +// + +import SwiftUI +import DesignSystem + +public struct ExploreListSkeletonView: View { + public init() {} + + public var body: some View { + VStack(spacing: 0) { + // 헤더 영역 + headerSection() + .padding(.top, 8) + .padding(.horizontal, 16) + .background(.staticWhite) + + // 정렬 섹션 + sortSection() + .padding(.top, 28) + .padding(.horizontal, 16) + .background(.staticWhite) + + // 리스트 영역 + ScrollView(showsIndicators: false) { + LazyVStack(spacing: 12) { + ForEach(0..<8) { _ in + skeletonListItem() + } + + Spacer(minLength: 80) + } + .padding(.horizontal, 16) + .padding(.top, 12) + } + .background(.gray100) + } + .background(.staticWhite) + } + + @ViewBuilder + private func headerSection() -> some View { + HStack(spacing: 16) { + // 뒤로 가기 버튼 + Circle() + .fill(.gray200) + .frame(width: 40, height: 40) + .skeletonShimmer() + + // 검색창 + HStack(spacing: 8) { + // 검색 아이콘 영역 + Circle() + .fill(.gray200) + .frame(width: 20, height: 20) + .skeletonShimmer() + + // 검색 텍스트 영역 + RoundedRectangle(cornerRadius: 4) + .fill(.gray200) + .frame(height: 16) + .skeletonShimmer() + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(.gray100) + .clipShape(RoundedRectangle(cornerRadius: 24)) + } + + // 카테고리 필터 버튼들 + HStack(spacing: 12) { + ForEach(0..<4) { index in + Capsule() + .fill(.gray200) + .frame(width: index == 0 ? 60 : [80, 70, 90][index-1], height: 32) + .skeletonShimmer() + } + + Spacer() + } + .padding(.top, 16) + } + + @ViewBuilder + private func sortSection() -> some View { + HStack { + Spacer() + + // 정렬 드롭다운 + HStack(spacing: 8) { + RoundedRectangle(cornerRadius: 4) + .fill(.gray200) + .frame(width: 80, height: 14) + .skeletonShimmer() + + Circle() + .fill(.gray200) + .frame(width: 12, height: 12) + .skeletonShimmer() + } + } + } + + @ViewBuilder + private func skeletonListItem() -> some View { + HStack(alignment: .top, spacing: 16) { + // 왼쪽 텍스트 영역 + VStack(alignment: .leading, spacing: 8) { + // 제목 + RoundedRectangle(cornerRadius: 4) + .fill(.gray200) + .frame(width: 120, height: 16) + .skeletonShimmer() + + // 부제목/카테고리 + RoundedRectangle(cornerRadius: 4) + .fill(.gray200) + .frame(width: 80, height: 12) + .skeletonShimmer() + + // 상태 배지 + Capsule() + .fill(.gray200) + .frame(width: 60, height: 20) + .skeletonShimmer() + + // 거리/시간 정보 + VStack(alignment: .leading, spacing: 4) { + RoundedRectangle(cornerRadius: 4) + .fill(.gray200) + .frame(width: 100, height: 12) + .skeletonShimmer() + + RoundedRectangle(cornerRadius: 4) + .fill(.gray200) + .frame(width: 140, height: 12) + .skeletonShimmer() + } + } + + Spacer() + + // 오른쪽 이미지 영역 + RoundedRectangle(cornerRadius: 12) + .fill(.gray200) + .frame(width: 80, height: 80) + .skeletonShimmer() + } + .padding(16) + .background(.staticWhite) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } +} + +private extension View { + func skeletonShimmer() -> some View { + modifier(ExploreListSkeletonShimmerModifier()) + } +} + +private struct ExploreListSkeletonShimmerModifier: 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/Home/Sources/ExploreList/View/ExploreListView.swift b/Projects/Presentation/Home/Sources/ExploreList/View/ExploreListView.swift new file mode 100644 index 0000000..09a8718 --- /dev/null +++ b/Projects/Presentation/Home/Sources/ExploreList/View/ExploreListView.swift @@ -0,0 +1,214 @@ +// +// ExploreListView.swift +// Home +// +// Created by Wonji Suh on 3/28/26. +// + +import SwiftUI +import DesignSystem +import Entity +import LogMacro + +import ComposableArchitecture + +public struct ExploreListView: View { + @Bindable var store: StoreOf + @Environment(\.dismiss) private var dismiss + + public init( + store: StoreOf + ) { + self.store = store + } + + public var body: some View { + ZStack { + VStack(spacing: 0) { + if store.shouldShowInitialSkeleton { + ExploreListSkeletonView() + } else if store.shouldShowEmptyState { + VStack(spacing: 0) { + ExploreSearchHeaderView( + stationName: "\(store.userSession.travelStationName)역", + searchText: store.searchText, + selectedCategory: store.selectedCategory, + showCategories: true, // 카테고리 표시 + isSearchable: true, // 검색 기능 활성화 + onBackTap: { dismiss() }, + onSearchTextChanged: { store.send(.view(.searchTextChanged($0))) }, + onCategoryTap: { store.send(.view(.categoryTapped($0))) }, + onSearchBarTap: nil + ) + .padding(.top, 8) + .padding(.horizontal, 16) + .background(.staticWhite) + + emptyExploreListView() + } + } else { + ExploreSearchHeaderView( + stationName: store.userSession.travelStationName, + searchText: store.searchText, + selectedCategory: store.selectedCategory, + showCategories: true, // 카테고리 표시 + isSearchable: true, // 검색 기능 활성화 + onBackTap: { dismiss() }, + onSearchTextChanged: { store.send(.view(.searchTextChanged($0))) }, + onCategoryTap: { store.send(.view(.categoryTapped($0))) }, + onSearchBarTap: nil + ) + .padding(.top, 8) + .padding(.horizontal, 16) + .background(.staticWhite) + + sortSection() + .padding(.top, 28) + .padding(.horizontal, 16) + .background(.staticWhite) + + ScrollView(showsIndicators: false) { + LazyVStack(spacing: 12) { + let displaySpots = store.filteredMapSpots + + ForEach(displaySpots) { spot in + ExploreSpotListCardView(spot: spot, store: store) + .onAppear { + guard store.shouldShowLoadMore else { return } + + // 간단하게 마지막 3개 아이템 중 하나면 로드 + let lastFewSpots = displaySpots.suffix(3) + guard lastFewSpots.contains(where: { $0.id == spot.id }) else { return } + + store.send(.view(.loadNextPage)) + } + } + + if store.isLoading { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + } + + + // 플로팅 버튼 공간 + Spacer(minLength: 80) + } + .padding(.horizontal, 16) + .padding(.top, 12) + } + .background(.gray100) + } + } + + // 플로팅 지도보기 버튼 + VStack { + Spacer() + floatingMapButton() + } + } + .background(.staticWhite) + .onAppear { + store.send(.view(.onAppear)) + } + } +} + +private extension ExploreListView { + + @ViewBuilder + func sortSection() -> some View { + HStack(spacing: 0) { + Spacer(minLength: 16) + + Menu { + ForEach(ExploreListSort.allCases, id: \.self) { sort in + Button { + store.send(.view(.sortTapped(sort))) + } label: { + HStack(spacing: 8) { + if store.selectedSort == sort { + Image(asset: .rowCheck) + .resizable() + .scaledToFit() + .frame(width: 12, height: 12) + } + + Text(sort.title) + .pretendardCustomFont(textStyle: .bodyMedium) + .foregroundStyle(.gray800) + } + .environment(\.layoutDirection, .leftToRight) + } + } + } label: { + HStack(spacing: 8) { + Text(store.selectedSort.title) + .pretendardCustomFont(textStyle: .bodyMedium) + .foregroundStyle(.gray700) + .lineLimit(1) + .minimumScaleFactor(0.6) + .truncationMode(.tail) + .frame(maxWidth: 200, alignment: .trailing) + + Image(asset: .arrowtriangleDown) + .resizable() + .scaledToFit() + .frame(width: 12, height: 12) + } + .padding(.vertical, 4) + .padding(.horizontal, 8) + } + } + } + + @ViewBuilder + func floatingMapButton() -> some View { + Button { + store.send(.delegate(.presentExploreMapAtCurrentLocation)) + } label: { + HStack(alignment: .center, spacing: 4) { + Image(asset: .locationBadge) + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + + Text("지도보기") + .pretendardCustomFont(textStyle: .body2Bold) + .foregroundStyle(.staticWhite) + } + .padding(.horizontal, 15) + .padding(.vertical, 10) + .background(.orange800) + .clipShape(RoundedRectangle(cornerRadius: 22)) + .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4) + .shadow(color: .black.opacity(0.1), radius: 24, x: 0, y: 8) + } + .padding(.bottom, 40) + } + + @ViewBuilder + func emptyExploreListView() -> some View { + VStack { + Spacer() + + + Image(asset: .emptyExplore) + .resizable() + .scaledToFit() + .frame(width: 100, height: 100) + + Spacer() + .frame(height: 24) + + + Text("근처에 장소가 없습니다.") + .pretendardCustomFont(textStyle: .bodyMedium) + .foregroundStyle(.gray550) + + + + Spacer() + } + } +} diff --git a/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift b/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift index 49d0b7b..930da9f 100644 --- a/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift +++ b/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift @@ -58,6 +58,10 @@ public struct HomeFeature { var hasAppearedOnce: Bool = false var shouldResetAfterExplore: Bool = false @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty + + // 지속적 저장이 필요한 역 위치 + @Shared(.appStorage("nearestStationLat")) var persistedStationLat: Double = 0.0 + @Shared(.appStorage("nearestStationLng")) var persistedStationLng: Double = 0.0 } enum CustomAlertMode: Equatable, Hashable { @@ -195,13 +199,15 @@ extension HomeFeature { guard let station = row.station else { return .none } state.selectedStation = station state.selectedStationID = row.stationID - state.selectedStationName = row.stationName + state.selectedStationName = row.stationName state.isSelected = false state.hasSelectedStation = true state.trainStation = nil state.$userSession.withLock { $0.travelID = String(row.stationID) $0.travelStationName = row.stationName + $0.travelStationLat = row.lat + $0.travelStationLng = row.lng } return .merge( .cancel(id: TrainStationFeature.CancelID.checkAccessToken), @@ -255,9 +261,40 @@ extension HomeFeature { case .departureTimeChanged(let date): state.currentTime = now - state.departureTime = date + + // DatePicker에서 받은 날짜의 시와 분만 추출 + let calendar = Calendar.current + let selectedComponents = calendar.dateComponents([.hour, .minute], from: date) + + guard let selectedHour = selectedComponents.hour, + let selectedMinute = selectedComponents.minute else { + return .none + } + + // 현재 시간을 기준으로 오늘 날짜에 선택된 시간을 설정 + var targetComponents = calendar.dateComponents([.year, .month, .day], from: state.currentTime) + targetComponents.hour = selectedHour + targetComponents.minute = selectedMinute + targetComponents.second = 0 + + guard let targetDate = calendar.date(from: targetComponents) else { + return .none + } + + // 선택된 시간이 현재 시간보다 이전이면 다음날로 설정 + let finalDate = if targetDate <= state.currentTime { + calendar.date(byAdding: .day, value: 1, to: targetDate) ?? targetDate + } else { + targetDate + } + + state.departureTime = finalDate state.departureTimePickerVisible = false state.isDepartureTimeSet = true + state.$userSession.withLock { + $0.remainingMinutes = state.remainingTotalMinutes + $0.departureTime = state.departureTime + } guard state.shouldShowDepartureWarningToast else { return .none } @@ -285,18 +322,18 @@ extension HomeFeature { case .requestHomeLocationPermission: return .run { _ in - let locationManager = await LocationPermissionManager.shared - let currentStatus = await locationManager.authorizationStatus + let currentStatus = await MainActor.run { + LocationPermissionManager.shared.authorizationStatus + } guard currentStatus == .notDetermined else { return } - _ = await locationManager.requestLocationPermission() + _ = await LocationPermissionManager.shared.requestLocationPermission() } case .requestExploreLocationPermission: return .run { send in - let locationManager = await LocationPermissionManager.shared - let status = await locationManager.requestLocationPermission() + let status = await LocationPermissionManager.shared.requestLocationPermission() let isGranted = status == .authorizedWhenInUse || status == .authorizedAlways await send(.inner(.exploreLocationPermissionChecked(isGranted))) } @@ -385,19 +422,46 @@ extension HomeFeature { state.$userSession.withLock { $0.travelID = "" $0.travelStationName = "" + $0.travelStationLat = nil + $0.travelStationLng = nil + $0.remainingMinutes = 0 + $0.departureTime = nil + $0.routeDistance = 0 + $0.routeDuration = 0 + $0.nearestStationName = "" + $0.nearestStationLat = nil + $0.nearestStationLng = nil } + + // appStorage도 초기화 + state.$persistedStationLat.withLock { $0 = 0.0 } + state.$persistedStationLng.withLock { $0 = 0.0 } return .none } } } extension HomeFeature.State { + var maxDepartureTime: Date { + let calendar = Calendar.current + let now = Date() + let nextDay = calendar.date(byAdding: .day, value: 1, to: now) ?? now + + // 다음날 23:59:59까지 선택 가능하도록 설정 + var components = calendar.dateComponents([.year, .month, .day], from: nextDay) + components.hour = 23 + components.minute = 59 + components.second = 59 + + return calendar.date(from: components) ?? nextDay + } + var remainingTotalMinutes: Int { (remainingTime.hour ?? 0) * 60 + (remainingTime.minute ?? 0) } var isStationReady: Bool { - hasSelectedStation || selectedStation == .seoul + hasSelectedStation } var isExploreNearbyEnabled: Bool { @@ -433,6 +497,7 @@ extension HomeFeature.State { } } + // MARK: - HomeReducer.State + Hashable extension HomeFeature.State: Hashable { public func hash(into hasher: inout Hasher) { diff --git a/Projects/Presentation/Home/Sources/Main/View/HomeView.swift b/Projects/Presentation/Home/Sources/Main/View/HomeView.swift index 704f350..017bced 100644 --- a/Projects/Presentation/Home/Sources/Main/View/HomeView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/HomeView.swift @@ -31,11 +31,11 @@ public struct HomeView: View { enum TimeCapsule { static let height: CGFloat = 77 - static let cornerRadius: CGFloat = 28 + static let cornerRadius: CGFloat = 24 } enum TimeDisplay { - static let cornerRadius: CGFloat = 36 + static let cornerRadius: CGFloat = 28 } } @@ -149,7 +149,7 @@ extension HomeView { Image(asset: .profile) .resizable() .scaledToFit() - .frame(width: 56, height: 56) + .frame(width: 48, height: 48) } .buttonStyle(.plain) } @@ -170,7 +170,7 @@ extension HomeView { .foregroundStyle(.gray900.opacity(0.9)) .padding(.bottom, 4) - Text(store.selectedStation.homeTitle) + Text(store.hasSelectedStation ? store.selectedStation.displayName : store.selectedStation.homeTitle) .pretendardFont(family: .Bold, size: 64) .foregroundStyle(store.isSelected || store.hasSelectedStation ? .gray900 : .slateGray) .tracking(-2.2) @@ -214,7 +214,6 @@ extension HomeView { DatePicker( HomeFeature.Strings.departureTimeSelection, selection: $store.departureTime, - in: store.currentTime..., displayedComponents: [.hourAndMinute] ) .datePickerStyle(.wheel) diff --git a/Projects/Presentation/Home/Sources/Manager/LocationPermissionManager.swift b/Projects/Presentation/Home/Sources/Manager/LocationPermissionManager.swift deleted file mode 100644 index 8ec7a66..0000000 --- a/Projects/Presentation/Home/Sources/Manager/LocationPermissionManager.swift +++ /dev/null @@ -1,226 +0,0 @@ -// -// LocationPermissionManager.swift -// Home -// -// Created by Roy on 2026-03-11 -// Copyright © 2026 TimeSpot, Ltd., All rights reserved. -// - -import Foundation -import CoreLocation - -#if canImport(UIKit) -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 final class LocationPermissionManager: NSObject, ObservableObject { - - // 싱글톤 인스턴스 - public static let shared = LocationPermissionManager() - @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? - - // 지속적인 위치 업데이트 콜백 (MainActor 격리) - @MainActor - public var onLocationUpdate: (@MainActor (CLLocation) -> Void)? - @MainActor - public var onLocationError: (@MainActor (Error) -> Void)? - - public override init() { - super.init() - setupLocationManager() - } - - private func setupLocationManager() { - locationManager.delegate = self - locationManager.desiredAccuracy = kCLLocationAccuracyBest - locationManager.distanceFilter = 10 // 10미터 이상 이동시 업데이트 - authorizationStatus = locationManager.authorizationStatus - } - - // async/await을 사용한 위치 권한 요청 - public func requestLocationPermission() async -> CLAuthorizationStatus { - let isLocationServicesEnabled = await Task.detached { - CLLocationManager.locationServicesEnabled() - }.value - - guard isLocationServicesEnabled else { - locationError = "위치 서비스가 비활성화되어 있습니다. 설정에서 활성화해 주세요." - return .denied - } - - // authorizationStatus는 델리게이트에서 업데이트된 값 사용 - switch authorizationStatus { - case .notDetermined: - return await withCheckedContinuation { continuation in - self.authorizationContinuation = continuation - locationManager.requestWhenInUseAuthorization() - } - case .denied, .restricted: - locationError = "위치 권한이 거부되었습니다. 설정에서 허용해 주세요." - return authorizationStatus - case .authorizedWhenInUse, .authorizedAlways: - return authorizationStatus - @unknown default: - locationError = "알 수 없는 위치 권한 상태입니다." - return authorizationStatus - } - } - - // iOS 14+ 정확한 위치 권한 요청 - public func requestFullAccuracy() { - if #available(iOS 14.0, *) { - locationManager.requestTemporaryFullAccuracyAuthorization(withPurposeKey: "TimeSpotLocationAccuracy") - } - } - - // 위치 업데이트 시작 - public func startLocationUpdates() { - guard authorizationStatus == .authorizedWhenInUse || authorizationStatus == .authorizedAlways else { - locationError = "위치 권한이 없습니다." - return - } - - locationManager.startUpdatingLocation() - } - - // 위치 업데이트 중지 - public func stopLocationUpdates() { - locationManager.stopUpdatingLocation() - } - - // async/await을 사용한 현재 위치 가져오기 - public func requestCurrentLocation() async throws -> CLLocation? { - guard authorizationStatus == .authorizedWhenInUse || authorizationStatus == .authorizedAlways else { - locationError = "위치 권한이 없습니다." - throw LocationError.permissionDenied - } - - return try await withCheckedThrowingContinuation { continuation in - self.locationContinuation = continuation - - if #available(iOS 14.0, *) { - locationManager.requestLocation() - } else { - // iOS 14 이전에서는 잠시 업데이트하고 중지 - locationManager.startUpdatingLocation() - Task { - try await Task.sleep(for: .seconds(3)) - self.stopLocationUpdates() - } - } - } - } - - // 설정 앱으로 이동 - public func openLocationSettings() { - #if canImport(UIKit) - Task { @MainActor in - if let settingsUrl = URL(string: UIApplication.openSettingsURLString), - UIApplication.shared.canOpenURL(settingsUrl) { - await UIApplication.shared.open(settingsUrl) - } - } - #endif - } - - // 위치 서비스 사용 가능 여부 - public func isLocationServicesEnabled() async -> Bool { - await Task.detached { - CLLocationManager.locationServicesEnabled() - }.value - } - - // 권한 상태 문자열 - public var authorizationStatusString: String { - switch authorizationStatus { - case .notDetermined: - return "권한 미결정" - case .restricted: - return "권한 제한됨" - case .denied: - return "권한 거부됨" - case .authorizedAlways: - return "항상 허용" - case .authorizedWhenInUse: - return "사용 중 허용" - @unknown default: - return "알 수 없음" - } - } -} - -// MARK: - CLLocationManagerDelegate -extension LocationPermissionManager: CLLocationManagerDelegate { - - nonisolated public func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { - guard let location = locations.last else { return } - - Task { @MainActor in - self.currentLocation = location - self.locationError = nil - - // 지속적인 위치 업데이트 콜백 호출 - await self.onLocationUpdate?(location) - - // continuation이 있으면 결과 반환 (일회성 요청용) - if let continuation = self.locationContinuation { - self.locationContinuation = nil - continuation.resume(returning: location) - } - } - } - - nonisolated public func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { - Task { @MainActor in - self.locationError = "위치 업데이트 실패: \(error.localizedDescription)" - - // 지속적인 위치 업데이트 에러 콜백 호출 - await self.onLocationError?(error) - - // continuation이 있으면 에러 반환 (일회성 요청용) - if let continuation = self.locationContinuation { - self.locationContinuation = nil - continuation.resume(throwing: error) - } - } - } - - nonisolated public func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { - Task { @MainActor in - self.authorizationStatus = status - self.locationError = nil - - // continuation이 있으면 권한 상태 반환 - if let continuation = self.authorizationContinuation { - self.authorizationContinuation = nil - continuation.resume(returning: status) - } - } - } -} diff --git a/Projects/Presentation/Home/Sources/Manager/NaverMapInitializer.swift b/Projects/Presentation/Home/Sources/Manager/NaverMapInitializer.swift index bd779f3..9e8c066 100644 --- a/Projects/Presentation/Home/Sources/Manager/NaverMapInitializer.swift +++ b/Projects/Presentation/Home/Sources/Manager/NaverMapInitializer.swift @@ -2,7 +2,7 @@ // NaverMapInitializer.swift // Home // -// Created by Claude on 3/12/26. +// Created by Wonji Suh on 3/12/26. // import Foundation diff --git a/Projects/Presentation/Home/Sources/Route/Components/RemainingTimeCard.swift b/Projects/Presentation/Home/Sources/Route/Components/RemainingTimeCard.swift new file mode 100644 index 0000000..111eee1 --- /dev/null +++ b/Projects/Presentation/Home/Sources/Route/Components/RemainingTimeCard.swift @@ -0,0 +1,180 @@ +// +// RemainingTimeCard.swift +// Home +// +// Created by Wonji Suh on 3/30/26. +// + +import SwiftUI +import DesignSystem +import Entity +import Utill + +/// 예상 남은 시간 및 거리 정보를 표시하는 카드 +public struct RemainingTimeCard: View { + let routeInfo: RouteInfo + let destinationName: String + + public init( + routeInfo: RouteInfo, + destinationName: String + ) { + self.routeInfo = routeInfo + self.destinationName = destinationName + } + + public var body: some View { + VStack(spacing: 0) { + cardContent() + } + .background(Color.white) + .cornerRadius(20) + .shadow( + color: Color.black.opacity(0.1), + radius: 10, + x: 0, + y: 2 + ) + .padding(.horizontal, 16) + .padding(.bottom, 32) + } + + @ViewBuilder + private func cardContent() -> some View { + VStack(spacing: 16) { + // 상단 목적지 정보 + headerSection() + + // 중앙 시간/거리 정보 + timeDistanceSection() + + // 하단 예상 도착 시간 + arrivalTimeSection() + } + .padding(20) + } + + @ViewBuilder + private func headerSection() -> some View { + HStack { + Image(systemName: "location.fill") + .foregroundColor(.blue) + .font(.system(size: 16, weight: .medium)) + + Text(destinationName) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.primary) + + Spacer() + + Text("도보") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.gray.opacity(0.1)) + .cornerRadius(8) + } + } + + @ViewBuilder + private func timeDistanceSection() -> some View { + HStack(spacing: 32) { + // 남은 시간 (가장 크게) + VStack(alignment: .leading, spacing: 4) { + Text("남은 시간") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.secondary) + + Text(routeInfo.duration.formattedDuration) + .font(.system(size: 32, weight: .bold)) + .foregroundColor(.primary) + } + + Spacer() + + // 남은 거리 + VStack(alignment: .trailing, spacing: 4) { + Text("남은 거리") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.secondary) + + Text(routeInfo.distance.formattedDistance) + .font(.system(size: 20, weight: .semibold)) + .foregroundColor(.blue) + } + } + } + + @ViewBuilder + private func arrivalTimeSection() -> some View { + HStack { + Image(systemName: "clock") + .foregroundColor(.green) + .font(.system(size: 14)) + + Text("\(estimatedArrivalTime) 도착 예정") + .font(.system(size: 15, weight: .medium)) + .foregroundColor(.primary) + + Spacer() + } + .padding(.top, 8) + .overlay( + Rectangle() + .frame(height: 1) + .foregroundColor(Color.gray.opacity(0.2)), + alignment: .top + ) + } + + /// 예상 도착 시간 계산 + private var estimatedArrivalTime: String { + let now = Date() + let arrivalDate = now.addingTimeInterval(TimeInterval(routeInfo.duration * 60)) + + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "HH:mm" + + return formatter.string(from: arrivalDate) + } +} + +#Preview { + VStack { + Spacer() + + RemainingTimeCard( + routeInfo: RouteInfo( + paths: [], + distance: 1250, // 1.25km로 표시됨 + duration: 15, // 15분 + tollFare: 0, + taxiFare: 0 + ), + destinationName: "서울학도병참전기념비역" + ) + } + .background(Color.gray.opacity(0.1)) + .edgesIgnoringSafeArea(.all) +} + +#Preview("Long Distance") { + VStack { + Spacer() + + RemainingTimeCard( + routeInfo: RouteInfo( + paths: [], + distance: 2500, // 2.5km로 표시됨 + duration: 75, // 1시간 15분으로 표시됨 + tollFare: 0, + taxiFare: 0 + ), + destinationName: "홍대입구역" + ) + } + .background(Color.gray.opacity(0.1)) + .edgesIgnoringSafeArea(.all) +} \ No newline at end of file diff --git a/Projects/Presentation/Home/Sources/Route/Components/RouteSkeletonView.swift b/Projects/Presentation/Home/Sources/Route/Components/RouteSkeletonView.swift new file mode 100644 index 0000000..0b59824 --- /dev/null +++ b/Projects/Presentation/Home/Sources/Route/Components/RouteSkeletonView.swift @@ -0,0 +1,181 @@ +// +// RouteSkeletonView.swift +// Home +// +// Created by Wonji Suh on 3/30/26. +// + +import SwiftUI +import DesignSystem + +/// 경로 계산 중 로딩 상태를 보여주는 스켈레톤 뷰 +public struct RouteSkeletonView: View { + public init() {} + + public var body: some View { + ZStack { + VStack { + // 🏷️ 상단 헤더 스켈레톤 (역 이름) + headerSkeleton() + .padding(.horizontal, 16) + .padding(.top, 8) + + Spacer() + + // 🗺️ 하단 길찾기 시작 버튼 + routeStartButtonSkeleton() + .padding(.horizontal, 16) + .padding(.bottom, 32) + } + + // 📊 중앙 상단에 떠있는 경로 정보 카드 + VStack { + routeInfoCardSkeleton() + .padding(.horizontal, 16) + .padding(.top, 80) // 헤더 아래 위치 + + Spacer() + } + } + } + + @ViewBuilder + private func headerSkeleton() -> some View { + VStack(spacing: 12) { + HStack { + // 뒤로 가기 버튼 + Circle() + .fill(.gray200) + .frame(width: 40, height: 40) + .skeletonShimmer() + + // 역 이름 + RoundedRectangle(cornerRadius: 20) + .fill(.gray200) + .frame(height: 40) + .frame(maxWidth: .infinity) + .skeletonShimmer() + + // 오른쪽 공간 (대칭을 위해) + Circle() + .fill(.clear) + .frame(width: 40, height: 40) + } + } + } + + @ViewBuilder + private func routeInfoCardSkeleton() -> some View { + VStack(alignment: .leading, spacing: 12) { + // 도보 라벨 + HStack { + RoundedRectangle(cornerRadius: 6) + .fill(.gray200) + .frame(width: 30, height: 16) + .skeletonShimmer() + + Spacer() + } + + // 큰 시간과 거리 정보 + HStack(alignment: .center) { + // 왼쪽: 큰 시간 텍스트 (3시간 30분) + VStack(alignment: .leading, spacing: 4) { + RoundedRectangle(cornerRadius: 8) + .fill(.gray200) + .frame(width: 120, height: 36) + .skeletonShimmer() + } + + Spacer() + + // 오른쪽: 거리 텍스트 (16.4km) + VStack(alignment: .trailing) { + RoundedRectangle(cornerRadius: 6) + .fill(.gray200) + .frame(width: 60, height: 24) + .skeletonShimmer() + } + } + + // 예상 도착 시간 + HStack { + Circle() + .fill(.gray200) + .frame(width: 16, height: 16) + .skeletonShimmer() + + RoundedRectangle(cornerRadius: 4) + .fill(.gray200) + .frame(width: 120, height: 16) + .skeletonShimmer() + + Spacer() + } + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(.white) + .shadow(color: .black.opacity(0.1), radius: 8, x: 0, y: 4) + ) + } + + @ViewBuilder + private func routeStartButtonSkeleton() -> some View { + RoundedRectangle(cornerRadius: 24) + .fill(.gray300) + .frame(height: 48) + .skeletonShimmer() + } +} + +private extension View { + func skeletonShimmer() -> some View { + modifier(RouteSkeletonShimmerModifier()) + } +} + +private struct RouteSkeletonShimmerModifier: 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.5) + .repeatForever(autoreverses: false) + ) { + isAnimating = true + } + } + } +} + +#Preview { + ZStack { + Color.gray.opacity(0.1) + .edgesIgnoringSafeArea(.all) + + RouteSkeletonView() + } +} \ No newline at end of file diff --git a/Projects/Presentation/Home/Sources/Route/Reducer/RouteFeature.swift b/Projects/Presentation/Home/Sources/Route/Reducer/RouteFeature.swift new file mode 100644 index 0000000..d289e83 --- /dev/null +++ b/Projects/Presentation/Home/Sources/Route/Reducer/RouteFeature.swift @@ -0,0 +1,476 @@ +// +// RouteFeature.swift +// Home +// +// Created by Wonji Suh on 3/30/26. +// + + +import Foundation +import ComposableArchitecture +import UIKit +import Entity +import CoreLocation +import UseCase +import LogMacro + +// MARK: - Journey Error +public enum JourneyError: Error { + case message(String) + + public var localizedDescription: String { + switch self { + case .message(let string): + return string + } + } +} + + +@Reducer +public struct RouteFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty + @Shared(.appStorage("selectedMapType")) var selectedMapTypeStorage: ExternalMapType = .naverMap + @Shared(.appStorage("nearestStationLat")) var persistedStationLat: Double = 0.0 + @Shared(.appStorage("nearestStationLng")) var persistedStationLng: Double = 0.0 + @Shared(.appStorage("visitingHistoryId")) var visitingHistoryId: Int = 0 + public var locationPermissionStatus: CLAuthorizationStatus = .notDetermined + public var currentLocation: CLLocation? + public var routeInfo: RouteInfo? + public var isLoadingRoute: Bool = false + public var routeError: String? + + 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 searchRoute + case startNavigation + case startJourney + } + + + + //MARK: - AsyncAction 비동기 처리 액션 + public enum AsyncAction: Equatable { + case startLocationUpdates + case waitForLocationThenSearchRoute + case searchRoute(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D) + case startNavigation(mapType: ExternalMapType, destination: CLLocationCoordinate2D, destinationName: String) + case startJourney + } + + //MARK: - 앱내에서 사용하는 액션 + public enum InnerAction: Equatable { + case locationPermissionStatusChanged(CLAuthorizationStatus) + case locationUpdated(CLLocation) + case routeSearchResponse(Result) + case journeyStartResponse(Result) + } + + //MARK: - NavigationAction + public enum DelegateAction: Equatable { + + + } + + @Dependency(\.getRouteUseCase) var getRouteUseCase + @Dependency(\.locationUseCase) var locationUseCase + @Dependency(\.historyRepository) var historyRepository + @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 RouteFeature { + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .onAppear: + let hasDestination = state.userSession.routeDestinationLat != nil && + state.userSession.routeDestinationLng != nil + + + if hasDestination { + return .merge( + .run { send in + let currentStatus = await locationUseCase.getAuthorizationStatus() + await send(.inner(.locationPermissionStatusChanged(currentStatus))) + }, + .send(.async(.startLocationUpdates)), + .send(.async(.waitForLocationThenSearchRoute)) + ) + } else { + return .merge( + .run { send in + let currentStatus = await locationUseCase.getAuthorizationStatus() + await send(.inner(.locationPermissionStatusChanged(currentStatus))) + }, + .send(.async(.startLocationUpdates)) + ) + } + + case .searchRoute: + guard let startLat = state.userSession.routeStartLat, + let startLng = state.userSession.routeStartLng, + let endLat = state.userSession.routeDestinationLat, + let endLng = state.userSession.routeDestinationLng else { + return .none + } + + let startCoord = CLLocationCoordinate2D(latitude: startLat, longitude: startLng) + let endCoord = CLLocationCoordinate2D(latitude: endLat, longitude: endLng) + return .send(.async(.searchRoute(from: startCoord, to: endCoord))) + + case .startNavigation: + guard let endLat = state.userSession.routeDestinationLat, + let endLng = state.userSession.routeDestinationLng else { + return .none + } + + let destination = CLLocationCoordinate2D(latitude: endLat, longitude: endLng) + let destinationName = state.userSession.routeDestinationName.isEmpty ? "목적지" : state.userSession.routeDestinationName + let mapType = state.selectedMapTypeStorage + + return .send(.async(.startNavigation(mapType: mapType, destination: destination, destinationName: destinationName))) + + case .startJourney: + // keychainManager에서 accessToken 확인 + return .run { [userSession = state.userSession, selectedMapType = state.selectedMapTypeStorage] send in + let result = await Result { + await keychainManager.accessToken() + } + + let accessToken = (try? result.get()) ?? "" + + if accessToken.isEmpty { + // accessToken이 없으면 바로 네비게이션 시작 + #logDebug("🔑 accessToken이 없습니다. 바로 네비게이션을 시작합니다.") + + guard let endLat = userSession.routeDestinationLat, + let endLng = userSession.routeDestinationLng else { + return + } + + let destination = CLLocationCoordinate2D(latitude: endLat, longitude: endLng) + let destinationName = userSession.routeDestinationName.isEmpty ? "목적지" : userSession.routeDestinationName + + await send(.async(.startNavigation(mapType: selectedMapType, destination: destination, destinationName: destinationName))) + } else { + // accessToken이 있으면 API 통신 후 네비게이션 + #logDebug("🔑 accessToken이 있습니다. API 통신을 시작합니다.") + await send(.async(.startJourney)) + } + } + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case .startLocationUpdates: + return .run { send in + await locationUseCase.startLocationUpdates( + onUpdate: { location in + Task { @MainActor in + send(.inner(.locationUpdated(location))) + } + }, + onError: { error in + #logDebug(" [Route] 위치 업데이트 실패: \(error.localizedDescription)") + } + ) + + // 초기 위치도 가져오기 + do { + if let location = try await locationUseCase.requestCurrentLocation() { + await send(.inner(.locationUpdated(location))) + } + } catch { + #logDebug(" [Route] 현재 위치 가져오기 실패: \(error.localizedDescription)") + } + } + + case .waitForLocationThenSearchRoute: + return .run { [userSession = state.userSession] send in + // 현재 위치를 한 번만 요청하고 실패하면 종료 + do { + if let currentLocation = try await locationUseCase.requestCurrentLocation(), + let endLat = userSession.routeDestinationLat, + let endLng = userSession.routeDestinationLng { + + let startCoord = currentLocation.coordinate + let endCoord = CLLocationCoordinate2D(latitude: endLat, longitude: endLng) + + await send(.async(.searchRoute(from: startCoord, to: endCoord))) + } + } catch { + #logDebug("❌ [Route] 현재 위치 요청 실패: \(error.localizedDescription)") + } + } + + case .searchRoute(let from, let to): + state.isLoadingRoute = true + return .run { send in + let result = await Result { + try await getRouteUseCase.execute(from: from, to: to, option: .walking) + } + .mapError(DirectionError.from) + + await send(.inner(.routeSearchResponse(result))) + } + + case .startNavigation(let mapType, let destination, let destinationName): + return .run { _ in + await getRouteUseCase.startNavigation( + mapType: mapType, + destination: destination, + destinationName: destinationName + ) + } + + case .startJourney: + return .run { [userSession = state.userSession, currentLocation = state.currentLocation] send in + guard let departureTime = userSession.departureTime, + let currentLocation = currentLocation else { + await send(.inner(.journeyStartResponse(.failure(.message("필요한 정보가 부족합니다."))))) + return + } + + // UserSession에서 필요한 정보 추출 + let stationIdString = userSession.travelID + let placeIdString = userSession.selectedExplorePlaceID + + guard let stationId = Int(stationIdString), + !stationIdString.isEmpty, + !placeIdString.isEmpty else { + await send(.inner(.journeyStartResponse(.failure(.message("역 정보 또는 장소 정보가 없습니다."))))) + return + } + + let placeId = placeIdString + + let input = StartJourneyInput( + stationId: stationId, + placeId: placeId, + trainDepartureTime: departureTime, + lat: currentLocation.coordinate.latitude, + lng: currentLocation.coordinate.longitude + ) + + do { + let journey = try await historyRepository.startJourney(input: input) + await send(.inner(.journeyStartResponse(.success(journey)))) + } catch { + await send(.inner(.journeyStartResponse(.failure(.message(error.localizedDescription))))) + } + } + } + } + + private func handleDelegateAction( + state: inout State, + action: DelegateAction + ) -> Effect { + switch action { + + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case .locationPermissionStatusChanged(let status): + state.locationPermissionStatus = status + return .none + + case .locationUpdated(let location): + state.currentLocation = location + + // 현재 위치가 업데이트되면 UserSession에 출발지로 저장하고 자동 경로 검색 + state.$userSession.withLock { + $0.routeStartLat = location.coordinate.latitude + $0.routeStartLng = location.coordinate.longitude + } + + // 목적지 정보가 있으면 자동으로 경로 검색 + if let endLat = state.userSession.routeDestinationLat, + let endLng = state.userSession.routeDestinationLng { + let endCoord = CLLocationCoordinate2D(latitude: endLat, longitude: endLng) + return .send(.async(.searchRoute(from: location.coordinate, to: endCoord))) + } + return .none + + case .routeSearchResponse(let result): + state.isLoadingRoute = false + switch result { + case .success(let routeInfo): + state.routeInfo = routeInfo + state.routeError = nil + + // UserSession에 경로 정보 저장 + state.$userSession.withLock { + $0.routeDistance = routeInfo.distance + $0.routeDuration = routeInfo.duration + } + + // 목적지를 가장 가까운 역으로 appStorage에 저장 (지속적 저장) + if let destLat = state.userSession.routeDestinationLat, + let destLng = state.userSession.routeDestinationLng { + state.$persistedStationLat.withLock { $0 = destLat } + state.$persistedStationLng.withLock { $0 = destLng } + } + + case .failure(let error): + state.routeError = error.localizedDescription + } + return .none + + case .journeyStartResponse(let result): + switch result { + case .success(let journey): + #logDebug("✅ 여정 시작 성공: \(journey.id)") + + // visitingHistoryId를 appStorage에 저장 + state.$visitingHistoryId.withLock { $0 = journey.id } + + // 여정 시작 성공 시 길찾기 시작 + guard let endLat = state.userSession.routeDestinationLat, + let endLng = state.userSession.routeDestinationLng else { + return .none + } + + let destination = CLLocationCoordinate2D(latitude: endLat, longitude: endLng) + let destinationName = state.userSession.routeDestinationName.isEmpty ? "목적지" : state.userSession.routeDestinationName + let mapType = state.selectedMapTypeStorage + + return .send(.async(.startNavigation(mapType: mapType, destination: destination, destinationName: destinationName))) + + case .failure(let error): + #logDebug("❌ 여정 시작 실패: \(error)") + + // API 실패 시에도 길찾기는 시작 + guard let endLat = state.userSession.routeDestinationLat, + let endLng = state.userSession.routeDestinationLng else { + return .none + } + + let destination = CLLocationCoordinate2D(latitude: endLat, longitude: endLng) + let destinationName = state.userSession.routeDestinationName.isEmpty ? "목적지" : state.userSession.routeDestinationName + let mapType = state.selectedMapTypeStorage + + return .send(.async(.startNavigation(mapType: mapType, destination: destination, destinationName: destinationName))) + } + } + } +} + +extension RouteFeature.State: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(locationPermissionStatus) + hasher.combine(currentLocation?.coordinate.latitude) + hasher.combine(currentLocation?.coordinate.longitude) + hasher.combine(routeInfo?.distance) + hasher.combine(routeInfo?.duration) + hasher.combine(isLoadingRoute) + hasher.combine(routeError) + hasher.combine(userSession) + hasher.combine(visitingHistoryId) + } +} + +// MARK: - Equatable Extensions + +extension RouteFeature.AsyncAction { + public static func == (lhs: RouteFeature.AsyncAction, rhs: RouteFeature.AsyncAction) -> Bool { + switch (lhs, rhs) { + case (.startLocationUpdates, .startLocationUpdates): + return true + case (.waitForLocationThenSearchRoute, .waitForLocationThenSearchRoute): + return true + case (.searchRoute(let lhsFrom, let lhsTo), .searchRoute(let rhsFrom, let rhsTo)): + let fromLatEqual = lhsFrom.latitude == rhsFrom.latitude + let fromLngEqual = lhsFrom.longitude == rhsFrom.longitude + let toLatEqual = lhsTo.latitude == rhsTo.latitude + let toLngEqual = lhsTo.longitude == rhsTo.longitude + return fromLatEqual && fromLngEqual && toLatEqual && toLngEqual + case (.startNavigation(let lhsType, let lhsDestination, let lhsName), + .startNavigation(let rhsType, let rhsDestination, let rhsName)): + let typeEqual = lhsType == rhsType + let latEqual = lhsDestination.latitude == rhsDestination.latitude + let lngEqual = lhsDestination.longitude == rhsDestination.longitude + let nameEqual = lhsName == rhsName + return typeEqual && latEqual && lngEqual && nameEqual + case (.startJourney, .startJourney): + return true + default: + return false + } + } +} + +extension RouteFeature.InnerAction { + public static func == (lhs: RouteFeature.InnerAction, rhs: RouteFeature.InnerAction) -> Bool { + switch (lhs, rhs) { + case (.locationPermissionStatusChanged(let lhsStatus), .locationPermissionStatusChanged(let rhsStatus)): + return lhsStatus == rhsStatus + case (.locationUpdated(let lhsLocation), .locationUpdated(let rhsLocation)): + let latEqual = lhsLocation.coordinate.latitude == rhsLocation.coordinate.latitude + let lngEqual = lhsLocation.coordinate.longitude == rhsLocation.coordinate.longitude + return latEqual && lngEqual + case (.routeSearchResponse(.success(let lhsRoute)), .routeSearchResponse(.success(let rhsRoute))): + return lhsRoute.distance == rhsRoute.distance && lhsRoute.duration == rhsRoute.duration + case (.routeSearchResponse(.failure(let lhsError)), .routeSearchResponse(.failure(let rhsError))): + return lhsError.localizedDescription == rhsError.localizedDescription + case (.journeyStartResponse(.success(let lhsJourney)), .journeyStartResponse(.success(let rhsJourney))): + return lhsJourney.id == rhsJourney.id + case (.journeyStartResponse(.failure(let lhsError)), .journeyStartResponse(.failure(let rhsError))): + return lhsError.localizedDescription == rhsError.localizedDescription + default: + return false + } + } +} diff --git a/Projects/Presentation/Home/Sources/Route/View/RouteView.swift b/Projects/Presentation/Home/Sources/Route/View/RouteView.swift new file mode 100644 index 0000000..d295fea --- /dev/null +++ b/Projects/Presentation/Home/Sources/Route/View/RouteView.swift @@ -0,0 +1,216 @@ +// +// RouteView.swift +// Home +// +// Created by Wonji Suh on 3/30/26. +// + +import SwiftUI +import CoreLocation +import DesignSystem +import Entity +import ComposableArchitecture +import LogMacro +import Utill + +public struct RouteView: View { + @Bindable var store: StoreOf + @Environment(\.dismiss) private var dismiss + @Environment(\.openURL) private var openURL + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + ZStack { + naverMap() + .edgesIgnoringSafeArea(.all) + + // 🦴 경로 계산 중일 때는 스켈레톤 전체 화면으로 표시 + if store.routeInfo == nil { + RouteSkeletonView() + .transition(.opacity) + .animation(.easeInOut(duration: 0.3), value: store.routeInfo == nil) + } else { + // ✅ 경로 계산 완료 후 실제 UI 표시 + VStack { + headerSection() + + remainingTimeCard() + .padding(.top, 12) + .transition(.scale.combined(with: .opacity)) + .animation(.easeInOut(duration: 0.3), value: store.routeInfo != nil) + + Spacer() + + // 🧭 길찾기 버튼 (하단에서 32만큼 떨어진 위치) + routeStartButton() + .padding(.bottom, 32) + } + .padding(.horizontal, 16) + .transition(.scale.combined(with: .opacity)) + .animation(.easeInOut(duration: 0.3), value: store.routeInfo != nil) + } + } + .onAppear { + store.send(.view(.onAppear)) + } + } +} + + +private extension RouteView { + @ViewBuilder + func naverMap() -> some View { + // 스켈레톤 로딩 중에는 마커와 경로 정보를 숨김 + let destination = store.isLoadingRoute ? nil : makeDestination() + let travelStation = store.isLoadingRoute ? nil : makeTravelStation() + let routeInfo = store.isLoadingRoute ? nil : store.routeInfo + + // 경로 정보가 업데이트되면 카메라 자동 조정 트리거 증가 + let autoFitTrigger = store.routeInfo != nil ? 1 : 0 + + NaverMapComponent( + locationPermissionStatus: store.locationPermissionStatus, + currentLocation: store.currentLocation, + routeInfo: routeInfo, + destination: destination, + travelStation: travelStation, + spots: [], + selectedSpotID: nil, + returnToLocationTrigger: 0, + autoFitTrigger: autoFitTrigger + ) + } + + private func makeDestination() -> Destination? { + makeLocation( + lat: store.userSession.routeDestinationLat, + lng: store.userSession.routeDestinationLng, + name: store.userSession.routeDestinationName.nilIfEmpty, + defaultName: "목적지" + ) + } + + private func makeTravelStation() -> Destination? { + makeLocation( + lat: store.userSession.travelStationLat, + lng: store.userSession.travelStationLng, + name: store.userSession.travelStationName.nilIfEmpty, + defaultName: "출발역" + ) + } + + private func makeLocation(lat: Double?, lng: Double?, name: String?, defaultName: String) -> Destination? { + guard let lat = lat, let lng = lng else { return nil } + + return Destination( + name: name ?? defaultName, + coordinate: CLLocationCoordinate2D(latitude: lat, longitude: lng) + ) + } + + private func makeSelectedSpotForRoute() -> [ExploreMapSpot] { + guard !store.userSession.selectedExploreSpotID.isEmpty, + let lat = store.userSession.routeDestinationLat, + let lng = store.userSession.routeDestinationLng else { + return [] + } + + let selectedSpot = ExploreMapSpot( + id: store.userSession.selectedExploreSpotID, + name: store.userSession.routeDestinationName.nilIfEmpty ?? "선택된 스팟", + category: .etc, // 기본 카테고리 + coordinate: CLLocationCoordinate2D(latitude: lat, longitude: lng), + hasDetail: true, + imageURL: nil, + badgeText: "", + subtitle: "", + statusText: "", + closingText: "", + distanceText: "", + walkTimeText: "", + address: "", + visitable: true + ) + + return [selectedSpot] + } + + @ViewBuilder + func headerSection() -> some View { + ExploreSearchHeaderView( + stationName: store.userSession.routeDestinationName, + showCategories: false, + isSearchable: false, + onBackTap: { dismiss() } + ) + .padding(.top, 8) + } + + @ViewBuilder + func remainingTimeCard() -> some View { + VStack(alignment: .leading) { + HStack { + Text("도보") + .pretendardCustomFont(textStyle: .body2Medium) + .foregroundStyle(.staticBlack) + + Spacer() + } + + Spacer() + .frame(height: 4) + + HStack{ + Text((store.routeInfo?.duration ?? 0).formattedDuration) + .pretendardCustomFont(textStyle: .heading1) + .foregroundStyle(.orange800) + + Spacer() + + Text((store.routeInfo?.distance ?? 0).formattedDistance) + .pretendardCustomFont(textStyle: .bodyMedium) + .foregroundStyle(.gray800) + } + + Spacer() + .frame(height: 8) + + HStack { + Image(systemName: "clock") + .foregroundStyle(.gray600) + .font(.system(size: 12)) + + Text("\(Date.estimatedArrivalTime(durationMinutes: store.routeInfo?.duration ?? 0)) 도착 예정") + .pretendardCustomFont(textStyle: .caption) + .foregroundStyle(.gray600) + + Spacer() + } + } + .padding(.horizontal, 24) + .padding(.vertical, 20) + .background( + RoundedRectangle(cornerRadius: 28) + .stroke(.orange500, style: .init(lineWidth: 1)) + .background(.orange100) + ) + .cornerRadius(28) + } + + @ViewBuilder + private func routeStartButton() -> some View { + CustomButton( + action: { + // 여정 시작 API 호출 + store.send(.view(.startJourney)) + }, + title: "길찾기 시작", + config: CustomButtonConfig.create(), + isEnable: store.routeInfo != nil + ) + .padding(.horizontal, 16) + } +} diff --git a/Projects/Presentation/Home/Sources/RouteNotification/Components/NotificationContentView.swift b/Projects/Presentation/Home/Sources/RouteNotification/Components/NotificationContentView.swift new file mode 100644 index 0000000..a123e4b --- /dev/null +++ b/Projects/Presentation/Home/Sources/RouteNotification/Components/NotificationContentView.swift @@ -0,0 +1,204 @@ +// +// NotificationContentView.swift +// Home +// +// Created by Wonji Suh on 4/1/26. +// + +import SwiftUI +import DesignSystem +import ComposableArchitecture + +public struct NotificationContentView: View { + private let titlePart1: String + private let highlightText: String + private let titlePart3: String + private let subtitle: String + private let image: ImageAsset + private let store: StoreOf + private let showBottomElements: Bool + private let isEndJourney: Bool + + public init( + titlePart1: String, + highlightText: String, + titlePart3: String, + subtitle: String, + image: ImageAsset, + store: StoreOf, + showBottomElements: Bool, + isEndJourney: Bool = false + ) { + self.titlePart1 = titlePart1 + self.highlightText = highlightText + self.titlePart3 = titlePart3 + self.subtitle = subtitle + self.image = image + self.store = store + self.showBottomElements = showBottomElements + self.isEndJourney = isEndJourney + } + + public var body: some View { + VStack(alignment: .center, spacing: 0) { + // 상단 여백 + Spacer() + .frame(height: 80) + + + // 메인 타이틀 + if isEndJourney { + // 여정 종료: 단순한 텍스트 + Text(titlePart1) + .pretendardCustomFont(textStyle: .heading1) + .foregroundStyle(.gray900) + .multilineTextAlignment(.center) + } else { + // 기존 로직 + VStack(spacing: 0) { + // titlePart1이 있고 줄바꿈이 있는 경우 (15분 케이스) + if !titlePart1.isEmpty && titlePart1.contains("\n") { + Text(titlePart1.replacingOccurrences(of: "\n", with: "")) + .pretendardCustomFont(textStyle: .heading1) + .foregroundStyle(.gray900) + .multilineTextAlignment(.center) + + HStack(spacing: 0) { + Text(highlightText) + .pretendardCustomFont(textStyle: .heading1) + .foregroundStyle(.orange800) + + Text(titlePart3) + .pretendardCustomFont(textStyle: .heading1) + .foregroundStyle(.gray900) + } + } + // titlePart3가 있고 줄바꿈이 있는 경우 (5분 케이스) + else if titlePart3.contains("\n") { + let parts = titlePart3.components(separatedBy: "\n") + + HStack(spacing: 0) { + Text(highlightText) + .pretendardCustomFont(textStyle: .heading1) + .foregroundStyle(.orange800) + + if parts.count > 0 { + Text(parts[0]) + .pretendardCustomFont(textStyle: .heading1) + .foregroundStyle(.gray900) + } + } + + if parts.count > 1 { + Text(parts[1]) + .pretendardCustomFont(textStyle: .heading1) + .foregroundStyle(.gray900) + .multilineTextAlignment(.center) + } + } + // 그 외의 경우 (10분, 지금 바로 케이스) - 한 줄로 표시 + else { + HStack(spacing: 0) { + Text(highlightText) + .pretendardCustomFont(textStyle: .heading1) + .foregroundStyle(.orange800) + + if !titlePart3.isEmpty { + Text(titlePart3) + .pretendardCustomFont(textStyle: .heading1) + .foregroundStyle(.gray900) + } + } + } + } + .multilineTextAlignment(.center) + } + + + Spacer() + .frame(height: 16) + + // 서브 타이틀 + Text(subtitle) + .pretendardCustomFont(textStyle: .bodyMedium) + .foregroundStyle(.gray800) + .multilineTextAlignment(.center) + .lineLimit(nil) + + if isEndJourney { + + Spacer() + .frame(height: 33) + + Image(asset: image) + .resizable() + .scaledToFit() + .frame(maxHeight: 320) + + } else { + + Spacer() + .frame(height: 24) + + Image(asset: image) + .resizable() + .scaledToFit() + } + + + if isEndJourney { + // 여정 종료: 종료하기 버튼만 표시 + Spacer() + .frame(height: 140) + + RouteNotificationButton( + title: "종료하기", + backgroundColor: .navy900, + foregroundColor: .white, + action: { store.send(.view(.closeButtonTapped)) } + ) + .padding(.horizontal, 24) + + Spacer() + .frame(height: 24) + } else if showBottomElements { + Spacer() + .frame(height: 28) + + // 열차 시간 + Text(store.formattedDepartureTime) + .pretendardCustomFont(textStyle: .bodyMedium) + .foregroundStyle(.gray800) + + Spacer() + .frame(height: 16) + + // 버튼들 + VStack(spacing: 12) { + RouteNotificationButton( + title: "역으로 출발하기", + backgroundColor: .navy900, + foregroundColor: .white, + action: { store.send(.view(.departureButtonTapped)) } + ) + + RouteNotificationButton( + title: "종료하기", + backgroundColor: .gray200, + foregroundColor: .gray800, + action: { store.send(.view(.closeButtonTapped)) } + ) + } + .padding(.horizontal, 16) + + Spacer() + .frame(height: 32) + } else { + Spacer() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.white) + .edgesIgnoringSafeArea(.all) + } +} diff --git a/Projects/Presentation/Home/Sources/RouteNotification/Components/RouteNotificationButton.swift b/Projects/Presentation/Home/Sources/RouteNotification/Components/RouteNotificationButton.swift new file mode 100644 index 0000000..feb08cb --- /dev/null +++ b/Projects/Presentation/Home/Sources/RouteNotification/Components/RouteNotificationButton.swift @@ -0,0 +1,39 @@ +// +// RouteNotificationButton.swift +// Home +// +// Created by Wonji Suh on 4/1/26. +// + +import SwiftUI +import DesignSystem + +public struct RouteNotificationButton: View { + private let title: String + private let backgroundColor: Color + private let foregroundColor: Color + private let action: () -> Void + + public init( + title: String, + backgroundColor: Color, + foregroundColor: Color, + action: @escaping () -> Void + ) { + self.title = title + self.backgroundColor = backgroundColor + self.foregroundColor = foregroundColor + self.action = action + } + + public var body: some View { + Button(action: action) { + Text(title) + .pretendardCustomFont(textStyle: .bodyBold) + .foregroundColor(foregroundColor) + .frame(maxWidth: .infinity, minHeight: 60) + .background(backgroundColor) + .cornerRadius(28) + } + } +} diff --git a/Projects/Presentation/Home/Sources/RouteNotification/Reducer/RouteNotificationFeature.swift b/Projects/Presentation/Home/Sources/RouteNotification/Reducer/RouteNotificationFeature.swift new file mode 100644 index 0000000..4961517 --- /dev/null +++ b/Projects/Presentation/Home/Sources/RouteNotification/Reducer/RouteNotificationFeature.swift @@ -0,0 +1,277 @@ +// +// RouteNotificationFeature.swift +// Home +// +// Created by Wonji Suh on 3/31/26. +// + + +import Foundation +import ComposableArchitecture +import CoreLocation +import Entity +import UseCase +import LogMacro + +// MARK: - Journey Error +public enum JourneyEndError: Error, Equatable { + case message(String) + + public var localizedDescription: String { + switch self { + case .message(let string): + return string + } + } + + public static func from(_ error: Error) -> JourneyEndError { + return .message(error.localizedDescription) + } +} + + +@Reducer +public struct RouteNotificationFeature { + public init() {} + + @Dependency(\.getRouteUseCase) var getRouteUseCase + @Dependency(\.historyRepository) var historyRepository + + @ObservableState + public struct State: Equatable { + public var notificationType: NotificationType = .now + @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty + @Shared(.appStorage("nearestStationLat")) var persistedStationLat: Double = 0.0 + @Shared(.appStorage("nearestStationLng")) var persistedStationLng: Double = 0.0 + @Shared(.appStorage("visitingHistoryId")) var visitingHistoryId: Int = 0 + + public init(notificationType: NotificationType = .now) { + self.notificationType = notificationType + } + } + + + 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 backButtonTapped + case departureButtonTapped + case closeButtonTapped + case onAppear + } + + + + //MARK: - AsyncAction 비동기 처리 액션 + public enum AsyncAction: Equatable { + case startNavigationToStation + case endJourney(journeyId: Int, isCompleted: Bool) + } + + //MARK: - 앱내에서 사용하는 액션 + public enum InnerAction: Equatable { + case journeyEndResponse(Result) + } + + //MARK: - DelegateAction + public enum DelegateAction: Equatable { + case presentHome + case presentRoute + case closeNotification + } + + + 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 RouteNotificationFeature { + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .backButtonTapped: + return .send(.delegate(.presentHome)) + + case .departureButtonTapped: + #logDebug("🔔 RouteNotificationFeature: 역으로 출발하기 버튼 탭됨") + return .concatenate( + .send(.async(.startNavigationToStation)), + .send(.delegate(.closeNotification)) + ) + + case .closeButtonTapped: + #logDebug("🔔 RouteNotificationFeature: 종료하기 버튼 탭됨") + if state.notificationType == .endJourney { + // 저장된 visitingHistoryId를 사용하여 여정 종료 API 호출 + let journeyId = state.visitingHistoryId + guard journeyId > 0 else { + #logDebug("❌ visitingHistoryId가 없습니다.") + return .send(.delegate(.closeNotification)) + } + return .send(.async(.endJourney(journeyId: journeyId, isCompleted: true))) + } else { + return .send(.delegate(.closeNotification)) + } + + case .onAppear: + return .none + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case .startNavigationToStation: + // appStorage에서 저장된 역 위치 확인 + let stationLat = state.persistedStationLat + let stationLng = state.persistedStationLng + + guard stationLat != 0.0 && stationLng != 0.0 else { + return .none + } + + let destination = CLLocationCoordinate2D(latitude: stationLat, longitude: stationLng) + let destinationName = state.userSession.travelStationName.isEmpty ? "역" : state.userSession.travelStationName + + // 기본 지도 앱 사용 (네이버맵 등) + return .run { _ in + await getRouteUseCase.startNavigation( + mapType: .naverMap, + destination: destination, + destinationName: destinationName + ) + } + + case .endJourney(let journeyId, let isCompleted): + return .run { send in + let result = await Result { + try await historyRepository.endJourney(journeyId: journeyId, isCompleted: isCompleted) + } + .mapError(JourneyEndError.from) + + await send(.inner(.journeyEndResponse(result))) + } + } + } + + private func handleDelegateAction( + state: inout State, + action: DelegateAction + ) -> Effect { + switch action { + case .presentHome: + return .none + case .presentRoute: + return .none + case .closeNotification: + return .none + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case .journeyEndResponse(let result): + switch result { + case .success(let journey): + #logDebug("✅ 여정 종료 성공: \(journey.id)") + + // 여정 종료 성공 시 visitingHistoryId 초기화 + state.$visitingHistoryId.withLock { $0 = 0 } + + // 대기 중인 딥링크도 제거하여 앱 재시작 시 알림 화면이 나타나지 않도록 함 + UserDefaults.standard.removeObject(forKey: "pendingPushDeepLink") + #logDebug("🗑️ 여정 종료 후 pendingPushDeepLink 제거 완료") + + // 알림 닫기 + return .send(.delegate(.closeNotification)) + case .failure(let error): + #logDebug("❌ 여정 종료 실패: \(error.localizedDescription)") + // TODO: 에러 처리 (토스트 메시지 등) + return .none + } + } + } +} + +extension RouteNotificationFeature.State: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(notificationType) + hasher.combine(userSession) + hasher.combine(persistedStationLat) + hasher.combine(persistedStationLng) + hasher.combine(visitingHistoryId) + } +} + +extension RouteNotificationFeature.State { + public var formattedDepartureTime: String { + guard let departureTime = userSession.departureTime else { + return "출발 시간 미설정" + } + + return "열차 시간: \(departureTime.formattedKoreanTime())" + } +} + +// MARK: - Equatable Extensions + +extension RouteNotificationFeature.InnerAction { + public static func == (lhs: RouteNotificationFeature.InnerAction, rhs: RouteNotificationFeature.InnerAction) -> Bool { + switch (lhs, rhs) { + case (.journeyEndResponse(.success(let lhsJourney)), .journeyEndResponse(.success(let rhsJourney))): + return lhsJourney.id == rhsJourney.id + case (.journeyEndResponse(.failure(let lhsError)), .journeyEndResponse(.failure(let rhsError))): + return lhsError.localizedDescription == rhsError.localizedDescription + default: + return false + } + } +} + +extension RouteNotificationFeature.AsyncAction { + public static func == (lhs: RouteNotificationFeature.AsyncAction, rhs: RouteNotificationFeature.AsyncAction) -> Bool { + switch (lhs, rhs) { + case (.startNavigationToStation, .startNavigationToStation): + return true + case (.endJourney(let lhsId, let lhsCompleted), .endJourney(let rhsId, let rhsCompleted)): + return lhsId == rhsId && lhsCompleted == rhsCompleted + default: + return false + } + } +} diff --git a/Projects/Presentation/Home/Sources/RouteNotification/View/RouteNotificationView.swift b/Projects/Presentation/Home/Sources/RouteNotification/View/RouteNotificationView.swift new file mode 100644 index 0000000..6cf8db6 --- /dev/null +++ b/Projects/Presentation/Home/Sources/RouteNotification/View/RouteNotificationView.swift @@ -0,0 +1,81 @@ +// +// RouteNotificationView.swift +// Home +// +// Created by Wonji Suh on 3/31/26. +// + +import SwiftUI + +import DesignSystem +import ComposableArchitecture + + +public struct RouteNotificationView: View { + @Bindable var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + switch store.notificationType { + case .fifteenMin: + NotificationContentView( + titlePart1: "역으로 출발하기까지\n", + highlightText: "15분", + titlePart3: " 남았어요!", + subtitle: "지금 하는 활동을 차분히 마무리해 주세요.", + image: .fifteenMinutesNotification, + store: store, + showBottomElements: true, + isEndJourney: false + ) + case .tenMin: + NotificationContentView( + titlePart1: "", + highlightText: "10분", + titlePart3: " 뒤면 역으로 출발해야 해요!", + subtitle: "이제 슬슬 일어날 준비를 해볼까요?", + image: .tenMinutesNotification, + store: store, + showBottomElements: true, + isEndJourney: false + ) + case .fiveMin: + NotificationContentView( + titlePart1: "", + highlightText: "5분", + titlePart3: " 뒤면 역으로 슬슬 일어날\n채비를 할 시간이에요.", + subtitle: "잠시 후 출발할 수 있도록 미리 준비해주세요.", + image: .fiveMinutesNotification, + store: store, + showBottomElements: true, + isEndJourney: false + ) + case .now: + NotificationContentView( + titlePart1: "", + highlightText: "지금 바로", + titlePart3: " 출발해야 해요!", + subtitle: "지금 바로 역으로 향해야 15분 전에\n플랫폼에 도착할 수 있어요.", + image: .startNotification, + store: store, + showBottomElements: true, + isEndJourney: false + ) + case .endJourney: + NotificationContentView( + titlePart1: "무사히 탑승하셨나요?", + highlightText: "", + titlePart3: "", + subtitle: "오늘 대기 시간이 맞진 여행이 되었어요.\n이제 편안한 여정 되세요!", + image: .endJourney, + store: store, + showBottomElements: false, + isEndJourney: true + ) + } + } +} + diff --git a/Projects/Presentation/Home/Sources/TrainStation/Model/StationRowModel.swift b/Projects/Presentation/Home/Sources/TrainStation/Model/StationRowModel.swift index 5ad93aa..f5f9050 100644 --- a/Projects/Presentation/Home/Sources/TrainStation/Model/StationRowModel.swift +++ b/Projects/Presentation/Home/Sources/TrainStation/Model/StationRowModel.swift @@ -7,17 +7,55 @@ import Foundation import Entity +import Utill +import IdentifiedCollections public struct StationRowModel: Identifiable, Equatable, Hashable { - public let id: String - public let favoriteID: Int? - public let station: Station? - public let stationID: Int - public let stationName: String - public let badges: [String] + public let stationEntity: StationEntity public let distanceText: String? - public let isFavorite: Bool + + // UI 편의 속성들 + public var id: String { + "\(rowType)-\(stationEntity.id)" + } + public var favoriteID: Int? { + stationEntity.favoriteID + } + public var station: Station? { + stationEntity.station + } + public var stationID: Int { + stationEntity.id + } + public var stationName: String { + stationEntity.name + } + public var badges: [String] { + stationEntity.badges + } + public var lat: Double? { + stationEntity.latitude + } + public var lng: Double? { + stationEntity.longitude + } + public var isFavorite: Bool { + stationEntity.isFavorite + } + + private let rowType: String + public init( + stationEntity: StationEntity, + distanceText: String? = nil, + rowType: String = "station" + ) { + self.stationEntity = stationEntity + self.distanceText = distanceText + self.rowType = rowType + } + + // 편의 생성자 (기존 코드 호환성을 위해) public init( id: String, favoriteID: Int? = nil, @@ -25,16 +63,181 @@ public struct StationRowModel: Identifiable, Equatable, Hashable { stationID: Int, stationName: String, badges: [String], + lat: Double? = nil, + lng: Double? = nil, distanceText: String?, isFavorite: Bool ) { - self.id = id - self.favoriteID = favoriteID - self.station = station - self.stationID = stationID - self.stationName = stationName - self.badges = badges - self.distanceText = distanceText - self.isFavorite = isFavorite + let entity = StationEntity( + id: stationID, + favoriteID: favoriteID, + station: station, + name: stationName, + badges: badges, + latitude: lat, + longitude: lng, + isFavorite: isFavorite + ) + + let rowType = id.components(separatedBy: "-").first ?? "station" + + self.init( + stationEntity: entity, + distanceText: distanceText, + rowType: rowType + ) + } +} + +// MARK: - Mapping Functions +extension StationRowModel { + static func makeFavoriteRows(from stations: [StationSummaryEntity]) -> IdentifiedArrayOf { + let uniqueStations = Array( + Dictionary( + stations.map { station in + (station.name.normalizedStationName, station) + }, + uniquingKeysWith: { first, _ in first } + ).values + ) + .sorted { $0.name.normalizedStationName < $1.name.normalizedStationName } + + let rows = uniqueStations.map { station in + let normalizedName = station.name.normalizedStationName + let stationEnum = Station(displayName: normalizedName) + let displayName = stationEnum?.displayName ?? normalizedName + + let entity = StationEntity( + id: station.stationID, + favoriteID: station.favoriteID ?? station.stationID, + station: stationEnum, + name: displayName, + badges: station.lines, + latitude: station.lat, + longitude: station.lng, + isFavorite: true + ) + + return StationRowModel( + stationEntity: entity, + distanceText: nil, + rowType: "favorite" + ) + } + + return IdentifiedArray(uniqueElements: rows) + } + + static func makeNearbyRows(from stations: [StationSummaryEntity]) -> IdentifiedArrayOf { + let rows = Array(stations.sorted { $0.name.normalizedStationName < $1.name.normalizedStationName }.prefix(3)).map { station in + let normalizedName = station.name.normalizedStationName + let stationEnum = Station(displayName: normalizedName) + let displayName = stationEnum?.displayName ?? normalizedName + + let entity = StationEntity( + id: station.stationID, + favoriteID: nil, + station: stationEnum, + name: displayName, + badges: station.lines, + latitude: station.lat, + longitude: station.lng, + isFavorite: false + ) + + return StationRowModel( + stationEntity: entity, + distanceText: "2.3km", + rowType: "nearby" + ) + } + + return IdentifiedArray(uniqueElements: rows) + } + + static func makeMajorRows(from stations: [StationSummaryEntity]) -> IdentifiedArrayOf { + let rows = stations + .sorted { $0.name.normalizedStationName < $1.name.normalizedStationName } + .map { station in + let normalizedName = station.name.normalizedStationName + let stationEnum = Station(displayName: normalizedName) + let displayName = stationEnum?.displayName ?? normalizedName + + let entity = StationEntity( + id: station.stationID, + favoriteID: nil, + station: stationEnum, + name: displayName, + badges: station.lines, + latitude: station.lat, + longitude: station.lng, + isFavorite: false + ) + + return StationRowModel( + stationEntity: entity, + distanceText: nil, + rowType: "station" + ) + } + + return IdentifiedArray(uniqueElements: rows) + } + + + static func applyFavoriteState( + favoriteRows: IdentifiedArrayOf, + nearbyRows: inout IdentifiedArrayOf, + majorRows: inout IdentifiedArrayOf + ) { + // 복잡한 표현식을 분리하여 컴파일러 타입 체킹 성능 향상 + let favoriteNamePairs = favoriteRows.compactMap { row -> (String, Int)? in + let identifier = row.favoriteID ?? row.stationID + return (row.stationName.normalizedStationName, identifier) + } + let favoriteNameMap: [String: Int] = Dictionary(uniqueKeysWithValues: favoriteNamePairs) + + // 복잡한 표현식을 분리하여 컴파일러 타입 체킹 성능 향상 + let updatedNearbyRows = Self.updateRowsWithFavoriteStatus( + rows: nearbyRows, + favoriteNameMap: favoriteNameMap, + rowType: "nearby" + ) + nearbyRows = IdentifiedArray(uniqueElements: updatedNearbyRows) + + let updatedMajorRows = Self.updateRowsWithFavoriteStatus( + rows: majorRows, + favoriteNameMap: favoriteNameMap, + rowType: "station" + ) + majorRows = IdentifiedArray(uniqueElements: updatedMajorRows) + } + + // 복잡한 표현식을 분리한 헬퍼 함수 + private static func updateRowsWithFavoriteStatus( + rows: IdentifiedArrayOf, + favoriteNameMap: [String: Int], + rowType: String + ) -> [StationRowModel] { + return rows.map { row in + let favoriteID = favoriteNameMap[row.stationName.normalizedStationName] + + let updatedEntity = StationEntity( + id: row.stationEntity.id, + favoriteID: favoriteID, + station: row.stationEntity.station, + name: row.stationEntity.name, + badges: row.stationEntity.badges, + latitude: row.stationEntity.latitude, + longitude: row.stationEntity.longitude, + isFavorite: favoriteID != nil + ) + + return StationRowModel( + stationEntity: updatedEntity, + distanceText: row.distanceText, + rowType: rowType + ) + } } } diff --git a/Projects/Presentation/Home/Sources/TrainStation/Reducer/TrainStationFeature.swift b/Projects/Presentation/Home/Sources/TrainStation/Reducer/TrainStationFeature.swift index 7d2c49d..a60f9d2 100644 --- a/Projects/Presentation/Home/Sources/TrainStation/Reducer/TrainStationFeature.swift +++ b/Projects/Presentation/Home/Sources/TrainStation/Reducer/TrainStationFeature.swift @@ -9,6 +9,8 @@ import Foundation import CoreLocation import ComposableArchitecture +import LogMacro +import IdentifiedCollections import DomainInterface import UseCase @@ -34,11 +36,12 @@ public struct TrainStationFeature { var shouldShowFavoriteSection: Bool = false var selectedStation: Station var selectedStationID: Int? - var favoriteRows: [StationRowModel] = [] - var nearbyRows: [StationRowModel] = [] - var majorRows: [StationRowModel] = [] + var favoriteRows: IdentifiedArrayOf = [] + var nearbyRows: IdentifiedArrayOf = [] + var majorRows: IdentifiedArrayOf = [] var isLoading: Bool = false var errorMessage: String? + @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty public init( selectedStation: Station = .seoul, @@ -74,7 +77,7 @@ public struct TrainStationFeature { case checkAccessToken case fetchStations case addFavoriteStation(Int) - case deleteFavoriteStation(Int) + case deleteFavoriteStation(favoriteID: Int, stationID: Int) } //MARK: - 앱내에서 사용하는 액션 @@ -84,7 +87,7 @@ public struct TrainStationFeature { case fetchStationsFailed(String) case addFavoriteStationResponse case addFavoriteStationFailed(String) - case deleteFavoriteStationResponse + case deleteFavoriteStationResponse(Int) case deleteFavoriteStationFailed(String) } @@ -125,6 +128,8 @@ extension TrainStationFeature { switch action { case .onAppear: state.isLoading = true + + // 항상 토큰 재확인 후 즐겨찾기 섹션 표시 여부 결정 return .merge( .send(.async(.checkAccessToken)), .send(.async(.fetchStations)) @@ -136,9 +141,12 @@ extension TrainStationFeature { state.selectedStationID = row.stationID return .send(.delegate(.stationSelected(row))) case .favoriteButtonTapped(let row): - guard state.shouldShowFavoriteSection else { return .none } + // 비회원이거나 즐겨찾기 섹션이 비활성화된 경우 즐겨찾기 기능 사용 불가 + guard state.shouldShowFavoriteSection && !state.userSession.isGuest else { return .none } + if row.isFavorite { - return .send(.async(.deleteFavoriteStation(row.stationID))) + guard let favoriteID = row.favoriteID else { return .none } + return .send(.async(.deleteFavoriteStation(favoriteID: favoriteID, stationID: row.stationID))) } else { return .send(.async(.addFavoriteStation(row.stationID))) } @@ -159,15 +167,32 @@ extension TrainStationFeature { .cancellable(id: CancelID.checkAccessToken) case .fetchStations: return .run { [stationUseCase] send in - let locationManager = await LocationPermissionManager.shared - let location = await MainActor.run { locationManager.currentLocation } - let lat = location?.coordinate.latitude ?? 37.5666805 - let lng = location?.coordinate.longitude ?? 126.9784147 + // 현재 위치를 적극적으로 가져오기 + var location: CLLocation? + + // 먼저 캐시된 위치 확인 + location = await MainActor.run { + LocationPermissionManager.shared.currentLocation + } + + // 캐시된 위치가 없으면 새로 요청 + if location == nil { + do { + location = try await LocationPermissionManager.shared.requestCurrentLocation() + } catch { + #logDebug("❌ 현재 위치 가져오기 실패: \(error.localizedDescription)") + } + } + + let userLat = location?.coordinate.latitude ?? 37.5666805 + let userLon = location?.coordinate.longitude ?? 126.9784147 + + #logDebug("📍 사용 중인 위치: \(userLat), \(userLon)") do { let entity = try await stationUseCase.fetchStations( - lat: lat, - lng: lng, + userLat: userLat, + userLon: userLon, page: 1, size: 30 ) @@ -183,16 +208,26 @@ extension TrainStationFeature { _ = try await stationUseCase.addFavoriteStation(stationID: stationID) await send(.inner(.addFavoriteStationResponse)) } catch { + let nsError = error as NSError + if nsError.domain == "StationFavoriteError", nsError.code == 409 { + await send(.inner(.addFavoriteStationResponse)) + return + } await send(.inner(.addFavoriteStationFailed(error.localizedDescription))) } } .cancellable(id: CancelID.favoriteMutation, cancelInFlight: true) - case .deleteFavoriteStation(let stationID): + case .deleteFavoriteStation(let favoriteID, let stationID): return .run { [stationUseCase] send in do { - _ = try await stationUseCase.deleteFavoriteStation(stationID: stationID) - await send(.inner(.deleteFavoriteStationResponse)) + _ = try await stationUseCase.deleteFavoriteStation(favoriteID: favoriteID) + await send(.inner(.deleteFavoriteStationResponse(stationID))) } catch { + let nsError = error as NSError + if nsError.domain == "StationFavoriteError", nsError.code == 404 { + await send(.inner(.deleteFavoriteStationResponse(stationID))) + return + } await send(.inner(.deleteFavoriteStationFailed(error.localizedDescription))) } } @@ -215,14 +250,34 @@ extension TrainStationFeature { action: InnerAction ) -> Effect { switch action { - case .accessTokenChecked(let shouldShowFavoriteSection): - state.shouldShowFavoriteSection = shouldShowFavoriteSection + case .accessTokenChecked(let hasAccessToken): + state.shouldShowFavoriteSection = hasAccessToken + + // UserSession의 isGuest 상태도 함께 업데이트 + state.$userSession.withLock { + $0.isGuest = !hasAccessToken + } + + return .none case .fetchStationsResponse(let entity): - state.favoriteRows = makeFavoriteRows(entity.favoriteStations) - state.nearbyRows = makeNearbyRows(entity.nearbyStations) - state.majorRows = makeMajorRows(entity.stations.content) - applyFavoriteState(state: &state) + state.favoriteRows = StationRowModel.makeFavoriteRows(from: entity.favoriteStations) + state.nearbyRows = StationRowModel.makeNearbyRows(from: entity.nearbyStations) + state.majorRows = StationRowModel.makeMajorRows(from: entity.stations.content) + + // Avoid overlapping access by copying to local variables + let favoriteRows = state.favoriteRows + var nearbyRows = state.nearbyRows + var majorRows = state.majorRows + + StationRowModel.applyFavoriteState( + favoriteRows: favoriteRows, + nearbyRows: &nearbyRows, + majorRows: &majorRows + ) + + state.nearbyRows = nearbyRows + state.majorRows = majorRows state.isLoading = false return .none case .fetchStationsFailed(let message): @@ -234,8 +289,49 @@ extension TrainStationFeature { case .addFavoriteStationFailed(let message): state.errorMessage = message return .none - case .deleteFavoriteStationResponse: - return .send(.async(.fetchStations)) + case .deleteFavoriteStationResponse(let stationID): + state.favoriteRows.removeAll { $0.stationID == stationID } + + let updatedNearbyRows = state.nearbyRows.map { row in + guard row.stationID == stationID else { return row } + let updatedEntity = StationEntity( + id: row.stationEntity.id, + favoriteID: nil, + station: row.stationEntity.station, + name: row.stationEntity.name, + badges: row.stationEntity.badges, + latitude: row.stationEntity.latitude, + longitude: row.stationEntity.longitude, + isFavorite: false + ) + return StationRowModel( + stationEntity: updatedEntity, + distanceText: row.distanceText, + rowType: "nearby" + ) + } + state.nearbyRows = IdentifiedArray(uniqueElements: updatedNearbyRows) + + let updatedMajorRows = state.majorRows.map { row in + guard row.stationID == stationID else { return row } + let updatedEntity = StationEntity( + id: row.stationEntity.id, + favoriteID: nil, + station: row.stationEntity.station, + name: row.stationEntity.name, + badges: row.stationEntity.badges, + latitude: row.stationEntity.latitude, + longitude: row.stationEntity.longitude, + isFavorite: false + ) + return StationRowModel( + stationEntity: updatedEntity, + distanceText: row.distanceText, + rowType: "station" + ) + } + state.majorRows = IdentifiedArray(uniqueElements: updatedMajorRows) + return .none case .deleteFavoriteStationFailed(let message): state.errorMessage = message return .none @@ -243,107 +339,18 @@ extension TrainStationFeature { } } -extension TrainStationFeature.State: Hashable {} - -private extension TrainStationFeature { - func makeFavoriteRows(_ stations: [StationSummaryEntity]) -> [StationRowModel] { - Array( - Dictionary( - stations.map { station in - (normalizedStationName(station.name), station) - }, - uniquingKeysWith: { first, _ in first } - ).values - ) - .sorted { normalizedStationName($0.name) < normalizedStationName($1.name) } - .map { station in - let normalizedName = normalizedStationName(station.name) - return StationRowModel( - id: "favorite-\(station.stationID)", - favoriteID: station.stationID, - station: Station(displayName: normalizedName), - stationID: station.stationID, - stationName: normalizedName, - badges: station.lines, - distanceText: nil, - isFavorite: true - ) - } - } - - func makeNearbyRows(_ stations: [StationSummaryEntity]) -> [StationRowModel] { - Array(stations.sorted { normalizedStationName($0.name) < normalizedStationName($1.name) }.prefix(3)).map { station in - let normalizedName = normalizedStationName(station.name) - return StationRowModel( - id: "nearby-\(station.stationID)", - favoriteID: nil, - station: Station(displayName: normalizedName), - stationID: station.stationID, - stationName: normalizedName, - badges: station.lines, - distanceText: "2.3km", - isFavorite: false - ) - } - } - - func makeMajorRows(_ stations: [StationSummaryEntity]) -> [StationRowModel] { - stations - .sorted { normalizedStationName($0.name) < normalizedStationName($1.name) } - .map { station in - let normalizedName = normalizedStationName(station.name) - return StationRowModel( - id: "station-\(station.stationID)", - favoriteID: nil, - station: Station(displayName: normalizedName), - stationID: station.stationID, - stationName: normalizedName, - badges: station.lines, - distanceText: nil, - isFavorite: false - ) - } - } - - func applyFavoriteState(state: inout State) { - let favoriteNameMap = Dictionary( - uniqueKeysWithValues: state.favoriteRows.map { - (normalizedStationName($0.stationName), $0.stationID) - } - ) - - state.nearbyRows = state.nearbyRows.map { row in - let favoriteID = favoriteNameMap[normalizedStationName(row.stationName)] - return StationRowModel( - id: row.id, - favoriteID: favoriteID, - station: row.station, - stationID: row.stationID, - stationName: row.stationName, - badges: row.badges, - distanceText: row.distanceText, - isFavorite: favoriteID != nil - ) - } - - state.majorRows = state.majorRows.map { row in - let favoriteID = favoriteNameMap[normalizedStationName(row.stationName)] - return StationRowModel( - id: row.id, - favoriteID: favoriteID, - station: row.station, - stationID: row.stationID, - stationName: row.stationName, - badges: row.badges, - distanceText: row.distanceText, - isFavorite: favoriteID != nil - ) - } - } - - func normalizedStationName(_ name: String) -> String { - name - .replacingOccurrences(of: "역", with: "") - .trimmingCharacters(in: .whitespacesAndNewlines) +extension TrainStationFeature.State: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(searchText) + hasher.combine(shouldShowFavoriteSection) + hasher.combine(selectedStation) + hasher.combine(selectedStationID) + hasher.combine(favoriteRows) + hasher.combine(nearbyRows) + hasher.combine(majorRows) + hasher.combine(isLoading) + hasher.combine(errorMessage) + hasher.combine(userSession) } } + diff --git a/Projects/Presentation/Home/Sources/TrainStation/View/TrainStationView.swift b/Projects/Presentation/Home/Sources/TrainStation/View/TrainStationView.swift index 2fbb876..3da9965 100644 --- a/Projects/Presentation/Home/Sources/TrainStation/View/TrainStationView.swift +++ b/Projects/Presentation/Home/Sources/TrainStation/View/TrainStationView.swift @@ -75,15 +75,15 @@ public struct TrainStationView: View { extension TrainStationView { private var filteredFavoriteStations: [StationRowModel] { - filterRows(store.favoriteRows) + filterRows(Array(store.favoriteRows)) } private var filteredNearbyStations: [StationRowModel] { - filterRows(store.nearbyRows) + filterRows(Array(store.nearbyRows)) } private var filteredMajorStations: [StationRowModel] { - filterRows(store.majorRows) + filterRows(Array(store.majorRows)) } private func filterRows(_ rows: [StationRowModel]) -> [StationRowModel] { @@ -249,15 +249,18 @@ extension TrainStationView { } .buttonStyle(.plain) - Button { - store.send(.view(.favoriteButtonTapped(row))) - } label: { - Image(systemName: row.isFavorite ? "star.fill" : "star") - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(row.isFavorite ? .orange800 : .gray550) - .frame(width: 20, height: 20) + // 비회원이 아닌 경우에만 즐겨찾기 버튼 표시 + if store.shouldShowFavoriteSection { + Button { + store.send(.view(.favoriteButtonTapped(row))) + } label: { + Image(systemName: row.isFavorite ? "star.fill" : "star") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(row.isFavorite ? .orange800 : .gray550) + .frame(width: 20, height: 20) + } + .buttonStyle(.plain) } - .buttonStyle(.plain) } .padding(.leading, 20) .padding(.trailing, 24) diff --git a/Projects/Presentation/OnBoarding/Sources/Reducer/OnBoardingFeature.swift b/Projects/Presentation/OnBoarding/Sources/Reducer/OnBoardingFeature.swift index 390d48e..2634905 100644 --- a/Projects/Presentation/OnBoarding/Sources/Reducer/OnBoardingFeature.swift +++ b/Projects/Presentation/OnBoarding/Sources/Reducer/OnBoardingFeature.swift @@ -30,6 +30,7 @@ public struct OnBoardingFeature { var selectedMap: ExternalMapType? = nil var loginEntity: LoginEntity? = nil @Shared(.inMemory(SharedKeys.userSession)) var userSession: UserSession = .empty + @Shared(.appStorage("selectedMapType")) var selectedMapTypeStorage: ExternalMapType = .naverMap } public enum Action: ViewAction, BindableAction { @@ -76,6 +77,7 @@ public struct OnBoardingFeature { } @Dependency(\.signUpUseCase) var signUpUseCase + @Dependency(\.keychainManager) var keychainManager public var body: some Reducer { BindingReducer() @@ -140,6 +142,11 @@ extension OnBoardingFeature { state.$userSession.withLock { $0.mapType = mapType } + // AppStorage에도 저장 + state.$selectedMapTypeStorage.withLock { + $0 = mapType + } + #logDebug("온보딩에서 mapType 선택: \(mapType)") return .none } } @@ -153,11 +160,20 @@ extension OnBoardingFeature { return .run { [ userSession = state.userSession ] send in + // Keychain에서 accessToken 확인 + let accessToken = await keychainManager.accessToken() + + // 비회원인 경우 (isGuest = true 또는 accessToken이 없음) API 통신 없이 바로 onBoardingCompleted로 이동 + if userSession.isGuest && accessToken?.isEmpty != false { + await send(.navigation(.onBoardingCompleted)) + return + } + let signupResult = await Result { try await signUpUseCase.registerUser(userSession: userSession) } .mapError(SignUpError.from) - return await send(.inner(.signUpResponse(signupResult))) + await send(.inner(.signUpResponse(signupResult))) } .cancellable(id: CancelID.signup, cancelInFlight: true) } @@ -207,7 +223,8 @@ extension OnBoardingFeature.State: Equatable { lhs.stepRange == rhs.stepRange && lhs.activeStep == rhs.activeStep && lhs.selectedMap == rhs.selectedMap && - lhs.loginEntity == rhs.loginEntity + lhs.loginEntity == rhs.loginEntity && + lhs.selectedMapTypeStorage == rhs.selectedMapTypeStorage } } extension OnBoardingFeature.State { @@ -215,6 +232,6 @@ extension OnBoardingFeature.State { hasher.combine(customAlert != nil) hasher.combine(activeStep) hasher.combine(selectedMap) + hasher.combine(selectedMapTypeStorage) } } - diff --git a/Projects/Presentation/OnBoarding/Sources/View/OnBoardingView.swift b/Projects/Presentation/OnBoarding/Sources/View/OnBoardingView.swift index e435f32..3a02c9f 100644 --- a/Projects/Presentation/OnBoarding/Sources/View/OnBoardingView.swift +++ b/Projects/Presentation/OnBoarding/Sources/View/OnBoardingView.swift @@ -87,7 +87,7 @@ extension OnBoardingView { action: { store.send(.view(.nextStepButtonTapped)) }, title: store.activeStep >= store.stepRange.upperBound ? "시작하기" : "다음으로", config: CustomButtonConfig.create(), - isEnable: true + isEnable: store.activeStep == 4 ? store.selectedMap != nil : true ) Spacer() diff --git a/Projects/Presentation/Profile/Project.swift b/Projects/Presentation/Profile/Project.swift index d0e049e..ce138bf 100644 --- a/Projects/Presentation/Profile/Project.swift +++ b/Projects/Presentation/Profile/Project.swift @@ -11,11 +11,12 @@ let project = Project.makeAppModule( product: .staticFramework, settings: .settings(), dependencies: [ + .SPM.composableArchitecture, + .SPM.tcaCoordinator, + .Domain(implements: .UseCase), + .Shared(implements: .DesignSystem), + .Presentation(implements: .Web) - .Domain(implements: .UseCase), - .Shared(implements: .DesignSystem), - .SPM.composableArchitecture, - .SPM.tcaCoordinator, ], sources: ["Sources/**"] ) diff --git a/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift b/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift index 5a27cbc..ecee5be 100644 --- a/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift +++ b/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift @@ -7,6 +7,7 @@ import ComposableArchitecture import TCACoordinators +import Web @Reducer public struct ProfileCoordinator { @@ -119,6 +120,17 @@ extension ProfileCoordinator { case .routeAction(id: _, action: .notification(.delegate(.presentBack))): return .send(.view(.backAction)) + case .routeAction(id: _, action: .setting(.delegate(.presentPrivacyPolicy))): + state.routes.push(.web(.init(url: "https://www.notion.so/329f94ae438b807d95dcd0f5f8abf66a?source=copy_link"))) + return .none + + case .routeAction(id: _, action: .setting(.delegate(.presentServicePolicy))): + state.routes.push(.web(.init(url: "https://www.notion.so/329f94ae438b804d99a3f8ba2c761e15?source=copy_link"))) + return .none + + case .routeAction(id: _, action: .web(.backToRoot)): + return .send(.view(.backAction)) + default: return .none } @@ -178,10 +190,11 @@ extension ProfileCoordinator { case setting(SettingFeature) case withDraw(WithDrawFeature) case notification(NotificationSettingFeature) + case web(WebFeature) } } -// MARK: - AuthScreen State Equatable & Hashable +// MARK: - ProfileScreen State Equatable & Hashable extension ProfileCoordinator.ProfileScreen.State: Equatable {} extension ProfileCoordinator.ProfileScreen.State: Hashable {} diff --git a/Projects/Presentation/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift b/Projects/Presentation/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift index a06eac9..fb36851 100644 --- a/Projects/Presentation/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift +++ b/Projects/Presentation/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift @@ -9,6 +9,7 @@ import SwiftUI import ComposableArchitecture import TCACoordinators +import Web public struct ProfileCoordinatorView: View { @Bindable var store: StoreOf @@ -48,6 +49,14 @@ public struct ProfileCoordinatorView: View { insertion: .move(edge: .trailing), removal: .move(edge: .leading) )) + + case .web(let webStore): + WebView(store: webStore) + .navigationBarBackButtonHidden() + .transition(.asymmetric( + insertion: .move(edge: .trailing), + removal: .move(edge: .leading) + )) } } } diff --git a/Projects/Presentation/Profile/Sources/Components/ProfileSkeletonView.swift b/Projects/Presentation/Profile/Sources/Main/Components/ProfileSkeletonView.swift similarity index 100% rename from Projects/Presentation/Profile/Sources/Components/ProfileSkeletonView.swift rename to Projects/Presentation/Profile/Sources/Main/Components/ProfileSkeletonView.swift diff --git a/Projects/Presentation/Profile/Sources/Components/TravelHistoryCardSkeletonView.swift b/Projects/Presentation/Profile/Sources/Main/Components/TravelHistoryCardSkeletonView.swift similarity index 100% rename from Projects/Presentation/Profile/Sources/Components/TravelHistoryCardSkeletonView.swift rename to Projects/Presentation/Profile/Sources/Main/Components/TravelHistoryCardSkeletonView.swift diff --git a/Projects/Presentation/Profile/Sources/Components/TravelHistoryCardView.swift b/Projects/Presentation/Profile/Sources/Main/Components/TravelHistoryCardView.swift similarity index 100% rename from Projects/Presentation/Profile/Sources/Components/TravelHistoryCardView.swift rename to Projects/Presentation/Profile/Sources/Main/Components/TravelHistoryCardView.swift diff --git a/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileFeature.swift b/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileFeature.swift index e60c6e8..f3fa33a 100644 --- a/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileFeature.swift +++ b/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileFeature.swift @@ -14,6 +14,10 @@ import UseCase @Reducer public struct ProfileFeature { + private enum Constants { + static let historyPageSize = 50 + } + public init() {} @ObservableState @@ -26,6 +30,7 @@ public struct ProfileFeature { var isHistoryLoading: Bool = false var isHistoryLoadingMore: Bool = false @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty + @Shared(.appStorage("selectedMapType")) var selectedMapTypeStorage: ExternalMapType = .naverMap public init() {} } @@ -160,7 +165,11 @@ extension ProfileFeature { } return .run { [travelHistorySort = state.travelHistorySort] send in let result = await Result { - try await historyUseCase.myHistory(page: page, size: 10, sort: travelHistorySort) + try await historyUseCase.myHistory( + page: page, + size: Constants.historyPageSize, + sort: travelHistorySort + ) } .mapError(ProfileError.from) await send(.inner(.fetchMyHistoryResponse(result, reset: reset))) @@ -200,6 +209,9 @@ extension ProfileFeature { $0.name = state.profileEntity?.nickname ?? "" $0.mapType = state.profileEntity?.mapType ?? .appleMap } + state.$selectedMapTypeStorage.withLock { + $0 = state.profileEntity?.mapType ?? .appleMap + } return .none case .failure(let error): diff --git a/Projects/Presentation/Profile/Sources/Main/View/ProfileView.swift b/Projects/Presentation/Profile/Sources/Main/View/ProfileView.swift index ac955d1..0a73522 100644 --- a/Projects/Presentation/Profile/Sources/Main/View/ProfileView.swift +++ b/Projects/Presentation/Profile/Sources/Main/View/ProfileView.swift @@ -69,7 +69,7 @@ extension ProfileView { VStack(alignment: .leading) { VStack { Spacer() - .frame(height: 12) + .frame(height: 16) HStack { Text("\(store.profileEntity?.nickname ?? "사용자")님") diff --git a/Projects/Presentation/Profile/Sources/NotificationSetting/Components/NotificationSettingSkeletonView.swift b/Projects/Presentation/Profile/Sources/NotificationSetting/Components/NotificationSettingSkeletonView.swift new file mode 100644 index 0000000..9b52582 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/NotificationSetting/Components/NotificationSettingSkeletonView.swift @@ -0,0 +1,84 @@ +// +// NotificationSettingSkeletonView.swift +// Profile +// + +import SwiftUI + +import DesignSystem +import Entity + +public struct NotificationSettingSkeletonView: View { + public init() {} + + public var body: some View { + LazyVStack(spacing: 0) { + ForEach(0.. some View { + modifier(NotificationSettingSkeletonShimmerModifier()) + } +} + +private struct NotificationSettingSkeletonShimmerModifier: ViewModifier { + @State private var isAnimating = false + + func body(content: Content) -> some View { + content + .overlay { + LinearGradient( + colors: [ + .white.opacity(0), + .white.opacity(0.45), + .white.opacity(0) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .rotationEffect(.degrees(20)) + .offset(x: isAnimating ? 180 : -180) + .mask(content) + } + .onAppear { + withAnimation(.easeInOut(duration: 1.1).repeatForever(autoreverses: false)) { + isAnimating = true + } + } + } +} diff --git a/Projects/Presentation/Profile/Sources/NotificationSetting/Reducer/NotificationSettingFeature.swift b/Projects/Presentation/Profile/Sources/NotificationSetting/Reducer/NotificationSettingFeature.swift index 157acba..7ecad31 100644 --- a/Projects/Presentation/Profile/Sources/NotificationSetting/Reducer/NotificationSettingFeature.swift +++ b/Projects/Presentation/Profile/Sources/NotificationSetting/Reducer/NotificationSettingFeature.swift @@ -11,6 +11,7 @@ import ComposableArchitecture import Utill import Entity +import UseCase @Reducer public struct NotificationSettingFeature { @@ -19,7 +20,8 @@ public struct NotificationSettingFeature { @ObservableState public struct State: Equatable { - var selectedOptions: [NotificationOption] = [.fiveMinutesBefore, .tenMinutesBefore] + var selectedOptions: [NotificationOption] = [] + var isLoading: Bool = false public init() {} } @@ -36,6 +38,7 @@ public struct NotificationSettingFeature { //MARK: - ViewAction @CasePathable public enum View { + case onAppear case notificationOptionTapped(NotificationOption) } @@ -43,11 +46,14 @@ public struct NotificationSettingFeature { //MARK: - AsyncAction 비동기 처리 액션 public enum AsyncAction: Equatable { - + case fetchNotificationSettings + case editNotificationSettings([NotificationOption]) } //MARK: - 앱내에서 사용하는 액션 public enum InnerAction: Equatable { + case fetchNotificationSettingsResponse(Result) + case editNotificationSettingsResponse(Result) } //MARK: - DelegateAction @@ -56,6 +62,12 @@ public struct NotificationSettingFeature { } + nonisolated enum CancelID: Hashable { + case fetchCancel + case editCancel + } + + @Dependency(\.profileUseCase) var profileUseCase public var body: some Reducer { BindingReducer() @@ -86,9 +98,17 @@ extension NotificationSettingFeature { action: View ) -> Effect { switch action { + case .onAppear: + state.isLoading = true + return .send(.async(.fetchNotificationSettings)) + case .notificationOptionTapped(let option): if option == .none { state.selectedOptions = [.none] + return .send(.async(.editNotificationSettings([]))) + } + + guard option != .departureTime else { return .none } @@ -96,15 +116,18 @@ extension NotificationSettingFeature { if state.selectedOptions.contains(option) { state.selectedOptions.removeAll { $0 == option } - return .none + if selectedEditableOptions(state: state).isEmpty { + state.selectedOptions = [.none] + } + return .send(.async(.editNotificationSettings(selectedEditableOptions(state: state)))) } - guard state.selectedOptions.count < 2 else { + guard selectedEditableOptions(state: state).count < 3 else { return .none } state.selectedOptions.append(option) - return .none + return .send(.async(.editNotificationSettings(selectedEditableOptions(state: state)))) } } @@ -113,7 +136,27 @@ extension NotificationSettingFeature { action: AsyncAction ) -> Effect { switch action { - + case .fetchNotificationSettings: + return .run { send in + let result = await Result { + try await profileUseCase.fetchNotificationSettings() + } + .mapError(ProfileError.from) + await send(.inner(.fetchNotificationSettingsResponse(result))) + } + .cancellable(id: CancelID.fetchCancel) + + case .editNotificationSettings(let notificationSettings): + return .run { send in + let result = await Result { + try await profileUseCase.editNotificationSettings( + notificationSettings: notificationSettings + ) + } + .mapError(ProfileError.from) + await send(.inner(.editNotificationSettingsResponse(result))) + } + .cancellable(id: CancelID.editCancel, cancelInFlight: true) } } @@ -132,14 +175,45 @@ extension NotificationSettingFeature { action: InnerAction ) -> Effect { switch action { + case .fetchNotificationSettingsResponse(let result), + .editNotificationSettingsResponse(let result): + state.isLoading = false + switch result { + case .success(let entity): + state.selectedOptions = makeSelectedOptions(entity: entity) + return .none + case .failure: + return .none + } + } + } +} +private extension NotificationSettingFeature { + func selectedEditableOptions(state: State) -> [NotificationOption] { + state.selectedOptions.filter { + $0 != .none && $0 != .departureTime } } + + func makeSelectedOptions(entity: NotificationEntity) -> [NotificationOption] { + let options: [NotificationOption] = entity.settings + .filter(\.isEnabled) + .map(\.option) + .filter { $0 != .departureTime } + + if options.isEmpty { + return [.none] + } + + return options + } } extension NotificationSettingFeature.State: Hashable { public func hash(into hasher: inout Hasher) { hasher.combine(selectedOptions) + hasher.combine(isLoading) } } diff --git a/Projects/Presentation/Profile/Sources/NotificationSetting/View/NotificationSettingView.swift b/Projects/Presentation/Profile/Sources/NotificationSetting/View/NotificationSettingView.swift index 4dccf39..936564e 100644 --- a/Projects/Presentation/Profile/Sources/NotificationSetting/View/NotificationSettingView.swift +++ b/Projects/Presentation/Profile/Sources/NotificationSetting/View/NotificationSettingView.swift @@ -44,6 +44,9 @@ public struct NotificationSettingView: View { } .padding(.horizontal, 16) } + .onAppear { + store.send(.view(.onAppear)) + } } } @@ -53,49 +56,54 @@ extension NotificationSettingView { 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: 16) + + if store.isLoading { + NotificationSettingSkeletonView() + } else { + LazyVStack(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(asset: .rowCheck) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + } } + .frame(height: 58) + .padding(.horizontal, 20) + .contentShape(Rectangle()) } - .frame(height: 58) - .padding(.horizontal, 20) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) + .buttonStyle(.plain) - if option != .fifteenMinutesBefore { - Rectangle() - .fill(.enableColor) - .frame(height: 1) - .padding(.horizontal, 14) + 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)) } - .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/Components/SettingMenuRowView.swift b/Projects/Presentation/Profile/Sources/Setting/Components/SettingMenuRowView.swift similarity index 100% rename from Projects/Presentation/Profile/Sources/Components/SettingMenuRowView.swift rename to Projects/Presentation/Profile/Sources/Setting/Components/SettingMenuRowView.swift diff --git a/Projects/Presentation/Profile/Sources/Setting/Reducer/SettingReducer.swift b/Projects/Presentation/Profile/Sources/Setting/Reducer/SettingReducer.swift index e2fbadd..43622ed 100644 --- a/Projects/Presentation/Profile/Sources/Setting/Reducer/SettingReducer.swift +++ b/Projects/Presentation/Profile/Sources/Setting/Reducer/SettingReducer.swift @@ -30,6 +30,10 @@ public struct SettingFeature { @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty @Shared(.appStorage("mapUrlScheme")) var mapURLScheme: String? + // 지도 dropdown 상태 + var showMapDropdown: Bool = false + + public init() {} } @@ -59,6 +63,7 @@ public struct SettingFeature { case timeNotificationRowTapped case logoutRowTapped case mapTypeSelected(ExternalMapType) + case toggleMapDropdown } @@ -82,6 +87,8 @@ public struct SettingFeature { case presentAuth case presentWithDraw case presentNotificationSetting + case presentPrivacyPolicy + case presentServicePolicy } @@ -137,8 +144,13 @@ extension SettingFeature { state.$userSession.withLock { $0.mapType = mapType } + state.showMapDropdown = false // dropdown 선택 후 닫기 return .send(.async(.editProfile(previousMapType: previousMapType))) + case .toggleMapDropdown: + state.showMapDropdown.toggle() + return .none + case .logoutRowTapped: return .send(.inner(.presentLogoutConfirmationAlert)) } @@ -225,6 +237,13 @@ extension SettingFeature { case .presentNotificationSetting: return .none + + case .presentServicePolicy: + return .none + + case .presentPrivacyPolicy: + return .none + } } @@ -292,5 +311,6 @@ extension SettingFeature.State: Hashable { hasher.combine(logoutEntity) hasher.combine(errorMessage) hasher.combine(userSession) + hasher.combine(showMapDropdown) } } diff --git a/Projects/Presentation/Profile/Sources/Setting/View/SettingView.swift b/Projects/Presentation/Profile/Sources/Setting/View/SettingView.swift index c6e4a94..e57263f 100644 --- a/Projects/Presentation/Profile/Sources/Setting/View/SettingView.swift +++ b/Projects/Presentation/Profile/Sources/Setting/View/SettingView.swift @@ -25,39 +25,128 @@ public struct SettingView: View { public var body: some View { ZStack(alignment: .top) { - Color.staticWhite - .ignoresSafeArea() + backgroundView + mainContentView + mapDropdownOverlay + } + .customAlert($store.scope(state: \.customAlert, action: \.scope.customAlert)) + } +} - VStack(spacing: 0) { - Spacer() - .frame(height: 8) +extension SettingView { + @ViewBuilder + private var backgroundView: some View { + Color.staticWhite + .ignoresSafeArea() + } - CustomNavigationBackBar( - buttonAction: { - store.send(.delegate(.presentBack)) - }, - title: "설정" - ) + @ViewBuilder + private var mainContentView: some View { + VStack(spacing: 0) { + Spacer() + .frame(height: 8) + + CustomNavigationBackBar( + buttonAction: { + store.send(.delegate(.presentBack)) + }, + title: "설정" + ) - Spacer() - .frame(height: 18) + Spacer() + .frame(height: 18) - notificationSettingsSection + notificationSettingsSection - Spacer() - .frame(height: 24) + Spacer() + .frame(height: 24) + + accountSettingsSection - accountSettingsSection + Spacer() + } + .padding(.horizontal, 16) + } + + @ViewBuilder + private var mapDropdownOverlay: some View { + if store.showMapDropdown { + ZStack { + Color.clear + .ignoresSafeArea() + .onTapGesture { + store.send(.view(.toggleMapDropdown)) + } + + VStack(spacing: 0) { + Spacer() + .frame(height: 8 + 44 + 18 + 56 + 56 + 56) + + HStack { + Spacer() + dropdownContent + .padding(.trailing, 32) + } + + Spacer() + } + } + .transition(.opacity) + } + } + + @ViewBuilder + private var dropdownContent: some View { + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(ExternalMapType.allCases.enumerated()), id: \.element) { index, mapType in + dropdownButton(for: mapType, at: index) + + if index < ExternalMapType.allCases.count - 1 { + Rectangle() + .fill(.enableColor) + .frame(height: 1) + } + } + } + .frame(width: 236) + .background(.staticWhite) + .clipShape(RoundedRectangle(cornerRadius: 30)) + .overlay( + RoundedRectangle(cornerRadius: 30) + .stroke(.enableColor, lineWidth: 1) + ) + .shadow(color: .black.opacity(0.1), radius: 8, x: 0, y: 4) + } + + @ViewBuilder + private func dropdownButton(for mapType: ExternalMapType, at index: Int) -> some View { + Button { + store.send(.view(.mapTypeSelected(mapType))) + } label: { + HStack(spacing: 12) { + Text(mapType.description) + .pretendardCustomFont(textStyle: .bodyMedium) + .foregroundStyle(.gray800) + .frame(maxWidth: .infinity, alignment: .leading) Spacer() + + if store.userSession.mapType == mapType { + Image(asset: .rowCheck) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + } } - .padding(.horizontal, 16) + .frame(height: 44) + .padding(.horizontal, 20) + .padding(.top, index == 0 ? 4 : 0) + .padding(.bottom, index == 2 ? 4 : 0) + .background(.white) } - .customAlert($store.scope(state: \.customAlert, action: \.scope.customAlert)) + .buttonStyle(ScaleButtonStyle()) } -} -extension SettingView { @ViewBuilder private var notificationSettingsSection: some View { settingsSection { @@ -76,30 +165,15 @@ extension SettingView { } ) - 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) - } - } + SettingMenuRowView( + title: "연동된 지도", + trailingText: store.userSession.mapType.description, + accessory: .dropdown, + showsDivider: false, + action: { + store.send(.view(.toggleMapDropdown)) } - } label: { - SettingMenuRowView( - title: "연동된 지도", - trailingText: store.userSession.mapType.description, - accessory: .dropdown, - showsDivider: false - ) - } + ) } } @@ -107,11 +181,17 @@ extension SettingView { private var accountSettingsSection: some View { settingsSection { SettingMenuRowView( - title: "서비스 이용 약관" + title: "서비스 이용 약관", + action: { + store.send(.delegate(.presentServicePolicy)) + } ) SettingMenuRowView( - title: "개인정보 처리방침" + title: "개인정보 처리방침", + action: { + store.send(.delegate(.presentPrivacyPolicy)) + } ) SettingMenuRowView( @@ -152,3 +232,13 @@ extension SettingView { openURL(settingsURL) } } + +// MARK: - Custom Button Style +struct ScaleButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.96 : 1.0) + .opacity(configuration.isPressed ? 0.8 : 1.0) + .animation(.easeInOut(duration: 0.1), value: configuration.isPressed) + } +} diff --git a/Projects/Presentation/Splash/Project.swift b/Projects/Presentation/Splash/Project.swift index 99fff43..87548f9 100644 --- a/Projects/Presentation/Splash/Project.swift +++ b/Projects/Presentation/Splash/Project.swift @@ -13,6 +13,7 @@ let project = Project.makeModule( dependencies: [ .Domain(implements: .UseCase), .Shared(implements: .DesignSystem), + .SPM.sdWebImage ], sources: ["Sources/**"], hasTests: true diff --git a/Projects/Presentation/Splash/Sources/Reducer/SplashReducer.swift b/Projects/Presentation/Splash/Sources/Reducer/SplashReducer.swift index 39a4ab8..622f87b 100644 --- a/Projects/Presentation/Splash/Sources/Reducer/SplashReducer.swift +++ b/Projects/Presentation/Splash/Sources/Reducer/SplashReducer.swift @@ -9,6 +9,8 @@ import Foundation import ComposableArchitecture import UseCase +import Entity +import LogMacro @Reducer @@ -19,9 +21,12 @@ public struct SplashReducer { static let tokenCheckDelay: Duration = .seconds(1.5) } + @ObservableState public struct State: Equatable { public var isCheckingToken = false public var hasValidToken = false + @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty + @Shared(.appStorage("selectedMapType")) var selectedMapTypeStorage: ExternalMapType = .naverMap public init() {} } @@ -44,6 +49,7 @@ public struct SplashReducer { //MARK: - AsyncAction 비동기 처리 액션 public enum AsyncAction: Equatable { case checkToken + case syncMapType } //MARK: - 앱내에서 사용하는 액션 @@ -90,7 +96,10 @@ extension SplashReducer { switch action { case .onAppear: state.isCheckingToken = true - return .send(.async(.checkToken)) + return .merge( + .send(.async(.syncMapType)), + .send(.async(.checkToken)) + ) } } @@ -99,6 +108,13 @@ extension SplashReducer { action: AsyncAction ) -> Effect { switch action { + case .syncMapType: + // AppStorage에서 저장된 mapType을 UserSession에 동기화 + state.$userSession.withLock { + $0.mapType = state.selectedMapTypeStorage + } + return .none + case .checkToken: return .run { send in // 키체인에서 액세스 토큰 확인 @@ -139,6 +155,12 @@ extension SplashReducer { state.isCheckingToken = false state.hasValidToken = hasToken + // UserSession의 isGuest 상태 업데이트 + state.$userSession.withLock { + $0.isGuest = !hasToken + } + + if hasToken { // 토큰이 있으면 메인 화면으로 return .send(.navigation(.presentHome)) @@ -149,4 +171,3 @@ extension SplashReducer { } } } - diff --git a/Projects/Presentation/Splash/Sources/View/SplashView.swift b/Projects/Presentation/Splash/Sources/View/SplashView.swift index 709bc20..1757928 100644 --- a/Projects/Presentation/Splash/Sources/View/SplashView.swift +++ b/Projects/Presentation/Splash/Sources/View/SplashView.swift @@ -7,15 +7,27 @@ import SwiftUI +import DesignSystem import ComposableArchitecture +import SDWebImageSwiftUI public struct SplashView: View { @Bindable var store: StoreOf - @State private var scale: CGFloat = 1.0 - @State private var bgOpacity: Double = 1.0 - @State private var logoOpacity: Double = 1.0 - @State private var isFinished = false + @State private var symbolScale: CGFloat = 1.0 + @State private var symbolScaleX: CGFloat = 1.0 + @State private var symbolScaleY: CGFloat = 1.0 + @State private var symbolRotation: Double = 0 + @State private var symbolOffsetX: CGFloat = 0 + @State private var symbolOpacity: Double = 1 + @State private var wordmarkOpacity: Double = 0 + @State private var wordmarkOffsetX: CGFloat = 14 + + private enum Constants { + static let symbolLargeSize = CGSize(width: 84, height: 77) + static let symbolSmallSize = CGSize(width: 30, height: 28) + static let wordmarkSize = CGSize(width: 212, height: 38) + } public init(store: StoreOf) { self.store = store @@ -23,35 +35,52 @@ public struct SplashView: View { public var body: some View { ZStack { - // 배경 - Color.black - .opacity(bgOpacity) - .ignoresSafeArea() - - // 로고 - Text("Uber") - .font(.system(size: 48, weight: .black)) - .foregroundColor(.white) - .scaleEffect(scale) - .opacity(logoOpacity) + AnimatedImage(name: "splash.gif", isAnimating: .constant(true)) + .resizable() + .scaledToFit() + .edgesIgnoringSafeArea(.all) } .onAppear { - // 토큰 확인 시작 store.send(.view(.onAppear)) + runAnimation() + } + } +} + +private extension SplashView { + func runAnimation() { + symbolScale = 1.0 + symbolScaleX = 1.0 + symbolScaleY = 1.0 + symbolRotation = 0 + symbolOffsetX = 0 + symbolOpacity = 1 + wordmarkOpacity = 0 + wordmarkOffsetX = 14 + + withAnimation(.easeInOut(duration: 0.32).delay(0.28)) { + symbolRotation = 24 + } + + withAnimation(.spring(response: 0.28, dampingFraction: 0.82).delay(0.82)) { + symbolRotation = 0 + } + + withAnimation(.easeInOut(duration: 0.26).delay(1.28)) { + symbolScale = Constants.symbolSmallSize.width / Constants.symbolLargeSize.width + } + + withAnimation(.easeInOut(duration: 0.26).delay(1.78)) { + symbolOffsetX = -54 + } + + withAnimation(.easeOut(duration: 0.12).delay(1.96)) { + symbolOpacity = 0 + } - // 1단계: 로고 확대 - withAnimation(.easeIn(duration: 0.6).delay(0.3)) { - scale = 30.0 - } - // 2단계: 페이드 아웃 - withAnimation(.easeIn(duration: 0.3).delay(0.7)) { - logoOpacity = 0 - bgOpacity = 0 - } - // 3단계: 완료 처리 - DispatchQueue.main.asyncAfter(deadline: .now() + 1.1) { - isFinished = true - } + withAnimation(.easeOut(duration: 0.2).delay(2.04)) { + wordmarkOpacity = 1 + wordmarkOffsetX = 0 } } } diff --git a/Projects/Presentation/Web/Project.swift b/Projects/Presentation/Web/Project.swift new file mode 100644 index 0000000..63a05bf --- /dev/null +++ b/Projects/Presentation/Web/Project.swift @@ -0,0 +1,18 @@ +import Foundation +import ProjectDescription +import DependencyPlugin +import ProjectTemplatePlugin +import ProjectTemplatePlugin +import DependencyPackagePlugin + +let project = Project.makeModule( + name: "Web", + bundleId: .appBundleID(name: ".Web"), + product: .staticFramework, + settings: .settings(), + dependencies: [ + .SPM.composableArchitecture, + .Shared(implements: .DesignSystem), + ], + sources: ["Sources/**"] +) diff --git a/Projects/Presentation/Web/Sources/Reducer/WebFeature.swift b/Projects/Presentation/Web/Sources/Reducer/WebFeature.swift new file mode 100644 index 0000000..d80b57a --- /dev/null +++ b/Projects/Presentation/Web/Sources/Reducer/WebFeature.swift @@ -0,0 +1,38 @@ +// +// WebFeature.swift +// Profile +// +// Created by Wonji Suh on 1/4/26. +// + +import Foundation +import ComposableArchitecture + + +@Reducer +public struct WebFeature { + public init() {} + + @ObservableState + public struct State: Equatable, Hashable { + var url: String = "" + + public init(url: String) { + self.url = url + } + } + + public enum Action { + case backToRoot + + } + + public var body: some Reducer { + Reduce { state, action in + switch action { + case .backToRoot: + return .none + } + } + } +} diff --git a/Projects/Presentation/Web/Sources/View/WebRepresentableView.swift b/Projects/Presentation/Web/Sources/View/WebRepresentableView.swift new file mode 100644 index 0000000..81962de --- /dev/null +++ b/Projects/Presentation/Web/Sources/View/WebRepresentableView.swift @@ -0,0 +1,172 @@ +// +// WebRepresentableView.swift +// Profile +// +// Created by Wonji Suh on 1/4/26. +// + +import SwiftUI +import WebKit + +import DesignSystem + + +public struct WebRepresentableView: UIViewRepresentable { + + // MARK: - URL to load + private var urlToLoad: String + + public init(urlToLoad: String) { + self.urlToLoad = urlToLoad + } + + public func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + public func makeUIView(context: Context) -> UIView { + // 컨테이너 + let containerView = UIView() + containerView.backgroundColor = UIColor(red: 26/255.0, green: 26/255.0, blue: 26/255.0, alpha: 1.0) + + // WKWebView + let configuration = WKWebViewConfiguration() + let webView = WKWebView(frame: .zero, configuration: configuration) + webView.scrollView.showsVerticalScrollIndicator = false + webView.scrollView.minimumZoomScale = 1.0 + webView.scrollView.maximumZoomScale = 1.0 + webView.navigationDelegate = context.coordinator + webView.uiDelegate = context.coordinator + webView.allowsLinkPreview = true + webView.backgroundColor = UIColor(red: 26/255.0, green: 26/255.0, blue: 26/255.0, alpha: 1.0) + webView.translatesAutoresizingMaskIntoConstraints = false + + // AnimatedImage로 로딩 GIF 표시 + let loadingContainer = createAnimatedImageLoader() + loadingContainer.translatesAutoresizingMaskIntoConstraints = false + + containerView.addSubview(webView) + containerView.addSubview(loadingContainer) + + NSLayoutConstraint.activate([ + // WebView는 전체 + webView.topAnchor.constraint(equalTo: containerView.topAnchor), + webView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + webView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + webView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + + // 로딩 컨테이너는 중앙 + loadingContainer.centerXAnchor.constraint(equalTo: containerView.centerXAnchor), + loadingContainer.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), + loadingContainer.widthAnchor.constraint(equalToConstant: 200), + loadingContainer.heightAnchor.constraint(equalToConstant: 200), + ]) + + // 코디네이터가 참조 보관 + context.coordinator.webView = webView + context.coordinator.loadingIndicator = loadingContainer + + // 로드 직전에 로딩 컨테이너 표시 + loadingContainer.alpha = 1 + + // 로드 + _Concurrency.Task { + await loadURLInWebView(urlToLoad: urlToLoad, webView: webView) + } + + return containerView + } + + func loadURLInWebView(urlToLoad: String, webView: WKWebView) async { + guard let url = URL(string: urlToLoad) else { + return + } + let request = URLRequest(url: url, cachePolicy: .useProtocolCachePolicy) + + await MainActor.run { + webView.configuration.upgradeKnownHostsToHTTPS = true + webView.configuration.preferences.minimumFontSize = 16 + webView.load(request) + } + } + + public func updateUIView(_ uiView: UIView, context: Context) { + // 필요 시 업데이트 + } + + // MARK: - AnimatedImage Loading Helper Functions + + private func createAnimatedImageLoader() -> UIView { + let containerView = UIView() + containerView.backgroundColor = .clear + + // SwiftUI ProgressView를 UIKit에 임베드 + let progressView = UIHostingController(rootView: + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .scaleEffect(1.5) + .frame(width: 200, height: 200) + ) + + progressView.view.backgroundColor = .clear + progressView.view.translatesAutoresizingMaskIntoConstraints = false + + containerView.addSubview(progressView.view) + + NSLayoutConstraint.activate([ + progressView.view.topAnchor.constraint(equalTo: containerView.topAnchor), + progressView.view.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + progressView.view.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + progressView.view.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + ]) + + return containerView + } + + // MARK: - Coordinator + public class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate { + var parent: WebRepresentableView + weak var webView: WKWebView? + weak var loadingIndicator: UIView? + + init(_ parent: WebRepresentableView) { + self.parent = parent + } + + // MARK: - WKNavigationDelegate + + public func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + // 로딩 시작 → AnimatedImage 표시 + DispatchQueue.main.async { [weak self] in + guard let self = self, let loadingIndicator = self.loadingIndicator else { return } + loadingIndicator.alpha = 1 + } + } + + public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + // 로딩 완료 → AnimatedImage 숨김(페이드아웃) + hideLoadingIndicator() + } + + public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + hideLoadingIndicator() + } + + public func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + hideLoadingIndicator() + } + + private func hideLoadingIndicator() { + DispatchQueue.main.async { [weak self] in + guard let self = self, let loadingIndicator = self.loadingIndicator else { return } + + // 로딩을 2초 더 표시한 후 숨김 + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + UIView.animate(withDuration: 0.5, animations: { + loadingIndicator.alpha = 0 + }) + } + } + } + } +} diff --git a/Projects/Presentation/Web/Sources/View/WebView.swift b/Projects/Presentation/Web/Sources/View/WebView.swift new file mode 100644 index 0000000..b4648e1 --- /dev/null +++ b/Projects/Presentation/Web/Sources/View/WebView.swift @@ -0,0 +1,47 @@ +// +// WebView.swift +// Profile +// +// Created by Wonji Suh on 1/4/26. +// + +import SwiftUI +import DesignSystem +import ComposableArchitecture + + +public struct WebView: 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(.backToRoot) + }, title: "") + .padding(.horizontal, 16) + + + Spacer() + .frame(height: 20) + + WebRepresentableView(urlToLoad: store.url) + .edgesIgnoringSafeArea(.bottom) + } + .navigationBarBackButtonHidden(true) + } + } +} + diff --git a/Projects/Presentation/Web/ebTests/Sources/Test.swift b/Projects/Presentation/Web/ebTests/Sources/Test.swift new file mode 100644 index 0000000..9c3864f --- /dev/null +++ b/Projects/Presentation/Web/ebTests/Sources/Test.swift @@ -0,0 +1,8 @@ +// +// base.swift +// DDDAttendance +// +// Created by Roy on 2026-03-29 +// Copyright © 2026 DDD , Ltd. All rights reserved. +// + diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/appLogo.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/appLogo.imageset/Contents.json new file mode 100644 index 0000000..d1d7df5 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/appLogo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "appLogo.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/appLogo.imageset/appLogo.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/appLogo.imageset/appLogo.svg new file mode 100644 index 0000000..bf12dd8 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/appLogo.imageset/appLogo.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/endJourney.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/endJourney.imageset/Contents.json new file mode 100644 index 0000000..4f310b2 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/endJourney.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "endJourney.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/endJourney.imageset/endJourney.png b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/endJourney.imageset/endJourney.png new file mode 100644 index 0000000..6755b3e Binary files /dev/null and b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/endJourney.imageset/endJourney.png differ diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/fifteenMinutesNotification.imageset/15Notification.png b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/fifteenMinutesNotification.imageset/15Notification.png new file mode 100644 index 0000000..4423e12 Binary files /dev/null and b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/fifteenMinutesNotification.imageset/15Notification.png differ diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/fifteenMinutesNotification.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/fifteenMinutesNotification.imageset/Contents.json new file mode 100644 index 0000000..21e8e2a --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/fifteenMinutesNotification.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "15Notification.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/fiveMinutesNotification.imageset/5Notification.png b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/fiveMinutesNotification.imageset/5Notification.png new file mode 100644 index 0000000..2f0a8f1 Binary files /dev/null and b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/fiveMinutesNotification.imageset/5Notification.png differ diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/fiveMinutesNotification.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/fiveMinutesNotification.imageset/Contents.json new file mode 100644 index 0000000..7a919fe --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/fiveMinutesNotification.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "5Notification.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} 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 index efe98e2..ad42a58 100644 --- a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/loginlogo.imageset/Contents.json +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/loginlogo.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "loginImage.png", + "filename" : "logo.svg", "idiom" : "universal" } ], 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 deleted file mode 100644 index 3913596..0000000 Binary files a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/loginlogo.imageset/loginImage.png and /dev/null differ diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/loginlogo.imageset/logo.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/loginlogo.imageset/logo.svg new file mode 100644 index 0000000..8426473 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/loginlogo.imageset/logo.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 b299af2..5cdc2aa 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" : "image 25563.png", + "filename" : "Layer_1.svg", "idiom" : "universal" } ], diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo1.imageset/Layer_1.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo1.imageset/Layer_1.svg new file mode 100644 index 0000000..5cf6bc0 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo1.imageset/Layer_1.svg @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 deleted file mode 100644 index 4e7b605..0000000 Binary files a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo1.imageset/image 25563.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 3ae489f..c212a1c 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" : "image 25561.png", + "filename" : "Frame.png", "idiom" : "universal" } ], diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo2.imageset/Frame.png b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo2.imageset/Frame.png new file mode 100644 index 0000000..d8b8457 Binary files /dev/null and b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo2.imageset/Frame.png differ 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 deleted file mode 100644 index db10fda..0000000 Binary files a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo2.imageset/image 25561.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 5181dfe..b7afc13 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" : "image 25562.png", + "filename" : "Layer_1.png", "idiom" : "universal" } ], diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo3.imageset/Layer_1.png b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo3.imageset/Layer_1.png new file mode 100644 index 0000000..ebe46ef Binary files /dev/null and b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo3.imageset/Layer_1.png differ 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 deleted file mode 100644 index 809b100..0000000 Binary files a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/onBoardingLogo3.imageset/image 25562.png and /dev/null differ diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/startNotification.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/startNotification.imageset/Contents.json new file mode 100644 index 0000000..c61adc4 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/startNotification.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "startNotification.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/startNotification.imageset/startNotification.png b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/startNotification.imageset/startNotification.png new file mode 100644 index 0000000..024e164 Binary files /dev/null and b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/startNotification.imageset/startNotification.png differ diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/tenMinutesNotification.imageset/10Notification.png b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/tenMinutesNotification.imageset/10Notification.png new file mode 100644 index 0000000..b6e2a6b Binary files /dev/null and b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/tenMinutesNotification.imageset/10Notification.png differ diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/tenMinutesNotification.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/tenMinutesNotification.imageset/Contents.json new file mode 100644 index 0000000..a81d3e0 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/tenMinutesNotification.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "10Notification.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/cafePin.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/cafePin.imageset/Contents.json new file mode 100644 index 0000000..143de72 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/cafePin.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "cafePin.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/cafePin.imageset/cafePin.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/cafePin.imageset/cafePin.svg new file mode 100644 index 0000000..d4ddbf8 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/cafePin.imageset/cafePin.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/emptyExplore.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/emptyExplore.imageset/Contents.json new file mode 100644 index 0000000..fc2934c --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/emptyExplore.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "emptyExplore.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/emptyExplore.imageset/emptyExplore.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/emptyExplore.imageset/emptyExplore.svg new file mode 100644 index 0000000..58fd807 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/emptyExplore.imageset/emptyExplore.svg @@ -0,0 +1,3 @@ + + + diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/endLocation.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/endLocation.imageset/Contents.json new file mode 100644 index 0000000..5f52d70 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/endLocation.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Group 1000003813.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/endLocation.imageset/Group 1000003813.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/endLocation.imageset/Group 1000003813.svg new file mode 100644 index 0000000..c28515d --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/endLocation.imageset/Group 1000003813.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/etcPin.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/etcPin.imageset/Contents.json new file mode 100644 index 0000000..f0b4461 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/etcPin.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "etcPin.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/etcPin.imageset/etcPin.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/etcPin.imageset/etcPin.svg new file mode 100644 index 0000000..9dfb3a7 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/etcPin.imageset/etcPin.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/foodPin.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/foodPin.imageset/Contents.json new file mode 100644 index 0000000..59cabd9 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/foodPin.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "foodPin.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/foodPin.imageset/foodPin.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/foodPin.imageset/foodPin.svg new file mode 100644 index 0000000..119c69d --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/foodPin.imageset/foodPin.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/gamePin.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/gamePin.imageset/Contents.json new file mode 100644 index 0000000..5e3ea88 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/gamePin.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "gamePin.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/gamePin.imageset/gamePin.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/gamePin.imageset/gamePin.svg new file mode 100644 index 0000000..5cf9070 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/gamePin.imageset/gamePin.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/button/noCheck.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/noCheck.imageset/Contents.json similarity index 100% rename from Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/button/noCheck.imageset/Contents.json rename to Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/noCheck.imageset/Contents.json diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/button/noCheck.imageset/noCheck.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/noCheck.imageset/noCheck.svg similarity index 100% rename from Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/button/noCheck.imageset/noCheck.svg rename to Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/noCheck.imageset/noCheck.svg diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/shoppingPin.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/shoppingPin.imageset/Contents.json new file mode 100644 index 0000000..24f6980 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/shoppingPin.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "shoppingPin.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/shoppingPin.imageset/shoppingPin.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/shoppingPin.imageset/shoppingPin.svg new file mode 100644 index 0000000..23ccaa6 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/shoppingPin.imageset/shoppingPin.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/spotPin.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/spotPin.imageset/Contents.json new file mode 100644 index 0000000..9e83b8f --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/spotPin.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "spotLocation.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/spotPin.imageset/spotLocation.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/spotPin.imageset/spotLocation.svg new file mode 100644 index 0000000..f3ca443 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/spotPin.imageset/spotLocation.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/startLocation.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/startLocation.imageset/Contents.json new file mode 100644 index 0000000..cc46c67 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/startLocation.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "startLocation.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/startLocation.imageset/startLocation.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/startLocation.imageset/startLocation.svg new file mode 100644 index 0000000..94d4623 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/startLocation.imageset/startLocation.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/stationLocation.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/stationLocation.imageset/Contents.json new file mode 100644 index 0000000..624199d --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/stationLocation.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "stationLocation.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/stationLocation.imageset/stationLocation.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/stationLocation.imageset/stationLocation.svg new file mode 100644 index 0000000..41153f7 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/map/stationLocation.imageset/stationLocation.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/Projects/Shared/DesignSystem/Sources/Color/ShapeStyle+.swift b/Projects/Shared/DesignSystem/Sources/Color/ShapeStyle+.swift index 2c118ef..84c2203 100644 --- a/Projects/Shared/DesignSystem/Sources/Color/ShapeStyle+.swift +++ b/Projects/Shared/DesignSystem/Sources/Color/ShapeStyle+.swift @@ -17,9 +17,14 @@ public extension ShapeStyle where Self == Color { static var gray500: Color { .init(hex: "BABABA") } static var gray550: Color { .init(hex: "B0B0B0") } static var gray600: Color { .init(hex: "A1A1A1") } + static var gray650: Color { .init(hex: "9F9F9F") } static var gray700: Color { .init(hex: "878787") } + static var gray750: Color { .init(hex: "595959") } static var gray800: Color { .init(hex: "545454") } + static var gray830: Color { .init(hex: "3D3D3D") } + static var gray850: Color { .init(hex: "373737") } static var gray900: Color { .init(hex: "181818") } + static var gray950: Color { .init(hex: "0A0A0A") } static var lightGray: Color { .init(hex: "CCCCCC") } static var mediumGray: Color { .init(hex: "6C6C6C")} static var slateGray : Color { .init(hex: "949FB1") } diff --git a/Projects/Shared/DesignSystem/Sources/Extension/Color/UIColor+.swift b/Projects/Shared/DesignSystem/Sources/Extension/Color/UIColor+.swift index ef6c72e..287950c 100644 --- a/Projects/Shared/DesignSystem/Sources/Extension/Color/UIColor+.swift +++ b/Projects/Shared/DesignSystem/Sources/Extension/Color/UIColor+.swift @@ -8,7 +8,7 @@ import UIKit public extension UIColor { - convenience init(hex: String, alpha: Double? = .zero) { + convenience init(hex: String, alpha: Double? = 1.0) { let scanner = Scanner(string: hex) _ = scanner.scanString("#") @@ -18,6 +18,6 @@ public extension UIColor { let r = Double((rgb >> 16) & 0xFF) / 255.0 let g = Double((rgb >> 8) & 0xFF) / 255.0 let b = Double((rgb >> 0) & 0xFF) / 255.0 - self.init(red: r, green: g, blue: b, alpha: 100) + self.init(red: r, green: g, blue: b, alpha: alpha ?? 1.0) } } diff --git a/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift b/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift index 8fb0cf2..7de616e 100644 --- a/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift +++ b/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift @@ -32,6 +32,7 @@ public enum ImageAsset: String { case tapEtc case tapFood case location + case rowCheck @@ -39,6 +40,16 @@ public enum ImageAsset: String { case naverMap case googleMap case appleMap + case shoppingPin + case cafePin + case etcPin + case foodPin + case gamePin + case spotPin + case endLocation + case startLocation + case emptyExplore + case stationLocation case onBoardingLogo1 case onBoardingLogo2 @@ -46,6 +57,13 @@ public enum ImageAsset: String { case homeLogo case logo case loginlogo + case appLogo + case locationBadge + case fiveMinutesNotification + case tenMinutesNotification + case fifteenMinutesNotification + case startNotification + case endJourney case warning diff --git a/Projects/Shared/DesignSystem/Sources/Ui/Alert/CustomPopup/CustomConfirmationPopupView.swift b/Projects/Shared/DesignSystem/Sources/Ui/Alert/CustomPopup/CustomConfirmationPopupView.swift index 0652987..061af59 100644 --- a/Projects/Shared/DesignSystem/Sources/Ui/Alert/CustomPopup/CustomConfirmationPopupView.swift +++ b/Projects/Shared/DesignSystem/Sources/Ui/Alert/CustomPopup/CustomConfirmationPopupView.swift @@ -193,10 +193,10 @@ struct CustomConfirmationPopup: View { .customConfirmationPopup( item: .withdrawAccount( onConfirm: { - print("탈퇴하기 선택") + // 탈퇴하기 선택 - 프리뷰용 로그 제거 }, onCancel: { - print("취소 선택") + // 취소 선택 - 프리뷰용 로그 제거 } ) ) @@ -216,10 +216,10 @@ struct CustomConfirmationPopup: View { cancelTitle: "취소", isDestructive: true, onConfirm: { - print("탈퇴하기 선택") + // 탈퇴하기 선택 - 프리뷰용 로그 제거 }, onCancel: { - print("취소 선택") + // 취소 선택 - 프리뷰용 로그 제거 } ) } diff --git a/Projects/Shared/DesignSystem/Sources/Ui/Navigation/CustomNavigationBar.swift b/Projects/Shared/DesignSystem/Sources/Ui/Navigation/CustomNavigationBar.swift index 6fa890a..ebdce99 100644 --- a/Projects/Shared/DesignSystem/Sources/Ui/Navigation/CustomNavigationBar.swift +++ b/Projects/Shared/DesignSystem/Sources/Ui/Navigation/CustomNavigationBar.swift @@ -34,7 +34,7 @@ public struct CustomNavigationBar: View { Image(asset: leftImage) .resizable() .scaledToFit() - .frame(width: 60, height: 60) + .frame(width: 48, height: 48) .onTapGesture { leftAction() } @@ -44,13 +44,12 @@ public struct CustomNavigationBar: View { .pretendardCustomFont(textStyle: .titleBold) .foregroundStyle(.staticBlack) - Spacer() Image(asset: rightImage) .resizable() .scaledToFit() - .frame(width: 60, height: 60) + .frame(width: 48, height: 48) .onTapGesture { rightAction() } diff --git a/Projects/Shared/DesignSystem/Sources/Ui/Navigation/NavigationBar.swift b/Projects/Shared/DesignSystem/Sources/Ui/Navigation/NavigationBar.swift index 32aa363..cf2b401 100644 --- a/Projects/Shared/DesignSystem/Sources/Ui/Navigation/NavigationBar.swift +++ b/Projects/Shared/DesignSystem/Sources/Ui/Navigation/NavigationBar.swift @@ -31,7 +31,7 @@ public struct CustomNavigationBackBar: View { Image(asset: .leftArrow) .resizable() .scaledToFit() - .frame(width: 60, height: 60) + .frame(width: 48, height: 48) .contentShape(Rectangle()) .onTapGesture { buttonAction() diff --git a/Projects/Shared/Utill/Sources/Array/Array+.swift b/Projects/Shared/Utill/Sources/Array/Array+.swift new file mode 100644 index 0000000..3c7825d --- /dev/null +++ b/Projects/Shared/Utill/Sources/Array/Array+.swift @@ -0,0 +1,34 @@ +// +// Array+.swift +// Utill +// +// Created by Wonji Suh on 3/30/26. +// + +import Foundation + +public extension Array where Element: Hashable { + // MARK: - Spot Utils + func hasUnresolvedSpots(keyPath: KeyPath) -> Bool where T: Hashable { + return contains { element in + let value = element[keyPath: keyPath] + // 타입별로 "미해결" 상태를 판단 + if let boolValue = value as? Bool { + return !boolValue // hasDetail이 false인 경우 + } + if let stringValue = value as? String { + return stringValue.isEmpty // 문자열이 비어있는 경우 + } + return false + } + } +} + +// ExploreMapSpot 전용 확장 +import Entity + +public extension Array where Element == ExploreMapSpot { + var hasUnresolvedBaseSpots: Bool { + return contains { !$0.hasDetail } + } +} \ No newline at end of file diff --git a/Projects/Shared/Utill/Sources/CoreLocation/CLLocationCoordinate2D+.swift b/Projects/Shared/Utill/Sources/CoreLocation/CLLocationCoordinate2D+.swift new file mode 100644 index 0000000..e2c8d6d --- /dev/null +++ b/Projects/Shared/Utill/Sources/CoreLocation/CLLocationCoordinate2D+.swift @@ -0,0 +1,44 @@ +// +// CLLocationCoordinate2D+.swift +// Utill +// + +import Foundation +import CoreLocation + +public extension CLLocationCoordinate2D { + var formattedCoordinateText: String { + String(format: "%.6f, %.6f", latitude, longitude) + } + + var approximateAddressText: String { + "\(formattedCoordinateText) 부근" + } + + // MARK: - Distance Utils + func distanceInMeters(to coordinate: CLLocationCoordinate2D) -> Double { + let fromLocation = CLLocation(latitude: self.latitude, longitude: self.longitude) + let toLocation = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude) + return fromLocation.distance(from: toLocation) + } + + func formattedDistanceText(to coordinate: CLLocationCoordinate2D) -> String { + let distanceInMeters = self.distanceInMeters(to: coordinate) + let roundedDistance = Int((distanceInMeters / 10).rounded() * 10) + return "\(roundedDistance)m" + } + + func walkingTimeText(to coordinate: CLLocationCoordinate2D, stationName: String) -> String { + let distanceInMeters = self.distanceInMeters(to: coordinate) + let walkingMinutes = max(Int(ceil(distanceInMeters / 67)), 1) + return "\(stationName)역에서 약 \(walkingMinutes)분" + } + + // MARK: - Coordinate Comparison + static func isSameCoordinate(_ lhs: Double?, _ rhs: Double?, tolerance: Double = 0.000001) -> Bool { + guard let lhs = lhs, let rhs = rhs else { + return lhs == nil && rhs == nil + } + return abs(lhs - rhs) < tolerance + } +} diff --git a/Projects/Shared/Utill/Sources/Date/Date+.swift b/Projects/Shared/Utill/Sources/Date/Date+.swift index 4e118fd..56091c0 100644 --- a/Projects/Shared/Utill/Sources/Date/Date+.swift +++ b/Projects/Shared/Utill/Sources/Date/Date+.swift @@ -139,11 +139,72 @@ public extension Date { dateFormatter.dateFormat = "a h시 m분" return dateFormatter.string(from: date) } + + func formattedReturnDeadlineText(addingMinutes minutes: Int) -> String { + let deadline = Calendar.current.date(byAdding: .minute, value: minutes, to: self) ?? self + + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "a h:mm" + return formatter.string(from: deadline) + } + + // MARK: - Departure Time Utils + func normalizedDepartureTime(from currentTime: Date) -> Date { + let calendar = Calendar.current + let currentDateComponents = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: currentTime) + let selectedTimeComponents = calendar.dateComponents([.hour, .minute], from: self) + + guard let selectedHour = selectedTimeComponents.hour, + let selectedMinute = selectedTimeComponents.minute else { + return self + } + + var normalizedComponents = DateComponents() + normalizedComponents.year = currentDateComponents.year + normalizedComponents.month = currentDateComponents.month + normalizedComponents.day = currentDateComponents.day + normalizedComponents.hour = selectedHour + normalizedComponents.minute = selectedMinute + + guard let normalizedDate = calendar.date(from: normalizedComponents) else { + return self + } + + // 선택된 시간이 현재 시간보다 이전이거나 같으면 다음날로 설정 + if normalizedDate <= currentTime { + return calendar.date(byAdding: .day, value: 1, to: normalizedDate) ?? normalizedDate + } + + return normalizedDate + } + + // MARK: - Route Time Utils + /// 예상 도착 시간 계산 (현재 시간 + 소요 시간) + /// - Parameter durationMinutes: 소요 시간 (분) + /// - Returns: "HH:mm" 형태의 도착 예정 시간 + static func estimatedArrivalTime(durationMinutes: Int) -> String { + let now = Date() + let arrivalDate = now.addingTimeInterval(TimeInterval(durationMinutes * 60)) + + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "HH:mm" + + return formatter.string(from: arrivalDate) + } } public extension Calendar { func remainingTimeComponents(from currentTime: Date, to targetTime: Date) -> DateComponents { - let components = dateComponents([.hour, .minute], from: currentTime, to: targetTime) + // targetTime이 currentTime보다 이전이면 다음날로 간주 + var adjustedTargetTime = targetTime + if targetTime < currentTime { + // 다음 날 같은 시간으로 조정 + adjustedTargetTime = date(byAdding: .day, value: 1, to: targetTime) ?? targetTime + } + + let components = dateComponents([.hour, .minute], from: currentTime, to: adjustedTargetTime) return DateComponents(hour: max(components.hour ?? 0, 0), minute: max(components.minute ?? 0, 0)) } } diff --git a/Projects/Shared/Utill/Sources/String/String+.swift b/Projects/Shared/Utill/Sources/String/String+.swift index df54bc9..98888b3 100644 --- a/Projects/Shared/Utill/Sources/String/String+.swift +++ b/Projects/Shared/Utill/Sources/String/String+.swift @@ -138,4 +138,142 @@ public extension String { return iso.date(from: fixed) }() } + + func formattedClosingTimeText() -> String { + if let time = self.split(separator: " ").last { + let hhmm = String(time.prefix(5)) + return "\(hhmm)에 영업종료" + } + return self + } + + var stayableMinutesDisplayText: String { + let text = self + .replacingOccurrences(of: " 체류 가능", with: "") + .replacingOccurrences(of: "약 ", with: "") + let value = text.isEmpty ? "0분" : text + return "약 \(value)" + } + + func walkMinutesDisplayText( + spotName: String, + subtitle: String, + distanceText: String + ) -> String { + let text = self + .replacingOccurrences(of: "\(spotName)에서 약 ", with: "") + .replacingOccurrences(of: "\(subtitle)에서 약 ", with: "") + .replacingOccurrences(of: "\(distanceText) ", with: "") + .components(separatedBy: "약 ") + .last? + .trimmingCharacters(in: .whitespacesAndNewlines) + + return (text?.isEmpty == false ? text! : "0분") + } + + var normalizedURL: URL? { + let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + if let url = URL(string: trimmed) { + return url + } + + let encoded = trimmed.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) + return encoded.flatMap(URL.init(string:)) + } + + var minutesValue: Int { + Int( + replacingOccurrences(of: "약 ", with: "") + .replacingOccurrences(of: "분", with: "") + ) ?? 0 + } + + static func openingHoursText(status: String?, closing: String?) -> String { + switch (status?.nilIfEmpty, closing?.nilIfEmpty) { + case let (status?, closing?): + return "\(status) \(closing)" + case let (status?, nil): + return status + case let (nil, closing?): + return closing + case (nil, nil): + return "영업 시간 정보 준비 중" + } + } + + var formattedPlaceNameForDisplay: String { + var value = self + + let patterns = [ + #"(?<=[가-힣A-Za-z0-9])(서울역|용산역|청량리역|강릉역|수서역|부산역|대전역|동대구역)"#, + #"(?<=(서울역|용산역|청량리역|강릉역|수서역|부산역|대전역|동대구역))(?=[가-힣A-Za-z0-9])"#, + #"(?<=[가-힣A-Za-z0-9])(롯데아울렛|현대아울렛|신세계아울렛)"# + ] + + for pattern in patterns { + value = value.replacingOccurrences( + of: pattern, + with: pattern.contains("(?=") ? " " : " $1", + options: .regularExpression + ) + } + + value = value.replacingOccurrences( + of: #"\s+"#, + with: " ", + options: .regularExpression + ) + + return value.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var nilIfEmpty: String? { + isEmpty ? nil : self + } + + // MARK: - Station Utils + var normalizedStationName: String { + self + .replacingOccurrences(of: "역", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +// MARK: - Distance Formatting +public extension Int { + /// 미터 단위 거리를 적절한 단위로 포맷팅 + /// 1000m 이상은 km로, 그 이하는 m로 표시 + var formattedDistance: String { + if self >= 1000 { + let kilometers = Double(self) / 1000.0 + + // 1.0km, 2.5km 등으로 표시 (소수점 1자리까지, 불필요한 .0은 제거) + if kilometers == Double(Int(kilometers)) { + return "\(Int(kilometers))km" + } else { + return String(format: "%.1fkm", kilometers) + } + } else { + return "\(self)m" + } + } + + /// 분 단위 시간을 포맷팅 + /// 60분 이상은 시간으로, 그 이하는 분으로 표시 + var formattedDuration: String { + if self >= 60 { + let hours = self / 60 + let minutes = self % 60 + + if minutes == 0 { + return "\(hours)시간" + } else { + return "\(hours)시간 \(minutes)분" + } + } else { + return "\(self)분" + } + } } diff --git a/Projects/Shared/Utill/Sources/UIKit/UIScreen+.swift b/Projects/Shared/Utill/Sources/UIKit/UIScreen+.swift new file mode 100644 index 0000000..0c4f0b5 --- /dev/null +++ b/Projects/Shared/Utill/Sources/UIKit/UIScreen+.swift @@ -0,0 +1,19 @@ +// +// UIScreen+.swift +// Utill +// +// Created by Wonji Suh on 3/30/26. +// + +import UIKit + +public extension UIScreen { + // MARK: - Card UI Utils + static var cardTravelDistance: CGFloat { + main.bounds.width - 8 + } + + static var cardSwipeThreshold: CGFloat { + (main.bounds.width - 32) / 2 + } +} \ No newline at end of file diff --git a/README.md b/README.md index b343cb4..fce0291 100644 --- a/README.md +++ b/README.md @@ -1,99 +1,141 @@ -# 타임 스팟 (TimeSpot) +# TimeSpot iOS -Tuist로 구성된 멀티 모듈 iOS 프로젝트입니다. +
-## 🏗️ 프로젝트 구조 +TimeSpot Logo -``` -TimeSpot/ -├── Projects/ -│ ├── App/ # 메인 애플리케이션 -│ ├── Presentation/ # UI 계층 -│ ├── Domain/ # 🔥 도메인 계층 (비즈니스 로직 + Protocol) -│ │ ├── Entity/ # 도메인 엔티티 + Entity Protocol -│ │ ├── UseCase/ # 비즈니스 로직 + UseCase Protocol -│ │ └── DomainInterface/ # Domain 계층 인터페이스 모듈 -│ ├── Data/ # 데이터 계층 (데이터 접근 + Model) -│ │ ├── Model/ # 데이터 전송 객체 (DTO, API Response) -│ │ ├── Repository/ # Repository 구현체 (Domain Protocol 구현) -│ │ ├── API/ # REST API 클라이언트 -│ │ └── Service/ # 데이터 처리 서비스 -│ ├── Network/ # 네트워크 계층 -│ │ ├── Networks/ # 네트워크 기본 설정 및 클라이언트 -│ │ ├── Foundations/ # 네트워크 기반 유틸리티 -│ │ └── ThirdPartys/ # 네트워크 써드파티 라이브러리 (AsyncMoya, WeaveDI) -│ └── Shared/ # 공통 모듈 -│ ├── DesignSystem/ # 공통 UI 컴포넌트, 폰트 등 -│ ├── Shared/ # 공통 공유 모듈 -│ └── Utill/ # 공통 유틸리티 -├── Tuist/ -│ ├── Package.swift -│ └── ProjectDescriptionHelpers/ -└── Plugins/ -``` +**여행의 새로운 시작, 대기 시간을 활용한 스마트한 여정** +![Platform](https://img.shields.io/badge/Platform-iOS-orange.svg) +![Language](https://img.shields.io/badge/Language-Swift-FA7343.svg?logo=swift&logoColor=white) +![iOS](https://img.shields.io/badge/iOS-17.0+-34C759.svg) +![Xcode](https://img.shields.io/badge/Xcode-16.0+-007ACC.svg) +![TCA](https://img.shields.io/badge/Architecture-TCA-purple.svg) +![Tuist](https://img.shields.io/badge/Modularization-Tuist-blue.svg) +[📱 App Store](#) | [🎯 Features](#-주요-기능) | [🏗 Architecture](#-프로젝트-아키텍처) | [🚀 Quick Start](#-빠른-시작) -![Tuist Graph](./graph.png) +--- -## 📦 주요 모듈 설명 +
-- **App**: 메인 애플리케이션 모듈 -- **Presentation**: UI 계층 (ViewController, ViewModel) -- **Domain**: 도메인 계층 (Entity, UseCase, Interface) -- **Data**: 데이터 계층 (Model, Repository, API, Service) -- **Network**: 네트워크 계층 (Networks, Foundations, ThirdPartys) -- **Shared**: 공통 모듈 (DesignSystem, Shared, Utill) +## 📖 프로젝트 소개 -## 🚀 빠른 시작 +**TimeSpot**은 KTX 출발 전 대기 시간을 효율적으로 활용할 수 있도록 도와주는 iOS 애플리케이션입니다. +역 주변의 관광지, 맛집, 카페 등을 탐색하고 출발 시간에 맞춰 안전하게 플랫폼으로 돌아올 수 있도록 가이드합니다. -### 프로젝트 생성 +> 💡 **우리는 왜 이 앱을 만들었을까요?** +> 기차를 기다리는 시간, 그저 대기실에서 시간을 보내기엔 아깝지 않나요? +> TimeSpot과 함께 여행의 시작부터 특별한 경험을 만들어보세요. -```bash -# TuistTool 컴파일 (최초 1회) -swiftc TuistTool.swift -o make +### 📱 스크린샷 -# 새 프로젝트 생성 (대화형) -./make newproject +
-# 또는 바로 설정 -./make newproject MyApp --bundle-id com.company.myapp -``` +| 메인 화면 | 역 선택 | 주변 탐색 | 경로 안내 | +|:---:|:---:|:---:|:---:| +| | | | | -### 개발 환경 설정 -```bash -# Tuist 4.97.2 워크플로우 -./make build # clean → install → generate -./make generate # 프로젝트 생성 -./make clean # 정리 -./make reset # 전체 캐시 리셋 -``` +
-## ⚙️ 개발 환경 +## ✨ 주요 기능 -- **iOS 17.0+** (Swift Concurrency 완전 지원) -- **Xcode 26.0.1+** -- **Swift 6.0+** -- **Tuist 4.97.2** +### 🚉 스마트한 여행 계획 +- **전국 주요 KTX역 지원**: 서울, 부산, 대전, 광주 등 전국 26개 역 +- **정확한 출발 시간 관리**: 다음날 23:59까지 자유로운 시간 설정 +- **대기 시간 계산**: 최소 20분 이상의 여유 시간 확보 -## 📚 사용 라이브러리 +### 🗺️ 주변 장소 탐색 +- **다양한 POI 정보**: 관광지, 맛집, 카페, 쇼핑, 문화시설 등 +- **실시간 위치 기반**: GPS를 활용한 정확한 주변 정보 +- **상세 정보 제공**: 영업시간, 리뷰, 연락처 등 -- **ComposableArchitecture**: 상태 관리 -- **TCACoordinators**: TCA 기반 네비게이션 -- **WeaveDI**: 의존성 주입 -- **Swift Concurrency**: Actor 기반 비동기 처리 +### 📍 똑똑한 길찾기 +- **NaverMap 연동**: 정확하고 빠른 경로 안내 +- **외부 앱 지원**: 네이버맵, 구글맵, 애플 지도 등 +- **실시간 소요 시간**: 도보 경로 및 예상 도착 시간 -## 🎯 주요 특징 +### ⏰ 알림 시스템 +- **15분 전**: "활동을 차분히 마무리해 주세요" +- **10분 전**: "슬슬 일어날 준비를 해볼까요?" +- **5분 전**: "출발 채비를 할 시간이에요" +- **즉시 출발**: "지금 바로 출발해야 해요!" + +### 📊 여행 기록 관리 +- **방문 히스토리**: 다녀온 장소와 여행 기록 보관 +- **여정 추적**: 출발부터 복귀까지의 전체 여정 관리 + +## 🏗 프로젝트 아키텍처 + +### 🎯 Micro Feature Architecture with Tuist -### 🏛️ Clean Architecture ``` -Presentation → Domain → Data - ↓ ↓ ↓ - UI 로직 비즈니스 데이터 +TimeSpot-iOS/ +├── 📱 Projects/ +│ ├── App/ # 메인 애플리케이션 타겟 +│ │ └── Sources/ +│ │ ├── Application/ # AppDelegate, SceneDelegate +│ │ └── Root/ # 루트 화면 설정 +│ │ +│ ├── Presentation/ # 🎨 UI Layer +│ │ └── Home/ # 홈 기능 모듈 +│ │ ├── Sources/ +│ │ │ ├── Main/ # 메인 홈 화면 +│ │ │ ├── TrainStation/ # 역 선택 +│ │ │ ├── Explore/ # 주변 탐색 +│ │ │ ├── Route/ # 경로 안내 +│ │ │ ├── RouteNotification/ # 알림 +│ │ │ └── Components/ # 공통 컴포넌트 +│ │ ├── Tests/ # 단위 테스트 +│ │ └── Testing/ # 테스트 Mock +│ │ +│ ├── Domain/ # 🔥 Business Logic Layer +│ │ ├── Entity/ # 도메인 엔티티 +│ │ ├── UseCase/ # 비즈니스 로직 구현 +│ │ └── DomainInterface/ # 인터페이스 정의 (Protocol) +│ │ +│ ├── Data/ # 📡 Data Layer +│ │ ├── Service/ # REST API 서비스 +│ │ ├── Repository/ # Repository 구현체 +│ │ └── Model/ # DTO, Response Models +│ │ +│ ├── Network/ # 🌐 Network Layer +│ │ ├── Networks/ # 네트워크 설정 +│ │ ├── Foundations/ # 네트워크 유틸리티 +│ │ └── ThirdPartys/ # AsyncMoya, WeaveDI +│ │ +│ └── Shared/ # 🔧 Shared Layer +│ ├── DesignSystem/ # 디자인 시스템 +│ ├── Utill/ # 공통 유틸리티 +│ └── ThirdPartyLib/ # 외부 라이브러리 래핑 +│ +└── 🔧 Tuist/ # 프로젝트 설정 + ├── Package.swift + └── ProjectDescriptionHelpers/ ``` -### 🔄 의존성 방향 +### 🏛️ Clean Architecture Pattern + +```mermaid +graph TD + A[🎨 Presentation Layer] --> B[🔥 Domain Layer] + B --> C[📡 Data Layer] + D[🌐 Network Layer] --> C + E[🔧 Shared Layer] --> A + E --> B + E --> C + + A -.-> F[SwiftUI Views] + A -.-> G[TCA Reducers] + B -.-> H[Use Cases] + B -.-> I[Entities] + C -.-> J[Repositories] + C -.-> K[API Services] +``` + +### 🔄 의존성 방향 원칙 + ``` Presentation → Domain (UseCase Protocol) ↓ @@ -104,38 +146,189 @@ Data/Repository → Domain (Entity + Repository Protocol) Data/Model → Domain (Entity 변환) ``` -**핵심 원칙:** -- Presentation은 Domain의 UseCase Protocol만 의존 -- Domain은 외부 계층에 의존하지 않는 순수 비즈니스 로직 -- Data는 Domain의 Entity와 Repository Protocol을 구현 -- 모든 데이터 흐름은 Domain을 중심으로 진행 +**핵심 설계 원칙:** +- ✅ **Presentation**은 Domain의 UseCase Protocol만 의존 +- ✅ **Domain**은 외부 계층에 의존하지 않는 순수 비즈니스 로직 +- ✅ **Data**는 Domain의 Entity와 Repository Protocol을 구현 +- ✅ 모든 데이터 흐름은 **Domain을 중심**으로 진행 + +## 🛠 기술 스택 -### 🚀 Swift Concurrency -- Actor 기반 Thread-Safe KeychainManager 구현 +### Core Technologies +- **🎯 Architecture**: The Composable Architecture (TCA) +- **📦 Modularization**: Tuist 4.97.2 (Micro Feature Architecture) +- **💉 Dependency Injection**: WeaveDI +- **🔀 Navigation**: TCA Navigation +- **⚡ Concurrency**: Swift Concurrency (Actor 기반 비동기 처리) -### 📦 Tuist 4.97.2 최적화 -- ✅ 새로운 `install` 명령어로 빠른 의존성 관리 -- ✅ 바이너리 캐시 활용으로 빌드 성능 향상 -- ✅ 암시적 의존성 자동 검사 +### Key Dependencies +- **ComposableArchitecture**: 상태 관리 및 단방향 데이터 플로우 +- **TCACoordinators**: TCA 기반 네비게이션 시스템 +- **WeaveDI**: 의존성 주입 컨테이너 +- **Swift Concurrency**: Actor 기반 Thread-Safe 비동기 처리 + +### UI & UX +- **🎨 UI Framework**: SwiftUI +- **🎨 Design System**: 커스텀 DesignSystem 모듈 +- **🖼️ Image Loading**: Kingfisher with 최적화 + +### Networking & Data +- **🌐 HTTP Client**: Moya + AsyncMoya +- **📱 API Architecture**: RESTful API with JSON +- **💾 Local Storage**: UserDefaults, Keychain (Actor 기반) +- **📡 Real-time**: Push Notifications (APNs) + +### Maps & Location +- **🗺️ Map Service**: Naver Maps SDK +- **📍 Location**: Core Location Framework +- **🛣️ Places**: Google Places API + +### Development Tools +- **📊 Analytics**: 커스텀 로깅 시스템 (LogMacro) +- **🔧 Build Tool**: Tuist + SPM +- **🧪 Testing**: SwiftTesting + TCA Testing +- **📱 Automation**: fastlane (스크린샷, 배포) +- **⚡ Performance**: Swift Concurrency (async/await, Actor) + +## 🚀 빠른 시작 + +### ✅ 필수 요구사항 + +- **💻 Xcode**: 16.0 이상 +- **📱 iOS**: 17.0 이상 +- **⚡ Swift**: 6.0 이상 +- **🔧 Tuist**: 4.97.2 이상 + +### 🛠 설치 및 실행 + +#### 1️⃣ 저장소 클론 +```bash +git clone https://github.com/Roy-wonji/TimeSpot-iOS.git +cd TimeSpot-iOS +``` + +#### 2️⃣ Tuist 설치 +```bash +curl -Ls https://install.tuist.io | bash +``` + +#### 3️⃣ 프로젝트 빌드 및 생성 +```bash +# 전체 워크플로우 (권장) +./make build # clean → install → generate + +# 또는 단계별 실행 +./make clean # 기존 파일 정리 +./make install # 의존성 설치 +./make generate # 프로젝트 생성 +``` + +#### 4️⃣ Xcode에서 실행 +```bash +open TimeSpot.xcworkspace +``` + +### ⚙️ 환경 설정 + +프로젝트 실행을 위해 다음 API 키가 필요합니다: + +```swift +// Config.swift에서 설정 +enum APIKeys { + static let naverMapsClientID = "YOUR_NAVER_MAPS_KEY" + static let googlePlacesAPI = "YOUR_GOOGLE_PLACES_KEY" + static let timeSpotServerURL = "YOUR_SERVER_URL" +} +``` ## 🛠️ 주요 명령어 -### 기본 워크플로우 +### 🔄 기본 워크플로우 ```bash -./make build # 전체 빌드 (권장) +./make build # 전체 빌드 프로세스 (권장) ./make generate # 프로젝트 생성만 -./make moduleinit # 새 모듈 생성 +./make moduleinit # 새 모듈 생성 (대화형) ``` -### 문제 해결 +### 🚨 문제 해결 ```bash -./make reset # 강력한 클린 + 재생성 +./make reset # 강력한 클린 + 캐시 삭제 + 재생성 +./make clean # 빌드 아티팩트 정리 ./make install # 의존성 재설치 ``` -### 코드 품질 +### 🔍 코드 품질 관리 ```bash -./make inspect-imports # 의존성 검사 -./make inspect-coverage # 코드 커버리지 +./make inspect-imports # 모듈 의존성 검사 +./make inspect-coverage # 코드 커버리지 분석 +./make graph # 의존성 그래프 생성 ``` +### 📦 모듈 관리 +```bash +./make moduleinit # 새 Feature 모듈 생성 +./make test # 전체 테스트 실행 +``` + +### 📱 스크린샷 자동 생성 (fastlane) +```bash +fastlane snapshot # 전체 스크린샷 생성 +fastlane snapshot --scheme TimeSpot # 특정 스킴만 +``` + +## 📋 사용법 + +### 1️⃣ 여행 계획 설정 +1. **출발역 선택**: 전국 26개 주요 KTX역 중 선택 +2. **출발 시간 설정**: 현재 시각부터 다음날 23:59까지 +3. **대기 시간 확인**: 최소 20분 이상의 여유 시간 필요 + +### 2️⃣ 주변 장소 탐색 +1. **"주변 탐색 시작하기"** 버튼 터치 +2. **위치 권한** 허용 (정확한 주변 정보 제공을 위해 필요) +3. **카테고리별 탐색**: 관광지 🏛️, 맛집 🍴, 카페 ☕, 쇼핑 🛍️ 등 + +### 3️⃣ 스마트한 경로 안내 +1. **장소 선택**: 방문하고 싶은 장소를 선택 +2. **"길찾기 시작"**: 버튼을 터치하여 여정 시작 +3. **외부 앱 연동**: 선호하는 지도 앱으로 실제 네비게이션 + +### 4️⃣ 안전한 복귀 가이드 +- **📱 스마트 알림**: 출발 시간에 맞춰 단계별 알림 +- **⏰ 실시간 추적**: 현재 위치에서 역까지의 실시간 경로 +- **🔔 맞춤 알림**: 개인 일정에 맞춘 최적의 출발 타이밍 + + +## 📄 라이선스 + +이 프로젝트는 **MIT 라이선스** 하에 배포됩니다. +자세한 내용은 [LICENSE](LICENSE) 파일을 참고하세요. + +## 👥 팀 & 크레딧 + +### 💻 개발팀 +- **iOS Developer & Architecture**: 서원지 ([@Roy-wonji](https://github.com/Roy-wonji)) +- **Backend Developer**: [@loadingKKamo21](https://github.com/loadingKKamo21) +- **Designer**: 박미란 +- **Product Manager**: 전희정 + + +## 📞 문의 및 지원 + +- 📧 **이메일**: suhwj81@gmail.com +- 🐛 **버그 신고**: [Issues](https://github.com/Roy-wonji/TimeSpot-iOS/issues) +- 💡 **기능 제안**: [Discussions](https://github.com/Roy-wonji/TimeSpot-iOS/discussions) +- 📱 **App Store**: [TimeSpot 다운로드](#) + +--- + +
+ +**Made with ❤️ by SWYP Team** + +*여행의 시작을 특별하게, TimeSpot과 함께하세요* + +[![Star this repo](https://img.shields.io/github/stars/Roy-wonji/TimeSpot-iOS?style=social)](https://github.com/Roy-wonji/TimeSpot-iOS) + +
+ diff --git a/Tuist/Package.swift b/Tuist/Package.swift index 50cdce0..18d39d5 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -21,7 +21,8 @@ let packageSettings = PackageSettings( "Clocks": .staticFramework, "ConcurrencyExtras": .staticFramework, "WeaveDI": .staticFramework, - "ReactiveSwift": .staticFramework + "ReactiveSwift": .staticFramework, + "SDWebImageSwiftUI": .staticFramework ] ) #endif @@ -36,5 +37,8 @@ let package = Package( .package(url: "https://github.com/Roy-wonji/AsyncMoya", from: "1.1.8"), .package(url: "https://github.com/openid/AppAuth-iOS.git", from: "2.0.0"), .package(url: "https://github.com/ReactiveCocoa/ReactiveSwift.git", from: "6.7.0"), + .package(url: "https://github.com/onevcat/Kingfisher.git", from: "8.2.0"), + .package(url: "https://github.com/firebase/firebase-ios-sdk.git", exact: "12.7.0"), + .package(url: "https://github.com/SDWebImage/SDWebImageSwiftUI.git", from: "2.0.0"), ] ) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 5d556fd..cb9741b 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -36,6 +36,33 @@ platform :ios do in_house: false ) + # 자동으로 빌드 번호 증가 + current_build_number = latest_testflight_build_number( + app_identifier: BUNDLE_ID, + platform: "ios" + ) + new_build_number = current_build_number + 1 + puts "🔢 Current build number: #{current_build_number}" + puts "🔢 New build number: #{new_build_number}" + + # Extension+String.swift 파일에서 빌드 번호 업데이트 + extension_file_path = "../Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift" + content = File.read(extension_file_path) + + # 빌드 번호 라인 수정 + updated_content = content.gsub(/public static func appBuildVersion\(buildVersion: String = "\d+"\) -> String \{/, + "public static func appBuildVersion(buildVersion: String = \"#{new_build_number}\") -> String {") + + File.write(extension_file_path, updated_content) + puts "✅ Updated build number to #{new_build_number} in Extension+String.swift" + + # Tuist 프로젝트 재생성 + Dir.chdir("..") do + puts "🔧 Regenerating Tuist workspace with new build number..." + sh("tuist generate --no-open") + puts "✅ Tuist workspace regenerated" + end + match( type: "appstore", readonly: true, @@ -44,6 +71,7 @@ platform :ios do app_identifier: ["io.TimeSpot.co"] ) + ipa_path = nil # Change to project root directory Dir.chdir("..") do puts "📁 Current directory: #{Dir.pwd}" @@ -123,7 +151,7 @@ platform :ios do upload_to_testflight( ipa: ipa_path, changelog: "변경사항", - groups: ["TimeSpot"], + groups: ["TimeSpot", "timeSpot"], beta_app_description: "TimeSpot 앱입니다.", notify_external_testers: true, skip_waiting_for_build_processing: true, @@ -144,14 +172,35 @@ platform :ios do in_house: false ) - # 자동으로 빌드 번호 증가 - current_build_number = latest_testflight_build_number( - app_identifier: BUNDLE_ID, - platform: "ios" - ) + # QA와 동일한 빌드 번호 자동 증가 로직 + begin + current_build_number = latest_testflight_build_number( + app_identifier: BUNDLE_ID, + platform: "ios" + ) + puts "📱 TestFlight 최신 빌드 번호: #{current_build_number}" + rescue => e + puts "⚠️ TestFlight 빌드 번호 조회 실패, 기본값 1 사용: #{e.message}" + current_build_number = 1 + end + + # App Store 라이브 버전 빌드 번호도 확인 + begin + live_build_number = app_store_build_number( + app_identifier: BUNDLE_ID, + platform: "ios" + ) + puts "🏪 App Store 라이브 빌드 번호: #{live_build_number}" + + # 더 높은 빌드 번호 사용 + current_build_number = [current_build_number, live_build_number].max + rescue => e + puts "⚠️ App Store 빌드 번호 조회 실패: #{e.message}" + end + new_build_number = current_build_number + 1 - puts "🔢 Current build number: #{current_build_number}" - puts "🔢 New build number: #{new_build_number}" + puts "🔢 현재 최고 빌드 번호: #{current_build_number}" + puts "🔢 새로운 빌드 번호: #{new_build_number}" # Extension+String.swift 파일에서 빌드 번호 업데이트 extension_file_path = "../Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift" @@ -162,14 +211,15 @@ platform :ios do "public static func appBuildVersion(buildVersion: String = \"#{new_build_number}\") -> String {") File.write(extension_file_path, updated_content) - puts "✅ Updated build number to #{new_build_number} in Extension+String.swift" + puts "✅ 빌드 번호를 #{new_build_number}로 업데이트 완료" # Tuist 프로젝트 재생성 Dir.chdir("..") do - puts "🔧 Regenerating Tuist workspace with new build number..." + puts "🔧 Tuist workspace 재생성 중..." sh("tuist generate --no-open") - puts "✅ Tuist workspace regenerated" + puts "✅ Tuist workspace 재생성 완료" end + match( type: "appstore", readonly: true, @@ -178,29 +228,82 @@ platform :ios do app_identifier: ["io.TimeSpot.co"] ) - ipa_path = build_app( - workspace: "#{APP_RELEASE_NAME}.xcworkspace", - scheme: "TimeSpot-Prod", - configuration: "Release", - output_directory: OUTPUT_DIR, - output_name: IPA_FILENAME, - export_method: "app-store", - clean: true, - silent: false, - verbose: true, - xcargs: "CODE_SIGN_IDENTITY='Apple Distribution' CODE_SIGN_STYLE=Manual DEVELOPMENT_TEAM=#{TEAM_ID} " + - "COMPILER_INDEX_STORE_ENABLE=NO ENABLE_BITCODE=NO " + - "SWIFT_COMPILATION_MODE=wholemodule " + - "-allowProvisioningUpdates", - export_options: { - signingStyle: "manual", - uploadBitcode: false, - teamID: TEAM_ID, - provisioningProfiles: { - "#{BUNDLE_ID}" => "match AppStore #{BUNDLE_ID}" + ipa_path = nil + # QA와 동일한 workspace 처리 로직 + Dir.chdir("..") do + puts "📁 현재 디렉토리: #{Dir.pwd}" + puts "📋 사용 가능한 파일들: #{Dir.entries('.')}" + + # Try to generate workspace if missing + workspace_files = Dir.glob("*.xcworkspace") + if workspace_files.empty? + puts "🔧 workspace가 없습니다, 생성 중..." + + # Check if tuist is available + tuist_available = system("which tuist > /dev/null 2>&1") + puts "Tuist 사용 가능: #{tuist_available}" + + unless tuist_available + puts "🔧 brew로 tuist 설치 중..." + sh("brew install tuist") + end + + begin + if File.exist?("make") + puts "🔧 ./make install 실행 중..." + sh("./make install") + puts "🔧 ./make generate 실행 중..." + sh("./make generate") + else + puts "🔧 tuist 직접 실행 중..." + sh("tuist install") + sh("tuist generate --no-open") + end + rescue => e + puts "❌ 생성 실패: #{e.message}" + end + + workspace_files = Dir.glob("*.xcworkspace") + puts "🔍 발견된 workspace 파일들: #{workspace_files}" + end + + if workspace_files.empty? + puts "❌ workspace를 찾을 수 없습니다!" + puts "🔍 디렉토리 내 모든 파일:" + Dir.entries('.').each { |f| puts " #{f}" } + UI.user_error!("workspace 파일을 생성할 수 없습니다. tuist 설정과 플러그인을 확인해주세요.") + end + + workspace_name = workspace_files.first + workspace_path = File.expand_path(workspace_name) + puts "✅ 사용할 workspace: #{workspace_name}" + puts "✅ 전체 workspace 경로: #{workspace_path}" + + ipa_path = build_app( + workspace: workspace_path, + scheme: "TimeSpot-Prod", + configuration: "Release", + export_method: "app-store", + output_directory: File.expand_path("../#{OUTPUT_DIR}"), + output_name: IPA_FILENAME, + clean: false, + silent: true, + xcargs: "CODE_SIGN_IDENTITY='Apple Distribution' CODE_SIGN_STYLE=Manual DEVELOPMENT_TEAM=#{TEAM_ID} " + + "COMPILER_INDEX_STORE_ENABLE=NO ENABLE_BITCODE=NO " + + "SWIFT_COMPILATION_MODE=wholemodule -parallelizeTargets " + + "ENABLE_PARALLEL_SIGNING=YES DISABLE_MANUAL_TARGET_ORDER_BUILD_WARNING=YES " + + "FRAMEWORK_SEARCH_PATHS='$(inherited)' LIBRARY_SEARCH_PATHS='$(inherited)' " + + "-allowProvisioningUpdates -quiet", + export_options: { + signingStyle: "manual", + uploadBitcode: false, + teamID: TEAM_ID, + provisioningProfiles: { + "#{BUNDLE_ID}" => "match AppStore #{BUNDLE_ID}" + } } - } - ) + ) + end # clean_symbols_from_ipa(ipa_path: ipa_path) @@ -226,6 +329,7 @@ platform :ios do UI.important(" 1. https://appstoreconnect.apple.com 접속") UI.important(" 2. TimeSpot 앱 선택") UI.important(" 3. 버전 #{options[:version]} 심사 상태 확인") + UI.important(" 4. 빌드 번호: #{new_build_number}") UI.important("") UI.success("✅ 빌드, 업로드, 심사 요청이 모두 성공적으로 완료되었습니다!") UI.success("🚀 이제 Apple의 심사를 기다리기만 하면 됩니다!") diff --git a/fastlane/README.md b/fastlane/README.md new file mode 100644 index 0000000..25c3781 --- /dev/null +++ b/fastlane/README.md @@ -0,0 +1,59 @@ +fastlane documentation +---- + +# Installation + +Make sure you have the latest version of the Xcode command line tools installed: + +```sh +xcode-select --install +``` + +For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane) + +# Available Actions + +### tdd_ci + +```sh +[bundle exec] fastlane tdd_ci +``` + +TDD 테스트 및 자동 배포 + +---- + + +## iOS + +### ios QA + +```sh +[bundle exec] fastlane ios QA +``` + +Upload to TestFlight (Debug) + +### ios release + +```sh +[bundle exec] fastlane ios release +``` + +Submit to App Store + +### ios submit_for_review + +```sh +[bundle exec] fastlane ios submit_for_review +``` + +Submit already uploaded version for review + +---- + +This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. + +More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools). + +The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools). diff --git a/fastlane/metadata/copyright.txt b/fastlane/metadata/copyright.txt new file mode 100644 index 0000000..9aea452 --- /dev/null +++ b/fastlane/metadata/copyright.txt @@ -0,0 +1 @@ +@TimeSpot diff --git a/fastlane/metadata/ko/apple_tv_privacy_policy.txt b/fastlane/metadata/ko/apple_tv_privacy_policy.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/fastlane/metadata/ko/apple_tv_privacy_policy.txt @@ -0,0 +1 @@ + diff --git a/fastlane/metadata/ko/description.txt b/fastlane/metadata/ko/description.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/fastlane/metadata/ko/description.txt @@ -0,0 +1 @@ + diff --git a/fastlane/metadata/ko/keywords.txt b/fastlane/metadata/ko/keywords.txt new file mode 100644 index 0000000..015da47 --- /dev/null +++ b/fastlane/metadata/ko/keywords.txt @@ -0,0 +1 @@ +타임스팟, 여랭, TIMESPOT, timespot, TimeSpot , 주변역 둘러볼때 diff --git a/fastlane/metadata/ko/marketing_url.txt b/fastlane/metadata/ko/marketing_url.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/fastlane/metadata/ko/marketing_url.txt @@ -0,0 +1 @@ + diff --git a/fastlane/metadata/ko/name.txt b/fastlane/metadata/ko/name.txt new file mode 100644 index 0000000..ed6012c --- /dev/null +++ b/fastlane/metadata/ko/name.txt @@ -0,0 +1 @@ +TimeSpot diff --git a/fastlane/metadata/ko/privacy_url.txt b/fastlane/metadata/ko/privacy_url.txt new file mode 100644 index 0000000..6352bab --- /dev/null +++ b/fastlane/metadata/ko/privacy_url.txt @@ -0,0 +1 @@ +https://www.notion.so/329f94ae438b807d95dcd0f5f8abf66a?source=copy_link diff --git a/fastlane/metadata/ko/promotional_text.txt b/fastlane/metadata/ko/promotional_text.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/fastlane/metadata/ko/promotional_text.txt @@ -0,0 +1 @@ + diff --git a/fastlane/metadata/ko/release_notes.txt b/fastlane/metadata/ko/release_notes.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/fastlane/metadata/ko/release_notes.txt @@ -0,0 +1 @@ + diff --git a/fastlane/metadata/ko/subtitle.txt b/fastlane/metadata/ko/subtitle.txt new file mode 100644 index 0000000..443e8dc --- /dev/null +++ b/fastlane/metadata/ko/subtitle.txt @@ -0,0 +1 @@ +대기시간 활용 · 기차여행 · 역 근처 추천 diff --git a/fastlane/metadata/ko/support_url.txt b/fastlane/metadata/ko/support_url.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/fastlane/metadata/ko/support_url.txt @@ -0,0 +1 @@ + diff --git a/fastlane/metadata/primary_category.txt b/fastlane/metadata/primary_category.txt new file mode 100644 index 0000000..bed7bef --- /dev/null +++ b/fastlane/metadata/primary_category.txt @@ -0,0 +1 @@ +TRAVEL diff --git a/fastlane/metadata/primary_first_sub_category.txt b/fastlane/metadata/primary_first_sub_category.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/fastlane/metadata/primary_first_sub_category.txt @@ -0,0 +1 @@ + diff --git a/fastlane/metadata/primary_second_sub_category.txt b/fastlane/metadata/primary_second_sub_category.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/fastlane/metadata/primary_second_sub_category.txt @@ -0,0 +1 @@ + diff --git a/fastlane/metadata/review_information/demo_password.txt b/fastlane/metadata/review_information/demo_password.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/fastlane/metadata/review_information/demo_password.txt @@ -0,0 +1 @@ + diff --git a/fastlane/metadata/review_information/demo_user.txt b/fastlane/metadata/review_information/demo_user.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/fastlane/metadata/review_information/demo_user.txt @@ -0,0 +1 @@ + diff --git a/fastlane/metadata/review_information/email_address.txt b/fastlane/metadata/review_information/email_address.txt new file mode 100644 index 0000000..f270004 --- /dev/null +++ b/fastlane/metadata/review_information/email_address.txt @@ -0,0 +1 @@ +shuwj81@icloud.com diff --git a/fastlane/metadata/review_information/first_name.txt b/fastlane/metadata/review_information/first_name.txt new file mode 100644 index 0000000..1ef1d2f --- /dev/null +++ b/fastlane/metadata/review_information/first_name.txt @@ -0,0 +1 @@ +원지 diff --git a/fastlane/metadata/review_information/last_name.txt b/fastlane/metadata/review_information/last_name.txt new file mode 100644 index 0000000..a963e41 --- /dev/null +++ b/fastlane/metadata/review_information/last_name.txt @@ -0,0 +1 @@ +서 diff --git a/fastlane/metadata/review_information/notes.txt b/fastlane/metadata/review_information/notes.txt new file mode 100644 index 0000000..d22cca7 --- /dev/null +++ b/fastlane/metadata/review_information/notes.txt @@ -0,0 +1 @@ +애플 또는 비회원로그인 처리 가능 합니다 diff --git a/fastlane/metadata/review_information/phone_number.txt b/fastlane/metadata/review_information/phone_number.txt new file mode 100644 index 0000000..407eae8 --- /dev/null +++ b/fastlane/metadata/review_information/phone_number.txt @@ -0,0 +1 @@ ++82 1094375187 diff --git a/fastlane/metadata/secondary_category.txt b/fastlane/metadata/secondary_category.txt new file mode 100644 index 0000000..cc52552 --- /dev/null +++ b/fastlane/metadata/secondary_category.txt @@ -0,0 +1 @@ +LIFESTYLE diff --git a/fastlane/metadata/secondary_first_sub_category.txt b/fastlane/metadata/secondary_first_sub_category.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/fastlane/metadata/secondary_first_sub_category.txt @@ -0,0 +1 @@ + diff --git a/fastlane/metadata/secondary_second_sub_category.txt b/fastlane/metadata/secondary_second_sub_category.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/fastlane/metadata/secondary_second_sub_category.txt @@ -0,0 +1 @@ + diff --git a/graph.png b/graph.png index bc93c04..d3d0a3d 100644 Binary files a/graph.png and b/graph.png differ