diff --git a/Plugins/DependencyPackagePlugin/ProjectDescriptionHelpers/DependencyPackage/Extension+TargetDependencySPM.swift b/Plugins/DependencyPackagePlugin/ProjectDescriptionHelpers/DependencyPackage/Extension+TargetDependencySPM.swift index 8132d0b..8d324b9 100644 --- a/Plugins/DependencyPackagePlugin/ProjectDescriptionHelpers/DependencyPackage/Extension+TargetDependencySPM.swift +++ b/Plugins/DependencyPackagePlugin/ProjectDescriptionHelpers/DependencyPackage/Extension+TargetDependencySPM.swift @@ -10,6 +10,7 @@ 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) 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/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json index 2a7279b..a9fd448 100644 --- a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -13,7 +13,7 @@ "value" : "dark" } ], - "filename" : "darklogo.png", + "filename" : "darklogo 2.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/darklogo 1.png b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/darklogo 1.png index c0b0d7d..bd78546 100644 Binary files a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/darklogo 1.png and b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/darklogo 1.png differ diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/darklogo 2.png b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/darklogo 2.png new file mode 100644 index 0000000..bd78546 Binary files /dev/null and b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/darklogo 2.png 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/Sources/Application/AppDelegate.swift b/Projects/App/Sources/Application/AppDelegate.swift index 6e28cf9..36fae9f 100644 --- a/Projects/App/Sources/Application/AppDelegate.swift +++ b/Projects/App/Sources/Application/AppDelegate.swift @@ -8,6 +8,7 @@ import UIKit import WeaveDI import Home +import Kingfisher class AppDelegate: UIResponder, UIApplicationDelegate { @@ -20,6 +21,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { await AppDIManager.shared.registerDefaultDependencies() } + // Kingfisher 캐시 최적화 설정 + configureImageCaching() + // 네이버맵 초기화 (Home 모듈의 NaverMapInitializer 사용) NaverMapInitializer.initialize() @@ -39,4 +43,41 @@ 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 + } } 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..5089d1c 100644 --- a/Projects/App/Sources/Di/KeychainTokenProvider.swift +++ b/Projects/App/Sources/Di/KeychainTokenProvider.swift @@ -6,11 +6,16 @@ // import Foundation +import Security import DomainInterface import Foundations final class KeychainTokenProvider: TokenProviding, @unchecked Sendable { + private enum Constants { + static let cachedAccessTokenKey = "cached_access_token" + } + private let keychainManager: KeychainManagingInterface init(keychainManager: KeychainManagingInterface) { @@ -23,10 +28,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,6 +56,7 @@ final class KeychainTokenProvider: TokenProviding, @unchecked Sendable { func saveAccessToken(_ token: String) { // 캐시 업데이트 TokenCache.shared.token = token + UserDefaults.standard.set(token, forKey: Constants.cachedAccessTokenKey) // 백그라운드에서 비동기적으로 저장 Task { @@ -45,9 +66,28 @@ final class KeychainTokenProvider: TokenProviding, @unchecked Sendable { print("Failed to save access token: \(error)") // 저장 실패 시 캐시도 초기화 TokenCache.shared.token = nil + UserDefaults.standard.removeObject(forKey: Constants.cachedAccessTokenKey) } } } + + 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/Data/API/Sources/API/Place/PlaceAPI.swift b/Projects/Data/API/Sources/API/Place/PlaceAPI.swift new file mode 100644 index 0000000..a0f54aa --- /dev/null +++ b/Projects/Data/API/Sources/API/Place/PlaceAPI.swift @@ -0,0 +1,25 @@ +// +// PlaceAPI.swift +// API +// +// Created by Wonji Suh on 3/27/26. +// + +import Foundation + +public enum PlaceAPI: String, CaseIterable { + case fetchPlace + case searchPlace + case detailPlace + + public var description: String { + switch self { + case .fetchPlace: + return "" + case .searchPlace: + return "/search" + case .detailPlace: + return "/detail" + } + } +} 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/Place/DTO/PlaceDTOModel.swift b/Projects/Data/Model/Sources/Place/DTO/PlaceDTOModel.swift new file mode 100644 index 0000000..06b29e6 --- /dev/null +++ b/Projects/Data/Model/Sources/Place/DTO/PlaceDTOModel.swift @@ -0,0 +1,116 @@ +// +// 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: Int + public let category: String + public let lat: Double + public let lon: Double + public let name: String? + public let address: String? + public let imageURL: String? + public let stayableMinutes: Int? + public let isOpen: Bool? + public let closingTime: String? + + enum CodingKeys: String, CodingKey { + case placeID = "placeId" + case category, lat, lon, name, address, imageURL = "imageUrl", stayableMinutes, isOpen, closingTime + } +} + +public struct PlaceSearchPageResponseDTO: Decodable, Equatable { + public let content: [PlaceResponseDTOModel] + public let number: Int + public let size: Int + public let hasNext: Bool + + enum CodingKeys: String, CodingKey { + case content, number, size, hasNext + } + + 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 + } +} + +public struct PlacePageableResponseDTO: Decodable, Equatable { + public let unpaged: Bool + public let paged: Bool + public let pageNumber: Int + public let pageSize: Int + public let offset: Int + public let sort: PlaceSortResponseDTO + + enum CodingKeys: String, CodingKey { + case unpaged, paged, pageNumber, pageSize, offset, sort + } + + public init( + unpaged: Bool = false, + paged: Bool = true, + pageNumber: Int = 0, + pageSize: Int = 10, + offset: Int = 0, + sort: PlaceSortResponseDTO = .init() + ) { + self.unpaged = unpaged + self.paged = paged + self.pageNumber = pageNumber + self.pageSize = pageSize + self.offset = offset + self.sort = sort + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.unpaged = try container.decodeIfPresent(Bool.self, forKey: .unpaged) ?? false + self.paged = try container.decodeIfPresent(Bool.self, forKey: .paged) ?? true + self.pageNumber = try container.decodeIfPresent(Int.self, forKey: .pageNumber) ?? 0 + self.pageSize = try container.decodeIfPresent(Int.self, forKey: .pageSize) ?? 10 + self.offset = try container.decodeIfPresent(Int.self, forKey: .offset) ?? 0 + self.sort = try container.decodeIfPresent(PlaceSortResponseDTO.self, forKey: .sort) ?? .init() + } +} + +public struct PlaceSortResponseDTO: Decodable, Equatable { + public let unsorted: Bool + public let sorted: Bool + public let empty: Bool + + public init( + unsorted: Bool = true, + sorted: Bool = false, + empty: Bool = true + ) { + self.unsorted = unsorted + self.sorted = sorted + self.empty = empty + } + + enum CodingKeys: String, CodingKey { + case unsorted, sorted, empty + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.unsorted = try container.decodeIfPresent(Bool.self, forKey: .unsorted) ?? true + self.sorted = try container.decodeIfPresent(Bool.self, forKey: .sorted) ?? false + self.empty = try container.decodeIfPresent(Bool.self, forKey: .empty) ?? 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..df09bc6 --- /dev/null +++ b/Projects/Data/Model/Sources/Place/DTO/PlaceDetailDTOModel.swift @@ -0,0 +1,28 @@ +// +// 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 name, category, address: String + let distanceToStation, timeToStation, stayableMinutes: Int + let stationLat, stationLon: Double + let leaveTime: String + let imageURL: [String] + let weekday, weekend: [String] + let phoneNumber: String + + enum CodingKeys: String, CodingKey { + case name, category, address, distanceToStation, timeToStation, stayableMinutes, stationLat, stationLon, leaveTime + case imageURL = "imageUrl" + case weekday, weekend, phoneNumber + } +} + 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..e5ec11d --- /dev/null +++ b/Projects/Data/Model/Sources/Place/Mapper/PlaceDTOModel+.swift @@ -0,0 +1,101 @@ +// +// PlaceDTOModel+.swift +// Model +// +// Created by Wonji Suh on 3/27/26. +// + + +import Foundation + +import Entity + +public extension PlaceResponseDTOModel { + func toDomain() -> PlaceEntity { + return PlaceEntity( + placeId: placeID, + name: name ?? "", + category: mapCategory(category), + lat: lat, + lon: lon, + address: address ?? "", + imageURL: imageURL, + stayableMinutes: stayableMinutes ?? 0, + isOpen: isOpen ?? false, + closingTime: closingTime + ) + } + + func toDomainForFetchPlaces() -> PlaceEntity { + return PlaceEntity( + placeId: placeID, + name: name ?? "", + category: mapCategory(category), + lat: lon, + lon: lat, + address: address ?? "", + imageURL: imageURL, + stayableMinutes: stayableMinutes ?? 0, + isOpen: isOpen ?? false, + closingTime: closingTime + ) + } + + private func mapCategory(_ value: String) -> ExploreCategory { + switch value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "카페", "cafe": + return .cafe + case "음식점", "restaurant": + return .restaurant + case "액티비티", "activity": + return .activity + case "쇼핑", "shopping", "etc", "기타": + return .etc + default: + return .etc + } + } +} + +public extension PlaceSearchPageResponseDTO { + func toDomain() -> PlaceSearchPageEntity { + PlaceSearchPageEntity( + pageable: PlacePageableResponseDTO( + pageNumber: number, + pageSize: size, + offset: number * size + ).toDomain(), + isLastPage: !hasNext, + numberOfElements: content.count, + isFirstPage: number == 0, + size: size, + content: content.map { $0.toDomain() }, + page: number, + sort: PlaceSortResponseDTO().toDomain(), + isEmpty: content.isEmpty + ) + } +} + +public extension PlacePageableResponseDTO { + func toDomain() -> PlacePageableEntity { + PlacePageableEntity( + isUnpaged: unpaged, + isPaged: paged, + pageNumber: pageNumber, + pageSize: pageSize, + offset: offset, + sort: sort.toDomain() + ) + } +} + +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..1c3afff --- /dev/null +++ b/Projects/Data/Model/Sources/Place/Mapper/PlaceDetailDTOModel+.swift @@ -0,0 +1,30 @@ +// +// PlaceDetailDTOModel+.swift +// Model +// +// Created by Wonji Suh on 3/29/26. +// + +import Foundation + +import Entity + +public extension PlaceDetailDTOResponseModel { + func toDomain() -> PlaceDetailEntity { + PlaceDetailEntity( + name: name, + category: category, + address: address, + distanceToStation: distanceToStation, + timeToStation: timeToStation, + stayableMinutes: stayableMinutes, + stationLat: stationLat, + stationLon: stationLon, + leaveTime: leaveTime, + imageURL: imageURL, + weekday: weekday, + weekend: weekend, + phoneNumber: phoneNumber + ) + } +} 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/OAuth/Auth/Repository/AuthRepositoryImpl.swift b/Projects/Data/Repository/Sources/OAuth/Auth/Repository/AuthRepositoryImpl.swift index dac6680..00ad950 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 } diff --git a/Projects/Data/Repository/Sources/Place/PlaceRepositoryImpl.swift b/Projects/Data/Repository/Sources/Place/PlaceRepositoryImpl.swift new file mode 100644 index 0000000..26e82d8 --- /dev/null +++ b/Projects/Data/Repository/Sources/Place/PlaceRepositoryImpl.swift @@ -0,0 +1,79 @@ +// +// 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.authorized, + ) { + self.provider = provider + } + + // MARK: - 장소 관련 api + public func fetchPlaces( + _ input: PlaceInput + ) async throws -> [PlaceEntity] { + let body: PlaceRequest = .init( + userLat: input.userLat, + userLon: input.userLon, + mapLat: input.mapLat, + mapLon: input.mapLon, + stationId: input.stationId, + remainingMinutes: input.remainingMinutes + ) + let dto: PlaceDTOModel = try await provider.request(.fetchPlaces(body: body)) + + return dto.data.map { $0.toDomainForFetchPlaces() } + } + + public func searchPlaces( + _ input: PlaceSearchInput + ) async throws -> PlaceSearchPageEntity { + let body: PlaceSearchRequest = .init( + userLat: input.userLat, + userLon: input.userLon, + stationId: input.stationId, + remainingMinutes: input.remainingMinutes, + keyword: input.keyword, + category: input.category, + sortBy: input.sortBy, + mapLat: input.mapLat, + mapLon: input.mapLon, + page: input.page, + size: input.size, + sort: ["MAP_NEAREST"] + ) + let dto: PlaceSearchDTOModel = try await provider.request(.searchPlaces(body: body)) + return dto.data.toDomain() + } + + public func detailPlaces( + _ input: PlaceDetailInput + ) async throws -> PlaceDetailEntity { + let body: PlaceDetailRequest = .init( + placeId: input.placeId, + stationId: input.stationId, + userLat: input.userLat, + userLon: input.userLon, + remainingMinutes: input.remainingMinutes + ) + + let dto: PlaceDetailDTOModel = try await provider.request(.detailPlaces(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..74c9e26 100644 --- a/Projects/Data/Repository/Sources/Station/StationRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/Station/StationRepositoryImpl.swift @@ -12,6 +12,7 @@ import Entity import Service import AsyncMoya +import Foundation public final class StationRepositoryImpl: StationInterface, @unchecked Sendable { private let authorizedProvider: MoyaProvider @@ -26,14 +27,14 @@ public final class StationRepositoryImpl: StationInterface, @unchecked Sendable } 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 +47,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/Place/PlaceRequest.swift b/Projects/Data/Service/Sources/Place/PlaceRequest.swift new file mode 100644 index 0000000..e6c5050 --- /dev/null +++ b/Projects/Data/Service/Sources/Place/PlaceRequest.swift @@ -0,0 +1,112 @@ +// +// PlaceRequest.swift +// Service +// +// Created by Wonji Suh on 3/27/26. +// + + +import Foundation + +public struct PlaceRequest: Encodable { + 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 + } +} + +public struct PlaceSearchRequest: Encodable { + 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 sortBy: 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, + sortBy: String = "STATION_NEAREST", + mapLat: Double? = nil, + mapLon: Double? = nil, + page: Int = 0, + size: Int = 200, + sort: [String] = ["MAP_NEAREST"] + ) { + self.userLat = userLat + self.userLon = userLon + self.stationId = stationId + self.remainingMinutes = remainingMinutes + self.keyword = keyword + self.category = category + self.sortBy = sortBy + self.mapLat = mapLat + self.mapLon = mapLon + self.page = page + self.size = size + self.sort = sort + } +} + +public struct PageableRequest: Encodable, Equatable { + public let page: Int + public let size: Int + + public init( + page: Int = 0, + size: Int = 200 + ) { + self.page = page + self.size = size + } +} + +public struct PlaceDetailRequest: Encodable { + let placeId: Int + let stationId: Int + let userLat: Double + let userLon: Double + 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/Data/Service/Sources/Place/PlaceService.swift b/Projects/Data/Service/Sources/Place/PlaceService.swift new file mode 100644 index 0000000..28007c1 --- /dev/null +++ b/Projects/Data/Service/Sources/Place/PlaceService.swift @@ -0,0 +1,65 @@ +// +// PlaceService.swift +// Service +// +// Created by Wonji Suh on 3/27/26. +// + +import Foundation + +import API +import Foundations + +import AsyncMoya + +public enum PlaceService { + case fetchPlaces(body: PlaceRequest) + case searchPlaces(body: PlaceSearchRequest) + case detailPlaces(body: PlaceDetailRequest) +} + + +extension PlaceService: BaseTargetType { + public typealias Domain = TimeSpotDomain + + public var domain: TimeSpotDomain { + return .place + } + + public var urlPath: String { + switch self { + case .fetchPlaces: + return PlaceAPI.fetchPlace.description + case .searchPlaces: + return PlaceAPI.searchPlace.description + case .detailPlaces: + return PlaceAPI.detailPlace.description + } + } + + public var error: [Int : NetworkError]? { + return nil + } + + public var method: Moya.Method { + switch self { + case .fetchPlaces, .searchPlaces, .detailPlaces: + return .get + } + } + + public var parameters: [String : Any]? { + switch self { + case .fetchPlaces(let body): + return body.toDictionary + case .searchPlaces(let body): + return body.toDictionary + case .detailPlaces(let body): + return body.toDictionary + } + } + + public var headers: [String : String]? { + return APIHeader.baseHeader + } +} 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..32e47a1 100644 --- a/Projects/Data/Service/Sources/Station/StationRequest.swift +++ b/Projects/Data/Service/Sources/Station/StationRequest.swift @@ -8,21 +8,21 @@ 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 init( - lat: Double, - lng: Double, + userLat: Double, + userLon: Double, page: Int = 1, size: Int = 10, sort: String = "stationName,ASC" ) { - 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 diff --git a/Projects/Data/Service/Sources/Station/StationService.swift b/Projects/Data/Service/Sources/Station/StationService.swift index 2ff5bb2..37e7889 100644 --- a/Projects/Data/Service/Sources/Station/StationService.swift +++ b/Projects/Data/Service/Sources/Station/StationService.swift @@ -15,7 +15,7 @@ import AsyncMoya public enum StationService { case allStation(body: StationRequest) case addFavoriteStation(body: AddFavoriteStationRequest) - case deleteFavoriteStation(deleteStationId: Int) + case deleteFavoriteStation(favoriteID: Int) } @@ -30,10 +30,10 @@ extension StationService: BaseTargetType { switch self { case .allStation: 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 } } @@ -66,7 +66,7 @@ extension StationService: BaseTargetType { public var headers: [String : String]? { switch self { case .allStation: - return APIHeader.notAccessTokenHeader + return APIHeader.baseHeader case .addFavoriteStation, .deleteFavoriteStation: return APIHeader.baseHeader } diff --git a/Projects/Presentation/Home/Sources/Manager/LocationPermissionManager.swift b/Projects/Domain/DomainInterface/Sources/Location/LocationPermissionManager.swift similarity index 59% rename from Projects/Presentation/Home/Sources/Manager/LocationPermissionManager.swift rename to Projects/Domain/DomainInterface/Sources/Location/LocationPermissionManager.swift index 8ec7a66..1dfb652 100644 --- a/Projects/Presentation/Home/Sources/Manager/LocationPermissionManager.swift +++ b/Projects/Domain/DomainInterface/Sources/Location/LocationPermissionManager.swift @@ -1,23 +1,21 @@ // // LocationPermissionManager.swift -// Home +// DomainInterface // -// Created by Roy on 2026-03-11 -// Copyright © 2026 TimeSpot, Ltd., All rights reserved. +// Created by Wonji Suh on 3/28/26. // import Foundation import CoreLocation - -#if canImport(UIKit) import UIKit -#endif // MARK: - Location Errors -public enum LocationError: Error, LocalizedError { +public enum LocationError: Error, LocalizedError, Equatable { case permissionDenied case locationUnavailable case timeout + case networkError + case unknown(String) public var errorDescription: String? { switch self { @@ -27,13 +25,36 @@ public enum LocationError: Error, LocalizedError { return "위치 정보를 가져올 수 없습니다." case .timeout: return "위치 요청 시간이 초과되었습니다." + case .networkError: + return "네트워크 오류가 발생했습니다." + case .unknown(let message): + return message } } } -// Swift Concurrency를 사용한 위치 권한 전용 관리자 +// MARK: - LocationPermissionManager Protocol +public protocol LocationPermissionManagerProtocol: Sendable { + var authorizationStatus: CLAuthorizationStatus { get async } + var currentLocation: CLLocation? { get async } + var locationError: String? { get async } + + 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 { +public final class LocationPermissionManager: NSObject, ObservableObject, LocationPermissionManagerProtocol { // 싱글톤 인스턴스 public static let shared = LocationPermissionManager() @@ -44,12 +65,13 @@ public final class LocationPermissionManager: NSObject, ObservableObject { private let locationManager = CLLocationManager() private var authorizationContinuation: CheckedContinuation? private var locationContinuation: CheckedContinuation? + private var locationTimeoutTask: Task? // 지속적인 위치 업데이트 콜백 (MainActor 격리) @MainActor - public var onLocationUpdate: (@MainActor (CLLocation) -> Void)? + public var onLocationUpdate: ((CLLocation) -> Void)? @MainActor - public var onLocationError: (@MainActor (Error) -> Void)? + public var onLocationError: ((Error) -> Void)? public override init() { super.init() @@ -63,6 +85,16 @@ public final class LocationPermissionManager: NSObject, ObservableObject { authorizationStatus = locationManager.authorizationStatus } + // 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 { @@ -93,36 +125,76 @@ public final class LocationPermissionManager: NSObject, ObservableObject { } // iOS 14+ 정확한 위치 권한 요청 - public func requestFullAccuracy() { + public func requestFullAccuracy() async { if #available(iOS 14.0, *) { - locationManager.requestTemporaryFullAccuracyAuthorization(withPurposeKey: "TimeSpotLocationAccuracy") + do { + try await locationManager.requestTemporaryFullAccuracyAuthorization( + withPurposeKey: "TimeSpotLocationAccuracy" + ) + } catch { + locationError = "정확한 위치 권한 요청에 실패했습니다." + } } } // 위치 업데이트 시작 - public func startLocationUpdates() { + 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() { + 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(5)) + guard !Task.isCancelled else { return } + self.resumeLocationContinuation(with: .failure(LocationError.timeout)) + } if #available(iOS 14.0, *) { locationManager.requestLocation() @@ -130,25 +202,13 @@ public final class LocationPermissionManager: NSObject, ObservableObject { // iOS 14 이전에서는 잠시 업데이트하고 중지 locationManager.startUpdatingLocation() Task { - try await Task.sleep(for: .seconds(3)) - self.stopLocationUpdates() + try? await Task.sleep(for: .seconds(3)) + await 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 { @@ -156,6 +216,15 @@ public final class LocationPermissionManager: NSObject, ObservableObject { }.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 { @@ -173,6 +242,21 @@ public final class LocationPermissionManager: NSObject, ObservableObject { 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 @@ -186,13 +270,10 @@ extension LocationPermissionManager: CLLocationManagerDelegate { self.locationError = nil // 지속적인 위치 업데이트 콜백 호출 - await self.onLocationUpdate?(location) + self.onLocationUpdate?(location) // continuation이 있으면 결과 반환 (일회성 요청용) - if let continuation = self.locationContinuation { - self.locationContinuation = nil - continuation.resume(returning: location) - } + self.resumeLocationContinuation(with: .success(location)) } } @@ -201,13 +282,10 @@ extension LocationPermissionManager: CLLocationManagerDelegate { self.locationError = "위치 업데이트 실패: \(error.localizedDescription)" // 지속적인 위치 업데이트 에러 콜백 호출 - await self.onLocationError?(error) + self.onLocationError?(error) // continuation이 있으면 에러 반환 (일회성 요청용) - if let continuation = self.locationContinuation { - self.locationContinuation = nil - continuation.resume(throwing: error) - } + self.resumeLocationContinuation(with: .failure(error)) } } diff --git a/Projects/Domain/DomainInterface/Sources/Place/DefaultPlaceRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Place/DefaultPlaceRepositoryImpl.swift new file mode 100644 index 0000000..f07fd4d --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Place/DefaultPlaceRepositoryImpl.swift @@ -0,0 +1,37 @@ +// +// 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: PlaceInput) async throws -> [PlaceEntity] { + throw NSError( + domain: "PlaceRepository", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "PlaceRepository is not configured."] + ) + } + + public func searchPlaces(_ 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..e7db85c --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Place/PlaceInterface.swift @@ -0,0 +1,39 @@ +// +// PlaceInterface.swift +// DomainInterface +// +// Created by Wonji Suh on 3/27/26. +// + +import Entity + +import WeaveDI +import ComposableArchitecture + +public protocol PlaceInterface: Sendable { + func fetchPlaces(_ input: PlaceInput) async throws -> [PlaceEntity] + func searchPlaces(_ 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..6d20d7b --- /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 { + 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/Explore/ExploreMapSpot.swift b/Projects/Domain/Entity/Sources/Explore/ExploreMapSpot.swift new file mode 100644 index 0000000..e50f88d --- /dev/null +++ b/Projects/Domain/Entity/Sources/Explore/ExploreMapSpot.swift @@ -0,0 +1,109 @@ +// +// 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 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 + ) { + 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 + } +} + +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 + } +} + +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) + } +} + +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/OAuth/UserSession.swift b/Projects/Domain/Entity/Sources/OAuth/UserSession.swift index 7bf4351..2c86ed8 100644 --- a/Projects/Domain/Entity/Sources/OAuth/UserSession.swift +++ b/Projects/Domain/Entity/Sources/OAuth/UserSession.swift @@ -15,6 +15,12 @@ public struct UserSession: Equatable, Hashable { public var mapType: ExternalMapType public var travelID: String public var travelStationName: String + public var travelStationLat: Double? + public var travelStationLng: Double? + public var remainingMinutes: Int + public var selectedExploreSpotID: String + public var selectedExplorePlaceID: String + public var explorePlacesFetchedAt: Date? public init( name: String = "", @@ -23,7 +29,13 @@ public struct UserSession: Equatable, Hashable { authCode: String = "", mapType: ExternalMapType = .appleMap, travelID: String = "", - travelStationName: String = "" + travelStationName: String = "", + travelStationLat: Double? = nil, + travelStationLng: Double? = nil, + remainingMinutes: Int = 0, + selectedExploreSpotID: String = "", + selectedExplorePlaceID: String = "", + explorePlacesFetchedAt: Date? = nil ) { self.name = name self.email = email @@ -32,6 +44,12 @@ public struct UserSession: Equatable, Hashable { self.mapType = mapType self.travelID = travelID self.travelStationName = travelStationName + self.travelStationLat = travelStationLat + self.travelStationLng = travelStationLng + self.remainingMinutes = remainingMinutes + self.selectedExploreSpotID = selectedExploreSpotID + self.selectedExplorePlaceID = selectedExplorePlaceID + self.explorePlacesFetchedAt = explorePlacesFetchedAt } } diff --git a/Projects/Domain/Entity/Sources/Place/PlaceDetailEntity.swift b/Projects/Domain/Entity/Sources/Place/PlaceDetailEntity.swift new file mode 100644 index 0000000..3f8dc42 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Place/PlaceDetailEntity.swift @@ -0,0 +1,54 @@ +// +// PlaceDetailEntity.swift +// Entity +// +// Created by Wonji Suh on 3/28/26. +// + +import Foundation + +public struct PlaceDetailEntity: Equatable, Hashable { + public let name: String + public let category: String + public let address: String + public let distanceToStation: Int + public let timeToStation: Int + public let stayableMinutes: Int + public let stationLat: Double + public let stationLon: Double + public let leaveTime: String + public let imageURL: [String] + public let weekday: [String] + public let weekend: [String] + public let phoneNumber: String + + public init( + name: String, + category: String, + address: String, + distanceToStation: Int, + timeToStation: Int, + stayableMinutes: Int, + stationLat: Double, + stationLon: Double, + leaveTime: String, + imageURL: [String], + weekday: [String], + weekend: [String], + phoneNumber: String + ) { + self.name = name + self.category = category + self.address = address + self.distanceToStation = distanceToStation + self.timeToStation = timeToStation + self.stayableMinutes = stayableMinutes + self.stationLat = stationLat + self.stationLon = stationLon + self.leaveTime = leaveTime + self.imageURL = imageURL + self.weekday = weekday + self.weekend = weekend + self.phoneNumber = phoneNumber + } +} 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..3e0f6ab --- /dev/null +++ b/Projects/Domain/Entity/Sources/Place/PlaceEntity.swift @@ -0,0 +1,119 @@ +// +// 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 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 + ) { + 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 + } +} + +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..e177a45 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Place/PlaceSearchInput.swift @@ -0,0 +1,52 @@ +// +// 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 sortBy: 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, + sortBy: String = "STATION_NEAREST", + mapLat: Double? = nil, + mapLon: Double? = nil, + page: Int = 0, + size: Int = 200, + sort: [String] = ["MAP_NEAREST"] + ) { + self.userLat = userLat + self.userLon = userLon + self.stationId = stationId + self.remainingMinutes = remainingMinutes + self.keyword = keyword + self.category = category + self.sortBy = sortBy + 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/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/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/Location/LocationUseCase.swift b/Projects/Domain/UseCase/Sources/Location/LocationUseCase.swift new file mode 100644 index 0000000..b765a77 --- /dev/null +++ b/Projects/Domain/UseCase/Sources/Location/LocationUseCase.swift @@ -0,0 +1,98 @@ +// +// 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 { + let locationManager = LocationPermissionManager.shared + return await locationManager.authorizationStatus + } + + public func requestLocationPermission() async -> CLAuthorizationStatus { + let locationManager = LocationPermissionManager.shared + return await locationManager.requestLocationPermission() + } + + public func requestFullAccuracy() async { + let locationManager = LocationPermissionManager.shared + await locationManager.requestFullAccuracy() + } + + public func startLocationUpdates( + onUpdate: @escaping @Sendable (CLLocation) -> Void, + onError: @escaping @Sendable (Error) -> Void + ) async { + let locationManager = LocationPermissionManager.shared + await locationManager.setLocationUpdateCallback(onUpdate) + await locationManager.setLocationErrorCallback(onError) + await locationManager.startLocationUpdates() + } + + public func stopLocationUpdates() async { + let locationManager = LocationPermissionManager.shared + await locationManager.stopLocationUpdates() + } + + public func requestCurrentLocation() async throws -> CLLocation? { + let locationManager = LocationPermissionManager.shared + return try await locationManager.requestCurrentLocation() + } + + public func isLocationServicesEnabled() async -> Bool { + let locationManager = LocationPermissionManager.shared + return await locationManager.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() +} \ No newline at end of file diff --git a/Projects/Domain/UseCase/Sources/Place/PlaceUseCaseImpl.swift b/Projects/Domain/UseCase/Sources/Place/PlaceUseCaseImpl.swift new file mode 100644 index 0000000..8c1b2da --- /dev/null +++ b/Projects/Domain/UseCase/Sources/Place/PlaceUseCaseImpl.swift @@ -0,0 +1,361 @@ +// +// 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 + ) async throws -> [PlaceEntity] + + func fetchInitialExploreSpots( + userSession: UserSession, + userLat: Double, + userLon: Double + ) async throws -> ExploreSpotPageEntity + + func searchPlaces( + userSession: UserSession, + userLat: Double, + userLon: Double, + keyword: String?, + category: ExploreCategory?, + sortBy: String, + mapLat: Double?, + mapLon: Double?, + page: Int + ) async throws -> PlaceSearchPageEntity + + func searchExploreSpots( + baseSpots: [ExploreMapSpot], + userSession: UserSession, + userLat: Double, + userLon: Double, + keyword: String?, + category: ExploreCategory?, + sortBy: 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 + ) async throws -> [PlaceEntity] { + let input = PlaceInput( + userLat: userLat, + userLon: userLon, + mapLat: userSession.travelStationLat ?? 0, + mapLon: userSession.travelStationLng ?? 0, + stationId: Int(userSession.travelID) ?? 0, + remainingMinutes: 250 + ) + + return try await repository.fetchPlaces(input) + } + + public func fetchInitialExploreSpots( + userSession: UserSession, + userLat: Double, + userLon: Double + ) async throws -> ExploreSpotPageEntity { + // 🚀 단순화: searchPlaces API 하나만 사용 + let searchInput = PlaceSearchInput( + userLat: userLat, + userLon: userLon, + stationId: Int(userSession.travelID) ?? 0, + remainingMinutes: 250, + keyword: nil, + category: nil, + sortBy: "STATION_NEAREST", + mapLat: userSession.travelStationLat, + mapLon: userSession.travelStationLng, + page: 0, + size: 200, + sort: ["MAP_NEAREST"] + ) + + let pageEntity = try await repository.searchPlaces(searchInput) + + #logDebug("🚀 [초기로딩] searchPlaces 응답: \(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 + 1, + hasNextPage: !pageEntity.isLastPage + ) + } + + public func searchPlaces( + userSession: UserSession, + userLat: Double, + userLon: Double, + keyword: String?, + category: ExploreCategory?, + sortBy: String = "STATION_NEAREST", + mapLat: Double?, + mapLon: Double?, + page: Int + ) async throws -> PlaceSearchPageEntity { + let input = PlaceSearchInput( + userLat: userLat, + userLon: userLon, + stationId: Int(userSession.travelID) ?? 0, + remainingMinutes: 250, + keyword: keyword, + category: mapCategory(category), + sortBy: sortBy, + mapLat: mapLat, + mapLon: mapLon, + page: page, + size: 200, + sort: ["MAP_NEAREST"] + ) + + return try await repository.searchPlaces(input) + } + + public func searchExploreSpots( + baseSpots: [ExploreMapSpot], + userSession: UserSession, + userLat: Double, + userLon: Double, + keyword: String?, + category: ExploreCategory?, + sortBy: String = "STATION_NEAREST", + mapLat: Double?, + mapLon: Double?, + page: Int + ) async throws -> ExploreSpotPageEntity { + // 🔍 단순화: searchPlaces API 하나만 사용 + let searchInput = PlaceSearchInput( + userLat: userLat, + userLon: userLon, + stationId: Int(userSession.travelID) ?? 0, + remainingMinutes: 250, + keyword: keyword, + category: mapCategory(category), + sortBy: sortBy, + mapLat: mapLat, + mapLon: mapLon, + page: page, + size: 30 // 더 많은 데이터 로딩 + ) + + #logDebug("🔍 [API요청] searchExploreSpots - page: \(page), size: 30") + + let pageEntity = try await repository.searchPlaces(searchInput) + + #logDebug("🔍 [API응답] searchExploreSpots - 응답 size: \(pageEntity.content.count), hasNext: \(!pageEntity.isLastPage)") + + #logDebug("🔍 [필터링] searchPlaces 응답: \(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 + 1, + 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(.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: "", + subtitle: entity.category.title, + statusText: "", + closingText: "", + distanceText: "", + walkTimeText: "", + address: entity.address + ) + } + + 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 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) + let walkingMinutes = max(Int(ceil(distanceInMeters / 67)), 1) + + distanceText = "\(roundedDistance)m" + walkTimeText = "\(stationName)역에서 약 \(walkingMinutes)분" + } else { + distanceText = "" + 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.isOpen ? "영업 중" : "영업 종료", + closingText: closingText, + distanceText: distanceText, + walkTimeText: walkTimeText, + address: entity.address + ) + } + + 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/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..7ec2d4d 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 { @@ -95,6 +96,10 @@ 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 + default: return .none } @@ -160,6 +165,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 99% rename from Projects/Presentation/Auth/Sources/Reducer/LoginFeature.swift rename to Projects/Presentation/Auth/Sources/Main/Reducer/LoginFeature.swift index 0b77221..9955cbd 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() {} diff --git a/Projects/Presentation/Auth/Sources/View/LoginView.swift b/Projects/Presentation/Auth/Sources/Main/View/LoginView.swift similarity index 86% rename from Projects/Presentation/Auth/Sources/View/LoginView.swift rename to Projects/Presentation/Auth/Sources/Main/View/LoginView.swift index 840fd66..9f12138 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))) } } 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..c0caca9 100644 --- a/Projects/Presentation/Home/Sources/Components/LocationPermissionOverlay.swift +++ b/Projects/Presentation/Home/Sources/Components/LocationPermissionOverlay.swift @@ -78,7 +78,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..5752533 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,23 @@ public struct NaverMapComponent: UIViewRepresentable { let currentLocation: CLLocation? let routeInfo: RouteInfo? let destination: Destination? - let returnToLocation: Bool // 현재 위치로 돌아가기 트리거 + let spots: [ExploreMapSpot] + let selectedSpotID: String? + let returnToLocationTrigger: 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 routePath: NMFPath? public init( @@ -30,17 +44,32 @@ public struct NaverMapComponent: UIViewRepresentable { currentLocation: CLLocation?, routeInfo: RouteInfo? = nil, destination: Destination? = nil, - returnToLocation: Bool = false + spots: [ExploreMapSpot] = [], + selectedSpotID: String? = nil, + returnToLocationTrigger: 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.spots = spots + self.selectedSpotID = selectedSpotID + self.returnToLocationTrigger = returnToLocationTrigger + 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 @@ -55,10 +84,16 @@ public struct NaverMapComponent: UIViewRepresentable { // 🎯 네이버 지도 위치 오버레이 설정 (항상 기본 오버레이 사용) mapView.locationOverlay.hidden = false + mapView.touchDelegate = context.coordinator + mapView.addCameraDelegate(delegate: context.coordinator) - // 현재 위치가 있으면 그 위치로, 없으면 서울로 초기 설정 - let initialLatitude = currentLocation?.coordinate.latitude ?? 37.5666805 - let initialLongitude = currentLocation?.coordinate.longitude ?? 126.9784147 + // 현재 위치가 있으면 그 위치로, 없으면 선택한 역 위치, 그것도 없으면 서울 + let initialLatitude = currentLocation?.coordinate.latitude + ?? destination?.coordinate.latitude + ?? 37.5666805 + let initialLongitude = currentLocation?.coordinate.longitude + ?? destination?.coordinate.longitude + ?? 126.9784147 let cameraPosition = NMFCameraPosition( NMGLatLng(lat: initialLatitude, lng: initialLongitude), @@ -70,72 +105,182 @@ 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) Self.currentMarker?.mapView = nil Self.destinationMarker?.mapView = nil Self.routePath?.mapView = nil + Self.spotMarkers.values.forEach { $0.mapView = nil } + Self.spotMarkers.removeAll() + Self.currentMarker = nil + Self.destinationMarker = nil + Self.selectedSpotID = nil + Self.lastSyncedSpotID = nil + Self.lastDestinationKey = nil + Self.lastReturnToLocationTrigger = nil + Self.lastAutoFitKey = nil + Self.routePath = nil + } + + public func updateUIView(_ uiView: NMFMapView, context: Context) { + context.coordinator.parent = self + let shouldReturnToLocation = + currentLocation != nil + && Self.lastReturnToLocationTrigger != returnToLocationTrigger + 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), + if shouldReturnToLocation { + Self.lastReturnToLocationTrigger = returnToLocationTrigger + Self.lastAutoFitKey = autoFitKey + + let target = NMGLatLng( + lat: location.coordinate.latitude, + lng: location.coordinate.longitude + ) + moveCamera( + on: uiView, + to: target, zoom: 16 ) - let cameraUpdate = NMFCameraUpdate(position: cameraPosition) - cameraUpdate.animationDuration = 0.8 - uiView.moveCamera(cameraUpdate) } // 경로가 있을 때는 출발점에 빨간색 마커도 추가로 표시 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.iconTintColor = UIColor.red + currentMarker.touchHandler = { _ in + Self.setSelectedSpotID(nil) + onMapTapped?() + return true + } + currentMarker.mapView = uiView + Self.currentMarker = currentMarker + } + + Self.currentMarker?.position = NMGLatLng( + lat: location.coordinate.latitude, + lng: location.coordinate.longitude + ) + Self.currentMarker?.mapView = uiView - print("🔴 [NaverMap] 출발점 빨간색 마커 추가 (텍스트 없이)") + #logDebug(" [NaverMapComponent] 출발점 마커 추가") } else { - print("🎯 [NaverMap] 네이버 기본 위치 오버레이만 사용") + Self.currentMarker?.mapView = nil + Self.currentMarker = nil + #logDebug(" [NaverMapComponent] 기본 위치 오버레이 사용") } + } else { + Self.currentMarker?.mapView = nil + Self.currentMarker = nil } // 목적지 마커 추가 (네이버 3D 기본 마커 - 초록색) if let destination = destination { - let destinationMarker = NMFMarker() - destinationMarker.position = NMGLatLng( + let destinationKey = "\(destination.coordinate.latitude),\(destination.coordinate.longitude),\(destination.name)" + if Self.destinationMarker == nil { + let destinationMarker = NMFMarker() + destinationMarker.iconTintColor = UIColor.systemGreen + destinationMarker.touchHandler = { _ in + Self.setSelectedSpotID(nil) + onMapTapped?() + return true + } + destinationMarker.mapView = uiView + Self.destinationMarker = destinationMarker + } + + Self.destinationMarker?.position = NMGLatLng( lat: destination.coordinate.latitude, lng: destination.coordinate.longitude ) + Self.destinationMarker?.mapView = uiView + + #logDebug(" [NaverMapComponent] 목적지 마커 추가: \(destination.name)") + + if !shouldPrioritizeCurrentLocation && 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 + } - // 네이버 기본 마커 (초록색) - destinationMarker.iconTintColor = UIColor.systemGreen - destinationMarker.mapView = uiView - Self.destinationMarker = destinationMarker + let previousSpotID = Self.lastSyncedSpotID + Self.setSelectedSpotID(selectedSpotID) + Self.lastSyncedSpotID = Self.selectedSpotID - print("🗺️ [NaverMap] 목적지 3D 마커 추가 (32x32): \(destination.name)") + if !spots.contains(where: { $0.id == Self.selectedSpotID }) { + Self.setSelectedSpotID(nil) + } + + syncSpotMarkers( + on: uiView, + coordinator: context.coordinator, + onSpotTapped: onSpotTapped + ) + + 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") + #logDebug(" [NaverMapComponent] 경로 정보: 좌표 \(routeInfo.paths.count)개, 거리 \(routeInfo.distance)m") // 경로 좌표들을 NMGLatLng 배열로 변환 let pathCoords = routeInfo.paths.map { coordinate in - print("📍 좌표: \(coordinate.latitude), \(coordinate.longitude)") + #logDebug(" [NaverMapComponent] 경로 좌표: \(coordinate.latitude), \(coordinate.longitude)") return NMGLatLng(lat: coordinate.latitude, lng: coordinate.longitude) } // 좌표가 부족한 경우 체크 guard pathCoords.count >= 2 else { - print("🚨 [NaverMap] 경로 좌표가 부족합니다: \(pathCoords.count)개") + #logDebug(" [NaverMapComponent] 경로 좌표 부족: \(pathCoords.count)개") return } @@ -155,12 +300,172 @@ public struct NaverMapComponent: UIViewRepresentable { // 🎯 경로 전체가 보이도록 카메라 조정 (중앙으로) adjustCameraToFitRoute(mapView: uiView, routeCoords: pathCoords, currentLocation: currentLocation) - print("🔵 [NaverMap] 경로 표시 및 카메라 조정 완료") + #logDebug(" [NaverMapComponent] 경로 표시 및 카메라 조정 완료") + } + } + + 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 cacheKey = NSString(string: "marker-\(category.rawValue)") + if let cachedImage = Self.markerImageCache.object(forKey: cacheKey) { + return cachedImage + } + + let asset: ImageAsset + switch category { + case .all: + asset = .etcPin + case .cafe: + asset = .cafePin + case .restaurant: + asset = .foodPin + case .activity: + asset = .gamePin + case .etc: + asset = .etcPin + @unknown default: + asset = .etcPin + } + + let image = UIImage(asset) ?? UIImage() + Self.markerImageCache.setObject(image, forKey: cacheKey) + return image + } + + private static func applySpotMarkerStyle( + _ marker: NMFMarker, + isSelected: Bool + ) { + marker.width = isSelected ? 36 : 20 + marker.height = isSelected ? 43 : 24 + marker.zIndex = isSelected ? 100 : 10 + } + + 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)) + + for (spotID, marker) in Self.spotMarkers where !currentSpotIDs.contains(spotID) { + marker.mapView = nil + Self.spotMarkers.removeValue(forKey: spotID) + } + + 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) + newMarker.mapView = mapView + Self.spotMarkers[spot.id] = newMarker + marker = newMarker + } + + marker.position = NMGLatLng( + lat: spot.coordinate.latitude, + lng: spot.coordinate.longitude + ) + marker.iconImage = NMFOverlayImage(image: markerImage(for: spot.category)) + marker.mapView = mapView + marker.touchHandler = { _ in + coordinator.markMarkerTap() + Self.setSelectedSpotID(spot.id) + onSpotTapped?(spot.id) + moveCamera( + on: mapView, + to: NMGLatLng( + lat: spot.coordinate.latitude, + lng: spot.coordinate.longitude + ), + zoom: 17 + ) + return true + } + Self.applySpotMarkerStyle(marker, isSelected: spot.id == Self.selectedSpotID) + } + } + + 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) @@ -252,7 +557,56 @@ public struct NaverMapComponent: UIViewRepresentable { cameraUpdate.animationDuration = 1.0 mapView.moveCamera(cameraUpdate) - print("📹 [NaverMap] 경로 전체가 보이도록 카메라 조정 완료") + #logDebug(" [NaverMapComponent] 경로 전체가 보이도록 카메라 조정 완료") + } + + 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 { diff --git a/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift b/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift index 1e84c41..8dd9845 100644 --- a/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift +++ b/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift @@ -8,6 +8,8 @@ import ComposableArchitecture import TCACoordinators import Profile +import CoreLocation +import Entity @Reducer public struct HomeCoordinator { @@ -49,6 +51,8 @@ public struct HomeCoordinator { case presentProfile case presentProfileWithAnimation case presentExplore + case presentExploreList(ExploreFeature.State) + case presentExploreDetail } // MARK: - NavigationAction @@ -98,6 +102,58 @@ 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: .profile(.navigation(.presentRoot))): return .send(.view(.backAction)) @@ -159,7 +215,44 @@ 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 } } @@ -170,7 +263,9 @@ extension HomeCoordinator { @Reducer public enum HomeScreen { case home(HomeFeature) - case explore(ExploreReducer) + case explore(ExploreFeature) + case exploreList(ExploreListFeature) + case exploreDetail(ExploreDetailFeature) case profile(ProfileCoordinator) } } diff --git a/Projects/Presentation/Home/Sources/Coordinator/View/HomeCoordinatorView.swift b/Projects/Presentation/Home/Sources/Coordinator/View/HomeCoordinatorView.swift index ca20df5..b96e251 100644 --- a/Projects/Presentation/Home/Sources/Coordinator/View/HomeCoordinatorView.swift +++ b/Projects/Presentation/Home/Sources/Coordinator/View/HomeCoordinatorView.swift @@ -36,6 +36,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() 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..d18e113 --- /dev/null +++ b/Projects/Presentation/Home/Sources/Explore/Components/ExploreCategoryChipView.swift @@ -0,0 +1,68 @@ +// +// ExploreCategoryChipView.swift +// Home +// + +import SwiftUI + +import DesignSystem +import Entity + +struct ExploreCategoryChipView: View { + let category: ExploreCategory + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 4) { + categoryIcon + + Text(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 .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..81c6504 --- /dev/null +++ b/Projects/Presentation/Home/Sources/Explore/Components/ExploreSearchHeaderView.swift @@ -0,0 +1,135 @@ +// +// ExploreSearchHeaderView.swift +// Home +// + +import SwiftUI + +import DesignSystem +import Entity + +struct ExploreSearchHeaderView: View { + let stationName: String + let searchText: String + let selectedCategory: ExploreCategory + let onBackTap: () -> Void + let onSearchTextChanged: (String) -> Void + let onCategoryTap: (ExploreCategory) -> Void + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 10) { + backButton() + searchBar() + } + + categoryScrollView() + .padding(.top, 10) + } + } + + @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 { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(.gray600) + + ZStack(alignment: .leading) { + if searchText.isEmpty { + Text("\(stationName)역") + .pretendardCustomFont(textStyle: .titleRegular) + .foregroundStyle(.gray600) + } + + TextField( + "", + text: Binding( + get: { searchText }, + set: onSearchTextChanged + ) + ) + .pretendardCustomFont(textStyle: .titleRegular) + .foregroundStyle(.staticBlack) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + } + .padding(.horizontal, 24) + .frame(height: 48) + .background(.staticWhite) + .clipShape(RoundedRectangle(cornerRadius: 18)) + .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 .activity: + targetCategory = .restaurant + 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..cf778b2 --- /dev/null +++ b/Projects/Presentation/Home/Sources/Explore/Components/ExploreSelectedSpotCardView.swift @@ -0,0 +1,232 @@ +// +// 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) + ) + .onAppear { + } + .onChange(of: currentSpot.id) { _ in + } + } + + private func cardContent(for spot: ExploreMapSpot) -> some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 0) { + if !spot.badgeText.isEmpty { + Text(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)) + .fixedSize(horizontal: false, vertical: true) + .layoutPriority(1) + + if !spot.subtitle.isEmpty { + Text(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(spot.statusText) + .pretendardCustomFont(textStyle: .body2Medium) + .foregroundStyle(.gray850) + } + + if !spot.closingText.isEmpty { + Text(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(spot.distanceText) + .pretendardCustomFont(textStyle: .bodyBold) + .foregroundStyle(.gray830) + } + + if !spot.walkTimeText.isEmpty { + Text(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: onRouteTap) { + Text("경로 확인하기") + .pretendardCustomFont(textStyle: .bodyBold) + .foregroundStyle(.staticWhite) + .frame(maxWidth: .infinity) + .frame(height: 55) + .background(.navy900) + .clipShape(RoundedRectangle(cornerRadius: 25)) + } + .buttonStyle(.plain) + .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 { + spot.name.count > 7 ? 2 : 1 + } + + private func titleMinHeight(for spot: ExploreMapSpot) -> CGFloat { + spot.name.count > 7 ? 44 : 24 + } + + private func formattedDisplayName(for spot: ExploreMapSpot) -> String { + let formatted = spot.name.formattedPlaceNameForDisplay + + guard spot.name.count > 7 else { + return formatted + } + + let characters = Array(formatted) + let threshold = min(7, characters.count) + + if let splitIndex = characters.indices.dropFirst(threshold).first(where: { characters[$0] == " " }) { + let left = String(characters[.. 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..2d365d0 --- /dev/null +++ b/Projects/Presentation/Home/Sources/Explore/Components/ExploreSpotListCardView.swift @@ -0,0 +1,197 @@ +// +// 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 { + HStack(alignment: .top, spacing: 14) { + VStack(alignment: .leading, spacing: 0) { + if !spot.badgeText.isEmpty { + Text(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(spot.statusText) + .pretendardCustomFont(textStyle: .body2Medium) + .foregroundStyle(.gray700) + .fixedSize() + } + + if !spot.closingText.isEmpty { + Text(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(spot.distanceText) + .pretendardFont(family: .SemiBold, size: 16) + .foregroundStyle(.staticBlack) + .fixedSize() + } + + if !spot.walkTimeText.isEmpty { + Text(spot.walkTimeText) + .pretendardCustomFont(textStyle: .body2Regular) + .foregroundStyle(.gray830) + .lineLimit(1) + .minimumScaleFactor(0.8) + .frame(maxWidth: .infinity, alignment: .leading) + .truncationMode(.tail) + } + } + + } + .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) + } + .onAppear { + } + .onChange(of: spot.id) { _ in + } + } + + @ViewBuilder + private var titleRow: some View { + HStack(alignment: .top, spacing: 6) { + Text(formattedDisplayName) + .font(.pretendardFontFamily(family: .SemiBold, size: 18)) + .foregroundStyle(.staticBlack) + .lineLimit(titleLineLimit) + .layoutPriority(1) + + if !spot.subtitle.isEmpty { + Text(spot.subtitle) + .font(.pretendardFontFamily(family: .Medium, size: 14)) + .foregroundStyle(.gray700) + .lineLimit(1) + .fixedSize() + .padding(.top, 2) + } + + Spacer(minLength: 0) + } + } + + private var titleLineLimit: Int { + spot.name.count > 7 ? 2 : 1 + } + + private var titleMinHeight: CGFloat { + spot.name.count > 7 ? 48 : 24 + } + + private var formattedDisplayName: String { + let formatted = spot.name.formattedPlaceNameForDisplay + + guard spot.name.count > 7 else { + return formatted + } + + let characters = Array(formatted) + let threshold = min(7, characters.count) + + if let splitIndex = characters.indices.dropFirst(threshold).first(where: { characters[$0] == " " }) { + let left = String(characters[.. 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..4e27dc2 --- /dev/null +++ b/Projects/Presentation/Home/Sources/Explore/Helpers/CardHelpers.swift @@ -0,0 +1,24 @@ +// +// CardHelpers.swift +// Home +// +// Created by Wonji Suh on 3/28/26. +// + +import Foundation +import SwiftUI + +// MARK: - CardHelpers + +public struct CardHelpers { + + // MARK: - Constants + + public static var cardTravelDistance: CGFloat { + UIScreen.main.bounds.width - 8 + } + + public static var cardSwipeThreshold: CGFloat { + (UIScreen.main.bounds.width - 32) / 2 + } +} \ 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..737ef43 --- /dev/null +++ b/Projects/Presentation/Home/Sources/Explore/Helpers/ExploreHelpers.swift @@ -0,0 +1,184 @@ +// +// 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 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 + } + + 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 + } + + public static func hasUnresolvedBaseSpots(_ spots: [ExploreMapSpot]) -> Bool { + return spots.contains { !$0.hasDetail } + } + + // 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..9c3dd34 --- /dev/null +++ b/Projects/Presentation/Home/Sources/Explore/Reducer/ExploreFeature.swift @@ -0,0 +1,983 @@ +// +// 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 searchPlaces + 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 searchText: String = "" + public var isLoadingPlaces: Bool = false + public var hasRequestedPlaces: Bool = false + public var hasFetchedPlacesWithCurrentLocation: Bool = false + public var currentPage: Int = 0 + 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 searchRouteToGangnam + case clearRoute + case returnToCurrentLocation + } + + public enum InnerAction: Equatable { + case locationPermissionStatusChanged(CLAuthorizationStatus) + case locationUpdated(CLLocation) + case locationUpdateFailed(String) + case fetchPlacesResponse(ExploreSpotPageEntity, usedCurrentLocation: Bool) + case fetchPlacesFailed(String, usedCurrentLocation: Bool) + case searchPlacesResponse( + ExploreSpotPageEntity, + append: Bool, + requestedPage: Int, + requestedKeyword: String, + requestedCategory: ExploreCategory?, + requestedMarkerLat: Double?, + requestedMarkerLon: Double?, + usedCurrentLocation: Bool + ) + case searchPlacesFailed(String) + // 길찾기 관련 액션 + case routeSearchStarted(Destination) + case routeSearchResponse(Result) + // 지도 카메라 제어 + case resetCameraFlag + case completeCardSwipe(next: Bool) + case finishCardTransition + // 네이버 이미지 검색 완료 + } + + public enum AsyncAction: Equatable { + case requestLocationPermission + case requestFullAccuracy + case startLocationUpdates + case stopLocationUpdates + case requestCurrentLocation + case fetchPlaces + case searchPlaces(page: Int, append: Bool) + // 길찾기 관련 액션 + case searchRoute(from: CLLocationCoordinate2D, to: Destination) + } + + + public enum DelegateAction: Equatable { + case presentExploreList + case presentExplorerDetail + } + + @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) + state.spots = [] + ExploreHelpers.syncSelectedSpot(state: &state) + return .merge( + .run { send in + let currentStatus = await locationUseCase.getAuthorizationStatus() + await send(.inner(.locationPermissionStatusChanged(currentStatus))) + }, + .send(.async(.fetchPlaces)) + ) + + 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.searchPlaces) + } + + state.$userSession.withLock { + $0.selectedExploreSpotID = spotID + $0.selectedExplorePlaceID = spotID + } + state.isSpotCardVisible = state.spots.contains(where: { $0.id == spotID && $0.hasDetail }) + #logDebug(" [ExploreReducer] spotTapped id=\(spotID), hasDetail=\(state.isSpotCardVisible)") + 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.searchPlaces), + .send(.async(.fetchPlaces)) + ) + + 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.searchPlaces) + } + return .none + + case .cardDragChanged(let offset): + guard !state.isCardTransitioning else { + return .none + } + let limitedOffset = max(min(offset, CardHelpers.cardTravelDistance), -CardHelpers.cardTravelDistance) + state.cardDragOffset = limitedOffset + return .none + + case .cardDragEnded(let translationWidth): + guard !state.isCardTransitioning else { + return .none + } + + if translationWidth > CardHelpers.cardSwipeThreshold { + return .send(.inner(.completeCardSwipe(next: false))) + } + + if translationWidth < -CardHelpers.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(.searchPlaces(page: state.currentPage, append: true))) + + case .mapCenterChanged(let coordinate): + state.mapCenterLat = coordinate.latitude + state.mapCenterLon = coordinate.longitude + 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: + print("🟡 [CurrentLocationButton] CameraUseCase 사용") + + // 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 + print("🟢 [CurrentLocationButton] CameraUseCase 처리 완료") + 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.searchPlaces), + .send(.async(.fetchPlaces)) + ) + } + return .none + + case .locationUpdateFailed(let error): + #logDebug(" [ExploreReducer] 위치 업데이트 실패: \(error)") + return .none + + case .fetchPlacesResponse(let entities, let usedCurrentLocation): + state.isLoadingPlaces = false + state.hasRequestedPlaces = false + state.spots = entities.spots + state.currentPage = entities.currentPage + state.hasNextPage = entities.hasNextPage || ExploreHelpers.hasUnresolvedBaseSpots(entities.spots) + state.hasFetchedPlacesWithCurrentLocation = usedCurrentLocation + state.$userSession.withLock { + $0.explorePlacesFetchedAt = Date() + } + return .none + + case .fetchPlacesFailed(let message, let usedCurrentLocation): + state.hasRequestedPlaces = false + ExploreHelpers.resetSearchContext(state: &state, clearMarker: false) + if usedCurrentLocation { + state.hasFetchedPlacesWithCurrentLocation = false + } + #logDebug(" [ExploreReducer] 장소 조회 실패: \(message)") + state.spots = [] + ExploreHelpers.clearSelectedSpot(state: &state) + return .none + + case .searchPlacesResponse( + let pageEntity, + let append, + let requestedPage, + let requestedKeyword, + let requestedCategory, + let requestedMarkerLat, + let requestedMarkerLon, + let usedCurrentLocation + ): + 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 requestedPage == 0 || append else { + return .none + } + + if requestedMarkerLat != nil || requestedMarkerLon != nil { + guard ExploreHelpers.isSameCoordinate(requestedMarkerLat, currentMarkerLat), + ExploreHelpers.isSameCoordinate(requestedMarkerLon, currentMarkerLon) else { + return .none + } + } else { + guard requestedKeyword == currentKeyword, + requestedCategory == currentCategory, + requestedMarkerLat == currentMarkerLat, + requestedMarkerLon == currentMarkerLon else { + return .none + } + } + + state.hasFetchedPlacesWithCurrentLocation = usedCurrentLocation + let newSpots = pageEntity.spots + let mergedSpots: [ExploreMapSpot] + if 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 = requestedPage + 1 + state.$userSession.withLock { + $0.explorePlacesFetchedAt = Date() + } + let newDetailedCount = newSpots.filter(\.hasDetail).count + let gainedMoreDetail = newDetailedCount > previousDetailedCount + let shouldKeepBootstrappingDetails = + requestedMarkerLat == nil + && requestedMarkerLon == nil + && requestedKeyword.isEmpty + && requestedCategory == nil + && ExploreHelpers.hasUnresolvedBaseSpots(newSpots) + && (requestedPage == 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(.searchPlaces(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 .searchPlacesFailed(let message): + state.isLoadingPlaces = false + state.hasRequestedPlaces = false + let wasPendingNextPage = state.pendingSelectFirstSpotFromNextPage + state.pendingSelectFirstSpotFromNextPage = false + #logDebug(" [ExploreReducer] 장소 검색 실패: \(message)") + if wasPendingNextPage { + return .send(.inner(.finishCardTransition)) + } + 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 + #logDebug(" [ExploreReducer] 경로 검색 완료: \(routeInfo.distance)m, \(routeInfo.duration)분") + case .failure(let error): + state.routeError = error.localizedDescription + #logDebug(" [ExploreReducer] 경로 검색 실패: \(error.localizedDescription)") + } + 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 { + #logDebug(" [ExploreReducer] completeCardSwipe ignored: cardSpots empty") + return .none + } + + let currentSelectedID = state.selectedSpot?.id ?? state.userSession.selectedExploreSpotID + let currentIndex = cardSpots.firstIndex(where: { $0.id == currentSelectedID }) ?? 0 + let entryOffset: CGFloat = next ? CardHelpers.cardTravelDistance : -CardHelpers.cardTravelDistance + let isAtEnd = next && currentIndex == cardSpots.count - 1 + let isAtStart = !next && currentIndex == 0 + + state.isCardTransitioning = true + state.cardDragOffset = next ? -CardHelpers.cardTravelDistance : CardHelpers.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: + guard Int(state.userSession.travelID) != nil, + state.userSession.travelStationLat != nil, + state.userSession.travelStationLng != nil, + !state.isLoadingPlaces, + !state.hasRequestedPlaces 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 + + 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(.fetchPlacesResponse(entities, usedCurrentLocation: usedCurrentLocation))) + case .failure(let error): + await send(.inner(.fetchPlacesFailed(error.localizedDescription, usedCurrentLocation: usedCurrentLocation))) + } + } + .cancellable(id: CancelID.fetchPlaces, cancelInFlight: true) + + case .searchPlaces(let page, let append): + guard Int(state.userSession.travelID) != nil, + !state.isLoadingPlaces, + !state.hasRequestedPlaces 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 userLat = state.currentLocation?.coordinate.latitude ?? fallbackLat + let userLon = state.currentLocation?.coordinate.longitude ?? fallbackLng + 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 = "MAP_NEAREST" + let usedCurrentLocation = state.currentLocation != nil + 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, + sortBy: sortBy, + mapLat: mapLat, + mapLon: mapLon, + page: page + ) + } + + switch result { + case .success(let entity): + await send( + .inner( + .searchPlacesResponse( + entity, + append: append, + requestedPage: page, + requestedKeyword: rawKeyword, + requestedCategory: category, + requestedMarkerLat: requestedMarkerLat, + requestedMarkerLon: requestedMarkerLon, + usedCurrentLocation: usedCurrentLocation + ) + ) + ) + case .failure(let error): + await send(.inner(.searchPlacesFailed(error.localizedDescription))) + } + } + .cancellable(id: CancelID.searchPlaces, cancelInFlight: true) + + // 길찾기 관련 액션 + 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))) + } + .cancellable(id: CancelID.searchRoute, 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 + } + } + + 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..2ef1b92 --- /dev/null +++ b/Projects/Presentation/Home/Sources/Explore/Reducer/ExploreState+Extensions.swift @@ -0,0 +1,152 @@ +// +// 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), + (.fetchPlaces, .fetchPlaces): + return true + case (.searchPlaces(let lhsPage, let lhsAppend), .searchPlaces(let rhsPage, let rhsAppend)): + return lhsPage == rhsPage && lhsAppend == rhsAppend + 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 + } + } +} 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..f1adf8c 100644 --- a/Projects/Presentation/Home/Sources/Explore/View/ExploreView.swift +++ b/Projects/Presentation/Home/Sources/Explore/View/ExploreView.swift @@ -9,15 +9,24 @@ import SwiftUI import ComposableArchitecture import CoreLocation +import UIKit import DesignSystem import Entity 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 } @@ -32,7 +41,7 @@ public struct ExploreView: View { Spacer() - currentLocationButton() + bottomSection() } } .onAppear { @@ -53,212 +62,75 @@ private extension ExploreView { currentLocation: store.currentLocation, routeInfo: store.routeInfo, destination: store.selectedDestination, - returnToLocation: store.shouldReturnToCurrentLocation + spots: store.state.filteredMapSpots, + selectedSpotID: store.userSession.selectedExploreSpotID.isEmpty + ? nil + : store.userSession.selectedExploreSpotID, + returnToLocationTrigger: store.returnToCurrentLocationTrigger, + 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, + onBackTap: { dismiss() }, + onSearchTextChanged: { store.send(.view(.searchTextChanged($0))) }, + onCategoryTap: { store.send(.view(.categoryTapped($0))) } + ) } @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: {}, + 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/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..dab6be0 --- /dev/null +++ b/Projects/Presentation/Home/Sources/ExploreDetail/Reducer/ExploreDetailFeature.swift @@ -0,0 +1,395 @@ +// +// 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 + +@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? + @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty + + public init() {} + } + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case 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 + } + + public enum AsyncAction: Equatable { + case fetchPlaceDetail + } + + public enum InnerAction: Equatable { + case fetchPlaceDetailResponse(Result) + } + + public enum DelegateAction: Equatable {} + + @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)) + } + } + + 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 {} + } + + 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: "취소" + ) + } + 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 + } +} + +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?.imageURL.compactMap { urlString -> URL? in + // Google Places API URL에 대한 특별한 처리 + if urlString.contains("places.googleapis.com") { + return URL(string: urlString.trimmingCharacters(in: .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.distanceToStation)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 { + remainingStayableMinutes <= 0 + } + + var returnDeadlineSuffixText: String { + isVisitUnavailable ? "" : " 에는 역으로 출발해야 합니다." + } + + var openingHoursText: String { + let weekdayText = summarizedOpeningHours(from: placeDetail?.weekday ?? []) + let weekendText = summarizedOpeningHours(from: placeDetail?.weekend ?? []) + + switch (weekdayText.isEmpty, weekendText.isEmpty) { + case (false, false): + return "평일 \(weekdayText), 주말 \(weekendText)" + case (false, true): + return "평일 \(weekdayText)" + case (true, false): + return "주말 \(weekendText)" + case (true, true): + return "영업 시간 정보 준비 중" + } + } + + var phoneNumberText: String { + placeDetail?.phoneNumber.nilIfEmpty ?? "전화번호 정보 준비 중" + } + + var addressText: String { + placeDetail?.address.nilIfEmpty ?? "주소 정보 준비 중" + } + + var stayableMinutesText: String { + "약 \(remainingStayableMinutes)분" + } + + var walkMinutesText: String { + if let placeDetail = placeDetail { + return "\(placeDetail.timeToStation)분" + } + return "0분" + } + + var mapCoordinate: CLLocationCoordinate2D { + if let placeDetail = placeDetail { + return CLLocationCoordinate2D( + latitude: placeDetail.stationLat, + longitude: placeDetail.stationLon + ) + } + + return CLLocationCoordinate2D( + latitude: userSession.travelStationLat ?? 37.5666805, + longitude: userSession.travelStationLng ?? 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..543ca8e --- /dev/null +++ b/Projects/Presentation/Home/Sources/ExploreDetail/View/ExploreDetailView.swift @@ -0,0 +1,427 @@ +// +// 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 { + if !(store.isLoading && store.placeDetail == nil) { + CustomNavigationBackBar(buttonAction: { + dismiss() + }, title: "") + .padding(.horizontal, 16) + .offset(y: -24) + } + + ScrollView(.vertical) { + Group { + if store.isLoading && store.placeDetail == nil { + ExploreDetailSkeletonView() + } else { + VStack(alignment: .leading) { + exploreSpotNameTitle() + + imageSection() + .padding(.top, 24) + + stayInfoSection() + .padding(.top, 24) + + returnDeadlineSection() + .padding(.top, 24) + + placeInfoSection() + .padding(.top, 29) + + locationMapSection() + .padding(.top, 24) + + routeButtonSection() + .padding(.top, 24) + } + } + } + .padding(.horizontal, 16) + .padding(.bottom, 28) + } + .scrollIndicators(.hidden) + } + + } + } + .onAppear { + store.send(.view(.onAppear)) + } + .refreshable { + // 캐시된 이미지 다시 확인 + } + .onChange(of: store.shouldDismiss) { _, shouldDismiss in + guard shouldDismiss else { return } + dismiss() + } + .customAlert($store.scope(state: \.customAlert, action: \.scope.customAlert)) + } +} + + +private extension ExploreDetailView { + + @ViewBuilder + func exploreSpotNameTitle() -> some View { + VStack(alignment: .leading) { + HStack(spacing: 8) { + Text(store.placeNameText.formattedPlaceNameForDisplay) + .pretendardCustomFont(textStyle: .heading1) + .foregroundStyle(.staticBlack) + .lineLimit(2) + + Text(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: store.stayableMinutesText, + title: "체류시간", + valueColor: .orange800 + ) + + divider + + metricColumn( + value: store.walkMinutesText, + title: "도보", + valueColor: .gray830 + ) + + divider + + metricColumn( + value: 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(store.returnDeadlineText) + .foregroundStyle(store.isVisitUnavailable ? .gray700 : .orange800) + + + Text(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: store.openingHoursText + ) + + infoRow( + icon: "phone.fill", + title: "전화번호", + content: store.phoneNumberText + ) + + infoRow( + icon: "location.fill", + title: "주소", + content: store.addressText + ) + } + } + } + + @ViewBuilder + func locationMapSection() -> some View { + GeometryReader { proxy in + Map(initialPosition: .region(store.mapRegion), interactionModes: .all) { + Annotation(store.placeNameText, coordinate: store.mapCoordinate) { + Image(asset: .spotPin) + .resizable() + .scaledToFit() + .frame(width: 24, height: 28) + } + } + .frame(width: proxy.size.width, height: 180) + .clipShape(RoundedRectangle(cornerRadius: 20)) + .clipped() + } + .frame(height: 180) + } + + @ViewBuilder + func routeButtonSection() -> some View { + CustomButton( + action: {}, + 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) + } + } + + func loadingPlaceholder() -> some View { + ZStack { + RoundedRectangle(cornerRadius: 20) + .fill(.gray200) + + VStack(spacing: 8) { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .gray600)) + .scaleEffect(0.8) + + Text("로딩 중...") + .pretendardCustomFont(textStyle: .body2Regular) + .foregroundStyle(.gray600) + } + } + } + +} 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..05e6d57 --- /dev/null +++ b/Projects/Presentation/Home/Sources/ExploreList/Reducer/ExploreListFeature.swift @@ -0,0 +1,440 @@ +// +// 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 + +public enum ExploreListSort: String, CaseIterable, Equatable { + case stationNearest = "STATION_NEAREST" + case userNearest = "USER_NEAREST" + + public var title: String { + switch self { + case .stationNearest: + return "역에서 가까운 순" + case .userNearest: + return "현재 위치로부터 가까운 순" + } + } +} + +@Reducer +public struct ExploreListFeature { + public init() {} + + enum CancelID: Hashable { + case searchPlaces + } + + @ObservableState + public struct State: Equatable { + public static let pageChunkSize = 10 + + public var searchText: String = "" + public var selectedCategory: ExploreCategory = .all + public var selectedSort: ExploreListSort = .stationNearest + public var requestSortBy: String = "STATION_NEAREST" + public var spots: [ExploreMapSpot] = [] + public var bufferedSpots: [ExploreMapSpot] = [] + public var currentPage: Int = 0 + public var hasNextPage: Bool = true + public var isLoading: Bool = false + 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 + // Explore에서 데이터 동기화 + case syncSpotsFromExplore([ExploreMapSpot], currentPage: Int, hasNextPage: Bool) + } + + + + //MARK: - AsyncAction 비동기 처리 액션 + public enum AsyncAction: Equatable { + case searchPlaces(page: Int, append: Bool) + } + + //MARK: - 앱내에서 사용하는 액션 + public enum InnerAction: Equatable { + case searchPlacesResponse(ExploreSpotPageEntity, append: Bool, requestedPage: Int) + case searchPlacesFailed(String) + case forceResetLoading + } + + //MARK: - NavigationAction + public enum NavigationAction: Equatable { + } + + public enum DelegateAction: Equatable { + case presentExploreMapAtCurrentLocation + } + + @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(.searchPlaces(page: 0, append: false))) + + case .searchTextChanged(let text): + state.searchText = text + state.lastTriggeredLoadSpotID = nil + return .none + + case .categoryTapped(let category): + state.selectedCategory = category + state.currentPage = 0 + state.hasNextPage = true + state.lastTriggeredLoadSpotID = nil + return .merge( + .cancel(id: CancelID.searchPlaces), + .send(.async(.searchPlaces(page: 0, append: false))) + ) + + case .sortTapped(let sort): + state.selectedSort = sort + state.requestSortBy = sort.rawValue + state.currentPage = 0 + state.hasNextPage = true + state.lastTriggeredLoadSpotID = nil + return .merge( + .cancel(id: CancelID.searchPlaces), + .send(.async(.searchPlaces(page: 0, append: false))) + ) + + case .loadNextPage: + guard !state.isLoading else { + // 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 + return .send(.async(.searchPlaces(page: state.currentPage, append: true))) + + + 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 .searchPlaces(page, append): + 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 + let category: ExploreCategory? = 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.searchPlaces( + userSession: userSession, + userLat: userLat, + userLon: userLon, + keyword: keyword, + category: category, + sortBy: sortBy, + mapLat: mapLat, + mapLon: mapLon, + page: page + ) + } + + switch result { + case .success(let pageEntity): + let spots = makeSpots(from: pageEntity.content, userSession: userSession) + await send( + .inner( + .searchPlacesResponse( + ExploreSpotPageEntity( + spots: spots, + currentPage: pageEntity.page + 1, + hasNextPage: !pageEntity.isLastPage + ), + append: append, + requestedPage: page + ) + ) + ) + case .failure(let error): + await send(.inner(.searchPlacesFailed(error.localizedDescription))) + } + } + .cancellable(id: CancelID.searchPlaces, cancelInFlight: true) + + } + } + + private func handleDelegateAction( + state: inout State, + action: DelegateAction + ) -> Effect { + switch action { + case .presentExploreMapAtCurrentLocation: + return .none + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case let .searchPlacesResponse(pageEntity, append, requestedPage): + // 무조건 로딩 해제 (중복 데이터여도) + state.isLoading = false + + if append { + let existingSpotIDs = Set(state.bufferedSpots.map(\.id)) + let uniqueNewSpots = pageEntity.spots.filter { !existingSpotIDs.contains($0.id) } + + // 중복 데이터만 있어도 페이지는 업데이트 + state.currentPage = requestedPage + 1 + state.hasNextPage = pageEntity.hasNextPage + + if !uniqueNewSpots.isEmpty { + state.bufferedSpots.append(contentsOf: uniqueNewSpots) + revealNextChunk(state: &state) + } + } else { + state.bufferedSpots = pageEntity.spots + state.spots = pageEntity.spots // 카테고리 변경 시 모든 데이터 바로 표시 + state.currentPage = requestedPage + 1 + state.hasNextPage = pageEntity.hasNextPage + } + + return .none + + case .searchPlacesFailed: + 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) + + return spots.filter { spot in + let hasDetail = spot.hasDetail + let matchesQuery = query.isEmpty || spot.name.localizedCaseInsensitiveContains(query) + return hasDetail && matchesQuery + } + } + + 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.isOpen ? "영업 중" : "영업 종료", + closingText: closingText, + distanceText: distanceText, + walkTimeText: walkTimeText, + address: place.address + ) + } + } +} + +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.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(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..a936b59 --- /dev/null +++ b/Projects/Presentation/Home/Sources/ExploreList/View/ExploreListSkeletonView.swift @@ -0,0 +1,12 @@ +// +// ExploreListSkeletonView.swift +// Home +// + +import SwiftUI + +struct ExploreListSkeletonView: View { + var body: some View { + ExploreSkeletonView() + } +} 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..82be137 --- /dev/null +++ b/Projects/Presentation/Home/Sources/ExploreList/View/ExploreListView.swift @@ -0,0 +1,186 @@ +// +// ExploreListView.swift +// Home +// +// Created by Wonji Suh on 3/28/26. +// + +import SwiftUI +import DesignSystem +import Entity + +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 shouldShowInitialSkeleton { + ExploreListSkeletonView() + } else { + ExploreSearchHeaderView( + stationName: store.userSession.travelStationName, + searchText: store.searchText, + selectedCategory: store.selectedCategory, + onBackTap: { dismiss() }, + onSearchTextChanged: { store.send(.view(.searchTextChanged($0))) }, + onCategoryTap: { store.send(.view(.categoryTapped($0))) } + ) + .padding(.top, 8) + .padding(.horizontal, 16) + .background(.staticWhite) + + sortSection() + .padding(.top, 28) + .padding(.horizontal, 16) + .background(.staticWhite) + + ScrollView(showsIndicators: false) { + LazyVStack(spacing: 12) { + ForEach(filteredSpots) { spot in + ExploreSpotListCardView(spot: spot, store: store) + .onAppear { + guard shouldShowLoadMore else { return } + + // 간단하게 마지막 3개 아이템 중 하나면 로드 + let lastFewSpots = filteredSpots.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 { + var shouldShowInitialSkeleton: Bool { + store.spots.isEmpty && (!store.hasLoadedInitialPage || store.isLoading) + } + + var isFilteringLocally: Bool { + !store.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var shouldShowLoadMore: Bool { + !isFilteringLocally && (store.spots.count < store.bufferedSpots.count || store.hasNextPage) + } + + var filteredSpots: [ExploreMapSpot] { + let query = store.searchText.trimmingCharacters(in: .whitespacesAndNewlines) + let sourceSpots = isFilteringLocally ? store.bufferedSpots : store.spots + + return sourceSpots.filter { spot in + let hasDetail = spot.hasDetail + let matchesQuery = query.isEmpty || spot.name.localizedCaseInsensitiveContains(query) + return hasDetail && matchesQuery + } + } + + @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) + } +} diff --git a/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift b/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift index 49d0b7b..05759b1 100644 --- a/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift +++ b/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift @@ -202,6 +202,8 @@ extension HomeFeature { 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 +257,12 @@ extension HomeFeature { case .departureTimeChanged(let date): state.currentTime = now - state.departureTime = date + state.departureTime = date.normalizedDepartureTime(from: state.currentTime) state.departureTimePickerVisible = false state.isDepartureTimeSet = true + state.$userSession.withLock { + $0.remainingMinutes = state.remainingTotalMinutes + } guard state.shouldShowDepartureWarningToast else { return .none } @@ -385,6 +390,9 @@ extension HomeFeature { state.$userSession.withLock { $0.travelID = "" $0.travelStationName = "" + $0.travelStationLat = nil + $0.travelStationLng = nil + $0.remainingMinutes = 0 } return .none } @@ -392,12 +400,16 @@ extension HomeFeature { } extension HomeFeature.State { + var maxDepartureTime: Date { + Calendar.current.date(byAdding: .day, value: 1, to: currentTime) ?? currentTime + } + var remainingTotalMinutes: Int { (remainingTime.hour ?? 0) * 60 + (remainingTime.minute ?? 0) } var isStationReady: Bool { - hasSelectedStation || selectedStation == .seoul + hasSelectedStation } var isExploreNearbyEnabled: Bool { @@ -433,6 +445,31 @@ extension HomeFeature.State { } } +private extension Date { + func normalizedDepartureTime(from currentTime: Date) -> Date { + let calendar = Calendar.current + let currentDateComponents = calendar.dateComponents([.year, .month, .day], from: currentTime) + let selectedTimeComponents = calendar.dateComponents([.hour, .minute], from: self) + + var normalizedComponents = DateComponents() + normalizedComponents.year = currentDateComponents.year + normalizedComponents.month = currentDateComponents.month + normalizedComponents.day = currentDateComponents.day + normalizedComponents.hour = selectedTimeComponents.hour + normalizedComponents.minute = selectedTimeComponents.minute + + 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: - 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..b78c874 100644 --- a/Projects/Presentation/Home/Sources/Main/View/HomeView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/HomeView.swift @@ -214,7 +214,7 @@ extension HomeView { DatePicker( HomeFeature.Strings.departureTimeSelection, selection: $store.departureTime, - in: store.currentTime..., + in: store.currentTime...store.maxDepartureTime, displayedComponents: [.hourAndMinute] ) .datePickerStyle(.wheel) 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/TrainStation/Model/StationRowModel.swift b/Projects/Presentation/Home/Sources/TrainStation/Model/StationRowModel.swift index 5ad93aa..c36b751 100644 --- a/Projects/Presentation/Home/Sources/TrainStation/Model/StationRowModel.swift +++ b/Projects/Presentation/Home/Sources/TrainStation/Model/StationRowModel.swift @@ -15,6 +15,8 @@ public struct StationRowModel: Identifiable, Equatable, Hashable { public let stationID: Int public let stationName: String public let badges: [String] + public let lat: Double? + public let lng: Double? public let distanceText: String? public let isFavorite: Bool @@ -25,6 +27,8 @@ public struct StationRowModel: Identifiable, Equatable, Hashable { stationID: Int, stationName: String, badges: [String], + lat: Double? = nil, + lng: Double? = nil, distanceText: String?, isFavorite: Bool ) { @@ -34,6 +38,8 @@ public struct StationRowModel: Identifiable, Equatable, Hashable { self.stationID = stationID self.stationName = stationName self.badges = badges + self.lat = lat + self.lng = lng self.distanceText = distanceText self.isFavorite = isFavorite } diff --git a/Projects/Presentation/Home/Sources/TrainStation/Reducer/TrainStationFeature.swift b/Projects/Presentation/Home/Sources/TrainStation/Reducer/TrainStationFeature.swift index 7d2c49d..d9a23be 100644 --- a/Projects/Presentation/Home/Sources/TrainStation/Reducer/TrainStationFeature.swift +++ b/Projects/Presentation/Home/Sources/TrainStation/Reducer/TrainStationFeature.swift @@ -74,7 +74,7 @@ public struct TrainStationFeature { case checkAccessToken case fetchStations case addFavoriteStation(Int) - case deleteFavoriteStation(Int) + case deleteFavoriteStation(favoriteID: Int, stationID: Int) } //MARK: - 앱내에서 사용하는 액션 @@ -84,7 +84,7 @@ public struct TrainStationFeature { case fetchStationsFailed(String) case addFavoriteStationResponse case addFavoriteStationFailed(String) - case deleteFavoriteStationResponse + case deleteFavoriteStationResponse(Int) case deleteFavoriteStationFailed(String) } @@ -138,7 +138,8 @@ extension TrainStationFeature { case .favoriteButtonTapped(let row): guard state.shouldShowFavoriteSection 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))) } @@ -161,13 +162,13 @@ extension TrainStationFeature { 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 + let userLat = location?.coordinate.latitude ?? 37.5666805 + let userLon = location?.coordinate.longitude ?? 126.9784147 do { let entity = try await stationUseCase.fetchStations( - lat: lat, - lng: lng, + userLat: userLat, + userLon: userLon, page: 1, size: 30 ) @@ -183,16 +184,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))) } } @@ -234,8 +245,39 @@ 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 } + state.nearbyRows = state.nearbyRows.map { row in + guard row.stationID == stationID else { return row } + return StationRowModel( + id: row.id, + favoriteID: nil, + station: row.station, + stationID: row.stationID, + stationName: row.stationName, + badges: row.badges, + lat: row.lat, + lng: row.lng, + distanceText: row.distanceText, + isFavorite: false + ) + } + state.majorRows = state.majorRows.map { row in + guard row.stationID == stationID else { return row } + return StationRowModel( + id: row.id, + favoriteID: nil, + station: row.station, + stationID: row.stationID, + stationName: row.stationName, + badges: row.badges, + lat: row.lat, + lng: row.lng, + distanceText: row.distanceText, + isFavorite: false + ) + } + return .none case .deleteFavoriteStationFailed(let message): state.errorMessage = message return .none @@ -260,11 +302,13 @@ private extension TrainStationFeature { let normalizedName = normalizedStationName(station.name) return StationRowModel( id: "favorite-\(station.stationID)", - favoriteID: station.stationID, + favoriteID: station.favoriteID ?? station.stationID, station: Station(displayName: normalizedName), stationID: station.stationID, stationName: normalizedName, badges: station.lines, + lat: station.lat, + lng: station.lng, distanceText: nil, isFavorite: true ) @@ -281,6 +325,8 @@ private extension TrainStationFeature { stationID: station.stationID, stationName: normalizedName, badges: station.lines, + lat: station.lat, + lng: station.lng, distanceText: "2.3km", isFavorite: false ) @@ -299,6 +345,8 @@ private extension TrainStationFeature { stationID: station.stationID, stationName: normalizedName, badges: station.lines, + lat: station.lat, + lng: station.lng, distanceText: nil, isFavorite: false ) @@ -306,9 +354,10 @@ private extension TrainStationFeature { } func applyFavoriteState(state: inout State) { - let favoriteNameMap = Dictionary( - uniqueKeysWithValues: state.favoriteRows.map { - (normalizedStationName($0.stationName), $0.stationID) + let favoriteNameMap: [String: Int] = Dictionary( + uniqueKeysWithValues: state.favoriteRows.compactMap { row -> (String, Int)? in + let identifier = row.favoriteID ?? row.stationID + return (normalizedStationName(row.stationName), identifier) } ) @@ -321,6 +370,8 @@ private extension TrainStationFeature { stationID: row.stationID, stationName: row.stationName, badges: row.badges, + lat: row.lat, + lng: row.lng, distanceText: row.distanceText, isFavorite: favoriteID != nil ) @@ -335,6 +386,8 @@ private extension TrainStationFeature { stationID: row.stationID, stationName: row.stationName, badges: row.badges, + lat: row.lat, + lng: row.lng, distanceText: row.distanceText, isFavorite: favoriteID != nil ) diff --git a/Projects/Presentation/OnBoarding/Sources/Reducer/OnBoardingFeature.swift b/Projects/Presentation/OnBoarding/Sources/Reducer/OnBoardingFeature.swift index 390d48e..2ddad38 100644 --- a/Projects/Presentation/OnBoarding/Sources/Reducer/OnBoardingFeature.swift +++ b/Projects/Presentation/OnBoarding/Sources/Reducer/OnBoardingFeature.swift @@ -217,4 +217,3 @@ extension OnBoardingFeature.State { hasher.combine(selectedMap) } } - 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..e560837 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 @@ -160,7 +164,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))) 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..253fa1a 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)) + } } } @@ -55,47 +58,52 @@ extension NotificationSettingView { 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) + 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..e8feb88 100644 --- a/Projects/Presentation/Profile/Sources/Setting/Reducer/SettingReducer.swift +++ b/Projects/Presentation/Profile/Sources/Setting/Reducer/SettingReducer.swift @@ -82,6 +82,8 @@ public struct SettingFeature { case presentAuth case presentWithDraw case presentNotificationSetting + case presentPrivacyPolicy + case presentServicePolicy } @@ -225,6 +227,13 @@ extension SettingFeature { case .presentNotificationSetting: return .none + + case .presentServicePolicy: + return .none + + case .presentPrivacyPolicy: + return .none + } } diff --git a/Projects/Presentation/Profile/Sources/Setting/View/SettingView.swift b/Projects/Presentation/Profile/Sources/Setting/View/SettingView.swift index c6e4a94..be08b57 100644 --- a/Projects/Presentation/Profile/Sources/Setting/View/SettingView.swift +++ b/Projects/Presentation/Profile/Sources/Setting/View/SettingView.swift @@ -82,9 +82,16 @@ extension SettingView { store.send(.view(.mapTypeSelected(mapType))) } label: { if store.userSession.mapType == mapType { - Label(mapType.description, systemImage: "checkmark") - .pretendardCustomFont(textStyle: .bodyMedium) - .foregroundStyle(.gray800) + HStack(spacing: 8) { + Image(asset: .rowCheck) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + + Text(mapType.description) + .pretendardCustomFont(textStyle: .bodyMedium) + .foregroundStyle(.gray800) + } } else { Text(mapType.description) .pretendardCustomFont(textStyle: .bodyMedium) @@ -107,11 +114,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( diff --git a/Projects/Presentation/Splash/Sources/Reducer/SplashReducer.swift b/Projects/Presentation/Splash/Sources/Reducer/SplashReducer.swift index 39a4ab8..de0ba68 100644 --- a/Projects/Presentation/Splash/Sources/Reducer/SplashReducer.swift +++ b/Projects/Presentation/Splash/Sources/Reducer/SplashReducer.swift @@ -149,4 +149,3 @@ extension SplashReducer { } } } - diff --git a/Projects/Presentation/Splash/Sources/View/SplashView.swift b/Projects/Presentation/Splash/Sources/View/SplashView.swift index 709bc20..d1d3e77 100644 --- a/Projects/Presentation/Splash/Sources/View/SplashView.swift +++ b/Projects/Presentation/Splash/Sources/View/SplashView.swift @@ -7,15 +7,26 @@ import SwiftUI +import DesignSystem import ComposableArchitecture 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 +34,74 @@ public struct SplashView: View { public var body: some View { ZStack { - // 배경 - Color.black - .opacity(bgOpacity) + Color.gray100 .ignoresSafeArea() - // 로고 - Text("Uber") - .font(.system(size: 48, weight: .black)) - .foregroundColor(.white) - .scaleEffect(scale) - .opacity(logoOpacity) + ZStack { + Image(asset: .appLogo) + .resizable() + .scaledToFit() + .frame( + width: Constants.symbolLargeSize.width, + height: Constants.symbolLargeSize.height + ) + .scaleEffect(x: symbolScale * symbolScaleX, y: symbolScale * symbolScaleY) + .rotationEffect(.degrees(symbolRotation)) + .offset(x: symbolOffsetX) + .opacity(symbolOpacity) + + Image(asset: .logo) + .resizable() + .scaledToFit() + .frame( + width: Constants.wordmarkSize.width, + height: Constants.wordmarkSize.height + ) + .opacity(wordmarkOpacity) + .offset(x: wordmarkOffsetX) + } } .onAppear { - // 토큰 확인 시작 store.send(.view(.onAppear)) + runAnimation() + } + } +} - // 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 - } +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 + } + + 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/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/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/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/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/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/Image/ImageAsset.swift b/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift index 8fb0cf2..10abc5b 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,12 @@ public enum ImageAsset: String { case naverMap case googleMap case appleMap + case shoppingPin + case cafePin + case etcPin + case foodPin + case gamePin + case spotPin case onBoardingLogo1 case onBoardingLogo2 @@ -46,6 +53,8 @@ public enum ImageAsset: String { case homeLogo case logo case loginlogo + case appLogo + case locationBadge case warning diff --git a/Projects/Shared/Utill/Sources/CoreLocation/CLLocationCoordinate2D+.swift b/Projects/Shared/Utill/Sources/CoreLocation/CLLocationCoordinate2D+.swift new file mode 100644 index 0000000..5c72c04 --- /dev/null +++ b/Projects/Shared/Utill/Sources/CoreLocation/CLLocationCoordinate2D+.swift @@ -0,0 +1,17 @@ +// +// CLLocationCoordinate2D+.swift +// Utill +// + +import Foundation +import CoreLocation + +public extension CLLocationCoordinate2D { + var formattedCoordinateText: String { + String(format: "%.6f, %.6f", latitude, longitude) + } + + var approximateAddressText: String { + "\(formattedCoordinateText) 부근" + } +} diff --git a/Projects/Shared/Utill/Sources/Date/Date+.swift b/Projects/Shared/Utill/Sources/Date/Date+.swift index 4e118fd..09440ad 100644 --- a/Projects/Shared/Utill/Sources/Date/Date+.swift +++ b/Projects/Shared/Utill/Sources/Date/Date+.swift @@ -139,11 +139,27 @@ 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) + } } 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..f59a03b 100644 --- a/Projects/Shared/Utill/Sources/String/String+.swift +++ b/Projects/Shared/Utill/Sources/String/String+.swift @@ -138,4 +138,98 @@ 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 + } } diff --git a/Tuist/Package.swift b/Tuist/Package.swift index 50cdce0..d8cc6a5 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -36,5 +36,6 @@ 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"), ] ) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 5d556fd..869e948 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -44,6 +44,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}" 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..8b13789 --- /dev/null +++ b/fastlane/metadata/copyright.txt @@ -0,0 +1 @@ + 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..8b13789 --- /dev/null +++ b/fastlane/metadata/ko/keywords.txt @@ -0,0 +1 @@ + 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..8b13789 --- /dev/null +++ b/fastlane/metadata/ko/privacy_url.txt @@ -0,0 +1 @@ + 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..8b13789 --- /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..8b13789 --- /dev/null +++ b/fastlane/metadata/primary_category.txt @@ -0,0 +1 @@ + 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..8b13789 --- /dev/null +++ b/fastlane/metadata/review_information/email_address.txt @@ -0,0 +1 @@ + diff --git a/fastlane/metadata/review_information/first_name.txt b/fastlane/metadata/review_information/first_name.txt new file mode 100644 index 0000000..8b13789 --- /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..8b13789 --- /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..8b13789 --- /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..8b13789 --- /dev/null +++ b/fastlane/metadata/review_information/phone_number.txt @@ -0,0 +1 @@ + diff --git a/fastlane/metadata/secondary_category.txt b/fastlane/metadata/secondary_category.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/fastlane/metadata/secondary_category.txt @@ -0,0 +1 @@ + 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 @@ +