Commit 7805888980

7805888980f6d41bcc881c5d2e6001b2d8e69351

parent: e597cdf5b7

Unsigned

cmc <hello@cleberg.net> · 2026-07-25 04:52 UTC

refactor: extract monitoring surface into DomainViewModel+Monitoring (v5 step 4, 2/n)

Second slice of the DomainViewModel decomposition, stacked on the audit split.

- Moves the monitoring configuration surface — notification authorization, the
  settings mutators (enabled/scope/interval/adaptive/sensitivity/quiet-hours/
  alert filter/alerts/selection), toggleMonitoring, runMonitoringNow, the manual
  run, and the per-domain interval/status labels — plus the private
  intervalLabel helper into DomainViewModel+Monitoring.swift. Pure move.
- persistMonitoringSettings(...) and sanitizeMonitoringSelection() are promoted
  private -> internal: the tracked-domain lifecycle (add/delete/clear) on the
  main type calls them too, so they stay put and are now reachable cross-file.
  The two visibility drops are the only non-deletion edits to DomainViewModel.

DomainViewModel.swift: 4761 -> 4601 lines. App builds clean; unit suite 58/58.
No project.pbxproj change (DomainDig/ is a synchronized group).

Layout: unified · split

DomainDig/DomainViewModel+Monitoring.swift added +174
@@ -0,0 +1,174 @@
1import Foundation
2import SwiftUI
3import UserNotifications
4
5/// Monitoring configuration surface of `DomainViewModel`: notification
6/// authorization, the monitoring settings mutators (scope, interval, adaptivity,
7/// sensitivity, quiet hours, alert filter/toggle, per-domain selection), the
8/// manual run, and the per-domain status labels.
9///
10/// Settings mutations persist through `persistMonitoringSettings(...)` and
11/// `sanitizeMonitoringSelection()`, which remain on the main type because the
12/// tracked-domain lifecycle (add/delete/clear) calls them too.
13extension DomainViewModel {
14 func refreshMonitoringAuthorizationStatus() async {
15 let settings = await UNUserNotificationCenter.current().notificationSettings()
16 monitoringNotificationStatus = settings.authorizationStatus
17 }
18
19 func setMonitoringEnabled(_ isEnabled: Bool) {
20 guard !isEnabled || FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
21 monitoringSettings.isEnabled = false
22 upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring)
23 return
24 }
25
26 monitoringSettings.isEnabled = isEnabled
27 persistMonitoringSettings(localActivationConfirmed: isEnabled)
28 monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
29 }
30
31 func setMonitoringScope(_ scope: MonitoringScope) {
32 monitoringSettings.scope = scope
33 persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
34 }
35
36 func setMonitoringBaseInterval(_ baseInterval: MonitoringBaseInterval) {
37 guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
38 upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring)
39 return
40 }
41 monitoringSettings.baseInterval = baseInterval.interval
42 persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
43 monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
44 }
45
46 func setMonitoringAdaptiveEnabled(_ isEnabled: Bool) {
47 guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
48 upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring)
49 return
50 }
51 monitoringSettings.adaptiveEnabled = isEnabled
52 persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
53 monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
54 }
55
56 func setMonitoringSensitivity(_ sensitivity: MonitoringSensitivity) {
57 guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
58 upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring)
59 return
60 }
61 monitoringSettings.sensitivity = sensitivity
62 persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
63 monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
64 }
65
66 func setMonitoringQuietHours(startHour: Int, endHour: Int, isEnabled: Bool) {
67 guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
68 upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring)
69 return
70 }
71 monitoringSettings.quietHours = isEnabled ? QuietHours(startHour: startHour, endHour: endHour) : nil
72 persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
73 }
74
75 func setMonitoringAlertFilter(_ filter: MonitoringAlertFilter) {
76 guard FeatureAccessService.hasAccess(to: .localAlerts) else {
77 monitoringSettings.alertsEnabled = false
78 upgradePrompt = FeatureAccessService.upgradePrompt(for: .localAlerts)
79 return
80 }
81 monitoringSettings.alertFilter = filter
82 persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
83 }
84
85 func setMonitoringAlertsEnabled(_ isEnabled: Bool) {
86 guard !isEnabled || FeatureAccessService.hasAccess(to: .localAlerts) else {
87 monitoringSettings.alertsEnabled = false
88 upgradePrompt = FeatureAccessService.upgradePrompt(for: .localAlerts)
89 return
90 }
91 monitoringSettings.alertsEnabled = isEnabled
92 persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
93 }
94
95 func setMonitoringSelection(for trackedDomain: TrackedDomain, isSelected: Bool) {
96 if isSelected {
97 if !monitoringSettings.selectedDomainIDs.contains(trackedDomain.id) {
98 monitoringSettings.selectedDomainIDs.append(trackedDomain.id)
99 }
100 } else {
101 monitoringSettings.selectedDomainIDs.removeAll { $0 == trackedDomain.id }
102 }
103 persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
104 }
105
106 func toggleMonitoring(for trackedDomain: TrackedDomain) {
107 guard canEdit(trackedDomain) else { return }
108 guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
109 upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring)
110 return
111 }
112 guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return }
113 trackedDomains[index].monitoringEnabled.toggle()
114 trackedDomains[index].updatedAt = Date()
115 MonitoringStorage.saveTrackedDomains(trackedDomains)
116 CloudSyncService.shared.scheduleSyncIfNeeded()
117 sanitizeMonitoringSelection()
118 }
119
120 func requestMonitoringNotificationAuthorization() async {
121 let granted = await LocalNotificationService.shared.requestAuthorizationIfNeeded()
122 monitoringSettings.alertsEnabled = granted
123 persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
124 await refreshMonitoringAuthorizationStatus()
125 }
126
127 func runMonitoringNow() {
128 guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
129 upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring)
130 return
131 }
132 guard !monitoringRunInProgress else { return }
133
134 monitoringRunInProgress = true
135 monitoringStatusMessage = nil
136
137 Task { [weak self] in
138 guard let self else { return }
139 let outcome = await DomainMonitoringService.shared.performMonitoring(
140 trigger: .manual,
141 requireEnabledSetting: false
142 )
143 await MainActor.run {
144 self.refreshMonitoringState()
145 self.monitoringRunInProgress = false
146 self.monitoringStatusMessage = outcome.message
147 }
148 }
149 }
150
151 func monitoringIntervalLabel(for trackedDomain: TrackedDomain) -> String {
152 let state = DomainMonitoringScheduler.normalizedMonitoringState(for: trackedDomain, settings: monitoringSettings)
153 return Self.intervalLabel(for: state.currentInterval)
154 }
155
156 func monitoringStatusLabel(for trackedDomain: TrackedDomain) -> String {
157 let state = trackedDomain.monitoringState
158 if let lastChangeDate = state.lastChangeDate,
159 Date().timeIntervalSince(lastChangeDate) <= 6 * 60 * 60 {
160 return "Recently Changed"
161 }
162 if state.consecutiveStableChecks >= 2 {
163 return "Stable"
164 }
165 return trackedDomain.monitoringEnabled ? "Active" : "Paused"
166 }
167
168 private static func intervalLabel(for interval: TimeInterval) -> String {
169 let formatter = DateComponentsFormatter()
170 formatter.allowedUnits = interval < 3600 ? [.minute] : [.hour, .minute]
171 formatter.unitsStyle = .full
172 return formatter.string(from: interval) ?? "Unknown"
173 }
174}
DomainDig/DomainViewModel.swift +2 −163
@@ -1100,167 +1100,6 @@ final class DomainViewModel {
11001100 await refreshMonitoringAuthorizationStatus()
11011101 }
11021102
1103 func refreshMonitoringAuthorizationStatus() async {
1104 let settings = await UNUserNotificationCenter.current().notificationSettings()
1105 monitoringNotificationStatus = settings.authorizationStatus
1106 }
1107
1108 func setMonitoringEnabled(_ isEnabled: Bool) {
1109 guard !isEnabled || FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
1110 monitoringSettings.isEnabled = false
1111 upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring)
1112 return
1113 }
1114
1115 monitoringSettings.isEnabled = isEnabled
1116 persistMonitoringSettings(localActivationConfirmed: isEnabled)
1117 monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
1118 }
1119
1120 func setMonitoringScope(_ scope: MonitoringScope) {
1121 monitoringSettings.scope = scope
1122 persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
1123 }
1124
1125 func setMonitoringBaseInterval(_ baseInterval: MonitoringBaseInterval) {
1126 guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
1127 upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring)
1128 return
1129 }
1130 monitoringSettings.baseInterval = baseInterval.interval
1131 persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
1132 monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
1133 }
1134
1135 func setMonitoringAdaptiveEnabled(_ isEnabled: Bool) {
1136 guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
1137 upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring)
1138 return
1139 }
1140 monitoringSettings.adaptiveEnabled = isEnabled
1141 persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
1142 monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
1143 }
1144
1145 func setMonitoringSensitivity(_ sensitivity: MonitoringSensitivity) {
1146 guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
1147 upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring)
1148 return
1149 }
1150 monitoringSettings.sensitivity = sensitivity
1151 persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
1152 monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
1153 }
1154
1155 func setMonitoringQuietHours(startHour: Int, endHour: Int, isEnabled: Bool) {
1156 guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
1157 upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring)
1158 return
1159 }
1160 monitoringSettings.quietHours = isEnabled ? QuietHours(startHour: startHour, endHour: endHour) : nil
1161 persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
1162 }
1163
1164 func setMonitoringAlertFilter(_ filter: MonitoringAlertFilter) {
1165 guard FeatureAccessService.hasAccess(to: .localAlerts) else {
1166 monitoringSettings.alertsEnabled = false
1167 upgradePrompt = FeatureAccessService.upgradePrompt(for: .localAlerts)
1168 return
1169 }
1170 monitoringSettings.alertFilter = filter
1171 persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
1172 }
1173
1174 func setMonitoringAlertsEnabled(_ isEnabled: Bool) {
1175 guard !isEnabled || FeatureAccessService.hasAccess(to: .localAlerts) else {
1176 monitoringSettings.alertsEnabled = false
1177 upgradePrompt = FeatureAccessService.upgradePrompt(for: .localAlerts)
1178 return
1179 }
1180 monitoringSettings.alertsEnabled = isEnabled
1181 persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
1182 }
1183
1184 func setMonitoringSelection(for trackedDomain: TrackedDomain, isSelected: Bool) {
1185 if isSelected {
1186 if !monitoringSettings.selectedDomainIDs.contains(trackedDomain.id) {
1187 monitoringSettings.selectedDomainIDs.append(trackedDomain.id)
1188 }
1189 } else {
1190 monitoringSettings.selectedDomainIDs.removeAll { $0 == trackedDomain.id }
1191 }
1192 persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
1193 }
1194
1195 func toggleMonitoring(for trackedDomain: TrackedDomain) {
1196 guard canEdit(trackedDomain) else { return }
1197 guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
1198 upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring)
1199 return
1200 }
1201 guard let index = trackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) else { return }
1202 trackedDomains[index].monitoringEnabled.toggle()
1203 trackedDomains[index].updatedAt = Date()
1204 MonitoringStorage.saveTrackedDomains(trackedDomains)
1205 CloudSyncService.shared.scheduleSyncIfNeeded()
1206 sanitizeMonitoringSelection()
1207 }
1208
1209 func requestMonitoringNotificationAuthorization() async {
1210 let granted = await LocalNotificationService.shared.requestAuthorizationIfNeeded()
1211 monitoringSettings.alertsEnabled = granted
1212 persistMonitoringSettings(localActivationConfirmed: monitoringSettings.isEnabled)
1213 await refreshMonitoringAuthorizationStatus()
1214 }
1215
1216 func runMonitoringNow() {
1217 guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
1218 upgradePrompt = FeatureAccessService.upgradePrompt(for: .automatedMonitoring)
1219 return
1220 }
1221 guard !monitoringRunInProgress else { return }
1222
1223 monitoringRunInProgress = true
1224 monitoringStatusMessage = nil
1225
1226 Task { [weak self] in
1227 guard let self else { return }
1228 let outcome = await DomainMonitoringService.shared.performMonitoring(
1229 trigger: .manual,
1230 requireEnabledSetting: false
1231 )
1232 await MainActor.run {
1233 self.refreshMonitoringState()
1234 self.monitoringRunInProgress = false
1235 self.monitoringStatusMessage = outcome.message
1236 }
1237 }
1238 }
1239
1240 func monitoringIntervalLabel(for trackedDomain: TrackedDomain) -> String {
1241 let state = DomainMonitoringScheduler.normalizedMonitoringState(for: trackedDomain, settings: monitoringSettings)
1242 return Self.intervalLabel(for: state.currentInterval)
1243 }
1244
1245 func monitoringStatusLabel(for trackedDomain: TrackedDomain) -> String {
1246 let state = trackedDomain.monitoringState
1247 if let lastChangeDate = state.lastChangeDate,
1248 Date().timeIntervalSince(lastChangeDate) <= 6 * 60 * 60 {
1249 return "Recently Changed"
1250 }
1251 if state.consecutiveStableChecks >= 2 {
1252 return "Stable"
1253 }
1254 return trackedDomain.monitoringEnabled ? "Active" : "Paused"
1255 }
1256
1257 private static func intervalLabel(for interval: TimeInterval) -> String {
1258 let formatter = DateComponentsFormatter()
1259 formatter.allowedUnits = interval < 3600 ? [.minute] : [.hour, .minute]
1260 formatter.unitsStyle = .full
1261 return formatter.string(from: interval) ?? "Unknown"
1262 }
1263
12641103 func rerunLookup(from entry: HistoryEntry, useSnapshotResolver: Bool) {
12651104 if useSnapshotResolver {
12661105 UserDefaults.standard.set(entry.resolverURLString, forKey: DNSResolverOption.userDefaultsKey)
@@ -2590,13 +2429,13 @@ final class DomainViewModel {
25902429 return existingMax + 1
25912430 }
25922431
2593 private func persistMonitoringSettings(localActivationConfirmed: Bool = false) {
2432 func persistMonitoringSettings(localActivationConfirmed: Bool = false) {
25942433 monitoringSettings = MonitoringStorage.sanitizeSettings(monitoringSettings, trackedDomains: trackedDomains)
25952434 MonitoringStorage.saveSettings(monitoringSettings)
25962435 CloudSyncService.shared.markMonitoringSettingsChanged(localActivationConfirmed: localActivationConfirmed)
25972436 }
25982437
2599 private func sanitizeMonitoringSelection() {
2438 func sanitizeMonitoringSelection() {
26002439 monitoringSettings = MonitoringStorage.sanitizeSettings(monitoringSettings, trackedDomains: trackedDomains)
26012440 MonitoringStorage.saveSettings(monitoringSettings)
26022441 CloudSyncService.shared.markMonitoringSettingsChanged(localActivationConfirmed: false)