From 38b146b59ffa35856801594e59aec28ed553e318 Mon Sep 17 00:00:00 2001 From: coby5502 Date: Sat, 19 Apr 2025 19:06:42 +0900 Subject: [PATCH 1/6] [ADD] map, search screen --- HotSpot/Sources/Domain/Entity/Location.swift | 25 +++ .../Sources/Domain/Entity/Restaurant.swift | 6 +- .../Domain/Service/LocationManager.swift | 49 ++++++ .../Domain/Service/RestaurantClient.swift | 39 +++++ .../Coordinator/AppCoordinator.swift | 49 +++--- .../Coordinator/AppCoordinatorView.swift | 25 ++- .../Map/Component/MapRepresentableView.swift | 146 ++++++++++++++++++ .../Sources/Presentation/Map/MapStore.swift | 77 +++++++++ .../Sources/Presentation/Map/MapView.swift | 57 +++++++ .../RestaurantDetailStore.swift | 1 + .../RestaurantDetailView.swift | 7 +- .../Presentation/Search/RestaurantCell.swift | 62 ++++++++ .../Presentation/Search/SearchStore.swift | 87 +++++++++-- .../Presentation/Search/SearchView.swift | 109 ++++++++++++- Tuist.swift | 9 -- Tuist/Package.resolved | 4 +- Tuist/Package.swift | 2 +- 17 files changed, 687 insertions(+), 67 deletions(-) create mode 100644 HotSpot/Sources/Domain/Entity/Location.swift create mode 100644 HotSpot/Sources/Domain/Service/LocationManager.swift create mode 100644 HotSpot/Sources/Domain/Service/RestaurantClient.swift create mode 100644 HotSpot/Sources/Presentation/Map/Component/MapRepresentableView.swift create mode 100644 HotSpot/Sources/Presentation/Map/MapStore.swift create mode 100644 HotSpot/Sources/Presentation/Map/MapView.swift create mode 100644 HotSpot/Sources/Presentation/Search/RestaurantCell.swift delete mode 100644 Tuist.swift diff --git a/HotSpot/Sources/Domain/Entity/Location.swift b/HotSpot/Sources/Domain/Entity/Location.swift new file mode 100644 index 0000000..d74dd4e --- /dev/null +++ b/HotSpot/Sources/Domain/Entity/Location.swift @@ -0,0 +1,25 @@ +import Foundation +import MapKit + +struct Location: Codable, Hashable { + var lat: Double + var lon: Double +} + +extension Location { + func toCLLocationCoordinate2D() -> CLLocationCoordinate2D { + CLLocationCoordinate2D( + latitude: self.lat, + longitude: self.lon + ) + } +} + +extension CLLocationCoordinate2D { + func toLocation() -> Location { + Location( + lat: self.latitude, + lon: self.longitude + ) + } +} diff --git a/HotSpot/Sources/Domain/Entity/Restaurant.swift b/HotSpot/Sources/Domain/Entity/Restaurant.swift index ec1a4d2..2d81f8f 100644 --- a/HotSpot/Sources/Domain/Entity/Restaurant.swift +++ b/HotSpot/Sources/Domain/Entity/Restaurant.swift @@ -6,12 +6,14 @@ struct Restaurant: Identifiable, Equatable { 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) { + init(id: UUID = UUID(), name: String, address: String, imageURL: URL? = nil, phone: String? = nil, location: Location?) { self.id = id self.name = name self.address = address self.imageURL = imageURL self.phone = phone + self.location = location } -} \ No newline at end of file +} diff --git a/HotSpot/Sources/Domain/Service/LocationManager.swift b/HotSpot/Sources/Domain/Service/LocationManager.swift new file mode 100644 index 0000000..652894a --- /dev/null +++ b/HotSpot/Sources/Domain/Service/LocationManager.swift @@ -0,0 +1,49 @@ +import Foundation +import CoreLocation +import ComposableArchitecture +import Dependencies + +struct LocationManager: DependencyKey { + static var liveValue: LocationManager = .live + + var requestLocation: @Sendable () async -> CLLocation? + + static let live = Self( + requestLocation: { + let manager = CLLocationManager() + manager.requestWhenInUseAuthorization() + + return await withCheckedContinuation { continuation in + let delegate = LocationDelegate(continuation: continuation) + manager.delegate = delegate + manager.requestLocation() + + // Keep the delegate alive until the location is received + _ = delegate + } + } + ) +} + +private class LocationDelegate: NSObject, CLLocationManagerDelegate { + let continuation: CheckedContinuation + + init(continuation: CheckedContinuation) { + self.continuation = continuation + } + + func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + continuation.resume(returning: locations.first) + } + + func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + continuation.resume(returning: nil) + } +} + +extension DependencyValues { + var locationManager: LocationManager { + get { self[LocationManager.self] } + set { self[LocationManager.self] = newValue } + } +} \ No newline at end of file diff --git a/HotSpot/Sources/Domain/Service/RestaurantClient.swift b/HotSpot/Sources/Domain/Service/RestaurantClient.swift new file mode 100644 index 0000000..114de23 --- /dev/null +++ b/HotSpot/Sources/Domain/Service/RestaurantClient.swift @@ -0,0 +1,39 @@ +import Foundation +import ComposableArchitecture +import Dependencies + +struct RestaurantClient: DependencyKey { + static var liveValue: RestaurantClient = .live + + var searchRestaurants: @Sendable (_ latitude: Double, _ longitude: Double, _ radius: Int) async throws -> [Restaurant] + + static let live = Self( + searchRestaurants: { latitude, longitude, radius in + // TODO: Implement actual API call + // For now, return mock data + return [ + Restaurant( + name: "맛있는 식당", + address: "서울시 강남구 테헤란로 123", + imageURL: URL(string: "https://example.com/image1.jpg"), + phone: "02-123-4567", + location: Location(lat: latitude, lon: longitude) + ), + Restaurant( + name: "맛있는 카페", + address: "서울시 강남구 테헤란로 456", + imageURL: URL(string: "https://example.com/image2.jpg"), + phone: "02-765-4321", + location: Location(lat: latitude + 0.001, lon: longitude + 0.001) + ) + ] + } + ) +} + +extension DependencyValues { + var restaurantClient: RestaurantClient { + get { self[RestaurantClient.self] } + set { self[RestaurantClient.self] = newValue } + } +} \ No newline at end of file diff --git a/HotSpot/Sources/Presentation/Coordinator/AppCoordinator.swift b/HotSpot/Sources/Presentation/Coordinator/AppCoordinator.swift index 25e6f24..3809298 100644 --- a/HotSpot/Sources/Presentation/Coordinator/AppCoordinator.swift +++ b/HotSpot/Sources/Presentation/Coordinator/AppCoordinator.swift @@ -4,42 +4,48 @@ import ComposableArchitecture @Reducer struct AppCoordinator { struct State: Equatable { - var search: SearchStore.State = .init() + var map: MapStore.State = .init() + var search: SearchStore.State? = nil var restaurantDetail: RestaurantDetailStore.State? var favorite: FavoriteStore.State? var settings: SettingsStore.State? + var selectedRestaurantId: UUID? } enum Action { + case map(MapStore.Action) case search(SearchStore.Action) case restaurantDetail(RestaurantDetailStore.Action) case favorite(FavoriteStore.Action) case settings(SettingsStore.Action) - case showRestaurantDetail + case showRestaurantDetail(UUID) case navigateToFavorite case navigateToSettings + case showSearch + case dismissSearch case dismissDetail case dismissFavorite case dismissSettings } var body: some ReducerOf { - Scope(state: \.search, action: \.search) { - SearchStore() - } - Reduce { state, action in switch action { - case .search(.showRestaurantDetail): - state.restaurantDetail = .init() + case .map(.showSearch), .showSearch: + state.search = .init() return .none - case .search(.navigateToSettings): - state.settings = .init() + case .search(.pop), .dismissSearch: + state.search = nil return .none - case .showRestaurantDetail: + case let .search(.selectRestaurant(restaurant)): + state.selectedRestaurantId = restaurant.id + return .send(.showRestaurantDetail(restaurant.id)) + + case let .showRestaurantDetail(id): + state.restaurantDetail = .init(restaurantId: id) return .none case .navigateToFavorite: @@ -52,6 +58,7 @@ struct AppCoordinator { case .dismissDetail: state.restaurantDetail = nil + state.selectedRestaurantId = nil return .none case .dismissFavorite: @@ -62,27 +69,9 @@ struct AppCoordinator { state.settings = nil return .none - case .search: - return .none - - case .restaurantDetail: - return .none - - case .favorite: - return .none - - case .settings: + case .map, .search, .restaurantDetail, .favorite, .settings: return .none } } - .ifLet(\.restaurantDetail, action: \.restaurantDetail) { - RestaurantDetailStore() - } - .ifLet(\.favorite, action: \.favorite) { - FavoriteStore() - } - .ifLet(\.settings, action: \.settings) { - SettingsStore() - } } } diff --git a/HotSpot/Sources/Presentation/Coordinator/AppCoordinatorView.swift b/HotSpot/Sources/Presentation/Coordinator/AppCoordinatorView.swift index c27555b..0b220cc 100644 --- a/HotSpot/Sources/Presentation/Coordinator/AppCoordinatorView.swift +++ b/HotSpot/Sources/Presentation/Coordinator/AppCoordinatorView.swift @@ -3,12 +3,29 @@ import ComposableArchitecture struct AppCoordinatorView: View { let store: StoreOf - + var body: some View { WithViewStore(store, observe: { $0 }) { viewStore in NavigationView { VStack { - SearchView(store: store.scope(state: \.search, action: \.search)) + MapView(store: store.scope(state: \.map, action: \.map)) + .ignoresSafeArea(.all, edges: .bottom) + + 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( @@ -19,7 +36,7 @@ struct AppCoordinatorView: View { ), isActive: viewStore.binding( get: { $0.restaurantDetail != nil }, - send: { $0 ? .showRestaurantDetail : .dismissDetail } + send: { $0 ? .showRestaurantDetail(viewStore.selectedRestaurantId ?? UUID()) : .dismissDetail } ) ) { EmptyView() @@ -58,7 +75,9 @@ struct AppCoordinatorView: View { } .hidden() } + .navigationBarHidden(true) } + .navigationViewStyle(.stack) } } } diff --git a/HotSpot/Sources/Presentation/Map/Component/MapRepresentableView.swift b/HotSpot/Sources/Presentation/Map/Component/MapRepresentableView.swift new file mode 100644 index 0000000..899f69a --- /dev/null +++ b/HotSpot/Sources/Presentation/Map/Component/MapRepresentableView.swift @@ -0,0 +1,146 @@ +import SwiftUI +import MapKit + +import CobyDS + +// Custom Annotation Class +class RestaurantAnnotation: NSObject, MKAnnotation { + var coordinate: CLLocationCoordinate2D + var title: String? + + init(coordinate: CLLocationCoordinate2D, title: String?) { + self.coordinate = coordinate + self.title = title + } +} + +// Custom Annotation View Class +class RestaurantAnnotationView: MKAnnotationView { + static let reuseIdentifier = "RestaurantAnnotationView" + + override var annotation: MKAnnotation? { + willSet { + guard let restaurantAnnotation = newValue as? RestaurantAnnotation else { return } + + canShowCallout = true + calloutOffset = CGPoint(x: -5, y: 5) + rightCalloutAccessoryView = UIButton(type: .detailDisclosure) + } + } + + private var iconImageView: UIImageView? + + override init(annotation: MKAnnotation?, reuseIdentifier: String?) { + super.init(annotation: annotation, reuseIdentifier: reuseIdentifier) + setupView() + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + setupView() + } + + private func setupView() { + // Circle background + let backgroundView = UIView(frame: CGRect(x: 0, y: 0, width: 40, height: 40)) + backgroundView.backgroundColor = UIColor(Color.staticBlack) + backgroundView.layer.cornerRadius = 20 + backgroundView.layer.masksToBounds = true + + // Add border to backgroundView + backgroundView.layer.borderColor = UIColor(Color.lineNormalNormal).cgColor + backgroundView.layer.borderWidth = 1.0 + + // White icon + let iconImageView = UIImageView(frame: CGRect(x: 0, y: 0, width: 24, height: 24)) + iconImageView.contentMode = .scaleAspectFit + iconImageView.tintColor = UIColor(Color.staticWhite) + + backgroundView.addSubview(iconImageView) + addSubview(backgroundView) + + self.iconImageView = iconImageView + } + + override func layoutSubviews() { + super.layoutSubviews() + subviews.first?.center = CGPoint(x: bounds.size.width / 2, y: bounds.size.height / 2) + iconImageView?.center = CGPoint(x: 20, y: 20) // Center the icon within the circle + } +} + +struct MapRepresentableView: UIViewRepresentable { + + @Binding var restaurants: [Restaurant] + @Binding var topLeft: Location? + @Binding var bottomRight: Location? + + init( + restaurants: Binding<[Restaurant]>, + topLeft: Binding, + bottomRight: Binding + ) { + self._restaurants = restaurants + self._topLeft = topLeft + self._bottomRight = bottomRight + } + + class Coordinator: NSObject, MKMapViewDelegate, CLLocationManagerDelegate { + var 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() + } + + 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 + } else { + annotationView = RestaurantAnnotationView(annotation: annotation, reuseIdentifier: RestaurantAnnotationView.reuseIdentifier) + annotationView.annotation = annotation + } + return annotationView + } + } + + func makeCoordinator() -> Coordinator { + return Coordinator(parent: self) + } + + func makeUIView(context: Context) -> MKMapView { + let mapView = MKMapView(frame: .zero) + context.coordinator.mapView = mapView + mapView.delegate = context.coordinator + mapView.showsUserLocation = true + return mapView + } + + func updateUIView(_ uiView: MKMapView, context: Context) { + self.addMarkersToMapView(uiView) + } + + private func addMarkersToMapView(_ mapView: MKMapView) { + mapView.removeAnnotations(mapView.annotations) + + let annotations = self.restaurants.compactMap { restaurant -> RestaurantAnnotation? in + guard let coordinate = restaurant.location?.toCLLocationCoordinate2D() else { return nil } + return RestaurantAnnotation( + coordinate: coordinate, + title: restaurant.name + ) + } + + mapView.addAnnotations(annotations) + } +} diff --git a/HotSpot/Sources/Presentation/Map/MapStore.swift b/HotSpot/Sources/Presentation/Map/MapStore.swift new file mode 100644 index 0000000..2f9963f --- /dev/null +++ b/HotSpot/Sources/Presentation/Map/MapStore.swift @@ -0,0 +1,77 @@ +import Foundation + +import ComposableArchitecture + +@Reducer +struct MapStore: Reducer { + struct State: Equatable { + var topLeft: Location? = nil + var bottomRight: Location? = nil + var restaurants: [Restaurant] = [] + var filteredRestaurants: [Restaurant] = [] + } + + enum Action { + case updateTopLeft(Location?) + case updateBottomRight(Location?) + case getRestaurants + case getRestaurantsResponse(TaskResult<[Restaurant]>) + case filterRestaurant + case onAppear + case fetchRestaurants + case fetchRestaurantsResponse(TaskResult<[Restaurant]>) + case showSearch + } + + var body: some ReducerOf { + Reduce { state, action in + switch action { + case let .updateTopLeft(location): + state.topLeft = location + return .send(.filterRestaurant) + case let .updateBottomRight(location): + state.bottomRight = location + return .send(.filterRestaurant) + case .getRestaurants: + return .none + case let .getRestaurantsResponse(.success(restaurants)): + state.restaurants = restaurants + return .send(.filterRestaurant) + case let .getRestaurantsResponse(.failure(error)): + print(error.localizedDescription) + return .none + case .filterRestaurant: + guard let topLeft = state.topLeft else { return .none } + guard let bottomRight = state.bottomRight else { return .none } + state.filteredRestaurants = state.restaurants.filter { restaurant in + guard let location = restaurant.location else { return false } + return location.lat <= topLeft.lat && + location.lat >= bottomRight.lat && + location.lon >= topLeft.lon && + location.lon <= bottomRight.lon + } + return .none + case .onAppear: + return .send(.fetchRestaurants) + case .fetchRestaurants: + return .none +// return .run { send in +// await send( +// .fetchRestaurantsResponse( +// await TaskResult { +// try await restaurantClient.fetchRestaurants() +// } +// ) +// ) +// } + case let .fetchRestaurantsResponse(.success(restaurants)): + state.restaurants = restaurants + return .none + case .fetchRestaurantsResponse(.failure): + return .none + case .showSearch: + return .none + } + } + } +} diff --git a/HotSpot/Sources/Presentation/Map/MapView.swift b/HotSpot/Sources/Presentation/Map/MapView.swift new file mode 100644 index 0000000..b9bbeb1 --- /dev/null +++ b/HotSpot/Sources/Presentation/Map/MapView.swift @@ -0,0 +1,57 @@ +import SwiftUI + +import CobyDS +import ComposableArchitecture + +struct MapView: View { + let store: StoreOf + + var body: some View { + WithViewStore(store, observe: { $0 }) { viewStore in + VStack(spacing: 0) { + TopBarView( + leftSide: .title, + leftTitle: "HotSpot", + rightSide: .icon, + rightIcon: UIImage.icSearch, + rightAction: { + viewStore.send(.showSearch) + } + ) + + ZStack(alignment: .bottom) { + MapRepresentableView( + restaurants: .constant(viewStore.restaurants), + topLeft: .constant(viewStore.topLeft), + bottomRight: .constant(viewStore.bottomRight) + ) + .ignoresSafeArea(.all, edges: .bottom) + +// ScrollView(.horizontal) { +// LazyHStack(spacing: 8) { +// ForEach(.store.filteredMemories) { restaurant in +// ThumbnailTileView( +// image: memory.photos.first, +// title: memory.title, +// subTitle: memory.date.formatShort, +// description: memory.note +// ) +// .frame(width: BaseSize.fullWidth, height: 120) +// .onTapGesture { +// store.send(.showDetailMemory(memory)) +// } +// .containerRelativeFrame(.horizontal) +// } +// } +// .scrollTargetLayout() +// } +// .contentMargins(.horizontal, BaseSize.horizantalPadding, for: .scrollContent) +// .scrollIndicators(.hidden) +// .scrollTargetBehavior(.viewAligned) +// .frame(height: 120) +// .padding(.bottom, 30) + } + } + } + } +} diff --git a/HotSpot/Sources/Presentation/RestaurantDetail/RestaurantDetailStore.swift b/HotSpot/Sources/Presentation/RestaurantDetail/RestaurantDetailStore.swift index c47ec87..94f1c2c 100644 --- a/HotSpot/Sources/Presentation/RestaurantDetail/RestaurantDetailStore.swift +++ b/HotSpot/Sources/Presentation/RestaurantDetail/RestaurantDetailStore.swift @@ -4,6 +4,7 @@ import ComposableArchitecture @Reducer struct RestaurantDetailStore { struct State: Equatable { + var restaurantId: UUID var isFavorite: Bool = false } diff --git a/HotSpot/Sources/Presentation/RestaurantDetail/RestaurantDetailView.swift b/HotSpot/Sources/Presentation/RestaurantDetail/RestaurantDetailView.swift index 203a946..8e015ef 100644 --- a/HotSpot/Sources/Presentation/RestaurantDetail/RestaurantDetailView.swift +++ b/HotSpot/Sources/Presentation/RestaurantDetail/RestaurantDetailView.swift @@ -21,9 +21,8 @@ struct RestaurantDetailView: View { #Preview { RestaurantDetailView( store: Store( - initialState: RestaurantDetailStore.State() - ) { - RestaurantDetailStore() - } + initialState: RestaurantDetailStore.State(restaurantId: UUID()), + reducer: { RestaurantDetailStore() } + ) ) } diff --git a/HotSpot/Sources/Presentation/Search/RestaurantCell.swift b/HotSpot/Sources/Presentation/Search/RestaurantCell.swift new file mode 100644 index 0000000..cc29c96 --- /dev/null +++ b/HotSpot/Sources/Presentation/Search/RestaurantCell.swift @@ -0,0 +1,62 @@ +import SwiftUI +import ComposableArchitecture + +struct RestaurantCell: View { + let restaurant: Restaurant + + var body: some View { + HStack(spacing: 12) { + // Restaurant Image + AsyncImage(url: restaurant.imageURL) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + Rectangle() + .fill(Color.gray.opacity(0.2)) + } + .frame(width: 60, height: 60) + .cornerRadius(8) + + // Restaurant Info + VStack(alignment: .leading, spacing: 4) { + Text(restaurant.name) + .font(.headline) + .foregroundColor(.primary) + + Text(restaurant.address) + .font(.subheadline) + .foregroundColor(.gray) + + if let phone = restaurant.phone { + Text(phone) + .font(.subheadline) + .foregroundColor(.gray) + } + } + + Spacer() + + Image(systemName: "chevron.right") + .foregroundColor(.gray) + } + .padding(.vertical, 8) + .padding(.horizontal) + .background(Color(.systemBackground)) + } +} + +#Preview { + RestaurantCell( + restaurant: Restaurant( + id: UUID(), + name: "BBQ치킨 강남점", + address: "서울시 강남구 테헤란로 123", + imageURL: URL(string: "https://example.com/image1.jpg"), + phone: "02-123-4567", + location: Location(lat: 37.5665, lon: 126.9780) + ) + ) + .previewLayout(.sizeThatFits) + .padding() +} diff --git a/HotSpot/Sources/Presentation/Search/SearchStore.swift b/HotSpot/Sources/Presentation/Search/SearchStore.swift index 0a850aa..eb31c15 100644 --- a/HotSpot/Sources/Presentation/Search/SearchStore.swift +++ b/HotSpot/Sources/Presentation/Search/SearchStore.swift @@ -1,29 +1,98 @@ import Foundation +import CoreLocation + import ComposableArchitecture @Reducer struct SearchStore { struct State: Equatable { - var selectedRange: Int = 1 + var searchText: String = "" + var restaurants: [Restaurant] = [] + var isLoading: Bool = false + var currentPage: Int = 1 + var hasMorePages: Bool = true } enum Action { - case showRestaurantDetail - case updateRange(Int) - case navigateToSettings + case searchTextChanged(String) + case search + case searchResponse(TaskResult<[Restaurant]>) + case loadMore + case selectRestaurant(Restaurant) + case pop } var body: some ReducerOf { Reduce { state, action in switch action { - case .showRestaurantDetail: + case let .searchTextChanged(text): + state.searchText = text + return .none + + case .search: + guard !state.searchText.isEmpty else { return .none } + + state.isLoading = true + state.currentPage = 1 + state.restaurants = [] + 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))) + } + + case let .searchResponse(.success(restaurants)): + state.isLoading = false + state.restaurants = restaurants + return .none + + case .searchResponse(.failure): + state.isLoading = false return .none - case let .updateRange(range): - state.selectedRange = range + + case .loadMore: return .none - case .navigateToSettings: + + case let .selectRestaurant(restaurant): + return .none + + case .pop: return .none } } } -} \ No newline at end of file + + private func generateDummyRestaurants(for query: String, page: Int = 1) -> [Restaurant] { + // Always return some results for testing + let restaurants = [ + Restaurant( + id: UUID(), + name: "BBQ치킨 강남점", + address: "서울시 강남구 테헤란로 123", + imageURL: URL(string: "https://example.com/image1.jpg"), + phone: "02-123-4567", + location: Location(lat: 37.5665, lon: 126.9780) + ), + Restaurant( + id: UUID(), + name: "BHC치킨 홍대점", + address: "서울시 마포구 홍대입구로 123", + imageURL: URL(string: "https://example.com/image2.jpg"), + phone: "02-234-5678", + location: Location(lat: 37.5665, lon: 126.9780) + ), + Restaurant( + id: UUID(), + name: "교촌치킨 이태원점", + address: "서울시 용산구 이태원로 123", + imageURL: URL(string: "https://example.com/image3.jpg"), + phone: "02-345-6789", + location: Location(lat: 37.5665, lon: 126.9780) + ) + ] + + return restaurants + } +} diff --git a/HotSpot/Sources/Presentation/Search/SearchView.swift b/HotSpot/Sources/Presentation/Search/SearchView.swift index 08becff..bfc0f5f 100644 --- a/HotSpot/Sources/Presentation/Search/SearchView.swift +++ b/HotSpot/Sources/Presentation/Search/SearchView.swift @@ -1,20 +1,115 @@ import SwiftUI +import CoreLocation + +import CobyDS import ComposableArchitecture struct SearchView: View { let store: StoreOf + @FocusState private var isSearchFocused: Bool + @State private var localSearchText: String = "" var body: some View { WithViewStore(store, observe: { $0 }) { viewStore in - VStack(spacing: 16) { - Button(action: { - viewStore.send(.navigateToSettings) - }) { - Image(systemName: "gearshape.fill") - .foregroundColor(.blue) + VStack(spacing: 0) { + if !isSearchFocused { + TopBarView( + leftSide: .left, + leftAction: { + viewStore.send(.pop) + }, + title: "Search" + ) + } + + // Search Bar + HStack(spacing: 12) { + Image(systemName: "magnifyingglass") + .foregroundColor(.gray) + + TextField("검색어를 입력하세요", text: $localSearchText) + .textFieldStyle(.plain) + .focused($isSearchFocused) + .onChange(of: localSearchText) { newValue in + viewStore.send(.searchTextChanged(newValue)) + } + .onSubmit { + if !localSearchText.isEmpty { + isSearchFocused = false + viewStore.send(.search) + } + } + .submitLabel(.search) + .autocapitalization(.none) + .disableAutocorrection(true) + + if !localSearchText.isEmpty { + Button { + localSearchText = "" + viewStore.send(.searchTextChanged("")) + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.gray) + } + } + + if isSearchFocused { + Button { + localSearchText = "" + viewStore.send(.searchTextChanged("")) + isSearchFocused = false + } label: { + Text("취소") + .foregroundColor(.blue) + } + } } + .padding(12) + .background(Color(.systemGray6)) + .cornerRadius(8) + .padding(.horizontal) + .padding(.top, isSearchFocused ? 16 : 0) + .animation(.easeInOut, value: isSearchFocused) + + // Search Results + ZStack { + if viewStore.isLoading { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if viewStore.restaurants.isEmpty { + VStack(spacing: 16) { + Image(systemName: "magnifyingglass") + .font(.system(size: 48)) + .foregroundColor(.gray) + Text(viewStore.searchText.isEmpty ? "검색어를 입력해주세요" : "검색 결과가 없습니다") + .font(.headline) + .foregroundColor(.gray) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(viewStore.restaurants) { restaurant in + RestaurantCell(restaurant: restaurant) + .onTapGesture { + viewStore.send(.selectRestaurant(restaurant)) + } + + if restaurant.id != viewStore.restaurants.last?.id { + Divider() + .padding(.leading) + } + } + } + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .navigationBarHidden(true) + .onTapGesture { + isSearchFocused = false } - .navigationTitle("HotSpot") } } } diff --git a/Tuist.swift b/Tuist.swift deleted file mode 100644 index 4733e16..0000000 --- a/Tuist.swift +++ /dev/null @@ -1,9 +0,0 @@ -import ProjectDescription - -let tuist = Tuist( -// Create an account with "tuist auth" and a project with "tuist project create" -// then uncomment the section below and set the project full-handle. -// * Read more: https://docs.tuist.io/guides/quick-start/gather-insights -// -// fullHandle: "{account_handle}/{project_handle}", -) diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index 73771ed..8ebc540 100644 --- a/Tuist/Package.resolved +++ b/Tuist/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CobyLibrary/CobyDS.git", "state" : { - "revision" : "52fc41848b3ff0aa91cac568978121d5755dd8af", - "version" : "1.7.2" + "revision" : "e46b3da12bc7f7c5e159b050262bba44d87870ea", + "version" : "1.7.5" } }, { diff --git a/Tuist/Package.swift b/Tuist/Package.swift index c538582..1a75b82 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.2"), + .package(url: "https://github.com/CobyLibrary/CobyDS.git", from: "1.7.5"), .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 322275c2bd8685f2accf3c7d0a13cf42a70379d2 Mon Sep 17 00:00:00 2001 From: coby5502 Date: Sat, 19 Apr 2025 19:39:04 +0900 Subject: [PATCH 2/6] [FEAT] simple design --- .../Coordinator/AppCoordinator.swift | 30 ++------ .../Coordinator/AppCoordinatorView.swift | 34 ---------- .../Presentation/Favorite/FavoriteStore.swift | 25 ------- .../Presentation/Favorite/FavoriteView.swift | 22 ------ .../Map/Component/MapRepresentableView.swift | 13 ++++ .../Map/Component/SnappingScrollView.swift | 68 +++++++++++++++++++ .../Sources/Presentation/Map/MapStore.swift | 67 ++++++++++-------- .../Sources/Presentation/Map/MapView.swift | 61 +++++++++-------- .../RestaurantDetailStore.swift | 41 +++++++++-- .../RestaurantDetailView.swift | 63 +++++++++++++++-- .../Presentation/Settings/SettingsStore.swift | 23 ------- .../Presentation/Settings/SettingsView.swift | 30 -------- 12 files changed, 250 insertions(+), 227 deletions(-) delete mode 100644 HotSpot/Sources/Presentation/Favorite/FavoriteStore.swift delete mode 100644 HotSpot/Sources/Presentation/Favorite/FavoriteView.swift create mode 100644 HotSpot/Sources/Presentation/Map/Component/SnappingScrollView.swift delete mode 100644 HotSpot/Sources/Presentation/Settings/SettingsStore.swift delete mode 100644 HotSpot/Sources/Presentation/Settings/SettingsView.swift diff --git a/HotSpot/Sources/Presentation/Coordinator/AppCoordinator.swift b/HotSpot/Sources/Presentation/Coordinator/AppCoordinator.swift index 3809298..16351e6 100644 --- a/HotSpot/Sources/Presentation/Coordinator/AppCoordinator.swift +++ b/HotSpot/Sources/Presentation/Coordinator/AppCoordinator.swift @@ -7,8 +7,6 @@ struct AppCoordinator { var map: MapStore.State = .init() var search: SearchStore.State? = nil var restaurantDetail: RestaurantDetailStore.State? - var favorite: FavoriteStore.State? - var settings: SettingsStore.State? var selectedRestaurantId: UUID? } @@ -16,17 +14,11 @@ struct AppCoordinator { case map(MapStore.Action) case search(SearchStore.Action) case restaurantDetail(RestaurantDetailStore.Action) - case favorite(FavoriteStore.Action) - case settings(SettingsStore.Action) case showRestaurantDetail(UUID) - case navigateToFavorite - case navigateToSettings case showSearch case dismissSearch case dismissDetail - case dismissFavorite - case dismissSettings } var body: some ReducerOf { @@ -48,28 +40,16 @@ struct AppCoordinator { state.restaurantDetail = .init(restaurantId: id) return .none - case .navigateToFavorite: - state.favorite = .init() - return .none - - case .navigateToSettings: - state.settings = .init() - return .none - - case .dismissDetail: + case .restaurantDetail(.pop), .dismissDetail: state.restaurantDetail = nil state.selectedRestaurantId = nil return .none - case .dismissFavorite: - state.favorite = nil - return .none - - case .dismissSettings: - state.settings = nil - return .none + case let .map(.showRestaurantDetail(id)): + state.selectedRestaurantId = id + return .send(.showRestaurantDetail(id)) - case .map, .search, .restaurantDetail, .favorite, .settings: + case .map, .search, .restaurantDetail: return .none } } diff --git a/HotSpot/Sources/Presentation/Coordinator/AppCoordinatorView.swift b/HotSpot/Sources/Presentation/Coordinator/AppCoordinatorView.swift index 0b220cc..2b7ecd3 100644 --- a/HotSpot/Sources/Presentation/Coordinator/AppCoordinatorView.swift +++ b/HotSpot/Sources/Presentation/Coordinator/AppCoordinatorView.swift @@ -9,7 +9,6 @@ struct AppCoordinatorView: View { NavigationView { VStack { MapView(store: store.scope(state: \.map, action: \.map)) - .ignoresSafeArea(.all, edges: .bottom) NavigationLink( destination: IfLetStore( @@ -42,40 +41,7 @@ struct AppCoordinatorView: View { EmptyView() } .hidden() - - NavigationLink( - destination: IfLetStore( - store.scope(state: \.favorite, action: \.favorite), - then: { store in - FavoriteView(store: store) - } - ), - isActive: viewStore.binding( - get: { $0.favorite != nil }, - send: { $0 ? .navigateToFavorite : .dismissFavorite } - ) - ) { - EmptyView() - } - .hidden() - - NavigationLink( - destination: IfLetStore( - store.scope(state: \.settings, action: \.settings), - then: { store in - SettingsView(store: store) - } - ), - isActive: viewStore.binding( - get: { $0.settings != nil }, - send: { $0 ? .navigateToSettings : .dismissSettings } - ) - ) { - EmptyView() - } - .hidden() } - .navigationBarHidden(true) } .navigationViewStyle(.stack) } diff --git a/HotSpot/Sources/Presentation/Favorite/FavoriteStore.swift b/HotSpot/Sources/Presentation/Favorite/FavoriteStore.swift deleted file mode 100644 index 13eb17f..0000000 --- a/HotSpot/Sources/Presentation/Favorite/FavoriteStore.swift +++ /dev/null @@ -1,25 +0,0 @@ -import Foundation -import ComposableArchitecture - -@Reducer -struct FavoriteStore { - struct State: Equatable { - // Empty state for now - } - - enum Action { - case showRestaurantDetail - case pop - } - - var body: some ReducerOf { - Reduce { state, action in - switch action { - case .showRestaurantDetail: - return .none - case .pop: - return .none - } - } - } -} \ No newline at end of file diff --git a/HotSpot/Sources/Presentation/Favorite/FavoriteView.swift b/HotSpot/Sources/Presentation/Favorite/FavoriteView.swift deleted file mode 100644 index fd304de..0000000 --- a/HotSpot/Sources/Presentation/Favorite/FavoriteView.swift +++ /dev/null @@ -1,22 +0,0 @@ -import SwiftUI -import ComposableArchitecture - -struct FavoriteView: View { - let store: StoreOf - - var body: some View { - WithViewStore(store, observe: { $0 }) { viewStore in - VStack { - Button(action: { viewStore.send(.pop) }) { - Label("뒤로", systemImage: "chevron.left") - } - } - } - } -} - -#Preview { - FavoriteView(store: Store(initialState: FavoriteStore.State()) { - FavoriteStore() - }) -} diff --git a/HotSpot/Sources/Presentation/Map/Component/MapRepresentableView.swift b/HotSpot/Sources/Presentation/Map/Component/MapRepresentableView.swift index 899f69a..ddb3a36 100644 --- a/HotSpot/Sources/Presentation/Map/Component/MapRepresentableView.swift +++ b/HotSpot/Sources/Presentation/Map/Component/MapRepresentableView.swift @@ -127,12 +127,24 @@ struct MapRepresentableView: UIViewRepresentable { } 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( @@ -140,6 +152,7 @@ struct MapRepresentableView: UIViewRepresentable { title: restaurant.name ) } + print("Created \(annotations.count) annotations") mapView.addAnnotations(annotations) } diff --git a/HotSpot/Sources/Presentation/Map/Component/SnappingScrollView.swift b/HotSpot/Sources/Presentation/Map/Component/SnappingScrollView.swift new file mode 100644 index 0000000..9fd318e --- /dev/null +++ b/HotSpot/Sources/Presentation/Map/Component/SnappingScrollView.swift @@ -0,0 +1,68 @@ +import SwiftUI +import CobyDS + +struct SnappingScrollView: View { + let items: [Item] + let itemWidth: CGFloat + let spacing: CGFloat + let content: (Item) -> Content + + @State private var scrollOffset: CGFloat = 0 + + init( + items: [Item], + itemWidth: CGFloat, + spacing: CGFloat = 8, + @ViewBuilder content: @escaping (Item) -> Content + ) { + self.items = items + self.itemWidth = itemWidth + self.spacing = spacing + self.content = content + } + + var body: some View { + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: spacing) { + ForEach(items) { item in + content(item) + .id(item.id) + } + } + .padding(.horizontal, BaseSize.horizantalPadding) + .background( + GeometryReader { geometry in + Color.clear.preference( + key: ScrollOffsetPreferenceKey.self, + value: geometry.frame(in: .named("scroll")).minX + ) + } + ) + } + .coordinateSpace(name: "scroll") + .onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in + scrollOffset = offset + } + .gesture( + DragGesture() + .onEnded { _ in + let totalWidth = itemWidth + spacing + let currentIndex = Int(round(scrollOffset / totalWidth)) + let adjustedIndex = max(0, min(currentIndex, items.count - 1)) + + withAnimation(.easeOut(duration: 0.2)) { + proxy.scrollTo(items[adjustedIndex].id, anchor: .center) + } + } + ) + } + } +} + +struct ScrollOffsetPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } +} diff --git a/HotSpot/Sources/Presentation/Map/MapStore.swift b/HotSpot/Sources/Presentation/Map/MapStore.swift index 2f9963f..c73982a 100644 --- a/HotSpot/Sources/Presentation/Map/MapStore.swift +++ b/HotSpot/Sources/Presentation/Map/MapStore.swift @@ -5,10 +5,35 @@ import ComposableArchitecture @Reducer struct MapStore: Reducer { struct State: Equatable { - var topLeft: Location? = nil - var bottomRight: Location? = nil - var restaurants: [Restaurant] = [] - var filteredRestaurants: [Restaurant] = [] + 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 } enum Action { @@ -16,11 +41,11 @@ struct MapStore: Reducer { case updateBottomRight(Location?) case getRestaurants case getRestaurantsResponse(TaskResult<[Restaurant]>) - case filterRestaurant case onAppear case fetchRestaurants case fetchRestaurantsResponse(TaskResult<[Restaurant]>) case showSearch + case showRestaurantDetail(UUID) } var body: some ReducerOf { @@ -28,42 +53,23 @@ struct MapStore: Reducer { switch action { case let .updateTopLeft(location): state.topLeft = location - return .send(.filterRestaurant) + return .none case let .updateBottomRight(location): state.bottomRight = location - return .send(.filterRestaurant) + return .none case .getRestaurants: return .none case let .getRestaurantsResponse(.success(restaurants)): state.restaurants = restaurants - return .send(.filterRestaurant) + return .none case let .getRestaurantsResponse(.failure(error)): print(error.localizedDescription) return .none - case .filterRestaurant: - guard let topLeft = state.topLeft else { return .none } - guard let bottomRight = state.bottomRight else { return .none } - state.filteredRestaurants = state.restaurants.filter { restaurant in - guard let location = restaurant.location else { return false } - return location.lat <= topLeft.lat && - location.lat >= bottomRight.lat && - location.lon >= topLeft.lon && - location.lon <= bottomRight.lon - } - return .none case .onAppear: - return .send(.fetchRestaurants) + print("MapStore onAppear action received") + return .none case .fetchRestaurants: return .none -// return .run { send in -// await send( -// .fetchRestaurantsResponse( -// await TaskResult { -// try await restaurantClient.fetchRestaurants() -// } -// ) -// ) -// } case let .fetchRestaurantsResponse(.success(restaurants)): state.restaurants = restaurants return .none @@ -71,6 +77,9 @@ struct MapStore: Reducer { return .none case .showSearch: return .none + case let .showRestaurantDetail(id): + state.selectedRestaurantId = id + return .none } } } diff --git a/HotSpot/Sources/Presentation/Map/MapView.swift b/HotSpot/Sources/Presentation/Map/MapView.swift index b9bbeb1..9ed696f 100644 --- a/HotSpot/Sources/Presentation/Map/MapView.swift +++ b/HotSpot/Sources/Presentation/Map/MapView.swift @@ -21,35 +21,42 @@ struct MapView: View { ZStack(alignment: .bottom) { MapRepresentableView( - restaurants: .constant(viewStore.restaurants), - topLeft: .constant(viewStore.topLeft), - bottomRight: .constant(viewStore.bottomRight) + 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) } + ) ) .ignoresSafeArea(.all, edges: .bottom) - -// ScrollView(.horizontal) { -// LazyHStack(spacing: 8) { -// ForEach(.store.filteredMemories) { restaurant in -// ThumbnailTileView( -// image: memory.photos.first, -// title: memory.title, -// subTitle: memory.date.formatShort, -// description: memory.note -// ) -// .frame(width: BaseSize.fullWidth, height: 120) -// .onTapGesture { -// store.send(.showDetailMemory(memory)) -// } -// .containerRelativeFrame(.horizontal) -// } -// } -// .scrollTargetLayout() -// } -// .contentMargins(.horizontal, BaseSize.horizantalPadding, for: .scrollContent) -// .scrollIndicators(.hidden) -// .scrollTargetBehavior(.viewAligned) -// .frame(height: 120) -// .padding(.bottom, 30) + .onAppear { + print("MapView appeared") + viewStore.send(.onAppear) + } + + SnappingScrollView( + items: viewStore.restaurants, + itemWidth: BaseSize.fullWidth + ) { restaurant in + ThumbnailTileView( + image: nil, + title: restaurant.name, + subTitle: "", + description: restaurant.address + ) + .frame(width: BaseSize.fullWidth, height: 120) + .onTapGesture { + viewStore.send(.showRestaurantDetail(restaurant.id)) + } + } + .frame(height: 120) + .padding(.bottom, 30) } } } diff --git a/HotSpot/Sources/Presentation/RestaurantDetail/RestaurantDetailStore.swift b/HotSpot/Sources/Presentation/RestaurantDetail/RestaurantDetailStore.swift index 94f1c2c..5afc678 100644 --- a/HotSpot/Sources/Presentation/RestaurantDetail/RestaurantDetailStore.swift +++ b/HotSpot/Sources/Presentation/RestaurantDetail/RestaurantDetailStore.swift @@ -5,22 +5,51 @@ import ComposableArchitecture struct RestaurantDetailStore { struct State: Equatable { var restaurantId: UUID - var isFavorite: Bool = false + var restaurant: Restaurant? + var isLoading: Bool = false } enum Action { - case toggleFavorite - case navigateToSettings + case onAppear + case fetchRestaurant + case fetchRestaurantResponse(TaskResult) + case pop } var body: some ReducerOf { Reduce { state, action in switch action { - case .toggleFavorite: - state.isFavorite.toggle() + case .onAppear: + return .run { send in + await send(.fetchRestaurant) + } + + case .fetchRestaurant: + state.isLoading = true + return .run { [id = state.restaurantId] send in + // TODO: 실제 API 호출로 대체 + try await Task.sleep(nanoseconds: 500_000_000) + let restaurant = Restaurant( + id: id, + name: "BBQ치킨 강남점", + address: "서울시 강남구 테헤란로 123", + imageURL: URL(string: "https://example.com/image1.jpg"), + phone: "02-123-4567", + location: Location(lat: 37.5665, lon: 126.9780) + ) + await send(.fetchRestaurantResponse(.success(restaurant))) + } + + case let .fetchRestaurantResponse(.success(restaurant)): + state.isLoading = false + state.restaurant = restaurant + return .none + + case .fetchRestaurantResponse(.failure): + state.isLoading = false return .none - case .navigateToSettings: + case .pop: return .none } } diff --git a/HotSpot/Sources/Presentation/RestaurantDetail/RestaurantDetailView.swift b/HotSpot/Sources/Presentation/RestaurantDetail/RestaurantDetailView.swift index 8e015ef..143276d 100644 --- a/HotSpot/Sources/Presentation/RestaurantDetail/RestaurantDetailView.swift +++ b/HotSpot/Sources/Presentation/RestaurantDetail/RestaurantDetailView.swift @@ -1,4 +1,6 @@ import SwiftUI + +import CobyDS import ComposableArchitecture struct RestaurantDetailView: View { @@ -6,14 +8,63 @@ struct RestaurantDetailView: View { var body: some View { WithViewStore(store, observe: { $0 }) { viewStore in - VStack { - Button(action: { viewStore.send(.navigateToSettings) }) { - Label("설정", systemImage: "gear") + VStack(spacing: 0) { + TopBarView( + leftSide: .left, + leftAction: { + viewStore.send(.pop) + }, + title: viewStore.restaurant?.name ?? "Restaurant" + ) + + if viewStore.isLoading { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let restaurant = viewStore.restaurant { + ScrollView { + VStack(spacing: 16) { + // Restaurant Image + if let imageURL = restaurant.imageURL { + AsyncImage(url: imageURL) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + Color.gray + } + .frame(height: 200) + .clipped() + } + + // Restaurant Info + VStack(alignment: .leading, spacing: 12) { + Text(restaurant.name) + .font(.title) + .fontWeight(.bold) + + Text(restaurant.address) + .font(.body) + .foregroundColor(.gray) + + if let phone = restaurant.phone { + Text(phone) + .font(.body) + .foregroundColor(.gray) + } + } + .padding() + } + } + } else { + Text("레스토랑 정보를 불러올 수 없습니다.") + .foregroundColor(.gray) + .frame(maxWidth: .infinity, maxHeight: .infinity) } - .buttonStyle(.bordered) } - .navigationTitle("맛집 상세") - .navigationBarTitleDisplayMode(.inline) + .navigationBarHidden(true) + .onAppear { + viewStore.send(.onAppear) + } } } } diff --git a/HotSpot/Sources/Presentation/Settings/SettingsStore.swift b/HotSpot/Sources/Presentation/Settings/SettingsStore.swift deleted file mode 100644 index 190675d..0000000 --- a/HotSpot/Sources/Presentation/Settings/SettingsStore.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation -import ComposableArchitecture - -@Reducer -struct SettingsStore { - struct State: Equatable { - var defaultSearchRange: Int = 1 - } - - enum Action { - case updateSearchRange(Int) - } - - var body: some ReducerOf { - Reduce { state, action in - switch action { - case let .updateSearchRange(range): - state.defaultSearchRange = range - return .none - } - } - } -} \ No newline at end of file diff --git a/HotSpot/Sources/Presentation/Settings/SettingsView.swift b/HotSpot/Sources/Presentation/Settings/SettingsView.swift deleted file mode 100644 index 163ec08..0000000 --- a/HotSpot/Sources/Presentation/Settings/SettingsView.swift +++ /dev/null @@ -1,30 +0,0 @@ -import SwiftUI -import ComposableArchitecture - -struct SettingsView: View { - let store: StoreOf - - var body: some View { - WithViewStore(store, observe: { $0 }) { viewStore in - VStack(spacing: 16) { - Button(action: { - viewStore.send(.updateSearchRange(1)) - }) { - Image(systemName: "gearshape.fill") - .foregroundColor(.blue) - } - } - .navigationTitle("설정") - .navigationBarTitleDisplayMode(.inline) - } - } -} - -#Preview { - SettingsView( - store: Store( - initialState: SettingsStore.State(), - reducer: { SettingsStore() } - ) - ) -} From b98fb35bd745e29f57a9a201405918ec216d33a4 Mon Sep 17 00:00:00 2001 From: Kim Doyoung <57849386+coby5502@users.noreply.github.com> Date: Sat, 19 Apr 2025 19:45:31 +0900 Subject: [PATCH 3/6] Update Test_on_develop.yml --- .github/workflows/Test_on_develop.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/Test_on_develop.yml b/.github/workflows/Test_on_develop.yml index b751d76..2a93734 100644 --- a/.github/workflows/Test_on_develop.yml +++ b/.github/workflows/Test_on_develop.yml @@ -22,7 +22,7 @@ jobs: run: mise trust - name: Install Tuist via mise - run: mise x -- tuist install + run: mise install tuist - name: Generate Xcode project run: mise x -- tuist generate From f38fb76545939d5e87da88b1db887a90d7f15006 Mon Sep 17 00:00:00 2001 From: Kim Doyoung <57849386+coby5502@users.noreply.github.com> Date: Sat, 19 Apr 2025 19:46:47 +0900 Subject: [PATCH 4/6] Update Test_on_develop.yml --- .github/workflows/Test_on_develop.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/Test_on_develop.yml b/.github/workflows/Test_on_develop.yml index 2a93734..0f065ec 100644 --- a/.github/workflows/Test_on_develop.yml +++ b/.github/workflows/Test_on_develop.yml @@ -24,6 +24,9 @@ jobs: - name: Install Tuist via mise run: mise install tuist + - name: Resolve dependencies + run: mise x -- tuist install + - name: Generate Xcode project run: mise x -- tuist generate From 0089823d2db6a090b0f1504931ccdaf89abbdbab Mon Sep 17 00:00:00 2001 From: Kim Doyoung <57849386+coby5502@users.noreply.github.com> Date: Sat, 19 Apr 2025 19:49:40 +0900 Subject: [PATCH 5/6] Update Test_on_develop.yml --- .github/workflows/Test_on_develop.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/Test_on_develop.yml b/.github/workflows/Test_on_develop.yml index 0f065ec..6932371 100644 --- a/.github/workflows/Test_on_develop.yml +++ b/.github/workflows/Test_on_develop.yml @@ -31,4 +31,4 @@ jobs: run: mise x -- tuist generate - name: Run Unit Tests - run: mise x -- tuist test Weave2-UnitTest --no-selective-testing + run: mise x -- tuist test HotSpotTests From ec4544898af69570fd05aeae8b96e495c7529a7e Mon Sep 17 00:00:00 2001 From: Kim Doyoung <57849386+coby5502@users.noreply.github.com> Date: Sat, 19 Apr 2025 19:51:59 +0900 Subject: [PATCH 6/6] Update Test_on_develop.yml --- .github/workflows/Test_on_develop.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/Test_on_develop.yml b/.github/workflows/Test_on_develop.yml index 6932371..08cd0b5 100644 --- a/.github/workflows/Test_on_develop.yml +++ b/.github/workflows/Test_on_develop.yml @@ -31,4 +31,4 @@ jobs: run: mise x -- tuist generate - name: Run Unit Tests - run: mise x -- tuist test HotSpotTests + run: mise x -- tuist test HotSpot