diff --git a/HotSpot/Configuration/Config.xcconfig b/HotSpot/Configuration/Config.xcconfig deleted file mode 100644 index 92c7b01..0000000 --- a/HotSpot/Configuration/Config.xcconfig +++ /dev/null @@ -1 +0,0 @@ -BASE_URL = http://webservice.recruit.co.jp/hotpepper diff --git a/HotSpot/Sources/Common/Dependency/ShopRepository+Dependency.swift b/HotSpot/Sources/Common/Dependency/ShopRepository+Dependency.swift new file mode 100644 index 0000000..5bed993 --- /dev/null +++ b/HotSpot/Sources/Common/Dependency/ShopRepository+Dependency.swift @@ -0,0 +1,14 @@ +import ComposableArchitecture + +private enum ShopRepositoryKey: DependencyKey { + static let liveValue: ShopRepository = ShopRepositoryImpl( + remoteDataSource: ShopRemoteDataSourceImpl() + ) +} + +extension DependencyValues { + var shopRepository: ShopRepository { + get { self[ShopRepositoryKey.self] } + set { self[ShopRepositoryKey.self] = newValue } + } +} diff --git a/HotSpot/Sources/Common/Extensions/MoyaProvider+Extension.swift b/HotSpot/Sources/Common/Extensions/MoyaProvider+Extension.swift new file mode 100644 index 0000000..6c64823 --- /dev/null +++ b/HotSpot/Sources/Common/Extensions/MoyaProvider+Extension.swift @@ -0,0 +1,24 @@ +import Moya + +extension MoyaProvider { + static var `default`: MoyaProvider { + return MoyaProvider( + plugins: [NetworkLoggerPlugin(configuration: .init(logOptions: .verbose))] + ) + } +} + +extension MoyaProvider { + func asyncRequest(_ target: Target) async throws -> Response { + return try await withCheckedThrowingContinuation { continuation in + self.request(target) { result in + switch result { + case let .success(response): + continuation.resume(returning: response) + case let .failure(error): + continuation.resume(throwing: error) + } + } + } + } +} diff --git a/HotSpot/Sources/Common/Extensions/ShopGenre.swift b/HotSpot/Sources/Common/Extensions/ShopGenre.swift new file mode 100644 index 0000000..dd3b190 --- /dev/null +++ b/HotSpot/Sources/Common/Extensions/ShopGenre.swift @@ -0,0 +1,72 @@ +import UIKit + +public enum ShopGenre { + public static func name(for genreCode: String) -> String { + switch genreCode { + case "G001": return "居酒屋" + case "G002": return "ダイニングバー・バル" + case "G003": return "創作料理" + case "G004": return "和食" + case "G005": return "洋食" + case "G006": return "イタリアン・フレンチ" + case "G007": return "中華" + case "G008": return "焼肉・ホルモン" + case "G009": return "アジア・エスニック料理" + case "G010": return "各国料理" + case "G011": return "カラオケ・パーティ" + case "G012": return "バー・カクテル" + case "G013": return "ラーメン" + case "G014": return "カフェ・スイーツ" + case "G015": return "その他グルメ" + case "G016": return "お好み焼き・もんじゃ" + case "G017": return "韓国料理" + default: return "その他" + } + } + + public static func color(for genreCode: String) -> UIColor { + switch genreCode { + case "G001": return UIColor(red: 0.8, green: 0.2, blue: 0.2, alpha: 1.0) // 居酒屋 - Red + case "G002": return UIColor(red: 0.6, green: 0.4, blue: 0.8, alpha: 1.0) // ダイニングバー・バル - Purple + case "G003": return UIColor(red: 0.2, green: 0.6, blue: 0.8, alpha: 1.0) // 創作料理 - Blue + case "G004": return UIColor(red: 0.8, green: 0.6, blue: 0.2, alpha: 1.0) // 和食 - Orange + case "G005": return UIColor(red: 0.4, green: 0.8, blue: 0.4, alpha: 1.0) // 洋食 - Green + case "G006": return UIColor(red: 0.8, green: 0.4, blue: 0.6, alpha: 1.0) // イタリアン・フレンチ - Pink + case "G007": return UIColor(red: 0.8, green: 0.2, blue: 0.4, alpha: 1.0) // 中華 - Deep Pink + case "G008": return UIColor(red: 0.4, green: 0.2, blue: 0.2, alpha: 1.0) // 焼肉・ホルモン - Brown + case "G017": return UIColor(red: 0.6, green: 0.2, blue: 0.2, alpha: 1.0) // 韓国料理 - Dark Red + case "G009": return UIColor(red: 0.2, green: 0.4, blue: 0.6, alpha: 1.0) // アジア・エスニック料理 - Dark Blue + case "G010": return UIColor(red: 0.4, green: 0.6, blue: 0.2, alpha: 1.0) // 各国料理 - Olive + case "G011": return UIColor(red: 0.8, green: 0.4, blue: 0.2, alpha: 1.0) // カラオケ・パーティ - Orange + case "G012": return UIColor(red: 0.6, green: 0.2, blue: 0.6, alpha: 1.0) // バー・カクテル - Purple + case "G013": return UIColor(red: 0.2, green: 0.8, blue: 0.6, alpha: 1.0) // ラーメン - Teal + case "G016": return UIColor(red: 0.8, green: 0.6, blue: 0.4, alpha: 1.0) // お好み焼き・もんじゃ - Light Brown + case "G014": return UIColor(red: 0.6, green: 0.8, blue: 0.2, alpha: 1.0) // カフェ・スイーツ - Light Green + case "G015": return UIColor(red: 0.4, green: 0.4, blue: 0.4, alpha: 1.0) // その他グルメ - Gray + default: return .black + } + } + + public static func image(for genreCode: String) -> UIImage? { + switch genreCode { + case "G001": return UIImage(systemName: "wineglass.fill") // 居酒屋 + case "G002": return UIImage(systemName: "wineglass") // ダイニングバー・バル + case "G003": return UIImage(systemName: "fork.knife") // 創作料理 + case "G004": return UIImage(systemName: "leaf.fill") // 和食 + case "G005": return UIImage(systemName: "fork.knife.circle") // 洋食 + case "G006": return UIImage(systemName: "fork.knife.circle.fill") // イタリアン・フレンチ + case "G007": return UIImage(systemName: "bowl.fill") // 中華 + case "G008": return UIImage(systemName: "flame.fill") // 焼肉・ホルモン + case "G017": return UIImage(systemName: "bowl") // 韓国料理 + case "G009": return UIImage(systemName: "globe.asia.australia.fill") // アジア・エスニック料理 + case "G010": return UIImage(systemName: "globe") // 各国料理 + case "G011": return UIImage(systemName: "music.mic") // カラオケ・パーティ + case "G012": return UIImage(systemName: "wineglass") // バー・カクテル + case "G013": return UIImage(systemName: "bowl") // ラーメン + case "G016": return UIImage(systemName: "flame") // お好み焼き・もんじゃ + case "G014": return UIImage(systemName: "cup.and.saucer.fill") // カフェ・スイーツ + case "G015": return UIImage(systemName: "questionmark.circle.fill") // その他グルメ + default: return UIImage(systemName: "mappin.circle.fill") + } + } +} \ No newline at end of file diff --git a/HotSpot/Sources/Common/Extensions/UIImage+Kingfisher.swift b/HotSpot/Sources/Common/Extensions/UIImage+Kingfisher.swift new file mode 100644 index 0000000..6eded86 --- /dev/null +++ b/HotSpot/Sources/Common/Extensions/UIImage+Kingfisher.swift @@ -0,0 +1,46 @@ +import UIKit +import Kingfisher + +extension UIImage { + static func load(from urlString: String?, completion: @escaping (UIImage?) -> Void) { + guard let urlString = urlString, + let url = URL(string: urlString) else { + completion(nil) + return + } + + KingfisherManager.shared.retrieveImage(with: url) { result in + switch result { + case .success(let value): + completion(value.image) + case .failure: + completion(nil) + } + } + } + + static func loadThumbnail(from urlString: String?, completion: @escaping (UIImage?) -> Void) { + guard let urlString = urlString, + let url = URL(string: urlString) else { + completion(nil) + return + } + + let processor = DownsamplingImageProcessor(size: CGSize(width: 200, height: 200)) + KingfisherManager.shared.retrieveImage( + with: url, + options: [ + .processor(processor), + .scaleFactor(UIScreen.main.scale), + .cacheOriginalImage + ] + ) { result in + switch result { + case .success(let value): + completion(value.image) + case .failure: + completion(nil) + } + } + } +} diff --git a/HotSpot/Sources/Data/API/ServiceAPI.swift b/HotSpot/Sources/Data/API/ServiceAPI.swift index f9aa11d..c5d776a 100644 --- a/HotSpot/Sources/Data/API/ServiceAPI.swift +++ b/HotSpot/Sources/Data/API/ServiceAPI.swift @@ -2,6 +2,7 @@ import Foundation import Moya enum ServiceAPI { + case searchShops(ShopSearchRequestDTO) } extension ServiceAPI: TargetType { @@ -15,30 +16,37 @@ extension ServiceAPI: TargetType { var path: String { switch self { - default: - return "" + case .searchShops: + return "/gourmet/v1/" } } var method: Moya.Method { switch self { - default: + case .searchShops: return .get } } var task: Task { + guard let apiKey = Bundle.main.object(forInfoDictionaryKey: "API_KEY") as? String else { + fatalError("API_KEY is not set in configuration") + } + switch self { - default: - return .requestPlain + case let .searchShops(request): + var parameters = request.asParameters + parameters["key"] = apiKey + parameters["format"] = "json" + return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) } } var headers: [String: String]? { - return [ - "Content-Type": "application/json" - ] + return nil } - var validationType: ValidationType { .successCodes } + var validationType: ValidationType { + return .successCodes + } } diff --git a/HotSpot/Sources/Data/DTO/Request/ShopSearchRequestDTO.swift b/HotSpot/Sources/Data/DTO/Request/ShopSearchRequestDTO.swift new file mode 100644 index 0000000..55cd645 --- /dev/null +++ b/HotSpot/Sources/Data/DTO/Request/ShopSearchRequestDTO.swift @@ -0,0 +1,41 @@ +import Foundation + +struct ShopSearchRequestDTO { + let lat: Double // Latitude + let lng: Double // Longitude + let range: Int // Search range (1–5) + let count: Int? // Number of results (1–100) + let keyword: String? // Keyword search + let genre: String? // Genre code + let order: Int? // Order: 1=recommend, 2=popularity + let start: Int? // Starting index for paging + let budget: String? // Budget code + let privateRoom: Bool? // Private room availability + let wifi: Bool? // Wi-Fi availability + let nonSmoking: Bool? // Non-smoking availability + let coupon: Bool? // Coupon availability + let openNow: Bool? // Currently open filter + + /// Converts the DTO into a dictionary of parameters for Moya or URL encoding + var asParameters: [String: Any] { + var params: [String: Any] = [ + "lat": lat, + "lng": lng, + "range": range + ] + + if let count = count { params["count"] = count } + if let keyword = keyword { params["keyword"] = keyword } + if let genre = genre { params["genre"] = genre } + if let order = order { params["order"] = order } + if let start = start { params["start"] = start } + if let budget = budget { params["budget"] = budget } + if let privateRoom = privateRoom { params["private_room"] = privateRoom ? 1 : 0 } + if let wifi = wifi { params["wifi"] = wifi ? 1 : 0 } + if let nonSmoking = nonSmoking { params["non_smoking"] = nonSmoking ? 1 : 0 } + if let coupon = coupon { params["coupon"] = coupon ? 1 : 0 } + if let openNow = openNow, openNow { params["open"] = "now" } + + return params + } +} diff --git a/HotSpot/Sources/Data/DTO/Response/ShopDTO.swift b/HotSpot/Sources/Data/DTO/Response/ShopDTO.swift new file mode 100644 index 0000000..f07afaa --- /dev/null +++ b/HotSpot/Sources/Data/DTO/Response/ShopDTO.swift @@ -0,0 +1,91 @@ +import Foundation + +struct ShopDTO: Decodable { + let id: String + let name: String + let nameKana: String? + let address: String + let stationName: String? + let lat: Double + let lng: Double + let access: String + let open: String? + let close: String? + let photo: Photo + let genre: Genre + let catchPhrase: String + let budget: Budget? + let wifi: String? + let nonSmoking: String? + let privateRoom: String? + let card: String? + + struct Photo: Decodable { + let pc: PcPhoto + + struct PcPhoto: Decodable { + let large: String + let medium: String + let small: String + + enum CodingKeys: String, CodingKey { + case large = "l" + case medium = "m" + case small = "s" + } + } + } + + struct Genre: Decodable { + let code: String + let name: String + let catchPhrase: String + + enum CodingKeys: String, CodingKey { + case code + case name + case catchPhrase = "catch" + } + } + + struct Budget: Decodable { + let code: String + let name: String + let average: String? + } + + enum CodingKeys: String, CodingKey { + case id + case name + case nameKana = "name_kana" + case address + case stationName = "station_name" + case lat + case lng + case access + case open + case close + case photo + case genre + case catchPhrase = "catch" + case budget + case wifi + case nonSmoking = "non_smoking" + case privateRoom = "private_room" + case card + } + + func toDomain() -> ShopModel { + ShopModel( + id: id, + name: name, + address: address, + latitude: lat, + longitude: lng, + imageUrl: photo.pc.large, + access: access, + openingHours: open, + genreCode: genre.code + ) + } +} diff --git a/HotSpot/Sources/Data/DTO/Response/ShopSearchResponseDTO.swift b/HotSpot/Sources/Data/DTO/Response/ShopSearchResponseDTO.swift new file mode 100644 index 0000000..643d7cc --- /dev/null +++ b/HotSpot/Sources/Data/DTO/Response/ShopSearchResponseDTO.swift @@ -0,0 +1,9 @@ +import Foundation + +struct ShopSearchResponseDTO: Decodable { + let results: ShopSearchResultsDTO + + enum CodingKeys: String, CodingKey { + case results + } +} diff --git a/HotSpot/Sources/Data/DTO/Response/ShopSearchResultsDTO.swift b/HotSpot/Sources/Data/DTO/Response/ShopSearchResultsDTO.swift new file mode 100644 index 0000000..45ea03d --- /dev/null +++ b/HotSpot/Sources/Data/DTO/Response/ShopSearchResultsDTO.swift @@ -0,0 +1,15 @@ +import Foundation + +struct ShopSearchResultsDTO: Decodable { + let resultsAvailable: Int + let resultsReturned: String + let resultsStart: Int + let shop: [ShopDTO] + + enum CodingKeys: String, CodingKey { + case resultsAvailable = "results_available" + case resultsReturned = "results_returned" + case resultsStart = "results_start" + case shop + } +} diff --git a/HotSpot/Sources/Data/DataSource/ShopRemoteDataSource.swift b/HotSpot/Sources/Data/DataSource/ShopRemoteDataSource.swift new file mode 100644 index 0000000..5b8d0c2 --- /dev/null +++ b/HotSpot/Sources/Data/DataSource/ShopRemoteDataSource.swift @@ -0,0 +1,5 @@ +import Foundation + +protocol ShopRemoteDataSource { + func search(request: ShopSearchRequestDTO) async throws -> ShopSearchResponseDTO +} diff --git a/HotSpot/Sources/Data/DataSource/ShopRemoteDataSourceImpl.swift b/HotSpot/Sources/Data/DataSource/ShopRemoteDataSourceImpl.swift new file mode 100644 index 0000000..04c0ae3 --- /dev/null +++ b/HotSpot/Sources/Data/DataSource/ShopRemoteDataSourceImpl.swift @@ -0,0 +1,16 @@ +import Foundation +import Moya + +final class ShopRemoteDataSourceImpl: ShopRemoteDataSource { + private let provider: MoyaProvider + + init(provider: MoyaProvider = .default) { + self.provider = provider + } + + func search(request: ShopSearchRequestDTO) async throws -> ShopSearchResponseDTO { + let target = ServiceAPI.searchShops(request) + let response = try await provider.asyncRequest(target) + return try JSONDecoder().decode(ShopSearchResponseDTO.self, from: response.data) + } +} diff --git a/HotSpot/Sources/Data/Repository/ShopRepositoryImpl.swift b/HotSpot/Sources/Data/Repository/ShopRepositoryImpl.swift new file mode 100644 index 0000000..2b95771 --- /dev/null +++ b/HotSpot/Sources/Data/Repository/ShopRepositoryImpl.swift @@ -0,0 +1,13 @@ +import Foundation + +final class ShopRepositoryImpl: ShopRepository { + private let remoteDataSource: ShopRemoteDataSource + + init(remoteDataSource: ShopRemoteDataSource) { + self.remoteDataSource = remoteDataSource + } + + func searchShops(request: ShopSearchRequestDTO) async throws -> ShopSearchResponseDTO { + try await remoteDataSource.search(request: request) + } +} diff --git a/HotSpot/Sources/Domain/Entity/Location.swift b/HotSpot/Sources/Domain/Entity/Location.swift deleted file mode 100644 index d74dd4e..0000000 --- a/HotSpot/Sources/Domain/Entity/Location.swift +++ /dev/null @@ -1,25 +0,0 @@ -import Foundation -import MapKit - -struct Location: Codable, Hashable { - var lat: Double - var lon: Double -} - -extension Location { - func toCLLocationCoordinate2D() -> CLLocationCoordinate2D { - CLLocationCoordinate2D( - latitude: self.lat, - longitude: self.lon - ) - } -} - -extension CLLocationCoordinate2D { - func toLocation() -> Location { - Location( - lat: self.latitude, - lon: self.longitude - ) - } -} diff --git a/HotSpot/Sources/Domain/Entity/Restaurant.swift b/HotSpot/Sources/Domain/Entity/Restaurant.swift deleted file mode 100644 index 2d81f8f..0000000 --- a/HotSpot/Sources/Domain/Entity/Restaurant.swift +++ /dev/null @@ -1,19 +0,0 @@ -import Foundation - -struct Restaurant: Identifiable, Equatable { - let id: UUID - let name: String - let address: String - let imageURL: URL? - let phone: String? - let location: Location? - - init(id: UUID = UUID(), name: String, address: String, imageURL: URL? = nil, phone: String? = nil, location: Location?) { - self.id = id - self.name = name - self.address = address - self.imageURL = imageURL - self.phone = phone - self.location = location - } -} diff --git a/HotSpot/Sources/Domain/Error/ShopError.swift b/HotSpot/Sources/Domain/Error/ShopError.swift new file mode 100644 index 0000000..c7026b5 --- /dev/null +++ b/HotSpot/Sources/Domain/Error/ShopError.swift @@ -0,0 +1,8 @@ +import Foundation + +enum ShopError: Error, Equatable { + case network + case decoding + case server(message: String) + case unknown +} diff --git a/HotSpot/Sources/Domain/Model/PaginationState.swift b/HotSpot/Sources/Domain/Model/PaginationState.swift new file mode 100644 index 0000000..2de256b --- /dev/null +++ b/HotSpot/Sources/Domain/Model/PaginationState.swift @@ -0,0 +1,35 @@ +import Foundation + +struct PaginationState { + var currentPage: Int + var isLastPage: Bool + var isLoading: Bool + + init(currentPage: Int = 1, isLastPage: Bool = false, isLoading: Bool = false) { + self.currentPage = currentPage + self.isLastPage = isLastPage + self.isLoading = isLoading + } + + mutating func reset() { + currentPage = 1 + isLastPage = false + isLoading = false + } + + mutating func update(isLastPage: Bool) { + self.isLastPage = isLastPage + } + + mutating func startLoading() { + isLoading = true + } + + mutating func finishLoading() { + isLoading = false + } + + mutating func incrementPage() { + currentPage += 1 + } +} \ No newline at end of file diff --git a/HotSpot/Sources/Domain/Model/SearchResultModel.swift b/HotSpot/Sources/Domain/Model/SearchResultModel.swift new file mode 100644 index 0000000..10f0bbc --- /dev/null +++ b/HotSpot/Sources/Domain/Model/SearchResultModel.swift @@ -0,0 +1,29 @@ +import Foundation + +struct SearchResultModel { + let shops: [ShopModel] + let currentPage: Int + let hasMore: Bool + let totalCount: Int + + init(shops: [ShopModel], currentPage: Int, hasMore: Bool, totalCount: Int) { + self.shops = shops + self.currentPage = currentPage + self.hasMore = hasMore + self.totalCount = totalCount + } + + static func from(response: ShopSearchResponseDTO, currentPage: Int) -> SearchResultModel { + let shops = response.results.shop.map { $0.toDomain() } + let resultsReturned = Int(response.results.resultsReturned) ?? 1 + let currentEnd = response.results.resultsStart + resultsReturned + let hasMore = currentEnd < response.results.resultsAvailable + + return SearchResultModel( + shops: shops, + currentPage: currentPage, + hasMore: hasMore, + totalCount: response.results.resultsAvailable + ) + } +} \ No newline at end of file diff --git a/HotSpot/Sources/Domain/Model/ShopModel.swift b/HotSpot/Sources/Domain/Model/ShopModel.swift new file mode 100644 index 0000000..082085f --- /dev/null +++ b/HotSpot/Sources/Domain/Model/ShopModel.swift @@ -0,0 +1,13 @@ +import Foundation + +struct ShopModel: Identifiable, Equatable { + let id: String + let name: String + let address: String + let latitude: Double + let longitude: Double + let imageUrl: String + let access: String + let openingHours: String? + let genreCode: String +} diff --git a/HotSpot/Sources/Domain/Repository/ShopRepository.swift b/HotSpot/Sources/Domain/Repository/ShopRepository.swift new file mode 100644 index 0000000..512a093 --- /dev/null +++ b/HotSpot/Sources/Domain/Repository/ShopRepository.swift @@ -0,0 +1,5 @@ +import Foundation + +protocol ShopRepository { + func searchShops(request: ShopSearchRequestDTO) async throws -> ShopSearchResponseDTO +} diff --git a/HotSpot/Sources/Domain/Service/RestaurantClient.swift b/HotSpot/Sources/Domain/Service/RestaurantClient.swift deleted file mode 100644 index 114de23..0000000 --- a/HotSpot/Sources/Domain/Service/RestaurantClient.swift +++ /dev/null @@ -1,39 +0,0 @@ -import Foundation -import ComposableArchitecture -import Dependencies - -struct RestaurantClient: DependencyKey { - static var liveValue: RestaurantClient = .live - - var searchRestaurants: @Sendable (_ latitude: Double, _ longitude: Double, _ radius: Int) async throws -> [Restaurant] - - static let live = Self( - searchRestaurants: { latitude, longitude, radius in - // TODO: Implement actual API call - // For now, return mock data - return [ - Restaurant( - name: "맛있는 식당", - address: "서울시 강남구 테헤란로 123", - imageURL: URL(string: "https://example.com/image1.jpg"), - phone: "02-123-4567", - location: Location(lat: latitude, lon: longitude) - ), - Restaurant( - name: "맛있는 카페", - address: "서울시 강남구 테헤란로 456", - imageURL: URL(string: "https://example.com/image2.jpg"), - phone: "02-765-4321", - location: Location(lat: latitude + 0.001, lon: longitude + 0.001) - ) - ] - } - ) -} - -extension DependencyValues { - var restaurantClient: RestaurantClient { - get { self[RestaurantClient.self] } - set { self[RestaurantClient.self] = newValue } - } -} \ No newline at end of file diff --git a/HotSpot/Sources/Domain/UseCase/InfiniteScrollSearchUseCase.swift b/HotSpot/Sources/Domain/UseCase/InfiniteScrollSearchUseCase.swift new file mode 100644 index 0000000..7fb3bc1 --- /dev/null +++ b/HotSpot/Sources/Domain/UseCase/InfiniteScrollSearchUseCase.swift @@ -0,0 +1,40 @@ +import Foundation + +struct InfiniteScrollSearchUseCase { + private let repository: ShopRepository + private let pageSize: Int + + init(repository: ShopRepository, pageSize: Int = 20) { + self.repository = repository + self.pageSize = pageSize + } + + func execute( + request: ShopSearchRequestDTO, + currentPage: Int, + isLoadMore: Bool = false + ) async throws -> SearchResultModel { + let targetPage = isLoadMore ? currentPage + 1 : currentPage + let start = (targetPage - 1) * pageSize + 1 + + let paginatedRequest = ShopSearchRequestDTO( + lat: request.lat, + lng: request.lng, + range: request.range, + count: pageSize, + keyword: request.keyword, + genre: request.genre, + order: request.order, + start: start, + budget: request.budget, + privateRoom: request.privateRoom, + wifi: request.wifi, + nonSmoking: request.nonSmoking, + coupon: request.coupon, + openNow: request.openNow + ) + + let response = try await repository.searchShops(request: paginatedRequest) + return SearchResultModel.from(response: response, currentPage: targetPage) + } +} diff --git a/HotSpot/Sources/Domain/UseCase/ShopsUseCase.swift b/HotSpot/Sources/Domain/UseCase/ShopsUseCase.swift new file mode 100644 index 0000000..dfddd83 --- /dev/null +++ b/HotSpot/Sources/Domain/UseCase/ShopsUseCase.swift @@ -0,0 +1,31 @@ +import Foundation + +struct ShopsUseCase { + private let repository: ShopRepository + + init(repository: ShopRepository) { + self.repository = repository + } + + func execute(lat: Double, lng: Double) async throws -> [ShopModel] { + let request = ShopSearchRequestDTO( + lat: lat, + lng: lng, + range: 5, + count: nil, + keyword: nil, + genre: nil, + order: nil, + start: nil, + budget: nil, + privateRoom: nil, + wifi: nil, + nonSmoking: nil, + coupon: nil, + openNow: nil + ) + + let response = try await repository.searchShops(request: request) + return response.results.shop.map { $0.toDomain() } + } +} diff --git a/HotSpot/Sources/Presentation/Common/ErrorMapper/ShopErrorMessageMapper.swift b/HotSpot/Sources/Presentation/Common/ErrorMapper/ShopErrorMessageMapper.swift new file mode 100644 index 0000000..f37784c --- /dev/null +++ b/HotSpot/Sources/Presentation/Common/ErrorMapper/ShopErrorMessageMapper.swift @@ -0,0 +1,16 @@ +import Foundation + +struct ShopErrorMessageMapper { + static func message(for error: ShopError) -> String { + switch error { + case .network: + return "ネットワークに接続できません。通信環境をご確認ください。" + case .decoding: + return "データの解析に失敗しました。" + case let .server(message): + return "サーバーエラー: \(message)" + case .unknown: + return "不明なエラーが発生しました。" + } + } +} diff --git a/HotSpot/Sources/Presentation/Coordinator/AppCoordinator.swift b/HotSpot/Sources/Presentation/Coordinator/AppCoordinator.swift index 16351e6..5edf3c1 100644 --- a/HotSpot/Sources/Presentation/Coordinator/AppCoordinator.swift +++ b/HotSpot/Sources/Presentation/Coordinator/AppCoordinator.swift @@ -5,23 +5,28 @@ import ComposableArchitecture struct AppCoordinator { struct State: Equatable { var map: MapStore.State = .init() - var search: SearchStore.State? = nil - var restaurantDetail: RestaurantDetailStore.State? - var selectedRestaurantId: UUID? + var search: SearchStore.State? + var shopDetail: ShopDetailStore.State? + var selectedShop: ShopModel? + var isDetailPresented: Bool = false } enum Action { case map(MapStore.Action) case search(SearchStore.Action) - case restaurantDetail(RestaurantDetailStore.Action) + case shopDetail(ShopDetailStore.Action) - case showRestaurantDetail(UUID) + case showShopDetail(ShopModel) case showSearch case dismissSearch case dismissDetail } var body: some ReducerOf { + Scope(state: \.map, action: \.map) { + MapStore() + } + Reduce { state, action in switch action { case .map(.showSearch), .showSearch: @@ -32,26 +37,48 @@ struct AppCoordinator { state.search = nil return .none - case let .search(.selectRestaurant(restaurant)): - state.selectedRestaurantId = restaurant.id - return .send(.showRestaurantDetail(restaurant.id)) + case let .search(.selectShop(shop)): + state.selectedShop = shop + state.isDetailPresented = true + return .send(.showShopDetail(shop)) + + case let .showShopDetail(shop): + state.shopDetail = .init(shop: shop) + state.isDetailPresented = true + return .none - case let .showRestaurantDetail(id): - state.restaurantDetail = .init(restaurantId: id) + case .shopDetail(.pop): + state.shopDetail = nil + state.selectedShop = nil + state.isDetailPresented = false return .none - case .restaurantDetail(.pop), .dismissDetail: - state.restaurantDetail = nil - state.selectedRestaurantId = nil + case .dismissDetail: + state.shopDetail = nil + state.selectedShop = nil + state.isDetailPresented = false return .none - case let .map(.showRestaurantDetail(id)): - state.selectedRestaurantId = id - return .send(.showRestaurantDetail(id)) + case let .map(.showShopDetail(shop)): + state.selectedShop = shop + state.isDetailPresented = true + return .send(.showShopDetail(shop)) + + case .map: + return .none - case .map, .search, .restaurantDetail: + case .search: + return .none + + case .shopDetail: return .none } } + .ifLet(\.search, action: \.search) { + SearchStore() + } + .ifLet(\.shopDetail, action: \.shopDetail) { + ShopDetailStore() + } } } diff --git a/HotSpot/Sources/Presentation/Coordinator/AppCoordinatorView.swift b/HotSpot/Sources/Presentation/Coordinator/AppCoordinatorView.swift index 2b7ecd3..3ccc38b 100644 --- a/HotSpot/Sources/Presentation/Coordinator/AppCoordinatorView.swift +++ b/HotSpot/Sources/Presentation/Coordinator/AppCoordinatorView.swift @@ -7,43 +7,79 @@ struct AppCoordinatorView: View { var body: some View { WithViewStore(store, observe: { $0 }) { viewStore in NavigationView { - VStack { - MapView(store: store.scope(state: \.map, action: \.map)) - - NavigationLink( - destination: IfLetStore( - store.scope(state: \.search, action: \.search), - then: { store in - SearchView(store: store) - } - ), - isActive: viewStore.binding( - get: { $0.search != nil }, - send: { $0 ? .showSearch : .dismissSearch } - ) - ) { - EmptyView() - } - .hidden() - - NavigationLink( - destination: IfLetStore( - store.scope(state: \.restaurantDetail, action: \.restaurantDetail), - then: { store in - RestaurantDetailView(store: store) - } - ), - isActive: viewStore.binding( - get: { $0.restaurantDetail != nil }, - send: { $0 ? .showRestaurantDetail(viewStore.selectedRestaurantId ?? UUID()) : .dismissDetail } - ) - ) { - EmptyView() - } - .hidden() - } + MapView(store: store.scope(state: \.map, action: \.map)) + .background( + Group { + searchNavigationLink(viewStore: viewStore) + mapToDetailNavigationLink(viewStore: viewStore) + } + ) } .navigationViewStyle(.stack) } } + + private func searchNavigationLink(viewStore: ViewStore) -> some View { + NavigationLink( + destination: IfLetStore( + store.scope(state: \.search, action: \.search), + then: { store in + SearchView(store: store) + .background(searchToDetailNavigationLink(viewStore: viewStore)) + } + ), + isActive: viewStore.binding( + get: { $0.search != nil }, + send: { $0 ? .showSearch : .dismissSearch } + ) + ) { + EmptyView() + } + .hidden() + } + + private func mapToDetailNavigationLink(viewStore: ViewStore) -> some View { + NavigationLink( + destination: IfLetStore( + store.scope(state: \.shopDetail, action: \.shopDetail), + then: { store in + ShopDetailView(store: store) + } + ), + isActive: viewStore.binding( + get: { $0.isDetailPresented && $0.search == nil }, + send: { $0 ? .showShopDetail(viewStore.selectedShop!) : .dismissDetail } + ) + ) { + EmptyView() + } + .hidden() + } + + private func searchToDetailNavigationLink(viewStore: ViewStore) -> some View { + NavigationLink( + destination: IfLetStore( + store.scope(state: \.shopDetail, action: \.shopDetail), + then: { store in + ShopDetailView(store: store) + } + ), + isActive: viewStore.binding( + get: { $0.isDetailPresented && $0.search != nil }, + send: { $0 ? .showShopDetail(viewStore.selectedShop!) : .dismissDetail } + ) + ) { + EmptyView() + } + .hidden() + } +} + +#Preview { + AppCoordinatorView( + store: Store( + initialState: AppCoordinator.State(), + reducer: { AppCoordinator() } + ) + ) } diff --git a/HotSpot/Sources/Presentation/Map/Component/MapRepresentableView.swift b/HotSpot/Sources/Presentation/Map/Component/MapRepresentableView.swift index ddb3a36..fc2d635 100644 --- a/HotSpot/Sources/Presentation/Map/Component/MapRepresentableView.swift +++ b/HotSpot/Sources/Presentation/Map/Component/MapRepresentableView.swift @@ -1,159 +1,60 @@ import SwiftUI import MapKit -import CobyDS - -// Custom Annotation Class -class RestaurantAnnotation: NSObject, MKAnnotation { - var coordinate: CLLocationCoordinate2D - var title: String? - - init(coordinate: CLLocationCoordinate2D, title: String?) { - self.coordinate = coordinate - self.title = title - } -} - -// Custom Annotation View Class -class RestaurantAnnotationView: MKAnnotationView { - static let reuseIdentifier = "RestaurantAnnotationView" - - override var annotation: MKAnnotation? { - willSet { - guard let restaurantAnnotation = newValue as? RestaurantAnnotation else { return } - - canShowCallout = true - calloutOffset = CGPoint(x: -5, y: 5) - rightCalloutAccessoryView = UIButton(type: .detailDisclosure) - } - } - - private var iconImageView: UIImageView? - - override init(annotation: MKAnnotation?, reuseIdentifier: String?) { - super.init(annotation: annotation, reuseIdentifier: reuseIdentifier) - setupView() - } - - required init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) - setupView() - } - - private func setupView() { - // Circle background - let backgroundView = UIView(frame: CGRect(x: 0, y: 0, width: 40, height: 40)) - backgroundView.backgroundColor = UIColor(Color.staticBlack) - backgroundView.layer.cornerRadius = 20 - backgroundView.layer.masksToBounds = true - - // Add border to backgroundView - backgroundView.layer.borderColor = UIColor(Color.lineNormalNormal).cgColor - backgroundView.layer.borderWidth = 1.0 - - // White icon - let iconImageView = UIImageView(frame: CGRect(x: 0, y: 0, width: 24, height: 24)) - iconImageView.contentMode = .scaleAspectFit - iconImageView.tintColor = UIColor(Color.staticWhite) - - backgroundView.addSubview(iconImageView) - addSubview(backgroundView) - - self.iconImageView = iconImageView - } - - override func layoutSubviews() { - super.layoutSubviews() - subviews.first?.center = CGPoint(x: bounds.size.width / 2, y: bounds.size.height / 2) - iconImageView?.center = CGPoint(x: 20, y: 20) // Center the icon within the circle - } -} - struct MapRepresentableView: UIViewRepresentable { - - @Binding var restaurants: [Restaurant] - @Binding var topLeft: Location? - @Binding var bottomRight: Location? - - init( - restaurants: Binding<[Restaurant]>, - topLeft: Binding, - bottomRight: Binding - ) { - self._restaurants = restaurants - self._topLeft = topLeft - self._bottomRight = bottomRight - } - - class Coordinator: NSObject, MKMapViewDelegate, CLLocationManagerDelegate { + var shops: [ShopModel] + var region: Binding + + class Coordinator: NSObject, MKMapViewDelegate { var parent: MapRepresentableView - var mapView: MKMapView? - + init(parent: MapRepresentableView) { self.parent = parent - super.init() } - + func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) { - self.parent.topLeft = mapView.convert(CGPoint(x: 0, y: 0), toCoordinateFrom: mapView).toLocation() - self.parent.bottomRight = mapView.convert(CGPoint(x: mapView.frame.width, y: mapView.frame.height), toCoordinateFrom: mapView).toLocation() + DispatchQueue.main.async { + self.parent.region.wrappedValue = mapView.region + } } - + func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? { - guard let annotation = annotation as? RestaurantAnnotation else { return nil } + if let cluster = annotation as? MKClusterAnnotation { + return ShopClusterAnnotationView(annotation: cluster, reuseIdentifier: "cluster") + } - let annotationView: RestaurantAnnotationView - if let dequeuedView = mapView.dequeueReusableAnnotationView(withIdentifier: RestaurantAnnotationView.reuseIdentifier) as? RestaurantAnnotationView { - dequeuedView.annotation = annotation - annotationView = dequeuedView - } else { - annotationView = RestaurantAnnotationView(annotation: annotation, reuseIdentifier: RestaurantAnnotationView.reuseIdentifier) - annotationView.annotation = annotation + if let shopAnnotation = annotation as? ShopAnnotation { + return ShopAnnotationView(annotation: shopAnnotation, reuseIdentifier: ShopAnnotationView.reuseIdentifier) } - return annotationView + + return nil } } - + func makeCoordinator() -> Coordinator { - return Coordinator(parent: self) + Coordinator(parent: self) } - + func makeUIView(context: Context) -> MKMapView { - let mapView = MKMapView(frame: .zero) - context.coordinator.mapView = mapView + let mapView = MKMapView() mapView.delegate = context.coordinator mapView.showsUserLocation = true + mapView.register(ShopAnnotationView.self, forAnnotationViewWithReuseIdentifier: ShopAnnotationView.reuseIdentifier) return mapView } - + func updateUIView(_ uiView: MKMapView, context: Context) { - print("MapRepresentableView updateUIView called") - print("Restaurants count: \(self.restaurants.count)") - self.addMarkersToMapView(uiView) - - // Set initial region if needed - if uiView.annotations.isEmpty { - let region = MKCoordinateRegion( - center: CLLocationCoordinate2D(latitude: 37.5665, longitude: 126.9780), - span: MKCoordinateSpan(latitudeDelta: 0.5, longitudeDelta: 0.5) - ) - uiView.setRegion(region, animated: true) - } - } - - private func addMarkersToMapView(_ mapView: MKMapView) { - mapView.removeAnnotations(mapView.annotations) - - print("Adding markers for \(self.restaurants.count) restaurants") - let annotations = self.restaurants.compactMap { restaurant -> RestaurantAnnotation? in - guard let coordinate = restaurant.location?.toCLLocationCoordinate2D() else { return nil } - return RestaurantAnnotation( - coordinate: coordinate, - title: restaurant.name + uiView.removeAnnotations(uiView.annotations) + + let annotations = shops.map { + ShopAnnotation( + coordinate: CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude), + title: $0.name, + shopId: $0.id, + genreCode: $0.genreCode ) } - print("Created \(annotations.count) annotations") - - mapView.addAnnotations(annotations) + + uiView.addAnnotations(annotations) } } diff --git a/HotSpot/Sources/Presentation/Map/Component/ShopAnnotation.swift b/HotSpot/Sources/Presentation/Map/Component/ShopAnnotation.swift new file mode 100644 index 0000000..659decf --- /dev/null +++ b/HotSpot/Sources/Presentation/Map/Component/ShopAnnotation.swift @@ -0,0 +1,15 @@ +import MapKit + +class ShopAnnotation: NSObject, MKAnnotation { + var coordinate: CLLocationCoordinate2D + var title: String? + var shopId: String + var genreCode: String + + init(coordinate: CLLocationCoordinate2D, title: String?, shopId: String, genreCode: String) { + self.coordinate = coordinate + self.title = title + self.shopId = shopId + self.genreCode = genreCode + } +} \ No newline at end of file diff --git a/HotSpot/Sources/Presentation/Map/Component/ShopAnnotationView.swift b/HotSpot/Sources/Presentation/Map/Component/ShopAnnotationView.swift new file mode 100644 index 0000000..02ce185 --- /dev/null +++ b/HotSpot/Sources/Presentation/Map/Component/ShopAnnotationView.swift @@ -0,0 +1,32 @@ +import UIKit +import MapKit + +class ShopAnnotationView: MKMarkerAnnotationView { + static let reuseIdentifier = "ShopAnnotationView" + + override var annotation: MKAnnotation? { + willSet { + guard let shopAnnotation = newValue as? ShopAnnotation else { return } + clusteringIdentifier = "Shop" + canShowCallout = false + isEnabled = false + markerTintColor = ShopGenre.color(for: shopAnnotation.genreCode) + glyphImage = ShopGenre.image(for: shopAnnotation.genreCode) + } + } + + override init(annotation: MKAnnotation?, reuseIdentifier: String?) { + super.init(annotation: annotation, reuseIdentifier: reuseIdentifier) + setupView() + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + setupView() + } + + private func setupView() { + frame = CGRect(x: 0, y: 0, width: 40, height: 40) + centerOffset = CGPoint(x: 0, y: -frame.size.height / 2) + } +} diff --git a/HotSpot/Sources/Presentation/Map/Component/ShopClusterAnnotationView.swift b/HotSpot/Sources/Presentation/Map/Component/ShopClusterAnnotationView.swift new file mode 100644 index 0000000..b051489 --- /dev/null +++ b/HotSpot/Sources/Presentation/Map/Component/ShopClusterAnnotationView.swift @@ -0,0 +1,56 @@ +import UIKit +import MapKit + +class ShopClusterAnnotationView: MKAnnotationView { + override var annotation: MKAnnotation? { + didSet { + guard let cluster = annotation as? MKClusterAnnotation else { return } + displayPriority = .defaultHigh + + let rect = CGRect(x: 0, y: 0, width: 40, height: 40) + image = UIGraphicsImageRenderer.image(for: cluster.memberAnnotations, in: rect) + } + } + + override init(annotation: MKAnnotation?, reuseIdentifier: String?) { + super.init(annotation: annotation, reuseIdentifier: reuseIdentifier) + collisionMode = .circle + centerOffset = CGPoint(x: 0, y: -10) + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + } +} + +extension UIGraphicsImageRenderer { + static func image(for annotations: [MKAnnotation], in rect: CGRect) -> UIImage { + let renderer = UIGraphicsImageRenderer(size: rect.size) + + let totalCount = annotations.count + let countText = "\(totalCount)" + + return renderer.image { _ in + UIColor.black.setFill() + UIBezierPath(ovalIn: rect).fill() + + UIColor.white.setFill() + UIBezierPath(ovalIn: CGRect(x: 8, y: 8, width: 24, height: 24)).fill() + + let attributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: UIColor.black, + .font: UIFont.boldSystemFont(ofSize: 14) + ] + + let textSize = countText.size(withAttributes: attributes) + let textRect = CGRect( + x: (rect.width - textSize.width) / 2, + y: (rect.height - textSize.height) / 2, + width: textSize.width, + height: textSize.height + ) + + countText.draw(in: textRect, withAttributes: attributes) + } + } +} \ No newline at end of file diff --git a/HotSpot/Sources/Presentation/Map/MapStore.swift b/HotSpot/Sources/Presentation/Map/MapStore.swift index c73982a..832661f 100644 --- a/HotSpot/Sources/Presentation/Map/MapStore.swift +++ b/HotSpot/Sources/Presentation/Map/MapStore.swift @@ -1,86 +1,120 @@ import Foundation - +import CoreLocation import ComposableArchitecture +import MapKit @Reducer -struct MapStore: Reducer { +struct MapStore { + @Dependency(\.shopRepository) var shopRepository + struct State: Equatable { - var topLeft: Location? = Location(lat: 37.5665, lon: 126.9780) - var bottomRight: Location? = Location(lat: 37.5665, lon: 126.9780) - var restaurants: [Restaurant] = [ - Restaurant( - id: UUID(), - name: "BBQ치킨 강남점", - address: "서울시 강남구 테헤란로 123", - imageURL: URL(string: "https://example.com/image1.jpg"), - phone: "02-123-4567", - location: Location(lat: 37.5665, lon: 126.9780) - ), - Restaurant( - id: UUID(), - name: "BHC치킨 홍대점", - address: "서울시 마포구 홍대입구로 123", - imageURL: URL(string: "https://example.com/image2.jpg"), - phone: "02-234-5678", - location: Location(lat: 37.5665, lon: 126.9780) - ), - Restaurant( - id: UUID(), - name: "교촌치킨 이태원점", - address: "서울시 용산구 이태원로 123", - imageURL: URL(string: "https://example.com/image3.jpg"), - phone: "02-345-6789", - location: Location(lat: 37.5665, lon: 126.9780) - ) - ] - var selectedRestaurantId: UUID? = nil + var shops: [ShopModel] = [] + var visibleShops: [ShopModel] = [] + var selectedShop: ShopModel? = nil + var region: MKCoordinateRegion = MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: 35.6762, longitude: 139.6503), + span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01) + ) + var error: String? = nil + var lastFetchedLocation: CLLocationCoordinate2D? = nil } - + enum Action { - case updateTopLeft(Location?) - case updateBottomRight(Location?) - case getRestaurants - case getRestaurantsResponse(TaskResult<[Restaurant]>) - case onAppear - case fetchRestaurants - case fetchRestaurantsResponse(TaskResult<[Restaurant]>) + case updateRegion(MKCoordinateRegion) + case fetchShops + case updateShops([ShopModel]) + case handleError(Error) case showSearch - case showRestaurantDetail(UUID) + case showShopDetail(ShopModel) } - + var body: some ReducerOf { Reduce { state, action in switch action { - case let .updateTopLeft(location): - state.topLeft = location - return .none - case let .updateBottomRight(location): - state.bottomRight = location - return .none - case .getRestaurants: - return .none - case let .getRestaurantsResponse(.success(restaurants)): - state.restaurants = restaurants - return .none - case let .getRestaurantsResponse(.failure(error)): - print(error.localizedDescription) - return .none - case .onAppear: - print("MapStore onAppear action received") - return .none - case .fetchRestaurants: + case let .updateRegion(region): + state.region = region + + if shouldFetchNewData(state: state, newRegion: region) { + state.lastFetchedLocation = region.center + return .send(.fetchShops) + } + + state.visibleShops = filterVisibleShops(state.shops, in: region) return .none - case let .fetchRestaurantsResponse(.success(restaurants)): - state.restaurants = restaurants + + case .fetchShops: + return .run { [region = state.region] send in + do { + let useCase = ShopsUseCase(repository: shopRepository) + let shops = try await useCase.execute( + lat: region.center.latitude, + lng: region.center.longitude + ) + await send(.updateShops(shops)) + } catch { + await send(.handleError(error)) + } + } + + case let .updateShops(shops): + state.shops = shops + state.visibleShops = filterVisibleShops(shops, in: state.region) return .none - case .fetchRestaurantsResponse(.failure): + + case let .handleError(error): + state.error = error.localizedDescription return .none + case .showSearch: return .none - case let .showRestaurantDetail(id): - state.selectedRestaurantId = id + + case let .showShopDetail(shop): + state.selectedShop = shop return .none } } } + + // MARK: - Helpers + func shouldFetchNewData(state: State, newRegion: MKCoordinateRegion) -> Bool { + guard let lastLocation = state.lastFetchedLocation else { + return true + } + + let distance = CLLocation(latitude: lastLocation.latitude, longitude: lastLocation.longitude) + .distance(from: CLLocation(latitude: newRegion.center.latitude, longitude: newRegion.center.longitude)) + + return distance > 100 + } + + func filterVisibleShops(_ shops: [ShopModel], in region: MKCoordinateRegion) -> [ShopModel] { + shops.filter { shop in + let coordinate = CLLocationCoordinate2D(latitude: shop.latitude, longitude: shop.longitude) + let latMin = region.center.latitude - region.span.latitudeDelta / 2 + let latMax = region.center.latitude + region.span.latitudeDelta / 2 + let lonMin = region.center.longitude - region.span.longitudeDelta / 2 + let lonMax = region.center.longitude + region.span.longitudeDelta / 2 + + return coordinate.latitude >= latMin && + coordinate.latitude <= latMax && + coordinate.longitude >= lonMin && + coordinate.longitude <= lonMax + } + } +} + +// MARK: - Equatable +extension MapStore.State { + static func == (lhs: MapStore.State, rhs: MapStore.State) -> Bool { + lhs.shops == rhs.shops && + lhs.visibleShops == rhs.visibleShops && + lhs.selectedShop == rhs.selectedShop && + lhs.region.center.latitude == rhs.region.center.latitude && + lhs.region.center.longitude == rhs.region.center.longitude && + lhs.region.span.latitudeDelta == rhs.region.span.latitudeDelta && + lhs.region.span.longitudeDelta == rhs.region.span.longitudeDelta && + lhs.error == rhs.error && + lhs.lastFetchedLocation?.latitude == rhs.lastFetchedLocation?.latitude && + lhs.lastFetchedLocation?.longitude == rhs.lastFetchedLocation?.longitude + } } diff --git a/HotSpot/Sources/Presentation/Map/MapView.swift b/HotSpot/Sources/Presentation/Map/MapView.swift index 9ed696f..5879f25 100644 --- a/HotSpot/Sources/Presentation/Map/MapView.swift +++ b/HotSpot/Sources/Presentation/Map/MapView.swift @@ -1,11 +1,14 @@ import SwiftUI - -import CobyDS +import MapKit import ComposableArchitecture +import CobyDS +import Kingfisher struct MapView: View { let store: StoreOf - + @State private var shopImages: [String: UIImage] = [:] + @State private var lastRegion: MKCoordinateRegion? + var body: some View { WithViewStore(store, observe: { $0 }) { viewStore in VStack(spacing: 0) { @@ -18,47 +21,50 @@ struct MapView: View { viewStore.send(.showSearch) } ) - + ZStack(alignment: .bottom) { MapRepresentableView( - restaurants: viewStore.binding( - get: { $0.restaurants }, - send: { _ in .getRestaurants } - ), - topLeft: viewStore.binding( - get: { $0.topLeft }, - send: { .updateTopLeft($0) } - ), - bottomRight: viewStore.binding( - get: { $0.bottomRight }, - send: { .updateBottomRight($0) } + shops: viewStore.visibleShops, + region: viewStore.binding( + get: { $0.region }, + send: { .updateRegion($0) } ) ) .ignoresSafeArea(.all, edges: .bottom) - .onAppear { - print("MapView appeared") - viewStore.send(.onAppear) - } + // Bottom card scroll view SnappingScrollView( - items: viewStore.restaurants, + items: viewStore.visibleShops, itemWidth: BaseSize.fullWidth - ) { restaurant in + ) { shop in ThumbnailTileView( - image: nil, - title: restaurant.name, - subTitle: "", - description: restaurant.address + image: shopImages[shop.id], + title: shop.name, + subTitle: nil, + description: shop.access, + subDescription: nil ) - .frame(width: BaseSize.fullWidth, height: 120) + .frame(width: BaseSize.fullWidth) .onTapGesture { - viewStore.send(.showRestaurantDetail(restaurant.id)) + viewStore.send(.showShopDetail(shop)) + } + .onAppear { + loadImage(for: shop) } } - .frame(height: 120) .padding(.bottom, 30) } } } } + + private func loadImage(for shop: ShopModel) { + guard shopImages[shop.id] == nil else { return } + + UIImage.loadThumbnail(from: shop.imageUrl) { image in + DispatchQueue.main.async { + shopImages[shop.id] = image + } + } + } } diff --git a/HotSpot/Sources/Presentation/RestaurantDetail/RestaurantDetailStore.swift b/HotSpot/Sources/Presentation/RestaurantDetail/RestaurantDetailStore.swift deleted file mode 100644 index 5afc678..0000000 --- a/HotSpot/Sources/Presentation/RestaurantDetail/RestaurantDetailStore.swift +++ /dev/null @@ -1,57 +0,0 @@ -import Foundation -import ComposableArchitecture - -@Reducer -struct RestaurantDetailStore { - struct State: Equatable { - var restaurantId: UUID - var restaurant: Restaurant? - var isLoading: Bool = false - } - - enum Action { - case onAppear - case fetchRestaurant - case fetchRestaurantResponse(TaskResult) - case pop - } - - var body: some ReducerOf { - Reduce { state, action in - switch action { - case .onAppear: - return .run { send in - await send(.fetchRestaurant) - } - - case .fetchRestaurant: - state.isLoading = true - return .run { [id = state.restaurantId] send in - // TODO: 실제 API 호출로 대체 - try await Task.sleep(nanoseconds: 500_000_000) - let restaurant = Restaurant( - id: id, - name: "BBQ치킨 강남점", - address: "서울시 강남구 테헤란로 123", - imageURL: URL(string: "https://example.com/image1.jpg"), - phone: "02-123-4567", - location: Location(lat: 37.5665, lon: 126.9780) - ) - await send(.fetchRestaurantResponse(.success(restaurant))) - } - - case let .fetchRestaurantResponse(.success(restaurant)): - state.isLoading = false - state.restaurant = restaurant - return .none - - case .fetchRestaurantResponse(.failure): - state.isLoading = false - return .none - - case .pop: - return .none - } - } - } -} \ No newline at end of file diff --git a/HotSpot/Sources/Presentation/RestaurantDetail/RestaurantDetailView.swift b/HotSpot/Sources/Presentation/RestaurantDetail/RestaurantDetailView.swift deleted file mode 100644 index 143276d..0000000 --- a/HotSpot/Sources/Presentation/RestaurantDetail/RestaurantDetailView.swift +++ /dev/null @@ -1,79 +0,0 @@ -import SwiftUI - -import CobyDS -import ComposableArchitecture - -struct RestaurantDetailView: View { - let store: StoreOf - - var body: some View { - WithViewStore(store, observe: { $0 }) { viewStore in - VStack(spacing: 0) { - TopBarView( - leftSide: .left, - leftAction: { - viewStore.send(.pop) - }, - title: viewStore.restaurant?.name ?? "Restaurant" - ) - - if viewStore.isLoading { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let restaurant = viewStore.restaurant { - ScrollView { - VStack(spacing: 16) { - // Restaurant Image - if let imageURL = restaurant.imageURL { - AsyncImage(url: imageURL) { image in - image - .resizable() - .aspectRatio(contentMode: .fill) - } placeholder: { - Color.gray - } - .frame(height: 200) - .clipped() - } - - // Restaurant Info - VStack(alignment: .leading, spacing: 12) { - Text(restaurant.name) - .font(.title) - .fontWeight(.bold) - - Text(restaurant.address) - .font(.body) - .foregroundColor(.gray) - - if let phone = restaurant.phone { - Text(phone) - .font(.body) - .foregroundColor(.gray) - } - } - .padding() - } - } - } else { - Text("레스토랑 정보를 불러올 수 없습니다.") - .foregroundColor(.gray) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - } - .navigationBarHidden(true) - .onAppear { - viewStore.send(.onAppear) - } - } - } -} - -#Preview { - RestaurantDetailView( - store: Store( - initialState: RestaurantDetailStore.State(restaurantId: UUID()), - reducer: { RestaurantDetailStore() } - ) - ) -} diff --git a/HotSpot/Sources/Presentation/Search/Component/EmptyResults.swift b/HotSpot/Sources/Presentation/Search/Component/EmptyResults.swift new file mode 100644 index 0000000..252a1be --- /dev/null +++ b/HotSpot/Sources/Presentation/Search/Component/EmptyResults.swift @@ -0,0 +1,17 @@ +import SwiftUI + +struct EmptyResults: View { + let searchText: String + + var body: some View { + VStack(spacing: 16) { + Image(systemName: "magnifyingglass") + .font(.system(size: 48)) + .foregroundColor(.gray) + Text(searchText.isEmpty ? "검색어를 입력해주세요" : "검색 결과가 없습니다") + .font(.headline) + .foregroundColor(.gray) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} \ No newline at end of file diff --git a/HotSpot/Sources/Presentation/Search/Component/SearchBar.swift b/HotSpot/Sources/Presentation/Search/Component/SearchBar.swift new file mode 100644 index 0000000..9cbbaa0 --- /dev/null +++ b/HotSpot/Sources/Presentation/Search/Component/SearchBar.swift @@ -0,0 +1,31 @@ +import SwiftUI + +struct SearchBar: View { + let searchText: String + let onSearch: (String) -> Void + @FocusState private var isFocused: Bool + @Binding var isSearchFocused: Bool + + var body: some View { + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.gray) + TextField("검색어를 입력하세요", text: .init( + get: { searchText }, + set: { onSearch($0) } + )) + .textFieldStyle(.plain) + .focused($isFocused) + } + .padding(8) + .background(Color(.systemGray6)) + .cornerRadius(8) + .padding(.horizontal) + .onChange(of: isFocused) { newValue in + isSearchFocused = newValue + } + .onChange(of: isSearchFocused) { newValue in + isFocused = newValue + } + } +} diff --git a/HotSpot/Sources/Presentation/Search/Component/SearchResults.swift b/HotSpot/Sources/Presentation/Search/Component/SearchResults.swift new file mode 100644 index 0000000..9526797 --- /dev/null +++ b/HotSpot/Sources/Presentation/Search/Component/SearchResults.swift @@ -0,0 +1,67 @@ +import SwiftUI +import CobyDS +import Kingfisher + +struct SearchResults: View { + let error: String? + let searchText: String + let shops: [ShopModel] + let onSelectShop: (ShopModel) -> Void + let onLoadMore: () -> Void + @State private var shopImages: [String: UIImage] = [:] + + var body: some View { + Group { + if let error = error { + Text(error) + .foregroundColor(.red) + } else if searchText.isEmpty { + EmptyResults(searchText: searchText) + } else if shops.isEmpty { + EmptyResults(searchText: searchText) + } else { + ScrollView { + LazyVStack(spacing: 16) { + ForEach(shops) { shop in + ThumbnailTileView( + image: shopImages[shop.id], + title: shop.name, + subTitle: nil, + description: shop.access, + subDescription: nil + ) + .frame(width: BaseSize.fullWidth) + .onTapGesture { + onSelectShop(shop) + } + .onAppear { + loadImage(for: shop) + if shop.id == shops.last?.id { + onLoadMore() + } + } + } + } + .padding() + } + .simultaneousGesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } + ) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func loadImage(for shop: ShopModel) { + guard shopImages[shop.id] == nil else { return } + + UIImage.loadThumbnail(from: shop.imageUrl) { image in + DispatchQueue.main.async { + shopImages[shop.id] = image + } + } + } +} diff --git a/HotSpot/Sources/Presentation/Search/RestaurantCell.swift b/HotSpot/Sources/Presentation/Search/RestaurantCell.swift deleted file mode 100644 index cc29c96..0000000 --- a/HotSpot/Sources/Presentation/Search/RestaurantCell.swift +++ /dev/null @@ -1,62 +0,0 @@ -import SwiftUI -import ComposableArchitecture - -struct RestaurantCell: View { - let restaurant: Restaurant - - var body: some View { - HStack(spacing: 12) { - // Restaurant Image - AsyncImage(url: restaurant.imageURL) { image in - image - .resizable() - .aspectRatio(contentMode: .fill) - } placeholder: { - Rectangle() - .fill(Color.gray.opacity(0.2)) - } - .frame(width: 60, height: 60) - .cornerRadius(8) - - // Restaurant Info - VStack(alignment: .leading, spacing: 4) { - Text(restaurant.name) - .font(.headline) - .foregroundColor(.primary) - - Text(restaurant.address) - .font(.subheadline) - .foregroundColor(.gray) - - if let phone = restaurant.phone { - Text(phone) - .font(.subheadline) - .foregroundColor(.gray) - } - } - - Spacer() - - Image(systemName: "chevron.right") - .foregroundColor(.gray) - } - .padding(.vertical, 8) - .padding(.horizontal) - .background(Color(.systemBackground)) - } -} - -#Preview { - RestaurantCell( - restaurant: Restaurant( - id: UUID(), - name: "BBQ치킨 강남점", - address: "서울시 강남구 테헤란로 123", - imageURL: URL(string: "https://example.com/image1.jpg"), - phone: "02-123-4567", - location: Location(lat: 37.5665, lon: 126.9780) - ) - ) - .previewLayout(.sizeThatFits) - .padding() -} diff --git a/HotSpot/Sources/Presentation/Search/SearchStore.swift b/HotSpot/Sources/Presentation/Search/SearchStore.swift index eb31c15..a9d0b1d 100644 --- a/HotSpot/Sources/Presentation/Search/SearchStore.swift +++ b/HotSpot/Sources/Presentation/Search/SearchStore.swift @@ -1,98 +1,184 @@ import Foundation import CoreLocation - import ComposableArchitecture @Reducer struct SearchStore { + @Dependency(\.shopRepository) var shopRepository + @Dependency(\.locationManager) var locationManager + struct State: Equatable { + var shops: [ShopModel] = [] var searchText: String = "" - var restaurants: [Restaurant] = [] - var isLoading: Bool = false - var currentPage: Int = 1 - var hasMorePages: Bool = true + var error: String? = nil + var currentLocation: CLLocationCoordinate2D? + var selectedShop: ShopModel? = nil + var paginationState: PaginationState = .init() + + static func == (lhs: State, rhs: State) -> Bool { + lhs.shops == rhs.shops && + lhs.searchText == rhs.searchText && + lhs.error == rhs.error && + lhs.currentLocation?.latitude == rhs.currentLocation?.latitude && + lhs.currentLocation?.longitude == rhs.currentLocation?.longitude && + lhs.selectedShop == rhs.selectedShop && + lhs.paginationState.currentPage == rhs.paginationState.currentPage && + lhs.paginationState.isLastPage == rhs.paginationState.isLastPage && + lhs.paginationState.isLoading == rhs.paginationState.isLoading + } } - + enum Action { - case searchTextChanged(String) - case search - case searchResponse(TaskResult<[Restaurant]>) - case loadMore - case selectRestaurant(Restaurant) + case onAppear + case search(String) + case selectShop(ShopModel) case pop + case updateLocation(CLLocationCoordinate2D) + case updateShops([ShopModel]) + case handleError(Error) + case loadMore + case updatePaginationState(PaginationState) } - + var body: some ReducerOf { Reduce { state, action in switch action { - case let .searchTextChanged(text): - state.searchText = text + case .onAppear: + return .run { send in + if let location = await locationManager.requestLocation() { + await send(.updateLocation(location.coordinate)) + } + } + + case let .updateLocation(location): + state.currentLocation = location return .none + + case let .updateShops(shops): + state.shops = shops + return .none + + case let .handleError(error): + state.error = error.localizedDescription + return .none + + case let .selectShop(shop): + state.selectedShop = shop + return .none + + case .pop: + return .none + + case let .search(text): + guard text != state.searchText else { return .none } - case .search: - guard !state.searchText.isEmpty else { return .none } - - state.isLoading = true - state.currentPage = 1 - state.restaurants = [] - state.hasMorePages = true + state.searchText = text + state.paginationState.reset() + print("Search started - text: \(text)") - return .run { [text = state.searchText] send in - try await Task.sleep(nanoseconds: 500_000_000) // Simulate network delay - let restaurants = generateDummyRestaurants(for: text) - await send(.searchResponse(.success(restaurants))) + return .run { [state] send in + do { + let location = state.currentLocation ?? CLLocationCoordinate2D(latitude: 34.6937, longitude: 135.5023) + let request = ShopSearchRequestDTO( + lat: location.latitude, + lng: location.longitude, + range: 5, + count: nil, + keyword: text, + genre: nil, + order: nil, + start: nil, + budget: nil, + privateRoom: nil, + wifi: nil, + nonSmoking: nil, + coupon: nil, + openNow: nil + ) + + let useCase = InfiniteScrollSearchUseCase(repository: shopRepository) + let result = try await useCase.execute( + request: request, + currentPage: 1 + ) + + print("Search result - currentPage: \(result.currentPage), hasMore: \(result.hasMore), shops count: \(result.shops.count)") + + await send(.updateShops(result.shops)) + await send(.updatePaginationState(PaginationState( + currentPage: result.currentPage, + isLastPage: !result.hasMore, + isLoading: false + ))) + } catch { + await send(.handleError(error)) + } } - case let .searchResponse(.success(restaurants)): - state.isLoading = false - state.restaurants = restaurants - return .none - - case .searchResponse(.failure): - state.isLoading = false - return .none - case .loadMore: - return .none + guard !state.paginationState.isLoading && !state.paginationState.isLastPage else { + print("LoadMore skipped - isLoading: \(state.paginationState.isLoading), isLastPage: \(state.paginationState.isLastPage), currentPage: \(state.paginationState.currentPage)") + return .none + } - case let .selectRestaurant(restaurant): - return .none + state.paginationState.startLoading() + print("Loading more - currentPage: \(state.paginationState.currentPage)") - case .pop: + return .run { [state] send in + do { + let location = state.currentLocation ?? CLLocationCoordinate2D(latitude: 34.6937, longitude: 135.5023) + let request = ShopSearchRequestDTO( + lat: location.latitude, + lng: location.longitude, + range: 5, + count: nil, + keyword: state.searchText, + genre: nil, + order: nil, + start: nil, + budget: nil, + privateRoom: nil, + wifi: nil, + nonSmoking: nil, + coupon: nil, + openNow: nil + ) + + let useCase = InfiniteScrollSearchUseCase(repository: shopRepository) + let result = try await useCase.execute( + request: request, + currentPage: state.paginationState.currentPage, + isLoadMore: true + ) + + print("LoadMore result - currentPage: \(result.currentPage), hasMore: \(result.hasMore), shops count: \(result.shops.count)") + + // Create a Set of existing shop IDs for quick lookup + let existingShopIds = Set(state.shops.map { $0.id }) + // Filter out any shops that are already in the list + let newShops = result.shops.filter { !existingShopIds.contains($0.id) } + + await send(.updateShops(state.shops + newShops)) + await send(.updatePaginationState(PaginationState( + currentPage: result.currentPage, + isLastPage: !result.hasMore, + isLoading: false + ))) + } catch { + await send(.handleError(error)) + await send(.updatePaginationState(PaginationState( + currentPage: state.paginationState.currentPage, + isLastPage: state.paginationState.isLastPage, + isLoading: false + ))) + } + } + + case let .updatePaginationState(newState): + state.paginationState = newState + print("PaginationState updated - currentPage: \(newState.currentPage), isLastPage: \(newState.isLastPage), isLoading: \(newState.isLoading)") return .none } } } - - private func generateDummyRestaurants(for query: String, page: Int = 1) -> [Restaurant] { - // Always return some results for testing - let restaurants = [ - Restaurant( - id: UUID(), - name: "BBQ치킨 강남점", - address: "서울시 강남구 테헤란로 123", - imageURL: URL(string: "https://example.com/image1.jpg"), - phone: "02-123-4567", - location: Location(lat: 37.5665, lon: 126.9780) - ), - Restaurant( - id: UUID(), - name: "BHC치킨 홍대점", - address: "서울시 마포구 홍대입구로 123", - imageURL: URL(string: "https://example.com/image2.jpg"), - phone: "02-234-5678", - location: Location(lat: 37.5665, lon: 126.9780) - ), - Restaurant( - id: UUID(), - name: "교촌치킨 이태원점", - address: "서울시 용산구 이태원로 123", - imageURL: URL(string: "https://example.com/image3.jpg"), - phone: "02-345-6789", - location: Location(lat: 37.5665, lon: 126.9780) - ) - ] - - return restaurants - } } diff --git a/HotSpot/Sources/Presentation/Search/SearchView.swift b/HotSpot/Sources/Presentation/Search/SearchView.swift index bfc0f5f..072f7d0 100644 --- a/HotSpot/Sources/Presentation/Search/SearchView.swift +++ b/HotSpot/Sources/Presentation/Search/SearchView.swift @@ -6,8 +6,7 @@ import ComposableArchitecture struct SearchView: View { let store: StoreOf - @FocusState private var isSearchFocused: Bool - @State private var localSearchText: String = "" + @State private var isSearchFocused = false var body: some View { WithViewStore(store, observe: { $0 }) { viewStore in @@ -17,98 +16,34 @@ struct SearchView: View { leftSide: .left, leftAction: { viewStore.send(.pop) - }, - title: "Search" + } ) } - // Search Bar - HStack(spacing: 12) { - Image(systemName: "magnifyingglass") - .foregroundColor(.gray) - - TextField("검색어를 입력하세요", text: $localSearchText) - .textFieldStyle(.plain) - .focused($isSearchFocused) - .onChange(of: localSearchText) { newValue in - viewStore.send(.searchTextChanged(newValue)) - } - .onSubmit { - if !localSearchText.isEmpty { - isSearchFocused = false - viewStore.send(.search) - } - } - .submitLabel(.search) - .autocapitalization(.none) - .disableAutocorrection(true) - - if !localSearchText.isEmpty { - Button { - localSearchText = "" - viewStore.send(.searchTextChanged("")) - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundColor(.gray) - } - } - - if isSearchFocused { - Button { - localSearchText = "" - viewStore.send(.searchTextChanged("")) - isSearchFocused = false - } label: { - Text("취소") - .foregroundColor(.blue) - } - } - } - .padding(12) - .background(Color(.systemGray6)) - .cornerRadius(8) - .padding(.horizontal) - .padding(.top, isSearchFocused ? 16 : 0) - .animation(.easeInOut, value: isSearchFocused) + SearchBar( + searchText: viewStore.searchText, + onSearch: { viewStore.send(.search($0)) }, + isSearchFocused: $isSearchFocused + ) - // Search Results - ZStack { - if viewStore.isLoading { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if viewStore.restaurants.isEmpty { - VStack(spacing: 16) { - Image(systemName: "magnifyingglass") - .font(.system(size: 48)) - .foregroundColor(.gray) - Text(viewStore.searchText.isEmpty ? "검색어를 입력해주세요" : "검색 결과가 없습니다") - .font(.headline) - .foregroundColor(.gray) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - ScrollView { - LazyVStack(spacing: 0) { - ForEach(viewStore.restaurants) { restaurant in - RestaurantCell(restaurant: restaurant) - .onTapGesture { - viewStore.send(.selectRestaurant(restaurant)) - } - - if restaurant.id != viewStore.restaurants.last?.id { - Divider() - .padding(.leading) - } - } - } + SearchResults( + error: viewStore.error, + searchText: viewStore.searchText, + shops: viewStore.shops, + onSelectShop: { viewStore.send(.selectShop($0)) }, + onLoadMore: { + if !viewStore.paginationState.isLastPage { + viewStore.send(.loadMore) } } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) + ) } .navigationBarHidden(true) + .onAppear { + viewStore.send(.onAppear) + } .onTapGesture { - isSearchFocused = false + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) } } } diff --git a/HotSpot/Sources/Presentation/ShopDetail/Components/ShopImageSection.swift b/HotSpot/Sources/Presentation/ShopDetail/Components/ShopImageSection.swift new file mode 100644 index 0000000..7ca3d73 --- /dev/null +++ b/HotSpot/Sources/Presentation/ShopDetail/Components/ShopImageSection.swift @@ -0,0 +1,39 @@ +import SwiftUI +import Kingfisher +import CobyDS + +struct ShopImageSection: View { + let imageUrl: String + + var body: some View { + Group { + if let url = URL(string: imageUrl) { + KFImage(url) + .placeholder { + Image(uiImage: UIImage.icImage) + .resizable() + .frame(width: 64, height: 64) + .foregroundColor(Color.labelAlternative) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.fillStrong) + } + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: BaseSize.screenWidth, height: BaseSize.screenWidth) + .clipped() + } else { + Image(uiImage: UIImage.icImage) + .resizable() + .frame(width: 64, height: 64) + .foregroundColor(Color.labelAlternative) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.fillStrong) + .frame(width: BaseSize.screenWidth, height: BaseSize.screenWidth) + } + } + } +} + +#Preview { + ShopImageSection(imageUrl: "https://example.com/image.jpg") +} \ No newline at end of file diff --git a/HotSpot/Sources/Presentation/ShopDetail/Components/ShopInfoSection.swift b/HotSpot/Sources/Presentation/ShopDetail/Components/ShopInfoSection.swift new file mode 100644 index 0000000..0f4806b --- /dev/null +++ b/HotSpot/Sources/Presentation/ShopDetail/Components/ShopInfoSection.swift @@ -0,0 +1,75 @@ +import SwiftUI +import CobyDS + +struct ShopInfoSection: View { + let shop: ShopModel + + var body: some View { + Group { + VStack(alignment: .leading, spacing: 24) { + // Name and Genre + VStack(alignment: .leading, spacing: 8) { + Text(shop.name) + .font(.title) + .fontWeight(.bold) + + Text(ShopGenre.name(for: shop.genreCode)) + .font(.subheadline) + .foregroundColor(.gray) + } + + // Address + VStack(alignment: .leading, spacing: 4) { + Text("住所") + .font(.headline) + Text(shop.address) + .font(.body) + } + + // Access + VStack(alignment: .leading, spacing: 4) { + Text("アクセス") + .font(.headline) + Text(shop.access) + .font(.body) + } + + // Open Hours + if let openingHours = shop.openingHours { + VStack(alignment: .leading, spacing: 4) { + Text("営業時間") + .font(.headline) + Text(openingHours) + .font(.body) + } + } + + // Location + VStack(alignment: .leading, spacing: 4) { + Text("位置情報") + .font(.headline) + + ShopLocationMapView(shop: shop) + } + } + .padding(.horizontal, BaseSize.horizantalPadding) + .padding(.vertical, BaseSize.verticalPadding) + } + } +} + +#Preview { + ShopInfoSection( + shop: ShopModel( + id: "test", + name: "テスト店舗", + address: "東京都渋谷区", + latitude: 35.6762, + longitude: 139.6503, + imageUrl: "https://example.com/image.jpg", + access: "渋谷駅から徒歩5分", + openingHours: "11:00-23:00", + genreCode: "G001" + ) + ) +} diff --git a/HotSpot/Sources/Presentation/ShopDetail/Components/ShopLocationMapView.swift b/HotSpot/Sources/Presentation/ShopDetail/Components/ShopLocationMapView.swift new file mode 100644 index 0000000..eb020c5 --- /dev/null +++ b/HotSpot/Sources/Presentation/ShopDetail/Components/ShopLocationMapView.swift @@ -0,0 +1,59 @@ +import SwiftUI +import MapKit + +struct ShopLocationMapView: View { + let shop: ShopModel + + var body: some View { + GeometryReader { geometry in + let coordinate = CLLocationCoordinate2D( + latitude: shop.latitude, + longitude: shop.longitude + ) + + let region = MKCoordinateRegion( + center: coordinate, + span: MKCoordinateSpan( + latitudeDelta: 0.01, + longitudeDelta: 0.01 + ) + ) + + Map( + coordinateRegion: .constant(region), + interactionModes: [] + ) + .frame(height: 200) + .cornerRadius(8) + .overlay( + Image(systemName: "mappin.circle.fill") + .resizable() + .frame(width: 32, height: 32) + .foregroundColor(Color(uiColor: ShopGenre.color(for: shop.genreCode))) + ) + .onTapGesture { + let placemark = MKPlacemark(coordinate: coordinate) + let mapItem = MKMapItem(placemark: placemark) + mapItem.name = shop.name + mapItem.openInMaps() + } + } + .frame(height: 200) + } +} + +#Preview { + ShopLocationMapView( + shop: ShopModel( + id: "test", + name: "テスト店舗", + address: "東京都渋谷区", + latitude: 35.6762, + longitude: 139.6503, + imageUrl: "https://example.com/image.jpg", + access: "渋谷駅から徒歩5分", + openingHours: "11:00-23:00", + genreCode: "G001" + ) + ) +} \ No newline at end of file diff --git a/HotSpot/Sources/Presentation/ShopDetail/ShopDetailStore.swift b/HotSpot/Sources/Presentation/ShopDetail/ShopDetailStore.swift new file mode 100644 index 0000000..6e217d7 --- /dev/null +++ b/HotSpot/Sources/Presentation/ShopDetail/ShopDetailStore.swift @@ -0,0 +1,24 @@ +import Foundation +import ComposableArchitecture + +@Reducer +struct ShopDetailStore { + @Dependency(\.shopRepository) var shopRepository + + struct State: Equatable { + let shop: ShopModel + } + + enum Action { + case pop + } + + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .pop: + return .none + } + } + } +} diff --git a/HotSpot/Sources/Presentation/ShopDetail/ShopDetailView.swift b/HotSpot/Sources/Presentation/ShopDetail/ShopDetailView.swift new file mode 100644 index 0000000..bd30bd3 --- /dev/null +++ b/HotSpot/Sources/Presentation/ShopDetail/ShopDetailView.swift @@ -0,0 +1,53 @@ +import SwiftUI +import ComposableArchitecture +import CobyDS +import Kingfisher + +struct ShopDetailView: View { + let store: StoreOf + + var body: some View { + WithViewStore(store, observe: { $0 }) { viewStore in + Group { + VStack(spacing: 0) { + TopBarView( + leftSide: .left, + leftAction: { + viewStore.send(.pop) + } + ) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 8) { + ShopImageSection(imageUrl: viewStore.shop.imageUrl) + + ShopInfoSection(shop: viewStore.shop) + } + } + } + .navigationBarHidden(true) + } + } + } +} + +#Preview { + ShopDetailView( + store: Store( + initialState: ShopDetailStore.State( + shop: ShopModel( + id: "test", + name: "テスト店舗", + address: "東京都渋谷区", + latitude: 35.6762, + longitude: 139.6503, + imageUrl: "https://example.com/image.jpg", + access: "渋谷駅から徒歩5分", + openingHours: "11:00-23:00", + genreCode: "G001" + ) + ), + reducer: { ShopDetailStore() } + ) + ) +} diff --git a/Project.swift b/Project.swift index f63519e..ede1553 100644 --- a/Project.swift +++ b/Project.swift @@ -7,17 +7,22 @@ let bundleTestID = "com.coby.HotSpotTests" let targetVersion = "15.0" let version = "1.0.0" let bundleVersion = "0" +let baseURL = "http://webservice.recruit.co.jp/hotpepper" +let apiKey = "8011379945b3b751" let project = Project( name: projectName, organizationName: organizationName, settings: .settings( - base: SettingsDictionary() - .automaticCodeSigning(devTeam: "3Y8YH8GWMM") - .swiftVersion("5.9"), + base: [ + "BASE_URL": SettingValue(stringLiteral: baseURL), + "API_KEY": SettingValue(stringLiteral: apiKey), + "SWIFT_VERSION": SettingValue(stringLiteral: "5.9"), + "DEVELOPMENT_TEAM": SettingValue(stringLiteral: "3Y8YH8GWMM") + ], configurations: [ - .debug(name: .debug, xcconfig: "\(projectName)/Configuration/Config.xcconfig"), - .release(name: .release, xcconfig: "\(projectName)/Configuration/Config.xcconfig") + .debug(name: .debug), + .release(name: .release) ] ), targets: [ @@ -29,20 +34,24 @@ let project = Project( deploymentTargets: .iOS(targetVersion), infoPlist: .extendingDefault( with: [ - "CFBundleShortVersionString": "\(version)", - "CFBundleVersion": "\(bundleVersion)", - "CFBundleDisplayName": "\(projectName)", - "UILaunchScreen": [ - "UIColorName": "", - "UIImageName": "", - ], - "NSAppTransportSecurity": [ - "NSExceptionDomains": [ - "webservice.recruit.co.jp": [ - "NSExceptionAllowsInsecureHTTPLoads": true - ] - ] - ] + "CFBundleShortVersionString": .string(version), + "CFBundleVersion": .string(bundleVersion), + "CFBundleDisplayName": .string(projectName), + "BASE_URL": .string(baseURL), + "API_KEY": .string(apiKey), + "UILaunchScreen": .dictionary([ + "UIColorName": .string(""), + "UIImageName": .string("") + ]), + "NSAppTransportSecurity": .dictionary([ + "NSExceptionDomains": .dictionary([ + "webservice.recruit.co.jp": .dictionary([ + "NSExceptionAllowsInsecureHTTPLoads": .boolean(true) + ]) + ]) + ]), + "NSLocationWhenInUseUsageDescription": .string("周辺の店舗を表示するために位置情報が必要です。"), + "NSLocationAlwaysAndWhenInUseUsageDescription": .string("周辺の店舗を表示するために位置情報が必要です。") ] ), sources: ["\(projectName)/Sources/**"], diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index 8ebc540..14271fe 100644 --- a/Tuist/Package.resolved +++ b/Tuist/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CobyLibrary/CobyDS.git", "state" : { - "revision" : "e46b3da12bc7f7c5e159b050262bba44d87870ea", - "version" : "1.7.5" + "revision" : "4dc0668cd4efc2719e4c1e51d288b55b3f0ca2c6", + "version" : "1.7.8" } }, { diff --git a/Tuist/Package.swift b/Tuist/Package.swift index 1a75b82..853f809 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -26,7 +26,7 @@ let package = Package( .iOS(.v15) ], dependencies: [ - .package(url: "https://github.com/CobyLibrary/CobyDS.git", from: "1.7.5"), + .package(url: "https://github.com/CobyLibrary/CobyDS.git", from: "1.7.8"), .package(url: "https://github.com/Moya/Moya.git", from: "15.0.3"), .package(url: "https://github.com/pointfreeco/swift-composable-architecture.git", from: "1.19.1"), .package(url: "https://github.com/onevcat/Kingfisher.git", from: "8.3.2")