diff --git a/Data/Sources/Dependencies/DataSource.swift b/Data/Sources/Dependencies/DataSource.swift index c0227b6..6d10c24 100644 --- a/Data/Sources/Dependencies/DataSource.swift +++ b/Data/Sources/Dependencies/DataSource.swift @@ -11,9 +11,7 @@ import Dependencies // MARK: - VersionDataSource DI public enum VersionDataSourceKey: TestDependencyKey { - #if DEBUG public static let testValue: VersionDataSource = EmptyVersionDataSource() - #endif } public extension DependencyValues { @@ -26,9 +24,7 @@ public extension DependencyValues { // MARK: - RecipeDataSource DI public enum RecipeDataSourceKey: TestDependencyKey { - #if DEBUG public static let testValue: RecipeDataSource = EmptyRecipeDataSource() - #endif } public extension DependencyValues { @@ -41,9 +37,7 @@ public extension DependencyValues { // MARK: - RecipePreviewDataSource DI public enum RecipePreviewDataSourceKey: TestDependencyKey { - #if DEBUG public static let testValue: RecipePreviewDataSource = EmptyRecipePreviewDataSource() - #endif } public extension DependencyValues { @@ -56,9 +50,7 @@ public extension DependencyValues { // MARK: - OnboardingDataSource DI public enum OnboardingDataSourceKey: TestDependencyKey { - #if DEBUG public static let testValue: OnboardingDataSource = EmptyOnboardingDataSource() - #endif } public extension DependencyValues { @@ -71,9 +63,7 @@ public extension DependencyValues { // MARK: - LanguageDataSource public enum LanguageDataSourceKey: TestDependencyKey { - #if DEBUG public static let testValue: LanguageDataSource = EmptyLanguageDataSource() - #endif } public extension DependencyValues { diff --git a/Data/Sources/Dependencies/Repository.swift b/Data/Sources/Dependencies/Repository.swift index 29c2b50..4190678 100644 --- a/Data/Sources/Dependencies/Repository.swift +++ b/Data/Sources/Dependencies/Repository.swift @@ -12,9 +12,7 @@ import Dependencies // MARK: - IntroRepository DI public enum IntroRepositoryKey: TestDependencyKey { - #if DEBUG public static let testValue: IntroRepository = EmptyIntroRepository() - #endif } public extension DependencyValues { @@ -27,9 +25,7 @@ public extension DependencyValues { // MARK: - RecipeConversionRepository DI public enum RecipeConversionRepositoryKey: TestDependencyKey { - #if DEBUG public static let testValue: RecipeConversionRepository = EmptyRecipeConversionRepository() - #endif } public extension DependencyValues { @@ -42,9 +38,7 @@ public extension DependencyValues { // MARK: - RecipeDetailRepository DI public enum RecipeDetailRepositoryKey: TestDependencyKey { - #if DEBUG public static let testValue: RecipeDetailRepository = EmptyRecipeDetailRepository() - #endif } public extension DependencyValues { @@ -57,9 +51,7 @@ public extension DependencyValues { // MARK: - RecipePreviewRepository DI public enum RecipePreviewRepositoryKey: TestDependencyKey { - #if DEBUG public static let testValue: RecipePreviewRepository = EmptyRecipePreviewRepository() - #endif } public extension DependencyValues { @@ -72,9 +64,7 @@ public extension DependencyValues { // MARK: - OnboardingRepository DI public enum OnboardingRepositoryKey: TestDependencyKey { - #if DEBUG public static let testValue: OnboardingRepository = EmptyOnboardingRepository() - #endif } public extension DependencyValues { @@ -87,9 +77,7 @@ public extension DependencyValues { // MARK: - LanguageRepository DI public enum LanguageRepositoryKey: TestDependencyKey { - #if DEBUG public static let testValue: LanguageRepository = EmptyLanguageRepository() - #endif } public extension DependencyValues { diff --git a/Domain/Sources/Dependencies/UseCase.swift b/Domain/Sources/Dependencies/UseCase.swift index 4a71f2b..22c7bfe 100644 --- a/Domain/Sources/Dependencies/UseCase.swift +++ b/Domain/Sources/Dependencies/UseCase.swift @@ -10,9 +10,7 @@ import Dependencies // MARK: - IntroUseCase DI public enum IntroUseCaseKey: TestDependencyKey { - #if DEBUG public static let testValue: any IntroUseCase = EmptyIntroUseCase() - #endif } public extension DependencyValues { @@ -25,9 +23,7 @@ public extension DependencyValues { // MARK: - FetchConvertedPreviewUseCase DI public enum FetchConvertedRecipeIDUseCaseKey: TestDependencyKey { - #if DEBUG public static let testValue: FetchConvertedRecipeIDUseCase = EmptyFetchConvertedPreviewUseCase() - #endif } public extension DependencyValues { @@ -40,9 +36,7 @@ public extension DependencyValues { // MARK: - FetchRecipeDetailUseCase DI public enum FetchRecipeDetailUseCaseKey: TestDependencyKey { - #if DEBUG public static let testValue: FetchRecipeDetailUseCase = EmptyFetchRecipeDetailUseCase() - #endif } public extension DependencyValues { @@ -55,9 +49,7 @@ public extension DependencyValues { // MARK: - FetchRecipePreviewUseCase DI public enum FetchRecipePreviewUseCaseKey: TestDependencyKey { - #if DEBUG public static let testValue: FetchRecipePreviewUseCase = EmptyFetchRecipePreviewUseCase() - #endif } public extension DependencyValues { @@ -70,9 +62,7 @@ public extension DependencyValues { // MARK: - FetchRecipePreviewUseCase DI public enum FetchRecipePreviewsUseCaseKey: TestDependencyKey { - #if DEBUG public static let testValue: FetchRecipePreviewsUseCase = EmptyFetchRecipePreviewsUseCase() - #endif } public extension DependencyValues { @@ -85,9 +75,7 @@ public extension DependencyValues { // MARK: - LaunchOnboardingCheckUseCase DI public enum LaunchOnboardingCheckUseCaseKey: TestDependencyKey { - #if DEBUG public static let testValue: LaunchOnboardingCheckUseCase = EmptyLaunchOnboardingCheckUseCase() - #endif } public extension DependencyValues { @@ -100,9 +88,7 @@ public extension DependencyValues { // MARK: - LaunchOnboardingCompleteUseCase DI public enum LaunchOnboardingCompleteUseCaseKey: TestDependencyKey { - #if DEBUG public static let testValue: LaunchOnboardingCompleteUseCase = EmptyLaunchOnboardingCompleteUseCase() - #endif } public extension DependencyValues { @@ -115,9 +101,7 @@ public extension DependencyValues { // MARK: - CookingOnboardingCheckUseCase DI public enum CookingOnboardingCheckUseCaseKey: TestDependencyKey { - #if DEBUG public static let testValue: CookingOnboardingCheckUseCase = EmptyCookingOnboardingCheckUseCase() - #endif } public extension DependencyValues { @@ -130,9 +114,7 @@ public extension DependencyValues { // MARK: - CookingOnboardingCompleteUseCase DI public enum CookingOnboardingCompleteUseCaseKey: TestDependencyKey { - #if DEBUG public static let testValue: CookingOnboardingCompleteUseCase = EmptyCookingOnboardingCompleteUseCase() - #endif } public extension DependencyValues { @@ -145,9 +127,7 @@ public extension DependencyValues { // MARK: - FetchUILanguageUseCase DI public enum FetchUILanguageUseCaseKey: TestDependencyKey { - #if DEBUG public static let testValue: FetchUILanguageUseCase = EmptyFetchUILanguageUseCase() - #endif } public extension DependencyValues { @@ -160,9 +140,7 @@ public extension DependencyValues { // MARK: - SetUILanguageUseCase DI public enum SetUILanguageUseCaseKey: TestDependencyKey { - #if DEBUG public static var testValue: SetUILanguageUseCase = EmptySetUILanguageUseCase() - #endif } public extension DependencyValues { @@ -175,9 +153,7 @@ public extension DependencyValues { // MARK: - FetchConversionLanguageUseCase DI public enum FetchConversionLanguageUseCaseKey: TestDependencyKey { - #if DEBUG public static var testValue: FetchConversionLanguageUseCase = EmptyFetchConversionLanguageUseCase() - #endif } public extension DependencyValues { @@ -192,9 +168,7 @@ public extension DependencyValues { // MARK: - SetConversionLanguageUseCase DI public enum SetConversionLanguageUseCaseKey: TestDependencyKey { - #if DEBUG public static var testValue: SetConversionLanguageUseCase = EmptySetConversionLanguageUseCase() - #endif } public extension DependencyValues { @@ -207,9 +181,7 @@ public extension DependencyValues { // MARK: - FetchOnboardingImageUseCase DI public enum FetchOnboardingImageUseCaseKey: TestDependencyKey { - #if DEBUG public static var testValue: FetchOnboardingImageUseCase = EmptyFetchOnboardingImageUseCase() - #endif } public extension DependencyValues { @@ -222,9 +194,7 @@ public extension DependencyValues { // MARK: - FetchCompletedRecipeConversionIDsUseCase DI public enum FetchCompletedRecipeConversionIDsUseCaseKey: TestDependencyKey { - #if DEBUG public static var testValue: FetchCompletedRecipeConversionIDsUseCase = EmptyFetchCompletedRecipeConversionIDsUseCase() - #endif } public extension DependencyValues { @@ -237,9 +207,7 @@ public extension DependencyValues { // MARK: - FetchRecipeStateUseCase DI public enum FetchRecipeStateUseCaseKey: TestDependencyKey { - #if DEBUG public static var testValue: FetchRecipeStateUseCase = EmptyFetchRecipeStateUseCase() - #endif } public extension DependencyValues { @@ -252,9 +220,7 @@ public extension DependencyValues { // MARK: - DeleteRecipeUseCase DI public enum DeleteRecipeUseCaseKey: TestDependencyKey { - #if DEBUG public static var testValue: DeleteRecipeUseCase = EmptyDeleteRecipeUseCase() - #endif } public extension DependencyValues { @@ -267,9 +233,7 @@ public extension DependencyValues { // MARK: - ClearRecipePreviewCacheUseCase DI public enum ClearRecipePreviewCacheUseCaseKey: TestDependencyKey { - #if DEBUG public static var testValue: ClearRecipePreviewCacheUseCase = EmptyClearRecipePreviewCacheUseCase() - #endif } public extension DependencyValues { diff --git a/Login/Sources/Dependencies/LoginManagerDependency.swift b/Login/Sources/Dependencies/LoginManagerDependency.swift index a0321b2..e4a3c35 100644 --- a/Login/Sources/Dependencies/LoginManagerDependency.swift +++ b/Login/Sources/Dependencies/LoginManagerDependency.swift @@ -9,9 +9,7 @@ import Dependencies import Foundation public enum LoginManagerKey: TestDependencyKey { - #if DEBUG public static let testValue: LoginManager = EmptyLoginManager() - #endif } public extension DependencyValues { diff --git a/Presentation/Sources/Feature/ConversionProcess/ConversionProcessReducer.swift b/Presentation/Sources/Feature/ConversionProcess/ConversionProcessReducer.swift index d79f9c3..7a5533f 100644 --- a/Presentation/Sources/Feature/ConversionProcess/ConversionProcessReducer.swift +++ b/Presentation/Sources/Feature/ConversionProcess/ConversionProcessReducer.swift @@ -11,6 +11,7 @@ import Entity import Foundation import UIKit import Logger +import Minifire @Reducer struct ConversionProcessReducer { @@ -38,6 +39,9 @@ struct ConversionProcessReducer { case pollResponse(RecipeStateViewData) case loadCompletedRecipe + case appDidEnterBackground + case appWillEnterForeground + case showError(ErrorDisplayType) case clearError @@ -87,6 +91,13 @@ struct ConversionProcessReducer { } } + case .appDidEnterBackground: + return .send(.stopPolling) + + case .appWillEnterForeground: + guard state.recipeConversionID != nil else { return .none } + return .send(.startPolling) + case .showError(let errorType): state.errorState.showError(errorType) return .none @@ -134,7 +145,7 @@ private extension ConversionProcessReducer { func reduceStartPolling() -> Effect { return .run { send in for await _ in Timer - .publish(every: 5, on: .main, in: .common) + .publish(every: 5, on: .main, in: .default) .autoconnect() .values { await send(.pollTick) @@ -232,3 +243,9 @@ extension ConversionProcessReducer { case navigateToFailed } } + +extension MFError: @retroactive LocalizedError { + public var errorDescription: String? { + self.description + } +} diff --git a/Presentation/Sources/Feature/ConversionProcess/ConversionProcessView.swift b/Presentation/Sources/Feature/ConversionProcess/ConversionProcessView.swift index cce6a6e..dfb5f27 100644 --- a/Presentation/Sources/Feature/ConversionProcess/ConversionProcessView.swift +++ b/Presentation/Sources/Feature/ConversionProcess/ConversionProcessView.swift @@ -12,6 +12,7 @@ import SwiftUI struct ConversionProcessView: View { @Bindable var store: StoreOf + @Environment(\.scenePhase) private var scenePhase var body: some View { VStack(spacing: 0) { @@ -26,6 +27,20 @@ struct ConversionProcessView: View { .onAppear { store.send(.onAppear) } + .onChange(of: scenePhase) { oldPhase, newPhase in + switch newPhase { + case .background: + print(">>> background") + store.send(.appDidEnterBackground) + case .active: + print(">>> foreground") + store.send(.appWillEnterForeground) + case .inactive: + break + @unknown default: + break + } + } .errorDialog( title: store.errorState.dialogTitle, message: store.errorState.dialogMessage, diff --git a/Presentation/Sources/Feature/Home/HomeReducer.swift b/Presentation/Sources/Feature/Home/HomeReducer.swift index d0b39a9..cf13802 100644 --- a/Presentation/Sources/Feature/Home/HomeReducer.swift +++ b/Presentation/Sources/Feature/Home/HomeReducer.swift @@ -20,9 +20,10 @@ struct HomeReducer { @Dependency(\.fetchCompletedRecipeConversionIDsUseCase) var fetchCompletedRecipeConversionIDsUseCase @Dependency(\.clearRecipePreviewCacheUseCase) var clearRecipePreviewCacheUseCase @Dependency(\.logger) var logger + @Dependency(\.continuousClock) var clock @ObservableState - struct State { + struct State: Equatable { let titleIconName: String = "title_icon" let pageSize: Int = 3 var cursorId: Int? @@ -35,7 +36,7 @@ struct HomeReducer { var errorState = ErrorHandlingState() } - enum Action { + enum Action: Equatable { case onAppear case addRecipe(RecipeListItem) case updateRecipe(RecipeListItem) @@ -161,11 +162,9 @@ struct HomeReducer { } case .startPolling: - return .run { send in - for await _ in Timer - .publish(every: 5, on: .main, in: .common) - .autoconnect() - .values { + return .run { [clock] send in + while true { + try await clock.sleep(for: .seconds(5)) await send(.pollTick) } } @@ -317,7 +316,7 @@ extension HomeReducer { } } -private extension RecipePreviewVO { +extension RecipePreviewVO { func toRecipeListItem() -> RecipeListItem { return RecipeListItem( recipeID: self.recipeID, diff --git a/Presentation/Sources/Feature/Home/IncompleteRecipeStore.swift b/Presentation/Sources/Feature/Home/IncompleteRecipeStore.swift index 8b0204c..b5ee1ea 100644 --- a/Presentation/Sources/Feature/Home/IncompleteRecipeStore.swift +++ b/Presentation/Sources/Feature/Home/IncompleteRecipeStore.swift @@ -9,7 +9,11 @@ import Dependencies import Foundation actor IncompleteRecipeStore { - private var ids: Set = [] + private var ids: Set + + init(ids: Set = []) { + self.ids = ids + } func set(_ ids: Set) { self.ids = ids diff --git a/Presentation/Tests/HomeReducerTests.swift b/Presentation/Tests/HomeReducerTests.swift new file mode 100644 index 0000000..e455055 --- /dev/null +++ b/Presentation/Tests/HomeReducerTests.swift @@ -0,0 +1,262 @@ +import XCTest +import ComposableArchitecture +import Entity +import Domain +@testable import Presentation +@testable import Logger + +final class HomeReducerTests: XCTestCase { + + // MARK: - 폴링 시작 테스트 + + @MainActor + func test_checkIfNeedsPolling_시작조건_완료되지않은레시피가있을때() async { + let clock = TestClock() + let mockLogger = MockLogger() + + // Given: 완료되지 않은 레시피가 이미 상태에 있는 경우 + let incompletedRecipe = RecipeListItem( + recipeID: 1, + thumbnail: nil, + title: "테스트 레시피", + servings: 2, + ingredients: "재료1 100g", + state: .inProgress + ) + + let store = TestStore( + initialState: HomeReducer.State( + recipes: [RecipeListItemReducer.State(item: incompletedRecipe)] + ) + ) { + HomeReducer() + } withDependencies: { + $0.continuousClock = clock + $0.logger = mockLogger + $0.incompletedRecipeIDStore = IncompleteRecipeStore() + } + store.exhaustivity = .off + + // When: 폴링 체크 실행 + await store.send(.checkIfNeedsPolling) + + // Then: 폴링이 시작되어야 함 + await store.receive(.startPolling, timeout: .seconds(2)) + } + + @MainActor + func test_checkIfNeedsPolling_시작안함_모든레시피가완료됨() async { + // Given: 모든 레시피가 완료된 상태 + let completedRecipe = RecipeListItem( + recipeID: 1, + thumbnail: nil, + title: "완료된 레시피", + servings: 2, + ingredients: "재료", + state: .completed + ) + + let store = TestStore( + initialState: HomeReducer.State( + recipes: [RecipeListItemReducer.State(item: completedRecipe)] + ) + ) { + HomeReducer() + } + + // When: 폴링 체크 실행 + await store.send(.checkIfNeedsPolling) + + // Then: 폴링이 시작되지 않아야 함 (추가 액션 없음) + } + + // MARK: - 폴링 타이머 테스트 + + @MainActor + func test_startPolling_타이머설정_5초간격() async { + let clock = TestClock() + + let store = TestStore(initialState: HomeReducer.State()) { + HomeReducer() + } withDependencies: { + $0.continuousClock = clock + $0.incompletedRecipeIDStore = IncompleteRecipeStore() + } + store.exhaustivity = .off + + // When: 폴링 시작 + await store.send(.startPolling) + + // 5초 진행 + await clock.advance(by: .seconds(5)) + await store.receive(.pollTick) + + // 추가 5초 진행 (아이디가 비었으므로 cancel) + await clock.advance(by: .seconds(5)) + await store.receive(.cancelTick) + } + + // MARK: - API 호출 테스트 + + @MainActor + func test_pollTick_API호출성공_완료된레시피목록반환() async { + let mockPreviewUseCase = MockFetchRecipePreviewUseCase() + let mockUseCase = MockFetchCompletedRecipeConversionIDsUseCase() + mockUseCase.resultToReturn = .success([1, 2]) + + let store = TestStore(initialState: HomeReducer.State()) { + HomeReducer() + } withDependencies: { + $0.fetchRecipePreviewUseCase = mockPreviewUseCase + $0.fetchCompletedRecipeConversionIDsUseCase = mockUseCase + $0.incompletedRecipeIDStore = IncompleteRecipeStore(ids: [1, 2, 3]) + } + store.exhaustivity = .off + + // When: 폴링 틱 실행 + await store.send(.pollTick) + + // Then: 완료된 레시피 ID 응답 처리 + await store.receive(.completedRecipeIDsRepsponse([1, 2])) + } + + // MARK: - 완료된 레시피 처리 테스트 + + @MainActor + func test_completedRecipeIDsResponse_레시피상태업데이트() async { + let mockPreviewUseCase = MockFetchRecipePreviewUseCase() + let updatedRecipe = RecipePreviewVO( + recipeID: 1, + thumbnailURL: nil, + title: "업데이트된 레시피", + servings: 2, + ingredients: [], + state: "COMPLETED" + ) + mockPreviewUseCase.resultToReturn = .success(updatedRecipe) + + let store = TestStore(initialState: HomeReducer.State()) { + HomeReducer() + } withDependencies: { + $0.fetchRecipePreviewUseCase = mockPreviewUseCase + $0.incompletedRecipeIDStore = IncompleteRecipeStore() + } + + // When: 완료된 레시피 ID 처리 + await store.send(.completedRecipeIDsRepsponse([1])) + + // Then: 레시피 상태가 업데이트됨 + await store.receive(.updateRecipe(updatedRecipe.toRecipeListItem())) + } + + // MARK: - 폴링 중단 테스트 + + @MainActor + func test_cancelTick_폴링중단_타이머정리() async { + let store = TestStore(initialState: HomeReducer.State()) { + HomeReducer() + } + + // When: 폴링 중단 + await store.send(.cancelTick) + + // Then: 타이머가 정리되어야 함 (메모리 누수 방지) + // TestStore가 자동으로 취소된 이펙트를 검증함 + } + + // MARK: - 폴링 빈도 제한 테스트 + + @MainActor + func test_pollTick_빈ID목록_자동폴링중단() async { + let mockStore = IncompleteRecipeStore() + + let store = TestStore(initialState: HomeReducer.State()) { + HomeReducer() + } withDependencies: { + $0.incompletedRecipeIDStore = mockStore + } + + // When: 완료되지 않은 레시피가 없는 상태에서 폴링 + await store.send(.pollTick) + + // Then: 자동으로 폴링 중단 + await store.receive(.cancelTick) + } +} + +// 완료된 레시피 변환 ID 조회 UseCase Mock +class MockFetchCompletedRecipeConversionIDsUseCase: FetchCompletedRecipeConversionIDsUseCase { + var resultToReturn: Result<[Int64], Error> = .success([]) + + func execute(ids: [Int64]) async throws -> [Int64] { + switch resultToReturn { + case .success(let ids): + return ids + case .failure(let error): + throw error + } + } +} + +class MockFetchRecipePreviewsUseCase: FetchRecipePreviewsUseCase { + var resultToReturn: Result = .success( + RecipeListVO( + previews: [], + hasNext: false, + cursorId: 0 + ) + ) + + func execute(cursorId: Int?, size: Int) async -> Result { + return resultToReturn + } +} + +class MockLogger: Logger { + func track(_ event: LogEvent) { } +} + +// MARK: - Extensions for Tests +extension RecipePreviewVO { + func toRecipeListItem() -> RecipeListItem { + return RecipeListItem( + recipeID: self.recipeID, + thumbnail: self.thumbnailURL, + title: self.title, + servings: self.servings, + ingredients: self.ingredients.map { "\($0.name) \($0.amount)" }.joined(separator: ", "), + state: self.state.toRecipeStateViewData() + ) + } +} + +extension RecipeStateVO { + func toRecipeStateViewData() -> RecipeStateViewData { + switch self { + case .completed: + return .completed + case .failed, .cancelled: + return .failed + default: + return .inProgress + } + } +} + +// 레시피 미리보기 조회 UseCase Mock +class MockFetchRecipePreviewUseCase: FetchRecipePreviewUseCase { + var resultToReturn: Result = .success( + RecipePreviewVO( + recipeID: 1, + thumbnailURL: nil, + title: "Test", + servings: 1, + ingredients: [], + state: "COMPLETED" + ) + ) + + func execute(recipeID: Int64) async -> Result { + return resultToReturn + } +} diff --git a/RecipeIt/Project.swift b/RecipeIt/Project.swift index 497db29..c71b69b 100644 --- a/RecipeIt/Project.swift +++ b/RecipeIt/Project.swift @@ -6,11 +6,21 @@ let project = Project( // 메인 앱 - Domain 모듈에 의존 .target( name: "RecipeIt", - destinations: .iOS, + destinations: [.iPhone], product: .app, bundleId: "com.puhahang.recipe-it", infoPlist: .extendingDefault( with: [ + "CFBundleShortVersionString": "1.1", + "FacebookAppID": "769014272186685", + "FacebookDisplayName": "Recipe-it", + "CFBundleURLTypes": .array([ + .dictionary([ + "CFBundleURLSchemes": .array([ + .string("fb769014272186685") + ]) + ]) + ]), "UILaunchScreen": [ "UIColorName": "", "UIImageName": "", @@ -28,7 +38,7 @@ let project = Project( .glob(pattern: "Resources/*.lproj/**") ], entitlements: .dictionary([ - "aps-environment": "development" + "aps-environment": "production" ]), scripts: [ .pre(script: """ @@ -56,5 +66,64 @@ let project = Project( .project(target: "Presentation", path: "../Presentation") ] ), + .target( + name: "RecipeIt-Mock", + destinations: [.iPhone], + product: .app, + bundleId: "com.puhahang.recipe-it.mock", + infoPlist: .extendingDefault( + with: [ + "UILaunchScreen": [ + "UIColorName": "", + "UIImageName": "", + ], + "NSSpeechRecognitionUsageDescription": "음성 인식을 사용하기 위해 필요합니다.", + "NSMicrophoneUsageDescription": "음성 입력을 위해 마이크 접근 권한이 필요합니다.", + "UIBackgroundModes": ["remote-notification"], + "FirebaseAppDelegateProxyEnabled": false, + "UIUserInterfaceStyle": "Light" + ] + ), + sources: ["Sources/**"], + resources: [ + "Resources/**", + .glob(pattern: "Resources/*.lproj/**") + ], + entitlements: .dictionary([ + "aps-environment": "production" + ]), + dependencies: [ + .project(target: "Domain", path: "../Domain"), + .project(target: "Data", path: "../Data"), + .project(target: "Shared", path: "../Shared/Shared"), + .project(target: "Login", path: "../Login"), + .project(target: "Presentation", path: "../Presentation") + ], + settings: .settings( + base: [ + "SWIFT_ACTIVE_COMPILATION_CONDITIONS": "USE_MOCK_REPOSITORY" + ] + ) + ), + ], + schemes: [ + .scheme( + name: "RecipeIt", + shared: true, + buildAction: .buildAction(targets: [.target("RecipeIt")]), + runAction: .runAction( + configuration: .debug, + executable: .target("RecipeIt") + ) + ), + .scheme( + name: "RecipeIt-Mock", + shared: true, + buildAction: .buildAction(targets: [.target("RecipeIt-Mock")]), + runAction: .runAction( + configuration: .debug, + executable: .target("RecipeIt-Mock") + ) + ) ] ) diff --git a/RecipeIt/Resources/Assets.xcassets/AppIcon.appiconset/100.png b/RecipeIt/Resources/Assets.xcassets/AppIcon.appiconset/100.png new file mode 100644 index 0000000..bc67c7d Binary files /dev/null and b/RecipeIt/Resources/Assets.xcassets/AppIcon.appiconset/100.png differ diff --git a/RecipeIt/Resources/Assets.xcassets/AppIcon.appiconset/144.png b/RecipeIt/Resources/Assets.xcassets/AppIcon.appiconset/144.png new file mode 100644 index 0000000..2ac008a Binary files /dev/null and b/RecipeIt/Resources/Assets.xcassets/AppIcon.appiconset/144.png differ diff --git a/RecipeIt/Resources/Assets.xcassets/AppIcon.appiconset/152.png b/RecipeIt/Resources/Assets.xcassets/AppIcon.appiconset/152.png new file mode 100644 index 0000000..db20c83 Binary files /dev/null and b/RecipeIt/Resources/Assets.xcassets/AppIcon.appiconset/152.png differ diff --git a/RecipeIt/Resources/Assets.xcassets/AppIcon.appiconset/167.png b/RecipeIt/Resources/Assets.xcassets/AppIcon.appiconset/167.png new file mode 100644 index 0000000..f09a119 Binary files /dev/null and b/RecipeIt/Resources/Assets.xcassets/AppIcon.appiconset/167.png differ diff --git a/RecipeIt/Resources/Assets.xcassets/AppIcon.appiconset/20.png b/RecipeIt/Resources/Assets.xcassets/AppIcon.appiconset/20.png new file mode 100644 index 0000000..36c3ac3 Binary files /dev/null and b/RecipeIt/Resources/Assets.xcassets/AppIcon.appiconset/20.png differ diff --git a/RecipeIt/Resources/Assets.xcassets/AppIcon.appiconset/50.png b/RecipeIt/Resources/Assets.xcassets/AppIcon.appiconset/50.png new file mode 100644 index 0000000..a07832b Binary files /dev/null and b/RecipeIt/Resources/Assets.xcassets/AppIcon.appiconset/50.png differ diff --git a/RecipeIt/Resources/Assets.xcassets/AppIcon.appiconset/72.png b/RecipeIt/Resources/Assets.xcassets/AppIcon.appiconset/72.png new file mode 100644 index 0000000..8181a3d Binary files /dev/null and b/RecipeIt/Resources/Assets.xcassets/AppIcon.appiconset/72.png differ diff --git a/RecipeIt/Resources/Assets.xcassets/AppIcon.appiconset/76.png b/RecipeIt/Resources/Assets.xcassets/AppIcon.appiconset/76.png new file mode 100644 index 0000000..deab042 Binary files /dev/null and b/RecipeIt/Resources/Assets.xcassets/AppIcon.appiconset/76.png differ diff --git a/RecipeIt/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/RecipeIt/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json index af727e0..4fdf882 100644 --- a/RecipeIt/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/RecipeIt/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -66,6 +66,84 @@ "scale" : "3x", "size" : "60x60" }, + { + "filename" : "20.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "20x20" + }, + { + "filename" : "40.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "20x20" + }, + { + "filename" : "29.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "29x29" + }, + { + "filename" : "58.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "40.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "40x40" + }, + { + "filename" : "80.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "40x40" + }, + { + "filename" : "50.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "50x50" + }, + { + "filename" : "100.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "50x50" + }, + { + "filename" : "72.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "72x72" + }, + { + "filename" : "144.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "72x72" + }, + { + "filename" : "76.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "76x76" + }, + { + "filename" : "152.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "76x76" + }, + { + "filename" : "167.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "83.5x83.5" + }, { "filename" : "1024.png", "idiom" : "ios-marketing", diff --git a/RecipeIt/Sources/AppDelegate.swift b/RecipeIt/Sources/AppDelegate.swift index df2a8bf..8b39c91 100644 --- a/RecipeIt/Sources/AppDelegate.swift +++ b/RecipeIt/Sources/AppDelegate.swift @@ -11,6 +11,7 @@ import Firebase import Login import UIKit import UserNotifications +import FacebookCore class AppDelegate: NSObject, UIApplicationDelegate { func application(_ application: UIApplication, @@ -20,10 +21,27 @@ class AppDelegate: NSObject, UIApplicationDelegate { Messaging.messaging().delegate = self application.registerForRemoteNotifications() + ApplicationDelegate.shared.application( + application, + didFinishLaunchingWithOptions: launchOptions + ) + return true } } +extension AppDelegate { + func application(_ app: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool { + ApplicationDelegate.shared.application(app, open: url, options: options) + } + + func applicationDidBecomeActive(_ application: UIApplication) { + AppEvents.shared.activateApp() + } +} + extension AppDelegate: UNUserNotificationCenterDelegate { func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { diff --git a/RecipeIt/Sources/DI/Repository.swift b/RecipeIt/Sources/DI/Repository.swift index 5e4fd4c..6f1e683 100644 --- a/RecipeIt/Sources/DI/Repository.swift +++ b/RecipeIt/Sources/DI/Repository.swift @@ -10,6 +10,14 @@ import Foundation import Data import Domain +private func shouldUseMockRepository() -> Bool { + #if USE_MOCK_REPOSITORY + return true + #else + return false + #endif +} + extension IntroRepositoryKey: @retroactive DependencyKey { public static let liveValue: any IntroRepository = { @Dependency(\.versionDataSource) var versionDataSource @@ -19,29 +27,43 @@ extension IntroRepositoryKey: @retroactive DependencyKey { extension RecipeConversionRepositoryKey: @retroactive DependencyKey { public static let liveValue: any RecipeConversionRepository = { + if shouldUseMockRepository() { + return MockRecipeConversionRepositoryImpl() + } + @Dependency(\.recipeDataSource) var recipeDataSource return RecipeConversionRepositoryImpl(dataSource: recipeDataSource) }() } extension RecipeDetailRepositoryKey: @retroactive DependencyKey { - public static let liveValue: any RecipeDetailRepository = { + public static let liveValue: any RecipeDetailRepository = { + if shouldUseMockRepository() { + return MockRecipeDetailRepositoryImpl() + } + @Dependency(\.recipeDataSource) var recipeDataSource return RecipeDetailRepositoryImpl(dataSource: recipeDataSource) }() -// public static var liveValue: any RecipeDetailRepository = MockRecipeDetailRepositoryImpl() } extension RecipePreviewRepositoryKey: @retroactive DependencyKey { public static let liveValue: any RecipePreviewRepository = { + if shouldUseMockRepository() { + return MockRecipePreviewRepositoryImpl() + } + @Dependency(\.recipePreviewDataSource) var recipePreviewDataSource return RecipePreviewRepositoryImpl(dataSource: recipePreviewDataSource) }() -// public static var liveValue: any RecipePreviewRepository = MockRecipePreviewRepositoryImpl() } extension OnboardingRepositoryKey: @retroactive DependencyKey { public static let liveValue: any OnboardingRepository = { + if shouldUseMockRepository() { + return MockOnboardingRepositoryImpl() + } + @Dependency(\.onboardingDataSource) var onboardingDataSource return OnboardingRepositoryImpl(dataSource: onboardingDataSource) }() diff --git a/Shared/Modules/Audio/Sources/AudioServiceImpl.swift b/Shared/Modules/Audio/Sources/AudioServiceImpl.swift index 162d094..47ebcdf 100644 --- a/Shared/Modules/Audio/Sources/AudioServiceImpl.swift +++ b/Shared/Modules/Audio/Sources/AudioServiceImpl.swift @@ -95,32 +95,27 @@ public final class AudioServiceImpl: NSObject, AudioService { self.mode = mode let session = AVAudioSession.sharedInstance() - switch mode { - case .speaker: - try session.setCategory( - .playAndRecord, - mode: .default, - options: [ - .duckOthers, - .defaultToSpeaker, - .allowBluetooth, - .mixWithOthers - ] - ) - - case .earpiece: + // 처음 한 번만 세션 설정 + if !session.categoryOptions.contains(.allowBluetooth) { try session.setCategory( .playAndRecord, mode: .voiceChat, options: [ - .duckOthers, +// .duckOthers, .allowBluetooth, .mixWithOthers ] ) + try session.setActive(true) } - try session.setActive(true) + // 출력 포트만 변경 + switch mode { + case .speaker: + try session.overrideOutputAudioPort(.speaker) + case .earpiece: + try session.overrideOutputAudioPort(.none) + } } public func read(text: String) async { @@ -161,6 +156,7 @@ private extension AudioServiceImpl { } func makeRecognitionRequest() -> SFSpeechAudioBufferRecognitionRequest { + print(">>> request") let request = SFSpeechAudioBufferRecognitionRequest() request.shouldReportPartialResults = true return request diff --git a/Shared/Modules/Audio/Sources/Dependencies/AudioDependency.swift b/Shared/Modules/Audio/Sources/Dependencies/AudioDependency.swift index 5a5c81d..c9e943d 100644 --- a/Shared/Modules/Audio/Sources/Dependencies/AudioDependency.swift +++ b/Shared/Modules/Audio/Sources/Dependencies/AudioDependency.swift @@ -9,9 +9,7 @@ import Dependencies import Foundation public enum AudioServiceKey: TestDependencyKey { - #if DEBUG public static let testValue: AudioService = EmptyAudioService() - #endif } public extension DependencyValues { diff --git a/Shared/Modules/KeyChainHelper/Sources/Dependencies/KeyChainDependency.swift b/Shared/Modules/KeyChainHelper/Sources/Dependencies/KeyChainDependency.swift index d2d4295..5706544 100644 --- a/Shared/Modules/KeyChainHelper/Sources/Dependencies/KeyChainDependency.swift +++ b/Shared/Modules/KeyChainHelper/Sources/Dependencies/KeyChainDependency.swift @@ -9,9 +9,7 @@ import Dependencies import Foundation public enum KeyChainHelperKey: TestDependencyKey { - #if DEBUG public static let testValue: KeyChainHelper = EmptyKeyChainHelper() - #endif } public extension DependencyValues { diff --git a/Shared/Modules/LocaleProvider/Sources/Dependencies/LocaleProviderKey.swift b/Shared/Modules/LocaleProvider/Sources/Dependencies/LocaleProviderKey.swift index 9d15c62..91e2ead 100644 --- a/Shared/Modules/LocaleProvider/Sources/Dependencies/LocaleProviderKey.swift +++ b/Shared/Modules/LocaleProvider/Sources/Dependencies/LocaleProviderKey.swift @@ -9,9 +9,7 @@ import Dependencies import Foundation public enum LocaleProviderKey: TestDependencyKey { - #if DEBUG public static let testValue: LocaleProvider = EmptyLocaleProvider() - #endif } public extension DependencyValues { diff --git a/Shared/Modules/Logger/Sources/Dependencies/LoggerKey.swift b/Shared/Modules/Logger/Sources/Dependencies/LoggerKey.swift index ed96d7d..8d33681 100644 --- a/Shared/Modules/Logger/Sources/Dependencies/LoggerKey.swift +++ b/Shared/Modules/Logger/Sources/Dependencies/LoggerKey.swift @@ -9,9 +9,7 @@ import Dependencies import Foundation public enum LoggerKey: TestDependencyKey { - #if DEBUG public static let testValue: Logger = EmptyLogger() - #endif } public extension DependencyValues { diff --git a/Shared/Shared/Project.swift b/Shared/Shared/Project.swift index 4a07126..cd1a6e1 100644 --- a/Shared/Shared/Project.swift +++ b/Shared/Shared/Project.swift @@ -16,7 +16,8 @@ let project = Project( .project(target: "Logger", path: "../Modules/Logger"), .external(name: "FirebaseCore"), .external(name: "FirebaseInstallations"), - .external(name: "FirebaseMessaging") + .external(name: "FirebaseMessaging"), + .external(name: "FacebookCore") ], settings: .settings( base: [ diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index 921c5c7..b1df892 100644 --- a/Tuist/Package.resolved +++ b/Tuist/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "7b12d0a2d2283fc7f01a7495335331a49ebcc4468e8e5dbc6220595271cae65f", + "originHash" : "5d9429d893a6d589fcb24dd6e6f063c42d8922e58d8d35190161c29ef4b8e1b8", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -55,6 +55,15 @@ "version" : "1.0.3" } }, + { + "identity" : "facebook-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/facebook/facebook-ios-sdk", + "state" : { + "revision" : "619d1772808425faa010d92293b26eeb9bc1d630", + "version" : "17.4.0" + } + }, { "identity" : "firebase-ios-sdk", "kind" : "remoteSourceControl", diff --git a/Tuist/Package.swift b/Tuist/Package.swift index e19a86c..36204e1 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -27,6 +27,7 @@ let package = Package( .package(url: "https://github.com/airbnb/lottie-spm.git", from: "4.5.2"), .package(url: "https://github.com/firebase/firebase-ios-sdk.git", .upToNextMajor(from: "11.15.0")), .package(url: "https://github.com/onevcat/Kingfisher.git", .upToNextMajor(from: "8.0.0")), - .package(url: "https://github.com/amplitude/Amplitude-Swift.git", .upToNextMajor(from: "1.13.9")) + .package(url: "https://github.com/amplitude/Amplitude-Swift.git", .upToNextMajor(from: "1.13.9")), + .package(url: "https://github.com/facebook/facebook-ios-sdk", from: "17.0.0") ] ) diff --git a/clean_architecture_rule.md b/clean_architecture_rule.md new file mode 100644 index 0000000..7620273 --- /dev/null +++ b/clean_architecture_rule.md @@ -0,0 +1,350 @@ +## 3. 코드 생성 요청 가이드 + +제가 "생성해줘", "만들어줘", "추가해줘" 등의 요청을 할 때, 아래의 규칙에 따라 파일 생성 및 수정을 수행해주세요. **`{FeatureName}`** 부분은 제가 요청하는 기능 이름(예: `FavoriteRecipe`)으로, **`{camelCaseFeatureName}`**은 이를 camelCase로 변환한 이름(예: `favoriteRecipe`)으로 대체하여 사용해주세요. + +### 3.1. 전체 기능 생성 (`full`) +**요청 예시**: "Gemini, `FavoriteRecipe`라는 이름으로 전체 기능을 생성해줘." + +**수행 작업**: 아래 목록의 모든 파일을 생성하고, 지정된 파일에 DI 코드를 추가합니다. + +
+전체 기능 생성 시 생성 및 수정될 파일 목록 보기 + +#### 신규 파일 생성 +1. **Entity VO**: `Entity/Sources/VO/{FeatureName}/{FeatureName}VO.swift` +2. **DataSource Protocol**: `Data/Sources/DataSource/{FeatureName}/{FeatureName}DataSource.swift` +3. **DataSource Impl**: `Data/Sources/DataSource/{FeatureName}/{FeatureName}DataSourceImpl.swift` +4. **Empty DataSource**: `Data/Sources/Dependencies/EmptyImpl/DataSource/Empty{FeatureName}DataSource.swift` +5. **Repository Protocol**: `Domain/Sources/Repository/{FeatureName}Repository.swift` +6. **Repository Impl**: `Data/Sources/RepositoryImpl/{FeatureName}/{FeatureName}RepositoryImpl.swift` +7. **Mock Repository**: `Data/Sources/RepositoryImpl/{FeatureName}/Mock{FeatureName}RepositoryImpl.swift` +8. **Empty Repository**: `Data/Sources/Dependencies/EmptyImpl/Repository/Empty{FeatureName}Repository.swift` +9. **UseCase Protocol**: `Domain/Sources/UseCase/{FeatureName}/{FeatureName}UseCase.swift` +10. **UseCase Impl**: `Domain/Sources/UseCase/{FeatureName}/{FeatureName}UseCaseImpl.swift` +11. **Empty UseCase**: `Domain/Sources/Dependencies/EmptyImpl/Empty{FeatureName}UseCase.swift` +12. **DTO**: `Data/Sources/DTO/{FeatureName}DTO.swift` + +#### 기존 파일 수정 (코드 추가) +1. **Data DI**: `Data/Sources/Dependencies/DataSource.swift` +2. **Repository DI**: `Data/Sources/Dependencies/Repository.swift` +3. **UseCase DI**: `Domain/Sources/Dependencies/UseCase.swift` +4. **App DI (DataSource)**: `RecipeIt/Sources/DI/DataSource.swift` +5. **App DI (Repository)**: `RecipeIt/Sources/DI/Repository.swift` +6. **App DI (UseCase)**: `RecipeIt/Sources/DI/UseCase.swift` + +
+ +### 3.2. DataSource 레이어 생성 (`datasource_only`) +**요청 예시**: "Gemini, `UserProfile` DataSource 레이어만 만들어줘." + +**수행 작업**: +
+DataSource 생성 시 생성 및 수정될 파일 목록 보기 + +- **신규 생성**: + - `Data/Sources/DataSource/{FeatureName}/{FeatureName}DataSource.swift` + - `Data/Sources/DataSource/{FeatureName}/{FeatureName}DataSourceImpl.swift` + - `Data/Sources/Dependencies/EmptyImpl/DataSource/Empty{FeatureName}DataSource.swift` + - `Data/Sources/DTO/{FeatureName}DTO.swift` +- **코드 추가**: + - `Data/Sources/Dependencies/DataSource.swift` + - `RecipeIt/Sources/DI/DataSource.swift` +
+ +### 3.3. Repository 레이어 생성 (`repository_only`) +**요청 예시**: "Gemini, `RecipeSearch` Repository만 생성해줘." + +**수행 작업**: +
+Repository 생성 시 생성 및 수정될 파일 목록 보기 + +- **신규 생성**: + - `Domain/Sources/Repository/{FeatureName}Repository.swift` + - `Data/Sources/RepositoryImpl/{FeatureName}/{FeatureName}RepositoryImpl.swift` + - `Data/Sources/RepositoryImpl/{FeatureName}/Mock{FeatureName}RepositoryImpl.swift` + - `Data/Sources/Dependencies/EmptyImpl/Repository/Empty{FeatureName}Repository.swift` +- **코드 추가**: + - `Data/Sources/Dependencies/Repository.swift` + - `RecipeIt/Sources/DI/Repository.swift` +
+ +### 3.4. UseCase 레이어 생성 (`usecase_only`) +**요청 예시**: "Gemini, `Login` UseCase만 만들어줘." + +**수행 작업**: +
+UseCase 생성 시 생성 및 수정될 파일 목록 보기 + +- **신규 생성**: + - `Domain/Sources/UseCase/{FeatureName}/{FeatureName}UseCase.swift` + - `Domain/Sources/UseCase/{FeatureName}/{FeatureName}UseCaseImpl.swift` + - `Domain/Sources/Dependencies/EmptyImpl/Empty{FeatureName}UseCase.swift` +- **코드 추가**: + - `Domain/Sources/Dependencies/UseCase.swift` + - `RecipeIt/Sources/DI/UseCase.swift` +
+ +### 3.5. 메소드 추가 (`add_method`) +**요청 예시**: "Gemini, `FavoriteRecipeRepository`에 '레시피 삭제' 메소드를 추가해줘." + +**수행 작업**: +1. 요청받은 컴포넌트 이름(예: `FavoriteRecipeRepository`)으로 프로젝트 내에서 관련 소스 파일을 검색합니다. (`*FavoriteRecipeRepository*.swift`) +2. 적절한 파일을 찾으면(주로 프로토콜과 구현체), 해당 파일의 내용을 읽습니다. +3. 요청받은 내용(예: '레시피 삭제')에 맞는 Swift 메소드 코드를 생성합니다. +4. 기존 파일의 클래스/구조체 닫는 괄호 `}` 바로 앞에 생성한 메소드 코드를 삽입합니다. 프로토콜과 구현체 모두에 적절한 코드를 추가해야 합니다. + +--- + +## 4. 코드 템플릿 + +### 4.1. 신규 파일 템플릿 +
+각 파일 타입에 대한 Swift 코드 템플릿 보기 + +**ENTITY_VO**: +```swift +import Foundation + +public struct {FeatureName}VO { + public let id: String + public let name: String + + public init(id: String, name: String) { + self.id = id + self.name = name + } +} +``` + +**DATA_SOURCE_PROTOCOL**: +```swift +import Foundation + +public protocol {FeatureName}DataSource { + func fetch() async throws -> String +} +``` + +**DATA_SOURCE_IMPL**: +```swift +import Foundation +import Dependencies + +public struct {FeatureName}DataSourceImpl: {FeatureName}DataSource { + public init() {} + + public func fetch() async throws -> String { + // TODO: Implement actual data fetching logic + return "Hello from {FeatureName}DataSourceImpl" + } +} +``` + +**EMPTY_DATA_SOURCE**: +```swift +import Foundation + +public struct Empty{FeatureName}DataSource: {FeatureName}DataSource { + public init() {} + + public func fetch() async throws -> String { + return "Empty {FeatureName} Data" + } +} +``` + +**DOMAIN_REPOSITORY_PROTOCOL**: +```swift +import Foundation +import Entity + +public protocol {FeatureName}Repository { + func fetch() async throws -> {FeatureName}VO +} +``` + +**REPOSITORY_IMPL**: +```swift +import Foundation +import Entity +import Domain +import Dependencies + +public struct {FeatureName}RepositoryImpl: {FeatureName}Repository { + @Dependency(\.{camelCaseFeatureName}DataSource) var dataSource + + public init() {} + + public func fetch() async throws -> {FeatureName}VO { + let data = try await dataSource.fetch() + return {FeatureName}VO(id: "1", name: data) + } +} +``` + +**MOCK_REPOSITORY_IMPL**: +```swift +import Foundation +import Entity +import Domain + +public struct Mock{FeatureName}RepositoryImpl: {FeatureName}Repository { + public init() {} + + public func fetch() async throws -> {FeatureName}VO { + return {FeatureName}VO(id: "mock-id", name: "Mock Data") + } +} +``` + +**EMPTY_REPOSITORY**: +```swift +import Foundation +import Entity +import Domain + +struct Empty{FeatureName}Repository: {FeatureName}Repository { + func fetch() async throws -> {FeatureName}VO { + return {FeatureName}VO(id: "empty", name: "Empty") + } +} +``` + +**DOMAIN_USECASE_PROTOCOL**: +```swift +import Foundation +import Entity + +public protocol {FeatureName}UseCase { + func execute() async throws -> {FeatureName}VO +} +``` + +**USECASE_IMPL**: +```swift +import Foundation +import Entity +import Domain +import Dependencies + +public struct {FeatureName}UseCaseImpl: {FeatureName}UseCase { + private let repository: {FeatureName}Repository + + public init(repository: {FeatureName}Repository) { + self.repository = repository + } + + public func execute() async throws -> {FeatureName}VO { + return try await repository.fetch() + } +} +``` + +**EMPTY_USECASE**: +```swift +import Foundation +import Entity + +struct Empty{FeatureName}UseCase: {FeatureName}UseCase { + func execute() async throws -> {FeatureName}VO { + return {FeatureName}VO(id: "empty", name: "Empty UseCase") + } +} +``` + +**DTO**: +```swift +import Foundation + +public struct {FeatureName}DTO: Decodable { + let id: String + let title: String +} +``` +
+ +### 4.2. DI 코드 추가 템플릿 +
+DI 파일에 추가할 코드 템플릿 보기 + +**DATA_DEPENDENCIES_DI**: +```swift +// MARK: - {FeatureName}DataSource DI + +public enum {FeatureName}DataSourceKey: TestDependencyKey { + public static let testValue: any {FeatureName}DataSource = Empty{FeatureName}DataSource() +} + +public extension DependencyValues { + var {camelCaseFeatureName}DataSource: any {FeatureName}DataSource { + get { self[{FeatureName}DataSourceKey.self] } + set { self[{FeatureName}DataSourceKey.self] = newValue } + } +} +``` + +**DOMAIN_DEPENDENCIES_REPOSITORY_DI**: +```swift +// MARK: - {FeatureName}Repository DI + +public enum {FeatureName}RepositoryKey: TestDependencyKey { + public static let testValue: any {FeatureName}Repository = Empty{FeatureName}Repository() +} + +public extension DependencyValues { + var {camelCaseFeatureName}Repository: any {FeatureName}Repository { + get { self[{FeatureName}RepositoryKey.self] } + set { self[{FeatureName}RepositoryKey.self] = newValue } + } +} +``` + +**DOMAIN_DEPENDENCIES_USECASE_DI**: +```swift +// MARK: - {FeatureName}UseCase DI + +public enum {FeatureName}UseCaseKey: TestDependencyKey { + public static let testValue: any {FeatureName}UseCase = Empty{FeatureName}UseCase() +} + +public extension DependencyValues { + var {camelCaseFeatureName}UseCase: any {FeatureName}UseCase { + get { self[{FeatureName}UseCaseKey.self] } + set { self[{FeatureName}UseCaseKey.self] = newValue } + } +} +``` + +**RECIPEIT_DI_DATASOURCE**: +```swift +import Dependencies +import Data + +extension {FeatureName}DataSourceKey: @retroactive DependencyKey { + public static let liveValue: any {FeatureName}DataSource = {FeatureName}DataSourceImpl() +} +``` + +**RECIPEIT_DI_REPOSITORY**: +```swift +import Dependencies +import Domain +import Data + +extension {FeatureName}RepositoryKey: @retroactive DependencyKey { + public static let liveValue: any {FeatureName}Repository = {FeatureName}RepositoryImpl() +} +``` + +**RECIPEIT_DI_USECASE**: +```swift +import Dependencies +import Domain + +extension {FeatureName}UseCaseKey: @retroactive DependencyKey { + public static let liveValue: any {FeatureName}UseCase = { + @Dependency(\.{camelCaseFeatureName}Repository) var repository + return {FeatureName}UseCaseImpl(repository: repository) + }() +} +``` diff --git a/gemini.md b/gemini.md new file mode 100644 index 0000000..fd7bd1c --- /dev/null +++ b/gemini.md @@ -0,0 +1,28 @@ +# PuHaHang 프로젝트 컨텍스트 및 코드 생성 규칙 + +## 1. 프로젝트 개요 +- **앱 이름**: PuHaHang (가칭) +- **목표**: iOS 레시피 추천 및 관리 앱 +- **주요 기술**: Swift, SwiftUI, TCA(The Composable Architecture), Tuist +- **핵심 아키텍처**: 모듈화된 클린 아키텍처 (Presentation - Domain - Data - Entity) +- **의존성 관리**: `swift-dependencies`를 사용한 DI + +## 2. 기본 원칙 +- **코딩 컨벤션**: `.swiftlint.yml` 규칙을 항상 준수합니다. +- **네이밍**: 타입은 `UpperCamelCase`, 변수/함수는 `lowerCamelCase`를 사용합니다. +- **주석**: `MARK:` 주석으로 코드 블록을 명확히 구분합니다. +- **파일 수정**: 기존 파일을 수정할 때는, 해당 파일의 스타일과 구조를 반드시 먼저 파악하고 일관성을 유지합니다. + +--- + +상세한 코드 생성 규칙은 [clean_architecture_rule.md](./clean_architecture_rule.md) 파일을 참조해주세요. + +## 3. 코드 생성 키워드 및 규칙 + +다음 키워드를 사용하여 코드 생성을 요청할 수 있습니다. 각 키워드에 대한 자세한 규칙은 `clean_architecture_rule.md` 파일을 참조하세요. + +- **`전체 기능 생성`**: Presentation - Domain - Data - Entity 계층에 걸친 전체 모듈 파일을 생성합니다. +- **`DataSource 레이어 생성`**: DataSource 관련 파일만 생성합니다. +- **`Repository 레이어 생성`**: Repository 관련 파일만 생성합니다. +- **`UseCase 레이어 생성`**: UseCase 관련 파일만 생성합니다. +- **`메소드 추가`**: 기존 파일에 새로운 메소드를 추가합니다. \ No newline at end of file