├── keys.env ├── init ├── Config ├── secrets.dev.xcconfig ├── config.debug.xcconfig ├── config.staging.xcconfig ├── config.release.xcconfig └── secrets.xcconfig ├── setup-env ├── setup-env.sh ├── ios-baseUITests ├── Resources │ ├── LogOutSuccessfully.json │ ├── AuthenticationError.json │ ├── SignUpSuccessfully.json │ ├── GetProfileSuccessfully.json │ └── LoginSuccessfully.json ├── XCTestCaseExtension.swift ├── XCUIElementExtension.swift ├── Info.plist ├── NetworkMockerExtension.swift ├── NetworkMocker.swift ├── XCUIApplicationExtension.swift └── ios_baseUITests.swift ├── ios-base ├── Assets.xcassets │ ├── Contents.json │ ├── Colors │ │ ├── Contents.json │ │ ├── deleteButton.colorset │ │ │ └── Contents.json │ │ ├── mainTitle.colorset │ │ │ └── Contents.json │ │ ├── buttonBackground.colorset │ │ │ └── Contents.json │ │ ├── screenBackground.colorset │ │ │ └── Contents.json │ │ └── redirectButtonTitle.colorset │ │ │ └── Contents.json │ └── AppIcon.appiconset │ │ └── Contents.json ├── Common │ ├── Models │ │ ├── NetworkState.swift │ │ ├── ViewModelState.swift │ │ ├── User.swift │ │ ├── AuthViewModelStateDelegate.swift │ │ └── Session.swift │ ├── Protocols │ │ └── ActivityIndicatorPresenter.swift │ └── Views │ │ └── PlaceholderTextView.swift ├── Networking │ ├── Services │ │ ├── Endpoints │ │ │ ├── UserEndpoint.swift │ │ │ └── AuthEndpoint.swift │ │ ├── BaseURLConvertible.swift │ │ ├── UserServices.swift │ │ ├── AuthenticationServices.swift │ │ └── APIClient.swift │ ├── Extensions │ │ └── APIClient+Application.swift │ └── Types │ │ └── API │ │ └── SessionHeadersProvider.swift ├── Onboarding │ ├── ViewModels │ │ ├── FirstViewModel.swift │ │ ├── SignInViewModel.swift │ │ └── SignUpViewModel.swift │ ├── Routes │ │ └── OnboardingRoutes.swift │ └── Views │ │ ├── FirstViewController.swift │ │ ├── SignInViewController.swift │ │ └── SignUpViewController.swift ├── Home │ ├── Routes │ │ └── HomeRoutes.swift │ ├── ViewModels │ │ └── HomeViewModel.swift │ └── Views │ │ └── HomeViewController.swift ├── Extensions │ ├── JSONEncodingExtension.swift │ ├── UIApplicationExtension.swift │ ├── ColorExtension.swift │ ├── DictionaryExtension.swift │ ├── LabelExtension.swift │ ├── ImageExtension.swift │ ├── TextFieldExtension.swift │ ├── StringExtension.swift │ ├── FontExtension.swift │ ├── ViewControllerExtension.swift │ ├── ButtonExtension.swift │ └── ViewExtension.swift ├── Navigators │ ├── AppNavigator.swift │ └── Navigator.swift ├── Helpers │ ├── Constants.swift │ ├── UIConstants.swift │ └── Secret.swift ├── Analytics │ ├── AnalyticsService.swift │ ├── FirebaseAnalyticsService.swift │ ├── AnalyticsManager.swift │ └── AnalyticsEvent.swift ├── Managers │ ├── UserDataManager.swift │ └── SessionManager.swift ├── Resources │ ├── Localization │ │ └── Localizable.strings │ └── GoogleFirebase │ │ ├── GoogleService-Debug-Info.plist │ │ ├── GoogleService-Staging-Info.plist │ │ └── GoogleService-Info.plist ├── AppDelegate.swift ├── Info.plist └── LaunchScreen.storyboard ├── fastlane ├── Pluginfile ├── Appfile ├── README.md └── Fastfile ├── Gemfile ├── ios-base.xcodeproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved └── xcshareddata │ └── xcschemes │ ├── ios-base-staging.xcscheme │ ├── ios-base-production.xcscheme │ └── ios-base-develop.xcscheme ├── .slather.yml ├── PULL_REQUEST_TEMPLATE.md ├── CODEOWNERS ├── .github └── workflows │ ├── test.yml │ ├── ci.yml │ └── release.yml ├── ios-baseUnitTests ├── Info.plist ├── Extensions │ └── StringExtensionUnitTests.swift └── Services │ └── UserServiceUnitTests.swift ├── .codeclimate.yml ├── LICENSE.md ├── .swiftlint.yml ├── .gitignore ├── setup-env.swift ├── init.swift ├── Gemfile.lock └── README.md /keys.env: -------------------------------------------------------------------------------- 1 | # Add one key on each line 2 | #EXAMPLE_KEY 3 | -------------------------------------------------------------------------------- /init: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootstrap/ios-base/HEAD/init -------------------------------------------------------------------------------- /Config/secrets.dev.xcconfig: -------------------------------------------------------------------------------- 1 | EXAMPLE_KEY = example_secret_value 2 | -------------------------------------------------------------------------------- /setup-env: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rootstrap/ios-base/HEAD/setup-env -------------------------------------------------------------------------------- /setup-env.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | ./setup-env keys.env Config/secrets.xcconfig 3 | -------------------------------------------------------------------------------- /ios-baseUITests/Resources/LogOutSuccessfully.json: -------------------------------------------------------------------------------- 1 | { 2 | "success": 1 3 | } 4 | -------------------------------------------------------------------------------- /ios-baseUITests/Resources/AuthenticationError.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": "Authentication error" 3 | } 4 | -------------------------------------------------------------------------------- /ios-base/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /ios-base/Assets.xcassets/Colors/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Config/config.debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include "secrets.dev.xcconfig" 2 | // Add here any build settings you want for the Debug configuration 3 | -------------------------------------------------------------------------------- /Config/config.staging.xcconfig: -------------------------------------------------------------------------------- 1 | #include "secrets.xcconfig" 2 | // Add here any build settings you want for the Staging configuration 3 | -------------------------------------------------------------------------------- /Config/config.release.xcconfig: -------------------------------------------------------------------------------- 1 | #include "secrets.xcconfig" 2 | // Add here any build settings you want for the Release(Production) configuration 3 | -------------------------------------------------------------------------------- /fastlane/Pluginfile: -------------------------------------------------------------------------------- 1 | # Autogenerated by fastlane 2 | # 3 | # Ensure this file is checked in to source control! 4 | 5 | gem 'fastlane-plugin-aws_s3' -------------------------------------------------------------------------------- /Config/secrets.xcconfig: -------------------------------------------------------------------------------- 1 | //This file must remain empty in the source control. 2 | //It only exists for the build process to succeed in non-Debug build configurations. 3 | -------------------------------------------------------------------------------- /ios-baseUITests/Resources/SignUpSuccessfully.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "id": 1, 4 | "email": "automation@test.com", 5 | "username": "test" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'slather' 4 | gem "fastlane" 5 | plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile') 6 | eval_gemfile(plugins_path) if File.exist?(plugins_path) 7 | -------------------------------------------------------------------------------- /ios-base.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ios-baseUITests/Resources/GetProfileSuccessfully.json: -------------------------------------------------------------------------------- 1 | { 2 | "user": { 3 | "id": 1, 4 | "email": "automation@test.com", 5 | "first_name": "FirstName", 6 | "last_name": "LastName", 7 | "username": "test" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.slather.yml: -------------------------------------------------------------------------------- 1 | coverage_service: cobertura_xml 2 | xcodeproj: ios-base.xcodeproj 3 | scheme: ios-base-develop 4 | binary_basename: ios-base-Debug 5 | ignore: 6 | - Pods/* 7 | - ios-base/Extensions/UIImageExtension.swift 8 | - ios-base/AppDelegate.swift 9 | -------------------------------------------------------------------------------- /PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | #### Description: 2 | 3 | * 4 | 5 | --- 6 | 7 | 8 | #### Notes: 9 | 10 | * 11 | 12 | --- 13 | 14 | #### Tasks: 15 | 16 | 17 | --- 18 | 19 | #### Risk: 20 | 21 | * 22 | 23 | --- 24 | 25 | #### Preview: 26 | 27 | * N/A 28 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # These owners will be the default owners for everything in 2 | # the repo. Unless a later match takes precedence 3 | 4 | * @pMalvasio @glm4 @CamilaMoscatelli @mato2593 @germanStabile @kstoletniy @fpiruzi @LetoFranco @LeandroHiga @danialepaco @fconelli @fnicola1 @DaegoDev @JUNIOR140889 @SofiaCantero24 5 | -------------------------------------------------------------------------------- /ios-base/Common/Models/NetworkState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NetworkState.swift 3 | // ios-base 4 | // 5 | // Created by German on 5/20/20. 6 | // Copyright © 2020 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum NetworkState: Equatable { 12 | case idle, loading, error(_ error: String) 13 | } 14 | -------------------------------------------------------------------------------- /ios-base/Networking/Services/Endpoints/UserEndpoint.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import RSSwiftNetworking 3 | 4 | internal enum UserEndpoint: RailsAPIEndpoint { 5 | 6 | case profile 7 | 8 | var path: String { 9 | "/user/profile" 10 | } 11 | 12 | var method: Network.HTTPMethod { 13 | .get 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /ios-base.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios-base/Common/Models/ViewModelState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewModelState.swift 3 | // ios-base 4 | // 5 | // Created by Mauricio Cousillas on 6/13/19. 6 | // Copyright © 2019 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum ViewModelState: Equatable { 12 | case loading 13 | case error(String) 14 | case idle 15 | } 16 | -------------------------------------------------------------------------------- /ios-baseUITests/Resources/LoginSuccessfully.json: -------------------------------------------------------------------------------- 1 | { 2 | "user": { 3 | "id": 1, 4 | "email": "automation@test.com", 5 | "provider": "email", 6 | "uid": "automation@test.com", 7 | "first_name": "FirstName", 8 | "last_name": "LastName", 9 | "username": "test", 10 | "created_at": "2017-02-23T13:54:33.283Z", 11 | "updated_at": "2017-02-23T13:54:33.425Z" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /ios-base/Onboarding/ViewModels/FirstViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FirstViewModel.swift 3 | // ios-base 4 | // 5 | // Created by German Stabile on 11/2/18. 6 | // Copyright © 2018 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class FirstViewModel { 12 | 13 | var state: AuthViewModelState = .network(state: .idle) { 14 | didSet { 15 | delegate?.didUpdateState(to: state) 16 | } 17 | } 18 | 19 | weak var delegate: AuthViewModelStateDelegate? 20 | } 21 | -------------------------------------------------------------------------------- /ios-base/Home/Routes/HomeRoutes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HomeRoutes.swift 3 | // ios-base 4 | // 5 | // Created by Mauricio Cousillas on 6/13/19. 6 | // Copyright © 2019 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | enum HomeRoutes: Route { 13 | case home 14 | 15 | var screen: UIViewController { 16 | switch self { 17 | case .home: 18 | let home = HomeViewController(viewModel: HomeViewModel()) 19 | return home 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /ios-baseUITests/XCTestCaseExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // XCTestCaseExtension.swift 3 | // ios-baseUITests 4 | // 5 | // Created by Germán Stábile on 2/13/20. 6 | // Copyright © 2020 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | extension XCTestCase { 12 | func waitFor(element: XCUIElement, timeOut: TimeInterval) { 13 | let exists = NSPredicate(format: "exists == 1") 14 | 15 | expectation(for: exists, evaluatedWith: element, handler: nil) 16 | waitForExpectations(timeout: timeOut, handler: nil) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /ios-base/Extensions/JSONEncodingExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JSONEncodingExtension.swift 3 | // ios-base 4 | // 5 | // Created by German on 5/15/18. 6 | // Copyright © 2018 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension JSONDecoder { 12 | 13 | func decode( 14 | _ type: T.Type, from dictionary: [String: Any] 15 | ) throws -> T where T: Decodable { 16 | let data = try? JSONSerialization.data(withJSONObject: dictionary, options: []) 17 | return try decode(T.self, from: data ?? Data()) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /ios-base/Extensions/UIApplicationExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIApplicationExtension.swift 3 | // ios-base 4 | // 5 | // Created by Agustina Chaer on 24/10/17. 6 | // Copyright © 2017 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | extension UIApplication { 13 | class func showNetworkActivity() { 14 | UIApplication.shared.isNetworkActivityIndicatorVisible = true 15 | } 16 | 17 | class func hideNetworkActivity() { 18 | UIApplication.shared.isNetworkActivityIndicatorVisible = false 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: CI Build 2 | 3 | # Run for any commits to any branch 4 | on: [pull_request] 5 | 6 | env: 7 | LANG: en_US.UTF-8 8 | 9 | jobs: 10 | run-test-suite: 11 | runs-on: macos-latest 12 | timeout-minutes: 45 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v3 16 | - name: Run Test suite 17 | run: | 18 | xcodebuild test -project ios-base.xcodeproj -scheme ios-base-develop -configuration Debug -destination 'platform=iOS Simulator,name=iPhone 14,OS=16.2' | xcpretty && exit ${PIPESTATUS[0]} 19 | -------------------------------------------------------------------------------- /ios-base/Common/Models/User.swift: -------------------------------------------------------------------------------- 1 | // 2 | // User.swift 3 | // ios-base 4 | // 5 | // Created by Rootstrap on 1/18/17. 6 | // Copyright © 2017 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct User: Codable { 12 | var id: Int 13 | var username: String 14 | var email: String 15 | var image: URL? 16 | 17 | private enum CodingKeys: String, CodingKey { 18 | case id 19 | case username 20 | case email 21 | case image = "profile_picture" 22 | } 23 | } 24 | 25 | struct UserData: Codable { 26 | var data: User 27 | 28 | private enum CodingKeys: String, CodingKey { 29 | case data 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /ios-base/Networking/Extensions/APIClient+Application.swift: -------------------------------------------------------------------------------- 1 | import RSSwiftNetworking 2 | import RSSwiftNetworkingAlamofire 3 | 4 | /// Provides an easy-access APIClient implementation to use across the application 5 | /// You can define and configure as many APIClients as needed 6 | internal class Client { 7 | 8 | static let shared = Client() 9 | private let apiClient: APIClient 10 | 11 | init(apiClient: APIClient = BaseAPIClient( 12 | networkProvider: AlamofireNetworkProvider(), 13 | headersProvider: RailsAPIHeadersProvider( 14 | sessionProvider: SessionHeadersProvider() 15 | ) 16 | )) { 17 | self.apiClient = apiClient 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /ios-base/Navigators/AppNavigator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppNavigator.swift 3 | // ios-base 4 | // 5 | // Created by Mauricio Cousillas on 6/13/19. 6 | // Copyright © 2019 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | internal class AppNavigator: BaseNavigator { 12 | 13 | static let shared = AppNavigator(isLoggedIn: SessionManager.shared.currentSession != nil) 14 | 15 | init(isLoggedIn: Bool) { 16 | let initialRoute: Route = isLoggedIn 17 | ? HomeRoutes.home 18 | : OnboardingRoutes.firstScreen 19 | super.init(with: initialRoute) 20 | } 21 | 22 | required init(with route: Route) { 23 | super.init(with: route) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /ios-base/Extensions/ColorExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorExtension.swift 3 | // ios-base 4 | // 5 | // Created by Karen Stoletniy on 12/7/21. 6 | // Copyright © 2021 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension UIColor { 12 | 13 | // swiftlint:disable force_unwrapping 14 | static let buttonBackground = UIColor(named: "buttonBackground")! 15 | static let deleteButton = UIColor(named: "deleteButton")! 16 | static let mainTitle = UIColor(named: "mainTitle")! 17 | static let redirectButtonTitle = UIColor(named: "redirectButtonTitle")! 18 | static let screenBackground = UIColor(named: "screenBackground")! 19 | // swiftlint:enable force_unwrapping 20 | } 21 | -------------------------------------------------------------------------------- /ios-base/Helpers/Constants.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Constants.swift 3 | // ios-base 4 | // 5 | // Created by German Lopez on 3/29/16. 6 | // Copyright © 2016 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct App { 12 | static let domain = Bundle.main.bundleIdentifier ?? "" 13 | 14 | static func error( 15 | domain: ErrorDomain = .generic, code: Int? = nil, 16 | localizedDescription: String = "" 17 | ) -> NSError { 18 | NSError( 19 | domain: App.domain + "." + domain.rawValue, 20 | code: code ?? 0, 21 | userInfo: [NSLocalizedDescriptionKey: localizedDescription] 22 | ) 23 | } 24 | } 25 | 26 | enum ErrorDomain: String { 27 | case generic = "GenericError" 28 | case parsing = "ParsingError" 29 | case network = "NetworkError" 30 | } 31 | -------------------------------------------------------------------------------- /ios-baseUITests/XCUIElementExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // XCUIElementExtension.swift 3 | // ios-baseUITests 4 | // 5 | // Created by Germán Stábile on 2/13/20. 6 | // Copyright © 2020 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | extension XCUIElement { 12 | 13 | func clearText(text: String? = nil) { 14 | guard let stringValue = value as? String ?? text else { 15 | return 16 | } 17 | 18 | tap() 19 | let deleteString = 20 | stringValue.map { _ in XCUIKeyboardKey.delete.rawValue }.joined() 21 | typeText(deleteString) 22 | } 23 | 24 | func forceTap() { 25 | if isHittable { 26 | tap() 27 | } else { 28 | let coordinate = self.coordinate(withNormalizedOffset: CGVector(dx: 0.0, dy: 0.0)) 29 | coordinate.tap() 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /ios-baseUITests/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 | -------------------------------------------------------------------------------- /ios-baseUnitTests/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 | -------------------------------------------------------------------------------- /ios-base/Analytics/AnalyticsService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnalyticsService.swift 3 | // ios-base 4 | // 5 | // Created by Mauricio Cousillas on 6/11/19. 6 | // Copyright © 2019 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | /** 11 | Protocol that defines the minimum API that an AnalyticsService should expose. 12 | The AnalyticsService is in charge of handling event logging 13 | for a specific analytics platform. 14 | */ 15 | protocol AnalyticsService { 16 | /// Sets up the underlying service. Eg: FirebaseApp.configure() 17 | func setup() 18 | 19 | /// Identifies the user with a unique ID 20 | func identifyUser(with userId: String) 21 | 22 | /// Logs an event with it's associated metadata 23 | func log(event: AnalyticsEvent) 24 | 25 | /// Resets all stored data and user identification 26 | func reset() 27 | } 28 | -------------------------------------------------------------------------------- /ios-base/Helpers/UIConstants.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIConstants.swift 3 | // ios-base 4 | // 5 | // Created by Karen Stoletniy on 13/7/21. 6 | // Copyright © 2021 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | struct UI { 13 | enum Defaults { 14 | static let margin: CGFloat = 32 15 | static let spacing: CGFloat = 20 16 | } 17 | 18 | enum ViewController { 19 | static let topMargin: CGFloat = 72 20 | static let smallTopMargin: CGFloat = 40 21 | static let bottomMargin: CGFloat = 60 22 | } 23 | 24 | enum Button { 25 | static let cornerRadius: CGFloat = 22.0 26 | static let height: CGFloat = 45.0 27 | static let width: CGFloat = 200.0 28 | static let spacing: CGFloat = 20.0 29 | } 30 | 31 | enum TextField { 32 | static let height: CGFloat = 40.0 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /ios-base/Networking/Types/API/SessionHeadersProvider.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import RSSwiftNetworking 3 | 4 | internal protocol CurrentUserSessionProvider { 5 | var currentSession: Session? { get } 6 | } 7 | 8 | internal class SessionHeadersProvider: SessionProvider { 9 | 10 | // MARK: - Properties 11 | 12 | var uid: String? { 13 | session?.uid 14 | } 15 | 16 | var client: String? { 17 | session?.client 18 | } 19 | 20 | var accessToken: String? { 21 | session?.accessToken 22 | } 23 | 24 | private let currentSessionProvider: CurrentUserSessionProvider 25 | 26 | private var session: Session? { 27 | currentSessionProvider.currentSession 28 | } 29 | 30 | // MARK: - 31 | 32 | init(currentSessionProvider: CurrentUserSessionProvider = SessionManager.shared) { 33 | self.currentSessionProvider = currentSessionProvider 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | version: "2" # required to adjust maintainability checks 2 | checks: 3 | argument-count: 4 | config: 5 | threshold: 8 6 | complex-logic: 7 | config: 8 | threshold: 5 9 | file-lines: 10 | config: 11 | threshold: 500 12 | method-complexity: 13 | config: 14 | threshold: 5 15 | method-count: 16 | config: 17 | threshold: 20 18 | method-lines: 19 | config: 20 | threshold: 25 21 | nested-control-flow: 22 | config: 23 | threshold: 3 24 | return-statements: 25 | enabled: false 26 | similar-code: 27 | config: 28 | threshold: # language-specific defaults. an override will affect all languages. 29 | identical-code: 30 | config: 31 | threshold: # language-specific defaults. an override will affect all languages. 32 | plugins: 33 | swiftlint: 34 | enabled: true 35 | -------------------------------------------------------------------------------- /ios-base/Extensions/DictionaryExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DictionaryExtension.swift 3 | // ios-base 4 | // 5 | // Created by German on 6/26/17. 6 | // Copyright © 2017 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | // + Operator definition for Dictionary types 12 | 13 | func + (left: [K: V], right: [K: V]) -> [K: V] { 14 | var merge = left 15 | for (key, value) in right { 16 | merge[key] = value 17 | } 18 | return merge 19 | } 20 | 21 | func += (left: inout [K: V], right: [K: V]) { 22 | left = left + right 23 | } 24 | 25 | extension Dictionary where Key: ExpressibleByStringLiteral { 26 | 27 | mutating func lowercaseKeys() { 28 | for key in self.keys { 29 | if let loweredKey = String(describing: key).lowercased() as? Key { 30 | self[loweredKey] = self.removeValue(forKey: key) 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /ios-base/Assets.xcassets/Colors/deleteButton.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x5B", 9 | "green" : "0x45", 10 | "red" : "0xB1" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0x5B", 27 | "green" : "0x45", 28 | "red" : "0xB1" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /ios-base/Assets.xcassets/Colors/mainTitle.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x68", 9 | "green" : "0x6B", 10 | "red" : "0x74" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0x68", 27 | "green" : "0x6B", 28 | "red" : "0x74" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /ios-base/Assets.xcassets/Colors/buttonBackground.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "0.760", 8 | "blue" : "0x33", 9 | "green" : "0x33", 10 | "red" : "0x33" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "0.760", 26 | "blue" : "0x33", 27 | "green" : "0x33", 28 | "red" : "0x33" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /ios-base/Assets.xcassets/Colors/screenBackground.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xF3", 9 | "green" : "0xF3", 10 | "red" : "0xF3" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0xF3", 27 | "green" : "0xF3", 28 | "red" : "0xF3" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /ios-base/Assets.xcassets/Colors/redirectButtonTitle.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x38", 9 | "green" : "0x38", 10 | "red" : "0x38" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0x38", 27 | "green" : "0x38", 28 | "red" : "0x38" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /ios-base/Extensions/LabelExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LabelExtension.swift 3 | // ios-base 4 | // 5 | // Created by Karen Stoletniy on 12/7/21. 6 | // Copyright © 2021 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | extension UILabel { 13 | 14 | static func titleLabel ( 15 | text: String = "", 16 | font: UIFont = .h2Regular, 17 | textColor: UIColor = .mainTitle, 18 | backgroundColor: UIColor = .clear, 19 | numberOfLines: Int = 0, 20 | textAlignment: NSTextAlignment = .left 21 | ) -> UILabel { 22 | let label = UILabel() 23 | label.translatesAutoresizingMaskIntoConstraints = false 24 | label.text = text 25 | label.font = font 26 | label.textColor = textColor 27 | label.backgroundColor = backgroundColor 28 | label.numberOfLines = numberOfLines 29 | label.textAlignment = textAlignment 30 | 31 | return label 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /ios-base/Common/Protocols/ActivityIndicatorPresenter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ActivityIndicatorPresenter.swift 3 | // ios-base 4 | // 5 | // Created by Germán Stábile on 5/21/20. 6 | // Copyright © 2020 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | protocol ActivityIndicatorPresenter: AnyObject { 12 | var activityIndicator: UIActivityIndicatorView { get } 13 | func showActivityIndicator(_ show: Bool) 14 | } 15 | 16 | extension ActivityIndicatorPresenter where Self: UIViewController { 17 | func showActivityIndicator(_ show: Bool) { 18 | view.isUserInteractionEnabled = !show 19 | 20 | guard show else { 21 | activityIndicator.removeFromSuperview() 22 | return 23 | } 24 | 25 | if activityIndicator.superview == nil { 26 | view.addSubview(activityIndicator) 27 | } 28 | activityIndicator.color = .black 29 | activityIndicator.frame = view.bounds 30 | activityIndicator.startAnimating() 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /ios-base/Extensions/ImageExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIImageExtension.swift 3 | // ios-base 4 | // 5 | // Created by German on 8/21/18. 6 | // Copyright © 2018 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | extension UIImage { 13 | 14 | class func random(size: CGSize = CGSize(width: 100, height: 100)) -> UIImage { 15 | let red = CGFloat.random(in: 0...255) 16 | let green = CGFloat.random(in: 0...255) 17 | let blue = CGFloat.random(in: 0...255) 18 | let color = UIColor(red: red / 255, green: green / 255, blue: blue / 255, alpha: 1.0) 19 | UIGraphicsBeginImageContext(size) 20 | let context = UIGraphicsGetCurrentContext() 21 | context?.setFillColor(color.cgColor) 22 | context?.addRect(CGRect(origin: .zero, size: size)) 23 | context?.fillPath() 24 | let image = UIGraphicsGetImageFromCurrentImageContext() 25 | UIGraphicsEndImageContext() 26 | return image ?? UIImage() 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /fastlane/Appfile: -------------------------------------------------------------------------------- 1 | # app_identifier("[[APP_IDENTIFIER]]") # The bundle identifier of your app 2 | # apple_id("[[APPLE_ID]]") # Your Apple email address 3 | 4 | 5 | # For more information about the Appfile, see: 6 | # https://docs.fastlane.tools/advanced/#appfile 7 | 8 | for_platform :ios do 9 | 10 | apple_id(ENV['FASTLANE_USER']) 11 | 12 | team_id(ENV['APPLE_TEAM_ID']) 13 | 14 | for_lane :build_develop do 15 | app_identifier('com.rootstrap.ios-base-develop') 16 | end 17 | 18 | for_lane :release_develop do 19 | app_identifier('com.rootstrap.ios-base-develop') 20 | end 21 | 22 | for_lane :build_staging do 23 | app_identifier('com.rootstrap.ios-base-staging') 24 | end 25 | 26 | for_lane :release_staging do 27 | app_identifier('com.rootstrap.ios-base-staging') 28 | end 29 | 30 | for_lane :build_production do 31 | app_identifier('com.rootstrap.ios-base-production') 32 | end 33 | 34 | for_lane :release_production do 35 | app_identifier('com.rootstrap.ios-base-production') 36 | end 37 | 38 | end 39 | -------------------------------------------------------------------------------- /ios-base/Managers/UserDataManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserDataManager.swift 3 | // ios-base 4 | // 5 | // Created by Rootstrap on 15/2/16. 6 | // Copyright © 2016 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class UserDataManager: NSObject { 12 | 13 | static let shared = UserDataManager() 14 | 15 | static let USERKEY = "ios-base-user" 16 | 17 | var currentUser: User? { 18 | get { 19 | let defaults = UserDefaults.standard 20 | if 21 | let data = defaults.data(forKey: UserDataManager.USERKEY), 22 | let user = try? JSONDecoder().decode(User.self, from: data) 23 | { 24 | return user 25 | } 26 | return nil 27 | } 28 | 29 | set { 30 | let user = try? JSONEncoder().encode(newValue) 31 | UserDefaults.standard.set(user, forKey: UserDataManager.USERKEY) 32 | } 33 | } 34 | 35 | func deleteUser() { 36 | UserDefaults.standard.removeObject(forKey: UserDataManager.USERKEY) 37 | } 38 | 39 | var isUserLogged: Bool { 40 | currentUser != nil 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /ios-base/Helpers/Secret.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Enclosing type to handle secret values configuration and fetching. 4 | enum Secret { 5 | 6 | // All keys for secrets used within the app. 7 | enum Key: String { 8 | case exampleKey = "ExampleKey" 9 | } 10 | 11 | enum Error: Swift.Error { 12 | case missingKey, invalidValue 13 | } 14 | 15 | /// Fetches the secret value, if any, for the given key. 16 | /// - Parameters: 17 | /// - key: The `Secret.Key` object to search the value for. 18 | /// - Returns: The value if found. An expception is thrown otherwise. 19 | static func value( 20 | for key: Secret.Key 21 | ) throws -> T where T: LosslessStringConvertible { 22 | guard let object = Bundle.main.object(forInfoDictionaryKey: key.rawValue) else { 23 | throw Error.missingKey 24 | } 25 | 26 | switch object { 27 | case let value as T: 28 | return value 29 | case let string as String: 30 | guard let value = T(string) else { fallthrough } 31 | return value 32 | default: 33 | throw Error.invalidValue 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /ios-base/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "size" : "20x20", 6 | "scale" : "2x" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "size" : "20x20", 11 | "scale" : "3x" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "size" : "29x29", 16 | "scale" : "2x" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "size" : "29x29", 21 | "scale" : "3x" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "size" : "40x40", 26 | "scale" : "2x" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "size" : "40x40", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "size" : "60x60", 36 | "scale" : "2x" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "size" : "60x60", 41 | "scale" : "3x" 42 | }, 43 | { 44 | "idiom" : "ios-marketing", 45 | "size" : "1024x1024", 46 | "scale" : "1x" 47 | } 48 | ], 49 | "info" : { 50 | "version" : 1, 51 | "author" : "xcode" 52 | } 53 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Rootstrap 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /ios-base/Resources/Localization/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* 2 | Localizable.strings 3 | ios-base 4 | 5 | Created by Karen Stoletniy on 12/7/21. 6 | Copyright © 2021 Rootstrap Inc. All rights reserved. 7 | */ 8 | 9 | // MARK: FIRST SCREEN 10 | "firstscreen_title" = "Welcome to \nRootstrap's iOS demo app."; 11 | "firstscreen_login_button_title" = "LOG IN"; 12 | "firstscreen_registre_button_title" = "Don’t have an account? Let’s create one ►"; 13 | 14 | // MARK: SIGN IN SCREEN 15 | "signin_title" = "Sign In"; 16 | "signin_email_placeholder" = "Email"; 17 | "signin_password_placeholder" = "Password"; 18 | "signin_button_title" = "LOG IN"; 19 | 20 | // MARK: SIGN UP SCREEN 21 | "signup_title" = "Sign Up"; 22 | "signup_email_placeholder" = "Email"; 23 | "signup_password_placeholder" = "Password"; 24 | "signup_confirm_password_placeholder" = "Confirm Password"; 25 | "signup_button_title" = "SIGN UP"; 26 | 27 | // MARK: HOME SCREEN 28 | 29 | "homescreen_title" = "You are signed in/up"; 30 | "homescreen_get_profile_button_title" = "Get my profile"; 31 | "homescreen_logout_button_title" = "LOG OUT"; 32 | "homescreen_delete_button_title" = "DELETE ACCOUNT"; 33 | -------------------------------------------------------------------------------- /ios-base/Analytics/FirebaseAnalyticsService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FirebaseAnalytics.swift 3 | // ios-base 4 | // 5 | // Created by Mauricio Cousillas on 6/11/19. 6 | // Copyright © 2019 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Firebase 11 | 12 | class FirebaseAnalyticsService: AnalyticsService { 13 | func setup() { 14 | guard 15 | let googleServicesPath = Bundle.main.object( 16 | forInfoDictionaryKey: "GoogleServicesFileName" 17 | ) as? String, 18 | let filePath = Bundle.main.path(forResource: googleServicesPath, ofType: "plist"), 19 | let firebaseOptions = FirebaseOptions(contentsOfFile: filePath) else { 20 | print(""" 21 | Failed to initialize firebase options, please check your configuration settings 22 | """) 23 | return 24 | } 25 | FirebaseApp.configure(options: firebaseOptions) 26 | } 27 | 28 | func identifyUser(with userId: String) { 29 | Analytics.setUserID(userId) 30 | } 31 | 32 | func log(event: AnalyticsEvent) { 33 | Analytics.logEvent(event.name, parameters: event.parameters) 34 | } 35 | 36 | func reset() { 37 | Analytics.resetAnalyticsData() 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /ios-base/Onboarding/Routes/OnboardingRoutes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OnboardingRoutes.swift 3 | // ios-base 4 | // 5 | // Created by Mauricio Cousillas on 6/13/19. 6 | // Copyright © 2019 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | enum OnboardingRoutes: Route { 13 | case firstScreen 14 | case signIn 15 | case signUp 16 | 17 | var screen: UIViewController { 18 | switch self { 19 | case .firstScreen: 20 | return buildFirstViewController() 21 | case .signIn: 22 | return buildSignInViewController() 23 | case .signUp: 24 | return buildSignUpViewController() 25 | } 26 | } 27 | 28 | private func buildSignInViewController() -> UIViewController { 29 | let signIn = SignInViewController(viewModel: SignInViewModelWithCredentials()) 30 | return signIn 31 | } 32 | 33 | private func buildSignUpViewController() -> UIViewController { 34 | let signUp = SignUpViewController(viewModel: SignUpViewModelWithEmail()) 35 | return signUp 36 | } 37 | 38 | private func buildFirstViewController() -> UIViewController { 39 | let firstViewController = FirstViewController(viewModel: FirstViewModel()) 40 | return firstViewController 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /ios-base/Analytics/AnalyticsManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnalyticsManager.swift 3 | // ios-base 4 | // 5 | // Created by Mauricio Cousillas on 6/11/19. 6 | // Copyright © 2019 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | /** 11 | Base component in charge of logging events on the application. 12 | The goal of this class is to act as a proxy 13 | between the app and all the analytics services that are integrated. 14 | Broadcast every event to all of it associated services. 15 | */ 16 | class AnalyticsManager: AnalyticsService { 17 | /** 18 | List of services that will be notified. 19 | You can either customize this class and add new ones, 20 | or subclass it and override the variable. 21 | */ 22 | open var services: [AnalyticsService] = [FirebaseAnalyticsService()] 23 | 24 | static let shared = AnalyticsManager() 25 | 26 | public func setup() { 27 | services.forEach { $0.setup() } 28 | } 29 | 30 | public func identifyUser(with userId: String) { 31 | services.forEach { $0.identifyUser(with: userId) } 32 | } 33 | 34 | public func log(event: AnalyticsEvent) { 35 | services.forEach { $0.log(event: event) } 36 | } 37 | 38 | func reset() { 39 | services.forEach { $0.reset() } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /ios-base/Resources/GoogleFirebase/GoogleService-Debug-Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CLIENT_ID 6 | 595648208500-9tdjhr79a8ct32jja2ar24ml3q22frll.apps.googleusercontent.com 7 | REVERSED_CLIENT_ID 8 | com.googleusercontent.apps.595648208500-9tdjhr79a8ct32jja2ar24ml3q22frll 9 | API_KEY 10 | AIzaSyCUFO5l9VPLBI1ldtvHujj4Rooh4zZqtsg 11 | GCM_SENDER_ID 12 | 595648208500 13 | PLIST_VERSION 14 | 1 15 | BUNDLE_ID 16 | com.rootstrap.ios-base-Debug 17 | PROJECT_ID 18 | test-ios-23c80 19 | STORAGE_BUCKET 20 | test-ios-23c80.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:595648208500:ios:cd7d7a82783abb95bcfb21 33 | 34 | 35 | -------------------------------------------------------------------------------- /ios-base/Resources/GoogleFirebase/GoogleService-Staging-Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CLIENT_ID 6 | 595648208500-9tdjhr79a8ct32jja2ar24ml3q22frll.apps.googleusercontent.com 7 | REVERSED_CLIENT_ID 8 | com.googleusercontent.apps.595648208500-9tdjhr79a8ct32jja2ar24ml3q22frll 9 | API_KEY 10 | AIzaSyCUFO5l9VPLBI1ldtvHujj4Rooh4zZqtsg 11 | GCM_SENDER_ID 12 | 595648208500 13 | PLIST_VERSION 14 | 1 15 | BUNDLE_ID 16 | com.rootstrap.ios-base-Staging 17 | PROJECT_ID 18 | test-ios-23c80 19 | STORAGE_BUCKET 20 | test-ios-23c80.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:595648208500:ios:cd7d7a82783abb95bcfb21 33 | 34 | 35 | -------------------------------------------------------------------------------- /ios-base/Resources/GoogleFirebase/GoogleService-Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CLIENT_ID 6 | 595648208500-9tdjhr79a8ct32jja2ar24ml3q22frll.apps.googleusercontent.com 7 | REVERSED_CLIENT_ID 8 | com.googleusercontent.apps.595648208500-9tdjhr79a8ct32jja2ar24ml3q22frll 9 | API_KEY 10 | AIzaSyCUFO5l9VPLBI1ldtvHujj4Rooh4zZqtsg 11 | GCM_SENDER_ID 12 | 595648208500 13 | PLIST_VERSION 14 | 1 15 | BUNDLE_ID 16 | com.rootstrap.ios-base 17 | PROJECT_ID 18 | test-ios-23c80 19 | STORAGE_BUCKET 20 | test-ios-23c80.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:595648208500:ios:cd7d7a82783abb95bcfb21 33 | 34 | -------------------------------------------------------------------------------- /ios-baseUnitTests/Extensions/StringExtensionUnitTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StringExtensionUnitTests.swift 3 | // ios-baseUnitTests 4 | // 5 | // Created by German on 4/30/20. 6 | // Copyright © 2020 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import ios_base_Debug 11 | 12 | class StringExtensionUnitTests: XCTestCase { 13 | func testEmailValidation() { 14 | XCTAssertFalse("username".isEmailFormatted()) 15 | XCTAssertFalse("username@test".isEmailFormatted()) 16 | XCTAssert("username@test.com".isEmailFormatted()) 17 | XCTAssert("username.alias+2@gmail.com".isEmailFormatted()) 18 | } 19 | 20 | func testAlphanumeric() { 21 | XCTAssertFalse("123598 sdg asd".isAlphanumericWithNoSpaces) 22 | XCTAssert("1231234314".isAlphanumericWithNoSpaces) 23 | XCTAssert("asdgasdg".isAlphanumericWithNoSpaces) 24 | XCTAssert("asdgasd28352".isAlphanumericWithNoSpaces) 25 | } 26 | 27 | func testHasNumbers() { 28 | XCTAssertFalse("asdgasdfgkjasf ".hasNumbers) 29 | XCTAssertFalse("$#^&@".hasNumbers) 30 | XCTAssert("asd235".hasNumbers) 31 | XCTAssert("124".hasNumbers) 32 | } 33 | 34 | func testHasPunctuation() { 35 | XCTAssertFalse("asgfasfdhg123".hasPunctuationCharacters) 36 | XCTAssert("asdg,asd !".hasPunctuationCharacters) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /ios-base/Extensions/TextFieldExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UITextFieldExtension.swift 3 | // ios-base 4 | // 5 | // Created by German on 9/13/19. 6 | // Copyright © 2019 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension UITextField { 12 | 13 | convenience init( 14 | target: Any, 15 | selector: Selector, 16 | placeholder: String, 17 | identifier: String = "", 18 | backgroundColor: UIColor = .white, 19 | height: CGFloat = UI.TextField.height, 20 | borderStyle: BorderStyle = .line, 21 | isPassword: Bool = false 22 | ) { 23 | self.init() 24 | 25 | translatesAutoresizingMaskIntoConstraints = false 26 | addTarget(target, action: selector, for: .editingChanged) 27 | self.placeholder = placeholder 28 | self.backgroundColor = backgroundColor 29 | self.borderStyle = borderStyle 30 | accessibilityIdentifier = identifier 31 | heightAnchor.constraint(equalToConstant: height).isActive = true 32 | isSecureTextEntry = isPassword 33 | guard isPassword else { return } 34 | textContentType = .oneTimeCode 35 | } 36 | 37 | func setPlaceholder(color: UIColor = .lightGray) { 38 | attributedPlaceholder = NSAttributedString( 39 | string: placeholder ?? "", 40 | attributes: [NSAttributedString.Key.foregroundColor: color] 41 | ) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /ios-base/Common/Models/AuthViewModelStateDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthViewModelStateDelegate.swift 3 | // ios-base 4 | // 5 | // Created by German on 5/20/20. 6 | // Copyright © 2020 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | protocol NetworkStatusDelegate: AnyObject { 13 | func networkStatusChanged(to networkStatus: NetworkState) 14 | } 15 | 16 | protocol AuthViewModelStateDelegate: NetworkStatusDelegate { 17 | func didUpdateState(to state: AuthViewModelState) 18 | } 19 | 20 | extension AuthViewModelStateDelegate where Self: UIViewController { 21 | func didUpdateState(to state: AuthViewModelState) { 22 | switch state { 23 | case .network(let networkStatus): 24 | networkStatusChanged(to: networkStatus) 25 | case .loggedIn: 26 | AppNavigator.shared.navigate(to: HomeRoutes.home, with: .changeRoot) 27 | } 28 | } 29 | } 30 | 31 | extension NetworkStatusDelegate where Self: UIViewController { 32 | func networkStatusChanged(to networkStatus: NetworkState) { 33 | if let viewController = self as? ActivityIndicatorPresenter { 34 | viewController.showActivityIndicator(networkStatus == .loading) 35 | } 36 | switch networkStatus { 37 | case .error(let errorDescription): 38 | showMessage(title: "Error", message: errorDescription) 39 | default: 40 | break 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | disabled_rules: # rule identifiers to exclude from running 2 | - todo 3 | - trailing_whitespace 4 | - type_name # deactivated because swift3 need lowercase enums 5 | opt_in_rules: # some rules are only opt-in 6 | - array_init 7 | - empty_count 8 | - contains_over_first_not_nil 9 | - explicit_init 10 | - fatal_error_message 11 | - first_where 12 | - force_unwrapping 13 | - implicit_return 14 | - joined_default_parameter 15 | - literal_expression_end_indentation 16 | - operator_usage_whitespace 17 | - overridden_super_call 18 | - yoda_condition 19 | # Find all the available rules by running: 20 | # swiftlint rules 21 | whitelist_rules: 22 | excluded: # paths to ignore during linting. Takes precedence over `included`. 23 | - Carthage 24 | - Pods 25 | - init.swift 26 | - R.generated.swift 27 | force_cast: error 28 | force_try: 29 | severity: warning # explicitly 30 | line_length: 31 | warning: 90 32 | error: 100 33 | type_body_length: 34 | warning: 300 35 | error: 400 36 | file_length: 37 | warning: 500 38 | error: 850 39 | function_parameter_count: 40 | warning: 6 41 | error: 8 42 | cyclomatic_complexity: 43 | ignores_case_statements: true 44 | warning: 5 45 | error: 7 46 | function_body_length: 47 | warning: 25 48 | error: 32 49 | identifier_name: 50 | excluded: ["id"] 51 | reporter: "xcode" # reporter type (xcode, json, csv, checkstyle) 52 | -------------------------------------------------------------------------------- /ios-baseUITests/NetworkMockerExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NetworkMockerExtension.swift 3 | // ios-baseUITests 4 | // 5 | // Created by Germán Stábile on 6/30/20. 6 | // Copyright © 2020 Rootstrap. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension NetworkMocker { 12 | 13 | func stubSignUp(shouldSucceed: Bool = true) { 14 | let responseFileName = shouldSucceed ? "SignUpSuccessfully" : "AuthenticationError" 15 | 16 | setupStub( 17 | url: "/users/", 18 | responseFilename: responseFileName, 19 | method: .POST 20 | ) 21 | } 22 | 23 | func stubLogOut() { 24 | setupStub( 25 | url: "/users/sign_out", 26 | responseFilename: "LogOutSuccessfully", 27 | method: .DELETE 28 | ) 29 | } 30 | 31 | func stubDeleteAccount() { 32 | setupStub( 33 | url: "/user/delete_account", 34 | responseFilename: "LogOutSuccessfully", 35 | method: .DELETE 36 | ) 37 | } 38 | 39 | func stubLogIn(shouldSucceed: Bool = true) { 40 | let responseFilename = shouldSucceed ? "LoginSuccessfully" : "AuthenticationError" 41 | 42 | setupStub( 43 | url: "/users/sign_in", 44 | responseFilename: responseFilename, 45 | method: .POST 46 | ) 47 | } 48 | 49 | func stubGetProfile() { 50 | setupStub( 51 | url: "/user/profile", 52 | responseFilename: "GetProfileSuccessfully", 53 | method: .GET 54 | ) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /ios-base/Analytics/AnalyticsEvent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnalyticsEvent.swift 3 | // ios-base 4 | // 5 | // Created by Mauricio Cousillas on 6/11/19. 6 | // Copyright © 2019 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /** 12 | Protocol that defines the minimum attributes that an AnalyticsEvent should have. 13 | The AnalyticsService is in charge of parsing these events and logging 14 | them to a specific analytics platform. 15 | */ 16 | protocol AnalyticsEvent { 17 | /// The event name, usually used to identify itself. 18 | var name: String { get } 19 | /// Payload sent to the analytics service as extra information for the event. 20 | var parameters: [String: Any] { get } 21 | } 22 | 23 | /** 24 | Enums are a nice way of grouping events semantically. 25 | For example you could have enums for AuthEvents, ProfileEvents, SearchEvents, etc. 26 | Any data structure that conforms to the AnalyticsEvent protocol will work though. 27 | */ 28 | enum Event: AnalyticsEvent { 29 | case login 30 | case registerSuccess(email: String) 31 | 32 | public var name: String { 33 | switch self { 34 | case .login: 35 | return "login" 36 | case .registerSuccess: 37 | return "register_success" 38 | } 39 | } 40 | 41 | var parameters: [String: Any] { 42 | switch self { 43 | case .registerSuccess(let email): 44 | return ["user_email": email] 45 | default: 46 | return [:] 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /ios-base/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // ios-base 4 | // 5 | // Created by Rootstrap on 15/2/16. 6 | // Copyright © 2016 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Firebase 11 | import IQKeyboardManagerSwift 12 | 13 | @UIApplicationMain 14 | class AppDelegate: UIResponder, UIApplicationDelegate { 15 | 16 | static let shared: AppDelegate = { 17 | guard let appD = UIApplication.shared.delegate as? AppDelegate else { 18 | return AppDelegate() 19 | } 20 | return appD 21 | }() 22 | 23 | var window: UIWindow? 24 | 25 | func application( 26 | _ application: UIApplication, 27 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? 28 | ) -> Bool { 29 | // Override point for customization after application launch. 30 | AnalyticsManager.shared.setup() 31 | 32 | IQKeyboardManager.shared.enable = true 33 | 34 | let rootVC = AppNavigator.shared.rootViewController 35 | window?.rootViewController = rootVC 36 | 37 | return true 38 | } 39 | 40 | func unexpectedLogout() { 41 | UserDataManager.shared.deleteUser() 42 | SessionManager.shared.deleteSession() 43 | // Clear any local data if needed 44 | // Take user to onboarding if needed, do NOT redirect the user 45 | // if is already in the landing to avoid losing the current VC stack state. 46 | if window?.rootViewController is HomeViewController { 47 | AppNavigator.shared.navigate(to: OnboardingRoutes.firstScreen, with: .changeRoot) 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI on Main 2 | 3 | # Runs for merges on the main branch 4 | on: 5 | push: 6 | branches: 7 | - main 8 | 9 | env: 10 | LANG: en_US.UTF-8 11 | # CodeClimate 12 | CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} 13 | # Notifications 14 | SLACK_WEBHOOK_URL: ${{ secrets.SLACK_URL }} 15 | 16 | jobs: 17 | test-suite-and-report: 18 | runs-on: macos-latest 19 | timeout-minutes: 45 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v3 23 | - name: Bundler 24 | run: bundle install --jobs 4 --retry 3 25 | - name: Setup Code Climate test reporter 26 | run: | 27 | pwd 28 | curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-darwin-amd64 > ./cc-test-reporter 29 | chmod +x ./cc-test-reporter 30 | ./cc-test-reporter before-build 31 | - name: Run Test suite 32 | run: | 33 | xcodebuild test -project ios-base.xcodeproj -scheme ios-base-develop -configuration Debug -destination 'platform=iOS Simulator,name=iPhone 14,OS=16.2' | xcpretty && exit ${PIPESTATUS[0]} 34 | - name: Slather Coverage 35 | run: bundle exec slather 36 | - name: Report coverate to Code Climate 37 | run: ./cc-test-reporter after-build 38 | - name: Send notification of build result 39 | uses: 8398a7/action-slack@v3 40 | with: 41 | status: ${{ job.status }} 42 | text: 'ios-base build status is ${{ job.status }}' 43 | fields: repo,message,commit,author,action,eventName,ref,workflow,job,took 44 | if: always() 45 | -------------------------------------------------------------------------------- /ios-base/Extensions/StringExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StringExtension.swift 3 | // ios-base 4 | // 5 | // Created by Juan Pablo Mazza on 9/9/16. 6 | // Copyright © 2016 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension String { 12 | var isAlphanumericWithNoSpaces: Bool { 13 | let alphaNumSet = CharacterSet( 14 | charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" 15 | ) 16 | return rangeOfCharacter(from: alphaNumSet.inverted) == nil 17 | } 18 | 19 | var hasPunctuationCharacters: Bool { 20 | rangeOfCharacter(from: CharacterSet.punctuationCharacters) != nil 21 | } 22 | 23 | var hasNumbers: Bool { 24 | rangeOfCharacter(from: CharacterSet(charactersIn: "0123456789")) != nil 25 | } 26 | 27 | var localized: String { 28 | self.localize() 29 | } 30 | 31 | func localize(comment: String = "") -> String { 32 | NSLocalizedString(self, comment: comment) 33 | } 34 | 35 | var validFilename: String { 36 | guard !isEmpty else { return "emptyFilename" } 37 | return addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? "emptyFilename" 38 | } 39 | 40 | // Regex fulfill RFC 5322 Internet Message format 41 | func isEmailFormatted() -> Bool { 42 | // swiftlint:disable line_length 43 | let emailRegex = "[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+(\\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+)*@([A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?\\.)+[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?" 44 | // swiftlint:enable line_length 45 | let predicate = NSPredicate(format: "SELF MATCHES %@", emailRegex) 46 | return predicate.evaluate(with: self) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /ios-base/Networking/Services/BaseURLConvertible.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BaseURLConvertible.swift 3 | // ios-base 4 | // 5 | // Created by Germán Stábile on 6/8/20. 6 | // Copyright © 2020 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Alamofire 11 | 12 | class BaseURLConvertible: URLConvertible { 13 | 14 | let path: String 15 | let baseUrl: String 16 | 17 | init(path: String, baseUrl: String = APIClient.getBaseUrl()) { 18 | self.path = path 19 | self.baseUrl = baseUrl 20 | } 21 | 22 | func asURL() throws -> URL { 23 | try "\(baseUrl)\(path)".asURL() 24 | } 25 | } 26 | 27 | class BaseURLRequestConvertible: URLRequestConvertible { 28 | let url: URLConvertible 29 | let method: HTTPMethod 30 | let headers: HTTPHeaders 31 | let params: [String: Any]? 32 | let encoding: ParameterEncoding? 33 | 34 | func asURLRequest() throws -> URLRequest { 35 | let request = try URLRequest( 36 | url: url, 37 | method: method, 38 | headers: headers 39 | ) 40 | if let params = params, let encoding = encoding { 41 | return try encoding.encode(request, with: params) 42 | } 43 | 44 | return request 45 | } 46 | 47 | init( 48 | path: String, 49 | baseUrl: String = APIClient.getBaseUrl(), 50 | method: HTTPMethod, 51 | encoding: ParameterEncoding? = nil, 52 | params: [String: Any]? = nil, 53 | headers: [String: String] = [:] 54 | ) { 55 | url = BaseURLConvertible(path: path, baseUrl: baseUrl) 56 | self.method = method 57 | self.headers = HTTPHeaders(headers) 58 | self.params = params 59 | self.encoding = encoding 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## Build generated 6 | build/ 7 | DerivedData 8 | 9 | ## Various settings 10 | *.pbxuser 11 | !default.pbxuser 12 | *.mode1v3 13 | !default.mode1v3 14 | *.mode2v3 15 | !default.mode2v3 16 | *.perspectivev3 17 | !default.perspectivev3 18 | xcuserdata 19 | 20 | ## Other 21 | *.xccheckout 22 | *.moved-aside 23 | *.xcuserstate 24 | *.xcscmblueprint 25 | 26 | ## Obj-C/Swift specific 27 | *.hmap 28 | *.ipa 29 | 30 | # Swift Package Manager 31 | # 32 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 33 | # Packages/ 34 | .build/ 35 | 36 | # CocoaPods 37 | # 38 | # We recommend against adding the Pods directory to your .gitignore. However 39 | # you should judge for yourself, the pros and cons are mentioned at: 40 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 41 | # 42 | Pods/ 43 | 44 | # Carthage 45 | # 46 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 47 | # Carthage/Checkouts 48 | 49 | Carthage/Build 50 | 51 | # fastlane 52 | # 53 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 54 | # screenshots whenever they are needed. 55 | # For more information about the recommended setup visit: 56 | # https://github.com/fastlane/fastlane/blob/master/docs/Gitignore.md 57 | 58 | fastlane/report.xml 59 | fastlane/screenshots 60 | .bundle/ 61 | 62 | report.html 63 | report.junit 64 | 65 | **/.DS_Store 66 | 67 | # Private keys 68 | secrets.xcconfig 69 | -------------------------------------------------------------------------------- /ios-base/Extensions/FontExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FontExtension.swift 3 | // ios-base 4 | // 5 | // Created by Germán Stábile on 2/28/19. 6 | // Copyright © 2019 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import RSFontSizes 11 | 12 | extension UIFont { 13 | static let h1Regular: UIFont = .font(size: .heading1).withWeight(.regular) 14 | static let h2Regular: UIFont = .font(size: .heading2).withWeight(.regular) 15 | static let h3Regular: UIFont = .font(size: .heading3).withWeight(.regular) 16 | static let h1Medium: UIFont = .font(size: .heading1).withWeight(.medium) 17 | static let h2Medium: UIFont = .font(size: .heading2).withWeight(.regular) 18 | static let h3Medium: UIFont = .font(size: .heading3).withWeight(.regular) 19 | 20 | private func withWeight(_ weight: UIFont.Weight) -> UIFont { 21 | var attributes = fontDescriptor.fontAttributes 22 | var traits = (attributes[.traits] as? [UIFontDescriptor.TraitKey: Any]) ?? [:] 23 | 24 | traits[.weight] = weight 25 | 26 | attributes[.name] = nil 27 | attributes[.traits] = traits 28 | attributes[.family] = familyName 29 | 30 | let descriptor = UIFontDescriptor(fontAttributes: attributes) 31 | 32 | return UIFont(descriptor: descriptor, size: pointSize) 33 | } 34 | 35 | static func font(withName name: String = "", size: Sizes) -> UIFont { 36 | name.font( 37 | withWeight: .normal, 38 | size: PointSize.proportional(to: (.screen6_5Inch, size.rawValue)) 39 | ) ?? UIFont.systemFont(ofSize: size.rawValue) 40 | } 41 | 42 | public enum Sizes: CGFloat { 43 | case heading1 = 32.0 44 | case heading2 = 16.0 45 | case heading3 = 15.0 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /ios-base/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Base URL 6 | $(BASE_URL) 7 | CFBundleDevelopmentRegion 8 | en 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | 1.0 21 | CFBundleSignature 22 | ???? 23 | CFBundleVersion 24 | 1 25 | ExampleKey 26 | $(EXAMPLE_KEY) 27 | GoogleServicesFileName 28 | $(GOOGLE_SERVICES_FILE) 29 | ITSAppUsesNonExemptEncryption 30 | 31 | LSRequiresIPhoneOS 32 | 33 | NSAppTransportSecurity 34 | 35 | NSAllowsArbitraryLoads 36 | 37 | NSAllowsLocalNetworking 38 | 39 | 40 | UILaunchStoryboardName 41 | LaunchScreen 42 | UIMainStoryboardFile 43 | LaunchScreen 44 | UIRequiredDeviceCapabilities 45 | 46 | armv7 47 | 48 | UISupportedInterfaceOrientations 49 | 50 | UIInterfaceOrientationPortrait 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /ios-baseUITests/NetworkMocker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NetworkMocker.swift 3 | // ios-baseUITests 4 | // 5 | // Created by Germán Stábile on 6/30/20. 6 | // Copyright © 2020 Rootstrap. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Swifter 11 | 12 | enum HTTPMethod { 13 | case POST 14 | case GET 15 | case PUT 16 | case DELETE 17 | } 18 | 19 | class NetworkMocker { 20 | 21 | var server = HttpServer() 22 | 23 | func setUp() throws { 24 | try server.start() 25 | } 26 | 27 | func tearDown() { 28 | server.stop() 29 | } 30 | 31 | public func setupStub( 32 | url: String, 33 | responseFilename: String, 34 | method: HTTPMethod = .GET 35 | ) { 36 | let testBundle = Bundle(for: type(of: self)) 37 | let filePath = testBundle.path(forResource: responseFilename, ofType: "json") ?? "" 38 | let fileUrl = URL(fileURLWithPath: filePath) 39 | guard let data = try? Data(contentsOf: fileUrl, options: .uncached) else { 40 | fatalError("Could not parse mocked data") 41 | } 42 | let json = dataToJSON(data: data) 43 | 44 | let response: ((HttpRequest) -> HttpResponse) = { _ in 45 | HttpResponse.ok(.json(json as AnyObject)) 46 | } 47 | 48 | switch method { 49 | case .GET: server.GET[url] = response 50 | case .POST: server.POST[url] = response 51 | case .DELETE: server.DELETE[url] = response 52 | case .PUT: server.PUT[url] = response 53 | } 54 | } 55 | 56 | func dataToJSON(data: Data) -> Any? { 57 | do { 58 | return try JSONSerialization.jsonObject(with: data, options: .mutableContainers) 59 | } catch let error { 60 | print(error) 61 | } 62 | return nil 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /ios-base/Networking/Services/UserServices.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserServices.swift 3 | // ios-base 4 | // 5 | // Created by Germán Stábile on 6/8/20. 6 | // Copyright © 2020 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import RSSwiftNetworking 11 | import RSSwiftNetworkingAlamofire 12 | 13 | internal class UserServices { 14 | 15 | enum UserError: LocalizedError { 16 | case getMyProfile 17 | case mapping 18 | 19 | var errorDescription: String? { 20 | switch self { 21 | case .getMyProfile: 22 | return "userError_login".localized 23 | case .mapping: 24 | return "userError_mapping".localized 25 | } 26 | } 27 | } 28 | 29 | private let userDataManager: UserDataManager 30 | private let apiClient: BaseAPIClient 31 | 32 | init( 33 | userDataManager: UserDataManager = .shared, 34 | apiClient: BaseAPIClient = BaseAPIClient.alamofire 35 | ) { 36 | self.userDataManager = userDataManager 37 | self.apiClient = apiClient 38 | } 39 | 40 | @discardableResult func getMyProfile() async -> Result { 41 | let response: RequestResponse = await apiClient.request( 42 | endpoint: UserEndpoint.profile 43 | ) 44 | switch response.result { 45 | case .success(let user): 46 | if let user = user { 47 | userDataManager.currentUser = user.data 48 | return .success(user) 49 | } else { 50 | return .failure(UserError.mapping) 51 | } 52 | case .failure: 53 | let noUserFoundError = App.error( 54 | domain: .parsing, 55 | localizedDescription: "Could not parse a valid user".localized 56 | ) 57 | return .failure(UserError.getMyProfile) 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /ios-base/Networking/Services/Endpoints/AuthEndpoint.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import RSSwiftNetworking 3 | 4 | internal enum AuthEndpoint: RailsAPIEndpoint { 5 | 6 | case signIn(email: String, password: String) 7 | case signUp( 8 | email: String, 9 | password: String, 10 | passwordConfirmation: String, 11 | picture: Data? 12 | ) 13 | case logout 14 | case deleteAccount 15 | 16 | private static let usersURL = "/users/" 17 | private static let currentUserURL = "/user/" 18 | 19 | var path: String { 20 | switch self { 21 | case .signIn: 22 | return AuthEndpoint.usersURL + "sign_in" 23 | case .signUp: 24 | return AuthEndpoint.usersURL 25 | case .logout: 26 | return AuthEndpoint.usersURL + "sign_out" 27 | case .deleteAccount: 28 | return AuthEndpoint.currentUserURL + "delete_account" 29 | } 30 | } 31 | 32 | var method: Network.HTTPMethod { 33 | switch self { 34 | case .signIn, .signUp: 35 | return .post 36 | case .logout, .deleteAccount: 37 | return .delete 38 | } 39 | } 40 | 41 | var parameters: [String: Any] { 42 | switch self { 43 | case .signIn(let email, let password): 44 | return [ 45 | "user": [ 46 | "email": email, 47 | "password": password 48 | ] 49 | ] 50 | case .signUp(let email, let password, let passwordConfirmation, let picture): 51 | var parameters = [ 52 | "email": email, 53 | "password": password, 54 | "password_confirmation": passwordConfirmation 55 | ] 56 | if let pictureData = picture { 57 | parameters["image"] = pictureData.asBase64Param() 58 | } 59 | return ["user": parameters] 60 | default: 61 | return [:] 62 | } 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /ios-base/Onboarding/ViewModels/SignInViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SignInViewModel.swift 3 | // ios-base 4 | // 5 | // Created by German on 8/3/18. 6 | // Copyright © 2018 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | protocol SignInViewModelDelegate: AuthViewModelStateDelegate { 12 | func didUpdateCredentials() 13 | } 14 | 15 | internal class SignInViewModelWithCredentials { 16 | 17 | private let analyticsManager: AnalyticsManager 18 | 19 | private var state: AuthViewModelState = .network(state: .idle) { 20 | didSet { 21 | delegate?.didUpdateState(to: state) 22 | } 23 | } 24 | 25 | weak var delegate: SignInViewModelDelegate? 26 | 27 | var email = "" { 28 | didSet { 29 | delegate?.didUpdateCredentials() 30 | } 31 | } 32 | 33 | var password = "" { 34 | didSet { 35 | delegate?.didUpdateCredentials() 36 | } 37 | } 38 | 39 | var hasValidCredentials: Bool { 40 | email.isEmailFormatted() && !password.isEmpty 41 | } 42 | 43 | private let authServices: AuthenticationServices 44 | 45 | init( 46 | authServices: AuthenticationServices = AuthenticationServices(), 47 | analyticsManager: AnalyticsManager = .shared 48 | ) { 49 | self.authServices = authServices 50 | self.analyticsManager = analyticsManager 51 | } 52 | 53 | @MainActor func login() async { 54 | state = .network(state: .loading) 55 | let result = await authServices.login( 56 | email: email, 57 | password: password 58 | ) 59 | switch result { 60 | case .success: 61 | self.state = .loggedIn 62 | analyticsManager.identifyUser(with: self.email) 63 | analyticsManager.log(event: Event.login) 64 | case .failure(let error): 65 | self.state = .network(state: .error(error.localizedDescription)) 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /ios-base/Managers/SessionManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SessionDataManager.swift 3 | // ios-base 4 | // 5 | // Created by Juan Pablo Mazza on 11/8/16. 6 | // Copyright © 2016 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Combine 11 | import SwiftUI 12 | 13 | internal class SessionManager: CurrentUserSessionProvider { 14 | 15 | var isSessionValidPublisher: AnyPublisher { 16 | currentSessionPublisher.map { $0?.isValid ?? false }.eraseToAnyPublisher() 17 | } 18 | 19 | private var currentSessionPublisher: AnyPublisher { 20 | userDefaults.publisher(for: \.currentSession).eraseToAnyPublisher() 21 | } 22 | 23 | private var subscriptions = Set() 24 | private let userDefaults: UserDefaults 25 | 26 | static let SESSIONKEY = "ios-base-session" 27 | 28 | static let shared = SessionManager() 29 | 30 | init(userDefaults: UserDefaults = .standard) { 31 | self.userDefaults = userDefaults 32 | } 33 | 34 | private(set) var currentSession: Session? { 35 | get { 36 | userDefaults.currentSession 37 | } 38 | 39 | set { 40 | userDefaults.currentSession = newValue 41 | } 42 | } 43 | 44 | func deleteSession() { 45 | currentSession = nil 46 | } 47 | 48 | func saveUser(session: Session) { 49 | userDefaults.currentSession = session 50 | } 51 | } 52 | 53 | fileprivate extension UserDefaults { 54 | @objc dynamic var currentSession: Session? { 55 | get { 56 | if 57 | let data = data(forKey: SessionManager.SESSIONKEY), 58 | let session = try? JSONDecoder().decode(Session.self, from: data) 59 | { 60 | return session 61 | } 62 | return nil 63 | } 64 | set { 65 | let session = try? JSONEncoder().encode(newValue) 66 | set(session, forKey: SessionManager.SESSIONKEY) 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /ios-base/Extensions/ViewControllerExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIViewControllerExtension.swift 3 | // ios-base 4 | // 5 | // Created by ignacio chiazzo Cardarello on 10/20/16. 6 | // Copyright © 2016 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | extension UIViewController { 13 | // MARK: - Message Error 14 | func showMessage( 15 | title: String, message: String, 16 | handler: ((_ action: UIAlertAction) -> Void)? = nil 17 | ) { 18 | let alert = UIAlertController( 19 | title: title, message: message, preferredStyle: UIAlertController.Style.alert 20 | ) 21 | alert.addAction( 22 | UIAlertAction(title: "Ok", style: UIAlertAction.Style.default, handler: handler) 23 | ) 24 | present(alert, animated: true, completion: nil) 25 | } 26 | 27 | func goToScreen(withIdentifier identifier: String, 28 | storyboardId: String? = nil, 29 | modally: Bool = false, 30 | viewControllerConfigurationBlock: ((UIViewController) -> Void)? = nil) { 31 | var storyboard = self.storyboard 32 | 33 | if let storyboardId = storyboardId { 34 | storyboard = UIStoryboard(name: storyboardId, bundle: nil) 35 | } 36 | 37 | guard let viewController = 38 | storyboard?.instantiateViewController(withIdentifier: identifier) else { 39 | assert(false, "No view controller found with that identifier") 40 | return 41 | } 42 | 43 | viewControllerConfigurationBlock?(viewController) 44 | 45 | if modally { 46 | present(viewController, animated: true) 47 | } else { 48 | assert(navigationController != nil, "navigation controller is nil") 49 | navigationController?.pushViewController(viewController, animated: true) 50 | } 51 | } 52 | 53 | func applyDefaultUIConfigs() { 54 | view.backgroundColor = .screenBackground 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /ios-base/Common/Models/Session.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Session.swift 3 | // ios-base 4 | // 5 | // Created by Juan Pablo Mazza on 11/8/16. 6 | // Copyright © 2016 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import RSSwiftNetworking 11 | import RSSwiftNetworkingAlamofire 12 | 13 | class Session: NSObject, Codable { 14 | @objc dynamic var uid: String? 15 | @objc dynamic var client: String? 16 | @objc dynamic var accessToken: String? 17 | @objc dynamic var expiry: Date? 18 | 19 | private enum CodingKeys: String, CodingKey { 20 | case uid 21 | case client 22 | case accessToken = "access-token" 23 | case expiry 24 | } 25 | 26 | var isValid: Bool { 27 | guard 28 | let uid = uid, 29 | let token = accessToken, 30 | let client = client 31 | else { 32 | return false 33 | } 34 | 35 | return !uid.isEmpty && !token.isEmpty && !client.isEmpty 36 | } 37 | 38 | init( 39 | uid: String? = nil, client: String? = nil, 40 | token: String? = nil, expires: Date? = nil 41 | ) { 42 | self.uid = uid 43 | self.client = client 44 | self.accessToken = token 45 | self.expiry = expires 46 | } 47 | 48 | init?(headers: [AnyHashable: Any]) { 49 | guard var stringHeaders = headers as? [String: String] else { 50 | return nil 51 | } 52 | 53 | stringHeaders.lowercaseKeys() 54 | 55 | if let expiryString = stringHeaders[HTTPHeader.expiry.rawValue], 56 | let expiryNumber = Double(expiryString) { 57 | expiry = Date(timeIntervalSince1970: expiryNumber) 58 | } 59 | uid = stringHeaders[HTTPHeader.uid.rawValue] 60 | client = stringHeaders[HTTPHeader.client.rawValue] 61 | accessToken = stringHeaders[HTTPHeader.token.rawValue] 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /ios-base/Extensions/ButtonExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ButtonExtension.swift 3 | // ios-base 4 | // 5 | // Created by Karen Stoletniy on 12/7/21. 6 | // Copyright © 2021 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | struct ButtonProperties { 13 | var color: UIColor = .buttonBackground 14 | var title: String = "" 15 | var accessibilityIdentifier: String = "" 16 | var titleColor: UIColor = .white 17 | var cornerRadius: CGFloat = UI.Button.cornerRadius 18 | var height: CGFloat = UI.Button.height 19 | var font: UIFont = .h3Medium 20 | var target: Any? 21 | var action: Selector? 22 | } 23 | 24 | extension UIButton { 25 | 26 | static func primaryButton(properties: ButtonProperties) -> UIButton { 27 | let button = UIButton() 28 | button.setup( 29 | color: properties.color, 30 | title: properties.title, 31 | identifier: properties.accessibilityIdentifier, 32 | titleColor: properties.titleColor, 33 | cornerRadius: properties.cornerRadius, 34 | height: properties.height, 35 | font: properties.font 36 | ) 37 | if let action = properties.action { 38 | button.addTarget(properties.target, action: action, for: .touchUpInside) 39 | } 40 | return button 41 | } 42 | 43 | private func setup( 44 | color: UIColor = .buttonBackground, 45 | title: String = "", 46 | identifier: String = "", 47 | titleColor: UIColor = .white, 48 | cornerRadius: CGFloat = UI.Button.cornerRadius, 49 | height: CGFloat = UI.Button.height, 50 | font: UIFont = .h3Medium 51 | ) { 52 | translatesAutoresizingMaskIntoConstraints = false 53 | setTitle(title, for: .normal) 54 | accessibilityIdentifier = identifier 55 | setTitleColor(titleColor, for: .normal) 56 | backgroundColor = color 57 | titleLabel?.font = font 58 | setRoundBorders(cornerRadius) 59 | heightAnchor.constraint(equalToConstant: height).isActive = true 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /ios-base/Common/Views/PlaceholderTextView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlaceholderTextView.swift 3 | // talkative-iOS 4 | // 5 | // Created by German Lopez on 6/6/16. 6 | // Copyright © 2016 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class PlaceholderTextView: UITextView { 12 | 13 | override var text: String! { 14 | didSet { 15 | // First text set, when placeholder is empty 16 | if text.isEmpty && text != placeholder && !self.isFirstResponder { 17 | text = placeholder 18 | return 19 | } 20 | textColor = text == placeholder ? placeholderColor : fontColor 21 | } 22 | } 23 | @IBInspectable var placeholder: String = "" { 24 | didSet { 25 | if text.isEmpty { 26 | text = placeholder 27 | textColor = placeholderColor 28 | } 29 | } 30 | } 31 | @IBInspectable var placeholderColor: UIColor? = .lightGray 32 | var fontColor: UIColor = .black 33 | 34 | required init?(coder aDecoder: NSCoder) { 35 | super.init(coder: aDecoder) 36 | if let txtC = textColor { 37 | self.fontColor = txtC 38 | } 39 | } 40 | 41 | override func awakeFromNib() { 42 | super.awakeFromNib() 43 | 44 | textColor = text == placeholder ? placeholderColor : fontColor 45 | } 46 | 47 | init( 48 | frame: CGRect, 49 | placeholder: String, 50 | placeholderColor: UIColor = .lightGray 51 | ) { 52 | super.init(frame: frame, textContainer: nil) 53 | self.placeholderColor = placeholderColor 54 | self.placeholder = placeholder 55 | if let txtC = textColor { 56 | self.fontColor = txtC 57 | } 58 | } 59 | 60 | override func becomeFirstResponder() -> Bool { 61 | let isFirstResponder = super.becomeFirstResponder() 62 | if text == placeholder && textColor == placeholderColor { 63 | text = "" 64 | } 65 | textColor = fontColor 66 | return isFirstResponder 67 | } 68 | 69 | override func resignFirstResponder() -> Bool { 70 | if text.isEmpty { 71 | text = placeholder 72 | textColor = placeholderColor 73 | } 74 | return super.resignFirstResponder() 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /ios-base/Onboarding/ViewModels/SignUpViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SignUpViewModel.swift 3 | // ios-base 4 | // 5 | // Created by German on 8/21/18. 6 | // Copyright © 2018 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | protocol SignUpViewModelDelegate: AuthViewModelStateDelegate { 13 | func formDidChange() 14 | } 15 | 16 | enum AuthViewModelState { 17 | case loggedIn 18 | case network(state: NetworkState) 19 | } 20 | 21 | class SignUpViewModelWithEmail { 22 | 23 | private let analyticsManager: AnalyticsManager 24 | private let authServices: AuthenticationServices 25 | 26 | init( 27 | authServices: AuthenticationServices = AuthenticationServices(), 28 | analyticsManager: AnalyticsManager = .shared 29 | ) { 30 | self.authServices = authServices 31 | self.analyticsManager = analyticsManager 32 | } 33 | 34 | private var state: AuthViewModelState = .network(state: .idle) { 35 | didSet { 36 | delegate?.didUpdateState(to: state) 37 | } 38 | } 39 | 40 | weak var delegate: SignUpViewModelDelegate? 41 | 42 | var email = "" { 43 | didSet { 44 | delegate?.formDidChange() 45 | } 46 | } 47 | 48 | var password = "" { 49 | didSet { 50 | delegate?.formDidChange() 51 | } 52 | } 53 | 54 | var passwordConfirmation = "" { 55 | didSet { 56 | delegate?.formDidChange() 57 | } 58 | } 59 | 60 | var hasValidData: Bool { 61 | email.isEmailFormatted() && !password.isEmpty && password == passwordConfirmation 62 | } 63 | 64 | @MainActor func signup() async { 65 | state = .network(state: .loading) 66 | let result = await authServices.signup( 67 | email: email, 68 | password: password, 69 | avatar64: UIImage.random() 70 | ) 71 | 72 | switch result { 73 | case .success: 74 | self.state = .loggedIn 75 | AnalyticsManager.shared.identifyUser(with: self.email) 76 | AnalyticsManager.shared.log(event: Event.registerSuccess(email: self.email)) 77 | AppNavigator.shared.navigate(to: HomeRoutes.home, with: .changeRoot) 78 | case .failure(let error): 79 | self.state = .network(state: .error(error.localizedDescription)) 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /ios-base/Home/ViewModels/HomeViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HomeViewModel.swift 3 | // ios-base 4 | // 5 | // Created by German on 8/3/18. 6 | // Copyright © 2018 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | protocol HomeViewModelDelegate: NetworkStatusDelegate { 12 | func didUpdateState(to state: HomeViewModelState) 13 | } 14 | 15 | enum HomeViewModelState { 16 | case loggedOut 17 | case loadedProfile 18 | case network(state: NetworkState) 19 | } 20 | 21 | class HomeViewModel { 22 | 23 | weak var delegate: HomeViewModelDelegate? 24 | 25 | var userEmail: String? 26 | 27 | private var state: HomeViewModelState = .network(state: .idle) { 28 | didSet { 29 | delegate?.didUpdateState(to: state) 30 | } 31 | } 32 | 33 | private let userServices: UserServices 34 | private let authServices: AuthenticationServices 35 | 36 | init( 37 | userServices: UserServices = UserServices(), 38 | authServices: AuthenticationServices = AuthenticationServices() 39 | ) { 40 | self.userServices = userServices 41 | self.authServices = authServices 42 | } 43 | 44 | func loadUserProfile() async { 45 | state = .network(state: .loading) 46 | 47 | let result = await userServices.getMyProfile() 48 | switch result { 49 | case .success(let user): 50 | self.userEmail = user.data.email 51 | self.state = .loadedProfile 52 | case .failure(let error): 53 | self.state = .network(state: .error(error.localizedDescription)) 54 | } 55 | } 56 | 57 | func logoutUser() async { 58 | state = .network(state: .loading) 59 | 60 | let result = await authServices.logout() 61 | switch result { 62 | case .success: 63 | self.didlogOutAccount() 64 | case .failure(let error): 65 | self.state = .network(state: .error(error.localizedDescription)) 66 | } 67 | } 68 | 69 | func deleteAccount() async { 70 | state = .network(state: .loading) 71 | 72 | let result = await authServices.deleteAccount() 73 | switch result { 74 | case .success: 75 | self.didlogOutAccount() 76 | case .failure(let error): 77 | self.state = .network(state: .error(error.localizedDescription)) 78 | } 79 | } 80 | 81 | private func didlogOutAccount() { 82 | state = .loggedOut 83 | AnalyticsManager.shared.reset() 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /ios-baseUITests/XCUIApplicationExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // XCUIApplicationExtension.swift 3 | // ios-baseUITests 4 | // 5 | // Created by Germán Stábile on 2/13/20. 6 | // Copyright © 2020 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | extension XCUIApplication { 12 | func type(text: String, on fieldName: String, isSecure: Bool = false) { 13 | let fields = isSecure ? secureTextFields : textFields 14 | let field = fields[fieldName] 15 | field.forceTap() 16 | field.typeText(text) 17 | } 18 | 19 | func clearText(on fieldName: String) { 20 | let field = textFields[fieldName] 21 | field.forceTap() 22 | field.clearText() 23 | } 24 | 25 | func logOutIfNeeded(in testCase: XCTestCase) { 26 | let logOutButton = buttons["LogoutButton"] 27 | let goToSignInButton = buttons["GoToSignInButton"] 28 | 29 | if logOutButton.exists { 30 | logOutButton.forceTap() 31 | testCase.waitFor(element: goToSignInButton, timeOut: 5) 32 | } 33 | } 34 | 35 | func attemptSignIn( 36 | in testCase: XCTestCase, 37 | with email: String, 38 | password: String 39 | ) { 40 | let goToSignInButton = buttons["GoToSignInButton"] 41 | let toolbarDoneButton = buttons["Done"] 42 | 43 | testCase.waitFor(element: goToSignInButton, timeOut: 2) 44 | goToSignInButton.forceTap() 45 | 46 | let signInButton = buttons["SignInButton"] 47 | 48 | testCase.waitFor(element: signInButton, timeOut: 2) 49 | 50 | type(text: email, on: "EmailTextField") 51 | 52 | toolbarDoneButton.forceTap() 53 | 54 | type(text: password, on: "PasswordTextField", isSecure: true) 55 | 56 | toolbarDoneButton.forceTap() 57 | 58 | signInButton.forceTap() 59 | } 60 | 61 | func attemptSignUp( 62 | in testCase: XCTestCase, 63 | email: String, 64 | password: String 65 | ) { 66 | buttons["GoToSignUpButton"].forceTap() 67 | 68 | let toolbarDoneButton = buttons["Done"] 69 | let signUpButton = buttons["SignUpButton"] 70 | testCase.waitFor(element: signUpButton, timeOut: 2) 71 | 72 | type(text: email, on: "EmailTextField") 73 | 74 | toolbarDoneButton.forceTap() 75 | type( 76 | text: password, 77 | on: "PasswordTextField", 78 | isSecure: true 79 | ) 80 | XCTAssertFalse(signUpButton.isEnabled) 81 | 82 | toolbarDoneButton.forceTap() 83 | type( 84 | text: password, 85 | on: "ConfirmPasswordTextField", 86 | isSecure: true 87 | ) 88 | 89 | toolbarDoneButton.forceTap() 90 | signUpButton.forceTap() 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Develop Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - develop 7 | 8 | env: 9 | LANG: en_US.UTF-8 10 | # S3 details - credentials should be stored as repo Secrets 11 | FOLDER: ios-base 12 | AWS_REGION: us-east-1 13 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 14 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 15 | KEYS_BUCKET: ${{ secrets.AWS_S3_KEYS_BUCKET }} 16 | BUILDS_BUCKET: ${{ secrets.AWS_S3_BUILDS_BUCKET }} 17 | KEYCHAIN_NAME: 'fastlane_tmp_keychain' 18 | KEYCHAIN_PASSWORD: '' 19 | # iOS Release - all sensitive values should be stored as repo Secrets 20 | APPLE_PROFILE: ${{ secrets.APPLE_PROFILE }} # Filename for the provisioning profile, eg AppStore_comrootstraiosbasedevelop.mobileprovision 21 | APPLE_CERT: ${{ secrets.APPLE_CERT }} # Filename for the distribution certificate (.cer) that should be uploaded to S3 bucket 22 | APPLE_KEY: ${{ secrets.APPLE_KEY }} # Filename for the key (.p12) that should be uploaded to S3 bucket 23 | APPLE_KEY_PASSWORD: ${{ secrets.APPLE_KEY_PASSWORD }} 24 | APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID}} # Organization's team ID in the Developer Portal 25 | FASTLANE_ITC_TEAM_ID: ${{ secrets.APPLE_TEAM_ITC_ID}} # iTunes connect team ID 26 | FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ secrets.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }} 27 | # Apple Application-specific password for uploading to TestFlight 28 | APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY_ID }} 29 | APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }} 30 | APP_STORE_CONNECT_API_KEY_FILE: ${{ secrets.APP_STORE_CONNECT_API_KEY_FILE }} 31 | # Notifications 32 | SLACK_URL: ${{ secrets.SLACK_URL }} 33 | SLACK_CHANNEL: '#dev-releases' 34 | 35 | jobs: 36 | ios: 37 | runs-on: macmini 38 | timeout-minutes: 45 39 | 40 | steps: 41 | - name: Checkout 42 | uses: actions/checkout@v2 43 | 44 | # Downloads certificate, private key and Firebase file 45 | - name: Download code signing items 46 | run: | 47 | aws s3 cp s3://$KEYS_BUCKET/$FOLDER/ . --recursive 48 | mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles 49 | mv ./$APPLE_PROFILE ~/Library/MobileDevice/Provisioning\ Profiles/$APPLE_PROFILE 50 | 51 | # Runs build and archives for AdHoc distribution 52 | # Pushes build to AWS S3 53 | # Sends Slack notification 54 | - name: Build with Fastlane 55 | run: bundle exec fastlane release_develop 56 | 57 | -------------------------------------------------------------------------------- /ios-base/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /fastlane/README.md: -------------------------------------------------------------------------------- 1 | Fastlane documentation 2 | ================ 3 | We use [Fastlane](https://docs.fastlane.tools/) for automating the iOS application build and submission 4 | 5 | # Installation 6 | 7 | Make sure you have the latest version of the Xcode command line tools installed: 8 | 9 | ``` 10 | xcode-select --install 11 | ``` 12 | 13 | Install _fastlane_ using 14 | ``` 15 | [sudo] gem install fastlane -NV 16 | ``` 17 | or alternatively using `brew cask install fastlane` 18 | 19 | # Project setup 20 | 21 | 1. Generate certificate and profiles for each target 22 | 2. [Disable automatic signing](https://developer.apple.com/library/archive/documentation/Security/Conceptual/CodeSigningGuide/Procedures/Procedures.html#//apple_ref/doc/uid/TP40005929-CH4-SW7) for each target in XCode and associate to the right provisioning profile 23 | 3. For uploading the builds to TestFlight, [AppStore Connect API](https://developer.apple.com/documentation/appstoreconnectapi/creating_api_keys_for_app_store_connect_api) keys are required 24 | 4. For uploading the builds to S3, [AWS keys](https://docs.aws.amazon.com/general/latest/gr/aws-sec-cred-types.html#access-keys-and-secret-access-keys) with valid permissions are required 25 | 26 | # Required environment variables 27 | 28 | * `FASTLANE_USER` : Your App Store Connect / Apple Developer Portal id used for managing certificates and submitting to the App Store 29 | * `FASTLANE_PASSWORD` : Your App Store Connect / Apple Developer Portal password, usually only needed if you also set the 30 | * `FASTLANE_TEAM_ID` : Developer Portal team id 31 | * `LANG` and `LC_ALL` : These set up the locale your shell and all the commands you execute run at. These need to be set to UTF-8 to work correctly,for example en_US.UTF-8 32 | * `APPLE_CERT` : Local path to distribution certificate file to be used for signing the build 33 | * `APPLE_KEY` : Private key (.p12 file) used for encrypting certificate 34 | * `APPLE_KEY_PASSWORD` : Password to private key file 35 | * `APP_STORE_CONNECT_API_KEY_KEY_ID` : AppStore Connect API ID 36 | * `APP_STORE_CONNECT_API_KEY_ISSUER_ID` : AppStore Connect issuer ID 37 | * `APP_STORE_CONNECT_API_KEY_FILE` : location of .p8 API key file 38 | * `AWS_ACCESS_KEY_ID` : credentials for uploading files to S3 39 | * `AWS_SECRET_ACCESS_KEY` 40 | * `AWS_REGION` 41 | * `BUILDS_BUCKET` : S3 bucket to upload the build to 42 | * `SLACK_CHANNEL` : Slack webhook url for sending notifications upon completion 43 | * `SLACK_URL` : Slack channel name 44 | 45 | # Available Actions 46 | 47 | ## build_* 48 | * Runs `pod install` 49 | * If needed downloads and installs the corresponding distribution certificate and profile 50 | * Builds and archive corresponding target (.ipa file is kept locally) 51 | ``` 52 | fastlane build_develop 53 | ``` 54 | 55 | ## share_* 56 | * Runs the build steps for the corresponding target 57 | * Gathers build version 58 | * Uploads the resulting .ipa to S3 59 | * Sends a Slack notification 60 | ``` 61 | fastlane share_develop 62 | ``` 63 | 64 | ## release_* 65 | * Checks for the Git status 66 | * Runs the build steps for the corresponding target 67 | * Generates changelog 68 | * Pushes the resulting .ipa to TestFlight 69 | * Sends a Slack notification 70 | ``` 71 | fastlane release_develop 72 | ``` 73 | -------------------------------------------------------------------------------- /ios-base/Onboarding/Views/FirstViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewController.swift 3 | // ios-base 4 | // 5 | // Created by Rootstrap on 15/2/16. 6 | // Copyright © 2016 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class FirstViewController: UIViewController, 12 | AuthViewModelStateDelegate, 13 | ActivityIndicatorPresenter { 14 | 15 | // MARK: - Views 16 | 17 | private lazy var titleLabel = UILabel.titleLabel( 18 | text: "firstscreen_title".localized, 19 | font: .h1Medium 20 | ) 21 | 22 | private lazy var signInButton = UIButton.primaryButton( 23 | properties: ButtonProperties( 24 | title: "firstscreen_login_button_title".localized, 25 | target: self, 26 | action: #selector(signInTapped) 27 | ) 28 | ) 29 | 30 | private lazy var signUpButton = UIButton.primaryButton( 31 | properties: ButtonProperties( 32 | color: .clear, 33 | title: "firstscreen_registre_button_title".localized, 34 | titleColor: .mainTitle, 35 | target: self, 36 | action: #selector(signUpTapped) 37 | ) 38 | ) 39 | 40 | let activityIndicator = UIActivityIndicatorView() 41 | 42 | private let viewModel: FirstViewModel 43 | 44 | init(viewModel: FirstViewModel) { 45 | self.viewModel = viewModel 46 | super.init(nibName: nil, bundle: nil) 47 | } 48 | 49 | @available(*, unavailable) 50 | required init?(coder: NSCoder) { 51 | fatalError("init(coder:) has not been implemented") 52 | } 53 | 54 | // MARK: - Lifecycle 55 | 56 | override func viewDidLoad() { 57 | super.viewDidLoad() 58 | viewModel.delegate = self 59 | 60 | configureViews() 61 | } 62 | 63 | override func viewWillAppear(_ animated: Bool) { 64 | super.viewWillAppear(animated) 65 | navigationController?.setNavigationBarHidden(true, animated: true) 66 | } 67 | 68 | // MARK: View Configuration 69 | 70 | private func configureViews() { 71 | applyDefaultUIConfigs() 72 | view.addSubviews(subviews: [titleLabel, signInButton, signUpButton]) 73 | activateConstraints() 74 | setupAccessibility() 75 | } 76 | 77 | private func setupAccessibility() { 78 | signUpButton.accessibilityIdentifier = "GoToSignUpButton" 79 | signInButton.accessibilityIdentifier = "GoToSignInButton" 80 | } 81 | 82 | private func activateConstraints() { 83 | signInButton.centerHorizontally(with: view) 84 | titleLabel.attachHorizontally(to: view) 85 | signUpButton.attachHorizontally(to: view) 86 | 87 | NSLayoutConstraint.activate([ 88 | titleLabel.topAnchor.constraint( 89 | equalTo: view.topAnchor, 90 | constant: UI.ViewController.topMargin 91 | ), 92 | signInButton.widthAnchor.constraint(greaterThanOrEqualToConstant: UI.Button.width), 93 | signUpButton.bottomAnchor.constraint( 94 | equalTo: view.bottomAnchor, 95 | constant: -UI.Defaults.margin 96 | ), 97 | signInButton.bottomAnchor.constraint( 98 | equalTo: signUpButton.topAnchor, 99 | constant: -UI.Button.spacing 100 | ) 101 | ]) 102 | } 103 | 104 | // MARK: - Actions 105 | 106 | @objc 107 | func signInTapped() { 108 | AppNavigator.shared.navigate(to: OnboardingRoutes.signIn, with: .push) 109 | } 110 | 111 | @objc 112 | func signUpTapped() { 113 | AppNavigator.shared.navigate(to: OnboardingRoutes.signUp, with: .push) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /ios-baseUnitTests/Services/UserServiceUnitTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserServiceUnitTests.swift 3 | // ios-baseUnitTests 4 | // 5 | // Created by German on 4/30/20. 6 | // Copyright © 2020 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import RSSwiftNetworking 11 | import RSSwiftNetworkingAlamofire 12 | @testable import ios_base_Debug 13 | 14 | class UserServiceUnitTests: XCTestCase { 15 | 16 | let testUser = User(id: 1, username: "username", email: "test@mail.com") 17 | 18 | var sessionManager: SessionManager! 19 | var userDataManager: UserDataManager! 20 | 21 | override func setUp() { 22 | super.setUp() 23 | sessionManager = SessionManager() 24 | userDataManager = UserDataManager() 25 | } 26 | 27 | override func tearDown() { 28 | super.tearDown() 29 | sessionManager.deleteSession() 30 | userDataManager.deleteUser() 31 | SessionManager.shared.deleteSession() 32 | UserDataManager.shared.deleteUser() 33 | } 34 | 35 | func testUserPersistence() { 36 | let service = AuthenticationServices( 37 | sessionManager: sessionManager, 38 | userDataManager: userDataManager 39 | ) 40 | _ = service.saveUserSession(testUser, headers: [:]) 41 | guard let persistedUser = userDataManager.currentUser else { 42 | XCTFail("User should NOT be nil") 43 | return 44 | } 45 | let user = User(id: 1, username: "username", email: "test@mail.com") 46 | XCTAssert(persistedUser.id == user.id) 47 | XCTAssert(persistedUser.username == user.username) 48 | XCTAssert(persistedUser.email == user.email) 49 | } 50 | 51 | func testGoodSessionPersistence() { 52 | let client = "dummySessionClient" 53 | let token = "dummySessionToken" 54 | let uid = testUser.email 55 | let expiry = "\(Date.timeIntervalSinceReferenceDate)" 56 | let sessionHeaders: [String: Any] = [ 57 | HTTPHeader.uid.rawValue: uid, 58 | HTTPHeader.client.rawValue: client, 59 | HTTPHeader.token.rawValue: token, 60 | HTTPHeader.expiry.rawValue: expiry 61 | ] 62 | 63 | let service = AuthenticationServices( 64 | sessionManager: sessionManager, 65 | userDataManager: userDataManager 66 | ) 67 | _ = service.saveUserSession(testUser, headers: sessionHeaders) 68 | 69 | guard let persistedSession = sessionManager.currentSession else { 70 | XCTFail("Session should NOT be nil") 71 | return 72 | } 73 | 74 | XCTAssert(persistedSession.client == client) 75 | XCTAssert(persistedSession.accessToken == token) 76 | XCTAssert(persistedSession.uid == uid) 77 | XCTAssert(persistedSession.accessToken == token) 78 | } 79 | 80 | func testBadSessionPersistence() { 81 | // Testing case where shouldn't be session at all 82 | let unusableHeaders = [HTTPHeader.client: "badHeaderKey"] 83 | let service = AuthenticationServices( 84 | sessionManager: sessionManager, 85 | userDataManager: userDataManager 86 | ) 87 | _ = service.saveUserSession(testUser, headers: unusableHeaders) 88 | XCTAssert(sessionManager.currentSession == nil) 89 | XCTAssert(sessionManager.currentSession?.isValid == nil) 90 | 91 | // Testing case where should be session but not valid 92 | let wrongSessionHeaders = [ 93 | "testKey": "testValue", 94 | HTTPHeader.uid.rawValue: "", 95 | HTTPHeader.client.rawValue: "", 96 | HTTPHeader.token.rawValue: "" 97 | ] 98 | _ = service.saveUserSession(testUser, headers: wrongSessionHeaders) 99 | XCTAssert(sessionManager.currentSession != nil) 100 | XCTAssertFalse(sessionManager.currentSession?.isValid ?? true) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /ios-base/Onboarding/Views/SignInViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SignInViewController.swift 3 | // ios-base 4 | // 5 | // Created by Rootstrap on 5/22/17. 6 | // Copyright © 2017 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class SignInViewController: UIViewController, ActivityIndicatorPresenter { 12 | 13 | // MARK: - Outlets 14 | 15 | private lazy var titleLabel = UILabel.titleLabel( 16 | text: "signin_title".localized, 17 | font: .h1Medium 18 | ) 19 | private lazy var logInButton = UIButton.primaryButton( 20 | properties: ButtonProperties( 21 | title: "signin_button_title".localized, 22 | accessibilityIdentifier: "SignInButton", 23 | target: self, 24 | action: #selector(tapOnSignInButton) 25 | ) 26 | ) 27 | 28 | private lazy var emailField = UITextField( 29 | target: self, 30 | selector: #selector(credentialsChanged), 31 | placeholder: "signin_email_placeholder".localized, 32 | identifier: "EmailTextField" 33 | ) 34 | 35 | private lazy var passwordField = UITextField( 36 | target: self, 37 | selector: #selector(credentialsChanged), 38 | placeholder: "signin_password_placeholder".localized, 39 | identifier: "PasswordTextField", 40 | isPassword: true 41 | ) 42 | 43 | let activityIndicator = UIActivityIndicatorView() 44 | 45 | private let viewModel: SignInViewModelWithCredentials 46 | 47 | init(viewModel: SignInViewModelWithCredentials) { 48 | self.viewModel = viewModel 49 | super.init(nibName: nil, bundle: nil) 50 | } 51 | 52 | @available(*, unavailable) 53 | required init?(coder: NSCoder) { 54 | fatalError("init(coder:) has not been implemented") 55 | } 56 | 57 | // MARK: - Lifecycle Events 58 | 59 | override func viewDidLoad() { 60 | super.viewDidLoad() 61 | viewModel.delegate = self 62 | configureViews() 63 | } 64 | 65 | override func viewWillAppear(_ animated: Bool) { 66 | super.viewWillAppear(animated) 67 | navigationController?.setNavigationBarHidden(false, animated: true) 68 | } 69 | 70 | // MARK: - Actions 71 | 72 | @objc func credentialsChanged(_ sender: UITextField) { 73 | let newValue = sender.text ?? "" 74 | switch sender { 75 | case emailField: 76 | viewModel.email = newValue 77 | case passwordField: 78 | viewModel.password = newValue 79 | default: break 80 | } 81 | } 82 | 83 | @objc func tapOnSignInButton(_ sender: Any) { 84 | Task { 85 | await viewModel.login() 86 | } 87 | } 88 | 89 | func setLoginButton(enabled: Bool) { 90 | logInButton.alpha = enabled ? 1 : 0.5 91 | logInButton.isEnabled = enabled 92 | } 93 | } 94 | 95 | private extension SignInViewController { 96 | func configureViews() { 97 | applyDefaultUIConfigs() 98 | setLoginButton(enabled: false) 99 | view.addSubviews(subviews: [ 100 | titleLabel, 101 | emailField, 102 | passwordField, 103 | logInButton 104 | ]) 105 | 106 | activateConstrains() 107 | } 108 | 109 | func activateConstrains() { 110 | [titleLabel, emailField, passwordField, logInButton].forEach { 111 | $0.attachHorizontally(to: view) 112 | } 113 | emailField.centerVertically(with: view) 114 | NSLayoutConstraint.activate([ 115 | titleLabel.topAnchor.constraint( 116 | equalTo: view.safeAreaLayoutGuide.topAnchor, 117 | constant: UI.ViewController.smallTopMargin 118 | ), 119 | passwordField.topAnchor.constraint( 120 | equalTo: emailField.bottomAnchor, 121 | constant: UI.Defaults.spacing 122 | ), 123 | logInButton.bottomAnchor.constraint( 124 | equalTo: view.bottomAnchor, 125 | constant: -UI.ViewController.bottomMargin 126 | ) 127 | ]) 128 | } 129 | } 130 | 131 | extension SignInViewController: SignInViewModelDelegate { 132 | func didUpdateCredentials() { 133 | setLoginButton(enabled: viewModel.hasValidCredentials) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /ios-base/Home/Views/HomeViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HomeViewController.swift 3 | // ios-base 4 | // 5 | // Created by Rootstrap on 5/23/17. 6 | // Copyright © 2017 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class HomeViewController: UIViewController, ActivityIndicatorPresenter { 12 | 13 | // MARK: - Outlets 14 | 15 | private lazy var welcomeLabel = UILabel.titleLabel( 16 | text: "homescreen_title".localized 17 | ) 18 | 19 | private lazy var logOutButton = UIButton.primaryButton( 20 | properties: ButtonProperties( 21 | color: .black, 22 | title: "homescreen_logout_button_title".localized, 23 | target: self, 24 | action: #selector(tapOnLogOutButton) 25 | ) 26 | ) 27 | 28 | private lazy var deleteAccountButton = UIButton.primaryButton( 29 | properties: ButtonProperties( 30 | color: .deleteButton, 31 | title: "homescreen_delete_button_title".localized, 32 | target: self, 33 | action: #selector(tapOnDeleteAccount) 34 | ) 35 | ) 36 | 37 | private lazy var getProfileButton: UIButton = { 38 | let button = UIButton() 39 | button.translatesAutoresizingMaskIntoConstraints = false 40 | button.setTitle("homescreen_get_profile_button_title".localized, for: .normal) 41 | button.setTitleColor(.blue, for: .normal) 42 | button.addTarget( 43 | self, 44 | action: #selector(tapOnGetMyProfile), 45 | for: .touchUpInside 46 | ) 47 | 48 | return button 49 | }() 50 | 51 | let activityIndicator = UIActivityIndicatorView() 52 | 53 | private var viewModel: HomeViewModel 54 | 55 | init(viewModel: HomeViewModel) { 56 | self.viewModel = viewModel 57 | super.init(nibName: nil, bundle: nil) 58 | } 59 | 60 | @available(*, unavailable) 61 | required init?(coder: NSCoder) { 62 | fatalError("init(coder:) has not been implemented") 63 | } 64 | 65 | // MARK: - Lifecycle Events 66 | override func viewDidLoad() { 67 | super.viewDidLoad() 68 | 69 | viewModel.delegate = self 70 | configureViews() 71 | } 72 | 73 | // MARK: - Actions 74 | 75 | @objc 76 | func tapOnGetMyProfile(_ sender: Any) async { 77 | await viewModel.loadUserProfile() 78 | } 79 | 80 | @objc 81 | func tapOnLogOutButton(_ sender: Any) async { 82 | await viewModel.logoutUser() 83 | } 84 | 85 | @objc 86 | func tapOnDeleteAccount(_ sender: Any) async { 87 | await viewModel.deleteAccount() 88 | } 89 | } 90 | 91 | private extension HomeViewController { 92 | 93 | private func configureViews() { 94 | applyDefaultUIConfigs() 95 | view.addSubviews( 96 | subviews: [welcomeLabel, logOutButton, deleteAccountButton, getProfileButton] 97 | ) 98 | activateConstraints() 99 | } 100 | 101 | private func activateConstraints() { 102 | welcomeLabel.centerHorizontally(with: view) 103 | getProfileButton.center(view) 104 | logOutButton.attachHorizontally(to: view) 105 | deleteAccountButton.attachHorizontally(to: view) 106 | 107 | NSLayoutConstraint.activate([ 108 | welcomeLabel.topAnchor.constraint( 109 | equalTo: view.topAnchor, 110 | constant: UI.ViewController.topMargin 111 | ), 112 | deleteAccountButton.bottomAnchor.constraint( 113 | equalTo: view.bottomAnchor, 114 | constant: -UI.Defaults.margin 115 | ), 116 | logOutButton.bottomAnchor.constraint( 117 | equalTo: deleteAccountButton.topAnchor, 118 | constant: -UI.Button.spacing 119 | ) 120 | ]) 121 | } 122 | 123 | } 124 | 125 | extension HomeViewController: HomeViewModelDelegate { 126 | func didUpdateState(to state: HomeViewModelState) { 127 | switch state { 128 | case .network(let networkStatus): 129 | networkStatusChanged(to: networkStatus) 130 | case .loadedProfile: 131 | showActivityIndicator(false) 132 | showMessage(title: "My Profile", message: "email: \(viewModel.userEmail ?? "")") 133 | case .loggedOut: 134 | showActivityIndicator(false) 135 | AppNavigator.shared.navigate( 136 | to: OnboardingRoutes.firstScreen, 137 | with: .changeRoot 138 | ) 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /ios-base/Extensions/ViewExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIViewExtension.swift 3 | // ios-base 4 | // 5 | // Created by Juan Pablo Mazza on 9/9/16. 6 | // Copyright © 2016 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | extension UIView { 13 | 14 | // MARK: - Instance methods 15 | 16 | // Change the default values for params as you wish 17 | func addBorder(color: UIColor = UIColor.black, weight: CGFloat = 1.0) { 18 | layer.borderColor = color.cgColor 19 | layer.borderWidth = weight 20 | } 21 | 22 | func setRoundBorders(_ cornerRadius: CGFloat = 10.0) { 23 | clipsToBounds = true 24 | layer.cornerRadius = cornerRadius 25 | } 26 | 27 | var typeName: String { 28 | String(describing: type(of: self)) 29 | } 30 | 31 | func instanceFromNib(withName name: String) -> UIView? { 32 | UINib( 33 | nibName: name, 34 | bundle: nil 35 | ).instantiate(withOwner: self, options: nil).first as? UIView 36 | } 37 | 38 | func addNibView( 39 | withNibName nibName: String? = nil, 40 | withAutoresizingMasks masks: AutoresizingMask = [.flexibleWidth, .flexibleHeight] 41 | ) -> UIView { 42 | let name = String(describing: type(of: self)) 43 | guard let view = instanceFromNib(withName: nibName ?? name) else { 44 | assert(false, "No nib found with that name") 45 | return UIView() 46 | } 47 | view.frame = bounds 48 | view.autoresizingMask = masks 49 | addSubview(view) 50 | return view 51 | } 52 | 53 | func animateChangeInLayout(withDuration duration: TimeInterval = 0.2) { 54 | setNeedsLayout() 55 | UIView.animate(withDuration: duration, animations: { [weak self] in 56 | self?.layoutIfNeeded() 57 | }) 58 | } 59 | 60 | // MARK: Constrains Helper 61 | 62 | func addSubviews(subviews: [UIView]) { 63 | for subview in subviews { 64 | addSubview(subview) 65 | } 66 | } 67 | 68 | func attachHorizontally( 69 | to view: UIView, 70 | leadingMargin: CGFloat = UI.Defaults.margin, 71 | trailingMargin: CGFloat = UI.Defaults.margin 72 | ) { 73 | NSLayoutConstraint.activate([ 74 | leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: leadingMargin), 75 | trailingAnchor.constraint( 76 | equalTo: view.trailingAnchor, 77 | constant: -trailingMargin 78 | ) 79 | ]) 80 | } 81 | 82 | func attachVertically( 83 | to view: UIView, 84 | topMargin: CGFloat = UI.Defaults.margin, 85 | bottomMargin: CGFloat = UI.Defaults.margin 86 | ) { 87 | NSLayoutConstraint.activate([ 88 | topAnchor.constraint(equalTo: view.topAnchor, constant: topMargin), 89 | bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -bottomMargin) 90 | ]) 91 | } 92 | 93 | /// Centers the view horizontally and vertically with a specific view 94 | /// 95 | /// - Parameters: 96 | /// - view: UIView on which the view will be centered horizontally 97 | /// - withOffset: CGPoint indicating the horizontal 98 | /// and vertical displacement of the view 99 | func center(_ view: UIView, withOffset offset: CGPoint = .zero) { 100 | centerHorizontally(with: view, withOffset: offset.x) 101 | centerVertically(with: view, withOffset: offset.y) 102 | } 103 | 104 | /// Centers the view horizontally with a specific view 105 | /// 106 | /// - Parameters: 107 | /// - view: UIView on which the view will be centered horizontally 108 | /// - withOffset: CGFloat indicating the horizontal displacement of the view 109 | func centerHorizontally( 110 | with view: UIView, 111 | withOffset offset: CGFloat = 0 112 | ) { 113 | centerXAnchor.constraint( 114 | equalTo: view.centerXAnchor, 115 | constant: offset 116 | ).isActive = true 117 | } 118 | 119 | /// Centers the view vertically with a specific view 120 | /// 121 | /// - Parameters: 122 | /// - view: UIView on which the view will be centered vertically 123 | /// - withOffset: CGFloat indicating the vertical displacement of the view 124 | func centerVertically( 125 | with view: UIView, 126 | withOffset offset: CGFloat = 0 127 | ) { 128 | centerYAnchor.constraint( 129 | equalTo: view.centerYAnchor, 130 | constant: offset 131 | ).isActive = true 132 | } 133 | } 134 | 135 | extension Array where Element: UIView { 136 | func addBorder(color: UIColor = UIColor.black, weight: CGFloat = 1.0) { 137 | for view in self { 138 | view.addBorder(color: color, weight: weight) 139 | } 140 | } 141 | 142 | func roundBorders(cornerRadius: CGFloat = 10.0) { 143 | for view in self { 144 | view.setRoundBorders(cornerRadius) 145 | } 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /ios-base/Onboarding/Views/SignUpViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SignUpViewController.swift 3 | // ios-base 4 | // 5 | // Created by Rootstrap on 5/22/17. 6 | // Copyright © 2017 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class SignUpViewController: UIViewController, ActivityIndicatorPresenter { 12 | 13 | // MARK: - Outlets 14 | 15 | private lazy var titleLabel = UILabel.titleLabel( 16 | text: "signup_title".localized, 17 | font: .h1Medium 18 | ) 19 | private lazy var signUpButton = UIButton.primaryButton( 20 | properties: ButtonProperties( 21 | title: "signup_button_title".localized, 22 | accessibilityIdentifier: "SignUpButton", 23 | target: self, 24 | action: #selector(tapOnSignUpButton) 25 | ) 26 | ) 27 | 28 | private lazy var emailField = UITextField( 29 | target: self, 30 | selector: #selector(formEditingChange), 31 | placeholder: "signup_email_placeholder".localized, 32 | identifier: "EmailTextField" 33 | ) 34 | 35 | private lazy var passwordField = UITextField( 36 | target: self, 37 | selector: #selector(formEditingChange), 38 | placeholder: "signup_password_placeholder".localized, 39 | identifier: "PasswordTextField", 40 | isPassword: true 41 | ) 42 | 43 | private lazy var passwordConfirmationField = UITextField( 44 | target: self, 45 | selector: #selector(formEditingChange), 46 | placeholder: "signup_confirm_password_placeholder".localized, 47 | identifier: "ConfirmPasswordTextField", 48 | isPassword: true 49 | ) 50 | 51 | let activityIndicator = UIActivityIndicatorView() 52 | 53 | private let viewModel: SignUpViewModelWithEmail 54 | 55 | init(viewModel: SignUpViewModelWithEmail) { 56 | self.viewModel = viewModel 57 | super.init(nibName: nil, bundle: nil) 58 | } 59 | 60 | @available(*, unavailable) 61 | required init?(coder: NSCoder) { 62 | fatalError("init(coder:) has not been implemented") 63 | } 64 | 65 | // MARK: - Lifecycle Events 66 | 67 | override func viewDidLoad() { 68 | super.viewDidLoad() 69 | 70 | viewModel.delegate = self 71 | setSignUpButton(enabled: false) 72 | configureViews() 73 | } 74 | 75 | override func viewWillAppear(_ animated: Bool) { 76 | super.viewWillAppear(animated) 77 | navigationController?.setNavigationBarHidden(false, animated: true) 78 | } 79 | 80 | // MARK: - Actions 81 | 82 | @objc 83 | func formEditingChange(_ sender: UITextField) { 84 | let newValue = sender.text ?? "" 85 | switch sender { 86 | case emailField: 87 | viewModel.email = newValue 88 | case passwordField: 89 | viewModel.password = newValue 90 | case passwordConfirmationField: 91 | viewModel.passwordConfirmation = newValue 92 | default: break 93 | } 94 | } 95 | 96 | @objc 97 | func tapOnSignUpButton(_ sender: Any) { 98 | Task { 99 | await viewModel.signup() 100 | } 101 | } 102 | 103 | func setSignUpButton(enabled: Bool) { 104 | signUpButton.alpha = enabled ? 1 : 0.5 105 | signUpButton.isEnabled = enabled 106 | } 107 | } 108 | 109 | private extension SignUpViewController { 110 | func configureViews() { 111 | applyDefaultUIConfigs() 112 | setSignUpButton(enabled: false) 113 | view.addSubviews(subviews: [ 114 | titleLabel, 115 | emailField, 116 | passwordField, 117 | passwordConfirmationField, 118 | signUpButton 119 | ]) 120 | 121 | activateConstrains() 122 | } 123 | 124 | private func activateConstrains() { 125 | addToViewHorizontally() 126 | emailField.centerVertically(with: view) 127 | NSLayoutConstraint.activate([ 128 | titleLabel.topAnchor.constraint( 129 | equalTo: view.safeAreaLayoutGuide.topAnchor, 130 | constant: UI.ViewController.smallTopMargin 131 | ), 132 | passwordField.topAnchor.constraint( 133 | equalTo: emailField.bottomAnchor, 134 | constant: UI.Defaults.spacing 135 | ), 136 | passwordConfirmationField.topAnchor.constraint( 137 | equalTo: passwordField.bottomAnchor, 138 | constant: UI.Defaults.spacing 139 | ), 140 | signUpButton.bottomAnchor.constraint( 141 | equalTo: view.bottomAnchor, 142 | constant: -UI.ViewController.bottomMargin 143 | ) 144 | ]) 145 | } 146 | 147 | private func addToViewHorizontally() { 148 | [ 149 | titleLabel, 150 | emailField, 151 | passwordField, 152 | passwordConfirmationField, 153 | signUpButton 154 | ].forEach { 155 | $0.attachHorizontally(to: view) 156 | } 157 | } 158 | } 159 | 160 | extension SignUpViewController: SignUpViewModelDelegate { 161 | func formDidChange() { 162 | setSignUpButton(enabled: viewModel.hasValidData) 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /setup-env.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: ConfiguratorError 4 | 5 | /// The possible errors that `ProjectConfigurator` can throw. 6 | public enum ConfiguratorError: LocalizedError { 7 | /// The arguments provided to the configurator do not match the requirements 8 | case invalidArguments 9 | 10 | /// The source file providing the secret keys can not be found. 11 | case keysFileNotFound 12 | 13 | /// Could not read valid keys from the source file provided. 14 | case emptyData 15 | 16 | /// One of the keys in the source file was not found in the environment. 17 | case keyMissing(String) 18 | 19 | public var errorDescription: String? { 20 | switch self { 21 | case .invalidArguments: 22 | return "The arguments provided to the configurator do not match the requirements" 23 | case .keysFileNotFound: 24 | return "The source file providing the secret keys can not be found." 25 | case .emptyData: 26 | return "Could not read valid keys from the source file provided." 27 | case .keyMissing(let key): 28 | return "The key \"\(key)\" is missing." 29 | } 30 | } 31 | } 32 | 33 | // MARK: EnvironmentConfigurator 34 | 35 | /// This class configures environment variables for the project. 36 | /// It takes a source file with the keys definition and inject the values 37 | /// to the provided output file. 38 | public final class EnvironmentConfigurator { 39 | 40 | enum Defaults { 41 | static let secretsFile = "secrets.xcconfig" 42 | static let keysFile = "keys.env" 43 | } 44 | 45 | private let keysSource: String 46 | 47 | private let fileManager = FileManager.default 48 | 49 | public init(keysSource: String? = nil) { 50 | self.keysSource = (keysSource ?? Defaults.keysFile).expandingTildeInPath 51 | } 52 | 53 | public func injectEnvironment(into file: String? = nil) throws { 54 | let keys = try readKeys() 55 | 56 | let fullPath = (file ?? Defaults.secretsFile).expandingTildeInPath 57 | if !fileManager.fileExists(atPath: fullPath) { 58 | fileManager.createFile(atPath: fullPath, contents: nil) 59 | } 60 | 61 | var rows: [String] = [] 62 | for key in keys { 63 | do { 64 | let value = try require(key: key) 65 | rows.append("\(key) = \(value)") 66 | } catch { 67 | // Attempt to write the key-value pairs found before failure. 68 | try rows.joined(separator: "\n") 69 | .write(toFile: fullPath, atomically: true, encoding: .utf8) 70 | throw error 71 | } 72 | } 73 | try rows.joined(separator: "\n") 74 | .write(toFile: fullPath, atomically: true, encoding: .utf8) 75 | } 76 | 77 | private func readKeys() throws -> [String] { 78 | guard fileManager.fileExists(atPath: keysSource) else { 79 | throw ConfiguratorError.keysFileNotFound 80 | } 81 | 82 | guard 83 | let data = fileManager.contents(atPath: keysSource), 84 | let content = String(data: data, encoding: .utf8) 85 | else { 86 | throw ConfiguratorError.emptyData 87 | } 88 | 89 | return content.split(separator: "\n") 90 | .map { String($0) } 91 | .filter { !$0.starts(with: "#") && !$0.isEmpty } 92 | } 93 | 94 | private func require(key: String) throws -> String { 95 | let environment = ProcessInfo.processInfo.environment 96 | guard let value = environment[key], !value.isEmpty else { 97 | throw ConfiguratorError.keyMissing(key) 98 | } 99 | 100 | return value 101 | } 102 | 103 | } 104 | 105 | // MARK: Script 106 | 107 | private let arguments = CommandLine.arguments 108 | private let executableName: String = arguments.first ?? "setup-env" 109 | 110 | private func outputError(_ error: Error) { 111 | fputs("Configurator failed: \(error.localizedDescription)\n", stderr) 112 | } 113 | 114 | private func showHelp() { 115 | print("Usage:") 116 | print("\(executableName) ") 117 | } 118 | 119 | guard arguments.count >= 1 else { 120 | showHelp() 121 | outputError(ConfiguratorError.invalidArguments) 122 | exit(EXIT_FAILURE) 123 | } 124 | 125 | private let keysSource = arguments[safe: 1] 126 | private let outputXCConfigFile = arguments[safe: 2] 127 | 128 | private let configurator = EnvironmentConfigurator(keysSource: keysSource) 129 | 130 | do { 131 | try configurator.injectEnvironment(into: outputXCConfigFile) 132 | print("Environment configured successfuly!") 133 | exit(EXIT_SUCCESS) 134 | } catch { 135 | outputError(error) 136 | exit(EXIT_FAILURE) 137 | } 138 | 139 | // MARK: Extensions 140 | 141 | internal extension String { 142 | var expandingTildeInPath: String { 143 | NSString(string: self).expandingTildeInPath 144 | } 145 | } 146 | 147 | extension Array { 148 | subscript(safe index: Int) -> Element? { 149 | guard index >= 0 && index < count else { 150 | return nil 151 | } 152 | return self[index] 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /ios-base.xcodeproj/xcshareddata/xcschemes/ios-base-staging.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 38 | 39 | 40 | 41 | 43 | 49 | 50 | 51 | 53 | 59 | 60 | 61 | 63 | 69 | 70 | 71 | 72 | 73 | 83 | 85 | 91 | 92 | 93 | 94 | 100 | 102 | 108 | 109 | 110 | 111 | 113 | 114 | 117 | 118 | 119 | -------------------------------------------------------------------------------- /ios-base.xcodeproj/xcshareddata/xcschemes/ios-base-production.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 38 | 39 | 40 | 41 | 43 | 49 | 50 | 51 | 53 | 59 | 60 | 61 | 63 | 69 | 70 | 71 | 72 | 73 | 83 | 85 | 91 | 92 | 93 | 94 | 100 | 102 | 108 | 109 | 110 | 111 | 113 | 114 | 117 | 118 | 119 | -------------------------------------------------------------------------------- /ios-baseUITests/ios_baseUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ios_baseUITests.swift 3 | // ios-baseUITests 4 | // 5 | // Created by Germán Stábile on 2/13/20. 6 | // Copyright © 2020 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import ios_base_Debug 11 | 12 | class ios_baseUITests: XCTestCase { 13 | 14 | var app: XCUIApplication! 15 | 16 | let networkMocker = NetworkMocker() 17 | 18 | override func setUp() { 19 | super.setUp() 20 | app = XCUIApplication() 21 | app.launchArguments = ["Automation Test"] 22 | 23 | try? networkMocker.setUp() 24 | } 25 | 26 | override func tearDown() { 27 | super.tearDown() 28 | networkMocker.tearDown() 29 | } 30 | 31 | func testCreateAccountValidations() { 32 | app.launch() 33 | 34 | app.buttons["GoToSignUpButton"].forceTap() 35 | 36 | let toolbarDoneButton = app.buttons["Done"] 37 | let signUpButton = app.buttons["SignUpButton"] 38 | waitFor(element: signUpButton, timeOut: 2) 39 | 40 | XCTAssertFalse(signUpButton.isEnabled) 41 | 42 | app.type(text: "automation@test", on: "EmailTextField") 43 | 44 | toolbarDoneButton.forceTap() 45 | app.type(text: "holahola", 46 | on: "PasswordTextField", 47 | isSecure: true) 48 | XCTAssertFalse(signUpButton.isEnabled) 49 | 50 | toolbarDoneButton.forceTap() 51 | app.type(text: "holahola", 52 | on: "ConfirmPasswordTextField", 53 | isSecure: true) 54 | XCTAssertFalse(signUpButton.isEnabled) 55 | 56 | toolbarDoneButton.forceTap() 57 | app.type(text: ".com", on: "EmailTextField") 58 | XCTAssert(signUpButton.isEnabled) 59 | toolbarDoneButton.forceTap() 60 | 61 | app.type(text: "holahol", 62 | on: "ConfirmPasswordTextField", 63 | isSecure: true) 64 | XCTAssertFalse(signUpButton.isEnabled) 65 | } 66 | 67 | func testSignInFailure() { 68 | app.launch() 69 | 70 | networkMocker.stubLogIn(shouldSucceed: false) 71 | 72 | app.attemptSignIn(in: self, 73 | with: "automation@test.com", 74 | password: "incorrect password") 75 | 76 | if let alert = app.alerts.allElementsBoundByIndex.first { 77 | waitFor(element: alert, timeOut: 2) 78 | 79 | alert.buttons.allElementsBoundByIndex.first?.forceTap() 80 | } 81 | 82 | let signInButton = app.buttons["SignInButton"] 83 | waitFor(element: signInButton, timeOut: 2) 84 | } 85 | 86 | func testSignInValidations() { 87 | app.launch() 88 | 89 | app.buttons["GoToSignInButton"].forceTap() 90 | 91 | let toolbarDoneButton = app.buttons["Done"] 92 | let signInButton = app.buttons["SignInButton"] 93 | 94 | waitFor(element: signInButton, timeOut: 2) 95 | 96 | XCTAssertFalse(signInButton.isEnabled) 97 | 98 | app.type(text: "automation@test", on: "EmailTextField") 99 | 100 | toolbarDoneButton.forceTap() 101 | app.type(text: "holahola", 102 | on: "PasswordTextField", 103 | isSecure: true) 104 | 105 | XCTAssertFalse(signInButton.isEnabled) 106 | 107 | toolbarDoneButton.forceTap() 108 | app.type(text: ".com", on: "EmailTextField") 109 | 110 | XCTAssert(signInButton.isEnabled) 111 | } 112 | 113 | /// These tests won't work because we need to mock headers 114 | /// and Swifter currently does not support this 115 | /// https://github.com/httpswift/swifter/pull/500 116 | // func testAccountCreation() { 117 | // app.launch() 118 | // 119 | // networkMocker.stubSignUp() 120 | // 121 | // app.attemptSignUp( 122 | // in: self, 123 | // email: "automation@test.com", 124 | // password: "holahola" 125 | // ) 126 | // 127 | // networkMocker.stubGetProfile() 128 | // let getMyProfile = app.buttons["GetMyProfileButton"] 129 | // waitFor(element: getMyProfile, timeOut: 10) 130 | // getMyProfile.tap() 131 | // 132 | // sleep(1) 133 | // if let alert = app.alerts.allElementsBoundByIndex.first { 134 | // waitFor(element: alert, timeOut: 10) 135 | // 136 | // alert.buttons.allElementsBoundByIndex.first?.tap() 137 | // } 138 | // 139 | // let logOutButton = app.buttons["LogoutButton"] 140 | // waitFor(element: logOutButton, timeOut: 5) 141 | // 142 | // networkMocker.stubLogOut() 143 | // 144 | // logOutButton.tap() 145 | // } 146 | // 147 | // func testSignInSuccess() { 148 | // app.launch() 149 | // 150 | // networkMocker.stubLogIn() 151 | // 152 | // app.attemptSignIn(in: self, 153 | // with: "automation@test.com", 154 | // password: "holahola") 155 | // 156 | // let logOutButton = app.buttons["LogoutButton"] 157 | // waitFor(element: logOutButton, timeOut: 10) 158 | // 159 | // networkMocker.stubLogOut() 160 | // logOutButton.forceTap() 161 | // 162 | // let goToSignInButton = app.buttons["GoToSignInButton"] 163 | // waitFor(element: goToSignInButton, timeOut: 10) 164 | // } 165 | } 166 | -------------------------------------------------------------------------------- /init.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | let baseProjectName = "ios-base" 4 | var projectName = "RSDemoProject" 5 | let baseDomain = "com.rootstrap" 6 | var bundleDomain = baseDomain 7 | let baseCompany = "Rootstrap Inc." 8 | var companyName = baseCompany 9 | 10 | let whiteList: [String] = [".DS_Store", "UserInterfaceState.xcuserstate", "init.swift"] 11 | let fileManager = FileManager.default 12 | var currentFolder: String { 13 | return fileManager.currentDirectoryPath 14 | } 15 | 16 | enum SetupStep: Int { 17 | case nameEntry = 1 18 | case bundleDomainEntry 19 | case companyNameEntry 20 | 21 | var question: String { 22 | switch self { 23 | case .nameEntry: return "Enter a name for the project" 24 | case .bundleDomainEntry: return "Enter the reversed domain of your organization" 25 | case .companyNameEntry: return "Enter the Company name to use on file's headers" 26 | } 27 | } 28 | } 29 | 30 | // Helper methods 31 | 32 | func prompt(message: String) -> String? { 33 | print("\n" + message) 34 | let answer = readLine() 35 | return answer == nil || answer == "" ? nil : answer! 36 | } 37 | 38 | func setup(step: SetupStep, defaultValue: String) -> String { 39 | let result = prompt(message: "\(step.rawValue). " + step.question + " (leave blank for \(defaultValue)).") 40 | guard let res = result else { 41 | print(defaultValue) 42 | return defaultValue 43 | } 44 | return res 45 | } 46 | 47 | func shell(_ args: String...) -> (output: String, exitCode: Int32) { 48 | let task = Process() 49 | task.launchPath = "/usr/bin/env" 50 | task.arguments = args 51 | task.currentDirectoryPath = currentFolder 52 | let pipe = Pipe() 53 | task.standardOutput = pipe 54 | task.launch() 55 | task.waitUntilExit() 56 | let data = pipe.fileHandleForReading.readDataToEndOfFile() 57 | let output = String(data: data, encoding: .utf8) ?? "" 58 | return (output, task.terminationStatus) 59 | } 60 | 61 | extension URL { 62 | var fileName: String { 63 | let urlValues = try? resourceValues(forKeys: [.nameKey]) 64 | return urlValues?.name ?? "" 65 | } 66 | 67 | var isDirectory: Bool { 68 | let urlValues = try? resourceValues(forKeys: [.isDirectoryKey]) 69 | return urlValues?.isDirectory ?? false 70 | } 71 | 72 | func rename(from oldName: String, to newName: String) { 73 | if fileName.contains(oldName) { 74 | let newName = fileName.replacingOccurrences(of: oldName, with: newName) 75 | try! fileManager.moveItem(at: self, to: URL(fileURLWithPath: newName, relativeTo: deletingLastPathComponent())) 76 | } 77 | } 78 | 79 | func replaceOccurrences(of value: String, with newValue: String) { 80 | guard let fileContent = try? String(contentsOfFile: path, encoding: .utf8) else { 81 | print("Unable to read file at: \(self)") 82 | return 83 | } 84 | let updatedContent = fileContent.replacingOccurrences(of: value, with: newValue) 85 | try! updatedContent.write(to: self, atomically: true, encoding: .utf8) 86 | } 87 | 88 | func setupForNewProject() { 89 | replaceOccurrences(of: baseProjectName, with: projectName) 90 | replaceOccurrences(of: baseDomain, with: bundleDomain) 91 | rename(from: baseProjectName, to: projectName) 92 | } 93 | } 94 | 95 | // Helper functions 96 | 97 | func changeOrganizationName() { 98 | let pbxProjectPath = "\(currentFolder)/\(baseProjectName).xcodeproj/project.pbxproj" 99 | guard 100 | fileManager.fileExists(atPath: pbxProjectPath), 101 | companyName != baseCompany 102 | else { return } 103 | 104 | print("\nUpdating company name to '\(companyName)'...") 105 | 106 | let filterKey = "ORGANIZATIONNAME" 107 | let organizationNameFilter = "\(filterKey) = \"\(baseCompany)\"" 108 | let organizationNameReplacement = "\(filterKey) = \"\(companyName)\"" 109 | let fileUrl = URL(fileURLWithPath: pbxProjectPath) 110 | fileUrl.replaceOccurrences( 111 | of: organizationNameFilter, 112 | with: organizationNameReplacement 113 | ) 114 | } 115 | 116 | // Project Initialization 117 | 118 | print(""" 119 | +-----------------------------------------+ 120 | | | 121 | | < New iOS Project Setup > | 122 | | | 123 | +-----------------------------------------+ 124 | """) 125 | 126 | projectName = setup(step: .nameEntry, defaultValue: projectName) 127 | bundleDomain = setup(step: .bundleDomainEntry, defaultValue: baseDomain) 128 | companyName = setup(step: .companyNameEntry, defaultValue: baseCompany) 129 | 130 | //Remove current git tracking 131 | _ = shell("rm", "-rf", ".git") 132 | 133 | changeOrganizationName() 134 | 135 | print("\nRenaming to '\(projectName)'...") 136 | let enumerator = fileManager.enumerator(at: URL(fileURLWithPath: currentFolder), includingPropertiesForKeys: [.nameKey, .isDirectoryKey])! 137 | var directories: [URL] = [] 138 | while let itemURL = enumerator.nextObject() as? URL { 139 | guard !whiteList.contains(itemURL.fileName) else { continue } 140 | if itemURL.isDirectory { 141 | directories.append(itemURL) 142 | } else { 143 | itemURL.setupForNewProject() 144 | } 145 | } 146 | 147 | for dir in directories.reversed() { 148 | dir.rename(from: baseProjectName, to: projectName) 149 | } 150 | //TODO: Rename current dir 151 | let currentURL = URL(fileURLWithPath: currentFolder) 152 | currentURL.rename(from: baseProjectName, to: projectName) 153 | 154 | print("Opening new project...") 155 | _ = shell("open", "\(projectName).xcworkspace") 156 | // Initialization Done! 157 | print("************** ALL SET! *******************") 158 | -------------------------------------------------------------------------------- /ios-base.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "abseil-cpp-swiftpm", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/firebase/abseil-cpp-SwiftPM.git", 7 | "state" : { 8 | "revision" : "fffc3c2729be5747390ad02d5100291a0d9ad26a", 9 | "version" : "0.20200225.4" 10 | } 11 | }, 12 | { 13 | "identity" : "alamofire", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/Alamofire/Alamofire.git", 16 | "state" : { 17 | "revision" : "bc268c28fb170f494de9e9927c371b8342979ece", 18 | "version" : "5.7.1" 19 | } 20 | }, 21 | { 22 | "identity" : "boringssl-swiftpm", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/firebase/boringssl-SwiftPM.git", 25 | "state" : { 26 | "revision" : "734a8247442fde37df4364c21f6a0085b6a36728", 27 | "version" : "0.7.2" 28 | } 29 | }, 30 | { 31 | "identity" : "device", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/Ekhoo/Device.git", 34 | "state" : { 35 | "revision" : "50054824ed26a6a0deb58c47480903eaaa4ec5dd", 36 | "version" : "3.5.0" 37 | } 38 | }, 39 | { 40 | "identity" : "firebase-ios-sdk", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/firebase/firebase-ios-sdk.git", 43 | "state" : { 44 | "revision" : "d28849cb12de765d013b7222123bd3b8ae8c4f0a", 45 | "version" : "8.6.0" 46 | } 47 | }, 48 | { 49 | "identity" : "googleappmeasurement", 50 | "kind" : "remoteSourceControl", 51 | "location" : "https://github.com/google/GoogleAppMeasurement.git", 52 | "state" : { 53 | "revision" : "06e74ef7ee7326e1af724d462091eed1e5c6fb4a", 54 | "version" : "8.3.1" 55 | } 56 | }, 57 | { 58 | "identity" : "googledatatransport", 59 | "kind" : "remoteSourceControl", 60 | "location" : "https://github.com/google/GoogleDataTransport.git", 61 | "state" : { 62 | "revision" : "98a00258d4518b7521253a70b7f70bb76d2120fe", 63 | "version" : "9.2.4" 64 | } 65 | }, 66 | { 67 | "identity" : "googleutilities", 68 | "kind" : "remoteSourceControl", 69 | "location" : "https://github.com/google/GoogleUtilities.git", 70 | "state" : { 71 | "revision" : "58d03d22beae762eaddbd30cb5a61af90d4b309f", 72 | "version" : "7.11.3" 73 | } 74 | }, 75 | { 76 | "identity" : "grpc-swiftpm", 77 | "kind" : "remoteSourceControl", 78 | "location" : "https://github.com/firebase/grpc-SwiftPM.git", 79 | "state" : { 80 | "revision" : "fb405dd2c7901485f7e158b24e3a0a47e4efd8b5", 81 | "version" : "1.28.4" 82 | } 83 | }, 84 | { 85 | "identity" : "gtm-session-fetcher", 86 | "kind" : "remoteSourceControl", 87 | "location" : "https://github.com/google/gtm-session-fetcher.git", 88 | "state" : { 89 | "revision" : "4e9bbf2808b8fee444e84a48f5f3c12641987d3e", 90 | "version" : "1.7.2" 91 | } 92 | }, 93 | { 94 | "identity" : "iqkeyboardmanager", 95 | "kind" : "remoteSourceControl", 96 | "location" : "https://github.com/hackiftekhar/IQKeyboardManager.git", 97 | "state" : { 98 | "revision" : "ea08e08958890043019d248065fe3d825f338087", 99 | "version" : "6.5.12" 100 | } 101 | }, 102 | { 103 | "identity" : "leveldb", 104 | "kind" : "remoteSourceControl", 105 | "location" : "https://github.com/firebase/leveldb.git", 106 | "state" : { 107 | "revision" : "0706abcc6b0bd9cedfbb015ba840e4a780b5159b", 108 | "version" : "1.22.2" 109 | } 110 | }, 111 | { 112 | "identity" : "nanopb", 113 | "kind" : "remoteSourceControl", 114 | "location" : "https://github.com/firebase/nanopb.git", 115 | "state" : { 116 | "revision" : "7ee9ef9f627d85cbe1b8c4f49a3ed26eed216c77", 117 | "version" : "2.30908.0" 118 | } 119 | }, 120 | { 121 | "identity" : "promises", 122 | "kind" : "remoteSourceControl", 123 | "location" : "https://github.com/google/promises.git", 124 | "state" : { 125 | "revision" : "ec957ccddbcc710ccc64c9dcbd4c7006fcf8b73a", 126 | "version" : "2.2.0" 127 | } 128 | }, 129 | { 130 | "identity" : "rsfontsizes", 131 | "kind" : "remoteSourceControl", 132 | "location" : "https://github.com/rootstrap/RSFontSizes", 133 | "state" : { 134 | "revision" : "a24d332335264a466b5cbc1387197dbd689a1a80", 135 | "version" : "1.3.3" 136 | } 137 | }, 138 | { 139 | "identity" : "rsswiftnetworking", 140 | "kind" : "remoteSourceControl", 141 | "location" : "https://github.com/rootstrap/RSSwiftNetworking", 142 | "state" : { 143 | "revision" : "0fb25cd68154313e597e68f3f286da46ed9cc4f9", 144 | "version" : "1.1.7" 145 | } 146 | }, 147 | { 148 | "identity" : "swift-protobuf", 149 | "kind" : "remoteSourceControl", 150 | "location" : "https://github.com/apple/swift-protobuf.git", 151 | "state" : { 152 | "revision" : "f25867a208f459d3c5a06935dceb9083b11cd539", 153 | "version" : "1.22.0" 154 | } 155 | }, 156 | { 157 | "identity" : "swifter", 158 | "kind" : "remoteSourceControl", 159 | "location" : "https://github.com/httpswift/swifter.git", 160 | "state" : { 161 | "revision" : "9483a5d459b45c3ffd059f7b55f9638e268632fd", 162 | "version" : "1.5.0" 163 | } 164 | } 165 | ], 166 | "version" : 2 167 | } 168 | -------------------------------------------------------------------------------- /ios-base.xcodeproj/xcshareddata/xcschemes/ios-base-develop.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 38 | 39 | 40 | 41 | 47 | 48 | 54 | 55 | 61 | 62 | 63 | 64 | 66 | 72 | 73 | 74 | 77 | 83 | 84 | 85 | 86 | 87 | 97 | 99 | 105 | 106 | 107 | 108 | 111 | 112 | 113 | 114 | 120 | 122 | 128 | 129 | 130 | 131 | 133 | 134 | 137 | 138 | 139 | -------------------------------------------------------------------------------- /ios-base/Networking/Services/AuthenticationServices.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthenticationServices.swift 3 | // ios-base 4 | // 5 | // Created by Germán Stábile on 6/8/20. 6 | // Copyright © 2020 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | import RSSwiftNetworking 12 | import RSSwiftNetworkingAlamofire 13 | 14 | internal class AuthenticationServices { 15 | 16 | enum AuthError: LocalizedError { 17 | case login 18 | case signUp 19 | case logout 20 | case mapping 21 | case request 22 | case userSessionInvalid 23 | 24 | var errorDescription: String? { 25 | switch self { 26 | case .login: 27 | return "authError_login".localized 28 | case .signUp: 29 | return "authError_signUp".localized 30 | case .logout: 31 | return "authError_logout".localized 32 | case .mapping: 33 | return "authError_mapping".localized 34 | case .request: 35 | return "authError_request".localized 36 | case .userSessionInvalid: 37 | return "authError_request".localized 38 | } 39 | } 40 | } 41 | 42 | // MARK: - Properties 43 | 44 | fileprivate static let usersUrl = "/users/" 45 | fileprivate static let currentUserUrl = "/user/" 46 | 47 | private let sessionManager: SessionManager 48 | private let userDataManager: UserDataManager 49 | private let apiClient: BaseAPIClient 50 | 51 | init( 52 | sessionManager: SessionManager = .shared, 53 | userDataManager: UserDataManager = .shared, 54 | apiClient: BaseAPIClient = BaseAPIClient.alamofire 55 | ) { 56 | self.sessionManager = sessionManager 57 | self.userDataManager = userDataManager 58 | self.apiClient = apiClient 59 | } 60 | 61 | @discardableResult func login( 62 | email: String, 63 | password: String 64 | ) async -> Result { 65 | let response: RequestResponse = await apiClient.request( 66 | endpoint: AuthEndpoint.signIn(email: email, password: password) 67 | ) 68 | switch response.result { 69 | case .success(let user): 70 | if 71 | let user, 72 | self.saveUserSession(user.data, headers: response.responseHeaders) 73 | { 74 | return .success(user) 75 | } else { 76 | return .failure(AuthError.mapping) 77 | } 78 | case .failure: 79 | return .failure(AuthError.login) 80 | } 81 | } 82 | 83 | /// Example Upload via Multipart requests. 84 | /// TODO: rails base backend not supporting multipart uploads yet 85 | @discardableResult func signup( 86 | email: String, 87 | password: String, 88 | avatar: UIImage 89 | ) async -> Result { 90 | 91 | guard let picData = avatar.jpegData(compressionQuality: 0.75) else { 92 | let pictureDataError = App.error( 93 | domain: .generic, 94 | localizedDescription: "Multipart image data could not be constructed" 95 | ) 96 | return .failure(AuthError.mapping) 97 | } 98 | 99 | // Mixed base64 encoded and multipart images are supported 100 | // in the [MultipartMedia] array 101 | // Example: `let image2 = Base64Media(key: "user[image]", data: picData)` 102 | // Then: media [image, image2] 103 | let image = MultipartMedia(fileName: "\(email)_image", key: "user[avatar]", data: picData) 104 | 105 | let endpoint = AuthEndpoint.signUp( 106 | email: email, 107 | password: password, 108 | passwordConfirmation: password, 109 | picture: nil 110 | ) 111 | 112 | let response: RequestResponse = await apiClient.multipartRequest( 113 | endpoint: endpoint, 114 | paramsRootKey: "", 115 | media: [image]) 116 | 117 | switch response.result { 118 | case .success(let user): 119 | if 120 | let user, 121 | self.saveUserSession(user.data, headers: response.responseHeaders) 122 | { 123 | return .success(user) 124 | } else { 125 | return .failure(AuthError.mapping) 126 | } 127 | case .failure: 128 | return .failure(AuthError.signUp) 129 | } 130 | } 131 | 132 | /// Example method that uploads base64 encoded image. 133 | @discardableResult func signup( 134 | email: String, 135 | password: String, 136 | avatar64: UIImage 137 | ) async -> Result { 138 | let response: RequestResponse = await apiClient.request( 139 | endpoint: AuthEndpoint.signUp( 140 | email: email, 141 | password: password, 142 | passwordConfirmation: password, 143 | picture: avatar64.jpegData(compressionQuality: 0.75) 144 | ) 145 | ) 146 | 147 | switch response.result { 148 | case .success(let user): 149 | if 150 | let user, 151 | self.saveUserSession(user.data, headers: response.responseHeaders) 152 | { 153 | return .success(user) 154 | } else { 155 | return .failure(AuthError.mapping) 156 | } 157 | case .failure: 158 | return .failure(AuthError.signUp) 159 | } 160 | } 161 | 162 | @discardableResult func logout() async -> Result { 163 | let response: RequestResponse = await apiClient.request( 164 | endpoint: AuthEndpoint.logout 165 | ) 166 | switch response.result { 167 | case .success: 168 | userDataManager.deleteUser() 169 | sessionManager.deleteSession() 170 | return .success(true) 171 | case .failure: 172 | return .failure(AuthError.logout) 173 | } 174 | } 175 | 176 | @discardableResult func deleteAccount() async -> Result { 177 | let response: RequestResponse = await apiClient.request( 178 | endpoint: AuthEndpoint.deleteAccount 179 | ) 180 | switch response.result { 181 | case .success: 182 | userDataManager.deleteUser() 183 | sessionManager.deleteSession() 184 | return .success(()) 185 | case .failure: 186 | return .failure(AuthError.logout) 187 | } 188 | } 189 | 190 | func saveUserSession( 191 | _ user: User?, 192 | headers: [AnyHashable: Any] 193 | ) -> Bool { 194 | userDataManager.currentUser = user 195 | guard let session = Session(headers: headers) else { return false } 196 | sessionManager.saveUser(session: session) 197 | return userDataManager.currentUser != nil && sessionManager.currentSession?.isValid ?? false 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | CFPropertyList (3.0.6) 5 | rexml 6 | activesupport (7.0.6) 7 | concurrent-ruby (~> 1.0, >= 1.0.2) 8 | i18n (>= 1.6, < 2) 9 | minitest (>= 5.1) 10 | tzinfo (~> 2.0) 11 | addressable (2.8.4) 12 | public_suffix (>= 2.0.2, < 6.0) 13 | apktools (0.7.4) 14 | rubyzip (~> 2.0) 15 | artifactory (3.0.15) 16 | atomos (0.1.3) 17 | aws-eventstream (1.2.0) 18 | aws-partitions (1.786.0) 19 | aws-sdk-core (3.178.0) 20 | aws-eventstream (~> 1, >= 1.0.2) 21 | aws-partitions (~> 1, >= 1.651.0) 22 | aws-sigv4 (~> 1.5) 23 | jmespath (~> 1, >= 1.6.1) 24 | aws-sdk-kms (1.71.0) 25 | aws-sdk-core (~> 3, >= 3.177.0) 26 | aws-sigv4 (~> 1.1) 27 | aws-sdk-s3 (1.130.0) 28 | aws-sdk-core (~> 3, >= 3.177.0) 29 | aws-sdk-kms (~> 1) 30 | aws-sigv4 (~> 1.6) 31 | aws-sigv4 (1.6.0) 32 | aws-eventstream (~> 1, >= 1.0.2) 33 | babosa (1.0.4) 34 | claide (1.1.0) 35 | clamp (1.3.2) 36 | colored (1.2) 37 | colored2 (3.1.2) 38 | commander (4.6.0) 39 | highline (~> 2.0.0) 40 | concurrent-ruby (1.2.2) 41 | declarative (0.0.20) 42 | digest-crc (0.6.5) 43 | rake (>= 12.0.0, < 14.0.0) 44 | domain_name (0.5.20190701) 45 | unf (>= 0.0.5, < 1.0.0) 46 | dotenv (2.8.1) 47 | emoji_regex (3.2.3) 48 | excon (0.100.0) 49 | faraday (1.10.3) 50 | faraday-em_http (~> 1.0) 51 | faraday-em_synchrony (~> 1.0) 52 | faraday-excon (~> 1.1) 53 | faraday-httpclient (~> 1.0) 54 | faraday-multipart (~> 1.0) 55 | faraday-net_http (~> 1.0) 56 | faraday-net_http_persistent (~> 1.0) 57 | faraday-patron (~> 1.0) 58 | faraday-rack (~> 1.0) 59 | faraday-retry (~> 1.0) 60 | ruby2_keywords (>= 0.0.4) 61 | faraday-cookie_jar (0.0.7) 62 | faraday (>= 0.8.0) 63 | http-cookie (~> 1.0.0) 64 | faraday-em_http (1.0.0) 65 | faraday-em_synchrony (1.0.0) 66 | faraday-excon (1.1.0) 67 | faraday-httpclient (1.0.1) 68 | faraday-multipart (1.0.4) 69 | multipart-post (~> 2) 70 | faraday-net_http (1.0.1) 71 | faraday-net_http_persistent (1.2.0) 72 | faraday-patron (1.0.0) 73 | faraday-rack (1.0.0) 74 | faraday-retry (1.0.3) 75 | faraday_middleware (1.2.0) 76 | faraday (~> 1.0) 77 | fastimage (2.2.7) 78 | fastlane (2.213.0) 79 | CFPropertyList (>= 2.3, < 4.0.0) 80 | addressable (>= 2.8, < 3.0.0) 81 | artifactory (~> 3.0) 82 | aws-sdk-s3 (~> 1.0) 83 | babosa (>= 1.0.3, < 2.0.0) 84 | bundler (>= 1.12.0, < 3.0.0) 85 | colored 86 | commander (~> 4.6) 87 | dotenv (>= 2.1.1, < 3.0.0) 88 | emoji_regex (>= 0.1, < 4.0) 89 | excon (>= 0.71.0, < 1.0.0) 90 | faraday (~> 1.0) 91 | faraday-cookie_jar (~> 0.0.6) 92 | faraday_middleware (~> 1.0) 93 | fastimage (>= 2.1.0, < 3.0.0) 94 | gh_inspector (>= 1.1.2, < 2.0.0) 95 | google-apis-androidpublisher_v3 (~> 0.3) 96 | google-apis-playcustomapp_v1 (~> 0.1) 97 | google-cloud-storage (~> 1.31) 98 | highline (~> 2.0) 99 | json (< 3.0.0) 100 | jwt (>= 2.1.0, < 3) 101 | mini_magick (>= 4.9.4, < 5.0.0) 102 | multipart-post (>= 2.0.0, < 3.0.0) 103 | naturally (~> 2.2) 104 | optparse (~> 0.1.1) 105 | plist (>= 3.1.0, < 4.0.0) 106 | rubyzip (>= 2.0.0, < 3.0.0) 107 | security (= 0.1.3) 108 | simctl (~> 1.6.3) 109 | terminal-notifier (>= 2.0.0, < 3.0.0) 110 | terminal-table (>= 1.4.5, < 2.0.0) 111 | tty-screen (>= 0.6.3, < 1.0.0) 112 | tty-spinner (>= 0.8.0, < 1.0.0) 113 | word_wrap (~> 1.0.0) 114 | xcodeproj (>= 1.13.0, < 2.0.0) 115 | xcpretty (~> 0.3.0) 116 | xcpretty-travis-formatter (>= 0.0.3) 117 | fastlane-plugin-aws_s3 (2.1.0) 118 | apktools (~> 0.7) 119 | aws-sdk-s3 (~> 1) 120 | mime-types (~> 3.3) 121 | gh_inspector (1.1.3) 122 | google-apis-androidpublisher_v3 (0.45.0) 123 | google-apis-core (>= 0.11.0, < 2.a) 124 | google-apis-core (0.11.0) 125 | addressable (~> 2.5, >= 2.5.1) 126 | googleauth (>= 0.16.2, < 2.a) 127 | httpclient (>= 2.8.1, < 3.a) 128 | mini_mime (~> 1.0) 129 | representable (~> 3.0) 130 | retriable (>= 2.0, < 4.a) 131 | rexml 132 | webrick 133 | google-apis-iamcredentials_v1 (0.17.0) 134 | google-apis-core (>= 0.11.0, < 2.a) 135 | google-apis-playcustomapp_v1 (0.13.0) 136 | google-apis-core (>= 0.11.0, < 2.a) 137 | google-apis-storage_v1 (0.19.0) 138 | google-apis-core (>= 0.9.0, < 2.a) 139 | google-cloud-core (1.6.0) 140 | google-cloud-env (~> 1.0) 141 | google-cloud-errors (~> 1.0) 142 | google-cloud-env (1.6.0) 143 | faraday (>= 0.17.3, < 3.0) 144 | google-cloud-errors (1.3.1) 145 | google-cloud-storage (1.44.0) 146 | addressable (~> 2.8) 147 | digest-crc (~> 0.4) 148 | google-apis-iamcredentials_v1 (~> 0.1) 149 | google-apis-storage_v1 (~> 0.19.0) 150 | google-cloud-core (~> 1.6) 151 | googleauth (>= 0.16.2, < 2.a) 152 | mini_mime (~> 1.0) 153 | googleauth (1.6.0) 154 | faraday (>= 0.17.3, < 3.a) 155 | jwt (>= 1.4, < 3.0) 156 | memoist (~> 0.16) 157 | multi_json (~> 1.11) 158 | os (>= 0.9, < 2.0) 159 | signet (>= 0.16, < 2.a) 160 | highline (2.0.3) 161 | http-cookie (1.0.5) 162 | domain_name (~> 0.5) 163 | httpclient (2.8.3) 164 | i18n (1.14.1) 165 | concurrent-ruby (~> 1.0) 166 | jmespath (1.6.2) 167 | json (2.6.3) 168 | jwt (2.7.1) 169 | memoist (0.16.2) 170 | mime-types (3.4.1) 171 | mime-types-data (~> 3.2015) 172 | mime-types-data (3.2023.0218.1) 173 | mini_magick (4.12.0) 174 | mini_mime (1.1.2) 175 | minitest (5.18.1) 176 | multi_json (1.15.0) 177 | multipart-post (2.3.0) 178 | nanaimo (0.3.0) 179 | naturally (2.2.1) 180 | nokogiri (1.15.3-x86_64-darwin) 181 | racc (~> 1.4) 182 | optparse (0.1.1) 183 | os (1.1.4) 184 | plist (3.7.0) 185 | public_suffix (5.0.3) 186 | racc (1.7.1) 187 | rake (13.0.6) 188 | representable (3.2.0) 189 | declarative (< 0.1.0) 190 | trailblazer-option (>= 0.1.1, < 0.2.0) 191 | uber (< 0.2.0) 192 | retriable (3.1.2) 193 | rexml (3.2.5) 194 | rouge (2.0.7) 195 | ruby2_keywords (0.0.5) 196 | rubyzip (2.3.2) 197 | security (0.1.3) 198 | signet (0.17.0) 199 | addressable (~> 2.8) 200 | faraday (>= 0.17.5, < 3.a) 201 | jwt (>= 1.5, < 3.0) 202 | multi_json (~> 1.10) 203 | simctl (1.6.10) 204 | CFPropertyList 205 | naturally 206 | slather (2.7.4) 207 | CFPropertyList (>= 2.2, < 4) 208 | activesupport 209 | clamp (~> 1.3) 210 | nokogiri (>= 1.13.9) 211 | xcodeproj (~> 1.21) 212 | terminal-notifier (2.0.0) 213 | terminal-table (1.8.0) 214 | unicode-display_width (~> 1.1, >= 1.1.1) 215 | trailblazer-option (0.1.2) 216 | tty-cursor (0.7.1) 217 | tty-screen (0.8.1) 218 | tty-spinner (0.9.3) 219 | tty-cursor (~> 0.7) 220 | tzinfo (2.0.6) 221 | concurrent-ruby (~> 1.0) 222 | uber (0.1.0) 223 | unf (0.1.4) 224 | unf_ext 225 | unf_ext (0.0.8.2) 226 | unicode-display_width (1.8.0) 227 | webrick (1.8.1) 228 | word_wrap (1.0.0) 229 | xcodeproj (1.22.0) 230 | CFPropertyList (>= 2.3.3, < 4.0) 231 | atomos (~> 0.1.3) 232 | claide (>= 1.0.2, < 2.0) 233 | colored2 (~> 3.1) 234 | nanaimo (~> 0.3.0) 235 | rexml (~> 3.2.4) 236 | xcpretty (0.3.0) 237 | rouge (~> 2.0.7) 238 | xcpretty-travis-formatter (1.0.1) 239 | xcpretty (~> 0.2, >= 0.0.7) 240 | 241 | PLATFORMS 242 | x86_64-darwin-21 243 | 244 | DEPENDENCIES 245 | fastlane 246 | fastlane-plugin-aws_s3 247 | slather 248 | 249 | BUNDLED WITH 250 | 2.3.22 251 | -------------------------------------------------------------------------------- /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 | skip_docs 17 | 18 | default_platform(:ios) 19 | 20 | # CONFIG VARIABLES 21 | app_name = 'ios-base' 22 | team_id = ENV["APPLE_TEAM_ID"] # The organization's team id in the Apple Developer portal 23 | cert = ENV["APPLE_CERT"] # Local path to distribution certificate file to be used for signing the build 24 | key = ENV["APPLE_KEY"] # Private key (.p12 file) used for encrypting certificate 25 | key_pwd = ENV["APPLE_KEY_PASSWORD"] # Password to private key file 26 | appstore_key_id = ENV["APP_STORE_CONNECT_API_KEY_KEY_ID"] # AppStore Connect API id and issuer 27 | appstore_issuer_id = ENV["APP_STORE_CONNECT_API_KEY_ISSUER_ID"] # 28 | appstore_key_filepath = ENV["APP_STORE_CONNECT_API_KEY_FILE"] # location of .p8 API key file 29 | 30 | # S3 31 | s3_key = ENV["AWS_ACCESS_KEY_ID"] # credentials for uploading files to S3 32 | s3_secret_key = ENV["AWS_SECRET_ACCESS_KEY"] # 33 | s3_region = ENV["AWS_REGION"] # 34 | s3_bucket = ENV["BUILDS_BUCKET"] # S3 bucket and parent folder to upload to 35 | folder = ENV["FOLDER"] # 36 | 37 | # Slack 38 | slack_channel = ENV["SLACK_CHANNEL"] # Slack webhook url and channel name for sending notifications upon completion 39 | slack_url = ENV["SLACK_URL"] # 40 | 41 | 42 | 43 | platform :ios do 44 | 45 | lane :set_signing do 46 | # Create keychain - (Travis setup works for GitHhub Actions too) 47 | setup_ci( 48 | force: true, 49 | provider: "travis" 50 | ) 51 | # Unlock keychain and set as default 52 | unlock_keychain( 53 | path: "fastlane_tmp_keychain", 54 | password: "", 55 | set_default: true 56 | ) 57 | # Import .cer and .p12 - this is a workaround for fastlane match when we retrieve certs from a custom location 58 | import_certificate( 59 | certificate_path: key, 60 | certificate_password: key_pwd, 61 | keychain_name: "fastlane_tmp_keychain", 62 | keychain_password: "", 63 | log_output: true 64 | ) 65 | import_certificate( 66 | certificate_path: cert, 67 | keychain_name: "fastlane_tmp_keychain", 68 | keychain_password: "", 69 | log_output: true 70 | ) 71 | end 72 | 73 | lane :build_and_sign do |options| 74 | set_signing 75 | # pod install 76 | cocoapods 77 | gym( 78 | scheme: options[:scheme], 79 | workspace: app_name+'.xcworkspace', 80 | export_method: options[:method], 81 | export_options: {iCloudContainerEnvironment: 'Production'}, 82 | clean: true, 83 | include_bitcode: true, 84 | output_name: options[:scheme]+".ipa" 85 | ) 86 | end 87 | 88 | lane :publish_appstore do |options| 89 | # get timestamp 90 | datetime = sh("date +%Y%m%d%H%M").chomp 91 | # get branch name 92 | branch = git_branch() 93 | # get app version 94 | version = get_version_number(target: options[:scheme]) 95 | changelog_from_git_commits( 96 | pretty: "- (%ae) %s",# Optional, lets you provide a custom format to apply to each commit when generating the changelog text 97 | date_format: "short",# Optional, lets you provide an additional date format to dates within the pretty-formatted string 98 | match_lightweight_tag: false, # Optional, lets you ignore lightweight (non-annotated) tags when searching for the last tag 99 | merge_commit_filtering: "exclude_merges" # Optional, lets you filter out merge commits 100 | ) 101 | # Load an App Store Connect API token 102 | api_key = app_store_connect_api_key( 103 | key_id: appstore_key_id, 104 | issuer_id: appstore_issuer_id, 105 | key_filepath: appstore_key_filepath, 106 | in_house: false # determines this is an AppStore team 107 | ) 108 | # Submit to TestFlight with the previous token 109 | upload_to_testflight( 110 | api_key: api_key, 111 | skip_waiting_for_build_processing: true 112 | ) 113 | # send Slack notification - optional 114 | slack( 115 | message: "Hi! A new iOS "+options[:scheme]+" build has been submitted to TestFlight", 116 | payload: { 117 | "Build Date" => Time.new.to_s, 118 | "Release Version" => version 119 | }, 120 | channel: slack_channel, 121 | slack_url: slack_url, 122 | use_webhook_configured_username_and_icon: true, 123 | fail_on_error: false, 124 | success: true 125 | ) 126 | end 127 | 128 | lane :publish_s3 do |options| 129 | # get timestamp 130 | datetime = sh("date +%Y%m%d%H%M").chomp 131 | # get branch name 132 | branch = git_branch() 133 | # get app version 134 | version = get_version_number(target: options[:scheme]) 135 | # push to S3 136 | s3_path = folder+"/ios/"+branch+"/"+version+"-"+datetime+"/" 137 | aws_s3( 138 | access_key: s3_key, 139 | secret_access_key: s3_secret_key, 140 | bucket: s3_bucket, 141 | region: s3_region, 142 | ipa: lane_context[SharedValues::IPA_OUTPUT_PATH], 143 | path: s3_path, 144 | acl: "public-read", 145 | upload_metadata: true 146 | ) 147 | # send notification 148 | slack( 149 | message: "Hi! A new iOS build has been uploaded for "+options[:scheme], 150 | payload: { 151 | "Build Date" => Time.new.to_s, 152 | "Release Version" => version, 153 | "Location" => lane_context[SharedValues::S3_FOLDER_OUTPUT_PATH], 154 | "Download link" => "https://"+s3_bucket+".s3.amazonaws.com/"+s3_path+options[:scheme]+".ipa" 155 | }, 156 | channel: slack_channel, 157 | slack_url: slack_url, 158 | use_webhook_configured_username_and_icon: true, 159 | fail_on_error: false, 160 | success: true 161 | ) 162 | end 163 | 164 | #DEVELOP 165 | lane :build_develop do 166 | build_and_sign( 167 | scheme: app_name+'-develop', 168 | method: 'ad-hoc' 169 | ) 170 | end 171 | 172 | lane :share_develop do 173 | build_and_sign( 174 | scheme: app_name+'-develop', 175 | method: 'ad-hoc' 176 | ) 177 | publish_s3( 178 | scheme: app_name+'-develop' 179 | ) 180 | end 181 | 182 | lane :release_develop do 183 | build_and_sign( 184 | scheme: app_name+'-develop', 185 | method: 'app-store' 186 | ) 187 | publish_appstore( 188 | scheme: app_name+'-develop' 189 | ) 190 | end 191 | 192 | #STAGING 193 | lane :build_staging do 194 | build_and_sign( 195 | scheme: app_name+'-staging', 196 | method: 'ad-hoc' 197 | ) 198 | end 199 | 200 | lane :share_staging do 201 | build_and_sign( 202 | scheme: app_name+'-staging', 203 | method: 'ad-hoc' 204 | ) 205 | publish_s3( 206 | scheme: app_name+'-staging' 207 | ) 208 | end 209 | 210 | lane :release_staging do 211 | build_and_sign( 212 | scheme: app_name+'-staging', 213 | method: 'app-store' 214 | ) 215 | publish_appstore( 216 | scheme: app_name+'-staging' 217 | ) 218 | end 219 | 220 | #PRODUCTION 221 | lane :build_production do 222 | build_and_sign( 223 | scheme: app_name+'-production', 224 | method: 'ad-hoc' 225 | ) 226 | end 227 | 228 | lane :share_production do 229 | build_and_sign( 230 | scheme: app_name+'-production', 231 | method: 'ad-hoc' 232 | ) 233 | publish_s3( 234 | scheme: app_name+'-production' 235 | ) 236 | end 237 | 238 | lane :release_production do 239 | build_and_sign( 240 | scheme: app_name+'-production', 241 | method: 'app-store' 242 | ) 243 | publish_appstore( 244 | scheme: app_name+'-production' 245 | ) 246 | end 247 | end 248 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Maintainability](https://api.codeclimate.com/v1/badges/21b076c80057210cda75/maintainability)](https://codeclimate.com/github/rootstrap/ios-base/maintainability) 2 | [![Test Coverage](https://api.codeclimate.com/v1/badges/21b076c80057210cda75/test_coverage)](https://codeclimate.com/github/rootstrap/ios-base/test_coverage) 3 | [![License](https://img.shields.io/github/license/rootstrap/ios-base.svg)](https://github.com/rootstrap/ios-base/blob/master/LICENSE.md) 4 | 5 | # iOS Base Template 6 | **iOS base** is a boilerplate project created by Rootstrap for new projects using Swift 5. The main objective is helping any new projects jump start into feature development by providing a handful of functionalities. 7 | 8 | ## Features 9 | This template comes with: 10 | #### Main 11 | - Extensible and decoupled integration with an **API Client** to easily communicate with **REST services**. 12 | - A few examples to **comprehend the app architecture**(e.g. Account creation, Login, Logout) 13 | - Useful classes to **manage User and Session data**. 14 | - **Secure** way to store and manage secret keys of your **third party integrations**. 15 | - Centralized and intuitive **navigation system** that simplifies the transitioning between view controllers and streamlines the navigation flow within the app. 16 | - Convenient **helpers** and **extensions** to boost your productivity and improve the general coding experience. 17 | 18 | ## How to use 19 | 1. Clone the repo. 20 | 2. Run `./init` from the recently created folder. 21 | 3. Initialize a new git repo and add your remote URL. 22 | 4. Done! 23 | 24 | To manage user and session persistence after the original sign in/up we store that information in the native UserDefaults. The parameters that we save are due to the usage of [Devise Token Auth](https://github.com/lynndylanhurley/devise_token_auth) for authentication on the server side. Suffice to say that this can be modified to be on par with the server authentication of your choice. 25 | 26 | ## Dependencies 27 | #### Main 28 | - [Alamofire](https://github.com/Alamofire/Alamofire) for easy and elegant connection with an API. 29 | - [IQKeyboardManagerSwift](https://github.com/hackiftekhar/IQKeyboardManager) for auto-scrolling to current input in long views. 30 | Note: this pod is not fully working on iOS 11. [Here](https://github.com/hackiftekhar/IQKeyboardManager/issues/972) is the issue we encountered and the meantime solution. 31 | - [Firebase](https://github.com/firebase/firebase-ios-sdk) for tools to help you build, grow and monetize your app. 32 | 33 | #### Utilities 34 | 35 | We have developed other libraries that can be helpful and you could integrate with the dependency manager of your choice. 36 | 37 | - **[PagedLists:](https://github.com/rootstrap/PagedLists)** Custom `UITableView` and `UICollectionView` classes to easily handle pagination. 38 | - **[RSFontSizes:](https://github.com/rootstrap/RSFontSizes)** allows you to manage different font sizes for every device screen size in a flexible manner. 39 | - **[RSFormView:](https://github.com/rootstrap/RSFormView)** a library that helps you to build fully customizable forms for data entry in a few minutes. 40 | - **[SwiftGradients:](https://github.com/rootstrap/SwiftGradients)** Useful extensions for `UIViews` and `CALayer` classes to add beautiful color gradients. 41 | 42 | 43 | #### Testing 44 | - [KIF](https://github.com/kif-framework/KIF) for UI testing. 45 | - [KIF/IdentifierTests](https://github.com/kif-framework/KIF) to have access to accesibility identifiers. 46 | 47 | ## Mandatory configuration 48 | #### Firebase 49 | 50 | In order for the project to run, you have to follow these steps: 51 | 1. Register your app with Firebase. 52 | 2. Download Firebase configuration file `GoogleService-Info.plist` from your account. 53 | 3. Add the downloaded file to the /Resources folder. 54 | 4. Done :) 55 | 56 | See the [Firebase documentation](https://firebase.google.com/docs/ios/setup) for more information. 57 | 58 | ## Code Quality Standards 59 | In order to meet the required code quality standards, this project runs [SwiftLint](https://github.com/realm/SwiftLint) 60 | during the build phase and reports warnings/errors directly through XCode. 61 | 62 | **NOTE:** It's needed to install [SwiftLint](https://github.com/realm/SwiftLint) into your local machine to report warnings/errors. 63 | 64 | The current SwiftLint rule configuration is based on [Rootstrap's Swift style guides](https://rootstrap.github.io/swift) and is synced with 65 | the CodeCliemate's configuration file. 66 | 67 | **NOTE:** Make sure you have SwiftLint version 0.35.0 or greater installed to avoid known false-positives with some of the rules. 68 | 69 | ## Security recommendations 70 | 71 | ### Secrets management 72 | 73 | We strongly recommend that all private keys be added to a `secrets.xcconfig` file that will remain locally and not be committed to your project repo. 74 | 75 | #### Adding new secrets 76 | 1. Add the new environment variable in your system: 77 | - Optional: For local development, you can run `export KEY=value` in the terminal. 78 | _Or you could start with a pre-filled secrets.dev.xcconfig file._ 79 | - In your CI/CD platform, simply add the environment variable with its value to the respective settings section. 80 | 2. Add the new key name to the `keys.env` file. 81 | _This could be any other file you use as source for the script mentioned in the next step._ 82 | 3. Configure your CI/CD to run: 83 | - `chmod u+x setup-env.sh` 84 | - `./setup-env.sh` 85 | 4. Add the key to the Info.plist of your app's target. 86 | _Example: ThirdPartyKey = ${THIRD_PARTY_KEY}_ 87 | 5. Add a new case to the `Secret.Key` enum. 88 | _The rawValue must match the key in the Info.plist file_ 89 | 6. Use it wisely :) 90 | 91 | **Note:** The `setup-env` script will fill in the `secrets.xcconfig` for Staging and Release builds. 92 | Use `secrets.dev.xcconfig` for the `Debug` Build Configuration. 93 | 94 | #### Secure storage 95 | 96 | We recommend using [AWS S3](https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html) for storing `.xcconfig` files containing all secrets, as well as any other sensitive files. Alternatively when not using Fastlane Match (eg might not be compatible with some CICD systems), AWS S3 can also be used for storing Certificates, Private Keys and Profiles required for app signing. The CICD code examples (described below) make use of the [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html) to download any files relevant for our project from a predefined bucket and folder 97 | 98 | Another alternative for managing sensitive files whithin the repo using Git-Secret can be found in the [**feature/git-secret**](https://github.com/rootstrap/ios-base/tree/feature/jenkins) branch 99 | 100 | ## CI/CD configuration with Bitrise (updated on Dec 12th 2021) 101 | 102 | We are going to start using a tool called Bitrise to configure de CI/CD pipelines for mobiles apps. 103 | 104 | --> For iOS apps you can find how to do it in this link: https://www.notion.so/rootstrap/iOS-CI-CD-01e00409a0144f5b85212bf889c627dd 105 | 106 | 107 | ## Automated Build and Deployment using Fastlane (DEPRECATED) 108 | 109 | We use [Fastlane](https://docs.fastlane.tools) to automate code signing, building and release to TestFlight. 110 | 111 | See details in [Fastlane folder](fastlane/README.md). 112 | 113 | ## Continuous Integration / Delivery (DEPRECATED) 114 | 115 | We recommend [GitHub Actions](https://docs.github.com/en/actions) for integrating Fastlane into a CI/CD pipeline. You can find two workflows in the GitHub workflows folder: 116 | * [ci.yml](.github/workflows/release.myl) : triggered on any push and PR, runs unit tests, coverage report and static analysis with [CodeClimate](https://github.com/codeclimate/codeclimate) 117 | * [release.yml](.github/workflows/release.yml) : triggered on push to specific branches, builds, signs and submits to TestFlight 118 | 119 | Alternatively you can merge branch [**feature/jenkins**](https://github.com/rootstrap/ios-base/tree/feature/jenkins) for some equivalent CICD boilerplate with **Jenkins**. 120 | 121 | On both alternatives we assume usage of Fastlane match for managing signing Certificates and Profiles, and AWS S3 for storing other files containing third-party keys 122 | 123 | ## License 124 | 125 | iOS-Base is available under the MIT license. See the LICENSE file for more info. 126 | 127 | **NOTE:** Remove the free LICENSE file for private projects or replace it with the corresponding license. 128 | 129 | ## Credits 130 | 131 | **iOS Base** is maintained by [Rootstrap](http://www.rootstrap.com) with the help of our [contributors](https://github.com/rootstrap/ios-base/contributors). 132 | 133 | [](http://www.rootstrap.com) 134 | -------------------------------------------------------------------------------- /ios-base/Navigators/Navigator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Navigator.swift 3 | // ios-base 4 | // 5 | // Created by Mauricio Cousillas on 6/13/19. 6 | // Copyright © 2019 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | /** 13 | The base navigator class implements the navigator protocol 14 | exposing basic behaviour extensible via inheritance. 15 | For example, you should subclass if you want to add 16 | some logic to the initial route by checking something 17 | stored in your keychain. 18 | */ 19 | open class BaseNavigator: Navigator { 20 | open var rootViewController: UINavigationController? 21 | open var currentViewController: UIViewController? { 22 | rootViewController?.visibleViewController ?? rootViewController?.topViewController 23 | } 24 | 25 | public required init(with route: Route) { 26 | rootViewController = route.screen.embedInNavigationController() 27 | } 28 | } 29 | 30 | /** 31 | The Navigator is the base of this framework, it handles all 32 | your application navigation stack. 33 | It keeps track of your root NavigationController and the 34 | ViewController that is currently displayed. This way it can 35 | handle any kind of navigation action that you might want to dispatch. 36 | */ 37 | public protocol Navigator: AnyObject { 38 | /// The root navigation controller of your stack. 39 | var rootViewController: UINavigationController? { get set } 40 | 41 | /// The currently visible ViewController 42 | var currentViewController: UIViewController? { get } 43 | 44 | /// Convencience init to set your application starting screen. 45 | init(with route: Route) 46 | 47 | /** 48 | Navigate from your current screen to a new route. 49 | - Parameters: 50 | - route: The destination route of your navigation action. 51 | - transition: The transition type that you want to use. 52 | - animated: Animate the transition or not. 53 | - completion: Completion handler. 54 | */ 55 | func navigate( 56 | to route: Route, with transition: TransitionType, 57 | animated: Bool, completion: (() -> Void)? 58 | ) 59 | 60 | /** 61 | Navigate from your current screen to a new entire navigator. 62 | Can only push a router as a modal. 63 | Afterwards, other controllers can be pushed inside the presented Navigator. 64 | - Parameters: 65 | - Navigator: The destination navigator that you want to navigate to 66 | - animated: Animate the transition or not. 67 | - completion: Completion handler. 68 | */ 69 | func navigate(to router: Navigator, animated: Bool, completion: (() -> Void)?) 70 | 71 | /** 72 | Handles backwards navigation through the stack. 73 | */ 74 | func pop(animated: Bool) 75 | 76 | /** 77 | Handles backwards navigation through the stack. 78 | - Parameters: 79 | - route: The index of the route of your navigation action. 80 | - animated: Animate the transition or not. 81 | */ 82 | func popTo(index: Int, animated: Bool) 83 | 84 | /** 85 | Handles backwards navigation through the stack. 86 | - Parameters: 87 | - route: The destination route of your navigation action. 88 | - animated: Animate the transition or not. 89 | */ 90 | func popTo(route: Route, animated: Bool) 91 | 92 | /** 93 | Dismiss your current ViewController. 94 | - Parameters: 95 | - animated: Animate the transition or not. 96 | - completion: Completion handler. 97 | */ 98 | func dismiss(animated: Bool, completion: (() -> Void)?) 99 | } 100 | 101 | public extension Navigator { 102 | func navigate( 103 | to route: Route, with transition: TransitionType, 104 | animated: Bool = true, completion: (() -> Void)? = nil 105 | ) { 106 | let viewController = route.screen 107 | switch transition { 108 | case .modal: 109 | route.transitionConfigurator?(currentViewController, viewController) 110 | currentViewController?.present( 111 | viewController, animated: animated, completion: completion 112 | ) 113 | case .push: 114 | route.transitionConfigurator?(currentViewController, viewController) 115 | rootViewController?.pushViewController(viewController, animated: animated) 116 | case .reset: 117 | route.transitionConfigurator?(nil, viewController) 118 | rootViewController?.setViewControllers([viewController], animated: animated) 119 | case .changeRoot(let transitionType, let transitionSubtype): 120 | let navigationController = viewController.embedInNavigationController() 121 | animateRootReplacementTransition( 122 | to: navigationController, 123 | withTransitionType: transitionType, 124 | andTransitionSubtype: transitionSubtype 125 | ) 126 | rootViewController = navigationController 127 | } 128 | } 129 | 130 | func navigate(to router: Navigator, animated: Bool, completion: (() -> Void)?) { 131 | guard let viewController = router.rootViewController else { 132 | assert(false, "Navigator does not have a root view controller") 133 | return 134 | } 135 | 136 | currentViewController?.present( 137 | viewController, animated: animated, completion: completion 138 | ) 139 | } 140 | 141 | func pop(animated: Bool = true) { 142 | rootViewController?.popViewController(animated: animated) 143 | } 144 | 145 | func popToRoot(animated: Bool = true) { 146 | rootViewController?.popToRootViewController(animated: animated) 147 | } 148 | 149 | func popTo(index: Int, animated: Bool = true) { 150 | guard 151 | let viewControllers = rootViewController?.viewControllers, 152 | viewControllers.count > index 153 | else { return } 154 | let viewController = viewControllers[index] 155 | rootViewController?.popToViewController(viewController, animated: animated) 156 | } 157 | 158 | func popTo(route: Route, animated: Bool = true) { 159 | guard 160 | let viewControllers = rootViewController?.viewControllers, 161 | let viewController = viewControllers.first(where: { 162 | type(of: $0) == type(of: route.screen) 163 | }) 164 | else { return } 165 | rootViewController?.popToViewController(viewController, animated: true) 166 | } 167 | 168 | func dismiss(animated: Bool = true, completion: (() -> Void)? = nil) { 169 | currentViewController?.dismiss(animated: animated, completion: completion) 170 | } 171 | 172 | private func animateRootReplacementTransition( 173 | to viewController: UIViewController, 174 | withTransitionType type: CATransitionType, 175 | andTransitionSubtype subtype: CATransitionSubtype 176 | ) { 177 | let window = UIApplication.shared.windows.first(where: { $0.isKeyWindow }) 178 | let transition = CATransition() 179 | transition.duration = 0.3 180 | transition.timingFunction = CAMediaTimingFunction( 181 | name: CAMediaTimingFunctionName.easeOut 182 | ) 183 | transition.type = type 184 | transition.subtype = subtype 185 | window?.layer.add(transition, forKey: kCATransition) 186 | 187 | window?.rootViewController = viewController 188 | } 189 | } 190 | 191 | /** 192 | Protocol used to define a Route. The route contains 193 | all the information necessary to instantiate it's screen. 194 | For example, you could have a LoginRoute, that knows how 195 | to instantiate it's viewModel, and also forward any 196 | information that it's passed to the Route. 197 | */ 198 | public protocol Route { 199 | typealias TransitionConfigurator = ( 200 | _ sourceVc: UIViewController?, _ destinationVc: UIViewController 201 | ) -> Void 202 | 203 | /// The screen that should be returned for that Route. 204 | var screen: UIViewController { get } 205 | 206 | /** 207 | Configuration callback executed just before pushing/presenting modally. 208 | Use this to set up any custom transition delegate, presentationStyle, etc. 209 | - Parameters: 210 | - sourceVc: The currently visible viewController, if any. 211 | - destinationVc: The (fully intialized) viewController to present. 212 | */ 213 | var transitionConfigurator: TransitionConfigurator? { get } 214 | } 215 | 216 | public extension Route { 217 | var transitionConfigurator: TransitionConfigurator? { 218 | nil 219 | } 220 | } 221 | 222 | /// Available Transition types for navigation actions. 223 | public enum TransitionType { 224 | 225 | /// Presents the screen modally on top of the current ViewController 226 | case modal 227 | 228 | /// Pushes the next screen to the rootViewController navigation Stack. 229 | case push 230 | 231 | /// Resets the rootViewController navitationStack and set's the Route's screen 232 | /// as the initial view controller of the stack. 233 | case reset 234 | 235 | /// Replaces the key window's Root view controller with the Route's screen. 236 | case changeRoot( 237 | transitionType: CATransitionType, 238 | transitionSubtype: CATransitionSubtype 239 | ) 240 | 241 | /// Allows to use the changeRoot transition type with default parameters 242 | static let changeRoot: TransitionType = .changeRoot( 243 | transitionType: .push, 244 | transitionSubtype: .fromTop 245 | ) 246 | } 247 | 248 | public extension UIViewController { 249 | func embedInNavigationController() -> UINavigationController { 250 | if let navigation = self as? UINavigationController { 251 | return navigation 252 | } 253 | let navigationController = UINavigationController(rootViewController: self) 254 | navigationController.isNavigationBarHidden = true 255 | return navigationController 256 | } 257 | } 258 | -------------------------------------------------------------------------------- /ios-base/Networking/Services/APIClient.swift: -------------------------------------------------------------------------------- 1 | // 2 | // APIClient.swift 3 | // ios-base 4 | // 5 | // Created by Germán Stábile on 6/8/20. 6 | // Copyright © 2020 Rootstrap Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Alamofire 11 | 12 | public enum SwiftBaseErrorCode: Int { 13 | case inputStreamReadFailed = -6000 14 | case outputStreamWriteFailed = -6001 15 | case contentTypeValidationFailed = -6002 16 | case statusCodeValidationFailed = -6003 17 | case dataSerializationFailed = -6004 18 | case stringSerializationFailed = -6005 19 | case jsonSerializationFailed = -6006 20 | case propertyListSerializationFailed = -6007 21 | } 22 | 23 | public typealias SuccessCallback = ( 24 | _ responseObject: [String: Any], 25 | _ responseHeaders: [AnyHashable: Any] 26 | ) -> Void 27 | 28 | public typealias FailureCallback = (_ error: Error) -> Void 29 | 30 | class APIClient { 31 | 32 | enum HTTPHeader: String { 33 | case uid = "uid" 34 | case client = "client" 35 | case token = "access-token" 36 | case expiry = "expiry" 37 | case accept = "Accept" 38 | case contentType = "Content-Type" 39 | } 40 | 41 | private static let emptyDataStatusCodes: Set = [204, 205] 42 | 43 | // Mandatory headers for Rails 5 API 44 | static let baseHeaders: [String: String] = [ 45 | HTTPHeader.accept.rawValue: "application/json", 46 | HTTPHeader.contentType.rawValue: "application/json" 47 | ] 48 | 49 | fileprivate class func getHeaders() -> [String: String] { 50 | if let session = SessionManager.currentSession { 51 | return baseHeaders + [ 52 | HTTPHeader.uid.rawValue: session.uid ?? "", 53 | HTTPHeader.client.rawValue: session.client ?? "", 54 | HTTPHeader.token.rawValue: session.accessToken ?? "" 55 | ] 56 | } 57 | return baseHeaders 58 | } 59 | 60 | class func getBaseUrl() -> String { 61 | Bundle.main.object(forInfoDictionaryKey: "Base URL") as? String ?? "" 62 | } 63 | 64 | // Recursively build multipart params to send along with media in upload requests. 65 | // If params includes the desired root key, 66 | // call this method with an empty String for rootKey param. 67 | class func multipartFormData( 68 | _ multipartForm: MultipartFormData, 69 | params: Any, 70 | rootKey: String 71 | ) { 72 | switch params.self { 73 | case let array as [Any]: 74 | for val in array { 75 | let forwardRootKey = rootKey.isEmpty ? "array[]" : rootKey + "[]" 76 | multipartFormData(multipartForm, params: val, rootKey: forwardRootKey) 77 | } 78 | case let dict as [String: Any]: 79 | for (key, value) in dict { 80 | let forwardRootKey = rootKey.isEmpty ? key : rootKey + "[\(key)]" 81 | multipartFormData(multipartForm, params: value, rootKey: forwardRootKey) 82 | } 83 | default: 84 | if let uploadData = "\(params)".data( 85 | using: String.Encoding.utf8, 86 | allowLossyConversion: false 87 | ) { 88 | let forwardRootKey = rootKey.isEmpty ? 89 | "\(type(of: params))".lowercased() : rootKey 90 | multipartForm.append(uploadData, withName: forwardRootKey) 91 | } 92 | } 93 | } 94 | 95 | // Multipart-form base request. Used to upload media along with desired params. 96 | // Note: Multipart request does not support Content-Type = application/json. 97 | // If your API requires this header 98 | // do not use this method or change backend to skip this validation. 99 | class func multipartRequest( 100 | method: HTTPMethod = .post, 101 | url: String, 102 | headers: [String: String] = APIClient.getHeaders(), 103 | params: [String: Any]?, 104 | paramsRootKey: String, 105 | media: [MultipartMedia], 106 | success: @escaping SuccessCallback, 107 | failure: @escaping FailureCallback 108 | ) { 109 | 110 | let requestConvertible = BaseURLRequestConvertible( 111 | path: url, 112 | method: method, 113 | headers: headers 114 | ) 115 | 116 | AF.upload( 117 | multipartFormData: { (multipartForm) -> Void in 118 | if let parameters = params { 119 | multipartFormData(multipartForm, params: parameters, rootKey: paramsRootKey) 120 | } 121 | for elem in media { 122 | elem.embed(inForm: multipartForm) 123 | } 124 | }, 125 | with: requestConvertible 126 | ) 127 | .responseJSON(completionHandler: { result in 128 | validateResult(result: result, success: success, failure: failure) 129 | }) 130 | } 131 | 132 | class func defaultEncoding(forMethod method: HTTPMethod) -> ParameterEncoding { 133 | switch method { 134 | case .post, .put, .patch: 135 | return JSONEncoding.default 136 | default: 137 | return URLEncoding.default 138 | } 139 | } 140 | 141 | class func request( 142 | _ method: HTTPMethod, 143 | url: String, 144 | params: [String: Any]? = nil, 145 | paramsEncoding: ParameterEncoding? = nil, 146 | success: @escaping SuccessCallback, 147 | failure: @escaping FailureCallback 148 | ) { 149 | let encoding = paramsEncoding ?? defaultEncoding(forMethod: method) 150 | let headers = APIClient.getHeaders() 151 | let requestConvertible = BaseURLRequestConvertible( 152 | path: url, 153 | method: method, 154 | encoding: encoding, 155 | params: params, 156 | headers: headers 157 | ) 158 | 159 | let request = AF.request(requestConvertible) 160 | 161 | request.responseJSON( 162 | completionHandler: { result in 163 | validateResult(result: result, success: success, failure: failure) 164 | } 165 | ) 166 | } 167 | 168 | // Handle rails-API-base errors if any 169 | class func handleCustomError(_ code: Int?, dictionary: [String: Any]) -> NSError? { 170 | if 171 | let messageDict = dictionary["errors"] as? [String: [String]], 172 | let firstKey = messageDict.keys.first 173 | { 174 | let errorsList = messageDict[firstKey] 175 | return NSError( 176 | domain: "\(firstKey) \(errorsList?.first ?? "")", 177 | code: code ?? 500, 178 | userInfo: nil 179 | ) 180 | } else if let error = dictionary["error"] as? String { 181 | return NSError(domain: error, code: code ?? 500, userInfo: nil) 182 | } else if 183 | let errors = dictionary["errors"] as? [String: Any], 184 | let firstKey = errors.keys.first 185 | { 186 | let errorDesc = errors[firstKey] ?? "" 187 | return NSError( 188 | domain: "\(firstKey) " + "\(errorDesc)", 189 | code: code ?? 500, 190 | userInfo: nil 191 | ) 192 | } else if dictionary["errors"] != nil || dictionary["error"] != nil { 193 | return NSError( 194 | domain: "Something went wrong. Try again later.", 195 | code: code ?? 500, 196 | userInfo: nil 197 | ) 198 | } 199 | return nil 200 | } 201 | 202 | fileprivate class func validateResult( 203 | result: AFDataResponse, 204 | success: @escaping SuccessCallback, 205 | failure: @escaping FailureCallback 206 | ) { 207 | let defaultError = App.error( 208 | domain: .parsing, 209 | localizedDescription: "Error parsing response".localized 210 | ) 211 | 212 | guard let response = result.response else { 213 | failure(defaultError) 214 | return 215 | } 216 | 217 | guard !validateEmptyResponse( 218 | response: response, 219 | data: result.data, 220 | success: success, 221 | failure: failure 222 | ) else { return } 223 | 224 | guard let data = result.data else { 225 | failure(defaultError) 226 | return 227 | } 228 | 229 | validateSerializationErrors( 230 | response: response, 231 | error: result.error, 232 | data: data, 233 | success: success, 234 | failure: failure 235 | ) 236 | } 237 | 238 | fileprivate class func validateEmptyResponse( 239 | response: HTTPURLResponse, 240 | data: Data?, 241 | success: @escaping SuccessCallback, 242 | failure: @escaping FailureCallback 243 | ) -> Bool { 244 | let defaultError = App.error( 245 | domain: .generic, 246 | localizedDescription: "Unexpected empty response".localized 247 | ) 248 | 249 | guard let data = data, !data.isEmpty else { 250 | let emptyResponseAllowed = emptyDataStatusCodes.contains( 251 | response.statusCode 252 | ) 253 | emptyResponseAllowed ? 254 | success([:], response.allHeaderFields) : failure(defaultError) 255 | return true 256 | } 257 | 258 | return false 259 | } 260 | 261 | fileprivate class func validateSerializationErrors( 262 | response: HTTPURLResponse, 263 | error: Error?, 264 | data: Data, 265 | success: @escaping SuccessCallback, 266 | failure: @escaping FailureCallback 267 | ) { 268 | var dictionary: [String: Any]? 269 | var serializationError: NSError? 270 | do { 271 | dictionary = try JSONSerialization.jsonObject( 272 | with: data, 273 | options: .allowFragments 274 | ) as? [String: Any] 275 | } catch let exceptionError as NSError { 276 | serializationError = exceptionError 277 | } 278 | // Check for errors in validate() or API 279 | if let errorOcurred = APIClient.handleCustomError( 280 | response.statusCode, 281 | dictionary: dictionary ?? [:] 282 | ) ?? error as NSError? { 283 | failure(errorOcurred) 284 | return 285 | } 286 | // Check for JSON serialization errors if any data received 287 | if let serializationError = serializationError { 288 | if (serializationError as NSError).code == 401 { 289 | AppDelegate.shared.unexpectedLogout() 290 | } 291 | failure(serializationError) 292 | } else { 293 | success(dictionary ?? [:], response.allHeaderFields) 294 | } 295 | } 296 | } 297 | --------------------------------------------------------------------------------