diff --git a/DependencyGraph/mohanyang_dev_graph.png b/DependencyGraph/mohanyang_dev_graph.png index 23c2ed3..dd30880 100644 Binary files a/DependencyGraph/mohanyang_dev_graph.png and b/DependencyGraph/mohanyang_dev_graph.png differ diff --git a/DependencyGraph/mohanyang_prod_graph.png b/DependencyGraph/mohanyang_prod_graph.png index 16d3cd4..6d6855f 100644 Binary files a/DependencyGraph/mohanyang_prod_graph.png and b/DependencyGraph/mohanyang_prod_graph.png differ diff --git a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Module/Domain.swift b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Module/Domain.swift index 0f5f3df..c8cdb30 100644 --- a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Module/Domain.swift +++ b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Module/Domain.swift @@ -13,4 +13,5 @@ public enum Domain: String, Modulable { case AuthService case PushService case UserService + case CatService } diff --git a/Projects/Core/UserDefaultsClient/Interface/Keys.swift b/Projects/Core/UserDefaultsClient/Interface/Keys.swift deleted file mode 100644 index 6aded0a..0000000 --- a/Projects/Core/UserDefaultsClient/Interface/Keys.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// Keys.swift -// UserDefaultsClientInterface -// -// Created by 김지현 on 8/9/24. -// Copyright © 2024 PomoNyang. All rights reserved. -// - -import Foundation - -public enum UserDefaultsKeys: String { - case isFirstInstalled = "mohanyang_userdefaults_isFirstInstalled" - case isOnboarded = "mohanyang_userdefaults_isOnboarded" -} diff --git a/Projects/Domain/CatService/Interface/API/CatAPIClientInterface.swift b/Projects/Domain/CatService/Interface/API/CatAPIClientInterface.swift new file mode 100644 index 0000000..1164423 --- /dev/null +++ b/Projects/Domain/CatService/Interface/API/CatAPIClientInterface.swift @@ -0,0 +1,31 @@ +// +// CatServiceInterface.swift +// CatService +// +// Created by 김지현 on 8/17/24. +// + +import Foundation + +import APIClientInterface + +import Dependencies +import DependenciesMacros + + +@DependencyClient +public struct CatService { + public var fetchCatLists: @Sendable ( + _ apiClient: APIClient + ) async throws -> CatList + + public var changeCatName: @Sendable ( + _ apiClient: APIClient, + _ name: String + ) async throws -> Void +} + +extension CatService: TestDependencyKey { + public static let previewValue = Self() + public static let testValue = Self() +} diff --git a/Projects/Domain/CatService/Interface/API/CatAPIRequest.swift b/Projects/Domain/CatService/Interface/API/CatAPIRequest.swift new file mode 100644 index 0000000..90ffc32 --- /dev/null +++ b/Projects/Domain/CatService/Interface/API/CatAPIRequest.swift @@ -0,0 +1,46 @@ +// +// CatAPIRequest.swift +// CatServiceInterface +// +// Created by 김지현 on 8/17/24. +// Copyright © 2024 PomoNyang. All rights reserved. +// + +import Foundation +import APIClientInterface + +public enum CatAPIrequest { + case fetchCatList, changeCatName(String) +} + +extension CatAPIrequest: APIBaseRequest { + public var baseURL: String { + return API.apiBaseURL + } + + public var path: String { + switch self { + case .fetchCatList, .changeCatName: + return "/api/v1/cats" + } + } + + public var method: HTTPMethod { + switch self { + case .fetchCatList: + return .get + case.changeCatName: + return .put + } + } + + public var parameters: RequestParams { + switch self { + case .fetchCatList: + return .requestPlain + case .changeCatName(let name): + let dto = CatDTO.Request.ChangeCatNameRequestDTO(name: name) + return .body(dto) + } + } +} diff --git a/Projects/Domain/CatService/Interface/DTO/CatDTO.swift b/Projects/Domain/CatService/Interface/DTO/CatDTO.swift new file mode 100644 index 0000000..ebb5dea --- /dev/null +++ b/Projects/Domain/CatService/Interface/DTO/CatDTO.swift @@ -0,0 +1,30 @@ +// +// CatDTO.swift +// CatServiceInterface +// +// Created by 김지현 on 8/17/24. +// Copyright © 2024 PomoNyang. All rights reserved. +// + +import Foundation + +public typealias CatList = [CatDTO.Response.GetCatListResponseDTO] + +public enum CatDTO { + public enum Request { } + public enum Response { } +} + +public extension CatDTO.Request { + struct ChangeCatNameRequestDTO: Encodable { + public var name: String + } +} + +public extension CatDTO.Response { + struct GetCatListResponseDTO: Equatable, Decodable { + public var no: Int + public var name: String + public var type: String + } +} diff --git a/Projects/Domain/CatService/Project.swift b/Projects/Domain/CatService/Project.swift new file mode 100644 index 0000000..96668c5 --- /dev/null +++ b/Projects/Domain/CatService/Project.swift @@ -0,0 +1,20 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +@_spi(Domain) +@_spi(Core) +import DependencyPlugin + +let project: Project = .makeTMABasedProject( + module: Domain.CatService, + scripts: [], + targets: [ + .sources, + .interface + ], + dependencies: [ + .interface: [ + .dependency(rootModule: Core.self) + ] + ] +) diff --git a/Projects/Domain/CatService/Sources/API/CatAPIClient.swift b/Projects/Domain/CatService/Sources/API/CatAPIClient.swift new file mode 100644 index 0000000..12bfb45 --- /dev/null +++ b/Projects/Domain/CatService/Sources/API/CatAPIClient.swift @@ -0,0 +1,36 @@ +// +// CatService.swift +// CatService +// +// Created by 김지현 on 8/17/24. +// + +import Foundation + +import APIClientInterface +import CatServiceInterface + +import Dependencies + +extension CatService: DependencyKey { + public static let liveValue: CatService = .live() + private static func live() -> Self { + return CatService( + fetchCatLists: { apiClient in + let request = CatAPIrequest.fetchCatList + return try await apiClient.apiRequest( + request: request, + as: CatList.self + ) + }, + + changeCatName: { apiClient, name in + let request = CatAPIrequest.changeCatName(name) + _ = try await apiClient.apiRequest( + request: request, + as: EmptyResponse.self + ) + } + ) + } +} diff --git a/Projects/Domain/UserService/Interface/API/UserAPIClientInterface.swift b/Projects/Domain/UserService/Interface/API/UserAPIClientInterface.swift index b65637d..62985dc 100644 --- a/Projects/Domain/UserService/Interface/API/UserAPIClientInterface.swift +++ b/Projects/Domain/UserService/Interface/API/UserAPIClientInterface.swift @@ -16,9 +16,6 @@ import DependenciesMacros @DependencyClient public struct UserService { - public var fetchCatLists: @Sendable ( - _ apiClient: APIClient - ) async throws -> CatList public var selectCat: @Sendable ( _ no: Int, _ apiClient: APIClient diff --git a/Projects/Domain/UserService/Interface/API/UserAPIRequest.swift b/Projects/Domain/UserService/Interface/API/UserAPIRequest.swift index babbc87..cabc551 100644 --- a/Projects/Domain/UserService/Interface/API/UserAPIRequest.swift +++ b/Projects/Domain/UserService/Interface/API/UserAPIRequest.swift @@ -10,7 +10,7 @@ import Foundation import APIClientInterface public enum UserAPIrequest { - case fetchCatList, selectCat(Int) + case selectCat(Int) } extension UserAPIrequest: APIBaseRequest { @@ -20,8 +20,6 @@ extension UserAPIrequest: APIBaseRequest { public var path: String { switch self { - case .fetchCatList: - return "/api/v1/cats" case .selectCat: return "/api/v1/users/cats" } @@ -29,8 +27,6 @@ extension UserAPIrequest: APIBaseRequest { public var method: HTTPMethod { switch self { - case .fetchCatList: - return .get case .selectCat: return .put } @@ -38,8 +34,6 @@ extension UserAPIrequest: APIBaseRequest { public var parameters: RequestParams { switch self { - case .fetchCatList: - return .requestPlain case .selectCat(let no): let dto = UserDTO.Request.SelectCatRequestDTO(catNo: no) return .body(dto) diff --git a/Projects/Domain/UserService/Interface/DTO/UserDTO.swift b/Projects/Domain/UserService/Interface/DTO/UserDTO.swift index 24f86b2..8ffe381 100644 --- a/Projects/Domain/UserService/Interface/DTO/UserDTO.swift +++ b/Projects/Domain/UserService/Interface/DTO/UserDTO.swift @@ -8,8 +8,6 @@ import Foundation -public typealias CatList = [UserDTO.Response.GetCatListResponseDTO] - public enum UserDTO { public enum Request { } public enum Response { } @@ -22,11 +20,5 @@ public extension UserDTO.Request { } public extension UserDTO.Response { - struct GetCatListResponseDTO: Equatable, Decodable { - public var no: Int - public var name: String - public var type: String - } - struct SelectCatResponseDTO: Decodable { } } diff --git a/Projects/Domain/UserService/Sources/API/UserAPIClient.swift b/Projects/Domain/UserService/Sources/API/UserAPIClient.swift index 91077c3..414427b 100644 --- a/Projects/Domain/UserService/Sources/API/UserAPIClient.swift +++ b/Projects/Domain/UserService/Sources/API/UserAPIClient.swift @@ -17,13 +17,6 @@ extension UserService: DependencyKey { public static let liveValue: UserService = .live() private static func live() -> Self { return UserService( - fetchCatLists: { apiClient in - let request = UserAPIrequest.fetchCatList - return try await apiClient.apiRequest( - request: request, - as: CatList.self - ) - }, selectCat: { no, apiClient in let request = UserAPIrequest.selectCat(no) _ = try await apiClient.apiRequest( diff --git a/Projects/Feature/Feature/Sources/AppCore.swift b/Projects/Feature/Feature/Sources/AppCore.swift index 3cdee87..f6308ef 100644 --- a/Projects/Feature/Feature/Sources/AppCore.swift +++ b/Projects/Feature/Feature/Sources/AppCore.swift @@ -95,7 +95,12 @@ public struct AppCore { case .home: return .none - + + case .onboarding(.selectCat(.presented(.namingCat(.presented(.moveToHome))))): + state.onboarding = nil + state.home = HomeCore.State() + return .none + case .onboarding: return .none } diff --git a/Projects/Feature/Feature/Sources/AppView.swift b/Projects/Feature/Feature/Sources/AppView.swift index a899c4f..152fbd9 100644 --- a/Projects/Feature/Feature/Sources/AppView.swift +++ b/Projects/Feature/Feature/Sources/AppView.swift @@ -33,6 +33,7 @@ public struct AppView: View { Color.red } } + .transition(.opacity) .onAppear { store.send(.onAppear) } diff --git a/Projects/Feature/OnboardingFeature/Sources/NamingCat/NamingCatCore.swift b/Projects/Feature/OnboardingFeature/Sources/NamingCat/NamingCatCore.swift new file mode 100644 index 0000000..dc08958 --- /dev/null +++ b/Projects/Feature/OnboardingFeature/Sources/NamingCat/NamingCatCore.swift @@ -0,0 +1,91 @@ +// +// NamingCatCore.swift +// OnboardingFeature +// +// Created by 김지현 on 8/17/24. +// Copyright © 2024 PomoNyang. All rights reserved. +// + +import UserDefaultsClientInterface +import APIClientInterface +import CatServiceInterface + +import ComposableArchitecture + +@Reducer +public struct NamingCatCore { + @ObservableState + public struct State: Equatable { + public init(selectedCat: AnyCat) { + self.selectedCat = selectedCat + } + + var selectedCat: AnyCat + var text: String = "" + var inputFieldError: NamingCatError? + var tooltip: DownDirectionTooltip? = .init() + } + + public enum Action: BindableAction { + case onAppear + case tapStartButton + case moveToHome + case binding(BindingAction) + } + + @Dependency(UserDefaultsClient.self) var userDefaultsClient + @Dependency(APIClient.self) var apiClient + @Dependency(CatService.self) var catService + + public init() {} + + public var body: some ReducerOf { + BindingReducer() + Reduce(self.core) + } + + private func core(state: inout State, action: Action) -> EffectOf { + let isOnboardedKey = "mohanyang_userdefaults_isOnboarded" + + switch action { + case .onAppear: + return .none + + case .tapStartButton: + return .run { [text = state.text] send in + _ = try await catService.changeCatName( + apiClient: apiClient, + name: text + ) + await userDefaultsClient.setBool(true, key: isOnboardedKey) + await send(.moveToHome) + } + + case .moveToHome: + return .none + + case .binding(\.text): + state.inputFieldError = setError(state.text) + return .none + + case .binding: + return .none + } + } + + private func setError(_ text: String) -> NamingCatError? { + var error: NamingCatError? = nil + + if text == " " { + error = .startsWithWhiteSpace + } else if text.count > 10 { + error = .exceedsMaxLength + } else if text.containsWhitespaceOrSpecialCharacters() { + error = .hasSpecialCharacter + } else { + error = nil + } + + return error + } +} diff --git a/Projects/Feature/OnboardingFeature/Sources/NamingCat/NamingCatError.swift b/Projects/Feature/OnboardingFeature/Sources/NamingCat/NamingCatError.swift new file mode 100644 index 0000000..1d00e6b --- /dev/null +++ b/Projects/Feature/OnboardingFeature/Sources/NamingCat/NamingCatError.swift @@ -0,0 +1,29 @@ +// +// NamingCatError.swift +// OnboardingFeature +// +// Created by 김지현 on 8/17/24. +// Copyright © 2024 PomoNyang. All rights reserved. +// + +import Foundation +import DesignSystem + +enum NamingCatError { + case hasSpecialCharacter + case exceedsMaxLength + case startsWithWhiteSpace +} + +extension NamingCatError: InputFieldErrorProtocol { + var message: String { + switch self { + case .hasSpecialCharacter: + "고양이 이름에는 공백, 특수문자가 들어갈 수 없어요" + case .exceedsMaxLength: + "고양이 이름은 10글자를 넘길 수 없어요" + case .startsWithWhiteSpace: + "고양이 이름은 빈 칸이 될 수 없어요" + } + } +} diff --git a/Projects/Feature/OnboardingFeature/Sources/NamingCat/NamingCatView.swift b/Projects/Feature/OnboardingFeature/Sources/NamingCat/NamingCatView.swift new file mode 100644 index 0000000..0f239fd --- /dev/null +++ b/Projects/Feature/OnboardingFeature/Sources/NamingCat/NamingCatView.swift @@ -0,0 +1,79 @@ +// +// NamingCatView.swift +// OnboardingFeature +// +// Created by 김지현 on 8/17/24. +// Copyright © 2024 PomoNyang. All rights reserved. +// + +import SwiftUI + +import DesignSystem + +import ComposableArchitecture + +struct NamingCatView: View { + @Bindable var store: StoreOf + + init(store: StoreOf) { + self.store = store + } + + var body: some View { + NavigationContainer( + title: Text("고양이 이름짓기"), + style: .navigation + ) { + VStack(spacing: 40) { + Spacer() + + ZStack { + Rectangle() + .foregroundStyle(Alias.Color.Background.secondary) + .frame(height: 240) + store.selectedCat.catImage + .setTooltipTarget(tooltip: DownDirectionTooltip.self) + } + + VStack(spacing: Alias.Spacing.small) { + HStack { + Text("내 고양이의 이름") + .font(Typography.subBodyR) + .foregroundStyle(Alias.Color.Text.secondary) + .padding(.leading, Alias.Spacing.xSmall) + Spacer() + } + InputField( + placeholder: store.selectedCat.name, + text: $store.text, + fieldError: $store.inputFieldError + ) + } + + Spacer() + + Button(title: "시작하기") { + store.send(.tapStartButton) + } + .buttonStyle(.box(level: .primary, size: .large, width: .low)) + .disabled(store.inputFieldError != nil || store.text == "") + } + .padding(.horizontal, 20) + } + .background { + Alias.Color.Background.primary + .ignoresSafeArea() + } + .tooltipDestination(tooltip: $store.tooltip) + .onAppear { store.send(.onAppear) } + } +} + +struct DownDirectionTooltip: Tooltip { + var title: Text { Text("반갑다냥! 내 이름을 지어줄래냥?") } + var color: TooltipColor { .white } + var direction: TooltipDirection { .down } + var targetCornerRadius: CGFloat? { Alias.BorderRadius.small } + var dimEnabled: Bool { false } + var padding: CGFloat { 12 } +} diff --git a/Projects/Feature/OnboardingFeature/Sources/Onboarding/OnboardingView.swift b/Projects/Feature/OnboardingFeature/Sources/Onboarding/OnboardingView.swift index bb3c87b..42243cc 100644 --- a/Projects/Feature/OnboardingFeature/Sources/Onboarding/OnboardingView.swift +++ b/Projects/Feature/OnboardingFeature/Sources/Onboarding/OnboardingView.swift @@ -63,7 +63,6 @@ public struct OnboardingView: View { Alias.Color.Background.primary .ignoresSafeArea() } - .onAppear { store.send(.onApear) } .navigationDestination( item: $store.scope(state: \.selectCat, action: \.selectCat) ) { store in @@ -76,6 +75,7 @@ public struct OnboardingView: View { .onAppear { store.width = geometry.size.width } } } + .onAppear { store.send(.onApear) } } } @@ -104,9 +104,3 @@ struct OnboardingCarouselContentView: View { .frame(width: width, height: 350) } } - -struct OnboardingView_Previews: PreviewProvider { - static var previews: some View { - OnboardingView(store: Store(initialState: OnboardingCore.State()) { OnboardingCore() }) - } -} diff --git a/Projects/Feature/OnboardingFeature/Sources/SelectCat/SelectCatCore.swift b/Projects/Feature/OnboardingFeature/Sources/SelectCat/SelectCatCore.swift index e165417..2f4f916 100644 --- a/Projects/Feature/OnboardingFeature/Sources/SelectCat/SelectCatCore.swift +++ b/Projects/Feature/OnboardingFeature/Sources/SelectCat/SelectCatCore.swift @@ -8,6 +8,7 @@ import APIClientInterface import UserServiceInterface +import CatServiceInterface import UserNotificationClientInterface import Shared @@ -17,30 +18,37 @@ import ComposableArchitecture public struct SelectCatCore { @ObservableState public struct State: Equatable { + public init() { } var catList: [AnyCat] = [] - //var catType: CatType? = nil var selectedCat: AnyCat? = nil + @Presents var namingCat: NamingCatCore.State? } public enum Action: BindableAction { case onAppear case selectCat(AnyCat) case tapNextButton + case moveToNamingCat case _fetchCatListRequest case _fetchCatListResponse(CatList) case _selectCatRequest case binding(BindingAction) + case namingCat(PresentationAction) } public init() {} @Dependency(APIClient.self) var apiClient @Dependency(UserService.self) var userService + @Dependency(CatService.self) var catService @Dependency(UserNotificationClient.self) var userNotificationClient public var body: some ReducerOf { BindingReducer() Reduce(self.core) + .ifLet(\.$namingCat, action: \.namingCat) { + NamingCatCore() + } } private func core(state: inout State, action: Action) -> EffectOf { @@ -55,9 +63,14 @@ public struct SelectCatCore { case .tapNextButton: return .run { send in await send(._selectCatRequest) } + case .moveToNamingCat: + guard let selectedCat = state.selectedCat else { return .none } + state.namingCat = NamingCatCore.State(selectedCat: selectedCat) + return .none + case ._fetchCatListRequest: return .run { send in - let response = try await userService.fetchCatLists(apiClient: apiClient) + let response = try await catService.fetchCatLists(apiClient: apiClient) await send(._fetchCatListResponse(response)) } @@ -77,11 +90,14 @@ public struct SelectCatCore { _ = try await userService.selectCat(no: selectedCat.no, apiClient: apiClient) // user notification 요청 _ = try await userNotificationClient.requestAuthorization([.alert, .badge, .sound]) - // go to naming cat + await send(.moveToNamingCat) } case .binding: return .none + + case .namingCat: + return .none } } } diff --git a/Projects/Feature/OnboardingFeature/Sources/SelectCat/SelectCatView.swift b/Projects/Feature/OnboardingFeature/Sources/SelectCat/SelectCatView.swift index 5ce48e1..a0a4e16 100644 --- a/Projects/Feature/OnboardingFeature/Sources/SelectCat/SelectCatView.swift +++ b/Projects/Feature/OnboardingFeature/Sources/SelectCat/SelectCatView.swift @@ -73,11 +73,16 @@ public struct SelectCatView: View { } .padding(.horizontal, 20) } - .onAppear { store.send(.onAppear) } .background { Alias.Color.Background.primary .ignoresSafeArea() } + .navigationDestination( + item: $store.scope(state: \.namingCat, action: \.namingCat) + ) { store in + NamingCatView(store: store) + } + .onAppear { store.send(.onAppear) } } } diff --git a/Projects/Feature/SplashFeature/Sources/SplashCore.swift b/Projects/Feature/SplashFeature/Sources/SplashCore.swift index 39b01e5..22e6e0b 100644 --- a/Projects/Feature/SplashFeature/Sources/SplashCore.swift +++ b/Projects/Feature/SplashFeature/Sources/SplashCore.swift @@ -34,6 +34,7 @@ public struct SplashCore { public init() { } let deviceIDKey = "mohanyang_keychain_device_id" + let isOnboardedKey = "mohanyang_userdefaults_isOnboarded" @Dependency(APIClient.self) var apiClient @Dependency(AuthService.self) var authService @@ -75,15 +76,14 @@ extension SplashCore { ) try await Task.sleep(for: .seconds(1.5)) - - userDefaultsClient.boolForKey(UserDefaultsKeys.isOnboarded.rawValue) ? + userDefaultsClient.boolForKey(isOnboardedKey) ? await send(.moveToHome) : await send(.moveToOnboarding) } } private func getDeviceUUID() -> String { guard let uuid = UIDevice.current.identifierForVendor?.uuidString, - keychainClient.create(key: "mohanyang_keychain_device_id", data: uuid) else { + keychainClient.create(key: deviceIDKey, data: uuid) else { return "" } return uuid diff --git a/Projects/Shared/DesignSystem/Sources/Component/InputField/InputField.swift b/Projects/Shared/DesignSystem/Sources/Component/InputField/InputField.swift index c51f2e2..53e36d9 100644 --- a/Projects/Shared/DesignSystem/Sources/Component/InputField/InputField.swift +++ b/Projects/Shared/DesignSystem/Sources/Component/InputField/InputField.swift @@ -27,7 +27,6 @@ public struct InputField: View { self._fieldError = fieldError } - // MARK: Placeholder Color를 TextFieldStyle 내부에서 변경하는 방법 Plz... public var body: some View { VStack(spacing: Alias.Spacing.small) { TextField( diff --git a/Projects/Shared/Utils/Sources/Extensions/String+Extension.swift b/Projects/Shared/Utils/Sources/Extensions/String+Extension.swift index 9bdffa2..75b0acf 100644 --- a/Projects/Shared/Utils/Sources/Extensions/String+Extension.swift +++ b/Projects/Shared/Utils/Sources/Extensions/String+Extension.swift @@ -19,4 +19,11 @@ extension String { .joined() return camelCased } + + public func containsWhitespaceOrSpecialCharacters() -> Bool { + let pattern = "[^a-zA-Z0-9가-힣ㄱ-ㅎㅏ-ㅣ]" + let regex = try! NSRegularExpression(pattern: pattern) + let range = NSRange(location: 0, length: self.utf16.count) + return regex.firstMatch(in: self, options: [], range: range) != nil + } }