Skip to content

feat(notifications): notification status page in Settings - #1153

Merged
bmc08gt merged 16 commits into
mainfrom
feat/notification-status
Oct 29, 2026
Merged

bmc08gt merged 16 commits into
mainfrom
feat/notification-status

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

When a user says they aren't getting notifications, support has no way to see why. The iOS permission, the banner and Lock Screen settings, and whether this phone's token ever reached our servers are all invisible from the app, and AddToken failures were dropped without a trace.

This adds Settings › Advanced › Notifications, a page that runs those checks, says which one is failing, and links each failure to its fix. Spec: code-payments/flipcash-client-orchestrator#73. Design: Figma page 12952:56.

  • NotificationStatus in FlipcashCore is a pure function from a settings snapshot plus the last registration result to the page's rows and summary. No UIKit or UserNotifications types, so every spec test case it covers is a unit test (NotificationStatusTests).
  • PushController.fetchStatus now returns the full UNNotificationSettings instead of only authorizationStatus; its callers in onboarding and the swap completion read .authorizationStatus from it.
  • Registration results are saved per account (NotificationRegistrationStore) and cleared on logout. AddToken errors from the Firebase and APNs token callbacks are recorded and reported instead of dropped, and didFailToRegisterForRemoteNotifications saves its error for the page.
  • The page runs its own registration attempt (delete token if asked, token fetch, AddToken) under one 10 second deadline. An attempt that never got a token saves nothing; closing the page mid-run drops the attempt and saves nothing.
  • The saved record keeps lastRegistrationOkAt, set by any ok and cleared on logout. A failed attempt after an earlier ok shows as a Warning ("Last confirmed …") instead of a Fail.
  • Check Again after an INVALID_PUSH_TOKEN result deletes the local token first, as Try Again does.
  • The "N checks passed" row toggles the groups open and closed and stays visible while open. During Check Again it shows Checking like the other rows.
  • On every re-run (Check Again, Try Again, and the re-run after Turn On is granted) the summary card keeps the previous run's summary and animates to the new one when the run finishes. "Checking…" shows only on the page's first run.
  • A refresh action in the navigation bar (accessibility label "Check Again") re-runs every check, including a fresh AddToken. Registration runs on open, on Check Again, on Try Again, and after the in-app Turn On when permission is granted (not on deny); returning from iOS Settings re-reads UNNotificationSettings and leaves the registration row on its last result.
  • Rows are laid out as Checking from the first frame and change status in place. Each row reserves a title and two lines of detail, so a result that wraps doesn't move the rows below it.
  • At accessibility text sizes the footer buttons scroll with the content instead of staying pinned, and a row's fix button moves under its text.
  • Send Test Notification calls SendTestPush with a 10 second deadline, and the button shows its loading state while the call runs. OK switches it to Send Again with a "Test notification sent" toast, and a failed send after that leaves it on Send Again. A transport failure, cancellation or timeout toasts "Couldn't reach Flipcash"; any other failure toasts "Couldn't send a test notification". The footer reads "Flipcash sends the test to every phone signed in to this account." The tap re-reads the permission first and sends nothing if it went away.
  • The server's test push carries an empty Payload. NotificationDelegate already presents a push with no chat payload as a foreground banner and routes nothing on tap, so the push needs no special case; NotificationPresentationTests pins the empty-Payload case. The earlier local flipcash_test notification, its delegate branches, its tap route and AppRouter.navigate(to:under:) are gone.
  • PushController.withDeadline returns at the deadline even when the call ignores cancellation. A task group would wait for every child, and SendTestPush runs behind a continuation that ignores cancellation, so a hung send would hold the button until the 15 s gRPC deadline. DeadlineRace instead races the call, the deadline and caller cancellation and cancels whichever loses. The same limit covers AddToken and the Firebase token fetch and delete. A timeout still throws CancellationError, so it maps to the same transport result as before. PushControllerDeadlineTests covers a call that ignores cancellation, a caller cancelled mid-call, and a value or error that arrives before the deadline.
  • A row's title is its check name in every state, Checking included: Notifications Allowed, Banners and Lock Screen, Sounds and Badges, and Registered for Push, on screen and in the shared report. On a warning or fail the problem sentence moves to the detail's first line, with the explanation, if there is one, on the second: "Notifications are turned off" over "Turned off for Flipcash in iOS Settings". Details stay sentence case.
  • Share Report sends a plain-text report with each check's raw value and a truncated copy of the token sent in the latest attempt ("none" if no token was fetched). The registration line describes the page's latest run (pass (statusPage, <time>), fail (noAPNsToken), checking), followed by a separate Last ok: <time> line, or Last ok: never. A zero offset is written UTC+0.
  • The summary's "Checked just now" appears once the first run finishes and stays through every later run.
  • textWarning already exists in Colors.swift, so the warning rows reuse it rather than adding the token the spec asks for.

