├── Application
├── Configuration
│ ├── Debug.xcconfig
│ ├── Release.xcconfig
│ └── Staging.xcconfig
├── Resources
│ ├── Assets.xcassets
│ │ ├── AccentColor.colorset
│ │ │ └── Contents.json
│ │ ├── AppIcon.appiconset
│ │ │ └── Contents.json
│ │ ├── Contents.json
│ │ ├── No_image_poster.imageset
│ │ │ ├── Contents.json
│ │ │ └── No_image_poster.png
│ │ └── logo.imageset
│ │ │ ├── Contents.json
│ │ │ └── logo.png
│ ├── Base.lproj
│ │ └── LaunchScreen.storyboard
│ ├── Firebase
│ │ ├── GoogleService-Info-Debug.plist
│ │ ├── GoogleService-Info-Staging.plist
│ │ └── GoogleService-Info.plist
│ └── Info.plist
└── Source
│ ├── App
│ ├── App+Style.swift
│ ├── App.swift
│ ├── AppCoordinator.swift
│ ├── AppDelegate.swift
│ └── SceneDelegate.swift
│ ├── Base
│ ├── Base.swift
│ ├── BaseCollectionViewController.swift
│ ├── BaseTableViewController.swift
│ ├── BaseViewController.swift
│ ├── Concrete
│ │ ├── Coordinator.swift
│ │ ├── NavigationCoordinator.swift
│ │ ├── TabBarCordinator.swift
│ │ └── TypeErased
│ │ │ ├── AnyCoordinatable.swift
│ │ │ └── AnyCoordinator.swift
│ └── Protocols
│ │ ├── Coordinatable.swift
│ │ ├── CoordinatorType.swift
│ │ ├── ModelType.swift
│ │ ├── Scene.swift
│ │ ├── ViewModelType.swift
│ │ └── ViewType.swift
│ ├── Extensions
│ ├── Bundle.swift
│ ├── UIDevice.swift
│ └── UIScrollView+KeyboardContentInsettable.swift
│ ├── Scenes
│ ├── Home
│ │ ├── MainTabBarController.swift
│ │ ├── MainTabBarCoordinator.swift
│ │ └── Posts
│ │ │ ├── Detail
│ │ │ ├── PostDetail.storyboard
│ │ │ ├── PostDetailCoordinator.swift
│ │ │ ├── PostDetailScene.swift
│ │ │ ├── PostDetailViewController.swift
│ │ │ ├── PostDetailViewModel.swift
│ │ │ └── Views
│ │ │ │ ├── CommentTableViewCell.swift
│ │ │ │ └── CommentTableViewCell.xib
│ │ │ └── List
│ │ │ ├── PostList.storyboard
│ │ │ ├── PostListCoordinator.swift
│ │ │ ├── PostListScene.swift
│ │ │ ├── PostListViewController.swift
│ │ │ └── PostListViewModel.swift
│ └── Login
│ │ ├── Login.storyboard
│ │ ├── LoginCoordinator.swift
│ │ ├── LoginScene.swift
│ │ ├── LoginViewController.swift
│ │ └── LoginViewModel.swift
│ └── Utilities
│ └── StoryboardInstantiatable.swift
├── ApplicationTests
├── Info.plist
├── Login
│ ├── LoginViewControllerTests.swift
│ └── LoginViewModelTests.swift
├── MirrorObject.swift
└── PostListViewModelTests.swift
├── Domain
├── CombineSupport
│ ├── Networking+Combine.swift
│ ├── Repository+Combine.swift
│ └── UseCase+Combine.swift
├── Concrete
│ └── TypeErased
│ │ └── AnyUseCase.swift
├── Domain.h
├── Entities
│ ├── CommentEntity.swift
│ ├── Conversion
│ │ └── DomainEntityConvertible.swift
│ └── PostEntity.swift
├── Info.plist
├── Protocols
│ ├── Endpoint.swift
│ ├── Entity.swift
│ ├── Model.swift
│ ├── Network.swift
│ ├── Repository.swift
│ ├── UseCase.swift
│ └── ValueObject.swift
└── UseCases
│ └── UseCaseProvider.swift
├── DomainTests
├── DomainTests.swift
└── Info.plist
├── Gemfile
├── Gemfile.lock
├── Platform
├── Entities
│ ├── Comment.swift
│ ├── Conversion
│ │ └── DomainEntityConvertible.swift
│ └── Post.swift
├── Info.plist
├── Network
│ ├── Endpoints
│ │ └── Endpoints.swift
│ ├── HTTPMethod.swift
│ └── Network.swift
├── Platform.h
├── Repository
│ └── RemoteRepository.swift
└── UseCases
│ ├── DoLoginUseCase.swift
│ ├── GetPostCommentsUseCase.swift
│ ├── GetPostDetailUseCase.swift
│ ├── GetPostsUseCase.swift
│ └── UseCaseProvider.swift
├── PlatformTests
├── Info.plist
└── PlatformTests.swift
├── Podfile.lock
├── README.md
├── cp_googleservices_plist.sh
├── fastlane
├── Appfile
├── Fastfile
├── Gymfile
├── Pluginfile
├── README.md
└── Scanfile
├── firebase_crashlytics.sh
├── profiles
└── README.md
├── project.yml
└── swiftlint.sh
/Application/Configuration/Debug.xcconfig:
--------------------------------------------------------------------------------
1 | // Configuration settings file format documentation can be found at:
2 | // https://help.apple.com/xcode/#/dev745c5c974
3 |
4 | BUNDLE_IDENTIFIER = REPLACE_BUNDLE_ID.dev
5 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG
6 | OTHER_SWIFT_FLAGS = -Xfrontend -warn-long-function-bodies=100
7 | APP_NAME = [Dev]Template
8 |
--------------------------------------------------------------------------------
/Application/Configuration/Release.xcconfig:
--------------------------------------------------------------------------------
1 | // Configuration settings file format documentation can be found at:
2 | // https://help.apple.com/xcode/#/dev745c5c974
3 |
4 | BUNDLE_IDENTIFIER = REPLACE_BUNDLE_ID
5 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = RELEASE
6 | APP_NAME = REPLACE_PROJECT_NAME
7 |
--------------------------------------------------------------------------------
/Application/Configuration/Staging.xcconfig:
--------------------------------------------------------------------------------
1 | // Configuration settings file format documentation can be found at:
2 | // https://help.apple.com/xcode/#/dev745c5c974
3 |
4 | BUNDLE_IDENTIFIER = REPLACE_BUNDLE_ID.staging
5 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = STAGING
6 | APP_NAME = [STG]REPLACE_PROJECT_NAME
7 |
--------------------------------------------------------------------------------
/Application/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 |
--------------------------------------------------------------------------------
/Application/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 |
--------------------------------------------------------------------------------
/Application/Resources/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/Application/Resources/Assets.xcassets/No_image_poster.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "No_image_poster.png",
5 | "idiom" : "universal"
6 | }
7 | ],
8 | "info" : {
9 | "author" : "xcode",
10 | "version" : 1
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/Application/Resources/Assets.xcassets/No_image_poster.imageset/No_image_poster.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/monstar-lab-oss/ios-template/ae53f76e1ea42c67f3a627a8875fdb9ef8bc996d/Application/Resources/Assets.xcassets/No_image_poster.imageset/No_image_poster.png
--------------------------------------------------------------------------------
/Application/Resources/Assets.xcassets/logo.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "logo.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/Application/Resources/Assets.xcassets/logo.imageset/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/monstar-lab-oss/ios-template/ae53f76e1ea42c67f3a627a8875fdb9ef8bc996d/Application/Resources/Assets.xcassets/logo.imageset/logo.png
--------------------------------------------------------------------------------
/Application/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 |
--------------------------------------------------------------------------------
/Application/Resources/Firebase/GoogleService-Info-Debug.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CLIENT_ID
6 | .apps.googleusercontent.com
7 | REVERSED_CLIENT_ID
8 | com.googleusercontent.apps.
9 | API_KEY
10 |
11 | GCM_SENDER_ID
12 |
13 | PLIST_VERSION
14 | 1
15 | BUNDLE_ID
16 | com.nodes.
17 | PROJECT_ID
18 |
19 | STORAGE_BUCKET
20 | .appspot.com
21 | IS_ADS_ENABLED
22 |
23 | IS_ANALYTICS_ENABLED
24 |
25 | IS_APPINVITE_ENABLED
26 |
27 | IS_GCM_ENABLED
28 |
29 | IS_SIGNIN_ENABLED
30 |
31 | GOOGLE_APP_ID
32 | 1::ios:
33 | DATABASE_URL
34 | https://firebaseio.com
35 |
36 |
--------------------------------------------------------------------------------
/Application/Resources/Firebase/GoogleService-Info-Staging.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CLIENT_ID
6 | .apps.googleusercontent.com
7 | REVERSED_CLIENT_ID
8 | com.googleusercontent.apps.
9 | API_KEY
10 |
11 | GCM_SENDER_ID
12 |
13 | PLIST_VERSION
14 | 1
15 | BUNDLE_ID
16 | com.nodes.ci-test-1
17 | PROJECT_ID
18 |
19 | STORAGE_BUCKET
20 | .appspot.com
21 | IS_ADS_ENABLED
22 |
23 | IS_ANALYTICS_ENABLED
24 |
25 | IS_APPINVITE_ENABLED
26 |
27 | IS_GCM_ENABLED
28 |
29 | IS_SIGNIN_ENABLED
30 |
31 | GOOGLE_APP_ID
32 | 1::ios:
33 | DATABASE_URL
34 | https://firebaseio.com
35 |
36 |
--------------------------------------------------------------------------------
/Application/Resources/Firebase/GoogleService-Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CLIENT_ID
6 | .apps.googleusercontent.com
7 | REVERSED_CLIENT_ID
8 | com.googleusercontent.apps.
9 | API_KEY
10 |
11 | GCM_SENDER_ID
12 |
13 | PLIST_VERSION
14 | 1
15 | BUNDLE_ID
16 | com.nodes.
17 | PROJECT_ID
18 |
19 | STORAGE_BUCKET
20 | .appspot.com
21 | IS_ADS_ENABLED
22 |
23 | IS_ANALYTICS_ENABLED
24 |
25 | IS_APPINVITE_ENABLED
26 |
27 | IS_GCM_ENABLED
28 |
29 | IS_SIGNIN_ENABLED
30 |
31 | GOOGLE_APP_ID
32 | 1::ios:
33 | DATABASE_URL
34 | https://.firebaseio.com
35 |
36 |
37 |
--------------------------------------------------------------------------------
/Application/Resources/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | APPL
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | 1
21 | ITSAppUsesNonExemptEncryption
22 |
23 | UIApplicationSceneManifest
24 |
25 | UIApplicationSupportsMultipleScenes
26 |
27 | UISceneConfigurations
28 |
29 | UIWindowSceneSessionRoleApplication
30 |
31 |
32 | UISceneConfigurationName
33 | Default Configuration
34 | UISceneDelegateClassName
35 | $(PRODUCT_MODULE_NAME).SceneDelegate
36 |
37 |
38 |
39 |
40 | UILaunchStoryboardName
41 | LaunchScreen
42 | UISupportedInterfaceOrientations
43 |
44 | UIInterfaceOrientationPortrait
45 |
46 |
47 |
48 |
--------------------------------------------------------------------------------
/Application/Source/App/App+Style.swift:
--------------------------------------------------------------------------------
1 | //
2 | // App+Style.swift
3 | // Template
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | extension App {
12 | enum Style {}
13 | }
14 |
15 | extension App.Style {
16 | enum Color {
17 | static let primary = UIColor(red: 155.0/255.0, green: 100.0/255.0, blue: 94.0/255.0, alpha: 1.0)
18 | static let secondary = UIColor(red: 200.0/255.0, green: 150.0/255.0, blue: 124.0/255.0, alpha: 1.0)
19 | }
20 | enum Font {
21 |
22 | }
23 |
24 | static func setup() {
25 | UITabBar.appearance().barStyle = .default
26 | UITabBar.appearance().clipsToBounds = true
27 | UITabBar.appearance().layer.borderColor = UIColor.clear.cgColor
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/Application/Source/App/App.swift:
--------------------------------------------------------------------------------
1 | //
2 | // App.swift
3 | // Template
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import UIKit.UIDevice
11 |
12 | enum Environment {
13 | case develop
14 | case staging
15 | case production
16 | }
17 |
18 | enum App {
19 |
20 | static let enivornment: Environment = {
21 | #if DEBUG
22 | return .develop
23 | #elseif STAGING
24 | return .staging
25 | #elseif RELEASE
26 | return .production
27 | #endif
28 | }()
29 |
30 | enum Server {
31 |
32 | static let scheme = "https"
33 |
34 | static let hostname: String = {
35 | switch App.enivornment {
36 | case .develop:
37 | return "jsonplaceholder.typicode.com"
38 | case .staging:
39 | return "jsonplaceholder.typicode.com"
40 | case .production:
41 | return "api.example.com"
42 | }
43 | }()
44 |
45 | static let apiKey = "YOUR_API_KEY"
46 |
47 | static var baseURL: URL {
48 | var urlComponents = URLComponents()
49 | urlComponents.scheme = Server.scheme
50 | urlComponents.host = Server.hostname
51 | // urlComponents.queryItems = [
52 | // URLQueryItem(name: "api_key", value: Server.apiKey)
53 | // ]
54 | guard let url = urlComponents.url else {
55 | fatalError("Invalid API URL...")
56 | }
57 | return url
58 | }
59 |
60 | static var metaHeaders: [String: String] {
61 | var appendString = "ios;"
62 | let environment: String = "\(App.enivornment)"
63 | appendString.append("\(environment);")
64 | appendString.append("\(Bundle.main.releaseVersionNumber ?? "");")
65 | appendString.append("\(UIDevice.current.systemVersion);")
66 | appendString.append("\(UIDevice.current.modelName)")
67 | return ["N-Meta": appendString]
68 | }
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/Application/Source/App/AppCoordinator.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppCoordinator.swift
3 | // Template
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import Platform
11 |
12 | final class AppCoordinator: CoordinatorType {
13 |
14 | let window: UIWindow
15 |
16 | var viewController: UIViewController {
17 | guard let rootVC = window.rootViewController else {
18 | fatalError("Window's `rootViewController` must be set first by calling `start` method")
19 | }
20 | return rootVC
21 | }
22 |
23 | private(set) var childCoordinators: [CoordinatorType]
24 |
25 | init(window: UIWindow) {
26 | self.window = window
27 | self.childCoordinators = []
28 | }
29 |
30 | func start() {
31 | App.Style.setup()
32 | let coordinator = MainTabBarCoordinator(
33 | useCaseProvider: Platform.UseCaseProvider(
34 | baseURL: App.Server.baseURL
35 | )
36 | )
37 | addChild(coordinator)
38 | coordinator.parentCoordinator = self.eraseToAnyCoordinator()
39 | coordinator.start()
40 | let rootVC = coordinator.viewController
41 | self.window.rootViewController = rootVC
42 | self.window.makeKeyAndVisible()
43 | }
44 |
45 | func addChild(_ coordinator: CoordinatorType) {
46 | childCoordinators.append(coordinator)
47 | }
48 |
49 | func removeChild(_ coordinator: CoordinatorType) {
50 | guard let index = childCoordinators.firstIndex(where: { $0 === coordinator }) else { return }
51 | childCoordinators.remove(at: index)
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/Application/Source/App/AppDelegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppDelegate.swift
3 | // Template
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | @main
12 | class AppDelegate: UIResponder, UIApplicationDelegate {
13 |
14 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
15 | // Override point for customization after application launch.
16 | return true
17 | }
18 |
19 | // MARK: UISceneSession Lifecycle
20 |
21 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
22 | // Called when a new scene session is being created.
23 | // Use this method to select a configuration to create the new scene with.
24 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
25 | }
26 |
27 | func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) {
28 | // Called when the user discards a scene session.
29 | // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
30 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return.
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/Application/Source/App/SceneDelegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SceneDelegate.swift
3 | // Template
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import Combine
11 |
12 | class SceneDelegate: UIResponder, UIWindowSceneDelegate {
13 |
14 | var window: UIWindow?
15 | var appCoordinator: AppCoordinator?
16 |
17 | var cancellables = Set()
18 |
19 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
20 | // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
21 | // If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
22 | // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
23 | if let windowScene = scene as? UIWindowScene {
24 | let window = UIWindow(windowScene: windowScene)
25 | if !ProcessInfo.isUnitTesting {
26 | let appCoordinator = AppCoordinator(window: window)
27 | appCoordinator.start()
28 | self.window = window
29 | } else {
30 | // let window = UIWindow(frame: UIScreen.main.bounds)
31 | window.rootViewController = UIViewController()
32 | window.makeKeyAndVisible()
33 | self.window = window
34 | }
35 | }
36 | }
37 |
38 | func sceneDidDisconnect(_ scene: UIScene) {
39 | // Called as the scene is being released by the system.
40 | // This occurs shortly after the scene enters the background, or when its session is discarded.
41 | // Release any resources associated with this scene that can be re-created the next time the scene connects.
42 | // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead).
43 | }
44 |
45 | func sceneDidBecomeActive(_ scene: UIScene) {
46 | // Called when the scene has moved from an inactive state to an active state.
47 | // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive.
48 | }
49 |
50 | func sceneWillResignActive(_ scene: UIScene) {
51 | // Called when the scene will move from an active state to an inactive state.
52 | // This may occur due to temporary interruptions (ex. an incoming phone call).
53 | }
54 |
55 | func sceneWillEnterForeground(_ scene: UIScene) {
56 | // Called as the scene transitions from the background to the foreground.
57 | // Use this method to undo the changes made on entering the background.
58 | }
59 |
60 | func sceneDidEnterBackground(_ scene: UIScene) {
61 | // Called as the scene transitions from the foreground to the background.
62 | // Use this method to save data, release shared resources, and store enough scene-specific state information
63 | // to restore the scene back to its current state.
64 | }
65 | }
66 |
67 | extension ProcessInfo {
68 | /// check passed arguments to detect if we are testing
69 | static var isUnitTesting: Bool {
70 | ProcessInfo.processInfo.arguments.contains("-UNITTEST")
71 | }
72 |
73 | static var isAutomatedTesting: Bool {
74 | ProcessInfo.processInfo.arguments.contains("-automatedTesting")
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/Application/Source/Base/Base.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Base.swift
3 | // Template
4 | //
5 | // Created by Aarif Sumra on 2021/09/02.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import Domain
10 | import UIKit
11 |
12 | protocol HasReuseIdentifier {
13 | static var reuseIdentifier: String { get }
14 | }
15 |
16 | protocol ItemConfigurable {
17 | associatedtype Item
18 | func configure(forItem: Item)
19 | }
20 |
21 | typealias HashableEntity = Hashable & Entity
22 |
--------------------------------------------------------------------------------
/Application/Source/Base/BaseCollectionViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BaseMoviesViewController.swift
3 | // BoxOffice
4 | //
5 | // Created by Sumra Aarif on 2021/01/22.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 | import UIKit
9 | import Combine
10 | import Domain
11 |
12 | typealias ItemConfigurableCollectionViewCellWithReuseIdentifer = ItemConfigurable & UICollectionViewCell & HasReuseIdentifier
13 |
14 | class BaseCollectionViewController: BaseViewController where Cell.Item == Model {
15 | // Enums
16 | enum Section {
17 | case main
18 | }
19 |
20 | // Typealiases
21 | typealias DataSource = UICollectionViewDiffableDataSource
22 | typealias Delegate = UICollectionViewDelegateFlowLayout
23 | typealias Snapshot = NSDiffableDataSourceSnapshot
24 |
25 | // Lazys
26 | lazy var dataSource: DataSource = {
27 | DataSource(
28 | collectionView: collectionView,
29 | cellProvider: { (collectionView, indexPath, item) -> UICollectionViewCell? in
30 | let cell = collectionView.dequeueReusableCell(withReuseIdentifier: Cell.reuseIdentifier, for: indexPath) as? Cell
31 | cell?.configure(forItem: item)
32 | return cell
33 | }
34 | )
35 | }()
36 | lazy var snapshot: Snapshot = {
37 | var snapshot: Snapshot = .init()
38 | snapshot.appendSections([.main])
39 | return snapshot
40 | }()
41 |
42 | weak var refreshControl: UIRefreshControl!
43 | weak var noResultsLabel: UILabel!
44 | // IBOutlets
45 | @IBOutlet weak var collectionView: UICollectionView!
46 |
47 | override func viewDidLoad() {
48 | super.viewDidLoad()
49 | setupRefreshControl()
50 | setupNoResultsLabel()
51 | setupCollectionView()
52 | }
53 |
54 | func updateCollection(with items: [Model]) {
55 | snapshot.appendItems(items)
56 | dataSource.apply(snapshot)
57 | }
58 |
59 | func setupCollectionView() {
60 | let nib = UINib(nibName: String(describing: type(of: Cell())), bundle: nil)
61 | collectionView.register(nib, forCellWithReuseIdentifier: Cell.reuseIdentifier)
62 | collectionView.keyboardDismissMode = .onDrag
63 | }
64 |
65 | func setupRefreshControl() {
66 | let refreshControl = UIRefreshControl()
67 | refreshControl.backgroundColor = .clear
68 | refreshControl.tintColor = .lightGray
69 | collectionView.refreshControl = refreshControl
70 | self.refreshControl = refreshControl
71 | }
72 |
73 | func setupNoResultsLabel() {
74 | let label = UILabel()
75 | label.text = "No Items Found!"
76 | label.textAlignment = .center
77 | label.sizeToFit()
78 | label.isHidden = true
79 | collectionView.backgroundView = label
80 | noResultsLabel = label
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/Application/Source/Base/BaseTableViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BaseTableViewController.swift
3 | // DACP
4 | //
5 | // Created by Aarif Sumra on 2021/07/08.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import Combine
11 | import CombineCocoa
12 | import Domain
13 |
14 | typealias ItemConfigurableTableViewCellWithReuseIdentifer = ItemConfigurable & UITableViewCell & HasReuseIdentifier
15 |
16 | class BaseTableViewController: BaseViewController where Cell.Item == Model {
17 |
18 | // MARK: - Enums and Type aliases
19 | enum Section {
20 | case main
21 | }
22 |
23 | typealias DataSource = UITableViewDiffableDataSource
24 | typealias Snapshot = NSDiffableDataSourceSnapshot
25 |
26 | // MARK: - Lazys
27 | lazy var refreshControl = makeRefreshControl()
28 | lazy var noResultsLabel = makeNoResultsLabel()
29 |
30 | // MARK: - Outlets
31 | @IBOutlet weak var tableView: UITableView!
32 |
33 | // MARK: - Properties
34 | private(set) var dataSource: DataSource!
35 | private var sections: [Section] = [.main]
36 | var clearsSelectionOnViewWillAppear = false
37 |
38 | // MARK: - View Lifecycle -
39 | override func viewDidLoad() {
40 | super.viewDidLoad()
41 |
42 | configureTableView()
43 | dataSource = makeDataSource()
44 | }
45 |
46 | override func viewWillAppear(_ animated: Bool) {
47 | super.viewWillAppear(animated)
48 | if clearsSelectionOnViewWillAppear, let selectedRows = tableView.indexPathsForSelectedRows {
49 | for indexPath in selectedRows {
50 | tableView.deselectRow(at: indexPath, animated: animated)
51 | }
52 | }
53 | }
54 |
55 | func configureTableView() {
56 | let nib = UINib(nibName: String(describing: type(of: Cell())), bundle: nil)
57 | tableView.register(nib, forCellReuseIdentifier: Cell.reuseIdentifier)
58 | tableView.keyboardDismissMode = .onDrag
59 | tableView.tableFooterView = UIView()
60 | }
61 |
62 | func makeDataSource() -> DataSource {
63 | return DataSource(
64 | tableView: tableView,
65 | cellProvider: { (tableView, indexPath, item) -> UITableViewCell? in
66 | guard let cell = tableView.dequeueReusableCell(withIdentifier: Cell.reuseIdentifier, for: indexPath) as? Cell else {
67 | return UITableViewCell()
68 | }
69 | cell.configure(forItem: item)
70 | return cell
71 | }
72 | )
73 | }
74 |
75 | func loadTable(withInitialData items: [Model] = []) {
76 | // initial data
77 | var snapshot = Snapshot()
78 | snapshot.appendSections(sections)
79 | snapshot.appendItems([], toSection: .main)
80 | dataSource.apply(snapshot)
81 | }
82 |
83 | func updateTable(with items: [Model], animatingDifferences: Bool = false) {
84 | var currentSnapshot = dataSource.snapshot()
85 | currentSnapshot.appendItems(items, toSection: .main)
86 | dataSource.apply(currentSnapshot, animatingDifferences: animatingDifferences)
87 |
88 | refreshControl.endRefreshing()
89 | noResultsLabel.isHidden = !items.isEmpty
90 | }
91 | }
92 |
93 | private extension BaseTableViewController {
94 |
95 | func makeRefreshControl() -> UIRefreshControl {
96 | let refreshControl = UIRefreshControl()
97 | refreshControl.backgroundColor = .clear
98 | refreshControl.tintColor = .lightGray
99 | tableView.refreshControl = refreshControl
100 | return refreshControl
101 |
102 | }
103 |
104 | func makeNoResultsLabel() -> UILabel {
105 | let label = UILabel()
106 | label.text = "No Items Found!"
107 | label.textAlignment = .center
108 | label.sizeToFit()
109 | label.isHidden = true
110 | tableView.backgroundView = label
111 | return label
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/Application/Source/Base/BaseViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BaseViewController.swift
3 | // BoxOffice
4 | //
5 | // Created by Aarif Sumra on 2021/07/16.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import UIKit.UIViewController
10 |
11 | class BaseViewController: UIViewController {
12 |
13 | }
14 |
--------------------------------------------------------------------------------
/Application/Source/Base/Concrete/Coordinator.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Coordinator.swift
3 | // Template
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import UIKit.UIViewController
10 | import UIKit.UINavigationController
11 | import UIKit.UITabBarController
12 |
13 | class Coordinator: NSObject, CoordinatorType {
14 |
15 | var parentCoordinator: AnyCoordinator?
16 |
17 | var childCoordinators: [CoordinatorType] {
18 | _childCoordinators
19 | }
20 | var viewController: UIViewController {
21 | _viewController
22 | }
23 |
24 | fileprivate let _viewController: T
25 | fileprivate var _childCoordinators: [CoordinatorType]
26 |
27 | override private init() {
28 | _childCoordinators = []
29 | _viewController = T()
30 | super.init()
31 | }
32 |
33 | init(viewController: T = .init(), childCoordinators: [CoordinatorType] = []) {
34 | self._viewController = viewController
35 | self._childCoordinators = childCoordinators
36 | super.init()
37 | }
38 |
39 | func start() {
40 | fatalError("Start method must be implemented")
41 | }
42 |
43 | func addChild(_ coordinator: CoordinatorType) {
44 | _childCoordinators.append(coordinator)
45 | }
46 |
47 | func removeChild(_ coordinator: CoordinatorType) {
48 | guard let index = _childCoordinators.firstIndex(where: { $0 === coordinator }) else { return }
49 | _childCoordinators.remove(at: index)
50 | }
51 |
52 | func removeAllChildren() {
53 | _childCoordinators.removeAll()
54 | }
55 |
56 | func removeFromParent() {
57 | parentCoordinator?.removeChild(self)
58 | }
59 |
60 | deinit {
61 | removeAllChildren()
62 | }
63 | }
64 |
65 | extension NavigationCoordinator {
66 | var navigationController: UINavigationController {
67 | return _viewController
68 | }
69 | }
70 |
71 | extension TabBarCoordinator {
72 | var tabBarController: UITabBarController {
73 | return _viewController
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/Application/Source/Base/Concrete/NavigationCoordinator.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NavigationCoordinator.swift
3 | // Template
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import UIKit.UINavigationController
10 |
11 | class NavigationCoordinator: Coordinator {
12 |
13 | override init(viewController: UINavigationController = .init(), childCoordinators: [CoordinatorType] = .init()) {
14 | super.init(viewController: viewController, childCoordinators: childCoordinators)
15 | }
16 |
17 | override func start() {
18 | navigationController.delegate = self
19 | }
20 |
21 | override func removeChild(_ coordinator: CoordinatorType) {
22 | if coordinator is UINavigationControllerDelegate {
23 | navigationController.delegate = self
24 | }
25 | super.removeChild(coordinator)
26 | }
27 | }
28 |
29 | extension NavigationCoordinator: UINavigationControllerDelegate {
30 |
31 | func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
32 | let childViewControllers = navigationController.viewControllers
33 | let transitionCoordinator = navigationController.transitionCoordinator
34 | let fromViewController = transitionCoordinator?.viewController(forKey: .from)
35 | guard let poppedViewController = fromViewController, !childViewControllers.contains(poppedViewController) else {
36 | return
37 | }
38 | removeFromParent()
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/Application/Source/Base/Concrete/TabBarCordinator.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TabBarCordinator.swift
3 | // Template
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import UIKit.UITabBarController
10 |
11 | class TabBarCoordinator: Coordinator {
12 |
13 | }
14 |
--------------------------------------------------------------------------------
/Application/Source/Base/Concrete/TypeErased/AnyCoordinatable.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AnyCoordinatable.swift
3 | // Template
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | /// A coordinatable that performs type erasure by wrapping another coordinatable.
10 | ///
11 | /// ``AnyCoordinatable`` is a concrete implementation of ``Coordinatable`` that has no significant
12 | /// properties of its own, and passes through all events to wrapped coordinatable.
13 | ///
14 | /// Use ``AnyCoordinatable`` to wrap a coordinator whose type has details you don’t want to expose
15 | /// across API boundaries, such as different modules. Wrapping a ``CoordinatorType`` with
16 | /// ``AnyCoordinatable`` also prevents callers from accessing its ``Coordinator.start()`` method.
17 | ///
18 | /// You can use ``Coordinatable/eraseToAnyCoordinatable()`` operator to wrap a publisher with ``AnyCoordinatable``.
19 |
20 | class AnyCoordinatable: Coordinatable {
21 |
22 | private let _navigationBlock: (Route) -> Void
23 |
24 | init(_ base: N) where N.Route == Route {
25 | _navigationBlock = base.coordinate
26 | }
27 |
28 | func coordinate(to route: Route) {
29 | _navigationBlock(route)
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/Application/Source/Base/Concrete/TypeErased/AnyCoordinator.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AnyCoordinator.swift
3 | // Template
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import UIKit.UIViewController
10 |
11 | struct AnyCoordinator {
12 |
13 | private let base: CoordinatorType
14 |
15 | init(_ base: CoordinatorType) {
16 | self.base = base
17 | }
18 |
19 | func removeChild(_ coordinator: CoordinatorType) {
20 | base.removeChild(coordinator)
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/Application/Source/Base/Protocols/Coordinatable.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Coordinatable.swift
3 | // Template
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | protocol Coordinatable: class {
10 | associatedtype Route
11 | func coordinate(to route: Route)
12 | }
13 |
14 | extension Coordinatable {
15 | func eraseToAnyCoordinatable() -> AnyCoordinatable {
16 | AnyCoordinatable(self)
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/Application/Source/Base/Protocols/CoordinatorType.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CoordinatorType.swift
3 | // Template
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import Combine
10 | import UIKit.UIViewController
11 |
12 | protocol ChildCoordinatorType {
13 | var parentCoordinator: AnyCoordinator? { get }
14 | }
15 |
16 | protocol CoordinatorType: class {
17 | var childCoordinators: [CoordinatorType] { get }
18 | var viewController: UIViewController { get }
19 | func start()
20 | func addChild(_ coordinator: CoordinatorType)
21 | func removeChild(_ coordinator: CoordinatorType)
22 | }
23 |
24 | extension CoordinatorType {
25 | func eraseToAnyCoordinator() -> AnyCoordinator {
26 | AnyCoordinator(self)
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/Application/Source/Base/Protocols/ModelType.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ModelType.swift
3 | // Template
4 | //
5 | // Created by Aarif Sumra on 2021/09/02.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | public protocol ModelType: Hashable {
10 |
11 | }
12 |
--------------------------------------------------------------------------------
/Application/Source/Base/Protocols/Scene.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Scene.swift
3 | // Template
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | public protocol Scene {
10 | associatedtype Route
11 | associatedtype View: ViewType
12 | associatedtype Dependencies
13 | var view: View { get }
14 | init(dependencies: Dependencies)
15 | }
16 |
--------------------------------------------------------------------------------
/Application/Source/Base/Protocols/ViewModelType.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ViewModelType.swift
3 | // Starter Template
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import Combine
11 |
12 | public protocol ViewModelType {
13 | associatedtype Input
14 | associatedtype Output
15 | func transform(_ input: Input) -> Output
16 | }
17 |
--------------------------------------------------------------------------------
/Application/Source/Base/Protocols/ViewType.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ViewType.swift
3 | // BoxOffice
4 | //
5 | // Created by Aarif Sumra on 2021/07/15.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import Combine
11 | import UIKit.UIView
12 |
13 | public protocol ViewType: class {
14 | associatedtype ViewModel: ViewModelType
15 | var view: UIView! { get }
16 | var viewModel: ViewModel! { get set }
17 |
18 | static func instantiate(with viewModel: ViewModel) -> Self
19 | func performBinding()
20 | }
21 |
22 | extension ViewType where Self: UIViewController & StoryboardInstantiatable {
23 |
24 | static func instantiate(with viewModel: ViewModel) -> Self {
25 | let storyboard = UIStoryboard(name: storyboardName, bundle: nil)
26 |
27 | if let identifier = storyboardIdentifier {
28 | guard let vc = storyboard.instantiateViewController(identifier: identifier) as? Self else {
29 | preconditionFailure("Unable to instantiate \(Self.self) from the storyboard named \(storyboardName) with identifer \(identifier)")
30 | }
31 | vc.viewModel = viewModel
32 | return vc
33 | }
34 |
35 | guard let vc = storyboard.instantiateInitialViewController() as? Self else {
36 | preconditionFailure("Unable to instantiate initial \(Self.self) from the storyboard named \(storyboardName)")
37 | }
38 | vc.viewModel = viewModel
39 | return vc
40 | }
41 | }
42 |
43 |
44 |
45 |
--------------------------------------------------------------------------------
/Application/Source/Extensions/Bundle.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Bundle.swift
3 | // Template
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | extension Bundle {
12 | var releaseVersionNumber: String? {
13 | return self.infoDictionary?["CFBundleShortVersionString"] as? String
14 | }
15 |
16 | var buildVersionNumber: String? {
17 | return self.infoDictionary?["CFBundleVersion"] as? String
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/Application/Source/Extensions/UIDevice.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIDevice.swift
3 | // Template
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import UIKit.UIDevice
10 |
11 | #if os(iOS) || os(tvOS)
12 | extension UIDevice {
13 |
14 | var modelName: String {
15 | var systemInfo = utsname()
16 | uname(&systemInfo)
17 | let machineMirror = Mirror(reflecting: systemInfo.machine)
18 | let identifier = machineMirror.children.reduce("") { identifier, element in
19 |
20 | guard let value = element.value as? Int8, value != 0 else { return identifier }
21 | return identifier + String(UnicodeScalar(UInt8(value)))
22 | }
23 | return identifier
24 | }
25 | }
26 | #endif
27 |
--------------------------------------------------------------------------------
/Application/Source/Extensions/UIScrollView+KeyboardContentInsettable.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIScrollView+KeyboardContentInsettable.swift
3 | // Template
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import Combine
10 | import UIKit
11 |
12 | protocol KeyboardContentInsettable {
13 | func registerForKeyboardEvents(with cancellables: inout Set)
14 | }
15 |
16 | extension UIScrollView: KeyboardContentInsettable {
17 | func registerForKeyboardEvents(with cancellables: inout Set) {
18 | NotificationCenter.Publisher(
19 | center: NotificationCenter.default,
20 | name: UIResponder.keyboardWillShowNotification
21 | )
22 | .compactMap({ $0.userInfo?["UIKeyboardFrameEndUserInfoKey"] as? CGRect })
23 | .map(\.height)
24 | .map { UIEdgeInsets(top: 0.0, left: 0.0, bottom: $0, right: 0.0)}
25 | .assign(to: \.contentInset, on: self)
26 | .store(in: &cancellables)
27 |
28 | NotificationCenter.Publisher(
29 | center: NotificationCenter.default,
30 | name: UIResponder.keyboardWillHideNotification
31 | )
32 | .map({_ in UIEdgeInsets.zero})
33 | .assign(to: \.contentInset, on: self)
34 | .store(in: &cancellables)
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/Application/Source/Scenes/Home/MainTabBarController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MainTabBarController.swift
3 | // Template
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | class MainTabBarController: UITabBarController {
12 |
13 | override func viewDidLoad() {
14 | super.viewDidLoad()
15 | tabBar.tintColor = .blue
16 | tabBar.barTintColor = .white
17 | tabBar.backgroundColor = .black
18 | tabBar.items?.forEach { item in
19 | item.imageInsets = UIEdgeInsets(top: 6, left: 0, bottom: -6, right: 0)
20 | item.titlePositionAdjustment = UIOffset(horizontal: 0, vertical: 8)
21 | }
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/Application/Source/Scenes/Home/MainTabBarCoordinator.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MainTabBarCoordinator.swift
3 | // Template
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import Domain
10 |
11 | import class UIKit.UINavigationController
12 | import class UIKit.UITabBarItem
13 | import class UIKit.UIImage
14 |
15 | final class MainTabBarCoordinator: TabBarCoordinator {
16 |
17 | private let useCaseProvider: UseCaseProvider
18 |
19 | init(useCaseProvider provider: UseCaseProvider) {
20 | self.useCaseProvider = provider
21 | super.init()
22 | }
23 |
24 | enum Tab {
25 | case postList
26 | }
27 |
28 | override func start() {
29 | [.postList].forEach(createTab)
30 | let viewControllers = childCoordinators.compactMap {
31 | $0.viewController
32 | }
33 | tabBarController.setViewControllers(viewControllers, animated: false)
34 | }
35 |
36 | private func createTab(_ tab: Tab) {
37 | let coordinator = makeCooordinator(forTab: tab)
38 | addChild(coordinator)
39 | coordinator.start()
40 | coordinator.viewController.tabBarItem = tab.tabBarItem
41 | }
42 |
43 | private func makeCooordinator(forTab tab: Tab) -> Coordinator {
44 | switch tab {
45 | case .postList:
46 | return PostListCoordinator(useCaseProvider: useCaseProvider)
47 | }
48 | }
49 | }
50 |
51 | extension MainTabBarCoordinator.Tab {
52 |
53 | var tabBarItem: UITabBarItem {
54 | switch self {
55 | case .postList:
56 | return UITabBarItem(title: "POSTs", systemName: "rectangle.grid.2x2.fill", tag: 1)
57 | }
58 | }
59 | }
60 |
61 | private extension UITabBarItem {
62 | convenience init(title: String, systemName: String, tag: Int) {
63 | self.init(
64 | title: title,
65 | image: UIImage(
66 | systemName: systemName,
67 | withConfiguration: UIImage.SymbolConfiguration(
68 | pointSize: 20,
69 | weight: .medium
70 | )
71 | )!,
72 | tag: tag
73 | )
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/Application/Source/Scenes/Home/Posts/Detail/PostDetail.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 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
52 |
58 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
--------------------------------------------------------------------------------
/Application/Source/Scenes/Home/Posts/Detail/PostDetailCoordinator.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PostDetailCoordinator.swift
3 | // Template
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import Domain
10 | import UIKit.UIViewController
11 |
12 | class PostDetailCoordinator: NavigationCoordinator {
13 |
14 | // MARK: - Properties
15 | private let useCaseProvider: UseCaseProvider
16 | private let postId: Int
17 |
18 | // MARK: - Init
19 | init(navigationController: UINavigationController, useCaseProvider: UseCaseProvider, postId: Int) {
20 | self.useCaseProvider = useCaseProvider
21 | self.postId = postId
22 | super.init(viewController: navigationController)
23 | }
24 |
25 | override func start() {
26 | super.start()
27 | let scene = PostDetailScene(
28 | dependencies: .init(
29 | postId: postId,
30 | coordinator: self.eraseToAnyCoordinatable(),
31 | useCaseProvider: useCaseProvider
32 | )
33 | )
34 | self.navigationController.pushViewController(scene.view, animated: true)
35 | }
36 | }
37 |
38 | extension PostDetailCoordinator: Coordinatable {
39 |
40 | func coordinate(to route: PostDetailScene.Route) {
41 |
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/Application/Source/Scenes/Home/Posts/Detail/PostDetailScene.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PostDetailScene.swift
3 | // Template
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import Domain
10 | import UIKit.UIViewController
11 | import UIKit.UIStoryboard
12 |
13 | final class PostDetailScene: Scene {
14 | // MARK: - Enums and Declarations
15 | enum Route {
16 | // This Coordinator has no `enum Route`.
17 | // It literally means that this is the last screen and no further navigation is possible,
18 | // though you can go back. 😉
19 | }
20 |
21 | struct Dependencies {
22 | let postId: Int
23 | let coordinator: AnyCoordinatable
24 | let useCaseProvider: UseCaseProvider
25 | }
26 |
27 | // MARK: - Properties
28 | let view: PostDetailViewController
29 |
30 | // MARK: - Init
31 | init(dependencies: Dependencies) {
32 | view = PostDetailViewController.instantiate(
33 | with: PostDetailViewModel(
34 | postId: dependencies.postId,
35 | coordinator: dependencies.coordinator,
36 | useCases: PostDetailViewModel.UseCases(
37 | getPostDetailUseCase: dependencies.useCaseProvider.makeGetPostDetailUseCase(),
38 | getPostCommentsUseCase: dependencies.useCaseProvider.makeGetPostCommentsUseCase(dependencies.postId))
39 | )
40 | )
41 | }
42 | }
43 |
44 |
--------------------------------------------------------------------------------
/Application/Source/Scenes/Home/Posts/Detail/PostDetailViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PostDetailViewController.swift
3 | // Template
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import Combine
11 | import Domain
12 |
13 | final class PostDetailViewController: UIViewController, StoryboardInstantiatable, ViewType {
14 |
15 | // MARK: - Statics
16 | static var storyboardName = "PostDetail"
17 |
18 | // MARK: - Enums and Type aliases
19 | enum Section {
20 | case main
21 | }
22 |
23 | typealias DataSource = UITableViewDiffableDataSource
24 | typealias Snapshot = NSDiffableDataSourceSnapshot
25 |
26 | // MARK: - Outlets
27 | @IBOutlet private weak var tableView: UITableView!
28 | @IBOutlet private var tableHeaderView: PostDetailHeaderView!
29 |
30 | // MARK: - Properties
31 | var viewModel: PostDetailViewModel!
32 |
33 | private weak var refreshControl: UIRefreshControl!
34 | private weak var noResultsLabel: UILabel!
35 |
36 | private var dataSource: DataSource!
37 | private var snapshot: Snapshot = .init()
38 |
39 | private var cancellables = Set()
40 |
41 | override func viewDidLoad() {
42 | super.viewDidLoad()
43 |
44 | let nib = UINib(nibName: "CommentTableViewCell", bundle: nil)
45 | tableView.register(nib, forCellReuseIdentifier: "commentCell")
46 |
47 | setupRefreshControl()
48 | setupNoResults()
49 | setupDataSource()
50 | layoutHeaderView()
51 | performBinding()
52 | }
53 |
54 | override func viewDidLayoutSubviews() {
55 | super.viewDidLayoutSubviews()
56 | tableHeaderView.layoutIfNeeded()
57 | }
58 |
59 | func performBinding() {
60 | assert(viewModel != nil)
61 |
62 | let viewModelInput = ViewModel.Input(
63 | refresh: Publishers.ControlEvent(control: refreshControl, events: .valueChanged)
64 | .map { _ in () }
65 | .prepend(())
66 | .eraseToAnyPublisher(),
67 | loadData: Just(()).eraseToAnyPublisher()
68 | )
69 |
70 | let viewModelOutput = viewModel.transform(viewModelInput)
71 |
72 | viewModelOutput.result.receive(on: RunLoop.main)
73 | .sink(receiveValue: { result in
74 | switch result {
75 | case .success(let item):
76 | self.tableHeaderView.titleLabel.text = item.title
77 | self.tableHeaderView.descriptionLabel.text = item.body
78 | case .failure(_):
79 | print("No posts found")
80 | }
81 | }).store(in: &cancellables)
82 |
83 | viewModelOutput.comments.receive(on: RunLoop.main)
84 | .sink(receiveValue: { [weak self] result in
85 | switch result {
86 | case .success(let items):
87 | self?.updateTable(with: items)
88 | self?.refreshControl.endRefreshing()
89 | if items.isEmpty {
90 | self?.noResultsLabel.isHidden = false
91 | }
92 | case .failure(_):
93 | print("No posts found")
94 | }
95 | }).store(in: &cancellables)
96 |
97 | }
98 |
99 | private func layoutHeaderView() {
100 | tableHeaderView.translatesAutoresizingMaskIntoConstraints = false
101 | tableView.tableHeaderView = tableHeaderView
102 | NSLayoutConstraint.activate([
103 | tableHeaderView.topAnchor.constraint(equalTo: tableView.topAnchor),
104 | tableHeaderView.widthAnchor.constraint(equalTo: tableView.widthAnchor),
105 | tableHeaderView.centerXAnchor.constraint(equalTo: tableView.centerXAnchor)])
106 | tableHeaderView.layoutIfNeeded()
107 | tableView.tableHeaderView = tableHeaderView
108 | }
109 | }
110 |
111 |
112 | private extension PostDetailViewController {
113 |
114 | func setupRefreshControl() {
115 | let refreshControl = UIRefreshControl()
116 | refreshControl.backgroundColor = .clear
117 | refreshControl.tintColor = .lightGray
118 | tableView.refreshControl = refreshControl
119 | self.refreshControl = refreshControl
120 | }
121 |
122 | func setupNoResults() {
123 | let label = UILabel()
124 | label.text = "No Posts Found!\n Please try different name again..."
125 | label.sizeToFit()
126 | label.isHidden = true
127 | tableView.backgroundView = label
128 | noResultsLabel = label
129 | }
130 |
131 | func setupDataSource() {
132 | snapshot.appendSections([.main])
133 | dataSource = DataSource(
134 | tableView: tableView,
135 | cellProvider: { (tableView, indexPath, item) -> UITableViewCell? in
136 | guard let cell = tableView.dequeueReusableCell(withIdentifier: "commentCell") as? CommentTableViewCell else {
137 | return UITableViewCell()
138 | }
139 | cell.bodyLabel.text = item.body
140 | cell.nameLabel.text = item.name
141 | cell.emailLabel.text = item.email
142 | return cell
143 | })
144 | }
145 |
146 | func updateTable(with items: [CommentEntity]) {
147 | snapshot.appendItems(items)
148 | dataSource.apply(snapshot)
149 | }
150 | }
151 |
152 |
153 |
154 | final class PostDetailHeaderView: UIView {
155 | @IBOutlet weak var titleLabel: UILabel!
156 | @IBOutlet weak var descriptionLabel: UILabel!
157 | }
158 |
--------------------------------------------------------------------------------
/Application/Source/Scenes/Home/Posts/Detail/PostDetailViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PostDetailViewModel.swift
3 | // Template
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import Combine
10 | import Domain
11 | import Platform
12 |
13 | class PostDetailViewModel: ViewModelType {
14 | // MARK: - Input
15 | struct Input {
16 | // Actions
17 | let refresh: AnyPublisher
18 | let loadData: AnyPublisher
19 | }
20 | // MARK: - Output
21 | struct Output {
22 | let result: AnyPublisher, Never>
23 | let comments: AnyPublisher, Never>
24 | }
25 |
26 | // MARK: - Properties
27 | private let postId: Int
28 | private var coordinator: AnyCoordinatable?
29 | private let useCases: UseCases
30 | private var cancellables = Set()
31 |
32 | // MARK: - Init
33 | init(postId: Int, coordinator: AnyCoordinatable?, useCases: UseCases) {
34 | self.postId = postId
35 | self.coordinator = coordinator
36 | self.useCases = useCases
37 | }
38 |
39 | // MARK: - I/O Transformer
40 | func transform(_ input: Input) -> Output {
41 | Output(
42 | result: input.loadData
43 | .flatMap { [unowned self]_ in
44 | self.useCases.getPostDetailUseCase.executePublisher(self.postId)
45 | .map {
46 | Result.success($0)
47 | }
48 | .catch { error -> Just> in
49 | return Just(.failure(error))
50 | }
51 |
52 | }
53 | .eraseToAnyPublisher(),
54 | comments: input.refresh
55 | .flatMap { [unowned self]_ in
56 | self.useCases.getPostCommentsUseCase.executePublisher(self.postId)
57 | .map {
58 | Result<[CommentEntity],Error>.success($0)
59 | }
60 | .catch { error -> Just> in
61 | return Just(.failure(error))
62 | }
63 |
64 | }
65 | .eraseToAnyPublisher()
66 | )
67 | }
68 | }
69 |
70 | extension PostDetailViewModel {
71 | struct UseCases {
72 | let getPostDetailUseCase: AnyUseCase
73 | let getPostCommentsUseCase: AnyUseCase
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/Application/Source/Scenes/Home/Posts/Detail/Views/CommentTableViewCell.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CommentTableViewCell.swift
3 | // Template
4 | //
5 | // Created by Aarif Sumra on 2021/09/03.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | class CommentTableViewCell: UITableViewCell {
12 |
13 | @IBOutlet weak var bodyLabel: UILabel!
14 | @IBOutlet weak var nameLabel: UILabel!
15 | @IBOutlet weak var emailLabel: UILabel!
16 |
17 |
18 | override func awakeFromNib() {
19 | super.awakeFromNib()
20 | // Initialization code
21 | }
22 |
23 | override func setSelected(_ selected: Bool, animated: Bool) {
24 | super.setSelected(selected, animated: animated)
25 |
26 | // Configure the view for the selected state
27 | }
28 |
29 | }
30 |
--------------------------------------------------------------------------------
/Application/Source/Scenes/Home/Posts/Detail/Views/CommentTableViewCell.xib:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
26 |
27 |
28 |
29 |
35 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
--------------------------------------------------------------------------------
/Application/Source/Scenes/Home/Posts/List/PostList.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 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
--------------------------------------------------------------------------------
/Application/Source/Scenes/Home/Posts/List/PostListCoordinator.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PostCoordinator.swift
3 | // Template
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import Domain
10 | import class UIKit.UIViewController
11 |
12 | class PostListCoordinator: NavigationCoordinator {
13 |
14 | // MARK: - Properties
15 | private let useCaseProvider: UseCaseProvider
16 |
17 | // MARK: - Init
18 | init(useCaseProvider: UseCaseProvider) {
19 | self.useCaseProvider = useCaseProvider
20 | super.init()
21 | }
22 |
23 | // MARK: - Start and show root scene
24 | override func start() {
25 | super.start()
26 | let scene = PostListScene(
27 | dependencies: .init(
28 | coordinator: self.eraseToAnyCoordinatable(),
29 | useCaseProvider: useCaseProvider
30 | )
31 | )
32 | self.navigationController.setViewControllers([scene.view], animated: false)
33 | }
34 | }
35 |
36 | // MARK: - ViewModel → Coordinator Callbacks
37 | extension PostListCoordinator: Coordinatable {
38 |
39 | func coordinate(to route: PostListScene.Route) {
40 | switch route {
41 | case .detail(let id):
42 | showPostDetail(id: id)
43 | }
44 | }
45 |
46 | private func showPostDetail(id: Int) {
47 | let coordinator = PostDetailCoordinator(
48 | navigationController: navigationController,
49 | useCaseProvider: useCaseProvider,
50 | postId: id
51 | )
52 | addChild(coordinator)
53 | coordinator.parentCoordinator = self.eraseToAnyCoordinator()
54 | coordinator.start()
55 | }
56 | }
57 |
58 |
--------------------------------------------------------------------------------
/Application/Source/Scenes/Home/Posts/List/PostListScene.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PostListScene.swift
3 | // Template
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import Domain
10 | import class UIKit.UIViewController
11 |
12 | final class PostListScene: Scene {
13 |
14 | // MARK: - Enums and Declarations
15 | enum Route {
16 | case detail(id: Int)
17 | }
18 |
19 | struct Dependencies {
20 | let coordinator: AnyCoordinatable
21 | let useCaseProvider: UseCaseProvider
22 | }
23 |
24 | // MARK: - Properties
25 | let view: PostListViewController
26 |
27 | // MARK: - Init
28 | init(dependencies: Dependencies) {
29 | let useCase = dependencies.useCaseProvider.makeGetPostsUseCase()
30 | view = PostListViewController.instantiate(
31 | with: PostListViewModel(
32 | coordinator: dependencies.coordinator,
33 | useCase: useCase
34 | )
35 | )
36 | }
37 | }
38 |
39 |
--------------------------------------------------------------------------------
/Application/Source/Scenes/Home/Posts/List/PostListViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PostListViewController.swift
3 | // Template
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import Combine
11 | import CombineCocoa
12 | import Domain
13 |
14 | final class PostListViewController: UIViewController, StoryboardInstantiatable, ViewType {
15 |
16 | // MARK: - Statics
17 | static var storyboardName = "PostList"
18 |
19 | // MARK: - Enums and Type aliases
20 | enum Section {
21 | case main
22 | }
23 |
24 | typealias DataSource = UITableViewDiffableDataSource
25 | typealias Snapshot = NSDiffableDataSourceSnapshot
26 |
27 | // MARK: - Outlets
28 | @IBOutlet private weak var tableView: UITableView!
29 |
30 | // MARK: - Properties
31 | var viewModel: PostListViewModel!
32 |
33 | private weak var refreshControl: UIRefreshControl!
34 | private weak var noResultsLabel: UILabel!
35 |
36 | private var dataSource: DataSource!
37 | private var snapshot: Snapshot = .init()
38 |
39 | private var cancellables = Set()
40 | private var itemSelectedPublisher: PassthroughSubject = .init()
41 |
42 | // MARK: - View Lifecycle -
43 | override func viewDidLoad() {
44 | super.viewDidLoad()
45 |
46 | tableView.delegate = self
47 | setupRefreshControl()
48 | setupNoResults()
49 | setupDataSource()
50 | performBinding()
51 | }
52 |
53 | func performBinding() {
54 | assert(viewModel != nil)
55 | let viewModelInput = PostListViewModel.Input(
56 | refresh: Publishers.ControlEvent(control: refreshControl, events: .valueChanged)
57 | .map { _ in () }
58 | .prepend(())
59 | .eraseToAnyPublisher(),
60 | itemSelected: itemSelectedPublisher
61 | .eraseToAnyPublisher(),
62 | loadMore: tableView.reachedBottomPublisher()
63 | .debounce(for: 0.1, scheduler: RunLoop.main)
64 | .eraseToAnyPublisher()
65 | )
66 |
67 | let viewModelOutput = viewModel?.transform(viewModelInput)
68 |
69 | viewModelOutput?.results.receive(on: DispatchQueue.main)
70 | .sink(receiveValue: { [weak self] values in
71 | self?.updateTable(with: values)
72 | self?.refreshControl.endRefreshing()
73 | if values.isEmpty {
74 | self?.noResultsLabel.isHidden = false
75 | }
76 | }).store(in: &cancellables)
77 | }
78 | }
79 |
80 | extension PostListViewController: UITableViewDelegate {
81 | func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
82 | guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
83 | itemSelectedPublisher.send(item.id)
84 | }
85 | }
86 |
87 | private extension PostListViewController {
88 |
89 | func setupRefreshControl() {
90 | let refreshControl = UIRefreshControl()
91 | refreshControl.backgroundColor = .clear
92 | refreshControl.tintColor = .lightGray
93 | tableView.refreshControl = refreshControl
94 | self.refreshControl = refreshControl
95 | }
96 |
97 | func setupNoResults() {
98 | let label = UILabel()
99 | label.text = "No Posts Found!\n Please try different name again..."
100 | label.sizeToFit()
101 | label.isHidden = true
102 | tableView.backgroundView = label
103 | noResultsLabel = label
104 | }
105 |
106 | func setupDataSource() {
107 | snapshot.appendSections([.main])
108 | dataSource = DataSource(
109 | tableView: tableView,
110 | cellProvider: { (tableView, indexPath, item) -> UITableViewCell? in
111 | let cell = UITableViewCell(style: .subtitle, reuseIdentifier: "cell") // For simplicity
112 | cell.textLabel?.text = item.title
113 | cell.detailTextLabel?.text = item.body
114 | return cell
115 | })
116 | }
117 |
118 | func updateTable(with items: [PostEntity]) {
119 | snapshot.appendItems(items)
120 | dataSource.apply(snapshot)
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/Application/Source/Scenes/Home/Posts/List/PostListViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PostListViewModel.swift
3 | // Template
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import Combine
10 | import Domain
11 | import Platform
12 |
13 | class PostListViewModel: ViewModelType {
14 | // MARK: - Input
15 | struct Input {
16 | let refresh: AnyPublisher
17 | let itemSelected: AnyPublisher
18 | let loadMore: AnyPublisher
19 | }
20 | // MARK: - Output
21 | struct Output {
22 | let results: AnyPublisher<[PostEntity], Never>
23 | }
24 |
25 | // MARK: - Properties
26 | private let coordinator: AnyCoordinatable?
27 | private let useCase: AnyUseCase
28 | private var cancellables = Set()
29 |
30 | // MARK: - Init
31 | init(coordinator: AnyCoordinatable, useCase: AnyUseCase) {
32 | self.coordinator = coordinator
33 | self.useCase = useCase
34 | }
35 |
36 | // MARK: - I/O Transformer
37 | func transform(_ input: Input) -> Output {
38 | // Item selection handeled locally
39 | input.itemSelected.sink(receiveValue: { [weak self] id in
40 | self?.coordinator?.coordinate(to: .detail(id: id))
41 | }).store(in: &cancellables)
42 |
43 | return Output(
44 | results: input.refresh
45 | .flatMap { _ in
46 | self.useCase.executePublisher(())
47 | .catch { _ -> Just<[PostEntity]> in
48 | // TODO: Handle Error
49 | return Just([])
50 | }
51 | }
52 | .eraseToAnyPublisher()
53 | )
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/Application/Source/Scenes/Login/Login.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 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
--------------------------------------------------------------------------------
/Application/Source/Scenes/Login/LoginCoordinator.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LoginCoordinator.swift
3 | // Template
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import Domain
10 |
11 | class LoginCoordinator: NavigationCoordinator {
12 |
13 | // MARK: - Properties
14 | private let useCaseProvider: UseCaseProvider
15 |
16 | // MARK: - Init
17 | init(useCaseProvider: UseCaseProvider) {
18 | self.useCaseProvider = useCaseProvider
19 | super.init()
20 | }
21 |
22 | // MARK: - Start and show root scene
23 | override func start() {
24 | super.start()
25 | let scene = LoginScene(
26 | dependencies: .init(
27 | coordinator: self.eraseToAnyCoordinatable(),
28 | useCaseProvider: useCaseProvider)
29 | )
30 | self.navigationController.setViewControllers([scene.view], animated: false)
31 | }
32 | }
33 |
34 | // MARK: - ViewModel → Coordinator Callbacks
35 | extension LoginCoordinator: Coordinatable {
36 |
37 | func coordinate(to route: LoginScene.Route) {
38 | switch route {
39 | case .signUp:
40 | showSignUp()
41 | case .forgotPassword:
42 | showForgotPassword()
43 | }
44 | }
45 |
46 | private func showSignUp() {
47 | }
48 |
49 | private func showForgotPassword() {
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/Application/Source/Scenes/Login/LoginScene.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LoginScene.swift
3 | // Template
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import Domain
10 |
11 | final class LoginScene: Scene {
12 | // MARK: - Enums and Declarations
13 | enum Route {
14 | case signUp
15 | case forgotPassword
16 | }
17 |
18 | struct Dependencies {
19 | let coordinator: AnyCoordinatable
20 | let useCaseProvider: UseCaseProvider
21 | }
22 |
23 | // MARK: - Properties
24 | let view: LoginViewController
25 |
26 | // MARK: - Init
27 | init(dependencies: Dependencies) {
28 | view = LoginViewController.instantiate(
29 | with: LoginViewModel(
30 | coordinator: dependencies.coordinator,
31 | useCase: dependencies.useCaseProvider.makeDoLoginUseCase()
32 | )
33 | )
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/Application/Source/Scenes/Login/LoginViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LoginViewController.swift
3 | // Template
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import Combine
11 | import CombineCocoa
12 |
13 | final class LoginViewController: UIViewController, StoryboardInstantiatable, ViewType {
14 |
15 | // MARK: - Statics
16 | static var storyboardName = "Login"
17 |
18 | // MARK: - Outlets
19 | @IBOutlet private weak var userNameTextField: UITextField!
20 | @IBOutlet private weak var passwordTextField: UITextField!
21 | @IBOutlet private weak var loginButton: UIButton!
22 | @IBOutlet private weak var scrollView: UIScrollView!
23 |
24 | // MARK: - Properties
25 | var viewModel: LoginViewModel!
26 | private var cancellables = Set()
27 |
28 | override func viewDidLoad() {
29 | super.viewDidLoad()
30 | performBinding()
31 | }
32 |
33 | func performBinding() {
34 | let viewModelInput = ViewModel.Input(
35 | username: userNameTextField.textPublisher,
36 | password: passwordTextField.textPublisher,
37 | doLogin: loginButton.tapPublisher
38 | )
39 |
40 | let viewModelOutput = viewModel.transform(viewModelInput)
41 |
42 | viewModelOutput.enableLogin.prepend(false)
43 | .receive(on: RunLoop.main)
44 | .assign(to: \.isEnabled, on: loginButton)
45 | .store(in: &cancellables)
46 |
47 | scrollView.registerForKeyboardEvents(with: &cancellables)
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/Application/Source/Scenes/Login/LoginViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LoginViewModel.swift
3 | // Template
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import Combine
10 | import Domain
11 |
12 | class LoginViewModel: ViewModelType {
13 |
14 | // MARK: - Input
15 | struct Input {
16 | // Data
17 | let username: AnyPublisher
18 | let password: AnyPublisher
19 | // Actions
20 | let doLogin: AnyPublisher
21 | }
22 | // MARK: - Output
23 | struct Output {
24 | let enableLogin: AnyPublisher
25 | }
26 |
27 | private let coordinator: AnyCoordinatable?
28 | private let useCase: AnyUseCase<(username: String, password: String), Bool>
29 |
30 |
31 | // MARK: - Init
32 | init(coordinator: AnyCoordinatable, useCase: AnyUseCase<(username: String, password: String), Bool>) {
33 | self.coordinator = coordinator
34 | self.useCase = useCase
35 | }
36 |
37 | // MARK: - I/O Transformer
38 | func transform(_ input: Input) -> Output {
39 | return Output(
40 | enableLogin: Publishers.CombineLatest(input.username, input.password)
41 | .map { username, password in
42 | (username?.isEmailValid() ?? false) && password?.count ?? 0 > 7
43 | }
44 | .eraseToAnyPublisher()
45 | )
46 | }
47 | }
48 |
49 | private extension String {
50 |
51 | private var emailPredicate: NSPredicate {
52 | let userid = "[A-Z0-9a-z._%+-]{1,}"
53 | let domain = "([A-Z0-9a-z._%+-]{1,}\\.){1,}"
54 | let regex = userid + "@" + domain + "[A-Za-z]{1,}"
55 | return NSPredicate(format: "SELF MATCHES %@", regex)
56 | }
57 |
58 | func isEmailValid() -> Bool {
59 | return emailPredicate.evaluate(with: self)
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/Application/Source/Utilities/StoryboardInstantiatable.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StoryboardInstantiatable.swift
3 | // BoxOffice
4 | //
5 | // Created by Aarif Sumra on 2021/07/16.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public protocol StoryboardInstantiatable: class {
12 | static var storyboardName: String { get }
13 | static var storyboardIdentifier: String? { get }
14 | static var storyboardBundle: Bundle? { get }
15 | }
16 |
17 | extension StoryboardInstantiatable {
18 | static var storyboardIdentifier: String? { return nil }
19 | static var storyboardBundle: Bundle? { return nil }
20 | }
21 |
--------------------------------------------------------------------------------
/ApplicationTests/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | 1
21 |
22 |
23 |
--------------------------------------------------------------------------------
/ApplicationTests/Login/LoginViewControllerTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LoginViewControllerTests.swift
3 | // TemplateTests
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import XCTest
10 | import Combine
11 | import Domain
12 | @testable import Template
13 |
14 | private class LoginViewControllerMirror: MirrorObject {
15 |
16 | var loginHeaderLabel: UILabel! { extract() }
17 | var userNameTextField: UITextField! { extract() }
18 | var passwordTextField: UITextField! { extract() }
19 | var loginButton: UIButton! { extract() }
20 | var loginFooterLabel: UILabel! { extract() }
21 | var scrollView: UIScrollView! { extract() }
22 |
23 | init(viewController: LoginViewController) {
24 | super.init(subject: viewController)
25 | }
26 | }
27 |
28 | class LoginViewControllerTests: XCTestCase {
29 |
30 | private var sut: LoginViewController!
31 | private var sutMirror: LoginViewControllerMirror!
32 | private var mockViewModel: MockLoginViewModel!
33 |
34 | override func setUp() {
35 | super.setUp()
36 | // Put setup code here. This method is called before the invocation of each test method in the class.
37 | let storyboard = UIStoryboard(name: "Login", bundle: nil)
38 | sut = storyboard.instantiateInitialViewController() as? LoginViewController
39 | sutMirror = LoginViewControllerMirror(viewController: sut)
40 | mockViewModel = MockLoginViewModel(
41 | coordinator: MockCoordinator().eraseToAnyCoordinatable(),
42 | useCase: MockUseCase().eraseToAnyUseCase()
43 | )
44 | sut.viewModel = mockViewModel
45 | sut.loadViewIfNeeded()
46 | }
47 |
48 | override func tearDown() {
49 | // Put teardown code here. This method is called after the invocation of each test method in the class.
50 | super.tearDown()
51 | }
52 |
53 | func test_UserNameTextField_Setup() throws {
54 | let textField = try XCTUnwrap(sutMirror.userNameTextField, "userNameTextField is not connected")
55 | // Configurations
56 | XCTAssertEqual(textField.textContentType, UITextContentType.emailAddress, "userNameTextField does not have an Email Address Content Type set")
57 | XCTAssertEqual(textField.placeholder, "Email", "userNameTextField placeholder is not correct or set")
58 | }
59 |
60 | func test_UserNameTextField_Bindings() throws {
61 | weak var textExpectation = expectation(description: "should received text")
62 | let expectedValue = "Sample Text"
63 | var result = ""
64 | let subscription = mockViewModel.username.compactMap { $0 }
65 | .sink { text in
66 | result = text
67 | if !result.isEmpty {
68 | textExpectation?.fulfill()
69 | }
70 | }
71 | sutMirror.userNameTextField.text = "Sample Text"
72 | sutMirror.userNameTextField.sendActions(for: .editingChanged)
73 | waitForExpectations(timeout: 1) { error in
74 | if error != nil {
75 | XCTFail(error.debugDescription)
76 | subscription.cancel()
77 | }
78 | }
79 | XCTAssertEqual(result, expectedValue)
80 | subscription.cancel()
81 | }
82 |
83 | func test_PasswordTextField_Setup() throws {
84 | let textField = try XCTUnwrap(sutMirror.passwordTextField, "passwordTextField is not connected")
85 | XCTAssertEqual(textField.textContentType, UITextContentType.password, "passwordTextField does not have an Password Content Type set")
86 | XCTAssertEqual(textField.placeholder, "Password", "passwordTextField placeholder is not correct or set")
87 | XCTAssertTrue(textField.isSecureTextEntry, "passwordTextField is not a Secure Text Entry Field")
88 | }
89 |
90 | func test_PasswordTextField_Bindings() throws {
91 | let textExpectation = expectation(description: "should received text")
92 | let expectedValue = "Sample Text"
93 | var result = ""
94 | let subscription = mockViewModel.username.compactMap { $0 }
95 | .sink { text in
96 | result = text
97 | if !result.isEmpty {
98 | textExpectation.fulfill()
99 | }
100 | }
101 | sutMirror.userNameTextField.text = "Sample Text"
102 | sutMirror.userNameTextField.sendActions(for: .editingChanged)
103 | waitForExpectations(timeout: 1) { error in
104 | if error != nil {
105 | XCTFail(error.debugDescription)
106 | subscription.cancel()
107 | }
108 | }
109 | XCTAssertEqual(result, expectedValue)
110 | subscription.cancel()
111 | }
112 |
113 | func test_LoginButton_Setup() throws {
114 | let button = try XCTUnwrap(sutMirror.loginButton, "loginButton is not connected")
115 | XCTAssertEqual(button.titleLabel?.text, "Login", "loginButton does not have an Password Content Type set")
116 | XCTAssertTrue(button.isEnabled, "loginButton is disabled when loaded")
117 | }
118 |
119 | func test_LoginButton_Bindings() throws {
120 | let tapExpectation = expectation(description: "should received tap event")
121 | let expectedValue = 1
122 | var tapCount = 0
123 | let subscription = mockViewModel.doLogin.sink { _ in
124 | tapCount += 1
125 | if tapCount > 0 {
126 | tapExpectation.fulfill()
127 | }
128 | }
129 | sutMirror.loginButton.sendActions(for: .touchUpInside)
130 | waitForExpectations(timeout: 1) { error in
131 | if error != nil {
132 | XCTFail(error.debugDescription)
133 | subscription.cancel()
134 | }
135 | }
136 | XCTAssertEqual(tapCount, expectedValue)
137 | subscription.cancel()
138 | }
139 |
140 | // func testPerformanceExample() throws {
141 | // // This is an example of a performance test case.
142 | // self.measure {
143 | // // Put the code you want to measure the time of here.
144 | // }
145 | // }
146 | }
147 |
148 | private class MockLoginViewModel: LoginViewModel {
149 | // Data
150 | var username: AnyPublisher!
151 | var password: AnyPublisher!
152 | // Actions
153 | var doLogin: AnyPublisher!
154 |
155 | override func transform(_ input: LoginViewModel.Input) -> LoginViewModel.Output {
156 | username = input.username
157 | password = input.password
158 | doLogin = input.doLogin
159 | return super.transform(input)
160 | }
161 | }
162 |
163 | private class MockCoordinator: NavigationCoordinator, Coordinatable {
164 | func coordinate(to route: LoginScene.Route) {
165 |
166 | }
167 | }
168 |
169 | private class MockUseCase: UseCase {
170 | func execute(_ parameters: (username: String, password: String), completion: ((Result) -> Void)?) {
171 |
172 | }
173 | }
174 |
--------------------------------------------------------------------------------
/ApplicationTests/Login/LoginViewModelTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LoginViewModelTests.swift
3 | // TemplateTests
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import XCTest
10 | import Combine
11 | import Domain
12 | @testable import Template
13 |
14 | class LoginViewModelTests: XCTestCase {
15 |
16 | var sut: LoginViewModel!
17 | var cancellables: Set!
18 |
19 | private var username: PassthroughSubject!
20 | private var password: PassthroughSubject!
21 | private var loginTap: PassthroughSubject!
22 | private var input: LoginViewModel.Input!
23 | private var output: LoginViewModel.Output!
24 |
25 | override func setUp() {
26 | // Put setup code here. This method is called before the invocation of each test method in the class.
27 | super.setUp()
28 | cancellables = []
29 | sut = LoginViewModel(
30 | coordinator: MockCoordinator().eraseToAnyCoordinatable(),
31 | useCase: MockUseCase().eraseToAnyUseCase()
32 | )
33 | username = PassthroughSubject()
34 | password = PassthroughSubject()
35 | loginTap = PassthroughSubject()
36 | input = LoginViewModel.Input(
37 | username: username.eraseToAnyPublisher(),
38 | password: password.eraseToAnyPublisher(),
39 | doLogin: loginTap.eraseToAnyPublisher()
40 | )
41 | output = sut.transform(input)
42 | }
43 |
44 | override func tearDown() {
45 | // Put teardown code here. This method is called after the invocation of each test method in the class.
46 | super.tearDown()
47 | }
48 |
49 | func testEnablesLoginButtonWhenUsernameIsEmail() {
50 | let receivedAllValues = expectation(description: "received all values")
51 | let expectedValues = [false, false, false, true]
52 | var result = [Bool]()
53 |
54 | output.enableLogin.sink { enabled in
55 | result.append(enabled)
56 | if enabled {
57 | receivedAllValues.fulfill()
58 | }
59 | }.store(in: &cancellables)
60 |
61 | password.send("xxxxxxxxx") // Valid Password Count > 6
62 |
63 | username.send("a")
64 | username.send("a@b")
65 | username.send("a@b.")
66 | username.send("a@b.c")
67 |
68 | waitForExpectations(timeout: 1) { error in
69 | if error != nil {
70 | XCTFail(error.debugDescription)
71 | }
72 | }
73 |
74 | XCTAssert(result.elementsEqual(expectedValues))
75 | }
76 |
77 | func testEnablesLoginButtonWhenPasswordCountMoreThanSix() {
78 | let receivedAllValues = expectation(description: "received all values")
79 | let expectedValues = [false, true]
80 | var result = [Bool]()
81 |
82 | output.enableLogin.sink { enabled in
83 | result.append(enabled)
84 | if enabled {
85 | receivedAllValues.fulfill()
86 | }
87 | }.store(in: &cancellables)
88 |
89 | username.send("a@b.c") // Valid Email Format
90 |
91 | password.send("1234567")
92 | password.send("12345678")
93 |
94 | print(result)
95 |
96 | waitForExpectations(timeout: 1) { error in
97 | if error != nil {
98 | XCTFail(error.debugDescription)
99 | }
100 | }
101 |
102 | XCTAssert(result.elementsEqual(expectedValues), "Validates Email")
103 | }
104 |
105 | func testPerformanceExample() throws {
106 | // This is an example of a performance test case.
107 | self.measure {
108 | // Put the code you want to measure the time of here.
109 | }
110 | }
111 | }
112 |
113 | private class MockCoordinator: NavigationCoordinator, Coordinatable {
114 | func coordinate(to route: LoginScene.Route) {
115 |
116 | }
117 | }
118 |
119 | private class MockUseCase: UseCase {
120 | func execute(_ parameters: (username: String, password: String), completion: ((Result) -> Void)?) {
121 |
122 | }
123 | }
124 |
--------------------------------------------------------------------------------
/ApplicationTests/MirrorObject.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MirrorObject.swift
3 | // TemplateTests
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | class MirrorObject {
10 | let mirror: Mirror
11 |
12 | init(subject: Any) {
13 | self.mirror = Mirror(reflecting: subject)
14 | }
15 |
16 | func extract(variableName: StaticString = #function) -> T? {
17 | return mirror.descendant("\(variableName)") as? T
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/ApplicationTests/PostListViewModelTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PostListViewModelTests.swift
3 | // TemplateTests
4 | //
5 | // Created by Aarif Sumra on 2021/09/02.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import XCTest
10 | import Combine
11 | import Domain
12 | @testable import Template
13 |
14 | class PostListViewModelTests: XCTestCase {
15 |
16 | var sut: PostListViewModel!
17 | var cancellables: Set!
18 |
19 | private var refresh: PassthroughSubject!
20 | private var itemSelected: PassthroughSubject!
21 | private var loadMore: PassthroughSubject!
22 | private var input: PostListViewModel.Input!
23 | private var output: PostListViewModel.Output!
24 |
25 | override func setUp() {
26 | // Put setup code here. This method is called before the invocation of each test method in the class.
27 | super.setUp()
28 | cancellables = []
29 | sut = PostListViewModel(
30 | coordinator: MockCoordinator().eraseToAnyCoordinatable(),
31 | useCase: MockUseCase().eraseToAnyUseCase()
32 | )
33 |
34 | refresh = PassthroughSubject()
35 | itemSelected = PassthroughSubject()
36 | loadMore = PassthroughSubject()
37 |
38 | input = PostListViewModel.Input(
39 | refresh: refresh.eraseToAnyPublisher(),
40 | itemSelected: itemSelected.eraseToAnyPublisher(),
41 | loadMore: loadMore.eraseToAnyPublisher()
42 | )
43 | output = sut.transform(input)
44 | }
45 |
46 | override func tearDown() {
47 | // Put teardown code here. This method is called after the invocation of each test method in the class.
48 | }
49 |
50 | }
51 |
52 | private class MockCoordinator: NavigationCoordinator, Coordinatable {
53 | func coordinate(to route: PostListScene.Route) {
54 |
55 | }
56 | }
57 |
58 | private class MockUseCase: UseCase {
59 | func execute(_ parameters: Void, completion: ((Result<[PostEntity], Error>) -> Void)?) {
60 |
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/Domain/CombineSupport/Networking+Combine.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Networking+Combine.swift
3 | // Platform
4 | //
5 | // Created by Aarif Sumra on 2021/06/03.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | #if canImport(Combine)
10 | import Combine
11 |
12 | extension Networking {
13 | /// Returns a publisher that wraps a network requestl.
14 | ///
15 | /// The publisher publishes data when the task completes, or terminates if the task fails with an error.
16 | /// - Parameter request: The URL request for which to send network request
17 | /// - Returns: A type erased publisher that wraps data result
18 | func sendRequestPublisher(_ request: URLRequest) -> AnyPublisher, Never> {
19 | Future { promise in
20 | self.send(request) { result in
21 | promise(.success(result))
22 | }
23 | }.eraseToAnyPublisher()
24 | }
25 | }
26 | #endif
27 |
--------------------------------------------------------------------------------
/Domain/CombineSupport/Repository+Combine.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Repository+Combine.swift
3 | // Platform
4 | //
5 | // Created by Aarif Sumra on 2021/06/02.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | #if canImport(Combine)
10 |
11 | import Combine
12 |
13 | extension Repository {
14 | /// Returns a publisher that wraps a repository request to fetch all.
15 | ///
16 | /// The publisher publishes data when the task completes, or terminates if the task fails with an error.
17 | /// - Parameter id: The id of entity to be fetched.
18 | /// - Returns: A publisher that wraps repository fetch result with entity array
19 | func fetchAllPublisher() -> AnyPublisher<[T], RepositoryError> {
20 | Future(fetchAll).eraseToAnyPublisher()
21 | }
22 | /// Returns a publisher that wraps a repository request for a given ID.
23 | ///
24 | /// The publisher publishes data when the task completes, or terminates if the task fails with an error.
25 | /// - Parameter id: The id of entity to be fetched.
26 | /// - Returns: A publisher that wraps repository fetch result with entity
27 | func fetchPublisher(forId id: Int) -> AnyPublisher {
28 | Future { promise in
29 | self.fetch(withId: id) { result in
30 | promise(result)
31 | }
32 | }.eraseToAnyPublisher()
33 | }
34 | /// Returns a publisher that wraps a repository request for a given query.
35 | ///
36 | /// The publisher publishes data when the task completes, or terminates if the task fails with an error.
37 | /// - Parameter queryItems: The array of `URLQueryItem`.
38 | /// - Returns: A publisher that wraps repository fetch result with entity array
39 | func queryPublisher(forQueryItems queryItems: [URLQueryItem]) -> AnyPublisher<[T], RepositoryError> {
40 | Future { promise in
41 | self.query(withQueryItems: queryItems) { result in
42 | promise(result)
43 | }
44 | }.eraseToAnyPublisher()
45 | }
46 | /// Returns a publisher that wraps a repository request to save new entity.
47 | ///
48 | /// The publisher publishes data when the task completes, or terminates if the task fails with an error.
49 | /// - Parameter entity: The entity to be saved
50 | /// - Returns: A publisher that wraps repository save result
51 | func savePublisher(for entity: T) -> AnyPublisher {
52 | Future { promise in
53 | self.save(entity: entity) { error in
54 | if let error = error {
55 | promise(.failure(error))
56 | } else {
57 | promise(.success(()))
58 | }
59 | }
60 | }.eraseToAnyPublisher()
61 | }
62 | /// Returns a publisher that wraps a repository request to update an existing entity.
63 | ///
64 | /// The publisher completes when the update completes, or terminates if the task fails with an error.
65 | /// - Parameter entity: The entity to be saved
66 | /// - Returns: A publisher that wraps repository save result
67 | func updatePublisher(for entity: T) -> AnyPublisher {
68 | Future { promise in
69 | self.update(entity: entity) { error in
70 | if let error = error {
71 | promise(.failure(error))
72 | } else {
73 | promise(.success(()))
74 | }
75 | }
76 | }.eraseToAnyPublisher()
77 | }
78 | /// Returns a publisher that wraps a repository request for deletion.
79 | ///
80 | /// The publisher completes when the deletion completes, or terminates if the fails with an error.
81 | /// - Parameter entity: The entity to be saved
82 | /// - Returns: A publisher that wraps repository save result
83 | func deletePublisher(for entity: T) -> AnyPublisher {
84 | Future { promise in
85 | self.delete(entity: entity) { error in
86 | if let error = error {
87 | promise(.failure(error))
88 | } else {
89 | promise(.success(()))
90 | }
91 | }
92 | }.eraseToAnyPublisher()
93 | }
94 | }
95 | #endif
96 |
--------------------------------------------------------------------------------
/Domain/CombineSupport/UseCase+Combine.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UseCase+Combine.swift
3 | // Domain
4 | //
5 | // Created by Aarif Sumra on 2021/06/05.
6 | // Copyright © 2021 DWANGO Co., Ltd All rights reserved.
7 | //
8 |
9 | #if canImport(Combine)
10 | import Combine
11 |
12 | public extension UseCase {
13 |
14 | func executePublisher(_ parameters: Parameters) -> AnyPublisher {
15 | Future { promise in
16 | self.execute(parameters) { result in
17 | promise(result)
18 | }
19 | }.eraseToAnyPublisher()
20 | }
21 | }
22 |
23 | #endif
24 |
--------------------------------------------------------------------------------
/Domain/Concrete/TypeErased/AnyUseCase.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AnyUseCase.swift
3 | // Domain
4 | //
5 | // Created by Aarif Sumra on 2021/06/29.
6 | // Copyright © 2021 DWANGO Co., Ltd. All rights reserved.
7 | //
8 |
9 | public class AnyUseCase: UseCase {
10 |
11 | private let _execute: (Parameters, ((Result) -> Void)?) -> Void
12 | private let _abort: () -> Void
13 |
14 | init(_ erasing: U) where U.Parameters == Parameters, U.Success == Success {
15 | self._execute = erasing.execute
16 | self._abort = erasing.abort
17 | }
18 |
19 | public func execute(_ parameters: Parameters, completion: ((Result) -> Void)?) {
20 | _execute(parameters, completion)
21 | }
22 |
23 | public func abort() {
24 | _abort()
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/Domain/Domain.h:
--------------------------------------------------------------------------------
1 | //
2 | // Domain.h
3 | // Domain
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | #import
10 |
11 | //! Project version number for Domain.
12 | FOUNDATION_EXPORT double DomainVersionNumber;
13 |
14 | //! Project version string for Domain.
15 | FOUNDATION_EXPORT const unsigned char DomainVersionString[];
16 |
17 | // In this header, you should import all the public headers of your framework using statements like #import
18 |
19 |
20 |
--------------------------------------------------------------------------------
/Domain/Entities/CommentEntity.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CommentEntity.swift
3 | // Domain
4 | //
5 | // Created by Aarif Sumra on 2021/09/03.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | public class CommentEntity: Entity {
10 | // Required
11 | public let id: Int
12 | public let postId: Int
13 | public let name: String
14 | public let email: String
15 | public let body: String
16 | // `Optional`s
17 |
18 | public init(id: Int, postId: Int, name: String, email: String, body: String) {
19 | self.id = id
20 | self.postId = postId
21 | self.name = name
22 | self.email = email
23 | self.body = body
24 | }
25 | }
26 |
27 | extension CommentEntity: Hashable {
28 | public static func == (lhs: CommentEntity, rhs: CommentEntity) -> Bool {
29 | return lhs.id == rhs.id // For now id comparision is enough
30 | }
31 |
32 | public func hash(into hasher: inout Hasher) {
33 | hasher.combine(id)
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/Domain/Entities/Conversion/DomainEntityConvertible.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DomainEntityConvertible.swift
3 | // Platform
4 | //
5 | // Created by Hiroshi Oshiro on 2021-04-23.
6 | // Copyright © 2021 DWANGO Co., Ltd All rights reserved.
7 | //
8 |
9 | import Domain
10 |
11 | protocol DomainEntityConvertible {
12 | associatedtype DomainEntity: Domain.Entity
13 |
14 | func asDomainEntity() -> DomainEntity
15 | }
16 |
--------------------------------------------------------------------------------
/Domain/Entities/PostEntity.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PostEntity.swift
3 | // Domain
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public class PostEntity: Entity {
12 | // Required
13 | public let id: Int
14 | public let title: String
15 | // `Optional`s
16 | public var body: String?
17 |
18 | public init(id: Int, title: String, body: String? = nil) {
19 | self.id = id
20 | self.title = title
21 | self.body = body
22 | }
23 | }
24 |
25 | extension PostEntity: Hashable {
26 | public static func == (lhs: PostEntity, rhs: PostEntity) -> Bool {
27 | return lhs.id == rhs.id // For now id comparision is enough
28 | }
29 |
30 | public func hash(into hasher: inout Hasher) {
31 | hasher.combine(id)
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/Domain/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | $(CURRENT_PROJECT_VERSION)
21 |
22 |
23 |
--------------------------------------------------------------------------------
/Domain/Protocols/Endpoint.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Endpoint.swift
3 | // Platform
4 | //
5 | // Created by Hiroshi Oshiro on 2021-04-23.
6 | // Copyright © 2021 DWANGO Co., Ltd All rights reserved.
7 | //
8 |
9 | public protocol Endpoint {
10 | var relativePath: String { get }
11 | var headers: [String: String] { get }
12 | }
13 |
--------------------------------------------------------------------------------
/Domain/Protocols/Entity.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Entity.swift
3 | // Domain
4 | //
5 | // Created by Hiroshi Oshiro on 2021-04-23.
6 | // Copyright © 2021 DWANGO Co., Ltd All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public protocol Entity: Identifiable, Model {
12 | }
13 |
--------------------------------------------------------------------------------
/Domain/Protocols/Model.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Model.swift
3 | // Domain
4 | //
5 | // Created by Aarif Sumra on 2021/09/01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | public protocol Model {}
10 |
11 |
--------------------------------------------------------------------------------
/Domain/Protocols/Network.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Network.swift
3 | // Domain
4 | //
5 | // Created by Hiroshi Oshiro on 2021-04-23.
6 | // Copyright © 2021 DWANGO Co., Ltd All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public enum NetworkingError: Error {
12 | case underlyingError(Error)
13 | case abnormalResponse(HTTPURLResponse)
14 | case emptyResponse
15 | }
16 |
17 | public protocol Networking: class {
18 | var session: URLSession { get }
19 | func send(_ request: URLRequest, completion: @escaping (Result) -> Void)
20 | }
21 |
--------------------------------------------------------------------------------
/Domain/Protocols/Repository.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Network.swift
3 | // Domain
4 | //
5 | // Created by Hiroshi Oshiro on 2021-04-23.
6 | // Copyright © 2021 DWANGO Co., Ltd All rights reserved.
7 | //
8 |
9 | public enum RepositoryError: Error {
10 | case underlyingError(Error)
11 | case saveFailed
12 | case updateFailed
13 | case deleteFailed
14 | }
15 |
16 | /// The fundamental repository has an entity type to operate with
17 | public protocol Repository: class {
18 | /// The entity type repository holds
19 | associatedtype T: Entity
20 | /// Fetches an entity from the repository
21 | func fetch(withId id: Int, completion: @escaping (Result) -> Void)
22 | /// Queries the repository with query items
23 | func query(withQueryItems queryItems: [URLQueryItem], completion: @escaping (Result<[T], RepositoryError>) -> Void)
24 | /// Fetch all the entities from the repository
25 | func fetchAll(_ completion: @escaping (Result<[T], RepositoryError>) -> Void)
26 | /// Saves the new entity to the repository
27 | func save(entity: T, completion: @escaping (RepositoryError?) -> Void)
28 | /// Updates an existing entity from the repository
29 | func update(entity: T, completion: @escaping (RepositoryError?) -> Void)
30 | /// Deletes an existing entity from the repository
31 | func delete(entity: T, completion: @escaping (RepositoryError?) -> Void)
32 | }
33 |
--------------------------------------------------------------------------------
/Domain/Protocols/UseCase.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UseCase.swift
3 | // Domain
4 | //
5 | // Created by Hiroshi Oshiro on 2021-04-23.
6 | // Copyright © 2021 DWANGO Co., Ltd All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public protocol UseCase {
12 | associatedtype Parameters
13 | associatedtype Success
14 | func execute(_ parameters: Parameters, completion: ((Result) -> Void)?)
15 | func abort()
16 | }
17 |
18 | public extension UseCase {
19 |
20 | func abort() {
21 | fatalError("Abort Method not implemented. To use this method you must implement it first.")
22 | }
23 |
24 | func eraseToAnyUseCase() -> AnyUseCase {
25 | AnyUseCase(self)
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/Domain/Protocols/ValueObject.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ValueObject.swift
3 | // Domain
4 | //
5 | // Created by Aarif Sumra on 2021/06/29.
6 | // Copyright © 2021 DWANGO Co., Ltd. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public protocol ValueObject: Model {
12 | }
13 |
--------------------------------------------------------------------------------
/Domain/UseCases/UseCaseProvider.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UseCaseProvider.swift
3 | // Domain
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | public protocol DoLoginUseCase: UseCase {}
10 | public protocol GetPostsUseCase: UseCase {}
11 | public protocol GetPostDetailUseCase: UseCase {}
12 | public protocol GetPostCommentsUseCase: UseCase {}
13 |
14 | public protocol UseCaseProvider {
15 | func makeDoLoginUseCase() -> AnyUseCase<(username: String, password: String), Bool>
16 | func makeGetPostsUseCase() -> AnyUseCase
17 | func makeGetPostDetailUseCase() -> AnyUseCase
18 | func makeGetPostCommentsUseCase(_ postId: Int) -> AnyUseCase
19 | }
20 |
--------------------------------------------------------------------------------
/DomainTests/DomainTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DomainTests.swift
3 | // DomainTests
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import XCTest
10 | @testable import Domain
11 |
12 | class DomainTests: XCTestCase {
13 |
14 | override func setUpWithError() throws {
15 | // Put setup code here. This method is called before the invocation of each test method in the class.
16 | }
17 |
18 | override func tearDownWithError() throws {
19 | // Put teardown code here. This method is called after the invocation of each test method in the class.
20 | }
21 |
22 | func testExample() throws {
23 | // This is an example of a functional test case.
24 | // Use XCTAssert and related functions to verify your tests produce the correct results.
25 | }
26 |
27 | func testPerformanceExample() throws {
28 | // This is an example of a performance test case.
29 | self.measure {
30 | // Put the code you want to measure the time of here.
31 | }
32 | }
33 |
34 | }
35 |
--------------------------------------------------------------------------------
/DomainTests/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | 1
21 |
22 |
23 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source "https://rubygems.org"
2 |
3 | git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
4 |
5 | gem 'cocoapods'
6 | gem 'fastlane'
7 | gem 'xcpretty'
8 | gem 'danger'
9 | gem 'danger-swiftlint'
10 | gem 'fastlane-plugin-firebase_app_distribution'
11 |
12 | plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
13 | eval_gemfile(plugins_path) if File.exist?(plugins_path)
--------------------------------------------------------------------------------
/Gemfile.lock:
--------------------------------------------------------------------------------
1 | GEM
2 | remote: https://rubygems.org/
3 | specs:
4 | CFPropertyList (3.0.3)
5 | activesupport (5.2.6)
6 | concurrent-ruby (~> 1.0, >= 1.0.2)
7 | i18n (>= 0.7, < 2)
8 | minitest (~> 5.1)
9 | tzinfo (~> 1.1)
10 | addressable (2.8.0)
11 | public_suffix (>= 2.0.2, < 5.0)
12 | algoliasearch (1.27.5)
13 | httpclient (~> 2.8, >= 2.8.3)
14 | json (>= 1.5.1)
15 | artifactory (3.0.15)
16 | atomos (0.1.3)
17 | aws-eventstream (1.1.1)
18 | aws-partitions (1.493.0)
19 | aws-sdk-core (3.119.1)
20 | aws-eventstream (~> 1, >= 1.0.2)
21 | aws-partitions (~> 1, >= 1.239.0)
22 | aws-sigv4 (~> 1.1)
23 | jmespath (~> 1.0)
24 | aws-sdk-kms (1.47.0)
25 | aws-sdk-core (~> 3, >= 3.119.0)
26 | aws-sigv4 (~> 1.1)
27 | aws-sdk-s3 (1.100.0)
28 | aws-sdk-core (~> 3, >= 3.119.0)
29 | aws-sdk-kms (~> 1)
30 | aws-sigv4 (~> 1.1)
31 | aws-sigv4 (1.2.4)
32 | aws-eventstream (~> 1, >= 1.0.2)
33 | babosa (1.0.4)
34 | claide (1.0.3)
35 | claide-plugins (0.9.2)
36 | cork
37 | nap
38 | open4 (~> 1.3)
39 | cocoapods (1.10.2)
40 | addressable (~> 2.6)
41 | claide (>= 1.0.2, < 2.0)
42 | cocoapods-core (= 1.10.2)
43 | cocoapods-deintegrate (>= 1.0.3, < 2.0)
44 | cocoapods-downloader (>= 1.4.0, < 2.0)
45 | cocoapods-plugins (>= 1.0.0, < 2.0)
46 | cocoapods-search (>= 1.0.0, < 2.0)
47 | cocoapods-trunk (>= 1.4.0, < 2.0)
48 | cocoapods-try (>= 1.1.0, < 2.0)
49 | colored2 (~> 3.1)
50 | escape (~> 0.0.4)
51 | fourflusher (>= 2.3.0, < 3.0)
52 | gh_inspector (~> 1.0)
53 | molinillo (~> 0.6.6)
54 | nap (~> 1.0)
55 | ruby-macho (~> 1.4)
56 | xcodeproj (>= 1.19.0, < 2.0)
57 | cocoapods-core (1.10.2)
58 | activesupport (> 5.0, < 6)
59 | addressable (~> 2.6)
60 | algoliasearch (~> 1.0)
61 | concurrent-ruby (~> 1.1)
62 | fuzzy_match (~> 2.0.4)
63 | nap (~> 1.0)
64 | netrc (~> 0.11)
65 | public_suffix
66 | typhoeus (~> 1.0)
67 | cocoapods-deintegrate (1.0.5)
68 | cocoapods-downloader (1.5.0)
69 | cocoapods-plugins (1.0.0)
70 | nap
71 | cocoapods-search (1.0.1)
72 | cocoapods-trunk (1.5.0)
73 | nap (>= 0.8, < 2.0)
74 | netrc (~> 0.11)
75 | cocoapods-try (1.2.0)
76 | colored (1.2)
77 | colored2 (3.1.2)
78 | commander (4.6.0)
79 | highline (~> 2.0.0)
80 | concurrent-ruby (1.1.9)
81 | cork (0.3.0)
82 | colored2 (~> 3.1)
83 | danger (8.3.1)
84 | claide (~> 1.0)
85 | claide-plugins (>= 0.9.2)
86 | colored2 (~> 3.1)
87 | cork (~> 0.1)
88 | faraday (>= 0.9.0, < 2.0)
89 | faraday-http-cache (~> 2.0)
90 | git (~> 1.7)
91 | kramdown (~> 2.3)
92 | kramdown-parser-gfm (~> 1.0)
93 | no_proxy_fix
94 | octokit (~> 4.7)
95 | terminal-table (>= 1, < 4)
96 | danger-swiftlint (0.27.0)
97 | danger
98 | rake (> 10)
99 | thor (~> 0.19)
100 | declarative (0.0.20)
101 | digest-crc (0.6.4)
102 | rake (>= 12.0.0, < 14.0.0)
103 | domain_name (0.5.20190701)
104 | unf (>= 0.0.5, < 1.0.0)
105 | dotenv (2.7.6)
106 | emoji_regex (3.2.2)
107 | escape (0.0.4)
108 | ethon (0.14.0)
109 | ffi (>= 1.15.0)
110 | excon (0.85.0)
111 | faraday (1.7.1)
112 | faraday-em_http (~> 1.0)
113 | faraday-em_synchrony (~> 1.0)
114 | faraday-excon (~> 1.1)
115 | faraday-httpclient (~> 1.0.1)
116 | faraday-net_http (~> 1.0)
117 | faraday-net_http_persistent (~> 1.1)
118 | faraday-patron (~> 1.0)
119 | faraday-rack (~> 1.0)
120 | multipart-post (>= 1.2, < 3)
121 | ruby2_keywords (>= 0.0.4)
122 | faraday-cookie_jar (0.0.7)
123 | faraday (>= 0.8.0)
124 | http-cookie (~> 1.0.0)
125 | faraday-em_http (1.0.0)
126 | faraday-em_synchrony (1.0.0)
127 | faraday-excon (1.1.0)
128 | faraday-http-cache (2.2.0)
129 | faraday (>= 0.8)
130 | faraday-httpclient (1.0.1)
131 | faraday-net_http (1.0.1)
132 | faraday-net_http_persistent (1.2.0)
133 | faraday-patron (1.0.0)
134 | faraday-rack (1.0.0)
135 | faraday_middleware (1.1.0)
136 | faraday (~> 1.0)
137 | fastimage (2.2.5)
138 | fastlane (2.193.0)
139 | CFPropertyList (>= 2.3, < 4.0.0)
140 | addressable (>= 2.8, < 3.0.0)
141 | artifactory (~> 3.0)
142 | aws-sdk-s3 (~> 1.0)
143 | babosa (>= 1.0.3, < 2.0.0)
144 | bundler (>= 1.12.0, < 3.0.0)
145 | colored
146 | commander (~> 4.6)
147 | dotenv (>= 2.1.1, < 3.0.0)
148 | emoji_regex (>= 0.1, < 4.0)
149 | excon (>= 0.71.0, < 1.0.0)
150 | faraday (~> 1.0)
151 | faraday-cookie_jar (~> 0.0.6)
152 | faraday_middleware (~> 1.0)
153 | fastimage (>= 2.1.0, < 3.0.0)
154 | gh_inspector (>= 1.1.2, < 2.0.0)
155 | google-apis-androidpublisher_v3 (~> 0.3)
156 | google-apis-playcustomapp_v1 (~> 0.1)
157 | google-cloud-storage (~> 1.31)
158 | highline (~> 2.0)
159 | json (< 3.0.0)
160 | jwt (>= 2.1.0, < 3)
161 | mini_magick (>= 4.9.4, < 5.0.0)
162 | multipart-post (~> 2.0.0)
163 | naturally (~> 2.2)
164 | optparse (~> 0.1.1)
165 | plist (>= 3.1.0, < 4.0.0)
166 | rubyzip (>= 2.0.0, < 3.0.0)
167 | security (= 0.1.3)
168 | simctl (~> 1.6.3)
169 | terminal-notifier (>= 2.0.0, < 3.0.0)
170 | terminal-table (>= 1.4.5, < 2.0.0)
171 | tty-screen (>= 0.6.3, < 1.0.0)
172 | tty-spinner (>= 0.8.0, < 1.0.0)
173 | word_wrap (~> 1.0.0)
174 | xcodeproj (>= 1.13.0, < 2.0.0)
175 | xcpretty (~> 0.3.0)
176 | xcpretty-travis-formatter (>= 0.0.3)
177 | fastlane-plugin-firebase_app_distribution (0.3.1)
178 | ffi (1.15.3)
179 | fourflusher (2.3.1)
180 | fuzzy_match (2.0.4)
181 | gh_inspector (1.1.3)
182 | git (1.9.1)
183 | rchardet (~> 1.8)
184 | google-apis-androidpublisher_v3 (0.10.0)
185 | google-apis-core (>= 0.4, < 2.a)
186 | google-apis-core (0.4.1)
187 | addressable (~> 2.5, >= 2.5.1)
188 | googleauth (>= 0.16.2, < 2.a)
189 | httpclient (>= 2.8.1, < 3.a)
190 | mini_mime (~> 1.0)
191 | representable (~> 3.0)
192 | retriable (>= 2.0, < 4.a)
193 | rexml
194 | webrick
195 | google-apis-iamcredentials_v1 (0.7.0)
196 | google-apis-core (>= 0.4, < 2.a)
197 | google-apis-playcustomapp_v1 (0.5.0)
198 | google-apis-core (>= 0.4, < 2.a)
199 | google-apis-storage_v1 (0.6.0)
200 | google-apis-core (>= 0.4, < 2.a)
201 | google-cloud-core (1.6.0)
202 | google-cloud-env (~> 1.0)
203 | google-cloud-errors (~> 1.0)
204 | google-cloud-env (1.5.0)
205 | faraday (>= 0.17.3, < 2.0)
206 | google-cloud-errors (1.1.0)
207 | google-cloud-storage (1.34.1)
208 | addressable (~> 2.5)
209 | digest-crc (~> 0.4)
210 | google-apis-iamcredentials_v1 (~> 0.1)
211 | google-apis-storage_v1 (~> 0.1)
212 | google-cloud-core (~> 1.6)
213 | googleauth (>= 0.16.2, < 2.a)
214 | mini_mime (~> 1.0)
215 | googleauth (0.17.0)
216 | faraday (>= 0.17.3, < 2.0)
217 | jwt (>= 1.4, < 3.0)
218 | memoist (~> 0.16)
219 | multi_json (~> 1.11)
220 | os (>= 0.9, < 2.0)
221 | signet (~> 0.14)
222 | highline (2.0.3)
223 | http-cookie (1.0.4)
224 | domain_name (~> 0.5)
225 | httpclient (2.8.3)
226 | i18n (1.8.10)
227 | concurrent-ruby (~> 1.0)
228 | jmespath (1.4.0)
229 | json (2.5.1)
230 | jwt (2.2.3)
231 | kramdown (2.3.1)
232 | rexml
233 | kramdown-parser-gfm (1.1.0)
234 | kramdown (~> 2.0)
235 | memoist (0.16.2)
236 | mini_magick (4.11.0)
237 | mini_mime (1.1.1)
238 | minitest (5.14.4)
239 | molinillo (0.6.6)
240 | multi_json (1.15.0)
241 | multipart-post (2.0.0)
242 | nanaimo (0.3.0)
243 | nap (1.1.0)
244 | naturally (2.2.1)
245 | netrc (0.11.0)
246 | no_proxy_fix (0.1.2)
247 | octokit (4.21.0)
248 | faraday (>= 0.9)
249 | sawyer (~> 0.8.0, >= 0.5.3)
250 | open4 (1.3.4)
251 | optparse (0.1.1)
252 | os (1.1.1)
253 | plist (3.6.0)
254 | public_suffix (4.0.6)
255 | rake (13.0.6)
256 | rchardet (1.8.0)
257 | representable (3.1.1)
258 | declarative (< 0.1.0)
259 | trailblazer-option (>= 0.1.1, < 0.2.0)
260 | uber (< 0.2.0)
261 | retriable (3.1.2)
262 | rexml (3.2.5)
263 | rouge (2.0.7)
264 | ruby-macho (1.4.0)
265 | ruby2_keywords (0.0.5)
266 | rubyzip (2.3.2)
267 | sawyer (0.8.2)
268 | addressable (>= 2.3.5)
269 | faraday (> 0.8, < 2.0)
270 | security (0.1.3)
271 | signet (0.15.0)
272 | addressable (~> 2.3)
273 | faraday (>= 0.17.3, < 2.0)
274 | jwt (>= 1.5, < 3.0)
275 | multi_json (~> 1.10)
276 | simctl (1.6.8)
277 | CFPropertyList
278 | naturally
279 | terminal-notifier (2.0.0)
280 | terminal-table (1.8.0)
281 | unicode-display_width (~> 1.1, >= 1.1.1)
282 | thor (0.20.3)
283 | thread_safe (0.3.6)
284 | trailblazer-option (0.1.1)
285 | tty-cursor (0.7.1)
286 | tty-screen (0.8.1)
287 | tty-spinner (0.9.3)
288 | tty-cursor (~> 0.7)
289 | typhoeus (1.4.0)
290 | ethon (>= 0.9.0)
291 | tzinfo (1.2.9)
292 | thread_safe (~> 0.1)
293 | uber (0.1.0)
294 | unf (0.1.4)
295 | unf_ext
296 | unf_ext (0.0.7.7)
297 | unicode-display_width (1.7.0)
298 | webrick (1.7.0)
299 | word_wrap (1.0.0)
300 | xcodeproj (1.21.0)
301 | CFPropertyList (>= 2.3.3, < 4.0)
302 | atomos (~> 0.1.3)
303 | claide (>= 1.0.2, < 2.0)
304 | colored2 (~> 3.1)
305 | nanaimo (~> 0.3.0)
306 | rexml (~> 3.2.4)
307 | xcpretty (0.3.0)
308 | rouge (~> 2.0.7)
309 | xcpretty-travis-formatter (1.0.1)
310 | xcpretty (~> 0.2, >= 0.0.7)
311 |
312 | PLATFORMS
313 | x86_64-darwin-19
314 |
315 | DEPENDENCIES
316 | cocoapods
317 | danger
318 | danger-swiftlint
319 | fastlane
320 | fastlane-plugin-firebase_app_distribution
321 | xcpretty
322 |
323 | BUNDLED WITH
324 | 2.2.26
325 |
--------------------------------------------------------------------------------
/Platform/Entities/Comment.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Comment.swift
3 | // Platform
4 | //
5 | // Created by Aarif Sumra on 2021/09/03.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import Domain
10 |
11 | struct Comment: Codable {
12 | // Required
13 | let id: Int
14 | let postId: Int
15 | let name: String
16 | let email: String
17 | let body: String
18 | }
19 |
20 | extension Comment: DomainEntityConvertible {
21 |
22 | init(from entity: CommentEntity) {
23 | self.id = entity.id
24 | self.postId = entity.postId
25 | self.name = entity.name
26 | self.email = entity.email
27 | self.body = entity.body
28 | }
29 |
30 | func asDomainEntity() -> CommentEntity {
31 | return CommentEntity(
32 | id: id,
33 | postId: postId,
34 | name: name,
35 | email: email,
36 | body: body
37 | )
38 | }
39 | }
40 |
41 |
42 | extension Comment: Hashable {
43 | public static func == (lhs: Comment, rhs: Comment) -> Bool {
44 | return lhs.id == rhs.id // For now id comparision is enough
45 | }
46 |
47 | public func hash(into hasher: inout Hasher) {
48 | hasher.combine(id)
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/Platform/Entities/Conversion/DomainEntityConvertible.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DomainEntityConvertible.swift
3 | // Platform
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import Domain
10 |
11 | protocol DomainEntityConvertible {
12 | associatedtype DomainEntity: Domain.Entity
13 |
14 | func asDomainEntity() -> DomainEntity
15 | init(from entity: DomainEntity)
16 | }
17 |
--------------------------------------------------------------------------------
/Platform/Entities/Post.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Post.swift
3 | // Platform
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import Domain
10 |
11 | struct Post: Codable {
12 | // Required
13 | let id: Int
14 | let title: String
15 | // `Optional`s
16 | var body: String? = nil
17 | }
18 |
19 | extension Post: DomainEntityConvertible {
20 |
21 | init(from entity: PostEntity) {
22 | self.id = entity.id
23 | self.title = entity.title
24 | self.body = entity.body
25 | }
26 |
27 | func asDomainEntity() -> PostEntity {
28 | return PostEntity(
29 | id: id,
30 | title: title,
31 | body: body
32 | )
33 | }
34 | }
35 |
36 |
37 | extension Post: Hashable {
38 | public static func == (lhs: Post, rhs: Post) -> Bool {
39 | return lhs.id == rhs.id // For now id comparision is enough
40 | }
41 |
42 | public func hash(into hasher: inout Hasher) {
43 | hasher.combine(id)
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/Platform/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | $(CURRENT_PROJECT_VERSION)
21 |
22 |
23 |
--------------------------------------------------------------------------------
/Platform/Network/Endpoints/Endpoints.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PostsEndpoint.swift
3 | // Platform
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import Domain
10 |
11 | enum Endpoints: Endpoint {
12 | case posts
13 | case commentsFor(postId: Int)
14 |
15 | var relativePath: String {
16 | switch self {
17 | case .posts:
18 | return "posts"
19 | case .commentsFor(let postId):
20 | return "posts/\(postId)/comments"
21 | }
22 | }
23 |
24 | var headers: [String : String] {
25 | return [
26 | "Content-Type": "application/json"
27 | ]
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/Platform/Network/HTTPMethod.swift:
--------------------------------------------------------------------------------
1 | //
2 | // HTTPMethod.swift
3 | // Template
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 | import Foundation
9 |
10 | /**
11 | A helper struct used for modelling HTTPMethods. The concept is stolen from
12 | https://davedelong.com/blog/2020/06/27/http-in-swift-part-1/
13 | and the reason we're not limiting the options with an `enum` is that, as the article above says:
14 | > In reality, the HTTP spec itself does not place a limit on the value of the request method,
15 | which allows for specs like WebDAV (which powers CalDAV and CardDAV)
16 | to add their own methods like COPY, LOCK, PROPFIND, and so on.
17 | So, we define a list of commonly used values (get, post and so on) but allows extension
18 | */
19 |
20 | public struct HTTPMethod: Hashable {
21 | public static let get = HTTPMethod(rawValue: "GET")
22 | public static let post = HTTPMethod(rawValue: "POST")
23 | public static let put = HTTPMethod(rawValue: "PUT")
24 | public static let patch = HTTPMethod(rawValue: "PATCH")
25 | public static let delete = HTTPMethod(rawValue: "DELETE")
26 |
27 | public let rawValue: String
28 | }
29 |
--------------------------------------------------------------------------------
/Platform/Network/Network.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Network.swift
3 | // Platform
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 | import Domain
9 |
10 | class Network: Networking {
11 |
12 | let session: URLSession
13 |
14 | init(session: URLSession) {
15 | self.session = session
16 | }
17 |
18 | static let `default` = {
19 | Network(session: URLSession.shared)
20 | }()
21 |
22 | func send(_ request: URLRequest, completion: @escaping (Result) -> Void) {
23 | debugPrint(request.curlString)
24 | let task = session.dataTask(with: request) { data, response, error in
25 | switch (data, response, error) {
26 | case (_, _, .some(let error)):
27 | completion(.failure(.underlyingError(error)))
28 | case (.none, .some(let response), .none):
29 | if let httpResponse = response as? HTTPURLResponse {
30 | completion(.failure(.abnormalResponse(httpResponse)))
31 | }
32 | case (.none, .none, .none):
33 | completion(.failure(.emptyResponse))
34 | case (.some(let data), _, _):
35 | completion(.success(data))
36 | }
37 | }
38 | task.resume()
39 | }
40 | }
41 |
42 | // https://gist.github.com/shaps80/ba6a1e2d477af0383e8f19b87f53661d
43 | private extension URLRequest {
44 |
45 | /**
46 | Returns a cURL command representation of this URL request.
47 | */
48 | var curlString: String {
49 | guard let url = url else { return "" }
50 | var baseCommand = #"curl "\#(url.absoluteString)""#
51 |
52 | if httpMethod == "HEAD" {
53 | baseCommand += " --head"
54 | }
55 |
56 | var command = [baseCommand]
57 |
58 | if let method = httpMethod, method != "GET" && method != "HEAD" {
59 | command.append("-X \(method)")
60 | }
61 |
62 | if let headers = allHTTPHeaderFields {
63 | for (key, value) in headers where key != "Cookie" {
64 | command.append("-H '\(key): \(value)'")
65 | }
66 | }
67 |
68 | if let data = httpBody, let body = String(data: data, encoding: .utf8) {
69 | command.append("-d '\(body)'")
70 | }
71 |
72 | return command.joined(separator: " \\\n\t")
73 | }
74 |
75 | }
76 |
--------------------------------------------------------------------------------
/Platform/Platform.h:
--------------------------------------------------------------------------------
1 | //
2 | // Platform.h
3 | // Platform
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | #import
10 |
11 | //! Project version number for Platform.
12 | FOUNDATION_EXPORT double PlatformVersionNumber;
13 |
14 | //! Project version string for Platform.
15 | FOUNDATION_EXPORT const unsigned char PlatformVersionString[];
16 |
17 | // In this header, you should import all the public headers of your framework using statements like #import
18 |
19 |
20 |
--------------------------------------------------------------------------------
/Platform/Repository/RemoteRepository.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RemoteRepository.swift
3 | // Platform
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import Domain
10 | import Combine
11 |
12 | class RemoteRepository: Repository where U: DomainEntityConvertible & Codable, T == U.DomainEntity {
13 |
14 | private let network: Networking
15 | private let baseURL: URL
16 | private let endpoint: Endpoint
17 |
18 | init(baseURL: URL, network: Networking, endpoint: Endpoint) {
19 | self.baseURL = baseURL
20 | self.network = network
21 | self.endpoint = endpoint
22 | }
23 |
24 | // MARK: - Reading from Repository -
25 | func fetch(withId id: Int, completion: @escaping (Result) -> Void) {
26 | let url = self.endpointURL.appendingPathComponent("\(id)")
27 | let request = URLRequest(url: url)
28 | network.send(request) { result in
29 | switch result {
30 | case .success(let data):
31 | do {
32 | let item = try self.decoder.decode(U.self, from: data)
33 | completion(.success(item.asDomainEntity()))
34 | } catch let decodingError {
35 | completion(.failure(.underlyingError(decodingError)))
36 | }
37 | case .failure(let networkError):
38 | completion(.failure(.underlyingError(networkError)))
39 | }
40 | }
41 | }
42 |
43 | func query(withQueryItems queryItems: [URLQueryItem], completion: @escaping (Result<[T], RepositoryError>) -> Void) {
44 | guard var urlComponents = URLComponents(url: endpointURL, resolvingAgainstBaseURL: false) else {
45 | return completion(.failure(.underlyingError(URLError(.badURL))))
46 | }
47 |
48 | urlComponents.queryItems?.append(contentsOf: queryItems)
49 |
50 | guard let url = urlComponents.url else {
51 | return completion(.failure(.underlyingError(URLError(.badURL))))
52 | }
53 |
54 | let request = URLRequest(url: url)
55 | self.network.send(request) { result in
56 | switch result {
57 | case .success(let data):
58 | do {
59 | let list = try self.decoder.decode([U].self, from: data)
60 | completion(.success(list.map { $0.asDomainEntity() }))
61 | } catch let decodingError {
62 | completion(.failure(.underlyingError(decodingError)))
63 | }
64 | case .failure(let networkError):
65 | completion(.failure(.underlyingError(networkError)))
66 | }
67 | }
68 | }
69 |
70 | func fetchAll(_ completion: @escaping (Result<[T], RepositoryError>) -> Void) {
71 | let request = URLRequest(url: self.endpointURL)
72 | network.send(request) { result in
73 | switch result {
74 | case .success(let data):
75 | do {
76 | let list = try self.decoder.decode([U].self, from: data)
77 | completion(.success(list.map { $0.asDomainEntity() }))
78 | } catch let decodingError {
79 | completion(.failure(.underlyingError(decodingError)))
80 | }
81 | case .failure(let networkError):
82 | completion(.failure(.underlyingError(networkError)))
83 | }
84 | }
85 | }
86 |
87 | // MARK: - Writing to Repository -
88 | func save(entity: T, completion: @escaping (RepositoryError?) -> Void) {
89 | save(isUpdate: false, entity: entity, completion: completion)
90 | }
91 |
92 | func update(entity: T, completion: @escaping (RepositoryError?) -> Void) {
93 | save(isUpdate: true, entity: entity, completion: completion)
94 | }
95 |
96 | func delete(entity: T, completion: @escaping (RepositoryError?) -> Void) {
97 | let url = baseURL.appendingPathComponent(endpoint.relativePath)
98 | let request = URLRequest(url: url)
99 | self.network.send(request) { result in
100 | switch result {
101 | case .success(let data):
102 | do {
103 | let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any]
104 | guard let dict = json, let success = dict["success"] as? Bool else {
105 | return completion(RepositoryError.deleteFailed)
106 | }
107 | completion(success ? nil : RepositoryError.deleteFailed)
108 | } catch let decodingError {
109 | completion(.underlyingError(decodingError))
110 | }
111 | case .failure(let networkError):
112 | completion(.underlyingError(networkError))
113 | }
114 | }
115 | }
116 |
117 | private let decoder: JSONDecoder = {
118 | let decoder = JSONDecoder()
119 | decoder.dateDecodingStrategy = .iso8601
120 | // this is also where you'd set the `keyDecodingStrategy`
121 | // if you'd be lucky enough to work with a snake_case_endpoint :)
122 | decoder.keyDecodingStrategy = .convertFromSnakeCase
123 | return decoder
124 | }()
125 | }
126 |
127 | private extension RemoteRepository {
128 |
129 | var endpointURL: URL {
130 | baseURL.appendingPathComponent(endpoint.relativePath)
131 | }
132 |
133 | func save(isUpdate: Bool, entity: T, completion: @escaping (RepositoryError?) -> Void) {
134 | let url = baseURL.appendingPathComponent(endpoint.relativePath)
135 | var request = URLRequest(url: url)
136 | let requestMethod: HTTPMethod = isUpdate ? .patch : .post
137 | request.httpMethod = requestMethod.rawValue
138 | request.httpBody = try? JSONEncoder().encode(U(from: entity))
139 | self.network.send(request) { result in
140 | switch result {
141 | case .success(let data):
142 | do {
143 | let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any]
144 | guard let dict = json, let success = dict["success"] as? Bool else {
145 | return completion(RepositoryError.saveFailed)
146 | }
147 | completion(success ? nil : RepositoryError.saveFailed)
148 | } catch let decodingError {
149 | completion(.underlyingError(decodingError))
150 | }
151 | case .failure(let networkError):
152 | completion(.underlyingError(networkError))
153 | }
154 | }
155 | }
156 | }
157 |
158 | private extension URL {
159 |
160 | func queryItemAdded(name: String, value: String?) -> URL? {
161 | return self.queryItemsAdded([URLQueryItem(name: name, value: value)])
162 | }
163 |
164 | func queryItemsAdded(_ queryItems: [URLQueryItem]) -> URL? {
165 | guard var components = URLComponents(url: self, resolvingAgainstBaseURL: nil != self.baseURL) else {
166 | return nil
167 | }
168 | components.queryItems = queryItems + (components.queryItems ?? [])
169 | return components.url
170 | }
171 |
172 | }
173 |
--------------------------------------------------------------------------------
/Platform/UseCases/DoLoginUseCase.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DoLoginUseCase.swift
3 | // Platform
4 | //
5 | // Created by Aarif Sumra on 2021/09/02.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import Domain
10 |
11 | final class DoLoginUseCase: Domain.DoLoginUseCase {
12 | // MARK: - Properties
13 | private let network: Networking
14 | private let endpoint: Endpoint
15 | private let baseURL: URL
16 |
17 | // MARK: - Init
18 | init(network: Networking, baseURL: URL, endpoint: Endpoint) {
19 | self.network = network
20 | self.baseURL = baseURL
21 | self.endpoint = endpoint
22 | }
23 |
24 | // MARK: - Execute with parameters
25 | func execute(_ parameters: (username: String, password: String), completion: ((Result) -> Void)?) {
26 | var request = URLRequest(url: endpointURL)
27 | request.allHTTPHeaderFields = endpoint.headers
28 | request.httpMethod = HTTPMethod.post.rawValue
29 | do {
30 | request.httpBody = try JSONSerialization.data(
31 | withJSONObject: ["username": parameters.username, "password": parameters.password], options: [])
32 | } catch let error {
33 | debugPrint(error.localizedDescription)
34 | completion?(.failure(error))
35 | }
36 |
37 | self.network.send(request) { result in
38 | switch result {
39 | case .success(let data):
40 | do {
41 | let item = try self.decoder.decode(Bool.self, from: data)
42 | completion?(.success(item))
43 | } catch let decodingError {
44 | completion?(.failure(decodingError))
45 | }
46 | case .failure(let networkError):
47 | completion?(.failure(networkError))
48 | }
49 | }
50 | }
51 | }
52 |
53 | extension DoLoginUseCase {
54 |
55 | private var decoder: JSONDecoder {
56 | let decoder = JSONDecoder()
57 | decoder.dateDecodingStrategy = .iso8601
58 | decoder.keyDecodingStrategy = .convertFromSnakeCase
59 | return decoder
60 | }
61 |
62 | private var endpointURL: URL {
63 | baseURL.appendingPathComponent(endpoint.relativePath)
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/Platform/UseCases/GetPostCommentsUseCase.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GetPostCommentsUseCase.swift
3 | // Platform
4 | //
5 | // Created by Aarif Sumra on 2021/09/03.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import Domain
10 |
11 | final class GetPostCommentsUseCase: Domain.GetPostCommentsUseCase {
12 |
13 | private let repository: RemoteRepository
14 |
15 | init(repository: RemoteRepository) {
16 | self.repository = repository
17 | }
18 |
19 | func execute(_ parameters: Int, completion: ((Result<[CommentEntity], Error>) -> Void)?) {
20 | repository.fetchAll { result in
21 | completion?(result.mapError { $0 as Error })
22 | }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Platform/UseCases/GetPostDetailUseCase.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GetPostDetailUseCase.swift
3 | // Platform
4 | //
5 | // Created by Aarif Sumra on 2021/09/01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import Domain
10 |
11 | final class GetPostDetailUseCase: Domain.GetPostDetailUseCase {
12 |
13 | private let repository: RemoteRepository
14 |
15 | init(repository: RemoteRepository) {
16 | self.repository = repository
17 | }
18 |
19 | func execute(_ id: Int, completion: ((Result) -> ())?){
20 | repository.fetch(withId: id) { result in
21 | completion?(result.mapError { $0 as Error })
22 | }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Platform/UseCases/GetPostsUseCase.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PostsUseCase.swift
3 | // Platform
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import Domain
10 |
11 | final class GetPostsUseCase: Domain.GetPostsUseCase {
12 |
13 | private let repository: RemoteRepository
14 |
15 | init(repository: RemoteRepository) {
16 | self.repository = repository
17 | }
18 |
19 | func execute(_ parameters: Void, completion: ((Result<[PostEntity], Error>) -> Void)?) {
20 | repository.fetchAll { result in
21 | completion?(result.mapError { $0 as Error })
22 | }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Platform/UseCases/UseCaseProvider.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UseCaseProvider.swift
3 | // Platform
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import Domain
10 |
11 | public final class UseCaseProvider: Domain.UseCaseProvider {
12 |
13 | private let baseURL: URL
14 |
15 | public init(baseURL: URL) {
16 | self.baseURL = baseURL
17 | }
18 |
19 | public func makeGetPostsUseCase() -> AnyUseCase {
20 | GetPostsUseCase(
21 | repository: postsRepository
22 | ).eraseToAnyUseCase()
23 | }
24 |
25 | public func makeGetPostDetailUseCase() -> AnyUseCase {
26 | GetPostDetailUseCase(
27 | repository: postsRepository
28 | ).eraseToAnyUseCase()
29 | }
30 |
31 | public func makeDoLoginUseCase() -> AnyUseCase<(username: String, password: String), Bool> {
32 | DoLoginUseCase(
33 | network: Network.default,
34 | baseURL: baseURL,
35 | endpoint: Endpoints.posts
36 | ).eraseToAnyUseCase()
37 | }
38 |
39 | public func makeGetPostCommentsUseCase(_ postId: Int) -> AnyUseCase {
40 | GetPostCommentsUseCase(
41 | repository: RemoteRepository(
42 | baseURL: baseURL,
43 | network: Network.default,
44 | endpoint: Endpoints.commentsFor(postId: postId)
45 | )
46 | ).eraseToAnyUseCase()
47 | }
48 | }
49 |
50 | extension UseCaseProvider {
51 |
52 | var postsRepository: RemoteRepository {
53 | RemoteRepository(
54 | baseURL: baseURL,
55 | network: Network.default,
56 | endpoint: Endpoints.posts
57 | )
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/PlatformTests/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | 1
21 |
22 |
23 |
--------------------------------------------------------------------------------
/PlatformTests/PlatformTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PlatformTests.swift
3 | // PlatformTests
4 | //
5 | // Created by Aarif Sumra on 2021-09-01.
6 | // Copyright © 2021 Monstar-Lab Inc. All rights reserved.
7 | //
8 |
9 | import XCTest
10 | @testable import Platform
11 |
12 | class PlatformTests: XCTestCase {
13 |
14 | override func setUpWithError() throws {
15 | // Put setup code here. This method is called before the invocation of each test method in the class.
16 | }
17 |
18 | override func tearDownWithError() throws {
19 | // Put teardown code here. This method is called after the invocation of each test method in the class.
20 | }
21 |
22 | func testExample() throws {
23 | // This is an example of a functional test case.
24 | // Use XCTAssert and related functions to verify your tests produce the correct results.
25 | }
26 |
27 | func testPerformanceExample() throws {
28 | // This is an example of a performance test case.
29 | self.measure {
30 | // Put the code you want to measure the time of here.
31 | }
32 | }
33 |
34 | }
35 |
--------------------------------------------------------------------------------
/Podfile.lock:
--------------------------------------------------------------------------------
1 | PODS:
2 | - Firebase/Analytics (7.3.0):
3 | - Firebase/Core
4 | - Firebase/Core (7.3.0):
5 | - Firebase/CoreOnly
6 | - FirebaseAnalytics (= 7.3.0)
7 | - Firebase/CoreOnly (7.3.0):
8 | - FirebaseCore (= 7.3.0)
9 | - Firebase/Crashlytics (7.3.0):
10 | - Firebase/CoreOnly
11 | - FirebaseCrashlytics (~> 7.3.0)
12 | - FirebaseAnalytics (7.3.0):
13 | - FirebaseCore (~> 7.0)
14 | - FirebaseInstallations (~> 7.0)
15 | - GoogleAppMeasurement (= 7.3.0)
16 | - GoogleUtilities/AppDelegateSwizzler (~> 7.0)
17 | - GoogleUtilities/MethodSwizzler (~> 7.0)
18 | - GoogleUtilities/Network (~> 7.0)
19 | - "GoogleUtilities/NSData+zlib (~> 7.0)"
20 | - nanopb (~> 2.30906.0)
21 | - FirebaseCore (7.3.0):
22 | - FirebaseCoreDiagnostics (~> 7.0)
23 | - GoogleUtilities/Environment (~> 7.0)
24 | - GoogleUtilities/Logger (~> 7.0)
25 | - FirebaseCoreDiagnostics (7.3.0):
26 | - GoogleDataTransport (~> 8.0)
27 | - GoogleUtilities/Environment (~> 7.0)
28 | - GoogleUtilities/Logger (~> 7.0)
29 | - nanopb (~> 2.30906.0)
30 | - FirebaseCrashlytics (7.3.0):
31 | - FirebaseCore (~> 7.0)
32 | - FirebaseInstallations (~> 7.0)
33 | - GoogleDataTransport (~> 8.0)
34 | - nanopb (~> 2.30906.0)
35 | - PromisesObjC (~> 1.2)
36 | - FirebaseInstallations (7.3.0):
37 | - FirebaseCore (~> 7.0)
38 | - GoogleUtilities/Environment (~> 7.0)
39 | - GoogleUtilities/UserDefaults (~> 7.0)
40 | - PromisesObjC (~> 1.2)
41 | - GoogleAppMeasurement (7.3.0):
42 | - GoogleUtilities/AppDelegateSwizzler (~> 7.0)
43 | - GoogleUtilities/MethodSwizzler (~> 7.0)
44 | - GoogleUtilities/Network (~> 7.0)
45 | - "GoogleUtilities/NSData+zlib (~> 7.0)"
46 | - nanopb (~> 2.30906.0)
47 | - GoogleDataTransport (8.1.0):
48 | - nanopb (~> 2.30906.0)
49 | - GoogleUtilities/AppDelegateSwizzler (7.1.1):
50 | - GoogleUtilities/Environment
51 | - GoogleUtilities/Logger
52 | - GoogleUtilities/Network
53 | - GoogleUtilities/Environment (7.1.1):
54 | - PromisesObjC (~> 1.2)
55 | - GoogleUtilities/Logger (7.1.1):
56 | - GoogleUtilities/Environment
57 | - GoogleUtilities/MethodSwizzler (7.1.1):
58 | - GoogleUtilities/Logger
59 | - GoogleUtilities/Network (7.1.1):
60 | - GoogleUtilities/Logger
61 | - "GoogleUtilities/NSData+zlib"
62 | - GoogleUtilities/Reachability
63 | - "GoogleUtilities/NSData+zlib (7.1.1)"
64 | - GoogleUtilities/Reachability (7.1.1):
65 | - GoogleUtilities/Logger
66 | - GoogleUtilities/UserDefaults (7.1.1):
67 | - GoogleUtilities/Logger
68 | - nanopb (2.30906.0):
69 | - nanopb/decode (= 2.30906.0)
70 | - nanopb/encode (= 2.30906.0)
71 | - nanopb/decode (2.30906.0)
72 | - nanopb/encode (2.30906.0)
73 | - PromisesObjC (1.2.12)
74 |
75 | DEPENDENCIES:
76 | - Firebase/Analytics
77 | - Firebase/Crashlytics
78 |
79 | SPEC REPOS:
80 | trunk:
81 | - Firebase
82 | - FirebaseAnalytics
83 | - FirebaseCore
84 | - FirebaseCoreDiagnostics
85 | - FirebaseCrashlytics
86 | - FirebaseInstallations
87 | - GoogleAppMeasurement
88 | - GoogleDataTransport
89 | - GoogleUtilities
90 | - nanopb
91 | - PromisesObjC
92 |
93 | SPEC CHECKSUMS:
94 | Firebase: 26223c695fe322633274198cb19dca8cb7e54416
95 | FirebaseAnalytics: 2580c2d62535ae7b644143d48941fcc239ea897a
96 | FirebaseCore: 4d3c72622ce0e2106aaa07bb4b2935ba2c370972
97 | FirebaseCoreDiagnostics: d50e11039e5984d92c8a512be2395f13df747350
98 | FirebaseCrashlytics: d31325312c92e2cb2f0386d589b9aa44e303d99b
99 | FirebaseInstallations: 971df89b48ae5ee4cc2bf6935f3857a525d28550
100 | GoogleAppMeasurement: 8d3c0aeede16ab7764144b5a4ca8e1d4323841b7
101 | GoogleDataTransport: 116c84c4bdeb76be2a7a46de51244368f9794eab
102 | GoogleUtilities: 3dc4ff0d5e4840e2fa8eef0889620e8c33d4218c
103 | nanopb: 1bf24dd71191072e120b83dd02d08f3da0d65e53
104 | PromisesObjC: 3113f7f76903778cf4a0586bd1ab89329a0b7b97
105 |
106 | PODFILE CHECKSUM: 3323356ea92621288c7e370851fbad1608d6300f
107 |
108 | COCOAPODS: 1.10.1
109 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ios-template
2 |
3 | An iOS project template to kickstart your project with CleanArchitecture with MVVM+Coordinator.
4 |
5 | ### Prerequisites:
6 | * [XcodeGen](https://github.com/yonaskolb/XcodeGen/blob/master/Docs/ProjectSpec.md#dependency)
7 |
8 |
9 | [Install Xcodegen](https://github.com/yonaskolb/XcodeGen#installing) by running below terminal command or suitable methods.
10 |
11 | ```
12 | brew install xcodegen
13 | ```
14 |
15 | ## Usage
16 |
17 |
18 | ### Create new repository from this template
19 |
20 | You can create new repository direcly from this Github template project. follow the instructions to create new project is given **[here](https://docs.github.com/en/github/creating-cloning-and-archiving-repositories/creating-a-repository-on-github/creating-a-repository-from-a-template)**.
21 |
22 |
23 | ### Clone the newly created repository
24 |
25 | Open Terminal.
26 |
27 | 1. Change the current working directory to the location where you want the cloned directory.
28 | 2. Type git clone, and then paste the URL you copied earlier.
29 | 3. `$ git clone https://github.com/YOUR-USERNAME/YOUR-REPOSITORY`
30 |
31 |
32 | ### Editing `project.yml`
33 |
34 | 1. find `#FIXME` comments
35 | 2. Replace the text of REPLACE_PROJECT_NAME with your desired project name e.g. YOUR_PROJECT_NAME
36 | 3. Replace the text of REPLACE_BUNDLE_ID_PREFIX with your desired project name e.g. REPLACE_BUNDLE_ID_PREFIX
37 |
38 | ### Run `Xcodegen` command
39 |
40 | Now just type `xcodegen` in terminal and return. This will create xcode project with *YOUR_PROJECT_NAME.xcodeproj*.
41 |
42 | ```shell
43 | $ xcodegen
44 | ⚙️ Generating plists...
45 | ⚙️ Generating project...
46 | ⚙️ Writing project...
47 | Created project at /Users/Aarif_Sumra/Developer/Github/GitHub/monstar-lab-oss/ios-template/YOUR_PROJECT_NAME.xcodeproj
48 | ```
49 |
50 | Now open it from command or using finder.
51 | ```shell
52 | $ open YOUR_PROJECT_NAME.xcodeproj
53 | ```
54 |
55 | You are done!
56 |
57 |
58 | ## 👥 Credits
59 | Made with ❤️ at Monstarlab
60 |
--------------------------------------------------------------------------------
/cp_googleservices_plist.sh:
--------------------------------------------------------------------------------
1 | # Use appropriate local file as per configuration
2 | GOOGLE_SERVICE_PLIST_FILE=GoogleService-Info.plist
3 | if [ $CONFIGURATION = "Debug" ]; then
4 | GOOGLE_SERVICE_PLIST_FILE=GoogleService-Info-Debug.plist
5 | else [ $CONFIGURATION = "Staging" ];
6 | GOOGLE_SERVICE_PLIST_FILE=GoogleService-Info-Staging.plist
7 | fi
8 | # If file exist then source it or show error
9 | FILE_PATH="${PROJECT_DIR}/Application/Resources/Firebase/${GOOGLE_SERVICE_PLIST_FILE}"
10 | echo $FILE_PATH
11 | if [ -f "$FILE_PATH" ]; then
12 | echo "Copying ${FILE_PATH} file for `${CONFIGURATION}` Configuration "
13 | cp -r ${FILE_PATH} "${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/GoogleService-Info.plist"
14 | exit 0
15 | else
16 | echo "warning: ${FILE_PATH} does not exist."
17 | echo "error: Provide ${FILE_PATH} file in project Resources/Firbase directory"
18 | exit -99
19 | fi
20 |
21 |
--------------------------------------------------------------------------------
/fastlane/Appfile:
--------------------------------------------------------------------------------
1 | #app_identifier("com.monstar-lab.ios.sample-ci.prod") # The bundle identifier of your app
2 | #apple_id("abc@xyz.com") # Your Apple email address
3 |
4 |
5 | # For more information about the Appfile, see:
6 | # https://docs.fastlane.tools/advanced/#appfile
7 |
--------------------------------------------------------------------------------
/fastlane/Fastfile:
--------------------------------------------------------------------------------
1 | # This file contains the fastlane.tools configuration
2 | # You can find the documentation at https://docs.fastlane.tools
3 | #
4 | # For a list of all available actions, check out
5 | #
6 | # https://docs.fastlane.tools/actions
7 | #
8 | # For a list of all available plugins, check out
9 | #
10 | # https://docs.fastlane.tools/plugins/available-plugins
11 | #
12 |
13 | # Uncomment the line if you want fastlane to automatically update itself
14 | # update_fastlane
15 |
16 | FIREBASE_CLI_PATH = './node_modules/.bin/firebase'
17 | ALIAS_MAP = {
18 | 'alpha' => 'Develop',
19 | 'beta' => 'Staging',
20 | 'prod'=> 'Production'
21 | }
22 |
23 | default_platform(:ios)
24 |
25 | platform :ios do
26 | before_all do
27 | # ensure_git_status_clean
28 | setup_circle_ci
29 | end
30 |
31 | desc 'Run unit test.'
32 | lane :test do
33 | run_tests
34 | end
35 |
36 | # Alpha, Beta and Prod lanes are used so that we can use Gymfile configuration
37 | desc 'Build alpha ipa'
38 | lane :alpha do |options|
39 | options[:environment] = "alpha"
40 | build_deploy options
41 | end
42 |
43 | desc 'Build beta ipa'
44 | lane :beta do |options|
45 | options[:environment] = "beta"
46 | build_deploy options
47 | end
48 |
49 | desc 'Build production ipa'
50 | lane :prod do |options|
51 | options[:environment] = "prod"
52 | build_deploy options
53 | end
54 |
55 | desc 'Build and deploy ipa'
56 | lane :build_deploy do |options|
57 | # Check if any new commits since last release
58 | is_releaseable = analyze_commits(match: "*#{options[:environment]}*")
59 | unless is_releaseable
60 | UI.important "No changes since last one hence skipping build"
61 | next
62 | end
63 | # Increment build number
64 | increment_build_number(
65 | build_number: lane_context[SharedValues::RELEASE_NEXT_VERSION] # set a specific number
66 | )
67 | # if you can use `match`, you use `match`.
68 | setup_provisioning_profiles
69 | # Build deploy and release on github
70 | build_app
71 | deploy_app options
72 | release_on_github
73 | end
74 |
75 | private_lane :deploy_app do |options|
76 |
77 | notes = conventional_changelog(title: "Change Log", format: "plain")
78 |
79 | environment = options[:environment]
80 | gsp_path = "SupportingFiles/GoogleService/#{ALIAS_MAP[environment]}-Info.plist"
81 | google_app_id = get_info_plist_value(path: gsp_path, key: 'GOOGLE_APP_ID')
82 |
83 | firebase_app_distribution(
84 | app: google_app_id,
85 | # groups: tester_group,
86 | release_notes: notes,
87 | firebase_cli_path: FIREBASE_CLI_PATH
88 | )
89 |
90 | if environment == 'prod'
91 | download_dsyms(
92 | version: 'latest'
93 | )
94 | end
95 |
96 | upload_symbols_to_crashlytics(gsp_path: gsp_path)
97 | clean_build_artifacts
98 | end
99 |
100 | desc "Release on github"
101 | private_lane :release_on_github do |options|
102 | notes = conventional_changelog(title: "Change Log")
103 |
104 | version_number = get_version_number
105 | build_number = get_build_number
106 |
107 | is_prerelease = true
108 | release_type = get_release_type options
109 | if release_type == "RELEASE"
110 | is_prerelease = false
111 | end
112 |
113 | name = "[#{release_type}] v#{version_number} Build: #{build_number}}"
114 |
115 | set_github_release(
116 | repository_name: "#{ENV['CIRCLE_PROJECT_USERNAME']}/#{ENV['CIRCLE_PROJECT_REPONAME']}",
117 | name: name,
118 | commitish: ENV['CIRCLE_SHA1'],
119 | description: notes,
120 | tag_name: "v#{version_number}/#{options[:environment]}/#{build_number}",
121 | is_prerelease: is_prerelease,
122 | upload_assets: [lane_context[SharedValues::IPA_OUTPUT_PATH]]
123 | )
124 | end
125 |
126 | private_lane :get_release_type do |options|
127 | next unless Helper.ci?
128 | branch = ENV['CIRCLE_BRANCH']
129 | release_type = "EXPERIMENTAL"
130 | if branch == "master"
131 | release_type = "RELEASE"
132 | elsif branch == "develop"
133 | release_type = "STAGING"
134 | elsif branch.start_with? "feature/sprint"
135 | release_type = "IN-HOUSE"
136 | elsif branch.start_with? "release/"
137 | release_type = "PRE-RELEASE"
138 | elsif branch.start_with? "hotfix/"
139 | release_type = "HOTFIX"
140 | end
141 | release_type
142 | end
143 |
144 | private_lane :setup_provisioning_profiles do |options|
145 | next unless Helper.ci?
146 |
147 | if File.exists?("~/Libary/Keychains/#{ENV['MATCH_KEYCHAIN_NAME']}.keychain-db")
148 | FileUtils.rm_f("~/Libary/Keychains/#{ENV['MATCH_KEYCHAIN_NAME']}.keychain-db")
149 | end
150 |
151 | create_keychain(
152 | name: ENV['MATCH_KEYCHAIN_NAME'],
153 | password: ENV["MATCH_KEYCHAIN_PASSWORD"],
154 | unlock: true,
155 | timeout: 3600,
156 | default_keychain: true
157 | )
158 |
159 | `curl -OL https://developer.apple.com/certificationauthority/AppleWWDRCA.cer`
160 | import_certificate(
161 | keychain_name: ENV['MATCH_KEYCHAIN_NAME'],
162 | keychain_password: ENV['MATCH_KEYCHAIN_PASSWORD'],
163 | certificate_path: 'fastlane/AppleWWDRCA.cer'
164 | )
165 |
166 | `echo #{ENV["DIST_CER_BASE64"]} | base64 -D > dist.cer`
167 | `echo #{ENV["DIST_KEY_BASE64"]} | base64 -D > dist.p12`
168 |
169 | import_certificate(
170 | keychain_name: ENV['MATCH_KEYCHAIN_NAME'],
171 | keychain_password: ENV['MATCH_KEYCHAIN_PASSWORD'],
172 | certificate_path: "fastlane/dist.p12",
173 | certificate_password: ENV["DIST_KEY_PASSWORD"])
174 | import_certificate(
175 | keychain_name: ENV['MATCH_KEYCHAIN_NAME'],
176 | keychain_password: ENV['MATCH_KEYCHAIN_PASSWORD'],
177 | certificate_path: "fastlane/dist.cer")
178 |
179 | Dir.glob('../profiles/*.mobileprovision').each {|filename|
180 | puts filename
181 | FastlaneCore::ProvisioningProfile.install(filename)
182 | }
183 | end
184 | end
185 |
--------------------------------------------------------------------------------
/fastlane/Gymfile:
--------------------------------------------------------------------------------
1 | # For more information about this configuration visit
2 | # https://docs.fastlane.tools/actions/gym/#gymfile
3 |
4 | # In general, you can use the options available
5 | # fastlane gym --help
6 |
7 | # Remove the # in front of the line to enable the option
8 |
9 | # scheme("Example")
10 |
11 | # sdk("iphoneos9.0")
12 |
13 | for_platform :ios do
14 |
15 | include_bitcode true
16 | include_symbols true
17 |
18 | for_lane :alpha do
19 | scheme 'Application'
20 | export_method 'development'
21 | end
22 |
23 | for_lane :beta do
24 | scheme 'Application'
25 | export_method 'ad-hoc'
26 | end
27 |
28 | for_lane :prod do
29 | scheme 'Application'
30 | export_method 'app-store'
31 | end
32 | end
--------------------------------------------------------------------------------
/fastlane/Pluginfile:
--------------------------------------------------------------------------------
1 | # Autogenerated by fastlane
2 | #
3 | # Ensure this file is checked in to source control!
4 |
5 | gem 'fastlane-plugin-semantic_release'
6 | gem 'fastlane-plugin-firebase_app_distribution'
7 | gem 'fastlane-plugin-versioning'
8 |
--------------------------------------------------------------------------------
/fastlane/README.md:
--------------------------------------------------------------------------------
1 | fastlane documentation
2 | ================
3 | # Installation
4 |
5 | Make sure you have the latest version of the Xcode command line tools installed:
6 |
7 | ```
8 | xcode-select --install
9 | ```
10 |
11 | Install _fastlane_ using
12 | ```
13 | [sudo] gem install fastlane -NV
14 | ```
15 | or alternatively using `brew cask install fastlane`
16 |
17 | # Available Actions
18 | ## iOS
19 | ### ios test
20 | ```
21 | fastlane ios test
22 | ```
23 | Run unit test.
24 | ### ios alpha
25 | ```
26 | fastlane ios alpha
27 | ```
28 | Build alpha ipa
29 | ### ios beta
30 | ```
31 | fastlane ios beta
32 | ```
33 | Build beta ipa
34 | ### ios prod
35 | ```
36 | fastlane ios prod
37 | ```
38 | Build production ipa
39 | ### ios build_deploy
40 | ```
41 | fastlane ios build_deploy
42 | ```
43 | Build and deploy ipa
44 |
45 | ----
46 |
47 | This README.md is auto-generated and will be re-generated every time [fastlane](https://fastlane.tools) is run.
48 | More information about fastlane can be found on [fastlane.tools](https://fastlane.tools).
49 | The documentation of fastlane can be found on [docs.fastlane.tools](https://docs.fastlane.tools).
50 |
--------------------------------------------------------------------------------
/fastlane/Scanfile:
--------------------------------------------------------------------------------
1 | # For more information about this configuration visit
2 | # https://docs.fastlane.tools/actions/scan/#scanfile
3 |
4 | # In general, you can use the options available
5 | # fastlane scan --help
6 |
7 | # Remove the # in front of the line to enable the option
8 |
9 | # scheme("Example")
10 |
11 | # open_report(true)
12 |
13 | # clean(true)
14 |
15 | # Enable skip_build to skip debug builds for faster test performance
16 | scheme 'sample-ci-ios'
17 | # skip_build true
18 | # clean true
19 | # disable_concurrent_testing true
20 | # max_concurrent_simulators 3
--------------------------------------------------------------------------------
/firebase_crashlytics.sh:
--------------------------------------------------------------------------------
1 | ${BUILD_DIR%Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run
2 |
--------------------------------------------------------------------------------
/profiles/README.md:
--------------------------------------------------------------------------------
1 | Please do the following to make fastlane builds work in CI environment:
2 | - [ ] convert your dist.p12(your private key for distribution) to base64 using cat dist.p12 | base64 | pbcopy and put it inside CI Environment as DIST_KEY_BASE64
3 | - [ ] convert your dist.cer(your certificate for distribution) to base64 using cat dist.cer | base64 | pbcopy and put it inside CI Environment as DIST_CER_BASE64
4 | - [ ] put password for dist.p12 in DIST_KEY_PASSWORD variable
--------------------------------------------------------------------------------
/project.yml:
--------------------------------------------------------------------------------
1 | name: REPLACE_PROJECT_NAME #FIXME
2 | packages:
3 | CombineCocoa:
4 | url: https://github.com/CombineCommunity/CombineCocoa
5 | from: 0.3.0
6 | CombineExt:
7 | url: https://github.com/CombineCommunity/CombineExt
8 | from: 1.3.0
9 | Firebase:
10 | url: https://github.com/firebase/firebase-ios-sdk
11 | from: 8.0.0
12 | configs:
13 | Debug: debug
14 | Staging: release
15 | Release: release
16 | options:
17 | bundleIdPrefix: REPLACE_BUNDLE_ID_PREFIX #FIXME
18 | findCarthageFrameworks: true
19 | deploymentTarget:
20 | iOS: 13.0
21 | targets:
22 | Domain:
23 | type: framework
24 | platform: iOS
25 | sources:
26 | - path: Domain
27 | scheme:
28 | testTargets:
29 | - DomainTests
30 | gatherCoverageData: false
31 | DomainTests:
32 | type: bundle.unit-test
33 | platform: iOS
34 | sources:
35 | - path: DomainTests
36 | Platform:
37 | type: framework
38 | platform: iOS
39 | sources:
40 | - path: Platform
41 | scheme:
42 | testTargets:
43 | - PlatformTests
44 | gatherCoverageData: false
45 | dependencies: #FIXME if necessary
46 | - target: Domain
47 | PlatformTests:
48 | type: bundle.unit-test
49 | platform: iOS
50 | sources:
51 | - path: PlatformTests
52 | Application:
53 | type: application
54 | platform: iOS
55 | sources:
56 | - path: Application
57 | configFiles:
58 | Debug: Application/Configuration/Debug.xcconfig
59 | Staging: Application/Configuration/Staging.xcconfig
60 | Release: Application/Configuration/Release.xcconfig
61 | scheme:
62 | testTargets:
63 | - ApplicationTests
64 | gatherCoverageData: false
65 | dependencies: #FIXME if necessary
66 | - target: Domain
67 | - target: Platform
68 | - package: CombineCocoa
69 | - package: CombineExt
70 | info:
71 | path: Application/Resources/Info.plist
72 | properties:
73 | ITSAppUsesNonExemptEncryption: false
74 | UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait]
75 | UILaunchStoryboardName: LaunchScreen
76 | UIApplicationSceneManifest:
77 | UIApplicationSupportsMultipleScenes: false
78 | UISceneConfigurations:
79 | UIWindowSceneSessionRoleApplication:
80 | - UISceneConfigurationName: Default Configuration
81 | UISceneDelegateClassName: $(PRODUCT_MODULE_NAME).SceneDelegate
82 | postCompileScripts:
83 | - path: swiftlint.sh
84 | name: Swiftlint
85 | postBuildScripts:
86 | - path: cp_googleservices_plist.sh
87 | name: Copy GoogleServices-Info.plist
88 | - path: firebase_crashlytics.sh
89 | name: Firebase Crashlytics dSYM
90 | settings:
91 | FRAMEWORK_SEARCH_PATHS:
92 | - $(inherited)
93 | DEVELOPMENT_TEAM: ABCDE12345 #FIXME
94 | ApplicationTests:
95 | type: bundle.unit-test
96 | platform: iOS
97 | sources:
98 | - path: ApplicationTests
99 |
--------------------------------------------------------------------------------
/swiftlint.sh:
--------------------------------------------------------------------------------
1 | if which swiftlint >/dev/null; then
2 | swiftlint
3 | else
4 | echo "warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint"
5 | fi
6 |
--------------------------------------------------------------------------------