├── .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 | 
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
--------------------------------------------------------------------------------