diff --git a/.github/workflows/appstore_release.yml b/.github/workflows/appstore_release.yml
index 0f63aeb7..86232d01 100644
--- a/.github/workflows/appstore_release.yml
+++ b/.github/workflows/appstore_release.yml
@@ -10,7 +10,7 @@ on:
jobs:
build:
- runs-on: macos-latest
+ runs-on: macos-26
steps:
- uses: actions/checkout@v4
@@ -18,7 +18,7 @@ jobs:
- name: Set up Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
- xcode-version: '16.1'
+ xcode-version: latest-stable
- uses: shimataro/ssh-key-action@v2
with:
diff --git a/.github/workflows/build_test.yml b/.github/workflows/build_test.yml
index f7b026c3..e7a6fb32 100644
--- a/.github/workflows/build_test.yml
+++ b/.github/workflows/build_test.yml
@@ -12,7 +12,7 @@ on:
jobs:
build:
- runs-on: macos-latest
+ runs-on: macos-26
steps:
- uses: actions/checkout@v4
@@ -20,7 +20,7 @@ jobs:
- name: Set up Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
- xcode-version: '16.1'
+ xcode-version: latest-stable
- uses: shimataro/ssh-key-action@v2
with:
diff --git a/.github/workflows/develop_hotfix.yml b/.github/workflows/develop_hotfix.yml
index 01172def..8a0ef29f 100644
--- a/.github/workflows/develop_hotfix.yml
+++ b/.github/workflows/develop_hotfix.yml
@@ -12,7 +12,7 @@ on:
jobs:
build:
if: startsWith(github.event.head_commit.message, '[hotfix]')
- runs-on: macos-latest
+ runs-on: macos-26
steps:
- uses: actions/checkout@v4
diff --git a/.github/workflows/testflight_release.yml b/.github/workflows/testflight_release.yml
index 6b7a0b6f..f8aebfd3 100644
--- a/.github/workflows/testflight_release.yml
+++ b/.github/workflows/testflight_release.yml
@@ -15,7 +15,7 @@ on:
jobs:
build:
if: ${{ github.event_name == 'workflow_dispatch' || github.event.pull_request.merged == true }}
- runs-on: macos-latest
+ runs-on: macos-26
steps:
- uses: actions/checkout@v4
@@ -23,7 +23,7 @@ jobs:
- name: Set up Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
- xcode-version: '16.1'
+ xcode-version: latest-stable
- uses: shimataro/ssh-key-action@v2
with:
diff --git a/Projects/App/Resources/Pokit-info.plist b/Projects/App/Resources/Pokit-info.plist
index 98d454b2..02fec033 100644
--- a/Projects/App/Resources/Pokit-info.plist
+++ b/Projects/App/Resources/Pokit-info.plist
@@ -21,7 +21,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 2.0.0
+ 2.0.2
CFBundleURLTypes
@@ -94,5 +94,7 @@
UIInterfaceOrientationLandscapeLeft
UIInterfaceOrientationLandscapeRight
+ AMPLITUDE_API_KEY
+ $(AMPLITUDE_API_KEY)
diff --git a/Projects/App/ShareExtension/Info.plist b/Projects/App/ShareExtension/Info.plist
index 8a334c06..c2ade449 100644
--- a/Projects/App/ShareExtension/Info.plist
+++ b/Projects/App/ShareExtension/Info.plist
@@ -13,7 +13,7 @@
CFBundleName
Pokit
CFBundleShortVersionString
- 1.0.9
+ 2.0.1
CFBundleURLTypes
diff --git a/Projects/App/Sources/AppDelegate/AppDelegate.swift b/Projects/App/Sources/AppDelegate/AppDelegate.swift
index f72ea97e..c51a2024 100644
--- a/Projects/App/Sources/AppDelegate/AppDelegate.swift
+++ b/Projects/App/Sources/AppDelegate/AppDelegate.swift
@@ -6,19 +6,25 @@
//
import SwiftUI
+import UIKit
import ComposableArchitecture
import Firebase
import FirebaseMessaging
import GoogleSignIn
+import Dependencies
final class AppDelegate: NSObject {
+ @Dependency(\.amplitude)
+ private var amplitude
+
let store = Store(initialState: AppDelegateFeature.State()) {
AppDelegateFeature()
}
}
//MARK: - UIApplicationDelegate
extension AppDelegate: UIApplicationDelegate {
+
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
if GIDSignIn.sharedInstance.handle(url) { return true }
return false
@@ -30,6 +36,15 @@ extension AppDelegate: UIApplicationDelegate {
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
self.store.send(.didFinishLaunching)
+
+ // 운영체제 버전 (ex: "iOS 18.0.0")
+ let osVersion = "iOS \(UIDevice.current.systemVersion)"
+
+ // 앱 번들 버전 (ex: "2.0.1")
+ let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
+ let amplitudeKey = Bundle.main.infoDictionary?["AMPLITUDE_API_KEY"] as? String ?? ""
+ amplitude.initialize(amplitudeKey, nil)
+ amplitude.track(.app_open(deviceOS: osVersion, appVersion: appVersion))
return true
}
diff --git a/Projects/App/Sources/MainTab/MainTabFeature.swift b/Projects/App/Sources/MainTab/MainTabFeature.swift
index 186d6bda..b6096ec8 100644
--- a/Projects/App/Sources/MainTab/MainTabFeature.swift
+++ b/Projects/App/Sources/MainTab/MainTabFeature.swift
@@ -24,6 +24,9 @@ public struct MainTabFeature {
private var categoryClient
@Dependency(UserDefaultsClient.self)
private var userDefaults
+ @Dependency(\.amplitude.track)
+ private var amplitudeTrack
+
/// - State
@ObservableState
public struct State: Equatable {
@@ -103,6 +106,14 @@ public struct MainTabFeature {
guard state.linkPopup == nil else { return .none }
state.categoryOfSavedContent = nil
return .none
+ case .binding(\.selectedTab):
+ switch state.selectedTab {
+ case .pokit:
+ amplitudeTrack(.view_home_pokit(entryPoint: "pokit"))
+ case .recommend:
+ amplitudeTrack(.view_home_recommend(entryPoint: "recommend"))
+ }
+ return .none
case .binding:
return .none
case let .pushAlertTapped(isTapped):
@@ -197,6 +208,13 @@ private extension MainTabFeature {
let categoryIdString = queryItems.first(where: { $0.name == "categoryId" })?.value,
let categoryId = Int(categoryIdString)
else { return .none }
+
+ switch state.selectedTab {
+ case .pokit:
+ amplitudeTrack(.view_home_pokit(entryPoint: "deeplink"))
+ case .recommend:
+ amplitudeTrack(.view_home_recommend(entryPoint: "deeplink"))
+ }
return .send(.async(.공유받은_카테고리_조회(categoryId: categoryId)))
case .경고_확인버튼_클릭:
diff --git a/Projects/App/Sources/MainTab/MainTabFeatureView.swift b/Projects/App/Sources/MainTab/MainTabFeatureView.swift
index 0fbfd373..6388ef08 100644
--- a/Projects/App/Sources/MainTab/MainTabFeatureView.swift
+++ b/Projects/App/Sources/MainTab/MainTabFeatureView.swift
@@ -74,10 +74,8 @@ public extension MainTabView {
}
if self.store.linkPopup != nil {
- PokitLinkPopup(
- type: $store.linkPopup,
- action: { send(.링크팝업_버튼_눌렀을때, animation: .pokitSpring) }
- )
+ PokitLinkPopup(type: $store.linkPopup)
+ .onAction { send(.링크팝업_버튼_눌렀을때, animation: .pokitSpring) }
}
}
}
@@ -93,11 +91,9 @@ private extension MainTabView {
.overlay(alignment: .bottom) {
VStack(spacing: 0) {
if store.linkPopup != nil {
- PokitLinkPopup(
- type: $store.linkPopup,
- action: { send(.링크팝업_버튼_눌렀을때, animation: .pokitSpring) }
- )
- .padding(.bottom, 20)
+ PokitLinkPopup(type: $store.linkPopup)
+ .onAction { send(.링크팝업_버튼_눌렀을때, animation: .pokitSpring) }
+ .padding(.bottom, 20)
}
bottomTabBar
@@ -273,7 +269,14 @@ private extension MainTabView {
var body: some View {
GeometryReader { proxy in
- let bottomSafeArea = proxy.safeAreaInsets.bottom
+ let bottomPadding: CGFloat = {
+ if #available(iOS 26.0, *) {
+ return 32
+ } else {
+ return 48
+ }
+ }()
+
HStack(spacing: 20) {
Spacer()
@@ -306,7 +309,7 @@ private extension MainTabView {
Spacer()
}
- .padding(.bottom, 48 - bottomSafeArea)
+ .padding(.bottom, bottomPadding)
.padding(.top, 36)
.pokitPresentationCornerRadius()
.pokitPresentationBackground()
@@ -319,6 +322,7 @@ private extension MainTabView {
}
.presentationDetents([.height(self.height)])
}
+ .ignoresSafeArea(edges: .bottom)
}
}
}
diff --git a/Projects/App/Sources/MainTab/MainTabPath.swift b/Projects/App/Sources/MainTab/MainTabPath.swift
index 68d1e33c..fbd1782d 100644
--- a/Projects/App/Sources/MainTab/MainTabPath.swift
+++ b/Projects/App/Sources/MainTab/MainTabPath.swift
@@ -194,13 +194,13 @@ public extension MainTabFeature {
case .검색:
return .merge(
.send(.path(.element(id: stackElementId, action: .검색(.delegate(.컨텐츠_검색))))),
- .send(.inner(.링크팝업_활성화(.success(title: Constants.링크_저장_완료_문구))), animation: .pokitSpring)
+ .send(.inner(.링크팝업_활성화(.success(title: Constants.링크_저장_완료_문구, until: 4))), animation: .pokitSpring)
)
default:
- return .send(.inner(.링크팝업_활성화(.success(title: Constants.링크_저장_완료_문구))), animation: .pokitSpring)
+ return .send(.inner(.링크팝업_활성화(.success(title: Constants.링크_저장_완료_문구, until: 4))), animation: .pokitSpring)
}
case .recommend(.delegate(.저장하기_완료)):
- return .send(.inner(.링크팝업_활성화(.success(title: Constants.링크_저장_완료_문구))), animation: .pokitSpring)
+ return .send(.inner(.링크팝업_활성화(.success(title: Constants.링크_저장_완료_문구, until: 4))), animation: .pokitSpring)
/// - 각 화면에서 링크 복사 감지했을 때 (링크 추가 및 수정 화면 제외)
case let .path(.element(_, action: .알림함(.delegate(.linkCopyDetected(url))))),
let .path(.element(_, action: .검색(.delegate(.linkCopyDetected(url))))),
diff --git a/Projects/CoreKit/Project.swift b/Projects/CoreKit/Project.swift
index 2679bc2b..a42f182a 100644
--- a/Projects/CoreKit/Project.swift
+++ b/Projects/CoreKit/Project.swift
@@ -36,6 +36,7 @@ let coreKit: Target = .target(
.external(name: "KakaoSDKCommon"),
.external(name: "KakaoSDKShare"),
.external(name: "KakaoSDKTemplate"),
+ .external(name: "AmplitudeSwift"),
],
settings: .settings()
)
diff --git a/Projects/CoreKit/Sources/Core/Analytics/AmplitudeManager.swift b/Projects/CoreKit/Sources/Core/Analytics/AmplitudeManager.swift
new file mode 100644
index 00000000..dfb58ae8
--- /dev/null
+++ b/Projects/CoreKit/Sources/Core/Analytics/AmplitudeManager.swift
@@ -0,0 +1,73 @@
+import Foundation
+import AmplitudeSwift
+
+/// Amplitude Analytics 관리자
+public final class AmplitudeManager {
+ public static let shared = AmplitudeManager()
+
+ private var amplitude: Amplitude?
+
+ private init() {}
+
+ /// Amplitude 초기화
+ /// - Parameters:
+ /// - apiKey: Amplitude API Key
+ /// - userId: 사용자 ID (옵셔널)
+ public func initialize(apiKey: String, userId: String? = nil) {
+ amplitude = Amplitude(configuration: Configuration(
+ apiKey: apiKey
+ ))
+
+ if let userId = userId {
+ setUserId(userId)
+ }
+ }
+
+ /// 사용자 ID 설정
+ /// - Parameter userId: 사용자 ID
+ public func setUserId(_ userId: String) {
+ amplitude?.setUserId(userId: userId)
+ }
+
+ /// 사용자 속성 설정
+ /// - Parameter properties: 사용자 속성
+ public func setUserProperties(_ properties: [String: Any]) {
+ let identify = Identify()
+ properties.forEach { key, value in
+ identify.set(property: key, value: value)
+ }
+ amplitude?.identify(identify: identify)
+ }
+
+ /// 이벤트 전송
+ /// - Parameter event: AnalyticsEvent
+ public func track(_ event: AnalyticsEvent) {
+ guard let amplitude = amplitude else {
+ print("⚠️ Amplitude가 초기화되지 않았습니다.")
+ return
+ }
+
+ let eventProperties = event.properties.isEmpty ? nil : event.properties
+ amplitude.track(
+ eventType: event.eventName,
+ eventProperties: eventProperties
+ )
+
+ #if DEBUG
+ print("📊 [Analytics] \(event.eventName)")
+ if let properties = eventProperties {
+ print(" Properties: \(properties)")
+ }
+ #endif
+ }
+
+ /// 이벤트 버퍼 즉시 전송
+ public func flush() {
+ amplitude?.flush()
+ }
+
+ /// Amplitude 리셋 (로그아웃 시 사용)
+ public func reset() {
+ amplitude?.reset()
+ }
+}
diff --git a/Projects/CoreKit/Sources/Core/Analytics/AnalyticsEvent.swift b/Projects/CoreKit/Sources/Core/Analytics/AnalyticsEvent.swift
new file mode 100644
index 00000000..7891168d
--- /dev/null
+++ b/Projects/CoreKit/Sources/Core/Analytics/AnalyticsEvent.swift
@@ -0,0 +1,164 @@
+import Foundation
+
+/// Analytics 이벤트 타입
+public enum AnalyticsEvent {
+ case app_open(deviceOS: String, appVersion: String)
+ case view_splash
+ case login_start(method: LoginMethod)
+ case login_complete(method: LoginMethod)
+ case interest_select(interests: [String])
+ case onboarding_complete
+ case view_home_pokit(entryPoint: String)
+ case view_home_recommend(entryPoint: String)
+ case add_folder(folderName: String)
+ case add_link(folderId: String, linkDomain: String, entryPoint: String? = nil, linkId: String? = nil, positionIndex: Int? = nil, algoVersion: String? = nil)
+ case view_folder_detail(folderId: String)
+ case view_link_detail(linkId: String, linkDomain: String, entryPoint: String? = nil, positionIndex: Int? = nil, cardType: String? = nil, algoVersion: String? = nil)
+ case share_link(linkId: String, shareTarget: String)
+ case session_end(duration: Int)
+
+ /// 이벤트명 (rawValue)
+ public var eventName: String {
+ switch self {
+ case .app_open: return "app_open"
+ case .view_splash: return "view_splash"
+ case .login_start: return "login_start"
+ case .login_complete: return "login_complete"
+ case .interest_select: return "interest_select"
+ case .onboarding_complete: return "onboarding_complete"
+ case .view_home_pokit: return "view_home_pokit"
+ case .view_home_recommend: return "view_home_recommend"
+ case .add_folder: return "add_folder"
+ case .add_link: return "add_link"
+ case .view_folder_detail: return "view_folder_detail"
+ case .view_link_detail: return "view_link_detail"
+ case .share_link: return "share_link"
+ case .session_end: return "session_end"
+ }
+ }
+
+ /// Amplitude에 전송할 속성값
+ public var properties: [String: Any] {
+ switch self {
+ case let .app_open(deviceOS, appVersion):
+ return [
+ PropertyKey.device_os.rawValue: deviceOS,
+ PropertyKey.app_version.rawValue: appVersion
+ ]
+
+ case .view_splash:
+ return [:]
+
+ case let .login_start(method):
+ return [PropertyKey.method.rawValue: method.rawValue]
+
+ case let .login_complete(method):
+ return [PropertyKey.method.rawValue: method.rawValue]
+
+ case let .interest_select(interests):
+ return [PropertyKey.interests.rawValue: interests]
+
+ case .onboarding_complete:
+ return [:]
+
+ case let .view_home_pokit(entryPoint):
+ return [PropertyKey.entry_point.rawValue: entryPoint]
+
+ case let .view_home_recommend(entryPoint):
+ return [PropertyKey.entry_point.rawValue: entryPoint]
+
+ case let .add_folder(folderName):
+ return [PropertyKey.folder_name.rawValue: folderName]
+
+ case let .add_link(folderId, linkDomain, entryPoint, linkId, positionIndex, algoVersion):
+ var props: [String: Any] = [
+ PropertyKey.folder_id.rawValue: folderId,
+ PropertyKey.link_domain.rawValue: linkDomain
+ ]
+ if let entryPoint = entryPoint {
+ props[PropertyKey.entry_point.rawValue] = entryPoint
+ }
+ if let linkId = linkId {
+ props[PropertyKey.link_id.rawValue] = linkId
+ }
+ if let positionIndex = positionIndex {
+ props[PropertyKey.position_index.rawValue] = positionIndex
+ }
+ if let algoVersion = algoVersion {
+ props[PropertyKey.algo_version.rawValue] = algoVersion
+ }
+ return props
+
+ case let .view_folder_detail(folderId):
+ return [PropertyKey.folder_id.rawValue: folderId]
+
+ case let .view_link_detail(linkId, linkDomain, entryPoint, positionIndex, cardType, algoVersion):
+ var props: [String: Any] = [
+ PropertyKey.link_id.rawValue: linkId,
+ PropertyKey.link_domain.rawValue: linkDomain
+ ]
+ if let entryPoint = entryPoint {
+ props[PropertyKey.entry_point.rawValue] = entryPoint
+ }
+ if let positionIndex = positionIndex {
+ props[PropertyKey.position_index.rawValue] = positionIndex
+ }
+ if let cardType = cardType {
+ props[PropertyKey.card_type.rawValue] = cardType
+ }
+ if let algoVersion = algoVersion {
+ props[PropertyKey.algo_version.rawValue] = algoVersion
+ }
+ return props
+
+ case let .share_link(linkId, shareTarget):
+ return [
+ PropertyKey.link_id.rawValue: linkId,
+ PropertyKey.share_target.rawValue: shareTarget
+ ]
+
+ case let .session_end(duration):
+ return [PropertyKey.duration.rawValue: duration]
+ }
+ }
+}
+
+/// Analytics 속성 키
+public enum PropertyKey: String {
+ case device_os
+ case app_version
+ case method
+ case interests
+ case entry_point
+ case folder_name
+ case folder_id
+ case link_domain
+ case link_id
+ case share_target
+ case duration
+ case position_index
+ case card_type
+ case algo_version
+}
+
+/// 로그인 방식
+public enum LoginMethod: String {
+ case apple = "Apple"
+ case google = "Google"
+ case kakao = "Kakao"
+}
+
+extension LoginMethod {
+ init?(authPlatform: String) {
+ switch authPlatform.lowercased() {
+ case "apple":
+ self = .apple
+ case "google":
+ self = .google
+ case "kakao":
+ self = .kakao
+ default:
+ return nil
+ }
+ }
+}
diff --git a/Projects/CoreKit/Sources/Core/Analytics/DependencyValues+Analytics.swift b/Projects/CoreKit/Sources/Core/Analytics/DependencyValues+Analytics.swift
new file mode 100644
index 00000000..034ac5b6
--- /dev/null
+++ b/Projects/CoreKit/Sources/Core/Analytics/DependencyValues+Analytics.swift
@@ -0,0 +1,51 @@
+import Dependencies
+import Foundation
+
+/// Amplitude Client Protocol
+public struct AmplitudeClient {
+ public var initialize: @Sendable (String, String?) -> Void
+ public var track: @Sendable (AnalyticsEvent) -> Void
+ public var setUserId: @Sendable (String) -> Void
+ public var setUserProperties: @Sendable ([String: Any]) -> Void
+ public var flush: @Sendable () -> Void
+ public var reset: @Sendable () -> Void
+}
+
+public extension DependencyValues {
+ var amplitude: AmplitudeClient {
+ get { self[AmplitudeClientKey.self] }
+ set { self[AmplitudeClientKey.self] = newValue }
+ }
+}
+
+private enum AmplitudeClientKey: DependencyKey {
+ static let liveValue = AmplitudeClient(
+ initialize: { apiKey, userId in
+ AmplitudeManager.shared.initialize(apiKey: apiKey, userId: userId)
+ },
+ track: { event in
+ AmplitudeManager.shared.track(event)
+ },
+ setUserId: { userId in
+ AmplitudeManager.shared.setUserId(userId)
+ },
+ setUserProperties: { properties in
+ AmplitudeManager.shared.setUserProperties(properties)
+ },
+ flush: {
+ AmplitudeManager.shared.flush()
+ },
+ reset: {
+ AmplitudeManager.shared.reset()
+ }
+ )
+
+ static let testValue = AmplitudeClient(
+ initialize: { _, _ in },
+ track: { _ in },
+ setUserId: { _ in },
+ setUserProperties: { _ in },
+ flush: { },
+ reset: { }
+ )
+}
diff --git a/Projects/CoreKit/Sources/Data/Network/Category/CategoryClient+LiveKey.swift b/Projects/CoreKit/Sources/Data/Network/Category/CategoryClient+LiveKey.swift
index 05fc56e0..d6fe994a 100644
--- a/Projects/CoreKit/Sources/Data/Network/Category/CategoryClient+LiveKey.swift
+++ b/Projects/CoreKit/Sources/Data/Network/Category/CategoryClient+LiveKey.swift
@@ -12,6 +12,7 @@ import Moya
extension CategoryClient: DependencyKey {
public static let liveValue: Self = {
+ @Dependency(\.amplitude.track) var amplitudeTrack
let provider = MoyaProvider.build()
return Self(
@@ -31,7 +32,10 @@ extension CategoryClient: DependencyKey {
)
},
카테고리_생성: { model in
- try await provider.request(.카테고리생성(model: model))
+ let response: CategoryEditResponse
+ response = try await provider.request(.카테고리생성(model: model))
+ amplitudeTrack(.add_folder(folderName: response.categoryName))
+ return response
},
카테고리_프로필_목록_조회: {
try await provider.request(.카테고리_프로필_목록_조회)
@@ -40,7 +44,8 @@ extension CategoryClient: DependencyKey {
try await provider.request(.유저_카테고리_개수_조회)
},
카테고리_상세_조회: { id in
- try await provider.request(.카테고리_상세_조회(categoryId: id))
+ amplitudeTrack(.view_folder_detail(folderId: id))
+ return try await provider.request(.카테고리_상세_조회(categoryId: id))
},
공유받은_카테고리_조회: { id, model in
try await provider.request(.공유받은_카테고리_조회(categoryId: id, model: model))
diff --git a/Projects/CoreKit/Sources/Data/Network/User/UserClient+LiveKey.swift b/Projects/CoreKit/Sources/Data/Network/User/UserClient+LiveKey.swift
index 900ec4d1..415be9bb 100644
--- a/Projects/CoreKit/Sources/Data/Network/User/UserClient+LiveKey.swift
+++ b/Projects/CoreKit/Sources/Data/Network/User/UserClient+LiveKey.swift
@@ -12,6 +12,8 @@ import Moya
extension UserClient: DependencyKey {
public static let liveValue: Self = {
+ @Dependency(\.amplitude.setUserProperties)
+ var amplitudeSetUserProperties
let provider = MoyaProvider.build()
return Self(
@@ -22,7 +24,10 @@ extension UserClient: DependencyKey {
try await provider.request(.닉네임_수정(model: model))
},
회원등록: { model in
- try await provider.request(.회원등록(model: model))
+ let response: BaseUserResponse
+ response = try await provider.request(.회원등록(model: model))
+ amplitudeSetUserProperties(["userId": response.id])
+ return response
},
닉네임_중복_체크: { nickname in
try await provider.request(.닉네임_중복_체크(nickname: nickname))
diff --git a/Projects/DSKit/Sources/Components/PokitCaution.swift b/Projects/DSKit/Sources/Components/PokitCaution.swift
index e2ced148..82baabdd 100644
--- a/Projects/DSKit/Sources/Components/PokitCaution.swift
+++ b/Projects/DSKit/Sources/Components/PokitCaution.swift
@@ -84,9 +84,14 @@ public struct PokitCaution: View {
private let type: CautionType
private let action: (() -> Void)?
- public init(
+ public init(type: CautionType) {
+ self.type = type
+ self.action = nil
+ }
+
+ private init(
type: CautionType,
- action: (() -> Void)? = nil
+ action: (() -> Void)?
) {
self.type = type
self.action = action
@@ -132,11 +137,15 @@ public struct PokitCaution: View {
.frame(maxHeight: .infinity)
.padding(.bottom, 92)
}
+
+ public func onAction(_ action: @escaping () -> Void) -> Self {
+ PokitCaution(type: self.type, action: action)
+ }
}
#Preview {
- PokitCaution(
- type: .미분류_링크없음,
- action: {}
- )
+ PokitCaution(type: .미분류_링크없음)
+ .onAction {
+
+ }
}
diff --git a/Projects/DSKit/Sources/Components/PokitLinkPopup.swift b/Projects/DSKit/Sources/Components/PokitLinkPopup.swift
index 866acb2d..84057cc3 100644
--- a/Projects/DSKit/Sources/Components/PokitLinkPopup.swift
+++ b/Projects/DSKit/Sources/Components/PokitLinkPopup.swift
@@ -15,17 +15,36 @@ public struct PokitLinkPopup: View {
@State
private var second: Int = 0
private let action: (() -> Void)?
+ private let until: Int
private let timer = Timer.publish(
every: 1,
on: .main,
in: .common
).autoconnect()
- public init(
+ public init(type: Binding) {
+ self._type = type
+ switch type.wrappedValue {
+ case let .link(_, _, until),
+ let .text(_, until),
+ let .success(_, until),
+ let .error(_, until),
+ let .warning(_, until),
+ let .report(_, until):
+ self.until = until
+ default:
+ self.until = 2
+ }
+ self.action = nil
+ }
+
+ private init(
type: Binding,
- action: (() -> Void)? = nil
+ until: Int,
+ action: (() -> Void)?
) {
self._type = type
+ self.until = until
self.action = action
}
@@ -40,7 +59,7 @@ public struct PokitLinkPopup: View {
.frame(width: 335, height: 60)
.transition(.move(edge: .bottom).combined(with: .opacity))
.onReceive(timer) { _ in
- guard second < 2 else {
+ guard second < until else {
closedPopup()
return
}
@@ -61,7 +80,7 @@ public struct PokitLinkPopup: View {
.multilineTextAlignment(.leading)
.foregroundStyle(textColor)
- if case let .link(_, url) = type {
+ if case let .link(_, url, _) = type {
Text(url)
.lineLimit(1)
.pokitFont(.detail2)
@@ -167,26 +186,34 @@ public struct PokitLinkPopup: View {
private var title: String {
switch type {
- case let .link(title, _),
- let .text(title),
- let .success(title),
- let .error(title),
- let .warning(title),
- let .report(title):
+ case let .link(title, _, _),
+ let .text(title, _),
+ let .success(title, _),
+ let .error(title, _),
+ let .warning(title, _),
+ let .report(title, _):
return title
default: return ""
}
}
+
+ public func onAction(_ action: @escaping () -> Void) -> Self {
+ PokitLinkPopup(
+ type: self.$type,
+ until: self.until,
+ action: action
+ )
+ }
}
public extension PokitLinkPopup {
enum PopupType: Equatable {
- case link(title: String, url: String)
- case text(title: String)
- case success(title: String)
- case error(title: String)
- case warning(title: String)
- case report(title: String)
+ case link(title: String, url: String, until: Int = 2)
+ case text(title: String, until: Int = 2)
+ case success(title: String, until: Int = 2)
+ case error(title: String, until: Int = 2)
+ case warning(title: String, until: Int = 2)
+ case report(title: String, until: Int = 2)
}
}
diff --git a/Projects/DSKit/Sources/Components/PokitProfileBottomSheet.swift b/Projects/DSKit/Sources/Components/PokitProfileBottomSheet.swift
index 8a3df705..55f76f00 100644
--- a/Projects/DSKit/Sources/Components/PokitProfileBottomSheet.swift
+++ b/Projects/DSKit/Sources/Components/PokitProfileBottomSheet.swift
@@ -44,17 +44,21 @@ public extension PokitProfileBottomSheet {
transaction: .init(animation: .pokitDissolve)
) { phase in
if let image = phase.image {
+ let isSelected = item.imageURL == selectedImage?.imageURL
+
Button(action: { delegateSend?(.이미지_선택했을때(item)) }) {
image
.resizable()
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
.buttonStyle(.plain)
- .overlay {
- if let selectedImage, item.imageURL == selectedImage.imageURL {
- RoundedRectangle(cornerRadius: 12, style: .continuous)
- .stroke(.pokit(.border(.brand)), lineWidth: 2)
- }
+ .overlay(if: isSelected) {
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
+ .stroke(.pokit(.border(.brand)), lineWidth: 2)
+ }
+ .overlay(if: !isSelected && item.id == 33) {
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
+ .stroke(.pokit(.border(.tertiary)), lineWidth: 2)
}
} else {
PokitSpinner()
diff --git a/Projects/DSKit/Sources/Modifiers/PokitPresentationCornerRadiusModifier.swift b/Projects/DSKit/Sources/Modifiers/PokitPresentationCornerRadiusModifier.swift
index ad650d98..eca60334 100644
--- a/Projects/DSKit/Sources/Modifiers/PokitPresentationCornerRadiusModifier.swift
+++ b/Projects/DSKit/Sources/Modifiers/PokitPresentationCornerRadiusModifier.swift
@@ -11,7 +11,9 @@ struct PokitPresentationCornerRadiusModifier: ViewModifier {
init() { }
func body(content: Content) -> some View {
- if #available(iOS 16.4, *) {
+ if #available(iOS 26.0, *) {
+ content
+ } else if #available(iOS 16.4, *) {
content
.presentationCornerRadius(20)
} else {
diff --git a/Projects/Domain/Sources/Base/BaseContentItem.swift b/Projects/Domain/Sources/Base/BaseContentItem.swift
index 27ebd387..bddcb66d 100644
--- a/Projects/Domain/Sources/Base/BaseContentItem.swift
+++ b/Projects/Domain/Sources/Base/BaseContentItem.swift
@@ -19,7 +19,7 @@ public struct BaseContentItem: Identifiable, Equatable, PokitLinkCardItem, Sorta
public let data: String
public let domain: String
public let createdAt: String
- public let isRead: Bool?
+ public var isRead: Bool?
public var isFavorite: Bool?
public let keyword: String?
diff --git a/Projects/Feature/FeatureCategoryDetail/Sources/CategoryDetailFeature.swift b/Projects/Feature/FeatureCategoryDetail/Sources/CategoryDetailFeature.swift
index 0425f105..ba1239d5 100644
--- a/Projects/Feature/FeatureCategoryDetail/Sources/CategoryDetailFeature.swift
+++ b/Projects/Feature/FeatureCategoryDetail/Sources/CategoryDetailFeature.swift
@@ -26,6 +26,9 @@ public struct CategoryDetailFeature {
private var contentClient
@Dependency(KakaoShareClient.self)
private var kakaoShareClient
+ @Dependency(\.amplitude.track)
+ private var amplitudeTrack
+
/// - State
@ObservableState
public struct State: Equatable {
@@ -65,9 +68,6 @@ public struct CategoryDetailFeature {
domain.contentList.hasNext
}
var isLoading: Bool = true
- var isContentsNotEmpty: Bool {
- (isFavoriteCategory && contents.contains { $0.content.isFavorite == true }) || (!isFavoriteCategory && !contents.isEmpty)
- }
public init(category: BaseCategoryItem) {
self.domain = .init(categpry: category)
@@ -199,11 +199,14 @@ private extension CategoryDetailFeature {
)
case let .분류_버튼_눌렀을때(type):
- if type == .즐겨찾기 {
+ switch type {
+ case .즐겨찾기:
state.domain.condition.isFavoriteFlitered.toggle()
+ guard state.domain.condition.isFavoriteFlitered else { break }
state.domain.condition.isUnreadFlitered = !state.domain.condition.isFavoriteFlitered
- } else {
+ case .안읽음:
state.domain.condition.isUnreadFlitered.toggle()
+ guard state.domain.condition.isUnreadFlitered else { break }
state.domain.condition.isFavoriteFlitered = !state.domain.condition.isUnreadFlitered
}
return .concatenate(
@@ -212,6 +215,10 @@ private extension CategoryDetailFeature {
)
case .공유_버튼_눌렀을때:
+ amplitudeTrack(.share_link(
+ linkId: "\(state.domain.category.id)",
+ shareTarget: "kakaotalk"
+ ))
kakaoShareClient.카테고리_카카오톡_공유(
CategoryKaKaoShareModel(
categoryName: state.domain.category.categoryName,
@@ -314,7 +321,7 @@ private extension CategoryDetailFeature {
case .카테고리_목록_조회_API:
return .run { send in
let request = BasePageableRequest(page: 0, size: 30, sort: ["createdAt,desc"])
- let response = try await categoryClient.카테고리_목록_조회(request, true, true).toDomain()
+ let response = try await categoryClient.카테고리_목록_조회(request, true, false).toDomain()
await send(.inner(.카테고리_목록_조회_API_반영(response)))
}
diff --git a/Projects/Feature/FeatureCategoryDetail/Sources/CategoryDetailView.swift b/Projects/Feature/FeatureCategoryDetail/Sources/CategoryDetailView.swift
index 5d0d80f6..0e66d0ff 100644
--- a/Projects/Feature/FeatureCategoryDetail/Sources/CategoryDetailView.swift
+++ b/Projects/Feature/FeatureCategoryDetail/Sources/CategoryDetailView.swift
@@ -65,7 +65,7 @@ public extension CategoryDetailView {
.padding(.top, 12)
.pokitNavigationBar { navigationBar }
.overlay(
- if: store.isContentsNotEmpty,
+ if: !store.isFavoriteCategory,
alignment: .bottomTrailing
) {
Button(action: { send(.링크_추가_버튼_눌렀을때) }) {
@@ -98,6 +98,7 @@ public extension CategoryDetailView {
action: { send(.카테고리_선택했을때($0)) }
)
.presentationDragIndicator(.visible)
+ .presentationDetents([.medium, .large])
} else {
PokitLoading()
.presentationDragIndicator(.visible)
@@ -215,30 +216,32 @@ private extension CategoryDetailView {
var filterHeader: some View {
let isFavoriteCategory = store.isFavoriteCategory
let favoriteContentsCount = store.contents.filter { $0.content.isFavorite ?? false }.count
- if store.isContentsNotEmpty {
- HStack(spacing: isFavoriteCategory ? 2 : 8) {
- if isFavoriteCategory {
- Image(.icon(.link))
- .resizable()
- .frame(width: 16, height: 16)
- .foregroundStyle(.pokit(.icon(.secondary)))
- Text("\(favoriteContentsCount)개")
- .foregroundStyle(.pokit(.text(.tertiary)))
- .pokitFont(.b2(.m))
- } else {
- favoriteButton
-
- unreadButton
- }
+
+ HStack(spacing: isFavoriteCategory ? 2 : 8) {
+ if isFavoriteCategory {
+ Image(.icon(.link))
+ .resizable()
+ .frame(width: 16, height: 16)
+ .foregroundStyle(.pokit(.icon(.secondary)))
- Spacer()
- PokitIconLTextLink(
- store.sortType.title,
- icon: .icon(.align),
- action: { send(.정렬_버튼_눌렀을때) }
- )
- .contentTransition(.numericText())
+ Text("\(favoriteContentsCount)개")
+ .foregroundStyle(.pokit(.text(.tertiary)))
+ .pokitFont(.b2(.m))
+
+ } else {
+ favoriteButton
+
+ unreadButton
}
+
+ Spacer()
+
+ PokitIconLTextLink(
+ store.sortType.title,
+ icon: .icon(.align),
+ action: { send(.정렬_버튼_눌렀을때) }
+ )
+ .contentTransition(.numericText())
}
}
@@ -286,53 +289,47 @@ private extension CategoryDetailView {
}
}
+ @ViewBuilder
var contentScrollView: some View {
- Group {
- if !store.isLoading {
- if store.contents.isEmpty {
- PokitCaution(
- type: .포킷상세_링크없음,
- action: { send(.링크_추가_버튼_눌렀을때) }
- )
- } else {
- LazyVStack(spacing: 0) {
- ForEach(
- Array(store.scope(state: \.contents, action: \.contents))
- ) { store in
- let isFirst = store.state.id == self.store.contents.first?.id
- let isLast = store.state.id == self.store.contents.last?.id
-
- if !self.store.isFavoriteCategory {
- ContentCardView(
- store: store,
- type: .linkList,
- isFirst: isFirst,
- isLast: isLast
- )
- } else {
- if store.content.isFavorite == true {
- ContentCardView(
- store: store,
- type: .linkList,
- isFirst: isFirst,
- isLast: isLast
- )
- }
- }
- }
+ if !store.isLoading {
+ if store.contents.isEmpty {
+ PokitCaution(type: .포킷상세_링크없음)
+ } else {
+ LazyVStack(spacing: 0) {
+ ForEach(
+ Array(store.scope(state: \.contents, action: \.contents))
+ ) { store in
+ let isFirst = store.state.id == self.store.contents.first?.id
+ let isLast = store.state.id == self.store.contents.last?.id
- if store.hasNext {
- PokitLoading()
- .task { await send(.pagenation).finish() }
+ if !self.store.isFavoriteCategory {
+ ContentCardView(
+ store: store,
+ type: .linkList,
+ isFirst: isFirst,
+ isLast: isLast
+ )
+ } else if store.content.isFavorite == true {
+ ContentCardView(
+ store: store,
+ type: .linkList,
+ isFirst: isFirst,
+ isLast: isLast
+ )
}
-
- Spacer()
}
- .padding(.bottom, 36)
+
+ if store.hasNext {
+ PokitLoading()
+ .task { await send(.pagenation).finish() }
+ }
+
+ Spacer()
}
- } else {
- PokitLoading()
+ .padding(.bottom, 36)
}
+ } else {
+ PokitLoading()
}
}
diff --git a/Projects/Feature/FeatureContentCard/Sources/ContentCard/ContentCardFeature.swift b/Projects/Feature/FeatureContentCard/Sources/ContentCard/ContentCardFeature.swift
index d5bfae8c..fe5c081a 100644
--- a/Projects/Feature/FeatureContentCard/Sources/ContentCard/ContentCardFeature.swift
+++ b/Projects/Feature/FeatureContentCard/Sources/ContentCard/ContentCardFeature.swift
@@ -21,6 +21,9 @@ public struct ContentCardFeature {
private var openURL
@Dependency(ContentClient.self)
private var contentClient
+ @Dependency(\.amplitude.track)
+ private var amplitudeTrack
+
/// - State
@ObservableState
public struct State: Equatable, Identifiable {
@@ -52,6 +55,7 @@ public struct ContentCardFeature {
public enum InnerAction: Equatable {
case 메타데이터_조회_수행_반영(String)
case 즐겨찾기_API_반영(Bool)
+ case 컨텐츠_상세_조회_API_반영
}
@CasePathable
@@ -60,6 +64,7 @@ public struct ContentCardFeature {
case 즐겨찾기_API
case 즐겨찾기_취소_API
case 썸네일_수정_API
+ case 컨텐츠_상세_조회_API
}
public enum ScopeAction: Equatable { case doNothing }
@@ -111,7 +116,14 @@ private extension ContentCardFeature {
guard let url = URL(string: state.content.data) else {
return .none
}
- return .run { _ in await openURL(url) }
+ return .run { [content = state.content] send in
+ amplitudeTrack(.view_link_detail(
+ linkId: "\(content.id)",
+ linkDomain: content.data
+ ))
+ await send(.async(.컨텐츠_상세_조회_API))
+ await openURL(url)
+ }
case .컨텐츠_항목_케밥_버튼_눌렀을때:
return .send(.delegate(.컨텐츠_항목_케밥_버튼_눌렀을때(content: state.content)))
case .메타데이터_조회:
@@ -137,6 +149,9 @@ private extension ContentCardFeature {
case .즐겨찾기_API_반영(let favorite):
state.content.isFavorite = favorite
return .none
+ case .컨텐츠_상세_조회_API_반영:
+ state.content.isRead = true
+ return .none
}
}
@@ -167,6 +182,11 @@ private extension ContentCardFeature {
try await contentClient.썸네일_수정("\(content.id)", request)
}
+ case .컨텐츠_상세_조회_API:
+ return .run { [id = state.content.id] send in
+ let _ = try await contentClient.컨텐츠_상세_조회("\(id)")
+ await send(.inner(.컨텐츠_상세_조회_API_반영))
+ }
}
}
diff --git a/Projects/Feature/FeatureContentSetting/Sources/ContentSetting/ContentSettingFeature.swift b/Projects/Feature/FeatureContentSetting/Sources/ContentSetting/ContentSettingFeature.swift
index a10909a1..7b0bc337 100644
--- a/Projects/Feature/FeatureContentSetting/Sources/ContentSetting/ContentSettingFeature.swift
+++ b/Projects/Feature/FeatureContentSetting/Sources/ContentSetting/ContentSettingFeature.swift
@@ -28,6 +28,9 @@ public struct ContentSettingFeature {
private var categoryClient
@Dependency(KeyboardClient.self)
private var keyboardClient
+ @Dependency(\.amplitude.track)
+ private var amplitudeTrack
+
/// - State
@ObservableState
public struct State: Equatable {
@@ -441,7 +444,11 @@ private extension ContentSettingFeature {
thumbNail: state.domain.thumbNail
)
return .run { send in
- let content = try await contentClient.컨텐츠_추가(request)
+ let response = try await contentClient.컨텐츠_추가(request)
+ amplitudeTrack(.add_link(
+ folderId: "\(response.contentId)",
+ linkDomain: response.data
+ ))
await send(.inner(.선택한_포킷_인메모리_삭제))
await send(.delegate(.저장하기_완료(category: category)))
} catch: { error, send in
diff --git a/Projects/Feature/FeatureContentSetting/Sources/ContentSetting/ContentSettingView.swift b/Projects/Feature/FeatureContentSetting/Sources/ContentSetting/ContentSettingView.swift
index e8d33455..76c032e9 100644
--- a/Projects/Feature/FeatureContentSetting/Sources/ContentSetting/ContentSettingView.swift
+++ b/Projects/Feature/FeatureContentSetting/Sources/ContentSetting/ContentSettingView.swift
@@ -46,10 +46,9 @@ public extension ContentSettingView {
}
.overlay(alignment: .bottom) {
if store.linkPopup != nil {
- PokitLinkPopup(
- type: $store.linkPopup,
- action: { send(.링크팝업_버튼_눌렀을때, animation: .pokitSpring) }
- )
+ PokitLinkPopup(type: $store.linkPopup)
+ .onAction { send(.링크팝업_버튼_눌렀을때, animation: .pokitSpring) }
+
}
}
.pokitMaxWidth()
diff --git a/Projects/Feature/FeatureIntro/Sources/Splash/SplashFeature.swift b/Projects/Feature/FeatureIntro/Sources/Splash/SplashFeature.swift
index 3f3cdcdd..c06021fa 100644
--- a/Projects/Feature/FeatureIntro/Sources/Splash/SplashFeature.swift
+++ b/Projects/Feature/FeatureIntro/Sources/Splash/SplashFeature.swift
@@ -27,6 +27,11 @@ public struct SplashFeature {
var keychain
@Dependency(VersionClient.self)
var versionClient
+ @Dependency(\.amplitude)
+ private var amplitude
+ @Dependency(UserClient.self)
+ var userClient
+
/// - State
@ObservableState
public struct State {
@@ -103,6 +108,7 @@ private extension SplashFeature {
case .onAppear:
return .run { [isNeedSessionDeleted = state.isNeedSessionDeleted] send in
+ amplitude.track(.view_splash)
try await self.clock.sleep(for: .milliseconds(2000))
/// Version Check
let response = try await versionClient.버전체크().toDomain()
@@ -173,6 +179,10 @@ private extension SplashFeature {
let tokenRequest = ReissueRequest(refreshToken: refreshToken)
let tokenResponse = try await authClient.토큰재발급(tokenRequest)
keychain.save(.accessToken, tokenResponse.accessToken)
+
+ let user = try await userClient.닉네임_조회()
+ amplitude.setUserProperties(["userId": user.id])
+
await send(.delegate(.autoLoginSuccess))
} catch {
await send(.delegate(.loginNeeded), animation: .smooth)
diff --git a/Projects/Feature/FeatureLogin/Sources/Login/LoginFeature.swift b/Projects/Feature/FeatureLogin/Sources/Login/LoginFeature.swift
index edc5e139..c7cec094 100644
--- a/Projects/Feature/FeatureLogin/Sources/Login/LoginFeature.swift
+++ b/Projects/Feature/FeatureLogin/Sources/Login/LoginFeature.swift
@@ -23,6 +23,8 @@ public struct LoginFeature {
var userDefaults
@Dependency(KeychainClient.self)
var keychain
+ @Dependency(\.amplitude)
+ private var amplitude
/// - State
@ObservableState
public struct State {
@@ -108,8 +110,10 @@ private extension LoginFeature {
func handleViewAction(_ action: Action.View, state: inout State) -> Effect {
switch action {
case .애플로그인_버튼_눌렀을때:
+ amplitude.track(.login_start(method: .apple))
return .send(.async(.애플로그인_소셜_API))
case .구글로그인_버튼_눌렀을때:
+ amplitude.track(.login_start(method: .google))
return .send(.async(.구글로그인_소셜_API))
}
}
@@ -169,6 +173,10 @@ private extension LoginFeature {
let appleTokenRequest = AppleTokenRequest(authCode: authCode, jwt: jwt)
let appleTokenResponse = try await authClient.apple(appleTokenRequest)
keychain.save(.serverRefresh, appleTokenResponse.refresh_token)
+ amplitude.track(.login_complete(method: .apple))
+
+ let user = try await userClient.닉네임_조회()
+ amplitude.setUserProperties(["userId": user.id])
await send(.inner(.로그인_이후_화면이동(isRegistered: tokenResponse.isRegistered)))
}
@@ -185,6 +193,10 @@ private extension LoginFeature {
keychain.save(.accessToken, tokenResponse.accessToken)
keychain.save(.refreshToken, tokenResponse.refreshToken)
keychain.save(.serverRefresh, response.serverRefreshToken)
+ amplitude.track(.login_complete(method: .google))
+
+ let user = try await userClient.닉네임_조회()
+ amplitude.setUserProperties(["userId": user.id])
await send(.inner(.로그인_이후_화면이동(isRegistered: tokenResponse.isRegistered)))
}
diff --git a/Projects/Feature/FeatureLogin/Sources/SelectField/SelectFieldFeature.swift b/Projects/Feature/FeatureLogin/Sources/SelectField/SelectFieldFeature.swift
index 38771fd6..fa1e5e6f 100644
--- a/Projects/Feature/FeatureLogin/Sources/SelectField/SelectFieldFeature.swift
+++ b/Projects/Feature/FeatureLogin/Sources/SelectField/SelectFieldFeature.swift
@@ -14,6 +14,8 @@ public struct SelectFieldFeature {
/// - Dependency
@Dependency(\.dismiss) var dismiss
@Dependency(UserClient.self) var userClient
+ @Dependency(\.amplitude.track)
+ private var amplitudeTrack
/// - State
@ObservableState
public struct State: Equatable {
@@ -93,6 +95,7 @@ private extension SelectFieldFeature {
}
case .nextButtonTapped:
let interests = Array(state.selectedFields)
+ amplitudeTrack(.interest_select(interests: interests))
return .send(.delegate(.pushSignUpDoneView(interests: interests)))
case .backButtonTapped:
return .run { _ in await self.dismiss() }
diff --git a/Projects/Feature/FeatureLogin/Sources/SignUpDone/SignUpDoneFeature.swift b/Projects/Feature/FeatureLogin/Sources/SignUpDone/SignUpDoneFeature.swift
index 4a9a50ff..b0429803 100644
--- a/Projects/Feature/FeatureLogin/Sources/SignUpDone/SignUpDoneFeature.swift
+++ b/Projects/Feature/FeatureLogin/Sources/SignUpDone/SignUpDoneFeature.swift
@@ -14,6 +14,8 @@ import Util
public struct SignUpDoneFeature {
/// - Dependency
@Dependency(\.dismiss) var dismiss
+ @Dependency(\.amplitude.track)
+ private var amplitudeTrack
/// - State
@ObservableState
public struct State: Equatable {
@@ -41,6 +43,7 @@ public struct SignUpDoneFeature {
case 제목_나타났을때
case 폭죽_이미지_나타났을때
case 푸키_이미지_나타났을때
+ case 뷰가_나타났을때
}
public enum InnerAction: Equatable { case 없음 }
public enum AsyncAction: Equatable { case 없음 }
@@ -95,6 +98,9 @@ private extension SignUpDoneFeature {
case .푸키_이미지_나타났을때:
state.pookiIsAppear = true
return .none
+ case .뷰가_나타났을때:
+ amplitudeTrack(.onboarding_complete)
+ return .none
}
}
/// - Inner Effect
diff --git a/Projects/Feature/FeatureLogin/Sources/SignUpDone/SignUpDoneView.swift b/Projects/Feature/FeatureLogin/Sources/SignUpDone/SignUpDoneView.swift
index 018e4360..8c47530d 100644
--- a/Projects/Feature/FeatureLogin/Sources/SignUpDone/SignUpDoneView.swift
+++ b/Projects/Feature/FeatureLogin/Sources/SignUpDone/SignUpDoneView.swift
@@ -49,6 +49,7 @@ public extension SignUpDoneView {
}
.ignoresSafeArea(edges: .bottom)
.navigationBarBackButtonHidden()
+ .onAppear { send(.뷰가_나타났을때) }
}
}
}
diff --git a/Projects/Feature/FeaturePokit/Sources/PokitFavoriteCard.swift b/Projects/Feature/FeaturePokit/Sources/PokitFavoriteCard.swift
index 9d6b783e..fffbb38f 100644
--- a/Projects/Feature/FeaturePokit/Sources/PokitFavoriteCard.swift
+++ b/Projects/Feature/FeaturePokit/Sources/PokitFavoriteCard.swift
@@ -7,6 +7,10 @@
import SwiftUI
+import NukeUI
+import DSKit
+import Util
+
public struct PokitFavoriteCard: View {
private let linkCount: Int
private let action: () -> Void
@@ -76,9 +80,20 @@ public struct PokitFavoriteCard: View {
}
private var thumbNail: some View {
- Image(.character(.pooki))
- .resizable()
- .frame(width: 84, height: 84)
+ LazyImage(url: Constants.즐겨찾기_썸네일_주소) { state in
+ Group {
+ if let image = state.image {
+ image
+ .resizable()
+ } else {
+ PokitSpinner()
+ .foregroundStyle(.pokit(.icon(.brand)))
+ .frame(width: 48, height: 48)
+ }
+ }
+ .animation(.pokitDissolve, value: state.image)
+ }
+ .frame(width: 84, height: 84)
}
private var background: some View {
diff --git a/Projects/Feature/FeaturePokit/Sources/PokitLinkEditFeature.swift b/Projects/Feature/FeaturePokit/Sources/PokitLinkEditFeature.swift
index 61a291ce..2b9df4b5 100644
--- a/Projects/Feature/FeaturePokit/Sources/PokitLinkEditFeature.swift
+++ b/Projects/Feature/FeaturePokit/Sources/PokitLinkEditFeature.swift
@@ -32,7 +32,7 @@ public struct PokitLinkEditFeature {
var list = IdentifiedArrayOf()
/// 선택한 링크 목록
var selectedItems = IdentifiedArrayOf()
- var isActive: Bool = false
+ var isActive: Bool { !selectedItems.isEmpty }
/// 포킷 이동 눌렀을 때 sheet
var categorySelectSheetPresetend: Bool = false
var linkDeleteSheetPresented: Bool = false
@@ -160,8 +160,6 @@ private extension PokitLinkEditFeature {
} else {
state.selectedItems.append(item)
}
-
- state.isActive = !state.selectedItems.isEmpty
return .none
case let .카테고리_선택했을때(pokit):
@@ -257,12 +255,10 @@ private extension PokitLinkEditFeature {
case .전체선택_버튼_눌렀을때:
state.selectedItems = state.list
- state.isActive = !state.selectedItems.isEmpty
return .none
case .전체해제_버튼_눌렀을때:
state.selectedItems.removeAll()
- state.isActive = !state.selectedItems.isEmpty
return .none
case .포킷이동_버튼_눌렀을때:
diff --git a/Projects/Feature/FeaturePokit/Sources/PokitLinkEditView.swift b/Projects/Feature/FeaturePokit/Sources/PokitLinkEditView.swift
index 1c6b7e56..26307a7c 100644
--- a/Projects/Feature/FeaturePokit/Sources/PokitLinkEditView.swift
+++ b/Projects/Feature/FeaturePokit/Sources/PokitLinkEditView.swift
@@ -63,11 +63,9 @@ public extension PokitLinkEditView {
}
.overlay(alignment: .bottom) {
if store.linkPopup != nil {
- PokitLinkPopup(
- type: $store.linkPopup,
- action: { send(.링크팝업_버튼_눌렀을때, animation: .pokitSpring) }
- )
- .pokitMaxWidth()
+ PokitLinkPopup(type: $store.linkPopup)
+ .onAction { send(.링크팝업_버튼_눌렀을때, animation: .pokitSpring) }
+ .pokitMaxWidth()
}
}
/// fullScreenCover를 통해 새로운 Destination을 만들었음
@@ -123,7 +121,7 @@ private extension PokitLinkEditView {
var actionFloatButtonView: some View {
PokitLinkEditFloatView(
- isActive: $store.isActive,
+ isActive: store.isActive,
delegateSend: { store.send(.scope(.floatButtonAction($0))) }
)
}
diff --git a/Projects/Feature/FeaturePokit/Sources/PokitRootView.swift b/Projects/Feature/FeaturePokit/Sources/PokitRootView.swift
index f5771274..91e46d4f 100644
--- a/Projects/Feature/FeaturePokit/Sources/PokitRootView.swift
+++ b/Projects/Feature/FeaturePokit/Sources/PokitRootView.swift
@@ -134,10 +134,8 @@ private extension PokitRootView {
var pokitView: some View {
if let categories = store.categories {
if categories.isEmpty {
- PokitCaution(
- type: .카테고리없음,
- action: { send(.포킷추가_버튼_눌렀을때) }
- )
+ PokitCaution(type: .카테고리없음)
+ .onAction { send(.포킷추가_버튼_눌렀을때) }
} else {
pokitList(categories)
}
@@ -181,10 +179,8 @@ private extension PokitRootView {
var unclassifiedView: some View {
if !store.isLoading {
if store.contents.isEmpty {
- PokitCaution(
- type: .미분류_링크없음,
- action: { send(.링크추가_버튼_눌렀을때) }
- )
+ PokitCaution(type: .미분류_링크없음)
+ .onAction { send(.링크추가_버튼_눌렀을때) }
} else {
unclassifiedList
.padding(.top, 20)
diff --git a/Projects/Feature/FeaturePokit/Sources/Sheet/PokitLinkEditFloatView.swift b/Projects/Feature/FeaturePokit/Sources/Sheet/PokitLinkEditFloatView.swift
index 5b94da51..db0bc79b 100644
--- a/Projects/Feature/FeaturePokit/Sources/Sheet/PokitLinkEditFloatView.swift
+++ b/Projects/Feature/FeaturePokit/Sources/Sheet/PokitLinkEditFloatView.swift
@@ -11,14 +11,14 @@ import SwiftUI
public struct PokitLinkEditFloatView: View {
/// 전체 선택/해제 toggle
@State private var isChecked: Bool = false
- @Binding private var isActive: Bool
+ private let isActive: Bool
private let delegateSend: ((PokitLinkEditFloatView.Delegate) -> Void)?
public init(
- isActive: Binding,
+ isActive: Bool,
delegateSend: ((PokitLinkEditFloatView.Delegate) -> Void)?
) {
- self._isActive = isActive
+ self.isActive = isActive
self.delegateSend = delegateSend
}
@@ -114,7 +114,7 @@ public extension PokitLinkEditFloatView {
}
#Preview {
PokitLinkEditFloatView(
- isActive: .constant(true),
+ isActive: true,
delegateSend: {_ in }
).padding(20)
}
diff --git a/Projects/Feature/FeatureRecommend/Sources/Recommend/RecommendFeature.swift b/Projects/Feature/FeatureRecommend/Sources/Recommend/RecommendFeature.swift
index 9f3376db..07d83460 100644
--- a/Projects/Feature/FeatureRecommend/Sources/Recommend/RecommendFeature.swift
+++ b/Projects/Feature/FeatureRecommend/Sources/Recommend/RecommendFeature.swift
@@ -23,6 +23,9 @@ public struct RecommendFeature {
private var categoryClient
@Dependency(\.openURL)
private var openURL
+ @Dependency(\.amplitude.track)
+ private var amplitudeTrack
+
/// - State
@ObservableState
public struct State: Equatable {
@@ -87,7 +90,7 @@ public struct RecommendFeature {
case 링크_공유_완료되었을때
case 검색_버튼_눌렀을때
case 알림_버튼_눌렀을때
- case 추천_컨텐츠_눌렀을때(String)
+ case 추천_컨텐츠_눌렀을때(BaseContentItem)
case 경고시트_dismiss
case 포킷선택_항목_눌렀을때(pokit: BaseCategoryItem)
case 포킷_추가하기_버튼_눌렀을때
@@ -168,7 +171,7 @@ private extension RecommendFeature {
case .onAppear:
return .merge(
shared(.async(.추천_조회_API), state: &state),
- shared(.async(.유저_관심사_조회_API), state: &state)
+ shared(.async(.관심사_조회_API), state: &state)
)
case .pagination:
return shared(.async(.추천_조회_페이징_API), state: &state)
@@ -211,11 +214,21 @@ private extension RecommendFeature {
return .send(.delegate(.검색_버튼_눌렀을때))
case .알림_버튼_눌렀을때:
return .send(.delegate(.알림_버튼_눌렀을때))
- case let .추천_컨텐츠_눌렀을때(urlString):
- guard let url = URL(string: urlString) else { return .none }
+ case let .추천_컨텐츠_눌렀을때(content):
+ guard let url = URL(string: content.data) else { return .none }
+ let index = state.recommendedList?.index(id: content.id)
+ amplitudeTrack(.view_link_detail(
+ linkId: "\(content.id)",
+ linkDomain: content.data,
+ entryPoint: "recommend",
+ positionIndex: index,
+ cardType: "list",
+ algoVersion: "v1.2"
+ ))
return .run { _ in await openURL(url) }
case .관심사_편집_버튼_눌렀을때:
- return shared(.async(.관심사_조회_API), state: &state)
+ state.showKeywordSheet = true
+ return .none
case let .키워드_선택_버튼_눌렀을때(interests):
state.showKeywordSheet = false
state.selectedInterest = nil
@@ -255,14 +268,18 @@ private extension RecommendFeature {
state.isLoading = false
return .none
case let .유저_관심사_조회_API_반영(interests):
- state.domain.myInterests = interests
- interests.forEach { state.selectedInterestList.insert($0) }
+ state.domain.myInterests = interests.filter { interest in
+ state.interests.contains(interest)
+ }
+ interests.forEach {
+ guard state.interests.contains($0) else { return }
+ state.selectedInterestList.insert($0)
+ }
return .none
case let .관심사_조회_API_반영(interests):
state.domain.interests = interests.filter({ interest in
interest.code != "default"
})
- state.showKeywordSheet = true
return .none
case let .컨텐츠_신고_API_반영(contentId):
state.domain.contentList.data?.removeAll(where: { $0.id == contentId })
@@ -319,13 +336,20 @@ private extension RecommendFeature {
return contentListFetch(state: &state)
case .유저_관심사_조회_API:
return .run { send in
- let interests = try await userClient.유저_관심사_목록_조회().map { $0.toDomian() }
+ let interests = try await userClient.유저_관심사_목록_조회()
+ .map { $0.toDomian() }
+ .sorted { $0.description < $1.description }
+
await send(.inner(.유저_관심사_조회_API_반영(interests)))
}
case .관심사_조회_API:
return .run { send in
- let interests = try await userClient.관심사_목록_조회().map { $0.toDomian() }
+ let interests = try await userClient.관심사_목록_조회()
+ .map { $0.toDomian() }
+ .sorted { $0.description < $1.description }
+
await send(.inner(.관심사_조회_API_반영(interests)))
+ await send(.async(.유저_관심사_조회_API))
}
case let .컨텐츠_신고_API(contentId):
return .run { send in
@@ -343,12 +367,8 @@ private extension RecommendFeature {
)
return categoryListFetch(request: request)
case .컨텐츠_추가_API:
- guard
- let categoryId = state.selectedPokit?.id,
- let category = state.domain.categoryListInQuiry.data?.first(where: {
- $0.id == categoryId
- }),
- let content = state.addContent
+ guard let categoryId = state.selectedPokit?.id,
+ let content = state.addContent
else { return .none }
let request = ContentBaseRequest(
data: content.data,
@@ -358,8 +378,17 @@ private extension RecommendFeature {
alertYn: "NO",
thumbNail: content.thumbNail
)
+ let index = state.recommendedList?.index(id: content.id)
return .run { send in
- let content = try await contentClient.컨텐츠_추가(request)
+ let response = try await contentClient.컨텐츠_추가(request)
+ amplitudeTrack(.add_link(
+ folderId: "\(categoryId)",
+ linkDomain: content.data,
+ entryPoint: "recommend",
+ linkId: "\(response.contentId)",
+ positionIndex: index,
+ algoVersion: "v1.2"
+ ))
await send(.delegate(.저장하기_완료))
}
}
diff --git a/Projects/Feature/FeatureRecommend/Sources/Recommend/RecommendView.swift b/Projects/Feature/FeatureRecommend/Sources/Recommend/RecommendView.swift
index 509a264e..19a13ddc 100644
--- a/Projects/Feature/FeatureRecommend/Sources/Recommend/RecommendView.swift
+++ b/Projects/Feature/FeatureRecommend/Sources/Recommend/RecommendView.swift
@@ -196,7 +196,7 @@ private extension RecommendView {
@ViewBuilder
func recommendedCard(_ content: BaseContentItem) -> some View {
- Button(action: { send(.추천_컨텐츠_눌렀을때(content.data)) }) {
+ Button(action: { send(.추천_컨텐츠_눌렀을때(content)) }) {
recomendedCardLabel(content)
}
}
diff --git a/Projects/Feature/FeatureSetting/Sources/Alert/PokitAlertBoxView.swift b/Projects/Feature/FeatureSetting/Sources/Alert/PokitAlertBoxView.swift
index f0e1e0ce..d658ebca 100644
--- a/Projects/Feature/FeatureSetting/Sources/Alert/PokitAlertBoxView.swift
+++ b/Projects/Feature/FeatureSetting/Sources/Alert/PokitAlertBoxView.swift
@@ -30,7 +30,7 @@ public extension PokitAlertBoxView {
if alertContents.isEmpty {
VStack {
PokitCaution(type: .알림없음)
- .padding(.top, 84)
+ .padding(.top, 84)
Spacer()
}
} else {
diff --git a/Projects/Feature/FeatureSetting/Sources/Search/PokitSearchView.swift b/Projects/Feature/FeatureSetting/Sources/Search/PokitSearchView.swift
index 6a819894..2225fda2 100644
--- a/Projects/Feature/FeatureSetting/Sources/Search/PokitSearchView.swift
+++ b/Projects/Feature/FeatureSetting/Sources/Search/PokitSearchView.swift
@@ -265,30 +265,21 @@ private extension PokitSearchView {
var resultList: some View {
VStack(alignment: .leading, spacing: 20) {
+ if store.isSearching {
+ PokitIconLTextLink(
+ store.isResultAscending ? "오래된순" : "최신순",
+ icon: .icon(.align),
+ action: { send(.정렬_버튼_눌렀을때) }
+ )
+ .contentTransition(.numericText())
+ .padding(.horizontal, 20)
+ }
+
if !store.isLoading {
- ScrollView {
- LazyVStack(spacing: 0) {
- ForEach(
- Array(store.scope(state: \.contents, action: \.contents))
- ) { store in
- let isFirst = store.state.id == self.store.contents.first?.id
- let isLast = store.state.id == self.store.contents.last?.id
-
- ContentCardView(
- store: store,
- type: .linkList,
- isFirst: isFirst,
- isLast: isLast
- )
- }
-
- if store.hasNext {
- PokitLoading()
- .task { await send(.로딩중일때, animation: .pokitDissolve).finish() }
- }
- }
- .padding(.horizontal, 20)
- .padding(.bottom, 36)
+ if store.contents.isEmpty && store.isSearching {
+ resultEmptyLabel
+ } else {
+ resultListContent
}
} else {
PokitLoading()
@@ -296,6 +287,47 @@ private extension PokitSearchView {
}
.padding(.top, 24)
}
+
+ var resultListContent: some View {
+ ScrollView {
+ LazyVStack(spacing: 0) {
+ ForEach(
+ Array(store.scope(state: \.contents, action: \.contents))
+ ) { store in
+ let isFirst = store.state.id == self.store.contents.first?.id
+ let isLast = store.state.id == self.store.contents.last?.id
+
+ ContentCardView(
+ store: store,
+ type: .linkList,
+ isFirst: isFirst,
+ isLast: isLast
+ )
+ }
+
+ if store.hasNext {
+ PokitLoading()
+ .task { await send(.로딩중일때, animation: .pokitDissolve).finish() }
+ }
+ }
+ .padding(.horizontal, 20)
+ .padding(.bottom, 36)
+ }
+ }
+
+ var resultEmptyLabel: some View {
+ VStack(spacing: 8) {
+ Text("검색어가 없어요")
+ .pokitFont(.title2)
+
+ Text("링크 제목, 포킷으로 검색해주세요")
+ .pokitFont(.b2(.m))
+ }
+ .padding(.top, 100)
+ .padding(.bottom, 80)
+ .frame(maxWidth: .infinity)
+ .foregroundStyle(.pokit(.text(.tertiary)))
+ }
}
//MARK: - Preview
#Preview {
diff --git a/Projects/Feature/FeatureSetting/Sources/Setting/PokitSettingView.swift b/Projects/Feature/FeatureSetting/Sources/Setting/PokitSettingView.swift
index ac0f33ec..9beed405 100644
--- a/Projects/Feature/FeatureSetting/Sources/Setting/PokitSettingView.swift
+++ b/Projects/Feature/FeatureSetting/Sources/Setting/PokitSettingView.swift
@@ -90,6 +90,8 @@ private extension PokitSettingView {
.frame(width: 40, height: 40)
Text(store.user?.nickname ?? "")
.pokitFont(.b1(.m))
+ .foregroundStyle(.pokit(.text(.primary)))
+
Spacer()
PokitTextButton(
"프로필 편집",
diff --git a/Projects/Util/Sources/Constants.swift b/Projects/Util/Sources/Constants.swift
index 4c12153f..8bb76877 100644
--- a/Projects/Util/Sources/Constants.swift
+++ b/Projects/Util/Sources/Constants.swift
@@ -24,11 +24,12 @@ public enum Constants {
public static let 마케팅_정보_수신_주소: URL = URL(string: "https://www.notion.so/bb6d0d6569204d5e9a7b67e5825f9d10")!
public static let 고객문의_주소: URL = URL(string: "https://www.instagram.com/pokit.official/")!
public static let 기본_썸네일_주소: URL = URL(string: "https://pokit-s3.s3.ap-northeast-2.amazonaws.com/logo/pokit.png")!
+ public static let 즐겨찾기_썸네일_주소: URL = URL(string: "https://pokit-s3.s3.ap-northeast-2.amazonaws.com/category-image/character.png")!
public static let 포킷_최대_갯수_문구: String = "최대 30개의 포킷을 생성할 수 있습니다.\n포킷을 삭제한 뒤에 추가해주세요."
public static let 복사한_링크_저장하기_문구: String = "복사한 링크 저장하기"
public static let 제목을_입력해주세요_문구: String = "제목을 입력해주세요"
- public static let 링크_저장_완료_문구: String = "링크 저장 완료"
+ public static let 링크_저장_완료_문구: String = "저정한 링크 보러가기"
public static let 메모_수정_완료_문구: String = "메모 수정 완료"
public static let 한글_영어_숫자_입력_문구: String = "한글, 영어, 숫자로만 입력이 가능합니다."
diff --git a/Tuist/Package.swift b/Tuist/Package.swift
index d669a4e3..f6dc4df2 100644
--- a/Tuist/Package.swift
+++ b/Tuist/Package.swift
@@ -29,6 +29,7 @@ let package = Package(
.package(url: "https://github.com/Kitura/Swift-JWT", from: "4.0.1"),
.package(url: "https://github.com/kean/Nuke", from: "12.8.0"),
.package(url: "https://github.com/scinfu/SwiftSoup", "2.7.0" ..< "2.7.5"),
- .package(url: "https://github.com/kakao/kakao-ios-sdk", from: "2.22.5")
+ .package(url: "https://github.com/kakao/kakao-ios-sdk", from: "2.22.5"),
+ .package(url: "https://github.com/amplitude/Amplitude-Swift", from: "1.15.2")
]
)