├── .gitignore
├── .idea
└── .idea.cookBookk.dir
│ └── .idea
│ └── workspace.xml
├── 1-git-flow.md
├── 10-debugging.md
├── 2-code-signing.md
├── 3-ci.md
├── 4-1-redux.md
├── 4-architecture.md
├── 5-tests.md
├── 6-estimates.md
├── 7-1-ui-in-code.md
├── 7-2-swiftui.md
├── 7-code-style.md
├── 8-ui-tests.md
├── 9-security.md
├── README.md
├── examples
└── ReTweet
│ ├── .gitignore
│ ├── Cartfile
│ ├── Cartfile.resolved
│ ├── ReTweet.xcodeproj
│ └── project.pbxproj
│ ├── ReTweet
│ ├── AppDelegate.swift
│ ├── Assets.xcassets
│ │ ├── AppIcon.appiconset
│ │ │ └── Contents.json
│ │ ├── Contents.json
│ │ └── placeholder.imageset
│ │ │ ├── Contents.json
│ │ │ ├── group2.png
│ │ │ ├── group2@2x.png
│ │ │ └── group2@3x.png
│ ├── Base.lproj
│ │ └── LaunchScreen.storyboard
│ ├── Features
│ │ ├── ComposeTweet
│ │ │ ├── ComposeTweetContentView.swift
│ │ │ ├── ComposeTweetContentView.xib
│ │ │ └── ComposeTweetViewController.swift
│ │ └── Timeline
│ │ │ ├── TimelineNamespace.swift
│ │ │ ├── View
│ │ │ ├── Cells
│ │ │ │ ├── PendingTweet
│ │ │ │ │ ├── TimelinePendingTweetCell.swift
│ │ │ │ │ └── TimelinePendingTweetCell.xib
│ │ │ │ └── Tweet
│ │ │ │ │ ├── TimelineTweetCell.swift
│ │ │ │ │ └── TimelineTweetCell.xib
│ │ │ ├── TimelineContentView.swift
│ │ │ └── TimelineViewController.swift
│ │ │ └── ViewModel
│ │ │ ├── Middleware
│ │ │ └── TimelineResendMiddleware.swift
│ │ │ ├── TimelineActionCreator.swift
│ │ │ ├── TimelineProps.swift
│ │ │ ├── TimelineStore.swift
│ │ │ └── TimelineViewModel.swift
│ ├── Info.plist
│ ├── Library
│ │ ├── CustomJSONDecoder.swift
│ │ ├── CustomJSONEncoder.swift
│ │ ├── ErrorPresenter.swift
│ │ ├── Formatter.swift
│ │ ├── NibInitializable.swift
│ │ ├── ReduxStore.swift
│ │ ├── Reusable.swift
│ │ └── RxExtensions.swift
│ ├── Models
│ │ ├── ComposedTweet.swift
│ │ ├── Tweet.swift
│ │ └── User.swift
│ └── Services
│ │ ├── NetworkModels
│ │ ├── NetworkOutgoingTweet.swift
│ │ └── NetworkTweet.swift
│ │ └── TwitterService.swift
│ ├── Readme.md
│ └── server
│ ├── app.js
│ ├── bin
│ └── www
│ ├── package-lock.json
│ ├── package.json
│ ├── public
│ └── stylesheets
│ │ └── style.css
│ ├── routes
│ ├── index.js
│ └── posts.js
│ └── views
│ ├── error.jade
│ ├── index.jade
│ └── layout.jade
└── resources
├── Fastfile-Advanced
├── Fastfile-Basic
├── Fastfile-GithubActions
├── ReduxStore.swift
├── TrueMVC.png
├── ci
├── APP_STORE_CONNECT_API_KEY_ISSUER_ID.png
├── APP_STORE_CONNECT_API_KEY_KEY_ID_example.png
├── auth key in Apple Development Portal.png
├── ci_done_jobs.png
├── ci_workflow.png
├── deployDevAction.png
├── differentTargetsJobs.png
├── fastlane-init-option.png
├── match-development-final.png
├── match-init-options.png
├── xcode-settings-example.png
└── xcode-settings-example2.png
├── circle-config.yml
├── cocoa_mvc.png
├── debugging
├── allocations1.png
├── allocations2.png
├── breakpoints.md
├── memGraph1.png
├── memGraph2.png
├── memGraph3.png
├── search1.png
└── timeProfiler1.png
├── estimates_screenshots
├── estimates_example.png
└── login.png
├── gitHubAction-Advanced.yml
├── gitHubAction-Basic.yml
├── illustrations
├── 7.2.empty_lines.png
├── 7.2.line_character_limit.png
├── 7.2.tabs_preferences.png
└── 7.2.text_settings.png
├── redux_vm.png
├── scripts
├── pre-commit
└── run-swiftlint.sh
├── security
└── app_switcher.png
├── ui_in_code
├── componentView.codesnippet
└── exampleView.png
└── ui_tests
└── ui_tests_1.gif
/.gitignore:
--------------------------------------------------------------------------------
1 | *.DS_Store
2 | .obsidian/
--------------------------------------------------------------------------------
/.idea/.idea.cookBookk.dir/.idea/workspace.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | {
24 | "associatedIndex": 7
25 | }
26 |
27 |
28 |
29 |
30 |
31 | {
32 | "keyToString": {
33 | "RunOnceActivity.OpenProjectViewOnStart": "true",
34 | "RunOnceActivity.ShowReadmeOnStart": "true",
35 | "git-widget-placeholder": "oleksiy/ci",
36 | "node.js.detected.package.eslint": "true",
37 | "node.js.detected.package.tslint": "true",
38 | "node.js.selected.package.eslint": "(autodetect)",
39 | "node.js.selected.package.tslint": "(autodetect)",
40 | "nodejs_package_manager_path": "npm",
41 | "vue.rearranger.settings.migration": "true"
42 | },
43 | "keyToStringList": {
44 | "rider.external.source.directories": [
45 | "/Users/oleksiy/Library/Application Support/JetBrains/Rider2023.3/resharper-host/DecompilerCache",
46 | "/Users/oleksiy/Library/Application Support/JetBrains/Rider2023.3/resharper-host/SourcesCache",
47 | "/Users/oleksiy/.local/share/Symbols/src"
48 | ]
49 | }
50 | }
51 |
52 |
53 |
54 |
55 | 1722590782458
56 |
57 |
58 | 1722590782458
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
--------------------------------------------------------------------------------
/1-git-flow.md:
--------------------------------------------------------------------------------
1 | # git flow
2 |
3 | We use [GitHub](https://github.com) and [BitBucket](https://bitbucket.org/product) for our projects.
4 |
5 | ## Commits
6 |
7 | - Keep your commits atomic, focused on small set of related changes (e.g. fix, irreducible feature or improvement).
8 | - When writing a concise commit message is difficult, it may indicate too many unrelated changes;
9 | - Write grammatically correct (i.e. start with a capital) commit messages in [imperative, present tense](https://stackoverflow.com/questions/3580013/should-i-use-past-or-present-tense-in-git-commit-messages).
10 |
11 | ## Branches
12 |
13 | ### Types
14 |
15 | In our workflow we have several branches:
16 |
17 | - `master` that always contains latest production code from AppStore;
18 | - `develop` that is used for staging purposes and should be always buildable;
19 | - `feature/*` branches. For every fix or feature you should create a separate branch. When you are done, create a Pull Request to the `develop`. There are some naming variations that you are free to use, like `fix/*`, `refactor/*`.
20 |
21 | ### Merging
22 |
23 | We use Pull Requests to merge any change to the `develop` or `master` branch. Sometimes it's ok to push a hotfix directly into the `develop` when the sky is falling, otherwise, please, use Pull Requests. This rule enforces [collective code ownership](https://martinfowler.com/bliki/CodeOwnership.html).
24 |
25 | Don't forget to wait till [CI](3-ci.md) is green before merging the PR.
26 |
27 | ## Code Review
28 |
29 | ### As a submitter:
30 |
31 | #### 1. Keep it short
32 | 
33 | Ideally, your Pull Request shouldn't exceed 400 lines of code. Beyond 200 lines and the effectiveness of a review drops significantly. By the time you’re at more than 400 they become almost pointless.
34 |
35 |
36 | To get number of lines without storyboards and nibs
37 |
38 | Use following shell alias:
39 |
40 | ```
41 | alias prsize="git diff --shortstat develop ':!*.xib' ':!*.storyboard' ':!*.pbxproj' ':!*.json' ':!*.plist'"
42 | ```
43 |
44 |
45 | #### 2. Provide context
46 | Ensure that your commit messages explain what you are trying to achieve and why. Link to any related tickets, or the specification. It’ll help the reviewer and you’ll get fewer issues coming back.
47 |
48 | #### 3. Review yourself
49 | Take a short break and review your Pull Request. Your changes look suprizingly different when presented as a git diff.
50 |
51 | ### As a reviewer:
52 |
53 | #### 1. Adopt a positive attitude
54 | Assume best intentions, and address the code rather than the person writing the code. **Criticism should never be personal.**
55 |
56 | #### 2. Take your time
57 | Do code reviews often and for short sessions. The effectiveness of your reviews decreases after around an hour. So putting off reviews and doing them in one almighty session doesn’t help anybody.
58 |
59 | #### 3. Give compliments
60 | During your code review you have noticed interesting approach or brilliant solution? Say it! Getting props from your colleagues is one of the best feelings ever.
61 |
62 | ### My pull request depends on another pending pull request. What do I do?
63 |
64 | If your next pull request is really that intimately related to your last, consider to continue working on it instead of opening another.
65 |
66 | If it really makes sense to open another, it's okay to base your next pull request on your unmerged branch.
67 |
68 | Follow this workflow
69 |
70 | 1. Create `feature/B` branch from `feature/A`.
71 | 2. Work on `feature/B`.
72 | 3. If more commits are added to `feature/A` after branching, rebase `feature/B` onto `feature/A`.
73 | 4. Finish work on `feature/B` and wait till `feature/A` is merged into the `develop`.
74 | 5. After `feature/A` is merged into `develop`, rebase `feature/B` onto `develop`.
75 | 6. Merge `feature/B` into `develop`.
76 |
77 |
78 |
79 | ---
80 |
81 | P.S. There are times when pull requests are quite large, or it is referencing existing code not found in the PR, or you for some other reason do not have a good enough context to review anything else than code style and typos. In these cases, we encourage you to go through the pull request together with the submitter, either physically or virtually
82 |
83 | Be warned, though: By doing this, you will both need to explain your thoughts and discuss other alternatives. Of course, this means you are putting yourself at risk of sharing your knowledge and/or learning some new stuff, so please be careful not to end up being even more awesome than you are.
84 |
85 |
86 |
--------------------------------------------------------------------------------
/2-code-signing.md:
--------------------------------------------------------------------------------
1 | # Code Signing with `match`
2 |
3 | When we want to distribute the application build, usually we have two options which development account to use:
4 |
5 | - use client development account if he has one;
6 | - use our own company-wide development account otherwise.
7 |
8 | 
9 |
10 | In both cases we have a bunch of problems:
11 |
12 | - we have separate code signing identities for every member. This results in dozens of profiles including a lot of duplicates;
13 | - the maximum amount of distribution certificates are limited by 3;
14 | - we need to manually renew and download latest provisioning profile every time we add a new device or certificate expires;
15 | - setting up a CI machine requires spending a lot of time.
16 |
17 |
18 | ### *match* to the resque!
19 |
20 | [*match*](https://docs.fastlane.tools/actions/match/) is an awesome tool from fastlane suite that makes our life 10x easier. *match* can generate, store and install actual valid development certificates with one command. To start using it you'll need an access to the development account and access to our certificates repository.
21 |
22 | *Please ping me (@arthur) in Slack if you don't have access to any of those.*
23 |
24 | ### Basics
25 |
26 | Install *match* with
27 |
28 | ```
29 | [sudo] gem install fastlane -NV
30 | ```
31 |
32 | To integrate *match* into a new project
33 |
34 | ```
35 | fastlane match init
36 | ```
37 |
38 | To generate or download valid certificates and provisioning profiles
39 |
40 | ```
41 | fastlane match development
42 | fastlane match adhoc
43 | fastlane match appstore
44 | ```
45 |
46 | If you don't want *match* to generate any new certificates or revoke previous ones run those command with flag `readonly`.
47 |
48 | After that, you can select match-generated provisioning profiles for signing in Xcode. Usually, they have prefix `match`.
49 |
50 | ### Things to consider:
51 |
52 | - If the app contains several targets, you can generate certificates for all of them at once after setting all of the `app_identifier`s in the `Matchfile`
53 | - Don’t set the provisioning profile in your Xcode project to Automatic, as it doesn’t always select the correct profile.
54 |
55 | ### Matchfile example
56 |
57 | ```
58 | git_url "url_of_our_cerfificates_repo"
59 | app_identifier ["com.uptech.App", "com.uptech.AppDevelopment", "com.uptech.AppStaging"]
60 | username "name_of_company_used_account@uptech.team"
61 | ```
--------------------------------------------------------------------------------
/3-ci.md:
--------------------------------------------------------------------------------
1 | # CI/CD
2 |
3 | Continous Integration is an important part of our development process. It allows us to move fast and be confident in our code. We use [fastlane](https://fastlane.tools) and [CircleCI](https://circleci.com) for the continuous integration.
4 |
5 | # App Configurations
6 |
7 | We maintain 4 different app configurations:
8 |
9 | 1. **Development** - used for the local development only.
10 | 2. **QA** - used for internal QA testing.
11 | 3. **Staging** - public Beta testing.
12 | 4. **Production** - App Store version.
13 |
14 | # Workflow
15 |
16 | CI/CD Workflow consist of several pipelines:
17 | 1. **Test Pipeline** - each commit in `feature/` branch triggers a Test Pipeline. Test pipeline verifies that app can be built and tests successeds.
18 | 2. **Automatic Deploy Pipeline** - each commit in `develop` branch triggers an Automatic Deploy Pipeline. This pipeline builds, tests the app, deploys a **QA** build and bumps build number of the app.
19 | 3. **Manual Deploy Pipeline** - is triggered manually via API call to our CI provider. CI takes the latest code on `develop` or `master` and deploys **Staging** or **Production** build accordingly.
20 |
21 |
22 | 
23 |
24 | # How to setup CI/CD for your project?
25 |
26 | ## 1. Setup fastlane
27 |
28 | fastlane is a shortcut for day-to-day developers tasks. It saves our time by automating the boring things like building and deploying the app or adding a device to the developer profile.
29 |
30 | fastlane scripts are written in Ruby. The main file is called **Fastfile**. Usually Fastfile consists of several **lanes**. Lane is just a sequence of **actions**, such as `build_ios_app`, `hockey` or `slack`. For more information refer to the [docs](https://docs.fastlane.tools).
31 |
32 | #### Basic Setup
33 |
34 | The [basic Fastfile](resources/Fastfile-Basic) contains the minimalistic set of lanes, needed to build and deploy a project on a CircleCI. Lane `test` just builds the project and runs the tests suite. Usually, this lane is run on every push to any branch. Lane `deploy` is a bit more complicated as it has more steps:
35 |
36 | 1. Retrieve certificates using `match`.
37 | 2. Build the app.
38 | 3. Deploy an app to the HockeyApp
39 | 4. Send a message to the slack channel.
40 |
41 | `deploy` lane is run on every merge to the `develop` branch.
42 |
43 | #### Advanced Setup
44 |
45 | Usually, basic setup is not enough, as we have several application configurations and we deserve to have nice things. In the [advanced Fastfile example](resources/Fastfile-Advanced) you can check typical fastlane configuration used in our projects. There are lanes for running tests, deploying Staging and Production builds, helper lanes for syncing provisioning profiles and adding a new iOS device to the developer profile.
46 |
47 | Whoa, a lot of Ruby code! Don't be afraid, it's pretty easy to follow. In addition, setting up the fastlane on the project makes you feel like a cool dev-ops 😎
48 |
49 | Code signing part is described in the [code signing chapter](2-code-signing.md). If you use match there will be no problems, just grant your CI machine SSH access to the certificates repo.
50 |
51 | ## 2. Configure the Github Actions
52 | ### Overview
53 | GitHub Actions allows us to automate, customize, and execute software development workflows directly in our GitHub repositories. It supports CI/CD, helping in building, testing, and deploying code.
54 | ### Prerequisites
55 | Before you start, ensure you have:
56 |
57 | Link to ready repository with project: [Project](https://github.com/uptechteam/githubActionsIOS)
58 | * A GitHub repository for your iOS project, and repository for your ceritificates, (ios-certificates in Uptech).
59 | * Fastlane set up in your project. You can follow the iOS getting started guide to initialize [Fastlane](http://docs.fastlane.tools/getting-started/ios/setup/).
60 |
61 | Prerequisites:
62 |
63 | First of all you need to set up fastlane, you can do that with that link:
64 | You’ll end up with a fastlane directory, a Fastfile, and an Appfile.
65 |
66 | We will use the following Fastlane tools for automated deployment:
67 |
68 | * ```app_store_connect_api_key```: Authenticates with the App Store Connect API using a private key instead of a username and password, enhancing security and streamlining the deployment process.
69 | * ```match```: Manages code signing certificates and provisioning profiles across machines and team members, ensuring consistency in development and production environments.
70 | * ```slack```: Integrates Fastlane with Slack for notifications and updates directly in your Slack channels, facilitating team collaboration and keeping everyone informed about the deployment process.
71 |
72 | ### Step 1: Initialize the Fastlane
73 |
74 | 1. To initialize Fastlane for your project, first, open up your Xcode workspace and change the Bundle Identifier to match your project’s unique identifier.
75 |
76 |
77 | 2. Navigate to the “ios” directory in your project and run the command
78 | ```bundle exec fastlane init``` or ```fastlane init```
79 |
80 | 3. It will ask you about option, choose number 4, then enter:
81 |
82 | 
83 |
84 | 3. Enter your Apple ID developer credentials when prompted, could ask for it a little bit later, and choose whether you want Fastlane to create the App ID and App on App Store Connect for you.
85 | If you choose to have Fastlane create these items, it will generate them for you automatically.
86 |
87 |
88 | 4. Finally, Fastlane will generate two files for you — an Appfile and a Fastfile — which you can modify to suit your specific deployment needs, also GemFile with all gems needed.
89 |
90 |
91 | 5. Try to run ```fastlane custom_lane ``` to check if everything is working without errors.
92 |
93 |
94 | ### Step 2: Initialize the match
95 | Match guide: [Match](https://docs.fastlane.tools/actions/match/)
96 |
97 | 1. If your GitHub repo is already created, run the following command in your terminal:
98 | ```fastlane match init ```
99 |
100 | 
101 |
102 | 2. Select git option.
103 |
104 |
105 | 3. Paste ssh git repository url you just created when prompted.
106 |
107 |
108 | 4. Remember all entered info, passphrase is very important, if you are using uptech repo for certificates use passphrase which is in OnePass.
109 |
110 |
111 | 5. Create new bundle and app in apple connect and developer account.
112 |
113 |
114 | Update fastlane/Matchfile to the following, changing the placeholders with the appropriate information:
115 |
116 | ```
117 | git_url "git@github.com:awesomeRepository/ios-certificates.git" # Certificates repository url
118 | git_branch "ci-githubActions-ios" # Your branch with the cerificates
119 | app_identifier(["com.uptech.ci-fastlane-githubActions"]) # The bundle identifier(s) of your app
120 | team_id "XXXXXXXXXX" # The bundle identifier(s) of your app
121 |
122 | # Note: If you don't have permission to edit the Fastfile, change file permissions using:
123 | * chmod -R u+w "/pathToYourFastlaneFolder"
124 | * sudo chown -R $(whoami) "/pathToYourFastlaneFolder"
125 | ```
126 |
127 | If you do multiple build for each environment, you need to provide all your app identifiers in the app_identifier, this case, I only have one.
128 | ```Example: app_identifier(["",""])```
129 |
130 | execute the command ``` fastlane match development ``` to generate the required certificates and files for local iOS device development. During the process, you will be prompted to provide a passphrase for Match storage, which should be remembered as it will be needed to decrypt the generated files.
131 |
132 | 5. Fill the rest info.
133 |
134 | 6. To create the necessary certificates and files for Testflight deployment, run ```fastlane match appstore```. As this is the second time Match is being executed, it will automatically remember the previously provided passphrase for decryption.
135 |
136 | When all actions will be done, you will see this message
137 | 
138 |
139 | ### Step 3: Configure xcode
140 |
141 | * First, make sure in XCode that automatically manages signing is not checked.
142 | ```NOTE: If you have an error: Provisioning profile "match Development com.uptech.ci-fastlane-githubActions" doesn't include signing certificate "XXXXXXX" then: go to Apple developer Profiles, find your, tap edit, add your account, save, download and open.```
143 |
144 |
145 | * Set Debug to match Development and Release to match AppStore for Provisioning Profile.
146 |
147 |
148 | * Check Signing & Capabilities to ensure that everything is set up correctly.
149 |
150 |
151 | * Finally, run your app on an iPhone device or simulator to confirm that it’s working properly.
152 |
153 |
154 | 
155 | 
156 |
157 | ### Step 4: Generate auth key in Apple Development Portal
158 |
159 | Generate auth key in Apple Development Portal, go to https://appstoreconnect.apple.com/access/api
160 | You can only generate those keys if you are the owner of the account.
161 | The image below shows where you can get your key id and issuer id.
162 |
163 | 
164 |
165 | Once you generate the key, make sure to securely download and store the .p8 file as you will only have one chance to do so.
166 | Losing the file would require generating a new key.
167 |
168 | ### Step 5: Update AppFile
169 |
170 | ```
171 | Make sure your fastlane/Appfile should look like this.
172 | itc_team_id(ENV["ITUNES_TEAM_ID"]) # App Store Connect Team ID
173 | team_id(ENV["APPSTORE_TEAM_ID"]) # Developer Portal Team ID
174 | ```
175 | Link how to find ID: [Team ID](https://sarunw.com/posts/fastlane-find-team-id/)
176 |
177 | ### Step 5: Update FastlaneFile
178 | Here is filled fastlane file here: [Faslane github actions](resources/Fastfile-GithubActions)
179 |
180 |
181 | #### Add different plugins:
182 | Here is docs for adding plugins: https://docs.fastlane.tools/plugins/using-plugins/
183 |
184 | In our files, we use mint and slack which means you need to have plugin file with this plugins:
185 | ```
186 | # Autogenerated by fastlane
187 | #
188 | # Ensure this file is checked in to source control!
189 |
190 | gem 'fastlane-plugin-mint'
191 | gem 'fastlane-plugin-slack_bot'
192 |
193 | ```
194 |
195 | ### Step 6: Set upGithub pipelines
196 |
197 | 1. Take your ssh key, if you do not have then setUp one:
198 |
199 | To set up an SSH key for your match repository ( the repository you use to store certificates ), navigate to the “Deploy keys” section in the repository’s settings.
200 | Generate an SSH key pair by running the command ```ssh-keygen -t rsa -b 4096 -C ``` and save it wherever convenient.
201 | Leave the passphrase empty. If you already used that ssh somewhere you can also use ssh-keygen -t ed25519 -C "YOUR_EMAIL" to generate ssh key.
202 |
203 |
204 | 2. Next, add the newly generated public key by running cat path/to/the/key.pub and copying the contents of the file. Paste this value into the appropriate field in the setting -> “Deploy keys” section and save the changes. The key can be named as desired.
205 |
206 |
207 | ### Step 7: Add github secrets
208 |
209 | Now it’s time set up github secrets for your main project repository. Open your main project repo and navigate to settings and in the left menu you will see secrets and variables -> actions.
210 | Add the following keys to your secrets.
211 | Here is link with additional info: [Match](https://docs.fastlane.tools/actions/match/)
212 |
213 | 1. Secret: APP_STORE_CONNECT_API_KEY_ISSUER_ID
214 |
215 | Value: Copy it from app connect:
216 | 
217 |
218 |
219 | 2. Secret: APP_STORE_CONNECT_API_KEY_KEY
220 |
221 | Value: Key you generated in Step 4, all data in file
222 |
223 |
224 | 3. Secret: APP_STORE_CONNECT_API_KEY_KEY_ID
225 |
226 | Value: Id of Key you generated in Step 4, example:
227 | 
228 | 4. Secret: FASTLANE_USER
229 |
230 | Value: Fastlane user you created before
231 |
232 |
233 | 5. Secret: MATCH_GIT_BASIC_AUTHORIZATION
234 |
235 | Value:
236 | [To generate your base64 key according to RFC 7617, run this:](http://docs.fastlane.tools/actions/match/)
237 |
238 | ``` echo -n your_github_username:your_personal_access_token | base64```
239 |
240 |
241 | 6. Secret: MATCH_PASSWORD
242 |
243 | Value: Enter your match password you entered before.
244 |
245 |
246 | 7. If you are running github action and this error occured: Unable to access certificates repo, try to do this add:
247 |
248 | KEY: SSH_PRIVATE_KEY
249 |
250 | Value: Content of your ssh private file
251 |
252 | Later add uses to your job: [Uses](https://github.com/marketplace/actions/webfactory-ssh-agent)
253 |
254 | ### Step 8: Setup github actions workflow
255 | Full documentation you can see here: [GitHubActions](https://docs.github.com/en/actions/quickstart)
256 |
257 |
258 | Create Workflow Directory
259 | Create a directory for your GitHub Actions workflows in your repository.
260 | Navigate to your GitHub repository, select the "Create new file" option, and enter the following path: .github/workflows/pullRequest.yml.
261 |
262 | Note: The filename pullRequest.yml is an example and can be customized to reflect the purpose of your workflow. For instance, you might name it ci.yml for continuous integration tasks, but another part should be exactly ``` .github/workflows ```.
263 |
264 | * Here is an example of basic job: [GitHubAction Basic file](resources/gitHubAction-Basic.yml), this job will run unit tests each time you make PR into develop branch.
265 | * You can do more actions if you want, here is file of advanced: [GitHubAction Basic file](resources/gitHubAction-Advanced.yml) it wont be triggered automatically, it is need to be triggered manuallyfrom github.
266 |
267 | #### Change apple account:
268 | Docs: [Nuke](https://docs.fastlane.tools/actions/match_nuke/)
269 |
270 | If you want to change your Apple account or delete certificates to create a new one, you need to do the following:
271 | ```
272 | fastlane match nuke development
273 | fastlane match nuke distribution
274 |
275 | NOTE: Be careful with NUKE, to not delete other certificates and profiles.
276 | ```
277 |
278 | #### Working with different targets:
279 | If your project has a different targets you need to create job for each target like here:
280 |
281 | 
282 |
283 | They are similiar, but calls lanes with different parameters, example:
284 |
285 | ```
286 | build_configuration:development
287 | build_configuration:production
288 | ```
289 |
290 | #### Final Steps
291 | * Commit and Push: Commit your workflow files and push them to your repository.
292 | * Trigger Workflows: Open a pull request to the develop branch to see the pull request workflow in action. Manually trigger the deployment workflow from the Actions tab on GitHub.
293 |
294 | 
295 | 
296 |
297 | ### 3. PROFIT 🚀
298 |
299 | - we've automated part of our workflow;
300 | - our code is always buildable and tests are green;
301 | - the latest executable build is always available to download.
302 |
--------------------------------------------------------------------------------
/4-architecture.md:
--------------------------------------------------------------------------------
1 | # Architectural Principles
2 |
3 | This chapter defines ground ideas and architectural principles we follow at Uptech.
4 |
5 | - [Background](#background)
6 | - [What is a Good Architecture?](#what-is-a-good-architecture)
7 | - [MVC](#mvc)
8 | - [What is MVC?](#what-is-mvc)
9 | - [Domain and Presentation](#domain-and-presentation)
10 | - [MVVM, MVP, VIPER, React/Redux and friends](#mvvm-mvp-viper-reactredux-and-friends)
11 | - [Conclusion](#conclusion)
12 | - [Cooking Recipes](#cooking-recipes)
13 | - [1. UIViewController is a part of the Presentation layer](#1-uiviewcontroller-is-a-part-of-the-presentation-layer)
14 | - [2. Rich Domain Model](#2-rich-domain-model)
15 | - [3. Don't fight the iOS SDK](#3-dont-fight-the-ios-sdk)
16 | - [4. Create other classes in the Presentation layer if you need it](#4-create-other-classes-in-the-presentation-layer-if-you-need-it)
17 | - [5. Aim for the simplest solution first](#5-aim-for-the-simplest-solution-first)
18 | - [6. Rx as a tool for asynchronous programming](#6-rx-as-a-tool-for-asynchronous-programming)
19 | - [Futures](#futures)
20 | - [Observer Pattern](#observer-pattern)
21 | - [7. Redux](#7-redux)
22 | - [Future Directions](#future-directions)
23 | - [Sources](#sources)
24 |
25 | # Background
26 |
27 | ## What is a Good Architecture?
28 |
29 | We can say that architecture is good when it follows a couple of traits:
30 |
31 | - each object has **a specific, clear role**. It's easy to understand, easy to change, and when you go and read the source code, you immediately see whether this is actually fulfilling that single role or whether logic you are about to write would breach it.
32 |
33 | - **data flow is simple**. You can easily debug a crash or an error, there is no need to jump across multiple different objects, mutating the same shared resource.
34 |
35 | - **changes are cheap**. The architecture **is flexible** because it's simple to understand and simple to change, but not because it has 200 abstraction layers and everything is abstracted to the point when nothing is comprehensible.
36 |
37 | ## MVC
38 |
39 | Famous "Cocoa MVC" diagram from the [official Apple documentation](https://developer.apple.com/library/archive/documentation/General/Conceptual/DevPedia-CocoaCore/MVC.html):
40 |
41 | 
42 |
43 | MVC is a standard Cocoa architectural pattern, which received a lot of criticism and was blamed for causing too much code in one place and too closely coupled code ("Massive View Controller").
44 |
45 | Very often MVC is interpreted as follows:
46 |
47 | - **Model** - data entities of our application (structs such as `User`, `Book` or `Balance`);
48 | - **View** - what user sees (subclasses of the `UIView`);
49 | - **Controller** - mediator between View and Model, which takes care of all the rest (networking, persistence, business logic, etc).
50 |
51 | But what if we misinterpreted the original idea of MVC? What if MVC is not just three classes "data, view and the rest"?
52 |
53 | ## What is MVC?
54 |
55 | MVC is a result of a very huge work. It was invented by Trygve Reenskaug as a result of his work on the Dynabook project at Xerox PARC in 1979. This project was going for about 10 years. Reenskaug summarized the main ideas and solutions in GUI application development that were accumulated during these 10 years in MVC.
56 | And it wasn’t like “Hey, we created a universal pattern in 10 years that you should use to solve any problem”. It is a fundamental mistake we made.
57 |
58 | **MVC is not a pattern, it's a philosophy.** It is not a scheme of app’s modules decomposition. MVC is one of the first attempts to formalize main ideas working with apps with GUI. These ideas are still relevant and [not only](https://dotnet.microsoft.com/apps/aspnet/mvc) [for the](https://hackernoon.com/from-mvc-to-modern-web-frameworks-8067ec9dee65) [iOS platform](https://docs.spring.io/spring/docs/3.2.x/spring-framework-reference/html/mvc.html).
59 |
60 | Please, read [the original MVC reports](http://folk.uio.no/trygver/2007/MVC_Originals.pdf) and [The Model-View-Controller. It's Past and Present](http://heim.ifi.uio.no/~trygver/2003/javazone-jaoo/MVC_pattern.pdf) if you want to know more, it's quite fascinating.
61 |
62 | **One of the main principles of MVC is to divide all our code to Presentation and Domain Model.**
63 |
64 | ## Domain and Presentation
65 |
66 | **Domain Model** is the core of our application. It is the main part of it. It consists of several business objects, for example, entities such as account, product, transaction and so on. And the logic around these objects is called business logic. For example, “if the user has little money on the account, give him a discount”. _Domain Model_ can consist of one object as well as a whole system of objects. It depends on how complex business logic is.
67 |
68 | **Presentation** is what users can see and interact with. In MVC the View and Controller are parts of Presentation.
69 |
70 | MVC philosophy in iOS:
71 |
72 | 
73 |
74 | ## MVVM, MVP, VIPER, React/Redux and friends
75 |
76 | A lot of different approaches and patterns were spawned to solve _(inexistent)_ "Massive View Controller" problem. On paper, they have noble goals that should help separate data model, business logic, model-view communication and put everything in its place. But practically most of them move a big ball of ~~mud~~ Domain logic from the ViewController into ViewModel/Presenter/Store/(put your container here), leaving the original problem unsolved.
77 |
78 | Thus, we can't call such patterns an architecture. **They are not an alternative to MVC**, but a scheme of a single module decomposition in the _Presentation_ layer.
79 |
80 | To be clear, there is nothing wrong with any of these patterns. Even though the _Domain Model_ contains most of the app state, we can't get rid of some stateful logic in the _Presentation_ layer. It includes view state, navigation logic or responding to the view lifecycle events. Such patterns solve problems in unique and interesting ways and can help you improve your _Presentation_ layer code.
81 |
82 | For example, MVVM makes some of the _Presentation_ code testable. Redux encapsulates a state of the module into a single object and guarantees that all the UI updates are synchronized with a module state.
83 |
84 | With that being said, the simplest solution is often the best one. Don't fight with iOS SDK, embrace and leverage the tools the platform gives us.
85 |
86 | ## Conclusion
87 |
88 | Don’t try to use MVC as one single structural design pattern but rather as a guideline for good application architecture, or a set of design patterns that can solve some of our problems.
89 |
90 | # Cooking Recipes
91 |
92 | ... or a "Good Architecture" best practices.
93 |
94 | ## 1. UIViewController is a part of the Presentation layer
95 |
96 | If you write there a business logic, network requests or anything else that doesn't relate to the user interface, it is not MVC.
97 |
98 | ## 2. Rich Domain Model
99 |
100 | Make your Domain Model smart. Use [design patterns](https://en.wikipedia.org/wiki/Software_design_pattern#Classification_and_list), unleash your engineering creativity here but be consistent. Keep in mind [our definition of a "Good Architecture"](#what-is-a-%22good-architecture%22).
101 |
102 | Requirements to the Domain Model:
103 |
104 | - Model must be encapsulated and must not reference the view or application framework;
105 | - The Model interface must expose actions, not primitive data operations;
106 | - Updates may occur only in response to change notifications from the Model.
107 |
108 | These requirements are the minimum for any application design.
109 |
110 | **Advice:**
111 |
112 | Use [**Facade** pattern](https://en.wikipedia.org/wiki/Facade_pattern) to build a nice API for your Presentation layer to use. Facades helps hide complex object graph of Domain model behind a single object. Example:
113 |
114 | ```swift
115 | protocol TodoListFacade {
116 | var tasksListDidChange: Observable { get }
117 |
118 | var tasks: [Task] { get }
119 |
120 | func createNewTask(name: String, dueDate: Date?)
121 | func markTaskAsCompleted(id: Task.ID)
122 | func removeTask()
123 | }
124 | ```
125 |
126 | _Notice that all the methods which update the model don't return any value. Presentation layer doesn't care about a result of the model update and updates itself only in response to notifications._
127 |
128 | ## 3. Don't fight the iOS SDK
129 |
130 | Fighting with iOS SDK is impossible and any attempt to do this complicates the system. As soon as we stop fighting with iOS SDK, all these staffs become useful. The SDK starts to help us and benefit.
131 |
132 | - Use patterns that iOS frameworks – especially UIKit – are already based on.
133 | - MVC, Delegate, Dependency Injection, Target / Action
134 |
135 | ## 4. Create other classes in the Presentation layer if you need it
136 |
137 | The Presentation layer can contain a lot of logic too. If your ViewController becomes fat, use [child view controllers](https://www.swiftbysundell.com/basics/child-view-controllers/) to decompose the screen. Decouple data sources from the view controller. If things are getting out of hand, use Redux.
138 |
139 | ## 5. Aim for the simplest solution first
140 |
141 | Apply design patterns as they are needed during the application evolution (growth of codebase) following the [YAGNI (you ain’t gonna need it)](https://en.wikipedia.org/wiki/You_aren%27t_gonna_need_it) principle, instead of choosing architecture and then trying to fit your application into it.
142 |
143 | Remember that **Obvious code > Clever code.**
144 |
145 | ## 6. Rx as a tool for asynchronous programming
146 |
147 | Rx is a handy tool that makes asynchronous programming simpler, solving [callback hell problem](http://callbackhell.com). Do not overuse Rx and don't follow Functional Reactive Programming paradigm. Keep Rx usage limited to 2 use cases: Futures and Observer pattern.
148 |
149 | ### Futures
150 |
151 | At the time of writing this chapter, Swift doesn't support asynchronous programming primitives such as _async_/_await_. Developers have to use callbacks for any kind of asynchronous operation. This [doesn't always end up well](http://callbackhell.com).
152 |
153 | Instead, we can resort to 3rd party libraries, such as Rx. Among a lot of concepts, RxSwift brings _Single_, which also knows as a _Future_ or a _Promise_.
154 |
155 | **Future** object represents the eventual completion (or failure) of an asynchronous operation, and its resulting value. Future is a proxy for a value not necessarily known when the promise is created.
156 |
157 | Futures allows to replace these code:
158 |
159 | ```swift
160 | func fetchTodoList(completionHandler: ([Task]) -> Void, errorHandler: (Error) -> Void) {
161 | ...
162 | }
163 |
164 | func storeTask(completionHandler: () -> Void) {
165 | ...
166 | }
167 |
168 | // At a call site
169 | fetchTodoList(
170 | completionHandler: { tasks in
171 | let group = DispatchGroup()
172 | for task in tasks {
173 | group.enter()
174 | storeTask {
175 | group.leave()
176 | }
177 | }
178 |
179 | group.wait()
180 | complete()
181 | },
182 | errorHandler: { error in ... }
183 | )
184 | ```
185 |
186 | With a simpler implementation:
187 |
188 | ```swift
189 | func fetchTodoList() -> Single<[Task]> {
190 | ...
191 | }
192 |
193 | func storeTask() -> Single {
194 | ...
195 | }
196 |
197 | // At a call site
198 | fetchTodoList()
199 | .flatMap { tasks in Single.zip(tasks.map(storeTask)) }
200 | .subscribe(
201 | onSuccess: { complete() },
202 | onError: { error in ... }
203 | )
204 | ```
205 |
206 | ### Observer Pattern
207 |
208 | **Observer pattern** is heavily utilized in Cocoa world and the SDK provides an API for that - `NotificationCenter`. The problem with a NotificationCenter is that notifications are not strongly typed, everything is `String` or `Any`. Instead, we can use generic `Observable` from RxSwift to observe changes in the Domain model.
209 |
210 | Apart from that Rx provides a lot of useful operators, such as `flatMapLatest`, `debounce` or `distinctUntilChanged`. Be aware of them, but do not overuse. Most of the asynchronous code is located in the Domain Model, so only part of the Presentation Layer that should touch Rx is subscribing to the model updates notifications.
211 |
212 | ## 7. Redux
213 |
214 | Redux is another useful approach in our toolbelt. Originally, Redux is an implementation of the unidirectional data flow architecture, where the whole app state is represented in a single big tree-object, and mutations to this object are limited by a distinct number of actions.
215 |
216 | This idea is also applicable to the unit of any size: whole app, single module or even just a class. Redux is an implementation detail of a single module so it doesn't affect the system. We use Redux for objects with a lot of interdependent states, it allows us to synchronize state management in a single function `reducer`. For more information refer to [the Redux chapter](4-1-redux.md).
217 |
218 | # Future Directions
219 |
220 | Both MVC and OOP were born around 40 years ago in the same research center Xerox PARC. Those paradigms moved the whole industry forward, made Software Engineering easier and more flexible, sped up the development processes. It's still a viable architecture solution to the app of any size. But what's next?
221 |
222 | SwiftUI and Combine may change the way we used to build things. SwiftUI makes possible to build declarative views and drive navigation through a single app state. Knowing that and observing the growing popularity of the functional programming approaches we expect a soon sunset of the MVC, and a paradigm shift towards unidirectional data flow in the iOS development.
223 |
224 | Keep calm and be ready for a brave new world!
225 |
226 | # Sources
227 |
228 | - [The only viable iOS architecture –– Amirzhan Idryshev
229 | ](https://medium.com/flawless-app-stories/the-only-viable-ios-architecture-c42f7b4c845d)
230 | - [Do MVC like it’s 1979 –– Bohdan Orlov
231 | ](https://badootech.badoo.com/do-mvc-like-its-1979-da62304f6568)
232 | - [MVC vs MVVM vs VIPER – which to use for iOS? –– Paul Hudson](https://www.hackingwithswift.com/articles/41/mvc-vs-mvvm-vs-viper-which-to-use-for-ios)
233 | - [Охота на мифический MVC. Обзор, возвращение к первоисточникам и про то, как анализировать и выводить шаблоны самому –– cobiot](https://habr.com/en/post/321050/)
234 | - [The worst possible application –– Matt Gallagher](https://www.cocoawithlove.com/blog/worst-possible-application.html)
235 |
--------------------------------------------------------------------------------
/5-tests.md:
--------------------------------------------------------------------------------
1 | # Tests
2 |
3 | ### General Rules
4 |
5 | ##### 1. We test our code.
6 |
7 | Although we don't aim for 100% code coverage, we keep it at a reasonable level. Ultimately, you decide by yourself what part of a specific project it is crucial to cover with tests and what kind of tests you need.
8 |
9 | ##### 2. Naming
10 |
11 | Test classes should be named as `\(TestingClassName)Tests.swift`.
12 |
13 | Test function names should reflect the behaviour which is being tested: `func test_theTestedBehaviourAndContext_expectedResult()`, or more concise for simple cases: `func test_theTestedBehaviour()`. For example:
14 |
15 | ```swift
16 | class ProfileServiceTests: XCTestCase {
17 | func test_gettingProfileWithoutLogin_emitsError() { ... }
18 | func test_gettingProfileWithoutId_returnsMyProfile() { ... }
19 | }
20 | ```
21 |
22 | ###### Why do we do this?
23 |
24 | For the best possible naming, imagine the situation when you (or one of your colleagues) have to deal with failed tests in your project. Small, explicitly named functions like
25 | ```swift
26 | func test_factorialForZero_returns1()
27 | ```
28 | will inform you exactly on what went wrong, but with functions like
29 | ```swift
30 | func test_checkMyMathIsOK()
31 | ```
32 | You'll probably have to dive deeper into the code.
33 |
34 | ##### 3. Location
35 |
36 | Test files should be placed in the same location as a tested class, so the project structure looks like that:
37 |
38 | ```
39 | |-- ViewModels
40 | |-- LoginViewModel.swift
41 | |-- LoginViewModelTests.swift
42 | ```
43 |
44 | ## Unit Tests
45 | Pay most attention to covering your View Models and Domain Layer Services code with unit tests.
46 |
47 | It's easier to test the code that doesn't keep state, but instead only defines the logic of transforming inputs into outputs. Distribute responsibilities by injecting dependencies when possible. Keeping this in mind while designing your app will make it easier to write tests.
48 |
49 | To write useful and conscious tests, think of what edge cases you can cover with them. If you mock so much of your logic that it makes testing conditions too unrealistic, maybe it's better to reconsider app design than keep writing mocks.
50 |
51 | ### Rx Testing
52 |
53 | We use RxTest framework for testing reactive code.
54 |
55 | ##### Usage
56 |
57 | Use `TestScheduler` to simulate the events at certain moments of time:
58 |
59 | ```swift
60 | let testScheduler = TestScheduler(initialClock: 0)
61 | testScheduler.createColdObservable([next(201, "String")])
62 | .asObservable()
63 | .bind(to: viewModel.testableObserver)
64 | .disposed(by: disposeBag)
65 |
66 | let expectedEvents = [next(201, "String")]
67 | let events = testScheduler.start { viewModel.testableObservable }.events
68 | XCTAsserEqual(events, expectedEvents)
69 | ```
70 |
71 | ### Best practices
72 |
73 | #### 1. To test `Void` type, compare two `debugDescription`
74 |
75 | *(WARNING: it may work wrong for types with implementation of `CustomDebugStringConvertible`)*
76 |
77 | ```swift
78 | XCTAsserEqual(events.debugDescription, expectedEvents.debugDescription)
79 | ```
80 |
81 | ###### Why do we do this?
82 | Void is not equatable, and we can't compare it. In debug description we will get some information about events (example: `next(()) @ 200`) where `next(())` our void event and `@ 200` it's time in milliseconds when it comes.
83 |
84 | #### 2. If you use the specific scheduler, you should inject it in ViewModel
85 |
86 | ```swift
87 | class ViewModel {
88 | let userName: Observable
89 | let lastUpdatedUserName: Observable
90 |
91 | init(
92 | userService: UserServiceProtocol,
93 | scheduler: SchedulerType = ConcurrentDispatchQueueScheduler(qos: .background)
94 | ) {
95 | let userName = userService.currentUser
96 | .observeOn(scheduler)
97 | .map { $0.name }
98 | .share(replay: 1, scope: .whileConnected)
99 |
100 | let lastUpdatedUserName = userName.map { _ in Date() }
101 | self.userName = userName
102 | self.lastUpdatedUserName = lastUpdatedUserName
103 | }
104 | }
105 |
106 | // Usage in tests:
107 |
108 | let testScheduler = TestScheduler(initialClock: 0)
109 | let viewModel = ViewModel(userService: testUserService, scheduler: testScheduler)
110 | ```
111 |
112 | ###### Why do we do this?
113 |
114 | When we test our events we use `TestScheduler` which conforms to the `SchedulerType`. The problem here is, when you would like to move your sequence to other thread you will lose events.
115 | Example:
116 |
117 | ```
118 | (1) we don't inject a scheduler
119 | TestScheduler -----x-x- -----> Here we try catch it
120 | \
121 | observerOn(Scheduler) -x-x-------> Here nothing
122 |
123 |
124 | (2) we do inject a scheduler
125 | TestScheduler -----x-x- -x-x-> Here we try catch it
126 | \ /
127 | observerOn(TestScheduler) -x-
128 | ```
129 |
130 | #### 3. Also, if we want to test `Date` type, we should inject a closure which will return the date
131 |
132 | ```swift
133 | typealias CurrentDateFactory = () -> Date
134 | class ViewModel {
135 | let userName: Observable
136 | let lastUpdatedUserName: Observable
137 |
138 | init(
139 | userService: UserServiceProtocol,
140 | scheduler: SchedulerType = ConcurrentDispatchQueueScheduler.init(qos: .background),
141 | currentDateFactory: @escaping CurrentDateFactory = { Date() }
142 | ) {
143 | let userName = userService.currentUser
144 | .observeOn(scheduler)
145 | .map { $0.name }
146 | .share(replay: 1, scope: .whileConnected)
147 |
148 | let lastUpdatedUserName = userName.map { _ in currentDateFactory() }
149 | self.userName = userName
150 | self.lastUpdatedUserName = lastUpdatedUserName
151 | }
152 |
153 | }
154 |
155 | // Usage in tests:
156 | let testScheduler = TestScheduler(initialClock: 0)
157 | let testDate = Date()
158 | let currentDateFactory = { return testDate }
159 | let viewModel = ViewModel(userService: testUserService, scheduler: testScheduler, currentDateFactory: currentDateFactory)
160 | ```
161 |
162 | ###### Why do we do this?
163 | The `Date()` initializer creates a current date using real system time. If a View Model calls it directly, there's no way to check its correctness in unit tests, because we don't know (with the required accuracy) what date it should contain. So, if in the suggested example the View Model created the date itself, it would be different from the `testDate`, because we created `testDate` earlier than the View Model.
164 |
165 | ### Mocking
166 | The best way to test some `ViewModel` which contains a `Service` (Domain Layer Services) is to create some mock of this `Service`. For this, we need to use `protocol` and create some class which will conform to it.
167 | Example:
168 |
169 | ```swift
170 | // Protocol of our service
171 | protocol UserServiceProtocol {
172 | var currentUser: Observable
173 | func fetchUser() -> Observable
174 | }
175 |
176 | // Mocking the class which conforms to our protocol
177 | class TestUserService: UserServiceProtocol {
178 |
179 | func fetchUser() -> Observable {
180 | return testFetchUser
181 | }
182 |
183 | var currentUser: Observable {
184 | return testCurrentUser
185 | }
186 |
187 | private let testCurrentUser: Observable
188 | private let testFetchUser: Observable
189 | init(
190 | testCurrentUser: Observable = .empty(),
191 | testFetchUser: Observable = .empty()
192 | ) {
193 | self.testCurrentUser = testCurrentUser
194 | self.testFetchUser = testFetchUser
195 | }
196 | }
197 | let testScheduler = TestScheduler(initialClock: 0)
198 | let date = Date()
199 | let currentDateFactory = { return date }
200 | let testUser = User(id: 1)
201 |
202 | // I recommend you mock only `func` or `properties` which you will use in viewModel.
203 | // We set a default value for testFetchUser to Observable.empty().
204 | // It will make our life easier :D
205 |
206 | let testCurrentUser = Observable.deferred {
207 | return self.testScheduler.createColdObservable([next(0, testUser)]).asObservable()
208 | }
209 |
210 | let testUserService: UserServiceProtocol = TestUserService(testCurrentUser: testCurrentUser)
211 |
212 | let viewModel = ViewModel(userService: testUserService, scheduler: testScheduler, currentDateFactory: currentDateFactory)
213 |
214 | ```
215 |
216 | As a conclusion, I would like to give a real [example](In%20the%20result%20i%20would%20like%20to%20give%20a%20real%20example.%20https://gist.github.com/romanfurman6/f3846351b669eacee3f786611edff72d).
217 |
218 |
219 | ## UI Tests
220 |
221 | UI tests help a lot with testing View Controllers and coordination, but take much more time to write and run than unit tests. Consider writing UI tests for long-term projects, projects with complicated UI design and navigation.
222 |
223 | Testing the apps on a real network is a bit of a headache. It takes more time and you have to recreate test accounts after each database drop. Stub networking for UI tests.
224 |
--------------------------------------------------------------------------------
/6-estimates.md:
--------------------------------------------------------------------------------
1 | # How to make Estimations
2 |
3 | Estimations is an essential part of the sales process and usually to make a precise estimate the help of a developer is needed.
4 | The main difficulties are:
5 | - overestimation may cause the lost of the potential client
6 | - underestimations may bring hard times during the development
7 | - each developer has own development speed
8 |
9 | ## Guide on how to make Estimates
10 |
11 | ### Gather requirements and inputs
12 |
13 | #### 1. Get to know your client and project 💼.
14 | a. make a quick research or ask the sales representative about the company, their business, missions (read why in [Communication Tips](#communicationtips) section);
15 | b. their audience since it might be people who may require Accessibility features in the app;
16 | c. review information about the project;
17 | d. ask for required iOS versions, devices needs to be supported (both iPhone and iPad), orientations.
18 |
19 | #### 2. Get features list 📜.
20 | The features can be one or more from the list:
21 | 1. Written:
22 | * user stories or features list
23 | * detailed specification
24 | 2. Visual:
25 | * wireframes
26 | * finished designs
27 | * interactive designs
28 |
29 | ### Estimate
30 |
31 | The result of the estimations is a table with columns: feature, min, avg, max. Where:
32 | + Feature - is a feature name / user story
33 | + Min / Max - minimum / maximum estimated time for a feature
34 | + Avg - average is arithmetic mean of min and max
35 |
36 | #### Step 1. Define and pick a feature
37 | Define the feature that is not relatively big but rather small and can't be broken to a smaller piece.
38 | For example, a screen like this:
39 |
40 | 
41 |
42 | can be broken into the following parts:
43 | 1. Login using credentials
44 | 2. Facebook login
45 | 3. Additional Facebook integration (optional depending on existence of other facebook related features)
46 |
47 | Note: "Forgot password" feature will most likely have a separate screen for it.
48 |
49 | Sometimes it's not possible to break down a relatively big feature, for example implementing a chat or video streaming. In this case the feature can be picked as is.
50 |
51 | Finally, after breaking down to smaller features, choose one feature and move to the next step.
52 |
53 | #### Step 2. Estimate the feature
54 |
55 | First, define the **complexity** of the feature: easy,
56 | + **Easy**: Login, forgot password, FAQ, help screens. Usually, static or dynamic screens, without too complex design, often without networking.
57 | + **Normal**: Home screen, registration, tables, collections, custom controls. Usual screens with networking, or custom controls.
58 | + **Hard**: Third-party API integration, audio/video recording, photo capturing, streaming, chatting, custom gestures, synchronization, offline mode.
59 | + **Extreme**: Usually they’re unclear and huge features. It’s better to divide them into smaller ones.
60 |
61 | Use complexity to define the difference between min/max values: the more complex it is the bigger difference.
62 | Why: the effect of different programming speed and seniority level is more visible on bigger features.
63 |
64 | One more thing before giving a final min/max number pay attention to custom things like: animations, sliders, android like [tabs](https://material.io/design/components/tabs.html).
65 |
66 | **Estimation time unit**.
67 | + hours (Int) - preferred unit for estimations
68 | + days (Double)
69 |
70 | #### Step 3. Include additional hours
71 |
72 | **QA/Tests/Fixes.**
73 |
74 | Include QA/Tests/Fixes/Management if agreed with client.
75 | Usually QA is 20%, management - 10%, Tests - 20% of total development time.
76 |
77 | ### Cheat sheets and tips
78 |
79 | **Important note**: use this cheat sheet only for rough estimates during sales process, which means - we are not committing to this budget/timeline, but just giving the client general price range. Also, don’t make it too high, since we need to sell it too :)
80 |
81 | Cheat sheet for complexity and min/max hour values:
82 |
83 | | Complexity | Min | Max |
84 | | ---------- | --- | --- |
85 | | easy | 2 | 12 |
86 | | normal | 8 | 24 |
87 | | hard | 24 | 100 |
88 | | extreme | 50 | 300 |
89 |
90 | Cheat sheet for common features:
91 |
92 | | Feature | Min | Max | Comments |
93 | | --------------------------------------------------------------------- | --- | --- | -------------------------------------------------- |
94 | | CI setup | 8 | 16 | might be not initial setup but through the project |
95 | | Project setup | 4 | 16 | might be not initial setup but through the project |
96 | | Chat (no lib) | 80 | 120 | of course it also depends on chat features |
97 | | Chat (with our lib) | 20 | 40 | of course it also depends on chat features |
98 | | Social network login / sharing | 4 | 8 | per each feature |
99 | | Payments system integration | 8 | 24 | a lot of time goes to communication / researching |
100 | | Image picker (default) | 2 | 4 | n/a |
101 | | Image picker (custom) | 16 | 24 | n/a |
102 | | Image cropper ([lib](https://github.com/ruslanskorb/RSKImageCropper)) | 4 | 8 | n/a |
103 | | Image cropper (custom) | 24 | 40 | n/a |
104 | | Internatialization | 8 | 16 | can be more if it's ؓؑ ؐ ؕ (arabic) |
105 | | Photo Camera (custom) | 24 | 40 | capture, focus, zoom |
106 | | Video Camera (custom) | 40 | 60 | simple video shooting |
107 | | Video Camera (lib) | 16 | 32 | simple video shooting |
108 | | Video Player (custom) | 24 | 32 | for a player with controls like default has |
109 | | Simple networking using our [lib](https://github.com/Moya/Moya) | 4 | 8 | n/a |
110 | | Complex networking w multipart requests and/or websockets | 16 | 24 | n/a |
111 |
112 | ### Beware of pitfalls
113 |
114 | When you are trying to make a precise estimate make sure to think of different small details that in total may result in a very time consuming feature.
115 | As an example, a table view that usually takes place on almost every screen can be not just a simple list but also have such pitfalls:
116 | + Design cells (which can contain a lot of information and extra buttons and have flexible content depending on height).
117 | + Loading data (send requests to the server, parse response, create data models)
118 | + Loading more, pull-down-to-refresh.
119 | + Showing loading, empty, and error states (like connection errors).
120 | + Adding/removing items from the table. In my experience, removing items from the table can be very tricky—many apps crash due to data inconsistency.
121 | + Merging content. Sometimes you will need to merge local content with data from the server.
122 | + User interaction when tapping on the cell itself or on a button on the cell (usually the background changes to indicate the selected state and a new screen opens).
123 | + Image caching for pictures in cells (if any).
124 | + Nice animation when cells appear on the screen while scrolling (image fading or light bouncing, depending on the project style).
125 |
126 | More examples [here](https://github.com/stanfy/ios-components-bikeshedding)
127 |
128 | ### Special cases
129 |
130 | **We do include supporting dev services:**
131 | - CI setup
132 | - Crashlytics
133 | - Analytics services
134 |
135 | **Consider Localization.**
136 | Often times clients don't mention if they need to support multiple languages.
137 |
138 | **Consider reusability (optional).**
139 | If you see that there are 5 screens with the same UI but different data, make sure to separate one feature to be "Generic/reusable table" which for example may take 10 hours, but screens will take much less than if they would be done from scratch. This also shows the quality and proficiency of the developer to the client (client can also be a tech person).
140 |
141 | **Hardware ⌚️ and IOT.**
142 | In case the hardware is involved we should carefully do the research and see if there are documentation (in English) available, how popular hardware is, if community used it and discussed before.
143 |
144 | **R&D features.**
145 | Some projects have features that we can't evaluate easily and require additional research. And it is normal to add R&D time into the feature estimation or break it down into 2 separate features: "research on feature implementation" and "feature implementation". R&D is crucial to make a right decision that later in time won't require re-coding a feature or changing the tech approach. Examples of such features can be: integration with not very well documented / popular library or API, implementing a just announced API by Apple.
146 |
147 | ### Example
148 |
149 | Here is an example of one of the estimates of iOS app:
150 | 
151 |
152 |
153 | ## Communication Tips 📝
154 |
155 | Sometimes we tend to underestimate the estimation stage of the sales process. This stage is one of the stages where a developer can be actively involved to:
156 | + suggest better solutions using professional knowledge (e.g. use tab bar instead of a slider menu)
157 | + upsell some feature (e.g. biometric authentication)
158 | + show attentiveness and involvement by asking questions (e.g. "should there be screen_name screen, since it's seems we are missing it as a part of Signup flow?")
159 | + show expertise
160 |
161 | Doing so can dramatically influence the sales impact and client's opinion on who we are and whether to work with us. Since it shows that we are proactive, attentive, smart, experts and so on 🤓.
162 |
163 | ## Summary ⛳️
164 | Estimations is an important stage at which we can show the potential client who we are. And yet it's a fun experience to live through the app in an hour or two and get to know some interesting business idea or future hyped startup 🚀.
165 |
--------------------------------------------------------------------------------
/7-2-swiftui.md:
--------------------------------------------------------------------------------
1 | # Cookbook SwiftUI
2 |
3 | Welcome to the SwiftUI Cookbook! In this cookbook, we will explore various techniques and best practices for building beautiful and functional user interfaces with SwiftUI.
4 |
5 | ## Table of Contents
6 |
7 | - [1. Keep your code clean and organized](#1-keep-your-code-clean-and-organized)
8 | - [2. Keep performance in mind](#2-keep-performance-in-mind)
9 | - [3. Configuring SwiftUI views](#3-configuring-swiftui-views)
10 | - [4. Use ViewModifiers to reuse styling logic](#4-use-viewmodifiers-to-reuse-styling-logic)
11 | - [5. Use structs for data modeling](#5-use-structs-for-data-modeling)
12 | - [6. Use environment objects for shared state](#6-use-environment-objects-for-shared-state)
13 | - [7. Enhancing Accessibility with SwiftLint Rules](#7-enhancing-accessibility-with-swiftlint-rules)
14 | ---
15 |
16 | ### 1. Keep your code clean and organized
17 | - **Whitespace and Indentation**: Use whitespace and proper indentation to make your code more readable. This helps in distinguishing between different blocks of code and makes it easier to navigate through your codebase.
18 | - **Logical Code Separation**: Separate your code into logical blocks. This can be achieved by using extensions or by breaking down complex views into smaller subviews. This approach not only makes your code cleaner but also more modular and easier to maintain.
19 | - **Modifiers on New Lines**: Place each modifier on a new line. This practice enhances readability, especially when you are using multiple modifiers on a single view. It makes it easier to track which modifiers are applied and in what order.
20 |
21 | ```swift
22 | struct ContentView: View {
23 | var body: some View {
24 | VStack {
25 | Text("Hello, world!")
26 | .padding()
27 |
28 | Button(action: {
29 | print("Button tapped")
30 | }) {
31 | Text("Tap me!")
32 | }
33 | }
34 | }
35 | }
36 | ```
37 |
38 | ### 2. Keep performance in mind
39 | When creating a view with a lot of instances inside, it's important to keep performance in mind. Creating too many instances or having too complex of a view hierarchy can lead to slow performance, especially on older devices.
40 |
41 | To improve performance, you can follow these best practices:
42 |
43 | 1. **Use Reusable Views**: For repeated elements, create a reusable view instead of new instances for each item. This reduces memory usage and improves performance.
44 | Example:
45 |
46 | ```swift
47 | struct ItemView: View {
48 | let item: Item
49 |
50 | var body: some View {
51 | Text(item.name)
52 | // Other view components
53 | }
54 | }
55 |
56 | struct ItemsListView: View {
57 | let items: [Item]
58 |
59 | var body: some View {
60 | List(items) { item in
61 | ItemView(item: item)
62 | }
63 | }
64 | }
65 | ```
66 | 2. **Use Lazy Loading**: For large datasets, use `LazyVStack`, `LazyHStack`, `LazyHGrid`, and `LazyVGrid`. These structures load views on demand, improving performance for large collections. But be aware that these structures load data once and do not reuse cells. If you have a large number of cells you should use `List`.
67 | 3. **Avoid Complex View Hierarchies**: To enhance rendering performance and maintain readability in SwiftUI, it's important to manage the complexity of view hierarchies effectively:
68 | - **Simplify View Hierarchies**: Aim to keep your view hierarchies as simple as possible. A complex hierarchy can slow down rendering and make your code harder to understand and maintain.
69 | - **Break Down When Necessary**: When your view hierarchy exceeds four levels of nesting, consider refactoring further nested views into separate, dedicated views. This "healthy nesting" rule helps in balancing the need for simplicity without fragmenting your codebase into too many small, unique components.
70 | - **Limit Computed `View` Properties**: Be cautious with the use of computed `View` properties. While they can be useful, excessive use may inadvertently increase complexity and impact performance. They are best used when they contribute to reducing overall view complexity or are essential for dynamic view updates.
71 | - **Strategic Refactoring**: Refactoring into smaller views is recommended, but only when it logically makes sense. This approach helps to avoid an excessive number of small, unique views, which can be as detrimental as overly complex hierarchies.
72 |
73 | By following these guidelines, you can create a well-structured and performant SwiftUI application. The key is to strike a balance: simplify and refactor where necessary, but avoid unnecessary fragmentation of your views.
74 | Example:
75 |
76 | ```swift
77 | // Complex hierarchy (Avoid)
78 | struct ComplexView: View {
79 | var body: some View {
80 | VStack {
81 | Text("Title")
82 |
83 | Text("Subtitles")
84 |
85 | Image("Logo")
86 |
87 | Text("Description")
88 |
89 | Button("Login") {
90 | // Action
91 | }
92 | }
93 | }
94 | }
95 |
96 | // Simplified hierarchy (Preferred)
97 | struct SimpleView: View {
98 | var body: some View {
99 | VStack {
100 | HeaderView()
101 | // Other subviews
102 | }
103 | }
104 | }
105 |
106 | struct HeaderView: View {
107 | var body: some View {
108 | Text("Title")
109 |
110 | Text("Subtitles")
111 | // Other subviews
112 | }
113 | }
114 | ```
115 |
116 | ### 3. Configuring SwiftUI views
117 | If a **View** contains more than three variables, it is advisable to employ an additional **struct** for initialization.
118 | - Avoid long initialization block:
119 |
120 | ```swift
121 | struct HomeView: View {
122 | var config: HomeViewConfig
123 |
124 | var body: some View {
125 | VStack {
126 | Text(config.title)
127 | .font(.title)
128 |
129 | Text(config.header)
130 | .font(.headline)
131 |
132 | Text(config.content)
133 | .font(.body)
134 | }
135 | }
136 | }
137 |
138 | extension HomeView {
139 | struct HomeViewConfig {
140 | var title: String
141 | var header: String
142 | var content: String
143 | var caption: String
144 | }
145 | }
146 | ```
147 |
148 | - Use `enum` when `View` has multiple callbacks:
149 |
150 | ```swift
151 | struct ContentView: View {
152 | var callback: (ContentViewAction) -> Void
153 |
154 | var body: some View {
155 | VStack {
156 | Button("Edit") {
157 | callback(.edit)
158 | }
159 |
160 | Button("Item 0") {
161 | callback(.itemId(0))
162 | }
163 | }
164 | }
165 | }
166 |
167 | extension ContentView {
168 | enum ContentViewAction {
169 | case edit
170 | case itemId(Int)
171 | }
172 | }
173 | ```
174 |
175 | ### 4. Use ViewModifiers to reuse styling logic
176 | In SwiftUI, modifiers are used to style views. They allow you to change the appearance and behavior of a view without changing its underlying properties. Modifiers can be powerful, but they can also slow down your app if overused. Try to use modifiers only when necessary and avoid using them excessively.
177 | ```swift
178 | struct ContentView: View {
179 | var body: some View {
180 | Text("Hello, world!")
181 | .foregroundColor(.green)
182 | .font(.title)
183 | .background(Color.white)
184 | .watermarked(with: "UpTech")
185 | }
186 | }
187 |
188 | struct Watermark: ViewModifier {
189 | var text: String
190 |
191 | func body(content: Content) -> some View {
192 | ZStack(alignment: .bottomTrailing) {
193 | content
194 | Text(text)
195 | .font(.caption)
196 | }
197 | }
198 | }
199 |
200 | extension View {
201 | func watermarked(with text: String) -> some View {
202 | modifier(Watermark(text: text))
203 | }
204 | }
205 | ```
206 |
207 | ### 5. Use structs for data modeling
208 | SwiftUI works best with structs. Use structs to model your data and pass it between views. This allows you to take advantage of SwiftUI's data binding and state management features.
209 | ```swift
210 | struct Person: Identifiable {
211 | var id = UUID()
212 | var name: String
213 | var age: Int
214 | }
215 |
216 | struct ContentView: View {
217 | var people = [
218 | Person(name: "John", age: 30),
219 | Person(name: "Jane", age: 25),
220 | Person(name: "Bob", age: 40)
221 | ]
222 |
223 | var body: some View {
224 | List(people) { person in
225 | Text(person.name)
226 | }
227 | }
228 | }
229 | ```
230 |
231 | ### 6. Use environment objects for shared state
232 | Environment objects in SwiftUI are powerful tools for sharing state across multiple views. However, their use should be carefully considered. Here are the guidelines:
233 | - **App-Wide Shared State**: Use `@EnvironmentObject` for a state that is truly global or app-wide, such as user preferences, themes, or authentication states. This should be used sparingly, as overuse can lead to tightly coupled components and make tracking data flow more difficult.
234 | - **View Models (VMs) and View-Specific Logic**: Prefer using `@ObservedObject` and `@StateObject` for a state that is specific to a particular view or its closely related components. This approach promotes better encapsulation and makes your views more reusable and easier to test.
235 | ```swift
236 | // Global state using @EnvironmentObject
237 | final class UserSettings: ObservableObject {
238 | @Published var darkMode = false
239 | }
240 |
241 | struct AppView: View {
242 | var body: some View {
243 | ContentView()
244 | .environmentObject(UserSettings())
245 | }
246 | }
247 |
248 | // Local state using @StateObject or @ObservedObject
249 | struct ContentView: View {
250 | @StateObject var viewModel = ContentViewModel()
251 | var body: some View {
252 | // View implementation
253 | }
254 | }
255 |
256 | final class ContentViewModel: ObservableObject {
257 | // View-specific state
258 | }
259 | ```
260 |
261 | ### 7. Enhancing Accessibility with SwiftLint Rules
262 | Accessibility is an important aspect of building inclusive apps that can be used by everyone. In this section, we will explore how to make your SwiftUI app more accessible.
263 |
264 | In addition to the standard SwiftUI accessibility features, it's beneficial to enforce certain accessibility best practices using SwiftLint, an industry-standard tool for maintaining Swift code quality. Specific `SwiftLint` rules that are particularly useful for accessibility are:
265 | 1. **`accessibility_label_for_text` Rule**: This rule ensures that all text elements have accessibility labels, especially when the text is not self-explanatory or when additional context is needed for screen readers.
266 | 2. **`accessibility_custom_action` Rule**: Use this rule to ensure that custom actions are provided for interactive elements that perform non-standard functions. This is crucial for users who rely on assistive technologies to understand what actions they can perform on a UI element.
267 | 3. **`accessibility_dynamic_type_text` Rule**: This rule checks that your app's text elements support dynamic type. This is important for users who need larger text sizes, ensuring that your app's UI adjusts text size based on the user's settings.
268 | 4. **`accessibility_highlighted_elements` Rule**: Ensure that elements that are highlighted or selected have appropriate accessibility traits so that their state is clear to users with visual impairments.
269 | 5. **`accessibility_minimum_tappable_area` Rule**: Enforce a minimum tappable area for buttons and other interactive elements, ensuring they are easily accessible for users with motor impairments.
270 |
271 | You can make your SwiftUI app more accessible by using several techniques, such as providing labels and hints for views, adjusting font sizes and colors, and enabling voiceover and other assistive technologies.
272 | ```swift
273 | struct ContentView: View {
274 | var body: some View {
275 | VStack {
276 | Text("Welcome")
277 | .accessibilityLabel("Welcome greeting")
278 | // Ensures that the text has an accessibility label
279 |
280 | Button(action: customAction) {
281 | Text("More Info")
282 | }
283 | .accessibilityAction(named: "Get More Information", customAction)
284 | // Adds a custom accessibility action
285 |
286 | Text("Adjustable Text")
287 | .font(.system(size: 16))
288 | .accessibilityDynamicTypeText()
289 | // Ensures support for dynamic type
290 |
291 | Button("Selected Item") {
292 | // Action
293 | }
294 | .accessibility(addTraits: [.isButton, .isSelected])
295 | // Marks the button as selected
296 |
297 | Button("Tap Me") {
298 | // Action
299 | }
300 | .frame(minWidth: 44, minHeight: 44)
301 | .accessibilityMinimumTappableArea()
302 | // Ensures a minimum tappable area
303 | }
304 | }
305 | }
306 | ```
--------------------------------------------------------------------------------
/8-ui-tests.md:
--------------------------------------------------------------------------------
1 | # UI Tests 📱
2 |
3 | This chapter of the cookbook can be considered as `exploration stage` for the following reasons:
4 |
5 | - First is that the team does not have a lot of experience with UI tests,
6 | - Second, this chapter only talks about the UI test tools that comes bundled with Xcode, and not some of the other options like [iOS Snapshot Test Case](https://github.com/uber/ios-snapshot-test-case), [EarlGrey](https://github.com/google/EarlGrey) and [KIF](https://github.com/kif-framework/KIF).
7 |
8 | ## Introduction
9 |
10 | Xcode 7 introduced UI testing, which lets us to create a UI test by recording interactions with the UI. UI testing works by finding an app’s UI objects with queries, synthesizing events, then sending them to those objects. The API enables us to examine a UI object’s properties and state in order to compare them against the expected state.
11 |
12 | UI tests tools that we get with Xcode rests upon two core technologies: the XCTest framework and Accessibility.
13 |
14 | - __XCTest__ provides the framework for UI testings, expanding on the same tools you already know from your Unit tests, like XCTAssert and all its relatives!
15 |
16 | - __Accessibility__. Yes, you might be asking yourself "why this guy started talking about Accessibility" right now, but it is not only related, but crucial to have UI tests! We use Accessibility tools that are normally are used for providing disabled users a good experience on iOS to also detect what is exposed for external use (the views that on the screen, interactable or not) and use that information for testing.
17 |
18 | Main reason we need to use Accessibility to extract information about the UI state of the app is that UI testing is a [black-box testing](https://en.wikipedia.org/wiki/Black-box_testing) framework. We shouldn't have to know anything about the implementation of the code we are testing. We can think of UI testing from the perspective of the user. The user doesn't care how our `MassiveViewController` works (or even that it exists 😈), so why should the UI Tests?
19 |
20 | ## Getting Started
21 |
22 | If your project doesn't already have a target for UI tests, you can add one by going to `File > New > Target..` in Xcode and select a “UI testing bundle”. Then edit your app’s scheme to run your UI tests when testing, by going to `Product > Scheme > Edit Scheme..` in Xcode and adding your UI testing bundle under “Test”.
23 |
24 | Before we start with an example, let's talk about using Accessibility to identify views in UI tests. To give our UI elements accesibility identifiers, we can either use interface builder or do it in the implementation code.
25 | - To use interface builder to give an identifier we need to select the view, go to `Identity inspector` tab and set a value for `Identifier` field located in the `Accessibility` section.
26 | - To give the identifier in code, set `accessibilityIdentifier` of the view to the identifier string you want like following: `anyView.accessibilityIdentifier = "someIdentifierString"`
27 |
28 | And also, one great side-effect of using `Accessibility` for UI tests is that it can help us make our apps accessible at the same time!
29 |
30 |
31 | You can see the the gif below to see the flow we will be testing. Here we will test closing the view by tapping close button, closing the view by swiping down and entering credentials and tapping `Let's Go!` button to signup.
32 |
33 |
34 |
35 | Now let's start with the example:
36 |
37 | *(The comments that start with `*` will be the explanations for the chapter, and others are just regular comments.)*
38 |
39 | ```swift
40 |
41 | import XCTest
42 |
43 | class SignupUITests: XCTestCase {
44 |
45 | var app: XCUIApplication!
46 |
47 | override func setUp() {
48 | super.setUp()
49 |
50 | // * We set this property to false to end test execution as soon
51 | // as a failure occurs, main reason is UI tests are slower than the
52 | // unit tests and we don't want to wait to find out we have a problem.
53 | continueAfterFailure = false
54 |
55 | app = XCUIApplication()
56 |
57 | // * Here we pass launch arguments to `didFinishLaunchingWithOptions`
58 | // in our AppDelegate. This is the only place where we can communicate
59 | // with the app in UI tests since it is black-boxed, so we need to do
60 | // any setup needed here. Names are self-explanatory but lets go over
61 | // anyway. CLEAN_STORAGE is used for resetting UserDefaults,
62 | // ALPHA_ENV is used for setting the environment and STUB_REQUESTS is
63 | // used for activating the network request stubbing.
64 | app.launchArguments.append(contentsOf: ["CLEAN_STORAGE", "ALPHA_ENV", "STUB_REQUESTS"])
65 | }
66 |
67 | func testSignup() {
68 | // * We launch to app to start testing. This is the most time consuming step
69 | // so we will combine all 3 tests and do them in one launch.
70 | app.launch()
71 |
72 | // * Preparing the views that we will check later if they exist. We are using
73 | // the `accessibilityIdentifier`s that we gave to our views to find them
74 | let landingView = app.otherElements["LandingView"]
75 | let signupView = app.otherElements["SignupCardView"]
76 | let signupLoadingView = app.otherElements["SignupLoadingView"]
77 |
78 | // Test Closing with Close Button * First test
79 | app.openSignupScreen() // * Here we navigate to the screen we want to test
80 | // * This was added as an extension to `XCUIApplication` because it is
81 | // used a lot of times. We need to navigate to the view we want to test.
82 |
83 | XCTAssert(signupView.waitForExistence(timeout: 5))
84 | // * Here we use .waitForExistence method to see if it appeared.
85 |
86 | // * Then we find the close button and tap on it to see if view is dismissed.
87 | let closeButton = app.buttons["SignupCardCloseButton"]
88 | closeButton.tap() // * Here we use tap method of XCUIElement.
89 |
90 | XCTAssert(landingView.waitForExistence(timeout: 5))
91 | // * We use waitForExistence method to see if landingView is visible after
92 | // dismissing SignupCardView
93 |
94 | // Test Closing with Swipe * Second Test
95 | app.openSignupScreen() // * Again we navigate to the screen we want to test
96 |
97 | XCTAssert(signupView.waitForExistence(timeout: 5))
98 | // * Again we use .waitForExistence method to see if it appeared.
99 |
100 | signupView.swipeDown()
101 | // * Here we use swipeDown method of XCUIElement, which sends a swipe-down
102 | // gesture to our top view! One of the coolest things that UI testing tools
103 | // allow us to do is using gestures.
104 |
105 | XCTAssert(landingView.waitForExistence(timeout: 5))
106 | // * Again we use waitForExistence method to see if landingView is visible after
107 | // dismissing SignupCardView
108 |
109 | // Test signing up * Third test
110 | app.openSignupScreen() // * Again we navigate to the screen we want to test
111 |
112 | XCTAssert(signupView.waitForExistence(timeout: 5))
113 | // * Again we use .waitForExistence method to see if it appeared.
114 |
115 | // * Here we are trying to get textfield inside another view, we know that
116 | // it is the first subview of the container view so we use .firstMatch
117 | let emailField = app.otherElements["SignupEmailFieldContainer"].otherElements.firstMatch
118 | let passwordField = app.otherElements["SignupPasswordFieldContainer"].otherElements.firstMatch
119 |
120 | // * And we use the method we added in XCUIElement extension to clear
121 | // old text(if there was any) and enter the new text.
122 | emailField.clearAndEnterText(text: "new.account@mail.com")
123 | passwordField.clearAndEnterText(text: "password123")
124 |
125 | app.buttons["SignupButton"].tap() // * We trigger a tap
126 |
127 | XCTAssert(signupLoadingView.waitForExistence(timeout: 5))
128 | // And we check if the next view after a successfull signup appears
129 |
130 | }
131 | }
132 |
133 | extension XCUIApplication {
134 | func openSignupScreen() {
135 | buttons["Sign Up"].tap()
136 | }
137 | }
138 |
139 | extension XCUIElement {
140 | /**
141 | Removes any current text in the field before typing in the new value
142 | - Parameter text: the text to enter into the field
143 | */
144 | func clearAndEnterText(text: String) {
145 | guard let stringValue = self.value as? String else {
146 | XCTFail("Tried to clear and enter text into a non string value")
147 | return
148 | }
149 |
150 | self.tap()
151 |
152 | let deleteString = stringValue.map { _ in XCUIKeyboardKey.delete.rawValue }.joined(separator: "")
153 |
154 | self.typeText(deleteString)
155 | self.typeText(text)
156 | }
157 | }
158 | ```
159 |
160 | ## Learnings and Tips
161 |
162 | #### 1. You can make your UI tests run faster by stubbing the network requests
163 |
164 | We saw in the above example that we pass a launch argument called `STUB_REQUESTS`, which was used in AppDelegate to check if we should enable stubbing. For this we used a library called [OHHTTPStubs](https://github.com/AliSoftware/OHHTTPStubs). It is quite easy to use, you just need the JSON response, the `host` and `path` of the request you want to stub and the status code you want. You can also simulate slow networks!
165 |
166 | #### 2. Don’t enable view's accessibility, and just use the identifier field
167 |
168 | When you are giving identifiers to your UI elements, if you tick the `Enabled` checkbox under accessibility options in IB or if you set `isAccessibilityElement` `true` in code, you won't be able to detect any of the subviews and to detect, you will have to confirm to `UIAccessibilityContainer` to make subviews accessible as seperate elements.
169 |
170 | #### 3. Sometimes you will have to find the container and access the children
171 |
172 | Sometimes you won't be able to find your views in XCUIApplication.elements, for example it might not able to find a UITextfield subclass that is in another view for styling purpuses(even if you did not check `isAccessibilityElement`). Here you will have to find the container and then access the children like we did in the example with the textfields. ( `app.otherElements["SignupEmailFieldContainer"].otherElements.firstMatch` )
173 |
174 | #### 4. You probably need to combine all tests for a screen in one `test` method.
175 |
176 | Unlike unit tests, it takes A LOT of time to run all UI tests separetaly, so you can do one `func test..()` for a view with multiple asserts to combine all tests for that view. But not sure if this is a good approach to be honest. It is a trade-off between time and structure.
177 |
178 | #### 5. You can use `Xcode Target`s to your advantage
179 |
180 | While you are working on UI tests for a specific screen, you can create a specific target for this UI test and save some time by not running every UI test in the project!
181 |
182 |
183 | ## Should We Add UI Tests to Our Development Cycle?
184 |
185 | This really depends on the project. For the regular projects where we are trying to firstly get the project by giving a good estimate and secondly trying to finish the project in this timeframe, UI tests might seem like a luxury. For projects that we are supporting, where the teams have more time for working on the project health, and where the client is willing, UI tests can be a nice addition and used as another tool to be used for maintaining project health.
186 |
187 | What I think that might taken into consideration in future is that, there can be a workflow where our QA engineers write the UI tests for the projects, similar to automation tests on Web(or probably the same?). Couple of arguments for this is, one: UITests are blackbox tests, where the writer of the tests can find related elements either by static texts or the accessibility identifiers, and where the only tools are Xcode UITest tools and the debugger. Second argument is, this would give our QA engineers a deeper insight and understanding about our development tools and more technical knowledge. But it would require a lot of training probably, to find the views, to put the identifiers, since as it is it was hard for me to get around some problems, and by nature of my position I have more knowledge on the platform then a team member who is a QA engineer.
188 |
189 | But this is only taking into consideration the tools that Xcode gives us. We might be able to find an alternative(there are already some that seem promising like iOS Snapshot Test Case and EarlGrey) that is easier to use and let's us do faster implementation with less time wasted on querying the UI elements and on other XCUITest framework weirdness. And at that point it might be plausible to give this responsibility to the QA team.
190 |
--------------------------------------------------------------------------------
/9-security.md:
--------------------------------------------------------------------------------
1 | # Security
2 |
3 | Most users trust sensitive data to our applications and our goal is to protect that data.
4 |
5 | ## iOS Secure Coding Practices Checklist
6 |
7 | - [1. Minimize amount of data](#1-minimize-amount-of-data)
8 | - [2. Always encrypt sensitive data](#2-always-encrypt-sensitive-data)
9 | - [2.1. Use Keychain](#21-use-keychain)
10 | - [2.2. Use iOS file encryption](#22-use-ios-file-encryption)
11 | - [2.3. Encrypt stored data with a symmetric key](#23-encrypt-stored-data-with-a-symmetric-key)
12 | - [3. Keep your connection secure](#3-keep-your-connection-secure)
13 | - [3.1. Do not disable App Transport Security](#31-do-not-disable-app-transport-security)
14 | - [3.2. Use SSL pinning](#32-use-ssl-pinning)
15 | - [4. Make your auth flow seamless](#4-make-your-auth-flow-seamless)
16 | - [4.1. Keep your access token lifetime reasonable](#41-keep-your-access-token-lifetime-reasonable)
17 | - [4.2. Use Password AutoFill in all sign-up and sign-in flows](#42-use-password-autofill-in-all-sign-up-and-sign-in-flows)
18 | - [4.3. Whenever possible, support biometric authentication](#43-whenever-possible-support-biometric-authentication)
19 | - [4.4. Use ASWebAuthenticationSession for OAuth](#44-use-aswebauthenticationsession-for-oauth)
20 | - [5. Do not store keys in the code](#5-do-not-store-keys-in-the-code)
21 | - [6. Validate custom URLs](#6-validate-custom-urls)
22 | - [7. Care about your dependencies](#7-care-about-your-dependencies)
23 | - [7.1. Minimize permissions](#71-minimize-permissions)
24 | - [7.2. Monitor your dependencies](#72-monitor-your-dependencies)
25 | - [8. Avoid showing sensitive data on screenshots](#8-avoid-showing-sensitive-data-on-screenshots)
26 | - [9. Do not log sensitive data](#9-do-not-log-sensitive-data)
27 |
28 | ### 1. Minimize amount of data
29 |
30 | - If the API responds with excessive responses containing a lot of unnecessary fields, talk to your backend team and ask them to return only required fields.
31 |
32 | - Don't store redundant data.
33 |
34 | ### 2. Always encrypt sensitive data
35 |
36 | Whenever you want to store a single `String` value or you would like to persist megabytes in CoreData you should never store it in plain text.
37 |
38 | #### 2.1. Use [Keychain](https://developer.apple.com/documentation/security/keychain_services)
39 |
40 | _Be careful with Keychain though. The items stored in the Keychain won't be removed when an application is uninstalled. The recommended approach is to wipe all Keychain data associated with an application when an app is first launched after installation._
41 |
42 | #### 2.2. Use [iOS file encryption](https://developer.apple.com/documentation/uikit/core_app/protecting_the_user_s_privacy/encrypting_your_app_s_files)
43 |
44 | On iOS 8 and above, files inside apps can be automatically encrypted using `NSFileProtection` whenever the device is locked.
45 |
46 | ```swift
47 | do {
48 | try data.write(to: fileURL, options: .completeFileProtection)
49 | }
50 | ```
51 |
52 | #### 2.3. Encrypt stored data with a symmetric key
53 |
54 | - User Defaults
55 | - Files
56 | - Database cells
57 |
58 | Instead of writing plain text let's encrypt it! For example using [Themis](https://github.com/cossacklabs/themis/wiki/Swift-Howto):
59 |
60 | ```swift
61 | let cellSeal = TSCellSeal(key: masterKeyData)!
62 | let encryptedMessage = try! cellSeal.wrap("message".data(using: .utf8)!, context: nil)
63 | userDefaults.set(encryptedMessage, forKey: "phoneNumber")
64 | ```
65 |
66 | Or Realm built-in encryption:
67 |
68 | ```swift
69 | // Generate a random encryption key
70 | var key = Data(count: 64)
71 | _ = key.withUnsafeMutableBytes { bytes in
72 | SecRandomCopyBytes(kSecRandomDefault, 64, bytes)
73 | }
74 |
75 | // Open the encrypted Realm file
76 | let realm = try! Realm(configuration: Realm.Configuration(encryptionKey: key))
77 | // Use the Realm as normal
78 | let dogs = realm.objects(Dog.self).filter("name contains 'Fido'")
79 | ```
80 |
81 | ### 3. Keep your connection secure
82 |
83 | #### 3.1. Do not disable App Transport Security
84 |
85 | - Do not allow insecure communication over HTTP.
86 | - Do not disable ATS by setting `NSAllowsArbitraryLoads`.
87 | - If you really need to allow insecure communication with particular servers, use `NSExceptionDomains`:
88 |
89 | ```xml
90 | NSAppTransportSecurity
91 |
92 | NSExceptionDomains
93 |
94 | appanalytics.company.com
95 |
96 | NSExceptionAllowsInsecureHTTPLoads
97 |
98 |
99 |
100 |
101 | ```
102 |
103 | #### 3.2. Use [SSL pinning](https://www.owasp.org/index.php/Pinning_Cheat_Sheet)
104 |
105 | Alamofire (and thus Moya) supports SSL pinning with a few lines of code:
106 |
107 | ```swift
108 | let policies: [String: ServerTrustPolicy] = [
109 | "test.example.com": ServerTrustPolicy.pinPublicKeys(
110 | publicKeys: ServerTrustPolicy.publicKeys(),
111 | validateCertificateChain: true,
112 | validateHost: true
113 | )
114 | ]
115 |
116 | let sessionManager = SessionManager(
117 | serverTrustPolicyManager: ServerTrustPolicyManager(policies: policies)
118 | )
119 |
120 | let provider = MoyaProvider(manager: sessionManager)
121 | ```
122 |
123 | ### 4. Make your auth flow seamless
124 |
125 | #### 4.1. Keep your access token lifetime reasonable
126 |
127 | Make it shorter for applications which process a significant amount of sensitive data. For example, reasonable session length for a banking app is around 15 minutes.
128 |
129 | #### 4.2. Use [Password AutoFill](https://developer.apple.com/documentation/security/password_autofill/) in all sign-up and sign-in flows
130 |
131 | #### 4.3. Whenever possible, support [biometric authentication](https://developer.apple.com/documentation/localauthentication)
132 |
133 | #### 4.4. Use [ASWebAuthenticationSession](https://developer.apple.com/documentation/authenticationservices/aswebauthenticationsession) for OAuth
134 |
135 | ### 5. Do not store keys in the code
136 |
137 | ```swift
138 | ⚠️ ⚠️ ⚠️
139 | let apiKey = "f2801f1b9fd1"
140 | let appId = "12312321"
141 | ```
142 |
143 | Such keys can be easily parsed from the binary file by running `strings MyApp`.
144 |
145 | It's better to obfuscate keys and secrets like described [here](https://medium.com/swift2go/increase-the-security-of-your-ios-app-by-obfuscating-sensitive-strings-swift-c915896711e6).
146 |
147 | ### 6. Validate custom URLs
148 |
149 | For applications that support deep links, it's recommended to thoroughly validate the URL and prompt user before triggering any important action.
150 |
151 |
152 | Vulnerability Exploit Example
153 |
154 | One example is the following bug in the Skype Mobile app, discovered in 2010: The Skype app registered the skype:// protocol handler, which allowed other apps to trigger calls to other Skype users and phone numbers. Unfortunately, Skype didn't ask users for permission before placing the calls, so any app could call arbitrary numbers without the user's knowledge.
155 |
156 | Attackers exploited this vulnerability by putting an invisible `` (where xxx was replaced by a premium number), so any Skype user who inadvertently visited a malicious website called the premium number.
157 |
158 |
159 |
160 | ### 7. Care about your dependencies
161 |
162 | #### 7.1. Minimize permissions
163 |
164 | Dependencies included in your application have access to the same data as your app does. This means that allowing access to the camera makes it possible for the 3rd party libraries to access both iPhone cameras any time the app is running. More on that in [Felix's blog](https://krausefx.com/blog/ios-privacy-watchuser-access-both-iphone-cameras-any-time-your-app-is-running).
165 |
166 | #### 7.2. Monitor your dependencies
167 |
168 | Monitor each change to your dependency manager lockfile. [Even minor change can introduce major security risks.](https://blog.npmjs.org/post/180565383195/details-about-the-event-stream-incident)
169 |
170 | **Each dependency is your responsibility.**
171 |
172 | ### 8. Avoid showing sensitive data on screenshots
173 |
174 | iOS has the concept of saving a screenshot when the application goes into the background. This feature can pose a security risk because screenshots (which may display sensitive information such as an email or corporate documents) are written to local storage, where they can be recovered by a rogue application with a sandbox bypass exploit or someone who steals the device.
175 |
176 | To avoid such risks it's recommended to clean up user interface when an app goes to the background:
177 |
178 | ```swift
179 | func applicationDidEnterBackground(_ application: UIApplication) {
180 | let overlay = OverlayView()
181 | window?.addSubview(overlay)
182 | }
183 | ```
184 |
185 | 
186 |
187 | ### 9. Do not log sensitive data
188 |
189 | If an application is logging sensitive information, then its data will be captured on device logs. An attacker can easily dump device logs and retrieve the user's sensitive information.
190 |
191 | Do not pass any sensitive information to the third party platforms like Mixpanel, Amplitude or Crashlytics.
192 |
193 | ---
194 |
195 | Further Reading:
196 |
197 | - https://www.apple.com/business/site/docs/iOS_Security_Guide.pdf
198 | - https://github.com/OWASP/owasp-mstg#ios-testing-guide
199 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Uptech iOS Cookbook
2 | [](https://stand-with-ukraine.pp.ua)
3 |
4 | We're international mobile and web development company that helps develop ideas from napkin sketches to full-fledged products. Combining our creativity, domain expertise, and product development approach, we deliver products that matter to users and businesses.
5 |
6 | This Cookbook compiles best practices and proven recipes for building exceptional products based on the collective experience of our iOS team.
7 |
8 | ### Table of Contents
9 |
10 | - [Git flow](https://github.com/uptechteam/ios-cookbook/blob/master/1-git-flow.md)
11 | - [Code Signing](https://github.com/uptechteam/ios-cookbook/blob/master/2-code-signing.md)
12 | - [CI/CD](https://github.com/uptechteam/ios-cookbook/blob/master/3-ci.md)
13 | - [Architectural Principles](https://github.com/uptechteam/ios-cookbook/blob/master/4-architecture.md)
14 | - [Redux](https://github.com/uptechteam/ios-cookbook/blob/master/4-1-redux.md)
15 | - [Tests](https://github.com/uptechteam/ios-cookbook/blob/master/5-tests.md)
16 | - [How to make Estimations](https://github.com/uptechteam/ios-cookbook/blob/master/6-estimates.md)
17 | - [Code Style](https://github.com/uptechteam/ios-cookbook/blob/master/7-code-style.md)
18 | - [UIKit In Code](https://github.com/uptechteam/ios-cookbook/blob/master/7-1-ui-in-code.md)
19 | - [SwiftUI](https://github.com/uptechteam/ios-cookbook/blob/master/7-2-swiftui.md)
20 | - [UI Tests](https://github.com/uptechteam/ios-cookbook/blob/master/8-ui-tests.md)
21 | - [Security](https://github.com/uptechteam/ios-cookbook/blob/master/9-security.md)
22 | - [Debugging](https://github.com/uptechteam/ios-cookbook/blob/master/10-debugging.md)
23 |
--------------------------------------------------------------------------------
/examples/ReTweet/.gitignore:
--------------------------------------------------------------------------------
1 | Carthage/Checkouts
2 | Carthage/Build
3 |
4 | fastlane/test_output
5 | fastlane/report.xml
6 |
7 | Strimmerz/Vendor/VXG/license/license
8 | StrimmerzSnapshotTests/FailureDiffs
9 | ### macOS ###
10 | *.DS_Store
11 | .AppleDouble
12 | .LSOverride
13 |
14 | # Icon must end with two \r
15 | Icon
16 |
17 | # Thumbnails
18 | ._*
19 |
20 | # Files that might appear in the root of a volume
21 | .DocumentRevisions-V100
22 | .fseventsd
23 | .Spotlight-V100
24 | .TemporaryItems
25 | .Trashes
26 | .VolumeIcon.icns
27 | .com.apple.timemachine.donotpresent
28 |
29 | # Directories potentially created on remote AFP share
30 | .AppleDB
31 | .AppleDesktop
32 | Network Trash Folder
33 | Temporary Items
34 | .apdisk
35 |
36 | ### Xcode ###
37 | # Xcode
38 | #
39 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
40 |
41 | ## Build generated
42 | build/
43 | DerivedData/
44 |
45 | ## Various settings
46 | *.pbxuser
47 | !default.pbxuser
48 | *.mode1v3
49 | !default.mode1v3
50 | *.mode2v3
51 | !default.mode2v3
52 | *.perspectivev3
53 | !default.perspectivev3
54 | xcuserdata/
55 |
56 | ## Other
57 | *.moved-aside
58 | *.xccheckout
59 | *.xcscmblueprint
60 |
61 | ### Xcode Patch ###
62 | *.xcodeproj/*
63 | !*.xcodeproj/project.pbxproj
64 | !*.xcodeproj/xcshareddata/
65 | !*.xcworkspace/contents.xcworkspacedata
66 | /*.gcno
67 |
68 |
69 | # Created by https://www.gitignore.io/api/node
70 |
71 | ### Node ###
72 | # Logs
73 | logs
74 | *.log
75 | npm-debug.log*
76 | yarn-debug.log*
77 | yarn-error.log*
78 |
79 | # Runtime data
80 | pids
81 | *.pid
82 | *.seed
83 | *.pid.lock
84 |
85 | # Directory for instrumented libs generated by jscoverage/JSCover
86 | lib-cov
87 |
88 | # Coverage directory used by tools like istanbul
89 | coverage
90 |
91 | # nyc test coverage
92 | .nyc_output
93 |
94 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
95 | .grunt
96 |
97 | # Bower dependency directory (https://bower.io/)
98 | bower_components
99 |
100 | # node-waf configuration
101 | .lock-wscript
102 |
103 | # Compiled binary addons (https://nodejs.org/api/addons.html)
104 | build/Release
105 |
106 | # Dependency directories
107 | node_modules/
108 | jspm_packages/
109 |
110 | # TypeScript v1 declaration files
111 | typings/
112 |
113 | # Optional npm cache directory
114 | .npm
115 |
116 | # Optional eslint cache
117 | .eslintcache
118 |
119 | # Optional REPL history
120 | .node_repl_history
121 |
122 | # Output of 'npm pack'
123 | *.tgz
124 |
125 | # Yarn Integrity file
126 | .yarn-integrity
127 |
128 | # dotenv environment variables file
129 | .env
130 |
131 | # parcel-bundler cache (https://parceljs.org/)
132 | .cache
133 |
134 | # next.js build output
135 | .next
136 |
137 | # nuxt.js build output
138 | .nuxt
139 |
140 | # vuepress build output
141 | .vuepress/dist
142 |
143 | # Serverless directories
144 | .serverless
145 |
146 |
147 | # End of https://www.gitignore.io/api/node
148 |
--------------------------------------------------------------------------------
/examples/ReTweet/Cartfile:
--------------------------------------------------------------------------------
1 | github "ReactiveX/RxSwift" ~> 4.3
2 | github "RxSwiftCommunity/RxDataSources" ~> 3.1
3 | github "Moya/Moya"
4 | github "AliSoftware/Dip"
5 | github "onevcat/Kingfisher" ~> 4.0
6 |
--------------------------------------------------------------------------------
/examples/ReTweet/Cartfile.resolved:
--------------------------------------------------------------------------------
1 | github "Alamofire/Alamofire" "4.7.3"
2 | github "AliSoftware/Dip" "7.0.0"
3 | github "Moya/Moya" "11.0.2"
4 | github "ReactiveCocoa/ReactiveSwift" "3.1.0"
5 | github "ReactiveX/RxSwift" "4.3.1"
6 | github "RxSwiftCommunity/RxDataSources" "3.1.0"
7 | github "antitypical/Result" "3.2.4"
8 | github "onevcat/Kingfisher" "4.10.0"
9 |
--------------------------------------------------------------------------------
/examples/ReTweet/ReTweet/AppDelegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppDelegate.swift
3 | // ReTweet
4 | //
5 | // Created by Arthur Myronenko on 10/11/18.
6 | // Copyright © 2018 Arthur Mironenko. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import RxSwift
11 | import Moya
12 |
13 | @UIApplicationMain
14 | class AppDelegate: UIResponder, UIApplicationDelegate {
15 |
16 | var window: UIWindow?
17 |
18 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
19 |
20 | let window = UIWindow()
21 | self.window = window
22 |
23 | let user = User(username: "Arthur")
24 | let twitterService = TwitterService(
25 | user: user,
26 | provider: MoyaProvider(plugins: [NetworkLoggerPlugin(verbose: true, cURL: true)])
27 | )
28 |
29 | let timelineViewController = TimelineViewController(user: user, twitterService: twitterService)
30 | let timelineNavigationController = UINavigationController(rootViewController: timelineViewController)
31 | window.rootViewController = timelineNavigationController
32 | window.makeKeyAndVisible()
33 |
34 | return true
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/examples/ReTweet/ReTweet/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" : "ipad",
45 | "size" : "20x20",
46 | "scale" : "1x"
47 | },
48 | {
49 | "idiom" : "ipad",
50 | "size" : "20x20",
51 | "scale" : "2x"
52 | },
53 | {
54 | "idiom" : "ipad",
55 | "size" : "29x29",
56 | "scale" : "1x"
57 | },
58 | {
59 | "idiom" : "ipad",
60 | "size" : "29x29",
61 | "scale" : "2x"
62 | },
63 | {
64 | "idiom" : "ipad",
65 | "size" : "40x40",
66 | "scale" : "1x"
67 | },
68 | {
69 | "idiom" : "ipad",
70 | "size" : "40x40",
71 | "scale" : "2x"
72 | },
73 | {
74 | "idiom" : "ipad",
75 | "size" : "76x76",
76 | "scale" : "1x"
77 | },
78 | {
79 | "idiom" : "ipad",
80 | "size" : "76x76",
81 | "scale" : "2x"
82 | },
83 | {
84 | "idiom" : "ipad",
85 | "size" : "83.5x83.5",
86 | "scale" : "2x"
87 | },
88 | {
89 | "idiom" : "ios-marketing",
90 | "size" : "1024x1024",
91 | "scale" : "1x"
92 | }
93 | ],
94 | "info" : {
95 | "version" : 1,
96 | "author" : "xcode"
97 | }
98 | }
--------------------------------------------------------------------------------
/examples/ReTweet/ReTweet/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "version" : 1,
4 | "author" : "xcode"
5 | }
6 | }
--------------------------------------------------------------------------------
/examples/ReTweet/ReTweet/Assets.xcassets/placeholder.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "filename" : "group2.png",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "filename" : "group2@2x.png",
11 | "scale" : "2x"
12 | },
13 | {
14 | "idiom" : "universal",
15 | "filename" : "group2@3x.png",
16 | "scale" : "3x"
17 | }
18 | ],
19 | "info" : {
20 | "version" : 1,
21 | "author" : "xcode"
22 | }
23 | }
--------------------------------------------------------------------------------
/examples/ReTweet/ReTweet/Assets.xcassets/placeholder.imageset/group2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/uptechteam/ios-cookbook/b5d1dca20f96d0c6023f97b23ed93b7fe2700709/examples/ReTweet/ReTweet/Assets.xcassets/placeholder.imageset/group2.png
--------------------------------------------------------------------------------
/examples/ReTweet/ReTweet/Assets.xcassets/placeholder.imageset/group2@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/uptechteam/ios-cookbook/b5d1dca20f96d0c6023f97b23ed93b7fe2700709/examples/ReTweet/ReTweet/Assets.xcassets/placeholder.imageset/group2@2x.png
--------------------------------------------------------------------------------
/examples/ReTweet/ReTweet/Assets.xcassets/placeholder.imageset/group2@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/uptechteam/ios-cookbook/b5d1dca20f96d0c6023f97b23ed93b7fe2700709/examples/ReTweet/ReTweet/Assets.xcassets/placeholder.imageset/group2@3x.png
--------------------------------------------------------------------------------
/examples/ReTweet/ReTweet/Base.lproj/LaunchScreen.storyboard:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/examples/ReTweet/ReTweet/Features/ComposeTweet/ComposeTweetContentView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ComposeTweetView.swift
3 | // ReTweet
4 | //
5 | // Created by Arthur Myronenko on 10/11/18.
6 | // Copyright © 2018 Arthur Mironenko. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | final class ComposeTweetContentView: UIView, NibInitializable {
12 |
13 | @IBOutlet weak var headerLabel: UILabel!
14 | @IBOutlet weak var tweetTextView: UITextView!
15 |
16 | override func awakeFromNib() {
17 | super.awakeFromNib()
18 | setup()
19 | }
20 |
21 | private func setup() {
22 | tweetTextView.textContainerInset = .zero
23 | tweetTextView.textContainer.lineFragmentPadding = 0
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/examples/ReTweet/ReTweet/Features/ComposeTweet/ComposeTweetContentView.xib:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
--------------------------------------------------------------------------------
/examples/ReTweet/ReTweet/Features/ComposeTweet/ComposeTweetViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ComposeTweetViewController.swift
3 | // ReTweet
4 | //
5 | // Created by Arthur Myronenko on 10/11/18.
6 | // Copyright © 2018 Arthur Mironenko. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import RxSwift
11 | import RxCocoa
12 |
13 | final class ComposeTweetViewController: UIViewController {
14 |
15 | fileprivate let discardButton = UIBarButtonItem(barButtonSystemItem: .cancel, target: nil, action: nil)
16 | fileprivate let sendButton = UIBarButtonItem(barButtonSystemItem: .done, target: nil, action: nil)
17 | fileprivate lazy var contentView = ComposeTweetContentView.initFromNib()
18 |
19 | let disposeBag = DisposeBag()
20 |
21 | init() {
22 | super.init(nibName: nil, bundle: nil)
23 | setup()
24 | }
25 |
26 | required init?(coder aDecoder: NSCoder) {
27 | fatalError("init(coder:) has not been implemented")
28 | }
29 |
30 | override func loadView() {
31 | view = contentView
32 | }
33 |
34 | override func viewDidLoad() {
35 | super.viewDidLoad()
36 | setup()
37 | }
38 |
39 | override func viewWillAppear(_ animated: Bool) {
40 | super.viewWillAppear(animated)
41 | contentView.tweetTextView.becomeFirstResponder()
42 | }
43 |
44 | private func setup() {
45 | setupUI()
46 | }
47 |
48 | private func setupUI() {
49 | title = "Compose Tweet"
50 | navigationItem.leftBarButtonItem = discardButton
51 | navigationItem.rightBarButtonItem = sendButton
52 | }
53 | }
54 |
55 | extension Reactive where Base: ComposeTweetViewController {
56 | var didDiscard: Observable {
57 | return base.discardButton.rx.tap.asObservable()
58 | }
59 |
60 | var didSubmit: Observable {
61 | let tweetText = base.contentView.tweetTextView.rx.text
62 | .asObservable()
63 | .map { $0 ?? "" }
64 |
65 | return base.sendButton.rx.tap
66 | .withLatestFrom(tweetText)
67 | .map(ComposedTweet.init)
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/examples/ReTweet/ReTweet/Features/Timeline/TimelineNamespace.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TimelineNamespace.swift
3 | // ReTweet
4 | //
5 | // Created by Arthur Myronenko on 10/12/18.
6 | // Copyright © 2018 Arthur Mironenko. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | enum Timeline { }
12 |
--------------------------------------------------------------------------------
/examples/ReTweet/ReTweet/Features/Timeline/View/Cells/PendingTweet/TimelinePendingTweetCell.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TimelinePendingTweetCell.swift
3 | // ReTweet
4 | //
5 | // Created by Arthur Myronenko on 10/11/18.
6 | // Copyright © 2018 Arthur Mironenko. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import RxSwift
11 | import RxCocoa
12 |
13 | final class TimelinePendingTweetCell: UITableViewCell, NibInitializable, ReusableCell {
14 |
15 | struct Props: Equatable {
16 | enum Status: Equatable {
17 | case sending
18 | case error
19 | }
20 |
21 | let username: String
22 | let time: String
23 | let tweet: String
24 | let status: Status
25 | }
26 |
27 | @IBOutlet private weak var avatarImageView: UIImageView!
28 | @IBOutlet private weak var usernameLabel: UILabel!
29 | @IBOutlet private weak var timeLabel: UILabel!
30 | @IBOutlet private weak var tweetLabel: UILabel!
31 | @IBOutlet private weak var loadingIndicator: UIActivityIndicatorView!
32 | @IBOutlet fileprivate weak var resendButton: UIButton!
33 |
34 | var disposeOnReuseBag = DisposeBag()
35 |
36 | override func prepareForReuse() {
37 | super.prepareForReuse()
38 | disposeOnReuseBag = DisposeBag()
39 | avatarImageView.kf.cancelDownloadTask()
40 | }
41 |
42 | func render(props: Props) {
43 | usernameLabel.text = props.username
44 | timeLabel.text = props.time
45 | tweetLabel.text = props.tweet
46 | setStatus(props.status)
47 | }
48 |
49 | private func setStatus(_ status: Props.Status) {
50 | switch status {
51 | case .error:
52 | loadingIndicator.stopAnimating()
53 | resendButton.isHidden = false
54 |
55 | case .sending:
56 | loadingIndicator.startAnimating()
57 | resendButton.isHidden = true
58 | }
59 | }
60 | }
61 |
62 | extension Reactive where Base: TimelinePendingTweetCell {
63 | var resendTap: Observable {
64 | return base.resendButton.rx.tap.asObservable()
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/examples/ReTweet/ReTweet/Features/Timeline/View/Cells/PendingTweet/TimelinePendingTweetCell.xib:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
39 |
45 |
51 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
--------------------------------------------------------------------------------
/examples/ReTweet/ReTweet/Features/Timeline/View/Cells/Tweet/TimelineTweetCell.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TimelineTweetCell.swift
3 | // ReTweet
4 | //
5 | // Created by Arthur Myronenko on 10/11/18.
6 | // Copyright © 2018 Arthur Mironenko. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import Kingfisher
11 |
12 | final class TimelineTweetCell: UITableViewCell, NibInitializable, ReusableCell {
13 |
14 | struct Props: Equatable {
15 | let username: String
16 | let time: String
17 | let avatarURL: URL?
18 | let tweet: String
19 | let likesAmount: Int
20 | }
21 |
22 | @IBOutlet private weak var usernameLabel: UILabel!
23 | @IBOutlet private weak var timeLabel: UILabel!
24 | @IBOutlet private weak var tweetLabel: UILabel!
25 | @IBOutlet private weak var avatarImageView: UIImageView!
26 | @IBOutlet private weak var likesLabel: UILabel!
27 |
28 | override func prepareForReuse() {
29 | super.prepareForReuse()
30 | avatarImageView.kf.cancelDownloadTask()
31 | }
32 |
33 | func render(props: Props) {
34 | usernameLabel.text = props.username
35 | timeLabel.text = props.time
36 | tweetLabel.text = props.tweet
37 | avatarImageView.kf.setImage(with: props.avatarURL)
38 | setLikes(props.likesAmount)
39 | }
40 |
41 | private func setLikes(_ likesAmount: Int) {
42 | likesLabel.text = "♥ \(likesAmount)"
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/examples/ReTweet/ReTweet/Features/Timeline/View/Cells/Tweet/TimelineTweetCell.xib:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
39 |
45 |
51 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
--------------------------------------------------------------------------------
/examples/ReTweet/ReTweet/Features/Timeline/View/TimelineContentView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TimelineContentView.swift
3 | // ReTweet
4 | //
5 | // Created by Arthur Myronenko on 10/11/18.
6 | // Copyright © 2018 Arthur Mironenko. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import RxSwift
11 | import RxCocoa
12 | import RxDataSources
13 |
14 | final class TimelineContentView: UIView {
15 |
16 | fileprivate typealias DataSource = RxTableViewSectionedReloadDataSource>
17 |
18 | private let tableView = UITableView()
19 | fileprivate let refreshControl = UIRefreshControl()
20 | private let dataSource: DataSource
21 |
22 | fileprivate let resendTap: Observable
23 | private let items = PublishSubject<[TimelineViewController.Props.Item]>()
24 | private let disposeBag = DisposeBag()
25 |
26 | override init(frame: CGRect) {
27 | (self.dataSource, self.resendTap) = makeDataSource()
28 | super.init(frame: frame)
29 | setup()
30 | }
31 |
32 | required init?(coder aDecoder: NSCoder) {
33 | fatalError()
34 | }
35 |
36 | private func setup() {
37 | tableView.addSubview(refreshControl)
38 | tableView.register(TimelineTweetCell.self)
39 | tableView.register(TimelinePendingTweetCell.self)
40 | tableView.rowHeight = UITableView.automaticDimension
41 | tableView.estimatedRowHeight = 170
42 |
43 | addSubview(tableView)
44 | tableView.translatesAutoresizingMaskIntoConstraints = false
45 | NSLayoutConstraint.activate([
46 | tableView.leftAnchor.constraint(equalTo: leftAnchor),
47 | tableView.topAnchor.constraint(equalTo: topAnchor),
48 | tableView.rightAnchor.constraint(equalTo: rightAnchor),
49 | tableView.bottomAnchor.constraint(equalTo: bottomAnchor)
50 | ])
51 |
52 | items
53 | .map { [SectionModel(model: Void(), items: $0)] }
54 | .bind(to: tableView.rx.items(dataSource: dataSource))
55 | .disposed(by: disposeBag)
56 | }
57 |
58 | func setItems(_ items: [TimelineViewController.Props.Item]) {
59 | self.items.onNext(items)
60 | }
61 |
62 | func toggleLoading(on: Bool) {
63 | if on {
64 | refreshControl.beginRefreshing()
65 | } else {
66 | refreshControl.endRefreshing()
67 | }
68 | }
69 | }
70 |
71 | private func makeDataSource() -> (dataSource: TimelineContentView.DataSource, resendTap: Observable) {
72 | let resendTap = PublishSubject()
73 | let dataSource = TimelineContentView.DataSource(configureCell: { (dataSource, tableView, indexPath, item) -> UITableViewCell in
74 | switch item {
75 | case .tweet(let props):
76 | let cell: TimelineTweetCell = tableView.dequeueReusableCell(for: indexPath)
77 | cell.render(props: props)
78 | return cell
79 |
80 | case .pendingTweet(let props):
81 | let cell: TimelinePendingTweetCell = tableView.dequeueReusableCell(for: indexPath)
82 | cell.render(props: props)
83 |
84 | cell.rx.resendTap
85 | .subscribe(onNext: { resendTap.onNext(indexPath.row) })
86 | .disposed(by: cell.disposeOnReuseBag)
87 |
88 | return cell
89 | }
90 | })
91 |
92 | return (dataSource, resendTap.asObservable())
93 | }
94 |
95 | extension Reactive where Base: TimelineContentView {
96 | var resendButtonTap: Observable {
97 | return base.resendTap
98 | }
99 |
100 | var pullToRefresh: Observable {
101 | return base.refreshControl.rx.controlEvent(.valueChanged).asObservable()
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/examples/ReTweet/ReTweet/Features/Timeline/View/TimelineViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TimelineViewController.swift
3 | // ReTweet
4 | //
5 | // Created by Arthur Myronenko on 10/11/18.
6 | // Copyright © 2018 Arthur Mironenko. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import RxSwift
11 | import RxCocoa
12 |
13 | final class TimelineViewController: UIViewController {
14 |
15 | struct Props {
16 | enum Item: Equatable {
17 | case tweet(TimelineTweetCell.Props)
18 | case pendingTweet(TimelinePendingTweetCell.Props)
19 | }
20 |
21 | let items: [Item]
22 | let isLoading: Bool
23 | let error: String?
24 | }
25 |
26 | private let newTweetButton = UIBarButtonItem(barButtonSystemItem: .compose, target: nil, action: nil)
27 | private lazy var contentView = TimelineContentView()
28 | private let errorPresenter = ErrorPresenter()
29 |
30 | private let postTweetSubject = PublishSubject()
31 |
32 | private let viewModel: Timeline.ViewModel
33 | private let disposeBag = DisposeBag()
34 | private var renderedProps: Props?
35 |
36 | init(user: User, twitterService: TwitterService) {
37 | self.viewModel = Timeline.ViewModel(user: user, twitterService: twitterService)
38 | super.init(nibName: nil, bundle: nil)
39 | }
40 |
41 | required init?(coder aDecoder: NSCoder) {
42 | fatalError("init(coder:) has not been implemented")
43 | }
44 |
45 | override func loadView() {
46 | view = contentView
47 | }
48 |
49 | override func viewDidLoad() {
50 | super.viewDidLoad()
51 | setup()
52 | }
53 |
54 | private func setup() {
55 | setupUI()
56 | setupBindings()
57 | }
58 |
59 | private func setupUI() {
60 | title = "Timeline"
61 | navigationItem.rightBarButtonItem = newTweetButton
62 | }
63 |
64 | private func setupBindings() {
65 | let inputs = Timeline.ViewModel.Inputs(
66 | viewWillAppear: rx.methodInvoked(#selector(viewWillAppear(_:))).map({ _ in Void() }),
67 | pullToRefresh: contentView.rx.pullToRefresh,
68 | newTweetButtonTap: newTweetButton.rx.tap.asObservable(),
69 | postTweet: postTweetSubject.asObservable(),
70 | resendTweet: contentView.rx.resendButtonTap,
71 | dismissError: errorPresenter.dismissed
72 | )
73 |
74 | let outputs = viewModel.makeOutputs(from: inputs)
75 |
76 | outputs.props
77 | .observeOn(MainScheduler.instance)
78 | .subscribe(onNext: { [unowned self] in self.render(props: $0) })
79 | .disposed(by: disposeBag)
80 |
81 | outputs.route
82 | .observeOn(MainScheduler.instance)
83 | .subscribe(onNext: { [unowned self] in self.navigate(by: $0) })
84 | .disposed(by: disposeBag)
85 |
86 | outputs.stateChanges
87 | .subscribe()
88 | .disposed(by: disposeBag)
89 | }
90 |
91 | private func render(props: Props) {
92 | if renderedProps?.items != props.items {
93 | contentView.setItems(props.items)
94 | }
95 |
96 | if renderedProps?.isLoading != props.isLoading {
97 | contentView.toggleLoading(on: props.isLoading)
98 | }
99 |
100 | if let error = props.error, renderedProps?.error != error {
101 | errorPresenter.present(error: error, on: self)
102 | }
103 |
104 | renderedProps = props
105 | }
106 |
107 | private func navigate(by route: Timeline.Route) {
108 | switch route {
109 | case .newTweet:
110 | showComposeTweet()
111 | }
112 | }
113 |
114 | private func showComposeTweet() {
115 | let composeViewController = ComposeTweetViewController()
116 | let composeNavigationController = UINavigationController(rootViewController: composeViewController)
117 | navigationController?.present(composeNavigationController, animated: true, completion: nil)
118 |
119 | composeViewController.rx.didDiscard
120 | .subscribe(onNext: { [weak self] in self?.navigationController?.dismiss(animated: true) })
121 | .disposed(by: composeViewController.disposeBag)
122 |
123 | composeViewController.rx.didSubmit
124 | .subscribe(onNext: { [weak self] submittedTweet in
125 | self?.postTweetSubject.onNext(submittedTweet)
126 | self?.navigationController?.dismiss(animated: true)
127 | })
128 | .disposed(by: composeViewController.disposeBag)
129 | }
130 | }
131 |
132 |
--------------------------------------------------------------------------------
/examples/ReTweet/ReTweet/Features/Timeline/ViewModel/Middleware/TimelineResendMiddleware.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TimelineResendMiddleware.swift
3 | // ReTweet
4 | //
5 | // Created by Arthur Myronenko on 10/18/18.
6 | // Copyright © 2018 Arthur Mironenko. All rights reserved.
7 | //
8 |
9 | import RxSwift
10 |
11 | extension Timeline {
12 | static func makeResendMiddleware(twitterService: TwitterService) -> Store.Middleware {
13 | let disposeBag = DisposeBag()
14 | return Store.makeMiddleware { dispatch, getState, next, action in
15 | next(action)
16 | let state = getState()
17 |
18 | guard case Action.resendTweet(let index) = action else {
19 | return
20 | }
21 |
22 | let tweetID = state.timeline[index]
23 | guard let pendingTweet = state.allPendingTweets[tweetID] else {
24 | fatalError("STATE IS OUR OF SYNC")
25 | }
26 |
27 | twitterService.postTweet(pendingTweet.tweet)
28 | .map(Action.sendTweetSuccess)
29 | .catchError { error in Observable.just(Action.sendTweetFailure(id: tweetID, error: error)) }
30 | .subscribe(onNext: dispatch)
31 | .disposed(by: disposeBag)
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/examples/ReTweet/ReTweet/Features/Timeline/ViewModel/TimelineActionCreator.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TimelineActionCreator.swift
3 | // ReTweet
4 | //
5 | // Created by Arthur Myronenko on 10/18/18.
6 | // Copyright © 2018 Arthur Mironenko. All rights reserved.
7 | //
8 |
9 | import RxSwift
10 |
11 | extension Timeline {
12 | final class ActionCreator {
13 | let actions: Observable
14 |
15 | init(inputs: ViewModel.Inputs, twitterService: TwitterService) {
16 | let fetchTweetsActions = Observable.merge(
17 | inputs.viewWillAppear.take(1),
18 | inputs.pullToRefresh
19 | )
20 | .flatMapLatest {
21 | twitterService.getTimeline(offset: 0, limit: 100)
22 | .map(Action.loadTweetsSuccess)
23 | .catchError { error in Observable.just(Action.loadTweetsFailure(error)) }
24 | .startWith(Action.loadTweets)
25 | }
26 |
27 | let postTweetActions = inputs.postTweet
28 | .flatMap { newTweet in
29 | return twitterService.postTweet(newTweet)
30 | .map(Action.sendTweetSuccess)
31 | .catchError { error in Observable.just(Action.sendTweetFailure(id: newTweet.id, error: error)) }
32 | .startWith(Action.sendTweet(newTweet))
33 | }
34 |
35 | let resendTweetAction = inputs.resendTweet
36 | .map(Action.resendTweet)
37 |
38 | let dismissErrorAction = inputs.dismissError
39 | .map { Action.dismissError }
40 |
41 | self.actions = Observable.merge(
42 | fetchTweetsActions,
43 | postTweetActions,
44 | resendTweetAction,
45 | dismissErrorAction
46 | )
47 | }
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/examples/ReTweet/ReTweet/Features/Timeline/ViewModel/TimelineProps.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TimelineProps.swift
3 | // ReTweet
4 | //
5 | // Created by Arthur Myronenko on 10/18/18.
6 | // Copyright © 2018 Arthur Mironenko. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | extension Timeline {
12 | static func makeProps(from state: State) -> TimelineViewController.Props {
13 | return TimelineViewController.Props(
14 | items: makeItems(from: state),
15 | isLoading: state.isLoading,
16 | error: state.error?.localizedDescription
17 | )
18 | }
19 |
20 | private static func makeItems(from state: State) -> [TimelineViewController.Props.Item] {
21 | func makeTweetCellProps(from tweet: Tweet) -> TimelineTweetCell.Props {
22 | return TimelineTweetCell.Props(
23 | username: tweet.username,
24 | time: DateFormatter.shortTime.string(from: tweet.date),
25 | avatarURL: tweet.avatar,
26 | tweet: tweet.text,
27 | likesAmount: tweet.likesAmount
28 | )
29 | }
30 |
31 | func makePendingCellProps(from pendingTweet: State.PendingTweet) -> TimelinePendingTweetCell.Props {
32 | return TimelinePendingTweetCell.Props(
33 | username: state.user.username,
34 | time: DateFormatter.shortTime.string(from: pendingTweet.tweet.sentDate),
35 | tweet: pendingTweet.tweet.text,
36 | status: pendingTweet.error == nil ? .sending : .error
37 | )
38 | }
39 |
40 | return state.timeline
41 | .compactMap { tweetID in
42 | if let pendingTweet = state.allPendingTweets[tweetID] {
43 | return .pendingTweet(makePendingCellProps(from: pendingTweet))
44 | } else if let fetchedTweet = state.allFetchedTweets[tweetID] {
45 | return .tweet(makeTweetCellProps(from: fetchedTweet))
46 | } else {
47 | fatalError("State is out of sync!")
48 | }
49 | }
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/examples/ReTweet/ReTweet/Features/Timeline/ViewModel/TimelineStore.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TimelineStore.swift
3 | // ReTweet
4 | //
5 | // Created by Arthur Myronenko on 10/18/18.
6 | // Copyright © 2018 Arthur Mironenko. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | extension Timeline {
12 | typealias Store = ReduxStore
13 |
14 | struct State {
15 | struct PendingTweet {
16 | let tweet: ComposedTweet
17 | var error: Error?
18 | }
19 |
20 | let user: User
21 | var allFetchedTweets: [Tweet.Identifier: Tweet]
22 | var allPendingTweets: [Tweet.Identifier: PendingTweet]
23 | var timeline: [Tweet.Identifier]
24 | var isLoading: Bool
25 | var error: Error?
26 | }
27 |
28 | enum Action {
29 | case loadTweets
30 | case loadTweetsSuccess([Tweet])
31 | case loadTweetsFailure(Error)
32 |
33 | case sendTweet(ComposedTweet)
34 | case sendTweetSuccess(Tweet)
35 | case sendTweetFailure(id: Tweet.Identifier, error: Error)
36 |
37 | case resendTweet(index: Int)
38 | case dismissError
39 | }
40 |
41 | static func reduce(state: State, action: Action) -> State {
42 | var newState = state
43 | switch action {
44 | case .loadTweets:
45 | newState.isLoading = true
46 |
47 | case .loadTweetsSuccess(let newTweets):
48 | newTweets.forEach { newState.allFetchedTweets[$0.id] = $0 }
49 | newState.isLoading = false
50 |
51 | let timelinePendingTweets = state.allPendingTweets.values.map { (id: $0.tweet.id, date: $0.tweet.sentDate) }
52 | let timelineFetchedTweets = newTweets.map { (id: $0.id, date: $0.date) }
53 |
54 | newState.timeline = (timelinePendingTweets + timelineFetchedTweets)
55 | .sorted { $0.date > $1.date }
56 | .map { $0.id }
57 |
58 | case .loadTweetsFailure(let error):
59 | newState.error = error
60 | newState.isLoading = false
61 |
62 | case .sendTweet(let newComposedTweet):
63 | newState.allPendingTweets[newComposedTweet.id] = State.PendingTweet(tweet: newComposedTweet, error: nil)
64 | newState.timeline.insert(newComposedTweet.id, at: 0)
65 |
66 | case .sendTweetSuccess(let fetchedTweet):
67 | newState.allPendingTweets[fetchedTweet.id] = nil
68 | newState.allFetchedTweets[fetchedTweet.id] = fetchedTweet
69 |
70 | case .sendTweetFailure(let tweetID, let error):
71 | newState.allPendingTweets[tweetID]?.error = error
72 |
73 | case .resendTweet(let index):
74 | let tweetID = state.timeline[index]
75 | newState.allPendingTweets[tweetID]?.error = nil
76 |
77 | case .dismissError:
78 | newState.error = nil
79 | }
80 |
81 | return newState
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/examples/ReTweet/ReTweet/Features/Timeline/ViewModel/TimelineViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TimelineViewModel.swift
3 | // ReTweet
4 | //
5 | // Created by Arthur Myronenko on 10/12/18.
6 | // Copyright © 2018 Arthur Mironenko. All rights reserved.
7 | //
8 |
9 | import RxSwift
10 |
11 | extension Timeline {
12 |
13 | enum Route {
14 | case newTweet
15 | }
16 |
17 | final class ViewModel {
18 | struct Inputs {
19 | let viewWillAppear: Observable
20 | let pullToRefresh: Observable
21 | let newTweetButtonTap: Observable
22 | let postTweet: Observable
23 | let resendTweet: Observable
24 | let dismissError: Observable
25 | }
26 |
27 | struct Outputs {
28 | let props: Observable
29 | let route: Observable
30 | let stateChanges: Observable
31 | }
32 |
33 | private let user: User
34 | private let twitterService: TwitterService
35 |
36 | init(user: User, twitterService: TwitterService) {
37 | self.user = user
38 | self.twitterService = twitterService
39 | }
40 |
41 | func makeOutputs(from inputs: Inputs) -> Outputs {
42 | let initialState = State(
43 | user: user,
44 | allFetchedTweets: [:],
45 | allPendingTweets: [:],
46 | timeline: [],
47 | isLoading: false,
48 | error: nil
49 | )
50 | let resendMiddleware = Timeline.makeResendMiddleware(twitterService: twitterService)
51 | let store = Store(initialState: initialState, reducer: Timeline.reduce, middlewares: [
52 | resendMiddleware
53 | ])
54 |
55 | let props = store.state
56 | .map(Timeline.makeProps)
57 |
58 | let actionCreator = ActionCreator(inputs: inputs, twitterService: twitterService)
59 |
60 | let stateChanges = actionCreator.actions
61 | .do(onNext: store.dispatch)
62 | .map { _ in Void() }
63 |
64 | return Outputs(
65 | props: props,
66 | route: inputs.newTweetButtonTap.map { Route.newTweet },
67 | stateChanges: stateChanges
68 | )
69 | }
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/examples/ReTweet/ReTweet/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | NSAppTransportSecurity
12 |
13 | NSAllowsArbitraryLoads
14 |
15 |
16 | CFBundleInfoDictionaryVersion
17 | 6.0
18 | CFBundleName
19 | $(PRODUCT_NAME)
20 | CFBundlePackageType
21 | APPL
22 | CFBundleShortVersionString
23 | 1.0
24 | CFBundleURLTypes
25 |
26 |
27 | CFBundleTypeRole
28 | Editor
29 | CFBundleURLName
30 | com.arthurmironenko.ReTweet
31 | CFBundleURLSchemes
32 |
33 | redux://
34 |
35 |
36 |
37 | CFBundleVersion
38 | 1
39 | LSRequiresIPhoneOS
40 |
41 | UILaunchStoryboardName
42 | LaunchScreen
43 | UIRequiredDeviceCapabilities
44 |
45 | armv7
46 |
47 | UISupportedInterfaceOrientations
48 |
49 | UIInterfaceOrientationPortrait
50 | UIInterfaceOrientationLandscapeLeft
51 | UIInterfaceOrientationLandscapeRight
52 |
53 | UISupportedInterfaceOrientations~ipad
54 |
55 | UIInterfaceOrientationPortrait
56 | UIInterfaceOrientationPortraitUpsideDown
57 | UIInterfaceOrientationLandscapeLeft
58 | UIInterfaceOrientationLandscapeRight
59 |
60 |
61 |
62 |
--------------------------------------------------------------------------------
/examples/ReTweet/ReTweet/Library/CustomJSONDecoder.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CustomJSONDecoder.swift
3 | // ReTweet
4 | //
5 | // Created by Arthur Myronenko on 10/15/18.
6 | // Copyright © 2018 Arthur Mironenko. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | final class CustomJSONDecoder: JSONDecoder {
12 | override init() {
13 | super.init()
14 | dateDecodingStrategy = .formatted(DateFormatter.realISO)
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/examples/ReTweet/ReTweet/Library/CustomJSONEncoder.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CustomJSONEncoder.swift
3 | // ReTweet
4 | //
5 | // Created by Arthur Myronenko on 10/15/18.
6 | // Copyright © 2018 Arthur Mironenko. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | final class CustomJSONEncoder: JSONEncoder {
12 | override init() {
13 | super.init()
14 | dateEncodingStrategy = .formatted(DateFormatter.realISO)
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/examples/ReTweet/ReTweet/Library/ErrorPresenter.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ErrorPresenter.swift
3 | // ReTweet
4 | //
5 | // Created by Arthur Myronenko on 10/16/18.
6 | // Copyright © 2018 Arthur Mironenko. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import RxSwift
11 |
12 | final class ErrorPresenter {
13 | private let alertController = UIAlertController(title: "", message: "", preferredStyle: .alert)
14 | private var presentedError: String?
15 |
16 | var dismissed: Observable
17 |
18 | init() {
19 | let dismissSubject = PublishSubject()
20 |
21 | self.dismissed = dismissSubject.asObservable()
22 |
23 | let okAction = UIAlertAction(title: "OK", style: .default) { [weak self] _ in
24 | dismissSubject.onNext(Void())
25 | self?.presentedError = nil
26 | }
27 |
28 | alertController.addAction(okAction)
29 | }
30 |
31 | func present(error: String?, on viewController: UIViewController) {
32 | guard presentedError == nil else {
33 | return
34 | }
35 |
36 | presentedError = error
37 | alertController.title = "Error"
38 | alertController.message = error
39 | viewController.present(alertController, animated: true)
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/examples/ReTweet/ReTweet/Library/Formatter.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Formatter.swift
3 | // ReTweet
4 | //
5 | // Created by Arthur Myronenko on 10/15/18.
6 | // Copyright © 2018 Arthur Mironenko. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | extension DateFormatter {
12 | static let realISO: DateFormatter = {
13 | let formatter = DateFormatter()
14 | formatter.locale = Locale(identifier: "en_US_POSIX")
15 | formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"
16 | return formatter
17 | }()
18 |
19 | static let shortTime: DateFormatter = {
20 | let formatter = DateFormatter()
21 | formatter.timeStyle = .short
22 | formatter.dateStyle = .none
23 | return formatter
24 | }()
25 | }
26 |
--------------------------------------------------------------------------------
/examples/ReTweet/ReTweet/Library/NibInitializable.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NibInitializable.swift
3 | // ReTweet
4 | //
5 | // Created by Arthur Myronenko on 10/11/18.
6 | // Copyright © 2018 Arthur Mironenko. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import UIKit
11 |
12 | protocol NibInitializable {
13 | static var nibName: String { get }
14 | static var nib: UINib { get }
15 | static func initFromNib() -> Self
16 | }
17 |
18 | extension NibInitializable where Self: UIView {
19 | static var nibName: String {
20 | return String(describing: Self.self)
21 | }
22 |
23 | static var nib: UINib {
24 | return UINib(nibName: nibName, bundle: nil)
25 | }
26 |
27 | static func initFromNib() -> Self {
28 | guard let view = nib.instantiate(withOwner: nil, options: nil).first as? Self else {
29 | fatalError("Could not instantiate view from nib with name \(nibName).")
30 | }
31 |
32 | return view
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/examples/ReTweet/ReTweet/Library/ReduxStore.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ReduxStore.swift
3 | // ReTweet
4 | //
5 | // Created by Arthur Myronenko on 10/12/18.
6 | // Copyright © 2018 Arthur Mironenko. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import RxSwift
11 |
12 | final class ReduxStore {
13 |
14 | typealias Reducer = (State, Action) -> State
15 | typealias Dispatch = (Action) -> Void
16 | typealias StateProvider = () -> State
17 | typealias Middleware = (@escaping Dispatch, @escaping () -> State) -> (@escaping Dispatch) -> Dispatch
18 |
19 | private let reducer: Reducer
20 |
21 | let state: Observable
22 | private let stateVariable: Variable
23 | private var dispatchFunction: Dispatch!
24 |
25 | init(
26 | initialState: State,
27 | reducer: @escaping Reducer,
28 | middlewares: [Middleware]
29 | ) {
30 | let stateVariable = Variable(initialState)
31 |
32 | self.reducer = reducer
33 | self.state = stateVariable.asObservable()
34 |
35 | let defaultDispatch: Dispatch = { action in
36 | stateVariable.value = reducer(stateVariable.value, action)
37 | }
38 |
39 | self.stateVariable = stateVariable
40 | self.dispatchFunction = middlewares
41 | .reversed()
42 | .reduce(defaultDispatch) { (dispatchFunction, middleware) -> Dispatch in
43 | let dispatch: Dispatch = { [weak self] in self?.dispatch(action: $0) }
44 | let getState: StateProvider = { stateVariable.value }
45 | return middleware(dispatch, getState)(dispatchFunction)
46 | }
47 | }
48 |
49 | func dispatch(action: Action) {
50 | dispatchFunction?(action)
51 | }
52 |
53 | func getState() -> State {
54 | return stateVariable.value
55 | }
56 |
57 | static func makeMiddleware(worker: @escaping (@escaping Dispatch, StateProvider, Dispatch, Action) -> Void) -> Middleware {
58 | return { dispatch, getState in { next in { action in worker(dispatch, getState, next, action) } } }
59 | }
60 | }
61 |
62 |
--------------------------------------------------------------------------------
/examples/ReTweet/ReTweet/Library/Reusable.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Reusable.swift
3 | // ReTweet
4 | //
5 | // Created by Arthur Myronenko on 10/11/18.
6 | // Copyright © 2018 Arthur Mironenko. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | protocol ReusableCell {
12 | static var reuseIdentifier: String { get }
13 | }
14 |
15 | extension ReusableCell {
16 | static var reuseIdentifier: String {
17 | return String(describing: Self.self)
18 | }
19 | }
20 |
21 | extension UITableView {
22 | func dequeueReusableCell(withType type: Cell.Type, forRowAt indexPath: IndexPath) -> Cell {
23 | guard let cell = self.dequeueReusableCell(withIdentifier: Cell.reuseIdentifier, for: indexPath) as? Cell else {
24 | fatalError("Could not dequeue reusable cell with \(Cell.reuseIdentifier) reuse identifier.")
25 | }
26 |
27 | return cell
28 | }
29 |
30 | func dequeueReusableCell(for indexPath: IndexPath, cellType: T.Type = T.self) -> T where T: ReusableCell {
31 | guard let cell = self.dequeueReusableCell(withIdentifier: cellType.reuseIdentifier, for: indexPath) as? T else {
32 | fatalError("Failed to dequeue a cell with identifier \(cellType.reuseIdentifier) matching type \(cellType.self).")
33 | }
34 |
35 | return cell
36 | }
37 |
38 | func register(_ cellType: T.Type) where T: ReusableCell {
39 | self.register(cellType.self, forCellReuseIdentifier: cellType.reuseIdentifier)
40 | }
41 |
42 | func register(_ cellType: T.Type) where T: ReusableCell & NibInitializable {
43 | self.register(cellType.nib, forCellReuseIdentifier: cellType.reuseIdentifier)
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/examples/ReTweet/ReTweet/Library/RxExtensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RxExtensions.swift
3 | // ReTweet
4 | //
5 | // Created by Arthur Myronenko on 10/18/18.
6 | // Copyright © 2018 Arthur Mironenko. All rights reserved.
7 | //
8 |
9 | import RxSwift
10 |
11 | extension ObservableType {
12 | func filterNil() -> Observable where E == T? {
13 | return self.filter { $0 != nil }.map { $0! }
14 | }
15 |
16 | func voidValues() -> Observable {
17 | return self.map { _ in Void() }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/examples/ReTweet/ReTweet/Models/ComposedTweet.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ComposedTweet.swift
3 | // ReTweet
4 | //
5 | // Created by Arthur Myronenko on 10/11/18.
6 | // Copyright © 2018 Arthur Mironenko. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | struct ComposedTweet {
12 | let id: Tweet.Identifier
13 | let text: String
14 | let sentDate: Date
15 | }
16 |
17 | extension ComposedTweet {
18 | init(text: String) {
19 | self.init(
20 | id: UUID().uuidString,
21 | text: text,
22 | sentDate: Date()
23 | )
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/examples/ReTweet/ReTweet/Models/Tweet.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Tweet.swift
3 | // ReTweet
4 | //
5 | // Created by Arthur Myronenko on 10/12/18.
6 | // Copyright © 2018 Arthur Mironenko. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | struct Tweet {
12 | typealias Identifier = String
13 |
14 | let id: Identifier
15 | let username: String
16 | let date: Date
17 | let avatar: URL
18 | let text: String
19 | let likesAmount: Int
20 | }
21 |
22 | extension Tweet {
23 | init?(response: NetworkTweet) {
24 | guard let avatarURL = URL(string: response.avatar) else {
25 | return nil
26 | }
27 |
28 | self.init(
29 | id: response.clientID,
30 | username: response.username,
31 | date: response.date,
32 | avatar: avatarURL,
33 | text: response.text,
34 | likesAmount: response.likes
35 | )
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/examples/ReTweet/ReTweet/Models/User.swift:
--------------------------------------------------------------------------------
1 | //
2 | // User.swift
3 | // ReTweet
4 | //
5 | // Created by Arthur Myronenko on 10/11/18.
6 | // Copyright © 2018 Arthur Mironenko. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | struct User {
12 | let username: String
13 | }
14 |
--------------------------------------------------------------------------------
/examples/ReTweet/ReTweet/Services/NetworkModels/NetworkOutgoingTweet.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NetworkOutgoingTweet.swift
3 | // ReTweet
4 | //
5 | // Created by Arthur Myronenko on 10/12/18.
6 | // Copyright © 2018 Arthur Mironenko. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | struct NetworkOutgoingTweet: Encodable {
12 | let username: String
13 | let text: String
14 | let clientID: String
15 | }
16 |
--------------------------------------------------------------------------------
/examples/ReTweet/ReTweet/Services/NetworkModels/NetworkTweet.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NetworkTweet.swift
3 | // ReTweet
4 | //
5 | // Created by Arthur Myronenko on 10/12/18.
6 | // Copyright © 2018 Arthur Mironenko. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | struct NetworkTweet: Decodable {
12 | let clientID: String
13 | let username: String
14 | let text: String
15 | let avatar: String
16 | let date: Date
17 | let likes: Int
18 | }
19 |
--------------------------------------------------------------------------------
/examples/ReTweet/ReTweet/Services/TwitterService.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TwitterService.swift
3 | // ReTweet
4 | //
5 | // Created by Arthur Myronenko on 10/12/18.
6 | // Copyright © 2018 Arthur Mironenko. All rights reserved.
7 | //
8 |
9 | import RxSwift
10 | import Moya
11 | import RxMoya
12 |
13 | enum TwitterTarget {
14 | case postTweet(NetworkOutgoingTweet)
15 | case timeline(offset: Int, limit: Int)
16 | }
17 |
18 | extension TwitterTarget: TargetType {
19 | var baseURL: URL {
20 | return URL(string: "http://localhost:3000/posts")!
21 | }
22 |
23 | var path: String {
24 | return "/"
25 | }
26 |
27 | var method: Moya.Method {
28 | switch self {
29 | case .postTweet: return .post
30 | case .timeline: return .get
31 | }
32 | }
33 |
34 | var sampleData: Data {
35 | return Data()
36 | }
37 |
38 | var task: Task {
39 | switch self {
40 | case .postTweet(let outgoingTweet):
41 | return Task.requestCustomJSONEncodable(outgoingTweet, encoder: CustomJSONEncoder())
42 |
43 | case .timeline(let offset, let limit):
44 | return Task.requestParameters(
45 | parameters: [
46 | "offset": offset,
47 | "limit": limit
48 | ],
49 | encoding: URLEncoding.default
50 | )
51 | }
52 | }
53 |
54 | var headers: [String: String]? {
55 | return nil
56 | }
57 | }
58 |
59 | final class TwitterService {
60 |
61 | private let user: User
62 | private let provider: MoyaProvider
63 | private let decoder = CustomJSONDecoder()
64 |
65 | init(user: User, provider: MoyaProvider) {
66 | self.user = user
67 | self.provider = provider
68 | }
69 |
70 | func getTimeline(offset: Int, limit: Int) -> Observable<[Tweet]> {
71 | let target = TwitterTarget.timeline(offset: offset, limit: limit)
72 | return provider.rx.request(target)
73 | .filterSuccessfulStatusCodes()
74 | .map([NetworkTweet].self, using: decoder, failsOnEmptyData: true)
75 | .debug()
76 | .map { $0.compactMap(Tweet.init) }
77 | .asObservable()
78 | }
79 |
80 | func postTweet(_ tweet: ComposedTweet) -> Observable {
81 | let newTweet = NetworkOutgoingTweet(username: user.username, text: tweet.text, clientID: tweet.id)
82 | let target = TwitterTarget.postTweet(newTweet)
83 | return provider.rx.request(target)
84 | .filterSuccessfulStatusCodes()
85 | .map(NetworkTweet.self, using: decoder, failsOnEmptyData: true)
86 | .asObservable()
87 | .flatMap { networkTweet -> Observable in
88 | guard let tweet = Tweet(response: networkTweet) else {
89 | return Observable.error(MoyaError.requestMapping("Error"))
90 | }
91 |
92 | return Observable.just(tweet)
93 | }
94 | .debug()
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/examples/ReTweet/Readme.md:
--------------------------------------------------------------------------------
1 | # Redux Demo
2 |
3 | This project is used to demonstrate an implementation of the Redux approach described in the [Redux chapter](../../4-1-redux.md).
4 |
5 | Refer to the [TimelineViewModel implementation](ReTweet/Features/Timeline/ViewModel) for the Redux stuff.
6 |
7 | # How to run
8 |
9 | 1. Fetch and build Carthage frameworks with `carthage bootstrap --platform ios --cache-builds`
10 | 2. `cd server`
11 | 3. `npm install`
12 | 4. `npm start` to launch the server
13 |
--------------------------------------------------------------------------------
/examples/ReTweet/server/app.js:
--------------------------------------------------------------------------------
1 | var createError = require("http-errors");
2 | var express = require("express");
3 | var path = require("path");
4 | var cookieParser = require("cookie-parser");
5 | var logger = require("morgan");
6 |
7 | var indexRouter = require("./routes/index");
8 | var postsRouter = require("./routes/posts");
9 |
10 | var app = express();
11 |
12 | // view engine setup
13 | app.set("views", path.join(__dirname, "views"));
14 | app.set("view engine", "jade");
15 |
16 | app.use(logger("dev"));
17 | app.use(express.json());
18 | app.use(express.urlencoded({ extended: false }));
19 | app.use(cookieParser());
20 | app.use(express.static(path.join(__dirname, "public")));
21 |
22 | app.use("/", indexRouter);
23 | app.use("/posts", postsRouter);
24 |
25 | // catch 404 and forward to error handler
26 | app.use(function(req, res, next) {
27 | next(createError(404));
28 | });
29 |
30 | // error handler
31 | app.use(function(err, req, res, next) {
32 | // set locals, only providing error in development
33 | res.locals.message = err.message;
34 | res.locals.error = req.app.get("env") === "development" ? err : {};
35 |
36 | // render the error page
37 | res.status(err.status || 500);
38 | res.render("error");
39 | });
40 |
41 | module.exports = app;
42 |
--------------------------------------------------------------------------------
/examples/ReTweet/server/bin/www:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | /**
4 | * Module dependencies.
5 | */
6 |
7 | var app = require('../app');
8 | var debug = require('debug')('server:server');
9 | var http = require('http');
10 |
11 | /**
12 | * Get port from environment and store in Express.
13 | */
14 |
15 | var port = normalizePort(process.env.PORT || '3000');
16 | app.set('port', port);
17 |
18 | /**
19 | * Create HTTP server.
20 | */
21 |
22 | var server = http.createServer(app);
23 |
24 | /**
25 | * Listen on provided port, on all network interfaces.
26 | */
27 |
28 | server.listen(port);
29 | server.on('error', onError);
30 | server.on('listening', onListening);
31 |
32 | /**
33 | * Normalize a port into a number, string, or false.
34 | */
35 |
36 | function normalizePort(val) {
37 | var port = parseInt(val, 10);
38 |
39 | if (isNaN(port)) {
40 | // named pipe
41 | return val;
42 | }
43 |
44 | if (port >= 0) {
45 | // port number
46 | return port;
47 | }
48 |
49 | return false;
50 | }
51 |
52 | /**
53 | * Event listener for HTTP server "error" event.
54 | */
55 |
56 | function onError(error) {
57 | if (error.syscall !== 'listen') {
58 | throw error;
59 | }
60 |
61 | var bind = typeof port === 'string'
62 | ? 'Pipe ' + port
63 | : 'Port ' + port;
64 |
65 | // handle specific listen errors with friendly messages
66 | switch (error.code) {
67 | case 'EACCES':
68 | console.error(bind + ' requires elevated privileges');
69 | process.exit(1);
70 | break;
71 | case 'EADDRINUSE':
72 | console.error(bind + ' is already in use');
73 | process.exit(1);
74 | break;
75 | default:
76 | throw error;
77 | }
78 | }
79 |
80 | /**
81 | * Event listener for HTTP server "listening" event.
82 | */
83 |
84 | function onListening() {
85 | var addr = server.address();
86 | var bind = typeof addr === 'string'
87 | ? 'pipe ' + addr
88 | : 'port ' + addr.port;
89 | debug('Listening on ' + bind);
90 | }
91 |
--------------------------------------------------------------------------------
/examples/ReTweet/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "server",
3 | "version": "0.0.0",
4 | "private": true,
5 | "scripts": {
6 | "start": "node ./bin/www"
7 | },
8 | "dependencies": {
9 | "cookie-parser": "~1.4.3",
10 | "debug": "~2.6.9",
11 | "express": "~4.17.3",
12 | "faker": "^4.1.0",
13 | "http-errors": "~1.6.2",
14 | "jade": "~1.11.0",
15 | "morgan": "~1.9.0"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/examples/ReTweet/server/public/stylesheets/style.css:
--------------------------------------------------------------------------------
1 | body {
2 | padding: 50px;
3 | font: 14px "Lucida Grande", Helvetica, Arial, sans-serif;
4 | }
5 |
6 | a {
7 | color: #00B7FF;
8 | }
9 |
--------------------------------------------------------------------------------
/examples/ReTweet/server/routes/index.js:
--------------------------------------------------------------------------------
1 | var express = require('express');
2 | var router = express.Router();
3 |
4 | /* GET home page. */
5 | router.get('/', function(req, res, next) {
6 | res.render('index', { title: 'Express' });
7 | });
8 |
9 | module.exports = router;
10 |
--------------------------------------------------------------------------------
/examples/ReTweet/server/routes/posts.js:
--------------------------------------------------------------------------------
1 | var faker = require("faker");
2 | var express = require("express");
3 | var router = express.Router();
4 |
5 | const userAvatar = "https://i.imgur.com/DihF6bx.png";
6 |
7 | var messages = Array.from(new Array(1000), (x, i) => {
8 | return {
9 | username: faker.name.findName(),
10 | text: faker.lorem.sentences(3),
11 | avatar: faker.image.avatar(),
12 | date: faker.date.recent(),
13 | likes: faker.random.number(100),
14 | clientID: faker.random.uuid()
15 | };
16 | }).sort((first, second) => {
17 | a = new Date(first.date);
18 | b = new Date(second.date);
19 | return a > b ? -1 : a < b ? 1 : 0;
20 | });
21 |
22 | router.get("/", function(req, res, next) {
23 | const offset = Number(req.query.offset);
24 | const limit = Number(req.query.limit);
25 |
26 | if (offset != undefined && limit != undefined) {
27 | const messagesSlice = messages.slice(offset, offset + limit);
28 | res.send(messagesSlice);
29 | } else {
30 | res.send(messages);
31 | }
32 | });
33 |
34 | router.post("/", function(req, res, next) {
35 | setTimeout(function() {
36 | let newMessage = req.body;
37 | newMessage.date = new Date();
38 | newMessage.likes = 0;
39 | newMessage.avatar = userAvatar;
40 | messages.unshift(newMessage);
41 | res.send(newMessage);
42 | }, 5000);
43 | });
44 |
45 | module.exports = router;
46 |
--------------------------------------------------------------------------------
/examples/ReTweet/server/views/error.jade:
--------------------------------------------------------------------------------
1 | extends layout
2 |
3 | block content
4 | h1= message
5 | h2= error.status
6 | pre #{error.stack}
7 |
--------------------------------------------------------------------------------
/examples/ReTweet/server/views/index.jade:
--------------------------------------------------------------------------------
1 | extends layout
2 |
3 | block content
4 | h1= title
5 | p Welcome to #{title}
6 |
--------------------------------------------------------------------------------
/examples/ReTweet/server/views/layout.jade:
--------------------------------------------------------------------------------
1 | doctype html
2 | html
3 | head
4 | title= title
5 | link(rel='stylesheet', href='/stylesheets/style.css')
6 | body
7 | block content
8 |
--------------------------------------------------------------------------------
/resources/Fastfile-Advanced:
--------------------------------------------------------------------------------
1 | default_platform(:ios)
2 |
3 | platform :ios do
4 | before_all do
5 | setup_circle_ci
6 | ENV["SLACK_URL"] = ""
7 | end
8 |
9 | desc "Runs all the tests"
10 | lane :test do |options|
11 | scheme = get_scheme(build_configuration: options[:build_configuration])
12 | run_tests(
13 | scheme: scheme,
14 | device: 'iPhone 6s',
15 | skip_slack: true,
16 | output_types: "junit",
17 | output_files: "results.xml"
18 | )
19 | end
20 |
21 | lane :test_and_staging do |options|
22 | test(build_configuration: "staging")
23 | deploy_staging
24 | end
25 |
26 | lane :test_and_production do
27 | test(build_configuration: "production")
28 | deploy_production
29 | end
30 |
31 | lane :deploy_staging do
32 | # Fetch provisioning profiles
33 | match(
34 | type: "adhoc",
35 | readonly: true
36 | )
37 |
38 | # Bump build number before the build
39 | build_number = get_build_number(xcodeproj: "AwesomeProject.xcodeproj").to_i + 1
40 | increment_build_number(build_number: build_number)
41 |
42 | # Build the project
43 | scheme = get_scheme(build_configuration: "staging")
44 | build_ios_app(
45 | scheme: scheme,
46 | export_method: "ad-hoc"
47 | )
48 |
49 | # Deploy to your builds distributor
50 | upload_to_testflight(
51 | username: "username@uptech.team",
52 | app_identifier: "awesomeproject.uptech.team"
53 | )
54 |
55 | # Commit bump build number, add a tag and push
56 | original_commit = last_git_commit
57 | clean_build_artifacts
58 | commit_version_bump(
59 | xcodeproj: "AwesomeProject.xcodeproj",
60 | message: "Bump build number #{build_number} [ci skip]"
61 | )
62 | add_git_tag(
63 | grouping: "builds",
64 | prefix: "v",
65 | build_number: build_number
66 | )
67 |
68 | push_to_git_remote(
69 | remote: "origin",
70 | local_branch: "develop",
71 | remote_branch: "develop",
72 | tags: true
73 | )
74 |
75 | version = get_version_number(xcodeproj: "AwesomeProject.xcodeproj")
76 | slack(
77 | message: "New Staging iOS build *#{version}* (#{build_number}) has been submitted to HockeyApp!",
78 | use_webhook_configured_username_and_icon: true,
79 | success: true,
80 | default_payloads: [],
81 | payload: {
82 | "Git Commit": original_commit[:message],
83 | "Git Author": original_commit[:author]
84 | }
85 | )
86 | end
87 |
88 | lane :deploy_production do
89 | # Fetch provisioning profiles
90 | match(
91 | type: "adhoc",
92 | readonly: true
93 | )
94 |
95 | # Build the project
96 | scheme = get_scheme(build_configuration: "production")
97 | build_ios_app(
98 | scheme: scheme,
99 | export_method: "ad-hoc"
100 | )
101 |
102 | upload_to_testflight(
103 | username: "username@uptech.team",
104 | app_identifier: "awesomeproject.uptech.team"
105 | )
106 |
107 | version = get_version_number(xcodeproj: "AwesomeProject.xcodeproj")
108 | build_number = get_build_number(xcodeproj: "AwesomeProject.xcodeproj")
109 | slack(
110 | message: "New Production iOS build *#{version}* (#{build_number}) has been submitted to HockeyApp!",
111 | use_webhook_configured_username_and_icon: true,
112 | success: true,
113 | default_payloads: [],
114 | payload: {
115 | "Git Commit": last_git_commit[:message],
116 | "Git Author": last_git_commit[:author]
117 | }
118 | )
119 | end
120 |
121 | desc "Zip .ipa file"
122 | lane :create_zip do |options|
123 | scheme = get_scheme(build_configuration: options[:build_configuration])
124 |
125 | version = get_version_number(xcodeproj: "AwesomeProject.xcodeproj")
126 | build_number = get_build_number(xcodeproj: AwesomeProject.xcodeproj)
127 |
128 | zip_name = "#{"AwesomeProject"} #{scheme} v#{version}(#{build_number}).zip"
129 |
130 | zip(
131 | path: "#{"AwesomeProject"}.ipa",
132 | output_path: "fastlane/#{zip_name}"
133 | )
134 | end
135 |
136 | desc "Post zip to slack"
137 | lane :post_zip do
138 | ENV['SLACK_API_TOKEN'] = ""
139 | zip_files = Dir.glob("*.zip")
140 |
141 | if zip_files.empty?
142 | UI.user_error!("No zip files found.")
143 | else
144 | zip_file_path = zip_files.first
145 | zip_name = File.basename(zip_file_path)
146 |
147 | file_upload_to_slack(
148 | initial_comment: "Latest #{"AwesomeProject"} build",
149 | file_path: "fastlane/#{zip_name}",
150 | file_name: zip_name,
151 | channels: "@U25689074A, # Channels or users ids in slack
152 | file_type: "zip"
153 | )
154 | end
155 | end
156 |
157 | desc "Fetches certificates and profiles from the ios-certificates repository by HTTPS"
158 | lane :sync_profiles do
159 | match(
160 | type: "development",
161 | readonly: true
162 | )
163 |
164 | match(
165 | type: "adhoc",
166 | readonly: true
167 | )
168 |
169 | match(
170 | type: "appstore",
171 | readonly: true
172 | )
173 | end
174 |
175 | lane :add_device do
176 | device_name = prompt(text: "Enter the device name: ")
177 | device_udid = prompt(text: "Enter the device UDID: ")
178 | device_hash = {}
179 | device_hash[device_name] = device_udid
180 | register_devices(devices: device_hash)
181 | match(
182 | type: "development",
183 | force_for_new_devices: true
184 | )
185 | match(
186 | type: "adhoc",
187 | force_for_new_devices: true
188 | )
189 | end
190 |
191 | error do |lane, exception, options|
192 | slack_train_crash
193 |
194 | slack(
195 | message: "*#{lane}* lane crashed: #{exception}",
196 | use_webhook_configured_username_and_icon: true,
197 | success: false,
198 | default_payloads: [:last_git_commit_message, :git_author]
199 | )
200 | end
201 |
202 | private_lane :get_scheme do |options|
203 | build_configuration = options[:build_configuration]
204 | if !build_configuration
205 | UI.build_failure!("No build configuration was passed!")
206 | end
207 |
208 | case build_configuration
209 | when "development"
210 | "AwesomeProject Development"
211 | when "staging"
212 | "AwesomeProject Staging"
213 | when "production"
214 | "AwesomeProject Production"
215 | else
216 | UI.build_failure!("Couldn't recognize passed build configuration!")
217 | end
218 | end
219 | end
220 |
--------------------------------------------------------------------------------
/resources/Fastfile-Basic:
--------------------------------------------------------------------------------
1 | default_platform(:ios)
2 |
3 | platform :ios do
4 | before_all do
5 | setup_circle_ci
6 | ENV["SLACK_URL"] = ""
7 | end
8 |
9 | desc "Runs all the tests"
10 | lane :test do
11 | run_tests(
12 | scheme: 'AwesomeProject',
13 | device: 'iPhone 6s',
14 | skip_slack: true,
15 | output_types: "junit",
16 | output_files: "results.xml"
17 | )
18 | end
19 |
20 | desc "Deploy app to the iTunesConnect"
21 | lane :deploy do
22 | # Fetch provisioning profiles
23 | match(
24 | type: "adhoc",
25 | readonly: true
26 | )
27 |
28 | # Build the project
29 | build_ios_app(
30 | scheme: 'AwesomeProject',
31 | export_method: "ad-hoc"
32 | )
33 |
34 | # Upload the .ipa on TestFlight
35 | upload_to_testflight(
36 | username: "username@uptech.team",
37 | app_identifier: "awesomeproject.uptech.team"
38 | )
39 |
40 | # Send a message to Slack notifying about the new build
41 | version = get_version_number(xcodeproj: "AwesomeProject.xcodeproj")
42 | slack(
43 | message: "New iOS build *#{version}* (#{build_number}) has been submitted to HockeyApp!",
44 | use_webhook_configured_username_and_icon: true,
45 | success: true,
46 | default_payloads: [],
47 | payload: {
48 | "Git Commit": original_commit[:message],
49 | "Git Author": original_commit[:author]
50 | }
51 | )
52 | end
53 |
54 | # Each time the error occures, send a Slack message
55 | error do |lane, exception, options|
56 | slack_train_crash
57 |
58 | slack(
59 | message: "*#{lane}* lane crashed: #{exception}",
60 | use_webhook_configured_username_and_icon: true,
61 | success: false,
62 | default_payloads: [:last_git_commit_message, :git_author]
63 | )
64 | end
65 | end
66 |
--------------------------------------------------------------------------------
/resources/Fastfile-GithubActions:
--------------------------------------------------------------------------------
1 | default_platform(:ios)
2 |
3 | APPNAME = "githubActions"
4 | XCODEPROJ = "#{APPNAME}.xcodeproj"
5 |
6 | platform :ios do
7 | # MARK: - Set up
8 |
9 | before_all do
10 | ENV["FASTLANE_XCODEBUILD_SETTINGS_RETRIES"] = "5"
11 | end
12 |
13 | desc "Run's swiftlint"
14 | lane :lint do
15 | swiftlint_executable = mint_which(package: "swiftlint")
16 |
17 | UI.user_error!("SwiftLint not found. Please install SwiftLint using Mint.") if swiftlint_executable.empty?
18 |
19 | swiftlint(
20 | executable: swiftlint_executable,
21 | strict: true,
22 | quiet: true,
23 | reporter: "emoji"
24 | )
25 | end
26 |
27 | desc "Build project"
28 | lane :build do |options|
29 | setup_ci if ENV['CI']
30 | scheme = get_scheme(build_configuration: options[:build_configuration])
31 | match(type: "development", readonly: true)
32 | build_app(
33 | scheme: APPNAME,
34 | silent: true,
35 | skip_archive: true,
36 | skip_profile_detection: true
37 | )
38 | end
39 |
40 | desc "Runs all the tests"
41 | lane :tests do
42 | run_tests(
43 | scheme: 'APPNAME',
44 | device: 'iPhone 15 Pro',
45 | skip_slack: true,
46 | output_types: "junit",
47 | output_files: "results.xml"
48 | )
49 | end
50 |
51 | desc "Deploy given scheme to Testflight"
52 | lane :deploy do |options|
53 | setup_ci if ENV['CI']
54 | # Fetch provisioning profiles
55 | match(type: "appstore", readonly: true)
56 |
57 | # Bump build number before the build
58 | build_number = get_build_number(xcodeproj: XCODEPROJ).to_i + 1
59 | increment_build_number(build_number: build_number)
60 |
61 | # Build the project with given scheme
62 | build_configuration = options[:build_configuration]
63 | scheme = get_scheme(build_configuration: build_configuration)
64 | build_app(
65 | scheme: APPNAME,
66 | silent: true,
67 | export_method: "app-store"
68 | )
69 |
70 | # Upload the build
71 | app_store_connect_api_key
72 | upload_to_testflight(
73 | username: "oleksiygumenykk@gmail.com",
74 | skip_waiting_for_build_processing: true
75 | )
76 |
77 | # Commit bump build number, add a tag and push
78 | commit_version_bump(
79 | force: true,
80 | message: "Bump build number #{build_number} [ci skip]"
81 | )
82 | push_to_git_remote(
83 | remote: "origin",
84 | local_branch: "develop",
85 | remote_branch: "develop"
86 | )
87 |
88 | version = get_version_number(xcodeproj: XCODEPROJ)
89 | add_git_tag(
90 | tag: "#{build_configuration}/v#{version}/#{build_number}"
91 | )
92 | push_git_tags
93 | end
94 |
95 | desc "Zip .ipa file"
96 | lane :create_zip do |options|
97 | scheme = get_scheme(build_configuration: options[:build_configuration])
98 |
99 | version = get_version_number(xcodeproj: XCODEPROJ)
100 | build_number = get_build_number(xcodeproj: XCODEPROJ)
101 |
102 | zip_name = "#{APPNAME} #{scheme} v#{version}(#{build_number}).zip"
103 |
104 | zip(
105 | path: "#{APPNAME}.ipa",
106 | output_path: "fastlane/#{zip_name}"
107 | )
108 | end
109 |
110 | desc "Get project scheme (dev, staging or prod)"
111 | private_lane :get_scheme do |options|
112 | build_configuration = options[:build_configuration]
113 | if !build_configuration
114 | UI.build_failure!("No build configuration was passed!")
115 | end
116 |
117 | case build_configuration
118 | when "development"
119 | "Development"
120 | when "staging"
121 | "Staging"
122 | when "production"
123 | "Production"
124 | else
125 | UI.build_failure!("Couldn't recognize passed build configuration!")
126 | end
127 | end
128 |
129 | end
130 |
--------------------------------------------------------------------------------
/resources/ReduxStore.swift:
--------------------------------------------------------------------------------
1 | import RxSwift
2 |
3 | final class ReduxStore {
4 |
5 | typealias Reducer = (State, Action) -> State
6 | typealias Dispatch = (Action) -> Void
7 | typealias StateProvider = () -> State
8 | typealias Middleware = (@escaping Dispatch, @escaping () -> State) -> (@escaping Dispatch) -> Dispatch
9 |
10 | private let reducer: Reducer
11 |
12 | let state: Observable
13 | private let stateVariable: Variable
14 | private var dispatchFunction: Dispatch!
15 |
16 | init(
17 | initialState: State,
18 | reducer: @escaping Reducer,
19 | middlewares: [Middleware]
20 | ) {
21 | let stateVariable = Variable(initialState)
22 |
23 | self.reducer = reducer
24 | self.state = stateVariable.asObservable()
25 |
26 | let defaultDispatch: Dispatch = { action in
27 | stateVariable.value = reducer(stateVariable.value, action)
28 | }
29 |
30 | self.stateVariable = stateVariable
31 | self.dispatchFunction = middlewares
32 | .reversed()
33 | .reduce(defaultDispatch) { (dispatchFunction, middleware) -> Dispatch in
34 | let dispatch: Dispatch = { [weak self] in self?.dispatch(action: $0) }
35 | let getState: StateProvider = { stateVariable.value }
36 | return middleware(dispatch, getState)(dispatchFunction)
37 | }
38 | }
39 |
40 | func dispatch(action: Action) {
41 | dispatchFunction?(action)
42 | }
43 |
44 | func getState() -> State {
45 | return stateVariable.value
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/resources/TrueMVC.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/uptechteam/ios-cookbook/b5d1dca20f96d0c6023f97b23ed93b7fe2700709/resources/TrueMVC.png
--------------------------------------------------------------------------------
/resources/ci/APP_STORE_CONNECT_API_KEY_ISSUER_ID.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/uptechteam/ios-cookbook/b5d1dca20f96d0c6023f97b23ed93b7fe2700709/resources/ci/APP_STORE_CONNECT_API_KEY_ISSUER_ID.png
--------------------------------------------------------------------------------
/resources/ci/APP_STORE_CONNECT_API_KEY_KEY_ID_example.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/uptechteam/ios-cookbook/b5d1dca20f96d0c6023f97b23ed93b7fe2700709/resources/ci/APP_STORE_CONNECT_API_KEY_KEY_ID_example.png
--------------------------------------------------------------------------------
/resources/ci/auth key in Apple Development Portal.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/uptechteam/ios-cookbook/b5d1dca20f96d0c6023f97b23ed93b7fe2700709/resources/ci/auth key in Apple Development Portal.png
--------------------------------------------------------------------------------
/resources/ci/ci_done_jobs.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/uptechteam/ios-cookbook/b5d1dca20f96d0c6023f97b23ed93b7fe2700709/resources/ci/ci_done_jobs.png
--------------------------------------------------------------------------------
/resources/ci/ci_workflow.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/uptechteam/ios-cookbook/b5d1dca20f96d0c6023f97b23ed93b7fe2700709/resources/ci/ci_workflow.png
--------------------------------------------------------------------------------
/resources/ci/deployDevAction.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/uptechteam/ios-cookbook/b5d1dca20f96d0c6023f97b23ed93b7fe2700709/resources/ci/deployDevAction.png
--------------------------------------------------------------------------------
/resources/ci/differentTargetsJobs.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/uptechteam/ios-cookbook/b5d1dca20f96d0c6023f97b23ed93b7fe2700709/resources/ci/differentTargetsJobs.png
--------------------------------------------------------------------------------
/resources/ci/fastlane-init-option.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/uptechteam/ios-cookbook/b5d1dca20f96d0c6023f97b23ed93b7fe2700709/resources/ci/fastlane-init-option.png
--------------------------------------------------------------------------------
/resources/ci/match-development-final.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/uptechteam/ios-cookbook/b5d1dca20f96d0c6023f97b23ed93b7fe2700709/resources/ci/match-development-final.png
--------------------------------------------------------------------------------
/resources/ci/match-init-options.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/uptechteam/ios-cookbook/b5d1dca20f96d0c6023f97b23ed93b7fe2700709/resources/ci/match-init-options.png
--------------------------------------------------------------------------------
/resources/ci/xcode-settings-example.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/uptechteam/ios-cookbook/b5d1dca20f96d0c6023f97b23ed93b7fe2700709/resources/ci/xcode-settings-example.png
--------------------------------------------------------------------------------
/resources/ci/xcode-settings-example2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/uptechteam/ios-cookbook/b5d1dca20f96d0c6023f97b23ed93b7fe2700709/resources/ci/xcode-settings-example2.png
--------------------------------------------------------------------------------
/resources/circle-config.yml:
--------------------------------------------------------------------------------
1 | version: 2.1
2 |
3 | executors:
4 | uptech_macos:
5 | macos:
6 | xcode: "11.1.0"
7 | shell: /bin/bash --login -eo pipefail
8 | environment:
9 | LC_ALL: en_US.UTF-8
10 | LANG: en_US.UTF-8
11 |
12 |
13 | commands:
14 | uptech_restore_cache:
15 | steps:
16 | - restore_cache:
17 | key: dependency-cache-{{ checksum "Cartfile.resolved" }}-{{ .Environment.CACHE_VERSION }}
18 |
19 | uptech_save_cache:
20 | steps:
21 | - save_cache:
22 | key: dependency-cache-{{ checksum "Cartfile.resolved" }}-{{ .Environment.CACHE_VERSION }}
23 | paths:
24 | - "Carthage/"
25 |
26 | install_dependencies:
27 | steps:
28 | - run:
29 | name: Bundle Install
30 | command: bundle install
31 | - run:
32 | name: Install Carthage & SwiftLint
33 | command: ./bin/upstall
34 | - run:
35 | name: Carthage Bootstrap
36 | command: carthage bootstrap --platform ios --cache-builds
37 |
38 | uptech_store_test_results:
39 | steps:
40 | - store_test_results:
41 | path: fastlane/test_output
42 |
43 |
44 | jobs:
45 | build-and-test:
46 | executor: uptech_macos
47 | steps:
48 | - checkout
49 | - uptech_restore_cache
50 | - install_dependencies
51 | - uptech_save_cache
52 | - run:
53 | name: Build and run tests
54 | command: bundle exec fastlane test build_configuration:staging
55 | - uptech_store_test_results
56 |
57 | deploy_qa:
58 | executor: uptech_macos
59 | steps:
60 | - checkout
61 | - uptech_restore_cache
62 | - install_dependencies
63 | - uptech_save_cache
64 | - run:
65 | name: Build and run tests
66 | command: bundle exec fastlane test_then_deploy_qa
67 | - uptech_store_test_results
68 |
69 | deploy_staging:
70 | executor: uptech_macos
71 | steps:
72 | - checkout
73 | - uptech_restore_cache
74 | - install_dependencies
75 | - uptech_save_cache
76 | - run:
77 | name: Build and run tests
78 | command: bundle exec fastlane test_then_deploy_staging
79 | - uptech_store_test_results
80 |
81 | deploy_production:
82 | executor: uptech_macos
83 | steps:
84 | - checkout
85 | - uptech_restore_cache
86 | - install_dependencies
87 | - uptech_save_cache
88 | - run:
89 | name: Build and run tests
90 | command: bundle exec fastlane test_then_deploy_production
91 | - uptech_store_test_results
92 |
93 | workflows:
94 | version: 2
95 | build:
96 | jobs:
97 | - build-and-test:
98 | filters:
99 | branches:
100 | ignore:
101 | - develop
102 | - deploy_qa:
103 | filters:
104 | branches:
105 | only:
106 | - develop
107 |
--------------------------------------------------------------------------------
/resources/cocoa_mvc.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/uptechteam/ios-cookbook/b5d1dca20f96d0c6023f97b23ed93b7fe2700709/resources/cocoa_mvc.png
--------------------------------------------------------------------------------
/resources/debugging/allocations1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/uptechteam/ios-cookbook/b5d1dca20f96d0c6023f97b23ed93b7fe2700709/resources/debugging/allocations1.png
--------------------------------------------------------------------------------
/resources/debugging/allocations2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/uptechteam/ios-cookbook/b5d1dca20f96d0c6023f97b23ed93b7fe2700709/resources/debugging/allocations2.png
--------------------------------------------------------------------------------
/resources/debugging/breakpoints.md:
--------------------------------------------------------------------------------
1 | ```swift
2 | All Objective-C Exceptions
3 | // Catches exceptions thrown by Objective-C code.
4 | // Default Xcode breakpoint created by clicking "+" to add breakpoint -> "Exception Breakpoint".
5 | // Change "Exception: All" to "Exception: Objective-C".
6 |
7 | -[UIApplication main]
8 | // Helps when printing objects via the debugger by making it aware of the classes in UIKit.
9 | // Symbolic breakpoint created by clicking "+" to add breakpoint -> "Symbolic Breakpoint".
10 | // Enter "-[UIApplication main]" for the Symbol.
11 | // Choose Action -> "Debugger Command".
12 | // Enter "expr @import UIKit" for the command.
13 | // Check "Automatically continue after evaluating actions".
14 |
15 | UIViewAlertForUnsatisfiableConstraints
16 | // Helps catch undesirable constraints. Usually, these don't cause obvious visual issues, but they should be fixed since we don't know what could happen in future OS versions.
17 | // Symbolic breakpoint created by clicking "+" to add breakpoint -> "Symbolic Breakpoint".
18 | // Enter "UIViewAlertForUnsatisfiableConstraints" for the Symbol.
19 |
20 | -[UIView(UIConstraintBasedLayout) _viewHierarchyUnpreparedForConstraint:]
21 | // This is another breakpoint that helps to catch undesirable constraints.
22 | // Symbolic breakpoint created by clicking "+" to add breakpoint -> "Symbolic Breakpoint".
23 | // Enter "-[UIView(UIConstraintBasedLayout) _viewHierarchyUnpreparedForConstraint:]" for the Symbol.
24 |
25 | UICollectionViewFlowLayoutBreakForInvalidSizes
26 | // Helps catch undesirable constraints in UICollectionViews.
27 | // Symbolic breakpoint created by clicking "+" to add breakpoint -> "Symbolic Breakpoint".
28 | // Enter "UICollectionViewFlowLayoutBreakForInvalidSizes" for the Symbol.
29 | ```
30 |
31 |
--------------------------------------------------------------------------------
/resources/debugging/memGraph1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/uptechteam/ios-cookbook/b5d1dca20f96d0c6023f97b23ed93b7fe2700709/resources/debugging/memGraph1.png
--------------------------------------------------------------------------------
/resources/debugging/memGraph2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/uptechteam/ios-cookbook/b5d1dca20f96d0c6023f97b23ed93b7fe2700709/resources/debugging/memGraph2.png
--------------------------------------------------------------------------------
/resources/debugging/memGraph3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/uptechteam/ios-cookbook/b5d1dca20f96d0c6023f97b23ed93b7fe2700709/resources/debugging/memGraph3.png
--------------------------------------------------------------------------------
/resources/debugging/search1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/uptechteam/ios-cookbook/b5d1dca20f96d0c6023f97b23ed93b7fe2700709/resources/debugging/search1.png
--------------------------------------------------------------------------------
/resources/debugging/timeProfiler1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/uptechteam/ios-cookbook/b5d1dca20f96d0c6023f97b23ed93b7fe2700709/resources/debugging/timeProfiler1.png
--------------------------------------------------------------------------------
/resources/estimates_screenshots/estimates_example.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/uptechteam/ios-cookbook/b5d1dca20f96d0c6023f97b23ed93b7fe2700709/resources/estimates_screenshots/estimates_example.png
--------------------------------------------------------------------------------
/resources/estimates_screenshots/login.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/uptechteam/ios-cookbook/b5d1dca20f96d0c6023f97b23ed93b7fe2700709/resources/estimates_screenshots/login.png
--------------------------------------------------------------------------------
/resources/gitHubAction-Advanced.yml:
--------------------------------------------------------------------------------
1 | name: Deploy development
2 |
3 | on:
4 | workflow_dispatch:
5 |
6 | jobs:
7 | deploy_dev:
8 | runs-on: macos-14
9 | steps:
10 | - name: Checkout repository
11 | uses: actions/checkout@v3
12 |
13 | - uses: webfactory/ssh-agent@v0.9.0
14 | with:
15 | ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
16 |
17 | - name: Cancel Previous Runs
18 | uses: styfle/cancel-workflow-action@0.11.0
19 | with:
20 | access_token: ${{ github.token }}
21 |
22 | - name: Set up Xcode
23 | uses: maxim-lobanov/setup-xcode@v1
24 | with:
25 | xcode-version: '15.3'
26 |
27 | - uses: ruby/setup-ruby@v1
28 | with:
29 | ruby-version: '2.7.6'
30 |
31 | - name: Install Bundler
32 | run: gem install bundler -v 2.4.7
33 |
34 | - name: Install gems
35 | run: bundle install
36 |
37 | - name: Build and deploy dev
38 | run: bundle exec fastlane deploy build_configuration:development
39 | env:
40 | MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
41 | MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
42 | APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY_ID }}
43 | APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
44 | APP_STORE_CONNECT_API_KEY_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY }}
45 | FASTLANE_USER: ${{ secrets.FASTLANE_USER }}
--------------------------------------------------------------------------------
/resources/gitHubAction-Basic.yml:
--------------------------------------------------------------------------------
1 | name: Build and test
2 |
3 | on:
4 | pull_request:
5 | types: [opened, synchronize]
6 |
7 | jobs:
8 | build_and_lint:
9 | runs-on: macos-14
10 | steps:
11 | - name: Checkout repository
12 | uses: actions/checkout@v3
13 |
14 | - uses: webfactory/ssh-agent@v0.9.0
15 | with:
16 | ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
17 |
18 | - name: Cancel Previous Runs
19 | uses: styfle/cancel-workflow-action@0.11.0
20 | with:
21 | access_token: ${{ github.token }}
22 |
23 | - name: Set up Xcode
24 | uses: maxim-lobanov/setup-xcode@v1
25 | with:
26 | xcode-version: '15.3'
27 |
28 | - uses: ruby/setup-ruby@v1
29 | with:
30 | ruby-version: '2.7.6'
31 |
32 | - name: Install Bundler
33 | run: gem install bundler -v 2.4.7
34 |
35 | - name: Install gems
36 | run: bundle install
37 |
38 | - name: Build
39 | run: bundle exec fastlane build build_configuration:development
40 | env:
41 | MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
42 | MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
43 |
44 | - name: Setup Mint Swift Package Manager
45 | uses: irgaly/setup-mint@v1.1.1
46 |
47 | - name: Test
48 | run: bundle exec fastlane tests
--------------------------------------------------------------------------------
/resources/illustrations/7.2.empty_lines.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/uptechteam/ios-cookbook/b5d1dca20f96d0c6023f97b23ed93b7fe2700709/resources/illustrations/7.2.empty_lines.png
--------------------------------------------------------------------------------
/resources/illustrations/7.2.line_character_limit.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/uptechteam/ios-cookbook/b5d1dca20f96d0c6023f97b23ed93b7fe2700709/resources/illustrations/7.2.line_character_limit.png
--------------------------------------------------------------------------------
/resources/illustrations/7.2.tabs_preferences.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/uptechteam/ios-cookbook/b5d1dca20f96d0c6023f97b23ed93b7fe2700709/resources/illustrations/7.2.tabs_preferences.png
--------------------------------------------------------------------------------
/resources/illustrations/7.2.text_settings.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/uptechteam/ios-cookbook/b5d1dca20f96d0c6023f97b23ed93b7fe2700709/resources/illustrations/7.2.text_settings.png
--------------------------------------------------------------------------------
/resources/redux_vm.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/uptechteam/ios-cookbook/b5d1dca20f96d0c6023f97b23ed93b7fe2700709/resources/redux_vm.png
--------------------------------------------------------------------------------
/resources/scripts/pre-commit:
--------------------------------------------------------------------------------
1 | #! /bin/sh
2 |
3 | # Function to add files to SwiftLint's SCRIPT_INPUT_FILE array
4 | function addFilesToLint {
5 | filename=""
6 | count=$2
7 | for item in $1
8 | do
9 | if [[ $item == *".swift"* ]]; then
10 | filename+="$item"
11 | export SCRIPT_INPUT_FILE_$count="$filename"
12 | count=$((count + 1))
13 | filename=""
14 | else
15 | filename+="$item "
16 | fi
17 | done
18 | }
19 |
20 | # Finding SwiftLint in Mint
21 | LINT=$(mint which swiftlint)
22 | if [[ -e "${LINT}" ]]; then
23 | printf "SwiftLint Started...\n"
24 | else
25 | echo "SwiftLint is not installed via Mint(https://github.com/yonaskolb/Mint)"
26 | exit 1
27 | fi
28 |
29 | # Getting .swift files which are in the commit and haven't been pushed yet
30 | targets=$(git diff --stat --cached --name-only $(git for-each-ref --format='%(upstream:short)' $(git symbolic-ref -q HEAD)) | grep -F ".swift")
31 |
32 | # Populating SwiftLint's SCRIPT_INPUT_FILE array
33 | count=0
34 | addFilesToLint "${targets[0]}" $count
35 | export SCRIPT_INPUT_FILE_COUNT=$count
36 |
37 | # Getting lint results
38 | RESULT=$($LINT lint --quiet --use-script-input-files)
39 |
40 | if [ "$RESULT" == '' ]; then
41 | printf "SwiftLint Finished\n"
42 | else
43 | printf "SwiftLint Failed:\n\n"
44 |
45 | while read -r line; do
46 | FILEPATH=$(echo $line | cut -d : -f 1)
47 | L=$(echo $line | cut -d : -f 2)
48 | C=$(echo $line | cut -d : -f 3)
49 | TYPE=$(echo $line | cut -d : -f 4 | cut -c 2-)
50 | MESSAGE=$(echo $line | cut -d : -f 5 | cut -c 2-)
51 | DESCRIPTION=$(echo $line | cut -d : -f 6 | cut -c 2-)
52 | printf "$TYPE:\n$FILEPATH:$L:$C\n$MESSAGE - $DESCRIPTION\n\n"
53 | done <<< "$RESULT"
54 |
55 | printf "Push aborted. Please fix them before pushing your code.\n"
56 | exit 1
57 | fi
--------------------------------------------------------------------------------
/resources/scripts/run-swiftlint.sh:
--------------------------------------------------------------------------------
1 | #! /bin/sh
2 |
3 | if [ -z "$CI" ]; then
4 | unset SDKROOT
5 | set -e
6 | mint run SwiftLint lint
7 | else
8 | echo "Note: SwiftLint is not expected to run during build phases on CI"
9 | fi
--------------------------------------------------------------------------------
/resources/security/app_switcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/uptechteam/ios-cookbook/b5d1dca20f96d0c6023f97b23ed93b7fe2700709/resources/security/app_switcher.png
--------------------------------------------------------------------------------
/resources/ui_in_code/componentView.codesnippet:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDECodeSnippetCompletionPrefix
6 | componentView
7 | IDECodeSnippetCompletionScopes
8 |
9 | All
10 | TopLevel
11 |
12 | IDECodeSnippetContents
13 | import RxSwift
14 |
15 | final class <#View#>: UIView {
16 | // MARK: - Private Properties
17 |
18 | private let privateView = UIView()
19 | fileprivate let exposedButton = UIButton()
20 |
21 | // MARK: - Lifecycle
22 |
23 | override init(frame: CGRect) {
24 | super.init(frame: frame)
25 | setupUI()
26 | }
27 |
28 | required init?(coder aDecoder: NSCoder) {
29 | fatalError("init(coder:) has not been implemented")
30 | }
31 |
32 | // MARK: - Private Methods
33 |
34 | private func setupUI() {
35 | setupView()
36 | setupPrivateView()
37 | setupExposedButton()
38 | }
39 |
40 | private func setupView() {
41 | backgroundColor = .clear
42 | directionalLayoutMargins = .init(top: .zero, leading: 32, bottom: .zero, trailing: 32)
43 | }
44 |
45 | private func setupPrivateView() {
46 | privateView.backgroundColor = .white
47 |
48 | privateView.translatesAutoresizingMaskIntoConstraints = false
49 | addSubview(privateView)
50 | NSLayoutConstraint.activate([
51 | privateView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor),
52 | privateView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
53 | privateView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
54 | privateView.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor)
55 | ])
56 | }
57 |
58 | private func setupExposedButton() {
59 | exposedButton.translatesAutoresizingMaskIntoConstraints = false
60 | privateView.addSubview(exposedButton)
61 | NSLayoutConstraint.activate([
62 | exposedButton.topAnchor.constraint(equalTo: privateView.topAnchor),
63 | exposedButton.leadingAnchor.constraint(equalTo: privateView.leadingAnchor),
64 | exposedButton.trailingAnchor.constraint(equalTo: privateView.trailingAnchor),
65 | exposedButton.bottomAnchor.constraint(equalTo: privateView.bottomAnchor),
66 | exposedButton.heightAnchor.constraint(equalToConstant: 48)
67 | ])
68 | }
69 | }
70 |
71 | extension Reactive where Base: <#View#> {
72 | var exposedButtonTap: Observable<Void> { base.exposedButton.rx.tap.asObservable() }
73 | }
74 |
75 | // MARK: - Declarations
76 |
77 | extension <#View#> {
78 | struct Props {}
79 | private enum Constants {}
80 | private enum Attributes {}
81 | }
82 | IDECodeSnippetIdentifier
83 | 34D8EC6A-B97C-4B4D-9D79-C70FBEB6B5D9
84 | IDECodeSnippetLanguage
85 | Xcode.SourceCodeLanguage.Swift
86 | IDECodeSnippetPlatformFamily
87 | iphoneos
88 | IDECodeSnippetSummary
89 | Generic View component
90 | IDECodeSnippetTitle
91 | View
92 | IDECodeSnippetUserSnippet
93 |
94 | IDECodeSnippetVersion
95 | 2
96 |
97 |
98 |
--------------------------------------------------------------------------------
/resources/ui_in_code/exampleView.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/uptechteam/ios-cookbook/b5d1dca20f96d0c6023f97b23ed93b7fe2700709/resources/ui_in_code/exampleView.png
--------------------------------------------------------------------------------
/resources/ui_tests/ui_tests_1.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/uptechteam/ios-cookbook/b5d1dca20f96d0c6023f97b23ed93b7fe2700709/resources/ui_tests/ui_tests_1.gif
--------------------------------------------------------------------------------