From 3f6e57eb02d1acef70fc546acb106ec656569e3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Thu, 11 Apr 2024 13:49:27 +0200 Subject: [PATCH 1/2] chore: migrate CI to GitHub Actions (#33) --- .circleci/config.yml | 24 ---- .github/workflows/spm-test.yml | 26 ++++ Gemfile | 3 - Gemfile.lock | 219 --------------------------------- fastlane/Appfile | 6 - fastlane/Fastfile | 60 --------- fastlane/README.md | 48 -------- 7 files changed, 26 insertions(+), 360 deletions(-) delete mode 100644 .circleci/config.yml create mode 100644 .github/workflows/spm-test.yml delete mode 100644 Gemfile delete mode 100644 Gemfile.lock delete mode 100644 fastlane/Appfile delete mode 100644 fastlane/Fastfile delete mode 100644 fastlane/README.md diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index c4a2c17..0000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,24 +0,0 @@ -version: 2.1 - -orbs: - ruby: circleci/ruby@1.2.0 - -jobs: - build: - macos: - xcode: "13.1.0" - working_directory: /Users/distiller/project - environment: - FL_OUTPUT_DIR: output - FASTLANE_LANE: tests - shell: /bin/bash --login -o pipefail - steps: - - checkout - - ruby/install-deps - - run: - name: fastlane - command: bundle exec fastlane $FASTLANE_LANE - - store_artifacts: - path: output - - store_test_results: - path: output/scan diff --git a/.github/workflows/spm-test.yml b/.github/workflows/spm-test.yml new file mode 100644 index 0000000..0e7cded --- /dev/null +++ b/.github/workflows/spm-test.yml @@ -0,0 +1,26 @@ + +name: MagicBell Swift Tests + +on: + pull_request: + +jobs: + build: + runs-on: macos-latest + steps: + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + - name: Checkout repository + uses: actions/checkout@v4 + - name: SPM Cache + uses: actions/cache@v3 + with: + path: .build + key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }} + restore-keys: | + ${{ runner.os }}-spm- + - name: Build + run: swift build --build-tests + - name: Test + run: swift test --skip-build \ No newline at end of file diff --git a/Gemfile b/Gemfile deleted file mode 100644 index 7a118b4..0000000 --- a/Gemfile +++ /dev/null @@ -1,3 +0,0 @@ -source "https://rubygems.org" - -gem "fastlane" diff --git a/Gemfile.lock b/Gemfile.lock deleted file mode 100644 index aa387e3..0000000 --- a/Gemfile.lock +++ /dev/null @@ -1,219 +0,0 @@ -GEM - remote: https://rubygems.org/ - specs: - CFPropertyList (3.0.5) - rexml - addressable (2.8.0) - public_suffix (>= 2.0.2, < 5.0) - artifactory (3.0.15) - atomos (0.1.3) - aws-eventstream (1.2.0) - aws-partitions (1.550.0) - aws-sdk-core (3.125.5) - aws-eventstream (~> 1, >= 1.0.2) - aws-partitions (~> 1, >= 1.525.0) - aws-sigv4 (~> 1.1) - jmespath (~> 1.0) - aws-sdk-kms (1.53.0) - aws-sdk-core (~> 3, >= 3.125.0) - aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.111.3) - aws-sdk-core (~> 3, >= 3.125.0) - aws-sdk-kms (~> 1) - aws-sigv4 (~> 1.4) - aws-sigv4 (1.4.0) - aws-eventstream (~> 1, >= 1.0.2) - babosa (1.0.4) - claide (1.1.0) - colored (1.2) - colored2 (3.1.2) - commander (4.6.0) - highline (~> 2.0.0) - declarative (0.0.20) - digest-crc (0.6.4) - rake (>= 12.0.0, < 14.0.0) - domain_name (0.5.20190701) - unf (>= 0.0.5, < 1.0.0) - dotenv (2.7.6) - emoji_regex (3.2.3) - excon (0.90.0) - faraday (1.9.3) - faraday-em_http (~> 1.0) - faraday-em_synchrony (~> 1.0) - faraday-excon (~> 1.1) - faraday-httpclient (~> 1.0) - faraday-multipart (~> 1.0) - faraday-net_http (~> 1.0) - faraday-net_http_persistent (~> 1.0) - faraday-patron (~> 1.0) - faraday-rack (~> 1.0) - faraday-retry (~> 1.0) - ruby2_keywords (>= 0.0.4) - faraday-cookie_jar (0.0.7) - faraday (>= 0.8.0) - http-cookie (~> 1.0.0) - faraday-em_http (1.0.0) - faraday-em_synchrony (1.0.0) - faraday-excon (1.1.0) - faraday-httpclient (1.0.1) - faraday-multipart (1.0.3) - multipart-post (>= 1.2, < 3) - faraday-net_http (1.0.1) - faraday-net_http_persistent (1.2.0) - faraday-patron (1.0.0) - faraday-rack (1.0.0) - faraday-retry (1.0.3) - faraday_middleware (1.2.0) - faraday (~> 1.0) - fastimage (2.2.6) - fastlane (2.203.0) - CFPropertyList (>= 2.3, < 4.0.0) - addressable (>= 2.8, < 3.0.0) - artifactory (~> 3.0) - aws-sdk-s3 (~> 1.0) - babosa (>= 1.0.3, < 2.0.0) - bundler (>= 1.12.0, < 3.0.0) - colored - commander (~> 4.6) - dotenv (>= 2.1.1, < 3.0.0) - emoji_regex (>= 0.1, < 4.0) - excon (>= 0.71.0, < 1.0.0) - faraday (~> 1.0) - faraday-cookie_jar (~> 0.0.6) - faraday_middleware (~> 1.0) - fastimage (>= 2.1.0, < 3.0.0) - gh_inspector (>= 1.1.2, < 2.0.0) - google-apis-androidpublisher_v3 (~> 0.3) - google-apis-playcustomapp_v1 (~> 0.1) - google-cloud-storage (~> 1.31) - highline (~> 2.0) - json (< 3.0.0) - jwt (>= 2.1.0, < 3) - mini_magick (>= 4.9.4, < 5.0.0) - multipart-post (~> 2.0.0) - naturally (~> 2.2) - optparse (~> 0.1.1) - plist (>= 3.1.0, < 4.0.0) - rubyzip (>= 2.0.0, < 3.0.0) - security (= 0.1.3) - simctl (~> 1.6.3) - terminal-notifier (>= 2.0.0, < 3.0.0) - terminal-table (>= 1.4.5, < 2.0.0) - tty-screen (>= 0.6.3, < 1.0.0) - tty-spinner (>= 0.8.0, < 1.0.0) - word_wrap (~> 1.0.0) - xcodeproj (>= 1.13.0, < 2.0.0) - xcpretty (~> 0.3.0) - xcpretty-travis-formatter (>= 0.0.3) - gh_inspector (1.1.3) - google-apis-androidpublisher_v3 (0.15.0) - google-apis-core (>= 0.4, < 2.a) - google-apis-core (0.4.2) - addressable (~> 2.5, >= 2.5.1) - googleauth (>= 0.16.2, < 2.a) - httpclient (>= 2.8.1, < 3.a) - mini_mime (~> 1.0) - representable (~> 3.0) - retriable (>= 2.0, < 4.a) - rexml - webrick - google-apis-iamcredentials_v1 (0.10.0) - google-apis-core (>= 0.4, < 2.a) - google-apis-playcustomapp_v1 (0.7.0) - google-apis-core (>= 0.4, < 2.a) - google-apis-storage_v1 (0.11.0) - google-apis-core (>= 0.4, < 2.a) - google-cloud-core (1.6.0) - google-cloud-env (~> 1.0) - google-cloud-errors (~> 1.0) - google-cloud-env (1.5.0) - faraday (>= 0.17.3, < 2.0) - google-cloud-errors (1.2.0) - google-cloud-storage (1.36.0) - addressable (~> 2.8) - digest-crc (~> 0.4) - google-apis-iamcredentials_v1 (~> 0.1) - google-apis-storage_v1 (~> 0.1) - google-cloud-core (~> 1.6) - googleauth (>= 0.16.2, < 2.a) - mini_mime (~> 1.0) - googleauth (1.1.0) - faraday (>= 0.17.3, < 2.0) - jwt (>= 1.4, < 3.0) - memoist (~> 0.16) - multi_json (~> 1.11) - os (>= 0.9, < 2.0) - signet (>= 0.16, < 2.a) - highline (2.0.3) - http-cookie (1.0.4) - domain_name (~> 0.5) - httpclient (2.8.3) - jmespath (1.5.0) - json (2.6.1) - jwt (2.3.0) - memoist (0.16.2) - mini_magick (4.11.0) - mini_mime (1.1.2) - multi_json (1.15.0) - multipart-post (2.0.0) - nanaimo (0.3.0) - naturally (2.2.1) - optparse (0.1.1) - os (1.1.4) - plist (3.6.0) - public_suffix (4.0.6) - rake (13.0.6) - representable (3.1.1) - declarative (< 0.1.0) - trailblazer-option (>= 0.1.1, < 0.2.0) - uber (< 0.2.0) - retriable (3.1.2) - rexml (3.2.5) - rouge (2.0.7) - ruby2_keywords (0.0.5) - rubyzip (2.3.2) - security (0.1.3) - signet (0.16.0) - addressable (~> 2.8) - faraday (>= 0.17.3, < 2.0) - jwt (>= 1.5, < 3.0) - multi_json (~> 1.10) - simctl (1.6.8) - CFPropertyList - naturally - terminal-notifier (2.0.0) - terminal-table (1.8.0) - unicode-display_width (~> 1.1, >= 1.1.1) - trailblazer-option (0.1.2) - tty-cursor (0.7.1) - tty-screen (0.8.1) - tty-spinner (0.9.3) - tty-cursor (~> 0.7) - uber (0.1.0) - unf (0.1.4) - unf_ext - unf_ext (0.0.8) - unicode-display_width (1.8.0) - webrick (1.7.0) - word_wrap (1.0.0) - xcodeproj (1.21.0) - CFPropertyList (>= 2.3.3, < 4.0) - atomos (~> 0.1.3) - claide (>= 1.0.2, < 2.0) - colored2 (~> 3.1) - nanaimo (~> 0.3.0) - rexml (~> 3.2.4) - xcpretty (0.3.0) - rouge (~> 2.0.7) - xcpretty-travis-formatter (1.0.1) - xcpretty (~> 0.2, >= 0.0.7) - -PLATFORMS - universal-darwin-21 - x86_64-darwin-20 - -DEPENDENCIES - fastlane - -BUNDLED WITH - 2.3.0 diff --git a/fastlane/Appfile b/fastlane/Appfile deleted file mode 100644 index 1803063..0000000 --- a/fastlane/Appfile +++ /dev/null @@ -1,6 +0,0 @@ -# app_identifier("[[APP_IDENTIFIER]]") # The bundle identifier of your app -# apple_id("[[APPLE_ID]]") # Your Apple email address - - -# For more information about the Appfile, see: -# https://docs.fastlane.tools/advanced/#appfile diff --git a/fastlane/Fastfile b/fastlane/Fastfile deleted file mode 100644 index b33b08d..0000000 --- a/fastlane/Fastfile +++ /dev/null @@ -1,60 +0,0 @@ -# This file contains the fastlane.tools configuration -# You can find the documentation at https://docs.fastlane.tools -# -# For a list of all available actions, check out -# -# https://docs.fastlane.tools/actions -# -# For a list of all available plugins, check out -# -# https://docs.fastlane.tools/plugins/available-plugins -# - -# Uncomment the line if you want fastlane to automatically update itself -update_fastlane - -default_platform(:ios) - -platform :ios do - before_all do - setup_circle_ci - end - - desc "Resolves the dependences needed in MagicBell.xcodeproj" - lane :update do - carthage( - command: "update", - no_skip_current: false, - use_xcframeworks: true, - use_binaries: false, - ) - end - - desc "Verifies the build process of MagicBell deployment" - lane :build do - carthage( - command: "build", - no_skip_current: false, - use_xcframeworks: true, - use_binaries: false, - ) - end - - desc "Running MagicBell Tests in iPhone 13 Simulator" - lane :tests do - carthage( - command: "update", - no_skip_current: false, - platform: "iOS", - use_xcframeworks: true, - use_binaries: false, - ) - - run_tests( - project: "MagicBell.xcodeproj", - scheme: "MagicBellTests", - devices: ["iPhone 13"] - ) - end -end - diff --git a/fastlane/README.md b/fastlane/README.md deleted file mode 100644 index e2a7b4d..0000000 --- a/fastlane/README.md +++ /dev/null @@ -1,48 +0,0 @@ -fastlane documentation ----- - -# Installation - -Make sure you have the latest version of the Xcode command line tools installed: - -```sh -xcode-select --install -``` - -For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane) - -# Available Actions - -## iOS - -### ios update - -```sh -[bundle exec] fastlane ios update -``` - -Resolves the dependences needed in MagicBell.xcodeproj - -### ios build - -```sh -[bundle exec] fastlane ios build -``` - -Verifies the build process of MagicBell deployment - -### ios tests - -```sh -[bundle exec] fastlane ios tests -``` - -Running MagicBell Tests in iPhone 13 Simulator - ----- - -This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. - -More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools). - -The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools). From 1da2cbbd381b58252da6c46394411b5970d7175d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Thu, 11 Apr 2024 13:58:48 +0200 Subject: [PATCH 2/2] feat: add function to update channel in notifications preferences (#34) --- Example/Example.xcodeproj/project.pbxproj | 8 + Example/Example/AppDelegate.swift | 2 +- Example/Example/Base.lproj/Main.storyboard | 262 ++++++++++++------ Example/Example/SceneDelegate.swift | 9 +- .../UIKit/MagicBellStoreViewController.swift | 44 ++- .../NotificationChannelsViewController.swift | 48 ++++ ...otificationPreferencesViewController.swift | 88 ++++++ Example/Podfile | 1 + README.md | 6 + .../Data/NotificationPreferencesEntity.swift | 14 + .../NotificationPreferencesDirector.swift | 60 +++- .../NotificationPreferencesEntityTests.swift | 30 +- 12 files changed, 448 insertions(+), 124 deletions(-) create mode 100644 Example/Example/UIKit/NotificationChannelsViewController.swift create mode 100644 Example/Example/UIKit/NotificationPreferencesViewController.swift diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj index ad564e0..3f226a7 100644 --- a/Example/Example.xcodeproj/project.pbxproj +++ b/Example/Example.xcodeproj/project.pbxproj @@ -15,6 +15,8 @@ 59EF9A9B2744700700FB2378 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 59EF9A992744700700FB2378 /* Main.storyboard */; }; 59EF9A9D2744700800FB2378 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 59EF9A9C2744700800FB2378 /* Assets.xcassets */; }; 59EF9AA02744700800FB2378 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 59EF9A9E2744700800FB2378 /* LaunchScreen.storyboard */; }; + 942497092BC1F76A006A3D3A /* NotificationPreferencesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942497082BC1F76A006A3D3A /* NotificationPreferencesViewController.swift */; }; + 94C074CE2BC212C5001615CF /* NotificationChannelsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94C074CD2BC212C5001615CF /* NotificationChannelsViewController.swift */; }; D2DC15F62758C68000282C27 /* UIColorExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2DC15F52758C68000282C27 /* UIColorExtensions.swift */; }; D2DC15F82758CC0B00282C27 /* MagicBellStoreCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2DC15F72758CC0B00282C27 /* MagicBellStoreCell.swift */; }; D2E4B088277B5EA500E9E98F /* MagicBellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2E4B087277B5E8A00E9E98F /* MagicBellView.swift */; }; @@ -35,6 +37,8 @@ 59EF9A9F2744700800FB2378 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 59EF9AA12744700800FB2378 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 911386F4690B2C7C97254425 /* Pods-Example.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Example.release.xcconfig"; path = "Target Support Files/Pods-Example/Pods-Example.release.xcconfig"; sourceTree = ""; }; + 942497082BC1F76A006A3D3A /* NotificationPreferencesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPreferencesViewController.swift; sourceTree = ""; }; + 94C074CD2BC212C5001615CF /* NotificationChannelsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationChannelsViewController.swift; sourceTree = ""; }; D2DC15F52758C68000282C27 /* UIColorExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIColorExtensions.swift; sourceTree = ""; }; D2DC15F72758CC0B00282C27 /* MagicBellStoreCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MagicBellStoreCell.swift; sourceTree = ""; }; D2E4B087277B5E8A00E9E98F /* MagicBellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MagicBellView.swift; sourceTree = ""; }; @@ -102,6 +106,8 @@ 599A9206275E702600656E32 /* BadgeBarButtonItem.swift */, 59EF9A972744700700FB2378 /* MagicBellStoreViewController.swift */, D2DC15F72758CC0B00282C27 /* MagicBellStoreCell.swift */, + 942497082BC1F76A006A3D3A /* NotificationPreferencesViewController.swift */, + 94C074CD2BC212C5001615CF /* NotificationChannelsViewController.swift */, ); path = UIKit; sourceTree = ""; @@ -271,9 +277,11 @@ D2DC15F82758CC0B00282C27 /* MagicBellStoreCell.swift in Sources */, 59EF9A942744700700FB2378 /* AppDelegate.swift in Sources */, D2E4B08B277B5EDF00E9E98F /* Appearance.swift in Sources */, + 942497092BC1F76A006A3D3A /* NotificationPreferencesViewController.swift in Sources */, D2E4B088277B5EA500E9E98F /* MagicBellView.swift in Sources */, 59EF9A962744700700FB2378 /* SceneDelegate.swift in Sources */, 599A9207275E702600656E32 /* BadgeBarButtonItem.swift in Sources */, + 94C074CE2BC212C5001615CF /* NotificationChannelsViewController.swift in Sources */, D2E4B08D277B619F00E9E98F /* UIHostingViewController.swift in Sources */, D2DC15F62758C68000282C27 /* UIColorExtensions.swift in Sources */, ); diff --git a/Example/Example/AppDelegate.swift b/Example/Example/AppDelegate.swift index 3028924..279b1b6 100644 --- a/Example/Example/AppDelegate.swift +++ b/Example/Example/AppDelegate.swift @@ -18,7 +18,7 @@ import UserNotifications extension MagicBellClient { /// Application global instance of MagicBellClient static var shared = MagicBellClient( - apiKey: "34ed17a8482e44c765d9e163015a8d586f0b3383", + apiKey: "d75bb407731e0be07547ad7d2f1c3aae3ebc1ec1", logLevel: .debug ) } diff --git a/Example/Example/Base.lproj/Main.storyboard b/Example/Example/Base.lproj/Main.storyboard index 464bd97..cb7390d 100644 --- a/Example/Example/Base.lproj/Main.storyboard +++ b/Example/Example/Base.lproj/Main.storyboard @@ -1,110 +1,194 @@ - + - - + - + - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + - - + + - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + - + diff --git a/Example/Example/SceneDelegate.swift b/Example/Example/SceneDelegate.swift index 06b7193..0824182 100644 --- a/Example/Example/SceneDelegate.swift +++ b/Example/Example/SceneDelegate.swift @@ -44,16 +44,19 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { window = UIWindow(windowScene: scene) // Defining the user to test - let user = MagicBellClient.shared.connectUser(email: "richard@example.com") + let user = MagicBellClient.shared.connectUser(email: "hi@ullrich.is") switch style { case .uiKit: let storyboard = UIStoryboard(name: "Main", bundle: nil) - guard let viewController = storyboard.instantiateInitialViewController() as? MagicBellStoreViewController else { + guard let navController = storyboard.instantiateInitialViewController() as? UINavigationController else { + fatalError("Invalid Storyboard") + } + guard let viewController = navController.topViewController as? MagicBellStoreViewController else { fatalError("Invalid Storyboard") } viewController.user = user - window?.rootViewController = viewController + window?.rootViewController = navController case .swiftUI: let store = user.store.build() window?.rootViewController = HostingController(rootView: NavigationView { diff --git a/Example/Example/UIKit/MagicBellStoreViewController.swift b/Example/Example/UIKit/MagicBellStoreViewController.swift index e2c57ea..65e69fe 100644 --- a/Example/Example/UIKit/MagicBellStoreViewController.swift +++ b/Example/Example/UIKit/MagicBellStoreViewController.swift @@ -15,17 +15,12 @@ import UIKit import MagicBell // swiftlint:disable type_body_length -class MagicBellStoreViewController: UIViewController, - UINavigationBarDelegate, - UITableViewDelegate, - UITableViewDataSource, - NotificationStoreContentObserver, - NotificationStoreCountObserver { +class MagicBellStoreViewController: UITableViewController, + NotificationStoreContentObserver, + NotificationStoreCountObserver { private var isLoadingNextPage = false - @IBOutlet weak var navigationBar: UINavigationBar! - @IBOutlet weak var tableView: UITableView! @IBOutlet weak var magicBellStoreItem: BadgeBarButtonItem! // swiftlint:disable implicitly_unwrapped_optional @@ -40,16 +35,11 @@ class MagicBellStoreViewController: UIViewController, private var observer: AnyObject? - override var title: String? { - didSet { navigationBar.topItem?.title = title } - } - override func viewDidLoad() { super.viewDidLoad() self.title = "Notifications" - - navigationBar.topItem?.title = self.title + self.navigationItem.backButtonTitle = "Notifications" let refreshControl = UIRefreshControl() refreshControl.addTarget(self, action: #selector(refreshAction(sender:)), for: .valueChanged) @@ -60,6 +50,14 @@ class MagicBellStoreViewController: UIViewController, super.viewWillAppear(animated) reloadStore() } + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + if segue.identifier == "preferences" { + if let destinationVC = segue.destination as? NotificationPreferencesViewController { + destinationVC.user = user + } + } + } // swiftlint:disable empty_count private func reloadStore() { @@ -135,6 +133,10 @@ class MagicBellStoreViewController: UIViewController, alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil)) self.present(alert, animated: true, completion: nil) }) + + alert.addAction(UIAlertAction(title: "Notification Preferences", style: .default) { action in + self.performSegue(withIdentifier: "preferences", sender: action) + }) alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil)) @@ -178,19 +180,13 @@ class MagicBellStoreViewController: UIViewController, present(alert, animated: true, completion: nil) } - // MARK: UINavigationBarDelegate - - func position(for bar: UIBarPositioning) -> UIBarPosition { - .topAttached - } - // MARK: UITableViewDataSource - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return store.count } - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { guard let cell = tableView.dequeueReusableCell(withIdentifier: "MagicBellStoreCell", for: indexPath) as? MagicBellStoreCell else { fatalError("Couldn't dequeue a MagicBellStoreCell") } @@ -221,7 +217,7 @@ class MagicBellStoreViewController: UIViewController, // MARK: UITableViewDelegate // swiftlint:disable cyclomatic_complexity - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) let notification = store[indexPath.row] @@ -299,7 +295,7 @@ class MagicBellStoreViewController: UIViewController, present(alert, animated: true, completion: nil) } - func scrollViewDidScroll(_ scrollView: UIScrollView) { + override func scrollViewDidScroll(_ scrollView: UIScrollView) { if !isLoadingNextPage && (scrollView.contentOffset.y > scrollView.contentSize.height - scrollView.bounds.size.height - 200) && store.hasNextPage { isLoadingNextPage = true diff --git a/Example/Example/UIKit/NotificationChannelsViewController.swift b/Example/Example/UIKit/NotificationChannelsViewController.swift new file mode 100644 index 0000000..9857260 --- /dev/null +++ b/Example/Example/UIKit/NotificationChannelsViewController.swift @@ -0,0 +1,48 @@ +// +// NotificationChannelsViewController.swift +// Example +// +// Created by Ullrich Schäfer on 07.04.24. +// + +import Foundation +import UIKit +import MagicBell + +protocol NotificationChannelsViewControllerDelegate : AnyObject { + func updateChannel(_ sender: NotificationChannelsViewController, categorySlug: String, channelSlug: String, enabled: Bool) +} + +class NotificationChannelsViewController: UITableViewController { + // swiftlint:disable implicitly_unwrapped_optional + var category: MagicBell.Category! { + didSet { + self.tableView.reloadData() + } + } + + weak var delegate: NotificationChannelsViewControllerDelegate? + + // MARK: - TableView + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return category?.channels.count ?? 0 + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let reuseIdentifier = "cell" + guard let cell = tableView.dequeueReusableCell(withIdentifier: reuseIdentifier) else { + fatalError("Could not dequeue table view cell with identifier \(reuseIdentifier)") + } + guard let channel = category?.channels[indexPath.row] else { return cell } + cell.textLabel?.text = channel.label + cell.detailTextLabel?.text = "\(channel.enabled)" + return cell + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + + let channel = self.category.channels[indexPath.row] + self.delegate?.updateChannel(self, categorySlug: category.slug, channelSlug: channel.slug, enabled: !channel.enabled) + } +} diff --git a/Example/Example/UIKit/NotificationPreferencesViewController.swift b/Example/Example/UIKit/NotificationPreferencesViewController.swift new file mode 100644 index 0000000..bd43842 --- /dev/null +++ b/Example/Example/UIKit/NotificationPreferencesViewController.swift @@ -0,0 +1,88 @@ +// +// NotificationPreferencesViewController.swift +// Example +// +// Created by Ullrich Schäfer on 06.04.24. +// + +import Foundation +import UIKit +import MagicBell +import ProgressHUD + +class NotificationPreferencesViewController: UITableViewController, NotificationChannelsViewControllerDelegate { + + // swiftlint:disable implicitly_unwrapped_optional + var user: MagicBell.User! + var preferences: MagicBell.NotificationPreferences? { + didSet { + if let channelsVC = channelsVC, + let category = channelsVC.category + { + channelsVC.category = preferences?.categories.filter({ $0.slug == category.slug }).first + } + self.tableView.reloadData() + } + } + + weak var channelsVC: NotificationChannelsViewController? + + override func viewDidLoad() { + super.viewDidLoad() + + user.preferences.fetch { result in + switch result { + case .failure(let error): + print("Error fetching notification preferences: \(error)") + case .success(let preferences): + self.preferences = preferences + } + } + } + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + if segue.identifier == "channels" { + if let destinationVC = segue.destination as? NotificationChannelsViewController { + + self.channelsVC = destinationVC + destinationVC.delegate = self + + if let indexPath = self.tableView.indexPath(for: sender as! UITableViewCell) { + destinationVC.category = self.preferences?.categories[indexPath.row] + } + } + } + } + + // MARK: - TableView + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return self.preferences?.categories.count ?? 0 + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let reuseIdentifier = "cell" + guard let cell = tableView.dequeueReusableCell(withIdentifier: reuseIdentifier) else { + fatalError("Could not dequeue table view cell with identifier \(reuseIdentifier)") + } + guard let category = self.preferences?.categories[indexPath.row] else { return cell } + cell.textLabel?.text = category.label + cell.detailTextLabel?.text = "\(category.channels.count)" + return cell + } + + // MARK: - NotificationChannelsViewControllerDelegate + + func updateChannel(_ sender: NotificationChannelsViewController, categorySlug: String, channelSlug: String, enabled: Bool) { + ProgressHUD.animate("Updating Preference...", .none, interaction: false) + user.preferences.update(categorySlug: categorySlug, channelSlug: channelSlug, enabled: enabled) { result in + switch result { + case .failure(let error): + print("Error fetching notification preferences: \(error)") + ProgressHUD.failed() + case .success(let preferences): + self.preferences = preferences + ProgressHUD.success() + } + } + } +} diff --git a/Example/Podfile b/Example/Podfile index 212c9fe..1779b45 100644 --- a/Example/Podfile +++ b/Example/Podfile @@ -7,6 +7,7 @@ target 'Example' do workspace './Example.xcworkspace' pod 'MagicBell', :path => '../' + pod 'ProgressHUD' pod 'SwiftLint' end diff --git a/README.md b/README.md index bb438d9..1a58cdb 100644 --- a/README.md +++ b/README.md @@ -575,6 +575,12 @@ To update the preferences, use `update`. user.preferences.update(preferences) { result in } ``` +To update a single channel you can use the provided convenience function `updateChannel`. + +```swift +user.preferences.update(categorySlug: categorySlug, channelSlug: channelSlug, enabled: enabled) { result in } +``` + ## Push Notifications You can register the device token with MagicBell for mobile push notifications. To do it, set the device token as soon diff --git a/Source/Features/NotificationPreferences/Data/NotificationPreferencesEntity.swift b/Source/Features/NotificationPreferences/Data/NotificationPreferencesEntity.swift index e14abea..a20dad3 100644 --- a/Source/Features/NotificationPreferences/Data/NotificationPreferencesEntity.swift +++ b/Source/Features/NotificationPreferences/Data/NotificationPreferencesEntity.swift @@ -17,12 +17,26 @@ struct ChannelEntity: Codable { let slug:String let label:String let enabled:Bool + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.slug, forKey: .slug) + // try container.encode(self.label, forKey: .label) // explicitly not encoding labels, as they are not expected in PUT calls + try container.encode(self.enabled, forKey: .enabled) + } } struct CategoryEntity: Codable { let slug:String let label:String let channels:[ChannelEntity] + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.slug, forKey: .slug) + // try container.encode(self.label, forKey: .label) // explicitly not encoding labels, as they are not expected in PUT calls + try container.encode(self.channels, forKey: .channels) + } } struct NotificationPreferencesEntity: Codable { diff --git a/Source/Features/NotificationPreferences/NotificationPreferencesDirector.swift b/Source/Features/NotificationPreferences/NotificationPreferencesDirector.swift index 3f69879..02221ca 100644 --- a/Source/Features/NotificationPreferences/NotificationPreferencesDirector.swift +++ b/Source/Features/NotificationPreferences/NotificationPreferencesDirector.swift @@ -22,12 +22,29 @@ public protocol NotificationPreferencesDirector { /// - completion: Closure with a `Result`. Success returns the `NotificationPreferences`. func fetch(completion: @escaping(Result) -> Void) - /// Updates the users notification preferences. Update can be partial and only will affect the categories included in the object being sent. + /// Updates the users notification preferences. + /// + /// - Important: Labels passed in categories and channels will be ignored, as the PUT endpoint does expect them. + /// + /// - SeeAlso: `update(categorySlug:channelSlug:enabled:completion:)` for a convenience function to update a single channel without having to construct an entire `NotificationPreferences` object + /// /// - Parameters: - /// - completion: Closure with a `Result`. Success returns the `NotificationPreferences`. + /// - notificationPreferences: Notificiation preferences to be updated. This can be partial subset of all categories or channels. The update will only affect what is included in the object. + /// - completion: Closure with a `Result`. Success returns `NotificationPreferences`. func update(_ notificationPreferences: NotificationPreferences, completion: @escaping(Result) -> Void) + + + /// Updates a single channel in a category + /// + /// - Parameters: + /// - categorySlug: A `String` identifying the category which contains the channel to update. + /// - channelSlug: A `String` identifying the channel to be updated. + /// - enabled: A `Bool` indicating wether the channel should be enabled or not. + /// - completion: Closure with a `Result`. Success returns the full `NotificationPreferences` containing all categories and channels. + func update(categorySlug: String, channelSlug: String, enabled: Bool, completion: @escaping(Result) -> Void) } +// Combine API public extension NotificationPreferencesDirector { /// Fetches the users notification preferences. /// - Returns: A future with the users notification preferences or an error @@ -40,7 +57,14 @@ public extension NotificationPreferencesDirector { } } - /// Updates the users notification preferences. Update can be partial and only will affect the categories included in the object being sent. + /// Updates the users notification preferences. + /// + /// - Important: Labels passed in categories and channels will be ignored, as the PUT endpoint does expect them. + /// + /// - SeeAlso: `update(categorySlug:channelSlug:enabled:)` for a convenience function to update a single channel without having to construct an entire `NotificationPreferences` object + /// + /// - Parameters: + /// - notificationPreferences: Notificiation preferences to be updated. This can be partial subset of all categories or channels. The update will only affect what is included in the object. /// - Returns: A future with the users notification preferences or an error @available(iOS 13.0, *) @discardableResult @@ -51,6 +75,23 @@ public extension NotificationPreferencesDirector { } } } + + /// Updates a channel in the users notification preferences. + /// + /// - Parameters: + /// - categorySlug: A `String` identifying the category which contains the channel to update. + /// - channelSlug: A `String` identifying the channel to be updated. + /// - enabled: A `Bool` indicating wether the channel should be enabled or not. + /// - Returns: A future with the users full notification preferences, containing all categories and channels or an error + @available(iOS 13.0, *) + @discardableResult + func update(categorySlug: String, channelSlug: String, enabled: Bool) -> Combine.Future { + return Future { promise in + self.update(categorySlug: categorySlug, channelSlug: channelSlug, enabled: enabled) { result in + promise(result) + } + } + } } struct DefaultNotificationPreferencesDirector: NotificationPreferencesDirector { @@ -90,4 +131,17 @@ struct DefaultNotificationPreferencesDirector: NotificationPreferencesDirector { completion(.failure(error)) } } + + func update(categorySlug: String, channelSlug: String, enabled: Bool, completion: @escaping(Result) -> Void) { + // Hack Alert: + // The put API does not require passing a label for categories and channels. + // The Harmony framework expects the GET and PUT datasources to have the same type though, so we are forced to have a label for PUT as well + // @see: `Get.T == T, Put.T == T` in this code: https://github.com/mobilejazz/harmony-swift/blob/a00a498c7432d25c43f84a0736d3f7d4f40809ae/Sources/Harmony/Data/DataSource/Future/DataSourceAssembler.swift#L22 + // The label will be ignored when encoding NotificationPreferencesEntity, so we are free to pass an empty string here + let dummyLabel = "" + + let channel = Channel(slug: channelSlug, label: dummyLabel, enabled: enabled) + let category = Category(slug: categorySlug, label: dummyLabel, channels: [channel]) + self.update(NotificationPreferences(categories: [category]), completion: completion) + } } diff --git a/Tests/Features/NotificationPreferences/Data/NotificationPreferencesEntityTests.swift b/Tests/Features/NotificationPreferences/Data/NotificationPreferencesEntityTests.swift index 84bd46a..12baa95 100644 --- a/Tests/Features/NotificationPreferences/Data/NotificationPreferencesEntityTests.swift +++ b/Tests/Features/NotificationPreferences/Data/NotificationPreferencesEntityTests.swift @@ -16,7 +16,7 @@ import XCTest import Nimble -let fixture = """ +let getFixture = """ { "notification_preferences":{ "categories":[ @@ -41,6 +41,29 @@ let fixture = """ } """ +// put payload does not expect labels +let putFixture = """ +{ + "notification_preferences":{ + "categories":[ + { + "slug":"user_liked_post", + "channels":[ + { + "slug":"in_app", + "enabled":true + }, + { + "slug":"mobile_push", + "enabled":false + } + ] + } + ] + } +} +""" + /// Helper function to make json data comparable /// re-encodes json data with sorted and stable keys order func jsonDataWithSortedKeys(_ data: Data) throws -> Data { @@ -53,7 +76,7 @@ final class NotificationPreferencesEntityTests: XCTestCase { let toDataMapper = EncodableToDataMapper() func testJsonDecoding() throws { - let json = fixture.data(using: .utf8)! + let json = getFixture.data(using: .utf8)! let entity = try! toDecodableMapper.map(json) @@ -76,7 +99,6 @@ final class NotificationPreferencesEntityTests: XCTestCase { } func testJsonCoding() throws { - let channel1 = ChannelEntity(slug: "in_app", label: "In app", enabled: true) let channel2 = ChannelEntity(slug: "mobile_push", label: "Mobile push", enabled: false) let category = CategoryEntity(slug: "user_liked_post", label: "User Liked Post", channels: [channel1, channel2]) @@ -85,7 +107,7 @@ final class NotificationPreferencesEntityTests: XCTestCase { let result = try! toDataMapper.map(entity) let comparableResult = try! jsonDataWithSortedKeys(result) - let comparableExpected = try! jsonDataWithSortedKeys(fixture.data(using: .utf8)!) + let comparableExpected = try! jsonDataWithSortedKeys(putFixture.data(using: .utf8)!) XCTAssertEqual(comparableResult, comparableExpected) }