From 4173ef8735173dfd72853c83fc3b07a765155c5c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 13:42:58 -0400 Subject: [PATCH 01/16] feat(notifications): notification status page in Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings › Advanced › Notifications checks the phone's notification settings and the push registration, offers a fix for each problem, posts a local test notification and shares a plain-text report. PushController now records every AddToken outcome per account instead of dropping it, keeps the last APNs registration error, and returns the full UNNotificationSettings from fetchStatus. --- Flipcash/Core/AppDelegate.swift | 3 + .../Core/Controllers/PushController.swift | 148 +++++- .../Navigation/AppRouter+Destination.swift | 7 +- .../AppRouter+DestinationView.swift | 3 + .../Currency Swap/InlineSwapCompletion.swift | 2 +- .../Onboarding/OnboardingViewModel.swift | 2 +- .../Settings/NotificationStatusScreen.swift | 272 ++++++++++ .../NotificationStatusViewModel.swift | 197 +++++++ .../Screens/Settings/SettingsScreen.swift | 5 + .../Core/Session/SessionAuthenticator.swift | 3 + .../Push/NotificationRegistrationStore.swift | 41 ++ .../Push/NotificationStatus.swift | 482 ++++++++++++++++++ .../Push/NotificationStatusTests.swift | 271 ++++++++++ .../Navigation/AppRouterCrossStackTests.swift | 1 + 14 files changed, 1424 insertions(+), 13 deletions(-) create mode 100644 Flipcash/Core/Screens/Settings/NotificationStatusScreen.swift create mode 100644 Flipcash/Core/Screens/Settings/NotificationStatusViewModel.swift create mode 100644 FlipcashCore/Sources/FlipcashCore/Push/NotificationRegistrationStore.swift create mode 100644 FlipcashCore/Sources/FlipcashCore/Push/NotificationStatus.swift create mode 100644 FlipcashCore/Tests/FlipcashCoreTests/Push/NotificationStatusTests.swift diff --git a/Flipcash/Core/AppDelegate.swift b/Flipcash/Core/AppDelegate.swift index f96792248..2616f4bdd 100644 --- a/Flipcash/Core/AppDelegate.swift +++ b/Flipcash/Core/AppDelegate.swift @@ -314,6 +314,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { logger.error("Push notification registration failed", metadata: ["error": "\(error)"]) + + // Before login there is no session to keep it for; the status page only exists after. + sessionContainer?.pushController.didFailToRegisterForRemoteNotifications(with: error) } } diff --git a/Flipcash/Core/Controllers/PushController.swift b/Flipcash/Core/Controllers/PushController.swift index 235c628b4..edb6ba544 100644 --- a/Flipcash/Core/Controllers/PushController.swift +++ b/Flipcash/Core/Controllers/PushController.swift @@ -43,6 +43,15 @@ class PushController { set { delegate.isArchived = newValue } } + /// Opens the notification status page when the user taps the test notification. + var openNotificationStatus: (@MainActor () -> Void)? { + get { delegate.openNotificationStatus } + set { delegate.openNotificationStatus = newValue } + } + + /// The last `didFailToRegisterForRemoteNotifications` error this launch, for the report. + @ObservationIgnored private(set) var lastAPNsError: String? + @ObservationIgnored private let owner: KeyPair @ObservationIgnored private let client: FlipClient @ObservationIgnored private let center: UNUserNotificationCenter @@ -51,6 +60,7 @@ class PushController { @ObservationIgnored private var apnsToken: Data? @ObservationIgnored private var uploadedFirebaseToken: String? @ObservationIgnored private var activeObserver: Any? + @ObservationIgnored private let registrationStore: NotificationRegistrationStore // MARK: - Init - @@ -59,6 +69,7 @@ class PushController { self.client = client self.center = .current() self.delegate = NotificationDelegate() + self.registrationStore = NotificationRegistrationStore(owner: owner.publicKey) delegate.didReceiveFCMToken = { [weak self] token in try await self?.didReceiveFirebaseToken(token: token) @@ -109,9 +120,16 @@ class PushController { try await didReceiveFirebaseToken(token: token) } } + + /// Called by the app delegate when APNs registration fails. Kept for the status page's report. + func didFailToRegisterForRemoteNotifications(with error: Error) { + lastAPNsError = "\(error)" + } /// Deletes the FCM token from the server and unregisters from APNs. func prepareForLogout() { + registrationStore.clear() + Task { guard uploadedFirebaseToken != nil else { return @@ -148,7 +166,67 @@ class PushController { /// Re-fetches the notification authorization status from the system. func refreshAuthorizationStatus() async { - authorizationStatus = await Self.fetchStatus() + authorizationStatus = await Self.fetchStatus().authorizationStatus + } + + // MARK: - Notification Status - + + /// The last recorded `AddToken` outcome for this account, from any call site. + var savedRegistration: NotificationStatus.RegistrationRecord? { + registrationStore.record + } + + /// The last six characters of the current FCM token, for the report. + var tokenSuffix: String? { + uploadedFirebaseToken.map { String($0.suffix(6)) } + } + + /// Fetches the current FCM token and calls `AddToken` with it, whether or not this token was + /// already uploaded, so the status page sees a fresh answer. Gives up after + /// ``NotificationStatus/registrationTimeout``. + func checkRegistration(deletingToken: Bool = false) async -> NotificationStatus.Registration { + let messaging = Messaging.messaging() + if deletingToken { + do { + try await messaging.deleteToken() + } catch { + ErrorReporting.captureError(error, reason: "Failed to delete local FCM token") + } + } + + let token: String + do { + token = try await Self.withTimeout { try await Messaging.messaging().token() } + } catch { + if messaging.apnsToken == nil { + logger.warning("No APNs token for registration check", metadata: ["error": "\(error)"]) + return .noAPNsToken + } + let result: NotificationStatus.RegistrationResult = error is CancellationError ? .transport : .unknown + ErrorReporting.captureError(error, reason: "Failed to fetch FCM token for registration check") + registrationStore.record = .init(at: .now, result: result, source: .statusPage) + return .finished(result) + } + + return .finished(await upload(token: token, source: .statusPage)) + } + + /// Posts the local test notification ``NotificationStatus/testDelay`` seconds from now. It has no + /// category, so it carries neither the chat Reply action nor the chat content extension. + func scheduleTestNotification() async throws { + let content = UNMutableNotificationContent() + content.title = "Test notification" + content.body = "If you can see this, notifications are working." + content.sound = .default + content.threadIdentifier = NotificationStatus.testThreadIdentifier + content.userInfo = [NotificationStatus.testUserInfoKey: true] + + let request = UNNotificationRequest( + identifier: NotificationStatus.testThreadIdentifier, + content: content, + trigger: UNTimeIntervalNotificationTrigger(timeInterval: NotificationStatus.testDelay, repeats: false) + ) + try await center.add(request) } // MARK: - Firebase - @@ -161,13 +239,54 @@ class PushController { } if uploadedFirebaseToken != token { - try await client.addToken( - token: token, - installationID: try await Self.installationID(), - owner: owner - ) + // A first upload this launch follows login or launch; any later one is a rotation. + let source: NotificationStatus.RegistrationSource = uploadedFirebaseToken == nil ? .login : .refresh + await upload(token: token, source: source) + } + } + + /// Calls `AddToken`, caches the token on success and records the outcome for this account. + @discardableResult + private func upload(token: String, source: NotificationStatus.RegistrationSource) async -> NotificationStatus.RegistrationResult { + let result: NotificationStatus.RegistrationResult + do { + let client = client + let owner = owner + try await Self.withTimeout { + try await client.addToken( + token: token, + installationID: try await Self.installationID(), + owner: owner + ) + } uploadedFirebaseToken = token // Cache uploaded token - + result = .ok + } catch let error as ErrorAddToken { + result = .init(error) + ErrorReporting.captureError(error, reason: "Failed to add push token", metadata: ["source": source.rawValue]) + } catch is CancellationError { + result = .transport + } catch { + result = .unknown + ErrorReporting.captureError(error, reason: "Failed to add push token", metadata: ["source": source.rawValue]) + } + + registrationStore.record = .init(at: .now, result: result, source: source) + logger.info("Recorded push registration", metadata: ["source": "\(source.rawValue)", "result": "\(result.rawValue)"]) + return result + } + + /// Runs `operation`, throwing `CancellationError` if it outlasts ``NotificationStatus/registrationTimeout``. + private static func withTimeout(_ operation: @escaping @Sendable () async throws -> T) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { try await operation() } + group.addTask { + try await Task.sleep(for: NotificationStatus.registrationTimeout) + throw CancellationError() + } + defer { group.cancelAll() } + guard let first = try await group.next() else { throw CancellationError() } + return first } } @@ -231,8 +350,8 @@ extension PushController { return granted } - static func fetchStatus() async -> UNAuthorizationStatus { - await UNUserNotificationCenter.current().notificationSettings().authorizationStatus + static func fetchStatus() async -> UNNotificationSettings { + await UNUserNotificationCenter.current().notificationSettings() } static func installationID() async throws -> String { @@ -277,6 +396,7 @@ private class NotificationDelegate: NSObject, @preconcurrency UNUserNotification var didReceiveFCMToken: (@MainActor (String?) async throws -> Void)? var isViewingConversation: (@MainActor (ConversationID) -> Bool)? var isArchived: (@MainActor (ConversationID) -> Bool)? + var openNotificationStatus: (@MainActor () -> Void)? override init() { super.init() @@ -293,6 +413,11 @@ private class NotificationDelegate: NSObject, @preconcurrency UNUserNotification let userInfo = notification.request.content.userInfo + // The local test notification has no push payload; show it even in the foreground. + if NotificationStatus.isTestNotification(userInfo) { + return [.banner, .list, .sound] + } + Messaging.messaging().appDidReceiveMessage(userInfo) postContactJoinIfNeeded(userInfo) @@ -330,6 +455,11 @@ private class NotificationDelegate: NSObject, @preconcurrency UNUserNotification func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { logger.info("Received notification response", metadata: ["action": "\(response.actionIdentifier)"]) + if NotificationStatus.isTestNotification(response.notification.request.content.userInfo) { + openNotificationStatus?() + return + } + Messaging.messaging().appDidReceiveMessage(response.notification.request.content.userInfo) postContactJoinIfNeeded(response.notification.request.content.userInfo) diff --git a/Flipcash/Core/Navigation/AppRouter+Destination.swift b/Flipcash/Core/Navigation/AppRouter+Destination.swift index ed419f5f7..55cca9953 100644 --- a/Flipcash/Core/Navigation/AppRouter+Destination.swift +++ b/Flipcash/Core/Navigation/AppRouter+Destination.swift @@ -96,6 +96,8 @@ extension AppRouter { case settingsAppSettings case settingsAccountSelection case settingsApplicationLogs + /// The notification status page, opened from Settings › Advanced or by tapping the test notification. + case settingsNotificationStatus case blockedUsers case trustedWebsites case accessKey @@ -180,7 +182,7 @@ extension AppRouter { .changeDisplayName, .changeProfilePicture, .username, .setMinimumTip, .settingsAdvancedBetaFeatures, .settingsAppSettings, .settingsAccountSelection, - .settingsApplicationLogs, .blockedUsers, .trustedWebsites, .accessKey, .withdraw: + .settingsApplicationLogs, .settingsNotificationStatus, .blockedUsers, .trustedWebsites, .accessKey, .withdraw: return .you case .profileName, .profilePhoto, .tipcard, .usernameLookup, .newChat, .newPublicGroup, .newPrivateGroup, .tipConversation, .tipConversationWithKeyboard, .chatMessage, .userProfile, .chatProfile, @@ -230,6 +232,7 @@ extension AppRouter { case .settingsAppSettings: "settingsAppSettings" case .settingsAccountSelection: "settingsAccountSelection" case .settingsApplicationLogs: "settingsApplicationLogs" + case .settingsNotificationStatus: "settingsNotificationStatus" case .blockedUsers: "blockedUsers" case .trustedWebsites: "trustedWebsites" case .accessKey: "accessKey" @@ -307,7 +310,7 @@ extension AppRouter { .settings, .accountInfo, .editProfile, .editBio, .editFeaturedGroups, .changeCoverPicture, .changeDisplayName, .changeProfilePicture, .settingsAdvancedBetaFeatures, .settingsAppSettings, .settingsAccountSelection, - .settingsApplicationLogs, .blockedUsers, .trustedWebsites, .accessKey, .withdraw, + .settingsApplicationLogs, .settingsNotificationStatus, .blockedUsers, .trustedWebsites, .accessKey, .withdraw, .profileName, .profilePhoto, .tipcard, .usernameLookup, .newChat, .newPublicGroup, .newPrivateGroup, .archivedChats, .chatSearch, .requests, .groupInvite: return nil diff --git a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift index 91f1000e5..2576c1dcf 100644 --- a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift +++ b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift @@ -172,6 +172,9 @@ struct DestinationView: View { case .settingsApplicationLogs: ApplicationLogsScreen() + case .settingsNotificationStatus: + NotificationStatusScreen() + case .blockedUsers: BlockedUsersScreen() diff --git a/Flipcash/Core/Screens/Main/Currency Swap/InlineSwapCompletion.swift b/Flipcash/Core/Screens/Main/Currency Swap/InlineSwapCompletion.swift index 345069a0d..4903fafa1 100644 --- a/Flipcash/Core/Screens/Main/Currency Swap/InlineSwapCompletion.swift +++ b/Flipcash/Core/Screens/Main/Currency Swap/InlineSwapCompletion.swift @@ -211,7 +211,7 @@ struct NotificationPrompt { static let shownKey = "flipcash-transaction-notify-prompt-shown" var defaults: UserDefaults = .standard - var status: () async -> UNAuthorizationStatus = { await PushController.fetchStatus() } + var status: () async -> UNAuthorizationStatus = { await PushController.fetchStatus().authorizationStatus } var authorize: () async -> Void = { _ = try? await PushController.authorizeAndRegister() } var hasShown: Bool { defaults.bool(forKey: Self.shownKey) } diff --git a/Flipcash/Core/Screens/Onboarding/OnboardingViewModel.swift b/Flipcash/Core/Screens/Onboarding/OnboardingViewModel.swift index fc21cddf4..0eb7ab67f 100644 --- a/Flipcash/Core/Screens/Onboarding/OnboardingViewModel.swift +++ b/Flipcash/Core/Screens/Onboarding/OnboardingViewModel.swift @@ -52,7 +52,7 @@ class OnboardingViewModel { /// - finishLogin: opens the session once onboarding is done. init( container: Container, - pushStatus: @escaping () async -> UNAuthorizationStatus = { await PushController.fetchStatus() }, + pushStatus: @escaping () async -> UNAuthorizationStatus = { await PushController.fetchStatus().authorizationStatus }, hasDisplayName: ((InitializedAccount) async -> Bool)? = nil, finishLogin: ((InitializedAccount) -> Void)? = nil ) { diff --git a/Flipcash/Core/Screens/Settings/NotificationStatusScreen.swift b/Flipcash/Core/Screens/Settings/NotificationStatusScreen.swift new file mode 100644 index 000000000..1379400f5 --- /dev/null +++ b/Flipcash/Core/Screens/Settings/NotificationStatusScreen.swift @@ -0,0 +1,272 @@ +// +// NotificationStatusScreen.swift +// Flipcash +// + +import SwiftUI +import FlipcashUI +import FlipcashCore + +/// Settings › Advanced › Notifications: whether notifications can reach this phone, what to fix +/// when they can't, and a local test notification. +struct NotificationStatusScreen: View { + + @Environment(PushController.self) private var pushController + @Environment(\.scenePhase) private var scenePhase + + @State private var model: NotificationStatusViewModel? + + var body: some View { + Background(color: .backgroundMain) { + if let model, let page = model.page { + content(model: model, page: page) + } else { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .navigationTitle("Notifications") + .toolbarTitleDisplayMode(.inline) + .task { + let model = model ?? NotificationStatusViewModel(pushController: pushController) + self.model = model + await model.runChecks() + } + .onChange(of: scenePhase) { _, phase in + // Coming back from Settings after a fix re-runs every check. + guard phase == .active, let model else { return } + Task { await model.runChecks() } + } + .onDisappear { + model?.stop() + } + } + + @ViewBuilder + private func content(model: NotificationStatusViewModel, page: NotificationStatus.Page) -> some View { + VStack(spacing: 0) { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + SummaryCard(summary: page.summary) + .padding(.top, 20) + + if let foldedTitle = page.foldedTitle, !model.isExpanded { + FoldedRow(title: foldedTitle) { + model.isExpanded = true + } + .padding(.top, 12) + } else { + ForEach(NotificationStatus.Group.allCases, id: \.self) { group in + let rows = page.rows(in: group) + if !rows.isEmpty { + SettingsSectionHeader(group.title) + .padding(.horizontal, -20) + CheckGroup(rows: rows) { fix in + Task { await model.perform(fix) } + } + } + } + } + } + .padding(.horizontal, 20) + } + + footer(model: model, page: page) + } + } + + @ViewBuilder + private func footer(model: NotificationStatusViewModel, page: NotificationStatus.Page) -> some View { + VStack(spacing: 12) { + Button(model.isTestSent ? "Send again" : "Send test notification") { + Task { await model.sendTest() } + } + .buttonStyle(model.isTestSent ? .filled20 : .filled) + .disabled(!page.isTestEnabled) + .accessibilityIdentifier("notification-status-send-test") + + ShareLink(item: model.report) { + Text("Share report") + } + .buttonStyle(.subtle) + + Group { + if !page.isTestEnabled { + Text("Turn on notifications to send a test.") + .foregroundStyle(Color.textSecondary) + } else if model.isTestSent { + Text("Sent. Lock your phone in the next 3 seconds to see it on the Lock Screen.") + .foregroundStyle(Color.textSuccess) + } else { + Text("The test is posted by this phone. It shows that notifications can appear here, not that Flipcash's servers can reach you.") + .foregroundStyle(Color.textSecondary) + } + } + .font(.appTextCaption) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 20) + .padding(.bottom, 20) + } +} + +// MARK: - Summary - + +private struct SummaryCard: View { + + let summary: NotificationStatus.Summary + + var body: some View { + HStack(spacing: 12) { + StatusMark(status: summary.status, size: .large) + + VStack(alignment: .leading, spacing: 2) { + Text(summary.title) + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + if let detail = summary.detail { + Text(detail) + .font(.appTextCaption) + .foregroundStyle(Color.textSecondary) + } + } + Spacer(minLength: 0) + } + .padding(16) + .background(Color.backgroundRow, in: RoundedRectangle(cornerRadius: Metrics.boxRadius, style: .continuous)) + .accessibilityElement(children: .combine) + } +} + +// MARK: - Rows - + +private struct FoldedRow: View { + + let title: String + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 12) { + StatusMark(status: .pass, size: .small) + Text(title) + .font(.appTextMessage) + .foregroundStyle(Color.textMain) + Spacer(minLength: 0) + Image(systemName: "chevron.down") + .foregroundStyle(Color.textSecondary) + } + .padding(16) + .background(Color.backgroundRow, in: RoundedRectangle(cornerRadius: Metrics.boxRadius, style: .continuous)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier("notification-status-folded") + } +} + +private struct CheckGroup: View { + + let rows: [NotificationStatus.Row] + let onFix: (NotificationStatus.Fix) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + ForEach(rows) { row in + CheckRow(row: row, onFix: onFix) + } + } + .padding(16) + .background(Color.backgroundRow, in: RoundedRectangle(cornerRadius: Metrics.boxRadius, style: .continuous)) + } +} + +private struct CheckRow: View { + + let row: NotificationStatus.Row + let onFix: (NotificationStatus.Fix) -> Void + + var body: some View { + HStack(spacing: 12) { + StatusMark(status: row.status, size: .small) + + VStack(alignment: .leading, spacing: 2) { + Text(row.title) + .font(.appTextMessage) + .foregroundStyle(Color.textMain) + if let detail = row.detail { + Text(detail) + .font(.appTextCaption) + .foregroundStyle(Color.textSecondary) + } + } + .accessibilityElement(children: .combine) + + Spacer(minLength: 0) + + if let fix = row.fix, let fixTitle = row.fixTitle { + Button(fixTitle) { onFix(fix) } + .font(.appTextSmall) + .foregroundStyle(Color.textAction) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(Capsule().fill(Color.action)) + .buttonStyle(.plain) + .accessibilityIdentifier("notification-status-fix-\(row.check.rawValue)") + } + } + } +} + +// MARK: - Status Mark - + +private struct StatusMark: View { + + enum Size { + case small + case large + + var width: CGFloat { self == .small ? 16 : 26 } + var height: CGFloat { self == .small ? 24 : 40 } + var glyph: Font { self == .small ? .appTextCaption : .appTextMedium } + } + + let status: NotificationStatus.Status + let size: Size + + var body: some View { + ZStack { + Capsule() + .fill(color) + if status == .checking { + ProgressView() + .controlSize(.mini) + .tint(Color.textAction) + } else { + Image(systemName: glyph) + .font(size.glyph) + .foregroundStyle(Color.textAction) + } + } + .frame(width: size.width, height: size.height) + .accessibilityLabel(Text(status.rawValue)) + } + + private var color: Color { + switch status { + case .pass: .textSuccess + case .warning: .textWarning + case .fail: .textError + case .checking, .unknown: .textSecondary + } + } + + private var glyph: String { + switch status { + case .pass: "checkmark" + case .warning, .fail: "exclamationmark" + case .checking, .unknown: "questionmark" + } + } +} diff --git a/Flipcash/Core/Screens/Settings/NotificationStatusViewModel.swift b/Flipcash/Core/Screens/Settings/NotificationStatusViewModel.swift new file mode 100644 index 000000000..2cae6a000 --- /dev/null +++ b/Flipcash/Core/Screens/Settings/NotificationStatusViewModel.swift @@ -0,0 +1,197 @@ +// +// NotificationStatusViewModel.swift +// Flipcash +// + +import UIKit +import FlipcashCore +import UserNotifications + +/// Runs the notification status page's checks and holds what the page shows. The rules live in +/// ``NotificationStatus``; this type gathers the inputs and acts on the fixes. +@Observable +final class NotificationStatusViewModel { + + private(set) var snapshot: NotificationStatus.Snapshot? + private(set) var registration: NotificationStatus.Registration = .checking + /// Whether the folded "N checks passed" row has been opened. Lasts until the page closes. + var isExpanded = false + /// Whether a test notification was scheduled since the page opened. + private(set) var isTestSent = false + + @ObservationIgnored private let pushController: PushController + @ObservationIgnored private var registrationTask: Task? + + init(pushController: PushController) { + self.pushController = pushController + } + + /// `nil` until the phone's settings have been read once. + var page: NotificationStatus.Page? { + snapshot.map { + NotificationStatus.evaluate( + snapshot: $0, + registration: registration, + saved: pushController.savedRegistration, + now: .now + ) + } + } + + var report: String { + NotificationStatus.report( + snapshot: snapshot ?? .init(authorization: .notDetermined), + registration: registration, + saved: pushController.savedRegistration, + context: .init( + appVersion: Self.appVersion, + osVersion: UIDevice.current.systemVersion, + deviceModel: Self.deviceModel, + tokenSuffix: pushController.tokenSuffix, + lastAPNsError: pushController.lastAPNsError + ), + now: .now + ) + } + + // MARK: - Checks - + + /// Reads the phone's settings and re-registers, unless a registration is already running. + func runChecks() async { + await refreshSettings() + if registrationTask == nil { + register() + } + } + + /// Marks an unfinished registration as interrupted when the page goes away. + func stop() { + guard let registrationTask else { return } + registrationTask.cancel() + self.registrationTask = nil + registration = .interrupted + } + + private func refreshSettings() async { + let settings = await PushController.fetchStatus() + snapshot = NotificationStatus.Snapshot(settings) + } + + private func register(deletingToken: Bool = false) { + registrationTask?.cancel() + registration = .checking + registrationTask = Task { [pushController] in + let result = await pushController.checkRegistration(deletingToken: deletingToken) + guard !Task.isCancelled else { return } + registration = result + registrationTask = nil + } + } + + // MARK: - Actions - + + func perform(_ fix: NotificationStatus.Fix) async { + switch fix { + case .openNotificationSettings: + guard let url = URL(string: UIApplication.openNotificationSettingsURLString) else { return } + await UIApplication.shared.open(url) + case .requestAuthorization: + do { + try await PushController.authorizeAndRegister() + } catch { + ErrorReporting.captureError(error, reason: "Failed to request notification authorization") + } + await runChecks() + case .retryRegistration: + register() + case .retryRegistrationDeletingToken: + register(deletingToken: true) + } + } + + /// Re-reads the permission first: if it went away while the page was open, nothing is sent + /// and the page shows notifications as off. + func sendTest() async { + await refreshSettings() + guard page?.isTestEnabled == true else { return } + + do { + try await pushController.scheduleTestNotification() + isTestSent = true + } catch { + ErrorReporting.captureError(error, reason: "Failed to schedule test notification") + } + } + + // MARK: - Device - + + private static var appVersion: String { + let info = Bundle.main.infoDictionary + let version = info?["CFBundleShortVersionString"] as? String ?? "?" + let build = info?["CFBundleVersion"] as? String ?? "?" + return "\(version) (\(build))" + } + + /// The hardware identifier, such as "iPhone17,1". The simulator reports the device it emulates. + private static var deviceModel: String { + if let simulated = ProcessInfo.processInfo.environment["SIMULATOR_MODEL_IDENTIFIER"] { + return simulated + } + var info = utsname() + uname(&info) + return withUnsafeBytes(of: &info.machine) { bytes in + String(decoding: bytes.prefix { $0 != 0 }, as: UTF8.self) + } + } +} + +// MARK: - Snapshot - + +extension NotificationStatus.Snapshot { + + init(_ settings: UNNotificationSettings) { + self.init( + authorization: .init(settings.authorizationStatus), + alert: .init(settings.alertSetting), + lockScreen: .init(settings.lockScreenSetting), + sound: .init(settings.soundSetting), + badge: .init(settings.badgeSetting), + alertStyle: .init(settings.alertStyle) + ) + } +} + +private extension NotificationStatus.Snapshot.Authorization { + init(_ status: UNAuthorizationStatus) { + switch status { + case .notDetermined: self = .notDetermined + case .denied: self = .denied + case .authorized: self = .authorized + case .provisional: self = .provisional + case .ephemeral: self = .ephemeral + @unknown default: self = .authorized + } + } +} + +private extension NotificationStatus.Snapshot.Setting { + init(_ setting: UNNotificationSetting) { + switch setting { + case .enabled: self = .enabled + case .disabled: self = .disabled + case .notSupported: self = .notSupported + @unknown default: self = .notSupported + } + } +} + +private extension NotificationStatus.Snapshot.AlertStyle { + init(_ style: UNAlertStyle) { + switch style { + case .none: self = .none + case .banner: self = .banner + case .alert: self = .alert + @unknown default: self = .banner + } + } +} diff --git a/Flipcash/Core/Screens/Settings/SettingsScreen.swift b/Flipcash/Core/Screens/Settings/SettingsScreen.swift index 7978bdc69..4f129a207 100644 --- a/Flipcash/Core/Screens/Settings/SettingsScreen.swift +++ b/Flipcash/Core/Screens/Settings/SettingsScreen.swift @@ -121,6 +121,11 @@ struct SettingsScreen: View { router.push(.settingsApplicationLogs) } + SettingsRow(systemImage: "bell", title: "Notifications", insets: insets) { + router.push(.settingsNotificationStatus) + } + .accessibilityIdentifier("settings-notifications-row") + if betaFlags.canSwitchAccounts { SettingsRow(asset: .switchAccounts, title: "Switch Accounts", badge: .beta, insets: insets) { router.push(.settingsAccountSelection) diff --git a/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index f53429e13..212bf5f0d 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -1054,6 +1054,9 @@ final class SessionContainer { pushController.isArchived = { [weak conversationController] conversationID in conversationController?.isArchived(conversationID) == true } + pushController.openNotificationStatus = { [weak appRouter] in + appRouter?.navigate(to: .settingsNotificationStatus) + } let chatSpotlightIndexer = ChatSpotlightIndexer( controller: conversationController, diff --git a/FlipcashCore/Sources/FlipcashCore/Push/NotificationRegistrationStore.swift b/FlipcashCore/Sources/FlipcashCore/Push/NotificationRegistrationStore.swift new file mode 100644 index 000000000..90028a6fd --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Push/NotificationRegistrationStore.swift @@ -0,0 +1,41 @@ +// +// NotificationRegistrationStore.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation + +/// Keeps the last `AddToken` outcome for one account, so the notification status page can report +/// it and soften a transport failure. Never stores the token itself. +public final class NotificationRegistrationStore { + + private let defaults: UserDefaults + private let key: String + + public init(defaults: UserDefaults = .standard, owner: PublicKey) { + self.defaults = defaults + self.key = "flipcash-\(owner.base58)-notification-registration" + } + + /// The saved outcome. Unreadable data is treated as none. + public var record: NotificationStatus.RegistrationRecord? { + get { + guard let data = defaults.data(forKey: key) else { return nil } + return try? JSONDecoder().decode(NotificationStatus.RegistrationRecord.self, from: data) + } + set { + guard let newValue, let data = try? JSONEncoder().encode(newValue) else { + defaults.removeObject(forKey: key) + return + } + defaults.set(data, forKey: key) + } + } + + /// Removes the saved outcome. Called on logout. + public func clear() { + defaults.removeObject(forKey: key) + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Push/NotificationStatus.swift b/FlipcashCore/Sources/FlipcashCore/Push/NotificationStatus.swift new file mode 100644 index 000000000..97949d82f --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Push/NotificationStatus.swift @@ -0,0 +1,482 @@ +// +// NotificationStatus.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation + +/// The Settings › Advanced › Notifications page, as a pure function from what the phone reports +/// and how the last push registration went to the rows, summary and report the page shows. +/// +/// Free of `UserNotifications` and UIKit types so the same rules can move to shared code later; +/// the app maps `UNNotificationSettings` into ``Snapshot`` at the edge. +public enum NotificationStatus { + + /// Marks the local test notification in its `userInfo`, so the notification delegate can + /// recognise it before trying to decode a push payload it doesn't carry. + public static let testUserInfoKey = "flipcash_test" + + /// Groups the test notification in Notification Center. + public static let testThreadIdentifier = "notification-test" + + /// Seconds between the tap and the test notification. + public static let testDelay: TimeInterval = 3 + + /// How long a registration may take before it counts as unreachable. + public static let registrationTimeout: Duration = .seconds(10) + + /// Whether `userInfo` belongs to the local test notification. + public static func isTestNotification(_ userInfo: [AnyHashable: Any]) -> Bool { + userInfo[testUserInfoKey] as? Bool == true + } +} + +// MARK: - Input - + +extension NotificationStatus { + + /// The phone's notification settings for this app, mirroring `UNNotificationSettings`. + public struct Snapshot: Equatable, Sendable { + + public enum Authorization: String, Sendable { + case notDetermined + case denied + case authorized + case provisional + case ephemeral + } + + public enum Setting: String, Sendable { + case enabled + case disabled + /// The device can't have this setting; it is never treated as off. + case notSupported + + var isOff: Bool { + switch self { + case .disabled: true + case .enabled, .notSupported: false + } + } + } + + public enum AlertStyle: String, Sendable { + case none + case banner + case alert + } + + public var authorization: Authorization + public var alert: Setting + public var lockScreen: Setting + public var sound: Setting + public var badge: Setting + public var alertStyle: AlertStyle + + public init( + authorization: Authorization, + alert: Setting = .enabled, + lockScreen: Setting = .enabled, + sound: Setting = .enabled, + badge: Setting = .enabled, + alertStyle: AlertStyle = .banner + ) { + self.authorization = authorization + self.alert = alert + self.lockScreen = lockScreen + self.sound = sound + self.badge = badge + self.alertStyle = alertStyle + } + } + + /// How an `AddToken` call ended. Persisted, so the raw values are stable. + public enum RegistrationResult: String, Codable, Sendable { + case ok + case invalidPushToken + case transport + case unknown + + /// Classifies a failed `AddToken` call. A dropped or cancelled call couldn't reach + /// Flipcash; anything else the server didn't accept is unknown. + public init(_ error: ErrorAddToken) { + switch error { + case .invalidPushToken: self = .invalidPushToken + case .transportFailure, .cancelled: self = .transport + case .ok, .unknown, .rejected: self = .unknown + } + } + } + + /// Which path made an `AddToken` call. Persisted, so the raw values are stable. + public enum RegistrationSource: String, Codable, Sendable { + case login + case refresh + case statusPage + } + + /// The last `AddToken` outcome from any call site, saved per account. + public struct RegistrationRecord: Codable, Equatable, Sendable { + public var at: Date + public var result: RegistrationResult + public var source: RegistrationSource + + public init(at: Date, result: RegistrationResult, source: RegistrationSource) { + self.at = at + self.result = result + self.source = source + } + } + + /// Where the page's own registration check stands. + public enum Registration: Equatable, Sendable { + /// The check is running. + case checking + /// The check stopped before it had an answer. + case interrupted + /// Firebase had no token to give, because the phone has no APNs token. + case noAPNsToken + /// `AddToken` answered, or timed out. + case finished(RegistrationResult) + } +} + +// MARK: - Output - + +extension NotificationStatus { + + public enum Status: String, Sendable { + case pass + case warning + case fail + case checking + case unknown + } + + public enum Check: String, CaseIterable, Sendable { + /// i1 + case permission + /// i2 + case display + /// i3 + case soundsAndBadges + /// i4 + case registration + + /// The check's name, used in the report and the passing row. + public var name: String { + switch self { + case .permission: "Notifications allowed" + case .display: "Banners and Lock Screen" + case .soundsAndBadges: "Sounds and badges" + case .registration: "Registered for push" + } + } + + public var group: Group { + switch self { + case .permission, .display, .soundsAndBadges: .phone + case .registration: .flipcash + } + } + } + + public enum Group: String, CaseIterable, Sendable { + case phone + case flipcash + + public var title: String { + switch self { + case .phone: "ON THIS PHONE" + case .flipcash: "WITH FLIPCASH" + } + } + } + + /// What a row's trailing button does. + public enum Fix: Equatable, Sendable { + /// Opens this app's notification settings in the Settings app. + case openNotificationSettings + /// Shows the system permission prompt in the app. + case requestAuthorization + /// Runs the registration again. + case retryRegistration + /// Deletes the local push token, then runs the registration again. + case retryRegistrationDeletingToken + } + + public struct Row: Equatable, Identifiable, Sendable { + public var check: Check + public var status: Status + public var title: String + public var detail: String? + public var fix: Fix? + /// The raw value behind the status, for the report. + public var raw: String? + + public var id: Check { check } + + /// The label on the fix button. The permission row says "Turn on" whether it opens + /// Settings or asks in the app. + public var fixTitle: String? { + guard let fix else { return nil } + if check == .permission { return "Turn on" } + switch fix { + case .openNotificationSettings, .requestAuthorization: + return "Fix" + case .retryRegistration, .retryRegistrationDeletingToken: + return "Try again" + } + } + } + + public struct Summary: Equatable, Sendable { + public var status: Status + public var title: String + public var detail: String? + } + + public struct Page: Equatable, Sendable { + /// Visible rows in check order. Rows that don't apply are already left out. + public var rows: [Row] + public var summary: Summary + /// Whether "Send test notification" can be tapped. + public var isTestEnabled: Bool + /// The title of the single row that replaces both groups when every check passes. + public var foldedTitle: String? + + public func rows(in group: Group) -> [Row] { + rows.filter { $0.check.group == group } + } + } +} + +// MARK: - Evaluate - + +extension NotificationStatus { + + /// Builds the page from the phone's settings and the registration check. + /// + /// `saved` is the last recorded `AddToken` outcome; it softens a transport failure into a + /// warning when the server most likely still holds a good registration. + public static func evaluate( + snapshot: Snapshot, + registration: Registration, + saved: RegistrationRecord?, + now: Date, + locale: Locale = .current + ) -> Page { + let permission = permissionRow(snapshot) + let permissionFails = permission.status == .fail + + var rows = [permission] + if !permissionFails { + rows.append(displayRow(snapshot)) + rows.append(soundsAndBadgesRow(snapshot)) + } + rows.append(registrationRow(registration, saved: saved, now: now, locale: locale)) + + let allPass = rows.allSatisfy { $0.status == .pass } + + return Page( + rows: rows, + summary: summary(rows, registration: registration), + isTestEnabled: !permissionFails, + foldedTitle: allPass ? plural(rows.count, "check passed", "checks passed") : nil + ) + } + + static func permissionRow(_ snapshot: Snapshot) -> Row { + let raw = snapshot.authorization.rawValue + switch snapshot.authorization { + case .authorized, .provisional, .ephemeral: + return Row(check: .permission, status: .pass, title: Check.permission.name, detail: "Allowed in iOS Settings", raw: raw) + case .denied: + return Row(check: .permission, status: .fail, title: "Notifications are turned off", detail: "Turned off for Flipcash in iOS Settings", fix: .openNotificationSettings, raw: raw) + case .notDetermined: + return Row(check: .permission, status: .fail, title: "Notifications aren't turned on yet", detail: "Flipcash hasn't asked yet", fix: .requestAuthorization, raw: raw) + } + } + + static func displayRow(_ snapshot: Snapshot) -> Row { + let bannersOff = snapshot.alert.isOff || snapshot.alertStyle == .none + let lockScreenOff = snapshot.lockScreen.isOff + + var off: [String] = [] + if snapshot.alert.isOff { off.append("alert disabled") } + if snapshot.alertStyle == .none { off.append("alertStyle none") } + if lockScreenOff { off.append("lockScreen disabled") } + let raw = off.isEmpty ? nil : off.joined(separator: ", ") + + if bannersOff { + return Row(check: .display, status: .warning, title: "Banners are off", detail: "New notifications only go to Notification Center", fix: .openNotificationSettings, raw: raw) + } + if lockScreenOff { + return Row(check: .display, status: .warning, title: "Hidden on the Lock Screen", fix: .openNotificationSettings, raw: raw) + } + return Row(check: .display, status: .pass, title: Check.display.name, detail: "Both on", raw: raw) + } + + static func soundsAndBadgesRow(_ snapshot: Snapshot) -> Row { + let title: String? = switch (snapshot.sound.isOff, snapshot.badge.isOff) { + case (true, true): "Sounds and badges are off" + case (true, false): "Sounds are off" + case (false, true): "Badges are off" + case (false, false): nil + } + + var off: [String] = [] + if snapshot.sound.isOff { off.append("sound disabled") } + if snapshot.badge.isOff { off.append("badge disabled") } + let raw = off.isEmpty ? nil : off.joined(separator: ", ") + + guard let title else { + return Row(check: .soundsAndBadges, status: .pass, title: Check.soundsAndBadges.name, detail: "Both on", raw: raw) + } + return Row(check: .soundsAndBadges, status: .warning, title: title, fix: .openNotificationSettings, raw: raw) + } + + static func registrationRow(_ registration: Registration, saved: RegistrationRecord?, now: Date, locale: Locale) -> Row { + let name = Check.registration.name + switch registration { + case .checking: + return Row(check: .registration, status: .checking, title: "Checking registration…", detail: "Contacting Flipcash", raw: "checking") + case .interrupted: + return Row(check: .registration, status: .unknown, title: name, detail: "Check was interrupted", fix: .retryRegistration, raw: "interrupted") + case .noAPNsToken: + return Row(check: .registration, status: .fail, title: "This phone isn't registered with Apple", fix: .retryRegistration, raw: "noAPNsToken") + case .finished(.ok): + return Row(check: .registration, status: .pass, title: name, detail: "Confirmed just now", raw: RegistrationResult.ok.rawValue) + case .finished(.invalidPushToken): + return Row(check: .registration, status: .fail, title: "Flipcash rejected this phone's push token", fix: .retryRegistrationDeletingToken, raw: RegistrationResult.invalidPushToken.rawValue) + case .finished(.transport): + let raw = RegistrationResult.transport.rawValue + if let saved, saved.result == .ok { + return Row(check: .registration, status: .warning, title: "Couldn't reach Flipcash", detail: "Last confirmed \(relative(saved.at, to: now, locale: locale))", fix: .retryRegistration, raw: raw) + } + return Row(check: .registration, status: .fail, title: "Couldn't reach Flipcash", detail: "Check your connection", fix: .retryRegistration, raw: raw) + case .finished(.unknown): + return Row(check: .registration, status: .fail, title: "Registration failed", fix: .retryRegistration, raw: RegistrationResult.unknown.rawValue) + } + } + + static func summary(_ rows: [Row], registration: Registration) -> Summary { + let fails = rows.filter { $0.status == .fail } + let warnings = rows.filter { $0.status == .warning } + let checked = "Checked just now" + + if !fails.isEmpty { + let title = fails.contains { $0.check == .permission } + ? "Notifications are off" + : plural(fails.count, "thing to fix", "things to fix") + return Summary(status: .fail, title: title, detail: checked) + } + if !warnings.isEmpty { + return Summary(status: .warning, title: plural(warnings.count, "thing to check", "things to check"), detail: checked) + } + if rows.contains(where: { $0.status == .checking }) { + return Summary(status: .checking, title: "Checking…", detail: nil) + } + if rows.contains(where: { $0.status == .unknown }) { + return Summary(status: .unknown, title: "Almost done checking", detail: "Push registration not confirmed yet") + } + return Summary(status: .pass, title: "Notifications are working", detail: checked) + } + + static func plural(_ count: Int, _ one: String, _ many: String) -> String { + "\(count) \(count == 1 ? one : many)" + } + + static func relative(_ date: Date, to now: Date, locale: Locale) -> String { + let formatter = RelativeDateTimeFormatter() + formatter.locale = locale + formatter.unitsStyle = .full + return formatter.localizedString(for: date, relativeTo: now) + } +} + +// MARK: - Report - + +extension NotificationStatus { + + /// What the report says about the device and app, beyond the checks themselves. + public struct ReportContext: Sendable { + public var appVersion: String + public var osVersion: String + public var deviceModel: String + /// The last six characters of the FCM token, or `nil` when there is none. + public var tokenSuffix: String? + /// The last `didFailToRegisterForRemoteNotifications` error, if any. + public var lastAPNsError: String? + + public init(appVersion: String, osVersion: String, deviceModel: String, tokenSuffix: String?, lastAPNsError: String?) { + self.appVersion = appVersion + self.osVersion = osVersion + self.deviceModel = deviceModel + self.tokenSuffix = tokenSuffix + self.lastAPNsError = lastAPNsError + } + } + + /// The plain-text report for support. Lists every check, including the ones the page hides, + /// with the raw value behind each status. Never includes the full token or any account id. + public static func report( + snapshot: Snapshot, + registration: Registration, + saved: RegistrationRecord?, + context: ReportContext, + now: Date, + timeZone: TimeZone = .current + ) -> String { + let rows = [ + permissionRow(snapshot), + displayRow(snapshot), + soundsAndBadgesRow(snapshot), + registrationRow(registration, saved: saved, now: now, locale: Locale(identifier: "en_US_POSIX")), + ] + + var lines = [ + "Flipcash notification report", + "\(timestamp(now, timeZone: timeZone)) (\(utcOffset(timeZone, at: now)))", + "App \(context.appVersion) · iOS \(context.osVersion) · \(context.deviceModel)", + "", + ] + + for row in rows { + var raw = row.raw + if row.check == .registration, let saved { + let at = timestamp(saved.at, timeZone: timeZone) + raw = row.status == .pass + ? "\(saved.source.rawValue), \(at)" + : [raw, "last \(saved.result.rawValue) via \(saved.source.rawValue), \(at)"].compactMap { $0 }.joined(separator: "; ") + } + let value = raw.map { " (\($0))" } ?? "" + lines.append("\(row.check.name): \(row.status.rawValue)\(value)") + } + + lines.append("") + lines.append("Token: \(context.tokenSuffix.map { "…\($0) (last 6)" } ?? "none")") + lines.append("Last APNs error: \(context.lastAPNsError ?? "none")") + + return lines.joined(separator: "\n") + } + + static func timestamp(_ date: Date, timeZone: TimeZone) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = timeZone + formatter.dateFormat = "yyyy-MM-dd HH:mm" + return formatter.string(from: date) + } + + static func utcOffset(_ timeZone: TimeZone, at date: Date) -> String { + let seconds = timeZone.secondsFromGMT(for: date) + let sign = seconds < 0 ? "-" : "+" + let hours = abs(seconds) / 3600 + let minutes = abs(seconds) % 3600 / 60 + guard seconds != 0 else { return "UTC" } + return minutes == 0 ? "UTC\(sign)\(hours)" : "UTC\(sign)\(hours):\(String(format: "%02d", minutes))" + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/Push/NotificationStatusTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/Push/NotificationStatusTests.swift new file mode 100644 index 000000000..279d19220 --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/Push/NotificationStatusTests.swift @@ -0,0 +1,271 @@ +// +// NotificationStatusTests.swift +// FlipcashCoreTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import Testing +@testable import FlipcashCore + +/// The notification status page's rules, one test per iOS row of the spec's Test cases table. +@Suite("Notification status") +struct NotificationStatusTests { + + private static let now = Date(timeIntervalSince1970: 1_793_200_000) + private static let allowed = NotificationStatus.Snapshot(authorization: .authorized) + + private static func page( + _ snapshot: NotificationStatus.Snapshot = allowed, + registration: NotificationStatus.Registration = .finished(.ok), + saved: NotificationStatus.RegistrationRecord? = nil + ) -> NotificationStatus.Page { + NotificationStatus.evaluate( + snapshot: snapshot, + registration: registration, + saved: saved, + now: now, + locale: Locale(identifier: "en_US") + ) + } + + private static func row(_ check: NotificationStatus.Check, in page: NotificationStatus.Page) -> NotificationStatus.Row? { + page.rows.first { $0.check == check } + } + + @Test("1: every check passes folds into one row and reads as working") + func allPass() { + let page = Self.page() + + #expect(page.summary.status == .pass) + #expect(page.summary.title == "Notifications are working") + #expect(page.summary.detail == "Checked just now") + #expect(page.foldedTitle == "4 checks passed") + #expect(page.rows.map(\.check) == [.permission, .display, .soundsAndBadges, .registration]) + #expect(page.isTestEnabled) + } + + @Test("2: permission denied hides the display rows and disables the test") + func permissionDenied() { + let page = Self.page(.init(authorization: .denied)) + + #expect(page.summary.status == .fail) + #expect(page.summary.title == "Notifications are off") + #expect(page.rows.map(\.check) == [.permission, .registration]) + #expect(Self.row(.permission, in: page)?.fix == .openNotificationSettings) + #expect(Self.row(.permission, in: page)?.fixTitle == "Turn on") + #expect(page.isTestEnabled == false) + #expect(page.foldedTitle == nil) + } + + @Test("3: notDetermined asks in the app rather than linking to Settings") + func notDetermined() { + let row = Self.row(.permission, in: Self.page(.init(authorization: .notDetermined))) + + #expect(row?.status == .fail) + #expect(row?.title == "Notifications aren't turned on yet") + #expect(row?.detail == "Flipcash hasn't asked yet") + #expect(row?.fix == .requestAuthorization) + #expect(row?.fixTitle == "Turn on") + } + + @Test("4: banners off alone is one warning and the test stays enabled") + func bannersOff() { + let page = Self.page(.init(authorization: .authorized, alertStyle: .none)) + let row = Self.row(.display, in: page) + + #expect(row?.status == .warning) + #expect(row?.title == "Banners are off") + #expect(row?.fix == .openNotificationSettings) + #expect(row?.fixTitle == "Fix") + #expect(page.summary.status == .warning) + #expect(page.summary.title == "1 thing to check") + #expect(page.isTestEnabled) + #expect(page.foldedTitle == nil) + } + + @Test("Lock Screen off alone is its own warning") + func lockScreenOff() { + let row = Self.row(.display, in: Self.page(.init(authorization: .authorized, lockScreen: .disabled))) + + #expect(row?.status == .warning) + #expect(row?.title == "Hidden on the Lock Screen") + } + + @Test("Sounds and badges name what is off", arguments: [ + (NotificationStatus.Snapshot.Setting.disabled, NotificationStatus.Snapshot.Setting.enabled, "Sounds are off"), + (.enabled, .disabled, "Badges are off"), + (.disabled, .disabled, "Sounds and badges are off"), + ]) + func soundsAndBadgesOff(sound: NotificationStatus.Snapshot.Setting, badge: NotificationStatus.Snapshot.Setting, title: String) { + let row = Self.row(.soundsAndBadges, in: Self.page(.init(authorization: .authorized, sound: sound, badge: badge))) + + #expect(row?.status == .warning) + #expect(row?.title == title) + } + + @Test("10: an invalid push token fails and its retry deletes the local token first") + func invalidPushToken() { + let page = Self.page(registration: .finished(.invalidPushToken)) + let row = Self.row(.registration, in: page) + + #expect(row?.status == .fail) + #expect(row?.title == "Flipcash rejected this phone's push token") + #expect(row?.fix == .retryRegistrationDeletingToken) + #expect(row?.fixTitle == "Try again") + #expect(page.summary.title == "1 thing to fix") + } + + @Test("11: a timeout with no saved ok fails with a connection hint") + func transportWithoutSavedOK() { + let saved = NotificationStatus.RegistrationRecord(at: Self.now.addingTimeInterval(-600), result: .transport, source: .login) + let row = Self.row(.registration, in: Self.page(registration: .finished(.transport), saved: saved)) + + #expect(row?.status == .fail) + #expect(row?.title == "Couldn't reach Flipcash") + #expect(row?.detail == "Check your connection") + #expect(row?.fix == .retryRegistration) + } + + @Test("11b: a timeout after a saved ok is only a warning") + func transportWithSavedOK() { + let saved = NotificationStatus.RegistrationRecord(at: Self.now.addingTimeInterval(-300), result: .ok, source: .refresh) + let page = Self.page(registration: .finished(.transport), saved: saved) + let row = Self.row(.registration, in: page) + + #expect(row?.status == .warning) + #expect(row?.title == "Couldn't reach Flipcash") + #expect(row?.detail == "Last confirmed 5 minutes ago") + #expect(page.summary.title == "1 thing to check") + } + + @Test("12: a registration in flight shows Checking on the row and the summary") + func registrationInFlight() { + let page = Self.page(registration: .checking) + let row = Self.row(.registration, in: page) + + #expect(row?.status == .checking) + #expect(row?.title == "Checking registration…") + #expect(row?.detail == "Contacting Flipcash") + #expect(page.summary.status == .checking) + #expect(page.summary.title == "Checking…") + #expect(page.foldedTitle == nil) + } + + @Test("13: a fail outranks a warning in the summary") + func failOutranksWarning() { + let page = Self.page(.init(authorization: .authorized, alertStyle: .none), registration: .finished(.unknown)) + + #expect(page.summary.status == .fail) + #expect(page.summary.title == "1 thing to fix") + } + + @Test("No APNs token fails the registration row") + func noAPNsToken() { + let page = Self.page(registration: .noAPNsToken) + #expect(page.summary.title == "1 thing to fix") + #expect(Self.row(.registration, in: page)?.title == "This phone isn't registered with Apple") + } + + @Test("14: notSupported settings are not counted as off") + func notSupported() { + let snapshot = NotificationStatus.Snapshot( + authorization: .authorized, + alert: .notSupported, + lockScreen: .notSupported, + sound: .notSupported, + badge: .notSupported + ) + let page = Self.page(snapshot) + + #expect(page.summary.status == .pass) + #expect(page.foldedTitle == "4 checks passed") + } + + @Test("An interrupted registration offers Try again") + func interrupted() { + let page = Self.page(registration: .interrupted) + let row = Self.row(.registration, in: page) + + #expect(row?.status == .unknown) + #expect(row?.detail == "Check was interrupted") + #expect(row?.fix == .retryRegistration) + #expect(page.summary.status == .unknown) + } + + @Test("Rows keep check order within a group, failures included") + func groupOrder() { + let page = Self.page(.init(authorization: .authorized, alertStyle: .none), registration: .finished(.unknown)) + + #expect(page.rows(in: .phone).map(\.check) == [.permission, .display, .soundsAndBadges]) + #expect(page.rows(in: .flipcash).map(\.check) == [.registration]) + } + + @Test("The test notification is recognised by its flag alone") + func testNotificationFlag() { + #expect(NotificationStatus.isTestNotification([NotificationStatus.testUserInfoKey: true])) + #expect(NotificationStatus.isTestNotification([:]) == false) + #expect(NotificationStatus.isTestNotification([NotificationStatus.testUserInfoKey: false]) == false) + } + + @Test("The report lists every check with its raw value and no full token") + func report() throws { + let saved = NotificationStatus.RegistrationRecord(at: Self.now, result: .ok, source: .statusPage) + let timeZone = try #require(TimeZone(secondsFromGMT: -4 * 3600)) + let report = NotificationStatus.report( + snapshot: .init(authorization: .denied, alertStyle: .none), + registration: .finished(.ok), + saved: saved, + context: .init(appVersion: "3.4.1 (812)", osVersion: "26.1", deviceModel: "iPhone17,1", tokenSuffix: "a1b2c3", lastAPNsError: nil), + now: Self.now, + timeZone: timeZone + ) + + #expect(report == """ + Flipcash notification report + 2026-10-28 11:06 (UTC-4) + App 3.4.1 (812) · iOS 26.1 · iPhone17,1 + + Notifications allowed: fail (denied) + Banners and Lock Screen: warning (alertStyle none) + Sounds and badges: pass + Registered for push: pass (statusPage, 2026-10-28 11:06) + + Token: …a1b2c3 (last 6) + Last APNs error: none + """) + } + + @Test("UTC offsets keep half hours") + func utcOffset() throws { + let india = try #require(TimeZone(secondsFromGMT: 5 * 3600 + 1800)) + #expect(NotificationStatus.utcOffset(india, at: Self.now) == "UTC+5:30") + #expect(NotificationStatus.utcOffset(try #require(TimeZone(secondsFromGMT: 0)), at: Self.now) == "UTC") + } + + @Test("AddToken errors classify as the spec's results", arguments: [ + (ErrorAddToken.invalidPushToken, NotificationStatus.RegistrationResult.invalidPushToken), + (.transportFailure, .transport), + (.cancelled, .transport), + (.rejected, .unknown), + (.unknown, .unknown), + ]) + func addTokenErrors(error: ErrorAddToken, expected: NotificationStatus.RegistrationResult) { + #expect(NotificationStatus.RegistrationResult(error) == expected) + } + + @Test("The saved registration is kept per account and cleared on logout") + func registrationStore() throws { + let defaults = try #require(UserDefaults(suiteName: "NotificationStatusTests.\(UUID())")) + let store = NotificationRegistrationStore(defaults: defaults, owner: KeyPair.mock.publicKey) + let record = NotificationStatus.RegistrationRecord(at: Self.now, result: .transport, source: .refresh) + + #expect(store.record == nil) + store.record = record + #expect(NotificationRegistrationStore(defaults: defaults, owner: KeyPair.mock.publicKey).record == record) + + store.clear() + #expect(store.record == nil) + } +} diff --git a/FlipcashTests/Navigation/AppRouterCrossStackTests.swift b/FlipcashTests/Navigation/AppRouterCrossStackTests.swift index 58ce1e1b2..d12d67c15 100644 --- a/FlipcashTests/Navigation/AppRouterCrossStackTests.swift +++ b/FlipcashTests/Navigation/AppRouterCrossStackTests.swift @@ -175,6 +175,7 @@ struct AppRouterCrossStackTests { (AppRouter.Destination.settingsAdvancedBetaFeatures, AppRouter.Stack.you), (AppRouter.Destination.settingsAccountSelection, AppRouter.Stack.you), (AppRouter.Destination.settingsApplicationLogs, AppRouter.Stack.you), + (AppRouter.Destination.settingsNotificationStatus, AppRouter.Stack.you), (AppRouter.Destination.accessKey, AppRouter.Stack.you), (AppRouter.Destination.withdraw, AppRouter.Stack.you), ] From fb1da1ae325df2ba83e57a50ba56fa89c614c957 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 15:00:52 -0400 Subject: [PATCH 02/16] feat(notifications): settle the status page in place and add Check again The page opened on a spinner, then inserted rows as each check returned, so the list jumped while it loaded. It now lays out every check as Checking from the first frame (`NotificationStatus.checkingPage`) and animates each status, the hidden permission-off rows, and the "N checks passed" fold in place. Rows have a fixed minimum height so a detail line coming or going doesn't move its neighbours. The buttons move into a bottom `scrollEdgeBar`, so the checks fade out behind them, and the group headers drop their negative padding to sit like SettingsScreen's. A refresh action in the navigation bar ("Check again") re-reads the phone's settings and runs a fresh `AddToken`. The rows keep their text and show Checking until the run ends; the action is disabled while a run is in progress. `NotificationStatusViewModelTests` covers both. --- .../Settings/NotificationStatusScreen.swift | 121 ++++++++++++------ .../NotificationStatusViewModel.swift | 51 +++++++- .../Push/NotificationStatus.swift | 27 ++++ .../Push/NotificationStatusTests.swift | 24 ++++ .../NotificationStatusViewModelTests.swift | 105 +++++++++++++++ 5 files changed, 285 insertions(+), 43 deletions(-) create mode 100644 FlipcashTests/Settings/NotificationStatusViewModelTests.swift diff --git a/Flipcash/Core/Screens/Settings/NotificationStatusScreen.swift b/Flipcash/Core/Screens/Settings/NotificationStatusScreen.swift index 1379400f5..d55378b28 100644 --- a/Flipcash/Core/Screens/Settings/NotificationStatusScreen.swift +++ b/Flipcash/Core/Screens/Settings/NotificationStatusScreen.swift @@ -17,16 +17,36 @@ struct NotificationStatusScreen: View { @State private var model: NotificationStatusViewModel? var body: some View { + // The page lays out every check before the first read so nothing moves as results land; + // status changes, hidden rows and the fold all animate in place. + let page = model?.page + let shown = page ?? NotificationStatus.checkingPage() + Background(color: .backgroundMain) { - if let model, let page = model.page { - content(model: model, page: page) - } else { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) + ScrollView { + VStack(alignment: .leading, spacing: 0) { + SummaryCard(summary: shown.summary) + .padding(.horizontal, 20) + .padding(.top, 20) + + checks(page: shown) + } + .padding(.bottom, 20) + .animation(.smooth, value: shown) + .animation(.smooth, value: model?.isExpanded) } } + // A scroll edge bar, so the checks fade out under the buttons. + .scrollEdgeBar(.bottom) { + footer(page: page) + } .navigationTitle("Notifications") .toolbarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + checkAgainButton + } + } .task { let model = model ?? NotificationStatusViewModel(pushController: pushController) self.model = model @@ -42,59 +62,73 @@ struct NotificationStatusScreen: View { } } - @ViewBuilder - private func content(model: NotificationStatusViewModel, page: NotificationStatus.Page) -> some View { - VStack(spacing: 0) { - ScrollView { - VStack(alignment: .leading, spacing: 0) { - SummaryCard(summary: page.summary) - .padding(.top, 20) + private var checkAgainButton: some View { + Button { + guard let model else { return } + Task { await model.checkAgain() } + } label: { + Image(systemName: "arrow.clockwise") + } + .disabled(model?.isRunning != false) + .accessibilityLabel("Check again") + .accessibilityIdentifier("notification-status-check-again") + } - if let foldedTitle = page.foldedTitle, !model.isExpanded { - FoldedRow(title: foldedTitle) { - model.isExpanded = true - } - .padding(.top, 12) - } else { - ForEach(NotificationStatus.Group.allCases, id: \.self) { group in - let rows = page.rows(in: group) - if !rows.isEmpty { - SettingsSectionHeader(group.title) - .padding(.horizontal, -20) - CheckGroup(rows: rows) { fix in - Task { await model.perform(fix) } - } - } + @ViewBuilder + private func checks(page: NotificationStatus.Page) -> some View { + if let foldedTitle = page.foldedTitle, model?.isExpanded != true { + FoldedRow(title: foldedTitle) { + model?.isExpanded = true + } + .padding(.horizontal, 20) + .padding(.top, 12) + .transition(.opacity) + } else { + VStack(alignment: .leading, spacing: 0) { + ForEach(NotificationStatus.Group.allCases, id: \.self) { group in + let rows = page.rows(in: group) + if !rows.isEmpty { + SettingsSectionHeader(group.title) + CheckGroup(rows: rows) { fix in + guard let model else { return } + Task { await model.perform(fix) } } + .padding(.horizontal, 20) } } - .padding(.horizontal, 20) } - - footer(model: model, page: page) + .transition(.opacity) } } + /// `page` is `nil` until the phone's settings have been read; the test stays off until then. @ViewBuilder - private func footer(model: NotificationStatusViewModel, page: NotificationStatus.Page) -> some View { + private func footer(page: NotificationStatus.Page?) -> some View { + let isTestSent = model?.isTestSent == true + let isTestEnabled = page?.isTestEnabled == true + VStack(spacing: 12) { - Button(model.isTestSent ? "Send again" : "Send test notification") { + Button(isTestSent ? "Send again" : "Send test notification") { + guard let model else { return } Task { await model.sendTest() } } - .buttonStyle(model.isTestSent ? .filled20 : .filled) - .disabled(!page.isTestEnabled) + .buttonStyle(isTestSent ? .filled20 : .filled) + // Both fills are translucent; without this the checks show through them. + .background(Color.backgroundMain, in: RoundedRectangle(cornerRadius: Metrics.buttonRadius)) + .disabled(!isTestEnabled) .accessibilityIdentifier("notification-status-send-test") - ShareLink(item: model.report) { + ShareLink(item: model?.report ?? "") { Text("Share report") } .buttonStyle(.subtle) + .disabled(model == nil) Group { - if !page.isTestEnabled { + if page != nil, !isTestEnabled { Text("Turn on notifications to send a test.") .foregroundStyle(Color.textSecondary) - } else if model.isTestSent { + } else if isTestSent { Text("Sent. Lock your phone in the next 3 seconds to see it on the Lock Screen.") .foregroundStyle(Color.textSuccess) } else { @@ -107,6 +141,7 @@ struct NotificationStatusScreen: View { .fixedSize(horizontal: false, vertical: true) } .padding(.horizontal, 20) + .padding(.top, 12) .padding(.bottom, 20) } } @@ -131,6 +166,7 @@ private struct SummaryCard: View { .foregroundStyle(Color.textSecondary) } } + .contentTransition(.opacity) Spacer(minLength: 0) } .padding(16) @@ -187,6 +223,8 @@ private struct CheckRow: View { let row: NotificationStatus.Row let onFix: (NotificationStatus.Fix) -> Void + @ScaledMetric(relativeTo: .body) private var rowHeight: CGFloat = 36 + var body: some View { HStack(spacing: 12) { StatusMark(status: row.status, size: .small) @@ -201,6 +239,7 @@ private struct CheckRow: View { .foregroundStyle(Color.textSecondary) } } + .contentTransition(.opacity) .accessibilityElement(children: .combine) Spacer(minLength: 0) @@ -213,9 +252,14 @@ private struct CheckRow: View { .padding(.vertical, 8) .background(Capsule().fill(Color.action)) .buttonStyle(.plain) + .disabled(row.status == .checking) .accessibilityIdentifier("notification-status-fix-\(row.check.rawValue)") + .transition(.opacity) } } + // Tall enough for a title and a detail line, so a row keeps its height when its detail + // comes or goes. + .frame(minHeight: rowHeight) } } @@ -243,10 +287,13 @@ private struct StatusMark: View { ProgressView() .controlSize(.mini) .tint(Color.textAction) + .transition(.opacity) } else { Image(systemName: glyph) .font(size.glyph) .foregroundStyle(Color.textAction) + .contentTransition(.symbolEffect(.replace)) + .transition(.opacity) } } .frame(width: size.width, height: size.height) diff --git a/Flipcash/Core/Screens/Settings/NotificationStatusViewModel.swift b/Flipcash/Core/Screens/Settings/NotificationStatusViewModel.swift index 2cae6a000..85bd91883 100644 --- a/Flipcash/Core/Screens/Settings/NotificationStatusViewModel.swift +++ b/Flipcash/Core/Screens/Settings/NotificationStatusViewModel.swift @@ -18,17 +18,39 @@ final class NotificationStatusViewModel { var isExpanded = false /// Whether a test notification was scheduled since the page opened. private(set) var isTestSent = false + /// The page as it stood when "Check again" was tapped, shown with every row Checking until + /// that run ends. + private var recheckingFrom: NotificationStatus.Page? @ObservationIgnored private let pushController: PushController + @ObservationIgnored private let readSettings: () async -> NotificationStatus.Snapshot + @ObservationIgnored private let registrationCheck: (_ deletingToken: Bool) async -> NotificationStatus.Registration + @ObservationIgnored private let recheckPause: Duration @ObservationIgnored private var registrationTask: Task? - init(pushController: PushController) { + /// `readSettings` and `registrationCheck` default to the phone's settings and + /// ``PushController/checkRegistration(deletingToken:)``. `recheckPause` is the least time + /// "Check again" shows the rows as Checking, so a fast re-check still reads as one. + init( + pushController: PushController, + readSettings: (() async -> NotificationStatus.Snapshot)? = nil, + registrationCheck: ((_ deletingToken: Bool) async -> NotificationStatus.Registration)? = nil, + recheckPause: Duration = .milliseconds(500) + ) { self.pushController = pushController + self.readSettings = readSettings ?? { NotificationStatus.Snapshot(await PushController.fetchStatus()) } + self.registrationCheck = registrationCheck ?? { [pushController] in + await pushController.checkRegistration(deletingToken: $0) + } + self.recheckPause = recheckPause } /// `nil` until the phone's settings have been read once. var page: NotificationStatus.Page? { - snapshot.map { + if let recheckingFrom { + return recheckingFrom.rechecking + } + return snapshot.map { NotificationStatus.evaluate( snapshot: $0, registration: registration, @@ -38,6 +60,11 @@ final class NotificationStatusViewModel { } } + /// Whether a run is in progress, during which "Check again" is disabled. + var isRunning: Bool { + recheckingFrom != nil || registration == .checking + } + var report: String { NotificationStatus.report( snapshot: snapshot ?? .init(authorization: .notDetermined), @@ -64,6 +91,19 @@ final class NotificationStatusViewModel { } } + /// Re-runs every check, including a fresh `AddToken`, keeping the rows the page shows now. + /// Returns once the run has finished; does nothing while a run is already in progress. + func checkAgain() async { + guard !isRunning, let page else { return } + recheckingFrom = page + register() + async let pause: Void? = try? Task.sleep(for: recheckPause) + await refreshSettings() + await registrationTask?.value + _ = await pause + recheckingFrom = nil + } + /// Marks an unfinished registration as interrupted when the page goes away. func stop() { guard let registrationTask else { return } @@ -73,15 +113,14 @@ final class NotificationStatusViewModel { } private func refreshSettings() async { - let settings = await PushController.fetchStatus() - snapshot = NotificationStatus.Snapshot(settings) + snapshot = await readSettings() } private func register(deletingToken: Bool = false) { registrationTask?.cancel() registration = .checking - registrationTask = Task { [pushController] in - let result = await pushController.checkRegistration(deletingToken: deletingToken) + registrationTask = Task { [registrationCheck] in + let result = await registrationCheck(deletingToken) guard !Task.isCancelled else { return } registration = result registrationTask = nil diff --git a/FlipcashCore/Sources/FlipcashCore/Push/NotificationStatus.swift b/FlipcashCore/Sources/FlipcashCore/Push/NotificationStatus.swift index 97949d82f..f12ead4b1 100644 --- a/FlipcashCore/Sources/FlipcashCore/Push/NotificationStatus.swift +++ b/FlipcashCore/Sources/FlipcashCore/Push/NotificationStatus.swift @@ -250,6 +250,17 @@ extension NotificationStatus { public func rows(in group: Group) -> [Row] { rows.filter { $0.check.group == group } } + + /// This page while its checks run again: the same rows with the same text, each marked + /// Checking, so nothing on the page moves until the new results land. + public var rechecking: Page { + var page = self + for index in page.rows.indices { + page.rows[index].status = .checking + } + page.summary = Summary(status: .checking, title: "Checking…") + return page + } } } @@ -257,6 +268,22 @@ extension NotificationStatus { extension NotificationStatus { + /// What the page shows before the phone's settings have been read: every check, still running. + public static func checkingPage() -> Page { + Page( + rows: Check.allCases.map { check in + switch check { + case .permission, .display, .soundsAndBadges: + Row(check: check, status: .checking, title: check.name, raw: "checking") + case .registration: + registrationRow(.checking, saved: nil, now: .distantPast, locale: .current) + } + }, + summary: Summary(status: .checking, title: "Checking…"), + isTestEnabled: false + ) + } + /// Builds the page from the phone's settings and the registration check. /// /// `saved` is the last recorded `AddToken` outcome; it softens a transport failure into a diff --git a/FlipcashCore/Tests/FlipcashCoreTests/Push/NotificationStatusTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/Push/NotificationStatusTests.swift index 279d19220..69d124a56 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/Push/NotificationStatusTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/Push/NotificationStatusTests.swift @@ -153,6 +153,30 @@ struct NotificationStatusTests { #expect(page.foldedTitle == nil) } + @Test("Before the settings are read, every check is listed as checking") + func checkingPage() { + let page = NotificationStatus.checkingPage() + + #expect(page.rows.map(\.check) == NotificationStatus.Check.allCases) + #expect(page.rows.allSatisfy { $0.status == .checking }) + #expect(Self.row(.registration, in: page)?.title == "Checking registration…") + #expect(page.summary.status == .checking) + #expect(page.isTestEnabled == false) + #expect(page.foldedTitle == nil) + } + + @Test("A re-run keeps every row and its text, marked as checking") + func recheckingKeepsRows() { + let page = Self.page(.init(authorization: .denied), registration: .noAPNsToken) + let rechecking = page.rechecking + + #expect(rechecking.rows.map(\.check) == page.rows.map(\.check)) + #expect(rechecking.rows.map(\.title) == page.rows.map(\.title)) + #expect(rechecking.rows.allSatisfy { $0.status == .checking }) + #expect(rechecking.summary.status == .checking) + #expect(rechecking.isTestEnabled == page.isTestEnabled) + } + @Test("13: a fail outranks a warning in the summary") func failOutranksWarning() { let page = Self.page(.init(authorization: .authorized, alertStyle: .none), registration: .finished(.unknown)) diff --git a/FlipcashTests/Settings/NotificationStatusViewModelTests.swift b/FlipcashTests/Settings/NotificationStatusViewModelTests.swift new file mode 100644 index 000000000..e37e8f0f6 --- /dev/null +++ b/FlipcashTests/Settings/NotificationStatusViewModelTests.swift @@ -0,0 +1,105 @@ +// +// NotificationStatusViewModelTests.swift +// FlipcashTests +// + +import Foundation +import Testing +import FlipcashCore +@testable import Flipcash + +@MainActor +@Suite("NotificationStatusViewModel") +struct NotificationStatusViewModelTests { + + @Test("Check again shows every row as Checking, registers again, then settles") + func checkAgainRerunsEveryCheck() async { + let settings = Gate(.init(authorization: .authorized)) + let registration = Gate(.finished(.ok)) + let model = NotificationStatusViewModel( + pushController: .mock, + readSettings: { await settings.next() }, + registrationCheck: { _ in await registration.next() }, + recheckPause: .zero + ) + + await model.runChecks() + await until { !model.isRunning } + #expect(registration.calls == 1) + #expect(model.page?.foldedTitle == "4 checks passed") + + settings.holdsNext = true + registration.holdsNext = true + let run = Task { await model.checkAgain() } + await until { settings.isHolding && registration.isHolding } + + #expect(registration.calls == 2) + #expect(model.isRunning) + #expect(model.page?.rows.map(\.check) == NotificationStatus.Check.allCases) + #expect(model.page?.rows.allSatisfy { $0.status == .checking } == true) + #expect(model.page?.summary.status == .checking) + + settings.release() + registration.release() + await run.value + + #expect(!model.isRunning) + #expect(model.page?.rows.allSatisfy { $0.status == .pass } == true) + } + + @Test("Check again does nothing while a run is in progress") + func checkAgainWhileRunning() async { + let registration = Gate(.finished(.ok)) + registration.holdsNext = true + let model = NotificationStatusViewModel( + pushController: .mock, + readSettings: { .init(authorization: .authorized) }, + registrationCheck: { _ in await registration.next() }, + recheckPause: .zero + ) + + await model.runChecks() + await until { registration.isHolding } + await model.checkAgain() + + #expect(registration.calls == 1) + registration.release() + } + + // MARK: - Support - + + /// Yields until `condition` holds, so the view model's unstructured registration task can run. + private func until(_ condition: () -> Bool) async { + while !condition() { + await Task.yield() + } + } +} + +/// Returns `value` on each call, or, once `holdsNext` is set, suspends the next call until `release()`. +@MainActor +private final class Gate { + + var holdsNext = false + private(set) var calls = 0 + private let value: Value + private var pending: CheckedContinuation? + + var isHolding: Bool { pending != nil } + + init(_ value: Value) { + self.value = value + } + + func next() async -> Value { + calls += 1 + guard holdsNext else { return value } + holdsNext = false + return await withCheckedContinuation { pending = $0 } + } + + func release() { + pending?.resume(returning: value) + pending = nil + } +} From d593f6b3db4e1b4f800b61e510346b86a5401b4a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 16:35:44 -0400 Subject: [PATCH 03/16] fix(notifications): title-case the status copy and say "just now" under a minute The group headers read "ON THIS PHONE" / "WITH FLIPCASH" and the fix buttons "Turn on" / "Try again"; the spec's Copy table has them in title case. "Last confirmed" passed a sub-minute save straight to RelativeDateTimeFormatter, which reads "0 minutes ago"; it now reads "just now". --- .../Push/NotificationStatus.swift | 14 +++++---- .../Push/NotificationStatusTests.swift | 30 ++++++++++++++++--- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/FlipcashCore/Sources/FlipcashCore/Push/NotificationStatus.swift b/FlipcashCore/Sources/FlipcashCore/Push/NotificationStatus.swift index f12ead4b1..0c1aad166 100644 --- a/FlipcashCore/Sources/FlipcashCore/Push/NotificationStatus.swift +++ b/FlipcashCore/Sources/FlipcashCore/Push/NotificationStatus.swift @@ -189,8 +189,8 @@ extension NotificationStatus { public var title: String { switch self { - case .phone: "ON THIS PHONE" - case .flipcash: "WITH FLIPCASH" + case .phone: "On This Phone" + case .flipcash: "With Flipcash" } } } @@ -218,16 +218,16 @@ extension NotificationStatus { public var id: Check { check } - /// The label on the fix button. The permission row says "Turn on" whether it opens + /// The label on the fix button. The permission row says "Turn On" whether it opens /// Settings or asks in the app. public var fixTitle: String? { guard let fix else { return nil } - if check == .permission { return "Turn on" } + if check == .permission { return "Turn On" } switch fix { case .openNotificationSettings, .requestAuthorization: return "Fix" case .retryRegistration, .retryRegistrationDeletingToken: - return "Try again" + return "Try Again" } } } @@ -242,7 +242,7 @@ extension NotificationStatus { /// Visible rows in check order. Rows that don't apply are already left out. public var rows: [Row] public var summary: Summary - /// Whether "Send test notification" can be tapped. + /// Whether "Send Test Notification" can be tapped. public var isTestEnabled: Bool /// The title of the single row that replaces both groups when every check passes. public var foldedTitle: String? @@ -416,7 +416,9 @@ extension NotificationStatus { "\(count) \(count == 1 ? one : many)" } + /// Under a minute reads "just now"; the formatter would say "0 minutes ago". static func relative(_ date: Date, to now: Date, locale: Locale) -> String { + guard abs(now.timeIntervalSince(date)) >= 60 else { return "just now" } let formatter = RelativeDateTimeFormatter() formatter.locale = locale formatter.unitsStyle = .full diff --git a/FlipcashCore/Tests/FlipcashCoreTests/Push/NotificationStatusTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/Push/NotificationStatusTests.swift index 69d124a56..4550b11de 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/Push/NotificationStatusTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/Push/NotificationStatusTests.swift @@ -54,7 +54,7 @@ struct NotificationStatusTests { #expect(page.summary.title == "Notifications are off") #expect(page.rows.map(\.check) == [.permission, .registration]) #expect(Self.row(.permission, in: page)?.fix == .openNotificationSettings) - #expect(Self.row(.permission, in: page)?.fixTitle == "Turn on") + #expect(Self.row(.permission, in: page)?.fixTitle == "Turn On") #expect(page.isTestEnabled == false) #expect(page.foldedTitle == nil) } @@ -67,7 +67,7 @@ struct NotificationStatusTests { #expect(row?.title == "Notifications aren't turned on yet") #expect(row?.detail == "Flipcash hasn't asked yet") #expect(row?.fix == .requestAuthorization) - #expect(row?.fixTitle == "Turn on") + #expect(row?.fixTitle == "Turn On") } @Test("4: banners off alone is one warning and the test stays enabled") @@ -113,7 +113,7 @@ struct NotificationStatusTests { #expect(row?.status == .fail) #expect(row?.title == "Flipcash rejected this phone's push token") #expect(row?.fix == .retryRegistrationDeletingToken) - #expect(row?.fixTitle == "Try again") + #expect(row?.fixTitle == "Try Again") #expect(page.summary.title == "1 thing to fix") } @@ -140,6 +140,28 @@ struct NotificationStatusTests { #expect(page.summary.title == "1 thing to check") } + @Test("A save under a minute old reads just now, not 0 minutes ago", arguments: [0.0, -1, -59]) + func transportWithFreshSavedOK(offset: TimeInterval) { + let saved = NotificationStatus.RegistrationRecord(at: Self.now.addingTimeInterval(offset), result: .ok, source: .refresh) + let page = Self.page(registration: .finished(.transport), saved: saved) + + #expect(Self.row(.registration, in: page)?.detail == "Last confirmed just now") + } + + @Test("A save a minute old reads in minutes") + func transportWithMinuteOldSavedOK() { + let saved = NotificationStatus.RegistrationRecord(at: Self.now.addingTimeInterval(-60), result: .ok, source: .refresh) + let page = Self.page(registration: .finished(.transport), saved: saved) + + #expect(Self.row(.registration, in: page)?.detail == "Last confirmed 1 minute ago") + } + + @Test("Group headers are title case") + func groupTitles() { + #expect(NotificationStatus.Group.phone.title == "On This Phone") + #expect(NotificationStatus.Group.flipcash.title == "With Flipcash") + } + @Test("12: a registration in flight shows Checking on the row and the summary") func registrationInFlight() { let page = Self.page(registration: .checking) @@ -207,7 +229,7 @@ struct NotificationStatusTests { #expect(page.foldedTitle == "4 checks passed") } - @Test("An interrupted registration offers Try again") + @Test("An interrupted registration offers Try Again") func interrupted() { let page = Self.page(registration: .interrupted) let row = Self.row(.registration, in: page) From cffc0dc337f465da41e5b4e67c7c61f947ce4092 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 16:35:45 -0400 Subject: [PATCH 04/16] feat(notifications): toast the test, keep rows still, scroll the footer when large - Buttons are title case. The group headers use ListHeader, since SettingsSectionHeader uppercases its text. - "Send Test Notification" shows "Test notification sent" through the floating toast host instead of the green text under the button. - A return to the foreground re-reads only UNNotificationSettings. Registration keeps its last result, so a folded page no longer unfolds and refolds while AddToken runs again. - Each row reserves a title and two lines of detail, Checking included, so a result that wraps doesn't push the rows below it. - At accessibility text sizes the footer scrolls with the checks instead of staying pinned. --- .../Settings/NotificationStatusScreen.swift | 80 +++++++++++++------ .../NotificationStatusViewModel.swift | 21 +++-- .../NotificationStatusViewModelTests.swift | 24 ++++++ 3 files changed, 94 insertions(+), 31 deletions(-) diff --git a/Flipcash/Core/Screens/Settings/NotificationStatusScreen.swift b/Flipcash/Core/Screens/Settings/NotificationStatusScreen.swift index d55378b28..896205185 100644 --- a/Flipcash/Core/Screens/Settings/NotificationStatusScreen.swift +++ b/Flipcash/Core/Screens/Settings/NotificationStatusScreen.swift @@ -12,7 +12,9 @@ import FlipcashCore struct NotificationStatusScreen: View { @Environment(PushController.self) private var pushController + @Environment(ToastController.self) private var toasts @Environment(\.scenePhase) private var scenePhase + @Environment(\.dynamicTypeSize) private var dynamicTypeSize @State private var model: NotificationStatusViewModel? @@ -21,6 +23,8 @@ struct NotificationStatusScreen: View { // status changes, hidden rows and the fold all animate in place. let page = model?.page let shown = page ?? NotificationStatus.checkingPage() + // At accessibility sizes a pinned footer would cover most of the screen. + let scrollsFooter = dynamicTypeSize.isAccessibilitySize Background(color: .backgroundMain) { ScrollView { @@ -30,6 +34,10 @@ struct NotificationStatusScreen: View { .padding(.top, 20) checks(page: shown) + + if scrollsFooter { + footer(page: page) + } } .padding(.bottom, 20) .animation(.smooth, value: shown) @@ -38,7 +46,10 @@ struct NotificationStatusScreen: View { } // A scroll edge bar, so the checks fade out under the buttons. .scrollEdgeBar(.bottom) { - footer(page: page) + if !scrollsFooter { + footer(page: page) + .toastClearance(toasts) + } } .navigationTitle("Notifications") .toolbarTitleDisplayMode(.inline) @@ -53,9 +64,10 @@ struct NotificationStatusScreen: View { await model.runChecks() } .onChange(of: scenePhase) { _, phase in - // Coming back from Settings after a fix re-runs every check. + // Coming back from Settings after a fix re-reads the phone's settings; registration + // keeps its last result. guard phase == .active, let model else { return } - Task { await model.runChecks() } + Task { await model.rereadSettings() } } .onDisappear { model?.stop() @@ -70,7 +82,7 @@ struct NotificationStatusScreen: View { Image(systemName: "arrow.clockwise") } .disabled(model?.isRunning != false) - .accessibilityLabel("Check again") + .accessibilityLabel("Check Again") .accessibilityIdentifier("notification-status-check-again") } @@ -88,7 +100,11 @@ struct NotificationStatusScreen: View { ForEach(NotificationStatus.Group.allCases, id: \.self) { group in let rows = page.rows(in: group) if !rows.isEmpty { - SettingsSectionHeader(group.title) + // Title case, which `SettingsSectionHeader` would uppercase; padded to match it. + ListHeader(group.title) + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, 3) CheckGroup(rows: rows) { fix in guard let model else { return } Task { await model.perform(fix) } @@ -108,9 +124,17 @@ struct NotificationStatusScreen: View { let isTestEnabled = page?.isTestEnabled == true VStack(spacing: 12) { - Button(isTestSent ? "Send again" : "Send test notification") { + Button(isTestSent ? "Send Again" : "Send Test Notification") { guard let model else { return } - Task { await model.sendTest() } + Task { + guard await model.sendTest() else { return } + toasts.show(.init( + "Test notification sent", + systemImage: "checkmark.circle.fill", + messageIdentifier: "notification-status-test-sent", + duration: .seconds(2) + )) + } } .buttonStyle(isTestSent ? .filled20 : .filled) // Both fills are translucent; without this the checks show through them. @@ -119,7 +143,7 @@ struct NotificationStatusScreen: View { .accessibilityIdentifier("notification-status-send-test") ShareLink(item: model?.report ?? "") { - Text("Share report") + Text("Share Report") } .buttonStyle(.subtle) .disabled(model == nil) @@ -128,9 +152,6 @@ struct NotificationStatusScreen: View { if page != nil, !isTestEnabled { Text("Turn on notifications to send a test.") .foregroundStyle(Color.textSecondary) - } else if isTestSent { - Text("Sent. Lock your phone in the next 3 seconds to see it on the Lock Screen.") - .foregroundStyle(Color.textSuccess) } else { Text("The test is posted by this phone. It shows that notifications can appear here, not that Flipcash's servers can reach you.") .foregroundStyle(Color.textSecondary) @@ -223,24 +244,36 @@ private struct CheckRow: View { let row: NotificationStatus.Row let onFix: (NotificationStatus.Fix) -> Void - @ScaledMetric(relativeTo: .body) private var rowHeight: CGFloat = 36 - var body: some View { HStack(spacing: 12) { StatusMark(status: row.status, size: .small) - VStack(alignment: .leading, spacing: 2) { - Text(row.title) - .font(.appTextMessage) - .foregroundStyle(Color.textMain) - if let detail = row.detail { - Text(detail) + // Every row, Checking included, is as tall as a title over two lines of detail, so a + // result that wraps doesn't move the rows below it. + ZStack(alignment: .leading) { + VStack(alignment: .leading, spacing: 2) { + Text(verbatim: " ") + .font(.appTextMessage) + Text(verbatim: " ") .font(.appTextCaption) - .foregroundStyle(Color.textSecondary) + .lineLimit(2, reservesSpace: true) } + .hidden() + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 2) { + Text(row.title) + .font(.appTextMessage) + .foregroundStyle(Color.textMain) + if let detail = row.detail { + Text(detail) + .font(.appTextCaption) + .foregroundStyle(Color.textSecondary) + } + } + .contentTransition(.opacity) + .accessibilityElement(children: .combine) } - .contentTransition(.opacity) - .accessibilityElement(children: .combine) Spacer(minLength: 0) @@ -257,9 +290,6 @@ private struct CheckRow: View { .transition(.opacity) } } - // Tall enough for a title and a detail line, so a row keeps its height when its detail - // comes or goes. - .frame(minHeight: rowHeight) } } diff --git a/Flipcash/Core/Screens/Settings/NotificationStatusViewModel.swift b/Flipcash/Core/Screens/Settings/NotificationStatusViewModel.swift index 85bd91883..6481d12d4 100644 --- a/Flipcash/Core/Screens/Settings/NotificationStatusViewModel.swift +++ b/Flipcash/Core/Screens/Settings/NotificationStatusViewModel.swift @@ -18,7 +18,7 @@ final class NotificationStatusViewModel { var isExpanded = false /// Whether a test notification was scheduled since the page opened. private(set) var isTestSent = false - /// The page as it stood when "Check again" was tapped, shown with every row Checking until + /// The page as it stood when "Check Again" was tapped, shown with every row Checking until /// that run ends. private var recheckingFrom: NotificationStatus.Page? @@ -30,7 +30,7 @@ final class NotificationStatusViewModel { /// `readSettings` and `registrationCheck` default to the phone's settings and /// ``PushController/checkRegistration(deletingToken:)``. `recheckPause` is the least time - /// "Check again" shows the rows as Checking, so a fast re-check still reads as one. + /// "Check Again" shows the rows as Checking, so a fast re-check still reads as one. init( pushController: PushController, readSettings: (() async -> NotificationStatus.Snapshot)? = nil, @@ -60,7 +60,7 @@ final class NotificationStatusViewModel { } } - /// Whether a run is in progress, during which "Check again" is disabled. + /// Whether a run is in progress, during which "Check Again" is disabled. var isRunning: Bool { recheckingFrom != nil || registration == .checking } @@ -104,6 +104,12 @@ final class NotificationStatusViewModel { recheckingFrom = nil } + /// Re-reads only the phone's settings, for a return to the foreground. Registration keeps its + /// last result, so a folded page doesn't unfold and refold. + func rereadSettings() async { + await refreshSettings() + } + /// Marks an unfinished registration as interrupted when the page goes away. func stop() { guard let registrationTask else { return } @@ -149,16 +155,19 @@ final class NotificationStatusViewModel { } /// Re-reads the permission first: if it went away while the page was open, nothing is sent - /// and the page shows notifications as off. - func sendTest() async { + /// and the page shows notifications as off. Returns whether a test was scheduled. + @discardableResult + func sendTest() async -> Bool { await refreshSettings() - guard page?.isTestEnabled == true else { return } + guard page?.isTestEnabled == true else { return false } do { try await pushController.scheduleTestNotification() isTestSent = true + return true } catch { ErrorReporting.captureError(error, reason: "Failed to schedule test notification") + return false } } diff --git a/FlipcashTests/Settings/NotificationStatusViewModelTests.swift b/FlipcashTests/Settings/NotificationStatusViewModelTests.swift index e37e8f0f6..afa8a15eb 100644 --- a/FlipcashTests/Settings/NotificationStatusViewModelTests.swift +++ b/FlipcashTests/Settings/NotificationStatusViewModelTests.swift @@ -66,6 +66,30 @@ struct NotificationStatusViewModelTests { registration.release() } + @Test("A return to the foreground re-reads the phone's settings without registering again") + func foregroundRereadsSettingsOnly() async { + var snapshot = NotificationStatus.Snapshot(authorization: .authorized) + let registration = Gate(.finished(.ok)) + let model = NotificationStatusViewModel( + pushController: .mock, + readSettings: { snapshot }, + registrationCheck: { _ in await registration.next() }, + recheckPause: .zero + ) + + await model.runChecks() + await until { !model.isRunning } + #expect(model.page?.foldedTitle == "4 checks passed") + + snapshot = .init(authorization: .authorized, alertStyle: .none) + await model.rereadSettings() + + #expect(registration.calls == 1, "AddToken runs only on open and on Check Again") + #expect(!model.isRunning) + #expect(model.page?.rows.first { $0.check == .display }?.status == .warning) + #expect(model.page?.rows.first { $0.check == .registration }?.status == .pass) + } + // MARK: - Support - /// Yields until `condition` holds, so the view model's unstructured registration task can run. From 7d03b0089cd84726cf5afa481e7e84d488713e3e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 16:35:45 -0400 Subject: [PATCH 05/16] fix(notifications): Back from the test notification returns to Settings Tapping the test notification set the You stack to the status page alone, so Back went to the You tab's root. navigate(to:under:) now pushes parent destinations beneath the target, and the tap routes through Settings, as Android does. --- Flipcash/Core/Navigation/AppRouter.swift | 16 ++++++----- .../Core/Session/SessionAuthenticator.swift | 2 +- .../Navigation/AppRouterCrossStackTests.swift | 27 +++++++++++++++++++ 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/Flipcash/Core/Navigation/AppRouter.swift b/Flipcash/Core/Navigation/AppRouter.swift index 55bae20dc..faf88a470 100644 --- a/Flipcash/Core/Navigation/AppRouter.swift +++ b/Flipcash/Core/Navigation/AppRouter.swift @@ -538,8 +538,8 @@ final class AppRouter { // MARK: - Cross-stack navigation /// Cross-stack navigation. Presents the destination's `owningStack` as the - /// root sheet (dismissing any nested sheets above) and sets `[destination]` - /// as the only path entry on that stack. Other stacks' paths are preserved + /// root sheet (dismissing any nested sheets above) and sets `parents + [destination]` + /// as the path on that stack. Other stacks' paths are preserved /// underneath. /// /// Call this from deeplinks, push notifications, and any programmatic @@ -550,11 +550,15 @@ final class AppRouter { /// > so leaf swaps from `[currencyInfo(A)]` to `[currencyInfo(B)]` rebuild /// > the destination with fresh `@State` rather than reusing the previous /// > view's view model. - func navigate(to destination: Destination) { + /// + /// `parents` are pushed beneath `destination` on its stack, so Back walks + /// through them; they must share its owning stack. + func navigate(to destination: Destination, under parents: [Destination] = []) { let targetStack = destination.owningStack + let trail = parents + [destination] var expected = NavigationPath() - expected.append(destination) + for step in trail { expected.append(step) } // A stack a tab owns is reached by bringing that tab forward. Presenting // its sheet instead lays a second copy of the surface over the tab that @@ -569,7 +573,7 @@ final class AppRouter { while !presentedSheets.isEmpty { dismissSheet() } requestedTabStack = targetStack - setPath([destination], on: targetStack) + setPath(trail, on: targetStack) logger.info("Routed to tab stack", metadata: [ "stack": "\(targetStack)", "destination": "\(destination)", @@ -593,7 +597,7 @@ final class AppRouter { // present(_:) handles dismiss-nested-then-swap-root semantics. present(targetSheet) - setPath([destination], on: targetStack) + setPath(trail, on: targetStack) } /// Like `navigate(to:)`, but when the destination's stack is already the visible surface the diff --git a/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index 212bf5f0d..51a3a5211 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -1055,7 +1055,7 @@ final class SessionContainer { conversationController?.isArchived(conversationID) == true } pushController.openNotificationStatus = { [weak appRouter] in - appRouter?.navigate(to: .settingsNotificationStatus) + appRouter?.navigate(to: .settingsNotificationStatus, under: [.settings]) } let chatSpotlightIndexer = ChatSpotlightIndexer( diff --git a/FlipcashTests/Navigation/AppRouterCrossStackTests.swift b/FlipcashTests/Navigation/AppRouterCrossStackTests.swift index d12d67c15..dfb719b0f 100644 --- a/FlipcashTests/Navigation/AppRouterCrossStackTests.swift +++ b/FlipcashTests/Navigation/AppRouterCrossStackTests.swift @@ -139,6 +139,33 @@ struct AppRouterCrossStackTests { "the wallet tab is preserved underneath") } + @Test("Tapping the test notification lands on the status page with Settings beneath it") + func navigate_underParents_setsTrail() { + let router = AppRouter() + router.activeTabStack = .balance + router.setPath([.currencyInfo(.usdc)], on: .balance) + + router.navigate(to: .settingsNotificationStatus, under: [.settings]) + + #expect(router.requestedTabStack == .you) + #expect(router[.you] == AppRouter.navigationPath(.settings, .settingsNotificationStatus), + "Back from the status page returns to Settings") + #expect(router[.balance] == AppRouter.navigationPath(.currencyInfo(.usdc))) + } + + @Test("Navigate under parents is idempotent when the trail is already showing") + func navigate_underParents_isIdempotent() { + let router = AppRouter() + router.navigate(to: .settingsNotificationStatus, under: [.settings]) + router.activeTabStack = .you + router.requestedTabStack = nil + + router.navigate(to: .settingsNotificationStatus, under: [.settings]) + + #expect(router.requestedTabStack == nil) + #expect(router[.you] == AppRouter.navigationPath(.settings, .settingsNotificationStatus)) + } + @Test("Navigate is idempotent when target state already matches current state") func navigate_isIdempotent() { let router = AppRouter() From 9058f8092fc6b48e071ab018a1247e7b36698211 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 16:44:26 -0400 Subject: [PATCH 06/16] fix(notifications): put the fix button under the row text at accessibility sizes Beside a Try Again pill at AX5, "This phone isn't registered with Apple" had a column a few letters wide and broke mid-word ("registe / red"). --- .../Settings/NotificationStatusScreen.swift | 45 ++++++++++++++----- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/Flipcash/Core/Screens/Settings/NotificationStatusScreen.swift b/Flipcash/Core/Screens/Settings/NotificationStatusScreen.swift index 896205185..bdf3f8996 100644 --- a/Flipcash/Core/Screens/Settings/NotificationStatusScreen.swift +++ b/Flipcash/Core/Screens/Settings/NotificationStatusScreen.swift @@ -244,7 +244,25 @@ private struct CheckRow: View { let row: NotificationStatus.Row let onFix: (NotificationStatus.Fix) -> Void + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + var body: some View { + // At accessibility sizes the fix button drops under the text, so the title keeps the + // full width instead of breaking mid-word beside it. + if dynamicTypeSize.isAccessibilitySize { + VStack(alignment: .leading, spacing: 12) { + label + fixButton + } + } else { + HStack(spacing: 12) { + label + fixButton + } + } + } + + private var label: some View { HStack(spacing: 12) { StatusMark(status: row.status, size: .small) @@ -276,19 +294,22 @@ private struct CheckRow: View { } Spacer(minLength: 0) + } + } - if let fix = row.fix, let fixTitle = row.fixTitle { - Button(fixTitle) { onFix(fix) } - .font(.appTextSmall) - .foregroundStyle(Color.textAction) - .padding(.horizontal, 14) - .padding(.vertical, 8) - .background(Capsule().fill(Color.action)) - .buttonStyle(.plain) - .disabled(row.status == .checking) - .accessibilityIdentifier("notification-status-fix-\(row.check.rawValue)") - .transition(.opacity) - } + @ViewBuilder + private var fixButton: some View { + if let fix = row.fix, let fixTitle = row.fixTitle { + Button(fixTitle) { onFix(fix) } + .font(.appTextSmall) + .foregroundStyle(Color.textAction) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(Capsule().fill(Color.action)) + .buttonStyle(.plain) + .disabled(row.status == .checking) + .accessibilityIdentifier("notification-status-fix-\(row.check.rawValue)") + .transition(.opacity) } } } From 3a35a2da92c3212ad559136d34870923c6e14b13 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 17:14:22 -0400 Subject: [PATCH 07/16] fix(notifications): soften a transport failure by the last ok, drop Unknown A timeout after a confirmed registration never became a Warning: the attempt saved `transport` before the view model read the record, so `saved.result == .ok` could not hold. The record now carries `lastRegistrationOkAt`, set only by an `ok` from any call site, and the view model reads it before the attempt starts. A failure with that time set is a Warning, "Last confirmed ". - One 10 s deadline covers the token delete, the token fetch and AddToken; the delete was unbounded and the fetch and AddToken each had their own 10 s. - A run that fetched no token saves nothing, including a failed Firebase fetch with an APNs token present. A run cancelled by the page closing saves nothing either. - The Unknown status and its copy are gone; closing the page mid-run drops the run. - Check Again after INVALID_PUSH_TOKEN deletes the local token first. - The report's token is the one sent in the latest attempt, or "none", and its registration line reads ` (, ,