Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit de00738

Browse files
committedJan 21, 2025
notificationsVC could crash when group membership changed
switch to a diffable datasource for the tableview listing the entry points for the default notifications and for the notification settings of each goal also used the hud activity indicator throughout fixes beeminder#397
1 parent b6b68cd commit de00738

3 files changed

+154
-88
lines changed
 

‎BeeSwift/Settings/ConfigureNotificationsViewController.swift

+121-81
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,17 @@ class ConfigureNotificationsViewController: UIViewController {
1717
private let logger = Logger(subsystem: "com.beeminder.beeminder", category: "ConfigureNotificationsViewController")
1818

1919
private var lastFetched : Date?
20-
fileprivate var goals : [Goal] = []
21-
fileprivate var cellReuseIdentifier = "configureNotificationsTableViewCell"
2220
fileprivate var tableView = UITableView()
2321
fileprivate let settingsButton = BSButton()
2422
private let goalManager: GoalManager
2523
private let viewContext: NSManagedObjectContext
2624
private let currentUserManager: CurrentUserManager
2725
private let requestManager: RequestManager
2826

27+
private lazy var dataSource: NotificationsTableViewDiffibleDataSource = {
28+
NotificationsTableViewDiffibleDataSource(goals: [], tableView: tableView)
29+
}()
30+
2931
init(goalManager: GoalManager, viewContext: NSManagedObjectContext, currentUserManager: CurrentUserManager, requestManager: RequestManager) {
3032
self.goalManager = goalManager
3133
self.viewContext = viewContext
@@ -65,21 +67,33 @@ class ConfigureNotificationsViewController: UIViewController {
6567
}
6668
self.tableView.isHidden = true
6769
self.tableView.delegate = self
68-
self.tableView.dataSource = self
70+
self.tableView.dataSource = self.dataSource
6971
self.tableView.refreshControl = {
7072
let refresh = UIRefreshControl()
7173
refresh.addTarget(self, action: #selector(fetchGoals), for: .valueChanged)
7274
return refresh
7375
}()
7476
self.tableView.tableFooterView = UIView()
75-
self.tableView.register(SettingsTableViewCell.self, forCellReuseIdentifier: self.cellReuseIdentifier)
7677
self.fetchGoals()
7778
self.updateHiddenElements()
7879
NotificationCenter.default.addObserver(self, selector: #selector(self.foregroundEntered), name: UIApplication.willEnterForegroundNotification, object: nil)
7980
}
8081

81-
override func didReceiveMemoryWarning() {
82-
super.didReceiveMemoryWarning()
82+
override func viewWillAppear(_ animated: Bool) {
83+
super.viewWillAppear(animated)
84+
85+
self.applySnapshot()
86+
}
87+
88+
private func applySnapshot() {
89+
guard lastFetched != nil else {
90+
let snapshot = NSDiffableDataSourceSnapshot<NotificationsTableViewDiffibleDataSource.Section, String>()
91+
dataSource.apply(snapshot)
92+
return
93+
}
94+
95+
let snapshot = dataSource.makeSnapshot()
96+
dataSource.apply(snapshot, animatingDifferences: true)
8397
}
8498

8599
@objc func settingsButtonTapped() {
@@ -112,7 +126,7 @@ class ConfigureNotificationsViewController: UIViewController {
112126
MBProgressHUD.showAdded(to: self.view, animated: true)
113127
do {
114128
try await self.goalManager.refreshGoals()
115-
self.goals = self.goalManager.staleGoals(context: self.viewContext)?.sorted(by: { $0.slug < $1.slug }) ?? []
129+
self.dataSource.goals = self.goalManager.staleGoals(context: self.viewContext)?.sorted(using: SortDescriptor(\.slug)) ?? []
116130
self.lastFetched = Date()
117131
MBProgressHUD.hide(for: self.view, animated: true)
118132
} catch {
@@ -125,102 +139,128 @@ class ConfigureNotificationsViewController: UIViewController {
125139
self.present(alert, animated: true, completion: nil)
126140
}
127141
}
128-
self.tableView.reloadData()
142+
self.applySnapshot()
129143
}
130144
}
131145
}
132146

133-
private extension ConfigureNotificationsViewController {
147+
private class NotificationsTableViewDiffibleDataSource: UITableViewDiffableDataSource<NotificationsTableViewDiffibleDataSource.Section, String> {
148+
static let cellReuseIdentifier = "configureNotificationsTableViewCell"
149+
150+
var goals: [Goal]
151+
152+
enum Section: Int, CaseIterable {
153+
case defaultNotificationSettings = 0
154+
case goalsUsingCustomSettings = 1
155+
case goalsUsingDefaults = 2
156+
}
157+
158+
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
159+
guard let section = Section(rawValue: section) else { return nil }
160+
161+
return switch section {
162+
case .defaultNotificationSettings:
163+
"Defaults"
164+
case .goalsUsingDefaults where goalsUsingDefaultNotifications.isEmpty:
165+
nil
166+
case .goalsUsingCustomSettings where goalsUsingNonDefaultNotifications.isEmpty:
167+
nil
168+
case .goalsUsingDefaults:
169+
"Using Defaults"
170+
case .goalsUsingCustomSettings:
171+
"Customized"
172+
}
173+
}
174+
175+
init(goals: [Goal], tableView: UITableView) {
176+
self.goals = goals
177+
tableView.register(SettingsTableViewCell.self, forCellReuseIdentifier: Self.cellReuseIdentifier)
178+
179+
super.init(tableView: tableView) { tableView, indexPath, title in
180+
guard
181+
let cell = tableView.dequeueReusableCell(withIdentifier: Self.cellReuseIdentifier, for: indexPath) as? SettingsTableViewCell
182+
else { return UITableViewCell() }
183+
184+
cell.title = title
185+
return cell
186+
}
187+
}
188+
134189
var goalsUsingDefaultNotifications: [Goal] {
135190
self.goals.filter { $0.useDefaults }
136191
}
137192

138193
var goalsUsingNonDefaultNotifications: [Goal] {
139194
self.goals.filter { !$0.useDefaults }
140195
}
196+
197+
func makeSnapshot() -> NSDiffableDataSourceSnapshot<Section, String> {
198+
var snapshot = NSDiffableDataSourceSnapshot<Section, String>()
199+
200+
snapshot.appendSections([.defaultNotificationSettings])
201+
snapshot.appendItems(["Default notification settings"],
202+
toSection: .defaultNotificationSettings)
141203

142-
}
204+
snapshot.appendSections([.goalsUsingCustomSettings])
205+
snapshot.appendItems(goalsUsingNonDefaultNotifications.map{ $0.slug },
206+
toSection: .goalsUsingCustomSettings)
207+
208+
snapshot.appendSections([.goalsUsingDefaults])
209+
snapshot.appendItems(goalsUsingDefaultNotifications.map{ $0.slug },
210+
toSection: .goalsUsingDefaults)
143211

144-
extension ConfigureNotificationsViewController : UITableViewDataSource, UITableViewDelegate {
145-
func numberOfSections(in tableView: UITableView) -> Int {
146-
guard lastFetched != nil else { return 0 }
147-
return 3
148-
}
149-
150-
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
151-
switch section {
152-
case 0:
153-
return 1
154-
case 1:
155-
return self.goalsUsingDefaultNotifications.count
156-
default:
157-
return self.goalsUsingNonDefaultNotifications.count
158-
}
159-
}
160-
161-
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
162-
switch section {
163-
case 0:
164-
guard !goals.isEmpty else { return nil }
165-
return "Defaults"
166-
case 1:
167-
guard !goalsUsingDefaultNotifications.isEmpty else { return nil }
168-
return "Using Defaults"
169-
default:
170-
guard !goalsUsingNonDefaultNotifications.isEmpty else { return nil }
171-
return "Customized"
172-
}
212+
snapshot.reconfigureItems(goals.map({ $0.slug }))
213+
214+
return snapshot
173215
}
174216

175-
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
176-
guard let cell = tableView.dequeueReusableCell(withIdentifier: self.cellReuseIdentifier) as? SettingsTableViewCell else {
177-
return UITableViewCell()
217+
func goalAtIndexPath(_ indexPath: IndexPath) -> Goal? {
218+
guard
219+
let section = NotificationsTableViewDiffibleDataSource.Section(rawValue: indexPath.section)
220+
else {
221+
return nil
178222
}
179223

180-
switch indexPath.section {
181-
case 0:
182-
cell.title = "Default notification settings"
183-
return cell
184-
case 1:
185-
let goal = self.goalsUsingDefaultNotifications[indexPath.row]
186-
cell.title = goal.slug
187-
return cell
188-
default:
189-
let goal = self.goalsUsingNonDefaultNotifications[indexPath.row]
190-
cell.title = goal.slug
191-
return cell
224+
return switch section {
225+
case .defaultNotificationSettings:
226+
nil
227+
case .goalsUsingCustomSettings:
228+
indexPath.row < goalsUsingNonDefaultNotifications.count ? goalsUsingNonDefaultNotifications[indexPath.row] : nil
229+
case .goalsUsingDefaults:
230+
indexPath.row < goalsUsingDefaultNotifications.count ? goalsUsingDefaultNotifications[indexPath.row] : nil
192231
}
193232
}
194-
233+
}
234+
235+
extension ConfigureNotificationsViewController: UITableViewDelegate {
195236
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
196-
let editNotificationsVC: UIViewController
237+
self.tableView.deselectRow(at: indexPath, animated: true)
197238

198-
switch indexPath.section {
199-
case 0:
200-
editNotificationsVC = EditDefaultNotificationsViewController(
201-
currentUserManager: currentUserManager,
202-
requestManager: requestManager,
203-
goalManager: goalManager,
204-
viewContext: viewContext)
205-
case 1:
206-
let goal = self.goalsUsingDefaultNotifications[indexPath.row]
207-
editNotificationsVC = EditGoalNotificationsViewController(
208-
goal: goal,
209-
currentUserManager: currentUserManager,
210-
requestManager: requestManager,
211-
goalManager: goalManager,
212-
viewContext: viewContext)
213-
default:
214-
let goal = self.goalsUsingNonDefaultNotifications[indexPath.row]
215-
editNotificationsVC = EditGoalNotificationsViewController(
216-
goal: goal,
217-
currentUserManager: currentUserManager,
218-
requestManager: requestManager,
219-
goalManager: goalManager,
220-
viewContext: viewContext)
239+
guard let section = NotificationsTableViewDiffibleDataSource.Section(rawValue: indexPath.section) else {
240+
return
221241
}
222242

223-
self.navigationController?.pushViewController(editNotificationsVC, animated: true)
224-
self.tableView.deselectRow(at: indexPath, animated: true)
243+
var editNotificationsVC: UIViewController? {
244+
switch section {
245+
case .defaultNotificationSettings:
246+
return EditDefaultNotificationsViewController(
247+
currentUserManager: currentUserManager,
248+
requestManager: requestManager,
249+
goalManager: goalManager,
250+
viewContext: viewContext)
251+
case .goalsUsingDefaults, .goalsUsingCustomSettings:
252+
guard let goal = self.dataSource.goalAtIndexPath(indexPath) else { return nil }
253+
return EditGoalNotificationsViewController(
254+
goal: goal,
255+
currentUserManager: currentUserManager,
256+
requestManager: requestManager,
257+
goalManager: goalManager,
258+
viewContext: viewContext)
259+
}
260+
}
261+
262+
if let editNotificationsVC {
263+
self.navigationController?.pushViewController(editNotificationsVC, animated: true)
264+
}
225265
}
226266
}

‎BeeSwift/Settings/EditDefaultNotificationsViewController.swift

+12-5
Original file line numberDiff line numberDiff line change
@@ -60,27 +60,34 @@ class EditDefaultNotificationsViewController: EditNotificationsViewController {
6060

6161
func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
6262
Task { @MainActor in
63-
if self.timePickerEditingMode == .alertstart {
63+
let hud = MBProgressHUD.showAdded(to: self.view, animated: true)
64+
hud.mode = .indeterminate
65+
66+
switch self.timePickerEditingMode {
67+
case .alertstart:
6468
self.updateAlertstartLabel(self.midnightOffsetFromTimePickerView())
6569
let params = ["default_alertstart" : self.midnightOffsetFromTimePickerView()]
6670
do {
6771
let _ = try await requestManager.put(url: "api/v1/users/{username}.json", parameters: params)
6872
try await currentUserManager.refreshUser()
73+
hud.hide(animated: true, afterDelay: 0.5)
6974
} catch {
7075
logger.error("Error setting default alert start: \(error)")
71-
//foo
76+
hud.hide(animated: true)
7277
}
73-
}
74-
if self.timePickerEditingMode == .deadline {
78+
case .deadline:
7579
self.updateDeadlineLabel(self.midnightOffsetFromTimePickerView())
7680
let params = ["default_deadline" : self.midnightOffsetFromTimePickerView()]
7781
do {
7882
let _ = try await requestManager.put(url: "api/v1/users/{username}.json", parameters: params)
7983
try await currentUserManager.refreshUser()
84+
hud.hide(animated: true, afterDelay: 0.5)
8085
} catch {
8186
logger.error("Error setting default deadline: \(error)")
82-
//foo
87+
hud.hide(animated: true, afterDelay: 0.5)
8388
}
89+
case .none:
90+
hud.hide(animated: true)
8491
}
8592
}
8693
}

‎BeeSwift/Settings/EditGoalNotificationsViewController.swift

+21-2
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ class EditGoalNotificationsViewController : EditNotificationsViewController {
4949
override func viewDidLoad() {
5050
super.viewDidLoad()
5151

52-
self.title = "\(self.goal.title) Notifications"
52+
self.title = self.goal.slug
5353

5454
let useDefaultsLabel = BSLabel()
5555
useDefaultsLabel.text = "Use defaults"
@@ -93,6 +93,9 @@ class EditGoalNotificationsViewController : EditNotificationsViewController {
9393

9494
func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
9595
Task { @MainActor in
96+
let hud = MBProgressHUD.showAdded(to: self.view, animated: true)
97+
hud.mode = .indeterminate
98+
9699
if self.timePickerEditingMode == .alertstart {
97100
self.updateAlertstartLabel(self.midnightOffsetFromTimePickerView())
98101
do {
@@ -101,9 +104,11 @@ class EditGoalNotificationsViewController : EditNotificationsViewController {
101104
try await self.goalManager.refreshGoal(self.goal.objectID)
102105

103106
self.useDefaultsSwitch.isOn = false
107+
hud.hide(animated: true, afterDelay: 0.5)
104108
} catch {
105109
logger.error("Error setting alert start \(error)")
106110
//foo
111+
hud.hide(animated: true)
107112
}
108113
}
109114
if self.timePickerEditingMode == .deadline {
@@ -114,37 +119,46 @@ class EditGoalNotificationsViewController : EditNotificationsViewController {
114119
try await self.goalManager.refreshGoal(self.goal.objectID)
115120

116121
self.useDefaultsSwitch.isOn = false
122+
hud.hide(animated: true, afterDelay: 0.5)
117123
} catch {
118124
let errorString = error.localizedDescription
119125
MBProgressHUD.hide(for: self.view, animated: true)
120126
let alert = UIAlertController(title: "Error saving to Beeminder", message: errorString, preferredStyle: .alert)
121127
alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
122128
self.present(alert, animated: true, completion: nil)
129+
hud.hide(animated: true)
123130
}
124131
}
125132
}
126133
}
127134

128135
@objc func useDefaultsSwitchValueChanged() {
129136
if self.useDefaultsSwitch.isOn {
130-
let alertController = UIAlertController(title: "Confirm", message: "This will wipe out your current settings for this goal. Are you sure?", preferredStyle: .alert)
137+
let alertController = UIAlertController(title: "Confirm", message: "This will set this goal's notification settings to your default ones. Are you sure?", preferredStyle: .alert)
131138
alertController.addAction(UIAlertAction(title: "Yes", style: .default, handler: { (action) -> Void in
132139
Task { @MainActor in
140+
let hud = MBProgressHUD.showAdded(to: self.view, animated: true)
141+
hud.mode = .indeterminate
142+
133143
do {
134144
let params = ["use_defaults" : true]
135145
let _ = try await self.requestManager.put(url: "api/v1/users/{username}/goals/\(self.goal.slug).json", parameters: params)
136146
try await self.goalManager.refreshGoal(self.goal.objectID)
147+
hud.hide(animated: true, afterDelay: 0.5)
137148
} catch {
138149
self.logger.error("Error setting goal to use defaults: \(error)")
139150
// TODO: Show UI failure
151+
hud.hide(animated: true)
140152
return
141153
}
142154

143155
do {
144156
try await self.currentUserManager.refreshUser()
157+
hud.hide(animated: true, afterDelay: 0.5)
145158
} catch {
146159
self.logger.error("Error syncing notification defaults")
147160
// TODO: Show UI failure
161+
hud.hide(animated: true)
148162
return
149163
}
150164

@@ -162,13 +176,18 @@ class EditGoalNotificationsViewController : EditNotificationsViewController {
162176
}
163177
else {
164178
Task { @MainActor in
179+
let hud = MBProgressHUD.showAdded(to: self.view, animated: true)
180+
hud.mode = .indeterminate
181+
165182
do {
166183
let params = ["use_defaults" : false]
167184
let _ = try await self.requestManager.put(url: "api/v1/users/{username}/goals/\(self.goal.slug).json", parameters: params)
168185
try await self.goalManager.refreshGoal(self.goal.objectID)
186+
hud.hide(animated: true, afterDelay: 0.5)
169187
} catch {
170188
logger.error("Error setting goal to NOT use defaults: \(error)")
171189
// foo
190+
hud.hide(animated: true)
172191
}
173192
}
174193
}

0 commit comments

Comments
 (0)
Please sign in to comment.