Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
//
// MapInitialSearchState.swift
// Neki-iOS
//
// Created by OpenAI Codex on 5/6/26.
//

import Foundation

/// 지도 진입 직후 첫 POI 검색이 가능한지 표현하는 상태
public enum MapInitialSearchState: Sendable, Equatable {
case awaitingPermission
case waitingForUserLocation
case readyForDefaultLocation
case readyForUserLocation
case completed
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ public struct MapFeature {
var isSDKAuthSuccessful: Bool = false
var detent: NekiSheetDetent = SheetStage.first.detent
var isSearchHereButtonVisible: Bool = false
var isFirstLoad: Bool = true
var isPermissionAlertPresented: Bool = false
var initialSearchState: MapInitialSearchState = .awaitingPermission

// Map State
var cameraPosition: GeographicCoordinate?
Expand Down Expand Up @@ -89,6 +89,7 @@ public struct MapFeature {
case setUserTrackingMode(Bool)
case didDetectMapInteraction
case presentPermissionAlert
case attemptInitialSearch

// Map Logic Actions
case mapLoaded(GeographicBoundingBox)
Expand Down Expand Up @@ -169,6 +170,7 @@ public struct MapFeature {
state.locationAuthorizationStatus = status
switch status {
case .authorizedAlways, .authorizedWhenInUse:
state.initialSearchState = .waitingForUserLocation
return .merge(
.run { send in
for await location in await mapClient.trackingLocation() {
Expand All @@ -179,17 +181,14 @@ public struct MapFeature {
)

case .notDetermined:
state.initialSearchState = .awaitingPermission
return state.locationAuthorizationNeeded ? .send(.requestPermission) : .none

case .denied, .restricted:
state.isUserTrackingMode = false

guard state.isFirstLoad, let bounds = state.currentBounds else { return .none }
state.isFirstLoad = false
return .merge(
.send(.fetchPhotoBooths(bounds: bounds)),
.send(.fetchNearbyPhotoBooths(bounds.center))
)
state.initialSearchState = .readyForDefaultLocation
updateCameraPosition(&state, to: Constants.defaultInitialPosition.coordinate)
return .send(.attemptInitialSearch)

@unknown default:
return .none
Expand All @@ -214,7 +213,7 @@ public struct MapFeature {
case let .mapLoaded(bounds):
state.currentBounds = bounds
state.cameraPosition = bounds.center
return .none
return .send(.attemptInitialSearch)

case .didTapCurrentLocationButton:
resetToMapMode(&state, for: .first)
Expand All @@ -234,11 +233,14 @@ public struct MapFeature {
case let .updateUserLocation(.success(location)):
state.userLocation = location

if state.isFirstLoad || state.isUserTrackingMode {
if state.isUserTrackingMode {
updateCameraPosition(&state, to: location.coordinate)
guard state.isFirstLoad else { return .none }
state.isSearchHereButtonVisible = false
}

guard state.initialSearchState == .waitingForUserLocation else { return .none }
state.isSearchHereButtonVisible = false
state.initialSearchState = .readyForUserLocation
updateCameraPosition(&state, to: location.coordinate)
return .none

case .updateUserLocation(.failure):
Expand All @@ -263,29 +265,14 @@ public struct MapFeature {
case let .cameraMotionEnded(bounds):
state.currentBounds = bounds
state.cameraPosition = bounds.center

guard state.isFirstLoad else { return .none }
guard state.locationAuthorizationStatus != .notDetermined else { return .none }

let targetCoordinate = state.isLocationAuthorized ? (state.userLocation ?? Constants.defaultInitialPosition) : Constants.defaultInitialPosition
let currentCameraLocation = CLLocation(latitude: bounds.center.latitude, longitude: bounds.center.longitude)

guard currentCameraLocation.distance(from: targetCoordinate) <= Constants.cameraTargetDistanceThreshold else { return .none }
state.isFirstLoad = false
let nearbyTargetCoordinate = state.userGeographicCoordinate ?? bounds.center
state.lastSearchedLocation = currentCameraLocation
return .merge(
.send(.fetchPhotoBooths(bounds: bounds)),
.send(.fetchNearbyPhotoBooths(nearbyTargetCoordinate))
)
return .send(.attemptInitialSearch)

case let .updateSearchButtonVisibility(isVisible):
state.isSearchHereButtonVisible = isVisible
return .none

case .didTapSearchHereButton:
guard let bounds = state.currentBounds else { return .none }
let nearbyTargetCoordinate = state.userGeographicCoordinate ?? bounds.center
let currentCenterLocation = CLLocation(latitude: bounds.center.latitude, longitude: bounds.center.longitude)
let isRegionChanged = checkIfRegionChanged(from: state.lastSearchedLocation, to: currentCenterLocation)
let hasFilter = state.photoBoothListState.filteredBrands.isEmpty == false
Expand All @@ -294,7 +281,22 @@ public struct MapFeature {
return .merge(
.run { _ in analytics.logEvent(event: event) },
.send(.fetchPhotoBooths(bounds: bounds)),
.send(.fetchNearbyPhotoBooths(nearbyTargetCoordinate))
nearbyPhotoBoothsEffect(for: state)
)

case .attemptInitialSearch:
guard let bounds = state.currentBounds else { return .none }
guard let targetCoordinate = initialSearchTargetCoordinate(for: state) else { return .none }

let currentCameraLocation = CLLocation(latitude: bounds.center.latitude, longitude: bounds.center.longitude)
let targetLocation = CLLocation(latitude: targetCoordinate.latitude, longitude: targetCoordinate.longitude)

guard currentCameraLocation.distance(from: targetLocation) <= Constants.cameraTargetDistanceThreshold else { return .none }
state.initialSearchState = .completed
state.lastSearchedLocation = currentCameraLocation
return .merge(
.send(.fetchPhotoBooths(bounds: bounds)),
nearbyPhotoBoothsEffect(for: state)
)

// MARK: - Data Fetching
Expand Down Expand Up @@ -469,4 +471,30 @@ private extension MapFeature {
guard let lastLocation else { return false }
return currentLocation.distance(from: lastLocation) >= Constants.regionChangeDistanceThreshold
}

func initialSearchTargetCoordinate(for state: State) -> GeographicCoordinate? {
switch state.initialSearchState {
case .awaitingPermission, .waitingForUserLocation, .completed:
return nil
case .readyForDefaultLocation:
return .init(
latitude: Constants.defaultInitialPosition.coordinate.latitude,
longitude: Constants.defaultInitialPosition.coordinate.longitude
)
case .readyForUserLocation:
return state.userGeographicCoordinate
}
}

func nearbyPhotoBoothsEffect(for state: State) -> Effect<Action> {
guard let userGeographicCoordinate = state.userGeographicCoordinate else {
let emptyBooths = IdentifiedArrayOf<PhotoBooth>()
return .merge(
.send(.photoBoothListAction(.setNearbyBooths(emptyBooths))),
.send(.photoBoothListAction(.setVisibleBooths(emptyBooths)))
)
}

return .send(.fetchNearbyPhotoBooths(userGeographicCoordinate))
}
}