mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
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:
@@ -25,7 +25,6 @@ struct AppPreferences: Equatable {
|
||||
var username: String
|
||||
var receiveFolder: ReceiveFolder
|
||||
var themeMode: ThemeMode
|
||||
var notificationsEnabled: Bool
|
||||
var diagnosticsEnabled: Bool
|
||||
var diagnosticsInstallId: String
|
||||
}
|
||||
@@ -34,7 +33,6 @@ struct AppPreferencesDefaults {
|
||||
let username: String
|
||||
let receiveFolder: ReceiveFolder
|
||||
let themeMode: ThemeMode
|
||||
var notificationsEnabled: Bool = false
|
||||
var diagnosticsEnabled: Bool = false
|
||||
}
|
||||
|
||||
@@ -51,7 +49,6 @@ final class AppPreferencesRepository: ObservableObject {
|
||||
static let receiveFolderValue = "receive_folder_value"
|
||||
static let receiveFolderDisplayName = "receive_folder_display_name"
|
||||
static let themeMode = "theme_mode"
|
||||
static let notificationsEnabled = "notifications_enabled"
|
||||
static let diagnosticsEnabled = "diagnostics_enabled"
|
||||
static let diagnosticsInstallId = "diagnostics_install_id"
|
||||
}
|
||||
@@ -66,14 +63,12 @@ final class AppPreferencesRepository: ObservableObject {
|
||||
let username = (defaults.string(forKey: Key.username)).flatMap { $0.isEmpty ? nil : $0 } ?? fallback.username
|
||||
let folder = resolveReceiveFolder(defaults, fallback: fallback.receiveFolder)
|
||||
let themeMode = defaults.string(forKey: Key.themeMode).flatMap(ThemeMode.init(rawValue:)) ?? fallback.themeMode
|
||||
let notifications = defaults.object(forKey: Key.notificationsEnabled) as? Bool ?? fallback.notificationsEnabled
|
||||
let diagnostics = defaults.object(forKey: Key.diagnosticsEnabled) as? Bool ?? fallback.diagnosticsEnabled
|
||||
let installId = defaults.string(forKey: Key.diagnosticsInstallId) ?? ""
|
||||
return AppPreferences(
|
||||
username: username,
|
||||
receiveFolder: folder,
|
||||
themeMode: themeMode,
|
||||
notificationsEnabled: notifications,
|
||||
diagnosticsEnabled: diagnostics,
|
||||
diagnosticsInstallId: installId
|
||||
)
|
||||
@@ -113,11 +108,6 @@ final class AppPreferencesRepository: ObservableObject {
|
||||
reload()
|
||||
}
|
||||
|
||||
func setNotificationsEnabled(_ enabled: Bool) {
|
||||
defaults.set(enabled, forKey: Key.notificationsEnabled)
|
||||
reload()
|
||||
}
|
||||
|
||||
func setDiagnosticsEnabled(_ enabled: Bool) {
|
||||
defaults.set(enabled, forKey: Key.diagnosticsEnabled)
|
||||
reload()
|
||||
|
||||
@@ -16,12 +16,32 @@ struct LocalNotification {
|
||||
let body: String
|
||||
}
|
||||
|
||||
/// Presents notifications even while the app is active. Without a delegate the
|
||||
/// system drops the banner when the app is frontmost — very visible on macOS,
|
||||
/// where the app window is usually open when a transfer completes.
|
||||
private final class NotificationPresenter: NSObject, UNUserNotificationCenterDelegate {
|
||||
func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification
|
||||
) async -> UNNotificationPresentationOptions {
|
||||
[.banner, .sound, .list]
|
||||
}
|
||||
}
|
||||
|
||||
/// Local notification service backed by `UNUserNotificationCenter`.
|
||||
@MainActor
|
||||
final class LocalNotificationService: ObservableObject {
|
||||
@Published private(set) var permission: NotificationPermission = .notDetermined
|
||||
|
||||
private let center = UNUserNotificationCenter.current()
|
||||
private let presenter = NotificationPresenter()
|
||||
|
||||
init() {
|
||||
center.delegate = presenter
|
||||
// Seed the permission immediately so gating (approval/lifecycle
|
||||
// notifications) never races a not-yet-refreshed `.notDetermined`.
|
||||
Task { _ = await refreshPermission() }
|
||||
}
|
||||
|
||||
func refreshPermission() async -> NotificationPermission {
|
||||
let settings = await center.notificationSettings()
|
||||
@@ -75,11 +95,6 @@ final class LocalNotificationService: ObservableObject {
|
||||
center.removeDeliveredNotifications(withIdentifiers: [id])
|
||||
}
|
||||
|
||||
func cancelAll() {
|
||||
center.removeAllPendingNotificationRequests()
|
||||
center.removeAllDeliveredNotifications()
|
||||
}
|
||||
|
||||
private static func map(_ status: UNAuthorizationStatus) -> NotificationPermission {
|
||||
switch status {
|
||||
case .authorized, .provisional, .ephemeral: return .granted
|
||||
|
||||
Reference in New Issue
Block a user