Skip to content

Commit 52fff25

Browse files
committed
feat(appbadge): add setting for app badge
1 parent 5300abb commit 52fff25

12 files changed

+243
-8
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import Foundation
2+
import Sync
3+
import UIKit
4+
import Combine
5+
6+
protocol BadgeProvider: AnyObject {
7+
var applicationIconBadgeNumber: Int { get set }
8+
}
9+
10+
extension UIApplication: BadgeProvider { }
11+
12+
class AppBadgeSetup {
13+
14+
private let source: Source
15+
private let notificationCenter: NotificationCenter
16+
private var subscriptions: Set<AnyCancellable> = []
17+
private var userDefaults: UserDefaults
18+
private let badgeProvider: BadgeProvider
19+
/// This completion block is called once the badge value is updated. This completion block is not currently used in the app code, but is utilized in tests, since setting the badge needs to occur on the main thread (when using UIApplication as the provider), which is called asynchronously. Thus, this is added as async test support.
20+
private let completion: (() -> Void)?
21+
22+
init(
23+
source: Source,
24+
userDefaults: UserDefaults,
25+
notificationCenter: NotificationCenter = .default,
26+
badgeProvider: BadgeProvider,
27+
completion: (() -> Void)? = nil
28+
) {
29+
self.source = source
30+
self.notificationCenter = notificationCenter
31+
self.userDefaults = userDefaults
32+
self.badgeProvider = badgeProvider
33+
self.completion = completion
34+
35+
setupNotificationSubscription()
36+
}
37+
38+
private func setupNotificationSubscription() {
39+
self.notificationCenter
40+
.publisher(for: .listUpdated)
41+
.sink { [weak self] _ in
42+
self?.manualCheckForSavedCount()
43+
}
44+
.store(in: &subscriptions)
45+
}
46+
47+
func manualCheckForSavedCount() {
48+
let numberOfSavesRequest = Requests.fetchSavedItems()
49+
var numberOfSaves: Int
50+
let currentValue = userDefaults.bool(forKey: AccountViewModel.ToggleAppBadgeKey)
51+
if currentValue == false {
52+
numberOfSaves = 0
53+
} else {
54+
do {
55+
numberOfSaves = try source.mainContext.fetch(numberOfSavesRequest).count
56+
print(numberOfSaves)
57+
} catch {
58+
numberOfSaves = 0
59+
}
60+
}
61+
62+
updateBadgeValue(numberOfSaves: numberOfSaves)
63+
}
64+
65+
private func updateBadgeValue(numberOfSaves: Int) {
66+
DispatchQueue.main.async { [weak self] in
67+
self?.badgeProvider.applicationIconBadgeNumber = numberOfSaves
68+
self?.completion?()
69+
}
70+
}
71+
}

PocketKit/Sources/PocketKit/MyList/SavedItemsList/SavedItemsListViewModel.swift

+4-2
Original file line numberDiff line numberDiff line change
@@ -59,14 +59,16 @@ class SavedItemsListViewModel: NSObject, ItemsListViewModel {
5959

6060
private var selectedFilters: Set<ItemsListFilter>
6161
private let availableFilters: [ItemsListFilter]
62+
private let notificationCenter: NotificationCenter
6263

63-
init(source: Source, tracker: Tracker, listOptions: ListOptions) {
64+
init(source: Source, tracker: Tracker, listOptions: ListOptions, notificationCenter: NotificationCenter) {
6465
self.source = source
6566
self.tracker = tracker
6667
self.selectedFilters = [.all]
6768
self.availableFilters = ItemsListFilter.allCases
6869
self.itemsController = source.makeItemsController()
6970
self.listOptions = listOptions
71+
self.notificationCenter = notificationCenter
7072

7173
super.init()
7274

@@ -531,14 +533,14 @@ extension SavedItemsListViewModel: SavedItemsControllerDelegate {
531533
guard .update == type else {
532534
return
533535
}
534-
535536
var snapshot = buildSnapshot()
536537
snapshot.reloadItems([ItemsListCell<ItemIdentifier>.item(savedItem.objectID)])
537538
_snapshot = snapshot
538539
}
539540

540541
func controllerDidChangeContent(_ controller: SavedItemsController) {
541542
itemsLoaded()
543+
notificationCenter.post(name: .listUpdated, object: nil)
542544
}
543545
}
544546

PocketKit/Sources/PocketKit/NotificationCenter+EventNames.swift

+1
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ import Foundation
33
extension Notification.Name {
44
static let userLoggedIn = Notification.Name("com.mozilla.pocket.userLoggedIn")
55
static let userLoggedOut = Notification.Name("com.mozilla.pocket.userLoggedOut")
6+
static let listUpdated = Notification.Name("com.mozilla.pocket.listUpdated")
67
}

PocketKit/Sources/PocketKit/PocketSceneDelegate.swift

+5-2
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ public class PocketSceneDelegate: UIResponder, UIWindowSceneDelegate {
1818
savedItemsList: SavedItemsListViewModel(
1919
source: Services.shared.source,
2020
tracker: Services.shared.tracker.childTracker(hosting: .saves.saves),
21-
listOptions: .saved
21+
listOptions: .saved,
22+
notificationCenter: .default
2223
),
2324
archivedItemsList: ArchivedItemsListViewModel(
2425
source: Services.shared.source,
@@ -34,7 +35,9 @@ public class PocketSceneDelegate: UIResponder, UIWindowSceneDelegate {
3435
),
3536
account: AccountViewModel(
3637
appSession: Services.shared.appSession,
37-
user: Services.shared.user
38+
user: Services.shared.user,
39+
userDefaults: Services.shared.userDefaults,
40+
notificationCenter: .default
3841
)
3942
),
4043
source: Services.shared.source,

PocketKit/Sources/PocketKit/Services.swift

+8
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ struct Services {
2929
let v3Client: V3ClientProtocol
3030
let instantSync: InstantSyncProtocol
3131
let braze: BrazeProtocol
32+
let appBadgeSetup: AppBadgeSetup
3233

3334
private let persistentContainer: PersistentContainer
3435

@@ -66,6 +67,7 @@ struct Services {
6667
)
6768

6869
sceneTracker = SceneTracker(tracker: tracker, userDefaults: userDefaults)
70+
6971
refreshCoordinator = RefreshCoordinator(
7072
notificationCenter: .default,
7173
taskScheduler: BGTaskScheduler.shared,
@@ -103,6 +105,12 @@ struct Services {
103105
braze: braze,
104106
instantSync: instantSync
105107
)
108+
109+
appBadgeSetup = AppBadgeSetup(
110+
source: source,
111+
userDefaults: userDefaults,
112+
badgeProvider: UIApplication.shared
113+
)
106114
}
107115
}
108116

PocketKit/Sources/PocketKit/Settings/AccountViewController.swift

+5
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ enum AccountSection {
66

77
enum AccountItem {
88
case signOut
9+
case toggleAppBadge
910
}
1011

1112
class AccountViewController: UIViewController {
@@ -32,6 +33,8 @@ class AccountViewController: UIViewController {
3233

3334
cell.contentConfiguration = content
3435
cell.backgroundConfiguration?.backgroundColor = UIColor(.ui.white1)
36+
case .toggleAppBadge:
37+
model.toggleAppBadge()
3538
}
3639
}
3740

@@ -74,6 +77,8 @@ extension AccountViewController: UICollectionViewDelegate {
7477
switch dataSource.itemIdentifier(for: indexPath) {
7578
case .signOut:
7679
model.signOut()
80+
case .toggleAppBadge:
81+
model.toggleAppBadge()
7782
default:
7883
break
7984
}

PocketKit/Sources/PocketKit/Settings/AccountViewModel.swift

+26-1
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,19 @@ import SharedPocketKit
66
import SwiftUI
77

88
class AccountViewModel: ObservableObject {
9+
static let ToggleAppBadgeKey = "AccountViewModel.ToggleAppBadge"
910
private let appSession: AppSession
1011
private let user: User
12+
private let userDefaults: UserDefaults
13+
private let notificationCenter: NotificationCenter
1114

12-
init(appSession: AppSession, user: User) {
15+
@AppStorage("Settings.ToggleAppBadge")
16+
public var appBadgeToggle: Bool = false
17+
18+
init(appSession: AppSession, user: User, userDefaults: UserDefaults, notificationCenter: NotificationCenter) {
1319
self.appSession = appSession
20+
self.userDefaults = userDefaults
21+
self.notificationCenter = notificationCenter
1422
self.user = user
1523
}
1624

@@ -21,6 +29,23 @@ class AccountViewModel: ObservableObject {
2129
appSession.currentSession = nil
2230
}
2331

32+
func toggleAppBadge() {
33+
UNUserNotificationCenter.current().requestAuthorization(options: .badge) {
34+
(granted, error) in
35+
guard error == nil && granted == true else {
36+
self.userDefaults.set(false, forKey: AccountViewModel.ToggleAppBadgeKey)
37+
DispatchQueue.main.async { [weak self] in
38+
self?.appBadgeToggle = false
39+
}
40+
return
41+
}
42+
43+
let currentValue = self.userDefaults.bool(forKey: AccountViewModel.ToggleAppBadgeKey)
44+
self.userDefaults.setValue(!currentValue, forKey: AccountViewModel.ToggleAppBadgeKey)
45+
self.notificationCenter.post(name: .listUpdated, object: nil)
46+
}
47+
}
48+
2449
@Published var isPresentingHelp = false
2550
@Published var isPresentingTerms = false
2651
@Published var isPresentingPrivacy = false
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import SwiftUI
2+
import Textile
3+
4+
struct SettingsRowToggle: View {
5+
6+
private var title: String
7+
8+
@ObservedObject
9+
private var model: AccountViewModel
10+
11+
let action: () -> Void
12+
13+
init(title: String, model: AccountViewModel, action: @escaping () -> Void) {
14+
self.title = title
15+
self.model = model
16+
self.action = action
17+
}
18+
19+
var body: some View {
20+
VStack {
21+
Toggle(title, isOn: model.$appBadgeToggle)
22+
.onChange(of: model.appBadgeToggle) { newValue in
23+
self.action()
24+
}
25+
}
26+
}
27+
}

PocketKit/Sources/PocketKit/Settings/SettingsView.swift

+6-1
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@ struct SettingsForm: View {
6464
})
6565
.textCase(nil)
6666

67+
Section(header: Text("App Customization").style(.settings.header)) {
68+
SettingsRowToggle(title: "Show App Badge Count", model: model) {
69+
model.toggleAppBadge()
70+
}
71+
}.textCase(nil)
72+
6773
Section(header: Text("About & Support").style(.settings.header)) {
6874
SettingsRowButton(title: "Help", icon: SFIconModel("questionmark.circle")) { model.isPresentingHelp.toggle() }
6975
.sheet(isPresented: $model.isPresentingHelp) {
@@ -80,7 +86,6 @@ struct SettingsForm: View {
8086
SFSafariView(url: URL(string: "https://getpocket.com/en/privacy/")!)
8187
.edgesIgnoringSafeArea(.bottom)
8288
}
83-
8489
}.textCase(nil)
8590
}
8691
.listRowBackground(Color(.ui.grey7))
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import XCTest
2+
import CoreData
3+
@testable import PocketKit
4+
import Combine
5+
import SharedPocketKit
6+
@testable import Sync
7+
8+
class MockBadgeProvider: BadgeProvider {
9+
var applicationIconBadgeNumber: Int = 0
10+
}
11+
12+
final class AppBadgeTrackerTests: XCTestCase {
13+
private var space: Space!
14+
private var source: MockSource!
15+
private var userDefaults: UserDefaults!
16+
private var accountViewModel: AccountViewModel!
17+
private var badgeProvider: MockBadgeProvider!
18+
private var savedItem: NSManagedObject!
19+
private var archivedItem: NSManagedObject!
20+
21+
override func setUp() {
22+
space = .testSpace()
23+
source = MockSource()
24+
source.mainContext = space.context
25+
savedItem = space.buildSavedItem()
26+
archivedItem = space.buildSavedItem(isArchived: true)
27+
28+
userDefaults = UserDefaults()
29+
badgeProvider = MockBadgeProvider()
30+
}
31+
32+
private func subject(completion: (() -> Void)? = nil) -> AppBadgeSetup {
33+
return AppBadgeSetup(source: source, userDefaults: userDefaults, badgeProvider: badgeProvider, completion: completion)
34+
}
35+
36+
override func tearDown() {
37+
userDefaults.removeObject(forKey: AccountViewModel.ToggleAppBadgeKey)
38+
}
39+
40+
func test_on_savedItemsUpdated_noSubscriberCalled() {
41+
userDefaults.setValue(false, forKey: AccountViewModel.ToggleAppBadgeKey)
42+
43+
source.mainContext.insert(savedItem)
44+
45+
NotificationCenter.default.post(name: .listUpdated, object: nil)
46+
47+
XCTAssertEqual(badgeProvider.applicationIconBadgeNumber, 0)
48+
source.mainContext.delete(savedItem)
49+
}
50+
51+
func test_on_savedItemsUpdated_subscribersCalledAddingElement() {
52+
let badgeExpectation = expectation(description: "expected badge count to be updated")
53+
let subject = subject {
54+
badgeExpectation.fulfill()
55+
}
56+
badgeExpectation.assertForOverFulfill = false
57+
58+
userDefaults.setValue(true, forKey: AccountViewModel.ToggleAppBadgeKey)
59+
60+
source.mainContext.insert(savedItem)
61+
source.mainContext.insert(archivedItem)
62+
63+
NotificationCenter.default.post(name: .listUpdated, object: nil)
64+
65+
wait(for: [badgeExpectation], timeout: 1)
66+
XCTAssertEqual(badgeProvider.applicationIconBadgeNumber, 1)
67+
source.mainContext.delete(savedItem)
68+
source.mainContext.delete(archivedItem)
69+
}
70+
71+
func test_on_savedItemsUpdated_subscribersCalledAddingAndDeleting() {
72+
let badgeExpectation = expectation(description: "expected badge count to be updated")
73+
let subject = subject {
74+
badgeExpectation.fulfill()
75+
}
76+
badgeExpectation.assertForOverFulfill = false
77+
78+
userDefaults.setValue(true, forKey: AccountViewModel.ToggleAppBadgeKey)
79+
80+
source.mainContext.insert(savedItem)
81+
82+
source.mainContext.delete(savedItem)
83+
NotificationCenter.default.post(name: .listUpdated, object: nil)
84+
85+
wait(for: [badgeExpectation], timeout: 1)
86+
XCTAssertEqual(badgeProvider.applicationIconBadgeNumber, 0)
87+
}
88+
}

PocketKit/Tests/PocketKitTests/Notifications/PushNotificationServiceTests.swift

-1
Original file line numberDiff line numberDiff line change
@@ -82,5 +82,4 @@ final class PushNotificationServiceTests: XCTestCase {
8282
XCTAssertEqual(braze.loggedOutCalls(), 1)
8383
XCTAssertEqual(instantSync.loggedOutCalls(), 1)
8484
}
85-
8685
}

PocketKit/Tests/PocketKitTests/SavedItemsListViewModelTests.swift

+2-1
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ class SavedItemsListViewModelTests: XCTestCase {
4141
SavedItemsListViewModel(
4242
source: source ?? self.source,
4343
tracker: tracker ?? self.tracker,
44-
listOptions: listOptions ?? self.listOptions
44+
listOptions: listOptions ?? self.listOptions,
45+
notificationCenter: .default
4546
)
4647
}
4748

0 commit comments

Comments
 (0)