├── .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 | [![Build Status](https://travis-ci.com/lagoueduCol/iOS-linyongjian.svg?branch=main)](https://travis-ci.com/lagoueduCol/iOS-linyongjian) 4 | [![Language](https://img.shields.io/badge/language-Swift%205.3-orange.svg)](https://swift.org) 5 | [![License](https://img.shields.io/github/license/lagoueduCol/moments-ios.svg?style=flat)](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 | --------------------------------------------------------------------------------