diff --git a/Projects/Domain/User/Interface/Sources/UserClient.swift b/Projects/Domain/User/Interface/Sources/UserClient.swift index 34fd1927..2bce6164 100644 --- a/Projects/Domain/User/Interface/Sources/UserClient.swift +++ b/Projects/Domain/User/Interface/Sources/UserClient.swift @@ -12,9 +12,11 @@ import Combine public struct UserClient { private let _isLoggedIn: () -> Bool private let _isAppDeleted: () -> Bool + private let _isCoachMarkViewed: () -> Bool private let _fetchFcmToken: () -> String? private let updateLoginState: (Bool) -> Void private let updateDeleteState: (Bool) -> Void + private let updateCoachMarkState: (Bool) -> Void private let updateFcmToken: (String) -> Void private let updatePushNotificationAllowStatus: (Bool) -> Void private let _fetchAlertState: () async throws -> [UserAlertState] @@ -31,11 +33,13 @@ public struct UserClient { public init( isLoggedIn: @escaping () -> Bool, isAppDeleted: @escaping () -> Bool, + isCoachMarkViewed: @escaping () -> Bool, fetchFcmToken: @escaping () -> String?, updateLoginState: @escaping (Bool) -> Void, updateDeleteState: @escaping (Bool) -> Void, updateFcmToken: @escaping (String) -> Void, updatePushNotificationAllowStatus: @escaping (Bool) -> Void, + updateCoachMarkState: @escaping (Bool) -> Void, fetchAlertState: @escaping () async throws -> [UserAlertState], fetchPushNotificationAllowStatus: @escaping () -> Bool, updateAlertState: @escaping (UserAlertState) async throws -> Void, @@ -44,11 +48,13 @@ public struct UserClient { ) { self._isLoggedIn = isLoggedIn self._isAppDeleted = isAppDeleted + self._isCoachMarkViewed = isCoachMarkViewed self._fetchFcmToken = fetchFcmToken self.updateLoginState = updateLoginState self.updateDeleteState = updateDeleteState self.updateFcmToken = updateFcmToken self.updatePushNotificationAllowStatus = updatePushNotificationAllowStatus + self.updateCoachMarkState = updateCoachMarkState self._fetchAlertState = fetchAlertState self._fetchPushNotificationAllowStatus = fetchPushNotificationAllowStatus self.updateAlertState = updateAlertState @@ -64,6 +70,10 @@ public struct UserClient { _isAppDeleted() } + public func isCoachMarkViewd() -> Bool { + _isCoachMarkViewed() + } + public func fetchFcmToken() -> String? { _fetchFcmToken() } @@ -75,7 +85,10 @@ public struct UserClient { public func updateDeleteState(isDelete: Bool) { updateDeleteState(isDelete) } - + + public func updateCoachMarkState(isViewed: Bool) { + updateCoachMarkState(isViewed) + } public func updateFcmToken(fcmToken: String) { updateFcmToken(fcmToken) } diff --git a/Projects/Domain/User/Sources/UserClient.swift b/Projects/Domain/User/Sources/UserClient.swift index c46472f1..b6792a34 100644 --- a/Projects/Domain/User/Sources/UserClient.swift +++ b/Projects/Domain/User/Sources/UserClient.swift @@ -22,6 +22,7 @@ extension UserClient: DependencyKey { case deleteState case fcmToken case alertAllowState + case coachMarkState } static public var liveValue: UserClient = .live() @@ -38,6 +39,10 @@ extension UserClient: DependencyKey { return !UserDefaults.standard.bool(forKey: UserDefaultsKeys.deleteState.rawValue) }, + isCoachMarkViewed: { + return UserDefaults.standard.bool(forKey: UserDefaultsKeys.coachMarkState.rawValue) + }, + fetchFcmToken: { return UserDefaults.standard.string(forKey: UserDefaultsKeys.fcmToken.rawValue) }, @@ -58,6 +63,10 @@ extension UserClient: DependencyKey { UserDefaults.standard.set(isAllow, forKey: UserDefaultsKeys.alertAllowState.rawValue) }, + updateCoachMarkState: { isViewed in + UserDefaults.standard.set(isViewed, forKey: UserDefaultsKeys.coachMarkState.rawValue) + }, + fetchAlertState: { let responseData = try await networkManager.reqeust(api: .apiType(UserAPI.fetchAlertState), dto: [AlertStateResponseDTO].self) return responseData.map { $0.toDomain() } diff --git a/Projects/Feature/SandBeach/Interface/Sources/Root/SandBeachRootFeature.swift b/Projects/Feature/SandBeach/Interface/Sources/Root/SandBeachRootFeature.swift index e69425e0..a17bbf52 100644 --- a/Projects/Feature/SandBeach/Interface/Sources/Root/SandBeachRootFeature.swift +++ b/Projects/Feature/SandBeach/Interface/Sources/Root/SandBeachRootFeature.swift @@ -10,7 +10,9 @@ import Foundation import FeatureProfileSetupInterface import FeatureBottleArrivalInterface import FeatureTabBarInterface + import DomainProfile +import DomainUserInterface import ComposableArchitecture @@ -38,19 +40,24 @@ public struct SandBeachRootFeature { var introduction: String var profileImageData: Data var isLoading: Bool + public var isCoachMarkViewed: Bool = true + public var sandBeach: SandBeachFeature.State + public var sandBeachCoachMark: SandBeachCoachMarkFeature.State public init( path: StackState = StackState(), introduction: String = "", profileImageData: Data = .init(), sandBeach: SandBeachFeature.State = .init(), + sandBeachCoachMark: SandBeachCoachMarkFeature.State = .init(), isLoading: Bool = false ) { self.path = path self.introduction = introduction self.profileImageData = profileImageData self.sandBeach = sandBeach + self.sandBeachCoachMark = sandBeachCoachMark self.isLoading = isLoading } } @@ -58,6 +65,7 @@ public struct SandBeachRootFeature { public enum Action { case path(StackAction) case sandBeach(SandBeachFeature.Action) + case sandBeachCoachMark(SandBeachCoachMarkFeature.Action) case profileSetupDidCompleted case delegate(Delegate) case selectedTabDidChanged(selectedTab: TabType) @@ -74,6 +82,10 @@ public struct SandBeachRootFeature { SandBeachFeature() } + Scope(state: \.sandBeachCoachMark, action: \.sandBeachCoachMark) { + SandBeachCoachMarkFeature() + } + reducer .forEach(\.path, action: \.path) } @@ -84,7 +96,8 @@ extension SandBeachRootFeature { let reducer = Reduce { state, action in @Dependency(\.profileClient) var profileClient - + @Dependency(\.userClient) var userClient + switch action { // IntrodctionSetup Delegate @@ -147,6 +160,10 @@ extension SandBeachRootFeature { case .writeButtonDidTapped: state.path.append(.IntroductionSetup(IntroductionSetupFeature.State())) return .none + + case .sandBeachLoadCompleted: + state.isCoachMarkViewed = userClient.isCoachMarkViewd() + return .none } // BottleArrivalDetail Delegate @@ -157,6 +174,15 @@ extension SandBeachRootFeature { return .none } + // SandBeachCoachMark Delegate + case let .sandBeachCoachMark(.delegate(delegate)): + switch delegate { + case .coachMarkDidCompleted: + userClient.updateCoachMarkState(isViewed: true) + state.isCoachMarkViewed = true + return .none + } + case .profileSetupDidCompleted: state.isLoading = false state.path.removeAll() diff --git a/Projects/Feature/SandBeach/Interface/Sources/Root/SandBeachRootView.swift b/Projects/Feature/SandBeach/Interface/Sources/Root/SandBeachRootView.swift index 95181627..514c8a0e 100644 --- a/Projects/Feature/SandBeach/Interface/Sources/Root/SandBeachRootView.swift +++ b/Projects/Feature/SandBeach/Interface/Sources/Root/SandBeachRootView.swift @@ -25,10 +25,17 @@ public struct SandBeachRootView: View { public var body: some View { WithPerceptionTracking { NavigationStack(path: $store.scope(state: \.path, action: \.path)) { + ZStack { SandBeachView(store: store.scope(state: \.sandBeach, action: \.sandBeach)) - .setTabBar(selectedTab: .sandBeach) { selectedTab in - store.send(.selectedTabDidChanged(selectedTab: selectedTab)) + .setTabBar(selectedTab: .sandBeach) { selectedTab in + store.send(.selectedTabDidChanged(selectedTab: selectedTab)) + } + + if !store.isCoachMarkViewed && store.sandBeach.userState == .noIntroduction { + SandBeachCoachMarkView( + store: store.scope(state: \.sandBeachCoachMark, action: \.sandBeachCoachMark)) } + } } destination: { store in WithPerceptionTracking { switch store.state { diff --git a/Projects/Feature/SandBeach/Interface/Sources/SandBeach/SandBeachFeatureInterface.swift b/Projects/Feature/SandBeach/Interface/Sources/SandBeach/SandBeachFeatureInterface.swift index fde17d34..eadd391a 100644 --- a/Projects/Feature/SandBeach/Interface/Sources/SandBeach/SandBeachFeatureInterface.swift +++ b/Projects/Feature/SandBeach/Interface/Sources/SandBeach/SandBeachFeatureInterface.swift @@ -56,6 +56,7 @@ public struct SandBeachFeature { case writeButtonDidTapped case newBottleIslandDidTapped case bottleStorageIslandDidTapped + case sandBeachLoadCompleted } case alert(Alert) @@ -152,7 +153,7 @@ extension SandBeachFeature { state.userState = userState state.isDisableIslandBottle = isDisableButton state.isLoading = false - return .none + return .send(.delegate(.sandBeachLoadCompleted)) case .writeButtonDidTapped: return .send(.delegate(.writeButtonDidTapped)) diff --git a/Projects/Feature/SandBeach/Interface/Sources/SandBeach/SandBeachView.swift b/Projects/Feature/SandBeach/Interface/Sources/SandBeach/SandBeachView.swift index ad21d50c..07cb9a22 100644 --- a/Projects/Feature/SandBeach/Interface/Sources/SandBeach/SandBeachView.swift +++ b/Projects/Feature/SandBeach/Interface/Sources/SandBeach/SandBeachView.swift @@ -20,67 +20,72 @@ public struct SandBeachView: View { public var body: some View { WithPerceptionTracking { - GeometryReader { geo in - WithPerceptionTracking { - if store.userState == .none && store.isLoading { - LoadingIndicator() - } else { - VStack(spacing: 0) { - Spacer() - BottleImageView(type: .local(bottleImageSystem: .illustraition(.logo))) - .frame(width: 78.06, height: 20) - .padding(.top, geo.safeAreaInsets.top + 14) - .padding(.bottom, 38) - - WantedSansStyleText( - store.userState.title, style: .title1, color: .secondary) - .frame(height: 62) - .multilineTextAlignment(.center) - .padding(.bottom, 24) - Spacer() - - popup - .padding(.bottom, 8) - - BottleImageView(type: .local( - bottleImageSystem: - store.userState.isEmptyBottle ? .illustraition(.islandEmptyBottle) : .illustraition(.islandHasBottle)) - ) - .frame(width: geo.size.width) - .frame(height: geo.size.width) - .asThrottleButton { - if store.userState.isHasNewBottle { - store.send(.newBottleIslandDidTapped) - } else if store.userState.isHasActiveBottle { - store.send(.bottleStorageIslandDidTapped) - } else if store.userState != .noIntroduction { - store.send(.newBottleIslandDidTapped) - } - } - .disabled(store.isDisableIslandBottle) - - Spacer() - } - } + if store.userState == .none && store.isLoading { + LoadingIndicator() + } else { + VStack(spacing: 0) { + Spacer() + .frame(height: 1) + logoImage + userStateTitle + popup + islandImage + Spacer() } } - .bottleAlert($store.scope(state: \.destination?.alert, action: \.destination.alert)) - .onAppear { - store.send(.onAppear) - } - .background { - BottleImageView( - type: .local(bottleImageSystem: .illustraition(.sandBeachBackground)) - ) - } } - .edgesIgnoringSafeArea([.top, .bottom]) + .bottleAlert($store.scope(state: \.destination?.alert, action: \.destination.alert)) + .onAppear { + store.send(.onAppear) + } + .background { + BottleImageView( + type: .local(bottleImageSystem: .illustraition(.sandBeachBackground)) + ) + .edgesIgnoringSafeArea(.all) + } } } // MARK: - Views public extension SandBeachView { + var logoImage: some View { + BottleImageView(type: .local(bottleImageSystem: .illustraition(.logo))) + .frame(width: 78.06, height: 20) + .padding(.top, 14) + .padding(.bottom, 46) + } + + var userStateTitle: some View { + WantedSansStyleText( + store.userState.title, style: .mainTitle, color: .secondary) + .multilineTextAlignment(.center) + .padding(.bottom, store.userState == .noIntroduction ? 32 : 64) + .lineSpacing(5) + } + + var islandImage: some View { + GeometryReader { geo in + BottleImageView(type: .local( + bottleImageSystem: + store.userState.isEmptyBottle ? .illustraition(.islandEmptyBottle) : .illustraition(.islandHasBottle)) + ) + .frame(width: geo.size.width) + .frame(height: geo.size.width) + .asThrottleButton { + if store.userState.isHasNewBottle { + store.send(.newBottleIslandDidTapped) + } else if store.userState.isHasActiveBottle { + store.send(.bottleStorageIslandDidTapped) + } else if store.userState != .noIntroduction { + store.send(.newBottleIslandDidTapped) + } + } + .disabled(store.isDisableIslandBottle) + } + } + @ViewBuilder var popup: some View { let userState = store.userState diff --git a/Projects/Feature/SandBeach/Interface/Sources/SandBeachCoachMark/SandBeachCoachMarkFeature.swift b/Projects/Feature/SandBeach/Interface/Sources/SandBeachCoachMark/SandBeachCoachMarkFeature.swift new file mode 100644 index 00000000..a2ed0f81 --- /dev/null +++ b/Projects/Feature/SandBeach/Interface/Sources/SandBeachCoachMark/SandBeachCoachMarkFeature.swift @@ -0,0 +1,30 @@ +// +// SandBeachCoachMarkFeature.swift +// FeatureSandBeachInterface +// +// Created by 임현규 on 10/28/24. +// + +import ComposableArchitecture + +extension SandBeachCoachMarkFeature { + public init() { + let reducer = Reduce { state, action in + switch action { + case .coachMarkDidTapped: + state.count += 1 + + if state.count == 3 { + return .send(.delegate(.coachMarkDidCompleted)) + } else { + return .none + } + + case .delegate: + return .none + } + } + + self.init(reducer: reducer) + } +} diff --git a/Projects/Feature/SandBeach/Interface/Sources/SandBeachCoachMark/SandBeachCoachMarkFeatureInterface.swift b/Projects/Feature/SandBeach/Interface/Sources/SandBeachCoachMark/SandBeachCoachMarkFeatureInterface.swift new file mode 100644 index 00000000..85043236 --- /dev/null +++ b/Projects/Feature/SandBeach/Interface/Sources/SandBeachCoachMark/SandBeachCoachMarkFeatureInterface.swift @@ -0,0 +1,38 @@ +// +// SandBeachCoachMarkFeatureInterface.swift +// FeatureSandBeachInterface +// +// Created by 임현규 on 10/28/24. +// + +import ComposableArchitecture + +@Reducer +public struct SandBeachCoachMarkFeature { + private let reducer: Reduce + + + public init(reducer: Reduce) { + self.reducer = reducer + } + + @ObservableState + public struct State: Equatable { + public var count: Int = 0 + + public init() {} + } + + public enum Action { + case coachMarkDidTapped + case delegate(Delegate) + + public enum Delegate { + case coachMarkDidCompleted + } + } + + public var body: some ReducerOf { + reducer + } +} diff --git a/Projects/Feature/SandBeach/Interface/Sources/SandBeachCoachMark/SandBeachCoachMarkView.swift b/Projects/Feature/SandBeach/Interface/Sources/SandBeachCoachMark/SandBeachCoachMarkView.swift new file mode 100644 index 00000000..00788278 --- /dev/null +++ b/Projects/Feature/SandBeach/Interface/Sources/SandBeachCoachMark/SandBeachCoachMarkView.swift @@ -0,0 +1,105 @@ +// +// SandBeachCoachMarkView.swift +// FeatureSandBeachInterface +// +// Created by 임현규 on 10/28/24. +// + +import SwiftUI + +import SharedDesignSystem + +import ComposableArchitecture + +public struct SandBeachCoachMarkView: View { + @Perception.Bindable private var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + ZStack { + Color.black.opacity(0.6) + .edgesIgnoringSafeArea(.all) + if store.count == 0 { + firstCoachMark + } else if store.count == 1 { + secondCoachMark + } else { + thirdCoachMark + } + } + .compositingGroup() + .asButton { + store.send(.coachMarkDidTapped) + } + } +} + +private extension SandBeachCoachMarkView { + var firstCoachMark: some View { + VStack(spacing: 0) { + Spacer() + .frame(height: 96) + + PopupView(popupType: .coachMark(content: "나의 첫인상이 될\n자기소개를 작성해주세요")) + + RoundedRectangle(cornerRadius: 20) + .frame(width: 267, height: 106) + .foregroundColor(.white) + .blendMode(.destinationOut) + .padding(.top, .xl) + + Spacer() + } + } + + var secondCoachMark: some View { + VStack(spacing: 0.0) { + Spacer() + .frame(height: 239) + + PopupView(popupType: .coachMark(content: "바구니를 클릭하면\n보틀 속 자기소개를 읽어볼 수 있어요")) + + GeometryReader { geo in + HStack(spacing: 0.0) { + Spacer() + RoundedRectangle(cornerRadius: 20) + .frame(width: geo.size.width - 200, height: geo.size.width - 200) + .foregroundColor(.white) + .blendMode(.destinationOut) + .padding(.top, .xl) + Spacer() + } + } + Spacer() + } + } + + var thirdCoachMark: some View { + GeometryReader { geo in + + VStack(spacing: 0.0) { + Spacer() + + PopupView(popupType: .coachMark(content: "가치관 문답을 시작한 경우\n문답에서 확인할 수 있어요")) + .offset(x: geo.size.width * 0.09) + + HStack(spacing: 0.0) { + Spacer() + RoundedRectangle(cornerRadius: 20) + .frame(width: 72, height: 72) + .foregroundColor(.white) + .blendMode(.destinationOut) + .padding(.top, .xl) + .offset(x: geo.size.width * 0.09) + Spacer() + + } + } + .offset(y: -34) + } + .ignoresSafeArea(.all, edges: .bottom) + } +} diff --git a/Projects/Feature/TabBar/Interface/Sources/TabBarModifier.swift b/Projects/Feature/TabBar/Interface/Sources/TabBarModifier.swift index 0c151aa6..c3f1f38a 100644 --- a/Projects/Feature/TabBar/Interface/Sources/TabBarModifier.swift +++ b/Projects/Feature/TabBar/Interface/Sources/TabBarModifier.swift @@ -42,7 +42,7 @@ private struct TabBarModifier: ViewModifier { color: selectedTab == item ? .primary : .enableTertiary ) } - .offset(y: -9) + .offset(y: -15) .asThrottleButton { action(item) } diff --git a/Projects/Shared/DesignSystem/Sources/Components/Popup/PopupType.swift b/Projects/Shared/DesignSystem/Sources/Components/Popup/PopupType.swift index 0306e841..629f5efe 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Popup/PopupType.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Popup/PopupType.swift @@ -10,5 +10,5 @@ import Foundation public enum PopupType { case text(content: String) case button(content: String, buttonTitle: String) - + case coachMark(content: String) } diff --git a/Projects/Shared/DesignSystem/Sources/Components/Popup/PopupView.swift b/Projects/Shared/DesignSystem/Sources/Components/Popup/PopupView.swift index b7b03e54..d0c78e30 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Popup/PopupView.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Popup/PopupView.swift @@ -42,6 +42,8 @@ private extension PopupView { WantedSansStyleText(content, style: .subTitle2, color: .secondary) case .button(let content, _): WantedSansStyleText(content, style: .subTitle2, color: .secondary) + case .coachMark(let content): + WantedSansStyleText(content, style: .subTitle2, color: .secondary) } } @@ -69,6 +71,9 @@ private extension PopupView { .frame(width: 227) } .padding(.lg) + case .coachMark: + popupText + .padding(.lg) } } } @@ -84,8 +89,9 @@ private extension PopupView { var height: CGFloat { switch popupType { - case .button: return 106 - case .text: return 42 + case .button: return 106 + case .text: return 42 + case .coachMark: return 42 } } } diff --git a/Projects/Shared/DesignSystem/Sources/Font/BottleFontSystem+WantedSans.swift b/Projects/Shared/DesignSystem/Sources/Font/BottleFontSystem+WantedSans.swift index 84339409..424a2ccf 100644 --- a/Projects/Shared/DesignSystem/Sources/Font/BottleFontSystem+WantedSans.swift +++ b/Projects/Shared/DesignSystem/Sources/Font/BottleFontSystem+WantedSans.swift @@ -15,6 +15,7 @@ public extension Font.BottleFontSystem { case subTitle2 case body case caption + case mainTitle } } @@ -33,6 +34,8 @@ public extension Font.BottleFontSystem.WantedSans { return SharedDesignSystemFontFamily.WantedSans.medium.swiftUIFont(size: 14) case .caption: return SharedDesignSystemFontFamily.WantedSans.medium.swiftUIFont(size: 12) + case .mainTitle: + return SharedDesignSystemFontFamily.WantedSans.bold.swiftUIFont(size: 32) } } }