feat(apple): local notifications for transfer lifecycle events

Adds background notifications for the "the thing you were waiting for is
done" moments, alongside the existing incoming-approval-request one:

  - a receive finished downloading            (receive -> done)
  - a receive failed / was interrupted         (receive -> failed)
  - a share you own failed                      (send -> failed)
  - a receiver finished downloading your share  (receiver status completed)

A new TransferNotificationCoordinator observes core state + signals and
publishes these; the decision of which moments notify is a pure function
(plannedTransferNotifications / plannedReceiverNotifications), unit-tested
independently. The first state snapshot only primes existing history as seen
so launch doesn't spam.

Notification permission is now the single source of truth. The in-app
notifications toggle and its decoupled UserDefaults preference are gone;
the Settings section shows an "Allow notifications" button that requests the
OS permission (or deep-links to Settings once decided), and notifications
gate purely on `permission == .granted`.

macOS delivery fixes:
  - add a UNUserNotificationCenterDelegate so banners present even while the
    app is active (the app window is usually open on macOS)
  - present-when-active on macOS, suppress-when-foregrounded on iOS
  - reserve the notification id before awaiting publish: the CombineLatest
    fired several times and re-added the same identifier, which macOS
    coalesces into a silent update with no banner
  - LocalNotificationService seeds its permission at init so gating can't
    race a not-yet-refreshed .notDetermined

Eight localized title/body strings added (apple-only); the shared
notifications_description copy is generalized from "receive requests" to
"transfer activity".
This commit is contained in:
2026-07-23 23:29:40 +02:00
parent 0e0d43bc4d
commit 3469b122c2
20 changed files with 482 additions and 98 deletions

View File

@@ -45,16 +45,22 @@ struct NotificationSettings: View {
var body: some View {
Section {
Toggle(isOn: Binding(
get: { model.state.notificationsEnabled },
set: { model.setNotificationsEnabled($0) }
)) {
Text(String(localized: L10n.Notifications.localTitle))
}
if model.state.notificationPermission == .denied {
Text(String(localized: L10n.Notifications.description)).foregroundStyle(.secondary)
switch model.state.notificationPermission {
case .notDetermined:
Button(String(localized: L10n.Notifications.localTitle), action: model.requestNotifications)
case .granted:
// Allowed the OS Settings app is where you disable or fine-tune.
Text(String(localized: L10n.Notifications.enabledMessage)).foregroundStyle(.secondary)
Button(String(localized: L10n.Button.openSettings), action: model.openNotificationSettings)
case .denied:
Text(String(localized: L10n.Notifications.permissionDenied)).foregroundStyle(.secondary)
Button(String(localized: L10n.Button.openSettings), action: model.openNotificationSettings)
case .unsupported:
Text(String(localized: L10n.Notifications.unsupported)).foregroundStyle(.secondary)
}
}
.onAppear { model.refreshNotificationPermission() }
}
}