Watch preference on the account screen !95

merged merged by cmc on 2026-09-12 06:00 UTC · krz/gitbay-ios:notifications-watch into main

4 files changed, +78 −14

Layout: unified · split

gitbay/Account/AccountViewModel.swift +22 −6
@@ -58,6 +58,10 @@ final class AccountViewModel {
5858 /// Whether activity mail is on (v1.16.0); nil until read, or on a
5959 /// server without the setting. Inbox rows are filed either way.
6060 private(set) var mailOn: Bool?
61 /// Whether the account watches every issue and merge request on the
62 /// repositories it can write to (v1.19.0); nil until read, or on a
63 /// server whose `show` has no `watch`.
64 private(set) var watchOn: Bool?
6165 /// The account's addresses; nil until read, or on a server without
6266 /// `email list`.
6367 private(set) var emails: [EmailAddress]?
@@ -83,23 +87,35 @@ final class AccountViewModel {
8387
8488 // MARK: - Notifications
8589
86 nonisolated private struct MailSetting: Decodable, Sendable {
90 nonisolated private struct NotificationSettings: Decodable, Sendable {
8791 let mail: Bool
92 let watch: Bool?
8893 }
8994
90 func loadMailPreference() async {
91 mailOn = (try? await client.read(
92 ["notifications", "settings", "show"], as: MailSetting.self))?.mail
95 func loadNotificationPreferences() async {
96 let settings = try? await client.read(
97 ["notifications", "settings", "show"], as: NotificationSettings.self)
98 mailOn = settings?.mail
99 watchOn = settings?.watch
93100 }
94101
95102 /// `notifications settings mail on|off`, then read the value back.
96103 func setMail(_ on: Bool) async {
104 await setPreference("mail", on)
105 }
106
107 /// `notifications settings watch on|off`, then read the value back.
108 func setWatch(_ on: Bool) async {
109 await setPreference("watch", on)
110 }
111
112 private func setPreference(_ name: String, _ on: Bool) async {
97113 working = true
98114 actionError = nil
99115 defer { working = false }
100116 do {
101 try await client.run(["notifications", "settings", "mail", on ? "on" : "off"])
102 await loadMailPreference()
117 try await client.run(["notifications", "settings", name, on ? "on" : "off"])
118 await loadNotificationPreferences()
103119 } catch let error as GitbayError {
104120 actionError = error.userFacingMessage
105121 } catch {
gitbay/Views/Account/AccountView.swift +12 −5
@@ -57,7 +57,7 @@ struct AccountView: View {
5757 .navigationTitle("Account")
5858 .navigationBarTitleDisplayMode(.inline)
5959 .task { await model.load() }
60 .task { await model.loadMailPreference() }
60 .task { await model.loadNotificationPreferences() }
6161 .task { await model.loadEmails() }
6262 .confirmationDialog(
6363 "Remove \(removingEmail?.address ?? "")?",
@@ -77,7 +77,7 @@ struct AccountView: View {
7777 }
7878 .refreshable {
7979 await model.load()
80 await model.loadMailPreference()
80 await model.loadNotificationPreferences()
8181 await model.loadEmails()
8282 }
8383 .sheet(isPresented: $addingSSH) {
@@ -142,8 +142,9 @@ struct AccountView: View {
142142
143143 // MARK: - Sections
144144
145 /// `notifications settings mail`: the mail half of notifications. The
146 /// inbox is filed either way, and login links still arrive.
145 /// `notifications settings mail` and `watch`. The inbox is filed
146 /// either way, and login links still arrive. Watch lands with
147 /// v1.19.0; an older server shows mail alone.
147148 @ViewBuilder
148149 private var notificationsSection: some View {
149150 if let mailOn = model.mailOn {
@@ -152,10 +153,16 @@ struct AccountView: View {
152153 await model.setMail($0)
153154 }
154155 .disabled(model.working)
156 if let watchOn = model.watchOn {
157 SettingToggle(title: "Watch writable repositories", serverValue: watchOn) {
158 await model.setWatch($0)
159 }
160 .disabled(model.working)
161 }
155162 } header: {
156163 Text("Notifications")
157164 } footer: {
158 Text("Off keeps the inbox and stops the mail. Login links are not activity and still arrive.")
165 Text("Mail off keeps the inbox and stops the mail; login links are not activity and still arrive. Watching makes you a recipient of every issue and merge request on repositories you can write to.")
159166 }
160167 }
161168 }
gitbayTests/AccountTests.swift +27 −3
@@ -48,13 +48,15 @@ struct AccountViewModelTests {
4848 /// v1.16.0: `notifications settings show` says whether activity mail
4949 /// is on; `notifications settings mail on|off` flips it. The read is
5050 /// separate from the keys, and an older server leaves it unknown.
51 /// A server before v1.19.0 answers without `watch`, which stays nil.
5152 @Test func mailPreferenceLoadsAndFlips() async throws {
5253 let (model, stub) = try await loadedModel()
5354 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"mail":true},"exit_code":0}"#, match: "argv=settings&argv=show"))
5455
55 await model.loadMailPreference()
56 await model.loadNotificationPreferences()
5657
5758 #expect(model.mailOn == true)
59 #expect(model.watchOn == nil)
5860 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
5961 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"mail":false},"exit_code":0}"#, match: "argv=settings&argv=show"))
6062
@@ -66,13 +68,35 @@ struct AccountViewModelTests {
6668 #expect(model.mailOn == false)
6769 }
6870
69 @Test func anOlderServerLeavesTheMailPreferenceUnknown() async throws {
71 /// v1.19.0: `show` also reports `watch`, and `notifications settings
72 /// watch on|off` flips it.
73 @Test func watchPreferenceLoadsAndFlips() async throws {
74 let (model, stub) = try await loadedModel()
75 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"mail":true,"watch":false},"exit_code":0}"#, match: "argv=settings&argv=show"))
76
77 await model.loadNotificationPreferences()
78
79 #expect(model.mailOn == true)
80 #expect(model.watchOn == false)
81 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
82 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"mail":true,"watch":true},"exit_code":0}"#, match: "argv=settings&argv=show"))
83
84 await model.setWatch(true)
85
86 let write = try #require(stub.seen.first { $0.method == "POST" })
87 let (argv, _) = try argvOf(write)
88 #expect(argv == ["notifications", "settings", "watch", "on"])
89 #expect(model.watchOn == true)
90 }
91
92 @Test func anOlderServerLeavesTheNotificationPreferencesUnknown() async throws {
7093 let (model, stub) = try await loadedModel()
7194 stub.enqueue(.init(status: 400, json: #"{"protocol_version":1,"error":"no command matches","exit_code":2}"#, match: "argv=settings&argv=show"))
7295
73 await model.loadMailPreference()
96 await model.loadNotificationPreferences()
7497
7598 #expect(model.mailOn == nil)
99 #expect(model.watchOn == nil)
76100 #expect(model.actionError == nil)
77101 }
78102
gitbayUITests/LiveSmokeUITests.swift +17
@@ -860,6 +860,23 @@ extension LiveSmokeUITests {
860860 flipMail()
861861 XCTAssertTrue(waitForValue(mail, mailWasOn ? "1" : "0", timeout: 10), "mail toggle did not restore")
862862
863 // The watch preference (v1.19.0) flips the same way.
864 let watch = app.switches["Watch writable repositories"].firstMatch
865 XCTAssertTrue(scrollTo(watch), "watch toggle not reachable")
866 let innerWatch = watch.switches.firstMatch
867 let flipWatch: () -> Void = {
868 if innerWatch.exists && innerWatch != watch {
869 innerWatch.tap()
870 } else {
871 watch.coordinate(withNormalizedOffset: CGVector(dx: 0.93, dy: 0.5)).tap()
872 }
873 }
874 let watchWasOn = (watch.value as? String) == "1"
875 flipWatch()
876 XCTAssertTrue(waitForValue(watch, watchWasOn ? "0" : "1", timeout: 10), "watch toggle did not persist")
877 flipWatch()
878 XCTAssertTrue(waitForValue(watch, watchWasOn ? "1" : "0", timeout: 10), "watch toggle did not restore")
879
863880 // A bogus verification code is refused, not swallowed.
864881 let code = app.descendants(matching: .any)
865882 .matching(identifier: "email-code").firstMatch