From 60cf0c4c1d37677a4ae83faecf7a8eef44a2390e Mon Sep 17 00:00:00 2001 From: coby5502 Date: Sat, 19 Apr 2025 22:52:31 +0900 Subject: [PATCH 01/21] [ADD] add api code --- HotSpot/Configuration/Config.xcconfig | 1 + .../Data/API/MoyaProvider+Extension.swift | 24 +++ HotSpot/Sources/Data/API/ServiceAPI.swift | 26 ++- .../DTO/Request/ShopSearchRequestDTO.swift | 18 ++ .../Sources/Data/DTO/Response/ShopDTO.swift | 37 ++++ .../DTO/Response/ShopSearchResponseDTO.swift | 9 + .../DTO/Response/ShopSearchResultsDTO.swift | 15 ++ .../DataSource/ShopRemoteDataSource.swift | 5 + .../DataSource/ShopRemoteDataSourceImpl.swift | 16 ++ .../Data/Repository/ShopRepositoryImpl.swift | 15 ++ HotSpot/Sources/Domain/Error/ShopError.swift | 16 ++ .../Domain/{Entity => Model}/Location.swift | 0 .../Domain/{Entity => Model}/Restaurant.swift | 6 +- HotSpot/Sources/Domain/Model/ShopModel.swift | 20 +++ .../Domain/Repository/ShopRepository.swift | 13 ++ .../Domain/Service/RestaurantClient.swift | 20 +-- .../Domain/UseCase/SearchShopsUseCase.swift | 21 +++ .../ErrorMapper/ShopErrorMessageMapper.swift | 16 ++ .../Coordinator/AppCoordinator.swift | 32 ++-- .../Coordinator/AppCoordinatorView.swift | 8 +- .../Map/Component/MapRepresentableView.swift | 162 +++++++----------- .../Sources/Presentation/Map/MapStore.swift | 109 +++++------- .../Sources/Presentation/Map/MapView.swift | 39 ++--- .../Presentation/Search/SearchStore.swift | 36 ++-- .../Presentation/Search/SearchView.swift | 10 +- .../{RestaurantCell.swift => ShopCell.swift} | 22 +-- .../ShopDetailStore.swift} | 28 +-- .../ShopDetailView.swift} | 26 +-- .../ShopRepository+Dependency.swift | 14 ++ 29 files changed, 476 insertions(+), 288 deletions(-) create mode 100644 HotSpot/Sources/Data/API/MoyaProvider+Extension.swift create mode 100644 HotSpot/Sources/Data/DTO/Request/ShopSearchRequestDTO.swift create mode 100644 HotSpot/Sources/Data/DTO/Response/ShopDTO.swift create mode 100644 HotSpot/Sources/Data/DTO/Response/ShopSearchResponseDTO.swift create mode 100644 HotSpot/Sources/Data/DTO/Response/ShopSearchResultsDTO.swift create mode 100644 HotSpot/Sources/Data/DataSource/ShopRemoteDataSource.swift create mode 100644 HotSpot/Sources/Data/DataSource/ShopRemoteDataSourceImpl.swift create mode 100644 HotSpot/Sources/Data/Repository/ShopRepositoryImpl.swift create mode 100644 HotSpot/Sources/Domain/Error/ShopError.swift rename HotSpot/Sources/Domain/{Entity => Model}/Location.swift (100%) rename HotSpot/Sources/Domain/{Entity => Model}/Restaurant.swift (63%) create mode 100644 HotSpot/Sources/Domain/Model/ShopModel.swift create mode 100644 HotSpot/Sources/Domain/Repository/ShopRepository.swift create mode 100644 HotSpot/Sources/Domain/UseCase/SearchShopsUseCase.swift create mode 100644 HotSpot/Sources/Presentation/Common/ErrorMapper/ShopErrorMessageMapper.swift rename HotSpot/Sources/Presentation/Search/{RestaurantCell.swift => ShopCell.swift} (78%) rename HotSpot/Sources/Presentation/{RestaurantDetail/RestaurantDetailStore.swift => ShopDetail/ShopDetailStore.swift} (66%) rename HotSpot/Sources/Presentation/{RestaurantDetail/RestaurantDetailView.swift => ShopDetail/ShopDetailView.swift} (76%) create mode 100644 HotSpot/Sources/Shared/Dependency/ShopRepository+Dependency.swift diff --git a/HotSpot/Configuration/Config.xcconfig b/HotSpot/Configuration/Config.xcconfig index 92c7b01..497b214 100644 --- a/HotSpot/Configuration/Config.xcconfig +++ b/HotSpot/Configuration/Config.xcconfig @@ -1 +1,2 @@ BASE_URL = http://webservice.recruit.co.jp/hotpepper +API_KEY = 8011379945b3b751 diff --git a/HotSpot/Sources/Data/API/MoyaProvider+Extension.swift b/HotSpot/Sources/Data/API/MoyaProvider+Extension.swift new file mode 100644 index 0000000..6c64823 --- /dev/null +++ b/HotSpot/Sources/Data/API/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/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..cdb0f49 --- /dev/null +++ b/HotSpot/Sources/Data/DTO/Request/ShopSearchRequestDTO.swift @@ -0,0 +1,18 @@ +import Foundation +import CoreLocation + +struct ShopSearchRequestDTO { + let lat: Double + let lng: Double + let range: Int + let count: Int + + var asParameters: [String: Any] { + return [ + "lat": lat, + "lng": lng, + "range": range, + "count": count + ] + } +} diff --git a/HotSpot/Sources/Data/DTO/Response/ShopDTO.swift b/HotSpot/Sources/Data/DTO/Response/ShopDTO.swift new file mode 100644 index 0000000..65ee2eb --- /dev/null +++ b/HotSpot/Sources/Data/DTO/Response/ShopDTO.swift @@ -0,0 +1,37 @@ +import Foundation + +struct ShopDTO: Decodable { + let id: String + let name: String + let address: String + let lat: Double + let lng: Double + let access: String + let open: String? + let photo: Photo + + struct Photo: Decodable { + let mobile: Mobile + + struct Mobile: Decodable { + let large: String + + enum CodingKeys: String, CodingKey { + case large = "l" + } + } + } + + func toDomain() -> ShopModel { + ShopModel( + id: id, + name: name, + address: address, + latitude: lat, + longitude: lng, + imageUrl: photo.mobile.large, + access: access, + openingHours: open + ) + } +} diff --git a/HotSpot/Sources/Data/DTO/Response/ShopSearchResponseDTO.swift b/HotSpot/Sources/Data/DTO/Response/ShopSearchResponseDTO.swift new file mode 100644 index 0000000..fe5206a --- /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..3ba7ccf --- /dev/null +++ b/HotSpot/Sources/Data/Repository/ShopRepositoryImpl.swift @@ -0,0 +1,15 @@ +import Foundation + +final class ShopRepositoryImpl: ShopRepository { + private let remoteDataSource: ShopRemoteDataSource + + init(remoteDataSource: ShopRemoteDataSource) { + self.remoteDataSource = remoteDataSource + } + + func searchShops(lat: Double, lng: Double, range: Int, count: Int) async throws -> [ShopModel] { + let requestDTO = ShopSearchRequestDTO(lat: lat, lng: lng, range: range, count: count) + let response = try await remoteDataSource.search(request: requestDTO) + return response.results.shop.map { $0.toDomain() } + } +} diff --git a/HotSpot/Sources/Domain/Error/ShopError.swift b/HotSpot/Sources/Domain/Error/ShopError.swift new file mode 100644 index 0000000..3ec9fe1 --- /dev/null +++ b/HotSpot/Sources/Domain/Error/ShopError.swift @@ -0,0 +1,16 @@ +// +// ShopError.swift +// HotSpot +// +// Created by Coby on 4/19/25. +// Copyright © 2025 Coby. All rights reserved. +// + +import Foundation + +enum ShopError: Error, Equatable { + case network + case decoding + case server(message: String) + case unknown +} diff --git a/HotSpot/Sources/Domain/Entity/Location.swift b/HotSpot/Sources/Domain/Model/Location.swift similarity index 100% rename from HotSpot/Sources/Domain/Entity/Location.swift rename to HotSpot/Sources/Domain/Model/Location.swift diff --git a/HotSpot/Sources/Domain/Entity/Restaurant.swift b/HotSpot/Sources/Domain/Model/Restaurant.swift similarity index 63% rename from HotSpot/Sources/Domain/Entity/Restaurant.swift rename to HotSpot/Sources/Domain/Model/Restaurant.swift index 2d81f8f..1e4d70c 100644 --- a/HotSpot/Sources/Domain/Entity/Restaurant.swift +++ b/HotSpot/Sources/Domain/Model/Restaurant.swift @@ -1,14 +1,14 @@ import Foundation -struct Restaurant: Identifiable, Equatable { - let id: UUID +struct Shop: Identifiable, Equatable { + let id: String 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?) { + init(id: String = "", name: String, address: String, imageURL: URL? = nil, phone: String? = nil, location: Location?) { self.id = id self.name = name self.address = address diff --git a/HotSpot/Sources/Domain/Model/ShopModel.swift b/HotSpot/Sources/Domain/Model/ShopModel.swift new file mode 100644 index 0000000..df67393 --- /dev/null +++ b/HotSpot/Sources/Domain/Model/ShopModel.swift @@ -0,0 +1,20 @@ +// +// ShopModel.swift +// HotSpot +// +// Created by Coby on 4/19/25. +// Copyright © 2025 Coby. All rights reserved. +// + +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? +} diff --git a/HotSpot/Sources/Domain/Repository/ShopRepository.swift b/HotSpot/Sources/Domain/Repository/ShopRepository.swift new file mode 100644 index 0000000..af4b7f6 --- /dev/null +++ b/HotSpot/Sources/Domain/Repository/ShopRepository.swift @@ -0,0 +1,13 @@ +// +// ShopRepository.swift +// HotSpot +// +// Created by Coby on 4/19/25. +// Copyright © 2025 Coby. All rights reserved. +// + +import Foundation + +protocol ShopRepository { + func searchShops(lat: Double, lng: Double, range: Int, count: Int) async throws -> [ShopModel] +} diff --git a/HotSpot/Sources/Domain/Service/RestaurantClient.swift b/HotSpot/Sources/Domain/Service/RestaurantClient.swift index 114de23..adfb555 100644 --- a/HotSpot/Sources/Domain/Service/RestaurantClient.swift +++ b/HotSpot/Sources/Domain/Service/RestaurantClient.swift @@ -2,24 +2,24 @@ import Foundation import ComposableArchitecture import Dependencies -struct RestaurantClient: DependencyKey { - static var liveValue: RestaurantClient = .live +struct ShopClient: DependencyKey { + static var liveValue: ShopClient = .live - var searchRestaurants: @Sendable (_ latitude: Double, _ longitude: Double, _ radius: Int) async throws -> [Restaurant] + var searchShops: @Sendable (_ latitude: Double, _ longitude: Double, _ radius: Int) async throws -> [Shop] static let live = Self( - searchRestaurants: { latitude, longitude, radius in + searchShops: { latitude, longitude, radius in // TODO: Implement actual API call // For now, return mock data return [ - Restaurant( + Shop( name: "맛있는 식당", address: "서울시 강남구 테헤란로 123", imageURL: URL(string: "https://example.com/image1.jpg"), phone: "02-123-4567", location: Location(lat: latitude, lon: longitude) ), - Restaurant( + Shop( name: "맛있는 카페", address: "서울시 강남구 테헤란로 456", imageURL: URL(string: "https://example.com/image2.jpg"), @@ -32,8 +32,8 @@ struct RestaurantClient: DependencyKey { } extension DependencyValues { - var restaurantClient: RestaurantClient { - get { self[RestaurantClient.self] } - set { self[RestaurantClient.self] = newValue } + var shopClient: ShopClient { + get { self[ShopClient.self] } + set { self[ShopClient.self] = newValue } } -} \ No newline at end of file +} diff --git a/HotSpot/Sources/Domain/UseCase/SearchShopsUseCase.swift b/HotSpot/Sources/Domain/UseCase/SearchShopsUseCase.swift new file mode 100644 index 0000000..ae697b2 --- /dev/null +++ b/HotSpot/Sources/Domain/UseCase/SearchShopsUseCase.swift @@ -0,0 +1,21 @@ +// +// SearchShopsUseCase.swift +// HotSpot +// +// Created by Coby on 4/19/25. +// Copyright © 2025 Coby. All rights reserved. +// + +import Foundation + +struct SearchShopsUseCase { + private let repository: ShopRepository + + init(repository: ShopRepository) { + self.repository = repository + } + + func execute(lat: Double, lng: Double, range: Int = 3, count: Int = 30) async throws -> [ShopModel] { + try await repository.searchShops(lat: lat, lng: lng, range: range, count: count) + } +} 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..6109b60 100644 --- a/HotSpot/Sources/Presentation/Coordinator/AppCoordinator.swift +++ b/HotSpot/Sources/Presentation/Coordinator/AppCoordinator.swift @@ -6,16 +6,16 @@ struct AppCoordinator { struct State: Equatable { var map: MapStore.State = .init() var search: SearchStore.State? = nil - var restaurantDetail: RestaurantDetailStore.State? - var selectedRestaurantId: UUID? + var shopDetail: ShopDetailStore.State? + var selectedShopId: String? } enum Action { case map(MapStore.Action) case search(SearchStore.Action) - case restaurantDetail(RestaurantDetailStore.Action) + case shopDetail(ShopDetailStore.Action) - case showRestaurantDetail(UUID) + case showShopDetail(String) case showSearch case dismissSearch case dismissDetail @@ -32,24 +32,24 @@ 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.selectedShopId = shop.id + return .send(.showShopDetail(shop.id)) - case let .showRestaurantDetail(id): - state.restaurantDetail = .init(restaurantId: id) + case let .showShopDetail(id): + state.shopDetail = .init(shopId: id) return .none - case .restaurantDetail(.pop), .dismissDetail: - state.restaurantDetail = nil - state.selectedRestaurantId = nil + case .shopDetail(.pop), .dismissDetail: + state.shopDetail = nil + state.selectedShopId = nil return .none - case let .map(.showRestaurantDetail(id)): - state.selectedRestaurantId = id - return .send(.showRestaurantDetail(id)) + case let .map(.showShopDetail(id)): + state.selectedShopId = id + return .send(.showShopDetail(id)) - case .map, .search, .restaurantDetail: + case .map, .search, .shopDetail: return .none } } diff --git a/HotSpot/Sources/Presentation/Coordinator/AppCoordinatorView.swift b/HotSpot/Sources/Presentation/Coordinator/AppCoordinatorView.swift index 2b7ecd3..834e500 100644 --- a/HotSpot/Sources/Presentation/Coordinator/AppCoordinatorView.swift +++ b/HotSpot/Sources/Presentation/Coordinator/AppCoordinatorView.swift @@ -28,14 +28,14 @@ struct AppCoordinatorView: View { NavigationLink( destination: IfLetStore( - store.scope(state: \.restaurantDetail, action: \.restaurantDetail), + store.scope(state: \.shopDetail, action: \.shopDetail), then: { store in - RestaurantDetailView(store: store) + ShopDetailView(store: store) } ), isActive: viewStore.binding( - get: { $0.restaurantDetail != nil }, - send: { $0 ? .showRestaurantDetail(viewStore.selectedRestaurantId ?? UUID()) : .dismissDetail } + get: { $0.shopDetail != nil }, + send: { $0 ? .showShopDetail(viewStore.selectedShopId ?? "0") : .dismissDetail } ) ) { EmptyView() diff --git a/HotSpot/Sources/Presentation/Map/Component/MapRepresentableView.swift b/HotSpot/Sources/Presentation/Map/Component/MapRepresentableView.swift index ddb3a36..4e90770 100644 --- a/HotSpot/Sources/Presentation/Map/Component/MapRepresentableView.swift +++ b/HotSpot/Sources/Presentation/Map/Component/MapRepresentableView.swift @@ -1,159 +1,125 @@ import SwiftUI import MapKit -import CobyDS +// MARK: - Custom Annotation -// Custom Annotation Class -class RestaurantAnnotation: NSObject, MKAnnotation { +class ShopAnnotation: 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" - +// MARK: - Custom Annotation View + +class ShopAnnotationView: MKAnnotationView { + static let reuseIdentifier = "ShopAnnotationView" + + private var iconImageView: UIImageView? + override var annotation: MKAnnotation? { willSet { - guard let restaurantAnnotation = newValue as? RestaurantAnnotation else { return } - + guard newValue is ShopAnnotation 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)) + let size: CGFloat = 40 + let backgroundView = UIView(frame: CGRect(x: 0, y: 0, width: size, height: size)) + backgroundView.backgroundColor = UIColor.black + backgroundView.layer.cornerRadius = size / 2 + backgroundView.layer.borderColor = UIColor.gray.cgColor + backgroundView.layer.borderWidth = 1 + + let iconImageView = UIImageView(frame: CGRect(x: 8, y: 8, width: 24, height: 24)) + iconImageView.tintColor = .white 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 + subviews.first?.center = CGPoint(x: bounds.midX, y: bounds.midY) + iconImageView?.center = CGPoint(x: bounds.midX, y: bounds.midY) } } +// MARK: - UIViewRepresentable Map + 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 onRegionChanged: (() -> Void)? + + 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() + parent.onRegionChanged?() } - + func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? { - guard let annotation = annotation as? RestaurantAnnotation else { return nil } - - let annotationView: RestaurantAnnotationView - if let dequeuedView = mapView.dequeueReusableAnnotationView(withIdentifier: RestaurantAnnotationView.reuseIdentifier) as? RestaurantAnnotationView { - dequeuedView.annotation = annotation - annotationView = dequeuedView + guard let annotation = annotation as? ShopAnnotation else { return nil } + + if let view = mapView.dequeueReusableAnnotationView(withIdentifier: ShopAnnotationView.reuseIdentifier) as? ShopAnnotationView { + view.annotation = annotation + return view } else { - annotationView = RestaurantAnnotationView(annotation: annotation, reuseIdentifier: RestaurantAnnotationView.reuseIdentifier) - annotationView.annotation = annotation + return ShopAnnotationView(annotation: annotation, reuseIdentifier: ShopAnnotationView.reuseIdentifier) } - return annotationView } } - + 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 + + // 초기 위치 + let region = MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: 37.5665, longitude: 126.9780), + span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05) + ) + mapView.setRegion(region, animated: false) + 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: [ShopAnnotation] = shops.map { + ShopAnnotation( + coordinate: CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude), + title: $0.name ) } - print("Created \(annotations.count) annotations") - - mapView.addAnnotations(annotations) + + uiView.addAnnotations(annotations) } } diff --git a/HotSpot/Sources/Presentation/Map/MapStore.swift b/HotSpot/Sources/Presentation/Map/MapStore.swift index c73982a..48fb044 100644 --- a/HotSpot/Sources/Presentation/Map/MapStore.swift +++ b/HotSpot/Sources/Presentation/Map/MapStore.swift @@ -1,84 +1,67 @@ import Foundation - import ComposableArchitecture @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 selectedShopId: String? = nil } - + enum Action { - case updateTopLeft(Location?) - case updateBottomRight(Location?) - case getRestaurants - case getRestaurantsResponse(TaskResult<[Restaurant]>) case onAppear - case fetchRestaurants - case fetchRestaurantsResponse(TaskResult<[Restaurant]>) + case mapDidMove + case fetchShops + case fetchShopsResponse(TaskResult<[ShopModel]>) case showSearch - case showRestaurantDetail(UUID) + case showShopDetail(String) } - + 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: - return .none - case let .fetchRestaurantsResponse(.success(restaurants)): - state.restaurants = restaurants + return .send(.fetchShops) + + case .mapDidMove: + return .send(.fetchShops) + + case .fetchShops: + // 서울 시청 좌표 + let centerLat = 37.5665 + let centerLng = 126.9780 + + return .run { send in + await send( + .fetchShopsResponse( + TaskResult { + try await shopRepository.searchShops( + lat: centerLat, + lng: centerLng, + range: 3, + count: 100 + ) + } + ) + ) + } + + case let .fetchShopsResponse(.success(shops)): + state.shops = shops return .none - case .fetchRestaurantsResponse(.failure): + + case .fetchShopsResponse(.failure): + // TODO: 에러 처리 로직 추가 가능 return .none + case .showSearch: + // TODO: 검색 화면 이동 트리거 return .none - case let .showRestaurantDetail(id): - state.selectedRestaurantId = id + + case let .showShopDetail(id): + state.selectedShopId = id return .none } } diff --git a/HotSpot/Sources/Presentation/Map/MapView.swift b/HotSpot/Sources/Presentation/Map/MapView.swift index 9ed696f..98adf58 100644 --- a/HotSpot/Sources/Presentation/Map/MapView.swift +++ b/HotSpot/Sources/Presentation/Map/MapView.swift @@ -1,11 +1,10 @@ import SwiftUI - -import CobyDS import ComposableArchitecture +import CobyDS struct MapView: View { let store: StoreOf - + var body: some View { WithViewStore(store, observe: { $0 }) { viewStore in VStack(spacing: 0) { @@ -18,41 +17,33 @@ 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.shops, + onRegionChanged: { + viewStore.send(.mapDidMove) + } ) .ignoresSafeArea(.all, edges: .bottom) .onAppear { - print("MapView appeared") viewStore.send(.onAppear) } + // 하단 카드 스크롤 SnappingScrollView( - items: viewStore.restaurants, + items: viewStore.shops, itemWidth: BaseSize.fullWidth - ) { restaurant in + ) { shop in ThumbnailTileView( - image: nil, - title: restaurant.name, - subTitle: "", - description: restaurant.address + image: nil, // 이미지 URL 있으면 Kingfisher 등으로 연결 + title: shop.name, + subTitle: shop.access, + description: shop.address ) .frame(width: BaseSize.fullWidth, height: 120) .onTapGesture { - viewStore.send(.showRestaurantDetail(restaurant.id)) + viewStore.send(.showShopDetail(shop.id)) } } .frame(height: 120) diff --git a/HotSpot/Sources/Presentation/Search/SearchStore.swift b/HotSpot/Sources/Presentation/Search/SearchStore.swift index eb31c15..59c37b8 100644 --- a/HotSpot/Sources/Presentation/Search/SearchStore.swift +++ b/HotSpot/Sources/Presentation/Search/SearchStore.swift @@ -7,7 +7,7 @@ import ComposableArchitecture struct SearchStore { struct State: Equatable { var searchText: String = "" - var restaurants: [Restaurant] = [] + var shops: [Shop] = [] var isLoading: Bool = false var currentPage: Int = 1 var hasMorePages: Bool = true @@ -16,9 +16,9 @@ struct SearchStore { enum Action { case searchTextChanged(String) case search - case searchResponse(TaskResult<[Restaurant]>) + case searchResponse(TaskResult<[Shop]>) case loadMore - case selectRestaurant(Restaurant) + case selectShop(Shop) case pop } @@ -34,18 +34,18 @@ struct SearchStore { state.isLoading = true state.currentPage = 1 - state.restaurants = [] + state.shops = [] state.hasMorePages = true 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))) + let shops = generateDummyShops(for: text) + await send(.searchResponse(.success(shops))) } - case let .searchResponse(.success(restaurants)): + case let .searchResponse(.success(shops)): state.isLoading = false - state.restaurants = restaurants + state.shops = shops return .none case .searchResponse(.failure): @@ -55,7 +55,7 @@ struct SearchStore { case .loadMore: return .none - case let .selectRestaurant(restaurant): + case let .selectShop(shop): return .none case .pop: @@ -64,27 +64,27 @@ struct SearchStore { } } - private func generateDummyRestaurants(for query: String, page: Int = 1) -> [Restaurant] { + private func generateDummyShops(for query: String, page: Int = 1) -> [Shop] { // Always return some results for testing - let restaurants = [ - Restaurant( - id: UUID(), + let shops = [ + Shop( + id: "1", 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(), + Shop( + id: "2", 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(), + Shop( + id: "3", name: "교촌치킨 이태원점", address: "서울시 용산구 이태원로 123", imageURL: URL(string: "https://example.com/image3.jpg"), @@ -93,6 +93,6 @@ struct SearchStore { ) ] - return restaurants + return shops } } diff --git a/HotSpot/Sources/Presentation/Search/SearchView.swift b/HotSpot/Sources/Presentation/Search/SearchView.swift index bfc0f5f..d348130 100644 --- a/HotSpot/Sources/Presentation/Search/SearchView.swift +++ b/HotSpot/Sources/Presentation/Search/SearchView.swift @@ -76,7 +76,7 @@ struct SearchView: View { if viewStore.isLoading { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if viewStore.restaurants.isEmpty { + } else if viewStore.shops.isEmpty { VStack(spacing: 16) { Image(systemName: "magnifyingglass") .font(.system(size: 48)) @@ -89,13 +89,13 @@ struct SearchView: View { } else { ScrollView { LazyVStack(spacing: 0) { - ForEach(viewStore.restaurants) { restaurant in - RestaurantCell(restaurant: restaurant) + ForEach(viewStore.shops) { shop in + ShopCell(shop: shop) .onTapGesture { - viewStore.send(.selectRestaurant(restaurant)) + viewStore.send(.selectShop(shop)) } - if restaurant.id != viewStore.restaurants.last?.id { + if shop.id != viewStore.shops.last?.id { Divider() .padding(.leading) } diff --git a/HotSpot/Sources/Presentation/Search/RestaurantCell.swift b/HotSpot/Sources/Presentation/Search/ShopCell.swift similarity index 78% rename from HotSpot/Sources/Presentation/Search/RestaurantCell.swift rename to HotSpot/Sources/Presentation/Search/ShopCell.swift index cc29c96..9e98c4a 100644 --- a/HotSpot/Sources/Presentation/Search/RestaurantCell.swift +++ b/HotSpot/Sources/Presentation/Search/ShopCell.swift @@ -1,13 +1,13 @@ import SwiftUI import ComposableArchitecture -struct RestaurantCell: View { - let restaurant: Restaurant +struct ShopCell: View { + let shop: Shop var body: some View { HStack(spacing: 12) { - // Restaurant Image - AsyncImage(url: restaurant.imageURL) { image in + // Shop Image + AsyncImage(url: shop.imageURL) { image in image .resizable() .aspectRatio(contentMode: .fill) @@ -18,17 +18,17 @@ struct RestaurantCell: View { .frame(width: 60, height: 60) .cornerRadius(8) - // Restaurant Info + // Shop Info VStack(alignment: .leading, spacing: 4) { - Text(restaurant.name) + Text(shop.name) .font(.headline) .foregroundColor(.primary) - Text(restaurant.address) + Text(shop.address) .font(.subheadline) .foregroundColor(.gray) - if let phone = restaurant.phone { + if let phone = shop.phone { Text(phone) .font(.subheadline) .foregroundColor(.gray) @@ -47,9 +47,9 @@ struct RestaurantCell: View { } #Preview { - RestaurantCell( - restaurant: Restaurant( - id: UUID(), + ShopCell( + shop: Shop( + id: "1", name: "BBQ치킨 강남점", address: "서울시 강남구 테헤란로 123", imageURL: URL(string: "https://example.com/image1.jpg"), diff --git a/HotSpot/Sources/Presentation/RestaurantDetail/RestaurantDetailStore.swift b/HotSpot/Sources/Presentation/ShopDetail/ShopDetailStore.swift similarity index 66% rename from HotSpot/Sources/Presentation/RestaurantDetail/RestaurantDetailStore.swift rename to HotSpot/Sources/Presentation/ShopDetail/ShopDetailStore.swift index 5afc678..e7c6585 100644 --- a/HotSpot/Sources/Presentation/RestaurantDetail/RestaurantDetailStore.swift +++ b/HotSpot/Sources/Presentation/ShopDetail/ShopDetailStore.swift @@ -2,17 +2,17 @@ import Foundation import ComposableArchitecture @Reducer -struct RestaurantDetailStore { +struct ShopDetailStore { struct State: Equatable { - var restaurantId: UUID - var restaurant: Restaurant? + var shopId: String + var shop: Shop? var isLoading: Bool = false } enum Action { case onAppear - case fetchRestaurant - case fetchRestaurantResponse(TaskResult) + case fetchShop + case fetchShopResponse(TaskResult) case pop } @@ -21,15 +21,15 @@ struct RestaurantDetailStore { switch action { case .onAppear: return .run { send in - await send(.fetchRestaurant) + await send(.fetchShop) } - case .fetchRestaurant: + case .fetchShop: state.isLoading = true - return .run { [id = state.restaurantId] send in + return .run { [id = state.shopId] send in // TODO: 실제 API 호출로 대체 try await Task.sleep(nanoseconds: 500_000_000) - let restaurant = Restaurant( + let shop = Shop( id: id, name: "BBQ치킨 강남점", address: "서울시 강남구 테헤란로 123", @@ -37,15 +37,15 @@ struct RestaurantDetailStore { phone: "02-123-4567", location: Location(lat: 37.5665, lon: 126.9780) ) - await send(.fetchRestaurantResponse(.success(restaurant))) + await send(.fetchShopResponse(.success(shop))) } - case let .fetchRestaurantResponse(.success(restaurant)): + case let .fetchShopResponse(.success(shop)): state.isLoading = false - state.restaurant = restaurant + state.shop = shop return .none - case .fetchRestaurantResponse(.failure): + case .fetchShopResponse(.failure): state.isLoading = false return .none @@ -54,4 +54,4 @@ struct RestaurantDetailStore { } } } -} \ No newline at end of file +} diff --git a/HotSpot/Sources/Presentation/RestaurantDetail/RestaurantDetailView.swift b/HotSpot/Sources/Presentation/ShopDetail/ShopDetailView.swift similarity index 76% rename from HotSpot/Sources/Presentation/RestaurantDetail/RestaurantDetailView.swift rename to HotSpot/Sources/Presentation/ShopDetail/ShopDetailView.swift index 143276d..0d00e59 100644 --- a/HotSpot/Sources/Presentation/RestaurantDetail/RestaurantDetailView.swift +++ b/HotSpot/Sources/Presentation/ShopDetail/ShopDetailView.swift @@ -3,8 +3,8 @@ import SwiftUI import CobyDS import ComposableArchitecture -struct RestaurantDetailView: View { - let store: StoreOf +struct ShopDetailView: View { + let store: StoreOf var body: some View { WithViewStore(store, observe: { $0 }) { viewStore in @@ -14,17 +14,17 @@ struct RestaurantDetailView: View { leftAction: { viewStore.send(.pop) }, - title: viewStore.restaurant?.name ?? "Restaurant" + title: viewStore.shop?.name ?? "Shop" ) if viewStore.isLoading { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let restaurant = viewStore.restaurant { + } else if let shop = viewStore.shop { ScrollView { VStack(spacing: 16) { - // Restaurant Image - if let imageURL = restaurant.imageURL { + // Shop Image + if let imageURL = shop.imageURL { AsyncImage(url: imageURL) { image in image .resizable() @@ -36,17 +36,17 @@ struct RestaurantDetailView: View { .clipped() } - // Restaurant Info + // Shop Info VStack(alignment: .leading, spacing: 12) { - Text(restaurant.name) + Text(shop.name) .font(.title) .fontWeight(.bold) - Text(restaurant.address) + Text(shop.address) .font(.body) .foregroundColor(.gray) - if let phone = restaurant.phone { + if let phone = shop.phone { Text(phone) .font(.body) .foregroundColor(.gray) @@ -70,10 +70,10 @@ struct RestaurantDetailView: View { } #Preview { - RestaurantDetailView( + ShopDetailView( store: Store( - initialState: RestaurantDetailStore.State(restaurantId: UUID()), - reducer: { RestaurantDetailStore() } + initialState: ShopDetailStore.State(shopId: ""), + reducer: { ShopDetailStore() } ) ) } diff --git a/HotSpot/Sources/Shared/Dependency/ShopRepository+Dependency.swift b/HotSpot/Sources/Shared/Dependency/ShopRepository+Dependency.swift new file mode 100644 index 0000000..5bed993 --- /dev/null +++ b/HotSpot/Sources/Shared/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 } + } +} From 791d52dddb312c42c43abc9198d612fd3d272da9 Mon Sep 17 00:00:00 2001 From: coby5502 Date: Sat, 19 Apr 2025 23:08:32 +0900 Subject: [PATCH 02/21] [FEAT] get location --- .../Map/Component/MapRepresentableView.swift | 12 +++++++--- .../Sources/Presentation/Map/MapStore.swift | 23 +++++++++++-------- .../Sources/Presentation/Map/MapView.swift | 4 ++-- Project.swift | 4 +++- Tuist/Package.resolved | 4 ++-- Tuist/Package.swift | 2 +- 6 files changed, 31 insertions(+), 18 deletions(-) diff --git a/HotSpot/Sources/Presentation/Map/Component/MapRepresentableView.swift b/HotSpot/Sources/Presentation/Map/Component/MapRepresentableView.swift index 4e90770..6f5fde9 100644 --- a/HotSpot/Sources/Presentation/Map/Component/MapRepresentableView.swift +++ b/HotSpot/Sources/Presentation/Map/Component/MapRepresentableView.swift @@ -66,7 +66,7 @@ class ShopAnnotationView: MKAnnotationView { struct MapRepresentableView: UIViewRepresentable { var shops: [ShopModel] - var onRegionChanged: (() -> Void)? + var onRegionChanged: ((Double, Double) -> Void)? class Coordinator: NSObject, MKMapViewDelegate { var parent: MapRepresentableView @@ -76,7 +76,8 @@ struct MapRepresentableView: UIViewRepresentable { } func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) { - parent.onRegionChanged?() + let center = mapView.region.center + parent.onRegionChanged?(center.latitude, center.longitude) } func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? { @@ -99,10 +100,15 @@ struct MapRepresentableView: UIViewRepresentable { let mapView = MKMapView() mapView.delegate = context.coordinator mapView.showsUserLocation = true + mapView.userTrackingMode = .none // 위치 추적 모드 설정 + + // 위치 서비스 권한 확인 + let locationManager = CLLocationManager() + locationManager.requestWhenInUseAuthorization() // 초기 위치 let region = MKCoordinateRegion( - center: CLLocationCoordinate2D(latitude: 37.5665, longitude: 126.9780), + center: CLLocationCoordinate2D(latitude: 35.6762, longitude: 139.6503), span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05) ) mapView.setRegion(region, animated: false) diff --git a/HotSpot/Sources/Presentation/Map/MapStore.swift b/HotSpot/Sources/Presentation/Map/MapStore.swift index 48fb044..f5b36fe 100644 --- a/HotSpot/Sources/Presentation/Map/MapStore.swift +++ b/HotSpot/Sources/Presentation/Map/MapStore.swift @@ -8,11 +8,13 @@ struct MapStore { struct State: Equatable { var shops: [ShopModel] = [] var selectedShopId: String? = nil + var centerLat: Double = 35.6762 // Default to Tokyo coordinates + var centerLng: Double = 139.6503 } enum Action { case onAppear - case mapDidMove + case mapDidMove(lat: Double, lng: Double) case fetchShops case fetchShopsResponse(TaskResult<[ShopModel]>) case showSearch @@ -25,24 +27,25 @@ struct MapStore { case .onAppear: return .send(.fetchShops) - case .mapDidMove: + case let .mapDidMove(lat, lng): + state.centerLat = lat + state.centerLng = lng return .send(.fetchShops) case .fetchShops: - // 서울 시청 좌표 - let centerLat = 37.5665 - let centerLng = 126.9780 - - return .run { send in + print("🔍 Fetching shops for coordinates: lat=\(state.centerLat), lng=\(state.centerLng)") + return .run { [centerLat = state.centerLat, centerLng = state.centerLng] send in await send( .fetchShopsResponse( TaskResult { - try await shopRepository.searchShops( + let shops = try await shopRepository.searchShops( lat: centerLat, lng: centerLng, range: 3, count: 100 ) + print("✅ Successfully fetched \(shops.count) shops") + return shops } ) ) @@ -50,9 +53,11 @@ struct MapStore { case let .fetchShopsResponse(.success(shops)): state.shops = shops + print("📊 Updated shops count: \(shops.count)") return .none - case .fetchShopsResponse(.failure): + case .fetchShopsResponse(.failure(let error)): + print("❌ Failed to fetch shops: \(error)") // TODO: 에러 처리 로직 추가 가능 return .none diff --git a/HotSpot/Sources/Presentation/Map/MapView.swift b/HotSpot/Sources/Presentation/Map/MapView.swift index 98adf58..d21abda 100644 --- a/HotSpot/Sources/Presentation/Map/MapView.swift +++ b/HotSpot/Sources/Presentation/Map/MapView.swift @@ -21,8 +21,8 @@ struct MapView: View { ZStack(alignment: .bottom) { MapRepresentableView( shops: viewStore.shops, - onRegionChanged: { - viewStore.send(.mapDidMove) + onRegionChanged: { lat, lng in + viewStore.send(.mapDidMove(lat: lat, lng: lng)) } ) .ignoresSafeArea(.all, edges: .bottom) diff --git a/Project.swift b/Project.swift index f63519e..5590d48 100644 --- a/Project.swift +++ b/Project.swift @@ -42,7 +42,9 @@ let project = Project( "NSExceptionAllowsInsecureHTTPLoads": true ] ] - ] + ], + "NSLocationWhenInUseUsageDescription": "周辺の店舗を表示するために位置情報が必要です。", + "NSLocationAlwaysAndWhenInUseUsageDescription": "周辺の店舗を表示するために位置情報が必要です。" ] ), sources: ["\(projectName)/Sources/**"], diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index 8ebc540..d245757 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" : "fba1e4674c4d1cc69b1b4434edbf8207a04afdb2", + "version" : "1.7.6" } }, { diff --git a/Tuist/Package.swift b/Tuist/Package.swift index 1a75b82..57120c3 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.6"), .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") From 4c6cea1afd011efcea3e5171bdd73310e984064b Mon Sep 17 00:00:00 2001 From: coby5502 Date: Sat, 19 Apr 2025 23:11:49 +0900 Subject: [PATCH 03/21] [FEAT] get my location --- .../Map/Component/MapRepresentableView.swift | 52 ++++++++++++----- .../Sources/Presentation/Map/MapStore.swift | 56 ++++++++++++------- 2 files changed, 74 insertions(+), 34 deletions(-) diff --git a/HotSpot/Sources/Presentation/Map/Component/MapRepresentableView.swift b/HotSpot/Sources/Presentation/Map/Component/MapRepresentableView.swift index 6f5fde9..022a3cc 100644 --- a/HotSpot/Sources/Presentation/Map/Component/MapRepresentableView.swift +++ b/HotSpot/Sources/Presentation/Map/Component/MapRepresentableView.swift @@ -68,15 +68,51 @@ struct MapRepresentableView: UIViewRepresentable { var shops: [ShopModel] var onRegionChanged: ((Double, Double) -> Void)? - class Coordinator: NSObject, MKMapViewDelegate { + class Coordinator: NSObject, MKMapViewDelegate, CLLocationManagerDelegate { var parent: MapRepresentableView + var locationManager: CLLocationManager? + var mapView: MKMapView? init(parent: MapRepresentableView) { self.parent = parent + super.init() + setupLocationManager() + } + + private func setupLocationManager() { + locationManager = CLLocationManager() + locationManager?.delegate = self + locationManager?.desiredAccuracy = kCLLocationAccuracyBest + locationManager?.requestWhenInUseAuthorization() + } + + func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + switch manager.authorizationStatus { + case .authorizedWhenInUse, .authorizedAlways: + print("📍 Location authorized") + manager.startUpdatingLocation() + case .denied, .restricted: + print("⚠️ Location access denied") + case .notDetermined: + print("❓ Location access not determined") + @unknown default: + break + } + } + + func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + guard let location = locations.last else { return } + let region = MKCoordinateRegion( + center: location.coordinate, + span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01) + ) + mapView?.setRegion(region, animated: true) + manager.stopUpdatingLocation() } func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) { let center = mapView.region.center + print("🗺️ Map region changed to: lat=\(center.latitude), lng=\(center.longitude)") parent.onRegionChanged?(center.latitude, center.longitude) } @@ -98,20 +134,10 @@ struct MapRepresentableView: UIViewRepresentable { func makeUIView(context: Context) -> MKMapView { let mapView = MKMapView() + context.coordinator.mapView = mapView mapView.delegate = context.coordinator mapView.showsUserLocation = true - mapView.userTrackingMode = .none // 위치 추적 모드 설정 - - // 위치 서비스 권한 확인 - let locationManager = CLLocationManager() - locationManager.requestWhenInUseAuthorization() - - // 초기 위치 - let region = MKCoordinateRegion( - center: CLLocationCoordinate2D(latitude: 35.6762, longitude: 139.6503), - span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05) - ) - mapView.setRegion(region, animated: false) + mapView.userTrackingMode = .none return mapView } diff --git a/HotSpot/Sources/Presentation/Map/MapStore.swift b/HotSpot/Sources/Presentation/Map/MapStore.swift index f5b36fe..f5af0b2 100644 --- a/HotSpot/Sources/Presentation/Map/MapStore.swift +++ b/HotSpot/Sources/Presentation/Map/MapStore.swift @@ -8,8 +8,9 @@ struct MapStore { struct State: Equatable { var shops: [ShopModel] = [] var selectedShopId: String? = nil - var centerLat: Double = 35.6762 // Default to Tokyo coordinates + var centerLat: Double = 35.6762 // Default to Tokyo var centerLng: Double = 139.6503 + var isInitialized: Bool = false } enum Action { @@ -25,47 +26,60 @@ struct MapStore { Reduce { state, action in switch action { case .onAppear: - return .send(.fetchShops) + print("🗺️ Map appeared, initializing...") + if !state.isInitialized { + state.isInitialized = true + return .send(.fetchShops) + } + return .none case let .mapDidMove(lat, lng): + print("📍 Map moved to: lat=\(lat), lng=\(lng)") state.centerLat = lat state.centerLng = lng return .send(.fetchShops) case .fetchShops: - print("🔍 Fetching shops for coordinates: lat=\(state.centerLat), lng=\(state.centerLng)") + print("🔍 Fetching shops with parameters:") + print(" - Center: lat=\(state.centerLat), lng=\(state.centerLng)") + print(" - Range: 3") + print(" - Count: 100") + return .run { [centerLat = state.centerLat, centerLng = state.centerLng] send in - await send( - .fetchShopsResponse( - TaskResult { - let shops = try await shopRepository.searchShops( - lat: centerLat, - lng: centerLng, - range: 3, - count: 100 - ) - print("✅ Successfully fetched \(shops.count) shops") - return shops - } + do { + print("📡 Making API request...") + let shops = try await shopRepository.searchShops( + lat: centerLat, + lng: centerLng, + range: 3, + count: 100 ) - ) + print("✅ API request successful") + print("📊 Received \(shops.count) shops") + await send(.fetchShopsResponse(.success(shops))) + } catch { + print("❌ API request failed: \(error)") + print("Error details: \(error.localizedDescription)") + await send(.fetchShopsResponse(.failure(error))) + } } case let .fetchShopsResponse(.success(shops)): + print("📦 Updating state with \(shops.count) shops") state.shops = shops - print("📊 Updated shops count: \(shops.count)") return .none - case .fetchShopsResponse(.failure(let error)): - print("❌ Failed to fetch shops: \(error)") - // TODO: 에러 처리 로직 추가 가능 + case let .fetchShopsResponse(.failure(error)): + print("⚠️ Failed to update shops: \(error)") + print("Error details: \(error.localizedDescription)") return .none case .showSearch: - // TODO: 검색 화면 이동 트리거 + print("🔎 Showing search view") return .none case let .showShopDetail(id): + print("🏪 Showing shop detail for ID: \(id)") state.selectedShopId = id return .none } From 556406679aa99e9d480aa01ca0ed49c4defc1dc6 Mon Sep 17 00:00:00 2001 From: coby5502 Date: Sat, 19 Apr 2025 23:59:36 +0900 Subject: [PATCH 04/21] [FIX] fix api error --- HotSpot/Configuration/Config.xcconfig | 2 - .../Coordinator/AppCoordinator.swift | 10 ++++ .../Map/Component/MapRepresentableView.swift | 52 +++-------------- .../Sources/Presentation/Map/MapStore.swift | 58 ++++++++----------- .../Sources/Presentation/Map/MapView.swift | 18 +++--- Project.swift | 49 +++++++++------- 6 files changed, 82 insertions(+), 107 deletions(-) delete mode 100644 HotSpot/Configuration/Config.xcconfig diff --git a/HotSpot/Configuration/Config.xcconfig b/HotSpot/Configuration/Config.xcconfig deleted file mode 100644 index 497b214..0000000 --- a/HotSpot/Configuration/Config.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -BASE_URL = http://webservice.recruit.co.jp/hotpepper -API_KEY = 8011379945b3b751 diff --git a/HotSpot/Sources/Presentation/Coordinator/AppCoordinator.swift b/HotSpot/Sources/Presentation/Coordinator/AppCoordinator.swift index 6109b60..7f45798 100644 --- a/HotSpot/Sources/Presentation/Coordinator/AppCoordinator.swift +++ b/HotSpot/Sources/Presentation/Coordinator/AppCoordinator.swift @@ -22,6 +22,10 @@ struct AppCoordinator { } var body: some ReducerOf { + Scope(state: \.map, action: \.map) { + MapStore() + } + Reduce { state, action in switch action { case .map(.showSearch), .showSearch: @@ -53,5 +57,11 @@ struct AppCoordinator { return .none } } + .ifLet(\.search, action: \.search) { + SearchStore() + } + .ifLet(\.shopDetail, action: \.shopDetail) { + ShopDetailStore() + } } } diff --git a/HotSpot/Sources/Presentation/Map/Component/MapRepresentableView.swift b/HotSpot/Sources/Presentation/Map/Component/MapRepresentableView.swift index 022a3cc..7cf6855 100644 --- a/HotSpot/Sources/Presentation/Map/Component/MapRepresentableView.swift +++ b/HotSpot/Sources/Presentation/Map/Component/MapRepresentableView.swift @@ -66,54 +66,21 @@ class ShopAnnotationView: MKAnnotationView { struct MapRepresentableView: UIViewRepresentable { var shops: [ShopModel] - var onRegionChanged: ((Double, Double) -> Void)? + var centerCoordinate: Binding<(Double, Double)> + var onRegionChanged: (CLLocationCoordinate2D) -> Void - class Coordinator: NSObject, MKMapViewDelegate, CLLocationManagerDelegate { + class Coordinator: NSObject, MKMapViewDelegate { var parent: MapRepresentableView - var locationManager: CLLocationManager? - var mapView: MKMapView? init(parent: MapRepresentableView) { self.parent = parent - super.init() - setupLocationManager() - } - - private func setupLocationManager() { - locationManager = CLLocationManager() - locationManager?.delegate = self - locationManager?.desiredAccuracy = kCLLocationAccuracyBest - locationManager?.requestWhenInUseAuthorization() - } - - func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { - switch manager.authorizationStatus { - case .authorizedWhenInUse, .authorizedAlways: - print("📍 Location authorized") - manager.startUpdatingLocation() - case .denied, .restricted: - print("⚠️ Location access denied") - case .notDetermined: - print("❓ Location access not determined") - @unknown default: - break - } - } - - func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { - guard let location = locations.last else { return } - let region = MKCoordinateRegion( - center: location.coordinate, - span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01) - ) - mapView?.setRegion(region, animated: true) - manager.stopUpdatingLocation() } func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) { - let center = mapView.region.center - print("🗺️ Map region changed to: lat=\(center.latitude), lng=\(center.longitude)") - parent.onRegionChanged?(center.latitude, center.longitude) + let center = mapView.centerCoordinate + DispatchQueue.main.async { + self.parent.onRegionChanged(center) + } } func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? { @@ -134,18 +101,15 @@ struct MapRepresentableView: UIViewRepresentable { func makeUIView(context: Context) -> MKMapView { let mapView = MKMapView() - context.coordinator.mapView = mapView mapView.delegate = context.coordinator mapView.showsUserLocation = true - mapView.userTrackingMode = .none - return mapView } func updateUIView(_ uiView: MKMapView, context: Context) { uiView.removeAnnotations(uiView.annotations) - let annotations: [ShopAnnotation] = shops.map { + let annotations = shops.map { ShopAnnotation( coordinate: CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude), title: $0.name diff --git a/HotSpot/Sources/Presentation/Map/MapStore.swift b/HotSpot/Sources/Presentation/Map/MapStore.swift index f5af0b2..310569c 100644 --- a/HotSpot/Sources/Presentation/Map/MapStore.swift +++ b/HotSpot/Sources/Presentation/Map/MapStore.swift @@ -11,75 +11,67 @@ struct MapStore { var centerLat: Double = 35.6762 // Default to Tokyo var centerLng: Double = 139.6503 var isInitialized: Bool = false + var error: String? = nil } - enum Action { + enum Action: BindableAction { + case binding(BindingAction) case onAppear - case mapDidMove(lat: Double, lng: Double) case fetchShops - case fetchShopsResponse(TaskResult<[ShopModel]>) case showSearch case showShopDetail(String) + case updateCoordinates(lat: Double, lng: Double) + case updateShops([ShopModel]) + case handleError(Error) } var body: some ReducerOf { + BindingReducer() + Reduce { state, action in switch action { + case .binding: + return .none + case .onAppear: - print("🗺️ Map appeared, initializing...") - if !state.isInitialized { - state.isInitialized = true - return .send(.fetchShops) + return .run { send in + await send(.fetchShops) } - return .none - case let .mapDidMove(lat, lng): - print("📍 Map moved to: lat=\(lat), lng=\(lng)") + case let .updateCoordinates(lat, lng): state.centerLat = lat state.centerLng = lng - return .send(.fetchShops) + return .run { send in + await send(.fetchShops) + } case .fetchShops: - print("🔍 Fetching shops with parameters:") - print(" - Center: lat=\(state.centerLat), lng=\(state.centerLng)") - print(" - Range: 3") - print(" - Count: 100") - - return .run { [centerLat = state.centerLat, centerLng = state.centerLng] send in + return .run { [state] send in do { - print("📡 Making API request...") let shops = try await shopRepository.searchShops( - lat: centerLat, - lng: centerLng, + lat: state.centerLat, + lng: state.centerLng, range: 3, count: 100 ) - print("✅ API request successful") - print("📊 Received \(shops.count) shops") - await send(.fetchShopsResponse(.success(shops))) + await send(.updateShops(shops)) } catch { - print("❌ API request failed: \(error)") - print("Error details: \(error.localizedDescription)") - await send(.fetchShopsResponse(.failure(error))) + await send(.handleError(error)) } } - case let .fetchShopsResponse(.success(shops)): - print("📦 Updating state with \(shops.count) shops") + case let .updateShops(shops): state.shops = shops return .none - case let .fetchShopsResponse(.failure(error)): - print("⚠️ Failed to update shops: \(error)") - print("Error details: \(error.localizedDescription)") + case let .handleError(error): + state.error = error.localizedDescription return .none case .showSearch: - print("🔎 Showing search view") return .none case let .showShopDetail(id): - print("🏪 Showing shop detail for ID: \(id)") state.selectedShopId = id return .none } diff --git a/HotSpot/Sources/Presentation/Map/MapView.swift b/HotSpot/Sources/Presentation/Map/MapView.swift index d21abda..edb95be 100644 --- a/HotSpot/Sources/Presentation/Map/MapView.swift +++ b/HotSpot/Sources/Presentation/Map/MapView.swift @@ -21,22 +21,23 @@ struct MapView: View { ZStack(alignment: .bottom) { MapRepresentableView( shops: viewStore.shops, - onRegionChanged: { lat, lng in - viewStore.send(.mapDidMove(lat: lat, lng: lng)) + centerCoordinate: viewStore.binding( + get: { ($0.centerLat, $0.centerLng) }, + send: { .updateCoordinates(lat: $0.0, lng: $0.1) } + ), + onRegionChanged: { center in + viewStore.send(.updateCoordinates(lat: center.latitude, lng: center.longitude)) } ) .ignoresSafeArea(.all, edges: .bottom) - .onAppear { - viewStore.send(.onAppear) - } - // 하단 카드 스크롤 + // Bottom card scroll view SnappingScrollView( items: viewStore.shops, itemWidth: BaseSize.fullWidth ) { shop in ThumbnailTileView( - image: nil, // 이미지 URL 있으면 Kingfisher 등으로 연결 + image: nil, // TODO: Connect with Kingfisher when image URL is available title: shop.name, subTitle: shop.access, description: shop.address @@ -50,6 +51,9 @@ struct MapView: View { .padding(.bottom, 30) } } + .onAppear { + viewStore.send(.onAppear) + } } } } diff --git a/Project.swift b/Project.swift index 5590d48..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,22 +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 - ] - ] - ], - "NSLocationWhenInUseUsageDescription": "周辺の店舗を表示するために位置情報が必要です。", - "NSLocationAlwaysAndWhenInUseUsageDescription": "周辺の店舗を表示するために位置情報が必要です。" + "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/**"], From 524f4d21c8b9929c9845f690b00965c2001492bd Mon Sep 17 00:00:00 2001 From: coby5502 Date: Sun, 20 Apr 2025 00:16:03 +0900 Subject: [PATCH 05/21] [CHORE] change api request --- .../DTO/Request/ShopSearchRequestDTO.swift | 35 +++++++++++++++---- .../Data/Repository/ShopRepositoryImpl.swift | 5 ++- .../Domain/Repository/ShopRepository.swift | 2 +- .../Domain/UseCase/SearchShopsUseCase.swift | 4 +-- .../Sources/Presentation/Map/MapStore.swift | 15 ++++++-- 5 files changed, 47 insertions(+), 14 deletions(-) diff --git a/HotSpot/Sources/Data/DTO/Request/ShopSearchRequestDTO.swift b/HotSpot/Sources/Data/DTO/Request/ShopSearchRequestDTO.swift index cdb0f49..278f84e 100644 --- a/HotSpot/Sources/Data/DTO/Request/ShopSearchRequestDTO.swift +++ b/HotSpot/Sources/Data/DTO/Request/ShopSearchRequestDTO.swift @@ -1,18 +1,41 @@ import Foundation -import CoreLocation struct ShopSearchRequestDTO { - let lat: Double - let lng: Double - let range: Int - let count: Int + 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] { - return [ + var params: [String: Any] = [ "lat": lat, "lng": lng, "range": range, "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/Repository/ShopRepositoryImpl.swift b/HotSpot/Sources/Data/Repository/ShopRepositoryImpl.swift index 3ba7ccf..d6887a7 100644 --- a/HotSpot/Sources/Data/Repository/ShopRepositoryImpl.swift +++ b/HotSpot/Sources/Data/Repository/ShopRepositoryImpl.swift @@ -7,9 +7,8 @@ final class ShopRepositoryImpl: ShopRepository { self.remoteDataSource = remoteDataSource } - func searchShops(lat: Double, lng: Double, range: Int, count: Int) async throws -> [ShopModel] { - let requestDTO = ShopSearchRequestDTO(lat: lat, lng: lng, range: range, count: count) - let response = try await remoteDataSource.search(request: requestDTO) + func searchShops(request: ShopSearchRequestDTO) async throws -> [ShopModel] { + let response = try await remoteDataSource.search(request: request) return response.results.shop.map { $0.toDomain() } } } diff --git a/HotSpot/Sources/Domain/Repository/ShopRepository.swift b/HotSpot/Sources/Domain/Repository/ShopRepository.swift index af4b7f6..220482c 100644 --- a/HotSpot/Sources/Domain/Repository/ShopRepository.swift +++ b/HotSpot/Sources/Domain/Repository/ShopRepository.swift @@ -9,5 +9,5 @@ import Foundation protocol ShopRepository { - func searchShops(lat: Double, lng: Double, range: Int, count: Int) async throws -> [ShopModel] + func searchShops(request: ShopSearchRequestDTO) async throws -> [ShopModel] } diff --git a/HotSpot/Sources/Domain/UseCase/SearchShopsUseCase.swift b/HotSpot/Sources/Domain/UseCase/SearchShopsUseCase.swift index ae697b2..9302a26 100644 --- a/HotSpot/Sources/Domain/UseCase/SearchShopsUseCase.swift +++ b/HotSpot/Sources/Domain/UseCase/SearchShopsUseCase.swift @@ -15,7 +15,7 @@ struct SearchShopsUseCase { self.repository = repository } - func execute(lat: Double, lng: Double, range: Int = 3, count: Int = 30) async throws -> [ShopModel] { - try await repository.searchShops(lat: lat, lng: lng, range: range, count: count) + func execute(request: ShopSearchRequestDTO) async throws -> [ShopModel] { + try await repository.searchShops(request: request) } } diff --git a/HotSpot/Sources/Presentation/Map/MapStore.swift b/HotSpot/Sources/Presentation/Map/MapStore.swift index 310569c..4832310 100644 --- a/HotSpot/Sources/Presentation/Map/MapStore.swift +++ b/HotSpot/Sources/Presentation/Map/MapStore.swift @@ -48,12 +48,23 @@ struct MapStore { case .fetchShops: return .run { [state] send in do { - let shops = try await shopRepository.searchShops( + let request = ShopSearchRequestDTO( lat: state.centerLat, lng: state.centerLng, range: 3, - count: 100 + count: 100, + keyword: nil, + genre: nil, + order: nil, + start: nil, + budget: nil, + privateRoom: nil, + wifi: nil, + nonSmoking: nil, + coupon: nil, + openNow: nil ) + let shops = try await shopRepository.searchShops(request: request) await send(.updateShops(shops)) } catch { await send(.handleError(error)) From ffb10e126d08688f9b1c3d9f148a1676b942842e Mon Sep 17 00:00:00 2001 From: coby5502 Date: Sun, 20 Apr 2025 01:20:12 +0900 Subject: [PATCH 06/21] [FEAT] clustering --- .../ShopRepository+Dependency.swift | 0 .../Extensions}/MoyaProvider+Extension.swift | 0 .../Extensions/UIImage+Kingfisher.swift | 21 +++++ .../Model/{Restaurant.swift => Shop.swift} | 0 .../Map/Component/MapRepresentableView.swift | 82 +++---------------- .../Map/Component/ShopAnnotation.swift | 13 +++ .../Map/Component/ShopAnnotationView.swift | 35 ++++++++ .../Component/ShopClusterAnnotationView.swift | 56 +++++++++++++ .../Sources/Presentation/Map/MapView.swift | 25 +++++- Tuist/Package.resolved | 4 +- Tuist/Package.swift | 2 +- 11 files changed, 160 insertions(+), 78 deletions(-) rename HotSpot/Sources/{Shared => Common}/Dependency/ShopRepository+Dependency.swift (100%) rename HotSpot/Sources/{Data/API => Common/Extensions}/MoyaProvider+Extension.swift (100%) create mode 100644 HotSpot/Sources/Common/Extensions/UIImage+Kingfisher.swift rename HotSpot/Sources/Domain/Model/{Restaurant.swift => Shop.swift} (100%) create mode 100644 HotSpot/Sources/Presentation/Map/Component/ShopAnnotation.swift create mode 100644 HotSpot/Sources/Presentation/Map/Component/ShopAnnotationView.swift create mode 100644 HotSpot/Sources/Presentation/Map/Component/ShopClusterAnnotationView.swift diff --git a/HotSpot/Sources/Shared/Dependency/ShopRepository+Dependency.swift b/HotSpot/Sources/Common/Dependency/ShopRepository+Dependency.swift similarity index 100% rename from HotSpot/Sources/Shared/Dependency/ShopRepository+Dependency.swift rename to HotSpot/Sources/Common/Dependency/ShopRepository+Dependency.swift diff --git a/HotSpot/Sources/Data/API/MoyaProvider+Extension.swift b/HotSpot/Sources/Common/Extensions/MoyaProvider+Extension.swift similarity index 100% rename from HotSpot/Sources/Data/API/MoyaProvider+Extension.swift rename to HotSpot/Sources/Common/Extensions/MoyaProvider+Extension.swift diff --git a/HotSpot/Sources/Common/Extensions/UIImage+Kingfisher.swift b/HotSpot/Sources/Common/Extensions/UIImage+Kingfisher.swift new file mode 100644 index 0000000..ab31104 --- /dev/null +++ b/HotSpot/Sources/Common/Extensions/UIImage+Kingfisher.swift @@ -0,0 +1,21 @@ +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) + } + } + } +} diff --git a/HotSpot/Sources/Domain/Model/Restaurant.swift b/HotSpot/Sources/Domain/Model/Shop.swift similarity index 100% rename from HotSpot/Sources/Domain/Model/Restaurant.swift rename to HotSpot/Sources/Domain/Model/Shop.swift diff --git a/HotSpot/Sources/Presentation/Map/Component/MapRepresentableView.swift b/HotSpot/Sources/Presentation/Map/Component/MapRepresentableView.swift index 7cf6855..d4c6299 100644 --- a/HotSpot/Sources/Presentation/Map/Component/MapRepresentableView.swift +++ b/HotSpot/Sources/Presentation/Map/Component/MapRepresentableView.swift @@ -1,69 +1,6 @@ import SwiftUI import MapKit -// MARK: - Custom Annotation - -class ShopAnnotation: NSObject, MKAnnotation { - var coordinate: CLLocationCoordinate2D - var title: String? - - init(coordinate: CLLocationCoordinate2D, title: String?) { - self.coordinate = coordinate - self.title = title - } -} - -// MARK: - Custom Annotation View - -class ShopAnnotationView: MKAnnotationView { - static let reuseIdentifier = "ShopAnnotationView" - - private var iconImageView: UIImageView? - - override var annotation: MKAnnotation? { - willSet { - guard newValue is ShopAnnotation else { return } - canShowCallout = true - rightCalloutAccessoryView = UIButton(type: .detailDisclosure) - } - } - - 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() { - let size: CGFloat = 40 - let backgroundView = UIView(frame: CGRect(x: 0, y: 0, width: size, height: size)) - backgroundView.backgroundColor = UIColor.black - backgroundView.layer.cornerRadius = size / 2 - backgroundView.layer.borderColor = UIColor.gray.cgColor - backgroundView.layer.borderWidth = 1 - - let iconImageView = UIImageView(frame: CGRect(x: 8, y: 8, width: 24, height: 24)) - iconImageView.tintColor = .white - iconImageView.contentMode = .scaleAspectFit - - backgroundView.addSubview(iconImageView) - addSubview(backgroundView) - self.iconImageView = iconImageView - } - - override func layoutSubviews() { - super.layoutSubviews() - subviews.first?.center = CGPoint(x: bounds.midX, y: bounds.midY) - iconImageView?.center = CGPoint(x: bounds.midX, y: bounds.midY) - } -} - -// MARK: - UIViewRepresentable Map - struct MapRepresentableView: UIViewRepresentable { var shops: [ShopModel] var centerCoordinate: Binding<(Double, Double)> @@ -84,14 +21,15 @@ struct MapRepresentableView: UIViewRepresentable { } func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? { - guard let annotation = annotation as? ShopAnnotation else { return nil } - - if let view = mapView.dequeueReusableAnnotationView(withIdentifier: ShopAnnotationView.reuseIdentifier) as? ShopAnnotationView { - view.annotation = annotation - return view - } else { - return ShopAnnotationView(annotation: annotation, reuseIdentifier: ShopAnnotationView.reuseIdentifier) + if let cluster = annotation as? MKClusterAnnotation { + return ShopClusterAnnotationView(annotation: cluster, reuseIdentifier: "cluster") + } + + if let shopAnnotation = annotation as? ShopAnnotation { + return ShopAnnotationView(annotation: shopAnnotation, reuseIdentifier: ShopAnnotationView.reuseIdentifier) } + + return nil } } @@ -103,6 +41,7 @@ struct MapRepresentableView: UIViewRepresentable { let mapView = MKMapView() mapView.delegate = context.coordinator mapView.showsUserLocation = true + mapView.register(ShopAnnotationView.self, forAnnotationViewWithReuseIdentifier: ShopAnnotationView.reuseIdentifier) return mapView } @@ -112,7 +51,8 @@ struct MapRepresentableView: UIViewRepresentable { let annotations = shops.map { ShopAnnotation( coordinate: CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude), - title: $0.name + title: $0.name, + shopId: $0.id ) } diff --git a/HotSpot/Sources/Presentation/Map/Component/ShopAnnotation.swift b/HotSpot/Sources/Presentation/Map/Component/ShopAnnotation.swift new file mode 100644 index 0000000..3a4abab --- /dev/null +++ b/HotSpot/Sources/Presentation/Map/Component/ShopAnnotation.swift @@ -0,0 +1,13 @@ +import MapKit + +class ShopAnnotation: NSObject, MKAnnotation { + var coordinate: CLLocationCoordinate2D + var title: String? + var shopId: String + + init(coordinate: CLLocationCoordinate2D, title: String?, shopId: String) { + self.coordinate = coordinate + self.title = title + self.shopId = shopId + } +} \ 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..6715dfc --- /dev/null +++ b/HotSpot/Sources/Presentation/Map/Component/ShopAnnotationView.swift @@ -0,0 +1,35 @@ +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 = true + calloutOffset = CGPoint(x: 0, y: 5) + markerTintColor = .black + glyphImage = UIImage(systemName: "mappin.circle.fill") + + let detailButton = UIButton(type: .detailDisclosure) + rightCalloutAccessoryView = detailButton + } + } + + 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) + } +} \ No newline at end of file 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/MapView.swift b/HotSpot/Sources/Presentation/Map/MapView.swift index edb95be..654761e 100644 --- a/HotSpot/Sources/Presentation/Map/MapView.swift +++ b/HotSpot/Sources/Presentation/Map/MapView.swift @@ -1,9 +1,12 @@ import SwiftUI + import ComposableArchitecture import CobyDS +import Kingfisher struct MapView: View { let store: StoreOf + @State private var shopImages: [String: UIImage] = [:] var body: some View { WithViewStore(store, observe: { $0 }) { viewStore in @@ -37,15 +40,19 @@ struct MapView: View { itemWidth: BaseSize.fullWidth ) { shop in ThumbnailTileView( - image: nil, // TODO: Connect with Kingfisher when image URL is available + image: shopImages[shop.id], title: shop.name, - subTitle: shop.access, - description: shop.address + subTitle: nil, + description: shop.access, + subDescription: nil ) - .frame(width: BaseSize.fullWidth, height: 120) + .frame(width: BaseSize.fullWidth) .onTapGesture { viewStore.send(.showShopDetail(shop.id)) } + .onAppear { + loadImage(for: shop) + } } .frame(height: 120) .padding(.bottom, 30) @@ -56,4 +63,14 @@ struct MapView: View { } } } + + private func loadImage(for shop: ShopModel) { + guard shopImages[shop.id] == nil else { return } + + UIImage.load(from: shop.imageUrl) { image in + DispatchQueue.main.async { + shopImages[shop.id] = image + } + } + } } diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index d245757..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" : "fba1e4674c4d1cc69b1b4434edbf8207a04afdb2", - "version" : "1.7.6" + "revision" : "4dc0668cd4efc2719e4c1e51d288b55b3f0ca2c6", + "version" : "1.7.8" } }, { diff --git a/Tuist/Package.swift b/Tuist/Package.swift index 57120c3..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.6"), + .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") From 57d63f631e6fc2f6bb22c774d6858e8720d2ef01 Mon Sep 17 00:00:00 2001 From: coby5502 Date: Sun, 20 Apr 2025 01:38:55 +0900 Subject: [PATCH 07/21] [FEAT] set genre icon --- .../Common/Extensions/ShopGenreColor.swift | 49 +++++++++++++++++++ .../Extensions/UIImage+Kingfisher.swift | 25 ++++++++++ .../Sources/Data/DTO/Response/ShopDTO.swift | 12 ++++- HotSpot/Sources/Domain/Model/ShopModel.swift | 1 + .../Map/Component/MapRepresentableView.swift | 9 ++-- .../Map/Component/ShopAnnotation.swift | 4 +- .../Map/Component/ShopAnnotationView.swift | 11 ++--- .../Sources/Presentation/Map/MapStore.swift | 10 +++- .../Sources/Presentation/Map/MapView.swift | 40 +++++++++++++-- 9 files changed, 143 insertions(+), 18 deletions(-) create mode 100644 HotSpot/Sources/Common/Extensions/ShopGenreColor.swift diff --git a/HotSpot/Sources/Common/Extensions/ShopGenreColor.swift b/HotSpot/Sources/Common/Extensions/ShopGenreColor.swift new file mode 100644 index 0000000..3b03c9b --- /dev/null +++ b/HotSpot/Sources/Common/Extensions/ShopGenreColor.swift @@ -0,0 +1,49 @@ +import UIKit + +public enum ShopGenreColor { + 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 index ab31104..6eded86 100644 --- a/HotSpot/Sources/Common/Extensions/UIImage+Kingfisher.swift +++ b/HotSpot/Sources/Common/Extensions/UIImage+Kingfisher.swift @@ -18,4 +18,29 @@ extension UIImage { } } } + + 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/DTO/Response/ShopDTO.swift b/HotSpot/Sources/Data/DTO/Response/ShopDTO.swift index 65ee2eb..d3474dc 100644 --- a/HotSpot/Sources/Data/DTO/Response/ShopDTO.swift +++ b/HotSpot/Sources/Data/DTO/Response/ShopDTO.swift @@ -9,6 +9,7 @@ struct ShopDTO: Decodable { let access: String let open: String? let photo: Photo + let genre: Genre struct Photo: Decodable { let mobile: Mobile @@ -22,6 +23,14 @@ struct ShopDTO: Decodable { } } + struct Genre: Decodable { + let code: String + + enum CodingKeys: String, CodingKey { + case code = "code" + } + } + func toDomain() -> ShopModel { ShopModel( id: id, @@ -31,7 +40,8 @@ struct ShopDTO: Decodable { longitude: lng, imageUrl: photo.mobile.large, access: access, - openingHours: open + openingHours: open, + genreCode: genre.code ) } } diff --git a/HotSpot/Sources/Domain/Model/ShopModel.swift b/HotSpot/Sources/Domain/Model/ShopModel.swift index df67393..637fbfc 100644 --- a/HotSpot/Sources/Domain/Model/ShopModel.swift +++ b/HotSpot/Sources/Domain/Model/ShopModel.swift @@ -17,4 +17,5 @@ struct ShopModel: Identifiable, Equatable { let imageUrl: String let access: String let openingHours: String? + let genreCode: String } diff --git a/HotSpot/Sources/Presentation/Map/Component/MapRepresentableView.swift b/HotSpot/Sources/Presentation/Map/Component/MapRepresentableView.swift index d4c6299..47a6438 100644 --- a/HotSpot/Sources/Presentation/Map/Component/MapRepresentableView.swift +++ b/HotSpot/Sources/Presentation/Map/Component/MapRepresentableView.swift @@ -4,7 +4,7 @@ import MapKit struct MapRepresentableView: UIViewRepresentable { var shops: [ShopModel] var centerCoordinate: Binding<(Double, Double)> - var onRegionChanged: (CLLocationCoordinate2D) -> Void + var onRegionChanged: (MKCoordinateRegion) -> Void class Coordinator: NSObject, MKMapViewDelegate { var parent: MapRepresentableView @@ -14,9 +14,9 @@ struct MapRepresentableView: UIViewRepresentable { } func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) { - let center = mapView.centerCoordinate + let region = mapView.region DispatchQueue.main.async { - self.parent.onRegionChanged(center) + self.parent.onRegionChanged(region) } } @@ -52,7 +52,8 @@ struct MapRepresentableView: UIViewRepresentable { ShopAnnotation( coordinate: CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude), title: $0.name, - shopId: $0.id + shopId: $0.id, + genreCode: $0.genreCode ) } diff --git a/HotSpot/Sources/Presentation/Map/Component/ShopAnnotation.swift b/HotSpot/Sources/Presentation/Map/Component/ShopAnnotation.swift index 3a4abab..659decf 100644 --- a/HotSpot/Sources/Presentation/Map/Component/ShopAnnotation.swift +++ b/HotSpot/Sources/Presentation/Map/Component/ShopAnnotation.swift @@ -4,10 +4,12 @@ class ShopAnnotation: NSObject, MKAnnotation { var coordinate: CLLocationCoordinate2D var title: String? var shopId: String + var genreCode: String - init(coordinate: CLLocationCoordinate2D, title: String?, shopId: 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 index 6715dfc..cf88801 100644 --- a/HotSpot/Sources/Presentation/Map/Component/ShopAnnotationView.swift +++ b/HotSpot/Sources/Presentation/Map/Component/ShopAnnotationView.swift @@ -8,13 +8,10 @@ class ShopAnnotationView: MKMarkerAnnotationView { willSet { guard let shopAnnotation = newValue as? ShopAnnotation else { return } clusteringIdentifier = "Shop" - canShowCallout = true - calloutOffset = CGPoint(x: 0, y: 5) - markerTintColor = .black - glyphImage = UIImage(systemName: "mappin.circle.fill") - - let detailButton = UIButton(type: .detailDisclosure) - rightCalloutAccessoryView = detailButton + canShowCallout = false + isEnabled = false + markerTintColor = ShopGenreColor.color(for: shopAnnotation.genreCode) + glyphImage = ShopGenreColor.image(for: shopAnnotation.genreCode) } } diff --git a/HotSpot/Sources/Presentation/Map/MapStore.swift b/HotSpot/Sources/Presentation/Map/MapStore.swift index 4832310..7c413ce 100644 --- a/HotSpot/Sources/Presentation/Map/MapStore.swift +++ b/HotSpot/Sources/Presentation/Map/MapStore.swift @@ -12,6 +12,7 @@ struct MapStore { var centerLng: Double = 139.6503 var isInitialized: Bool = false var error: String? = nil + var currentRange: Int = 3 // Default to 1km } enum Action: BindableAction { @@ -23,6 +24,7 @@ struct MapStore { case updateCoordinates(lat: Double, lng: Double) case updateShops([ShopModel]) case handleError(Error) + case updateRange(Int) } var body: some ReducerOf { @@ -45,13 +47,19 @@ struct MapStore { await send(.fetchShops) } + case let .updateRange(range): + state.currentRange = range + return .run { send in + await send(.fetchShops) + } + case .fetchShops: return .run { [state] send in do { let request = ShopSearchRequestDTO( lat: state.centerLat, lng: state.centerLng, - range: 3, + range: state.currentRange, count: 100, keyword: nil, genre: nil, diff --git a/HotSpot/Sources/Presentation/Map/MapView.swift b/HotSpot/Sources/Presentation/Map/MapView.swift index 654761e..f2428d6 100644 --- a/HotSpot/Sources/Presentation/Map/MapView.swift +++ b/HotSpot/Sources/Presentation/Map/MapView.swift @@ -1,5 +1,5 @@ import SwiftUI - +import MapKit import ComposableArchitecture import CobyDS import Kingfisher @@ -7,6 +7,7 @@ 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 @@ -28,8 +29,9 @@ struct MapView: View { get: { ($0.centerLat, $0.centerLng) }, send: { .updateCoordinates(lat: $0.0, lng: $0.1) } ), - onRegionChanged: { center in - viewStore.send(.updateCoordinates(lat: center.latitude, lng: center.longitude)) + onRegionChanged: { region in + viewStore.send(.updateCoordinates(lat: region.center.latitude, lng: region.center.longitude)) + updateRangeIfNeeded(region: region, viewStore: viewStore) } ) .ignoresSafeArea(.all, edges: .bottom) @@ -67,10 +69,40 @@ struct MapView: View { private func loadImage(for shop: ShopModel) { guard shopImages[shop.id] == nil else { return } - UIImage.load(from: shop.imageUrl) { image in + UIImage.loadThumbnail(from: shop.imageUrl) { image in DispatchQueue.main.async { shopImages[shop.id] = image } } } + + private func updateRangeIfNeeded(region: MKCoordinateRegion, viewStore: ViewStore) { + // Calculate the approximate distance in meters based on the visible region + let span = region.span + let center = region.center + + // Calculate the distance in meters (approximate) + let latDistance = span.latitudeDelta * 111000 // 1 degree of latitude is approximately 111km + let lngDistance = span.longitudeDelta * 111000 * cos(center.latitude * .pi / 180) + let maxDistance = max(latDistance, lngDistance) + + // Determine the appropriate range based on the distance + let newRange: Int + if maxDistance <= 300 { + newRange = 1 + } else if maxDistance <= 500 { + newRange = 2 + } else if maxDistance <= 1000 { + newRange = 3 + } else if maxDistance <= 2000 { + newRange = 4 + } else { + newRange = 5 + } + + // Only update if the range has changed + if viewStore.currentRange != newRange { + viewStore.send(.updateRange(newRange)) + } + } } From c119d903a2eb999b161ea920d305b5cd89744170 Mon Sep 17 00:00:00 2001 From: coby5502 Date: Sun, 20 Apr 2025 02:07:47 +0900 Subject: [PATCH 08/21] [FEAT] fiter shops --- .../Map/Component/MapRepresentableView.swift | 6 +- .../Sources/Presentation/Map/MapStore.swift | 96 +++++++++++++++---- .../Sources/Presentation/Map/MapView.swift | 46 ++------- 3 files changed, 87 insertions(+), 61 deletions(-) diff --git a/HotSpot/Sources/Presentation/Map/Component/MapRepresentableView.swift b/HotSpot/Sources/Presentation/Map/Component/MapRepresentableView.swift index 47a6438..fc2d635 100644 --- a/HotSpot/Sources/Presentation/Map/Component/MapRepresentableView.swift +++ b/HotSpot/Sources/Presentation/Map/Component/MapRepresentableView.swift @@ -3,8 +3,7 @@ import MapKit struct MapRepresentableView: UIViewRepresentable { var shops: [ShopModel] - var centerCoordinate: Binding<(Double, Double)> - var onRegionChanged: (MKCoordinateRegion) -> Void + var region: Binding class Coordinator: NSObject, MKMapViewDelegate { var parent: MapRepresentableView @@ -14,9 +13,8 @@ struct MapRepresentableView: UIViewRepresentable { } func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) { - let region = mapView.region DispatchQueue.main.async { - self.parent.onRegionChanged(region) + self.parent.region.wrappedValue = mapView.region } } diff --git a/HotSpot/Sources/Presentation/Map/MapStore.swift b/HotSpot/Sources/Presentation/Map/MapStore.swift index 7c413ce..a8b2b25 100644 --- a/HotSpot/Sources/Presentation/Map/MapStore.swift +++ b/HotSpot/Sources/Presentation/Map/MapStore.swift @@ -1,5 +1,7 @@ import Foundation +import CoreLocation import ComposableArchitecture +import MapKit @Reducer struct MapStore { @@ -7,12 +9,29 @@ struct MapStore { struct State: Equatable { var shops: [ShopModel] = [] + var visibleShops: [ShopModel] = [] var selectedShopId: String? = nil - var centerLat: Double = 35.6762 // Default to Tokyo - var centerLng: Double = 139.6503 + var region: MKCoordinateRegion = MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: 35.6762, longitude: 139.6503), + span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01) + ) var isInitialized: Bool = false var error: String? = nil - var currentRange: Int = 3 // Default to 1km + var lastFetchedLocation: CLLocationCoordinate2D? = nil + + static func == (lhs: State, rhs: State) -> Bool { + lhs.shops == rhs.shops && + lhs.visibleShops == rhs.visibleShops && + lhs.selectedShopId == rhs.selectedShopId && + 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.isInitialized == rhs.isInitialized && + lhs.error == rhs.error && + lhs.lastFetchedLocation?.latitude == rhs.lastFetchedLocation?.latitude && + lhs.lastFetchedLocation?.longitude == rhs.lastFetchedLocation?.longitude + } } enum Action: BindableAction { @@ -21,10 +40,9 @@ struct MapStore { case fetchShops case showSearch case showShopDetail(String) - case updateCoordinates(lat: Double, lng: Double) + case updateRegion(MKCoordinateRegion) case updateShops([ShopModel]) case handleError(Error) - case updateRange(Int) } var body: some ReducerOf { @@ -36,30 +54,58 @@ struct MapStore { return .none case .onAppear: + state.lastFetchedLocation = state.region.center return .run { send in await send(.fetchShops) } - case let .updateCoordinates(lat, lng): - state.centerLat = lat - state.centerLng = lng - return .run { send in - await send(.fetchShops) + case let .updateRegion(region): + state.region = region + + // Check if we need to fetch new data based on distance + if let lastLocation = state.lastFetchedLocation { + let distance = CLLocation(latitude: lastLocation.latitude, longitude: lastLocation.longitude) + .distance(from: CLLocation(latitude: region.center.latitude, longitude: region.center.longitude)) + + // Only fetch if moved more than 100 meters + if distance > 100 { + state.lastFetchedLocation = region.center + return .run { send in + await send(.fetchShops) + } + } + } else { + state.lastFetchedLocation = region.center + return .run { send in + await send(.fetchShops) + } } - case let .updateRange(range): - state.currentRange = range - return .run { send in - await send(.fetchShops) + // Filter visible shops based on region + let northEast = CLLocationCoordinate2D( + latitude: region.center.latitude + region.span.latitudeDelta / 2, + longitude: region.center.longitude + region.span.longitudeDelta / 2 + ) + let southWest = CLLocationCoordinate2D( + latitude: region.center.latitude - region.span.latitudeDelta / 2, + longitude: region.center.longitude - region.span.longitudeDelta / 2 + ) + + state.visibleShops = state.shops.filter { shop in + shop.latitude <= northEast.latitude && + shop.latitude >= southWest.latitude && + shop.longitude <= northEast.longitude && + shop.longitude >= southWest.longitude } + return .none case .fetchShops: return .run { [state] send in do { let request = ShopSearchRequestDTO( - lat: state.centerLat, - lng: state.centerLng, - range: state.currentRange, + lat: state.region.center.latitude, + lng: state.region.center.longitude, + range: 5, // Fixed range count: 100, keyword: nil, genre: nil, @@ -81,6 +127,22 @@ struct MapStore { case let .updateShops(shops): state.shops = shops + // Filter visible shops based on current region + let northEast = CLLocationCoordinate2D( + latitude: state.region.center.latitude + state.region.span.latitudeDelta / 2, + longitude: state.region.center.longitude + state.region.span.longitudeDelta / 2 + ) + let southWest = CLLocationCoordinate2D( + latitude: state.region.center.latitude - state.region.span.latitudeDelta / 2, + longitude: state.region.center.longitude - state.region.span.longitudeDelta / 2 + ) + + state.visibleShops = shops.filter { shop in + shop.latitude <= northEast.latitude && + shop.latitude >= southWest.latitude && + shop.longitude <= northEast.longitude && + shop.longitude >= southWest.longitude + } return .none case let .handleError(error): diff --git a/HotSpot/Sources/Presentation/Map/MapView.swift b/HotSpot/Sources/Presentation/Map/MapView.swift index f2428d6..72d8dc7 100644 --- a/HotSpot/Sources/Presentation/Map/MapView.swift +++ b/HotSpot/Sources/Presentation/Map/MapView.swift @@ -24,21 +24,17 @@ struct MapView: View { ZStack(alignment: .bottom) { MapRepresentableView( - shops: viewStore.shops, - centerCoordinate: viewStore.binding( - get: { ($0.centerLat, $0.centerLng) }, - send: { .updateCoordinates(lat: $0.0, lng: $0.1) } - ), - onRegionChanged: { region in - viewStore.send(.updateCoordinates(lat: region.center.latitude, lng: region.center.longitude)) - updateRangeIfNeeded(region: region, viewStore: viewStore) - } + shops: viewStore.visibleShops, + region: viewStore.binding( + get: { $0.region }, + send: { .updateRegion($0) } + ) ) .ignoresSafeArea(.all, edges: .bottom) // Bottom card scroll view SnappingScrollView( - items: viewStore.shops, + items: viewStore.visibleShops, itemWidth: BaseSize.fullWidth ) { shop in ThumbnailTileView( @@ -75,34 +71,4 @@ struct MapView: View { } } } - - private func updateRangeIfNeeded(region: MKCoordinateRegion, viewStore: ViewStore) { - // Calculate the approximate distance in meters based on the visible region - let span = region.span - let center = region.center - - // Calculate the distance in meters (approximate) - let latDistance = span.latitudeDelta * 111000 // 1 degree of latitude is approximately 111km - let lngDistance = span.longitudeDelta * 111000 * cos(center.latitude * .pi / 180) - let maxDistance = max(latDistance, lngDistance) - - // Determine the appropriate range based on the distance - let newRange: Int - if maxDistance <= 300 { - newRange = 1 - } else if maxDistance <= 500 { - newRange = 2 - } else if maxDistance <= 1000 { - newRange = 3 - } else if maxDistance <= 2000 { - newRange = 4 - } else { - newRange = 5 - } - - // Only update if the range has changed - if viewStore.currentRange != newRange { - viewStore.send(.updateRange(newRange)) - } - } } From 953a49c7340a9fd924204e7bf80eec68d2257139 Mon Sep 17 00:00:00 2001 From: coby5502 Date: Sun, 20 Apr 2025 02:22:50 +0900 Subject: [PATCH 09/21] [CHORE] MapStore refactoring --- .../Common/Extensions/MapKit+Extension.swift | 24 ++++ .../Sources/Presentation/Map/MapStore.swift | 135 ++++++++---------- 2 files changed, 84 insertions(+), 75 deletions(-) create mode 100644 HotSpot/Sources/Common/Extensions/MapKit+Extension.swift diff --git a/HotSpot/Sources/Common/Extensions/MapKit+Extension.swift b/HotSpot/Sources/Common/Extensions/MapKit+Extension.swift new file mode 100644 index 0000000..6c5ce68 --- /dev/null +++ b/HotSpot/Sources/Common/Extensions/MapKit+Extension.swift @@ -0,0 +1,24 @@ +import MapKit + +extension MKCoordinateRegion { + var northEast: CLLocationCoordinate2D { + CLLocationCoordinate2D( + latitude: center.latitude + span.latitudeDelta / 2, + longitude: center.longitude + span.longitudeDelta / 2 + ) + } + + var southWest: CLLocationCoordinate2D { + CLLocationCoordinate2D( + latitude: center.latitude - span.latitudeDelta / 2, + longitude: center.longitude - span.longitudeDelta / 2 + ) + } + + func contains(_ coordinate: CLLocationCoordinate2D) -> Bool { + coordinate.latitude <= northEast.latitude && + coordinate.latitude >= southWest.latitude && + coordinate.longitude <= northEast.longitude && + coordinate.longitude >= southWest.longitude + } +} diff --git a/HotSpot/Sources/Presentation/Map/MapStore.swift b/HotSpot/Sources/Presentation/Map/MapStore.swift index a8b2b25..0b60e50 100644 --- a/HotSpot/Sources/Presentation/Map/MapStore.swift +++ b/HotSpot/Sources/Presentation/Map/MapStore.swift @@ -18,20 +18,6 @@ struct MapStore { var isInitialized: Bool = false var error: String? = nil var lastFetchedLocation: CLLocationCoordinate2D? = nil - - static func == (lhs: State, rhs: State) -> Bool { - lhs.shops == rhs.shops && - lhs.visibleShops == rhs.visibleShops && - lhs.selectedShopId == rhs.selectedShopId && - 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.isInitialized == rhs.isInitialized && - lhs.error == rhs.error && - lhs.lastFetchedLocation?.latitude == rhs.lastFetchedLocation?.latitude && - lhs.lastFetchedLocation?.longitude == rhs.lastFetchedLocation?.longitude - } } enum Action: BindableAction { @@ -62,62 +48,20 @@ struct MapStore { case let .updateRegion(region): state.region = region - // Check if we need to fetch new data based on distance - if let lastLocation = state.lastFetchedLocation { - let distance = CLLocation(latitude: lastLocation.latitude, longitude: lastLocation.longitude) - .distance(from: CLLocation(latitude: region.center.latitude, longitude: region.center.longitude)) - - // Only fetch if moved more than 100 meters - if distance > 100 { - state.lastFetchedLocation = region.center - return .run { send in - await send(.fetchShops) - } - } - } else { + if shouldFetchNewData(state: state, newRegion: region) { state.lastFetchedLocation = region.center return .run { send in await send(.fetchShops) } } - // Filter visible shops based on region - let northEast = CLLocationCoordinate2D( - latitude: region.center.latitude + region.span.latitudeDelta / 2, - longitude: region.center.longitude + region.span.longitudeDelta / 2 - ) - let southWest = CLLocationCoordinate2D( - latitude: region.center.latitude - region.span.latitudeDelta / 2, - longitude: region.center.longitude - region.span.longitudeDelta / 2 - ) - - state.visibleShops = state.shops.filter { shop in - shop.latitude <= northEast.latitude && - shop.latitude >= southWest.latitude && - shop.longitude <= northEast.longitude && - shop.longitude >= southWest.longitude - } + state.visibleShops = filterVisibleShops(state.shops, in: region) return .none case .fetchShops: return .run { [state] send in do { - let request = ShopSearchRequestDTO( - lat: state.region.center.latitude, - lng: state.region.center.longitude, - range: 5, // Fixed range - count: 100, - keyword: nil, - genre: nil, - order: nil, - start: nil, - budget: nil, - privateRoom: nil, - wifi: nil, - nonSmoking: nil, - coupon: nil, - openNow: nil - ) + let request = createSearchRequest(for: state.region) let shops = try await shopRepository.searchShops(request: request) await send(.updateShops(shops)) } catch { @@ -127,22 +71,7 @@ struct MapStore { case let .updateShops(shops): state.shops = shops - // Filter visible shops based on current region - let northEast = CLLocationCoordinate2D( - latitude: state.region.center.latitude + state.region.span.latitudeDelta / 2, - longitude: state.region.center.longitude + state.region.span.longitudeDelta / 2 - ) - let southWest = CLLocationCoordinate2D( - latitude: state.region.center.latitude - state.region.span.latitudeDelta / 2, - longitude: state.region.center.longitude - state.region.span.longitudeDelta / 2 - ) - - state.visibleShops = shops.filter { shop in - shop.latitude <= northEast.latitude && - shop.latitude >= southWest.latitude && - shop.longitude <= northEast.longitude && - shop.longitude >= southWest.longitude - } + state.visibleShops = filterVisibleShops(shops, in: state.region) return .none case let .handleError(error): @@ -159,3 +88,59 @@ struct MapStore { } } } + +// MARK: - Private Helpers +private extension MapStore { + 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 + region.contains(CLLocationCoordinate2D(latitude: shop.latitude, longitude: shop.longitude)) + } + } + + func createSearchRequest(for region: MKCoordinateRegion) -> ShopSearchRequestDTO { + ShopSearchRequestDTO( + lat: region.center.latitude, + lng: region.center.longitude, + range: 5, + count: 100, + keyword: nil, + genre: nil, + order: nil, + start: nil, + budget: nil, + privateRoom: nil, + wifi: nil, + nonSmoking: nil, + coupon: nil, + openNow: nil + ) + } +} + +// MARK: - Equatable +extension MapStore.State { + static func == (lhs: MapStore.State, rhs: MapStore.State) -> Bool { + lhs.shops == rhs.shops && + lhs.visibleShops == rhs.visibleShops && + lhs.selectedShopId == rhs.selectedShopId && + 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.isInitialized == rhs.isInitialized && + lhs.error == rhs.error && + lhs.lastFetchedLocation?.latitude == rhs.lastFetchedLocation?.latitude && + lhs.lastFetchedLocation?.longitude == rhs.lastFetchedLocation?.longitude + } +} From f5c77483b41dd47f3c99b9ab4f47e5a2a7b8f8ad Mon Sep 17 00:00:00 2001 From: coby5502 Date: Sun, 20 Apr 2025 02:58:04 +0900 Subject: [PATCH 10/21] [FEAT] connect shop scene --- HotSpot/Sources/Domain/Model/Shop.swift | 19 --- .../Domain/Service/RestaurantClient.swift | 39 ----- .../Coordinator/AppCoordinator.swift | 22 +-- .../Coordinator/AppCoordinatorView.swift | 30 ++-- .../Coordinator/MapNavigationView.swift | 1 + .../Sources/Presentation/Map/MapStore.swift | 10 +- .../Sources/Presentation/Map/MapView.swift | 3 +- .../Search/Component/EmptyResults.swift | 17 +++ .../Search/Component/SearchBar.swift | 24 ++++ .../Search/Component/SearchResults.swift | 56 ++++++++ .../Presentation/Search/SearchStore.swift | 133 +++++++++-------- .../Presentation/Search/SearchView.swift | 98 ++----------- .../Presentation/Search/ShopCell.swift | 62 -------- .../ShopDetail/ShopDetailStore.swift | 49 ++----- .../ShopDetail/ShopDetailView.swift | 135 +++++++++++------- 15 files changed, 303 insertions(+), 395 deletions(-) delete mode 100644 HotSpot/Sources/Domain/Model/Shop.swift delete mode 100644 HotSpot/Sources/Domain/Service/RestaurantClient.swift create mode 100644 HotSpot/Sources/Presentation/Coordinator/MapNavigationView.swift create mode 100644 HotSpot/Sources/Presentation/Search/Component/EmptyResults.swift create mode 100644 HotSpot/Sources/Presentation/Search/Component/SearchBar.swift create mode 100644 HotSpot/Sources/Presentation/Search/Component/SearchResults.swift delete mode 100644 HotSpot/Sources/Presentation/Search/ShopCell.swift diff --git a/HotSpot/Sources/Domain/Model/Shop.swift b/HotSpot/Sources/Domain/Model/Shop.swift deleted file mode 100644 index 1e4d70c..0000000 --- a/HotSpot/Sources/Domain/Model/Shop.swift +++ /dev/null @@ -1,19 +0,0 @@ -import Foundation - -struct Shop: Identifiable, Equatable { - let id: String - let name: String - let address: String - let imageURL: URL? - let phone: String? - let location: Location? - - init(id: String = "", 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/Service/RestaurantClient.swift b/HotSpot/Sources/Domain/Service/RestaurantClient.swift deleted file mode 100644 index adfb555..0000000 --- a/HotSpot/Sources/Domain/Service/RestaurantClient.swift +++ /dev/null @@ -1,39 +0,0 @@ -import Foundation -import ComposableArchitecture -import Dependencies - -struct ShopClient: DependencyKey { - static var liveValue: ShopClient = .live - - var searchShops: @Sendable (_ latitude: Double, _ longitude: Double, _ radius: Int) async throws -> [Shop] - - static let live = Self( - searchShops: { latitude, longitude, radius in - // TODO: Implement actual API call - // For now, return mock data - return [ - Shop( - name: "맛있는 식당", - address: "서울시 강남구 테헤란로 123", - imageURL: URL(string: "https://example.com/image1.jpg"), - phone: "02-123-4567", - location: Location(lat: latitude, lon: longitude) - ), - Shop( - 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 shopClient: ShopClient { - get { self[ShopClient.self] } - set { self[ShopClient.self] = newValue } - } -} diff --git a/HotSpot/Sources/Presentation/Coordinator/AppCoordinator.swift b/HotSpot/Sources/Presentation/Coordinator/AppCoordinator.swift index 7f45798..1094aeb 100644 --- a/HotSpot/Sources/Presentation/Coordinator/AppCoordinator.swift +++ b/HotSpot/Sources/Presentation/Coordinator/AppCoordinator.swift @@ -5,9 +5,9 @@ import ComposableArchitecture struct AppCoordinator { struct State: Equatable { var map: MapStore.State = .init() - var search: SearchStore.State? = nil + var search: SearchStore.State? var shopDetail: ShopDetailStore.State? - var selectedShopId: String? + var selectedShop: ShopModel? } enum Action { @@ -15,7 +15,7 @@ struct AppCoordinator { case search(SearchStore.Action) case shopDetail(ShopDetailStore.Action) - case showShopDetail(String) + case showShopDetail(ShopModel) case showSearch case dismissSearch case dismissDetail @@ -37,21 +37,21 @@ struct AppCoordinator { return .none case let .search(.selectShop(shop)): - state.selectedShopId = shop.id - return .send(.showShopDetail(shop.id)) + state.selectedShop = shop + return .send(.showShopDetail(shop)) - case let .showShopDetail(id): - state.shopDetail = .init(shopId: id) + case let .showShopDetail(shop): + state.shopDetail = .init(shop: shop) return .none case .shopDetail(.pop), .dismissDetail: state.shopDetail = nil - state.selectedShopId = nil + state.selectedShop = nil return .none - case let .map(.showShopDetail(id)): - state.selectedShopId = id - return .send(.showShopDetail(id)) + case let .map(.showShopDetail(shop)): + state.selectedShop = shop + return .send(.showShopDetail(shop)) case .map, .search, .shopDetail: return .none diff --git a/HotSpot/Sources/Presentation/Coordinator/AppCoordinatorView.swift b/HotSpot/Sources/Presentation/Coordinator/AppCoordinatorView.swift index 834e500..236a73e 100644 --- a/HotSpot/Sources/Presentation/Coordinator/AppCoordinatorView.swift +++ b/HotSpot/Sources/Presentation/Coordinator/AppCoordinatorView.swift @@ -26,21 +26,23 @@ struct AppCoordinatorView: View { } .hidden() - NavigationLink( - destination: IfLetStore( - store.scope(state: \.shopDetail, action: \.shopDetail), - then: { store in - ShopDetailView(store: store) - } - ), - isActive: viewStore.binding( - get: { $0.shopDetail != nil }, - send: { $0 ? .showShopDetail(viewStore.selectedShopId ?? "0") : .dismissDetail } - ) - ) { - EmptyView() + if let selectedShop = viewStore.selectedShop { + NavigationLink( + destination: IfLetStore( + store.scope(state: \.shopDetail, action: \.shopDetail), + then: { store in + ShopDetailView(store: store) + } + ), + isActive: viewStore.binding( + get: { $0.shopDetail != nil }, + send: { $0 ? .showShopDetail(selectedShop) : .dismissDetail } + ) + ) { + EmptyView() + } + .hidden() } - .hidden() } } .navigationViewStyle(.stack) diff --git a/HotSpot/Sources/Presentation/Coordinator/MapNavigationView.swift b/HotSpot/Sources/Presentation/Coordinator/MapNavigationView.swift new file mode 100644 index 0000000..0519ecb --- /dev/null +++ b/HotSpot/Sources/Presentation/Coordinator/MapNavigationView.swift @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/HotSpot/Sources/Presentation/Map/MapStore.swift b/HotSpot/Sources/Presentation/Map/MapStore.swift index 0b60e50..5a5e9ae 100644 --- a/HotSpot/Sources/Presentation/Map/MapStore.swift +++ b/HotSpot/Sources/Presentation/Map/MapStore.swift @@ -10,7 +10,7 @@ struct MapStore { struct State: Equatable { var shops: [ShopModel] = [] var visibleShops: [ShopModel] = [] - var selectedShopId: String? = nil + var selectedShop: ShopModel? = nil var region: MKCoordinateRegion = MKCoordinateRegion( center: CLLocationCoordinate2D(latitude: 35.6762, longitude: 139.6503), span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01) @@ -25,7 +25,7 @@ struct MapStore { case onAppear case fetchShops case showSearch - case showShopDetail(String) + case showShopDetail(ShopModel) case updateRegion(MKCoordinateRegion) case updateShops([ShopModel]) case handleError(Error) @@ -81,8 +81,8 @@ struct MapStore { case .showSearch: return .none - case let .showShopDetail(id): - state.selectedShopId = id + case let .showShopDetail(shop): + state.selectedShop = shop return .none } } @@ -133,7 +133,7 @@ extension MapStore.State { static func == (lhs: MapStore.State, rhs: MapStore.State) -> Bool { lhs.shops == rhs.shops && lhs.visibleShops == rhs.visibleShops && - lhs.selectedShopId == rhs.selectedShopId && + 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 && diff --git a/HotSpot/Sources/Presentation/Map/MapView.swift b/HotSpot/Sources/Presentation/Map/MapView.swift index 72d8dc7..46fc463 100644 --- a/HotSpot/Sources/Presentation/Map/MapView.swift +++ b/HotSpot/Sources/Presentation/Map/MapView.swift @@ -46,13 +46,12 @@ struct MapView: View { ) .frame(width: BaseSize.fullWidth) .onTapGesture { - viewStore.send(.showShopDetail(shop.id)) + viewStore.send(.showShopDetail(shop)) } .onAppear { loadImage(for: shop) } } - .frame(height: 120) .padding(.bottom, 30) } } 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..ee1c09e --- /dev/null +++ b/HotSpot/Sources/Presentation/Search/Component/SearchBar.swift @@ -0,0 +1,24 @@ +import SwiftUI + +struct SearchBar: View { + let searchText: String + let onSearch: (String) -> Void + @FocusState var isFocused: 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() + } +} \ No newline at end of file diff --git a/HotSpot/Sources/Presentation/Search/Component/SearchResults.swift b/HotSpot/Sources/Presentation/Search/Component/SearchResults.swift new file mode 100644 index 0000000..63fc698 --- /dev/null +++ b/HotSpot/Sources/Presentation/Search/Component/SearchResults.swift @@ -0,0 +1,56 @@ +import SwiftUI +import CobyDS +import Kingfisher + +struct SearchResults: View { + let error: String? + let searchText: String + let shops: [ShopModel] + let onSelectShop: (ShopModel) -> Void + @State private var shopImages: [String: UIImage] = [:] + + var body: some View { + Group { + if let error = error { + Text(error) + .foregroundColor(.red) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } 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) + } + } + } + .padding() + } + } + } + .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/SearchStore.swift b/HotSpot/Sources/Presentation/Search/SearchStore.swift index 59c37b8..8d52816 100644 --- a/HotSpot/Sources/Presentation/Search/SearchStore.swift +++ b/HotSpot/Sources/Presentation/Search/SearchStore.swift @@ -1,98 +1,93 @@ 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 shops: [Shop] = [] - var isLoading: Bool = false - var currentPage: Int = 1 - var hasMorePages: Bool = true + var error: String? = nil + var currentLocation: CLLocationCoordinate2D? + + 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 + } } - + enum Action { - case searchTextChanged(String) - case search - case searchResponse(TaskResult<[Shop]>) - case loadMore - case selectShop(Shop) + case onAppear + case search(String) + case selectShop(ShopModel) case pop + case updateLocation(CLLocationCoordinate2D) + case updateShops([ShopModel]) + case handleError(Error) } - + 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 .search(text): + guard let location = state.currentLocation else { return .none } - case .search: - guard !state.searchText.isEmpty else { return .none } - - state.isLoading = true - state.currentPage = 1 - state.shops = [] - state.hasMorePages = true - - return .run { [text = state.searchText] send in - try await Task.sleep(nanoseconds: 500_000_000) // Simulate network delay - let shops = generateDummyShops(for: text) - await send(.searchResponse(.success(shops))) + return .run { send in + do { + let request = ShopSearchRequestDTO( + lat: location.latitude, + lng: location.longitude, + range: 5, + count: 100, + keyword: text, + genre: nil, + order: nil, + start: nil, + budget: nil, + privateRoom: nil, + wifi: nil, + nonSmoking: nil, + coupon: nil, + openNow: nil + ) + let shops = try await shopRepository.searchShops(request: request) + await send(.updateShops(shops)) + } catch { + await send(.handleError(error)) + } } - - case let .searchResponse(.success(shops)): - state.isLoading = false + + case let .updateShops(shops): state.shops = shops return .none - - case .searchResponse(.failure): - state.isLoading = false - return .none - - case .loadMore: + + case let .handleError(error): + state.error = error.localizedDescription return .none - + case let .selectShop(shop): return .none - + case .pop: return .none } } } - - private func generateDummyShops(for query: String, page: Int = 1) -> [Shop] { - // Always return some results for testing - let shops = [ - Shop( - id: "1", - name: "BBQ치킨 강남점", - address: "서울시 강남구 테헤란로 123", - imageURL: URL(string: "https://example.com/image1.jpg"), - phone: "02-123-4567", - location: Location(lat: 37.5665, lon: 126.9780) - ), - Shop( - id: "2", - name: "BHC치킨 홍대점", - address: "서울시 마포구 홍대입구로 123", - imageURL: URL(string: "https://example.com/image2.jpg"), - phone: "02-234-5678", - location: Location(lat: 37.5665, lon: 126.9780) - ), - Shop( - id: "3", - name: "교촌치킨 이태원점", - address: "서울시 용산구 이태원로 123", - imageURL: URL(string: "https://example.com/image3.jpg"), - phone: "02-345-6789", - location: Location(lat: 37.5665, lon: 126.9780) - ) - ] - - return shops - } } diff --git a/HotSpot/Sources/Presentation/Search/SearchView.swift b/HotSpot/Sources/Presentation/Search/SearchView.swift index d348130..088f4e4 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 @@ -22,94 +21,25 @@ struct SearchView: View { ) } - // 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)) } + ) - // Search Results - ZStack { - if viewStore.isLoading { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if viewStore.shops.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.shops) { shop in - ShopCell(shop: shop) - .onTapGesture { - viewStore.send(.selectShop(shop)) - } - - if shop.id != viewStore.shops.last?.id { - Divider() - .padding(.leading) - } - } - } - } - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) + SearchResults( + error: viewStore.error, + searchText: viewStore.searchText, + shops: viewStore.shops, + onSelectShop: { viewStore.send(.selectShop($0)) } + ) } .navigationBarHidden(true) .onTapGesture { isSearchFocused = false } + .onAppear { + viewStore.send(.onAppear) + } } } } diff --git a/HotSpot/Sources/Presentation/Search/ShopCell.swift b/HotSpot/Sources/Presentation/Search/ShopCell.swift deleted file mode 100644 index 9e98c4a..0000000 --- a/HotSpot/Sources/Presentation/Search/ShopCell.swift +++ /dev/null @@ -1,62 +0,0 @@ -import SwiftUI -import ComposableArchitecture - -struct ShopCell: View { - let shop: Shop - - var body: some View { - HStack(spacing: 12) { - // Shop Image - AsyncImage(url: shop.imageURL) { image in - image - .resizable() - .aspectRatio(contentMode: .fill) - } placeholder: { - Rectangle() - .fill(Color.gray.opacity(0.2)) - } - .frame(width: 60, height: 60) - .cornerRadius(8) - - // Shop Info - VStack(alignment: .leading, spacing: 4) { - Text(shop.name) - .font(.headline) - .foregroundColor(.primary) - - Text(shop.address) - .font(.subheadline) - .foregroundColor(.gray) - - if let phone = shop.phone { - Text(phone) - .font(.subheadline) - .foregroundColor(.gray) - } - } - - Spacer() - - Image(systemName: "chevron.right") - .foregroundColor(.gray) - } - .padding(.vertical, 8) - .padding(.horizontal) - .background(Color(.systemBackground)) - } -} - -#Preview { - ShopCell( - shop: Shop( - id: "1", - 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/ShopDetail/ShopDetailStore.swift b/HotSpot/Sources/Presentation/ShopDetail/ShopDetailStore.swift index e7c6585..4f427da 100644 --- a/HotSpot/Sources/Presentation/ShopDetail/ShopDetailStore.swift +++ b/HotSpot/Sources/Presentation/ShopDetail/ShopDetailStore.swift @@ -3,52 +3,31 @@ import ComposableArchitecture @Reducer struct ShopDetailStore { + @Dependency(\.shopRepository) var shopRepository + struct State: Equatable { - var shopId: String - var shop: Shop? + var shop: ShopModel var isLoading: Bool = false + var error: String? = nil } - - enum Action { + + enum Action: BindableAction { + case binding(BindingAction) case onAppear - case fetchShop - case fetchShopResponse(TaskResult) case pop } - + var body: some ReducerOf { + BindingReducer() + Reduce { state, action in switch action { - case .onAppear: - return .run { send in - await send(.fetchShop) - } - - case .fetchShop: - state.isLoading = true - return .run { [id = state.shopId] send in - // TODO: 실제 API 호출로 대체 - try await Task.sleep(nanoseconds: 500_000_000) - let shop = Shop( - 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(.fetchShopResponse(.success(shop))) - } - - case let .fetchShopResponse(.success(shop)): - state.isLoading = false - state.shop = shop + case .binding: return .none - - case .fetchShopResponse(.failure): - state.isLoading = false + + case .onAppear: return .none - + case .pop: return .none } diff --git a/HotSpot/Sources/Presentation/ShopDetail/ShopDetailView.swift b/HotSpot/Sources/Presentation/ShopDetail/ShopDetailView.swift index 0d00e59..8769333 100644 --- a/HotSpot/Sources/Presentation/ShopDetail/ShopDetailView.swift +++ b/HotSpot/Sources/Presentation/ShopDetail/ShopDetailView.swift @@ -1,67 +1,78 @@ import SwiftUI - -import CobyDS import ComposableArchitecture +import CobyDS struct ShopDetailView: 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.shop?.name ?? "Shop" - ) - - if viewStore.isLoading { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let shop = viewStore.shop { - ScrollView { - VStack(spacing: 16) { - // Shop Image - if let imageURL = shop.imageURL { - AsyncImage(url: imageURL) { image in - image - .resizable() - .aspectRatio(contentMode: .fill) - } placeholder: { - Color.gray - } - .frame(height: 200) - .clipped() - } + ScrollView { + VStack(spacing: 0) { + // Header Image + AsyncImage(url: URL(string: viewStore.shop.imageUrl)) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + Color.gray + } + .frame(height: 200) + .clipped() + + // Shop Info + VStack(alignment: .leading, spacing: 16) { + // Name and Genre + VStack(alignment: .leading, spacing: 8) { + Text(viewStore.shop.name) + .font(.title) + .fontWeight(.bold) - // Shop Info - VStack(alignment: .leading, spacing: 12) { - Text(shop.name) - .font(.title) - .fontWeight(.bold) - - Text(shop.address) + Text(viewStore.shop.genreCode) + .font(.subheadline) + .foregroundColor(.gray) + } + + // Address + VStack(alignment: .leading, spacing: 4) { + Text("住所") + .font(.headline) + Text(viewStore.shop.address) + .font(.body) + } + + // Access + VStack(alignment: .leading, spacing: 4) { + Text("アクセス") + .font(.headline) + Text(viewStore.shop.access) + .font(.body) + } + + // Open Hours + if let openingHours = viewStore.shop.openingHours { + VStack(alignment: .leading, spacing: 4) { + Text("営業時間") + .font(.headline) + Text(openingHours) .font(.body) - .foregroundColor(.gray) - - if let phone = shop.phone { - Text(phone) - .font(.body) - .foregroundColor(.gray) - } } - .padding() } } - } else { - Text("레스토랑 정보를 불러올 수 없습니다.") - .foregroundColor(.gray) - .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button { + viewStore.send(.pop) + } label: { + Image(systemName: "chevron.left") + .foregroundColor(.primary) + } } } - .navigationBarHidden(true) .onAppear { viewStore.send(.onAppear) } @@ -70,10 +81,24 @@ struct ShopDetailView: View { } #Preview { - ShopDetailView( - store: Store( - initialState: ShopDetailStore.State(shopId: ""), - reducer: { ShopDetailStore() } + NavigationView { + 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() } + ) ) - ) + } } From f6779d0120d4e1f851f322165d1ad25f4693ce18 Mon Sep 17 00:00:00 2001 From: coby5502 Date: Sun, 20 Apr 2025 03:07:46 +0900 Subject: [PATCH 11/21] [FIX] fix navigate error --- .../Coordinator/AppCoordinator.swift | 5 ++ .../Coordinator/AppCoordinatorView.swift | 36 ++++++----- .../Coordinator/MapNavigationView.swift | 1 - .../Presentation/Search/SearchView.swift | 3 +- .../ShopDetail/ShopDetailStore.swift | 6 -- .../ShopDetail/ShopDetailView.swift | 60 ++++++++----------- 6 files changed, 49 insertions(+), 62 deletions(-) delete mode 100644 HotSpot/Sources/Presentation/Coordinator/MapNavigationView.swift diff --git a/HotSpot/Sources/Presentation/Coordinator/AppCoordinator.swift b/HotSpot/Sources/Presentation/Coordinator/AppCoordinator.swift index 1094aeb..53d47bc 100644 --- a/HotSpot/Sources/Presentation/Coordinator/AppCoordinator.swift +++ b/HotSpot/Sources/Presentation/Coordinator/AppCoordinator.swift @@ -8,6 +8,7 @@ struct AppCoordinator { var search: SearchStore.State? var shopDetail: ShopDetailStore.State? var selectedShop: ShopModel? + var isDetailPresented: Bool = false } enum Action { @@ -38,19 +39,23 @@ struct AppCoordinator { 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 .shopDetail(.pop), .dismissDetail: state.shopDetail = nil state.selectedShop = nil + state.isDetailPresented = false return .none case let .map(.showShopDetail(shop)): state.selectedShop = shop + state.isDetailPresented = true return .send(.showShopDetail(shop)) case .map, .search, .shopDetail: diff --git a/HotSpot/Sources/Presentation/Coordinator/AppCoordinatorView.swift b/HotSpot/Sources/Presentation/Coordinator/AppCoordinatorView.swift index 236a73e..01ca260 100644 --- a/HotSpot/Sources/Presentation/Coordinator/AppCoordinatorView.swift +++ b/HotSpot/Sources/Presentation/Coordinator/AppCoordinatorView.swift @@ -8,11 +8,11 @@ struct AppCoordinatorView: View { WithViewStore(store, observe: { $0 }) { viewStore in NavigationView { VStack { - MapView(store: store.scope(state: \.map, action: \.map)) + MapView(store: store.scope(state: \.map, action: { .map($0) })) NavigationLink( destination: IfLetStore( - store.scope(state: \.search, action: \.search), + store.scope(state: \.search, action: { .search($0) }), then: { store in SearchView(store: store) } @@ -25,24 +25,22 @@ struct AppCoordinatorView: View { EmptyView() } .hidden() - - if let selectedShop = viewStore.selectedShop { - NavigationLink( - destination: IfLetStore( - store.scope(state: \.shopDetail, action: \.shopDetail), - then: { store in - ShopDetailView(store: store) - } - ), - isActive: viewStore.binding( - get: { $0.shopDetail != nil }, - send: { $0 ? .showShopDetail(selectedShop) : .dismissDetail } - ) - ) { - EmptyView() - } - .hidden() + + NavigationLink( + destination: IfLetStore( + store.scope(state: \.shopDetail, action: { .shopDetail($0) }), + then: { store in + ShopDetailView(store: store) + } + ), + isActive: viewStore.binding( + get: { $0.isDetailPresented }, + send: { $0 ? .showShopDetail(viewStore.selectedShop!) : .dismissDetail } + ) + ) { + EmptyView() } + .hidden() } } .navigationViewStyle(.stack) diff --git a/HotSpot/Sources/Presentation/Coordinator/MapNavigationView.swift b/HotSpot/Sources/Presentation/Coordinator/MapNavigationView.swift deleted file mode 100644 index 0519ecb..0000000 --- a/HotSpot/Sources/Presentation/Coordinator/MapNavigationView.swift +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/HotSpot/Sources/Presentation/Search/SearchView.swift b/HotSpot/Sources/Presentation/Search/SearchView.swift index 088f4e4..da26e97 100644 --- a/HotSpot/Sources/Presentation/Search/SearchView.swift +++ b/HotSpot/Sources/Presentation/Search/SearchView.swift @@ -16,8 +16,7 @@ struct SearchView: View { leftSide: .left, leftAction: { viewStore.send(.pop) - }, - title: "Search" + } ) } diff --git a/HotSpot/Sources/Presentation/ShopDetail/ShopDetailStore.swift b/HotSpot/Sources/Presentation/ShopDetail/ShopDetailStore.swift index 4f427da..6b67244 100644 --- a/HotSpot/Sources/Presentation/ShopDetail/ShopDetailStore.swift +++ b/HotSpot/Sources/Presentation/ShopDetail/ShopDetailStore.swift @@ -7,13 +7,10 @@ struct ShopDetailStore { struct State: Equatable { var shop: ShopModel - var isLoading: Bool = false - var error: String? = nil } enum Action: BindableAction { case binding(BindingAction) - case onAppear case pop } @@ -25,9 +22,6 @@ struct ShopDetailStore { case .binding: return .none - case .onAppear: - return .none - case .pop: return .none } diff --git a/HotSpot/Sources/Presentation/ShopDetail/ShopDetailView.swift b/HotSpot/Sources/Presentation/ShopDetail/ShopDetailView.swift index 8769333..8063c13 100644 --- a/HotSpot/Sources/Presentation/ShopDetail/ShopDetailView.swift +++ b/HotSpot/Sources/Presentation/ShopDetail/ShopDetailView.swift @@ -9,6 +9,13 @@ struct ShopDetailView: View { WithViewStore(store, observe: { $0 }) { viewStore in ScrollView { VStack(spacing: 0) { + TopBarView( + leftSide: .left, + leftAction: { + viewStore.send(.pop) + } + ) + // Header Image AsyncImage(url: URL(string: viewStore.shop.imageUrl)) { image in image @@ -62,43 +69,28 @@ struct ShopDetailView: View { .padding() } } - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button { - viewStore.send(.pop) - } label: { - Image(systemName: "chevron.left") - .foregroundColor(.primary) - } - } - } - .onAppear { - viewStore.send(.onAppear) - } + .navigationBarHidden(true) } } } #Preview { - NavigationView { - 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() } - ) + 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() } ) - } -} + ) +} From 73c2633f6893354573d7daf4dc5a1ec9c3256db1 Mon Sep 17 00:00:00 2001 From: coby5502 Date: Sun, 20 Apr 2025 03:29:04 +0900 Subject: [PATCH 12/21] [FEAT] shop detail scene --- .../{ShopGenreColor.swift => ShopGenre.swift} | 25 +++- .../Coordinator/AppCoordinator.swift | 16 ++- .../Map/Component/ShopAnnotationView.swift | 6 +- .../ShopDetail/ShopDetailView.swift | 120 ++++++++++-------- 4 files changed, 109 insertions(+), 58 deletions(-) rename HotSpot/Sources/Common/Extensions/{ShopGenreColor.swift => ShopGenre.swift} (78%) diff --git a/HotSpot/Sources/Common/Extensions/ShopGenreColor.swift b/HotSpot/Sources/Common/Extensions/ShopGenre.swift similarity index 78% rename from HotSpot/Sources/Common/Extensions/ShopGenreColor.swift rename to HotSpot/Sources/Common/Extensions/ShopGenre.swift index 3b03c9b..dd3b190 100644 --- a/HotSpot/Sources/Common/Extensions/ShopGenreColor.swift +++ b/HotSpot/Sources/Common/Extensions/ShopGenre.swift @@ -1,6 +1,29 @@ import UIKit -public enum ShopGenreColor { +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 diff --git a/HotSpot/Sources/Presentation/Coordinator/AppCoordinator.swift b/HotSpot/Sources/Presentation/Coordinator/AppCoordinator.swift index 53d47bc..5edf3c1 100644 --- a/HotSpot/Sources/Presentation/Coordinator/AppCoordinator.swift +++ b/HotSpot/Sources/Presentation/Coordinator/AppCoordinator.swift @@ -47,7 +47,13 @@ struct AppCoordinator { state.isDetailPresented = true return .none - case .shopDetail(.pop), .dismissDetail: + case .shopDetail(.pop): + state.shopDetail = nil + state.selectedShop = nil + state.isDetailPresented = false + return .none + + case .dismissDetail: state.shopDetail = nil state.selectedShop = nil state.isDetailPresented = false @@ -58,7 +64,13 @@ struct AppCoordinator { state.isDetailPresented = true return .send(.showShopDetail(shop)) - case .map, .search, .shopDetail: + case .map: + return .none + + case .search: + return .none + + case .shopDetail: return .none } } diff --git a/HotSpot/Sources/Presentation/Map/Component/ShopAnnotationView.swift b/HotSpot/Sources/Presentation/Map/Component/ShopAnnotationView.swift index cf88801..02ce185 100644 --- a/HotSpot/Sources/Presentation/Map/Component/ShopAnnotationView.swift +++ b/HotSpot/Sources/Presentation/Map/Component/ShopAnnotationView.swift @@ -10,8 +10,8 @@ class ShopAnnotationView: MKMarkerAnnotationView { clusteringIdentifier = "Shop" canShowCallout = false isEnabled = false - markerTintColor = ShopGenreColor.color(for: shopAnnotation.genreCode) - glyphImage = ShopGenreColor.image(for: shopAnnotation.genreCode) + markerTintColor = ShopGenre.color(for: shopAnnotation.genreCode) + glyphImage = ShopGenre.image(for: shopAnnotation.genreCode) } } @@ -29,4 +29,4 @@ class ShopAnnotationView: MKMarkerAnnotationView { frame = CGRect(x: 0, y: 0, width: 40, height: 40) centerOffset = CGPoint(x: 0, y: -frame.size.height / 2) } -} \ No newline at end of file +} diff --git a/HotSpot/Sources/Presentation/ShopDetail/ShopDetailView.swift b/HotSpot/Sources/Presentation/ShopDetail/ShopDetailView.swift index 8063c13..4050d00 100644 --- a/HotSpot/Sources/Presentation/ShopDetail/ShopDetailView.swift +++ b/HotSpot/Sources/Presentation/ShopDetail/ShopDetailView.swift @@ -1,72 +1,88 @@ import SwiftUI import ComposableArchitecture import CobyDS +import Kingfisher struct ShopDetailView: View { let store: StoreOf - + var body: some View { WithViewStore(store, observe: { $0 }) { viewStore in - ScrollView { - VStack(spacing: 0) { - TopBarView( - leftSide: .left, - leftAction: { - viewStore.send(.pop) - } - ) - - // Header Image - AsyncImage(url: URL(string: viewStore.shop.imageUrl)) { image in - image + VStack(spacing: 0) { + TopBarView( + leftSide: .left, + leftAction: { + viewStore.send(.pop) + } + ) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 8) { + KFImage(URL(string: viewStore.shop.imageUrl)) + .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) - } placeholder: { - Color.gray - } - .frame(height: 200) - .clipped() - - // Shop Info - VStack(alignment: .leading, spacing: 16) { - // Name and Genre - VStack(alignment: .leading, spacing: 8) { - Text(viewStore.shop.name) - .font(.title) - .fontWeight(.bold) + .frame(width: BaseSize.screenWidth, height: BaseSize.screenWidth) + .clipped() + + VStack(alignment: .leading, spacing: 24) { + // Name and Genre + VStack(alignment: .leading, spacing: 8) { + Text(viewStore.shop.name) + .font(.title) + .fontWeight(.bold) + + Text(ShopGenre.name(for: viewStore.shop.genreCode)) + .font(.subheadline) + .foregroundColor(.gray) + } - Text(viewStore.shop.genreCode) - .font(.subheadline) - .foregroundColor(.gray) - } - - // Address - VStack(alignment: .leading, spacing: 4) { - Text("住所") - .font(.headline) - Text(viewStore.shop.address) - .font(.body) - } - - // Access - VStack(alignment: .leading, spacing: 4) { - Text("アクセス") - .font(.headline) - Text(viewStore.shop.access) - .font(.body) - } - - // Open Hours - if let openingHours = viewStore.shop.openingHours { + // Address VStack(alignment: .leading, spacing: 4) { - Text("営業時間") + Text("住所") .font(.headline) - Text(openingHours) + Text(viewStore.shop.address) + .font(.body) + } + + // Access + VStack(alignment: .leading, spacing: 4) { + Text("アクセス") + .font(.headline) + Text(viewStore.shop.access) + .font(.body) + } + + // Open Hours + if let openingHours = viewStore.shop.openingHours { + VStack(alignment: .leading, spacing: 4) { + Text("営業時間") + .font(.headline) + Text(openingHours) + .font(.body) + } + } + + // Location + VStack(alignment: .leading, spacing: 4) { + Text("位置情報") + .font(.headline) + Text("緯度: \(viewStore.shop.latitude)") + .font(.body) + Text("経度: \(viewStore.shop.longitude)") .font(.body) } } + .padding(.horizontal, BaseSize.horizantalPadding) + .padding(.vertical, BaseSize.verticalPadding) } - .padding() } } .navigationBarHidden(true) From f0cb6781d813947c15547299d9478004f37f632e Mon Sep 17 00:00:00 2001 From: coby5502 Date: Sun, 20 Apr 2025 04:01:01 +0900 Subject: [PATCH 13/21] [CHORE] shopDetail refactoring --- .../Coordinator/AppCoordinatorView.swift | 17 +++- .../Components/ShopImageSection.swift | 39 +++++++++ .../Components/ShopInfoSection.swift | 75 ++++++++++++++++ .../Components/ShopLocationMapView.swift | 59 +++++++++++++ .../ShopDetail/ShopDetailStore.swift | 2 +- .../ShopDetail/ShopDetailView.swift | 87 +++---------------- 6 files changed, 201 insertions(+), 78 deletions(-) create mode 100644 HotSpot/Sources/Presentation/ShopDetail/Components/ShopImageSection.swift create mode 100644 HotSpot/Sources/Presentation/ShopDetail/Components/ShopInfoSection.swift create mode 100644 HotSpot/Sources/Presentation/ShopDetail/Components/ShopLocationMapView.swift diff --git a/HotSpot/Sources/Presentation/Coordinator/AppCoordinatorView.swift b/HotSpot/Sources/Presentation/Coordinator/AppCoordinatorView.swift index 01ca260..c110a6d 100644 --- a/HotSpot/Sources/Presentation/Coordinator/AppCoordinatorView.swift +++ b/HotSpot/Sources/Presentation/Coordinator/AppCoordinatorView.swift @@ -8,11 +8,11 @@ struct AppCoordinatorView: View { WithViewStore(store, observe: { $0 }) { viewStore in NavigationView { VStack { - MapView(store: store.scope(state: \.map, action: { .map($0) })) + MapView(store: store.scope(state: \.map, action: \.map)) NavigationLink( destination: IfLetStore( - store.scope(state: \.search, action: { .search($0) }), + store.scope(state: \.search, action: \.search), then: { store in SearchView(store: store) } @@ -28,13 +28,13 @@ struct AppCoordinatorView: View { NavigationLink( destination: IfLetStore( - store.scope(state: \.shopDetail, action: { .shopDetail($0) }), + store.scope(state: \.shopDetail, action: \.shopDetail), then: { store in ShopDetailView(store: store) } ), isActive: viewStore.binding( - get: { $0.isDetailPresented }, + get: { $0.shopDetail != nil }, send: { $0 ? .showShopDetail(viewStore.selectedShop!) : .dismissDetail } ) ) { @@ -47,3 +47,12 @@ struct AppCoordinatorView: View { } } } + +#Preview { + AppCoordinatorView( + store: Store( + initialState: AppCoordinator.State(), + reducer: { AppCoordinator() } + ) + ) +} 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 index 6b67244..e608dde 100644 --- a/HotSpot/Sources/Presentation/ShopDetail/ShopDetailStore.swift +++ b/HotSpot/Sources/Presentation/ShopDetail/ShopDetailStore.swift @@ -6,7 +6,7 @@ struct ShopDetailStore { @Dependency(\.shopRepository) var shopRepository struct State: Equatable { - var shop: ShopModel + let shop: ShopModel } enum Action: BindableAction { diff --git a/HotSpot/Sources/Presentation/ShopDetail/ShopDetailView.swift b/HotSpot/Sources/Presentation/ShopDetail/ShopDetailView.swift index 4050d00..bd30bd3 100644 --- a/HotSpot/Sources/Presentation/ShopDetail/ShopDetailView.swift +++ b/HotSpot/Sources/Presentation/ShopDetail/ShopDetailView.swift @@ -8,84 +8,25 @@ struct ShopDetailView: View { var body: some View { WithViewStore(store, observe: { $0 }) { viewStore in - VStack(spacing: 0) { - TopBarView( - leftSide: .left, - leftAction: { - viewStore.send(.pop) - } - ) - - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 8) { - KFImage(URL(string: viewStore.shop.imageUrl)) - .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() - - VStack(alignment: .leading, spacing: 24) { - // Name and Genre - VStack(alignment: .leading, spacing: 8) { - Text(viewStore.shop.name) - .font(.title) - .fontWeight(.bold) - - Text(ShopGenre.name(for: viewStore.shop.genreCode)) - .font(.subheadline) - .foregroundColor(.gray) - } - - // Address - VStack(alignment: .leading, spacing: 4) { - Text("住所") - .font(.headline) - Text(viewStore.shop.address) - .font(.body) - } - - // Access - VStack(alignment: .leading, spacing: 4) { - Text("アクセス") - .font(.headline) - Text(viewStore.shop.access) - .font(.body) - } - - // Open Hours - if let openingHours = viewStore.shop.openingHours { - VStack(alignment: .leading, spacing: 4) { - Text("営業時間") - .font(.headline) - Text(openingHours) - .font(.body) - } - } + Group { + VStack(spacing: 0) { + TopBarView( + leftSide: .left, + leftAction: { + viewStore.send(.pop) + } + ) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 8) { + ShopImageSection(imageUrl: viewStore.shop.imageUrl) - // Location - VStack(alignment: .leading, spacing: 4) { - Text("位置情報") - .font(.headline) - Text("緯度: \(viewStore.shop.latitude)") - .font(.body) - Text("経度: \(viewStore.shop.longitude)") - .font(.body) - } + ShopInfoSection(shop: viewStore.shop) } - .padding(.horizontal, BaseSize.horizantalPadding) - .padding(.vertical, BaseSize.verticalPadding) } } + .navigationBarHidden(true) } - .navigationBarHidden(true) } } } From 5acc04955ddd9003e7fc73bbce27e903c2329942 Mon Sep 17 00:00:00 2001 From: coby5502 Date: Sun, 20 Apr 2025 04:26:34 +0900 Subject: [PATCH 14/21] [FEAT] search feature --- HotSpot/Sources/Domain/Error/ShopError.swift | 8 ---- HotSpot/Sources/Domain/Model/ShopModel.swift | 8 ---- .../Domain/Repository/ShopRepository.swift | 8 ---- .../Domain/UseCase/SearchShopsUseCase.swift | 8 ---- .../Coordinator/AppCoordinatorView.swift | 2 +- .../Sources/Presentation/Map/MapStore.swift | 9 ----- .../Sources/Presentation/Map/MapView.swift | 3 -- .../Search/Component/SearchBar.swift | 4 +- .../Presentation/Search/SearchStore.swift | 37 +++++++++++-------- 9 files changed, 24 insertions(+), 63 deletions(-) diff --git a/HotSpot/Sources/Domain/Error/ShopError.swift b/HotSpot/Sources/Domain/Error/ShopError.swift index 3ec9fe1..c7026b5 100644 --- a/HotSpot/Sources/Domain/Error/ShopError.swift +++ b/HotSpot/Sources/Domain/Error/ShopError.swift @@ -1,11 +1,3 @@ -// -// ShopError.swift -// HotSpot -// -// Created by Coby on 4/19/25. -// Copyright © 2025 Coby. All rights reserved. -// - import Foundation enum ShopError: Error, Equatable { diff --git a/HotSpot/Sources/Domain/Model/ShopModel.swift b/HotSpot/Sources/Domain/Model/ShopModel.swift index 637fbfc..082085f 100644 --- a/HotSpot/Sources/Domain/Model/ShopModel.swift +++ b/HotSpot/Sources/Domain/Model/ShopModel.swift @@ -1,11 +1,3 @@ -// -// ShopModel.swift -// HotSpot -// -// Created by Coby on 4/19/25. -// Copyright © 2025 Coby. All rights reserved. -// - import Foundation struct ShopModel: Identifiable, Equatable { diff --git a/HotSpot/Sources/Domain/Repository/ShopRepository.swift b/HotSpot/Sources/Domain/Repository/ShopRepository.swift index 220482c..2c6bf36 100644 --- a/HotSpot/Sources/Domain/Repository/ShopRepository.swift +++ b/HotSpot/Sources/Domain/Repository/ShopRepository.swift @@ -1,11 +1,3 @@ -// -// ShopRepository.swift -// HotSpot -// -// Created by Coby on 4/19/25. -// Copyright © 2025 Coby. All rights reserved. -// - import Foundation protocol ShopRepository { diff --git a/HotSpot/Sources/Domain/UseCase/SearchShopsUseCase.swift b/HotSpot/Sources/Domain/UseCase/SearchShopsUseCase.swift index 9302a26..7c47200 100644 --- a/HotSpot/Sources/Domain/UseCase/SearchShopsUseCase.swift +++ b/HotSpot/Sources/Domain/UseCase/SearchShopsUseCase.swift @@ -1,11 +1,3 @@ -// -// SearchShopsUseCase.swift -// HotSpot -// -// Created by Coby on 4/19/25. -// Copyright © 2025 Coby. All rights reserved. -// - import Foundation struct SearchShopsUseCase { diff --git a/HotSpot/Sources/Presentation/Coordinator/AppCoordinatorView.swift b/HotSpot/Sources/Presentation/Coordinator/AppCoordinatorView.swift index c110a6d..3fc1fae 100644 --- a/HotSpot/Sources/Presentation/Coordinator/AppCoordinatorView.swift +++ b/HotSpot/Sources/Presentation/Coordinator/AppCoordinatorView.swift @@ -34,7 +34,7 @@ struct AppCoordinatorView: View { } ), isActive: viewStore.binding( - get: { $0.shopDetail != nil }, + get: { $0.isDetailPresented }, send: { $0 ? .showShopDetail(viewStore.selectedShop!) : .dismissDetail } ) ) { diff --git a/HotSpot/Sources/Presentation/Map/MapStore.swift b/HotSpot/Sources/Presentation/Map/MapStore.swift index 5a5e9ae..40c7ead 100644 --- a/HotSpot/Sources/Presentation/Map/MapStore.swift +++ b/HotSpot/Sources/Presentation/Map/MapStore.swift @@ -15,14 +15,12 @@ struct MapStore { center: CLLocationCoordinate2D(latitude: 35.6762, longitude: 139.6503), span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01) ) - var isInitialized: Bool = false var error: String? = nil var lastFetchedLocation: CLLocationCoordinate2D? = nil } enum Action: BindableAction { case binding(BindingAction) - case onAppear case fetchShops case showSearch case showShopDetail(ShopModel) @@ -39,12 +37,6 @@ struct MapStore { case .binding: return .none - case .onAppear: - state.lastFetchedLocation = state.region.center - return .run { send in - await send(.fetchShops) - } - case let .updateRegion(region): state.region = region @@ -138,7 +130,6 @@ extension MapStore.State { 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.isInitialized == rhs.isInitialized && 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 46fc463..5879f25 100644 --- a/HotSpot/Sources/Presentation/Map/MapView.swift +++ b/HotSpot/Sources/Presentation/Map/MapView.swift @@ -55,9 +55,6 @@ struct MapView: View { .padding(.bottom, 30) } } - .onAppear { - viewStore.send(.onAppear) - } } } diff --git a/HotSpot/Sources/Presentation/Search/Component/SearchBar.swift b/HotSpot/Sources/Presentation/Search/Component/SearchBar.swift index ee1c09e..854334b 100644 --- a/HotSpot/Sources/Presentation/Search/Component/SearchBar.swift +++ b/HotSpot/Sources/Presentation/Search/Component/SearchBar.swift @@ -19,6 +19,6 @@ struct SearchBar: View { .padding(8) .background(Color(.systemGray6)) .cornerRadius(8) - .padding() + .padding(.horizontal) } -} \ No newline at end of file +} diff --git a/HotSpot/Sources/Presentation/Search/SearchStore.swift b/HotSpot/Sources/Presentation/Search/SearchStore.swift index 8d52816..4796363 100644 --- a/HotSpot/Sources/Presentation/Search/SearchStore.swift +++ b/HotSpot/Sources/Presentation/Search/SearchStore.swift @@ -12,13 +12,15 @@ struct SearchStore { var searchText: String = "" var error: String? = nil var currentLocation: CLLocationCoordinate2D? + var selectedShop: ShopModel? = nil 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.currentLocation?.longitude == rhs.currentLocation?.longitude && + lhs.selectedShop == rhs.selectedShop } } @@ -46,8 +48,25 @@ struct SearchStore { 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 let location = state.currentLocation else { return .none } + // TODO: Uncomment when back in Japan + // guard let location = state.currentLocation else { return .none } + let location = CLLocationCoordinate2D(latitude: 35.6762, longitude: 139.6503) // Tokyo return .run { send in do { @@ -73,20 +92,6 @@ struct SearchStore { await send(.handleError(error)) } } - - case let .updateShops(shops): - state.shops = shops - return .none - - case let .handleError(error): - state.error = error.localizedDescription - return .none - - case let .selectShop(shop): - return .none - - case .pop: - return .none } } } From 7ba8baef5e36ef62a844105787aceb89c7f2449c Mon Sep 17 00:00:00 2001 From: coby5502 Date: Sun, 20 Apr 2025 04:56:36 +0900 Subject: [PATCH 15/21] [FIX] detail pop error --- .../Coordinator/AppCoordinatorView.swift | 97 ++++++++++++------- 1 file changed, 62 insertions(+), 35 deletions(-) diff --git a/HotSpot/Sources/Presentation/Coordinator/AppCoordinatorView.swift b/HotSpot/Sources/Presentation/Coordinator/AppCoordinatorView.swift index 3fc1fae..3ccc38b 100644 --- a/HotSpot/Sources/Presentation/Coordinator/AppCoordinatorView.swift +++ b/HotSpot/Sources/Presentation/Coordinator/AppCoordinatorView.swift @@ -7,45 +7,72 @@ 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: \.shopDetail, action: \.shopDetail), - then: { store in - ShopDetailView(store: store) - } - ), - isActive: viewStore.binding( - get: { $0.isDetailPresented }, - send: { $0 ? .showShopDetail(viewStore.selectedShop!) : .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 { From 788fb8ec09154d7b5c6601570b4917b6d03aa8ca Mon Sep 17 00:00:00 2001 From: coby5502 Date: Sun, 20 Apr 2025 15:19:47 +0900 Subject: [PATCH 16/21] [FEAT] add search usecase --- .../SearchRepository+Dependency.swift | 14 +++ .../DTO/Request/ShopSearchRequestDTO.swift | 10 ++ .../Sources/Data/DTO/Response/ShopDTO.swift | 64 +++++++++++-- .../DTO/Response/ShopSearchResponseDTO.swift | 16 +++- .../Repository/SearchRepositoryImpl.swift | 14 +++ .../Data/Repository/ShopRepositoryImpl.swift | 5 +- .../Domain/Model/PaginationState.swift | 35 +++++++ .../Domain/Model/SearchResultModel.swift | 28 ++++++ .../Domain/Repository/SearchRepository.swift | 5 + .../Domain/Repository/ShopRepository.swift | 2 +- .../UseCase/InfiniteScrollSearchUseCase.swift | 45 +++++++++ .../Domain/UseCase/SearchShopsUseCase.swift | 13 --- .../Sources/Domain/UseCase/ShopsUseCase.swift | 33 +++++++ .../Sources/Presentation/Map/MapStore.swift | 26 +----- .../Search/Component/SearchResults.swift | 13 +++ .../Presentation/Search/SearchStore.swift | 92 +++++++++++++++++-- .../Presentation/Search/SearchView.swift | 8 +- 17 files changed, 363 insertions(+), 60 deletions(-) create mode 100644 HotSpot/Sources/Common/Dependency/SearchRepository+Dependency.swift create mode 100644 HotSpot/Sources/Data/Repository/SearchRepositoryImpl.swift create mode 100644 HotSpot/Sources/Domain/Model/PaginationState.swift create mode 100644 HotSpot/Sources/Domain/Model/SearchResultModel.swift create mode 100644 HotSpot/Sources/Domain/Repository/SearchRepository.swift create mode 100644 HotSpot/Sources/Domain/UseCase/InfiniteScrollSearchUseCase.swift delete mode 100644 HotSpot/Sources/Domain/UseCase/SearchShopsUseCase.swift create mode 100644 HotSpot/Sources/Domain/UseCase/ShopsUseCase.swift diff --git a/HotSpot/Sources/Common/Dependency/SearchRepository+Dependency.swift b/HotSpot/Sources/Common/Dependency/SearchRepository+Dependency.swift new file mode 100644 index 0000000..963b618 --- /dev/null +++ b/HotSpot/Sources/Common/Dependency/SearchRepository+Dependency.swift @@ -0,0 +1,14 @@ +import ComposableArchitecture + +private enum SearchRepositoryKey: DependencyKey { + static let liveValue: SearchRepository = SearchRepositoryImpl( + remoteDataSource: ShopRemoteDataSourceImpl() + ) +} + +extension DependencyValues { + var searchRepository: SearchRepository { + get { self[SearchRepositoryKey.self] } + set { self[SearchRepositoryKey.self] = newValue } + } +} \ No newline at end of file diff --git a/HotSpot/Sources/Data/DTO/Request/ShopSearchRequestDTO.swift b/HotSpot/Sources/Data/DTO/Request/ShopSearchRequestDTO.swift index 278f84e..8079334 100644 --- a/HotSpot/Sources/Data/DTO/Request/ShopSearchRequestDTO.swift +++ b/HotSpot/Sources/Data/DTO/Request/ShopSearchRequestDTO.swift @@ -15,6 +15,8 @@ struct ShopSearchRequestDTO { let nonSmoking: Bool? // Non-smoking availability let coupon: Bool? // Coupon availability let openNow: Bool? // Currently open filter + let page: Int? // Page number (1-based) + let pageSize: Int? // Items per page (1-100) /// Converts the DTO into a dictionary of parameters for Moya or URL encoding var asParameters: [String: Any] { @@ -35,6 +37,14 @@ struct ShopSearchRequestDTO { 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" } + + // Add pagination parameters if available + if let page = page, let pageSize = pageSize { + params["start"] = (page - 1) * pageSize + 1 + params["count"] = pageSize + } else if let start = start { + params["start"] = start + } return params } diff --git a/HotSpot/Sources/Data/DTO/Response/ShopDTO.swift b/HotSpot/Sources/Data/DTO/Response/ShopDTO.swift index d3474dc..f07afaa 100644 --- a/HotSpot/Sources/Data/DTO/Response/ShopDTO.swift +++ b/HotSpot/Sources/Data/DTO/Response/ShopDTO.swift @@ -3,34 +3,78 @@ 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 mobile: Mobile - - struct Mobile: 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 = "code" + 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, @@ -38,7 +82,7 @@ struct ShopDTO: Decodable { address: address, latitude: lat, longitude: lng, - imageUrl: photo.mobile.large, + 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 index fe5206a..08e2870 100644 --- a/HotSpot/Sources/Data/DTO/Response/ShopSearchResponseDTO.swift +++ b/HotSpot/Sources/Data/DTO/Response/ShopSearchResponseDTO.swift @@ -2,7 +2,21 @@ import Foundation struct ShopSearchResponseDTO: Decodable { let results: ShopSearchResultsDTO - + + var hasMore: Bool { + let currentEnd = results.resultsStart + (Int(results.resultsReturned) ?? 0) + return currentEnd < results.resultsAvailable + } + + var currentPage: Int { + (results.resultsStart - 1) / (Int(results.resultsReturned) ?? 1) + 1 + } + + var totalPages: Int { + let pageSize = Int(results.resultsReturned) ?? 1 + return Int(ceil(Double(results.resultsAvailable) / Double(pageSize))) + } + enum CodingKeys: String, CodingKey { case results } diff --git a/HotSpot/Sources/Data/Repository/SearchRepositoryImpl.swift b/HotSpot/Sources/Data/Repository/SearchRepositoryImpl.swift new file mode 100644 index 0000000..c007ded --- /dev/null +++ b/HotSpot/Sources/Data/Repository/SearchRepositoryImpl.swift @@ -0,0 +1,14 @@ +import Foundation + +final class SearchRepositoryImpl: SearchRepository { + private let remoteDataSource: ShopRemoteDataSource + + init(remoteDataSource: ShopRemoteDataSource) { + self.remoteDataSource = remoteDataSource + } + + func searchShops(request: ShopSearchRequestDTO, currentPage: Int) async throws -> SearchResultModel { + let useCase = InfiniteScrollSearchUseCase(repository: ShopRepositoryImpl(remoteDataSource: remoteDataSource)) + return try await useCase.execute(request: request, currentPage: currentPage) + } +} \ No newline at end of file diff --git a/HotSpot/Sources/Data/Repository/ShopRepositoryImpl.swift b/HotSpot/Sources/Data/Repository/ShopRepositoryImpl.swift index d6887a7..2b95771 100644 --- a/HotSpot/Sources/Data/Repository/ShopRepositoryImpl.swift +++ b/HotSpot/Sources/Data/Repository/ShopRepositoryImpl.swift @@ -7,8 +7,7 @@ final class ShopRepositoryImpl: ShopRepository { self.remoteDataSource = remoteDataSource } - func searchShops(request: ShopSearchRequestDTO) async throws -> [ShopModel] { - let response = try await remoteDataSource.search(request: request) - return response.results.shop.map { $0.toDomain() } + func searchShops(request: ShopSearchRequestDTO) async throws -> ShopSearchResponseDTO { + try await remoteDataSource.search(request: request) } } 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..074461f --- /dev/null +++ b/HotSpot/Sources/Domain/Model/SearchResultModel.swift @@ -0,0 +1,28 @@ +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 currentEnd = response.results.resultsStart + (Int(response.results.resultsReturned) ?? 0) + 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/Repository/SearchRepository.swift b/HotSpot/Sources/Domain/Repository/SearchRepository.swift new file mode 100644 index 0000000..a39a88f --- /dev/null +++ b/HotSpot/Sources/Domain/Repository/SearchRepository.swift @@ -0,0 +1,5 @@ +import Foundation + +protocol SearchRepository { + func searchShops(request: ShopSearchRequestDTO, currentPage: Int) async throws -> SearchResultModel +} \ No newline at end of file diff --git a/HotSpot/Sources/Domain/Repository/ShopRepository.swift b/HotSpot/Sources/Domain/Repository/ShopRepository.swift index 2c6bf36..512a093 100644 --- a/HotSpot/Sources/Domain/Repository/ShopRepository.swift +++ b/HotSpot/Sources/Domain/Repository/ShopRepository.swift @@ -1,5 +1,5 @@ import Foundation protocol ShopRepository { - func searchShops(request: ShopSearchRequestDTO) async throws -> [ShopModel] + func searchShops(request: ShopSearchRequestDTO) async throws -> ShopSearchResponseDTO } diff --git a/HotSpot/Sources/Domain/UseCase/InfiniteScrollSearchUseCase.swift b/HotSpot/Sources/Domain/UseCase/InfiniteScrollSearchUseCase.swift new file mode 100644 index 0000000..1b781fa --- /dev/null +++ b/HotSpot/Sources/Domain/UseCase/InfiniteScrollSearchUseCase.swift @@ -0,0 +1,45 @@ +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 + ) async throws -> SearchResultModel { + let paginatedRequest = ShopSearchRequestDTO( + lat: request.lat, + lng: request.lng, + range: request.range, + count: pageSize, + keyword: request.keyword, + genre: request.genre, + order: request.order, + start: (currentPage - 1) * pageSize + 1, + budget: request.budget, + privateRoom: request.privateRoom, + wifi: request.wifi, + nonSmoking: request.nonSmoking, + coupon: request.coupon, + openNow: request.openNow, + page: currentPage, + pageSize: pageSize + ) + + let response = try await repository.searchShops(request: paginatedRequest) + return SearchResultModel.from(response: response, currentPage: currentPage) + } + + func loadMore( + request: ShopSearchRequestDTO, + currentPage: Int + ) async throws -> SearchResultModel { + try await execute(request: request, currentPage: currentPage + 1) + } +} \ No newline at end of file diff --git a/HotSpot/Sources/Domain/UseCase/SearchShopsUseCase.swift b/HotSpot/Sources/Domain/UseCase/SearchShopsUseCase.swift deleted file mode 100644 index 7c47200..0000000 --- a/HotSpot/Sources/Domain/UseCase/SearchShopsUseCase.swift +++ /dev/null @@ -1,13 +0,0 @@ -import Foundation - -struct SearchShopsUseCase { - private let repository: ShopRepository - - init(repository: ShopRepository) { - self.repository = repository - } - - func execute(request: ShopSearchRequestDTO) async throws -> [ShopModel] { - try await repository.searchShops(request: request) - } -} diff --git a/HotSpot/Sources/Domain/UseCase/ShopsUseCase.swift b/HotSpot/Sources/Domain/UseCase/ShopsUseCase.swift new file mode 100644 index 0000000..0849c9a --- /dev/null +++ b/HotSpot/Sources/Domain/UseCase/ShopsUseCase.swift @@ -0,0 +1,33 @@ +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: 100, + keyword: nil, + genre: nil, + order: nil, + start: nil, + budget: nil, + privateRoom: nil, + wifi: nil, + nonSmoking: nil, + coupon: nil, + openNow: nil, + page: nil, + pageSize: nil + ) + + let response = try await repository.searchShops(request: request) + return response.results.shop.map { $0.toDomain() } + } +} diff --git a/HotSpot/Sources/Presentation/Map/MapStore.swift b/HotSpot/Sources/Presentation/Map/MapStore.swift index 40c7ead..3d90128 100644 --- a/HotSpot/Sources/Presentation/Map/MapStore.swift +++ b/HotSpot/Sources/Presentation/Map/MapStore.swift @@ -53,8 +53,11 @@ struct MapStore { case .fetchShops: return .run { [state] send in do { - let request = createSearchRequest(for: state.region) - let shops = try await shopRepository.searchShops(request: request) + let useCase = ShopsUseCase(repository: shopRepository) + let shops = try await useCase.execute( + lat: state.region.center.latitude, + lng: state.region.center.longitude + ) await send(.updateShops(shops)) } catch { await send(.handleError(error)) @@ -99,25 +102,6 @@ private extension MapStore { region.contains(CLLocationCoordinate2D(latitude: shop.latitude, longitude: shop.longitude)) } } - - func createSearchRequest(for region: MKCoordinateRegion) -> ShopSearchRequestDTO { - ShopSearchRequestDTO( - lat: region.center.latitude, - lng: region.center.longitude, - range: 5, - count: 100, - keyword: nil, - genre: nil, - order: nil, - start: nil, - budget: nil, - privateRoom: nil, - wifi: nil, - nonSmoking: nil, - coupon: nil, - openNow: nil - ) - } } // MARK: - Equatable diff --git a/HotSpot/Sources/Presentation/Search/Component/SearchResults.swift b/HotSpot/Sources/Presentation/Search/Component/SearchResults.swift index 63fc698..1b0d4e7 100644 --- a/HotSpot/Sources/Presentation/Search/Component/SearchResults.swift +++ b/HotSpot/Sources/Presentation/Search/Component/SearchResults.swift @@ -7,6 +7,8 @@ struct SearchResults: View { let searchText: String let shops: [ShopModel] let onSelectShop: (ShopModel) -> Void + let isLoading: Bool + let onLoadMore: () -> Void @State private var shopImages: [String: UIImage] = [:] var body: some View { @@ -36,9 +38,20 @@ struct SearchResults: View { loadImage(for: shop) } } + + if isLoading { + ProgressView() + .frame(maxWidth: .infinity, alignment: .center) + .padding() + } } .padding() } + .onAppear { + if !shops.isEmpty { + onLoadMore() + } + } } } .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/HotSpot/Sources/Presentation/Search/SearchStore.swift b/HotSpot/Sources/Presentation/Search/SearchStore.swift index 4796363..306066f 100644 --- a/HotSpot/Sources/Presentation/Search/SearchStore.swift +++ b/HotSpot/Sources/Presentation/Search/SearchStore.swift @@ -4,7 +4,7 @@ import ComposableArchitecture @Reducer struct SearchStore { - @Dependency(\.shopRepository) var shopRepository + @Dependency(\.searchRepository) var searchRepository @Dependency(\.locationManager) var locationManager struct State: Equatable { @@ -13,6 +13,7 @@ struct SearchStore { 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 && @@ -20,7 +21,10 @@ struct SearchStore { lhs.error == rhs.error && lhs.currentLocation?.latitude == rhs.currentLocation?.latitude && lhs.currentLocation?.longitude == rhs.currentLocation?.longitude && - lhs.selectedShop == rhs.selectedShop + lhs.selectedShop == rhs.selectedShop && + lhs.paginationState.currentPage == rhs.paginationState.currentPage && + lhs.paginationState.isLastPage == rhs.paginationState.isLastPage && + lhs.paginationState.isLoading == rhs.paginationState.isLoading } } @@ -32,6 +36,8 @@ struct SearchStore { case updateLocation(CLLocationCoordinate2D) case updateShops([ShopModel]) case handleError(Error) + case loadMore + case updatePaginationState(PaginationState) } var body: some ReducerOf { @@ -63,18 +69,72 @@ struct SearchStore { case .pop: return .none + case .loadMore: + guard !state.paginationState.isLoading && !state.paginationState.isLastPage else { + return .none + } + + state.paginationState.startLoading() + + return .run { [state] send in + do { + let location = state.currentLocation ?? CLLocationCoordinate2D(latitude: 35.6762, longitude: 139.6503) + let request = ShopSearchRequestDTO( + lat: location.latitude, + lng: location.longitude, + range: 5, + count: 20, + keyword: state.searchText, + genre: nil, + order: nil, + start: nil, + budget: nil, + privateRoom: nil, + wifi: nil, + nonSmoking: nil, + coupon: nil, + openNow: nil, + page: state.paginationState.currentPage, + pageSize: 20 + ) + + let result = try await searchRepository.searchShops( + request: request, + currentPage: state.paginationState.currentPage + ) + + await send(.updateShops(state.shops + result.shops)) + 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 + return .none + case let .search(text): - // TODO: Uncomment when back in Japan - // guard let location = state.currentLocation else { return .none } - let location = CLLocationCoordinate2D(latitude: 35.6762, longitude: 139.6503) // Tokyo + state.searchText = text + state.paginationState.reset() - return .run { send in + return .run { [state] send in do { + let location = state.currentLocation ?? CLLocationCoordinate2D(latitude: 35.6762, longitude: 139.6503) let request = ShopSearchRequestDTO( lat: location.latitude, lng: location.longitude, range: 5, - count: 100, + count: 20, keyword: text, genre: nil, order: nil, @@ -84,10 +144,22 @@ struct SearchStore { wifi: nil, nonSmoking: nil, coupon: nil, - openNow: nil + openNow: nil, + page: 1, + pageSize: 20 + ) + + let result = try await searchRepository.searchShops( + request: request, + currentPage: 1 ) - let shops = try await shopRepository.searchShops(request: request) - await send(.updateShops(shops)) + + await send(.updateShops(result.shops)) + await send(.updatePaginationState(PaginationState( + currentPage: result.currentPage, + isLastPage: !result.hasMore, + isLoading: false + ))) } catch { await send(.handleError(error)) } diff --git a/HotSpot/Sources/Presentation/Search/SearchView.swift b/HotSpot/Sources/Presentation/Search/SearchView.swift index da26e97..8599b17 100644 --- a/HotSpot/Sources/Presentation/Search/SearchView.swift +++ b/HotSpot/Sources/Presentation/Search/SearchView.swift @@ -29,7 +29,13 @@ struct SearchView: View { error: viewStore.error, searchText: viewStore.searchText, shops: viewStore.shops, - onSelectShop: { viewStore.send(.selectShop($0)) } + onSelectShop: { viewStore.send(.selectShop($0)) }, + isLoading: viewStore.paginationState.isLoading, + onLoadMore: { + if !viewStore.paginationState.isLastPage { + viewStore.send(.loadMore) + } + } ) } .navigationBarHidden(true) From efdb2d844539b36ed51f7e7bb11fb248be042d7a Mon Sep 17 00:00:00 2001 From: coby5502 Date: Sun, 20 Apr 2025 15:28:50 +0900 Subject: [PATCH 17/21] [FIX] remove same file --- .../SearchRepository+Dependency.swift | 14 ----------- .../Repository/SearchRepositoryImpl.swift | 14 ----------- HotSpot/Sources/Domain/Model/Location.swift | 25 ------------------- .../Domain/Repository/SearchRepository.swift | 5 ---- .../UseCase/InfiniteScrollSearchUseCase.swift | 21 +--------------- .../Presentation/Search/SearchStore.swift | 8 +++--- 6 files changed, 6 insertions(+), 81 deletions(-) delete mode 100644 HotSpot/Sources/Common/Dependency/SearchRepository+Dependency.swift delete mode 100644 HotSpot/Sources/Data/Repository/SearchRepositoryImpl.swift delete mode 100644 HotSpot/Sources/Domain/Model/Location.swift delete mode 100644 HotSpot/Sources/Domain/Repository/SearchRepository.swift diff --git a/HotSpot/Sources/Common/Dependency/SearchRepository+Dependency.swift b/HotSpot/Sources/Common/Dependency/SearchRepository+Dependency.swift deleted file mode 100644 index 963b618..0000000 --- a/HotSpot/Sources/Common/Dependency/SearchRepository+Dependency.swift +++ /dev/null @@ -1,14 +0,0 @@ -import ComposableArchitecture - -private enum SearchRepositoryKey: DependencyKey { - static let liveValue: SearchRepository = SearchRepositoryImpl( - remoteDataSource: ShopRemoteDataSourceImpl() - ) -} - -extension DependencyValues { - var searchRepository: SearchRepository { - get { self[SearchRepositoryKey.self] } - set { self[SearchRepositoryKey.self] = newValue } - } -} \ No newline at end of file diff --git a/HotSpot/Sources/Data/Repository/SearchRepositoryImpl.swift b/HotSpot/Sources/Data/Repository/SearchRepositoryImpl.swift deleted file mode 100644 index c007ded..0000000 --- a/HotSpot/Sources/Data/Repository/SearchRepositoryImpl.swift +++ /dev/null @@ -1,14 +0,0 @@ -import Foundation - -final class SearchRepositoryImpl: SearchRepository { - private let remoteDataSource: ShopRemoteDataSource - - init(remoteDataSource: ShopRemoteDataSource) { - self.remoteDataSource = remoteDataSource - } - - func searchShops(request: ShopSearchRequestDTO, currentPage: Int) async throws -> SearchResultModel { - let useCase = InfiniteScrollSearchUseCase(repository: ShopRepositoryImpl(remoteDataSource: remoteDataSource)) - return try await useCase.execute(request: request, currentPage: currentPage) - } -} \ No newline at end of file diff --git a/HotSpot/Sources/Domain/Model/Location.swift b/HotSpot/Sources/Domain/Model/Location.swift deleted file mode 100644 index d74dd4e..0000000 --- a/HotSpot/Sources/Domain/Model/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/Repository/SearchRepository.swift b/HotSpot/Sources/Domain/Repository/SearchRepository.swift deleted file mode 100644 index a39a88f..0000000 --- a/HotSpot/Sources/Domain/Repository/SearchRepository.swift +++ /dev/null @@ -1,5 +0,0 @@ -import Foundation - -protocol SearchRepository { - func searchShops(request: ShopSearchRequestDTO, currentPage: Int) async throws -> SearchResultModel -} \ No newline at end of file diff --git a/HotSpot/Sources/Domain/UseCase/InfiniteScrollSearchUseCase.swift b/HotSpot/Sources/Domain/UseCase/InfiniteScrollSearchUseCase.swift index 1b781fa..9cfd064 100644 --- a/HotSpot/Sources/Domain/UseCase/InfiniteScrollSearchUseCase.swift +++ b/HotSpot/Sources/Domain/UseCase/InfiniteScrollSearchUseCase.swift @@ -13,26 +13,7 @@ struct InfiniteScrollSearchUseCase { request: ShopSearchRequestDTO, currentPage: Int ) async throws -> SearchResultModel { - let paginatedRequest = ShopSearchRequestDTO( - lat: request.lat, - lng: request.lng, - range: request.range, - count: pageSize, - keyword: request.keyword, - genre: request.genre, - order: request.order, - start: (currentPage - 1) * pageSize + 1, - budget: request.budget, - privateRoom: request.privateRoom, - wifi: request.wifi, - nonSmoking: request.nonSmoking, - coupon: request.coupon, - openNow: request.openNow, - page: currentPage, - pageSize: pageSize - ) - - let response = try await repository.searchShops(request: paginatedRequest) + let response = try await repository.searchShops(request: request) return SearchResultModel.from(response: response, currentPage: currentPage) } diff --git a/HotSpot/Sources/Presentation/Search/SearchStore.swift b/HotSpot/Sources/Presentation/Search/SearchStore.swift index 306066f..60ffcb8 100644 --- a/HotSpot/Sources/Presentation/Search/SearchStore.swift +++ b/HotSpot/Sources/Presentation/Search/SearchStore.swift @@ -4,7 +4,7 @@ import ComposableArchitecture @Reducer struct SearchStore { - @Dependency(\.searchRepository) var searchRepository + @Dependency(\.shopRepository) var shopRepository @Dependency(\.locationManager) var locationManager struct State: Equatable { @@ -98,7 +98,8 @@ struct SearchStore { pageSize: 20 ) - let result = try await searchRepository.searchShops( + let useCase = InfiniteScrollSearchUseCase(repository: shopRepository) + let result = try await useCase.loadMore( request: request, currentPage: state.paginationState.currentPage ) @@ -149,7 +150,8 @@ struct SearchStore { pageSize: 20 ) - let result = try await searchRepository.searchShops( + let useCase = InfiniteScrollSearchUseCase(repository: shopRepository) + let result = try await useCase.execute( request: request, currentPage: 1 ) From 6dc52819dc94041a48277e9665ea66cb551df78d Mon Sep 17 00:00:00 2001 From: coby5502 Date: Sun, 20 Apr 2025 15:54:26 +0900 Subject: [PATCH 18/21] [CHORE] close keyboard --- .../DTO/Request/ShopSearchRequestDTO.swift | 16 ++------- .../UseCase/InfiniteScrollSearchUseCase.swift | 34 +++++++++++++------ .../Sources/Domain/UseCase/ShopsUseCase.swift | 6 ++-- .../Search/Component/SearchBar.swift | 9 ++++- .../Search/Component/SearchResults.swift | 9 ++++- .../Presentation/Search/SearchStore.swift | 21 +++++------- .../Presentation/Search/SearchView.swift | 6 ++-- 7 files changed, 56 insertions(+), 45 deletions(-) diff --git a/HotSpot/Sources/Data/DTO/Request/ShopSearchRequestDTO.swift b/HotSpot/Sources/Data/DTO/Request/ShopSearchRequestDTO.swift index 8079334..55cd645 100644 --- a/HotSpot/Sources/Data/DTO/Request/ShopSearchRequestDTO.swift +++ b/HotSpot/Sources/Data/DTO/Request/ShopSearchRequestDTO.swift @@ -4,7 +4,7 @@ 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 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 @@ -15,18 +15,16 @@ struct ShopSearchRequestDTO { let nonSmoking: Bool? // Non-smoking availability let coupon: Bool? // Coupon availability let openNow: Bool? // Currently open filter - let page: Int? // Page number (1-based) - let pageSize: Int? // Items per page (1-100) /// 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, - "count": count + "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 } @@ -37,14 +35,6 @@ struct ShopSearchRequestDTO { 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" } - - // Add pagination parameters if available - if let page = page, let pageSize = pageSize { - params["start"] = (page - 1) * pageSize + 1 - params["count"] = pageSize - } else if let start = start { - params["start"] = start - } return params } diff --git a/HotSpot/Sources/Domain/UseCase/InfiniteScrollSearchUseCase.swift b/HotSpot/Sources/Domain/UseCase/InfiniteScrollSearchUseCase.swift index 9cfd064..4a2e8e4 100644 --- a/HotSpot/Sources/Domain/UseCase/InfiniteScrollSearchUseCase.swift +++ b/HotSpot/Sources/Domain/UseCase/InfiniteScrollSearchUseCase.swift @@ -11,16 +11,30 @@ struct InfiniteScrollSearchUseCase { func execute( request: ShopSearchRequestDTO, - currentPage: Int + currentPage: Int, + isLoadMore: Bool = false ) async throws -> SearchResultModel { - let response = try await repository.searchShops(request: request) - return SearchResultModel.from(response: response, currentPage: currentPage) - } - - func loadMore( - request: ShopSearchRequestDTO, - currentPage: Int - ) async throws -> SearchResultModel { - try await execute(request: request, currentPage: currentPage + 1) + 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) } } \ No newline at end of file diff --git a/HotSpot/Sources/Domain/UseCase/ShopsUseCase.swift b/HotSpot/Sources/Domain/UseCase/ShopsUseCase.swift index 0849c9a..dfddd83 100644 --- a/HotSpot/Sources/Domain/UseCase/ShopsUseCase.swift +++ b/HotSpot/Sources/Domain/UseCase/ShopsUseCase.swift @@ -12,7 +12,7 @@ struct ShopsUseCase { lat: lat, lng: lng, range: 5, - count: 100, + count: nil, keyword: nil, genre: nil, order: nil, @@ -22,9 +22,7 @@ struct ShopsUseCase { wifi: nil, nonSmoking: nil, coupon: nil, - openNow: nil, - page: nil, - pageSize: nil + openNow: nil ) let response = try await repository.searchShops(request: request) diff --git a/HotSpot/Sources/Presentation/Search/Component/SearchBar.swift b/HotSpot/Sources/Presentation/Search/Component/SearchBar.swift index 854334b..9cbbaa0 100644 --- a/HotSpot/Sources/Presentation/Search/Component/SearchBar.swift +++ b/HotSpot/Sources/Presentation/Search/Component/SearchBar.swift @@ -3,7 +3,8 @@ import SwiftUI struct SearchBar: View { let searchText: String let onSearch: (String) -> Void - @FocusState var isFocused: Bool + @FocusState private var isFocused: Bool + @Binding var isSearchFocused: Bool var body: some View { HStack { @@ -20,5 +21,11 @@ struct SearchBar: View { .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 index 1b0d4e7..96e246a 100644 --- a/HotSpot/Sources/Presentation/Search/Component/SearchResults.swift +++ b/HotSpot/Sources/Presentation/Search/Component/SearchResults.swift @@ -16,7 +16,8 @@ struct SearchResults: View { if let error = error { Text(error) .foregroundColor(.red) - .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if searchText.isEmpty { + EmptyResults(searchText: searchText) } else if shops.isEmpty { EmptyResults(searchText: searchText) } else { @@ -52,6 +53,12 @@ struct SearchResults: View { onLoadMore() } } + .simultaneousGesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } + ) } } .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/HotSpot/Sources/Presentation/Search/SearchStore.swift b/HotSpot/Sources/Presentation/Search/SearchStore.swift index 60ffcb8..530e27c 100644 --- a/HotSpot/Sources/Presentation/Search/SearchStore.swift +++ b/HotSpot/Sources/Presentation/Search/SearchStore.swift @@ -78,12 +78,12 @@ struct SearchStore { return .run { [state] send in do { - let location = state.currentLocation ?? CLLocationCoordinate2D(latitude: 35.6762, longitude: 139.6503) + let location = state.currentLocation ?? CLLocationCoordinate2D(latitude: 34.6937, longitude: 135.5023) let request = ShopSearchRequestDTO( lat: location.latitude, lng: location.longitude, range: 5, - count: 20, + count: nil, keyword: state.searchText, genre: nil, order: nil, @@ -93,15 +93,14 @@ struct SearchStore { wifi: nil, nonSmoking: nil, coupon: nil, - openNow: nil, - page: state.paginationState.currentPage, - pageSize: 20 + openNow: nil ) let useCase = InfiniteScrollSearchUseCase(repository: shopRepository) - let result = try await useCase.loadMore( + let result = try await useCase.execute( request: request, - currentPage: state.paginationState.currentPage + currentPage: state.paginationState.currentPage, + isLoadMore: true ) await send(.updateShops(state.shops + result.shops)) @@ -130,12 +129,12 @@ struct SearchStore { return .run { [state] send in do { - let location = state.currentLocation ?? CLLocationCoordinate2D(latitude: 35.6762, longitude: 139.6503) + let location = state.currentLocation ?? CLLocationCoordinate2D(latitude: 34.6937, longitude: 135.5023) let request = ShopSearchRequestDTO( lat: location.latitude, lng: location.longitude, range: 5, - count: 20, + count: nil, keyword: text, genre: nil, order: nil, @@ -145,9 +144,7 @@ struct SearchStore { wifi: nil, nonSmoking: nil, coupon: nil, - openNow: nil, - page: 1, - pageSize: 20 + openNow: nil ) let useCase = InfiniteScrollSearchUseCase(repository: shopRepository) diff --git a/HotSpot/Sources/Presentation/Search/SearchView.swift b/HotSpot/Sources/Presentation/Search/SearchView.swift index 8599b17..38e8d42 100644 --- a/HotSpot/Sources/Presentation/Search/SearchView.swift +++ b/HotSpot/Sources/Presentation/Search/SearchView.swift @@ -22,7 +22,8 @@ struct SearchView: View { SearchBar( searchText: viewStore.searchText, - onSearch: { viewStore.send(.search($0)) } + onSearch: { viewStore.send(.search($0)) }, + isSearchFocused: $isSearchFocused ) SearchResults( @@ -39,9 +40,6 @@ struct SearchView: View { ) } .navigationBarHidden(true) - .onTapGesture { - isSearchFocused = false - } .onAppear { viewStore.send(.onAppear) } From 5dae137943347ee7b77eb82d00b272d6cdb5aa03 Mon Sep 17 00:00:00 2001 From: coby5502 Date: Sun, 20 Apr 2025 16:12:03 +0900 Subject: [PATCH 19/21] [FIX] fix infinite scroll --- .../DTO/Response/ShopSearchResponseDTO.swift | 14 ----- .../Domain/Model/SearchResultModel.swift | 3 +- .../UseCase/InfiniteScrollSearchUseCase.swift | 2 +- .../Search/Component/SearchResults.swift | 15 +---- .../Presentation/Search/SearchStore.swift | 63 ++++++++++++------- .../Presentation/Search/SearchView.swift | 4 +- 6 files changed, 48 insertions(+), 53 deletions(-) diff --git a/HotSpot/Sources/Data/DTO/Response/ShopSearchResponseDTO.swift b/HotSpot/Sources/Data/DTO/Response/ShopSearchResponseDTO.swift index 08e2870..643d7cc 100644 --- a/HotSpot/Sources/Data/DTO/Response/ShopSearchResponseDTO.swift +++ b/HotSpot/Sources/Data/DTO/Response/ShopSearchResponseDTO.swift @@ -3,20 +3,6 @@ import Foundation struct ShopSearchResponseDTO: Decodable { let results: ShopSearchResultsDTO - var hasMore: Bool { - let currentEnd = results.resultsStart + (Int(results.resultsReturned) ?? 0) - return currentEnd < results.resultsAvailable - } - - var currentPage: Int { - (results.resultsStart - 1) / (Int(results.resultsReturned) ?? 1) + 1 - } - - var totalPages: Int { - let pageSize = Int(results.resultsReturned) ?? 1 - return Int(ceil(Double(results.resultsAvailable) / Double(pageSize))) - } - enum CodingKeys: String, CodingKey { case results } diff --git a/HotSpot/Sources/Domain/Model/SearchResultModel.swift b/HotSpot/Sources/Domain/Model/SearchResultModel.swift index 074461f..10f0bbc 100644 --- a/HotSpot/Sources/Domain/Model/SearchResultModel.swift +++ b/HotSpot/Sources/Domain/Model/SearchResultModel.swift @@ -15,7 +15,8 @@ struct SearchResultModel { static func from(response: ShopSearchResponseDTO, currentPage: Int) -> SearchResultModel { let shops = response.results.shop.map { $0.toDomain() } - let currentEnd = response.results.resultsStart + (Int(response.results.resultsReturned) ?? 0) + let resultsReturned = Int(response.results.resultsReturned) ?? 1 + let currentEnd = response.results.resultsStart + resultsReturned let hasMore = currentEnd < response.results.resultsAvailable return SearchResultModel( diff --git a/HotSpot/Sources/Domain/UseCase/InfiniteScrollSearchUseCase.swift b/HotSpot/Sources/Domain/UseCase/InfiniteScrollSearchUseCase.swift index 4a2e8e4..7fb3bc1 100644 --- a/HotSpot/Sources/Domain/UseCase/InfiniteScrollSearchUseCase.swift +++ b/HotSpot/Sources/Domain/UseCase/InfiniteScrollSearchUseCase.swift @@ -37,4 +37,4 @@ struct InfiniteScrollSearchUseCase { let response = try await repository.searchShops(request: paginatedRequest) return SearchResultModel.from(response: response, currentPage: targetPage) } -} \ No newline at end of file +} diff --git a/HotSpot/Sources/Presentation/Search/Component/SearchResults.swift b/HotSpot/Sources/Presentation/Search/Component/SearchResults.swift index 96e246a..9526797 100644 --- a/HotSpot/Sources/Presentation/Search/Component/SearchResults.swift +++ b/HotSpot/Sources/Presentation/Search/Component/SearchResults.swift @@ -7,7 +7,6 @@ struct SearchResults: View { let searchText: String let shops: [ShopModel] let onSelectShop: (ShopModel) -> Void - let isLoading: Bool let onLoadMore: () -> Void @State private var shopImages: [String: UIImage] = [:] @@ -37,22 +36,14 @@ struct SearchResults: View { } .onAppear { loadImage(for: shop) + if shop.id == shops.last?.id { + onLoadMore() + } } } - - if isLoading { - ProgressView() - .frame(maxWidth: .infinity, alignment: .center) - .padding() - } } .padding() } - .onAppear { - if !shops.isEmpty { - onLoadMore() - } - } .simultaneousGesture( DragGesture(minimumDistance: 0) .onChanged { _ in diff --git a/HotSpot/Sources/Presentation/Search/SearchStore.swift b/HotSpot/Sources/Presentation/Search/SearchStore.swift index 530e27c..a9d0b1d 100644 --- a/HotSpot/Sources/Presentation/Search/SearchStore.swift +++ b/HotSpot/Sources/Presentation/Search/SearchStore.swift @@ -69,12 +69,12 @@ struct SearchStore { case .pop: return .none - case .loadMore: - guard !state.paginationState.isLoading && !state.paginationState.isLastPage else { - return .none - } + case let .search(text): + guard text != state.searchText else { return .none } - state.paginationState.startLoading() + state.searchText = text + state.paginationState.reset() + print("Search started - text: \(text)") return .run { [state] send in do { @@ -84,7 +84,7 @@ struct SearchStore { lng: location.longitude, range: 5, count: nil, - keyword: state.searchText, + keyword: text, genre: nil, order: nil, start: nil, @@ -99,11 +99,12 @@ struct SearchStore { let useCase = InfiniteScrollSearchUseCase(repository: shopRepository) let result = try await useCase.execute( request: request, - currentPage: state.paginationState.currentPage, - isLoadMore: true + currentPage: 1 ) - await send(.updateShops(state.shops + result.shops)) + 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, @@ -111,21 +112,17 @@ struct SearchStore { ))) } 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 - return .none - - case let .search(text): - state.searchText = text - state.paginationState.reset() + case .loadMore: + 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 + } + + state.paginationState.startLoading() + print("Loading more - currentPage: \(state.paginationState.currentPage)") return .run { [state] send in do { @@ -135,7 +132,7 @@ struct SearchStore { lng: location.longitude, range: 5, count: nil, - keyword: text, + keyword: state.searchText, genre: nil, order: nil, start: nil, @@ -150,10 +147,18 @@ struct SearchStore { let useCase = InfiniteScrollSearchUseCase(repository: shopRepository) let result = try await useCase.execute( request: request, - currentPage: 1 + currentPage: state.paginationState.currentPage, + isLoadMore: true ) - await send(.updateShops(result.shops)) + 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, @@ -161,8 +166,18 @@ struct SearchStore { ))) } 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 } } } diff --git a/HotSpot/Sources/Presentation/Search/SearchView.swift b/HotSpot/Sources/Presentation/Search/SearchView.swift index 38e8d42..072f7d0 100644 --- a/HotSpot/Sources/Presentation/Search/SearchView.swift +++ b/HotSpot/Sources/Presentation/Search/SearchView.swift @@ -31,7 +31,6 @@ struct SearchView: View { searchText: viewStore.searchText, shops: viewStore.shops, onSelectShop: { viewStore.send(.selectShop($0)) }, - isLoading: viewStore.paginationState.isLoading, onLoadMore: { if !viewStore.paginationState.isLastPage { viewStore.send(.loadMore) @@ -43,6 +42,9 @@ struct SearchView: View { .onAppear { viewStore.send(.onAppear) } + .onTapGesture { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } } } } From 67d2bdef6e97cdd7268c8c090e51460fb0978057 Mon Sep 17 00:00:00 2001 From: coby5502 Date: Mon, 21 Apr 2025 05:53:11 +0900 Subject: [PATCH 20/21] [FIX] remove BindableAction --- .../Sources/Presentation/Map/MapStore.swift | 36 +++++++------------ .../ShopDetail/ShopDetailStore.swift | 10 ++---- 2 files changed, 15 insertions(+), 31 deletions(-) diff --git a/HotSpot/Sources/Presentation/Map/MapStore.swift b/HotSpot/Sources/Presentation/Map/MapStore.swift index 3d90128..3d053ee 100644 --- a/HotSpot/Sources/Presentation/Map/MapStore.swift +++ b/HotSpot/Sources/Presentation/Map/MapStore.swift @@ -19,44 +19,36 @@ struct MapStore { var lastFetchedLocation: CLLocationCoordinate2D? = nil } - enum Action: BindableAction { - case binding(BindingAction) - case fetchShops - case showSearch - case showShopDetail(ShopModel) + enum Action { case updateRegion(MKCoordinateRegion) + case fetchShops case updateShops([ShopModel]) case handleError(Error) + case showSearch + case showShopDetail(ShopModel) } var body: some ReducerOf { - BindingReducer() - Reduce { state, action in switch action { - case .binding: - return .none - case let .updateRegion(region): state.region = region - + if shouldFetchNewData(state: state, newRegion: region) { state.lastFetchedLocation = region.center - return .run { send in - await send(.fetchShops) - } + return .send(.fetchShops) } state.visibleShops = filterVisibleShops(state.shops, in: region) return .none case .fetchShops: - return .run { [state] send in + return .run { [region = state.region] send in do { let useCase = ShopsUseCase(repository: shopRepository) let shops = try await useCase.execute( - lat: state.region.center.latitude, - lng: state.region.center.longitude + lat: region.center.latitude, + lng: region.center.longitude ) await send(.updateShops(shops)) } catch { @@ -82,21 +74,19 @@ struct MapStore { } } } -} -// MARK: - Private Helpers -private extension MapStore { + // 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 region.contains(CLLocationCoordinate2D(latitude: shop.latitude, longitude: shop.longitude)) diff --git a/HotSpot/Sources/Presentation/ShopDetail/ShopDetailStore.swift b/HotSpot/Sources/Presentation/ShopDetail/ShopDetailStore.swift index e608dde..6e217d7 100644 --- a/HotSpot/Sources/Presentation/ShopDetail/ShopDetailStore.swift +++ b/HotSpot/Sources/Presentation/ShopDetail/ShopDetailStore.swift @@ -9,22 +9,16 @@ struct ShopDetailStore { let shop: ShopModel } - enum Action: BindableAction { - case binding(BindingAction) + enum Action { case pop } var body: some ReducerOf { - BindingReducer() - Reduce { state, action in switch action { - case .binding: - return .none - case .pop: return .none } } } -} +} From 30497eaf81cbab4d769c904d5df4e1b88d520509 Mon Sep 17 00:00:00 2001 From: coby5502 Date: Mon, 21 Apr 2025 06:33:27 +0900 Subject: [PATCH 21/21] [FIX] fix error --- .../Common/Extensions/MapKit+Extension.swift | 24 ------------------- .../Sources/Presentation/Map/MapStore.swift | 11 ++++++++- 2 files changed, 10 insertions(+), 25 deletions(-) delete mode 100644 HotSpot/Sources/Common/Extensions/MapKit+Extension.swift diff --git a/HotSpot/Sources/Common/Extensions/MapKit+Extension.swift b/HotSpot/Sources/Common/Extensions/MapKit+Extension.swift deleted file mode 100644 index 6c5ce68..0000000 --- a/HotSpot/Sources/Common/Extensions/MapKit+Extension.swift +++ /dev/null @@ -1,24 +0,0 @@ -import MapKit - -extension MKCoordinateRegion { - var northEast: CLLocationCoordinate2D { - CLLocationCoordinate2D( - latitude: center.latitude + span.latitudeDelta / 2, - longitude: center.longitude + span.longitudeDelta / 2 - ) - } - - var southWest: CLLocationCoordinate2D { - CLLocationCoordinate2D( - latitude: center.latitude - span.latitudeDelta / 2, - longitude: center.longitude - span.longitudeDelta / 2 - ) - } - - func contains(_ coordinate: CLLocationCoordinate2D) -> Bool { - coordinate.latitude <= northEast.latitude && - coordinate.latitude >= southWest.latitude && - coordinate.longitude <= northEast.longitude && - coordinate.longitude >= southWest.longitude - } -} diff --git a/HotSpot/Sources/Presentation/Map/MapStore.swift b/HotSpot/Sources/Presentation/Map/MapStore.swift index 3d053ee..832661f 100644 --- a/HotSpot/Sources/Presentation/Map/MapStore.swift +++ b/HotSpot/Sources/Presentation/Map/MapStore.swift @@ -89,7 +89,16 @@ struct MapStore { func filterVisibleShops(_ shops: [ShopModel], in region: MKCoordinateRegion) -> [ShopModel] { shops.filter { shop in - region.contains(CLLocationCoordinate2D(latitude: shop.latitude, longitude: shop.longitude)) + 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 } } }