├── .gitignore ├── AppStoreClone ├── App │ ├── Project.swift │ ├── Resources │ │ ├── Assets.xcassets │ │ │ ├── AccentColor.colorset │ │ │ │ └── Contents.json │ │ │ ├── AppIcon.appiconset │ │ │ │ └── Contents.json │ │ │ └── Contents.json │ │ └── Preview Content │ │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ ├── Sources │ │ ├── AppStoreClone.swift │ │ ├── MainTabAction.swift │ │ ├── MainTabEnvironment.swift │ │ ├── MainTabReducer.swift │ │ ├── MainTabState.swift │ │ ├── MainTabView.swift │ │ └── SystemEnvironment.swift │ └── Tests │ │ └── .gitkeep ├── Features │ ├── Chos │ │ ├── Project.swift │ │ ├── Resources │ │ │ └── .gitkeep │ │ ├── Sources │ │ │ ├── Component │ │ │ │ ├── RatingStar │ │ │ │ │ └── RatingStar.swift │ │ │ │ └── SearchBar │ │ │ │ │ └── SearchBar.swift │ │ │ ├── Network │ │ │ │ ├── Core │ │ │ │ │ ├── AlamofireNetworking.swift │ │ │ │ │ ├── CompositingErrorDomain.swift │ │ │ │ │ └── ParameterConverter.swift │ │ │ │ ├── Protocol │ │ │ │ │ └── RequestType.swift │ │ │ │ ├── RequestModel │ │ │ │ │ └── APIRequestModel.swift │ │ │ │ ├── ResponseModel │ │ │ │ │ └── Search │ │ │ │ │ │ └── SearchResult.swift │ │ │ │ └── UseCase │ │ │ │ │ ├── AppStoreUseCase.swift │ │ │ │ │ └── Platform │ │ │ │ │ └── AppStoreUseCasePlatform.swift │ │ │ ├── Search │ │ │ │ ├── ChosSearchHomeView.swift │ │ │ │ └── SearchCore.swift │ │ │ └── SearchResult │ │ │ │ └── SearchResultCore.swift │ │ └── Tests │ │ │ └── .gitkeep │ ├── Havi │ │ ├── Project.swift │ │ ├── Resources │ │ │ └── .gitkeep │ │ ├── Sources │ │ │ ├── PresentationLayer │ │ │ │ ├── HaviSearchDetail │ │ │ │ │ ├── HaviSearchDetailAction.swift │ │ │ │ │ ├── HaviSearchDetailEnvironment.swift │ │ │ │ │ ├── HaviSearchDetailReducer.swift │ │ │ │ │ ├── HaviSearchDetailState.swift │ │ │ │ │ └── HaviSearchDetailView.swift │ │ │ │ ├── HaviSearchHome │ │ │ │ │ ├── HaviSearchFeature.swift │ │ │ │ │ └── HaviSearchHomeView.swift │ │ │ │ └── HaviSearchResult │ │ │ │ │ ├── HaviSearchResultAction.swift │ │ │ │ │ ├── HaviSearchResultEnvironment.swift │ │ │ │ │ ├── HaviSearchResultReducer.swift │ │ │ │ │ ├── HaviSearchResultState.swift │ │ │ │ │ └── HaviSearchResultView.swift │ │ │ └── SearchDomain │ │ │ │ ├── Search.swift │ │ │ │ └── SearchClient.swift │ │ └── Tests │ │ │ └── .gitkeep │ └── Heizel │ │ ├── Project.swift │ │ ├── Resources │ │ └── .gitkeep │ │ ├── Sources │ │ └── HeizelTest.swift │ │ └── Tests │ │ └── .gitkeep └── Modules │ ├── Core │ ├── Project.swift │ ├── Resources │ │ └── .gitkeep │ ├── Sources │ │ ├── .gitkeep │ │ ├── Extensions │ │ │ ├── Sequence+async.swift │ │ │ ├── Subject+emittingValues.swift │ │ │ ├── Task+delayed.swift │ │ │ └── Task+retrying.swift │ │ └── Network │ │ │ ├── Endpoint.swift │ │ │ ├── NetworkLogger.swift │ │ │ ├── NetworkRepository.swift │ │ │ └── URLSessionType.swift │ └── Tests │ │ ├── .gitkeep │ │ ├── Data │ │ ├── appInfoKakaoBank.json │ │ └── appInfoListKakaoBank.json │ │ ├── NetworkRepositoryTest.swift │ │ └── URLSessionMock.swift │ └── ThirdPartyManager │ ├── Project.swift │ ├── Resources │ └── .gitkeep │ ├── Sources │ ├── .gitkeep │ └── TempSource.swift │ └── Tests │ └── .gitkeep ├── Configurations ├── Base │ ├── Common.xcconfig │ ├── Projects │ │ ├── Project-Beta.xcconfig │ │ ├── Project-Development.xcconfig │ │ ├── Project-Production.xcconfig │ │ ├── Project-QA.xcconfig │ │ ├── Project-Test.xcconfig │ │ └── Shared.xcconfig │ └── Targets │ │ ├── Application.xcconfig │ │ ├── Extension.xcconfig │ │ ├── Framework.xcconfig │ │ └── StaticLibrary.xcconfig └── iOS │ ├── iOS-Example.xcconfig │ ├── iOS-Extension.xcconfig │ ├── iOS-Framework.xcconfig │ ├── iOS-StaticLibrary.xcconfig │ └── iOS-Tests.xcconfig ├── README.md ├── Tooling ├── TCA.xctemplate │ ├── TemplateIcon.png │ ├── TemplateIcon@2x.png │ ├── TemplateInfo.plist │ ├── ___FILEBASENAME___Feature.swift │ └── ___FILEBASENAME___View.swift └── install-xcode-template.sh ├── Tuist ├── Config.swift ├── Dependencies.swift └── ProjectDescriptionHelpers │ ├── Action+Template.swift │ ├── Dependencies+Template.swift │ ├── Project+Extension.swift │ ├── Project+Templates.swift │ └── XCConfig.swift └── Workspace.swift /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/macos,xcode,swift,cocoapods 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=macos,xcode,swift,cocoapods 4 | 5 | ### CocoaPods ### 6 | ## CocoaPods GitIgnore Template 7 | 8 | # CocoaPods - Only use to conserve bandwidth / Save time on Pushing 9 | # - Also handy if you have a large number of dependant pods 10 | # - AS PER https://guides.cocoapods.org/using/using-cocoapods.html NEVER IGNORE THE LOCK FILE 11 | Pods/ 12 | 13 | ### macOS ### 14 | # General 15 | .DS_Store 16 | .AppleDouble 17 | .LSOverride 18 | 19 | # Icon must end with two \r 20 | Icon 21 | 22 | 23 | # Thumbnails 24 | ._* 25 | 26 | # Files that might appear in the root of a volume 27 | .DocumentRevisions-V100 28 | .fseventsd 29 | .Spotlight-V100 30 | .TemporaryItems 31 | .Trashes 32 | .VolumeIcon.icns 33 | .com.apple.timemachine.donotpresent 34 | 35 | # Directories potentially created on remote AFP share 36 | .AppleDB 37 | .AppleDesktop 38 | Network Trash Folder 39 | Temporary Items 40 | .apdisk 41 | 42 | ### Swift ### 43 | # Xcode 44 | # 45 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 46 | 47 | ## User settings 48 | xcuserdata/ 49 | 50 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 51 | *.xcscmblueprint 52 | *.xccheckout 53 | 54 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 55 | build/ 56 | DerivedData/ 57 | *.moved-aside 58 | *.pbxuser 59 | !default.pbxuser 60 | *.mode1v3 61 | !default.mode1v3 62 | *.mode2v3 63 | !default.mode2v3 64 | *.perspectivev3 65 | !default.perspectivev3 66 | 67 | ## Obj-C/Swift specific 68 | *.hmap 69 | 70 | ## App packaging 71 | *.ipa 72 | *.dSYM.zip 73 | *.dSYM 74 | 75 | ## Playgrounds 76 | timeline.xctimeline 77 | playground.xcworkspace 78 | 79 | # Swift Package Manager 80 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 81 | # Packages/ 82 | # Package.pins 83 | # Package.resolved 84 | # *.xcodeproj 85 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 86 | # hence it is not needed unless you have added a package configuration file to your project 87 | # .swiftpm 88 | 89 | .build/ 90 | 91 | # CocoaPods 92 | # We recommend against adding the Pods directory to your .gitignore. However 93 | # you should judge for yourself, the pros and cons are mentioned at: 94 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 95 | # Pods/ 96 | # Add this line if you want to avoid checking in source code from the Xcode workspace 97 | # *.xcworkspace 98 | 99 | # Carthage 100 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 101 | # Carthage/Checkouts 102 | 103 | Carthage/Build/ 104 | 105 | # Accio dependency management 106 | Dependencies/ 107 | .accio/ 108 | 109 | # fastlane 110 | # It is recommended to not store the screenshots in the git repo. 111 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 112 | # For more information about the recommended setup visit: 113 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 114 | 115 | fastlane/report.xml 116 | fastlane/Preview.html 117 | fastlane/screenshots/**/*.png 118 | fastlane/test_output 119 | 120 | # Code Injection 121 | # After new code Injection tools there's a generated folder /iOSInjectionProject 122 | # https://github.com/johnno1962/injectionforxcode 123 | 124 | iOSInjectionProject/ 125 | 126 | ### Xcode ### 127 | # Xcode 128 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 129 | 130 | 131 | 132 | 133 | ## Gcc Patch 134 | /*.gcno 135 | 136 | ### Tuist derived files ### 137 | graph.dot 138 | Derived/ 139 | 140 | ### Projects ### 141 | *.xcodeproj 142 | *.xcworkspace 143 | 144 | 145 | # End of https://www.toptal.com/developers/gitignore/api/macos,xcode,swift,cocoapods 146 | -------------------------------------------------------------------------------- /AppStoreClone/App/Project.swift: -------------------------------------------------------------------------------- 1 | import ProjectDescription 2 | import ProjectDescriptionHelpers 3 | 4 | let projectName: String = "AppStoreClone" 5 | let organizationName: String = "havi" 6 | 7 | let project = Project.project( 8 | name: projectName, 9 | organizationName: organizationName, 10 | product: .app, 11 | dependencies: [ 12 | .Havi, 13 | .Chos, 14 | .Heizel 15 | ], 16 | schemes: [] 17 | ) 18 | -------------------------------------------------------------------------------- /AppStoreClone/App/Resources/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /AppStoreClone/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /AppStoreClone/App/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /AppStoreClone/App/Resources/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /AppStoreClone/App/Sources/AppStoreClone.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UnsplashApp.swift 3 | // Unsplash 4 | // 5 | // Created by 한상진 on 2022/01/26. 6 | // 7 | 8 | import SwiftUI 9 | import ComposableArchitecture 10 | 11 | @main 12 | struct AppStoreClone: App { 13 | var body: some Scene { 14 | WindowGroup { 15 | MainTabView( 16 | store: Store( 17 | initialState: MainTabState(), 18 | reducer: MainTabReducer, 19 | environment: .live(environment: MainTabEnvironment()) 20 | ) 21 | ) 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /AppStoreClone/App/Sources/MainTabAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainTabAction.swift 3 | // AppStoreClone 4 | // 5 | // Created by 한상진 on 2022/04/01. 6 | // Copyright © 2022 havi. All rights reserved. 7 | // 8 | 9 | import ThirdPartyManager 10 | import ComposableArchitecture 11 | 12 | import Havi 13 | import Chos 14 | 15 | enum MainTabAction: Equatable { 16 | case haviAction(HaviSearchHomeAction) 17 | case chosAction(ChosSearchAction) 18 | // chosAction 19 | // heizelAction 20 | } 21 | -------------------------------------------------------------------------------- /AppStoreClone/App/Sources/MainTabEnvironment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainTabEnvironment.swift 3 | // AppStoreClone 4 | // 5 | // Created by 한상진 on 2022/04/01. 6 | // Copyright © 2022 havi. All rights reserved. 7 | // 8 | 9 | import ThirdPartyManager 10 | import ComposableArchitecture 11 | 12 | struct MainTabEnvironment: Equatable { 13 | } 14 | -------------------------------------------------------------------------------- /AppStoreClone/App/Sources/MainTabReducer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainTabReducer.swift 3 | // AppStoreClone 4 | // 5 | // Created by 한상진 on 2022/04/01. 6 | // Copyright © 2022 havi. All rights reserved. 7 | // 8 | 9 | import ThirdPartyManager 10 | import ComposableArchitecture 11 | 12 | import Havi 13 | import Core 14 | import Chos 15 | 16 | let MainTabReducer = Reducer< 17 | MainTabState, 18 | MainTabAction, 19 | SystemEnvironment 20 | >.combine( 21 | haviSearchHomeReducer.pullback( 22 | state: \.haviSearchState, 23 | action: /MainTabAction.haviAction, 24 | environment: { _ in 25 | let session = URLSession(configuration: .default) 26 | let network = NetworkRepositoryImpl(with: session) 27 | return HaviSearchHomeEnvironment(searchClient: .live) 28 | } 29 | ), 30 | chosSearchReducer.pullback( 31 | state: \.chosSearchState, 32 | action: /MainTabAction.chosAction, 33 | environment: { _ in 34 | .init( 35 | appStoreUsecase: AppStoreUseCasePlatform( 36 | apiNetworking: AlamofireAPINetworking.init(domainURL: "https://itunes.apple.com")), 37 | receiveQueue: DispatchQueue.main) 38 | }) 39 | // 40 | // 41 | ) 42 | // .debug() 43 | -------------------------------------------------------------------------------- /AppStoreClone/App/Sources/MainTabState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainTabState.swift 3 | // AppStoreClone 4 | // 5 | // Created by 한상진 on 2022/04/01. 6 | // Copyright © 2022 havi. All rights reserved. 7 | // 8 | 9 | import ThirdPartyManager 10 | import ComposableArchitecture 11 | 12 | import Havi 13 | import Chos 14 | 15 | struct MainTabState: Equatable { 16 | var haviSearchState: HaviSearchHomeState = .init() 17 | var chosSearchState: ChosSearchState = .init() 18 | // heizel 19 | } 20 | -------------------------------------------------------------------------------- /AppStoreClone/App/Sources/MainTabView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TabView.swift 3 | // AppStoreClone 4 | // 5 | // Created by 한상진 on 2022/03/25. 6 | // Copyright © 2022 havi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | import Havi 12 | import Heizel 13 | import Chos 14 | import Core 15 | import ThirdPartyManager 16 | 17 | import ComposableArchitecture 18 | 19 | struct MainTabView: View { 20 | 21 | let store: Store 22 | 23 | var body: some View { 24 | WithViewStore(self.store.stateless) { _ in 25 | TabView { 26 | ChosTab 27 | HaviTab 28 | HeizelTab 29 | } 30 | } 31 | } 32 | 33 | private var ChosTab: some View { 34 | ChosSearchHomeView( 35 | store: store.scope( 36 | state: \.chosSearchState, 37 | action: MainTabAction.chosAction 38 | ) 39 | ) 40 | .tabItem { 41 | Image(systemName: "magnifyingglass") 42 | Text("Chos") 43 | } 44 | } 45 | 46 | private var HaviTab: some View { 47 | HaviSearchHomeView( 48 | store: store.scope( 49 | state: \.haviSearchState, 50 | action: MainTabAction.haviAction 51 | ) 52 | ) 53 | .tabItem { 54 | Label("Havi", systemImage: "magnifyingglass") 55 | } 56 | } 57 | 58 | private var HeizelTab: some View { 59 | HeizelSearchHomeView() 60 | .tabItem { 61 | Image(systemName: "magnifyingglass") 62 | Text("Heizel") 63 | } 64 | } 65 | } 66 | 67 | //struct TabView_Previews: PreviewProvider { 68 | // static var previews: some View { MainTabView( 69 | // store: .init( 70 | // initialState: .init(), 71 | // reducer: MainTabReducer, 72 | // environment: .init() 73 | // ) 74 | // ) 75 | // } 76 | //} 77 | -------------------------------------------------------------------------------- /AppStoreClone/App/Sources/SystemEnvironment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SystemEnvironment.swift 3 | // AppStoreClone 4 | // 5 | // Created by 한상진 on 2022/04/10. 6 | // Copyright © 2022 havi. All rights reserved. 7 | // 8 | 9 | import ComposableArchitecture 10 | 11 | @dynamicMemberLookup 12 | struct SystemEnvironment { 13 | var environment: Environment 14 | 15 | subscript( 16 | dynamicMember keyPath: WritableKeyPath 17 | ) -> Dependency { 18 | get { self.environment[keyPath: keyPath] } 19 | set { self.environment[keyPath: keyPath] = newValue } 20 | } 21 | 22 | var mainQueue: () -> AnySchedulerOf 23 | var decoder: () -> JSONDecoder 24 | 25 | private static func decoder() -> JSONDecoder { 26 | let decoder = JSONDecoder() 27 | decoder.keyDecodingStrategy = .convertFromSnakeCase 28 | return decoder 29 | } 30 | 31 | static func live(environment: Environment) -> Self { 32 | Self(environment: environment, mainQueue: { .main }, decoder: decoder) 33 | } 34 | 35 | static func dev(environment: Environment) -> Self { 36 | Self(environment: environment, mainQueue: { .main }, decoder: decoder) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /AppStoreClone/App/Tests/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/havilog/AppStoreClone/6341fb890d9efaf1d64e607ec5fe7b7f48019643/AppStoreClone/App/Tests/.gitkeep -------------------------------------------------------------------------------- /AppStoreClone/Features/Chos/Project.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Project.swift 3 | // ProjectDescriptionHelpers 4 | // 5 | // Created by 한상진 on 2022/01/27. 6 | // 7 | 8 | import ProjectDescription 9 | import ProjectDescriptionHelpers 10 | 11 | let project = Project.framework( 12 | name: "Chos", 13 | dependencies: [ 14 | .Core 15 | ] 16 | ) 17 | -------------------------------------------------------------------------------- /AppStoreClone/Features/Chos/Resources/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/havilog/AppStoreClone/6341fb890d9efaf1d64e607ec5fe7b7f48019643/AppStoreClone/Features/Chos/Resources/.gitkeep -------------------------------------------------------------------------------- /AppStoreClone/Features/Chos/Sources/Component/RatingStar/RatingStar.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | public struct FiveStarView: View { 5 | let rating: CGFloat 6 | let color: Color 7 | let width: CGFloat 8 | let height: CGFloat 9 | 10 | public init( 11 | rating: CGFloat, 12 | color: Color = .gray, 13 | width: CGFloat = 40, 14 | height: CGFloat = 40 15 | ) { 16 | self.rating = rating 17 | self.color = color 18 | self.width = width 19 | self.height = height 20 | } 21 | 22 | public var body: some View { 23 | RatingStar(rating: rating, color: color, width: width, height: height) 24 | } 25 | } 26 | 27 | struct ForegroundStar: View { 28 | let rating: CGFloat 29 | let color: Color 30 | let index: Int 31 | let width: CGFloat 32 | let height: CGFloat 33 | 34 | var maskRatio: CGFloat { 35 | let mask = rating - CGFloat(index) 36 | 37 | switch mask { 38 | case 1...: return 1 39 | case ..<0: return 0 40 | default: return mask 41 | } 42 | } 43 | 44 | init(rating: CGFloat, color: Color, index: Int, width: CGFloat, height: CGFloat) { 45 | self.rating = rating 46 | self.color = color 47 | self.index = index 48 | self.width = width 49 | self.height = height 50 | } 51 | 52 | var body: some View { 53 | Image(systemName: "star.fill") 54 | .resizable() 55 | .frame(width: width, height: height) 56 | .foregroundColor(self.color) 57 | .mask( 58 | Rectangle() 59 | .size( 60 | width: 40 * self.maskRatio, 61 | height: 40 62 | ) 63 | ) 64 | } 65 | } 66 | 67 | private struct RatingStar: View { 68 | let rating: CGFloat 69 | let color: Color 70 | let width: CGFloat 71 | let height: CGFloat 72 | 73 | init(rating: CGFloat, color: Color, width: CGFloat, height: CGFloat) { 74 | self.rating = rating 75 | self.color = color 76 | self.width = width 77 | self.height = height 78 | } 79 | 80 | var body: some View { 81 | HStack { 82 | ForEach(0..<5) { index in 83 | ZStack(alignment: .center) { 84 | Image(systemName: "star") 85 | .resizable() 86 | .frame(width: width, height: height) 87 | .foregroundColor(color) 88 | ForegroundStar( 89 | rating: self.rating, 90 | color: self.color, 91 | index: index, 92 | width: width, 93 | height: height) 94 | } 95 | } 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /AppStoreClone/Features/Chos/Sources/Component/SearchBar/SearchBar.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | struct SearchBar { 5 | @Binding var text: String 6 | @State var isEditing: Bool = false 7 | 8 | public init(text: Binding) { 9 | self._text = text 10 | } 11 | 12 | private enum Const { 13 | static let contentsPadding: EdgeInsets = .init(top: 8, leading: 16, bottom: 8, trailing: 16) 14 | } 15 | } 16 | 17 | extension SearchBar: View { 18 | var body: some View { 19 | HStack { 20 | TextField("검색", text: $text) 21 | .padding() 22 | .padding(.horizontal, 25) 23 | .background(Color(.systemGray6)) 24 | .cornerRadius(8) 25 | .overlay( 26 | HStack { 27 | Image(systemName: "magnifyingglass") 28 | .foregroundColor(.gray) 29 | .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) 30 | .padding(.leading, 8) 31 | 32 | if isEditing { 33 | Button(action: { 34 | text = "" 35 | }) { 36 | Image(systemName: "multiply.circle.fill") 37 | .foregroundColor(.gray) 38 | .padding(.trailing, 8) 39 | } 40 | } 41 | } 42 | ) 43 | .onTapGesture { isEditing = true } 44 | 45 | if isEditing { 46 | Button(action: { 47 | isEditing = false 48 | text = "" 49 | }) { 50 | Text("취소") 51 | } 52 | .transition(.move(edge: .trailing)) 53 | .animation(.easeOut) 54 | } 55 | } 56 | .padding(.init(top: 8, leading: 16, bottom: 8, trailing: 16)) 57 | .transition(.move(edge: .trailing)) 58 | .animation(.easeOut) 59 | } 60 | } 61 | 62 | struct SearchBar_Previews: PreviewProvider { 63 | static var previews: some View { 64 | SearchBar(text: .init(get: { "" }, set: { _ in })) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /AppStoreClone/Features/Chos/Sources/Network/Core/AlamofireNetworking.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AlamofireNetworking.swift 3 | // Chos 4 | // 5 | // Created by 조상호 on 2022/03/29. 6 | // Copyright © 2022 havi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Alamofire 11 | import Combine 12 | import ThirdPartyManager 13 | import Moya 14 | 15 | class SessionManager { 16 | private init() {} 17 | 18 | static let shared = SessionManager() 19 | 20 | fileprivate let session: Session = { 21 | let session = Session() 22 | 23 | session.sessionConfiguration.httpCookieAcceptPolicy = .always 24 | 25 | return session 26 | }() 27 | } 28 | 29 | public struct AlamofireAPINetworking: APINetworking { 30 | public let domainURL: String 31 | 32 | public init(domainURL: String) { 33 | self.domainURL = domainURL 34 | } 35 | } 36 | 37 | private struct AlamofireAPIProducer { 38 | static var responseProducer: (Int?, Data?, AFError?) -> Result { 39 | { statusCode, data, _ in 40 | guard let statusCode = statusCode else { 41 | return .failure(.networkNotFound) 42 | } 43 | 44 | guard let data = data else { return .failure(.notFoundData) } 45 | 46 | switch statusCode { 47 | case 401: 48 | return .failure(CompositingErrorDomain.networkNotFound) 49 | case 200 ... 299: 50 | return .success(data) 51 | default: break 52 | } 53 | 54 | return statusCode == 404 55 | ? .failure(CompositingErrorDomain.networkNotFound) 56 | : .failure(CompositingErrorDomain.networkUnknown) 57 | } 58 | } 59 | } 60 | 61 | extension AlamofireAPINetworking: CombineAPINetworkRequestType { 62 | public func request(model: AlamofireRequestModelConvertable & DefaultParameterValue) -> AnyPublisher { 63 | let fullURL = model.makeCompositedURLPath(domainURL: domainURL) 64 | let parameters = model.makeCompositedRequestParameter() 65 | 66 | return Future { promise in 67 | SessionManager.shared.session.request( 68 | fullURL, 69 | method: model.AFMethod, 70 | parameters: parameters, 71 | encoding: model.AFEncoding, 72 | headers: model.AFHeaders) 73 | .responseData { fetch in 74 | let logMessage = """ 75 | --------- API Pull -------- 76 | --------- Requeust Info -------- 77 | \(fetch.request?.description ?? "null") 78 | --------- Requeust Parameter -------- 79 | \(parameters) 80 | --------- Requeust Body -------- 81 | \(String(data: fetch.request?.httpBody ?? Data(), encoding: .utf8) ?? "null") 82 | --------- Response -------- 83 | \(fetch.response?.description ?? "null") 84 | ---------- Response Data -------- 85 | \(String(data: fetch.data ?? Data(), encoding: .utf8) ?? "null") 86 | ---------- Response Error -------- 87 | \(fetch.error.debugDescription) 88 | """ 89 | 90 | print(logMessage) 91 | 92 | promise(AlamofireAPIProducer.responseProducer(fetch.response?.statusCode, fetch.value, fetch.error)) 93 | } 94 | } 95 | .eraseToAnyPublisher() 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /AppStoreClone/Features/Chos/Sources/Network/Core/CompositingErrorDomain.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CompositingError.swift 3 | // Chos 4 | // 5 | // Created by 조상호 on 2022/03/29. 6 | // Copyright © 2022 havi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public enum CompositingErrorDomain: Error { 12 | case networkNotFound 13 | case notFoundData 14 | case networkUnknown 15 | case networkRemoteFail(RemoteErrorDomain) 16 | case other(Error) 17 | 18 | public var displayMessage: String { 19 | 20 | switch self { 21 | case .networkNotFound: 22 | return "Network is offline. please reconnect the Network." 23 | case let .networkRemoteFail(domain): 24 | return domain.message ?? "" 25 | default: 26 | return otherMessage 27 | } 28 | } 29 | 30 | private var otherMessage: String { 31 | "Missing Error" 32 | } 33 | } 34 | 35 | extension CompositingErrorDomain: Equatable { 36 | public static func == (lhs: Self, rhs: Self) -> Bool { 37 | lhs.displayMessage == rhs.displayMessage 38 | } 39 | } 40 | 41 | public struct RemoteErrorDomain: Decodable, Equatable { 42 | public init(code: Int?, message: String?) { 43 | self.code = code 44 | self.message = message 45 | } 46 | 47 | public var code: Int? 48 | public var message: String? 49 | 50 | private enum CodingKeys: String, CodingKey { 51 | case code, message 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /AppStoreClone/Features/Chos/Sources/Network/Core/ParameterConverter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ParameterConverter.swift 3 | // Chos 4 | // 5 | // Created by 조상호 on 2022/04/05. 6 | // Copyright © 2022 havi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public struct ParameterConverter { 12 | static func build(model: Encodable) -> [String: Any] { 13 | guard let data = try? model.toEncodedData(), 14 | let dic = try? JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed) as? [String: Any] 15 | else { return [:] } 16 | 17 | return dic 18 | } 19 | } 20 | 21 | 22 | extension Encodable { 23 | func toEncodedData() throws -> Data { 24 | try JSONEncoder().encode(self) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /AppStoreClone/Features/Chos/Sources/Network/Protocol/RequestType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RequestType.swift 3 | // Chos 4 | // 5 | // Created by 조상호 on 2022/03/29. 6 | // Copyright © 2022 havi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Alamofire 11 | import Combine 12 | 13 | enum RequestModelExtraType { 14 | enum Method { 15 | case get, delete, post, put 16 | } 17 | 18 | enum Encoding { 19 | case `default`, json 20 | } 21 | } 22 | 23 | public protocol APINetworking { 24 | var domainURL: String { get } 25 | } 26 | 27 | protocol RequestModelType { 28 | var baseURL: String { get } 29 | var pathURL: String { get } 30 | var headers: [String: Any] { get } 31 | var parameters: [String: Any] { get } 32 | var method: RequestModelExtraType.Method { get } 33 | var encoding: RequestModelExtraType.Encoding { get } 34 | } 35 | 36 | public protocol AlamofireRequestModelConvertable { 37 | var AFHeaders: Alamofire.HTTPHeaders? { get } 38 | var AFMethod: Alamofire.HTTPMethod { get } 39 | var AFEncoding: Alamofire.ParameterEncoding { get } 40 | } 41 | 42 | extension AlamofireRequestModelConvertable where Self: RequestModelType { 43 | var AFHeaders: Alamofire.HTTPHeaders? { 44 | let headerList = headers 45 | .map{ ($0.key, "\($0.value)") } 46 | .map(Alamofire.HTTPHeader.init(name: value:)) 47 | 48 | return Alamofire.HTTPHeaders(headerList) 49 | } 50 | var AFMethod: Alamofire.HTTPMethod { 51 | switch method { 52 | case .get: return Alamofire.HTTPMethod.get 53 | case .delete: return Alamofire.HTTPMethod.delete 54 | case .post: return Alamofire.HTTPMethod.post 55 | case .put: return Alamofire.HTTPMethod.put 56 | } 57 | } 58 | 59 | var AFEncoding: Alamofire.ParameterEncoding { 60 | switch encoding { 61 | case .default: return URLEncoding.default 62 | case .json: return JSONEncoding(options: .fragmentsAllowed) 63 | } 64 | } 65 | } 66 | 67 | public protocol DefaultParameterValue { 68 | func makeCompositedURLPath(domainURL: String) -> String 69 | func makeCompositedRequestParameter() -> [String: Any] 70 | } 71 | 72 | extension DefaultParameterValue where Self: RequestModelType { 73 | func makeCompositedURLPath(domainURL: String) -> String { 74 | let url = baseURL.isEmpty ? domainURL : baseURL 75 | return url + "/search" 76 | } 77 | 78 | func makeCompositedRequestParameter() -> [String: Any] { 79 | parameters 80 | } 81 | } 82 | 83 | public protocol CombineAPINetworkRequestType { 84 | func request(model: AlamofireRequestModelConvertable & DefaultParameterValue) -> AnyPublisher 85 | } 86 | -------------------------------------------------------------------------------- /AppStoreClone/Features/Chos/Sources/Network/RequestModel/APIRequestModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // APIRequestModel.swift 3 | // Chos 4 | // 5 | // Created by 조상호 on 2022/04/05. 6 | // Copyright © 2022 havi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct APIRequestModel: RequestModelType { 12 | let baseURL: String 13 | let pathURL: String 14 | let headers: [String : Any] 15 | let parameters: [String : Any] 16 | let method: RequestModelExtraType.Method 17 | let encoding: RequestModelExtraType.Encoding 18 | 19 | public init( 20 | baseURL: String = "", 21 | pathURL: String = "", 22 | headers: [String: Any] = [:], 23 | parameters: [String: Any] = [:], 24 | method: RequestModelExtraType.Method, 25 | encoding: RequestModelExtraType.Encoding = .default) 26 | { 27 | self.baseURL = baseURL 28 | self.pathURL = pathURL 29 | self.headers = headers 30 | self.parameters = parameters 31 | self.method = method 32 | self.encoding = encoding 33 | } 34 | } 35 | 36 | extension APIRequestModel: AlamofireRequestModelConvertable, DefaultParameterValue { 37 | 38 | } 39 | -------------------------------------------------------------------------------- /AppStoreClone/Features/Chos/Sources/Network/ResponseModel/Search/SearchResult.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchResult.swift 3 | // Chos 4 | // 5 | // Created by 조상호 on 2022/03/29. 6 | // Copyright © 2022 havi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public enum SearchDomain { 12 | 13 | public struct SearchResult: Equatable, Codable { 14 | public init(resultCount: Int, results: [AppData]) { 15 | self.resultCount = resultCount 16 | self.results = results 17 | } 18 | 19 | public let resultCount: Int 20 | public let results: [AppData] 21 | } 22 | 23 | public struct AppData: Equatable, Codable, Identifiable { 24 | public init( 25 | artworkUrl60: String, 26 | artworkUrl100: String, 27 | artwrokUrl512: String, 28 | screenshotUrls: [String], 29 | supportedDevices: [String], 30 | minimumOsVersion: String, 31 | trackName: String, 32 | averageUserRating: Double, 33 | averageUserRatingForCurrentVersion: Double, 34 | contentAdvisoryRating: String, 35 | userRatingCount: Int, 36 | releaseNotes: String?) 37 | { 38 | self.artworkUrl60 = artworkUrl60 39 | self.artworkUrl100 = artworkUrl100 40 | self.artworkUrl512 = artwrokUrl512 41 | self.screenshotUrls = screenshotUrls 42 | self.supportedDevices = supportedDevices 43 | self.minimumOsVersion = minimumOsVersion 44 | self.trackName = trackName 45 | self.averageUserRating = averageUserRating 46 | self.averageUserRatingForCurrentVersion = averageUserRatingForCurrentVersion 47 | self.contentAdvisoryRating = contentAdvisoryRating 48 | self.userRatingCount = userRatingCount 49 | self.releaseNotes = releaseNotes 50 | } 51 | 52 | public let artworkUrl60: String 53 | public let artworkUrl100: String 54 | public let artworkUrl512: String 55 | public let screenshotUrls: [String] 56 | public let supportedDevices: [String] 57 | public let minimumOsVersion: String 58 | public let trackName: String 59 | public let averageUserRating: Double 60 | public let averageUserRatingForCurrentVersion: Double 61 | public let contentAdvisoryRating: String 62 | public let userRatingCount: Int 63 | public let releaseNotes: String? 64 | 65 | public var id: String { 66 | artworkUrl60 + trackName 67 | } 68 | } 69 | } 70 | 71 | extension SearchDomain { 72 | public enum Request { 73 | public struct SearchRequest: Codable { 74 | public let term: String 75 | public let entity: String 76 | public let country: String 77 | public let lang: String 78 | 79 | public init(term: String, entity: String, country: String, lang: String) { 80 | self.term = term 81 | self.entity = entity 82 | self.country = country 83 | self.lang = lang 84 | } 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /AppStoreClone/Features/Chos/Sources/Network/UseCase/AppStoreUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppStoreUseCase.swift 3 | // Chos 4 | // 5 | // Created by 조상호 on 2022/04/05. 6 | // Copyright © 2022 havi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | import ComposableArchitecture 12 | 13 | public protocol AppStoreUseCase { 14 | var searchKeyword: (SearchDomain.Request.SearchRequest) -> Effect { get } 15 | } 16 | -------------------------------------------------------------------------------- /AppStoreClone/Features/Chos/Sources/Network/UseCase/Platform/AppStoreUseCasePlatform.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppStoreUseCasePlatform.swift 3 | // Chos 4 | // 5 | // Created by 조상호 on 2022/04/05. 6 | // Copyright © 2022 havi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import ComposableArchitecture 11 | import Alamofire 12 | import Combine 13 | 14 | public struct AppStoreUseCasePlatform { 15 | public init(apiNetworking: APINetworking & CombineAPINetworkRequestType) { 16 | self.apiNetworking = apiNetworking 17 | } 18 | 19 | let apiNetworking: APINetworking & CombineAPINetworkRequestType 20 | 21 | private var baseURL: String { 22 | apiNetworking.domainURL 23 | } 24 | } 25 | 26 | extension AppStoreUseCasePlatform: AppStoreUseCase { 27 | public var searchKeyword: (SearchDomain.Request.SearchRequest) -> Effect { 28 | { requestModel in 29 | 30 | let model = APIRequestModel( 31 | baseURL: baseURL, 32 | parameters: ParameterConverter.build(model: requestModel), 33 | method: .get) 34 | 35 | return apiNetworking 36 | .request(model: model) 37 | .decode(type: SearchDomain.SearchResult.self, decoder: JSONDecoder()) 38 | .mapError{ error in 39 | guard let err = error as? CompositingErrorDomain else { return .other(error) } 40 | return err 41 | } 42 | .eraseToAnyPublisher() 43 | .eraseToEffect() 44 | } 45 | } 46 | } 47 | 48 | -------------------------------------------------------------------------------- /AppStoreClone/Features/Chos/Sources/Search/ChosSearchHomeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChosTest.swift 3 | // Chos 4 | // 5 | // Created by 한상진 on 2022/03/22. 6 | // Copyright © 2022 havi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import ComposableArchitecture 11 | 12 | public struct ChosSearchHomeView: View { 13 | let store: Store 14 | @State private var viewState: SearchViewState = .homeView 15 | @Environment(\.isSearching) var isSearching 16 | 17 | public init(store: Store) { 18 | self.store = store 19 | } 20 | 21 | public var body: some View { 22 | WithViewStore(self.store) { viewStore in 23 | NavigationView { 24 | VStack { 25 | switch viewState { 26 | case .homeView: 27 | SearchComponent.renderHomeView( 28 | recentlyKeyword: viewStore.recentlyKeyword, 29 | onTapAction: { 30 | viewStore.send(.onSearchKeyword($0)) 31 | viewState = .showSearchResult 32 | }) 33 | case .showMatchKeyword: 34 | SearchComponent.renderKeywordMatchingComponent( 35 | matchingKeywords: viewStore.filteredRecentlyKeyword, 36 | onTapSearchAction: { 37 | viewStore.send(.onSearchKeyword($0)) 38 | viewState = .showSearchResult 39 | }) 40 | case .showSearchResult: 41 | SearchComponent.renderSearchResultView( 42 | resultItems: viewStore.searchResults, 43 | onTapAction: { 44 | viewStore.send(.onTapResult($0)) 45 | }) 46 | } 47 | } 48 | .navigationTitle("검색") 49 | } 50 | .navigationViewStyle(.stack) 51 | .searchable(text: .init( 52 | get: { viewStore.searchKeyword }, 53 | set: { 54 | viewStore.send(.onChangedSearchKeyword($0)) 55 | viewState = $0.isEmpty ? .homeView : .showMatchKeyword 56 | }) 57 | ) 58 | .onSubmit(of: .search) { 59 | viewStore.send(.onSearchKeyword(viewStore.searchKeyword)) 60 | viewState = .showSearchResult 61 | } 62 | } 63 | } 64 | } 65 | 66 | fileprivate struct SearchComponent { 67 | @ViewBuilder 68 | static func renderHomeView( 69 | recentlyKeyword: [String], 70 | onTapAction: @escaping (String) -> Void) -> some View { 71 | VStack { 72 | HStack { 73 | Text("최근 검색어") 74 | .font(.title3) 75 | .fontWeight(.bold) 76 | 77 | Spacer() 78 | } 79 | 80 | ForEach(recentlyKeyword, id: \.self) { item in 81 | renderRecentlyComponent( 82 | onTapAction: { onTapAction($0) }, 83 | item: item) 84 | } 85 | 86 | Spacer() 87 | } 88 | .padding(20) 89 | .animation(.easeInOut) 90 | } 91 | 92 | static func renderKeywordMatchingComponent( 93 | matchingKeywords: [String], 94 | onTapSearchAction: @escaping (String) -> Void) -> some View { 95 | ScrollView { 96 | LazyVStack { 97 | ForEach(matchingKeywords, id: \.self) { item in 98 | renderItemComponent( 99 | onTapAction: { onTapSearchAction($0) }, 100 | item: item) 101 | } 102 | } 103 | .padding() 104 | } 105 | } 106 | 107 | static func renderSearchResultView( 108 | resultItems: [SearchDomain.AppData], 109 | onTapAction: @escaping (SearchDomain.AppData) -> Void) -> some View { 110 | ScrollView { 111 | LazyVStack { 112 | ForEach(resultItems) { item in 113 | renderAppDataComponent( 114 | appData: item, 115 | onTapAction: { onTapAction($0) }, 116 | onTapOpenAction: { _ in }) 117 | } 118 | } 119 | } 120 | } 121 | 122 | @ViewBuilder 123 | private static func renderItemComponent( 124 | onTapAction: @escaping (String) -> Void, 125 | item: String) -> some View { 126 | Button(action: { onTapAction(item) }) { 127 | VStack { 128 | HStack { 129 | Image(systemName: "magnifyingglass") 130 | .foregroundColor(.gray) 131 | Text(item) 132 | .font(.system(size: 20)) 133 | .lineLimit(1) 134 | .truncationMode(.middle) 135 | Spacer() 136 | } 137 | .foregroundColor(.black) 138 | .padding(8) 139 | 140 | Divider() 141 | } 142 | } 143 | } 144 | 145 | private static func renderRecentlyComponent( 146 | onTapAction: @escaping (String) -> Void, 147 | item: String) -> some View { 148 | Button(action: { onTapAction(item) }) { 149 | VStack { 150 | HStack { 151 | Text(item) 152 | .font(.system(size: 16)) 153 | .foregroundColor(.blue) 154 | 155 | Spacer() 156 | } 157 | .padding(8) 158 | 159 | Divider() 160 | } 161 | } 162 | } 163 | 164 | private static func renderAppDataComponent( 165 | appData: SearchDomain.AppData, 166 | onTapAction: @escaping (SearchDomain.AppData) -> Void, 167 | onTapOpenAction: @escaping (SearchDomain.AppData) -> Void) -> some View { 168 | Button(action: { onTapAction(appData) }) { 169 | HStack(alignment: .center) { 170 | AsyncImage(url: .init(string: appData.artworkUrl60), 171 | content: { image in 172 | image 173 | .mask { 174 | RoundedRectangle(cornerRadius: 6) 175 | .frame(width: 60, height: 60) 176 | } 177 | }, placeholder: { 178 | Rectangle() 179 | .frame(width: 60, height: 60) 180 | .foregroundColor(.gray) 181 | }) 182 | 183 | VStack(alignment: .leading) { 184 | Text(appData.trackName) 185 | .bold() 186 | 187 | Text(appData.trackName) 188 | .foregroundColor(.gray) 189 | .lineLimit(1) 190 | .truncationMode(.tail) 191 | 192 | FiveStarView(rating: appData.averageUserRating, width: 15, height: 15) 193 | } 194 | 195 | Spacer() 196 | } 197 | } 198 | .padding() 199 | } 200 | } 201 | 202 | enum SearchViewState { 203 | case homeView 204 | case showMatchKeyword 205 | case showSearchResult 206 | } 207 | -------------------------------------------------------------------------------- /AppStoreClone/Features/Chos/Sources/Search/SearchCore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchCore.swift 3 | // Chos 4 | // 5 | // Created by 조상호 on 2022/03/29. 6 | // Copyright © 2022 havi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import ComposableArchitecture 11 | import Combine 12 | 13 | public struct ChosSearchState: Equatable { 14 | var searchKeyword: String = "" 15 | var recentlyKeyword: [String] = [] 16 | var searchResults: [SearchDomain.AppData] = [] 17 | var filteredRecentlyKeyword: [String] = [] 18 | 19 | public init() {} 20 | } 21 | 22 | public enum ChosSearchAction: Equatable { 23 | case onSearchKeyword(String) 24 | case onChangedSearchKeyword(String) 25 | case onRecieveValue(Result) 26 | case onTapResult(SearchDomain.AppData) 27 | } 28 | 29 | public struct ChosSearchEnvironment { 30 | let appStoreUsecase: AppStoreUseCase 31 | let receiveQueue: DispatchQueue 32 | 33 | public init(appStoreUsecase: AppStoreUseCase, receiveQueue: DispatchQueue) { 34 | self.appStoreUsecase = appStoreUsecase 35 | self.receiveQueue = receiveQueue 36 | } 37 | } 38 | 39 | public let chosSearchReducer = Reducer { 40 | state, action, environment in 41 | switch action { 42 | 43 | case let .onChangedSearchKeyword(keyword): 44 | ( keyword.isEmpty ) 45 | ? ( state.filteredRecentlyKeyword = [] ) 46 | : ( state.filteredRecentlyKeyword = state.recentlyKeyword.filter{ $0.contains(keyword) } ) 47 | 48 | state.searchKeyword = keyword 49 | 50 | return .none 51 | 52 | case let .onSearchKeyword(keyword): 53 | struct SearchKeywordId: Hashable {} 54 | 55 | state.searchKeyword = keyword 56 | 57 | guard !state.recentlyKeyword.contains(keyword) else { return .none } 58 | state.recentlyKeyword.append(keyword) 59 | 60 | state.searchResults = [] 61 | 62 | return environment.appStoreUsecase 63 | .searchKeyword(.init(term: state.searchKeyword, entity: "software", country: "KR", lang: "ko_KR")) 64 | .catchToEffect(ChosSearchAction.onRecieveValue) 65 | .cancellable(id: SearchKeywordId(), cancelInFlight: true) 66 | 67 | case let .onTapResult(data): 68 | 69 | return .none 70 | 71 | case let .onRecieveValue(.failure(error)): 72 | return .none 73 | 74 | case let.onRecieveValue(.success(result)): 75 | state.searchResults = result.results 76 | return .none 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /AppStoreClone/Features/Chos/Sources/SearchResult/SearchResultCore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchResultCore.swift 3 | // Chos 4 | // 5 | // Created by 조상호 on 2022/05/05. 6 | // Copyright © 2022 havi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import ComposableArchitecture 11 | import Combine 12 | 13 | public struct SearchResultState: Equatable { 14 | var searchResults: [SearchDomain.AppData] 15 | 16 | } 17 | -------------------------------------------------------------------------------- /AppStoreClone/Features/Chos/Tests/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/havilog/AppStoreClone/6341fb890d9efaf1d64e607ec5fe7b7f48019643/AppStoreClone/Features/Chos/Tests/.gitkeep -------------------------------------------------------------------------------- /AppStoreClone/Features/Havi/Project.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Project.swift 3 | // ProjectDescriptionHelpers 4 | // 5 | // Created by 한상진 on 2022/01/27. 6 | // 7 | 8 | import ProjectDescription 9 | import ProjectDescriptionHelpers 10 | 11 | let project = Project.framework( 12 | name: "Havi", 13 | dependencies: [ 14 | .Core 15 | ] 16 | ) 17 | -------------------------------------------------------------------------------- /AppStoreClone/Features/Havi/Resources/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/havilog/AppStoreClone/6341fb890d9efaf1d64e607ec5fe7b7f48019643/AppStoreClone/Features/Havi/Resources/.gitkeep -------------------------------------------------------------------------------- /AppStoreClone/Features/Havi/Sources/PresentationLayer/HaviSearchDetail/HaviSearchDetailAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HaviSearchDetailAction.swift 3 | // Havi 4 | // 5 | // Created by 한상진 on 2022/03/29. 6 | // Copyright © 2022 havi. All rights reserved. 7 | // 8 | 9 | import ThirdPartyManager 10 | import ComposableArchitecture 11 | 12 | enum HaviSearchDetailAction: Equatable { 13 | } 14 | -------------------------------------------------------------------------------- /AppStoreClone/Features/Havi/Sources/PresentationLayer/HaviSearchDetail/HaviSearchDetailEnvironment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HaviSearchDetailEnvironment.swift 3 | // Havi 4 | // 5 | // Created by 한상진 on 2022/03/29. 6 | // Copyright © 2022 havi. All rights reserved. 7 | // 8 | 9 | import ThirdPartyManager 10 | import ComposableArchitecture 11 | 12 | struct HaviSearchDetailEnvironment: Equatable { 13 | } 14 | -------------------------------------------------------------------------------- /AppStoreClone/Features/Havi/Sources/PresentationLayer/HaviSearchDetail/HaviSearchDetailReducer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HaviSearchDetailReducer.swift 3 | // Havi 4 | // 5 | // Created by 한상진 on 2022/03/29. 6 | // Copyright © 2022 havi. All rights reserved. 7 | // 8 | 9 | import ThirdPartyManager 10 | import ComposableArchitecture 11 | 12 | let HaviSearchDetailReducer = Reducer.combine( 13 | Reducer { state, action, environment in 14 | switch action { 15 | } 16 | } 17 | ) 18 | .debug() 19 | -------------------------------------------------------------------------------- /AppStoreClone/Features/Havi/Sources/PresentationLayer/HaviSearchDetail/HaviSearchDetailState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HaviSearchDetailState.swift 3 | // Havi 4 | // 5 | // Created by 한상진 on 2022/03/29. 6 | // Copyright © 2022 havi. All rights reserved. 7 | // 8 | 9 | import ThirdPartyManager 10 | import ComposableArchitecture 11 | 12 | struct HaviSearchDetailState: Equatable { 13 | } 14 | -------------------------------------------------------------------------------- /AppStoreClone/Features/Havi/Sources/PresentationLayer/HaviSearchDetail/HaviSearchDetailView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HaviSearchDetailView.swift 3 | // Havi 4 | // 5 | // Created by 한상진 on 2022/03/29. 6 | // Copyright © 2022 havi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | import ThirdPartyManager 12 | import ComposableArchitecture 13 | 14 | struct HaviSearchDetailView: View { 15 | 16 | // MARK: Init 17 | 18 | init() { 19 | 20 | } 21 | 22 | // MARK: Body 23 | 24 | var body: some View { 25 | Text("Hello, World!") 26 | } 27 | } 28 | 29 | // MARK: Preview 30 | 31 | struct HaviSearchDetailView_Previews: PreviewProvider { 32 | static var previews: some View { 33 | HaviSearchDetailView() 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /AppStoreClone/Features/Havi/Sources/PresentationLayer/HaviSearchHome/HaviSearchFeature.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HaviSearchFeature.swift 3 | // Havi 4 | // 5 | // Created by 한상진 on 2022/04/17. 6 | // Copyright © 2022 havi. All rights reserved. 7 | // 8 | 9 | import Core 10 | import Combine 11 | import Foundation 12 | import ThirdPartyManager 13 | import ComposableArchitecture 14 | 15 | public struct HaviSearchHomeEnvironment { 16 | var searchClient: SearchClient 17 | 18 | public init(searchClient: SearchClient) { 19 | self.searchClient = searchClient 20 | } 21 | } 22 | 23 | public enum HaviSearchHomeAction: Equatable { 24 | case searchKeywordChanged(String) 25 | case searchButtonTapped 26 | case searchDataLoaded(Result) 27 | } 28 | 29 | public struct HaviSearchHomeState: Equatable { 30 | var searchModel: SearchAPIResult? 31 | var query: String = "" 32 | 33 | public init() { } 34 | } 35 | 36 | public let haviSearchHomeReducer = Reducer.combine( 37 | Reducer { state, action, environment in 38 | switch action { 39 | case .searchButtonTapped: 40 | return environment.searchClient 41 | .searchQuery(state.query) 42 | .receive(on: DispatchQueue.main) 43 | .catchToEffect() 44 | .map(HaviSearchHomeAction.searchDataLoaded) 45 | 46 | case let .searchDataLoaded(result): 47 | switch result { 48 | case let .success(searchResult): 49 | state.searchModel = searchResult 50 | 51 | case let .failure(error): 52 | print(error) 53 | } 54 | return .none 55 | 56 | case let .searchKeywordChanged(query): 57 | state.query = query 58 | return .none 59 | } 60 | } 61 | ) 62 | -------------------------------------------------------------------------------- /AppStoreClone/Features/Havi/Sources/PresentationLayer/HaviSearchHome/HaviSearchHomeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HaviSearchHomeView.swift 3 | // Havi 4 | // 5 | // Created by 한상진 on 2022/03/29. 6 | // Copyright © 2022 havi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | import ThirdPartyManager 12 | import ComposableArchitecture 13 | 14 | public struct HaviSearchHomeView: View { 15 | 16 | private let gridItemLayout: [GridItem] = [GridItem(.flexible())] 17 | 18 | private let store: Store 19 | @ObservedObject private var viewStore: ViewStore 20 | 21 | public init(store: Store) { 22 | self.store = store 23 | self.viewStore = ViewStore(store) 24 | configureNavigation() 25 | } 26 | 27 | private func configureNavigation() { 28 | UINavigationBar.appearance().largeTitleTextAttributes = [.foregroundColor: UIColor.white] 29 | UINavigationBar.appearance().titleTextAttributes = [.foregroundColor: UIColor.white] 30 | UINavigationBar.appearance().isTranslucent = true 31 | } 32 | 33 | public var body: some View { 34 | NavigationView { 35 | searchResultListView 36 | } 37 | .navigationViewStyle(.stack) 38 | .searchable( 39 | text: viewStore.binding( 40 | get: \.query, 41 | send: HaviSearchHomeAction.searchKeywordChanged 42 | ), 43 | prompt: "게임, 앱, 스토리 등" 44 | ) // 여기 suggestion view 만들어야함 45 | .foregroundColor(.white) 46 | .onSubmit(of: .search) { 47 | viewStore.send(.searchButtonTapped) 48 | } 49 | } 50 | 51 | private var searchResultListView: some View { 52 | ZStack { 53 | Color.black 54 | .ignoresSafeArea() 55 | 56 | ScrollView { 57 | LazyVGrid(columns: gridItemLayout) { 58 | ForEach(viewStore.state.searchModel?.results ?? [], id: \.self) { result in 59 | SearchResultCell(item: result) 60 | } 61 | } 62 | } 63 | } 64 | .navigationTitle("검색") 65 | } 66 | 67 | private func SearchResultCell(item: SearchAPIResult.SearchResult) -> some View { 68 | HStack { 69 | asyncIconImageView(item: item) 70 | 71 | titleDescriptionStarVStack(item: item) 72 | 73 | Spacer() 74 | 75 | openButton 76 | } 77 | .padding(.horizontal, 30) 78 | .padding(.vertical, 15) 79 | } 80 | 81 | private func asyncIconImageView(item: SearchAPIResult.SearchResult) -> some View { 82 | AsyncImage(url: item.artworkUrl512) { image in 83 | image 84 | .resizable() 85 | .aspectRatio(contentMode: .fit) 86 | } placeholder: { 87 | ProgressView() 88 | } 89 | .frame(width: 70, height: 70) 90 | .mask(RoundedRectangle(cornerRadius: 16)) 91 | } 92 | 93 | private func titleDescriptionStarVStack(item: SearchAPIResult.SearchResult) -> some View { 94 | VStack(alignment: .leading, spacing: 3) { 95 | Text(item.trackName) 96 | .font(.system(size: 17)) 97 | 98 | Text("description 찾아야함") 99 | .font(.system(size: 14)) 100 | .foregroundColor(Color(UIColor.darkGray)) 101 | 102 | Text("별 그려야함") 103 | .font(.system(size: 14)) 104 | .foregroundColor(Color(UIColor.darkGray)) 105 | } 106 | } 107 | 108 | private var openButton: some View { 109 | Button("열기") { } 110 | .frame(width: 70, height: 30, alignment: .center) 111 | .background(Color(UIColor.darkGray)) 112 | .clipShape(Capsule()) 113 | .foregroundColor(.blue) 114 | } 115 | } 116 | 117 | struct HaviSearchHomeView_Previews: PreviewProvider { 118 | static var previews: some View { 119 | HaviSearchHomeView( 120 | store: .init( 121 | initialState: HaviSearchHomeState(), 122 | reducer: haviSearchHomeReducer, 123 | environment: HaviSearchHomeEnvironment(searchClient: .live) 124 | ) 125 | ) 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /AppStoreClone/Features/Havi/Sources/PresentationLayer/HaviSearchResult/HaviSearchResultAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HaviSearchResultAction.swift 3 | // Havi 4 | // 5 | // Created by 한상진 on 2022/03/29. 6 | // Copyright © 2022 havi. All rights reserved. 7 | // 8 | 9 | import ThirdPartyManager 10 | import ComposableArchitecture 11 | 12 | enum HaviSearchResultAction: Equatable { 13 | } 14 | -------------------------------------------------------------------------------- /AppStoreClone/Features/Havi/Sources/PresentationLayer/HaviSearchResult/HaviSearchResultEnvironment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HaviSearchResultEnvironment.swift 3 | // Havi 4 | // 5 | // Created by 한상진 on 2022/03/29. 6 | // Copyright © 2022 havi. All rights reserved. 7 | // 8 | 9 | import ThirdPartyManager 10 | import ComposableArchitecture 11 | 12 | struct HaviSearchResultEnvironment: Equatable { 13 | } 14 | -------------------------------------------------------------------------------- /AppStoreClone/Features/Havi/Sources/PresentationLayer/HaviSearchResult/HaviSearchResultReducer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HaviSearchResultReducer.swift 3 | // Havi 4 | // 5 | // Created by 한상진 on 2022/03/29. 6 | // Copyright © 2022 havi. All rights reserved. 7 | // 8 | 9 | import ThirdPartyManager 10 | import ComposableArchitecture 11 | 12 | let HaviSearchResultReducer = Reducer.combine( 13 | Reducer { state, action, environment in 14 | switch action { 15 | } 16 | } 17 | ) 18 | .debug() 19 | -------------------------------------------------------------------------------- /AppStoreClone/Features/Havi/Sources/PresentationLayer/HaviSearchResult/HaviSearchResultState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HaviSearchResultState.swift 3 | // Havi 4 | // 5 | // Created by 한상진 on 2022/03/29. 6 | // Copyright © 2022 havi. All rights reserved. 7 | // 8 | 9 | import ThirdPartyManager 10 | import ComposableArchitecture 11 | 12 | struct HaviSearchResultState: Equatable { 13 | } 14 | -------------------------------------------------------------------------------- /AppStoreClone/Features/Havi/Sources/PresentationLayer/HaviSearchResult/HaviSearchResultView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HaviSearchResultView.swift 3 | // Havi 4 | // 5 | // Created by 한상진 on 2022/03/29. 6 | // Copyright © 2022 havi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | import ThirdPartyManager 12 | import ComposableArchitecture 13 | 14 | struct HaviSearchResultView: View { 15 | 16 | // MARK: Init 17 | 18 | init() { 19 | 20 | } 21 | 22 | // MARK: Body 23 | 24 | var body: some View { 25 | Text("Hello, World!") 26 | } 27 | } 28 | 29 | // MARK: Preview 30 | 31 | struct HaviSearchResultView_Previews: PreviewProvider { 32 | static var previews: some View { 33 | HaviSearchResultView() 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /AppStoreClone/Features/Havi/Sources/SearchDomain/Search.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Search.swift 3 | // Havi 4 | // 5 | // Created by 한상진 on 2022/03/26. 6 | // Copyright © 2022 havi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public struct SearchAPIResult: Decodable, Hashable { 12 | public let resultCount: Int 13 | public let results: [SearchResult] 14 | } 15 | 16 | extension SearchAPIResult { 17 | public struct SearchResult: Decodable, Hashable { 18 | public let artworkUrl60: URL // - 60x60사이즈 로고 19 | public let artworkUrl100: URL // - 100x100사이즈 로고 20 | public let artworkUrl512: URL // - 512x512사이즈 로고 21 | public let screenshotUrls: [URL] // - 스크린샷 [Url리스트] - url 디코딩 로직 적용 22 | public let supportedDevices: [String] // - 지원 기기 목록 23 | public let minimumOsVersion: String // minimum Deployment Target 24 | public let trackName: String // 앱 이름 25 | public let averageUserRating: Double // - 전체 평점 26 | public let averageUserRatingForCurrentVersion: Double // - 이 버전에 대한평점 0.0~5.0 27 | public let contentAdvisoryRating: String // - 연령등급 28 | public let userRatingCount: Int // - 평가 개수 - 1000단위로 반올림 예상, n.n만개 등으로 포매팅 필요 29 | public let releaseNotes: String? // - 릴리즈노트 - 기본 세줄 + 더보기 - 클릭시 펼쳐짐 30 | public let price: Int // 가격 31 | public let formattedPrice: String // 포맷가격 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /AppStoreClone/Features/Havi/Sources/SearchDomain/SearchClient.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchClient.swift 3 | // Havi 4 | // 5 | // Created by 한상진 on 2022/05/05. 6 | // Copyright © 2022 havi. All rights reserved. 7 | // 8 | 9 | import Core 10 | import ComposableArchitecture 11 | 12 | public struct SearchClient { 13 | var searchQuery: (String) -> Effect 14 | 15 | public struct SearchFailure: Error, Equatable { 16 | init(error: Error) { 17 | // do some porting 18 | } 19 | } 20 | } 21 | 22 | extension SearchClient { 23 | public static var live: Self { 24 | return SearchClient( 25 | searchQuery: { term in 26 | Effect.task { 27 | try await NetworkRepositoryImpl(with: URLSession.shared).reqeust( 28 | with: Endpoint.searchApp(term: term), 29 | for: SearchAPIResult.self 30 | ) 31 | } 32 | .mapError(SearchFailure.init) 33 | .eraseToEffect() 34 | } 35 | ) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /AppStoreClone/Features/Havi/Tests/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/havilog/AppStoreClone/6341fb890d9efaf1d64e607ec5fe7b7f48019643/AppStoreClone/Features/Havi/Tests/.gitkeep -------------------------------------------------------------------------------- /AppStoreClone/Features/Heizel/Project.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Project.swift 3 | // ProjectDescriptionHelpers 4 | // 5 | // Created by 한상진 on 2022/01/27. 6 | // 7 | 8 | import ProjectDescription 9 | import ProjectDescriptionHelpers 10 | 11 | let project = Project.framework( 12 | name: "Heizel", 13 | dependencies: [ 14 | .Core 15 | ] 16 | ) 17 | -------------------------------------------------------------------------------- /AppStoreClone/Features/Heizel/Resources/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/havilog/AppStoreClone/6341fb890d9efaf1d64e607ec5fe7b7f48019643/AppStoreClone/Features/Heizel/Resources/.gitkeep -------------------------------------------------------------------------------- /AppStoreClone/Features/Heizel/Sources/HeizelTest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HeizelTest.swift 3 | // Heizel 4 | // 5 | // Created by 한상진 on 2022/03/22. 6 | // Copyright © 2022 havi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | public struct HeizelSearchHomeView: View { 12 | 13 | public init() { 14 | 15 | } 16 | 17 | public var body: some View { 18 | Text("Hello, Heizel!") 19 | } 20 | } 21 | 22 | struct HeizelSearchHomeView_Previews: PreviewProvider { 23 | static var previews: some View { 24 | HeizelSearchHomeView() 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /AppStoreClone/Features/Heizel/Tests/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/havilog/AppStoreClone/6341fb890d9efaf1d64e607ec5fe7b7f48019643/AppStoreClone/Features/Heizel/Tests/.gitkeep -------------------------------------------------------------------------------- /AppStoreClone/Modules/Core/Project.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Project.swift 3 | // ProjectDescriptionHelpers 4 | // 5 | // Created by 한상진 on 2022/01/27. 6 | // 7 | 8 | import ProjectDescription 9 | import ProjectDescriptionHelpers 10 | 11 | let project = Project.framework( 12 | name: "Core", 13 | dependencies: [ 14 | .ThirdPartyManager 15 | ] 16 | ) 17 | -------------------------------------------------------------------------------- /AppStoreClone/Modules/Core/Resources/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/havilog/AppStoreClone/6341fb890d9efaf1d64e607ec5fe7b7f48019643/AppStoreClone/Modules/Core/Resources/.gitkeep -------------------------------------------------------------------------------- /AppStoreClone/Modules/Core/Sources/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/havilog/AppStoreClone/6341fb890d9efaf1d64e607ec5fe7b7f48019643/AppStoreClone/Modules/Core/Sources/.gitkeep -------------------------------------------------------------------------------- /AppStoreClone/Modules/Core/Sources/Extensions/Sequence+async.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Sequence+concurrentMap.swift 3 | // Core 4 | // 5 | // Created by 한상진 on 2022/03/25. 6 | // Copyright © 2022 havi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension Sequence { 12 | func asyncMap( 13 | _ transform: (Element) async throws -> T 14 | ) async rethrows -> [T] { 15 | var values = [T]() 16 | 17 | for element in self { 18 | try await values.append(transform(element)) 19 | } 20 | 21 | return values 22 | } 23 | 24 | func asyncForEach( 25 | _ operation: (Element) async throws -> Void 26 | ) async rethrows { 27 | for element in self { 28 | try await operation(element) 29 | } 30 | } 31 | } 32 | 33 | public extension Sequence { 34 | func concurrentForEach( 35 | _ operation: @escaping (Element) async -> Void 36 | ) async { 37 | // A task group automatically waits for all of its 38 | // sub-tasks to complete, while also performing those 39 | // tasks in parallel: 40 | await withTaskGroup(of: Void.self) { group in 41 | for element in self { 42 | group.addTask { 43 | await operation(element) 44 | } 45 | } 46 | } 47 | } 48 | 49 | func concurrentMap( 50 | _ transform: @escaping (Element) async throws -> T 51 | ) async throws -> [T] { 52 | let tasks = map { element in 53 | Task { 54 | try await transform(element) 55 | } 56 | } 57 | 58 | return try await tasks.asyncMap { task in 59 | try await task.value 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /AppStoreClone/Modules/Core/Sources/Extensions/Subject+emittingValues.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Subject+emittingValues.swift 3 | // Core 4 | // 5 | // Created by 한상진 on 2022/03/25. 6 | // Copyright © 2022 havi. All rights reserved. 7 | // 8 | 9 | import Combine 10 | 11 | public extension PassthroughSubject where Failure == Error { 12 | static func emittingValues( 13 | from sequence: T 14 | ) -> Self where T.Element == Output { 15 | let subject = Self() 16 | 17 | Task { 18 | do { 19 | for try await value in sequence { 20 | subject.send(value) 21 | } 22 | 23 | subject.send(completion: .finished) 24 | } catch { 25 | subject.send(completion: .failure(error)) 26 | } 27 | } 28 | 29 | return subject 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /AppStoreClone/Modules/Core/Sources/Extensions/Task+delayed.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Task+delayed.swift 3 | // Core 4 | // 5 | // Created by 한상진 on 2022/03/25. 6 | // Copyright © 2022 havi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension Task where Failure == Error { 12 | static func delayed( 13 | byTimeInterval delayInterval: TimeInterval, 14 | priority: TaskPriority? = nil, 15 | operation: @escaping @Sendable () async throws -> Success 16 | ) -> Task { 17 | Task(priority: priority) { 18 | let delay = UInt64(delayInterval * 1_000_000_000) 19 | try await Task.sleep(nanoseconds: delay) 20 | return try await operation() 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /AppStoreClone/Modules/Core/Sources/Extensions/Task+retrying.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Task+retrying.swift 3 | // Core 4 | // 5 | // Created by 한상진 on 2022/03/25. 6 | // Copyright © 2022 havi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension Task where Failure == Error { 12 | @discardableResult 13 | static func retrying( 14 | priority: TaskPriority? = nil, 15 | maxRetryCount: Int = 3, 16 | retryDelay: TimeInterval = 1, 17 | operation: @Sendable @escaping () async throws -> Success 18 | ) -> Task { 19 | Task(priority: priority) { 20 | for _ in 0...sleep(nanoseconds: delay) 27 | 28 | continue 29 | } 30 | } 31 | 32 | try Task.checkCancellation() 33 | return try await operation() 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /AppStoreClone/Modules/Core/Sources/Network/Endpoint.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Endpoint.swift 3 | // Core 4 | // 5 | // Created by 한상진 on 2022/03/25. 6 | // Copyright © 2022 havi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | // MARK: Constant 12 | 13 | public typealias HTTPHeaders = [String: String] 14 | public typealias Parameters = [String: Any?] 15 | 16 | public enum HTTPHeaderFields { 17 | static let contentType: String = "Content-Type" 18 | static let json: String = "application/json" 19 | } 20 | 21 | public enum HTTPMethod: String { 22 | case get = "GET" 23 | case put = "PUT" 24 | case post = "POST" 25 | case patch = "PATCH" 26 | case delete = "DELETE" 27 | } 28 | 29 | public enum HTTPTask { 30 | case requestPlain 31 | case requestHeader(urlParams: Parameters?) 32 | case requestBody(body: Any?) 33 | } 34 | 35 | public enum NetworkError: Error { 36 | case unknown 37 | case invalidUrlRequest 38 | 39 | case dataTaskFailed 40 | case invalidStatus 41 | case unableToDecode 42 | 43 | case encodeHeaderFail 44 | case encodeBodyFail 45 | } 46 | 47 | // MARK: Protocol 48 | 49 | public protocol EndpointType { 50 | var baseURL: URL { get } 51 | var path: String? { get } 52 | var httpMethod: HTTPMethod { get } 53 | var task: HTTPTask { get } 54 | var headers: HTTPHeaders? { get } 55 | func asURLRequest() throws -> URLRequest 56 | } 57 | 58 | // MARK: Endpoint 59 | 60 | public enum Endpoint { 61 | case searchApp(term: String) 62 | } 63 | //https://itunes.apple.com/search?entity=software&term=kakao&country=kr 64 | extension Endpoint: EndpointType { 65 | public var baseURL: URL { 66 | guard let baseURL = URL(string: "https://itunes.apple.com") 67 | else { fatalError("잘못된 baseURL") } 68 | 69 | return baseURL 70 | } 71 | 72 | public var path: String? { 73 | switch self { 74 | case .searchApp: 75 | return "/search" 76 | } 77 | } 78 | 79 | public var httpMethod: HTTPMethod { 80 | switch self { 81 | case .searchApp: 82 | return .get 83 | } 84 | } 85 | 86 | public var task: HTTPTask { 87 | switch self { 88 | case let .searchApp(term): 89 | return .requestHeader(urlParams: [ 90 | "entity": "software", 91 | "country": "kr", 92 | "term": term 93 | ]) 94 | } 95 | } 96 | 97 | public var headers: HTTPHeaders? { 98 | return [ 99 | HTTPHeaderFields.contentType: HTTPHeaderFields.json 100 | ] 101 | } 102 | 103 | // MARK: URLRequest 104 | 105 | public func asURLRequest() throws -> URLRequest { 106 | var url: URL = baseURL 107 | 108 | url.appendPathComponent(path ?? "") 109 | 110 | var request: URLRequest = .init(url: url) 111 | 112 | request.httpMethod = httpMethod.rawValue 113 | request.allHTTPHeaderFields = headers 114 | 115 | switch task { 116 | case .requestPlain: 117 | break 118 | 119 | case let .requestHeader(parameters): 120 | try encodeHeader(&request, with: parameters) 121 | 122 | case let .requestBody(parameters): 123 | try encodeBody(&request, with: parameters) 124 | } 125 | 126 | return request 127 | } 128 | 129 | // MARK: Encoder 130 | 131 | private func encodeHeader(_ request: inout URLRequest, with parameter: Parameters?) throws { 132 | guard let parameter = parameter, 133 | let url = request.url, 134 | var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false), 135 | !parameter.isEmpty 136 | else { throw NetworkError.encodeHeaderFail } 137 | 138 | urlComponents.queryItems = parameter 139 | .compactMapValues { $0 } 140 | .compactMap { URLQueryItem(name: $0.key, value: "\($0.value)") } 141 | 142 | request.url = urlComponents.url 143 | } 144 | 145 | private func encodeBody(_ request: inout URLRequest, with parameter: Any?) throws { 146 | guard let parameter = parameter, 147 | let encodableParameter = parameter as? Encodable, 148 | let json = encodableParameter.toJSON(), 149 | let data = try? JSONSerialization.data(withJSONObject: json, options: []) 150 | else { throw NetworkError.encodeBodyFail } 151 | 152 | request.httpBody = data 153 | } 154 | } 155 | 156 | extension Encodable { 157 | fileprivate func toJSON(_ encoder: JSONEncoder = JSONEncoder()) -> [String: Any]? { 158 | encoder.keyEncodingStrategy = .convertToSnakeCase 159 | 160 | guard let data = try? encoder.encode(self), 161 | let object = try? JSONSerialization.jsonObject(with: data), 162 | let json = object as? [String: Any] 163 | else { return nil } 164 | 165 | return json 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /AppStoreClone/Modules/Core/Sources/Network/NetworkLogger.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NetworkLogger.swift 3 | // Core 4 | // 5 | // Created by 한상진 on 2022/03/25. 6 | // Copyright © 2022 havi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct NetworkLogger { 12 | private enum Level: String { 13 | case request = "📧 REQUEST" 14 | case info = "💡 INFO" 15 | case debug = "💬 DEBUG" 16 | case error = "⚠️ ERROR" 17 | case success = "💎 SUCCESS" 18 | } 19 | 20 | private static var currentDate: String { 21 | let formatter = DateFormatter() 22 | formatter.dateFormat = "HH:mm:ss" 23 | return formatter.string(from: Date()) 24 | } 25 | 26 | private static func log( 27 | level: Level, 28 | message: Any 29 | ) { 30 | #if DEBUG 31 | print("\(currentDate) \(level.rawValue) \(sourceFileName(filePath: #file)), \(#line) \(#function)") 32 | #endif 33 | } 34 | 35 | static func request(_ items: Any...) { 36 | let output = toOutput(with: items) 37 | log(level: .request, message: output) 38 | } 39 | 40 | static func error(_ items: Any...) { 41 | let output = toOutput(with: items) 42 | log(level: .error, message: output) 43 | } 44 | 45 | static func success(_ items: Any...) { 46 | let output = toOutput(with: items) 47 | log(level: .success, message: output) 48 | } 49 | 50 | static func info(_ items: Any...) { 51 | let output = toOutput(with: items) 52 | log(level: .info, message: output) 53 | } 54 | 55 | static func debug(_ items: Any...) { 56 | let output = toOutput(with: items) 57 | log(level: .debug, message: output) 58 | } 59 | 60 | private static func sourceFileName(filePath: String) -> String { 61 | let components = filePath.components(separatedBy: "/") 62 | let fileName = components.last ?? "" 63 | return String(fileName.split(separator: ".").first ?? "") 64 | } 65 | 66 | private static func toOutput(with items: [Any]) -> Any { 67 | return items.map { String("\($0)") }.joined(separator: " ") 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /AppStoreClone/Modules/Core/Sources/Network/NetworkRepository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Network.swift 3 | // Core 4 | // 5 | // Created by 한상진 on 2022/03/25. 6 | // Copyright © 2022 havi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public protocol NetworkRepository { 12 | func reqeust( 13 | with endpoint: EndpointType, 14 | for type: Model.Type 15 | ) async throws -> Model 16 | } 17 | 18 | public struct NetworkRepositoryImpl: NetworkRepository { 19 | 20 | private let session: URLSessionProtocol 21 | 22 | public init(with session: URLSessionProtocol) { 23 | self.session = session 24 | } 25 | 26 | // endpoint에서 모델 지정 27 | public func reqeust( 28 | with endpoint: EndpointType, 29 | for type: Model.Type 30 | ) async throws -> Model { 31 | let urlRequest: URLRequest = try makeURLRequest(with: endpoint) 32 | let (data, response) = try await request(with: urlRequest) 33 | try validateReponse(response) 34 | let model = try decodeModel(from: data, modelType: Model.self) 35 | return model 36 | } 37 | 38 | private func makeURLRequest(with endpoint: EndpointType) throws -> URLRequest { 39 | do { 40 | return try endpoint.asURLRequest() 41 | } catch { 42 | throw NetworkError.invalidUrlRequest 43 | } 44 | } 45 | 46 | private func request(with urlRequest: URLRequest) async throws -> (Data, URLResponse) { 47 | do { 48 | return try await Task.retrying { 49 | return try await session.data(for: urlRequest, delegate: nil) 50 | }.value 51 | } catch { 52 | throw NetworkError.dataTaskFailed 53 | } 54 | } 55 | 56 | private func validateReponse(_ response: URLResponse) throws { 57 | let correctStatusCodeRange: Range = 200..<300 58 | guard 59 | let response = response as? HTTPURLResponse, 60 | correctStatusCodeRange ~= response.statusCode 61 | else { 62 | throw NetworkError.invalidStatus 63 | } 64 | } 65 | 66 | private func decodeModel( 67 | from data: Data, 68 | modelType: Model.Type 69 | ) throws -> Model { 70 | guard let model = try? JSONDecoder().decode(Model.self, from: data) else { 71 | throw NetworkError.unableToDecode 72 | } 73 | return model 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /AppStoreClone/Modules/Core/Sources/Network/URLSessionType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URLSessionType.swift 3 | // Core 4 | // 5 | // Created by 한상진 on 2022/03/25. 6 | // Copyright © 2022 havi. All rights reserved. 7 | // 8 | 9 | import Combine 10 | import Foundation 11 | 12 | public protocol URLSessionProtocol { 13 | func dataTask( 14 | with request: URLRequest, 15 | completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void 16 | ) -> URLSessionDataTask 17 | 18 | func data( 19 | for request: URLRequest, 20 | delegate: URLSessionTaskDelegate? 21 | ) async throws -> (Data, URLResponse) 22 | } 23 | 24 | extension URLSession: URLSessionProtocol { } 25 | -------------------------------------------------------------------------------- /AppStoreClone/Modules/Core/Tests/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/havilog/AppStoreClone/6341fb890d9efaf1d64e607ec5fe7b7f48019643/AppStoreClone/Modules/Core/Tests/.gitkeep -------------------------------------------------------------------------------- /AppStoreClone/Modules/Core/Tests/Data/appInfoKakaoBank.json: -------------------------------------------------------------------------------- 1 | { 2 | "ipadScreenshotUrls": [], 3 | "appletvScreenshotUrls": [], 4 | "artworkUrl60": "https://is3-ssl.mzstatic.com/image/thumb/Purple126/v4/08/f1/96/08f19603-9025-afe8-629c-6437e13d453a/source/60x60bb.jpg", 5 | "artworkUrl512": "https://is3-ssl.mzstatic.com/image/thumb/Purple126/v4/08/f1/96/08f19603-9025-afe8-629c-6437e13d453a/source/512x512bb.jpg", 6 | "artworkUrl100": "https://is3-ssl.mzstatic.com/image/thumb/Purple126/v4/08/f1/96/08f19603-9025-afe8-629c-6437e13d453a/source/100x100bb.jpg", 7 | "artistViewUrl": "https://apps.apple.com/kr/developer/kakaobank-corp/id1258016943?uo=4", 8 | "screenshotUrls": [ 9 | "https://is1-ssl.mzstatic.com/image/thumb/PurpleSource126/v4/cd/f5/45/cdf5450b-70c3-c4ff-62f7-459e96b3bd0c/645b5f7a-5ff9-43a0-aefa-e4a5e80468c0_5.5_screenshot_01.png/392x696bb.png", 10 | "https://is3-ssl.mzstatic.com/image/thumb/PurpleSource116/v4/ae/39/2c/ae392c8a-b8ab-ffb9-0775-1c5ae5046c76/2354ae48-4269-40d0-b388-c362715cc973_5.5_screenshot_02.png/392x696bb.png", 11 | "https://is4-ssl.mzstatic.com/image/thumb/PurpleSource126/v4/74/c0/64/74c064e9-80a0-7b3b-a411-bdd0ec7e0e0c/f7b60d70-a895-4ae4-a3aa-7cdfc0f0ed5d_5.5_screenshot_03.png/392x696bb.png", 12 | "https://is5-ssl.mzstatic.com/image/thumb/PurpleSource126/v4/c9/24/e3/c924e3c8-7fe6-5a7c-85be-6489940d0d15/d9d3bdda-1cbc-4433-9e41-aeafb482bec1_5.5_screenshot_04.png/392x696bb.png", 13 | "https://is4-ssl.mzstatic.com/image/thumb/PurpleSource126/v4/e4/d0/f3/e4d0f340-9719-60b9-c2ff-16204c815318/56ec8853-3ab7-467d-a4a6-91543f0f3a12_5.5_screenshot_05.png/392x696bb.png", 14 | "https://is5-ssl.mzstatic.com/image/thumb/PurpleSource126/v4/e5/dc/6a/e5dc6a20-a0f9-d275-3d1e-0e4679dc11b9/70cd8c73-8106-473f-a577-5c38a91dde9c_5.5_screenshot_06.png/392x696bb.png", 15 | "https://is3-ssl.mzstatic.com/image/thumb/PurpleSource116/v4/50/c6/14/50c6143f-de61-d1ea-3ebb-ea0410ade3a4/247996e7-e734-4fb7-9ebb-ef6e23401025_5.5_screenshot_07.png/392x696bb.png", 16 | "https://is5-ssl.mzstatic.com/image/thumb/PurpleSource116/v4/7f/8f/d6/7f8fd643-f60d-d7cb-585e-4dfdb53fbf0d/f090e40b-b0a5-457d-ad04-511ba9b3730f_5.5_screenshot_08.png/392x696bb.png", 17 | "https://is2-ssl.mzstatic.com/image/thumb/PurpleSource126/v4/0d/77/f9/0d77f9ca-2e3e-c4d8-2e18-f6435a46faf7/1034a713-d45c-4426-9e27-f735f2764eb5_5.5_screenshot_09.png/392x696bb.png" 18 | ], 19 | "isGameCenterEnabled": false, 20 | "features": [], 21 | "advisories": [], 22 | "supportedDevices": ["iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878", "iPadMini5-iPadMini5", "iPadMini5Cellular-iPadMini5Cellular", "iPadAir3-iPadAir3", "iPadAir3Cellular-iPadAir3Cellular", "iPodTouchSeventhGen-iPodTouchSeventhGen", "iPhone11-iPhone11", "iPhone11Pro-iPhone11Pro", "iPadSeventhGen-iPadSeventhGen", "iPadSeventhGenCellular-iPadSeventhGenCellular", "iPhone11ProMax-iPhone11ProMax", "iPhoneSESecondGen-iPhoneSESecondGen", "iPadProSecondGen-iPadProSecondGen", "iPadProSecondGenCellular-iPadProSecondGenCellular", "iPadProFourthGen-iPadProFourthGen", "iPadProFourthGenCellular-iPadProFourthGenCellular", "iPhone12Mini-iPhone12Mini", "iPhone12-iPhone12", "iPhone12Pro-iPhone12Pro", "iPhone12ProMax-iPhone12ProMax", "iPadAir4-iPadAir4", "iPadAir4Cellular-iPadAir4Cellular", "iPadEighthGen-iPadEighthGen", "iPadEighthGenCellular-iPadEighthGenCellular", "iPadProThirdGen-iPadProThirdGen", "iPadProThirdGenCellular-iPadProThirdGenCellular", "iPadProFifthGen-iPadProFifthGen", "iPadProFifthGenCellular-iPadProFifthGenCellular", "iPhone13Pro-iPhone13Pro", "iPhone13ProMax-iPhone13ProMax", "iPhone13Mini-iPhone13Mini", "iPhone13-iPhone13", "iPadMiniSixthGen-iPadMiniSixthGen", "iPadMiniSixthGenCellular-iPadMiniSixthGenCellular", "iPadNinthGen-iPadNinthGen", "iPadNinthGenCellular-iPadNinthGenCellular", "iPhoneSEThirdGen-iPhoneSEThirdGen", "iPadAirFifthGen-iPadAirFifthGen", "iPadAirFifthGenCellular-iPadAirFifthGenCellular"], 23 | "kind": "software", 24 | "minimumOsVersion": "11.0", 25 | "trackCensoredName": "카카오뱅크", 26 | "languageCodesISO2A": ["KO"], 27 | "fileSizeBytes": "322662400", 28 | "sellerUrl": "https://www.kakaobank.com/", 29 | "formattedPrice": "무료", 30 | "contentAdvisoryRating": "4+", 31 | "averageUserRatingForCurrentVersion": 3.38544000000000000483169060316868126392364501953125, 32 | "userRatingCountForCurrentVersion": 10510, 33 | "averageUserRating": 3.38544000000000000483169060316868126392364501953125, 34 | "trackViewUrl": "https://apps.apple.com/kr/app/%EC%B9%B4%EC%B9%B4%EC%98%A4%EB%B1%85%ED%81%AC/id1258016944?uo=4", 35 | "trackContentRating": "4+", 36 | "bundleId": "com.kakaobank.channel", 37 | "releaseDate": "2017-07-26T15:24:27Z", 38 | "primaryGenreName": "Finance", 39 | "genreIds": ["6015"], 40 | "isVppDeviceBasedLicensingEnabled": true, 41 | "trackId": 1258016944, 42 | "trackName": "카카오뱅크", 43 | "sellerName": "KakaoBank Corp.", 44 | "currentVersionReleaseDate": "2022-03-14T02:51:27Z", 45 | "releaseNotes": "2.12.1\n● 사용성 개선 및 안정화\n\n2.12.0 \n● 실험실 추가 기능 오픈 \n - 전체메뉴 탭의 자주 사용하는 서비스를 즐겨찾기 할 수 있어요.\n\n● 입출금 외에도 모든 예적금 통장 사본을 볼 수 있어요.\n● mini 26일저금 현황 공유를 인스타그램 '스토리' 용으로 최적화하였어요.", 46 | "primaryGenreId": 6015, 47 | "currency": "KRW", 48 | "description": "일상에서 더 쉽게, 더 자주 만나는\n제1금융권 은행, 카카오뱅크\n\n■ 새롭게 디자인된 은행\n• 365일 언제나 지점 방문 없이 모든 은행 업무를 모바일에서\n• 7분만에 끝나는 쉬운 계좌개설\n\n■ 쉬운 사용성\n• 공동인증서, 보안카드 없는 계좌이체\n• 계좌번호를 몰라도 카톡 친구에게 간편 송금 (상대방이 카카오뱅크 고객이 아니어도 송금 가능)\n\n■ 내 취향대로 선택\n• 카카오프렌즈 캐릭터 디자인부터 고급스러운 블랙 컬러까지, 세련된 디자인의 체크카드\n• 내 마음대로 설정하는 계좌 이름과 색상\n\n■ 눈에 보이는 혜택\n• 복잡한 가입 조건이나 우대 조건 없이, 누구에게나 경쟁력있는 금리와 혜택 제공\n• 늘어나는 이자를 실시간으로 확인할 수 있는 정기예금\n• 만 19세 이상 대한민국 국민의 90%가 신청 가능한 비상금대출(소액 마이너스대출)\n\n■ 카카오프렌즈와 함께하는 26주적금\n• 천원부터 차곡차곡 26주동안 매주 쌓는 적금\n• 카카오프렌즈 응원과 함께하면 어느덧 만기 달성이 눈앞에!\n\n\n■ 알아서 차곡차곡 모아주는 저금통\n• 원하는 모으기 규칙 선택으로 부담없이 저축하기\n• 평소에는 귀여운 아이템으로, 엿보기 기능으로 잔액 확인\n\n■ 함께쓰고 같이보는 모임통장\n• 손쉽게 카카오톡 친구들을 멤버로 초대 \n• 잔액과 입출금 현황을 멤버들과 함께 보기 \n• 위트있는 메시지카드로 회비 요청\n\n■ 소중한 ‘내 신용정보’ 관리\n• 제1금융권에서 안전하게 무료로 내 신용정보 확인\n• 신용 변동내역 발생 시 알림 서비스 및 신용정보 관리 꿀팁 제공\n\n■ 파격적인 수수료로 해외송금\n• 365일 언제 어디서든 이용가능한 해외송금 (보내기 및 받기)\n• 해외계좌 및 웨스턴유니온(WU)을 통해 전세계 200여 개국으로 해외송금 가능\n• 거래외국환은행 지정, 연장 업무도 지점방문없이 모바일에서 신청 가능 \n\n■ 카카오뱅크에서 만나는 제휴서비스\n• 증권사 주식계좌도 간편하게 개설 가능\n• 프렌즈 캐릭터가 함께하는 제휴 신용카드 신청 가능\n\n■ 이체수수료 및 입출금 수수료 면제\n• 타행 이체 및 자동이체 수수료 면제\n• 국내 모든 ATM(은행, 제휴 VAN사 기기) 입금/출금/이체 수수료 면제\n\n* ATM/CD기 입금/출금/이체 수수료는 향후 정책에 따라 변경될 수 있습니다. 정책이 변경되는 경우 시행 1개월 전에 카카오뱅크 앱 및 홈페이지를 통해 미리 알려드립니다.\n\n■ 고객센터 운영 시간 안내\n• 예/적금, 대출, 카드 문의 : 1599-3333 (09:00 ~ 22:00 365일)\n• 전월세 보증금 대출, 외환 문의 : 1599-3333 (09:00 ~ 18:00 평일)\n• 사고 신고 : 1599-8888 (24시간 365일)\n\n■ 챗봇 운영 시간 안내\n• 카카오톡 플러스친구 \"카카오뱅크 고객센터\" (24시간 365일)\n\n■ 카카오뱅크 앱 이용을 위한 권한 및 목적 안내\n• 카메라(선택) : 신분증 촬영 및 서류 제출, 영상통화, 프로필 사진 등록\n• 사진(선택) : 이체⁄송금⁄출금 확인증, 카드매출전표 저장\n• 위치(선택) : 부정가입방지 및 부정거래탐지\n\n * 선택 접근권한은 동의하지 않아도 서비스를 이용하실 수 있습니다.", 49 | "artistId": 1258016943, 50 | "artistName": "KakaoBank Corp.", 51 | "genres": ["금융"], 52 | "price": 0.00, 53 | "version": "2.12.1", 54 | "wrapperType": "software", 55 | "userRatingCount": 10510 56 | } 57 | -------------------------------------------------------------------------------- /AppStoreClone/Modules/Core/Tests/NetworkRepositoryTest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Endpoint.swift 3 | // Core 4 | // 5 | // Created by 한상진 on 2022/03/25. 6 | // Copyright © 2022 havi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import XCTest 11 | @testable import Havi 12 | 13 | class NetworkRepositoryTests: XCTestCase { 14 | 15 | var sut: NetworkRepository! 16 | var urlSessionMock: URLSessionProtocol! 17 | 18 | override func setUpWithError() throws { 19 | urlSessionMock = URLSessionMock( 20 | shouldDataTaskSucceed: true, 21 | isFirstTryFail: false 22 | ) 23 | sut = NetworkRepositoryImpl(with: urlSessionMock) 24 | } 25 | 26 | override func tearDownWithError() throws { 27 | 28 | } 29 | 30 | private func readLocalFile(forName name: String) -> Data? { 31 | do { 32 | let bundle = Bundle(for: type(of: self)) 33 | if let bundlePath = bundle.path(forResource: name, ofType: "json"), 34 | let jsonData = try String(contentsOfFile: bundlePath).data(using: .utf8) { 35 | return jsonData 36 | } 37 | } catch { 38 | print(error) 39 | } 40 | 41 | return nil 42 | } 43 | } 44 | 45 | extension NetworkRepositoryTests { 46 | func test_endpoint와_decodeType이_잘들어가면_성공() async throws { 47 | // given 48 | let endpoint = Endpoint.searchApp(term: "카카오") 49 | let data = readLocalFile(forName: "appInfoListKakaoBank")! 50 | let mockResult = try! JSONDecoder().decode(SearchAPIResult.self, from: data) 51 | 52 | // when 53 | let result = try await sut.reqeust(with: endpoint, for: SearchAPIResult.self) 54 | 55 | // then 56 | XCTAssertEqual(result, mockResult) 57 | } 58 | 59 | func test_endpoint를_잘못넣으면_실패() async throws { 60 | //given 61 | let endpoint = Endpoint.searchApp(term: "잘못된 term") 62 | 63 | do { 64 | // when 65 | _ = try await sut.reqeust(with: endpoint, for: SearchAPIResult.self) 66 | } catch { 67 | // then 68 | XCTAssertEqual(error as! NetworkError, NetworkError.unableToDecode) 69 | } 70 | } 71 | 72 | func test_decodeType을_잘못넣으면_실패() async throws { 73 | //given 74 | let endpoint = Endpoint.searchApp(term: "카카오") 75 | 76 | do { 77 | // when 78 | _ = try await sut.reqeust(with: endpoint, for: [SearchAPIResult].self) 79 | } catch { 80 | // then 81 | XCTAssertEqual(error as! NetworkError, NetworkError.unableToDecode) 82 | } 83 | } 84 | 85 | func test_첫_시도가_실패해도_retry에서_성공() async throws { 86 | //given 87 | sut = NetworkRepositoryImpl( 88 | with: URLSessionMock( 89 | shouldDataTaskSucceed: true, 90 | isFirstTryFail: true 91 | ) 92 | ) 93 | let endpoint = Endpoint.searchApp(term: "카카오") 94 | let data = readLocalFile(forName: "appInfoListKakaoBank")! 95 | let mockResult = try! JSONDecoder().decode(SearchAPIResult.self, from: data) 96 | 97 | //when 98 | let result = try await sut.reqeust(with: endpoint, for: SearchAPIResult.self) 99 | 100 | //then 101 | XCTAssertEqual(result, mockResult) 102 | 103 | } 104 | } 105 | 106 | -------------------------------------------------------------------------------- /AppStoreClone/Modules/Core/Tests/URLSessionMock.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URLSessionMock.swift 3 | // HaviTests 4 | // 5 | // Created by 한상진 on 2022/03/26. 6 | // Copyright © 2022 havi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Havi 11 | 12 | final class URLSessionMock: URLSessionProtocol { 13 | 14 | var shouldDataTaskSucceed: Bool 15 | var isFirstTryFail: Bool = false 16 | 17 | init( 18 | shouldDataTaskSucceed: Bool, 19 | isFirstTryFail: Bool 20 | ) { 21 | self.shouldDataTaskSucceed = shouldDataTaskSucceed 22 | self.isFirstTryFail = isFirstTryFail 23 | } 24 | 25 | func dataTask(with request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask { 26 | fatalError() 27 | } 28 | 29 | func data(for request: URLRequest, delegate: URLSessionTaskDelegate?) async throws -> (Data, URLResponse) { 30 | 31 | let successResponse = HTTPURLResponse( 32 | url: request.url!, 33 | statusCode: 200, 34 | httpVersion: "2", 35 | headerFields: nil 36 | )! 37 | 38 | let failureResponse = HTTPURLResponse( 39 | url: request.url!, 40 | statusCode: 402, 41 | httpVersion: "2", 42 | headerFields: nil 43 | )! 44 | 45 | if shouldDataTaskSucceed { 46 | let component = URLComponents(url: request.url!, resolvingAgainstBaseURL: false)! 47 | if isFirstTryFail { 48 | throw NetworkError.dataTaskFailed 49 | } else { 50 | if component.queryItems!.contains(where: { $0.value == "카카오" }) { 51 | let data = readLocalFile(forName: "appInfoListKakaoBank")! 52 | return (data, successResponse) 53 | } else { 54 | return (.init(), successResponse) 55 | } 56 | } 57 | } else { 58 | return (.init(), failureResponse) 59 | } 60 | } 61 | 62 | private func readLocalFile(forName name: String) -> Data? { 63 | do { 64 | let bundle = Bundle(for: type(of: self)) 65 | if let bundlePath = bundle.path(forResource: name, ofType: "json"), 66 | let jsonData = try String(contentsOfFile: bundlePath).data(using: .utf8) { 67 | return jsonData 68 | } 69 | } catch { 70 | print(error) 71 | } 72 | 73 | return nil 74 | } 75 | 76 | } 77 | -------------------------------------------------------------------------------- /AppStoreClone/Modules/ThirdPartyManager/Project.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Project.swift 3 | // ProjectDescriptionHelpers 4 | // 5 | // Created by 한상진 on 2022/01/27. 6 | // 7 | 8 | import ProjectDescription 9 | import ProjectDescriptionHelpers 10 | 11 | let project = Project.framework( 12 | name: "ThirdPartyManager", 13 | packages: [ 14 | .Moya, 15 | .ComposableArchitecture 16 | ], 17 | dependencies: [ 18 | .SPM.Moya, 19 | .SPM.ComposableArchitecture 20 | ] 21 | ) 22 | -------------------------------------------------------------------------------- /AppStoreClone/Modules/ThirdPartyManager/Resources/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/havilog/AppStoreClone/6341fb890d9efaf1d64e607ec5fe7b7f48019643/AppStoreClone/Modules/ThirdPartyManager/Resources/.gitkeep -------------------------------------------------------------------------------- /AppStoreClone/Modules/ThirdPartyManager/Sources/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/havilog/AppStoreClone/6341fb890d9efaf1d64e607ec5fe7b7f48019643/AppStoreClone/Modules/ThirdPartyManager/Sources/.gitkeep -------------------------------------------------------------------------------- /AppStoreClone/Modules/ThirdPartyManager/Sources/TempSource.swift: -------------------------------------------------------------------------------- 1 | // this is for tuist 2 | -------------------------------------------------------------------------------- /AppStoreClone/Modules/ThirdPartyManager/Tests/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/havilog/AppStoreClone/6341fb890d9efaf1d64e607ec5fe7b7f48019643/AppStoreClone/Modules/ThirdPartyManager/Tests/.gitkeep -------------------------------------------------------------------------------- /Configurations/Base/Common.xcconfig: -------------------------------------------------------------------------------- 1 | // 2 | // This file defines common settings that should be enabled for every new 3 | // project. Typically, you want to use Debug, Release, or a similar variant 4 | // instead. 5 | // 6 | 7 | // Disable legacy-compatible header searching 8 | ALWAYS_SEARCH_USER_PATHS = NO 9 | 10 | // Architectures to build 11 | ARCHS = $(ARCHS_STANDARD) 12 | 13 | // Check for Grand Central Dispatch idioms that may lead to poor performance. 14 | CLANG_ANALYZER_GCD_PERFORMANCE = YES 15 | 16 | // Warn when a number object, such as an instance of `NSNumber`, `CFNumberRef`, `OSNumber`, or `OSBoolean` is compared or converted to a primitive value instead of another object. 17 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE 18 | 19 | // Whether to warn when a floating-point value is used as a loop counter 20 | CLANG_ANALYZER_SECURITY_FLOATLOOPCOUNTER = YES 21 | 22 | // Whether to warn about use of rand() and random() being used instead of arc4random() 23 | CLANG_ANALYZER_SECURITY_INSECUREAPI_RAND = YES 24 | 25 | // Whether to warn about strcpy() and strcat() 26 | CLANG_ANALYZER_SECURITY_INSECUREAPI_STRCPY = YES 27 | 28 | // Whether to enable module imports 29 | CLANG_ENABLE_MODULES = YES 30 | 31 | // Enable ARC 32 | CLANG_ENABLE_OBJC_ARC = YES 33 | 34 | // Warn when a source file does not end with a newline. 35 | GCC_WARN_ABOUT_MISSING_NEWLINE = YES 36 | 37 | // Warn about assigning integer constants to enum values that are out of the range of the enumerated type. 38 | CLANG_WARN_ASSIGN_ENUM = YES 39 | 40 | // Warns when an atomic is used with an implicitly sequentially-consistent memory order, instead of explicitly specifying memory order. 41 | CLANG_WARN_ATOMIC_IMPLICIT_SEQ_CST = YES 42 | 43 | // Warn about block captures of implicitly autoreleasing parameters. 44 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES 45 | 46 | // Warn about implicit conversions to boolean values that are suspicious. 47 | // For example, writing 'if (foo)' with 'foo' being the name a function will trigger a warning. 48 | CLANG_WARN_BOOL_CONVERSION = YES 49 | 50 | // Warn about suspicious uses of the comma operator. 51 | CLANG_WARN_COMMA = YES 52 | 53 | // Warn about implicit conversions of constant values that cause the constant value to change, 54 | // either through a loss of precision, or entirely in its meaning. 55 | CLANG_WARN_CONSTANT_CONVERSION = YES 56 | 57 | // Whether to warn when overriding deprecated methods 58 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES 59 | 60 | // Warn about direct accesses to the Objective-C 'isa' pointer instead of using a runtime API. 61 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR 62 | 63 | // Warns about issues in documentation comments (`doxygen`-style) such as missing or incorrect documentation tags. 64 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES 65 | 66 | // Warn about declaring the same method more than once within the same @interface. 67 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES 68 | 69 | // Warn about loop bodies that are suspiciously empty. 70 | CLANG_WARN_EMPTY_BODY = YES 71 | 72 | // Warn about implicit conversions between different kinds of enum values. 73 | // For example, this can catch issues when using the wrong enum flag as an argument to a function or method. 74 | CLANG_WARN_ENUM_CONVERSION = YES 75 | 76 | // Whether to warn on implicit conversions between signed/unsigned types 77 | CLANG_WARN_IMPLICIT_SIGN_CONVERSION = NO 78 | 79 | // Warn about implicit conversions between pointers and integers. 80 | // For example, this can catch issues when one incorrectly intermixes using NSNumbers and raw integers. 81 | CLANG_WARN_INT_CONVERSION = YES 82 | 83 | // Warn about non-literal expressions that evaluate to zero being treated as a null pointer. 84 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES 85 | 86 | // Warn about implicit capture of self (e.g. direct ivar access) 87 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES 88 | 89 | // Warn about implicit conversions from Objective-C literals to values of incompatible type. 90 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES 91 | 92 | // Don't warn about repeatedly using a weak reference without assigning the weak reference to a strong reference. Too many false positives. 93 | CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = NO 94 | 95 | // Warn about classes that unintentionally do not subclass a root class (such as NSObject). 96 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR 97 | 98 | // Warn about implicit ownership types on Objective-C object references as out parameters. For example, declaring a parameter with type `NSObject**` will produce a warning because the compiler will assume that the out parameter's ownership type is `__autoreleasing` 99 | CLANG_WARN_OBJC_EXPLICIT_OWNERSHIP_TYPE = YES 100 | 101 | // Warns when a quoted include is used instead of a framework style include in a framework header. 102 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES 103 | 104 | // Warn about ranged-based for loops. 105 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES 106 | 107 | // Whether to warn on suspicious implicit conversions 108 | CLANG_WARN_SUSPICIOUS_IMPLICIT_CONVERSION = YES 109 | 110 | // Warn about non-prototype declarations. 111 | CLANG_WARN_STRICT_PROTOTYPES = YES 112 | 113 | // Warn if an API that is newer than the deployment target is used without "if (@available(...))" guards. 114 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE 115 | 116 | // Warn about incorrect uses of nullable values 117 | CLANG_WARN_NULLABLE_TO_NONNULL_CONVERSION = YES 118 | 119 | // Warn for missing nullability attributes 120 | CLANG_ANALYZER_NONNULL = YES 121 | 122 | // Warn when a non-localized string is passed to a user-interface method expecting a localized string 123 | CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES 124 | 125 | // Warn about potentially unreachable code 126 | CLANG_WARN_UNREACHABLE_CODE = YES 127 | 128 | // The format of debugging symbols 129 | DEBUG_INFORMATION_FORMAT = dwarf-with-dsym 130 | 131 | // Whether to compile assertions in 132 | ENABLE_NS_ASSERTIONS = YES 133 | 134 | // Whether to require objc_msgSend to be cast before invocation 135 | ENABLE_STRICT_OBJC_MSGSEND = YES 136 | 137 | // Which C variant to use. gnu11 is the default value when creating a Xcode project with version 10.1 (10B61) 138 | GCC_C_LANGUAGE_STANDARD = gnu11 139 | 140 | // Whether to enable exceptions for Objective-C 141 | GCC_ENABLE_OBJC_EXCEPTIONS = YES 142 | 143 | // Whether to generate debugging symbols 144 | GCC_GENERATE_DEBUGGING_SYMBOLS = YES 145 | 146 | // Whether to precompile the prefix header (if one is specified) 147 | GCC_PRECOMPILE_PREFIX_HEADER = YES 148 | 149 | // Whether to enable strict aliasing, meaning that two pointers of different 150 | // types (other than void * or any id type) cannot point to the same memory 151 | // location 152 | GCC_STRICT_ALIASING = YES 153 | 154 | // Whether symbols not explicitly exported are hidden by default (this primarily 155 | // only affects C++ code) 156 | GCC_SYMBOLS_PRIVATE_EXTERN = NO 157 | 158 | // Whether static variables are thread-safe by default 159 | GCC_THREADSAFE_STATICS = NO 160 | 161 | // Which compiler to use 162 | GCC_VERSION = com.apple.compilers.llvm.clang.1_0 163 | 164 | // Whether warnings are treated as errors 165 | GCC_TREAT_WARNINGS_AS_ERRORS = YES 166 | SWIFT_TREAT_WARNINGS_AS_ERRORS = YES 167 | 168 | // Whether to warn about 64-bit values being implicitly shortened to 32 bits 169 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES 170 | 171 | // Whether to warn about fields missing from structure initializers (only if 172 | // designated initializers aren't used) 173 | GCC_WARN_ABOUT_MISSING_FIELD_INITIALIZERS = YES 174 | 175 | // Whether to warn about missing function prototypes 176 | GCC_WARN_ABOUT_MISSING_PROTOTYPES = NO 177 | 178 | // Whether to warn about implicit conversions in the signedness of the type 179 | // a pointer is pointing to (e.g., 'int *' getting converted to 'unsigned int *') 180 | GCC_WARN_ABOUT_POINTER_SIGNEDNESS = YES 181 | 182 | // Whether to warn when the value returned from a function/method/block does not 183 | // match its return type 184 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR 185 | 186 | // Whether to warn on a class not implementing all the required methods of 187 | // a protocol it declares conformance to 188 | GCC_WARN_ALLOW_INCOMPLETE_PROTOCOL = YES 189 | 190 | // Whether to warn when switching on an enum value, and all possibilities are 191 | // not accounted for 192 | GCC_WARN_CHECK_SWITCH_STATEMENTS = YES 193 | 194 | // Whether to warn about the use of four-character constants 195 | GCC_WARN_FOUR_CHARACTER_CONSTANTS = YES 196 | 197 | // Whether to warn about an aggregate data type's initializer not being fully 198 | // bracketed (e.g., array initializer syntax) 199 | GCC_WARN_INITIALIZER_NOT_FULLY_BRACKETED = YES 200 | 201 | // Whether to warn about missing braces or parentheses that make the meaning of 202 | // the code ambiguous 203 | GCC_WARN_MISSING_PARENTHESES = YES 204 | 205 | // Whether to warn about unsafe comparisons between values of different 206 | // signedness 207 | GCC_WARN_SIGN_COMPARE = YES 208 | 209 | // Whether to warn about the arguments to printf-style functions not matching 210 | // the format specifiers 211 | GCC_WARN_TYPECHECK_CALLS_TO_PRINTF = YES 212 | 213 | // Warn if a "@selector(...)" expression referring to an undeclared selector is found 214 | GCC_WARN_UNDECLARED_SELECTOR = YES 215 | 216 | // Warn if a variable might be clobbered by a `setjmp` call or if an automatic variable is used without prior initialization. 217 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE 218 | 219 | // Whether to warn about static functions that are unused 220 | GCC_WARN_UNUSED_FUNCTION = YES 221 | 222 | // Whether to warn about labels that are unused 223 | GCC_WARN_UNUSED_LABEL = YES 224 | 225 | // Whether to warn about variables that are never used 226 | GCC_WARN_UNUSED_VARIABLE = YES 227 | 228 | // Whether to run the static analyzer with every build 229 | RUN_CLANG_STATIC_ANALYZER = YES 230 | 231 | // Don't treat unknown warnings as errors, and disable GCC compatibility warnings and unused static const variable warnings 232 | WARNING_CFLAGS = -Wno-error=unknown-warning-option -Wno-gcc-compat -Wno-unused-const-variable 233 | 234 | // This setting is on for new projects as of Xcode ~6.3, though it is still not 235 | // the default. It warns if the same variable is declared in two binaries that 236 | // are linked together. 237 | GCC_NO_COMMON_BLOCKS = YES 238 | 239 | // This warnings detects when a function will recursively call itself on every 240 | // code path though that function. More information can be found here: 241 | // http://lists.llvm.org/pipermail/cfe-commits/Week-of-Mon-20131216/096004.html 242 | CLANG_WARN_INFINITE_RECURSION = YES 243 | 244 | // This warning detects suspicious uses of std::move. 245 | CLANG_WARN_SUSPICIOUS_MOVE = YES 246 | -------------------------------------------------------------------------------- /Configurations/Base/Projects/Project-Beta.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Shared.xcconfig" 2 | 3 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) BETA 4 | -------------------------------------------------------------------------------- /Configurations/Base/Projects/Project-Development.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Shared.xcconfig" 2 | 3 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) DEV -------------------------------------------------------------------------------- /Configurations/Base/Projects/Project-Production.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Shared.xcconfig" 2 | 3 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) PRODUCTION -------------------------------------------------------------------------------- /Configurations/Base/Projects/Project-QA.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Shared.xcconfig" 2 | 3 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) QA -------------------------------------------------------------------------------- /Configurations/Base/Projects/Project-Test.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Shared.xcconfig" 2 | 3 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) TEST 4 | 5 | -------------------------------------------------------------------------------- /Configurations/Base/Projects/Shared.xcconfig: -------------------------------------------------------------------------------- 1 | ENABLE_BITCODE = NO -------------------------------------------------------------------------------- /Configurations/Base/Targets/Application.xcconfig: -------------------------------------------------------------------------------- 1 | // 2 | // This file defines additional configuration options that are appropriate only 3 | // for an application. Typically, you want to use a platform-specific variant 4 | // instead. 5 | // 6 | 7 | // Firebase requires this 8 | OTHER_LDFLAGS = -ObjC 9 | 10 | // Auto set to our development team to ease building the app 11 | //DEVELOPMENT_TEAM = AW656G5PFM 12 | 13 | // Whether to strip out code that isn't called from anywhere 14 | DEAD_CODE_STRIPPING = NO 15 | 16 | // Sets the @rpath for the application such that it can include frameworks in 17 | // the application bundle (inside the "Frameworks" folder) 18 | LD_RUNPATH_SEARCH_PATHS = @executable_path/../Frameworks @loader_path/../Frameworks @executable_path/Frameworks 19 | -------------------------------------------------------------------------------- /Configurations/Base/Targets/Extension.xcconfig: -------------------------------------------------------------------------------- 1 | // 2 | // This file defines additional configuration options that are appropriate only 3 | // for an extension embedded within an application. Typically, you want to use a 4 | // platform-specific variant instead. 5 | // 6 | 7 | // Whether to strip out code that isn't called from anywhere 8 | DEAD_CODE_STRIPPING = NO 9 | -------------------------------------------------------------------------------- /Configurations/Base/Targets/Framework.xcconfig: -------------------------------------------------------------------------------- 1 | // 2 | // This file defines additional configuration options that are appropriate only 3 | // for a framework. Typically, you want to use a platform-specific variant 4 | // instead. 5 | // 6 | 7 | // Disable code signing for successful device builds with Xcode 8. Frameworks do 8 | // need to be signed, but they don't need to be signed at compile time because 9 | // they'll be re-signed when you include them in your app. 10 | CODE_SIGNING_REQUIRED = NO 11 | CODE_SIGN_IDENTITY = 12 | 13 | // Whether to strip out code that isn't called from anywhere 14 | DEAD_CODE_STRIPPING = NO 15 | 16 | // Whether this framework should define an LLVM module 17 | DEFINES_MODULE = YES 18 | 19 | // Whether function calls should be position-dependent (should always be 20 | // disabled for library code) 21 | GCC_DYNAMIC_NO_PIC = NO 22 | 23 | // Default frameworks to the name of the project, instead of any 24 | // platform-specific target 25 | PRODUCT_NAME = $(PROJECT_NAME) 26 | 27 | // Enables the framework to be included from any location as long as the 28 | // loader’s runpath search paths includes it. For example from an application 29 | // bundle (inside the "Frameworks" folder) or shared folder 30 | INSTALL_PATH = @rpath 31 | LD_DYLIB_INSTALL_NAME = @rpath/$(PRODUCT_NAME).$(WRAPPER_EXTENSION)/$(PRODUCT_NAME) 32 | SKIP_INSTALL = YES 33 | 34 | // Make SwiftUI previews work in frameworks that depend on other non-system 35 | // frameworks. The Xcode agent that renders SwiftUI previews seems to copy all 36 | // required frameworks from the built product into a single temp directory. 37 | // This allows the rendering agent to find the frameworks there. 38 | // Source: https://github.com/NSHipster/articles/issues/673 39 | LD_RUNPATH_SEARCH_PATHS = $(inherited) @loader_path/.. 40 | 41 | // Disallows use of APIs that are not available 42 | // to app extensions and linking to frameworks 43 | // that have not been built with this setting enabled. 44 | APPLICATION_EXTENSION_API_ONLY = YES 45 | -------------------------------------------------------------------------------- /Configurations/Base/Targets/StaticLibrary.xcconfig: -------------------------------------------------------------------------------- 1 | // 2 | // This file defines additional configuration options that are appropriate only 3 | // for a static library. Typically, you want to use a platform-specific variant 4 | // instead. 5 | // 6 | 7 | // Whether to strip out code that isn't called from anywhere 8 | DEAD_CODE_STRIPPING = NO 9 | 10 | // Whether to strip debugging symbols when copying resources (like included 11 | // binaries). 12 | // 13 | // Overrides Release.xcconfig when used at the target level. 14 | COPY_PHASE_STRIP = NO 15 | 16 | // Whether function calls should be position-dependent (should always be 17 | // disabled for library code) 18 | GCC_DYNAMIC_NO_PIC = NO 19 | 20 | // Copy headers to "include/LibraryName" in the build folder by default. This 21 | // lets consumers use #import syntax even for static 22 | // libraries 23 | PUBLIC_HEADERS_FOLDER_PATH = include/$PRODUCT_NAME 24 | 25 | // Don't include in an xcarchive 26 | SKIP_INSTALL = YES 27 | 28 | // Disallows use of APIs that are not available 29 | // to app extensions and linking to frameworks 30 | // that have not been built with this setting enabled. 31 | APPLICATION_EXTENSION_API_ONLY = YES 32 | 33 | -------------------------------------------------------------------------------- /Configurations/iOS/iOS-Example.xcconfig: -------------------------------------------------------------------------------- 1 | //#include "iOS-Base.xcconfig" -------------------------------------------------------------------------------- /Configurations/iOS/iOS-Extension.xcconfig: -------------------------------------------------------------------------------- 1 | //#include "iOS-Base.xcconfig" -------------------------------------------------------------------------------- /Configurations/iOS/iOS-Framework.xcconfig: -------------------------------------------------------------------------------- 1 | //#include "iOS-Base.xcconfig" -------------------------------------------------------------------------------- /Configurations/iOS/iOS-StaticLibrary.xcconfig: -------------------------------------------------------------------------------- 1 | //#include "iOS-Base.xcconfig" -------------------------------------------------------------------------------- /Configurations/iOS/iOS-Tests.xcconfig: -------------------------------------------------------------------------------- 1 | // 2 | // This file defines additional configuration options that are appropriate only 3 | // for iOS. This file is not standalone -- it is meant to be included into 4 | // a configuration file for a specific type of target. 5 | // 6 | 7 | // Xcode needs this to find archived headers if SKIP_INSTALL is set 8 | HEADER_SEARCH_PATHS = $(inherited) $(OBJROOT)/UninstalledProducts/include 9 | 10 | // Where to find embedded frameworks 11 | LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/Frameworks @loader_path/Frameworks 12 | 13 | // The base SDK to use (if no version is specified, the latest version is 14 | // assumed) 15 | SDKROOT = iphoneos 16 | 17 | // Supported device families (1 is iPhone, 2 is iPad) 18 | TARGETED_DEVICE_FAMILY = 1,2 19 | 20 | SWIFT_COMPILATION_MODE = wholemodule 21 | 22 | SWIFT_INCLUDE_PATHS = $(inherited) 23 | 24 | CODE_SIGN_STYLE = Manual 25 | 26 | SWIFT_TREAT_WARNINGS_AS_ERRORS = NO 27 | 28 | OTHER_LDFLAGS = -ObjC 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AppStoreClone 2 | 3 | AppStore의 검색 화면을 SwiftUI, Combine, TCA, async/await를 활용하여 구현해보자 4 | 5 | - Swift version: 5.5 6 | - Xcode version: 13.3 7 | - tuist version: 3.0.1 8 | 9 | ## 실행 방법 10 | 11 | 1. tuist가 설치가 안되어있다면 다음 명령어로 tuist 설치 12 | 13 | ``` 14 | curl -Ls https://install.tuist.io | bash 15 | ``` 16 | 17 | 2. tuist generate로 프로젝트 파일 생성 18 | 19 | cc. tuist version이 다르면 .. tuist edit 에서 manifest file에서 잡아주면 되는거 같은데 안해봐서 모름 20 | 21 | ## 요구사항 22 | 23 | 1. 앱 스토어 검색화면 구현 24 | 2. 키워드 입력 시 검색결과 리스트로 표출 25 | 3. 이미지 캐싱 및 페이지네이션 구현 26 | 4. 셀 클릭 시 디테일로 이동 27 | -------------------------------------------------------------------------------- /Tooling/TCA.xctemplate/TemplateIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/havilog/AppStoreClone/6341fb890d9efaf1d64e607ec5fe7b7f48019643/Tooling/TCA.xctemplate/TemplateIcon.png -------------------------------------------------------------------------------- /Tooling/TCA.xctemplate/TemplateIcon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/havilog/AppStoreClone/6341fb890d9efaf1d64e607ec5fe7b7f48019643/Tooling/TCA.xctemplate/TemplateIcon@2x.png -------------------------------------------------------------------------------- /Tooling/TCA.xctemplate/TemplateInfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Kind 6 | Xcode.IDEFoundation.TextSubstitutionFileTemplateKind 7 | Summary 8 | TCA 9 | Description 10 | TCA description 11 | SortOrder 12 | 7 13 | AllowedTypes 14 | 15 | Item 0 16 | public.swift-source 17 | 18 | DefaultCompletionName 19 | TCA 20 | MainTemplateFiles 21 | ___FILEBASENAME___.swift 22 | Platforms 23 | 24 | com.apple.platform.iphoneos 25 | 26 | Options 27 | 28 | 29 | Identifier 30 | productName 31 | Required 32 | true 33 | Name 34 | TCA name: 35 | Description 36 | The name of the TCA components to create 37 | Type 38 | text 39 | NotPersisted 40 | true 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /Tooling/TCA.xctemplate/___FILEBASENAME___Feature.swift: -------------------------------------------------------------------------------- 1 | //___FILEHEADER___ 2 | 3 | import ThirdPartyManager 4 | import ComposableArchitecture 5 | 6 | struct ___VARIABLE_productName___State: Equatable { 7 | } 8 | 9 | enum ___VARIABLE_productName___Action: Equatable { 10 | } 11 | 12 | struct ___VARIABLE_productName___Environment: Equatable { 13 | } 14 | 15 | let ___VARIABLE_productName___Reducer = Reducer<___VARIABLE_productName___State, ___VARIABLE_productName___Action, ___VARIABLE_productName___Environment>.combine( 16 | Reducer { state, action, environment in 17 | switch action { 18 | } 19 | } 20 | ) 21 | .debug() 22 | 23 | -------------------------------------------------------------------------------- /Tooling/TCA.xctemplate/___FILEBASENAME___View.swift: -------------------------------------------------------------------------------- 1 | //___FILEHEADER___ 2 | 3 | import SwiftUI 4 | 5 | import ThirdPartyManager 6 | import ComposableArchitecture 7 | 8 | struct ___VARIABLE_productName___View: View { 9 | 10 | // MARK: Init 11 | 12 | init() { 13 | 14 | } 15 | 16 | // MARK: Body 17 | 18 | var body: some View { 19 | Text("Hello, World!") 20 | } 21 | } 22 | 23 | // MARK: Preview 24 | 25 | struct ___VARIABLE_productName___View_Previews: PreviewProvider { 26 | static var previews: some View { 27 | ___VARIABLE_productName___View() 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Tooling/install-xcode-template.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # Configuration 4 | XCODE_TEMPLATE_DIR=$HOME'/Library/Developer/Xcode/Templates/File Templates/TCA' 5 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 6 | 7 | # Copy RIBs file templates into the local RIBs template directory 8 | xcodeTemplate () { 9 | echo "==> Copying up TCA Xcode file templates..." 10 | 11 | if [ -d "$XCODE_TEMPLATE_DIR" ]; then 12 | rm -R "$XCODE_TEMPLATE_DIR" 13 | fi 14 | mkdir -p "$XCODE_TEMPLATE_DIR" 15 | 16 | cp -R $SCRIPT_DIR/*.xctemplate "$XCODE_TEMPLATE_DIR" 17 | } 18 | 19 | xcodeTemplate 20 | 21 | echo "==> ... success!" 22 | echo "==> TCA have been set up. In Xcode, select 'New File...' to use TCA templates." 23 | -------------------------------------------------------------------------------- /Tuist/Config.swift: -------------------------------------------------------------------------------- 1 | import ProjectDescription 2 | 3 | let config = Config( 4 | compatibleXcodeVersions: .upToNextMajor("13.2.1"), 5 | swiftVersion: "5.4.2", 6 | plugins: [ 7 | // .local(path: .relativeToRoot("Plugin/UtilityPlugin")) 8 | ] 9 | // ,[ 10 | // generationOptions: [ProjectDescription.Config.GenerationOptions.options() 11 | // .xcodeProjectName("\(.projectName)"), 12 | // .organizationName("havi"), 13 | // .disableAutogeneratedSchemes, 14 | // .disableSynthesizedResourceAccessors, 15 | // ] 16 | ) 17 | -------------------------------------------------------------------------------- /Tuist/Dependencies.swift: -------------------------------------------------------------------------------- 1 | import ProjectDescription 2 | 3 | let dependencies = Dependencies( 4 | carthage: .init( 5 | [ 6 | // .github(path: "minsone-opensource-fork/FLEX", 7 | // requirement: .revision("2ad3092f4b9e31fc7294e45f3ee241324e17f0b3")), 8 | // .github(path: "ReactiveX/RxSwift", 9 | // requirement: .branch("main")), 10 | // .github(path: "Quick/Quick", 11 | // requirement: .branch("main")), 12 | // .github(path: "Quick/Nimble", 13 | // requirement: .branch("main")), 14 | // .github(path: "RxSwiftCommunity/RxNimble", 15 | // requirement: .branch("master")), 16 | // .github(path: "uber/RIBs", 17 | // requirement: .branch("master")), 18 | // .github(path: "ReactorKit/ReactorKit", 19 | // requirement: .branch("master")), 20 | // .github(path: "ReactorKit/WeakMapTable", 21 | // requirement: .branch("master")), 22 | // .github(path: "layoutBox/FlexLayout", 23 | // requirement: .branch("master")), 24 | // .github(path: "layoutBox/PinLayout", 25 | // requirement: .branch("master")), 26 | // .github(path: "SnapKit/SnapKit", 27 | // requirement: .branch("main")), 28 | // .github(path: "morizotter/TouchVisualizer", 29 | // requirement: .branch("master")), 30 | ] 31 | ), 32 | swiftPackageManager: .init( 33 | [ 34 | // .package(url: "https://github.com/Alamofire/Alamofire.git", .branch("master")), 35 | // .package(url: "https://github.com/SnapKit/SnapKit.git", .upToNextMajor(from: "5.0.1")), 36 | // .package(url: "https://github.com/AliSoftware/OHHTTPStubs.git", .branch("master")), 37 | // .package(url: "https://github.com/layoutBox/FlexLayout.git", .branch("master")), 38 | // .package(url: "https://github.com/ReactorKit/ReactorKit.git", .branch("master")), 39 | // .package(url: "https://github.com/uber/RIBs.git", .branch("master")), 40 | // .package(url: "https://github.com/ReactiveX/RxSwift.git", from: "6.0.0"), 41 | // .local(path: .relativeToRoot("Projects/UserInterface/ResourcePackage")), 42 | ] 43 | ), 44 | platforms: [.iOS] 45 | ) 46 | -------------------------------------------------------------------------------- /Tuist/ProjectDescriptionHelpers/Action+Template.swift: -------------------------------------------------------------------------------- 1 | import ProjectDescription 2 | 3 | public extension TargetScript { 4 | static let swiftlint = TargetScript.pre( 5 | path: .relativeToRoot("Scripts/SwiftLintRunScript.sh"), 6 | name: "SwiftLint" 7 | ) 8 | } 9 | -------------------------------------------------------------------------------- /Tuist/ProjectDescriptionHelpers/Dependencies+Template.swift: -------------------------------------------------------------------------------- 1 | import ProjectDescription 2 | 3 | public extension TargetDependency { 4 | static let Chos: TargetDependency = .project( 5 | target: "Chos", 6 | path: .relativeToRoot("AppStoreClone/Features/Chos") 7 | ) 8 | 9 | static let Heizel: TargetDependency = .project( 10 | target: "Heizel", 11 | path: .relativeToRoot("AppStoreClone/Features/Heizel") 12 | ) 13 | 14 | static let Havi: TargetDependency = .project( 15 | target: "Havi", 16 | path: .relativeToRoot("AppStoreClone/Features/Havi") 17 | ) 18 | 19 | static let Core: TargetDependency = .project( 20 | target: "Core", 21 | path: .relativeToRoot("AppStoreClone/Modules/Core") 22 | ) 23 | 24 | static let ThirdPartyManager: TargetDependency = .project( 25 | target: "ThirdPartyManager", 26 | path: .relativeToRoot("AppStoreClone/Modules/ThirdPartyManager") 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /Tuist/ProjectDescriptionHelpers/Project+Extension.swift: -------------------------------------------------------------------------------- 1 | import ProjectDescription 2 | 3 | extension DeploymentTarget { 4 | public static let defaultDeployment: DeploymentTarget = .iOS(targetVersion: "15.0", devices: .iphone) 5 | } 6 | 7 | // BuildSettings Key 8 | public extension String { 9 | static let marketVersion = "MARKETING_VERSION" 10 | static let currentProjectVersion = "CURRENT_PROJECT_VERSION" 11 | static let codeSignIdentity = "CODE_SIGN_IDENTITY" 12 | static let codeSigningStyle = "CODE_SIGNING_STYLE" 13 | static let codeSigningRequired = "CODE_SIGNING_REQUIRED" 14 | static let developmentTeam = "DEVELOPMENT_TEAM" 15 | static let bundleIdentifier = "Baycon_Bundle_Identifier" 16 | static let bundleName = "Havi_Bundle_Name" 17 | static let provisioningProfileSpecifier = "PROVISIONING_PROFILE_SPECIFIER" 18 | static let swiftVersion = "SWIFT_VERSION" 19 | static let developmentAssetPaths = "DEVELOPMENT_ASSET_PATHS" 20 | static let enableTestability = "ENABLE_TESTABILITY" 21 | } 22 | 23 | extension TargetDependency { 24 | public struct SPM { 25 | } 26 | } 27 | 28 | // dependencies 29 | public extension TargetDependency.SPM { 30 | static let Moya = TargetDependency.package(product: "Moya") 31 | static let ComposableArchitecture = TargetDependency.package(product: "ComposableArchitecture") 32 | } 33 | 34 | public extension Package { 35 | static let Moya = Package.remote(url: "https://github.com/Moya/Moya", requirement: .exact("15.0.0")) 36 | static let ComposableArchitecture = Package.remote(url: "https://github.com/pointfreeco/swift-composable-architecture", requirement: .exact("0.34.0")) 37 | } 38 | -------------------------------------------------------------------------------- /Tuist/ProjectDescriptionHelpers/Project+Templates.swift: -------------------------------------------------------------------------------- 1 | import ProjectDescription 2 | 3 | public extension Project { 4 | static func staticLibrary( 5 | name: String, 6 | platform: Platform = .iOS, 7 | packages: [Package] = [], 8 | dependencies: [TargetDependency] = [], 9 | customSettings: [String: SettingValue] = [:] 10 | ) -> Self { 11 | return project( 12 | name: name, 13 | packages: packages, 14 | product: .staticLibrary, 15 | platform: platform, 16 | dependencies: dependencies, 17 | customSettings: customSettings 18 | ) 19 | } 20 | 21 | static func staticFramework( 22 | name: String, 23 | platform: Platform = .iOS, 24 | packages: [Package] = [], 25 | dependencies: [TargetDependency] = [], 26 | customSettings: [String: SettingValue] = [:] 27 | ) -> Self { 28 | return project( 29 | name: name, 30 | packages: packages, 31 | product: .staticFramework, 32 | platform: platform, 33 | dependencies: dependencies, 34 | customSettings: customSettings 35 | ) 36 | } 37 | 38 | static func framework( 39 | name: String, 40 | platform: Platform = .iOS, 41 | packages: [Package] = [], 42 | dependencies: [TargetDependency] = [], 43 | customSettings: [String: SettingValue] = [:] 44 | ) -> Self { 45 | return project( 46 | name: name, 47 | packages: packages, 48 | product: .framework, 49 | platform: platform, 50 | dependencies: dependencies, 51 | customSettings: customSettings 52 | ) 53 | } 54 | } 55 | 56 | /* 57 | 만약 배포 버전에 문제가 생긴다면 settings 안에 다음 코드 추가 58 | base: [ 59 | !프로젝트 버전 번호, 마케팅 버전 번호는 Info.plist를 통해서 설정! 60 | .marketVersion: "1.1", 61 | .currentProjectVersion: "2" 62 | ], 63 | */ 64 | public extension Project { 65 | static func project( 66 | name: String, 67 | organizationName: String = "havi", 68 | packages: [Package] = [], 69 | product: Product, 70 | platform: Platform = .iOS, 71 | deploymentTarget: DeploymentTarget? = .defaultDeployment, 72 | scripts: [TargetScript] = [], 73 | dependencies: [TargetDependency] = [], 74 | customSettings: [String: SettingValue] = [:], 75 | infoPlist: InfoPlist = .default, 76 | settings: Settings? = nil, 77 | schemes: [Scheme] = [] 78 | ) -> Self { 79 | var base: SettingsDictionary = [ 80 | .codeSignIdentity: "Apple Development", 81 | .codeSigningStyle: "Automatic", 82 | .developmentTeam: "85329TR25G", 83 | .codeSigningRequired: "NO", 84 | .swiftVersion: "5.4.2", 85 | .enableTestability: "YES" 86 | ] 87 | 88 | customSettings.forEach { 89 | base.updateValue($1, forKey: $0) 90 | } 91 | let xcconfig = product == .framework ? XCConfig.framework : XCConfig.project 92 | let settings: Settings = .settings( 93 | base: base, 94 | configurations: xcconfig 95 | ) 96 | 97 | let additionalInfoPlist: [String: InfoPlist.Value] = [ 98 | "CFBundleShortVersionString": "1.0", 99 | "CFBundleVersion": "1", 100 | "UIMainStoryboardFile": "", 101 | "UILaunchStoryboardName": "LaunchScreen" 102 | ] 103 | 104 | let mainTarget: Target = Target( 105 | name: name, 106 | platform: platform, 107 | product: product, 108 | bundleId: "com.\(organizationName).\(name)", 109 | deploymentTarget: deploymentTarget, 110 | infoPlist: .extendingDefault(with: additionalInfoPlist), 111 | sources: ["Sources/**"], 112 | resources: ["Resources/**"], 113 | scripts: scripts, 114 | dependencies: dependencies 115 | ) 116 | 117 | let testTarget: Target = Target( 118 | name: "\(name)Tests", 119 | platform: platform, 120 | product: .unitTests, 121 | bundleId: "com.\(organizationName).\(name)Tests", 122 | deploymentTarget: deploymentTarget, 123 | infoPlist: .default, 124 | sources: ["Tests/**"], 125 | resources: ["Tests/**"], 126 | dependencies: [.target(name: name)] 127 | ) 128 | 129 | let targets: [Target] = [ 130 | mainTarget, 131 | testTarget 132 | ] 133 | 134 | return Project( 135 | name: name, 136 | organizationName: organizationName, 137 | packages: packages, 138 | settings: settings, 139 | targets: targets, 140 | schemes: schemes 141 | ) 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /Tuist/ProjectDescriptionHelpers/XCConfig.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import ProjectDescription 3 | 4 | struct XCConfig { 5 | private struct Path { 6 | static var framework: ProjectDescription.Path { .relativeToRoot("Configurations/iOS/iOS-Framework.xcconfig") } 7 | static var example: ProjectDescription.Path { .relativeToRoot("Configurations/iOS/iOS-Example.xcconfig") } 8 | static var tests: ProjectDescription.Path { .relativeToRoot("Configurations/iOS/iOS-Tests.xcconfig") } 9 | static func project(_ config: String) -> ProjectDescription.Path { .relativeToRoot("Configurations/Base/Projects/Project-\(config).xcconfig") } 10 | } 11 | 12 | static let framework: [Configuration] = [ 13 | .debug(name: "Development", xcconfig: Path.framework), 14 | .release(name: "Production", xcconfig: Path.framework), 15 | ] 16 | 17 | static let project: [Configuration] = [ 18 | .debug(name: "Development", xcconfig: Path.project("Development")), 19 | .release(name: "Production", xcconfig: Path.project("Production")), 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /Workspace.swift: -------------------------------------------------------------------------------- 1 | import ProjectDescription 2 | 3 | let workspace = Workspace( 4 | name: "AppStoreClone", 5 | projects: ["AppStoreClone/App"] 6 | ) 7 | --------------------------------------------------------------------------------