diff --git a/KillingPart.xcodeproj/project.pbxproj b/KillingPart.xcodeproj/project.pbxproj index fc91f6d..d05e3b5 100644 --- a/KillingPart.xcodeproj/project.pbxproj +++ b/KillingPart.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 12F7A0012F3F010000A00001 /* KakaoSDKCommon in Frameworks */ = {isa = PBXBuildFile; productRef = 12F7A0112F3F010000A00001 /* KakaoSDKCommon */; }; 12F7A0022F3F010000A00001 /* KakaoSDKAuth in Frameworks */ = {isa = PBXBuildFile; productRef = 12F7A0122F3F010000A00001 /* KakaoSDKAuth */; }; 12F7A0032F3F010000A00001 /* KakaoSDKUser in Frameworks */ = {isa = PBXBuildFile; productRef = 12F7A0132F3F010000A00001 /* KakaoSDKUser */; }; + 13A100032FF5A00000A1B001 /* AmplitudeUnified in Frameworks */ = {isa = PBXBuildFile; productRef = 13A100022FF5A00000A1B001 /* AmplitudeUnified */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -81,6 +82,7 @@ files = ( 12F7A0012F3F010000A00001 /* KakaoSDKCommon in Frameworks */, 12F7A0022F3F010000A00001 /* KakaoSDKAuth in Frameworks */, + 13A100032FF5A00000A1B001 /* AmplitudeUnified in Frameworks */, 1260BBC82FA88B900006BF01 /* GoogleSignInSwift in Frameworks */, 1224698D2FA765FE00A6EF76 /* FirebaseMessaging in Frameworks */, 1224698B2FA765FE00A6EF76 /* FirebaseCore in Frameworks */, @@ -155,6 +157,7 @@ 1224698C2FA765FE00A6EF76 /* FirebaseMessaging */, 1260BBC52FA88B900006BF01 /* GoogleSignIn */, 1260BBC72FA88B900006BF01 /* GoogleSignInSwift */, + 13A100022FF5A00000A1B001 /* AmplitudeUnified */, ); productName = KillingPart; productReference = 1231F11D2F372E5B00CFA51D /* KillingPart.app */; @@ -242,6 +245,7 @@ 12F7A0212F3F010000A00001 /* XCRemoteSwiftPackageReference "kakao-ios-sdk" */, 122469892FA765FE00A6EF76 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, 1260BBC42FA88B900006BF01 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */, + 13A100012FF5A00000A1B001 /* XCRemoteSwiftPackageReference "AmplitudeUnified-Swift" */, ); preferredProjectObjectVersion = 77; productRefGroup = 1231F11E2F372E5B00CFA51D /* Products */; @@ -448,7 +452,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = KillingPart/KillingPart.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 38; + CURRENT_PROJECT_VERSION = 41; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = GQ89YG5G9R; ENABLE_APP_SANDBOX = YES; @@ -473,7 +477,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.2.3; + MARKETING_VERSION = 1.2.6; PRODUCT_BUNDLE_IDENTIFIER = com.killingpoint.killingpart; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -493,7 +497,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = KillingPart/KillingPart.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 38; + CURRENT_PROJECT_VERSION = 41; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = GQ89YG5G9R; ENABLE_APP_SANDBOX = YES; @@ -518,7 +522,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.2.3; + MARKETING_VERSION = 1.2.6; PRODUCT_BUNDLE_IDENTIFIER = com.killingpoint.killingpart; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -691,6 +695,14 @@ kind = branch; }; }; + 13A100012FF5A00000A1B001 /* XCRemoteSwiftPackageReference "AmplitudeUnified-Swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/amplitude/AmplitudeUnified-Swift"; + requirement = { + branch = main; + kind = branch; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -729,6 +741,11 @@ package = 12F7A0212F3F010000A00001 /* XCRemoteSwiftPackageReference "kakao-ios-sdk" */; productName = KakaoSDKUser; }; + 13A100022FF5A00000A1B001 /* AmplitudeUnified */ = { + isa = XCSwiftPackageProductDependency; + package = 13A100012FF5A00000A1B001 /* XCRemoteSwiftPackageReference "AmplitudeUnified-Swift" */; + productName = AmplitudeUnified; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 1231F1152F372E5B00CFA51D /* Project object */; diff --git a/KillingPart.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/KillingPart.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4156566..de6c808 100644 --- a/KillingPart.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/KillingPart.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "bc3c4c74f2be949902d31d4307d6c7444dca4f412bbe0d2cd69f1bd4e8676af5", + "originHash" : "dccd8ddebbd64f512335ab3a726e468c2a72f72c10addb5239598111c2f8d6d2", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -19,6 +19,69 @@ "version" : "5.11.1" } }, + { + "identity" : "amplitude-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/amplitude/Amplitude-iOS.git", + "state" : { + "revision" : "7ec0f8971b68c1cb859028655fbff1bcde8170dd", + "version" : "8.22.2" + } + }, + { + "identity" : "amplitude-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/amplitude/Amplitude-Swift.git", + "state" : { + "revision" : "1c2bf3a45697ae33c4f13c7845cc3a3272fe3368", + "version" : "1.18.3" + } + }, + { + "identity" : "amplitudecore-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/amplitude/AmplitudeCore-Swift.git", + "state" : { + "revision" : "d9d24b79d4fcb4f1d1dcbc50ae20430afd22ebd0", + "version" : "1.4.5" + } + }, + { + "identity" : "amplitudesessionreplay-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/amplitude/AmplitudeSessionReplay-iOS.git", + "state" : { + "revision" : "339d1af2c50eedce29158ab70befb163fad4e260", + "version" : "0.10.1" + } + }, + { + "identity" : "amplitudeunified-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/amplitude/AmplitudeUnified-Swift", + "state" : { + "branch" : "main", + "revision" : "c91bca61d5ccb43d5be98267d912c5b36ed2ed21" + } + }, + { + "identity" : "analytics-connector-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/amplitude/analytics-connector-ios.git", + "state" : { + "revision" : "4adbfe85486e6dcdcdca5fa9362097ffe5ec712b", + "version" : "1.3.1" + } + }, + { + "identity" : "analytics-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/segmentio/analytics-swift", + "state" : { + "revision" : "729d6ae08b5488123f5c8e973cd4385ffa84f46a", + "version" : "1.9.4" + } + }, { "identity" : "app-check", "kind" : "remoteSourceControl", @@ -37,6 +100,15 @@ "version" : "2.0.0" } }, + { + "identity" : "experiment-ios-client", + "kind" : "remoteSourceControl", + "location" : "https://github.com/amplitude/experiment-ios-client.git", + "state" : { + "revision" : "96e9879fade782bf51e7b3acb7d0836aa50e0637", + "version" : "1.20.2" + } + }, { "identity" : "firebase-ios-sdk", "kind" : "remoteSourceControl", @@ -127,6 +199,15 @@ "version" : "101.0.0" } }, + { + "identity" : "jsonsafeencoding-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/segmentio/jsonsafeencoding-swift.git", + "state" : { + "revision" : "af6a8b360984085e36c6341b21ecb35c12f47ebd", + "version" : "2.0.0" + } + }, { "identity" : "kakao-ios-sdk", "kind" : "remoteSourceControl", @@ -162,6 +243,15 @@ "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", "version" : "2.4.0" } + }, + { + "identity" : "sovran-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/segmentio/sovran-swift.git", + "state" : { + "revision" : "24867f3e4ac62027db9827112135e6531b6f4051", + "version" : "1.1.2" + } } ], "version" : 3 diff --git a/KillingPart/Info.plist b/KillingPart/Info.plist index 90d72ab..0973f7d 100644 --- a/KillingPart/Info.plist +++ b/KillingPart/Info.plist @@ -4,6 +4,8 @@ APP_STORE_URL $(APP_STORE_URL) + AMPLITUDE_API_KEY + $(AMPLITUDE_API_KEY) BASE_URL $(BASE_URL) CFBundleURLTypes diff --git a/KillingPart/Services/AmplitudeClient.swift b/KillingPart/Services/AmplitudeClient.swift new file mode 100644 index 0000000..2e4437a --- /dev/null +++ b/KillingPart/Services/AmplitudeClient.swift @@ -0,0 +1,54 @@ +import AmplitudeUnified +import Foundation + +final class AmplitudeClient { + static let shared = AmplitudeClient() + + private var amplitude: Amplitude? + private var isConfigured = false + + private init() {} + + func configure(apiKey: String) { + guard !isConfigured else { return } + guard let normalizedKey = normalizedApiKey(from: apiKey) else { return } + + amplitude = Amplitude(apiKey: normalizedKey) + isConfigured = true + } + + func track(eventType: String, properties: [String: Any]? = nil) { + guard let amplitude else { return } + + if let properties, !properties.isEmpty { + amplitude.track(eventType: eventType, eventProperties: properties) + return + } + + amplitude.track(eventType: eventType) + } + + func setUserId(_ userId: String?) { + guard + let amplitude, + let userId = userId?.trimmingCharacters(in: .whitespacesAndNewlines), + !userId.isEmpty + else { + return + } + + amplitude.setUserId(userId: userId) + } + + private func normalizedApiKey(from rawValue: String) -> String? { + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !trimmed.isEmpty else { return nil } + guard !trimmed.hasPrefix("$(") else { return nil } + guard !trimmed.hasPrefix("YOUR_") else { return nil } + guard trimmed != "AMPLITUDE_API_KEY" else { return nil } + guard trimmed != "" else { return nil } + + return trimmed + } +} diff --git a/KillingPart/Services/AppDelegate.swift b/KillingPart/Services/AppDelegate.swift index c6a556d..cd1d386 100644 --- a/KillingPart/Services/AppDelegate.swift +++ b/KillingPart/Services/AppDelegate.swift @@ -15,6 +15,10 @@ final class AppDelegate: NSObject, UIApplicationDelegate { if let remotePayload = launchOptions?[.remoteNotification] as? [AnyHashable: Any] { FCMManager.shared.handleLaunchRemoteNotification(remotePayload) } + let amplitudeApiKey = (Bundle.main.object(forInfoDictionaryKey: "AMPLITUDE_API_KEY") as? String ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + AmplitudeClient.shared.configure(apiKey: amplitudeApiKey) + AmplitudeClient.shared.track(eventType: "app_opened") print("[FCM][1] Firebase 초기화 완료") return true } diff --git a/KillingPart/ViewModels/InitialSetupFlowViewModel.swift b/KillingPart/ViewModels/InitialSetupFlowViewModel.swift index 0fe8733..10b12b5 100644 --- a/KillingPart/ViewModels/InitialSetupFlowViewModel.swift +++ b/KillingPart/ViewModels/InitialSetupFlowViewModel.swift @@ -37,6 +37,7 @@ final class InitialSetupFlowViewModel: ObservableObject { private let diaryService: DiaryServicing private let calendarService: CalendarServicing private let shouldSkipNameSetupForAppleLogin: Bool + private var hasTrackedOnboardingTerminalEvent = false var onComplete: (() -> Void)? @@ -153,6 +154,7 @@ final class InitialSetupFlowViewModel: ObservableObject { } func skipAllTutorialAndFinish() { + trackOnboardingSkippedIfNeeded() onComplete?() } @@ -229,6 +231,7 @@ final class InitialSetupFlowViewModel: ObservableObject { } func finishTutorial() { + trackOnboardingCompletedIfNeeded() onComplete?() } @@ -321,6 +324,42 @@ final class InitialSetupFlowViewModel: ObservableObject { } } + private func trackOnboardingSkippedIfNeeded() { + guard !hasTrackedOnboardingTerminalEvent else { return } + hasTrackedOnboardingTerminalEvent = true + + let properties: [String: Any] = [ + "skip_step": skipStepName(for: step) + ] + AmplitudeClient.shared.track(eventType: "onboard_skipped", properties: properties) + } + + private func trackOnboardingCompletedIfNeeded() { + guard !hasTrackedOnboardingTerminalEvent else { return } + hasTrackedOnboardingTerminalEvent = true + + AmplitudeClient.shared.track(eventType: "onboard_completed") + } + + private func skipStepName(for step: Step) -> String { + switch step { + case .tutorialChoice: + return "tutorial_choice" + case .tutorialTrackSearch: + return "tutorial_track_search" + case .tutorialTrim: + return "tutorial_trim" + case .tutorialHome: + return "tutorial_home" + case .tutorialDiaryDetail: + return "tutorial_diary_detail" + case .tutorialNotification: + return "tutorial_notification" + case .policyAgreement, .nameSetup, .tagSetup, .tutorialFinal: + return "unknown" + } + } + private func resolveErrorMessage(from error: Error) -> String { if let userError = error as? UserServiceError { return userError.errorDescription ?? "요청 처리에 실패했어요." diff --git a/KillingPart/ViewModels/LoginViewModel.swift b/KillingPart/ViewModels/LoginViewModel.swift index a458619..f4dfadc 100644 --- a/KillingPart/ViewModels/LoginViewModel.swift +++ b/KillingPart/ViewModels/LoginViewModel.swift @@ -45,6 +45,7 @@ final class LoginViewModel: ObservableObject { if isSuccess { isNewUser = false lastSuccessfulLoginProvider = nil + trackLoginCompletion(provider: "email", isNewUser: false) onLoginSuccess?(false) } else { loginErrorMessage = "이메일과 비밀번호를 입력해주세요." @@ -63,6 +64,7 @@ final class LoginViewModel: ObservableObject { let response = try await authenticationService.loginWithKakao(accessToken: kakaoAccessToken) isNewUser = response.isNew lastSuccessfulLoginProvider = .kakao + trackLoginCompletion(provider: "kakao", isNewUser: response.isNew) onLoginSuccess?(response.isNew) } catch let authError as AuthenticationServiceError { loginErrorMessage = authError.errorDescription @@ -90,6 +92,7 @@ final class LoginViewModel: ObservableObject { ) isNewUser = response.isNew lastSuccessfulLoginProvider = .apple + trackLoginCompletion(provider: "apple", isNewUser: response.isNew) onLoginSuccess?(response.isNew) } catch let authError as AuthenticationServiceError { loginErrorMessage = authError.errorDescription @@ -112,6 +115,7 @@ final class LoginViewModel: ObservableObject { let response = try await authenticationService.loginWithGoogle(idToken: googleIDToken) isNewUser = response.isNew lastSuccessfulLoginProvider = .google + trackLoginCompletion(provider: "google", isNewUser: response.isNew) onLoginSuccess?(response.isNew) } catch let authError as AuthenticationServiceError { loginErrorMessage = authError.errorDescription @@ -133,6 +137,7 @@ final class LoginViewModel: ObservableObject { _ = try await authService.loginWithTester() isNewUser = true lastSuccessfulLoginProvider = .tester + trackLoginCompletion(provider: "tester", isNewUser: true) onLoginSuccess?(true) } catch let socialError as AuthServiceError { loginErrorMessage = socialError.errorDescription @@ -163,4 +168,15 @@ final class LoginViewModel: ObservableObject { isLoading = false activeSocialLoginProvider = nil } + + private func trackLoginCompletion(provider: String, isNewUser: Bool) { + let eventType = isNewUser ? "signup_completed" : "signin_completed" + AmplitudeClient.shared.track( + eventType: eventType, + properties: [ + "provider": provider, + "is_new_user": isNewUser + ] + ) + } } diff --git a/KillingPart/Views/Screens/Main/Add/AddSearchDetail/AddSearchDetailView.swift b/KillingPart/Views/Screens/Main/Add/AddSearchDetail/AddSearchDetailView.swift index a9e0684..003670f 100644 --- a/KillingPart/Views/Screens/Main/Add/AddSearchDetail/AddSearchDetailView.swift +++ b/KillingPart/Views/Screens/Main/Add/AddSearchDetail/AddSearchDetailView.swift @@ -146,6 +146,9 @@ struct AddSearchDetailView: View { if isTutorialTrimFocusActive { isTutorialTrimFocusActive = false } + }, + onTrimInteractionEnded: { control in + trackCutHandleAdjusted(control: control) } ) .transition(stepTransition) @@ -240,7 +243,10 @@ struct AddSearchDetailView: View { } isForwardStepTransition = true withAnimation(.easeInOut(duration: 0.28)) { - _ = viewModel.moveToCommentStep() + let isMoved = viewModel.moveToCommentStep() + if isMoved { + trackCutCompleted() + } } } @@ -255,6 +261,11 @@ struct AddSearchDetailView: View { Task { let isSuccess = await viewModel.submitDiary() if isSuccess { + if isTutorialTrimFocusEnabled { + trackOnboardingKillingPartCutCompleted() + } else { + trackKillingPartCutCompleted() + } onSaved?() if shouldNavigateToPlayKillingPartOnSave { NotificationCenter.default.post(name: .navigateToPlayKillingPart, object: nil) @@ -268,6 +279,68 @@ struct AddSearchDetailView: View { } } } + + private func trackCutHandleAdjusted(control: AddSearchDetailTrimInteractionControl) { + var properties = trimRangeProperties + properties["control"] = control.rawValue + AmplitudeClient.shared.track(eventType: "cut_handle_adjusted", properties: properties) + } + + private func trackCutCompleted() { + var properties = trackIdentityProperties + properties.merge(trimRangeProperties) { _, new in new } + AmplitudeClient.shared.track(eventType: "cut_completed", properties: properties) + } + + private func trackKillingPartCutCompleted() { + let totalCount = AddSearchDetailCutCounter.incrementAndGet() + var properties = trackIdentityProperties + properties.merge(trimRangeProperties) { _, new in new } + properties["total_killingpart_cut_count"] = totalCount + AmplitudeClient.shared.track(eventType: "killingpart_cut_completed", properties: properties) + } + + private func trackOnboardingKillingPartCutCompleted() { + var properties = trackIdentityProperties + properties.merge(trimRangeProperties) { _, new in new } + AmplitudeClient.shared.track( + eventType: "onboarding_killingpart_cut_completed", + properties: properties + ) + } + + private var trackIdentityProperties: [String: Any] { + [ + "track_id": viewModel.track.id, + "track_title": viewModel.track.title, + "track_artist": viewModel.track.artist + ] + } + + private var trimRangeProperties: [String: Any] { + let start = roundedSeconds(viewModel.startSeconds) + let end = roundedSeconds(viewModel.endSeconds) + return [ + "start_sec": start, + "end_sec": end, + "clip_duration_sec": roundedSeconds(max(end - start, 0)) + ] + } + + private func roundedSeconds(_ seconds: Double) -> Double { + (seconds * 100).rounded() / 100 + } +} + +private enum AddSearchDetailCutCounter { + private static let key = "amplitude_total_killingpart_cut_count" + + static func incrementAndGet() -> Int { + let defaults = UserDefaults.standard + let nextValue = defaults.integer(forKey: key) + 1 + defaults.set(nextValue, forKey: key) + return nextValue + } } #Preview { diff --git a/KillingPart/Views/Screens/Main/Add/AddSearchDetail/components/AddSearchDetailTrimSection.swift b/KillingPart/Views/Screens/Main/Add/AddSearchDetail/components/AddSearchDetailTrimSection.swift index 26ac805..04dd1c7 100644 --- a/KillingPart/Views/Screens/Main/Add/AddSearchDetail/components/AddSearchDetailTrimSection.swift +++ b/KillingPart/Views/Screens/Main/Add/AddSearchDetail/components/AddSearchDetailTrimSection.swift @@ -3,6 +3,7 @@ import SwiftUI struct AddSearchDetailTrimSection: View { @ObservedObject var viewModel: AddSearchDetailViewModel let onTrimInteracted: () -> Void + let onTrimInteractionEnded: (_ control: AddSearchDetailTrimInteractionControl) -> Void private var startDisplayTimeText: String { TimeFormatter.minuteSecondText(from: viewModel.startSeconds) @@ -32,6 +33,7 @@ struct AddSearchDetailTrimSection: View { startTimeText: startDisplayTimeText, endTimeText: endDisplayTimeText, onTrimInteracted: onTrimInteracted, + onInteractionEnded: onTrimInteractionEnded, onUpdateRange: { start, end in viewModel.updateRange(start: start, end: end) } diff --git a/KillingPart/Views/Screens/Main/Add/AddSearchDetail/components/AddSearchDetailWaveformTrimView.swift b/KillingPart/Views/Screens/Main/Add/AddSearchDetail/components/AddSearchDetailWaveformTrimView.swift index 1d46467..3ca129e 100644 --- a/KillingPart/Views/Screens/Main/Add/AddSearchDetail/components/AddSearchDetailWaveformTrimView.swift +++ b/KillingPart/Views/Screens/Main/Add/AddSearchDetail/components/AddSearchDetailWaveformTrimView.swift @@ -1,6 +1,12 @@ import SwiftUI import UIKit +enum AddSearchDetailTrimInteractionControl: String { + case left + case right + case spectrumBar = "spectrum_bar" +} + struct AddSearchDetailWaveformTrimView: View { @Binding var startSeconds: Double @Binding var endSeconds: Double @@ -8,6 +14,7 @@ struct AddSearchDetailWaveformTrimView: View { let startTimeText: String let endTimeText: String let onTrimInteracted: () -> Void + let onInteractionEnded: (_ control: AddSearchDetailTrimInteractionControl) -> Void let onUpdateRange: (_ start: Double, _ end: Double) -> Void private let horizontalPadding: CGFloat = 18 @@ -41,6 +48,7 @@ struct AddSearchDetailWaveformTrimView: View { @State private var timelineViewportOffsetX: CGFloat = 0 @State private var timelineViewportWidth: CGFloat = 0 @State private var timelineContentWidth: CGFloat = 0 + @State private var timelineScrollEndWorkItem: DispatchWorkItem? var body: some View { GeometryReader { proxy in @@ -67,9 +75,7 @@ struct AddSearchDetailWaveformTrimView: View { } }, onViewportChange: { viewport in - timelineViewportOffsetX = viewport.contentOffsetX - timelineViewportWidth = viewport.viewportWidth - timelineContentWidth = viewport.contentWidth + handleTimelineViewportChanged(viewport) } ) } @@ -88,6 +94,8 @@ struct AddSearchDetailWaveformTrimView: View { } .onDisappear { stopAutoScrollTimer() + timelineScrollEndWorkItem?.cancel() + timelineScrollEndWorkItem = nil } } @@ -261,6 +269,9 @@ struct AddSearchDetailWaveformTrimView: View { let target = timeForOverviewX(value.location.x, width: width) moveSelectionCenter(to: target) } + .onEnded { _ in + onInteractionEnded(.spectrumBar) + } ) } @@ -385,6 +396,34 @@ struct AddSearchDetailWaveformTrimView: View { return clampedOffset...upper } + private func handleTimelineViewportChanged(_ viewport: AddSearchDetailTimelineViewport) { + let previousOffset = timelineViewportOffsetX + timelineViewportOffsetX = viewport.contentOffsetX + timelineViewportWidth = viewport.viewportWidth + timelineContentWidth = viewport.contentWidth + + let didOffsetChange = abs(previousOffset - viewport.contentOffsetX) > 0.5 + guard didOffsetChange else { return } + guard activeHandleDragDirection == nil else { return } + guard viewport.isTracking || viewport.isDragging || viewport.isDecelerating else { return } + + scheduleTimelineScrollEndEvent() + } + + private func scheduleTimelineScrollEndEvent() { + timelineScrollEndWorkItem?.cancel() + let workItem = DispatchWorkItem { + guard let scrollView = timelineScrollView else { return } + if scrollView.isTracking || scrollView.isDragging || scrollView.isDecelerating { + scheduleTimelineScrollEndEvent() + return + } + onInteractionEnded(.spectrumBar) + } + timelineScrollEndWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: workItem) + } + private func startHandleDragGesture(contentWidth: CGFloat, viewportWidth: CGFloat) -> some Gesture { DragGesture(minimumDistance: 0, coordinateSpace: .named(timelineCoordinateSpaceName)) .onChanged { value in @@ -399,6 +438,7 @@ struct AddSearchDetailWaveformTrimView: View { } .onEnded { _ in startDragBase = nil + onInteractionEnded(.left) endActiveHandleDrag() } } @@ -417,6 +457,7 @@ struct AddSearchDetailWaveformTrimView: View { } .onEnded { _ in endDragBase = nil + onInteractionEnded(.right) endActiveHandleDrag() } } @@ -655,6 +696,9 @@ private struct AddSearchDetailTimelineViewport { let contentOffsetX: CGFloat let viewportWidth: CGFloat let contentWidth: CGFloat + let isTracking: Bool + let isDragging: Bool + let isDecelerating: Bool } private struct AddSearchDetailScrollViewResolver: UIViewRepresentable { @@ -762,7 +806,10 @@ private struct AddSearchDetailScrollViewResolver: UIViewRepresentable { let snapshot = AddSearchDetailTimelineViewport( contentOffsetX: scrollView.contentOffset.x, viewportWidth: scrollView.bounds.width, - contentWidth: scrollView.contentSize.width + contentWidth: scrollView.contentSize.width, + isTracking: scrollView.isTracking, + isDragging: scrollView.isDragging, + isDecelerating: scrollView.isDecelerating ) DispatchQueue.main.async { self.onViewportChange(snapshot) diff --git a/KillingPart/Views/Screens/Main/Add/Components/AddTrackListView.swift b/KillingPart/Views/Screens/Main/Add/Components/AddTrackListView.swift index bb656fe..7da95a8 100644 --- a/KillingPart/Views/Screens/Main/Add/Components/AddTrackListView.swift +++ b/KillingPart/Views/Screens/Main/Add/Components/AddTrackListView.swift @@ -20,6 +20,12 @@ struct AddTrackListView: View { AddTrackRowView(track: track) } .buttonStyle(.plain) + .simultaneousGesture( + TapGesture() + .onEnded { + trackSelectedEvent(for: track) + } + ) .onAppear { onTrackAppear(track.id) } @@ -41,6 +47,18 @@ struct AddTrackListView: View { .scrollDismissesKeyboard(.immediately) .scrollIndicators(.hidden) } + + private func trackSelectedEvent(for track: SpotifySimpleTrack) { + AmplitudeClient.shared.track( + eventType: "track_selected", + properties: [ + "track_id": track.id, + "track_title": track.title, + "track_artist": track.artist, + "album_id": track.albumId + ] + ) + } } private struct AddTrackRowView: View { diff --git a/KillingPart/Views/Screens/Main/MainTabView.swift b/KillingPart/Views/Screens/Main/MainTabView.swift index d172097..f31bb2f 100644 --- a/KillingPart/Views/Screens/Main/MainTabView.swift +++ b/KillingPart/Views/Screens/Main/MainTabView.swift @@ -2,11 +2,16 @@ import SwiftUI import UIKit struct MainTabView: View { + @Environment(\.scenePhase) private var scenePhase let onLogout: () -> Void @State private var selectedTab: MainRootTab = .my @State private var randomRefreshTrigger = 0 @StateObject private var socialViewModel = SocialViewModel() @StateObject private var socialFeedViewModel = FeedViewModel() + @State private var activeTabForAnalytics: MainRootTab = .my + @State private var activeTabEnteredAt = Date() + @State private var hasTrackedInitialTabSelection = false + @State private var hasTrackedInactivePhaseStay = false var body: some View { TabView(selection: $selectedTab) { @@ -57,7 +62,24 @@ struct MainTabView: View { .toolbarColorScheme(.dark, for: .tabBar) .toolbarBackground(.black, for: .tabBar) .toolbarBackground(.visible, for: .tabBar) + .onAppear { + trackInitialMainTabSelectionIfNeeded() + } + .onDisappear { + guard scenePhase == .active else { return } + trackMainTabStayedIfNeeded(endReason: "view_disappear") + } + .onChange(of: scenePhase) { phase in + if phase == .active { + hasTrackedInactivePhaseStay = false + return + } + guard !hasTrackedInactivePhaseStay else { return } + hasTrackedInactivePhaseStay = true + trackMainTabStayedIfNeeded(endReason: "app_background") + } .onChange(of: selectedTab) { tab in + handleMainTabSelectionChanged(to: tab) guard tab == .random else { return } randomRefreshTrigger &+= 1 } @@ -89,6 +111,56 @@ struct MainTabView: View { await socialViewModel.notificationListViewModel.handlePushAlarmRoute(route) } } + + private func trackInitialMainTabSelectionIfNeeded() { + guard !hasTrackedInitialTabSelection else { return } + hasTrackedInitialTabSelection = true + activeTabForAnalytics = selectedTab + activeTabEnteredAt = Date() + + AmplitudeClient.shared.track( + eventType: "main_tab_selected", + properties: [ + "tab": selectedTab.analyticsName, + "previous_tab": "none" + ] + ) + } + + private func handleMainTabSelectionChanged(to tab: MainRootTab) { + trackInitialMainTabSelectionIfNeeded() + guard activeTabForAnalytics != tab else { return } + + let previousTab = activeTabForAnalytics + trackMainTabStayedIfNeeded(endReason: "tab_change") + AmplitudeClient.shared.track( + eventType: "main_tab_selected", + properties: [ + "tab": tab.analyticsName, + "previous_tab": previousTab.analyticsName + ] + ) + + activeTabForAnalytics = tab + activeTabEnteredAt = Date() + } + + private func trackMainTabStayedIfNeeded(endReason: String) { + guard hasTrackedInitialTabSelection else { return } + let stayDuration = Date().timeIntervalSince(activeTabEnteredAt) + guard stayDuration > 0 else { return } + + let roundedDuration = (stayDuration * 100).rounded() / 100 + AmplitudeClient.shared.track( + eventType: "main_tab_stayed", + properties: [ + "tab": activeTabForAnalytics.analyticsName, + "stay_duration_sec": roundedDuration, + "end_reason": endReason + ] + ) + activeTabEnteredAt = Date() + } } private enum MainRootTab: Hashable { @@ -124,6 +196,19 @@ private enum MainRootTab: Hashable { return nil } } + + var analyticsName: String { + switch self { + case .my: + return "my" + case .random: + return "explore" + case .social: + return "social" + case .add: + return "add" + } + } } private struct MainTabBarSelectionObserver: UIViewControllerRepresentable { diff --git a/KillingPart/Views/Screens/Main/Randoms/RandomSearchView.swift b/KillingPart/Views/Screens/Main/Randoms/RandomSearchView.swift index 5c9d44b..ba005e2 100644 --- a/KillingPart/Views/Screens/Main/Randoms/RandomSearchView.swift +++ b/KillingPart/Views/Screens/Main/Randoms/RandomSearchView.swift @@ -8,6 +8,7 @@ struct RandomSearchView: View { @State private var isRefreshingFromTabEvent = false @State private var refreshErrorMessage: String? @State private var lastTabEventRefreshAt: Date = .distantPast + @State private var exploreSwipeCountInSession = 0 private let tabEventRefreshDebounceInterval: TimeInterval = 3 var body: some View { @@ -32,6 +33,18 @@ struct RandomSearchView: View { socialViewModel.makeSocialMyCollectionViewModel( for: resolvedCollectionUser(from: user) ) + }, + onPageChanged: { oldIndex, newIndex, diaryId in + exploreSwipeCountInSession += 1 + AmplitudeClient.shared.track( + eventType: "explore_feed_card_viewed", + properties: [ + "from_index": oldIndex, + "to_index": newIndex, + "swipe_count_in_session": exploreSwipeCountInSession, + "diary_id": diaryId + ] + ) } ) } diff --git a/KillingPart/Views/Screens/Main/Social/FeedSection/FeedSectionView.swift b/KillingPart/Views/Screens/Main/Social/FeedSection/FeedSectionView.swift index 841eb79..1593766 100644 --- a/KillingPart/Views/Screens/Main/Social/FeedSection/FeedSectionView.swift +++ b/KillingPart/Views/Screens/Main/Social/FeedSection/FeedSectionView.swift @@ -5,10 +5,12 @@ struct FeedSectionView: View { @ObservedObject var viewModel: FeedViewModel let isParentActive: Bool let makeCollectionViewModel: (UserModel) -> SocialMyCollectionViewModel + let onPageChanged: ((_ oldIndex: Int, _ newIndex: Int, _ diaryId: Int) -> Void)? @State private var currentPageIndex = 0 @State private var elapsedInCurrentRange: TimeInterval = 0 @State private var isViewActive = false @State private var previousFeedCount = 0 + @State private var lastTrackedPageIndex: Int? @State private var playbackFocusToken = 0 @State private var selectedProfileUser: UserModel? @State private var isProfileNavigationActive = false @@ -24,6 +26,18 @@ struct FeedSectionView: View { private let playbackTimer = Timer.publish(every: 0.25, on: .main, in: .common).autoconnect() + init( + viewModel: FeedViewModel, + isParentActive: Bool, + makeCollectionViewModel: @escaping (UserModel) -> SocialMyCollectionViewModel, + onPageChanged: ((_ oldIndex: Int, _ newIndex: Int, _ diaryId: Int) -> Void)? = nil + ) { + self.viewModel = viewModel + self.isParentActive = isParentActive + self.makeCollectionViewModel = makeCollectionViewModel + self.onPageChanged = onPageChanged + } + var body: some View { GeometryReader { geometry in let bottomInset = max(geometry.safeAreaInsets.bottom, AppSpacing.m) + AppSpacing.xl @@ -67,6 +81,7 @@ struct FeedSectionView: View { .onAppear { isViewActive = true previousFeedCount = viewModel.feeds.count + lastTrackedPageIndex = currentPageIndex handleFocusActivated() } .onDisappear { @@ -88,9 +103,13 @@ struct FeedSectionView: View { if newCount == 0 { currentPageIndex = 0 elapsedInCurrentRange = 0 + lastTrackedPageIndex = nil } else if currentPageIndex >= newCount { currentPageIndex = max(newCount - 1, 0) } + if newCount > 0 { + lastTrackedPageIndex = currentPageIndex + } if hasAppendedFeed && isPlaybackActive { bumpPlaybackFocusToken() } @@ -255,7 +274,15 @@ struct FeedSectionView: View { guard isPlaybackActive else { return } elapsedInCurrentRange += 0.25 } - .onChange(of: currentPageIndex) { _ in + .onChange(of: currentPageIndex) { newIndex in + if + let oldIndex = lastTrackedPageIndex, + oldIndex != newIndex, + let diaryId = diaryID(at: newIndex) + { + onPageChanged?(oldIndex, newIndex, diaryId) + } + lastTrackedPageIndex = newIndex elapsedInCurrentRange = 0 bumpPlaybackFocusToken() } @@ -344,6 +371,11 @@ struct FeedSectionView: View { viewModel.feeds.first(where: { $0.diaryId == diaryId }) } + private func diaryID(at index: Int) -> Int? { + guard viewModel.feeds.indices.contains(index) else { return nil } + return viewModel.feeds[index].diaryId + } + private func makeUserModel(from feed: DiaryFeedModel) -> UserModel { let username = trimmedString(feed.username, fallback: "킬링파트 사용자") let tag = normalizeTag(trimmedString(feed.tag, fallback: "killingpart_user"))