├── .gitignore ├── .package.resolved ├── LICENSE ├── Projects ├── App │ ├── .package.resolved │ ├── .swiftlint.yml │ ├── Project.swift │ ├── Resources │ │ ├── Assets.xcassets │ │ │ ├── AccentColor.colorset │ │ │ │ └── Contents.json │ │ │ ├── AppIcon.appiconset │ │ │ │ └── Contents.json │ │ │ └── Contents.json │ │ └── Base.lproj │ │ │ └── LaunchScreen.storyboard │ ├── Sources │ │ └── Application │ │ │ └── AppDelegate.swift │ ├── Supporting │ │ └── Info.plist │ └── Tests │ │ └── .gitkeep ├── Features │ ├── Project.swift │ ├── Resources │ │ └── .gitkeep │ ├── Sources │ │ ├── .gitkeep │ │ ├── Extension │ │ │ └── Snapkit+Safe.swift │ │ └── Scenes │ │ │ ├── Initial │ │ │ └── LaunchVC.swift │ │ │ └── Main │ │ │ └── MainVC.swift │ └── Tests │ │ └── .gitkeep └── Modules │ ├── CoreKit │ ├── Project.swift │ ├── Resources │ │ └── .gitkeep │ ├── Sources │ │ ├── .gitkeep │ │ ├── API │ │ │ └── NetworkConstant.swift │ │ └── Common │ │ │ └── Preferences.swift │ └── Tests │ │ └── .gitkeep │ ├── DatabaseModule │ ├── Project.swift │ ├── Resources │ │ └── .gitkeep │ ├── Sources │ │ ├── BaseRealm.swift │ │ ├── DatabaseLogger.swift │ │ ├── DatabaseRepository.swift │ │ └── Extension │ │ │ ├── Realm+SafeWrite.swift │ │ │ └── Realm+Utility.swift │ └── Tests │ │ ├── DatabaseRepositoryTests.swift │ │ └── RealmWrapperTests.swift │ ├── NetworkModule │ ├── Project.swift │ ├── Resources │ │ └── .gitkeep │ ├── Sources │ │ ├── .gitkeep │ │ ├── NetworkLogger.swift │ │ └── NetworkRepository.swift │ └── Tests │ │ ├── .gitkeep │ │ ├── MockTodosAPI.swift │ │ └── NetworkRepositoryTests.swift │ ├── ThirdPartyManager │ ├── LocalSPM │ │ └── Logger │ │ │ ├── Package.swift │ │ │ ├── README.md │ │ │ ├── Sources │ │ │ └── Logger │ │ │ │ └── Logger.swift │ │ │ └── Tests │ │ │ └── LoggerTests │ │ │ └── LoggerTests.swift │ ├── Project.swift │ ├── Resources │ │ └── .gitkeep │ ├── Sources │ │ ├── .gitkeep │ │ └── TempSource.swift │ └── Tests │ │ └── .gitkeep │ └── UtilityModule │ ├── Project.swift │ ├── Resources │ └── .gitkeep │ ├── Sources │ ├── .gitkeep │ └── Extension │ │ ├── Foundation │ │ ├── Array+safe.swift │ │ ├── Bool+Utility.swift │ │ └── Optional+Utility.swift │ │ └── UIKit │ │ └── Cell+reusableID.swift │ └── Tests │ └── .gitkeep ├── README.md ├── Scripts └── SwiftLintRunScript.sh ├── Tuist └── ProjectDescriptionHelpers │ ├── Action+Template.swift │ ├── Dependencies+Template.swift │ ├── Project+Extension.swift │ └── Project+Templates.swift ├── Workspace.swift └── graph.png /.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 | -------------------------------------------------------------------------------- /.package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "Alamofire", 6 | "repositoryURL": "https://github.com/Alamofire/Alamofire.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "f96b619bcb2383b43d898402283924b80e2c4bae", 10 | "version": "5.4.3" 11 | } 12 | }, 13 | { 14 | "package": "CwlCatchException", 15 | "repositoryURL": "https://github.com/mattgallagher/CwlCatchException.git", 16 | "state": { 17 | "branch": null, 18 | "revision": "682841464136f8c66e04afe5dbd01ab51a3a56f2", 19 | "version": "2.1.0" 20 | } 21 | }, 22 | { 23 | "package": "CwlPreconditionTesting", 24 | "repositoryURL": "https://github.com/mattgallagher/CwlPreconditionTesting.git", 25 | "state": { 26 | "branch": null, 27 | "revision": "02b7a39a99c4da27abe03cab2053a9034379639f", 28 | "version": "2.0.0" 29 | } 30 | }, 31 | { 32 | "package": "Moya", 33 | "repositoryURL": "https://github.com/Moya/Moya", 34 | "state": { 35 | "branch": null, 36 | "revision": "b3e5a233e0d85fd4d69f561c80988590859c7dee", 37 | "version": "14.0.0" 38 | } 39 | }, 40 | { 41 | "package": "Nimble", 42 | "repositoryURL": "https://github.com/Quick/Nimble", 43 | "state": { 44 | "branch": null, 45 | "revision": "af1730dde4e6c0d45bf01b99f8a41713ce536790", 46 | "version": "9.2.0" 47 | } 48 | }, 49 | { 50 | "package": "Quick", 51 | "repositoryURL": "https://github.com/Quick/Quick", 52 | "state": { 53 | "branch": null, 54 | "revision": "bd86ca0141e3cfb333546de5a11ede63f0c4a0e6", 55 | "version": "4.0.0" 56 | } 57 | }, 58 | { 59 | "package": "ReactiveSwift", 60 | "repositoryURL": "https://github.com/Moya/ReactiveSwift.git", 61 | "state": { 62 | "branch": null, 63 | "revision": "f195d82bb30e412e70446e2b4a77e1b514099e88", 64 | "version": "6.1.0" 65 | } 66 | }, 67 | { 68 | "package": "ReactorKit", 69 | "repositoryURL": "https://github.com/ReactorKit/ReactorKit", 70 | "state": { 71 | "branch": null, 72 | "revision": "fc392a1dc4c98a496089b8bd091cd92f608fd299", 73 | "version": "2.1.1" 74 | } 75 | }, 76 | { 77 | "package": "Realm", 78 | "repositoryURL": "https://github.com/realm/realm-cocoa", 79 | "state": { 80 | "branch": null, 81 | "revision": "827f9bf97f44e40fda8a750698f3e096734629ee", 82 | "version": "10.8.1" 83 | } 84 | }, 85 | { 86 | "package": "RealmDatabase", 87 | "repositoryURL": "https://github.com/realm/realm-core", 88 | "state": { 89 | "branch": null, 90 | "revision": "d85d071cc25b6f64fabbbebbaaae20f367ef64ae", 91 | "version": "11.0.3" 92 | } 93 | }, 94 | { 95 | "package": "RxExpect", 96 | "repositoryURL": "https://github.com/devxoul/RxExpect.git", 97 | "state": { 98 | "branch": null, 99 | "revision": "c3a3bb3d46ee831582c6619ecc48cda1cdbff890", 100 | "version": "2.0.0" 101 | } 102 | }, 103 | { 104 | "package": "RxSwift", 105 | "repositoryURL": "https://github.com/ReactiveX/RxSwift", 106 | "state": { 107 | "branch": null, 108 | "revision": "254617dd7fae0c45319ba5fbea435bf4d0e15b5d", 109 | "version": "5.1.2" 110 | } 111 | }, 112 | { 113 | "package": "SnapKit", 114 | "repositoryURL": "https://github.com/SnapKit/SnapKit", 115 | "state": { 116 | "branch": null, 117 | "revision": "d458564516e5676af9c70b4f4b2a9178294f1bc6", 118 | "version": "5.0.1" 119 | } 120 | }, 121 | { 122 | "package": "Swinject", 123 | "repositoryURL": "https://github.com/Swinject/Swinject", 124 | "state": { 125 | "branch": null, 126 | "revision": "8a76d2c74bafbb455763487cc6a08e91bad1f78b", 127 | "version": "2.7.1" 128 | } 129 | }, 130 | { 131 | "package": "Then", 132 | "repositoryURL": "https://github.com/devxoul/Then", 133 | "state": { 134 | "branch": null, 135 | "revision": "e421a7b3440a271834337694e6050133a3958bc7", 136 | "version": "2.7.0" 137 | } 138 | }, 139 | { 140 | "package": "WeakMapTable", 141 | "repositoryURL": "https://github.com/ReactorKit/WeakMapTable.git", 142 | "state": { 143 | "branch": null, 144 | "revision": "9580560169b4b48ba2affe7badba6a7f360495f4", 145 | "version": "1.2.0" 146 | } 147 | } 148 | ] 149 | }, 150 | "version": 1 151 | } 152 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 SangJin Han 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Projects/App/.package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "Alamofire", 6 | "repositoryURL": "https://github.com/Alamofire/Alamofire.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "f96b619bcb2383b43d898402283924b80e2c4bae", 10 | "version": "5.4.3" 11 | } 12 | }, 13 | { 14 | "package": "Logger", 15 | "repositoryURL": "https://github.com/shibapm/Logger", 16 | "state": { 17 | "branch": null, 18 | "revision": "53c3ecca5abe8cf46697e33901ee774236d94cce", 19 | "version": "0.2.3" 20 | } 21 | }, 22 | { 23 | "package": "Moya", 24 | "repositoryURL": "https://github.com/Moya/Moya", 25 | "state": { 26 | "branch": null, 27 | "revision": "d27767c3624cc64a45bb371b267b89c92d70c670", 28 | "version": "14.0.1" 29 | } 30 | }, 31 | { 32 | "package": "Nimble", 33 | "repositoryURL": "https://github.com/Quick/Nimble.git", 34 | "state": { 35 | "branch": null, 36 | "revision": "7a46a5fc86cb917f69e3daf79fcb045283d8f008", 37 | "version": "8.1.2" 38 | } 39 | }, 40 | { 41 | "package": "OHHTTPStubs", 42 | "repositoryURL": "https://github.com/AliSoftware/OHHTTPStubs.git", 43 | "state": { 44 | "branch": null, 45 | "revision": "12f19662426d0434d6c330c6974d53e2eb10ecd9", 46 | "version": "9.1.0" 47 | } 48 | }, 49 | { 50 | "package": "PackageConfig", 51 | "repositoryURL": "https://github.com/shibapm/PackageConfig.git", 52 | "state": { 53 | "branch": null, 54 | "revision": "bf90dc69fa0792894b08a0b74cf34029694ae486", 55 | "version": "0.13.0" 56 | } 57 | }, 58 | { 59 | "package": "Quick", 60 | "repositoryURL": "https://github.com/Quick/Quick.git", 61 | "state": { 62 | "branch": null, 63 | "revision": "09b3becb37cb2163919a3842a4c5fa6ec7130792", 64 | "version": "2.2.1" 65 | } 66 | }, 67 | { 68 | "package": "ReactiveSwift", 69 | "repositoryURL": "https://github.com/Moya/ReactiveSwift.git", 70 | "state": { 71 | "branch": null, 72 | "revision": "f195d82bb30e412e70446e2b4a77e1b514099e88", 73 | "version": "6.1.0" 74 | } 75 | }, 76 | { 77 | "package": "ReactorKit", 78 | "repositoryURL": "https://github.com/ReactorKit/ReactorKit", 79 | "state": { 80 | "branch": null, 81 | "revision": "fc392a1dc4c98a496089b8bd091cd92f608fd299", 82 | "version": "2.1.1" 83 | } 84 | }, 85 | { 86 | "package": "Realm", 87 | "repositoryURL": "https://github.com/realm/realm-cocoa", 88 | "state": { 89 | "branch": null, 90 | "revision": "827f9bf97f44e40fda8a750698f3e096734629ee", 91 | "version": "10.8.1" 92 | } 93 | }, 94 | { 95 | "package": "RealmDatabase", 96 | "repositoryURL": "https://github.com/realm/realm-core", 97 | "state": { 98 | "branch": null, 99 | "revision": "d85d071cc25b6f64fabbbebbaaae20f367ef64ae", 100 | "version": "11.0.3" 101 | } 102 | }, 103 | { 104 | "package": "Rocket", 105 | "repositoryURL": "https://github.com/shibapm/Rocket", 106 | "state": { 107 | "branch": null, 108 | "revision": "51a77ce5fa66c42715c14dcc542c01cd7a60fb27", 109 | "version": "1.2.0" 110 | } 111 | }, 112 | { 113 | "package": "RxExpect", 114 | "repositoryURL": "https://github.com/devxoul/RxExpect.git", 115 | "state": { 116 | "branch": null, 117 | "revision": "c3a3bb3d46ee831582c6619ecc48cda1cdbff890", 118 | "version": "2.0.0" 119 | } 120 | }, 121 | { 122 | "package": "RxSwift", 123 | "repositoryURL": "https://github.com/ReactiveX/RxSwift", 124 | "state": { 125 | "branch": null, 126 | "revision": "254617dd7fae0c45319ba5fbea435bf4d0e15b5d", 127 | "version": "5.1.2" 128 | } 129 | }, 130 | { 131 | "package": "SnapKit", 132 | "repositoryURL": "https://github.com/SnapKit/SnapKit", 133 | "state": { 134 | "branch": null, 135 | "revision": "d458564516e5676af9c70b4f4b2a9178294f1bc6", 136 | "version": "5.0.1" 137 | } 138 | }, 139 | { 140 | "package": "SwiftShell", 141 | "repositoryURL": "https://github.com/kareman/SwiftShell", 142 | "state": { 143 | "branch": null, 144 | "revision": "a6014fe94c3dbff0ad500e8da4f251a5d336530b", 145 | "version": "5.1.0-beta.1" 146 | } 147 | }, 148 | { 149 | "package": "Swinject", 150 | "repositoryURL": "https://github.com/Swinject/Swinject", 151 | "state": { 152 | "branch": null, 153 | "revision": "8a76d2c74bafbb455763487cc6a08e91bad1f78b", 154 | "version": "2.7.1" 155 | } 156 | }, 157 | { 158 | "package": "Then", 159 | "repositoryURL": "https://github.com/devxoul/Then", 160 | "state": { 161 | "branch": null, 162 | "revision": "e421a7b3440a271834337694e6050133a3958bc7", 163 | "version": "2.7.0" 164 | } 165 | }, 166 | { 167 | "package": "WeakMapTable", 168 | "repositoryURL": "https://github.com/ReactorKit/WeakMapTable.git", 169 | "state": { 170 | "branch": null, 171 | "revision": "9580560169b4b48ba2affe7badba6a7f360495f4", 172 | "version": "1.2.0" 173 | } 174 | }, 175 | { 176 | "package": "Yams", 177 | "repositoryURL": "https://github.com/jpsim/Yams", 178 | "state": { 179 | "branch": null, 180 | "revision": "9ff1cc9327586db4e0c8f46f064b6a82ec1566fa", 181 | "version": "4.0.6" 182 | } 183 | } 184 | ] 185 | }, 186 | "version": 1 187 | } 188 | -------------------------------------------------------------------------------- /Projects/App/.swiftlint.yml: -------------------------------------------------------------------------------- 1 | disabled_rules: 2 | - file_length # 한 파일당 1000줄 이상일 경우 3 | - identifier_name # 변수명에 _ 등 이 들어갈 경우 4 | - trailing_whitespace # 엔터 쳤을 때 default tab 5 | - unused_closure_parameter # 클로져 안에 안쓰이는 변수가 있을 때 6 | - nesting # enum 안에 enum 쓰고 싶음.. 7 | 8 | included: 9 | - Sources 10 | - ../Features/Sources 11 | - ../Modules/CoreKit/Sources 12 | - ../Modules/DatabaseModule/Sources 13 | - ../Modules/NetworkModule/Sources 14 | - ../Modules/ThirdPartyManager/Sources 15 | - ../Modules/UtilityModule/Sources 16 | 17 | line_length: 120 18 | function_body_length: 100 19 | -------------------------------------------------------------------------------- /Projects/App/Project.swift: -------------------------------------------------------------------------------- 1 | import ProjectDescription 2 | import ProjectDescriptionHelpers 3 | 4 | let actions: [TargetAction] = [.swiftlint] 5 | 6 | let projectName: String = "HaviTemplateApp" 7 | let organization: String = "havi" 8 | 9 | let schemes = [ 10 | Scheme( 11 | name: "\(projectName)-AdHoc", 12 | shared: true, 13 | buildAction: BuildAction(targets: ["\(projectName)"]), 14 | testAction: TestAction( 15 | targets: ["\(projectName)Tests"], 16 | configurationName: "AdHoc", 17 | coverage: true 18 | ), 19 | runAction: RunAction(configurationName: "AdHoc"), 20 | archiveAction: ArchiveAction(configurationName: "AdHoc"), 21 | profileAction: ProfileAction(configurationName: "AdHoc"), 22 | analyzeAction: AnalyzeAction(configurationName: "AdHoc") 23 | ), 24 | Scheme( 25 | name: "\(projectName)-Release", 26 | shared: true, 27 | buildAction: BuildAction(targets: ["\(projectName)"]), 28 | testAction: TestAction( 29 | targets: ["\(projectName)Tests"], 30 | configurationName: "Release", 31 | coverage: true 32 | ), 33 | runAction: RunAction(configurationName: "Release"), 34 | archiveAction: ArchiveAction(configurationName: "Release"), 35 | profileAction: ProfileAction(configurationName: "Release"), 36 | analyzeAction: AnalyzeAction(configurationName: "Release") 37 | ), 38 | ] 39 | 40 | let project = Project.project( 41 | name: projectName, 42 | organizationName: organization, 43 | product: .app, 44 | actions: actions, 45 | dependencies: [ 46 | .features 47 | ], 48 | infoPlist: "Supporting/Info.plist", 49 | schemes: schemes 50 | ) 51 | -------------------------------------------------------------------------------- /Projects/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 | -------------------------------------------------------------------------------- /Projects/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 | -------------------------------------------------------------------------------- /Projects/App/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Projects/App/Resources/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Projects/App/Sources/Application/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // iOSTemplateApp 4 | // 5 | // Created by 한상진 on 2021/06/22. 6 | // 7 | 8 | import UIKit 9 | import Features 10 | 11 | @main 12 | class AppDelegate: UIResponder, UIApplicationDelegate { 13 | 14 | var window: UIWindow? 15 | 16 | func application( 17 | _ application: UIApplication, 18 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? 19 | ) -> Bool { 20 | designateWindowsRootVC() 21 | return true 22 | } 23 | 24 | } 25 | 26 | extension AppDelegate { 27 | /// Window의 초기 뷰컨 설정 28 | private func designateWindowsRootVC() { 29 | // window 초기화 및 설정 30 | let window = UIWindow(frame: UIScreen.main.bounds) 31 | self.window = window 32 | 33 | // LaunchVC에서 앱 초기에 설정해줘야 할 부분 설정 34 | let launchVC = LaunchVC(window: window) 35 | window.rootViewController = launchVC 36 | window.makeKeyAndVisible() 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Projects/App/Supporting/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSContactsUsageDescription 6 | 연락처 접근 권한 필요 7 | NSCameraUsageDescription 8 | 사진 및 동영상 촬영을 위한 카메라 사용 권한 9 | NSPhotoLibraryUsageDescription 10 | 사진 및 동영상 첨부를 위한 앨범 사용 권한 11 | NSPhotoLibraryAddUsageDescription 12 | 앨범에 사진 추가 사용 권한 13 | CFBundleDevelopmentRegion 14 | $(DEVELOPMENT_LANGUAGE) 15 | CFBundleExecutable 16 | $(EXECUTABLE_NAME) 17 | CFBundleIdentifier 18 | $(PRODUCT_BUNDLE_IDENTIFIER) 19 | CFBundleInfoDictionaryVersion 20 | 6.0 21 | CFBundleName 22 | $(PRODUCT_NAME) 23 | CFBundlePackageType 24 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 25 | CFBundleShortVersionString 26 | 1.0 27 | CFBundleVersion 28 | 1 29 | LSRequiresIPhoneOS 30 | 31 | UIApplicationSupportsIndirectInputEvents 32 | 33 | UILaunchStoryboardName 34 | LaunchScreen 35 | UIRequiredDeviceCapabilities 36 | 37 | armv7 38 | 39 | UISupportedInterfaceOrientations 40 | 41 | UIInterfaceOrientationPortrait 42 | UIInterfaceOrientationLandscapeLeft 43 | UIInterfaceOrientationLandscapeRight 44 | 45 | UISupportedInterfaceOrientations~ipad 46 | 47 | UIInterfaceOrientationPortrait 48 | UIInterfaceOrientationPortraitUpsideDown 49 | UIInterfaceOrientationLandscapeLeft 50 | UIInterfaceOrientationLandscapeRight 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /Projects/App/Tests/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/havilog/HaviTemplateApp/810dd1fa20d994784b0fbe7f09dae0048a32d21b/Projects/App/Tests/.gitkeep -------------------------------------------------------------------------------- /Projects/Features/Project.swift: -------------------------------------------------------------------------------- 1 | import ProjectDescription 2 | import ProjectDescriptionHelpers 3 | 4 | let project = Project.framework( 5 | name: "Features", 6 | dependencies: [ 7 | .coreKit 8 | ] 9 | ) 10 | -------------------------------------------------------------------------------- /Projects/Features/Resources/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/havilog/HaviTemplateApp/810dd1fa20d994784b0fbe7f09dae0048a32d21b/Projects/Features/Resources/.gitkeep -------------------------------------------------------------------------------- /Projects/Features/Sources/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/havilog/HaviTemplateApp/810dd1fa20d994784b0fbe7f09dae0048a32d21b/Projects/Features/Sources/.gitkeep -------------------------------------------------------------------------------- /Projects/Features/Sources/Extension/Snapkit+Safe.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Snapkit+Safe.swift 3 | // Features 4 | // 5 | // Created by 한상진 on 2021/07/07. 6 | // Copyright © 2021 softbay. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import ThirdPartyManager 11 | 12 | import SnapKit 13 | 14 | extension UIView { 15 | var safeArea: ConstraintLayoutGuideDSL { 16 | return safeAreaLayoutGuide.snp 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Projects/Features/Sources/Scenes/Initial/LaunchVC.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LaunchVC.swift 3 | // Features 4 | // 5 | // Created by 홍경표 on 2021/07/01. 6 | // Copyright © 2021 softbay. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Logger 11 | 12 | public final class LaunchVC: UIViewController { 13 | private let window: UIWindow 14 | 15 | public init(window: UIWindow) { 16 | self.window = window 17 | super.init(nibName: nil, bundle: nil) 18 | } 19 | 20 | required init?(coder: NSCoder) { 21 | fatalError("init(coder:) has not been implemented") 22 | } 23 | 24 | deinit { 25 | Logger.debug("\(self) deinit") 26 | } 27 | 28 | public override func viewDidLoad() { 29 | super.viewDidLoad() 30 | view.backgroundColor = .systemGreen 31 | } 32 | public override func viewDidAppear(_ animated: Bool) { 33 | super.viewDidAppear(animated) 34 | DispatchQueue.main.async { [weak self] in 35 | // let hasAgreed: Bool = UserDefaults.standard.bool(forKey: "hasAgreed") 36 | let hasAgreed: Bool = true // 이미 약관동의 했다고 가정 37 | if hasAgreed == true { 38 | // 메인 화면 39 | self?.goMain() 40 | } else { 41 | // 약관동의 화면 42 | // self?.goIntro() 43 | } 44 | } 45 | } 46 | 47 | private func goMain() { 48 | let mainVC = MainVC() 49 | window.rootViewController = mainVC 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Projects/Features/Sources/Scenes/Main/MainVC.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainVC.swift 3 | // Features 4 | // 5 | // Created by 한상진 on 2021/08/14. 6 | // Copyright © 2021 havi. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | public final class MainVC: UIViewController { 12 | override public func viewDidLoad() { 13 | super.viewDidLoad() 14 | 15 | view.backgroundColor = .red 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Projects/Features/Tests/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/havilog/HaviTemplateApp/810dd1fa20d994784b0fbe7f09dae0048a32d21b/Projects/Features/Tests/.gitkeep -------------------------------------------------------------------------------- /Projects/Modules/CoreKit/Project.swift: -------------------------------------------------------------------------------- 1 | import ProjectDescription 2 | import ProjectDescriptionHelpers 3 | 4 | let project = Project.framework( 5 | name: "CoreKit", 6 | dependencies: [ 7 | .networkModule, 8 | .databaseModule, 9 | .utilityModule, 10 | .thirdPartyManager, 11 | ] 12 | ) 13 | -------------------------------------------------------------------------------- /Projects/Modules/CoreKit/Resources/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/havilog/HaviTemplateApp/810dd1fa20d994784b0fbe7f09dae0048a32d21b/Projects/Modules/CoreKit/Resources/.gitkeep -------------------------------------------------------------------------------- /Projects/Modules/CoreKit/Sources/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/havilog/HaviTemplateApp/810dd1fa20d994784b0fbe7f09dae0048a32d21b/Projects/Modules/CoreKit/Sources/.gitkeep -------------------------------------------------------------------------------- /Projects/Modules/CoreKit/Sources/API/NetworkConstant.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NetworkConstant.swift 3 | // CoreKit 4 | // 5 | // Created by 한상진 on 2021/07/27. 6 | // Copyright © 2021 softbay. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum BaseURLs { 12 | } 13 | 14 | enum APIKeys { 15 | } 16 | 17 | enum HTTPHeaderFields { 18 | static let authorization: String = "Authorization" 19 | static let acceptType: String = "Accept" 20 | static let acceptEncoding: String = "Accept-Encoding" 21 | static let contentType: String = "Content-Type" 22 | static let json: String = "application/json" 23 | } 24 | -------------------------------------------------------------------------------- /Projects/Modules/CoreKit/Sources/Common/Preferences.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Preferences.swift 3 | // CoreKit 4 | // 5 | // Created by 한상진 on 2021/07/21. 6 | // Copyright © 2021 softbay. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public struct Preferences { 12 | /// sample 13 | @ValueProperty(uniqueKey: "isLoggedIn", defaultValue: false) 14 | static var isLoggedIn: Bool 15 | } 16 | 17 | // MARK: Protocol 18 | 19 | /// ValueProperty의 경우 optional일 경우 set newvalue에서 앱이 죽을 수 있으므로 20 | /// optional로 캐스팅 한 뒤에 nil일 경우 removeObject를 해줘야한다. 21 | protocol OptionalType { 22 | func isNil() -> Bool 23 | } 24 | 25 | extension Optional: OptionalType { 26 | func isNil() -> Bool { return self == nil } 27 | } 28 | 29 | /// 공통으로 쓰이는 타입 정의 30 | class UserDefaultStorage { 31 | let uniqueKey: String 32 | let defaultValue: T 33 | 34 | init(uniqueKey: String, defaultValue: T) { 35 | self.uniqueKey = uniqueKey 36 | self.defaultValue = defaultValue 37 | } 38 | } 39 | 40 | /// Codable한 모델을 UserDefault에 저장하기 위한 객체 41 | @propertyWrapper 42 | final class CodableProperty: UserDefaultStorage { 43 | var projectedValue: CodableProperty { return self } 44 | var wrappedValue: T { 45 | get { 46 | guard let data = UserDefaults.standard.object(forKey: uniqueKey) as? Data, 47 | let decodedData = try? PropertyListDecoder().decode(T.self, from: data) 48 | else { return defaultValue } 49 | 50 | return decodedData 51 | } 52 | 53 | set { 54 | let data = try? PropertyListEncoder().encode(newValue) 55 | UserDefaults.standard.set(data, forKey: uniqueKey) 56 | } 57 | } 58 | } 59 | 60 | /// String, Int등의 기본적인 value들을 저장하기 위한 객체 61 | @propertyWrapper 62 | final class ValueProperty: UserDefaultStorage { 63 | var projectedValue: ValueProperty { return self } 64 | var wrappedValue: T { 65 | get { 66 | UserDefaults.standard.value(forKey: uniqueKey) as? T ?? defaultValue 67 | } 68 | 69 | set { 70 | if let value = newValue as? OptionalType, value.isNil() { 71 | UserDefaults.standard.removeObject(forKey: uniqueKey) 72 | } else { 73 | UserDefaults.standard.set(newValue, forKey: uniqueKey) 74 | } 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Projects/Modules/CoreKit/Tests/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/havilog/HaviTemplateApp/810dd1fa20d994784b0fbe7f09dae0048a32d21b/Projects/Modules/CoreKit/Tests/.gitkeep -------------------------------------------------------------------------------- /Projects/Modules/DatabaseModule/Project.swift: -------------------------------------------------------------------------------- 1 | import ProjectDescription 2 | import ProjectDescriptionHelpers 3 | 4 | let project = Project.framework( 5 | name: "DatabaseModule", 6 | dependencies: [ 7 | .thirdPartyManager 8 | ] 9 | ) 10 | 11 | -------------------------------------------------------------------------------- /Projects/Modules/DatabaseModule/Resources/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/havilog/HaviTemplateApp/810dd1fa20d994784b0fbe7f09dae0048a32d21b/Projects/Modules/DatabaseModule/Resources/.gitkeep -------------------------------------------------------------------------------- /Projects/Modules/DatabaseModule/Sources/BaseRealm.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RealmWrapper.swift 3 | // DatabaseModule 4 | // 5 | // Created by 한상진 on 2021/07/02. 6 | // Copyright © 2021 softbay. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import ThirdPartyManager 11 | 12 | import RealmSwift 13 | 14 | // MARK: Sample 15 | 16 | class SampleClass: Object, PropertyRepresentable { 17 | enum RealmProperty: String { 18 | case firstName 19 | case lastName 20 | case fullName 21 | case someAddedProperty 22 | } 23 | 24 | /// String, Date 는 String? = nil로 옵셔널 선언이 가능하지만, 25 | /// Int, Double 같은 아이들은 26 | /// let age = RealmOptional() 27 | /// 와 같이 생성해야 한다. 28 | 29 | @objc dynamic var firstName: String = "" // version 0 30 | @objc dynamic var lastName: String = "" // version 0 31 | @objc dynamic var fullName: String = "" // added at version 1 32 | @objc dynamic var someAddedProperty: String = "" // added at version 2 33 | 34 | override static func primaryKey() -> String? { return RealmProperty.fullName.rawValue } 35 | } 36 | 37 | // MARK: BaseRealm 38 | 39 | private let schemaVersion: UInt64 = 0 40 | 41 | /// BaseRealm은 RealmObjectList의 property로 한 번만 초기화 된다. 42 | /// migration block에서 마이그레이션은 보통 AppDelegate에서 실행되어야 한다. 43 | @propertyWrapper 44 | public struct BaseRealm { 45 | private let testRealm: Realm? 46 | 47 | public var wrappedValue: DatabaseRepositoryType { 48 | return DatabaseRepository(realm: testRealm == nil ? realm : testRealm!) 49 | } 50 | 51 | private var realm: Realm { 52 | // swiftlint:disable force_try 53 | return try! Realm(configuration: realmConfiguration) 54 | // swiftlint:enable force_try 55 | } 56 | 57 | private var realmConfiguration: Realm.Configuration { 58 | // Test를 위한 migration block이 있으면 Test를 넣어줌 59 | return .init( 60 | schemaVersion: schemaVersion, 61 | migrationBlock: migrationBlock 62 | ) 63 | } 64 | 65 | private var migrationBlock: MigrationBlock { 66 | /// migration block 사용법 sample 67 | let migrationBlock: MigrationBlock = { migration, oldSchemaVersion in 68 | migration.enumerateObjects(ofType: SampleClass.className()) { oldObject, newObject in 69 | // swiftlint:disable force_cast 70 | if oldSchemaVersion < 1 { 71 | let firstName = oldObject!["firstName"] as! String 72 | let lastName = oldObject!["lastName"] as! String 73 | newObject!["fullName"] = "\(firstName)\(lastName)" 74 | } 75 | // swiftlint:enable force_cast 76 | 77 | if oldSchemaVersion < 2 { 78 | newObject!["someAddedProperty"] = "" 79 | } 80 | } 81 | } 82 | 83 | return migrationBlock 84 | } 85 | 86 | public init( 87 | testRealm: Realm? = nil 88 | ) { 89 | self.testRealm = testRealm 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Projects/Modules/DatabaseModule/Sources/DatabaseLogger.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DatabaseLogger.swift 3 | // DatabaseModule 4 | // 5 | // Created by 한상진 on 2021/08/11. 6 | // Copyright © 2021 softbay. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct DatabaseLogger { 12 | private enum Level: String { 13 | case fetch = "💡 FETCH" 14 | case debug = "💬 DEBUG" 15 | case write = "💎 WRITE" 16 | case delete = "❌ DELETE" 17 | case fatal = "🔥 FATAL" 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 fetch(_ items: Any...) { 36 | let output = toOutput(with: items) 37 | log(level: .fetch, message: output) 38 | } 39 | 40 | static func write(_ items: Any...) { 41 | let output = toOutput(with: items) 42 | log(level: .write, message: output) 43 | } 44 | 45 | static func delete(_ items: Any...) { 46 | let output = toOutput(with: items) 47 | log(level: .delete, message: output) 48 | } 49 | 50 | static func fatal(_ items: Any...) { 51 | let output = toOutput(with: items) 52 | log(level: .fatal, 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 | -------------------------------------------------------------------------------- /Projects/Modules/DatabaseModule/Sources/DatabaseRepository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DatabaseRepository.swift 3 | // DatabaseModule 4 | // 5 | // Created by 한상진 on 2021/07/01. 6 | // Copyright © 2021 softbay. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import ThirdPartyManager 11 | 12 | import RealmSwift 13 | 14 | // MARK: Protocol 15 | 16 | /// Object타입의 property를 접근할 때에는 write transaction안에서 접근해야한다. 17 | /// ex) Pizza: Object 18 | /// let pizza: Pizza = Pizza(name: "cheese") 19 | /// realm.add(pizza) 20 | /// let test = realm.objects(Pizza.self).first 21 | /// test.update { 22 | /// $0.name = "potato" 23 | /// } 24 | /// 이런식으로 update함수를 써서 프로퍼티를 변경해야한다. 25 | public protocol ObjectPropertyUpdatable { } 26 | extension ObjectPropertyUpdatable where Self: Object { 27 | public func update(_ block: (Self) throws -> Void) rethrows { 28 | try? self.realm?.safeWrite { 29 | try? block(self) 30 | } 31 | } 32 | } 33 | extension Object: ObjectPropertyUpdatable { } 34 | 35 | /// Object class 안에 Enum으로 RealmProperty를 정의하여 36 | /// "name" 과 같은 string literal을 37 | /// ClassName.RealmProperty.rawvalue로 쓸 수 있게 하기 위해 정의 38 | public protocol PropertyRepresentable { 39 | associatedtype RealmProperty 40 | } 41 | 42 | public protocol DatabaseRepositoryType: AnyObject { 43 | // 외부에서 실제 realm에 직접 접근하는 경우가 필요할 경우 필요 44 | // var realm: Realm { get } 45 | 46 | func fetchObjects( 47 | for type: T.Type, 48 | filter: QueryFilter?, 49 | sortProperty: String?, 50 | ordering: OrderingType 51 | ) -> [T] 52 | func fetchObjectsResults( 53 | for type: T.Type, 54 | filter: QueryFilter?, 55 | sortProperty: String?, 56 | ordering: OrderingType 57 | ) -> Results 58 | 59 | func add(_ object: Object?) 60 | func set(_ object: Object?) 61 | func set(_ objects: [Object]?) 62 | func delete(_ object: Object?) 63 | func delete(_ objects: [Object]?) 64 | } 65 | 66 | extension DatabaseRepositoryType { 67 | // default value를 주기 위해 사용 68 | public func fetchObjects( 69 | for type: T.Type, 70 | filter: QueryFilter? = nil, 71 | sortProperty: String? = nil, 72 | ordering: OrderingType = .ascending 73 | ) -> [T] { 74 | return fetchObjects(for: type, filter: filter, sortProperty: sortProperty, ordering: ordering) 75 | } 76 | 77 | public func fetchObjectsResults( 78 | for type: T.Type, 79 | filter: QueryFilter? = nil, 80 | sortProperty: String? = nil, 81 | ordering: OrderingType = .ascending 82 | ) -> Results { 83 | return fetchObjectsResults(for: type, filter: filter, sortProperty: sortProperty, ordering: ordering) 84 | } 85 | } 86 | 87 | // MARK: Constant 88 | 89 | public enum QueryFilter { 90 | case string(query: String) 91 | case predicate(query: NSPredicate) 92 | } 93 | 94 | public enum OrderingType { 95 | case ascending 96 | case descending 97 | } 98 | 99 | public final class DatabaseRepository: DatabaseRepositoryType { 100 | 101 | private let realm: Realm 102 | 103 | // MARK: Init 104 | 105 | public init(config: Realm.Configuration) { 106 | do { 107 | self.realm = try Realm(configuration: config) 108 | } catch { 109 | DatabaseLogger.fatal("Realm config failed") 110 | fatalError() 111 | } 112 | } 113 | 114 | public init(realm: Realm) { 115 | self.realm = realm 116 | } 117 | 118 | // MARK: Query 119 | 120 | /// Results가 아닌 [T]를 반환하는 이유는 121 | /// Results는 Realm에 종속적인 타입이기 때문에 122 | /// 상위 모듈에서 Realm을 import해주지 않기 위해서 123 | /// 필요한 query나 sortedKey를 인자로 받아서 124 | /// DatabaseRepository에서 필요한 쿼리를 수행 후 반환. 125 | /// - Parameters: 126 | /// - type: 가져올 타입 127 | /// - filter: String or NSPredicate 128 | /// - sortProperty: 소팅이 필요할 경우 키값 129 | /// - ordering: 오름차 순 / 내림차 순 130 | public func fetchObjects( 131 | for type: T.Type, 132 | filter: QueryFilter? = nil, 133 | sortProperty: String? = nil, 134 | ordering: OrderingType = .ascending 135 | ) -> [T] { 136 | fetchObjectsResults(for: T.self, filter: filter, sortProperty: sortProperty, ordering: ordering).toArray() 137 | } 138 | 139 | public func fetchObjectsResults( 140 | for type: T.Type, 141 | filter: QueryFilter? = nil, 142 | sortProperty: String? = nil, 143 | ordering: OrderingType = .ascending 144 | ) -> Results { 145 | var results = realm.objects(T.self) 146 | 147 | if let filter = filter { 148 | switch filter { 149 | case let .predicate(query): 150 | results = results.filter(query) 151 | case let .string(query): 152 | results = results.filter(query) 153 | } 154 | } 155 | 156 | if let sortProperty = sortProperty { 157 | results = results.sorted(byKeyPath: sortProperty, ascending: ordering == .ascending) 158 | } 159 | 160 | DatabaseLogger.fetch(results) 161 | 162 | return results 163 | } 164 | 165 | // MARK: CRUD 166 | 167 | public func add(_ object: Object?) { 168 | guard let object = object else { return } 169 | 170 | try? realm.safeWrite { 171 | realm.add(object) 172 | } 173 | 174 | DatabaseLogger.write(object) 175 | } 176 | 177 | public func set(_ object: Object?) { 178 | guard let object = object else { return } 179 | 180 | try? realm.safeWrite { 181 | realm.add(object, update: .all) 182 | } 183 | 184 | DatabaseLogger.write(object) 185 | } 186 | 187 | public func set(_ objects: [Object]?) { 188 | guard let objects = objects else { return } 189 | 190 | try? realm.safeWrite { 191 | realm.add(objects, update: .all) 192 | } 193 | 194 | DatabaseLogger.write(objects) 195 | } 196 | 197 | public func delete(_ object: Object?) { 198 | guard let object = object else { return } 199 | 200 | try? realm.safeWrite { 201 | realm.delete(object) 202 | } 203 | 204 | DatabaseLogger.delete(object) 205 | } 206 | 207 | public func delete(_ objects: [Object]?) { 208 | guard let objects = objects else { return } 209 | 210 | try? realm.safeWrite { 211 | realm.delete(objects) 212 | } 213 | 214 | DatabaseLogger.delete(objects) 215 | } 216 | 217 | public func deleteAll() { 218 | try? realm.safeWrite { 219 | realm.deleteAll() 220 | } 221 | 222 | DatabaseLogger.delete("all database deleted") 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /Projects/Modules/DatabaseModule/Sources/Extension/Realm+SafeWrite.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Realm+SafeWrite.swift 3 | // DatabaseModule 4 | // 5 | // Created by 한상진 on 2021/07/05. 6 | // Copyright © 2021 softbay. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import ThirdPartyManager 11 | 12 | import RealmSwift 13 | 14 | extension Realm { 15 | public func safeWrite(_ block: (() throws -> Void)) throws { 16 | if isInWriteTransaction { 17 | try block() 18 | } else { 19 | try write(block) 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Projects/Modules/DatabaseModule/Sources/Extension/Realm+Utility.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Realm+Utility.swift 3 | // DatabaseModule 4 | // 5 | // Created by 한상진 on 2021/07/02. 6 | // Copyright © 2021 softbay. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import ThirdPartyManager 11 | 12 | import RealmSwift 13 | 14 | /// detached extension은 mobile fax에 있는 extension 로직을 가져온 것으로, 15 | /// 현재 맨 아래 있는 toArray를 제외한 extension은 사용하지 않는다. 16 | protocol DetachableObject: AnyObject { 17 | func detached() -> Self 18 | } 19 | 20 | extension Object: DetachableObject { 21 | 22 | func detached() -> Self { 23 | let detached = type(of: self).init() 24 | for property in objectSchema.properties { 25 | guard let value = value(forKey: property.name) else { continue } 26 | 27 | if property.isArray == true { 28 | // Realm List property support 29 | let detachable = value as? DetachableObject 30 | detached.setValue(detachable?.detached(), forKey: property.name) 31 | } else if property.type == .object { 32 | // Realm Object property support 33 | let detachable = value as? DetachableObject 34 | detached.setValue(detachable?.detached(), forKey: property.name) 35 | } else { 36 | detached.setValue(value, forKey: property.name) 37 | } 38 | } 39 | return detached 40 | } 41 | } 42 | 43 | extension List: DetachableObject { 44 | func detached() -> List { 45 | let result = List() 46 | 47 | forEach { 48 | if let detachable = $0 as? DetachableObject { 49 | if let detached = detachable.detached() as? Element { 50 | result.append(detached) 51 | } 52 | } else { 53 | result.append($0) // Primtives are pass by value; don't need to recreate 54 | } 55 | } 56 | 57 | return result 58 | } 59 | 60 | func toArray() -> [Element] { 61 | return Array(self.detached()) 62 | } 63 | } 64 | 65 | extension Results { 66 | public func toArray() -> [Element] { 67 | return Array(self) 68 | } 69 | 70 | public func toArray(type: T.Type) -> [T] { 71 | return compactMap { $0 as? T } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Projects/Modules/DatabaseModule/Tests/DatabaseRepositoryTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DatabaseRepositoryTests.swift 3 | // DatabaseModuleTests 4 | // 5 | // Created by 한상진 on 2021/07/01. 6 | // Copyright © 2021 softbay. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | @testable import DatabaseModule 12 | @testable import ThirdPartyManager 13 | 14 | import Realm 15 | import RealmSwift 16 | 17 | class Ingredient: Object, PropertyRepresentable { 18 | enum RealmProperty: String { 19 | case cheese 20 | case something 21 | } 22 | 23 | @objc public dynamic var cheese: String = "" 24 | @objc public dynamic var something: String = "" 25 | 26 | override static func primaryKey() -> String? { return RealmProperty.something.rawValue } 27 | } 28 | 29 | class Pizza: Object, PropertyRepresentable { 30 | enum RealmProperty: String { 31 | case name 32 | case orderNumber 33 | } 34 | 35 | @objc public dynamic var name: String = "" 36 | @objc public dynamic var orderNumber: Int = 0 37 | public var ingredients: List = .init() 38 | 39 | override static func primaryKey() -> String? { return RealmProperty.name.rawValue } 40 | 41 | convenience init(name: String) { 42 | self.init() 43 | 44 | self.name = name 45 | } 46 | 47 | convenience init( 48 | name: String, 49 | orderNumber: Int 50 | ) { 51 | self.init() 52 | 53 | self.name = name 54 | self.orderNumber = orderNumber 55 | } 56 | } 57 | 58 | class DatabaseRepositoryTests: XCTestCase { 59 | var sut: DatabaseRepository! 60 | var realm: Realm! 61 | 62 | override func setUp() { 63 | // This ensures that each test can't accidentally access or modify the data 64 | let config: Realm.Configuration = .init(inMemoryIdentifier: self.name) 65 | realm = try! Realm(configuration: config) 66 | sut = DatabaseRepository(realm: realm) 67 | } 68 | 69 | override func tearDown() { 70 | try? realm.write { 71 | realm.deleteAll() 72 | } 73 | } 74 | 75 | func test_Fetch_ResultsArray_From_Realm() { 76 | XCTAssertTrue(realm.isEmpty) 77 | 78 | let ing1 = Ingredient() 79 | ing1.something = "111" 80 | 81 | let ing2 = Ingredient() 82 | ing2.something = "222" 83 | 84 | let ings: List = .init() 85 | ings.append(ing1) 86 | ings.append(ing2) 87 | 88 | let cheesePizza = Pizza() 89 | cheesePizza.name = "BBBCheese" 90 | cheesePizza.ingredients = ings 91 | 92 | let potatoPizza = Pizza() 93 | potatoPizza.name = "PPPPotato" 94 | potatoPizza.ingredients = ings 95 | 96 | try? realm.write { 97 | realm.add(cheesePizza) 98 | realm.add(potatoPizza) 99 | } 100 | 101 | let result = sut.fetchObjects(for: Pizza.self) 102 | 103 | XCTAssertEqual(result, [cheesePizza, potatoPizza]) 104 | XCTAssertNotEqual(result, [potatoPizza, cheesePizza]) 105 | } 106 | 107 | func test_fetch_resultsArray_with_filter() { 108 | XCTAssertTrue(realm.isEmpty) 109 | 110 | let p1 = Pizza(name: "p1") 111 | let p2 = Pizza(name: "p2") 112 | let p3 = Pizza(name: "p3") 113 | 114 | try? realm.write { 115 | realm.add(p1) 116 | realm.add(p2) 117 | realm.add(p3) 118 | } 119 | 120 | XCTAssertEqual(realm.objects(Pizza.self).count, 3) 121 | 122 | let result = sut.fetchObjects(for: Pizza.self, filter: .string(query: "\(Pizza.RealmProperty.name) == 'p1'")) 123 | 124 | XCTAssertEqual(result, [p1]) 125 | 126 | XCTAssertEqual(result.first, p1) 127 | } 128 | 129 | func test_fetch_resultsArray_with_sortKey() { 130 | XCTAssertTrue(realm.isEmpty) 131 | 132 | let p3 = Pizza(name: "p3", orderNumber: 2) 133 | let p2 = Pizza(name: "p2", orderNumber: 1) 134 | let p1 = Pizza(name: "p1", orderNumber: 0) 135 | 136 | try? realm.write { 137 | realm.add(p3) 138 | realm.add(p2) 139 | realm.add(p1) 140 | } 141 | 142 | XCTAssertEqual(realm.objects(Pizza.self).count, 3) 143 | 144 | var result = sut.fetchObjects(for: Pizza.self, sortProperty: "orderNumber") 145 | 146 | XCTAssertEqual(result, [p1, p2, p3]) 147 | 148 | result = sut.fetchObjects(for: Pizza.self, sortProperty: "orderNumber", ordering: .descending) 149 | 150 | XCTAssertEqual(result, [p3, p2, p1]) 151 | } 152 | 153 | func test_Add_TestObject_To_Realm() { 154 | XCTAssertTrue(realm.isEmpty) 155 | 156 | XCTAssertEqual(realm.objects(Pizza.self).count, 0) 157 | 158 | let margherita = Pizza() 159 | margherita.name = "Margherita" 160 | sut.add(margherita) 161 | 162 | XCTAssertEqual(realm.objects(Pizza.self).count, 1) 163 | 164 | if let first = realm.objects(Pizza.self).first { 165 | XCTAssertEqual(first.name, "Margherita") 166 | } 167 | } 168 | 169 | func test_Set_Object_To_Realm() { 170 | XCTAssertTrue(realm.isEmpty) 171 | 172 | let p1 = Pizza(name: "p1", orderNumber: 0) 173 | 174 | try? realm.write { 175 | realm.add(p1) 176 | } 177 | 178 | XCTAssertEqual(realm.objects(Pizza.self).count, 1) 179 | 180 | let changePizza = Pizza(name: "p1", orderNumber: 1) 181 | 182 | sut.set(changePizza) 183 | 184 | XCTAssertEqual(realm.objects(Pizza.self).first, changePizza) 185 | } 186 | 187 | func test_Delete_Object_From_Realm() { 188 | XCTAssertTrue(realm.isEmpty) 189 | 190 | let p1 = Pizza(name: "p1", orderNumber: 0) 191 | 192 | try? realm.write { 193 | realm.add(p1) 194 | } 195 | 196 | XCTAssertEqual(realm.objects(Pizza.self).count, 1) 197 | XCTAssertEqual(realm.objects(Pizza.self).first, p1) 198 | 199 | sut.delete(p1) 200 | 201 | XCTAssertEqual(realm.objects(Pizza.self).count, 0) 202 | XCTAssertTrue(realm.isEmpty) 203 | } 204 | 205 | func test_Change_Obejcts_Property() { 206 | XCTAssertTrue(realm.isEmpty) 207 | 208 | let p1 = Pizza(name: "p1", orderNumber: 0) 209 | 210 | try? realm.write { 211 | realm.add(p1) 212 | } 213 | 214 | let pizza = realm.objects(Pizza.self).first! 215 | 216 | pizza.update { 217 | $0.orderNumber = 9 218 | } 219 | 220 | XCTAssertEqual(realm.objects(Pizza.self).first!.orderNumber, 9) 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /Projects/Modules/DatabaseModule/Tests/RealmWrapperTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BaseRealmTests.swift 3 | // DatabaseModuleTests 4 | // 5 | // Created by 한상진 on 2021/07/06. 6 | // Copyright © 2021 softbay. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | @testable import DatabaseModule 12 | @testable import ThirdPartyManager 13 | 14 | import Realm 15 | import RealmSwift 16 | 17 | class RealmWrapperTests: XCTestCase { 18 | 19 | var sut: BaseRealm! 20 | var realm: Realm! 21 | 22 | override func setUpWithError() throws { 23 | realm = try! Realm( 24 | configuration: .init( 25 | inMemoryIdentifier: self.name, 26 | schemaVersion: 10000, 27 | migrationBlock: { migration, oldSchemaVersion in 28 | migration.enumerateObjects(ofType: Pizza.className()) { oldObject, newObject in 29 | if oldSchemaVersion < 10000 { 30 | let name = oldObject!["name"] as! String 31 | let orderNumber = oldObject!["orderNumber"] as! Int 32 | newObject!["nameOrder"] = "\(name)\(orderNumber)" 33 | } 34 | } 35 | } 36 | ) 37 | ) 38 | 39 | sut = BaseRealm(testRealm: realm) 40 | } 41 | 42 | override func tearDownWithError() throws { 43 | try? realm.write { 44 | realm.deleteAll() 45 | } 46 | 47 | realm = nil 48 | sut = nil 49 | } 50 | 51 | func test_BaseRealm_WrappedObject_fetchObjects() { 52 | let p1 = Pizza(name: "test1", orderNumber: 1) 53 | let p2 = Pizza(name: "test2", orderNumber: 2) 54 | let p3 = Pizza(name: "test3", orderNumber: 3) 55 | 56 | try? realm.write { 57 | realm.add(p1) 58 | realm.add(p3) 59 | realm.add(p2) 60 | } 61 | 62 | XCTAssertEqual(sut.wrappedValue.fetchObjects(for: Pizza.self), [p1, p3, p2]) 63 | 64 | try? realm.write { 65 | realm.delete(p2) 66 | } 67 | 68 | XCTAssertEqual(sut.wrappedValue.fetchObjects(for: Pizza.self), [p1, p3]) 69 | } 70 | 71 | func test_BaseRealm_WrappedObject_add_set_delete() { 72 | let p1 = Pizza(name: "test1", orderNumber: 1) 73 | let p2 = Pizza(name: "test2", orderNumber: 2) 74 | let p3 = Pizza(name: "test3", orderNumber: 3) 75 | 76 | sut.wrappedValue.add(p1) 77 | 78 | XCTAssertEqual(realm.objects(Pizza.self).first, p1) 79 | 80 | sut.wrappedValue.set([p3, p2]) 81 | 82 | XCTAssertEqual(realm.objects(Pizza.self).toArray(), [p1, p3, p2]) 83 | 84 | sut.wrappedValue.delete(p3) 85 | 86 | XCTAssertEqual(realm.objects(Pizza.self).toArray(), [p1, p2]) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /Projects/Modules/NetworkModule/Project.swift: -------------------------------------------------------------------------------- 1 | import ProjectDescription 2 | import ProjectDescriptionHelpers 3 | 4 | let project = Project.framework( 5 | name: "NetworkModule", 6 | dependencies: [ 7 | .thirdPartyManager 8 | ] 9 | ) 10 | 11 | -------------------------------------------------------------------------------- /Projects/Modules/NetworkModule/Resources/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/havilog/HaviTemplateApp/810dd1fa20d994784b0fbe7f09dae0048a32d21b/Projects/Modules/NetworkModule/Resources/.gitkeep -------------------------------------------------------------------------------- /Projects/Modules/NetworkModule/Sources/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/havilog/HaviTemplateApp/810dd1fa20d994784b0fbe7f09dae0048a32d21b/Projects/Modules/NetworkModule/Sources/.gitkeep -------------------------------------------------------------------------------- /Projects/Modules/NetworkModule/Sources/NetworkLogger.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NetworkLogger.swift 3 | // NetworkModule 4 | // 5 | // Created by 한상진 on 2021/08/11. 6 | // Copyright © 2021 softbay. 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 | -------------------------------------------------------------------------------- /Projects/Modules/NetworkModule/Sources/NetworkRepository.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Moya 3 | import RxMoya 4 | import RxSwift 5 | 6 | public protocol NetworkRepositoryType { 7 | // 제네릭하게 API를 받기 위한 associatedtype 8 | associatedtype EndpointType: TargetType 9 | 10 | // Test 작성을 위한 initializer 11 | init( 12 | isStub: Bool, 13 | sampleStatusCode: Int, 14 | customEndpointClosure: ((EndpointType) -> Endpoint)?, 15 | printLogs: Bool 16 | ) 17 | 18 | // 구현부 19 | func request(endpoint: EndpointType, for type: Model.Type) -> Single 20 | func download(endpoint: EndpointType) -> Observable 21 | } 22 | 23 | public extension NetworkRepositoryType { 24 | static func consProvider( 25 | _ isStub: Bool = false, 26 | _ sampleStatusCode: Int = 200, 27 | _ customEndpointClosure: ((EndpointType) -> Endpoint)? = nil, 28 | _ printLogs: Bool = false 29 | ) -> MoyaProvider { 30 | if isStub == false { 31 | return MoyaProvider(plugins: printLogs ? [NetworkLoggerPlugin()] : []) 32 | } else { 33 | // 테스트 시에 호출되는 stub 클로져 34 | let endPointClosure = { (target: EndpointType) -> Endpoint in 35 | let sampleResponseClosure: () -> EndpointSampleResponse = { 36 | EndpointSampleResponse.networkResponse(sampleStatusCode, target.sampleData) 37 | } 38 | 39 | return Endpoint( 40 | url: URL(target: target).absoluteString, 41 | sampleResponseClosure: sampleResponseClosure, 42 | method: target.method, 43 | task: target.task, 44 | httpHeaderFields: target.headers 45 | ) 46 | } 47 | return MoyaProvider( 48 | endpointClosure: customEndpointClosure ?? endPointClosure, 49 | stubClosure: MoyaProvider.immediatelyStub, 50 | plugins: printLogs ? [NetworkLoggerPlugin()] : [] 51 | ) 52 | } 53 | } 54 | } 55 | 56 | public final class NetworkRepository: NetworkRepositoryType { 57 | 58 | private let provider: MoyaProvider 59 | 60 | public init( 61 | isStub: Bool = false, 62 | sampleStatusCode: Int = 200, 63 | customEndpointClosure: ((EndpointType) -> Endpoint)? = nil, 64 | printLogs: Bool = false 65 | ) { 66 | self.provider = Self.consProvider(isStub, sampleStatusCode, customEndpointClosure, printLogs) 67 | } 68 | 69 | public func request( 70 | endpoint: EndpointType, 71 | for type: Model.Type 72 | ) -> Single { 73 | let requestString = "\(endpoint.method.rawValue) \(endpoint.path)" 74 | 75 | return provider.rx.request(endpoint) 76 | .filterSuccessfulStatusCodes() 77 | .do( 78 | onSuccess: { value in 79 | NetworkLogger.success(requestString, value.statusCode) 80 | }, 81 | onError: { error in 82 | if let response = (error as? MoyaError)?.response { 83 | if let jsonObject = try? response.mapJSON(failsOnEmptyData: false) { 84 | NetworkLogger.error(requestString, response.statusCode, jsonObject) 85 | } else if let rawString = String(data: response.data, encoding: .utf8) { 86 | NetworkLogger.error(requestString, response.statusCode, rawString) 87 | } else { 88 | NetworkLogger.error(requestString, response.statusCode) 89 | } 90 | } else { 91 | NetworkLogger.error(requestString, error, error.localizedDescription) 92 | } 93 | }, 94 | onSubscribed: { 95 | NetworkLogger.request(requestString) 96 | } 97 | ) 98 | .map(Model.self) 99 | } 100 | 101 | public func download(endpoint: EndpointType) -> Observable { 102 | let requestString = "\(endpoint.method.rawValue) \(endpoint.path)" 103 | 104 | return provider.rx.requestWithProgress(endpoint) 105 | .do( 106 | onNext: { 107 | NetworkLogger.info("completedUnitCount", $0.progressObject?.completedUnitCount ?? 0) 108 | }, 109 | onError: { error in 110 | NetworkLogger.error(error, error.localizedDescription) 111 | }, 112 | onSubscribed: { 113 | NetworkLogger.request(requestString) 114 | } 115 | ) 116 | .filter { $0.completed } 117 | .do(onNext: { _ in 118 | NetworkLogger.success("download complete") 119 | }) 120 | .map { _ in } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /Projects/Modules/NetworkModule/Tests/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/havilog/HaviTemplateApp/810dd1fa20d994784b0fbe7f09dae0048a32d21b/Projects/Modules/NetworkModule/Tests/.gitkeep -------------------------------------------------------------------------------- /Projects/Modules/NetworkModule/Tests/MockTodosAPI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockTodosAPI.swift 3 | // NetworkModule 4 | // 5 | // Created by 홍경표 on 2021/07/01. 6 | // Copyright © 2021 softbay. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | import Moya 12 | 13 | struct MockTodo: Codable, Equatable { 14 | var userId: Int 15 | var id: Int 16 | var title: String 17 | var completed: Bool? 18 | } 19 | 20 | enum MockTodosAPI: TargetType { 21 | case getTodo(id: Int) 22 | case getTodos(id: Int? = nil, userId: Int? = nil, title: String? = nil, completed: Bool? = nil) 23 | case create(MockTodo) 24 | case updatePut(todo: MockTodo) 25 | case updatePatch(id: Int, title: String? = nil, completed: Bool? = nil) 26 | case delete(id: Int) 27 | 28 | var baseURL: URL { 29 | return URL(string: "https://jsonplaceholder.typicode.com/todos")! 30 | } 31 | 32 | var path: String { 33 | switch self { 34 | case let .getTodo(id): 35 | return "\(id)" 36 | 37 | case .getTodos: 38 | return "" 39 | 40 | case .create: 41 | return "" 42 | 43 | case let .updatePut(todo): 44 | return "\(todo.id)" 45 | 46 | case let .updatePatch(id, _, _): 47 | return "\(id)" 48 | 49 | case let .delete(id): 50 | return "\(id)" 51 | 52 | } 53 | } 54 | 55 | var method: Moya.Method { 56 | switch self { 57 | case .getTodo: return .get 58 | case .getTodos: return .get 59 | case .create: return .post 60 | case .updatePut: return .put 61 | case .updatePatch: return .patch 62 | case .delete: return .delete 63 | } 64 | } 65 | 66 | var sampleData: Data { 67 | switch self { 68 | case .getTodo: 69 | return Data( 70 | """ 71 | { 72 | "userId": 1, 73 | "id": 3, 74 | "title": "fugiat veniam minus", 75 | "completed": false 76 | } 77 | """.utf8 78 | ) 79 | 80 | case .getTodos: 81 | return Data( 82 | """ 83 | [ 84 | { 85 | "userId": 6, 86 | "id": 105, 87 | "title": "totam quia dolorem et illum repellat voluptas optio", 88 | "completed": true 89 | }, 90 | { 91 | "userId": 6, 92 | "id": 106, 93 | "title": "ad illo quis voluptatem temporibus", 94 | "completed": true 95 | }, 96 | { 97 | "userId": 6, 98 | "id": 108, 99 | "title": "a eos eaque nihil et exercitationem incidunt delectus", 100 | "completed": true 101 | }, 102 | { 103 | "userId": 6, 104 | "id": 109, 105 | "title": "autem temporibus harum quisquam in culpa", 106 | "completed": true 107 | }, 108 | { 109 | "userId": 6, 110 | "id": 110, 111 | "title": "aut aut ea corporis", 112 | "completed": true 113 | }, 114 | { 115 | "userId": 6, 116 | "id": 116, 117 | "title": "ipsa dolores vel facilis ut", 118 | "completed": true 119 | } 120 | ] 121 | """.utf8 122 | ) 123 | 124 | case .create: 125 | return Data( 126 | """ 127 | { 128 | "userId": 1, 129 | "id": 201, 130 | "title": "piopio" 131 | } 132 | """.utf8 133 | ) 134 | 135 | case .updatePut: 136 | return Data( 137 | """ 138 | { 139 | "userId": 1, 140 | "id": 1, 141 | "title": "updated title", 142 | "completed": true, 143 | } 144 | """.utf8 145 | ) 146 | 147 | case .updatePatch: 148 | return Data( 149 | """ 150 | { 151 | "userId": 1, 152 | "id": 1, 153 | "title": "Final Final", 154 | "completed": false, 155 | } 156 | """.utf8 157 | ) 158 | 159 | case .delete: 160 | return Data() 161 | } 162 | } 163 | 164 | var task: Task { 165 | switch self { 166 | case .getTodo: 167 | return .requestPlain 168 | 169 | case let .getTodos(id, userId, title, completed): 170 | var params: [String: Any] = [:] 171 | if let id = id { 172 | params.updateValue(id, forKey: "id") 173 | } 174 | if let userId = userId { 175 | params.updateValue(userId, forKey: "userId") 176 | } 177 | if let title = title { 178 | params.updateValue(title, forKey: "title") 179 | } 180 | if let completed = completed { 181 | params.updateValue(completed, forKey: "completed") 182 | } 183 | let encoding = URLEncoding(destination: .queryString, boolEncoding: .literal) 184 | return .requestParameters(parameters: params, encoding: encoding) 185 | 186 | case let .create(newTodo): 187 | var params = (try? newTodo.toDictionary()) ?? [:] 188 | params.removeValue(forKey: "id") 189 | return .requestParameters(parameters: params, encoding: JSONEncoding.default) 190 | 191 | case let .updatePut(todo): 192 | let params = (try? todo.toDictionary()) ?? [:] 193 | return .requestParameters(parameters: params, encoding: JSONEncoding.default) 194 | 195 | case let .updatePatch(_, title, completed): 196 | var params: [String: Any] = [:] 197 | if let title = title { 198 | params.updateValue(title, forKey: "title") 199 | } 200 | if let completed = completed { 201 | params.updateValue(completed, forKey: "completed") 202 | } 203 | return .requestParameters(parameters: params, encoding: JSONEncoding.default) 204 | 205 | 206 | case .delete: 207 | return .requestPlain 208 | } 209 | } 210 | 211 | var headers: [String : String]? { 212 | return [ 213 | "Content-type": "application/json; charset=UTF-8", 214 | ] 215 | } 216 | 217 | } 218 | 219 | extension Encodable { 220 | func toDictionary() throws -> [String: Any]? { 221 | let data = try JSONEncoder().encode(self) 222 | let jsonData = try JSONSerialization.jsonObject(with: data) 223 | return jsonData as? [String : Any] 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /Projects/Modules/NetworkModule/Tests/NetworkRepositoryTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NetworkRepositoryTests.swift 3 | // NetworkModuleTests 4 | // 5 | // Created by 홍경표 on 2021/07/01. 6 | // Copyright © 2021 softbay. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | @testable import NetworkModule 12 | @testable import ThirdPartyManager 13 | 14 | import Moya 15 | import RxSwift 16 | 17 | final class NetworkRepositoryTests: XCTestCase { 18 | 19 | let decoder: JSONDecoder = .init() 20 | 21 | // MARK: GET mock 테스트 (MoyaProvider의 isStub 활용) 22 | func test_get_mock() { 23 | let expectation = expectation(description: "GET request should succeed") 24 | 25 | let networkRepo = NetworkRepository(isStub: true) 26 | 27 | let getMockEndpoint: MockTodosAPI = .getTodo(id: 1) 28 | let expected: MockTodo = try! decoder.decode(MockTodo.self, from: getMockEndpoint.sampleData) 29 | 30 | _ = networkRepo.fetch(endpoint: getMockEndpoint, for: MockTodo.self) 31 | .subscribe { response in 32 | debugPrint("[Response]:", response) 33 | XCTAssertEqual(response, expected) 34 | expectation.fulfill() 35 | } onError: { error in 36 | XCTFail(error.localizedDescription) 37 | } 38 | 39 | waitForExpectations(timeout: 10) { error in 40 | if let error = error { 41 | XCTFail(error.localizedDescription) 42 | } 43 | } 44 | } 45 | 46 | // MARK: GET 실제 데이터 테스트 47 | func test_get_RealData() throws { 48 | let expectation = expectation(description: "GET request should succeed") 49 | 50 | let networkRepo = NetworkRepository() 51 | 52 | let getModelEndpoint: MockTodosAPI = .getTodo(id: 3) 53 | let expectedData: Data = Data( 54 | """ 55 | { 56 | "userId": 1, 57 | "id": 3, 58 | "title": "fugiat veniam minus", 59 | "completed": false 60 | } 61 | """.utf8 62 | ) 63 | let expected: MockTodo = try! decoder.decode(MockTodo.self, from: expectedData) 64 | 65 | _ = networkRepo.fetch(endpoint: getModelEndpoint, for: MockTodo.self) 66 | .subscribe { response in 67 | debugPrint("[Response]:", response) 68 | XCTAssertEqual(response, expected) 69 | expectation.fulfill() 70 | } onError: { error in 71 | XCTFail(error.localizedDescription) 72 | } 73 | 74 | waitForExpectations(timeout: 10) { error in 75 | if let error = error { 76 | XCTFail(error.localizedDescription) 77 | } 78 | } 79 | } 80 | 81 | // MARK: GET with Query 실제 데이터 테스트 82 | func test_get_withQuery() { 83 | let expectation = expectation(description: "GET request should succeed") 84 | 85 | let networkRepo = NetworkRepository() 86 | 87 | let getWithQueryEndpoint: MockTodosAPI = .getTodos(userId: 6, completed: true) 88 | let expected: [MockTodo] = try! decoder.decode([MockTodo].self, from: getWithQueryEndpoint.sampleData) 89 | 90 | _ = networkRepo.fetch(endpoint: getWithQueryEndpoint, for: [MockTodo].self) 91 | .subscribe { response in 92 | debugPrint("[Response]:", response) 93 | XCTAssertEqual(response, expected) 94 | expectation.fulfill() 95 | } onError: { error in 96 | print(error) 97 | XCTFail(error.localizedDescription) 98 | } 99 | 100 | waitForExpectations(timeout: 10) { error in 101 | if let error = error { 102 | XCTFail(error.localizedDescription) 103 | } 104 | } 105 | } 106 | 107 | // MARK: POST 테스트 108 | func test_post() { 109 | let expectation = expectation(description: "POST request should succeed") 110 | 111 | let networkRepo = NetworkRepository() 112 | 113 | let newTodo: MockTodo = .init(userId: 1, id: 0, title: "piopio") 114 | let postEndpoint: MockTodosAPI = .create(newTodo) 115 | let expected: MockTodo = try! decoder.decode(MockTodo.self, from: postEndpoint.sampleData) 116 | 117 | _ = networkRepo.fetch(endpoint: postEndpoint, for: MockTodo.self) 118 | .subscribe { response in 119 | debugPrint("[Response]:", response) 120 | XCTAssertEqual(response, expected) 121 | expectation.fulfill() 122 | } onError: { error in 123 | XCTFail(error.localizedDescription) 124 | } 125 | 126 | waitForExpectations(timeout: 10) { error in 127 | if let error = error { 128 | XCTFail(error.localizedDescription) 129 | } 130 | } 131 | } 132 | 133 | // MARK: PUT 테스트 134 | func test_put() { 135 | let expectation = expectation(description: "POST request should succeed") 136 | 137 | let networkRepo = NetworkRepository() 138 | 139 | let todoToUpdate: MockTodo = .init(userId: 1, id: 1, title: "updated title", completed: true) 140 | let putEndpoint: MockTodosAPI = .updatePut(todo: todoToUpdate) 141 | let expected: MockTodo = try! decoder.decode(MockTodo.self, from: putEndpoint.sampleData) 142 | 143 | _ = networkRepo.fetch(endpoint: putEndpoint, for: MockTodo.self) 144 | .subscribe { response in 145 | debugPrint("[Response]:", response) 146 | XCTAssertEqual(response, expected) 147 | expectation.fulfill() 148 | } onError: { error in 149 | XCTFail(error.localizedDescription) 150 | } 151 | 152 | waitForExpectations(timeout: 10) { error in 153 | if let error = error { 154 | XCTFail(error.localizedDescription) 155 | } 156 | } 157 | } 158 | 159 | // MARK: PATCH 테스트 160 | func test_patch() { 161 | let expectation = expectation(description: "POST request should succeed") 162 | 163 | let networkRepo = NetworkRepository() 164 | 165 | let patchEndpoint: MockTodosAPI = .updatePatch(id: 1, title: "Final Final") 166 | let expected: MockTodo = try! decoder.decode(MockTodo.self, from: patchEndpoint.sampleData) 167 | 168 | _ = networkRepo.fetch(endpoint: patchEndpoint, for: MockTodo.self) 169 | .subscribe { response in 170 | debugPrint("[Response]:", response) 171 | XCTAssertEqual(response, expected) 172 | expectation.fulfill() 173 | } onError: { error in 174 | XCTFail(error.localizedDescription) 175 | } 176 | 177 | waitForExpectations(timeout: 10) { error in 178 | if let error = error { 179 | XCTFail(error.localizedDescription) 180 | } 181 | } 182 | } 183 | 184 | // MARK: DELETE 테스트 185 | func test_delete() { 186 | let expectation = expectation(description: "POST request should succeed") 187 | 188 | let networkRepo = NetworkRepository() 189 | 190 | let deleteEndpoint: MockTodosAPI = .delete(id: 1) 191 | 192 | // 현재 테스트 API는 Delete에 대해서 성공 여부가 상관없이 response가 "{ }" 이렇게 내려옴 193 | let expected: [String: String] = [:] 194 | 195 | _ = networkRepo.fetch(endpoint: deleteEndpoint, for: [String: String].self) 196 | .subscribe { response in 197 | debugPrint("[Response]:", response) 198 | XCTAssertEqual(response, expected) 199 | expectation.fulfill() 200 | } onError: { error in 201 | XCTFail(error.localizedDescription) 202 | } 203 | 204 | waitForExpectations(timeout: 10) { error in 205 | if let error = error { 206 | XCTFail(error.localizedDescription) 207 | } 208 | } 209 | } 210 | 211 | // MARK: StatusCode Filter 테스트 212 | func test_filterSuccessfulStatusCodes() { 213 | let expectedStatusCode: Int = 404 214 | 215 | let networkRepo = NetworkRepository(isStub: true, sampleStatusCode: expectedStatusCode) 216 | 217 | _ = networkRepo.fetch(endpoint: .getTodo(id: 1), for: MockTodo.self) 218 | .subscribe(onSuccess: { response in 219 | XCTFail() 220 | }, onError: { error in 221 | guard let error = error as? MoyaError else { 222 | XCTFail() 223 | return 224 | } 225 | XCTAssertEqual(error.response?.statusCode, expectedStatusCode) 226 | }) 227 | } 228 | 229 | } 230 | -------------------------------------------------------------------------------- /Projects/Modules/ThirdPartyManager/LocalSPM/Logger/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Logger", 8 | platforms: [.iOS(.v13)], 9 | products: [ 10 | // Products define the executables and libraries a package produces, and make them visible to other packages. 11 | .library( 12 | name: "Logger", 13 | targets: ["Logger"]), 14 | ], 15 | dependencies: [ 16 | // Dependencies declare other packages that this package depends on. 17 | // .package(url: /* package url */, from: "1.0.0"), 18 | ], 19 | targets: [ 20 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 21 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 22 | .target( 23 | name: "Logger", 24 | dependencies: [], 25 | path: "Sources" 26 | ), 27 | .testTarget( 28 | name: "LoggerTests", 29 | dependencies: ["Logger"] 30 | ), 31 | ] 32 | ) 33 | -------------------------------------------------------------------------------- /Projects/Modules/ThirdPartyManager/LocalSPM/Logger/README.md: -------------------------------------------------------------------------------- 1 | # Logger 2 | 3 | A description of this package. 4 | -------------------------------------------------------------------------------- /Projects/Modules/ThirdPartyManager/LocalSPM/Logger/Sources/Logger/Logger.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Log.swift 3 | // UtilityModule 4 | // 5 | // Created by 홍경표 on 2021/08/05. 6 | // Copyright © 2021 softbay. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public struct Logger { 12 | 13 | private init() {} 14 | 15 | private enum Level: String { 16 | case debug = "💬 DEBUG" 17 | case info = "💡 INFO" 18 | case error = "⚠️ ERROR" 19 | case fatal = "🔥 FATAL" 20 | } 21 | 22 | private static let dateFormatter: DateFormatter = { 23 | let formatter = DateFormatter() 24 | formatter.dateFormat = "HH:mm:ss" 25 | return formatter 26 | }() 27 | 28 | private static var currentDateString: String { 29 | return dateFormatter.string(from: Date()) 30 | } 31 | 32 | private static func log( 33 | level: Level, 34 | _ output: Any, 35 | fileName: String = #file, 36 | function: String = #function, 37 | line: Int = #line, 38 | separator: String = " " 39 | ) { 40 | #if DEBUG 41 | let pretty = "\(currentDateString) \(level.rawValue) \(sourceFileName(filePath: fileName)):#\(line) \(function) ->" 42 | print(pretty, output) 43 | #endif 44 | } 45 | 46 | public static func debug( 47 | _ items: Any..., 48 | fileName: String = #file, 49 | function: String = #function, 50 | line: Int = #line, 51 | separator: String = " " 52 | ) { 53 | let output = toOutput(with: items) 54 | log(level: .debug, output, fileName: fileName, function: function, line: line, separator: separator) 55 | } 56 | 57 | public static func info( 58 | _ items: Any..., 59 | fileName: String = #file, 60 | function: String = #function, 61 | line: Int = #line, 62 | separator: String = " " 63 | ) { 64 | let output = toOutput(with: items) 65 | log(level: .info, output, fileName: fileName, function: function, line: line, separator: separator) 66 | } 67 | 68 | public static func error( 69 | _ items: Any..., 70 | fileName: String = #file, 71 | function: String = #function, 72 | line: Int = #line, 73 | separator: String = " " 74 | ) { 75 | let output = toOutput(with: items) 76 | log(level: .error, output, fileName: fileName, function: function, line: line, separator: separator) 77 | } 78 | 79 | public static func fatal( 80 | _ items: Any..., 81 | fileName: String = #file, 82 | function: String = #function, 83 | line: Int = #line, 84 | separator: String = " " 85 | ) { 86 | let output = toOutput(with: items) 87 | log(level: .fatal, output, fileName: fileName, function: function, line: line, separator: separator) 88 | } 89 | 90 | private static func sourceFileName(filePath: String) -> String { 91 | let components = filePath.components(separatedBy: "/") 92 | let fileName = components.last ?? "" 93 | return String(fileName.split(separator: ".").first ?? "") 94 | } 95 | 96 | private static func toOutput(with items: [Any]) -> Any { 97 | return items.map { String("\($0)") }.joined(separator: " ") 98 | } 99 | 100 | } 101 | -------------------------------------------------------------------------------- /Projects/Modules/ThirdPartyManager/LocalSPM/Logger/Tests/LoggerTests/LoggerTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Logger 3 | 4 | final class LoggerTests: XCTestCase { 5 | func testExample() { 6 | // This is an example of a functional test case. 7 | // Use XCTAssert and related functions to verify your tests produce the correct 8 | // results. 9 | XCTAssertEqual(Logger().text, "Hello, World!") 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Projects/Modules/ThirdPartyManager/Project.swift: -------------------------------------------------------------------------------- 1 | import ProjectDescription 2 | import ProjectDescriptionHelpers 3 | 4 | let project = Project.framework( 5 | name: "ThirdPartyManager", 6 | packages: [ 7 | // remote spm 8 | .Moya, // Alamofire와 Rx를 한번 더 wrapping하여 테스터블하고 endpoint 사용이 가능한 라이브러리 9 | .Realm, // 현재 구조에서는 UserDefaults와 Realm을 사용하여 데이터를 저장 10 | .SnapKit, // 코드로 UI를 쉽게 구현하기 위해 적용 11 | .ReactorKit, // 단방향 반응형 비동기 처리를 위한 프레임워크 12 | .RxSwift, // 반응형 프로그래밍에서 비동기 처리를 쉽게 하기 위해 선언 13 | .Then, // 없으면 개발 못함 ㅎ 14 | .Swinject, // DI container개념을 도입하여 의존성을 쉽게 주입 15 | 16 | // local spm 17 | .Logger, // Log를 찍기 위한 local SPM 18 | ], 19 | dependencies: [ 20 | // remote spm 21 | .SPM.Moya, 22 | .SPM.RxMoya, 23 | .SPM.Realm, 24 | .SPM.RealmSwift, 25 | .SPM.SnapKit, 26 | .SPM.ReactorKit, 27 | .SPM.RxSwift, 28 | .SPM.RxCocoa, 29 | .SPM.RxRelay, 30 | .SPM.Then, 31 | .SPM.Swinject, 32 | 33 | // local spm 34 | .SPM.Logger, 35 | ] 36 | ) 37 | -------------------------------------------------------------------------------- /Projects/Modules/ThirdPartyManager/Resources/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/havilog/HaviTemplateApp/810dd1fa20d994784b0fbe7f09dae0048a32d21b/Projects/Modules/ThirdPartyManager/Resources/.gitkeep -------------------------------------------------------------------------------- /Projects/Modules/ThirdPartyManager/Sources/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/havilog/HaviTemplateApp/810dd1fa20d994784b0fbe7f09dae0048a32d21b/Projects/Modules/ThirdPartyManager/Sources/.gitkeep -------------------------------------------------------------------------------- /Projects/Modules/ThirdPartyManager/Sources/TempSource.swift: -------------------------------------------------------------------------------- 1 | // this is for tuist 2 | -------------------------------------------------------------------------------- /Projects/Modules/ThirdPartyManager/Tests/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/havilog/HaviTemplateApp/810dd1fa20d994784b0fbe7f09dae0048a32d21b/Projects/Modules/ThirdPartyManager/Tests/.gitkeep -------------------------------------------------------------------------------- /Projects/Modules/UtilityModule/Project.swift: -------------------------------------------------------------------------------- 1 | import ProjectDescription 2 | import ProjectDescriptionHelpers 3 | 4 | let project = Project.framework( 5 | name: "UtilityModule", 6 | dependencies: [] 7 | ) 8 | 9 | 10 | -------------------------------------------------------------------------------- /Projects/Modules/UtilityModule/Resources/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/havilog/HaviTemplateApp/810dd1fa20d994784b0fbe7f09dae0048a32d21b/Projects/Modules/UtilityModule/Resources/.gitkeep -------------------------------------------------------------------------------- /Projects/Modules/UtilityModule/Sources/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/havilog/HaviTemplateApp/810dd1fa20d994784b0fbe7f09dae0048a32d21b/Projects/Modules/UtilityModule/Sources/.gitkeep -------------------------------------------------------------------------------- /Projects/Modules/UtilityModule/Sources/Extension/Foundation/Array+safe.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Array+safe.swift 3 | // UtilityModule 4 | // 5 | // Created by 한상진 on 2021/07/21. 6 | // Copyright © 2021 softbay. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension Array { 12 | subscript(safe index: Int) -> Element? { 13 | return indices ~= index ? self[index] : nil 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Projects/Modules/UtilityModule/Sources/Extension/Foundation/Bool+Utility.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Bool+Utility.swift 3 | // UtilityModule 4 | // 5 | // Created by 한상진 on 2021/07/01. 6 | // Copyright © 2021 softbay. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension Bool { 12 | var isTrue: Bool { 13 | return self == true 14 | } 15 | 16 | var isFalse: Bool { 17 | return self == false 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Projects/Modules/UtilityModule/Sources/Extension/Foundation/Optional+Utility.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Optional+Utility.swift 3 | // UtilityModule 4 | // 5 | // Created by 한상진 on 2021/07/01. 6 | // Copyright © 2021 softbay. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension Optional { 12 | var isNil: Bool { 13 | return self == nil 14 | } 15 | 16 | var isNotNil: Bool { 17 | return self != nil 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Projects/Modules/UtilityModule/Sources/Extension/UIKit/Cell+reusableID.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cell+reusableID.swift 3 | // UtilityModule 4 | // 5 | // Created by 한상진 on 2021/07/07. 6 | // Copyright © 2021 softbay. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | public protocol ReuseIdentifiable { 12 | static var reusableID: String { get } 13 | } 14 | 15 | public extension ReuseIdentifiable { 16 | static var reusableID: String { 17 | return String(describing: self) 18 | } 19 | } 20 | 21 | extension UITableViewCell: ReuseIdentifiable {} 22 | extension UICollectionReusableView: ReuseIdentifiable {} 23 | -------------------------------------------------------------------------------- /Projects/Modules/UtilityModule/Tests/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/havilog/HaviTemplateApp/810dd1fa20d994784b0fbe7f09dae0048a32d21b/Projects/Modules/UtilityModule/Tests/.gitkeep -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HaviTemplateApp 2 | 3 | 참고 깃허브: [갓소네님 TemplateApp](https://github.com/minsOne/iOSApplicationTemplate) 4 | 5 | # 계층 구조 6 | 7 | ![image](https://github.com/hansangjin96/HaviTemplateApp/blob/main/graph.png) 8 | 9 | Features의 경우 하위 모듈로 화면 단위 모듈이 추가되어야함. 10 | 11 | # 모듈화를 통해 얻는 효과 12 | 13 | 1. 앱 개발자의 결과물은 화면이고, 화면을 빠르게 개발하기 위해서는 화면 단위의 개발이 필요하다. 14 | 따라서 화면 모듈만 빠르게 빌드하고 개발할 수 있다. 15 | 16 | 2. 도메인 별로 코드를 관리할 수 있다. 따라서 테스트 코드 작성에도 용이해지고 빌드시간도 줄어든다. 17 | 18 | 3. 개발자마다 각자 맡은 모듈을 개발하여 협업에 용이하다. 19 | 20 | 등등의 장점이 있다. 21 | 22 | # tuist를 사용하는 이유 23 | 24 | 가장 큰 이유는 프로젝트, framework등에 대한 설정을 템플릿화 해서 빠른 생성을 하기 위함이다. 25 | 26 | 또 다른 이유로는 `.xcodeproj`파일을 git에 올리지 않음으로 merge conflict를 방지한다. 27 | 28 | `xcodegen`같은 도구도 있지만 .swift파일로 관리하고 싶고, 기능적인 지원도 tuist가 더 많기 때문에 tuist를 선택하게 되었다. 29 | 30 | [tuist에 관련한 내 블로그](https://velog.io/@hansangjin96/%ED%98%91%EC%97%85-Tuist%EB%A1%9C-.xcodeproj-%EB%A8%B8%EC%A7%80-%EC%BB%A8%ED%94%8C%EB%A6%AD%ED%8A%B8-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0) 31 | 32 | -------------------------------------------------------------------------------- /Scripts/SwiftLintRunScript.sh: -------------------------------------------------------------------------------- 1 | if which swiftlint > /dev/null; then 2 | swiftlint 3 | else 4 | echo "SwiftLint not installed?" 5 | fi 6 | 7 | #if which /usr/local/bin/swiftlint >/dev/null; then 8 | # cd .. 9 | # /usr/local/bin/swiftlint lint --lenient 10 | #else 11 | # echo "warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint" 12 | #fi 13 | -------------------------------------------------------------------------------- /Tuist/ProjectDescriptionHelpers/Action+Template.swift: -------------------------------------------------------------------------------- 1 | import ProjectDescription 2 | 3 | public extension TargetAction { 4 | static let swiftlint = TargetAction.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 | // Modules 5 | static let core: TargetDependency = .project(target: "Core", path: .relativeToRoot("Modules/Core")) 6 | static let displayKit: TargetDependency = .project(target: "DisplayKit", path: .relativeToRoot("Modules/DisplayKit")) 7 | static let marvelAPI: TargetDependency = .project(target: "MarvelAPI", path: .relativeToRoot("Modules/MarvelAPI")) 8 | static let network: TargetDependency = .project(target: "Network", path: .relativeToRoot("Modules/Network")) 9 | static let support: TargetDependency = .project(target: "Support", path: .relativeToRoot("Modules/Support")) 10 | 11 | // Carthage 12 | static let injection: TargetDependency = .framework(path: .carthage("Injection")) 13 | static let nimble: TargetDependency = .framework(path: .carthage("Nimble")) 14 | static let httpStubs: TargetDependency = .framework(path: .carthage("OHHTTPStubs")) 15 | } 16 | 17 | extension Path { 18 | private static let FrameworksRoot = "Carthage/Build/iOS" 19 | 20 | static func carthage(_ name: String) -> Path { 21 | .relativeToRoot("\(FrameworksRoot)/\(name).framework") 22 | } 23 | } 24 | 25 | public extension TargetDependency { 26 | static let features: TargetDependency = .project(target: "Features", path: .relativeToRoot("Projects/Features")) 27 | static let coreKit: TargetDependency = .project(target: "CoreKit", path: .relativeToRoot("Projects/Modules/CoreKit")) 28 | static let networkModule: TargetDependency = .project(target: "NetworkModule", path: .relativeToRoot("Projects/Modules/NetworkModule")) 29 | static let databaseModule: TargetDependency = .project(target: "DatabaseModule", path: .relativeToRoot("Projects/Modules/DatabaseModule")) 30 | static let thirdPartyManager: TargetDependency = .project(target: "ThirdPartyManager", path: .relativeToRoot("Projects/Modules/ThirdPartyManager")) 31 | static let utilityModule: TargetDependency = .project(target: "UtilityModule", path: .relativeToRoot("Projects/Modules/UtilityModule")) 32 | } 33 | -------------------------------------------------------------------------------- /Tuist/ProjectDescriptionHelpers/Project+Extension.swift: -------------------------------------------------------------------------------- 1 | import ProjectDescription 2 | 3 | extension DeploymentTarget { 4 | public static let defaultDeployment: DeploymentTarget = .iOS(targetVersion: "14.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 | } 19 | 20 | extension TargetDependency { 21 | public struct SPM { 22 | } 23 | } 24 | 25 | // dependencies 26 | public extension TargetDependency.SPM { 27 | static let Moya = TargetDependency.package(product: "Moya") 28 | static let RxMoya = TargetDependency.package(product: "RxMoya") 29 | static let Realm = TargetDependency.package(product: "Realm") 30 | static let RealmSwift = TargetDependency.package(product: "RealmSwift") 31 | static let SnapKit = TargetDependency.package(product: "SnapKit") 32 | static let ReactorKit = TargetDependency.package(product: "ReactorKit") 33 | static let RxSwift = TargetDependency.package(product: "RxSwift") 34 | static let RxCocoa = TargetDependency.package(product: "RxCocoa") 35 | static let RxRelay = TargetDependency.package(product: "RxRelay") 36 | static let Then = TargetDependency.package(product: "Then") 37 | static let Swinject = TargetDependency.package(product: "Swinject") 38 | 39 | // for test 40 | static let Nimble = TargetDependency.package(product: "Nimble") 41 | static let Quick = TargetDependency.package(product: "Quick") 42 | static let RxTest = TargetDependency.package(product: "RxTest") 43 | static let RxBlocking = TargetDependency.package(product: "RxBlocking") 44 | 45 | // local spm 46 | static let ResourcePackage = TargetDependency.package(product: "ResourcePackage") 47 | 48 | static let Logger = TargetDependency.package(product: "Logger") 49 | } 50 | 51 | public extension Package { 52 | // remote spm 53 | static let Moya = Package.remote(url: "https://github.com/Moya/Moya", requirement: .upToNextMajor(from: "14.0.0")) 54 | static let Realm = Package.remote(url: "https://github.com/realm/realm-cocoa", requirement: .upToNextMajor(from: "10.8.0")) 55 | static let SnapKit = Package.remote(url: "https://github.com/SnapKit/SnapKit", requirement: .upToNextMajor(from: "5.0.1")) 56 | static let ReactorKit = Package.remote(url: "https://github.com/ReactorKit/ReactorKit", requirement: .upToNextMajor(from: "2.1.1")) 57 | static let RxSwift = Package.remote(url: "https://github.com/ReactiveX/RxSwift", requirement: .upToNextMajor(from: "5.0.0")) 58 | static let Then = Package.remote(url: "https://github.com/devxoul/Then", requirement: .upToNextMajor(from: "2.7.0")) 59 | static let Swinject = Package.remote(url: "https://github.com/Swinject/Swinject", requirement: .upToNextMajor(from: "2.7.1")) 60 | static let Nimble = Package.remote(url: "https://github.com/Quick/Nimble", requirement: .upToNextMajor(from: "9.2.0")) 61 | static let Quick = Package.remote(url: "https://github.com/Quick/Quick", requirement: .upToNextMajor(from: "4.0.0")) 62 | 63 | // local spm 64 | static let ResourcePackage = Package.local(path: .relativeToRoot("Projects/Modules/ResourcePackage")) 65 | static let Logger = Package.local(path: .relativeToRoot("Projects/Modules/ThirdPartyManager/LocalSPM/Logger")) 66 | } 67 | -------------------------------------------------------------------------------- /Tuist/ProjectDescriptionHelpers/Project+Templates.swift: -------------------------------------------------------------------------------- 1 | import ProjectDescription 2 | //import UtilityPlugin 3 | 4 | public extension Project { 5 | static func staticLibrary( 6 | name: String, 7 | platform: Platform = .iOS, 8 | packages: [Package] = [], 9 | dependencies: [TargetDependency] = [], 10 | customSettings: [String: SettingValue] = [:] 11 | ) -> Self { 12 | return project( 13 | name: name, 14 | packages: packages, 15 | product: .staticLibrary, 16 | platform: platform, 17 | dependencies: dependencies, 18 | customSettings: customSettings 19 | ) 20 | } 21 | 22 | static func staticFramework( 23 | name: String, 24 | platform: Platform = .iOS, 25 | packages: [Package] = [], 26 | dependencies: [TargetDependency] = [], 27 | customSettings: [String: SettingValue] = [:] 28 | ) -> Self { 29 | return project( 30 | name: name, 31 | packages: packages, 32 | product: .staticFramework, 33 | platform: platform, 34 | dependencies: dependencies, 35 | customSettings: customSettings 36 | ) 37 | } 38 | 39 | static func framework( 40 | name: String, 41 | platform: Platform = .iOS, 42 | packages: [Package] = [], 43 | dependencies: [TargetDependency] = [], 44 | customSettings: [String: SettingValue] = [:] 45 | ) -> Self { 46 | return project( 47 | name: name, 48 | packages: packages, 49 | product: .framework, 50 | platform: platform, 51 | dependencies: dependencies, 52 | customSettings: customSettings 53 | ) 54 | } 55 | } 56 | 57 | /* 58 | 만약 배포 버전에 문제가 생긴다면 settings 안에 다음 코드 추가 59 | base: [ 60 | !프로젝트 버전 번호, 마케팅 버전 번호는 Info.plist를 통해서 설정! 61 | .marketVersion: "1.1", 62 | .currentProjectVersion: "2" 63 | ], 64 | */ 65 | public extension Project { 66 | static func project( 67 | name: String, 68 | organizationName: String = "havi", 69 | packages: [Package] = [], 70 | product: Product, 71 | platform: Platform = .iOS, 72 | deploymentTarget: DeploymentTarget? = .defaultDeployment, 73 | actions: [TargetAction] = [], 74 | dependencies: [TargetDependency] = [], 75 | customSettings: [String: SettingValue] = [:], 76 | infoPlist: InfoPlist = .default, 77 | settings: Settings? = nil, 78 | schemes: [Scheme] = [] 79 | ) -> Self { 80 | var base: SettingsDictionary = [ 81 | .codeSignIdentity: "Apple Development", 82 | .codeSigningStyle: "Automatic", 83 | .developmentTeam: "85329TR25G", 84 | .codeSigningRequired: "NO" 85 | ] 86 | 87 | customSettings.forEach { 88 | base.updateValue($1, forKey: $0) 89 | } 90 | 91 | let settings = Settings( 92 | base: base, 93 | configurations: [ 94 | .debug(name: "Debug", settings: [ 95 | .bundleIdentifier: "com.\(organizationName).\(name)", 96 | .bundleName: "\(name)_Dev" 97 | ]), 98 | .debug(name: "AdHoc", settings: [ 99 | .bundleIdentifier: "com.\(organizationName).\(name)", 100 | .bundleName: "\(name)_AdHoc" 101 | ]), 102 | .release(name: "Release", settings: [ 103 | .bundleIdentifier: "com.\(organizationName).\(name)", 104 | .bundleName: "\(name)_Dist" 105 | ]) 106 | ] 107 | ) 108 | 109 | let mainTarget: Target = Target( 110 | name: name, 111 | platform: platform, 112 | product: product, 113 | bundleId: "com.\(organizationName).\(name)", 114 | deploymentTarget: deploymentTarget, 115 | infoPlist: infoPlist, 116 | sources: ["Sources/**"], 117 | resources: ["Resources/**"], 118 | actions: actions, 119 | dependencies: dependencies 120 | ) 121 | 122 | let testTarget: Target = Target( 123 | name: "\(name)Tests", 124 | platform: platform, 125 | product: .unitTests, 126 | bundleId: "com.\(organizationName).\(name)Tests", 127 | deploymentTarget: deploymentTarget, 128 | infoPlist: .default, 129 | sources: ["Tests/**"], 130 | dependencies: [ 131 | .target(name: name), 132 | .SPM.Quick, 133 | .SPM.Nimble, 134 | ] 135 | ) 136 | 137 | let targets: [Target] = [ 138 | mainTarget 139 | ] 140 | 141 | return Project( 142 | name: name, 143 | organizationName: organizationName, 144 | packages: packages + [.Quick, .Nimble], 145 | settings: settings, 146 | targets: targets, 147 | schemes: schemes 148 | ) 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /Workspace.swift: -------------------------------------------------------------------------------- 1 | import ProjectDescription 2 | 3 | let workspace = Workspace( 4 | name: "HaviWorkspace", 5 | projects: ["Projects/App"] 6 | ) 7 | -------------------------------------------------------------------------------- /graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/havilog/HaviTemplateApp/810dd1fa20d994784b0fbe7f09dae0048a32d21b/graph.png --------------------------------------------------------------------------------