├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
└── pull_request_template.md
├── .gitignore
├── .ruby-version
├── .travis.yml
├── Frameworks
└── DesignKit
│ ├── DesignKit.podspec
│ ├── LICENSE
│ └── src
│ ├── Avatar
│ └── UIImageViewExtensions.swift
│ ├── Color
│ └── UIColorExtensions.swift
│ ├── FavoriteButton
│ └── UIButtonExtensions.swift
│ ├── Font
│ └── UIFontExtensions.swift
│ └── Spacing
│ └── Spacing.swift
├── Gemfile
├── Gemfile.lock
├── LICENSE
├── Moments.xcworkspace
├── contents.xcworkspacedata
└── xcshareddata
│ └── IDEWorkspaceChecks.plist
├── Moments
├── .swiftlint.yml
├── Moments.xcodeproj
│ ├── project.pbxproj
│ ├── project.xcworkspace
│ │ ├── contents.xcworkspacedata
│ │ └── xcshareddata
│ │ │ └── IDEWorkspaceChecks.plist
│ └── xcshareddata
│ │ └── xcschemes
│ │ ├── Moments-AppStore.xcscheme
│ │ ├── Moments-Internal.xcscheme
│ │ └── Moments.xcscheme
├── Moments
│ ├── AppDelegate.swift
│ ├── Assets.xcassets
│ │ ├── AccentColor.colorset
│ │ │ └── Contents.json
│ │ ├── AppIcon.appiconset
│ │ │ ├── Contents.json
│ │ │ ├── icon-ios-1024@1x.png
│ │ │ ├── icon-ios-20@2x.png
│ │ │ ├── icon-ios-20@3x.png
│ │ │ ├── icon-ios-29@2x.png
│ │ │ ├── icon-ios-29@3x.png
│ │ │ ├── icon-ios-40@2x.png
│ │ │ ├── icon-ios-40@3x.png
│ │ │ ├── icon-ios-60@2x.png
│ │ │ ├── icon-ios-60@3x.png
│ │ │ ├── icon-ios-76@2x.png
│ │ │ └── icon-ios-83.5@2x.png
│ │ └── Contents.json
│ ├── Configurations
│ │ ├── AppStoreProject.xcconfig
│ │ ├── AppStoreTarget.xcconfig
│ │ ├── BaseConfigurations.xcconfig
│ │ ├── BaseProject.xcconfig
│ │ ├── BaseTarget.xcconfig
│ │ ├── CompilerAndLanguage.xcconfig
│ │ ├── DebugProject.xcconfig
│ │ ├── DebugTarget.xcconfig
│ │ ├── Firebase
│ │ │ ├── GoogleService-Info-AppStore.plist
│ │ │ ├── GoogleService-Info-Development.plist
│ │ │ └── GoogleService-Info-Internal.plist
│ │ ├── InternalProject.xcconfig
│ │ ├── InternalTarget.xcconfig
│ │ └── SDKAndDeviceSupport.xcconfig
│ ├── Features
│ │ ├── InternalMenu
│ │ │ ├── Routing
│ │ │ │ ├── DesignKitDemoNavigator.swift
│ │ │ │ └── InternalMenuNavigator.swift
│ │ │ ├── ViewModels
│ │ │ │ ├── InternalMenuActionTriggerItemViewModel.swift
│ │ │ │ ├── InternalMenuCrashAppItemViewModel.swift
│ │ │ │ ├── InternalMenuDescriptionItemViewModel.swift
│ │ │ │ ├── InternalMenuDesignKitDemoItemViewModel.swift
│ │ │ │ ├── InternalMenuFeatureToggleItemViewModel.swift
│ │ │ │ ├── InternalMenuItemViewModel.swift
│ │ │ │ ├── InternalMenuSection.swift
│ │ │ │ └── InternalMenuViewModel.swift
│ │ │ └── Views
│ │ │ │ ├── DesignKitDemoViewController.swift
│ │ │ │ ├── InternalMenuActionTriggerCell.swift
│ │ │ │ ├── InternalMenuCellType.swift
│ │ │ │ ├── InternalMenuDescriptionCell.swift
│ │ │ │ ├── InternalMenuFeatureToggleCell.swift
│ │ │ │ └── InternalMenuViewController.swift
│ │ └── Moments
│ │ │ ├── Analytics
│ │ │ ├── LikeActionTrackingEvent.swift
│ │ │ └── UnlikeActionTrackingEvent.swift
│ │ │ ├── Models
│ │ │ └── MomentsDetails.swift
│ │ │ ├── Networking
│ │ │ ├── GetMomentsByUserIDSession.swift
│ │ │ └── UpdateMomentLikeSession.swift
│ │ │ ├── Repositories
│ │ │ └── MomentsRepo.swift
│ │ │ ├── ViewModels
│ │ │ ├── MomentListItemViewModel.swift
│ │ │ ├── MomentsTimelineViewModel.swift
│ │ │ └── UserProfileListItemViewModel.swift
│ │ │ └── Views
│ │ │ ├── MomentListItemView.swift
│ │ │ ├── MomentsTimelineViewController.swift
│ │ │ └── UserProfileListItemView.swift
│ ├── Foundations
│ │ ├── ABTest
│ │ │ ├── ABTestProvider.swift
│ │ │ └── FirebaseABTestProvider.swift
│ │ ├── Analytics
│ │ │ ├── Common
│ │ │ │ ├── ActionTrackingEvent.swift
│ │ │ │ ├── ScreenviewsTrackingEvent.swift
│ │ │ │ ├── TrackingEvent.swift
│ │ │ │ ├── TrackingEventType.swift
│ │ │ │ ├── TrackingProvider.swift
│ │ │ │ └── TrackingRepo.swift
│ │ │ └── Firebase
│ │ │ │ ├── FirebaseActionTrackingEvent.swift
│ │ │ │ └── FirebaseTrackingProvider.swift
│ │ ├── DataStore
│ │ │ ├── PersistentDataStoreType.swift
│ │ │ ├── UserDataStore.swift
│ │ │ └── UserDefaultsPersistentDataStore.swift
│ │ ├── DateFormatter
│ │ │ └── RelativeDateTimeFormatterType.swift
│ │ ├── Debugging
│ │ │ └── DebuggingUltils.swift
│ │ ├── Extensions
│ │ │ └── UIApplicationExtensions.swift
│ │ ├── Networking
│ │ │ ├── API.swift
│ │ │ └── APISession.swift
│ │ ├── RemoteConfig
│ │ │ ├── FirebaseRemoteConfigDefaults.plist
│ │ │ ├── FirebaseRemoteConfigProvider.swift
│ │ │ └── RemoteConfigProvider.swift
│ │ ├── Routing
│ │ │ ├── AppRouter.swift
│ │ │ ├── AppRouting.swift
│ │ │ ├── Navigating.swift
│ │ │ ├── TransitionType.swift
│ │ │ └── UniversalLinks.swift
│ │ ├── Testing
│ │ │ └── UnitTestViewController.swift
│ │ ├── Toggles
│ │ │ ├── BuildTargetTogglesDataStore.swift
│ │ │ ├── FirebaseRemoteTogglesDataStore.swift
│ │ │ ├── InternalTogglesDataStore.swift
│ │ │ └── TogglesDataStoreType.swift
│ │ ├── Utilities
│ │ │ ├── Configuration.swift
│ │ │ └── Functions.swift
│ │ ├── ViewModels
│ │ │ ├── ListItemViewModel.swift
│ │ │ └── ListViewModel.swift
│ │ └── Views
│ │ │ ├── BaseListItemView.swift
│ │ │ ├── BaseTableViewCell.swift
│ │ │ ├── BaseTableViewController.swift
│ │ │ ├── BaseViewController.swift
│ │ │ ├── ListItemCell.swift
│ │ │ └── ListItemView.swift
│ ├── Generated
│ │ └── Strings.swift
│ ├── Info.plist
│ ├── Resources
│ │ ├── Base.lproj
│ │ │ └── LaunchScreen.storyboard
│ │ ├── en.lproj
│ │ │ └── Localizable.strings
│ │ └── zh-Hans.lproj
│ │ │ └── Localizable.strings
│ ├── SceneDelegate.swift
│ └── main.swift
├── MomentsTests
│ ├── Features
│ │ └── Moments
│ │ │ ├── Analytics
│ │ │ ├── LikeActionTrackingEventTests.swift
│ │ │ └── UnlikeActionTrackingEventTests.swift
│ │ │ ├── Networking
│ │ │ ├── GetMomentsByUserIDSessionTests.swift
│ │ │ └── UpdateMomentLikeSessionTests.swift
│ │ │ ├── Repositories
│ │ │ └── MomentsRepoTests.swift
│ │ │ └── ViewModels
│ │ │ ├── MomentListItemViewModelTests.swift
│ │ │ ├── MomentsTimelineViewModelTests.swift
│ │ │ └── UserProfileListItemViewModelTests.swift
│ ├── Info.plist
│ ├── MomentsTests.swift
│ └── Utilities
│ │ ├── EquatableViaDump.swift
│ │ ├── MockError.swift
│ │ ├── MockNow.swift
│ │ ├── MockTrackingRepo.swift
│ │ ├── TestFixture.swift
│ │ └── TestObserver.swift
├── MomentsUITests
│ ├── Info.plist
│ └── MomentsUITests.swift
├── RoutingSource.swift
└── swiftgen.yml
├── Playgrounds
└── RxSwiftPlayground.playground
│ ├── Contents.swift
│ ├── Sources
│ └── Ultils.swift
│ └── contents.xcplayground
├── Podfile
├── Podfile.lock
├── README.md
├── fastlane
├── Fastfile
├── Pluginfile
└── README.md
└── scripts
├── export_env.sh
├── increment_build_number.sh
├── setup.sh
└── sort-Xcode-project-file.pl
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: '[BUG] - '
5 | labels: 'bug'
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '.... screen'
16 | 2. Tap on '.... button'
17 | 3. Scroll down to '.... view/cell'
18 | 4. See '....error'
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Smartphone (please complete the following information):**
27 | - Device: [e.g. Simulator, iPhone12 Pro Max]
28 | - OS: [e.g. iOS 14.1]
29 | - Version [e.g. 22]
30 |
31 | **Additional context**
32 | Add any other context about the problem here.
33 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: '[Feature request] - '
5 | labels: 'enhancement'
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 | ## Summary
2 |
3 | - Github issue/doc: _link_
4 | - Card: _link_
5 |
6 | _A clear and concise description of this PR. e.g. Adding Link button to moments screen_
7 |
8 | ## Details
9 | ### Description
10 | _Long description of this PR._
11 | - _Why are we doing this?_
12 | - _Any other related context_
13 |
14 | ### Screengrabs (if applicable)
15 |
16 | _Optional but highly recommended._
17 |
18 | | Before | After |
19 | | - | - |
20 | | _before_ | _after_ |
21 |
22 | ## Quality Analysis
23 |
24 | - [ ] Unit tests that cover all added and changed code
25 | - [ ] Tested on Simulator
26 | - [ ] Tested on iPhone
27 | - [ ] Tested on iPad (if applicable)
28 |
29 | **Testing steps:**
30 |
31 | 0. _Step 1_
32 | 0. _Step 2_
33 | 0. _..._
34 |
35 | ## Checklist
36 |
37 | * [ ] Has feature toggling been considered?
38 | * [ ] Has tested both dark mode and light mode if there is any UI change?
39 | * [ ] Has tested Dynamic Type if there is any UI change?
40 | * [ ] Has tested language support for multiple locales if there is any UI change?
41 | * [ ] Have new test cases been unit tested?
42 | * [ ] Have run `bundle exec fastlane prepare_pr`?
43 | * [ ] Need to labelled the PR? (If applicable: e.g. added new dependencies etc.)
44 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 |
2 | # Created by https://www.toptal.com/developers/gitignore/api/xcode,swift,swiftpackagemanager,cocoapods,appcode
3 | # Edit at https://www.toptal.com/developers/gitignore?templates=xcode,swift,swiftpackagemanager,cocoapods,appcode
4 |
5 | ### AppCode ###
6 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
7 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
8 |
9 | # User-specific stuff
10 | .idea/**/workspace.xml
11 | .idea/**/tasks.xml
12 | .idea/**/usage.statistics.xml
13 | .idea/**/dictionaries
14 | .idea/**/shelf
15 |
16 | # Generated files
17 | .idea/**/contentModel.xml
18 |
19 | # Sensitive or high-churn files
20 | .idea/**/dataSources/
21 | .idea/**/dataSources.ids
22 | .idea/**/dataSources.local.xml
23 | .idea/**/sqlDataSources.xml
24 | .idea/**/dynamic.xml
25 | .idea/**/uiDesigner.xml
26 | .idea/**/dbnavigator.xml
27 |
28 | # Gradle
29 | .idea/**/gradle.xml
30 | .idea/**/libraries
31 |
32 | # Gradle and Maven with auto-import
33 | # When using Gradle or Maven with auto-import, you should exclude module files,
34 | # since they will be recreated, and may cause churn. Uncomment if using
35 | # auto-import.
36 | # .idea/artifacts
37 | # .idea/compiler.xml
38 | # .idea/jarRepositories.xml
39 | # .idea/modules.xml
40 | # .idea/*.iml
41 | # .idea/modules
42 | # *.iml
43 | # *.ipr
44 |
45 | # CMake
46 | cmake-build-*/
47 |
48 | # Mongo Explorer plugin
49 | .idea/**/mongoSettings.xml
50 |
51 | # File-based project format
52 | *.iws
53 |
54 | # IntelliJ
55 | out/
56 |
57 | # mpeltonen/sbt-idea plugin
58 | .idea_modules/
59 |
60 | # JIRA plugin
61 | atlassian-ide-plugin.xml
62 |
63 | # Cursive Clojure plugin
64 | .idea/replstate.xml
65 |
66 | # Crashlytics plugin (for Android Studio and IntelliJ)
67 | com_crashlytics_export_strings.xml
68 | crashlytics.properties
69 | crashlytics-build.properties
70 | fabric.properties
71 |
72 | # Editor-based Rest Client
73 | .idea/httpRequests
74 |
75 | # Android studio 3.1+ serialized cache file
76 | .idea/caches/build_file_checksums.ser
77 |
78 | ### AppCode Patch ###
79 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
80 |
81 | # *.iml
82 | # modules.xml
83 | # .idea/misc.xml
84 | # *.ipr
85 |
86 | # Sonarlint plugin
87 | # https://plugins.jetbrains.com/plugin/7973-sonarlint
88 | .idea/**/sonarlint/
89 |
90 | # SonarQube Plugin
91 | # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin
92 | .idea/**/sonarIssues.xml
93 |
94 | # Markdown Navigator plugin
95 | # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced
96 | .idea/**/markdown-navigator.xml
97 | .idea/**/markdown-navigator-enh.xml
98 | .idea/**/markdown-navigator/
99 |
100 | # Cache file creation bug
101 | # See https://youtrack.jetbrains.com/issue/JBR-2257
102 | .idea/$CACHE_FILE$
103 |
104 | # CodeStream plugin
105 | # https://plugins.jetbrains.com/plugin/12206-codestream
106 | .idea/codestream.xml
107 |
108 | ### CocoaPods ###
109 | ## CocoaPods GitIgnore Template
110 |
111 | # CocoaPods - Only use to conserve bandwidth / Save time on Pushing
112 | # - Also handy if you have a large number of dependant pods
113 | # - AS PER https://guides.cocoapods.org/using/using-cocoapods.html NEVER IGNORE THE LOCK FILE
114 | Pods/
115 |
116 | ### Swift ###
117 | # Xcode
118 | #
119 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
120 |
121 | ## User settings
122 | xcuserdata/
123 |
124 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
125 | *.xcscmblueprint
126 | *.xccheckout
127 |
128 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
129 | build/
130 | DerivedData/
131 | *.moved-aside
132 | *.pbxuser
133 | !default.pbxuser
134 | *.mode1v3
135 | !default.mode1v3
136 | *.mode2v3
137 | !default.mode2v3
138 | *.perspectivev3
139 | !default.perspectivev3
140 |
141 | ## Obj-C/Swift specific
142 | *.hmap
143 |
144 | ## App packaging
145 | *.ipa
146 | *.dSYM.zip
147 | *.dSYM
148 |
149 | ## Playgrounds
150 | timeline.xctimeline
151 | playground.xcworkspace
152 |
153 | # Swift Package Manager
154 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
155 | # Packages/
156 | # Package.pins
157 | # Package.resolved
158 | # *.xcodeproj
159 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
160 | # hence it is not needed unless you have added a package configuration file to your project
161 | # .swiftpm
162 |
163 | .build/
164 |
165 | # CocoaPods
166 | # We recommend against adding the Pods directory to your .gitignore. However
167 | # you should judge for yourself, the pros and cons are mentioned at:
168 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
169 | # Pods/
170 | # Add this line if you want to avoid checking in source code from the Xcode workspace
171 | # *.xcworkspace
172 |
173 | # Carthage
174 | # Add this line if you want to avoid checking in source code from Carthage dependencies.
175 | # Carthage/Checkouts
176 |
177 | Carthage/Build/
178 |
179 | # Accio dependency management
180 | Dependencies/
181 | .accio/
182 |
183 | # fastlane
184 | # It is recommended to not store the screenshots in the git repo.
185 | # Instead, use fastlane to re-generate the screenshots whenever they are needed.
186 | # For more information about the recommended setup visit:
187 | # https://docs.fastlane.tools/best-practices/source-control/#source-control
188 |
189 | fastlane/report.xml
190 | fastlane/Preview.html
191 | fastlane/screenshots/**/*.png
192 | fastlane/test_output
193 |
194 | # Code Injection
195 | # After new code Injection tools there's a generated folder /iOSInjectionProject
196 | # https://github.com/johnno1962/injectionforxcode
197 |
198 | iOSInjectionProject/
199 |
200 | ### SwiftPackageManager ###
201 | # Packages
202 | xcuserdata
203 | # *.xcodeproj
204 |
205 |
206 | ### Xcode ###
207 | # Xcode
208 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
209 |
210 |
211 |
212 |
213 | ## Gcc Patch
214 | /*.gcno
215 |
216 | ### Xcode Patch ###
217 | *.xcodeproj/*
218 | !*.xcodeproj/project.pbxproj
219 | !*.xcodeproj/xcshareddata/
220 | !*.xcworkspace/contents.xcworkspacedata
221 | **/xcshareddata/WorkspaceSettings.xcsettings
222 |
223 | # End of https://www.toptal.com/developers/gitignore/api/xcode,swift,swiftpackagemanager,cocoapods,appcode
224 |
225 | # fastlane specific
226 | fastlane/report.xml
227 |
228 | # deliver temporary files
229 | fastlane/Preview.html
230 |
231 | # snapshot generated screenshots
232 | fastlane/screenshots/**/*.png
233 | fastlane/screenshots/screenshots.html
234 |
235 | # scan temporary files
236 | fastlane/test_output
237 |
238 | # Fastlane.swift runner binary
239 | fastlane/FastlaneRunner
240 |
241 | fastlane/dist
242 |
243 | # Keys
244 | local.keys
245 |
--------------------------------------------------------------------------------
/.ruby-version:
--------------------------------------------------------------------------------
1 | 2.7.2
2 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: swift
2 | osx_image: xcode12.2
3 | env:
4 | global:
5 | - CI_BUILD_NUMBER=${TRAVIS_BUILD_NUMBER}
6 | before_install:
7 | - bundle install
8 | - bundle exec pod install
9 |
10 | jobs:
11 | include:
12 | - stage: "Build"
13 | name: "Build internal app"
14 | script:
15 | - set -o pipefail
16 | - echo "machine github.com login $GITHUB_API_TOKEN" >> ~/.netrc
17 | - bundle exec fastlane download_profiles
18 | - bundle exec fastlane archive_internal
19 |
20 | - stage: "Test"
21 | name: "Test app"
22 | script:
23 | - set -o pipefail
24 | - bundle exec fastlane tests
25 |
26 | - stage: "Archive, sign and deploy internal app"
27 | name: "Archive Internal app"
28 | if: branch = main
29 | script:
30 | - set -o pipefail
31 | - echo "machine github.com login $GITHUB_API_TOKEN" >> ~/.netrc
32 | - bundle exec fastlane download_profiles
33 | - ./scripts/increment_build_number.sh
34 | - bundle exec fastlane archive_internal
35 | - bundle exec fastlane upload_symbols_to_crashlytics_internal
36 | - bundle exec fastlane deploy_internal
37 |
38 | - stage: "Archive, sign and deploy production app"
39 | name: "Archive Production app"
40 | if: branch = main # usually use `if: branch = release`
41 | script:
42 | - set -o pipefail
43 | - echo "machine github.com login $GITHUB_API_TOKEN" >> ~/.netrc
44 | - bundle exec fastlane download_profiles
45 | - ./scripts/increment_build_number.sh
46 | - bundle exec fastlane archive_appstore
47 | - bundle exec fastlane upload_symbols_to_crashlytics_appstore
48 | - bundle exec fastlane deploy_appstore
49 |
50 | after_success:
51 | - sleep 5 # Workaround for https://github.com/travis-ci/travis-ci/issues/4725
--------------------------------------------------------------------------------
/Frameworks/DesignKit/DesignKit.podspec:
--------------------------------------------------------------------------------
1 | # Start from https://github.com/CocoaPods/pod-template/blob/master/NAME.podspec
2 | #
3 | # Be sure to run `pod lib lint ${POD_NAME}.podspec' to ensure this is a
4 | # valid spec before submitting.
5 | #
6 | # Any lines starting with a # are optional, but their use is encouraged
7 | # To learn more about a Podspec see https://guides.cocoapods.org/syntax/podspec.html
8 | #
9 |
10 | Pod::Spec.new do |s|
11 | s.name = 'DesignKit'
12 | s.version = '1.0.0'
13 | s.summary = 'Design components'
14 |
15 | # This description is used to generate tags and improve search results.
16 | # * Think: What does it do? Why did you write it? What is the focus?
17 | # * Try to keep it short, snappy and to the point.
18 | # * Write the description between the DESC delimiters below.
19 | # * Finally, don't worry about the indent, CocoaPods strips it!
20 |
21 | s.description = <<-DESC
22 | Contains the decomponents for Design System.
23 | DESC
24 |
25 | s.homepage = 'https://github.com/JakeLin/moments-ios'
26 | s.license = 'MIT'
27 | s.author = 'MIT'
28 | s.source = { :path => '.' }
29 |
30 | s.ios.deployment_target = '14.0'
31 | s.swift_versions = '5.3'
32 |
33 | s.source_files = 'src/**/*'
34 | # s.resources = 'assets/**/*'
35 |
36 | end
37 |
--------------------------------------------------------------------------------
/Frameworks/DesignKit/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Jake Lin
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/Frameworks/DesignKit/src/Avatar/UIImageViewExtensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Avatar.swift
3 | // DesignKit
4 | //
5 | // Created by Jake Lin on 20/10/20.
6 | //
7 |
8 | import UIKit
9 |
10 | public extension UIImageView {
11 | func asAvatar(cornerRadius: CGFloat = 4) {
12 | clipsToBounds = true
13 | layer.cornerRadius = cornerRadius
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/Frameworks/DesignKit/src/Color/UIColorExtensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIColor+Palette.swift
3 | // DesignKit
4 | //
5 | // Created by Jake Lin on 20/10/20.
6 | //
7 | // The colors are picked up from https://backpack.github.io/guidelines/colors
8 |
9 | import UIKit
10 |
11 | public extension UIColor {
12 | static let designKit = DesignKitPalette.self
13 |
14 | enum DesignKitPalette {
15 | public static let primary: UIColor = dynamicColor(light: UIColor(hex: 0x0770e3), dark: UIColor(hex: 0x6d9feb))
16 | public static let background: UIColor = dynamicColor(light: .white, dark: .black)
17 | public static let secondaryBackground: UIColor = dynamicColor(light: UIColor(hex: 0xf1f2f8), dark: UIColor(hex: 0x1D1B20))
18 | public static let tertiaryBackground: UIColor = dynamicColor(light: .white, dark: UIColor(hex: 0x2C2C2E))
19 | public static let line: UIColor = dynamicColor(light: UIColor(hex: 0xcdcdd7), dark: UIColor(hex: 0x48484A))
20 | public static let primaryText: UIColor = dynamicColor(light: UIColor(hex: 0x111236), dark: .white)
21 | public static let secondaryText: UIColor = dynamicColor(light: UIColor(hex: 0x68697f), dark: UIColor(hex: 0x8E8E93))
22 | public static let tertiaryText: UIColor = dynamicColor(light: UIColor(hex: 0x8f90a0), dark: UIColor(hex: 0x8E8E93))
23 | public static let quaternaryText: UIColor = dynamicColor(light: UIColor(hex: 0xb2b2bf), dark: UIColor(hex: 0x8E8E93))
24 |
25 | static private func dynamicColor(light: UIColor, dark: UIColor) -> UIColor {
26 | return UIColor { $0.userInterfaceStyle == .dark ? dark : light }
27 | }
28 | }
29 | }
30 |
31 | public extension UIColor {
32 | convenience init(hex: Int) {
33 | let components = (
34 | R: CGFloat((hex >> 16) & 0xff) / 255,
35 | G: CGFloat((hex >> 08) & 0xff) / 255,
36 | B: CGFloat((hex >> 00) & 0xff) / 255
37 | )
38 | self.init(red: components.R, green: components.G, blue: components.B, alpha: 1)
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/Frameworks/DesignKit/src/FavoriteButton/UIButtonExtensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIButtonExtensions.swift
3 | // DesignKit
4 | //
5 | // Created by Jake Lin on 2/11/20.
6 | //
7 |
8 | import Foundation
9 | import UIKit
10 |
11 | public extension UIButton {
12 | // swiftlint:disable no_hardcoded_strings
13 | func asStarFavoriteButton(pointSize: CGFloat = 18, weight: UIImage.SymbolWeight = .semibold, scale: UIImage.SymbolScale = .default, fillColor: UIColor = UIColor(hex: 0xf1c40f)) {
14 | let symbolConfiguration = UIImage.SymbolConfiguration(pointSize: pointSize, weight: weight, scale: scale)
15 | let starImage = UIImage(systemName: "star", withConfiguration: symbolConfiguration)
16 | setImage(starImage, for: .normal)
17 |
18 | let starFillImage = UIImage(systemName: "star.fill", withConfiguration: symbolConfiguration)
19 | setImage(starFillImage, for: .selected)
20 |
21 | tintColor = fillColor
22 | addTarget(self, action: #selector(touchUpInside), for: .touchUpInside)
23 | }
24 |
25 | func asHeartFavoriteButton(pointSize: CGFloat = 18, weight: UIImage.SymbolWeight = .semibold, scale: UIImage.SymbolScale = .default, fillColor: UIColor = UIColor(hex: 0xe74c3c)) {
26 | let symbolConfiguration = UIImage.SymbolConfiguration(pointSize: pointSize, weight: weight, scale: scale)
27 | let heartImage = UIImage(systemName: "heart", withConfiguration: symbolConfiguration)
28 | setImage(heartImage, for: .normal)
29 |
30 | let heartFillImage = UIImage(systemName: "heart.fill", withConfiguration: symbolConfiguration)
31 | setImage(heartFillImage, for: .selected)
32 |
33 | tintColor = fillColor
34 | addTarget(self, action: #selector(touchUpInside), for: .touchUpInside)
35 | }
36 | // swiftlint:enable no_hardcoded_strings
37 | }
38 |
39 | private extension UIButton {
40 | @objc
41 | private func touchUpInside(sender: UIButton) {
42 | isSelected = !isSelected
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/Frameworks/DesignKit/src/Font/UIFontExtensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIFont+Typography.swift
3 | // DesignKit
4 | //
5 | // Created by Jake Lin on 19/10/20.
6 | //
7 |
8 | import UIKit
9 |
10 | public extension UIFont {
11 | static let designKit = DesignKitTypography()
12 |
13 | struct DesignKitTypography {
14 | public var display1: UIFont {
15 | scaled(baseFont: .systemFont(ofSize: 42, weight: .semibold), forTextStyle: .largeTitle, maximumFactor: 1.5)
16 | }
17 |
18 | public var display2: UIFont {
19 | scaled(baseFont: .systemFont(ofSize: 36, weight: .semibold), forTextStyle: .largeTitle, maximumFactor: 1.5)
20 | }
21 |
22 | public var title1: UIFont {
23 | scaled(baseFont: .systemFont(ofSize: 24, weight: .semibold), forTextStyle: .title1)
24 | }
25 |
26 | public var title2: UIFont {
27 | scaled(baseFont: .systemFont(ofSize: 20, weight: .semibold), forTextStyle: .title2)
28 | }
29 |
30 | public var title3: UIFont {
31 | scaled(baseFont: .systemFont(ofSize: 18, weight: .semibold), forTextStyle: .title3)
32 | }
33 |
34 | public var title4: UIFont {
35 | scaled(baseFont: .systemFont(ofSize: 14, weight: .regular), forTextStyle: .headline)
36 | }
37 |
38 | public var title5: UIFont {
39 | scaled(baseFont: .systemFont(ofSize: 12, weight: .regular), forTextStyle: .subheadline)
40 | }
41 |
42 | public var bodyBold: UIFont {
43 | scaled(baseFont: .systemFont(ofSize: 16, weight: .semibold), forTextStyle: .body)
44 | }
45 |
46 | public var body: UIFont {
47 | scaled(baseFont: .systemFont(ofSize: 16, weight: .light), forTextStyle: .body)
48 | }
49 |
50 | public var captionBold: UIFont {
51 | scaled(baseFont: .systemFont(ofSize: 14, weight: .semibold), forTextStyle: .caption1)
52 | }
53 |
54 | public var caption: UIFont {
55 | scaled(baseFont: .systemFont(ofSize: 14, weight: .light), forTextStyle: .caption1)
56 | }
57 |
58 | public var small: UIFont {
59 | scaled(baseFont: .systemFont(ofSize: 12, weight: .light), forTextStyle: .footnote)
60 | }
61 | }
62 | }
63 |
64 | private extension UIFont.DesignKitTypography {
65 | func scaled(baseFont: UIFont, forTextStyle textStyle: UIFont.TextStyle = .body, maximumFactor: CGFloat? = nil) -> UIFont {
66 | let fontMetrics = UIFontMetrics(forTextStyle: textStyle)
67 |
68 | if let maximumFactor = maximumFactor {
69 | let maximumPointSize = baseFont.pointSize * maximumFactor
70 | return fontMetrics.scaledFont(for: baseFont, maximumPointSize: maximumPointSize)
71 | }
72 | return fontMetrics.scaledFont(for: baseFont)
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/Frameworks/DesignKit/src/Spacing/Spacing.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Spacing.swift
3 | // DesignKit
4 | //
5 | // Created by Jake Lin on 24/10/20.
6 | //
7 |
8 | public struct Spacing {
9 | public static let twoExtraSmall: CGFloat = 4
10 | public static let extraSmall: CGFloat = 8
11 | public static let small: CGFloat = 12
12 | public static let medium: CGFloat = 18
13 | public static let large: CGFloat = 24
14 | public static let extraLarge: CGFloat = 32
15 | public static let twoExtraLarge: CGFloat = 40
16 | public static let threeExtraLarge: CGFloat = 48
17 | }
18 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | source "https://rubygems.org"
4 |
5 | git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
6 |
7 | gem "cocoapods", "1.11.2"
8 | gem "fastlane", "2.180.1"
9 |
10 | plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
11 | eval_gemfile(plugins_path) if File.exist?(plugins_path)
12 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Jake Lin
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Moments.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
7 |
9 |
10 |
11 |
13 |
14 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/Moments.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Moments/.swiftlint.yml:
--------------------------------------------------------------------------------
1 | only_rules:
2 | - array_init
3 | - attributes
4 | - block_based_kvo
5 | - class_delegate_protocol
6 | - closing_brace
7 | - closure_end_indentation
8 | - closure_parameter_position
9 | - closure_spacing
10 | - collection_alignment
11 | - comma
12 | - compiler_protocol_init
13 | - contains_over_filter_count
14 | - contains_over_filter_is_empty
15 | - contains_over_first_not_nil
16 | - contains_over_range_nil_comparison
17 | - control_statement
18 | - custom_rules
19 | - deployment_target
20 | - discarded_notification_center_observer
21 | - duplicate_enum_cases
22 | - duplicate_imports
23 | - dynamic_inline
24 | - empty_collection_literal
25 | - empty_count
26 | - empty_enum_arguments
27 | - empty_parameters
28 | - empty_parentheses_with_trailing_closure
29 | - empty_string
30 | - explicit_init
31 | - explicit_self
32 | - extension_access_modifier
33 | - fatal_error_message
34 | - first_where
35 | - flatmap_over_map_reduce
36 | - for_where
37 | - force_cast
38 | - force_try
39 | - force_unwrapping
40 | - function_default_parameter_at_end
41 | - generic_type_name
42 | - identical_operands
43 | - implicit_getter
44 | - inert_defer
45 | - is_disjoint
46 | - joined_default_parameter
47 | - large_tuple
48 | - last_where
49 | - leading_whitespace
50 | - legacy_cggeometry_functions
51 | - legacy_constant
52 | - legacy_constructor
53 | - legacy_hashing
54 | - legacy_multiple
55 | - legacy_nsgeometry_functions
56 | - legacy_random
57 | - let_var_whitespace
58 | - lower_acl_than_parent
59 | - mark
60 | - sorted_first_last
61 | - multiple_closures_with_trailing_closure
62 | - no_fallthrough_only
63 | - nslocalizedstring_key
64 | - nslocalizedstring_require_bundle
65 | - nsobject_prefer_isequal
66 | - opening_brace
67 | - operator_usage_whitespace
68 | - operator_whitespace
69 | - overridden_super_call
70 | - pattern_matching_keywords
71 | - prefixed_toplevel_constant
72 | - private_action
73 | - private_outlet
74 | - private_over_fileprivate
75 | - private_unit_test
76 | - prohibited_interface_builder
77 | - prohibited_super_call
78 | - protocol_property_accessors_order
79 | - quick_discouraged_focused_test
80 | - quick_discouraged_pending_test
81 | - raw_value_for_camel_cased_codable_enum
82 | - reduce_boolean
83 | - reduce_into
84 | - redundant_discardable_let
85 | - redundant_nil_coalescing
86 | - redundant_objc_attribute
87 | - redundant_optional_initialization
88 | - redundant_set_access_control
89 | - redundant_string_enum_value
90 | - redundant_void_return
91 | - required_enum_case
92 | - return_arrow_whitespace
93 | - shorthand_operator
94 | - single_test_class
95 | - statement_position
96 | - static_operator
97 | - superfluous_disable_command
98 | - switch_case_alignment
99 | - syntactic_sugar
100 | - todo
101 | - trailing_comma
102 | - trailing_newline
103 | - trailing_semicolon
104 | - trailing_whitespace
105 | - type_name
106 | - unavailable_function
107 | - unneeded_break_in_switch
108 | - unneeded_parentheses_in_closure_argument
109 | - unowned_variable_capture
110 | - untyped_error_in_catch
111 | - unused_capture_list
112 | - unused_closure_parameter
113 | - unused_control_flow_label
114 | - unused_declaration
115 | - unused_enumerated
116 | - unused_import
117 | - unused_optional_binding
118 | - unused_setter_value
119 | - vertical_whitespace
120 | - vertical_whitespace_closing_braces
121 | - vertical_whitespace_opening_braces
122 | - void_return
123 | - xct_specific_matcher
124 | - yoda_condition
125 |
126 | included:
127 | - "../Frameworks/DesignKit"
128 |
129 | excluded:
130 | - Pods
131 | - "./MomentsTests"
132 | - "./MomentsUITests"
133 | - "./Moments/MomentsTests"
134 | - "./Moments/MomentsUITests"
135 |
136 | custom_rules:
137 | no_hardcoded_strings:
138 | regex: "([A-Za-z]+)"
139 | match_kinds: string
140 | message: "Please do not hardcode strings and add them to the appropriate `Localizable.strings` file; a build script compiles all strings into strongly typed resources available through `Generated/Strings.swift`, e.g. `L10n.accessCamera"
141 | severity: warning
142 |
--------------------------------------------------------------------------------
/Moments/Moments.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Moments/Moments.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Moments/Moments.xcodeproj/xcshareddata/xcschemes/Moments-AppStore.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
30 |
31 |
32 |
33 |
43 |
45 |
51 |
52 |
53 |
54 |
60 |
62 |
68 |
69 |
70 |
71 |
73 |
74 |
77 |
78 |
79 |
--------------------------------------------------------------------------------
/Moments/Moments.xcodeproj/xcshareddata/xcschemes/Moments-Internal.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
30 |
31 |
32 |
33 |
43 |
45 |
51 |
52 |
53 |
54 |
60 |
62 |
68 |
69 |
70 |
71 |
73 |
74 |
77 |
78 |
79 |
--------------------------------------------------------------------------------
/Moments/Moments.xcodeproj/xcshareddata/xcschemes/Moments.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
30 |
31 |
33 |
39 |
40 |
41 |
43 |
49 |
50 |
51 |
52 |
53 |
63 |
65 |
71 |
72 |
73 |
74 |
77 |
78 |
79 |
80 |
86 |
88 |
94 |
95 |
96 |
97 |
99 |
100 |
103 |
104 |
105 |
--------------------------------------------------------------------------------
/Moments/Moments/AppDelegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppDelegate.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 15/10/20.
6 | //
7 |
8 | import UIKit
9 | import Firebase
10 |
11 | class AppDelegate: UIResponder, UIApplicationDelegate {
12 | func application(_ application: UIApplication,
13 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
14 | // Override point for customization after application launch.
15 | onLaunch()
16 |
17 | return true
18 | }
19 |
20 | // MARK: Handle Universal Links here if not opt into Scenes
21 | func application(_ application: UIApplication,
22 | continue userActivity: NSUserActivity,
23 | restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
24 | // Get URL components from the incoming user activity.
25 | guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
26 | let incomingURL = userActivity.webpageURL else {
27 | return false
28 | }
29 |
30 | let router: AppRouting = AppRouter.shared
31 | router.route(to: incomingURL, from: nil, using: .present)
32 | return true
33 | }
34 |
35 | // MARK: UISceneSession Lifecycle
36 |
37 | func application(_ application: UIApplication,
38 | configurationForConnecting connectingSceneSession: UISceneSession,
39 | options: UIScene.ConnectionOptions) -> UISceneConfiguration {
40 | // Called when a new scene session is being created.
41 | // Use this method to select a configuration to create the new scene with.
42 | return UISceneConfiguration(name: L10n.Development.defaultConfiguration, sessionRole: connectingSceneSession.role)
43 | }
44 |
45 | func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) {
46 | // Called when the user discards a scene session.
47 | // If any sessions were discarded while the application was not running,
48 | // this will be called shortly after application:didFinishLaunchingWithOptions.
49 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return.
50 | }
51 | }
52 |
53 | private extension AppDelegate {
54 | func onLaunch() {
55 | FirebaseApp.configure()
56 |
57 | // Can register multiple tracking providers here
58 | [FirebaseTrackingProvider()].forEach {
59 | TrackingRepo.shared.register(trackingProvider: $0)
60 | }
61 |
62 | // Register routing here
63 | let router: AppRouting = AppRouter.shared
64 | // swiftlint:disable no_hardcoded_strings
65 | router.register(path: "InternalMenu", navigator: InternalMenuNavigator())
66 | router.register(path: "DesignKit", navigator: DesignKitDemoNavigator())
67 | // swiftlint:enable no_hardcoded_strings
68 |
69 | let togglesDataStore: TogglesDataStoreType = BuildTargetTogglesDataStore.shared
70 | if togglesDataStore.isToggleOn(BuildTargetToggle.debug) {
71 | // There is still a bug in the Firebase Console, so the ID won't work until they fix it
72 | // https://github.com/firebase/firebase-ios-sdk/issues/6892#issuecomment-721795650
73 | Installations.installations().authTokenForcingRefresh(true) { token, error in
74 | // swiftlint:disable no_hardcoded_strings
75 | if let error = error {
76 | print("Error fetching token: \(error)")
77 | return
78 | }
79 | guard let token = token else { return }
80 | Installations.installations().installationID { id, _ in
81 | print("Auth token: \(token.authToken)\nExpiration date: \(token.expirationDate)\nInstallation id: \(id ?? "invalid")")
82 | }
83 | // swiftlint:enable no_hardcoded_strings
84 | }
85 | }
86 | }
87 | }
88 |
89 | extension UIWindow {
90 | override open func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
91 | let togglesDataStore: TogglesDataStoreType = BuildTargetTogglesDataStore.shared
92 | if togglesDataStore.isToggleOn(BuildTargetToggle.debug)
93 | || togglesDataStore.isToggleOn(BuildTargetToggle.internal) {
94 | let router: AppRouting = AppRouter.shared
95 | if motion == .motionShake {
96 | let trackingRepo: TrackingRepoType = TrackingRepo.shared
97 | // swiftlint:disable no_hardcoded_strings
98 | trackingRepo.trackEvent(TrackingEvent(name: "shake", parameters: ["userID": UserDataStore.current.userID, "datetime": Date()]))
99 | router.route(to: URL(string: "\(UniversalLinks.baseURL)InternalMenu"), from: rootViewController, using: .present)
100 | // swiftlint:enable no_hardcoded_strings
101 | }
102 | }
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/Moments/Moments/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0.890",
9 | "green" : "0.439",
10 | "red" : "0.027"
11 | }
12 | },
13 | "idiom" : "universal"
14 | },
15 | {
16 | "appearances" : [
17 | {
18 | "appearance" : "luminosity",
19 | "value" : "dark"
20 | }
21 | ],
22 | "color" : {
23 | "color-space" : "srgb",
24 | "components" : {
25 | "alpha" : "1.000",
26 | "blue" : "0.922",
27 | "green" : "0.624",
28 | "red" : "0.427"
29 | }
30 | },
31 | "idiom" : "universal"
32 | }
33 | ],
34 | "info" : {
35 | "author" : "xcode",
36 | "version" : 1
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/Moments/Moments/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "icon-ios-20@2x.png",
5 | "idiom" : "iphone",
6 | "scale" : "2x",
7 | "size" : "20x20"
8 | },
9 | {
10 | "filename" : "icon-ios-20@3x.png",
11 | "idiom" : "iphone",
12 | "scale" : "3x",
13 | "size" : "20x20"
14 | },
15 | {
16 | "filename" : "icon-ios-29@2x.png",
17 | "idiom" : "iphone",
18 | "scale" : "2x",
19 | "size" : "29x29"
20 | },
21 | {
22 | "filename" : "icon-ios-29@3x.png",
23 | "idiom" : "iphone",
24 | "scale" : "3x",
25 | "size" : "29x29"
26 | },
27 | {
28 | "filename" : "icon-ios-40@2x.png",
29 | "idiom" : "iphone",
30 | "scale" : "2x",
31 | "size" : "40x40"
32 | },
33 | {
34 | "filename" : "icon-ios-40@3x.png",
35 | "idiom" : "iphone",
36 | "scale" : "3x",
37 | "size" : "40x40"
38 | },
39 | {
40 | "filename" : "icon-ios-60@2x.png",
41 | "idiom" : "iphone",
42 | "scale" : "2x",
43 | "size" : "60x60"
44 | },
45 | {
46 | "filename" : "icon-ios-60@3x.png",
47 | "idiom" : "iphone",
48 | "scale" : "3x",
49 | "size" : "60x60"
50 | },
51 | {
52 | "idiom" : "ipad",
53 | "scale" : "1x",
54 | "size" : "20x20"
55 | },
56 | {
57 | "idiom" : "ipad",
58 | "scale" : "2x",
59 | "size" : "20x20"
60 | },
61 | {
62 | "idiom" : "ipad",
63 | "scale" : "1x",
64 | "size" : "29x29"
65 | },
66 | {
67 | "idiom" : "ipad",
68 | "scale" : "2x",
69 | "size" : "29x29"
70 | },
71 | {
72 | "idiom" : "ipad",
73 | "scale" : "1x",
74 | "size" : "40x40"
75 | },
76 | {
77 | "idiom" : "ipad",
78 | "scale" : "2x",
79 | "size" : "40x40"
80 | },
81 | {
82 | "idiom" : "ipad",
83 | "scale" : "1x",
84 | "size" : "76x76"
85 | },
86 | {
87 | "filename" : "icon-ios-76@2x.png",
88 | "idiom" : "ipad",
89 | "scale" : "2x",
90 | "size" : "76x76"
91 | },
92 | {
93 | "filename" : "icon-ios-83.5@2x.png",
94 | "idiom" : "ipad",
95 | "scale" : "2x",
96 | "size" : "83.5x83.5"
97 | },
98 | {
99 | "filename" : "icon-ios-1024@1x.png",
100 | "idiom" : "ios-marketing",
101 | "scale" : "1x",
102 | "size" : "1024x1024"
103 | }
104 | ],
105 | "info" : {
106 | "author" : "xcode",
107 | "version" : 1
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/Moments/Moments/Assets.xcassets/AppIcon.appiconset/icon-ios-1024@1x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lagoueduCol/iOS-linyongjian/04f46daa34adb861eea3dffb08b0f4672cd72b88/Moments/Moments/Assets.xcassets/AppIcon.appiconset/icon-ios-1024@1x.png
--------------------------------------------------------------------------------
/Moments/Moments/Assets.xcassets/AppIcon.appiconset/icon-ios-20@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lagoueduCol/iOS-linyongjian/04f46daa34adb861eea3dffb08b0f4672cd72b88/Moments/Moments/Assets.xcassets/AppIcon.appiconset/icon-ios-20@2x.png
--------------------------------------------------------------------------------
/Moments/Moments/Assets.xcassets/AppIcon.appiconset/icon-ios-20@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lagoueduCol/iOS-linyongjian/04f46daa34adb861eea3dffb08b0f4672cd72b88/Moments/Moments/Assets.xcassets/AppIcon.appiconset/icon-ios-20@3x.png
--------------------------------------------------------------------------------
/Moments/Moments/Assets.xcassets/AppIcon.appiconset/icon-ios-29@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lagoueduCol/iOS-linyongjian/04f46daa34adb861eea3dffb08b0f4672cd72b88/Moments/Moments/Assets.xcassets/AppIcon.appiconset/icon-ios-29@2x.png
--------------------------------------------------------------------------------
/Moments/Moments/Assets.xcassets/AppIcon.appiconset/icon-ios-29@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lagoueduCol/iOS-linyongjian/04f46daa34adb861eea3dffb08b0f4672cd72b88/Moments/Moments/Assets.xcassets/AppIcon.appiconset/icon-ios-29@3x.png
--------------------------------------------------------------------------------
/Moments/Moments/Assets.xcassets/AppIcon.appiconset/icon-ios-40@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lagoueduCol/iOS-linyongjian/04f46daa34adb861eea3dffb08b0f4672cd72b88/Moments/Moments/Assets.xcassets/AppIcon.appiconset/icon-ios-40@2x.png
--------------------------------------------------------------------------------
/Moments/Moments/Assets.xcassets/AppIcon.appiconset/icon-ios-40@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lagoueduCol/iOS-linyongjian/04f46daa34adb861eea3dffb08b0f4672cd72b88/Moments/Moments/Assets.xcassets/AppIcon.appiconset/icon-ios-40@3x.png
--------------------------------------------------------------------------------
/Moments/Moments/Assets.xcassets/AppIcon.appiconset/icon-ios-60@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lagoueduCol/iOS-linyongjian/04f46daa34adb861eea3dffb08b0f4672cd72b88/Moments/Moments/Assets.xcassets/AppIcon.appiconset/icon-ios-60@2x.png
--------------------------------------------------------------------------------
/Moments/Moments/Assets.xcassets/AppIcon.appiconset/icon-ios-60@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lagoueduCol/iOS-linyongjian/04f46daa34adb861eea3dffb08b0f4672cd72b88/Moments/Moments/Assets.xcassets/AppIcon.appiconset/icon-ios-60@3x.png
--------------------------------------------------------------------------------
/Moments/Moments/Assets.xcassets/AppIcon.appiconset/icon-ios-76@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lagoueduCol/iOS-linyongjian/04f46daa34adb861eea3dffb08b0f4672cd72b88/Moments/Moments/Assets.xcassets/AppIcon.appiconset/icon-ios-76@2x.png
--------------------------------------------------------------------------------
/Moments/Moments/Assets.xcassets/AppIcon.appiconset/icon-ios-83.5@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lagoueduCol/iOS-linyongjian/04f46daa34adb861eea3dffb08b0f4672cd72b88/Moments/Moments/Assets.xcassets/AppIcon.appiconset/icon-ios-83.5@2x.png
--------------------------------------------------------------------------------
/Moments/Moments/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/Moments/Moments/Configurations/AppStoreProject.xcconfig:
--------------------------------------------------------------------------------
1 | #include "BaseProject.xcconfig"
2 |
3 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) PRODUCTION
4 |
--------------------------------------------------------------------------------
/Moments/Moments/Configurations/AppStoreTarget.xcconfig:
--------------------------------------------------------------------------------
1 | #include "../Pods/Target Support Files/Pods-Moments/Pods-Moments.appstore.xcconfig"
2 | #include "BaseTarget.xcconfig"
3 |
4 | PRODUCT_BUNDLE_NAME = $(inherited)
5 | PRODUCT_BUNDLE_IDENTIFIER = com.ibanimatable.moments
6 | FIREBASE_CONFIG_FILENAME = GoogleService-Info-AppStore
7 |
--------------------------------------------------------------------------------
/Moments/Moments/Configurations/BaseConfigurations.xcconfig:
--------------------------------------------------------------------------------
1 | //
2 | // BaseConfigurations.xcconfig
3 | // Moments
4 | //
5 | // Created by Jake Lin on 25/10/20.
6 | //
7 |
8 | // Configuration settings file format documentation can be found at:
9 | // https://help.apple.com/xcode/#/dev745c5c974
10 | //
11 | // Store App base configurations like API URLs
12 |
13 | API_BASE_URL = momentsapi.herokuapp.com
14 |
--------------------------------------------------------------------------------
/Moments/Moments/Configurations/BaseProject.xcconfig:
--------------------------------------------------------------------------------
1 | #include "CompilerAndLanguage.xcconfig"
2 | #include "SDKAndDeviceSupport.xcconfig"
3 | #include "BaseConfigurations.xcconfig"
4 |
--------------------------------------------------------------------------------
/Moments/Moments/Configurations/BaseTarget.xcconfig:
--------------------------------------------------------------------------------
1 | // Versioning
2 | PRODUCT_VERSION_SUFFIX=0
3 | PRODUCT_VERSION_BASE = 1.0.0
4 | PRODUCT_VERSION = $(PRODUCT_VERSION_BASE).$(PRODUCT_VERSION_SUFFIX)
5 |
6 | PRODUCT_BUNDLE_NAME = Moments
7 |
--------------------------------------------------------------------------------
/Moments/Moments/Configurations/CompilerAndLanguage.xcconfig:
--------------------------------------------------------------------------------
1 | // Apple LLVM 9.0 - Language - Modules
2 | CLANG_ENABLE_MODULES = YES
3 | SWIFT_VERSION = 5.0
4 | EXCLUDED_ARCHS[sdk=iphonesimulator*] = arm64
5 |
--------------------------------------------------------------------------------
/Moments/Moments/Configurations/DebugProject.xcconfig:
--------------------------------------------------------------------------------
1 | #include "BaseProject.xcconfig"
2 |
3 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) DEBUG
4 |
--------------------------------------------------------------------------------
/Moments/Moments/Configurations/DebugTarget.xcconfig:
--------------------------------------------------------------------------------
1 | #include "../Pods/Target Support Files/Pods-Moments/Pods-Moments.debug.xcconfig"
2 | #include "BaseTarget.xcconfig"
3 |
4 | PRODUCT_BUNDLE_NAME = $(inherited) α
5 | PRODUCT_BUNDLE_IDENTIFIER = com.ibanimatable.moments.development
6 | FIREBASE_CONFIG_FILENAME = GoogleService-Info-Development
7 |
--------------------------------------------------------------------------------
/Moments/Moments/Configurations/Firebase/GoogleService-Info-AppStore.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CLIENT_ID
6 | 374168413412-org6qh9ivjo6qnn4tk2b3lr3d2ie09m4.apps.googleusercontent.com
7 | REVERSED_CLIENT_ID
8 | com.googleusercontent.apps.374168413412-org6qh9ivjo6qnn4tk2b3lr3d2ie09m4
9 | API_KEY
10 | AIzaSyAC-ZpOnV2luh0JdacvODnoh_EarVRAZtU
11 | GCM_SENDER_ID
12 | 374168413412
13 | PLIST_VERSION
14 | 1
15 | BUNDLE_ID
16 | com.ibanimatable.moments
17 | PROJECT_ID
18 | moments-app-project
19 | STORAGE_BUCKET
20 | moments-app-project.appspot.com
21 | IS_ADS_ENABLED
22 |
23 | IS_ANALYTICS_ENABLED
24 |
25 | IS_APPINVITE_ENABLED
26 |
27 | IS_GCM_ENABLED
28 |
29 | IS_SIGNIN_ENABLED
30 |
31 | GOOGLE_APP_ID
32 | 1:374168413412:ios:65c73876317e91c8a038f1
33 | DATABASE_URL
34 | https://moments-app-project.firebaseio.com
35 |
36 |
--------------------------------------------------------------------------------
/Moments/Moments/Configurations/Firebase/GoogleService-Info-Development.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CLIENT_ID
6 | 374168413412-14hl40mmnhq4p54g26l5krraako1863g.apps.googleusercontent.com
7 | REVERSED_CLIENT_ID
8 | com.googleusercontent.apps.374168413412-14hl40mmnhq4p54g26l5krraako1863g
9 | API_KEY
10 | AIzaSyAC-ZpOnV2luh0JdacvODnoh_EarVRAZtU
11 | GCM_SENDER_ID
12 | 374168413412
13 | PLIST_VERSION
14 | 1
15 | BUNDLE_ID
16 | com.ibanimatable.moments.development
17 | PROJECT_ID
18 | moments-app-project
19 | STORAGE_BUCKET
20 | moments-app-project.appspot.com
21 | IS_ADS_ENABLED
22 |
23 | IS_ANALYTICS_ENABLED
24 |
25 | IS_APPINVITE_ENABLED
26 |
27 | IS_GCM_ENABLED
28 |
29 | IS_SIGNIN_ENABLED
30 |
31 | GOOGLE_APP_ID
32 | 1:374168413412:ios:361f28f238da1b03a038f1
33 | DATABASE_URL
34 | https://moments-app-project.firebaseio.com
35 |
36 |
--------------------------------------------------------------------------------
/Moments/Moments/Configurations/Firebase/GoogleService-Info-Internal.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CLIENT_ID
6 | 374168413412-nq595n17qseqn25rt87t1ircfm7b3f4s.apps.googleusercontent.com
7 | REVERSED_CLIENT_ID
8 | com.googleusercontent.apps.374168413412-nq595n17qseqn25rt87t1ircfm7b3f4s
9 | API_KEY
10 | AIzaSyAC-ZpOnV2luh0JdacvODnoh_EarVRAZtU
11 | GCM_SENDER_ID
12 | 374168413412
13 | PLIST_VERSION
14 | 1
15 | BUNDLE_ID
16 | com.ibanimatable.moments.internal
17 | PROJECT_ID
18 | moments-app-project
19 | STORAGE_BUCKET
20 | moments-app-project.appspot.com
21 | IS_ADS_ENABLED
22 |
23 | IS_ANALYTICS_ENABLED
24 |
25 | IS_APPINVITE_ENABLED
26 |
27 | IS_GCM_ENABLED
28 |
29 | IS_SIGNIN_ENABLED
30 |
31 | GOOGLE_APP_ID
32 | 1:374168413412:ios:912d89b30767d8e5a038f1
33 | DATABASE_URL
34 | https://moments-app-project.firebaseio.com
35 |
36 |
--------------------------------------------------------------------------------
/Moments/Moments/Configurations/InternalProject.xcconfig:
--------------------------------------------------------------------------------
1 | #include "BaseProject.xcconfig"
2 |
3 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) INTERNAL
4 |
--------------------------------------------------------------------------------
/Moments/Moments/Configurations/InternalTarget.xcconfig:
--------------------------------------------------------------------------------
1 | #include "../Pods/Target Support Files/Pods-Moments/Pods-Moments.internal.xcconfig"
2 | #include "BaseTarget.xcconfig"
3 |
4 | PRODUCT_BUNDLE_NAME = $(inherited) β
5 | PRODUCT_BUNDLE_IDENTIFIER = com.ibanimatable.moments.internal
6 | FIREBASE_CONFIG_FILENAME = GoogleService-Info-Internal
7 |
--------------------------------------------------------------------------------
/Moments/Moments/Configurations/SDKAndDeviceSupport.xcconfig:
--------------------------------------------------------------------------------
1 | // Deployment
2 | TARGETED_DEVICE_FAMILY = 1
3 | IPHONEOS_DEPLOYMENT_TARGET = 14.0
4 |
--------------------------------------------------------------------------------
/Moments/Moments/Features/InternalMenu/Routing/DesignKitDemoNavigator.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DesignKitDemoNavigator.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 4/2/21.
6 | //
7 |
8 | import Foundation
9 | import UIKit
10 |
11 | struct DesignKitDemoNavigator: Navigating {
12 | func navigate(from viewController: UIViewController, using transitionType: TransitionType, parameters: [String: String]) {
13 | let togglesDataStore: TogglesDataStoreType = BuildTargetTogglesDataStore.shared
14 | guard togglesDataStore.isToggleOn(BuildTargetToggle.debug) || togglesDataStore.isToggleOn(BuildTargetToggle.internal) else {
15 | return
16 | }
17 |
18 | // swiftlint:disable no_hardcoded_strings
19 | guard let productName = parameters["productname"], let versionNumber = parameters["version"] else {
20 | return
21 | }
22 | // swiftlint:enable no_hardcoded_strings
23 |
24 | let destinationViewController = DesignKitDemoViewController(productName: productName, versionNumber: versionNumber)
25 | navigate(to: destinationViewController, from: viewController, using: transitionType)
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/Moments/Moments/Features/InternalMenu/Routing/InternalMenuNavigator.swift:
--------------------------------------------------------------------------------
1 | //
2 | // InternalMenuRoute.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 20/10/20.
6 | //
7 |
8 | import UIKit
9 |
10 | struct InternalMenuNavigator: Navigating {
11 | func navigate(from viewController: UIViewController, using transitionType: TransitionType, parameters: [String : String]) {
12 | let togglesDataStore: TogglesDataStoreType = BuildTargetTogglesDataStore.shared
13 | guard togglesDataStore.isToggleOn(BuildTargetToggle.debug) || togglesDataStore.isToggleOn(BuildTargetToggle.internal) else {
14 | return
15 | }
16 |
17 | let navigationController = UINavigationController(rootViewController: InternalMenuViewController())
18 | navigate(to: navigationController, from: viewController, using: transitionType)
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/Moments/Moments/Features/InternalMenu/ViewModels/InternalMenuActionTriggerItemViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // InternalMenuActionTriggerItemViewModel.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 19/10/20.
6 | //
7 |
8 | import UIKit
9 | import DesignKit
10 |
11 | class InternalMenuActionTriggerItemViewModel: InternalMenuItemViewModel {
12 | let type: InternalMenuItemType = .actionTrigger
13 |
14 | var title: String { fatalError(L10n.Development.fatalErrorSubclassToImplement) }
15 |
16 | // swiftlint:disable unavailable_function
17 | func select() { fatalError(L10n.Development.fatalErrorSubclassToImplement) }
18 | }
19 |
--------------------------------------------------------------------------------
/Moments/Moments/Features/InternalMenu/ViewModels/InternalMenuCrashAppItemViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // InteralMenuCrashAppItemViewModel.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 10/11/20.
6 | //
7 |
8 | import Foundation
9 |
10 | final class InternalMenuCrashAppItemViewModel: InternalMenuActionTriggerItemViewModel {
11 | override var title: String {
12 | return L10n.InternalMenu.crashApp
13 | }
14 |
15 | // swiftlint:disable unavailable_function
16 | override func select() {
17 | // swiftlint:disable fatal_error_message
18 | fatalError()
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/Moments/Moments/Features/InternalMenu/ViewModels/InternalMenuDescriptionItemViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // InternalMenuDescriptionItemViewModel.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 17/10/20.
6 | //
7 |
8 | struct InternalMenuDescriptionItemViewModel: InternalMenuItemViewModel {
9 | let type: InternalMenuItemType = .description
10 | let title: String
11 | }
12 |
--------------------------------------------------------------------------------
/Moments/Moments/Features/InternalMenu/ViewModels/InternalMenuDesignKitDemoItemViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DesignKitDemoItemViewModel.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 19/10/20.
6 | //
7 |
8 | import Foundation
9 |
10 | final class InternalMenuDesignKitDemoItemViewModel: InternalMenuActionTriggerItemViewModel {
11 | private let router: AppRouting
12 | private let routingSourceProvider: RoutingSourceProvider
13 |
14 | init(router: AppRouting, routingSourceProvider: @escaping RoutingSourceProvider) {
15 | self.router = router
16 | self.routingSourceProvider = routingSourceProvider
17 | }
18 |
19 | override var title: String {
20 | return L10n.InternalMenu.designKitDemo
21 | }
22 |
23 | override func select() {
24 | // swiftlint:disable no_hardcoded_strings
25 | router.route(to: URL(string: "\(UniversalLinks.baseURL)DesignKit?productName=DesignKit&version=1.0.1"), from: routingSourceProvider(), using: .show)
26 | // swiftlint:enable no_hardcoded_strings
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/Moments/Moments/Features/InternalMenu/ViewModels/InternalMenuFeatureToggleItemViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // InternalMenuFeatureToggleItemViewModel.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 31/10/20.
6 | //
7 |
8 | import Foundation
9 |
10 | struct InternalMenuFeatureToggleItemViewModel: InternalMenuItemViewModel {
11 | private let toggle: ToggleType
12 | private let togglesDataStore: TogglesDataStoreType
13 |
14 | init(title: String, toggle: ToggleType, togglesDataStore: TogglesDataStoreType = InternalTogglesDataStore.shared) {
15 | self.title = title
16 | self.toggle = toggle
17 | self.togglesDataStore = togglesDataStore
18 | }
19 |
20 | let type: InternalMenuItemType = .featureToggle
21 | let title: String
22 |
23 | var isOn: Bool {
24 | return togglesDataStore.isToggleOn(toggle)
25 | }
26 |
27 | func toggle(isOn: Bool) {
28 | togglesDataStore.update(toggle: toggle, value: isOn)
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/Moments/Moments/Features/InternalMenu/ViewModels/InternalMenuItemViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // InternalMenuItemViewModel.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 17/10/20.
6 | //
7 |
8 | import Foundation
9 |
10 | enum InternalMenuItemType: String {
11 | case description
12 | case featureToggle
13 | case actionTrigger
14 | }
15 |
16 | protocol InternalMenuItemViewModel {
17 | var type: InternalMenuItemType { get }
18 | var title: String { get }
19 |
20 | func select()
21 | }
22 |
23 | extension InternalMenuItemViewModel {
24 | func select() { }
25 | }
26 |
--------------------------------------------------------------------------------
/Moments/Moments/Features/InternalMenu/ViewModels/InternalMenuSection.swift:
--------------------------------------------------------------------------------
1 | //
2 | // InternalMenuSection.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 17/10/20.
6 | //
7 |
8 | import RxDataSources
9 |
10 | struct InternalMenuSection: SectionModelType {
11 | let title: String
12 | let items: [InternalMenuItemViewModel]
13 | let footer: String?
14 |
15 | init(title: String, items: [InternalMenuItemViewModel], footer: String? = nil) {
16 | self.title = title
17 | self.items = items
18 | self.footer = footer
19 | }
20 |
21 | init(original: InternalMenuSection, items: [InternalMenuItemViewModel]) {
22 | self.init(title: original.title, items: items, footer: original.footer)
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Moments/Moments/Features/InternalMenu/ViewModels/InternalMenuViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // InternalMenuViewModel.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 17/10/20.
6 | //
7 |
8 | import RxSwift
9 | import RxDataSources
10 |
11 | protocol InternalMenuViewModelType {
12 | var title: String { get }
13 | var sections: Observable<[InternalMenuSection]> { get }
14 | }
15 |
16 | struct InternalMenuViewModel: InternalMenuViewModelType {
17 | let title = L10n.InternalMenu.area51
18 | let sections: Observable<[InternalMenuSection]>
19 |
20 | init(router: AppRouting, routingSourceProvider: @escaping RoutingSourceProvider) {
21 | let appVersion = "\(L10n.InternalMenu.version) \((Bundle.main.object(forInfoDictionaryKey: L10n.InternalMenu.cfBundleVersion) as? String) ?? "1.0")"
22 |
23 | let infoSection = InternalMenuSection(
24 | title: L10n.InternalMenu.generalInfo,
25 | items: [InternalMenuDescriptionItemViewModel(title: appVersion)]
26 | )
27 |
28 | let designKitSection = InternalMenuSection(
29 | title: L10n.InternalMenu.designKitDemo,
30 | items: [InternalMenuDesignKitDemoItemViewModel(router: router, routingSourceProvider: routingSourceProvider)])
31 |
32 | let featureTogglesSection = InternalMenuSection(
33 | title: L10n.InternalMenu.featureToggles,
34 | items: [
35 | InternalMenuFeatureToggleItemViewModel(title: L10n.InternalMenu.likeButtonForMomentEnabled, toggle: InternalToggle.isLikeButtonForMomentEnabled)
36 | ])
37 |
38 | let toolsSection = InternalMenuSection(
39 | title: L10n.InternalMenu.tools,
40 | items: [InternalMenuCrashAppItemViewModel()]
41 | )
42 |
43 | sections = .just([
44 | infoSection,
45 | designKitSection,
46 | featureTogglesSection,
47 | toolsSection
48 | ])
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/Moments/Moments/Features/InternalMenu/Views/InternalMenuActionTriggerCell.swift:
--------------------------------------------------------------------------------
1 | //
2 | // InternalMenuActionTriggerCell.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 19/10/20.
6 | //
7 |
8 | import UIKit
9 |
10 | class InternalMenuActionTriggerCell: UITableViewCell, InternalMenuCellType {
11 | func update(with item: InternalMenuItemViewModel) {
12 | guard let item = item as? InternalMenuActionTriggerItemViewModel else {
13 | return
14 | }
15 |
16 | accessoryType = .disclosureIndicator
17 | textLabel?.text = item.title
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/Moments/Moments/Features/InternalMenu/Views/InternalMenuCellType.swift:
--------------------------------------------------------------------------------
1 | //
2 | // InternalMenuCell.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 17/10/20.
6 | //
7 |
8 | import UIKit
9 |
10 | protocol InternalMenuCellType {
11 | func update(with item: InternalMenuItemViewModel)
12 | }
13 |
--------------------------------------------------------------------------------
/Moments/Moments/Features/InternalMenu/Views/InternalMenuDescriptionCell.swift:
--------------------------------------------------------------------------------
1 | //
2 | // InternalMenuDescriptionCell.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 17/10/20.
6 | //
7 |
8 | import UIKit
9 |
10 | class InternalMenuDescriptionCell: UITableViewCell, InternalMenuCellType {
11 | func update(with item: InternalMenuItemViewModel) {
12 | guard let item = item as? InternalMenuDescriptionItemViewModel else {
13 | return
14 | }
15 |
16 | selectionStyle = .none
17 | textLabel?.text = item.title
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/Moments/Moments/Features/InternalMenu/Views/InternalMenuFeatureToggleCell.swift:
--------------------------------------------------------------------------------
1 | //
2 | // InternalMenuFeatureToggleCell.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 31/10/20.
6 | //
7 |
8 | import Foundation
9 | import UIKit
10 | import RxSwift
11 |
12 | class InternalMenuFeatureToggleCell: UITableViewCell, InternalMenuCellType {
13 | private let switchControl: UISwitch = configure(.init()) {
14 | $0.translatesAutoresizingMaskIntoConstraints = false
15 | }
16 |
17 | private var item: InternalMenuFeatureToggleItemViewModel?
18 | private lazy var disposeBag: DisposeBag = .init()
19 |
20 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
21 | super.init(style: style, reuseIdentifier: reuseIdentifier)
22 |
23 | setupUI()
24 | setupBindings()
25 | }
26 |
27 | // swiftlint:disable unavailable_function
28 | required init?(coder: NSCoder) {
29 | fatalError(L10n.Development.fatalErrorInitCoderNotImplemented)
30 | }
31 |
32 | func update(with item: InternalMenuItemViewModel) {
33 | guard let item = item as? InternalMenuFeatureToggleItemViewModel else {
34 | return
35 | }
36 |
37 | self.item = item
38 | textLabel?.text = item.title
39 | switchControl.isOn = item.isOn
40 | }
41 | }
42 |
43 | private extension InternalMenuFeatureToggleCell {
44 | func setupUI() {
45 | selectionStyle = .none
46 | accessoryView = switchControl
47 | }
48 |
49 | func setupBindings() {
50 | switchControl.rx.isOn.changed
51 | .distinctUntilChanged()
52 | .asObservable()
53 | .subscribe(onNext: { [weak self] in
54 | self?.item?.toggle(isOn: $0)
55 | })
56 | .disposed(by: disposeBag)
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/Moments/Moments/Features/InternalMenu/Views/InternalMenuViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // InternalMenuViewController.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 17/10/20.
6 | //
7 |
8 | import UIKit
9 | import RxDataSources
10 | import SnapKit
11 |
12 | final class InternalMenuViewController: BaseViewController {
13 | private var viewModel: InternalMenuViewModelType!
14 |
15 | private lazy var tableView: UITableView = configure(UITableView(frame: CGRect.zero, style: .grouped)) {
16 | $0.rowHeight = UITableView.automaticDimension
17 | $0.estimatedRowHeight = 44
18 |
19 | $0.register(InternalMenuDescriptionCell.self, forCellReuseIdentifier: InternalMenuItemType.description.rawValue)
20 | $0.register(InternalMenuActionTriggerCell.self, forCellReuseIdentifier: InternalMenuItemType.actionTrigger.rawValue)
21 | $0.register(InternalMenuFeatureToggleCell.self, forCellReuseIdentifier: InternalMenuItemType.featureToggle.rawValue)
22 | }
23 |
24 | init(router: AppRouting = AppRouter.shared) {
25 | super.init()
26 |
27 | // Remember to weak self to avoid retain cycle
28 | viewModel = InternalMenuViewModel(router: router, routingSourceProvider: { [weak self] in
29 | return self
30 | })
31 | }
32 |
33 | override func viewDidLoad() {
34 | super.viewDidLoad()
35 |
36 | setupUI()
37 | setupConstraints()
38 |
39 | DispatchQueue.main.async {
40 | // Walkaround for a warning
41 | // https://github.com/RxSwiftCommunity/RxDataSources/issues/331
42 | self.setupBindings()
43 | }
44 | }
45 | }
46 |
47 | private extension InternalMenuViewController {
48 | func setupUI() {
49 | title = viewModel.title
50 | view.addSubview(tableView)
51 | }
52 |
53 | func setupConstraints() {
54 | tableView.snp.makeConstraints {
55 | $0.edges.equalToSuperview()
56 | }
57 | }
58 |
59 | func setupBindings() {
60 | let dismissBarButtonItem: UIBarButtonItem = UIBarButtonItem(systemItem: .done)
61 | dismissBarButtonItem.rx.tap.subscribe(onNext: { [weak self] in
62 | self?.dismiss(animated: true, completion: nil)
63 | }).disposed(by: disposeBag)
64 | navigationItem.rightBarButtonItem = dismissBarButtonItem
65 |
66 | let dataSource = RxTableViewSectionedReloadDataSource(
67 | configureCell: { _, tableView, indexPath, item in
68 | let cell = tableView.dequeueReusableCell(withIdentifier: item.type.rawValue, for: indexPath)
69 | if let cell = cell as? InternalMenuCellType {
70 | cell.update(with: item)
71 | }
72 | return cell
73 | }, titleForHeaderInSection: { dataSource, section in
74 | return dataSource.sectionModels[section].title
75 | }, titleForFooterInSection: { dataSource, section in
76 | return dataSource.sectionModels[section].footer
77 | })
78 |
79 | viewModel.sections
80 | .bind(to: tableView.rx.items(dataSource: dataSource))
81 | .disposed(by: disposeBag)
82 |
83 | tableView.rx
84 | .itemSelected
85 | .subscribe(onNext: { [weak self] indexPath in
86 | self?.tableView.deselectRow(at: indexPath, animated: true)
87 | })
88 | .disposed(by: disposeBag)
89 |
90 | tableView.rx
91 | .modelSelected(InternalMenuItemViewModel.self)
92 | .subscribe(onNext: { item in
93 | item.select()
94 | })
95 | .disposed(by: disposeBag)
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/Moments/Moments/Features/Moments/Analytics/LikeActionTrackingEvent.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LikeActionTrackingEvent.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 5/11/20.
6 | //
7 |
8 | import Foundation
9 | import FirebaseAnalytics
10 |
11 | struct LikeActionTrackingEvent: ActionTrackingEventType {
12 | let momentID: String
13 | let userID: String
14 | }
15 |
16 | extension LikeActionTrackingEvent: FirebaseActionTrackingEvent {
17 | var parameters: [String : Any] {
18 | // swiftlint:disable no_hardcoded_strings
19 | return [
20 | AnalyticsParameterItemID: "moment-id-\(momentID)-user-id-\(userID)",
21 | AnalyticsParameterItemName: "moment-like"
22 | ]
23 | // swiftlint:enable no_hardcoded_strings
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/Moments/Moments/Features/Moments/Analytics/UnlikeActionTrackingEvent.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UnlikeActionTrackingEvent.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 5/11/20.
6 | //
7 |
8 | import Foundation
9 | import FirebaseAnalytics
10 |
11 | struct UnlikeActionTrackingEvent: ActionTrackingEventType {
12 | let momentID: String
13 | let userID: String
14 | }
15 |
16 | extension UnlikeActionTrackingEvent: FirebaseActionTrackingEvent {
17 | var parameters: [String : Any] {
18 | // swiftlint:disable no_hardcoded_strings
19 | return [
20 | AnalyticsParameterItemID: "moment-id-\(momentID)-user-id-\(userID)",
21 | AnalyticsParameterItemName: "moment-unlike"
22 | ]
23 | // swiftlint:enable no_hardcoded_strings
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/Moments/Moments/Features/Moments/Models/MomentsDetails.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MomentsDetails.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 26/10/20.
6 | //
7 |
8 | import Foundation
9 |
10 | struct MomentsDetails: Codable {
11 | let userDetails: UserDetails
12 | let moments: [Moment]
13 |
14 | struct UserDetails: Codable {
15 | let id: String
16 | let name: String
17 | let avatar: String
18 | let backgroundImage: String
19 | }
20 |
21 | struct Moment: Codable {
22 | let id: String
23 | let userDetails: MomentUserDetails
24 | let type: MomentType
25 | let title: String?
26 | let url: String?
27 | let photos: [String]
28 | let createdDate: String
29 | let isLiked: Bool? // Change to non-optional when removing `isLikeButtonForMomentEnabled` toggle
30 | let likes: [LikedUserDetails]? // Change to non-optional when removing `isLikeButtonForMomentEnabled` toggle
31 |
32 | struct MomentUserDetails: Codable {
33 | let name: String
34 | let avatar: String
35 | }
36 |
37 | struct LikedUserDetails: Codable {
38 | let id: String
39 | let avatar: String
40 | }
41 |
42 | // swiftlint:disable no_hardcoded_strings
43 | enum MomentType: String, Codable {
44 | case url = "URL"
45 | case photos = "PHOTOS"
46 | }
47 | // swiftlint:enable no_hardcoded_strings
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/Moments/Moments/Features/Moments/Networking/GetMomentsByUserIDSession.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GetMomentsByUserIDSession.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 26/10/20.
6 | //
7 |
8 | import Foundation
9 | import Alamofire
10 | import RxSwift
11 |
12 | protocol GetMomentsByUserIDSessionType {
13 | func getMoments(userID: String) -> Observable
14 | }
15 |
16 | // swiftlint:disable no_hardcoded_strings
17 | struct GetMomentsByUserIDSession: GetMomentsByUserIDSessionType {
18 | struct Response: Codable {
19 | let data: Data
20 |
21 | struct Data: Codable {
22 | let getMomentsDetailsByUserID: MomentsDetails
23 | }
24 | }
25 |
26 | struct Session: APISession {
27 | typealias ReponseType = Response
28 |
29 | let path = L10n.Development.graphqlPath
30 | let parameters: Parameters
31 | let headers: HTTPHeaders = .init()
32 |
33 | init(userID: String, togglesDataStore: TogglesDataStoreType) {
34 | let variables: [AnyHashable: Encodable] = ["userID": userID,
35 | "withLikes": togglesDataStore.isToggleOn(InternalToggle.isLikeButtonForMomentEnabled)]
36 | parameters = ["query": Self.query,
37 | "variables": variables]
38 | }
39 |
40 | private static let query = """
41 | query getMomentsDetailsByUserID($userID: ID!, $withLikes: Boolean!) {
42 | getMomentsDetailsByUserID(userID: $userID) {
43 | userDetails {
44 | id
45 | name
46 | avatar
47 | backgroundImage
48 | }
49 | moments {
50 | id
51 | userDetails {
52 | name
53 | avatar
54 | }
55 | type
56 | title
57 | photos
58 | createdDate
59 | isLiked @include(if: $withLikes)
60 | likes @include(if: $withLikes) {
61 | id
62 | avatar
63 | }
64 | }
65 | }
66 | }
67 | """
68 | }
69 |
70 | private let togglesDataStore: TogglesDataStoreType
71 | private let sessionHandler: (Session) -> Observable
72 |
73 | init(togglesDataStore: TogglesDataStoreType = InternalTogglesDataStore.shared, sessionHandler: @escaping (Session) -> Observable = {
74 | $0.post($0.path, headers: $0.headers, parameters: $0.parameters)
75 | }) {
76 | self.togglesDataStore = togglesDataStore
77 | self.sessionHandler = sessionHandler
78 | }
79 |
80 | func getMoments(userID: String) -> Observable {
81 | let session = Session(userID: userID, togglesDataStore: togglesDataStore)
82 | return sessionHandler(session).map { $0.data.getMomentsDetailsByUserID }
83 | }
84 | }
85 | // swiftlint:enable no_hardcoded_strings
86 |
--------------------------------------------------------------------------------
/Moments/Moments/Features/Moments/Networking/UpdateMomentLikeSession.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UpdateMomentLikeSession.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 3/11/20.
6 | //
7 |
8 | import Foundation
9 | import Alamofire
10 | import RxSwift
11 |
12 | protocol UpdateMomentLikeSessionType {
13 | func updateLike(_ isLiked: Bool, momentID: String, fromUserID userID: String) -> Observable
14 | }
15 |
16 | // swiftlint:disable no_hardcoded_strings
17 | struct UpdateMomentLikeSession: UpdateMomentLikeSessionType {
18 | struct Response: Codable {
19 | let data: Data
20 |
21 | struct Data: Codable {
22 | let updateMomentLike: MomentsDetails
23 | }
24 | }
25 |
26 | struct Session: APISession {
27 | typealias ReponseType = Response
28 |
29 | let path = L10n.Development.graphqlPath
30 | let parameters: Parameters
31 | let headers: HTTPHeaders = .init()
32 |
33 | init(momentID: String, userID: String, isLiked: Bool) {
34 | let variables: [AnyHashable: Encodable] = ["momentID": momentID,
35 | "userID": userID,
36 | "isLiked": isLiked]
37 | parameters = ["query": Self.query,
38 | "variables": variables]
39 | }
40 |
41 | private static let query = """
42 | mutation updateMomentLike($momentID: ID!, $userID: ID!, $isLiked: Boolean!) {
43 | updateMomentLike(momentID: $momentID, userID: $userID, isLiked: $isLiked) {
44 | userDetails {
45 | id
46 | name
47 | avatar
48 | backgroundImage
49 | }
50 | moments {
51 | id
52 | userDetails {
53 | name
54 | avatar
55 | }
56 | type
57 | title
58 | photos
59 | createdDate
60 | isLiked
61 | likes {
62 | id
63 | avatar
64 | }
65 | }
66 | }
67 | }
68 | """
69 | }
70 |
71 | private let sessionHandler: (Session) -> Observable
72 |
73 | init(sessionHandler: @escaping (Session) -> Observable = {
74 | $0.post($0.path, headers: $0.headers, parameters: $0.parameters)
75 | }) {
76 | self.sessionHandler = sessionHandler
77 | }
78 |
79 | func updateLike(_ isLiked: Bool, momentID: String, fromUserID userID: String) -> Observable {
80 | let session = Session(momentID: momentID, userID: userID, isLiked: isLiked)
81 | return sessionHandler(session).map { $0.data.updateMomentLike }
82 | }
83 | }
84 | // swiftlint:enable no_hardcoded_strings
85 |
--------------------------------------------------------------------------------
/Moments/Moments/Features/Moments/Repositories/MomentsRepo.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MomentsRepo.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 26/10/20.
6 | //
7 |
8 | import Foundation
9 | import RxSwift
10 |
11 | protocol MomentsRepoType {
12 | var momentsDetails: ReplaySubject { get }
13 |
14 | func getMoments(userID: String) -> Observable
15 | func updateLike(isLiked: Bool, momentID: String, fromUserID userID: String) -> Observable
16 | }
17 |
18 | struct MomentsRepo: MomentsRepoType {
19 | private(set) var momentsDetails: ReplaySubject = .create(bufferSize: 1)
20 | private let disposeBag: DisposeBag = .init()
21 |
22 | private let persistentDataStore: PersistentDataStoreType
23 | private let getMomentsByUserIDSession: GetMomentsByUserIDSessionType
24 | private let updateMomentLikeSession: UpdateMomentLikeSessionType
25 |
26 | static let shared: MomentsRepo = {
27 | return MomentsRepo(
28 | persistentDataStore: UserDefaultsPersistentDataStore.shared,
29 | getMomentsByUserIDSession: GetMomentsByUserIDSession(),
30 | updateMomentLikeSession: UpdateMomentLikeSession()
31 | )
32 | }()
33 |
34 | init(persistentDataStore: PersistentDataStoreType,
35 | getMomentsByUserIDSession: GetMomentsByUserIDSessionType,
36 | updateMomentLikeSession: UpdateMomentLikeSessionType) {
37 | self.persistentDataStore = persistentDataStore
38 | self.getMomentsByUserIDSession = getMomentsByUserIDSession
39 | self.updateMomentLikeSession = updateMomentLikeSession
40 |
41 | setupBindings()
42 | }
43 |
44 | func getMoments(userID: String) -> Observable {
45 | return getMomentsByUserIDSession
46 | .getMoments(userID: userID)
47 | .do(onNext: { persistentDataStore.save(momentsDetails: $0) })
48 | .map { _ in () }
49 | .catchErrorJustReturn(())
50 | }
51 |
52 | func updateLike(isLiked: Bool, momentID: String, fromUserID userID: String) -> Observable {
53 | return updateMomentLikeSession
54 | .updateLike(isLiked, momentID: momentID, fromUserID: userID)
55 | .do(onNext: { persistentDataStore.save(momentsDetails: $0) })
56 | .map { _ in () }
57 | .catchErrorJustReturn(())
58 | }
59 | }
60 |
61 | private extension MomentsRepo {
62 | func setupBindings() {
63 | persistentDataStore
64 | .momentsDetails
65 | .subscribe(momentsDetails)
66 | .disposed(by: disposeBag)
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/Moments/Moments/Features/Moments/ViewModels/MomentListItemViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MomentListItemViewModel.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 29/10/20.
6 | //
7 |
8 | import Foundation
9 | import RxSwift
10 |
11 | struct MomentListItemViewModel: ListItemViewModel {
12 | let userAvatarURL: URL?
13 | let userName: String
14 | let title: String?
15 | let photoURL: URL? // This version only supports one image
16 | let postDateDescription: String?
17 | let isLiked: Bool
18 | let likes: [URL]
19 |
20 | private let momentID: String
21 | private let momentsRepo: MomentsRepoType
22 | private let trackingRepo: TrackingRepoType
23 |
24 | init(moment: MomentsDetails.Moment, momentsRepo: MomentsRepoType = MomentsRepo.shared, trackingRepo: TrackingRepoType = TrackingRepo.shared, now: Date = Date(), relativeDateTimeFormatter: RelativeDateTimeFormatterType = RelativeDateTimeFormatter()) {
25 | momentID = moment.id
26 | self.momentsRepo = momentsRepo
27 | self.trackingRepo = trackingRepo
28 | userAvatarURL = URL(string: moment.userDetails.avatar)
29 | userName = moment.userDetails.name
30 | title = moment.title
31 | isLiked = moment.isLiked ?? false
32 | likes = moment.likes?.compactMap { URL(string: $0.avatar) } ?? []
33 |
34 | if let firstPhoto = moment.photos.first {
35 | photoURL = URL(string: firstPhoto)
36 | } else {
37 | photoURL = nil
38 | }
39 |
40 | var formatter = relativeDateTimeFormatter
41 | formatter.unitsStyle = .full
42 | if let timeInterval = TimeInterval(moment.createdDate) {
43 | let createdDate = Date(timeIntervalSince1970: timeInterval)
44 | postDateDescription = formatter.localizedString(for: createdDate, relativeTo: now)
45 | } else {
46 | postDateDescription = nil
47 | }
48 | }
49 |
50 | func like(from userID: String) -> Observable {
51 | trackingRepo.trackAction(LikeActionTrackingEvent(momentID: momentID, userID: userID))
52 | return momentsRepo.updateLike(isLiked: true, momentID: momentID, fromUserID: userID)
53 | }
54 |
55 | func unlike(from userID: String) -> Observable {
56 | trackingRepo.trackAction(UnlikeActionTrackingEvent(momentID: momentID, userID: userID))
57 | return momentsRepo.updateLike(isLiked: false, momentID: momentID, fromUserID: userID)
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/Moments/Moments/Features/Moments/ViewModels/MomentsTimelineViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MomentsTimelineViewModel.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 26/10/20.
6 | //
7 |
8 | import Foundation
9 | import RxSwift
10 | import RxDataSources
11 |
12 | struct MomentsTimelineViewModel: ListViewModel {
13 | private(set) var listItems: BehaviorSubject<[SectionModel]> = .init(value: [])
14 | private(set) var hasError: BehaviorSubject = .init(value: false)
15 |
16 | private let disposeBag: DisposeBag = .init()
17 | private let userID: String
18 | private let momentsRepo: MomentsRepoType
19 | private let trackingRepo: TrackingRepoType
20 |
21 | init(userID: String,
22 | momentsRepo: MomentsRepoType = MomentsRepo.shared,
23 | trackingRepo: TrackingRepoType = TrackingRepo.shared) {
24 | self.userID = userID
25 | self.momentsRepo = momentsRepo
26 | self.trackingRepo = trackingRepo
27 |
28 | setupBindings()
29 | }
30 |
31 | func loadItems() -> Observable {
32 | return momentsRepo.getMoments(userID: userID)
33 | }
34 |
35 | func trackScreenviews() {
36 | // The screen name should match the same screen on Android
37 | trackingRepo.trackScreenviews(ScreenviewsTrackingEvent(screenName: L10n.Tracking.momentsScreen, screenClass: String(describing: self)))
38 | }
39 | }
40 |
41 | private extension MomentsTimelineViewModel {
42 | func setupBindings() {
43 | momentsRepo.momentsDetails
44 | .map {
45 | [UserProfileListItemViewModel(userDetails: $0.userDetails)]
46 | + $0.moments.map { MomentListItemViewModel(moment: $0) }
47 | }
48 | .subscribe(onNext: {
49 | listItems.onNext([SectionModel(model: "", items: $0)])
50 | }, onError: { _ in
51 | hasError.onNext(true)
52 | })
53 | .disposed(by: disposeBag)
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/Moments/Moments/Features/Moments/ViewModels/UserProfileListItemViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UserProfileListItemViewModel.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 26/10/20.
6 | //
7 |
8 | import Foundation
9 |
10 | struct UserProfileListItemViewModel: ListItemViewModel {
11 | let name: String
12 | let avatarURL: URL?
13 | let backgroundImageURL: URL?
14 |
15 | init(userDetails: MomentsDetails.UserDetails) {
16 | name = userDetails.name
17 | avatarURL = URL(string: userDetails.avatar)
18 | backgroundImageURL = URL(string: userDetails.backgroundImage)
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/Moments/Moments/Features/Moments/Views/MomentsTimelineViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MomentsTimelineViewController.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 28/10/20.
6 | //
7 |
8 | import Foundation
9 | import UIKit
10 | import RxSwift
11 | import RxCocoa
12 | import RxDataSources
13 | import DesignKit
14 |
15 | final class MomentsTimelineViewController: BaseTableViewController {
16 | override init() {
17 | super.init()
18 | viewModel = MomentsTimelineViewModel(userID: UserDataStore.current.userID)
19 | }
20 |
21 | override func viewDidAppear(_ animated: Bool) {
22 | super.viewDidAppear(animated)
23 | viewModel.trackScreenviews()
24 | }
25 |
26 | override var tableViewCellsToRegister: [String : UITableViewCell.Type] {
27 | return [
28 | UserProfileListItemViewModel.reuseIdentifier: BaseTableViewCell.self,
29 | MomentListItemViewModel.reuseIdentifier: BaseTableViewCell.self
30 | ]
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/Moments/Moments/Features/Moments/Views/UserProfileListItemView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UserProfileListItemView.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 27/10/20.
6 | //
7 |
8 | import Foundation
9 | import UIKit
10 | import DesignKit
11 |
12 | final class UserProfileListItemView: BaseListItemView {
13 | private let backgroundImageView: UIImageView = configure(.init()) {
14 | $0.translatesAutoresizingMaskIntoConstraints = false
15 | $0.contentMode = .scaleAspectFill
16 | $0.accessibilityIgnoresInvertColors = true
17 | }
18 |
19 | private let avatarImageView: UIImageView = configure(.init()) {
20 | $0.translatesAutoresizingMaskIntoConstraints = false
21 | $0.asAvatar(cornerRadius: 8)
22 | $0.contentMode = .scaleAspectFill
23 | $0.accessibilityIgnoresInvertColors = true
24 | }
25 |
26 | private let nameLabel: UILabel = configure(.init()) {
27 | $0.translatesAutoresizingMaskIntoConstraints = false
28 | $0.font = UIFont.designKit.title3
29 | $0.textColor = .white
30 | $0.numberOfLines = 1
31 | }
32 |
33 | private let remoteTogglesDataStore: TogglesDataStoreType
34 |
35 | convenience override init(frame: CGRect = .zero) {
36 | self.init(frame: frame, remoteTogglesDataStore: FirebaseRemoteTogglesDataStore.shared)
37 | }
38 |
39 | init(frame: CGRect, remoteTogglesDataStore: TogglesDataStoreType) {
40 | self.remoteTogglesDataStore = remoteTogglesDataStore
41 | super.init(frame: frame)
42 |
43 | setupUI()
44 | setupConstraints()
45 | }
46 |
47 | // swiftlint:disable unavailable_function
48 | required init?(coder aDecoder: NSCoder) {
49 | fatalError(L10n.Development.fatalErrorInitCoderNotImplemented)
50 | }
51 | // swiftlint:enable unavailable_function
52 |
53 | override func update(with viewModel: ListItemViewModel) {
54 | guard let viewModel = viewModel as? UserProfileListItemViewModel else {
55 | return
56 | }
57 |
58 | backgroundImageView.kf.setImage(with: viewModel.backgroundImageURL)
59 | avatarImageView.kf.setImage(with: viewModel.avatarURL)
60 | nameLabel.text = viewModel.name
61 | }
62 | }
63 |
64 | private extension UserProfileListItemView {
65 | func setupUI() {
66 | backgroundColor = UIColor.designKit.background
67 |
68 | [backgroundImageView, avatarImageView, nameLabel].forEach {
69 | addSubview($0)
70 | }
71 |
72 | // Round the avatar if the remote toggle is on
73 | if remoteTogglesDataStore.isToggleOn(RemoteToggle.isRoundedAvatar) {
74 | avatarImageView.asAvatar(cornerRadius: 40)
75 | }
76 | }
77 |
78 | func setupConstraints() {
79 | backgroundImageView.snp.makeConstraints {
80 | $0.top.leading.trailing.equalToSuperview()
81 | $0.bottom.equalToSuperview().offset(-Spacing.medium)
82 | $0.height.equalTo(backgroundImageView.snp.width).multipliedBy(0.8).priority(999)
83 | }
84 |
85 | avatarImageView.snp.makeConstraints {
86 | $0.right.equalToSuperview().offset(-Spacing.medium)
87 | $0.bottom.equalToSuperview()
88 | $0.height.equalTo(80)
89 | $0.width.equalTo(80)
90 | }
91 |
92 | nameLabel.snp.makeConstraints {
93 | $0.right.equalTo(self.avatarImageView.snp.left).offset(-Spacing.medium)
94 | $0.centerY.equalTo(self.avatarImageView.snp.centerY)
95 | }
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/Moments/Moments/Foundations/ABTest/ABTestProvider.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ABTestProvider.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 14/11/20.
6 | //
7 |
8 | import Foundation
9 |
10 | enum LikeButtonStyle: String {
11 | case heart, star
12 | }
13 |
14 | protocol ABTestProvider {
15 | var likeButtonStyle: LikeButtonStyle? { get }
16 | }
17 |
--------------------------------------------------------------------------------
/Moments/Moments/Foundations/ABTest/FirebaseABTestProvider.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FirebaseABTestProvider.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 14/11/20.
6 | //
7 |
8 | import Foundation
9 |
10 | struct FirebaseABTestProvider: ABTestProvider {
11 | static let shared: FirebaseABTestProvider = .init()
12 |
13 | private let remoteConfigProvider: RemoteConfigProvider
14 |
15 | private init(remoteConfigProvider: RemoteConfigProvider = FirebaseRemoteConfigProvider.shared) {
16 | self.remoteConfigProvider = remoteConfigProvider
17 | }
18 |
19 | var likeButtonStyle: LikeButtonStyle? {
20 | guard let likeButtonStyleString = remoteConfigProvider.getString(by: FirebaseRemoteConfigKey.likeButtonStyle) else {
21 | return nil
22 | }
23 | return LikeButtonStyle(rawValue: likeButtonStyleString)
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/Moments/Moments/Foundations/Analytics/Common/ActionTrackingEvent.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ActionTrackingEventType.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 5/11/20.
6 | //
7 |
8 | import Foundation
9 |
10 | protocol ActionTrackingEventType: TrackingEventType {
11 | var parameters: [String: Any] { get }
12 | }
13 |
--------------------------------------------------------------------------------
/Moments/Moments/Foundations/Analytics/Common/ScreenviewsTrackingEvent.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ScreenviewsTrackingEvent.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 5/11/20.
6 | //
7 |
8 | import Foundation
9 |
10 | struct ScreenviewsTrackingEvent: TrackingEventType {
11 | let screenName: String
12 | let screenClass: String
13 | }
14 |
--------------------------------------------------------------------------------
/Moments/Moments/Foundations/Analytics/Common/TrackingEvent.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TrackingEvent.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 6/11/20.
6 | //
7 |
8 | import Foundation
9 |
10 | // Can send any event name with any parameters
11 | struct TrackingEvent: TrackingEventType {
12 | let name: String
13 | let parameters: [String: Any]
14 | }
15 |
--------------------------------------------------------------------------------
/Moments/Moments/Foundations/Analytics/Common/TrackingEventType.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TrackingEventType.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 5/11/20.
6 | //
7 |
8 | import Foundation
9 |
10 | protocol TrackingEventType { }
11 |
--------------------------------------------------------------------------------
/Moments/Moments/Foundations/Analytics/Common/TrackingProvider.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TrackingProvider.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 5/11/20.
6 | //
7 |
8 | import Foundation
9 |
10 | protocol TrackingProvider {
11 | func trackScreenviews(_ event: TrackingEventType)
12 | func trackEvent(_ event: TrackingEventType)
13 | func trackAction(_ event: TrackingEventType)
14 | }
15 |
--------------------------------------------------------------------------------
/Moments/Moments/Foundations/Analytics/Common/TrackingRepo.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TrackingRepo.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 5/11/20.
6 | //
7 |
8 | import Foundation
9 |
10 | protocol TrackingRepoType {
11 | func register(trackingProvider: TrackingProvider)
12 | func trackScreenviews(_ event: TrackingEventType)
13 | func trackEvent(_ event: TrackingEventType)
14 | func trackAction(_ event: TrackingEventType)
15 | }
16 |
17 | final class TrackingRepo: TrackingRepoType {
18 | static let shared: TrackingRepo = .init()
19 |
20 | private var providers = [TrackingProvider]()
21 |
22 | private init() { }
23 |
24 | func register(trackingProvider: TrackingProvider) {
25 | providers.append(trackingProvider)
26 | }
27 |
28 | func trackScreenviews(_ event: TrackingEventType) {
29 | providers.forEach { $0.trackScreenviews(event) }
30 | }
31 |
32 | func trackEvent(_ event: TrackingEventType) {
33 | providers.forEach { $0.trackEvent(event) }
34 | }
35 |
36 | func trackAction(_ event: TrackingEventType) {
37 | providers.forEach { $0.trackAction(event) }
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/Moments/Moments/Foundations/Analytics/Firebase/FirebaseActionTrackingEvent.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FirebaseActionTrackingEvent.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 5/11/20.
6 | //
7 |
8 | import Foundation
9 |
10 | protocol FirebaseActionTrackingEvent: ActionTrackingEventType { }
11 |
--------------------------------------------------------------------------------
/Moments/Moments/Foundations/Analytics/Firebase/FirebaseTrackingProvider.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FirebaseTrackingProvider.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 5/11/20.
6 | //
7 |
8 | import Foundation
9 | import FirebaseAnalytics
10 |
11 | struct FirebaseTrackingProvider: TrackingProvider {
12 | func trackScreenviews(_ event: TrackingEventType) {
13 | guard let event = event as? ScreenviewsTrackingEvent else {
14 | return
15 | }
16 |
17 | // If need to send out this event manually, set `FirebaseAutomaticScreenReportingEnabled` to `Boolean(NO)` in Info.plist
18 | Analytics.logEvent(AnalyticsEventSelectContent, parameters: [
19 | AnalyticsParameterScreenName: event.screenName,
20 | AnalyticsParameterScreenClass: event.screenClass])
21 | }
22 |
23 | func trackEvent(_ event: TrackingEventType) {
24 | guard let event = event as? TrackingEvent else {
25 | return
26 | }
27 |
28 | Analytics.logEvent(event.name, parameters: event.parameters)
29 | }
30 |
31 | func trackAction(_ event: TrackingEventType) {
32 | guard let event = event as? FirebaseActionTrackingEvent else {
33 | return
34 | }
35 |
36 | Analytics.logEvent(AnalyticsEventSelectContent, parameters: event.parameters)
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/Moments/Moments/Foundations/DataStore/PersistentDataStoreType.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PersistentDataStoreType.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 3/11/20.
6 | //
7 |
8 | import Foundation
9 | import RxSwift
10 |
11 | protocol PersistentDataStoreType {
12 | var momentsDetails: ReplaySubject { get }
13 |
14 | func save(momentsDetails: MomentsDetails)
15 | }
16 |
--------------------------------------------------------------------------------
/Moments/Moments/Foundations/DataStore/UserDataStore.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UserDataStore.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 3/11/20.
6 | //
7 |
8 | import Foundation
9 |
10 | protocol UserDataStoreType {
11 | var userID: String { get }
12 | }
13 |
14 | struct UserDataStore: UserDataStoreType {
15 | // Hardcode the user id to 0
16 | var userID: String {
17 | "0"
18 | }
19 |
20 | private init() { }
21 |
22 | static let current = UserDataStore()
23 | }
24 |
--------------------------------------------------------------------------------
/Moments/Moments/Foundations/DataStore/UserDefaultsPersistentDataStore.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UserDefaultsPersistentDataStore.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 3/11/20.
6 | //
7 |
8 | import Foundation
9 | import RxSwift
10 |
11 | struct UserDefaultsPersistentDataStore: PersistentDataStoreType {
12 | static let shared: UserDefaultsPersistentDataStore = .init()
13 |
14 | private(set) var momentsDetails: ReplaySubject = .create(bufferSize: 1)
15 | private let disposeBage: DisposeBag = .init()
16 | private let defaults = UserDefaults.standard
17 | private let momentsDetailsKey = String(describing: MomentsDetails.self)
18 |
19 | private init() {
20 | setupBindings()
21 | }
22 |
23 | func save(momentsDetails: MomentsDetails) {
24 | if let encodedData = try? JSONEncoder().encode(momentsDetails) {
25 | defaults.set(encodedData, forKey: momentsDetailsKey)
26 | }
27 | }
28 | }
29 |
30 | private extension UserDefaultsPersistentDataStore {
31 | func setupBindings() {
32 | defaults.rx
33 | .observe(Data.self, momentsDetailsKey)
34 | .compactMap { $0 }
35 | .compactMap { try? JSONDecoder().decode(MomentsDetails.self, from: $0) }
36 | .subscribe(momentsDetails)
37 | .disposed(by: disposeBage)
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/Moments/Moments/Foundations/DateFormatter/RelativeDateTimeFormatterType.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RelativeDateTimeFormatterType.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 17/11/20.
6 | //
7 |
8 | import Foundation
9 |
10 | protocol RelativeDateTimeFormatterType {
11 | var unitsStyle: RelativeDateTimeFormatter.UnitsStyle { get set }
12 |
13 | func localizedString(for date: Date, relativeTo referenceDate: Date) -> String
14 | }
15 |
16 | extension RelativeDateTimeFormatter: RelativeDateTimeFormatterType { }
17 |
--------------------------------------------------------------------------------
/Moments/Moments/Foundations/Debugging/DebuggingUltils.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DebuggingUltils.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 15/3/21.
6 | //
7 |
8 | import Foundation
9 | import RxSwift
10 |
11 | // swiftlint:disable no_hardcoded_strings
12 | func getThreadName() -> String {
13 | if Thread.current.isMainThread {
14 | return "Main Thread"
15 | } else if let name = Thread.current.name {
16 | if name.isEmpty {
17 | return "Unnamed Thread"
18 | }
19 | return name
20 | } else {
21 | return "Unknown Thread"
22 | }
23 | }
24 |
25 | extension ObservableType {
26 | func dumpObservable() -> Observable {
27 | return self.do(onNext: { element in
28 | print("[Observable] \(element) emitted on \(getThreadName())")
29 | })
30 | }
31 |
32 | func dumpObserver() -> Disposable {
33 | return self.subscribe(onNext: { element in
34 | print("[Observer] \(element) received on \(getThreadName())")
35 | })
36 | }
37 | }
38 |
39 | // swiftlint:enable no_hardcoded_strings
40 |
--------------------------------------------------------------------------------
/Moments/Moments/Foundations/Extensions/UIApplicationExtensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIApplicationExtensions.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 18/10/20.
6 | //
7 |
8 | import UIKit
9 |
10 | extension UIApplication {
11 | var rootViewController: UIViewController? {
12 | let keyWindow = connectedScenes
13 | .filter({ $0.activationState == .foregroundActive })
14 | .map({ $0 as? UIWindowScene })
15 | .compactMap({ $0 })
16 | .first?.windows
17 | .first(where: { $0.isKeyWindow })
18 | return keyWindow?.rootViewController
19 | }
20 |
21 | static var appVersion: String {
22 | // swiftlint:disable no_hardcoded_strings
23 | return Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0"
24 | // swiftlint:enable no_hardcoded_strings
25 | }
26 |
27 | var isRunningUnitTests: Bool {
28 | // swiftlint:disable no_hardcoded_strings
29 | return NSClassFromString("XCTestCase") != nil
30 | // swiftlint:enable no_hardcoded_strings
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/Moments/Moments/Foundations/Networking/API.swift:
--------------------------------------------------------------------------------
1 | //
2 | // API.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 25/10/20.
6 | //
7 |
8 | import Foundation
9 |
10 | enum API {
11 | // swiftlint:disable force_try
12 | // swiftlint:disable force_unwrapping
13 | // swiftlint:disable no_hardcoded_strings
14 | static let baseURL = try! URL(string: "https://" + Configuration.value(for: "API_BASE_URL"))!
15 | // swiftlint:enable force_try
16 | // swiftlint:enable force_unwrapping
17 | // swiftlint:enable no_hardcoded_strings
18 | }
19 |
--------------------------------------------------------------------------------
/Moments/Moments/Foundations/Networking/APISession.swift:
--------------------------------------------------------------------------------
1 | //
2 | // APISession.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 25/10/20.
6 | //
7 |
8 | import Foundation
9 | import Alamofire
10 | import RxSwift
11 |
12 | enum APISessionError: Error {
13 | case networkError(error: Error, statusCode: Int)
14 | case invalidJSON
15 | case noData
16 | }
17 |
18 | protocol APISession {
19 | associatedtype ReponseType: Codable
20 |
21 | func post(_ path: String, headers: HTTPHeaders, parameters: Parameters?) -> Observable
22 | }
23 |
24 | extension APISession {
25 | var defaultHeaders: HTTPHeaders {
26 | // swiftlint:disable no_hardcoded_strings
27 | let headers: HTTPHeaders = [
28 | "x-app-platform": "iOS",
29 | "x-app-version": UIApplication.appVersion,
30 | "x-os-version": UIDevice.current.systemVersion
31 | ]
32 | // swiftlint:enable no_hardcoded_strings
33 |
34 | return headers
35 | }
36 |
37 | var baseUrl: URL {
38 | return API.baseURL
39 | }
40 |
41 | func post(_ path: String, headers: HTTPHeaders = [:], parameters: Parameters? = nil) -> Observable {
42 | return request(path, method: .post, headers: headers, parameters: parameters, encoding: JSONEncoding.default)
43 | }
44 | }
45 |
46 | private extension APISession {
47 | func request(_ path: String, method: HTTPMethod, headers: HTTPHeaders, parameters: Parameters?, encoding: ParameterEncoding) -> Observable {
48 | let url = baseUrl.appendingPathComponent(path)
49 | let allHeaders = HTTPHeaders(defaultHeaders.dictionary.merging(headers.dictionary) { $1 })
50 |
51 | return Observable.create { observer -> Disposable in
52 | // swiftlint:disable no_hardcoded_strings
53 | let queue = DispatchQueue(label: "moments.app.api", qos: .background, attributes: .concurrent)
54 | // swiftlint:enable no_hardcoded_strings
55 |
56 | let request = AF.request(url, method: method, parameters: parameters, encoding: encoding, headers: allHeaders, interceptor: nil, requestModifier: nil)
57 | .validate()
58 | .responseJSON(queue: queue) { response in
59 | switch response.result {
60 | case .success:
61 | guard let data = response.data else {
62 | // if no error provided by Alamofire return .noData error instead.
63 | observer.onError(response.error ?? APISessionError.noData)
64 | return
65 | }
66 | do {
67 | let model = try JSONDecoder().decode(ReponseType.self, from: data)
68 | observer.onNext(model)
69 | observer.onCompleted()
70 | } catch {
71 | observer.onError(error)
72 | }
73 | case .failure(let error):
74 | if let statusCode = response.response?.statusCode {
75 | observer.onError(APISessionError.networkError(error: error, statusCode: statusCode))
76 | } else {
77 | observer.onError(error)
78 | }
79 | }
80 | }
81 |
82 | return Disposables.create {
83 | request.cancel()
84 | }
85 | }
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/Moments/Moments/Foundations/RemoteConfig/FirebaseRemoteConfigDefaults.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | isRoundedAvatar
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Moments/Moments/Foundations/RemoteConfig/FirebaseRemoteConfigProvider.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FirebaseRemoteConfigProvider.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 11/11/20.
6 | //
7 |
8 | import Foundation
9 | import FirebaseRemoteConfig
10 |
11 | enum FirebaseRemoteConfigKey: String, RemoteConfigKey {
12 | case isRoundedAvatar
13 | case likeButtonStyle
14 | }
15 |
16 | struct FirebaseRemoteConfigProvider: RemoteConfigProvider {
17 | static let shared: FirebaseRemoteConfigProvider = .init()
18 |
19 | private let remoteConfig = RemoteConfig.remoteConfig()
20 |
21 | private init() { }
22 |
23 | func setup() {
24 | // swiftlint:disable no_hardcoded_strings
25 | remoteConfig.setDefaults(fromPlist: "FirebaseRemoteConfigDefaults")
26 | // swiftlint:enable no_hardcoded_strings
27 | }
28 |
29 | func fetch() {
30 | remoteConfig.fetchAndActivate()
31 | }
32 |
33 | func getString(by key: RemoteConfigKey) -> String? {
34 | guard let key = key as? FirebaseRemoteConfigKey else {
35 | return nil
36 | }
37 |
38 | return remoteConfig[key.rawValue].stringValue
39 | }
40 |
41 | func getInt(by key: RemoteConfigKey) -> Int? {
42 | guard let key = key as? FirebaseRemoteConfigKey else {
43 | return nil
44 | }
45 |
46 | return Int(truncating: remoteConfig[key.rawValue].numberValue)
47 | }
48 |
49 | func getBool(by key: RemoteConfigKey) -> Bool {
50 | guard let key = key as? FirebaseRemoteConfigKey else {
51 | return false
52 | }
53 |
54 | return remoteConfig[key.rawValue].boolValue
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/Moments/Moments/Foundations/RemoteConfig/RemoteConfigProvider.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RemoteConfigProvider.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 11/11/20.
6 | //
7 |
8 | import Foundation
9 |
10 | protocol RemoteConfigKey { }
11 |
12 | protocol RemoteConfigProvider {
13 | func setup()
14 | func fetch()
15 |
16 | func getString(by key: RemoteConfigKey) -> String?
17 | func getInt(by key: RemoteConfigKey) -> Int?
18 | func getBool(by key: RemoteConfigKey) -> Bool
19 | }
20 |
--------------------------------------------------------------------------------
/Moments/Moments/Foundations/Routing/AppRouter.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppRouter.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 17/10/20.
6 | //
7 |
8 | import UIKit
9 |
10 | final class AppRouter: AppRouting {
11 | static let shared: AppRouter = .init()
12 |
13 | private var navigators: [String: Navigating] = [:]
14 |
15 | private init() { }
16 |
17 | func register(path: String, navigator: Navigating) {
18 | navigators[path.lowercased()] = navigator
19 | }
20 |
21 | func route(to url: URL?, from routingSource: RoutingSource?, using transitionType: TransitionType = .present) {
22 | guard let url = url, let sourceViewController = routingSource as? UIViewController ?? UIApplication.shared.rootViewController else { return }
23 |
24 | let path = url.lastPathComponent.lowercased()
25 | guard let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return }
26 | let parameters: [String: String] = (urlComponents.queryItems ?? []).reduce(into: [:]) { params, queryItem in
27 | params[queryItem.name.lowercased()] = queryItem.value
28 | }
29 | navigators[path]?.navigate(from: sourceViewController, using: transitionType, parameters: parameters)
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/Moments/Moments/Foundations/Routing/AppRouting.swift:
--------------------------------------------------------------------------------
1 | //
2 | // File.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 3/2/21.
6 | //
7 |
8 | import Foundation
9 | import UIKit
10 |
11 | protocol AppRouting {
12 | func register(path: String, navigator: Navigating)
13 | func route(to url: URL?, from routingSource: RoutingSource?, using transitionType: TransitionType)
14 | }
15 |
--------------------------------------------------------------------------------
/Moments/Moments/Foundations/Routing/Navigating.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Navigating.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 3/2/21.
6 | //
7 |
8 | import Foundation
9 | import UIKit
10 |
11 | protocol Navigating {
12 | func navigate(from viewController: UIViewController, using transitionType: TransitionType, parameters: [String: String])
13 | }
14 |
15 | extension Navigating {
16 | func navigate(to destinationViewController: UIViewController, from sourceViewController: UIViewController, using transitionType: TransitionType) {
17 | switch transitionType {
18 | case .show:
19 | sourceViewController.show(destinationViewController, sender: nil)
20 | case .present:
21 | sourceViewController.present(destinationViewController, animated: true)
22 | }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Moments/Moments/Foundations/Routing/TransitionType.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TransitionType.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 3/2/21.
6 | //
7 |
8 | import Foundation
9 |
10 | enum TransitionType: String {
11 | case show, present
12 | }
13 |
--------------------------------------------------------------------------------
/Moments/Moments/Foundations/Routing/UniversalLinks.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Constants.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 3/2/21.
6 | //
7 |
8 | import Foundation
9 |
10 | enum UniversalLinks {
11 | // swiftlint:disable no_hardcoded_strings
12 | static let baseURL = "https://momentsapp.com/"
13 | // swiftlint:enable no_hardcoded_strings
14 | }
15 |
--------------------------------------------------------------------------------
/Moments/Moments/Foundations/Testing/UnitTestViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UnitTestViewController.swift
3 | // MomentsTests
4 | //
5 | // Created by Jake Lin on 15/11/20.
6 | //
7 |
8 | import UIKit
9 |
10 | final class UnitTestViewController: UIViewController {
11 | private let messageLabel: UILabel = configure(.init()) {
12 | $0.translatesAutoresizingMaskIntoConstraints = false
13 | $0.text = L10n.Development.runningUnitTests
14 | }
15 |
16 | override func viewDidLoad() {
17 | super.viewDidLoad()
18 |
19 | setupUI()
20 | setConstraints()
21 | }
22 | }
23 |
24 | private extension UnitTestViewController {
25 | func setupUI() {
26 | view.backgroundColor = .green
27 | view.addSubview(messageLabel)
28 | }
29 |
30 | func setConstraints() {
31 | NSLayoutConstraint.activate([
32 | messageLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
33 | messageLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor)
34 | ])
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/Moments/Moments/Foundations/Toggles/BuildTargetTogglesDataStore.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BuildTargetTogglesDataStore.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 11/11/20.
6 | //
7 |
8 | import Foundation
9 |
10 | enum BuildTargetToggle: ToggleType {
11 | case debug, `internal`, production
12 | }
13 |
14 | struct BuildTargetTogglesDataStore: TogglesDataStoreType {
15 | static let shared: BuildTargetTogglesDataStore = .init()
16 |
17 | private let buildTarget: BuildTargetToggle
18 |
19 | private init() {
20 | #if DEBUG
21 | buildTarget = .debug
22 | #endif
23 |
24 | #if INTERNAL
25 | buildTarget = .internal
26 | #endif
27 |
28 | #if PRODUCTION
29 | buildTarget = .production
30 | #endif
31 | }
32 |
33 | func isToggleOn(_ toggle: ToggleType) -> Bool {
34 | guard let toggle = toggle as? BuildTargetToggle else {
35 | return false
36 | }
37 |
38 | return toggle == buildTarget
39 | }
40 |
41 | func update(toggle: ToggleType, value: Bool) { }
42 | }
43 |
--------------------------------------------------------------------------------
/Moments/Moments/Foundations/Toggles/FirebaseRemoteTogglesDataStore.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RemoteTogglesDataStore.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 11/11/20.
6 | //
7 |
8 | import Foundation
9 |
10 | enum RemoteToggle: String, ToggleType {
11 | case isRoundedAvatar
12 | }
13 |
14 | struct FirebaseRemoteTogglesDataStore: TogglesDataStoreType {
15 | static let shared: FirebaseRemoteTogglesDataStore = .init()
16 |
17 | private let remoteConfigProvider: RemoteConfigProvider
18 |
19 | private init(remoteConfigProvider: RemoteConfigProvider = FirebaseRemoteConfigProvider.shared) {
20 | self.remoteConfigProvider = remoteConfigProvider
21 | self.remoteConfigProvider.setup()
22 | self.remoteConfigProvider.fetch()
23 | }
24 |
25 | func isToggleOn(_ toggle: ToggleType) -> Bool {
26 | guard let toggle = toggle as? RemoteToggle, let remoteConfiKey = FirebaseRemoteConfigKey(rawValue: toggle.rawValue) else {
27 | return false
28 | }
29 |
30 | return remoteConfigProvider.getBool(by: remoteConfiKey)
31 | }
32 |
33 | func update(toggle: ToggleType, value: Bool) { }
34 | }
35 |
--------------------------------------------------------------------------------
/Moments/Moments/Foundations/Toggles/InternalTogglesDataStore.swift:
--------------------------------------------------------------------------------
1 | //
2 | // InternalTogglesDataSource.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 31/10/20.
6 | //
7 |
8 | import Foundation
9 |
10 | enum InternalToggle: String, ToggleType {
11 | case isLikeButtonForMomentEnabled
12 | }
13 |
14 | struct InternalTogglesDataStore: TogglesDataStoreType {
15 | private let userDefaults: UserDefaults
16 |
17 | private init(userDefaults: UserDefaults) {
18 | self.userDefaults = userDefaults
19 | self.userDefaults.register(defaults: [
20 | InternalToggle.isLikeButtonForMomentEnabled.rawValue: false
21 | ])
22 | }
23 |
24 | static let shared: InternalTogglesDataStore = .init(userDefaults: .standard)
25 |
26 | func isToggleOn(_ toggle: ToggleType) -> Bool {
27 | guard let toggle = toggle as? InternalToggle else {
28 | return false
29 | }
30 |
31 | return userDefaults.bool(forKey: toggle.rawValue)
32 | }
33 |
34 | func update(toggle: ToggleType, value: Bool) {
35 | guard let toggle = toggle as? InternalToggle else {
36 | return
37 | }
38 |
39 | userDefaults.set(value, forKey: toggle.rawValue)
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/Moments/Moments/Foundations/Toggles/TogglesDataStoreType.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TogglesDataStoreType.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 11/11/20.
6 | //
7 |
8 | import Foundation
9 |
10 | protocol ToggleType { }
11 |
12 | protocol TogglesDataStoreType {
13 | func isToggleOn(_ toggle: ToggleType) -> Bool
14 | func update(toggle: ToggleType, value: Bool)
15 | }
16 |
--------------------------------------------------------------------------------
/Moments/Moments/Foundations/Utilities/Configuration.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Configuration.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 25/10/20.
6 | //
7 |
8 | import Foundation
9 |
10 | enum Configuration {
11 | enum Error: Swift.Error {
12 | case missingKey, invalidValue
13 | }
14 |
15 | static func value(for key: String) throws -> T where T: LosslessStringConvertible {
16 | guard let object = Bundle.main.object(forInfoDictionaryKey: key) else {
17 | throw Error.missingKey
18 | }
19 |
20 | switch object {
21 | case let value as T:
22 | return value
23 | case let string as String:
24 | guard let value = T(string) else { fallthrough }
25 | return value
26 | default:
27 | throw Error.invalidValue
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/Moments/Moments/Foundations/Utilities/Functions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Functions.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 17/10/20.
6 | //
7 |
8 | import UIKit
9 |
10 | func configure(_ object: T, closure: (T) -> Void) -> T {
11 | closure(object)
12 | return object
13 | }
14 |
--------------------------------------------------------------------------------
/Moments/Moments/Foundations/ViewModels/ListItemViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ListItemViewModel.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 26/10/20.
6 | //
7 |
8 | import Foundation
9 |
10 | protocol ListItemViewModel {
11 | static var reuseIdentifier: String { get }
12 | }
13 |
14 | extension ListItemViewModel {
15 | static var reuseIdentifier: String {
16 | String(describing: self)
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/Moments/Moments/Foundations/ViewModels/ListViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ListViewModel.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 26/10/20.
6 | //
7 |
8 | import Foundation
9 | import RxSwift
10 | import RxDataSources
11 |
12 | protocol ListViewModel {
13 | var listItems: BehaviorSubject<[SectionModel]> { get }
14 | var hasContent: Observable { get }
15 | var hasError: BehaviorSubject { get }
16 |
17 | func trackScreenviews()
18 |
19 | // Need the conformed class to implement
20 | func loadItems() -> Observable
21 | }
22 |
23 | extension ListViewModel {
24 | var hasContent: Observable {
25 | return listItems
26 | .map(\.isEmpty)
27 | .distinctUntilChanged()
28 | .asObservable()
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/Moments/Moments/Foundations/Views/BaseListItemView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BaseListItemView.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 27/10/20.
6 | //
7 |
8 | import Foundation
9 | import UIKit
10 | import RxSwift
11 |
12 | class BaseListItemView: UIView, ListItemView {
13 | lazy var disposeBag: DisposeBag = .init()
14 |
15 | // Implemented by subclass
16 | // swiftlint:disable unavailable_function
17 | func update(with viewModel: ListItemViewModel) {
18 | fatalError(L10n.Development.fatalErrorSubclassToImplement)
19 | }
20 | // swiftlint:enable unavailable_function
21 | }
22 |
--------------------------------------------------------------------------------
/Moments/Moments/Foundations/Views/BaseTableViewCell.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BaseTableViewCell.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 26/10/20.
6 | //
7 |
8 | import Foundation
9 | import UIKit
10 |
11 | final class BaseTableViewCell: UITableViewCell, ListItemCell {
12 | private let view: V
13 |
14 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
15 | view = .init()
16 | super.init(style: style, reuseIdentifier: reuseIdentifier)
17 | selectionStyle = .none
18 |
19 | contentView.addSubview(view)
20 | view.snp.makeConstraints {
21 | $0.edges.equalToSuperview()
22 | }
23 | }
24 |
25 | // swiftlint:disable unavailable_function
26 | required init?(coder: NSCoder) {
27 | fatalError(L10n.Development.fatalErrorInitCoderNotImplemented)
28 | }
29 | // swiftlint:enable unavailable_function
30 |
31 | func update(with viewModel: ListItemViewModel) {
32 | view.update(with: viewModel)
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/Moments/Moments/Foundations/Views/BaseTableViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BaseTableViewController.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 30/10/20.
6 | //
7 |
8 | import Foundation
9 | import UIKit
10 | import RxSwift
11 | import RxCocoa
12 | import RxDataSources
13 | import DesignKit
14 |
15 | class BaseTableViewController: BaseViewController {
16 | var viewModel: ListViewModel!
17 |
18 | private let tableView: UITableView = configure(.init()) {
19 | $0.translatesAutoresizingMaskIntoConstraints = false
20 | $0.separatorStyle = .none
21 | $0.rowHeight = UITableView.automaticDimension
22 | $0.estimatedRowHeight = 100
23 | $0.contentInsetAdjustmentBehavior = .never
24 | $0.backgroundColor = UIColor.designKit.background
25 | }
26 | private let activityIndicatorView: UIActivityIndicatorView = configure(.init(style: .large)) {
27 | $0.translatesAutoresizingMaskIntoConstraints = false
28 | }
29 | private let errorLabel: UILabel = configure(.init()) {
30 | $0.translatesAutoresizingMaskIntoConstraints = false
31 | $0.isHidden = true
32 | $0.textColor = UIColor.designKit.primaryText
33 | $0.text = L10n.MomentsList.errorMessage
34 | }
35 |
36 | override func viewDidLoad() {
37 | super.viewDidLoad()
38 |
39 | setupUI()
40 | setupConstraints()
41 | DispatchQueue.main.async {
42 | // Walkaround for a warning
43 | // https://github.com/RxSwiftCommunity/RxDataSources/issues/331
44 | self.setupBindings()
45 | }
46 |
47 | loadItems()
48 | }
49 |
50 | var tableViewCellsToRegister: [String: UITableViewCell.Type] {
51 | fatalError(L10n.Development.fatalErrorSubclassToImplement)
52 | }
53 | }
54 |
55 | private extension BaseTableViewController {
56 | func setupUI() {
57 | view.backgroundColor = UIColor.designKit.background
58 |
59 | tableViewCellsToRegister.forEach {
60 | tableView.register($0.value, forCellReuseIdentifier: $0.key)
61 | }
62 |
63 | [tableView, activityIndicatorView, errorLabel].forEach {
64 | view.addSubview($0)
65 | }
66 | }
67 |
68 | func setupConstraints() {
69 | tableView.snp.makeConstraints {
70 | $0.edges.equalToSuperview()
71 | }
72 |
73 | activityIndicatorView.snp.makeConstraints {
74 | $0.center.equalToSuperview()
75 | }
76 |
77 | errorLabel.snp.makeConstraints {
78 | $0.center.equalToSuperview()
79 | }
80 | }
81 |
82 | func setupBindings() {
83 | tableView.refreshControl = configure(UIRefreshControl()) {
84 | let refreshControl = $0
85 | $0.rx.controlEvent(.valueChanged)
86 | .filter { refreshControl.isRefreshing }
87 | .bind { [weak self] _ in self?.loadItems() }
88 | .disposed(by: disposeBag)
89 | }
90 |
91 | let dataSource = RxTableViewSectionedReloadDataSource>(configureCell: { _, tableView, indexPath, item in
92 | let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: type(of: item)), for: indexPath)
93 | (cell as? ListItemCell)?.update(with: item)
94 | return cell
95 | })
96 | viewModel.listItems
97 | .bind(to: tableView.rx.items(dataSource: dataSource))
98 | .disposed(by: disposeBag)
99 |
100 | viewModel.hasError
101 | .map { !$0 }
102 | .bind(to: errorLabel.rx.isHidden)
103 | .disposed(by: disposeBag)
104 | }
105 |
106 | func loadItems() {
107 | viewModel.hasError.onNext(false)
108 | viewModel.loadItems()
109 | .observeOn(MainScheduler.instance)
110 | .do(onDispose: { [weak self] in
111 | self?.activityIndicatorView.rx.isAnimating.onNext(false)
112 | self?.tableView.refreshControl?.endRefreshing()
113 | })
114 | .map { false }
115 | .startWith(true)
116 | .distinctUntilChanged()
117 | .bind(to: activityIndicatorView.rx.isAnimating)
118 | .disposed(by: disposeBag)
119 | }
120 | }
121 |
--------------------------------------------------------------------------------
/Moments/Moments/Foundations/Views/BaseViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BaseViewController.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 17/10/20.
6 | //
7 |
8 | import UIKit
9 | import RxSwift
10 |
11 | class BaseViewController: UIViewController {
12 | lazy var disposeBag: DisposeBag = .init()
13 |
14 | init() {
15 | super.init(nibName: nil, bundle: nil)
16 | }
17 |
18 | // swiftlint:disable no_hardcoded_strings
19 | @available(*, unavailable, message: "We don't support init view controller from a nib.")
20 | override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
21 | super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
22 | }
23 | // swiftlint:enable no_hardcoded_strings
24 |
25 | // swiftlint:disable no_hardcoded_strings
26 | @available(*, unavailable, message: "We don't support init view controller from a nib.")
27 | required init?(coder: NSCoder) {
28 | fatalError(L10n.Development.fatalErrorInitCoderNotImplemented)
29 | }
30 | // swiftlint:enable no_hardcoded_strings
31 | }
32 |
--------------------------------------------------------------------------------
/Moments/Moments/Foundations/Views/ListItemCell.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ListItemCell.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 26/10/20.
6 | //
7 |
8 | protocol ListItemCell: AnyObject {
9 | func update(with viewModel: ListItemViewModel)
10 | }
11 |
--------------------------------------------------------------------------------
/Moments/Moments/Foundations/Views/ListItemView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ListItemView.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 27/10/20.
6 | //
7 |
8 | protocol ListItemView: AnyObject {
9 | func update(with viewModel: ListItemViewModel)
10 | }
11 |
--------------------------------------------------------------------------------
/Moments/Moments/Generated/Strings.swift:
--------------------------------------------------------------------------------
1 | // swiftlint:disable all
2 | // Generated using SwiftGen — https://github.com/SwiftGen/SwiftGen
3 |
4 | import Foundation
5 |
6 | // swiftlint:disable superfluous_disable_command file_length implicit_return
7 |
8 | // MARK: - Strings
9 |
10 | // swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length
11 | // swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces
12 | internal enum L10n {
13 |
14 | internal enum Tracking {
15 | /// Moments screen
16 | internal static let momentsScreen = L10n.tr("Localizable", "Tracking.momentsScreen")
17 | }
18 |
19 | internal enum Development {
20 | /// Default Configuration
21 | internal static let defaultConfiguration = L10n.tr("Localizable", "development.defaultConfiguration")
22 | /// This property has been accessed before the superview has been loaded. Only use this property in `viewDidLoad` or later, as accessing it inside `init` may add a second instance of this view to the hierarchy.
23 | internal static let fatalErrorAccessedAutomaticallyAdjustedContentViewEarly = L10n.tr("Localizable", "development.fatalErrorAccessedAutomaticallyAdjustedContentViewEarly")
24 | /// init(coder:) has not been implemented
25 | internal static let fatalErrorInitCoderNotImplemented = L10n.tr("Localizable", "development.fatalErrorInitCoderNotImplemented")
26 | /// Subclass has to implement this function
27 | internal static let fatalErrorSubclassToImplement = L10n.tr("Localizable", "development.fatalErrorSubclassToImplement")
28 | /// /graphql
29 | internal static let graphqlPath = L10n.tr("Localizable", "development.graphqlPath")
30 | /// Running unit tests...
31 | internal static let runningUnitTests = L10n.tr("Localizable", "development.runningUnitTests")
32 | }
33 |
34 | internal enum InternalMenu {
35 | /// Area 51
36 | internal static let area51 = L10n.tr("Localizable", "internalMenu.area51")
37 | /// Avatars
38 | internal static let avatars = L10n.tr("Localizable", "internalMenu.avatars")
39 | /// CFBundleVersion
40 | internal static let cfBundleVersion = L10n.tr("Localizable", "internalMenu.CFBundleVersion")
41 | /// Colors
42 | internal static let colors = L10n.tr("Localizable", "internalMenu.colors")
43 | /// Crash App
44 | internal static let crashApp = L10n.tr("Localizable", "internalMenu.crashApp")
45 | /// DesignKit Demo
46 | internal static let designKitDemo = L10n.tr("Localizable", "internalMenu.designKitDemo")
47 | /// Favorite button
48 | internal static let favoriteButton = L10n.tr("Localizable", "internalMenu.favoriteButton")
49 | /// Feature Toggles
50 | internal static let featureToggles = L10n.tr("Localizable", "internalMenu.featureToggles")
51 | /// General Info
52 | internal static let generalInfo = L10n.tr("Localizable", "internalMenu.generalInfo")
53 | /// Heart favorite button
54 | internal static let heartFavoriteButton = L10n.tr("Localizable", "internalMenu.heartFavoriteButton")
55 | /// Like Button Enable
56 | internal static let likeButtonForMomentEnabled = L10n.tr("Localizable", "internalMenu.likeButtonForMomentEnabled")
57 | /// Star favorite button
58 | internal static let starFavoriteButton = L10n.tr("Localizable", "internalMenu.starFavoriteButton")
59 | /// Tools
60 | internal static let tools = L10n.tr("Localizable", "internalMenu.tools")
61 | /// Typography
62 | internal static let typography = L10n.tr("Localizable", "internalMenu.typography")
63 | /// Version
64 | internal static let version = L10n.tr("Localizable", "internalMenu.version")
65 | }
66 |
67 | internal enum MomentsList {
68 | /// Something went wrong, please try again later
69 | internal static let errorMessage = L10n.tr("Localizable", "momentsList.errorMessage")
70 | }
71 | }
72 | // swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length
73 | // swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces
74 |
75 | // MARK: - Implementation Details
76 |
77 | extension L10n {
78 | private static func tr(_ table: String, _ key: String, _ args: CVarArg...) -> String {
79 | let format = BundleToken.bundle.localizedString(forKey: key, value: nil, table: table)
80 | return String(format: format, locale: Locale.current, arguments: args)
81 | }
82 | }
83 |
84 | // swiftlint:disable convenience_type
85 | private final class BundleToken {
86 | static let bundle: Bundle = {
87 | #if SWIFT_PACKAGE
88 | return Bundle.module
89 | #else
90 | return Bundle(for: BundleToken.self)
91 | #endif
92 | }()
93 | }
94 | // swiftlint:enable convenience_type
95 |
--------------------------------------------------------------------------------
/Moments/Moments/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | ITSAppUsesNonExemptEncryption
6 |
7 | API_BASE_URL
8 | $(API_BASE_URL)
9 | CFBundleDevelopmentRegion
10 | $(DEVELOPMENT_LANGUAGE)
11 | CFBundleExecutable
12 | $(EXECUTABLE_NAME)
13 | CFBundleIdentifier
14 | $(PRODUCT_BUNDLE_IDENTIFIER)
15 | CFBundleInfoDictionaryVersion
16 | 6.0
17 | CFBundleName
18 | $(PRODUCT_BUNDLE_NAME)
19 | CFBundlePackageType
20 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
21 | CFBundleShortVersionString
22 | ${PRODUCT_VERSION_BASE}
23 | CFBundleVersion
24 | ${PRODUCT_VERSION}
25 | LSRequiresIPhoneOS
26 |
27 | UIApplicationSceneManifest
28 |
29 | UIApplicationSupportsMultipleScenes
30 |
31 | UISceneConfigurations
32 |
33 | UIWindowSceneSessionRoleApplication
34 |
35 |
36 | UISceneConfigurationName
37 | Default Configuration
38 | UISceneDelegateClassName
39 | $(PRODUCT_MODULE_NAME).SceneDelegate
40 |
41 |
42 |
43 |
44 | UIApplicationSupportsIndirectInputEvents
45 |
46 | UILaunchStoryboardName
47 | LaunchScreen
48 | UIRequiredDeviceCapabilities
49 |
50 | armv7
51 |
52 | UISupportedInterfaceOrientations
53 |
54 | UIInterfaceOrientationPortrait
55 |
56 | UISupportedInterfaceOrientations~ipad
57 |
58 | UIInterfaceOrientationPortrait
59 | UIInterfaceOrientationPortraitUpsideDown
60 | UIInterfaceOrientationLandscapeLeft
61 | UIInterfaceOrientationLandscapeRight
62 |
63 |
64 |
65 |
--------------------------------------------------------------------------------
/Moments/Moments/Resources/Base.lproj/LaunchScreen.storyboard:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/Moments/Moments/Resources/en.lproj/Localizable.strings:
--------------------------------------------------------------------------------
1 | /*
2 | Localizable.strings
3 | Moments
4 |
5 | Created by Jake Lin on 22/10/20.
6 |
7 | */
8 |
9 | // Internal Menu
10 | "internalMenu.area51" = "Area 51";
11 | "internalMenu.generalInfo" = "General Info";
12 | "internalMenu.version" = "Version";
13 | "internalMenu.CFBundleVersion" = "CFBundleVersion";
14 | "internalMenu.designKitDemo" = "DesignKit Demo";
15 | "internalMenu.featureToggles" = "Feature Toggles";
16 | "internalMenu.tools" = "Tools";
17 | "internalMenu.crashApp" = "Crash App";
18 |
19 | "internalMenu.typography" = "Typography";
20 | "internalMenu.colors" = "Colors";
21 | "internalMenu.avatars" = "Avatars";
22 |
23 | "internalMenu.favoriteButton" = "Favorite button";
24 | "internalMenu.starFavoriteButton" = "Star favorite button";
25 | "internalMenu.heartFavoriteButton" = "Heart favorite button";
26 |
27 | "internalMenu.likeButtonForMomentEnabled" = "Like Button Enable";
28 |
29 | // Moments List
30 | "momentsList.errorMessage" = "Something went wrong, please try again later";
31 |
32 | // Development messages
33 | "development.defaultConfiguration" = "Default Configuration";
34 | "development.fatalErrorInitCoderNotImplemented" = "init(coder:) has not been implemented";
35 | "development.fatalErrorAccessedAutomaticallyAdjustedContentViewEarly" = "This property has been accessed before the superview has been loaded. Only use this property in `viewDidLoad` or later, as accessing it inside `init` may add a second instance of this view to the hierarchy.";
36 | "development.fatalErrorSubclassToImplement" = "Subclass has to implement this function";
37 | "development.graphqlPath" = "/graphql";
38 | "development.runningUnitTests" = "Running unit tests...";
39 |
40 | // Tracking events
41 | "Tracking.momentsScreen" = "Moments screen";
42 |
--------------------------------------------------------------------------------
/Moments/Moments/Resources/zh-Hans.lproj/Localizable.strings:
--------------------------------------------------------------------------------
1 | /*
2 | Localizable.strings
3 | Moments
4 |
5 | Created by Jake Lin on 22/10/20.
6 |
7 | */
8 |
9 | // Internal Menu
10 | "internalMenu.area51" = "51 区";
11 | "internalMenu.generalInfo" = "通用信息";
12 | "internalMenu.version" = "版本号";
13 | "internalMenu.CFBundleVersion" = "CFBundleVersion";
14 | "internalMenu.designKitDemo" = "DesignKit 范例";
15 | "internalMenu.featureToggles" = "功能开关";
16 | "internalMenu.tools" = "工具箱";
17 | "internalMenu.crashApp" = "闪退 App";
18 |
19 | "internalMenu.typography" = "字体";
20 | "internalMenu.colors" = "颜色";
21 | "internalMenu.avatars" = "人像";
22 |
23 | "internalMenu.favoriteButton" = "点赞按钮";
24 | "internalMenu.starFavoriteButton" = "星形点赞按钮";
25 | "internalMenu.heartFavoriteButton" = "心形点赞按钮";
26 |
27 | "internalMenu.likeButtonForMomentEnabled" = "开启点赞按钮";
28 |
29 | // Moments List
30 | "momentsList.errorMessage" = "出错啦,请稍后再试";
31 |
32 | // Development messages
33 | "development.defaultConfiguration" = "Default Configuration";
34 | "development.fatalErrorInitCoderNotImplemented" = "init(coder:) has not been implemented";
35 | "development.fatalErrorAccessedAutomaticallyAdjustedContentViewEarly" = "This property has been accessed before the superview has been loaded. Only use this property in `viewDidLoad` or later, as accessing it inside `init` may add a second instance of this view to the hierarchy.";
36 | "development.fatalErrorSubclassToImplement" = "Subclass has to implement this function";
37 | "development.graphqlPath" = "/graphql";
38 | "development.runningUnitTests" = "运行单元测试中...";
39 |
40 | // Tracking events
41 | "Tracking.momentsScreen" = "Moments screen";
42 |
--------------------------------------------------------------------------------
/Moments/Moments/SceneDelegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SceneDelegate.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 15/10/20.
6 | //
7 |
8 | import UIKit
9 |
10 | class SceneDelegate: UIResponder, UIWindowSceneDelegate {
11 | var window: UIWindow?
12 |
13 | func scene(_ scene: UIScene,
14 | willConnectTo session: UISceneSession,
15 | options connectionOptions: UIScene.ConnectionOptions) {
16 | // Use this method to optionally configure and attach the UIWindow `window`
17 | // to the provided UIWindowScene `scene`.
18 | // If using a storyboard, the `window` property will automatically be initialized
19 | // and attached to the scene.
20 | // This delegate does not imply the connecting scene
21 | // or session are new (see `application:configurationForConnectingSceneSession` instead).
22 |
23 | guard let windowScene = (scene as? UIWindowScene) else { return }
24 |
25 | // Do not want to use private API `_removeSessionFromSessionSet` as memtioned on https://gist.github.com/HiddenJester/e5409ce2ca823b0003c59ce11a494b1d
26 | // Just conditionally check and replace with `UnitTestViewController` when running unit tests
27 | window = UIWindow(windowScene: windowScene)
28 | if UIApplication.shared.isRunningUnitTests {
29 | window?.rootViewController = UnitTestViewController()
30 | } else {
31 | window?.rootViewController = MomentsTimelineViewController()
32 | }
33 | window?.makeKeyAndVisible()
34 |
35 | // Handle Universal Links here if already opt into Scenes
36 | if let userActivity = connectionOptions.userActivities.first,
37 | userActivity.activityType == NSUserActivityTypeBrowsingWeb,
38 | let incomingURL = userActivity.webpageURL {
39 | let router: AppRouting = AppRouter.shared
40 | router.route(to: incomingURL, from: nil, using: .present)
41 | }
42 | }
43 |
44 | func sceneDidDisconnect(_ scene: UIScene) {
45 | // Called as the scene is being released by the system.
46 | // This occurs shortly after the scene enters the background, or when its session is discarded.
47 | // Release any resources associated with this scene that can be re-created the next time the scene connects.
48 | // The scene may re-connect later, as its session was not necessarily discarded
49 | // (see `application:didDiscardSceneSessions` instead).
50 | }
51 |
52 | func sceneDidBecomeActive(_ scene: UIScene) {
53 | // Called when the scene has moved from an inactive state to an active state.
54 | // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive.
55 | }
56 |
57 | func sceneWillResignActive(_ scene: UIScene) {
58 | // Called when the scene will move from an active state to an inactive state.
59 | // This may occur due to temporary interruptions (ex. an incoming phone call).
60 | }
61 |
62 | func sceneWillEnterForeground(_ scene: UIScene) {
63 | // Called as the scene transitions from the background to the foreground.
64 | // Use this method to undo the changes made on entering the background.
65 | }
66 |
67 | func sceneDidEnterBackground(_ scene: UIScene) {
68 | // Called as the scene transitions from the foreground to the background.
69 | // Use this method to save data, release shared resources, and store enough scene-specific state information
70 | // to restore the scene back to its current state.
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/Moments/Moments/main.swift:
--------------------------------------------------------------------------------
1 | //
2 | // main.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 15/11/20.
6 | //
7 |
8 | import UIKit
9 |
10 | _ = UIApplicationMain(
11 | CommandLine.argc,
12 | CommandLine.unsafeArgv,
13 | nil,
14 | UIApplication.shared.isRunningUnitTests ? nil: NSStringFromClass(AppDelegate.self)
15 | )
16 |
--------------------------------------------------------------------------------
/Moments/MomentsTests/Features/Moments/Analytics/LikeActionTrackingEventTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LikeActionTrackingEventTests.swift
3 | // MomentsTests
4 | //
5 | // Created by Jake Lin on 16/11/20.
6 | //
7 |
8 | import Foundation
9 |
10 | import Quick
11 | import Nimble
12 | import FirebaseAnalytics
13 | @testable import Moments
14 |
15 | final class LikeActionTrackingEventTests: QuickSpec {
16 | override func spec() {
17 | describe("LikeActionTrackingEvent") {
18 | var testSubject: LikeActionTrackingEvent!
19 |
20 | beforeEach {
21 | testSubject = LikeActionTrackingEvent(momentID: "0", userID: "1")
22 | }
23 |
24 | context("FirebaseActionTrackingEvent") {
25 | it("should conform to `FirebaseActionTrackingEvent`") {
26 | expect(testSubject).to(beAKindOf(FirebaseActionTrackingEvent.self))
27 | }
28 |
29 | it("should return parameters correctly") {
30 | expect(testSubject.parameters[AnalyticsParameterItemID] as? String).to(equal("moment-id-0-user-id-1"))
31 | expect(testSubject.parameters[AnalyticsParameterItemName] as? String).to(equal("moment-like"))
32 | }
33 | }
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/Moments/MomentsTests/Features/Moments/Analytics/UnlikeActionTrackingEventTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UnlikeActionTrackingEventTests.swift
3 | // MomentsTests
4 | //
5 | // Created by Jake Lin on 16/11/20.
6 | //
7 |
8 | import Foundation
9 |
10 | import Quick
11 | import Nimble
12 | import FirebaseAnalytics
13 | @testable import Moments
14 |
15 | final class UnlikeActionTrackingEventTests: QuickSpec {
16 | override func spec() {
17 | describe("UnlikeActionTrackingEvent") {
18 | var testSubject: UnlikeActionTrackingEvent!
19 |
20 | beforeEach {
21 | testSubject = UnlikeActionTrackingEvent(momentID: "0", userID: "1")
22 | }
23 |
24 | context("FirebaseActionTrackingEvent") {
25 | it("should conform to `FirebaseActionTrackingEvent`") {
26 | expect(testSubject).to(beAKindOf(FirebaseActionTrackingEvent.self))
27 | }
28 |
29 | it("should return parameters correctly") {
30 | expect(testSubject.parameters[AnalyticsParameterItemID] as? String).to(equal("moment-id-0-user-id-1"))
31 | expect(testSubject.parameters[AnalyticsParameterItemName] as? String).to(equal("moment-unlike"))
32 | }
33 | }
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/Moments/MomentsTests/Features/Moments/Networking/UpdateMomentLikeSessionTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UpdateMomentLikeSessionTests.swift
3 | // MomentsTests
4 | //
5 | // Created by Jake Lin on 17/11/20.
6 | //
7 |
8 | import Foundation
9 | import Quick
10 | import Nimble
11 | import RxSwift
12 | import RxTest
13 |
14 | @testable import Moments
15 |
16 | final class UpdateMomentLikeSessionTests: QuickSpec {
17 | override func spec() {
18 | describe("UpdateMomentLikeSession") {
19 | var testSubject: UpdateMomentLikeSession!
20 | var testScheduler: TestScheduler!
21 | var testObserver: TestableObserver!
22 | var mockResponseEvent: Recorded>!
23 | var disposeBag: DisposeBag!
24 |
25 | beforeEach {
26 | testScheduler = TestScheduler(initialClock: 0)
27 | testObserver = testScheduler.createObserver(MomentsDetails.self)
28 | disposeBag = DisposeBag()
29 | }
30 |
31 | context("updateLike(:momentID:userID:") {
32 | context("when response status code 200 with valid response") {
33 | beforeEach {
34 | mockResponseEvent = .next(100, TestData.successResponse)
35 | updateLike(mockEvent: mockResponseEvent)
36 | }
37 |
38 | it("should complete and map the response correctly") {
39 | let expectedMomentsDetails = TestFixture.momentsDetails
40 | let actualMomentsDetails = testObserver.events.first!.value.element!
41 |
42 | expect(actualMomentsDetails).toEventually(equal(expectedMomentsDetails))
43 | }
44 | }
45 |
46 | context("when response status code 200 with invalid data") {
47 | let invalidJSONError: APISessionError = .invalidJSON
48 |
49 | beforeEach {
50 | mockResponseEvent = .error(100, invalidJSONError, UpdateMomentLikeSession.Response.self)
51 | updateLike(mockEvent: mockResponseEvent)
52 | }
53 |
54 | it("should throw invalid json error") {
55 | let actualError = testObserver.events.first!.value.error as! APISessionError
56 | expect(actualError).toEventually(equal(.invalidJSON))
57 | }
58 | }
59 |
60 | context("when response status code non-200") {
61 | let networkError: APISessionError = .networkError(error: MockError(), statusCode: 500)
62 |
63 | beforeEach {
64 | mockResponseEvent = .error(100, networkError, UpdateMomentLikeSession.Response.self)
65 | updateLike(mockEvent: mockResponseEvent)
66 | }
67 |
68 | it("should throw a network error") {
69 | let actualError = testObserver.events.first!.value.error as! APISessionError
70 | expect(actualError).toEventually(equal(networkError))
71 | }
72 | }
73 | }
74 |
75 | func updateLike(mockEvent: Recorded>) {
76 | let testableObservable = testScheduler.createHotObservable([mockEvent])
77 | testSubject = UpdateMomentLikeSession { _ in testableObservable.asObservable() }
78 | testSubject.updateLike(true, momentID: "0", fromUserID: "1").subscribe(testObserver).disposed(by: disposeBag)
79 | testScheduler.start()
80 | }
81 | }
82 | }
83 | }
84 |
85 | private struct TestData {
86 | static let successResponse: UpdateMomentLikeSession.Response = {
87 | let response = try! JSONDecoder().decode(UpdateMomentLikeSession.Response.self,
88 | from: TestData.successjson.data(using: .utf8)!)
89 | return response
90 | }()
91 |
92 | static let successjson = """
93 | {
94 | "data": {
95 | "updateMomentLike": {
96 | "userDetails": {
97 | "id": "0",
98 | "name": "Jake Lin",
99 | "avatar": "https://avatars0.githubusercontent.com/u/573856?s=460&v=4",
100 | "backgroundImage": "https://cdn.pixabay.com/photo/2015/04/23/22/00/tree-736885_960_720.jpg"
101 | },
102 | "moments": [
103 | {
104 | "id": "0",
105 | "userDetails": {
106 | "name": "Taylor Swift",
107 | "avatar": "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQlk0dgrwcQ0FiTKdgR3atzstJ_wZC4gtPgOmUYBsLO2aa9ssXs"
108 | },
109 | "type": "PHOTOS",
110 | "title": "the pic is awesome",
111 | "photos": [
112 | "https://encrypted-tbn0.gstatic.com/images?q=tbn%3AANd9GcRisv-yQgXGrto6OxQxX62JyvyQGvRsQQ760g&usqp=CAU"
113 | ],
114 | "createdDate": "1605521360",
115 | "isLiked": true,
116 | "likes": [
117 | {
118 | "id": "0",
119 | "avatar": "https://avatars0.githubusercontent.com/u/573856?s=460&v=4"
120 | }
121 | ]
122 | },
123 | {
124 | "id": "1",
125 | "userDetails": {
126 | "name": "Mattt",
127 | "avatar": "https://pbs.twimg.com/profile_images/969321564050112513/fbdJZmEh_400x400.jpg"
128 | },
129 | "type": "PHOTOS",
130 | "title": "Low-level programming on iOS",
131 | "photos": [
132 | "https://i.pinimg.com/originals/15/27/3e/15273e2fa37cba67b5c539f254b26c21.png"
133 | ],
134 | "createdDate": "1605519980",
135 | "isLiked": false,
136 | "likes": [
137 | {
138 | "id": "105",
139 | "avatar": "https://randomuser.me/api/portraits/women/69.jpg"
140 | }
141 | ]
142 | }
143 | ]
144 | }
145 | }
146 | }
147 | """
148 | }
149 |
--------------------------------------------------------------------------------
/Moments/MomentsTests/Features/Moments/Repositories/MomentsRepoTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MomentsRepoTests.swift
3 | // MomentsTests
4 | //
5 | // Created by Jake Lin on 16/11/20.
6 | //
7 |
8 | import Foundation
9 |
10 | import Quick
11 | import Nimble
12 | import RxSwift
13 | import RxTest
14 |
15 | @testable import Moments
16 |
17 | private class MockUserDefaultsPersistentDataStore: PersistentDataStoreType {
18 | private(set) var momentsDetails: ReplaySubject = .create(bufferSize: 1)
19 |
20 | private(set) var savedMomentsDetails: MomentsDetails?
21 |
22 | func save(momentsDetails: MomentsDetails) {
23 | savedMomentsDetails = momentsDetails
24 | }
25 | }
26 |
27 | private class MockGetMomentsByUserIDSession: GetMomentsByUserIDSessionType {
28 | private(set) var getMomentsHasbeenCalled = false
29 | private(set) var passedUserID: String = ""
30 |
31 | func getMoments(userID: String) -> Observable {
32 | passedUserID = userID
33 | getMomentsHasbeenCalled = true
34 |
35 | return Observable.just(TestFixture.momentsDetails)
36 | }
37 | }
38 |
39 | private class MockUpdateMomentLikeSession: UpdateMomentLikeSessionType {
40 | private(set) var updateLikeHasbeenCalled = false
41 | private(set) var passedIsLiked: Bool = false
42 | private(set) var passedMomentID: String = ""
43 | private(set) var passedUserID: String = ""
44 |
45 | func updateLike(_ isLiked: Bool, momentID: String, fromUserID userID: String) -> Observable {
46 | updateLikeHasbeenCalled = true
47 | passedIsLiked = isLiked
48 | passedMomentID = momentID
49 | passedUserID = userID
50 |
51 | return Observable.just(TestFixture.momentsDetails)
52 | }
53 | }
54 |
55 | final class MomentsRepoTests: QuickSpec {
56 | override func spec() {
57 | describe("MomentsRepo") {
58 | var testSubject: MomentsRepo!
59 | var mockUserDefaultsPersistentDataStore: MockUserDefaultsPersistentDataStore!
60 | var mockGetMomentsByUserIDSession: MockGetMomentsByUserIDSession!
61 | var mockUpdateMomentLikeSession: MockUpdateMomentLikeSession!
62 | var disposeBag: DisposeBag!
63 |
64 | beforeEach {
65 | mockUserDefaultsPersistentDataStore = MockUserDefaultsPersistentDataStore()
66 | mockGetMomentsByUserIDSession = MockGetMomentsByUserIDSession()
67 | mockUpdateMomentLikeSession = MockUpdateMomentLikeSession()
68 | disposeBag = DisposeBag()
69 |
70 | testSubject = MomentsRepo(persistentDataStore: mockUserDefaultsPersistentDataStore, getMomentsByUserIDSession: mockGetMomentsByUserIDSession, updateMomentLikeSession: mockUpdateMomentLikeSession)
71 | }
72 |
73 | context("getMoments(userID:)") {
74 | beforeEach {
75 | testSubject.getMoments(userID: "1").subscribe().disposed(by: disposeBag)
76 | }
77 |
78 | it("should call `GetMomentsByUserIDSessionType.getMoments`") {
79 | expect(mockGetMomentsByUserIDSession.getMomentsHasbeenCalled).to(beTrue())
80 | expect(mockGetMomentsByUserIDSession.passedUserID).to(be("1"))
81 | }
82 |
83 | it("should save a `MomentsDetails` object") {
84 | expect(mockUserDefaultsPersistentDataStore.savedMomentsDetails).to(equal(TestFixture.momentsDetails))
85 | }
86 | }
87 |
88 | context("updateLike(isLiked:momentID:fromUserID:)") {
89 | beforeEach {
90 | testSubject.updateLike(isLiked: true, momentID: "0", fromUserID: "1").subscribe().disposed(by: disposeBag)
91 | }
92 |
93 | it("should call `UpdateMomentLikeSessionType.updateLike`") {
94 | expect(mockUpdateMomentLikeSession.updateLikeHasbeenCalled).to(beTrue())
95 | expect(mockUpdateMomentLikeSession.passedIsLiked).to(beTrue())
96 | expect(mockUpdateMomentLikeSession.passedMomentID).to(be("0"))
97 | expect(mockUpdateMomentLikeSession.passedUserID).to(be("1"))
98 | }
99 |
100 | it("should save a `MomentsDetails` object") {
101 | expect(mockUserDefaultsPersistentDataStore.savedMomentsDetails).to(equal(TestFixture.momentsDetails))
102 | }
103 | }
104 |
105 | context("momentsDetails") {
106 | var testObserver: TestObserver!
107 |
108 | beforeEach {
109 | testObserver = TestObserver()
110 | testSubject.momentsDetails.subscribe(testObserver).disposed(by: disposeBag)
111 | }
112 |
113 | it("should be `nil` by default") {
114 | expect(testObserver.lastElement).to(beNil())
115 | }
116 |
117 | context("when persistentDataStore has new data") {
118 | beforeEach {
119 | mockUserDefaultsPersistentDataStore.momentsDetails.onNext(TestFixture.momentsDetails)
120 | }
121 |
122 | it("should notify a next event with the new data") {
123 | expect(testObserver.lastElement).toEventually(equal(TestFixture.momentsDetails))
124 | }
125 | }
126 | }
127 | }
128 | }
129 | }
130 |
--------------------------------------------------------------------------------
/Moments/MomentsTests/Features/Moments/ViewModels/MomentListItemViewModelTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MomentListItemViewModelTests.swift
3 | // MomentsTests
4 | //
5 | // Created by Jake Lin on 17/11/20.
6 | //
7 |
8 | import Foundation
9 |
10 | import Quick
11 | import Nimble
12 | import RxSwift
13 | import RxTest
14 |
15 | @testable import Moments
16 |
17 | private class MockMomentsRepo: MomentsRepoType {
18 | var momentsDetails: ReplaySubject = .create(bufferSize: 1)
19 |
20 | func getMoments(userID: String) -> Observable {
21 | return Observable.just(())
22 | }
23 |
24 | private(set) var updateLikeHasBeenCalled: Bool = false
25 | private(set) var passedIsLiked: Bool!
26 | private(set) var passedMomentID: String!
27 | private(set) var passedUserID: String!
28 |
29 | func updateLike(isLiked: Bool, momentID: String, fromUserID userID: String) -> Observable {
30 | updateLikeHasBeenCalled = true
31 | passedIsLiked = isLiked
32 | passedMomentID = momentID
33 | passedUserID = userID
34 | return Observable.just(())
35 | }
36 | }
37 |
38 | private struct MockRelativeDateTimeFormatter: RelativeDateTimeFormatterType {
39 | var unitsStyle: RelativeDateTimeFormatter.UnitsStyle = .full
40 |
41 | func localizedString(for date: Date, relativeTo referenceDate: Date) -> String {
42 | return "3 days ago"
43 | }
44 | }
45 |
46 | final class MomentListItemViewModelTests: QuickSpec {
47 | override func spec() {
48 | describe("MomentListItemViewModel") {
49 | var testSubject: MomentListItemViewModel!
50 | var mockMomentsRepo: MockMomentsRepo!
51 | var mockTrackingRepo: MockTrackingRepo!
52 | var mockNow: Date!
53 | var mockRelativeDateTimeFormatter: MockRelativeDateTimeFormatter!
54 | var disposeBag: DisposeBag!
55 |
56 | beforeEach {
57 | mockMomentsRepo = MockMomentsRepo()
58 | mockTrackingRepo = MockTrackingRepo()
59 | mockNow = MockNow.now
60 | mockRelativeDateTimeFormatter = MockRelativeDateTimeFormatter()
61 | disposeBag = DisposeBag()
62 | }
63 |
64 | context("init(moment:)") {
65 | context("when all data provided") {
66 | beforeEach {
67 | testSubject = MomentListItemViewModel(moment: TestFixture.moment, momentsRepo: mockMomentsRepo, trackingRepo: mockTrackingRepo, now: mockNow, relativeDateTimeFormatter: mockRelativeDateTimeFormatter)
68 | }
69 |
70 | it("should initialize the properties correctly") {
71 | expect(testSubject.userAvatarURL).to(equal(URL(string: "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQlk0dgrwcQ0FiTKdgR3atzstJ_wZC4gtPgOmUYBsLO2aa9ssXs")))
72 | expect(testSubject.userName).to(equal("Taylor Swift"))
73 | expect(testSubject.title).to(equal("the pic is awesome"))
74 | expect(testSubject.photoURL).to(equal(URL(string: "https://encrypted-tbn0.gstatic.com/images?q=tbn%3AANd9GcRisv-yQgXGrto6OxQxX62JyvyQGvRsQQ760g&usqp=CAU")))
75 | expect(testSubject.postDateDescription).to(equal("3 days ago"))
76 | }
77 | }
78 |
79 | context("when `moment.userDetails.avatar` is not a valid URL") {
80 | beforeEach {
81 | testSubject = MomentListItemViewModel(moment: MomentsDetails.Moment(id: "0", userDetails: MomentsDetails.Moment.MomentUserDetails(name: "Taylor Swift", avatar: "this is not a valid URL"), type: MomentsDetails.Moment.MomentType.photos, title: "the pic is awesome", url: nil, photos: [ "https://encrypted-tbn0.gstatic.com/images?q=tbn%3AANd9GcRisv-yQgXGrto6OxQxX62JyvyQGvRsQQ760g&usqp=CAU"], createdDate: "1605521360", isLiked: true, likes: [MomentsDetails.Moment.LikedUserDetails(id: "0", avatar: "https://avatars0.githubusercontent.com/u/573856?s=460&v=4")]), momentsRepo: mockMomentsRepo, trackingRepo: mockTrackingRepo, now: mockNow, relativeDateTimeFormatter: MockRelativeDateTimeFormatter())
82 | }
83 |
84 | it("`userAvatarURL` should be nil") {
85 | expect(testSubject.userAvatarURL).to(beNil())
86 | }
87 | }
88 |
89 | context("when `moment.photos` is empty") {
90 | beforeEach {
91 | testSubject = MomentListItemViewModel(moment: MomentsDetails.Moment(id: "0", userDetails: MomentsDetails.Moment.MomentUserDetails(name: "Taylor Swift", avatar: "this is not a valid URL"), type: MomentsDetails.Moment.MomentType.photos, title: "the pic is awesome", url: nil, photos: [], createdDate: "1605521360", isLiked: true, likes: [MomentsDetails.Moment.LikedUserDetails(id: "0", avatar: "https://avatars0.githubusercontent.com/u/573856?s=460&v=4")]), momentsRepo: mockMomentsRepo, trackingRepo: mockTrackingRepo, now: mockNow, relativeDateTimeFormatter: MockRelativeDateTimeFormatter())
92 | }
93 |
94 | it("`photoURL` should be nil") {
95 | expect(testSubject.photoURL).to(beNil())
96 | }
97 | }
98 | }
99 |
100 | context("reuseIdentifier") {
101 | it("should return the view model's name") {
102 | expect(MomentListItemViewModel.reuseIdentifier).to(equal("MomentListItemViewModel"))
103 | }
104 | }
105 |
106 | context("like(from:)") {
107 | beforeEach {
108 | testSubject = MomentListItemViewModel(moment: TestFixture.moment, momentsRepo: mockMomentsRepo, trackingRepo: mockTrackingRepo, now: mockNow, relativeDateTimeFormatter: MockRelativeDateTimeFormatter())
109 |
110 | testSubject.like(from: "1").subscribe().disposed(by: disposeBag)
111 | }
112 |
113 | it("should track with correct event") {
114 | expect(mockTrackingRepo.trackedActionEvent).to(beAKindOf(LikeActionTrackingEvent.self))
115 | }
116 |
117 | it("should call `momentsRepo.updateLike` with correct parameters") {
118 | expect(mockMomentsRepo.updateLikeHasBeenCalled).to(beTrue())
119 | expect(mockMomentsRepo.passedIsLiked).to(beTrue())
120 | expect(mockMomentsRepo.passedMomentID).to(equal("0"))
121 | expect(mockMomentsRepo.passedUserID).to(equal("1"))
122 | }
123 | }
124 |
125 | context("unlike(from:)") {
126 | beforeEach {
127 | testSubject = MomentListItemViewModel(moment: TestFixture.moment, momentsRepo: mockMomentsRepo, trackingRepo: mockTrackingRepo, now: mockNow, relativeDateTimeFormatter: MockRelativeDateTimeFormatter())
128 |
129 | testSubject.unlike(from: "1").subscribe().disposed(by: disposeBag)
130 | }
131 |
132 | it("should track with correct event") {
133 | expect(mockTrackingRepo.trackedActionEvent).to(beAKindOf(UnlikeActionTrackingEvent.self))
134 | }
135 |
136 | it("should call `momentsRepo.updateLike` with correct parameters") {
137 | expect(mockMomentsRepo.updateLikeHasBeenCalled).to(beTrue())
138 | expect(mockMomentsRepo.passedIsLiked).to(beFalse())
139 | expect(mockMomentsRepo.passedMomentID).to(equal("0"))
140 | expect(mockMomentsRepo.passedUserID).to(equal("1"))
141 | }
142 | }
143 | }
144 | }
145 | }
146 |
--------------------------------------------------------------------------------
/Moments/MomentsTests/Features/Moments/ViewModels/MomentsTimelineViewModelTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MomentsTimelineViewModelTests.swift
3 | // MomentsTests
4 | //
5 | // Created by Jake Lin on 18/11/20.
6 | //
7 |
8 | import Foundation
9 |
10 | import Quick
11 | import Nimble
12 | import RxSwift
13 | import RxTest
14 |
15 | @testable import Moments
16 |
17 | private class MockMomentsRepo: MomentsRepoType {
18 | var momentsDetails: ReplaySubject = .create(bufferSize: 1)
19 |
20 | private(set) var getMomentsHasBeenCalled: Bool = false
21 | private(set) var passedUserID: String!
22 |
23 | func getMoments(userID: String) -> Observable {
24 | getMomentsHasBeenCalled = true
25 | passedUserID = userID
26 | return Observable.just(())
27 | }
28 |
29 | func updateLike(isLiked: Bool, momentID: String, fromUserID userID: String) -> Observable {
30 | return Observable.just(())
31 | }
32 | }
33 |
34 | final class MomentsTimelineViewModelTests: QuickSpec {
35 | override func spec() {
36 | describe("MomentsTimelineViewModel") {
37 | var testSubject: MomentsTimelineViewModel!
38 | var mockMomentsRepo: MockMomentsRepo!
39 | var disposeBag: DisposeBag!
40 |
41 | beforeEach {
42 | mockMomentsRepo = MockMomentsRepo()
43 | disposeBag = DisposeBag()
44 | testSubject = MomentsTimelineViewModel(userID: "1", momentsRepo: mockMomentsRepo)
45 | }
46 |
47 | context("loadItems()") {
48 | beforeEach {
49 | testSubject.loadItems().subscribe().disposed(by: disposeBag)
50 | }
51 |
52 | it("call `momentsRepo.getMoments()` with the correct parameters") {
53 | expect(mockMomentsRepo.getMomentsHasBeenCalled).to(beTrue())
54 | expect(mockMomentsRepo.passedUserID).to(equal("1"))
55 | }
56 | }
57 | }
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/Moments/MomentsTests/Features/Moments/ViewModels/UserProfileListItemViewModelTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UserProfileListItemViewModelTests.swift
3 | // MomentsTests
4 | //
5 | // Created by Jake Lin on 17/11/20.
6 | //
7 |
8 | import Foundation
9 |
10 | import Quick
11 | import Nimble
12 | import RxSwift
13 | import RxTest
14 |
15 | @testable import Moments
16 |
17 | final class UserProfileListItemViewModelTests: QuickSpec {
18 | override func spec() {
19 | describe("UserProfileListItemViewModel") {
20 | var testSubject: UserProfileListItemViewModel!
21 |
22 | context("init(userDetails:)") {
23 | context("when all data provided") {
24 | beforeEach {
25 | testSubject = UserProfileListItemViewModel(userDetails: TestFixture.userDetails)
26 | }
27 |
28 | it("should initialize the properties correctly") {
29 | expect(testSubject.name).to(equal("Jake Lin"))
30 | expect(testSubject.avatarURL).to(equal(URL(string: "https://avatars0.githubusercontent.com/u/573856?s=460&v=4")))
31 | expect(testSubject.backgroundImageURL).to(equal(URL(string: "https://cdn.pixabay.com/photo/2015/04/23/22/00/tree-736885_960_720.jpg")))
32 | }
33 | }
34 |
35 | context("when `userDetails.avatar` is not a valid URL") {
36 | beforeEach {
37 | testSubject = UserProfileListItemViewModel(userDetails: MomentsDetails.UserDetails(id: "1", name: "name", avatar: "this is not a valid URL", backgroundImage: "https://cdn.pixabay.com/photo/2015/04/23/22/00/tree-736885_960_720.jpg"))
38 | }
39 |
40 | it("`avatarURL` should be nil") {
41 | expect(testSubject.avatarURL).to(beNil())
42 | }
43 | }
44 |
45 | context("when `userDetails.backgroundImage` is not a valid URL") {
46 | beforeEach {
47 | testSubject = UserProfileListItemViewModel(userDetails: MomentsDetails.UserDetails(id: "1", name: "name", avatar: "https://avatars0.githubusercontent.com/u/573856?s=460&v=4", backgroundImage: "this is not a valid URL"))
48 | }
49 |
50 | it("`backgroundImageURL` should be nil") {
51 | expect(testSubject.backgroundImageURL).to(beNil())
52 | }
53 | }
54 | }
55 |
56 | context("reuseIdentifier") {
57 | it("should return the view model's name") {
58 | expect(UserProfileListItemViewModel.reuseIdentifier).to(equal("UserProfileListItemViewModel"))
59 | }
60 | }
61 | }
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/Moments/MomentsTests/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | 1
21 |
22 |
23 |
--------------------------------------------------------------------------------
/Moments/MomentsTests/MomentsTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MomentsTests.swift
3 | // MomentsTests
4 | //
5 | // Created by Jake Lin on 15/10/20.
6 | //
7 |
8 | import XCTest
9 | @testable import Moments
10 |
11 | class MomentsTests: XCTestCase {
12 | override func setUpWithError() throws {
13 | // Put setup code here. This method is called before the invocation of each test method in the class.
14 | }
15 |
16 | override func tearDownWithError() throws {
17 | // Put teardown code here. This method is called after the invocation of each test method in the class.
18 | }
19 |
20 | func testExample() throws {
21 | // This is an example of a functional test case.
22 | // Use XCTAssert and related functions to verify your tests produce the correct results.
23 | }
24 |
25 | func testPerformanceExample() throws {
26 | // This is an example of a performance test case.
27 | self.measure {
28 | // Put the code you want to measure the time of here.
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/Moments/MomentsTests/Utilities/EquatableViaDump.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EquatableViaDump.swift
3 | // MomentsTests
4 | //
5 | // Created by Jake Lin on 16/11/20.
6 | //
7 |
8 | import Foundation
9 |
10 | public protocol EquatableViaDump: Equatable {
11 | static func == (lhs: Self, rhs: Self) -> Bool
12 | }
13 |
14 | public extension EquatableViaDump {
15 | static func == (lhs: Self, rhs: Self) -> Bool {
16 | var ldump = ""
17 | var rdump = ""
18 | dump(lhs, to: &ldump)
19 | dump(rhs, to: &rdump)
20 | return ldump == rdump
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/Moments/MomentsTests/Utilities/MockError.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MockError.swift
3 | // MomentsTests
4 | //
5 | // Created by Jake Lin on 16/11/20.
6 | //
7 |
8 | import Foundation
9 |
10 | struct MockError: Error, EquatableViaDump { }
11 |
--------------------------------------------------------------------------------
/Moments/MomentsTests/Utilities/MockNow.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MockNow.swift
3 | // MomentsTests
4 | //
5 | // Created by Jake Lin on 17/11/20.
6 | //
7 |
8 | import Foundation
9 |
10 | struct MockNow { }
11 |
12 | extension MockNow {
13 | static let now: Date = {
14 | let formatter = DateFormatter()
15 | formatter.dateFormat = "yyyy/MM/dd HH:mm:ss"
16 | return formatter.date(from: "2020/11/11 00:00:00")!
17 | }()
18 | }
19 |
--------------------------------------------------------------------------------
/Moments/MomentsTests/Utilities/MockTrackingRepo.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MockTrackingRepo.swift
3 | // MomentsTests
4 | //
5 | // Created by Jake Lin on 17/11/20.
6 | //
7 |
8 | import Foundation
9 |
10 | @testable import Moments
11 |
12 | class MockTrackingRepo: TrackingRepoType {
13 | func register(trackingProvider: TrackingProvider) { }
14 |
15 | func trackScreenviews(_ event: TrackingEventType) { }
16 |
17 | func trackEvent(_ event: TrackingEventType) { }
18 |
19 | private(set) var trackedActionEvent: TrackingEventType!
20 |
21 | func trackAction(_ event: TrackingEventType) {
22 | trackedActionEvent = event
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Moments/MomentsTests/Utilities/TestFixture.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TestFixture.swift
3 | // MomentsTests
4 | //
5 | // Created by Jake Lin on 16/11/20.
6 | //
7 |
8 | import Foundation
9 | @testable import Moments
10 |
11 | struct TestFixture { }
12 |
13 | extension TestFixture {
14 | static let userDetails: MomentsDetails.UserDetails = {
15 | return MomentsDetails.UserDetails(id: "0", name: "Jake Lin", avatar: "https://avatars0.githubusercontent.com/u/573856?s=460&v=4", backgroundImage: "https://cdn.pixabay.com/photo/2015/04/23/22/00/tree-736885_960_720.jpg")
16 | }()
17 |
18 | static let moment: MomentsDetails.Moment = {
19 | return MomentsDetails.Moment(id: "0", userDetails: MomentsDetails.Moment.MomentUserDetails(name: "Taylor Swift", avatar: "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQlk0dgrwcQ0FiTKdgR3atzstJ_wZC4gtPgOmUYBsLO2aa9ssXs"), type: MomentsDetails.Moment.MomentType.photos, title: "the pic is awesome", url: nil, photos: [ "https://encrypted-tbn0.gstatic.com/images?q=tbn%3AANd9GcRisv-yQgXGrto6OxQxX62JyvyQGvRsQQ760g&usqp=CAU"], createdDate: "1605521360", isLiked: true, likes: [MomentsDetails.Moment.LikedUserDetails(id: "0", avatar: "https://avatars0.githubusercontent.com/u/573856?s=460&v=4")])
20 | }()
21 |
22 | static let moments: [MomentsDetails.Moment] = {
23 | return [
24 | moment,
25 | MomentsDetails.Moment(id: "1", userDetails: MomentsDetails.Moment.MomentUserDetails(name: "Mattt", avatar: "https://pbs.twimg.com/profile_images/969321564050112513/fbdJZmEh_400x400.jpg"), type: MomentsDetails.Moment.MomentType.photos, title: "Low-level programming on iOS", url: nil, photos: ["https://i.pinimg.com/originals/15/27/3e/15273e2fa37cba67b5c539f254b26c21.png"], createdDate: "1605519980", isLiked: false, likes: [MomentsDetails.Moment.LikedUserDetails(id: "105", avatar: "https://randomuser.me/api/portraits/women/69.jpg")])]
26 | }()
27 |
28 | static let momentsDetails: MomentsDetails = {
29 | return MomentsDetails(userDetails: userDetails, moments: moments)
30 | }()
31 | }
32 |
--------------------------------------------------------------------------------
/Moments/MomentsTests/Utilities/TestObserver.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TestObserver.swift
3 | // MomentsTests
4 | //
5 | // Created by Jake Lin on 17/11/20.
6 | //
7 |
8 | import Foundation
9 | import RxSwift
10 |
11 | class TestObserver: ObserverType {
12 | private var lastEvent: Event?
13 |
14 | var lastElement: ElementType? {
15 | return lastEvent?.element
16 | }
17 |
18 | var lastError: Error? {
19 | return lastEvent?.error
20 | }
21 |
22 | var isCompleted: Bool {
23 | return lastEvent?.isCompleted ?? false
24 | }
25 |
26 | func on(_ event: Event) {
27 | lastEvent = event
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/Moments/MomentsUITests/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | 1
21 |
22 |
23 |
--------------------------------------------------------------------------------
/Moments/MomentsUITests/MomentsUITests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MomentsUITests.swift
3 | // MomentsUITests
4 | //
5 | // Created by Jake Lin on 15/10/20.
6 | //
7 |
8 | import XCTest
9 |
10 | class MomentsUITests: XCTestCase {
11 | override func setUpWithError() throws {
12 | // Put setup code here. This method is called before the invocation of each test method in the class.
13 |
14 | // In UI tests it is usually best to stop immediately when a failure occurs.
15 | continueAfterFailure = false
16 |
17 | // In UI tests it’s important to set the initial state
18 | // - such as interface orientation
19 | // - required for your tests before they run. The setUp method is a good place to do this.
20 | }
21 |
22 | override func tearDownWithError() throws {
23 | // Put teardown code here. This method is called after the invocation of each test method in the class.
24 | }
25 |
26 | func testExample() throws {
27 | // UI tests must launch the application that they test.
28 | let app = XCUIApplication()
29 | app.launch()
30 |
31 | // Use recording to get started writing UI tests.
32 | // Use XCTAssert and related functions to verify your tests produce the correct results.
33 | }
34 |
35 | func testLaunchPerformance() throws {
36 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, *) {
37 | // This measures how long it takes to launch your application.
38 | measure(metrics: [XCTApplicationLaunchMetric()]) {
39 | XCUIApplication().launch()
40 | }
41 | }
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/Moments/RoutingSource.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RoutingSource.swift
3 | // Moments
4 | //
5 | // Created by Jake Lin on 4/2/21.
6 | //
7 |
8 | import Foundation
9 | import UIKit
10 |
11 | protocol RoutingSource: AnyObject { }
12 |
13 | typealias RoutingSourceProvider = () -> RoutingSource?
14 |
15 | extension UIViewController: RoutingSource { }
16 |
--------------------------------------------------------------------------------
/Moments/swiftgen.yml:
--------------------------------------------------------------------------------
1 | ## Note: all of the config entries below are just examples with placeholders. Be sure to edit and adjust to your needs when uncommenting.
2 |
3 | ## In case your config entries all use a common input/output parent directory, you can specify those here.
4 | ## Every input/output paths in the rest of the config will then be expressed relative to these.
5 | ## Those two top-level keys are optional and default to "." (the directory of the config file).
6 | # input_dir: MyLib/Sources/
7 | # output_dir: MyLib/Generated/
8 |
9 |
10 | ## Generate constants for your localized strings.
11 | ## Be sure that SwiftGen only parses ONE locale (typically Base.lproj, or en.lproj, or whichever your development region is); otherwise it will generate the same keys multiple times.
12 | ## SwiftGen will parse all `.strings` files found in that folder.
13 | strings:
14 | inputs:
15 | - Moments/Resources/en.lproj
16 | outputs:
17 | - templateName: structured-swift5
18 | output: Moments/Generated/Strings.swift
19 |
20 |
21 | ## Generate constants for your Assets Catalogs, including constants for images, colors, ARKit resources, etc.
22 | ## This example also shows how to provide additional parameters to your template to customize the output.
23 | ## - Especially the `forceProvidesNamespaces: true` param forces to create sub-namespace for each folder/group used in your Asset Catalogs, even the ones without "Provides Namespace". Without this param, SwiftGen only generates sub-namespaces for folders/groups which have the "Provides Namespace" box checked in the Inspector pane.
24 | ## - To know which params are supported for a template, use `swiftgen template doc xcassets swift5` to open the template documentation on GitHub.
25 | # xcassets:
26 | # inputs:
27 | # - Main.xcassets
28 | # - ProFeatures.xcassets
29 | # outputs:
30 | # - templateName: swift5
31 | # params:
32 | # forceProvidesNamespaces: true
33 | # output: XCAssets+Generated.swift
34 |
35 |
36 | ## Generate constants for your storyboards and XIBs.
37 | ## This one generates 2 output files, one containing the storyboard scenes, and another for the segues.
38 | ## (You can remove the segues entry if you don't use segues in your IB files).
39 | ## For `inputs` we can use "." here (aka "current directory", at least relative to `input_dir` = "MyLib/Sources"),
40 | ## and SwiftGen will recursively find all `*.storyboard` and `*.xib` files in there.
41 | # ib:
42 | # inputs:
43 | # - .
44 | # outputs:
45 | # - templateName: scenes-swift5
46 | # output: IB-Scenes+Generated.swift
47 | # - templateName: segues-swift5
48 | # output: IB-Segues+Generated.swift
49 |
50 |
51 | ## There are other parsers available for you to use depending on your needs, for example:
52 | ## - `fonts` (if you have custom ttf/ttc font files)
53 | ## - `coredata` (for CoreData models)
54 | ## - `json`, `yaml` and `plist` (to parse custom JSON/YAML/Plist files and generate code from their content)
55 | ## …
56 | ##
57 | ## For more info, use `swiftgen config doc` to open the full documentation on GitHub.
58 | ## https://github.com/SwiftGen/SwiftGen/tree/6.4.0/Documentation/
59 |
--------------------------------------------------------------------------------
/Playgrounds/RxSwiftPlayground.playground/Sources/Ultils.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import RxSwift
3 |
4 | public func demo(of description: String, action: () -> Void) {
5 | print("\n——— Demo of:", description, "———")
6 | action()
7 | }
8 |
9 | public func getThreadName() -> String {
10 | if Thread.current.isMainThread {
11 | return "Main Thread"
12 | } else if let name = Thread.current.name {
13 | if name.isEmpty {
14 | return "Unnamed Thread"
15 | }
16 | return name
17 | } else {
18 | return "Unknown Thread"
19 | }
20 | }
21 |
22 | public extension ObservableType {
23 | func dumpObservable() -> Observable {
24 | return self.do(onNext: { element in
25 | print("[Observable] \(element) emitted on \(getThreadName())")
26 | })
27 | }
28 |
29 | func dumpObserver() -> Disposable {
30 | return self.subscribe(onNext: { element in
31 | print("[Observer] \(element) received on \(getThreadName())")
32 | })
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/Playgrounds/RxSwiftPlayground.playground/contents.xcplayground:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/Podfile:
--------------------------------------------------------------------------------
1 | source 'https://cdn.cocoapods.org/'
2 |
3 | workspace './Moments.xcworkspace'
4 | project './Moments/Moments.xcodeproj'
5 |
6 | platform :ios, '14.0'
7 | use_frameworks!
8 |
9 | # ignore all warnings from all dependencies
10 | inhibit_all_warnings!
11 |
12 | def dev_pods
13 | pod 'SwiftLint', '= 0.42.0', configurations: ['Debug']
14 | pod 'SwiftGen', '= 6.4.0', configurations: ['Debug']
15 | end
16 |
17 | def core_pods
18 | pod 'RxSwift', '= 5.1.1'
19 | pod 'RxRelay', '= 5.1.1'
20 | pod 'Alamofire', '= 5.3.0'
21 | end
22 |
23 | def thirdparty_pods
24 | pod 'Firebase/Analytics', '= 7.0.0'
25 | pod 'Firebase/Crashlytics', '= 7.0.0'
26 | pod 'Firebase/RemoteConfig', '= 7.0.0'
27 | pod 'Firebase/Performance', '= 7.0.0'
28 | end
29 |
30 | def ui_pods
31 | pod 'SnapKit', '= 5.0.1'
32 | pod 'Kingfisher', '= 5.15.6'
33 | pod 'RxCocoa', '= 5.1.1'
34 | pod 'RxDataSources', '= 4.0.1'
35 | end
36 |
37 | def internal_pods
38 | pod 'DesignKit', :path => './Frameworks/DesignKit', :inhibit_warnings => false
39 | end
40 |
41 | def test_pods
42 | pod 'Quick', '= 3.0.0'
43 | pod 'Nimble', '= 9.0.0'
44 | pod 'RxTest', '= 5.1.1'
45 | pod 'RxBlocking', '= 5.1.1'
46 | end
47 |
48 | target 'Moments' do
49 | dev_pods
50 | core_pods
51 | thirdparty_pods
52 | ui_pods
53 | internal_pods
54 | end
55 |
56 | target 'MomentsTests' do
57 | core_pods
58 | thirdparty_pods
59 | test_pods
60 | end
61 |
62 | post_install do |installer|
63 | installer.pods_project.targets.each do |target|
64 | target.build_configurations.each do |config|
65 | config.build_settings.delete 'IPHONEOS_DEPLOYMENT_TARGET'
66 | config.build_settings["EXCLUDED_ARCHS[sdk=iphonesimulator*]"] = "arm64"
67 | end
68 | end
69 | end
70 |
71 |
--------------------------------------------------------------------------------
/Podfile.lock:
--------------------------------------------------------------------------------
1 | PODS:
2 | - Alamofire (5.3.0)
3 | - DesignKit (1.0.0)
4 | - Differentiator (4.0.1)
5 | - Firebase/Analytics (7.0.0):
6 | - Firebase/Core
7 | - Firebase/Core (7.0.0):
8 | - Firebase/CoreOnly
9 | - FirebaseAnalytics (= 7.0.0)
10 | - Firebase/CoreOnly (7.0.0):
11 | - FirebaseCore (= 7.0.0)
12 | - Firebase/Crashlytics (7.0.0):
13 | - Firebase/CoreOnly
14 | - FirebaseCrashlytics (~> 7.0.0)
15 | - Firebase/Performance (7.0.0):
16 | - Firebase/CoreOnly
17 | - FirebasePerformance (~> 7.0.0)
18 | - Firebase/RemoteConfig (7.0.0):
19 | - Firebase/CoreOnly
20 | - FirebaseRemoteConfig (~> 7.0.0)
21 | - FirebaseABTesting (7.0.0):
22 | - FirebaseCore (~> 7.0)
23 | - FirebaseAnalytics (7.0.0):
24 | - FirebaseCore (~> 7.0)
25 | - FirebaseInstallations (~> 7.0)
26 | - GoogleAppMeasurement (= 7.0.0)
27 | - GoogleUtilities/AppDelegateSwizzler (~> 7.0)
28 | - GoogleUtilities/MethodSwizzler (~> 7.0)
29 | - GoogleUtilities/Network (~> 7.0)
30 | - "GoogleUtilities/NSData+zlib (~> 7.0)"
31 | - nanopb (~> 2.30906.0)
32 | - FirebaseCore (7.0.0):
33 | - FirebaseCoreDiagnostics (~> 7.0)
34 | - GoogleUtilities/Environment (~> 7.0)
35 | - GoogleUtilities/Logger (~> 7.0)
36 | - FirebaseCoreDiagnostics (7.0.0):
37 | - GoogleDataTransport (~> 8.0)
38 | - GoogleUtilities/Environment (~> 7.0)
39 | - GoogleUtilities/Logger (~> 7.0)
40 | - nanopb (~> 2.30906.0)
41 | - FirebaseCrashlytics (7.0.0):
42 | - FirebaseCore (~> 7.0)
43 | - FirebaseInstallations (~> 7.0)
44 | - GoogleDataTransport (~> 8.0)
45 | - nanopb (~> 2.30906.0)
46 | - PromisesObjC (~> 1.2)
47 | - FirebaseInstallations (7.0.0):
48 | - FirebaseCore (~> 7.0)
49 | - GoogleUtilities/Environment (~> 7.0)
50 | - GoogleUtilities/UserDefaults (~> 7.0)
51 | - PromisesObjC (~> 1.2)
52 | - FirebasePerformance (7.0.1):
53 | - FirebaseCore (~> 7.0)
54 | - FirebaseInstallations (~> 7.0)
55 | - FirebaseRemoteConfig (~> 7.0)
56 | - GoogleDataTransport (~> 8.0)
57 | - GoogleToolboxForMac/Logger (~> 2.1)
58 | - "GoogleToolboxForMac/NSData+zlib (~> 2.1)"
59 | - GoogleUtilities/Environment (~> 7.0)
60 | - GoogleUtilities/ISASwizzler (~> 7.0)
61 | - GoogleUtilities/MethodSwizzler (~> 7.0)
62 | - GTMSessionFetcher/Core (~> 1.1)
63 | - Protobuf (~> 3.12)
64 | - FirebaseRemoteConfig (7.0.0):
65 | - FirebaseABTesting (~> 7.0)
66 | - FirebaseCore (~> 7.0)
67 | - FirebaseInstallations (~> 7.0)
68 | - GoogleUtilities/Environment (~> 7.0)
69 | - "GoogleUtilities/NSData+zlib (~> 7.0)"
70 | - GoogleAppMeasurement (7.0.0):
71 | - GoogleUtilities/AppDelegateSwizzler (~> 7.0)
72 | - GoogleUtilities/MethodSwizzler (~> 7.0)
73 | - GoogleUtilities/Network (~> 7.0)
74 | - "GoogleUtilities/NSData+zlib (~> 7.0)"
75 | - nanopb (~> 2.30906.0)
76 | - GoogleDataTransport (8.0.0):
77 | - nanopb (~> 2.30906.0)
78 | - GoogleToolboxForMac/Defines (2.3.0)
79 | - GoogleToolboxForMac/Logger (2.3.0):
80 | - GoogleToolboxForMac/Defines (= 2.3.0)
81 | - "GoogleToolboxForMac/NSData+zlib (2.3.0)":
82 | - GoogleToolboxForMac/Defines (= 2.3.0)
83 | - GoogleUtilities/AppDelegateSwizzler (7.0.0):
84 | - GoogleUtilities/Environment
85 | - GoogleUtilities/Logger
86 | - GoogleUtilities/Network
87 | - GoogleUtilities/Environment (7.0.0):
88 | - PromisesObjC (~> 1.2)
89 | - GoogleUtilities/ISASwizzler (7.0.0)
90 | - GoogleUtilities/Logger (7.0.0):
91 | - GoogleUtilities/Environment
92 | - GoogleUtilities/MethodSwizzler (7.0.0):
93 | - GoogleUtilities/Logger
94 | - GoogleUtilities/Network (7.0.0):
95 | - GoogleUtilities/Logger
96 | - "GoogleUtilities/NSData+zlib"
97 | - GoogleUtilities/Reachability
98 | - "GoogleUtilities/NSData+zlib (7.0.0)"
99 | - GoogleUtilities/Reachability (7.0.0):
100 | - GoogleUtilities/Logger
101 | - GoogleUtilities/UserDefaults (7.0.0):
102 | - GoogleUtilities/Logger
103 | - GTMSessionFetcher/Core (1.5.0)
104 | - Kingfisher (5.15.6):
105 | - Kingfisher/Core (= 5.15.6)
106 | - Kingfisher/Core (5.15.6)
107 | - nanopb (2.30906.0):
108 | - nanopb/decode (= 2.30906.0)
109 | - nanopb/encode (= 2.30906.0)
110 | - nanopb/decode (2.30906.0)
111 | - nanopb/encode (2.30906.0)
112 | - Nimble (9.0.0)
113 | - PromisesObjC (1.2.11)
114 | - Protobuf (3.13.0)
115 | - Quick (3.0.0)
116 | - RxBlocking (5.1.1):
117 | - RxSwift (~> 5)
118 | - RxCocoa (5.1.1):
119 | - RxRelay (~> 5)
120 | - RxSwift (~> 5)
121 | - RxDataSources (4.0.1):
122 | - Differentiator (~> 4.0)
123 | - RxCocoa (~> 5.0)
124 | - RxSwift (~> 5.0)
125 | - RxRelay (5.1.1):
126 | - RxSwift (~> 5)
127 | - RxSwift (5.1.1)
128 | - RxTest (5.1.1):
129 | - RxSwift (~> 5)
130 | - SnapKit (5.0.1)
131 | - SwiftGen (6.4.0)
132 | - SwiftLint (0.42.0)
133 |
134 | DEPENDENCIES:
135 | - Alamofire (= 5.3.0)
136 | - DesignKit (from `./Frameworks/DesignKit`)
137 | - Firebase/Analytics (= 7.0.0)
138 | - Firebase/Crashlytics (= 7.0.0)
139 | - Firebase/Performance (= 7.0.0)
140 | - Firebase/RemoteConfig (= 7.0.0)
141 | - Kingfisher (= 5.15.6)
142 | - Nimble (= 9.0.0)
143 | - Quick (= 3.0.0)
144 | - RxBlocking (= 5.1.1)
145 | - RxCocoa (= 5.1.1)
146 | - RxDataSources (= 4.0.1)
147 | - RxRelay (= 5.1.1)
148 | - RxSwift (= 5.1.1)
149 | - RxTest (= 5.1.1)
150 | - SnapKit (= 5.0.1)
151 | - SwiftGen (= 6.4.0)
152 | - SwiftLint (= 0.42.0)
153 |
154 | SPEC REPOS:
155 | trunk:
156 | - Alamofire
157 | - Differentiator
158 | - Firebase
159 | - FirebaseABTesting
160 | - FirebaseAnalytics
161 | - FirebaseCore
162 | - FirebaseCoreDiagnostics
163 | - FirebaseCrashlytics
164 | - FirebaseInstallations
165 | - FirebasePerformance
166 | - FirebaseRemoteConfig
167 | - GoogleAppMeasurement
168 | - GoogleDataTransport
169 | - GoogleToolboxForMac
170 | - GoogleUtilities
171 | - GTMSessionFetcher
172 | - Kingfisher
173 | - nanopb
174 | - Nimble
175 | - PromisesObjC
176 | - Protobuf
177 | - Quick
178 | - RxBlocking
179 | - RxCocoa
180 | - RxDataSources
181 | - RxRelay
182 | - RxSwift
183 | - RxTest
184 | - SnapKit
185 | - SwiftGen
186 | - SwiftLint
187 |
188 | EXTERNAL SOURCES:
189 | DesignKit:
190 | :path: "./Frameworks/DesignKit"
191 |
192 | SPEC CHECKSUMS:
193 | Alamofire: 2c792affbdc2f18016e08fdbcacd60aebe1ba593
194 | DesignKit: 2538711c55dd9588c021a1346b8f1a1b96328a8a
195 | Differentiator: 886080237d9f87f322641dedbc5be257061b0602
196 | Firebase: 50be68416f50eb4eb2ecb0e78acab9a051ef95df
197 | FirebaseABTesting: b78ae653b7658b8f1c076eaa21029c936d58f758
198 | FirebaseAnalytics: c1166b7990bae464c6436132510bb718c6680f80
199 | FirebaseCore: cf3122185fce1cf71cedbbc498ea84d2b3e7cb69
200 | FirebaseCoreDiagnostics: 5f4aa04fdb04923693cc704c7ef9158bdf41a48b
201 | FirebaseCrashlytics: bd430b7323e8b49492a93e563e81899d0615f917
202 | FirebaseInstallations: c28d4bcbb5c6884d1a39afbc0bd7fc590e31e9b7
203 | FirebasePerformance: feb172454ef6568c8246d5713b6e65fde9f2e384
204 | FirebaseRemoteConfig: ff8d3542cbd919c9d3851fd544690b8848fc0402
205 | GoogleAppMeasurement: 7790ef975d1d463c8614cd949a847e612edf087a
206 | GoogleDataTransport: 6ce8004a961db1b905740d7be106c61ba7e89c21
207 | GoogleToolboxForMac: 1350d40e86a76f7863928d63bcb0b89c84c521c5
208 | GoogleUtilities: ffb2f4159f2c897c6e8992bd7fbcdef8a300589c
209 | GTMSessionFetcher: b3503b20a988c4e20cc189aa798fd18220133f52
210 | Kingfisher: b3554e7bf6106115b44e8795300bad580ef2fdc7
211 | nanopb: 1bf24dd71191072e120b83dd02d08f3da0d65e53
212 | Nimble: 3b4ec3fd40f1dc178058e0981107721c615643d8
213 | PromisesObjC: 8c196f5a328c2cba3e74624585467a557dcb482f
214 | Protobuf: 3dac39b34a08151c6d949560efe3f86134a3f748
215 | Quick: 6d9559f40647bc4d510103842ef2fdd882d753e2
216 | RxBlocking: 5f700a78cad61ce253ebd37c9a39b5ccc76477b4
217 | RxCocoa: 32065309a38d29b5b0db858819b5bf9ef038b601
218 | RxDataSources: efee07fa4de48477eca0a4611e6d11e2da9c1114
219 | RxRelay: d77f7d771495f43c556cbc43eebd1bb54d01e8e9
220 | RxSwift: 81470a2074fa8780320ea5fe4102807cb7118178
221 | RxTest: 711632d5644dffbeb62c936a521b5b008a1e1faa
222 | SnapKit: 97b92857e3df3a0c71833cce143274bf6ef8e5eb
223 | SwiftGen: 67860cc7c3cfc2ed25b9b74cfd55495fc89f9108
224 | SwiftLint: 4fa9579c63416865179bc416f0a92d55f009600d
225 |
226 | PODFILE CHECKSUM: fba3684ee289286d0d415188646618577132eed8
227 |
228 | COCOAPODS: 1.11.2
229 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # moments-ios
2 |
3 | [](https://travis-ci.com/lagoueduCol/iOS-linyongjian)
4 | [](https://swift.org)
5 | [](https://github.com/lagoueduCol/moments-ios/blob/main/LICENSE)
6 |
7 | ## Environment setup
8 |
9 | Please download Xcode Version 12.2 (12B45b) from [Apple Developer Website](https://developer.apple.com/download/more/) and install rbenv, if you haven't installed rbenv before, please follow [Environment setup](https://github.com/JakeLin/moments-ios/wiki/Environment-setup) to install.
10 |
11 | After that, run the following commands to install all required components and set up the development environment:
12 |
13 | ```shell
14 | $ ./scripts/setup.sh
15 | $ open Moments.xcworkspace
16 | ```
17 |
18 | ### Automation
19 |
20 | If you'd like to run the automation steps on your local machine, make sure your create a file called `local.keys` and put all the keys in as below:
21 |
22 | ```
23 | CI_BUILD_NUMBER=10 # Change it every time when running the build locally
24 | APP_STORE_CONNECT_API_CONTENT=
25 | FIREBASE_API_TOKEN=
26 | GITHUB_API_TOKEN=
27 | MATCH_PASSWORD=
28 | ```
29 |
30 | And run `$ source local.keys` After that, you can follow the steps in [.travis.yml](https://github.com/lagoueduCol/iOS-linyongjian/blob/main/.travis.yml) to run the automations locally
31 |
--------------------------------------------------------------------------------
/fastlane/Pluginfile:
--------------------------------------------------------------------------------
1 | # Autogenerated by fastlane
2 | #
3 | # Ensure this file is checked in to source control!
4 |
5 | gem 'fastlane-plugin-firebase_app_distribution'
6 |
--------------------------------------------------------------------------------
/fastlane/README.md:
--------------------------------------------------------------------------------
1 | fastlane documentation
2 | ================
3 | # Installation
4 |
5 | Make sure you have the latest version of the Xcode command line tools installed:
6 |
7 | ```
8 | xcode-select --install
9 | ```
10 |
11 | Install _fastlane_ using
12 | ```
13 | [sudo] gem install fastlane -NV
14 | ```
15 | or alternatively using `brew install fastlane`
16 |
17 | # Available Actions
18 | ## iOS
19 | ### ios lint_code
20 | ```
21 | fastlane ios lint_code
22 | ```
23 | Lint code
24 | ### ios format_code
25 | ```
26 | fastlane ios format_code
27 | ```
28 | Lint and format code
29 | ### ios sort_files
30 | ```
31 | fastlane ios sort_files
32 | ```
33 | Sort Xcode project files
34 | ### ios prepare_pr
35 | ```
36 | fastlane ios prepare_pr
37 | ```
38 | Prepare for a pull request
39 | ### ios build_dev_app
40 | ```
41 | fastlane ios build_dev_app
42 | ```
43 | Build development app
44 | ### ios tests
45 | ```
46 | fastlane ios tests
47 | ```
48 | Run unit tests
49 | ### ios download_profiles
50 | ```
51 | fastlane ios download_profiles
52 | ```
53 | Download certificates and profiles
54 | ### ios create_new_profiles
55 | ```
56 | fastlane ios create_new_profiles
57 | ```
58 | Create all new provisioning profiles managed by fastlane match
59 | ### ios nuke_profiles
60 | ```
61 | fastlane ios nuke_profiles
62 | ```
63 | Nuke all provisioning profiles managed by fastlane match
64 | ### ios add_device
65 | ```
66 | fastlane ios add_device
67 | ```
68 | Add a new device to provisioning profile
69 | ### ios archive_internal
70 | ```
71 | fastlane ios archive_internal
72 | ```
73 | Creates an archive of the Internal app for testing
74 | ### ios archive_appstore
75 | ```
76 | fastlane ios archive_appstore
77 | ```
78 | Creates an archive of the Production app with Appstore distribution
79 | ### ios upload_symbols_to_crashlytics_internal
80 | ```
81 | fastlane ios upload_symbols_to_crashlytics_internal
82 | ```
83 | Upload symbols to Crashlytics for Internal app
84 | ### ios upload_symbols_to_crashlytics_appstore
85 | ```
86 | fastlane ios upload_symbols_to_crashlytics_appstore
87 | ```
88 | Upload symbols to Crashlytics for Production app
89 | ### ios deploy_internal
90 | ```
91 | fastlane ios deploy_internal
92 | ```
93 | Deploy the Internal app to Firebase Distribution
94 | ### ios deploy_appstore
95 | ```
96 | fastlane ios deploy_appstore
97 | ```
98 | Deploy the Production app to TestFlight and App Store
99 |
100 | ----
101 |
102 | This README.md is auto-generated and will be re-generated every time [fastlane](https://fastlane.tools) is run.
103 | More information about fastlane can be found on [fastlane.tools](https://fastlane.tools).
104 | The documentation of fastlane can be found on [docs.fastlane.tools](https://docs.fastlane.tools).
105 |
--------------------------------------------------------------------------------
/scripts/export_env.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | # Call `source ./scripts/export_env.sh` to execute
3 |
4 | # export the environment variables
5 | export $(grep -v '^#' ./local.keys | sed 's/#.*//')
6 |
7 | export MATCH_GIT_BASIC_AUTHORIZATION=$(echo -n momentsci:$GITHUB_API_TOKEN | base64)
--------------------------------------------------------------------------------
/scripts/increment_build_number.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | VERSION_XCCONFIG="Moments/Moments/Configurations/BaseTarget.xcconfig"
4 | SED_CMD="s/\\(PRODUCT_VERSION_SUFFIX=\\).*/\\1${CI_BUILD_NUMBER}/" # Make sure setting this environment variable before call script.
5 |
6 | sed -e ${SED_CMD} -i.bak ${VERSION_XCCONFIG}
7 | rm -f ${VERSION_XCCONFIG}.bak
8 |
--------------------------------------------------------------------------------
/scripts/setup.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 |
4 | # Install ruby using rbenv
5 | ruby_version=`cat .ruby-version`
6 | if [[ ! -d "$HOME/.rbenv/versions/$ruby_version" ]]; then
7 | rbenv install $ruby_version;
8 | rbenv init
9 | fi
10 |
11 | # Install bunlder
12 | gem install bundler
13 |
14 | # Install all gems
15 | bundle install
16 |
17 | # Install all pods
18 | bundle exec pod install
19 |
20 | # Create alias
21 | . ~/.zshrc && [ `alias | grep "pod" | wc -l` = 0 ] && echo 'alias pod="bundle exec pod"' >> $HOME/.zshrc && . ~/.zshrc
22 |
--------------------------------------------------------------------------------
/scripts/sort-Xcode-project-file.pl:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env perl
2 |
3 | # Copyright (C) 2007, 2008, 2009, 2010 Apple Inc. All rights reserved.
4 | #
5 | # Redistribution and use in source and binary forms, with or without
6 | # modification, are permitted provided that the following conditions
7 | # are met:
8 | #
9 | # 1. Redistributions of source code must retain the above copyright
10 | # notice, this list of conditions and the following disclaimer.
11 | # 2. Redistributions in binary form must reproduce the above copyright
12 | # notice, this list of conditions and the following disclaimer in the
13 | # documentation and/or other materials provided with the distribution.
14 | # 3. Neither the name of Apple Inc. ("Apple") nor the names of
15 | # its contributors may be used to endorse or promote products derived
16 | # from this software without specific prior written permission.
17 | #
18 | # THIS SOFTWARE IS PROVIDED BY APPLE AND ITS CONTRIBUTORS "AS IS" AND ANY
19 | # EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
20 | # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21 | # DISCLAIMED. IN NO EVENT SHALL APPLE OR ITS CONTRIBUTORS BE LIABLE FOR ANY
22 | # DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
23 | # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
24 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
25 | # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
27 | # THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28 |
29 | # Script to sort "children" and "files" sections in Xcode project.pbxproj files
30 |
31 | use strict;
32 | use warnings;
33 |
34 | use File::Basename;
35 | use File::Spec;
36 | use File::Temp qw(tempfile);
37 | use Getopt::Long;
38 |
39 | sub sortChildrenByFileName($$);
40 | sub sortFilesByFileName($$);
41 |
42 | # Files (or products) without extensions
43 | my %isFile = map { $_ => 1 } qw(
44 | create_hash_table
45 | jsc
46 | minidom
47 | testapi
48 | testjsglue
49 | );
50 |
51 | my $printWarnings = 1;
52 | my $showHelp;
53 |
54 | my $getOptionsResult = GetOptions(
55 | 'h|help' => \$showHelp,
56 | 'w|warnings!' => \$printWarnings,
57 | );
58 |
59 | if (scalar(@ARGV) == 0 && !$showHelp) {
60 | print STDERR "ERROR: No Xcode project files (project.pbxproj) listed on command-line.\n";
61 | undef $getOptionsResult;
62 | }
63 |
64 | if (!$getOptionsResult || $showHelp) {
65 | print STDERR <<__END__;
66 | Usage: @{[ basename($0) ]} [options] path/to/project.pbxproj [path/to/project.pbxproj ...]
67 | -h|--help show this help message
68 | -w|--[no-]warnings show or suppress warnings (default: show warnings)
69 | __END__
70 | exit 1;
71 | }
72 |
73 | for my $projectFile (@ARGV) {
74 | if (basename($projectFile) =~ /\.xcodeproj$/) {
75 | $projectFile = File::Spec->catfile($projectFile, "project.pbxproj");
76 | }
77 |
78 | if (basename($projectFile) ne "project.pbxproj") {
79 | print STDERR "WARNING: Not an Xcode project file: $projectFile\n" if $printWarnings;
80 | next;
81 | }
82 |
83 | # Grab the mainGroup for the project file.
84 | my $mainGroup = "";
85 | open(IN, "< $projectFile") || die "Could not open $projectFile: $!";
86 | while (my $line = ) {
87 | $mainGroup = $2 if $line =~ m#^(\s*)mainGroup = ([0-9A-F]{24}( /\* .+ \*/)?);$#;
88 | }
89 | close(IN);
90 |
91 | my ($OUT, $tempFileName) = tempfile(
92 | basename($projectFile) . "-XXXXXXXX",
93 | DIR => dirname($projectFile),
94 | UNLINK => 0,
95 | );
96 |
97 | # Clean up temp file in case of die()
98 | $SIG{__DIE__} = sub {
99 | close(IN);
100 | close($OUT);
101 | unlink($tempFileName);
102 | };
103 |
104 | my @lastTwo = ();
105 | open(IN, "< $projectFile") || die "Could not open $projectFile: $!";
106 | while (my $line = ) {
107 | if ($line =~ /^(\s*)files = \(\s*$/) {
108 | print $OUT $line;
109 | my $endMarker = $1 . ");";
110 | my @files;
111 | while (my $fileLine = ) {
112 | if ($fileLine =~ /^\Q$endMarker\E\s*$/) {
113 | $endMarker = $fileLine;
114 | last;
115 | }
116 | push @files, $fileLine;
117 | }
118 | print $OUT sort sortFilesByFileName @files;
119 | print $OUT $endMarker;
120 | } elsif ($line =~ /^(\s*)children = \(\s*$/) {
121 | print $OUT $line;
122 | my $endMarker = $1 . ");";
123 | my @children;
124 | while (my $childLine = ) {
125 | if ($childLine =~ /^\Q$endMarker\E\s*$/) {
126 | $endMarker = $childLine;
127 | last;
128 | }
129 | push @children, $childLine;
130 | }
131 | if ($lastTwo[0] =~ m#^\s+\Q$mainGroup\E = \{$#) {
132 | # Don't sort mainGroup
133 | print $OUT @children;
134 | } else {
135 | print $OUT sort sortChildrenByFileName @children;
136 | }
137 | print $OUT $endMarker;
138 | } else {
139 | print $OUT $line;
140 | }
141 |
142 | push @lastTwo, $line;
143 | shift @lastTwo if scalar(@lastTwo) > 2;
144 | }
145 | close(IN);
146 | close($OUT);
147 |
148 | unlink($projectFile) || die "Could not delete $projectFile: $!";
149 | rename($tempFileName, $projectFile) || die "Could not rename $tempFileName to $projectFile: $!";
150 | }
151 |
152 | exit 0;
153 |
154 | sub sortChildrenByFileName($$)
155 | {
156 | my ($a, $b) = @_;
157 | my $aFileName = $1 if $a =~ /^\s*[A-Z0-9]{24} \/\* (.+) \*\/,$/;
158 | my $bFileName = $1 if $b =~ /^\s*[A-Z0-9]{24} \/\* (.+) \*\/,$/;
159 | my $aSuffix = $1 if $aFileName =~ m/\.([^.]+)$/;
160 | my $bSuffix = $1 if $bFileName =~ m/\.([^.]+)$/;
161 | if ((!$aSuffix && !$isFile{$aFileName} && $bSuffix) || ($aSuffix && !$bSuffix && !$isFile{$bFileName})) {
162 | return !$aSuffix ? -1 : 1;
163 | }
164 | if ($aFileName =~ /^UnifiedSource\d+/ && $bFileName =~ /^UnifiedSource\d+/) {
165 | my $aNumber = $1 if $aFileName =~ /^UnifiedSource(\d+)/;
166 | my $bNumber = $1 if $bFileName =~ /^UnifiedSource(\d+)/;
167 | return $aNumber <=> $bNumber;
168 | }
169 | return lc($aFileName) cmp lc($bFileName);
170 | }
171 |
172 | sub sortFilesByFileName($$)
173 | {
174 | my ($a, $b) = @_;
175 | my $aFileName = $1 if $a =~ /^\s*[A-Z0-9]{24} \/\* (.+) in /;
176 | my $bFileName = $1 if $b =~ /^\s*[A-Z0-9]{24} \/\* (.+) in /;
177 | if ($aFileName =~ /^UnifiedSource\d+/ && $bFileName =~ /^UnifiedSource\d+/) {
178 | my $aNumber = $1 if $aFileName =~ /^UnifiedSource(\d+)/;
179 | my $bNumber = $1 if $bFileName =~ /^UnifiedSource(\d+)/;
180 | return $aNumber <=> $bNumber;
181 | }
182 | return lc($aFileName) cmp lc($bFileName);
183 | }
184 |
--------------------------------------------------------------------------------