Skip to content

Commit e8ca559

Browse files
Merge pull request #223 from YAPP-Github/feat/#221-ga4
[Feat] #221 - Firebase Ananytics 지도, 포즈, 마이페이지 모듈 연동
2 parents 24754d8 + ef46744 commit e8ca559

18 files changed

Lines changed: 355 additions & 127 deletions

File tree

Neki-iOS/APP/Sources/Application/AppCoordinator.swift

Lines changed: 43 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ struct AppCoordinator {
6969

7070
@Dependency(\.authClient) private var authClient
7171
@Dependency(\.appVersionClient) private var appVersionClient
72+
@Dependency(\.analyticsClient) private var analytics
7273
@Dependency(\.continuousClock) var clock
7374
@Dependency(\.openURL) private var openURL
7475
@Dependency(\.date.now) private var now
@@ -155,6 +156,9 @@ struct AppCoordinator {
155156
case let .splashSequenceCompleted(finalStatus, finalVersionResult):
156157
state.$userSessionStatus.withLock { $0 = finalStatus }
157158

159+
let userID: Int? = extractUserID(from: finalStatus)
160+
let configureEffect: Effect<Action> = .run { _ in analytics.configure(userID) }
161+
158162
guard finalVersionResult.status != .mustUpdate else {
159163
state.versionAlert = .updateNeeded
160164
return .none
@@ -166,7 +170,10 @@ struct AppCoordinator {
166170
return .none
167171
}
168172

169-
return navigateToNextScreen(state: &state, sessionStatus: finalStatus)
173+
return .merge(
174+
configureEffect,
175+
navigateToNextScreen(state: &state, sessionStatus: finalStatus)
176+
)
170177

171178
case .executePendingShareExtensionIfNeeded:
172179
guard let appGroupID = state.pendingShareAppGroupID else { return .none }
@@ -189,27 +196,29 @@ struct AppCoordinator {
189196
case let .userSessionStatusChanged(newStatus):
190197
if state.userSessionStatus != newStatus { state.$userSessionStatus.withLock { $0 = newStatus } }
191198

192-
if case .splash = state.route, state.versionAlert != nil { return .none }
199+
let userID: Int? = extractUserID(from: newStatus)
200+
let configureEffect: Effect<Action> = .run { _ in analytics.configure(userID) }
201+
202+
if case .splash = state.route, state.versionAlert != nil { return configureEffect }
193203

204+
let navigationEffect: Effect<Action>
194205
switch newStatus {
195-
case let .signedIn(user):
196-
if case .mainTab = state.route { return .none }
197-
state.route = .mainTab(.init())
198-
return .none
199-
200-
case .signedOut:
201-
state.route = .auth(.init())
202-
guard case .expired = newStatus else { return .none }
203-
state.toastItem = .init("다시 로그인 해주세요.")
204-
return .none
206+
case .signedIn:
207+
if case .mainTab = state.route {
208+
navigationEffect = .none
209+
} else {
210+
state.route = .mainTab(.init())
211+
navigationEffect = .none
212+
}
205213

206-
case .expired:
214+
case .signedOut, .expired:
207215
state.route = .auth(.init())
208-
guard case .expired = newStatus else { return .none }
209-
state.toastItem = .init("다시 로그인 해주세요.")
210-
return .none
216+
if case .expired = newStatus { state.toastItem = .init("다시 로그인 해주세요.") }
217+
navigationEffect = .none
211218
}
212219

220+
return .merge(configureEffect, navigationEffect)
221+
213222
case .route(.onboarding(.delegate(.didFinishOnboarding))):
214223
state.$hasSeenOnboarding.withLock { $0 = true }
215224
state.route = .auth(.init())
@@ -218,15 +227,19 @@ struct AppCoordinator {
218227
case let .route(.auth(.delegate(.moveToMainTab(user)))):
219228
state.$userSessionStatus.withLock { $0 = .signedIn(user) }
220229
state.route = .mainTab(.init())
221-
return .send(.executePendingShareExtensionIfNeeded)
230+
let configureEffect: Effect<Action> = .run { _ in analytics.configure(user.id) }
231+
return .merge(
232+
configureEffect,
233+
.send(.executePendingShareExtensionIfNeeded)
234+
)
222235

223236
case .route(.mainTab(.delegate(.signedOut))), .route(.mainTab(.delegate(.withdraw))):
224237
state.$userSessionStatus.withLock { $0 = .signedOut }
225238
if case .route(.mainTab(.delegate(.withdraw))) = action {
226239
state.initializeUserDefaults()
227240
}
228241
state.route = .auth(.init())
229-
return .none
242+
return .run { _ in analytics.configure(nil) }
230243

231244
case .binding(\.isAlertPresented):
232245
guard state.isAlertPresented == false else { return .none }
@@ -239,8 +252,13 @@ struct AppCoordinator {
239252
}
240253
}
241254
}
242-
243-
private func navigateToNextScreen(state: inout State, sessionStatus: UserSessionStatus) -> Effect<Action> {
255+
}
256+
257+
258+
// MARK: - AppCoordinator + Helpers
259+
260+
private extension AppCoordinator {
261+
func navigateToNextScreen(state: inout State, sessionStatus: UserSessionStatus) -> Effect<Action> {
244262
switch sessionStatus {
245263
case .signedIn:
246264
state.route = .mainTab(.init())
@@ -256,6 +274,11 @@ struct AppCoordinator {
256274
return .none
257275
}
258276
}
277+
278+
func extractUserID(from status: UserSessionStatus) -> Int? {
279+
guard case let .signedIn(user) = status else { return nil }
280+
return user.id
281+
}
259282
}
260283

261284
extension AppCoordinator {

Neki-iOS/Core/Sources/Analytics/Sources/Domain/Sources/Entities/AnalyticsEventName.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,5 +38,9 @@ public enum AnalyticsEventName: String {
3838
case poseBookmarkFilter = "pose_bookmark_filter"
3939
case poseBookmark = "pose_bookmark"
4040

41+
// 마이페이지
42+
case logout = "mypage_logout"
43+
case withdraw = "mypage_withdraw"
44+
4145
var value: String { self.rawValue }
4246
}

Neki-iOS/Core/Sources/ImagePicker/Presentation/Extension/Data+.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,8 @@ extension Data {
5151
return nil
5252
}
5353

54-
var width = properties[kCGImagePropertyPixelWidth] as? Int
55-
var height = properties[kCGImagePropertyPixelHeight] as? Int
54+
let width = properties[kCGImagePropertyPixelWidth] as? Int
55+
let height = properties[kCGImagePropertyPixelHeight] as? Int
5656

5757
if let w = width, let h = height {
5858
return (w, h)

Neki-iOS/Features/Map/Sources/Domain/Sources/Entities/DirectionAppType.swift

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ import Foundation
99
import DeveloperToolsSupport
1010

1111
/// 길찾기 기능으로 제공되는 외부 앱
12-
public enum DirectionAppType: CaseIterable {
13-
case googleMap, naverMap, kakaoMap
12+
public enum DirectionAppType: String, CaseIterable {
13+
case googleMap = "google_map"
14+
case naverMap = "naver_map"
15+
case kakaoMap = "kakao_map"
1416

1517
var imageResources: ImageResource {
1618
switch self {
@@ -44,7 +46,7 @@ public extension DirectionAppType {
4446
case .naverMap:
4547
// 네이버: 모바일 웹 길찾기 페이지 포맷 (도착지 설정)
4648
// slng, slat(출발지)는 생략 시 현재위치, elng, elat(도착지), etext(도착지명)
47-
return URL(string: "https://m.map.naver.com/route.nhn?menu=route&elat=\(coordinate.latitude)&elng=\(coordinate.longitude)&etext=\(nameEncoded)")
49+
return URL(string: "https://app.map.naver.com/launchApp?cmd=outlink&req=route&dlat=\(coordinate.latitude)&dlng=\(coordinate.longitude)&dname=\(nameEncoded)")
4850

4951
case .kakaoMap:
5052
// 카카오: 웹/앱 연동형 링크
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
//
2+
// MapAnalyticsEvent.swift
3+
// Neki-iOS
4+
//
5+
// Created by SwainYun on 4/19/26.
6+
//
7+
8+
import Foundation
9+
10+
enum MapAnalyticsEvent {
11+
case mapReSearch(hasFilter: Bool, regionChanged: Bool)
12+
case mapBrandFilterToggle(action: MapFilterAction, selectedCount: Int, brandName: String)
13+
case boothSelect(brandName: String, entryPoint: MapEntryPoint)
14+
case mapRouteClick(mapType: DirectionAppType)
15+
}
16+
17+
18+
// MARK: - MapAnalyticsEvent + AnalyticsEvent
19+
20+
extension MapAnalyticsEvent: AnalyticsEvent {
21+
var name: AnalyticsEventName {
22+
switch self {
23+
case .mapReSearch: return .mapReSearch
24+
case .mapBrandFilterToggle: return .mapBrandFilterToggle
25+
case .boothSelect: return .boothSelect
26+
case .mapRouteClick: return .mapRouteClick
27+
}
28+
}
29+
30+
var parameters: [AnalyticsParameterKey : Any]? {
31+
switch self {
32+
case let .mapReSearch(hasFilter, regionChanged):
33+
return [.hasFilter: hasFilter, .regionChanged: regionChanged]
34+
case let .mapBrandFilterToggle(action, selectedCount, brandName):
35+
return [.action: action.rawValue, .selectedCount: selectedCount, .brandName: brandName]
36+
case let .boothSelect(brandName, entryPoint):
37+
return [.brandName: brandName, .entryPoint: entryPoint.rawValue]
38+
case let .mapRouteClick(mapType):
39+
return [.mapType: mapType.rawValue]
40+
}
41+
}
42+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
//
2+
// MapEntryPoint.swift
3+
// Neki-iOS
4+
//
5+
// Created by SwainYun on 4/19/26.
6+
//
7+
8+
import Foundation
9+
10+
enum MapEntryPoint: String {
11+
case map = "map"
12+
case bottomSheet = "bottom_sheet"
13+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
//
2+
// MapFilterAction.swift
3+
// Neki-iOS
4+
//
5+
// Created by SwainYun on 4/19/26.
6+
//
7+
8+
import Foundation
9+
10+
enum MapFilterAction: String {
11+
case select = "select"
12+
case deselect = "deselect"
13+
}

Neki-iOS/Features/Map/Sources/Presentation/Sources/Feature/MapFeature.swift

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import os
1414
public struct MapFeature {
1515
enum Constants {
1616
static let defaultInitialPosition: CLLocation = .init(latitude: 37.498095, longitude: 127.027610)
17+
static let cameraTargetDistanceThreshold: CLLocationDistance = 200
18+
static let regionChangeDistanceThreshold: CLLocationDistance = 500
1719
}
1820

1921
enum SheetStage {
@@ -41,6 +43,7 @@ public struct MapFeature {
4143
// Map State
4244
var cameraPosition: GeographicCoordinate?
4345
var currentBounds: GeographicBoundingBox?
46+
var lastSearchedLocation: CLLocation?
4447

4548
// User Location
4649
var locationAuthorizationStatus: CLAuthorizationStatus = .notDetermined
@@ -109,6 +112,7 @@ public struct MapFeature {
109112
case processNewChunk([PhotoBooth], isFirstBatch: Bool)
110113
case appendProcessedChunk(map: [PhotoBooth], isFirstBatch: Bool)
111114
case didFinishBackgroundCalculation(map: IdentifiedArrayOf<PhotoBooth>, list: IdentifiedArrayOf<PhotoBooth>)
115+
case didSelectDirectionApp(DirectionAppType)
112116

113117
// Binding & Child
114118
case binding(BindingAction<State>)
@@ -126,6 +130,7 @@ public struct MapFeature {
126130

127131
@Dependency(\.mapClient) private var mapClient
128132
@Dependency(\.photoBoothClient) private var photoBoothClient
133+
@Dependency(\.analyticsClient) private var analytics
129134
@Dependency(\.openURL) private var openURL
130135

131136
public var body: some ReducerOf<Self> {
@@ -261,18 +266,14 @@ public struct MapFeature {
261266

262267
guard state.isFirstLoad else { return .none }
263268
guard state.locationAuthorizationStatus != .notDetermined else { return .none }
264-
let targetCoordinate: CLLocation
265-
if state.isLocationAuthorized {
266-
guard let userLocation = state.userLocation else { return .none }
267-
targetCoordinate = userLocation
268-
} else {
269-
targetCoordinate = Constants.defaultInitialPosition
270-
}
271269

270+
let targetCoordinate = state.isLocationAuthorized ? (state.userLocation ?? Constants.defaultInitialPosition) : Constants.defaultInitialPosition
272271
let currentCameraLocation = CLLocation(latitude: bounds.center.latitude, longitude: bounds.center.longitude)
273-
guard currentCameraLocation.distance(from: targetCoordinate) <= 200 else { return .none }
272+
273+
guard currentCameraLocation.distance(from: targetCoordinate) <= Constants.cameraTargetDistanceThreshold else { return .none }
274274
state.isFirstLoad = false
275275
let nearbyTargetCoordinate = state.userGeographicCoordinate ?? bounds.center
276+
state.lastSearchedLocation = currentCameraLocation
276277
return .merge(
277278
.send(.fetchPhotoBooths(bounds: bounds)),
278279
.send(.fetchNearbyPhotoBooths(nearbyTargetCoordinate))
@@ -285,7 +286,13 @@ public struct MapFeature {
285286
case .didTapSearchHereButton:
286287
guard let bounds = state.currentBounds else { return .none }
287288
let nearbyTargetCoordinate = state.userGeographicCoordinate ?? bounds.center
289+
let currentCenterLocation = CLLocation(latitude: bounds.center.latitude, longitude: bounds.center.longitude)
290+
let isRegionChanged = checkIfRegionChanged(from: state.lastSearchedLocation, to: currentCenterLocation)
291+
let hasFilter = state.photoBoothListState.filteredBrands.isEmpty == false
292+
let event = MapAnalyticsEvent.mapReSearch(hasFilter: hasFilter, regionChanged: isRegionChanged)
293+
state.lastSearchedLocation = currentCenterLocation
288294
return .merge(
295+
.run { _ in analytics.logEvent(event: event) },
289296
.send(.fetchPhotoBooths(bounds: bounds)),
290297
.send(.fetchNearbyPhotoBooths(nearbyTargetCoordinate))
291298
)
@@ -392,7 +399,8 @@ public struct MapFeature {
392399
case .didTapBooth(let photoBooth):
393400
state.isUserTrackingMode = false
394401
selectPhotoBooth(&state, photoBooth: photoBooth)
395-
return .none
402+
let event = MapAnalyticsEvent.boothSelect(brandName: photoBooth.brand.name, entryPoint: .map)
403+
return .run { _ in analytics.logEvent(event: event) }
396404

397405
case .didTapBoothCard:
398406
state.isUserTrackingMode = false
@@ -408,6 +416,15 @@ public struct MapFeature {
408416
state.directionSheetPhotoBooth = state.selectedBooth
409417
return .none
410418

419+
case let .didSelectDirectionApp(appType):
420+
guard let photoBooth = state.directionSheetPhotoBooth else { return .none }
421+
guard let url = appType.connectLink(coordinate: photoBooth.coordinate, name: photoBooth.name) else { return .none }
422+
state.directionSheetPhotoBooth = nil
423+
return .merge(
424+
.run { _ in analytics.logEvent(event: MapAnalyticsEvent.mapRouteClick(mapType: appType)) },
425+
.run { _ in await openURL(url) }
426+
)
427+
411428
case .updateSDKAuthStatus(let isAuthorized):
412429
state.isSDKAuthSuccessful = isAuthorized
413430
return .none
@@ -416,7 +433,10 @@ public struct MapFeature {
416433
return .send(.startBackgroundCalculation)
417434

418435
case let .photoBoothListAction(.didTapBooth(photoBooth)):
419-
return .send(.didTapBooth(photoBooth))
436+
state.isUserTrackingMode = false
437+
selectPhotoBooth(&state, photoBooth: photoBooth)
438+
let event = MapAnalyticsEvent.boothSelect(brandName: photoBooth.brand.name, entryPoint: .bottomSheet)
439+
return .run { _ in analytics.logEvent(event: event) }
420440

421441
default:
422442
return .none
@@ -444,4 +464,9 @@ private extension MapFeature {
444464
func updateCameraPosition(_ state: inout State, to coordinate: CLLocationCoordinate2D) {
445465
state.cameraPosition = .init(latitude: coordinate.latitude, longitude: coordinate.longitude)
446466
}
467+
468+
func checkIfRegionChanged(from lastLocation: CLLocation?, to currentLocation: CLLocation) -> Bool {
469+
guard let lastLocation else { return false }
470+
return currentLocation.distance(from: lastLocation) >= Constants.regionChangeDistanceThreshold
471+
}
447472
}

Neki-iOS/Features/Map/Sources/Presentation/Sources/Feature/PhotoBoothListFeature.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,19 @@ public struct PhotoBoothListFeature {
3737
case binding(BindingAction<State>)
3838
}
3939

40+
@Dependency(\.analyticsClient) private var analytics
41+
4042
public var body: some ReducerOf<Self> {
4143
BindingReducer()
4244

4345
Reduce { (state: inout State, action: Action) -> Effect<Action> in
4446
switch action {
4547
case let .selectFilterOption(brand):
48+
let filterAction: MapFilterAction = state.filteredBrands.contains(brand) ? .deselect : .select
4649
toggleFilterOptionSelection(&state, brand: brand)
47-
return .none
50+
let selectedCount = state.filteredBrands.count
51+
let event = MapAnalyticsEvent.mapBrandFilterToggle(action: filterAction, selectedCount: selectedCount, brandName: brand.name)
52+
return .run { _ in analytics.logEvent(event: event) }
4853

4954
case .toggleTooltip:
5055
state.$isTooltipPresented.withLock { $0.toggle() }

0 commit comments

Comments
 (0)