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