@bmc08gt bmc08gt self-assigned this Oct 28, 2026
@bmc08gt
bmc08gt force-pushed the feat/notification-status branch from d7b0f29 to c9eba4e Compare October 28, 2026 20:50
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.
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.
…er 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".
…er 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.
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.
…ility 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").
…nknown

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 <relative time>".

- 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 `<status> (<result>, <source>, <time>)`.
- The summary's second line waits for every check to finish and stays
  through Check Again.
- "N checks passed" stays as a toggle above the groups it opens, and
  shows Checking during Check Again.
…gister only on grant

Try Again and the Turn On re-run evaluated with the registration Checking,
which cleared the summary's second line until the run finished. The view
model now passes whether a run has finished before, and the summary keeps
"Checked just now" once one has.

The in-app Turn On re-ran every check whatever the answer. It now re-reads
the settings and runs Registration again only when permission is granted,
since the phone can't register while notifications are off.
… line

The registration line now describes the page's latest run only:
"<status> (<result unless ok>, <source>, <time>)", "fail (noAPNsToken)"
when the run saved nothing, and "checking" while it runs. The older
record's "; last ok, ..." suffix is replaced by a separate
"Last ok: <time>" line, or "Last ok: never".

The header writes a zero offset as UTC+0, like UTC-4 elsewhere.
@bmc08gt
bmc08gt force-pushed the feat/notification-status branch from 0916520 to 0e37fbe Compare October 29, 2026 10:56
The test button now calls SendTestPush with a 10 s deadline instead of
posting a local notification, so a test that arrives shows the server can
reach this phone. While the call runs the button shows its loading state.
OK switches it to "Send Again" with a "Test notification sent" toast; a
transport failure, cancellation or timeout toasts "Couldn't reach
Flipcash", and any other failure "Couldn't send a test notification".

The server's push carries an empty Payload. NotificationDelegate already
presents a push it can't decode as a chat and routes nothing on tap, so
the flipcash_test branches and the tap route to the status page go away.
A presentation test pins the empty-Payload case.
Its only caller was the local test notification's tap route, removed in
the previous commit.
During Check Again the summary card now holds the previous run's summary
and animates to the new one when the run finishes; "Checking…" shows only
on the page's first run. The folded row reads its Checking mark from the
rows instead of the summary, so it still shows Checking during the re-run.
Try Again and the registration re-run after Turn On is granted now hold the
summary the page showed before the tap until registration finishes, as Check
Again already does. "Checking…" shows only on the page's first run.

Turn On captures the summary before the permission request, because the
settings re-read that follows would otherwise change it before registration
starts.
…ellation

withDeadline ran the call in a task group, which waits for every child before
returning. SendTestPush runs behind a continuation that ignores cancellation,
so a hung send waited for the 15 s gRPC deadline instead of the 10 s limit.
The helper now races the call, the deadline and caller cancellation through
one continuation and cancels whatever loses, so AddToken and the FCM token
fetch get the same wall-clock limit. Timeouts still throw CancellationError,
so the transport outcomes are unchanged.

Also pins that a failed send after one that worked keeps Send Again, and
corrects the screen doc: the test notification comes from Flipcash.
… sleep

The previous stand-in looped on Task.sleep, which throws at once after cancellation, so it spun until its 2 s ran out. The deadline lock also takes checked state now that everything it holds is Sendable.
Row titles show on screen and in the copied report, and the spec now
calls for title case there: Notifications Allowed, Sounds and Badges,
Registered for Push. Problem titles and details stay sentence case.
@bmc08gt
bmc08gt merged commit 8e943c8 into main Oct 29, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant