From 80d85839bd9f9432b9b3e847b50c41ec2f6401a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=94=94=ED=95=B4?= Date: Tue, 5 Aug 2025 16:39:51 +0900 Subject: [PATCH 1/8] =?UTF-8?q?[FEAT]:=20=EC=BB=B4=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=ED=94=8C=EB=9E=98=EA=B7=B8=20=EA=B8=B0=EB=B0=98=20=EC=8B=9C?= =?UTF-8?q?=EB=82=98=EB=A6=AC=EC=98=A4=20=EB=B6=84=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- RecipeIt/Project.swift | 59 ++++++++++++++++++++++++++++ RecipeIt/Sources/DI/Repository.swift | 28 +++++++++++-- 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/RecipeIt/Project.swift b/RecipeIt/Project.swift index 497db29..0d46db3 100644 --- a/RecipeIt/Project.swift +++ b/RecipeIt/Project.swift @@ -56,5 +56,64 @@ let project = Project( .project(target: "Presentation", path: "../Presentation") ] ), + .target( + name: "RecipeIt-Mock", + destinations: .iOS, + 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": "development" + ]), + 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/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) }() From 62bdcc0cc8be04997a2f973e8cf439cb0aa45295 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=94=94=ED=95=B4?= Date: Tue, 5 Aug 2025 23:55:31 +0900 Subject: [PATCH 2/8] =?UTF-8?q?[FEAT]:=20gemini=20=EC=97=B0=EB=8F=99=20Dat?= =?UTF-8?q?aSource-Repository-UseCase=20=EC=9E=90=EB=8F=99=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EB=B0=8F=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=ED=94=84=EB=A1=AC=ED=94=84=ED=8A=B8=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- auto_generate.py | 363 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 363 insertions(+) create mode 100755 auto_generate.py diff --git a/auto_generate.py b/auto_generate.py new file mode 100755 index 0000000..40347e3 --- /dev/null +++ b/auto_generate.py @@ -0,0 +1,363 @@ +#!/usr/bin/env python3 +import sys +import os +import json +import re +from pathlib import Path + +try: + import google.generativeai as genai +except ImportError: + print("pip3 install google-generativeai") + sys.exit(1) + +def generate_code(feature_name, creation_type, target_component=None, method_description=None): + # API 설정 + api_key = os.getenv("GEMINI_API_KEY") + if not api_key: + print("export GEMINI_API_KEY='your-api-key'") + sys.exit(1) + + genai.configure(api_key=api_key) + model = genai.GenerativeModel('gemini-1.5-flash') + + # 프롬프트 생성 + if creation_type == "full": + prompt = """ +iOS Clean Architecture 패턴에 맞춰 '{feature_name}' 기능을 생성해주세요. + +다음 파일들의 Swift 코드를 생성하고, JSON 형식으로 응답해주세요: +1. Entity/Sources/VO/{feature_name}/{feature_name}VO.swift +2. Data/Sources/DataSource/{feature_name}/{feature_name}DataSource.swift +3. Data/Sources/DataSource/{feature_name}/{feature_name}DataSourceImpl.swift +4. Data/Sources/Dependencies/EmptyImpl/DataSource/Empty{feature_name}DataSource.swift +5. Data/Sources/Dependencies/DataSource.swift (새로운 DI 등록 코드만) +6. Domain/Sources/Repository/{feature_name}Repository.swift +7. Data/Sources/RepositoryImpl/{feature_name}/{feature_name}RepositoryImpl.swift +8. Data/Sources/RepositoryImpl/{feature_name}/Mock{feature_name}RepositoryImpl.swift +9. Data/Sources/Dependencies/EmptyImpl/Repository/Empty{feature_name}Repository.swift +10. Data/Sources/Dependencies/Repository.swift (새로운 DI 등록 코드만) +11. Domain/Sources/UseCase/{feature_name}/{feature_name}UseCase.swift +12. Domain/Sources/UseCase/{feature_name}/{feature_name}UseCaseImpl.swift +13. Domain/Sources/Dependencies/EmptyImpl/Empty{feature_name}UseCase.swift +14. Domain/Sources/Dependencies/UseCase.swift (새로운 DI 등록 코드만) +15. Data/Sources/DTO/{feature_name}DTO.swift +16. RecipeIt/Sources/DI/DataSource.swift (새로운 liveValue 등록 코드만) +17. RecipeIt/Sources/DI/Repository.swift (새로운 liveValue 등록 코드만) +18. RecipeIt/Sources/DI/UseCase.swift (새로운 liveValue 등록 코드만) + +접근 제어자 가이드라인: +- Entity VO: public struct (다른 모듈에서 사용) + - 프로퍼티: public let + - 생성자: public init +- Domain Repository/UseCase 프로토콜: public protocol (다른 모듈에서 사용) +- Data DataSource 프로토콜: public protocol (다른 모듈에서 사용) +- 구현체 (Impl): public struct/class (DI 주입용) +- Mock 구현체: public struct (DI 주입용) +- Empty DataSource: public struct (DI testValue용) +- Empty Repository: struct (internal, DI testValue용) +- Empty UseCase: struct (internal, 같은 모듈 내에서만 사용) +- DTO: public struct (DataSource 메소드 리턴 타입으로 사용) + - 프로퍼티: let (internal, Decodable 사용) + +Import 가이드라인: +- Entity VO: import Foundation +- DataSource 프로토콜: import Foundation +- DataSource 구현체: import Foundation, import Dependencies +- Empty DataSource: import Foundation +- Repository 프로토콜: import Foundation, import Entity +- Repository 구현체: import Foundation, import Entity, import Domain +- Mock Repository: import Foundation, import Entity, import Domain +- Empty Repository: import Foundation, import Entity, import Domain +- UseCase 프로토콜: import Foundation, import Entity +- UseCase 구현체: import Foundation, import Entity +- Empty UseCase: import Foundation, import Entity +- DTO: import Foundation + +구현 가이드라인: +- DataSource 구현체: public init 포함, 함수 시그니처만 제공하고 TODO 주석으로 실제 구현 필요함을 표시 +- Repository 구현체: public init 포함, 완전한 구현 제공 +- UseCase 구현체: public init 포함, 완전한 구현 제공 +- UseCase 함수명: 무조건 execute 사용 +- Mock 구현체들: public init 포함, 완전한 구현 제공 + +DI 등록 가이드라인: +- DataSource: Data/Sources/Dependencies/DataSource.swift에 TestDependencyKey 등록 (MARK 주석 + enum + DependencyValues extension) +- Repository: Data/Sources/Dependencies/Repository.swift에 TestDependencyKey 등록 (MARK 주석 + enum + DependencyValues extension) +- UseCase: Domain/Sources/Dependencies/UseCase.swift에 TestDependencyKey 등록 (MARK 주석 + enum + DependencyValues extension) +- RecipeIt 모듈: 각각 RecipeIt/Sources/DI/ 파일들에 DependencyKey extension으로 liveValue 등록 +- 패턴: extension {feature_name}DataSourceKey: @retroactive DependencyKey +- liveValue에는 실제 구현체 사용, testValue에는 Empty 구현체 사용 +- 변수명은 camelCase 사용 + +변수명 변환 예시: +- TestFeature → testFeature +- FavoriteRecipe → favoriteRecipe +- UserProfile → userProfile + +중요: DI 등록 파일들 (Dependencies/*.swift, DI/*.swift)의 경우, +기존 파일에 추가될 새로운 DI 등록 코드만 생성해주세요. +파일 전체 내용이 아닌 추가할 부분만 생성하면 됩니다. +예를 들어 Domain/Sources/Dependencies/UseCase.swift의 경우: +1. 기존 파일의 모든 내용 (기존 DI 등록들) +2. + 새로운 {feature_name}UseCase DI 등록 + +예시: 새로운 {feature_name}UseCase DI 등록 코드만 생성 + +DI 등록 패턴 예시: +- UseCase DI 등록: +``` +// MARK: - {feature_name}UseCase DI + +public enum {feature_name}UseCaseKey: TestDependencyKey {{ + #if DEBUG + public static let testValue: {feature_name}UseCase = Empty{feature_name}UseCase() + #endif +}} + +public extension DependencyValues {{ + var {{camelCase변수명}}UseCase: {feature_name}UseCase {{ + get {{ self[{feature_name}UseCaseKey.self] }} + set {{ self[{feature_name}UseCaseKey.self] = newValue }} + }} +}} +``` + +- Repository DI 등록: +``` +// MARK: - {feature_name}Repository DI + +public enum {feature_name}RepositoryKey: TestDependencyKey {{ + #if DEBUG + public static let testValue: {feature_name}Repository = Empty{feature_name}Repository() + #endif +}} + +public extension DependencyValues {{ + var {{camelCase변수명}}Repository: {feature_name}Repository {{ + get {{ self[{feature_name}RepositoryKey.self] }} + set {{ self[{feature_name}RepositoryKey.self] = newValue }} + }} +}} +``` + +- DataSource DI 등록: +``` +// MARK: - {feature_name}DataSource DI + +public enum {feature_name}DataSourceKey: TestDependencyKey {{ + #if DEBUG + public static let testValue: {feature_name}DataSource = Empty{feature_name}DataSource() + #endif +}} + +public extension DependencyValues {{ + var {{camelCase변수명}}DataSource: {feature_name}DataSource {{ + get {{ self[{feature_name}DataSourceKey.self] }} + set {{ self[{feature_name}DataSourceKey.self] = newValue }} + }} +}} +``` + +- RecipeIt 모듈 DI 등록 (liveValue): +``` +extension {feature_name}DataSourceKey: @retroactive DependencyKey {{ + public static let liveValue: any {feature_name}DataSource = {feature_name}DataSourceImpl() +}} + +extension {feature_name}RepositoryKey: @retroactive DependencyKey {{ + public static let liveValue: any {feature_name}Repository = {{ + @Dependency(\.{{camelCase변수명}}DataSource) var dataSource + return {feature_name}RepositoryImpl(dataSource: dataSource) + }}() +}} + +extension {feature_name}UseCaseKey: @retroactive DependencyKey {{ + public static let liveValue: any {feature_name}UseCase = {{ + @Dependency(\.{{camelCase변수명}}Repository) var repository + return {feature_name}UseCaseImpl(repository: repository) + }}() +}} +``` + +구현체 가이드라인: +- DataSource 구현체는 반드시 public init 포함 +- Repository 구현체는 반드시 public init 포함 +- UseCase 구현체도 반드시 public init 포함 + +응답 형식: +{{"files": [{{"path": "파일경로", "content": "Swift코드"}}]}} + +JSON 생성 주의사항: +- Swift 코드 내의 백슬래시(\)는 \\로 이스케이프 +- Swift 코드 내의 쌍따옴표(")는 \"로 이스케이프 +- 개행 문자는 \n으로 표현 +- JSON이 유효한 형식인지 확인 +""".format(feature_name=feature_name) + elif creation_type == "datasource_only": + prompt = """ +'{feature_name}' DataSource의 Swift 코드를 생성하고, JSON 형식으로 응답해주세요. + +생성할 파일들: +1. Data/Sources/DataSource/{feature_name}/{feature_name}DataSource.swift +2. Data/Sources/DataSource/{feature_name}/{feature_name}DataSourceImpl.swift +3. Data/Sources/Dependencies/EmptyImpl/DataSource/Empty{feature_name}DataSource.swift +4. Data/Sources/DTO/{feature_name}DTO.swift +5. Data/Sources/Dependencies/DataSource.swift (새로운 DI 등록 코드만) +6. RecipeIt/Sources/DI/DataSource.swift (새로운 liveValue 등록 코드만) + +접근 제어자: +- DataSource 프로토콜: public protocol +- DataSource 구현체: public struct/class +- Empty DataSource: public struct +- DTO: public struct + +Import 가이드라인: +- DataSource 프로토콜: import Foundation +- DataSource 구현체: import Foundation, import Dependencies +- Empty DataSource: import Foundation +- DTO: import Foundation + +응답 형식: +{{"files": [{{"path": "파일경로", "content": "Swift코드"}}]}} +""".format(feature_name=feature_name) + elif creation_type == "repository_only": + prompt = """ +'{feature_name}' Repository의 Swift 코드를 생성하고, JSON 형식으로 응답해주세요. + +생성할 파일들: +1. Domain/Sources/Repository/{feature_name}Repository.swift +2. Data/Sources/RepositoryImpl/{feature_name}/{feature_name}RepositoryImpl.swift +3. Data/Sources/RepositoryImpl/{feature_name}/Mock{feature_name}RepositoryImpl.swift +4. Data/Sources/Dependencies/EmptyImpl/Repository/Empty{feature_name}Repository.swift +5. Data/Sources/Dependencies/Repository.swift (새로운 DI 등록 코드만) +6. RecipeIt/Sources/DI/Repository.swift (새로운 liveValue 등록 코드만) + +접근 제어자: +- Repository 프로토콜: public protocol +- Repository 구현체: public struct/class +- Mock Repository: public struct (DI 주입용) +- Empty Repository: struct (internal) + +Import 가이드라인: +- Repository 프로토콜: import Foundation, import Entity +- Repository 구현체: import Foundation, import Entity, import Domain +- Mock Repository: import Foundation, import Entity, import Domain +- Empty Repository: import Foundation, import Entity, import Domain + +응답 형식: +{{"files": [{{"path": "파일경로", "content": "Swift코드"}}]}} +""".format(feature_name=feature_name) + elif creation_type == "usecase_only": + prompt = """ +'{feature_name}' UseCase의 Swift 코드를 생성하고, JSON 형식으로 응답해주세요. + +생성할 파일들: +1. Domain/Sources/UseCase/{feature_name}/{feature_name}UseCase.swift +2. Domain/Sources/UseCase/{feature_name}/{feature_name}UseCaseImpl.swift +3. Domain/Sources/Dependencies/EmptyImpl/Empty{feature_name}UseCase.swift +4. Domain/Sources/Dependencies/UseCase.swift (새로운 DI 등록 코드만) +5. RecipeIt/Sources/DI/UseCase.swift (새로운 liveValue 등록 코드만) + +접근 제어자: +- UseCase 프로토콜: public protocol +- UseCase 구현체: public struct/class +- Empty UseCase: struct (internal) + +Import 가이드라인: +- UseCase 프로토콜: import Foundation, import Entity +- UseCase 구현체: import Foundation, import Entity +- Empty UseCase: import Foundation, import Entity + +응답 형식: +{{"files": [{{"path": "파일경로", "content": "Swift코드"}}]}} +""".format(feature_name=feature_name) + elif creation_type == "method_add": + prompt = """ +기존 {target_component}에 '{method_description}' 메소드를 추가하는 Swift 코드를 생성하고, JSON 형식으로 응답해주세요. + +응답 형식: +{{"files": [{{"path": "수정할파일경로", "content": "추가할Swift메소드코드"}}]}} +""".format(target_component=target_component, method_description=method_description) + + # API 호출 + response = model.generate_content(prompt) + + # JSON 파싱 및 파일 생성 + json_match = re.search(r'```json\s*(.*?)\s*```', response.text, re.DOTALL) + if json_match: + json_text = json_match.group(1) + else: + json_text = response.text.strip() + + # JSON 파싱 전 전처리 (일반적인 문제 수정) + # json_text = json_text.replace('\\n', '\\\\n').replace('\\"', '\\\\"') + + try: + data = json.loads(json_text) + except json.JSONDecodeError: + print("❌ JSON 파싱 실패 - Gemini 응답을 다시 시도하세요") + return + + # 파일 생성 + for file_info in data["files"]: + file_path = Path(file_info["path"]) + content = file_info["content"] + + # JSON에서 온 개행문자를 실제 개행으로 변환 + content = content.replace('\\n', '\n').replace('\\"', '"') + + full_path = Path.cwd() / file_path + + # DI 등록 파일들은 기존 파일에 추가 + is_di_file = any(di_path in str(file_path) for di_path in [ + "Dependencies/DataSource.swift", + "Dependencies/Repository.swift", + "Dependencies/UseCase.swift", + "DI/DataSource.swift", + "DI/Repository.swift", + "DI/UseCase.swift" + ]) + + if is_di_file and full_path.exists(): + # 기존 파일에 새로운 내용 추가 + existing_content = full_path.read_text(encoding='utf-8') + new_content = existing_content + '\n' + content + '\n' + full_path.write_text(new_content, encoding='utf-8') + print(f"✅ Added to: {full_path}") + else: + # 일반 파일들은 새로 생성 + full_path.parent.mkdir(parents=True, exist_ok=True) + full_path.write_text(content, encoding='utf-8') + print(f"✅ Created: {full_path}") + +def main(): + if len(sys.argv) < 2: + print("사용법: python3 auto_generate.py <기능명>") + sys.exit(1) + + feature_name = sys.argv[1] + + print("1. full - 전체") + print("2. datasource_only - DataSource만") + print("3. repository_only - Repository만") + print("4. usecase_only - UseCase만") + print("5. method_add - 메소드 추가") + + choice = input("선택: ").strip() + + if choice == "1": + generate_code(feature_name, "full") + elif choice == "2": + generate_code(feature_name, "datasource_only") + elif choice == "3": + generate_code(feature_name, "repository_only") + elif choice == "4": + generate_code(feature_name, "usecase_only") + elif choice == "5": + target_component = input("대상 컴포넌트: ").strip() + method_description = input("추가할 메소드: ").strip() + generate_code(feature_name, "method_add", target_component, method_description) + +if __name__ == "__main__": + main() \ No newline at end of file From 30e5141d4729e1de0bcdc7dd458bcdfc3436a427 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=94=94=ED=95=B4?= Date: Wed, 6 Aug 2025 12:33:34 +0900 Subject: [PATCH 3/8] =?UTF-8?q?[CHORE]:=20=EC=9E=84=EC=8B=9C=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Feature/Home/HomeReducer.swift | 15 +- .../Feature/Home/IncompleteRecipeStore.swift | 6 +- Presentation/Tests/HomeReducerTests.swift | 262 ++++++++++++++++++ 3 files changed, 274 insertions(+), 9 deletions(-) create mode 100644 Presentation/Tests/HomeReducerTests.swift diff --git a/Presentation/Sources/Feature/Home/HomeReducer.swift b/Presentation/Sources/Feature/Home/HomeReducer.swift index a535220..7f35606 100644 --- a/Presentation/Sources/Feature/Home/HomeReducer.swift +++ b/Presentation/Sources/Feature/Home/HomeReducer.swift @@ -19,9 +19,10 @@ struct HomeReducer { @Dependency(\.fetchRecipePreviewUseCase) var fetchRecipePreviewUseCase @Dependency(\.fetchCompletedRecipeConversionIDsUseCase) var fetchCompletedRecipeConversionIDsUseCase @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? @@ -34,7 +35,7 @@ struct HomeReducer { var errorState = ErrorHandlingState() } - enum Action { + enum Action: Equatable { case onAppear case addRecipe(RecipeListItem) case updateRecipe(RecipeListItem) @@ -154,11 +155,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) } } @@ -294,7 +293,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 + } +} From c5a6609c978ca019307ba9a4e04107cdf0146b13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=94=94=ED=95=B4?= Date: Tue, 5 Aug 2025 14:51:07 +0900 Subject: [PATCH 4/8] =?UTF-8?q?[REFACTOR]:=20=EC=BA=90=EC=8B=B1=EC=9D=84?= =?UTF-8?q?=20=EC=9C=84=ED=95=B4=20=EA=B8=B0=EC=A1=B4=20DTO=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=EB=A5=BC=20Codable=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Data/Sources/DTO/IngredientDTO.swift | 2 +- Data/Sources/DTO/RecipeListDTO.swift | 2 +- Data/Sources/DTO/RecipePreviewDTO.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Data/Sources/DTO/IngredientDTO.swift b/Data/Sources/DTO/IngredientDTO.swift index 60916d1..6b9fc82 100644 --- a/Data/Sources/DTO/IngredientDTO.swift +++ b/Data/Sources/DTO/IngredientDTO.swift @@ -8,7 +8,7 @@ import Foundation import Entity -public struct IngredientDTO: Decodable { +public struct IngredientDTO: Codable { let id: Int64 let name: String let amount: String diff --git a/Data/Sources/DTO/RecipeListDTO.swift b/Data/Sources/DTO/RecipeListDTO.swift index 2503edf..611c694 100644 --- a/Data/Sources/DTO/RecipeListDTO.swift +++ b/Data/Sources/DTO/RecipeListDTO.swift @@ -8,7 +8,7 @@ import Entity import Foundation -public struct RecipeListDTO: Decodable { +public struct RecipeListDTO: Codable { let recipes: [RecipePreviewDTO] let hasNext: Bool let cursorId: Int? diff --git a/Data/Sources/DTO/RecipePreviewDTO.swift b/Data/Sources/DTO/RecipePreviewDTO.swift index a38a8fd..4726c9d 100644 --- a/Data/Sources/DTO/RecipePreviewDTO.swift +++ b/Data/Sources/DTO/RecipePreviewDTO.swift @@ -8,7 +8,7 @@ import Foundation import Entity -public struct RecipePreviewDTO: Decodable { +public struct RecipePreviewDTO: Codable { let recipeID: Int64 let title: String? let thumbnail: String? From cde0688db68ff538d0127e9e6460cf3b39703bfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=94=94=ED=95=B4?= Date: Tue, 5 Aug 2025 14:51:49 +0900 Subject: [PATCH 5/8] =?UTF-8?q?[REFACTOR]:=20=EB=A0=88=EC=8B=9C=ED=94=BC?= =?UTF-8?q?=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=EC=BA=90=EC=8B=9C?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=B6=88=EB=9F=AC=EC=98=A4=EB=8A=94=20?= =?UTF-8?q?=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RecipePreview/RecipePreviewDataSource.swift | 2 +- .../DataSource/EmptyRecipePreviewDataSource.swift | 2 +- .../Repository/EmptyRecipePreviewRepository.swift | 2 +- .../RecipePreview/ErrorRecipePreviewRepositoryImpl.swift | 3 +-- .../RecipePreview/MockRecipePreviewRepositoryImpl.swift | 2 +- .../RecipePreview/RecipePreviewRepositoryImpl.swift | 6 ++---- .../EmptyImpl/EmptyFetchRecipePreviewsUseCase.swift | 2 +- Domain/Sources/Repository/RecipePreviewRepository.swift | 2 +- .../FetchRecipePreviews/FetchRecipePreviewsUseCase.swift | 2 +- .../FetchRecipePreviewsUseCaseImpl.swift | 4 ++-- Presentation/Sources/Feature/Home/HomeReducer.swift | 9 ++------- 11 files changed, 14 insertions(+), 22 deletions(-) diff --git a/Data/Sources/DataSource/RecipePreview/RecipePreviewDataSource.swift b/Data/Sources/DataSource/RecipePreview/RecipePreviewDataSource.swift index aab01d3..439e9fd 100644 --- a/Data/Sources/DataSource/RecipePreview/RecipePreviewDataSource.swift +++ b/Data/Sources/DataSource/RecipePreview/RecipePreviewDataSource.swift @@ -9,5 +9,5 @@ import Foundation public protocol RecipePreviewDataSource { func fetchPreview(recipeID: Int64) async throws -> RecipePreviewDTO - func fetchPreviews(cursorId: Int?, size: Int) async throws -> RecipeListDTO + func fetchPreviews(cursorId: Int?, size: Int, fromCache: Bool) async throws -> RecipeListDTO } diff --git a/Data/Sources/Dependencies/EmptyImpl/DataSource/EmptyRecipePreviewDataSource.swift b/Data/Sources/Dependencies/EmptyImpl/DataSource/EmptyRecipePreviewDataSource.swift index d50f64e..406fef2 100644 --- a/Data/Sources/Dependencies/EmptyImpl/DataSource/EmptyRecipePreviewDataSource.swift +++ b/Data/Sources/Dependencies/EmptyImpl/DataSource/EmptyRecipePreviewDataSource.swift @@ -12,7 +12,7 @@ public struct EmptyRecipePreviewDataSource: RecipePreviewDataSource { throwUnimplemented() } - public func fetchPreviews(cursorId: Int?, size: Int) async throws -> RecipeListDTO { + public func fetchPreviews(cursorId: Int?, size: Int, fromCache: Bool) async throws -> RecipeListDTO { throwUnimplemented() } } diff --git a/Data/Sources/Dependencies/EmptyImpl/Repository/EmptyRecipePreviewRepository.swift b/Data/Sources/Dependencies/EmptyImpl/Repository/EmptyRecipePreviewRepository.swift index 9063785..91f73f2 100644 --- a/Data/Sources/Dependencies/EmptyImpl/Repository/EmptyRecipePreviewRepository.swift +++ b/Data/Sources/Dependencies/EmptyImpl/Repository/EmptyRecipePreviewRepository.swift @@ -14,7 +14,7 @@ struct EmptyRecipePreviewRepository: RecipePreviewRepository { throwUnimplemented() } - func fetchPreviews(cursorId: Int?, size: Int) async throws -> RecipeListVO { + public func fetchPreviews(cursorId: Int?, size: Int, fromCache: Bool) async throws -> Entity.RecipeListVO { throwUnimplemented() } } diff --git a/Data/Sources/RepositoryImpl/RecipePreview/ErrorRecipePreviewRepositoryImpl.swift b/Data/Sources/RepositoryImpl/RecipePreview/ErrorRecipePreviewRepositoryImpl.swift index 484a9c0..c285f89 100644 --- a/Data/Sources/RepositoryImpl/RecipePreview/ErrorRecipePreviewRepositoryImpl.swift +++ b/Data/Sources/RepositoryImpl/RecipePreview/ErrorRecipePreviewRepositoryImpl.swift @@ -14,14 +14,13 @@ public enum RecipePreviewError: Error { } public struct ErrorRecipePreviewRepositoryImpl: RecipePreviewRepository { - public init() { } public func fetchPreview(for recipeID: Int64) async throws -> RecipePreviewVO { throw RecipePreviewError.forcedFailure } - public func fetchPreviews(cursorId: Int?, size: Int) async throws -> RecipeListVO { + public func fetchPreviews(cursorId: Int?, size: Int, fromCache: Bool) async throws -> Entity.RecipeListVO { throw RecipePreviewError.forcedFailure } } diff --git a/Data/Sources/RepositoryImpl/RecipePreview/MockRecipePreviewRepositoryImpl.swift b/Data/Sources/RepositoryImpl/RecipePreview/MockRecipePreviewRepositoryImpl.swift index d4493fc..57c5423 100644 --- a/Data/Sources/RepositoryImpl/RecipePreview/MockRecipePreviewRepositoryImpl.swift +++ b/Data/Sources/RepositoryImpl/RecipePreview/MockRecipePreviewRepositoryImpl.swift @@ -44,7 +44,7 @@ public struct MockRecipePreviewRepositoryImpl: RecipePreviewRepository { ) } - public func fetchPreviews(cursorId: Int?, size: Int) async throws -> RecipeListVO { + public func fetchPreviews(cursorId: Int?, size: Int, fromCache: Bool = false) async throws -> RecipeListVO { let previews = [ RecipePreviewVO( recipeID: 1, diff --git a/Data/Sources/RepositoryImpl/RecipePreview/RecipePreviewRepositoryImpl.swift b/Data/Sources/RepositoryImpl/RecipePreview/RecipePreviewRepositoryImpl.swift index c63f5d3..21b500c 100644 --- a/Data/Sources/RepositoryImpl/RecipePreview/RecipePreviewRepositoryImpl.swift +++ b/Data/Sources/RepositoryImpl/RecipePreview/RecipePreviewRepositoryImpl.swift @@ -18,13 +18,11 @@ public struct RecipePreviewRepositoryImpl: RecipePreviewRepository { public func fetchPreview(for recipeID: Int64) async throws -> RecipePreviewVO { let recipePreview = try await dataSource.fetchPreview(recipeID: recipeID) - return recipePreview.toDomain() } - public func fetchPreviews(cursorId: Int?, size: Int) async throws -> RecipeListVO { - let recipeList = try await dataSource.fetchPreviews(cursorId: cursorId, size: size) - + public func fetchPreviews(cursorId: Int?, size: Int, fromCache: Bool = false) async throws -> RecipeListVO { + let recipeList = try await dataSource.fetchPreviews(cursorId: cursorId, size: size, fromCache: fromCache) return recipeList.toDomain() } } diff --git a/Domain/Sources/Dependencies/EmptyImpl/EmptyFetchRecipePreviewsUseCase.swift b/Domain/Sources/Dependencies/EmptyImpl/EmptyFetchRecipePreviewsUseCase.swift index 847251a..aa2aaa2 100644 --- a/Domain/Sources/Dependencies/EmptyImpl/EmptyFetchRecipePreviewsUseCase.swift +++ b/Domain/Sources/Dependencies/EmptyImpl/EmptyFetchRecipePreviewsUseCase.swift @@ -11,7 +11,7 @@ import Foundation struct EmptyFetchRecipePreviewsUseCase: FetchRecipePreviewsUseCase { init() {} - func execute(cursorId: Int?, size: Int) async -> Result { + func execute(cursorId: Int?, size: Int, fromCache: Bool) async -> Result { unimplementedFailure() } } diff --git a/Domain/Sources/Repository/RecipePreviewRepository.swift b/Domain/Sources/Repository/RecipePreviewRepository.swift index aefc14a..b66fd09 100644 --- a/Domain/Sources/Repository/RecipePreviewRepository.swift +++ b/Domain/Sources/Repository/RecipePreviewRepository.swift @@ -10,5 +10,5 @@ import Entity public protocol RecipePreviewRepository { func fetchPreview(for recipeID: Int64) async throws -> RecipePreviewVO - func fetchPreviews(cursorId: Int?, size: Int) async throws -> RecipeListVO + func fetchPreviews(cursorId: Int?, size: Int, fromCache: Bool) async throws -> RecipeListVO } diff --git a/Domain/Sources/UseCase/FetchRecipePreviews/FetchRecipePreviewsUseCase.swift b/Domain/Sources/UseCase/FetchRecipePreviews/FetchRecipePreviewsUseCase.swift index 7415611..7655a7e 100644 --- a/Domain/Sources/UseCase/FetchRecipePreviews/FetchRecipePreviewsUseCase.swift +++ b/Domain/Sources/UseCase/FetchRecipePreviews/FetchRecipePreviewsUseCase.swift @@ -9,5 +9,5 @@ import Foundation import Entity public protocol FetchRecipePreviewsUseCase { - func execute(cursorId: Int?, size: Int) async -> Result + func execute(cursorId: Int?, size: Int, fromCache: Bool) async -> Result } diff --git a/Domain/Sources/UseCase/FetchRecipePreviews/FetchRecipePreviewsUseCaseImpl.swift b/Domain/Sources/UseCase/FetchRecipePreviews/FetchRecipePreviewsUseCaseImpl.swift index 86245f6..3a32c59 100644 --- a/Domain/Sources/UseCase/FetchRecipePreviews/FetchRecipePreviewsUseCaseImpl.swift +++ b/Domain/Sources/UseCase/FetchRecipePreviews/FetchRecipePreviewsUseCaseImpl.swift @@ -15,9 +15,9 @@ public struct FetchRecipePreviewsUseCaseImpl: FetchRecipePreviewsUseCase { self.recipePreviewRepository = recipePreviewRepository } - public func execute(cursorId: Int?, size: Int) async -> Result { + public func execute(cursorId: Int?, size: Int, fromCache: Bool = false) async -> Result { do { - let list = try await recipePreviewRepository.fetchPreviews(cursorId: cursorId, size: size) + let list = try await recipePreviewRepository.fetchPreviews(cursorId: cursorId, size: size, fromCache: fromCache) return .success(list) } catch { return .failure(error) diff --git a/Presentation/Sources/Feature/Home/HomeReducer.swift b/Presentation/Sources/Feature/Home/HomeReducer.swift index 7f35606..f446335 100644 --- a/Presentation/Sources/Feature/Home/HomeReducer.swift +++ b/Presentation/Sources/Feature/Home/HomeReducer.swift @@ -116,7 +116,7 @@ struct HomeReducer { let pageSize = state.pageSize return .run { send in - let result = await fetchRecipePreviewsUseCase.execute(cursorId: cursorId, size: pageSize) + let result = await fetchRecipePreviewsUseCase.execute(cursorId: cursorId, size: pageSize, fromCache: false) switch result { case .success(let list): @@ -216,15 +216,10 @@ struct HomeReducer { private extension HomeReducer { func reduceOnAppear(_ state: inout HomeReducer.State) -> Effect { - state.cursorId = nil - state.recipes = [] - state.listState = .idle - state.errorState.clearError() - let pageSize = state.pageSize return .run { send in - let result = await fetchRecipePreviewsUseCase.execute(cursorId: nil, size: pageSize) + let result = await fetchRecipePreviewsUseCase.execute(cursorId: nil, size: pageSize, fromCache: true) switch result { case .success(let list): From 8a2ad03ab7bece1e01f6cd34728cfbe962fa80d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=94=94=ED=95=B4?= Date: Tue, 5 Aug 2025 14:52:22 +0900 Subject: [PATCH 6/8] =?UTF-8?q?[FEAT]:=20=EC=BA=90=EC=8B=9C=EB=90=9C=20?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=EB=B6=88=EB=9F=AC?= =?UTF-8?q?=EC=98=A4=EB=8A=94=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RecipePreviewDataSourceImpl.swift | 36 +++++++++++++++++- .../Sources/Feature/Home/HomeReducer.swift | 37 +++++++++++++++---- 2 files changed, 63 insertions(+), 10 deletions(-) diff --git a/Data/Sources/DataSource/RecipePreview/RecipePreviewDataSourceImpl.swift b/Data/Sources/DataSource/RecipePreview/RecipePreviewDataSourceImpl.swift index bfb01cc..9ca994f 100644 --- a/Data/Sources/DataSource/RecipePreview/RecipePreviewDataSourceImpl.swift +++ b/Data/Sources/DataSource/RecipePreview/RecipePreviewDataSourceImpl.swift @@ -13,6 +13,18 @@ import Login public struct RecipePreviewDataSourceImpl: RecipePreviewDataSource { @Dependency(\.loginManager) var loginManager + private let fileManager = FileManager.default + private var cacheDirectory: URL { + let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first! + let cacheDir = documentsDirectory.appendingPathComponent("RecipeCache") + + if !fileManager.fileExists(atPath: cacheDir.path) { + try? fileManager.createDirectory(at: cacheDir, withIntermediateDirectories: true) + } + + return cacheDir + } + public init() { } public func fetchPreview(recipeID: Int64) async throws -> RecipePreviewDTO { @@ -28,7 +40,11 @@ public struct RecipePreviewDataSourceImpl: RecipePreviewDataSource { return response.value } - public func fetchPreviews(cursorId: Int?, size: Int) async throws -> RecipeListDTO { + public func fetchPreviews(cursorId: Int?, size: Int, fromCache: Bool = false) async throws -> RecipeListDTO { + if fromCache && cursorId == nil { + return try loadPreviewsFromCache(size: size) + } + guard let localToken = loginManager.loadLocalToken() else { throw DataSourceError.unauthorized } let request = MFRequest(url: "https://recipe-it.net/api/v1/recipe/list") @@ -43,7 +59,23 @@ public struct RecipePreviewDataSourceImpl: RecipePreviewDataSource { let response = try await MF .setErrorDecoder(ServerDecoder.shared) .requestDecodable(of: RecipeListDTO.self, request) - + + if cursorId == nil { + try? savePreviewsToCache(response.value, size: size) + } + return response.value } + + private func savePreviewsToCache(_ data: RecipeListDTO, size: Int) throws { + let fileURL = cacheDirectory.appendingPathComponent("previews_first_\(size).json") + let jsonData = try JSONEncoder().encode(data) + try jsonData.write(to: fileURL) + } + + private func loadPreviewsFromCache(size: Int) throws -> RecipeListDTO { + let fileURL = cacheDirectory.appendingPathComponent("previews_first_\(size).json") + let data = try Data(contentsOf: fileURL) + return try JSONDecoder().decode(RecipeListDTO.self, from: data) + } } diff --git a/Presentation/Sources/Feature/Home/HomeReducer.swift b/Presentation/Sources/Feature/Home/HomeReducer.swift index f446335..de27fe6 100644 --- a/Presentation/Sources/Feature/Home/HomeReducer.swift +++ b/Presentation/Sources/Feature/Home/HomeReducer.swift @@ -232,13 +232,7 @@ private extension HomeReducer { await send(.checkIfNeedsPolling) case .failure(let error): - let errorType = ErrorDisplayType.dialog( - title: "오류가 발생하였습니다", - message: "인터넷 연결 상태를 확인하고 다시 시도해주세요.", - showBackButton: false - ) - - await send(.showError(errorType)) + await send(.refresh) } } } @@ -268,7 +262,34 @@ private extension HomeReducer { func reduceRefresh(_ state: inout HomeReducer.State) -> Effect { state.cursorId = nil state.recipes = [] - return .send(.onAppear) + state.listState = .idle + state.errorState.clearError() + + let pageSize = state.pageSize + + return .run { send in + let result = await fetchRecipePreviewsUseCase.execute(cursorId: nil, size: pageSize, fromCache: false) + + switch result { + case .success(let list): + let hasNext = list.hasNext + let cursorId = list.cursorId + let items = list.previews.map { $0.toRecipeListItem() } + + await send(.updatePageState(hasNext, cursorId)) + await send(.addRecipes(items)) + await send(.checkIfNeedsPolling) + + case .failure(let error): + let errorType = ErrorDisplayType.dialog( + title: "오류가 발생하였습니다", + message: "인터넷 연결 상태를 확인하고 다시 시도해주세요.", + showBackButton: false + ) + + await send(.showError(errorType)) + } + } } } From 57d94fc8c612507e41299b1e85bd678285dd6237 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=94=94=ED=95=B4?= Date: Tue, 5 Aug 2025 15:14:02 +0900 Subject: [PATCH 7/8] =?UTF-8?q?[FEAT]:=20=EB=A0=88=EC=8B=9C=ED=94=BC=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EC=8B=9C=20=EC=BA=90=EC=8B=9C=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=EB=B0=8F=20=EB=8B=A4=EC=8B=9C=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RecipePreviewDataSource.swift | 1 + .../RecipePreviewDataSourceImpl.swift | 7 +++++++ .../EmptyRecipePreviewDataSource.swift | 4 ++++ .../EmptyRecipePreviewRepository.swift | 6 +++++- .../ErrorRecipePreviewRepositoryImpl.swift | 4 ++++ .../MockRecipePreviewRepositoryImpl.swift | 3 +++ .../RecipePreviewRepositoryImpl.swift | 4 ++++ .../EmptyClearRecipePreviewCacheUseCase.swift | 9 +++++++++ Domain/Sources/Dependencies/UseCase.swift | 15 +++++++++++++++ .../Repository/RecipePreviewRepository.swift | 1 + .../ClearRecipePreviewCacheUseCase.swift | 5 +++++ .../ClearRecipePreviewCacheUseCaseImpl.swift | 18 ++++++++++++++++++ .../Sources/Feature/Home/HomeReducer.swift | 9 ++++++++- RecipeIt/Sources/DI/UseCase.swift | 7 +++++++ 14 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 Domain/Sources/Dependencies/EmptyImpl/EmptyClearRecipePreviewCacheUseCase.swift create mode 100644 Domain/Sources/UseCase/ClearRecipePreviewCache/ClearRecipePreviewCacheUseCase.swift create mode 100644 Domain/Sources/UseCase/ClearRecipePreviewCache/ClearRecipePreviewCacheUseCaseImpl.swift diff --git a/Data/Sources/DataSource/RecipePreview/RecipePreviewDataSource.swift b/Data/Sources/DataSource/RecipePreview/RecipePreviewDataSource.swift index 439e9fd..bc3c0a9 100644 --- a/Data/Sources/DataSource/RecipePreview/RecipePreviewDataSource.swift +++ b/Data/Sources/DataSource/RecipePreview/RecipePreviewDataSource.swift @@ -10,4 +10,5 @@ import Foundation public protocol RecipePreviewDataSource { func fetchPreview(recipeID: Int64) async throws -> RecipePreviewDTO func fetchPreviews(cursorId: Int?, size: Int, fromCache: Bool) async throws -> RecipeListDTO + func clearCache() throws } diff --git a/Data/Sources/DataSource/RecipePreview/RecipePreviewDataSourceImpl.swift b/Data/Sources/DataSource/RecipePreview/RecipePreviewDataSourceImpl.swift index 9ca994f..eccc9ad 100644 --- a/Data/Sources/DataSource/RecipePreview/RecipePreviewDataSourceImpl.swift +++ b/Data/Sources/DataSource/RecipePreview/RecipePreviewDataSourceImpl.swift @@ -78,4 +78,11 @@ public struct RecipePreviewDataSourceImpl: RecipePreviewDataSource { let data = try Data(contentsOf: fileURL) return try JSONDecoder().decode(RecipeListDTO.self, from: data) } + + public func clearCache() throws { + let contents = try fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: nil) + for fileURL in contents { + try fileManager.removeItem(at: fileURL) + } + } } diff --git a/Data/Sources/Dependencies/EmptyImpl/DataSource/EmptyRecipePreviewDataSource.swift b/Data/Sources/Dependencies/EmptyImpl/DataSource/EmptyRecipePreviewDataSource.swift index 406fef2..1dd36c3 100644 --- a/Data/Sources/Dependencies/EmptyImpl/DataSource/EmptyRecipePreviewDataSource.swift +++ b/Data/Sources/Dependencies/EmptyImpl/DataSource/EmptyRecipePreviewDataSource.swift @@ -15,4 +15,8 @@ public struct EmptyRecipePreviewDataSource: RecipePreviewDataSource { public func fetchPreviews(cursorId: Int?, size: Int, fromCache: Bool) async throws -> RecipeListDTO { throwUnimplemented() } + + public func clearCache() throws { + throwUnimplemented() + } } diff --git a/Data/Sources/Dependencies/EmptyImpl/Repository/EmptyRecipePreviewRepository.swift b/Data/Sources/Dependencies/EmptyImpl/Repository/EmptyRecipePreviewRepository.swift index 91f73f2..9590f15 100644 --- a/Data/Sources/Dependencies/EmptyImpl/Repository/EmptyRecipePreviewRepository.swift +++ b/Data/Sources/Dependencies/EmptyImpl/Repository/EmptyRecipePreviewRepository.swift @@ -14,7 +14,11 @@ struct EmptyRecipePreviewRepository: RecipePreviewRepository { throwUnimplemented() } - public func fetchPreviews(cursorId: Int?, size: Int, fromCache: Bool) async throws -> Entity.RecipeListVO { + func fetchPreviews(cursorId: Int?, size: Int, fromCache: Bool) async throws -> Entity.RecipeListVO { + throwUnimplemented() + } + + func clearCache() async throws { throwUnimplemented() } } diff --git a/Data/Sources/RepositoryImpl/RecipePreview/ErrorRecipePreviewRepositoryImpl.swift b/Data/Sources/RepositoryImpl/RecipePreview/ErrorRecipePreviewRepositoryImpl.swift index c285f89..1138fd7 100644 --- a/Data/Sources/RepositoryImpl/RecipePreview/ErrorRecipePreviewRepositoryImpl.swift +++ b/Data/Sources/RepositoryImpl/RecipePreview/ErrorRecipePreviewRepositoryImpl.swift @@ -23,4 +23,8 @@ public struct ErrorRecipePreviewRepositoryImpl: RecipePreviewRepository { public func fetchPreviews(cursorId: Int?, size: Int, fromCache: Bool) async throws -> Entity.RecipeListVO { throw RecipePreviewError.forcedFailure } + + public func clearCache() async throws { + throw RecipePreviewError.forcedFailure + } } diff --git a/Data/Sources/RepositoryImpl/RecipePreview/MockRecipePreviewRepositoryImpl.swift b/Data/Sources/RepositoryImpl/RecipePreview/MockRecipePreviewRepositoryImpl.swift index 57c5423..84f6061 100644 --- a/Data/Sources/RepositoryImpl/RecipePreview/MockRecipePreviewRepositoryImpl.swift +++ b/Data/Sources/RepositoryImpl/RecipePreview/MockRecipePreviewRepositoryImpl.swift @@ -138,4 +138,7 @@ public struct MockRecipePreviewRepositoryImpl: RecipePreviewRepository { cursorId: 10 ) } + + public func clearCache() async throws { + } } diff --git a/Data/Sources/RepositoryImpl/RecipePreview/RecipePreviewRepositoryImpl.swift b/Data/Sources/RepositoryImpl/RecipePreview/RecipePreviewRepositoryImpl.swift index 21b500c..282e767 100644 --- a/Data/Sources/RepositoryImpl/RecipePreview/RecipePreviewRepositoryImpl.swift +++ b/Data/Sources/RepositoryImpl/RecipePreview/RecipePreviewRepositoryImpl.swift @@ -25,4 +25,8 @@ public struct RecipePreviewRepositoryImpl: RecipePreviewRepository { let recipeList = try await dataSource.fetchPreviews(cursorId: cursorId, size: size, fromCache: fromCache) return recipeList.toDomain() } + + public func clearCache() async throws { + try dataSource.clearCache() + } } diff --git a/Domain/Sources/Dependencies/EmptyImpl/EmptyClearRecipePreviewCacheUseCase.swift b/Domain/Sources/Dependencies/EmptyImpl/EmptyClearRecipePreviewCacheUseCase.swift new file mode 100644 index 0000000..f4b2f7f --- /dev/null +++ b/Domain/Sources/Dependencies/EmptyImpl/EmptyClearRecipePreviewCacheUseCase.swift @@ -0,0 +1,9 @@ +import Foundation + +struct EmptyClearRecipePreviewCacheUseCase: ClearRecipePreviewCacheUseCase { + init() {} + + func execute() async -> Result { + return unimplementedFailure() + } +} diff --git a/Domain/Sources/Dependencies/UseCase.swift b/Domain/Sources/Dependencies/UseCase.swift index bafca2d..4a71f2b 100644 --- a/Domain/Sources/Dependencies/UseCase.swift +++ b/Domain/Sources/Dependencies/UseCase.swift @@ -263,3 +263,18 @@ public extension DependencyValues { set { self[DeleteRecipeUseCaseKey.self] = newValue } } } + +// MARK: - ClearRecipePreviewCacheUseCase DI + +public enum ClearRecipePreviewCacheUseCaseKey: TestDependencyKey { + #if DEBUG + public static var testValue: ClearRecipePreviewCacheUseCase = EmptyClearRecipePreviewCacheUseCase() + #endif +} + +public extension DependencyValues { + var clearRecipePreviewCacheUseCase: ClearRecipePreviewCacheUseCase { + get { self[ClearRecipePreviewCacheUseCaseKey.self] } + set { self[ClearRecipePreviewCacheUseCaseKey.self] = newValue } + } +} diff --git a/Domain/Sources/Repository/RecipePreviewRepository.swift b/Domain/Sources/Repository/RecipePreviewRepository.swift index b66fd09..406baa6 100644 --- a/Domain/Sources/Repository/RecipePreviewRepository.swift +++ b/Domain/Sources/Repository/RecipePreviewRepository.swift @@ -11,4 +11,5 @@ import Entity public protocol RecipePreviewRepository { func fetchPreview(for recipeID: Int64) async throws -> RecipePreviewVO func fetchPreviews(cursorId: Int?, size: Int, fromCache: Bool) async throws -> RecipeListVO + func clearCache() async throws } diff --git a/Domain/Sources/UseCase/ClearRecipePreviewCache/ClearRecipePreviewCacheUseCase.swift b/Domain/Sources/UseCase/ClearRecipePreviewCache/ClearRecipePreviewCacheUseCase.swift new file mode 100644 index 0000000..879b75d --- /dev/null +++ b/Domain/Sources/UseCase/ClearRecipePreviewCache/ClearRecipePreviewCacheUseCase.swift @@ -0,0 +1,5 @@ +import Foundation + +public protocol ClearRecipePreviewCacheUseCase { + func execute() async -> Result +} diff --git a/Domain/Sources/UseCase/ClearRecipePreviewCache/ClearRecipePreviewCacheUseCaseImpl.swift b/Domain/Sources/UseCase/ClearRecipePreviewCache/ClearRecipePreviewCacheUseCaseImpl.swift new file mode 100644 index 0000000..ce60b37 --- /dev/null +++ b/Domain/Sources/UseCase/ClearRecipePreviewCache/ClearRecipePreviewCacheUseCaseImpl.swift @@ -0,0 +1,18 @@ +import Foundation + +public struct ClearRecipePreviewCacheUseCaseImpl: ClearRecipePreviewCacheUseCase { + private let recipePreviewRepository: RecipePreviewRepository + + public init(recipePreviewRepository: RecipePreviewRepository) { + self.recipePreviewRepository = recipePreviewRepository + } + + public func execute() async -> Result { + do { + try await recipePreviewRepository.clearCache() + return .success(()) + } catch { + return .failure(error) + } + } +} diff --git a/Presentation/Sources/Feature/Home/HomeReducer.swift b/Presentation/Sources/Feature/Home/HomeReducer.swift index de27fe6..cf13802 100644 --- a/Presentation/Sources/Feature/Home/HomeReducer.swift +++ b/Presentation/Sources/Feature/Home/HomeReducer.swift @@ -18,6 +18,7 @@ struct HomeReducer { @Dependency(\.fetchRecipePreviewsUseCase) var fetchRecipePreviewsUseCase @Dependency(\.fetchRecipePreviewUseCase) var fetchRecipePreviewUseCase @Dependency(\.fetchCompletedRecipeConversionIDsUseCase) var fetchCompletedRecipeConversionIDsUseCase + @Dependency(\.clearRecipePreviewCacheUseCase) var clearRecipePreviewCacheUseCase @Dependency(\.logger) var logger @Dependency(\.continuousClock) var clock @@ -88,7 +89,13 @@ struct HomeReducer { case .recipes(.element(id: let recipeId, action: .delegate(.deleteRequested))): state.recipes.remove(id: recipeId) state.listState = state.recipes.isEmpty ? .empty : .loaded - return .none + + let pageSize = state.pageSize + return .run { send in + await clearRecipePreviewCacheUseCase.execute() + + let _ = await fetchRecipePreviewsUseCase.execute(cursorId: nil, size: pageSize, fromCache: false) + } case .recipes: return .none diff --git a/RecipeIt/Sources/DI/UseCase.swift b/RecipeIt/Sources/DI/UseCase.swift index 5c90433..e9cce32 100644 --- a/RecipeIt/Sources/DI/UseCase.swift +++ b/RecipeIt/Sources/DI/UseCase.swift @@ -127,3 +127,10 @@ extension DeleteRecipeUseCaseKey: @retroactive DependencyKey { return DeleteRecipeUseCaseImpl(recipeDetailRepository: recipeDetailRepository) }() } + +extension ClearRecipePreviewCacheUseCaseKey: @retroactive DependencyKey { + public static var liveValue: any ClearRecipePreviewCacheUseCase = { + @Dependency(\.recipePreviewRepository) var recipePreviewRepository + return ClearRecipePreviewCacheUseCaseImpl(recipePreviewRepository: recipePreviewRepository) + }() +} From 1be7a7e47723d1eaf67a44f206c73a949276c7fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=94=94=ED=95=B4?= Date: Tue, 23 Sep 2025 15:44:48 +0900 Subject: [PATCH 8/8] =?UTF-8?q?=EB=B0=B0=ED=8F=AC=20=EB=B2=84=EC=A0=84=201?= =?UTF-8?q?.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Data/Sources/Dependencies/DataSource.swift | 10 - Data/Sources/Dependencies/Repository.swift | 12 - Domain/Sources/Dependencies/UseCase.swift | 36 -- .../Dependencies/LoginManagerDependency.swift | 2 - .../ConversionProcessReducer.swift | 19 +- .../ConversionProcessView.swift | 15 + RecipeIt/Project.swift | 18 +- .../AppIcon.appiconset/100.png | Bin 0 -> 8125 bytes .../AppIcon.appiconset/144.png | Bin 0 -> 14705 bytes .../AppIcon.appiconset/152.png | Bin 0 -> 16132 bytes .../AppIcon.appiconset/167.png | Bin 0 -> 19077 bytes .../Assets.xcassets/AppIcon.appiconset/20.png | Bin 0 -> 761 bytes .../Assets.xcassets/AppIcon.appiconset/50.png | Bin 0 -> 3090 bytes .../Assets.xcassets/AppIcon.appiconset/72.png | Bin 0 -> 4977 bytes .../Assets.xcassets/AppIcon.appiconset/76.png | Bin 0 -> 5382 bytes .../AppIcon.appiconset/Contents.json | 78 ++++ RecipeIt/Sources/AppDelegate.swift | 18 + .../Audio/Sources/AudioServiceImpl.swift | 28 +- .../Dependencies/AudioDependency.swift | 2 - .../Dependencies/KeyChainDependency.swift | 2 - .../Dependencies/LocaleProviderKey.swift | 2 - .../Sources/Dependencies/LoggerKey.swift | 2 - Shared/Shared/Project.swift | 3 +- Tuist/Package.resolved | 11 +- Tuist/Package.swift | 3 +- auto_generate.py | 363 ------------------ clean_architecture_rule.md | 350 +++++++++++++++++ gemini.md | 28 ++ 28 files changed, 547 insertions(+), 455 deletions(-) create mode 100644 RecipeIt/Resources/Assets.xcassets/AppIcon.appiconset/100.png create mode 100644 RecipeIt/Resources/Assets.xcassets/AppIcon.appiconset/144.png create mode 100644 RecipeIt/Resources/Assets.xcassets/AppIcon.appiconset/152.png create mode 100644 RecipeIt/Resources/Assets.xcassets/AppIcon.appiconset/167.png create mode 100644 RecipeIt/Resources/Assets.xcassets/AppIcon.appiconset/20.png create mode 100644 RecipeIt/Resources/Assets.xcassets/AppIcon.appiconset/50.png create mode 100644 RecipeIt/Resources/Assets.xcassets/AppIcon.appiconset/72.png create mode 100644 RecipeIt/Resources/Assets.xcassets/AppIcon.appiconset/76.png delete mode 100755 auto_generate.py create mode 100644 clean_architecture_rule.md create mode 100644 gemini.md 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/RecipeIt/Project.swift b/RecipeIt/Project.swift index 0d46db3..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: """ @@ -58,7 +68,7 @@ let project = Project( ), .target( name: "RecipeIt-Mock", - destinations: .iOS, + destinations: [.iPhone], product: .app, bundleId: "com.puhahang.recipe-it.mock", infoPlist: .extendingDefault( @@ -80,7 +90,7 @@ let project = Project( .glob(pattern: "Resources/*.lproj/**") ], entitlements: .dictionary([ - "aps-environment": "development" + "aps-environment": "production" ]), dependencies: [ .project(target: "Domain", path: "../Domain"), 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 0000000000000000000000000000000000000000..bc67c7dc32cb5a8c95417b9753f3107a1ee65b8f GIT binary patch literal 8125 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 XjRKA2z?JM*00000NkvXXu0mjf85^Ah literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2ac008ab0da9e59eda11adfd5a593e817482f2a4 GIT binary patch literal 14705 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..36c3ac38e120bd65fccbff096e017a1a8e2c6ed9 GIT binary patch literal 761 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8181a3d4d8e66672837bc11d35bc379446d59c6f GIT binary patch literal 4977 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..deab042c6d73f3a8a4dd061d0ca048c1af0eecc5 GIT binary patch literal 5382 zcmV+h75VCkP)Px}z)3_wRCr$9Tnm&G)tUaPtGat0JO*Z%K|zpL3=oaU;+}Oko7KdK8%;!yXriJ* zqQ@A)7$H0*3W5lZ5<(Idf^6bG%zE}n_5m7qgAWE+vnE7X<-`X90s{i`Vjex+U3c@} zN6oF_wn8T|Nig(_ug_CV+^)SeeF2n*CU64w|)z}y$3jS0BC6f za@_#vcPc{O$3L5w`i;J~S6A?ex{W`o>d>mvf7h$Gc1Hiw0a7VoKs7LAIB>yuVC*M> zssZ9A-v_AAs^3O=jukL^D3K7izO@T@I!02@{Tt|xq*OgHu_k&}2C7QiB{#8PRos!9e

G*@ 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 literal 0 HcmV?d00001 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/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/auto_generate.py b/auto_generate.py deleted file mode 100755 index 40347e3..0000000 --- a/auto_generate.py +++ /dev/null @@ -1,363 +0,0 @@ -#!/usr/bin/env python3 -import sys -import os -import json -import re -from pathlib import Path - -try: - import google.generativeai as genai -except ImportError: - print("pip3 install google-generativeai") - sys.exit(1) - -def generate_code(feature_name, creation_type, target_component=None, method_description=None): - # API 설정 - api_key = os.getenv("GEMINI_API_KEY") - if not api_key: - print("export GEMINI_API_KEY='your-api-key'") - sys.exit(1) - - genai.configure(api_key=api_key) - model = genai.GenerativeModel('gemini-1.5-flash') - - # 프롬프트 생성 - if creation_type == "full": - prompt = """ -iOS Clean Architecture 패턴에 맞춰 '{feature_name}' 기능을 생성해주세요. - -다음 파일들의 Swift 코드를 생성하고, JSON 형식으로 응답해주세요: -1. Entity/Sources/VO/{feature_name}/{feature_name}VO.swift -2. Data/Sources/DataSource/{feature_name}/{feature_name}DataSource.swift -3. Data/Sources/DataSource/{feature_name}/{feature_name}DataSourceImpl.swift -4. Data/Sources/Dependencies/EmptyImpl/DataSource/Empty{feature_name}DataSource.swift -5. Data/Sources/Dependencies/DataSource.swift (새로운 DI 등록 코드만) -6. Domain/Sources/Repository/{feature_name}Repository.swift -7. Data/Sources/RepositoryImpl/{feature_name}/{feature_name}RepositoryImpl.swift -8. Data/Sources/RepositoryImpl/{feature_name}/Mock{feature_name}RepositoryImpl.swift -9. Data/Sources/Dependencies/EmptyImpl/Repository/Empty{feature_name}Repository.swift -10. Data/Sources/Dependencies/Repository.swift (새로운 DI 등록 코드만) -11. Domain/Sources/UseCase/{feature_name}/{feature_name}UseCase.swift -12. Domain/Sources/UseCase/{feature_name}/{feature_name}UseCaseImpl.swift -13. Domain/Sources/Dependencies/EmptyImpl/Empty{feature_name}UseCase.swift -14. Domain/Sources/Dependencies/UseCase.swift (새로운 DI 등록 코드만) -15. Data/Sources/DTO/{feature_name}DTO.swift -16. RecipeIt/Sources/DI/DataSource.swift (새로운 liveValue 등록 코드만) -17. RecipeIt/Sources/DI/Repository.swift (새로운 liveValue 등록 코드만) -18. RecipeIt/Sources/DI/UseCase.swift (새로운 liveValue 등록 코드만) - -접근 제어자 가이드라인: -- Entity VO: public struct (다른 모듈에서 사용) - - 프로퍼티: public let - - 생성자: public init -- Domain Repository/UseCase 프로토콜: public protocol (다른 모듈에서 사용) -- Data DataSource 프로토콜: public protocol (다른 모듈에서 사용) -- 구현체 (Impl): public struct/class (DI 주입용) -- Mock 구현체: public struct (DI 주입용) -- Empty DataSource: public struct (DI testValue용) -- Empty Repository: struct (internal, DI testValue용) -- Empty UseCase: struct (internal, 같은 모듈 내에서만 사용) -- DTO: public struct (DataSource 메소드 리턴 타입으로 사용) - - 프로퍼티: let (internal, Decodable 사용) - -Import 가이드라인: -- Entity VO: import Foundation -- DataSource 프로토콜: import Foundation -- DataSource 구현체: import Foundation, import Dependencies -- Empty DataSource: import Foundation -- Repository 프로토콜: import Foundation, import Entity -- Repository 구현체: import Foundation, import Entity, import Domain -- Mock Repository: import Foundation, import Entity, import Domain -- Empty Repository: import Foundation, import Entity, import Domain -- UseCase 프로토콜: import Foundation, import Entity -- UseCase 구현체: import Foundation, import Entity -- Empty UseCase: import Foundation, import Entity -- DTO: import Foundation - -구현 가이드라인: -- DataSource 구현체: public init 포함, 함수 시그니처만 제공하고 TODO 주석으로 실제 구현 필요함을 표시 -- Repository 구현체: public init 포함, 완전한 구현 제공 -- UseCase 구현체: public init 포함, 완전한 구현 제공 -- UseCase 함수명: 무조건 execute 사용 -- Mock 구현체들: public init 포함, 완전한 구현 제공 - -DI 등록 가이드라인: -- DataSource: Data/Sources/Dependencies/DataSource.swift에 TestDependencyKey 등록 (MARK 주석 + enum + DependencyValues extension) -- Repository: Data/Sources/Dependencies/Repository.swift에 TestDependencyKey 등록 (MARK 주석 + enum + DependencyValues extension) -- UseCase: Domain/Sources/Dependencies/UseCase.swift에 TestDependencyKey 등록 (MARK 주석 + enum + DependencyValues extension) -- RecipeIt 모듈: 각각 RecipeIt/Sources/DI/ 파일들에 DependencyKey extension으로 liveValue 등록 -- 패턴: extension {feature_name}DataSourceKey: @retroactive DependencyKey -- liveValue에는 실제 구현체 사용, testValue에는 Empty 구현체 사용 -- 변수명은 camelCase 사용 - -변수명 변환 예시: -- TestFeature → testFeature -- FavoriteRecipe → favoriteRecipe -- UserProfile → userProfile - -중요: DI 등록 파일들 (Dependencies/*.swift, DI/*.swift)의 경우, -기존 파일에 추가될 새로운 DI 등록 코드만 생성해주세요. -파일 전체 내용이 아닌 추가할 부분만 생성하면 됩니다. -예를 들어 Domain/Sources/Dependencies/UseCase.swift의 경우: -1. 기존 파일의 모든 내용 (기존 DI 등록들) -2. + 새로운 {feature_name}UseCase DI 등록 - -예시: 새로운 {feature_name}UseCase DI 등록 코드만 생성 - -DI 등록 패턴 예시: -- UseCase DI 등록: -``` -// MARK: - {feature_name}UseCase DI - -public enum {feature_name}UseCaseKey: TestDependencyKey {{ - #if DEBUG - public static let testValue: {feature_name}UseCase = Empty{feature_name}UseCase() - #endif -}} - -public extension DependencyValues {{ - var {{camelCase변수명}}UseCase: {feature_name}UseCase {{ - get {{ self[{feature_name}UseCaseKey.self] }} - set {{ self[{feature_name}UseCaseKey.self] = newValue }} - }} -}} -``` - -- Repository DI 등록: -``` -// MARK: - {feature_name}Repository DI - -public enum {feature_name}RepositoryKey: TestDependencyKey {{ - #if DEBUG - public static let testValue: {feature_name}Repository = Empty{feature_name}Repository() - #endif -}} - -public extension DependencyValues {{ - var {{camelCase변수명}}Repository: {feature_name}Repository {{ - get {{ self[{feature_name}RepositoryKey.self] }} - set {{ self[{feature_name}RepositoryKey.self] = newValue }} - }} -}} -``` - -- DataSource DI 등록: -``` -// MARK: - {feature_name}DataSource DI - -public enum {feature_name}DataSourceKey: TestDependencyKey {{ - #if DEBUG - public static let testValue: {feature_name}DataSource = Empty{feature_name}DataSource() - #endif -}} - -public extension DependencyValues {{ - var {{camelCase변수명}}DataSource: {feature_name}DataSource {{ - get {{ self[{feature_name}DataSourceKey.self] }} - set {{ self[{feature_name}DataSourceKey.self] = newValue }} - }} -}} -``` - -- RecipeIt 모듈 DI 등록 (liveValue): -``` -extension {feature_name}DataSourceKey: @retroactive DependencyKey {{ - public static let liveValue: any {feature_name}DataSource = {feature_name}DataSourceImpl() -}} - -extension {feature_name}RepositoryKey: @retroactive DependencyKey {{ - public static let liveValue: any {feature_name}Repository = {{ - @Dependency(\.{{camelCase변수명}}DataSource) var dataSource - return {feature_name}RepositoryImpl(dataSource: dataSource) - }}() -}} - -extension {feature_name}UseCaseKey: @retroactive DependencyKey {{ - public static let liveValue: any {feature_name}UseCase = {{ - @Dependency(\.{{camelCase변수명}}Repository) var repository - return {feature_name}UseCaseImpl(repository: repository) - }}() -}} -``` - -구현체 가이드라인: -- DataSource 구현체는 반드시 public init 포함 -- Repository 구현체는 반드시 public init 포함 -- UseCase 구현체도 반드시 public init 포함 - -응답 형식: -{{"files": [{{"path": "파일경로", "content": "Swift코드"}}]}} - -JSON 생성 주의사항: -- Swift 코드 내의 백슬래시(\)는 \\로 이스케이프 -- Swift 코드 내의 쌍따옴표(")는 \"로 이스케이프 -- 개행 문자는 \n으로 표현 -- JSON이 유효한 형식인지 확인 -""".format(feature_name=feature_name) - elif creation_type == "datasource_only": - prompt = """ -'{feature_name}' DataSource의 Swift 코드를 생성하고, JSON 형식으로 응답해주세요. - -생성할 파일들: -1. Data/Sources/DataSource/{feature_name}/{feature_name}DataSource.swift -2. Data/Sources/DataSource/{feature_name}/{feature_name}DataSourceImpl.swift -3. Data/Sources/Dependencies/EmptyImpl/DataSource/Empty{feature_name}DataSource.swift -4. Data/Sources/DTO/{feature_name}DTO.swift -5. Data/Sources/Dependencies/DataSource.swift (새로운 DI 등록 코드만) -6. RecipeIt/Sources/DI/DataSource.swift (새로운 liveValue 등록 코드만) - -접근 제어자: -- DataSource 프로토콜: public protocol -- DataSource 구현체: public struct/class -- Empty DataSource: public struct -- DTO: public struct - -Import 가이드라인: -- DataSource 프로토콜: import Foundation -- DataSource 구현체: import Foundation, import Dependencies -- Empty DataSource: import Foundation -- DTO: import Foundation - -응답 형식: -{{"files": [{{"path": "파일경로", "content": "Swift코드"}}]}} -""".format(feature_name=feature_name) - elif creation_type == "repository_only": - prompt = """ -'{feature_name}' Repository의 Swift 코드를 생성하고, JSON 형식으로 응답해주세요. - -생성할 파일들: -1. Domain/Sources/Repository/{feature_name}Repository.swift -2. Data/Sources/RepositoryImpl/{feature_name}/{feature_name}RepositoryImpl.swift -3. Data/Sources/RepositoryImpl/{feature_name}/Mock{feature_name}RepositoryImpl.swift -4. Data/Sources/Dependencies/EmptyImpl/Repository/Empty{feature_name}Repository.swift -5. Data/Sources/Dependencies/Repository.swift (새로운 DI 등록 코드만) -6. RecipeIt/Sources/DI/Repository.swift (새로운 liveValue 등록 코드만) - -접근 제어자: -- Repository 프로토콜: public protocol -- Repository 구현체: public struct/class -- Mock Repository: public struct (DI 주입용) -- Empty Repository: struct (internal) - -Import 가이드라인: -- Repository 프로토콜: import Foundation, import Entity -- Repository 구현체: import Foundation, import Entity, import Domain -- Mock Repository: import Foundation, import Entity, import Domain -- Empty Repository: import Foundation, import Entity, import Domain - -응답 형식: -{{"files": [{{"path": "파일경로", "content": "Swift코드"}}]}} -""".format(feature_name=feature_name) - elif creation_type == "usecase_only": - prompt = """ -'{feature_name}' UseCase의 Swift 코드를 생성하고, JSON 형식으로 응답해주세요. - -생성할 파일들: -1. Domain/Sources/UseCase/{feature_name}/{feature_name}UseCase.swift -2. Domain/Sources/UseCase/{feature_name}/{feature_name}UseCaseImpl.swift -3. Domain/Sources/Dependencies/EmptyImpl/Empty{feature_name}UseCase.swift -4. Domain/Sources/Dependencies/UseCase.swift (새로운 DI 등록 코드만) -5. RecipeIt/Sources/DI/UseCase.swift (새로운 liveValue 등록 코드만) - -접근 제어자: -- UseCase 프로토콜: public protocol -- UseCase 구현체: public struct/class -- Empty UseCase: struct (internal) - -Import 가이드라인: -- UseCase 프로토콜: import Foundation, import Entity -- UseCase 구현체: import Foundation, import Entity -- Empty UseCase: import Foundation, import Entity - -응답 형식: -{{"files": [{{"path": "파일경로", "content": "Swift코드"}}]}} -""".format(feature_name=feature_name) - elif creation_type == "method_add": - prompt = """ -기존 {target_component}에 '{method_description}' 메소드를 추가하는 Swift 코드를 생성하고, JSON 형식으로 응답해주세요. - -응답 형식: -{{"files": [{{"path": "수정할파일경로", "content": "추가할Swift메소드코드"}}]}} -""".format(target_component=target_component, method_description=method_description) - - # API 호출 - response = model.generate_content(prompt) - - # JSON 파싱 및 파일 생성 - json_match = re.search(r'```json\s*(.*?)\s*```', response.text, re.DOTALL) - if json_match: - json_text = json_match.group(1) - else: - json_text = response.text.strip() - - # JSON 파싱 전 전처리 (일반적인 문제 수정) - # json_text = json_text.replace('\\n', '\\\\n').replace('\\"', '\\\\"') - - try: - data = json.loads(json_text) - except json.JSONDecodeError: - print("❌ JSON 파싱 실패 - Gemini 응답을 다시 시도하세요") - return - - # 파일 생성 - for file_info in data["files"]: - file_path = Path(file_info["path"]) - content = file_info["content"] - - # JSON에서 온 개행문자를 실제 개행으로 변환 - content = content.replace('\\n', '\n').replace('\\"', '"') - - full_path = Path.cwd() / file_path - - # DI 등록 파일들은 기존 파일에 추가 - is_di_file = any(di_path in str(file_path) for di_path in [ - "Dependencies/DataSource.swift", - "Dependencies/Repository.swift", - "Dependencies/UseCase.swift", - "DI/DataSource.swift", - "DI/Repository.swift", - "DI/UseCase.swift" - ]) - - if is_di_file and full_path.exists(): - # 기존 파일에 새로운 내용 추가 - existing_content = full_path.read_text(encoding='utf-8') - new_content = existing_content + '\n' + content + '\n' - full_path.write_text(new_content, encoding='utf-8') - print(f"✅ Added to: {full_path}") - else: - # 일반 파일들은 새로 생성 - full_path.parent.mkdir(parents=True, exist_ok=True) - full_path.write_text(content, encoding='utf-8') - print(f"✅ Created: {full_path}") - -def main(): - if len(sys.argv) < 2: - print("사용법: python3 auto_generate.py <기능명>") - sys.exit(1) - - feature_name = sys.argv[1] - - print("1. full - 전체") - print("2. datasource_only - DataSource만") - print("3. repository_only - Repository만") - print("4. usecase_only - UseCase만") - print("5. method_add - 메소드 추가") - - choice = input("선택: ").strip() - - if choice == "1": - generate_code(feature_name, "full") - elif choice == "2": - generate_code(feature_name, "datasource_only") - elif choice == "3": - generate_code(feature_name, "repository_only") - elif choice == "4": - generate_code(feature_name, "usecase_only") - elif choice == "5": - target_component = input("대상 컴포넌트: ").strip() - method_description = input("추가할 메소드: ").strip() - generate_code(feature_name, "method_add", target_component, method_description) - -if __name__ == "__main__": - main() \ No newline at end of file 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