├── 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 | [](https://codeclimate.com/github/rootstrap/ios-base/maintainability)
2 | [](https://codeclimate.com/github/rootstrap/ios-base/test_coverage)
3 | [](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 |
--------------------------------------------------------------------------------